From 00514f05257cb6113a7214bcf7c9774e585f1321 Mon Sep 17 00:00:00 2001 From: jimmy Date: Thu, 29 Jan 2026 00:54:20 +1300 Subject: [PATCH] Add in-app settings menu and fix settings API Move WiFi and device name configuration into a modal menu, reuse existing settings endpoints, and harden settings serialization and startup for MicroPython. --- Pipfile | 2 +- src/controllers/settings.py | 4 +- src/main.py | 3 +- src/static/help.js | 218 +++++++++++++++++++++++++++++++++++- src/static/style.css | 116 +++++++++++++++++++ src/templates/index.html | 106 ++++++++++++++++++ 6 files changed, 444 insertions(+), 5 deletions(-) diff --git a/Pipfile b/Pipfile index 55646c4..8746574 100644 --- a/Pipfile +++ b/Pipfile @@ -20,4 +20,4 @@ python_version = "3.12" [scripts] web = "python /home/pi/led-controller/tests/web.py" watch = "python -m watchfiles 'python tests/web.py' src tests" -install = "pipenv install" +install = "pipenv install" \ No newline at end of file diff --git a/src/controllers/settings.py b/src/controllers/settings.py index 6e7a85b..0b327ca 100644 --- a/src/controllers/settings.py +++ b/src/controllers/settings.py @@ -9,7 +9,9 @@ settings = Settings() @controller.get('') async def get_settings(request): """Get all settings.""" - return json.dumps(dict(settings)), 200, {'Content-Type': 'application/json'} + # Settings is already a dict subclass; avoid dict() wrapper which can + # trigger MicroPython's "dict update sequence has wrong length" quirk. + return json.dumps(settings), 200, {'Content-Type': 'application/json'} @controller.get('/wifi/station') async def get_station_status(request): diff --git a/src/main.py b/src/main.py index 685897d..f3d60b7 100644 --- a/src/main.py +++ b/src/main.py @@ -25,7 +25,8 @@ async def main(port=80): settings = Settings() print("Starting") - network.WLAN(network.STA_IF).active(True) + sta = network.WLAN(network.STA_IF) + sta.active(True) # Initialize ESPNow singleton (config + peers) esp = ESPNow() diff --git a/src/static/help.js b/src/static/help.js index d902092..d91996c 100644 --- a/src/static/help.js +++ b/src/static/help.js @@ -1,4 +1,5 @@ document.addEventListener('DOMContentLoaded', () => { + // Help modal const helpBtn = document.getElementById('help-btn'); const helpModal = document.getElementById('help-modal'); const helpCloseBtn = document.getElementById('help-close-btn'); @@ -22,6 +23,219 @@ document.addEventListener('DOMContentLoaded', () => { } }); } -} -) + // Settings modal wiring (reusing existing settings endpoints). + const settingsButton = document.getElementById('settings-btn'); + const settingsModal = document.getElementById('settings-modal'); + const settingsCloseButton = document.getElementById('settings-close-btn'); + + const showSettingsMessage = (text, type = 'success') => { + const messageEl = document.getElementById('settings-message'); + if (!messageEl) return; + messageEl.textContent = text; + messageEl.className = `message ${type} show`; + setTimeout(() => { + messageEl.classList.remove('show'); + }, 5000); + }; + + async function loadDeviceSettings() { + try { + const response = await fetch('/settings'); + const data = await response.json(); + const nameInput = document.getElementById('device-name-input'); + if (nameInput && data && typeof data === 'object') { + nameInput.value = data.device_name || 'led-controller'; + } + } catch (error) { + console.error('Error loading device settings:', error); + } + } + + async function loadStationStatus() { + try { + const response = await fetch('/settings/wifi/station'); + const status = await response.json(); + const statusEl = document.getElementById('station-status'); + if (!statusEl) return; + if (status.connected) { + statusEl.innerHTML = ` +

Connection Status: Connected

+

SSID: ${status.ssid || 'N/A'}

+

IP Address: ${status.ip || 'N/A'}

+

Gateway: ${status.gateway || 'N/A'}

+

Netmask: ${status.netmask || 'N/A'}

+

DNS: ${status.dns || 'N/A'}

+ `; + } else { + statusEl.innerHTML = ` +

Connection Status: Disconnected

+

Not connected to any WiFi network

+ `; + } + } catch (error) { + console.error('Error loading station status:', error); + } + } + + async function loadStationCredentials() { + try { + const response = await fetch('/settings/wifi/station/credentials'); + const creds = await response.json(); + if (creds.ssid) document.getElementById('station-ssid').value = creds.ssid; + if (creds.ip) document.getElementById('station-ip').value = creds.ip; + if (creds.gateway) document.getElementById('station-gateway').value = creds.gateway; + } catch (error) { + console.error('Error loading station credentials:', error); + } + } + + async function loadAPStatus() { + try { + const response = await fetch('/settings/wifi/ap'); + const config = await response.json(); + const statusEl = document.getElementById('ap-status'); + if (!statusEl) return; + if (config.active) { + statusEl.innerHTML = ` +

AP Status: Active

+

SSID: ${config.ssid || 'N/A'}

+

Channel: ${config.channel || 'Auto'}

+

IP Address: ${config.ip || 'N/A'}

+ `; + } else { + statusEl.innerHTML = ` +

AP Status: Inactive

+

Access Point is not currently active

