4 Commits

Author SHA1 Message Date
Chuck
3b763b613a fix(wifi): address four review findings in wifi_manager.py
IP parsing (line 476): use partition(':') so bare "ip/mask" lines
(no field-label prefix) are handled without IndexError; falls back to
the full string when no ':' is present before splitting on '/'.

AP-mode override comment (line 503): add one-line explanation above
the wifi_connected/ssid/ip_address clear so maintainers know why the
fields are reset while wlan0 reports as "connected".

Stale force-flag cleanup (__init__): remove a left-over
_FORCE_AP_FLAG_PATH from a prior crash on first instantiation per
process (guarded by class-level _startup_cleanup_done so the nmcli
AP-state check only runs once, not on every per-request instantiation).

Force-flag logging (enable_ap_mode): log at debug when force=True is
applied, log success at debug and failure with OSError details at
warning for both the hostapd and nmcli hotspot paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 15:08:15 -04:00
Chuck
f279980b44 fix(wifi): suppress false-positive Bandit B603/B607 on new nmcli calls
Both subprocess.run calls in the SSID connection lookup use fixed
arguments (no user input) or values derived from nmcli's own output —
not from user-controlled data. Add nosec B603 B607 annotations to
silence the Codacy/Bandit warnings, consistent with existing nosec
usage in the file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 14:58:36 -04:00
Chuck
6313b9c25f fix(wifi): strict bool parsing for force; nosec annotation parity
- api_v3.py: replace bool(...) coercion for force with strict check —
  only actual boolean True or strings "true"/"1" (case-insensitive)
  pass; "false", integers, and other strings are treated as False so
  the Ethernet/WiFi guards and _FORCE_AP_FLAG_PATH cannot be bypassed
  by accident
- wifi_manager.py: add nosec B108 annotation to _IP_FORWARD_SAVE_PATH
  to match the identical annotation already on _FORCE_AP_FLAG_PATH

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 14:31:00 -04:00
Chuck
d81156d53e fix(wifi): fix AP mode, captive portal, and WiFi connect flow
- Fix scan API returning 500: scan_networks() returns a tuple but the
  endpoint was iterating it directly; unpack with _was_cached
- Fix IP address display showing 'IP4.ADDRESS[1]:x.x.x.x': nmcli -t
  output includes the field label; split on ':' before '/'
- Add force parameter to enable_ap_mode() to bypass WiFi/Ethernet
  guards; expose via force JSON body field in the AP enable endpoint
- Fix daemon auto-disabling forced AP: add _FORCE_AP_FLAG_PATH flag
  file written on force-enable and checked in check_and_manage_ap_mode
  before auto-disabling; disable_ap_mode() clears it
- Fix wifi_connected false positive in AP mode: _get_status_nmcli()
  was reporting wlan0 as 'connected' when it was running as AP;
  override wifi_connected=False when _is_ap_mode_active() is True
- Fix AP verification failure on async NM activation: retry
  _get_ap_status_nmcli() up to 5 times with 2s delay instead of
  single immediate check
- Fix WiFi connect ignoring existing NM connections: nmcli does not
  support 802-11-wireless.ssid as a column in 'connection show';
  replace with NAME,TYPE list then per-connection SSID query via -g
  (fixes 'netplan generate failed' error on Trixie / netplan systems)
- Fix failsafe AP re-enable blocked by Ethernet: all recovery-path
  enable_ap_mode() calls in connect_to_network() now pass force=True

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 14:22:26 -04:00
4 changed files with 37 additions and 150 deletions

View File

@@ -185,19 +185,13 @@ class StateReconciliation:
message=f"Reconciliation failed: {str(e)}" message=f"Reconciliation failed: {str(e)}"
) )
# Top-level config keys that are NOT plugins. # Top-level config keys that are NOT plugins
# Includes both config.json structural keys and config_secrets.json top-level
# keys (load_config() deep-merges secrets in, so secrets keys appear here too).
_SYSTEM_CONFIG_KEYS = frozenset({ _SYSTEM_CONFIG_KEYS = frozenset({
'web_display_autostart', 'timezone', 'location', 'display', 'web_display_autostart', 'timezone', 'location', 'display',
'plugin_system', 'vegas_scroll_speed', 'vegas_separator_width', 'plugin_system', 'vegas_scroll_speed', 'vegas_separator_width',
'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order', 'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order',
'vegas_excluded_plugins', 'vegas_scroll_enabled', 'logging', 'vegas_excluded_plugins', 'vegas_scroll_enabled', 'logging',
'dim_schedule', 'network', 'system', 'schedule', 'dim_schedule', 'network', 'system', 'schedule',
# Multi-display sync config (config.json structural key)
'sync',
# Secrets file top-level keys (merged in by load_config)
'github', 'youtube',
}) })
def _get_config_state(self) -> Dict[str, Dict[str, Any]]: def _get_config_state(self) -> Dict[str, Dict[str, Any]]:

