// ============================================================================
// Collapsible Section Toggle
// ============================================================================
function toggleSection(headerElement) {
const contentElement = headerElement.nextElementSibling;
if (headerElement.classList.contains('collapsed')) {
// Expand
headerElement.classList.remove('collapsed');
contentElement.classList.remove('collapsed');
} else {
// Collapse
headerElement.classList.add('collapsed');
contentElement.classList.add('collapsed');
}
}
// ============================================================================
// Constants and Utilities
// ============================================================================
// Constants for log message types
const LOG_TYPE = {
SUCCESS: 'success',
WARNING: 'warning',
ERROR: 'error',
INFO: 'info',
DEBUG: 'debug'
};
// Helper function to convert provider name to camelCase for ID lookup
// e.g., "dw_leds" -> "DwLeds", "wled" -> "Wled", "none" -> "None"
function providerToCamelCase(provider) {
return provider.split('_').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join('');
}
// Constants for cache
const CACHE_KEYS = {
CONNECTION_STATUS: 'connection_status',
LAST_UPDATE: 'last_status_update'
};
const CACHE_DURATION = 5000; // 5 seconds cache duration
// Function to log messages
function logMessage(message, type = LOG_TYPE.DEBUG) {
console.log(`[${type}] ${message}`);
}
// Function to get cached connection status
function getCachedConnectionStatus() {
const cachedData = localStorage.getItem(CACHE_KEYS.CONNECTION_STATUS);
const lastUpdate = localStorage.getItem(CACHE_KEYS.LAST_UPDATE);
if (cachedData && lastUpdate) {
const now = Date.now();
const cacheAge = now - parseInt(lastUpdate);
if (cacheAge < CACHE_DURATION) {
return JSON.parse(cachedData);
}
}
return null;
}
// Function to set cached connection status
function setCachedConnectionStatus(data) {
localStorage.setItem(CACHE_KEYS.CONNECTION_STATUS, JSON.stringify(data));
localStorage.setItem(CACHE_KEYS.LAST_UPDATE, Date.now().toString());
}
// Function to update serial connection status
async function updateSerialStatus(forceUpdate = false) {
try {
// Check cache first unless force update is requested
if (!forceUpdate) {
const cachedData = getCachedConnectionStatus();
if (cachedData) {
updateConnectionUI(cachedData);
return;
}
}
const response = await fetch('/serial_status');
if (response.ok) {
const data = await response.json();
setCachedConnectionStatus(data);
updateConnectionUI(data);
}
} catch (error) {
logMessage(`Error checking serial status: ${error.message}`, LOG_TYPE.ERROR);
}
}
// Function to update UI based on connection status
function updateConnectionUI(data) {
const statusElement = document.getElementById('serialStatus');
const iconElement = document.querySelector('.material-icons.text-3xl');
const disconnectButton = document.getElementById('disconnectButton');
const portSelectionDiv = document.getElementById('portSelectionDiv');
if (statusElement && iconElement) {
if (data.connected) {
statusElement.textContent = `Connected to ${data.port || 'unknown port'}`;
statusElement.className = 'text-green-500 text-sm font-medium leading-normal';
iconElement.textContent = 'usb';
if (disconnectButton) {
disconnectButton.hidden = false;
}
if (portSelectionDiv) {
portSelectionDiv.hidden = true;
}
} else {
statusElement.textContent = 'Disconnected';
statusElement.className = 'text-red-500 text-sm font-medium leading-normal';
iconElement.textContent = 'usb_off';
if (disconnectButton) {
disconnectButton.hidden = true;
}
if (portSelectionDiv) {
portSelectionDiv.hidden = false;
}
}
}
}
// Function to update available serial ports
async function updateSerialPorts() {
try {
const response = await fetch('/list_serial_ports');
if (response.ok) {
const ports = await response.json();
const portsElement = document.getElementById('availablePorts');
const portSelect = document.getElementById('portSelect');
const preferredPortSelect = document.getElementById('preferredPortSelect');
if (portsElement) {
portsElement.textContent = ports.length > 0 ? ports.join(', ') : 'No ports available';
}
if (portSelect) {
// Clear existing options except the first one
while (portSelect.options.length > 1) {
portSelect.remove(1);
}
// Add new options
ports.forEach(port => {
const option = document.createElement('option');
option.value = port;
option.textContent = port;
portSelect.appendChild(option);
});
// If there's exactly one port available, select and connect to it
if (ports.length === 1) {
portSelect.value = ports[0];
// Trigger connect button click
const connectButton = document.getElementById('connectButton');
if (connectButton) {
connectButton.click();
}
}
}
// Also update the preferred port select dropdown
if (preferredPortSelect) {
// Store current selection
const currentPreferred = preferredPortSelect.value;
// Clear existing options except the first one (no preference)
while (preferredPortSelect.options.length > 1) {
preferredPortSelect.remove(1);
}
// Add all available ports
ports.forEach(port => {
const option = document.createElement('option');
option.value = port;
option.textContent = port;
preferredPortSelect.appendChild(option);
});
// Restore selection if it's still available
if (currentPreferred && ports.includes(currentPreferred)) {
preferredPortSelect.value = currentPreferred;
}
}
}
} catch (error) {
logMessage(`Error fetching serial ports: ${error.message}`, LOG_TYPE.ERROR);
}
}
// Function to load and display preferred port setting
async function loadPreferredPort() {
try {
const response = await fetch('/api/preferred-port');
if (response.ok) {
const data = await response.json();
const preferredPortSelect = document.getElementById('preferredPortSelect');
const currentPreferredPort = document.getElementById('currentPreferredPort');
const preferredPortDisplay = document.getElementById('preferredPortDisplay');
if (preferredPortSelect && data.preferred_port) {
// Check if the preferred port is in the options
const optionExists = Array.from(preferredPortSelect.options).some(
opt => opt.value === data.preferred_port
);
if (optionExists) {
preferredPortSelect.value = data.preferred_port;
} else {
// Add the preferred port as an option (it might not be currently available)
const option = document.createElement('option');
option.value = data.preferred_port;
option.textContent = `${data.preferred_port} (not currently available)`;
preferredPortSelect.appendChild(option);
preferredPortSelect.value = data.preferred_port;
}
}
// Show current preferred port indicator
if (currentPreferredPort && preferredPortDisplay && data.preferred_port) {
preferredPortDisplay.textContent = `Currently set to: ${data.preferred_port}`;
currentPreferredPort.classList.remove('hidden');
} else if (currentPreferredPort) {
currentPreferredPort.classList.add('hidden');
}
}
} catch (error) {
logMessage(`Error loading preferred port: ${error.message}`, LOG_TYPE.ERROR);
}
}
// Function to save preferred port setting
async function savePreferredPort() {
const preferredPortSelect = document.getElementById('preferredPortSelect');
if (!preferredPortSelect) return;
const preferredPort = preferredPortSelect.value || null;
try {
const response = await fetch('/api/settings', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ connection: { preferred_port: preferredPort } })
});
if (response.ok) {
await response.json();
const currentPreferredPort = document.getElementById('currentPreferredPort');
const preferredPortDisplay = document.getElementById('preferredPortDisplay');
if (preferredPort) {
showStatusMessage(`Preferred port set to: ${preferredPort}`, 'success');
if (currentPreferredPort && preferredPortDisplay) {
preferredPortDisplay.textContent = `Currently set to: ${preferredPort}`;
currentPreferredPort.classList.remove('hidden');
}
} else {
showStatusMessage('Preferred port cleared - will auto-detect on startup', 'success');
if (currentPreferredPort) {
currentPreferredPort.classList.add('hidden');
}
}
} else {
throw new Error('Failed to save preferred port');
}
} catch (error) {
showStatusMessage(`Failed to save preferred port: ${error.message}`, 'error');
}
}
function setWledButtonState(isSet) {
const saveWledConfig = document.getElementById('saveWledConfig');
if (!saveWledConfig) return;
if (isSet) {
saveWledConfig.className = 'flex items-center justify-center gap-2 min-w-[100px] max-w-[480px] cursor-pointer rounded-lg h-10 px-4 bg-red-600 hover:bg-red-700 text-white text-sm font-medium leading-normal tracking-[0.015em] transition-colors';
saveWledConfig.innerHTML = 'closeClear WLED IP';
} else {
saveWledConfig.className = 'flex items-center justify-center gap-2 min-w-[100px] max-w-[480px] cursor-pointer rounded-lg h-10 px-4 bg-sky-600 hover:bg-sky-700 text-white text-sm font-medium leading-normal tracking-[0.015em] transition-colors';
saveWledConfig.innerHTML = 'saveSave Configuration';
}
}
// Handle LED provider selection and show/hide appropriate config sections
function updateLedProviderUI() {
const provider = document.querySelector('input[name="ledProvider"]:checked')?.value || 'none';
const wledConfig = document.getElementById('wledConfig');
const dwLedsConfig = document.getElementById('dwLedsConfig');
if (wledConfig && dwLedsConfig) {
if (provider === 'wled') {
wledConfig.classList.remove('hidden');
dwLedsConfig.classList.add('hidden');
} else if (provider === 'dw_leds') {
wledConfig.classList.add('hidden');
dwLedsConfig.classList.remove('hidden');
} else {
wledConfig.classList.add('hidden');
dwLedsConfig.classList.add('hidden');
}
}
}
// Load LED configuration from server
async function loadLedConfig() {
try {
const response = await fetch('/get_led_config');
if (response.ok) {
const data = await response.json();
// Set provider radio button
const providerRadio = document.getElementById(`ledProvider${providerToCamelCase(data.provider)}`);
if (providerRadio) {
providerRadio.checked = true;
} else {
document.getElementById('ledProviderNone').checked = true;
}
// Set WLED IP if configured
if (data.wled_ip) {
const wledIpInput = document.getElementById('wledIpInput');
if (wledIpInput) {
wledIpInput.value = data.wled_ip;
}
}
// Set DW LED configuration if configured
if (data.dw_led_num_leds) {
const numLedsInput = document.getElementById('dwLedNumLeds');
if (numLedsInput) {
numLedsInput.value = data.dw_led_num_leds;
}
}
if (data.dw_led_gpio_pin) {
const gpioPinInput = document.getElementById('dwLedGpioPin');
if (gpioPinInput) {
gpioPinInput.value = data.dw_led_gpio_pin;
}
}
if (data.dw_led_pixel_order) {
const pixelOrderInput = document.getElementById('dwLedPixelOrder');
if (pixelOrderInput) {
pixelOrderInput.value = data.dw_led_pixel_order;
}
}
// Update UI to show correct config section
updateLedProviderUI();
}
} catch (error) {
logMessage(`Error loading LED config: ${error.message}`, LOG_TYPE.ERROR);
}
}
// Initialize settings page
document.addEventListener('DOMContentLoaded', async () => {
// Initialize UI with default disconnected state
updateConnectionUI({ connected: false });
// Handle scroll to section if hash is present in URL
if (window.location.hash) {
setTimeout(() => {
const targetSection = document.querySelector(window.location.hash);
if (targetSection) {
targetSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
// Add a subtle highlight animation
targetSection.style.transition = 'background-color 0.5s ease';
const originalBg = targetSection.style.backgroundColor;
targetSection.style.backgroundColor = 'rgba(14, 165, 233, 0.1)';
setTimeout(() => {
targetSection.style.backgroundColor = originalBg;
}, 2000);
}
}, 300); // Delay to ensure page is fully loaded
}
// Load all data asynchronously using unified settings endpoint
Promise.all([
// Unified settings endpoint (replaces multiple individual fetches)
fetch('/api/settings').then(response => response.json()).catch(() => ({})),
// Non-settings operational endpoints (kept separate)
fetch('/serial_status').then(response => response.json()).catch(() => ({ connected: false })),
fetch('/api/version').then(response => response.json()).catch(() => ({ current: '1.0.0', latest: '1.0.0', update_available: false })),
fetch('/list_serial_ports').then(response => response.json()).catch(() => []),
getCachedPatternFiles().catch(() => [])
]).then(([settings, statusData, updateData, ports, patterns]) => {
// Map unified settings to legacy variable names for backward compatibility with existing UI code
const ledConfigData = {
provider: settings.led?.provider || 'none',
wled_ip: settings.led?.wled_ip || null,
dw_led_num_leds: settings.led?.dw_led?.num_leds,
dw_led_gpio_pin: settings.led?.dw_led?.gpio_pin,
dw_led_pixel_order: settings.led?.dw_led?.pixel_order
};
const clearPatterns = {
custom_clear_from_in: settings.patterns?.custom_clear_from_in,
custom_clear_from_out: settings.patterns?.custom_clear_from_out
};
const clearSpeedData = {
clear_pattern_speed: settings.patterns?.clear_pattern_speed,
effective_speed: settings.patterns?.clear_pattern_speed // Will be handled by UI
};
const appNameData = { app_name: settings.app?.name || 'Dune Weaver' };
const scheduledPauseData = settings.scheduled_pause || { enabled: false, time_slots: [] };
const preferredPortData = { preferred_port: settings.connection?.preferred_port };
// Store full settings for other initialization functions
window.unifiedSettings = settings;
// Update connection status
setCachedConnectionStatus(statusData);
updateConnectionUI(statusData);
// Update LED configuration
const providerRadio = document.getElementById(`ledProvider${providerToCamelCase(ledConfigData.provider)}`);
if (providerRadio) {
providerRadio.checked = true;
} else {
document.getElementById('ledProviderNone').checked = true;
}
if (ledConfigData.wled_ip) {
const wledIpInput = document.getElementById('wledIpInput');
if (wledIpInput) wledIpInput.value = ledConfigData.wled_ip;
}
// Load DW LED settings
if (ledConfigData.dw_led_num_leds) {
const numLedsInput = document.getElementById('dwLedNumLeds');
if (numLedsInput) numLedsInput.value = ledConfigData.dw_led_num_leds;
}
if (ledConfigData.dw_led_gpio_pin) {
const gpioPinInput = document.getElementById('dwLedGpioPin');
if (gpioPinInput) gpioPinInput.value = ledConfigData.dw_led_gpio_pin;
}
if (ledConfigData.dw_led_pixel_order) {
const pixelOrderInput = document.getElementById('dwLedPixelOrder');
if (pixelOrderInput) pixelOrderInput.value = ledConfigData.dw_led_pixel_order;
}
updateLedProviderUI()
// Update version display
const currentVersionText = document.getElementById('currentVersionText');
const latestVersionText = document.getElementById('latestVersionText');
const updateButton = document.getElementById('updateSoftware');
const updateIcon = document.getElementById('updateIcon');
const updateText = document.getElementById('updateText');
if (currentVersionText) {
currentVersionText.textContent = updateData.current;
}
if (latestVersionText) {
if (updateData.error) {
latestVersionText.textContent = 'Error checking updates';
latestVersionText.className = 'text-red-500 text-sm font-normal leading-normal';
} else {
latestVersionText.textContent = updateData.latest;
latestVersionText.className = 'text-slate-500 text-sm font-normal leading-normal';
}
}
// Update button state
if (updateButton && updateIcon && updateText) {
if (updateData.update_available) {
updateButton.disabled = false;
updateButton.className = 'flex items-center justify-center gap-1.5 min-w-[84px] cursor-pointer rounded-lg h-9 px-3 bg-emerald-500 hover:bg-emerald-600 text-white text-xs font-medium leading-normal tracking-[0.015em] transition-colors';
updateIcon.textContent = 'download';
updateText.textContent = 'Update';
} else {
updateButton.disabled = true;
updateButton.className = 'flex items-center justify-center gap-1.5 min-w-[84px] cursor-pointer rounded-lg h-9 px-3 bg-gray-400 text-white text-xs font-medium leading-normal tracking-[0.015em] transition-colors disabled:opacity-50 disabled:cursor-not-allowed';
updateIcon.textContent = 'check';
updateText.textContent = 'Up to date';
}
}
// Update port selection
const portSelect = document.getElementById('portSelect');
if (portSelect) {
// Clear existing options except the first one
while (portSelect.options.length > 1) {
portSelect.remove(1);
}
// Add new options
ports.forEach(port => {
const option = document.createElement('option');
option.value = port;
option.textContent = port;
portSelect.appendChild(option);
});
// If there's exactly one port available, select it
if (ports.length === 1) {
portSelect.value = ports[0];
}
}
// Update preferred port selection
const preferredPortSelect = document.getElementById('preferredPortSelect');
const currentPreferredPort = document.getElementById('currentPreferredPort');
const preferredPortDisplay = document.getElementById('preferredPortDisplay');
if (preferredPortSelect) {
// Clear existing options except the first one (no preference)
while (preferredPortSelect.options.length > 1) {
preferredPortSelect.remove(1);
}
// Add all available ports
ports.forEach(port => {
const option = document.createElement('option');
option.value = port;
option.textContent = port;
preferredPortSelect.appendChild(option);
});
// Set the current preferred port value
if (preferredPortData && preferredPortData.preferred_port) {
// Check if the preferred port is in the available ports
const isAvailable = ports.includes(preferredPortData.preferred_port);
if (isAvailable) {
preferredPortSelect.value = preferredPortData.preferred_port;
} else {
// Add the preferred port as an option (it might not be currently available)
const option = document.createElement('option');
option.value = preferredPortData.preferred_port;
option.textContent = `${preferredPortData.preferred_port} (not currently available)`;
preferredPortSelect.appendChild(option);
preferredPortSelect.value = preferredPortData.preferred_port;
}
// Show the current preferred port indicator
if (currentPreferredPort && preferredPortDisplay) {
preferredPortDisplay.textContent = `Currently set to: ${preferredPortData.preferred_port}`;
currentPreferredPort.classList.remove('hidden');
}
}
}
// Initialize autocomplete for clear patterns
const clearFromInInput = document.getElementById('customClearFromInInput');
const clearFromOutInput = document.getElementById('customClearFromOutInput');
if (clearFromInInput && clearFromOutInput && patterns && Array.isArray(patterns)) {
// Store patterns globally for autocomplete
window.availablePatterns = patterns;
// Set current values if they exist
if (clearPatterns && clearPatterns.custom_clear_from_in) {
clearFromInInput.value = clearPatterns.custom_clear_from_in;
}
if (clearPatterns && clearPatterns.custom_clear_from_out) {
clearFromOutInput.value = clearPatterns.custom_clear_from_out;
}
// Initialize autocomplete for both inputs
initializeAutocomplete('customClearFromInInput', 'clearFromInSuggestions', 'clearFromInClear', patterns);
initializeAutocomplete('customClearFromOutInput', 'clearFromOutSuggestions', 'clearFromOutClear', patterns);
console.log('Autocomplete initialized with', patterns.length, 'patterns');
}
// Set clear pattern speed
const clearPatternSpeedInput = document.getElementById('clearPatternSpeedInput');
const effectiveClearSpeed = document.getElementById('effectiveClearSpeed');
if (clearPatternSpeedInput && clearSpeedData) {
// Only set value if clear_pattern_speed is not null
if (clearSpeedData.clear_pattern_speed !== null && clearSpeedData.clear_pattern_speed !== undefined) {
clearPatternSpeedInput.value = clearSpeedData.clear_pattern_speed;
if (effectiveClearSpeed) {
effectiveClearSpeed.textContent = `Current: ${clearSpeedData.clear_pattern_speed} steps/min`;
}
} else {
// Leave empty to show placeholder for default
clearPatternSpeedInput.value = '';
if (effectiveClearSpeed && clearSpeedData.effective_speed) {
effectiveClearSpeed.textContent = `Using default pattern speed: ${clearSpeedData.effective_speed} steps/min`;
}
}
}
// Update app name
const appNameInput = document.getElementById('appNameInput');
if (appNameInput && appNameData.app_name) {
appNameInput.value = appNameData.app_name;
}
// Store Still Sands data for later initialization
window.initialStillSandsData = scheduledPauseData;
}).catch(error => {
logMessage(`Error initializing settings page: ${error.message}`, LOG_TYPE.ERROR);
});
// Set up event listeners
setupEventListeners();
});
// Setup event listeners
function setupEventListeners() {
// Save App Name
const saveAppNameButton = document.getElementById('saveAppName');
const appNameInput = document.getElementById('appNameInput');
if (saveAppNameButton && appNameInput) {
saveAppNameButton.addEventListener('click', async () => {
const appName = appNameInput.value.trim() || 'Dune Weaver';
try {
const response = await fetch('/api/settings', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ app: { name: appName } })
});
if (response.ok) {
await response.json();
showStatusMessage('Application name updated successfully. Refresh the page to see changes.', 'success');
// Update the page title and header immediately
document.title = `Settings - ${appName}`;
const headerTitle = document.querySelector('h1.text-gray-800');
if (headerTitle) {
// Update just the text content, preserving the connection status dot
const textNode = headerTitle.childNodes[0];
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
textNode.textContent = data.app_name;
}
}
} else {
throw new Error('Failed to save application name');
}
} catch (error) {
showStatusMessage(`Failed to save application name: ${error.message}`, 'error');
}
});
// Handle Enter key in app name input
appNameInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
saveAppNameButton.click();
}
});
}
// LED provider selection change handlers
const ledProviderRadios = document.querySelectorAll('input[name="ledProvider"]');
ledProviderRadios.forEach(radio => {
radio.addEventListener('change', updateLedProviderUI);
});
// Save LED configuration
const saveLedConfig = document.getElementById('saveLedConfig');
if (saveLedConfig) {
saveLedConfig.addEventListener('click', async () => {
const provider = document.querySelector('input[name="ledProvider"]:checked')?.value || 'none';
let requestBody = { provider };
if (provider === 'wled') {
const wledIp = document.getElementById('wledIpInput')?.value;
if (!wledIp) {
showStatusMessage('Please enter a WLED IP address', 'error');
return;
}
requestBody.ip_address = wledIp;
} else if (provider === 'dw_leds') {
const numLeds = parseInt(document.getElementById('dwLedNumLeds')?.value) || 60;
const gpioPin = parseInt(document.getElementById('dwLedGpioPin')?.value) || 12;
const pixelOrder = document.getElementById('dwLedPixelOrder')?.value || 'GRB';
requestBody.num_leds = numLeds;
requestBody.gpio_pin = gpioPin;
requestBody.pixel_order = pixelOrder;
}
try {
const response = await fetch('/set_led_config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
});
if (response.ok) {
const data = await response.json();
if (provider === 'wled' && data.wled_ip) {
localStorage.setItem('wled_ip', data.wled_ip);
showStatusMessage('WLED configured successfully', 'success');
} else if (provider === 'dw_leds') {
// Check if there's a warning (hardware not available but settings saved)
if (data.warning) {
showStatusMessage(
`Settings saved for testing. Hardware issue: ${data.warning}`,
'warning'
);
} else {
showStatusMessage(
`DW LEDs configured: ${data.dw_led_num_leds} LEDs on GPIO${data.dw_led_gpio_pin}`,
'success'
);
}
} else if (provider === 'none') {
localStorage.removeItem('wled_ip');
showStatusMessage('LED controller disabled', 'success');
}
} else {
// Extract error detail from response
const errorData = await response.json().catch(() => ({}));
const errorMessage = errorData.detail || 'Failed to save LED configuration';
showStatusMessage(errorMessage, 'error');
}
} catch (error) {
showStatusMessage(`Failed to save LED configuration: ${error.message}`, 'error');
}
});
}
// Update software
const updateSoftware = document.getElementById('updateSoftware');
if (updateSoftware) {
updateSoftware.addEventListener('click', async () => {
if (updateSoftware.disabled) {
return;
}
try {
const response = await fetch('/api/update', {
method: 'POST'
});
const data = await response.json();
if (data.success) {
showStatusMessage('Software update started successfully', 'success');
} else if (data.manual_update_url) {
// Show modal with manual update instructions, but use wiki link
const wikiData = {
...data,
manual_update_url: 'https://github.com/tuanchris/dune-weaver/wiki/Updating-software'
};
showUpdateInstructionsModal(wikiData);
} else {
showStatusMessage(data.message || 'No updates available', 'info');
}
} catch (error) {
logMessage(`Error updating software: ${error.message}`, LOG_TYPE.ERROR);
showStatusMessage('Failed to check for updates', 'error');
}
});
}
// Connect button
const connectButton = document.getElementById('connectButton');
if (connectButton) {
connectButton.addEventListener('click', async () => {
const portSelect = document.getElementById('portSelect');
if (!portSelect || !portSelect.value) {
logMessage('Please select a port first', LOG_TYPE.WARNING);
return;
}
try {
const response = await fetch('/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ port: portSelect.value })
});
if (response.ok) {
logMessage('Connected successfully', LOG_TYPE.SUCCESS);
await updateSerialStatus(true); // Force update after connecting
} else {
throw new Error('Failed to connect');
}
} catch (error) {
logMessage(`Error connecting to device: ${error.message}`, LOG_TYPE.ERROR);
}
});
}
// Disconnect button
const disconnectButton = document.getElementById('disconnectButton');
if (disconnectButton) {
disconnectButton.addEventListener('click', async () => {
try {
const response = await fetch('/disconnect', {
method: 'POST'
});
if (response.ok) {
logMessage('Device disconnected successfully', LOG_TYPE.SUCCESS);
await updateSerialStatus(true); // Force update after disconnecting
} else {
throw new Error('Failed to disconnect device');
}
} catch (error) {
logMessage(`Error disconnecting device: ${error.message}`, LOG_TYPE.ERROR);
}
});
}
// Save preferred port button
const savePreferredPortButton = document.getElementById('savePreferredPort');
if (savePreferredPortButton) {
savePreferredPortButton.addEventListener('click', savePreferredPort);
}
// Save custom clear patterns button
const saveClearPatterns = document.getElementById('saveClearPatterns');
if (saveClearPatterns) {
saveClearPatterns.addEventListener('click', async () => {
const clearFromInInput = document.getElementById('customClearFromInInput');
const clearFromOutInput = document.getElementById('customClearFromOutInput');
if (!clearFromInInput || !clearFromOutInput) {
return;
}
// Validate that the entered patterns exist (if not empty)
const inValue = clearFromInInput.value.trim();
const outValue = clearFromOutInput.value.trim();
if (inValue && window.availablePatterns && !window.availablePatterns.includes(inValue)) {
showStatusMessage(`Pattern not found: ${inValue}`, 'error');
return;
}
if (outValue && window.availablePatterns && !window.availablePatterns.includes(outValue)) {
showStatusMessage(`Pattern not found: ${outValue}`, 'error');
return;
}
try {
const response = await fetch('/api/settings', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
patterns: {
custom_clear_from_in: inValue || null,
custom_clear_from_out: outValue || null
}
})
});
if (response.ok) {
showStatusMessage('Clear patterns saved successfully', 'success');
} else {
const error = await response.json();
throw new Error(error.detail || 'Failed to save clear patterns');
}
} catch (error) {
showStatusMessage(`Failed to save clear patterns: ${error.message}`, 'error');
}
});
}
// Logo upload functionality
const logoFileInput = document.getElementById('logoFileInput');
const resetLogoBtn = document.getElementById('resetLogoBtn');
const logoPreview = document.getElementById('logoPreview');
const logoUploadStatus = document.getElementById('logoUploadStatus');
if (logoFileInput) {
logoFileInput.addEventListener('change', async (event) => {
const file = event.target.files[0];
if (!file) return;
// Show uploading status
if (logoUploadStatus) {
logoUploadStatus.textContent = 'Uploading...';
logoUploadStatus.className = 'text-xs text-slate-500';
}
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/api/upload-logo', {
method: 'POST',
body: formData
});
if (response.ok) {
const data = await response.json();
// Update preview image with cache-busting query param
if (logoPreview) {
logoPreview.src = data.url + '?t=' + Date.now();
}
// Show reset button
if (resetLogoBtn) {
resetLogoBtn.classList.remove('hidden');
}
// Show success status
if (logoUploadStatus) {
logoUploadStatus.textContent = 'Logo uploaded successfully!';
logoUploadStatus.className = 'text-xs text-green-600';
setTimeout(() => {
logoUploadStatus.textContent = '';
}, 3000);
}
showStatusMessage('Logo uploaded successfully. Refresh the page to see all changes.', 'success');
} else {
const error = await response.json();
throw new Error(error.detail || 'Failed to upload logo');
}
} catch (error) {
if (logoUploadStatus) {
logoUploadStatus.textContent = `Error: ${error.message}`;
logoUploadStatus.className = 'text-xs text-red-600';
}
showStatusMessage(`Failed to upload logo: ${error.message}`, 'error');
}
// Reset file input so the same file can be re-selected
logoFileInput.value = '';
});
}
if (resetLogoBtn) {
resetLogoBtn.addEventListener('click', async () => {
try {
const response = await fetch('/api/custom-logo', {
method: 'DELETE'
});
if (response.ok) {
// Reset preview to default logo
if (logoPreview) {
logoPreview.src = '/static/apple-touch-icon.png?t=' + Date.now();
}
// Hide reset button
resetLogoBtn.classList.add('hidden');
showStatusMessage('Logo reset to default. Refresh the page to see all changes.', 'success');
} else {
const error = await response.json();
throw new Error(error.detail || 'Failed to reset logo');
}
} catch (error) {
showStatusMessage(`Failed to reset logo: ${error.message}`, 'error');
}
});
}
// Save clear pattern speed button
const saveClearSpeed = document.getElementById('saveClearSpeed');
if (saveClearSpeed) {
saveClearSpeed.addEventListener('click', async () => {
const clearPatternSpeedInput = document.getElementById('clearPatternSpeedInput');
if (!clearPatternSpeedInput) {
return;
}
let speed;
if (clearPatternSpeedInput.value === '' || clearPatternSpeedInput.value === null) {
// Empty value means use default (None)
speed = null;
} else {
speed = parseInt(clearPatternSpeedInput.value);
// Validate speed only if it's not null
if (isNaN(speed) || speed < 50 || speed > 2000) {
showStatusMessage('Clear pattern speed must be between 50 and 2000, or leave empty for default', 'error');
return;
}
}
try {
const response = await fetch('/api/settings', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ patterns: { clear_pattern_speed: speed } })
});
if (response.ok) {
await response.json();
if (speed === null) {
showStatusMessage('Clear pattern speed set to default', 'success');
} else {
showStatusMessage(`Clear pattern speed set to ${speed} steps/min`, 'success');
}
// Update the effective speed display
const effectiveClearSpeed = document.getElementById('effectiveClearSpeed');
if (effectiveClearSpeed) {
if (speed === null) {
effectiveClearSpeed.textContent = `Using default pattern speed: ${data.effective_speed} steps/min`;
} else {
effectiveClearSpeed.textContent = `Current: ${speed} steps/min`;
}
}
} else {
const error = await response.json();
throw new Error(error.detail || 'Failed to save clear pattern speed');
}
} catch (error) {
showStatusMessage(`Failed to save clear pattern speed: ${error.message}`, 'error');
}
});
}
}
// Button click handlers
document.addEventListener('DOMContentLoaded', function() {
// Home button
const homeButton = document.getElementById('homeButton');
if (homeButton) {
homeButton.addEventListener('click', async () => {
try {
const response = await fetch('/send_home', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const data = await response.json();
if (data.success) {
updateStatus('Moving to home position...');
}
} catch (error) {
console.error('Error sending home command:', error);
updateStatus('Error: Failed to move to home position');
}
});
}
// Stop button
const stopButton = document.getElementById('stopButton');
if (stopButton) {
stopButton.addEventListener('click', async () => {
try {
const response = await fetch('/stop_execution', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const data = await response.json();
if (data.success) {
updateStatus('Execution stopped');
}
} catch (error) {
console.error('Error stopping execution:', error);
updateStatus('Error: Failed to stop execution');
}
});
}
// Move to Center button
const centerButton = document.getElementById('centerButton');
if (centerButton) {
centerButton.addEventListener('click', async () => {
try {
const response = await fetch('/move_to_center', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const data = await response.json();
if (data.success) {
updateStatus('Moving to center position...');
}
} catch (error) {
console.error('Error moving to center:', error);
updateStatus('Error: Failed to move to center');
}
});
}
// Move to Perimeter button
const perimeterButton = document.getElementById('perimeterButton');
if (perimeterButton) {
perimeterButton.addEventListener('click', async () => {
try {
const response = await fetch('/move_to_perimeter', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const data = await response.json();
if (data.success) {
updateStatus('Moving to perimeter position...');
}
} catch (error) {
console.error('Error moving to perimeter:', error);
updateStatus('Error: Failed to move to perimeter');
}
});
}
});
// Function to update status
function updateStatus(message) {
const statusElement = document.querySelector('.text-slate-800.text-base.font-medium.leading-normal');
if (statusElement) {
statusElement.textContent = message;
// Reset status after 3 seconds if it's a temporary message
if (message.includes('Moving') || message.includes('Execution')) {
setTimeout(() => {
statusElement.textContent = 'Status';
}, 3000);
}
}
}
// Function to show status messages (using existing base.js showStatusMessage if available)
function showStatusMessage(message, type) {
if (typeof window.showStatusMessage === 'function') {
window.showStatusMessage(message, type);
} else {
// Fallback to console logging
console.log(`[${type}] ${message}`);
}
}
// Function to show update instructions modal
function showUpdateInstructionsModal(data) {
// Create modal HTML
const modal = document.createElement('div');
modal.id = 'updateInstructionsModal';
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4';
modal.innerHTML = `
Manual Update Required
${data.message}
`;
document.body.appendChild(modal);
// Add event listeners
const openGitHubButton = modal.querySelector('#openGitHubRelease');
const closeButton = modal.querySelector('#closeUpdateModal');
openGitHubButton.addEventListener('click', () => {
window.open(data.manual_update_url, '_blank');
document.body.removeChild(modal);
});
closeButton.addEventListener('click', () => {
document.body.removeChild(modal);
});
// Close on outside click
modal.addEventListener('click', (e) => {
if (e.target === modal) {
document.body.removeChild(modal);
}
});
}
// Autocomplete functionality
function initializeAutocomplete(inputId, suggestionsId, clearButtonId, patterns) {
const input = document.getElementById(inputId);
const suggestionsDiv = document.getElementById(suggestionsId);
const clearButton = document.getElementById(clearButtonId);
let selectedIndex = -1;
if (!input || !suggestionsDiv) return;
// Function to update clear button visibility
function updateClearButton() {
if (clearButton) {
if (input.value.trim()) {
clearButton.classList.remove('hidden');
} else {
clearButton.classList.add('hidden');
}
}
}
// Format pattern name for display
function formatPatternName(pattern) {
return pattern.replace('.thr', '').replace(/_/g, ' ');
}
// Filter patterns based on input
function filterPatterns(searchTerm) {
if (!searchTerm) return patterns.slice(0, 20); // Show first 20 when empty
const term = searchTerm.toLowerCase();
return patterns.filter(pattern => {
const name = pattern.toLowerCase();
return name.includes(term);
}).sort((a, b) => {
// Prioritize patterns that start with the search term
const aStarts = a.toLowerCase().startsWith(term);
const bStarts = b.toLowerCase().startsWith(term);
if (aStarts && !bStarts) return -1;
if (!aStarts && bStarts) return 1;
return a.localeCompare(b);
}).slice(0, 20); // Limit to 20 results
}
// Highlight matching text
function highlightMatch(text, searchTerm) {
if (!searchTerm) return text;
const regex = new RegExp(`(${searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
return text.replace(regex, '$1');
}
// Show suggestions
function showSuggestions(searchTerm) {
const filtered = filterPatterns(searchTerm);
if (filtered.length === 0 && searchTerm) {
suggestionsDiv.innerHTML = 'No patterns found
';
suggestionsDiv.classList.remove('hidden');
return;
}
suggestionsDiv.innerHTML = filtered.map((pattern, index) => {
const displayName = formatPatternName(pattern);
const highlighted = highlightMatch(displayName, searchTerm);
return `${highlighted}
`;
}).join('');
suggestionsDiv.classList.remove('hidden');
selectedIndex = -1;
}
// Hide suggestions
function hideSuggestions() {
setTimeout(() => {
suggestionsDiv.classList.add('hidden');
selectedIndex = -1;
}, 200);
}
// Select suggestion
function selectSuggestion(value) {
input.value = value;
hideSuggestions();
updateClearButton();
}
// Handle keyboard navigation
function handleKeyboard(e) {
const items = suggestionsDiv.querySelectorAll('.suggestion-item[data-value]');
if (e.key === 'ArrowDown') {
e.preventDefault();
selectedIndex = Math.min(selectedIndex + 1, items.length - 1);
updateSelection(items);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
selectedIndex = Math.max(selectedIndex - 1, -1);
updateSelection(items);
} else if (e.key === 'Enter') {
e.preventDefault();
if (selectedIndex >= 0 && items[selectedIndex]) {
selectSuggestion(items[selectedIndex].dataset.value);
} else if (items.length === 1) {
selectSuggestion(items[0].dataset.value);
}
} else if (e.key === 'Escape') {
hideSuggestions();
}
}
// Update visual selection
function updateSelection(items) {
items.forEach((item, index) => {
if (index === selectedIndex) {
item.classList.add('selected');
item.scrollIntoView({ block: 'nearest' });
} else {
item.classList.remove('selected');
}
});
}
// Event listeners
input.addEventListener('input', (e) => {
const value = e.target.value.trim();
updateClearButton();
if (value.length > 0 || e.target === document.activeElement) {
showSuggestions(value);
} else {
hideSuggestions();
}
});
input.addEventListener('focus', () => {
const value = input.value.trim();
showSuggestions(value);
});
input.addEventListener('blur', hideSuggestions);
input.addEventListener('keydown', handleKeyboard);
// Click handler for suggestions
suggestionsDiv.addEventListener('click', (e) => {
const item = e.target.closest('.suggestion-item[data-value]');
if (item) {
selectSuggestion(item.dataset.value);
}
});
// Mouse hover handler
suggestionsDiv.addEventListener('mouseover', (e) => {
const item = e.target.closest('.suggestion-item[data-value]');
if (item) {
selectedIndex = parseInt(item.dataset.index);
const items = suggestionsDiv.querySelectorAll('.suggestion-item[data-value]');
updateSelection(items);
}
});
// Clear button handler
if (clearButton) {
clearButton.addEventListener('click', () => {
input.value = '';
updateClearButton();
hideSuggestions();
input.focus();
});
}
// Initialize clear button visibility
updateClearButton();
}
// auto_play Mode Functions
async function initializeauto_playMode() {
const auto_playToggle = document.getElementById('auto_playModeToggle');
const auto_playSettings = document.getElementById('auto_playSettings');
const auto_playPlaylistSelect = document.getElementById('auto_playPlaylistSelect');
const auto_playRunModeSelect = document.getElementById('auto_playRunModeSelect');
const auto_playPauseTimeInput = document.getElementById('auto_playPauseTimeInput');
const auto_playClearPatternSelect = document.getElementById('auto_playClearPatternSelect');
const auto_playShuffleToggle = document.getElementById('auto_playShuffleToggle');
// Load current auto_play settings
try {
const response = await fetch('/api/auto_play-mode');
const data = await response.json();
auto_playToggle.checked = data.enabled;
if (data.enabled) {
auto_playSettings.style.display = 'block';
}
// Set current values
auto_playRunModeSelect.value = data.run_mode || 'loop';
auto_playPauseTimeInput.value = data.pause_time || 5.0;
auto_playClearPatternSelect.value = data.clear_pattern || 'adaptive';
auto_playShuffleToggle.checked = data.shuffle || false;
// Load playlists for selection
const playlistsResponse = await fetch('/list_all_playlists');
const playlists = await playlistsResponse.json();
// Clear and populate playlist select
auto_playPlaylistSelect.innerHTML = '';
playlists.forEach(playlist => {
const option = document.createElement('option');
option.value = playlist;
option.textContent = playlist;
if (playlist === data.playlist) {
option.selected = true;
}
auto_playPlaylistSelect.appendChild(option);
});
} catch (error) {
logMessage(`Error loading auto_play settings: ${error.message}`, LOG_TYPE.ERROR);
}
// Get save button
const saveAutoPlayButton = document.getElementById('saveAutoPlaySettings');
// Function to save settings
async function saveSettings(showFeedback = false) {
const originalButtonHTML = saveAutoPlayButton ? saveAutoPlayButton.innerHTML : '';
if (showFeedback && saveAutoPlayButton) {
saveAutoPlayButton.disabled = true;
saveAutoPlayButton.innerHTML = 'refreshSaving...';
}
try {
const response = await fetch('/api/auto_play-mode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: auto_playToggle.checked,
playlist: auto_playPlaylistSelect.value || null,
run_mode: auto_playRunModeSelect.value,
pause_time: parseFloat(auto_playPauseTimeInput.value) || 0,
clear_pattern: auto_playClearPatternSelect.value,
shuffle: auto_playShuffleToggle.checked
})
});
if (!response.ok) {
throw new Error('Failed to save settings');
}
if (showFeedback && saveAutoPlayButton) {
saveAutoPlayButton.innerHTML = 'checkSaved!';
showStatusMessage('Auto-play settings saved successfully', 'success');
setTimeout(() => {
saveAutoPlayButton.innerHTML = originalButtonHTML;
saveAutoPlayButton.disabled = false;
}, 2000);
}
} catch (error) {
logMessage(`Error saving auto_play settings: ${error.message}`, LOG_TYPE.ERROR);
if (showFeedback && saveAutoPlayButton) {
showStatusMessage(`Failed to save settings: ${error.message}`, 'error');
saveAutoPlayButton.innerHTML = originalButtonHTML;
saveAutoPlayButton.disabled = false;
}
}
}
// Toggle auto_play settings visibility and save
auto_playToggle.addEventListener('change', async () => {
auto_playSettings.style.display = auto_playToggle.checked ? 'block' : 'none';
await saveSettings(false); // Auto-save toggle state without full feedback
const statusText = auto_playToggle.checked ? 'enabled' : 'disabled';
showStatusMessage(`Auto-play ${statusText}`, 'success');
});
// Save button click handler
if (saveAutoPlayButton) {
saveAutoPlayButton.addEventListener('click', () => saveSettings(true));
}
}
// Initialize auto_play mode when DOM is ready
document.addEventListener('DOMContentLoaded', function() {
initializeauto_playMode();
initializeStillSandsMode();
initializeHomingConfig();
});
// Still Sands Mode Functions
async function initializeStillSandsMode() {
logMessage('Initializing Still Sands mode', LOG_TYPE.INFO);
const stillSandsToggle = document.getElementById('scheduledPauseToggle');
const stillSandsSettings = document.getElementById('scheduledPauseSettings');
const addTimeSlotButton = document.getElementById('addTimeSlotButton');
const saveStillSandsButton = document.getElementById('savePauseSettings');
const timeSlotsContainer = document.getElementById('timeSlotsContainer');
const wledControlToggle = document.getElementById('stillSandsWledControl');
const finishPatternToggle = document.getElementById('stillSandsFinishPattern');
const timezoneSelect = document.getElementById('stillSandsTimezone');
// Check if elements exist
if (!stillSandsToggle || !stillSandsSettings || !addTimeSlotButton || !saveStillSandsButton || !timeSlotsContainer) {
logMessage('Still Sands elements not found, skipping initialization', LOG_TYPE.WARNING);
logMessage(`Found elements: toggle=${!!stillSandsToggle}, settings=${!!stillSandsSettings}, addBtn=${!!addTimeSlotButton}, saveBtn=${!!saveStillSandsButton}, container=${!!timeSlotsContainer}`, LOG_TYPE.WARNING);
return;
}
logMessage('All Still Sands elements found successfully', LOG_TYPE.INFO);
// Track time slots
let timeSlots = [];
let slotIdCounter = 0;
// Load current Still Sands settings from initial data
try {
// Use the data loaded during page initialization, fallback to API if not available
let data;
if (window.initialStillSandsData) {
data = window.initialStillSandsData;
// Clear the global variable after use
delete window.initialStillSandsData;
} else {
// Fallback to API call if initial data not available
const response = await fetch('/api/scheduled-pause');
data = await response.json();
}
stillSandsToggle.checked = data.enabled || false;
if (data.enabled) {
stillSandsSettings.style.display = 'block';
}
// Load WLED control setting
if (wledControlToggle) {
wledControlToggle.checked = data.control_wled || false;
}
// Load finish pattern setting
if (finishPatternToggle) {
finishPatternToggle.checked = data.finish_pattern || false;
}
// Load timezone setting
if (timezoneSelect) {
timezoneSelect.value = data.timezone || '';
}
// Load existing time slots
timeSlots = data.time_slots || [];
// Assign IDs to loaded slots BEFORE rendering
if (timeSlots.length > 0) {
slotIdCounter = 0;
timeSlots.forEach(slot => {
slot.id = ++slotIdCounter;
});
}
renderTimeSlots();
} catch (error) {
logMessage(`Error loading Still Sands settings: ${error.message}`, LOG_TYPE.ERROR);
// Initialize with empty settings if load fails
timeSlots = [];
renderTimeSlots();
}
// Function to validate time format (HH:MM)
function isValidTime(timeString) {
const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/;
return timeRegex.test(timeString);
}
// Function to create a new time slot element
function createTimeSlotElement(slot) {
const slotDiv = document.createElement('div');
slotDiv.className = 'time-slot-item';
slotDiv.dataset.slotId = slot.id;
slotDiv.innerHTML = `
`;
// Add event listeners for this slot
const startTimeInput = slotDiv.querySelector('.start-time');
const endTimeInput = slotDiv.querySelector('.end-time');
const daysSelect = slotDiv.querySelector('.days-select');
const customDaysContainer = slotDiv.querySelector('.custom-days-container');
const removeButton = slotDiv.querySelector('.remove-slot-btn');
// Show/hide custom days based on selection
daysSelect.addEventListener('change', () => {
customDaysContainer.style.display = daysSelect.value === 'custom' ? 'block' : 'none';
updateTimeSlot(slot.id);
});
// Update slot data when inputs change
startTimeInput.addEventListener('change', () => updateTimeSlot(slot.id));
endTimeInput.addEventListener('change', () => updateTimeSlot(slot.id));
// Handle custom day checkboxes
customDaysContainer.addEventListener('change', () => updateTimeSlot(slot.id));
// Remove slot button
removeButton.addEventListener('click', () => {
removeTimeSlot(slot.id);
});
return slotDiv;
}
// Function to render all time slots
function renderTimeSlots() {
timeSlotsContainer.innerHTML = '';
if (timeSlots.length === 0) {
timeSlotsContainer.innerHTML = `
schedule
No time slots configured
Click "Add Time Slot" to create a pause schedule
`;
return;
}
timeSlots.forEach(slot => {
const slotElement = createTimeSlotElement(slot);
timeSlotsContainer.appendChild(slotElement);
});
}
// Function to add a new time slot
function addTimeSlot() {
const newSlot = {
id: ++slotIdCounter,
start_time: '22:00',
end_time: '08:00',
days: 'daily',
custom_days: []
};
timeSlots.push(newSlot);
renderTimeSlots();
}
// Function to remove a time slot
function removeTimeSlot(slotId) {
timeSlots = timeSlots.filter(slot => slot.id !== slotId);
renderTimeSlots();
}
// Function to update a time slot's data
function updateTimeSlot(slotId) {
const slotElement = timeSlotsContainer.querySelector(`[data-slot-id="${slotId}"]`);
if (!slotElement) return;
const slot = timeSlots.find(s => s.id === slotId);
if (!slot) return;
// Update slot data from inputs
slot.start_time = slotElement.querySelector('.start-time').value;
slot.end_time = slotElement.querySelector('.end-time').value;
slot.days = slotElement.querySelector('.days-select').value;
// Update custom days if applicable
if (slot.days === 'custom') {
const checkedDays = Array.from(slotElement.querySelectorAll(`input[name="custom-days-${slotId}"]:checked`))
.map(cb => cb.value);
slot.custom_days = checkedDays;
} else {
slot.custom_days = [];
}
}
// Function to validate all time slots
function validateTimeSlots() {
const errors = [];
timeSlots.forEach((slot, index) => {
if (!slot.start_time || !isValidTime(slot.start_time)) {
errors.push(`Time slot ${index + 1}: Invalid start time`);
}
if (!slot.end_time || !isValidTime(slot.end_time)) {
errors.push(`Time slot ${index + 1}: Invalid end time`);
}
if (slot.days === 'custom' && (!slot.custom_days || slot.custom_days.length === 0)) {
errors.push(`Time slot ${index + 1}: Please select at least one day for custom schedule`);
}
});
return errors;
}
// Function to save settings
async function saveStillSandsSettings() {
// Update all slots from current form values
timeSlots.forEach(slot => updateTimeSlot(slot.id));
// Validate time slots
const validationErrors = validateTimeSlots();
if (validationErrors.length > 0) {
showStatusMessage(`Validation errors: ${validationErrors.join(', ')}`, 'error');
return;
}
// Update button UI to show loading state
const originalButtonHTML = saveStillSandsButton.innerHTML;
saveStillSandsButton.disabled = true;
saveStillSandsButton.innerHTML = 'refreshSaving...';
try {
const response = await fetch('/api/scheduled-pause', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: stillSandsToggle.checked,
control_wled: wledControlToggle ? wledControlToggle.checked : false,
finish_pattern: finishPatternToggle ? finishPatternToggle.checked : false,
timezone: timezoneSelect ? (timezoneSelect.value || null) : null,
time_slots: timeSlots.map(slot => ({
start_time: slot.start_time,
end_time: slot.end_time,
days: slot.days,
custom_days: slot.custom_days
}))
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || 'Failed to save Still Sands settings');
}
// Show success state temporarily
saveStillSandsButton.innerHTML = 'checkSaved!';
showStatusMessage('Still Sands settings saved successfully', 'success');
// Restore button after 2 seconds
setTimeout(() => {
saveStillSandsButton.innerHTML = originalButtonHTML;
saveStillSandsButton.disabled = false;
}, 2000);
} catch (error) {
logMessage(`Error saving Still Sands settings: ${error.message}`, LOG_TYPE.ERROR);
showStatusMessage(`Failed to save settings: ${error.message}`, 'error');
// Restore button immediately on error
saveStillSandsButton.innerHTML = originalButtonHTML;
saveStillSandsButton.disabled = false;
}
}
// Note: Slot IDs are now assigned during initialization above, before first render
// Event listeners
stillSandsToggle.addEventListener('change', async () => {
logMessage(`Still Sands toggle changed: ${stillSandsToggle.checked}`, LOG_TYPE.INFO);
stillSandsSettings.style.display = stillSandsToggle.checked ? 'block' : 'none';
logMessage(`Settings display set to: ${stillSandsSettings.style.display}`, LOG_TYPE.INFO);
// Auto-save when toggle changes
try {
await saveStillSandsSettings();
const statusText = stillSandsToggle.checked ? 'enabled' : 'disabled';
showStatusMessage(`Still Sands ${statusText} successfully`, 'success');
} catch (error) {
logMessage(`Error saving Still Sands toggle: ${error.message}`, LOG_TYPE.ERROR);
showStatusMessage(`Failed to save Still Sands setting: ${error.message}`, 'error');
}
});
addTimeSlotButton.addEventListener('click', addTimeSlot);
saveStillSandsButton.addEventListener('click', saveStillSandsSettings);
// Add listener for WLED control toggle
if (wledControlToggle) {
wledControlToggle.addEventListener('change', async () => {
logMessage(`WLED control toggle changed: ${wledControlToggle.checked}`, LOG_TYPE.INFO);
// Auto-save when WLED control changes
await saveStillSandsSettings();
});
}
// Add listener for finish pattern toggle
if (finishPatternToggle) {
finishPatternToggle.addEventListener('change', async () => {
logMessage(`Finish pattern toggle changed: ${finishPatternToggle.checked}`, LOG_TYPE.INFO);
// Auto-save when finish pattern setting changes
await saveStillSandsSettings();
});
}
// Add listener for timezone select
if (timezoneSelect) {
timezoneSelect.addEventListener('change', async () => {
logMessage(`Timezone changed: ${timezoneSelect.value || 'System Default'}`, LOG_TYPE.INFO);
// Auto-save when timezone changes
await saveStillSandsSettings();
});
}
}
// Homing Configuration
async function initializeHomingConfig() {
logMessage('Initializing homing configuration', LOG_TYPE.INFO);
const homingModeCrash = document.getElementById('homingModeCrash');
const homingModeSensor = document.getElementById('homingModeSensor');
const angularOffsetInput = document.getElementById('angularOffsetInput');
const compassOffsetContainer = document.getElementById('compassOffsetContainer');
const saveHomingConfigButton = document.getElementById('saveHomingConfig');
const homingInfoContent = document.getElementById('homingInfoContent');
const autoHomeEnabledToggle = document.getElementById('autoHomeEnabledToggle');
const autoHomeSettings = document.getElementById('autoHomeSettings');
const autoHomeAfterPatternsInput = document.getElementById('autoHomeAfterPatternsInput');
// Check if elements exist
if (!homingModeCrash || !homingModeSensor || !angularOffsetInput || !saveHomingConfigButton || !homingInfoContent || !compassOffsetContainer) {
logMessage('Homing configuration elements not found, skipping initialization', LOG_TYPE.WARNING);
return;
}
logMessage('Homing configuration elements found successfully', LOG_TYPE.INFO);
// Function to get selected homing mode
function getSelectedMode() {
return homingModeCrash.checked ? 0 : 1;
}
// Function to update info box and visibility based on selected mode
function updateHomingInfo() {
const mode = getSelectedMode();
// Show/hide compass offset based on mode
if (mode === 0) {
compassOffsetContainer.style.display = 'none';
homingInfoContent.innerHTML = `
Crash Homing Mode:
- • Y axis moves -22mm (or -30mm for mini) until physical stop
- • Theta set to 0, rho set to 0
- • No x0 y0 command sent
- • No hardware sensors required
`;
} else {
compassOffsetContainer.style.display = 'block';
homingInfoContent.innerHTML = `
Sensor Homing Mode:
- • Requires hardware limit switches
- • Requires additional configuration
`;
}
}
// Load current homing configuration
try {
const response = await fetch('/api/homing-config');
const data = await response.json();
// Set radio button based on mode
if (data.homing_mode === 1) {
homingModeSensor.checked = true;
} else {
homingModeCrash.checked = true;
}
angularOffsetInput.value = data.angular_homing_offset_degrees || 0;
// Load auto-home settings
if (autoHomeEnabledToggle) {
autoHomeEnabledToggle.checked = data.auto_home_enabled || false;
if (autoHomeSettings) {
autoHomeSettings.style.display = data.auto_home_enabled ? 'block' : 'none';
}
}
if (autoHomeAfterPatternsInput) {
autoHomeAfterPatternsInput.value = data.auto_home_after_patterns || 5;
}
updateHomingInfo();
logMessage(`Loaded homing config: mode=${data.homing_mode}, offset=${data.angular_homing_offset_degrees}°, auto_home=${data.auto_home_enabled}, after=${data.auto_home_after_patterns}`, LOG_TYPE.INFO);
} catch (error) {
logMessage(`Error loading homing configuration: ${error.message}`, LOG_TYPE.ERROR);
// Initialize with defaults if load fails
homingModeCrash.checked = true;
angularOffsetInput.value = 0;
if (autoHomeEnabledToggle) autoHomeEnabledToggle.checked = false;
if (autoHomeAfterPatternsInput) autoHomeAfterPatternsInput.value = 5;
updateHomingInfo();
}
// Function to save homing configuration
async function saveHomingConfig() {
// Update button UI to show loading state
const originalButtonHTML = saveHomingConfigButton.innerHTML;
saveHomingConfigButton.disabled = true;
saveHomingConfigButton.innerHTML = 'refreshSaving...';
try {
const requestBody = {
homing_mode: getSelectedMode(),
angular_homing_offset_degrees: parseFloat(angularOffsetInput.value) || 0
};
// Include auto-home settings if elements exist
if (autoHomeEnabledToggle) {
requestBody.auto_home_enabled = autoHomeEnabledToggle.checked;
}
if (autoHomeAfterPatternsInput) {
const afterPatterns = parseInt(autoHomeAfterPatternsInput.value);
if (!isNaN(afterPatterns) && afterPatterns >= 1) {
requestBody.auto_home_after_patterns = afterPatterns;
}
}
const response = await fetch('/api/homing-config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || 'Failed to save homing configuration');
}
// Show success state temporarily
saveHomingConfigButton.innerHTML = 'checkSaved!';
showStatusMessage('Homing configuration saved successfully', 'success');
// Restore button after 2 seconds
setTimeout(() => {
saveHomingConfigButton.innerHTML = originalButtonHTML;
saveHomingConfigButton.disabled = false;
}, 2000);
} catch (error) {
logMessage(`Error saving homing configuration: ${error.message}`, LOG_TYPE.ERROR);
showStatusMessage(`Failed to save homing configuration: ${error.message}`, 'error');
// Restore button immediately on error
saveHomingConfigButton.innerHTML = originalButtonHTML;
saveHomingConfigButton.disabled = false;
}
}
// Event listeners
homingModeCrash.addEventListener('change', updateHomingInfo);
homingModeSensor.addEventListener('change', updateHomingInfo);
saveHomingConfigButton.addEventListener('click', saveHomingConfig);
// Auto-home toggle event listener
if (autoHomeEnabledToggle && autoHomeSettings) {
autoHomeEnabledToggle.addEventListener('change', () => {
autoHomeSettings.style.display = autoHomeEnabledToggle.checked ? 'block' : 'none';
});
}
}
// Toggle password visibility helper
function togglePasswordVisibility(inputId, button) {
const input = document.getElementById(inputId);
if (!input || !button) return;
const icon = button.querySelector('.material-icons');
if (input.type === 'password') {
input.type = 'text';
if (icon) icon.textContent = 'visibility';
} else {
input.type = 'password';
if (icon) icon.textContent = 'visibility_off';
}
}
// MQTT Configuration
async function initializeMqttConfig() {
logMessage('Initializing MQTT configuration', LOG_TYPE.INFO);
const mqttEnableToggle = document.getElementById('mqttEnableToggle');
const mqttSettings = document.getElementById('mqttSettings');
const mqttStatusBanner = document.getElementById('mqttStatusBanner');
const mqttConnectedBanner = document.getElementById('mqttConnectedBanner');
const mqttDisconnectedBanner = document.getElementById('mqttDisconnectedBanner');
const mqttBrokerInput = document.getElementById('mqttBrokerInput');
const mqttPortInput = document.getElementById('mqttPortInput');
const mqttUsernameInput = document.getElementById('mqttUsernameInput');
const mqttPasswordInput = document.getElementById('mqttPasswordInput');
const mqttDeviceNameInput = document.getElementById('mqttDeviceNameInput');
const mqttDeviceIdInput = document.getElementById('mqttDeviceIdInput');
const mqttClientIdInput = document.getElementById('mqttClientIdInput');
const mqttDiscoveryPrefixInput = document.getElementById('mqttDiscoveryPrefixInput');
const testMqttButton = document.getElementById('testMqttConnection');
const mqttTestResult = document.getElementById('mqttTestResult');
const saveMqttButton = document.getElementById('saveMqttConfig');
const mqttRestartNotice = document.getElementById('mqttRestartNotice');
// Check if elements exist
if (!mqttEnableToggle || !mqttSettings || !saveMqttButton) {
logMessage('MQTT configuration elements not found, skipping initialization', LOG_TYPE.WARNING);
return;
}
logMessage('MQTT configuration elements found successfully', LOG_TYPE.INFO);
// Track if settings have changed (to show restart notice)
let originalConfig = null;
let configChanged = false;
// Function to update UI based on enabled state
function updateMqttSettingsVisibility() {
mqttSettings.style.display = mqttEnableToggle.checked ? 'block' : 'none';
if (mqttStatusBanner) {
mqttStatusBanner.classList.toggle('hidden', !mqttEnableToggle.checked);
}
}
// Function to update connection status banners
function updateConnectionStatus(connected) {
if (mqttConnectedBanner && mqttDisconnectedBanner) {
if (connected) {
mqttConnectedBanner.classList.remove('hidden');
mqttDisconnectedBanner.classList.add('hidden');
} else {
mqttConnectedBanner.classList.add('hidden');
mqttDisconnectedBanner.classList.remove('hidden');
}
}
}
// Function to check if config has changed
function checkConfigChanged() {
if (!originalConfig) return false;
const currentConfig = {
enabled: mqttEnableToggle.checked,
broker: mqttBrokerInput.value,
port: parseInt(mqttPortInput.value) || 1883,
username: mqttUsernameInput.value,
password: mqttPasswordInput.value,
device_name: mqttDeviceNameInput.value,
device_id: mqttDeviceIdInput.value,
client_id: mqttClientIdInput.value,
discovery_prefix: mqttDiscoveryPrefixInput.value
};
return JSON.stringify(currentConfig) !== JSON.stringify(originalConfig);
}
// Function to show/hide restart notice
function updateRestartNotice() {
configChanged = checkConfigChanged();
if (mqttRestartNotice) {
mqttRestartNotice.classList.toggle('hidden', !configChanged);
}
}
// Load current MQTT configuration
try {
const response = await fetch('/api/mqtt-config');
const data = await response.json();
mqttEnableToggle.checked = data.enabled || false;
mqttBrokerInput.value = data.broker || '';
mqttPortInput.value = data.port || 1883;
mqttUsernameInput.value = data.username || '';
// Note: Password is not returned from API for security
mqttDeviceNameInput.value = data.device_name || 'Dune Weaver';
mqttDeviceIdInput.value = data.device_id || 'dune_weaver';
mqttClientIdInput.value = data.client_id || 'dune_weaver';
mqttDiscoveryPrefixInput.value = data.discovery_prefix || 'homeassistant';
// Store original config for change detection
originalConfig = {
enabled: data.enabled || false,
broker: data.broker || '',
port: data.port || 1883,
username: data.username || '',
password: '', // We don't have the original password
device_name: data.device_name || 'Dune Weaver',
device_id: data.device_id || 'dune_weaver',
client_id: data.client_id || 'dune_weaver',
discovery_prefix: data.discovery_prefix || 'homeassistant'
};
updateMqttSettingsVisibility();
// Update connection status if MQTT is enabled
if (data.enabled) {
updateConnectionStatus(data.connected || false);
}
logMessage(`Loaded MQTT config: enabled=${data.enabled}, broker=${data.broker}`, LOG_TYPE.INFO);
} catch (error) {
logMessage(`Error loading MQTT configuration: ${error.message}`, LOG_TYPE.ERROR);
// Initialize with defaults if load fails
mqttEnableToggle.checked = false;
updateMqttSettingsVisibility();
}
// Function to save MQTT configuration
async function saveMqttConfig() {
// Validate required fields if MQTT is enabled
if (mqttEnableToggle.checked && !mqttBrokerInput.value.trim()) {
showStatusMessage('MQTT broker address is required when MQTT is enabled', 'error');
mqttBrokerInput.focus();
return;
}
// Update button UI to show loading state
const originalButtonHTML = saveMqttButton.innerHTML;
saveMqttButton.disabled = true;
saveMqttButton.innerHTML = 'refreshSaving...';
try {
const requestBody = {
enabled: mqttEnableToggle.checked,
broker: mqttBrokerInput.value.trim(),
port: parseInt(mqttPortInput.value) || 1883,
username: mqttUsernameInput.value.trim() || null,
device_name: mqttDeviceNameInput.value.trim() || 'Dune Weaver',
device_id: mqttDeviceIdInput.value.trim() || 'dune_weaver',
client_id: mqttClientIdInput.value.trim() || 'dune_weaver',
discovery_prefix: mqttDiscoveryPrefixInput.value.trim() || 'homeassistant'
};
// Only include password if it was changed (not empty)
if (mqttPasswordInput.value) {
requestBody.password = mqttPasswordInput.value;
}
const response = await fetch('/api/mqtt-config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || 'Failed to save MQTT configuration');
}
const data = await response.json();
// Update original config for change detection
originalConfig = {
enabled: requestBody.enabled,
broker: requestBody.broker,
port: requestBody.port,
username: requestBody.username || '',
password: '', // Reset password tracking
device_name: requestBody.device_name,
device_id: requestBody.device_id,
client_id: requestBody.client_id,
discovery_prefix: requestBody.discovery_prefix
};
// Clear password field after save
mqttPasswordInput.value = '';
// Show success state temporarily
saveMqttButton.innerHTML = 'checkSaved!';
showStatusMessage('MQTT configuration saved successfully. Restart the application to apply changes.', 'success');
// Show restart notice
if (mqttRestartNotice) {
mqttRestartNotice.classList.remove('hidden');
}
// Restore button after 2 seconds
setTimeout(() => {
saveMqttButton.innerHTML = originalButtonHTML;
saveMqttButton.disabled = false;
}, 2000);
} catch (error) {
logMessage(`Error saving MQTT configuration: ${error.message}`, LOG_TYPE.ERROR);
showStatusMessage(`Failed to save MQTT configuration: ${error.message}`, 'error');
// Restore button immediately on error
saveMqttButton.innerHTML = originalButtonHTML;
saveMqttButton.disabled = false;
}
}
// Function to test MQTT connection
async function testMqttConnection() {
// Validate broker address
if (!mqttBrokerInput.value.trim()) {
showStatusMessage('Please enter a broker address to test', 'error');
mqttBrokerInput.focus();
return;
}
// Update button UI to show loading state
const originalButtonHTML = testMqttButton.innerHTML;
testMqttButton.disabled = true;
testMqttButton.innerHTML = 'refreshTesting...';
// Clear previous result
if (mqttTestResult) {
mqttTestResult.innerHTML = '';
}
try {
const requestBody = {
broker: mqttBrokerInput.value.trim(),
port: parseInt(mqttPortInput.value) || 1883,
username: mqttUsernameInput.value.trim() || null,
password: mqttPasswordInput.value || null
};
const response = await fetch('/api/mqtt-test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
});
const data = await response.json();
if (data.success) {
if (mqttTestResult) {
mqttTestResult.innerHTML = 'check_circleConnection successful!';
}
showStatusMessage('MQTT connection test successful', 'success');
} else {
if (mqttTestResult) {
mqttTestResult.innerHTML = `error${data.error || 'Connection failed'}`;
}
showStatusMessage(`MQTT test failed: ${data.error || 'Connection failed'}`, 'error');
}
} catch (error) {
logMessage(`Error testing MQTT connection: ${error.message}`, LOG_TYPE.ERROR);
if (mqttTestResult) {
mqttTestResult.innerHTML = `errorTest failed: ${error.message}`;
}
showStatusMessage(`MQTT test failed: ${error.message}`, 'error');
} finally {
// Restore button
testMqttButton.innerHTML = originalButtonHTML;
testMqttButton.disabled = false;
}
}
// Event listeners
mqttEnableToggle.addEventListener('change', () => {
updateMqttSettingsVisibility();
updateRestartNotice();
});
// Track changes to show restart notice
[mqttBrokerInput, mqttPortInput, mqttUsernameInput, mqttPasswordInput,
mqttDeviceNameInput, mqttDeviceIdInput, mqttClientIdInput, mqttDiscoveryPrefixInput].forEach(input => {
if (input) {
input.addEventListener('input', updateRestartNotice);
}
});
testMqttButton.addEventListener('click', testMqttConnection);
saveMqttButton.addEventListener('click', saveMqttConfig);
}
// Initialize MQTT config when DOM is ready
document.addEventListener('DOMContentLoaded', function() {
initializeMqttConfig();
initializeTableTypeConfig();
});
// ============================================================================
// Table Type Configuration
// ============================================================================
function initializeTableTypeConfig() {
const tableTypeSelect = document.getElementById('tableTypeSelect');
const saveTableTypeButton = document.getElementById('saveTableType');
const detectedTableType = document.getElementById('detectedTableType');
if (!tableTypeSelect || !saveTableTypeButton) {
logMessage('Table type elements not found', LOG_TYPE.WARNING);
return;
}
// Load current settings
loadTableTypeSettings();
// Save button click handler
saveTableTypeButton.addEventListener('click', saveTableTypeConfig);
async function loadTableTypeSettings() {
try {
const response = await fetch('/api/settings');
if (!response.ok) throw new Error('Failed to fetch settings');
const settings = await response.json();
const machine = settings.machine || {};
// Populate dropdown with available table types
tableTypeSelect.innerHTML = '';
if (machine.available_table_types) {
machine.available_table_types.forEach(type => {
const option = document.createElement('option');
option.value = type.value;
option.textContent = type.label;
tableTypeSelect.appendChild(option);
});
}
// Set current override value
if (machine.table_type_override) {
tableTypeSelect.value = machine.table_type_override;
} else {
tableTypeSelect.value = '';
}
// Update detected type display
if (detectedTableType) {
const detected = machine.detected_table_type;
if (detected) {
// Find the label for the detected type
const typeInfo = machine.available_table_types?.find(t => t.value === detected);
detectedTableType.textContent = typeInfo ? typeInfo.label : detected;
} else {
detectedTableType.textContent = 'Not connected';
}
}
logMessage('Table type settings loaded', LOG_TYPE.DEBUG);
} catch (error) {
logMessage(`Error loading table type settings: ${error.message}`, LOG_TYPE.ERROR);
}
}
async function saveTableTypeConfig() {
const originalButtonHTML = saveTableTypeButton.innerHTML;
saveTableTypeButton.disabled = true;
saveTableTypeButton.innerHTML = 'refreshSaving...';
try {
const response = await fetch('/api/settings', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
machine: {
table_type_override: tableTypeSelect.value || ''
}
})
});
if (!response.ok) throw new Error('Failed to save settings');
const result = await response.json();
if (result.success) {
showStatusMessage('Table type settings saved. Changes will take effect on next connection.', 'success');
// Reload to show updated effective type
await loadTableTypeSettings();
} else {
throw new Error('Save failed');
}
} catch (error) {
logMessage(`Error saving table type: ${error.message}`, LOG_TYPE.ERROR);
showStatusMessage(`Failed to save table type: ${error.message}`, 'error');
} finally {
saveTableTypeButton.innerHTML = originalButtonHTML;
saveTableTypeButton.disabled = false;
}
}
}