mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-14 11:06:39 +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:
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
|
||||
Reference in New Issue
Block a user