View File

@@ -2,10 +2,8 @@ from flask import Flask, request, redirect, url_for, jsonify, Response, send_fro
import json import json
import logging import logging
import os import os
import queue
import sys import sys
import subprocess import subprocess
import threading
import time import time
from pathlib import Path from pathlib import Path
from datetime import datetime, timedelta from datetime import datetime, timedelta
@@ -415,53 +413,13 @@ def add_security_headers(response):
return response return response
class _StreamBroadcaster: # SSE helper function
"""Fan-out broadcaster: one background generator thread pushes to all SSE clients. def sse_response(generator_func):
"""Helper to create SSE responses"""
This means N browser tabs share one generator instead of each running their own, def generate():
keeping PIL encodes / subprocess forks constant regardless of how many tabs are open. for data in generator_func():
""" yield f"data: {json.dumps(data)}\n\n"
return Response(generate(), mimetype='text/event-stream')
def __init__(self, generator_factory):
self._generator_factory = generator_factory
self._clients: set = set()
self._lock = threading.Lock()
self._thread: threading.Thread | None = None
def subscribe(self) -> queue.Queue:
q: queue.Queue = queue.Queue(maxsize=5)
with self._lock:
self._clients.add(q)
if not (self._thread and self._thread.is_alive()):
self._thread = threading.Thread(target=self._broadcast, daemon=True)
self._thread.start()
return q
def unsubscribe(self, q: queue.Queue) -> None:
with self._lock:
self._clients.discard(q)
def _broadcast(self):
for data in self._generator_factory():
with self._lock:
if not self._clients:
# No subscribers — exit so the thread doesn't spin indefinitely.
# subscribe() will restart it when a new client arrives.
break
for q in self._clients:
try:
q.put_nowait(data)
except queue.Full:
# Client is reading too slowly; drop the oldest item and
# deliver the latest so the queue never stalls the client.
try:
q.get_nowait()
except queue.Empty:
pass
try:
q.put_nowait(data)
except queue.Full:
pass
# System status generator for SSE # System status generator for SSE
def system_status_generator(): def system_status_generator():
@@ -638,50 +596,20 @@ def logs_generator():
time.sleep(5) # Update every 5 seconds (reduced frequency for better performance) time.sleep(5) # Update every 5 seconds (reduced frequency for better performance)
# One broadcaster per stream — shared across all SSE clients
_stats_broadcaster = _StreamBroadcaster(system_status_generator)
_display_broadcaster = _StreamBroadcaster(display_preview_generator)
_logs_broadcaster = _StreamBroadcaster(logs_generator)
def _sse_stream(broadcaster: _StreamBroadcaster) -> Response:
"""Return a streaming SSE response backed by a shared broadcaster."""
q = broadcaster.subscribe()
def generate():
try:
while True:
try:
data = q.get(timeout=30)
yield f"data: {json.dumps(data)}\n\n"
except queue.Empty:
# Send an SSE comment heartbeat to keep the connection alive
# through proxies that close idle connections.
yield ": heartbeat\n\n"
except GeneratorExit:
pass
finally:
broadcaster.unsubscribe(q)
return Response(generate(), mimetype='text/event-stream')
# SSE endpoints # SSE endpoints
@app.route('/api/v3/stream/stats') @app.route('/api/v3/stream/stats')
def stream_stats(): def stream_stats():
return _sse_stream(_stats_broadcaster) return sse_response(system_status_generator)
@app.route('/api/v3/stream/display') @app.route('/api/v3/stream/display')
def stream_display(): def stream_display():
return _sse_stream(_display_broadcaster) return sse_response(display_preview_generator)
@app.route('/api/v3/stream/logs') @app.route('/api/v3/stream/logs')
def stream_logs(): def stream_logs():
return _sse_stream(_logs_broadcaster) return sse_response(logs_generator)
# Exempt SSE streams from CSRF and apply a generous rate limit. # Exempt SSE streams from CSRF and add rate limiting
# SSE connections are long-lived HTTP requests, not repeated API calls, so the
# tight "20 per minute" default would be exhausted quickly on reconnects.
if csrf: if csrf:
csrf.exempt(stream_stats) csrf.exempt(stream_stats)
csrf.exempt(stream_display) csrf.exempt(stream_display)
@@ -689,9 +617,9 @@ if csrf:
# Note: api_v3 blueprint is exempted above after registration # Note: api_v3 blueprint is exempted above after registration
if limiter: if limiter:
limiter.limit("200 per minute")(stream_stats) limiter.limit("20 per minute")(stream_stats)
limiter.limit("200 per minute")(stream_display) limiter.limit("20 per minute")(stream_display)
limiter.limit("200 per minute")(stream_logs) limiter.limit("20 per minute")(stream_logs)
# Main route - redirect to v3 interface as default # Main route - redirect to v3 interface as default
@app.route('/') @app.route('/')

