mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-14 02:56:36 +00:00
merge: bring the element-style work onto main's api_v3 package split
main moved 16 commits while this branch was open, and one of them split api_v3.py into a package. A rebase would have conflicted on that file in each of the 15 commits that touch it; merging resolves it once. Reapplied into the package, unchanged in substance: - _schema_type_is / _schema_allows_null and the nullable-field handling (api_v3/__init__.py, where the form parser now lives) - the indexed-array recombiner's union-type fix and its all-blank-means-null case (api_v3/plugins.py) - scalable / native_size on the font catalog, and the removal of the three font-override stubs (api_v3/fonts.py) Two places took main's version rather than mine, deliberately: - pages_v3 now reads the schema through SchemaManager *and* keeps resolve_under for the raw-read fallback, so the containment guard main added in the CodeQL triage survives. - serve_plugin_widget moves off os.path.basename onto safe_path_component and resolve_under. main dropped `import os` from pages_v3 in favour of those helpers, and they reject a bad component rather than mangling it into a valid-looking one. The url-map snapshot loses exactly the three font-override routes. Edited rather than regenerated: that test exists to catch a route lost by accident during a move, and regenerating it wholesale is how such a loss would be papered over. Verified against a fresh run of current main: same failure set, 4692 passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+57
-65
@@ -17,6 +17,9 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from src.config_manager import ConfigManager
|
||||
from src.web_interface.error_handler import describe_exception
|
||||
from src.common.path_safety import (
|
||||
resolve_under, safe_path_component, safe_relative_parts,
|
||||
)
|
||||
from werkzeug.exceptions import HTTPException
|
||||
from src.exceptions import ConfigError
|
||||
from src.plugin_system.plugin_manager import PluginManager
|
||||
@@ -32,6 +35,8 @@ _JOURNALCTL = shutil.which('journalctl')
|
||||
_SYSTEMCTL = shutil.which('systemctl')
|
||||
_VCGENCMD = shutil.which('vcgencmd')
|
||||
|
||||
from web_interface.system_metrics import collect_system_metrics
|
||||
|
||||
# Create Flask app
|
||||
app = Flask(__name__)
|
||||
app.secret_key = os.urandom(24)
|
||||
@@ -205,66 +210,66 @@ app.register_blueprint(api_v3, url_prefix='/api/v3')
|
||||
# Route to serve plugin asset files (registered on main app, not blueprint, for /assets/... path)
|
||||
@app.route('/assets/plugins/<plugin_id>/uploads/<path:filename>', methods=['GET'])
|
||||
def serve_plugin_asset(plugin_id, filename):
|
||||
"""Serve uploaded asset files from assets/plugins/{plugin_id}/uploads/"""
|
||||
"""Serve uploaded asset files from assets/plugins/{plugin_id}/uploads/
|
||||
|
||||
Both URL parts are validated before any path is built. The containment
|
||||
check here used to compare the *asset directory* against the project root
|
||||
rather than against assets/plugins, so a plugin_id of ``..`` moved the
|
||||
served directory a level up and still passed.
|
||||
"""
|
||||
try:
|
||||
# Build the asset directory path
|
||||
assets_dir = project_root / 'assets' / 'plugins' / plugin_id / 'uploads'
|
||||
assets_dir = assets_dir.resolve()
|
||||
|
||||
uploads_base = (project_root / 'assets' / 'plugins').resolve()
|
||||
|
||||
safe_plugin_id = safe_path_component(plugin_id)
|
||||
if not safe_plugin_id:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid asset path'}), 403
|
||||
|
||||
safe_parts = safe_relative_parts(filename)
|
||||
if not safe_parts:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid file path'}), 403
|
||||
|
||||
assets_dir = resolve_under(uploads_base, safe_plugin_id, 'uploads')
|
||||
if assets_dir is None:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid asset path'}), 403
|
||||
|
||||
# Security check: ensure the assets directory exists and is within project_root
|
||||
if not assets_dir.exists() or not assets_dir.is_dir():
|
||||
return jsonify({'status': 'error', 'message': 'Asset directory not found'}), 404
|
||||
|
||||
# Ensure we're serving from within the assets directory (prevent directory traversal)
|
||||
# Use proper path resolution instead of string prefix matching to prevent bypasses
|
||||
assets_dir_resolved = assets_dir.resolve()
|
||||
project_root_resolved = project_root.resolve()
|
||||
|
||||
# Check that assets_dir is actually within project_root using commonpath
|
||||
try:
|
||||
common_path = os.path.commonpath([str(assets_dir_resolved), str(project_root_resolved)])
|
||||
if common_path != str(project_root_resolved):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid asset path'}), 403
|
||||
except ValueError:
|
||||
# commonpath raises ValueError if paths are on different drives (Windows)
|
||||
return jsonify({'status': 'error', 'message': 'Invalid asset path'}), 403
|
||||
|
||||
# Resolve the requested file path
|
||||
requested_file = (assets_dir / filename).resolve()
|
||||
|
||||
# Security check: ensure file is within the assets directory using proper path comparison
|
||||
# Use commonpath to ensure assets_dir is a true parent of requested_file
|
||||
try:
|
||||
common_path = os.path.commonpath([str(requested_file), str(assets_dir_resolved)])
|
||||
if common_path != str(assets_dir_resolved):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid file path'}), 403
|
||||
except ValueError:
|
||||
# commonpath raises ValueError if paths are on different drives (Windows)
|
||||
|
||||
# Resolve the requested file path. resolve_under repeats the
|
||||
# containment check after resolving, which is what catches a symlink
|
||||
# inside the uploads directory pointing out of it.
|
||||
requested_file = resolve_under(assets_dir, *safe_parts)
|
||||
if requested_file is None:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid file path'}), 403
|
||||
|
||||
|
||||
# Check if file exists
|
||||
if not requested_file.exists() or not requested_file.is_file():
|
||||
return jsonify({'status': 'error', 'message': 'File not found'}), 404
|
||||
|
||||
|
||||
# Determine content type based on file extension
|
||||
lowered = requested_file.name.lower()
|
||||
content_type = 'application/octet-stream'
|
||||
if filename.lower().endswith(('.png', '.jpg', '.jpeg')):
|
||||
content_type = 'image/jpeg' if filename.lower().endswith(('.jpg', '.jpeg')) else 'image/png'
|
||||
elif filename.lower().endswith('.gif'):
|
||||
if lowered.endswith(('.png', '.jpg', '.jpeg')):
|
||||
content_type = 'image/jpeg' if lowered.endswith(('.jpg', '.jpeg')) else 'image/png'
|
||||
elif lowered.endswith('.gif'):
|
||||
content_type = 'image/gif'
|
||||
elif filename.lower().endswith('.bmp'):
|
||||
elif lowered.endswith('.bmp'):
|
||||
content_type = 'image/bmp'
|
||||
elif filename.lower().endswith('.webp'):
|
||||
elif lowered.endswith('.webp'):
|
||||
content_type = 'image/webp'
|
||||
elif filename.lower().endswith('.svg'):
|
||||
elif lowered.endswith('.svg'):
|
||||
content_type = 'image/svg+xml'
|
||||
elif filename.lower().endswith('.json'):
|
||||
elif lowered.endswith('.json'):
|
||||
content_type = 'application/json'
|
||||
elif filename.lower().endswith('.txt'):
|
||||
elif lowered.endswith('.txt'):
|
||||
content_type = 'text/plain'
|
||||
|
||||
# Use send_from_directory to serve the file
|
||||
return send_from_directory(str(assets_dir), filename, mimetype=content_type)
|
||||
|
||||
# Use send_from_directory to serve the file. The path handed over is
|
||||
# the validated one, rebuilt from the components that were checked.
|
||||
return send_from_directory(
|
||||
str(assets_dir), '/'.join(safe_parts), mimetype=content_type
|
||||
)
|
||||
|
||||
except Exception:
|
||||
app.logger.exception('Error serving plugin asset file')
|
||||
@@ -607,26 +612,12 @@ def system_status_generator():
|
||||
"""Generate system status updates"""
|
||||
while True:
|
||||
try:
|
||||
# Try to import psutil for system stats
|
||||
try:
|
||||
import psutil
|
||||
# interval=None is non-blocking; primed at module startup above
|
||||
cpu_percent = round(psutil.cpu_percent(interval=None), 1)
|
||||
memory = psutil.virtual_memory()
|
||||
memory_used_percent = round(memory.percent, 1)
|
||||
|
||||
# Try to get CPU temperature (Raspberry Pi specific)
|
||||
cpu_temp = 0
|
||||
try:
|
||||
with open('/sys/class/thermal/thermal_zone0/temp', 'r') as f:
|
||||
cpu_temp = round(float(f.read()) / 1000.0, 1)
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
except ImportError:
|
||||
cpu_percent = 0
|
||||
memory_used_percent = 0
|
||||
cpu_temp = 0
|
||||
metrics = collect_system_metrics()
|
||||
cpu_percent = metrics['cpu_percent']
|
||||
memory_used_percent = metrics['memory_used_percent']
|
||||
memory_available_mb = metrics['memory_available_mb']
|
||||
disk_used_percent = metrics['disk_used_percent']
|
||||
cpu_temp = metrics['cpu_temp']
|
||||
|
||||
# Check if display service is running (cached to avoid per-client subprocess forks)
|
||||
now = time.time()
|
||||
@@ -647,8 +638,9 @@ def system_status_generator():
|
||||
'service_active': service_active,
|
||||
'cpu_percent': cpu_percent,
|
||||
'memory_used_percent': memory_used_percent,
|
||||
'memory_available_mb': memory_available_mb,
|
||||
'cpu_temp': cpu_temp,
|
||||
'disk_used_percent': 0,
|
||||
'disk_used_percent': disk_used_percent,
|
||||
'power': _get_power_status()
|
||||
}
|
||||
yield status
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,230 @@
|
||||
"""Backup creation, listing and restore.
|
||||
|
||||
Routes decorate the shared `api_v3` Blueprint from ._common, so their
|
||||
endpoint names are unchanged by living here.
|
||||
"""
|
||||
from web_interface.blueprints.api_v3 import (
|
||||
PROJECT_ROOT, Path, _coerce_to_bool, _safe_backup_path, api_v3,
|
||||
datetime, json, jsonify, logger, os, plugin_store_manager, request,
|
||||
tempfile,
|
||||
)
|
||||
import web_interface.blueprints.api_v3 as _pkg
|
||||
# Read through the module rather than bound by value: tests patch these
|
||||
# as module attributes, and a value binding would not see the patch.
|
||||
# Several are also called from helpers that live in __init__, so the
|
||||
# package is the only patch point that covers every caller.
|
||||
|
||||
|
||||
@api_v3.route('/backup/preview', methods=['GET'])
|
||||
def backup_preview():
|
||||
"""Return a summary of what a new backup would include."""
|
||||
try:
|
||||
from src.backup_manager import preview_backup_contents
|
||||
data = preview_backup_contents(PROJECT_ROOT)
|
||||
return jsonify({'status': 'success', 'data': data})
|
||||
except Exception as e:
|
||||
logger.error("backup_preview failed: %s", e, exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
|
||||
@api_v3.route('/backup/list', methods=['GET'])
|
||||
def backup_list():
|
||||
"""List backup ZIPs stored in the export directory."""
|
||||
try:
|
||||
_pkg._BACKUP_EXPORT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
entries = []
|
||||
for p in sorted(_pkg._BACKUP_EXPORT_DIR.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True):
|
||||
if not p.is_file() or p.suffix != '.zip':
|
||||
continue
|
||||
st = p.stat()
|
||||
entries.append({
|
||||
'filename': p.name,
|
||||
'size': st.st_size,
|
||||
'created_at': datetime.fromtimestamp(st.st_mtime).strftime('%Y-%m-%d %H:%M:%S'),
|
||||
})
|
||||
return jsonify({'status': 'success', 'data': entries})
|
||||
except Exception as e:
|
||||
logger.error("backup_list failed: %s", e, exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
|
||||
@api_v3.route('/backup/export', methods=['POST'])
|
||||
def backup_export():
|
||||
"""Create a new backup ZIP and return its filename."""
|
||||
try:
|
||||
from src.backup_manager import create_backup
|
||||
zip_path = create_backup(PROJECT_ROOT, output_dir=_pkg._BACKUP_EXPORT_DIR)
|
||||
return jsonify({'status': 'success', 'filename': zip_path.name})
|
||||
except Exception as e:
|
||||
logger.error("backup_export failed: %s", e, exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
|
||||
@api_v3.route('/backup/validate', methods=['POST'])
|
||||
def backup_validate():
|
||||
"""Validate an uploaded backup ZIP and return its manifest."""
|
||||
try:
|
||||
from src.backup_manager import validate_backup
|
||||
if 'backup_file' not in request.files:
|
||||
return jsonify({'status': 'error', 'message': 'No backup_file in request'}), 400
|
||||
f = request.files['backup_file']
|
||||
with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as tmp:
|
||||
tmp_path = tmp.name
|
||||
f.save(tmp_path)
|
||||
try:
|
||||
ok, err_msg, manifest = validate_backup(Path(tmp_path))
|
||||
finally:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
if not ok:
|
||||
logger.warning("Backup validation failed: %s", err_msg)
|
||||
return jsonify({'status': 'error', 'message': 'Invalid or corrupted backup file'}), 400
|
||||
safe_manifest = {
|
||||
'schema_version': manifest.get('schema_version'),
|
||||
'created_at': manifest.get('created_at'),
|
||||
'ledmatrix_version': manifest.get('ledmatrix_version'),
|
||||
'hostname': manifest.get('hostname'),
|
||||
'contents': manifest.get('contents', []),
|
||||
'detected_contents': manifest.get('detected_contents', []),
|
||||
'plugins': manifest.get('plugins', []),
|
||||
'total_uncompressed': manifest.get('total_uncompressed'),
|
||||
'file_count': manifest.get('file_count'),
|
||||
}
|
||||
return jsonify({'status': 'success', 'data': safe_manifest})
|
||||
except Exception as e:
|
||||
logger.error("backup_validate failed: %s", e, exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
|
||||
#: The only keys RestoreOptions recognizes. A typo'd or renamed key (e.g.
|
||||
#: "restoreSecrets") would otherwise be silently ignored by opts_dict.get(),
|
||||
#: leaving that flag at its True default -- restoring secrets a caller's
|
||||
#: request clearly meant to exclude, with no indication anything was wrong.
|
||||
_RESTORE_OPTION_KEYS = frozenset((
|
||||
'restore_config', 'restore_secrets', 'restore_wifi', 'restore_fonts',
|
||||
'restore_plugin_uploads', 'reinstall_plugins',
|
||||
))
|
||||
@api_v3.route('/backup/restore', methods=['POST'])
|
||||
def backup_restore():
|
||||
"""Restore a backup ZIP with optional RestoreOptions."""
|
||||
try:
|
||||
from src.backup_manager import restore_backup, RestoreOptions
|
||||
if 'backup_file' not in request.files:
|
||||
return jsonify({'status': 'error', 'message': 'No backup_file in request'}), 400
|
||||
f = request.files['backup_file']
|
||||
options_raw = request.form.get('options', '{}')
|
||||
try:
|
||||
opts_dict = json.loads(options_raw)
|
||||
except json.JSONDecodeError:
|
||||
opts_dict = None
|
||||
if not isinstance(opts_dict, dict):
|
||||
# Every option defaults to True, so falling back to {} on a
|
||||
# parse failure would silently perform a FULL restore —
|
||||
# secrets and all — for a caller who asked for a narrow one
|
||||
# and mis-serialized it. Refuse instead of guessing.
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Invalid options: expected a JSON object',
|
||||
}), 400
|
||||
unknown_keys = set(opts_dict) - _RESTORE_OPTION_KEYS
|
||||
if unknown_keys:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Unknown restore option(s): {", ".join(sorted(unknown_keys))}',
|
||||
}), 400
|
||||
# _coerce_to_bool (not bare bool()) because a request can send these
|
||||
# as JSON strings: bool("false") is True in Python, so a caller who
|
||||
# explicitly asked to skip secrets would have had them restored
|
||||
# anyway.
|
||||
options = RestoreOptions(
|
||||
restore_config=_coerce_to_bool(opts_dict.get('restore_config', True)),
|
||||
restore_secrets=_coerce_to_bool(opts_dict.get('restore_secrets', True)),
|
||||
restore_wifi=_coerce_to_bool(opts_dict.get('restore_wifi', True)),
|
||||
restore_fonts=_coerce_to_bool(opts_dict.get('restore_fonts', True)),
|
||||
restore_plugin_uploads=_coerce_to_bool(opts_dict.get('restore_plugin_uploads', True)),
|
||||
reinstall_plugins=_coerce_to_bool(opts_dict.get('reinstall_plugins', True)),
|
||||
)
|
||||
with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as tmp:
|
||||
tmp_path = tmp.name
|
||||
f.save(tmp_path)
|
||||
try:
|
||||
result = restore_backup(Path(tmp_path), PROJECT_ROOT, options)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Reinstall plugins if requested and store manager available
|
||||
if options.reinstall_plugins and result.plugins_to_install:
|
||||
psm = getattr(api_v3, 'plugin_store_manager', None) or plugin_store_manager
|
||||
for plug in result.plugins_to_install:
|
||||
pid = plug.get('plugin_id')
|
||||
if not pid:
|
||||
continue
|
||||
try:
|
||||
if psm and hasattr(psm, 'install_plugin'):
|
||||
ok = psm.install_plugin(pid)
|
||||
if ok:
|
||||
result.plugins_installed.append(pid)
|
||||
else:
|
||||
result.plugins_failed.append({'plugin_id': pid, 'error': 'install_plugin returned False'})
|
||||
else:
|
||||
result.plugins_failed.append({'plugin_id': pid, 'error': 'Store manager unavailable'})
|
||||
except Exception as pe:
|
||||
logger.error(
|
||||
"[Backup] Failed to reinstall plugin %r: %s", pid, pe, exc_info=True
|
||||
)
|
||||
result.plugins_failed.append({'plugin_id': pid, 'error': 'Installation failed; see server logs'})
|
||||
|
||||
# A restore that dropped files can still report success if the only
|
||||
# failures were plugin reinstalls, since those don't touch result.errors.
|
||||
if result.plugins_failed:
|
||||
result.success = False
|
||||
|
||||
data = result.to_dict()
|
||||
if not result.success:
|
||||
# Name what failed, and what nonetheless landed. A restore is
|
||||
# partial far more often than it is total -- a fresh install can
|
||||
# leave config_secrets.json unwritable by the web service, so
|
||||
# config restores and secrets do not. "Restore had errors" alone
|
||||
# left the user unable to tell a wholly failed restore from one
|
||||
# that quietly dropped their API keys.
|
||||
failed_plugins = [
|
||||
str(p.get('plugin_id')) for p in (result.plugins_failed or []) if p.get('plugin_id')
|
||||
]
|
||||
parts = []
|
||||
if result.restored:
|
||||
parts.append(f"restored: {', '.join(result.restored)}")
|
||||
if result.errors:
|
||||
parts.append(f"failed: {'; '.join(result.errors)}")
|
||||
if failed_plugins:
|
||||
parts.append(f"plugins not reinstalled: {', '.join(failed_plugins)}")
|
||||
message = 'Restore incomplete — ' + ('. '.join(parts) if parts else 'see logs')
|
||||
return jsonify({'status': 'error', 'message': message, 'data': data}), 500
|
||||
return jsonify({'status': 'success', 'data': data})
|
||||
except Exception as e:
|
||||
logger.error("backup_restore failed: %s", e, exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
|
||||
@api_v3.route('/backup/download/<path:filename>', methods=['GET'])
|
||||
def backup_download(filename):
|
||||
"""Stream a backup ZIP to the browser."""
|
||||
from flask import send_from_directory
|
||||
if _safe_backup_path(filename) is None:
|
||||
return jsonify({'status': 'error', 'message': 'Backup not found'}), 404
|
||||
try:
|
||||
# send_from_directory uses werkzeug safe_join internally — CodeQL-recognized sanitizer.
|
||||
return send_from_directory(_pkg._BACKUP_EXPORT_DIR, filename, as_attachment=True)
|
||||
except FileNotFoundError:
|
||||
return jsonify({'status': 'error', 'message': 'Backup not found'}), 404
|
||||
@api_v3.route('/backup/<path:filename>', methods=['DELETE'])
|
||||
def backup_delete(filename):
|
||||
"""Delete a stored backup ZIP."""
|
||||
safe = _safe_backup_path(filename)
|
||||
if safe is None:
|
||||
return jsonify({'status': 'error', 'message': 'Backup not found'}), 404
|
||||
# Enumerate the export directory and match by name so the unlink target is
|
||||
# a filesystem-derived path rather than one constructed from user input.
|
||||
try:
|
||||
for entry in _pkg._BACKUP_EXPORT_DIR.iterdir():
|
||||
if entry.is_file() and entry.name == safe.name:
|
||||
entry.unlink()
|
||||
return jsonify({'status': 'success'})
|
||||
except OSError as e:
|
||||
logger.error("backup_delete failed: %s", e, exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
|
||||
return jsonify({'status': 'error', 'message': 'Backup not found'}), 404
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,326 @@
|
||||
"""Display control, on-demand playback and preview.
|
||||
|
||||
Routes decorate the shared `api_v3` Blueprint from ._common, so their
|
||||
endpoint names are unchanged by living here.
|
||||
"""
|
||||
from web_interface.blueprints.api_v3 import (
|
||||
_ensure_cache_manager, _ensure_display_service_running,
|
||||
_get_display_service_status, _stop_display_service, api_v3,
|
||||
describe_exception, jsonify, logger, os, request, uuid,
|
||||
)
|
||||
import web_interface.blueprints.api_v3 as _pkg
|
||||
# Read through the module rather than bound by value: tests patch these
|
||||
# as module attributes, and a value binding would not see the patch.
|
||||
# Several are also called from helpers that live in __init__, so the
|
||||
# package is the only patch point that covers every caller.
|
||||
|
||||
|
||||
@api_v3.route('/display/current', methods=['GET'])
|
||||
def get_display_current():
|
||||
"""Get current display state"""
|
||||
try:
|
||||
import base64
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
snapshot_path = "/tmp/led_matrix_preview.png"
|
||||
|
||||
# Get display dimensions from config
|
||||
try:
|
||||
if api_v3.config_manager:
|
||||
main_config = api_v3.config_manager.load_config()
|
||||
hardware_config = main_config.get('display', {}).get('hardware', {})
|
||||
cols = hardware_config.get('cols', 64)
|
||||
chain_length = hardware_config.get('chain_length', 2)
|
||||
rows = hardware_config.get('rows', 32)
|
||||
parallel = hardware_config.get('parallel', 1)
|
||||
width = cols * chain_length
|
||||
height = rows * parallel
|
||||
else:
|
||||
width = 128
|
||||
height = 64
|
||||
except Exception:
|
||||
width = 128
|
||||
height = 64
|
||||
|
||||
# Try to read snapshot file
|
||||
image_data = None
|
||||
if os.path.exists(snapshot_path):
|
||||
try:
|
||||
with Image.open(snapshot_path) as img:
|
||||
# Convert to PNG and encode as base64
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, format='PNG')
|
||||
image_data = base64.b64encode(buffer.getvalue()).decode('utf-8')
|
||||
except Exception as img_err:
|
||||
# File might be being written or corrupted, return None
|
||||
pass
|
||||
|
||||
display_data = {
|
||||
'timestamp': _pkg.time.time(),
|
||||
'width': width,
|
||||
'height': height,
|
||||
'image': image_data # Base64 encoded image data or None if unavailable
|
||||
}
|
||||
return jsonify({'status': 'success', 'data': display_data})
|
||||
except Exception as e:
|
||||
logger.error('Unhandled exception', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/display/modes', methods=['GET'])
|
||||
def get_display_modes():
|
||||
"""Every display mode that can be requested on-demand, with its plugin.
|
||||
|
||||
/plugins/installed carries no mode information, so anything driving the
|
||||
display from outside the web UI (the Home Assistant MQTT bridge, a script)
|
||||
had to read each plugin's manifest.json off disk and reimplement the
|
||||
fallbacks in PluginManager.get_plugin_display_modes to do it. This is the
|
||||
same list the force-display dialog offers, from the source that owns it.
|
||||
|
||||
Knowing each mode's plugin_id also matters because /display/on-demand/start
|
||||
falls back to find_plugin_for_mode when plugin_id is omitted, and that
|
||||
lookup only sees modes declared in a static manifest -- a plugin whose
|
||||
modes are generated (each installed Starlark app is one) 404s there.
|
||||
Sending the plugin_id from this list skips the lookup entirely.
|
||||
|
||||
Query params:
|
||||
include_disabled: '1' to list modes of disabled plugins too. They can
|
||||
still be requested on-demand -- the controller enables the plugin
|
||||
for the duration -- so they are reported with enabled: false
|
||||
rather than omitted.
|
||||
"""
|
||||
try:
|
||||
if not api_v3.plugin_manager:
|
||||
return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500
|
||||
|
||||
# Discovery is lazy and normally triggered by whichever endpoint runs
|
||||
# first, which is a person opening the dashboard. A caller that never
|
||||
# visits it would otherwise see an empty list.
|
||||
api_v3.plugin_manager.discover_plugins()
|
||||
|
||||
include_disabled = request.args.get('include_disabled') in ('1', 'true', 'True')
|
||||
full_config = api_v3.config_manager.load_config() if api_v3.config_manager else {}
|
||||
|
||||
modes = []
|
||||
for plugin_id, manifest in sorted(api_v3.plugin_manager.plugin_manifests.items()):
|
||||
# A hand-edited or migrated config.json can hold a non-dict under a
|
||||
# plugin id; DisplayController._reconcile guards the same shape, so
|
||||
# it happens in practice. Without this, .get() raises AttributeError,
|
||||
# the loop aborts and the endpoint answers 500 with no modes at all
|
||||
# -- one bad section would blank every entity the MQTT bridge builds
|
||||
# from this list.
|
||||
plugin_config = full_config.get(plugin_id)
|
||||
if not isinstance(plugin_config, dict):
|
||||
if plugin_config is not None:
|
||||
logger.warning(
|
||||
"Config for plugin %r is %s, not an object; treating it as disabled",
|
||||
plugin_id, type(plugin_config).__name__)
|
||||
plugin_config = {}
|
||||
enabled = bool(plugin_config.get('enabled', False))
|
||||
if not enabled and not include_disabled:
|
||||
continue
|
||||
plugin_name = (manifest or {}).get('name') or plugin_id
|
||||
plugin_modes = api_v3.plugin_manager.get_plugin_display_modes(plugin_id) or [plugin_id]
|
||||
for mode in plugin_modes:
|
||||
# A single-mode plugin's mode is the plugin, so its own name is
|
||||
# the readable label. Multi-mode plugins have no per-mode name
|
||||
# anywhere, so the raw mode string is the only thing to show.
|
||||
modes.append({
|
||||
'mode': mode,
|
||||
'plugin_id': plugin_id,
|
||||
'plugin_name': plugin_name,
|
||||
'name': plugin_name if len(plugin_modes) == 1 else mode,
|
||||
'enabled': enabled,
|
||||
})
|
||||
|
||||
return jsonify({'status': 'success', 'data': {'modes': modes}})
|
||||
except Exception as exc:
|
||||
# describe_exception, not a bare message: test_web_error_detail.py
|
||||
# enforces that every handler here returns it, because a device whose
|
||||
# storage is failing otherwise answers "see logs for details" from the
|
||||
# log viewer too. It redacts credentials out of the exception text.
|
||||
# CodeQL flags this as stack-trace exposure across all ~75 handlers;
|
||||
# it is the project's deliberate, reviewed trade-off.
|
||||
logger.error('Error in get_display_modes', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(exc)}), 500
|
||||
@api_v3.route('/display/on-demand/status', methods=['GET'])
|
||||
def get_on_demand_status():
|
||||
"""Return the current on-demand display state."""
|
||||
try:
|
||||
cache = _ensure_cache_manager()
|
||||
state = cache.get('display_on_demand_state', max_age=120)
|
||||
if state is None:
|
||||
state = {
|
||||
'active': False,
|
||||
'status': 'idle',
|
||||
'last_updated': None
|
||||
}
|
||||
service_status = _get_display_service_status()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'state': state,
|
||||
'service': service_status
|
||||
}
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.error('Error in get_on_demand_status', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(exc)}), 500
|
||||
@api_v3.route('/display/on-demand/start', methods=['POST'])
|
||||
def start_on_demand_display():
|
||||
"""Request the display controller to run a specific plugin on-demand."""
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
plugin_id = data.get('plugin_id')
|
||||
mode = data.get('mode')
|
||||
duration = data.get('duration')
|
||||
pinned = bool(data.get('pinned', False))
|
||||
start_service = data.get('start_service', True)
|
||||
|
||||
if not plugin_id and not mode:
|
||||
return jsonify({'status': 'error', 'message': 'plugin_id or mode is required'}), 400
|
||||
|
||||
resolved_plugin = plugin_id
|
||||
resolved_mode = mode
|
||||
|
||||
if api_v3.plugin_manager:
|
||||
if resolved_plugin and resolved_plugin not in api_v3.plugin_manager.plugin_manifests:
|
||||
return jsonify({'status': 'error', 'message': f'Plugin {resolved_plugin} not found'}), 404
|
||||
|
||||
if resolved_plugin and not resolved_mode:
|
||||
modes = api_v3.plugin_manager.get_plugin_display_modes(resolved_plugin)
|
||||
resolved_mode = modes[0] if modes else resolved_plugin
|
||||
elif resolved_mode and not resolved_plugin:
|
||||
resolved_plugin = api_v3.plugin_manager.find_plugin_for_mode(resolved_mode)
|
||||
if not resolved_plugin:
|
||||
return jsonify({'status': 'error', 'message': f'Mode {resolved_mode} not found'}), 404
|
||||
|
||||
# Note: On-demand can work with disabled plugins - the display controller
|
||||
# will temporarily enable them during initialization if needed
|
||||
# We don't block the request here, but log it for debugging
|
||||
if api_v3.config_manager and resolved_plugin:
|
||||
config = api_v3.config_manager.load_config()
|
||||
plugin_config = config.get(resolved_plugin, {})
|
||||
if 'enabled' in plugin_config and not plugin_config.get('enabled', False):
|
||||
logger.info(
|
||||
"On-demand request for disabled plugin '%s' - will be temporarily enabled",
|
||||
resolved_plugin,
|
||||
)
|
||||
|
||||
# Set the on-demand request in cache FIRST (before starting service)
|
||||
# This ensures the request is available when the service starts/restarts
|
||||
cache = _ensure_cache_manager()
|
||||
request_id = data.get('request_id') or str(uuid.uuid4())
|
||||
request_payload = {
|
||||
'request_id': request_id,
|
||||
'action': 'start',
|
||||
'plugin_id': resolved_plugin,
|
||||
'mode': resolved_mode,
|
||||
'duration': duration,
|
||||
'pinned': pinned,
|
||||
'timestamp': _pkg.time.time()
|
||||
}
|
||||
cache.set('display_on_demand_request', request_payload)
|
||||
|
||||
# Check if display service is running (or will be started)
|
||||
service_status = _get_display_service_status()
|
||||
service_was_running = service_status.get('active', False)
|
||||
|
||||
# Stop the display service first to ensure clean state when we will restart it
|
||||
if service_was_running and start_service:
|
||||
import time as time_module
|
||||
logger.debug("Stopping display service before starting on-demand mode")
|
||||
_stop_display_service()
|
||||
# Wait a brief moment for the service to fully stop
|
||||
time_module.sleep(1.5)
|
||||
logger.debug("Display service stopped, now starting with on-demand request")
|
||||
|
||||
if not service_status.get('active') and not start_service:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Display service is not running. Please start the display service or enable "Start Service" option.',
|
||||
'service_status': service_status
|
||||
}), 400
|
||||
|
||||
service_result = None
|
||||
if start_service:
|
||||
service_result = _ensure_display_service_running()
|
||||
# Check if service actually started
|
||||
if service_result and not service_result.get('active'):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to start display service. Please check service logs or start it manually.',
|
||||
'service_result': service_result
|
||||
}), 500
|
||||
|
||||
# Service was restarted (or started fresh) with on-demand request in cache
|
||||
# The display controller will read the request during initialization or when it polls
|
||||
|
||||
response_data = {
|
||||
'request_id': request_id,
|
||||
'plugin_id': resolved_plugin,
|
||||
'mode': resolved_mode,
|
||||
'duration': duration,
|
||||
'pinned': pinned,
|
||||
'service': service_result
|
||||
}
|
||||
return jsonify({'status': 'success', 'data': response_data})
|
||||
except Exception as exc:
|
||||
logger.error('Error in start_on_demand_display', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(exc)}), 500
|
||||
@api_v3.route('/display/on-demand/stop', methods=['POST'])
|
||||
def stop_on_demand_display():
|
||||
"""Request the display controller to stop on-demand mode."""
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
stop_service = data.get('stop_service', False)
|
||||
|
||||
# Set the stop request in cache FIRST
|
||||
# The display controller will poll this and restart without the on-demand filter
|
||||
cache = _ensure_cache_manager()
|
||||
request_id = data.get('request_id') or str(uuid.uuid4())
|
||||
request_payload = {
|
||||
'request_id': request_id,
|
||||
'action': 'stop',
|
||||
'timestamp': _pkg.time.time()
|
||||
}
|
||||
cache.set('display_on_demand_request', request_payload)
|
||||
|
||||
# Note: The display controller's _clear_on_demand() will handle the restart
|
||||
# to restore normal operation with all plugins
|
||||
|
||||
service_result = None
|
||||
if stop_service:
|
||||
service_result = _stop_display_service()
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'request_id': request_id,
|
||||
'service': service_result
|
||||
}
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.error('Error in stop_on_demand_display', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(exc)}), 500
|
||||
@api_v3.route('/display/current-status', methods=['GET'])
|
||||
def get_current_display_status():
|
||||
"""Return the display mode/plugin currently intended to be shown.
|
||||
|
||||
Published by the display process (display_controller._publish_current_mode_state)
|
||||
to the shared cache whenever the active mode changes, so the web UI (e.g. the
|
||||
System Logs page) can show what's on screen without querying the display
|
||||
process directly.
|
||||
"""
|
||||
try:
|
||||
cache = _ensure_cache_manager()
|
||||
state = cache.get('display_current_state', max_age=120)
|
||||
if state is None:
|
||||
state = {
|
||||
'mode': None,
|
||||
'plugin_id': None,
|
||||
'last_updated': None,
|
||||
}
|
||||
return jsonify({'status': 'success', 'data': state})
|
||||
except Exception as e:
|
||||
logger.error('Error in get_current_display_status', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@@ -0,0 +1,448 @@
|
||||
"""Font catalogue, upload, preview and deletion.
|
||||
|
||||
Routes decorate the shared `api_v3` Blueprint from ._common, so their
|
||||
endpoint names are unchanged by living here.
|
||||
"""
|
||||
from web_interface.blueprints.api_v3 import (
|
||||
PROJECT_ROOT, Path, Response, SYSTEM_FONTS, api_v3, describe_exception,
|
||||
jsonify, logger, os, re, request, validate_file_upload,
|
||||
)
|
||||
|
||||
|
||||
@api_v3.route('/fonts/catalog', methods=['GET'])
|
||||
def get_fonts_catalog():
|
||||
"""Get fonts catalog"""
|
||||
try:
|
||||
# Check cache first (5 minute TTL)
|
||||
try:
|
||||
from web_interface.cache import get_cached, set_cached
|
||||
cached_result = get_cached('fonts_catalog', ttl_seconds=300)
|
||||
if cached_result is not None:
|
||||
return jsonify({'status': 'success', 'data': {'catalog': cached_result}})
|
||||
except ImportError:
|
||||
# Cache not available, continue without caching
|
||||
get_cached = None
|
||||
set_cached = None
|
||||
|
||||
# Try to import freetype, but continue without it if unavailable
|
||||
try:
|
||||
import freetype
|
||||
freetype_available = True
|
||||
except ImportError:
|
||||
freetype_available = False
|
||||
|
||||
# Scan assets/fonts directory for actual font files
|
||||
fonts_dir = PROJECT_ROOT / "assets" / "fonts"
|
||||
catalog = {}
|
||||
|
||||
if fonts_dir.exists() and fonts_dir.is_dir():
|
||||
for filename in os.listdir(fonts_dir):
|
||||
if filename.endswith(('.ttf', '.otf', '.bdf')):
|
||||
filepath = fonts_dir / filename
|
||||
# Generate family name from filename (without extension)
|
||||
family_name = os.path.splitext(filename)[0]
|
||||
|
||||
# Try to get font metadata using freetype (for TTF/OTF)
|
||||
metadata = {}
|
||||
if filename.endswith(('.ttf', '.otf')) and freetype_available:
|
||||
try:
|
||||
face = freetype.Face(str(filepath))
|
||||
if face.valid:
|
||||
# Get font family name from font file
|
||||
family_name_from_font = face.family_name.decode('utf-8') if face.family_name else family_name
|
||||
metadata = {
|
||||
'family': family_name_from_font,
|
||||
'style': face.style_name.decode('utf-8') if face.style_name else 'Regular',
|
||||
'num_glyphs': face.num_glyphs,
|
||||
'units_per_em': face.units_per_EM
|
||||
}
|
||||
# Use font's family name if available
|
||||
if family_name_from_font:
|
||||
family_name = family_name_from_font
|
||||
except Exception:
|
||||
# If freetype fails, use filename-based name
|
||||
pass
|
||||
|
||||
# Store relative path from project root
|
||||
relative_path = str(filepath.relative_to(PROJECT_ROOT))
|
||||
font_type = 'ttf' if filename.endswith('.ttf') else 'otf' if filename.endswith('.otf') else 'bdf'
|
||||
|
||||
# Generate human-readable display name from family_name
|
||||
display_name = family_name.replace('-', ' ').replace('_', ' ')
|
||||
# Add space before capital letters for camelCase names
|
||||
display_name = re.sub(r'([a-z])([A-Z])', r'\1 \2', display_name)
|
||||
# Add space before numbers that follow letters
|
||||
display_name = re.sub(r'([a-zA-Z])(\d)', r'\1 \2', display_name)
|
||||
# Clean up multiple spaces
|
||||
display_name = ' '.join(display_name.split())
|
||||
|
||||
# Use filename (without extension) as unique key to avoid collisions
|
||||
# when multiple files share the same family_name from font metadata
|
||||
catalog_key = os.path.splitext(filename)[0]
|
||||
|
||||
# Check if this is a system font (cannot be deleted)
|
||||
is_system = catalog_key.lower() in SYSTEM_FONTS
|
||||
|
||||
# BDF files are fixed-size bitmap strikes: FreeType
|
||||
# accepts only the pixel size baked into the file. The
|
||||
# UI needs to know that before offering a size control,
|
||||
# or it offers a number that cannot take effect.
|
||||
native_size = None
|
||||
if font_type == 'bdf':
|
||||
try:
|
||||
from src.element_style import _read_bdf_native_size
|
||||
native_size = _read_bdf_native_size(str(filepath))
|
||||
except Exception:
|
||||
native_size = None
|
||||
|
||||
catalog[catalog_key] = {
|
||||
'filename': filename,
|
||||
'family_name': family_name,
|
||||
'display_name': display_name,
|
||||
'path': relative_path,
|
||||
'type': font_type,
|
||||
'is_system': is_system,
|
||||
'scalable': font_type != 'bdf',
|
||||
'native_size': native_size,
|
||||
'metadata': metadata if metadata else None
|
||||
}
|
||||
|
||||
# Cache the result (5 minute TTL) if available
|
||||
if set_cached:
|
||||
try:
|
||||
set_cached('fonts_catalog', catalog, ttl_seconds=300)
|
||||
except Exception:
|
||||
logger.error("[FontCatalog] Failed to cache fonts_catalog", exc_info=True)
|
||||
|
||||
return jsonify({'status': 'success', 'data': {'catalog': catalog}})
|
||||
except Exception as e:
|
||||
logger.error("%s failed", request.path, exc_info=True)
|
||||
return jsonify({'status': 'error',
|
||||
'message': 'An error occurred; see logs for details',
|
||||
'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/fonts/tokens', methods=['GET'])
|
||||
def get_font_tokens():
|
||||
"""Get font size tokens"""
|
||||
try:
|
||||
# This would integrate with the actual font system
|
||||
# For now, return sample tokens
|
||||
tokens = {
|
||||
'xs': 6,
|
||||
'sm': 8,
|
||||
'md': 10,
|
||||
'lg': 12,
|
||||
'xl': 14,
|
||||
'xxl': 16
|
||||
}
|
||||
return jsonify({'status': 'success', 'data': {'tokens': tokens}})
|
||||
except Exception as e:
|
||||
logger.error('Unhandled exception', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/fonts/upload', methods=['POST'])
|
||||
def upload_font():
|
||||
"""Upload font file"""
|
||||
try:
|
||||
if 'font_file' not in request.files:
|
||||
return jsonify({'status': 'error', 'message': 'No font file provided'}), 400
|
||||
|
||||
font_file = request.files['font_file']
|
||||
if font_file.filename == '':
|
||||
return jsonify({'status': 'error', 'message': 'No file selected'}), 400
|
||||
|
||||
# Validate filename. validate_file_upload takes max_size_mb but only
|
||||
# checks the filename/extension with it -- it never looks at the
|
||||
# actual upload size, so the size limit below is enforced separately
|
||||
# before the file is saved (same pattern as the .star upload above).
|
||||
MAX_FONT_SIZE_MB = 10
|
||||
is_valid, error_msg = validate_file_upload(
|
||||
font_file.filename,
|
||||
max_size_mb=MAX_FONT_SIZE_MB,
|
||||
allowed_extensions=['.ttf', '.otf', '.bdf']
|
||||
)
|
||||
if not is_valid:
|
||||
return jsonify({'status': 'error', 'message': error_msg}), 400
|
||||
|
||||
# Check file size (stated limit is MAX_FONT_SIZE_MB)
|
||||
font_file.seek(0, 2) # Seek to end
|
||||
file_size = font_file.tell()
|
||||
font_file.seek(0) # Reset to beginning
|
||||
max_font_size_bytes = MAX_FONT_SIZE_MB * 1024 * 1024
|
||||
if file_size > max_font_size_bytes:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'File too large (max {MAX_FONT_SIZE_MB}MB, got {file_size / 1024 / 1024:.1f}MB)'
|
||||
}), 400
|
||||
|
||||
font_family = request.form.get('font_family', '')
|
||||
|
||||
if not font_family:
|
||||
return jsonify({'status': 'error', 'message': 'Font file and family name required'}), 400
|
||||
|
||||
# Validate font family name
|
||||
if not font_family.replace('_', '').replace('-', '').isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Font family name must contain only letters, numbers, underscores, and hyphens'}), 400
|
||||
|
||||
# Save the font file to assets/fonts directory
|
||||
fonts_dir = PROJECT_ROOT / "assets" / "fonts"
|
||||
fonts_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create filename from family name
|
||||
original_ext = os.path.splitext(font_file.filename)[1].lower()
|
||||
safe_filename = f"{font_family}{original_ext}"
|
||||
filepath = fonts_dir / safe_filename
|
||||
|
||||
# Check if file already exists
|
||||
if filepath.exists():
|
||||
return jsonify({'status': 'error', 'message': f'Font with name {font_family} already exists'}), 400
|
||||
|
||||
# Save the file
|
||||
font_file.save(str(filepath))
|
||||
|
||||
# Clear font catalog cache
|
||||
try:
|
||||
from web_interface.cache import delete_cached
|
||||
delete_cached('fonts_catalog')
|
||||
except ImportError as e:
|
||||
logger.warning("[FontUpload] Cache module not available: %s", e)
|
||||
except Exception:
|
||||
logger.error("[FontUpload] Failed to clear fonts_catalog cache", exc_info=True)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': f'Font {font_family} uploaded successfully',
|
||||
'font_family': font_family,
|
||||
'filename': safe_filename,
|
||||
'path': f'assets/fonts/{safe_filename}'
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error('Unhandled exception', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/fonts/preview', methods=['GET'])
|
||||
def get_font_preview() -> tuple[Response, int] | Response:
|
||||
"""Generate a preview image of text rendered with a specific font"""
|
||||
try:
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import io
|
||||
import base64
|
||||
|
||||
# Limits to prevent DoS via large image generation on constrained devices
|
||||
MAX_TEXT_CHARS = 100
|
||||
MAX_TEXT_LINES = 3
|
||||
MAX_DIM = 1024 # Max width or height in pixels
|
||||
MAX_PIXELS = 500000 # Max total pixels (e.g., ~700x700)
|
||||
|
||||
font_filename = request.args.get('font', '')
|
||||
text = request.args.get('text', 'Sample Text 123')
|
||||
bg_color = request.args.get('bg', '000000')
|
||||
fg_color = request.args.get('fg', 'ffffff')
|
||||
|
||||
# Validate text length and line count early
|
||||
if len(text) > MAX_TEXT_CHARS:
|
||||
return jsonify({'status': 'error', 'message': f'Text exceeds maximum length of {MAX_TEXT_CHARS} characters'}), 400
|
||||
if text.count('\n') >= MAX_TEXT_LINES:
|
||||
return jsonify({'status': 'error', 'message': f'Text exceeds maximum of {MAX_TEXT_LINES} lines'}), 400
|
||||
|
||||
# Safe integer parsing for size
|
||||
try:
|
||||
size = int(request.args.get('size', 12))
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid font size'}), 400
|
||||
|
||||
if not font_filename:
|
||||
return jsonify({'status': 'error', 'message': 'Font filename required'}), 400
|
||||
|
||||
# Validate size
|
||||
if size < 4 or size > 72:
|
||||
return jsonify({'status': 'error', 'message': 'Font size must be between 4 and 72'}), 400
|
||||
|
||||
# Security: Validate font_filename to prevent path traversal
|
||||
# Only allow alphanumeric, hyphen, underscore, and dot (for extension)
|
||||
safe_name = Path(font_filename).name # Strip any directory components
|
||||
if safe_name != font_filename or '..' in font_filename:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid font filename'}), 400
|
||||
|
||||
# Validate extension
|
||||
allowed_extensions = ['.ttf', '.otf', '.bdf']
|
||||
has_valid_ext = any(safe_name.lower().endswith(ext) for ext in allowed_extensions)
|
||||
name_without_ext = safe_name.rsplit('.', 1)[0] if '.' in safe_name else safe_name
|
||||
|
||||
# Find the font file
|
||||
fonts_dir = PROJECT_ROOT / "assets" / "fonts"
|
||||
if not fonts_dir.exists():
|
||||
return jsonify({'status': 'error', 'message': 'Fonts directory not found'}), 404
|
||||
|
||||
font_path = fonts_dir / safe_name
|
||||
|
||||
if not font_path.exists() and not has_valid_ext:
|
||||
# Try finding by family name (without extension)
|
||||
for ext in allowed_extensions:
|
||||
potential_path = fonts_dir / f"{name_without_ext}{ext}"
|
||||
if potential_path.exists():
|
||||
font_path = potential_path
|
||||
break
|
||||
|
||||
# Final security check: ensure path is within fonts_dir
|
||||
try:
|
||||
font_path.resolve().relative_to(fonts_dir.resolve())
|
||||
except ValueError:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid font path'}), 400
|
||||
|
||||
if not font_path.exists():
|
||||
return jsonify({'status': 'error', 'message': f'Font file not found: {font_filename}'}), 404
|
||||
|
||||
# Parse colors
|
||||
try:
|
||||
bg_rgb = tuple(int(bg_color[i:i+2], 16) for i in (0, 2, 4))
|
||||
fg_rgb = tuple(int(fg_color[i:i+2], 16) for i in (0, 2, 4))
|
||||
except (ValueError, IndexError):
|
||||
bg_rgb = (0, 0, 0)
|
||||
fg_rgb = (255, 255, 255)
|
||||
|
||||
# Load font
|
||||
font = None
|
||||
if str(font_path).endswith('.bdf'):
|
||||
# BDF fonts require complex per-glyph rendering via freetype
|
||||
# Return explicit error rather than showing misleading preview with default font
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'BDF font preview not supported. BDF fonts will render correctly on the LED matrix.'
|
||||
}), 400
|
||||
else:
|
||||
# TTF/OTF fonts
|
||||
try:
|
||||
font = ImageFont.truetype(str(font_path), size)
|
||||
except (IOError, OSError) as e:
|
||||
# IOError/OSError raised for invalid/corrupt font files
|
||||
logger.warning("[FontPreview] Failed to load font %s: %s", font_path, e)
|
||||
font = ImageFont.load_default()
|
||||
|
||||
# Calculate text size
|
||||
temp_img = Image.new('RGB', (1, 1))
|
||||
temp_draw = ImageDraw.Draw(temp_img)
|
||||
bbox = temp_draw.textbbox((0, 0), text, font=font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
text_height = bbox[3] - bbox[1]
|
||||
|
||||
# Create image with padding
|
||||
padding = 10
|
||||
img_width = max(text_width + padding * 2, 100)
|
||||
img_height = max(text_height + padding * 2, 30)
|
||||
|
||||
# Validate resulting image size to prevent memory/CPU spikes
|
||||
if img_width > MAX_DIM or img_height > MAX_DIM:
|
||||
return jsonify({'status': 'error', 'message': 'Requested image too large'}), 400
|
||||
if img_width * img_height > MAX_PIXELS:
|
||||
return jsonify({'status': 'error', 'message': 'Requested image too large'}), 400
|
||||
|
||||
img = Image.new('RGB', (img_width, img_height), bg_rgb)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Center text
|
||||
x = (img_width - text_width) // 2
|
||||
y = (img_height - text_height) // 2
|
||||
|
||||
draw.text((x, y), text, font=font, fill=fg_rgb)
|
||||
|
||||
# Convert to base64
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, format='PNG')
|
||||
buffer.seek(0)
|
||||
img_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'image': f'data:image/png;base64,{img_base64}',
|
||||
'width': img_width,
|
||||
'height': img_height
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error('Unhandled exception', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/fonts/<font_family>', methods=['DELETE'])
|
||||
def delete_font(font_family: str) -> tuple[Response, int] | Response:
|
||||
"""Delete a user-uploaded font file"""
|
||||
try:
|
||||
# Security: Validate font_family to prevent path traversal
|
||||
# Reject if it contains path separators or ..
|
||||
if '..' in font_family or '/' in font_family or '\\' in font_family:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid font family name'}), 400
|
||||
|
||||
# Only allow safe characters: alphanumeric, hyphen, underscore, dot
|
||||
if not re.match(r'^[a-zA-Z0-9_\-\.]+$', font_family):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid font family name'}), 400
|
||||
|
||||
# Check if this is a system font (uses module-level SYSTEM_FONTS frozenset)
|
||||
if font_family.lower() in SYSTEM_FONTS:
|
||||
return jsonify({'status': 'error', 'message': 'Cannot delete system fonts'}), 403
|
||||
|
||||
# Find and delete the font file
|
||||
fonts_dir = PROJECT_ROOT / "assets" / "fonts"
|
||||
|
||||
# Ensure fonts directory exists
|
||||
if not fonts_dir.exists() or not fonts_dir.is_dir():
|
||||
return jsonify({'status': 'error', 'message': 'Fonts directory not found'}), 404
|
||||
|
||||
deleted = False
|
||||
deleted_filename = None
|
||||
|
||||
# Only try valid font extensions (no empty string to avoid matching directories)
|
||||
for ext in ['.ttf', '.otf', '.bdf']:
|
||||
potential_path = fonts_dir / f"{font_family}{ext}"
|
||||
|
||||
# Security: Verify path is within fonts_dir
|
||||
try:
|
||||
potential_path.resolve().relative_to(fonts_dir.resolve())
|
||||
except ValueError:
|
||||
continue # Path escapes fonts_dir, skip
|
||||
|
||||
if potential_path.exists() and potential_path.is_file():
|
||||
potential_path.unlink()
|
||||
deleted = True
|
||||
deleted_filename = f"{font_family}{ext}"
|
||||
break
|
||||
|
||||
if not deleted:
|
||||
# Try case-insensitive match within fonts directory
|
||||
font_family_lower = font_family.lower()
|
||||
for filename in os.listdir(fonts_dir):
|
||||
# Only consider files with valid font extensions
|
||||
if not any(filename.lower().endswith(ext) for ext in ['.ttf', '.otf', '.bdf']):
|
||||
continue
|
||||
|
||||
name_without_ext = os.path.splitext(filename)[0]
|
||||
if name_without_ext.lower() == font_family_lower:
|
||||
filepath = fonts_dir / filename
|
||||
|
||||
# Security: Verify path is within fonts_dir
|
||||
try:
|
||||
filepath.resolve().relative_to(fonts_dir.resolve())
|
||||
except ValueError:
|
||||
continue # Path escapes fonts_dir, skip
|
||||
|
||||
if filepath.is_file():
|
||||
filepath.unlink()
|
||||
deleted = True
|
||||
deleted_filename = filename
|
||||
break
|
||||
|
||||
if not deleted:
|
||||
return jsonify({'status': 'error', 'message': f'Font not found: {font_family}'}), 404
|
||||
|
||||
# Clear font catalog cache
|
||||
try:
|
||||
from web_interface.cache import delete_cached
|
||||
delete_cached('fonts_catalog')
|
||||
except ImportError as e:
|
||||
logger.warning("[FontDelete] Cache module not available: %s", e)
|
||||
except Exception:
|
||||
logger.error("[FontDelete] Failed to clear fonts_catalog cache", exc_info=True)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': f'Font {deleted_filename} deleted successfully'
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error('Unhandled exception', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@@ -0,0 +1,523 @@
|
||||
"""Routes with no larger group of their own: errors, integrations,
|
||||
cache, sync, skins, logs, health and hardware.
|
||||
|
||||
Routes decorate the shared `api_v3` Blueprint from ._common, so their
|
||||
endpoint names are unchanged by living here.
|
||||
"""
|
||||
from web_interface.blueprints.api_v3 import (
|
||||
_coerce_to_bool,
|
||||
ErrorCode, Path, _JOURNALCTL, _MQTT_BRIDGE_CONFIG, _MQTT_BRIDGE_DEFAULTS,
|
||||
_MQTT_BRIDGE_DIR, _SUDO, _coerce_mqtt_bridge_value,
|
||||
_get_display_service_status, _mqtt_bridge_service_state,
|
||||
_read_mqtt_bridge_config, api_v3, contextlib, describe_exception,
|
||||
error_response, get_error_aggregator, json, jsonify, logger, os, request,
|
||||
subprocess, success_response, tempfile,
|
||||
)
|
||||
from src.common.path_safety import safe_path_component
|
||||
import web_interface.blueprints.api_v3 as _pkg
|
||||
# Read through the module rather than bound by value: tests patch these
|
||||
# as module attributes, and a value binding would not see the patch.
|
||||
# Several are also called from helpers that live in __init__, so the
|
||||
# package is the only patch point that covers every caller.
|
||||
|
||||
|
||||
@api_v3.route('/health', methods=['GET'])
|
||||
def get_health():
|
||||
"""Get system health status"""
|
||||
try:
|
||||
health_status = {
|
||||
'status': 'healthy',
|
||||
'timestamp': _pkg.time.time(),
|
||||
'services': {},
|
||||
'checks': {}
|
||||
}
|
||||
|
||||
# Check web interface service
|
||||
# Stamp the start _pkg.time before measuring against it -- reading it with a
|
||||
# fallback of _pkg.time.time() and only assigning afterwards made the very
|
||||
# first call subtract two separate clock reads, reporting a small
|
||||
# negative uptime.
|
||||
if not hasattr(get_health, '_start_time'):
|
||||
get_health._start_time = _pkg.time.time()
|
||||
health_status['services']['web_interface'] = {
|
||||
'status': 'running',
|
||||
'uptime_seconds': _pkg.time.time() - get_health._start_time
|
||||
}
|
||||
|
||||
# Check display service
|
||||
display_service_status = _get_display_service_status()
|
||||
health_status['services']['display_service'] = {
|
||||
'status': 'active' if display_service_status.get('active') else 'inactive',
|
||||
'details': display_service_status
|
||||
}
|
||||
|
||||
# Check config file accessibility
|
||||
try:
|
||||
if api_v3.config_manager:
|
||||
test_config = api_v3.config_manager.load_config()
|
||||
health_status['checks']['config_file'] = {
|
||||
'status': 'accessible',
|
||||
'readable': True
|
||||
}
|
||||
else:
|
||||
health_status['checks']['config_file'] = {
|
||||
'status': 'unknown',
|
||||
'readable': False
|
||||
}
|
||||
except Exception as e:
|
||||
health_status['checks']['config_file'] = {
|
||||
'status': 'error',
|
||||
'readable': False,
|
||||
'error': 'see logs for details'
|
||||
}
|
||||
|
||||
# Check plugin system
|
||||
try:
|
||||
if api_v3.plugin_manager:
|
||||
# Try to discover plugins (lightweight check)
|
||||
plugin_count = len(api_v3.plugin_manager.get_available_plugins()) if hasattr(api_v3.plugin_manager, 'get_available_plugins') else 0
|
||||
health_status['checks']['plugin_system'] = {
|
||||
'status': 'operational',
|
||||
'plugin_count': plugin_count
|
||||
}
|
||||
else:
|
||||
health_status['checks']['plugin_system'] = {
|
||||
'status': 'not_initialized'
|
||||
}
|
||||
except Exception as e:
|
||||
health_status['checks']['plugin_system'] = {
|
||||
'status': 'error',
|
||||
'error': 'see logs for details'
|
||||
}
|
||||
|
||||
# Check hardware connectivity (if display manager available)
|
||||
try:
|
||||
snapshot_path = "/tmp/led_matrix_preview.png"
|
||||
if os.path.exists(snapshot_path):
|
||||
# Check if snapshot is recent (updated in last 60 seconds)
|
||||
mtime = os.path.getmtime(snapshot_path)
|
||||
age_seconds = _pkg.time.time() - mtime
|
||||
health_status['checks']['hardware'] = {
|
||||
'status': 'connected' if age_seconds < 60 else 'stale',
|
||||
'snapshot_age_seconds': round(age_seconds, 1)
|
||||
}
|
||||
else:
|
||||
health_status['checks']['hardware'] = {
|
||||
'status': 'no_snapshot',
|
||||
'note': 'Display service may not be running'
|
||||
}
|
||||
except Exception as e:
|
||||
health_status['checks']['hardware'] = {
|
||||
'status': 'unknown',
|
||||
'error': 'see logs for details'
|
||||
}
|
||||
|
||||
# Determine overall health
|
||||
all_healthy = all(
|
||||
check.get('status') in ['accessible', 'operational', 'connected', 'running', 'active']
|
||||
for check in health_status['checks'].values()
|
||||
)
|
||||
|
||||
if not all_healthy:
|
||||
health_status['status'] = 'degraded'
|
||||
|
||||
return jsonify({'status': 'success', 'data': health_status})
|
||||
except Exception as e:
|
||||
logger.error("%s failed", request.path, exc_info=True)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'An error occurred; see logs for details',
|
||||
'details': describe_exception(e),
|
||||
'data': {'status': 'unhealthy'}
|
||||
}), 500
|
||||
@api_v3.route('/hardware/status', methods=['GET'])
|
||||
def get_hardware_status():
|
||||
"""Return LED matrix hardware initialization status written by display_manager at startup."""
|
||||
status_path = "/tmp/led_matrix_hw_status.json" # nosec B108
|
||||
try:
|
||||
with open(status_path) as f:
|
||||
hw_data = json.load(f)
|
||||
return jsonify({"status": "success", "data": hw_data})
|
||||
except FileNotFoundError:
|
||||
return jsonify({"status": "success", "data": {"ok": None, "error": "Display service not yet started"}})
|
||||
except PermissionError:
|
||||
logger.warning("Permission denied reading hardware status file; display service may be running as a different user")
|
||||
return jsonify({"status": "success", "data": {"ok": False, "error": "Hardware status temporarily unavailable"}})
|
||||
except json.JSONDecodeError:
|
||||
logger.error("Failed to parse hardware status file", exc_info=True)
|
||||
return jsonify({"status": "success", "data": {"ok": False, "error": "Hardware status file corrupted"}})
|
||||
except Exception:
|
||||
logger.error("Unexpected error reading hardware status", exc_info=True)
|
||||
return jsonify({"status": "error", "message": "Unable to read hardware status"}), 500
|
||||
@api_v3.route('/skins', methods=['GET'])
|
||||
def list_skins():
|
||||
"""List installed visual skins (docs/SKIN_SYSTEM.md).
|
||||
|
||||
Optional ?plugin_id=... filters to skins matching that plugin.
|
||||
"""
|
||||
try:
|
||||
from src.skin_system import skin_runtime
|
||||
|
||||
plugin_id = request.args.get('plugin_id')
|
||||
if plugin_id:
|
||||
skins = skin_runtime.skins_for_plugin(plugin_id)
|
||||
else:
|
||||
# The discovery cache self-invalidates on directory/manifest
|
||||
# mtime changes, so no force_refresh — keeps Pi disk I/O down.
|
||||
skins = skin_runtime.discover_skins()
|
||||
|
||||
payload = []
|
||||
for skin_id, manifest in sorted(skins.items()):
|
||||
skin_dir = Path(manifest['_skin_dir'])
|
||||
preview = manifest.get('preview')
|
||||
payload.append({
|
||||
'id': skin_id,
|
||||
'name': manifest.get('name', skin_id),
|
||||
'version': manifest.get('version'),
|
||||
'author': manifest.get('author'),
|
||||
'description': manifest.get('description', ''),
|
||||
'skin_api_version': manifest.get('skin_api_version'),
|
||||
'targets': manifest.get('targets', {}),
|
||||
'modes': manifest.get('modes', []),
|
||||
'has_preview': bool(preview and (skin_dir / preview).is_file()),
|
||||
})
|
||||
return jsonify({'status': 'success', 'data': {'skins': payload}})
|
||||
except Exception as e:
|
||||
logger.error('Error in list_skins', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/logs', methods=['GET'])
|
||||
def get_logs():
|
||||
"""Get system logs from journalctl"""
|
||||
try:
|
||||
if not _JOURNALCTL:
|
||||
return jsonify({'status': 'error', 'message': 'journalctl not found on this system'}), 503
|
||||
# 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(
|
||||
_cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
logs_text = result.stdout.strip()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'logs': logs_text if logs_text else 'No logs available from ledmatrix or ledmatrix-web service'
|
||||
}
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Failed to get logs: {result.stderr}'
|
||||
}), 500
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Timeout while fetching logs'
|
||||
}), 500
|
||||
except Exception as e:
|
||||
logger.error("%s failed", request.path, exc_info=True)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'An error occurred; see logs for details',
|
||||
'details': describe_exception(e)
|
||||
}), 500
|
||||
# Multi-Display Sync Endpoints
|
||||
@api_v3.route('/sync/status', methods=['GET'])
|
||||
def get_sync_status():
|
||||
"""Return live multi-display sync status written by the display process."""
|
||||
import os as _os
|
||||
status_file = "/tmp/led_matrix_sync_status.json"
|
||||
# Also surface config so the UI can show the configured role even before
|
||||
# the display process has written a status file.
|
||||
cfg_role = "standalone"
|
||||
cfg_port = 5765
|
||||
if api_v3.config_manager:
|
||||
try:
|
||||
cfg = api_v3.config_manager.load_config().get("sync", {})
|
||||
cfg_role = cfg.get("role", "standalone")
|
||||
cfg_port = int(cfg.get("port", 5765))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if _os.path.exists(status_file):
|
||||
try:
|
||||
with open(status_file) as f:
|
||||
live = json.load(f)
|
||||
return jsonify({"status": "success", "data": live})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Status file not yet written — return config-only placeholder
|
||||
return jsonify({
|
||||
"status": "success",
|
||||
"data": {
|
||||
"role": cfg_role,
|
||||
"port": cfg_port,
|
||||
"state": "starting",
|
||||
}
|
||||
})
|
||||
@api_v3.route('/cache/list', methods=['GET'])
|
||||
def list_cache_files():
|
||||
"""List all cache files with metadata"""
|
||||
try:
|
||||
if not api_v3.cache_manager:
|
||||
# Initialize cache manager if not already initialized
|
||||
from src.cache_manager import CacheManager
|
||||
api_v3.cache_manager = CacheManager()
|
||||
|
||||
cache_files = api_v3.cache_manager.list_cache_files()
|
||||
cache_dir = api_v3.cache_manager.get_cache_dir()
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'cache_files': cache_files,
|
||||
'cache_dir': cache_dir,
|
||||
'total_files': len(cache_files)
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error('Error in list_cache_files', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/cache/delete', methods=['POST'])
|
||||
def delete_cache_file():
|
||||
"""Delete a specific cache file by key"""
|
||||
try:
|
||||
if not api_v3.cache_manager:
|
||||
# Initialize cache manager if not already initialized
|
||||
from src.cache_manager import CacheManager
|
||||
api_v3.cache_manager = CacheManager()
|
||||
|
||||
data = request.get_json(silent=True)
|
||||
if not data or 'key' not in data:
|
||||
return jsonify({'status': 'error', 'message': 'cache key is required'}), 400
|
||||
|
||||
cache_key = data['key']
|
||||
|
||||
# The key names the file about to be removed. DiskCache refuses an
|
||||
# unusable key on its own, but silently: say so here instead of
|
||||
# reporting a deletion that never happened.
|
||||
if safe_path_component(cache_key) is None:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid cache key'}), 400
|
||||
|
||||
# Delete the cache file
|
||||
api_v3.cache_manager.clear_cache(cache_key)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': f'Cache file for key "{cache_key}" deleted successfully'
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error('Error in delete_cache_file', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/errors/summary', methods=['GET'])
|
||||
def get_error_summary():
|
||||
"""
|
||||
Get summary of all errors for monitoring and debugging.
|
||||
|
||||
Returns error counts, detected patterns, and recent errors.
|
||||
"""
|
||||
try:
|
||||
aggregator = get_error_aggregator()
|
||||
summary = aggregator.get_error_summary()
|
||||
return success_response(data=summary, message="Error summary retrieved")
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting error summary: {e}", exc_info=True)
|
||||
return error_response(
|
||||
error_code=ErrorCode.SYSTEM_ERROR,
|
||||
message="Failed to retrieve error summary",
|
||||
status_code=500
|
||||
)
|
||||
@api_v3.route('/errors/plugin/<plugin_id>', methods=['GET'])
|
||||
def get_plugin_errors(plugin_id):
|
||||
"""
|
||||
Get error health status for a specific plugin.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
|
||||
Returns health status and error statistics for the plugin.
|
||||
"""
|
||||
try:
|
||||
aggregator = get_error_aggregator()
|
||||
health = aggregator.get_plugin_health(plugin_id)
|
||||
return success_response(data=health, message="Plugin health retrieved")
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting plugin health for {plugin_id}: {e}", exc_info=True)
|
||||
return error_response(
|
||||
error_code=ErrorCode.SYSTEM_ERROR,
|
||||
message=f"Failed to retrieve health for plugin {plugin_id}",
|
||||
status_code=500
|
||||
)
|
||||
@api_v3.route('/errors/clear', methods=['POST'])
|
||||
def clear_old_errors():
|
||||
"""
|
||||
Clear error records older than specified age.
|
||||
|
||||
Request body (optional):
|
||||
max_age_hours: Maximum age in hours (default: 24, max: 8760 = 1 year)
|
||||
"""
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
raw_max_age = data.get('max_age_hours', 24)
|
||||
|
||||
# Validate and coerce max_age_hours
|
||||
try:
|
||||
max_age_hours = int(raw_max_age)
|
||||
if max_age_hours < 1:
|
||||
return error_response(
|
||||
error_code=ErrorCode.INVALID_INPUT,
|
||||
message="max_age_hours must be at least 1",
|
||||
context={'provided_value': raw_max_age},
|
||||
status_code=400
|
||||
)
|
||||
if max_age_hours > 8760: # 1 year max
|
||||
return error_response(
|
||||
error_code=ErrorCode.INVALID_INPUT,
|
||||
message="max_age_hours cannot exceed 8760 (1 year)",
|
||||
context={'provided_value': raw_max_age},
|
||||
status_code=400
|
||||
)
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
return error_response(
|
||||
error_code=ErrorCode.INVALID_INPUT,
|
||||
message="max_age_hours must be a valid integer",
|
||||
context={'provided_value': str(raw_max_age)},
|
||||
status_code=400
|
||||
)
|
||||
|
||||
aggregator = get_error_aggregator()
|
||||
cleared_count = aggregator.clear_old_records(max_age_hours=max_age_hours)
|
||||
|
||||
return success_response(
|
||||
data={'cleared_count': cleared_count},
|
||||
message=f"Cleared {cleared_count} error records older than {max_age_hours} hours"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error clearing old errors: {e}", exc_info=True)
|
||||
return error_response(
|
||||
error_code=ErrorCode.SYSTEM_ERROR,
|
||||
message="Failed to clear old errors",
|
||||
status_code=500
|
||||
)
|
||||
|
||||
|
||||
@api_v3.route('/integrations/mqtt-bridge', methods=['GET'])
|
||||
def get_mqtt_bridge():
|
||||
"""Bridge service state and its settings, minus the password."""
|
||||
try:
|
||||
config = _read_mqtt_bridge_config()
|
||||
password = config.get('mqtt_password')
|
||||
safe = {key: config.get(key, default)
|
||||
for key, default in _MQTT_BRIDGE_DEFAULTS.items()}
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'service': _mqtt_bridge_service_state(),
|
||||
'config_exists': _MQTT_BRIDGE_CONFIG.is_file(),
|
||||
'config_path': str(_MQTT_BRIDGE_CONFIG),
|
||||
'config': safe,
|
||||
# Enough to render "a password is set" without disclosing it.
|
||||
'password_set': bool(password),
|
||||
'env_override_prefix': 'LEDMATRIX_MQTT_',
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
logger.exception('Error reading MQTT bridge settings')
|
||||
return jsonify({'status': 'error', 'message': 'Could not read bridge settings',
|
||||
'details': describe_exception(e)}), 500
|
||||
|
||||
@api_v3.route('/integrations/mqtt-bridge/config', methods=['PUT'])
|
||||
def update_mqtt_bridge_config():
|
||||
"""Write bridge_config.json.
|
||||
|
||||
The password is write-only: omit it to leave whatever is stored alone, send
|
||||
a value to replace it, or send clear_password to remove it. It is never
|
||||
returned by the GET above, so a form that round-tripped it would otherwise
|
||||
have to blank it on every save.
|
||||
"""
|
||||
try:
|
||||
# No `or {}` here: get_json(silent=True) returns None for a missing or
|
||||
# unparseable body, and `None or {}` produced an empty dict that then
|
||||
# satisfied the isinstance check below -- so malformed JSON, `null`,
|
||||
# `[]` and `false` all reported success while applying nothing.
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({'status': 'error', 'message': 'Body must be a JSON object'}), 400
|
||||
|
||||
config = _read_mqtt_bridge_config()
|
||||
existing_password = config.get('mqtt_password')
|
||||
|
||||
updates = {}
|
||||
for key in _MQTT_BRIDGE_DEFAULTS:
|
||||
if key not in data:
|
||||
continue
|
||||
value, err = _coerce_mqtt_bridge_value(key, data[key])
|
||||
if err:
|
||||
return jsonify({'status': 'error', 'message': err}), 400
|
||||
updates[key] = value
|
||||
|
||||
config.update(updates)
|
||||
|
||||
# Coerced, not merely truthy: the string "false" is truthy in Python,
|
||||
# so a client echoing the field back as a string would have wiped a
|
||||
# stored password it meant to keep.
|
||||
if _coerce_to_bool(data.get('clear_password')):
|
||||
config['mqtt_password'] = None
|
||||
elif 'mqtt_password' in data and str(data['mqtt_password']) != '':
|
||||
new_password = str(data['mqtt_password'])
|
||||
if len(new_password) > 300:
|
||||
return jsonify({'status': 'error', 'message': 'Password is too long'}), 400
|
||||
config['mqtt_password'] = new_password
|
||||
else:
|
||||
config['mqtt_password'] = existing_password
|
||||
|
||||
# CWE-319: a password with TLS off is sent in the clear. On a trusted
|
||||
# LAN that is a normal, deliberate setup, so this is refused rather
|
||||
# than forbidden -- allow_insecure_mqtt is the explicit acknowledgement.
|
||||
insecure = bool(config.get('mqtt_password')) and not config.get('mqtt_tls')
|
||||
if insecure and not config.get('allow_insecure_mqtt'):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'MQTT credentials would cross the network in cleartext '
|
||||
'with TLS disabled. Enable mqtt_tls, or set '
|
||||
'allow_insecure_mqtt to accept that on a trusted network.'
|
||||
}), 400
|
||||
if insecure:
|
||||
logger.warning('MQTT bridge: a password is set without TLS and '
|
||||
'allow_insecure_mqtt is on; credentials will cross the '
|
||||
'network in cleartext')
|
||||
|
||||
_MQTT_BRIDGE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
# Write via a temp file in the same directory so a crash mid-write
|
||||
# cannot leave a half-written config the bridge would refuse to load.
|
||||
fd, tmp_path = tempfile.mkstemp(dir=str(_MQTT_BRIDGE_DIR), prefix='.bridge_config.')
|
||||
try:
|
||||
with os.fdopen(fd, 'w', encoding='utf-8') as handle:
|
||||
json.dump(config, handle, indent=2, sort_keys=True)
|
||||
handle.write('\n')
|
||||
os.chmod(tmp_path, 0o600)
|
||||
os.replace(tmp_path, _MQTT_BRIDGE_CONFIG)
|
||||
except Exception:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(tmp_path)
|
||||
raise
|
||||
|
||||
service = _mqtt_bridge_service_state()
|
||||
message = 'Bridge settings saved.'
|
||||
if service['active']:
|
||||
message += ' Restart the bridge for them to take effect.'
|
||||
return jsonify({'status': 'success', 'message': message,
|
||||
'data': {'password_set': bool(config.get('mqtt_password')),
|
||||
'restart_required': service['active']}})
|
||||
except Exception as e:
|
||||
logger.exception('Error saving MQTT bridge settings')
|
||||
return jsonify({'status': 'error', 'message': 'Could not save bridge settings',
|
||||
'details': describe_exception(e)}), 500
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,958 @@
|
||||
"""Starlark / Tronbyte app management routes.
|
||||
|
||||
Routes decorate the shared `api_v3` Blueprint from ._common, so their
|
||||
endpoint names are unchanged by living here.
|
||||
"""
|
||||
import signal
|
||||
import threading
|
||||
|
||||
from web_interface.blueprints.api_v3 import (
|
||||
PROJECT_ROOT, Path, _PIXLET_EDITOR_DEFAULT_PORT,
|
||||
_PIXLET_EDITOR_DEFAULT_TIMEOUT, _PIXLET_EDITOR_MAX_TIMEOUT,
|
||||
_PIXLET_EDITOR_SCRIPT, _PIXLET_EDITOR_STATE, _clear_pixlet_editor_state,
|
||||
_find_pixlet_binary, _install_star_file, _pixlet_editor_alive,
|
||||
_pixlet_editor_status, _read_pixlet_editor_state,
|
||||
_STARLARK_APPS_DIR, _standalone_render_starlark_app,
|
||||
_starlark_github_token, _starlark_manifest_lock,
|
||||
_validate_and_sanitize_app_id,
|
||||
_validate_starlark_app_path, _validate_timing_value, api_v3, contextlib,
|
||||
describe_exception, json, jsonify, logger, os, request, shutil,
|
||||
subprocess, tempfile,
|
||||
)
|
||||
import web_interface.blueprints.api_v3 as _pkg
|
||||
# Read through the module rather than bound by value: tests patch these
|
||||
# as module attributes, and a value binding would not see the patch.
|
||||
# Several are also called from helpers that live in __init__, so the
|
||||
# package is the only patch point that covers every caller.
|
||||
|
||||
|
||||
@api_v3.route('/starlark/status', methods=['GET'])
|
||||
def get_starlark_status():
|
||||
"""Get Starlark plugin status and Pixlet availability."""
|
||||
try:
|
||||
starlark_plugin = _pkg._get_starlark_plugin()
|
||||
if starlark_plugin:
|
||||
info = starlark_plugin.get_info()
|
||||
magnify_info = starlark_plugin.get_magnify_recommendation()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'pixlet_available': info.get('pixlet_available', False),
|
||||
'pixlet_version': info.get('pixlet_version'),
|
||||
'installed_apps': info.get('installed_apps', 0),
|
||||
'enabled_apps': info.get('enabled_apps', 0),
|
||||
'current_app': info.get('current_app'),
|
||||
'plugin_enabled': starlark_plugin.enabled,
|
||||
'display_info': magnify_info
|
||||
})
|
||||
|
||||
# Plugin not loaded - check Pixlet availability via shared resolver
|
||||
# (respects user-configured pixlet_path, bundled binary, and system PATH)
|
||||
full_config = api_v3.config_manager.load_config() if api_v3.config_manager else {}
|
||||
pixlet_path = _find_pixlet_binary(full_config.get('starlark-apps', {}).get('pixlet_path'))
|
||||
pixlet_available = pixlet_path is not None
|
||||
|
||||
# Read app counts from manifest
|
||||
manifest = _pkg._read_starlark_manifest()
|
||||
apps = manifest.get('apps', {})
|
||||
installed_count = len(apps)
|
||||
enabled_count = sum(1 for a in apps.values() if a.get('enabled', True))
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'pixlet_available': pixlet_available,
|
||||
'pixlet_version': None,
|
||||
'installed_apps': installed_count,
|
||||
'enabled_apps': enabled_count,
|
||||
'plugin_enabled': True,
|
||||
'plugin_loaded': False,
|
||||
'display_info': {}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("[Starlark] get_starlark_status failed")
|
||||
return jsonify({'status': 'error', 'message': 'Failed to get Starlark status', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/starlark/install-pixlet', methods=['POST'])
|
||||
def install_pixlet():
|
||||
"""Download and install Pixlet binary."""
|
||||
try:
|
||||
script_path = PROJECT_ROOT / 'scripts' / 'download_pixlet.sh'
|
||||
if not script_path.exists():
|
||||
return jsonify({'status': 'error', 'message': 'Installation script not found'}), 404
|
||||
|
||||
os.chmod(script_path, 0o755)
|
||||
|
||||
result = subprocess.run(
|
||||
[str(script_path)],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
logger.info("Pixlet downloaded successfully")
|
||||
return jsonify({'status': 'success', 'message': 'Pixlet installed successfully!', 'output': result.stdout})
|
||||
else:
|
||||
return jsonify({'status': 'error', 'message': f'Failed to download Pixlet: {result.stderr}'}), 500
|
||||
|
||||
except subprocess.TimeoutExpired as err:
|
||||
logger.exception("[Starlark] Pixlet download timed out")
|
||||
return jsonify({'status': 'error', 'message': 'Download timed out',
|
||||
'details': describe_exception(err)}), 500
|
||||
except Exception as e:
|
||||
logger.exception("[Starlark] install_pixlet failed")
|
||||
return jsonify({'status': 'error', 'message': 'Failed to install Pixlet', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/starlark/apps', methods=['GET'])
|
||||
def get_starlark_apps():
|
||||
"""List all installed Starlark apps."""
|
||||
try:
|
||||
starlark_plugin = _pkg._get_starlark_plugin()
|
||||
if starlark_plugin:
|
||||
apps_list = []
|
||||
for app_id, app_instance in starlark_plugin.apps.items():
|
||||
apps_list.append({
|
||||
'id': app_id,
|
||||
'name': app_instance.manifest.get('name', app_id),
|
||||
'enabled': app_instance.is_enabled(),
|
||||
'has_frames': app_instance.frames is not None,
|
||||
'render_interval': app_instance.get_render_interval(),
|
||||
'display_duration': app_instance.get_display_duration(),
|
||||
'config': app_instance.config,
|
||||
'has_schema': app_instance.schema is not None,
|
||||
'last_render_time': app_instance.last_render_time
|
||||
})
|
||||
return jsonify({'status': 'success', 'apps': apps_list, 'count': len(apps_list)})
|
||||
|
||||
# Standalone: read manifest from disk
|
||||
manifest = _pkg._read_starlark_manifest()
|
||||
apps_list = []
|
||||
for app_id, app_data in manifest.get('apps', {}).items():
|
||||
apps_list.append({
|
||||
'id': app_id,
|
||||
'name': app_data.get('name', app_id),
|
||||
'enabled': app_data.get('enabled', True),
|
||||
'has_frames': False,
|
||||
'render_interval': app_data.get('render_interval', 300),
|
||||
'display_duration': app_data.get('display_duration', 15),
|
||||
'config': app_data.get('config', {}),
|
||||
'has_schema': False,
|
||||
'last_render_time': None
|
||||
})
|
||||
return jsonify({'status': 'success', 'apps': apps_list, 'count': len(apps_list)})
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("[Starlark] get_starlark_apps failed")
|
||||
return jsonify({'status': 'error', 'message': 'Failed to get Starlark apps', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/starlark/apps/<app_id>', methods=['GET'])
|
||||
def get_starlark_app(app_id):
|
||||
"""Get details for a specific Starlark app."""
|
||||
try:
|
||||
# Validate app_id before any filesystem access
|
||||
app_dir, error_msg = _validate_starlark_app_path(app_id)
|
||||
if error_msg:
|
||||
return jsonify({'status': 'error', 'message': error_msg}), 400
|
||||
|
||||
starlark_plugin = _pkg._get_starlark_plugin()
|
||||
if starlark_plugin:
|
||||
app = starlark_plugin.apps.get(app_id)
|
||||
if not app:
|
||||
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'app': {
|
||||
'id': app_id,
|
||||
'name': app.manifest.get('name', app_id),
|
||||
'enabled': app.is_enabled(),
|
||||
'config': app.config,
|
||||
'schema': app.schema,
|
||||
'render_interval': app.get_render_interval(),
|
||||
'display_duration': app.get_display_duration(),
|
||||
'has_frames': app.frames is not None,
|
||||
'frame_count': len(app.frames) if app.frames else 0,
|
||||
'last_render_time': app.last_render_time,
|
||||
}
|
||||
})
|
||||
|
||||
# Standalone: read from manifest
|
||||
manifest = _pkg._read_starlark_manifest()
|
||||
app_data = manifest.get('apps', {}).get(app_id)
|
||||
if not app_data:
|
||||
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
|
||||
|
||||
# Load schema from schema.json if it exists (path already validated above)
|
||||
schema = None
|
||||
schema_file = app_dir / 'schema.json'
|
||||
if schema_file.exists():
|
||||
try:
|
||||
with open(schema_file, 'r') as f:
|
||||
schema = json.load(f)
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
logger.warning(f"Failed to load schema for {app_id}: {e}")
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'app': {
|
||||
'id': app_id,
|
||||
'name': app_data.get('name', app_id),
|
||||
'enabled': app_data.get('enabled', True),
|
||||
'config': app_data.get('config', {}),
|
||||
'schema': schema,
|
||||
'render_interval': app_data.get('render_interval', 300),
|
||||
'display_duration': app_data.get('display_duration', 15),
|
||||
'has_frames': False,
|
||||
'frame_count': 0,
|
||||
'last_render_time': None,
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("[Starlark] get_starlark_app failed")
|
||||
return jsonify({'status': 'error', 'message': 'Failed to get Starlark app', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/starlark/upload', methods=['POST'])
|
||||
def upload_starlark_app():
|
||||
"""Upload and install a new Starlark app."""
|
||||
try:
|
||||
if 'file' not in request.files:
|
||||
return jsonify({'status': 'error', 'message': 'No file uploaded'}), 400
|
||||
|
||||
file = request.files['file']
|
||||
if not file.filename or not file.filename.endswith('.star'):
|
||||
return jsonify({'status': 'error', 'message': 'File must have .star extension'}), 400
|
||||
|
||||
# Check file size (limit to 5MB for .star files)
|
||||
file.seek(0, 2) # Seek to end
|
||||
file_size = file.tell()
|
||||
file.seek(0) # Reset to beginning
|
||||
MAX_STAR_SIZE = 5 * 1024 * 1024 # 5MB
|
||||
if file_size > MAX_STAR_SIZE:
|
||||
return jsonify({'status': 'error', 'message': f'File too large (max 5MB, got {file_size/1024/1024:.1f}MB)'}), 400
|
||||
|
||||
app_name = request.form.get('name')
|
||||
app_id_input = request.form.get('app_id')
|
||||
filename_base = file.filename.replace('.star', '') if file.filename else None
|
||||
app_id, app_id_error = _validate_and_sanitize_app_id(app_id_input, fallback_source=filename_base)
|
||||
if app_id_error:
|
||||
return jsonify({'status': 'error', 'message': f'Invalid app_id: {app_id_error}'}), 400
|
||||
|
||||
render_interval_input = request.form.get('render_interval')
|
||||
render_interval = 300
|
||||
if render_interval_input is not None:
|
||||
render_interval, err = _validate_timing_value(render_interval_input, 'render_interval')
|
||||
if err:
|
||||
return jsonify({'status': 'error', 'message': err}), 400
|
||||
render_interval = render_interval or 300
|
||||
|
||||
display_duration_input = request.form.get('display_duration')
|
||||
display_duration = 15
|
||||
if display_duration_input is not None:
|
||||
display_duration, err = _validate_timing_value(display_duration_input, 'display_duration')
|
||||
if err:
|
||||
return jsonify({'status': 'error', 'message': err}), 400
|
||||
display_duration = display_duration or 15
|
||||
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix='.star') as tmp:
|
||||
file.save(tmp.name)
|
||||
temp_path = tmp.name
|
||||
|
||||
try:
|
||||
metadata = {'name': app_name or app_id, 'render_interval': render_interval, 'display_duration': display_duration}
|
||||
starlark_plugin = _pkg._get_starlark_plugin()
|
||||
if starlark_plugin:
|
||||
success = starlark_plugin.install_app(app_id, temp_path, metadata)
|
||||
else:
|
||||
success = _install_star_file(app_id, temp_path, metadata)
|
||||
if success:
|
||||
return jsonify({'status': 'success', 'message': f'App installed: {app_id}', 'app_id': app_id})
|
||||
else:
|
||||
return jsonify({'status': 'error', 'message': 'Failed to install app'}), 500
|
||||
finally:
|
||||
try:
|
||||
os.unlink(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
except (OSError, IOError) as err:
|
||||
# This used to withhold the detail because it names absolute paths on
|
||||
# the device. A full disk and a bad permission are indistinguishable
|
||||
# without it, though, and describe_exception redacts credentials and
|
||||
# truncates -- the same trade-off every other handler here makes.
|
||||
logger.exception("[Starlark] File error uploading starlark app: %s", err)
|
||||
return jsonify({'status': 'error', 'message': 'File error during upload',
|
||||
'details': describe_exception(err)}), 500
|
||||
except ImportError as err:
|
||||
logger.exception("[Starlark] Module load error uploading starlark app: %s", err)
|
||||
return jsonify({'status': 'error', 'message': 'Failed to load app module', 'details': describe_exception(err)}), 500
|
||||
except Exception as err:
|
||||
logger.exception("[Starlark] Unexpected error uploading starlark app: %s", err)
|
||||
return jsonify({'status': 'error', 'message': 'Failed to upload app', 'details': describe_exception(err)}), 500
|
||||
@api_v3.route('/starlark/apps/<app_id>', methods=['DELETE'])
|
||||
def uninstall_starlark_app(app_id):
|
||||
"""Uninstall a Starlark app."""
|
||||
try:
|
||||
# Validate app_id before any filesystem access
|
||||
app_dir, error_msg = _validate_starlark_app_path(app_id)
|
||||
if error_msg:
|
||||
return jsonify({'status': 'error', 'message': error_msg}), 400
|
||||
|
||||
starlark_plugin = _pkg._get_starlark_plugin()
|
||||
if starlark_plugin:
|
||||
success = starlark_plugin.uninstall_app(app_id)
|
||||
else:
|
||||
# Standalone: remove app dir and manifest entry. app_dir is the
|
||||
# path _validate_starlark_app_path checked, not a fresh join.
|
||||
import shutil
|
||||
if app_dir.exists():
|
||||
shutil.rmtree(app_dir)
|
||||
with _starlark_manifest_lock():
|
||||
manifest = _pkg._read_starlark_manifest()
|
||||
manifest.get('apps', {}).pop(app_id, None)
|
||||
success = _pkg._write_starlark_manifest(manifest)
|
||||
|
||||
if success:
|
||||
return jsonify({'status': 'success', 'message': f'App uninstalled: {app_id}'})
|
||||
else:
|
||||
return jsonify({'status': 'error', 'message': 'Failed to uninstall app'}), 500
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("[Starlark] uninstall_starlark_app failed")
|
||||
return jsonify({'status': 'error', 'message': 'Failed to uninstall Starlark app', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/starlark/apps/<app_id>/config', methods=['GET'])
|
||||
def get_starlark_app_config(app_id):
|
||||
"""Get configuration for a Starlark app."""
|
||||
try:
|
||||
# Validate app_id before any filesystem access
|
||||
app_dir, error_msg = _validate_starlark_app_path(app_id)
|
||||
if error_msg:
|
||||
return jsonify({'status': 'error', 'message': error_msg}), 400
|
||||
|
||||
starlark_plugin = _pkg._get_starlark_plugin()
|
||||
if starlark_plugin:
|
||||
app = starlark_plugin.apps.get(app_id)
|
||||
if not app:
|
||||
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
|
||||
return jsonify({'status': 'success', 'config': app.config, 'schema': app.schema})
|
||||
|
||||
# Standalone: read from config.json. app_dir is the path
|
||||
# _validate_starlark_app_path checked, not a fresh join.
|
||||
config_file = app_dir / "config.json"
|
||||
|
||||
if not app_dir.exists():
|
||||
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
|
||||
|
||||
config = {}
|
||||
if config_file.exists():
|
||||
try:
|
||||
with open(config_file, 'r') as f:
|
||||
config = json.load(f)
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
logger.warning(f"Failed to load config for {app_id}: {e}")
|
||||
|
||||
# Load schema from schema.json
|
||||
schema = None
|
||||
schema_file = app_dir / "schema.json"
|
||||
if schema_file.exists():
|
||||
try:
|
||||
with open(schema_file, 'r') as f:
|
||||
schema = json.load(f)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load schema for {app_id}: {e}")
|
||||
|
||||
return jsonify({'status': 'success', 'config': config, 'schema': schema})
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("[Starlark] get_starlark_app_config failed")
|
||||
return jsonify({'status': 'error', 'message': 'Failed to get Starlark app config', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/starlark/apps/<app_id>/config', methods=['PUT'])
|
||||
def update_starlark_app_config(app_id):
|
||||
"""Update configuration for a Starlark app."""
|
||||
try:
|
||||
# Validate app_id before any filesystem access
|
||||
app_dir, error_msg = _validate_starlark_app_path(app_id)
|
||||
if error_msg:
|
||||
return jsonify({'status': 'error', 'message': error_msg}), 400
|
||||
|
||||
data = request.get_json(silent=True)
|
||||
if not data:
|
||||
return jsonify({'status': 'error', 'message': 'No configuration provided'}), 400
|
||||
|
||||
if 'render_interval' in data:
|
||||
val, err = _validate_timing_value(data['render_interval'], 'render_interval')
|
||||
if err:
|
||||
return jsonify({'status': 'error', 'message': err}), 400
|
||||
data['render_interval'] = val
|
||||
|
||||
if 'display_duration' in data:
|
||||
val, err = _validate_timing_value(data['display_duration'], 'display_duration')
|
||||
if err:
|
||||
return jsonify({'status': 'error', 'message': err}), 400
|
||||
data['display_duration'] = val
|
||||
|
||||
starlark_plugin = _pkg._get_starlark_plugin()
|
||||
if starlark_plugin:
|
||||
app = starlark_plugin.apps.get(app_id)
|
||||
if not app:
|
||||
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
|
||||
|
||||
# Extract timing keys from data before updating config (they belong in manifest, not config)
|
||||
render_interval = data.pop('render_interval', None)
|
||||
display_duration = data.pop('display_duration', None)
|
||||
|
||||
# Snapshot before mutating. save_config() can fail, and the route
|
||||
# answers 500 below when it does -- but the loaded app kept the new
|
||||
# values anyway, so a later GET returned configuration that was
|
||||
# never persisted and the plugin rendered with it.
|
||||
prev_config = dict(app.config)
|
||||
prev_manifest = dict(app.manifest)
|
||||
|
||||
# Update config with non-timing fields only
|
||||
app.config.update(data)
|
||||
|
||||
# Update manifest with timing fields
|
||||
timing_changed = False
|
||||
if render_interval is not None:
|
||||
app.manifest['render_interval'] = render_interval
|
||||
timing_changed = True
|
||||
if display_duration is not None:
|
||||
app.manifest['display_duration'] = display_duration
|
||||
timing_changed = True
|
||||
saved = app.save_config()
|
||||
if not saved:
|
||||
app.config.clear(); app.config.update(prev_config)
|
||||
app.manifest.clear(); app.manifest.update(prev_manifest)
|
||||
if saved:
|
||||
# Persist manifest if timing changed (same pattern as toggle endpoint)
|
||||
if timing_changed:
|
||||
try:
|
||||
# Use safe manifest update to prevent race conditions
|
||||
timing_updates = {}
|
||||
if render_interval is not None:
|
||||
timing_updates['render_interval'] = render_interval
|
||||
if display_duration is not None:
|
||||
timing_updates['display_duration'] = display_duration
|
||||
|
||||
def update_fn(manifest):
|
||||
manifest['apps'][app_id].update(timing_updates)
|
||||
starlark_plugin._update_manifest_safe(update_fn)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to persist timing to manifest for {app_id}: {e}")
|
||||
starlark_plugin._render_app(app, force=True)
|
||||
return jsonify({'status': 'success', 'message': 'Configuration updated', 'config': app.config})
|
||||
else:
|
||||
return jsonify({'status': 'error', 'message': 'Failed to save configuration'}), 500
|
||||
|
||||
# Standalone: update both config.json and manifest. Both live under
|
||||
# the manifest lock (serialized against every other standalone
|
||||
# manifest read-modify-write -- see _starlark_manifest_lock), and if
|
||||
# the manifest write fails after config.json was already written,
|
||||
# config.json is rolled back so the two do not end up out of sync.
|
||||
with _starlark_manifest_lock():
|
||||
manifest = _pkg._read_starlark_manifest()
|
||||
app_data = manifest.get('apps', {}).get(app_id)
|
||||
if not app_data:
|
||||
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
|
||||
|
||||
# Extract timing keys (they go in manifest, not config.json)
|
||||
render_interval = data.pop('render_interval', None)
|
||||
display_duration = data.pop('display_duration', None)
|
||||
|
||||
# Update manifest with timing values
|
||||
if render_interval is not None:
|
||||
app_data['render_interval'] = render_interval
|
||||
if display_duration is not None:
|
||||
app_data['display_duration'] = display_duration
|
||||
|
||||
# Load current config from config.json. app_dir is the path
|
||||
# _validate_starlark_app_path checked, not a fresh join.
|
||||
config_file = app_dir / "config.json"
|
||||
existed_before = config_file.exists()
|
||||
previous_config_bytes = None
|
||||
current_config = {}
|
||||
if existed_before:
|
||||
try:
|
||||
previous_config_bytes = config_file.read_bytes()
|
||||
current_config = json.loads(previous_config_bytes)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load config for {app_id}: {e}")
|
||||
|
||||
# Update config with new values (excluding timing keys)
|
||||
current_config.update(data)
|
||||
|
||||
# Write updated config to config.json
|
||||
try:
|
||||
with open(config_file, 'w') as f:
|
||||
json.dump(current_config, f, indent=2)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save config.json for {app_id}: {e}")
|
||||
logger.exception("Failed to save Starlark configuration for %r", app_id)
|
||||
return jsonify({'status': 'error', 'message': 'Failed to save configuration',
|
||||
'details': describe_exception(e)}), 500
|
||||
|
||||
# Also update manifest for backward compatibility
|
||||
app_data.setdefault('config', {}).update(data)
|
||||
|
||||
if _pkg._write_starlark_manifest(manifest):
|
||||
return jsonify({'status': 'success', 'message': 'Configuration updated', 'config': current_config})
|
||||
|
||||
# The manifest write failed after config.json was already
|
||||
# written -- roll config.json back rather than leave the two
|
||||
# disagreeing about what was saved.
|
||||
try:
|
||||
if existed_before and previous_config_bytes is not None:
|
||||
config_file.write_bytes(previous_config_bytes)
|
||||
elif not existed_before:
|
||||
config_file.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
logger.exception(
|
||||
"Failed to roll back config.json for %r after manifest write failure", app_id)
|
||||
return jsonify({'status': 'error', 'message': 'Failed to save manifest'}), 500
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("[Starlark] update_starlark_app_config failed")
|
||||
return jsonify({'status': 'error', 'message': 'Failed to update Starlark app config', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/starlark/apps/<app_id>/toggle', methods=['POST'])
|
||||
def toggle_starlark_app(app_id):
|
||||
"""Enable or disable a Starlark app."""
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
starlark_plugin = _pkg._get_starlark_plugin()
|
||||
if starlark_plugin:
|
||||
app = starlark_plugin.apps.get(app_id)
|
||||
if not app:
|
||||
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
|
||||
enabled = data.get('enabled')
|
||||
if enabled is None:
|
||||
enabled = not app.is_enabled()
|
||||
app.manifest['enabled'] = enabled
|
||||
# Use safe manifest update to prevent race conditions
|
||||
def update_fn(manifest):
|
||||
manifest['apps'][app_id]['enabled'] = enabled
|
||||
starlark_plugin._update_manifest_safe(update_fn)
|
||||
return jsonify({'status': 'success', 'message': f"App {'enabled' if enabled else 'disabled'}", 'enabled': enabled})
|
||||
|
||||
# Standalone: update manifest directly
|
||||
with _starlark_manifest_lock():
|
||||
manifest = _pkg._read_starlark_manifest()
|
||||
app_data = manifest.get('apps', {}).get(app_id)
|
||||
if not app_data:
|
||||
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
|
||||
|
||||
enabled = data.get('enabled')
|
||||
if enabled is None:
|
||||
enabled = not app_data.get('enabled', True)
|
||||
app_data['enabled'] = enabled
|
||||
if _pkg._write_starlark_manifest(manifest):
|
||||
return jsonify({'status': 'success', 'message': f"App {'enabled' if enabled else 'disabled'}", 'enabled': enabled})
|
||||
else:
|
||||
return jsonify({'status': 'error', 'message': 'Failed to save'}), 500
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("[Starlark] toggle_starlark_app failed")
|
||||
return jsonify({'status': 'error', 'message': 'Failed to toggle Starlark app', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/starlark/apps/<app_id>/render', methods=['POST'])
|
||||
def render_starlark_app(app_id):
|
||||
"""Force render a Starlark app."""
|
||||
try:
|
||||
app_dir, err = _validate_starlark_app_path(app_id)
|
||||
if err:
|
||||
return jsonify({'status': 'error', 'message': err}), 400
|
||||
|
||||
starlark_plugin = _pkg._get_starlark_plugin()
|
||||
if starlark_plugin:
|
||||
app = starlark_plugin.apps.get(app_id)
|
||||
if not app:
|
||||
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
|
||||
success = starlark_plugin._render_app(app, force=True)
|
||||
if success:
|
||||
return jsonify({'status': 'success', 'message': 'App rendered',
|
||||
'frame_count': len(app.frames) if app.frames else 0})
|
||||
return jsonify({'status': 'error', 'message': 'Failed to render app'}), 500
|
||||
|
||||
# Web-service context: plugin not loaded, call pixlet directly
|
||||
success, status_code, error = _standalone_render_starlark_app(app_id)
|
||||
if success:
|
||||
return jsonify({'status': 'success', 'message': 'App rendered successfully', 'frame_count': 0}), status_code
|
||||
return jsonify({'status': 'error', 'message': error or 'Render failed', 'frame_count': 0}), status_code
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("[Starlark] render_starlark_app failed")
|
||||
return jsonify({'status': 'error', 'message': 'Failed to render Starlark app', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/starlark/repository/browse', methods=['GET'])
|
||||
def browse_tronbyte_repository():
|
||||
"""Browse all apps in the Tronbyte repository (bulk cached fetch).
|
||||
|
||||
Returns ALL apps with metadata, categories, and authors.
|
||||
Filtering/sorting/pagination is handled client-side.
|
||||
Results are cached server-side for 2 hours.
|
||||
"""
|
||||
try:
|
||||
TronbyteRepository = _pkg._get_tronbyte_repository_class()
|
||||
|
||||
repo = TronbyteRepository(github_token=_starlark_github_token())
|
||||
|
||||
result = repo.list_all_apps_cached()
|
||||
|
||||
rate_limit = repo.get_rate_limit_info()
|
||||
|
||||
# An upstream failure used to arrive here as an empty app list and go
|
||||
# out as 'success', so the store drew an empty grid and said nothing.
|
||||
# 502: the request was fine, GitHub was not.
|
||||
if result.get('error'):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': result['error'],
|
||||
'rate_limit': rate_limit,
|
||||
}), 502
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'apps': result['apps'],
|
||||
'categories': result['categories'],
|
||||
'authors': result['authors'],
|
||||
'count': result['count'],
|
||||
'cached': result['cached'],
|
||||
'rate_limit': rate_limit,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("[Starlark] browse_tronbyte_repository failed")
|
||||
return jsonify({'status': 'error', 'message': 'Failed to browse repository', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/starlark/repository/install', methods=['POST'])
|
||||
def install_from_tronbyte_repository():
|
||||
"""Install an app from the Tronbyte repository."""
|
||||
try:
|
||||
data = request.get_json(silent=True)
|
||||
if not data or 'app_id' not in data:
|
||||
return jsonify({'status': 'error', 'message': 'app_id is required'}), 400
|
||||
|
||||
app_id, app_id_error = _validate_and_sanitize_app_id(data['app_id'])
|
||||
if app_id_error:
|
||||
return jsonify({'status': 'error', 'message': f'Invalid app_id: {app_id_error}'}), 400
|
||||
|
||||
TronbyteRepository = _pkg._get_tronbyte_repository_class()
|
||||
import tempfile
|
||||
|
||||
repo = TronbyteRepository(github_token=_starlark_github_token())
|
||||
|
||||
success, metadata, error = repo.get_app_metadata(data['app_id'])
|
||||
if not success:
|
||||
return jsonify({'status': 'error', 'message': f'Failed to fetch app metadata: {error}'}), 404
|
||||
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix='.star') as tmp:
|
||||
temp_path = tmp.name
|
||||
|
||||
try:
|
||||
# Pass filename from metadata (e.g., "analog_clock.star" for analogclock app)
|
||||
# Note: manifest uses 'fileName' (camelCase), not 'filename'
|
||||
filename = metadata.get('fileName') if metadata else None
|
||||
success, error = repo.download_star_file(data['app_id'], Path(temp_path), filename=filename)
|
||||
if not success:
|
||||
return jsonify({'status': 'error', 'message': f'Failed to download app: {error}'}), 500
|
||||
|
||||
# Download assets (images, sources, etc.) to a temp directory
|
||||
import tempfile
|
||||
temp_assets_dir = tempfile.mkdtemp()
|
||||
try:
|
||||
success_assets, error_assets = repo.download_app_assets(data['app_id'], Path(temp_assets_dir))
|
||||
# Asset download is non-critical - log warning but continue if it fails
|
||||
if not success_assets:
|
||||
logger.warning(f"Failed to download assets for {data['app_id']}: {error_assets}")
|
||||
|
||||
render_interval = data.get('render_interval', 300)
|
||||
ri, err = _validate_timing_value(render_interval, 'render_interval')
|
||||
if err:
|
||||
return jsonify({'status': 'error', 'message': err}), 400
|
||||
render_interval = ri or 300
|
||||
|
||||
display_duration = data.get('display_duration', 15)
|
||||
dd, err = _validate_timing_value(display_duration, 'display_duration')
|
||||
if err:
|
||||
return jsonify({'status': 'error', 'message': err}), 400
|
||||
display_duration = dd or 15
|
||||
|
||||
install_metadata = {
|
||||
'name': metadata.get('name', app_id) if metadata else app_id,
|
||||
'render_interval': render_interval,
|
||||
'display_duration': display_duration
|
||||
}
|
||||
|
||||
starlark_plugin = _pkg._get_starlark_plugin()
|
||||
if starlark_plugin:
|
||||
success = starlark_plugin.install_app(app_id, temp_path, install_metadata, assets_dir=temp_assets_dir)
|
||||
else:
|
||||
success = _install_star_file(app_id, temp_path, install_metadata, assets_dir=temp_assets_dir)
|
||||
finally:
|
||||
# Clean up temp assets directory
|
||||
import shutil
|
||||
try:
|
||||
shutil.rmtree(temp_assets_dir)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if success:
|
||||
return jsonify({'status': 'success', 'message': f'App installed: {metadata.get("name", app_id) if metadata else app_id}', 'app_id': app_id})
|
||||
else:
|
||||
return jsonify({'status': 'error', 'message': 'Failed to install app'}), 500
|
||||
finally:
|
||||
try:
|
||||
os.unlink(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("[Starlark] install_from_tronbyte_repository failed")
|
||||
return jsonify({'status': 'error', 'message': 'Failed to install from repository', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/starlark/repository/categories', methods=['GET'])
|
||||
def get_tronbyte_categories():
|
||||
"""Get list of available app categories (uses bulk cache)."""
|
||||
try:
|
||||
TronbyteRepository = _pkg._get_tronbyte_repository_class()
|
||||
repo = TronbyteRepository(github_token=_starlark_github_token())
|
||||
|
||||
result = repo.list_all_apps_cached()
|
||||
|
||||
if result.get('error'):
|
||||
return jsonify({'status': 'error', 'message': result['error']}), 502
|
||||
|
||||
return jsonify({'status': 'success', 'categories': result['categories']})
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("[Starlark] get_tronbyte_categories failed")
|
||||
return jsonify({'status': 'error', 'message': 'Failed to fetch categories', 'details': describe_exception(e)}), 500
|
||||
|
||||
|
||||
@api_v3.route('/starlark/editor/apps', methods=['GET'])
|
||||
def list_pixlet_editor_apps():
|
||||
"""Apps on disk that the editor can open.
|
||||
|
||||
Read from the directory rather than the loaded plugin: the editor works on
|
||||
files, and the plugin may not be loaded in this process at all.
|
||||
"""
|
||||
try:
|
||||
apps = []
|
||||
if _STARLARK_APPS_DIR.is_dir():
|
||||
for entry in sorted(_STARLARK_APPS_DIR.iterdir()):
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
star_files = sorted(entry.glob('*.star'))
|
||||
name = entry.name
|
||||
manifest = entry / 'manifest.json'
|
||||
if manifest.is_file():
|
||||
try:
|
||||
with open(manifest, encoding='utf-8') as handle:
|
||||
data = json.load(handle)
|
||||
if isinstance(data, dict):
|
||||
name = data.get('name') or name
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
apps.append({
|
||||
'id': entry.name,
|
||||
'name': name,
|
||||
'editable': bool(star_files),
|
||||
'has_config': (entry / 'config.json').is_file(),
|
||||
})
|
||||
return jsonify({'status': 'success', 'data': {
|
||||
'apps': apps,
|
||||
'apps_dir': str(_STARLARK_APPS_DIR),
|
||||
'pixlet_available': _find_pixlet_binary() is not None,
|
||||
}})
|
||||
except Exception as e:
|
||||
logger.exception('Error listing editor apps')
|
||||
return jsonify({'status': 'error', 'message': 'Could not list apps',
|
||||
'details': describe_exception(e)}), 500
|
||||
|
||||
@api_v3.route('/starlark/editor/status', methods=['GET'])
|
||||
def get_pixlet_editor_status():
|
||||
"""Whether a session is running, and how long it has left."""
|
||||
try:
|
||||
return jsonify({'status': 'success', 'data': _pixlet_editor_status()})
|
||||
except Exception as e:
|
||||
logger.exception('Error reading editor status')
|
||||
return jsonify({'status': 'error', 'message': 'Could not read editor status',
|
||||
'details': describe_exception(e)}), 500
|
||||
|
||||
#: Flask runs threaded in the supported service, so two start requests can each
|
||||
#: observe running=False, each launch an editor, and the second state write
|
||||
#: replace the first PID -- orphaning a process that holds the display down with
|
||||
#: nothing left recording it. The check-launch-write sequence takes this lock.
|
||||
_EDITOR_START_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _terminate_editor_process(pid, wait_s=5):
|
||||
"""Signal an editor's process group and wait briefly for it to exit."""
|
||||
try:
|
||||
os.killpg(os.getpgid(pid), signal.SIGTERM)
|
||||
except (ProcessLookupError, PermissionError, OSError):
|
||||
with contextlib.suppress(ProcessLookupError, PermissionError):
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
deadline = _pkg.time.time() + wait_s
|
||||
while _pkg.time.time() < deadline and _pixlet_editor_alive(pid):
|
||||
_pkg.time.sleep(0.25)
|
||||
if _pixlet_editor_alive(pid):
|
||||
with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
|
||||
os.killpg(os.getpgid(pid), signal.SIGKILL)
|
||||
|
||||
|
||||
@api_v3.route('/starlark/editor/start', methods=['POST'])
|
||||
def start_pixlet_editor():
|
||||
"""Start an editing session for one app."""
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
app_id = data.get('app_id')
|
||||
|
||||
app_dir, err = _validate_starlark_app_path(app_id or '')
|
||||
if err or not app_dir:
|
||||
return jsonify({'status': 'error', 'message': err or 'Invalid app_id'}), 400
|
||||
if not app_dir.is_dir():
|
||||
return jsonify({'status': 'error', 'message': f'No such app: {app_id}'}), 404
|
||||
if not any(app_dir.glob('*.star')):
|
||||
return jsonify({'status': 'error',
|
||||
'message': f'{app_id} has no .star file to edit'}), 400
|
||||
if not _PIXLET_EDITOR_SCRIPT.is_file():
|
||||
return jsonify({'status': 'error', 'message': 'Editor script not found'}), 404
|
||||
if _find_pixlet_binary() is None:
|
||||
return jsonify({'status': 'error',
|
||||
'message': 'Pixlet is not installed - install it first'}), 503
|
||||
|
||||
with _EDITOR_START_LOCK:
|
||||
current = _pixlet_editor_status()
|
||||
if current.get('running'):
|
||||
return jsonify({'status': 'error',
|
||||
'message': f"An editor session for '{current.get('app_id')}' is "
|
||||
f"already running; stop it first"}), 409
|
||||
|
||||
try:
|
||||
timeout_s = int(data.get('timeout') or _PIXLET_EDITOR_DEFAULT_TIMEOUT)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({'status': 'error', 'message': 'timeout must be a whole number'}), 400
|
||||
if not 60 <= timeout_s <= _PIXLET_EDITOR_MAX_TIMEOUT:
|
||||
return jsonify({'status': 'error',
|
||||
'message': f'timeout must be between 60 and '
|
||||
f'{_PIXLET_EDITOR_MAX_TIMEOUT} seconds'}), 400
|
||||
try:
|
||||
port = int(data.get('port') or _PIXLET_EDITOR_DEFAULT_PORT)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({'status': 'error', 'message': 'port must be a whole number'}), 400
|
||||
if not 1024 <= port <= 65535:
|
||||
return jsonify({'status': 'error', 'message': 'port must be between 1024 and 65535'}), 400
|
||||
|
||||
env = dict(os.environ)
|
||||
env['PIXLET_EDITOR_PORT'] = str(port)
|
||||
env['PIXLET_EDITOR_TIMEOUT'] = str(timeout_s)
|
||||
# A browser reaching this endpoint is remote by definition, so the
|
||||
# session needs to listen on more than loopback to be usable at all --
|
||||
# but only as a *default*. An operator who has already set
|
||||
# PIXLET_EDITOR_HOST (e.g. to keep it loopback-only even from the web
|
||||
# UI) must not have that overridden here.
|
||||
env.setdefault('PIXLET_EDITOR_HOST', '0.0.0.0')
|
||||
|
||||
log_path = Path(tempfile.gettempdir()) / 'ledmatrix_pixlet_editor.log'
|
||||
log_handle = open(log_path, 'w', encoding='utf-8') # noqa: SIM115 - owned by the child
|
||||
try:
|
||||
# start_new_session so the script leads its own process group: the
|
||||
# stop route signals the group, which is what lets the EXIT trap run
|
||||
# and hand the display back.
|
||||
process = subprocess.Popen( # nosec B603 - fixed script path, validated app_id
|
||||
['/bin/bash', str(_PIXLET_EDITOR_SCRIPT), app_dir.name],
|
||||
cwd=str(PROJECT_ROOT), env=env,
|
||||
stdout=log_handle, stderr=subprocess.STDOUT,
|
||||
start_new_session=True)
|
||||
finally:
|
||||
log_handle.close()
|
||||
|
||||
now = _pkg.time.time()
|
||||
state = {'pid': process.pid, 'app_id': app_dir.name, 'port': port,
|
||||
'timeout': timeout_s, 'started_at': now, 'deadline': now + timeout_s,
|
||||
'host': env['PIXLET_EDITOR_HOST'], 'log': str(log_path)}
|
||||
try:
|
||||
with open(_PIXLET_EDITOR_STATE, 'w', encoding='utf-8') as handle:
|
||||
json.dump(state, handle)
|
||||
except OSError as err:
|
||||
# The process is up but nothing records its PID: status and stop
|
||||
# would both report no session while the display stays down until
|
||||
# the timeout expires. Take the editor with us instead.
|
||||
# exc_info: this path answers 500, and the guard in
|
||||
# test_web_error_detail.py requires the traceback to reach the log
|
||||
# as well as the sanitized detail reaching the caller.
|
||||
logger.error('Started an editor session but could not record it: %s',
|
||||
err, exc_info=True)
|
||||
_terminate_editor_process(process.pid)
|
||||
return jsonify({'status': 'error',
|
||||
'message': 'Could not record the editor session; '
|
||||
'the editor was stopped.',
|
||||
'details': describe_exception(err)}), 500
|
||||
|
||||
logger.info('Pixlet editor started for %s on port %s (pid %s, %ss limit)',
|
||||
app_dir.name, port, process.pid, timeout_s)
|
||||
return jsonify({'status': 'success',
|
||||
'message': f"Editing '{app_dir.name}'. The display is stopped until "
|
||||
f"the session ends.",
|
||||
'data': _pixlet_editor_status()})
|
||||
except Exception as e:
|
||||
logger.exception('Error starting the pixlet editor')
|
||||
return jsonify({'status': 'error', 'message': 'Could not start the editor',
|
||||
'details': describe_exception(e)}), 500
|
||||
|
||||
@api_v3.route('/starlark/editor/stop', methods=['POST'])
|
||||
def stop_pixlet_editor():
|
||||
"""End the running session and give the display back."""
|
||||
try:
|
||||
state = _read_pixlet_editor_state()
|
||||
if not state or not _pixlet_editor_alive(state.get('pid')):
|
||||
_clear_pixlet_editor_state()
|
||||
return jsonify({'status': 'success', 'message': 'No editor session was running.',
|
||||
'data': {'running': False}})
|
||||
|
||||
pid = int(state['pid'])
|
||||
# SIGTERM the group, not the pid: bash forwards nothing to `timeout` and
|
||||
# its child on its own, and the trap needs to run to restart the display.
|
||||
try:
|
||||
os.killpg(os.getpgid(pid), signal.SIGTERM)
|
||||
except (ProcessLookupError, PermissionError, OSError) as err:
|
||||
logger.debug('Could not signal the editor process group: %s', err)
|
||||
with contextlib.suppress(ProcessLookupError, PermissionError):
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
|
||||
# Give the trap a moment to stop pixlet and restart ledmatrix.
|
||||
deadline = _pkg.time.time() + 10
|
||||
while _pkg.time.time() < deadline and _pixlet_editor_alive(pid):
|
||||
_pkg.time.sleep(0.25)
|
||||
|
||||
escalated = False
|
||||
if _pixlet_editor_alive(pid):
|
||||
logger.warning('Editor session %s ignored SIGTERM; sending SIGKILL.', pid)
|
||||
with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
|
||||
os.killpg(os.getpgid(pid), signal.SIGKILL)
|
||||
escalated = True
|
||||
|
||||
_clear_pixlet_editor_state()
|
||||
|
||||
if escalated:
|
||||
# SIGKILL gives the script's EXIT trap no chance to run, so nothing
|
||||
# has handed the display back. Reporting "the display is restarting"
|
||||
# here was simply untrue: restart it, and if that fails say so
|
||||
# rather than leave the panel dark behind a success response.
|
||||
result = _run_systemctl_command(
|
||||
['sudo', 'systemctl', 'start', 'ledmatrix.service'])
|
||||
if result.get('returncode') != 0:
|
||||
logger.error('Display restart after SIGKILL failed: %s',
|
||||
(result.get('stderr') or '').strip())
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Editor force-stopped, but the display could not be '
|
||||
'restarted automatically - start it manually.',
|
||||
'details': (result.get('stderr') or '').strip(),
|
||||
'data': {'running': False}}), 500
|
||||
return jsonify({'status': 'success',
|
||||
'message': 'Editor force-stopped; the display has been '
|
||||
'restarted.',
|
||||
'data': {'running': False}})
|
||||
|
||||
return jsonify({'status': 'success',
|
||||
'message': 'Editor stopped; the display is restarting.',
|
||||
'data': {'running': False}})
|
||||
except Exception as e:
|
||||
logger.exception('Error stopping the pixlet editor')
|
||||
return jsonify({'status': 'error', 'message': 'Could not stop the editor',
|
||||
'details': describe_exception(e)}), 500
|
||||
@@ -0,0 +1,662 @@
|
||||
"""Service control, updates, versions and system status.
|
||||
|
||||
Routes decorate the shared `api_v3` Blueprint from ._common, so their
|
||||
endpoint names are unchanged by living here.
|
||||
"""
|
||||
from web_interface.blueprints.api_v3 import (
|
||||
Any, Dict, PROJECT_ROOT, Path, _GIT, _UPDATE_CHECK_TTL,
|
||||
_describe_git_failure, _get_display_service_status, _git_current_branch,
|
||||
_git_remote_branch_exists, _git_upstream, _pip_install_requirements,
|
||||
_scrub_git_remote_url, _truncate_output, _update_check_cache,
|
||||
_update_check_failed, api_v3, checkout_branch, describe_exception,
|
||||
get_git_version, jsonify, logger, os, request, resolve_pull_command,
|
||||
shutil, subprocess,
|
||||
)
|
||||
import web_interface.blueprints.api_v3 as _pkg
|
||||
# Read through the module rather than bound by value: tests patch these
|
||||
# as module attributes, and a value binding would not see the patch.
|
||||
# Several are also called from helpers that live in __init__, so the
|
||||
# package is the only patch point that covers every caller.
|
||||
|
||||
|
||||
@api_v3.route('/system/status', methods=['GET'])
|
||||
def get_system_status():
|
||||
"""Get system status"""
|
||||
try:
|
||||
# Check cache first (10 second TTL for system status)
|
||||
try:
|
||||
from web_interface.cache import get_cached, set_cached
|
||||
cached_result = get_cached('system_status', ttl_seconds=10)
|
||||
if cached_result is not None:
|
||||
return jsonify({'status': 'success', 'data': cached_result})
|
||||
except ImportError:
|
||||
# Cache not available, continue without caching
|
||||
get_cached = None
|
||||
set_cached = None
|
||||
|
||||
# Import psutil for system monitoring
|
||||
try:
|
||||
import psutil
|
||||
except ImportError:
|
||||
# Fallback if psutil not available
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'psutil not available for system monitoring'
|
||||
}), 503
|
||||
|
||||
# Get system metrics using psutil
|
||||
cpu_percent = psutil.cpu_percent(interval=0.1) # Short interval for responsiveness
|
||||
memory = psutil.virtual_memory()
|
||||
memory_percent = memory.percent
|
||||
disk = psutil.disk_usage('/')
|
||||
disk_percent = disk.percent
|
||||
|
||||
# Calculate uptime
|
||||
boot_time = psutil.boot_time()
|
||||
uptime_seconds = _pkg.time.time() - boot_time
|
||||
uptime_hours = uptime_seconds / 3600
|
||||
uptime_days = uptime_hours / 24
|
||||
|
||||
# Format uptime string
|
||||
if uptime_days >= 1:
|
||||
uptime_str = f"{int(uptime_days)}d {int(uptime_hours % 24)}h"
|
||||
elif uptime_hours >= 1:
|
||||
uptime_str = f"{int(uptime_hours)}h {int((uptime_seconds % 3600) / 60)}m"
|
||||
else:
|
||||
uptime_str = f"{int(uptime_seconds / 60)}m"
|
||||
|
||||
# Get CPU temperature (Raspberry Pi)
|
||||
cpu_temp = None
|
||||
try:
|
||||
temp_file = '/sys/class/thermal/thermal_zone0/temp'
|
||||
if os.path.exists(temp_file):
|
||||
with open(temp_file, 'r') as f:
|
||||
temp_millidegrees = int(f.read().strip())
|
||||
cpu_temp = temp_millidegrees / 1000.0 # Convert to Celsius
|
||||
except (IOError, ValueError, OSError):
|
||||
# Temperature sensor not available or error reading
|
||||
cpu_temp = None
|
||||
|
||||
# Get display service status
|
||||
service_status = _get_display_service_status()
|
||||
|
||||
status = {
|
||||
'timestamp': _pkg.time.time(),
|
||||
'uptime': uptime_str,
|
||||
'uptime_seconds': int(uptime_seconds),
|
||||
'service_active': service_status.get('active', False),
|
||||
'cpu_percent': round(cpu_percent, 1),
|
||||
'memory_used_percent': round(memory_percent, 1),
|
||||
'memory_total_mb': round(memory.total / (1024 * 1024), 1),
|
||||
'memory_used_mb': round(memory.used / (1024 * 1024), 1),
|
||||
# MemAvailable, not total-minus-used: it accounts for reclaimable
|
||||
# page cache, so it is what actually predicts memory trouble. A
|
||||
# board can read 70% "used" and be fine, or read the same and be
|
||||
# about to fail fork(), and only this number tells them apart.
|
||||
'memory_available_mb': round(memory.available / (1024 * 1024), 1),
|
||||
'cpu_temp': round(cpu_temp, 1) if cpu_temp is not None else None,
|
||||
'disk_used_percent': round(disk_percent, 1),
|
||||
'disk_total_gb': round(disk.total / (1024 * 1024 * 1024), 1),
|
||||
'disk_used_gb': round(disk.used / (1024 * 1024 * 1024), 1)
|
||||
}
|
||||
|
||||
# Cache the result if available
|
||||
if set_cached:
|
||||
try:
|
||||
set_cached('system_status', status, ttl_seconds=10)
|
||||
except Exception:
|
||||
pass # Cache write failed, but continue
|
||||
|
||||
return jsonify({'status': 'success', 'data': status})
|
||||
except Exception as e:
|
||||
logger.error('Unhandled exception', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/system/version', methods=['GET'])
|
||||
def get_system_version():
|
||||
"""Get LEDMatrix repository version"""
|
||||
try:
|
||||
version = get_git_version()
|
||||
return jsonify({'status': 'success', 'data': {'version': version}})
|
||||
except Exception as e:
|
||||
logger.error("get_system_version failed: %s", e, exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'Unable to retrieve version'}), 500
|
||||
@api_v3.route('/system/check-update', methods=['GET'])
|
||||
def check_for_update():
|
||||
"""Check whether a newer LEDMatrix commit is available on origin/main."""
|
||||
now = _pkg.time.time()
|
||||
if _update_check_cache['result'] and now - _update_check_cache['ts'] < _UPDATE_CHECK_TTL:
|
||||
return jsonify(_update_check_cache['result'])
|
||||
|
||||
_safe: Dict[str, Any] = {'update_available': False, 'remote_sha': 'unknown', 'commits_behind': 0}
|
||||
try:
|
||||
cwd = str(PROJECT_ROOT)
|
||||
fetch_result = subprocess.run(
|
||||
['git', 'fetch', 'origin', 'main', '--quiet'],
|
||||
capture_output=True, timeout=10, cwd=cwd,
|
||||
)
|
||||
if fetch_result.returncode != 0:
|
||||
stderr = fetch_result.stderr.decode(errors='replace').strip()
|
||||
logger.warning("check-update: git fetch failed (rc=%d): %s",
|
||||
fetch_result.returncode, stderr)
|
||||
failed = _update_check_failed(_describe_git_failure(stderr))
|
||||
_update_check_cache['result'] = failed
|
||||
_update_check_cache['ts'] = now
|
||||
return jsonify(failed)
|
||||
local = subprocess.run(
|
||||
['git', 'rev-parse', 'HEAD'],
|
||||
capture_output=True, text=True, timeout=5, cwd=cwd,
|
||||
).stdout.strip()
|
||||
remote = subprocess.run(
|
||||
['git', 'rev-parse', 'origin/main'],
|
||||
capture_output=True, text=True, timeout=5, cwd=cwd,
|
||||
).stdout.strip()
|
||||
|
||||
if not local or not remote:
|
||||
return jsonify(_safe)
|
||||
|
||||
if local == remote:
|
||||
result: Dict[str, Any] = {'update_available': False, 'remote_sha': remote, 'commits_behind': 0}
|
||||
else:
|
||||
count_str = subprocess.run(
|
||||
['git', 'rev-list', 'HEAD..origin/main', '--count'],
|
||||
capture_output=True, text=True, timeout=5, cwd=cwd,
|
||||
).stdout.strip()
|
||||
count = int(count_str) if count_str.isdigit() else 0
|
||||
result = {'update_available': count > 0, 'remote_sha': remote, 'commits_behind': count}
|
||||
|
||||
_update_check_cache['result'] = result
|
||||
_update_check_cache['ts'] = now
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.warning("check-update failed: %s", e)
|
||||
return jsonify(_update_check_failed(
|
||||
"Could not check for updates; see logs for details."))
|
||||
#: sudo's own wording when it needs a password it cannot ask for. The web
|
||||
#: interface runs unprivileged, so its systemctl/reboot/journalctl calls only
|
||||
#: work once scripts/install/configure_web_sudo.sh has granted NOPASSWD --
|
||||
#: which first_time_install.sh does not do. That makes this the common case on
|
||||
#: a fresh device, and "Action failed; see logs for details" named none of it,
|
||||
#: while the log viewer was broken for the very same reason.
|
||||
_SUDO_NEEDS_PASSWORD = (
|
||||
'a password is required',
|
||||
'no tty present',
|
||||
'a terminal is required',
|
||||
)
|
||||
|
||||
_SUDO_HINT = (
|
||||
'Passwordless sudo is not configured for the web interface user, so this '
|
||||
'action cannot run. Run scripts/install/configure_web_sudo.sh as that user, '
|
||||
'then retry.'
|
||||
)
|
||||
|
||||
|
||||
def _sudo_hint_for(text):
|
||||
"""An actionable hint when `text` is sudo refusing to prompt, else None."""
|
||||
lowered = (text or '').lower()
|
||||
if any(marker in lowered for marker in _SUDO_NEEDS_PASSWORD):
|
||||
return _SUDO_HINT
|
||||
return None
|
||||
|
||||
|
||||
@api_v3.route('/system/action', methods=['POST'])
|
||||
def execute_system_action():
|
||||
"""Execute system actions (start/stop/reboot/etc)"""
|
||||
try:
|
||||
# HTMX sends data as form data, not JSON
|
||||
data = request.get_json(silent=True) or {}
|
||||
if not data:
|
||||
# Try to get from form data if JSON fails
|
||||
data = {
|
||||
'action': request.form.get('action'),
|
||||
'mode': request.form.get('mode')
|
||||
}
|
||||
|
||||
if not data or 'action' not in data:
|
||||
return jsonify({'status': 'error', 'message': 'Action required'}), 400
|
||||
|
||||
action = data['action']
|
||||
mode = data.get('mode') # For on-demand modes
|
||||
|
||||
# Map actions to subprocess calls (similar to original implementation)
|
||||
if action == 'start_display':
|
||||
if mode:
|
||||
# For on-demand modes, we would need to integrate with the display controller
|
||||
# For now, just start the display service
|
||||
try:
|
||||
result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix.service'],
|
||||
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)
|
||||
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',
|
||||
# This branch returns before the shared nonzero-result
|
||||
# response below, so it needs the hint of its own or an
|
||||
# on-demand start reports "Failed to start display" and
|
||||
# says nothing about the sudo that actually refused it.
|
||||
'message': (
|
||||
'Display started' if result.returncode == 0
|
||||
else _sudo_hint_for(result.stderr) or 'Failed to start display'
|
||||
),
|
||||
}
|
||||
if result.returncode != 0:
|
||||
resp['returncode'] = result.returncode
|
||||
resp['stderr'] = result.stderr.strip()
|
||||
return jsonify(resp)
|
||||
else:
|
||||
result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix.service'],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
elif action == 'stop_display':
|
||||
result = subprocess.run(['sudo', 'systemctl', 'stop', 'ledmatrix.service'],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
elif action == 'enable_autostart':
|
||||
result = subprocess.run(['sudo', 'systemctl', 'enable', 'ledmatrix.service'],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
elif action == 'disable_autostart':
|
||||
result = subprocess.run(['sudo', 'systemctl', 'disable', 'ledmatrix.service'],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
elif action == 'reboot_system':
|
||||
result = subprocess.run(['sudo', 'reboot'],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
elif action == 'shutdown_system':
|
||||
result = subprocess.run(['sudo', 'poweroff'],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
elif action == 'git_pull':
|
||||
# Use PROJECT_ROOT instead of hardcoded path
|
||||
project_dir = str(PROJECT_ROOT)
|
||||
|
||||
# Decide how to pull BEFORE stashing. If this checkout cannot be
|
||||
# updated at all, stashing first would put the user's local changes
|
||||
# away for an update that was never going to run.
|
||||
pull_args, upstream_note, pull_error = resolve_pull_command(project_dir)
|
||||
if pull_error:
|
||||
logger.warning("git pull not attempted: %s", pull_error)
|
||||
return jsonify({'status': 'error', 'message': pull_error})
|
||||
|
||||
# Check if there are local changes that need to be stashed
|
||||
# Exclude plugins directory - plugins are separate repos and shouldn't be stashed with base project
|
||||
# Use --untracked-files=no to skip untracked files check (much faster with symlinked plugins)
|
||||
try:
|
||||
status_result = subprocess.run(
|
||||
['git', 'status', '--porcelain', '--untracked-files=no'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
cwd=project_dir
|
||||
)
|
||||
# Filter out any changes in plugins directory - plugins are separate repositories
|
||||
# Git status format: XY filename (where X is status of index, Y is status of work tree)
|
||||
status_lines = [line for line in status_result.stdout.strip().split('\n')
|
||||
if line.strip() and 'plugins/' not in line]
|
||||
has_changes = bool('\n'.join(status_lines).strip())
|
||||
except subprocess.TimeoutExpired:
|
||||
# If status check times out, assume there might be changes and proceed
|
||||
# This is safer than failing the update
|
||||
has_changes = True
|
||||
status_result = type('obj', (object,), {'stdout': '', 'stderr': 'Status check timed out'})()
|
||||
|
||||
stash_info = ""
|
||||
|
||||
# Stash local changes if they exist (excluding plugins)
|
||||
# Plugins are separate repositories and shouldn't be stashed with base project updates
|
||||
if has_changes:
|
||||
try:
|
||||
# Use pathspec to exclude plugins directory from stash
|
||||
stash_result = subprocess.run(
|
||||
['git', 'stash', 'push', '-m', 'LEDMatrix auto-stash before update', '--', ':!plugins'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
cwd=project_dir
|
||||
)
|
||||
if stash_result.returncode == 0:
|
||||
logger.debug("git stash: stashed local changes before pull")
|
||||
stash_info = " Local changes were stashed."
|
||||
else:
|
||||
logger.warning("git stash failed before pull (returncode=%d)", stash_result.returncode)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("git stash timed out, proceeding with pull")
|
||||
|
||||
# Record HEAD before the pull so dependency changes can be detected
|
||||
old_head = None
|
||||
try:
|
||||
_pre = subprocess.run(['git', 'rev-parse', 'HEAD'],
|
||||
capture_output=True, text=True, timeout=10, cwd=project_dir)
|
||||
if _pre.returncode == 0:
|
||||
old_head = _pre.stdout.strip()
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("git rev-parse timed out before pull")
|
||||
|
||||
# Whether the pull actually brought new code in. "Already up to
|
||||
# date" is a success too, and prompting for a restart then would
|
||||
# train users to ignore the prompt.
|
||||
code_changed = False
|
||||
|
||||
# Perform the git pull. Branches without an upstream were given
|
||||
# an explicit "origin <branch>" above so the update still works.
|
||||
result = subprocess.run(
|
||||
pull_args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
cwd=project_dir
|
||||
)
|
||||
|
||||
# Give the branch tracking information so the next pull is a plain
|
||||
# `git pull` — otherwise every update repeats the fallback.
|
||||
if result.returncode == 0 and upstream_note:
|
||||
branch = _git_current_branch(project_dir)
|
||||
if branch:
|
||||
try:
|
||||
subprocess.run(
|
||||
['git', 'branch', f'--set-upstream-to=origin/{branch}', branch],
|
||||
capture_output=True, text=True, timeout=10, cwd=project_dir)
|
||||
except (subprocess.TimeoutExpired, OSError) as exc:
|
||||
logger.debug("could not set upstream for %s: %s", branch, exc)
|
||||
|
||||
# Return custom response for git_pull
|
||||
if result.returncode == 0:
|
||||
pull_message = "Code updated successfully."
|
||||
if has_changes:
|
||||
pull_message = f"Code updated successfully. Local changes were automatically stashed.{stash_info}"
|
||||
if result.stdout and "Already up to date" not in result.stdout:
|
||||
pull_message = f"Code updated successfully.{stash_info}"
|
||||
if upstream_note:
|
||||
pull_message = f"{pull_message} {upstream_note}"
|
||||
|
||||
# Keep Python dependencies in sync automatically: if the pull
|
||||
# changed a requirements file, install it now — users updating
|
||||
# from the web UI (most of them) never SSH in to pip install.
|
||||
# Installs go through the same root-visible path as the
|
||||
# Tools-tab buttons (_pip_install_requirements).
|
||||
dep_notes = []
|
||||
try:
|
||||
_post = subprocess.run(['git', 'rev-parse', 'HEAD'],
|
||||
capture_output=True, text=True, timeout=10, cwd=project_dir)
|
||||
new_head = _post.stdout.strip() if _post.returncode == 0 else None
|
||||
if old_head and new_head and old_head != new_head:
|
||||
code_changed = True
|
||||
diff = subprocess.run(
|
||||
['git', 'diff', '--name-only', f'{old_head}..{new_head}'],
|
||||
capture_output=True, text=True, timeout=15, cwd=project_dir)
|
||||
changed = set(diff.stdout.split()) if diff.returncode == 0 else set()
|
||||
for rel in ('requirements.txt', 'web_interface/requirements.txt'):
|
||||
req_path = PROJECT_ROOT / rel
|
||||
if rel not in changed or not req_path.exists():
|
||||
continue
|
||||
# Each file's install is isolated: a timeout or
|
||||
# OSError (e.g. the sudo wrapper/interpreter
|
||||
# missing) on one file must not abort the other.
|
||||
try:
|
||||
r = _pip_install_requirements(req_path, timeout=180)
|
||||
if r.returncode == 0:
|
||||
dep_notes.append(f"Dependencies from {rel} updated.")
|
||||
else:
|
||||
dep_notes.append(
|
||||
f"Dependency install from {rel} failed — "
|
||||
"run Install Base Requirements from the Tools tab.")
|
||||
logger.warning("post-update pip install failed for %s: %s",
|
||||
rel, _truncate_output(r.stdout, r.stderr))
|
||||
except subprocess.TimeoutExpired:
|
||||
dep_notes.append(
|
||||
f"Dependency install from {rel} timed out — "
|
||||
"run Install Base Requirements from the Tools tab.")
|
||||
logger.warning("post-update pip install timed out for %s", rel)
|
||||
except OSError as install_err:
|
||||
dep_notes.append(
|
||||
f"Dependency install from {rel} failed — "
|
||||
"run Install Base Requirements from the Tools tab.")
|
||||
logger.warning("post-update pip install errored for %s: %s",
|
||||
rel, install_err)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("post-update dependency sync timed out")
|
||||
if dep_notes:
|
||||
pull_message += " " + " ".join(dep_notes)
|
||||
# A `git pull` restores built-in plugins (committed under
|
||||
# plugin-repos/) even if the user uninstalled them. Re-remove
|
||||
# any the user previously uninstalled so the update doesn't
|
||||
# resurrect them.
|
||||
if api_v3.plugin_store_manager:
|
||||
try:
|
||||
purged = api_v3.plugin_store_manager.purge_uninstalled_plugins()
|
||||
if purged:
|
||||
logger.info(
|
||||
"Re-removed %d uninstalled plugin(s) restored by update: %s",
|
||||
len(purged), ", ".join(purged),
|
||||
)
|
||||
except (OSError, RuntimeError) as purge_err:
|
||||
logger.warning("Post-update plugin purge failed: %s", purge_err)
|
||||
else:
|
||||
logger.warning("git pull failed (returncode=%d): %s", result.returncode, result.stderr)
|
||||
# Show git's own first line: "check logs" leaves the user with
|
||||
# nothing to act on, and these failures are usually actionable
|
||||
# (conflicting local commits, no upstream, network).
|
||||
detail = next((ln.strip() for ln in (result.stderr or '').splitlines()
|
||||
if ln.strip()), '')
|
||||
pull_message = f"Update failed: {detail}" if detail else "Update failed; check logs for details"
|
||||
|
||||
# Nothing here restarts anything: the pull replaces files on
|
||||
# disk while the display and web services keep running the code
|
||||
# they loaded at boot. Without this the user is told the update
|
||||
# succeeded and sees no change until they happen to reboot.
|
||||
return jsonify({
|
||||
'status': 'success' if result.returncode == 0 else 'error',
|
||||
'message': pull_message,
|
||||
'restart_required': bool(result.returncode == 0 and code_changed),
|
||||
})
|
||||
elif action == 'checkout_branch':
|
||||
# Switch branches from the Tools tab. Needed because a checkout
|
||||
# that predates tracking (or a restored backup) can leave the pi
|
||||
# on a branch the update button cannot pull.
|
||||
result_payload, http_status = checkout_branch(
|
||||
str(PROJECT_ROOT), data.get('branch') or '', stash=bool(data.get('stash')))
|
||||
return jsonify(result_payload), http_status
|
||||
|
||||
elif action == 'restart_display_service':
|
||||
result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix.service'],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
elif action == 'restart_web_service':
|
||||
# Try to restart the web service (assuming it's ledmatrix-web.service)
|
||||
result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix-web.service'],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
elif action == 'install_base_requirements':
|
||||
# Base + web interface requirements: flask-compress and friends
|
||||
# live in web_interface/requirements.txt, not the root file.
|
||||
req_files = [f for f in (PROJECT_ROOT / 'requirements.txt',
|
||||
PROJECT_ROOT / 'web_interface' / 'requirements.txt')
|
||||
if f.exists()]
|
||||
if not req_files:
|
||||
return jsonify({'status': 'error', 'message': 'No requirements.txt found at project root'})
|
||||
outputs = []
|
||||
all_ok = True
|
||||
for req_file in req_files:
|
||||
label = req_file.relative_to(PROJECT_ROOT)
|
||||
# Isolate each file's install: a timeout or OSError on one
|
||||
# (e.g. requirements.txt) must not abort the rest of the
|
||||
# loop (e.g. web_interface/requirements.txt never attempted).
|
||||
try:
|
||||
result = _pip_install_requirements(req_file, timeout=120)
|
||||
all_ok = all_ok and result.returncode == 0
|
||||
outputs.append(f"== {label} ==\n" + _truncate_output(result.stdout, result.stderr))
|
||||
except subprocess.TimeoutExpired:
|
||||
all_ok = False
|
||||
outputs.append(f"== {label} ==\nTimed out after 120s")
|
||||
logger.warning("install_base_requirements timed out for %s", label)
|
||||
except OSError as install_err:
|
||||
all_ok = False
|
||||
outputs.append(f"== {label} ==\nFailed: {install_err}")
|
||||
logger.warning("install_base_requirements errored for %s: %s", label, install_err)
|
||||
return jsonify({
|
||||
'status': 'success' if all_ok else 'error',
|
||||
'message': 'Base requirements installed successfully' if all_ok else 'pip install failed',
|
||||
'output': "\n".join(outputs)
|
||||
})
|
||||
elif action == 'install_plugin_requirements':
|
||||
active_pm = getattr(api_v3, 'plugin_manager', None)
|
||||
if active_pm:
|
||||
plugins_dir = Path(active_pm.plugins_dir)
|
||||
else:
|
||||
_cm = getattr(api_v3, 'config_manager', None)
|
||||
_cfg = _cm.load_config() if _cm else {}
|
||||
_dir_name = _cfg.get('plugin_system', {}).get('plugins_directory', 'plugin-repos')
|
||||
plugins_dir = Path(_dir_name) if os.path.isabs(_dir_name) else PROJECT_ROOT / _dir_name
|
||||
results = []
|
||||
if plugins_dir.exists():
|
||||
for p in sorted(plugins_dir.iterdir()):
|
||||
req = p / 'requirements.txt'
|
||||
if p.is_dir() and req.exists():
|
||||
try:
|
||||
r = _pip_install_requirements(req, timeout=60)
|
||||
results.append({
|
||||
'plugin': p.name,
|
||||
'ok': r.returncode == 0,
|
||||
'output': _truncate_output(r.stdout, r.stderr)
|
||||
})
|
||||
except subprocess.TimeoutExpired:
|
||||
results.append({'plugin': p.name, 'ok': False, 'output': 'pip install timed out'})
|
||||
except OSError as exc:
|
||||
results.append({'plugin': p.name, 'ok': False, 'output': exc.strerror or 'OS error'})
|
||||
ok_count = sum(1 for r in results if r['ok'])
|
||||
all_ok = all(r['ok'] for r in results) if results else True
|
||||
return jsonify({
|
||||
'status': 'success' if all_ok else 'error',
|
||||
'message': f'Processed {len(results)} plugin(s) — {ok_count} succeeded' if results else 'No plugin requirements.txt files found',
|
||||
'details': results
|
||||
})
|
||||
elif action == 'force_git_reset':
|
||||
if not _GIT:
|
||||
return jsonify({'status': 'error', 'message': 'git not found on this system'}), 503
|
||||
project_dir = str(PROJECT_ROOT)
|
||||
fetch = subprocess.run(
|
||||
[_GIT, 'fetch', 'origin'],
|
||||
capture_output=True, text=True, timeout=30, cwd=project_dir
|
||||
)
|
||||
if fetch.returncode != 0:
|
||||
return jsonify({'status': 'error', 'message': 'git fetch failed', 'output': fetch.stderr.strip()})
|
||||
reset = subprocess.run(
|
||||
[_GIT, 'reset', '--hard', 'origin/main'],
|
||||
capture_output=True, text=True, timeout=30, cwd=project_dir
|
||||
)
|
||||
return jsonify({
|
||||
'status': 'success' if reset.returncode == 0 else 'error',
|
||||
'message': 'Reset to origin/main successfully' if reset.returncode == 0 else 'git reset failed',
|
||||
'output': (reset.stdout + reset.stderr).strip()
|
||||
})
|
||||
elif action == 'clear_pycache':
|
||||
cleared = 0
|
||||
failed = 0
|
||||
for d in PROJECT_ROOT.rglob('__pycache__'):
|
||||
if d.is_dir():
|
||||
try:
|
||||
shutil.rmtree(d)
|
||||
cleared += 1
|
||||
except OSError:
|
||||
failed += 1
|
||||
msg = f'Cleared {cleared} __pycache__ directories'
|
||||
if failed:
|
||||
msg += f' ({failed} could not be removed)'
|
||||
return jsonify({'status': 'success', 'message': msg})
|
||||
else:
|
||||
return jsonify({'status': 'error', 'message': 'Unknown action'}), 400
|
||||
|
||||
logger.info("system action '%s' returncode=%d", action, result.returncode)
|
||||
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',
|
||||
'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()
|
||||
hint = _sudo_hint_for(result.stderr)
|
||||
if hint:
|
||||
resp['message'] = hint
|
||||
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:
|
||||
logger.error("execute_system_action failed: %s", e, exc_info=True)
|
||||
detail = describe_exception(e)
|
||||
resp = {
|
||||
'status': 'error',
|
||||
'message': _sudo_hint_for(detail) or 'Action failed; see logs for details',
|
||||
'details': detail,
|
||||
}
|
||||
return jsonify(resp), 500
|
||||
@api_v3.route('/system/git-info', methods=['GET'])
|
||||
def get_git_info():
|
||||
"""Return branch, dirty state, recent commits and remote URL for the Tools tab."""
|
||||
if not _GIT:
|
||||
return jsonify({'status': 'error', 'message': 'git not found on this system'}), 503
|
||||
d = str(PROJECT_ROOT)
|
||||
try:
|
||||
branch = subprocess.run([_GIT, 'branch', '--show-current'], capture_output=True, text=True, timeout=10, cwd=d)
|
||||
if branch.returncode != 0:
|
||||
return jsonify({'status': 'error', 'message': f'git branch failed: {branch.stderr.strip()}'}), 500
|
||||
|
||||
status = subprocess.run([_GIT, 'status', '--short', '--untracked-files=no'], capture_output=True, text=True, timeout=15, cwd=d)
|
||||
if status.returncode != 0:
|
||||
return jsonify({'status': 'error', 'message': f'git status failed: {status.stderr.strip()}'}), 500
|
||||
|
||||
log = subprocess.run([_GIT, 'log', '--oneline', '-5'], capture_output=True, text=True, timeout=10, cwd=d)
|
||||
remote = subprocess.run([_GIT, 'remote', 'get-url', 'origin'], capture_output=True, text=True, timeout=10, cwd=d)
|
||||
branch_name = branch.stdout.strip()
|
||||
upstream = _git_upstream(d)
|
||||
return jsonify({
|
||||
'branch': branch_name,
|
||||
'dirty': bool(status.stdout.strip()),
|
||||
'status': status.stdout.strip(),
|
||||
'recent_commits': log.stdout.strip() if log.returncode == 0 else '',
|
||||
'remote_url': _scrub_git_remote_url(remote.stdout.strip()) if remote.returncode == 0 else '',
|
||||
# Surfaced so the Tools tab can warn before the user clicks Pull
|
||||
# Latest, rather than after it fails.
|
||||
'upstream': upstream,
|
||||
'can_pull': bool(upstream) or _git_remote_branch_exists(d, branch_name),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error("get_git_info failed: %s", e, exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'Failed to get git info'}), 500
|
||||
@api_v3.route('/system/git-branches', methods=['GET'])
|
||||
def get_git_branches():
|
||||
"""List branches available to switch to, for the Tools tab picker."""
|
||||
if not _GIT:
|
||||
return jsonify({'status': 'error', 'message': 'git not found on this system'}), 503
|
||||
d = str(PROJECT_ROOT)
|
||||
try:
|
||||
# Refresh remote refs so a branch created since the last fetch shows up.
|
||||
subprocess.run([_GIT, 'fetch', 'origin', '--prune'],
|
||||
capture_output=True, text=True, timeout=60, cwd=d)
|
||||
|
||||
local = subprocess.run([_GIT, 'for-each-ref', '--format=%(refname:short)', 'refs/heads'],
|
||||
capture_output=True, text=True, timeout=15, cwd=d)
|
||||
remote = subprocess.run([_GIT, 'for-each-ref', '--format=%(refname:short)', 'refs/remotes/origin'],
|
||||
capture_output=True, text=True, timeout=15, cwd=d)
|
||||
if local.returncode != 0:
|
||||
return jsonify({'status': 'error', 'message': 'Could not list branches'}), 500
|
||||
|
||||
local_names = [b for b in local.stdout.split() if b]
|
||||
remote_names = []
|
||||
for ref in remote.stdout.split() if remote.returncode == 0 else []:
|
||||
name = ref.split('origin/', 1)[-1]
|
||||
# origin/HEAD is a symbolic alias, not a branch a user can pick.
|
||||
if name and name != 'HEAD' and name not in local_names:
|
||||
remote_names.append(name)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'current': _git_current_branch(d),
|
||||
'upstream': _git_upstream(d),
|
||||
'local': sorted(local_names),
|
||||
'remote_only': sorted(remote_names),
|
||||
})
|
||||
except subprocess.TimeoutExpired:
|
||||
return jsonify({'status': 'error', 'message': 'Timed out talking to the remote'}), 504
|
||||
except OSError as e:
|
||||
logger.error("get_git_branches failed: %s", e, exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'Failed to list branches'}), 500
|
||||
@@ -0,0 +1,413 @@
|
||||
"""Wi-Fi scanning, connection and status routes.
|
||||
|
||||
Routes decorate the shared `api_v3` Blueprint from ._common, so their
|
||||
endpoint names are unchanged by living here.
|
||||
"""
|
||||
from web_interface.blueprints.api_v3 import (
|
||||
api_v3, describe_exception, jsonify, logger, request,
|
||||
)
|
||||
|
||||
|
||||
def _parse_bool_ish(value):
|
||||
"""Coerce a JSON value that is supposed to be a boolean.
|
||||
|
||||
A JSON boolean arrives as a real Python bool, but these routes are a
|
||||
public HTTP contract and not every caller sends one. `bool(value)` gets
|
||||
two common cases wrong: `bool("false")` is True (a non-empty string is
|
||||
always truthy), and a plain int does not match an `is True` check
|
||||
(`1 is True` is False, since `True` is a distinct singleton from the int
|
||||
`1`) -- so a caller sending `{"enabled": 1}` was silently treated as
|
||||
False. Recognizes a real bool, "true"/"false"/"1"/"0"/"yes"/"no"
|
||||
case-insensitively, and int 1/0.
|
||||
|
||||
Returns None for anything else, rather than guessing. A supplied-but-
|
||||
unrecognized value (e.g. a typo) used to silently become False here,
|
||||
which for `enabled` on the radio route could disconnect Wi-Fi the caller
|
||||
never asked to turn off -- callers must treat None as a validation
|
||||
error, not a real False.
|
||||
"""
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
lowered = value.strip().lower()
|
||||
if lowered in ('true', '1', 'yes'):
|
||||
return True
|
||||
if lowered in ('false', '0', 'no'):
|
||||
return False
|
||||
return None
|
||||
if isinstance(value, int):
|
||||
if value == 1:
|
||||
return True
|
||||
if value == 0:
|
||||
return False
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
# WiFi Management Endpoints
|
||||
@api_v3.route('/wifi/status', methods=['GET'])
|
||||
def get_wifi_status():
|
||||
"""Get current WiFi connection status"""
|
||||
try:
|
||||
from src.wifi_manager import WiFiManager
|
||||
|
||||
wifi_manager = WiFiManager()
|
||||
status = wifi_manager.get_wifi_status()
|
||||
|
||||
# Get auto-enable setting from config
|
||||
auto_enable_ap = wifi_manager.config.get("auto_enable_ap_mode", True) # Default: True (safe due to grace period)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'connected': status.connected,
|
||||
'ssid': status.ssid,
|
||||
'ip_address': status.ip_address,
|
||||
'signal': status.signal,
|
||||
'ap_mode_active': status.ap_mode_active,
|
||||
'auto_enable_ap_mode': auto_enable_ap
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error("%s failed", request.path, exc_info=True)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'An error occurred; see logs for details',
|
||||
'details': describe_exception(e)
|
||||
}), 500
|
||||
@api_v3.route('/wifi/scan', methods=['GET'])
|
||||
def scan_wifi_networks():
|
||||
"""Scan for available WiFi networks
|
||||
|
||||
If AP mode is active, it will be temporarily disabled during scanning
|
||||
and automatically re-enabled afterward. Users connected to the AP will
|
||||
be briefly disconnected during this process.
|
||||
"""
|
||||
try:
|
||||
from src.wifi_manager import WiFiManager
|
||||
|
||||
wifi_manager = WiFiManager()
|
||||
|
||||
# Check if AP mode is active before scanning (for user notification)
|
||||
ap_was_active = wifi_manager._is_ap_mode_active()
|
||||
|
||||
# Perform the scan (this will handle AP mode disabling/enabling internally)
|
||||
networks, _was_cached = wifi_manager.scan_networks()
|
||||
|
||||
# Convert to dict format
|
||||
networks_data = [
|
||||
{
|
||||
'ssid': net.ssid,
|
||||
'signal': net.signal,
|
||||
'security': net.security,
|
||||
'frequency': net.frequency
|
||||
}
|
||||
for net in networks
|
||||
]
|
||||
|
||||
response_data = {
|
||||
'status': 'success',
|
||||
'data': networks_data
|
||||
}
|
||||
|
||||
# Inform user if AP mode was temporarily disabled
|
||||
if ap_was_active:
|
||||
response_data['message'] = (
|
||||
f'Found {len(networks_data)} networks. '
|
||||
'Note: AP mode was temporarily disabled during scanning and has been re-enabled. '
|
||||
'If you were connected to the setup network, you may need to reconnect.'
|
||||
)
|
||||
|
||||
return jsonify(response_data)
|
||||
except Exception as e:
|
||||
logger.error("Error scanning WiFi networks", exc_info=True)
|
||||
error_message = 'An error occurred while scanning WiFi networks; see logs for details'
|
||||
|
||||
# Provide more specific error messages for common issues
|
||||
error_str = str(e).lower()
|
||||
if 'permission' in error_str or 'sudo' in error_str:
|
||||
error_message = (
|
||||
'Permission error while scanning. '
|
||||
'The WiFi scan requires appropriate permissions. '
|
||||
'Please ensure the application has necessary privileges.'
|
||||
)
|
||||
elif 'timeout' in error_str:
|
||||
error_message = (
|
||||
'WiFi scan timed out. '
|
||||
'The scan took too long to complete. '
|
||||
'This may happen if the WiFi interface is busy or in use.'
|
||||
)
|
||||
elif 'no wifi' in error_str or 'not available' in error_str:
|
||||
error_message = (
|
||||
'WiFi scanning tools are not available. '
|
||||
'Please ensure NetworkManager (nmcli) or iwlist is installed.'
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': error_message
|
||||
}), 500
|
||||
@api_v3.route('/wifi/connect', methods=['POST'])
|
||||
def connect_wifi():
|
||||
"""Connect to a WiFi network"""
|
||||
try:
|
||||
from src.wifi_manager import WiFiManager
|
||||
|
||||
data = request.get_json(silent=True)
|
||||
if not data:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Request body is required'
|
||||
}), 400
|
||||
|
||||
if 'ssid' not in data:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'SSID is required'
|
||||
}), 400
|
||||
|
||||
ssid = data['ssid']
|
||||
if not ssid or not ssid.strip():
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'SSID cannot be empty'
|
||||
}), 400
|
||||
|
||||
ssid = ssid.strip()
|
||||
password = data.get('password', '') or ''
|
||||
|
||||
wifi_manager = WiFiManager()
|
||||
success, message = wifi_manager.connect_to_network(ssid, password)
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': message
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': message or 'Failed to connect to network'
|
||||
}), 400
|
||||
except Exception as e:
|
||||
logger.error("Error connecting to WiFi", exc_info=True)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
|
||||
}), 500
|
||||
@api_v3.route('/wifi/disconnect', methods=['POST'])
|
||||
def disconnect_wifi():
|
||||
"""Disconnect from the current WiFi network"""
|
||||
try:
|
||||
from src.wifi_manager import WiFiManager
|
||||
|
||||
wifi_manager = WiFiManager()
|
||||
success, message = wifi_manager.disconnect_from_network()
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': message
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': message or 'Failed to disconnect from network'
|
||||
}), 400
|
||||
except Exception as e:
|
||||
logger.error("Error disconnecting from WiFi", exc_info=True)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
|
||||
}), 500
|
||||
@api_v3.route('/wifi/ap/enable', methods=['POST'])
|
||||
def enable_ap_mode():
|
||||
"""Enable access point mode"""
|
||||
try:
|
||||
from src.wifi_manager import WiFiManager
|
||||
|
||||
wifi_manager = WiFiManager()
|
||||
_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:
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': message
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': message
|
||||
}), 400
|
||||
except Exception as e:
|
||||
logger.error("%s failed", request.path, exc_info=True)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'An error occurred; see logs for details',
|
||||
'details': describe_exception(e)
|
||||
}), 500
|
||||
@api_v3.route('/wifi/ap/disable', methods=['POST'])
|
||||
def disable_ap_mode():
|
||||
"""Disable access point mode"""
|
||||
try:
|
||||
from src.wifi_manager import WiFiManager
|
||||
|
||||
wifi_manager = WiFiManager()
|
||||
success, message = wifi_manager.disable_ap_mode()
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': message
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': message
|
||||
}), 400
|
||||
except Exception as e:
|
||||
logger.error("%s failed", request.path, exc_info=True)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'An error occurred; see logs for details',
|
||||
'details': describe_exception(e)
|
||||
}), 500
|
||||
@api_v3.route('/wifi/ap/auto-enable', methods=['GET'])
|
||||
def get_auto_enable_ap_mode():
|
||||
"""Get auto-enable AP mode setting"""
|
||||
try:
|
||||
from src.wifi_manager import WiFiManager
|
||||
|
||||
wifi_manager = WiFiManager()
|
||||
auto_enable = wifi_manager.config.get("auto_enable_ap_mode", True) # Default: True (safe due to grace period)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'auto_enable_ap_mode': auto_enable
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error("%s failed", request.path, exc_info=True)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'An error occurred; see logs for details',
|
||||
'details': describe_exception(e)
|
||||
}), 500
|
||||
@api_v3.route('/wifi/ap/auto-enable', methods=['POST'])
|
||||
def set_auto_enable_ap_mode():
|
||||
"""Set auto-enable AP mode setting"""
|
||||
try:
|
||||
from src.wifi_manager import WiFiManager
|
||||
|
||||
data = request.get_json(silent=True)
|
||||
if data is None or 'auto_enable_ap_mode' not in data:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'auto_enable_ap_mode is required'
|
||||
}), 400
|
||||
|
||||
auto_enable = _parse_bool_ish(data['auto_enable_ap_mode'])
|
||||
if auto_enable is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'auto_enable_ap_mode must be a boolean'
|
||||
}), 400
|
||||
|
||||
wifi_manager = WiFiManager()
|
||||
wifi_manager.config["auto_enable_ap_mode"] = auto_enable
|
||||
wifi_manager._save_config()
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': f'Auto-enable AP mode set to {auto_enable}',
|
||||
'data': {
|
||||
'auto_enable_ap_mode': auto_enable
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error("%s failed", request.path, exc_info=True)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'An error occurred; see logs for details',
|
||||
'details': describe_exception(e)
|
||||
}), 500
|
||||
@api_v3.route('/wifi/radio', methods=['GET'])
|
||||
def get_wifi_radio():
|
||||
"""Get current WiFi radio state (enabled/disabled) and wired-fallback status."""
|
||||
try:
|
||||
from src.wifi_manager import WiFiManager
|
||||
|
||||
wifi_manager = WiFiManager()
|
||||
state = wifi_manager.get_wifi_radio_state()
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'data': state
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error("Error getting WiFi radio state", exc_info=True)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
|
||||
}), 500
|
||||
@api_v3.route('/wifi/radio', methods=['POST'])
|
||||
def set_wifi_radio():
|
||||
"""Turn the WiFi radio on or off.
|
||||
|
||||
Body: {"enabled": bool, "force": bool (optional)}. Disabling is refused
|
||||
unless Ethernet is connected or force=True, to avoid locking the user out
|
||||
of this web interface.
|
||||
"""
|
||||
try:
|
||||
from src.wifi_manager import WiFiManager
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
if 'enabled' not in data:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'enabled is required'
|
||||
}), 400
|
||||
|
||||
# Parse defensively: bool("false") is True and a plain int never
|
||||
# matches `is True`, so `_parse_bool_ish` handles bool, string and
|
||||
# int 1/0 — the endpoint is a public contract, not just the shipped
|
||||
# UI (which always sends real JSON booleans). An unrecognized value
|
||||
# must be rejected, not silently disable the radio: this is the
|
||||
# route that can drop the caller's own connection to this interface.
|
||||
enabled = _parse_bool_ish(data['enabled'])
|
||||
if enabled is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'enabled must be a boolean'
|
||||
}), 400
|
||||
force = _parse_bool_ish(data.get('force', False))
|
||||
if force is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'force must be a boolean'
|
||||
}), 400
|
||||
|
||||
wifi_manager = WiFiManager()
|
||||
success, message, reason = wifi_manager.set_wifi_radio(enabled, force=force)
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': message,
|
||||
'data': wifi_manager.get_wifi_radio_state()
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': message,
|
||||
'reason': reason
|
||||
}), 400
|
||||
except Exception as e:
|
||||
logger.error("Error setting WiFi radio state", exc_info=True)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
|
||||
}), 500
|
||||
@@ -4,8 +4,6 @@ from markupsafe import escape
|
||||
from html.parser import HTMLParser
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import os.path
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
@@ -15,6 +13,7 @@ _SAFE_WEB_UI_FILE_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}\.html$')
|
||||
_SAFE_WIDGET_NAME_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$')
|
||||
_SAFE_WIDGET_SCRIPT_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}\.js$')
|
||||
from src.web_interface.secret_helpers import mask_secret_fields
|
||||
from src.common.path_safety import resolve_under, safe_path_component
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -274,7 +273,15 @@ def serve_plugin_web_ui(plugin_id, filename):
|
||||
"""Serve a plugin's web_ui/ HTML fragment as a standalone page.
|
||||
|
||||
Wraps the fragment with a minimal HTML page that injects window.PLUGIN_ID
|
||||
and loads Tailwind CSS so the fragment runs correctly in a sandboxed iframe.
|
||||
and loads Tailwind CSS so the fragment runs correctly inside the iframe
|
||||
that plugin_config.html embeds it in.
|
||||
|
||||
That iframe carries no ``sandbox`` attribute, so the fragment runs with
|
||||
the interface's own origin. That is deliberate rather than an oversight:
|
||||
the fragment is a file from an installed plugin, and an installed plugin
|
||||
already runs Python on the device. The trust boundary is plugin install,
|
||||
not this route. It is worth knowing when reading the code, which is why
|
||||
it says so here instead of claiming a sandbox that is not there.
|
||||
"""
|
||||
# Validate URL-derived values against strict allowlists before any path or
|
||||
# script operations.
|
||||
@@ -283,11 +290,12 @@ def serve_plugin_web_ui(plugin_id, filename):
|
||||
if not _SAFE_WEB_UI_FILE_RE.match(filename):
|
||||
return 'Invalid filename', 400, {'Content-Type': 'text/plain'}
|
||||
|
||||
# os.path.basename() is the CodeQL-recognised path sanitizer used throughout
|
||||
# this codebase (see plugin_loader.py). Applying it here breaks the taint
|
||||
# chain even though the allowlist above already prevents path separators.
|
||||
safe_id = os.path.basename(plugin_id)
|
||||
safe_fn = os.path.basename(filename)
|
||||
# The allowlists above already forbid a separator, but the value that gets
|
||||
# joined has to be the checked one, not the argument -- see
|
||||
# src/common/path_safety.py. safe_path_component rejects rather than
|
||||
# truncates, so these cannot disagree.
|
||||
safe_id = safe_path_component(plugin_id)
|
||||
safe_fn = safe_path_component(filename)
|
||||
if not safe_id or not safe_fn:
|
||||
return 'Invalid path component', 400, {'Content-Type': 'text/plain'}
|
||||
|
||||
@@ -297,22 +305,19 @@ def serve_plugin_web_ui(plugin_id, filename):
|
||||
try:
|
||||
_plugins_base = Path(pages_v3.plugin_manager.plugins_dir).resolve()
|
||||
|
||||
# Reconstruct from sanitised basename — CodeQL-approved pattern.
|
||||
_plugin_dir = (_plugins_base / safe_id).resolve()
|
||||
_plugin_dir.relative_to(_plugins_base) # containment guard
|
||||
_plugin_dir = resolve_under(_plugins_base, safe_id)
|
||||
if _plugin_dir is None:
|
||||
return 'Forbidden', 403, {'Content-Type': 'text/plain'}
|
||||
|
||||
# Mirror PluginManager's ledmatrix- prefix fallback.
|
||||
if not _plugin_dir.exists():
|
||||
_alt_id = os.path.basename(f'ledmatrix-{safe_id}')
|
||||
_alt = (_plugins_base / _alt_id).resolve()
|
||||
try:
|
||||
_alt.relative_to(_plugins_base)
|
||||
_alt = resolve_under(_plugins_base, f'ledmatrix-{safe_id}')
|
||||
if _alt is not None:
|
||||
_plugin_dir = _alt
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
web_ui_path = (_plugin_dir / 'web_ui' / safe_fn).resolve()
|
||||
web_ui_path.relative_to(_plugin_dir / 'web_ui') # second guard
|
||||
web_ui_path = resolve_under(_plugin_dir / 'web_ui', safe_fn)
|
||||
if web_ui_path is None:
|
||||
return 'Forbidden', 403, {'Content-Type': 'text/plain'}
|
||||
|
||||
if not web_ui_path.exists():
|
||||
return 'Not found', 404, {'Content-Type': 'text/plain'}
|
||||
@@ -369,17 +374,14 @@ def _plugin_dir_for(safe_id):
|
||||
plugins directory, with PluginManager's ``ledmatrix-`` prefix fallback.
|
||||
"""
|
||||
plugins_base = Path(pages_v3.plugin_manager.plugins_dir).resolve()
|
||||
plugin_dir = (plugins_base / safe_id).resolve()
|
||||
plugin_dir.relative_to(plugins_base) # containment guard
|
||||
plugin_dir = resolve_under(plugins_base, safe_id)
|
||||
if plugin_dir is None:
|
||||
raise ValueError('plugin id escapes the plugins directory')
|
||||
|
||||
if not plugin_dir.exists():
|
||||
alt_id = os.path.basename(f'ledmatrix-{safe_id}')
|
||||
alt = (plugins_base / alt_id).resolve()
|
||||
try:
|
||||
alt.relative_to(plugins_base)
|
||||
alt = resolve_under(plugins_base, f'ledmatrix-{safe_id}')
|
||||
if alt is not None:
|
||||
plugin_dir = alt
|
||||
except ValueError:
|
||||
pass
|
||||
return plugin_dir
|
||||
|
||||
|
||||
@@ -432,11 +434,11 @@ def serve_plugin_widget(plugin_id, widget_name):
|
||||
if not _SAFE_WIDGET_NAME_RE.match(widget_name):
|
||||
return 'Invalid widget name', 400, {'Content-Type': 'text/plain'}
|
||||
|
||||
# os.path.basename() is the CodeQL-recognised path sanitizer used
|
||||
# throughout this codebase; the allowlists above already exclude
|
||||
# separators, this breaks the taint chain.
|
||||
safe_id = os.path.basename(plugin_id)
|
||||
safe_widget = os.path.basename(widget_name)
|
||||
# safe_path_component is this codebase's sanitiser (src/common/
|
||||
# path_safety.py): it rejects rather than mangles, so a name that is not
|
||||
# a plain path component never reaches the filesystem.
|
||||
safe_id = safe_path_component(plugin_id)
|
||||
safe_widget = safe_path_component(widget_name)
|
||||
if not safe_id or not safe_widget:
|
||||
return 'Invalid path component', 400, {'Content-Type': 'text/plain'}
|
||||
|
||||
@@ -455,10 +457,10 @@ def serve_plugin_widget(plugin_id, widget_name):
|
||||
return 'Not found', 404, {'Content-Type': 'text/plain'}
|
||||
|
||||
widgets_dir = (plugin_dir / 'widgets').resolve()
|
||||
script_path = (widgets_dir / os.path.basename(script)).resolve()
|
||||
script_path.relative_to(widgets_dir) # second containment guard
|
||||
|
||||
if not script_path.is_file():
|
||||
# The script name comes from the plugin's manifest, not the request,
|
||||
# so it gets the same containment treatment the URL parts got.
|
||||
script_path = resolve_under(widgets_dir, script)
|
||||
if script_path is None or not script_path.is_file():
|
||||
return 'Not found', 404, {'Content-Type': 'text/plain'}
|
||||
|
||||
body = script_path.read_text(encoding='utf-8')
|
||||
@@ -759,9 +761,11 @@ def _load_plugin_config_partial(plugin_id):
|
||||
Load plugin configuration partial - server-side rendered form.
|
||||
This replaces the client-side generateConfigForm() JavaScript.
|
||||
"""
|
||||
# Sanitize with basename (CodeQL-recognized sanitizer) then regex-validate format
|
||||
plugin_id = os.path.basename(plugin_id or '')
|
||||
if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9._\-:]*$', plugin_id):
|
||||
# Refuse an id that is not a plain directory name, rather than quietly
|
||||
# basename-ing it down to one: "../weather" used to become "weather" and
|
||||
# render a partial the caller never asked for.
|
||||
plugin_id = safe_path_component(plugin_id)
|
||||
if not plugin_id or not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9._\-:]*$', plugin_id):
|
||||
return '<div class="text-red-500 p-4">Invalid plugin ID</div>', 400
|
||||
|
||||
try:
|
||||
@@ -774,10 +778,8 @@ def _load_plugin_config_partial(plugin_id):
|
||||
|
||||
# Resolve and validate all plugin paths against the plugins base directory
|
||||
_plugins_base = Path(pages_v3.plugin_manager.plugins_dir).resolve()
|
||||
_plugin_dir = (_plugins_base / plugin_id).resolve()
|
||||
try:
|
||||
_plugin_dir.relative_to(_plugins_base)
|
||||
except ValueError:
|
||||
_plugin_dir = resolve_under(_plugins_base, plugin_id)
|
||||
if _plugin_dir is None:
|
||||
return '<div class="text-red-500 p-4">Invalid plugin ID</div>', 400
|
||||
|
||||
# Try to get plugin info first
|
||||
@@ -801,19 +803,17 @@ def _load_plugin_config_partial(plugin_id):
|
||||
config = full_config.get(plugin_id, {})
|
||||
|
||||
# Load uploaded images from metadata file if images field exists in schema
|
||||
schema_path_temp = _plugin_dir / "config_schema.json"
|
||||
if schema_path_temp.exists():
|
||||
schema_path_temp = resolve_under(_plugin_dir, "config_schema.json")
|
||||
if schema_path_temp is not None and schema_path_temp.exists():
|
||||
try:
|
||||
with open(schema_path_temp, 'r', encoding='utf-8') as f:
|
||||
temp_schema = json.load(f)
|
||||
if (temp_schema.get('properties', {}).get('images', {}).get('x-widget') == 'file-upload' or
|
||||
temp_schema.get('properties', {}).get('images', {}).get('x_widget') == 'file-upload'):
|
||||
_assets_base = (Path(__file__).parent.parent.parent / 'assets' / 'plugins').resolve()
|
||||
metadata_file = (_assets_base / plugin_id / 'uploads' / '.metadata.json').resolve()
|
||||
try:
|
||||
metadata_file.relative_to(_assets_base)
|
||||
except ValueError:
|
||||
metadata_file = None
|
||||
metadata_file = resolve_under(
|
||||
_assets_base, plugin_id, 'uploads', '.metadata.json'
|
||||
)
|
||||
if metadata_file and metadata_file.exists():
|
||||
try:
|
||||
with open(metadata_file, 'r', encoding='utf-8') as mf:
|
||||
@@ -858,8 +858,10 @@ def _load_plugin_config_partial(plugin_id):
|
||||
logger.warning("SchemaManager could not load schema for %s: %s",
|
||||
plugin_id, e)
|
||||
if not schema:
|
||||
schema_path = _plugin_dir / "config_schema.json"
|
||||
if schema_path.exists():
|
||||
# resolve_under keeps the containment guard main added here; the
|
||||
# SchemaManager path above does its own.
|
||||
schema_path = resolve_under(_plugin_dir, "config_schema.json")
|
||||
if schema_path is not None and schema_path.exists():
|
||||
try:
|
||||
with open(schema_path, 'r', encoding='utf-8') as f:
|
||||
schema = json.load(f)
|
||||
@@ -868,8 +870,8 @@ def _load_plugin_config_partial(plugin_id):
|
||||
|
||||
# Get web UI actions from plugin manifest
|
||||
web_ui_actions = []
|
||||
manifest_path = _plugin_dir / "manifest.json"
|
||||
if manifest_path.exists():
|
||||
manifest_path = resolve_under(_plugin_dir, "manifest.json")
|
||||
if manifest_path is not None and manifest_path.exists():
|
||||
try:
|
||||
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||||
manifest = json.load(f)
|
||||
@@ -917,9 +919,10 @@ def _load_plugin_config_partial(plugin_id):
|
||||
|
||||
def _load_starlark_config_partial(app_id):
|
||||
"""Load configuration partial for a Starlark app."""
|
||||
# Sanitize with basename (CodeQL-recognized sanitizer) then regex-validate format
|
||||
app_id = os.path.basename(app_id or '')
|
||||
if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9_\-]*$', app_id):
|
||||
# Refuse an id that is not a plain directory name rather than basename-ing
|
||||
# it down to one -- see _load_plugin_config_partial for why.
|
||||
app_id = safe_path_component(app_id)
|
||||
if not app_id or not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9_\-]*$', app_id):
|
||||
return '<div class="text-red-500 p-4">Invalid app ID</div>', 400
|
||||
|
||||
try:
|
||||
@@ -958,11 +961,7 @@ def _load_starlark_config_partial(app_id):
|
||||
|
||||
# Load schema from schema.json if it exists — validate path stays within starlark_base
|
||||
schema = None
|
||||
schema_file = (starlark_base / app_id / 'schema.json').resolve()
|
||||
try:
|
||||
schema_file.relative_to(starlark_base)
|
||||
except ValueError:
|
||||
schema_file = None
|
||||
schema_file = resolve_under(starlark_base, app_id, 'schema.json')
|
||||
if schema_file and schema_file.exists():
|
||||
try:
|
||||
with open(schema_file, 'r') as f:
|
||||
@@ -972,11 +971,7 @@ def _load_starlark_config_partial(app_id):
|
||||
|
||||
# Load config from config.json if it exists — validate path stays within starlark_base
|
||||
config = {}
|
||||
config_file = (starlark_base / app_id / 'config.json').resolve()
|
||||
try:
|
||||
config_file.relative_to(starlark_base)
|
||||
except ValueError:
|
||||
config_file = None
|
||||
config_file = resolve_under(starlark_base, app_id, 'config.json')
|
||||
if config_file and config_file.exists():
|
||||
try:
|
||||
with open(config_file, 'r') as f:
|
||||
|
||||
Regular → Executable
@@ -602,9 +602,9 @@
|
||||
};
|
||||
// Build the <i class="..."> + label as DOM nodes so a
|
||||
// hostile plugin.icon (e.g. containing a quote) can't
|
||||
// break out of the attribute. escapeHtml only escapes
|
||||
// <, >, &, not ", so attribute-context interpolation
|
||||
// would be unsafe.
|
||||
// break out of the attribute. escapeHtml now escapes
|
||||
// quotes too, but setting the property directly cannot
|
||||
// be got wrong at all, so it stays.
|
||||
const iconEl = document.createElement('i');
|
||||
iconEl.className = plugin.icon || 'fas fa-puzzle-piece';
|
||||
const labelNode = document.createTextNode(plugin.name || plugin.id);
|
||||
@@ -643,10 +643,15 @@
|
||||
}
|
||||
},
|
||||
|
||||
// Quotes too, so the result is safe inside a quoted attribute
|
||||
// value -- the textContent/innerHTML round-trip alone only
|
||||
// escapes &, < and >.
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
return div.innerHTML
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
},
|
||||
|
||||
async refreshPlugins() {
|
||||
|
||||
@@ -260,6 +260,9 @@ function closeErrorModal() {
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS.
|
||||
*
|
||||
* Quotes are escaped as well so the result is safe inside a quoted attribute
|
||||
* value -- the textContent/innerHTML round-trip alone only escapes &, < and >.
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
if (typeof text !== 'string') {
|
||||
@@ -267,7 +270,9 @@ function escapeHtml(text) {
|
||||
}
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
return div.innerHTML
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -118,15 +118,27 @@
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
* Always escapes the input, even for non-strings, by coercing to string first
|
||||
*
|
||||
* The result is safe in text content AND inside quoted attribute values.
|
||||
* The textContent/innerHTML round-trip only escapes `&`, `<` and `>` --
|
||||
* the HTML serializer leaves quotes alone because they are harmless in a
|
||||
* text node. Every widget here interpolates the result into attributes
|
||||
* (`value="${escapeHtml(v)}"`), where an unescaped `"` closes the
|
||||
* attribute and lets the value inject its own, so the quotes have to go
|
||||
* too. Each widget's standalone fallback already did this; the shared
|
||||
* implementation they all prefer did not.
|
||||
*
|
||||
* @param {*} text - Text to escape (will be coerced to string)
|
||||
* @returns {string} Escaped text
|
||||
* @returns {string} Escaped text, safe for text and attribute contexts
|
||||
*/
|
||||
escapeHtml(text) {
|
||||
// Always coerce to string first, then escape
|
||||
const textStr = String(text);
|
||||
const div = document.createElement('div');
|
||||
div.textContent = textStr;
|
||||
return div.innerHTML;
|
||||
return div.innerHTML
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -50,10 +50,14 @@
|
||||
}
|
||||
|
||||
// Escape HTML to prevent XSS (for HTML contexts)
|
||||
// Quotes too: the result lands in quoted attribute values below, and
|
||||
// the textContent/innerHTML round-trip only escapes &, < and >.
|
||||
const escapeHtml = (text) => {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
div.textContent = String(text ?? '');
|
||||
return div.innerHTML
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
};
|
||||
|
||||
// Use validated/sanitized hex for style attribute and input values
|
||||
|
||||
@@ -804,10 +804,14 @@
|
||||
const schedule = image.schedule || { enabled: false, mode: 'always', start_time: '08:00', end_time: '18:00', days: {} };
|
||||
|
||||
// Escape HTML helper
|
||||
// Quotes too: the result lands in quoted attribute values below, and the
|
||||
// textContent/innerHTML round-trip only escapes &, < and >.
|
||||
const escapeHtml = (text) => {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
div.textContent = String(text ?? '');
|
||||
return div.innerHTML
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
};
|
||||
|
||||
// Use sanitizedId for all ID references in the schedule HTML
|
||||
|
||||
@@ -738,10 +738,15 @@
|
||||
delete btn._jfmOrigText;
|
||||
}
|
||||
|
||||
// Quotes too: the result lands in quoted attribute values (title=,
|
||||
// data-cat=, pattern=, ...), and the textContent/innerHTML round-trip
|
||||
// only escapes &, < and >.
|
||||
_esc(str) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = String(str ?? '');
|
||||
return d.innerHTML;
|
||||
return d.innerHTML
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
_fmtSize(bytes) {
|
||||
|
||||
@@ -206,10 +206,15 @@
|
||||
else console.log(`[PFM][${type}] ${msg}`);
|
||||
}
|
||||
|
||||
// Quotes too: the result lands in quoted attribute values (id=, value=,
|
||||
// data-col=), and the textContent/innerHTML round-trip only escapes
|
||||
// &, < and >.
|
||||
function escHtml(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = String(s ?? '');
|
||||
return d.innerHTML;
|
||||
return d.innerHTML
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
@@ -383,7 +388,7 @@
|
||||
<button class="pfm-btn pfm-btn-primary" id="${escHtml(fieldId)}_save_btn">
|
||||
<i class="fas fa-save mr-1"></i>Save
|
||||
</button>
|
||||
</div>`;
|
||||
</div>`);
|
||||
overlay.appendChild(modal);
|
||||
// Bind events after DOM insertion — filename captured in closure, not in HTML.
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_modal_close`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||
@@ -411,7 +416,7 @@
|
||||
<textarea id="${escHtml(fieldId)}_json_ta" rows="20"
|
||||
style="width:100%;font-family:monospace;font-size:.75rem;border:1px solid #d1d5db;border-radius:.375rem;padding:.5rem;"
|
||||
>${escHtml(JSON.stringify(content, null, 2))}</textarea>
|
||||
<div id="${escHtml(fieldId)}_json_err" style="color:#dc2626;font-size:.75rem;margin-top:.25rem;"></div>`;
|
||||
<div id="${escHtml(fieldId)}_json_err" style="color:#dc2626;font-size:.75rem;margin-top:.25rem;"></div>`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -431,6 +436,20 @@
|
||||
if (!entries.length) { container.textContent = 'No entries.'; return; }
|
||||
|
||||
const cols = Object.keys(entries[0][1]);
|
||||
|
||||
// Delegated listener: day/col reach _pfmCellEdit only through data-*
|
||||
// attributes, never through a JS string spliced into an inline
|
||||
// handler -- the browser HTML-decodes attribute values before
|
||||
// running them as script, which undoes escHtml's quote escaping and
|
||||
// reopens the exact injection it exists to close. `container` is a
|
||||
// fresh element per modal open (see _pfmOpenEdit), so this attaches
|
||||
// exactly once per table, even though buildPage() re-renders below.
|
||||
container.addEventListener('input', (e) => {
|
||||
const cell = e.target.closest('input[data-day], textarea[data-day]');
|
||||
if (!cell) return;
|
||||
window._pfmCellEdit(fieldId, cell.dataset.day, cell.dataset.col, cell.value);
|
||||
});
|
||||
|
||||
const MS_PER_DAY = 86400 * 1000; // eslint-disable-line no-magic-numbers -- 86400s/day is not magic
|
||||
const todayDoy = Math.ceil((new Date() - new Date(new Date().getFullYear(), 0, 0)) / MS_PER_DAY);
|
||||
const total = entries.length;
|
||||
@@ -459,18 +478,16 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
${pageEntries.map(([day, val]) => `
|
||||
<tr data-day="${day}" class="${parseInt(day) === todayDoy ? 'today-row' : ''}">
|
||||
<tr data-day="${escHtml(day)}" class="${parseInt(day) === todayDoy ? 'today-row' : ''}">
|
||||
<td class="pfm-day-col" style="user-select:none;">${escHtml(day)}</td>
|
||||
${cols.map(col => {
|
||||
const v = val[col] ?? '';
|
||||
const isLong = String(v).length > 60 || col === 'description' || col === 'definition' || col === 'content';
|
||||
return isLong
|
||||
? `<td><textarea data-day="${day}" data-col="${escHtml(col)}" rows="2"
|
||||
oninput="window._pfmCellEdit('${fieldId}','${day}','${escHtml(col)}',this.value)"
|
||||
? `<td><textarea data-day="${escHtml(day)}" data-col="${escHtml(col)}" rows="2"
|
||||
>${escHtml(String(v))}</textarea></td>`
|
||||
: `<td><input type="text" data-day="${day}" data-col="${escHtml(col)}"
|
||||
value="${escHtml(String(v))}"
|
||||
oninput="window._pfmCellEdit('${fieldId}','${day}','${escHtml(col)}',this.value)"></td>`;
|
||||
: `<td><input type="text" data-day="${escHtml(day)}" data-col="${escHtml(col)}"
|
||||
value="${escHtml(String(v))}"></td>`;
|
||||
}).join('')}
|
||||
</tr>`).join('')}
|
||||
</tbody>
|
||||
@@ -489,7 +506,7 @@
|
||||
${page >= totalPages ? 'disabled' : ''}
|
||||
onclick="window._pfmTablePage('${fieldId}',${page + 1})">Next ›</button>
|
||||
</div>
|
||||
</div>`;
|
||||
</div>`);
|
||||
st._tablePage = page;
|
||||
st._tableEntries = entries;
|
||||
st._tableCols = cols;
|
||||
@@ -578,7 +595,7 @@
|
||||
<button class="pfm-btn pfm-btn-danger" id="${escHtml(fieldId)}_del_confirm">
|
||||
<i class="fas fa-trash mr-1"></i>Delete
|
||||
</button>
|
||||
</div>`;
|
||||
</div>`);
|
||||
overlay.appendChild(modal);
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_del_close`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_del_cancel`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||
@@ -631,7 +648,7 @@
|
||||
<i class="fas fa-plus mr-1"></i>Create
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
</div>`);
|
||||
overlay.appendChild(modal);
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_cre_close`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_cre_cancel`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||
@@ -784,7 +801,7 @@
|
||||
<div class="pfm-grid">
|
||||
<div class="pfm-empty"><i class="fas fa-spinner fa-spin"></i>Loading…</div>
|
||||
</div>
|
||||
</div>`;
|
||||
</div>`);
|
||||
|
||||
loadFiles(fieldId);
|
||||
},
|
||||
|
||||
@@ -54,9 +54,16 @@
|
||||
// RFC 3986 scheme pattern: starts with letter, then letters/digits/+/./-
|
||||
const RFC_SCHEME_PATTERN = /^[A-Za-z][A-Za-z0-9+.-]*$/;
|
||||
|
||||
// Schemes that execute script when navigated to. These can never be
|
||||
// allowed, whatever a schema's allowedProtocols asks for: the validated
|
||||
// value is written straight into an <a href>, so allowing "javascript"
|
||||
// here would turn a config field into script execution.
|
||||
const SCRIPTABLE_SCHEMES = ['javascript', 'data', 'vbscript', 'blob', 'filesystem'];
|
||||
|
||||
/**
|
||||
* Normalize and validate protocol list against RFC 3986 scheme pattern.
|
||||
* Accepts schemes like "http", "https", "git+ssh", "android-app", etc.
|
||||
* Scriptable schemes are dropped -- see SCRIPTABLE_SCHEMES.
|
||||
* @param {Array|string} protocols - Protocol list (array or comma-separated string)
|
||||
* @returns {Array} Normalized lowercase protocols, defaults to ['http', 'https']
|
||||
*/
|
||||
@@ -70,15 +77,25 @@
|
||||
const normalized = list
|
||||
.map(p => String(p).trim())
|
||||
.filter(p => RFC_SCHEME_PATTERN.test(p))
|
||||
.map(p => p.toLowerCase());
|
||||
.map(p => p.toLowerCase())
|
||||
.filter(p => !SCRIPTABLE_SCHEMES.includes(p));
|
||||
return normalized.length > 0 ? normalized : ['http', 'https'];
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `string` parses as a URL whose scheme is allowed AND is not
|
||||
* one that executes script. The scriptable check is repeated here rather
|
||||
* than trusted to normalizeProtocols so that a caller passing its own
|
||||
* protocol list cannot re-open the hole.
|
||||
*/
|
||||
function isValidUrl(string, allowedProtocols) {
|
||||
try {
|
||||
const url = new URL(string);
|
||||
const protocol = url.protocol.replace(':', '').toLowerCase();
|
||||
if (SCRIPTABLE_SCHEMES.includes(protocol)) {
|
||||
return false;
|
||||
}
|
||||
if (allowedProtocols && allowedProtocols.length > 0) {
|
||||
const protocol = url.protocol.replace(':', '').toLowerCase();
|
||||
return allowedProtocols.includes(protocol);
|
||||
}
|
||||
return true;
|
||||
@@ -87,6 +104,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A value safe to use as an <a href>: the URL itself when it validates,
|
||||
* and '' otherwise. Keeps the "render an href for whatever is stored"
|
||||
* path from emitting a javascript: URL that was never validated.
|
||||
*/
|
||||
function safeHref(value, allowedProtocols) {
|
||||
return isValidUrl(value, allowedProtocols) ? value : '';
|
||||
}
|
||||
|
||||
window.LEDMatrixWidgets.register('url-input', {
|
||||
name: 'URL Input Widget',
|
||||
version: '1.0.0',
|
||||
@@ -139,7 +165,7 @@
|
||||
html += `
|
||||
<div id="${fieldId}_preview" class="mt-2 ${currentValue && isValidUrl(currentValue, allowedProtocols) ? '' : 'hidden'}">
|
||||
<a id="${fieldId}_preview_link"
|
||||
href="${escapeHtml(currentValue)}"
|
||||
href="${escapeHtml(safeHref(currentValue, allowedProtocols))}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm text-blue-600 hover:text-blue-800 flex items-center">
|
||||
@@ -231,10 +257,23 @@
|
||||
const protocols = normalizeProtocols(widgetEl?.dataset.protocols);
|
||||
|
||||
if (previewEl && previewLink) {
|
||||
if (value && isValidUrl(value, protocols)) {
|
||||
// Scheme checked inline, right where the value reaches the DOM sink,
|
||||
// rather than through safeHref/isValidUrl -- CodeQL's DOM-based-XSS
|
||||
// sanitizer recognition does not trace a boolean-returning helper two
|
||||
// calls deep, so it kept flagging this assignment even though the
|
||||
// scriptable-scheme check (see SCRIPTABLE_SCHEMES) already covered it.
|
||||
let scheme = '';
|
||||
try {
|
||||
scheme = value ? new URL(value).protocol.replace(':', '').toLowerCase() : '';
|
||||
} catch (_) {
|
||||
scheme = '';
|
||||
}
|
||||
const schemeIsSafe = !!scheme && !SCRIPTABLE_SCHEMES.includes(scheme) && protocols.includes(scheme);
|
||||
if (schemeIsSafe) {
|
||||
previewLink.href = value;
|
||||
previewEl.classList.remove('hidden');
|
||||
} else {
|
||||
previewLink.removeAttribute('href');
|
||||
previewEl.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4370,12 +4370,15 @@ function renderCustomRegistryPlugins(plugins, registryUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape HTML helper
|
||||
// Escape HTML helper. Quotes too: the result lands in quoted attribute
|
||||
// values, and the textContent/innerHTML round-trip only escapes &, < and >.
|
||||
const escapeHtml = (text) => {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
return div.innerHTML
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
};
|
||||
|
||||
// Helper function to escape for JavaScript strings
|
||||
@@ -4471,11 +4474,15 @@ function isGithubUrl(url) {
|
||||
}
|
||||
}
|
||||
|
||||
// Utility function to escape HTML
|
||||
// Utility function to escape HTML. Quotes too: most call sites interpolate the
|
||||
// result into a quoted attribute value, and the textContent/innerHTML
|
||||
// round-trip only escapes &, < and >.
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
return div.innerHTML
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Utility function to escape text for use in HTML attributes
|
||||
@@ -5623,11 +5630,15 @@ document.addEventListener('htmx:afterSettle', function() {
|
||||
let starlarkDataLoaded = false;
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
// Quotes too: the result lands in quoted attribute values (data-app-id=,
|
||||
// title=), and the textContent/innerHTML round-trip only escapes &, < and >.
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
return div.innerHTML
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function isStarlarkInstalled(appId) {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""System metrics for the status stream.
|
||||
|
||||
Deliberately free of Flask and app imports: importing web_interface.app
|
||||
constructs the Flask application and a CacheManager, and the latter claims the
|
||||
cache directory with a cleanup thread. Reading a CPU percentage should not do
|
||||
that, and neither should testing it.
|
||||
|
||||
Every value is best-effort. A number that cannot be taken comes back as None so
|
||||
the UI can render '--', because a confident wrong number is worse than a blank:
|
||||
``disk_used_percent`` used to be hardcoded to 0, which read as "plenty of room"
|
||||
on a card that was filling up.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
_THERMAL_ZONE = '/sys/class/thermal/thermal_zone0/temp'
|
||||
|
||||
|
||||
def _cpu_temp_c() -> Optional[float]:
|
||||
"""CPU temperature in degrees C, or None off a Raspberry Pi."""
|
||||
try:
|
||||
with open(_THERMAL_ZONE, 'r') as f:
|
||||
return round(float(f.read()) / 1000.0, 1)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def collect_system_metrics() -> Dict[str, Any]:
|
||||
"""Collect the numbers that predict trouble on a small board.
|
||||
|
||||
``memory_available_mb`` is MemAvailable rather than a used percentage: it
|
||||
accounts for reclaimable page cache, so it is what separates a board at 70%
|
||||
"used" that is fine from one at 70% that is about to fail fork(). A 1GB Pi
|
||||
can sit at either and only this number tells them apart.
|
||||
"""
|
||||
try:
|
||||
import psutil
|
||||
except ImportError:
|
||||
return {
|
||||
'cpu_percent': 0,
|
||||
'memory_used_percent': 0,
|
||||
'memory_available_mb': None,
|
||||
'disk_used_percent': None,
|
||||
'cpu_temp': _cpu_temp_c() or 0,
|
||||
}
|
||||
|
||||
# interval=None is non-blocking; app startup primes psutil's internal state.
|
||||
cpu_percent = round(psutil.cpu_percent(interval=None), 1)
|
||||
memory = psutil.virtual_memory()
|
||||
|
||||
try:
|
||||
disk_used_percent: Optional[float] = round(psutil.disk_usage('/').percent, 1)
|
||||
except OSError:
|
||||
disk_used_percent = None
|
||||
|
||||
return {
|
||||
'cpu_percent': cpu_percent,
|
||||
'memory_used_percent': round(memory.percent, 1),
|
||||
'memory_available_mb': round(memory.available / (1024 * 1024), 1),
|
||||
'disk_used_percent': disk_used_percent,
|
||||
'cpu_temp': _cpu_temp_c() or 0,
|
||||
}
|
||||
@@ -152,7 +152,8 @@ function displayCacheFiles(data) {
|
||||
<span class="text-sm text-gray-600">${escapeHtml(modifiedStr)}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button onclick="deleteCacheFile('${escapeHtml(cacheFile.key)}')"
|
||||
<button onclick="deleteCacheFile(this.dataset.cacheKey)"
|
||||
data-cache-key="${escapeHtml(cacheFile.key)}"
|
||||
class="text-red-600 hover:text-red-900 px-3 py-1 rounded hover:bg-red-50 transition-colors"
|
||||
title="Delete cache file">
|
||||
<i class="fas fa-trash mr-1"></i>Delete
|
||||
@@ -220,10 +221,14 @@ function showError(message) {
|
||||
}
|
||||
|
||||
// Utility function to escape HTML
|
||||
// Quotes too: the result is interpolated into quoted attribute values, and the
|
||||
// textContent/innerHTML round-trip only escapes &, < and >.
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
return div.innerHTML
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Make deleteCacheFile available globally for onclick handlers
|
||||
|
||||
@@ -711,11 +711,15 @@ function showError(message) {
|
||||
}
|
||||
}
|
||||
|
||||
// Utility function to escape HTML
|
||||
// Utility function to escape HTML. Quotes too: the result is interpolated into
|
||||
// quoted attribute values (option value=, title=), and the
|
||||
// textContent/innerHTML round-trip only escapes &, < and >.
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
return div.innerHTML
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function refreshCurrentPluginStatus() {
|
||||
|
||||
@@ -30,10 +30,33 @@
|
||||
if (!d.unresolved || d.unresolved.length === 0) return;
|
||||
var key = d.unresolved.map(function (i) { return i.plugin_id; }).sort().join(',');
|
||||
if (sessionStorage.getItem(DISMISS_KEY) === key) return;
|
||||
var ids = d.unresolved.map(function (i) { return i.plugin_id; }).join(', ');
|
||||
document.getElementById('reconciliation-banner-text').textContent =
|
||||
'Stale plugin config entries found: ' + ids +
|
||||
'. Remove them from config.json or reinstall via the Plugin Store.';
|
||||
// These are opposite problems and need opposite advice. Lumping
|
||||
// them together told users to delete config.json entries for
|
||||
// plugins that were installed and correctly configured.
|
||||
function idsOfType(t) {
|
||||
return d.unresolved.filter(function (i) { return i.type === t; })
|
||||
.map(function (i) { return i.plugin_id; });
|
||||
}
|
||||
var notConfigured = idsOfType('plugin_missing_in_config');
|
||||
var notInstalled = idsOfType('plugin_missing_on_disk');
|
||||
var other = d.unresolved.filter(function (i) {
|
||||
return i.type !== 'plugin_missing_in_config'
|
||||
&& i.type !== 'plugin_missing_on_disk';
|
||||
}).map(function (i) { return i.plugin_id; });
|
||||
|
||||
var parts = [];
|
||||
if (notConfigured.length) {
|
||||
parts.push('Installed but missing from config: ' + notConfigured.join(', ')
|
||||
+ '. Configure them in the Plugin Store - do not remove anything from config.json.');
|
||||
}
|
||||
if (notInstalled.length) {
|
||||
parts.push('In config but not installed: ' + notInstalled.join(', ')
|
||||
+ '. Reinstall via the Plugin Store, or remove these entries from config.json.');
|
||||
}
|
||||
if (other.length) {
|
||||
parts.push('Needs attention: ' + other.join(', ') + '.');
|
||||
}
|
||||
document.getElementById('reconciliation-banner-text').textContent = parts.join(' ');
|
||||
var banner = document.getElementById('reconciliation-banner');
|
||||
banner.dataset.dismissKey = key;
|
||||
banner.style.setProperty('display', 'flex', 'important');
|
||||
|
||||
@@ -233,6 +233,46 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MQTT Bridge -->
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900">MQTT Bridge</h2>
|
||||
<span id="mqtt-bridge-state" class="text-xs px-2 py-1 rounded-full bg-gray-100 text-gray-600">checking…</span>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-gray-600">
|
||||
Exposes the display to Home Assistant over MQTT — force a mode on demand, set
|
||||
brightness, toggle power. Runs as <code class="bg-gray-100 px-1 rounded">ledmatrix-mqtt-bridge.service</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="mqtt-bridge-body" class="space-y-4">
|
||||
<p class="text-sm text-gray-500">Loading settings…</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pixlet Config Editor -->
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Pixlet Config Editor</h2>
|
||||
<p class="mt-1 text-sm text-gray-600">
|
||||
Opens a Starlark app in Pixlet's own config form, which runs the app for real —
|
||||
so dropdowns that build their options at runtime work, and you see the render update
|
||||
as you type.
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-amber-700 bg-amber-50 border border-amber-200 rounded-md p-2">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
The display stops while a session is open, and the editor has no password — the
|
||||
same footing as this page. Sessions end by themselves after 30 minutes, so the
|
||||
panel is never left dark.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="pixlet-editor-body" class="space-y-4">
|
||||
<p class="text-sm text-gray-500">Loading apps…</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Power -->
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||
@@ -962,5 +1002,348 @@
|
||||
|
||||
// WiFi radio current state.
|
||||
window.loadWifiRadio();
|
||||
|
||||
// ── MQTT bridge ───────────────────────────────────────────────────────────
|
||||
|
||||
const MQTT_BRIDGE_URL = '/api/v3/integrations/mqtt-bridge';
|
||||
|
||||
function mqttBadge(service, configExists) {
|
||||
if (!service.installed) return ['bg-gray-100 text-gray-600', 'not installed'];
|
||||
if (service.active) return ['bg-green-100 text-green-700', 'running'];
|
||||
if (!configExists) return ['bg-amber-100 text-amber-700', 'not configured'];
|
||||
return ['bg-amber-100 text-amber-700', 'stopped'];
|
||||
}
|
||||
|
||||
function renderMqttBridge(data) {
|
||||
const badge = document.getElementById('mqtt-bridge-state');
|
||||
const [cls, label] = mqttBadge(data.service, data.config_exists);
|
||||
if (badge) {
|
||||
badge.className = 'text-xs px-2 py-1 rounded-full ' + cls;
|
||||
badge.textContent = label;
|
||||
}
|
||||
|
||||
const c = data.config || {};
|
||||
const field = (id, label, value, type, extra) => `
|
||||
<label class="block">
|
||||
<span class="text-xs font-medium text-gray-700">${escHtml(label)}</span>
|
||||
<input id="${id}" type="${type || 'text'}" value="${value === null || value === undefined ? '' : escHtml(String(value))}"
|
||||
${extra || ''}
|
||||
class="mt-1 w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md">
|
||||
</label>`;
|
||||
|
||||
document.getElementById('mqtt-bridge-body').innerHTML = `
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
${field('mqtt-host', 'Broker host', c.mqtt_host)}
|
||||
${field('mqtt-port', 'Port', c.mqtt_port, 'number', 'min="1" max="65535"')}
|
||||
${field('mqtt-username', 'Username (optional)', c.mqtt_username)}
|
||||
<label class="block">
|
||||
<span class="text-xs font-medium text-gray-700">Password</span>
|
||||
<input id="mqtt-password" type="password" value=""
|
||||
placeholder="${data.password_set ? 'unchanged — leave blank to keep' : 'none set'}"
|
||||
class="mt-1 w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md">
|
||||
<span class="text-xs text-gray-500">
|
||||
Never sent back to the browser.
|
||||
${data.password_set ? '<button type="button" id="mqtt-clear-password" class="text-red-600 hover:underline ml-1">Clear it</button>' : ''}
|
||||
</span>
|
||||
</label>
|
||||
${field('mqtt-topic', 'Command topic', c.mqtt_topic)}
|
||||
${field('mqtt-client-id', 'Client ID', c.mqtt_client_id)}
|
||||
${field('mqtt-api-base', 'LEDMatrix API base', c.ledmatrix_api_base)}
|
||||
${field('mqtt-timeout', 'Request timeout (s)', c.request_timeout, 'number', 'min="1" max="300"')}
|
||||
${field('mqtt-duration', 'On-demand duration (s, blank = default)', c.on_demand_duration, 'number', 'min="1" max="86400"')}
|
||||
<label class="block">
|
||||
<span class="text-xs font-medium text-gray-700">Log level</span>
|
||||
<select id="mqtt-log-level" class="mt-1 w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md bg-white">
|
||||
${['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'].map(l =>
|
||||
`<option value="${l}" ${c.log_level === l ? 'selected' : ''}>${l}</option>`).join('')}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input id="mqtt-tls" type="checkbox" ${c.mqtt_tls ? 'checked' : ''}
|
||||
class="rounded border-gray-300">
|
||||
Use TLS
|
||||
<span class="text-xs text-gray-500">(a password without TLS crosses the network in the clear)</span>
|
||||
</label>
|
||||
|
||||
<div class="flex items-center justify-between gap-4 pt-2">
|
||||
<p class="text-xs text-gray-500">
|
||||
Saved to <code class="bg-gray-100 px-1 rounded">${escHtml(data.config_path)}</code>.
|
||||
Any field can also be set as
|
||||
<code class="bg-gray-100 px-1 rounded">${escHtml(data.env_override_prefix)}<KEY></code>,
|
||||
which wins over the file.
|
||||
</p>
|
||||
<button id="btn-mqtt-save" onclick="saveMqttBridge()"
|
||||
class="shrink-0 inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700">
|
||||
<i class="fas fa-save mr-2"></i>Save settings
|
||||
</button>
|
||||
</div>
|
||||
<div id="result-mqtt-save" class="hidden"></div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 pt-4 border-t border-gray-100">
|
||||
${data.service.installed ? `
|
||||
<button id="btn-mqtt-restart" onclick="toolsAction('mqtt_bridge_restart','btn-mqtt-restart','result-mqtt-service')"
|
||||
class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||
<i class="fas fa-sync-alt mr-2"></i>Restart
|
||||
</button>
|
||||
<button id="btn-mqtt-startstop"
|
||||
onclick="toolsAction('${data.service.active ? 'mqtt_bridge_stop' : 'mqtt_bridge_start'}','btn-mqtt-startstop','result-mqtt-service')"
|
||||
class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||
<i class="fas fa-${data.service.active ? 'stop' : 'play'} mr-2"></i>${data.service.active ? 'Stop' : 'Start'}
|
||||
</button>` : `
|
||||
<button id="btn-mqtt-install" onclick="installMqttBridge()"
|
||||
class="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700">
|
||||
<i class="fas fa-download mr-2"></i>Install & start bridge
|
||||
</button>
|
||||
<span class="text-xs text-gray-500">Installs dependencies, enables the unit and starts it.</span>`}
|
||||
</div>
|
||||
<div id="result-mqtt-service" class="hidden"></div>
|
||||
`;
|
||||
|
||||
const clearBtn = document.getElementById('mqtt-clear-password');
|
||||
if (clearBtn) clearBtn.addEventListener('click', () => clearMqttPassword());
|
||||
}
|
||||
|
||||
window.loadMqttBridge = function() {
|
||||
fetch(MQTT_BRIDGE_URL)
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.status !== 'success') throw new Error(d.message || 'Failed');
|
||||
renderMqttBridge(d.data);
|
||||
})
|
||||
.catch(err => {
|
||||
document.getElementById('mqtt-bridge-body').innerHTML =
|
||||
`<p class="text-sm text-red-600">Could not load bridge settings: ${escHtml(err.message)}</p>`;
|
||||
});
|
||||
};
|
||||
|
||||
function mqttBody() {
|
||||
const val = id => (document.getElementById(id) || {}).value;
|
||||
const body = {
|
||||
mqtt_host: val('mqtt-host'),
|
||||
mqtt_port: val('mqtt-port'),
|
||||
mqtt_username: val('mqtt-username'),
|
||||
mqtt_topic: val('mqtt-topic'),
|
||||
mqtt_client_id: val('mqtt-client-id'),
|
||||
ledmatrix_api_base: val('mqtt-api-base'),
|
||||
request_timeout: val('mqtt-timeout'),
|
||||
on_demand_duration: val('mqtt-duration') === '' ? null : val('mqtt-duration'),
|
||||
log_level: val('mqtt-log-level'),
|
||||
mqtt_tls: !!(document.getElementById('mqtt-tls') || {}).checked,
|
||||
};
|
||||
// Only send a password when one was typed; blank means "leave it alone".
|
||||
const pw = val('mqtt-password');
|
||||
if (pw) body.mqtt_password = pw;
|
||||
return body;
|
||||
}
|
||||
|
||||
window.saveMqttBridge = function() {
|
||||
setBusy('btn-mqtt-save', true);
|
||||
fetch(MQTT_BRIDGE_URL + '/config', {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(mqttBody())
|
||||
})
|
||||
.then(r => r.json().then(d => ({ok: r.ok, d})))
|
||||
.then(({ok, d}) => {
|
||||
showResult('result-mqtt-save', ok && d.status === 'success',
|
||||
d.message || (ok ? 'Saved' : 'Failed'));
|
||||
if (ok && d.status === 'success') loadMqttBridge();
|
||||
})
|
||||
.catch(err => showResult('result-mqtt-save', false, 'Request failed: ' + err.message))
|
||||
.finally(() => setBusy('btn-mqtt-save', false));
|
||||
};
|
||||
|
||||
function clearMqttPassword() {
|
||||
if (!confirm('Remove the stored MQTT password?')) return;
|
||||
fetch(MQTT_BRIDGE_URL + '/config', {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({clear_password: true})
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
showResult('result-mqtt-save', d.status === 'success', d.message || 'Password cleared');
|
||||
loadMqttBridge();
|
||||
})
|
||||
.catch(err => showResult('result-mqtt-save', false, 'Request failed: ' + err.message));
|
||||
}
|
||||
|
||||
window.installMqttBridge = function() {
|
||||
// Installing pulls dependencies, so it is slower than the other actions
|
||||
// and worth saying so rather than leaving a spinner unexplained.
|
||||
showResult('result-mqtt-service', true, 'Installing — this pulls Python dependencies and can take a minute…');
|
||||
setBusy('btn-mqtt-install', true);
|
||||
fetch('/api/v3/system/action', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({action: 'mqtt_bridge_install'})
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
showResult('result-mqtt-service', d.status === 'success',
|
||||
d.message || (d.status === 'success' ? 'Installed' : 'Failed'), d.output || '');
|
||||
loadMqttBridge();
|
||||
})
|
||||
.catch(err => showResult('result-mqtt-service', false, 'Request failed: ' + err.message))
|
||||
.finally(() => setBusy('btn-mqtt-install', false));
|
||||
};
|
||||
|
||||
// ── Pixlet config editor ──────────────────────────────────────────────────
|
||||
|
||||
const PIXLET_EDITOR_URL = '/api/v3/starlark/editor';
|
||||
let pixletPollTimer = null;
|
||||
|
||||
function fmtRemaining(seconds) {
|
||||
if (seconds === null || seconds === undefined) return '';
|
||||
const m = Math.floor(seconds / 60), s = seconds % 60;
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function editorHost(port) {
|
||||
// The session binds 0.0.0.0, so whatever host reached this page reaches
|
||||
// the editor too -- no tunnel, and no guessing the device's address.
|
||||
return `${window.location.protocol}//${window.location.hostname}:${port}/`;
|
||||
}
|
||||
|
||||
function renderPixletEditor(status, apps, pixletAvailable, appsDir) {
|
||||
const body = document.getElementById('pixlet-editor-body');
|
||||
if (!body) return;
|
||||
|
||||
if (!pixletAvailable) {
|
||||
body.innerHTML = `
|
||||
<p class="text-sm text-gray-700">
|
||||
Pixlet is not installed, so there is nothing to edit with. Install it from the
|
||||
Starlark Apps section of the Plugins tab, or run
|
||||
<code class="bg-gray-100 px-1 rounded">./scripts/download_pixlet.sh</code>.
|
||||
</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const active = status.running ? `
|
||||
<div class="rounded-md border border-amber-300 bg-amber-50 p-3">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-amber-900">
|
||||
<i class="fas fa-circle-notch fa-spin mr-1"></i>
|
||||
Editing <code class="bg-amber-100 px-1 rounded">${escHtml(status.app_id)}</code> — the display is stopped
|
||||
</p>
|
||||
<p class="text-xs text-amber-800 mt-1">
|
||||
<a href="${editorHost(status.port)}" target="_blank" rel="noopener"
|
||||
class="underline font-medium">Open the editor →</a>
|
||||
<span class="ml-2">stops on its own in
|
||||
<span id="pixlet-countdown">${fmtRemaining(status.seconds_remaining)}</span></span>
|
||||
</p>
|
||||
</div>
|
||||
<button id="btn-pixlet-stop" onclick="stopPixletEditor()"
|
||||
class="shrink-0 inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700">
|
||||
<i class="fas fa-stop mr-2"></i>Stop now
|
||||
</button>
|
||||
</div>
|
||||
</div>` : '';
|
||||
|
||||
const rows = apps.length ? apps.map(a => `
|
||||
<div class="flex items-center justify-between gap-4 py-2 border-t border-gray-100 first:border-t-0">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 truncate">${escHtml(a.name)}</p>
|
||||
<p class="text-xs text-gray-500 truncate">${escHtml(a.id)}${a.editable ? '' : ' — no .star file'}</p>
|
||||
</div>
|
||||
<button id="btn-pixlet-edit-${escHtml(a.id)}"
|
||||
${(!a.editable || status.running) ? 'disabled' : ''}
|
||||
data-pixlet-edit data-app-id="${escHtml(a.id)}"
|
||||
class="shrink-0 inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<i class="fas fa-sliders-h mr-2"></i>Edit
|
||||
</button>
|
||||
</div>`).join('') : `
|
||||
<p class="text-sm text-gray-500">
|
||||
No Starlark apps installed. Install one from the Plugins tab first
|
||||
(looked in <code class="bg-gray-100 px-1 rounded">${escHtml(appsDir)}</code>).
|
||||
</p>`;
|
||||
|
||||
body.innerHTML = active + `<div class="mt-2">${rows}</div>
|
||||
<div id="result-pixlet" class="hidden"></div>`;
|
||||
|
||||
// Bound here rather than via an inline onclick: escHtml does not encode
|
||||
// single quotes, and the id used to be interpolated into a single-quoted
|
||||
// JS string, so an app directory containing an apostrophe could break
|
||||
// out of it and run script. Through dataset the value is only ever
|
||||
// parsed as an HTML attribute, never as JavaScript.
|
||||
body.querySelectorAll('[data-pixlet-edit]').forEach(btn => {
|
||||
btn.addEventListener('click', () => startPixletEditor(btn.dataset.appId));
|
||||
});
|
||||
}
|
||||
|
||||
window.loadPixletEditor = function() {
|
||||
Promise.all([
|
||||
fetch(PIXLET_EDITOR_URL + '/status').then(r => r.json()),
|
||||
fetch(PIXLET_EDITOR_URL + '/apps').then(r => r.json())
|
||||
])
|
||||
.then(([s, a]) => {
|
||||
const status = (s.data || {});
|
||||
const apps = (a.data || {}).apps || [];
|
||||
renderPixletEditor(status, apps, (a.data || {}).pixlet_available, (a.data || {}).apps_dir || '');
|
||||
schedulePixletPoll(status.running);
|
||||
})
|
||||
.catch(err => {
|
||||
const body = document.getElementById('pixlet-editor-body');
|
||||
if (body) body.innerHTML = `<p class="text-sm text-red-600">Could not load: ${escHtml(err.message)}</p>`;
|
||||
});
|
||||
};
|
||||
|
||||
function schedulePixletPoll(running) {
|
||||
// Poll only while a session is open: that is the state worth watching,
|
||||
// and it is what makes the banner survive a page reload.
|
||||
if (pixletPollTimer) { clearInterval(pixletPollTimer); pixletPollTimer = null; }
|
||||
if (!running) return;
|
||||
pixletPollTimer = setInterval(() => {
|
||||
fetch(PIXLET_EDITOR_URL + '/status')
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
const st = d.data || {};
|
||||
if (!st.running) { loadPixletEditor(); return; }
|
||||
const el = document.getElementById('pixlet-countdown');
|
||||
if (el) el.textContent = fmtRemaining(st.seconds_remaining);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
window.startPixletEditor = function(appId) {
|
||||
const btnId = 'btn-pixlet-edit-' + appId;
|
||||
setBusy(btnId, true);
|
||||
fetch(PIXLET_EDITOR_URL + '/start', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({app_id: appId})
|
||||
})
|
||||
.then(r => r.json().then(d => ({ok: r.ok, d})))
|
||||
.then(({ok, d}) => {
|
||||
if (!ok || d.status !== 'success') {
|
||||
showResult('result-pixlet', false, d.message || 'Could not start the editor');
|
||||
return;
|
||||
}
|
||||
loadPixletEditor();
|
||||
})
|
||||
.catch(err => showResult('result-pixlet', false, 'Request failed: ' + err.message))
|
||||
.finally(() => setBusy(btnId, false));
|
||||
};
|
||||
|
||||
window.stopPixletEditor = function() {
|
||||
setBusy('btn-pixlet-stop', true);
|
||||
fetch(PIXLET_EDITOR_URL + '/stop', {method: 'POST'})
|
||||
.then(r => r.json())
|
||||
.then(d => { showResult('result-pixlet', d.status === 'success', d.message || 'Stopped'); loadPixletEditor(); })
|
||||
.catch(err => showResult('result-pixlet', false, 'Request failed: ' + err.message))
|
||||
.finally(() => setBusy('btn-pixlet-stop', false));
|
||||
};
|
||||
|
||||
// Initial load for both sections. Down here rather than at the shared init
|
||||
// site above, because these are assignments to window rather than hoisted
|
||||
// declarations -- calling them earlier in the file throws.
|
||||
loadMqttBridge();
|
||||
loadPixletEditor();
|
||||
|
||||
})();
|
||||
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user