+ `; + } + if (config.saved_ssid) document.getElementById('ap-ssid').value = config.saved_ssid; + if (config.saved_channel) document.getElementById('ap-channel').value = config.saved_channel; + } catch (error) { + console.error('Error loading AP status:', error); + } + } + + if (settingsButton && settingsModal) { + settingsButton.addEventListener('click', () => { + settingsModal.classList.add('active'); + // Load current WiFi status/config when opening + loadDeviceSettings(); + loadStationStatus(); + loadStationCredentials(); + loadAPStatus(); + }); + } + + if (settingsCloseButton && settingsModal) { + settingsCloseButton.addEventListener('click', () => { + settingsModal.classList.remove('active'); + }); + } + + if (settingsModal) { + settingsModal.addEventListener('click', (event) => { + if (event.target === settingsModal) { + settingsModal.classList.remove('active'); + } + }); + } + + const stationForm = document.getElementById('station-form'); + if (stationForm) { + stationForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const formData = { + ssid: document.getElementById('station-ssid').value, + password: document.getElementById('station-password').value, + ip: document.getElementById('station-ip').value || null, + gateway: document.getElementById('station-gateway').value || null, + }; + try { + const response = await fetch('/settings/wifi/station', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(formData), + }); + const result = await response.json(); + if (response.ok) { + showSettingsMessage('WiFi station connected successfully!', 'success'); + setTimeout(loadStationStatus, 1000); + } else { + showSettingsMessage(`Error: ${result.error || 'Failed to connect'}`, 'error'); + } + } catch (error) { + showSettingsMessage(`Error: ${error.message}`, 'error'); + } + }); + } + + const deviceForm = document.getElementById('device-form'); + if (deviceForm) { + deviceForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const nameInput = document.getElementById('device-name-input'); + const deviceName = nameInput ? nameInput.value.trim() : ''; + if (!deviceName) { + showSettingsMessage('Device name is required', 'error'); + return; + } + try { + const response = await fetch('/settings/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ device_name: deviceName }), + }); + const result = await response.json(); + if (response.ok) { + showSettingsMessage('Device name saved. It will be used on next restart.', 'success'); + } else { + showSettingsMessage(`Error: ${result.error || 'Failed to save device name'}`, 'error'); + } + } catch (error) { + showSettingsMessage(`Error: ${error.message}`, 'error'); + } + }); + } + const apForm = document.getElementById('ap-form'); + if (apForm) { + apForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const formData = { + ssid: document.getElementById('ap-ssid').value, + password: document.getElementById('ap-password').value, + channel: document.getElementById('ap-channel').value || null, + }; + + if (formData.password && formData.password.length > 0 && formData.password.length < 8) { + showSettingsMessage('AP password must be at least 8 characters', 'error'); + return; + } + + if (formData.channel) { + formData.channel = parseInt(formData.channel, 10); + if (formData.channel < 1 || formData.channel > 11) { + showSettingsMessage('Channel must be between 1 and 11', 'error'); + return; + } + } + + try { + const response = await fetch('/settings/wifi/ap', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(formData), + }); + const result = await response.json(); + if (response.ok) { + showSettingsMessage('Access Point configured successfully!', 'success'); + setTimeout(loadAPStatus, 1000); + } else { + showSettingsMessage(`Error: ${result.error || 'Failed to configure AP'}`, 'error'); + } + } catch (error) { + showSettingsMessage(`Error: ${error.message}`, 'error'); + } + }); + } +}); diff --git a/src/static/style.css b/src/static/style.css index 2e5d16e..cf7c2e8 100644 --- a/src/static/style.css +++ b/src/static/style.css @@ -355,6 +355,122 @@ header h1 { font-size: 1.1rem; } +/* Settings modal layout */ +.settings-section { + background-color: #1a1a1a; + border-radius: 8px; + padding: 1rem; + margin-top: 1rem; + border: 1px solid #4a4a4a; +} + +.settings-section h3 { + font-size: 1.1rem; + margin-bottom: 0.75rem; + color: #fff; + border-bottom: 1px solid #4a4a4a; + padding-bottom: 0.25rem; +} + +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + color: #ccc; + font-weight: 500; +} + +.form-group input[type="text"], +.form-group input[type="password"], +.form-group input[type="number"], +.form-group select { + width: 100%; + padding: 0.5rem; + background-color: #2e2e2e; + border: 1px solid #4a4a4a; + border-radius: 4px; + color: white; + font-size: 0.95rem; +} + +.form-group small { + display: block; + margin-top: 0.25rem; + color: #888; + font-size: 0.8rem; +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; +} + +.status-info { + background-color: #2e2e2e; + border: 1px solid #4a4a4a; + border-radius: 4px; + padding: 0.75rem; + margin-bottom: 0.75rem; +} + +.status-info h3, +.status-info h4 { + font-size: 1rem; + margin-bottom: 0.5rem; + color: #fff; +} + +.status-info p { + color: #aaa; + margin: 0.25rem 0; + font-size: 0.9rem; +} + +.status-connected { + color: #4caf50; +} + +.status-disconnected { + color: #f44336; +} + +.btn-group { + display: flex; + gap: 0.5rem; + margin-top: 0.75rem; +} + +.btn-full { + flex: 1; +} + +.message { + padding: 0.75rem; + border-radius: 4px; + margin-bottom: 1rem; + display: none; +} + +.message.success { + background-color: #1b5e20; + color: #4caf50; + border: 1px solid #4caf50; +} + +.message.error { + background-color: #5e1b1b; + color: #f44336; + border: 1px solid #f44336; +} + +.message.show { + display: block; +} + .patterns-list { display: flex; flex-direction: column; diff --git a/src/templates/index.html b/src/templates/index.html index c4afb7e..077d84a 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -16,6 +16,7 @@ + @@ -230,6 +231,111 @@ + + +