mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-05-26 05:53:33 +00:00
Compare commits
3 Commits
fix/wifi-a
...
fix/log-vi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c15af5bb2 | ||
|
|
0c7d03a476 | ||
|
|
321a87f734 |
@@ -151,6 +151,18 @@ 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.
|
||||||
@@ -474,7 +486,10 @@ 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:
|
||||||
ip_address = line.split('/')[0].strip()
|
# nmcli -t output is "IP4.ADDRESS[1]:x.x.x.x/prefix";
|
||||||
|
# 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
|
||||||
@@ -500,6 +515,13 @@ 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,
|
||||||
@@ -690,6 +712,10 @@ 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."""
|
||||||
@@ -1367,7 +1393,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()
|
ap_success, ap_msg = self.enable_ap_mode(force=True)
|
||||||
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."
|
||||||
@@ -1379,7 +1405,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()
|
ap_success, ap_msg = self.enable_ap_mode(force=True)
|
||||||
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."
|
||||||
@@ -1400,7 +1426,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()
|
self.enable_ap_mode(force=True)
|
||||||
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)
|
||||||
@@ -1464,24 +1490,27 @@ 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)
|
||||||
|
|
||||||
# First, check if connection already exists and try to activate it
|
# Find existing NM connection for this SSID.
|
||||||
# NetworkManager connection names might not match SSID exactly, so search by SSID
|
# 802-11-wireless.ssid is not a valid column in 'nmcli connection show',
|
||||||
check_result = subprocess.run(
|
# so list all wifi connections then query each one's SSID individually.
|
||||||
["nmcli", "-t", "-f", "NAME,802-11-wireless.ssid", "connection", "show"],
|
list_result = subprocess.run( # nosec B603 B607 - fixed args, no user input
|
||||||
capture_output=True,
|
["nmcli", "-t", "-f", "NAME,TYPE", "connection", "show"],
|
||||||
text=True,
|
capture_output=True, text=True, timeout=5
|
||||||
timeout=5
|
|
||||||
)
|
)
|
||||||
|
|
||||||
existing_conn_name = None
|
existing_conn_name = None
|
||||||
if check_result.returncode == 0:
|
if list_result.returncode == 0:
|
||||||
for line in check_result.stdout.strip().split('\n'):
|
for line in list_result.stdout.strip().split('\n'):
|
||||||
if ':' in line:
|
if ':' not in line:
|
||||||
|
continue
|
||||||
parts = line.split(':')
|
parts = line.split(':')
|
||||||
if len(parts) >= 2:
|
if len(parts) < 2 or parts[1].strip() != '802-11-wireless':
|
||||||
|
continue
|
||||||
conn_name = parts[0].strip()
|
conn_name = parts[0].strip()
|
||||||
conn_ssid = parts[1].strip() if len(parts) > 1 else ""
|
ssid_r = subprocess.run( # nosec B603 B607 - conn_name from nmcli output, not user input
|
||||||
if conn_ssid == ssid:
|
["nmcli", "-g", "802-11-wireless.ssid", "connection", "show", conn_name],
|
||||||
|
capture_output=True, text=True, timeout=5
|
||||||
|
)
|
||||||
|
if ssid_r.returncode == 0 and ssid_r.stdout.strip() == ssid:
|
||||||
existing_conn_name = conn_name
|
existing_conn_name = conn_name
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -1855,7 +1884,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) -> Tuple[bool, str]:
|
def enable_ap_mode(self, force: bool = False) -> Tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
Enable access point mode
|
Enable access point mode
|
||||||
|
|
||||||
@@ -1877,20 +1906,29 @@ 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
|
# Check if WiFi is connected (skip when force=True)
|
||||||
status = self.get_wifi_status()
|
status = self.get_wifi_status()
|
||||||
if status.connected:
|
if not force and 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
|
# Check if Ethernet is connected (skip when force=True)
|
||||||
if self._is_ethernet_connected():
|
if not force and 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)
|
||||||
@@ -1900,6 +1938,12 @@ 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)"
|
||||||
@@ -2091,8 +2135,14 @@ 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
|
# Verify the AP is actually running (retry up to 5x with 2s delay for NM async activation)
|
||||||
|
status = {}
|
||||||
|
for _attempt in range(5):
|
||||||
status = self._get_ap_status_nmcli()
|
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)")
|
||||||
@@ -2290,6 +2340,7 @@ 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:
|
||||||
@@ -2478,22 +2529,29 @@ 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 - disable AP mode
|
# Should not have AP but do - check if it was manually force-enabled
|
||||||
# Always disable if WiFi or Ethernet connects, regardless of auto_enable setting
|
force_active = self._FORCE_AP_FLAG_PATH.exists()
|
||||||
if status.connected or ethernet_connected:
|
if status.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:
|
||||||
if status.connected:
|
|
||||||
logger.info("Auto-disabled AP mode (WiFi connected)")
|
logger.info("Auto-disabled AP mode (WiFi connected)")
|
||||||
elif ethernet_connected:
|
self._disconnected_checks = 0
|
||||||
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.
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ 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 shutil
|
||||||
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
|
||||||
@@ -22,6 +25,9 @@ from src.plugin_system.state_manager import PluginStateManager
|
|||||||
from src.plugin_system.operation_history import OperationHistory
|
from src.plugin_system.operation_history import OperationHistory
|
||||||
from src.plugin_system.health_monitor import PluginHealthMonitor
|
from src.plugin_system.health_monitor import PluginHealthMonitor
|
||||||
|
|
||||||
|
_JOURNALCTL = shutil.which('journalctl')
|
||||||
|
_SYSTEMCTL = shutil.which('systemctl')
|
||||||
|
|
||||||
# Create Flask app
|
# Create Flask app
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.secret_key = os.urandom(24)
|
app.secret_key = os.urandom(24)
|
||||||
@@ -413,13 +419,53 @@ 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:
|
||||||
|
# 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():
|
||||||
@@ -450,12 +496,13 @@ def system_status_generator():
|
|||||||
# Check if display service is running (cached to avoid per-client subprocess forks)
|
# Check if display service is running (cached to avoid per-client subprocess forks)
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if (now - _ledmatrix_service_cache['timestamp']) >= _LEDMATRIX_SERVICE_CACHE_TTL:
|
if (now - _ledmatrix_service_cache['timestamp']) >= _LEDMATRIX_SERVICE_CACHE_TTL:
|
||||||
|
if _SYSTEMCTL:
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(['systemctl', 'is-active', 'ledmatrix'],
|
result = subprocess.run([_SYSTEMCTL, 'is-active', 'ledmatrix'],
|
||||||
capture_output=True, text=True, timeout=2)
|
capture_output=True, text=True, timeout=2)
|
||||||
_ledmatrix_service_cache['active'] = result.stdout.strip() == 'active'
|
_ledmatrix_service_cache['active'] = result.stdout.strip() == 'active'
|
||||||
except (subprocess.SubprocessError, OSError):
|
except (subprocess.SubprocessError, OSError) as e:
|
||||||
pass
|
app.logger.warning("systemctl status check failed: %s", e)
|
||||||
_ledmatrix_service_cache['timestamp'] = now
|
_ledmatrix_service_cache['timestamp'] = now
|
||||||
service_active = _ledmatrix_service_cache['active']
|
service_active = _ledmatrix_service_cache['active']
|
||||||
|
|
||||||
@@ -547,8 +594,13 @@ def logs_generator():
|
|||||||
# Get recent logs from journalctl (simplified version)
|
# Get recent logs from journalctl (simplified version)
|
||||||
# Note: User should be in systemd-journal group to read logs without sudo
|
# Note: User should be in systemd-journal group to read logs without sudo
|
||||||
try:
|
try:
|
||||||
|
if not _JOURNALCTL:
|
||||||
|
yield {'timestamp': time.time(), 'logs': 'journalctl not found; cannot read logs'}
|
||||||
|
time.sleep(60)
|
||||||
|
continue
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
['journalctl', '-u', 'ledmatrix.service', '-n', '50', '--no-pager'],
|
[_JOURNALCTL, '-u', 'ledmatrix.service', '-u', 'ledmatrix-web.service',
|
||||||
|
'-n', '50', '--no-pager', '--output=short-iso'],
|
||||||
capture_output=True, text=True, timeout=5
|
capture_output=True, text=True, timeout=5
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -564,7 +616,7 @@ def logs_generator():
|
|||||||
# No logs available
|
# No logs available
|
||||||
logs_data = {
|
logs_data = {
|
||||||
'timestamp': time.time(),
|
'timestamp': time.time(),
|
||||||
'logs': 'No logs available from ledmatrix service'
|
'logs': 'No logs available from ledmatrix or ledmatrix-web service'
|
||||||
}
|
}
|
||||||
yield logs_data
|
yield logs_data
|
||||||
else:
|
else:
|
||||||
@@ -596,20 +648,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 +699,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('/')
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import os
|
|||||||
import re
|
import re
|
||||||
import stat
|
import stat
|
||||||
import sys
|
import sys
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
@@ -25,6 +26,9 @@ from src.web_interface.validators import (
|
|||||||
)
|
)
|
||||||
from src.error_aggregator import get_error_aggregator
|
from src.error_aggregator import get_error_aggregator
|
||||||
|
|
||||||
|
_SUDO = shutil.which('sudo')
|
||||||
|
_JOURNALCTL = shutil.which('journalctl')
|
||||||
|
|
||||||
# Will be initialized when blueprint is registered
|
# Will be initialized when blueprint is registered
|
||||||
config_manager = None
|
config_manager = None
|
||||||
plugin_manager = None
|
plugin_manager = None
|
||||||
@@ -1456,31 +1460,41 @@ def execute_system_action():
|
|||||||
if mode:
|
if mode:
|
||||||
# For on-demand modes, we would need to integrate with the display controller
|
# For on-demand modes, we would need to integrate with the display controller
|
||||||
# For now, just start the display service
|
# For now, just start the display service
|
||||||
|
try:
|
||||||
result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix'],
|
result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix'],
|
||||||
capture_output=True, text=True)
|
capture_output=True, text=True, timeout=10)
|
||||||
|
except subprocess.TimeoutExpired as e:
|
||||||
|
logger.error("start_display (%s) timed out: %s", mode, e)
|
||||||
|
return jsonify({'status': 'error', 'message': 'Command timed out', 'returncode': -1, 'stderr': 'timeout'})
|
||||||
logger.info("start_display (%s) returned code %d", mode, result.returncode)
|
logger.info("start_display (%s) returned code %d", mode, result.returncode)
|
||||||
return jsonify({
|
if result.returncode != 0 and result.stderr:
|
||||||
|
logger.error("start_display (%s) stderr: %s", mode, result.stderr.strip())
|
||||||
|
resp = {
|
||||||
'status': 'success' if result.returncode == 0 else 'error',
|
'status': 'success' if result.returncode == 0 else 'error',
|
||||||
'message': 'Display started' if result.returncode == 0 else 'Failed to start display',
|
'message': 'Display started' if result.returncode == 0 else 'Failed to start display',
|
||||||
})
|
}
|
||||||
|
if result.returncode != 0:
|
||||||
|
resp['returncode'] = result.returncode
|
||||||
|
resp['stderr'] = result.stderr.strip()
|
||||||
|
return jsonify(resp)
|
||||||
else:
|
else:
|
||||||
result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix'],
|
result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix'],
|
||||||
capture_output=True, text=True)
|
capture_output=True, text=True, timeout=10)
|
||||||
elif action == 'stop_display':
|
elif action == 'stop_display':
|
||||||
result = subprocess.run(['sudo', 'systemctl', 'stop', 'ledmatrix'],
|
result = subprocess.run(['sudo', 'systemctl', 'stop', 'ledmatrix'],
|
||||||
capture_output=True, text=True)
|
capture_output=True, text=True, timeout=10)
|
||||||
elif action == 'enable_autostart':
|
elif action == 'enable_autostart':
|
||||||
result = subprocess.run(['sudo', 'systemctl', 'enable', 'ledmatrix'],
|
result = subprocess.run(['sudo', 'systemctl', 'enable', 'ledmatrix'],
|
||||||
capture_output=True, text=True)
|
capture_output=True, text=True, timeout=10)
|
||||||
elif action == 'disable_autostart':
|
elif action == 'disable_autostart':
|
||||||
result = subprocess.run(['sudo', 'systemctl', 'disable', 'ledmatrix'],
|
result = subprocess.run(['sudo', 'systemctl', 'disable', 'ledmatrix'],
|
||||||
capture_output=True, text=True)
|
capture_output=True, text=True, timeout=10)
|
||||||
elif action == 'reboot_system':
|
elif action == 'reboot_system':
|
||||||
result = subprocess.run(['sudo', 'reboot'],
|
result = subprocess.run(['sudo', 'reboot'],
|
||||||
capture_output=True, text=True)
|
capture_output=True, text=True, timeout=10)
|
||||||
elif action == 'shutdown_system':
|
elif action == 'shutdown_system':
|
||||||
result = subprocess.run(['sudo', 'poweroff'],
|
result = subprocess.run(['sudo', 'poweroff'],
|
||||||
capture_output=True, text=True)
|
capture_output=True, text=True, timeout=10)
|
||||||
elif action == 'git_pull':
|
elif action == 'git_pull':
|
||||||
# Use PROJECT_ROOT instead of hardcoded path
|
# Use PROJECT_ROOT instead of hardcoded path
|
||||||
project_dir = str(PROJECT_ROOT)
|
project_dir = str(PROJECT_ROOT)
|
||||||
@@ -1555,20 +1569,29 @@ def execute_system_action():
|
|||||||
})
|
})
|
||||||
elif action == 'restart_display_service':
|
elif action == 'restart_display_service':
|
||||||
result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix'],
|
result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix'],
|
||||||
capture_output=True, text=True)
|
capture_output=True, text=True, timeout=10)
|
||||||
elif action == 'restart_web_service':
|
elif action == 'restart_web_service':
|
||||||
# Try to restart the web service (assuming it's ledmatrix-web.service)
|
# Try to restart the web service (assuming it's ledmatrix-web.service)
|
||||||
result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix-web'],
|
result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix-web'],
|
||||||
capture_output=True, text=True)
|
capture_output=True, text=True, timeout=10)
|
||||||
else:
|
else:
|
||||||
return jsonify({'status': 'error', 'message': 'Unknown action'}), 400
|
return jsonify({'status': 'error', 'message': 'Unknown action'}), 400
|
||||||
|
|
||||||
logger.info("system action '%s' returncode=%d", action, result.returncode)
|
logger.info("system action '%s' returncode=%d", action, result.returncode)
|
||||||
return jsonify({
|
if result.returncode != 0 and result.stderr:
|
||||||
|
logger.error("system action '%s' stderr: %s", action, result.stderr.strip())
|
||||||
|
resp = {
|
||||||
'status': 'success' if result.returncode == 0 else 'error',
|
'status': 'success' if result.returncode == 0 else 'error',
|
||||||
'message': 'Action completed' if result.returncode == 0 else 'Action failed; check logs for details',
|
'message': 'Action completed' if result.returncode == 0 else 'Action failed; check logs for details',
|
||||||
})
|
}
|
||||||
|
if result.returncode != 0:
|
||||||
|
resp['returncode'] = result.returncode
|
||||||
|
resp['stderr'] = result.stderr.strip()
|
||||||
|
return jsonify(resp)
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired as e:
|
||||||
|
logger.error("system action '%s' timed out: %s", action, e)
|
||||||
|
return jsonify({'status': 'error', 'message': 'Command timed out', 'returncode': -1, 'stderr': 'timeout'})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("execute_system_action failed: %s", e, exc_info=True)
|
logger.error("execute_system_action failed: %s", e, exc_info=True)
|
||||||
return jsonify({'status': 'error', 'message': 'Action failed; see logs for details'}), 500
|
return jsonify({'status': 'error', 'message': 'Action failed; see logs for details'}), 500
|
||||||
@@ -6425,9 +6448,14 @@ def list_plugin_assets():
|
|||||||
def get_logs():
|
def get_logs():
|
||||||
"""Get system logs from journalctl"""
|
"""Get system logs from journalctl"""
|
||||||
try:
|
try:
|
||||||
|
if not _JOURNALCTL:
|
||||||
|
return jsonify({'status': 'error', 'message': 'journalctl not found on this system'}), 503
|
||||||
# Get recent logs from journalctl
|
# Get recent logs from journalctl
|
||||||
|
_cmd = ([_SUDO, _JOURNALCTL] if _SUDO else [_JOURNALCTL]) + [
|
||||||
|
'-u', 'ledmatrix.service', '-u', 'ledmatrix-web.service',
|
||||||
|
'-n', '100', '--no-pager', '--output=short-iso']
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
['sudo', 'journalctl', '-u', 'ledmatrix.service', '-n', '100', '--no-pager'],
|
_cmd,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=5
|
timeout=5
|
||||||
@@ -6438,7 +6466,7 @@ def get_logs():
|
|||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'data': {
|
'data': {
|
||||||
'logs': logs_text if logs_text else 'No logs available from ledmatrix service'
|
'logs': logs_text if logs_text else 'No logs available from ledmatrix or ledmatrix-web service'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
@@ -6542,7 +6570,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 = wifi_manager.scan_networks()
|
networks, _was_cached = wifi_manager.scan_networks()
|
||||||
|
|
||||||
# Convert to dict format
|
# Convert to dict format
|
||||||
networks_data = [
|
networks_data = [
|
||||||
@@ -6680,7 +6708,9 @@ def enable_ap_mode():
|
|||||||
from src.wifi_manager import WiFiManager
|
from src.wifi_manager import WiFiManager
|
||||||
|
|
||||||
wifi_manager = WiFiManager()
|
wifi_manager = WiFiManager()
|
||||||
success, message = wifi_manager.enable_ap_mode()
|
_force_raw = (request.get_json(silent=True) or {}).get('force', False)
|
||||||
|
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({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* global showNotification, updateSystemStats, htmx */
|
/* global showNotification, updateSystemStats, updateDisplayPreview, 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,7 +51,8 @@ document.body.addEventListener('htmx:afterRequest', function(event) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// SSE reconnection helper
|
// SSE reconnection helper — closes and reopens both SSE streams,
|
||||||
|
// 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();
|
||||||
@@ -60,14 +61,18 @@ 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() {
|
window.displaySource.onmessage = function(event) {
|
||||||
// Handle display updates
|
const data = JSON.parse(event.data);
|
||||||
|
if (typeof updateDisplayPreview === 'function') updateDisplayPreview(data);
|
||||||
};
|
};
|
||||||
|
if (window._displayErrorHandler) window.displaySource.addEventListener('error', window._displayErrorHandler);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1370,34 +1370,64 @@
|
|||||||
|
|
||||||
<!-- 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;
|
||||||
|
if (connected) {
|
||||||
|
el.innerHTML = `
|
||||||
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
|
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||||
<span class="text-gray-600">Connected</span>
|
<span class="text-gray-600">Connected</span>
|
||||||
`;
|
`;
|
||||||
});
|
} else if (reconnecting) {
|
||||||
|
el.innerHTML = `
|
||||||
statsSource.addEventListener('error', function() {
|
<div class="w-2 h-2 bg-yellow-500 rounded-full animate-pulse"></div>
|
||||||
document.getElementById('connection-status').innerHTML = `
|
<span class="text-gray-600">Reconnecting…</span>
|
||||||
<div class="w-2 h-2 bg-red-500 rounded-full"></div>
|
|
||||||
<span class="text-gray-600">Disconnected</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;
|
||||||
|
|
||||||
|
// Named on window so reconnectSSE() in app.js can reattach them after
|
||||||
|
// replacing the EventSource instances.
|
||||||
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user