Compare commits

...
Author SHA1 Message Date
ChuckBuildsandClaude Sonnet 5 cd7e16e58e fix(security): validate plugin_id before path construction in /api/install
CodeQL flagged 16 high-severity "path depends on user-provided value"
alerts. Investigated each:

- install_locally() (/api/install) built a filesystem path from
  metadata.id without validating it at that point -- it was only
  implicitly safe because _generate_plugin_files() validates the same
  field (re-extracted independently) earlier in the same request. That's
  a real gap: reorder or change that earlier call and it's an exploitable
  path traversal / arbitrary file write. Fixed by validating plugin_id
  directly against _PLUGIN_ID_RE at the point the path is built, matching
  the pattern already used correctly in validate_id() and load_plugin().
- The other 10 flagged locations (serve_font's allowlist check,
  validate_id, load_plugin and its downstream reads) were already
  guarded by an explicit check earlier in the same function -- false
  positives from CodeQL not modeling those as sanitizers.

Also fixed 2 of the 5 "stack trace exposed" warnings that were genuine:
install_locally() and load_plugin() returned raw OSError/Exception text
to the client in a 500 response; now logged server-side with a generic
client-facing message. The other 3 (generate_zip/install_locally/
preview_code returning str(ValueError) from _generate_plugin_files) are
deliberate, human-authored validation messages, not exception internals
-- left as-is.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
2026-07-14 17:23:25 -04:00
ChuckBuildsandClaude Sonnet 5 47e3021fc3 fix: address Codacy findings in the Composer blueprint
- Dropped a pointless f-string prefix (no placeholders) on the default
  plugin description.
- Replaced two bare except:pass/continue blocks (manifest.json listing,
  config_schema.json parsing) with a logged warning before falling
  through to the same skip-this-entry behavior -- same control flow,
  now visible in logs instead of silent.

Skipped as false positives (verified against actual usage, not fixed):
- Jinja2 Environment(autoescape=False) -- this env renders manager.py.j2,
  a Python source-code generator, never HTML; autoescaping would corrupt
  generated code. Flagged by a generic XSS rule that assumes all Jinja2
  environments render HTML.
- "Flask route directly returning a formatted string" on _as_rgb_filter
  -- that's a Jinja *filter* function, not a Flask route.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
2026-07-14 16:31:43 -04:00
ChuckBuildsandClaude Sonnet 5 e319540c6e feat(web): add Plugin Composer -- visual drag-and-drop plugin builder
Web UI (/composer/) for building a working LEDMatrix plugin without
writing Python: drop elements (text, time, date, countdown, scrolling
text, bar/waveform, groups, custom config variables) onto a canvas
matching the real panel's pixel grid, configure them with live preview,
then generate a real plugin (manager.py + manifest.json + config_schema.json)
from manager.py.j2 -- downloadable as a ZIP or installed directly.

