mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-18 12:59:12 +00:00
Compare commits
2
Commits
v3.8.6
...
4e91cf1fef
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e91cf1fef | ||
|
|
60d5d19898 |
Vendored
+947
@@ -0,0 +1,947 @@
|
|||||||
|
[
|
||||||
|
[
|
||||||
|
"/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/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/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"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Regression test: POST /fonts/upload must enforce its own stated size limit.
|
||||||
|
|
||||||
|
validate_file_upload(filename, max_size_mb=10, allowed_extensions=[...]) reads
|
||||||
|
like it checks the upload's size, but it only ever validated the filename
|
||||||
|
(traversal characters, extension) -- max_size_mb was accepted and silently
|
||||||
|
ignored. Nothing else in the handler checked the actual upload size either,
|
||||||
|
so it saved whatever was posted to assets/fonts/<family><ext> regardless of
|
||||||
|
size, unlike the sibling .star and plugin-asset upload routes, which check
|
||||||
|
`file.tell()` against a stated limit before saving.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||||
|
|
||||||
|
URL = "/api/v3/fonts/upload"
|
||||||
|
TEN_MB = 10 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fonts_root(tmp_path):
|
||||||
|
# PROJECT_ROOT is bound by value in fonts.py, so it is patched there.
|
||||||
|
with patch("web_interface.blueprints.api_v3.fonts.PROJECT_ROOT", tmp_path):
|
||||||
|
yield tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
def upload(client, content, filename="myfont.ttf", family="myfont"):
|
||||||
|
data = {
|
||||||
|
"font_file": (io.BytesIO(content), filename),
|
||||||
|
"font_family": family,
|
||||||
|
}
|
||||||
|
return client.post(URL, data=data, content_type="multipart/form-data")
|
||||||
|
|
||||||
|
|
||||||
|
class TestFontUploadSizeLimit:
|
||||||
|
def test_oversized_font_is_rejected(self, api_v3_client, fonts_root):
|
||||||
|
response = upload(api_v3_client, b"x" * (TEN_MB + 1))
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "too large" in response.get_json()["message"].lower()
|
||||||
|
assert not (fonts_root / "assets" / "fonts" / "myfont.ttf").exists(), (
|
||||||
|
"an oversized font was saved to disk before being rejected")
|
||||||
|
|
||||||
|
def test_a_font_right_at_the_limit_is_accepted(self, api_v3_client, fonts_root):
|
||||||
|
response = upload(api_v3_client, b"x" * TEN_MB,
|
||||||
|
filename="atlimit.ttf", family="atlimit")
|
||||||
|
assert response.status_code == 200, response.get_json()
|
||||||
|
assert (fonts_root / "assets" / "fonts" / "atlimit.ttf").exists()
|
||||||
|
|
||||||
|
def test_an_ordinary_small_font_is_still_accepted(self, api_v3_client, fonts_root):
|
||||||
|
response = upload(api_v3_client, b"fake font bytes",
|
||||||
|
filename="small.ttf", family="small")
|
||||||
|
assert response.status_code == 200, response.get_json()
|
||||||
|
assert (fonts_root / "assets" / "fonts" / "small.ttf").read_bytes() == b"fake font bytes"
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""Regression test: POST /display/on-demand/start restarting a running
|
||||||
|
service must not import a name that does not exist.
|
||||||
|
|
||||||
|
display.py has `import web_interface.blueprints.api_v3 as _pkg` and reads
|
||||||
|
mutable, test-patched attributes back through it (`_pkg.time.time()`,
|
||||||
|
`_pkg._get_starlark_plugin()`, ...) rather than binding them by value, per
|
||||||
|
the package's own docstring. One spot went further and wrote a genuine
|
||||||
|
`import` *statement* against that alias --
|
||||||
|
|
||||||
|
import _pkg.time as time_module
|
||||||
|
|
||||||
|
-- but `_pkg` is a local name bound by `import ... as _pkg` in this module,
|
||||||
|
not a real top-level package, so `import _pkg.time` is not something Python
|
||||||
|
can resolve; it raises ModuleNotFoundError. That line only runs when the
|
||||||
|
display service is already running and the caller also asked to (re)start
|
||||||
|
it, so this endpoint failed on exactly the restart path -- the one where a
|
||||||
|
cache write recording the new on-demand request had already happened.
|
||||||
|
|
||||||
|
The route wraps its body in `except Exception`, so the failure reached the
|
||||||
|
caller as a handled 500 with a generic message, not an unhandled crash --
|
||||||
|
but a 500 all the same on a request that should have restarted the service
|
||||||
|
and reported success.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||||
|
|
||||||
|
URL = "/api/v3/display/on-demand/start"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def restart_path(api_v3_module):
|
||||||
|
"""Force the `service_was_running and start_service` branch.
|
||||||
|
|
||||||
|
plugin_manager and config_manager are set to None so the route takes
|
||||||
|
the simplest path to that branch rather than tripping over unrelated
|
||||||
|
MagicMock plumbing; _ensure_cache_manager, _get_display_service_status,
|
||||||
|
_stop_display_service and _ensure_display_service_running are bound by
|
||||||
|
value in display.py (see its own docstring), so they are patched on
|
||||||
|
that submodule rather than on the package.
|
||||||
|
"""
|
||||||
|
api_v3_module.api_v3.plugin_manager = None
|
||||||
|
api_v3_module.api_v3.config_manager = None
|
||||||
|
|
||||||
|
with patch("web_interface.blueprints.api_v3.display._ensure_cache_manager") as ensure_cache, \
|
||||||
|
patch("web_interface.blueprints.api_v3.display._get_display_service_status") as get_status, \
|
||||||
|
patch("web_interface.blueprints.api_v3.display._stop_display_service") as stop_service, \
|
||||||
|
patch("web_interface.blueprints.api_v3.display._ensure_display_service_running") as ensure_running:
|
||||||
|
ensure_cache.return_value = MagicMock()
|
||||||
|
# Active before the request: service_was_running becomes True.
|
||||||
|
get_status.return_value = {"active": True}
|
||||||
|
ensure_running.return_value = {"active": True}
|
||||||
|
yield {
|
||||||
|
"ensure_cache": ensure_cache,
|
||||||
|
"get_status": get_status,
|
||||||
|
"stop_service": stop_service,
|
||||||
|
"ensure_running": ensure_running,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestRestartingARunningService:
|
||||||
|
def test_it_does_not_500(self, api_v3_client, restart_path):
|
||||||
|
response = api_v3_client.post(
|
||||||
|
URL, json={"plugin_id": "weather", "start_service": True})
|
||||||
|
body = response.get_json()
|
||||||
|
assert response.status_code == 200, body
|
||||||
|
assert body["status"] == "success", body
|
||||||
|
|
||||||
|
def test_the_service_is_actually_stopped_and_restarted(
|
||||||
|
self, api_v3_client, restart_path):
|
||||||
|
api_v3_client.post(
|
||||||
|
URL, json={"plugin_id": "weather", "start_service": True})
|
||||||
|
restart_path["stop_service"].assert_called_once()
|
||||||
|
restart_path["ensure_running"].assert_called_once()
|
||||||
|
|
||||||
|
def test_a_service_that_was_not_running_is_not_stopped_first(
|
||||||
|
self, api_v3_client, restart_path):
|
||||||
|
# The buggy import sits inside `if service_was_running and
|
||||||
|
# start_service`, so it only ever fired on the restart path --
|
||||||
|
# this is the other side of that branch, unaffected either way,
|
||||||
|
# kept here so the branch condition itself stays covered.
|
||||||
|
restart_path["get_status"].return_value = {"active": False}
|
||||||
|
response = api_v3_client.post(
|
||||||
|
URL, json={"plugin_id": "weather", "start_service": True})
|
||||||
|
assert response.status_code == 200, response.get_json()
|
||||||
|
restart_path["stop_service"].assert_not_called()
|
||||||
@@ -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,54 @@
|
|||||||
|
"""Regression test: per-day schedule validation errors must say "time", not
|
||||||
|
"_pkg.time".
|
||||||
|
|
||||||
|
The split into a package rewrote every bare `time` reference that needed to
|
||||||
|
read through the shared module as `_pkg.time` (see the package's own
|
||||||
|
docstring on why -- tests patch it, so it has to be read back live rather
|
||||||
|
than bound by value). That rewrite was mechanical and matched the substring
|
||||||
|
"time" inside string literals and comments too, so the user-facing message
|
||||||
|
|
||||||
|
"Invalid start time for {day}: ..."
|
||||||
|
|
||||||
|
came out as
|
||||||
|
|
||||||
|
"Invalid start _pkg.time for {day}: ..."
|
||||||
|
|
||||||
|
in both POST /config/schedule and POST /config/dim-schedule, for both the
|
||||||
|
start and end time of a per-day entry.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("url", [
|
||||||
|
"/api/v3/config/schedule",
|
||||||
|
"/api/v3/config/dim-schedule",
|
||||||
|
])
|
||||||
|
class TestPerDayTimeErrorsAreNotCorrupted:
|
||||||
|
def test_invalid_start_time_message(self, api_v3_client, api_v3_module, url):
|
||||||
|
response = api_v3_client.post(url, json={
|
||||||
|
"mode": "per_day",
|
||||||
|
"monday_start": "not-a-time",
|
||||||
|
})
|
||||||
|
assert response.status_code == 400
|
||||||
|
message = response.get_json()["message"]
|
||||||
|
assert "_pkg" not in message, message
|
||||||
|
assert message.startswith("Invalid start time for monday:"), message
|
||||||
|
|
||||||
|
def test_invalid_end_time_message(self, api_v3_client, api_v3_module, url):
|
||||||
|
response = api_v3_client.post(url, json={
|
||||||
|
"mode": "per_day",
|
||||||
|
"monday_start": "07:00",
|
||||||
|
"monday_end": "not-a-time",
|
||||||
|
})
|
||||||
|
assert response.status_code == 400
|
||||||
|
message = response.get_json()["message"]
|
||||||
|
assert "_pkg" not in message, message
|
||||||
|
assert message.startswith("Invalid end time for monday:"), message
|
||||||
@@ -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]
|
||||||
@@ -229,6 +229,61 @@ class TestRadio:
|
|||||||
assert api_v3_client.post(self.URL, json={"enabled": True}).status_code == 500
|
assert api_v3_client.post(self.URL, json={"enabled": True}).status_code == 500
|
||||||
|
|
||||||
|
|
||||||
|
class TestAutoEnableApMode:
|
||||||
|
URL = "/api/v3/wifi/ap/auto-enable"
|
||||||
|
|
||||||
|
def test_requires_the_field(self, api_v3_client, wifi_manager):
|
||||||
|
response = api_v3_client.post(self.URL, json={})
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw,expected", [
|
||||||
|
(True, True), (False, False),
|
||||||
|
("true", True), ("True", True), ("1", True), ("yes", True),
|
||||||
|
("false", False), ("False", False), ("0", False), ("no", False),
|
||||||
|
(1, True), (0, False),
|
||||||
|
])
|
||||||
|
def test_value_is_coerced_not_just_truthy(
|
||||||
|
self, api_v3_client, wifi_manager, raw, expected):
|
||||||
|
# Regression: bool(data['auto_enable_ap_mode']) meant a caller who
|
||||||
|
# sent the JSON string "false" got it stored as True — bool("false")
|
||||||
|
# is True, since any non-empty string is truthy.
|
||||||
|
wifi_manager.config = {}
|
||||||
|
response = api_v3_client.post(self.URL, json={"auto_enable_ap_mode": raw})
|
||||||
|
assert response.status_code == 200, response.get_json()
|
||||||
|
assert response.get_json()["data"]["auto_enable_ap_mode"] is expected
|
||||||
|
assert wifi_manager.config["auto_enable_ap_mode"] is expected
|
||||||
|
|
||||||
|
def test_a_string_false_does_not_enable_it(self, api_v3_client, wifi_manager):
|
||||||
|
# The exact shape of the bug.
|
||||||
|
wifi_manager.config = {}
|
||||||
|
api_v3_client.post(self.URL, json={"auto_enable_ap_mode": "false"})
|
||||||
|
assert wifi_manager.config["auto_enable_ap_mode"] is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestRadioEnabledAndForceAcceptIntegers:
|
||||||
|
"""`{"enabled": 1}` / `{"enabled": 0}` used to be mishandled: the old
|
||||||
|
coercion was `raw is True or (isinstance(raw, str) and ...)`, and
|
||||||
|
`1 is True` is False in Python -- an int is never the `True` singleton
|
||||||
|
even though it equals it -- so a plain integer fell through to False
|
||||||
|
regardless of its value.
|
||||||
|
"""
|
||||||
|
URL = "/api/v3/wifi/radio"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw,expected", [(1, True), (0, False)])
|
||||||
|
def test_enabled_as_an_integer(self, api_v3_client, wifi_manager, raw, expected):
|
||||||
|
wifi_manager.set_wifi_radio.return_value = (True, "ok", None)
|
||||||
|
wifi_manager.get_wifi_radio_state.return_value = {}
|
||||||
|
api_v3_client.post(self.URL, json={"enabled": raw})
|
||||||
|
wifi_manager.set_wifi_radio.assert_called_once_with(expected, force=False)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw,expected", [(1, True), (0, False)])
|
||||||
|
def test_force_as_an_integer(self, api_v3_client, wifi_manager, raw, expected):
|
||||||
|
wifi_manager.set_wifi_radio.return_value = (True, "ok", None)
|
||||||
|
wifi_manager.get_wifi_radio_state.return_value = {}
|
||||||
|
api_v3_client.post(self.URL, json={"enabled": True, "force": raw})
|
||||||
|
wifi_manager.set_wifi_radio.assert_called_once_with(True, force=expected)
|
||||||
|
|
||||||
|
|
||||||
class TestNoRealNetworking:
|
class TestNoRealNetworking:
|
||||||
def test_wifi_manager_is_never_constructed_for_real(self, api_v3_client):
|
def test_wifi_manager_is_never_constructed_for_real(self, api_v3_client):
|
||||||
# Guard against a future refactor moving the import to module level,
|
# Guard against a future refactor moving the import to module level,
|
||||||
|
|||||||
@@ -103,6 +103,43 @@ def test_non_dict_input_passes_through():
|
|||||||
assert _redact_credentials(None) is None
|
assert _redact_credentials(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_list_of_bare_credentials_is_redacted():
|
||||||
|
"""A credential-named key whose value is a list of scalars, not a list
|
||||||
|
of named-field dicts, used to pass through untouched: the dict branch
|
||||||
|
recursed into `v` on its own value only, so a bare string list item had
|
||||||
|
no field name to test and fell through the base case unredacted.
|
||||||
|
"""
|
||||||
|
config = {"tokens": ["abc123", "def456"], "timezone": "America/New_York"}
|
||||||
|
out = _redact_credentials(config)
|
||||||
|
assert out["tokens"] == ["", ""]
|
||||||
|
assert out["timezone"] == "America/New_York"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_list_nested_inside_a_credential_named_container_is_also_blanked():
|
||||||
|
"""The list fix applies at any depth under a credential-named key, not
|
||||||
|
only when the list is the key's direct value.
|
||||||
|
"""
|
||||||
|
config = {"auth": {"backup_tokens": ["a", "b"], "note": "keep"}}
|
||||||
|
out = _redact_credentials(config)
|
||||||
|
assert out["auth"]["backup_tokens"] == ["", ""]
|
||||||
|
assert out["auth"]["note"] == "keep"
|
||||||
|
|
||||||
|
|
||||||
|
def test_credential_shaped_container_with_named_fields_still_only_blanks_those():
|
||||||
|
"""Unchanged behaviour for the case the container test above already
|
||||||
|
covers: a dict whose sub-keys are semantically named fields is walked
|
||||||
|
normally, not blanket-blanked, so an ordinary field next to a
|
||||||
|
credential-named one survives.
|
||||||
|
"""
|
||||||
|
config = {"secrets": {"api_key": "k", "note": "keep", "list": ["a", "b"]}}
|
||||||
|
out = _redact_credentials(config)
|
||||||
|
assert out["secrets"]["api_key"] == ""
|
||||||
|
assert out["secrets"]["note"] == "keep"
|
||||||
|
# "list" isn't itself credential-named, so it is walked, not blanked --
|
||||||
|
# but it holds no credential-named field either, so it survives whole.
|
||||||
|
assert out["secrets"]["list"] == ["a", "b"]
|
||||||
|
|
||||||
|
|
||||||
def test_the_endpoint_itself_redacts():
|
def test_the_endpoint_itself_redacts():
|
||||||
"""Through the view function, not the helper.
|
"""Through the view function, not the helper.
|
||||||
|
|
||||||
@@ -130,7 +167,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
|
||||||
|
|||||||
@@ -140,6 +140,41 @@ class TestRequestValidation:
|
|||||||
assert restore.call_args[0][2].restore_config is True
|
assert restore.call_args[0][2].restore_config is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestOptionsAreBooleanAware:
|
||||||
|
"""Regression: bool("false") is True in Python.
|
||||||
|
|
||||||
|
Every restore flag used bare bool() coercion, so a caller that sends its
|
||||||
|
options as JSON strings rather than real booleans -- a form field, a
|
||||||
|
hand-built request -- had `{"restore_secrets": "false"}` restore secrets
|
||||||
|
anyway, the opposite of what was asked. Fixed with the same
|
||||||
|
string-aware `_coerce_to_bool` already used for checkbox-style config
|
||||||
|
fields elsewhere in this package (config.py, plugins.py).
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw,expected", [
|
||||||
|
("false", False), ("False", False), ("FALSE", False),
|
||||||
|
("0", False),
|
||||||
|
("true", True), ("True", True), ("1", True),
|
||||||
|
])
|
||||||
|
def test_string_valued_flags_are_parsed_not_just_truthy(
|
||||||
|
self, client, restore, raw, expected):
|
||||||
|
post(client, options=json.dumps({"restore_secrets": raw}))
|
||||||
|
assert restore.call_args[0][2].restore_secrets is expected
|
||||||
|
|
||||||
|
def test_a_string_false_does_not_restore_secrets(self, client, restore):
|
||||||
|
# The exact shape of the bug: a truthy non-empty string coerced by
|
||||||
|
# bare bool() to True regardless of its contents.
|
||||||
|
post(client, options=json.dumps({"restore_secrets": "false"}))
|
||||||
|
assert restore.call_args[0][2].restore_secrets is False
|
||||||
|
|
||||||
|
def test_real_json_booleans_still_work(self, client, restore):
|
||||||
|
post(client, options=json.dumps({"restore_secrets": False,
|
||||||
|
"restore_config": True}))
|
||||||
|
options = restore.call_args[0][2]
|
||||||
|
assert options.restore_secrets is False
|
||||||
|
assert options.restore_config is True
|
||||||
|
|
||||||
|
|
||||||
class TestSuccess:
|
class TestSuccess:
|
||||||
def test_success_returns_the_result(self, client, restore):
|
def test_success_returns_the_result(self, client, restore):
|
||||||
restore.return_value = FakeResult(success=True, restored=["config", "secrets"])
|
restore.return_value = FakeResult(success=True, restored=["config", "secrets"])
|
||||||
@@ -230,7 +265,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
|
||||||
|
|
||||||
@@ -360,6 +363,22 @@ class TestDiagnosticsAreRedacted:
|
|||||||
assert 'hunter2' not in error, error
|
assert 'hunter2' not in error, error
|
||||||
assert '<redacted>' in error, error
|
assert '<redacted>' in error, error
|
||||||
|
|
||||||
|
def test_script_stderr_is_redacted_in_the_log_too(self, tmp_path, caplog):
|
||||||
|
# Regression: the return value went through redact_text (asserted
|
||||||
|
# above), but the logger.error call right next to it logged `raw`
|
||||||
|
# verbatim -- a script that handles OAuth client secrets and can
|
||||||
|
# quote them in its stderr, landing unredacted in the log (CWE-532).
|
||||||
|
script = tmp_path / 'calendar_registration.py'
|
||||||
|
script.write_text(
|
||||||
|
'import sys\n'
|
||||||
|
'sys.stderr.write("boom client_secret=hunter2 more\\n")\n',
|
||||||
|
encoding='utf-8')
|
||||||
|
with caplog.at_level('ERROR', logger=mod.logger.name):
|
||||||
|
mod._run_calendar_registration(tmp_path, '')
|
||||||
|
logged = '\n'.join(r.getMessage() for r in caplog.records)
|
||||||
|
assert 'hunter2' not in logged, logged
|
||||||
|
assert '<redacted>' in logged, logged
|
||||||
|
|
||||||
def test_a_failing_script_payload_is_redacted(self, client):
|
def test_a_failing_script_payload_is_redacted(self, client):
|
||||||
(client.plugin_dir / 'credentials.json').write_text('{}', encoding='utf-8')
|
(client.plugin_dir / 'credentials.json').write_text('{}', encoding='utf-8')
|
||||||
(client.plugin_dir / 'calendar_registration.py').write_text(
|
(client.plugin_dir / 'calendar_registration.py').write_text(
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -395,6 +395,132 @@ class TestTheManifestStaysRelocatable:
|
|||||||
assert self._install(tmp_path)['star_file'] == 'demo.star'
|
assert self._install(tmp_path)['star_file'] == 'demo.star'
|
||||||
|
|
||||||
|
|
||||||
|
class TestManifestLockPreventsLostUpdates:
|
||||||
|
"""The standalone manifest fallback (no plugin instance loaded) reads,
|
||||||
|
mutates and writes manifest.json with no coordination across requests.
|
||||||
|
Each write is atomic on its own (temp file + rename), but two concurrent
|
||||||
|
read-modify-write cycles can still race: both read the same starting
|
||||||
|
manifest, and the second write silently discards whatever the first one
|
||||||
|
added. _starlark_manifest_lock closes that window -- mirrors
|
||||||
|
StarlarkAppsPlugin._update_manifest_safe, which already does this when
|
||||||
|
the plugin instance is loaded.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def starlark_dir(self, tmp_path, monkeypatch):
|
||||||
|
from web_interface.blueprints import api_v3 as module
|
||||||
|
apps_dir = tmp_path / "starlark-apps"
|
||||||
|
apps_dir.mkdir()
|
||||||
|
monkeypatch.setattr(module, '_STARLARK_APPS_DIR', apps_dir)
|
||||||
|
monkeypatch.setattr(module, '_STARLARK_MANIFEST_FILE', apps_dir / 'manifest.json')
|
||||||
|
module._write_starlark_manifest({'apps': {}})
|
||||||
|
return apps_dir
|
||||||
|
|
||||||
|
def test_two_concurrent_updates_are_both_kept(self, starlark_dir):
|
||||||
|
import threading
|
||||||
|
import time as _time
|
||||||
|
|
||||||
|
from web_interface.blueprints import api_v3 as module
|
||||||
|
|
||||||
|
def add_app(app_id):
|
||||||
|
with module._starlark_manifest_lock():
|
||||||
|
manifest = module._read_starlark_manifest()
|
||||||
|
# Widen the window between read and write. Without the lock
|
||||||
|
# both threads read here before either writes, and whichever
|
||||||
|
# writes second overwrites the other's addition; with the
|
||||||
|
# lock, the second thread cannot even start its read until
|
||||||
|
# the first has written and released.
|
||||||
|
_time.sleep(0.05)
|
||||||
|
manifest.setdefault('apps', {})[app_id] = {'enabled': True}
|
||||||
|
module._write_starlark_manifest(manifest)
|
||||||
|
|
||||||
|
threads = [threading.Thread(target=add_app, args=(app_id,))
|
||||||
|
for app_id in ('a', 'b')]
|
||||||
|
for t in threads:
|
||||||
|
t.start()
|
||||||
|
for t in threads:
|
||||||
|
t.join(timeout=5)
|
||||||
|
|
||||||
|
manifest = module._read_starlark_manifest()
|
||||||
|
assert set(manifest['apps']) == {'a', 'b'}, (
|
||||||
|
"a concurrent update was lost: %r" % (manifest,))
|
||||||
|
|
||||||
|
def test_the_lock_is_reentrant_safe_across_sequential_calls(self, starlark_dir):
|
||||||
|
"""Not reentrant within one thread -- just that using it twice in a
|
||||||
|
row (the ordinary case: one request, then the next) works cleanly
|
||||||
|
and does not leak the lock file descriptor or leave it locked."""
|
||||||
|
from web_interface.blueprints import api_v3 as module
|
||||||
|
|
||||||
|
for app_id in ('first', 'second'):
|
||||||
|
with module._starlark_manifest_lock():
|
||||||
|
manifest = module._read_starlark_manifest()
|
||||||
|
manifest.setdefault('apps', {})[app_id] = {'enabled': True}
|
||||||
|
module._write_starlark_manifest(manifest)
|
||||||
|
|
||||||
|
manifest = module._read_starlark_manifest()
|
||||||
|
assert set(manifest['apps']) == {'first', 'second'}
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigAndManifestStayInSync:
|
||||||
|
"""Standalone-mode PUT /starlark/apps/<id>/config (no plugin instance
|
||||||
|
loaded) writes config.json and then the manifest. If the manifest write
|
||||||
|
fails after config.json was already written, the two disagree about
|
||||||
|
what was saved unless config.json is rolled back.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app_dir(self, tmp_path, monkeypatch):
|
||||||
|
from web_interface.blueprints import api_v3 as module
|
||||||
|
apps_dir = tmp_path / "starlark-apps"
|
||||||
|
apps_dir.mkdir()
|
||||||
|
monkeypatch.setattr(module, '_STARLARK_APPS_DIR', apps_dir)
|
||||||
|
monkeypatch.setattr(module, '_STARLARK_MANIFEST_FILE', apps_dir / 'manifest.json')
|
||||||
|
one_app_dir = apps_dir / 'demo'
|
||||||
|
one_app_dir.mkdir()
|
||||||
|
module._write_starlark_manifest({'apps': {'demo': {'name': 'Demo', 'enabled': True}}})
|
||||||
|
return one_app_dir
|
||||||
|
|
||||||
|
def test_manifest_write_failure_rolls_back_an_existing_config_json(self, client, app_dir):
|
||||||
|
config_file = app_dir / 'config.json'
|
||||||
|
config_file.write_text(json.dumps({'existing': 'value'}))
|
||||||
|
|
||||||
|
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \
|
||||||
|
patch('web_interface.blueprints.api_v3._write_starlark_manifest', return_value=False):
|
||||||
|
resp = client.put('/api/v3/starlark/apps/demo/config',
|
||||||
|
json={'new_field': 'x'})
|
||||||
|
|
||||||
|
assert resp.status_code == 500
|
||||||
|
assert json.loads(config_file.read_text()) == {'existing': 'value'}, (
|
||||||
|
"config.json kept the new value even though the manifest write "
|
||||||
|
"that was supposed to follow it failed")
|
||||||
|
|
||||||
|
def test_manifest_write_failure_removes_a_freshly_created_config_json(self, client, app_dir):
|
||||||
|
config_file = app_dir / 'config.json'
|
||||||
|
assert not config_file.exists()
|
||||||
|
|
||||||
|
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \
|
||||||
|
patch('web_interface.blueprints.api_v3._write_starlark_manifest', return_value=False):
|
||||||
|
resp = client.put('/api/v3/starlark/apps/demo/config',
|
||||||
|
json={'new_field': 'x'})
|
||||||
|
|
||||||
|
assert resp.status_code == 500
|
||||||
|
assert not config_file.exists(), (
|
||||||
|
"config.json was left behind even though the manifest write "
|
||||||
|
"that was supposed to follow it failed")
|
||||||
|
|
||||||
|
def test_success_updates_both_config_and_manifest(self, client, app_dir):
|
||||||
|
from web_interface.blueprints import api_v3 as module
|
||||||
|
|
||||||
|
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None):
|
||||||
|
resp = client.put('/api/v3/starlark/apps/demo/config',
|
||||||
|
json={'new_field': 'x'})
|
||||||
|
|
||||||
|
assert resp.status_code == 200, resp.get_json()
|
||||||
|
assert json.loads((app_dir / 'config.json').read_text())['new_field'] == 'x'
|
||||||
|
manifest = json.loads(module._STARLARK_MANIFEST_FILE.read_text())
|
||||||
|
assert manifest['apps']['demo']['config']['new_field'] == 'x'
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# The store loaded, then stopped loading, and nothing anywhere said why.
|
# The store loaded, then stopped loading, and nothing anywhere said why.
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -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,216 @@
|
|||||||
|
"""Backup creation, listing and restore.
|
||||||
|
|
||||||
|
Routes decorate the shared `api_v3` Blueprint from ._common, so their
|
||||||
|
endpoint names are unchanged by living here.
|
||||||
|
"""
|
||||||
|
from web_interface.blueprints.api_v3 import (
|
||||||
|
PROJECT_ROOT, Path, _coerce_to_bool, _safe_backup_path, api_v3,
|
||||||
|
datetime, json, jsonify, logger, os, plugin_store_manager, request,
|
||||||
|
tempfile,
|
||||||
|
)
|
||||||
|
import web_interface.blueprints.api_v3 as _pkg
|
||||||
|
# Read through the module rather than bound by value: tests patch these
|
||||||
|
# as module attributes, and a value binding would not see the patch.
|
||||||
|
# Several are also called from helpers that live in __init__, so the
|
||||||
|
# package is the only patch point that covers every caller.
|
||||||
|
|
||||||
|
|
||||||
|
@api_v3.route('/backup/preview', methods=['GET'])
|
||||||
|
def backup_preview():
|
||||||
|
"""Return a summary of what a new backup would include."""
|
||||||
|
try:
|
||||||
|
from src.backup_manager import preview_backup_contents
|
||||||
|
data = preview_backup_contents(PROJECT_ROOT)
|
||||||
|
return jsonify({'status': 'success', 'data': data})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("backup_preview failed: %s", e, exc_info=True)
|
||||||
|
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
|
||||||
|
@api_v3.route('/backup/list', methods=['GET'])
|
||||||
|
def backup_list():
|
||||||
|
"""List backup ZIPs stored in the export directory."""
|
||||||
|
try:
|
||||||
|
_pkg._BACKUP_EXPORT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
entries = []
|
||||||
|
for p in sorted(_pkg._BACKUP_EXPORT_DIR.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True):
|
||||||
|
if not p.is_file() or p.suffix != '.zip':
|
||||||
|
continue
|
||||||
|
st = p.stat()
|
||||||
|
entries.append({
|
||||||
|
'filename': p.name,
|
||||||
|
'size': st.st_size,
|
||||||
|
'created_at': datetime.fromtimestamp(st.st_mtime).strftime('%Y-%m-%d %H:%M:%S'),
|
||||||
|
})
|
||||||
|
return jsonify({'status': 'success', 'data': entries})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("backup_list failed: %s", e, exc_info=True)
|
||||||
|
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
|
||||||
|
@api_v3.route('/backup/export', methods=['POST'])
|
||||||
|
def backup_export():
|
||||||
|
"""Create a new backup ZIP and return its filename."""
|
||||||
|
try:
|
||||||
|
from src.backup_manager import create_backup
|
||||||
|
zip_path = create_backup(PROJECT_ROOT, output_dir=_pkg._BACKUP_EXPORT_DIR)
|
||||||
|
return jsonify({'status': 'success', 'filename': zip_path.name})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("backup_export failed: %s", e, exc_info=True)
|
||||||
|
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
|
||||||
|
@api_v3.route('/backup/validate', methods=['POST'])
|
||||||
|
def backup_validate():
|
||||||
|
"""Validate an uploaded backup ZIP and return its manifest."""
|
||||||
|
try:
|
||||||
|
from src.backup_manager import validate_backup
|
||||||
|
if 'backup_file' not in request.files:
|
||||||
|
return jsonify({'status': 'error', 'message': 'No backup_file in request'}), 400
|
||||||
|
f = request.files['backup_file']
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as tmp:
|
||||||
|
tmp_path = tmp.name
|
||||||
|
f.save(tmp_path)
|
||||||
|
try:
|
||||||
|
ok, err_msg, manifest = validate_backup(Path(tmp_path))
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if not ok:
|
||||||
|
logger.warning("Backup validation failed: %s", err_msg)
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid or corrupted backup file'}), 400
|
||||||
|
safe_manifest = {
|
||||||
|
'schema_version': manifest.get('schema_version'),
|
||||||
|
'created_at': manifest.get('created_at'),
|
||||||
|
'ledmatrix_version': manifest.get('ledmatrix_version'),
|
||||||
|
'hostname': manifest.get('hostname'),
|
||||||
|
'contents': manifest.get('contents', []),
|
||||||
|
'detected_contents': manifest.get('detected_contents', []),
|
||||||
|
'plugins': manifest.get('plugins', []),
|
||||||
|
'total_uncompressed': manifest.get('total_uncompressed'),
|
||||||
|
'file_count': manifest.get('file_count'),
|
||||||
|
}
|
||||||
|
return jsonify({'status': 'success', 'data': safe_manifest})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("backup_validate failed: %s", e, exc_info=True)
|
||||||
|
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
|
||||||
|
@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
|
||||||
|
# _coerce_to_bool (not bare bool()) because a request can send these
|
||||||
|
# as JSON strings: bool("false") is True in Python, so a caller who
|
||||||
|
# explicitly asked to skip secrets would have had them restored
|
||||||
|
# anyway.
|
||||||
|
options = RestoreOptions(
|
||||||
|
restore_config=_coerce_to_bool(opts_dict.get('restore_config', True)),
|
||||||
|
restore_secrets=_coerce_to_bool(opts_dict.get('restore_secrets', True)),
|
||||||
|
restore_wifi=_coerce_to_bool(opts_dict.get('restore_wifi', True)),
|
||||||
|
restore_fonts=_coerce_to_bool(opts_dict.get('restore_fonts', True)),
|
||||||
|
restore_plugin_uploads=_coerce_to_bool(opts_dict.get('restore_plugin_uploads', True)),
|
||||||
|
reinstall_plugins=_coerce_to_bool(opts_dict.get('reinstall_plugins', True)),
|
||||||
|
)
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as tmp:
|
||||||
|
tmp_path = tmp.name
|
||||||
|
f.save(tmp_path)
|
||||||
|
try:
|
||||||
|
result = restore_backup(Path(tmp_path), PROJECT_ROOT, options)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Reinstall plugins if requested and store manager available
|
||||||
|
if options.reinstall_plugins and result.plugins_to_install:
|
||||||
|
psm = getattr(api_v3, 'plugin_store_manager', None) or plugin_store_manager
|
||||||
|
for plug in result.plugins_to_install:
|
||||||
|
pid = plug.get('plugin_id')
|
||||||
|
if not pid:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if psm and hasattr(psm, 'install_plugin'):
|
||||||
|
ok = psm.install_plugin(pid)
|
||||||
|
if ok:
|
||||||
|
result.plugins_installed.append(pid)
|
||||||
|
else:
|
||||||
|
result.plugins_failed.append({'plugin_id': pid, 'error': 'install_plugin returned False'})
|
||||||
|
else:
|
||||||
|
result.plugins_failed.append({'plugin_id': pid, 'error': 'Store manager unavailable'})
|
||||||
|
except Exception as pe:
|
||||||
|
logger.error(
|
||||||
|
"[Backup] Failed to reinstall plugin %r: %s", pid, pe, exc_info=True
|
||||||
|
)
|
||||||
|
result.plugins_failed.append({'plugin_id': pid, 'error': 'Installation failed; see server logs'})
|
||||||
|
|
||||||
|
# A restore that dropped files can still report success if the only
|
||||||
|
# failures were plugin reinstalls, since those don't touch result.errors.
|
||||||
|
if result.plugins_failed:
|
||||||
|
result.success = False
|
||||||
|
|
||||||
|
data = result.to_dict()
|
||||||
|
if not result.success:
|
||||||
|
# Name what failed, and what nonetheless landed. A restore is
|
||||||
|
# partial far more often than it is total -- a fresh install can
|
||||||
|
# leave config_secrets.json unwritable by the web service, so
|
||||||
|
# config restores and secrets do not. "Restore had errors" alone
|
||||||
|
# left the user unable to tell a wholly failed restore from one
|
||||||
|
# that quietly dropped their API keys.
|
||||||
|
failed_plugins = [
|
||||||
|
str(p.get('plugin_id')) for p in (result.plugins_failed or []) if p.get('plugin_id')
|
||||||
|
]
|
||||||
|
parts = []
|
||||||
|
if result.restored:
|
||||||
|
parts.append(f"restored: {', '.join(result.restored)}")
|
||||||
|
if result.errors:
|
||||||
|
parts.append(f"failed: {'; '.join(result.errors)}")
|
||||||
|
if failed_plugins:
|
||||||
|
parts.append(f"plugins not reinstalled: {', '.join(failed_plugins)}")
|
||||||
|
message = 'Restore incomplete — ' + ('. '.join(parts) if parts else 'see logs')
|
||||||
|
return jsonify({'status': 'error', 'message': message, 'data': data}), 500
|
||||||
|
return jsonify({'status': 'success', 'data': data})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("backup_restore failed: %s", e, exc_info=True)
|
||||||
|
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
|
||||||
|
@api_v3.route('/backup/download/<path:filename>', methods=['GET'])
|
||||||
|
def backup_download(filename):
|
||||||
|
"""Stream a backup ZIP to the browser."""
|
||||||
|
from flask import send_from_directory
|
||||||
|
if _safe_backup_path(filename) is None:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Backup not found'}), 404
|
||||||
|
try:
|
||||||
|
# send_from_directory uses werkzeug safe_join internally — CodeQL-recognized sanitizer.
|
||||||
|
return send_from_directory(_pkg._BACKUP_EXPORT_DIR, filename, as_attachment=True)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Backup not found'}), 404
|
||||||
|
@api_v3.route('/backup/<path:filename>', methods=['DELETE'])
|
||||||
|
def backup_delete(filename):
|
||||||
|
"""Delete a stored backup ZIP."""
|
||||||
|
safe = _safe_backup_path(filename)
|
||||||
|
if safe is None:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Backup not found'}), 404
|
||||||
|
# Enumerate the export directory and match by name so the unlink target is
|
||||||
|
# a filesystem-derived path rather than one constructed from user input.
|
||||||
|
try:
|
||||||
|
for entry in _pkg._BACKUP_EXPORT_DIR.iterdir():
|
||||||
|
if entry.is_file() and entry.name == safe.name:
|
||||||
|
entry.unlink()
|
||||||
|
return jsonify({'status': 'success'})
|
||||||
|
except OSError as e:
|
||||||
|
logger.error("backup_delete failed: %s", e, exc_info=True)
|
||||||
|
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
|
||||||
|
return jsonify({'status': 'error', 'message': 'Backup not found'}), 404
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,326 @@
|
|||||||
|
"""Display control, on-demand playback and preview.
|
||||||
|
|
||||||
|
Routes decorate the shared `api_v3` Blueprint from ._common, so their
|
||||||
|
endpoint names are unchanged by living here.
|
||||||
|
"""
|
||||||
|
from web_interface.blueprints.api_v3 import (
|
||||||
|
_ensure_cache_manager, _ensure_display_service_running,
|
||||||
|
_get_display_service_status, _stop_display_service, api_v3,
|
||||||
|
describe_exception, jsonify, logger, os, request, uuid,
|
||||||
|
)
|
||||||
|
import web_interface.blueprints.api_v3 as _pkg
|
||||||
|
# Read through the module rather than bound by value: tests patch these
|
||||||
|
# as module attributes, and a value binding would not see the patch.
|
||||||
|
# Several are also called from helpers that live in __init__, so the
|
||||||
|
# package is the only patch point that covers every caller.
|
||||||
|
|
||||||
|
|
||||||
|
@api_v3.route('/display/current', methods=['GET'])
|
||||||
|
def get_display_current():
|
||||||
|
"""Get current display state"""
|
||||||
|
try:
|
||||||
|
import base64
|
||||||
|
from PIL import Image
|
||||||
|
import io
|
||||||
|
|
||||||
|
snapshot_path = "/tmp/led_matrix_preview.png"
|
||||||
|
|
||||||
|
# Get display dimensions from config
|
||||||
|
try:
|
||||||
|
if api_v3.config_manager:
|
||||||
|
main_config = api_v3.config_manager.load_config()
|
||||||
|
hardware_config = main_config.get('display', {}).get('hardware', {})
|
||||||
|
cols = hardware_config.get('cols', 64)
|
||||||
|
chain_length = hardware_config.get('chain_length', 2)
|
||||||
|
rows = hardware_config.get('rows', 32)
|
||||||
|
parallel = hardware_config.get('parallel', 1)
|
||||||
|
width = cols * chain_length
|
||||||
|
height = rows * parallel
|
||||||
|
else:
|
||||||
|
width = 128
|
||||||
|
height = 64
|
||||||
|
except Exception:
|
||||||
|
width = 128
|
||||||
|
height = 64
|
||||||
|
|
||||||
|
# Try to read snapshot file
|
||||||
|
image_data = None
|
||||||
|
if os.path.exists(snapshot_path):
|
||||||
|
try:
|
||||||
|
with Image.open(snapshot_path) as img:
|
||||||
|
# Convert to PNG and encode as base64
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
img.save(buffer, format='PNG')
|
||||||
|
image_data = base64.b64encode(buffer.getvalue()).decode('utf-8')
|
||||||
|
except Exception as img_err:
|
||||||
|
# File might be being written or corrupted, return None
|
||||||
|
pass
|
||||||
|
|
||||||
|
display_data = {
|
||||||
|
'timestamp': _pkg.time.time(),
|
||||||
|
'width': width,
|
||||||
|
'height': height,
|
||||||
|
'image': image_data # Base64 encoded image data or None if unavailable
|
||||||
|
}
|
||||||
|
return jsonify({'status': 'success', 'data': display_data})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Unhandled exception', exc_info=True)
|
||||||
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||||
|
@api_v3.route('/display/modes', methods=['GET'])
|
||||||
|
def get_display_modes():
|
||||||
|
"""Every display mode that can be requested on-demand, with its plugin.
|
||||||
|
|
||||||
|
/plugins/installed carries no mode information, so anything driving the
|
||||||
|
display from outside the web UI (the Home Assistant MQTT bridge, a script)
|
||||||
|
had to read each plugin's manifest.json off disk and reimplement the
|
||||||
|
fallbacks in PluginManager.get_plugin_display_modes to do it. This is the
|
||||||
|
same list the force-display dialog offers, from the source that owns it.
|
||||||
|
|
||||||
|
Knowing each mode's plugin_id also matters because /display/on-demand/start
|
||||||
|
falls back to find_plugin_for_mode when plugin_id is omitted, and that
|
||||||
|
lookup only sees modes declared in a static manifest -- a plugin whose
|
||||||
|
modes are generated (each installed Starlark app is one) 404s there.
|
||||||
|
Sending the plugin_id from this list skips the lookup entirely.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
include_disabled: '1' to list modes of disabled plugins too. They can
|
||||||
|
still be requested on-demand -- the controller enables the plugin
|
||||||
|
for the duration -- so they are reported with enabled: false
|
||||||
|
rather than omitted.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not api_v3.plugin_manager:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500
|
||||||
|
|
||||||
|
# Discovery is lazy and normally triggered by whichever endpoint runs
|
||||||
|
# first, which is a person opening the dashboard. A caller that never
|
||||||
|
# visits it would otherwise see an empty list.
|
||||||
|
api_v3.plugin_manager.discover_plugins()
|
||||||
|
|
||||||
|
include_disabled = request.args.get('include_disabled') in ('1', 'true', 'True')
|
||||||
|
full_config = api_v3.config_manager.load_config() if api_v3.config_manager else {}
|
||||||
|
|
||||||
|
modes = []
|
||||||
|
for plugin_id, manifest in sorted(api_v3.plugin_manager.plugin_manifests.items()):
|
||||||
|
# A hand-edited or migrated config.json can hold a non-dict under a
|
||||||
|
# plugin id; DisplayController._reconcile guards the same shape, so
|
||||||
|
# it happens in practice. Without this, .get() raises AttributeError,
|
||||||
|
# the loop aborts and the endpoint answers 500 with no modes at all
|
||||||
|
# -- one bad section would blank every entity the MQTT bridge builds
|
||||||
|
# from this list.
|
||||||
|
plugin_config = full_config.get(plugin_id)
|
||||||
|
if not isinstance(plugin_config, dict):
|
||||||
|
if plugin_config is not None:
|
||||||
|
logger.warning(
|
||||||
|
"Config for plugin %r is %s, not an object; treating it as disabled",
|
||||||
|
plugin_id, type(plugin_config).__name__)
|
||||||
|
plugin_config = {}
|
||||||
|
enabled = bool(plugin_config.get('enabled', False))
|
||||||
|
if not enabled and not include_disabled:
|
||||||
|
continue
|
||||||
|
plugin_name = (manifest or {}).get('name') or plugin_id
|
||||||
|
plugin_modes = api_v3.plugin_manager.get_plugin_display_modes(plugin_id) or [plugin_id]
|
||||||
|
for mode in plugin_modes:
|
||||||
|
# A single-mode plugin's mode is the plugin, so its own name is
|
||||||
|
# the readable label. Multi-mode plugins have no per-mode name
|
||||||
|
# anywhere, so the raw mode string is the only thing to show.
|
||||||
|
modes.append({
|
||||||
|
'mode': mode,
|
||||||
|
'plugin_id': plugin_id,
|
||||||
|
'plugin_name': plugin_name,
|
||||||
|
'name': plugin_name if len(plugin_modes) == 1 else mode,
|
||||||
|
'enabled': enabled,
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({'status': 'success', 'data': {'modes': modes}})
|
||||||
|
except Exception as exc:
|
||||||
|
# describe_exception, not a bare message: test_web_error_detail.py
|
||||||
|
# enforces that every handler here returns it, because a device whose
|
||||||
|
# storage is failing otherwise answers "see logs for details" from the
|
||||||
|
# log viewer too. It redacts credentials out of the exception text.
|
||||||
|
# CodeQL flags this as stack-trace exposure across all ~75 handlers;
|
||||||
|
# it is the project's deliberate, reviewed trade-off.
|
||||||
|
logger.error('Error in get_display_modes', exc_info=True)
|
||||||
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(exc)}), 500
|
||||||
|
@api_v3.route('/display/on-demand/status', methods=['GET'])
|
||||||
|
def get_on_demand_status():
|
||||||
|
"""Return the current on-demand display state."""
|
||||||
|
try:
|
||||||
|
cache = _ensure_cache_manager()
|
||||||
|
state = cache.get('display_on_demand_state', max_age=120)
|
||||||
|
if state is None:
|
||||||
|
state = {
|
||||||
|
'active': False,
|
||||||
|
'status': 'idle',
|
||||||
|
'last_updated': None
|
||||||
|
}
|
||||||
|
service_status = _get_display_service_status()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'data': {
|
||||||
|
'state': state,
|
||||||
|
'service': service_status
|
||||||
|
}
|
||||||
|
})
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error('Error in get_on_demand_status', exc_info=True)
|
||||||
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(exc)}), 500
|
||||||
|
@api_v3.route('/display/on-demand/start', methods=['POST'])
|
||||||
|
def start_on_demand_display():
|
||||||
|
"""Request the display controller to run a specific plugin on-demand."""
|
||||||
|
try:
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
plugin_id = data.get('plugin_id')
|
||||||
|
mode = data.get('mode')
|
||||||
|
duration = data.get('duration')
|
||||||
|
pinned = bool(data.get('pinned', False))
|
||||||
|
start_service = data.get('start_service', True)
|
||||||
|
|
||||||
|
if not plugin_id and not mode:
|
||||||
|
return jsonify({'status': 'error', 'message': 'plugin_id or mode is required'}), 400
|
||||||
|
|
||||||
|
resolved_plugin = plugin_id
|
||||||
|
resolved_mode = mode
|
||||||
|
|
||||||
|
if api_v3.plugin_manager:
|
||||||
|
if resolved_plugin and resolved_plugin not in api_v3.plugin_manager.plugin_manifests:
|
||||||
|
return jsonify({'status': 'error', 'message': f'Plugin {resolved_plugin} not found'}), 404
|
||||||
|
|
||||||
|
if resolved_plugin and not resolved_mode:
|
||||||
|
modes = api_v3.plugin_manager.get_plugin_display_modes(resolved_plugin)
|
||||||
|
resolved_mode = modes[0] if modes else resolved_plugin
|
||||||
|
elif resolved_mode and not resolved_plugin:
|
||||||
|
resolved_plugin = api_v3.plugin_manager.find_plugin_for_mode(resolved_mode)
|
||||||
|
if not resolved_plugin:
|
||||||
|
return jsonify({'status': 'error', 'message': f'Mode {resolved_mode} not found'}), 404
|
||||||
|
|
||||||
|
# Note: On-demand can work with disabled plugins - the display controller
|
||||||
|
# will temporarily enable them during initialization if needed
|
||||||
|
# We don't block the request here, but log it for debugging
|
||||||
|
if api_v3.config_manager and resolved_plugin:
|
||||||
|
config = api_v3.config_manager.load_config()
|
||||||
|
plugin_config = config.get(resolved_plugin, {})
|
||||||
|
if 'enabled' in plugin_config and not plugin_config.get('enabled', False):
|
||||||
|
logger.info(
|
||||||
|
"On-demand request for disabled plugin '%s' - will be temporarily enabled",
|
||||||
|
resolved_plugin,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set the on-demand request in cache FIRST (before starting service)
|
||||||
|
# This ensures the request is available when the service starts/restarts
|
||||||
|
cache = _ensure_cache_manager()
|
||||||
|
request_id = data.get('request_id') or str(uuid.uuid4())
|
||||||
|
request_payload = {
|
||||||
|
'request_id': request_id,
|
||||||
|
'action': 'start',
|
||||||
|
'plugin_id': resolved_plugin,
|
||||||
|
'mode': resolved_mode,
|
||||||
|
'duration': duration,
|
||||||
|
'pinned': pinned,
|
||||||
|
'timestamp': _pkg.time.time()
|
||||||
|
}
|
||||||
|
cache.set('display_on_demand_request', request_payload)
|
||||||
|
|
||||||
|
# Check if display service is running (or will be started)
|
||||||
|
service_status = _get_display_service_status()
|
||||||
|
service_was_running = service_status.get('active', False)
|
||||||
|
|
||||||
|
# Stop the display service first to ensure clean state when we will restart it
|
||||||
|
if service_was_running and start_service:
|
||||||
|
import time as time_module
|
||||||
|
logger.debug("Stopping display service before starting on-demand mode")
|
||||||
|
_stop_display_service()
|
||||||
|
# Wait a brief moment for the service to fully stop
|
||||||
|
time_module.sleep(1.5)
|
||||||
|
logger.debug("Display service stopped, now starting with on-demand request")
|
||||||
|
|
||||||
|
if not service_status.get('active') and not start_service:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Display service is not running. Please start the display service or enable "Start Service" option.',
|
||||||
|
'service_status': service_status
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
service_result = None
|
||||||
|
if start_service:
|
||||||
|
service_result = _ensure_display_service_running()
|
||||||
|
# Check if service actually started
|
||||||
|
if service_result and not service_result.get('active'):
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Failed to start display service. Please check service logs or start it manually.',
|
||||||
|
'service_result': service_result
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
# Service was restarted (or started fresh) with on-demand request in cache
|
||||||
|
# The display controller will read the request during initialization or when it polls
|
||||||
|
|
||||||
|
response_data = {
|
||||||
|
'request_id': request_id,
|
||||||
|
'plugin_id': resolved_plugin,
|
||||||
|
'mode': resolved_mode,
|
||||||
|
'duration': duration,
|
||||||
|
'pinned': pinned,
|
||||||
|
'service': service_result
|
||||||
|
}
|
||||||
|
return jsonify({'status': 'success', 'data': response_data})
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error('Error in start_on_demand_display', exc_info=True)
|
||||||
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(exc)}), 500
|
||||||
|
@api_v3.route('/display/on-demand/stop', methods=['POST'])
|
||||||
|
def stop_on_demand_display():
|
||||||
|
"""Request the display controller to stop on-demand mode."""
|
||||||
|
try:
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
stop_service = data.get('stop_service', False)
|
||||||
|
|
||||||
|
# Set the stop request in cache FIRST
|
||||||
|
# The display controller will poll this and restart without the on-demand filter
|
||||||
|
cache = _ensure_cache_manager()
|
||||||
|
request_id = data.get('request_id') or str(uuid.uuid4())
|
||||||
|
request_payload = {
|
||||||
|
'request_id': request_id,
|
||||||
|
'action': 'stop',
|
||||||
|
'timestamp': _pkg.time.time()
|
||||||
|
}
|
||||||
|
cache.set('display_on_demand_request', request_payload)
|
||||||
|
|
||||||
|
# Note: The display controller's _clear_on_demand() will handle the restart
|
||||||
|
# to restore normal operation with all plugins
|
||||||
|
|
||||||
|
service_result = None
|
||||||
|
if stop_service:
|
||||||
|
service_result = _stop_display_service()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'data': {
|
||||||
|
'request_id': request_id,
|
||||||
|
'service': service_result
|
||||||
|
}
|
||||||
|
})
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error('Error in stop_on_demand_display', exc_info=True)
|
||||||
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(exc)}), 500
|
||||||
|
@api_v3.route('/display/current-status', methods=['GET'])
|
||||||
|
def get_current_display_status():
|
||||||
|
"""Return the display mode/plugin currently intended to be shown.
|
||||||
|
|
||||||
|
Published by the display process (display_controller._publish_current_mode_state)
|
||||||
|
to the shared cache whenever the active mode changes, so the web UI (e.g. the
|
||||||
|
System Logs page) can show what's on screen without querying the display
|
||||||
|
process directly.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
cache = _ensure_cache_manager()
|
||||||
|
state = cache.get('display_current_state', max_age=120)
|
||||||
|
if state is None:
|
||||||
|
state = {
|
||||||
|
'mode': None,
|
||||||
|
'plugin_id': None,
|
||||||
|
'last_updated': None,
|
||||||
|
}
|
||||||
|
return jsonify({'status': 'success', 'data': state})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Error in get_current_display_status', exc_info=True)
|
||||||
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
"""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. validate_file_upload takes max_size_mb but only
|
||||||
|
# checks the filename/extension with it -- it never looks at the
|
||||||
|
# actual upload size, so the size limit below is enforced separately
|
||||||
|
# before the file is saved (same pattern as the .star upload above).
|
||||||
|
MAX_FONT_SIZE_MB = 10
|
||||||
|
is_valid, error_msg = validate_file_upload(
|
||||||
|
font_file.filename,
|
||||||
|
max_size_mb=MAX_FONT_SIZE_MB,
|
||||||
|
allowed_extensions=['.ttf', '.otf', '.bdf']
|
||||||
|
)
|
||||||
|
if not is_valid:
|
||||||
|
return jsonify({'status': 'error', 'message': error_msg}), 400
|
||||||
|
|
||||||
|
# Check file size (stated limit is MAX_FONT_SIZE_MB)
|
||||||
|
font_file.seek(0, 2) # Seek to end
|
||||||
|
file_size = font_file.tell()
|
||||||
|
font_file.seek(0) # Reset to beginning
|
||||||
|
max_font_size_bytes = MAX_FONT_SIZE_MB * 1024 * 1024
|
||||||
|
if file_size > max_font_size_bytes:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'File too large (max {MAX_FONT_SIZE_MB}MB, got {file_size / 1024 / 1024:.1f}MB)'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
font_family = request.form.get('font_family', '')
|
||||||
|
|
||||||
|
if not font_family:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Font file and family name required'}), 400
|
||||||
|
|
||||||
|
# Validate font family name
|
||||||
|
if not font_family.replace('_', '').replace('-', '').isalnum():
|
||||||
|
return jsonify({'status': 'error', 'message': 'Font family name must contain only letters, numbers, underscores, and hyphens'}), 400
|
||||||
|
|
||||||
|
# Save the font file to assets/fonts directory
|
||||||
|
fonts_dir = PROJECT_ROOT / "assets" / "fonts"
|
||||||
|
fonts_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Create filename from family name
|
||||||
|
original_ext = os.path.splitext(font_file.filename)[1].lower()
|
||||||
|
safe_filename = f"{font_family}{original_ext}"
|
||||||
|
filepath = fonts_dir / safe_filename
|
||||||
|
|
||||||
|
# Check if file already exists
|
||||||
|
if filepath.exists():
|
||||||
|
return jsonify({'status': 'error', 'message': f'Font with name {font_family} already exists'}), 400
|
||||||
|
|
||||||
|
# Save the file
|
||||||
|
font_file.save(str(filepath))
|
||||||
|
|
||||||
|
# Clear font catalog cache
|
||||||
|
try:
|
||||||
|
from web_interface.cache import delete_cached
|
||||||
|
delete_cached('fonts_catalog')
|
||||||
|
except ImportError as e:
|
||||||
|
logger.warning("[FontUpload] Cache module not available: %s", e)
|
||||||
|
except Exception:
|
||||||
|
logger.error("[FontUpload] Failed to clear fonts_catalog cache", exc_info=True)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': f'Font {font_family} uploaded successfully',
|
||||||
|
'font_family': font_family,
|
||||||
|
'filename': safe_filename,
|
||||||
|
'path': f'assets/fonts/{safe_filename}'
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Unhandled exception', exc_info=True)
|
||||||
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||||
|
@api_v3.route('/fonts/preview', methods=['GET'])
|
||||||
|
def get_font_preview() -> tuple[Response, int] | Response:
|
||||||
|
"""Generate a preview image of text rendered with a specific font"""
|
||||||
|
try:
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
import io
|
||||||
|
import base64
|
||||||
|
|
||||||
|
# Limits to prevent DoS via large image generation on constrained devices
|
||||||
|
MAX_TEXT_CHARS = 100
|
||||||
|
MAX_TEXT_LINES = 3
|
||||||
|
MAX_DIM = 1024 # Max width or height in pixels
|
||||||
|
MAX_PIXELS = 500000 # Max total pixels (e.g., ~700x700)
|
||||||
|
|
||||||
|
font_filename = request.args.get('font', '')
|
||||||
|
text = request.args.get('text', 'Sample Text 123')
|
||||||
|
bg_color = request.args.get('bg', '000000')
|
||||||
|
fg_color = request.args.get('fg', 'ffffff')
|
||||||
|
|
||||||
|
# Validate text length and line count early
|
||||||
|
if len(text) > MAX_TEXT_CHARS:
|
||||||
|
return jsonify({'status': 'error', 'message': f'Text exceeds maximum length of {MAX_TEXT_CHARS} characters'}), 400
|
||||||
|
if text.count('\n') >= MAX_TEXT_LINES:
|
||||||
|
return jsonify({'status': 'error', 'message': f'Text exceeds maximum of {MAX_TEXT_LINES} lines'}), 400
|
||||||
|
|
||||||
|
# Safe integer parsing for size
|
||||||
|
try:
|
||||||
|
size = int(request.args.get('size', 12))
|
||||||
|
except (ValueError, TypeError, OverflowError):
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid font size'}), 400
|
||||||
|
|
||||||
|
if not font_filename:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Font filename required'}), 400
|
||||||
|
|
||||||
|
# Validate size
|
||||||
|
if size < 4 or size > 72:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Font size must be between 4 and 72'}), 400
|
||||||
|
|
||||||
|
# Security: Validate font_filename to prevent path traversal
|
||||||
|
# Only allow alphanumeric, hyphen, underscore, and dot (for extension)
|
||||||
|
safe_name = Path(font_filename).name # Strip any directory components
|
||||||
|
if safe_name != font_filename or '..' in font_filename:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid font filename'}), 400
|
||||||
|
|
||||||
|
# Validate extension
|
||||||
|
allowed_extensions = ['.ttf', '.otf', '.bdf']
|
||||||
|
has_valid_ext = any(safe_name.lower().endswith(ext) for ext in allowed_extensions)
|
||||||
|
name_without_ext = safe_name.rsplit('.', 1)[0] if '.' in safe_name else safe_name
|
||||||
|
|
||||||
|
# Find the font file
|
||||||
|
fonts_dir = PROJECT_ROOT / "assets" / "fonts"
|
||||||
|
if not fonts_dir.exists():
|
||||||
|
return jsonify({'status': 'error', 'message': 'Fonts directory not found'}), 404
|
||||||
|
|
||||||
|
font_path = fonts_dir / safe_name
|
||||||
|
|
||||||
|
if not font_path.exists() and not has_valid_ext:
|
||||||
|
# Try finding by family name (without extension)
|
||||||
|
for ext in allowed_extensions:
|
||||||
|
potential_path = fonts_dir / f"{name_without_ext}{ext}"
|
||||||
|
if potential_path.exists():
|
||||||
|
font_path = potential_path
|
||||||
|
break
|
||||||
|
|
||||||
|
# Final security check: ensure path is within fonts_dir
|
||||||
|
try:
|
||||||
|
font_path.resolve().relative_to(fonts_dir.resolve())
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid font path'}), 400
|
||||||
|
|
||||||
|
if not font_path.exists():
|
||||||
|
return jsonify({'status': 'error', 'message': f'Font file not found: {font_filename}'}), 404
|
||||||
|
|
||||||
|
# Parse colors
|
||||||
|
try:
|
||||||
|
bg_rgb = tuple(int(bg_color[i:i+2], 16) for i in (0, 2, 4))
|
||||||
|
fg_rgb = tuple(int(fg_color[i:i+2], 16) for i in (0, 2, 4))
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
bg_rgb = (0, 0, 0)
|
||||||
|
fg_rgb = (255, 255, 255)
|
||||||
|
|
||||||
|
# Load font
|
||||||
|
font = None
|
||||||
|
if str(font_path).endswith('.bdf'):
|
||||||
|
# BDF fonts require complex per-glyph rendering via freetype
|
||||||
|
# Return explicit error rather than showing misleading preview with default font
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'BDF font preview not supported. BDF fonts will render correctly on the LED matrix.'
|
||||||
|
}), 400
|
||||||
|
else:
|
||||||
|
# TTF/OTF fonts
|
||||||
|
try:
|
||||||
|
font = ImageFont.truetype(str(font_path), size)
|
||||||
|
except (IOError, OSError) as e:
|
||||||
|
# IOError/OSError raised for invalid/corrupt font files
|
||||||
|
logger.warning("[FontPreview] Failed to load font %s: %s", font_path, e)
|
||||||
|
font = ImageFont.load_default()
|
||||||
|
|
||||||
|
# Calculate text size
|
||||||
|
temp_img = Image.new('RGB', (1, 1))
|
||||||
|
temp_draw = ImageDraw.Draw(temp_img)
|
||||||
|
bbox = temp_draw.textbbox((0, 0), text, font=font)
|
||||||
|
text_width = bbox[2] - bbox[0]
|
||||||
|
text_height = bbox[3] - bbox[1]
|
||||||
|
|
||||||
|
# Create image with padding
|
||||||
|
padding = 10
|
||||||
|
img_width = max(text_width + padding * 2, 100)
|
||||||
|
img_height = max(text_height + padding * 2, 30)
|
||||||
|
|
||||||
|
# Validate resulting image size to prevent memory/CPU spikes
|
||||||
|
if img_width > MAX_DIM or img_height > MAX_DIM:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Requested image too large'}), 400
|
||||||
|
if img_width * img_height > MAX_PIXELS:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Requested image too large'}), 400
|
||||||
|
|
||||||
|
img = Image.new('RGB', (img_width, img_height), bg_rgb)
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
# Center text
|
||||||
|
x = (img_width - text_width) // 2
|
||||||
|
y = (img_height - text_height) // 2
|
||||||
|
|
||||||
|
draw.text((x, y), text, font=font, fill=fg_rgb)
|
||||||
|
|
||||||
|
# Convert to base64
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
img.save(buffer, format='PNG')
|
||||||
|
buffer.seek(0)
|
||||||
|
img_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'data': {
|
||||||
|
'image': f'data:image/png;base64,{img_base64}',
|
||||||
|
'width': img_width,
|
||||||
|
'height': img_height
|
||||||
|
}
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Unhandled exception', exc_info=True)
|
||||||
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||||
|
@api_v3.route('/fonts/<font_family>', methods=['DELETE'])
|
||||||
|
def delete_font(font_family: str) -> tuple[Response, int] | Response:
|
||||||
|
"""Delete a user-uploaded font file"""
|
||||||
|
try:
|
||||||
|
# Security: Validate font_family to prevent path traversal
|
||||||
|
# Reject if it contains path separators or ..
|
||||||
|
if '..' in font_family or '/' in font_family or '\\' in font_family:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid font family name'}), 400
|
||||||
|
|
||||||
|
# Only allow safe characters: alphanumeric, hyphen, underscore, dot
|
||||||
|
if not re.match(r'^[a-zA-Z0-9_\-\.]+$', font_family):
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid font family name'}), 400
|
||||||
|
|
||||||
|
# Check if this is a system font (uses module-level SYSTEM_FONTS frozenset)
|
||||||
|
if font_family.lower() in SYSTEM_FONTS:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Cannot delete system fonts'}), 403
|
||||||
|
|
||||||
|
# Find and delete the font file
|
||||||
|
fonts_dir = PROJECT_ROOT / "assets" / "fonts"
|
||||||
|
|
||||||
|
# Ensure fonts directory exists
|
||||||
|
if not fonts_dir.exists() or not fonts_dir.is_dir():
|
||||||
|
return jsonify({'status': 'error', 'message': 'Fonts directory not found'}), 404
|
||||||
|
|
||||||
|
deleted = False
|
||||||
|
deleted_filename = None
|
||||||
|
|
||||||
|
# Only try valid font extensions (no empty string to avoid matching directories)
|
||||||
|
for ext in ['.ttf', '.otf', '.bdf']:
|
||||||
|
potential_path = fonts_dir / f"{font_family}{ext}"
|
||||||
|
|
||||||
|
# Security: Verify path is within fonts_dir
|
||||||
|
try:
|
||||||
|
potential_path.resolve().relative_to(fonts_dir.resolve())
|
||||||
|
except ValueError:
|
||||||
|
continue # Path escapes fonts_dir, skip
|
||||||
|
|
||||||
|
if potential_path.exists() and potential_path.is_file():
|
||||||
|
potential_path.unlink()
|
||||||
|
deleted = True
|
||||||
|
deleted_filename = f"{font_family}{ext}"
|
||||||
|
break
|
||||||
|
|
||||||
|
if not deleted:
|
||||||
|
# Try case-insensitive match within fonts directory
|
||||||
|
font_family_lower = font_family.lower()
|
||||||
|
for filename in os.listdir(fonts_dir):
|
||||||
|
# Only consider files with valid font extensions
|
||||||
|
if not any(filename.lower().endswith(ext) for ext in ['.ttf', '.otf', '.bdf']):
|
||||||
|
continue
|
||||||
|
|
||||||
|
name_without_ext = os.path.splitext(filename)[0]
|
||||||
|
if name_without_ext.lower() == font_family_lower:
|
||||||
|
filepath = fonts_dir / filename
|
||||||
|
|
||||||
|
# Security: Verify path is within fonts_dir
|
||||||
|
try:
|
||||||
|
filepath.resolve().relative_to(fonts_dir.resolve())
|
||||||
|
except ValueError:
|
||||||
|
continue # Path escapes fonts_dir, skip
|
||||||
|
|
||||||
|
if filepath.is_file():
|
||||||
|
filepath.unlink()
|
||||||
|
deleted = True
|
||||||
|
deleted_filename = filename
|
||||||
|
break
|
||||||
|
|
||||||
|
if not deleted:
|
||||||
|
return jsonify({'status': 'error', 'message': f'Font not found: {font_family}'}), 404
|
||||||
|
|
||||||
|
# Clear font catalog cache
|
||||||
|
try:
|
||||||
|
from web_interface.cache import delete_cached
|
||||||
|
delete_cached('fonts_catalog')
|
||||||
|
except ImportError as e:
|
||||||
|
logger.warning("[FontDelete] Cache module not available: %s", e)
|
||||||
|
except Exception:
|
||||||
|
logger.error("[FontDelete] Failed to clear fonts_catalog cache", exc_info=True)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': f'Font {deleted_filename} deleted successfully'
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Unhandled exception', exc_info=True)
|
||||||
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
"""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, _SUDO, _get_display_service_status, api_v3,
|
||||||
|
describe_exception, error_response, get_error_aggregator, json, jsonify,
|
||||||
|
logger, os, request, subprocess, success_response,
|
||||||
|
)
|
||||||
|
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
|
||||||
|
)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,712 @@
|
|||||||
|
"""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, _find_pixlet_binary, _install_star_file, _standalone_render_starlark_app,
|
||||||
|
_starlark_github_token, _starlark_manifest_lock, _validate_and_sanitize_app_id,
|
||||||
|
_validate_starlark_app_path, _validate_timing_value, api_v3, describe_exception, json, jsonify,
|
||||||
|
logger, os, request, shutil, subprocess, tempfile,
|
||||||
|
)
|
||||||
|
import web_interface.blueprints.api_v3 as _pkg
|
||||||
|
# Read through the module rather than bound by value: tests patch these
|
||||||
|
# as module attributes, and a value binding would not see the patch.
|
||||||
|
# Several are also called from helpers that live in __init__, so the
|
||||||
|
# package is the only patch point that covers every caller.
|
||||||
|
|
||||||
|
|
||||||
|
@api_v3.route('/starlark/status', methods=['GET'])
|
||||||
|
def get_starlark_status():
|
||||||
|
"""Get Starlark plugin status and Pixlet availability."""
|
||||||
|
try:
|
||||||
|
starlark_plugin = _pkg._get_starlark_plugin()
|
||||||
|
if starlark_plugin:
|
||||||
|
info = starlark_plugin.get_info()
|
||||||
|
magnify_info = starlark_plugin.get_magnify_recommendation()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'pixlet_available': info.get('pixlet_available', False),
|
||||||
|
'pixlet_version': info.get('pixlet_version'),
|
||||||
|
'installed_apps': info.get('installed_apps', 0),
|
||||||
|
'enabled_apps': info.get('enabled_apps', 0),
|
||||||
|
'current_app': info.get('current_app'),
|
||||||
|
'plugin_enabled': starlark_plugin.enabled,
|
||||||
|
'display_info': magnify_info
|
||||||
|
})
|
||||||
|
|
||||||
|
# Plugin not loaded - check Pixlet availability via shared resolver
|
||||||
|
# (respects user-configured pixlet_path, bundled binary, and system PATH)
|
||||||
|
full_config = api_v3.config_manager.load_config() if api_v3.config_manager else {}
|
||||||
|
pixlet_path = _find_pixlet_binary(full_config.get('starlark-apps', {}).get('pixlet_path'))
|
||||||
|
pixlet_available = pixlet_path is not None
|
||||||
|
|
||||||
|
# Read app counts from manifest
|
||||||
|
manifest = _pkg._read_starlark_manifest()
|
||||||
|
apps = manifest.get('apps', {})
|
||||||
|
installed_count = len(apps)
|
||||||
|
enabled_count = sum(1 for a in apps.values() if a.get('enabled', True))
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'pixlet_available': pixlet_available,
|
||||||
|
'pixlet_version': None,
|
||||||
|
'installed_apps': installed_count,
|
||||||
|
'enabled_apps': enabled_count,
|
||||||
|
'plugin_enabled': True,
|
||||||
|
'plugin_loaded': False,
|
||||||
|
'display_info': {}
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("[Starlark] get_starlark_status failed")
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to get Starlark status', 'details': describe_exception(e)}), 500
|
||||||
|
@api_v3.route('/starlark/install-pixlet', methods=['POST'])
|
||||||
|
def install_pixlet():
|
||||||
|
"""Download and install Pixlet binary."""
|
||||||
|
try:
|
||||||
|
script_path = PROJECT_ROOT / 'scripts' / 'download_pixlet.sh'
|
||||||
|
if not script_path.exists():
|
||||||
|
return jsonify({'status': 'error', 'message': 'Installation script not found'}), 404
|
||||||
|
|
||||||
|
os.chmod(script_path, 0o755)
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
[str(script_path)],
|
||||||
|
cwd=str(PROJECT_ROOT),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=300
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
logger.info("Pixlet downloaded successfully")
|
||||||
|
return jsonify({'status': 'success', 'message': 'Pixlet installed successfully!', 'output': result.stdout})
|
||||||
|
else:
|
||||||
|
return jsonify({'status': 'error', 'message': f'Failed to download Pixlet: {result.stderr}'}), 500
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired as err:
|
||||||
|
logger.exception("[Starlark] Pixlet download timed out")
|
||||||
|
return jsonify({'status': 'error', 'message': 'Download timed out',
|
||||||
|
'details': describe_exception(err)}), 500
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("[Starlark] install_pixlet failed")
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to install Pixlet', 'details': describe_exception(e)}), 500
|
||||||
|
@api_v3.route('/starlark/apps', methods=['GET'])
|
||||||
|
def get_starlark_apps():
|
||||||
|
"""List all installed Starlark apps."""
|
||||||
|
try:
|
||||||
|
starlark_plugin = _pkg._get_starlark_plugin()
|
||||||
|
if starlark_plugin:
|
||||||
|
apps_list = []
|
||||||
|
for app_id, app_instance in starlark_plugin.apps.items():
|
||||||
|
apps_list.append({
|
||||||
|
'id': app_id,
|
||||||
|
'name': app_instance.manifest.get('name', app_id),
|
||||||
|
'enabled': app_instance.is_enabled(),
|
||||||
|
'has_frames': app_instance.frames is not None,
|
||||||
|
'render_interval': app_instance.get_render_interval(),
|
||||||
|
'display_duration': app_instance.get_display_duration(),
|
||||||
|
'config': app_instance.config,
|
||||||
|
'has_schema': app_instance.schema is not None,
|
||||||
|
'last_render_time': app_instance.last_render_time
|
||||||
|
})
|
||||||
|
return jsonify({'status': 'success', 'apps': apps_list, 'count': len(apps_list)})
|
||||||
|
|
||||||
|
# Standalone: read manifest from disk
|
||||||
|
manifest = _pkg._read_starlark_manifest()
|
||||||
|
apps_list = []
|
||||||
|
for app_id, app_data in manifest.get('apps', {}).items():
|
||||||
|
apps_list.append({
|
||||||
|
'id': app_id,
|
||||||
|
'name': app_data.get('name', app_id),
|
||||||
|
'enabled': app_data.get('enabled', True),
|
||||||
|
'has_frames': False,
|
||||||
|
'render_interval': app_data.get('render_interval', 300),
|
||||||
|
'display_duration': app_data.get('display_duration', 15),
|
||||||
|
'config': app_data.get('config', {}),
|
||||||
|
'has_schema': False,
|
||||||
|
'last_render_time': None
|
||||||
|
})
|
||||||
|
return jsonify({'status': 'success', 'apps': apps_list, 'count': len(apps_list)})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("[Starlark] get_starlark_apps failed")
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to get Starlark apps', 'details': describe_exception(e)}), 500
|
||||||
|
@api_v3.route('/starlark/apps/<app_id>', methods=['GET'])
|
||||||
|
def get_starlark_app(app_id):
|
||||||
|
"""Get details for a specific Starlark app."""
|
||||||
|
try:
|
||||||
|
# Validate app_id before any filesystem access
|
||||||
|
app_dir, error_msg = _validate_starlark_app_path(app_id)
|
||||||
|
if error_msg:
|
||||||
|
return jsonify({'status': 'error', 'message': error_msg}), 400
|
||||||
|
|
||||||
|
starlark_plugin = _pkg._get_starlark_plugin()
|
||||||
|
if starlark_plugin:
|
||||||
|
app = starlark_plugin.apps.get(app_id)
|
||||||
|
if not app:
|
||||||
|
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'app': {
|
||||||
|
'id': app_id,
|
||||||
|
'name': app.manifest.get('name', app_id),
|
||||||
|
'enabled': app.is_enabled(),
|
||||||
|
'config': app.config,
|
||||||
|
'schema': app.schema,
|
||||||
|
'render_interval': app.get_render_interval(),
|
||||||
|
'display_duration': app.get_display_duration(),
|
||||||
|
'has_frames': app.frames is not None,
|
||||||
|
'frame_count': len(app.frames) if app.frames else 0,
|
||||||
|
'last_render_time': app.last_render_time,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
# Standalone: read from manifest
|
||||||
|
manifest = _pkg._read_starlark_manifest()
|
||||||
|
app_data = manifest.get('apps', {}).get(app_id)
|
||||||
|
if not app_data:
|
||||||
|
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
|
||||||
|
|
||||||
|
# Load schema from schema.json if it exists (path already validated above)
|
||||||
|
schema = None
|
||||||
|
schema_file = app_dir / 'schema.json'
|
||||||
|
if schema_file.exists():
|
||||||
|
try:
|
||||||
|
with open(schema_file, 'r') as f:
|
||||||
|
schema = json.load(f)
|
||||||
|
except (OSError, json.JSONDecodeError) as e:
|
||||||
|
logger.warning(f"Failed to load schema for {app_id}: {e}")
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'app': {
|
||||||
|
'id': app_id,
|
||||||
|
'name': app_data.get('name', app_id),
|
||||||
|
'enabled': app_data.get('enabled', True),
|
||||||
|
'config': app_data.get('config', {}),
|
||||||
|
'schema': schema,
|
||||||
|
'render_interval': app_data.get('render_interval', 300),
|
||||||
|
'display_duration': app_data.get('display_duration', 15),
|
||||||
|
'has_frames': False,
|
||||||
|
'frame_count': 0,
|
||||||
|
'last_render_time': None,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("[Starlark] get_starlark_app failed")
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to get Starlark app', 'details': describe_exception(e)}), 500
|
||||||
|
@api_v3.route('/starlark/upload', methods=['POST'])
|
||||||
|
def upload_starlark_app():
|
||||||
|
"""Upload and install a new Starlark app."""
|
||||||
|
try:
|
||||||
|
if 'file' not in request.files:
|
||||||
|
return jsonify({'status': 'error', 'message': 'No file uploaded'}), 400
|
||||||
|
|
||||||
|
file = request.files['file']
|
||||||
|
if not file.filename or not file.filename.endswith('.star'):
|
||||||
|
return jsonify({'status': 'error', 'message': 'File must have .star extension'}), 400
|
||||||
|
|
||||||
|
# Check file size (limit to 5MB for .star files)
|
||||||
|
file.seek(0, 2) # Seek to end
|
||||||
|
file_size = file.tell()
|
||||||
|
file.seek(0) # Reset to beginning
|
||||||
|
MAX_STAR_SIZE = 5 * 1024 * 1024 # 5MB
|
||||||
|
if file_size > MAX_STAR_SIZE:
|
||||||
|
return jsonify({'status': 'error', 'message': f'File too large (max 5MB, got {file_size/1024/1024:.1f}MB)'}), 400
|
||||||
|
|
||||||
|
app_name = request.form.get('name')
|
||||||
|
app_id_input = request.form.get('app_id')
|
||||||
|
filename_base = file.filename.replace('.star', '') if file.filename else None
|
||||||
|
app_id, app_id_error = _validate_and_sanitize_app_id(app_id_input, fallback_source=filename_base)
|
||||||
|
if app_id_error:
|
||||||
|
return jsonify({'status': 'error', 'message': f'Invalid app_id: {app_id_error}'}), 400
|
||||||
|
|
||||||
|
render_interval_input = request.form.get('render_interval')
|
||||||
|
render_interval = 300
|
||||||
|
if render_interval_input is not None:
|
||||||
|
render_interval, err = _validate_timing_value(render_interval_input, 'render_interval')
|
||||||
|
if err:
|
||||||
|
return jsonify({'status': 'error', 'message': err}), 400
|
||||||
|
render_interval = render_interval or 300
|
||||||
|
|
||||||
|
display_duration_input = request.form.get('display_duration')
|
||||||
|
display_duration = 15
|
||||||
|
if display_duration_input is not None:
|
||||||
|
display_duration, err = _validate_timing_value(display_duration_input, 'display_duration')
|
||||||
|
if err:
|
||||||
|
return jsonify({'status': 'error', 'message': err}), 400
|
||||||
|
display_duration = display_duration or 15
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False, suffix='.star') as tmp:
|
||||||
|
file.save(tmp.name)
|
||||||
|
temp_path = tmp.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
metadata = {'name': app_name or app_id, 'render_interval': render_interval, 'display_duration': display_duration}
|
||||||
|
starlark_plugin = _pkg._get_starlark_plugin()
|
||||||
|
if starlark_plugin:
|
||||||
|
success = starlark_plugin.install_app(app_id, temp_path, metadata)
|
||||||
|
else:
|
||||||
|
success = _install_star_file(app_id, temp_path, metadata)
|
||||||
|
if success:
|
||||||
|
return jsonify({'status': 'success', 'message': f'App installed: {app_id}', 'app_id': app_id})
|
||||||
|
else:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to install app'}), 500
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(temp_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
except (OSError, IOError) as err:
|
||||||
|
# This used to withhold the detail because it names absolute paths on
|
||||||
|
# the device. A full disk and a bad permission are indistinguishable
|
||||||
|
# without it, though, and describe_exception redacts credentials and
|
||||||
|
# truncates -- the same trade-off every other handler here makes.
|
||||||
|
logger.exception("[Starlark] File error uploading starlark app: %s", err)
|
||||||
|
return jsonify({'status': 'error', 'message': 'File error during upload',
|
||||||
|
'details': describe_exception(err)}), 500
|
||||||
|
except ImportError as err:
|
||||||
|
logger.exception("[Starlark] Module load error uploading starlark app: %s", err)
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to load app module', 'details': describe_exception(err)}), 500
|
||||||
|
except Exception as err:
|
||||||
|
logger.exception("[Starlark] Unexpected error uploading starlark app: %s", err)
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to upload app', 'details': describe_exception(err)}), 500
|
||||||
|
@api_v3.route('/starlark/apps/<app_id>', methods=['DELETE'])
|
||||||
|
def uninstall_starlark_app(app_id):
|
||||||
|
"""Uninstall a Starlark app."""
|
||||||
|
try:
|
||||||
|
# Validate app_id before any filesystem access
|
||||||
|
app_dir, error_msg = _validate_starlark_app_path(app_id)
|
||||||
|
if error_msg:
|
||||||
|
return jsonify({'status': 'error', 'message': error_msg}), 400
|
||||||
|
|
||||||
|
starlark_plugin = _pkg._get_starlark_plugin()
|
||||||
|
if starlark_plugin:
|
||||||
|
success = starlark_plugin.uninstall_app(app_id)
|
||||||
|
else:
|
||||||
|
# Standalone: remove app dir and manifest entry. app_dir is the
|
||||||
|
# path _validate_starlark_app_path checked, not a fresh join.
|
||||||
|
import shutil
|
||||||
|
if app_dir.exists():
|
||||||
|
shutil.rmtree(app_dir)
|
||||||
|
with _starlark_manifest_lock():
|
||||||
|
manifest = _pkg._read_starlark_manifest()
|
||||||
|
manifest.get('apps', {}).pop(app_id, None)
|
||||||
|
success = _pkg._write_starlark_manifest(manifest)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return jsonify({'status': 'success', 'message': f'App uninstalled: {app_id}'})
|
||||||
|
else:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to uninstall app'}), 500
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("[Starlark] uninstall_starlark_app failed")
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to uninstall Starlark app', 'details': describe_exception(e)}), 500
|
||||||
|
@api_v3.route('/starlark/apps/<app_id>/config', methods=['GET'])
|
||||||
|
def get_starlark_app_config(app_id):
|
||||||
|
"""Get configuration for a Starlark app."""
|
||||||
|
try:
|
||||||
|
# Validate app_id before any filesystem access
|
||||||
|
app_dir, error_msg = _validate_starlark_app_path(app_id)
|
||||||
|
if error_msg:
|
||||||
|
return jsonify({'status': 'error', 'message': error_msg}), 400
|
||||||
|
|
||||||
|
starlark_plugin = _pkg._get_starlark_plugin()
|
||||||
|
if starlark_plugin:
|
||||||
|
app = starlark_plugin.apps.get(app_id)
|
||||||
|
if not app:
|
||||||
|
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
|
||||||
|
return jsonify({'status': 'success', 'config': app.config, 'schema': app.schema})
|
||||||
|
|
||||||
|
# Standalone: read from config.json. app_dir is the path
|
||||||
|
# _validate_starlark_app_path checked, not a fresh join.
|
||||||
|
config_file = app_dir / "config.json"
|
||||||
|
|
||||||
|
if not app_dir.exists():
|
||||||
|
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
|
||||||
|
|
||||||
|
config = {}
|
||||||
|
if config_file.exists():
|
||||||
|
try:
|
||||||
|
with open(config_file, 'r') as f:
|
||||||
|
config = json.load(f)
|
||||||
|
except (OSError, json.JSONDecodeError) as e:
|
||||||
|
logger.warning(f"Failed to load config for {app_id}: {e}")
|
||||||
|
|
||||||
|
# Load schema from schema.json
|
||||||
|
schema = None
|
||||||
|
schema_file = app_dir / "schema.json"
|
||||||
|
if schema_file.exists():
|
||||||
|
try:
|
||||||
|
with open(schema_file, 'r') as f:
|
||||||
|
schema = json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to load schema for {app_id}: {e}")
|
||||||
|
|
||||||
|
return jsonify({'status': 'success', 'config': config, 'schema': schema})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("[Starlark] get_starlark_app_config failed")
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to get Starlark app config', 'details': describe_exception(e)}), 500
|
||||||
|
@api_v3.route('/starlark/apps/<app_id>/config', methods=['PUT'])
|
||||||
|
def update_starlark_app_config(app_id):
|
||||||
|
"""Update configuration for a Starlark app."""
|
||||||
|
try:
|
||||||
|
# Validate app_id before any filesystem access
|
||||||
|
app_dir, error_msg = _validate_starlark_app_path(app_id)
|
||||||
|
if error_msg:
|
||||||
|
return jsonify({'status': 'error', 'message': error_msg}), 400
|
||||||
|
|
||||||
|
data = request.get_json(silent=True)
|
||||||
|
if not data:
|
||||||
|
return jsonify({'status': 'error', 'message': 'No configuration provided'}), 400
|
||||||
|
|
||||||
|
if 'render_interval' in data:
|
||||||
|
val, err = _validate_timing_value(data['render_interval'], 'render_interval')
|
||||||
|
if err:
|
||||||
|
return jsonify({'status': 'error', 'message': err}), 400
|
||||||
|
data['render_interval'] = val
|
||||||
|
|
||||||
|
if 'display_duration' in data:
|
||||||
|
val, err = _validate_timing_value(data['display_duration'], 'display_duration')
|
||||||
|
if err:
|
||||||
|
return jsonify({'status': 'error', 'message': err}), 400
|
||||||
|
data['display_duration'] = val
|
||||||
|
|
||||||
|
starlark_plugin = _pkg._get_starlark_plugin()
|
||||||
|
if starlark_plugin:
|
||||||
|
app = starlark_plugin.apps.get(app_id)
|
||||||
|
if not app:
|
||||||
|
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
|
||||||
|
|
||||||
|
# Extract timing keys from data before updating config (they belong in manifest, not config)
|
||||||
|
render_interval = data.pop('render_interval', None)
|
||||||
|
display_duration = data.pop('display_duration', None)
|
||||||
|
|
||||||
|
# Snapshot before mutating. save_config() can fail, and the route
|
||||||
|
# answers 500 below when it does -- but the loaded app kept the new
|
||||||
|
# values anyway, so a later GET returned configuration that was
|
||||||
|
# never persisted and the plugin rendered with it.
|
||||||
|
prev_config = dict(app.config)
|
||||||
|
prev_manifest = dict(app.manifest)
|
||||||
|
|
||||||
|
# Update config with non-timing fields only
|
||||||
|
app.config.update(data)
|
||||||
|
|
||||||
|
# Update manifest with timing fields
|
||||||
|
timing_changed = False
|
||||||
|
if render_interval is not None:
|
||||||
|
app.manifest['render_interval'] = render_interval
|
||||||
|
timing_changed = True
|
||||||
|
if display_duration is not None:
|
||||||
|
app.manifest['display_duration'] = display_duration
|
||||||
|
timing_changed = True
|
||||||
|
saved = app.save_config()
|
||||||
|
if not saved:
|
||||||
|
app.config.clear(); app.config.update(prev_config)
|
||||||
|
app.manifest.clear(); app.manifest.update(prev_manifest)
|
||||||
|
if saved:
|
||||||
|
# Persist manifest if timing changed (same pattern as toggle endpoint)
|
||||||
|
if timing_changed:
|
||||||
|
try:
|
||||||
|
# Use safe manifest update to prevent race conditions
|
||||||
|
timing_updates = {}
|
||||||
|
if render_interval is not None:
|
||||||
|
timing_updates['render_interval'] = render_interval
|
||||||
|
if display_duration is not None:
|
||||||
|
timing_updates['display_duration'] = display_duration
|
||||||
|
|
||||||
|
def update_fn(manifest):
|
||||||
|
manifest['apps'][app_id].update(timing_updates)
|
||||||
|
starlark_plugin._update_manifest_safe(update_fn)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to persist timing to manifest for {app_id}: {e}")
|
||||||
|
starlark_plugin._render_app(app, force=True)
|
||||||
|
return jsonify({'status': 'success', 'message': 'Configuration updated', 'config': app.config})
|
||||||
|
else:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to save configuration'}), 500
|
||||||
|
|
||||||
|
# Standalone: update both config.json and manifest. Both live under
|
||||||
|
# the manifest lock (serialized against every other standalone
|
||||||
|
# manifest read-modify-write -- see _starlark_manifest_lock), and if
|
||||||
|
# the manifest write fails after config.json was already written,
|
||||||
|
# config.json is rolled back so the two do not end up out of sync.
|
||||||
|
with _starlark_manifest_lock():
|
||||||
|
manifest = _pkg._read_starlark_manifest()
|
||||||
|
app_data = manifest.get('apps', {}).get(app_id)
|
||||||
|
if not app_data:
|
||||||
|
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
|
||||||
|
|
||||||
|
# Extract timing keys (they go in manifest, not config.json)
|
||||||
|
render_interval = data.pop('render_interval', None)
|
||||||
|
display_duration = data.pop('display_duration', None)
|
||||||
|
|
||||||
|
# Update manifest with timing values
|
||||||
|
if render_interval is not None:
|
||||||
|
app_data['render_interval'] = render_interval
|
||||||
|
if display_duration is not None:
|
||||||
|
app_data['display_duration'] = display_duration
|
||||||
|
|
||||||
|
# Load current config from config.json. app_dir is the path
|
||||||
|
# _validate_starlark_app_path checked, not a fresh join.
|
||||||
|
config_file = app_dir / "config.json"
|
||||||
|
existed_before = config_file.exists()
|
||||||
|
previous_config_bytes = None
|
||||||
|
current_config = {}
|
||||||
|
if existed_before:
|
||||||
|
try:
|
||||||
|
previous_config_bytes = config_file.read_bytes()
|
||||||
|
current_config = json.loads(previous_config_bytes)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to load config for {app_id}: {e}")
|
||||||
|
|
||||||
|
# Update config with new values (excluding timing keys)
|
||||||
|
current_config.update(data)
|
||||||
|
|
||||||
|
# Write updated config to config.json
|
||||||
|
try:
|
||||||
|
with open(config_file, 'w') as f:
|
||||||
|
json.dump(current_config, f, indent=2)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to save config.json for {app_id}: {e}")
|
||||||
|
logger.exception("Failed to save Starlark configuration for %r", app_id)
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to save configuration',
|
||||||
|
'details': describe_exception(e)}), 500
|
||||||
|
|
||||||
|
# Also update manifest for backward compatibility
|
||||||
|
app_data.setdefault('config', {}).update(data)
|
||||||
|
|
||||||
|
if _pkg._write_starlark_manifest(manifest):
|
||||||
|
return jsonify({'status': 'success', 'message': 'Configuration updated', 'config': current_config})
|
||||||
|
|
||||||
|
# The manifest write failed after config.json was already
|
||||||
|
# written -- roll config.json back rather than leave the two
|
||||||
|
# disagreeing about what was saved.
|
||||||
|
try:
|
||||||
|
if existed_before and previous_config_bytes is not None:
|
||||||
|
config_file.write_bytes(previous_config_bytes)
|
||||||
|
elif not existed_before:
|
||||||
|
config_file.unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
logger.exception(
|
||||||
|
"Failed to roll back config.json for %r after manifest write failure", app_id)
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to save manifest'}), 500
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("[Starlark] update_starlark_app_config failed")
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to update Starlark app config', 'details': describe_exception(e)}), 500
|
||||||
|
@api_v3.route('/starlark/apps/<app_id>/toggle', methods=['POST'])
|
||||||
|
def toggle_starlark_app(app_id):
|
||||||
|
"""Enable or disable a Starlark app."""
|
||||||
|
try:
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
|
||||||
|
starlark_plugin = _pkg._get_starlark_plugin()
|
||||||
|
if starlark_plugin:
|
||||||
|
app = starlark_plugin.apps.get(app_id)
|
||||||
|
if not app:
|
||||||
|
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
|
||||||
|
enabled = data.get('enabled')
|
||||||
|
if enabled is None:
|
||||||
|
enabled = not app.is_enabled()
|
||||||
|
app.manifest['enabled'] = enabled
|
||||||
|
# Use safe manifest update to prevent race conditions
|
||||||
|
def update_fn(manifest):
|
||||||
|
manifest['apps'][app_id]['enabled'] = enabled
|
||||||
|
starlark_plugin._update_manifest_safe(update_fn)
|
||||||
|
return jsonify({'status': 'success', 'message': f"App {'enabled' if enabled else 'disabled'}", 'enabled': enabled})
|
||||||
|
|
||||||
|
# Standalone: update manifest directly
|
||||||
|
with _starlark_manifest_lock():
|
||||||
|
manifest = _pkg._read_starlark_manifest()
|
||||||
|
app_data = manifest.get('apps', {}).get(app_id)
|
||||||
|
if not app_data:
|
||||||
|
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
|
||||||
|
|
||||||
|
enabled = data.get('enabled')
|
||||||
|
if enabled is None:
|
||||||
|
enabled = not app_data.get('enabled', True)
|
||||||
|
app_data['enabled'] = enabled
|
||||||
|
if _pkg._write_starlark_manifest(manifest):
|
||||||
|
return jsonify({'status': 'success', 'message': f"App {'enabled' if enabled else 'disabled'}", 'enabled': enabled})
|
||||||
|
else:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to save'}), 500
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("[Starlark] toggle_starlark_app failed")
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to toggle Starlark app', 'details': describe_exception(e)}), 500
|
||||||
|
@api_v3.route('/starlark/apps/<app_id>/render', methods=['POST'])
|
||||||
|
def render_starlark_app(app_id):
|
||||||
|
"""Force render a Starlark app."""
|
||||||
|
try:
|
||||||
|
app_dir, err = _validate_starlark_app_path(app_id)
|
||||||
|
if err:
|
||||||
|
return jsonify({'status': 'error', 'message': err}), 400
|
||||||
|
|
||||||
|
starlark_plugin = _pkg._get_starlark_plugin()
|
||||||
|
if starlark_plugin:
|
||||||
|
app = starlark_plugin.apps.get(app_id)
|
||||||
|
if not app:
|
||||||
|
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
|
||||||
|
success = starlark_plugin._render_app(app, force=True)
|
||||||
|
if success:
|
||||||
|
return jsonify({'status': 'success', 'message': 'App rendered',
|
||||||
|
'frame_count': len(app.frames) if app.frames else 0})
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to render app'}), 500
|
||||||
|
|
||||||
|
# Web-service context: plugin not loaded, call pixlet directly
|
||||||
|
success, status_code, error = _standalone_render_starlark_app(app_id)
|
||||||
|
if success:
|
||||||
|
return jsonify({'status': 'success', 'message': 'App rendered successfully', 'frame_count': 0}), status_code
|
||||||
|
return jsonify({'status': 'error', 'message': error or 'Render failed', 'frame_count': 0}), status_code
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("[Starlark] render_starlark_app failed")
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to render Starlark app', 'details': describe_exception(e)}), 500
|
||||||
|
@api_v3.route('/starlark/repository/browse', methods=['GET'])
|
||||||
|
def browse_tronbyte_repository():
|
||||||
|
"""Browse all apps in the Tronbyte repository (bulk cached fetch).
|
||||||
|
|
||||||
|
Returns ALL apps with metadata, categories, and authors.
|
||||||
|
Filtering/sorting/pagination is handled client-side.
|
||||||
|
Results are cached server-side for 2 hours.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
TronbyteRepository = _pkg._get_tronbyte_repository_class()
|
||||||
|
|
||||||
|
repo = TronbyteRepository(github_token=_starlark_github_token())
|
||||||
|
|
||||||
|
result = repo.list_all_apps_cached()
|
||||||
|
|
||||||
|
rate_limit = repo.get_rate_limit_info()
|
||||||
|
|
||||||
|
# An upstream failure used to arrive here as an empty app list and go
|
||||||
|
# out as 'success', so the store drew an empty grid and said nothing.
|
||||||
|
# 502: the request was fine, GitHub was not.
|
||||||
|
if result.get('error'):
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': result['error'],
|
||||||
|
'rate_limit': rate_limit,
|
||||||
|
}), 502
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'apps': result['apps'],
|
||||||
|
'categories': result['categories'],
|
||||||
|
'authors': result['authors'],
|
||||||
|
'count': result['count'],
|
||||||
|
'cached': result['cached'],
|
||||||
|
'rate_limit': rate_limit,
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("[Starlark] browse_tronbyte_repository failed")
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to browse repository', 'details': describe_exception(e)}), 500
|
||||||
|
@api_v3.route('/starlark/repository/install', methods=['POST'])
|
||||||
|
def install_from_tronbyte_repository():
|
||||||
|
"""Install an app from the Tronbyte repository."""
|
||||||
|
try:
|
||||||
|
data = request.get_json(silent=True)
|
||||||
|
if not data or 'app_id' not in data:
|
||||||
|
return jsonify({'status': 'error', 'message': 'app_id is required'}), 400
|
||||||
|
|
||||||
|
app_id, app_id_error = _validate_and_sanitize_app_id(data['app_id'])
|
||||||
|
if app_id_error:
|
||||||
|
return jsonify({'status': 'error', 'message': f'Invalid app_id: {app_id_error}'}), 400
|
||||||
|
|
||||||
|
TronbyteRepository = _pkg._get_tronbyte_repository_class()
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
repo = TronbyteRepository(github_token=_starlark_github_token())
|
||||||
|
|
||||||
|
success, metadata, error = repo.get_app_metadata(data['app_id'])
|
||||||
|
if not success:
|
||||||
|
return jsonify({'status': 'error', 'message': f'Failed to fetch app metadata: {error}'}), 404
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False, suffix='.star') as tmp:
|
||||||
|
temp_path = tmp.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Pass filename from metadata (e.g., "analog_clock.star" for analogclock app)
|
||||||
|
# Note: manifest uses 'fileName' (camelCase), not 'filename'
|
||||||
|
filename = metadata.get('fileName') if metadata else None
|
||||||
|
success, error = repo.download_star_file(data['app_id'], Path(temp_path), filename=filename)
|
||||||
|
if not success:
|
||||||
|
return jsonify({'status': 'error', 'message': f'Failed to download app: {error}'}), 500
|
||||||
|
|
||||||
|
# Download assets (images, sources, etc.) to a temp directory
|
||||||
|
import tempfile
|
||||||
|
temp_assets_dir = tempfile.mkdtemp()
|
||||||
|
try:
|
||||||
|
success_assets, error_assets = repo.download_app_assets(data['app_id'], Path(temp_assets_dir))
|
||||||
|
# Asset download is non-critical - log warning but continue if it fails
|
||||||
|
if not success_assets:
|
||||||
|
logger.warning(f"Failed to download assets for {data['app_id']}: {error_assets}")
|
||||||
|
|
||||||
|
render_interval = data.get('render_interval', 300)
|
||||||
|
ri, err = _validate_timing_value(render_interval, 'render_interval')
|
||||||
|
if err:
|
||||||
|
return jsonify({'status': 'error', 'message': err}), 400
|
||||||
|
render_interval = ri or 300
|
||||||
|
|
||||||
|
display_duration = data.get('display_duration', 15)
|
||||||
|
dd, err = _validate_timing_value(display_duration, 'display_duration')
|
||||||
|
if err:
|
||||||
|
return jsonify({'status': 'error', 'message': err}), 400
|
||||||
|
display_duration = dd or 15
|
||||||
|
|
||||||
|
install_metadata = {
|
||||||
|
'name': metadata.get('name', app_id) if metadata else app_id,
|
||||||
|
'render_interval': render_interval,
|
||||||
|
'display_duration': display_duration
|
||||||
|
}
|
||||||
|
|
||||||
|
starlark_plugin = _pkg._get_starlark_plugin()
|
||||||
|
if starlark_plugin:
|
||||||
|
success = starlark_plugin.install_app(app_id, temp_path, install_metadata, assets_dir=temp_assets_dir)
|
||||||
|
else:
|
||||||
|
success = _install_star_file(app_id, temp_path, install_metadata, assets_dir=temp_assets_dir)
|
||||||
|
finally:
|
||||||
|
# Clean up temp assets directory
|
||||||
|
import shutil
|
||||||
|
try:
|
||||||
|
shutil.rmtree(temp_assets_dir)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return jsonify({'status': 'success', 'message': f'App installed: {metadata.get("name", app_id) if metadata else app_id}', 'app_id': app_id})
|
||||||
|
else:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to install app'}), 500
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(temp_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("[Starlark] install_from_tronbyte_repository failed")
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to install from repository', 'details': describe_exception(e)}), 500
|
||||||
|
@api_v3.route('/starlark/repository/categories', methods=['GET'])
|
||||||
|
def get_tronbyte_categories():
|
||||||
|
"""Get list of available app categories (uses bulk cache)."""
|
||||||
|
try:
|
||||||
|
TronbyteRepository = _pkg._get_tronbyte_repository_class()
|
||||||
|
repo = TronbyteRepository(github_token=_starlark_github_token())
|
||||||
|
|
||||||
|
result = repo.list_all_apps_cached()
|
||||||
|
|
||||||
|
if result.get('error'):
|
||||||
|
return jsonify({'status': 'error', 'message': result['error']}), 502
|
||||||
|
|
||||||
|
return jsonify({'status': 'success', 'categories': result['categories']})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("[Starlark] get_tronbyte_categories failed")
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to fetch categories', 'details': describe_exception(e)}), 500
|
||||||
@@ -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,390 @@
|
|||||||
|
"""Wi-Fi scanning, connection and status routes.
|
||||||
|
|
||||||
|
Routes decorate the shared `api_v3` Blueprint from ._common, so their
|
||||||
|
endpoint names are unchanged by living here.
|
||||||
|
"""
|
||||||
|
from web_interface.blueprints.api_v3 import (
|
||||||
|
api_v3, describe_exception, jsonify, logger, request,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_bool_ish(value, default=False):
|
||||||
|
"""Coerce a JSON value that is supposed to be a boolean.
|
||||||
|
|
||||||
|
A JSON boolean arrives as a real Python bool, but these routes are a
|
||||||
|
public HTTP contract and not every caller sends one. `bool(value)` gets
|
||||||
|
two common cases wrong: `bool("false")` is True (a non-empty string is
|
||||||
|
always truthy), and a plain int does not match an `is True` check
|
||||||
|
(`1 is True` is False, since `True` is a distinct singleton from the int
|
||||||
|
`1`) -- so a caller sending `{"enabled": 1}` was silently treated as
|
||||||
|
False. Recognizes a real bool, "true"/"false"/"1"/"0"/"yes"/"no"
|
||||||
|
case-insensitively, and int 1/0; anything else falls back to `default`.
|
||||||
|
"""
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
lowered = value.strip().lower()
|
||||||
|
if lowered in ('true', '1', 'yes'):
|
||||||
|
return True
|
||||||
|
if lowered in ('false', '0', 'no'):
|
||||||
|
return False
|
||||||
|
return default
|
||||||
|
if isinstance(value, int):
|
||||||
|
if value == 1:
|
||||||
|
return True
|
||||||
|
if value == 0:
|
||||||
|
return False
|
||||||
|
return default
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
# WiFi Management Endpoints
|
||||||
|
@api_v3.route('/wifi/status', methods=['GET'])
|
||||||
|
def get_wifi_status():
|
||||||
|
"""Get current WiFi connection status"""
|
||||||
|
try:
|
||||||
|
from src.wifi_manager import WiFiManager
|
||||||
|
|
||||||
|
wifi_manager = WiFiManager()
|
||||||
|
status = wifi_manager.get_wifi_status()
|
||||||
|
|
||||||
|
# Get auto-enable setting from config
|
||||||
|
auto_enable_ap = wifi_manager.config.get("auto_enable_ap_mode", True) # Default: True (safe due to grace period)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'data': {
|
||||||
|
'connected': status.connected,
|
||||||
|
'ssid': status.ssid,
|
||||||
|
'ip_address': status.ip_address,
|
||||||
|
'signal': status.signal,
|
||||||
|
'ap_mode_active': status.ap_mode_active,
|
||||||
|
'auto_enable_ap_mode': auto_enable_ap
|
||||||
|
}
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("%s failed", request.path, exc_info=True)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'An error occurred; see logs for details',
|
||||||
|
'details': describe_exception(e)
|
||||||
|
}), 500
|
||||||
|
@api_v3.route('/wifi/scan', methods=['GET'])
|
||||||
|
def scan_wifi_networks():
|
||||||
|
"""Scan for available WiFi networks
|
||||||
|
|
||||||
|
If AP mode is active, it will be temporarily disabled during scanning
|
||||||
|
and automatically re-enabled afterward. Users connected to the AP will
|
||||||
|
be briefly disconnected during this process.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from src.wifi_manager import WiFiManager
|
||||||
|
|
||||||
|
wifi_manager = WiFiManager()
|
||||||
|
|
||||||
|
# Check if AP mode is active before scanning (for user notification)
|
||||||
|
ap_was_active = wifi_manager._is_ap_mode_active()
|
||||||
|
|
||||||
|
# Perform the scan (this will handle AP mode disabling/enabling internally)
|
||||||
|
networks, _was_cached = wifi_manager.scan_networks()
|
||||||
|
|
||||||
|
# Convert to dict format
|
||||||
|
networks_data = [
|
||||||
|
{
|
||||||
|
'ssid': net.ssid,
|
||||||
|
'signal': net.signal,
|
||||||
|
'security': net.security,
|
||||||
|
'frequency': net.frequency
|
||||||
|
}
|
||||||
|
for net in networks
|
||||||
|
]
|
||||||
|
|
||||||
|
response_data = {
|
||||||
|
'status': 'success',
|
||||||
|
'data': networks_data
|
||||||
|
}
|
||||||
|
|
||||||
|
# Inform user if AP mode was temporarily disabled
|
||||||
|
if ap_was_active:
|
||||||
|
response_data['message'] = (
|
||||||
|
f'Found {len(networks_data)} networks. '
|
||||||
|
'Note: AP mode was temporarily disabled during scanning and has been re-enabled. '
|
||||||
|
'If you were connected to the setup network, you may need to reconnect.'
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify(response_data)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error scanning WiFi networks", exc_info=True)
|
||||||
|
error_message = 'An error occurred while scanning WiFi networks; see logs for details'
|
||||||
|
|
||||||
|
# Provide more specific error messages for common issues
|
||||||
|
error_str = str(e).lower()
|
||||||
|
if 'permission' in error_str or 'sudo' in error_str:
|
||||||
|
error_message = (
|
||||||
|
'Permission error while scanning. '
|
||||||
|
'The WiFi scan requires appropriate permissions. '
|
||||||
|
'Please ensure the application has necessary privileges.'
|
||||||
|
)
|
||||||
|
elif 'timeout' in error_str:
|
||||||
|
error_message = (
|
||||||
|
'WiFi scan timed out. '
|
||||||
|
'The scan took too long to complete. '
|
||||||
|
'This may happen if the WiFi interface is busy or in use.'
|
||||||
|
)
|
||||||
|
elif 'no wifi' in error_str or 'not available' in error_str:
|
||||||
|
error_message = (
|
||||||
|
'WiFi scanning tools are not available. '
|
||||||
|
'Please ensure NetworkManager (nmcli) or iwlist is installed.'
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': error_message
|
||||||
|
}), 500
|
||||||
|
@api_v3.route('/wifi/connect', methods=['POST'])
|
||||||
|
def connect_wifi():
|
||||||
|
"""Connect to a WiFi network"""
|
||||||
|
try:
|
||||||
|
from src.wifi_manager import WiFiManager
|
||||||
|
|
||||||
|
data = request.get_json(silent=True)
|
||||||
|
if not data:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Request body is required'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
if 'ssid' not in data:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'SSID is required'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
ssid = data['ssid']
|
||||||
|
if not ssid or not ssid.strip():
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'SSID cannot be empty'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
ssid = ssid.strip()
|
||||||
|
password = data.get('password', '') or ''
|
||||||
|
|
||||||
|
wifi_manager = WiFiManager()
|
||||||
|
success, message = wifi_manager.connect_to_network(ssid, password)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': message
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': message or 'Failed to connect to network'
|
||||||
|
}), 400
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error connecting to WiFi", exc_info=True)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
|
||||||
|
}), 500
|
||||||
|
@api_v3.route('/wifi/disconnect', methods=['POST'])
|
||||||
|
def disconnect_wifi():
|
||||||
|
"""Disconnect from the current WiFi network"""
|
||||||
|
try:
|
||||||
|
from src.wifi_manager import WiFiManager
|
||||||
|
|
||||||
|
wifi_manager = WiFiManager()
|
||||||
|
success, message = wifi_manager.disconnect_from_network()
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': message
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': message or 'Failed to disconnect from network'
|
||||||
|
}), 400
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error disconnecting from WiFi", exc_info=True)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
|
||||||
|
}), 500
|
||||||
|
@api_v3.route('/wifi/ap/enable', methods=['POST'])
|
||||||
|
def enable_ap_mode():
|
||||||
|
"""Enable access point mode"""
|
||||||
|
try:
|
||||||
|
from src.wifi_manager import WiFiManager
|
||||||
|
|
||||||
|
wifi_manager = WiFiManager()
|
||||||
|
_force_raw = (request.get_json(silent=True) or {}).get('force', False)
|
||||||
|
force = _force_raw is True or (isinstance(_force_raw, str) and _force_raw.lower() in ('true', '1'))
|
||||||
|
success, message = wifi_manager.enable_ap_mode(force=force)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': message
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': message
|
||||||
|
}), 400
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("%s failed", request.path, exc_info=True)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'An error occurred; see logs for details',
|
||||||
|
'details': describe_exception(e)
|
||||||
|
}), 500
|
||||||
|
@api_v3.route('/wifi/ap/disable', methods=['POST'])
|
||||||
|
def disable_ap_mode():
|
||||||
|
"""Disable access point mode"""
|
||||||
|
try:
|
||||||
|
from src.wifi_manager import WiFiManager
|
||||||
|
|
||||||
|
wifi_manager = WiFiManager()
|
||||||
|
success, message = wifi_manager.disable_ap_mode()
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': message
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': message
|
||||||
|
}), 400
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("%s failed", request.path, exc_info=True)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'An error occurred; see logs for details',
|
||||||
|
'details': describe_exception(e)
|
||||||
|
}), 500
|
||||||
|
@api_v3.route('/wifi/ap/auto-enable', methods=['GET'])
|
||||||
|
def get_auto_enable_ap_mode():
|
||||||
|
"""Get auto-enable AP mode setting"""
|
||||||
|
try:
|
||||||
|
from src.wifi_manager import WiFiManager
|
||||||
|
|
||||||
|
wifi_manager = WiFiManager()
|
||||||
|
auto_enable = wifi_manager.config.get("auto_enable_ap_mode", True) # Default: True (safe due to grace period)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'data': {
|
||||||
|
'auto_enable_ap_mode': auto_enable
|
||||||
|
}
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("%s failed", request.path, exc_info=True)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'An error occurred; see logs for details',
|
||||||
|
'details': describe_exception(e)
|
||||||
|
}), 500
|
||||||
|
@api_v3.route('/wifi/ap/auto-enable', methods=['POST'])
|
||||||
|
def set_auto_enable_ap_mode():
|
||||||
|
"""Set auto-enable AP mode setting"""
|
||||||
|
try:
|
||||||
|
from src.wifi_manager import WiFiManager
|
||||||
|
|
||||||
|
data = request.get_json(silent=True)
|
||||||
|
if data is None or 'auto_enable_ap_mode' not in data:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'auto_enable_ap_mode is required'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
auto_enable = _parse_bool_ish(data['auto_enable_ap_mode'], default=False)
|
||||||
|
|
||||||
|
wifi_manager = WiFiManager()
|
||||||
|
wifi_manager.config["auto_enable_ap_mode"] = auto_enable
|
||||||
|
wifi_manager._save_config()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': f'Auto-enable AP mode set to {auto_enable}',
|
||||||
|
'data': {
|
||||||
|
'auto_enable_ap_mode': auto_enable
|
||||||
|
}
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("%s failed", request.path, exc_info=True)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'An error occurred; see logs for details',
|
||||||
|
'details': describe_exception(e)
|
||||||
|
}), 500
|
||||||
|
@api_v3.route('/wifi/radio', methods=['GET'])
|
||||||
|
def get_wifi_radio():
|
||||||
|
"""Get current WiFi radio state (enabled/disabled) and wired-fallback status."""
|
||||||
|
try:
|
||||||
|
from src.wifi_manager import WiFiManager
|
||||||
|
|
||||||
|
wifi_manager = WiFiManager()
|
||||||
|
state = wifi_manager.get_wifi_radio_state()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'data': state
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error getting WiFi radio state", exc_info=True)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
|
||||||
|
}), 500
|
||||||
|
@api_v3.route('/wifi/radio', methods=['POST'])
|
||||||
|
def set_wifi_radio():
|
||||||
|
"""Turn the WiFi radio on or off.
|
||||||
|
|
||||||
|
Body: {"enabled": bool, "force": bool (optional)}. Disabling is refused
|
||||||
|
unless Ethernet is connected or force=True, to avoid locking the user out
|
||||||
|
of this web interface.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from src.wifi_manager import WiFiManager
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
if 'enabled' not in data:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'enabled is required'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Parse defensively: bool("false") is True and a plain int never
|
||||||
|
# matches `is True`, so `_parse_bool_ish` handles bool, string and
|
||||||
|
# int 1/0 — the endpoint is a public contract, not just the shipped
|
||||||
|
# UI (which always sends real JSON booleans).
|
||||||
|
enabled = _parse_bool_ish(data['enabled'], default=False)
|
||||||
|
force = _parse_bool_ish(data.get('force', False), default=False)
|
||||||
|
|
||||||
|
wifi_manager = WiFiManager()
|
||||||
|
success, message, reason = wifi_manager.set_wifi_radio(enabled, force=force)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': message,
|
||||||
|
'data': wifi_manager.get_wifi_radio_state()
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': message,
|
||||||
|
'reason': reason
|
||||||
|
}), 400
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error setting WiFi radio state", exc_info=True)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
|
||||||
|
}), 500
|
||||||
Reference in New Issue
Block a user