refactor(api-v3): split the 10,469-line blueprint into a package (#553)

* refactor(api-v3): split the 10,469-line blueprint into a package

web_interface/blueprints/api_v3.py held 111 routes, 56 helpers and 181
functions in one module -- 9% of the core by line count and three times the
next largest file. It becomes a package of nine route modules grouped by path
segment, plus __init__.py for the shared imports, constants, Blueprint and
helpers.

Every route module decorates the SAME api_v3 Blueprint object, so endpoint
names stay api_v3.<function>, the URL map is unchanged and app.py is untouched.
Verified: 111 routes before, 111 after, byte-identical rules, endpoints and
methods, and every endpoint still on the one blueprint.

  plugins   3,867   config    1,178   starlark  692   system  619
  fonts       452   misc        398   wifi      361   display 326   backup 212
  __init__  1,787 (imports, constants, Blueprint, 56 helpers)

Two things the URL-map check could not catch, both found by running the suite:

1. PROJECT_ROOT = Path(__file__).parent.parent.parent. Moving the code one
   directory deeper made that resolve to web_interface/ instead of the project
   root. Nothing failed at import; it surfaced as ~110 tests failing with 404s
   and "installation script not found", because every path built from it was
   one level too shallow. Now parents[3], and test_api_v3_url_map.py asserts
   PROJECT_ROOT/run.py exists so the next move cannot repeat it.

2. Module-attribute patching. Tests do
   monkeypatch.setattr(api_v3_module, "_BACKUP_EXPORT_DIR", ...) and a route
   module that binds such a name by value never sees the patch. The shared code
   therefore stays in __init__.py rather than moving to a _common submodule --
   it has to live on the module the tests patch -- and the eleven names tests
   patch are read back through the package (_pkg.X) instead of bound by value.
   Those eleven were found by AST-scanning every setattr in the test tree, not
   by guessing; "time" is among them, used to drive a fake clock through the
   second-resolution credential-backup filenames.

Test changes are confined to what genuinely moved: patch targets that now name
the owning route module, imports of helpers, and six tests that scan the api_v3
source as a file and now read the package directory.

Full suite: 4,278 passed, 68 skipped, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

* fix(api-v3): address CodeRabbit findings from the blueprint-split review

Fixes to the api_v3 package split (PR #553), one per finding verified
against the actual code:

- __init__.py: _redact_credentials only blanked scalar values under a
  credential-named key; a bare list of secrets under such a key (e.g.
  tokens: ["a", "b"]) passed through untouched, since the list branch
  recursed with no memory that its key looked like a credential. Nested
  dicts still walk normally (a documented, tested behaviour -- a container
  like secrets: {api_key: ..., note: ...} is a section name, not a value to
  blank outright), but any value reached under a credential-shaped key is
  now actually blanked.

- __init__.py: the OAuth helper script's raw stderr/stdout went to
  logger.error unredacted (CWE-532) right next to a comment claiming this
  was deliberate; the HTTP response already used the existing redact_text
  helper. Routed the log line through the same helper.

- __init__.py / starlark.py: the standalone Starlark manifest fallback
  (used when the plugin instance isn't loaded) read-modified-wrote
  manifest.json with no lock, unlike StarlarkAppsPlugin._update_manifest_safe
  (plugin-repos/starlark-apps/manager.py), which already holds an flock for
  the same file when the plugin is loaded. Added _starlark_manifest_lock,
  mirroring that pattern, and wrapped every standalone read-modify-write
  call site in it. The app-config update route also wrote config.json and
  the manifest as two separate, non-transactional writes (a second,
  distinct finding at the same call site); config.json is now rolled back
  if the manifest write that follows it fails.

- backup.py: restore options used bare bool() on values from the request,
  so {"restore_secrets": "false"} restored secrets anyway (bool("false") is
  True). Switched to the existing _coerce_to_bool helper already used for
  this exact purpose elsewhere in the package.

- config.py: an automated import-rewrite mangled four user-facing
  validation strings and their neighbouring comments -- "Invalid start
  time" had become "Invalid start _pkg.time" (and likewise for "end time")
  in both the schedule and dim-schedule per-day validation paths.

- display.py: `import _pkg.time as time_module` -- _pkg is a local alias
  for the package, not a real importable module, so this raised
  ModuleNotFoundError whenever a caller restarted an already-running
  display service via /display/on-demand/start, after the on-demand
  request was already written to cache. Fixed to `import time`. Audited
  the rest of the package for the same `_pkg.<module>` import mistake;
  every other `_pkg.` reference is a legitimate attribute read-through
  (`_pkg.time.time()`, `_pkg._get_starlark_plugin()`, ...), not a broken
  import statement.

- fonts.py: validate_file_upload's max_size_mb parameter is silently
  unused by that helper (it only checks filename/extension) -- the font
  upload route saved arbitrarily large files as a result. Added the same
  seek-and-check pattern already used for the sibling .star upload.

- wifi.py: two ad hoc, inconsistent bool coercions. POST
  /wifi/ap/auto-enable used bare bool(), so a JSON string "false" enabled
  it. POST /wifi/radio's enabled/force parsing recognized real bool and
  some strings but not int 1/0 (1 is True is False in Python). Factored one
  small _parse_bool_ish helper local to this file and used it at all three
  sites.

Not changed: the "unknown/misspelled restore option keys default to True"
half of the backup.py finding -- the file's own comment documents that a
missing key deliberately means "restore everything," matching the
already-existing JSON-parse-failure guard a few lines above it; only the
bool-coercion defect was a real bug.

Added or extended regression tests for every fix, following each area's
existing test conventions. Full suite: 4328 passed, 62 skipped, 2 failed
on both this branch and origin/main (missing tzdata package breaks two
timezone-alias tests in test_onboarding_checklist.py, unrelated to this
change) -- no new failures.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3bPMESe2TfrGvbs1ef9c5

* fix(api-v3): reject unknown restore option keys

CodeRabbit's review of the blueprint split (#553) asked that
POST /backup/restore reject option keys outside RestoreOptions'
known set. The follow-up commit fixed the bool("false")-is-True
bug with _coerce_to_bool but never added the key check: a typo'd
or renamed key (e.g. "restoreSecrets") is silently ignored by
opts_dict.get(key, True), so the flag stays at its True default
and secrets get restored despite the caller's request saying
otherwise -- with no indication anything was wrong.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vmcwf5vMgYqdt8bJTZtiwb

* fix(api-v3): address CodeRabbit findings on the blueprint split

- _redact_credentials: blank scalar descendants of objects reached
  through a credential-owned list (e.g. tokens: [{"value": "secret"}])
  regardless of field name -- the existing name-based walk only
  protected direct dict values under a credential key, not list items.
- wifi.py: reject enabled/force/auto_enable_ap_mode values
  _parse_bool_ish can't recognize (400) instead of silently treating
  them as False, which could disable Wi-Fi or the radio itself.
- Starlark manifest locking: lock a stable manifest.json.lock sidecar
  instead of manifest.json itself, in both the standalone route path
  (_starlark_manifest_lock) and the plugin path
  (StarlarkAppsPlugin._save_manifest / _update_manifest_safe).
  manifest.json is replaced by an atomic rename on every write, which
  swaps in a fresh inode; a lock held on the old inode does not
  exclude a second locker that opens the path afresh right after the
  rename and gets the new inode, so two writers could race despite
  each holding "a lock". A sidecar that no write ever touches always
  resolves to the same inode for every locker.

Skipped as stale: the "serialize the complete manifest
read-modify-write" finding at api_v3/__init__.py -- every standalone
handler that calls _write_starlark_manifest is already wrapped in
_starlark_manifest_lock() on this branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(api-v3): re-check reconciliation findings by the reconciler's own rules

Both CodeRabbit findings on the merge commit, verified against the code first.

Major, plugins.py: the stale-findings filter derived its own notion of "in
config" and "on disk", and both were looser than the reconciliation module's.
set(load_config()) also contains system keys, the secrets-file keys load_config()
merges in, and non-dict values; and any directory holding a manifest.json
counted as installed even when that manifest does not parse. Either looseness
clears a finding that is still true -- and a secrets key read as a plugin is the
precise bug the filter exists to stop reporting, so reintroducing that asymmetry
while re-checking was the wrong way round.

The two extractions now live in state_reconciliation.py as config_plugin_ids()
and disk_plugin_ids(), with ignored_config_keys() and secrets_top_level_keys()
alongside. _get_config_state() and _get_disk_state() use them too, so there is
one definition rather than two that can drift. _get_disk_state() re-reads each
manifest for version/name after taking membership from the shared extractor;
that costs one extra small read per plugin on a path that runs once per boot.

Minor, the new test: the fixture assigned api_v3.config_manager and
api_v3.plugin_manager directly. Those live on a module-level blueprint
singleton, so the mocks leaked into every later test that imports api_v3 --
pointing at a tmp_path already deleted. Both now go through monkeypatch.setattr,
which restores them. This is the same pollution class that made an earlier test
in this session break seven unrelated ones, so it is worth getting right.

Five cases added for the parity itself: a secrets key, a system key and a
non-dict value must not clear an "installed but missing from config" finding,
and neither an unparseable manifest nor a .standalone-backup- directory may
count as installed. All five fail against the looser version.

Linux CI on the preceding commit: Core unit tests, plugin harness, CodeQL and
CodeRabbit all pass. Codacy reads action_required on every commit of this
branch including the first, so it is pre-existing and not from this work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-11 10:07:38 -04:00
committed by GitHub
co-authored by Claude Opus 5
parent 0ab95586fb
commit bdb9a94033
32 changed files with 12151 additions and 10083 deletions
+15 -6
View File
@@ -220,6 +220,8 @@ class StarlarkAppsPlugin(BasePlugin):
# App storage
self.apps_dir = self._get_apps_directory()
self.manifest_file = self.apps_dir / "manifest.json"
# A dedicated, never-replaced file to flock -- see _update_manifest_safe.
self.manifest_lock_file = self.apps_dir / "manifest.json.lock"
self.apps: Dict[str, StarlarkApp] = {}
# Display state
@@ -564,7 +566,8 @@ class StarlarkAppsPlugin(BasePlugin):
def _save_manifest(self, manifest: Dict[str, Any]) -> bool:
"""
Save apps manifest to file with file locking to prevent race conditions.
Acquires exclusive lock on manifest file before writing to prevent concurrent modifications.
Acquires exclusive lock on the manifest lock sidecar before writing to
prevent concurrent modifications.
"""
temp_file = None
lock_fd = None
@@ -572,9 +575,14 @@ class StarlarkAppsPlugin(BasePlugin):
# Create parent directory if needed
self.manifest_file.parent.mkdir(parents=True, exist_ok=True)
# Open manifest file for locking (create if doesn't exist, don't truncate)
# Use os.open with O_CREAT | O_RDWR to create if missing, but don't truncate
lock_fd = os.open(str(self.manifest_file), os.O_CREAT | os.O_RDWR, 0o644)
# Lock the sidecar file, not manifest_file itself: manifest_file is
# replaced by an atomic rename below, which swaps in a fresh inode
# a second locker's fresh os.open() would pick up unguarded. The
# sidecar is never written to or renamed over, so it always
# resolves to the same inode for every locker (see
# _update_manifest_safe and web_interface's _starlark_manifest_lock,
# which must lock this same file for the guarantee to hold).
lock_fd = os.open(str(self.manifest_lock_file), os.O_CREAT | os.O_RDWR, 0o644)
# Acquire exclusive lock on manifest file BEFORE creating temp file
# This serializes all writers and prevents concurrent races
@@ -630,8 +638,9 @@ class StarlarkAppsPlugin(BasePlugin):
# Create parent directory if needed
self.manifest_file.parent.mkdir(parents=True, exist_ok=True)
# Open manifest file for locking (create if doesn't exist, don't truncate)
lock_fd = os.open(str(self.manifest_file), os.O_CREAT | os.O_RDWR, 0o644)
# Lock the sidecar file, not manifest_file itself -- see the
# comment in _save_manifest for why.
lock_fd = os.open(str(self.manifest_lock_file), os.O_CREAT | os.O_RDWR, 0o644)
# Acquire exclusive lock for entire read-modify-write cycle
fcntl.flock(lock_fd, fcntl.LOCK_EX)
+83 -34
View File
@@ -56,6 +56,71 @@ class ReconciliationResult:
message: str
def secrets_top_level_keys(config_manager) -> Set[str]:
"""Top-level keys load_config() merges in from the secrets file.
Deliberately fail-safe: an unreadable, absent, malformed or non-path
secrets location narrows this set rather than raising, because a failure
here must never break reconciliation.
"""
try:
path = config_manager.get_secrets_path()
with open(path, 'r') as f:
secrets = json.load(f)
except (AttributeError, OSError, TypeError, ValueError):
return set()
return set(secrets) if isinstance(secrets, dict) else set()
def ignored_config_keys(config_manager) -> Set[str]:
"""Config keys that are not plugin ids: system keys plus secrets keys."""
return set(StateReconciliation._SYSTEM_CONFIG_KEYS) | secrets_top_level_keys(config_manager)
def config_plugin_ids(config: Dict[str, Any], ignored_keys: Set[str]) -> Set[str]:
"""Plugin ids a loaded config declares.
Shared with the web interface so a stored verdict is re-checked against the
same definition that produced it. ``set(config)`` is NOT equivalent: it also
contains system keys, the secrets-file keys load_config() merges in, and
non-dict values. Counting any of those as a plugin is exactly what turned a
'data' key in the secrets file into a phantom plugin, and using the loose
set to re-check findings would clear ones that are still true.
"""
return {k for k, v in (config or {}).items()
if isinstance(v, dict) and k not in ignored_keys}
def disk_plugin_ids(plugins_dir) -> Set[str]:
"""Plugin ids actually installed on disk.
A directory counts only when it is not a standalone backup and its
manifest.json parses. A corrupt manifest must not read as installed, or a
live "in config but not on disk" finding gets cleared on the strength of an
unreadable file.
"""
ids: Set[str] = set()
root = Path(plugins_dir)
try:
if not root.exists():
return ids
for entry in root.iterdir():
if not entry.is_dir() or '.standalone-backup-' in entry.name:
continue
manifest = entry / "manifest.json"
if not manifest.exists():
continue
try:
with open(manifest, 'r') as f:
json.load(f)
except (OSError, ValueError):
continue
ids.add(entry.name)
except OSError:
return ids
return ids
def still_unresolved(entries: List[Dict[str, Any]],
config_keys: Set[str],
installed_ids: Set[str]) -> List[Dict[str, Any]]:
@@ -240,16 +305,7 @@ class StateReconciliation:
reported as "in config but not on disk". Reading the file keeps this
correct no matter what it holds.
"""
try:
path = self.config_manager.get_secrets_path()
with open(path, 'r') as f:
secrets = json.load(f)
except (AttributeError, OSError, TypeError, ValueError):
# Deliberately broad: an unreadable, absent, malformed or
# non-path secrets location must narrow this set, never break
# reconciliation. ValueError covers json.JSONDecodeError.
return set()
return set(secrets) if isinstance(secrets, dict) else set()
return secrets_top_level_keys(self.config_manager)
def _get_config_state(self) -> Dict[str, Dict[str, Any]]:
"""Get plugin state from config file."""
@@ -257,11 +313,8 @@ class StateReconciliation:
try:
config = self.config_manager.load_config()
ignored = self._SYSTEM_CONFIG_KEYS | self._secrets_top_level_keys()
for plugin_id, plugin_config in config.items():
if not isinstance(plugin_config, dict):
continue
if plugin_id in ignored:
continue
for plugin_id in config_plugin_ids(config, ignored):
plugin_config = config[plugin_id]
state[plugin_id] = {
'enabled': plugin_config.get('enabled', True),
'version': plugin_config.get('version'),
@@ -275,25 +328,21 @@ class StateReconciliation:
"""Get plugin state from disk (installed plugins)."""
state = {}
try:
if self.plugins_dir.exists():
for plugin_dir in self.plugins_dir.iterdir():
if plugin_dir.is_dir():
plugin_id = plugin_dir.name
if '.standalone-backup-' in plugin_id:
continue
manifest_path = plugin_dir / "manifest.json"
if manifest_path.exists():
import json
try:
with open(manifest_path, 'r') as f:
manifest = json.load(f)
state[plugin_id] = {
'exists_on_disk': True,
'version': manifest.get('version'),
'name': manifest.get('name')
}
except Exception: # nosec B110 - corrupt/unreadable manifest; skip this plugin, outer except logs
pass
# Membership comes from the shared extractor so the web interface
# re-checks stored findings against this same definition; the
# manifest is then re-read here only for version/name.
for plugin_id in disk_plugin_ids(self.plugins_dir):
manifest_path = self.plugins_dir / plugin_id / "manifest.json"
try:
with open(manifest_path, 'r') as f:
manifest = json.load(f)
except (OSError, ValueError): # nosec B112 - raced or corrupt; skip
continue
state[plugin_id] = {
'exists_on_disk': True,
'version': manifest.get('version'),
'name': manifest.get('name')
}
except Exception as e:
self.logger.warning(f"Error reading disk state: {e}")
return state
+947
View File
@@ -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"
]
]
]
+60
View File
@@ -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"
+93
View File
@@ -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()
+8 -3
View File
@@ -106,7 +106,12 @@ class TestMissingBodyGivesTheDeclaredError:
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):
"""`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.
"""
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
]
assert offenders == [], (
@@ -124,7 +129,7 @@ class TestNoBodyReadContradictsItsOwnGuard:
def test_no_not_data_guard_is_unreachable(self):
"""A `if not data:` guard needs a read that can actually return None."""
lines = self.SOURCE.read_text().splitlines()
lines = self._source.splitlines()
offenders = []
for i, line in enumerate(lines):
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
+136
View File
@@ -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]
+79 -1
View File
@@ -195,7 +195,7 @@ class TestRadio:
@pytest.mark.parametrize("raw,expected", [
(True, True), ("true", True), ("1", True), ("yes", True),
(False, False), ("false", False), ("off", False), (0, False),
(False, False), ("false", False), ("no", False), (0, False),
])
def test_enabled_coercion_is_string_aware(
self, api_v3_client, wifi_manager, raw, expected):
@@ -228,6 +228,84 @@ class TestRadio:
wifi_manager.set_wifi_radio.side_effect = RuntimeError("boom")
assert api_v3_client.post(self.URL, json={"enabled": True}).status_code == 500
def test_unrecognized_enabled_value_is_rejected_not_treated_as_false(
self, api_v3_client, wifi_manager):
# An invalid `enabled` used to silently fall back to False, which
# can disconnect Wi-Fi (or, with force=true, drop the caller's own
# connection to this interface) even though nothing asked for that.
response = api_v3_client.post(self.URL, json={"enabled": "typo"})
assert response.status_code == 400
wifi_manager.set_wifi_radio.assert_not_called()
def test_unrecognized_force_value_is_rejected_not_treated_as_false(
self, api_v3_client, wifi_manager):
response = api_v3_client.post(
self.URL, json={"enabled": False, "force": "typo"})
assert response.status_code == 400
wifi_manager.set_wifi_radio.assert_not_called()
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
def test_unrecognized_value_is_rejected_not_treated_as_false(
self, api_v3_client, wifi_manager):
wifi_manager.config = {}
response = api_v3_client.post(self.URL, json={"auto_enable_ap_mode": "typo"})
assert response.status_code == 400
assert "auto_enable_ap_mode" not in wifi_manager.config
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:
def test_wifi_manager_is_never_constructed_for_real(self, api_v3_client):
+50 -1
View File
@@ -103,6 +103,54 @@ def test_non_dict_input_passes_through():
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_objects_inside_a_credential_named_list_are_blanked_regardless_of_field_names():
"""A list item has no field name of its own to test against
`_CREDENTIAL_NAME_PARTS`, so an object reached through a credential-owned
list must be blanked outright rather than walked by field name -- unlike
the dict-directly-under-a-credential-key case in the test above.
"""
config = {"tokens": [{"value": "secret", "kind": "bearer"}]}
out = _redact_credentials(config)
assert out["tokens"] == [{"value": "", "kind": ""}]
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():
"""Through the view function, not the helper.
@@ -130,7 +178,8 @@ def test_the_endpoint_itself_redacts():
app = flask.Flask(__name__)
try:
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)
finally:
mod.api_v3.config_manager = previous
+167
View File
@@ -0,0 +1,167 @@
"""The reconciliation-status endpoint must re-check its stored verdict.
#557 added _drop_stale_reconciliation_findings() so a resolved condition stops
being reported: the verdict is a snapshot written once per run, and a run that
fails to apply a fix also declines to retry, so a device whose plugins were all
present in config kept being told for hours that four of them were missing.
That wiring had no test. Only the pure still_unresolved() helper in
src/plugin_system/ was covered, which lives outside web_interface and therefore
survived the api_v3 blueprint split untouched -- so when #553 moved this
endpoint into api_v3/plugins.py, losing the filter would have been completely
silent. This test exists so that cannot happen again.
"""
import json
import sys
import tempfile
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from flask import Flask
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from web_interface.blueprints.api_v3 import api_v3 # noqa: E402
IN_CONFIG = "plugin_missing_in_config"
ON_DISK = "plugin_missing_on_disk"
@pytest.fixture
def client(tmp_path, monkeypatch):
"""App whose status file, config and plugins dir are all under our control."""
monkeypatch.setattr(tempfile, "gettempdir", lambda: str(tmp_path))
plugins_dir = tmp_path / "plugin-repos"
plugins_dir.mkdir()
def _install(plugin_id):
d = plugins_dir / plugin_id
d.mkdir()
(d / "manifest.json").write_text(json.dumps({"id": plugin_id}), encoding="utf-8")
def _setup(verdict, config=None, installed=(), secrets=None, corrupt=()):
(tmp_path / "ledmatrix_reconciliation.json").write_text(
json.dumps(verdict), encoding="utf-8")
for pid in installed:
_install(pid)
for pid in corrupt:
d = plugins_dir / pid
d.mkdir(exist_ok=True)
(d / "manifest.json").write_text("{ this is not json", encoding="utf-8")
secrets_path = tmp_path / "config_secrets.json"
secrets_path.write_text(json.dumps(secrets or {}), encoding="utf-8")
cm = MagicMock()
cm.load_config.return_value = dict(config or {})
cm.get_secrets_path.return_value = str(secrets_path)
pm = MagicMock()
pm.plugins_dir = str(plugins_dir)
# setattr via monkeypatch: these live on a module-level blueprint
# singleton, so assigning them directly leaks mocks -- pointing at a
# deleted tmp_path -- into every later test that imports api_v3.
monkeypatch.setattr(api_v3, "config_manager", cm, raising=False)
monkeypatch.setattr(api_v3, "plugin_manager", pm, raising=False)
app = Flask(__name__)
app.config["TESTING"] = True
app.register_blueprint(api_v3, url_prefix="/api/v3")
return app.test_client()
return _setup
def _get(c):
return c.get("/api/v3/plugins/reconciliation-status").get_json()["data"]
class TestStaleFindingsAreDroppedByTheEndpoint:
def test_a_plugin_now_in_config_is_no_longer_reported(self, client):
c = client({"done": True, "unresolved": [
{"plugin_id": "football-scoreboard", "type": IN_CONFIG}]},
config={"football-scoreboard": {"enabled": True}})
# The regression: this kept being reported for hours after it resolved.
assert _get(c)["unresolved"] == []
def test_a_plugin_now_installed_is_no_longer_reported(self, client):
c = client({"done": True, "unresolved": [
{"plugin_id": "odds-ticker", "type": ON_DISK}]},
installed=["odds-ticker"])
assert _get(c)["unresolved"] == []
def test_a_finding_that_still_holds_is_kept(self, client):
c = client({"done": True, "unresolved": [
{"plugin_id": "ghost-plugin", "type": ON_DISK}]})
assert [e["plugin_id"] for e in _get(c)["unresolved"]] == ["ghost-plugin"]
def test_the_reported_device_verdict_clears(self, client):
"""The five findings the live device served, against its real state."""
installed = ["football-scoreboard", "ledmatrix-weather",
"odds-ticker", "starlark-apps"]
c = client({"done": True, "unresolved": [
{"plugin_id": p, "type": IN_CONFIG} for p in installed]},
config={p: {"enabled": True} for p in installed},
installed=installed)
assert _get(c)["unresolved"] == []
class TestTheEndpointStaysRobust:
def test_an_empty_verdict_is_passed_through(self, client):
c = client({"done": True, "unresolved": []})
assert _get(c) == {"done": True, "unresolved": []}
def test_a_broken_config_manager_leaves_findings_untouched(self, client):
"""Best-effort: a stale warning beats a failed endpoint."""
c = client({"done": True, "unresolved": [
{"plugin_id": "football-scoreboard", "type": IN_CONFIG}]})
api_v3.config_manager.load_config.side_effect = OSError("config unreadable")
body = _get(c)
assert [e["plugin_id"] for e in body["unresolved"]] == ["football-scoreboard"]
def test_a_run_still_in_progress_is_reported_as_such(self, client):
c = client({"done": False, "unresolved": []})
assert _get(c)["done"] is False
class TestTheFilterUsesTheReconcilersOwnRules:
"""A looser definition of "in config" or "on disk" clears findings that are
still true. Both cases raised by CodeRabbit on #553."""
def test_a_secrets_key_does_not_clear_an_in_config_finding(self, client):
# load_config() merges the secrets file in, so 'data' appears in the
# config dict -- but it is not a plugin. A plain set(config) would treat
# it as one and clear this finding.
c = client({"done": True, "unresolved": [
{"plugin_id": "data", "type": IN_CONFIG}]},
config={"data": {"mode": "nfl_recent"}},
secrets={"data": {"mode": "nfl_recent"}})
assert [e["plugin_id"] for e in _get(c)["unresolved"]] == ["data"]
def test_a_system_key_does_not_clear_an_in_config_finding(self, client):
c = client({"done": True, "unresolved": [
{"plugin_id": "display", "type": IN_CONFIG}]},
config={"display": {"hardware": {}}})
assert [e["plugin_id"] for e in _get(c)["unresolved"]] == ["display"]
def test_a_non_dict_value_does_not_clear_an_in_config_finding(self, client):
c = client({"done": True, "unresolved": [
{"plugin_id": "timezone", "type": IN_CONFIG}]},
config={"timezone": "America/Chicago"})
assert [e["plugin_id"] for e in _get(c)["unresolved"]] == ["timezone"]
def test_an_unparseable_manifest_does_not_count_as_installed(self, client):
# Otherwise a corrupt file clears a live "in config but not on disk"
# finding on the strength of something nothing can read.
c = client({"done": True, "unresolved": [
{"plugin_id": "broken-plugin", "type": ON_DISK}]},
corrupt=["broken-plugin"])
assert [e["plugin_id"] for e in _get(c)["unresolved"]] == ["broken-plugin"]
def test_a_standalone_backup_dir_does_not_count_as_installed(self, client):
c = client({"done": True, "unresolved": [
{"plugin_id": "weather.standalone-backup-20260101", "type": ON_DISK}]},
installed=["weather.standalone-backup-20260101"])
assert len(_get(c)["unresolved"]) == 1
+45
View File
@@ -6,6 +6,7 @@ frame for the whole display_duration instead of rotating on -- the same class
of defect as a display() that returns None.
"""
import json
import sys
import types
from pathlib import Path
@@ -197,6 +198,50 @@ class TestInstalledAppsTakeTurns:
assert p.display(force_clear=True) is False
class TestManifestLockSidecarIsStable:
"""_save_manifest and _update_manifest_safe must lock a sidecar file that
a manifest write never replaces. manifest.json itself is swapped for a
fresh inode by every atomic write (temp file + rename); a lock taken on
manifest.json directly would not exclude a second locker whose fresh
os.open() lands on that new inode right after a write, so two writers
could still race each other despite each believing it held the lock.
web_interface._starlark_manifest_lock (web_interface/blueprints/api_v3)
must lock this same sidecar name for that guarantee to hold across both
the plugin-loaded and standalone paths.
"""
def _plugin_with_manifest_dir(self, manager_module, tmp_path):
p = _plugin(manager_module)
p.manifest_file = tmp_path / "manifest.json"
p.manifest_lock_file = tmp_path / "manifest.json.lock"
return p
def test_the_lock_sidecar_is_not_the_manifest_file(self, manager_module, tmp_path):
p = self._plugin_with_manifest_dir(manager_module, tmp_path)
assert p.manifest_lock_file != p.manifest_file
assert p.manifest_lock_file.name == "manifest.json.lock"
def test_the_locked_files_inode_survives_repeated_writes(self, manager_module, tmp_path):
p = self._plugin_with_manifest_dir(manager_module, tmp_path)
assert p._save_manifest({"apps": {}})
lock_ino_before = p.manifest_lock_file.stat().st_ino
for app_id in ("one", "two", "three"):
def _update(manifest, app_id=app_id):
manifest.setdefault("apps", {})[app_id] = {"enabled": True}
assert p._update_manifest_safe(_update)
lock_ino_after = p.manifest_lock_file.stat().st_ino
assert lock_ino_after == lock_ino_before, (
"the locked file's inode changed across writes -- a locker that "
"opened it before this write and one that opens it after would "
"no longer contend for the same lock")
manifest = json.loads(p.manifest_file.read_text())
assert set(manifest["apps"]) == {"one", "two", "three"}
class TestAnimationsRunAtFrameRate:
def test_the_plugin_asks_for_the_high_fps_loop(self, manager_module):
"""The controller reads this attribute; a multi-frame app is otherwise
+4 -1
View File
@@ -470,7 +470,10 @@ class TestApiBoundsMatchValidate:
"""Extract the numeric_fields map from api_v3 without importing Flask."""
import ast
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)
for node in ast.walk(tree):
if not isinstance(node, ast.Assign):
+4 -4
View File
@@ -390,7 +390,7 @@ class TestConfigAPI:
class TestSystemAPI:
"""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):
"""Test getting system status."""
# The endpoint returns 503 without psutil, which is an optional
@@ -407,7 +407,7 @@ class TestSystemAPI:
data = json.loads(response.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):
"""Test getting system version."""
mock_result = MagicMock()
@@ -421,7 +421,7 @@ class TestSystemAPI:
data = json.loads(response.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):
"""Test executing system action."""
mock_result = MagicMock()
@@ -502,7 +502,7 @@ class TestDisplayAPI:
if response.status_code in [200, 201]:
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):
"""Test stopping on-demand display."""
+5 -1
View File
@@ -114,7 +114,11 @@ class TestHandlersCarryDetail:
"""
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)
# This used to match one exact message string, so a handler that wrote
@@ -139,6 +139,58 @@ class TestRequestValidation:
assert post(client, options="{}").status_code == 200
assert restore.call_args[0][2].restore_config is True
def test_unknown_option_key_is_refused(self, client, restore):
# Regression: opts_dict.get('restore_secrets', True) silently
# ignores a typo'd/renamed key like "restoreSecrets" and keeps the
# True default, restoring secrets a caller's request clearly meant
# to exclude -- with no indication anything was wrong.
response = post(client, options=json.dumps({"restoreSecrets": False}))
assert response.status_code == 400
assert "Unknown restore option" in response.get_json()["message"]
assert "restoreSecrets" in response.get_json()["message"]
restore.assert_not_called()
def test_known_and_unknown_keys_together_are_refused(self, client, restore):
response = post(client, options=json.dumps({
"restore_secrets": False, "restore_everything": True}))
assert response.status_code == 400
restore.assert_not_called()
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:
def test_success_returns_the_result(self, client, restore):
@@ -230,7 +282,7 @@ class TestPluginReinstall:
def test_missing_store_manager_is_reported_per_plugin(self, client, restore):
restore.return_value = FakeResult(plugins_to_install=[{"plugin_id": "clock"}])
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()
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'
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')
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/authenticate'" in source
@@ -360,6 +363,22 @@ class TestDiagnosticsAreRedacted:
assert 'hunter2' not 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):
(client.plugin_dir / 'credentials.json').write_text('{}', encoding='utf-8')
(client.plugin_dir / 'calendar_registration.py').write_text(
@@ -10,8 +10,8 @@ from pathlib import Path
import pytest
SOURCE = (Path(__file__).resolve().parents[2]
/ "web_interface" / "blueprints" / "api_v3.py")
API_V3_PKG = (Path(__file__).resolve().parents[2]
/ "web_interface" / "blueprints" / "api_v3")
#: Objects that still hold submitted secret values at the point these log
#: 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():
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()
if stripped.startswith("#"):
continue
@@ -15,8 +15,18 @@ from pathlib import Path
from src.web_interface.secret_helpers import find_secret_fields, separate_secrets
API_V3_PATH = (Path(__file__).resolve().parents[2]
/ "web_interface" / "blueprints" / "api_v3.py")
API_V3_PKG = (Path(__file__).resolve().parents[2]
/ "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.
EXPECTED_INLINE_COPIES = 0
@@ -24,7 +34,7 @@ EXPECTED_INLINE_COPIES = 0
class TestNoInlineCopies:
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))
def test_no_inline_find_secret_fields(self):
@@ -46,7 +56,7 @@ class TestNoInlineCopies:
def test_canonical_import_present(self):
# Tripwire: the endpoints still need the helpers, so removing the
# 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(
r"from src\.web_interface\.secret_helpers import .*find_secret_fields",
source,
@@ -63,14 +63,14 @@ class TestRoutesAreRegistered:
class TestInstallPixlet:
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="")
resp = client.post('/api/v3/starlark/install-pixlet')
assert resp.status_code != 404, "the route is still missing"
assert resp.get_json().get('message') != 'Resource not found'
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="")
resp = client.post('/api/v3/starlark/install-pixlet')
body = resp.get_json()
@@ -78,7 +78,7 @@ class TestInstallPixlet:
assert 'message' in body, "the JS shows data.message on success"
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")
resp = client.post('/api/v3/starlark/install-pixlet')
body = resp.get_json()
@@ -88,7 +88,7 @@ class TestInstallPixlet:
def test_a_timeout_is_reported_rather_than_hanging(self, client):
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)):
resp = client.post('/api/v3/starlark/install-pixlet')
assert resp.get_json()['status'] == 'error'
@@ -395,6 +395,164 @@ class TestTheManifestStaysRelocatable:
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')
monkeypatch.setattr(module, '_STARLARK_MANIFEST_LOCK_FILE', apps_dir / 'manifest.json.lock')
module._write_starlark_manifest({'apps': {}})
return apps_dir
def test_the_locked_file_survives_a_manifest_write(self, starlark_dir):
"""_write_starlark_manifest replaces manifest.json with a fresh inode
on every write (temp file + rename). If the lock were taken on that
same file, a second locker's fresh os.open() right after the rename
would land on the new inode -- unguarded, because only the old,
now-orphaned inode was ever locked -- and two writers could race
despite each believing it "held the lock" (see the docstring on
_starlark_manifest_lock). Locking a sidecar path that no write ever
touches or renames over closes that: the inode identity of what gets
locked must not change across writes.
"""
import os
from web_interface.blueprints import api_v3 as module
with module._starlark_manifest_lock():
manifest = module._read_starlark_manifest()
lock_ino_before = os.stat(module._STARLARK_MANIFEST_LOCK_FILE).st_ino
for app_id in ('one', 'two', 'three'):
with module._starlark_manifest_lock():
manifest = module._read_starlark_manifest()
manifest.setdefault('apps', {})[app_id] = {'enabled': True}
assert module._write_starlark_manifest(manifest)
lock_ino_after = os.stat(module._STARLARK_MANIFEST_LOCK_FILE).st_ino
assert lock_ino_after == lock_ino_before, (
"the locked file's inode changed across writes -- a locker that "
"opened it before this write and one that opens it after would "
"no longer contend for the same lock")
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.
#
@@ -19,7 +19,12 @@ import re
from pathlib import Path
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"
@@ -51,7 +56,7 @@ def _granted_systemctl_rules(script: str) -> set[tuple[str, str]]:
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())
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:
"""Privileged systemctl calls must name the unit as <name>.service so they
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")}
assert not unqualified, (
"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
+230
View File
@@ -0,0 +1,230 @@
"""Backup creation, listing and restore.
Routes decorate the shared `api_v3` Blueprint from ._common, so their
endpoint names are unchanged by living here.
"""
from web_interface.blueprints.api_v3 import (
PROJECT_ROOT, Path, _coerce_to_bool, _safe_backup_path, api_v3,
datetime, json, jsonify, logger, os, plugin_store_manager, request,
tempfile,
)
import web_interface.blueprints.api_v3 as _pkg
# Read through the module rather than bound by value: tests patch these
# as module attributes, and a value binding would not see the patch.
# Several are also called from helpers that live in __init__, so the
# package is the only patch point that covers every caller.
@api_v3.route('/backup/preview', methods=['GET'])
def backup_preview():
"""Return a summary of what a new backup would include."""
try:
from src.backup_manager import preview_backup_contents
data = preview_backup_contents(PROJECT_ROOT)
return jsonify({'status': 'success', 'data': data})
except Exception as e:
logger.error("backup_preview failed: %s", e, exc_info=True)
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
@api_v3.route('/backup/list', methods=['GET'])
def backup_list():
"""List backup ZIPs stored in the export directory."""
try:
_pkg._BACKUP_EXPORT_DIR.mkdir(parents=True, exist_ok=True)
entries = []
for p in sorted(_pkg._BACKUP_EXPORT_DIR.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True):
if not p.is_file() or p.suffix != '.zip':
continue
st = p.stat()
entries.append({
'filename': p.name,
'size': st.st_size,
'created_at': datetime.fromtimestamp(st.st_mtime).strftime('%Y-%m-%d %H:%M:%S'),
})
return jsonify({'status': 'success', 'data': entries})
except Exception as e:
logger.error("backup_list failed: %s", e, exc_info=True)
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
@api_v3.route('/backup/export', methods=['POST'])
def backup_export():
"""Create a new backup ZIP and return its filename."""
try:
from src.backup_manager import create_backup
zip_path = create_backup(PROJECT_ROOT, output_dir=_pkg._BACKUP_EXPORT_DIR)
return jsonify({'status': 'success', 'filename': zip_path.name})
except Exception as e:
logger.error("backup_export failed: %s", e, exc_info=True)
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
@api_v3.route('/backup/validate', methods=['POST'])
def backup_validate():
"""Validate an uploaded backup ZIP and return its manifest."""
try:
from src.backup_manager import validate_backup
if 'backup_file' not in request.files:
return jsonify({'status': 'error', 'message': 'No backup_file in request'}), 400
f = request.files['backup_file']
with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as tmp:
tmp_path = tmp.name
f.save(tmp_path)
try:
ok, err_msg, manifest = validate_backup(Path(tmp_path))
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
if not ok:
logger.warning("Backup validation failed: %s", err_msg)
return jsonify({'status': 'error', 'message': 'Invalid or corrupted backup file'}), 400
safe_manifest = {
'schema_version': manifest.get('schema_version'),
'created_at': manifest.get('created_at'),
'ledmatrix_version': manifest.get('ledmatrix_version'),
'hostname': manifest.get('hostname'),
'contents': manifest.get('contents', []),
'detected_contents': manifest.get('detected_contents', []),
'plugins': manifest.get('plugins', []),
'total_uncompressed': manifest.get('total_uncompressed'),
'file_count': manifest.get('file_count'),
}
return jsonify({'status': 'success', 'data': safe_manifest})
except Exception as e:
logger.error("backup_validate failed: %s", e, exc_info=True)
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
#: The only keys RestoreOptions recognizes. A typo'd or renamed key (e.g.
#: "restoreSecrets") would otherwise be silently ignored by opts_dict.get(),
#: leaving that flag at its True default -- restoring secrets a caller's
#: request clearly meant to exclude, with no indication anything was wrong.
_RESTORE_OPTION_KEYS = frozenset((
'restore_config', 'restore_secrets', 'restore_wifi', 'restore_fonts',
'restore_plugin_uploads', 'reinstall_plugins',
))
@api_v3.route('/backup/restore', methods=['POST'])
def backup_restore():
"""Restore a backup ZIP with optional RestoreOptions."""
try:
from src.backup_manager import restore_backup, RestoreOptions
if 'backup_file' not in request.files:
return jsonify({'status': 'error', 'message': 'No backup_file in request'}), 400
f = request.files['backup_file']
options_raw = request.form.get('options', '{}')
try:
opts_dict = json.loads(options_raw)
except json.JSONDecodeError:
opts_dict = None
if not isinstance(opts_dict, dict):
# Every option defaults to True, so falling back to {} on a
# parse failure would silently perform a FULL restore —
# secrets and all — for a caller who asked for a narrow one
# and mis-serialized it. Refuse instead of guessing.
return jsonify({
'status': 'error',
'message': 'Invalid options: expected a JSON object',
}), 400
unknown_keys = set(opts_dict) - _RESTORE_OPTION_KEYS
if unknown_keys:
return jsonify({
'status': 'error',
'message': f'Unknown restore option(s): {", ".join(sorted(unknown_keys))}',
}), 400
# _coerce_to_bool (not bare bool()) because a request can send these
# as JSON strings: bool("false") is True in Python, so a caller who
# explicitly asked to skip secrets would have had them restored
# anyway.
options = RestoreOptions(
restore_config=_coerce_to_bool(opts_dict.get('restore_config', True)),
restore_secrets=_coerce_to_bool(opts_dict.get('restore_secrets', True)),
restore_wifi=_coerce_to_bool(opts_dict.get('restore_wifi', True)),
restore_fonts=_coerce_to_bool(opts_dict.get('restore_fonts', True)),
restore_plugin_uploads=_coerce_to_bool(opts_dict.get('restore_plugin_uploads', True)),
reinstall_plugins=_coerce_to_bool(opts_dict.get('reinstall_plugins', True)),
)
with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as tmp:
tmp_path = tmp.name
f.save(tmp_path)
try:
result = restore_backup(Path(tmp_path), PROJECT_ROOT, options)
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
# Reinstall plugins if requested and store manager available
if options.reinstall_plugins and result.plugins_to_install:
psm = getattr(api_v3, 'plugin_store_manager', None) or plugin_store_manager
for plug in result.plugins_to_install:
pid = plug.get('plugin_id')
if not pid:
continue
try:
if psm and hasattr(psm, 'install_plugin'):
ok = psm.install_plugin(pid)
if ok:
result.plugins_installed.append(pid)
else:
result.plugins_failed.append({'plugin_id': pid, 'error': 'install_plugin returned False'})
else:
result.plugins_failed.append({'plugin_id': pid, 'error': 'Store manager unavailable'})
except Exception as pe:
logger.error(
"[Backup] Failed to reinstall plugin %r: %s", pid, pe, exc_info=True
)
result.plugins_failed.append({'plugin_id': pid, 'error': 'Installation failed; see server logs'})
# A restore that dropped files can still report success if the only
# failures were plugin reinstalls, since those don't touch result.errors.
if result.plugins_failed:
result.success = False
data = result.to_dict()
if not result.success:
# Name what failed, and what nonetheless landed. A restore is
# partial far more often than it is total -- a fresh install can
# leave config_secrets.json unwritable by the web service, so
# config restores and secrets do not. "Restore had errors" alone
# left the user unable to tell a wholly failed restore from one
# that quietly dropped their API keys.
failed_plugins = [
str(p.get('plugin_id')) for p in (result.plugins_failed or []) if p.get('plugin_id')
]
parts = []
if result.restored:
parts.append(f"restored: {', '.join(result.restored)}")
if result.errors:
parts.append(f"failed: {'; '.join(result.errors)}")
if failed_plugins:
parts.append(f"plugins not reinstalled: {', '.join(failed_plugins)}")
message = 'Restore incomplete — ' + ('. '.join(parts) if parts else 'see logs')
return jsonify({'status': 'error', 'message': message, 'data': data}), 500
return jsonify({'status': 'success', 'data': data})
except Exception as e:
logger.error("backup_restore failed: %s", e, exc_info=True)
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
@api_v3.route('/backup/download/<path:filename>', methods=['GET'])
def backup_download(filename):
"""Stream a backup ZIP to the browser."""
from flask import send_from_directory
if _safe_backup_path(filename) is None:
return jsonify({'status': 'error', 'message': 'Backup not found'}), 404
try:
# send_from_directory uses werkzeug safe_join internally — CodeQL-recognized sanitizer.
return send_from_directory(_pkg._BACKUP_EXPORT_DIR, filename, as_attachment=True)
except FileNotFoundError:
return jsonify({'status': 'error', 'message': 'Backup not found'}), 404
@api_v3.route('/backup/<path:filename>', methods=['DELETE'])
def backup_delete(filename):
"""Delete a stored backup ZIP."""
safe = _safe_backup_path(filename)
if safe is None:
return jsonify({'status': 'error', 'message': 'Backup not found'}), 404
# Enumerate the export directory and match by name so the unlink target is
# a filesystem-derived path rather than one constructed from user input.
try:
for entry in _pkg._BACKUP_EXPORT_DIR.iterdir():
if entry.is_file() and entry.name == safe.name:
entry.unlink()
return jsonify({'status': 'success'})
except OSError as e:
logger.error("backup_delete failed: %s", e, exc_info=True)
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
return jsonify({'status': 'error', 'message': 'Backup not found'}), 404
File diff suppressed because it is too large Load Diff
+326
View File
@@ -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
+467
View File
@@ -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
+398
View File
@@ -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
+712
View File
@@ -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
+662
View File
@@ -0,0 +1,662 @@
"""Service control, updates, versions and system status.
Routes decorate the shared `api_v3` Blueprint from ._common, so their
endpoint names are unchanged by living here.
"""
from web_interface.blueprints.api_v3 import (
Any, Dict, PROJECT_ROOT, Path, _GIT, _UPDATE_CHECK_TTL,
_describe_git_failure, _get_display_service_status, _git_current_branch,
_git_remote_branch_exists, _git_upstream, _pip_install_requirements,
_scrub_git_remote_url, _truncate_output, _update_check_cache,
_update_check_failed, api_v3, checkout_branch, describe_exception,
get_git_version, jsonify, logger, os, request, resolve_pull_command,
shutil, subprocess,
)
import web_interface.blueprints.api_v3 as _pkg
# Read through the module rather than bound by value: tests patch these
# as module attributes, and a value binding would not see the patch.
# Several are also called from helpers that live in __init__, so the
# package is the only patch point that covers every caller.
@api_v3.route('/system/status', methods=['GET'])
def get_system_status():
"""Get system status"""
try:
# Check cache first (10 second TTL for system status)
try:
from web_interface.cache import get_cached, set_cached
cached_result = get_cached('system_status', ttl_seconds=10)
if cached_result is not None:
return jsonify({'status': 'success', 'data': cached_result})
except ImportError:
# Cache not available, continue without caching
get_cached = None
set_cached = None
# Import psutil for system monitoring
try:
import psutil
except ImportError:
# Fallback if psutil not available
return jsonify({
'status': 'error',
'message': 'psutil not available for system monitoring'
}), 503
# Get system metrics using psutil
cpu_percent = psutil.cpu_percent(interval=0.1) # Short interval for responsiveness
memory = psutil.virtual_memory()
memory_percent = memory.percent
disk = psutil.disk_usage('/')
disk_percent = disk.percent
# Calculate uptime
boot_time = psutil.boot_time()
uptime_seconds = _pkg.time.time() - boot_time
uptime_hours = uptime_seconds / 3600
uptime_days = uptime_hours / 24
# Format uptime string
if uptime_days >= 1:
uptime_str = f"{int(uptime_days)}d {int(uptime_hours % 24)}h"
elif uptime_hours >= 1:
uptime_str = f"{int(uptime_hours)}h {int((uptime_seconds % 3600) / 60)}m"
else:
uptime_str = f"{int(uptime_seconds / 60)}m"
# Get CPU temperature (Raspberry Pi)
cpu_temp = None
try:
temp_file = '/sys/class/thermal/thermal_zone0/temp'
if os.path.exists(temp_file):
with open(temp_file, 'r') as f:
temp_millidegrees = int(f.read().strip())
cpu_temp = temp_millidegrees / 1000.0 # Convert to Celsius
except (IOError, ValueError, OSError):
# Temperature sensor not available or error reading
cpu_temp = None
# Get display service status
service_status = _get_display_service_status()
status = {
'timestamp': _pkg.time.time(),
'uptime': uptime_str,
'uptime_seconds': int(uptime_seconds),
'service_active': service_status.get('active', False),
'cpu_percent': round(cpu_percent, 1),
'memory_used_percent': round(memory_percent, 1),
'memory_total_mb': round(memory.total / (1024 * 1024), 1),
'memory_used_mb': round(memory.used / (1024 * 1024), 1),
# MemAvailable, not total-minus-used: it accounts for reclaimable
# page cache, so it is what actually predicts memory trouble. A
# board can read 70% "used" and be fine, or read the same and be
# about to fail fork(), and only this number tells them apart.
'memory_available_mb': round(memory.available / (1024 * 1024), 1),
'cpu_temp': round(cpu_temp, 1) if cpu_temp is not None else None,
'disk_used_percent': round(disk_percent, 1),
'disk_total_gb': round(disk.total / (1024 * 1024 * 1024), 1),
'disk_used_gb': round(disk.used / (1024 * 1024 * 1024), 1)
}
# Cache the result if available
if set_cached:
try:
set_cached('system_status', status, ttl_seconds=10)
except Exception:
pass # Cache write failed, but continue
return jsonify({'status': 'success', 'data': status})
except Exception as e:
logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/system/version', methods=['GET'])
def get_system_version():
"""Get LEDMatrix repository version"""
try:
version = get_git_version()
return jsonify({'status': 'success', 'data': {'version': version}})
except Exception as e:
logger.error("get_system_version failed: %s", e, exc_info=True)
return jsonify({'status': 'error', 'message': 'Unable to retrieve version'}), 500
@api_v3.route('/system/check-update', methods=['GET'])
def check_for_update():
"""Check whether a newer LEDMatrix commit is available on origin/main."""
now = _pkg.time.time()
if _update_check_cache['result'] and now - _update_check_cache['ts'] < _UPDATE_CHECK_TTL:
return jsonify(_update_check_cache['result'])
_safe: Dict[str, Any] = {'update_available': False, 'remote_sha': 'unknown', 'commits_behind': 0}
try:
cwd = str(PROJECT_ROOT)
fetch_result = subprocess.run(
['git', 'fetch', 'origin', 'main', '--quiet'],
capture_output=True, timeout=10, cwd=cwd,
)
if fetch_result.returncode != 0:
stderr = fetch_result.stderr.decode(errors='replace').strip()
logger.warning("check-update: git fetch failed (rc=%d): %s",
fetch_result.returncode, stderr)
failed = _update_check_failed(_describe_git_failure(stderr))
_update_check_cache['result'] = failed
_update_check_cache['ts'] = now
return jsonify(failed)
local = subprocess.run(
['git', 'rev-parse', 'HEAD'],
capture_output=True, text=True, timeout=5, cwd=cwd,
).stdout.strip()
remote = subprocess.run(
['git', 'rev-parse', 'origin/main'],
capture_output=True, text=True, timeout=5, cwd=cwd,
).stdout.strip()
if not local or not remote:
return jsonify(_safe)
if local == remote:
result: Dict[str, Any] = {'update_available': False, 'remote_sha': remote, 'commits_behind': 0}
else:
count_str = subprocess.run(
['git', 'rev-list', 'HEAD..origin/main', '--count'],
capture_output=True, text=True, timeout=5, cwd=cwd,
).stdout.strip()
count = int(count_str) if count_str.isdigit() else 0
result = {'update_available': count > 0, 'remote_sha': remote, 'commits_behind': count}
_update_check_cache['result'] = result
_update_check_cache['ts'] = now
return jsonify(result)
except Exception as e:
logger.warning("check-update failed: %s", e)
return jsonify(_update_check_failed(
"Could not check for updates; see logs for details."))
#: sudo's own wording when it needs a password it cannot ask for. The web
#: interface runs unprivileged, so its systemctl/reboot/journalctl calls only
#: work once scripts/install/configure_web_sudo.sh has granted NOPASSWD --
#: which first_time_install.sh does not do. That makes this the common case on
#: a fresh device, and "Action failed; see logs for details" named none of it,
#: while the log viewer was broken for the very same reason.
_SUDO_NEEDS_PASSWORD = (
'a password is required',
'no tty present',
'a terminal is required',
)
_SUDO_HINT = (
'Passwordless sudo is not configured for the web interface user, so this '
'action cannot run. Run scripts/install/configure_web_sudo.sh as that user, '
'then retry.'
)
def _sudo_hint_for(text):
"""An actionable hint when `text` is sudo refusing to prompt, else None."""
lowered = (text or '').lower()
if any(marker in lowered for marker in _SUDO_NEEDS_PASSWORD):
return _SUDO_HINT
return None
@api_v3.route('/system/action', methods=['POST'])
def execute_system_action():
"""Execute system actions (start/stop/reboot/etc)"""
try:
# HTMX sends data as form data, not JSON
data = request.get_json(silent=True) or {}
if not data:
# Try to get from form data if JSON fails
data = {
'action': request.form.get('action'),
'mode': request.form.get('mode')
}
if not data or 'action' not in data:
return jsonify({'status': 'error', 'message': 'Action required'}), 400
action = data['action']
mode = data.get('mode') # For on-demand modes
# Map actions to subprocess calls (similar to original implementation)
if action == 'start_display':
if mode:
# For on-demand modes, we would need to integrate with the display controller
# For now, just start the display service
try:
result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix.service'],
capture_output=True, text=True, timeout=10)
except subprocess.TimeoutExpired as e:
logger.error("start_display (%s) timed out: %s", mode, e)
return jsonify({'status': 'error', 'message': 'Command timed out', 'returncode': -1, 'stderr': 'timeout'})
logger.info("start_display (%s) returned code %d", mode, result.returncode)
if result.returncode != 0 and result.stderr:
logger.error("start_display (%s) stderr: %s", mode, result.stderr.strip())
resp = {
'status': 'success' if result.returncode == 0 else 'error',
# This branch returns before the shared nonzero-result
# response below, so it needs the hint of its own or an
# on-demand start reports "Failed to start display" and
# says nothing about the sudo that actually refused it.
'message': (
'Display started' if result.returncode == 0
else _sudo_hint_for(result.stderr) or 'Failed to start display'
),
}
if result.returncode != 0:
resp['returncode'] = result.returncode
resp['stderr'] = result.stderr.strip()
return jsonify(resp)
else:
result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix.service'],
capture_output=True, text=True, timeout=10)
elif action == 'stop_display':
result = subprocess.run(['sudo', 'systemctl', 'stop', 'ledmatrix.service'],
capture_output=True, text=True, timeout=10)
elif action == 'enable_autostart':
result = subprocess.run(['sudo', 'systemctl', 'enable', 'ledmatrix.service'],
capture_output=True, text=True, timeout=10)
elif action == 'disable_autostart':
result = subprocess.run(['sudo', 'systemctl', 'disable', 'ledmatrix.service'],
capture_output=True, text=True, timeout=10)
elif action == 'reboot_system':
result = subprocess.run(['sudo', 'reboot'],
capture_output=True, text=True, timeout=10)
elif action == 'shutdown_system':
result = subprocess.run(['sudo', 'poweroff'],
capture_output=True, text=True, timeout=10)
elif action == 'git_pull':
# Use PROJECT_ROOT instead of hardcoded path
project_dir = str(PROJECT_ROOT)
# Decide how to pull BEFORE stashing. If this checkout cannot be
# updated at all, stashing first would put the user's local changes
# away for an update that was never going to run.
pull_args, upstream_note, pull_error = resolve_pull_command(project_dir)
if pull_error:
logger.warning("git pull not attempted: %s", pull_error)
return jsonify({'status': 'error', 'message': pull_error})
# Check if there are local changes that need to be stashed
# Exclude plugins directory - plugins are separate repos and shouldn't be stashed with base project
# Use --untracked-files=no to skip untracked files check (much faster with symlinked plugins)
try:
status_result = subprocess.run(
['git', 'status', '--porcelain', '--untracked-files=no'],
capture_output=True,
text=True,
timeout=30,
cwd=project_dir
)
# Filter out any changes in plugins directory - plugins are separate repositories
# Git status format: XY filename (where X is status of index, Y is status of work tree)
status_lines = [line for line in status_result.stdout.strip().split('\n')
if line.strip() and 'plugins/' not in line]
has_changes = bool('\n'.join(status_lines).strip())
except subprocess.TimeoutExpired:
# If status check times out, assume there might be changes and proceed
# This is safer than failing the update
has_changes = True
status_result = type('obj', (object,), {'stdout': '', 'stderr': 'Status check timed out'})()
stash_info = ""
# Stash local changes if they exist (excluding plugins)
# Plugins are separate repositories and shouldn't be stashed with base project updates
if has_changes:
try:
# Use pathspec to exclude plugins directory from stash
stash_result = subprocess.run(
['git', 'stash', 'push', '-m', 'LEDMatrix auto-stash before update', '--', ':!plugins'],
capture_output=True,
text=True,
timeout=30,
cwd=project_dir
)
if stash_result.returncode == 0:
logger.debug("git stash: stashed local changes before pull")
stash_info = " Local changes were stashed."
else:
logger.warning("git stash failed before pull (returncode=%d)", stash_result.returncode)
except subprocess.TimeoutExpired:
logger.warning("git stash timed out, proceeding with pull")
# Record HEAD before the pull so dependency changes can be detected
old_head = None
try:
_pre = subprocess.run(['git', 'rev-parse', 'HEAD'],
capture_output=True, text=True, timeout=10, cwd=project_dir)
if _pre.returncode == 0:
old_head = _pre.stdout.strip()
except subprocess.TimeoutExpired:
logger.warning("git rev-parse timed out before pull")
# Whether the pull actually brought new code in. "Already up to
# date" is a success too, and prompting for a restart then would
# train users to ignore the prompt.
code_changed = False
# Perform the git pull. Branches without an upstream were given
# an explicit "origin <branch>" above so the update still works.
result = subprocess.run(
pull_args,
capture_output=True,
text=True,
timeout=60,
cwd=project_dir
)
# Give the branch tracking information so the next pull is a plain
# `git pull` — otherwise every update repeats the fallback.
if result.returncode == 0 and upstream_note:
branch = _git_current_branch(project_dir)
if branch:
try:
subprocess.run(
['git', 'branch', f'--set-upstream-to=origin/{branch}', branch],
capture_output=True, text=True, timeout=10, cwd=project_dir)
except (subprocess.TimeoutExpired, OSError) as exc:
logger.debug("could not set upstream for %s: %s", branch, exc)
# Return custom response for git_pull
if result.returncode == 0:
pull_message = "Code updated successfully."
if has_changes:
pull_message = f"Code updated successfully. Local changes were automatically stashed.{stash_info}"
if result.stdout and "Already up to date" not in result.stdout:
pull_message = f"Code updated successfully.{stash_info}"
if upstream_note:
pull_message = f"{pull_message} {upstream_note}"
# Keep Python dependencies in sync automatically: if the pull
# changed a requirements file, install it now — users updating
# from the web UI (most of them) never SSH in to pip install.
# Installs go through the same root-visible path as the
# Tools-tab buttons (_pip_install_requirements).
dep_notes = []
try:
_post = subprocess.run(['git', 'rev-parse', 'HEAD'],
capture_output=True, text=True, timeout=10, cwd=project_dir)
new_head = _post.stdout.strip() if _post.returncode == 0 else None
if old_head and new_head and old_head != new_head:
code_changed = True
diff = subprocess.run(
['git', 'diff', '--name-only', f'{old_head}..{new_head}'],
capture_output=True, text=True, timeout=15, cwd=project_dir)
changed = set(diff.stdout.split()) if diff.returncode == 0 else set()
for rel in ('requirements.txt', 'web_interface/requirements.txt'):
req_path = PROJECT_ROOT / rel
if rel not in changed or not req_path.exists():
continue
# Each file's install is isolated: a timeout or
# OSError (e.g. the sudo wrapper/interpreter
# missing) on one file must not abort the other.
try:
r = _pip_install_requirements(req_path, timeout=180)
if r.returncode == 0:
dep_notes.append(f"Dependencies from {rel} updated.")
else:
dep_notes.append(
f"Dependency install from {rel} failed — "
"run Install Base Requirements from the Tools tab.")
logger.warning("post-update pip install failed for %s: %s",
rel, _truncate_output(r.stdout, r.stderr))
except subprocess.TimeoutExpired:
dep_notes.append(
f"Dependency install from {rel} timed out — "
"run Install Base Requirements from the Tools tab.")
logger.warning("post-update pip install timed out for %s", rel)
except OSError as install_err:
dep_notes.append(
f"Dependency install from {rel} failed — "
"run Install Base Requirements from the Tools tab.")
logger.warning("post-update pip install errored for %s: %s",
rel, install_err)
except subprocess.TimeoutExpired:
logger.warning("post-update dependency sync timed out")
if dep_notes:
pull_message += " " + " ".join(dep_notes)
# A `git pull` restores built-in plugins (committed under
# plugin-repos/) even if the user uninstalled them. Re-remove
# any the user previously uninstalled so the update doesn't
# resurrect them.
if api_v3.plugin_store_manager:
try:
purged = api_v3.plugin_store_manager.purge_uninstalled_plugins()
if purged:
logger.info(
"Re-removed %d uninstalled plugin(s) restored by update: %s",
len(purged), ", ".join(purged),
)
except (OSError, RuntimeError) as purge_err:
logger.warning("Post-update plugin purge failed: %s", purge_err)
else:
logger.warning("git pull failed (returncode=%d): %s", result.returncode, result.stderr)
# Show git's own first line: "check logs" leaves the user with
# nothing to act on, and these failures are usually actionable
# (conflicting local commits, no upstream, network).
detail = next((ln.strip() for ln in (result.stderr or '').splitlines()
if ln.strip()), '')
pull_message = f"Update failed: {detail}" if detail else "Update failed; check logs for details"
# Nothing here restarts anything: the pull replaces files on
# disk while the display and web services keep running the code
# they loaded at boot. Without this the user is told the update
# succeeded and sees no change until they happen to reboot.
return jsonify({
'status': 'success' if result.returncode == 0 else 'error',
'message': pull_message,
'restart_required': bool(result.returncode == 0 and code_changed),
})
elif action == 'checkout_branch':
# Switch branches from the Tools tab. Needed because a checkout
# that predates tracking (or a restored backup) can leave the pi
# on a branch the update button cannot pull.
result_payload, http_status = checkout_branch(
str(PROJECT_ROOT), data.get('branch') or '', stash=bool(data.get('stash')))
return jsonify(result_payload), http_status
elif action == 'restart_display_service':
result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix.service'],
capture_output=True, text=True, timeout=10)
elif action == 'restart_web_service':
# Try to restart the web service (assuming it's ledmatrix-web.service)
result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix-web.service'],
capture_output=True, text=True, timeout=10)
elif action == 'install_base_requirements':
# Base + web interface requirements: flask-compress and friends
# live in web_interface/requirements.txt, not the root file.
req_files = [f for f in (PROJECT_ROOT / 'requirements.txt',
PROJECT_ROOT / 'web_interface' / 'requirements.txt')
if f.exists()]
if not req_files:
return jsonify({'status': 'error', 'message': 'No requirements.txt found at project root'})
outputs = []
all_ok = True
for req_file in req_files:
label = req_file.relative_to(PROJECT_ROOT)
# Isolate each file's install: a timeout or OSError on one
# (e.g. requirements.txt) must not abort the rest of the
# loop (e.g. web_interface/requirements.txt never attempted).
try:
result = _pip_install_requirements(req_file, timeout=120)
all_ok = all_ok and result.returncode == 0
outputs.append(f"== {label} ==\n" + _truncate_output(result.stdout, result.stderr))
except subprocess.TimeoutExpired:
all_ok = False
outputs.append(f"== {label} ==\nTimed out after 120s")
logger.warning("install_base_requirements timed out for %s", label)
except OSError as install_err:
all_ok = False
outputs.append(f"== {label} ==\nFailed: {install_err}")
logger.warning("install_base_requirements errored for %s: %s", label, install_err)
return jsonify({
'status': 'success' if all_ok else 'error',
'message': 'Base requirements installed successfully' if all_ok else 'pip install failed',
'output': "\n".join(outputs)
})
elif action == 'install_plugin_requirements':
active_pm = getattr(api_v3, 'plugin_manager', None)
if active_pm:
plugins_dir = Path(active_pm.plugins_dir)
else:
_cm = getattr(api_v3, 'config_manager', None)
_cfg = _cm.load_config() if _cm else {}
_dir_name = _cfg.get('plugin_system', {}).get('plugins_directory', 'plugin-repos')
plugins_dir = Path(_dir_name) if os.path.isabs(_dir_name) else PROJECT_ROOT / _dir_name
results = []
if plugins_dir.exists():
for p in sorted(plugins_dir.iterdir()):
req = p / 'requirements.txt'
if p.is_dir() and req.exists():
try:
r = _pip_install_requirements(req, timeout=60)
results.append({
'plugin': p.name,
'ok': r.returncode == 0,
'output': _truncate_output(r.stdout, r.stderr)
})
except subprocess.TimeoutExpired:
results.append({'plugin': p.name, 'ok': False, 'output': 'pip install timed out'})
except OSError as exc:
results.append({'plugin': p.name, 'ok': False, 'output': exc.strerror or 'OS error'})
ok_count = sum(1 for r in results if r['ok'])
all_ok = all(r['ok'] for r in results) if results else True
return jsonify({
'status': 'success' if all_ok else 'error',
'message': f'Processed {len(results)} plugin(s) — {ok_count} succeeded' if results else 'No plugin requirements.txt files found',
'details': results
})
elif action == 'force_git_reset':
if not _GIT:
return jsonify({'status': 'error', 'message': 'git not found on this system'}), 503
project_dir = str(PROJECT_ROOT)
fetch = subprocess.run(
[_GIT, 'fetch', 'origin'],
capture_output=True, text=True, timeout=30, cwd=project_dir
)
if fetch.returncode != 0:
return jsonify({'status': 'error', 'message': 'git fetch failed', 'output': fetch.stderr.strip()})
reset = subprocess.run(
[_GIT, 'reset', '--hard', 'origin/main'],
capture_output=True, text=True, timeout=30, cwd=project_dir
)
return jsonify({
'status': 'success' if reset.returncode == 0 else 'error',
'message': 'Reset to origin/main successfully' if reset.returncode == 0 else 'git reset failed',
'output': (reset.stdout + reset.stderr).strip()
})
elif action == 'clear_pycache':
cleared = 0
failed = 0
for d in PROJECT_ROOT.rglob('__pycache__'):
if d.is_dir():
try:
shutil.rmtree(d)
cleared += 1
except OSError:
failed += 1
msg = f'Cleared {cleared} __pycache__ directories'
if failed:
msg += f' ({failed} could not be removed)'
return jsonify({'status': 'success', 'message': msg})
else:
return jsonify({'status': 'error', 'message': 'Unknown action'}), 400
logger.info("system action '%s' returncode=%d", action, result.returncode)
if result.returncode != 0 and result.stderr:
logger.error("system action '%s' stderr: %s", action, result.stderr.strip())
resp = {
'status': 'success' if result.returncode == 0 else 'error',
'message': 'Action completed' if result.returncode == 0 else 'Action failed; check logs for details',
}
if result.returncode != 0:
resp['returncode'] = result.returncode
resp['stderr'] = result.stderr.strip()
hint = _sudo_hint_for(result.stderr)
if hint:
resp['message'] = hint
return jsonify(resp)
except subprocess.TimeoutExpired as e:
logger.error("system action '%s' timed out: %s", action, e)
return jsonify({'status': 'error', 'message': 'Command timed out', 'returncode': -1, 'stderr': 'timeout'})
except Exception as e:
logger.error("execute_system_action failed: %s", e, exc_info=True)
detail = describe_exception(e)
resp = {
'status': 'error',
'message': _sudo_hint_for(detail) or 'Action failed; see logs for details',
'details': detail,
}
return jsonify(resp), 500
@api_v3.route('/system/git-info', methods=['GET'])
def get_git_info():
"""Return branch, dirty state, recent commits and remote URL for the Tools tab."""
if not _GIT:
return jsonify({'status': 'error', 'message': 'git not found on this system'}), 503
d = str(PROJECT_ROOT)
try:
branch = subprocess.run([_GIT, 'branch', '--show-current'], capture_output=True, text=True, timeout=10, cwd=d)
if branch.returncode != 0:
return jsonify({'status': 'error', 'message': f'git branch failed: {branch.stderr.strip()}'}), 500
status = subprocess.run([_GIT, 'status', '--short', '--untracked-files=no'], capture_output=True, text=True, timeout=15, cwd=d)
if status.returncode != 0:
return jsonify({'status': 'error', 'message': f'git status failed: {status.stderr.strip()}'}), 500
log = subprocess.run([_GIT, 'log', '--oneline', '-5'], capture_output=True, text=True, timeout=10, cwd=d)
remote = subprocess.run([_GIT, 'remote', 'get-url', 'origin'], capture_output=True, text=True, timeout=10, cwd=d)
branch_name = branch.stdout.strip()
upstream = _git_upstream(d)
return jsonify({
'branch': branch_name,
'dirty': bool(status.stdout.strip()),
'status': status.stdout.strip(),
'recent_commits': log.stdout.strip() if log.returncode == 0 else '',
'remote_url': _scrub_git_remote_url(remote.stdout.strip()) if remote.returncode == 0 else '',
# Surfaced so the Tools tab can warn before the user clicks Pull
# Latest, rather than after it fails.
'upstream': upstream,
'can_pull': bool(upstream) or _git_remote_branch_exists(d, branch_name),
})
except Exception as e:
logger.error("get_git_info failed: %s", e, exc_info=True)
return jsonify({'status': 'error', 'message': 'Failed to get git info'}), 500
@api_v3.route('/system/git-branches', methods=['GET'])
def get_git_branches():
"""List branches available to switch to, for the Tools tab picker."""
if not _GIT:
return jsonify({'status': 'error', 'message': 'git not found on this system'}), 503
d = str(PROJECT_ROOT)
try:
# Refresh remote refs so a branch created since the last fetch shows up.
subprocess.run([_GIT, 'fetch', 'origin', '--prune'],
capture_output=True, text=True, timeout=60, cwd=d)
local = subprocess.run([_GIT, 'for-each-ref', '--format=%(refname:short)', 'refs/heads'],
capture_output=True, text=True, timeout=15, cwd=d)
remote = subprocess.run([_GIT, 'for-each-ref', '--format=%(refname:short)', 'refs/remotes/origin'],
capture_output=True, text=True, timeout=15, cwd=d)
if local.returncode != 0:
return jsonify({'status': 'error', 'message': 'Could not list branches'}), 500
local_names = [b for b in local.stdout.split() if b]
remote_names = []
for ref in remote.stdout.split() if remote.returncode == 0 else []:
name = ref.split('origin/', 1)[-1]
# origin/HEAD is a symbolic alias, not a branch a user can pick.
if name and name != 'HEAD' and name not in local_names:
remote_names.append(name)
return jsonify({
'status': 'success',
'current': _git_current_branch(d),
'upstream': _git_upstream(d),
'local': sorted(local_names),
'remote_only': sorted(remote_names),
})
except subprocess.TimeoutExpired:
return jsonify({'status': 'error', 'message': 'Timed out talking to the remote'}), 504
except OSError as e:
logger.error("get_git_branches failed: %s", e, exc_info=True)
return jsonify({'status': 'error', 'message': 'Failed to list branches'}), 500
+413
View File
@@ -0,0 +1,413 @@
"""Wi-Fi scanning, connection and status routes.
Routes decorate the shared `api_v3` Blueprint from ._common, so their
endpoint names are unchanged by living here.
"""
from web_interface.blueprints.api_v3 import (
api_v3, describe_exception, jsonify, logger, request,
)
def _parse_bool_ish(value):
"""Coerce a JSON value that is supposed to be a boolean.
A JSON boolean arrives as a real Python bool, but these routes are a
public HTTP contract and not every caller sends one. `bool(value)` gets
two common cases wrong: `bool("false")` is True (a non-empty string is
always truthy), and a plain int does not match an `is True` check
(`1 is True` is False, since `True` is a distinct singleton from the int
`1`) -- so a caller sending `{"enabled": 1}` was silently treated as
False. Recognizes a real bool, "true"/"false"/"1"/"0"/"yes"/"no"
case-insensitively, and int 1/0.
Returns None for anything else, rather than guessing. A supplied-but-
unrecognized value (e.g. a typo) used to silently become False here,
which for `enabled` on the radio route could disconnect Wi-Fi the caller
never asked to turn off -- callers must treat None as a validation
error, not a real False.
"""
if isinstance(value, bool):
return value
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in ('true', '1', 'yes'):
return True
if lowered in ('false', '0', 'no'):
return False
return None
if isinstance(value, int):
if value == 1:
return True
if value == 0:
return False
return None
return None
# WiFi Management Endpoints
@api_v3.route('/wifi/status', methods=['GET'])
def get_wifi_status():
"""Get current WiFi connection status"""
try:
from src.wifi_manager import WiFiManager
wifi_manager = WiFiManager()
status = wifi_manager.get_wifi_status()
# Get auto-enable setting from config
auto_enable_ap = wifi_manager.config.get("auto_enable_ap_mode", True) # Default: True (safe due to grace period)
return jsonify({
'status': 'success',
'data': {
'connected': status.connected,
'ssid': status.ssid,
'ip_address': status.ip_address,
'signal': status.signal,
'ap_mode_active': status.ap_mode_active,
'auto_enable_ap_mode': auto_enable_ap
}
})
except Exception as e:
logger.error("%s failed", request.path, exc_info=True)
return jsonify({
'status': 'error',
'message': 'An error occurred; see logs for details',
'details': describe_exception(e)
}), 500
@api_v3.route('/wifi/scan', methods=['GET'])
def scan_wifi_networks():
"""Scan for available WiFi networks
If AP mode is active, it will be temporarily disabled during scanning
and automatically re-enabled afterward. Users connected to the AP will
be briefly disconnected during this process.
"""
try:
from src.wifi_manager import WiFiManager
wifi_manager = WiFiManager()
# Check if AP mode is active before scanning (for user notification)
ap_was_active = wifi_manager._is_ap_mode_active()
# Perform the scan (this will handle AP mode disabling/enabling internally)
networks, _was_cached = wifi_manager.scan_networks()
# Convert to dict format
networks_data = [
{
'ssid': net.ssid,
'signal': net.signal,
'security': net.security,
'frequency': net.frequency
}
for net in networks
]
response_data = {
'status': 'success',
'data': networks_data
}
# Inform user if AP mode was temporarily disabled
if ap_was_active:
response_data['message'] = (
f'Found {len(networks_data)} networks. '
'Note: AP mode was temporarily disabled during scanning and has been re-enabled. '
'If you were connected to the setup network, you may need to reconnect.'
)
return jsonify(response_data)
except Exception as e:
logger.error("Error scanning WiFi networks", exc_info=True)
error_message = 'An error occurred while scanning WiFi networks; see logs for details'
# Provide more specific error messages for common issues
error_str = str(e).lower()
if 'permission' in error_str or 'sudo' in error_str:
error_message = (
'Permission error while scanning. '
'The WiFi scan requires appropriate permissions. '
'Please ensure the application has necessary privileges.'
)
elif 'timeout' in error_str:
error_message = (
'WiFi scan timed out. '
'The scan took too long to complete. '
'This may happen if the WiFi interface is busy or in use.'
)
elif 'no wifi' in error_str or 'not available' in error_str:
error_message = (
'WiFi scanning tools are not available. '
'Please ensure NetworkManager (nmcli) or iwlist is installed.'
)
return jsonify({
'status': 'error',
'message': error_message
}), 500
@api_v3.route('/wifi/connect', methods=['POST'])
def connect_wifi():
"""Connect to a WiFi network"""
try:
from src.wifi_manager import WiFiManager
data = request.get_json(silent=True)
if not data:
return jsonify({
'status': 'error',
'message': 'Request body is required'
}), 400
if 'ssid' not in data:
return jsonify({
'status': 'error',
'message': 'SSID is required'
}), 400
ssid = data['ssid']
if not ssid or not ssid.strip():
return jsonify({
'status': 'error',
'message': 'SSID cannot be empty'
}), 400
ssid = ssid.strip()
password = data.get('password', '') or ''
wifi_manager = WiFiManager()
success, message = wifi_manager.connect_to_network(ssid, password)
if success:
return jsonify({
'status': 'success',
'message': message
})
else:
return jsonify({
'status': 'error',
'message': message or 'Failed to connect to network'
}), 400
except Exception as e:
logger.error("Error connecting to WiFi", exc_info=True)
return jsonify({
'status': 'error',
'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
}), 500
@api_v3.route('/wifi/disconnect', methods=['POST'])
def disconnect_wifi():
"""Disconnect from the current WiFi network"""
try:
from src.wifi_manager import WiFiManager
wifi_manager = WiFiManager()
success, message = wifi_manager.disconnect_from_network()
if success:
return jsonify({
'status': 'success',
'message': message
})
else:
return jsonify({
'status': 'error',
'message': message or 'Failed to disconnect from network'
}), 400
except Exception as e:
logger.error("Error disconnecting from WiFi", exc_info=True)
return jsonify({
'status': 'error',
'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
}), 500
@api_v3.route('/wifi/ap/enable', methods=['POST'])
def enable_ap_mode():
"""Enable access point mode"""
try:
from src.wifi_manager import WiFiManager
wifi_manager = WiFiManager()
_force_raw = (request.get_json(silent=True) or {}).get('force', False)
force = _force_raw is True or (isinstance(_force_raw, str) and _force_raw.lower() in ('true', '1'))
success, message = wifi_manager.enable_ap_mode(force=force)
if success:
return jsonify({
'status': 'success',
'message': message
})
else:
return jsonify({
'status': 'error',
'message': message
}), 400
except Exception as e:
logger.error("%s failed", request.path, exc_info=True)
return jsonify({
'status': 'error',
'message': 'An error occurred; see logs for details',
'details': describe_exception(e)
}), 500
@api_v3.route('/wifi/ap/disable', methods=['POST'])
def disable_ap_mode():
"""Disable access point mode"""
try:
from src.wifi_manager import WiFiManager
wifi_manager = WiFiManager()
success, message = wifi_manager.disable_ap_mode()
if success:
return jsonify({
'status': 'success',
'message': message
})
else:
return jsonify({
'status': 'error',
'message': message
}), 400
except Exception as e:
logger.error("%s failed", request.path, exc_info=True)
return jsonify({
'status': 'error',
'message': 'An error occurred; see logs for details',
'details': describe_exception(e)
}), 500
@api_v3.route('/wifi/ap/auto-enable', methods=['GET'])
def get_auto_enable_ap_mode():
"""Get auto-enable AP mode setting"""
try:
from src.wifi_manager import WiFiManager
wifi_manager = WiFiManager()
auto_enable = wifi_manager.config.get("auto_enable_ap_mode", True) # Default: True (safe due to grace period)
return jsonify({
'status': 'success',
'data': {
'auto_enable_ap_mode': auto_enable
}
})
except Exception as e:
logger.error("%s failed", request.path, exc_info=True)
return jsonify({
'status': 'error',
'message': 'An error occurred; see logs for details',
'details': describe_exception(e)
}), 500
@api_v3.route('/wifi/ap/auto-enable', methods=['POST'])
def set_auto_enable_ap_mode():
"""Set auto-enable AP mode setting"""
try:
from src.wifi_manager import WiFiManager
data = request.get_json(silent=True)
if data is None or 'auto_enable_ap_mode' not in data:
return jsonify({
'status': 'error',
'message': 'auto_enable_ap_mode is required'
}), 400
auto_enable = _parse_bool_ish(data['auto_enable_ap_mode'])
if auto_enable is None:
return jsonify({
'status': 'error',
'message': 'auto_enable_ap_mode must be a boolean'
}), 400
wifi_manager = WiFiManager()
wifi_manager.config["auto_enable_ap_mode"] = auto_enable
wifi_manager._save_config()
return jsonify({
'status': 'success',
'message': f'Auto-enable AP mode set to {auto_enable}',
'data': {
'auto_enable_ap_mode': auto_enable
}
})
except Exception as e:
logger.error("%s failed", request.path, exc_info=True)
return jsonify({
'status': 'error',
'message': 'An error occurred; see logs for details',
'details': describe_exception(e)
}), 500
@api_v3.route('/wifi/radio', methods=['GET'])
def get_wifi_radio():
"""Get current WiFi radio state (enabled/disabled) and wired-fallback status."""
try:
from src.wifi_manager import WiFiManager
wifi_manager = WiFiManager()
state = wifi_manager.get_wifi_radio_state()
return jsonify({
'status': 'success',
'data': state
})
except Exception as e:
logger.error("Error getting WiFi radio state", exc_info=True)
return jsonify({
'status': 'error',
'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
}), 500
@api_v3.route('/wifi/radio', methods=['POST'])
def set_wifi_radio():
"""Turn the WiFi radio on or off.
Body: {"enabled": bool, "force": bool (optional)}. Disabling is refused
unless Ethernet is connected or force=True, to avoid locking the user out
of this web interface.
"""
try:
from src.wifi_manager import WiFiManager
data = request.get_json(silent=True) or {}
if 'enabled' not in data:
return jsonify({
'status': 'error',
'message': 'enabled is required'
}), 400
# Parse defensively: bool("false") is True and a plain int never
# matches `is True`, so `_parse_bool_ish` handles bool, string and
# int 1/0 — the endpoint is a public contract, not just the shipped
# UI (which always sends real JSON booleans). An unrecognized value
# must be rejected, not silently disable the radio: this is the
# route that can drop the caller's own connection to this interface.
enabled = _parse_bool_ish(data['enabled'])
if enabled is None:
return jsonify({
'status': 'error',
'message': 'enabled must be a boolean'
}), 400
force = _parse_bool_ish(data.get('force', False))
if force is None:
return jsonify({
'status': 'error',
'message': 'force must be a boolean'
}), 400
wifi_manager = WiFiManager()
success, message, reason = wifi_manager.set_wifi_radio(enabled, force=force)
if success:
return jsonify({
'status': 'success',
'message': message,
'data': wifi_manager.get_wifi_radio_state()
})
else:
return jsonify({
'status': 'error',
'message': message,
'reason': reason
}), 400
except Exception as e:
logger.error("Error setting WiFi radio state", exc_info=True)
return jsonify({
'status': 'error',
'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
}), 500