3 Commits

Author SHA1 Message Date
0fdc11c0b0 ESP-NOW: STA interface, notify browser on send failure
- Activate STA interface before ESP-NOW to fix ESP_ERR_ESPNOW_IF
- Notify browser on send failure: WebSocket sends error JSON; preset API returns 503
- Use exceptions for failure (not return value) to avoid false errors when send succeeds
- presets.js: handle server error messages in WebSocket onmessage

Made-with: Cursor
2026-03-08 23:47:55 +13:00
91bd78ab31 Add favicon route and minor cleanup
- Add /favicon.ico route (204) to avoid browser 404
- CSS formatting tweaks
- Pipfile trailing newline

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-20 11:49:11 +13:00
2be0640622 Remove WiFi station (client) support
- Drop station connect/status/credentials from wifi util and settings API
- Remove station activation from main
- Remove station UI and JS from index, settings template, and help.js
- Device settings now only configure WiFi Access Point

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-20 11:49:04 +13:00
12 changed files with 50 additions and 375 deletions

View File

@@ -21,4 +21,4 @@ python_version = "3.12"
[scripts] [scripts]
web = "python /home/pi/led-controller/tests/web.py" web = "python /home/pi/led-controller/tests/web.py"
watch = "python -m watchfiles 'python tests/web.py' src tests" watch = "python -m watchfiles 'python tests/web.py' src tests"
install = "pipenv install" install = "pipenv install"

View File

@@ -128,6 +128,11 @@ async def run_local():
"""Serve the settings page.""" """Serve the settings page."""
return send_file('src/templates/settings.html') return send_file('src/templates/settings.html')
# Favicon: avoid 404 in browser console (no file needed)
@app.route('/favicon.ico')
def favicon(request):
return '', 204
# Static file route # Static file route
@app.route("/static/<path:path>") @app.route("/static/<path:path>")
def static_handler(request, path): def static_handler(request, path):

View File

@@ -179,14 +179,20 @@ async def send_presets(request, session):
batch = test_batch batch = test_batch
last_msg = test_msg last_msg = test_msg
else: else:
await send_chunk(batch) try:
await send_chunk(batch)
except Exception:
return json.dumps({"error": "ESP-NOW send failed"}), 503, {'Content-Type': 'application/json'}
await asyncio.sleep_ms(SEND_DELAY_MS) await asyncio.sleep_ms(SEND_DELAY_MS)
messages_sent += 1 messages_sent += 1
batch = {name: preset_obj} batch = {name: preset_obj}
last_msg = build_message(presets=batch, save=save_flag, default=default_id) last_msg = build_message(presets=batch, save=save_flag, default=default_id)
if batch: if batch:
await send_chunk(batch) try:
await send_chunk(batch)
except Exception:
return json.dumps({"error": "ESP-NOW send failed"}), 503, {'Content-Type': 'application/json'}
await asyncio.sleep_ms(SEND_DELAY_MS) await asyncio.sleep_ms(SEND_DELAY_MS)
messages_sent += 1 messages_sent += 1

View File

