1 Commits

Author SHA1 Message Date
Chuck
704e99f55c fix(web-ui): support multiple browser tabs via SSE broadcaster pattern
Each SSE stream (stats, display preview, logs) previously ran a separate
generator per connected client, so two open tabs meant double the PIL
image encodes per second and double the journalctl subprocesses. Under
load or on reconnect storms the tight "20 per minute" rate limit was
easily exhausted, silently breaking tabs without any user-facing
explanation.

- Replace per-client sse_response generators with _StreamBroadcaster:
  one background thread per stream type fans data to all subscribed
  client queues, keeping CPU/subprocess work constant regardless of
  how many tabs are open
- Add 30-second SSE heartbeat comments to keep idle connections alive
  through proxies
- Raise SSE rate limit from "20/min" to "200/min" to prevent reconnect
  storms from exhausting the limit
- Assign statsSource/displaySource to window.* so reconnectSSE() in
  app.js can actually reach them (was dead code due to const scoping)
- Add displaySource error handler so display preview failures are no
  longer completely silent
- Improve connection status badge: shows "Reconnecting…" on first few
  errors, "Disconnected" with tooltip hint after persistent failure
- Complete the empty displaySource.onmessage stub in reconnectSSE()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 17:16:47 -04:00
5 changed files with 163 additions and 134 deletions

View File

