mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
fix: web UI and src.common bugs (wifi wrong-password, plugin icon, starlark toggle, API caching, scroll/logo/font helpers) (#646)
- wifi: keep the "wrong_password:" prefix through the restore/AP fallback so the UI's incorrect-password prompt fires again. - /plugins/installed returns the manifest's icon (string only). - /starlark/apps/<id>/toggle coerces `enabled` and delegates to _toggle_starlark_app (disk before memory, no KeyError, "false" is false). - /api/v3/ JSON GETs are sent Cache-Control: no-store; non-JSON keeps 5s. - ScrollHelper.set_scrolling_image converts non-RGB input (alpha onto black); create/set_scrolling_image reset last_update_time like reset_scroll. - LogoHelper backs off a failed download per path for MISSING_LOGO_RECHECK_SECONDS; cleared on invalidate/clear_cache. - refresh_placeholder_timestamp saves atomically. - FontManager.clear_cache / _clear_plugin_font_cache bump cache_generation. - Odds manager: per-game logs to DEBUG; JSON decode error caught before RequestException (same cooldown). - element_style mangled continuations; startup validator skips null plugin blocks and reuses the controller's discovery. - src/common/README lists frame_timing, json_body, render_gate. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,18 @@ accepts both, but the store flags the old spelling as deprecated
|
|||||||
- `check_system_compatibility.sh` no longer reports installed packages as missing.
|
- `check_system_compatibility.sh` no longer reports installed packages as missing.
|
||||||
- A network failure fetching GitHub repo info logs a warning, not an error.
|
- A network failure fetching GitHub repo info logs a warning, not an error.
|
||||||
|
|
||||||
|
- Web UI and `src.common` fixes:
|
||||||
|
- A wrong Wi-Fi password is reported as one again ("Incorrect password for ..."); the fallback that restores the old network or brings up the setup AP was replacing the signal.
|
||||||
|
- Plugin tabs show the manifest's `icon`: `/api/v3/plugins/installed` now includes it.
|
||||||
|
- `POST /api/v3/starlark/apps/<id>/toggle` goes through the same code as `/plugins/toggle`: `"false"` disables, a failed save no longer leaves the running app out of step with disk, and a loaded app with no manifest entry no longer answers 500.
|
||||||
|
- `/api/v3/` JSON responses are sent `Cache-Control: no-store`, so a reload right after an install, toggle or Wi-Fi connect shows the new state. Non-JSON files served through the API keep the 5 s cache.
|
||||||
|
- `ScrollHelper.set_scrolling_image()` accepts RGBA, L and palette images (transparent pixels become black), and a new scrolling image no longer jumps ahead by the time the helper sat idle.
|
||||||
|
- `LogoHelper.load_logo_with_download()` waits an hour before retrying a download that failed for a missing logo, instead of retrying (with a 30 s timeout) on every call.
|
||||||
|
- Restamping a placeholder logo writes the file atomically.
|
||||||
|
- `FontManager.clear_cache()` and unregistering a plugin's fonts bump `cache_generation`, so cached layouts are rebuilt.
|
||||||
|
- The odds manager logs cache hits, misses and fetches at DEBUG, and a bad JSON body is logged as a parse error rather than a failed fetch.
|
||||||
|
- Startup plugin validation no longer gives up on a `null` plugin block, and plugins are discovered once at startup instead of twice.
|
||||||
|
- `src/common/README.md` lists `frame_timing`, `json_body` and `render_gate`.
|
||||||
- Plugin system:
|
- Plugin system:
|
||||||
- A plugin whose `on_enable()` raises is no longer left registered: the next load retries it instead of reporting "already loaded" for a plugin that never ran.
|
- A plugin whose `on_enable()` raises is no longer left registered: the next load retries it instead of reporting "already loaded" for a plugin that never ran.
|
||||||
- One plugin's `get_info()` raising no longer breaks the installed-plugins list; it is logged and shown with empty runtime info.
|
- One plugin's `get_info()` raising no longer breaks the installed-plugins list; it is logged and shown with empty runtime info.
|
||||||
|
|||||||
@@ -5,11 +5,9 @@
|
|||||||
A plugin can name an icon for its tab in the web interface's second nav row
|
A plugin can name an icon for its tab in the web interface's second nav row
|
||||||
(next to **Plugin Manager**) with the `icon` field in `manifest.json`.
|
(next to **Plugin Manager**) with the `icon` field in `manifest.json`.
|
||||||
|
|
||||||
> **Status:** the tab code honors `icon`, but `GET /api/v3/plugins/installed`
|
`GET /api/v3/plugins/installed` passes the manifest's `icon` through (a
|
||||||
> (`web_interface/blueprints/api_v3/plugins.py`) does not currently include
|
non-string value comes back as `null`), and a plugin without one gets the
|
||||||
> the manifest's `icon` in its response, so every tab shows the default
|
default puzzle piece.
|
||||||
> puzzle piece. Setting `icon` is harmless and will take effect once the API
|
|
||||||
> passes it through again.
|
|
||||||
|
|
||||||
## Font Awesome classes only
|
## Font Awesome classes only
|
||||||
|
|
||||||
@@ -55,8 +53,8 @@ With no `icon` (or an empty one) the tab shows `fas fa-puzzle-piece`.
|
|||||||
or misspelled class renders as a blank space.
|
or misspelled class renders as a blank space.
|
||||||
2. Include the style prefix (`fas`, `far` or `fab`) as well as the icon
|
2. Include the style prefix (`fas`, `far` or `fab`) as well as the icon
|
||||||
class.
|
class.
|
||||||
3. See the status note above: the icon is currently not passed through by
|
3. The manifest is re-read on each plugin list load; reload the page after
|
||||||
the API.
|
editing `icon`.
|
||||||
|
|
||||||
## Related Documentation
|
## Related Documentation
|
||||||
|
|
||||||
|
|||||||
@@ -123,8 +123,10 @@ class BaseOddsManager:
|
|||||||
# Check cache first
|
# Check cache first
|
||||||
cached_data = self.cache_manager.get_with_auto_strategy(cache_key)
|
cached_data = self.cache_manager.get_with_auto_strategy(cache_key)
|
||||||
|
|
||||||
|
# Per-game chatter, logged on every update of every game on the
|
||||||
|
# slate: debug, not the journal.
|
||||||
if cached_data:
|
if cached_data:
|
||||||
self.logger.info(f"Using cached odds from ESPN for {cache_key}")
|
self.logger.debug(f"Using cached odds from ESPN for {cache_key}")
|
||||||
return cached_data
|
return cached_data
|
||||||
|
|
||||||
if time.monotonic() < self._skip_network_until:
|
if time.monotonic() < self._skip_network_until:
|
||||||
@@ -137,7 +139,7 @@ class BaseOddsManager:
|
|||||||
self._skip_network_until - time.monotonic())
|
self._skip_network_until - time.monotonic())
|
||||||
return None
|
return None
|
||||||
|
|
||||||
self.logger.info(f"Cache miss - fetching fresh odds from ESPN for {cache_key}")
|
self.logger.debug(f"Cache miss - fetching fresh odds from ESPN for {cache_key}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Map league names to ESPN API format
|
# Map league names to ESPN API format
|
||||||
@@ -151,7 +153,7 @@ class BaseOddsManager:
|
|||||||
|
|
||||||
espn_league = league_mapping.get(league, league)
|
espn_league = league_mapping.get(league, league)
|
||||||
url = f"{self.base_url}/{sport}/leagues/{espn_league}/events/{event_id}/competitions/{event_id}/odds"
|
url = f"{self.base_url}/{sport}/leagues/{espn_league}/events/{event_id}/competitions/{event_id}/odds"
|
||||||
self.logger.info(f"Requesting odds from URL: {url}")
|
self.logger.debug(f"Requesting odds from URL: {url}")
|
||||||
|
|
||||||
response = self.session.get(url, timeout=self.request_timeout)
|
response = self.session.get(url, timeout=self.request_timeout)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
@@ -163,9 +165,9 @@ class BaseOddsManager:
|
|||||||
|
|
||||||
odds_data = self._extract_espn_data(raw_data)
|
odds_data = self._extract_espn_data(raw_data)
|
||||||
if odds_data:
|
if odds_data:
|
||||||
self.logger.info(f"Successfully extracted odds data: {odds_data}")
|
self.logger.debug(f"Successfully extracted odds data: {odds_data}")
|
||||||
self.cache_manager.set(cache_key, odds_data, ttl=interval)
|
self.cache_manager.set(cache_key, odds_data, ttl=interval)
|
||||||
self.logger.info(f"Saved odds data to cache for {cache_key} with TTL {interval}s")
|
self.logger.debug(f"Saved odds data to cache for {cache_key} with TTL {interval}s")
|
||||||
else:
|
else:
|
||||||
self.logger.debug(f"No odds data available for {cache_key}")
|
self.logger.debug(f"No odds data available for {cache_key}")
|
||||||
# Cache the absence too, so the game is not re-requested
|
# Cache the absence too, so the game is not re-requested
|
||||||
@@ -174,14 +176,19 @@ class BaseOddsManager:
|
|||||||
|
|
||||||
return odds_data
|
return odds_data
|
||||||
|
|
||||||
|
# Before RequestException: requests' JSONDecodeError subclasses it, so
|
||||||
|
# listed second this branch never ran and a bad body was reported as a
|
||||||
|
# failed fetch. It holds off like a failed fetch did, so only the
|
||||||
|
# message changes.
|
||||||
|
except (json.JSONDecodeError, requests.exceptions.JSONDecodeError):
|
||||||
|
self._skip_network_until = time.monotonic() + self._FAILURE_COOLDOWN
|
||||||
|
self.logger.error(f"Error decoding JSON response from ESPN API for {cache_key}.")
|
||||||
except requests.exceptions.RequestException as e:
|
except requests.exceptions.RequestException as e:
|
||||||
self._skip_network_until = time.monotonic() + self._FAILURE_COOLDOWN
|
self._skip_network_until = time.monotonic() + self._FAILURE_COOLDOWN
|
||||||
self.logger.error(
|
self.logger.error(
|
||||||
"Error fetching odds from ESPN API for %s: %s. Holding off on odds "
|
"Error fetching odds from ESPN API for %s: %s. Holding off on odds "
|
||||||
"for %.0fs so a slate of games does not pay this timeout each.",
|
"for %.0fs so a slate of games does not pay this timeout each.",
|
||||||
cache_key, e, self._FAILURE_COOLDOWN)
|
cache_key, e, self._FAILURE_COOLDOWN)
|
||||||
except json.JSONDecodeError:
|
|
||||||
self.logger.error(f"Error decoding JSON response from ESPN API for {cache_key}.")
|
|
||||||
|
|
||||||
return self.cache_manager.get_with_auto_strategy(cache_key)
|
return self.cache_manager.get_with_auto_strategy(cache_key)
|
||||||
|
|
||||||
|
|||||||
@@ -27,9 +27,12 @@ Rules for the package:
|
|||||||
| [`bdf_font`](#bdf_font) | Load and draw BDF bitmap fonts | Yes, if drawing BDF text directly | Unreleased |
|
| [`bdf_font`](#bdf_font) | Load and draw BDF bitmap fonts | Yes, if drawing BDF text directly | Unreleased |
|
||||||
| [`espn_dates`](#espn_dates) | Fetch ESPN scoreboards across a date range | Yes (scoreboards) | 3.5.0 |
|
| [`espn_dates`](#espn_dates) | Fetch ESPN scoreboards across a date range | Yes (scoreboards) | 3.5.0 |
|
||||||
| [`font_layout`](#font_layout) | Reproducible TrueType loading, crisp sizes | Yes | 3.4.0 |
|
| [`font_layout`](#font_layout) | Reproducible TrueType loading, crisp sizes | Yes | 3.4.0 |
|
||||||
|
| [`frame_timing`](#frame_timing) | Timing of every presented frame, stall watchdog | No, core-internal | n/a |
|
||||||
|
| [`json_body`](#json_body) | Parse a response body as JSON, with orjson if installed | Optional (large payloads) | Unreleased |
|
||||||
| [`logo_helper`](#logo_helper) | Load, resize and cache team logos | Yes | — |
|
| [`logo_helper`](#logo_helper) | Load, resize and cache team logos | Yes | — |
|
||||||
| [`path_safety`](#path_safety) | Turn request-supplied names into safe paths | No, core-internal | n/a |
|
| [`path_safety`](#path_safety) | Turn request-supplied names into safe paths | No, core-internal | n/a |
|
||||||
| [`permission_utils`](#permission_utils) | File modes and shared-group ownership | Rarely | — |
|
| [`permission_utils`](#permission_utils) | File modes and shared-group ownership | Rarely | — |
|
||||||
|
| [`render_gate`](#render_gate) | Keep background Python off the GIL while the panel swaps | No, core-internal | n/a |
|
||||||
| [`scroll_config`](#scroll_config) | Plugin scroll config → configured `ScrollHelper` | Yes (scrollers) | 3.4.0 |
|
| [`scroll_config`](#scroll_config) | Plugin scroll config → configured `ScrollHelper` | Yes (scrollers) | 3.4.0 |
|
||||||
| [`scroll_helper`](#scroll_helper) | Pre-rendered horizontal scrolling | Yes | — |
|
| [`scroll_helper`](#scroll_helper) | Pre-rendered horizontal scrolling | Yes | — |
|
||||||
| [`snapshot_policy`](#snapshot_policy) | When to write the web preview frame | No, core-internal | n/a |
|
| [`snapshot_policy`](#snapshot_policy) | When to write the web preview frame | No, core-internal | n/a |
|
||||||
@@ -107,6 +110,24 @@ the size a bundled face renders on whole pixels at. `resolve_asset_path()`
|
|||||||
resolves `assets/fonts/...` against the install root rather than the
|
resolves `assets/fonts/...` against the install root rather than the
|
||||||
working directory.
|
working directory.
|
||||||
|
|
||||||
|
### frame_timing
|
||||||
|
|
||||||
|
[`frame_timing.py`](frame_timing.py). Core-internal. `DisplayManager`
|
||||||
|
records every presented frame in a `FrameTimingRecorder`, which writes
|
||||||
|
cumulative late-frame counters and histograms to `/dev/shm` for
|
||||||
|
`scripts/frame_soak.py` and `scripts/render_bench.py`. `StallWatchdog` logs
|
||||||
|
the stack of whatever holds up a scroll. See
|
||||||
|
[docs/SCROLL_PERFORMANCE.md](../../docs/SCROLL_PERFORMANCE.md).
|
||||||
|
|
||||||
|
### json_body
|
||||||
|
|
||||||
|
[`json_body.py`](json_body.py). `response_json(response)` is
|
||||||
|
`response.json()` parsed by orjson when it is installed, falling back to the
|
||||||
|
stdlib parser (and requests' own error) otherwise. For multi-MB payloads such
|
||||||
|
as a season schedule, where the parse holds the GIL and freezes the display.
|
||||||
|
A plugin that also runs on older cores should guard the import, as
|
||||||
|
`espn_dates` does.
|
||||||
|
|
||||||
### logo_helper
|
### logo_helper
|
||||||
|
|
||||||
[`logo_helper.py`](logo_helper.py). `LogoHelper(display_width,
|
[`logo_helper.py`](logo_helper.py). `LogoHelper(display_width,
|
||||||
@@ -134,6 +155,14 @@ let the root display service and the web user share files:
|
|||||||
already call these; a plugin needs them only when it creates its own files
|
already call these; a plugin needs them only when it creates its own files
|
||||||
outside the cache. See [docs/PERMISSIONS.md](../../docs/PERMISSIONS.md).
|
outside the cache. See [docs/PERMISSIONS.md](../../docs/PERMISSIONS.md).
|
||||||
|
|
||||||
|
### render_gate
|
||||||
|
|
||||||
|
[`render_gate.py`](render_gate.py). Core-internal. `RenderGate` is opened by
|
||||||
|
the render thread around each vsync swap; a background thread inside
|
||||||
|
`gate.yielding()` (Vegas's prefetch) parks while the gate is closed, so the
|
||||||
|
render thread finds the GIL free when its refresh arrives. It never parks a
|
||||||
|
thread holding a guarded lock or inside logging, threading or import code.
|
||||||
|
|
||||||
### scroll_config
|
### scroll_config
|
||||||
|
|
||||||
[`scroll_config.py`](scroll_config.py). `configure(scroll_helper,
|
[`scroll_config.py`](scroll_config.py). `configure(scroll_helper,
|
||||||
|
|||||||
@@ -80,6 +80,11 @@ class LogoHelper:
|
|||||||
# Time-bounded rather than permanent so a logo that appears later (the
|
# Time-bounded rather than permanent so a logo that appears later (the
|
||||||
# downloader writes them at runtime) is still picked up.
|
# downloader writes them at runtime) is still picked up.
|
||||||
self._missing_logos: Dict[str, float] = {}
|
self._missing_logos: Dict[str, float] = {}
|
||||||
|
|
||||||
|
# Failed downloads by logo path. A logo that is absent (not a stale
|
||||||
|
# placeholder) has no on-disk timestamp to back off on, so without this
|
||||||
|
# every call retried the download -- up to a 30s timeout each time.
|
||||||
|
self._download_failures: Dict[str, float] = {}
|
||||||
|
|
||||||
# Session for HTTP requests
|
# Session for HTTP requests
|
||||||
self.session = requests.Session()
|
self.session = requests.Session()
|
||||||
@@ -204,7 +209,12 @@ class LogoHelper:
|
|||||||
return self.load_logo(team_abbr, logo_path, max_width, max_height,
|
return self.load_logo(team_abbr, logo_path, max_width, max_height,
|
||||||
scale)
|
scale)
|
||||||
|
|
||||||
# Download if URL provided and file doesn't exist
|
# Download if URL provided and file doesn't exist, unless the last
|
||||||
|
# attempt for this path failed recently.
|
||||||
|
failed_at = self._download_failures.get(str(logo_path))
|
||||||
|
if (logo_url and failed_at is not None
|
||||||
|
and time.time() - failed_at < MISSING_LOGO_RECHECK_SECONDS):
|
||||||
|
logo_url = None
|
||||||
if logo_url:
|
if logo_url:
|
||||||
try:
|
try:
|
||||||
self.logger.info(f"Downloading logo for {team_abbr} from {logo_url}")
|
self.logger.info(f"Downloading logo for {team_abbr} from {logo_url}")
|
||||||
@@ -218,6 +228,7 @@ class LogoHelper:
|
|||||||
scale)
|
scale)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Failed to download logo for {team_abbr}: {e}")
|
self.logger.error(f"Failed to download logo for {team_abbr}: {e}")
|
||||||
|
self._download_failures[str(logo_path)] = time.time()
|
||||||
# The retry failed, so restart the back-off. The stale
|
# The retry failed, so restart the back-off. The stale
|
||||||
# placeholder is still on disk with its old timestamp, and
|
# placeholder is still on disk with its old timestamp, and
|
||||||
# leaving it there means the next call retries immediately --
|
# leaving it there means the next call retries immediately --
|
||||||
@@ -240,6 +251,7 @@ class LogoHelper:
|
|||||||
# leaving it would hide a logo we just downloaded.
|
# leaving it would hide a logo we just downloaded.
|
||||||
for key in [k for k in self._missing_logos if k.startswith(prefix)]:
|
for key in [k for k in self._missing_logos if k.startswith(prefix)]:
|
||||||
del self._missing_logos[key]
|
del self._missing_logos[key]
|
||||||
|
self._download_failures.pop(str(logo_path), None)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _refresh_stale_placeholder(logo_path: Path) -> None:
|
def _refresh_stale_placeholder(logo_path: Path) -> None:
|
||||||
@@ -332,6 +344,7 @@ class LogoHelper:
|
|||||||
self._logo_cache.clear()
|
self._logo_cache.clear()
|
||||||
self._cache_order.clear()
|
self._cache_order.clear()
|
||||||
self._missing_logos.clear()
|
self._missing_logos.clear()
|
||||||
|
self._download_failures.clear()
|
||||||
self.logger.debug("Logo cache cleared")
|
self.logger.debug("Logo cache cleared")
|
||||||
|
|
||||||
def get_cache_stats(self) -> Dict[str, int]:
|
def get_cache_stats(self) -> Dict[str, int]:
|
||||||
|
|||||||
@@ -254,6 +254,9 @@ class ScrollHelper:
|
|||||||
now = time.time()
|
now = time.time()
|
||||||
self.scroll_start_time = now
|
self.scroll_start_time = now
|
||||||
self.last_progress_log_time = now
|
self.last_progress_log_time = now
|
||||||
|
# The position just went back to 0; the first update must not advance
|
||||||
|
# it by however long the helper sat idle (off-screen) before this.
|
||||||
|
self.last_update_time = now
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
"Dynamic duration target set to %ds (min=%ds, max=%ds, buffer=%.2f)",
|
"Dynamic duration target set to %ds (min=%ds, max=%ds, buffer=%.2f)",
|
||||||
self.calculated_duration,
|
self.calculated_duration,
|
||||||
@@ -776,6 +779,18 @@ class ScrollHelper:
|
|||||||
self.clear_cache()
|
self.clear_cache()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Every frame is cut from cached_array with Image.frombytes('RGB', ...),
|
||||||
|
# which reads a 4-channel (RGBA) array as garbage and raises on a
|
||||||
|
# 1-channel (L) one. Transparent pixels go to black, the panel's
|
||||||
|
# background, rather than to whatever colour hides under the alpha.
|
||||||
|
if image.mode != 'RGB':
|
||||||
|
if 'A' in image.mode or 'transparency' in image.info:
|
||||||
|
rgba = image.convert('RGBA')
|
||||||
|
image = Image.new('RGB', rgba.size, (0, 0, 0))
|
||||||
|
image.paste(rgba, (0, 0), rgba)
|
||||||
|
else:
|
||||||
|
image = image.convert('RGB')
|
||||||
|
|
||||||
# Set the cached image
|
# Set the cached image
|
||||||
self.cached_image = image
|
self.cached_image = image
|
||||||
|
|
||||||
@@ -802,6 +817,9 @@ class ScrollHelper:
|
|||||||
self.scroll_start_time = now
|
self.scroll_start_time = now
|
||||||
self.last_progress_log_time = now
|
self.last_progress_log_time = now
|
||||||
self.last_step_time = now # Initialize step timer for frame-based scrolling
|
self.last_step_time = now # Initialize step timer for frame-based scrolling
|
||||||
|
# The position just went back to 0; the first update must not advance
|
||||||
|
# it by however long the helper sat idle before this image arrived.
|
||||||
|
self.last_update_time = now
|
||||||
|
|
||||||
self.logger.debug("Set scrolling image: %dx%d, total_scroll_width=%d",
|
self.logger.debug("Set scrolling image: %dx%d, total_scroll_width=%d",
|
||||||
image.width, image.height, self.total_scroll_width)
|
image.width, image.height, self.total_scroll_width)
|
||||||
|
|||||||
@@ -318,13 +318,19 @@ class DisplayController:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Could not enable plugin health/resource monitoring: %s", e)
|
logger.warning("Could not enable plugin health/resource monitoring: %s", e)
|
||||||
|
|
||||||
|
# Discover plugins. Before the plugin checks so they can reuse the
|
||||||
|
# list: each discover_plugins() call rescans the plugins directory
|
||||||
|
# and logs every plugin again.
|
||||||
|
discovered_plugins = self.plugin_manager.discover_plugins()
|
||||||
|
logger.info("Discovered %d plugin(s)", len(discovered_plugins))
|
||||||
|
|
||||||
# Only the plugin checks: validate_all() above has run the rest,
|
# Only the plugin checks: validate_all() above has run the rest,
|
||||||
# and running it again logged every config warning twice.
|
# and running it again logged every config warning twice.
|
||||||
try:
|
try:
|
||||||
from src.startup_validator import StartupValidator
|
from src.startup_validator import StartupValidator
|
||||||
validator = StartupValidator(self.config_manager, self.plugin_manager,
|
validator = StartupValidator(self.config_manager, self.plugin_manager,
|
||||||
cache_manager=self.cache_manager)
|
cache_manager=self.cache_manager)
|
||||||
validator._validate_plugins()
|
validator._validate_plugins(discovered_plugins=discovered_plugins)
|
||||||
for warning in validator.warnings:
|
for warning in validator.warnings:
|
||||||
logger.warning("Plugin validation warning: %s", warning)
|
logger.warning("Plugin validation warning: %s", warning)
|
||||||
if validator.errors:
|
if validator.errors:
|
||||||
@@ -333,10 +339,6 @@ class DisplayController:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Plugin validation could not be completed: %s", e)
|
logger.warning("Plugin validation could not be completed: %s", e)
|
||||||
|
|
||||||
# Discover plugins
|
|
||||||
discovered_plugins = self.plugin_manager.discover_plugins()
|
|
||||||
logger.info("Discovered %d plugin(s)", len(discovered_plugins))
|
|
||||||
|
|
||||||
# Check for on-demand plugin filter from cache
|
# Check for on-demand plugin filter from cache
|
||||||
on_demand_config = self.cache_manager.get('display_on_demand_config', max_age=3600)
|
on_demand_config = self.cache_manager.get('display_on_demand_config', max_age=3600)
|
||||||
enabled_plugins = self._select_startup_plugins(discovered_plugins, on_demand_config)
|
enabled_plugins = self._select_startup_plugins(discovered_plugins, on_demand_config)
|
||||||
|
|||||||
@@ -840,7 +840,7 @@ def defaults_from_schema(schema: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
defaults['align'] = align_spec['default']
|
defaults['align'] = align_spec['default']
|
||||||
scale_spec = spec.get('scale')
|
scale_spec = spec.get('scale')
|
||||||
if isinstance(scale_spec, dict) and 'default' in scale_spec:
|
if isinstance(scale_spec, dict) and 'default' in scale_spec:
|
||||||
layout.setdefault(element_key, {})['scale'] = scale_spec['default']
|
layout.setdefault(element_key, {})['scale'] = scale_spec['default']
|
||||||
elif scale_spec is True:
|
elif scale_spec is True:
|
||||||
layout.setdefault(element_key, {})['scale'] = 1.0
|
layout.setdefault(element_key, {})['scale'] = 1.0
|
||||||
if defaults:
|
if defaults:
|
||||||
@@ -856,7 +856,7 @@ def defaults_from_schema(schema: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
continue
|
continue
|
||||||
scale_prop = (block.get('properties') or {}).get('scale')
|
scale_prop = (block.get('properties') or {}).get('scale')
|
||||||
if isinstance(scale_prop, dict) and 'default' in scale_prop:
|
if isinstance(scale_prop, dict) and 'default' in scale_prop:
|
||||||
layout.setdefault(element_key, {})['scale'] = scale_prop['default']
|
layout.setdefault(element_key, {})['scale'] = scale_prop['default']
|
||||||
for element_key, block in properties.items():
|
for element_key, block in properties.items():
|
||||||
if element_key in ('layout', 'modes') or element_key in elements:
|
if element_key in ('layout', 'modes') or element_key in elements:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -426,6 +426,9 @@ class FontManager:
|
|||||||
keys_to_remove = [key for key in self.font_cache.keys() if key.startswith(f"{plugin_id}::")]
|
keys_to_remove = [key for key in self.font_cache.keys() if key.startswith(f"{plugin_id}::")]
|
||||||
for key in keys_to_remove:
|
for key in keys_to_remove:
|
||||||
del self.font_cache[key]
|
del self.font_cache[key]
|
||||||
|
if keys_to_remove:
|
||||||
|
# Font objects someone may hold were dropped; see cache_generation.
|
||||||
|
self.cache_generation += 1
|
||||||
|
|
||||||
@deprecated("3.7.0")
|
@deprecated("3.7.0")
|
||||||
def get_plugin_fonts(self, plugin_id: str) -> List[str]:
|
def get_plugin_fonts(self, plugin_id: str) -> List[str]:
|
||||||
@@ -732,6 +735,9 @@ class FontManager:
|
|||||||
"""Clear font and metrics cache."""
|
"""Clear font and metrics cache."""
|
||||||
self.font_cache.clear()
|
self.font_cache.clear()
|
||||||
self.metrics_cache.clear()
|
self.metrics_cache.clear()
|
||||||
|
# Holders of derived caches (layout fits, font usage) key off this;
|
||||||
|
# without the bump they kept serving results for the dropped fonts.
|
||||||
|
self.cache_generation += 1
|
||||||
logger.info("Font cache cleared")
|
logger.info("Font cache cleared")
|
||||||
|
|
||||||
@deprecated("3.7.0", "read font_catalog")
|
@deprecated("3.7.0", "read font_catalog")
|
||||||
|
|||||||
@@ -235,7 +235,10 @@ def refresh_placeholder_timestamp(filepath: Path) -> bool:
|
|||||||
metadata = PngInfo()
|
metadata = PngInfo()
|
||||||
metadata.add_text(PLACEHOLDER_MARKER, str(time.time()))
|
metadata.add_text(PLACEHOLDER_MARKER, str(time.time()))
|
||||||
with Image.open(filepath) as img:
|
with Image.open(filepath) as img:
|
||||||
img.copy().save(filepath, "PNG", pnginfo=metadata)
|
image = img.copy()
|
||||||
|
# Atomically, like every other logo write: a renderer can open this
|
||||||
|
# file at any moment, and an in-place save exposes a truncated PNG.
|
||||||
|
save_png_atomically(image, filepath, pnginfo=metadata)
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("Could not refresh placeholder timestamp for %s", filepath,
|
logger.debug("Could not refresh placeholder timestamp for %s", filepath,
|
||||||
|
|||||||
@@ -268,15 +268,21 @@ class StartupValidator:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.warnings.append(f"Could not validate display configuration: {e}")
|
self.warnings.append(f"Could not validate display configuration: {e}")
|
||||||
|
|
||||||
def _validate_plugins(self) -> None:
|
def _validate_plugins(self, discovered_plugins=None) -> None:
|
||||||
"""Validate plugin configurations and dependencies."""
|
"""Validate plugin configurations and dependencies.
|
||||||
|
|
||||||
|
``discovered_plugins`` is a list the caller already got from
|
||||||
|
``discover_plugins()``; passing it skips a second directory scan (and
|
||||||
|
its duplicate log lines) at startup.
|
||||||
|
"""
|
||||||
if not self.plugin_manager:
|
if not self.plugin_manager:
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get enabled plugins from config
|
# Get enabled plugins from config
|
||||||
config = self.config_manager.get_config()
|
config = self.config_manager.get_config()
|
||||||
discovered_plugins = self.plugin_manager.discover_plugins()
|
if discovered_plugins is None:
|
||||||
|
discovered_plugins = self.plugin_manager.discover_plugins()
|
||||||
|
|
||||||
# Check for enabled plugins that don't exist
|
# Check for enabled plugins that don't exist
|
||||||
for plugin_id, plugin_config in config.items():
|
for plugin_id, plugin_config in config.items():
|
||||||
@@ -294,7 +300,11 @@ class StartupValidator:
|
|||||||
|
|
||||||
# Validate plugin configurations
|
# Validate plugin configurations
|
||||||
for plugin_id in discovered_plugins:
|
for plugin_id in discovered_plugins:
|
||||||
plugin_config = config.get(plugin_id, {})
|
plugin_config = config.get(plugin_id)
|
||||||
|
# A null block ("my-plugin": null) is not an enabled plugin;
|
||||||
|
# .get() on it raised and abandoned every remaining check.
|
||||||
|
if not isinstance(plugin_config, dict):
|
||||||
|
continue
|
||||||
if plugin_config.get('enabled', False):
|
if plugin_config.get('enabled', False):
|
||||||
# Check if plugin can be loaded (without actually loading it)
|
# Check if plugin can be loaded (without actually loading it)
|
||||||
plugin_dir = self.plugin_manager.get_plugin_directory(plugin_id)
|
plugin_dir = self.plugin_manager.get_plugin_directory(plugin_id)
|
||||||
|
|||||||
+14
-5
@@ -1317,6 +1317,15 @@ class WiFiManager:
|
|||||||
|
|
||||||
if self.has_nmcli:
|
if self.has_nmcli:
|
||||||
success, message = self._connect_nmcli(ssid, password)
|
success, message = self._connect_nmcli(ssid, password)
|
||||||
|
# Keep the "wrong_password:" prefix on whatever failure message
|
||||||
|
# the recovery below returns: the web UI keys its "incorrect
|
||||||
|
# password" prompt off it, and the restore/AP messages would
|
||||||
|
# otherwise erase it.
|
||||||
|
wrong_password = (not success and isinstance(message, str)
|
||||||
|
and message.startswith("wrong_password:"))
|
||||||
|
|
||||||
|
def _fail(msg):
|
||||||
|
return False, (f"wrong_password: {msg}" if wrong_password else msg)
|
||||||
|
|
||||||
# If connection failed, try to restore original connection
|
# If connection failed, try to restore original connection
|
||||||
if not success and original_connection and original_ssid:
|
if not success and original_connection and original_ssid:
|
||||||
@@ -1327,18 +1336,18 @@ class WiFiManager:
|
|||||||
if restore_success:
|
if restore_success:
|
||||||
logger.info(f"Successfully restored original connection: {original_ssid}")
|
logger.info(f"Successfully restored original connection: {original_ssid}")
|
||||||
self._show_led_message("Restored!", duration=3)
|
self._show_led_message("Restored!", duration=3)
|
||||||
return False, f"Failed to connect to {ssid}, restored {original_ssid}"
|
return _fail(f"Failed to connect to {ssid}, restored {original_ssid}")
|
||||||
else:
|
else:
|
||||||
logger.error(f"Failed to restore original connection: {original_ssid}")
|
logger.error(f"Failed to restore original connection: {original_ssid}")
|
||||||
return self._failsafe_ap(
|
return _fail(self._failsafe_ap(
|
||||||
"Connection failed and restoration failed. AP mode enabled.",
|
"Connection failed and restoration failed. AP mode enabled.",
|
||||||
"Connection failed, restoration failed, and AP mode failed")
|
"Connection failed, restoration failed, and AP mode failed")[1])
|
||||||
|
|
||||||
# If connection failed and no original connection to restore, enable AP mode
|
# If connection failed and no original connection to restore, enable AP mode
|
||||||
elif not success:
|
elif not success:
|
||||||
logger.warning(f"Connection to {ssid} failed and no original connection to restore")
|
logger.warning(f"Connection to {ssid} failed and no original connection to restore")
|
||||||
return self._failsafe_ap("Connection failed. AP mode enabled.",
|
return _fail(self._failsafe_ap("Connection failed. AP mode enabled.",
|
||||||
"Connection failed and AP mode failed")
|
"Connection failed and AP mode failed")[1])
|
||||||
|
|
||||||
return success, message
|
return success, message
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""GET /api/v3/plugins/installed carries the manifest's `icon`.
|
||||||
|
|
||||||
|
The tab nav sets ``iconEl.className = plugin.icon || 'fas fa-puzzle-piece'``
|
||||||
|
(app-shell.js, app-early.js), but the route never included `icon`, so every
|
||||||
|
plugin tab showed the default puzzle piece.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from test._api_v3_test_helpers import ( # noqa: F401 - fixtures
|
||||||
|
api_v3_client, api_v3_module,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def installed(api_v3_module, api_v3_client, tmp_path):
|
||||||
|
def _get(manifest_extra):
|
||||||
|
api = api_v3_module.api_v3
|
||||||
|
info = {'id': 'demo', 'name': 'Demo', 'version': '1.0.0', 'loaded': False}
|
||||||
|
info.update(manifest_extra)
|
||||||
|
api.plugin_manager.plugins_dir = str(tmp_path) # no manifest on disk
|
||||||
|
api.plugin_manager.get_all_plugin_info = MagicMock(return_value=[info])
|
||||||
|
api.plugin_manager.get_plugin = MagicMock(return_value=None)
|
||||||
|
api.plugin_store_manager.get_registry_info = MagicMock(return_value=None)
|
||||||
|
api.config_manager.load_config = MagicMock(return_value={})
|
||||||
|
response = api_v3_client.get('/api/v3/plugins/installed')
|
||||||
|
assert response.status_code == 200
|
||||||
|
plugins = [p for p in response.get_json()['data']['plugins'] if p['id'] == 'demo']
|
||||||
|
assert len(plugins) == 1
|
||||||
|
return plugins[0]
|
||||||
|
return _get
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_manifest_icon_is_passed_through(installed):
|
||||||
|
assert installed({'icon': 'fas fa-cloud-sun'})['icon'] == 'fas fa-cloud-sun'
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_icon_comes_back_as_null(installed):
|
||||||
|
# The JS falls back to the puzzle piece on a falsy value.
|
||||||
|
assert installed({})['icon'] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_non_string_icon_is_not_passed_through(installed):
|
||||||
|
assert installed({'icon': {'class': 'fas fa-star'}})['icon'] is None
|
||||||
@@ -13,6 +13,7 @@ its requests through a session so it can identify itself to ESPN, so patching
|
|||||||
the module-level requests.get would no longer intercept anything.
|
the module-level requests.get would no longer intercept anything.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -161,6 +162,34 @@ class TestGetOdds:
|
|||||||
assert result == {'stale': True}
|
assert result == {'stale': True}
|
||||||
assert cache_manager.get_with_auto_strategy.call_count == 2
|
assert cache_manager.get_with_auto_strategy.call_count == 2
|
||||||
|
|
||||||
|
def test_a_bad_body_is_reported_as_a_parse_error(
|
||||||
|
self, manager, cache_manager, mock_get, caplog):
|
||||||
|
# requests' JSONDecodeError is a RequestException, so the JSON branch
|
||||||
|
# sat unreachable behind it and a bad body read as a failed fetch.
|
||||||
|
response = _make_response(None)
|
||||||
|
response.json.side_effect = requests.exceptions.JSONDecodeError('x', 'doc', 0)
|
||||||
|
mock_get.return_value = response
|
||||||
|
cache_manager.get_with_auto_strategy.side_effect = [None, {'stale': True}]
|
||||||
|
|
||||||
|
with caplog.at_level(logging.ERROR, logger=manager.logger.name):
|
||||||
|
result = manager.get_odds('football', 'nfl', '401')
|
||||||
|
|
||||||
|
assert result == {'stale': True}
|
||||||
|
messages = [r.getMessage() for r in caplog.records]
|
||||||
|
assert any('decoding JSON' in m for m in messages), messages
|
||||||
|
assert not any('Error fetching odds' in m for m in messages), messages
|
||||||
|
# The hold-off is unchanged: a bad body still backs off like a failed
|
||||||
|
# fetch did.
|
||||||
|
assert manager._skip_network_until > 0
|
||||||
|
|
||||||
|
def test_routine_fetches_log_nothing_at_info(
|
||||||
|
self, manager, cache_manager, mock_get, caplog):
|
||||||
|
with caplog.at_level(logging.INFO, logger=manager.logger.name):
|
||||||
|
manager.get_odds('football', 'nfl', '401') # miss + fetch
|
||||||
|
cache_manager.get_with_auto_strategy.return_value = {'spread': 1}
|
||||||
|
manager.get_odds('football', 'nfl', '401') # hit
|
||||||
|
assert [r for r in caplog.records if r.levelno == logging.INFO] == []
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# _extract_espn_data
|
# _extract_espn_data
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""src/common/README.md says it lists every module; keep it true.
|
||||||
|
|
||||||
|
frame_timing, json_body and render_gate landed without rows, so the page a
|
||||||
|
plugin author reads to see what core offers was missing three modules.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
COMMON = Path(__file__).resolve().parent.parent / "src" / "common"
|
||||||
|
|
||||||
|
|
||||||
|
def _modules():
|
||||||
|
return sorted(p.stem for p in COMMON.glob("*.py") if p.stem != "__init__")
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_module_has_a_table_row_and_a_section():
|
||||||
|
readme = (COMMON / "README.md").read_text(encoding="utf-8")
|
||||||
|
missing_rows = [m for m in _modules() if f"| [`{m}`](#{m}) |" not in readme]
|
||||||
|
missing_sections = [m for m in _modules() if f"\n### {m}\n" not in readme]
|
||||||
|
assert not missing_rows, f"no summary-table row for: {missing_rows}"
|
||||||
|
assert not missing_sections, f"no '### <module>' section for: {missing_sections}"
|
||||||
@@ -25,8 +25,9 @@ class RecordingValidator:
|
|||||||
self.warnings = ['config warning']
|
self.warnings = ['config warning']
|
||||||
return True, [], list(self.warnings)
|
return True, [], list(self.warnings)
|
||||||
|
|
||||||
def _validate_plugins(self):
|
def _validate_plugins(self, discovered_plugins=None):
|
||||||
RecordingValidator.calls.append(('plugins',))
|
RecordingValidator.calls.append(('plugins',))
|
||||||
|
RecordingValidator.discovered = discovered_plugins
|
||||||
self.warnings = ['plugin warning']
|
self.warnings = ['plugin warning']
|
||||||
|
|
||||||
|
|
||||||
@@ -75,3 +76,19 @@ def test_each_check_runs_once(mock_config_manager, mock_display_manager,
|
|||||||
assert any('plugin warning' in r.getMessage() for r in caplog.records)
|
assert any('plugin warning' in r.getMessage() for r in caplog.records)
|
||||||
finally:
|
finally:
|
||||||
controller.cleanup()
|
controller.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugins_are_discovered_once(mock_config_manager, mock_display_manager,
|
||||||
|
mock_cache_manager, test_config_with_plugins,
|
||||||
|
emulator_mode):
|
||||||
|
"""The plugin checks reuse the controller's discovery rather than rescanning
|
||||||
|
the plugins directory (and logging every plugin) a second time."""
|
||||||
|
RecordingValidator.calls = []
|
||||||
|
RecordingValidator.discovered = None
|
||||||
|
controller = _build_controller(mock_config_manager, mock_display_manager,
|
||||||
|
mock_cache_manager, test_config_with_plugins)
|
||||||
|
try:
|
||||||
|
assert controller.plugin_manager.discover_plugins.call_count == 1
|
||||||
|
assert RecordingValidator.discovered == []
|
||||||
|
finally:
|
||||||
|
controller.cleanup()
|
||||||
|
|||||||
@@ -137,6 +137,24 @@ class TestCacheLifecycle:
|
|||||||
assert fm.cache_generation == gen_before + 1
|
assert fm.cache_generation == gen_before + 1
|
||||||
assert not fm.font_cache
|
assert not fm.font_cache
|
||||||
|
|
||||||
|
def test_clear_cache_bumps_generation(self, fm):
|
||||||
|
# Layout contexts and font-usage results are keyed off
|
||||||
|
# cache_generation; clear_cache used to drop the fonts without
|
||||||
|
# telling them.
|
||||||
|
gen_before = fm.cache_generation
|
||||||
|
fm.clear_cache()
|
||||||
|
assert fm.cache_generation == gen_before + 1
|
||||||
|
|
||||||
|
def test_clearing_a_plugins_cached_fonts_bumps_generation(self, fm):
|
||||||
|
fm.font_cache["demo::tiny_8"] = object()
|
||||||
|
gen_before = fm.cache_generation
|
||||||
|
fm._clear_plugin_font_cache("demo")
|
||||||
|
assert "demo::tiny_8" not in fm.font_cache
|
||||||
|
assert fm.cache_generation == gen_before + 1
|
||||||
|
# Nothing to drop, nothing to rebuild.
|
||||||
|
fm._clear_plugin_font_cache("demo")
|
||||||
|
assert fm.cache_generation == gen_before + 1
|
||||||
|
|
||||||
|
|
||||||
class TestPluginFonts:
|
class TestPluginFonts:
|
||||||
"""plugin:// sources resolve against the plugin's own directory, which
|
"""plugin:// sources resolve against the plugin's own directory, which
|
||||||
|
|||||||
@@ -356,6 +356,18 @@ class TestRefreshPlaceholderTimestamp:
|
|||||||
def test_missing_file_is_not_an_error(self, tmp_path):
|
def test_missing_file_is_not_an_error(self, tmp_path):
|
||||||
assert refresh_placeholder_timestamp(tmp_path / "nope.png") is False
|
assert refresh_placeholder_timestamp(tmp_path / "nope.png") is False
|
||||||
|
|
||||||
|
def test_the_restamp_is_atomic(self, tmp_path):
|
||||||
|
# It used to save over the file in place, so a renderer opening the
|
||||||
|
# logo mid-write read a truncated PNG. A failed write must now leave
|
||||||
|
# the previous placeholder intact and no temp file behind.
|
||||||
|
assert LogoDownloader().create_placeholder_logo("COLL", str(tmp_path))
|
||||||
|
path = tmp_path / "COLL.png"
|
||||||
|
before = path.read_bytes()
|
||||||
|
with patch("src.logo_downloader.os.replace", side_effect=OSError("disk full")):
|
||||||
|
assert refresh_placeholder_timestamp(path) is False
|
||||||
|
assert path.read_bytes() == before
|
||||||
|
assert sorted(p.name for p in tmp_path.iterdir()) == ["COLL.png"]
|
||||||
|
|
||||||
|
|
||||||
class TestFailurePaths:
|
class TestFailurePaths:
|
||||||
def test_a_team_without_logos_is_a_failed_download(self, tmp_path):
|
def test_a_team_without_logos_is_a_failed_download(self, tmp_path):
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""A failed logo download is not retried on every call.
|
||||||
|
|
||||||
|
A logo that is simply absent (not a stale placeholder) had nothing to back
|
||||||
|
off on: every load_logo_with_download() call retried the download, each with
|
||||||
|
a 30s timeout, for as long as the host was down or the URL was dead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import requests
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from src.common.logo_helper import MISSING_LOGO_RECHECK_SECONDS, LogoHelper
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _no_real_chmod(monkeypatch):
|
||||||
|
monkeypatch.setattr("src.common.logo_helper.ensure_directory_permissions", MagicMock())
|
||||||
|
monkeypatch.setattr("src.logo_downloader.ensure_file_permissions", MagicMock())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def helper():
|
||||||
|
h = LogoHelper(display_width=64, display_height=32,
|
||||||
|
logger=logging.getLogger("test.logo_backoff"))
|
||||||
|
h.session.get = MagicMock(side_effect=requests.ConnectionError("down"))
|
||||||
|
return h
|
||||||
|
|
||||||
|
|
||||||
|
def _png_response():
|
||||||
|
buf = io.BytesIO()
|
||||||
|
Image.new("RGBA", (20, 20), (0, 128, 0, 255)).save(buf, "PNG")
|
||||||
|
data = buf.getvalue()
|
||||||
|
response = MagicMock()
|
||||||
|
response.__enter__.return_value = response
|
||||||
|
response.__exit__.return_value = False
|
||||||
|
response.raise_for_status = MagicMock()
|
||||||
|
response.headers = {"content-type": "image/png"}
|
||||||
|
response.iter_content = lambda *a, **k: iter([data])
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def _load(helper, path):
|
||||||
|
return helper.load_logo_with_download("PHI", path, "http://x/logo.png",
|
||||||
|
max_width=20, max_height=20)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_second_call_does_not_retry(helper, tmp_path):
|
||||||
|
path = tmp_path / "PHI.png"
|
||||||
|
assert _load(helper, path) is not None # placeholder
|
||||||
|
assert _load(helper, path) is not None
|
||||||
|
assert helper.session.get.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_it_retries_after_the_recheck_window(helper, tmp_path):
|
||||||
|
path = tmp_path / "PHI.png"
|
||||||
|
_load(helper, path)
|
||||||
|
helper._download_failures[str(path)] -= MISSING_LOGO_RECHECK_SECONDS + 1
|
||||||
|
_load(helper, path)
|
||||||
|
assert helper.session.get.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_other_paths_are_not_held_back(helper, tmp_path):
|
||||||
|
_load(helper, tmp_path / "PHI.png")
|
||||||
|
_load(helper, tmp_path / "DAL.png")
|
||||||
|
assert helper.session.get.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_cache_forgets_failures(helper, tmp_path):
|
||||||
|
path = tmp_path / "PHI.png"
|
||||||
|
_load(helper, path)
|
||||||
|
helper.clear_cache()
|
||||||
|
_load(helper, path)
|
||||||
|
assert helper.session.get.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalidate_forgets_the_failure(helper, tmp_path):
|
||||||
|
path = tmp_path / "PHI.png"
|
||||||
|
_load(helper, path)
|
||||||
|
helper._invalidate_cached_logo("PHI", path)
|
||||||
|
helper.session.get = MagicMock(return_value=_png_response())
|
||||||
|
logo = _load(helper, path)
|
||||||
|
helper.session.get.assert_called_once()
|
||||||
|
assert path.exists()
|
||||||
|
assert logo.getpixel((0, 0))[:3] == (0, 128, 0)
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""ScrollHelper: what a new scrolling image does on the way in.
|
||||||
|
|
||||||
|
- set_scrolling_image cached a non-RGB image as-is, and every frame is cut
|
||||||
|
with Image.frombytes('RGB', ...): an RGBA strip came out as garbage and an
|
||||||
|
L strip raised.
|
||||||
|
- Only reset_scroll() reset last_update_time. In time-based mode the first
|
||||||
|
update after a new image (e.g. the plugin coming back on screen) advanced
|
||||||
|
the position by the whole idle gap, skipping the start of the content.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from src.common.scroll_helper import ScrollHelper
|
||||||
|
|
||||||
|
W, H = 16, 8
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def helper():
|
||||||
|
return ScrollHelper(display_width=W, display_height=H)
|
||||||
|
|
||||||
|
|
||||||
|
class TestNonRgbImages:
|
||||||
|
def test_rgba_is_composited_onto_black(self, helper):
|
||||||
|
img = Image.new('RGBA', (40, H), (0, 0, 0, 0))
|
||||||
|
for x in range(10):
|
||||||
|
for y in range(H):
|
||||||
|
img.putpixel((x, y), (255, 0, 0, 255))
|
||||||
|
# Hidden colour under full transparency must not leak through.
|
||||||
|
img.putpixel((20, 0), (0, 255, 0, 0))
|
||||||
|
helper.set_scrolling_image(img)
|
||||||
|
assert helper.cached_image.mode == 'RGB'
|
||||||
|
frame = helper.get_visible_portion()
|
||||||
|
assert frame.size == (W, H)
|
||||||
|
assert frame.getpixel((0, 0)) == (255, 0, 0)
|
||||||
|
assert frame.getpixel((12, 0)) == (0, 0, 0)
|
||||||
|
helper.scroll_position = 20.0
|
||||||
|
assert helper.get_visible_portion().getpixel((0, 0)) == (0, 0, 0)
|
||||||
|
|
||||||
|
def test_greyscale_does_not_raise(self, helper):
|
||||||
|
helper.set_scrolling_image(Image.new('L', (40, H), 200))
|
||||||
|
frame = helper.get_visible_portion()
|
||||||
|
assert frame.getpixel((0, 0)) == (200, 200, 200)
|
||||||
|
|
||||||
|
def test_palette_with_transparency(self, helper):
|
||||||
|
img = Image.new('P', (40, H), 1)
|
||||||
|
img.putpalette([0, 0, 0, 0, 0, 255] + [0] * 762)
|
||||||
|
img.info['transparency'] = 1
|
||||||
|
helper.set_scrolling_image(img)
|
||||||
|
assert helper.get_visible_portion().getpixel((0, 0)) == (0, 0, 0)
|
||||||
|
|
||||||
|
def test_an_rgb_image_is_kept_as_the_same_object(self, helper):
|
||||||
|
img = Image.new('RGB', (40, H), (1, 2, 3))
|
||||||
|
helper.set_scrolling_image(img)
|
||||||
|
assert helper.cached_image is img
|
||||||
|
|
||||||
|
|
||||||
|
class TestNoJumpAfterIdle:
|
||||||
|
def _idle_then_new_image(self, helper, install):
|
||||||
|
helper.scroll_speed = 100.0 # px/s, time-based
|
||||||
|
install(helper)
|
||||||
|
helper.update_scroll_position()
|
||||||
|
# Off screen for 10 seconds, then a fresh image.
|
||||||
|
helper.last_update_time = time.time() - 10.0
|
||||||
|
install(helper)
|
||||||
|
helper.update_scroll_position()
|
||||||
|
return helper.scroll_position
|
||||||
|
|
||||||
|
def test_set_scrolling_image(self, helper):
|
||||||
|
pos = self._idle_then_new_image(
|
||||||
|
helper, lambda h: h.set_scrolling_image(Image.new('RGB', (5000, H))))
|
||||||
|
assert pos < 50, f"jumped {pos}px on the first update of a new image"
|
||||||
|
|
||||||
|
def test_create_scrolling_image(self, helper):
|
||||||
|
pos = self._idle_then_new_image(
|
||||||
|
helper, lambda h: h.create_scrolling_image([Image.new('RGB', (5000, H))]))
|
||||||
|
assert pos < 50, f"jumped {pos}px on the first update of a new image"
|
||||||
|
|
||||||
|
def test_fixed_step_mode_still_steps_exactly(self, helper):
|
||||||
|
helper.set_pixels_per_frame(2)
|
||||||
|
helper.set_scrolling_image(Image.new('RGB', (5000, H)))
|
||||||
|
helper.update_scroll_position()
|
||||||
|
helper.update_scroll_position()
|
||||||
|
assert helper.scroll_position == 4.0
|
||||||
@@ -242,6 +242,28 @@ class TestPlugins:
|
|||||||
assert is_valid is True
|
assert is_valid is True
|
||||||
assert errors == []
|
assert errors == []
|
||||||
|
|
||||||
|
def test_a_null_plugin_block_does_not_abandon_the_checks(self, good_cache, tmp_path):
|
||||||
|
# config.get(id, {}) returns None for "known": null, and .get() on it
|
||||||
|
# raised -- caught as "Could not validate plugins", skipping every
|
||||||
|
# plugin after it.
|
||||||
|
pm = MagicMock()
|
||||||
|
pm.discover_plugins.return_value = ['nulled', 'known']
|
||||||
|
plugin_dir = tmp_path / "known"
|
||||||
|
plugin_dir.mkdir() # no manifest.json
|
||||||
|
pm.get_plugin_directory.return_value = str(plugin_dir)
|
||||||
|
|
||||||
|
config = dict(GOOD_CONFIG, nulled=None, known={'enabled': True})
|
||||||
|
validator = StartupValidator(make_config_manager(config), pm)
|
||||||
|
validator._validate_plugins()
|
||||||
|
assert not any('Could not validate plugins' in w for w in validator.warnings)
|
||||||
|
assert "Plugin 'known' manifest.json not found" in validator.errors
|
||||||
|
|
||||||
|
def test_a_passed_discovery_is_reused(self, good_cache):
|
||||||
|
pm = MagicMock()
|
||||||
|
validator = StartupValidator(make_config_manager(dict(GOOD_CONFIG)), pm)
|
||||||
|
validator._validate_plugins(discovered_plugins=[])
|
||||||
|
pm.discover_plugins.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
class TestIdempotence:
|
class TestIdempotence:
|
||||||
"""validate_all() resets error/warning state each run (the fixed bug)."""
|
"""validate_all() resets error/warning state each run (the fixed bug)."""
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""A rejected passphrase must reach the web UI as "wrong_password:".
|
||||||
|
|
||||||
|
_connect_nmcli prefixes the message with "wrong_password:" and the api_v3
|
||||||
|
wifi route turns that into ``error_type == 'wrong_password'``, which the
|
||||||
|
setup pages key their "incorrect password" prompt off. _connect_validated
|
||||||
|
used to replace the message on every failure branch (restore the old
|
||||||
|
network, or bring the setup AP up), so the prefix never survived.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from src.wifi_manager import WiFiManager # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _manager(connected_ssid=None):
|
||||||
|
manager = WiFiManager.__new__(WiFiManager) # no __init__: no real host access
|
||||||
|
manager.has_nmcli = True
|
||||||
|
manager._wifi_interface = "wlan0"
|
||||||
|
manager.get_wifi_status = MagicMock(return_value=SimpleNamespace(
|
||||||
|
connected=bool(connected_ssid), ssid=connected_ssid, ip_address=None))
|
||||||
|
manager._is_ap_mode_active = MagicMock(return_value=False)
|
||||||
|
manager._ensure_wifi_radio_enabled = MagicMock(return_value=True)
|
||||||
|
manager._show_led_message = MagicMock()
|
||||||
|
manager.disconnect_from_network = MagicMock(return_value=(True, "ok"))
|
||||||
|
manager._wait_for_device_idle = MagicMock(return_value=True)
|
||||||
|
manager.enable_ap_mode = MagicMock(return_value=(True, "AP up"))
|
||||||
|
return manager
|
||||||
|
|
||||||
|
|
||||||
|
def _run(manager, nmcli_result):
|
||||||
|
manager._connect_nmcli = MagicMock(return_value=nmcli_result)
|
||||||
|
nmcli_out = SimpleNamespace(returncode=0, stdout="GENERAL.CONNECTION:OldNet\n")
|
||||||
|
with patch("src.wifi_manager.subprocess.run", return_value=nmcli_out), \
|
||||||
|
patch("src.wifi_manager.time.sleep"):
|
||||||
|
return manager._connect_validated("HomeNet", "hunter22")
|
||||||
|
|
||||||
|
|
||||||
|
WRONG = (False, "wrong_password: Secrets were required, but not provided")
|
||||||
|
|
||||||
|
|
||||||
|
class TestWrongPasswordSurvivesRecovery:
|
||||||
|
def test_when_the_setup_ap_comes_back_up(self):
|
||||||
|
ok, message = _run(_manager(), WRONG)
|
||||||
|
assert ok is False
|
||||||
|
assert message.startswith("wrong_password:")
|
||||||
|
|
||||||
|
def test_when_the_setup_ap_fails_too(self):
|
||||||
|
manager = _manager()
|
||||||
|
manager.enable_ap_mode.return_value = (False, "hostapd missing")
|
||||||
|
ok, message = _run(manager, WRONG)
|
||||||
|
assert ok is False
|
||||||
|
assert message.startswith("wrong_password:")
|
||||||
|
assert "hostapd missing" in message
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("restored", [True, False])
|
||||||
|
def test_when_the_previous_network_is_restored_or_not(self, restored):
|
||||||
|
manager = _manager(connected_ssid="OldNet")
|
||||||
|
manager._restore_original_connection = MagicMock(return_value=restored)
|
||||||
|
ok, message = _run(manager, WRONG)
|
||||||
|
assert ok is False
|
||||||
|
assert message.startswith("wrong_password:")
|
||||||
|
|
||||||
|
|
||||||
|
class TestOtherFailuresAreUnchanged:
|
||||||
|
def test_a_generic_failure_gets_no_prefix(self):
|
||||||
|
ok, message = _run(_manager(), (False, "Connection timed out"))
|
||||||
|
assert ok is False
|
||||||
|
assert message == "Connection failed. AP mode enabled."
|
||||||
|
|
||||||
|
def test_success_passes_through(self):
|
||||||
|
assert _run(_manager(), (True, "Connected to HomeNet")) == (True, "Connected to HomeNet")
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""/api/v3 JSON GETs must not be served from the browser cache.
|
||||||
|
|
||||||
|
They carried ``Cache-Control: private, max-age=5``, so a fetch() made right
|
||||||
|
after an install, a toggle or a Wi-Fi connect could be answered with the copy
|
||||||
|
from before it. Non-JSON files served through the API keep the short cache.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Flask, Response, jsonify
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
import web_interface.app as web_app
|
||||||
|
# The real after_request hook on a throwaway app, so the header logic is
|
||||||
|
# tested without depending on any real endpoint's managers.
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.after_request(web_app.add_security_headers)
|
||||||
|
app.add_url_rule('/api/v3/probe/json', 'probe_json',
|
||||||
|
lambda: jsonify({'status': 'success'}))
|
||||||
|
app.add_url_rule('/api/v3/probe/file', 'probe_file',
|
||||||
|
lambda: Response('body{}', mimetype='text/css'))
|
||||||
|
app.add_url_rule('/static/v3/probe.css', 'probe_static',
|
||||||
|
lambda: Response('body{}', mimetype='text/css'))
|
||||||
|
with app.test_client() as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
def test_json_gets_are_not_stored(client):
|
||||||
|
resp = client.get('/api/v3/probe/json')
|
||||||
|
assert resp.headers['Cache-Control'] == 'no-store'
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_json_files_keep_the_short_cache(client):
|
||||||
|
resp = client.get('/api/v3/probe/file')
|
||||||
|
assert resp.headers['Cache-Control'] == 'private, max-age=5, must-revalidate'
|
||||||
|
|
||||||
|
|
||||||
|
def test_static_assets_are_still_long_cached(client):
|
||||||
|
resp = client.get('/static/v3/probe.css')
|
||||||
|
assert 'immutable' in resp.headers.get('Cache-Control', '')
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_real_app_marks_an_api_json_answer_no_store():
|
||||||
|
import web_interface.app as web_app
|
||||||
|
web_app.app.config['TESTING'] = True
|
||||||
|
with web_app.app.test_client() as c:
|
||||||
|
resp = c.get('/api/v3/no-such-endpoint-for-cache-test')
|
||||||
|
assert resp.mimetype == 'application/json'
|
||||||
|
assert resp.headers['Cache-Control'] == 'no-store'
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""POST /api/v3/starlark/apps/<app_id>/toggle shares _toggle_starlark_app.
|
||||||
|
|
||||||
|
The route carried its own copy of the toggle with the bugs the shared helper
|
||||||
|
had already fixed: it changed the loaded app before the disk write (so a
|
||||||
|
failed save left the two disagreeing), indexed ``manifest['apps'][app_id]``
|
||||||
|
(a KeyError, answered as a 500, for a loaded app with no on-disk entry), and
|
||||||
|
stored ``"false"`` -- a truthy string -- as the new state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
PKG = 'web_interface.blueprints.api_v3'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
from web_interface.app import app
|
||||||
|
app.config['TESTING'] = True
|
||||||
|
with app.test_client() as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
def _loaded_plugin(enabled=True, save_ok=True, disk=None):
|
||||||
|
app = MagicMock()
|
||||||
|
app.manifest = {'enabled': enabled}
|
||||||
|
app.is_enabled.return_value = enabled
|
||||||
|
plugin = MagicMock()
|
||||||
|
plugin.apps = {'clock': app}
|
||||||
|
disk = {'apps': {}} if disk is None else disk
|
||||||
|
|
||||||
|
def _update(fn):
|
||||||
|
if not save_ok:
|
||||||
|
return False
|
||||||
|
fn(disk)
|
||||||
|
return True
|
||||||
|
plugin._update_manifest_safe.side_effect = _update
|
||||||
|
return plugin, app, disk
|
||||||
|
|
||||||
|
|
||||||
|
def _post(client, body):
|
||||||
|
return client.post('/api/v3/starlark/apps/clock/toggle', json=body)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadedApp:
|
||||||
|
def test_a_missing_disk_entry_is_created_not_a_500(self, client):
|
||||||
|
plugin, app, disk = _loaded_plugin(enabled=True)
|
||||||
|
with patch(f'{PKG}._get_starlark_plugin', return_value=plugin):
|
||||||
|
resp = _post(client, {'enabled': False})
|
||||||
|
body = resp.get_json()
|
||||||
|
assert resp.status_code == 200, body
|
||||||
|
assert body == {'status': 'success', 'message': body['message'], 'enabled': False}
|
||||||
|
assert disk['apps']['clock']['enabled'] is False
|
||||||
|
assert app.manifest['enabled'] is False
|
||||||
|
|
||||||
|
def test_a_failed_save_leaves_the_loaded_app_alone(self, client):
|
||||||
|
plugin, app, _ = _loaded_plugin(enabled=True, save_ok=False)
|
||||||
|
with patch(f'{PKG}._get_starlark_plugin', return_value=plugin):
|
||||||
|
resp = _post(client, {'enabled': False})
|
||||||
|
assert resp.status_code == 500
|
||||||
|
assert resp.get_json()['status'] == 'error'
|
||||||
|
assert app.manifest['enabled'] is True
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('sent', ['false', 'False', 0, '0'])
|
||||||
|
def test_a_false_string_or_zero_disables(self, client, sent):
|
||||||
|
plugin, app, disk = _loaded_plugin(enabled=True)
|
||||||
|
with patch(f'{PKG}._get_starlark_plugin', return_value=plugin):
|
||||||
|
resp = _post(client, {'enabled': sent})
|
||||||
|
assert resp.get_json()['enabled'] is False
|
||||||
|
assert disk['apps']['clock']['enabled'] is False
|
||||||
|
|
||||||
|
def test_an_unrecognised_value_is_refused(self, client):
|
||||||
|
plugin, app, disk = _loaded_plugin(enabled=True)
|
||||||
|
with patch(f'{PKG}._get_starlark_plugin', return_value=plugin):
|
||||||
|
resp = _post(client, {'enabled': 'maybe'})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert disk == {'apps': {}}
|
||||||
|
|
||||||
|
def test_no_enabled_flips_the_current_state(self, client):
|
||||||
|
plugin, app, disk = _loaded_plugin(enabled=True)
|
||||||
|
with patch(f'{PKG}._get_starlark_plugin', return_value=plugin):
|
||||||
|
resp = _post(client, {})
|
||||||
|
assert resp.get_json()['enabled'] is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestStandalone:
|
||||||
|
def _run(self, client, body, manifest):
|
||||||
|
written = {}
|
||||||
|
# The real lock is fcntl-based (Linux only); the toggle logic is what
|
||||||
|
# is under test here.
|
||||||
|
with patch(f'{PKG}._get_starlark_plugin', return_value=None), \
|
||||||
|
patch(f'{PKG}._starlark_manifest_lock', contextlib.nullcontext), \
|
||||||
|
patch(f'{PKG}._read_starlark_manifest', return_value=manifest), \
|
||||||
|
patch(f'{PKG}._write_starlark_manifest',
|
||||||
|
side_effect=lambda m: written.update(m) or True):
|
||||||
|
resp = _post(client, body)
|
||||||
|
return resp, written
|
||||||
|
|
||||||
|
def test_toggle_is_written(self, client):
|
||||||
|
resp, written = self._run(client, {'enabled': 'false'},
|
||||||
|
{'apps': {'clock': {'enabled': True}}})
|
||||||
|
assert resp.get_json()['enabled'] is False
|
||||||
|
assert written['apps']['clock']['enabled'] is False
|
||||||
|
|
||||||
|
def test_no_enabled_flips_the_manifest_state(self, client):
|
||||||
|
resp, _ = self._run(client, {}, {'apps': {'clock': {'enabled': False}}})
|
||||||
|
assert resp.get_json()['enabled'] is True
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('body', [{}, {'enabled': True}])
|
||||||
|
def test_an_unknown_app_is_404(self, client, body):
|
||||||
|
resp, written = self._run(client, body, {'apps': {}})
|
||||||
|
assert resp.status_code == 404
|
||||||
|
assert 'clock' in resp.get_json()['message']
|
||||||
|
assert written == {}
|
||||||
@@ -570,10 +570,16 @@ def add_security_headers(response):
|
|||||||
response.headers['Cache-Control'] = 'public, max-age=31536000, immutable'
|
response.headers['Cache-Control'] = 'public, max-age=31536000, immutable'
|
||||||
response.headers['Expires'] = (datetime.now() + timedelta(days=365)).strftime('%a, %d %b %Y %H:%M:%S GMT')
|
response.headers['Expires'] = (datetime.now() + timedelta(days=365)).strftime('%a, %d %b %Y %H:%M:%S GMT')
|
||||||
elif request.path.startswith('/api/v3/'):
|
elif request.path.startswith('/api/v3/'):
|
||||||
# Short cache for API responses (5 seconds) to allow for quick updates
|
|
||||||
# but reduce server load for repeated requests
|
|
||||||
if request.method == 'GET' and 'stream' not in request.path:
|
if request.method == 'GET' and 'stream' not in request.path:
|
||||||
response.headers['Cache-Control'] = 'private, max-age=5, must-revalidate'
|
if response.mimetype == 'application/json':
|
||||||
|
# JSON is live state. A cached copy made a fetch() right after
|
||||||
|
# an install, toggle or Wi-Fi connect show the state from
|
||||||
|
# before it for up to the cache lifetime.
|
||||||
|
response.headers['Cache-Control'] = 'no-store'
|
||||||
|
else:
|
||||||
|
# Files served through the API (plugin web UI assets, backup
|
||||||
|
# downloads) keep the short cache.
|
||||||
|
response.headers['Cache-Control'] = 'private, max-age=5, must-revalidate'
|
||||||
else:
|
else:
|
||||||
# No cache for HTML pages to ensure fresh content
|
# No cache for HTML pages to ensure fresh content
|
||||||
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
|
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
|
||||||
|
|||||||
@@ -153,6 +153,9 @@ def get_installed_plugins():
|
|||||||
'category': plugin_info.get('category', 'General'),
|
'category': plugin_info.get('category', 'General'),
|
||||||
'description': plugin_info.get('description', 'No description available'),
|
'description': plugin_info.get('description', 'No description available'),
|
||||||
'tags': plugin_info.get('tags', []),
|
'tags': plugin_info.get('tags', []),
|
||||||
|
# The tab nav uses this as the <i> element's Font Awesome class
|
||||||
|
# (app-shell.js / app-early.js); only a string can be one.
|
||||||
|
'icon': plugin_info.get('icon') if isinstance(plugin_info.get('icon'), str) else None,
|
||||||
'enabled': enabled,
|
'enabled': enabled,
|
||||||
'verified': verified,
|
'verified': verified,
|
||||||
'loaded': plugin_info.get('loaded', False),
|
'loaded': plugin_info.get('loaded', False),
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from web_interface.blueprints.api_v3 import (
|
|||||||
subprocess, tempfile,
|
subprocess, tempfile,
|
||||||
)
|
)
|
||||||
import web_interface.blueprints.api_v3 as _pkg
|
import web_interface.blueprints.api_v3 as _pkg
|
||||||
|
from web_interface.blueprints.api_v3.wifi import _parse_bool_ish
|
||||||
# Read through the module rather than bound by value: tests patch these
|
# Read through the module rather than bound by value: tests patch these
|
||||||
# as module attributes, and a value binding would not see the patch.
|
# as module attributes, and a value binding would not see the patch.
|
||||||
# Several are also called from helpers that live in __init__, so the
|
# Several are also called from helpers that live in __init__, so the
|
||||||
@@ -547,36 +548,33 @@ def toggle_starlark_app(app_id):
|
|||||||
try:
|
try:
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
|
|
||||||
starlark_plugin = _pkg._get_starlark_plugin()
|
# The write itself goes through _toggle_starlark_app, the same helper
|
||||||
if starlark_plugin:
|
# /plugins/toggle uses: it saves to disk before touching the loaded
|
||||||
app = starlark_plugin.apps.get(app_id)
|
# app, and tolerates a loaded app with no on-disk entry. This route
|
||||||
if not app:
|
# used to carry its own copy without either fix, and stored "false"
|
||||||
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
|
# (a truthy string) as-is.
|
||||||
enabled = data.get('enabled')
|
enabled = data.get('enabled')
|
||||||
if enabled is None:
|
if enabled is None:
|
||||||
|
_, err = _validate_starlark_app_path(app_id)
|
||||||
|
if err:
|
||||||
|
return jsonify({'status': 'error', 'message': err}), 400
|
||||||
|
# No explicit state: flip the current one.
|
||||||
|
starlark_plugin = _pkg._get_starlark_plugin()
|
||||||
|
app = starlark_plugin.apps.get(app_id) if starlark_plugin else None
|
||||||
|
if app is not None:
|
||||||
enabled = not app.is_enabled()
|
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:
|
else:
|
||||||
return jsonify({'status': 'error', 'message': 'Failed to save'}), 500
|
app_data = _pkg._read_starlark_manifest().get('apps', {}).get(app_id)
|
||||||
|
if not app_data:
|
||||||
|
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
|
||||||
|
enabled = not app_data.get('enabled', True)
|
||||||
|
else:
|
||||||
|
enabled = _parse_bool_ish(enabled)
|
||||||
|
if enabled is None:
|
||||||
|
return jsonify({'status': 'error',
|
||||||
|
'message': 'enabled must be true or false'}), 400
|
||||||
|
|
||||||
|
return _pkg._toggle_starlark_app(app_id, enabled)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("[Starlark] toggle_starlark_app failed")
|
logger.exception("[Starlark] toggle_starlark_app failed")
|
||||||
|
|||||||
Reference in New Issue
Block a user