mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
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:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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}")
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||||
|
})();
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()
|
||||||
|
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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'])
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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–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–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">
|
||||||
|
|||||||
Reference in New Issue
Block a user