@@ -151,18 +151,6 @@ class WiFiManager:
f"hostapd: {self.has_hostapd}, dnsmasq: {self.has_dnsmasq}, " f"hostapd: {self.has_hostapd}, dnsmasq: {self.has_dnsmasq}, "
f"interface: {self._wifi_interface}, trixie: {self._is_trixie}") f"interface: {self._wifi_interface}, trixie: {self._is_trixie}")
# Once per process: remove a stale force-AP flag left by a prior crash.
# Guard with a class-level flag so the nmcli AP-state check only runs
# once even though WiFiManager is instantiated per-request.
if not WiFiManager._startup_cleanup_done:
WiFiManager._startup_cleanup_done = True
if self._FORCE_AP_FLAG_PATH.exists() and not self._is_ap_mode_active():
try:
self._FORCE_AP_FLAG_PATH.unlink(missing_ok=True)
logger.debug("Removed stale force-AP flag on startup (AP not active)")
except OSError as exc:
logger.warning(f"Could not remove stale force-AP flag: {exc}")
def _show_led_message(self, message: str, duration: int = 5): def _show_led_message(self, message: str, duration: int = 5):
""" """
Show a WiFi status message on the LED display. Show a WiFi status message on the LED display.
@@ -486,10 +474,7 @@ class WiFiManager:
if result.returncode == 0: if result.returncode == 0:
for line in result.stdout.strip().split('\n'): for line in result.stdout.strip().split('\n'):
if '/' in line: if '/' in line:
# nmcli -t output is "IP4.ADDRESS[1]:x.x.x.x/prefix"; ip_address = line.split('/')[0].strip()
# bare "x.x.x.x/prefix" is also accepted defensively.
_, sep, rest = line.partition(':')
ip_address = (rest if sep else line).split('/')[0].strip()
break break
# Final fallback: Get signal strength by matching SSID in WiFi list # Final fallback: Get signal strength by matching SSID in WiFi list
@@ -515,13 +500,6 @@ class WiFiManager:
# Check if AP mode is active # Check if AP mode is active
ap_active = self._is_ap_mode_active() ap_active = self._is_ap_mode_active()
# wlan0 shows as "connected" in AP mode; clear client-station fields so
# callers don't mistake the AP for an outbound WiFi connection.
if ap_active and wifi_connected:
wifi_connected = False
ssid = None
ip_address = None
logger.debug(f"{wlan_device} is in AP mode — overriding wifi_connected to False")
return WiFiStatus( return WiFiStatus(
connected=wifi_connected, connected=wifi_connected,
@@ -712,10 +690,6 @@ class WiFiManager:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_IP_FORWARD_SAVE_PATH = Path("/tmp/ledmatrix_ip_forward_saved") # nosec B108 - process-specific named file; device is single-user RPi _IP_FORWARD_SAVE_PATH = Path("/tmp/ledmatrix_ip_forward_saved") # nosec B108 - process-specific named file; device is single-user RPi
# Written when AP mode is manually force-enabled; prevents daemon auto-disable
_FORCE_AP_FLAG_PATH = Path("/tmp/ledmatrix_force_ap_active") # nosec B108 - process-specific named file; device is single-user RPi
# Ensures the startup stale-flag cleanup runs once per process, not per instantiation
_startup_cleanup_done: bool = False
def _validate_ap_config(self) -> Tuple[str, int]: def _validate_ap_config(self) -> Tuple[str, int]:
"""Return a sanitized (ssid, channel) pair from config, falling back to defaults.""" """Return a sanitized (ssid, channel) pair from config, falling back to defaults."""
@@ -1393,7 +1367,7 @@ class WiFiManager:
logger.error(f"Failed to restore original connection: {original_ssid}") logger.error(f"Failed to restore original connection: {original_ssid}")
# Trigger AP mode as last resort # Trigger AP mode as last resort
self._show_led_message("Enabling AP mode...", duration=5) self._show_led_message("Enabling AP mode...", duration=5)
ap_success, ap_msg = self.enable_ap_mode(force=True) ap_success, ap_msg = self.enable_ap_mode()
if ap_success: if ap_success:
logger.info("AP mode enabled as failsafe") logger.info("AP mode enabled as failsafe")
return False, "Connection failed and restoration failed. AP mode enabled." return False, "Connection failed and restoration failed. AP mode enabled."
@@ -1405,7 +1379,7 @@ class WiFiManager:
elif not success: elif not success:
logger.warning(f"Connection to {ssid} failed and no original connection to restore") logger.warning(f"Connection to {ssid} failed and no original connection to restore")
self._show_led_message("Enabling AP mode...", duration=5) self._show_led_message("Enabling AP mode...", duration=5)
ap_success, ap_msg = self.enable_ap_mode(force=True) ap_success, ap_msg = self.enable_ap_mode()
if ap_success: if ap_success:
logger.info("AP mode enabled as failsafe") logger.info("AP mode enabled as failsafe")
return False, "Connection failed. AP mode enabled." return False, "Connection failed. AP mode enabled."
@@ -1426,7 +1400,7 @@ class WiFiManager:
logger.error(f"Failed to restore after exception: {restore_error}") logger.error(f"Failed to restore after exception: {restore_error}")
# Last resort: enable AP mode # Last resort: enable AP mode
try: try:
self.enable_ap_mode(force=True) self.enable_ap_mode()
except Exception as ap_error: # nosec B110 - last-resort; do not re-raise, but log for debugging except Exception as ap_error: # nosec B110 - last-resort; do not re-raise, but log for debugging
logger.error("Last-resort AP mode enable failed in recovery path: %s", ap_error, exc_info=True) logger.error("Last-resort AP mode enable failed in recovery path: %s", ap_error, exc_info=True)
return False, str(e) return False, str(e)
@@ -1490,29 +1464,26 @@ class WiFiManager:
# Show LED message # Show LED message
self._show_led_message(f"Connecting to {ssid}...", duration=10) self._show_led_message(f"Connecting to {ssid}...", duration=10)
# Find existing NM connection for this SSID. # First, check if connection already exists and try to activate it
# 802-11-wireless.ssid is not a valid column in 'nmcli connection show', # NetworkManager connection names might not match SSID exactly, so search by SSID
# so list all wifi connections then query each one's SSID individually. check_result = subprocess.run(
list_result = subprocess.run( # nosec B603 B607 - fixed args, no user input ["nmcli", "-t", "-f", "NAME,802-11-wireless.ssid", "connection", "show"],
["nmcli", "-t", "-f", "NAME,TYPE", "connection", "show"], capture_output=True,
capture_output=True, text=True, timeout=5 text=True,
timeout=5
) )
existing_conn_name = None existing_conn_name = None
if list_result.returncode == 0: if check_result.returncode == 0:
for line in list_result.stdout.strip().split('\n'): for line in check_result.stdout.strip().split('\n'):
if ':' not in line: if ':' in line:
continue parts = line.split(':')
parts = line.split(':') if len(parts) >= 2:
if len(parts) < 2 or parts[1].strip() != '802-11-wireless': conn_name = parts[0].strip()
continue conn_ssid = parts[1].strip() if len(parts) > 1 else ""
conn_name = parts[0].strip() if conn_ssid == ssid:
ssid_r = subprocess.run( # nosec B603 B607 - conn_name from nmcli output, not user input existing_conn_name = conn_name
["nmcli", "-g", "802-11-wireless.ssid", "connection", "show", conn_name], break
capture_output=True, text=True, timeout=5
)
if ssid_r.returncode == 0 and ssid_r.stdout.strip() == ssid:
existing_conn_name = conn_name
break
# Also try direct lookup by SSID (in case connection name matches SSID) # Also try direct lookup by SSID (in case connection name matches SSID)
if not existing_conn_name: if not existing_conn_name:
@@ -1884,7 +1855,7 @@ class WiFiManager:
logger.warning(f"Failed to enable WiFi radio after {max_retries} attempts") logger.warning(f"Failed to enable WiFi radio after {max_retries} attempts")
return False return False
def enable_ap_mode(self, force: bool = False) -> Tuple[bool, str]: def enable_ap_mode(self) -> Tuple[bool, str]:
""" """
Enable access point mode Enable access point mode
@@ -1906,29 +1877,20 @@ class WiFiManager:
if not self._ensure_wifi_radio_enabled(): if not self._ensure_wifi_radio_enabled():
return False, "WiFi radio is disabled and could not be enabled" return False, "WiFi radio is disabled and could not be enabled"
# Check if WiFi is connected (skip when force=True) # Check if WiFi is connected
status = self.get_wifi_status() status = self.get_wifi_status()
if not force and status.connected: if status.connected:
return False, "Cannot enable AP mode while WiFi is connected" return False, "Cannot enable AP mode while WiFi is connected"
# Check if Ethernet is connected (skip when force=True) # Check if Ethernet is connected
if not force and self._is_ethernet_connected(): if self._is_ethernet_connected():
return False, "Cannot enable AP mode while Ethernet is connected" return False, "Cannot enable AP mode while Ethernet is connected"
if force:
logger.debug(f"enable_ap_mode: force=True — WiFi/Ethernet guards bypassed; will create {self._FORCE_AP_FLAG_PATH}")
# Try hostapd/dnsmasq first (captive portal mode) # Try hostapd/dnsmasq first (captive portal mode)
if self.has_hostapd and self.has_dnsmasq: if self.has_hostapd and self.has_dnsmasq:
result = self._enable_ap_mode_hostapd() result = self._enable_ap_mode_hostapd()
if result[0]: if result[0]:
self._ap_enabled_at = time.time() self._ap_enabled_at = time.time()
if force:
try:
self._FORCE_AP_FLAG_PATH.touch()
logger.debug(f"Force-AP flag created: {self._FORCE_AP_FLAG_PATH}")
except OSError as exc:
logger.warning(f"Failed to create force-AP flag {self._FORCE_AP_FLAG_PATH}: {exc}")
return result return result
# Fallback to nmcli hotspot (simpler, no captive portal) # Fallback to nmcli hotspot (simpler, no captive portal)
@@ -1938,12 +1900,6 @@ class WiFiManager:
result = self._enable_ap_mode_nmcli_hotspot() result = self._enable_ap_mode_nmcli_hotspot()
if result[0]: if result[0]:
self._ap_enabled_at = time.time() self._ap_enabled_at = time.time()
if force:
try:
self._FORCE_AP_FLAG_PATH.touch()
logger.debug(f"Force-AP flag created: {self._FORCE_AP_FLAG_PATH}")
except OSError as exc:
logger.warning(f"Failed to create force-AP flag {self._FORCE_AP_FLAG_PATH}: {exc}")
return result return result
return False, "No WiFi tools available (nmcli, hostapd, or dnsmasq required)" return False, "No WiFi tools available (nmcli, hostapd, or dnsmasq required)"
@@ -2135,14 +2091,8 @@ class WiFiManager:
self._clear_led_message() self._clear_led_message()
return False, "AP started but captive-portal redirect setup failed" return False, "AP started but captive-portal redirect setup failed"
# Verify the AP is actually running (retry up to 5x with 2s delay for NM async activation) # Verify the AP is actually running
status = {} status = self._get_ap_status_nmcli()
for _attempt in range(5):
status = self._get_ap_status_nmcli()
if status.get('active'):
break
logger.debug(f"AP verification attempt {_attempt + 1}/5 not yet active, waiting 2s")
time.sleep(2)
if status.get('active'): if status.get('active'):
ip = status.get('ip', '192.168.4.1') ip = status.get('ip', '192.168.4.1')
logger.info(f"AP mode confirmed active at {ip} (open network, no password)") logger.info(f"AP mode confirmed active at {ip} (open network, no password)")
@@ -2340,7 +2290,6 @@ class WiFiManager:
logger.warning("WiFi radio may be disabled after nmcli AP cleanup") logger.warning("WiFi radio may be disabled after nmcli AP cleanup")
self._ap_enabled_at = None self._ap_enabled_at = None
self._FORCE_AP_FLAG_PATH.unlink(missing_ok=True)
logger.info("AP mode disabled successfully") logger.info("AP mode disabled successfully")
return True, "AP mode disabled" return True, "AP mode disabled"
except Exception as e: except Exception as e:
@@ -2529,29 +2478,22 @@ address=/detectportal.firefox.com/192.168.4.1
else: else:
logger.warning(f"Failed to enable AP mode: {message}") logger.warning(f"Failed to enable AP mode: {message}")
elif not should_have_ap and ap_active: elif not should_have_ap and ap_active:
# Should not have AP but do - check if it was manually force-enabled # Should not have AP but do - disable AP mode
force_active = self._FORCE_AP_FLAG_PATH.exists() # Always disable if WiFi or Ethernet connects, regardless of auto_enable setting
if status.connected: if status.connected or ethernet_connected:
# WiFi connected: always disable AP (user successfully configured WiFi)
success, message = self.disable_ap_mode() success, message = self.disable_ap_mode()
if success: if success:
logger.info("Auto-disabled AP mode (WiFi connected)") if status.connected:
self._disconnected_checks = 0 logger.info("Auto-disabled AP mode (WiFi connected)")
elif ethernet_connected:
logger.info("Auto-disabled AP mode (Ethernet connected)")
self._disconnected_checks = 0 # Reset counter
return True return True
else: else:
logger.warning(f"Failed to auto-disable AP mode: {message}") logger.warning(f"Failed to auto-disable AP mode: {message}")
elif ethernet_connected and not force_active:
# Ethernet connected, AP not manually forced: auto-disable
success, message = self.disable_ap_mode()
if success:
logger.info("Auto-disabled AP mode (Ethernet connected)")
self._disconnected_checks = 0
return True
else:
logger.warning(f"Failed to auto-disable AP mode: {message}")
elif ethernet_connected and force_active:
logger.debug("AP mode is force-active; Ethernet connected but auto-disable suppressed")
elif not auto_enable: elif not auto_enable:
# AP is active but auto_enable is disabled - this means it was manually enabled
# Don't disable it automatically, let it stay active
logger.debug("AP mode is active (manually enabled), keeping active") logger.debug("AP mode is active (manually enabled), keeping active")
# Idle-timeout check: disable AP if no client has connected within the window. # Idle-timeout check: disable AP if no client has connected within the window.

View File

@@ -2,8 +2,10 @@ 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
@@ -413,13 +415,44 @@ def add_security_headers(response):
return response return response
# SSE helper function class _StreamBroadcaster:
def sse_response(generator_func): """Fan-out broadcaster: one background generator thread pushes to all SSE clients.
"""Helper to create SSE responses"""
def generate(): This means N browser tabs share one generator instead of each running their own,
for data in generator_func(): keeping PIL encodes / subprocess forks constant regardless of how many tabs are open.
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:
continue
dead = set()
for q in self._clients:
try:
q.put_nowait(data)
except queue.Full:
dead.add(q)
self._clients -= dead
# System status generator for SSE # System status generator for SSE
def system_status_generator(): def system_status_generator():
@@ -596,20 +629,50 @@ 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_response(system_status_generator) return _sse_stream(_stats_broadcaster)
@app.route('/api/v3/stream/display') @app.route('/api/v3/stream/display')
def stream_display(): def stream_display():
return sse_response(display_preview_generator) return _sse_stream(_display_broadcaster)
@app.route('/api/v3/stream/logs') @app.route('/api/v3/stream/logs')
def stream_logs(): def stream_logs():
return sse_response(logs_generator) return _sse_stream(_logs_broadcaster)
# Exempt SSE streams from CSRF and add rate limiting # Exempt SSE streams from CSRF and apply a generous rate limit.
# 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)
@@ -617,9 +680,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("20 per minute")(stream_stats) limiter.limit("200 per minute")(stream_stats)
limiter.limit("20 per minute")(stream_display) limiter.limit("200 per minute")(stream_display)
limiter.limit("20 per minute")(stream_logs) limiter.limit("200 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

@@ -6542,7 +6542,7 @@ def scan_wifi_networks():
ap_was_active = wifi_manager._is_ap_mode_active() ap_was_active = wifi_manager._is_ap_mode_active()
# Perform the scan (this will handle AP mode disabling/enabling internally) # Perform the scan (this will handle AP mode disabling/enabling internally)
networks, _was_cached = wifi_manager.scan_networks() networks = wifi_manager.scan_networks()
# Convert to dict format # Convert to dict format
networks_data = [ networks_data = [
@@ -6680,9 +6680,7 @@ def enable_ap_mode():
from src.wifi_manager import WiFiManager from src.wifi_manager import WiFiManager
wifi_manager = WiFiManager() wifi_manager = WiFiManager()
_force_raw = (request.get_json(silent=True) or {}).get('force', False) success, message = wifi_manager.enable_ap_mode()
force = _force_raw is True or (isinstance(_force_raw, str) and _force_raw.lower() in ('true', '1'))
success, message = wifi_manager.enable_ap_mode(force=force)
if success: if success:
return jsonify({ return jsonify({

View File

@@ -51,7 +51,7 @@ document.body.addEventListener('htmx:afterRequest', function(event) {
} }
}); });
// SSE reconnection helper // SSE reconnection helper — closes and reopens both SSE streams.
window.reconnectSSE = function() { window.reconnectSSE = function() {
if (window.statsSource) { if (window.statsSource) {
window.statsSource.close(); window.statsSource.close();
@@ -65,8 +65,9 @@ window.reconnectSSE = function() {
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() { window.displaySource.onmessage = function(event) {
// Handle display updates const data = JSON.parse(event.data);
if (typeof updateDisplayPreview === 'function') updateDisplayPreview(data);
}; };
} }
}; };

View File

@@ -1370,33 +1370,58 @@
<!-- SSE connection for real-time updates --> <!-- SSE connection for real-time updates -->
<script> <script>
// Connect to SSE streams // Assign to window so reconnectSSE() in app.js can reach them.
const statsSource = new EventSource('/api/v3/stream/stats'); window.statsSource = new EventSource('/api/v3/stream/stats');
const displaySource = new EventSource('/api/v3/stream/display'); window.displaySource = new EventSource('/api/v3/stream/display');
statsSource.onmessage = function(event) { window.statsSource.onmessage = function(event) {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
updateSystemStats(data); updateSystemStats(data);
}; };
displaySource.onmessage = function(event) { window.displaySource.onmessage = function(event) {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
updateDisplayPreview(data); updateDisplayPreview(data);
}; };
// Connection status function _setConnectionStatus(connected, reconnecting) {
statsSource.addEventListener('open', function() { const el = document.getElementById('connection-status');
document.getElementById('connection-status').innerHTML = ` if (!el) return;
<div class="w-2 h-2 bg-green-500 rounded-full"></div> if (connected) {
<span class="text-gray-600">Connected</span> el.innerHTML = `
`; <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;
window.statsSource.addEventListener('open', function() {
_statsErrorCount = 0;
_setConnectionStatus(true, false);
}); });
statsSource.addEventListener('error', function() { window.statsSource.addEventListener('error', function() {
document.getElementById('connection-status').innerHTML = ` _statsErrorCount++;
<div class="w-2 h-2 bg-red-500 rounded-full"></div> // EventSource readyState 0 = CONNECTING (auto-retrying), 2 = CLOSED
<span class="text-gray-600">Disconnected</span> var reconnecting = window.statsSource.readyState === EventSource.CONNECTING;
`; _setConnectionStatus(false, reconnecting && _statsErrorCount <= 3);
});
window.displaySource.addEventListener('error', 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 + ')');
}); });
function updateSystemStats(data) { function updateSystemStats(data) {