@@ -13,51 +13,6 @@ async def get_settings(request):
# trigger MicroPython's "dict update sequence has wrong length" quirk. # trigger MicroPython's "dict update sequence has wrong length" quirk.
return json.dumps(settings), 200, {'Content-Type': 'application/json'} return json.dumps(settings), 200, {'Content-Type': 'application/json'}
@controller.get('/wifi/station')
async def get_station_status(request):
"""Get WiFi station connection status."""
status = wifi.get_sta_status()
if status:
return json.dumps(status), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Failed to get station status"}), 500
@controller.post('/wifi/station')
async def connect_station(request):
"""Connect to WiFi station with credentials."""
try:
data = request.json
ssid = data.get('ssid')
password = data.get('password', '')
ip = data.get('ip')
gateway = data.get('gateway')
if not ssid:
return json.dumps({"error": "SSID is required"}), 400
# Save credentials to settings
settings['wifi_station_ssid'] = ssid
settings['wifi_station_password'] = password
if ip:
settings['wifi_station_ip'] = ip
if gateway:
settings['wifi_station_gateway'] = gateway
settings.save()
# Attempt connection
result = wifi.connect(ssid, password, ip, gateway)
if result:
return json.dumps({
"message": "Connected successfully",
"ip": result[0],
"netmask": result[1],
"gateway": result[2],
"dns": result[3] if len(result) > 3 else None
}), 200, {'Content-Type': 'application/json'}
else:
return json.dumps({"error": "Failed to connect"}), 400
except Exception as e:
return json.dumps({"error": str(e)}), 500
@controller.get('/wifi/ap') @controller.get('/wifi/ap')
async def get_ap_config(request): async def get_ap_config(request):
"""Get Access Point configuration.""" """Get Access Point configuration."""
@@ -106,15 +61,6 @@ async def configure_ap(request):
except Exception as e: except Exception as e:
return json.dumps({"error": str(e)}), 500 return json.dumps({"error": str(e)}), 500
@controller.get('/wifi/station/credentials')
async def get_station_credentials(request):
"""Get saved WiFi station credentials (without password)."""
return json.dumps({
"ssid": settings.get('wifi_station_ssid', ''),
"ip": settings.get('wifi_station_ip', ''),
"gateway": settings.get('wifi_station_gateway', '')
}), 200, {'Content-Type': 'application/json'}
@controller.put('/settings') @controller.put('/settings')
async def update_settings(request): async def update_settings(request):
"""Update general settings.""" """Update general settings."""

View File

@@ -9,7 +9,6 @@ from microdot.session import Session
from settings import Settings from settings import Settings
import aioespnow import aioespnow
import network
import controllers.preset as preset import controllers.preset as preset
import controllers.profile as profile import controllers.profile as profile
import controllers.group as group import controllers.group as group
@@ -27,9 +26,6 @@ async def main(port=80):
print(settings) print(settings)
print("Starting") print("Starting")
sta = network.WLAN(network.STA_IF)
sta.active(True)
# Initialize ESPNow singleton (config + peers) # Initialize ESPNow singleton (config + peers)
esp = ESPNow() esp = ESPNow()
@@ -103,7 +99,13 @@ async def main(port=80):
print("WS received raw:", data) print("WS received raw:", data)
# Forward raw JSON payload over ESPNow to configured peers # Forward raw JSON payload over ESPNow to configured peers
await esp.send(data) try:
await esp.send(data)
except Exception:
try:
await ws.send(json.dumps({"error": "ESP-NOW send failed"}))
except Exception:
pass
else: else:
break break

View File

@@ -1,3 +1,5 @@
import network
import aioespnow import aioespnow
@@ -20,11 +22,17 @@ class ESPNow:
if getattr(self, "_initialized", False): if getattr(self, "_initialized", False):
return return
# Initialize ESPNow once (no disk persistence) # ESP-NOW requires a WiFi interface to be active (STA or AP). Activate STA
# so ESP-NOW has an interface to use; we don't need to connect to an AP.
try:
sta = network.WLAN(network.STA_IF)
sta.active(True)
except Exception as e:
print("ESPNow: STA active failed:", e)
self._esp = aioespnow.AIOESPNow() self._esp = aioespnow.AIOESPNow()
self._esp.active(True) self._esp.active(True)
try: try:
self._esp.add_peer(b"\xff\xff\xff\xff\xff\xff") self._esp.add_peer(b"\xff\xff\xff\xff\xff\xff")
except Exception: except Exception:
@@ -56,6 +64,6 @@ class ESPNow:
try: try:
await self._esp.asend(b"\xff\xff\xff\xff\xff\xff", payload) await self._esp.asend(b"\xff\xff\xff\xff\xff\xff", payload)
except Exception as e: except Exception as e:
# Log send failures but don't crash the app
print("ESPNow.send error:", e) print("ESPNow.send error:", e)
raise

View File

