mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-18 04:49:13 +00:00
Compare commits
2
Commits
v3.8.1
...
56ba235f4c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56ba235f4c | ||
|
|
60d5d19898 |
Regular → Executable
Regular → Executable
+65
-11
@@ -18,20 +18,33 @@
|
|||||||
# ./scripts/utils/pixlet_config_editor.sh # list installed apps
|
# ./scripts/utils/pixlet_config_editor.sh # list installed apps
|
||||||
# ./scripts/utils/pixlet_config_editor.sh <app_id> # edit
|
# ./scripts/utils/pixlet_config_editor.sh <app_id> # edit
|
||||||
#
|
#
|
||||||
# Binds loopback only, and there is deliberately no flag to change that:
|
# Binds the LAN by default, matching the web interface, which already serves
|
||||||
# `pixlet serve` has no authentication, and anything that can reach it can
|
# 0.0.0.0:5000 with no authentication -- anything that can reach this can
|
||||||
# rewrite the app's config. To edit from another machine, forward the port --
|
# already reconfigure the display there. `pixlet serve` has no authentication
|
||||||
# which authenticates as SSH and leaves nothing listening on the LAN:
|
# either, so treat both the same way: fine on a home network, not on an open
|
||||||
|
# one. Override the bind and the session length with:
|
||||||
|
#
|
||||||
|
# PIXLET_EDITOR_HOST=127.0.0.1 ./scripts/utils/pixlet_config_editor.sh <app>
|
||||||
|
# PIXLET_EDITOR_TIMEOUT=600 ./scripts/utils/pixlet_config_editor.sh <app>
|
||||||
|
#
|
||||||
|
# For loopback-only editing from another machine, forward the port instead:
|
||||||
#
|
#
|
||||||
# ssh -L 8080:localhost:8080 pi@ledpi.local
|
# ssh -L 8080:localhost:8080 pi@ledpi.local
|
||||||
|
#
|
||||||
|
# The session always ends by itself after PIXLET_EDITOR_TIMEOUT seconds
|
||||||
|
# (default 30 minutes). The display is stopped while editing, so a session
|
||||||
|
# left open would otherwise leave the panel dark indefinitely -- the timeout
|
||||||
|
# is what makes it safe to start one from the web interface.
|
||||||
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
|
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
|
||||||
APPS_DIR="$PROJECT_ROOT_DIR/starlark-apps"
|
APPS_DIR="$PROJECT_ROOT_DIR/starlark-apps"
|
||||||
PORT="${PIXLET_EDITOR_PORT:-8080}"
|
PORT="${PIXLET_EDITOR_PORT:-8080}"
|
||||||
# Loopback only. See the header: pixlet serve is unauthenticated.
|
# LAN by default; see the header for why, and how to force loopback.
|
||||||
BIND_HOST="127.0.0.1"
|
BIND_HOST="${PIXLET_EDITOR_HOST:-0.0.0.0}"
|
||||||
|
# Hard stop, so the display cannot be left off by a forgotten session.
|
||||||
|
EDITOR_TIMEOUT="${PIXLET_EDITOR_TIMEOUT:-1800}"
|
||||||
|
|
||||||
APP_ID="${1:-}"
|
APP_ID="${1:-}"
|
||||||
|
|
||||||
@@ -109,6 +122,19 @@ fi
|
|||||||
# failure worth guarding against.
|
# failure worth guarding against.
|
||||||
cleanup() {
|
cleanup() {
|
||||||
echo ""
|
echo ""
|
||||||
|
# Kill the serve child explicitly. `timeout` is started with --foreground so
|
||||||
|
# it shares this script's process group (without that it makes its own, and
|
||||||
|
# a group signal aimed at this script would orphan pixlet with the port
|
||||||
|
# still bound). Belt and braces: signal the recorded pid too, because a
|
||||||
|
# group signal only reaches it while the group is shared.
|
||||||
|
if [ -n "${SERVE_PID:-}" ] && kill -0 "$SERVE_PID" 2>/dev/null; then
|
||||||
|
kill -TERM "$SERVE_PID" 2>/dev/null || true
|
||||||
|
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
||||||
|
kill -0 "$SERVE_PID" 2>/dev/null || break
|
||||||
|
sleep 0.3
|
||||||
|
done
|
||||||
|
kill -KILL "$SERVE_PID" 2>/dev/null || true
|
||||||
|
fi
|
||||||
if [ "$DISPLAY_WAS_RUNNING" = true ]; then
|
if [ "$DISPLAY_WAS_RUNNING" = true ]; then
|
||||||
echo "Restarting the display service..."
|
echo "Restarting the display service..."
|
||||||
sudo systemctl restart ledmatrix || echo "⚠ Could not restart ledmatrix - do it by hand"
|
sudo systemctl restart ledmatrix || echo "⚠ Could not restart ledmatrix - do it by hand"
|
||||||
@@ -122,22 +148,50 @@ if [ "$DISPLAY_WAS_RUNNING" = true ]; then
|
|||||||
sudo systemctl stop ledmatrix
|
sudo systemctl stop ledmatrix
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ "$BIND_HOST" = "0.0.0.0" ]; then
|
||||||
|
REACH_HOST="$(hostname).local"
|
||||||
|
else
|
||||||
|
REACH_HOST="localhost"
|
||||||
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Editing: $APP_ID"
|
echo "Editing: $APP_ID"
|
||||||
echo "App file: $STAR_FILE"
|
echo "App file: $STAR_FILE"
|
||||||
echo "URL: http://localhost:$PORT/"
|
echo "URL: http://$REACH_HOST:$PORT/"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Listening on localhost only -- pixlet serve has no authentication."
|
if [ "$BIND_HOST" = "0.0.0.0" ]; then
|
||||||
echo "From another machine, forward the port:"
|
echo "Reachable on the LAN, and pixlet serve has no authentication -- the"
|
||||||
|
echo "same footing as the web interface on port 5000. Set"
|
||||||
|
echo "PIXLET_EDITOR_HOST=127.0.0.1 to keep it to this machine."
|
||||||
|
else
|
||||||
|
echo "Listening on $BIND_HOST only. From another machine, forward the port:"
|
||||||
echo " ssh -L $PORT:localhost:$PORT $(whoami)@$(hostname)"
|
echo " ssh -L $PORT:localhost:$PORT $(whoami)@$(hostname)"
|
||||||
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
echo "Changes save straight to the real config as you make them."
|
echo "Changes save straight to the real config as you make them."
|
||||||
echo "Press Ctrl+C when finished - the display restarts automatically."
|
echo "Press Ctrl+C when finished - the display restarts automatically."
|
||||||
|
echo "This session stops on its own after ${EDITOR_TIMEOUT}s regardless."
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
cd "$APP_DIR"
|
cd "$APP_DIR"
|
||||||
"$PIXLET_BIN" serve "$(basename "$STAR_FILE")" \
|
# `timeout` owns the hard stop rather than the caller: the trap above restarts
|
||||||
|
# the display however this exits, so a session that outlives the person who
|
||||||
|
# started it still gives the panel back. Exit 124 is timeout's own code for
|
||||||
|
# "expired", which is a normal end here, not a failure.
|
||||||
|
# --foreground: stay in this script's process group so one signal reaches the
|
||||||
|
# whole session. Backgrounded + `wait` so the EXIT trap can run while the child
|
||||||
|
# is still alive; a foreground child would leave bash waiting on it instead.
|
||||||
|
timeout --foreground "$EDITOR_TIMEOUT" "$PIXLET_BIN" serve "$(basename "$STAR_FILE")" \
|
||||||
--host "$BIND_HOST" \
|
--host "$BIND_HOST" \
|
||||||
--port "$PORT" \
|
--port "$PORT" \
|
||||||
--no-browser \
|
--no-browser \
|
||||||
--saveconfig "$CONFIG_FILE"
|
--saveconfig "$CONFIG_FILE" &
|
||||||
|
SERVE_PID=$!
|
||||||
|
|
||||||
|
status=0
|
||||||
|
wait "$SERVE_PID" || status=$?
|
||||||
|
if [ "$status" -eq 124 ]; then
|
||||||
|
echo "Session reached its ${EDITOR_TIMEOUT}s limit."
|
||||||
|
status=0
|
||||||
|
fi
|
||||||
|
exit "$status"
|
||||||
|
|||||||
Vendored
+998
@@ -0,0 +1,998 @@
|
|||||||
|
[
|
||||||
|
[
|
||||||
|
"/api/v3/backup/<path:filename>",
|
||||||
|
"api_v3.backup_delete",
|
||||||
|
[
|
||||||
|
"DELETE",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/backup/download/<path:filename>",
|
||||||
|
"api_v3.backup_download",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/backup/export",
|
||||||
|
"api_v3.backup_export",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/backup/list",
|
||||||
|
"api_v3.backup_list",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/backup/preview",
|
||||||
|
"api_v3.backup_preview",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/backup/restore",
|
||||||
|
"api_v3.backup_restore",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/backup/validate",
|
||||||
|
"api_v3.backup_validate",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/cache/delete",
|
||||||
|
"api_v3.delete_cache_file",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/cache/list",
|
||||||
|
"api_v3.list_cache_files",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/config/dim-schedule",
|
||||||
|
"api_v3.get_dim_schedule_config",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/config/dim-schedule",
|
||||||
|
"api_v3.save_dim_schedule_config",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/config/main",
|
||||||
|
"api_v3.get_main_config",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/config/main",
|
||||||
|
"api_v3.save_main_config",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/config/raw/main",
|
||||||
|
"api_v3.save_raw_main_config",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/config/raw/secrets",
|
||||||
|
"api_v3.save_raw_secrets_config",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/config/schedule",
|
||||||
|
"api_v3.get_schedule_config",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/config/schedule",
|
||||||
|
"api_v3.save_schedule_config",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/config/secrets",
|
||||||
|
"api_v3.get_secrets_config",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/display/current",
|
||||||
|
"api_v3.get_display_current",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/display/current-status",
|
||||||
|
"api_v3.get_current_display_status",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/display/modes",
|
||||||
|
"api_v3.get_display_modes",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/display/on-demand/start",
|
||||||
|
"api_v3.start_on_demand_display",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/display/on-demand/status",
|
||||||
|
"api_v3.get_on_demand_status",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/display/on-demand/stop",
|
||||||
|
"api_v3.stop_on_demand_display",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/errors/clear",
|
||||||
|
"api_v3.clear_old_errors",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/errors/plugin/<plugin_id>",
|
||||||
|
"api_v3.get_plugin_errors",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/errors/summary",
|
||||||
|
"api_v3.get_error_summary",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/fonts/<font_family>",
|
||||||
|
"api_v3.delete_font",
|
||||||
|
[
|
||||||
|
"DELETE",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/fonts/catalog",
|
||||||
|
"api_v3.get_fonts_catalog",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/fonts/overrides",
|
||||||
|
"api_v3.get_fonts_overrides",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/fonts/overrides",
|
||||||
|
"api_v3.save_fonts_overrides",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/fonts/overrides/<element_key>",
|
||||||
|
"api_v3.delete_font_override",
|
||||||
|
[
|
||||||
|
"DELETE",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/fonts/preview",
|
||||||
|
"api_v3.get_font_preview",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/fonts/tokens",
|
||||||
|
"api_v3.get_font_tokens",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/fonts/upload",
|
||||||
|
"api_v3.upload_font",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/hardware/status",
|
||||||
|
"api_v3.get_hardware_status",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/health",
|
||||||
|
"api_v3.get_health",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/integrations/mqtt-bridge",
|
||||||
|
"api_v3.get_mqtt_bridge",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/integrations/mqtt-bridge/config",
|
||||||
|
"api_v3.update_mqtt_bridge_config",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"PUT"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/logs",
|
||||||
|
"api_v3.get_logs",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/<plugin_id>/static/<path:file_path>",
|
||||||
|
"api_v3.serve_plugin_static",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/action",
|
||||||
|
"api_v3.execute_plugin_action",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/assets/delete",
|
||||||
|
"api_v3.delete_plugin_asset",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/assets/list",
|
||||||
|
"api_v3.list_plugin_assets",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/assets/upload",
|
||||||
|
"api_v3.upload_plugin_asset",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/authenticate/spotify",
|
||||||
|
"api_v3.authenticate_spotify",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/authenticate/ytm",
|
||||||
|
"api_v3.authenticate_ytm",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/calendar/authenticate",
|
||||||
|
"api_v3.authenticate_calendar",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/calendar/list-calendars",
|
||||||
|
"api_v3.list_calendar_calendars",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/calendar/upload-credentials",
|
||||||
|
"api_v3.upload_calendar_credentials",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/config",
|
||||||
|
"api_v3.get_plugin_config",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/config",
|
||||||
|
"api_v3.save_plugin_config",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/config/reset",
|
||||||
|
"api_v3.reset_plugin_config",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/health",
|
||||||
|
"api_v3.get_plugin_health",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/health/<plugin_id>",
|
||||||
|
"api_v3.get_plugin_health_single",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/health/<plugin_id>/reset",
|
||||||
|
"api_v3.reset_plugin_health",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/install",
|
||||||
|
"api_v3.install_plugin",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/install-from-url",
|
||||||
|
"api_v3.install_plugin_from_url",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/installed",
|
||||||
|
"api_v3.get_installed_plugins",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/limits/<plugin_id>",
|
||||||
|
"api_v3.manage_plugin_limits",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/metrics",
|
||||||
|
"api_v3.get_plugin_metrics",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/metrics/<plugin_id>",
|
||||||
|
"api_v3.get_plugin_metrics_single",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/metrics/<plugin_id>/reset",
|
||||||
|
"api_v3.reset_plugin_metrics",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/of-the-day/json/delete",
|
||||||
|
"api_v3.delete_of_the_day_json",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/of-the-day/json/upload",
|
||||||
|
"api_v3.upload_of_the_day_json",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/operation/<operation_id>",
|
||||||
|
"api_v3.get_operation_status",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/operation/history",
|
||||||
|
"api_v3.clear_operation_history",
|
||||||
|
[
|
||||||
|
"DELETE",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/operation/history",
|
||||||
|
"api_v3.get_operation_history",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/reconciliation-status",
|
||||||
|
"api_v3.get_reconciliation_status",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/registry-from-url",
|
||||||
|
"api_v3.get_registry_from_url",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/saved-repositories",
|
||||||
|
"api_v3.add_saved_repository",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/saved-repositories",
|
||||||
|
"api_v3.get_saved_repositories",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/saved-repositories",
|
||||||
|
"api_v3.remove_saved_repository",
|
||||||
|
[
|
||||||
|
"DELETE",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/schema",
|
||||||
|
"api_v3.get_plugin_schema",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/state",
|
||||||
|
"api_v3.get_plugin_state",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/state/reconcile",
|
||||||
|
"api_v3.reconcile_plugin_state",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/store/github-status",
|
||||||
|
"api_v3.get_github_auth_status",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/store/list",
|
||||||
|
"api_v3.list_plugin_store",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/store/refresh",
|
||||||
|
"api_v3.refresh_plugin_store",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/toggle",
|
||||||
|
"api_v3.toggle_plugin",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/uninstall",
|
||||||
|
"api_v3.uninstall_plugin",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/update",
|
||||||
|
"api_v3.update_plugin",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/skins",
|
||||||
|
"api_v3.list_skins",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/starlark/apps",
|
||||||
|
"api_v3.get_starlark_apps",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/starlark/apps/<app_id>",
|
||||||
|
"api_v3.get_starlark_app",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/starlark/apps/<app_id>",
|
||||||
|
"api_v3.uninstall_starlark_app",
|
||||||
|
[
|
||||||
|
"DELETE",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/starlark/apps/<app_id>/config",
|
||||||
|
"api_v3.get_starlark_app_config",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/starlark/apps/<app_id>/config",
|
||||||
|
"api_v3.update_starlark_app_config",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"PUT"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/starlark/apps/<app_id>/render",
|
||||||
|
"api_v3.render_starlark_app",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/starlark/apps/<app_id>/toggle",
|
||||||
|
"api_v3.toggle_starlark_app",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/starlark/editor/apps",
|
||||||
|
"api_v3.list_pixlet_editor_apps",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/starlark/editor/start",
|
||||||
|
"api_v3.start_pixlet_editor",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/starlark/editor/status",
|
||||||
|
"api_v3.get_pixlet_editor_status",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/starlark/editor/stop",
|
||||||
|
"api_v3.stop_pixlet_editor",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/starlark/install-pixlet",
|
||||||
|
"api_v3.install_pixlet",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/starlark/repository/browse",
|
||||||
|
"api_v3.browse_tronbyte_repository",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/starlark/repository/categories",
|
||||||
|
"api_v3.get_tronbyte_categories",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/starlark/repository/install",
|
||||||
|
"api_v3.install_from_tronbyte_repository",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/starlark/status",
|
||||||
|
"api_v3.get_starlark_status",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/starlark/upload",
|
||||||
|
"api_v3.upload_starlark_app",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/sync/status",
|
||||||
|
"api_v3.get_sync_status",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/system/action",
|
||||||
|
"api_v3.execute_system_action",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/system/check-update",
|
||||||
|
"api_v3.check_for_update",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/system/git-branches",
|
||||||
|
"api_v3.get_git_branches",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/system/git-info",
|
||||||
|
"api_v3.get_git_info",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/system/status",
|
||||||
|
"api_v3.get_system_status",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/system/version",
|
||||||
|
"api_v3.get_system_version",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/wifi/ap/auto-enable",
|
||||||
|
"api_v3.get_auto_enable_ap_mode",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/wifi/ap/auto-enable",
|
||||||
|
"api_v3.set_auto_enable_ap_mode",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/wifi/ap/disable",
|
||||||
|
"api_v3.disable_ap_mode",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/wifi/ap/enable",
|
||||||
|
"api_v3.enable_ap_mode",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/wifi/connect",
|
||||||
|
"api_v3.connect_wifi",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/wifi/disconnect",
|
||||||
|
"api_v3.disconnect_wifi",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/wifi/radio",
|
||||||
|
"api_v3.get_wifi_radio",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/wifi/radio",
|
||||||
|
"api_v3.set_wifi_radio",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/wifi/scan",
|
||||||
|
"api_v3.scan_wifi_networks",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/wifi/status",
|
||||||
|
"api_v3.get_wifi_status",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
@@ -39,6 +39,7 @@ nothing is listening, so it stays useful in a bare checkout.
|
|||||||
| `dom/test_installed_dom.js` | yes | The toolbar in a real DOM: pill/search/sort interaction, the HTMX partial re-swap, and a `getComputedStyle` check that `.filter-pill[data-active]` really matches the emitted markup |
|
| `dom/test_installed_dom.js` | yes | The toolbar in a real DOM: pill/search/sort interaction, the HTMX partial re-swap, and a `getComputedStyle` check that `.filter-pill[data-active]` really matches the emitted markup |
|
||||||
| `dom/test_store_dom.js` | yes | Store pagination, per-page, category, tri-state Installed button, and persistence across a re-boot, against the live registry |
|
| `dom/test_store_dom.js` | yes | Store pagination, per-page, category, tri-state Installed button, and persistence across a re-boot, against the live registry |
|
||||||
| `dom/test_no_double_fetch.js` | yes | Loads the **whole** `plugins_manager.js` and counts requests: typing in the store search must filter the cached list, not refetch `/api/v3/plugins/store/list` |
|
| `dom/test_no_double_fetch.js` | yes | Loads the **whole** `plugins_manager.js` and counts requests: typing in the store search must filter the cached list, not refetch `/api/v3/plugins/store/list` |
|
||||||
|
| `dom/test_tools_sections.js` | yes | The Tools tab's MQTT bridge and Pixlet editor sections: form prefill, the write-only password (blank means unchanged), the running-session banner and countdown, and that the editor link points at the host you loaded the page from |
|
||||||
|
|
||||||
Point the DOM suites at a rig with a full plugin set when it matters — a dev box
|
Point the DOM suites at a rig with a full plugin set when it matters — a dev box
|
||||||
with two plugins installed will pass while exercising very little.
|
with two plugins installed will pass while exercising very little.
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// Real-DOM (jsdom) test of the two new Tools sections. The HTML is the actual
|
||||||
|
// server-rendered /partials/tools, and the payloads are the real API's, so a
|
||||||
|
// renamed field or a changed shape fails this rather than passing quietly.
|
||||||
|
const http = require('http');
|
||||||
|
const { JSDOM, VirtualConsole } = require('jsdom');
|
||||||
|
|
||||||
|
const BASE = process.env.BASE || 'http://localhost:5000';
|
||||||
|
const get = p => new Promise((res, rej) =>
|
||||||
|
http.get(BASE + p, r => { let d = ''; r.on('data', c => d += c); r.on('end', () => res(d)); }).on('error', rej));
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const partial = await get('/partials/tools');
|
||||||
|
const bridge = JSON.parse(await get('/api/v3/integrations/mqtt-bridge'));
|
||||||
|
const apps = JSON.parse(await get('/api/v3/starlark/editor/apps'));
|
||||||
|
|
||||||
|
const errs = [];
|
||||||
|
const vc = new VirtualConsole();
|
||||||
|
vc.on('jsdomError', e => errs.push(String(e.message || e).split('\n')[0]));
|
||||||
|
|
||||||
|
// Controllable fetch: serve the real payloads, and let tests swap in others.
|
||||||
|
let editorStatus = { status: 'success', data: { running: false } };
|
||||||
|
let bridgePayload = bridge;
|
||||||
|
let onPut = null;
|
||||||
|
const stubFetch = (url, opts) => {
|
||||||
|
const u = String(url);
|
||||||
|
if (onPut && opts && opts.method === 'PUT') onPut(JSON.parse(opts.body));
|
||||||
|
let body = { status: 'success', data: {} };
|
||||||
|
if (u.includes('/integrations/mqtt-bridge')) body = bridgePayload;
|
||||||
|
else if (u.includes('/starlark/editor/status')) body = editorStatus;
|
||||||
|
else if (u.includes('/starlark/editor/apps')) body = apps;
|
||||||
|
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(body) });
|
||||||
|
};
|
||||||
|
|
||||||
|
// runScripts:'dangerously' so the partial's own <script> executes the way a
|
||||||
|
// browser runs it -- function declarations land on window. Evaluating the
|
||||||
|
// source by hand instead leaves helpers like escHtml off the global object
|
||||||
|
// and the page fails in ways it never would in a browser.
|
||||||
|
const dom = new JSDOM(`<!doctype html><html><body>${partial}</body></html>`,
|
||||||
|
{ runScripts: 'dangerously', virtualConsole: vc, url: BASE + '/',
|
||||||
|
beforeParse(w) { w.fetch = stubFetch; w.confirm = () => true; } });
|
||||||
|
const { window } = dom;
|
||||||
|
|
||||||
|
const tick = ms => new Promise(r => setTimeout(r, ms));
|
||||||
|
await tick(300);
|
||||||
|
|
||||||
|
const $ = id => window.document.getElementById(id);
|
||||||
|
let pass = 0, fail = 0;
|
||||||
|
const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
|
||||||
|
: (fail++, console.log(' FAIL ' + l + (x !== undefined ? ' → ' + JSON.stringify(x).slice(0, 200) : '')));
|
||||||
|
|
||||||
|
console.log('\n── Tools: MQTT bridge + Pixlet editor (real DOM) ──');
|
||||||
|
|
||||||
|
// ── MQTT bridge ────────────────────────────────────────────────────────
|
||||||
|
ok('bridge form rendered', !!$('mqtt-host'), $('mqtt-bridge-body').textContent.slice(0, 80));
|
||||||
|
ok('host prefilled from the API', $('mqtt-host').value === bridge.data.config.mqtt_host,
|
||||||
|
{ got: $('mqtt-host') && $('mqtt-host').value, want: bridge.data.config.mqtt_host });
|
||||||
|
ok('port prefilled', $('mqtt-port').value === String(bridge.data.config.mqtt_port));
|
||||||
|
ok('log level selected', $('mqtt-log-level').value === bridge.data.config.log_level);
|
||||||
|
ok('TLS checkbox matches', $('mqtt-tls').checked === !!bridge.data.config.mqtt_tls);
|
||||||
|
ok('password field is EMPTY', $('mqtt-password').value === '');
|
||||||
|
ok('password field is type=password', $('mqtt-password').type === 'password');
|
||||||
|
ok('no password value anywhere in the DOM',
|
||||||
|
!/s3cret|mqtt_password"\s*:\s*"/.test(window.document.body.innerHTML));
|
||||||
|
ok('state badge rendered', ($('mqtt-bridge-state').textContent || '').trim().length > 0,
|
||||||
|
$('mqtt-bridge-state').textContent);
|
||||||
|
ok('not-installed shows an Install button', !!$('btn-mqtt-install'));
|
||||||
|
ok('config path shown', $('mqtt-bridge-body').textContent.includes('bridge_config.json'));
|
||||||
|
ok('env override hint shown', $('mqtt-bridge-body').textContent.includes('LEDMATRIX_MQTT_'));
|
||||||
|
|
||||||
|
// The save body must omit the password when the field is blank.
|
||||||
|
let sent = null;
|
||||||
|
onPut = body => { sent = body; };
|
||||||
|
window.saveMqttBridge();
|
||||||
|
await tick(150);
|
||||||
|
ok('save omits password when left blank', sent && !('mqtt_password' in sent), sent && Object.keys(sent));
|
||||||
|
ok('save sends the edited fields', sent && sent.mqtt_host === bridge.data.config.mqtt_host, sent);
|
||||||
|
|
||||||
|
$('mqtt-password').value = 'typed-secret';
|
||||||
|
window.saveMqttBridge();
|
||||||
|
await tick(150);
|
||||||
|
ok('save includes password once typed', sent && sent.mqtt_password === 'typed-secret');
|
||||||
|
onPut = null;
|
||||||
|
|
||||||
|
// ── Pixlet editor, idle ────────────────────────────────────────────────
|
||||||
|
const appIds = (apps.data.apps || []).map(a => a.id);
|
||||||
|
ok('editor lists the apps on disk',
|
||||||
|
appIds.every(id => $('pixlet-editor-body').textContent.includes(id)), appIds);
|
||||||
|
ok('no session banner while idle', !$('pixlet-countdown'));
|
||||||
|
ok('warns that the display stops',
|
||||||
|
window.document.body.textContent.includes('display stops while a session is open'));
|
||||||
|
|
||||||
|
// ── Pixlet editor, running ─────────────────────────────────────────────
|
||||||
|
editorStatus = { status: 'success', data: {
|
||||||
|
running: true, app_id: 'test-editor-app', port: 8099, seconds_remaining: 1634,
|
||||||
|
timeout: 1800, host_bound: '0.0.0.0' } };
|
||||||
|
window.loadPixletEditor();
|
||||||
|
await tick(200);
|
||||||
|
|
||||||
|
ok('running session shows the banner', !!$('pixlet-countdown'));
|
||||||
|
ok('countdown formatted mm:ss', $('pixlet-countdown').textContent === '27:14',
|
||||||
|
$('pixlet-countdown') && $('pixlet-countdown').textContent);
|
||||||
|
ok('Stop button offered', !!$('btn-pixlet-stop'));
|
||||||
|
const link = [...window.document.querySelectorAll('#pixlet-editor-body a')].find(a => /Open the editor/.test(a.textContent));
|
||||||
|
ok('editor link present', !!link);
|
||||||
|
ok('link uses this host, not localhost — no tunnel needed',
|
||||||
|
!!link && link.href.includes(window.location.hostname) && link.href.includes(':8099'),
|
||||||
|
link && link.href);
|
||||||
|
ok('Edit buttons disabled while a session runs',
|
||||||
|
[...window.document.querySelectorAll('[id^="btn-pixlet-edit-"]')].every(b => b.disabled));
|
||||||
|
ok('banner names the app being edited',
|
||||||
|
$('pixlet-editor-body').textContent.includes('test-editor-app'));
|
||||||
|
|
||||||
|
ok('no uncaught JS errors', errs.length === 0, errs.slice(0, 3));
|
||||||
|
console.log(`\n${pass} passed, ${fail} failed\n`);
|
||||||
|
process.exit(fail ? 1 : 0);
|
||||||
|
})().catch(e => { console.log('HARNESS ERROR: ' + e.stack.split('\n').slice(0, 5).join('\n')); process.exit(1); });
|
||||||
+2
-1
@@ -15,7 +15,8 @@ const fs = require('fs');
|
|||||||
|
|
||||||
const BASE = process.env.BASE || 'http://localhost:5000';
|
const BASE = process.env.BASE || 'http://localhost:5000';
|
||||||
const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js'];
|
const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js'];
|
||||||
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js'];
|
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
|
||||||
|
'dom/test_tools_sections.js'];
|
||||||
|
|
||||||
function reachable(url) {
|
function reachable(url) {
|
||||||
return new Promise(res => {
|
return new Promise(res => {
|
||||||
|
|||||||
@@ -106,7 +106,12 @@ class TestMissingBodyGivesTheDeclaredError:
|
|||||||
|
|
||||||
|
|
||||||
class TestNoBodyReadContradictsItsOwnGuard:
|
class TestNoBodyReadContradictsItsOwnGuard:
|
||||||
SOURCE = Path(__file__).parent.parent / "web_interface/blueprints/api_v3.py"
|
PKG = Path(__file__).parent.parent / "web_interface/blueprints/api_v3"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _source(self) -> str:
|
||||||
|
"""api_v3 is a package; read every module of it."""
|
||||||
|
return "\n".join(p.read_text() for p in sorted(self.PKG.glob("*.py")))
|
||||||
|
|
||||||
def test_no_or_default_read_is_unguarded(self):
|
def test_no_or_default_read_is_unguarded(self):
|
||||||
"""`get_json() or <default>` is a contradiction without silent=True.
|
"""`get_json() or <default>` is a contradiction without silent=True.
|
||||||
@@ -115,7 +120,7 @@ class TestNoBodyReadContradictsItsOwnGuard:
|
|||||||
means the call raises before the default can apply.
|
means the call raises before the default can apply.
|
||||||
"""
|
"""
|
||||||
offenders = [
|
offenders = [
|
||||||
line.strip() for line in self.SOURCE.read_text().splitlines()
|
line.strip() for line in self._source.splitlines()
|
||||||
if "request.get_json()" in line and " or " in line
|
if "request.get_json()" in line and " or " in line
|
||||||
]
|
]
|
||||||
assert offenders == [], (
|
assert offenders == [], (
|
||||||
@@ -124,7 +129,7 @@ class TestNoBodyReadContradictsItsOwnGuard:
|
|||||||
|
|
||||||
def test_no_not_data_guard_is_unreachable(self):
|
def test_no_not_data_guard_is_unreachable(self):
|
||||||
"""A `if not data:` guard needs a read that can actually return None."""
|
"""A `if not data:` guard needs a read that can actually return None."""
|
||||||
lines = self.SOURCE.read_text().splitlines()
|
lines = self._source.splitlines()
|
||||||
offenders = []
|
offenders = []
|
||||||
for i, line in enumerate(lines):
|
for i, line in enumerate(lines):
|
||||||
if re.search(r"=\s*request\.get_json\(\)\s*$", line):
|
if re.search(r"=\s*request\.get_json\(\)\s*$", line):
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"""The /api/v3 URL map is a contract, and the split must not have moved it.
|
||||||
|
|
||||||
|
api_v3.py was one 10,469-line module and is now a package. Every route module
|
||||||
|
in it decorates the *same* Blueprint object, so this refactor was supposed to
|
||||||
|
be invisible from outside: same URLs, same endpoint names, same methods.
|
||||||
|
|
||||||
|
"Supposed to be" is the problem. A route function silently dropped during a
|
||||||
|
move -- a module that never gets imported, a decorator left behind -- costs
|
||||||
|
nothing at import time and fails only when someone hits the URL. So the map is
|
||||||
|
pinned here.
|
||||||
|
|
||||||
|
The snapshot is intentionally the *whole* map rather than a count. A count
|
||||||
|
passes when one route is deleted and another added, which is exactly the shape
|
||||||
|
a careless move produces.
|
||||||
|
|
||||||
|
If you are adding a route, this test is meant to fail: add the entry to
|
||||||
|
EXPECTED. If you are moving one between modules, it is meant to pass unchanged
|
||||||
|
-- endpoint names are `api_v3.<function>` regardless of which module the
|
||||||
|
function lives in, and that is the property that makes the package safe.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
from web_interface.blueprints.api_v3 import api_v3
|
||||||
|
|
||||||
|
SNAPSHOT = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||||
|
"fixtures", "api_v3_url_map.json")
|
||||||
|
|
||||||
|
|
||||||
|
def _current_map():
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.register_blueprint(api_v3, url_prefix="/api/v3")
|
||||||
|
return sorted(
|
||||||
|
[r.rule, r.endpoint, sorted(r.methods)]
|
||||||
|
for r in app.url_map.iter_rules()
|
||||||
|
if r.endpoint != "static"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_url_map_matches_the_snapshot():
|
||||||
|
current = _current_map()
|
||||||
|
with open(SNAPSHOT, encoding="utf-8") as fh:
|
||||||
|
expected = json.load(fh)
|
||||||
|
|
||||||
|
cur = {(r, e) for r, e, _ in current}
|
||||||
|
exp = {(r, e) for r, e, _ in expected}
|
||||||
|
|
||||||
|
lost = sorted(exp - cur)
|
||||||
|
added = sorted(cur - exp)
|
||||||
|
assert not lost, (
|
||||||
|
f"{len(lost)} route(s) disappeared from /api/v3: {lost[:5]}. "
|
||||||
|
"A route lost in a module move costs nothing at import time and fails "
|
||||||
|
"only when someone hits the URL.")
|
||||||
|
assert not added, (
|
||||||
|
f"{len(added)} new route(s): {added[:5]}. If that is intended, "
|
||||||
|
f"regenerate {os.path.relpath(SNAPSHOT)}.")
|
||||||
|
|
||||||
|
# Methods too: a route that quietly loses POST is still a broken route.
|
||||||
|
cur_methods = {(r, e): m for r, e, m in current}
|
||||||
|
for rule, endpoint, methods in expected:
|
||||||
|
assert cur_methods[(rule, endpoint)] == methods, (
|
||||||
|
f"{rule} ({endpoint}) methods changed: "
|
||||||
|
f"{methods} -> {cur_methods[(rule, endpoint)]}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_endpoint_is_on_the_one_blueprint():
|
||||||
|
"""The package must not fragment into several blueprints.
|
||||||
|
|
||||||
|
Splitting into per-domain *blueprints* would rename every endpoint from
|
||||||
|
`api_v3.foo` to `api_v3_plugins.foo` and break any url_for() that names
|
||||||
|
one. Keeping a single Blueprint object across the modules is what makes
|
||||||
|
the split a pure code move, so assert it rather than trusting it.
|
||||||
|
"""
|
||||||
|
for rule, endpoint, _ in _current_map():
|
||||||
|
assert endpoint.startswith("api_v3."), (
|
||||||
|
f"{rule} is registered as {endpoint}, not on the api_v3 blueprint")
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_snapshot_is_not_empty():
|
||||||
|
"""Guards the failure mode this file exists to prevent.
|
||||||
|
|
||||||
|
An empty or truncated snapshot would make every assertion above pass
|
||||||
|
vacuously -- the same trap as a route map that imports no modules.
|
||||||
|
"""
|
||||||
|
with open(SNAPSHOT, encoding="utf-8") as fh:
|
||||||
|
expected = json.load(fh)
|
||||||
|
assert len(expected) > 100, (
|
||||||
|
f"snapshot has only {len(expected)} routes; it should have the whole "
|
||||||
|
"/api/v3 surface")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("module", [
|
||||||
|
"backup", "config", "display", "fonts", "misc",
|
||||||
|
"plugins", "starlark", "system", "wifi",
|
||||||
|
])
|
||||||
|
def test_every_route_module_contributes(module):
|
||||||
|
"""Each module must actually register something.
|
||||||
|
|
||||||
|
A module that fails to import, or that is left out of __init__, takes its
|
||||||
|
routes with it silently -- the package still imports and the app still
|
||||||
|
starts.
|
||||||
|
"""
|
||||||
|
import importlib
|
||||||
|
mod = importlib.import_module(f"web_interface.blueprints.api_v3.{module}")
|
||||||
|
routes = [n for n in dir(mod)
|
||||||
|
if callable(getattr(mod, n, None))
|
||||||
|
and getattr(getattr(mod, n), "__module__", "") == mod.__name__]
|
||||||
|
assert routes, f"{module}.py defines no view functions"
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_root_points_at_the_project():
|
||||||
|
"""PROJECT_ROOT is derived from __file__, so moving the file breaks it.
|
||||||
|
|
||||||
|
The split moved this code from web_interface/blueprints/api_v3.py to
|
||||||
|
web_interface/blueprints/api_v3/_common.py -- one directory deeper -- and
|
||||||
|
`Path(__file__).parent.parent.parent` quietly began resolving to
|
||||||
|
web_interface/ instead of the project root. Nothing failed at import. It
|
||||||
|
surfaced as routes returning 404 and "installation script not found",
|
||||||
|
because every path built from it pointed one level too shallow.
|
||||||
|
|
||||||
|
A URL-map check cannot catch that: the routes were all registered, they
|
||||||
|
just could not find anything.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from web_interface.blueprints.api_v3 import PROJECT_ROOT
|
||||||
|
|
||||||
|
# The project root is the directory holding run.py and web_interface/.
|
||||||
|
assert (PROJECT_ROOT / "run.py").is_file(), (
|
||||||
|
f"PROJECT_ROOT is {PROJECT_ROOT}, which has no run.py; it is not the "
|
||||||
|
"project root")
|
||||||
|
assert (PROJECT_ROOT / "web_interface").is_dir()
|
||||||
|
assert PROJECT_ROOT == Path(__file__).resolve().parents[1]
|
||||||
@@ -130,7 +130,8 @@ def test_the_endpoint_itself_redacts():
|
|||||||
app = flask.Flask(__name__)
|
app = flask.Flask(__name__)
|
||||||
try:
|
try:
|
||||||
with app.test_request_context("/config/main"):
|
with app.test_request_context("/config/main"):
|
||||||
response = mod.get_main_config()
|
# get_main_config is a route; it lives in the config module now.
|
||||||
|
response = mod.config.get_main_config()
|
||||||
payload = response.get_json() if hasattr(response, "get_json") else _json.loads(response[0].data)
|
payload = response.get_json() if hasattr(response, "get_json") else _json.loads(response[0].data)
|
||||||
finally:
|
finally:
|
||||||
mod.api_v3.config_manager = previous
|
mod.api_v3.config_manager = previous
|
||||||
|
|||||||
@@ -470,7 +470,10 @@ class TestApiBoundsMatchValidate:
|
|||||||
"""Extract the numeric_fields map from api_v3 without importing Flask."""
|
"""Extract the numeric_fields map from api_v3 without importing Flask."""
|
||||||
import ast
|
import ast
|
||||||
import pathlib
|
import pathlib
|
||||||
src = pathlib.Path('web_interface/blueprints/api_v3.py').read_text()
|
# api_v3 is a package; the routes are spread across its modules.
|
||||||
|
src = '\n'.join(
|
||||||
|
p.read_text() for p in
|
||||||
|
sorted(pathlib.Path('web_interface/blueprints/api_v3').glob('*.py')))
|
||||||
tree = ast.parse(src)
|
tree = ast.parse(src)
|
||||||
for node in ast.walk(tree):
|
for node in ast.walk(tree):
|
||||||
if not isinstance(node, ast.Assign):
|
if not isinstance(node, ast.Assign):
|
||||||
|
|||||||
@@ -390,7 +390,7 @@ class TestConfigAPI:
|
|||||||
class TestSystemAPI:
|
class TestSystemAPI:
|
||||||
"""Test system API endpoints."""
|
"""Test system API endpoints."""
|
||||||
|
|
||||||
@patch('web_interface.blueprints.api_v3.subprocess')
|
@patch('web_interface.blueprints.api_v3.system.subprocess')
|
||||||
def test_get_system_status(self, mock_subprocess, client):
|
def test_get_system_status(self, mock_subprocess, client):
|
||||||
"""Test getting system status."""
|
"""Test getting system status."""
|
||||||
# The endpoint returns 503 without psutil, which is an optional
|
# The endpoint returns 503 without psutil, which is an optional
|
||||||
@@ -407,7 +407,7 @@ class TestSystemAPI:
|
|||||||
data = json.loads(response.data)
|
data = json.loads(response.data)
|
||||||
assert 'service' in data or 'status' in data or 'active' in data
|
assert 'service' in data or 'status' in data or 'active' in data
|
||||||
|
|
||||||
@patch('web_interface.blueprints.api_v3.subprocess')
|
@patch('web_interface.blueprints.api_v3.system.subprocess')
|
||||||
def test_get_system_version(self, mock_subprocess, client):
|
def test_get_system_version(self, mock_subprocess, client):
|
||||||
"""Test getting system version."""
|
"""Test getting system version."""
|
||||||
mock_result = MagicMock()
|
mock_result = MagicMock()
|
||||||
@@ -421,7 +421,7 @@ class TestSystemAPI:
|
|||||||
data = json.loads(response.data)
|
data = json.loads(response.data)
|
||||||
assert 'version' in data.get('data', {}) or 'version' in data
|
assert 'version' in data.get('data', {}) or 'version' in data
|
||||||
|
|
||||||
@patch('web_interface.blueprints.api_v3.subprocess')
|
@patch('web_interface.blueprints.api_v3.system.subprocess')
|
||||||
def test_execute_system_action(self, mock_subprocess, client):
|
def test_execute_system_action(self, mock_subprocess, client):
|
||||||
"""Test executing system action."""
|
"""Test executing system action."""
|
||||||
mock_result = MagicMock()
|
mock_result = MagicMock()
|
||||||
@@ -502,7 +502,7 @@ class TestDisplayAPI:
|
|||||||
if response.status_code in [200, 201]:
|
if response.status_code in [200, 201]:
|
||||||
assert api_v3.cache_manager.set.called
|
assert api_v3.cache_manager.set.called
|
||||||
|
|
||||||
@patch('web_interface.blueprints.api_v3._ensure_cache_manager')
|
@patch('web_interface.blueprints.api_v3.display._ensure_cache_manager')
|
||||||
def test_stop_on_demand_display(self, mock_ensure_cache, client):
|
def test_stop_on_demand_display(self, mock_ensure_cache, client):
|
||||||
"""Test stopping on-demand display."""
|
"""Test stopping on-demand display."""
|
||||||
|
|
||||||
|
|||||||
@@ -114,7 +114,11 @@ class TestHandlersCarryDetail:
|
|||||||
"""
|
"""
|
||||||
import ast
|
import ast
|
||||||
|
|
||||||
src = open("web_interface/blueprints/api_v3.py").read()
|
# api_v3 is a package; the routes are spread across its modules.
|
||||||
|
import pathlib
|
||||||
|
src = "\n".join(
|
||||||
|
p.read_text() for p in
|
||||||
|
sorted(pathlib.Path("web_interface/blueprints/api_v3").glob("*.py")))
|
||||||
tree = ast.parse(src)
|
tree = ast.parse(src)
|
||||||
|
|
||||||
# This used to match one exact message string, so a handler that wrote
|
# This used to match one exact message string, so a handler that wrote
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ class TestPluginReinstall:
|
|||||||
def test_missing_store_manager_is_reported_per_plugin(self, client, restore):
|
def test_missing_store_manager_is_reported_per_plugin(self, client, restore):
|
||||||
restore.return_value = FakeResult(plugins_to_install=[{"plugin_id": "clock"}])
|
restore.return_value = FakeResult(plugins_to_install=[{"plugin_id": "clock"}])
|
||||||
api_v3.plugin_store_manager = None
|
api_v3.plugin_store_manager = None
|
||||||
with patch("web_interface.blueprints.api_v3.plugin_store_manager", None):
|
with patch("web_interface.blueprints.api_v3.backup.plugin_store_manager", None):
|
||||||
body = post(client).get_json()
|
body = post(client).get_json()
|
||||||
assert body["data"]["plugins_failed"][0]["error"] == "Store manager unavailable"
|
assert body["data"]["plugins_failed"][0]["error"] == "Store manager unavailable"
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,10 @@ class TestTheRoutesExistAtAll:
|
|||||||
oauth = Path(project_root) / 'web_interface/static/v3/js/widgets/google-oauth.js'
|
oauth = Path(project_root) / 'web_interface/static/v3/js/widgets/google-oauth.js'
|
||||||
assert '/api/v3/plugins/calendar/list-calendars' in picker.read_text(encoding='utf-8')
|
assert '/api/v3/plugins/calendar/list-calendars' in picker.read_text(encoding='utf-8')
|
||||||
assert '/api/v3/plugins/calendar/authenticate' in oauth.read_text(encoding='utf-8')
|
assert '/api/v3/plugins/calendar/authenticate' in oauth.read_text(encoding='utf-8')
|
||||||
source = (Path(project_root) / 'web_interface/blueprints/api_v3.py').read_text(encoding='utf-8')
|
# api_v3 is a package now, so the route strings are spread across its
|
||||||
|
# modules; read the whole directory rather than one file.
|
||||||
|
pkg = Path(project_root) / 'web_interface/blueprints/api_v3'
|
||||||
|
source = "\n".join(f.read_text(encoding='utf-8') for f in sorted(pkg.glob('*.py')))
|
||||||
assert "'/plugins/calendar/list-calendars'" in source
|
assert "'/plugins/calendar/list-calendars'" in source
|
||||||
assert "'/plugins/calendar/authenticate'" in source
|
assert "'/plugins/calendar/authenticate'" in source
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
SOURCE = (Path(__file__).resolve().parents[2]
|
API_V3_PKG = (Path(__file__).resolve().parents[2]
|
||||||
/ "web_interface" / "blueprints" / "api_v3.py")
|
/ "web_interface" / "blueprints" / "api_v3")
|
||||||
|
|
||||||
#: Objects that still hold submitted secret values at the point these log
|
#: Objects that still hold submitted secret values at the point these log
|
||||||
#: calls run. Interpolating one whole into a log message leaks credentials.
|
#: calls run. Interpolating one whole into a log message leaks credentials.
|
||||||
@@ -19,7 +19,7 @@ UNREDACTED = ("plugin_config", "secrets_config", "current_secrets")
|
|||||||
|
|
||||||
|
|
||||||
def _logging_lines():
|
def _logging_lines():
|
||||||
for number, line in enumerate(SOURCE.read_text(encoding="utf-8").splitlines(), 1):
|
for number, line in enumerate("\n".join(p.read_text(encoding="utf-8") for p in sorted(API_V3_PKG.glob("*.py"))).splitlines(), 1):
|
||||||
stripped = line.strip()
|
stripped = line.strip()
|
||||||
if stripped.startswith("#"):
|
if stripped.startswith("#"):
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -15,8 +15,18 @@ from pathlib import Path
|
|||||||
|
|
||||||
from src.web_interface.secret_helpers import find_secret_fields, separate_secrets
|
from src.web_interface.secret_helpers import find_secret_fields, separate_secrets
|
||||||
|
|
||||||
API_V3_PATH = (Path(__file__).resolve().parents[2]
|
API_V3_PKG = (Path(__file__).resolve().parents[2]
|
||||||
/ "web_interface" / "blueprints" / "api_v3.py")
|
/ "web_interface" / "blueprints" / "api_v3")
|
||||||
|
|
||||||
|
|
||||||
|
def _api_v3_source() -> str:
|
||||||
|
"""Every module of the api_v3 package as one string.
|
||||||
|
|
||||||
|
It used to be a single file; the inline copies this guards against could
|
||||||
|
now reappear in any module of the package.
|
||||||
|
"""
|
||||||
|
return "\n".join(p.read_text(encoding="utf-8")
|
||||||
|
for p in sorted(API_V3_PKG.glob("*.py")))
|
||||||
|
|
||||||
# The migration is complete: any inline reimplementation is a regression.
|
# The migration is complete: any inline reimplementation is a regression.
|
||||||
EXPECTED_INLINE_COPIES = 0
|
EXPECTED_INLINE_COPIES = 0
|
||||||
@@ -24,7 +34,7 @@ EXPECTED_INLINE_COPIES = 0
|
|||||||
|
|
||||||
class TestNoInlineCopies:
|
class TestNoInlineCopies:
|
||||||
def _count(self, name: str) -> int:
|
def _count(self, name: str) -> int:
|
||||||
source = API_V3_PATH.read_text(encoding="utf-8")
|
source = _api_v3_source()
|
||||||
return len(re.findall(rf"^\s*def {name}\(", source, flags=re.MULTILINE))
|
return len(re.findall(rf"^\s*def {name}\(", source, flags=re.MULTILINE))
|
||||||
|
|
||||||
def test_no_inline_find_secret_fields(self):
|
def test_no_inline_find_secret_fields(self):
|
||||||
@@ -46,7 +56,7 @@ class TestNoInlineCopies:
|
|||||||
def test_canonical_import_present(self):
|
def test_canonical_import_present(self):
|
||||||
# Tripwire: the endpoints still need the helpers, so removing the
|
# Tripwire: the endpoints still need the helpers, so removing the
|
||||||
# import means either dead secret handling or a new local copy.
|
# import means either dead secret handling or a new local copy.
|
||||||
source = API_V3_PATH.read_text(encoding="utf-8")
|
source = _api_v3_source()
|
||||||
assert re.search(
|
assert re.search(
|
||||||
r"from src\.web_interface\.secret_helpers import .*find_secret_fields",
|
r"from src\.web_interface\.secret_helpers import .*find_secret_fields",
|
||||||
source,
|
source,
|
||||||
|
|||||||
@@ -63,14 +63,14 @@ class TestRoutesAreRegistered:
|
|||||||
|
|
||||||
class TestInstallPixlet:
|
class TestInstallPixlet:
|
||||||
def test_it_does_not_404(self, client):
|
def test_it_does_not_404(self, client):
|
||||||
with patch('web_interface.blueprints.api_v3.subprocess.run') as run:
|
with patch('web_interface.blueprints.api_v3.starlark.subprocess.run') as run:
|
||||||
run.return_value = MagicMock(returncode=0, stdout="ok", stderr="")
|
run.return_value = MagicMock(returncode=0, stdout="ok", stderr="")
|
||||||
resp = client.post('/api/v3/starlark/install-pixlet')
|
resp = client.post('/api/v3/starlark/install-pixlet')
|
||||||
assert resp.status_code != 404, "the route is still missing"
|
assert resp.status_code != 404, "the route is still missing"
|
||||||
assert resp.get_json().get('message') != 'Resource not found'
|
assert resp.get_json().get('message') != 'Resource not found'
|
||||||
|
|
||||||
def test_success_is_reported_in_the_shape_the_button_reads(self, client):
|
def test_success_is_reported_in_the_shape_the_button_reads(self, client):
|
||||||
with patch('web_interface.blueprints.api_v3.subprocess.run') as run:
|
with patch('web_interface.blueprints.api_v3.starlark.subprocess.run') as run:
|
||||||
run.return_value = MagicMock(returncode=0, stdout="done", stderr="")
|
run.return_value = MagicMock(returncode=0, stdout="done", stderr="")
|
||||||
resp = client.post('/api/v3/starlark/install-pixlet')
|
resp = client.post('/api/v3/starlark/install-pixlet')
|
||||||
body = resp.get_json()
|
body = resp.get_json()
|
||||||
@@ -78,7 +78,7 @@ class TestInstallPixlet:
|
|||||||
assert 'message' in body, "the JS shows data.message on success"
|
assert 'message' in body, "the JS shows data.message on success"
|
||||||
|
|
||||||
def test_a_failed_download_says_why(self, client):
|
def test_a_failed_download_says_why(self, client):
|
||||||
with patch('web_interface.blueprints.api_v3.subprocess.run') as run:
|
with patch('web_interface.blueprints.api_v3.starlark.subprocess.run') as run:
|
||||||
run.return_value = MagicMock(returncode=1, stdout="", stderr="no such release")
|
run.return_value = MagicMock(returncode=1, stdout="", stderr="no such release")
|
||||||
resp = client.post('/api/v3/starlark/install-pixlet')
|
resp = client.post('/api/v3/starlark/install-pixlet')
|
||||||
body = resp.get_json()
|
body = resp.get_json()
|
||||||
@@ -88,7 +88,7 @@ class TestInstallPixlet:
|
|||||||
|
|
||||||
def test_a_timeout_is_reported_rather_than_hanging(self, client):
|
def test_a_timeout_is_reported_rather_than_hanging(self, client):
|
||||||
import subprocess as sp
|
import subprocess as sp
|
||||||
with patch('web_interface.blueprints.api_v3.subprocess.run',
|
with patch('web_interface.blueprints.api_v3.starlark.subprocess.run',
|
||||||
side_effect=sp.TimeoutExpired(cmd='x', timeout=300)):
|
side_effect=sp.TimeoutExpired(cmd='x', timeout=300)):
|
||||||
resp = client.post('/api/v3/starlark/install-pixlet')
|
resp = client.post('/api/v3/starlark/install-pixlet')
|
||||||
assert resp.get_json()['status'] == 'error'
|
assert resp.get_json()['status'] == 'error'
|
||||||
|
|||||||
@@ -19,7 +19,12 @@ import re
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
API_V3 = PROJECT_ROOT / "web_interface" / "blueprints" / "api_v3.py"
|
# api_v3 is a package; a sudo systemctl call can live in any of its modules.
|
||||||
|
API_V3_PKG = PROJECT_ROOT / "web_interface" / "blueprints" / "api_v3"
|
||||||
|
|
||||||
|
|
||||||
|
def _api_v3_source() -> str:
|
||||||
|
return "\n".join(p.read_text() for p in sorted(API_V3_PKG.glob("*.py")))
|
||||||
SUDOERS_SCRIPT = PROJECT_ROOT / "scripts" / "install" / "configure_web_sudo.sh"
|
SUDOERS_SCRIPT = PROJECT_ROOT / "scripts" / "install" / "configure_web_sudo.sh"
|
||||||
|
|
||||||
|
|
||||||
@@ -51,7 +56,7 @@ def _granted_systemctl_rules(script: str) -> set[tuple[str, str]]:
|
|||||||
|
|
||||||
|
|
||||||
def test_every_sudo_systemctl_call_is_granted() -> None:
|
def test_every_sudo_systemctl_call_is_granted() -> None:
|
||||||
calls = _sudo_systemctl_calls(API_V3.read_text())
|
calls = _sudo_systemctl_calls(_api_v3_source())
|
||||||
rules = _granted_systemctl_rules(SUDOERS_SCRIPT.read_text())
|
rules = _granted_systemctl_rules(SUDOERS_SCRIPT.read_text())
|
||||||
|
|
||||||
assert calls, "expected to find sudo systemctl calls in api_v3.py"
|
assert calls, "expected to find sudo systemctl calls in api_v3.py"
|
||||||
@@ -68,7 +73,7 @@ def test_every_sudo_systemctl_call_is_granted() -> None:
|
|||||||
def test_units_are_fully_qualified() -> None:
|
def test_units_are_fully_qualified() -> None:
|
||||||
"""Privileged systemctl calls must name the unit as <name>.service so they
|
"""Privileged systemctl calls must name the unit as <name>.service so they
|
||||||
match the sudoers grants, which use the fully-qualified unit name."""
|
match the sudoers grants, which use the fully-qualified unit name."""
|
||||||
calls = _sudo_systemctl_calls(API_V3.read_text())
|
calls = _sudo_systemctl_calls(_api_v3_source())
|
||||||
unqualified = {(v, u) for v, u in calls if not u.endswith(".service")}
|
unqualified = {(v, u) for v, u in calls if not u.endswith(".service")}
|
||||||
assert not unqualified, (
|
assert not unqualified, (
|
||||||
"sudo systemctl calls must use fully-qualified .service unit names: "
|
"sudo systemctl calls must use fully-qualified .service unit names: "
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,211 @@
|
|||||||
|
"""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, _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
|
||||||
|
@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
|
||||||
|
options = RestoreOptions(
|
||||||
|
restore_config=bool(opts_dict.get('restore_config', True)),
|
||||||
|
restore_secrets=bool(opts_dict.get('restore_secrets', True)),
|
||||||
|
restore_wifi=bool(opts_dict.get('restore_wifi', True)),
|
||||||
|
restore_fonts=bool(opts_dict.get('restore_fonts', True)),
|
||||||
|
restore_plugin_uploads=bool(opts_dict.get('restore_plugin_uploads', True)),
|
||||||
|
reinstall_plugins=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 _pkg.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,452 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
catalog[catalog_key] = {
|
||||||
|
'filename': filename,
|
||||||
|
'family_name': family_name,
|
||||||
|
'display_name': display_name,
|
||||||
|
'path': relative_path,
|
||||||
|
'type': font_type,
|
||||||
|
'is_system': is_system,
|
||||||
|
'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/overrides', methods=['GET'])
|
||||||
|
def get_fonts_overrides():
|
||||||
|
"""Get font overrides"""
|
||||||
|
try:
|
||||||
|
# This would integrate with the actual font system
|
||||||
|
# For now, return empty overrides
|
||||||
|
overrides = {}
|
||||||
|
return jsonify({'status': 'success', 'data': {'overrides': overrides}})
|
||||||
|
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/overrides', methods=['POST'])
|
||||||
|
def save_fonts_overrides():
|
||||||
|
"""Save font overrides"""
|
||||||
|
try:
|
||||||
|
data = request.get_json(silent=True)
|
||||||
|
if not data:
|
||||||
|
return jsonify({'status': 'error', 'message': 'No data provided'}), 400
|
||||||
|
|
||||||
|
# This would integrate with the actual font system
|
||||||
|
return jsonify({'status': 'success', 'message': 'Font overrides saved'})
|
||||||
|
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/overrides/<element_key>', methods=['DELETE'])
|
||||||
|
def delete_font_override(element_key):
|
||||||
|
"""Delete font override"""
|
||||||
|
try:
|
||||||
|
# This would integrate with the actual font system
|
||||||
|
return jsonify({'status': 'success', 'message': f'Font override for {element_key} deleted'})
|
||||||
|
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
|
||||||
|
is_valid, error_msg = validate_file_upload(
|
||||||
|
font_file.filename,
|
||||||
|
max_size_mb=10,
|
||||||
|
allowed_extensions=['.ttf', '.otf', '.bdf']
|
||||||
|
)
|
||||||
|
if not is_valid:
|
||||||
|
return jsonify({'status': 'error', 'message': error_msg}), 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,496 @@
|
|||||||
|
"""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 (
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
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']
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
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)
|
||||||
|
|
||||||
|
if 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
|
||||||
|
|
||||||
|
if config.get('mqtt_password') and not config.get('mqtt_tls'):
|
||||||
|
logger.warning('MQTT bridge: a password is set without TLS; '
|
||||||
|
'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,872 @@
|
|||||||
|
"""Starlark / Tronbyte app management 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 (
|
||||||
|
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,
|
||||||
|
_standalone_render_starlark_app, _starlark_github_token,
|
||||||
|
_validate_and_sanitize_app_id, _validate_starlark_app_path,
|
||||||
|
_validate_timing_value, api_v3, contextlib, describe_exception, json,
|
||||||
|
jsonify, logger, os, request, shutil, signal, 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)
|
||||||
|
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
|
||||||
|
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"
|
||||||
|
current_config = {}
|
||||||
|
if config_file.exists():
|
||||||
|
try:
|
||||||
|
with open(config_file, 'r') as f:
|
||||||
|
current_config = json.load(f)
|
||||||
|
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})
|
||||||
|
else:
|
||||||
|
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
|
||||||
|
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
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
|
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 has to listen on more than loopback to be usable at all.
|
||||||
|
env['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 = time.time()
|
||||||
|
state = {'pid': process.pid, 'app_id': app_dir.name, 'port': port,
|
||||||
|
'timeout': timeout_s, 'started_at': now, 'deadline': now + timeout_s,
|
||||||
|
'host': '0.0.0.0', 'log': str(log_path)}
|
||||||
|
try:
|
||||||
|
with open(_PIXLET_EDITOR_STATE, 'w', encoding='utf-8') as handle:
|
||||||
|
json.dump(state, handle)
|
||||||
|
except OSError as err:
|
||||||
|
logger.warning('Started an editor session but could not record it: %s', err)
|
||||||
|
|
||||||
|
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 = time.time() + 10
|
||||||
|
while time.time() < deadline and _pixlet_editor_alive(pid):
|
||||||
|
time.sleep(0.25)
|
||||||
|
|
||||||
|
if _pixlet_editor_alive(pid):
|
||||||
|
logger.warning('Editor session %s ignored SIGTERM; sending SIGKILL. The display '
|
||||||
|
'may need a manual restart.', pid)
|
||||||
|
with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
|
||||||
|
os.killpg(os.getpgid(pid), signal.SIGKILL)
|
||||||
|
|
||||||
|
_clear_pixlet_editor_state()
|
||||||
|
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,619 @@
|
|||||||
|
"""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."))
|
||||||
|
@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',
|
||||||
|
'message': 'Display started' if result.returncode == 0 else 'Failed to start display',
|
||||||
|
}
|
||||||
|
if result.returncode != 0:
|
||||||
|
resp['returncode'] = result.returncode
|
||||||
|
resp['stderr'] = result.stderr.strip()
|
||||||
|
return jsonify(resp)
|
||||||
|
else:
|
||||||
|
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()
|
||||||
|
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)
|
||||||
|
return jsonify({'status': 'error', 'message': 'Action failed; see logs for details'}), 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,361 @@
|
|||||||
|
"""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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 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 = bool(data['auto_enable_ap_mode'])
|
||||||
|
|
||||||
|
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, so mirror the string-aware
|
||||||
|
# coercion used for `force` — the endpoint is a public contract, not just
|
||||||
|
# the shipped UI (which always sends real JSON booleans).
|
||||||
|
_enabled_raw = data['enabled']
|
||||||
|
enabled = _enabled_raw is True or (isinstance(_enabled_raw, str) and _enabled_raw.lower() in ('true', '1', 'yes'))
|
||||||
|
_force_raw = data.get('force', False)
|
||||||
|
force = _force_raw is True or (isinstance(_force_raw, str) and _force_raw.lower() in ('true', '1', 'yes'))
|
||||||
|
|
||||||
|
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
|
||||||
@@ -233,6 +233,46 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- MQTT Bridge -->
|
||||||
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
|
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">MQTT Bridge</h2>
|
||||||
|
<span id="mqtt-bridge-state" class="text-xs px-2 py-1 rounded-full bg-gray-100 text-gray-600">checking…</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-sm text-gray-600">
|
||||||
|
Exposes the display to Home Assistant over MQTT — force a mode on demand, set
|
||||||
|
brightness, toggle power. Runs as <code class="bg-gray-100 px-1 rounded">ledmatrix-mqtt-bridge.service</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="mqtt-bridge-body" class="space-y-4">
|
||||||
|
<p class="text-sm text-gray-500">Loading settings…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pixlet Config Editor -->
|
||||||
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
|
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">Pixlet Config Editor</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-600">
|
||||||
|
Opens a Starlark app in Pixlet's own config form, which runs the app for real —
|
||||||
|
so dropdowns that build their options at runtime work, and you see the render update
|
||||||
|
as you type.
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-sm text-amber-700 bg-amber-50 border border-amber-200 rounded-md p-2">
|
||||||
|
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||||
|
The display stops while a session is open, and the editor has no password — the
|
||||||
|
same footing as this page. Sessions end by themselves after 30 minutes, so the
|
||||||
|
panel is never left dark.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="pixlet-editor-body" class="space-y-4">
|
||||||
|
<p class="text-sm text-gray-500">Loading apps…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- System Power -->
|
<!-- System Power -->
|
||||||
<div class="bg-white rounded-lg shadow p-6">
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
<div class="border-b border-gray-200 pb-4 mb-6">
|
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||||
@@ -962,5 +1002,339 @@
|
|||||||
|
|
||||||
// WiFi radio current state.
|
// WiFi radio current state.
|
||||||
window.loadWifiRadio();
|
window.loadWifiRadio();
|
||||||
|
|
||||||
|
// ── MQTT bridge ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const MQTT_BRIDGE_URL = '/api/v3/integrations/mqtt-bridge';
|
||||||
|
|
||||||
|
function mqttBadge(service, configExists) {
|
||||||
|
if (!service.installed) return ['bg-gray-100 text-gray-600', 'not installed'];
|
||||||
|
if (service.active) return ['bg-green-100 text-green-700', 'running'];
|
||||||
|
if (!configExists) return ['bg-amber-100 text-amber-700', 'not configured'];
|
||||||
|
return ['bg-amber-100 text-amber-700', 'stopped'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMqttBridge(data) {
|
||||||
|
const badge = document.getElementById('mqtt-bridge-state');
|
||||||
|
const [cls, label] = mqttBadge(data.service, data.config_exists);
|
||||||
|
if (badge) {
|
||||||
|
badge.className = 'text-xs px-2 py-1 rounded-full ' + cls;
|
||||||
|
badge.textContent = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
const c = data.config || {};
|
||||||
|
const field = (id, label, value, type, extra) => `
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-xs font-medium text-gray-700">${escHtml(label)}</span>
|
||||||
|
<input id="${id}" type="${type || 'text'}" value="${value === null || value === undefined ? '' : escHtml(String(value))}"
|
||||||
|
${extra || ''}
|
||||||
|
class="mt-1 w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md">
|
||||||
|
</label>`;
|
||||||
|
|
||||||
|
document.getElementById('mqtt-bridge-body').innerHTML = `
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
${field('mqtt-host', 'Broker host', c.mqtt_host)}
|
||||||
|
${field('mqtt-port', 'Port', c.mqtt_port, 'number', 'min="1" max="65535"')}
|
||||||
|
${field('mqtt-username', 'Username (optional)', c.mqtt_username)}
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-xs font-medium text-gray-700">Password</span>
|
||||||
|
<input id="mqtt-password" type="password" value=""
|
||||||
|
placeholder="${data.password_set ? 'unchanged — leave blank to keep' : 'none set'}"
|
||||||
|
class="mt-1 w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md">
|
||||||
|
<span class="text-xs text-gray-500">
|
||||||
|
Never sent back to the browser.
|
||||||
|
${data.password_set ? '<button type="button" id="mqtt-clear-password" class="text-red-600 hover:underline ml-1">Clear it</button>' : ''}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
${field('mqtt-topic', 'Command topic', c.mqtt_topic)}
|
||||||
|
${field('mqtt-client-id', 'Client ID', c.mqtt_client_id)}
|
||||||
|
${field('mqtt-api-base', 'LEDMatrix API base', c.ledmatrix_api_base)}
|
||||||
|
${field('mqtt-timeout', 'Request timeout (s)', c.request_timeout, 'number', 'min="1" max="300"')}
|
||||||
|
${field('mqtt-duration', 'On-demand duration (s, blank = default)', c.on_demand_duration, 'number', 'min="1" max="86400"')}
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-xs font-medium text-gray-700">Log level</span>
|
||||||
|
<select id="mqtt-log-level" class="mt-1 w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md bg-white">
|
||||||
|
${['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'].map(l =>
|
||||||
|
`<option value="${l}" ${c.log_level === l ? 'selected' : ''}>${l}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2 text-sm text-gray-700">
|
||||||
|
<input id="mqtt-tls" type="checkbox" ${c.mqtt_tls ? 'checked' : ''}
|
||||||
|
class="rounded border-gray-300">
|
||||||
|
Use TLS
|
||||||
|
<span class="text-xs text-gray-500">(a password without TLS crosses the network in the clear)</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-4 pt-2">
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
Saved to <code class="bg-gray-100 px-1 rounded">${escHtml(data.config_path)}</code>.
|
||||||
|
Any field can also be set as
|
||||||
|
<code class="bg-gray-100 px-1 rounded">${escHtml(data.env_override_prefix)}<KEY></code>,
|
||||||
|
which wins over the file.
|
||||||
|
</p>
|
||||||
|
<button id="btn-mqtt-save" onclick="saveMqttBridge()"
|
||||||
|
class="shrink-0 inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700">
|
||||||
|
<i class="fas fa-save mr-2"></i>Save settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="result-mqtt-save" class="hidden"></div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2 pt-4 border-t border-gray-100">
|
||||||
|
${data.service.installed ? `
|
||||||
|
<button id="btn-mqtt-restart" onclick="toolsAction('mqtt_bridge_restart','btn-mqtt-restart','result-mqtt-service')"
|
||||||
|
class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||||
|
<i class="fas fa-sync-alt mr-2"></i>Restart
|
||||||
|
</button>
|
||||||
|
<button id="btn-mqtt-startstop"
|
||||||
|
onclick="toolsAction('${data.service.active ? 'mqtt_bridge_stop' : 'mqtt_bridge_start'}','btn-mqtt-startstop','result-mqtt-service')"
|
||||||
|
class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||||
|
<i class="fas fa-${data.service.active ? 'stop' : 'play'} mr-2"></i>${data.service.active ? 'Stop' : 'Start'}
|
||||||
|
</button>` : `
|
||||||
|
<button id="btn-mqtt-install" onclick="installMqttBridge()"
|
||||||
|
class="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700">
|
||||||
|
<i class="fas fa-download mr-2"></i>Install & start bridge
|
||||||
|
</button>
|
||||||
|
<span class="text-xs text-gray-500">Installs dependencies, enables the unit and starts it.</span>`}
|
||||||
|
</div>
|
||||||
|
<div id="result-mqtt-service" class="hidden"></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const clearBtn = document.getElementById('mqtt-clear-password');
|
||||||
|
if (clearBtn) clearBtn.addEventListener('click', () => clearMqttPassword());
|
||||||
|
}
|
||||||
|
|
||||||
|
window.loadMqttBridge = function() {
|
||||||
|
fetch(MQTT_BRIDGE_URL)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => {
|
||||||
|
if (d.status !== 'success') throw new Error(d.message || 'Failed');
|
||||||
|
renderMqttBridge(d.data);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
document.getElementById('mqtt-bridge-body').innerHTML =
|
||||||
|
`<p class="text-sm text-red-600">Could not load bridge settings: ${escHtml(err.message)}</p>`;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
function mqttBody() {
|
||||||
|
const val = id => (document.getElementById(id) || {}).value;
|
||||||
|
const body = {
|
||||||
|
mqtt_host: val('mqtt-host'),
|
||||||
|
mqtt_port: val('mqtt-port'),
|
||||||
|
mqtt_username: val('mqtt-username'),
|
||||||
|
mqtt_topic: val('mqtt-topic'),
|
||||||
|
mqtt_client_id: val('mqtt-client-id'),
|
||||||
|
ledmatrix_api_base: val('mqtt-api-base'),
|
||||||
|
request_timeout: val('mqtt-timeout'),
|
||||||
|
on_demand_duration: val('mqtt-duration') === '' ? null : val('mqtt-duration'),
|
||||||
|
log_level: val('mqtt-log-level'),
|
||||||
|
mqtt_tls: !!(document.getElementById('mqtt-tls') || {}).checked,
|
||||||
|
};
|
||||||
|
// Only send a password when one was typed; blank means "leave it alone".
|
||||||
|
const pw = val('mqtt-password');
|
||||||
|
if (pw) body.mqtt_password = pw;
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.saveMqttBridge = function() {
|
||||||
|
setBusy('btn-mqtt-save', true);
|
||||||
|
fetch(MQTT_BRIDGE_URL + '/config', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(mqttBody())
|
||||||
|
})
|
||||||
|
.then(r => r.json().then(d => ({ok: r.ok, d})))
|
||||||
|
.then(({ok, d}) => {
|
||||||
|
showResult('result-mqtt-save', ok && d.status === 'success',
|
||||||
|
d.message || (ok ? 'Saved' : 'Failed'));
|
||||||
|
if (ok && d.status === 'success') loadMqttBridge();
|
||||||
|
})
|
||||||
|
.catch(err => showResult('result-mqtt-save', false, 'Request failed: ' + err.message))
|
||||||
|
.finally(() => setBusy('btn-mqtt-save', false));
|
||||||
|
};
|
||||||
|
|
||||||
|
function clearMqttPassword() {
|
||||||
|
if (!confirm('Remove the stored MQTT password?')) return;
|
||||||
|
fetch(MQTT_BRIDGE_URL + '/config', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({clear_password: true})
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => {
|
||||||
|
showResult('result-mqtt-save', d.status === 'success', d.message || 'Password cleared');
|
||||||
|
loadMqttBridge();
|
||||||
|
})
|
||||||
|
.catch(err => showResult('result-mqtt-save', false, 'Request failed: ' + err.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
window.installMqttBridge = function() {
|
||||||
|
// Installing pulls dependencies, so it is slower than the other actions
|
||||||
|
// and worth saying so rather than leaving a spinner unexplained.
|
||||||
|
showResult('result-mqtt-service', true, 'Installing — this pulls Python dependencies and can take a minute…');
|
||||||
|
setBusy('btn-mqtt-install', true);
|
||||||
|
fetch('/api/v3/system/action', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({action: 'mqtt_bridge_install'})
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => {
|
||||||
|
showResult('result-mqtt-service', d.status === 'success',
|
||||||
|
d.message || (d.status === 'success' ? 'Installed' : 'Failed'), d.output || '');
|
||||||
|
loadMqttBridge();
|
||||||
|
})
|
||||||
|
.catch(err => showResult('result-mqtt-service', false, 'Request failed: ' + err.message))
|
||||||
|
.finally(() => setBusy('btn-mqtt-install', false));
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Pixlet config editor ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const PIXLET_EDITOR_URL = '/api/v3/starlark/editor';
|
||||||
|
let pixletPollTimer = null;
|
||||||
|
|
||||||
|
function fmtRemaining(seconds) {
|
||||||
|
if (seconds === null || seconds === undefined) return '';
|
||||||
|
const m = Math.floor(seconds / 60), s = seconds % 60;
|
||||||
|
return `${m}:${String(s).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function editorHost(port) {
|
||||||
|
// The session binds 0.0.0.0, so whatever host reached this page reaches
|
||||||
|
// the editor too -- no tunnel, and no guessing the device's address.
|
||||||
|
return `${window.location.protocol}//${window.location.hostname}:${port}/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPixletEditor(status, apps, pixletAvailable, appsDir) {
|
||||||
|
const body = document.getElementById('pixlet-editor-body');
|
||||||
|
if (!body) return;
|
||||||
|
|
||||||
|
if (!pixletAvailable) {
|
||||||
|
body.innerHTML = `
|
||||||
|
<p class="text-sm text-gray-700">
|
||||||
|
Pixlet is not installed, so there is nothing to edit with. Install it from the
|
||||||
|
Starlark Apps section of the Plugins tab, or run
|
||||||
|
<code class="bg-gray-100 px-1 rounded">./scripts/download_pixlet.sh</code>.
|
||||||
|
</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = status.running ? `
|
||||||
|
<div class="rounded-md border border-amber-300 bg-amber-50 p-3">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-amber-900">
|
||||||
|
<i class="fas fa-circle-notch fa-spin mr-1"></i>
|
||||||
|
Editing <code class="bg-amber-100 px-1 rounded">${escHtml(status.app_id)}</code> — the display is stopped
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-amber-800 mt-1">
|
||||||
|
<a href="${editorHost(status.port)}" target="_blank" rel="noopener"
|
||||||
|
class="underline font-medium">Open the editor →</a>
|
||||||
|
<span class="ml-2">stops on its own in
|
||||||
|
<span id="pixlet-countdown">${fmtRemaining(status.seconds_remaining)}</span></span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button id="btn-pixlet-stop" onclick="stopPixletEditor()"
|
||||||
|
class="shrink-0 inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700">
|
||||||
|
<i class="fas fa-stop mr-2"></i>Stop now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>` : '';
|
||||||
|
|
||||||
|
const rows = apps.length ? apps.map(a => `
|
||||||
|
<div class="flex items-center justify-between gap-4 py-2 border-t border-gray-100 first:border-t-0">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-sm font-medium text-gray-900 truncate">${escHtml(a.name)}</p>
|
||||||
|
<p class="text-xs text-gray-500 truncate">${escHtml(a.id)}${a.editable ? '' : ' — no .star file'}</p>
|
||||||
|
</div>
|
||||||
|
<button id="btn-pixlet-edit-${escHtml(a.id)}"
|
||||||
|
${(!a.editable || status.running) ? 'disabled' : ''}
|
||||||
|
onclick="startPixletEditor('${escHtml(a.id)}')"
|
||||||
|
class="shrink-0 inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
|
<i class="fas fa-sliders-h mr-2"></i>Edit
|
||||||
|
</button>
|
||||||
|
</div>`).join('') : `
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
No Starlark apps installed. Install one from the Plugins tab first
|
||||||
|
(looked in <code class="bg-gray-100 px-1 rounded">${escHtml(appsDir)}</code>).
|
||||||
|
</p>`;
|
||||||
|
|
||||||
|
body.innerHTML = active + `<div class="mt-2">${rows}</div>
|
||||||
|
<div id="result-pixlet" class="hidden"></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.loadPixletEditor = function() {
|
||||||
|
Promise.all([
|
||||||
|
fetch(PIXLET_EDITOR_URL + '/status').then(r => r.json()),
|
||||||
|
fetch(PIXLET_EDITOR_URL + '/apps').then(r => r.json())
|
||||||
|
])
|
||||||
|
.then(([s, a]) => {
|
||||||
|
const status = (s.data || {});
|
||||||
|
const apps = (a.data || {}).apps || [];
|
||||||
|
renderPixletEditor(status, apps, (a.data || {}).pixlet_available, (a.data || {}).apps_dir || '');
|
||||||
|
schedulePixletPoll(status.running);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
const body = document.getElementById('pixlet-editor-body');
|
||||||
|
if (body) body.innerHTML = `<p class="text-sm text-red-600">Could not load: ${escHtml(err.message)}</p>`;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
function schedulePixletPoll(running) {
|
||||||
|
// Poll only while a session is open: that is the state worth watching,
|
||||||
|
// and it is what makes the banner survive a page reload.
|
||||||
|
if (pixletPollTimer) { clearInterval(pixletPollTimer); pixletPollTimer = null; }
|
||||||
|
if (!running) return;
|
||||||
|
pixletPollTimer = setInterval(() => {
|
||||||
|
fetch(PIXLET_EDITOR_URL + '/status')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => {
|
||||||
|
const st = d.data || {};
|
||||||
|
if (!st.running) { loadPixletEditor(); return; }
|
||||||
|
const el = document.getElementById('pixlet-countdown');
|
||||||
|
if (el) el.textContent = fmtRemaining(st.seconds_remaining);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.startPixletEditor = function(appId) {
|
||||||
|
const btnId = 'btn-pixlet-edit-' + appId;
|
||||||
|
setBusy(btnId, true);
|
||||||
|
fetch(PIXLET_EDITOR_URL + '/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({app_id: appId})
|
||||||
|
})
|
||||||
|
.then(r => r.json().then(d => ({ok: r.ok, d})))
|
||||||
|
.then(({ok, d}) => {
|
||||||
|
if (!ok || d.status !== 'success') {
|
||||||
|
showResult('result-pixlet', false, d.message || 'Could not start the editor');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadPixletEditor();
|
||||||
|
})
|
||||||
|
.catch(err => showResult('result-pixlet', false, 'Request failed: ' + err.message))
|
||||||
|
.finally(() => setBusy(btnId, false));
|
||||||
|
};
|
||||||
|
|
||||||
|
window.stopPixletEditor = function() {
|
||||||
|
setBusy('btn-pixlet-stop', true);
|
||||||
|
fetch(PIXLET_EDITOR_URL + '/stop', {method: 'POST'})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => { showResult('result-pixlet', d.status === 'success', d.message || 'Stopped'); loadPixletEditor(); })
|
||||||
|
.catch(err => showResult('result-pixlet', false, 'Request failed: ' + err.message))
|
||||||
|
.finally(() => setBusy('btn-pixlet-stop', false));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial load for both sections. Down here rather than at the shared init
|
||||||
|
// site above, because these are assignments to window rather than hoisted
|
||||||
|
// declarations -- calling them earlier in the file throws.
|
||||||
|
loadMqttBridge();
|
||||||
|
loadPixletEditor();
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user