View File

@@ -1,4 +1,4 @@
/* global showNotification, updateSystemStats, updateDisplayPreview, htmx */ /* global showNotification, updateSystemStats, htmx */
// LED Matrix v3 JavaScript // LED Matrix v3 JavaScript
// Additional helpers for HTMX and Alpine.js integration // Additional helpers for HTMX and Alpine.js integration
@@ -51,8 +51,7 @@ document.body.addEventListener('htmx:afterRequest', function(event) {
} }
}); });
// SSE reconnection helper — closes and reopens both SSE streams, // SSE reconnection helper
// reattaching the open/error handlers defined in base.html.
window.reconnectSSE = function() { window.reconnectSSE = function() {
if (window.statsSource) { if (window.statsSource) {
window.statsSource.close(); window.statsSource.close();
@@ -61,18 +60,14 @@ window.reconnectSSE = function() {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
if (typeof updateSystemStats === 'function') updateSystemStats(data); if (typeof updateSystemStats === 'function') updateSystemStats(data);
}; };
if (window._statsOpenHandler) window.statsSource.addEventListener('open', window._statsOpenHandler);
if (window._statsErrorHandler) window.statsSource.addEventListener('error', window._statsErrorHandler);
} }
if (window.displaySource) { if (window.displaySource) {
window.displaySource.close(); window.displaySource.close();
window.displaySource = new EventSource('/api/v3/stream/display'); window.displaySource = new EventSource('/api/v3/stream/display');
window.displaySource.onmessage = function(event) { window.displaySource.onmessage = function() {
const data = JSON.parse(event.data); // Handle display updates
if (typeof updateDisplayPreview === 'function') updateDisplayPreview(data);
}; };
if (window._displayErrorHandler) window.displaySource.addEventListener('error', window._displayErrorHandler);
} }
}; };

View File

@@ -1370,64 +1370,34 @@
<!-- SSE connection for real-time updates --> <!-- SSE connection for real-time updates -->
<script> <script>
// Assign to window so reconnectSSE() in app.js can reach them. // Connect to SSE streams
window.statsSource = new EventSource('/api/v3/stream/stats'); const statsSource = new EventSource('/api/v3/stream/stats');
window.displaySource = new EventSource('/api/v3/stream/display'); const displaySource = new EventSource('/api/v3/stream/display');
window.statsSource.onmessage = function(event) { statsSource.onmessage = function(event) {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
updateSystemStats(data); updateSystemStats(data);
}; };
window.displaySource.onmessage = function(event) { displaySource.onmessage = function(event) {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
updateDisplayPreview(data); updateDisplayPreview(data);
}; };
function _setConnectionStatus(connected, reconnecting) { // Connection status
const el = document.getElementById('connection-status'); statsSource.addEventListener('open', function() {
if (!el) return; document.getElementById('connection-status').innerHTML = `
if (connected) { <div class="w-2 h-2 bg-green-500 rounded-full"></div>
el.innerHTML = ` <span class="text-gray-600">Connected</span>
<div class="w-2 h-2 bg-green-500 rounded-full"></div> `;
<span class="text-gray-600">Connected</span> });
`;
} else if (reconnecting) {
el.innerHTML = `
<div class="w-2 h-2 bg-yellow-500 rounded-full animate-pulse"></div>
<span class="text-gray-600">Reconnecting…</span>
`;
} else {
el.innerHTML = `
<div class="w-2 h-2 bg-red-500 rounded-full"></div>
<span class="text-gray-600" title="Connection lost — try refreshing the page">Disconnected</span>
`;
}
}
var _statsErrorCount = 0; statsSource.addEventListener('error', function() {
document.getElementById('connection-status').innerHTML = `
// Named on window so reconnectSSE() in app.js can reattach them after <div class="w-2 h-2 bg-red-500 rounded-full"></div>
// replacing the EventSource instances. <span class="text-gray-600">Disconnected</span>
window._statsOpenHandler = function() { `;
_statsErrorCount = 0; });
_setConnectionStatus(true, false);
};
window._statsErrorHandler = function() {
_statsErrorCount++;
// EventSource readyState 0 = CONNECTING (auto-retrying), 2 = CLOSED
var reconnecting = window.statsSource.readyState === EventSource.CONNECTING;
_setConnectionStatus(false, reconnecting && _statsErrorCount <= 3);
};
window._displayErrorHandler = function() {
// Display stream errors don't change the status badge but log to console
// so failures aren't completely silent.
console.warn('LEDMatrix: display preview stream error (readyState=' + window.displaySource.readyState + ')');
};
window.statsSource.addEventListener('open', window._statsOpenHandler);
window.statsSource.addEventListener('error', window._statsErrorHandler);
window.displaySource.addEventListener('error', window._displayErrorHandler);
function updateSystemStats(data) { function updateSystemStats(data) {
// Update CPU in header // Update CPU in header