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]
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"

View File

@@ -128,6 +128,11 @@ async def run_local():
"""Serve the settings page."""
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
@app.route("/static/<path:path>")
def static_handler(request, path):

View File

@@ -179,14 +179,20 @@ async def send_presets(request, session):
batch = test_batch
last_msg = test_msg
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)
messages_sent += 1
batch = {name: preset_obj}
last_msg = build_message(presets=batch, save=save_flag, default=default_id)
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)
messages_sent += 1

View File

@@ -13,51 +13,6 @@ async def get_settings(request):
# 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):
"""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')
async def get_ap_config(request):
"""Get Access Point configuration."""
@@ -106,15 +61,6 @@ async def configure_ap(request):
except Exception as e:
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')
async def update_settings(request):
"""Update general settings."""

View File

@@ -9,7 +9,6 @@ from microdot.session import Session
from settings import Settings
import aioespnow
import network
import controllers.preset as preset
import controllers.profile as profile
import controllers.group as group
@@ -27,9 +26,6 @@ async def main(port=80):
print(settings)
print("Starting")
sta = network.WLAN(network.STA_IF)
sta.active(True)
# Initialize ESPNow singleton (config + peers)
esp = ESPNow()
@@ -103,7 +99,13 @@ async def main(port=80):
print("WS received raw:", data)
# 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:
break

View File

@@ -1,3 +1,5 @@
import network
import aioespnow
@@ -20,11 +22,17 @@ class ESPNow:
if getattr(self, "_initialized", False):
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.active(True)
try:
self._esp.add_peer(b"\xff\xff\xff\xff\xff\xff")
except Exception:
@@ -56,6 +64,6 @@ class ESPNow:
try:
await self._esp.asend(b"\xff\xff\xff\xff\xff\xff", payload)
except Exception as e:
# Log send failures but don't crash the app
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() {
try {
const response = await fetch('/settings/wifi/ap');
@@ -149,8 +111,6 @@ document.addEventListener('DOMContentLoaded', () => {
settingsModal.classList.add('active');
// Load current WiFi status/config when opening
loadDeviceSettings();
loadStationStatus();
loadStationCredentials();
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');
if (deviceForm) {
deviceForm.addEventListener('submit', async (e) => {

View File

@@ -25,6 +25,18 @@ const getEspnowSocket = () => {
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 = () => {
espnowSocketReady = false;
espnowSocket = null;

View File

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

View File

@@ -250,7 +250,7 @@
<div id="settings-modal" class="modal">
<div class="modal-content">
<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>
@@ -269,48 +269,6 @@
</form>
</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 -->
<div class="settings-section ap-settings-section">
<h3>WiFi Access Point</h3>

View File

@@ -170,53 +170,11 @@
<div class="settings-header">
<h1>Device Settings</h1>
<p>Configure WiFi and device settings</p>
<p>Configure WiFi Access Point settings</p>
</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 -->
<div class="settings-section">
<h2>WiFi Access Point Settings</h2>
@@ -264,47 +222,6 @@
}, 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
async function loadAPStatus() {
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
document.getElementById('ap-form').addEventListener('submit', async (e) => {
e.preventDefault();
@@ -415,15 +299,10 @@
});
// Load all data on page load
loadStationStatus();
loadStationCredentials();
loadAPStatus();
// Refresh status every 10 seconds
setInterval(() => {
loadStationStatus();
loadAPStatus();
}, 10000);
setInterval(loadAPStatus, 10000);
</script>
</body>
</html>

View File

@@ -1,28 +1,4 @@
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):
@@ -42,6 +18,7 @@ def get_mac():
ap_if = network.WLAN(network.AP_IF)
return ap_if.config('mac')
def get_ap_config():
"""Get current AP configuration."""
try:
@@ -63,38 +40,3 @@ def get_ap_config():
except Exception as e:
print(f"Error getting AP config: {e}")
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