fix: settings the display ignored, a memory leak, and the plugin card handler (#605)

* fix(errors): stop affected_plugins growing without bound

Each repeat of an error pattern appended every plugin in the time window to
the pattern's list again, so a plugin failing in a loop grew the display
process's memory without limit: 3,000 errors from three plugins reached 2.5
million entries. Keep the list unique.

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

* fix(fonts): load a BDF font at its native size instead of PIL's default

FreeType rejects any size but a BDF strike's own, and FontManager answered
that with ImageFont.load_default() -- a different typeface -- so 5x7.bdf
requested at 8 or 10px rendered as PIL's default font. Retry at the native
strike, as element_style already does.

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

* fix(web): plugin toggle failures no longer claim "operation in progress"

Every exception in POST /plugins/toggle was mapped to
PLUGIN_OPERATION_CONFLICT, so any failure told the user "A plugin operation
is already in progress". Report the failure as what it is, and record the
plugin id in the operation history for form posts too (it read a `data`
variable that only the JSON path set).

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

* fix(web): route plugin card clicks through handlePluginAction

The document-level delegation checked `typeof handlePluginAction`, which is
scoped inside the plugin-manager IIFE and so never visible to it. Every card
click took a copied fallback that stopped propagation (the grid's own
listener never ran), confirmed an uninstall twice, and sent Starlark app
uninstalls to POST /plugins/uninstall instead of DELETE /starlark/apps/<id>.
Expose the handler on window and delegate to it.

Also run every test/js/unit suite under pytest: they need only node, but CI
ran one of the eight.

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

* fix(display): apply Rotation durations, WiFi messages and Vegas settings

Three settings the web UI saves never reached the display:

- Rotation & Durations: display.display_durations was never read. Every
  plugin inherits get_display_duration() and the plugin was asked first. A
  saved value now wins. The page shows unsaved screens blank with the
  plugin's own duration as a placeholder, and saving a blank removes the
  override, so one save no longer pins every screen.
- WiFi status overlay: the controller looked for wifi_status.json one
  directory above the repo. Both sides now use
  wifi_manager.get_wifi_status_path(). The message is written by rename so
  the display never reads it half-written, and the resumed plugin redraws the
  whole panel afterwards.
- Vegas: nothing called coordinator.update_config(), so saved Vegas settings
  never reached a running scroll. They are now queued when
  display.vegas_scroll changes, and applied while Vegas is stopped too, so a
  disable then re-enable works. The follower's scroll-speed default (75) now
  matches VegasModeConfig's (50).

Also throttles Vegas's per-frame live-priority scan to 4Hz. It cost 139us
per frame on a Pi 4 with two scoreboards (1.7% of a 125fps frame) and grows
with each plugin.

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

* fix: keep affected_plugins order when serialized; guard non-Element targets

ErrorPattern.to_dict() ran the now-ordered list through set(), so
get_error_summary() listed plugins in an unstable order. The document-level
card-action listener called event.target.closest() without checking the
target is an Element.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-23 10:29:16 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 0903f9055f
commit 342e9164b8
23 changed files with 645 additions and 183 deletions
+7
View File
@@ -19,6 +19,13 @@ accepts both, but the store flags the old spelling as deprecated
## Unreleased ## Unreleased
- `FontManager.get_font()` returns a BDF font at its native size when asked for
a size the file doesn't contain (5x7.bdf at 8 or 10px, say). It used to
return PIL's default font, a different typeface, so a plugin that relied on
that will now render the font it asked for.
- `src.wifi_manager.get_wifi_status_path()` — where WiFi status messages for
the display are written (`config/wifi_status.json`).
## 3.5.0 ## 3.5.0
New modules a plugin may import via `src.*` (floor on 3.5.0): New modules a plugin may import via `src.*` (floor on 3.5.0):
+56 -39
View File
@@ -26,7 +26,6 @@ import json
import threading import threading
import types import types
from contextlib import contextmanager from contextlib import contextmanager
from pathlib import Path
from typing import Dict, Any, List, Optional, Callable from typing import Dict, Any, List, Optional, Callable
from datetime import datetime from datetime import datetime
from concurrent.futures import ThreadPoolExecutor, as_completed # pylint: disable=no-name-in-module from concurrent.futures import ThreadPoolExecutor, as_completed # pylint: disable=no-name-in-module
@@ -63,9 +62,6 @@ _vegas_mode_imported = False
VegasModeCoordinator = None VegasModeCoordinator = None
DEFAULT_DYNAMIC_DURATION_CAP = 180.0 DEFAULT_DYNAMIC_DURATION_CAP = 180.0
# WiFi status message file path (same as used in wifi_manager.py)
WIFI_STATUS_FILE = None # Will be initialized in __init__
class DisplayController: class DisplayController:
""" """
Top-level controller that owns the LED display run loop. Top-level controller that owns the LED display run loop.
@@ -231,13 +227,11 @@ class DisplayController:
# once live priority ends. # once live priority ends.
self._live_resume_index: Optional[int] = None self._live_resume_index: Optional[int] = None
# WiFi status message tracking # WiFi status message tracking. The path comes from wifi_manager so
global WIFI_STATUS_FILE # reader and writer can't drift: this used to resolve three levels up
if WIFI_STATUS_FILE is None: # from src/, one above the repo, and never saw a message.
# Resolve project root (same logic as wifi_manager.py) from src.wifi_manager import get_wifi_status_path
project_root = Path(__file__).parent.parent.parent.resolve() self.wifi_status_file = get_wifi_status_path()
WIFI_STATUS_FILE = project_root / "config" / "wifi_status.json"
self.wifi_status_file = WIFI_STATUS_FILE
self.wifi_status_active = False self.wifi_status_active = False
self.wifi_status_expires_at: Optional[float] = None self.wifi_status_expires_at: Optional[float] = None
# _check_wifi_status_message throttle state (checked at frame rate, # _check_wifi_status_message throttle state (checked at frame rate,
@@ -428,9 +422,7 @@ class DisplayController:
self._normal_brightness: int = ( self._normal_brightness: int = (
self.config.get('display', {}).get('hardware', {}).get('brightness', 90) self.config.get('display', {}).get('hardware', {}).get('brightness', 90)
) )
self._scroll_speed: float = ( self._scroll_speed: float = self._vegas_scroll_speed(self.config)
self.config.get('display', {}).get('vegas_scroll', {}).get('scroll_speed', 75)
)
# Brightness state tracking for dim schedule # Brightness state tracking for dim schedule
self.current_brightness = self._normal_brightness self.current_brightness = self._normal_brightness
@@ -450,16 +442,7 @@ class DisplayController:
# Register controller-level hot-reload callback so cached config values # Register controller-level hot-reload callback so cached config values
# (_normal_brightness, _scroll_speed, _tz, minute-gates) stay in sync # (_normal_brightness, _scroll_speed, _tz, minute-gates) stay in sync
# when the user saves settings via the web UI. # when the user saves settings via the web UI.
def _controller_config_change(old_config: Dict[str, Any], new_config: Dict[str, Any]) -> None: self.config_service.subscribe(self._controller_config_change)
self._refresh_config_cache(new_config)
# If a plugin was enabled/disabled, flag a reconcile for the main
# loop to apply (loading/unloading off the watcher thread is unsafe).
if (self._enabled_set_changed(old_config, new_config)
or self._enabled_plugin_not_running(new_config)):
with self._reconcile_flag_lock:
self._pending_plugin_reconcile = True
self.config_service.subscribe(_controller_config_change)
# Publish initial on-demand state # Publish initial on-demand state
try: try:
@@ -581,6 +564,9 @@ class DisplayController:
"""Check if Vegas mode should be running.""" """Check if Vegas mode should be running."""
if not self.vegas_coordinator: if not self.vegas_coordinator:
return False return False
# A stopped coordinator never reaches run_frame(), where queued config
# is applied, so re-enabling Vegas from the web UI would never land.
self.vegas_coordinator.apply_pending_config_if_idle()
if not self.vegas_coordinator.is_enabled: if not self.vegas_coordinator.is_enabled:
return False return False
if self.on_demand_active: if self.on_demand_active:
@@ -1068,16 +1054,22 @@ class DisplayController:
self._tick_plugin_updates() self._tick_plugin_updates()
def _get_display_duration(self, mode_key): def _get_display_duration(self, mode_key):
"""Get display duration for a mode.""" """Seconds to show a mode: the Rotation & Durations page's value for it
# Check plugin-specific duration first (display.display_durations), else the plugin's own duration.
if mode_key in self.plugin_modes:
plugin_instance = self.plugin_modes[mode_key] The saved value has to win. Every plugin inherits
if hasattr(plugin_instance, 'get_display_duration'): get_display_duration(), so checking the plugin first meant the page's
return plugin_instance.get_display_duration() values were never read.
"""
# Fall back to config display_durations = self.config.get('display', {}).get('display_durations', {}) or {}
display_durations = self.config.get('display', {}).get('display_durations', {}) override = display_durations.get(mode_key)
return display_durations.get(mode_key, 30) if isinstance(override, (int, float)) and not isinstance(override, bool) and override > 0:
return float(override)
plugin_instance = self.plugin_modes.get(mode_key)
if plugin_instance is not None and hasattr(plugin_instance, 'get_display_duration'):
return plugin_instance.get_display_duration()
return 30
def _get_global_dynamic_cap(self) -> Optional[float]: def _get_global_dynamic_cap(self) -> Optional[float]:
"""Return global fallback dynamic duration cap.""" """Return global fallback dynamic duration cap."""
@@ -2012,8 +2004,9 @@ class DisplayController:
if wifi_status_data: if wifi_status_data:
# Display WiFi status message and skip normal rotation # Display WiFi status message and skip normal rotation
if self._display_wifi_status_message(wifi_status_data): if self._display_wifi_status_message(wifi_status_data):
# Sleep for a short time to show the message # The plugin that resumes afterwards must redraw
# Use a short sleep to allow for quick updates # the whole panel, not paint over the message.
self.force_change = True
self._sleep_with_plugin_updates(0.5) self._sleep_with_plugin_updates(0.5)
continue # Skip to next iteration, don't rotate continue # Skip to next iteration, don't rotate
else: else:
@@ -3226,6 +3219,24 @@ class DisplayController:
self.current_mode_index %= len(self.available_modes) self.current_mode_index %= len(self.available_modes)
self.current_display_mode = self.available_modes[self.current_mode_index] self.current_display_mode = self.available_modes[self.current_mode_index]
def _controller_config_change(self, old_config: Dict[str, Any], new_config: Dict[str, Any]) -> None:
"""ConfigService subscriber: runs on the config-watcher thread."""
self._refresh_config_cache(new_config)
# Vegas keeps its own parsed copy of display.vegas_scroll. Queue the
# new one only when it changed: applying it rebuilds the strip.
# (getattr: this can fire before __init__ creates the coordinator.)
vegas = getattr(self, 'vegas_coordinator', None)
if vegas is not None and (
(old_config.get('display', {}) or {}).get('vegas_scroll')
!= (new_config.get('display', {}) or {}).get('vegas_scroll')):
vegas.update_config(new_config)
# If a plugin was enabled/disabled, flag a reconcile for the main
# loop to apply (loading/unloading off the watcher thread is unsafe).
if (self._enabled_set_changed(old_config, new_config)
or self._enabled_plugin_not_running(new_config)):
with self._reconcile_flag_lock:
self._pending_plugin_reconcile = True
def _refresh_config_cache(self, new_config: Dict[str, Any]) -> None: def _refresh_config_cache(self, new_config: Dict[str, Any]) -> None:
"""Refresh all config-derived caches when a hot-reload fires. """Refresh all config-derived caches when a hot-reload fires.
@@ -3238,9 +3249,7 @@ class DisplayController:
self._normal_brightness = ( self._normal_brightness = (
self.config.get('display', {}).get('hardware', {}).get('brightness', 90) self.config.get('display', {}).get('hardware', {}).get('brightness', 90)
) )
self._scroll_speed = ( self._scroll_speed = self._vegas_scroll_speed(self.config)
self.config.get('display', {}).get('vegas_scroll', {}).get('scroll_speed', 75)
)
# Force the timezone to be re-derived from the new config on next schedule check # Force the timezone to be re-derived from the new config on next schedule check
self._tz = None self._tz = None
# Invalidate minute-gates so the new schedule/dim times take effect immediately # Invalidate minute-gates so the new schedule/dim times take effect immediately
@@ -3250,6 +3259,14 @@ class DisplayController:
logger.debug("Config cache refreshed (brightness=%s, scroll_speed=%s)", logger.debug("Config cache refreshed (brightness=%s, scroll_speed=%s)",
self._normal_brightness, self._scroll_speed) self._normal_brightness, self._scroll_speed)
@staticmethod
def _vegas_scroll_speed(config: Dict[str, Any]) -> float:
"""Vegas scroll speed in px/s. The default must match VegasModeConfig's
(50): a follower dead-reckons with this value between the leader's
position packets, so a different default made it run 50% fast."""
vegas_cfg = (config.get('display', {}) or {}).get('vegas_scroll', {}) or {}
return float(vegas_cfg.get('scroll_speed', 50.0))
def cleanup(self): def cleanup(self):
"""Clean up resources.""" """Clean up resources."""
# Stop the async update worker first so no in-flight update() call # Stop the async update worker first so no in-flight update() call
+6 -3
View File
@@ -64,7 +64,7 @@ class ErrorPattern:
"count": self.count, "count": self.count,
"first_seen": self.first_seen.isoformat(), "first_seen": self.first_seen.isoformat(),
"last_seen": self.last_seen.isoformat(), "last_seen": self.last_seen.isoformat(),
"affected_plugins": list(set(self.affected_plugins)), "affected_plugins": list(dict.fromkeys(self.affected_plugins)),
"sample_messages": self.sample_messages[:3], # Keep only 3 samples "sample_messages": self.sample_messages[:3], # Keep only 3 samples
"severity": self.severity "severity": self.severity
} }
@@ -195,7 +195,9 @@ class ErrorAggregator:
severity = "warning" severity = "warning"
# Collect affected plugins # Collect affected plugins
affected_plugins = [r.plugin_id for r in recent_same_type if r.plugin_id] # Unique, in first-seen order. Each repeat re-scans the whole
# window, so merging duplicates in below grew without bound.
affected_plugins = list(dict.fromkeys(r.plugin_id for r in recent_same_type if r.plugin_id))
# Collect sample messages # Collect sample messages
sample_messages = list(set(r.message for r in recent_same_type[:5])) sample_messages = list(set(r.message for r in recent_same_type[:5]))
@@ -233,7 +235,8 @@ class ErrorAggregator:
self._patterns[pattern_key].count = count self._patterns[pattern_key].count = count
self._patterns[pattern_key].last_seen = record.timestamp self._patterns[pattern_key].last_seen = record.timestamp
self._patterns[pattern_key].severity = severity self._patterns[pattern_key].severity = severity
self._patterns[pattern_key].affected_plugins.extend(affected_plugins) known = self._patterns[pattern_key].affected_plugins
known.extend(p for p in affected_plugins if p not in known)
def on_pattern_detected(self, callback: Callable[[ErrorPattern], None]) -> None: def on_pattern_detected(self, callback: Callable[[ErrorPattern], None]) -> None:
""" """
+11 -2
View File
@@ -505,8 +505,17 @@ class FontManager:
"%spx", font_path, size_px, native_size "%spx", font_path, size_px, native_size
) )
face = freetype.Face(font_path) face = freetype.Face(font_path)
# Set character size (width, height) in 1/64th of points try:
face.set_char_size(size_px * 64, size_px * 64, 72, 72) # Character size in 1/64th points at 72dpi == pixel size.
face.set_char_size(size_px * 64, size_px * 64, 72, 72)
except freetype.FT_Exception:
# FreeType rejects any size but the strike's own, and get_font
# used to answer that with PIL's default font -- a different
# typeface. Use the native strike, as element_style does.
if native_size is None or native_size == size_px:
raise
face = freetype.Face(font_path)
face.set_char_size(native_size * 64, native_size * 64, 72, 72)
return face return face
except Exception as e: except Exception as e:
logger.error(f"Error loading BDF font {font_path}: {e}") logger.error(f"Error loading BDF font {font_path}: {e}")
+29 -3
View File
@@ -42,6 +42,12 @@ _FPS_HEALTHY_FRACTION = 0.9
#: rather than fine. #: rather than fine.
_FPS_HEARTBEAT_INTERVAL = 300.0 _FPS_HEARTBEAT_INTERVAL = 300.0
#: Seconds between live-priority scans while scrolling. The scan asks every
#: plugin mode has_live_priority() / has_live_content(): 139us per call on a
#: Pi 4 with two scoreboards (9 modes), 1.7% of a 125fps frame, growing with
#: every plugin. Game state doesn't change within a quarter second.
_LIVE_PRIORITY_CHECK_INTERVAL = 0.25
def _percentile(ordered: List[float], fraction: float) -> float: def _percentile(ordered: List[float], fraction: float) -> float:
"""Nearest-rank percentile of an already-sorted list. """Nearest-rank percentile of an already-sorted list.
@@ -70,6 +76,9 @@ class VegasModeCoordinator:
- Provide status and control interface - Provide status and control interface
""" """
# Class-level so coordinators built without __init__ (tests) have it.
_last_live_check: float = float('-inf')
def __init__( def __init__(
self, self,
config: Dict[str, Any], config: Dict[str, Any],
@@ -336,9 +345,14 @@ class VegasModeCoordinator:
# Check for config updates (synchronized access) # Check for config updates (synchronized access)
has_pending_update = self._pending_config_update has_pending_update = self._pending_config_update
# Check for live priority # Check for live priority (throttled; see _LIVE_PRIORITY_CHECK_INTERVAL).
if self._check_live_priority(): # Only a negative result is ever reused: a positive one pauses Vegas,
return False # and run_frame() returns early while paused.
now = time.monotonic()
if now - self._last_live_check >= _LIVE_PRIORITY_CHECK_INTERVAL:
self._last_live_check = now
if self._check_live_priority():
return False
# Apply pending config update outside lock # Apply pending config update outside lock
if has_pending_update: if has_pending_update:
@@ -633,6 +647,18 @@ class VegasModeCoordinator:
logger.debug("Config update queued (version %d)", self._config_version) logger.debug("Config update queued (version %d)", self._config_version)
def apply_pending_config_if_idle(self) -> None:
"""Apply a queued config update while Vegas isn't running.
run_frame() applies updates between frames but returns early once
Vegas has stopped, so without this a disable followed by a re-enable
would never take effect. Call from the display thread only.
"""
with self._state_lock:
if self._is_active or not self._pending_config_update:
return
self._apply_pending_config()
def _apply_pending_config(self) -> None: def _apply_pending_config(self) -> None:
"""Apply pending configuration update.""" """Apply pending configuration update."""
# Atomically grab pending config and clear it to avoid losing concurrent updates # Atomically grab pending config and clear it to avoid losing concurrent updates
+11 -1
View File
@@ -58,6 +58,12 @@ def get_wifi_config_path():
return Path(project_root) / "config" / "wifi_config.json" return Path(project_root) / "config" / "wifi_config.json"
def get_wifi_status_path() -> Path:
"""The status-message file WiFiManager writes and the display controller
reads (config/wifi_status.json, next to wifi_config.json)."""
return get_wifi_config_path().parent / "wifi_status.json"
HOSTAPD_CONFIG_PATH = Path("/etc/hostapd/hostapd.conf") HOSTAPD_CONFIG_PATH = Path("/etc/hostapd/hostapd.conf")
DNSMASQ_CONFIG_PATH = Path("/etc/dnsmasq.d/ledmatrix-captive.conf") DNSMASQ_CONFIG_PATH = Path("/etc/dnsmasq.d/ledmatrix-captive.conf")
# Drop-in config for NetworkManager's built-in dnsmasq (ipv4.method=shared). # Drop-in config for NetworkManager's built-in dnsmasq (ipv4.method=shared).
@@ -182,8 +188,12 @@ class WiFiManager:
'duration': duration 'duration': duration
} }
LED_STATUS_FILE.parent.mkdir(parents=True, exist_ok=True) LED_STATUS_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(LED_STATUS_FILE, 'w') as f: # Write-then-rename: the display reads this at ~1 Hz and deletes
# a file it can't parse, so a half-written one would lose the message.
tmp_path = LED_STATUS_FILE.with_name(LED_STATUS_FILE.name + '.tmp')
with open(tmp_path, 'w') as f:
json.dump(status, f) json.dump(status, f)
os.replace(tmp_path, LED_STATUS_FILE)
logger.info(f"LED message: {message}") logger.info(f"LED message: {message}")
except Exception as e: except Exception as e:
logger.debug(f"Could not write LED status message: {e}") logger.debug(f"Could not write LED status message: {e}")
+3 -1
View File
@@ -15,7 +15,8 @@ npm install # jsdom, for the DOM suites only
node run_all.js node run_all.js
``` ```
The unit suites need nothing but node. The DOM suites additionally need a The unit suites need nothing but node; `test/test_js_unit_suites.py` runs every
`unit/*.js` under pytest, so CI covers them. The DOM suites additionally need a
running web interface, because they test against the **real** server-rendered running web interface, because they test against the **real** server-rendered
HTML and the **real** API rather than fixtures: HTML and the **real** API rather than fixtures:
@@ -41,6 +42,7 @@ nothing is listening, so it stays useful in a bare checkout.
| `unit/test_style_editor_layout_leaf_columns.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only key whose own value is a leaf (no x/y sub-object, e.g. a `show_logo` toggle) gets a self-keyed column instead of a blank, uneditable row | | `unit/test_style_editor_layout_leaf_columns.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only key whose own value is a leaf (no x/y sub-object, e.g. a `show_logo` toggle) gets a self-keyed column instead of a blank, uneditable row |
| `unit/test_style_editor_layout_leaf_collision.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only leaf key still gets its own column even when its name collides with an unrelated element's style sub-field or another layout axis's sub-field | | `unit/test_style_editor_layout_leaf_collision.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only leaf key still gets its own column even when its name collides with an unrelated element's style sub-field or another layout axis's sub-field |
| `unit/test_inline_handler_escaping.js` | no | The store, saved-repository and custom-registry inline `onclick` handlers and the live `window.updateImageList` from `plugins_manager.js`: a registry id, URL or uploaded file name carrying `'`, `"` or entities adds no attributes and reaches the handler intact, and the store's View button opens only http(s) links | | `unit/test_inline_handler_escaping.js` | no | The store, saved-repository and custom-registry inline `onclick` handlers and the live `window.updateImageList` from `plugins_manager.js`: a registry id, URL or uploaded file name carrying `'`, `"` or entities adds no attributes and reaches the handler intact, and the store's View button opens only http(s) links |
| `unit/test_plugin_action_delegation.js` | no | The document-level card-action delegation and `handlePluginAction` from `plugins_manager.js`, run with the handler inside an IIFE as in the real file: each action is handled once, a Starlark app uninstall goes to `DELETE /starlark/apps/<id>`, and an uninstall is confirmed once |
| `dom/test_installed_dom.js` | yes | The toolbar in a real DOM: pill/search/sort interaction, the HTMX partial re-swap, and a `getComputedStyle` check that `.filter-pill[data-active]` really matches the emitted markup | | `dom/test_installed_dom.js` | yes | The toolbar in a real DOM: pill/search/sort interaction, the HTMX partial re-swap, and a `getComputedStyle` check that `.filter-pill[data-active]` really matches the emitted markup |
| `dom/test_store_dom.js` | yes | Store pagination, per-page, category, tri-state Installed button, and persistence across a re-boot, against the live registry | | `dom/test_store_dom.js` | yes | Store pagination, per-page, category, tri-state Installed button, and persistence across a re-boot, against the live registry |
| `dom/test_no_double_fetch.js` | yes | Loads the **whole** `plugins_manager.js` and counts requests: typing in the store search must filter the cached list, not refetch `/api/v3/plugins/store/list` | | `dom/test_no_double_fetch.js` | yes | Loads the **whole** `plugins_manager.js` and counts requests: typing in the store search must filter the cached list, not refetch `/api/v3/plugins/store/list` |
+2 -1
View File
@@ -18,7 +18,8 @@ const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js',
'unit/test_html_escaping.js', 'unit/test_style_editor_element_keys.js', 'unit/test_html_escaping.js', 'unit/test_style_editor_element_keys.js',
'unit/test_style_editor_layout_leaf_columns.js', 'unit/test_style_editor_layout_leaf_columns.js',
'unit/test_style_editor_layout_leaf_collision.js', 'unit/test_style_editor_layout_leaf_collision.js',
'unit/test_update_all.js', 'unit/test_inline_handler_escaping.js']; 'unit/test_update_all.js', 'unit/test_inline_handler_escaping.js',
'unit/test_plugin_action_delegation.js'];
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js', const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
'dom/test_tools_sections.js']; 'dom/test_tools_sections.js'];
@@ -0,0 +1,127 @@
// Installed-plugin card actions go through handlePluginAction exactly once.
//
// The document-level delegation in plugins_manager.js tested
// `typeof handlePluginAction`, which lives inside the plugin-manager IIFE and
// so was never visible to it. Every click took a copied fallback instead,
// which stopped propagation (the grid's own listener never ran), asked to
// confirm an uninstall twice, and sent Starlark app uninstalls to the plugin
// endpoint instead of DELETE /starlark/apps/<id>.
//
// Runs the shipped global delegation and handlePluginAction, sliced out of
// plugins_manager.js, against a minimal fake DOM.
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const SRC = fs.readFileSync(
path.resolve(__dirname, '../../../web_interface/static/v3/plugins_manager.js'), 'utf8');
function slice(startMarker, endMarker) {
const a = SRC.indexOf(startMarker);
const b = SRC.indexOf(endMarker, a);
if (a < 0 || b < 0) throw new Error(`could not find ${startMarker} .. ${endMarker}`);
return SRC.slice(a, b);
}
const GLOBAL_DELEGATION = slice('(function setupGlobalEventDelegation() {', '// Note: configurePlugin');
const HANDLER = slice('function handlePluginAction(event) {', 'function findInstalledPlugin(pluginId)');
let pass = 0, fail = 0;
const ok = (label, cond, extra) => cond
? (pass++, console.log(' ok ' + label))
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' ' + JSON.stringify(extra) : '')));
function setup() {
const listeners = {};
const calls = { confirm: 0, fetch: [], uninstallPlugin: [], togglePlugin: [] };
const window = {
installedPlugins: [{ id: 'clock', enabled: false }],
uninstallPlugin: id => calls.uninstallPlugin.push(id),
togglePlugin: (id, on) => calls.togglePlugin.push([id, on]),
};
const ctx = {
window,
document: {
addEventListener: (type, fn, capture) => { (listeners[type] = listeners[type] || []).push(fn); },
},
confirm: () => { calls.confirm++; return true; },
fetch: (url, opts) => { calls.fetch.push([url, opts && opts.method]); return new Promise(() => {}); },
alert: () => {},
console,
setTimeout,
debugLog: () => {},
getInstalledFilter: () => null,
};
vm.createContext(ctx);
// The handler lives inside the plugin-manager IIFE in the real file, so it
// runs in one here too: the global delegation must not see it by name.
vm.runInContext(GLOBAL_DELEGATION + '\n(function() {\n' + HANDLER + '\n})();', ctx);
const click = (action, pluginId) => {
const el = {
getAttribute: name => ({ 'data-action': action, 'data-plugin-id': pluginId })[name],
type: 'button',
};
let stopped = false;
const event = {
type: 'click',
target: { closest: () => el },
preventDefault() {},
stopPropagation() { stopped = true; },
};
for (const fn of listeners.click || []) fn(event);
return stopped;
};
return { window, calls, click, listeners };
}
(async () => {
console.log('\n-- plugin card action delegation --');
{
const t = setup();
ok('handlePluginAction is exposed on window', typeof t.window.handlePluginAction === 'function');
ok('document-level click listener registered', (t.listeners.click || []).length === 1);
}
{
// A non-Element target (a text node, the document) has no closest().
const t = setup();
let threw = null;
try { for (const fn of t.listeners.click) fn({ type: 'click', target: {} }); } catch (e) { threw = e; }
ok('non-Element event target is ignored', threw === null, threw && threw.message);
}
{
const t = setup();
t.click('uninstall', 'starlark:analogclock');
ok('Starlark uninstall confirms once', t.calls.confirm === 1, t.calls.confirm);
ok('Starlark uninstall hits DELETE /starlark/apps/<id>',
t.calls.fetch.length === 1 && t.calls.fetch[0][0] === '/api/v3/starlark/apps/analogclock'
&& t.calls.fetch[0][1] === 'DELETE', t.calls.fetch);
ok('Starlark uninstall does not go to the plugin uninstaller', t.calls.uninstallPlugin.length === 0);
}
{
const t = setup();
t.click('uninstall', 'clock');
await new Promise(r => setTimeout(r, 20));
ok('plugin uninstall: handler itself does not confirm (uninstallPlugin does)', t.calls.confirm === 0,
t.calls.confirm);
ok('plugin uninstall calls uninstallPlugin once', t.calls.uninstallPlugin.length === 1
&& t.calls.uninstallPlugin[0] === 'clock', t.calls.uninstallPlugin);
}
{
const t = setup();
const stopped = t.click('toggle', 'clock');
await new Promise(r => setTimeout(r, 20));
ok('toggle flips the stored state', JSON.stringify(t.calls.togglePlugin) === '[["clock",true]]',
t.calls.togglePlugin);
ok('handled once (propagation stopped)', stopped === true);
}
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
})();
+13
View File
@@ -210,6 +210,19 @@ class TestVegasCycleDurations:
assert resp.status_code == 200, resp.get_json() assert resp.status_code == 200, resp.get_json()
assert saved['config']['display']['display_durations']['clock_duration'] == 45 assert saved['config']['display']['display_durations']['clock_duration'] == 45
def test_per_mode_duration_saves_and_blank_clears_it(self, api_v3_client, saved, api_v3_module):
# The Rotation page leaves a mode blank to mean "the plugin's own
# duration"; a saved value overrides the plugin, so blank must remove
# it rather than 400 or pin a number.
stored = copy.deepcopy(STORED)
stored['display']['display_durations'] = {'weather_current': 40, 'clock': 20}
api_v3_module.api_v3.config_manager.load_config.side_effect = lambda *a, **k: copy.deepcopy(stored)
resp = _post_json(api_v3_client, {'__form_section': 'durations',
'duration__clock': '45',
'duration__weather_current': ''})
assert resp.status_code == 200, resp.get_json()
assert saved['config']['display']['display_durations'] == {'clock': 45}
class TestRawSaveStartsAutoUpdateSetup: class TestRawSaveStartsAutoUpdateSetup:
@pytest.fixture @pytest.fixture
+29
View File
@@ -0,0 +1,29 @@
"""POST /plugins/toggle reports what actually went wrong.
Every failure used to be mapped to PLUGIN_OPERATION_CONFLICT, so the user was
told "A plugin operation is already in progress" when, say, the config could
not be read.
"""
import json
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401
def test_failure_is_not_reported_as_a_conflict(api_v3_client, api_v3_module, monkeypatch):
monkeypatch.setattr(api_v3_module, '_discovered_plugin_manifests',
lambda *a, **k: {'clock': {}})
api_v3_module.api_v3.config_manager.load_config.side_effect = OSError('disk gone')
resp = api_v3_client.post('/api/v3/plugins/toggle',
data=json.dumps({'plugin_id': 'clock', 'enabled': True}),
content_type='application/json')
assert resp.status_code == 500
body = resp.get_json()
assert body['error_code'] != 'PLUGIN_OPERATION_CONFLICT'
assert 'already in progress' not in body['message']
assert body['message'] == 'Failed to enable plugin clock'
history = api_v3_module.api_v3.operation_history.record_operation
history.assert_called_once()
assert history.call_args.kwargs['plugin_id'] == 'clock'
@@ -110,12 +110,14 @@ class TestCachedConfigValues:
assert controller._normal_brightness == expected assert controller._normal_brightness == expected
def test_scroll_speed_cached(self, controller): def test_scroll_speed_cached(self, controller):
"""_scroll_speed must equal what the config says.""" """_scroll_speed must equal what the config says, defaulting to Vegas's
own default so a follower dead-reckons at the leader's speed."""
from src.vegas_mode.config import VegasModeConfig
expected = ( expected = (
controller.config controller.config
.get("display", {}) .get("display", {})
.get("vegas_scroll", {}) .get("vegas_scroll", {})
.get("scroll_speed", 75) .get("scroll_speed", VegasModeConfig.from_config({}).scroll_speed)
) )
assert controller._scroll_speed == expected assert controller._scroll_speed == expected
+121
View File
@@ -0,0 +1,121 @@
"""DisplayController settings that the web UI saves but the display ignored.
- Rotation & Durations: display.display_durations was never read, because
every plugin inherits get_display_duration() and the plugin was asked first.
- WiFi status overlay: the controller looked for wifi_status.json one level
above the repo, so WiFiManager's messages never reached the panel.
- Vegas: settings saved in the web UI never reached the running coordinator,
and the follower's scroll-speed default (75) disagreed with Vegas's (50).
"""
import os
import threading
from unittest.mock import MagicMock
os.environ.setdefault("EMULATOR", "true")
from src.display_controller import DisplayController
def _controller(config=None, plugin_modes=None):
dc = object.__new__(DisplayController)
dc.config = config or {}
dc.plugin_modes = plugin_modes or {}
return dc
def _plugin(duration):
plugin = MagicMock()
plugin.get_display_duration.return_value = duration
return plugin
class TestDisplayDuration:
def test_saved_duration_overrides_the_plugin(self):
dc = _controller({'display': {'display_durations': {'clock': 45}}},
{'clock': _plugin(15.0)})
assert dc._get_display_duration('clock') == 45.0
def test_unsaved_mode_uses_the_plugin_duration(self):
dc = _controller({'display': {'display_durations': {'other': 45}}},
{'clock': _plugin(12.0)})
assert dc._get_display_duration('clock') == 12.0
def test_invalid_saved_values_fall_back_to_the_plugin(self):
for bad in (0, -5, True, '30', None):
dc = _controller({'display': {'display_durations': {'clock': bad}}},
{'clock': _plugin(12.0)})
assert dc._get_display_duration('clock') == 12.0, bad
def test_unknown_mode_without_a_plugin_gets_the_default(self):
assert _controller()._get_display_duration('nothing') == 30
def test_hot_reloaded_config_is_used(self):
dc = _controller({}, {'clock': _plugin(15.0)})
dc._refresh_config_cache({'display': {'display_durations': {'clock': 60}}})
assert dc._get_display_duration('clock') == 60.0
class TestWifiStatusPath:
def test_reader_and_writer_agree(self, test_display_controller):
from src.wifi_manager import get_wifi_status_path
repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
path = get_wifi_status_path()
assert path.name == 'wifi_status.json'
# Inside this checkout's config/, not the directory above the repo.
assert os.path.normcase(str(path.parent.parent)) == os.path.normcase(repo)
assert test_display_controller.wifi_status_file == path
def test_status_message_is_written_whole(self, tmp_path, monkeypatch):
import json
import src.wifi_manager as wm
target = tmp_path / 'wifi_status.json'
monkeypatch.setattr(wm, 'LED_STATUS_FILE', target)
# The display polls this file and deletes one it can't parse, so it
# must appear by rename, never be seen half-written.
renames = []
real_replace = os.replace
monkeypatch.setattr(wm.os, 'replace',
lambda src, dst: (renames.append(dst), real_replace(src, dst)))
manager = object.__new__(wm.WiFiManager)
manager._show_led_message('Connecting to home...', duration=10)
assert json.loads(target.read_text())['message'] == 'Connecting to home...'
assert renames == [target]
assert not (tmp_path / 'wifi_status.json.tmp').exists()
class TestVegasSettings:
def _controller(self):
dc = _controller({'display': {'vegas_scroll': {'scroll_speed': 40}}})
dc._reconcile_flag_lock = threading.Lock()
dc._pending_plugin_reconcile = False
dc._enabled_set_changed = lambda old, new: False
dc._enabled_plugin_not_running = lambda new: False
dc.vegas_coordinator = MagicMock()
return dc
def test_changed_vegas_settings_reach_the_coordinator(self):
dc = self._controller()
old = {'display': {'vegas_scroll': {'scroll_speed': 40}}}
new = {'display': {'vegas_scroll': {'scroll_speed': 80}}}
dc._controller_config_change(old, new)
dc.vegas_coordinator.update_config.assert_called_once_with(new)
assert dc._scroll_speed == 80.0
def test_unrelated_saves_leave_vegas_alone(self):
# Applying a Vegas config rebuilds the strip, so only do it on change.
dc = self._controller()
old = {'display': {'vegas_scroll': {'scroll_speed': 40}, 'hardware': {'brightness': 50}}}
new = {'display': {'vegas_scroll': {'scroll_speed': 40}, 'hardware': {'brightness': 90}}}
dc._controller_config_change(old, new)
dc.vegas_coordinator.update_config.assert_not_called()
def test_scroll_speed_default_matches_vegas_config(self):
from src.vegas_mode.config import VegasModeConfig
assert DisplayController._vegas_scroll_speed({}) == VegasModeConfig.from_config({}).scroll_speed
def test_stopped_coordinator_applies_queued_config(self):
dc = self._controller()
dc.on_demand_active = False
dc._is_vegas_mode_active()
dc.vegas_coordinator.apply_pending_config_if_idle.assert_called_once()
+10
View File
@@ -154,6 +154,16 @@ class TestPatternDetection:
assert "ValueError" not in aggregator._patterns assert "ValueError" not in aggregator._patterns
def test_affected_plugins_stays_bounded(self):
"""Each repeat used to append every plugin in the window again, so a
plugin failing in a loop grew this list without limit (3,000 errors
from three plugins reached 2.5 million entries)."""
aggregator = ErrorAggregator(pattern_threshold=2, pattern_window_minutes=60)
for i in range(300):
aggregator.record_error(error=ValueError("loop"), plugin_id=f"p{i % 3}")
assert aggregator._patterns["ValueError"].affected_plugins == ["p0", "p1", "p2"]
assert aggregator._patterns["ValueError"].to_dict()["affected_plugins"] == ["p0", "p1", "p2"]
def test_pattern_severity_increases_with_count(self): def test_pattern_severity_increases_with_count(self):
"""Pattern severity should increase with more occurrences.""" """Pattern severity should increase with more occurrences."""
aggregator = ErrorAggregator( aggregator = ErrorAggregator(
+8
View File
@@ -45,6 +45,14 @@ class TestGetFont:
font = fm.get_font("five_by_seven", 7) font = fm.get_font("five_by_seven", 7)
assert isinstance(font, freetype.Face) assert isinstance(font, freetype.Face)
def test_bdf_at_a_size_it_lacks_uses_its_native_strike(self, fm):
# FreeType rejects any size but the strike's own. This used to hand
# back PIL's default font, a different typeface, for 5x7 at 8 or 10.
for size in (8, 10):
font = fm.get_font("five_by_seven", size)
assert isinstance(font, freetype.Face), size
assert font.size.y_ppem == 7
def test_repeat_call_returns_cached_identity(self, fm): def test_repeat_call_returns_cached_identity(self, fm):
first = fm.get_font("press_start", 8) first = fm.get_font("press_start", 8)
hits_before = fm.performance_stats["cache_hits"] hits_before = fm.performance_stats["cache_hits"]
+27
View File
@@ -0,0 +1,27 @@
"""Run every web-interface JS unit suite (test/js/unit/*.js) under pytest.
They need nothing but node, but CI ran only one of them, from
test/web_interface/test_update_all_plugins.py. The DOM suites need jsdom and
a running server, so they stay with test/js/run_all.js.
"""
import shutil
import subprocess
from pathlib import Path
import pytest
UNIT_DIR = Path(__file__).resolve().parent / 'js' / 'unit'
SUITES = sorted(UNIT_DIR.glob('test_*.js'))
def test_suites_found():
assert SUITES, f"no JS unit suites under {UNIT_DIR}"
@pytest.mark.skipif(shutil.which('node') is None, reason='node is not installed')
@pytest.mark.parametrize('suite', SUITES, ids=[s.name for s in SUITES])
def test_js_unit_suite(suite):
result = subprocess.run([shutil.which('node'), str(suite)], capture_output=True,
text=True, timeout=120, cwd=str(UNIT_DIR.parent))
assert result.returncode == 0, result.stdout + result.stderr
+92
View File
@@ -0,0 +1,92 @@
"""VegasModeCoordinator: settings updates and the per-frame live check.
- Settings saved in the web UI are queued with update_config() and applied
between frames. run_frame() returns early once Vegas has stopped, so a
disable followed by a re-enable used to be queued forever.
- The live-priority scan asks every plugin mode whether it is live. It ran on
every frame (125fps); it now runs at most every _LIVE_PRIORITY_CHECK_INTERVAL.
"""
import threading
from unittest.mock import MagicMock
from src.vegas_mode import coordinator as coordinator_module
from src.vegas_mode.config import VegasModeConfig
from src.vegas_mode.coordinator import VegasModeCoordinator
def _coordinator(active=True):
# Built without __init__ so no display, stream or render stack is needed.
c = VegasModeCoordinator.__new__(VegasModeCoordinator)
c.vegas_config = VegasModeConfig.from_config({'display': {'vegas_scroll': {'enabled': True}}})
c.render_pipeline = MagicMock()
c.render_pipeline.has_deferred.return_value = False
c.render_pipeline.needs_extension.return_value = False
c.render_pipeline.is_cycle_complete.return_value = False
c.stream_manager = MagicMock()
c.plugin_adapter = MagicMock()
c.stats = {'cycles_completed': 0, 'config_updates': 0}
c._state_lock = threading.Lock()
c._is_active = active
c._is_paused = False
c._should_stop = False
c._pending_config_update = False
c._pending_config = None
c._config_version = 0
c._live_priority_check = None
c._live_priority_active = False
c._interrupt_check = None
c.sync_manager = None
return c
class TestConfigWhileStopped:
def test_queued_config_applies_while_stopped(self):
c = _coordinator(active=False)
c.update_config({'display': {'vegas_scroll': {'enabled': True, 'scroll_speed': 90}}})
c.start = MagicMock()
c.apply_pending_config_if_idle()
assert c.vegas_config.scroll_speed == 90
assert c._pending_config_update is False
def test_reenabling_after_a_disable_takes_effect(self):
c = _coordinator(active=False)
c.vegas_config = VegasModeConfig.from_config({}) # disabled
c.start = MagicMock()
c.update_config({'display': {'vegas_scroll': {'enabled': True}}})
c.apply_pending_config_if_idle()
assert c.is_enabled
c.start.assert_called_once()
def test_running_coordinator_waits_for_the_next_frame(self):
c = _coordinator(active=True)
c.update_config({'display': {'vegas_scroll': {'enabled': True, 'scroll_speed': 90}}})
c.apply_pending_config_if_idle()
assert c.vegas_config.scroll_speed != 90
assert c._pending_config_update is True
class TestLivePriorityThrottle:
def test_scan_runs_at_most_once_per_interval(self, monkeypatch):
now = [1000.0]
monkeypatch.setattr(coordinator_module.time, 'monotonic', lambda: now[0])
c = _coordinator()
c._live_priority_check = MagicMock(return_value=None)
for _ in range(10):
c.run_frame()
assert c._live_priority_check.call_count == 1
now[0] += coordinator_module._LIVE_PRIORITY_CHECK_INTERVAL
c.run_frame()
assert c._live_priority_check.call_count == 2
def test_live_content_still_pauses_vegas(self, monkeypatch):
monkeypatch.setattr(coordinator_module.time, 'monotonic', lambda: 1000.0)
c = _coordinator()
c._live_priority_check = MagicMock(return_value='nfl_live')
c.pause = MagicMock()
assert c.run_frame() is False
c.pause.assert_called_once()
c.render_pipeline.render_frame.assert_not_called()
+22
View File
@@ -186,6 +186,28 @@ def test_durations_page_groups_by_plugin(client):
assert "Other saved entries" in body assert "Other saved entries" in body
def test_durations_page_leaves_unsaved_modes_blank(client):
"""A saved duration overrides the plugin's own, so the page must not
pre-fill one for every mode: the first save would pin them all. Unsaved
modes are blank, with the plugin's duration as the placeholder."""
import copy
from web_interface.blueprints import pages_v3 as pv
config = copy.deepcopy(SMOKE_CONFIG)
config["clock"]["display_duration"] = 20
config["display"]["display_durations"] = {"weather_current": 40}
pv.pages_v3.config_manager.load_config.return_value = config
body = client.get("/partials/durations").get_data(as_text=True)
def field(mode):
start = body.index(f'id="duration__{mode}"')
return body[start:body.index(">", start)]
assert 'value=""' in field("clock") and 'placeholder="20"' in field("clock")
assert 'value="40"' in field("weather_current")
assert 'value=""' in field("weather_daily")
assert 'placeholder="15"' in field("weather_daily")
def test_display_advanced_section_contains_tuning_fields(client): def test_display_advanced_section_contains_tuning_fields(client):
body = client.get("/partials/display").get_data(as_text=True) body = client.get("/partials/display").get_data(as_text=True)
adv = body.find('id="display-section-advanced-hardware"') adv = body.find('id="display-section-advanced-hardware"')
@@ -985,6 +985,10 @@ def save_main_config():
mode_key = field[len('duration__'):] mode_key = field[len('duration__'):]
if not mode_key: if not mode_key:
continue continue
if raw_value is None or str(raw_value).strip() == '':
# Blank means "use the plugin's own duration".
current_config['display']['display_durations'].pop(mode_key, None)
continue
try: try:
int_value = int(raw_value) int_value = int(raw_value)
except (ValueError, TypeError, OverflowError): except (ValueError, TypeError, OverflowError):
+11 -9
View File
@@ -409,6 +409,8 @@ def manage_plugin_limits(plugin_id):
@api_v3.route('/plugins/toggle', methods=['POST']) @api_v3.route('/plugins/toggle', methods=['POST'])
def toggle_plugin(): def toggle_plugin():
"""Toggle plugin enabled/disabled""" """Toggle plugin enabled/disabled"""
plugin_id = None
enabled = None
try: try:
if not api_v3.plugin_manager or not api_v3.config_manager: if not api_v3.plugin_manager or not api_v3.config_manager:
return jsonify({'status': 'error', 'message': 'Plugin or config manager not initialized'}), 500 return jsonify({'status': 'error', 'message': 'Plugin or config manager not initialized'}), 500
@@ -506,21 +508,21 @@ def toggle_plugin():
message=f"Plugin {plugin_id} {'enabled' if enabled else 'disabled'} successfully" message=f"Plugin {plugin_id} {'enabled' if enabled else 'disabled'} successfully"
) )
except Exception as e: except Exception as e:
from src.web_interface.errors import WebInterfaceError # Not PLUGIN_OPERATION_CONFLICT: that told the user "an operation is
error = WebInterfaceError.from_exception(e, ErrorCode.PLUGIN_OPERATION_CONFLICT) # already in progress" whatever actually went wrong.
logger.error('Error toggling plugin %s', plugin_id, exc_info=True)
if api_v3.operation_history: if api_v3.operation_history:
toggle_type = "enable" if ('data' in locals() and data.get('enabled')) else "disable"
api_v3.operation_history.record_operation( api_v3.operation_history.record_operation(
toggle_type, "enable" if enabled else "disable",
plugin_id=data.get('plugin_id') if 'data' in locals() else None, plugin_id=plugin_id,
status="failed", status="failed",
error=str(e) error=str(e)
) )
action = 'enable' if enabled else 'disable' if enabled is not None else 'toggle'
return error_response( return error_response(
error.error_code, ErrorCode.UNKNOWN_ERROR,
error.message, f"Failed to {action} plugin {plugin_id or ''}".rstrip(),
details=error.details, details=describe_exception(e),
context=error.context,
status_code=500 status_code=500
) )
@api_v3.route('/plugins/operation/<operation_id>', methods=['GET']) @api_v3.route('/plugins/operation/<operation_id>', methods=['GET'])
+33 -6
View File
@@ -552,14 +552,39 @@ def _load_display_partial():
logger.error("Error loading partial", exc_info=True) logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500 return "Error loading partial", 500
def _plugin_default_duration(plugin_id, plugin_config):
"""Seconds a plugin shows each screen when the Rotation page sets none.
Mirrors BasePlugin.get_display_duration's config fallback: the plugin's
display_duration, else its schema default, else 15.
"""
def _valid(v):
return isinstance(v, (int, float)) and not isinstance(v, bool) and v > 0
value = plugin_config.get('display_duration')
if not _valid(value):
schema = None
schema_mgr = getattr(pages_v3, 'schema_manager', None)
if schema_mgr is not None:
try:
schema = schema_mgr.load_schema(plugin_id)
except Exception:
logger.debug("durations: no schema for %s", plugin_id, exc_info=True)
value = plugin_config_defaults(schema if isinstance(schema, dict) else None).get(
'display_duration')
if not _valid(value):
value = 15
return int(value) if float(value).is_integer() else value
def _load_durations_partial(): def _load_durations_partial():
"""Load rotation & durations partial. """Load rotation & durations partial.
Builds one duration entry per display mode of every enabled plugin Builds one duration entry per display mode of every enabled plugin. A mode
(falling back to the display controller's 30s default), overlaid with any with a value saved in display.display_durations shows it; the rest are
values saved in display.display_durations. Historically the template only blank, with the plugin's own duration as the placeholder, because a saved
looped over saved keys, and nothing ever populated them, so the page value overrides the plugin (see DisplayController._get_display_duration).
rendered empty. Pre-filling every mode would pin them all on the first save.
""" """
try: try:
if pages_v3.config_manager: if pages_v3.config_manager:
@@ -578,10 +603,12 @@ def _load_durations_partial():
continue continue
modes = pages_v3.plugin_manager.get_plugin_display_modes(pid) or [pid] modes = pages_v3.plugin_manager.get_plugin_display_modes(pid) or [pid]
covered_keys.update(modes) covered_keys.update(modes)
default = _plugin_default_duration(pid, main_config.get(pid, {}) or {})
duration_groups.append({ duration_groups.append({
'plugin_id': pid, 'plugin_id': pid,
'plugin_name': info.get('name') or pid, 'plugin_name': info.get('name') or pid,
'modes': [{'key': m, 'value': saved.get(m, 30)} for m in modes], 'modes': [{'key': m, 'value': saved.get(m, ''), 'default': default}
for m in modes],
}) })
# Saved keys not owned by any enabled plugin (disabled or # Saved keys not owned by any enabled plugin (disabled or
# uninstalled plugins) stay visible rather than vanishing. # uninstalled plugins) stay visible rather than vanishing.
+16 -114
View File
@@ -401,124 +401,25 @@ window.__pendingInstalledPlugins = window.__pendingInstalledPlugins || null;
window.__pendingStorePlugins = window.__pendingStorePlugins || null; window.__pendingStorePlugins = window.__pendingStorePlugins || null;
window.__pluginDomReady = window.__pluginDomReady || false; window.__pluginDomReady = window.__pluginDomReady || false;
// Set up global event delegation for plugin actions (works even before plugins are loaded) // Document-level delegation for plugin card actions, so a card works even if
// it was rendered before the grid's own listener was attached. It hands the
// event to handlePluginAction, which the plugin-manager IIFE below exposes on
// window. (It used to test `typeof handlePluginAction`, which is IIFE-scoped
// and so never visible here: every click took a copied fallback instead, which
// asked to confirm an uninstall twice and sent Starlark app uninstalls to the
// plugin endpoint.)
(function setupGlobalEventDelegation() { (function setupGlobalEventDelegation() {
// Use document-level delegation so it works for dynamically added content
const handleGlobalPluginAction = function(event) { const handleGlobalPluginAction = function(event) {
// Only handle if it's a plugin action const target = event.target;
const button = event.target.closest('button[data-action][data-plugin-id]') || if (!target || typeof target.closest !== 'function') return;
event.target.closest('input[data-action][data-plugin-id]'); const el = target.closest('button[data-action][data-plugin-id]') ||
if (!button) return; target.closest('input[data-action][data-plugin-id]');
if (!el || typeof window.handlePluginAction !== 'function') return;
const action = button.getAttribute('data-action'); window.handlePluginAction(event);
const pluginId = button.getAttribute('data-plugin-id');
// For toggle and configure, ensure functions are available
if (action === 'toggle' || action === 'configure') {
const funcName = action === 'toggle' ? 'togglePlugin' : 'configurePlugin';
if (!window[funcName] || typeof window[funcName] !== 'function') {
// Prevent default and stop propagation immediately to avoid double handling
event.preventDefault();
event.stopPropagation();
console.warn(`[GLOBAL DELEGATION] ${funcName} not available yet, waiting...`);
// Capture state synchronously from plugin data (source of truth)
let targetChecked = false;
if (action === 'toggle') {
const plugin = (window.installedPlugins || []).find(p => p.id === pluginId);
let currentEnabled;
if (plugin) {
currentEnabled = Boolean(plugin.enabled);
} else if (button.type === 'checkbox') {
currentEnabled = button.checked;
} else {
currentEnabled = false;
}
targetChecked = !currentEnabled; // Toggle to opposite state
}
// Wait for function to be available
let attempts = 0;
const maxAttempts = 20; // 1 second total
const checkInterval = setInterval(() => {
attempts++;
if (window[funcName] && typeof window[funcName] === 'function') {
clearInterval(checkInterval);
// Call the function directly
if (action === 'toggle') {
window.togglePlugin(pluginId, targetChecked);
} else {
window.configurePlugin(pluginId);
}
} else if (attempts >= maxAttempts) {
clearInterval(checkInterval);
console.error(`[GLOBAL DELEGATION] ${funcName} not available after ${maxAttempts} attempts`);
if (typeof showNotification === 'function') {
showNotification(`${funcName} not loaded. Please refresh the page.`, 'error');
}
}
}, 50);
return; // Don't proceed with normal handling
}
}
// Prevent default and stop propagation to avoid double handling
event.preventDefault();
event.stopPropagation();
// If handlePluginAction exists, use it; otherwise handle directly
if (typeof handlePluginAction === 'function') {
handlePluginAction(event);
} else {
// Fallback: handle directly if functions are available
if (action === 'toggle' && window.togglePlugin) {
// Get the current enabled state from plugin data (source of truth)
const plugin = (window.installedPlugins || []).find(p => p.id === pluginId);
let currentEnabled;
if (plugin) {
currentEnabled = Boolean(plugin.enabled);
} else if (button.type === 'checkbox') {
currentEnabled = button.checked;
} else {
currentEnabled = false;
}
// Toggle the state - we want the opposite of current state
const isChecked = !currentEnabled;
// Prevent default behavior to avoid double-toggling and change event
// (Already done at start of function, but safe to repeat)
event.preventDefault();
event.stopPropagation();
debugLog('[DEBUG toggle fallback] Plugin:', pluginId, 'Current enabled (from data):', currentEnabled, 'New state:', isChecked);
window.togglePlugin(pluginId, isChecked);
} else if (action === 'configure' && window.configurePlugin) {
event.preventDefault();
event.stopPropagation();
window.configurePlugin(pluginId);
} else if (action === 'update' && window.updatePlugin) {
event.preventDefault();
event.stopPropagation();
debugLog('[DEBUG update fallback] Updating plugin:', pluginId);
window.updatePlugin(pluginId);
} else if (action === 'uninstall' && window.uninstallPlugin) {
event.preventDefault();
event.stopPropagation();
debugLog('[DEBUG uninstall fallback] Uninstalling plugin:', pluginId);
if (confirm(`Are you sure you want to uninstall ${pluginId}?`)) {
window.uninstallPlugin(pluginId);
}
}
}
}; };
// Set up delegation on document (capture phase for better reliability) // Capture phase, so this runs before the grid's own listener;
// handlePluginAction stops propagation, so an action is handled once.
document.addEventListener('click', handleGlobalPluginAction, true); document.addEventListener('click', handleGlobalPluginAction, true);
document.addEventListener('change', handleGlobalPluginAction, true); document.addEventListener('change', handleGlobalPluginAction, true);
debugLog('[PLUGINS SCRIPT] Global event delegation set up'); debugLog('[PLUGINS SCRIPT] Global event delegation set up');
@@ -1846,6 +1747,7 @@ function handlePluginAction(event) {
break; break;
} }
} }
window.handlePluginAction = handlePluginAction;
function findInstalledPlugin(pluginId) { function findInstalledPlugin(pluginId) {
const plugins = window.installedPlugins || installedPlugins || []; const plugins = window.installedPlugins || installedPlugins || [];
@@ -36,7 +36,7 @@
<div class="bg-gray-50 rounded-lg p-4 space-y-5"> <div class="bg-gray-50 rounded-lg p-4 space-y-5">
<div> <div>
<h3 class="text-md font-medium text-gray-900 mb-1">Screen Durations</h3> <h3 class="text-md font-medium text-gray-900 mb-1">Screen Durations</h3>
<p class="text-sm text-gray-600">How long each screen stays on before rotating to the next one, in seconds (5&ndash;600, default 30).</p> <p class="text-sm text-gray-600">How long each screen stays on before rotating to the next one, in seconds (5&ndash;600). Leave a screen blank to use its plugin's own duration, shown greyed out.</p>
</div> </div>
{% for group in duration_groups %} {% for group in duration_groups %}
<div> <div>
@@ -45,12 +45,13 @@
{% for mode in group.modes %} {% for mode in group.modes %}
<div class="form-group" id="setting-durations-{{ mode.key }}" data-setting-key="display.display_durations.{{ mode.key }}"> <div class="form-group" id="setting-durations-{{ mode.key }}" data-setting-key="display.display_durations.{{ mode.key }}">
<label for="duration__{{ mode.key }}" class="block text-sm font-medium text-gray-700"> <label for="duration__{{ mode.key }}" class="block text-sm font-medium text-gray-700">
{{ mode.key | replace('_', ' ') | title }}{{ ui.help_tip('How long the ' ~ (mode.key | replace('_', ' ')) ~ ' screen stays on before rotating to the next one, in seconds.\nRange: 5–600. Currently ' ~ mode.value ~ 's.', mode.key | replace('_', ' ') | title) }} {{ mode.key | replace('_', ' ') | title }}{{ ui.help_tip('How long the ' ~ (mode.key | replace('_', ' ')) ~ ' screen stays on before rotating to the next one, in seconds.\nRange: 5–600.' ~ (' Blank uses the plugin\'s own ' ~ mode.default ~ 's.' if mode.default else ''), mode.key | replace('_', ' ') | title) }}
</label> </label>
<input type="number" <input type="number"
id="duration__{{ mode.key }}" id="duration__{{ mode.key }}"
name="duration__{{ mode.key }}" name="duration__{{ mode.key }}"
value="{{ mode.value }}" value="{{ mode.value }}"
{% if mode.default %}placeholder="{{ mode.default }}"{% endif %}
min="5" min="5"
max="600" max="600"
class="form-control"> class="form-control">