@@ -80,44 +80,6 @@ document.addEventListener('DOMContentLoaded', () => {
} }
} }
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 = `
<h4>Connection Status: <span class="status-connected">Connected</span></h4>
<p><strong>SSID:</strong> ${status.ssid || 'N/A'}</p>
<p><strong>IP Address:</strong> ${status.ip || 'N/A'}</p>
<p><strong>Gateway:</strong> ${status.gateway || 'N/A'}</p>
<p><strong>Netmask:</strong> ${status.netmask || 'N/A'}</p>
<p><strong>DNS:</strong> ${status.dns || 'N/A'}</p>
`;
} else {
statusEl.innerHTML = `
<h4>Connection Status: <span class="status-disconnected">Disconnected</span></h4>
<p>Not connected to any WiFi network</p>
`;
}
} 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() { async function loadAPStatus() {
try { try {
const response = await fetch('/settings/wifi/ap'); const response = await fetch('/settings/wifi/ap');
@@ -149,8 +111,6 @@ document.addEventListener('DOMContentLoaded', () => {
settingsModal.classList.add('active'); settingsModal.classList.add('active');
// Load current WiFi status/config when opening // Load current WiFi status/config when opening
loadDeviceSettings(); loadDeviceSettings();
loadStationStatus();
loadStationCredentials();
loadAPStatus(); loadAPStatus();
}); });
} }
@@ -169,45 +129,6 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
} }
const stationForm = document.getElementById('station-form');
if (stationForm) {
stationForm.addEventListener('submit', async (e) => {
e.preventDefault();
const ssid = (document.getElementById('station-ssid').value || '').trim();
if (!ssid) {
showSettingsMessage('SSID is required', 'error');
return;
}
const formData = {
ssid,
password: document.getElementById('station-password').value || '',
ip: (document.getElementById('station-ip').value || '').trim() || null,
gateway: (document.getElementById('station-gateway').value || '').trim() || null,
};
try {
const response = await fetch('/settings/wifi/station', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
});
let result = {};
try {
result = await response.json();
} catch (_) {
result = { error: response.status === 400 ? 'Bad request (check SSID and connection)' : 'Request failed' };
}
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'); const deviceForm = document.getElementById('device-form');
if (deviceForm) { if (deviceForm) {
deviceForm.addEventListener('submit', async (e) => { deviceForm.addEventListener('submit', async (e) => {

View File

@@ -25,6 +25,18 @@ const getEspnowSocket = () => {
espnowPendingMessages = []; espnowPendingMessages = [];
}; };
espnowSocket.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data && data.error) {
console.error('ESP-NOW:', data.error);
alert('ESP-NOW send failed. ' + (data.error === 'ESP-NOW send failed' ? 'Check device WiFi/interface.' : data.error));
}
} catch (_) {
// Ignore non-JSON or non-error messages
}
};
espnowSocket.onclose = () => { espnowSocket.onclose = () => {
espnowSocketReady = false; espnowSocketReady = false;
espnowSocket = null; espnowSocket = null;

View File

@@ -794,9 +794,7 @@ header h1 {
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.25rem;
} } header h1 {
header h1 {
font-size: 1.1rem; font-size: 1.1rem;
} /* On mobile, hide header buttons; all actions (including Tabs) are in the Menu dropdown */ } /* On mobile, hide header buttons; all actions (including Tabs) are in the Menu dropdown */
.header-actions { .header-actions {
@@ -1047,9 +1045,7 @@ header h1 {
max-width: 900px; max-width: 900px;
max-height: 90vh; max-height: 90vh;
overflow-y: auto; overflow-y: auto;
} }#settings-modal .modal-content > p.muted-text {
#settings-modal .modal-content > p.muted-text {
margin-bottom: 1rem; margin-bottom: 1rem;
}#settings-modal .settings-section.ap-settings-section { }#settings-modal .settings-section.ap-settings-section {
margin-top: 1.5rem; margin-top: 1.5rem;

View File

@@ -250,7 +250,7 @@
<div id="settings-modal" class="modal"> <div id="settings-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
<h2>Device Settings</h2> <h2>Device Settings</h2>
<p class="muted-text">Configure WiFi and device settings.</p> <p class="muted-text">Configure WiFi Access Point and device settings.</p>
<div id="settings-message" class="message"></div> <div id="settings-message" class="message"></div>
@@ -269,48 +269,6 @@
</form> </form>
</div> </div>
<!-- WiFi Station Settings -->
<div class="settings-section">
<h3>WiFi Station (Client)</h3>
<div id="station-status" class="status-info">
<h4>Connection Status</h4>
<p>Loading...</p>
</div>
<form id="station-form">
<div class="form-group">
<label for="station-ssid">SSID (Network Name)</label>
<input type="text" id="station-ssid" name="ssid" placeholder="Enter WiFi network name" required>
<small>The name of the WiFi network to connect to</small>
</div>
<div class="form-group">
<label for="station-password">Password</label>
<input type="password" id="station-password" name="password" placeholder="Enter WiFi password">
<small>Leave empty for open networks</small>
</div>
<div class="form-row">
<div class="form-group">
<label for="station-ip">IP Address (Optional)</label>
<input type="text" id="station-ip" name="ip" placeholder="192.168.1.100">
<small>Static IP address (leave empty for DHCP)</small>
</div>
<div class="form-group">
<label for="station-gateway">Gateway (Optional)</label>
<input type="text" id="station-gateway" name="gateway" placeholder="192.168.1.1">
<small>Gateway/router IP address</small>
</div>
</div>
<div class="btn-group">
<button type="submit" class="btn btn-primary btn-full">Connect</button>
</div>
</form>
</div>
<!-- WiFi Access Point Settings --> <!-- WiFi Access Point Settings -->
<div class="settings-section ap-settings-section"> <div class="settings-section ap-settings-section">
<h3>WiFi Access Point</h3> <h3>WiFi Access Point</h3>

View File

@@ -170,53 +170,11 @@
<div class="settings-header"> <div class="settings-header">
<h1>Device Settings</h1> <h1>Device Settings</h1>
<p>Configure WiFi and device settings</p> <p>Configure WiFi Access Point settings</p>
</div> </div>
<div id="message" class="message"></div> <div id="message" class="message"></div>
<!-- WiFi Station Settings -->
<div class="settings-section">
<h2>WiFi Station (Client) Settings</h2>
<div id="station-status" class="status-info">
<h3>Connection Status</h3>
<p>Loading...</p>
</div>
<form id="station-form">
<div class="form-group">
<label for="station-ssid">SSID (Network Name)</label>
<input type="text" id="station-ssid" name="ssid" placeholder="Enter WiFi network name" required>
<small>The name of the WiFi network to connect to</small>
</div>
<div class="form-group">
<label for="station-password">Password</label>
<input type="password" id="station-password" name="password" placeholder="Enter WiFi password">
<small>Leave empty for open networks</small>
</div>
<div class="form-row">
<div class="form-group">
<label for="station-ip">IP Address (Optional)</label>
<input type="text" id="station-ip" name="ip" placeholder="192.168.1.100">
<small>Static IP address (leave empty for DHCP)</small>
</div>
<div class="form-group">
<label for="station-gateway">Gateway (Optional)</label>
<input type="text" id="station-gateway" name="gateway" placeholder="192.168.1.1">
<small>Gateway/router IP address</small>
</div>
</div>
<div class="btn-group">
<button type="submit" class="btn btn-primary btn-full">Connect</button>
</div>
</form>
</div>
<!-- WiFi Access Point Settings --> <!-- WiFi Access Point Settings -->
<div class="settings-section"> <div class="settings-section">
<h2>WiFi Access Point Settings</h2> <h2>WiFi Access Point Settings</h2>
@@ -264,47 +222,6 @@
}, 5000); }, 5000);
} }
// Load station status
async function loadStationStatus() {
try {
const response = await fetch('/settings/wifi/station');
const status = await response.json();
const statusEl = document.getElementById('station-status');
if (status.connected) {
statusEl.innerHTML = `
<h3>Connection Status: <span class="status-connected">Connected</span></h3>
<p><strong>SSID:</strong> ${status.ssid || 'N/A'}</p>
<p><strong>IP Address:</strong> ${status.ip || 'N/A'}</p>
<p><strong>Gateway:</strong> ${status.gateway || 'N/A'}</p>
<p><strong>Netmask:</strong> ${status.netmask || 'N/A'}</p>
<p><strong>DNS:</strong> ${status.dns || 'N/A'}</p>
`;
} else {
statusEl.innerHTML = `
<h3>Connection Status: <span class="status-disconnected">Disconnected</span></h3>
<p>Not connected to any WiFi network</p>
`;
}
} catch (error) {
console.error('Error loading station status:', error);
}
}
// Load saved station credentials
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);
}
}
// Load AP status and config // Load AP status and config
async function loadAPStatus() { async function loadAPStatus() {
try { try {
@@ -334,39 +251,6 @@
} }
} }
// Station form submission
document.getElementById('station-form').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) {
showMessage('WiFi station connected successfully!', 'success');
setTimeout(loadStationStatus, 1000);
} else {
showMessage(`Error: ${result.error || 'Failed to connect'}`, 'error');
}
} catch (error) {
showMessage(`Error: ${error.message}`, 'error');
}
});
// AP form submission // AP form submission
document.getElementById('ap-form').addEventListener('submit', async (e) => { document.getElementById('ap-form').addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
@@ -415,15 +299,10 @@
}); });
// Load all data on page load // Load all data on page load
loadStationStatus();
loadStationCredentials();
loadAPStatus(); loadAPStatus();
// Refresh status every 10 seconds // Refresh status every 10 seconds
setInterval(() => { setInterval(loadAPStatus, 10000);
loadStationStatus();
loadAPStatus();
}, 10000);
</script> </script>
</body> </body>
</html> </html>

