mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
* fix(web): plugin dir resolver in routes, nmcli AP detection, daemon config reload, upload safety - Route plugin lookups (installed list, update, recorded version, config form, web UI pages) through the plugin manager's resolver so plugins in ledmatrix-<id> directories work. - Captive-portal detection also sees the nmcli fallback AP (cached). - WiFi monitor daemon re-reads wifi_config.json when its mtime changes. - Drop the AP check in disconnect_from_network that could never fire. - LED status file per WiFiManager; config path falls back to this checkout. - BDF font preview via src.common.bdf_font. - Asset uploads validate every file before saving; metadata and calendar credentials written atomically; no absolute path in the response; asset delete answers 400 for a missing body. - Coerce string booleans in plugin toggle, on-demand start and AP force. - SSE broadcaster clears its thread handle before exiting. - start.py log filter handles every exc_info form. - Cleanups: unused plugins/fonts partial work, duplicate backup catch-alls, raw-config error helper, update-route tidy, redundant imports. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): request BDF font previews now that the server renders them The Fonts tab skipped the preview request for .bdf files because the server used to refuse them; /fonts/preview now draws BDF with the shared loader. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): take the update route's plugin directory from a directory listing CodeQL flagged the path built from the request's plugin_id (the id was already validated with safe_path_component, which CodeQL doesn't model; the same flow on main is alerts 738/739). The directory is now the entry of plugins_dir matched by name, so nothing built from user input reaches the filesystem; an id with nothing installed goes to the store manager, which reports it not found as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): read the blueprint's plugin_manager defensively in _plugin_directory _get_plugin_version now goes through _plugin_directory, which read api_v3.plugin_manager directly; the attribute exists only once the app sets it, so test_path_traversal_guards::test_a_real_manifest_is_read failed when run on its own (order-dependent in the full suite). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
150 lines
5.2 KiB
Python
150 lines
5.2 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
LED Matrix Web Interface V3 Startup Script
|
|
Modern web interface with real-time display preview and plugin management.
|
|
"""
|
|
|
|
import os
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import logging
|
|
from pathlib import Path
|
|
|
|
logger = logging.getLogger('web_interface.start')
|
|
|
|
# No route to host, broken pipe, connection reset: a client went away.
|
|
_CLIENT_DISCONNECT_ERRNOS = (113, 32, 104)
|
|
|
|
|
|
def _exc_info_value(exc_info):
|
|
"""The exception a logging call's ``exc_info`` argument refers to.
|
|
|
|
logging accepts a (type, value, traceback) tuple, an exception instance,
|
|
or True for the exception being handled. The werkzeug filter below used
|
|
to unpack it as a tuple, so True or an instance raised TypeError from
|
|
inside the logging call itself.
|
|
"""
|
|
if isinstance(exc_info, BaseException):
|
|
return exc_info
|
|
if isinstance(exc_info, tuple):
|
|
return exc_info[1] if len(exc_info) > 1 else None
|
|
return sys.exc_info()[1]
|
|
|
|
|
|
def get_local_ips():
|
|
"""Get list of local IP addresses the service will be accessible on."""
|
|
ips = []
|
|
|
|
# Check if AP mode is active
|
|
try:
|
|
result = subprocess.run(
|
|
["systemctl", "is-active", "hostapd"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=2
|
|
)
|
|
if result.returncode == 0 and result.stdout.strip() == "active":
|
|
ips.append("192.168.4.1 (AP Mode)")
|
|
except Exception: # nosec B110 - AP mode IP detection is non-critical startup info; systemctl may not exist
|
|
pass
|
|
|
|
# Get IPs from hostname -I
|
|
try:
|
|
result = subprocess.run(
|
|
["hostname", "-I"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=2
|
|
)
|
|
if result.returncode == 0:
|
|
for ip in result.stdout.strip().split():
|
|
ip = ip.strip()
|
|
if ip and not ip.startswith("127.") and ip != "192.168.4.1":
|
|
ips.append(ip)
|
|
except Exception: # nosec B110 - hostname -I output parsing; non-critical startup info
|
|
pass
|
|
|
|
# Fallback: try socket method
|
|
if not ips:
|
|
try:
|
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
try:
|
|
s.connect(('8.8.8.8', 80))
|
|
ip = s.getsockname()[0]
|
|
if ip and not ip.startswith("127."):
|
|
ips.append(ip)
|
|
finally:
|
|
s.close()
|
|
except Exception:
|
|
pass
|
|
|
|
return ips if ips else ["localhost"]
|
|
|
|
def main():
|
|
"""Main startup function."""
|
|
# Change to project root directory
|
|
project_root = Path(__file__).parent.parent
|
|
os.chdir(project_root)
|
|
|
|
# Add to Python path
|
|
sys.path.insert(0, str(project_root))
|
|
|
|
# Configure logging to suppress non-critical socket errors
|
|
# These occur when clients disconnect and are harmless
|
|
werkzeug_logger = logging.getLogger('werkzeug')
|
|
original_log_exception = werkzeug_logger.error
|
|
|
|
def log_exception_filtered(message, *args, **kwargs):
|
|
"""Filter out non-critical socket errors from werkzeug logs."""
|
|
if isinstance(message, str):
|
|
# Suppress "No route to host" and similar connection errors
|
|
if 'No route to host' in message or 'errno 113' in message:
|
|
# Log at debug level instead of error
|
|
werkzeug_logger.debug(message, *args, **kwargs)
|
|
return
|
|
# Suppress broken pipe errors (client disconnected)
|
|
if 'Broken pipe' in message or 'errno 32' in message:
|
|
werkzeug_logger.debug(message, *args, **kwargs)
|
|
return
|
|
# For exceptions, check if it's a socket error
|
|
if 'exc_info' in kwargs and kwargs['exc_info']:
|
|
exc_value = _exc_info_value(kwargs['exc_info'])
|
|
if isinstance(exc_value, OSError):
|
|
if exc_value.errno in _CLIENT_DISCONNECT_ERRNOS:
|
|
werkzeug_logger.debug(message, *args, **kwargs)
|
|
return
|
|
# Log everything else normally
|
|
original_log_exception(message, *args, **kwargs)
|
|
|
|
werkzeug_logger.error = log_exception_filtered
|
|
|
|
# Importing the app also sets up logging, so the lines below reach the
|
|
# journal through it.
|
|
from web_interface.app import app, start_auto_update_scheduler
|
|
start_auto_update_scheduler()
|
|
|
|
logger.info("Starting LED Matrix Web Interface V3, binding to 0.0.0.0:5000")
|
|
# get_local_ips() always returns at least "localhost".
|
|
logger.info("Access the interface at:")
|
|
for ip in get_local_ips():
|
|
if "AP Mode" in ip:
|
|
logger.info(" - http://192.168.4.1:5000 (AP Mode - connect to LEDMatrix-Setup WiFi)")
|
|
else:
|
|
logger.info(" - http://%s:5000", ip)
|
|
|
|
try:
|
|
# threaded=True is Flask's default since 1.0, but set it explicitly
|
|
# so it's self-documenting: the three /api/v3/stream/* SSE endpoints
|
|
# hold long-lived connections and would starve other requests under
|
|
# a single-threaded server.
|
|
app.run(host='0.0.0.0', port=5000, debug=False, threaded=True)
|
|
except OSError as e:
|
|
if e.errno not in _CLIENT_DISCONNECT_ERRNOS:
|
|
raise
|
|
werkzeug_logger.debug("Client disconnected: %s", e, exc_info=True)
|
|
|
|
if __name__ == '__main__':
|
|
main()
|
|
|