NOTE: composer_bp is not yet registered in web_interface/app.py, so this
blueprint is currently inert. Split out of the original chore/dead-code-
removal commit, which had accidentally bundled this in alongside unrelated
dead-code deletions; app.py registration was not part of that commit
either and still needs to be added before this is reachable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
2026-07-14 16:27:50 -04:00
14a59c863c perf(wifi): one status fetch per monitor tick instead of three (#411)
* perf(wifi): one status fetch per monitor tick instead of three

The wifi monitor daemon fetched WiFi status + ethernet state before its
AP-mode check, the check internally fetched the same state again (with
retry), and the daemon fetched a third time afterwards — each fetch is
several nmcli subprocess forks, every 30s, forever, even on a perfectly
healthy link.

check_and_manage_ap_mode's decision logic is extracted to
_manage_ap_mode(status, ethernet, ap_active); the new
check_and_manage_ap_mode_with_state() runs the single (retrying) fetch
battery and returns (changed, status, ethernet, ap_active_after) — the
post-state is derivable because state only ever flips via one enable or
one disable. The original bool-returning method delegates, so existing
callers are untouched. The daemon's dead pre-fetch is removed and its
post-check reads use the returned state; the AP-enable retry semantics
(_get_wifi_status_with_retry) are preserved exactly. ~10-15 forks/30s
drops to ~4-6.

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

* fix(wifi): add missing type hints on AP-mode state helpers, fix unused-var lint in tests

- check_and_manage_ap_mode_with_state now declares its
  Tuple[bool, WiFiStatus, bool, bool] return type instead of being untyped.
- _manage_ap_mode's status/ethernet_connected/ap_active parameters are now
  typed (WiFiStatus, bool, bool), matching its existing -> bool return hint.
- test_wifi_check_state.py: rename unused unpacked variables (status/
  ethernet/ap_after) to underscore-prefixed names at the three call sites
  that don't use them, leaving the used ones untouched.

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

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 11:22:37 -04:00
bff13129c4 perf(config): mtime-signature fast path for load_config (#410)
load_config re-read and re-parsed config.json, the secrets file, AND the
template (running the recursive migration diff) on every call — with
~30 call sites in web request handlers, some hit 2-3x per request.

Fast path: stat all three files (mtime_ns + size); when unchanged since
the last successful load, return the already-parsed self.config (same
aliasing semantics as before). The signature is taken AFTER load +
migration so a migration write-back doesn't retrigger, and both save
paths refresh it. Cross-process freshness is preserved by construction:
a save from the other process bumps the file mtime, so the next load
here re-reads — verified by a dedicated test. Same-second edits are
caught by mtime_ns plus a size check.


Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 08:49:43 -04:00
6499794c12 fix(testing): add MockCacheManager.get_cached_data_with_strategy/save_cache (#409)
* fix(testing): add MockCacheManager.get_cached_data_with_strategy/save_cache

ledmatrix-leaderboard's data_fetcher.py calls these two real-CacheManager
methods (src/cache_manager.py:313,817), but MockCacheManager had neither --
update() always hit an AttributeError, caught by a broad except and logged,
so the harness rendered an empty-but-green leaderboard on every test run
without ever exercising real standings data.

Both mocks delegate to the existing get()/set() -- a mock doesn't need the
real strategy's per-data-type max_age/market-hours timing, plugins under
test just need the methods to exist and round-trip whatever was cached.

* fix(testing): clear get_cached_data_with_strategy_calls in MockCacheManager.reset()

reset() cleared get_calls/set_calls/delete_calls but not the newer
get_cached_data_with_strategy_calls tracker, so a reused mock (e.g. across
test cases sharing a fixture) retained stale strategy-call records after
reset().

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

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 08:33:08 -04:00
ChuckandGitHub 3d347a368a fix(testing): stop plugin enabled:false schema defaults from silently disabling harness tests (#408)
check_plugin.py, render_plugin.py, and the pytest plugin matrix each built
config as {"enabled": True} then merged in config_schema.json's defaults on
top, letting a plugin's own enabled:false default (a reasonable choice for
a seasonal/opt-in plugin -- 15 of 23 real plugins ship one) silently win.
Every harness/CI render of those plugins was testing "disabled, do
nothing" rather than real behavior.

Extract build_full_config() into testing/loading.py (already the shared
home for plugin-discovery/config-default logic) and use it from all three
call sites: schema defaults, then a forced enabled=True, then harness.json's
config, then the caller's explicit config -- so a test can still
deliberately disable a plugin on purpose, it just can't happen by accident
via the plugin's own shipped schema default anymore.
2026-07-14 08:21:35 -04:00
0aca40cf3a perf(plugins): run scheduled updates off the render thread (#407)
* perf(plugins): run scheduled updates off the render thread

plugin update() executed inline in the render loop — execute_update's
internal thread.join(timeout=30) blocked it, so one slow plugin HTTP
fetch froze scrolling for the whole fetch (up to 30s; DNS-retry storms
made this a regular occurrence on flaky networks).

Scheduling stays on the render thread and keeps every existing gate
(enabled, circuit breaker, can_execute, interval); due updates are now
enqueued to a single background worker (serialized — same one-at-a-time
execution as before, no thundering herd). RUNNING is set at enqueue so
can_execute blocks re-entry alongside the pending-set dedup.

Per-plugin locks make the old implicit update/display no-overlap
guarantee explicit: the worker holds the plugin's lock through its
update; the display side try-locks and, when the plugin is mid-update,
holds the last frame for that iteration — reported as success so a
mid-update skip never advances the rotation. Unlike before, the
guarantee now also holds across the post-timeout window (previously the
lingering update thread overlapped display()). Deadlock-free by
construction: the worker takes one lock; display never blocks.

Timeout semantics unchanged (lingering daemon thread documented).
Kill switch: plugin_system.synchronous_updates: true restores the
inline path.

8 new concurrency tests (non-blocking scheduler, overlap assertion
under a hammering display loop, lock release on failure/timeout paths,
dedup, kill switch); 4-min devpi soak clean (updates completing,
rotation advancing, no stuck RUNNING states).

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

* fix(plugins): fix skipped-frame health/force_change tracking, config validation, and lock-lifetime gaps in async updates

Addresses PR #407 review findings:
- display_controller: only clear force_change / record health success
  when display() actually ran this frame, not when the frame was skipped
  because the plugin's lock was busy (a skip must preserve a pending
  mode-switch force_clear).
- plugin_manager: replace bool() coercion of synchronous_updates with
  explicit isinstance validation of plugin_system/synchronous_updates,
  failing safe to synchronous mode (with a logged reason) on malformed
  config instead of silently defaulting to async.
- plugin_manager: _update_worker_loop now acquires the plugin lock before
  looking up its instance and re-checks under the lock, so an unloaded
  plugin's lifecycle state is never resurrected to ENABLED.
- plugin_manager + display_controller: move lock ownership (and, for
  updates, RUNNING/pending lifecycle bookkeeping) into the actual update()/
  display() call itself rather than the timeout-wrapped caller, so the
  lock stays held for the real operation's duration even after
  PluginExecutor's own join(timeout) elapses and a lingering daemon thread
  keeps running in the background.
- DisplayController.cleanup() now stops the update worker before tearing
  down display/cache resources; stop_update_worker() logs when the join
  times out instead of failing silently.
- test_async_plugin_updates: rewrite test_unloaded_while_queued_is_harmless
  to exercise the public unload_plugin() lifecycle (via a deterministic
  blocker) instead of deleting pm.plugins directly, and add a regression
  test proving the plugin lock stays held through PluginExecutor's own
  timeout while the real update() call is still running.

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

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 08:08:51 -04:00
21 changed files with 6188 additions and 104 deletions
+6 -7
View File
@@ -37,7 +37,7 @@ os.environ['EMULATOR'] = 'true'
from src.logging_config import get_logger # noqa: E402
from src.plugin_system.testing.loading import ( # noqa: E402
find_plugin_dir, load_config_defaults, load_harness_spec, load_manifest,
build_full_config, find_plugin_dir, load_harness_spec, load_manifest,
)
from src.plugin_system.testing.harness import ( # noqa: E402
RenderResult, render_plugin_matrix, compare_to_goldens, write_goldens,
@@ -97,12 +97,11 @@ def check_one(plugin_id: str, search_dirs: List[str], sizes, mock_data: Dict,
# matrix path does; explicit CLI flags still override the file.
spec = load_harness_spec(plugin_dir)
# config_schema defaults (real-install behavior), then harness.json config,
# then CLI --config — most specific wins.
full_config = {"enabled": True}
full_config.update(load_config_defaults(plugin_dir))
full_config.update(spec.get("config", {}))
full_config.update(config)
# config_schema defaults (real-install behavior, with enabled forced True
# so a plugin's own enabled:false default can't accidentally disable
# testing), then harness.json config, then CLI --config — most specific
# wins.
full_config = build_full_config(plugin_dir, spec, config)
# Precedence: CLI flag > LEDMATRIX_TEST_SIZES env > harness.json > default.
effective_sizes = sizes if sizes else resolve_test_sizes(spec.get("sizes"))
+2 -5
View File
@@ -28,7 +28,7 @@ os.environ['EMULATOR'] = 'true'
# Import logger after path setup so src.logging_config is importable
from src.logging_config import get_logger # noqa: E402
from src.plugin_system.testing.loading import ( # noqa: E402
find_plugin_dir, load_manifest, load_config_defaults,
build_full_config, find_plugin_dir, load_manifest,
)
logger = get_logger("[Render Plugin]")
@@ -83,16 +83,13 @@ def main() -> int:
manifest = load_manifest(Path(plugin_dir))
# Parse config: start with schema defaults, then apply overrides
config_defaults = load_config_defaults(Path(plugin_dir))
try:
user_config = json.loads(args.config)
except json.JSONDecodeError as e:
logger.error("Invalid JSON config: %s", e)
return 1
config = {'enabled': True}
config.update(config_defaults)
config.update(user_config)
config = build_full_config(Path(plugin_dir), cli_config=user_config)
# Load mock data if provided
mock_data = {}
+12 -16
View File
@@ -78,21 +78,17 @@ class WiFiMonitorDaemon:
while self.running:
try:
# Get current status before checking
status = self.wifi_manager.get_wifi_status()
ethernet_connected = self.wifi_manager._is_ethernet_connected()
# Check WiFi status and manage AP mode
state_changed = self.wifi_manager.check_and_manage_ap_mode()
# Get updated status after check
updated_status = self.wifi_manager.get_wifi_status()
updated_ethernet = self.wifi_manager._is_ethernet_connected()
# One combined check that also returns the state it observed —
# the previous flow fetched status before AND after the check
# on top of the check's own internal fetch, each one several
# nmcli subprocess forks, every 30s, forever.
(state_changed, updated_status, updated_ethernet,
ap_active) = self.wifi_manager.check_and_manage_ap_mode_with_state()
current_state = {
'connected': updated_status.connected,
'ethernet_connected': updated_ethernet,
'ap_active': updated_status.ap_mode_active,
'ap_active': ap_active,
'ssid': updated_status.ssid
}
@@ -109,7 +105,7 @@ class WiFiMonitorDaemon:
else:
logger.debug("Ethernet not connected")
if updated_status.ap_mode_active:
if ap_active:
logger.info(f"AP mode ACTIVE - SSID: {ap_ssid} (IP: 192.168.4.1)")
else:
logger.debug("AP mode inactive")
@@ -123,16 +119,16 @@ class WiFiMonitorDaemon:
# Log periodic status (less verbose)
if updated_status.connected:
logger.debug(f"Status check: WiFi={updated_status.ssid} ({updated_status.signal}%), "
f"Ethernet={updated_ethernet}, AP={updated_status.ap_mode_active}")
f"Ethernet={updated_ethernet}, AP={ap_active}")
else:
logger.debug(f"Status check: WiFi=disconnected, Ethernet={updated_ethernet}, AP={updated_status.ap_mode_active}")
logger.debug(f"Status check: WiFi=disconnected, Ethernet={updated_ethernet}, AP={ap_active}")
# Escalating recovery: if nmcli reports connected but actual internet
# is unreachable for several consecutive checks, restart NetworkManager.
# This is done HERE (not inside check_and_manage_ap_mode) to keep the
# AP-enable trigger clean and avoid false-positive AP enables from
# transient packet loss on otherwise working WiFi.
if updated_status.connected and not updated_status.ap_mode_active:
if updated_status.connected and not ap_active:
if not self.wifi_manager.check_internet_connectivity():
self._consecutive_internet_failures += 1
logger.warning(
+46 -4
View File
@@ -56,6 +56,13 @@ class ConfigManager:
self.secrets_path: str = secrets_path or "config/config_secrets.json"
self.template_path: str = "config/config.template.json"
self.config: Dict[str, Any] = {}
# (mtime_ns, size) signature of (config, secrets, template) at the
# last successful load. load_config() skips the full re-read (3 file
# parses + recursive template migration) when nothing changed —
# ~30 web request handlers call it, some 2-3x per request. Cross-
# process freshness is preserved: another process's save bumps the
# mtime, so the next load here re-reads.
self._loaded_sig: Optional[tuple] = None
self.logger: logging.Logger = get_logger(__name__)
# Initialize atomic config manager
@@ -122,6 +129,14 @@ class ConfigManager:
# Update in-memory config if save was successful
if result.status == SaveResultStatus.SUCCESS:
self.config = new_config_data
# In-memory config now matches what was just written; refresh
# the load signature so the fast path stays valid. NOTE: the
# in-memory copy includes merged secrets; the on-disk file has
# them stripped — the fast path returning self.config preserves
# exactly the pre-cache behavior (load-after-save also returned
# the secret-merged self.config only after re-reading secrets;
# here secrets file is unchanged, so contents are equivalent).
self._loaded_sig = self._files_signature()
self.logger.info(f"Configuration successfully saved atomically to {os.path.abspath(self.config_path)}")
elif result.status == SaveResultStatus.ROLLED_BACK:
# Reload config from file after rollback
@@ -179,13 +194,36 @@ class ConfigManager:
atomic_mgr = self._get_atomic_manager()
return atomic_mgr.validate_config_file(config_path)
def _files_signature(self) -> tuple:
"""(mtime_ns, size) of config/secrets/template, None for missing —
cheap staleness probe (3 stats) for the load_config fast path."""
sig = []
for path in (self.config_path, self.secrets_path, self.template_path):
try:
st = os.stat(path)
sig.append((st.st_mtime_ns, st.st_size))
except OSError:
sig.append(None)
return tuple(sig)
def load_config(self) -> Dict[str, Any]:
"""Load configuration from JSON files."""
"""Load configuration from JSON files.
Fast path: when config.json, config_secrets.json and the template
are all unchanged since the last successful load (mtime_ns + size),
the already-parsed self.config is returned without touching the
files — same aliasing semantics as the full path, which also
returns self.config.
"""
try:
current_sig = self._files_signature()
if self.config and self._loaded_sig == current_sig:
return self.config
# Check if config file exists, if not create from template
if not os.path.exists(self.config_path):
self._create_config_from_template()
# Load main config
self.logger.info(f"Attempting to load config from: {os.path.abspath(self.config_path)}")
with open(self.config_path, 'r') as f:
@@ -205,7 +243,10 @@ class ConfigManager:
self.logger.warning(f"Secrets file not readable ({self.secrets_path}): {e}. Continuing without secrets.")
except (json.JSONDecodeError, OSError) as e:
self.logger.warning(f"Error reading secrets file ({self.secrets_path}): {e}. Continuing without secrets.")
# Signature taken AFTER load + migration (migration may write the
# config back), so it reflects exactly what was read/written.
self._loaded_sig = self._files_signature()
return self.config
except FileNotFoundError as e:
@@ -264,7 +305,8 @@ class ConfigManager:
json.dump(config_to_write, f, indent=4)
# Update the in-memory config to the new state (which includes secrets for runtime)
self.config = new_config_data
self.config = new_config_data
self._loaded_sig = self._files_signature()
self.logger.info(f"Configuration successfully saved to {os.path.abspath(self.config_path)}")
if secrets_content:
self.logger.info("Secret values were preserved in memory and not written to the main config file.")
+131 -27
View File
@@ -23,6 +23,9 @@ Entry point: :func:`main` — instantiates :class:`DisplayController` and calls
import time
import os
import json
import threading
import types
from contextlib import contextmanager
from pathlib import Path
from typing import Dict, Any, List, Optional, Callable
from datetime import datetime
@@ -892,6 +895,30 @@ class DisplayController:
except Exception: # pylint: disable=broad-except
logger.exception("Error running scheduled plugin updates")
@contextmanager
def _display_lock_or_skip(self, plugin_id):
"""Try-lock guard keeping a plugin's display() off its in-flight update().
Yields True when display may run (lock held, released on exit) or
when no lock support exists (older plugin manager). Yields False when
the plugin's update() is currently executing on the background
worker — the caller should treat the frame as displayed (the panel
holds the last pushed frame) rather than as a plugin failure, so a
mid-update skip never advances the rotation.
"""
pm = self.plugin_manager
if not pm or not hasattr(pm, 'get_plugin_lock') or not plugin_id:
yield True
return
lock = pm.get_plugin_lock(plugin_id)
if not lock.acquire(blocking=False):
yield False
return
try:
yield True
finally:
lock.release()
_FOLLOWER_SEND_INTERVAL = 1.0 / 90 # raw bytes are cheap; 90fps > follower render rate
def _follower_rebuild_scroll_image(self) -> None:
@@ -1879,6 +1906,7 @@ class DisplayController:
plugin_id = getattr(manager_to_display, 'plugin_id', active_mode)
try:
logger.debug(f"Calling display() for {active_mode} with force_clear={self.force_change}")
can_display = False
if hasattr(manager_to_display, 'display'):
# Opt #1: look up (or compute once) whether display() accepts display_mode
_cache_key = plugin_id
@@ -1889,14 +1917,64 @@ class DisplayController:
)
_accepts_display_mode = self._plugin_accepts_display_mode[_cache_key]
# Use PluginExecutor for safe execution with timeout
if self.plugin_manager and hasattr(self.plugin_manager, 'plugin_executor'):
result = self.plugin_manager.plugin_executor.execute_display(
manager_to_display,
plugin_id,
force_clear=self.force_change,
display_mode=active_mode if _accepts_display_mode else None
)
pm = self.plugin_manager
display_lock = None
can_display = True
if pm and hasattr(pm, 'get_plugin_lock'):
display_lock = pm.get_plugin_lock(plugin_id)
can_display = display_lock.acquire(blocking=False)
if not can_display:
# update() in flight on the worker — hold
# the last frame; not a plugin failure
result = True
elif pm and hasattr(pm, 'plugin_executor'):
# PluginExecutor's own thread.join(timeout) can
# return before the real display() call
# finishes (a lingering daemon thread keeps
# running it) -- so the lock is released from
# inside the wrapped call itself, whichever
# thread actually finishes it, rather than
# here when this dispatch merely returns.
release_guard = threading.Lock()
released = {'done': False}
def _release_display_lock():
with release_guard:
if released['done']:
return
released['done'] = True
if display_lock is not None:
display_lock.release()
if _accepts_display_mode:
def _display_target(display_mode=None, force_clear=False):
try:
return manager_to_display.display(
display_mode=display_mode, force_clear=force_clear)
finally:
_release_display_lock()
else:
def _display_target(force_clear=False):
try:
return manager_to_display.display(force_clear=force_clear)
finally:
_release_display_lock()
try:
result = self.plugin_manager.plugin_executor.execute_display(
types.SimpleNamespace(display=_display_target),
plugin_id,
force_clear=self.force_change,
display_mode=active_mode if _accepts_display_mode else None
)
except Exception: # pragma: no cover - defensive;
# execute_display catches everything
# internally, but guarantee the lock is
# never leaked if something unexpected
# slips through.
_release_display_lock()
raise
# execute_display returns bool, convert to expected format
if result:
result = True # Success
@@ -1904,10 +1982,14 @@ class DisplayController:
result = False # Failed
else:
# Fallback to direct call if executor not available
if _accepts_display_mode:
result = manager_to_display.display(display_mode=active_mode, force_clear=self.force_change)
else:
result = manager_to_display.display(force_clear=self.force_change)
try:
if _accepts_display_mode:
result = manager_to_display.display(display_mode=active_mode, force_clear=self.force_change)
else:
result = manager_to_display.display(force_clear=self.force_change)
finally:
if display_lock is not None:
display_lock.release()
logger.debug(f"display() returned: {result} (type: {type(result)})")
# Check if display() returned a boolean (new behavior)
@@ -1916,11 +1998,15 @@ class DisplayController:
if not display_result:
logger.info("Plugin %s display() returned False for mode %s", plugin_id, active_mode)
# Record success if display completed without exception
if self.plugin_manager and hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker:
self.plugin_manager.health_tracker.record_success(plugin_id)
self.force_change = False
# Record success only when display() actually ran this
# frame -- a skipped frame (lock busy) held the last
# frame, not a real success, and must not clear
# force_change or the pending mode-switch clear will
# be lost when display() finally does run.
if can_display:
if self.plugin_manager and hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker:
self.plugin_manager.health_tracker.record_success(plugin_id)
self.force_change = False
except Exception as exc: # pylint: disable=broad-except
logger.exception("Error displaying %s", self.current_display_mode)
# Record failure
@@ -2205,11 +2291,16 @@ class DisplayController:
while True:
try:
# Pass display_mode to maintain sticky manager state
if _accepts_display_mode:
result = manager_to_display.display(display_mode=active_mode, force_clear=False)
else:
result = manager_to_display.display(force_clear=False)
with self._display_lock_or_skip(plugin_id) as can_display:
if can_display:
# Pass display_mode to maintain sticky manager state
if _accepts_display_mode:
result = manager_to_display.display(display_mode=active_mode, force_clear=False)
else:
result = manager_to_display.display(force_clear=False)
else:
# update() in flight — hold the last frame
result = True
if isinstance(result, bool) and not result:
logger.debug("Display returned False, breaking early")
break
@@ -2269,11 +2360,16 @@ class DisplayController:
break
try:
# Pass display_mode to maintain sticky manager state
if _accepts_display_mode:
result = manager_to_display.display(display_mode=active_mode, force_clear=False)
else:
result = manager_to_display.display(force_clear=False)
with self._display_lock_or_skip(plugin_id) as can_display:
if can_display:
# Pass display_mode to maintain sticky manager state
if _accepts_display_mode:
result = manager_to_display.display(display_mode=active_mode, force_clear=False)
else:
result = manager_to_display.display(force_clear=False)
else:
# update() in flight — hold the last frame
result = True
if isinstance(result, bool) and not result:
# For dynamic duration plugins, don't exit on False - keep looping
# until cycle is complete or max duration is reached
@@ -2791,6 +2887,14 @@ class DisplayController:
def cleanup(self):
"""Clean up resources."""
# Stop the async update worker first so no in-flight update() call
# is still touching display/cache-backed resources while they're
# torn down below.
if self.plugin_manager and hasattr(self.plugin_manager, 'stop_update_worker'):
try:
self.plugin_manager.stop_update_worker()
except Exception as e:
logger.warning("Error stopping plugin update worker: %s", e)
# Shutdown config service if it exists
if hasattr(self, 'config_service'):
try:
+228 -41
View File
@@ -8,12 +8,13 @@ API Version: 1.0.0
"""
import json
import queue
import sys
import time
import threading
import types
from pathlib import Path
from typing import Dict, List, Optional, Any
from typing import Dict, List, Optional, Any, Tuple
import logging
from src.exceptions import PluginError, ConfigError
from src.logging_config import get_logger
@@ -97,6 +98,50 @@ class PluginManager:
# Health tracking (optional, set by display_controller if available)
self.health_tracker = None
self.resource_monitor = None
# --- Asynchronous plugin updates -------------------------------
# update() used to run inline in the render loop (execute_update's
# internal thread.join(timeout=30) blocked it), so one slow plugin
# HTTP fetch froze scrolling for the whole fetch. Scheduling still
# happens on the render thread (run_scheduled_updates), but
# execution moves to this single background worker. Per-plugin
# locks keep a plugin's update() and display() mutually exclusive —
# today's implicit guarantee, now explicit (and, unlike today,
# also held across the post-timeout window).
# Kill switch: plugin_system.synchronous_updates: true restores the
# inline path.
self._update_queue: "queue.Queue[Optional[Tuple[str, float]]]" = queue.Queue()
self._pending_updates: set = set()
self._pending_lock = threading.Lock()
self._plugin_locks: Dict[str, threading.Lock] = {}
self._plugin_locks_guard = threading.Lock()
self._update_worker: Optional[threading.Thread] = None
self._synchronous_updates = False
if self.config_manager is not None:
try:
cfg = self.config_manager.get_config() or {}
except (OSError, ValueError) as exc:
self.logger.warning(
"Could not load config to check plugin_system.synchronous_updates "
"(%s: %s); defaulting to synchronous updates", type(exc).__name__, exc)
self._synchronous_updates = True
else:
plugin_system_cfg = cfg.get('plugin_system', {})
if not isinstance(plugin_system_cfg, dict):
self.logger.warning(
"config plugin_system must be a mapping, got %s; "
"defaulting to synchronous updates",
type(plugin_system_cfg).__name__)
self._synchronous_updates = True
else:
sync_value = plugin_system_cfg.get('synchronous_updates', False)
if not isinstance(sync_value, bool):
self.logger.warning(
"config plugin_system.synchronous_updates must be a boolean, "
"got %r; defaulting to synchronous updates", sync_value)
self._synchronous_updates = True
else:
self._synchronous_updates = sync_value
# Ensure plugins directory exists with proper permissions
try:
@@ -744,47 +789,189 @@ class PluginManager:
last_update = self.plugin_last_update.get(plugin_id, 0.0)
if last_update == 0.0 or (current_time - last_update) >= interval:
# Update state to RUNNING
self.state_manager.set_state(plugin_id, PluginState.RUNNING)
try:
# Use PluginExecutor for safe execution
success = False
if self.resource_monitor:
# If resource monitor exists, wrap the call
def monitored_update():
self.resource_monitor.monitor_call(plugin_id, plugin_instance.update)
# SimpleNamespace stores `update` as an *instance*
# attribute, so attribute lookup returns the plain
# function object as-is. A dynamically-built class
# (`type(..., {'update': monitored_update})`) instead
# stores it as a *class* attribute, which the
# descriptor protocol turns into a bound method on
# access -- silently prepending the instance as an
# implicit first argument to a function that takes
# none, raising "monitored_update() takes 0
# positional arguments but 1 was given" on every call.
success = self.plugin_executor.execute_update(
types.SimpleNamespace(update=monitored_update),
plugin_id
)
else:
success = self.plugin_executor.execute_update(plugin_instance, plugin_id)
if success:
with self._plugin_last_update_lock:
self.plugin_last_update[plugin_id] = current_time
self.state_manager.record_update(plugin_id)
# Update state back to ENABLED
self.state_manager.set_state(plugin_id, PluginState.ENABLED)
# Record success
if self.health_tracker:
self.health_tracker.record_success(plugin_id)
else:
self._record_update_failure(plugin_id)
except Exception as exc: # pylint: disable=broad-except
self.logger.exception("Error updating plugin %s: %s", plugin_id, exc)
if self._synchronous_updates:
# Kill-switch path: the original inline execution
# (blocks the caller until update() completes/times out)
self.state_manager.set_state(plugin_id, PluginState.RUNNING)
self._execute_update_now(plugin_id, plugin_instance, current_time)
else:
self._enqueue_update(plugin_id, current_time)
def get_plugin_lock(self, plugin_id: str) -> threading.Lock:
"""Per-plugin lock keeping update() and display() mutually exclusive.
The update worker holds it for the duration of a plugin's update();
the display side acquires it non-blocking and skips that frame's
display() call when the plugin is mid-update.
"""
with self._plugin_locks_guard:
lock = self._plugin_locks.get(plugin_id)
if lock is None:
lock = threading.Lock()
self._plugin_locks[plugin_id] = lock
return lock
def _enqueue_update(self, plugin_id: str, scheduled_time: float) -> None:
"""Queue a due update for the background worker (dedup while pending)."""
with self._pending_lock:
if plugin_id in self._pending_updates:
return
self._pending_updates.add(plugin_id)
# RUNNING is set at enqueue time so can_execute() blocks re-entry and
# the web UI shows the truthful state while the item waits its turn.
self.state_manager.set_state(plugin_id, PluginState.RUNNING)
self._ensure_update_worker()
self._update_queue.put((plugin_id, scheduled_time))
def _ensure_update_worker(self) -> None:
if self._update_worker is not None and self._update_worker.is_alive():
return
self._update_worker = threading.Thread(
target=self._update_worker_loop, name='plugin-update-worker',
daemon=True)
self._update_worker.start()
def _update_worker_loop(self) -> None:
"""Single worker: dispatches queued updates off the render thread
(matching the old inline behavior no thundering herd of
concurrent fetches).
The plugin's lock is acquired here, before its instance is looked
up, and the instance is re-fetched under the lock a concurrent
unload_plugin() can't leave this loop about to run update() on an
instance that's already been torn down. The lock — and RUNNING/
pending lifecycle state is released by the update itself once the
real update() call genuinely finishes (see _execute_update_now),
which can be after this dispatch returns if PluginExecutor's own
timeout elapses first.
"""
while True:
item = self._update_queue.get()
if item is None: # shutdown sentinel
return
plugin_id, scheduled_time = item
lock = self.get_plugin_lock(plugin_id)
lock.acquire()
plugin_instance = self.plugins.get(plugin_id)
if plugin_instance is None: # unloaded while queued; its
# lifecycle state was already cleared by unload_plugin —
# leave it alone rather than resurrecting it to ENABLED
lock.release()
with self._pending_lock:
self._pending_updates.discard(plugin_id)
continue
try:
self._execute_update_now(plugin_id, plugin_instance,
scheduled_time, lock=lock)
except Exception: # pylint: disable=broad-except
# _execute_update_now guarantees the lock/pending bookkeeping
# is released via its own _finish() before returning or
# raising; this is a last-resort log only.
self.logger.exception("update worker: unexpected error for %s",
plugin_id)
def stop_update_worker(self, timeout: float = 5.0) -> None:
"""Signal the worker to exit (used by cleanup; thread is a daemon)."""
if self._update_worker is not None and self._update_worker.is_alive():
self._update_queue.put(None)
self._update_worker.join(timeout=timeout)
if self._update_worker.is_alive():
self.logger.warning(
"Update worker did not stop within %.1fs; it is a daemon "
"thread and will be abandoned on shutdown", timeout)
def _execute_update_now(self, plugin_id: str, plugin_instance: Any,
scheduled_time: float,
lock: Optional[threading.Lock] = None) -> None:
"""Execute a plugin's update() via PluginExecutor, then bookkeep.
Caller is responsible for having set RUNNING state.
On the synchronous path (``lock=None``) this is the original,
unchanged inline behavior. On the async worker path, PluginExecutor's
internal thread.join(timeout) blocks only the calling thread -- on
timeout the lingering daemon update-thread keeps running the real
plugin.update() call unkillable in the background. So that the
plugin's lock (and its RUNNING/pending lifecycle state) stays held
for that real duration rather than just this bounded wait, ownership
of both is carried by the wrapped update callable itself, released
from whichever thread actually finishes it -- see _finish() below.
"""
finish_guard = threading.Lock()
finished = {'done': False}
def _finish(success: bool, exc: Optional[Exception] = None) -> None:
with finish_guard:
if finished['done']:
return
finished['done'] = True
try:
if success:
with self._plugin_last_update_lock:
self.plugin_last_update[plugin_id] = scheduled_time
self.state_manager.record_update(plugin_id)
self.state_manager.set_state(plugin_id, PluginState.ENABLED)
if self.health_tracker:
self.health_tracker.record_success(plugin_id)
else:
self._record_update_failure(plugin_id, exc=exc)
finally:
if lock is not None:
lock.release()
with self._pending_lock:
self._pending_updates.discard(plugin_id)
if lock is None:
# Synchronous / no-lock path: unchanged behavior.
try:
if self.resource_monitor:
def monitored_update():
self.resource_monitor.monitor_call(plugin_id, plugin_instance.update)
# SimpleNamespace stores `update` as an *instance*
# attribute, so attribute lookup returns the plain
# function object as-is. A dynamically-built class
# (`type(..., {'update': monitored_update})`) instead
# stores it as a *class* attribute, which the
# descriptor protocol turns into a bound method on
# access -- silently prepending the instance as an
# implicit first argument to a function that takes
# none, raising "monitored_update() takes 0
# positional arguments but 1 was given" on every call.
success = self.plugin_executor.execute_update(
types.SimpleNamespace(update=monitored_update),
plugin_id
)
else:
success = self.plugin_executor.execute_update(plugin_instance, plugin_id)
_finish(success)
except Exception as exc: # pylint: disable=broad-except
self.logger.exception("Error updating plugin %s: %s", plugin_id, exc)
_finish(False, exc=exc)
return
# Async worker path: the real update() call -- through the resource
# monitor, if configured -- owns finishing the lock/lifecycle
# bookkeeping, from whichever thread actually runs it to completion.
def _target_update() -> None:
try:
if self.resource_monitor:
self.resource_monitor.monitor_call(plugin_id, plugin_instance.update)
else:
plugin_instance.update()
except Exception as exc:
_finish(False, exc=exc)
raise
else:
_finish(True)
try:
self.plugin_executor.execute_update(
types.SimpleNamespace(update=_target_update), plugin_id)
except Exception as exc: # pragma: no cover - defensive; execute_update
# catches everything internally, but guarantee _finish still
# runs (releasing the lock) if something unexpected slips through.
self.logger.exception("Unexpected error dispatching update for %s: %s", plugin_id, exc)
_finish(False, exc=exc)
def run_scheduled_updates_with_changes(self, current_time: Optional[float] = None) -> List[str]:
"""
+24
View File
@@ -88,3 +88,27 @@ def load_harness_spec(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
with open(mock_path, 'r') as mf:
spec['mock_data_contents'] = json.load(mf)
return spec
def build_full_config(
plugin_dir: Union[str, Path],
spec: Optional[Dict[str, Any]] = None,
cli_config: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Build the config a plugin sees under test.
Merge order: config_schema.json defaults, then a forced ``enabled: True``,
then harness.json's config overlay, then the caller's explicit config --
most specific wins. `enabled` is re-asserted *after* the schema defaults
so a plugin that reasonably ships `enabled: false` (e.g. a seasonal or
opt-in plugin) can't silently make every harness run test "disabled, do
nothing" by accident -- callers that genuinely want to test the disabled
path can still do so via `cli_config={"enabled": False}`.
"""
spec = spec or {}
config: Dict[str, Any] = {}
config.update(load_config_defaults(plugin_dir))
config["enabled"] = True
config.update(spec.get("config", {}))
config.update(cli_config or {})
return config
+20
View File
@@ -71,6 +71,7 @@ class MockCacheManager:
self.get_calls = []
self.set_calls = []
self.delete_calls = []
self.get_cached_data_with_strategy_calls = []
# Real temp dir for plugins that write/read files under cache_dir.
# Registered for cleanup so each mock instance doesn't leak a tmp dir.
self.cache_dir = tempfile.mkdtemp(prefix="ledmatrix-mock-cache-")
@@ -108,6 +109,24 @@ class MockCacheManager:
self.delete_calls.append(key)
if key in self._cache:
del self._cache[key]
def get_cached_data_with_strategy(self, key: str, data_type: str = 'default') -> Optional[Any]:
"""Mock of CacheManager.get_cached_data_with_strategy (src/cache_manager.py).
The real method picks a max_age/memory_ttl strategy per data_type
(and extends it during market-closed hours for market data) before
delegating to get_cached_data(). None of that timing nuance matters
for a mock -- plugins under test just need the method to exist and
return whatever was cached, so this delegates straight to get().
"""
self.get_cached_data_with_strategy_calls.append({'key': key, 'data_type': data_type})
return self.get(key)
def save_cache(self, key: str, data: Any) -> None:
"""Mock of CacheManager.save_cache (src/cache_manager.py) -- the
write-side counterpart to get_cached_data_with_strategy, used by the
same real-CacheManager-oriented plugins. Delegates to set()."""
self.set(key, data)
if key in self._cache_timestamps:
del self._cache_timestamps[key]
@@ -118,6 +137,7 @@ class MockCacheManager:
self.get_calls = []
self.set_calls = []
self.delete_calls = []
self.get_cached_data_with_strategy_calls = []
class MockConfigManager:
+24
View File
@@ -2564,11 +2564,35 @@ address=/detectportal.firefox.com/192.168.4.1
Returns:
True if AP mode state changed, False otherwise
"""
changed, _status, _ethernet, _ap = self.check_and_manage_ap_mode_with_state()
return changed
def check_and_manage_ap_mode_with_state(self) -> Tuple[bool, WiFiStatus, bool, bool]:
"""Like check_and_manage_ap_mode, but also returns the state it
observed, so callers (the wifi monitor daemon) don't have to re-run
the same nmcli subprocess battery before AND after the check
each status fetch is several process forks.
Returns:
(state_changed, WiFiStatus, ethernet_connected, ap_active_after)
"""
try:
# Get status with retry for more reliable detection
status = self._get_wifi_status_with_retry()
ethernet_connected = self._is_ethernet_connected()
ap_active = self._is_ap_mode_active()
changed = self._manage_ap_mode(status, ethernet_connected, ap_active)
# State only ever changes via one enable or one disable, so the
# post-state is the inverse of the pre-state when changed.
ap_after = (not ap_active) if changed else ap_active
return changed, status, ethernet_connected, ap_after
except Exception as e:
logger.error(f"Error checking AP mode: {e}", exc_info=True)
return False, WiFiStatus(connected=False), False, False
def _manage_ap_mode(self, status: WiFiStatus, ethernet_connected: bool, ap_active: bool) -> bool:
"""AP-mode decision logic against an already-fetched state snapshot."""
try:
auto_enable = self.config.get("auto_enable_ap_mode", True) # Default: True (safe due to grace period)
# Log current state for debugging
+58
View File
@@ -253,3 +253,61 @@ class TestCheckPluginHonorsHarnessJson:
)
assert captured["freeze_time"] == "2030-01-01 00:00:00"
assert captured["config"]["timezone"] == "America/New_York"
class TestBuildFullConfigForcesEnabled:
"""Regression: a plugin's own config_schema.json may reasonably default
enabled to False (e.g. a seasonal or opt-in plugin) -- march-madness and
14 other real plugins do. The harness must still test it as enabled
unless a caller explicitly asks otherwise, or every render silently
becomes a same-shaped "disabled, do nothing" no-op."""
def _make_plugin_with_disabled_default(self, tmp_path):
pdir = tmp_path / "plugins" / "demo-seasonal"
pdir.mkdir(parents=True)
(pdir / "manifest.json").write_text(json.dumps({
"id": "demo-seasonal", "name": "Demo Seasonal", "version": "1.0.0",
"author": "test", "entry_point": "manager.py",
"class_name": "DemoSeasonal", "display_modes": ["demo-seasonal"],
"compatible_versions": ["*"],
}))
(pdir / "config_schema.json").write_text(json.dumps({
"type": "object",
"properties": {"enabled": {"type": "boolean", "default": False}},
}))
return pdir
def test_schema_disabled_default_does_not_win(self, tmp_path):
from src.plugin_system.testing.loading import build_full_config
plugin_dir = self._make_plugin_with_disabled_default(tmp_path)
config = build_full_config(plugin_dir)
assert config["enabled"] is True
def test_harness_json_config_can_still_disable(self, tmp_path):
from src.plugin_system.testing.loading import build_full_config
plugin_dir = self._make_plugin_with_disabled_default(tmp_path)
config = build_full_config(plugin_dir, spec={"config": {"enabled": False}})
assert config["enabled"] is False
def test_explicit_cli_config_can_still_disable(self, tmp_path):
from src.plugin_system.testing.loading import build_full_config
plugin_dir = self._make_plugin_with_disabled_default(tmp_path)
config = build_full_config(plugin_dir, cli_config={"enabled": False})
assert config["enabled"] is False
def test_check_one_renders_a_schema_disabled_plugin_as_enabled(self, tmp_path, monkeypatch):
"""End-to-end: check_plugin.py's check_one() must not blank-render a
plugin just because its own schema defaults enabled to False."""
mod = _load_check_plugin_cli()
plugin_dir = self._make_plugin_with_disabled_default(tmp_path)
captured = {}
monkeypatch.setattr(mod, "render_plugin_matrix",
lambda **kw: captured.update(kw) or [])
monkeypatch.setattr(mod, "compare_to_goldens", lambda *a, **k: [])
mod.check_one(
plugin_id="demo-seasonal", search_dirs=[str(tmp_path / "plugins")],
sizes=None, mock_data={}, config={}, run_update=True,
out_dir=None, update_golden=False, golden_dir_override=None,
freeze_time=None,
)
assert captured["config"]["enabled"] is True
+2 -4
View File
@@ -22,7 +22,7 @@ import pytest
from src.plugin_system.testing.harness import (
render_plugin_matrix, compare_to_goldens,
)
from src.plugin_system.testing.loading import load_config_defaults, load_harness_spec
from src.plugin_system.testing.loading import build_full_config, load_harness_spec
from src.plugin_system.testing.sizes import resolve_test_sizes
PROJECT_ROOT = Path(__file__).resolve().parents[2]
@@ -81,9 +81,7 @@ def test_plugin_renders_across_sizes_and_screens(plugin_id: str) -> None:
plugin_dir = _PLUGINS[plugin_id]
spec = load_harness_spec(plugin_dir)
config = {"enabled": True}
config.update(load_config_defaults(plugin_dir))
config.update(spec.get("config", {}))
config = build_full_config(plugin_dir, spec)
# Sizes: LEDMATRIX_TEST_SIZES env (test on real hardware) wins, then the
# plugin's own harness.json "sizes", else the default representative sample.
+270
View File
@@ -0,0 +1,270 @@
"""Tests for asynchronous plugin updates (plugin_manager background worker).
The invariants that keep this change safe:
1. run_scheduled_updates returns immediately a slow update() can never
again freeze the render loop (the original defect: 30s scroll freezes).
2. A plugin's update() and display() are NEVER concurrent — the per-plugin
lock makes the old implicit no-overlap guarantee explicit, and it stays
held through PluginExecutor's own timeout: a lingering, still-running
update() keeps the lock even after PluginExecutor gives up waiting on it.
3. Failure/timeout bookkeeping is unchanged (same executor, same
_record_update_failure path, same last-update stamping).
4. The kill switch (plugin_system.synchronous_updates) restores the
inline path exactly.
"""
import os
import sys
import threading
import time
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from src.plugin_system.plugin_manager import PluginManager # noqa: E402
from src.plugin_system.plugin_state import PluginState # noqa: E402
class SlowPlugin:
"""Fake plugin whose update() sleeps and records overlap violations."""
def __init__(self, update_seconds=0.5):
self.enabled = True
self.update_seconds = update_seconds
self.update_calls = 0
self.display_calls = 0
self.in_update = False
self.overlap_detected = False
def update(self):
self.in_update = True
self.update_calls += 1
time.sleep(self.update_seconds)
self.in_update = False
return True
def display(self, force_clear=False):
if self.in_update:
self.overlap_detected = True
self.display_calls += 1
return True
@pytest.fixture
def pm(tmp_path):
manager = PluginManager(plugins_dir=str(tmp_path), config_manager=None,
display_manager=None, cache_manager=None)
yield manager
manager.stop_update_worker()
def _install(pm, plugin, plugin_id="slow-plugin"):
pm.plugins[plugin_id] = plugin
pm._update_interval_cache[plugin_id] = 0.01 # always due
# load_plugin normally registers state; can_execute() gates on it
pm.state_manager.set_state(plugin_id, PluginState.ENABLED)
return plugin_id
class TestSchedulerNonBlocking:
def test_run_scheduled_updates_returns_immediately(self, pm):
plugin_id = _install(pm, SlowPlugin(update_seconds=2.0))
start = time.monotonic()
pm.run_scheduled_updates()
elapsed = time.monotonic() - start
assert elapsed < 0.1, f"scheduler blocked for {elapsed:.2f}s"
# the update actually runs in the background
deadline = time.monotonic() + 5
while pm.plugins[plugin_id].update_calls == 0 and time.monotonic() < deadline:
time.sleep(0.05)
assert pm.plugins[plugin_id].update_calls == 1
def test_no_double_enqueue_while_pending(self, pm):
plugin_id = _install(pm, SlowPlugin(update_seconds=0.8))
for _ in range(20):
pm.run_scheduled_updates()
time.sleep(0.01)
time.sleep(1.5) # let the single queued update finish
assert pm.plugins[plugin_id].update_calls == 1
class TestUpdateDisplayExclusion:
def test_display_lock_held_during_update(self, pm):
plugin_id = _install(pm, SlowPlugin(update_seconds=0.6))
pm.run_scheduled_updates()
# give the worker a moment to take the lock and enter update()
deadline = time.monotonic() + 2
while not pm.plugins[plugin_id].in_update and time.monotonic() < deadline:
time.sleep(0.01)
lock = pm.get_plugin_lock(plugin_id)
assert lock.acquire(blocking=False) is False, \
"lock must be held while update() runs"
# and released afterwards
deadline = time.monotonic() + 3
while pm.plugins[plugin_id].in_update and time.monotonic() < deadline:
time.sleep(0.05)
time.sleep(0.1)
assert lock.acquire(blocking=False) is True
lock.release()
def test_no_overlap_under_hammering_display_loop(self, pm):
"""Simulate the render loop's try-lock display pattern at high rate
while updates fire the plugin itself asserts no overlap."""
plugin = SlowPlugin(update_seconds=0.15)
plugin_id = _install(pm, plugin)
stop = threading.Event()
def render_loop():
while not stop.is_set():
lock = pm.get_plugin_lock(plugin_id)
if lock.acquire(blocking=False):
try:
plugin.display()
finally:
lock.release()
time.sleep(0.002)
renderer = threading.Thread(target=render_loop, daemon=True)
renderer.start()
try:
for _ in range(6):
pm.plugin_last_update.pop(plugin_id, None) # force due
pm.run_scheduled_updates()
time.sleep(0.3)
finally:
stop.set()
renderer.join(timeout=2)
assert plugin.update_calls >= 3
assert plugin.display_calls > 10
assert plugin.overlap_detected is False
def test_lock_held_through_timeout_until_real_update_finishes(self, pm):
"""PluginExecutor's join(timeout) can return before the real
update() call does -- the lingering daemon thread keeps running it.
The plugin's lock must stay held for that whole real duration, not
just PluginExecutor's bounded wait, or display() could run
concurrently with a still-executing update()."""
plugin = SlowPlugin(update_seconds=0.3)
plugin_id = _install(pm, plugin)
pm.plugin_executor.default_timeout = 0.05 # times out well before
# update_seconds elapses
pm.run_scheduled_updates()
deadline = time.monotonic() + 2
while not plugin.in_update and time.monotonic() < deadline:
time.sleep(0.01)
assert plugin.in_update is True
# PluginExecutor's own timeout has now elapsed, but the real
# update() (0.3s) is still running in its lingering daemon thread.
time.sleep(0.15)
assert plugin.in_update is True, "test setup: update should still be running"
lock = pm.get_plugin_lock(plugin_id)
assert lock.acquire(blocking=False) is False, \
"lock must stay held through PluginExecutor's timeout while the real update() runs"
# Once the real update() genuinely finishes, the lock is released.
deadline = time.monotonic() + 2
while plugin.in_update and time.monotonic() < deadline:
time.sleep(0.02)
time.sleep(0.1)
assert lock.acquire(blocking=False) is True
lock.release()
def test_state_returns_to_enabled_after_update(self, pm):
"""RUNNING is set at enqueue (blocks re-entry via can_execute) and
must return to an executable state once the update finishes."""
plugin_id = _install(pm, SlowPlugin(update_seconds=0.1))
pm.run_scheduled_updates()
# while queued/running, re-entry is blocked
assert pm.state_manager.can_execute(plugin_id) is False
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
if (pm.plugins[plugin_id].update_calls
and pm.state_manager.can_execute(plugin_id)):
break
time.sleep(0.05)
assert pm.plugins[plugin_id].update_calls == 1
assert pm.state_manager.can_execute(plugin_id) is True
class TestFailurePaths:
def test_update_failure_routes_through_failure_bookkeeping(self, pm):
class FailingPlugin(SlowPlugin):
def update(self):
self.update_calls += 1
raise RuntimeError("boom")
plugin_id = _install(pm, FailingPlugin())
pm.run_scheduled_updates()
deadline = time.monotonic() + 3
while pm.plugins[plugin_id].update_calls == 0 and time.monotonic() < deadline:
time.sleep(0.05)
time.sleep(0.2)
# failure stamped so the interval gate holds (no hot retry loop)
assert pm.plugin_last_update.get(plugin_id, 0) > 0
# lock released after failure
assert pm.get_plugin_lock(plugin_id).acquire(blocking=False) is True
pm.get_plugin_lock(plugin_id).release()
def test_unloaded_while_queued_is_harmless(self, pm):
"""Exercise the public unload_plugin() lifecycle rather than
deleting pm.plugins directly: queue the target's update behind a
deterministic blocker (occupying the single worker), unload the
target while its item still sits queued, then release the blocker
and confirm the target's update never ran and its state stayed
unloaded rather than being resurrected to ENABLED."""
blocker_event = threading.Event()
class BlockerPlugin(SlowPlugin):
def update(self):
self.update_calls += 1
blocker_event.wait(timeout=5)
return True
blocker_id = _install(pm, BlockerPlugin(), plugin_id="blocker-plugin")
target = SlowPlugin(update_seconds=0.05)
target_id = _install(pm, target, plugin_id="slow-plugin")
# Dispatch the blocker first so it occupies the single worker
# thread, then enqueue the target behind it -- deterministically
# queued, not yet started.
pm._enqueue_update(blocker_id, time.time())
deadline = time.monotonic() + 2
while pm.plugins[blocker_id].update_calls == 0 and time.monotonic() < deadline:
time.sleep(0.01)
assert pm.plugins[blocker_id].update_calls == 1
pm._enqueue_update(target_id, time.time())
assert target_id in pm._pending_updates
assert pm.unload_plugin(target_id) is True
assert target_id not in pm.plugins
blocker_event.set() # let the blocker finish; worker moves on to
# the target's queued item
deadline = time.monotonic() + 3
while target_id in pm._pending_updates and time.monotonic() < deadline:
time.sleep(0.02)
assert target.update_calls == 0, "update() must not run for an unloaded plugin"
assert target_id not in pm._pending_updates
assert pm.state_manager.get_state(target_id) == PluginState.UNLOADED
class TestKillSwitch:
def test_synchronous_mode_blocks_like_before(self, pm):
pm._synchronous_updates = True
plugin_id = _install(pm, SlowPlugin(update_seconds=0.4))
start = time.monotonic()
pm.run_scheduled_updates()
elapsed = time.monotonic() - start
assert elapsed >= 0.4, "synchronous mode must run inline"
assert pm.plugins[plugin_id].update_calls == 1
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-v"]))
+129
View File
@@ -0,0 +1,129 @@
"""Tests for load_config's mtime fast path (src/config_manager.py).
load_config used to re-read + re-parse config.json, the template (with a
recursive migration diff) and secrets on EVERY call ~30 web request
handlers call it, some 2-3x per request. The fast path skips all of it
when the three files' (mtime_ns, size) signatures are unchanged.
The invariant that matters most: cross-process freshness a save from
the web process must be picked up by the display process's next load.
That's guaranteed because the signature is re-stat'd on every call.
"""
import json
import os
import sys
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from src.config_manager import ConfigManager # noqa: E402
@pytest.fixture
def mgr(tmp_path):
config = tmp_path / "config.json"
secrets = tmp_path / "secrets.json"
template = tmp_path / "template.json"
config.write_text(json.dumps({"display": {"brightness": 90}, "timezone": "UTC"}))
secrets.write_text(json.dumps({"weather": {"api_key": "sek"}}))
template.write_text(json.dumps({"display": {"brightness": 90}, "timezone": "UTC"}))
m = ConfigManager(config_path=str(config), secrets_path=str(secrets))
m.template_path = str(template)
return m, config, secrets, template
def _count_opens(monkeypatch, mgr_paths):
"""Count open() calls hitting the config files."""
counts = {"n": 0}
real_open = open
def counting_open(file, *args, **kwargs):
if str(file) in mgr_paths:
counts["n"] += 1
return real_open(file, *args, **kwargs)
import builtins
monkeypatch.setattr(builtins, "open", counting_open)
return counts
class TestFastPath:
def test_unchanged_files_are_not_reread(self, mgr, monkeypatch):
m, config, secrets, template = mgr
first = m.load_config()
assert first["weather"]["api_key"] == "sek" # secrets merged
counts = _count_opens(monkeypatch, {str(config), str(secrets), str(template)})
for _ in range(10):
again = m.load_config()
assert counts["n"] == 0, "fast path must not re-open any config file"
assert again is first # same aliasing semantics as the full path
def test_config_change_triggers_reload(self, mgr):
m, config, secrets, template = mgr
m.load_config()
data = json.loads(config.read_text())
data["display"]["brightness"] = 55
config.write_text(json.dumps(data))
os.utime(config, (os.stat(config).st_atime, os.stat(config).st_mtime + 2))
assert m.load_config()["display"]["brightness"] == 55
def test_secrets_change_triggers_reload(self, mgr):
m, config, secrets, template = mgr
m.load_config()
secrets.write_text(json.dumps({"weather": {"api_key": "NEW"}}))
os.utime(secrets, (os.stat(secrets).st_atime, os.stat(secrets).st_mtime + 2))
assert m.load_config()["weather"]["api_key"] == "NEW"
def test_template_change_triggers_reload_and_migration(self, mgr):
m, config, secrets, template = mgr
m.load_config()
template.write_text(json.dumps({
"display": {"brightness": 90}, "timezone": "UTC",
"brand_new_key": {"added": True}}))
os.utime(template, (os.stat(template).st_atime, os.stat(template).st_mtime + 2))
reloaded = m.load_config()
assert reloaded.get("brand_new_key") == {"added": True}
def test_same_second_edit_detected_via_mtime_ns_or_size(self, mgr):
"""Coarse-mtime same-second edits: size difference still busts it."""
m, config, secrets, template = mgr
m.load_config()
st = os.stat(config)
data = json.loads(config.read_text())
data["timezone"] = "America/New_York" # different byte length
config.write_text(json.dumps(data))
os.utime(config, (st.st_atime, st.st_mtime)) # force same mtime
assert m.load_config()["timezone"] == "America/New_York"
class TestSaveCoherence:
def test_save_config_then_load_returns_saved_data(self, mgr, monkeypatch):
m, config, secrets, template = mgr
m.load_config()
new = {"display": {"brightness": 42}, "timezone": "UTC",
"weather": {"api_key": "sek"}}
m.save_config(new)
counts = _count_opens(monkeypatch, {str(config), str(secrets), str(template)})
loaded = m.load_config()
assert loaded["display"]["brightness"] == 42
assert loaded["weather"]["api_key"] == "sek" # secrets survive in memory
assert counts["n"] == 0 # signature refreshed by save; no re-read
def test_cross_process_save_is_picked_up(self, mgr):
"""Another process writing config.json (different mtime) must bust
this process's fast path — the core cross-process guarantee."""
m, config, secrets, template = mgr
m.load_config()
other = ConfigManager(config_path=str(config), secrets_path=str(secrets))
other.template_path = str(template)
other.load_config()
other.save_config({"display": {"brightness": 11}, "timezone": "UTC",
"weather": {"api_key": "sek"}})
os.utime(config, (os.stat(config).st_atime, os.stat(config).st_mtime + 2))
assert m.load_config()["display"]["brightness"] == 11
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-v"]))
+8
View File
@@ -123,6 +123,14 @@ class TestPluginManager:
pm.run_scheduled_updates(current_time=time.time())
# Updates now execute on the background worker (the scheduler
# returns immediately) — wait for completion before asserting.
deadline = time.time() + 5
while (plugin_instance.update.call_count == 0
and time.time() < deadline):
time.sleep(0.02)
pm.stop_update_worker()
plugin_instance.update.assert_called_once()
assert "test_plugin" in pm.plugin_last_update
assert pm.state_manager.get_state("test_plugin") == PluginState.ENABLED
+45
View File
@@ -0,0 +1,45 @@
"""
Unit tests for src/plugin_system/testing/mocks.py.
MockCacheManager/MockPluginManager stand in for the real production
managers under the plugin safety harness -- a missing method here isn't a
harness bug in the abstract, it's a plugin silently failing to render
under test (confirmed on ledmatrix-leaderboard, which calls
get_cached_data_with_strategy() and previously hit an AttributeError that
its own broad except swallowed, producing an empty-but-green render).
"""
from src.plugin_system.testing.mocks import MockCacheManager
class TestMockCacheManagerStrategyMethod:
def test_get_cached_data_with_strategy_returns_cached_value(self):
cm = MockCacheManager()
cm.set("standings_nfl", {"teams": ["KC", "BUF"]})
result = cm.get_cached_data_with_strategy("standings_nfl", "sports_live")
assert result == {"teams": ["KC", "BUF"]}
def test_get_cached_data_with_strategy_returns_none_when_missing(self):
cm = MockCacheManager()
assert cm.get_cached_data_with_strategy("missing_key") is None
def test_get_cached_data_with_strategy_defaults_data_type(self):
cm = MockCacheManager()
cm.set("k", "v")
assert cm.get_cached_data_with_strategy("k") == "v"
def test_calls_are_tracked(self):
cm = MockCacheManager()
cm.get_cached_data_with_strategy("k", "sports_live")
assert cm.get_cached_data_with_strategy_calls == [{"key": "k", "data_type": "sports_live"}]
def test_save_cache_is_readable_via_strategy_lookup(self):
cm = MockCacheManager()
cm.save_cache("standings_nfl", {"teams": ["KC", "BUF"]})
assert cm.get_cached_data_with_strategy("standings_nfl") == {"teams": ["KC", "BUF"]}
def test_reset_clears_strategy_call_tracking(self):
cm = MockCacheManager()
cm.get_cached_data_with_strategy("k", "sports_live")
cm.reset()
assert cm.get_cached_data_with_strategy_calls == []
+92
View File
@@ -0,0 +1,92 @@
"""Tests for check_and_manage_ap_mode_with_state (src/wifi_manager.py).
The wifi monitor daemon used to fetch WiFi status before AND after each
check on top of the check's own internal fetch — every fetch is several
nmcli subprocess forks, every 30s, forever. The new API returns the state
the check observed, so the daemon runs exactly one fetch battery per tick.
"""
import os
import sys
from unittest.mock import MagicMock, patch
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from src.wifi_manager import WiFiManager, WiFiStatus # noqa: E402
@pytest.fixture
def wm(tmp_path):
with patch.object(WiFiManager, "_load_config", return_value={}, create=True):
manager = WiFiManager.__new__(WiFiManager)
# minimal attribute setup without running the real __init__
manager.config = {"auto_enable_ap_mode": True}
manager._disconnected_checks = 0
manager._disconnected_checks_required = 3
manager._ap_enabled_at = None
return manager
def _wire(wm, connected, ethernet, ap_active):
wm._get_wifi_status_with_retry = MagicMock(
return_value=WiFiStatus(connected=connected, ssid="net" if connected else None))
wm._is_ethernet_connected = MagicMock(return_value=ethernet)
wm._is_ap_mode_active = MagicMock(return_value=ap_active)
wm.enable_ap_mode = MagicMock(return_value=(True, "ok"))
wm.disable_ap_mode = MagicMock(return_value=(True, "ok"))
wm.scan_networks = MagicMock(return_value=([], False))
wm._save_cached_scan = MagicMock()
wm._FORCE_AP_FLAG_PATH = MagicMock()
wm._FORCE_AP_FLAG_PATH.exists.return_value = False
class TestWithState:
def test_single_fetch_per_call(self, wm):
_wire(wm, connected=True, ethernet=False, ap_active=False)
wm.check_and_manage_ap_mode_with_state()
assert wm._get_wifi_status_with_retry.call_count == 1
assert wm._is_ethernet_connected.call_count == 1
assert wm._is_ap_mode_active.call_count == 1
def test_returns_observed_state(self, wm):
_wire(wm, connected=True, ethernet=True, ap_active=False)
changed, status, ethernet, ap_after = wm.check_and_manage_ap_mode_with_state()
assert changed is False
assert status.connected is True
assert ethernet is True
assert ap_after is False
def test_ap_after_inverts_on_disable(self, wm):
"""WiFi reconnects while AP is up -> auto-disable -> ap_after False."""
_wire(wm, connected=True, ethernet=False, ap_active=True)
changed, _status, _ethernet, ap_after = wm.check_and_manage_ap_mode_with_state()
assert changed is True
assert ap_after is False
wm.disable_ap_mode.assert_called_once()
def test_ap_after_inverts_on_enable(self, wm):
"""Grace period exhausted with nothing connected -> enable -> True."""
_wire(wm, connected=False, ethernet=False, ap_active=False)
wm._disconnected_checks = 2 # this call is the 3rd
changed, _status, _ethernet, ap_after = wm.check_and_manage_ap_mode_with_state()
assert changed is True
assert ap_after is True
wm.enable_ap_mode.assert_called_once()
def test_bool_wrapper_is_back_compatible(self, wm):
_wire(wm, connected=True, ethernet=False, ap_active=False)
assert wm.check_and_manage_ap_mode() is False
_wire(wm, connected=True, ethernet=False, ap_active=True)
assert wm.check_and_manage_ap_mode() is True
def test_exception_path_never_raises(self, wm):
wm._get_wifi_status_with_retry = MagicMock(side_effect=RuntimeError("nmcli gone"))
changed, status, _ethernet, _ap_after = wm.check_and_manage_ap_mode_with_state()
assert changed is False
assert status.connected is False
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-v"]))
+847
View File
@@ -0,0 +1,847 @@
"""
Plugin Composer blueprint drag-and-drop plugin builder for LEDMatrix.
Routes:
GET /composer/ Composer page
POST /composer/api/generate Generate and return plugin ZIP
POST /composer/api/install Write plugin directly to plugins_dir
GET /composer/api/fonts/<name> Serve TTF font files for canvas rendering
GET /composer/api/validate-id/<id> Check if a plugin ID is already taken
"""
import ast
import io
import json
import logging
import re
import zipfile
from datetime import datetime
from pathlib import Path
import jinja2
import jsonschema
from flask import Blueprint, jsonify, render_template, request, send_file
logger = logging.getLogger(__name__)
composer_bp = Blueprint('composer', __name__)
# Module-level attributes injected by app.py at registration time
composer_bp.config_manager = None
composer_bp.plugin_manager = None
composer_bp.plugins_dir = None
composer_bp.project_root = None
# Fonts safe to serve to the browser for canvas rendering
_ALLOWED_FONTS = frozenset({'PressStart2P-Regular.ttf', '4x6-font.ttf', '5by7.regular.ttf'})
# Map composer font keys → DisplayManager attribute names
_FONT_ATTR_MAP = {
'press_start': 'regular_font',
'four_by_six': 'extra_small_font',
'five_by_seven': 'bdf_5x7_font',
}
# Font sizes in LED pixels (used to compute second-line Y offsets)
_FONT_SIZE_MAP = {
'press_start': 8,
'four_by_six': 6,
'five_by_seven': 7,
}
_PLUGIN_ID_RE = re.compile(r'^[a-z][a-z0-9-]{0,62}$')
_PYTHON_IDENT_RE = re.compile(r'^[a-zA-Z_][a-zA-Z0-9_]*$')
# ── Jinja2 environment (separate from Flask's; autoescape=False for code gen) ──
_jinja_env: jinja2.Environment | None = None
def _get_jinja_env() -> jinja2.Environment:
global _jinja_env
if _jinja_env is None:
template_dir = Path(__file__).parent.parent / 'templates' / 'v3' / 'composer'
_jinja_env = jinja2.Environment(
loader=jinja2.FileSystemLoader(str(template_dir)),
autoescape=False,
trim_blocks=True,
lstrip_blocks=True,
)
_jinja_env.filters['as_rgb'] = _as_rgb_filter
_jinja_env.filters['as_fill'] = _as_fill_filter
return _jinja_env
def _as_rgb_filter(val) -> str:
"""[r, g, b] → '(r, g, b)'"""
if val is None:
return 'None'
return f'({int(val[0])}, {int(val[1])}, {int(val[2])})'
def _as_fill_filter(val) -> str:
"""[r, g, b] or None → '(r, g, b)' or 'None'"""
if val is None:
return 'None'
return _as_rgb_filter(val)
# ── Helper functions ──────────────────────────────────────────────────────────
def _to_class_name(name: str) -> str:
"""'My Clock''MyClockPlugin' (avoids double-suffix if name already ends with Plugin)"""
words = re.sub(r'[^a-zA-Z0-9]', ' ', name).split()
base = ''.join(w.capitalize() for w in words)
return base if base.endswith('Plugin') else base + 'Plugin'
def _compute_pos_expr(val: int, anchor: str | None, dim_var: str) -> str:
"""Produce a Python expression string for an anchored or fixed position.
anchor=None/'left'/'top' fixed pixel value
anchor='center' dim_var // 2 ± offset
anchor='right'/'bottom' dim_var - offset
"""
if not anchor or anchor in ('left', 'top'):
return str(val)
if anchor in ('center', 'middle'):
if val == 0:
return f"{dim_var} // 2"
return f"{dim_var} // 2 + {val}" if val > 0 else f"{dim_var} // 2 - {abs(val)}"
if anchor in ('right', 'bottom'):
return dim_var if val == 0 else f"{dim_var} - {val}"
return str(val)
# Character widths in LED pixels per font (for text-alignment x offset math)
_FONT_CHAR_W = {
'press_start': 8,
'four_by_six': 4,
'five_by_seven': 5,
}
def _aligned_x_expr(x_base_expr: str, text_align: str, char_count: int, char_w: int) -> str:
"""Return Python x expression for text alignment.
left x_base_expr (no change)
center x_base_expr - half_text_width
right x_base_expr - text_width
"""
if text_align == 'left' or not text_align:
return x_base_expr
text_px = char_count * char_w
if text_align == 'center':
offset = text_px // 2
return f"({x_base_expr}) - {offset}" if offset else x_base_expr
if text_align == 'right':
return f"({x_base_expr}) - {text_px}" if text_px else x_base_expr
return x_base_expr
def _preprocess_elements(elements: list) -> list:
"""Expand raw element dicts into template-ready dicts with anchor expressions.
Invisible elements (visible=False) are excluded from generated code entirely.
"""
result = []
for el in elements:
# Skip hidden elements — they exist only in the preview
if el.get('visible') is False:
continue
p = dict(el)
t = el.get('type', '')
# Section elements are layer-list annotations only — no canvas output
if t == 'section':
continue
x_anchor = el.get('xAnchor') or None
y_anchor = el.get('yAnchor') or None
p['min_width'] = int(el.get('minWidth', 0) or 0)
if t in ('text', 'clock'):
font_key = el.get('font', 'press_start')
p['font_attr'] = _FONT_ATTR_MAP.get(font_key, 'regular_font')
p['rgb_tuple'] = f"({el.get('r', 255)}, {el.get('g', 255)}, {el.get('b', 255)})"
text_align = el.get('textAlign', 'left')
raw_x = el.get('x', 0)
x_base_expr = _compute_pos_expr(raw_x, x_anchor, 'width')
p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
font_size = _FONT_SIZE_MAP.get(font_key, 8)
char_w = _FONT_CHAR_W.get(font_key, 8)
line_spacing = int(el.get('lineSpacing', 2))
y_expr = p['y_expr']
p['y2_expr'] = f"({y_expr}) + {font_size + line_spacing}"
if t == 'text':
t1 = el.get('text', '') or ''
t2 = el.get('text2', '') or ''
p['text2'] = t2
# Detect {variable} tokens — generate format_map() call instead of literal
_var_re = re.compile(r'\{([a-zA-Z_]\w*)\}')
p['text_is_template'] = bool(_var_re.search(t1) or _var_re.search(t2))
ref_len = max(len(t1), len(t2)) if t2 else len(t1)
p['x_expr'] = _aligned_x_expr(x_base_expr, text_align, ref_len, char_w)
p['x2_expr'] = p['x_expr'] # second line uses same x
else: # clock
fmt1 = el.get('format', '%H:%M') or '%H:%M'
fmt2 = el.get('format2', '') or ''
p['format2'] = fmt2
ref_len = max(len(fmt1), len(fmt2)) if fmt2 else len(fmt1)
p['x_expr'] = _aligned_x_expr(x_base_expr, text_align, ref_len, char_w)
p['x2_expr'] = p['x_expr']
p['blink'] = bool(el.get('blink', False))
elif t == 'dynamic_text':
binding = el.get('binding', {})
p['binding_source'] = binding.get('source', 'config')
p['binding_key'] = binding.get('key', '')
p['binding_format'] = binding.get('format')
font_key = el.get('font', 'press_start')
p['font_attr'] = _FONT_ATTR_MAP.get(font_key, 'regular_font')
p['rgb_tuple'] = f"({el.get('r', 255)}, {el.get('g', 200)}, {el.get('b', 100)})"
x_base_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width')
p['x_expr'] = x_base_expr # dynamic text: runtime content determines width; use raw pos
p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
p['blink'] = bool(el.get('blink', False))
elif t == 'rectangle':
x_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width')
y_expr = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
w = el.get('width', 10)
h = el.get('height', 8)
p['x_expr'] = x_expr
p['y_expr'] = y_expr
# x2/y2 as runtime expressions to support anchored positions
p['x2_expr'] = f"({x_expr}) + {w}"
p['y2_expr'] = f"({y_expr}) + {h}"
fill = (
[el.get('fillR', 0), el.get('fillG', 0), el.get('fillB', 128)]
if el.get('hasFill', True) else None
)
outline = (
[el.get('outR', 255), el.get('outG', 255), el.get('outB', 255)]
if el.get('hasOutline', True) else None
)
p['fill_tuple'] = _as_fill_filter(fill)
p['outline_tuple'] = _as_fill_filter(outline)
p['blink'] = bool(el.get('blink', False))
elif t in ('line', 'divider'):
if t == 'divider':
orient = el.get('orientation', 'horizontal')
if orient == 'horizontal':
y_val = el.get('y', 16)
y_expr = _compute_pos_expr(y_val, y_anchor, 'height')
p.update(x0_expr='0', y0_expr=y_expr, x1_expr='width - 1', y1_expr=y_expr)
else:
x_val = el.get('x', 64)
x_expr = _compute_pos_expr(x_val, x_anchor, 'width')
p.update(x0_expr=x_expr, y0_expr='0', x1_expr=x_expr, y1_expr='height - 1')
else:
p['x0_expr'] = _compute_pos_expr(el.get('x0', 0), x_anchor, 'width')
p['y0_expr'] = _compute_pos_expr(el.get('y0', 0), y_anchor, 'height')
p['x1_expr'] = str(el.get('x1', 127))
p['y1_expr'] = str(el.get('y1', 0))
p['rgb_tuple'] = f"({el.get('r', 180)}, {el.get('g', 180)}, {el.get('b', 180)})"
p['line_width'] = el.get('lineWidth', 1)
p['blink'] = bool(el.get('blink', False))
elif t == 'progress_bar':
p['x_expr'] = _compute_pos_expr(el.get('x', 0), x_anchor, 'width')
p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
p['bar_width'] = int(el.get('barWidth', 40))
p['bar_height'] = int(el.get('barHeight', 6))
binding = el.get('binding', {})
p['binding_key'] = binding.get('key', '')
p['fill_tuple'] = f"({el.get('r', 100)}, {el.get('g', 200)}, {el.get('b', 100)})"
bg = (
[el.get('bgR', 30), el.get('bgG', 30), el.get('bgB', 30)]
if el.get('hasBg', True) else None
)
outline = (
[el.get('outR', 100), el.get('outG', 100), el.get('outB', 100)]
if el.get('hasOutline', True) else None
)
p['bg_tuple'] = _as_fill_filter(bg)
p['outline_tuple'] = _as_fill_filter(outline)
p['blink'] = bool(el.get('blink', False))
elif t == 'arc':
x_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width')
y_expr = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
w = el.get('width', 24)
h = el.get('height', 24)
p['x_expr'] = x_expr
p['y_expr'] = y_expr
p['x2_expr'] = f"({x_expr}) + {w}"
p['y2_expr'] = f"({y_expr}) + {h}"
p['start_angle'] = int(el.get('startAngle', 0))
p['end_angle'] = int(el.get('endAngle', 270))
p['line_width'] = max(1, int(el.get('lineWidth', 2)))
p['rgb_tuple'] = f"({el.get('r', 255)}, {el.get('g', 200)}, {el.get('b', 0)})"
p['blink'] = bool(el.get('blink', False))
elif t == 'ellipse':
x_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width')
y_expr = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
w = el.get('width', 24)
h = el.get('height', 12)
p['x_expr'] = x_expr
p['y_expr'] = y_expr
p['x2_expr'] = f"({x_expr}) + {w}"
p['y2_expr'] = f"({y_expr}) + {h}"
fill = (
[el.get('fillR', 0), el.get('fillG', 100), el.get('fillB', 200)]
if el.get('hasFill', True) else None
)
outline = (
[el.get('outR', 100), el.get('outG', 180), el.get('outB', 255)]
if el.get('hasOutline', True) else None
)
p['fill_tuple'] = _as_fill_filter(fill)
p['outline_tuple'] = _as_fill_filter(outline)
p['blink'] = bool(el.get('blink', False))
elif t == 'pixel':
p['x_expr'] = _compute_pos_expr(el.get('x', 0), x_anchor, 'width')
p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
p['rgb_tuple'] = f"({el.get('r', 255)}, {el.get('g', 255)}, {el.get('b', 255)})"
p['blink'] = bool(el.get('blink', False))
elif t == 'rounded_rectangle':
x_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width')
y_expr = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
w = el.get('width', 24)
h = el.get('height', 10)
p['x_expr'] = x_expr
p['y_expr'] = y_expr
p['x2_expr'] = f"({x_expr}) + {w}"
p['y2_expr'] = f"({y_expr}) + {h}"
p['border_radius'] = int(el.get('borderRadius', 3))
fill = (
[el.get('fillR', 0), el.get('fillG', 80), el.get('fillB', 180)]
if el.get('hasFill', True) else None
)
outline = (
[el.get('outR', 120), el.get('outG', 180), el.get('outB', 255)]
if el.get('hasOutline', True) else None
)
p['fill_tuple'] = _as_fill_filter(fill)
p['outline_tuple'] = _as_fill_filter(outline)
p['blink'] = bool(el.get('blink', False))
elif t == 'countdown':
font_key = el.get('font', 'four_by_six')
p['font_attr'] = _FONT_ATTR_MAP.get(font_key, 'extra_small_font')
p['rgb_tuple'] = f"({el.get('r', 255)}, {el.get('g', 180)}, {el.get('b', 0)})"
binding = el.get('binding', {})
p['binding_key'] = binding.get('key', '')
p['countdown_format'] = el.get('countdownFormat', 'dh')
x_base_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width')
p['x_expr'] = x_base_expr
p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
p['blink'] = bool(el.get('blink', False))
elif t == 'pips':
p['x_expr'] = _compute_pos_expr(el.get('x', 0), x_anchor, 'width')
p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
p['pip_count'] = max(1, int(el.get('count', 5)))
p['pip_size'] = max(1, int(el.get('pipSize', 4)))
p['pip_spacing'] = max(0, int(el.get('pipSpacing', 2)))
p['show_empty'] = bool(el.get('showEmpty', True))
binding = el.get('binding', {})
p['binding_key'] = binding.get('key', '')
p['fill_tuple'] = f"({el.get('r', 255)}, {el.get('g', 200)}, {el.get('b', 0)})"
p['empty_tuple'] = f"({el.get('emptyR', 50)}, {el.get('emptyG', 50)}, {el.get('emptyB', 50)})"
p['blink'] = bool(el.get('blink', False))
elif t == 'sparkline':
x_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width')
y_expr = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
p['x_expr'] = x_expr
p['y_expr'] = y_expr
p['bar_width_px'] = int(el.get('width', 40))
p['bar_height_px'] = int(el.get('height', 12))
p['bar_count'] = max(1, int(el.get('barCount', 8)))
p['bar_spacing'] = max(0, int(el.get('barSpacing', 1)))
binding = el.get('binding', {})
p['binding_key'] = binding.get('key', '')
p['fill_tuple'] = f"({el.get('r', 80)}, {el.get('g', 200)}, {el.get('b', 120)})"
bg = [el.get('bgR', 30), el.get('bgG', 30), el.get('bgB', 30)] if el.get('hasBg', False) else None
p['bg_tuple'] = _as_fill_filter(bg)
p['blink'] = bool(el.get('blink', False))
elif t == 'gauge':
x_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width')
y_expr = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
w = el.get('width', 32)
h = el.get('height', 32)
p['x_expr'] = x_expr
p['y_expr'] = y_expr
p['x2_expr'] = f"({x_expr}) + {w}"
p['y2_expr'] = f"({y_expr}) + {h}"
p['start_angle'] = int(el.get('startAngle', 135))
p['end_angle'] = int(el.get('endAngle', 45))
p['line_width'] = max(1, int(el.get('lineWidth', 3)))
p['rgb_tuple'] = f"({el.get('r', 80)}, {el.get('g', 220)}, {el.get('b', 80)})"
track = (
[el.get('trackR', 40), el.get('trackG', 40), el.get('trackB', 40)]
if el.get('hasTrack', True) else None
)
p['track_tuple'] = _as_fill_filter(track)
binding = el.get('binding', {})
p['binding_key'] = binding.get('key', '')
font_key = el.get('font', 'four_by_six')
p['font_attr'] = _FONT_ATTR_MAP.get(font_key, 'extra_small_font')
p['show_label'] = bool(el.get('showLabel', True))
p['label_tuple'] = f"({el.get('labelR', 200)}, {el.get('labelG', 200)}, {el.get('labelB', 200)})"
p['blink'] = bool(el.get('blink', False))
elif t == 'marquee':
font_key = el.get('font', 'press_start')
p['font_attr'] = _FONT_ATTR_MAP.get(font_key, 'regular_font')
p['rgb_tuple'] = f"({el.get('r', 255)}, {el.get('g', 255)}, {el.get('b', 255)})"
p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
p['text'] = el.get('text', 'Scrolling text')
p['char_w'] = _FONT_CHAR_W.get(font_key, 8)
p['gap'] = int(el.get('gap', 16))
p['scroll_speed'] = max(1, int(el.get('scrollSpeed', 1)))
p['direction'] = el.get('direction', 'left')
# Data key stored in self._data for stateful scrolling across display() calls
raw_id = str(el.get('id', 0)).replace('-', '_')
p['data_key'] = f"mq_{raw_id}"
p['blink'] = bool(el.get('blink', False))
result.append(p)
return result
def _generate_plugin_files(data: dict) -> dict:
"""
Generate all plugin file contents as strings.
Returns dict: {'manager.py', 'manifest.json', 'config_schema.json', 'requirements.txt'}
Raises ValueError with a human-readable message on any validation failure.
"""
metadata = data.get('metadata', {})
elements = data.get('elements', [])
data_model = data.get('dataModel', {})
config_vars = data_model.get('configVars', [])
plugin_id = metadata.get('id', '').strip()
if not _PLUGIN_ID_RE.match(plugin_id):
raise ValueError(
'Plugin ID must start with a lowercase letter and contain only '
'lowercase letters, numbers, and hyphens (max 63 chars).'
)
plugin_name = metadata.get('name', '').strip()
if not plugin_name:
raise ValueError('Plugin name is required.')
author = metadata.get('author', '').strip()
if not author:
raise ValueError('Author is required.')
version = metadata.get('version', '1.0.0').strip()
# Validate config var keys are valid Python identifiers
for cv in config_vars:
key = cv.get('key', '')
if not _PYTHON_IDENT_RE.match(key):
raise ValueError(f'Config variable key "{key}" is not a valid Python identifier.')
class_name = _to_class_name(plugin_name)
# Only consider visible elements for code generation flags
visible_elements = [e for e in elements if e.get('visible') is not False]
processed = _preprocess_elements(elements)
has_clock = any(e.get('type') == 'clock' for e in visible_elements)
has_blink = any(e.get('blink') for e in visible_elements)
has_countdown = any(e.get('type') == 'countdown' for e in visible_elements)
_var_re = re.compile(r'\{[a-zA-Z_]\w*\}')
has_text_template = any(
e.get('type') == 'text' and (
_var_re.search(e.get('text', '') or '') or
_var_re.search(e.get('text2', '') or '')
)
for e in visible_elements
)
# Background fill color (None → don't render, use LED panel's native black)
bg_color: str | None = None
bg_raw = metadata.get('bgColor')
if isinstance(bg_raw, dict):
r, g, b = int(bg_raw.get('r', 0)), int(bg_raw.get('g', 0)), int(bg_raw.get('b', 0))
if r or g or b:
bg_color = f'({r}, {g}, {b})'
# Render manager.py
env = _get_jinja_env()
try:
tmpl = env.get_template('manager.py.j2')
except jinja2.TemplateNotFound:
raise ValueError('Code generation template not found. This is a server configuration issue.')
manager_py = tmpl.render(
plugin_name=plugin_name,
class_name=class_name,
plugin_id=plugin_id,
generated_date=datetime.now().strftime('%Y-%m-%d'),
config_vars=config_vars,
elements=processed,
has_clock=has_clock,
has_blink=has_blink,
has_countdown=has_countdown,
has_text_template=has_text_template,
bg_color=bg_color,
)
# Syntax-check the generated Python
try:
ast.parse(manager_py)
except SyntaxError as exc:
raise ValueError(f'Generated code has a syntax error: {exc}') from exc
# Build manifest
manifest = {
'id': plugin_id,
'name': plugin_name,
'version': version,
'author': author,
'description': metadata.get('description', 'Custom plugin created with LEDMatrix Plugin Composer'),
'category': metadata.get('category', 'custom'),
'tags': ['composer', 'custom'],
'entry_point': 'manager.py',
'class_name': class_name,
'display_modes': [plugin_id],
'compatible_versions': ['>=2.0.0'],
'last_updated': datetime.now().strftime('%Y-%m-%d'),
'update_interval': int(metadata.get('update_interval', 60)),
'default_duration': float(metadata.get('display_duration', 15)),
'versions': [
{'released': datetime.now().strftime('%Y-%m-%d'), 'version': version}
],
}
# Validate manifest against the project's schema
if composer_bp.project_root:
schema_path = Path(composer_bp.project_root) / 'schema' / 'manifest_schema.json'
if schema_path.exists():
schema = json.loads(schema_path.read_text())
validator = jsonschema.Draft7Validator(schema)
errors = list(validator.iter_errors(manifest))
if errors:
msgs = '; '.join(e.message for e in errors[:3])
raise ValueError(f'Manifest validation failed: {msgs}')
# Build config_schema
type_map = {
'string': {'type': 'string'},
'number': {'type': 'number', 'minimum': 0},
'boolean': {'type': 'boolean'},
'color': {
'type': 'array',
'items': {'type': 'integer', 'minimum': 0, 'maximum': 255},
'minItems': 3,
'maxItems': 3,
},
}
config_properties = {
'enabled': {'type': 'boolean', 'default': True},
'display_duration': {'type': 'number', 'minimum': 1, 'default': float(metadata.get('display_duration', 15))},
}
for cv in config_vars:
cv_type = cv.get('type', 'string')
prop = dict(type_map.get(cv_type, {'type': 'string'}))
if cv.get('description'):
prop['description'] = cv['description']
if cv.get('label'):
prop['title'] = cv['label']
default = cv.get('default', '')
if cv_type == 'number':
try:
prop['default'] = float(default) if default != '' else 0
except (TypeError, ValueError):
prop['default'] = 0
elif cv_type == 'boolean':
prop['default'] = bool(default)
else:
prop['default'] = default
config_properties[cv['key']] = prop
config_schema = {
'$schema': 'http://json-schema.org/draft-07/schema#',
'type': 'object',
'properties': config_properties,
}
return {
'manager.py': manager_py,
'manifest.json': json.dumps(manifest, indent=2),
'config_schema.json': json.dumps(config_schema, indent=2),
'requirements.txt': '',
}
def _save_composer_state(target_dir: Path, payload: dict) -> None:
"""Persist the raw composer payload alongside the generated plugin files."""
(target_dir / '_composer_state.json').write_text(
json.dumps(payload, indent=2, ensure_ascii=False), encoding='utf-8'
)
def _pack_zip(files: dict, plugin_id: str) -> io.BytesIO:
"""Pack generated plugin files into an in-memory ZIP."""
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', compression=zipfile.ZIP_DEFLATED) as zf:
for filename, content in files.items():
info = zipfile.ZipInfo(f'{plugin_id}/{filename}')
info.compress_type = zipfile.ZIP_DEFLATED
zf.writestr(info, content.encode('utf-8') if isinstance(content, str) else content)
buf.seek(0)
return buf
# ── Routes ────────────────────────────────────────────────────────────────────
@composer_bp.route('/')
def index():
return render_template('v3/composer.html')
@composer_bp.route('/api/generate', methods=['POST'])
def generate_zip():
data = request.get_json(force=True, silent=True)
if not data:
return jsonify({'status': 'error', 'message': 'No JSON body'}), 400
try:
files = _generate_plugin_files(data)
except ValueError as exc:
return jsonify({'status': 'error', 'message': str(exc)}), 422
plugin_id = data.get('metadata', {}).get('id', 'plugin')
files['_composer_state.json'] = json.dumps(data, indent=2, ensure_ascii=False)
zip_buf = _pack_zip(files, plugin_id)
return send_file(
zip_buf,
mimetype='application/zip',
as_attachment=True,
download_name=f'{plugin_id}.zip',
)
@composer_bp.route('/api/install', methods=['POST'])
def install_locally():
if not composer_bp.plugins_dir:
return jsonify({'status': 'error', 'message': 'Plugin directory not configured'}), 503
data = request.get_json(force=True, silent=True)
if not data:
return jsonify({'status': 'error', 'message': 'No JSON body'}), 400
try:
files = _generate_plugin_files(data)
except ValueError as exc:
return jsonify({'status': 'error', 'message': str(exc)}), 422
plugin_id = data.get('metadata', {}).get('id', '')
# _generate_plugin_files() above already validates metadata.id via this
# same regex before it will return, but that guarantee lives in a
# different function -- re-check here, at the point the path is actually
# built, so this route stays safe on its own if that call is ever
# reordered or changed.
if not _PLUGIN_ID_RE.match(plugin_id):
return jsonify({'status': 'error', 'message': 'Invalid plugin ID'}), 400
target = Path(composer_bp.plugins_dir) / plugin_id
force = bool(data.get('_force', False))
if target.exists() and not force:
return jsonify({
'status': 'conflict',
'message': f'Plugin "{plugin_id}" is already installed.',
}), 409
try:
if target.exists() and force:
import shutil as _shutil
_shutil.rmtree(target)
target.mkdir(parents=True, exist_ok=False)
for filename, content in files.items():
(target / filename).write_text(content, encoding='utf-8')
_save_composer_state(target, data)
except OSError as exc:
logger.error('Failed to write plugin files for %s: %s', plugin_id, exc)
return jsonify({'status': 'error', 'message': 'Failed to write plugin files'}), 500
# Trigger plugin discovery so it shows up in the Plugin Manager immediately
if composer_bp.plugin_manager:
try:
composer_bp.plugin_manager.discover_plugins()
except Exception as exc:
logger.warning('discover_plugins() failed after composer install: %s', exc)
return jsonify({
'status': 'success',
'message': f'Plugin "{plugin_id}" installed successfully.',
'plugin_id': plugin_id,
})
@composer_bp.route('/api/fonts/<font_name>')
def serve_font(font_name):
"""Serve an allowlisted font file for canvas FontFace loading."""
if font_name not in _ALLOWED_FONTS:
return '', 404
if not composer_bp.project_root:
return '', 503
font_path = Path(composer_bp.project_root) / 'assets' / 'fonts' / font_name
if not font_path.exists():
return '', 404
return send_file(str(font_path), mimetype='font/ttf')
@composer_bp.route('/api/validate-id/<plugin_id>')
def validate_id(plugin_id):
"""Check whether a plugin ID is valid and available."""
if not _PLUGIN_ID_RE.match(plugin_id):
return jsonify({'valid': False, 'available': False, 'reason': 'Invalid format'})
if composer_bp.plugins_dir:
taken = (Path(composer_bp.plugins_dir) / plugin_id).exists()
if taken:
return jsonify({'valid': True, 'available': False, 'reason': 'Already installed'})
return jsonify({'valid': True, 'available': True})
@composer_bp.route('/api/plugins')
def list_plugins():
"""List installed plugins, flagging which ones have a saved composer state."""
if not composer_bp.plugins_dir:
return jsonify([])
plugins_dir = Path(composer_bp.plugins_dir)
results = []
for entry in sorted(plugins_dir.iterdir()):
if not entry.is_dir():
continue
manifest_path = entry / 'manifest.json'
if not manifest_path.exists():
continue
try:
manifest = json.loads(manifest_path.read_text())
except Exception as e:
logger.warning("Skipping %s: unreadable manifest.json (%s)", entry.name, e)
continue
has_state = (entry / '_composer_state.json').exists()
results.append({
'id': manifest.get('id', entry.name),
'name': manifest.get('name', entry.name),
'version': manifest.get('version', ''),
'author': manifest.get('author', ''),
'has_composer_state': has_state,
})
return jsonify(results)
@composer_bp.route('/api/preview', methods=['POST'])
def preview_code():
"""Generate plugin files and return them as JSON for the code preview modal."""
data = request.get_json(force=True, silent=True)
if not data:
return jsonify({'status': 'error', 'message': 'No JSON body'}), 400
try:
files = _generate_plugin_files(data)
except ValueError as exc:
return jsonify({'status': 'error', 'message': str(exc)}), 422
return jsonify({
'status': 'ok',
'files': {
'manager.py': files['manager.py'],
'manifest.json': files['manifest.json'],
'config_schema.json': files['config_schema.json'],
},
})
@composer_bp.route('/api/load/<plugin_id>')
def load_plugin(plugin_id):
"""Load a plugin's composer state for editing.
If a _composer_state.json exists, return it verbatim.
Otherwise, extract config vars from config_schema.json for a partial import.
"""
if not composer_bp.plugins_dir:
return jsonify({'status': 'error', 'message': 'Plugin directory not configured'}), 503
if not _PLUGIN_ID_RE.match(plugin_id):
return jsonify({'status': 'error', 'message': 'Invalid plugin ID'}), 400
plugin_dir = Path(composer_bp.plugins_dir) / plugin_id
if not plugin_dir.exists():
return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404
# Full composer state
state_path = plugin_dir / '_composer_state.json'
if state_path.exists():
try:
state = json.loads(state_path.read_text())
return jsonify({'status': 'ok', 'source': 'composer', 'state': state})
except Exception as exc:
logger.error('Failed to read composer state for %s: %s', plugin_id, exc)
return jsonify({'status': 'error', 'message': 'Failed to read state'}), 500
# Partial import from config_schema.json
schema_path = plugin_dir / 'config_schema.json'
manifest_path = plugin_dir / 'manifest.json'
config_vars = []
if schema_path.exists():
try:
schema = json.loads(schema_path.read_text())
props = schema.get('properties', {})
skip = {'enabled', 'display_duration', 'update_interval'}
type_map = {'boolean': 'boolean', 'number': 'number', 'integer': 'number', 'string': 'string'}
for key, prop in props.items():
if key in skip:
continue
prop_type = prop.get('type', 'string')
if isinstance(prop_type, list):
prop_type = next((t for t in prop_type if t != 'null'), 'string')
# Detect color arrays
if prop_type == 'array' and prop.get('maxItems') == 3:
cv_type = 'color'
else:
cv_type = type_map.get(prop_type, 'string')
config_vars.append({
'key': key,
'label': prop.get('title', key.replace('_', ' ').title()),
'type': cv_type,
'default': prop.get('default', ''),
'description': prop.get('description', ''),
})
except Exception as e:
logger.warning("Failed to parse config_schema.json for %s: %s", plugin_id, e)
manifest = {}
if manifest_path.exists():
try:
manifest = json.loads(manifest_path.read_text())
except Exception:
pass
partial_state = {
'composer_version': '1.0',
'metadata': {
'id': manifest.get('id', plugin_id),
'name': manifest.get('name', plugin_id),
'author': manifest.get('author', ''),
'version': manifest.get('version', '1.0.0'),
'description': manifest.get('description', ''),
'category': manifest.get('category', 'custom'),
'display_duration': manifest.get('default_duration', 15),
'update_interval': manifest.get('update_interval', 60),
'api_requirements': manifest.get('api_requirements', []),
},
'elements': [],
'dataModel': {'configVars': config_vars, 'dataSources': [], 'computedVars': []},
}
return jsonify({'status': 'ok', 'source': 'schema_import', 'state': partial_state})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,757 @@
/**
* ComposerCanvas stateless LED matrix canvas renderer.
*
* Coordinate system: LED pixels (integers). All drawing multiplies by SCALE.
* PIL draw.text(x,y) is top-left; canvas fillText(x,y) is baseline.
* Canvas text cy = (actualY + fontSizePx) * SCALE
*
* Anchors: element x/y are offsets from their anchor point:
* xAnchor=null/'left' x is fixed offset from left
* xAnchor='center' x is offset from width/2
* xAnchor='right' x is offset inward from right edge
* yAnchor follows the same pattern with 'top'/'middle'/'bottom'
*
* Breakpoints: elements with minWidth > currentMatrixW are rendered at 25% opacity.
*
* Resize handles: drawn on selected rectangles; 8 handles (corners + edge mids).
*/
window.ComposerCanvas = (() => {
'use strict';
let _canvas = null;
let _ctx = null;
let _showGrid = true;
const DISPLAY_PRESETS = [
{ label: '64×32', w: 64, h: 32 },
{ label: '128×32', w: 128, h: 32 },
{ label: '128×64', w: 128, h: 64 },
{ label: '256×32', w: 256, h: 32 },
{ label: '256×64', w: 256, h: 64 },
];
const FONT_MAP = {
press_start: { family: "'PressStart2P', monospace", sizePx: 8, charW: 8 },
four_by_six: { family: 'monospace', sizePx: 6, charW: 4 },
five_by_seven: { family: 'monospace', sizePx: 7, charW: 5 },
};
const ELEMENT_DEFAULTS = {
text: {
text: 'Hello', font: 'press_start',
r: 255, g: 255, b: 255,
text2: '', lineSpacing: 2, textAlign: 'left',
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
dynamic_text: {
binding: { source: 'config', key: '', format: null },
font: 'press_start', textAlign: 'left',
r: 255, g: 200, b: 100,
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
clock: {
format: '%H:%M', font: 'press_start',
r: 100, g: 255, b: 100,
format2: '', lineSpacing: 2, textAlign: 'left',
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
rectangle: {
width: 20, height: 8,
fillR: 0, fillG: 0, fillB: 128, hasFill: true,
outR: 255, outG: 255, outB: 255, hasOutline: true,
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
ellipse: {
width: 24, height: 12,
fillR: 0, fillG: 100, fillB: 200, hasFill: true,
outR: 100, outG: 180, outB: 255, hasOutline: true,
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
arc: {
width: 24, height: 24,
startAngle: 0, endAngle: 270, lineWidth: 2,
r: 255, g: 200, b: 0,
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
pixel: {
r: 255, g: 255, b: 255,
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
rounded_rectangle: {
width: 24, height: 10, borderRadius: 3,
fillR: 0, fillG: 80, fillB: 180, hasFill: true,
outR: 120, outG: 180, outB: 255, hasOutline: true,
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
line: {
x0: 0, y0: 16, x1: 63, y1: 16,
r: 180, g: 180, b: 180, lineWidth: 1,
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
divider: {
orientation: 'horizontal', y: 16, x: 64,
r: 100, g: 100, b: 100,
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
progress_bar: {
barWidth: 60, barHeight: 6,
binding: { source: 'config', key: '', format: null },
r: 80, g: 200, b: 80,
bgR: 30, bgG: 30, bgB: 30, hasBg: true,
outR: 100, outG: 100, outB: 100, hasOutline: true,
previewPct: 65,
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
countdown: {
binding: { source: 'config', key: '', format: null },
countdownFormat: 'dh',
font: 'four_by_six', textAlign: 'left',
r: 255, g: 180, b: 0,
previewText: '42d 3h',
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
marquee: {
text: 'Scrolling text', font: 'press_start',
r: 255, g: 255, b: 255,
scrollSpeed: 1, gap: 16, direction: 'left',
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
section: {
label: 'Section',
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
pips: {
count: 5, filled: 3, pipSize: 4, pipSpacing: 2,
r: 255, g: 200, b: 0,
emptyR: 50, emptyG: 50, emptyB: 50, showEmpty: true,
binding: { source: 'config', key: '', format: null },
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
sparkline: {
width: 40, height: 12,
barCount: 8, barSpacing: 1,
r: 80, g: 200, b: 120,
bgR: 30, bgG: 30, bgB: 30, hasBg: false,
binding: { source: 'config', key: '', format: null },
previewData: '0.3,0.6,0.4,0.8,0.5,0.9,0.7,0.85',
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
gauge: {
width: 32, height: 32,
startAngle: 135, endAngle: 45, lineWidth: 3,
binding: { source: 'config', key: '', format: null },
r: 80, g: 220, b: 80,
trackR: 40, trackG: 40, trackB: 40, hasTrack: true,
showLabel: true, font: 'four_by_six', labelR: 200, labelG: 200, labelB: 200,
previewPct: 65,
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
};
// ── Anchor resolution ────────────────────────────────────────────────
function resolveAnchor(val, anchor, dim) {
if (!anchor || anchor === 'left' || anchor === 'top') return val;
if (anchor === 'center' || anchor === 'middle') return Math.floor(dim / 2) + val;
if (anchor === 'right' || anchor === 'bottom') return dim - val;
return val;
}
function computeActualPos(el, matrixW, matrixH) {
const ax = resolveAnchor(el.x ?? el.x0 ?? 0, el.xAnchor, matrixW);
const ay = resolveAnchor(el.y ?? el.y0 ?? 0, el.yAnchor, matrixH);
return { x: ax, y: ay };
}
// ── Bounding box (LED pixel space) ──────────────────────────────────
function getBoundingBox(el, matrixW, matrixH) {
const { x: ax, y: ay } = computeActualPos(el, matrixW, matrixH);
const finfo = FONT_MAP[el.font] || FONT_MAP.press_start;
switch (el.type) {
case 'text': {
const t1 = el.text || '', t2 = el.text2 || '';
const w = Math.max(t1.length, t2.length) * finfo.charW;
const h = t2 ? finfo.sizePx * 2 + (el.lineSpacing ?? 2) : finfo.sizePx;
const bx = el.textAlign === 'center' ? ax - w / 2 : el.textAlign === 'right' ? ax - w : ax;
return { x: bx, y: ay, w, h };
}
case 'dynamic_text': {
const key = el.binding?.key || '?';
const w = (`{${key}}`).length * finfo.charW;
const bx = el.textAlign === 'center' ? ax - w / 2 : el.textAlign === 'right' ? ax - w : ax;
return { x: bx, y: ay, w, h: finfo.sizePx };
}
case 'clock': {
const t1 = el.format || '%H:%M', t2 = el.format2 || '';
const w = Math.max(t1.length, t2.length) * finfo.charW;
const h = t2 ? finfo.sizePx * 2 + (el.lineSpacing ?? 2) : finfo.sizePx;
const bx = el.textAlign === 'center' ? ax - w / 2 : el.textAlign === 'right' ? ax - w : ax;
return { x: bx, y: ay, w, h };
}
case 'countdown': {
const pt = el.previewText || '--d --h';
const w = pt.length * finfo.charW;
const bx = el.textAlign === 'center' ? ax - w / 2 : el.textAlign === 'right' ? ax - w : ax;
return { x: bx, y: ay, w, h: finfo.sizePx };
}
case 'rectangle':
case 'rounded_rectangle':
case 'ellipse':
case 'arc':
return { x: ax, y: ay, w: el.width, h: el.height };
case 'pixel':
return { x: ax, y: ay, w: 1, h: 1 };
case 'line':
return {
x: Math.min(el.x0, el.x1), y: Math.min(el.y0, el.y1),
w: Math.max(1, Math.abs(el.x1 - el.x0)),
h: Math.max(1, Math.abs(el.y1 - el.y0)),
};
case 'divider':
return el.orientation === 'horizontal'
? { x: 0, y: ay, w: matrixW, h: 1 }
: { x: ax, y: 0, w: 1, h: matrixH };
case 'progress_bar':
return { x: ax, y: ay, w: el.barWidth ?? 60, h: el.barHeight ?? 6 };
case 'marquee': {
const mfinfo = FONT_MAP[el.font] || FONT_MAP.press_start;
return { x: 0, y: ay, w: matrixW, h: mfinfo.sizePx };
}
case 'gauge':
return { x: ax, y: ay, w: el.width ?? 32, h: el.height ?? 32 };
case 'sparkline':
return { x: ax, y: ay, w: el.width ?? 40, h: el.height ?? 12 };
case 'pips': {
const pc = el.count ?? 5, ps = el.pipSize ?? 4, pg = el.pipSpacing ?? 2;
return { x: ax, y: ay, w: pc * ps + (pc - 1) * pg, h: ps };
}
case 'section':
return { x: ax, y: ay, w: 0, h: 0 };
default:
return { x: ax, y: ay, w: 4, h: 4 };
}
}
// ── Resize handle support ─────────────────────────────────────────────
// Returns 8 handle points for a rectangle in LED pixel space
function _getRectHandles(el, matrixW, matrixH) {
const { x: ax, y: ay } = computeActualPos(el, matrixW, matrixH);
const w = el.width, h = el.height;
const cx = ax + w / 2, cy = ay + h / 2;
return {
nw: { x: ax, y: ay },
n: { x: cx, y: ay },
ne: { x: ax + w, y: ay },
w: { x: ax, y: cy },
e: { x: ax + w, y: cy },
sw: { x: ax, y: ay + h },
s: { x: cx, y: ay + h },
se: { x: ax + w, y: ay + h },
};
}
// Returns the handle direction under LED-space point (lx, ly), or null
function getResizeHandle(el, lx, ly, matrixW, matrixH) {
if (!['rectangle', 'rounded_rectangle', 'ellipse', 'arc', 'gauge', 'sparkline'].includes(el.type)) return null;
const handles = _getRectHandles(el, matrixW, matrixH);
const PAD = 4;
for (const [dir, pt] of Object.entries(handles)) {
if (Math.abs(lx - pt.x) <= PAD && Math.abs(ly - pt.y) <= PAD) return dir;
}
return null;
}
const _HANDLE_CURSORS = {
nw: 'nw-resize', n: 'n-resize', ne: 'ne-resize',
w: 'w-resize', e: 'e-resize',
sw: 'sw-resize', s: 's-resize', se: 'se-resize',
};
function getCursorForHandle(handle) {
return _HANDLE_CURSORS[handle] || 'crosshair';
}
// ── Hit test ─────────────────────────────────────────────────────────
function hitTest(el, lx, ly, matrixW, matrixH) {
const PAD = 3;
const bb = getBoundingBox(el, matrixW, matrixH);
return (
lx >= bb.x - PAD && lx <= bb.x + bb.w + PAD &&
ly >= bb.y - PAD && ly <= bb.y + bb.h + PAD
);
}
// ── Draw a single element ─────────────────────────────────────────────
function _drawElement(ctx, el, SCALE, matrixW, matrixH, opts = {}) {
const s = SCALE;
const { x: ax, y: ay } = computeActualPos(el, matrixW, matrixH);
const belowBreakpoint = el.minWidth > 0 && matrixW < el.minWidth;
const hidden = el.visible === false;
ctx.save();
if (hidden) ctx.globalAlpha = 0.12;
else if (belowBreakpoint) ctx.globalAlpha = 0.25;
// Blink animation: when blinkOff, fully hide blinking elements
if (el.blink) {
if (opts.blinkOff) { ctx.restore(); return; }
ctx.globalAlpha *= 0.55;
}
// Helper: compute draw X for text alignment
const _textX = (text, finfo) => {
const tw = text.length * finfo.charW * s;
if (el.textAlign === 'center') return ax * s - tw / 2;
if (el.textAlign === 'right') return ax * s - tw;
return ax * s;
};
try {
switch (el.type) {
case 'text':
case 'dynamic_text':
case 'clock': {
const finfo = FONT_MAP[el.font] || FONT_MAP.press_start;
const key = el.binding?.key || '?';
const pv = opts.previewValues?.[key];
// Substitute {variable} tokens in text using previewValues
const _subVars = str => (str || '').replace(/\{(\w+)\}/g, (_, k) => {
const v = opts.previewValues?.[k];
return v !== undefined && v !== '' ? String(v) : `{${k}}`;
});
const displayText =
el.type === 'text' ? _subVars(el.text || '')
: el.type === 'clock' ? (el.format || '%H:%M')
: (pv !== undefined && pv !== '' ? String(pv) : `{${key}}`);
ctx.font = `${finfo.sizePx * s}px ${finfo.family}`;
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
ctx.fillText(displayText, _textX(displayText, finfo), (ay + finfo.sizePx) * s);
// Second line (text and clock)
if (el.type === 'text' && el.text2) {
const t2 = _subVars(el.text2);
const y2 = ay + finfo.sizePx + (el.lineSpacing ?? 2);
ctx.fillText(t2, _textX(t2, finfo), (y2 + finfo.sizePx) * s);
}
if (el.type === 'clock' && el.format2) {
const y2 = ay + finfo.sizePx + (el.lineSpacing ?? 2);
ctx.fillText(el.format2, _textX(el.format2, finfo), (y2 + finfo.sizePx) * s);
}
break;
}
case 'countdown': {
const finfo = FONT_MAP[el.font] || FONT_MAP.press_start;
const t = el.previewText || '--d --h';
ctx.font = `${finfo.sizePx * s}px ${finfo.family}`;
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
ctx.fillText(t, _textX(t, finfo), (ay + finfo.sizePx) * s);
break;
}
case 'rectangle': {
const rx = ax * s, ry = ay * s;
const rw = el.width * s, rh = el.height * s;
if (el.hasFill) {
ctx.fillStyle = `rgb(${el.fillR},${el.fillG},${el.fillB})`;
ctx.fillRect(rx, ry, rw, rh);
}
if (el.hasOutline) {
ctx.strokeStyle = `rgb(${el.outR},${el.outG},${el.outB})`;
ctx.lineWidth = 1;
ctx.strokeRect(rx, ry, rw, rh);
}
break;
}
case 'ellipse': {
const cx = (ax + el.width / 2) * s;
const cy = (ay + el.height / 2) * s;
const rx = (el.width / 2) * s;
const ry = (el.height / 2) * s;
ctx.beginPath();
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
if (el.hasFill) {
ctx.fillStyle = `rgb(${el.fillR},${el.fillG},${el.fillB})`;
ctx.fill();
}
if (el.hasOutline) {
ctx.strokeStyle = `rgb(${el.outR},${el.outG},${el.outB})`;
ctx.lineWidth = 1;
ctx.stroke();
}
break;
}
case 'arc': {
const cx = (ax + el.width / 2) * s;
const cy = (ay + el.height / 2) * s;
const rx = (el.width / 2) * s;
const ry = (el.height / 2) * s;
// PIL: 0°=right, clockwise. Canvas: same with anticlockwise=false
const startRad = (el.startAngle ?? 0) * Math.PI / 180;
const endRad = (el.endAngle ?? 270) * Math.PI / 180;
ctx.beginPath();
ctx.ellipse(cx, cy, rx, ry, 0, startRad, endRad, false);
ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`;
ctx.lineWidth = Math.max(1, el.lineWidth || 2);
ctx.stroke();
break;
}
case 'pixel': {
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
ctx.fillRect(ax * s, ay * s, s, s);
break;
}
case 'rounded_rectangle': {
const rx = ax * s, ry = ay * s;
const rw = el.width * s, rh = el.height * s;
const rad = Math.min((el.borderRadius ?? 3) * s, rw / 2, rh / 2);
ctx.beginPath();
ctx.roundRect(rx, ry, rw, rh, rad);
if (el.hasFill) {
ctx.fillStyle = `rgb(${el.fillR},${el.fillG},${el.fillB})`;
ctx.fill();
}
if (el.hasOutline) {
ctx.strokeStyle = `rgb(${el.outR},${el.outG},${el.outB})`;
ctx.lineWidth = 1;
ctx.stroke();
}
break;
}
case 'line': {
ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`;
ctx.lineWidth = Math.max(1, el.lineWidth || 1);
ctx.beginPath();
ctx.moveTo(el.x0 * s, el.y0 * s);
ctx.lineTo(el.x1 * s, el.y1 * s);
ctx.stroke();
break;
}
case 'divider': {
const isH = (el.orientation || 'horizontal') === 'horizontal';
ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`;
ctx.lineWidth = 1;
ctx.beginPath();
if (isH) {
ctx.moveTo(0, ay * s + 0.5);
ctx.lineTo(_canvas.width, ay * s + 0.5);
} else {
ctx.moveTo(ax * s + 0.5, 0);
ctx.lineTo(ax * s + 0.5, _canvas.height);
}
ctx.stroke();
break;
}
case 'pips': {
const pipCount = Math.max(1, el.count ?? 5);
const pvPips = opts.previewValues?.[el.binding?.key];
const filledN = pvPips !== undefined
? Math.max(0, Math.min(pipCount, Math.round(parseFloat(pvPips) || 0)))
: Math.max(0, Math.min(pipCount, el.filled ?? 3));
const ps = Math.max(1, el.pipSize ?? 4);
const pg = Math.max(0, el.pipSpacing ?? 2);
for (let i = 0; i < pipCount; i++) {
const isFilled = i < filledN;
if (!isFilled && !el.showEmpty) continue;
ctx.fillStyle = isFilled
? `rgb(${el.r},${el.g},${el.b})`
: `rgb(${el.emptyR ?? 50},${el.emptyG ?? 50},${el.emptyB ?? 50})`;
ctx.fillRect((ax + i * (ps + pg)) * s, ay * s, ps * s, ps * s);
}
break;
}
case 'sparkline': {
const slW = el.width ?? 40, slH = el.height ?? 12;
const count = Math.max(1, el.barCount ?? 8);
const spacing = el.barSpacing ?? 1;
const barW = Math.max(1, Math.floor((slW - spacing * (count - 1)) / count));
const rawVals = (el.previewData || '').split(',')
.map(v => parseFloat(v.trim())).filter(n => !isNaN(n));
while (rawVals.length < count) rawVals.push(0);
const maxV = Math.max(...rawVals.slice(0, count), 0.001);
const rx = ax * s, ry = ay * s;
if (el.hasBg) {
ctx.fillStyle = `rgb(${el.bgR ?? 30},${el.bgG ?? 30},${el.bgB ?? 30})`;
ctx.fillRect(rx, ry, slW * s, slH * s);
}
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
for (let i = 0; i < count; i++) {
const norm = Math.max(0, Math.min(1, rawVals[i] / maxV));
const barH = Math.max(1, Math.round(slH * norm));
const bx = rx + (barW + spacing) * i * s;
const by = ry + (slH - barH) * s;
ctx.fillRect(bx, by, barW * s, barH * s);
}
break;
}
case 'gauge': {
const gw = (el.width ?? 32), gh = (el.height ?? 32);
const cx = (ax + gw / 2) * s, cy = (ay + gh / 2) * s;
const rx = (gw / 2) * s, ry = (gh / 2) * s;
const lw = Math.max(1, (el.lineWidth ?? 3));
const startDeg = el.startAngle ?? 135;
const endDeg = el.endAngle ?? 45;
// Arc sweep: from startDeg clockwise to endDeg (PIL convention)
const totalSweep = ((endDeg - startDeg) + 360) % 360 || 360;
const pvGauge = opts.previewValues?.[el.binding?.key];
const pct = pvGauge !== undefined
? Math.max(0, Math.min(100, parseFloat(pvGauge) || 0)) / 100
: Math.max(0, Math.min(100, el.previewPct ?? 65)) / 100;
const fillSweep = totalSweep * pct;
const toRad = deg => (deg - 90) * Math.PI / 180; // canvas 0=top, PIL 0=right → offset -90
// Track arc
if (el.hasTrack !== false) {
ctx.beginPath();
ctx.ellipse(cx, cy, rx - lw / 2, ry - lw / 2, 0, toRad(startDeg), toRad(startDeg + totalSweep), false);
ctx.strokeStyle = `rgb(${el.trackR ?? 40},${el.trackG ?? 40},${el.trackB ?? 40})`;
ctx.lineWidth = lw * s;
ctx.stroke();
}
// Fill arc
if (pct > 0) {
ctx.beginPath();
ctx.ellipse(cx, cy, rx - lw / 2, ry - lw / 2, 0, toRad(startDeg), toRad(startDeg + fillSweep), false);
ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`;
ctx.lineWidth = lw * s;
ctx.stroke();
}
// Centre label
if (el.showLabel) {
const gfinfo = FONT_MAP[el.font || 'four_by_six'] || FONT_MAP.four_by_six;
const labelText = Math.round(pct * 100) + '%';
ctx.font = `${gfinfo.sizePx * s}px ${gfinfo.family}`;
ctx.fillStyle = `rgb(${el.labelR ?? 200},${el.labelG ?? 200},${el.labelB ?? 200})`;
const ltw = ctx.measureText(labelText).width;
ctx.fillText(labelText, cx - ltw / 2, cy + (gfinfo.sizePx * s) / 2);
}
break;
}
case 'marquee': {
const finfo = FONT_MAP[el.font] || FONT_MAP.press_start;
const text = el.text || 'Scrolling text';
const tw = text.length * finfo.charW * s;
const gap = (el.gap ?? 16) * s;
const totalW = tw + gap;
const tick = opts.animTick ?? 0;
const speed = (el.scrollSpeed ?? 1) * 2;
const scrolled = (tick * speed) % totalW;
// left: text enters from right; right: text enters from left
const startX = el.direction === 'right'
? scrolled - tw
: matrixW * s - scrolled;
ctx.font = `${finfo.sizePx * s}px ${finfo.family}`;
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
// Clip to canvas width so text doesn't bleed outside
ctx.save();
ctx.beginPath();
ctx.rect(0, ay * s - 1, matrixW * s, (finfo.sizePx + 2) * s);
ctx.clip();
for (let i = -1; i <= 2; i++) {
ctx.fillText(text, startX + i * totalW, (ay + finfo.sizePx) * s);
}
ctx.restore();
break;
}
case 'progress_bar': {
const bw = el.barWidth ?? 60, bh = el.barHeight ?? 6;
const pvPb = opts.previewValues?.[el.binding?.key];
const pct = pvPb !== undefined
? Math.max(0, Math.min(100, parseFloat(pvPb) || 0)) / 100
: Math.max(0, Math.min(100, el.previewPct ?? 65)) / 100;
const rx = ax * s, ry = ay * s;
if (el.hasBg) {
ctx.fillStyle = `rgb(${el.bgR ?? 30},${el.bgG ?? 30},${el.bgB ?? 30})`;
ctx.fillRect(rx, ry, bw * s, bh * s);
}
const fillW = Math.max(0, Math.round(bw * pct));
if (fillW > 0) {
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
ctx.fillRect(rx, ry, fillW * s, bh * s);
}
if (el.hasOutline) {
ctx.strokeStyle = `rgb(${el.outR ?? 100},${el.outG ?? 100},${el.outB ?? 100})`;
ctx.lineWidth = 1;
ctx.strokeRect(rx, ry, bw * s, bh * s);
}
break;
}
}
if (belowBreakpoint) {
ctx.globalAlpha = 0.6;
const bb = getBoundingBox(el, matrixW, matrixH);
ctx.font = `${Math.max(8, s * 2)}px monospace`;
ctx.fillStyle = '#facc15';
ctx.fillText(`${el.minWidth}px`, bb.x * s, (bb.y + 4) * s);
}
} finally {
ctx.restore();
}
}
// ── Selection indicator ──────────────────────────────────────────────
function _drawSelection(ctx, el, SCALE, matrixW, matrixH) {
const bb = getBoundingBox(el, matrixW, matrixH);
const PAD = 2, s = SCALE;
const rx = bb.x * s - PAD, ry = bb.y * s - PAD;
const rw = bb.w * s + PAD * 2, rh = bb.h * s + PAD * 2;
ctx.save();
ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = 1;
ctx.setLineDash([3, 2]);
ctx.strokeRect(rx, ry, rw, rh);
ctx.setLineDash([]);
if (el.xAnchor || el.yAnchor) {
ctx.font = `${Math.max(7, s)}px sans-serif`;
ctx.fillStyle = '#a78bfa';
const anchorText = [
el.xAnchor ? `x:${el.xAnchor[0]}` : '',
el.yAnchor ? `y:${el.yAnchor[0]}` : '',
].filter(Boolean).join(' ');
if (anchorText) ctx.fillText(anchorText, rx + 1, ry - 2);
}
// Resize handles: on rect, rounded rect, ellipse
if (['rectangle', 'rounded_rectangle', 'ellipse', 'arc', 'gauge', 'sparkline'].includes(el.type)) {
const handles = _getRectHandles(el, matrixW, matrixH);
const HS = 5;
ctx.fillStyle = 'white';
ctx.strokeStyle = '#2563eb';
ctx.lineWidth = 1;
for (const pt of Object.values(handles)) {
const hx = pt.x * s - HS / 2;
const hy = pt.y * s - HS / 2;
ctx.fillRect(hx, hy, HS, HS);
ctx.strokeRect(hx, hy, HS, HS);
}
} else {
// Corner dots for non-rectangle elements
ctx.fillStyle = '#3b82f6';
const HS = 4;
for (const [hx, hy] of [
[rx - HS / 2, ry - HS / 2], [rx + rw - HS / 2, ry - HS / 2],
[rx - HS / 2, ry + rh - HS / 2], [rx + rw - HS / 2, ry + rh - HS / 2],
]) ctx.fillRect(hx, hy, HS, HS);
}
ctx.restore();
}
// ── Dimension tooltip while dragging ─────────────────────────────────
function drawDragTooltip(ctx, el, SCALE, matrixW, matrixH) {
const bb = getBoundingBox(el, matrixW, matrixH);
const label = el.type === 'rectangle'
? `${el.width}×${el.height}`
: `${bb.x},${bb.y}`;
const s = SCALE;
ctx.save();
ctx.font = `${Math.max(9, s * 1.5)}px monospace`;
const tw = ctx.measureText(label).width;
const tx = bb.x * s, ty = (bb.y - 2) * s;
ctx.fillStyle = 'rgba(0,0,0,0.7)';
ctx.fillRect(tx - 2, ty - 10, tw + 4, 12);
ctx.fillStyle = 'white';
ctx.fillText(label, tx, ty);
ctx.restore();
}
// ── Public API ───────────────────────────────────────────────────────
function init(canvasEl) {
_canvas = canvasEl;
_ctx = canvasEl.getContext('2d');
}
function setGrid(show) { _showGrid = show; }
function updateCanvasSize(matrixW, matrixH, SCALE) {
if (!_canvas) return;
_canvas.width = matrixW * SCALE;
_canvas.height = matrixH * SCALE;
}
function render(elements, selectedId, matrixW, matrixH, SCALE, opts = {}) {
if (!_ctx) return;
const cW = matrixW * SCALE, cH = matrixH * SCALE;
const bg = opts.bgColor;
_ctx.fillStyle = bg ? `rgb(${bg.r},${bg.g},${bg.b})` : '#000';
_ctx.fillRect(0, 0, cW, cH);
if (_showGrid) {
_ctx.strokeStyle = 'rgba(255,255,255,0.07)';
_ctx.lineWidth = 0.5;
for (let x = SCALE; x < cW; x += SCALE) {
_ctx.beginPath(); _ctx.moveTo(x, 0); _ctx.lineTo(x, cH); _ctx.stroke();
}
for (let y = SCALE; y < cH; y += SCALE) {
_ctx.beginPath(); _ctx.moveTo(0, y); _ctx.lineTo(cW, y); _ctx.stroke();
}
}
for (const el of elements) _drawElement(_ctx, el, SCALE, matrixW, matrixH, opts);
if (opts.showRuler) {
_ctx.save();
_ctx.fillStyle = 'rgba(255,255,255,0.08)';
_ctx.fillRect(0, 0, cW, SCALE); // top strip
_ctx.fillRect(0, 0, SCALE, cH); // left strip
_ctx.strokeStyle = 'rgba(255,255,255,0.5)';
_ctx.fillStyle = 'rgba(255,255,255,0.6)';
_ctx.font = `${Math.max(5, SCALE - 1)}px monospace`;
const step = SCALE >= 4 ? 8 : 16;
for (let px = 0; px <= matrixW; px += step) {
const cx = px * SCALE;
const major = px % 32 === 0;
_ctx.lineWidth = 0.5;
_ctx.beginPath(); _ctx.moveTo(cx, 0); _ctx.lineTo(cx, major ? SCALE : SCALE * 0.5); _ctx.stroke();
if (major && px > 0 && px < matrixW - 4) _ctx.fillText(String(px), cx + 1, SCALE - 1);
}
for (let py = 0; py <= matrixH; py += step) {
const cy = py * SCALE;
const major = py % 32 === 0;
_ctx.beginPath(); _ctx.moveTo(0, cy); _ctx.lineTo(major ? SCALE : SCALE * 0.5, cy); _ctx.stroke();
if (major && py > 0 && py < matrixH - 4) _ctx.fillText(String(py), 1, cy + SCALE - 1);
}
_ctx.restore();
}
if (opts.showGuides) {
_ctx.save();
_ctx.strokeStyle = 'rgba(255,60,60,0.45)';
_ctx.lineWidth = 1;
_ctx.setLineDash([4, 3]);
const mx = Math.floor(cW / 2) + 0.5;
const my = Math.floor(cH / 2) + 0.5;
_ctx.beginPath(); _ctx.moveTo(mx, 0); _ctx.lineTo(mx, cH); _ctx.stroke();
_ctx.beginPath(); _ctx.moveTo(0, my); _ctx.lineTo(cW, my); _ctx.stroke();
_ctx.setLineDash([]);
_ctx.restore();
}
const sel = selectedId != null ? elements.find(e => e.id === selectedId) : null;
if (sel) {
_drawSelection(_ctx, sel, SCALE, matrixW, matrixH);
if (opts.showTooltip) drawDragTooltip(_ctx, sel, SCALE, matrixW, matrixH);
}
}
return {
init, render, setGrid, updateCanvasSize,
hitTest, getBoundingBox, computeActualPos, resolveAnchor,
getResizeHandle, getCursorForHandle,
ELEMENT_DEFAULTS, FONT_MAP, DISPLAY_PRESETS,
};
})();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,298 @@
"""
{{ plugin_name }} — LEDMatrix Plugin
Generated by LEDMatrix Plugin Composer on {{ generated_date }}
Extension points:
update() → add HTTP/MQTT data-fetching logic here
_get_display_values() → map fetched data to display strings
display() → add new elements or adapt layout per display size
"""
from src.plugin_system.base_plugin import BasePlugin
{% if has_clock or has_countdown %}
from datetime import datetime
{% endif %}
{% if has_blink %}
import time
{% endif %}
{% if has_text_template %}
from collections import defaultdict
{% endif %}
class {{ class_name }}(BasePlugin):
def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_manager):
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
{% for var in config_vars %}
self.{{ var.key }} = config.get({{ var.key | tojson }}, {{ var.default | tojson }})
{% endfor %}
# Live data cache — populated by update(); always {} in static layouts
self._data = {}
def update(self):
"""Fetch and refresh display data.
For dynamic plugins: fetch from APIs/MQTT here and store in self._data.
_get_display_values() will read self._data to produce display strings.
"""
# --- Data sources (add fetch logic here for dynamic plugins) ---
pass
def _get_display_values(self):
"""Map config variables and live data to display-ready strings.
This is the single extension point for v2 data sources:
add self._data lookups here once update() populates them.
"""
return {
{% for var in config_vars %}
{{ var.key | tojson }}: str(self.{{ var.key }}),
{% endfor %}
}
def display(self, force_clear=False):
try:
{% if has_text_template %}
values = defaultdict(str, self._get_display_values())
{% else %}
values = self._get_display_values()
{% endif %}
if force_clear:
self.display_manager.clear()
width = self.display_manager.width
height = self.display_manager.height
{% if bg_color %}
self.display_manager.draw.rectangle([0, 0, width, height], fill={{ bg_color }})
{% endif %}
# ── Elements (rendered bottom to top) ──────────────────────────
{% for el in elements %}
{% set p = " " if el.min_width > 0 else " " %}
{% set pi = (p + " ") if el.blink else p %}
{% if el.min_width > 0 %}
if width >= {{ el.min_width }}: # breakpoint: {{ el.min_width }}px+ displays only
{% endif %}
{% if el.blink %}
{{ p }}if int(time.time() * 2) % 2:
{% endif %}
{% if el.type == 'text' %}
{{ pi }}self.display_manager.draw_text(
{% if el.text_is_template %}
{{ pi }} {{ el.text | tojson }}.format_map(values),
{% else %}
{{ pi }} {{ el.text | tojson }},
{% endif %}
{{ pi }} x={{ el.x_expr }}, y={{ el.y_expr }},
{{ pi }} color={{ el.rgb_tuple }},
{{ pi }} font=self.display_manager.{{ el.font_attr }},
{{ pi }})
{% if el.text2 %}
{{ pi }}self.display_manager.draw_text(
{% if el.text_is_template %}
{{ pi }} {{ el.text2 | tojson }}.format_map(values),
{% else %}
{{ pi }} {{ el.text2 | tojson }},
{% endif %}
{{ pi }} x={{ el.x2_expr }}, y={{ el.y2_expr }},
{{ pi }} color={{ el.rgb_tuple }},
{{ pi }} font=self.display_manager.{{ el.font_attr }},
{{ pi }})
{% endif %}
{% elif el.type == 'dynamic_text' %}
{% if el.binding_source == 'config' %}
{{ pi }}self.display_manager.draw_text(
{{ pi }} values.get({{ el.binding_key | tojson }}, ''),
{{ pi }} x={{ el.x_expr }}, y={{ el.y_expr }},
{{ pi }} color={{ el.rgb_tuple }},
{{ pi }} font=self.display_manager.{{ el.font_attr }},
{{ pi }})
{% endif %}
{% elif el.type == 'clock' %}
{{ pi }}self.display_manager.draw_text(
{{ pi }} datetime.now().strftime({{ el.format | tojson }}),
{{ pi }} x={{ el.x_expr }}, y={{ el.y_expr }},
{{ pi }} color={{ el.rgb_tuple }},
{{ pi }} font=self.display_manager.{{ el.font_attr }},
{{ pi }})
{% if el.format2 %}
{{ pi }}self.display_manager.draw_text(
{{ pi }} datetime.now().strftime({{ el.format2 | tojson }}),
{{ pi }} x={{ el.x2_expr }}, y={{ el.y2_expr }},
{{ pi }} color={{ el.rgb_tuple }},
{{ pi }} font=self.display_manager.{{ el.font_attr }},
{{ pi }})
{% endif %}
{% elif el.type == 'countdown' %}
{{ pi }}_cd_target = float(values.get({{ el.binding_key | tojson }}, 0) or 0)
{{ pi }}_cd_secs = max(0.0, _cd_target - datetime.now().timestamp())
{% if el.countdown_format == 'dhms' %}
{{ pi }}_cd_d, _cd_rem = divmod(int(_cd_secs), 86400)
{{ pi }}_cd_h, _cd_rem = divmod(_cd_rem, 3600)
{{ pi }}_cd_m, _cd_s = divmod(_cd_rem, 60)
{{ pi }}_cd_str = f'{_cd_d}d {_cd_h:02d}:{_cd_m:02d}:{_cd_s:02d}'
{% elif el.countdown_format == 'hms' %}
{{ pi }}_cd_h, _cd_rem = divmod(int(_cd_secs), 3600)
{{ pi }}_cd_m, _cd_s = divmod(_cd_rem, 60)
{{ pi }}_cd_str = f'{_cd_h}h {_cd_m:02d}:{_cd_s:02d}'
{% elif el.countdown_format == 'dhm' %}
{{ pi }}_cd_d, _cd_rem = divmod(int(_cd_secs), 86400)
{{ pi }}_cd_h, _cd_m = divmod(_cd_rem // 60, 60)
{{ pi }}_cd_str = f'{_cd_d}d {_cd_h:02d}h {_cd_m:02d}m'
{% else %}
{{ pi }}_cd_d, _cd_rem = divmod(int(_cd_secs), 86400)
{{ pi }}_cd_h = _cd_rem // 3600
{{ pi }}_cd_str = f'{_cd_d}d {_cd_h}h'
{% endif %}
{{ pi }}self.display_manager.draw_text(
{{ pi }} _cd_str,
{{ pi }} x={{ el.x_expr }}, y={{ el.y_expr }},
{{ pi }} color={{ el.rgb_tuple }},
{{ pi }} font=self.display_manager.{{ el.font_attr }},
{{ pi }})
{% elif el.type == 'rectangle' %}
{{ pi }}self.display_manager.draw.rectangle(
{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}],
{{ pi }} fill={{ el.fill_tuple }},
{{ pi }} outline={{ el.outline_tuple }},
{{ pi }})
{% elif el.type == 'arc' %}
{{ pi }}self.display_manager.draw.arc(
{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}],
{{ pi }} start={{ el.start_angle }}, end={{ el.end_angle }},
{{ pi }} fill={{ el.rgb_tuple }},
{{ pi }} width={{ el.line_width }},
{{ pi }})
{% elif el.type == 'ellipse' %}
{{ pi }}self.display_manager.draw.ellipse(
{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}],
{{ pi }} fill={{ el.fill_tuple }},
{{ pi }} outline={{ el.outline_tuple }},
{{ pi }})
{% elif el.type == 'pixel' %}
{{ pi }}self.display_manager.draw.point(
{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}],
{{ pi }} fill={{ el.rgb_tuple }},
{{ pi }})
{% elif el.type == 'rounded_rectangle' %}
{{ pi }}self.display_manager.draw.rounded_rectangle(
{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}],
{{ pi }} radius={{ el.border_radius }},
{{ pi }} fill={{ el.fill_tuple }},
{{ pi }} outline={{ el.outline_tuple }},
{{ pi }})
{% elif el.type in ('line', 'divider') %}
{{ pi }}self.display_manager.draw.line(
{{ pi }} [{{ el.x0_expr }}, {{ el.y0_expr }}, {{ el.x1_expr }}, {{ el.y1_expr }}],
{{ pi }} fill={{ el.rgb_tuple }},
{{ pi }} width={{ el.line_width }},
{{ pi }})
{% elif el.type == 'pips' %}
{{ pi }}_pip_filled = max(0, min({{ el.pip_count }}, int(float(values.get({{ el.binding_key | tojson }}, 0) or 0))))
{{ pi }}for _pip_i in range({{ el.pip_count }}):
{{ pi }} _pip_x = ({{ el.x_expr }}) + _pip_i * ({{ el.pip_size }} + {{ el.pip_spacing }})
{{ pi }} _pip_color = {{ el.fill_tuple }} if _pip_i < _pip_filled else {{ el.empty_tuple }}
{% if not el.show_empty %}
{{ pi }} if _pip_i >= _pip_filled:
{{ pi }} continue
{% endif %}
{{ pi }} self.display_manager.draw.rectangle(
{{ pi }} [_pip_x, {{ el.y_expr }}, _pip_x + {{ el.pip_size }} - 1, ({{ el.y_expr }}) + {{ el.pip_size }} - 1],
{{ pi }} fill=_pip_color,
{{ pi }} )
{% elif el.type == 'sparkline' %}
{{ pi }}_sl_raw = str(values.get({{ el.binding_key | tojson }}, '') or '')
{{ pi }}_sl_vals = [float(v.strip()) for v in _sl_raw.split(',') if v.strip()][:{{ el.bar_count }}]
{{ pi }}_sl_vals += [0.0] * max(0, {{ el.bar_count }} - len(_sl_vals))
{{ pi }}_sl_max = max(_sl_vals) if any(_sl_vals) else 1.0
{{ pi }}_sl_bw = max(1, ({{ el.bar_width_px }} - {{ el.bar_spacing }} * ({{ el.bar_count }} - 1)) // {{ el.bar_count }})
{% if el.bg_tuple != 'None' %}
{{ pi }}self.display_manager.draw.rectangle(
{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, ({{ el.x_expr }}) + {{ el.bar_width_px }}, ({{ el.y_expr }}) + {{ el.bar_height_px }}],
{{ pi }} fill={{ el.bg_tuple }},
{{ pi }})
{% endif %}
{{ pi }}for _sl_i, _sl_v in enumerate(_sl_vals):
{{ pi }} _sl_norm = max(0.0, min(1.0, _sl_v / (_sl_max or 1)))
{{ pi }} _sl_bh = max(1, round({{ el.bar_height_px }} * _sl_norm))
{{ pi }} _sl_bx = ({{ el.x_expr }}) + (_sl_bw + {{ el.bar_spacing }}) * _sl_i
{{ pi }} _sl_by = ({{ el.y_expr }}) + {{ el.bar_height_px }} - _sl_bh
{{ pi }} self.display_manager.draw.rectangle(
{{ pi }} [_sl_bx, _sl_by, _sl_bx + _sl_bw - 1, _sl_by + _sl_bh - 1],
{{ pi }} fill={{ el.fill_tuple }},
{{ pi }} )
{% elif el.type == 'gauge' %}
{{ pi }}_gv = max(0.0, min(100.0, float(values.get({{ el.binding_key | tojson }}, 0) or 0)))
{{ pi }}_g_total = (({{ el.end_angle }} - {{ el.start_angle }}) % 360) or 360
{{ pi }}_g_sweep = _g_total * _gv / 100.0
{% if el.track_tuple != 'None' %}
{{ pi }}self.display_manager.draw.arc(
{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}],
{{ pi }} start={{ el.start_angle }}, end={{ el.start_angle }} + _g_total,
{{ pi }} fill={{ el.track_tuple }},
{{ pi }} width={{ el.line_width }},
{{ pi }})
{% endif %}
{{ pi }}if _g_sweep > 0:
{{ pi }} self.display_manager.draw.arc(
{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}],
{{ pi }} start={{ el.start_angle }}, end={{ el.start_angle }} + _g_sweep,
{{ pi }} fill={{ el.rgb_tuple }},
{{ pi }} width={{ el.line_width }},
{{ pi }} )
{% if el.show_label %}
{{ pi }}_g_cx = ({{ el.x_expr }}) + ({{ el.x2_expr }} - ({{ el.x_expr }})) // 2
{{ pi }}_g_cy = ({{ el.y_expr }}) + ({{ el.y2_expr }} - ({{ el.y_expr }})) // 2
{{ pi }}self.display_manager.draw_text(
{{ pi }} f'{int(_gv)}%',
{{ pi }} x=_g_cx, y=_g_cy,
{{ pi }} color={{ el.label_tuple }},
{{ pi }} font=self.display_manager.{{ el.font_attr }},
{{ pi }})
{% endif %}
{% elif el.type == 'marquee' %}
{{ pi }}_{{ el.data_key }}_text = {{ el.text | tojson }}
{{ pi }}_{{ el.data_key }}_tw = len(_{{ el.data_key }}_text) * {{ el.char_w }}
{{ pi }}_{{ el.data_key }}_x = int(self._data.get({{ el.data_key | tojson }}, width))
{% if el.direction == 'right' %}
{{ pi }}_{{ el.data_key }}_x += {{ el.scroll_speed }}
{{ pi }}if _{{ el.data_key }}_x > width:
{{ pi }} _{{ el.data_key }}_x = -(_{{ el.data_key }}_tw + {{ el.gap }})
{% else %}
{{ pi }}_{{ el.data_key }}_x -= {{ el.scroll_speed }}
{{ pi }}if _{{ el.data_key }}_x < -(_{{ el.data_key }}_tw + {{ el.gap }}):
{{ pi }} _{{ el.data_key }}_x = width
{% endif %}
{{ pi }}self._data[{{ el.data_key | tojson }}] = _{{ el.data_key }}_x
{{ pi }}self.display_manager.draw_text(
{{ pi }} _{{ el.data_key }}_text,
{{ pi }} x=_{{ el.data_key }}_x, y={{ el.y_expr }},
{{ pi }} color={{ el.rgb_tuple }},
{{ pi }} font=self.display_manager.{{ el.font_attr }},
{{ pi }})
{% elif el.type == 'progress_bar' %}
{{ pi }}_pb_x = {{ el.x_expr }}
{{ pi }}_pb_y = {{ el.y_expr }}
{{ pi }}_pb_pct = max(0.0, min(100.0, float(values.get({{ el.binding_key | tojson }}, 0) or 0))) / 100.0
{{ pi }}_pb_fill_w = int({{ el.bar_width }} * _pb_pct)
{{ pi }}self.display_manager.draw.rectangle(
{{ pi }} [_pb_x, _pb_y, _pb_x + {{ el.bar_width }}, _pb_y + {{ el.bar_height }}],
{{ pi }} fill={{ el.bg_tuple }},
{{ pi }} outline={{ el.outline_tuple }},
{{ pi }})
{{ pi }}if _pb_fill_w > 0:
{{ pi }} self.display_manager.draw.rectangle(
{{ pi }} [_pb_x, _pb_y, _pb_x + _pb_fill_w, _pb_y + {{ el.bar_height }}],
{{ pi }} fill={{ el.fill_tuple }},
{{ pi }} )
{% endif %}
{% endfor %}
# ── End elements ───────────────────────────────────────────────
self.display_manager.update_display()
except Exception as e:
self.logger.error('Display error: %s', e, exc_info=True)