View File

@@ -1,28 +1,4 @@
import network import network
from time import sleep
def connect(ssid, password, ip, gateway):
if ssid is None:
print("Missing ssid")
return None
if password is None:
password = ''
try:
sta_if = network.WLAN(network.STA_IF)
if ip is not None and gateway is not None:
sta_if.ifconfig((ip, '255.255.255.0', gateway, '1.1.1.1'))
if not sta_if.isconnected():
print('connecting to network...')
sta_if.active(True)
sta_if.connect(ssid, password)
sleep(0.1)
if sta_if.isconnected():
return sta_if.ifconfig()
return None
return sta_if.ifconfig()
except Exception as e:
print(f"Failed to connect to wifi {e}")
return None
def ap(ssid, password, channel=None): def ap(ssid, password, channel=None):
@@ -42,6 +18,7 @@ def get_mac():
ap_if = network.WLAN(network.AP_IF) ap_if = network.WLAN(network.AP_IF)
return ap_if.config('mac') return ap_if.config('mac')
def get_ap_config(): def get_ap_config():
"""Get current AP configuration.""" """Get current AP configuration."""
try: try:
@@ -63,38 +40,3 @@ def get_ap_config():
except Exception as e: except Exception as e:
print(f"Error getting AP config: {e}") print(f"Error getting AP config: {e}")
return None return None
def get_sta_status():
"""Get current station connection status."""
try:
sta_if = network.WLAN(network.STA_IF)
if sta_if.active():
if sta_if.isconnected():
config = sta_if.ifconfig()
return {
'connected': True,
'ssid': sta_if.config('essid'),
'ip': config[0] if config else None,
'gateway': config[2] if len(config) > 2 else None,
'netmask': config[1] if len(config) > 1 else None,
'dns': config[3] if len(config) > 3 else None
}
return {
'connected': False,
'ssid': None,
'ip': None,
'gateway': None,
'netmask': None,
'dns': None
}
return {
'connected': False,
'ssid': None,
'ip': None,
'gateway': None,
'netmask': None,
'dns': None
}
except Exception as e:
print(f"Error getting STA status: {e}")
return None