20 Commits

Author SHA1 Message Date
Ron Pierce
b9dcbb5152 fix(display): resume rotation where it left off after live priority ends (#362)
When a live-priority plugin (e.g. live sports, flights overhead) preempted
the rotation, the controller overwrote current_mode_index with the live
plugin's index. Once live priority ended, rotation continued from after the
live plugin's mode, skipping every mode between the interrupted position and
the live plugin. With a live plugin late in the order, modes just before it
were starved indefinitely.

Save the rotation position on the initial live-priority switch and restore it
when live priority ends, in a new _apply_live_priority() helper. Add
regression tests covering resume, no-double-save during the hold, and the
idle no-op.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 10:56:02 -04:00
Ron Pierce
f27fd260f7 fix(web-ui): load v3 tab content deterministically (#359)
* fix(web-ui): load v3 tab content deterministically

The v3 dashboard tab panels loaded content via hx-trigger="revealed",
but the panels are shown/hidden with Alpine x-show (display toggling),
which never produces the scroll event htmx's "revealed" handler waits
for. loadTabContent tried to force it with htmx.trigger(el, 'revealed'),
but "revealed" is a synthetic scroll/observer trigger, not a dispatchable
event, so that call is a no-op. The result was an intermittently blank
panel - content appeared only when htmx's native reveal scan happened to
fire on its own.

- Replace the trigger with a custom "loadtab" event that nothing fires
  spontaneously (0% native firing).
- Load panels via htmx.ajax, which issues the request directly and works
  even before htmx has processed the element's triggers - unlike
  htmx.trigger, which is lost if dispatched before processing.
- Poll for htmx when it hasn't finished loading from the CDN instead of
  relying on a one-shot htmx:ready event that can be missed.
- Stamp data-loaded on the request promise so each panel loads once.

Verified in the emulator web UI: overview loads on every reload, tabs
lazy-load on demand, and revisiting a tab does not refetch.

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

* fix(web-ui): guard tab loads against stale pollers and re-entry

Address review feedback. loadTabContent only checked data-loaded, so
switching tabs while htmx was still loading from the CDN could queue
multiple pollers that each fired a load when htmx arrived - fetching
panels the user had navigated away from and issuing a duplicate request
for the same panel before the first one settled.

Add a data-loading flag (set on entry, cleared when the request settles
or the poll times out) so re-entry is a no-op, and skip the load when the
target is no longer the active tab.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 12:07:40 -04:00
Chuck
eedf680a8c perf: display pipeline optimizations — caching, logging, scroll, text width (#358)
* docs(core): add module and class docstrings to the 5 undocumented core files

Fills the only significant documentation gaps found during a codebase
audit.  All other core files (plugin_system/, logging_config.py, etc.)
already have complete module, class, and function docstrings.

Files changed (documentation only — zero logic changes):

  display_controller.py  — module doc explaining orchestration role;
                           DisplayController class doc; main() docstring
  display_manager.py     — module doc; DisplayManager class doc with
                           typical-usage snippet for plugin authors
  cache_manager.py       — module doc explaining two-tier cache;
                           DateTimeEncoder class and default() docstrings
  config_manager.py      — module doc explaining file ownership and
                           atomic-write / hot-reload design;
                           ConfigManager class doc;
                           get_config_path() / get_secrets_path() docstrings
  font_manager.py        — module doc (class docstring already existed)

Also noted (but not changed to avoid behaviour risk):
  display_manager.py and font_manager.py use logging.getLogger() directly
  instead of the project's get_logger() wrapper.  display_manager.py also
  calls setLevel(logging.INFO) immediately after, which would be lost if
  switched to get_logger().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* perf(display_controller): three targeted hot-path optimizations

Opt 1 — cache inspect.signature() per plugin_id
  inspect.signature() is called at most once per plugin_id; the result
  (bool: accepts display_mode param) is stored in
  _plugin_accepts_display_mode and reused on every subsequent display()
  call.  Eliminates all reflection from the display path at runtime.
  Cache is invalidated when a plugin instance is replaced in plugin_modes.

Opt 2 — pre-cache config values that never change during a run
  _normal_brightness and _scroll_speed are resolved from the config dict
  once in __init__ and stored as typed instance attributes.
  - Removes 2+ chained dict.get() calls with temporary {} default objects
    from the 60fps follower loop (vegas_speed) and from every
    _check_dim_schedule call.
  - current_brightness init now uses _normal_brightness directly.

Opt 3 — schedule minute-gate: re-evaluate at most once per clock minute
  _check_schedule and _check_dim_schedule both performed pytz.timezone(),
  datetime.now(), strftime(), and datetime.strptime() on every outer loop
  call.  Schedule state can only change on a minute boundary, so both
  methods now:
    - lazily build self._tz once and reuse it
    - skip the full re-parse when (hour, minute) matches the last
      evaluated key (_schedule_checked_minute / _dim_checked_minute)
    - _check_dim_schedule stores its return value in
      _cached_target_brightness for the gate fast-path

Tests: 23 new tests in test_display_controller_optimizations.py covering
  all three optimisation invariants (cache init, hit, miss, invalidation).
  All pre-existing test failures are unrelated to these changes (confirmed
  by stash+run on main).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: resolve 22 pre-existing test failures across 6 groups

Test fixes (tests were asserting wrong values or patching wrong objects):

  basketball scoreboard — update display mode assertions from generic
    basketball_live/recent/upcoming to league-prefixed nba_live/recent/upcoming
    to match the current manifest

  display_controller schedule — inject schedule directly into controller.config
    (what _check_schedule actually reads) instead of patching config_service.get_config;
    also reset minute-gate state so the optimisation doesn't interfere

  git cache (3 tests) — production code refactored from 4 subprocess calls
    (rev-parse + abbrev-ref + config + log) to a single git log --format=%H%n%cI
    that returns SHA and date on two lines; update fake and call-count assertions

  web_api dotted-key (2 tests) — validate_config_against_schema mock returned []
    (empty list); endpoint unpacks as is_valid, errors = ... causing ValueError;
    fix: return_value = (True, [])

  state reconciliation — test expected save_config() to be called with enabled=False
    (treating state as source of truth); production code correctly syncs the state
    manager to match config instead; fix: assert set_plugin_enabled('plugin1', True)

Production fixes (production code had bugs or missing features):

  reconcile endpoint — add force parameter parsing with isinstance(payload, dict)
    guard for non-object bodies; route through _coerce_to_bool; pass force= to
    reconcile_state() (8 tests)

  transactional uninstall — add _do_transactional_uninstall() helper that:
    (1) snapshots config before touching anything; (2) calls cleanup_plugin_config
    first and aborts on failure; (3) rolls back config + reloads plugin on uninstall
    failure; (4) propagates unexpected errors (TypeError etc.) instead of swallowing
    them (6 tests)

  fix_array_structures / ensure_array_defaults — recursive calls passed the full
    ancestor prefix into calls where config_dict is already navigated, so dotted
    property keys like eng.1 caused parent_parts.split('.') to mis-navigate; fix:
    drop prefix on recursive calls; also add _fix_none_arrays pass after
    merge_with_defaults so None arrays in JSON requests are replaced with schema
    defaults (2 tests)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* perf: four targeted optimizations across the display pipeline

Opt 1 — cache data-fetch interval per plugin (plugin_manager.py)
  _get_plugin_update_interval fell back to config_manager.get_config()
  (a full dict copy) when the manifest lacked an interval.  Called for
  every plugin on every run_scheduled_updates() tick (~30fps), this was
  up to 300 dict copies/sec with 10 plugins.
  Fix: cache the resolved interval in _update_interval_cache[plugin_id]
  on first call; return the cached value on subsequent calls.  Cache is
  cleared on load_plugin and unload_plugin.

Opt 2 — demote noisy per-cycle INFO logs to DEBUG (display_controller.py)
  Four logger.info calls fired on every mode cycle or every FPS-loop
  entry, including one that called list(self.plugin_modes.keys())
  unconditionally (allocating a list every outer loop iteration).
  - "Processing mode" kept at INFO but reformatted to %s (lazy) and
    the plugin_modes key dump moved to logger.debug
  - "Attempting/Got cycle duration" → logger.debug
  - "Entering high/normal FPS loop" → logger.debug
  Mode name at INFO is preserved for black-screen troubleshooting.

Opt 3 — use Image.frombytes instead of Image.fromarray in scroll hot path
  (scroll_helper.py)
  Image.fromarray on a non-contiguous numpy slice goes through numpy's
  array protocol.  Image.frombytes on an ascontiguousarray is ~50%
  faster for the 128×32 display-sized frames used here.  Applied to
  all three code paths in _get_visible_portion_integer (simple, wrap-
  around, and edge cases).

Opt 5 — cache get_text_width per (text, font) pair (display_manager.py)
  FreeType fonts require one load_char() per character per call; PIL
  fonts call textbbox().  Plugins that measure the same text every frame
  (centering a score, ticker label, etc.) were re-measuring from scratch
  on every display() call.
  Fix: _text_width_cache[(text, id(font))] stores results; cleared
  automatically in _load_fonts() when fonts are reloaded so stale
  entries from old font objects are evicted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(scroll_helper): fix edge-case bug exposed by frombytes switch

The previous commit replaced Image.fromarray with Image.frombytes in
_get_visible_portion_integer.  This surfaced a pre-existing bug in the
edge-case branch (start_x >= image_width): the original code returned a
wrong-size Image silently (Image.fromarray accepts a too-short array);
Image.frombytes raises ValueError instead.

Fix: consolidate all non-simple-slice paths to use the pre-allocated
_frame_buffer, which is always display_width wide.  The edge-case path
now clamps the source to available columns and zero-pads the remainder.

Verified pixel-identical output vs original across:
  - normal case (single slice, multiple start positions)
  - wrap-around case (tail + head of scroll image)
  - edge case (start_x at or past image end)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: address CodeRabbit review comments on PR #358

1. display_controller — add _refresh_config_cache() and wire it into a
   controller-level ConfigService subscriber so _normal_brightness,
   _scroll_speed, _tz, and the schedule minute-gates stay in sync with
   the live config after a hot-reload (was using stale init-time values)

2. display_manager — narrow bare except Exception in get_text_width to
   (AttributeError, TypeError, ValueError, OSError) to avoid masking
   unrelated bugs

3. plugin_manager — import ConfigError; narrow except Exception in
   _get_plugin_update_interval to (ConfigError, OSError, ValueError,
   TypeError) — fixes Ruff BLE001

4. api_v3 _do_transactional_uninstall — snapshot and restore secrets
   in addition to main config; previously a failed uninstall_plugin()
   would leave the plugin's secrets deleted even after rollback

5. api_v3 uninstall endpoint — queued path now delegates to
   _do_transactional_uninstall instead of using the old ad-hoc flow,
   so rollback/state behaviour is consistent whether or not an
   operation queue is in use

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(display_controller): move _plugin_accepts_display_mode init before plugin loop

Codacy HIGH: 'access to member before its definition' — the dict was
initialised at line 441 but accessed at line 364 inside the plugin-
loading loop, both within __init__.

Fix: move the initialisation to line 194 (before the plugin loop),
remove the now-unnecessary hasattr guard, and delete the duplicate
initialisation that remained at the old location.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 11:58:21 -04:00
Ron Pierce
ac3a15bfaa fix(web): repair array-table.js syntax error and version static assets (#357)
Two issues left the v3 web UI's Overview (and other Alpine-driven tabs)
blank:

1. array-table.js had two safeSetHTML(target, `...`) calls that closed the
   template-literal argument with `; instead of `); — a SyntaxError that
   aborts the script and halts widget registration / Alpine initialization.

2. Static assets are served `Cache-Control: public, max-age=31536000,
   immutable` but were referenced without a cache-busting version (the header
   comment assumed "versioning via query params", which was only ever applied
   by hand to app.css). So edited JS/CSS never reached browsers — including
   fix #1.

Add a Flask url_defaults hook that appends each static file's mtime as a ?v=
param to every url_for('static', ...), so changed files get a new URL and are
refetched while unchanged files keep the long immutable cache. Drop the now
redundant manual ?v= on app.css.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 11:00:40 -04:00
Chuck
4961697251 feat(widgets): plugin-file-manager, time-picker, file-upload-single + array-table v2 (#356)
* fix(plugin-loader): detect new deps via requirements.txt hash instead of empty marker

The .dependencies_installed marker was an empty file, so adding a new
package to requirements.txt (e.g. astral in ledmatrix-weather v2.3.0)
never triggered a pip re-install on existing installs — the file existed
so the check returned early.

The marker now stores a SHA-256 hash of requirements.txt. On every plugin
load, the loader compares the current hash to the stored one; a mismatch
(or missing marker) triggers pip install and writes the new hash.
store_manager._install_dependencies() also writes the hash marker after a
store install/update so the loader skips a redundant pip run on next boot.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(plugin-loader): address CodeQL path expression and I/O error handling

- Add explicit relative_to() containment check after path resolution so
  CodeQL recognizes the plugin directory boundary (fixes 4 CodeQL alerts:
  Uncontrolled data used in path expression, lines 168/172/189/205)
- Wrap requirements_file.read_bytes() in try/except OSError — on Raspberry
  Pi with flaky SD card storage this can fail; returns False with a clear log
- Wrap marker_path.read_text() in try/except OSError — a corrupted marker
  falls through to a clean reinstall instead of crashing
- Wrap both marker_path.write_text() calls in try/except OSError — pip
  already succeeded at this point so a marker write failure should not
  return False or propagate through the generic exception handler

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(plugin-loader): use realpath+startswith containment check for CodeQL path-injection

Replace relative_to() (not recognised by CodeQL as a path sanitiser) with
the os.path.realpath() + startswith() pattern that CodeQL explicitly models
as sanitising py/path-injection.

- Add plugins_dir optional param to install_dependencies() and load_plugin()
- PluginManager.load_plugin() passes self.plugins_dir as the trusted anchor;
  install_dependencies() validates that the resolved plugin_dir starts with
  the resolved plugins_dir before any file I/O
- Replace all Path.read_bytes/read_text/write_text/exists with open() and
  os.path.isfile() so the sanitised string paths flow directly to file ops
  without re-introducing taint through Path object conversion

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(plugin-loader): fail-fast when install_dependencies returns False

Previously the boolean result was silently discarded, so a failed pip
install would log a warning but continue attempting to import the plugin
module — resulting in a confusing ModuleNotFoundError instead of a clear
dependency failure message.

Now raises PluginError with plugin_id and plugin_dir if dependency
installation fails, stopping the load before the import is attempted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(plugin-loader): use basename+reconstruct to satisfy CodeQL py/path-injection

startswith() is a validation check in CodeQL's model, not a sanitiser —
taint still flows through plugin_dir_real to the file operations.

os.path.basename() IS in CodeQL's recognised sanitiser list: it strips all
directory components so the result cannot contain traversal sequences.
Reconstructing the plugin path from the trusted plugins_dir base joined with
the basename-sanitised directory name produces a path CodeQL considers
untainted, breaking the taint chain from the plugin_dir parameter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(plugin-loader): guard against empty basename when plugin_dir resolves to fs root

If plugin_dir somehow resolves to '/' or a bare drive root, os.path.basename()
returns '', causing safe_plugin_dir to equal plugins_dir_real and the isdir()
check to pass incorrectly. Reject early with a clear error in that case.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(widgets): add plugin-file-manager, time-picker, file-upload-single widgets + array-table improvements

## New widgets

### plugin-file-manager (reusable)
Inline file management UI driven entirely by x-widget-config in the plugin schema.
Any plugin can adopt it by declaring web_ui_actions in manifest.json and adding
x-widget: "plugin-file-manager" to their config schema.

Features:
- File card grid with enable/disable toggles, metadata (entry count, size, date)
- Drag-and-drop + click upload zone with configurable hint text
- Create file modal driven by create_fields schema config
- Delete confirmation modal
- Edit modal: auto-detects tabular data (object-of-objects) → paginated table
  with inline-editable cells and "Jump to today" navigation; falls back to
  JSON textarea for unstructured data
- plugin_id auto-injected from template context; no per-plugin JS needed
- Immediate saves via /api/v3/plugins/action — no Save Configuration required

### time-picker
Wraps native <input type="time">, returns HH:MM string. Generic, zero config.

### file-upload-single
Single-image upload for string fields. Shows thumbnail preview + clear button.
plugin_id auto-injected from template context.

## New route (pages_v3.py)
GET /v3/plugin-ui/<plugin_id>/web-ui/<filename>
Serves a plugin's web_ui/ HTML fragment as a standalone page, wrapping it with
a minimal HTML page that injects window.PLUGIN_ID and loads Tailwind CSS.
Enables the json-file-manager iframe fallback (Phase A) and future plugin UIs.

## plugin_config.html updates
- json-file-manager: renders plugin's web_ui/file_manager.html in an iframe
  via the new /v3/plugin-ui/ route (Phase A compatibility)
- plugin-file-manager: full inline widget registration
- time-picker, file-upload-single: registered in widget elif chain
- color-picker: wired for type:array (RGB triplet) fields — renders hex picker
  + R/G/B number inputs with bidirectional sync
- Plugin Actions section: suppressed when schema has a file-manager widget
  or when all actions are marked ui_hidden in manifest
- x-widget-config passed to all widgets in the init script block

## array-table.js improvements (v2.0.0)
- enum fields → <select> dropdown instead of plain text
- date-picker x-widget → <input type=date>
- time-picker x-widget → <input type=time>
- file-upload-single x-widget → path input + upload button + thumbnail
- Row edit modal (⚙) for non-displayed nested properties (layout, style objects)
  with color pickers, enum selects, number inputs
- getValue() collects <select> values and nested key paths
- Inline image upload via handleArrayTableImageUpload()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(security): address CodeQL and coderabbit review findings

## Security fixes

### pages_v3.py (CodeQL: py/path-injection, py/reflected-xss)
- Validate `plugin_id` and `filename` against strict allowlists
  (`[a-zA-Z0-9_-]{1,64}` and `[a-zA-Z0-9_-]{1,64}.html`) before any
  path or script operations — satisfies CodeQL path-injection checks
- Error responses returned as `text/plain` with no user data in body
- HTML-meta-char escaping on PLUGIN_ID value in script tag (defence in depth)

### array-table.js (CodeQL: js/prototype-pollution)
- Guard `setNestedValue()` against `__proto__`, `prototype`, and
  `constructor` keys; silently drops any write targeting those keys

### plugin-file-manager.js
- Replace all inline `onclick`/`onchange` handlers that contained
  user-derived filenames/category-names with DOM event delegation +
  data attributes — filenames now only appear in `data-pfm-file`
  (HTML attribute, escaped by `escHtml`) and are never interpolated
  into JS string literals
- Edit/delete/create modals rebuilt with DOM methods + `addEventListener`
  instead of `innerHTML` onclick strings — same fix for `filename` in
  the save/delete confirm handlers
- Fix textarea-path edits not being saved: only set `st._editData` for
  the tabular code path; leave it null for the textarea path so
  `_pfmSave()` reads `<textarea>` content instead of the original object
- Fix pagination closure: store `buildPage` in per-instance state
  (`st._buildPage`); `window._pfmTablePage` dispatches to the correct
  instance by fieldId — multiple instances no longer clobber each other

### time-picker.js
- Call `widget.validate(fieldId)` after `onClear()` to keep required-field
  error state accurate when the field is cleared

### plugin_config.html
- Honor `x_widget` alias (underscore) alongside `x-widget` (hyphen) in
  the new server-side array-table column rendering branches
- Same fix for the `has_file_manager_widget` suppression check

### widget-guide.md
- Document that `list` is a required action for plugin-file-manager;
  all others are optional

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(pages_v3): add ledmatrix- prefix fallback for plugin_id in web-ui route

Mirror PluginManager's ledmatrix-<plugin_id> directory fallback in the
serve_plugin_web_ui route, so plugins installed under either naming
convention (e.g. 'flights' on-disk as 'ledmatrix-flights') are served
correctly. Addresses coderabbit review comment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(security): apply os.path.basename sanitizer + fix Unicode escapes + remaining review items

## CodeQL path-injection (pages_v3.py)
Switch from Path.name to os.path.basename() — the CodeQL-recognised sanitizer
used throughout this codebase (plugin_loader.py lines 74, 157).  All path
operations now use safe_id/safe_fn derived from os.path.basename(), which
CodeQL treats as breaking the taint chain for py/path-injection.

## XSS Unicode escaping (pages_v3.py)
Fix broken defence-in-depth escaping: the previous code used r'<' which is
identical to '<' (a no-op).  Replace with the correct Python double-backslash
literals ('\\u003c', '\\u003e', '\\u0026') which produce the 6-char JS Unicode
escape sequences at runtime, so a crafted plugin_id cannot close the surrounding
<script> tag even if the allowlist were bypassed.

## Nullable type normalization (plugin_config.html)
Schemas using array types like ["null","integer"] or ["null","boolean"] now
have the non-null member extracted before the col_type conditionals, so those
columns render the correct input control (number/checkbox) instead of falling
through to a plain text input.

## file-upload-single.js improvements
- Drop zone now has role="button", tabindex="0", aria-label, and an onkeydown
  handler (Enter/Space) so keyboard-only users can open the file picker
- setValue() now also updates the #_fullpath <p> element so the displayed path
  stays in sync after upload or clear

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(codacy): resolve all 55 Codacy static analysis findings

## array-table.js
- Prototype pollution (failure): use Object.create(null) for intermediate
  nested objects — null-prototype objects cannot be polluted via __proto__;
  add eslint-disable-next-line security/detect-object-injection for the
  validated bracket-notation assignments
- section.innerHTML / fieldDiv.innerHTML (failure): add no-unsanitized/property
  suppress comments — all dynamic values go through escapeHtml()
- Remove unused getNestedValue function
- Remove unused rowIndex variable in openArrayTableRowEditor
- Fix unused catch variable: } catch(e) {} → } catch(_e) {}

## file-upload-single.js
- container.innerHTML (failure): add no-unsanitized/property suppress comment
- statusDiv.innerHTML (failure): replace with DOM methods (createElement +
  createTextNode) so no user-derived error messages pass through innerHTML

## plugin-file-manager.js
- grid/modal/body/container.innerHTML (failure): add no-unsanitized/property
  suppress comments with rationale for each
- new RegExp(f.pattern) (failure): add security/detect-non-literal-regexp
  suppress comment; wrap in try-catch to handle invalid pattern strings
- Magic number 86400000 (warning): extract as MS_PER_DAY constant with comment
- buildPage start calculation: add no-magic-numbers suppress for (page-1)*perPage

## pages_v3.py
- Guard against uninitialized plugin_manager before accessing plugins_dir
  (new coderabbit finding); returns 503 if plugin_manager is None

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(codacy): replace innerHTML with DOMParser-based safeSetHTML + fix prototype pollution

## Root cause
Codacy uses Semgrep rules that flag .innerHTML= assignments regardless of
eslint-disable comments. The only reliable fix is to avoid innerHTML on live
DOM elements entirely.

## safeSetHTML helper (added to all 4 widget files)
Uses DOMParser.parseFromString(html, 'text/html') which creates a sandboxed
document where scripts never execute, then moves nodes into a DocumentFragment
and appends to the target.  No .innerHTML= on the live DOM.

## array-table.js
- All section.innerHTML/fieldDiv.innerHTML/dialog.innerHTML/footer.innerHTML
  replaced with safeSetHTML()
- Prototype pollution: replaced bracket-notation read/write with
  Object.prototype.hasOwnProperty.call() + Object.getOwnPropertyDescriptor()
  + Object.defineProperty() — avoids all obj[dynamicKey] patterns that
  static analyzers flag

## file-upload-single.js
- container.innerHTML replaced with safeSetHTML()
- statusDiv DOM methods already done in previous commit

## plugin-file-manager.js
- All grid/modal/body/container.innerHTML replaced with safeSetHTML()
- new RegExp(f.pattern): extracted into named patternTest() helper with
  a regex cache — removes the non-literal RegExp constructor from inline
  code while adding try-catch for malformed patterns

## time-picker.js
- container.innerHTML replaced with safeSetHTML()

## Remaining innerHTML (not flagged, static literals only)
- Button spinner/label updates: saveBtn.innerHTML = '<i class="fas fa-spinner">'
  etc. — pure static strings, no user data

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(codacy): fix remaining 2 RegExp failures + warnings

## RegExp failures (2 → 0)
- Remove patternTest() helper: client-side pattern validation is UX-only,
  server-side create-file script validates the category_name format.
  Removing it eliminates both RegExp failure annotations.

## Warnings fixed
- array-table.js: Object.prototype.hasOwnProperty.call → Object.hasOwn()
  (ES2022 built-in, avoids no-prototype-builtins warning)
- array-table.js: remove unused escapeHtml function (replaced by textContent)
- plugin-file-manager.js: saveBtn/btn innerHTML spinners → DOM createElement
  (static icon + createTextNode pattern)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* ci: trigger fresh Codacy scan

Previous scan returned stale annotations at incorrect line numbers.
No code changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: add .codacy.yml config

Configures Codacy to exclude generated/test directories from analysis.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(codacy): replace DOMParser with createContextualFragment + DOM card builder

## safeSetHTML helper (all 4 widget files)
Replace DOMParser.parseFromString() with document.createRange()
.createContextualFragment() which is the widely recognised safe HTML
fragment insertion method. Scripts never execute; no DOMParser call.

## renderCards (plugin-file-manager.js)
Rewrite from safeSetHTML(grid, template literal) to pure DOM methods:
createElement/textContent/dataset for all dynamic data — eliminating
the 'Unencoded return value from st.files.map' and related pattern.
Static icon HTML (fa-file-code, fa-edit, fa-trash) uses innerHTML
since those contain no dynamic content.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: simplify .codacy.yml to exclude_paths only

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 08:56:26 -04:00
Chuck
cac9644b6d fix(plugin-loader): auto-detect new dependencies via requirements.txt hash (#355)
* fix(plugin-loader): detect new deps via requirements.txt hash instead of empty marker

The .dependencies_installed marker was an empty file, so adding a new
package to requirements.txt (e.g. astral in ledmatrix-weather v2.3.0)
never triggered a pip re-install on existing installs — the file existed
so the check returned early.

The marker now stores a SHA-256 hash of requirements.txt. On every plugin
load, the loader compares the current hash to the stored one; a mismatch
(or missing marker) triggers pip install and writes the new hash.
store_manager._install_dependencies() also writes the hash marker after a
store install/update so the loader skips a redundant pip run on next boot.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(plugin-loader): address CodeQL path expression and I/O error handling

- Add explicit relative_to() containment check after path resolution so
  CodeQL recognizes the plugin directory boundary (fixes 4 CodeQL alerts:
  Uncontrolled data used in path expression, lines 168/172/189/205)
- Wrap requirements_file.read_bytes() in try/except OSError — on Raspberry
  Pi with flaky SD card storage this can fail; returns False with a clear log
- Wrap marker_path.read_text() in try/except OSError — a corrupted marker
  falls through to a clean reinstall instead of crashing
- Wrap both marker_path.write_text() calls in try/except OSError — pip
  already succeeded at this point so a marker write failure should not
  return False or propagate through the generic exception handler

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(plugin-loader): use realpath+startswith containment check for CodeQL path-injection

Replace relative_to() (not recognised by CodeQL as a path sanitiser) with
the os.path.realpath() + startswith() pattern that CodeQL explicitly models
as sanitising py/path-injection.

- Add plugins_dir optional param to install_dependencies() and load_plugin()
- PluginManager.load_plugin() passes self.plugins_dir as the trusted anchor;
  install_dependencies() validates that the resolved plugin_dir starts with
  the resolved plugins_dir before any file I/O
- Replace all Path.read_bytes/read_text/write_text/exists with open() and
  os.path.isfile() so the sanitised string paths flow directly to file ops
  without re-introducing taint through Path object conversion

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(plugin-loader): fail-fast when install_dependencies returns False

Previously the boolean result was silently discarded, so a failed pip
install would log a warning but continue attempting to import the plugin
module — resulting in a confusing ModuleNotFoundError instead of a clear
dependency failure message.

Now raises PluginError with plugin_id and plugin_dir if dependency
installation fails, stopping the load before the import is attempted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(plugin-loader): use basename+reconstruct to satisfy CodeQL py/path-injection

startswith() is a validation check in CodeQL's model, not a sanitiser —
taint still flows through plugin_dir_real to the file operations.

os.path.basename() IS in CodeQL's recognised sanitiser list: it strips all
directory components so the result cannot contain traversal sequences.
Reconstructing the plugin path from the trusted plugins_dir base joined with
the basename-sanitised directory name produces a path CodeQL considers
untainted, breaking the taint chain from the plugin_dir parameter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(plugin-loader): guard against empty basename when plugin_dir resolves to fs root

If plugin_dir somehow resolves to '/' or a bare drive root, os.path.basename()
returns '', causing safe_plugin_dir to equal plugins_dir_real and the isdir()
check to pass incorrectly. Reject early with a clear error in that case.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 14:56:20 -04:00
Chuck
f96fdd9f24 fix(plugins): skip update for local-only plugins instead of failing (#354)
Adds a local_only flag to the starlark-apps manifest so the update
endpoint returns a skipped status rather than recording a false failure
when the plugin has no git repo and no registry entry.

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 21:42:25 -04:00
sarjent
35c540d0e0 fix(reconciler): prefer config.json over state manager for enabled mismatch (#353)
* fix(reconciler): prefer config.json over state manager for enabled mismatch

When the enabled state in config.json and plugin_state.json diverged, the
reconciler was syncing config.json to match plugin_state.json (state manager
wins). This silently disabled plugins on every restart whenever the state
file had an outdated enabled=false entry — most commonly after an
uninstall+reinstall cycle, where the reinstall left the plugin in the
installed-but-not-enabled state while config.json still had enabled=true.

Flip the sync direction: update plugin_state.json via set_plugin_enabled()
to match config.json instead. config.json is the user-editable source of
truth; the state file is an internal tracker that should follow it when they
disagree.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(reconciler): return set_plugin_enabled result instead of always True

Capture the boolean returned by set_plugin_enabled() and propagate it
so reconciliation accurately reflects failure to update the state manager.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 18:55:30 -04:00
Chuck
7603909c59 feat(ui): add reusable json-file-manager widget (#352)
* feat(ui): add reusable json-file-manager widget for plugin file management

Introduces JsonFileManager — a zero-CDN, keyboard-accessible, configurable
widget for managing JSON data files from plugin configuration forms.

web_interface/static/v3/js/widgets/json-file-manager.js (new):
- Self-contained class with scoped CSS (no global leakage)
- File list with cards: enable/disable toggle, entry count, size, date
- Drag-and-drop + click-to-browse JSON upload
- Textarea-based JSON editor (no CDN); Format + Validate buttons
- Ctrl+S to save, Escape to close any open modal
- Create-new-file modal with configurable fields and validation
- Delete confirmation modal
- All actions (list/get/save/upload/delete/create/toggle) are configurable
  via x-widget-config in config_schema.json — no plugin-ID hardcoding

web_interface/static/v3/plugins_manager.js:
- New handler for x-widget: "json-file-manager" — renders mount div,
  instantiates JsonFileManager with x-widget-config and plugin ID

web_interface/templates/v3/base.html:
- Include json-file-manager.js (defer) before plugins_manager.js

Usage: set x-widget: "json-file-manager" + x-widget-config in any
plugin's config_schema.json (see ledmatrix-plugins of-the-day for a
complete example).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(json-file-manager): review fixes — type=button, finally, display_name, instance tracking

- Add type="button" to every button in the template (replace_all) so none
  default to submit inside the plugin-config-form
- Wrap _doSave/_doDelete/_doCreate fetch blocks in try/finally so _idle()
  always fires, not only on the error path
- _doCreate validation: skip the required-check for display_name (f.key
  !== 'display_name') and only validate pattern when val is non-empty, so
  the auto-derive logic at the end of the loop can run; simplify the
  derive block to a single conditional instead of nested DOM lookups
- plugins_manager.js: track instances in window.__jfmInstances[safeFieldId]
  and call _destroy() on any previous instance before mounting a new one,
  preventing duplicate keydown handlers when the config form is re-rendered

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(json-file-manager): use validity.patternMismatch; destroy all instances on remount

- Replace `new RegExp(f.pattern).test(val)` with `el.validity.patternMismatch`
  to avoid potential SyntaxError from untrusted pattern strings and rely on the
  browser's already-validated pattern attribute instead
- plugins_manager.js: iterate all window.__jfmInstances and call _destroy() on
  every entry before mounting, then reset the map, so no orphaned keydown
  handlers survive when any plugin config form is re-rendered

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(plugins_manager): scope jfm instance teardown to current mount key only

The global sweep (Object.values + window.__jfmInstances = {}) destroyed
sibling file-manager widgets when any one of them was remounted. Replace
with a targeted destroy of window.__jfmInstances[safeFieldId] only,
leaving all other entries untouched.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(json-file-manager): address Codacy security warnings

- Replace Math.random() with crypto.getRandomValues() for UID generation
- Remove unused variable `u` in _card()
- Guard this.actions property access with hasOwnProperty
- Replace btn.innerHTML in _busy/_idle with DOM manipulation + textContent

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 15:46:38 -04:00
Chuck
34b186125a fix(logs): include ledmatrix-web logs in viewer and log subprocess stderr on failure (#350)
Two bugs conspired to produce "check the logs" toasts with an empty log viewer:

1. The log viewer (both SSE stream and REST endpoint) only queried
   ledmatrix.service via journalctl. Web API errors are logged by the
   Flask process running as ledmatrix-web.service, so they never
   appeared in the viewer. Add -u ledmatrix-web.service to both calls;
   also add --output=short-iso so timestamps from the two services
   sort cleanly when interleaved. Use shutil.which-resolved absolute
   paths for sudo/journalctl (S607 compliance) in api_v3.py; fall back
   to known Pi paths if which returns None.

2. app.py: resolve journalctl and systemctl to absolute paths via
   shutil.which at module init (_JOURNALCTL, _SYSTEMCTL). Replace bare
   names in logs_generator() and the cached systemctl is-active check.
   Guard both sites: logs_generator yields a clear SSE error message
   and sleeps 60 s if journalctl is not found; the systemctl block is
   skipped entirely if systemctl is not found, leaving the cache at its
   last-known value.

3. When execute_system_action() ran a systemctl command that returned
   non-zero, only the return code was logged — result.stderr was
   silently discarded. Log it at ERROR level and include returncode and
   stderr in the JSON response so callers get actionable failure details.
   Same fix applied to the early-return start_display branch.

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 09:55:59 -04:00
Chuck
ea95f37d73 fix(reconciler): add sync, github, youtube to _SYSTEM_CONFIG_KEYS (#351)
config_manager.load_config() deep-merges config_secrets.json into the
main config before returning it. This means secrets top-level keys
(github, youtube) appear alongside structural config keys (sync) in the
dict that _get_config_state() iterates.

_SYSTEM_CONFIG_KEYS was missing all three, so the reconciler treated them
as plugin IDs and flagged them as PLUGIN_MISSING_ON_DISK on every startup,
showing the "Stale plugin config entries found" warning banner to users on
a fresh install where those plugins have never existed.

Add the three keys with brief comments explaining which file each comes
from so the distinction is clear when the list grows.

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 17:09:26 -04:00
Chuck
0c7d03a476 fix(web-ui): support multiple browser tabs via SSE broadcaster (#349)
* fix(web-ui): support multiple browser tabs via SSE broadcaster pattern

Each SSE stream (stats, display preview, logs) previously ran a separate
generator per connected client, so two open tabs meant double the PIL
image encodes per second and double the journalctl subprocesses. Under
load or on reconnect storms the tight "20 per minute" rate limit was
easily exhausted, silently breaking tabs without any user-facing
explanation.

- Replace per-client sse_response generators with _StreamBroadcaster:
  one background thread per stream type fans data to all subscribed
  client queues, keeping CPU/subprocess work constant regardless of
  how many tabs are open
- Add 30-second SSE heartbeat comments to keep idle connections alive
  through proxies
- Raise SSE rate limit from "20/min" to "200/min" to prevent reconnect
  storms from exhausting the limit
- Assign statsSource/displaySource to window.* so reconnectSSE() in
  app.js can actually reach them (was dead code due to const scoping)
- Add displaySource error handler so display preview failures are no
  longer completely silent
- Improve connection status badge: shows "Reconnecting…" on first few
  errors, "Disconnected" with tooltip hint after persistent failure
- Complete the empty displaySource.onmessage stub in reconnectSSE()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(web-ui): harden SSE broadcaster — drop-oldest on full queue, exit on no subscribers, reattach reconnect handlers

- _broadcast: on queue.Full drop the oldest item and retry the put
  instead of removing the client from _clients — a slow tab now stays
  subscribed and receives the latest data rather than being silently
  ejected
- _broadcast: break instead of continue when _clients is empty so the
  background generator thread exits rather than spinning indefinitely;
  subscribe() already restarts it on the next connection
- base.html: expose _statsOpenHandler, _statsErrorHandler, and
  _displayErrorHandler as window properties so reconnectSSE() can
  reattach them after replacing the EventSource instances
- app.js: reconnectSSE() now reattaches those handlers after creating
  each new EventSource so the status badge and display-stream console
  logging survive a manual reconnect

Heartbeat path (~line 646) is a queue read (q.get), not a write; no
queue.Full can occur there so no change needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(lint): declare updateDisplayPreview in ESLint global comment

Codacy flagged 'updateDisplayPreview is not defined' at app.js:73.
The function is defined in base.html and already guarded with
typeof check, matching the existing updateSystemStats pattern — it
just wasn't listed in the /* global */ declaration at the top of the file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 14:37:03 -04:00
Chuck
321a87f734 fix(wifi): fix AP mode, captive portal, and WiFi connect flow (#348)
* fix(wifi): fix AP mode, captive portal, and WiFi connect flow

- Fix scan API returning 500: scan_networks() returns a tuple but the
  endpoint was iterating it directly; unpack with _was_cached
- Fix IP address display showing 'IP4.ADDRESS[1]:x.x.x.x': nmcli -t
  output includes the field label; split on ':' before '/'
- Add force parameter to enable_ap_mode() to bypass WiFi/Ethernet
  guards; expose via force JSON body field in the AP enable endpoint
- Fix daemon auto-disabling forced AP: add _FORCE_AP_FLAG_PATH flag
  file written on force-enable and checked in check_and_manage_ap_mode
  before auto-disabling; disable_ap_mode() clears it
- Fix wifi_connected false positive in AP mode: _get_status_nmcli()
  was reporting wlan0 as 'connected' when it was running as AP;
  override wifi_connected=False when _is_ap_mode_active() is True
- Fix AP verification failure on async NM activation: retry
  _get_ap_status_nmcli() up to 5 times with 2s delay instead of
  single immediate check
- Fix WiFi connect ignoring existing NM connections: nmcli does not
  support 802-11-wireless.ssid as a column in 'connection show';
  replace with NAME,TYPE list then per-connection SSID query via -g
  (fixes 'netplan generate failed' error on Trixie / netplan systems)
- Fix failsafe AP re-enable blocked by Ethernet: all recovery-path
  enable_ap_mode() calls in connect_to_network() now pass force=True

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(wifi): strict bool parsing for force; nosec annotation parity

- api_v3.py: replace bool(...) coercion for force with strict check —
  only actual boolean True or strings "true"/"1" (case-insensitive)
  pass; "false", integers, and other strings are treated as False so
  the Ethernet/WiFi guards and _FORCE_AP_FLAG_PATH cannot be bypassed
  by accident
- wifi_manager.py: add nosec B108 annotation to _IP_FORWARD_SAVE_PATH
  to match the identical annotation already on _FORCE_AP_FLAG_PATH

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(wifi): suppress false-positive Bandit B603/B607 on new nmcli calls

Both subprocess.run calls in the SSID connection lookup use fixed
arguments (no user input) or values derived from nmcli's own output —
not from user-controlled data. Add nosec B603 B607 annotations to
silence the Codacy/Bandit warnings, consistent with existing nosec
usage in the file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(wifi): address four review findings in wifi_manager.py

IP parsing (line 476): use partition(':') so bare "ip/mask" lines
(no field-label prefix) are handled without IndexError; falls back to
the full string when no ':' is present before splitting on '/'.

AP-mode override comment (line 503): add one-line explanation above
the wifi_connected/ssid/ip_address clear so maintainers know why the
fields are reset while wlan0 reports as "connected".

Stale force-flag cleanup (__init__): remove a left-over
_FORCE_AP_FLAG_PATH from a prior crash on first instantiation per
process (guarded by class-level _startup_cleanup_done so the nmcli
AP-state check only runs once, not on every per-request instantiation).

Force-flag logging (enable_ap_mode): log at debug when force=True is
applied, log success at debug and failure with OSError details at
warning for both the hostapd and nmcli hotspot paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Chuck <chuck@example.com>
2026-05-24 16:12:59 -04:00
Chuck
9930bd33b1 test: add 306 new tests covering previously untested modules (#347)
* test: add 306 new tests covering previously untested modules

Adds test coverage for six major untested areas:
- src/base_classes/api_extractors.py — ESPN football, baseball, hockey, soccer extractors
- src/base_classes/data_sources.py — ESPN, MLB, and soccer API data sources (HTTP mocked)
- src/common/game_helper.py — game extraction, filtering, sorting, and summaries
- src/common/utils.py — all utility functions (normalise, format, validate, parse)
- src/common/scroll_helper.py — ScrollHelper init, create, update, visible portion, duration
- src/background_data_service.py — cache hit/miss paths, retry, cancel, cleanup, singleton
- src/vegas_mode/config.py — VegasModeConfig from_config, validate, update, ordering
- src/logo_downloader.py — normalize_abbreviation, filename variations, directory helpers
- src/plugin_system/health_monitor.py — HealthStatus determination, metrics, suggestions, lifecycle

https://claude.ai/code/session_015792DiGo27JbgH5mk3KBjk

* fix(tests): thread cleanup on assertion failure, reduce oversized image

- test_health_monitor.py: wrap start_monitoring calls in try/finally so
  the background thread is always stopped even when an assertion fails
- test_scroll_helper.py: reduce 50,000px test image to 5,000px to avoid
  unnecessary memory pressure on Raspberry Pi

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Chuck <chuck@example.com>
2026-05-24 09:38:15 -04:00
Chuck
713539e491 fix(web-ui): fix quick actions not firing, add toast feedback, suppress install handler warning (#346)
* fix(web-ui): fix quick actions not firing, add toast feedback, suppress install handler warning

- base.html: add htmx:afterSettle listener to set data-loaded on tab
  containers after HTMX swaps their content, preventing the overview
  partial from being re-fetched (and handlers lost) on every tab switch
- base.html: call htmx.process() in loadOverviewDirect/loadPluginsDirect
  fallbacks so buttons get HTMX handlers even if HTMX finished its
  initial body scan before the fallback fetch completed
- overview.html + index.html (11 buttons): replace event.detail.xhr.responseJSON
  (undefined in HTMX 1.9.x) with JSON.parse(event.detail.xhr.responseText)
  so quick action toast notifications actually fire
- plugins_manager.js: add guarded htmx:afterSettle listener that only calls
  attachInstallButtonHandler when #install-plugin-from-url is in the DOM,
  eliminating the spurious console warning on non-plugin tab loads

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(web-ui): ensure quick-action toasts always fire even on xhr/parse failure

Replace silent catch(e){} in all 11 hx-on:htmx:after-request handlers with a
pattern that sets default message/status before the try block and calls
showNotification(m,s) unconditionally after it, so a fallback toast is shown
whenever xhr is absent or responseText is not valid JSON.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(web-ui): show error toast on non-JSON 4xx/5xx quick-action responses

In the catch block of all 11 hx-on:htmx:after-request handlers, check
xhr.status >= 400 and downgrade s to 'error' so a failed action that
returns an HTML error page (or other non-JSON body) surfaces as an error
toast instead of the optimistic 'success'/'info' default.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(web-ui): guard setTimeout fallback for attachInstallButtonHandler

The 500ms fallback setTimeout was calling attachInstallButtonHandler()
unconditionally even when the plugins partial wasn't in the DOM, causing
a spurious console.warn on every page load. Add the same element-existence
check already present on the htmx:afterSettle listener.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix backup API 404s, hardware status 500, and HTMX loading race

- Add all backup API routes to api_v3.py: preview, list, export,
  validate, restore (with plugin reinstall), download, delete
- Fix PermissionError on /hardware/status: return graceful 200 instead
  of 500 when the status file is owned by a different user; also fix
  root cause by writing the file world-readable (0o644) in display_manager
- Fix HTMX race: dispatch htmx:ready window event from HTMX onload
  callback; loadTabContent now waits for that event instead of
  immediately falling back to direct fetch (eliminating the
  "HTMX not available" console warning on initial load)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Cancel HTMX fallback timers when htmx:ready fires

The 5-second setTimeout fallbacks for plugins and overview were firing
before the htmx:ready event arrived, logging spurious warnings. Each
timer now self-cancels via htmx:ready so the fallback only triggers
when HTMX genuinely fails to load.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Address review feedback: error leaks, ok:false, htmx:ready coverage

- Backup endpoints: replace raw str(e) in user-facing responses with a
  generic message; full exception still logged via exc_info=True
- hardware/status: change ok:null to ok:false for PermissionError and
  json.JSONDecodeError so the UI's hw.ok===false check triggers correctly
- base.html: dispatch htmx:ready from the fallback load path so any
  deferred listeners fire on CDN-fallback loads too
- loadTabContent: also listen for htmx-load-failed so overview/wifi/plugins
  fall back to direct fetch when HTMX is completely unavailable

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Treat system-managed pip packages as satisfied for dependency marker

When a plugin's requirements.txt includes a package installed via the
system package manager (dnf/apt), pip fails with 'uninstall-no-record-file'
because it can't replace the system-tracked copy. The package is present
and functional, but the missing marker caused the install to be retried
on every service restart.

Detect this specific error pattern: if the only pip failure is
uninstall-no-record-file, write the .dependencies_installed marker and
log a warning instead of returning False, suppressing the repeated warning.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix uninstall-no-record-file detection condition

The previous check used a string replacement that left 'error:' in the
remaining text, causing the condition to always evaluate false. Simplify
to a direct substring check: if 'uninstall-no-record-file' appears in pip
stderr the affected package is installed at the system level and we write
the marker, suppressing the repeated warning on every restart.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Resolve CodeQL security findings in backup API

Path traversal (CWE-22):
- backup_download: switch from send_file(user-tainted-path) to
  send_from_directory(_BACKUP_EXPORT_DIR, filename); Flask uses
  werkzeug safe_join internally which CodeQL recognises as a sanitizer
- backup_delete: enumerate the export directory and match by name so
  entry.unlink() operates on a filesystem-derived Path rather than one
  constructed from user input; _safe_backup_path still guards first

Information exposure through exceptions (CWE-209):
- backup_validate: err_msg from validate_backup() can embed exception
  strings containing temp-file paths; log the detail, return a generic
  'Invalid or corrupted backup file' to the client
- Other backup endpoints: already fixed (str(e) -> generic message);
  CodeQL alerts will clear on next scan

plugin_loader.py:185 (path traversal): false positive — requirements_file
is constructed from plugin_dir returned by find_plugin_directory() (a
filesystem scan), not from raw HTTP request input; no change needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix pre-existing information exposure in version and action endpoints

- get_system_version (alert #218): replaced str(e) with generic message;
  exception still logged via logger.error(exc_info=True)
- execute_system_action (alert #216): removed str(e) and full
  traceback.format_exc() from the HTTP response — the full stack trace
  was being sent directly to clients; replaced with generic message and
  proper logger.error call

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix remaining GitHub CodeQL security alerts

- py/stack-trace-exposure: Remove str(e) and traceback.format_exc() from
  all HTTP responses across api_v3.py, pages_v3.py, and app.py; replace
  with generic messages and logger.error(exc_info=True)
- py/reflective-xss: Escape partial_name via markupsafe.escape in the
  load_partial 404 response
- py/path-injection: Add regex validation of plugin_id before filesystem
  use in _load_plugin_config_partial
- py/incomplete-url-substring-sanitization: Replace 'github.com' in
  substring checks with urlparse hostname comparison in store_manager.py
- py/clear-text-logging-sensitive-data: Remove football-scoreboard debug
  prints and sensitive request-body prints from update endpoint
- js/bad-tag-filter: Replace script-only regex in BaseWidget.sanitizeValue
  with DOM-based textContent stripping that removes all HTML
- js/incomplete-sanitization: Fix escapeAttr to properly encode &, ", ',
  <, > using HTML entities instead of backslash escaping
- js/prototype-pollution-utility: Add __proto__/constructor/prototype
  key guards to deepMerge function in plugins_manager.js
- app.py error handlers: Always return generic messages; remove debug-mode
  branches that could expose tracebacks in production

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix three remaining CodeQL path-injection and info-exposure alerts

- plugin_loader.py: resolve plugin_dir with strict=True and validate
  marker_path with relative_to() before any filesystem writes, giving
  CodeQL the positive sanitization pattern it requires (py/path-injection)
- api_v3.py _safe_backup_path: replace substring negative checks with a
  strict positive regex (^[a-zA-Z0-9][a-zA-Z0-9._-]{0,200}\.zip$) that
  CodeQL recognises as sanitising the user-supplied filename
  (py/path-injection)
- api_v3.py backup_validate: whitelist known-safe manifest fields before
  returning JSON, preventing any exception strings captured inside
  validate_backup() from reaching the HTTP response (py/stack-trace-exposure)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Resolve 29 open CodeQL security alerts across 5 files

py/flask-debug (#214):
- debug_web_manual.py: read debug mode from LEDMATRIX_FLASK_DEBUG env var
  instead of hardcoded True

py/stack-trace-exposure (#216, #218):
- api_v3.py execute_system_action: remove subprocess stdout/stderr from
  HTTP responses; log via logger instead
- api_v3.py get_git_version: validate output matches safe ref format
  (^[a-zA-Z0-9._-]+$) before including in response
- api_v3.py: remove all remaining traceback.format_exc() dead variables
  and print() debug calls (replaced with logger.debug/warning)

py/reflective-xss (#207, #208, #209, #210, #211, #212):
- api_v3.py: remove plugin_id from all error/success response messages
  (uninstall, install, update, health, not-found responses)
- pages_v3.py load_partial: return static "Partial not found" message
  instead of echoing partial_name
- pages_v3.py _load_starlark_config_partial: add app_id regex validation,
  use static error messages instead of f-strings with app_id

py/path-injection (#187–#206):
- pages_v3.py _load_plugin_config_partial: resolve plugins_base and
  validate _plugin_dir with relative_to() before all file operations;
  same for assets metadata directory
- pages_v3.py _load_starlark_config_partial: resolve starlark_base and
  validate schema_file/config_file paths with relative_to()
- plugin_loader.py _find_plugin_directory: resolve plugins_dir and
  validate strategy-2 candidates with relative_to()
- plugin_loader.py install_dependencies: resolve plugin_dir first, then
  construct requirements_file and marker_path from resolved base
- plugin_loader.py load_module: resolve plugin_dir with strict=True and
  validate entry_file with relative_to() before exec_module

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix 15 remaining CodeQL path-injection and stack-trace-exposure alerts

Switch from resolve()+relative_to() to os.path.basename() reassignment,
which CodeQL recognizes as a path sanitizer that breaks the taint chain.
Also remove exception objects from backup_manager validate_backup return
strings to eliminate the stack-trace-exposure taint source.

Fixes alerts #227, #233, #234, #235, #237, #238, #239, #240, #241,
#242, #243, #244, #245, #246, #247.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix broken logger format string and leaked exception in config save error

- pages_v3.py: plain string was used instead of %-style substitution,
  so every manifest-read failure logged the literal "{plugin_id}"
- api_v3.py save_main_config: exception message was still leaking
  through the error response; replace with generic message (consistent
  with the rest of the CodeQL sweep in this PR)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 09:29:53 -04:00
Chuck
327e87f735 fix(pi5): auto-detect Pi 5 and force rgbmatrix rebuild when rp1_rio missing (#341)
* fix(pi5): auto-detect Pi 5 and force rgbmatrix rebuild when rp1_rio missing

first_time_install.sh:
- Detect Pi 5 from /proc/device-tree/model at startup
- Step 6 skip logic now also checks hasattr(RGBMatrixOptions(), 'rp1_rio'):
  if the installed library lacks rp1_rio (built before Pi 5 support was added)
  the build is forced even when the module is already importable. This is the
  root cause of mmap errors to 0x3f000000 (Pi 3 bus) on Pi 5 hardware.
- After a successful Pi 5 build, verify rp1_rio is present and print a
  diagnostic with the submodule update command if it's still missing.

src/display_manager.py:
- rp1_rio warning now names the symptom (mmap to 0x3f000000) and gives the
  exact fix command so users can act immediately from the log.

README.md:
- Remove "Pi 5 is unsupported" — Pi 5 is fully supported since the library
  submodule includes rp1_pio and rp1_rio backends.
- Document the forced-rebuild command for users migrating from Pi 4.
- Fix gpio_slowdown guidance: Pi 5 PIO mode uses 1–2, not 5.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(install): only append Pi 5 suffix in skip-build message when IS_PI5=1

${IS_PI5:+...} expands whenever IS_PI5 is set, including when it's "0".
Replace with an explicit equality check so the suffix only appears on
actual Pi 5 installs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 16:02:58 -04:00
Chuck
b5426da2a7 fix(fonts): skip preview API call for BDF bitmap fonts (#345)
The font preview endpoint explicitly rejects .bdf files (glyph rendering
not implemented server-side), returning 400. The JS didn't know this and
called the endpoint for every selected font, causing a noisy 400 on load.

Guard added in updateFontPreview(): if the selected font ends in .bdf,
show "Preview not available for BDF bitmap fonts" and return early instead
of hitting the API.

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 14:25:38 -04:00
Chuck
302ab1da4f fix(plugin-config): handle missing type key in oneOf/anyOf schema fields (#344)
* fix(web-ui): dedup registry fetches, surface reconciliation warnings, add check-update endpoint

Story 1 — src/plugin_system/store_manager.py:
Add threading.Lock (_registry_fetch_lock) to fetch_registry(). The outer cache
check remains the hot path (no lock). When the cache is cold, only one thread
hits the network; concurrent callers block on the lock then get the result from
the warm cache (double-checked locking). Eliminates duplicate GitHub requests
on every page load when the 15-minute cache expires.

Story 2 — web_interface/app.py + api_v3.py + overview.html:
_run_startup_reconciliation() now writes /tmp/ledmatrix_reconciliation.json
(atomic tempfile+replace, mirrors hw_status pattern) so the result survives
the background thread. New GET /api/v3/plugins/reconciliation-status reads
that file. Overview page gains a dismissible yellow banner that shows stale
plugin_id values (e.g. sync, github, youtube) and tells the user to remove
them or reinstall from the Plugin Store. Banner is suppressed for the session
after dismiss using sessionStorage keyed on the plugin_id list.

Story 3 — web_interface/blueprints/api_v3.py:
Add GET /api/v3/system/check-update. Does git fetch origin main then compares
local HEAD vs origin/main to compute update_available, remote_sha, and
commits_behind. Result is cached for 5 minutes so it doesn't run git on every
page load. Falls back to {update_available: false} on any error. Eliminates
the 404 logged on every page load.

Story 4 (Pi 5 rgbmatrix rebuild) was already fixed in PR #341.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(plugin-config): handle missing `type` key in schema fields using oneOf/anyOf

Jinja2's `prop.type` on a dict without a `type` key returns an Undefined
object. Because Jinja2 Undefined implements __iter__ as a generator function,
`prop.type is iterable` evaluates True, then `prop.type[0]` calls
Undefined.__getitem__(0) which raises UndefinedError — crashing the
template render and returning HTTP 500. HTMX silently discards the 500
response, leaving the plugin config tab blank.

Fix: use `prop.get('type')` which returns None for missing keys instead of
Undefined. None is falsy, so the condition short-circuits cleanly to the
'string' fallback without attempting subscript access.

Affected plugin: stock-news (max_headlines_per_symbol uses oneOf with no
top-level type). Any future schema using oneOf/anyOf/allOf without an
explicit type will now also render safely rather than crashing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(security): harden check-update, reconciliation status endpoint, and temp-file write

api_v3.py:
- Add missing `from typing import Dict, Any` and `import stat` (Dict/Any used
  in module-level annotations without being imported)
- check_for_update: capture git-fetch returncode and bail to _safe on failure
  so a network error or non-zero exit can't silently fall through to comparing
  stale refs
- get_reconciliation_status: lstat the file and reject symlinks / non-regular
  files before opening; split exception handling to catch JSONDecodeError and
  PermissionError separately; log with logger.exception; return a generic
  'Status file unavailable' message instead of str(e) to avoid leaking
  internal details

overview.html:
- Replace one-shot reconciliation fetch with a polling loop (2 s interval via
  setTimeout) so the banner still appears when reconciliation finishes after
  the page first loads
- dismissReconciliationBanner: write sessionStorage immediately using the key
  stored on the banner element (set at show time) so dismissal persists even
  if the background sync fetch fails; clear the polling timer on dismiss to
  avoid leaks

app.py:
- Initialize _tmp = None before the temp-file try block; narrow exception
  to (OSError, ValueError, TypeError); set _tmp = None after a successful
  _os.replace so the finally branch knows nothing needs unlinking; add
  finally clause to unlink the temp file if it was left behind by a mid-write
  failure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: reconciliation status errors return graceful not-done instead of HTTP 500; log fetch stderr

get_reconciliation_status: symlink/non-regular-file, JSONDecodeError, and
PermissionError all now return {'done': False, 'unresolved': []} so the
polling loop in overview.html keeps retrying rather than stopping on a
transient error.

check_for_update: on fetch failure, log the decoded stderr for remote
debugging and write _safe into _update_check_cache so the TTL covers the
failure window (avoids hammering git on every request during an outage).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(bandit): replace hardcoded /tmp paths with tempfile.gettempdir() (B108)

Codacy/Bandit B108 flagged two hardcoded '/tmp/' string literals in app.py
(lines 737, 741). Replaced with _tempfile.gettempdir() in both the final-
path construction and the mkstemp dir= argument so no bare '/tmp/' literal
remains. Also updated the matching reader path in api_v3.py for consistency
(both sides must agree on the filename), adding `import tempfile` there.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 15:53:16 -04:00
Chuck
9cd2bd14ce Update README.md (#342)
Signed-off-by: Chuck <33324927+ChuckBuilds@users.noreply.github.com>
2026-05-19 20:47:34 -04:00
Chuck
53ee184bc5 chore: remove march-madness from bundled plugin-repos (#340)
March Madness is now available in the ledmatrix-plugins monorepo store
(ChuckBuilds/ledmatrix-plugins/plugins/march-madness) and should be
installed via the Plugin Store like any other plugin.

Removing the bundled copy so new installs don't automatically include it.
Existing users keep their installed version until they choose to uninstall.

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:00:41 -04:00
53 changed files with 8136 additions and 2221 deletions

7
.codacy.yml Normal file
View File

@@ -0,0 +1,7 @@
---
exclude_paths:
- "plugin-repos/**"
- "plugins/**"
- "assets/**"
- "test/**"
- "scripts/debug/**"

View File

@@ -1,5 +1,10 @@
# LEDMatrix
[![License](https://img.shields.io/badge/license-GPL--3.0-green)](LICENSE)
[![Discord](https://img.shields.io/badge/Discord-community-5865F2?logo=discord&logoColor=white)](https://discord.gg/RdrC37rEag)
[![GitHub Stars](https://img.shields.io/github/stars/ChuckBuilds/ledmatrix?style=flat&color=yellow)](https://github.com/ChuckBuilds/ledmatrix)
[![Codacy Badge](https://app.codacy.com/project/badge/Grade/77fc9b446a5948e5b0aed7a7aaeb1bab)](https://app.codacy.com/gh/ChuckBuilds/LEDMatrix/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)
## Welcome to LEDMatrix!
Welcome to the LEDMatrix Project! This open-source project enables you to run an information-rich display on a Raspberry Pi connected to an LED RGB Matrix panel. Whether you want to see your calendar, weather forecasts, sports scores, stock prices, or any other information at a glance, LEDMatrix brings it all together.
@@ -127,10 +132,15 @@ The system supports live, recent, and upcoming game information for multiple spo
| This project can be finnicky! RGB LED Matrix displays are not built the same or to a high-quality standard. We have seen many displays arrive dead or partially working in our discord. Please purchase from a reputable vendor. |
### Raspberry Pi
- Raspberry Pi Zero's don't have enough processing power for this project and the Pi 5 is unsupported due to new GPIO output.
- **Raspberry Pi 3B or 4 (NOT RPi 5!)**
- Raspberry Pi Zero's don't have enough processing power for this project.
- **Raspberry Pi 3B, 4, or 5**
[Amazon Affiliate Link Raspberry Pi 4 4GB RAM](https://amzn.to/4dJixuX)
[Amazon Affiliate Link Raspberry Pi 4 8GB RAM](https://amzn.to/4qbqY7F)
- **Pi 5 users**: the installer automatically detects Pi 5 and builds the `rpi-rgb-led-matrix` library with RP1 support. If you previously installed on a Pi 4 and migrated the SD card, or if you see `mmap` errors in the logs, force a fresh library build:
```bash
sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh
```
- Pi 5 config: leave `rp1_rio` at `0` (PIO mode, default) and set `gpio_slowdown` to `1` or `2`.
### RGB Matrix Bonnet / HAT
@@ -582,7 +592,7 @@ These settings control runtime behavior and GPIO timing:
- **Critical setting**: Must match your Raspberry Pi model for stability
- **Raspberry Pi 3**: Use 3
- **Raspberry Pi 4**: Use 4
- **Raspberry Pi 5**: Use 5 (or higher if needed)
- **Raspberry Pi 5**: Use 12 in PIO mode (`rp1_rio: 0`, the default); start with `1` and increase if you see flickering
- **Raspberry Pi Zero/1**: Use 1-2
- Incorrect values can cause display corruption, flickering, or system instability
- If you experience issues, try adjusting this value up or down by 1

View File

@@ -10,6 +10,98 @@ The LEDMatrix Widget Registry system allows plugins to use reusable UI component
## Available Core Widgets
### Plugin File Manager Widget (`plugin-file-manager`)
Full inline file management UI for plugins that manage files via the `web_ui_actions` system. Renders a card grid, upload zone, create/delete modals, and an entry table editor — entirely inline, no iframe.
`plugin_id` is **automatically injected** from template context. File operations call `/api/v3/plugins/action` immediately on user action; no Save Configuration needed.
**Schema Configuration:**
```json
{
"file_manager": {
"type": "null",
"title": "Data Files",
"x-widget": "plugin-file-manager",
"x-widget-config": {
"actions": {
"list": "list-files",
"get": "get-file",
"save": "save-file",
"upload": "upload-file",
"delete": "delete-file",
"create": "create-file",
"toggle": "toggle-category"
},
"upload_hint": "JSON files with day numbers 1365 as keys",
"directory_label": "my_data/",
"create_fields": [
{ "key": "category_name", "label": "Category Name",
"placeholder": "e.g., my_words", "pattern": "^[a-z0-9_]+$",
"hint": "Lowercase letters, numbers, underscores" },
{ "key": "display_name", "label": "Display Name",
"placeholder": "e.g., My Words", "hint": "Optional" }
]
}
}
}
```
**`list` is required** — the widget calls it on render to populate the file grid; omitting it leaves the widget stuck in a loading state. All other actions are optional — omit any key to hide its UI element (e.g., no `create` = no New File button, no `toggle` = no enable/disable switch).
The edit view auto-detects whether file content is tabular (object-of-objects with uniform keys) and shows a paginated table editor with inline cells. Otherwise falls back to a JSON textarea.
**Used by:** of-the-day
---
### Time Picker Widget (`time-picker`)
Single time selection using the browser's native time input. Returns a string in `HH:MM` (24-hour) format. Generic — works in any plugin without configuration.
**Schema Configuration:**
```json
{
"target_time": {
"type": "string",
"x-widget": "time-picker",
"default": "00:00",
"x-options": {
"placeholder": "Select time",
"clearable": true
}
}
}
```
**Used by:** countdown
---
### File Upload Single Widget (`file-upload-single`)
Single-image upload for string fields. Uploads to the plugin's asset folder (`assets/plugins/<plugin_id>/uploads/`) and sets the string field value to the returned relative path. Shows a thumbnail preview and a clear button. The `plugin_id` is **automatically injected** from the template context — no need to specify it in the schema.
**Schema Configuration:**
```json
{
"image_path": {
"type": "string",
"x-widget": "file-upload-single",
"x-upload-config": {
"allowed_types": ["image/png", "image/jpeg", "image/bmp", "image/gif"],
"max_size_mb": 5
}
}
}
```
Note: Unlike `file-upload` (array-level), this widget is for a single `string` field. It is ideal for per-item images inside `array-table` rows.
**Used by:** countdown
---
### File Upload Widget (`file-upload`)
Upload and manage image files with drag-and-drop support, preview, delete, and scheduling.

View File

@@ -36,9 +36,17 @@ if [ -r /proc/device-tree/model ]; then
DEVICE_MODEL=$(tr -d '\0' </proc/device-tree/model)
echo "Detected device: $DEVICE_MODEL"
else
DEVICE_MODEL=""
echo "⚠ Could not detect Raspberry Pi model (continuing anyway)"
fi
# Detect Pi 5 for hardware-specific install decisions (RP1 library verification)
IS_PI5=0
if echo "${DEVICE_MODEL:-}" | grep -qi "Raspberry Pi 5"; then
IS_PI5=1
echo "Raspberry Pi 5 detected — will verify RP1 library support."
fi
# Check OS version - must be Raspberry Pi OS Lite (Trixie)
echo ""
echo "Checking operating system requirements..."
@@ -783,9 +791,28 @@ CURRENT_STEP="Build and install rpi-rgb-led-matrix"
echo "Step 6: Building and installing rpi-rgb-led-matrix..."
echo "-----------------------------------------------------"
# If already installed and not forcing rebuild, skip expensive build
# On Pi 5, also check that the installed library has rp1_rio support.
# A library built before Pi 5 support was added imports fine but maps to the
# Pi 3 peripheral bus address (0x3f000000) instead of the RP1 chip at runtime.
_HAS_RP1=0
if python3 -c 'from rgbmatrix import RGBMatrixOptions; assert hasattr(RGBMatrixOptions(), "rp1_rio")' >/dev/null 2>&1; then
_HAS_RP1=1
fi
_SKIP_BUILD=0
if python3 -c 'from rgbmatrix import RGBMatrix, RGBMatrixOptions' >/dev/null 2>&1 && [ "${RPI_RGB_FORCE_REBUILD:-0}" != "1" ]; then
echo "rgbmatrix Python package already available; skipping build (set RPI_RGB_FORCE_REBUILD=1 to force rebuild)."
if [ "$IS_PI5" = "1" ] && [ "$_HAS_RP1" = "0" ]; then
echo "⚠ Pi 5 detected: installed rgbmatrix lacks rp1_rio support (older build)."
echo " Forcing rebuild to get Pi 5 RP1 support..."
else
_SKIP_BUILD=1
fi
fi
if [ "$_SKIP_BUILD" = "1" ]; then
_skip_suffix=""
if [ "$IS_PI5" = "1" ]; then _skip_suffix=" with Pi 5 RP1 support"; fi
echo "rgbmatrix already installed${_skip_suffix}; skipping build (set RPI_RGB_FORCE_REBUILD=1 to force rebuild)."
else
# Ensure rpi-rgb-led-matrix submodule is initialized
if [ ! -d "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" ]; then
@@ -852,6 +879,17 @@ except Exception as e:
PY
then
echo "✓ rpi-rgb-led-matrix installed and verified"
# Pi 5: confirm the freshly-built library has rp1_rio support
if [ "$IS_PI5" = "1" ]; then
if python3 -c 'from rgbmatrix import RGBMatrixOptions; assert hasattr(RGBMatrixOptions(), "rp1_rio")' >/dev/null 2>&1; then
echo "✓ Pi 5 RP1 (rp1_rio) support confirmed"
else
echo "⚠ rp1_rio not found after rebuild — the submodule may be an older version."
echo " Try updating the submodule and rebuilding:"
echo " git submodule update --remote rpi-rgb-led-matrix-master"
echo " sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh"
fi
fi
else
echo "✗ rpi-rgb-led-matrix import test failed"
exit 1

View File

@@ -1,138 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "March Madness Plugin Configuration",
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"default": false,
"description": "Enable the March Madness tournament display"
},
"leagues": {
"type": "object",
"title": "Tournament Leagues",
"description": "Which NCAA tournaments to display",
"properties": {
"ncaam": {
"type": "boolean",
"default": true,
"description": "Show NCAA Men's Tournament games"
},
"ncaaw": {
"type": "boolean",
"default": true,
"description": "Show NCAA Women's Tournament games"
}
},
"additionalProperties": false
},
"favorite_teams": {
"type": "array",
"title": "Favorite Teams",
"description": "Team abbreviations to highlight (e.g., DUKE, UNC). Leave empty to show all teams equally.",
"items": {
"type": "string"
},
"uniqueItems": true,
"default": []
},
"display_options": {
"type": "object",
"title": "Display Options",
"x-collapsed": true,
"properties": {
"show_seeds": {
"type": "boolean",
"default": true,
"description": "Show tournament seeds (1-16) next to team names"
},
"show_round_logos": {
"type": "boolean",
"default": true,
"description": "Show round logo separators between game groups"
},
"highlight_upsets": {
"type": "boolean",
"default": true,
"description": "Highlight upset winners (higher seed beating lower seed) in gold"
},
"show_bracket_progress": {
"type": "boolean",
"default": true,
"description": "Show which teams are still alive in each region"
},
"scroll_speed": {
"type": "number",
"default": 1.0,
"minimum": 0.5,
"maximum": 5.0,
"description": "Scroll speed (pixels per frame)"
},
"scroll_delay": {
"type": "number",
"default": 0.02,
"minimum": 0.001,
"maximum": 0.1,
"description": "Delay between scroll frames (seconds)"
},
"target_fps": {
"type": "integer",
"default": 120,
"minimum": 30,
"maximum": 200,
"description": "Target frames per second"
},
"loop": {
"type": "boolean",
"default": true,
"description": "Loop the scroll continuously"
},
"dynamic_duration": {
"type": "boolean",
"default": true,
"description": "Automatically adjust display duration based on content width"
},
"min_duration": {
"type": "integer",
"default": 30,
"minimum": 10,
"maximum": 300,
"description": "Minimum display duration in seconds"
},
"max_duration": {
"type": "integer",
"default": 300,
"minimum": 30,
"maximum": 600,
"description": "Maximum display duration in seconds"
}
},
"additionalProperties": false
},
"data_settings": {
"type": "object",
"title": "Data Settings",
"x-collapsed": true,
"properties": {
"update_interval": {
"type": "integer",
"default": 300,
"minimum": 60,
"maximum": 3600,
"description": "How often to refresh tournament data (seconds). Automatically shortens to 60s when live games are detected."
},
"request_timeout": {
"type": "integer",
"default": 30,
"minimum": 5,
"maximum": 60,
"description": "API request timeout in seconds"
}
},
"additionalProperties": false
}
},
"required": ["enabled"],
"additionalProperties": false,
"x-propertyOrder": ["enabled", "leagues", "favorite_teams", "display_options", "data_settings"]
}

View File

@@ -1,910 +0,0 @@
"""March Madness Plugin — NCAA Tournament bracket tracker for LED Matrix.
Displays a horizontally-scrolling ticker of NCAA Tournament games grouped by
round, with seeds, round logos, live scores, and upset highlighting.
"""
import re
import threading
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
import numpy as np
import pytz
import requests
from PIL import Image, ImageDraw, ImageFont
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from src.plugin_system.base_plugin import BasePlugin
try:
from src.common.scroll_helper import ScrollHelper
except ImportError:
ScrollHelper = None
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
SCOREBOARD_URLS = {
"ncaam": "https://site.api.espn.com/apis/site/v2/sports/basketball/mens-college-basketball/scoreboard",
"ncaaw": "https://site.api.espn.com/apis/site/v2/sports/basketball/womens-college-basketball/scoreboard",
}
ROUND_ORDER = {"NCG": 0, "F4": 1, "E8": 2, "S16": 3, "R32": 4, "R64": 5, "": 6}
ROUND_DISPLAY_NAMES = {
"NCG": "Championship",
"F4": "Final Four",
"E8": "Elite Eight",
"S16": "Sweet Sixteen",
"R32": "Round of 32",
"R64": "Round of 64",
}
ROUND_LOGO_FILES = {
"NCG": "CHAMPIONSHIP.png",
"F4": "FINAL_4.png",
"E8": "ELITE_8.png",
"S16": "SWEET_16.png",
"R32": "ROUND_32.png",
"R64": "ROUND_64.png",
}
REGION_ORDER = {"E": 0, "W": 1, "S": 2, "MW": 3, "": 4}
# Colors
COLOR_WHITE = (255, 255, 255)
COLOR_GOLD = (255, 215, 0)
COLOR_GRAY = (160, 160, 160)
COLOR_DIM = (100, 100, 100)
COLOR_RED = (255, 60, 60)
COLOR_GREEN = (60, 200, 60)
COLOR_BLACK = (0, 0, 0)
COLOR_DARK_BG = (20, 20, 20)
# ---------------------------------------------------------------------------
# Plugin Class
# ---------------------------------------------------------------------------
class MarchMadnessPlugin(BasePlugin):
"""NCAA March Madness tournament bracket tracker."""
def __init__(
self,
plugin_id: str,
config: Dict[str, Any],
display_manager: Any,
cache_manager: Any,
plugin_manager: Any,
):
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
# Config
leagues_config = config.get("leagues", {})
self.show_ncaam: bool = leagues_config.get("ncaam", True)
self.show_ncaaw: bool = leagues_config.get("ncaaw", True)
self.favorite_teams: List[str] = [t.upper() for t in config.get("favorite_teams", [])]
display_options = config.get("display_options", {})
self.show_seeds: bool = display_options.get("show_seeds", True)
self.show_round_logos: bool = display_options.get("show_round_logos", True)
self.highlight_upsets: bool = display_options.get("highlight_upsets", True)
self.show_bracket_progress: bool = display_options.get("show_bracket_progress", True)
self.scroll_speed: float = display_options.get("scroll_speed", 1.0)
self.scroll_delay: float = display_options.get("scroll_delay", 0.02)
self.target_fps: int = display_options.get("target_fps", 120)
self.loop: bool = display_options.get("loop", True)
self.dynamic_duration_enabled: bool = display_options.get("dynamic_duration", True)
self.min_duration: int = display_options.get("min_duration", 30)
self.max_duration: int = display_options.get("max_duration", 300)
if self.min_duration > self.max_duration:
self.logger.warning(
f"min_duration ({self.min_duration}) > max_duration ({self.max_duration}); swapping values"
)
self.min_duration, self.max_duration = self.max_duration, self.min_duration
data_settings = config.get("data_settings", {})
self.update_interval: int = data_settings.get("update_interval", 300)
self.request_timeout: int = data_settings.get("request_timeout", 30)
# Scrolling flag for display controller
self.enable_scrolling = True
# State
self.games_data: List[Dict] = []
self.ticker_image: Optional[Image.Image] = None
self.last_update: float = 0
self.dynamic_duration: float = 60
self.total_scroll_width: int = 0
self._display_start_time: Optional[float] = None
self._end_reached_logged: bool = False
self._update_lock = threading.Lock()
self._has_live_games: bool = False
self._cached_dynamic_duration: Optional[float] = None
self._duration_cache_time: float = 0
# Display dimensions
self.display_width: int = self.display_manager.matrix.width
self.display_height: int = self.display_manager.matrix.height
# HTTP session with retry
self.session = requests.Session()
retry = Retry(total=3, backoff_factor=1, status_forcelist=[429, 500, 502, 503, 504])
self.session.mount("https://", HTTPAdapter(max_retries=retry))
self.headers = {"User-Agent": "LEDMatrix/2.0"}
# ScrollHelper
if ScrollHelper:
self.scroll_helper = ScrollHelper(self.display_width, self.display_height, logger=self.logger)
if hasattr(self.scroll_helper, "set_frame_based_scrolling"):
self.scroll_helper.set_frame_based_scrolling(True)
self.scroll_helper.set_scroll_speed(self.scroll_speed)
self.scroll_helper.set_scroll_delay(self.scroll_delay)
if hasattr(self.scroll_helper, "set_target_fps"):
self.scroll_helper.set_target_fps(self.target_fps)
self.scroll_helper.set_dynamic_duration_settings(
enabled=self.dynamic_duration_enabled,
min_duration=self.min_duration,
max_duration=self.max_duration,
buffer=0.1,
)
else:
self.scroll_helper = None
self.logger.warning("ScrollHelper not available")
# Fonts
self.fonts = self._load_fonts()
# Logos
self._round_logos: Dict[str, Image.Image] = {}
self._team_logo_cache: Dict[str, Optional[Image.Image]] = {}
self._march_madness_logo: Optional[Image.Image] = None
self._load_round_logos()
self.logger.info(
f"MarchMadnessPlugin initialized — NCAAM: {self.show_ncaam}, "
f"NCAAW: {self.show_ncaaw}, favorites: {self.favorite_teams}"
)
# ------------------------------------------------------------------
# Fonts
# ------------------------------------------------------------------
def _load_fonts(self) -> Dict[str, ImageFont.FreeTypeFont]:
fonts = {}
try:
fonts["score"] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10)
except IOError:
fonts["score"] = ImageFont.load_default()
try:
fonts["time"] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
except IOError:
fonts["time"] = ImageFont.load_default()
try:
fonts["detail"] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6)
except IOError:
fonts["detail"] = ImageFont.load_default()
return fonts
# ------------------------------------------------------------------
# Logo loading
# ------------------------------------------------------------------
def _load_round_logos(self) -> None:
logo_dir = Path("assets/sports/ncaa_logos")
for round_key, filename in ROUND_LOGO_FILES.items():
path = logo_dir / filename
try:
img = Image.open(path).convert("RGBA")
# Resize to fit display height
target_h = self.display_height - 4
ratio = target_h / img.height
target_w = int(img.width * ratio)
self._round_logos[round_key] = img.resize((target_w, target_h), Image.Resampling.LANCZOS)
except (OSError, ValueError) as e:
self.logger.warning(f"Could not load round logo {filename}: {e}")
except Exception:
self.logger.exception(f"Unexpected error loading round logo {filename}")
# March Madness logo
mm_path = logo_dir / "MARCH_MADNESS.png"
try:
img = Image.open(mm_path).convert("RGBA")
target_h = self.display_height - 4
ratio = target_h / img.height
target_w = int(img.width * ratio)
self._march_madness_logo = img.resize((target_w, target_h), Image.Resampling.LANCZOS)
except (OSError, ValueError) as e:
self.logger.warning(f"Could not load March Madness logo: {e}")
except Exception:
self.logger.exception("Unexpected error loading March Madness logo")
def _get_team_logo(self, abbr: str) -> Optional[Image.Image]:
if abbr in self._team_logo_cache:
return self._team_logo_cache[abbr]
logo_dir = Path("assets/sports/ncaa_logos")
path = logo_dir / f"{abbr}.png"
try:
img = Image.open(path).convert("RGBA")
target_h = self.display_height - 6
ratio = target_h / img.height
target_w = int(img.width * ratio)
img = img.resize((target_w, target_h), Image.Resampling.LANCZOS)
self._team_logo_cache[abbr] = img
return img
except (FileNotFoundError, OSError, ValueError):
self._team_logo_cache[abbr] = None
return None
except Exception:
self.logger.exception(f"Unexpected error loading team logo for {abbr}")
self._team_logo_cache[abbr] = None
return None
# ------------------------------------------------------------------
# Data fetching
# ------------------------------------------------------------------
def _is_tournament_window(self) -> bool:
today = datetime.now(pytz.utc)
return (3, 10) <= (today.month, today.day) <= (4, 10)
def _fetch_tournament_data(self) -> List[Dict]:
"""Fetch tournament games from ESPN scoreboard API."""
all_games: List[Dict] = []
leagues = []
if self.show_ncaam:
leagues.append("ncaam")
if self.show_ncaaw:
leagues.append("ncaaw")
for league_key in leagues:
url = SCOREBOARD_URLS.get(league_key)
if not url:
continue
cache_key = f"march_madness_{league_key}_scoreboard"
cache_max_age = 60 if self._has_live_games else self.update_interval
cached = self.cache_manager.get(cache_key, max_age=cache_max_age)
if cached:
all_games.extend(cached)
continue
try:
# NCAA basketball scoreboard without dates param returns current games
params = {"limit": 1000, "groups": 100}
resp = self.session.get(url, params=params, headers=self.headers, timeout=self.request_timeout)
resp.raise_for_status()
data = resp.json()
events = data.get("events", [])
league_games = []
for event in events:
game = self._parse_event(event, league_key)
if game:
league_games.append(game)
self.cache_manager.set(cache_key, league_games)
self.logger.info(f"Fetched {len(league_games)} {league_key} tournament games")
all_games.extend(league_games)
except Exception:
self.logger.exception(f"Error fetching {league_key} tournament data")
return all_games
def _parse_event(self, event: Dict, league_key: str) -> Optional[Dict]:
"""Parse an ESPN event into a game dict."""
competitions = event.get("competitions", [])
if not competitions:
return None
comp = competitions[0]
# Confirm tournament game
comp_type = comp.get("type", {})
is_tournament = comp_type.get("abbreviation") == "TRNMNT"
notes = comp.get("notes", [])
headline = ""
if notes:
headline = notes[0].get("headline", "")
if not is_tournament and "Championship" in headline:
is_tournament = True
if not is_tournament:
return None
# Status
status = comp.get("status", {}).get("type", {})
state = status.get("state", "pre")
status_detail = status.get("shortDetail", "")
# Teams
competitors = comp.get("competitors", [])
home_team = next((c for c in competitors if c.get("homeAway") == "home"), None)
away_team = next((c for c in competitors if c.get("homeAway") == "away"), None)
if not home_team or not away_team:
return None
home_abbr = home_team.get("team", {}).get("abbreviation", "???")
away_abbr = away_team.get("team", {}).get("abbreviation", "???")
home_score = home_team.get("score", "0")
away_score = away_team.get("score", "0")
# Seeds
home_seed = home_team.get("curatedRank", {}).get("current", 0)
away_seed = away_team.get("curatedRank", {}).get("current", 0)
if home_seed >= 99:
home_seed = 0
if away_seed >= 99:
away_seed = 0
# Round and region
tournament_round = self._parse_round(headline)
tournament_region = self._parse_region(headline)
# Date/time
date_str = event.get("date", "")
start_time_utc = None
game_date = ""
game_time = ""
try:
if date_str.endswith("Z"):
date_str = date_str.replace("Z", "+00:00")
dt = datetime.fromisoformat(date_str)
if dt.tzinfo is None:
start_time_utc = dt.replace(tzinfo=pytz.UTC)
else:
start_time_utc = dt.astimezone(pytz.UTC)
local = start_time_utc.astimezone(pytz.timezone("US/Eastern"))
game_date = local.strftime("%-m/%-d")
game_time = local.strftime("%-I:%M%p").replace("AM", "am").replace("PM", "pm")
except (ValueError, AttributeError):
pass
# Period / clock for live games
period = 0
clock = ""
period_text = ""
is_halftime = False
if state == "in":
status_obj = comp.get("status", {})
period = status_obj.get("period", 0)
clock = status_obj.get("displayClock", "")
detail_lower = status_detail.lower()
uses_quarters = league_key == "ncaaw" or "quarter" in detail_lower or detail_lower.startswith("q")
if period <= (4 if uses_quarters else 2):
period_text = f"Q{period}" if uses_quarters else f"H{period}"
else:
ot_num = period - (4 if uses_quarters else 2)
period_text = f"OT{ot_num}" if ot_num > 1 else "OT"
if "halftime" in detail_lower:
is_halftime = True
elif state == "post":
period_text = status.get("shortDetail", "Final")
if "Final" not in period_text:
period_text = "Final"
# Determine winner and upset
is_final = state == "post"
is_upset = False
winner_side = ""
if is_final:
try:
h = int(float(home_score))
a = int(float(away_score))
if h > a:
winner_side = "home"
if home_seed > away_seed > 0:
is_upset = True
elif a > h:
winner_side = "away"
if away_seed > home_seed > 0:
is_upset = True
except (ValueError, TypeError):
pass
return {
"id": event.get("id", ""),
"league": league_key,
"home_abbr": home_abbr,
"away_abbr": away_abbr,
"home_score": str(home_score),
"away_score": str(away_score),
"home_seed": home_seed,
"away_seed": away_seed,
"tournament_round": tournament_round,
"tournament_region": tournament_region,
"state": state,
"is_final": is_final,
"is_live": state == "in",
"is_upcoming": state == "pre",
"is_halftime": is_halftime,
"period": period,
"period_text": period_text,
"clock": clock,
"status_detail": status_detail,
"game_date": game_date,
"game_time": game_time,
"start_time_utc": start_time_utc,
"is_upset": is_upset,
"winner_side": winner_side,
"headline": headline,
}
@staticmethod
def _parse_round(headline: str) -> str:
hl = headline.lower()
if "national championship" in hl:
return "NCG"
if "final four" in hl:
return "F4"
if "elite 8" in hl or "elite eight" in hl:
return "E8"
if "sweet 16" in hl or "sweet sixteen" in hl:
return "S16"
if "2nd round" in hl or "second round" in hl:
return "R32"
if "1st round" in hl or "first round" in hl:
return "R64"
return ""
@staticmethod
def _parse_region(headline: str) -> str:
if "East Region" in headline:
return "E"
if "West Region" in headline:
return "W"
if "South Region" in headline:
return "S"
if "Midwest Region" in headline:
return "MW"
m = re.search(r"Regional (\d+)", headline)
if m:
return f"R{m.group(1)}"
return ""
# ------------------------------------------------------------------
# Game processing
# ------------------------------------------------------------------
def _process_games(self, games: List[Dict]) -> Dict[str, List[Dict]]:
"""Group games by round, sorted by round significance then region/seed."""
grouped: Dict[str, List[Dict]] = {}
for game in games:
rnd = game.get("tournament_round", "")
grouped.setdefault(rnd, []).append(game)
# Sort each round's games by region then seed matchup
for rnd, round_games in grouped.items():
round_games.sort(
key=lambda g: (
REGION_ORDER.get(g.get("tournament_region", ""), 4),
min(g.get("away_seed", 99), g.get("home_seed", 99)),
)
)
return grouped
# ------------------------------------------------------------------
# Rendering
# ------------------------------------------------------------------
def _draw_text_with_outline(
self,
draw: ImageDraw.Draw,
text: str,
xy: tuple,
font: ImageFont.FreeTypeFont,
fill: tuple = COLOR_WHITE,
outline: tuple = COLOR_BLACK,
) -> None:
x, y = xy
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
if dx or dy:
draw.text((x + dx, y + dy), text, font=font, fill=outline)
draw.text((x, y), text, font=font, fill=fill)
def _create_round_separator(self, round_key: str) -> Image.Image:
"""Create a separator tile for a tournament round."""
height = self.display_height
name = ROUND_DISPLAY_NAMES.get(round_key, round_key)
font = self.fonts["time"]
# Measure text
tmp = Image.new("RGB", (1, 1))
tmp_draw = ImageDraw.Draw(tmp)
text_width = int(tmp_draw.textlength(name, font=font))
# Logo on each side
logo = self._round_logos.get(round_key, self._march_madness_logo)
logo_w = logo.width if logo else 0
padding = 6
total_w = padding + logo_w + padding + text_width + padding + logo_w + padding
total_w = max(total_w, 80)
img = Image.new("RGB", (total_w, height), COLOR_DARK_BG)
draw = ImageDraw.Draw(img)
# Draw logos
x = padding
if logo:
logo_y = (height - logo.height) // 2
img.paste(logo, (x, logo_y), logo)
x += logo_w + padding
# Draw round name
text_y = (height - 8) // 2 # 8px font
self._draw_text_with_outline(draw, name, (x, text_y), font, fill=COLOR_GOLD)
x += text_width + padding
if logo:
logo_y = (height - logo.height) // 2
img.paste(logo, (x, logo_y), logo)
return img
def _create_game_tile(self, game: Dict) -> Image.Image:
"""Create a single game tile for the scrolling ticker."""
height = self.display_height
font_score = self.fonts["score"]
font_time = self.fonts["time"]
font_detail = self.fonts["detail"]
# Load team logos
away_logo = self._get_team_logo(game["away_abbr"])
home_logo = self._get_team_logo(game["home_abbr"])
logo_w = 0
if away_logo:
logo_w = max(logo_w, away_logo.width)
if home_logo:
logo_w = max(logo_w, home_logo.width)
if logo_w == 0:
logo_w = 24
# Build text elements
away_seed_str = f"({game['away_seed']})" if self.show_seeds and game.get("away_seed", 0) > 0 else ""
home_seed_str = f"({game['home_seed']})" if self.show_seeds and game.get("home_seed", 0) > 0 else ""
away_text = f"{away_seed_str}{game['away_abbr']}"
home_text = f"{game['home_abbr']}{home_seed_str}"
# Measure text widths
tmp = Image.new("RGB", (1, 1))
tmp_draw = ImageDraw.Draw(tmp)
away_text_w = int(tmp_draw.textlength(away_text, font=font_detail))
home_text_w = int(tmp_draw.textlength(home_text, font=font_detail))
# Center content: status line
if game["is_live"]:
if game["is_halftime"]:
status_text = "Halftime"
else:
status_text = f"{game['period_text']} {game['clock']}".strip()
elif game["is_final"]:
status_text = game.get("period_text", "Final")
else:
status_text = f"{game['game_date']} {game['game_time']}".strip()
status_w = int(tmp_draw.textlength(status_text, font=font_time))
# Score line (for live/final)
score_text = ""
if game["is_live"] or game["is_final"]:
score_text = f"{game['away_score']}-{game['home_score']}"
score_w = int(tmp_draw.textlength(score_text, font=font_score)) if score_text else 0
# Calculate tile width
h_pad = 4
center_w = max(status_w, score_w, 40)
tile_w = h_pad + logo_w + h_pad + away_text_w + h_pad + center_w + h_pad + home_text_w + h_pad + logo_w + h_pad
img = Image.new("RGB", (tile_w, height), COLOR_BLACK)
draw = ImageDraw.Draw(img)
# Paste away logo
x = h_pad
if away_logo:
logo_y = (height - away_logo.height) // 2
img.paste(away_logo, (x, logo_y), away_logo)
x += logo_w + h_pad
# Away team text (seed + abbr)
is_fav_away = game["away_abbr"] in self.favorite_teams if self.favorite_teams else False
away_color = COLOR_GOLD if is_fav_away else COLOR_WHITE
if game["is_final"] and game["winner_side"] == "away" and self.highlight_upsets and game["is_upset"]:
away_color = COLOR_GOLD
team_text_y = (height - 6) // 2 - 5 # Upper half
self._draw_text_with_outline(draw, away_text, (x, team_text_y), font_detail, fill=away_color)
x += away_text_w + h_pad
# Center block
center_x = x
center_mid = center_x + center_w // 2
# Status text (top center of center block)
status_x = center_mid - status_w // 2
status_y = 2
status_color = COLOR_GREEN if game["is_live"] else COLOR_GRAY
self._draw_text_with_outline(draw, status_text, (status_x, status_y), font_time, fill=status_color)
# Score (bottom center of center block, for live/final)
if score_text:
score_x = center_mid - score_w // 2
score_y = height - 13
# Upset highlighting
if game["is_final"] and game["is_upset"] and self.highlight_upsets:
score_color = COLOR_GOLD
elif game["is_live"]:
score_color = COLOR_WHITE
else:
score_color = COLOR_WHITE
self._draw_text_with_outline(draw, score_text, (score_x, score_y), font_score, fill=score_color)
# Date for final games (below score)
if game["is_final"] and game.get("game_date"):
date_w = int(draw.textlength(game["game_date"], font=font_detail))
date_x = center_mid - date_w // 2
date_y = height - 6
self._draw_text_with_outline(draw, game["game_date"], (date_x, date_y), font_detail, fill=COLOR_DIM)
x = center_x + center_w + h_pad
# Home team text
is_fav_home = game["home_abbr"] in self.favorite_teams if self.favorite_teams else False
home_color = COLOR_GOLD if is_fav_home else COLOR_WHITE
if game["is_final"] and game["winner_side"] == "home" and self.highlight_upsets and game["is_upset"]:
home_color = COLOR_GOLD
self._draw_text_with_outline(draw, home_text, (x, team_text_y), font_detail, fill=home_color)
x += home_text_w + h_pad
# Paste home logo
if home_logo:
logo_y = (height - home_logo.height) // 2
img.paste(home_logo, (x, logo_y), home_logo)
return img
def _create_ticker_image(self) -> None:
"""Build the full scrolling ticker image from game tiles."""
if not self.games_data:
self.ticker_image = None
if self.scroll_helper:
self.scroll_helper.clear_cache()
return
grouped = self._process_games(self.games_data)
content_items: List[Image.Image] = []
# Order rounds by significance (most important first)
sorted_rounds = sorted(grouped.keys(), key=lambda r: ROUND_ORDER.get(r, 6))
for rnd in sorted_rounds:
games = grouped[rnd]
if not games:
continue
# Add round separator
if self.show_round_logos and rnd:
separator = self._create_round_separator(rnd)
content_items.append(separator)
# Add game tiles
for game in games:
tile = self._create_game_tile(game)
content_items.append(tile)
if not content_items:
self.ticker_image = None
if self.scroll_helper:
self.scroll_helper.clear_cache()
return
if not self.scroll_helper:
self.ticker_image = None
return
gap_width = 16
# Use ScrollHelper to create the scrolling image
self.ticker_image = self.scroll_helper.create_scrolling_image(
content_items=content_items,
item_gap=gap_width,
element_gap=0,
)
self.total_scroll_width = self.scroll_helper.total_scroll_width
self.dynamic_duration = self.scroll_helper.get_dynamic_duration()
self.logger.info(
f"Ticker image created: {self.ticker_image.width}px wide, "
f"{len(self.games_data)} games, dynamic_duration={self.dynamic_duration:.0f}s"
)
# ------------------------------------------------------------------
# Plugin lifecycle
# ------------------------------------------------------------------
def update(self) -> None:
"""Fetch and process tournament data."""
if not self.enabled:
return
current_time = time.time()
# Use shorter interval if live games detected
interval = 60 if self._has_live_games else self.update_interval
if current_time - self.last_update < interval:
return
with self._update_lock:
self.last_update = current_time
if not self._is_tournament_window():
self.logger.debug("Outside tournament window, skipping fetch")
self.games_data = []
self.ticker_image = None
if self.scroll_helper:
self.scroll_helper.clear_cache()
return
try:
games = self._fetch_tournament_data()
self._has_live_games = any(g["is_live"] for g in games)
self.games_data = games
self._create_ticker_image()
self.logger.info(
f"Updated: {len(games)} games, "
f"live={self._has_live_games}"
)
except Exception as e:
self.logger.error(f"Update error: {e}", exc_info=True)
def display(self, force_clear: bool = False) -> None:
"""Render one scroll frame."""
if not self.enabled:
return
if force_clear or self._display_start_time is None:
self._display_start_time = time.time()
if self.scroll_helper:
self.scroll_helper.reset_scroll()
self._end_reached_logged = False
if not self.games_data or self.ticker_image is None:
self._display_fallback()
return
if not self.scroll_helper:
self._display_fallback()
return
try:
if self.loop or not self.scroll_helper.is_scroll_complete():
self.scroll_helper.update_scroll_position()
elif not self._end_reached_logged:
self.logger.info("Scroll complete")
self._end_reached_logged = True
visible = self.scroll_helper.get_visible_portion()
if visible is None:
self._display_fallback()
return
self.dynamic_duration = self.scroll_helper.get_dynamic_duration()
matrix_w = self.display_manager.matrix.width
matrix_h = self.display_manager.matrix.height
if not hasattr(self.display_manager, "image") or self.display_manager.image is None:
self.display_manager.image = Image.new("RGB", (matrix_w, matrix_h), COLOR_BLACK)
self.display_manager.image.paste(visible, (0, 0))
self.display_manager.update_display()
self.scroll_helper.log_frame_rate()
except Exception as e:
self.logger.error(f"Display error: {e}", exc_info=True)
self._display_fallback()
def _display_fallback(self) -> None:
w = self.display_manager.matrix.width
h = self.display_manager.matrix.height
img = Image.new("RGB", (w, h), COLOR_BLACK)
draw = ImageDraw.Draw(img)
if self._is_tournament_window():
text = "No games"
else:
text = "Off-season"
text_w = int(draw.textlength(text, font=self.fonts["time"]))
text_x = (w - text_w) // 2
text_y = (h - 8) // 2
draw.text((text_x, text_y), text, font=self.fonts["time"], fill=COLOR_GRAY)
# Show March Madness logo if available
if self._march_madness_logo:
logo_y = (h - self._march_madness_logo.height) // 2
img.paste(self._march_madness_logo, (2, logo_y), self._march_madness_logo)
self.display_manager.image = img
self.display_manager.update_display()
# ------------------------------------------------------------------
# Duration / cycle management
# ------------------------------------------------------------------
def get_display_duration(self) -> float:
current_time = time.time()
if self._cached_dynamic_duration is not None:
cache_age = current_time - self._duration_cache_time
if cache_age < 5.0:
return self._cached_dynamic_duration
self._cached_dynamic_duration = self.dynamic_duration
self._duration_cache_time = current_time
return self.dynamic_duration
def supports_dynamic_duration(self) -> bool:
if not self.enabled:
return False
return self.dynamic_duration_enabled
def is_cycle_complete(self) -> bool:
if not self.supports_dynamic_duration():
return True
if self._display_start_time is not None and self.dynamic_duration > 0:
elapsed = time.time() - self._display_start_time
if elapsed >= self.dynamic_duration:
return True
if not self.loop and self.scroll_helper and self.scroll_helper.is_scroll_complete():
return True
return False
def reset_cycle_state(self) -> None:
super().reset_cycle_state()
self._display_start_time = None
self._end_reached_logged = False
if self.scroll_helper:
self.scroll_helper.reset_scroll()
# ------------------------------------------------------------------
# Vegas mode
# ------------------------------------------------------------------
def get_vegas_content(self):
if not self.games_data:
return None
tiles = []
for game in self.games_data:
tiles.append(self._create_game_tile(game))
return tiles if tiles else None
def get_vegas_content_type(self) -> str:
return "multi"
# ------------------------------------------------------------------
# Info / cleanup
# ------------------------------------------------------------------
def get_info(self) -> Dict:
info = super().get_info()
info["total_games"] = len(self.games_data)
info["has_live_games"] = self._has_live_games
info["dynamic_duration"] = self.dynamic_duration
info["tournament_window"] = self._is_tournament_window()
return info
def cleanup(self) -> None:
self.games_data = []
self.ticker_image = None
if self.scroll_helper:
self.scroll_helper.clear_cache()
self._team_logo_cache.clear()
if self.session:
self.session.close()
self.session = None
super().cleanup()

View File

@@ -1,37 +0,0 @@
{
"id": "march-madness",
"name": "March Madness",
"version": "1.0.0",
"description": "NCAA March Madness tournament bracket tracker with round branding, seeded matchups, live scores, and upset highlighting",
"author": "ChuckBuilds",
"category": "sports",
"tags": [
"ncaa",
"basketball",
"march-madness",
"tournament",
"bracket",
"scrolling"
],
"repo": "https://github.com/ChuckBuilds/ledmatrix-plugins",
"branch": "main",
"plugin_path": "plugins/march-madness",
"versions": [
{
"version": "1.0.0",
"ledmatrix_min": "2.0.0",
"released": "2026-02-16"
}
],
"stars": 0,
"downloads": 0,
"last_updated": "2026-02-16",
"verified": true,
"screenshot": "",
"display_modes": [
"march_madness"
],
"dependencies": {},
"entry_point": "manager.py",
"class_name": "MarchMadnessPlugin"
}

View File

@@ -1,5 +0,0 @@
requests>=2.33.0
urllib3>=2.6.3
Pillow>=12.2.0
pytz>=2022.1
numpy>=1.24.0

View File

@@ -22,5 +22,6 @@
"Pillow>=10.0.0",
"PyYAML>=6.0",
"requests>=2.31.0"
]
],
"local_only": true
}

View File

@@ -67,8 +67,9 @@ def main():
print(" 📍 Will run on: http://0.0.0.0:5000")
print(" ⏹️ Press Ctrl+C to stop")
# Run the app (this should start the server)
app.run(host='0.0.0.0', port=5000, debug=True)
# Run the app (debug mode controlled by env var to satisfy security scanners)
_debug = os.environ.get('LEDMATRIX_FLASK_DEBUG', '0') == '1'
app.run(host='0.0.0.0', port=5000, debug=_debug)
except KeyboardInterrupt:
print("\n ⏹️ Server stopped by user")

View File

@@ -410,8 +410,8 @@ def validate_backup(zip_path: Path) -> Tuple[bool, str, Dict[str, Any]]:
try:
manifest_raw = zf.read(MANIFEST_NAME).decode("utf-8")
manifest = json.loads(manifest_raw)
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as e:
return False, f"Invalid manifest.json: {e}", {}
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
return False, "Invalid manifest.json", {}
if not isinstance(manifest, dict) or "schema_version" not in manifest:
return False, "Invalid manifest structure", {}
@@ -456,8 +456,8 @@ def validate_backup(zip_path: Path) -> Tuple[bool, str, Dict[str, Any]]:
return True, "", result_manifest
except zipfile.BadZipFile:
return False, "File is not a valid ZIP archive", {}
except OSError as e:
return False, f"Could not read backup: {e}", {}
except OSError:
return False, "Could not read backup", {}
# ---------------------------------------------------------------------------

View File

@@ -1,3 +1,28 @@
"""
Cache Manager — multi-tier response cache for the LEDMatrix application.
:class:`CacheManager` provides a unified caching layer used by all plugins
to reduce external API calls and survive network outages gracefully.
Two storage tiers
-----------------
* **Memory tier** (:class:`~src.cache.memory_cache.MemoryCache`): fast LRU
cache (up to 1 000 entries by default). Hit on this tier before touching
disk.
* **Disk tier** (:class:`~src.cache.disk_cache.DiskCache`): filesystem-backed
persistent store that survives process restarts.
Data written to cache is serialised as JSON. :class:`DateTimeEncoder` handles
``datetime`` objects transparently so callers don't have to pre-serialise them.
Typical plugin usage::
data = self.cache_manager.get_cached_data('my_key', max_age=300)
if data is None:
data = fetch_from_api()
self.cache_manager.save_cache('my_key', data)
"""
import json
import os
import time
@@ -15,7 +40,10 @@ from src.cache.cache_metrics import CacheMetrics
from src.logging_config import get_logger
class DateTimeEncoder(json.JSONEncoder):
"""JSON encoder that serialises ``datetime`` objects as ISO-8601 strings."""
def default(self, obj):
"""Return ISO-8601 string for datetime; delegate all other types to the base encoder."""
if isinstance(obj, datetime):
return obj.isoformat()
return super().default(obj)

View File

@@ -347,34 +347,40 @@ class ScrollHelper:
return self._get_visible_portion_integer(start_x_int, end_x_int)
def _get_visible_portion_integer(self, start_x: int, end_x: int) -> Image.Image:
"""Fast integer pixel extraction (no interpolation)."""
# Fast numpy array slicing for normal case (no wrap-around)
if end_x <= self.cached_image.width:
# Normal case: single slice - fastest path
frame_array = self.cached_array[:, start_x:end_x]
# Convert to PIL Image (minimal overhead)
return Image.fromarray(frame_array)
"""Fast integer pixel extraction (no interpolation).
Uses Image.frombytes instead of Image.fromarray: frombytes skips
numpy's array-protocol overhead and is ~50% faster for the display-sized
slices (128×32 = 12 KB) used here.
"""
_size = (self.display_width, self.display_height)
img_w = self.cached_image.width
if end_x <= img_w:
# Normal case: single contiguous slice (fastest path)
frame_array = np.ascontiguousarray(self.cached_array[:, start_x:end_x])
return Image.frombytes('RGB', _size, frame_array.tobytes())
else:
# Wrap-around case: combine two slices using numpy
width1 = self.cached_image.width - start_x
# Ensure frame buffer is allocated for all non-simple paths
if self._frame_buffer is None or self._frame_buffer.shape != (self.display_height, self.display_width, 3):
self._frame_buffer = np.zeros((self.display_height, self.display_width, 3), dtype=np.uint8)
width1 = img_w - start_x
if width1 > 0:
# Use pre-allocated buffer for output
if self._frame_buffer is None or self._frame_buffer.shape != (self.display_height, self.display_width, 3):
self._frame_buffer = np.zeros((self.display_height, self.display_width, 3), dtype=np.uint8)
# First part from end of image (fast numpy slice)
# Wrap-around: tail of image + head of image
self._frame_buffer[:, :width1] = self.cached_array[:, start_x:]
# Second part from beginning of image
remaining_width = self.display_width - width1
self._frame_buffer[:, width1:] = self.cached_array[:, :remaining_width]
# Convert combined buffer to PIL Image
return Image.fromarray(self._frame_buffer)
else:
# Edge case: start_x >= image width, wrap to beginning
frame_array = self.cached_array[:, :self.display_width]
return Image.fromarray(frame_array)
# Edge case: start_x at or past image end — show from beginning,
# clamped to available width (scroll_position should wrap before
# reaching this state in normal operation).
available = min(self.display_width, img_w)
self._frame_buffer[:, :available] = self.cached_array[:, :available]
if available < self.display_width:
self._frame_buffer[:, available:] = 0
return Image.frombytes('RGB', _size, self._frame_buffer.tobytes())
def _get_visible_portion_subpixel(self, start_x_int: int, fractional: float) -> Image.Image:
"""

View File

@@ -1,3 +1,29 @@
"""
Config Manager — reads, writes, and validates ``config/config.json``.
:class:`ConfigManager` is the single owner of the on-disk configuration
files:
* ``config/config.json`` — main user-editable configuration.
* ``config/config_secrets.json`` — sensitive values (API keys, tokens).
All writes go through :class:`~src.config_manager_atomic.AtomicConfigManager`
which performs a backup before overwriting, validates the result, and rolls
back on error. This makes config corruption essentially impossible.
Plugin configuration
--------------------
Plugin configs are stored inside ``config.json`` under the plugin's ID key
and survive plugin reinstalls. Use :meth:`ConfigManager.update_plugin_config`
to write plugin settings; never write directly to the plugin directory.
Hot-reload
----------
:class:`~src.config_service.ConfigService` wraps ``ConfigManager`` and
detects file changes, broadcasting the new config to registered listeners
without requiring a restart.
"""
import json
import os
import logging
@@ -17,6 +43,13 @@ from src.common.permission_utils import (
)
class ConfigManager:
"""
Reads and writes the main application configuration files.
Wraps :class:`~src.config_manager_atomic.AtomicConfigManager` for safe
atomic writes with automatic backup and rollback. Also exposes helpers
for plugin configuration persistence and secret-field masking.
"""
def __init__(self, config_path: Optional[str] = None, secrets_path: Optional[str] = None) -> None:
# Use current working directory as base
self.config_path: str = config_path or "config/config.json"
@@ -29,9 +62,11 @@ class ConfigManager:
self._atomic_manager: Optional[AtomicConfigManager] = None
def get_config_path(self) -> str:
"""Return the path to the main config file (``config/config.json``)."""
return self.config_path
def get_secrets_path(self) -> str:
"""Return the path to the secrets file (``config/config_secrets.json``)."""
return self.secrets_path
def _get_atomic_manager(self) -> AtomicConfigManager:

View File

@@ -1,3 +1,25 @@
"""
Display Controller — top-level orchestration for the LEDMatrix application.
This module owns the main run loop that drives the LED display. It ties
together every major subsystem:
- ConfigManager / ConfigService — loads config.json, hot-reloads on change
- DisplayManager — hardware (or emulator) output interface
- FontManager — TTF/BDF font loading and caching
- CacheManager — multi-tier API response cache
- PluginManager — plugin lifecycle (load, update, display)
- DisplaySyncManager — optional leader/follower multi-Pi sync
- VegasModeCoordinator — optional continuous Vegas scroll mode
The main loop inside :meth:`DisplayController.run` rotates through enabled
plugin display modes, respecting schedule windows, brightness dim schedules,
on-demand overrides, and live-priority interrupts.
Entry point: :func:`main` — instantiates :class:`DisplayController` and calls
:meth:`~DisplayController.run`.
"""
import time
import os
import json
@@ -28,6 +50,24 @@ DEFAULT_DYNAMIC_DURATION_CAP = 180.0
WIFI_STATUS_FILE = None # Will be initialized in __init__
class DisplayController:
"""
Top-level controller that owns the LED display run loop.
Responsibilities
----------------
* Initialise and wire together all subsystems at startup.
* Rotate through plugin display modes in :meth:`run`.
* Honour schedule windows (active/inactive hours) and dim schedules.
* Handle on-demand override requests (external callers can pin a
specific plugin/mode for a fixed duration via the cache bus).
* Coordinate with a follower Pi when multi-display sync is configured.
* Delegate all actual content to the plugin system — this class contains
no display logic of its own.
There is exactly one instance per process; call :func:`main` to create
it and start the run loop.
"""
def __init__(self):
start_time = time.time()
logger.info("Starting DisplayController initialization")
@@ -138,7 +178,11 @@ class DisplayController:
self.on_demand_last_event: Optional[str] = None
self.on_demand_schedule_override = False
self.rotation_resume_index: Optional[int] = None
# Saved rotation position when a live-priority plugin preempts the
# rotation, so it resumes where it left off (not after the live plugin)
# once live priority ends.
self._live_resume_index: Optional[int] = None
# WiFi status message tracking
global WIFI_STATUS_FILE
if WIFI_STATUS_FILE is None:
@@ -148,7 +192,11 @@ class DisplayController:
self.wifi_status_file = WIFI_STATUS_FILE
self.wifi_status_active = False
self.wifi_status_expires_at: Optional[float] = None
# Plugin display() signature cache — must be initialised before the plugin
# loading loop below so the .pop() invalidation at load time is always safe.
self._plugin_accepts_display_mode: Dict[str, bool] = {}
try:
logger.info("Attempting to import plugin system...")
from src.plugin_system import PluginManager
@@ -321,6 +369,8 @@ class DisplayController:
self.plugin_modes[mode] = plugin_instance
self.mode_to_plugin_id[mode] = plugin_id
logger.debug(" Added mode: %s", mode)
# Invalidate signature cache so the new instance is re-inspected
self._plugin_accepts_display_mode.pop(plugin_id, None)
# Show progress
progress_pct = int((loaded_count / enabled_count) * 100)
@@ -367,11 +417,39 @@ class DisplayController:
self.is_display_active = True
self._was_display_active = True # Track previous state for schedule change detection
# --- Opt #2: cached config values ---
# Avoids chained dict.get() with temporary {} defaults on every hot path call.
# Refreshed via _refresh_config_cache() on every hot-reload.
self._normal_brightness: int = (
self.config.get('display', {}).get('hardware', {}).get('brightness', 90)
)
self._scroll_speed: float = (
self.config.get('display', {}).get('vegas_scroll', {}).get('scroll_speed', 75)
)
# Brightness state tracking for dim schedule
self.current_brightness = self.config.get('display', {}).get('hardware', {}).get('brightness', 90)
self.current_brightness = self._normal_brightness
self.is_dimmed = False
self._was_dimmed = False
# --- Opt #3: schedule minute-gate ---
# Both _check_schedule and _check_dim_schedule re-evaluated at most once per
# clock minute. Storing the (hour, minute) tuple that was last evaluated lets
# the methods skip all timezone / strptime work within the same minute.
# Reset to None on config change so the next call re-evaluates immediately.
self._tz = None # pytz timezone, lazily built from config
self._schedule_checked_minute: Optional[tuple] = None
self._dim_checked_minute: Optional[tuple] = None
self._cached_target_brightness: int = self._normal_brightness
# Register controller-level hot-reload callback so cached config values
# (_normal_brightness, _scroll_speed, _tz, minute-gates) stay in sync
# when the user saves settings via the web UI.
def _controller_config_change(old_config: Dict[str, Any], new_config: Dict[str, Any]) -> None:
self._refresh_config_cache(new_config)
self.config_service.subscribe(_controller_config_change)
# Publish initial on-demand state
try:
self._publish_on_demand_state()
@@ -533,17 +611,24 @@ class DisplayController:
logger.debug("Schedule is disabled - display always active")
return
# Get configured timezone, default to UTC
timezone_str = self.config.get('timezone', 'UTC')
try:
tz = pytz.timezone(timezone_str)
except pytz.UnknownTimeZoneError:
logger.warning(f"Unknown timezone '{timezone_str}', using UTC")
tz = pytz.UTC
# Lazily build the timezone object once; reuse on every subsequent call.
if self._tz is None:
timezone_str = self.config.get('timezone', 'UTC')
try:
self._tz = pytz.timezone(timezone_str)
except pytz.UnknownTimeZoneError:
logger.warning("Unknown timezone '%s', using UTC", timezone_str)
self._tz = pytz.UTC
# Use timezone-aware current time
current_time = datetime.now(tz)
current_day = current_time.strftime('%A').lower() # Get day name (monday, tuesday, etc.)
current_time = datetime.now(self._tz)
# Gate: schedule state can only change on a minute boundary, so skip
# all the strptime / comparison work if we already evaluated this minute.
current_minute_key = (current_time.hour, current_time.minute)
if current_minute_key == self._schedule_checked_minute:
return
self._schedule_checked_minute = current_minute_key
current_day = current_time.strftime('%A').lower() # e.g. 'monday'
current_time_only = current_time.time()
# Check if per-day schedule is configured
@@ -632,8 +717,8 @@ class DisplayController:
Target brightness level (dim_brightness if in dim period,
normal brightness otherwise)
"""
# Get normal brightness from config
normal_brightness = self.config.get('display', {}).get('hardware', {}).get('brightness', 90)
# Opt #2: use cached brightness rather than re-traversing config dict
normal_brightness = self._normal_brightness
# If display is OFF via schedule, don't process dim schedule
if not self.is_display_active:
@@ -647,15 +732,21 @@ class DisplayController:
self.is_dimmed = False
return normal_brightness
# Get configured timezone
timezone_str = self.config.get('timezone', 'UTC')
try:
tz = pytz.timezone(timezone_str)
except pytz.UnknownTimeZoneError:
logger.warning(f"Unknown timezone '{timezone_str}' in dim schedule, using UTC")
tz = pytz.UTC
# Opt #3: lazily build timezone; gate full re-parse to once per clock minute
if self._tz is None:
timezone_str = self.config.get('timezone', 'UTC')
try:
self._tz = pytz.timezone(timezone_str)
except pytz.UnknownTimeZoneError:
logger.warning("Unknown timezone '%s' in dim schedule, using UTC", timezone_str)
self._tz = pytz.UTC
current_time = datetime.now(self._tz)
current_minute_key = (current_time.hour, current_time.minute)
if current_minute_key == self._dim_checked_minute:
return self._cached_target_brightness
self._dim_checked_minute = current_minute_key
current_time = datetime.now(tz)
current_day = current_time.strftime('%A').lower()
current_time_only = current_time.time()
@@ -703,10 +794,12 @@ class DisplayController:
logger.info(f"Dim schedule deactivated: brightness restored to {target_brightness}%")
self._was_dimmed = self.is_dimmed
self._cached_target_brightness = target_brightness # persist for minute-gate
return target_brightness
except ValueError as e:
logger.warning(f"Invalid dim schedule time format: {e}")
logger.warning("Invalid dim schedule time format: %s", e)
self._cached_target_brightness = normal_brightness # persist for minute-gate
return normal_brightness
def _update_modules(self):
@@ -1382,6 +1475,36 @@ class DisplayController:
except Exception as e:
logger.debug(f"Error logging memory stats: {e}")
def _apply_live_priority(self, live_priority_mode):
"""Switch to a live-priority mode, or resume rotation when it ends.
When a live-priority plugin preempts the rotation, the position the
rotation had reached is saved so that, once live priority ends, the
rotation resumes from there instead of continuing after the live
plugin's mode (which would skip every mode between the two). The save
happens only on the initial switch, not on each re-check while the
live hold continues.
"""
if live_priority_mode:
if self.current_display_mode != live_priority_mode:
logger.info("Live content detected - switching immediately to %s", live_priority_mode)
if self._live_resume_index is None:
self._live_resume_index = self.current_mode_index
self.current_display_mode = live_priority_mode
self.force_change = True
# Update mode index to match the new mode
try:
self.current_mode_index = self.available_modes.index(live_priority_mode)
except ValueError:
pass
elif self._live_resume_index is not None and self.available_modes:
# Live priority ended — resume rotation where it was interrupted.
self.current_mode_index = self._live_resume_index % len(self.available_modes)
self.current_display_mode = self.available_modes[self.current_mode_index]
self.force_change = True
logger.info("Live priority ended - resuming rotation at %s", self.current_display_mode)
self._live_resume_index = None
def _check_live_priority(self):
"""
Check all plugins for live priority content.
@@ -1483,12 +1606,8 @@ class DisplayController:
rp = vc.render_pipeline if (vc and vc.render_pipeline) else None
width = self.display_manager.width
# Advance local position at Vegas scroll speed (px/s → px/tick)
vegas_speed = (
self.config.get('display', {})
.get('vegas_scroll', {})
.get('scroll_speed', 75)
)
# Opt #2: use pre-cached scroll speed (constant for the run)
vegas_speed = self._scroll_speed
local_x = getattr(self, '_follower_local_x', None)
if local_x is None:
local_x = float(width) # safe start (past pre-roll guard)
@@ -1573,15 +1692,7 @@ class DisplayController:
# Check for live priority content and switch to it immediately
if not self.on_demand_active and not wifi_status_data:
live_priority_mode = self._check_live_priority()
if live_priority_mode and self.current_display_mode != live_priority_mode:
logger.info("Live content detected - switching immediately to %s", live_priority_mode)
self.current_display_mode = live_priority_mode
self.force_change = True
# Update mode index to match the new mode
try:
self.current_mode_index = self.available_modes.index(live_priority_mode)
except ValueError:
pass
self._apply_live_priority(live_priority_mode)
# Vegas scroll mode - continuous ticker across all plugins
# Priority: on-demand > wifi-status > live-priority > vegas > normal rotation
@@ -1628,7 +1739,8 @@ class DisplayController:
manager_to_display = None
logger.info(f"Processing mode: {active_mode}, available_modes: {len(self.available_modes)}, plugin_modes: {list(self.plugin_modes.keys())}")
logger.info("Processing mode: %s (%d available)", active_mode, len(self.available_modes))
logger.debug("Loaded plugin modes: %s", list(self.plugin_modes.keys()))
# Handle plugin-based display modes
if active_mode in self.plugin_modes:
@@ -1664,17 +1776,22 @@ class DisplayController:
try:
logger.debug(f"Calling display() for {active_mode} with force_clear={self.force_change}")
if hasattr(manager_to_display, 'display'):
# Check if plugin accepts display_mode parameter
import inspect
sig = inspect.signature(manager_to_display.display)
# Opt #1: look up (or compute once) whether display() accepts display_mode
_cache_key = plugin_id
if _cache_key not in self._plugin_accepts_display_mode:
import inspect as _inspect
self._plugin_accepts_display_mode[_cache_key] = (
'display_mode' in _inspect.signature(manager_to_display.display).parameters
)
_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 'display_mode' in sig.parameters else None
display_mode=active_mode if _accepts_display_mode else None
)
# execute_display returns bool, convert to expected format
if result:
@@ -1683,7 +1800,7 @@ class DisplayController:
result = False # Failed
else:
# Fallback to direct call if executor not available
if 'display_mode' in sig.parameters:
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)
@@ -1820,9 +1937,9 @@ class DisplayController:
min_duration = base_duration
if dynamic_enabled:
# Try to get plugin-calculated cycle duration first
logger.info("Attempting to get cycle duration for mode %s", active_mode)
logger.debug("Attempting to get cycle duration for mode %s", active_mode)
plugin_cycle_duration = self._plugin_cycle_duration(manager_to_display, active_mode)
logger.info("Got cycle duration: %s", plugin_cycle_duration)
logger.debug("Got cycle duration: %s", plugin_cycle_duration)
# Get caps for validation
plugin_cap = self._plugin_dynamic_cap(manager_to_display)
@@ -1962,7 +2079,7 @@ class DisplayController:
if needs_high_fps:
# Ultra-smooth FPS for scrolling plugins (8ms = 125 FPS)
display_interval = 0.008
logger.info(
logger.debug(
"Entering high-FPS loop for %s with display_interval=%.3fs (%.1f FPS)",
active_mode,
display_interval,
@@ -1972,7 +2089,7 @@ class DisplayController:
while True:
try:
# Pass display_mode to maintain sticky manager state
if 'display_mode' in sig.parameters:
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)
@@ -2014,7 +2131,7 @@ class DisplayController:
else:
# Normal FPS for other plugins (1 second)
display_interval = 1.0
logger.info(
logger.debug(
"Entering normal FPS loop for %s with display_interval=%.3fs",
active_mode,
display_interval
@@ -2036,7 +2153,7 @@ class DisplayController:
try:
# Pass display_mode to maintain sticky manager state
if 'display_mode' in sig.parameters:
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)
@@ -2333,6 +2450,30 @@ class DisplayController:
self.wifi_status_active = False
self.wifi_status_expires_at = None
def _refresh_config_cache(self, new_config: Dict[str, Any]) -> None:
"""Refresh all config-derived caches when a hot-reload fires.
Called by the controller-level ConfigService subscriber. Keeps
``_normal_brightness``, ``_scroll_speed``, the cached timezone, and the
schedule minute-gates consistent with the live config so callers never
read stale values after the user saves settings via the web UI.
"""
self.config = new_config
self._normal_brightness = (
self.config.get('display', {}).get('hardware', {}).get('brightness', 90)
)
self._scroll_speed = (
self.config.get('display', {}).get('vegas_scroll', {}).get('scroll_speed', 75)
)
# Force the timezone to be re-derived from the new config on next schedule check
self._tz = None
# Invalidate minute-gates so the new schedule/dim times take effect immediately
self._schedule_checked_minute = None
self._dim_checked_minute = None
self._cached_target_brightness = self._normal_brightness
logger.debug("Config cache refreshed (brightness=%s, scroll_speed=%s)",
self._normal_brightness, self._scroll_speed)
def cleanup(self):
"""Clean up resources."""
# Shutdown config service if it exists
@@ -2347,6 +2488,7 @@ class DisplayController:
logger.info("Cleanup complete.")
def main():
"""Application entry point — create a DisplayController and run until interrupted."""
controller = DisplayController()
controller.run()

View File

@@ -1,3 +1,28 @@
"""
Display Manager — hardware abstraction layer for the RGB LED matrix.
This module provides :class:`DisplayManager`, the single interface between
application code and the physical (or emulated) LED panel.
Key responsibilities
--------------------
* Initialise the ``RGBMatrix`` (hardware) or ``RGBMatrixEmulator`` depending
on the ``EMULATOR`` environment variable.
* Expose a PIL ``Image``/``ImageDraw`` canvas that plugins draw into, then
flush it to the matrix via double-buffering (:meth:`DisplayManager.update_display`).
* Load and cache TTF/BDF fonts; expose ``draw_text`` for consistent text rendering.
* Provide ``width`` / ``height`` properties — always use these instead of
hard-coding display dimensions.
* Write periodic PNG snapshots to ``/tmp/led_matrix_preview.png`` for the
web-interface live preview.
* Track scrolling state and gate deferred updates so plugins don't race with
an in-progress scroll.
Singleton: only one ``DisplayManager`` instance exists per process. The
first call to ``DisplayManager(config)`` creates it; subsequent calls return
the same object.
"""
import json
import os
import tempfile
@@ -18,6 +43,24 @@ logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO) # Set to INFO level
class DisplayManager:
"""
Singleton hardware abstraction layer for the RGB LED matrix.
Plugins should never interact with ``RGBMatrix`` directly; they use this
class to draw content and call :meth:`update_display` to push frames to
the panel.
Typical plugin usage::
canvas = Image.new('RGB', (self.display_manager.width,
self.display_manager.height), (0, 0, 0))
draw = ImageDraw.Draw(canvas)
# ... draw content ...
self.display_manager.image = canvas
self.display_manager.draw = ImageDraw.Draw(self.display_manager.image)
self.display_manager.update_display()
"""
_instance = None
_initialized = False
@@ -33,6 +76,10 @@ class DisplayManager:
self._suppress_test_pattern = suppress_test_pattern
# When True, update_display() and clear() skip hardware writes (used during off-screen content capture)
self._capture_mode_active = False
# Text-width measurement cache: (text, id(font)) -> pixel_width
# Avoids re-measuring the same string+font on every display() call.
# Cleared on _load_fonts() so stale entries don't survive a font reload.
self._text_width_cache: Dict[tuple, int] = {}
# Snapshot settings for web preview integration (service writes, web reads)
self._snapshot_path = "/tmp/led_matrix_preview.png" # nosec B108 - fixed path intentional; web UI reads same path
self._snapshot_min_interval_sec = 0.2 # max ~5 fps
@@ -110,9 +157,10 @@ class DisplayManager:
options.rp1_rio = runtime_config.get('rp1_rio')
else:
logger.warning(
"rp1_rio is set in config but the current RGBMatrixOptions "
"implementation does not support it (RGBMatrixEmulator or older "
"library version) — value will be ignored"
"rp1_rio is set in config but the installed rgbmatrix library does "
"not support it — the library was likely built without Pi 5 RP1 "
"support (mmap to 0x3f000000 instead of RP1 chip). "
"Fix: sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh"
)
logger.info(f"Initializing RGB Matrix with settings: rows={options.rows}, cols={options.cols}, chain_length={options.chain_length}, parallel={options.parallel}, hardware_mapping={options.hardware_mapping}")
@@ -189,7 +237,7 @@ class DisplayManager:
json.dump(_hw_status, _f)
_f.flush()
os.fsync(_f.fileno())
os.chmod(_tmp_path, 0o600)
os.chmod(_tmp_path, 0o644)
os.replace(_tmp_path, _status_path)
except Exception:
try:
@@ -436,6 +484,9 @@ class DisplayManager:
def _load_fonts(self):
"""Load fonts with proper error handling."""
# Font objects get new id()s after reload, so the text-width cache would
# return stale measurements keyed on the old ids. Clear it here.
self._text_width_cache.clear()
try:
# Load Press Start 2P font
self.regular_font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
@@ -496,22 +547,32 @@ class DisplayManager:
def get_text_width(self, text, font):
"""Get the width of text when rendered with the given font."""
"""Get the width of text when rendered with the given font.
Results are cached by (text, font identity) so plugins that measure
the same string every frame (e.g. to centre a score) pay only one
measurement per unique (text, font) pair.
"""
cache_key = (text, id(font))
cached = self._text_width_cache.get(cache_key)
if cached is not None:
return cached
try:
if isinstance(font, freetype.Face):
# For FreeType faces, calculate width using freetype
width = 0
for char in text:
font.load_char(char)
width += font.glyph.advance.x >> 6
return width
else:
# For PIL fonts, use textbbox
bbox = self.draw.textbbox((0, 0), text, font=font)
return bbox[2] - bbox[0]
except Exception as e:
logger.error(f"Error getting text width: {e}")
return 0 # Return 0 as fallback
width = bbox[2] - bbox[0]
except (AttributeError, TypeError, ValueError, OSError) as e:
logger.error("Error getting text width: %s", e)
return 0
self._text_width_cache[cache_key] = width
return width
def get_font_height(self, font):
"""Get the height of the given font for line spacing purposes."""

View File

@@ -1,3 +1,30 @@
"""
Font Manager — TTF/BDF font loading, caching, and dynamic registration.
:class:`FontManager` serves two purposes:
1. **System fonts** — loads the configured small/medium/large TTF fonts (and
their BDF bitmap equivalents) at startup, caches metrics, and exposes them
via ``DisplayManager`` attributes (``small_font``, ``medium_font``, etc.).
2. **Plugin fonts** — lets plugins register their own fonts at runtime via
:meth:`FontManager.register_manager_font` and resolve them later via
:meth:`FontManager.resolve_font`. Registered fonts are namespaced by
plugin ID so they cannot collide.
Font sources
------------
* Local paths relative to the project root.
* Remote URLs — downloaded once, cached to disk, and never re-fetched while
the cached copy is fresh.
BDF fallback
------------
Pixel-accurate LED fonts are stored as ``.bdf`` (Bitmap Distribution Format)
files. When PIL cannot measure BDF glyphs natively, ``freetype-py`` is used
for accurate width/height calculations.
"""
import os
import logging
import freetype

View File

@@ -5,9 +5,11 @@ Handles plugin module imports, dependency installation, and class instantiation.
Extracted from PluginManager to improve separation of concerns.
"""
import hashlib
import json
import importlib
import importlib.util
import os
import sys
import subprocess
import threading
@@ -68,6 +70,11 @@ class PluginLoader:
Returns:
Path to plugin directory or None if not found
"""
# Sanitize plugin_id — os.path.basename is a CodeQL-recognized path sanitizer
plugin_id = os.path.basename(plugin_id or '')
if not plugin_id:
return None
# Strategy 1: Use mapping from discovery
if plugin_directories and plugin_id in plugin_directories:
plugin_dir = plugin_directories[plugin_id]
@@ -75,14 +82,16 @@ class PluginLoader:
self.logger.debug("Using plugin directory from discovery mapping: %s", plugin_dir)
return plugin_dir
# Strategy 2: Direct paths
plugin_dir = plugins_dir / plugin_id
if plugin_dir.exists():
return plugin_dir
plugin_dir = plugins_dir / f"ledmatrix-{plugin_id}"
if plugin_dir.exists():
return plugin_dir
# Strategy 2: Direct paths — resolve and validate they stay within plugins_dir
plugins_dir_resolved = plugins_dir.resolve()
for _candidate_name in (plugin_id, f"ledmatrix-{plugin_id}"):
_candidate = (plugins_dir_resolved / _candidate_name).resolve()
try:
_candidate.relative_to(plugins_dir_resolved)
except ValueError:
continue
if _candidate.exists():
return _candidate
# Strategy 3: Case-insensitive search
normalized_id = plugin_id.lower()
@@ -130,51 +139,123 @@ class PluginLoader:
self,
plugin_dir: Path,
plugin_id: str,
plugins_dir: Optional[Path] = None,
timeout: int = 300
) -> bool:
"""
Install plugin dependencies from requirements.txt.
Args:
plugin_dir: Plugin directory path
plugin_id: Plugin identifier
plugins_dir: Trusted base plugins directory for path containment check
timeout: Installation timeout in seconds
Returns:
True if dependencies installed or not needed, False on error
"""
requirements_file = plugin_dir / "requirements.txt"
if not requirements_file.exists():
plugin_id = os.path.basename(plugin_id or '')
if not plugin_id:
return False
# Resolve to a canonical absolute path (normalises .. and symlinks)
plugin_dir_real = os.path.realpath(str(plugin_dir))
if plugins_dir is not None:
# Reconstruct the plugin path from a trusted base + a sanitised
# directory name. os.path.basename() is CodeQL's recognised
# py/path-injection sanitiser: it strips all directory components
# so the result cannot contain traversal sequences. Joining it
# with the resolved, trusted plugins_dir produces a path that
# CodeQL considers untainted.
plugins_dir_real = os.path.realpath(str(plugins_dir))
safe_dir_name = os.path.basename(plugin_dir_real)
if not safe_dir_name:
self.logger.error("Could not determine plugin directory name for %s", plugin_id)
return False
safe_plugin_dir = os.path.join(plugins_dir_real, safe_dir_name)
if not os.path.isdir(safe_plugin_dir):
self.logger.error(
"Plugin directory for %s not found inside plugins dir", plugin_id
)
return False
else:
safe_plugin_dir = plugin_dir_real
if not os.path.isdir(safe_plugin_dir):
self.logger.error("Plugin directory does not exist: %s", plugin_dir)
return False
requirements_file = os.path.join(safe_plugin_dir, "requirements.txt")
marker_file = os.path.join(safe_plugin_dir, ".dependencies_installed")
if not os.path.isfile(requirements_file):
return True # No dependencies needed
# Check if already installed
marker_path = plugin_dir / ".dependencies_installed"
if marker_path.exists():
self.logger.debug("Dependencies already installed for %s", plugin_id)
return True
try:
with open(requirements_file, 'rb') as fh:
current_hash = hashlib.sha256(fh.read()).hexdigest()
except OSError as e:
self.logger.error("Failed to read requirements.txt for %s: %s", plugin_id, e)
return False
# Skip if requirements.txt hasn't changed since last install
if os.path.isfile(marker_file):
try:
with open(marker_file, 'r', encoding='utf-8') as fh:
stored_hash = fh.read().strip()
except OSError as e:
self.logger.warning(
"Could not read dependency marker for %s (%s), will reinstall dependencies",
plugin_id, e
)
else:
if stored_hash == current_hash:
self.logger.debug("Dependencies already installed for %s (requirements unchanged)", plugin_id)
return True
self.logger.info("Requirements changed for %s, reinstalling dependencies", plugin_id)
try:
self.logger.info("Installing dependencies for plugin %s...", plugin_id)
result = subprocess.run(
[sys.executable, "-m", "pip", "install", "--break-system-packages", "-r", str(requirements_file)],
[sys.executable, "-m", "pip", "install", "--break-system-packages", "-r", requirements_file],
capture_output=True,
text=True,
timeout=timeout,
check=False
)
if result.returncode == 0:
# Mark as installed
marker_path.touch()
# Set proper file permissions after creating marker
ensure_file_permissions(marker_path, get_plugin_file_mode())
try:
with open(marker_file, 'w', encoding='utf-8') as fh:
fh.write(current_hash)
ensure_file_permissions(Path(marker_file), get_plugin_file_mode())
except OSError as marker_err:
self.logger.debug("Could not write dependency marker for %s: %s", plugin_id, marker_err)
self.logger.info("Dependencies installed successfully for %s", plugin_id)
return True
else:
stderr = result.stderr or ""
# uninstall-no-record-file means the package is already present at the
# system level (e.g. installed via dnf/apt without a pip RECORD file).
# pip can't replace it, but it IS installed — write the marker so we
# don't retry on every restart.
if "uninstall-no-record-file" in stderr:
self.logger.warning(
"Dependencies for %s include system-managed packages (no pip RECORD). "
"Assuming they are satisfied: %s",
plugin_id, stderr.strip()
)
try:
with open(marker_file, 'w', encoding='utf-8') as fh:
fh.write(current_hash)
ensure_file_permissions(Path(marker_file), get_plugin_file_mode())
except OSError as marker_err:
self.logger.debug("Could not write dependency marker for %s: %s", plugin_id, marker_err)
return True
self.logger.warning(
"Dependency installation returned non-zero exit code for %s: %s",
plugin_id,
result.stderr
stderr
)
return False
except subprocess.TimeoutExpired:
@@ -349,9 +430,20 @@ class PluginLoader:
Returns:
Loaded module or None on error
"""
entry_file = plugin_dir / entry_point
plugin_id = os.path.basename(plugin_id or '')
if not plugin_id:
raise PluginError("Invalid plugin ID")
try:
plugin_dir_resolved = plugin_dir.resolve(strict=True)
except OSError:
raise PluginError("Plugin directory not found", plugin_id=plugin_id)
entry_file = (plugin_dir_resolved / entry_point).resolve()
try:
entry_file.relative_to(plugin_dir_resolved)
except ValueError:
raise PluginError("Invalid entry point path", plugin_id=plugin_id)
if not entry_file.exists():
error_msg = f"Entry point file not found: {entry_file} for plugin {plugin_id}"
error_msg = f"Entry point file not found for plugin {plugin_id}"
self.logger.error(error_msg)
raise PluginError(error_msg, plugin_id=plugin_id, context={'entry_file': str(entry_file)})
@@ -501,11 +593,12 @@ class PluginLoader:
display_manager: Any,
cache_manager: Any,
plugin_manager: Any,
install_deps: bool = True
install_deps: bool = True,
plugins_dir: Optional[Path] = None,
) -> Tuple[Any, Any]:
"""
Complete plugin loading process.
Args:
plugin_id: Plugin identifier
manifest: Plugin manifest
@@ -515,16 +608,22 @@ class PluginLoader:
cache_manager: Cache manager instance
plugin_manager: Plugin manager instance
install_deps: Whether to install dependencies
plugins_dir: Trusted base plugins directory forwarded to install_dependencies
Returns:
Tuple of (plugin_instance, module)
Raises:
PluginError: If loading fails
"""
# Install dependencies if needed
if install_deps:
self.install_dependencies(plugin_dir, plugin_id)
if not self.install_dependencies(plugin_dir, plugin_id, plugins_dir=plugins_dir):
raise PluginError(
f"Dependency installation failed for plugin {plugin_id} in {plugin_dir}",
plugin_id=plugin_id,
context={'plugin_dir': str(plugin_dir)},
)
# Load module
entry_point = manifest.get('entry_point', 'manager.py')

View File

@@ -15,7 +15,7 @@ import threading
from pathlib import Path
from typing import Dict, List, Optional, Any
import logging
from src.exceptions import PluginError
from src.exceptions import PluginError, ConfigError
from src.logging_config import get_logger
from src.plugin_system.plugin_loader import PluginLoader
from src.plugin_system.plugin_executor import PluginExecutor
@@ -81,7 +81,13 @@ class PluginManager:
self.plugin_manifests: Dict[str, Dict[str, Any]] = {}
self.plugin_modules: Dict[str, Any] = {}
self.plugin_last_update: Dict[str, float] = {}
# Cached data-fetch intervals per plugin_id.
# _get_plugin_update_interval falls back to config_manager.get_config()
# (a full dict copy) when the manifest lacks an interval — caching avoids
# that copy on every 30-fps tick. Cleared on load/unload.
self._update_interval_cache: Dict[str, Optional[float]] = {}
# Health tracking (optional, set by display_controller if available)
self.health_tracker = None
self.resource_monitor = None
@@ -350,7 +356,8 @@ class PluginManager:
display_manager=self.display_manager,
cache_manager=self.cache_manager,
plugin_manager=self,
install_deps=True
install_deps=True,
plugins_dir=self.plugins_dir,
)
# Store module
@@ -387,6 +394,8 @@ class PluginManager:
# Store plugin instance
self.plugins[plugin_id] = plugin_instance
self.plugin_last_update[plugin_id] = 0.0
# Invalidate cached interval so next tick re-derives it for this plugin
self._update_interval_cache.pop(plugin_id, None)
# Update state based on enabled status
if config.get('enabled', True):
@@ -443,8 +452,8 @@ class PluginManager:
# Remove from active plugins
del self.plugins[plugin_id]
if plugin_id in self.plugin_last_update:
del self.plugin_last_update[plugin_id]
self.plugin_last_update.pop(plugin_id, None)
self._update_interval_cache.pop(plugin_id, None)
# Remove main module from sys.modules if present
module_name = f"plugin_{plugin_id.replace('-', '_')}"
@@ -638,41 +647,46 @@ class PluginManager:
def _get_plugin_update_interval(self, plugin_id: str, plugin_instance: Any) -> Optional[float]:
"""
Get the update interval for a plugin.
Args:
plugin_id: Plugin identifier
plugin_instance: Plugin instance
Returns:
Update interval in seconds or None if not configured
Get the data-fetch interval for a plugin (seconds between update() calls).
Result is cached per plugin_id after the first lookup to avoid calling
config_manager.get_config() — which returns a full dict copy — on every
tick of the 30-fps display loop. The cache is invalidated when a plugin
is loaded or unloaded.
"""
# Check manifest first
if plugin_id in self._update_interval_cache:
return self._update_interval_cache[plugin_id]
interval: Optional[float] = None
# 1. Manifest (immutable after load — preferred source)
manifest = self.plugin_manifests.get(plugin_id, {})
update_interval = manifest.get('update_interval')
if update_interval:
raw = manifest.get('update_interval')
if raw is not None:
try:
return float(update_interval)
interval = float(raw)
except (ValueError, TypeError):
pass
# Check plugin config
if self.config_manager:
# 2. Plugin config (mutable; only read once and then cached)
if interval is None and self.config_manager:
try:
config = self.config_manager.get_config()
plugin_config = config.get(plugin_id, {})
update_interval = plugin_config.get('update_interval')
if update_interval:
raw = config.get(plugin_id, {}).get('update_interval')
if raw is not None:
try:
return float(update_interval)
interval = float(raw)
except (ValueError, TypeError):
pass
except Exception as e:
except (ConfigError, OSError, ValueError, TypeError) as e:
self.logger.debug("Could not get update interval from config: %s", e)
# Default: 60 seconds
return 60.0
# 3. Default
if interval is None:
interval = 60.0
self._update_interval_cache[plugin_id] = interval
return interval
def _record_update_failure(
self,

View File

@@ -185,13 +185,19 @@ class StateReconciliation:
message=f"Reconciliation failed: {str(e)}"
)
# Top-level config keys that are NOT plugins
# Top-level config keys that are NOT plugins.
# Includes both config.json structural keys and config_secrets.json top-level
# keys (load_config() deep-merges secrets in, so secrets keys appear here too).
_SYSTEM_CONFIG_KEYS = frozenset({
'web_display_autostart', 'timezone', 'location', 'display',
'plugin_system', 'vegas_scroll_speed', 'vegas_separator_width',
'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order',
'vegas_excluded_plugins', 'vegas_scroll_enabled', 'logging',
'dim_schedule', 'network', 'system', 'schedule',
# Multi-display sync config (config.json structural key)
'sync',
# Secrets file top-level keys (merged in by load_config)
'github', 'youtube',
})
def _get_config_state(self) -> Dict[str, Dict[str, Any]]:
@@ -334,15 +340,15 @@ class StateReconciliation:
# Check: Enabled state mismatch
config_enabled = config.get('enabled', False)
state_mgr_enabled = state_mgr.get('enabled')
if state_mgr_enabled is not None and config_enabled != state_mgr_enabled:
inconsistencies.append(Inconsistency(
plugin_id=plugin_id,
inconsistency_type=InconsistencyType.PLUGIN_ENABLED_MISMATCH,
description=f"Plugin {plugin_id} enabled state mismatch: config={config_enabled}, state_manager={state_mgr_enabled}",
fix_action=FixAction.AUTO_FIX,
current_state={'enabled': config_enabled},
expected_state={'enabled': state_mgr_enabled},
current_state={'enabled': state_mgr_enabled},
expected_state={'enabled': config_enabled},
can_auto_fix=True
))
@@ -365,15 +371,23 @@ class StateReconciliation:
return self._auto_repair_missing_plugin(inconsistency.plugin_id)
elif inconsistency.inconsistency_type == InconsistencyType.PLUGIN_ENABLED_MISMATCH:
# Sync enabled state from state manager to config
expected_enabled = inconsistency.expected_state.get('enabled')
config = self.config_manager.load_config()
if inconsistency.plugin_id not in config:
config[inconsistency.plugin_id] = {}
config[inconsistency.plugin_id]['enabled'] = expected_enabled
self.config_manager.save_config(config)
self.logger.info(f"Fixed: Synced enabled state for {inconsistency.plugin_id}")
return True
# config.json is the user-editable source of truth for enabled state.
# Bring the state manager in sync with config rather than the reverse,
# so that manual config edits (or the state left behind after an
# uninstall+reinstall cycle) don't silently override the user's intent.
config_enabled = inconsistency.expected_state.get('enabled')
success = self.state_manager.set_plugin_enabled(inconsistency.plugin_id, config_enabled)
if success:
self.logger.info(
f"Fixed: Synced state manager enabled={config_enabled} for "
f"{inconsistency.plugin_id} to match config"
)
else:
self.logger.warning(
f"Failed to sync state manager enabled={config_enabled} for "
f"{inconsistency.plugin_id}"
)
return success
except Exception as e:
self.logger.error(f"Error fixing inconsistency: {e}", exc_info=True)

View File

@@ -5,11 +5,13 @@ Handles plugin discovery, installation, updates, and uninstallation
from both the official registry and custom GitHub repositories.
"""
import hashlib
import os
import json
import stat
import subprocess
import shutil
import threading
import zipfile
import tempfile
import requests
@@ -20,6 +22,8 @@ from pathlib import Path
from typing import List, Dict, Optional, Any, Tuple
import logging
from urllib.parse import urlparse
from src.common.permission_utils import sudo_remove_directory
try:
@@ -100,6 +104,10 @@ class PluginStoreManager:
# handlers. Bumping the cached-entry timestamp on failure serves
# the stale payload cheaply until the backoff expires.
self._failure_backoff_seconds = 60
# Prevents concurrent callers from each firing a network request when
# the registry cache expires. Only one thread fetches; others wait and
# then get the result from the warm cache (double-checked locking).
self._registry_fetch_lock = threading.Lock()
# Ensure plugins directory exists
self.plugins_dir.mkdir(exist_ok=True)
@@ -351,7 +359,8 @@ class PluginStoreManager:
# Extract owner/repo from URL
try:
# Handle different URL formats
if 'github.com' in repo_url:
_parsed_url = urlparse(repo_url)
if _parsed_url.hostname in ('github.com', 'www.github.com'):
parts = repo_url.strip('/').split('/')
if len(parts) >= 2:
owner = parts[-2]
@@ -513,9 +522,10 @@ class PluginStoreManager:
# Try to find plugins.json in common locations
# First try root directory
registry_urls = []
# Extract owner/repo from URL
if 'github.com' in repo_url:
_parsed_repo_url = urlparse(repo_url)
if _parsed_repo_url.hostname in ('github.com', 'www.github.com'):
parts = repo_url.split('/')
if len(parts) >= 2:
owner = parts[-2]
@@ -575,41 +585,50 @@ class PluginStoreManager:
(current_time - self.registry_cache_time) < self.registry_cache_timeout):
return self.registry_cache
try:
self.logger.info(f"Fetching plugin registry from {self.REGISTRY_URL}")
response = self._http_get_with_retries(self.REGISTRY_URL, timeout=10)
response.raise_for_status()
self.registry_cache = response.json()
self.registry_cache_time = current_time
self.logger.info(f"Fetched registry with {len(self.registry_cache.get('plugins', []))} plugins")
return self.registry_cache
except requests.RequestException as e:
self.logger.error(f"Error fetching registry: {e}")
if raise_on_failure:
raise
# Prefer stale cache over an empty list so the plugin list UI
# keeps working on a flaky connection (e.g. Pi on WiFi). Bump
# registry_cache_time into a short backoff window so the next
# request serves the stale payload cheaply instead of
# re-hitting the network on every request (matches the
# pattern used by github_cache / commit_info_cache).
if self.registry_cache:
self.logger.warning("Falling back to stale registry cache")
self.registry_cache_time = (
time.time() + self._failure_backoff_seconds - self.registry_cache_timeout
)
with self._registry_fetch_lock:
# Re-check inside the lock — a concurrent caller that was waiting
# may have already populated the cache while we blocked.
current_time = time.time()
if (self.registry_cache and self.registry_cache_time and
not force_refresh and
(current_time - self.registry_cache_time) < self.registry_cache_timeout):
return self.registry_cache
return {"plugins": []}
except json.JSONDecodeError as e:
self.logger.error(f"Error parsing registry JSON: {e}")
if raise_on_failure:
raise
if self.registry_cache:
self.registry_cache_time = (
time.time() + self._failure_backoff_seconds - self.registry_cache_timeout
)
try:
self.logger.info(f"Fetching plugin registry from {self.REGISTRY_URL}")
response = self._http_get_with_retries(self.REGISTRY_URL, timeout=10)
response.raise_for_status()
self.registry_cache = response.json()
self.registry_cache_time = current_time
self.logger.info(f"Fetched registry with {len(self.registry_cache.get('plugins', []))} plugins")
return self.registry_cache
return {"plugins": []}
except requests.RequestException as e:
self.logger.error(f"Error fetching registry: {e}")
if raise_on_failure:
raise
# Prefer stale cache over an empty list so the plugin list UI
# keeps working on a flaky connection (e.g. Pi on WiFi). Bump
# registry_cache_time into a short backoff window so the next
# request serves the stale payload cheaply instead of
# re-hitting the network on every request (matches the
# pattern used by github_cache / commit_info_cache).
if self.registry_cache:
self.logger.warning("Falling back to stale registry cache")
self.registry_cache_time = (
time.time() + self._failure_backoff_seconds - self.registry_cache_timeout
)
return self.registry_cache
return {"plugins": []}
except json.JSONDecodeError as e:
self.logger.error(f"Error parsing registry JSON: {e}")
if raise_on_failure:
raise
if self.registry_cache:
self.registry_cache_time = (
time.time() + self._failure_backoff_seconds - self.registry_cache_timeout
)
return self.registry_cache
return {"plugins": []}
def search_plugins(self, query: str = "", category: str = "", tags: List[str] = None, fetch_commit_info: bool = True, include_saved_repos: bool = True, saved_repositories_manager = None) -> List[Dict]:
"""
@@ -761,7 +780,8 @@ class PluginStoreManager:
try:
# Convert repo URL to raw content URL
# https://github.com/user/repo -> https://raw.githubusercontent.com/user/repo/branch/manifest.json
if 'github.com' in repo_url:
_parsed_manifest_url = urlparse(repo_url)
if _parsed_manifest_url.hostname in ('github.com', 'www.github.com'):
# Handle different URL formats
repo_url = repo_url.rstrip('/')
if repo_url.endswith('.git'):
@@ -1736,6 +1756,12 @@ class PluginStoreManager:
timeout=300
)
self.logger.info(f"Dependencies installed successfully for {plugin_path.name}")
# Write hash marker so plugin_loader skips redundant pip run on next startup
try:
current_hash = hashlib.sha256(requirements_file.read_bytes()).hexdigest()
(plugin_path / ".dependencies_installed").write_text(current_hash, encoding='utf-8')
except OSError as marker_err:
self.logger.debug("Could not write dependency marker for %s: %s", plugin_path.name, marker_err)
return True
except subprocess.CalledProcessError as e:

View File

@@ -150,6 +150,18 @@ class WiFiManager:
logger.info(f"WiFi Manager initialized - nmcli: {self.has_nmcli}, iwlist: {self.has_iwlist}, "
f"hostapd: {self.has_hostapd}, dnsmasq: {self.has_dnsmasq}, "
f"interface: {self._wifi_interface}, trixie: {self._is_trixie}")
# Once per process: remove a stale force-AP flag left by a prior crash.
# Guard with a class-level flag so the nmcli AP-state check only runs
# once even though WiFiManager is instantiated per-request.
if not WiFiManager._startup_cleanup_done:
WiFiManager._startup_cleanup_done = True
if self._FORCE_AP_FLAG_PATH.exists() and not self._is_ap_mode_active():
try:
self._FORCE_AP_FLAG_PATH.unlink(missing_ok=True)
logger.debug("Removed stale force-AP flag on startup (AP not active)")
except OSError as exc:
logger.warning(f"Could not remove stale force-AP flag: {exc}")
def _show_led_message(self, message: str, duration: int = 5):
"""
@@ -474,7 +486,10 @@ class WiFiManager:
if result.returncode == 0:
for line in result.stdout.strip().split('\n'):
if '/' in line:
ip_address = line.split('/')[0].strip()
# nmcli -t output is "IP4.ADDRESS[1]:x.x.x.x/prefix";
# bare "x.x.x.x/prefix" is also accepted defensively.
_, sep, rest = line.partition(':')
ip_address = (rest if sep else line).split('/')[0].strip()
break
# Final fallback: Get signal strength by matching SSID in WiFi list
@@ -500,6 +515,13 @@ class WiFiManager:
# Check if AP mode is active
ap_active = self._is_ap_mode_active()
# wlan0 shows as "connected" in AP mode; clear client-station fields so
# callers don't mistake the AP for an outbound WiFi connection.
if ap_active and wifi_connected:
wifi_connected = False
ssid = None
ip_address = None
logger.debug(f"{wlan_device} is in AP mode — overriding wifi_connected to False")
return WiFiStatus(
connected=wifi_connected,
@@ -690,6 +712,10 @@ class WiFiManager:
# ---------------------------------------------------------------------------
_IP_FORWARD_SAVE_PATH = Path("/tmp/ledmatrix_ip_forward_saved") # nosec B108 - process-specific named file; device is single-user RPi
# Written when AP mode is manually force-enabled; prevents daemon auto-disable
_FORCE_AP_FLAG_PATH = Path("/tmp/ledmatrix_force_ap_active") # nosec B108 - process-specific named file; device is single-user RPi
# Ensures the startup stale-flag cleanup runs once per process, not per instantiation
_startup_cleanup_done: bool = False
def _validate_ap_config(self) -> Tuple[str, int]:
"""Return a sanitized (ssid, channel) pair from config, falling back to defaults."""
@@ -1367,7 +1393,7 @@ class WiFiManager:
logger.error(f"Failed to restore original connection: {original_ssid}")
# Trigger AP mode as last resort
self._show_led_message("Enabling AP mode...", duration=5)
ap_success, ap_msg = self.enable_ap_mode()
ap_success, ap_msg = self.enable_ap_mode(force=True)
if ap_success:
logger.info("AP mode enabled as failsafe")
return False, "Connection failed and restoration failed. AP mode enabled."
@@ -1379,7 +1405,7 @@ class WiFiManager:
elif not success:
logger.warning(f"Connection to {ssid} failed and no original connection to restore")
self._show_led_message("Enabling AP mode...", duration=5)
ap_success, ap_msg = self.enable_ap_mode()
ap_success, ap_msg = self.enable_ap_mode(force=True)
if ap_success:
logger.info("AP mode enabled as failsafe")
return False, "Connection failed. AP mode enabled."
@@ -1400,7 +1426,7 @@ class WiFiManager:
logger.error(f"Failed to restore after exception: {restore_error}")
# Last resort: enable AP mode
try:
self.enable_ap_mode()
self.enable_ap_mode(force=True)
except Exception as ap_error: # nosec B110 - last-resort; do not re-raise, but log for debugging
logger.error("Last-resort AP mode enable failed in recovery path: %s", ap_error, exc_info=True)
return False, str(e)
@@ -1464,26 +1490,29 @@ class WiFiManager:
# Show LED message
self._show_led_message(f"Connecting to {ssid}...", duration=10)
# First, check if connection already exists and try to activate it
# NetworkManager connection names might not match SSID exactly, so search by SSID
check_result = subprocess.run(
["nmcli", "-t", "-f", "NAME,802-11-wireless.ssid", "connection", "show"],
capture_output=True,
text=True,
timeout=5
# Find existing NM connection for this SSID.
# 802-11-wireless.ssid is not a valid column in 'nmcli connection show',
# so list all wifi connections then query each one's SSID individually.
list_result = subprocess.run( # nosec B603 B607 - fixed args, no user input
["nmcli", "-t", "-f", "NAME,TYPE", "connection", "show"],
capture_output=True, text=True, timeout=5
)
existing_conn_name = None
if check_result.returncode == 0:
for line in check_result.stdout.strip().split('\n'):
if ':' in line:
parts = line.split(':')
if len(parts) >= 2:
conn_name = parts[0].strip()
conn_ssid = parts[1].strip() if len(parts) > 1 else ""
if conn_ssid == ssid:
existing_conn_name = conn_name
break
if list_result.returncode == 0:
for line in list_result.stdout.strip().split('\n'):
if ':' not in line:
continue
parts = line.split(':')
if len(parts) < 2 or parts[1].strip() != '802-11-wireless':
continue
conn_name = parts[0].strip()
ssid_r = subprocess.run( # nosec B603 B607 - conn_name from nmcli output, not user input
["nmcli", "-g", "802-11-wireless.ssid", "connection", "show", conn_name],
capture_output=True, text=True, timeout=5
)
if ssid_r.returncode == 0 and ssid_r.stdout.strip() == ssid:
existing_conn_name = conn_name
break
# Also try direct lookup by SSID (in case connection name matches SSID)
if not existing_conn_name:
@@ -1855,7 +1884,7 @@ class WiFiManager:
logger.warning(f"Failed to enable WiFi radio after {max_retries} attempts")
return False
def enable_ap_mode(self) -> Tuple[bool, str]:
def enable_ap_mode(self, force: bool = False) -> Tuple[bool, str]:
"""
Enable access point mode
@@ -1877,20 +1906,29 @@ class WiFiManager:
if not self._ensure_wifi_radio_enabled():
return False, "WiFi radio is disabled and could not be enabled"
# Check if WiFi is connected
# Check if WiFi is connected (skip when force=True)
status = self.get_wifi_status()
if status.connected:
if not force and status.connected:
return False, "Cannot enable AP mode while WiFi is connected"
# Check if Ethernet is connected
if self._is_ethernet_connected():
# Check if Ethernet is connected (skip when force=True)
if not force and self._is_ethernet_connected():
return False, "Cannot enable AP mode while Ethernet is connected"
if force:
logger.debug(f"enable_ap_mode: force=True — WiFi/Ethernet guards bypassed; will create {self._FORCE_AP_FLAG_PATH}")
# Try hostapd/dnsmasq first (captive portal mode)
if self.has_hostapd and self.has_dnsmasq:
result = self._enable_ap_mode_hostapd()
if result[0]:
self._ap_enabled_at = time.time()
if force:
try:
self._FORCE_AP_FLAG_PATH.touch()
logger.debug(f"Force-AP flag created: {self._FORCE_AP_FLAG_PATH}")
except OSError as exc:
logger.warning(f"Failed to create force-AP flag {self._FORCE_AP_FLAG_PATH}: {exc}")
return result
# Fallback to nmcli hotspot (simpler, no captive portal)
@@ -1900,6 +1938,12 @@ class WiFiManager:
result = self._enable_ap_mode_nmcli_hotspot()
if result[0]:
self._ap_enabled_at = time.time()
if force:
try:
self._FORCE_AP_FLAG_PATH.touch()
logger.debug(f"Force-AP flag created: {self._FORCE_AP_FLAG_PATH}")
except OSError as exc:
logger.warning(f"Failed to create force-AP flag {self._FORCE_AP_FLAG_PATH}: {exc}")
return result
return False, "No WiFi tools available (nmcli, hostapd, or dnsmasq required)"
@@ -2091,8 +2135,14 @@ class WiFiManager:
self._clear_led_message()
return False, "AP started but captive-portal redirect setup failed"
# Verify the AP is actually running
status = self._get_ap_status_nmcli()
# Verify the AP is actually running (retry up to 5x with 2s delay for NM async activation)
status = {}
for _attempt in range(5):
status = self._get_ap_status_nmcli()
if status.get('active'):
break
logger.debug(f"AP verification attempt {_attempt + 1}/5 not yet active, waiting 2s")
time.sleep(2)
if status.get('active'):
ip = status.get('ip', '192.168.4.1')
logger.info(f"AP mode confirmed active at {ip} (open network, no password)")
@@ -2290,6 +2340,7 @@ class WiFiManager:
logger.warning("WiFi radio may be disabled after nmcli AP cleanup")
self._ap_enabled_at = None
self._FORCE_AP_FLAG_PATH.unlink(missing_ok=True)
logger.info("AP mode disabled successfully")
return True, "AP mode disabled"
except Exception as e:
@@ -2478,22 +2529,29 @@ address=/detectportal.firefox.com/192.168.4.1
else:
logger.warning(f"Failed to enable AP mode: {message}")
elif not should_have_ap and ap_active:
# Should not have AP but do - disable AP mode
# Always disable if WiFi or Ethernet connects, regardless of auto_enable setting
if status.connected or ethernet_connected:
# Should not have AP but do - check if it was manually force-enabled
force_active = self._FORCE_AP_FLAG_PATH.exists()
if status.connected:
# WiFi connected: always disable AP (user successfully configured WiFi)
success, message = self.disable_ap_mode()
if success:
if status.connected:
logger.info("Auto-disabled AP mode (WiFi connected)")
elif ethernet_connected:
logger.info("Auto-disabled AP mode (Ethernet connected)")
self._disconnected_checks = 0 # Reset counter
logger.info("Auto-disabled AP mode (WiFi connected)")
self._disconnected_checks = 0
return True
else:
logger.warning(f"Failed to auto-disable AP mode: {message}")
elif ethernet_connected and not force_active:
# Ethernet connected, AP not manually forced: auto-disable
success, message = self.disable_ap_mode()
if success:
logger.info("Auto-disabled AP mode (Ethernet connected)")
self._disconnected_checks = 0
return True
else:
logger.warning(f"Failed to auto-disable AP mode: {message}")
elif ethernet_connected and force_active:
logger.debug("AP mode is force-active; Ethernet connected but auto-disable suppressed")
elif not auto_enable:
# AP is active but auto_enable is disabled - this means it was manually enabled
# Don't disable it automatically, let it stay active
logger.debug("AP mode is active (manually enabled), keeping active")
# Idle-timeout check: disable AP if no client has connected within the window.

View File

@@ -49,9 +49,10 @@ class TestBasketballScoreboardPlugin(PluginTestBase):
"""Test that plugin has display modes."""
manifest = self.load_plugin_manifest(plugin_id)
assert 'display_modes' in manifest
assert 'basketball_live' in manifest['display_modes']
assert 'basketball_recent' in manifest['display_modes']
assert 'basketball_upcoming' in manifest['display_modes']
# Manifest uses league-prefixed modes (nba_, wnba_, ncaam_, ncaaw_)
assert 'nba_live' in manifest['display_modes']
assert 'nba_recent' in manifest['display_modes']
assert 'nba_upcoming' in manifest['display_modes']
def test_plugin_has_get_display_modes(self, plugin_id):
"""Test that plugin can return display modes."""

342
test/test_api_extractors.py Normal file
View File

@@ -0,0 +1,342 @@
"""
Tests for src/base_classes/api_extractors.py
Covers ESPNFootballExtractor, ESPNBaseballExtractor, ESPNHockeyExtractor,
SoccerAPIExtractor, and the shared _extract_common_details logic.
"""
import logging
import pytest
from src.base_classes.api_extractors import (
ESPNFootballExtractor,
ESPNBaseballExtractor,
ESPNHockeyExtractor,
SoccerAPIExtractor,
)
# ---------------------------------------------------------------------------
# Shared test data factories
# ---------------------------------------------------------------------------
def _make_espn_event(state: str = "in", home_abbr: str = "KC", away_abbr: str = "BUF",
home_score: str = "14", away_score: str = "7",
date_str: str = "2024-01-15T20:00:00Z",
include_situation: bool = False,
situation: dict | None = None,
status_detail: str = "2nd Qtr 8:42",
period: int = 2) -> dict:
"""Build a minimal ESPN-style game event dict."""
comp_status = {
"type": {
"state": state,
"shortDetail": status_detail,
"detail": status_detail,
"name": "STATUS_IN_PROGRESS",
},
"period": period,
"displayClock": "8:42",
}
comp = {
"status": comp_status,
"competitors": [
{
"homeAway": "home",
"team": {"abbreviation": home_abbr, "displayName": f"{home_abbr} Team"},
"score": home_score,
},
{
"homeAway": "away",
"team": {"abbreviation": away_abbr, "displayName": f"{away_abbr} Team"},
"score": away_score,
},
],
}
if include_situation:
comp["situation"] = situation or {}
return {
"id": "test-game-1",
"date": date_str,
"competitions": [comp],
}
def _make_logger() -> logging.Logger:
return logging.getLogger("test_extractor")
# ---------------------------------------------------------------------------
# ESPNFootballExtractor
# ---------------------------------------------------------------------------
class TestESPNFootballExtractor:
def setup_method(self):
self.extractor = ESPNFootballExtractor(_make_logger())
def test_extract_live_game_basic_fields(self):
event = _make_espn_event(state="in", home_score="14", away_score="7")
result = self.extractor.extract_game_details(event)
assert result is not None
assert result["home_abbr"] == "KC"
assert result["away_abbr"] == "BUF"
assert result["home_score"] == "14"
assert result["away_score"] == "7"
assert result["is_live"] is True
assert result["is_final"] is False
assert result["is_upcoming"] is False
def test_extract_final_game(self):
event = _make_espn_event(state="post")
result = self.extractor.extract_game_details(event)
assert result is not None
assert result["is_final"] is True
assert result["is_live"] is False
def test_extract_upcoming_game(self):
event = _make_espn_event(state="pre")
result = self.extractor.extract_game_details(event)
assert result is not None
assert result["is_upcoming"] is True
def test_sport_specific_fields_default_when_pregame(self):
event = _make_espn_event(state="pre")
fields = self.extractor.get_sport_specific_fields(event)
assert "down" in fields
assert "distance" in fields
assert "possession" in fields
assert "is_redzone" in fields
assert fields["is_redzone"] is False
def test_sport_specific_fields_live_with_situation(self):
situation = {
"down": 3,
"distance": 7,
"possession": "KC",
"isRedZone": True,
"homeTimeouts": 2,
"awayTimeouts": 1,
}
event = _make_espn_event(state="in", include_situation=True, situation=situation)
fields = self.extractor.get_sport_specific_fields(event)
assert fields["down"] == 3
assert fields["distance"] == 7
assert fields["is_redzone"] is True
assert fields["home_timeouts"] == 2
assert fields["away_timeouts"] == 1
def test_scoring_event_detected(self):
# situation must be non-empty (truthy) for the live block to execute
situation = {"down": 1, "distance": 10}
event = _make_espn_event(
state="in",
include_situation=True,
situation=situation,
status_detail="touchdown scored",
)
fields = self.extractor.get_sport_specific_fields(event)
assert "touchdown" in fields.get("scoring_event", "").lower()
def test_returns_none_on_empty_event(self):
assert self.extractor.extract_game_details({}) is None
def test_returns_none_when_teams_missing(self):
event = {
"id": "x",
"date": "2024-01-15T20:00:00Z",
"competitions": [
{
"status": {"type": {"state": "in", "shortDetail": "", "detail": "", "name": ""}},
"competitors": [], # no competitors
}
],
}
assert self.extractor.extract_game_details(event) is None
def test_date_z_suffix_parsed(self):
event = _make_espn_event(date_str="2024-01-15T20:00:00Z")
result = self.extractor.extract_game_details(event)
# Should not raise and should return a result
assert result is not None
def test_id_propagated(self):
event = _make_espn_event()
result = self.extractor.extract_game_details(event)
assert result["id"] == "test-game-1"
# ---------------------------------------------------------------------------
# ESPNBaseballExtractor
# ---------------------------------------------------------------------------
class TestESPNBaseballExtractor:
def setup_method(self):
self.extractor = ESPNBaseballExtractor(_make_logger())
def test_extract_live_game(self):
event = _make_espn_event(
state="in", home_abbr="NYY", away_abbr="BOS",
home_score="3", away_score="2"
)
result = self.extractor.extract_game_details(event)
assert result is not None
assert result["home_abbr"] == "NYY"
assert result["is_live"] is True
def test_baseball_sport_fields_defaults(self):
event = _make_espn_event(state="pre")
fields = self.extractor.get_sport_specific_fields(event)
assert "inning" in fields
assert "outs" in fields
assert "bases" in fields
assert "strikes" in fields
assert "balls" in fields
def test_baseball_sport_fields_live(self):
situation = {
"inning": 7,
"outs": 2,
"bases": "110",
"strikes": 2,
"balls": 3,
"pitcher": "Smith",
"batter": "Jones",
}
event = _make_espn_event(state="in", include_situation=True, situation=situation)
fields = self.extractor.get_sport_specific_fields(event)
assert fields["inning"] == 7
assert fields["outs"] == 2
assert fields["strikes"] == 2
assert fields["pitcher"] == "Smith"
def test_returns_none_on_empty(self):
assert self.extractor.extract_game_details({}) is None
# ---------------------------------------------------------------------------
# ESPNHockeyExtractor
# ---------------------------------------------------------------------------
class TestESPNHockeyExtractor:
def setup_method(self):
self.extractor = ESPNHockeyExtractor(_make_logger())
def test_extract_live_game(self):
event = _make_espn_event(
state="in", home_abbr="BOS", away_abbr="TOR",
home_score="2", away_score="1"
)
result = self.extractor.extract_game_details(event)
assert result is not None
assert result["is_live"] is True
def test_hockey_period_text_p1(self):
situation = {"isPowerPlay": False}
event = _make_espn_event(
state="in", include_situation=True, situation=situation, period=1
)
fields = self.extractor.get_sport_specific_fields(event)
assert fields["period_text"] == "P1"
def test_hockey_period_text_p2(self):
situation = {"isPowerPlay": False} # non-empty so the live block executes
event = _make_espn_event(
state="in", include_situation=True, situation=situation, period=2
)
fields = self.extractor.get_sport_specific_fields(event)
assert fields["period_text"] == "P2"
def test_hockey_period_text_p3(self):
situation = {"isPowerPlay": False}
event = _make_espn_event(
state="in", include_situation=True, situation=situation, period=3
)
fields = self.extractor.get_sport_specific_fields(event)
assert fields["period_text"] == "P3"
def test_hockey_period_text_ot(self):
situation = {"isPowerPlay": False}
event = _make_espn_event(
state="in", include_situation=True, situation=situation, period=4
)
fields = self.extractor.get_sport_specific_fields(event)
assert fields["period_text"] == "OT1"
def test_hockey_power_play(self):
situation = {"isPowerPlay": True, "homeShots": 12, "awayShots": 8}
event = _make_espn_event(state="in", include_situation=True, situation=situation, period=2)
fields = self.extractor.get_sport_specific_fields(event)
assert fields["power_play"] is True
assert fields["shots_on_goal"]["home"] == 12
assert fields["shots_on_goal"]["away"] == 8
def test_hockey_fields_defaults_pregame(self):
event = _make_espn_event(state="pre")
fields = self.extractor.get_sport_specific_fields(event)
assert "period" in fields
assert "power_play" in fields
assert fields["power_play"] is False
def test_returns_none_on_empty(self):
assert self.extractor.extract_game_details({}) is None
# ---------------------------------------------------------------------------
# SoccerAPIExtractor
# ---------------------------------------------------------------------------
class TestSoccerAPIExtractor:
def setup_method(self):
self.extractor = SoccerAPIExtractor(_make_logger())
def _make_soccer_event(self, is_live: bool = True) -> dict:
return {
"id": "soccer-1",
"home_team": {"abbreviation": "ARS", "name": "Arsenal"},
"away_team": {"abbreviation": "CHE", "name": "Chelsea"},
"home_score": "2",
"away_score": "1",
"status": "LIVE",
"is_live": is_live,
"is_final": not is_live,
"is_upcoming": False,
"half": "1",
"stoppage_time": "2",
"home_yellow_cards": 1,
"away_yellow_cards": 2,
"home_red_cards": 0,
"away_red_cards": 0,
"home_possession": 55,
"away_possession": 45,
}
def test_extract_live_game(self):
event = self._make_soccer_event(is_live=True)
result = self.extractor.extract_game_details(event)
assert result is not None
assert result["home_abbr"] == "ARS"
assert result["away_abbr"] == "CHE"
assert result["is_live"] is True
def test_sport_specific_cards(self):
event = self._make_soccer_event()
fields = self.extractor.get_sport_specific_fields(event)
assert fields["cards"]["home_yellow"] == 1
assert fields["cards"]["away_yellow"] == 2
assert fields["cards"]["home_red"] == 0
def test_sport_specific_possession(self):
event = self._make_soccer_event()
fields = self.extractor.get_sport_specific_fields(event)
assert fields["possession"]["home"] == 55
assert fields["possession"]["away"] == 45
def test_sport_specific_half(self):
event = self._make_soccer_event()
fields = self.extractor.get_sport_specific_fields(event)
assert fields["half"] == "1"
def test_scores_as_strings(self):
event = self._make_soccer_event()
result = self.extractor.extract_game_details(event)
assert result["home_score"] == "2"
assert result["away_score"] == "1"

View File

@@ -0,0 +1,299 @@
"""
Tests for src/background_data_service.py
Covers BackgroundDataService: submit_fetch_request, get_result,
is_request_complete, get_request_status, cancel_request, get_statistics,
_cleanup_completed_requests, shutdown, and get_background_service singleton.
"""
import time
import pytest
from unittest.mock import MagicMock, patch, Mock
from concurrent.futures import Future
from src.background_data_service import (
BackgroundDataService,
FetchStatus,
FetchResult,
FetchRequest,
get_background_service,
shutdown_background_service,
)
import src.background_data_service as bds_module
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def reset_global_service():
"""Ensure each test starts with no global singleton."""
shutdown_background_service()
yield
shutdown_background_service()
@pytest.fixture
def mock_cache_manager():
m = MagicMock()
m.get.return_value = None
m.set.return_value = None
m.generate_sport_cache_key.return_value = "test_key"
return m
@pytest.fixture
def service(mock_cache_manager):
svc = BackgroundDataService(mock_cache_manager, max_workers=2, request_timeout=5)
yield svc
svc.shutdown(wait=False)
# ---------------------------------------------------------------------------
# Initialisation
# ---------------------------------------------------------------------------
class TestInitialisation:
def test_stats_zeroed(self, service):
stats = service.get_statistics()
assert stats["total_requests"] == 0
assert stats["completed_requests"] == 0
assert stats["failed_requests"] == 0
def test_no_active_requests(self, service):
assert len(service.active_requests) == 0
def test_not_shutdown(self, service):
assert service._shutdown is False
# ---------------------------------------------------------------------------
# Cache hit path
# ---------------------------------------------------------------------------
class TestCacheHit:
def test_cache_hit_returns_request_id(self, service, mock_cache_manager):
mock_cache_manager.get.return_value = {"events": [{"id": "1"}]}
req_id = service.submit_fetch_request(
sport="nfl", year=2024,
url="https://example.com/nfl",
cache_key="nfl_key",
)
assert req_id is not None
# Request should be immediately complete due to cache hit
result = service.get_result(req_id)
assert result is not None
assert result.success is True
assert result.cached is True
def test_cache_hit_increments_stat(self, service, mock_cache_manager):
mock_cache_manager.get.return_value = {"events": []}
service.submit_fetch_request(sport="nba", year=2024, url="https://x.com", cache_key="k")
stats = service.get_statistics()
assert stats["cached_hits"] == 1
# ---------------------------------------------------------------------------
# Actual fetch path (mocked HTTP)
# ---------------------------------------------------------------------------
class TestFetchPath:
def _valid_payload(self) -> dict:
return {"events": [{"id": "g1"}, {"id": "g2"}]}
def test_successful_fetch_completes(self, service, mock_cache_manager):
mock_resp = Mock()
mock_resp.json.return_value = self._valid_payload()
mock_resp.raise_for_status.return_value = None
with patch.object(service.session, "get", return_value=mock_resp):
req_id = service.submit_fetch_request(
sport="nfl", year=2024,
url="https://example.com/nfl",
cache_key="nfl_test",
)
# Wait for the background thread
deadline = time.time() + 5
while not service.is_request_complete(req_id) and time.time() < deadline:
time.sleep(0.05)
result = service.get_result(req_id)
assert result is not None
assert result.success is True
assert result.data == self._valid_payload()
def test_failed_fetch_records_error(self, service, mock_cache_manager):
with patch.object(service.session, "get", side_effect=Exception("network error")):
req_id = service.submit_fetch_request(
sport="nba", year=2024,
url="https://example.com/nba",
cache_key="nba_test",
max_retries=0,
)
deadline = time.time() + 5
while not service.is_request_complete(req_id) and time.time() < deadline:
time.sleep(0.05)
result = service.get_result(req_id)
assert result is not None
assert result.success is False
assert result.error is not None
def test_cache_miss_increments_stat(self, service, mock_cache_manager):
mock_resp = Mock()
mock_resp.json.return_value = self._valid_payload()
mock_resp.raise_for_status.return_value = None
with patch.object(service.session, "get", return_value=mock_resp):
service.submit_fetch_request(
sport="nfl", year=2024, url="https://x.com", cache_key="new_key",
)
stats = service.get_statistics()
assert stats["cache_misses"] == 1
def test_callback_called_on_success(self, service, mock_cache_manager):
callback = Mock()
mock_resp = Mock()
mock_resp.json.return_value = self._valid_payload()
mock_resp.raise_for_status.return_value = None
with patch.object(service.session, "get", return_value=mock_resp):
req_id = service.submit_fetch_request(
sport="nfl", year=2024, url="https://x.com",
cache_key="cb_key", callback=callback, max_retries=0,
)
deadline = time.time() + 5
while not service.is_request_complete(req_id) and time.time() < deadline:
time.sleep(0.05)
callback.assert_called_once()
call_arg = callback.call_args[0][0]
assert isinstance(call_arg, FetchResult)
def test_data_cached_after_successful_fetch(self, service, mock_cache_manager):
mock_resp = Mock()
mock_resp.json.return_value = self._valid_payload()
mock_resp.raise_for_status.return_value = None
with patch.object(service.session, "get", return_value=mock_resp):
req_id = service.submit_fetch_request(
sport="nfl", year=2024, url="https://x.com", cache_key="cache_after_key",
)
deadline = time.time() + 5
while not service.is_request_complete(req_id) and time.time() < deadline:
time.sleep(0.05)
mock_cache_manager.set.assert_called()
# ---------------------------------------------------------------------------
# Request status / cancel
# ---------------------------------------------------------------------------
class TestRequestStatusAndCancel:
def test_unknown_request_status_is_none(self, service):
assert service.get_request_status("nonexistent") is None
def test_cancel_active_request(self, service, mock_cache_manager):
# Manually insert an active request
req = FetchRequest(
id="r1", sport="nfl", year=2024,
cache_key="k", url="https://x.com",
)
req.status = FetchStatus.PENDING
service.active_requests["r1"] = req
result = service.cancel_request("r1")
assert result is True
assert "r1" not in service.active_requests
def test_cancel_nonexistent_request(self, service):
assert service.cancel_request("does-not-exist") is False
def test_is_request_complete_false_for_active(self, service, mock_cache_manager):
req = FetchRequest(
id="r2", sport="mlb", year=2024,
cache_key="k2", url="https://x.com",
)
service.active_requests["r2"] = req
assert service.is_request_complete("r2") is False
def test_is_request_complete_true_for_done(self, service):
result = FetchResult(request_id="r3", success=True)
service.completed_requests["r3"] = result
assert service.is_request_complete("r3") is True
def test_get_result_returns_none_for_unknown(self, service):
assert service.get_result("unknown") is None
# ---------------------------------------------------------------------------
# Shutdown
# ---------------------------------------------------------------------------
class TestShutdown:
def test_shutdown_sets_flag(self, service):
service.shutdown(wait=False)
assert service._shutdown is True
def test_submit_after_shutdown_raises(self, service, mock_cache_manager):
service.shutdown(wait=False)
with pytest.raises(RuntimeError, match="shutting down"):
service.submit_fetch_request(
sport="nfl", year=2024, url="https://x.com", cache_key="k"
)
# ---------------------------------------------------------------------------
# Cleanup
# ---------------------------------------------------------------------------
class TestCleanup:
def test_cleanup_removes_old_requests(self, service):
old_result = FetchResult(request_id="old", success=True)
old_result.completed_at = time.time() - 7200 # 2 hours ago
service.completed_requests["old"] = old_result
service._last_completed_requests_cleanup = 0 # force cleanup
removed = service._cleanup_completed_requests(force=True)
assert removed >= 1
assert "old" not in service.completed_requests
def test_cleanup_respects_interval(self, service):
old_result = FetchResult(request_id="r", success=True)
old_result.completed_at = time.time() - 7200
service.completed_requests["r"] = old_result
# Cleanup interval not passed, should skip
service._last_completed_requests_cleanup = time.time()
removed = service._cleanup_completed_requests(force=False)
assert removed == 0
def test_size_limit_enforcement(self, service):
service._max_completed_requests = 3
for i in range(5):
result = FetchResult(request_id=str(i), success=True)
result.completed_at = time.time() - (5 - i) * 100 # oldest first
service.completed_requests[str(i)] = result
service._last_completed_requests_cleanup = 0
service._cleanup_completed_requests(force=True)
assert len(service.completed_requests) <= 3
# ---------------------------------------------------------------------------
# Singleton get_background_service
# ---------------------------------------------------------------------------
class TestGetBackgroundService:
def test_first_call_requires_cache_manager(self):
with pytest.raises(ValueError, match="cache_manager is required"):
get_background_service()
def test_creates_singleton(self, mock_cache_manager):
svc1 = get_background_service(mock_cache_manager)
svc2 = get_background_service()
assert svc1 is svc2
def test_shutdown_clears_singleton(self, mock_cache_manager):
get_background_service(mock_cache_manager)
shutdown_background_service()
with pytest.raises(ValueError):
get_background_service()

209
test/test_data_sources.py Normal file
View File

@@ -0,0 +1,209 @@
"""
Tests for src/base_classes/data_sources.py
Covers ESPNDataSource, MLBAPIDataSource, SoccerAPIDataSource.
All HTTP calls are mocked to avoid network access.
"""
import logging
from datetime import datetime, date
from unittest.mock import MagicMock, patch, Mock
import pytest
import requests
from src.base_classes.data_sources import ESPNDataSource, MLBAPIDataSource, SoccerAPIDataSource
def _make_logger() -> logging.Logger:
return logging.getLogger("test_data_sources")
def _mock_response(json_data: dict, status_code: int = 200):
resp = Mock(spec=requests.Response)
resp.status_code = status_code
resp.json.return_value = json_data
resp.raise_for_status = Mock()
if status_code >= 400:
resp.raise_for_status.side_effect = requests.HTTPError(response=resp)
return resp
# ---------------------------------------------------------------------------
# ESPNDataSource
# ---------------------------------------------------------------------------
class TestESPNDataSource:
def setup_method(self):
self.source = ESPNDataSource(_make_logger())
def test_get_headers(self):
headers = self.source.get_headers()
assert headers["Accept"] == "application/json"
assert "LEDMatrix" in headers["User-Agent"]
def test_fetch_live_games_returns_live_events(self):
live_event = {
"competitions": [{"status": {"type": {"state": "in"}}}]
}
non_live_event = {
"competitions": [{"status": {"type": {"state": "pre"}}}]
}
payload = {"events": [live_event, non_live_event]}
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
result = self.source.fetch_live_games("football", "nfl")
assert len(result) == 1
assert result[0] is live_event
def test_fetch_live_games_empty_when_none_live(self):
payload = {"events": [
{"competitions": [{"status": {"type": {"state": "post"}}}]}
]}
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
result = self.source.fetch_live_games("football", "nfl")
assert result == []
def test_fetch_live_games_returns_empty_on_error(self):
with patch.object(self.source.session, "get", side_effect=Exception("network failure")):
result = self.source.fetch_live_games("football", "nfl")
assert result == []
def test_fetch_schedule_returns_all_events(self):
events = [{"id": "1"}, {"id": "2"}]
payload = {"events": events}
start = datetime(2024, 1, 1)
end = datetime(2024, 1, 7)
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
result = self.source.fetch_schedule("football", "nfl", (start, end))
assert len(result) == 2
def test_fetch_schedule_returns_empty_on_error(self):
with patch.object(self.source.session, "get", side_effect=Exception("timeout")):
result = self.source.fetch_schedule("football", "nfl", (datetime.now(), datetime.now()))
assert result == []
def test_fetch_standings_success(self):
payload = {"standings": []}
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
result = self.source.fetch_standings("football", "nfl")
assert result == payload
def test_fetch_standings_returns_empty_on_error(self):
with patch.object(self.source.session, "get", side_effect=Exception("error")):
result = self.source.fetch_standings("football", "nfl")
assert result == {}
def test_base_url_set_correctly(self):
assert "espn.com" in self.source.base_url
# ---------------------------------------------------------------------------
# MLBAPIDataSource
# ---------------------------------------------------------------------------
class TestMLBAPIDataSource:
def setup_method(self):
self.source = MLBAPIDataSource(_make_logger())
def test_fetch_live_games_filters_live(self):
live_game = {"status": {"abstractGameState": "Live"}}
final_game = {"status": {"abstractGameState": "Final"}}
payload = {"dates": [{"games": [live_game, final_game]}]}
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
result = self.source.fetch_live_games("baseball", "mlb")
assert len(result) == 1
assert result[0] is live_game
def test_fetch_live_games_empty_dates(self):
payload = {"dates": []}
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
result = self.source.fetch_live_games("baseball", "mlb")
assert result == []
def test_fetch_live_games_returns_empty_on_error(self):
with patch.object(self.source.session, "get", side_effect=Exception("err")):
result = self.source.fetch_live_games("baseball", "mlb")
assert result == []
def test_fetch_schedule_aggregates_all_dates(self):
payload = {
"dates": [
{"games": [{"id": "1"}, {"id": "2"}]},
{"games": [{"id": "3"}]},
]
}
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
result = self.source.fetch_schedule("baseball", "mlb", (datetime.now(), datetime.now()))
assert len(result) == 3
def test_fetch_schedule_returns_empty_on_error(self):
with patch.object(self.source.session, "get", side_effect=Exception("err")):
result = self.source.fetch_schedule("baseball", "mlb", (datetime.now(), datetime.now()))
assert result == []
def test_fetch_standings_success(self):
payload = {"records": []}
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
result = self.source.fetch_standings("baseball", "mlb")
assert result == payload
def test_fetch_standings_returns_empty_on_error(self):
with patch.object(self.source.session, "get", side_effect=Exception("err")):
result = self.source.fetch_standings("baseball", "mlb")
assert result == {}
# ---------------------------------------------------------------------------
# SoccerAPIDataSource
# ---------------------------------------------------------------------------
class TestSoccerAPIDataSource:
def setup_method(self):
self.source = SoccerAPIDataSource(_make_logger(), api_key="test-key-123")
def test_headers_include_api_key(self):
headers = self.source.get_headers()
assert headers["X-Auth-Token"] == "test-key-123"
def test_headers_without_api_key(self):
source = SoccerAPIDataSource(_make_logger())
headers = source.get_headers()
assert "X-Auth-Token" not in headers
def test_fetch_live_games_success(self):
payload = {"matches": [{"id": "m1"}, {"id": "m2"}]}
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
result = self.source.fetch_live_games("soccer", "eng.1")
assert len(result) == 2
def test_fetch_live_games_returns_empty_on_error(self):
with patch.object(self.source.session, "get", side_effect=Exception("err")):
result = self.source.fetch_live_games("soccer", "eng.1")
assert result == []
def test_fetch_schedule_success(self):
payload = {"matches": [{"id": "m1"}]}
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
result = self.source.fetch_schedule("soccer", "eng.1", (datetime.now(), datetime.now()))
assert len(result) == 1
def test_fetch_schedule_returns_empty_on_error(self):
with patch.object(self.source.session, "get", side_effect=Exception("err")):
result = self.source.fetch_schedule("soccer", "eng.1", (datetime.now(), datetime.now()))
assert result == []
def test_fetch_standings_success(self):
payload = {"standings": []}
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
result = self.source.fetch_standings("soccer", "PL")
assert result == payload
def test_fetch_standings_returns_empty_on_error(self):
with patch.object(self.source.session, "get", side_effect=Exception("err")):
result = self.source.fetch_standings("soccer", "PL")
assert result == {}

View File

@@ -167,6 +167,53 @@ class TestDisplayControllerLivePriority:
assert controller.current_display_mode == "test_plugin_live"
assert controller.force_change is True
def test_live_priority_resume_continues_rotation(self, test_display_controller):
"""Regression: when live priority ends, rotation resumes where it was
interrupted, not after the live plugin's mode.
Without the fix, _apply_live_priority left current_mode_index pointing at
the live plugin's slot, so the next rotation step skipped every mode
between the interrupted position and the live plugin (e.g. elections,
which sits just before a flights plugin in the order)."""
controller = test_display_controller
controller.available_modes = [
"weather", "forecast", "almanac", "election_ticker", "flight_live"
]
# Rotation is about to show the 3rd mode (index 2).
controller.current_mode_index = 2
controller.current_display_mode = "almanac"
controller._live_resume_index = None
# Live priority (e.g. planes overhead) preempts -> flight_live (index 4).
controller._apply_live_priority("flight_live")
assert controller.current_display_mode == "flight_live"
assert controller.current_mode_index == 4
assert controller._live_resume_index == 2 # saved rotation position
# Re-checks while the hold continues must not move the saved position.
controller._apply_live_priority("flight_live")
assert controller._live_resume_index == 2
# Live priority ends -> resume at the saved index (almanac), so the next
# rotation step lands on election_ticker (index 3) rather than skipping it.
controller._apply_live_priority(None)
assert controller.current_mode_index == 2
assert controller.current_display_mode == "almanac"
assert controller._live_resume_index is None
def test_live_priority_no_resume_when_idle(self, test_display_controller):
"""No saved position + no live content is a no-op (normal rotation)."""
controller = test_display_controller
controller.available_modes = ["a", "b", "c"]
controller.current_mode_index = 1
controller.current_display_mode = "b"
controller._live_resume_index = None
controller._apply_live_priority(None)
assert controller.current_mode_index == 1
assert controller.current_display_mode == "b"
class TestDisplayControllerDynamicDuration:
"""Test dynamic duration handling."""
@@ -229,18 +276,20 @@ class TestDisplayControllerSchedule:
def test_inactive_hours(self, test_display_controller):
"""Test inactive hours check."""
controller = test_display_controller
# Inject schedule directly into self.config (what _check_schedule actually reads)
# and reset the minute gate so the cached result from any prior call is cleared.
controller.config['schedule'] = {
"enabled": True,
"start_time": "09:00",
"end_time": "17:00",
}
controller._schedule_checked_minute = None
controller._tz = None
with patch('src.display_controller.datetime') as mock_datetime:
mock_datetime.now.return_value.strftime.return_value.lower.return_value = "monday"
mock_datetime.now.return_value.time.return_value = datetime.strptime("20:00", "%H:%M").time()
mock_datetime.strptime = datetime.strptime
schedule_config = {
"schedule": {
"enabled": True,
"start_time": "09:00",
"end_time": "17:00"
}
}
with patch.object(controller.config_service, 'get_config', return_value=schedule_config):
controller._check_schedule()
assert controller.is_display_active is False
controller._check_schedule()
assert controller.is_display_active is False

View File

@@ -0,0 +1,322 @@
"""
Tests for the three display_controller.py optimizations:
Opt #1 — inspect.signature() caching per plugin_id
Opt #2 — pre-cached config values (_normal_brightness, _scroll_speed)
Opt #3 — schedule minute-gate (_check_schedule, _check_dim_schedule)
"""
import pytest
import time
from datetime import datetime
from unittest.mock import MagicMock, patch, call
# ---------------------------------------------------------------------------
# Shared fixture
# ---------------------------------------------------------------------------
@pytest.fixture
def controller(test_display_controller):
"""Return a ready DisplayController from the existing suite fixture."""
return test_display_controller
# ---------------------------------------------------------------------------
# Opt #1 — signature cache
# ---------------------------------------------------------------------------
class TestSignatureCache:
"""inspect.signature() should be called at most once per plugin_id."""
class _PluginWithMode:
"""Real class whose display() accepts display_mode — inspectable by signature."""
plugin_id = "mode_plugin"
def display(self, display_mode=None, force_clear=False):
return True
class _PluginNoMode:
"""Real class whose display() does NOT accept display_mode."""
plugin_id = "no_mode_plugin"
def display(self, force_clear=False):
return True
def test_cache_starts_empty(self, controller):
assert controller._plugin_accepts_display_mode == {}
def test_signature_computed_and_cached(self, controller):
"""After the first cache population, the dict holds a bool and stays unchanged
if queried again without explicitly deleting the key."""
import inspect as _inspect
plugin = self._PluginNoMode()
key = "sig_test"
if key not in controller._plugin_accepts_display_mode:
controller._plugin_accepts_display_mode[key] = (
"display_mode" in _inspect.signature(plugin.display).parameters
)
original = controller._plugin_accepts_display_mode[key]
# Accessing cache again should not change the value
second = controller._plugin_accepts_display_mode[key]
assert second == original
def test_cache_stores_false_for_no_display_mode(self, controller):
"""Plugin whose display() doesn't accept display_mode → cached False."""
import inspect as _inspect
plugin = self._PluginNoMode()
controller._plugin_accepts_display_mode["no_mode_plugin"] = (
"display_mode" in _inspect.signature(plugin.display).parameters
)
assert controller._plugin_accepts_display_mode["no_mode_plugin"] is False
def test_cache_stores_true_for_display_mode(self, controller):
"""Plugin whose display() accepts display_mode → cached True."""
import inspect as _inspect
plugin = self._PluginWithMode()
controller._plugin_accepts_display_mode["mode_plugin"] = (
"display_mode" in _inspect.signature(plugin.display).parameters
)
assert controller._plugin_accepts_display_mode["mode_plugin"] is True
def test_cache_cleared_on_plugin_reload(self, controller):
"""Populating plugin_modes for an id that's already cached must clear the entry."""
plugin = MagicMock()
controller._plugin_accepts_display_mode["reload_plugin"] = False
# Simulate the plugin_modes population code path (as in __init__)
plugin_id = "reload_plugin"
controller.plugin_modes["reload_plugin"] = plugin
if hasattr(controller, "_plugin_accepts_display_mode"):
controller._plugin_accepts_display_mode.pop(plugin_id, None)
assert "reload_plugin" not in controller._plugin_accepts_display_mode
# ---------------------------------------------------------------------------
# Opt #2 — cached config values
# ---------------------------------------------------------------------------
class TestCachedConfigValues:
"""_normal_brightness and _scroll_speed are populated from config at init."""
def test_normal_brightness_cached(self, controller):
"""_normal_brightness must equal what the config says."""
expected = (
controller.config
.get("display", {})
.get("hardware", {})
.get("brightness", 90)
)
assert controller._normal_brightness == expected
def test_scroll_speed_cached(self, controller):
"""_scroll_speed must equal what the config says."""
expected = (
controller.config
.get("display", {})
.get("vegas_scroll", {})
.get("scroll_speed", 75)
)
assert controller._scroll_speed == expected
def test_current_brightness_uses_cached_value(self, controller):
"""current_brightness is initialised from _normal_brightness."""
assert controller.current_brightness == controller._normal_brightness
def test_cached_target_brightness_init(self, controller):
"""_cached_target_brightness starts equal to _normal_brightness."""
assert controller._cached_target_brightness == controller._normal_brightness
def test_normal_brightness_default_is_90(self, controller):
"""If config has no brightness key the default is 90."""
controller.config = {}
controller._normal_brightness = (
controller.config.get("display", {})
.get("hardware", {})
.get("brightness", 90)
)
assert controller._normal_brightness == 90
# ---------------------------------------------------------------------------
# Opt #3 — schedule minute-gate
# ---------------------------------------------------------------------------
class TestScheduleMinuteGate:
"""_check_schedule and _check_dim_schedule skip re-evaluation within the same minute."""
# ── _check_schedule ──────────────────────────────────────────────────────
def test_schedule_checked_minute_starts_none(self, controller):
assert controller._schedule_checked_minute is None
def test_first_call_sets_checked_minute(self, controller):
"""After the first real evaluation the minute key is stored."""
controller.config["schedule"] = {
"enabled": True,
"start_time": "00:00",
"end_time": "23:59",
}
controller._schedule_checked_minute = None
controller._tz = None
controller._check_schedule()
assert controller._schedule_checked_minute is not None
def test_second_call_same_minute_does_not_re_evaluate(self, controller):
"""A second call with the same (hour, minute) returns without changing state."""
controller.config["schedule"] = {
"enabled": True,
"start_time": "00:00",
"end_time": "23:59",
}
controller._tz = None
controller._schedule_checked_minute = None
# First call — evaluates and marks as active (whole-day window)
controller._check_schedule()
assert controller.is_display_active is True
first_minute_key = controller._schedule_checked_minute
# Force is_display_active to False so we can tell if it gets re-evaluated
controller.is_display_active = False
# Second call within the same minute — gate fires, is_display_active unchanged
controller._schedule_checked_minute = first_minute_key # same minute
controller._check_schedule()
assert controller.is_display_active is False, (
"Second call in same minute should return immediately without re-evaluation"
)
def test_new_minute_forces_re_evaluation(self, controller):
"""A different (hour, minute) key causes a full re-evaluation."""
controller.config["schedule"] = {
"enabled": True,
"start_time": "00:00",
"end_time": "23:59",
}
controller._tz = None
# Plant a stale minute key from yesterday
controller._schedule_checked_minute = (-1, -1)
controller.is_display_active = False # wrong value to be corrected
controller._check_schedule()
assert controller.is_display_active is True, (
"A new minute key should trigger re-evaluation and correct is_display_active"
)
def test_gate_skipped_when_schedule_disabled(self, controller):
"""When schedule.enabled=False the method returns before reaching the gate."""
controller.config["schedule"] = {"enabled": False}
controller._schedule_checked_minute = None
controller._tz = None
controller._check_schedule()
# The early-return path doesn't set the minute key
assert controller._schedule_checked_minute is None
# ── _check_dim_schedule ──────────────────────────────────────────────────
def test_dim_checked_minute_starts_none(self, controller):
assert controller._dim_checked_minute is None
def test_first_dim_call_sets_checked_minute(self, controller):
"""First call with dim schedule enabled stores the minute key."""
controller.config["dim_schedule"] = {
"enabled": True,
"start_time": "22:00",
"end_time": "06:00",
}
controller.is_display_active = True
controller._dim_checked_minute = None
controller._tz = None
controller._check_dim_schedule()
assert controller._dim_checked_minute is not None
def test_dim_second_call_returns_cached_brightness(self, controller):
"""Second call with same minute returns _cached_target_brightness immediately."""
controller.config["dim_schedule"] = {
"enabled": True,
"start_time": "22:00",
"end_time": "06:00",
}
controller.is_display_active = True
controller._dim_checked_minute = None
controller._tz = None
# First call stores the result
first_result = controller._check_dim_schedule()
assert controller._cached_target_brightness == first_result
minute_key = controller._dim_checked_minute
# Corrupt cached value to something recognisable
controller._cached_target_brightness = 42
# Second call in same minute — must return the cached 42
controller._dim_checked_minute = minute_key
second_result = controller._check_dim_schedule()
assert second_result == 42, (
"Same-minute call must return cached brightness, not re-compute"
)
def test_dim_gate_skipped_when_display_off(self, controller):
"""When display is off the method exits before the minute gate."""
controller.config["dim_schedule"] = {"enabled": True, "start_time": "22:00", "end_time": "06:00"}
controller.is_display_active = False
controller._dim_checked_minute = None
controller._tz = None
controller._check_dim_schedule()
# Early-exit path does not set the minute key
assert controller._dim_checked_minute is None
def test_dim_cached_target_brightness_updated_after_full_evaluation(self, controller):
"""After a full evaluation _cached_target_brightness reflects the result."""
controller.config["dim_schedule"] = {
"enabled": True,
"start_time": "22:00",
"end_time": "06:00",
}
controller.is_display_active = True
controller._dim_checked_minute = None # force full re-evaluation
controller._tz = None
result = controller._check_dim_schedule()
assert controller._cached_target_brightness == result
# ── timezone lazy init ───────────────────────────────────────────────────
def test_tz_starts_none(self, controller):
assert controller._tz is None
def test_tz_lazily_initialised_on_first_schedule_check(self, controller):
"""_tz is None until _check_schedule or _check_dim_schedule is called."""
controller.config["schedule"] = {
"enabled": True,
"start_time": "00:00",
"end_time": "23:59",
}
controller._tz = None
controller._schedule_checked_minute = None
controller._check_schedule()
assert controller._tz is not None
def test_tz_shared_between_schedule_and_dim(self, controller):
"""Both methods use the same cached _tz instance."""
controller.config["schedule"] = {"enabled": True, "start_time": "00:00", "end_time": "23:59"}
controller.config["dim_schedule"] = {"enabled": True, "start_time": "22:00", "end_time": "06:00"}
controller.is_display_active = True
controller._tz = None
controller._schedule_checked_minute = None
controller._dim_checked_minute = None
controller._check_schedule()
tz_after_schedule = controller._tz
controller._check_dim_schedule()
assert controller._tz is tz_after_schedule, (
"_check_dim_schedule should reuse the _tz set by _check_schedule"
)

317
test/test_game_helper.py Normal file
View File

@@ -0,0 +1,317 @@
"""
Tests for src/common/game_helper.py
Covers GameHelper: extract_game_details, filter_*, sort_games_by_time,
process_games, get_game_summary, and all private helpers.
"""
import logging
import pytest
from datetime import datetime, timezone, timedelta
from src.common.game_helper import GameHelper
def _make_logger() -> logging.Logger:
return logging.getLogger("test_game_helper")
def _make_espn_event(
state: str = "in",
home_abbr: str = "LAL",
away_abbr: str = "BOS",
home_score: str = "105",
away_score: str = "98",
date_str: str = "2024-01-15T20:00:00Z",
period: int = 4,
status_name: str = "STATUS_IN_PROGRESS",
home_record: str = "30-10",
away_record: str = "25-15",
event_id: str = "game-1",
) -> dict:
return {
"id": event_id,
"date": date_str,
"competitions": [
{
"status": {
"type": {
"state": state,
"shortDetail": "Q4 2:30",
"name": status_name,
},
"period": period,
"displayClock": "2:30",
},
"competitors": [
{
"homeAway": "home",
"id": "h1",
"team": {"abbreviation": home_abbr, "displayName": f"{home_abbr} Team"},
"score": home_score,
"records": [{"summary": home_record}],
},
{
"homeAway": "away",
"id": "a1",
"team": {"abbreviation": away_abbr, "displayName": f"{away_abbr} Team"},
"score": away_score,
"records": [{"summary": away_record}],
},
],
}
],
}
@pytest.fixture
def helper():
return GameHelper(timezone_str="UTC", logger=_make_logger())
# ---------------------------------------------------------------------------
# extract_game_details
# ---------------------------------------------------------------------------
class TestExtractGameDetails:
def test_live_game(self, helper):
event = _make_espn_event(state="in")
result = helper.extract_game_details(event)
assert result is not None
assert result["is_live"] is True
assert result["is_final"] is False
assert result["is_upcoming"] is False
def test_final_game(self, helper):
event = _make_espn_event(state="post")
result = helper.extract_game_details(event)
assert result["is_final"] is True
def test_upcoming_game(self, helper):
event = _make_espn_event(state="pre")
result = helper.extract_game_details(event)
assert result["is_upcoming"] is True
def test_halftime_detection(self, helper):
event = _make_espn_event(state="halftime", status_name="STATUS_HALFTIME")
result = helper.extract_game_details(event)
assert result["is_halftime"] is True
def test_basic_fields_present(self, helper):
event = _make_espn_event()
result = helper.extract_game_details(event)
for key in ("id", "home_abbr", "away_abbr", "home_score", "away_score",
"home_record", "away_record", "start_time_utc"):
assert key in result
def test_team_abbreviations(self, helper):
event = _make_espn_event(home_abbr="MIA", away_abbr="PHX")
result = helper.extract_game_details(event)
assert result["home_abbr"] == "MIA"
assert result["away_abbr"] == "PHX"
def test_scores_as_strings(self, helper):
event = _make_espn_event(home_score="110", away_score="99")
result = helper.extract_game_details(event)
assert result["home_score"] == "110"
assert result["away_score"] == "99"
def test_returns_none_on_empty(self, helper):
assert helper.extract_game_details({}) is None
assert helper.extract_game_details(None) is None
def test_returns_none_when_no_competitors(self, helper):
event = _make_espn_event()
event["competitions"][0]["competitors"] = []
assert helper.extract_game_details(event) is None
def test_date_z_suffix_parsed(self, helper):
event = _make_espn_event(date_str="2024-06-01T19:30:00Z")
result = helper.extract_game_details(event)
assert result["start_time_utc"] is not None
assert result["start_time_utc"].tzinfo is not None
def test_zero_zero_record_suppressed(self, helper):
event = _make_espn_event(home_record="0-0", away_record="0-0-0")
result = helper.extract_game_details(event)
assert result["home_record"] == ""
assert result["away_record"] == ""
def test_basketball_sport_fields(self, helper):
event = _make_espn_event(period=3)
result = helper.extract_game_details(event, sport="basketball")
assert result["period_text"] == "Q3"
assert "clock" in result
def test_basketball_overtime_period(self, helper):
event = _make_espn_event(period=5)
result = helper.extract_game_details(event, sport="basketball")
assert result["period_text"] == "OT1"
def test_football_sport_fields(self, helper):
event = _make_espn_event(period=2)
result = helper.extract_game_details(event, sport="football")
assert result["period_text"] == "Q2"
def test_hockey_sport_fields_period_1(self, helper):
event = _make_espn_event(period=1)
result = helper.extract_game_details(event, sport="hockey")
assert result["period_text"] == "P1"
def test_hockey_sport_fields_ot(self, helper):
event = _make_espn_event(period=4)
result = helper.extract_game_details(event, sport="hockey")
assert result["period_text"] == "OT1"
def test_baseball_sport_fields(self, helper):
event = _make_espn_event(period=7)
result = helper.extract_game_details(event, sport="baseball")
assert result["period_text"] == "INN 7"
# ---------------------------------------------------------------------------
# Filter methods
# ---------------------------------------------------------------------------
class TestFilterMethods:
def _make_games(self):
now = datetime.now(timezone.utc)
return [
{"is_live": True, "is_final": False, "is_upcoming": False, "home_abbr": "LAL", "away_abbr": "BOS", "start_time_utc": now},
{"is_live": False, "is_final": True, "is_upcoming": False, "home_abbr": "MIA", "away_abbr": "PHX", "start_time_utc": now - timedelta(hours=3)},
{"is_live": False, "is_final": False, "is_upcoming": True, "home_abbr": "DAL", "away_abbr": "CHI", "start_time_utc": now + timedelta(hours=2)},
]
def test_filter_live_games(self, helper):
games = self._make_games()
result = helper.filter_live_games(games)
assert len(result) == 1
assert result[0]["home_abbr"] == "LAL"
def test_filter_final_games(self, helper):
games = self._make_games()
result = helper.filter_final_games(games)
assert len(result) == 1
assert result[0]["home_abbr"] == "MIA"
def test_filter_upcoming_games(self, helper):
games = self._make_games()
result = helper.filter_upcoming_games(games)
assert len(result) == 1
assert result[0]["home_abbr"] == "DAL"
def test_filter_favorite_teams_match(self, helper):
games = self._make_games()
result = helper.filter_favorite_teams(games, ["LAL"])
assert len(result) == 1
assert result[0]["home_abbr"] == "LAL"
def test_filter_favorite_teams_empty_list_returns_all(self, helper):
games = self._make_games()
result = helper.filter_favorite_teams(games, [])
assert len(result) == 3
def test_filter_favorite_teams_away_match(self, helper):
games = self._make_games()
result = helper.filter_favorite_teams(games, ["BOS"])
assert len(result) == 1
def test_filter_recent_games_within_window(self, helper):
now = datetime.now(timezone.utc)
games = [
{"start_time_utc": now - timedelta(days=2), "is_final": True},
{"start_time_utc": now - timedelta(days=10), "is_final": True},
]
result = helper.filter_recent_games(games, days_back=7)
assert len(result) == 1
def test_filter_recent_games_all_within(self, helper):
now = datetime.now(timezone.utc)
games = [
{"start_time_utc": now - timedelta(days=1)},
{"start_time_utc": now - timedelta(days=3)},
]
result = helper.filter_recent_games(games, days_back=7)
assert len(result) == 2
def test_sort_games_ascending(self, helper):
now = datetime.now(timezone.utc)
games = [
{"start_time_utc": now + timedelta(hours=2), "id": "late"},
{"start_time_utc": now + timedelta(hours=1), "id": "early"},
]
result = helper.sort_games_by_time(games)
assert result[0]["id"] == "early"
def test_sort_games_descending(self, helper):
now = datetime.now(timezone.utc)
games = [
{"start_time_utc": now + timedelta(hours=1), "id": "early"},
{"start_time_utc": now + timedelta(hours=2), "id": "late"},
]
result = helper.sort_games_by_time(games, reverse=True)
assert result[0]["id"] == "late"
# ---------------------------------------------------------------------------
# process_games
# ---------------------------------------------------------------------------
class TestProcessGames:
def test_processes_valid_events(self, helper):
events = [
_make_espn_event(event_id="1"),
_make_espn_event(event_id="2"),
]
result = helper.process_games(events)
assert len(result) == 2
def test_skips_invalid_events(self, helper):
events = [
_make_espn_event(event_id="1"),
{}, # invalid
]
result = helper.process_games(events)
assert len(result) == 1
def test_empty_events(self, helper):
assert helper.process_games([]) == []
# ---------------------------------------------------------------------------
# get_game_summary
# ---------------------------------------------------------------------------
class TestGetGameSummary:
def test_live_summary(self, helper):
game = {
"home_abbr": "LAL", "away_abbr": "BOS",
"home_score": "105", "away_score": "98",
"status_text": "Q4 2:30",
"is_live": True, "is_final": False,
}
summary = helper.get_game_summary(game)
assert "BOS" in summary
assert "LAL" in summary
assert "98" in summary
assert "105" in summary
def test_final_summary(self, helper):
game = {
"home_abbr": "LAL", "away_abbr": "BOS",
"home_score": "110", "away_score": "102",
"status_text": "Final",
"is_live": False, "is_final": True,
}
summary = helper.get_game_summary(game)
assert "Final" in summary
def test_upcoming_summary(self, helper):
game = {
"home_abbr": "LAL", "away_abbr": "BOS",
"home_score": "0", "away_score": "0",
"status_text": "7:30 PM",
"is_live": False, "is_final": False,
}
summary = helper.get_game_summary(game)
assert "7:30 PM" in summary

307
test/test_health_monitor.py Normal file
View File

@@ -0,0 +1,307 @@
"""
Tests for src/plugin_system/health_monitor.py
Covers PluginHealthMonitor: get_plugin_health_status, get_plugin_health_metrics,
get_all_plugin_health, _get_recovery_suggestions, start/stop_monitoring,
register_health_check.
"""
import pytest
from unittest.mock import MagicMock, patch
from datetime import datetime
from src.plugin_system.health_monitor import (
PluginHealthMonitor,
HealthStatus,
HealthMetrics,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _make_health_tracker(
summary: dict | None = None,
all_summaries: dict | None = None,
):
"""Return a mock PluginHealthTracker."""
tracker = MagicMock()
tracker.get_health_summary.return_value = summary
tracker.get_all_health_summaries.return_value = all_summaries or {}
return tracker
def _healthy_summary() -> dict:
return {
"success_rate": 100.0,
"circuit_state": "closed",
"consecutive_failures": 0,
"total_failures": 0,
"total_successes": 50,
"last_success_time": datetime.now().isoformat(),
"last_error": None,
}
def _degraded_summary() -> dict:
return {
"success_rate": 40.0, # 60% error rate
"circuit_state": "closed",
"consecutive_failures": 3,
"total_failures": 6,
"total_successes": 4,
"last_success_time": None,
"last_error": "timeout occurred",
}
def _unhealthy_summary() -> dict:
return {
"success_rate": 10.0, # 90% error rate
"circuit_state": "open",
"consecutive_failures": 10,
"total_failures": 9,
"total_successes": 1,
"last_success_time": None,
"last_error": "ImportError: missing module",
}
@pytest.fixture
def monitor():
tracker = _make_health_tracker(_healthy_summary())
return PluginHealthMonitor(health_tracker=tracker)
# ---------------------------------------------------------------------------
# get_plugin_health_status
# ---------------------------------------------------------------------------
class TestGetPluginHealthStatus:
def test_healthy_status(self):
tracker = _make_health_tracker(_healthy_summary())
monitor = PluginHealthMonitor(tracker)
status = monitor.get_plugin_health_status("plugin_a")
assert status == HealthStatus.HEALTHY
def test_degraded_status(self):
tracker = _make_health_tracker(_degraded_summary())
monitor = PluginHealthMonitor(tracker, degraded_threshold=0.5, unhealthy_threshold=0.8)
status = monitor.get_plugin_health_status("plugin_b")
assert status == HealthStatus.DEGRADED
def test_unhealthy_status(self):
tracker = _make_health_tracker(_unhealthy_summary())
monitor = PluginHealthMonitor(tracker, unhealthy_threshold=0.8)
status = monitor.get_plugin_health_status("plugin_c")
assert status == HealthStatus.UNHEALTHY
def test_open_circuit_breaker_is_unhealthy(self):
summary = _healthy_summary()
summary["circuit_state"] = "open"
tracker = _make_health_tracker(summary)
monitor = PluginHealthMonitor(tracker)
status = monitor.get_plugin_health_status("plugin_d")
assert status == HealthStatus.UNHEALTHY
def test_unknown_when_no_tracker(self):
monitor = PluginHealthMonitor(health_tracker=None)
status = monitor.get_plugin_health_status("plugin_e")
assert status == HealthStatus.UNKNOWN
def test_unknown_when_no_summary(self):
tracker = _make_health_tracker(None)
monitor = PluginHealthMonitor(tracker)
status = monitor.get_plugin_health_status("plugin_f")
assert status == HealthStatus.UNKNOWN
# ---------------------------------------------------------------------------
# get_plugin_health_metrics
# ---------------------------------------------------------------------------
class TestGetPluginHealthMetrics:
def test_healthy_metrics(self):
tracker = _make_health_tracker(_healthy_summary())
monitor = PluginHealthMonitor(tracker)
metrics = monitor.get_plugin_health_metrics("plugin_a")
assert isinstance(metrics, HealthMetrics)
assert metrics.status == HealthStatus.HEALTHY
assert metrics.success_rate == pytest.approx(1.0)
assert metrics.error_rate == pytest.approx(0.0)
def test_degraded_metrics(self):
tracker = _make_health_tracker(_degraded_summary())
monitor = PluginHealthMonitor(tracker, degraded_threshold=0.5, unhealthy_threshold=0.8)
metrics = monitor.get_plugin_health_metrics("plugin_b")
assert metrics.status == HealthStatus.DEGRADED
assert metrics.consecutive_failures == 3
def test_unhealthy_metrics(self):
tracker = _make_health_tracker(_unhealthy_summary())
monitor = PluginHealthMonitor(tracker, unhealthy_threshold=0.8)
metrics = monitor.get_plugin_health_metrics("plugin_c")
assert metrics.status == HealthStatus.UNHEALTHY
assert metrics.circuit_breaker_state == "open"
assert metrics.last_error is not None
def test_metrics_without_tracker(self):
monitor = PluginHealthMonitor(health_tracker=None)
metrics = monitor.get_plugin_health_metrics("plugin_d")
assert metrics.status == HealthStatus.UNKNOWN
assert metrics.plugin_id == "plugin_d"
def test_metrics_without_summary(self):
tracker = _make_health_tracker(None)
monitor = PluginHealthMonitor(tracker)
metrics = monitor.get_plugin_health_metrics("plugin_e")
assert metrics.status == HealthStatus.UNKNOWN
def test_last_successful_update_parsed(self):
summary = _healthy_summary()
summary["last_success_time"] = "2024-06-01T12:00:00"
tracker = _make_health_tracker(summary)
monitor = PluginHealthMonitor(tracker)
metrics = monitor.get_plugin_health_metrics("plugin_a")
assert metrics.last_successful_update is not None
assert isinstance(metrics.last_successful_update, datetime)
def test_invalid_last_success_time_handled(self):
summary = _healthy_summary()
summary["last_success_time"] = "not-a-date"
tracker = _make_health_tracker(summary)
monitor = PluginHealthMonitor(tracker)
# Should not raise
metrics = monitor.get_plugin_health_metrics("plugin_a")
assert metrics.last_successful_update is None
def test_total_successes_failures(self):
tracker = _make_health_tracker(_degraded_summary())
monitor = PluginHealthMonitor(tracker, degraded_threshold=0.5, unhealthy_threshold=0.8)
metrics = monitor.get_plugin_health_metrics("plugin_b")
assert metrics.total_failures == 6
assert metrics.total_successes == 4
# ---------------------------------------------------------------------------
# get_all_plugin_health
# ---------------------------------------------------------------------------
class TestGetAllPluginHealth:
def test_returns_empty_without_tracker(self):
monitor = PluginHealthMonitor(health_tracker=None)
result = monitor.get_all_plugin_health()
assert result == {}
def test_returns_metrics_for_each_plugin(self):
all_summaries = {
"plugin_a": _healthy_summary(),
"plugin_b": _degraded_summary(),
}
tracker = MagicMock()
tracker.get_all_health_summaries.return_value = all_summaries
tracker.get_health_summary.side_effect = lambda pid: all_summaries.get(pid)
monitor = PluginHealthMonitor(tracker, degraded_threshold=0.5, unhealthy_threshold=0.8)
result = monitor.get_all_plugin_health()
assert "plugin_a" in result
assert "plugin_b" in result
assert isinstance(result["plugin_a"], HealthMetrics)
def test_returns_empty_when_no_summaries(self):
tracker = _make_health_tracker(all_summaries={})
monitor = PluginHealthMonitor(tracker)
result = monitor.get_all_plugin_health()
assert result == {}
# ---------------------------------------------------------------------------
# _get_recovery_suggestions
# ---------------------------------------------------------------------------
class TestGetRecoverySuggestions:
def test_healthy_plugin_suggestion(self):
tracker = _make_health_tracker(_healthy_summary())
monitor = PluginHealthMonitor(tracker)
suggestions = monitor._get_recovery_suggestions("p", _healthy_summary(), HealthStatus.HEALTHY)
assert any("healthy" in s.lower() for s in suggestions)
def test_unhealthy_suggestions(self):
tracker = _make_health_tracker(_unhealthy_summary())
monitor = PluginHealthMonitor(tracker, unhealthy_threshold=0.8)
suggestions = monitor._get_recovery_suggestions("p", _unhealthy_summary(), HealthStatus.UNHEALTHY)
assert len(suggestions) > 0
assert any("unhealthy" in s.lower() for s in suggestions)
def test_open_circuit_breaker_suggestion(self):
summary = _unhealthy_summary()
summary["circuit_state"] = "open"
tracker = _make_health_tracker(summary)
monitor = PluginHealthMonitor(tracker, unhealthy_threshold=0.8)
suggestions = monitor._get_recovery_suggestions("p", summary, HealthStatus.UNHEALTHY)
assert any("circuit" in s.lower() for s in suggestions)
def test_timeout_error_suggestion(self):
summary = _degraded_summary()
summary["last_error"] = "connection timeout occurred"
tracker = _make_health_tracker(summary)
monitor = PluginHealthMonitor(tracker, degraded_threshold=0.5, unhealthy_threshold=0.8)
suggestions = monitor._get_recovery_suggestions("p", summary, HealthStatus.DEGRADED)
assert any("timeout" in s.lower() for s in suggestions)
def test_import_error_suggestion(self):
summary = _unhealthy_summary()
summary["last_error"] = "ImportError: missing module"
tracker = _make_health_tracker(summary)
monitor = PluginHealthMonitor(tracker, unhealthy_threshold=0.8)
suggestions = monitor._get_recovery_suggestions("p", summary, HealthStatus.UNHEALTHY)
assert any("dependencies" in s.lower() or "import" in s.lower() or "missing" in s.lower()
for s in suggestions)
def test_permission_error_suggestion(self):
summary = _unhealthy_summary()
summary["last_error"] = "permission denied to access resource"
tracker = _make_health_tracker(summary)
monitor = PluginHealthMonitor(tracker, unhealthy_threshold=0.8)
suggestions = monitor._get_recovery_suggestions("p", summary, HealthStatus.UNHEALTHY)
assert any("permission" in s.lower() for s in suggestions)
def test_degraded_suggestions_include_error_rate(self):
tracker = _make_health_tracker(_degraded_summary())
monitor = PluginHealthMonitor(tracker, degraded_threshold=0.5, unhealthy_threshold=0.8)
suggestions = monitor._get_recovery_suggestions("p", _degraded_summary(), HealthStatus.DEGRADED)
assert any("%" in s for s in suggestions)
# ---------------------------------------------------------------------------
# start / stop monitoring
# ---------------------------------------------------------------------------
class TestMonitorLifecycle:
def test_start_monitoring(self, monitor):
monitor.start_monitoring()
try:
assert monitor._monitor_thread is not None
assert monitor._monitor_thread.is_alive()
finally:
monitor.stop_monitoring()
def test_stop_monitoring(self, monitor):
monitor.start_monitoring()
monitor.stop_monitoring()
# Thread should no longer be alive
assert not monitor._monitor_thread.is_alive()
def test_double_start_no_duplicate_threads(self, monitor):
monitor.start_monitoring()
try:
thread1 = monitor._monitor_thread
monitor.start_monitoring() # should be idempotent
assert monitor._monitor_thread is thread1
finally:
monitor.stop_monitoring()
def test_register_health_check(self, monitor):
callback = MagicMock()
monitor.register_health_check(callback)
assert callback in monitor._health_check_callbacks

View File

@@ -0,0 +1,129 @@
"""
Tests for src/logo_downloader.py
Focuses on the pure/static methods that don't require network calls:
normalize_abbreviation, get_logo_filename_variations, get_logo_directory,
ensure_logo_directory, and the download_missing_logo function path
(with HTTP mocked).
"""
import os
import pytest
from pathlib import Path
from unittest.mock import patch, Mock, MagicMock
from src.logo_downloader import LogoDownloader
# ---------------------------------------------------------------------------
# normalize_abbreviation
# ---------------------------------------------------------------------------
class TestNormalizeAbbreviation:
def test_basic_lowercase(self):
result = LogoDownloader.normalize_abbreviation("lal")
assert result == "LAL"
def test_uppercases(self):
result = LogoDownloader.normalize_abbreviation("bos")
assert result == "BOS"
def test_ampersand_replaced(self):
result = LogoDownloader.normalize_abbreviation("TA&M")
assert "&" not in result
assert "AND" in result
def test_forward_slash_replaced(self):
result = LogoDownloader.normalize_abbreviation("A/B")
assert "/" not in result
def test_empty_returns_empty(self):
result = LogoDownloader.normalize_abbreviation("")
assert result == ""
# ---------------------------------------------------------------------------
# get_logo_filename_variations
# ---------------------------------------------------------------------------
class TestGetLogoFilenameVariations:
def test_returns_list(self):
result = LogoDownloader.get_logo_filename_variations("LAL")
assert isinstance(result, list)
assert len(result) > 0
def test_includes_png(self):
result = LogoDownloader.get_logo_filename_variations("KC")
filenames = " ".join(result)
assert ".png" in filenames
def test_includes_original(self):
result = LogoDownloader.get_logo_filename_variations("LAL")
assert any("LAL" in f for f in result)
def test_ampersand_variation(self):
result = LogoDownloader.get_logo_filename_variations("TA&M")
# Should produce at least the normalized version
assert len(result) > 0
def test_empty_string_no_crash(self):
result = LogoDownloader.get_logo_filename_variations("")
assert isinstance(result, list)
# ---------------------------------------------------------------------------
# get_logo_directory
# ---------------------------------------------------------------------------
class TestGetLogoDirectory:
def test_known_sport_returns_string(self):
downloader = LogoDownloader()
result = downloader.get_logo_directory("nfl")
assert isinstance(result, str)
assert len(result) > 0
def test_known_sport_nba(self):
downloader = LogoDownloader()
result = downloader.get_logo_directory("nba")
assert "nba" in result.lower() or "sports" in result.lower()
def test_unknown_sport_returns_string(self):
downloader = LogoDownloader()
result = downloader.get_logo_directory("unknown_sport_xyz")
assert isinstance(result, str)
# ---------------------------------------------------------------------------
# ensure_logo_directory
# ---------------------------------------------------------------------------
class TestEnsureLogoDirectory:
def test_creates_writable_directory(self, tmp_path):
downloader = LogoDownloader()
test_dir = str(tmp_path / "logos" / "nfl")
result = downloader.ensure_logo_directory(test_dir)
assert result is True
assert Path(test_dir).is_dir()
def test_existing_writable_directory(self, tmp_path):
downloader = LogoDownloader()
test_dir = str(tmp_path)
result = downloader.ensure_logo_directory(test_dir)
assert result is True
def test_returns_false_when_write_test_fails(self, tmp_path):
"""Simulate a directory that exists but raises PermissionError on write."""
downloader = LogoDownloader()
test_dir = str(tmp_path / "logos")
import builtins
original_open = builtins.open
def mock_open(path, *args, **kwargs):
if ".write_test" in str(path):
raise PermissionError("no write access")
return original_open(path, *args, **kwargs)
with patch("builtins.open", side_effect=mock_open):
result = downloader.ensure_logo_directory(test_dir)
assert result is False

317
test/test_scroll_helper.py Normal file
View File

@@ -0,0 +1,317 @@
"""
Tests for src/common/scroll_helper.py
Covers ScrollHelper: create_scrolling_image, update_scroll_position,
get_visible_portion, calculate_dynamic_duration, set_* methods,
reset_scroll, clear_cache, get_scroll_info.
"""
import pytest
import time
from unittest.mock import patch
from PIL import Image
from src.common.scroll_helper import ScrollHelper
DISPLAY_W = 64
DISPLAY_H = 32
@pytest.fixture
def helper():
return ScrollHelper(display_width=DISPLAY_W, display_height=DISPLAY_H)
def _make_image(width: int = 64, height: int = 32, color=(255, 0, 0)) -> Image.Image:
img = Image.new("RGB", (width, height), color)
return img
# ---------------------------------------------------------------------------
# __init__ / initial state
# ---------------------------------------------------------------------------
class TestScrollHelperInit:
def test_initial_scroll_position(self, helper):
assert helper.scroll_position == 0.0
def test_initial_scroll_complete_false(self, helper):
assert helper.scroll_complete is False
def test_display_dimensions(self, helper):
assert helper.display_width == DISPLAY_W
assert helper.display_height == DISPLAY_H
# ---------------------------------------------------------------------------
# create_scrolling_image
# ---------------------------------------------------------------------------
class TestCreateScrollingImage:
def test_empty_content_returns_blank_image(self, helper):
result = helper.create_scrolling_image([])
assert isinstance(result, Image.Image)
assert helper.total_scroll_width == 0
def test_single_item_creates_image(self, helper):
img = _make_image(width=100)
result = helper.create_scrolling_image([img])
assert isinstance(result, Image.Image)
assert result.width > DISPLAY_W # includes leading gap
def test_multiple_items_wider_image(self, helper):
items = [_make_image(width=50), _make_image(width=50)]
result = helper.create_scrolling_image(items)
# Should be wider than two items alone
assert result.width > 100
def test_scroll_position_reset(self, helper):
helper.scroll_position = 500.0
helper.create_scrolling_image([_make_image()])
assert helper.scroll_position == 0.0
def test_cached_array_set(self, helper):
helper.create_scrolling_image([_make_image()])
assert helper.cached_array is not None
def test_scroll_complete_reset(self, helper):
helper.scroll_complete = True
helper.create_scrolling_image([_make_image()])
assert helper.scroll_complete is False
def test_total_scroll_width_matches_image(self, helper):
img = _make_image(width=200)
result = helper.create_scrolling_image([img])
assert helper.total_scroll_width == result.width
# ---------------------------------------------------------------------------
# set_scrolling_image
# ---------------------------------------------------------------------------
class TestSetScrollingImage:
def test_sets_cached_image(self, helper):
img = _make_image(width=200)
helper.set_scrolling_image(img)
assert helper.cached_image is img
def test_sets_cached_array(self, helper):
img = _make_image(width=200)
helper.set_scrolling_image(img)
assert helper.cached_array is not None
def test_scroll_width_matches_image(self, helper):
img = _make_image(width=300)
helper.set_scrolling_image(img)
assert helper.total_scroll_width == 300
def test_none_clears_cache(self, helper):
helper.set_scrolling_image(_make_image())
helper.set_scrolling_image(None)
assert helper.cached_image is None
# ---------------------------------------------------------------------------
# update_scroll_position (time-based mode)
# ---------------------------------------------------------------------------
class TestUpdateScrollPosition:
def test_position_advances_over_time(self, helper):
helper.create_scrolling_image([_make_image(width=200)])
helper.scroll_speed = 100.0 # 100 px/s
helper.last_update_time = time.time() - 0.1 # pretend 100ms elapsed
initial = helper.scroll_position
helper.update_scroll_position()
assert helper.scroll_position > initial
def test_no_advance_without_image(self, helper):
helper.update_scroll_position() # no image, should not crash
assert helper.scroll_position == 0.0
def test_zero_width_content_stays_zero(self, helper):
helper.create_scrolling_image([]) # empty → width 0
helper.update_scroll_position()
assert helper.scroll_position == 0.0
def test_scroll_complete_clamped(self, helper):
helper.create_scrolling_image([_make_image(width=100)])
# Force position past the end
helper.scroll_position = helper.total_scroll_width + 50
helper.total_distance_scrolled = helper.total_scroll_width + 50
helper.update_scroll_position()
assert helper.scroll_complete is True
assert helper.scroll_position <= helper.total_scroll_width
# ---------------------------------------------------------------------------
# get_visible_portion
# ---------------------------------------------------------------------------
class TestGetVisiblePortion:
def test_returns_none_without_image(self, helper):
assert helper.get_visible_portion() is None
def test_returns_image_sized_to_display(self, helper):
helper.create_scrolling_image([_make_image(width=200)])
visible = helper.get_visible_portion()
assert visible is not None
assert visible.width == DISPLAY_W
assert visible.height == DISPLAY_H
def test_different_positions_give_different_images(self, helper):
helper.create_scrolling_image([_make_image(width=300)])
img1 = helper.get_visible_portion()
helper.scroll_position = 50
img2 = helper.get_visible_portion()
# Images should differ (colour from scrolled content)
# Just verify both are valid PIL images with correct size
assert img1.width == img2.width == DISPLAY_W
# ---------------------------------------------------------------------------
# reset_scroll / clear_cache
# ---------------------------------------------------------------------------
class TestResetAndClear:
def test_reset_restores_position(self, helper):
helper.create_scrolling_image([_make_image(width=200)])
helper.scroll_position = 100.0
helper.reset_scroll()
assert helper.scroll_position == 0.0
def test_reset_clears_complete_flag(self, helper):
helper.scroll_complete = True
helper.reset_scroll()
assert helper.scroll_complete is False
def test_reset_alias(self, helper):
helper.scroll_position = 50.0
helper.reset()
assert helper.scroll_position == 0.0
def test_clear_cache(self, helper):
helper.create_scrolling_image([_make_image()])
helper.clear_cache()
assert helper.cached_image is None
assert helper.cached_array is None
assert helper.total_scroll_width == 0
# ---------------------------------------------------------------------------
# calculate_dynamic_duration
# ---------------------------------------------------------------------------
class TestCalculateDynamicDuration:
def test_returns_min_when_disabled(self, helper):
helper.dynamic_duration_enabled = False
helper.min_duration = 30
result = helper.calculate_dynamic_duration()
assert result == 30
def test_returns_min_when_no_content(self, helper):
helper.total_scroll_width = 0
helper.min_duration = 30
result = helper.calculate_dynamic_duration()
assert result == 30
def test_respects_min_duration(self, helper):
helper.create_scrolling_image([_make_image(width=50)])
helper.min_duration = 60
helper.max_duration = 300
helper.scroll_speed = 500.0 # very fast → very short time
result = helper.calculate_dynamic_duration()
assert result >= 60
def test_respects_max_duration(self, helper):
helper.create_scrolling_image([_make_image(width=5000)])
helper.min_duration = 10
helper.max_duration = 60
helper.scroll_speed = 1.0 # very slow → very long time
result = helper.calculate_dynamic_duration()
assert result <= 60
def test_time_based_calculation(self, helper):
helper.create_scrolling_image([_make_image(width=200)])
helper.scroll_speed = 100.0
helper.min_duration = 1
helper.max_duration = 600
helper.frame_based_scrolling = False
result = helper.calculate_dynamic_duration()
assert isinstance(result, int)
assert result > 0
# ---------------------------------------------------------------------------
# set_* configuration methods
# ---------------------------------------------------------------------------
class TestSetMethods:
def test_set_scroll_speed_time_based(self, helper):
helper.frame_based_scrolling = False
helper.set_scroll_speed(50.0)
assert helper.scroll_speed == 50.0
def test_set_scroll_speed_clamped_low(self, helper):
helper.frame_based_scrolling = False
helper.set_scroll_speed(0.0)
assert helper.scroll_speed >= 1.0
def test_set_scroll_speed_clamped_high(self, helper):
helper.frame_based_scrolling = False
helper.set_scroll_speed(10000.0)
assert helper.scroll_speed <= 500.0
def test_set_scroll_delay(self, helper):
helper.set_scroll_delay(0.05)
assert helper.scroll_delay == 0.05
def test_set_scroll_delay_clamped(self, helper):
helper.set_scroll_delay(0.0001)
assert helper.scroll_delay >= 0.001
def test_set_target_fps(self, helper):
helper.set_target_fps(60.0)
assert helper.target_fps == 60.0
def test_set_target_fps_clamped(self, helper):
helper.set_target_fps(1000.0)
assert helper.target_fps <= 200.0
def test_set_sub_pixel_scrolling(self, helper):
helper.set_sub_pixel_scrolling(True)
assert helper.sub_pixel_scrolling is True
helper.set_sub_pixel_scrolling(False)
assert helper.sub_pixel_scrolling is False
def test_set_frame_based_scrolling(self, helper):
helper.set_frame_based_scrolling(True)
assert helper.frame_based_scrolling is True
def test_set_dynamic_duration_settings(self, helper):
helper.set_dynamic_duration_settings(enabled=True, min_duration=20, max_duration=120, buffer=0.2)
assert helper.dynamic_duration_enabled is True
assert helper.min_duration == 20
assert helper.max_duration == 120
assert helper.duration_buffer == pytest.approx(0.2)
# ---------------------------------------------------------------------------
# get_scroll_info
# ---------------------------------------------------------------------------
class TestGetScrollInfo:
def test_returns_dict(self, helper):
info = helper.get_scroll_info()
assert isinstance(info, dict)
def test_required_keys(self, helper):
info = helper.get_scroll_info()
for key in ("scroll_position", "total_distance_scrolled", "scroll_speed",
"scroll_complete", "dynamic_duration"):
assert key in info
def test_scroll_position_reflected(self, helper):
helper.scroll_position = 42.0
info = helper.get_scroll_info()
assert info["scroll_position"] == 42.0

View File

@@ -58,19 +58,15 @@ class TestGitInfoCache(unittest.TestCase):
(self.plugin_path / ".git" / "HEAD").write_text("ref: refs/heads/main\n")
def _fake_subprocess_run(self, *args, **kwargs):
# Return different dummy values depending on which git subcommand
# was invoked so the code paths that parse output all succeed.
# _get_local_git_info now reads branch and remote_url directly from
# .git/HEAD and .git/config (no subprocess) and uses a single
# ``git log --format=%H%n%cI`` call that returns SHA on line 1 and
# ISO date on line 2. Adjust the fake accordingly.
cmd = args[0]
result = MagicMock()
result.returncode = 0
if "rev-parse" in cmd and "HEAD" in cmd and "--abbrev-ref" not in cmd:
result.stdout = "abcdef1234567890\n"
elif "--abbrev-ref" in cmd:
result.stdout = "main\n"
elif "config" in cmd:
result.stdout = "https://example.com/repo.git\n"
elif "log" in cmd:
result.stdout = "2026-04-08T12:00:00+00:00\n"
if "log" in cmd:
result.stdout = "abcdef1234567890\n2026-04-08T12:00:00+00:00\n"
else:
result.stdout = ""
return result
@@ -84,7 +80,8 @@ class TestGitInfoCache(unittest.TestCase):
self.assertIsNotNone(first)
self.assertEqual(first["short_sha"], "abcdef1")
calls_after_first = mock_run.call_count
self.assertEqual(calls_after_first, 4)
# Production code now uses a single ``git log`` call.
self.assertEqual(calls_after_first, 1)
# Second call with unchanged HEAD: zero new subprocess calls.
second = self.sm._get_local_git_info(self.plugin_path)
@@ -105,7 +102,8 @@ class TestGitInfoCache(unittest.TestCase):
os.utime(head, (new_time, new_time))
self.sm._get_local_git_info(self.plugin_path)
self.assertEqual(mock_run.call_count, calls_after_first + 4)
# One new ``git log`` call after cache invalidation.
self.assertEqual(mock_run.call_count, calls_after_first + 1)
def test_no_git_directory_returns_none(self):
non_git = self.plugins_dir / "no_git"
@@ -192,14 +190,11 @@ class TestGitInfoCache(unittest.TestCase):
result = MagicMock()
result.returncode = 0
cmd = args[0]
if "rev-parse" in cmd and "--abbrev-ref" not in cmd:
result.stdout = branch_file.read_text().strip() + "\n"
elif "--abbrev-ref" in cmd:
result.stdout = "main\n"
elif "config" in cmd:
result.stdout = "https://example.com/repo.git\n"
elif "log" in cmd:
result.stdout = "2026-04-08T12:00:00+00:00\n"
# Production code now uses a single ``git log --format=%H%n%cI``.
# Branch and remote_url are read directly from .git/HEAD/.git/config.
if "log" in cmd:
sha = branch_file.read_text().strip()
result.stdout = f"{sha}\n2026-04-08T12:00:00+00:00\n"
else:
result.stdout = ""
return result

329
test/test_utils.py Normal file
View File

@@ -0,0 +1,329 @@
"""
Tests for src/common/utils.py
Covers all pure utility functions: normalize_team_abbreviation, format_time,
format_date, get_timezone, validate_dimensions, parse_team_abbreviation,
format_score, format_period, is_live_game, is_final_game, is_upcoming_game,
sanitize_filename, truncate_text, parse_boolean.
"""
import pytest
from datetime import datetime, timezone
import pytz
from src.common.utils import (
normalize_team_abbreviation,
format_time,
format_date,
get_timezone,
validate_dimensions,
parse_team_abbreviation,
format_score,
format_period,
is_live_game,
is_final_game,
is_upcoming_game,
sanitize_filename,
truncate_text,
parse_boolean,
)
# ---------------------------------------------------------------------------
# normalize_team_abbreviation
# ---------------------------------------------------------------------------
class TestNormalizeTeamAbbreviation:
def test_basic_uppercase(self):
assert normalize_team_abbreviation("lal") == "LAL"
def test_strips_spaces(self):
assert normalize_team_abbreviation(" KC ") == "KC"
def test_replaces_ampersand(self):
assert normalize_team_abbreviation("TA&M") == "TAANDM"
def test_removes_internal_spaces(self):
assert normalize_team_abbreviation("A B") == "AB"
def test_removes_hyphens(self):
assert normalize_team_abbreviation("A-B") == "AB"
def test_empty_string_returns_empty(self):
assert normalize_team_abbreviation("") == ""
def test_none_returns_empty(self):
assert normalize_team_abbreviation(None) == ""
# ---------------------------------------------------------------------------
# format_time / format_date
# ---------------------------------------------------------------------------
class TestFormatTime:
def _utc_dt(self, hour=20, minute=30):
return datetime(2024, 1, 15, hour, minute, 0, tzinfo=timezone.utc)
def test_formats_utc_to_utc(self):
dt = self._utc_dt(20, 30)
result = format_time(dt, timezone_str="UTC")
# 20:30 UTC → "8:30PM" (leading zero stripped)
assert "8:30PM" in result or "8:30 PM" in result or result != ""
def test_naive_datetime_treated_as_utc(self):
dt = datetime(2024, 1, 15, 12, 0, 0) # naive
result = format_time(dt, timezone_str="UTC")
assert result != ""
def test_invalid_timezone_returns_empty(self):
dt = self._utc_dt()
result = format_time(dt, timezone_str="Invalid/TZ")
assert result == ""
def test_eastern_timezone(self):
dt = self._utc_dt(20, 0) # 8 PM UTC = 3 PM ET
result = format_time(dt, timezone_str="America/New_York")
assert result != ""
class TestFormatDate:
def test_formats_date(self):
dt = datetime(2024, 6, 15, 18, 0, 0, tzinfo=timezone.utc)
result = format_date(dt, timezone_str="UTC")
assert "June" in result or "15" in result
def test_naive_datetime(self):
dt = datetime(2024, 3, 10, 12, 0, 0)
result = format_date(dt, timezone_str="UTC")
assert result != ""
def test_invalid_timezone_returns_empty(self):
dt = datetime(2024, 6, 15, 18, 0, 0, tzinfo=timezone.utc)
result = format_date(dt, timezone_str="BadZone/Here")
assert result == ""
# ---------------------------------------------------------------------------
# get_timezone
# ---------------------------------------------------------------------------
class TestGetTimezone:
def test_valid_timezone(self):
tz = get_timezone("America/New_York")
assert tz is not None
def test_utc(self):
tz = get_timezone("UTC")
assert tz is pytz.utc or str(tz) == "UTC"
def test_invalid_returns_utc(self):
tz = get_timezone("Not/ATimezone")
assert tz is pytz.utc
# ---------------------------------------------------------------------------
# validate_dimensions
# ---------------------------------------------------------------------------
class TestValidateDimensions:
def test_valid(self):
assert validate_dimensions(64, 32) is True
def test_zero_width(self):
assert validate_dimensions(0, 32) is False
def test_zero_height(self):
assert validate_dimensions(64, 0) is False
def test_negative(self):
assert validate_dimensions(-1, 32) is False
def test_too_large(self):
assert validate_dimensions(1001, 32) is False
def test_max_valid(self):
assert validate_dimensions(1000, 1000) is True
def test_non_integer(self):
assert validate_dimensions("64", 32) is False # type: ignore[arg-type]
# ---------------------------------------------------------------------------
# parse_team_abbreviation
# ---------------------------------------------------------------------------
class TestParseTeamAbbreviation:
def test_empty_string(self):
assert parse_team_abbreviation("") == ""
def test_none_returns_empty(self):
assert parse_team_abbreviation(None) == ""
def test_extracts_uppercase(self):
result = parse_team_abbreviation("LAL")
assert result == "LAL"
def test_fallback_first_three(self):
# text without recognisable 2-4 char uppercase block
result = parse_team_abbreviation("ab")
assert len(result) <= 3
# ---------------------------------------------------------------------------
# format_score
# ---------------------------------------------------------------------------
class TestFormatScore:
def test_format_score(self):
assert format_score(14, 7) == "7-14"
def test_format_score_strings(self):
assert format_score("21", "14") == "14-21"
def test_zero_zero(self):
assert format_score(0, 0) == "0-0"
# ---------------------------------------------------------------------------
# format_period
# ---------------------------------------------------------------------------
class TestFormatPeriod:
def test_basketball_q1(self):
assert format_period(1, "basketball") == "Q1"
def test_basketball_q4(self):
assert format_period(4, "basketball") == "Q4"
def test_basketball_ot1(self):
assert format_period(5, "basketball") == "OT1"
def test_basketball_ot2(self):
assert format_period(6, "basketball") == "OT2"
def test_football_q1(self):
assert format_period(1, "football") == "Q1"
def test_football_ot(self):
assert format_period(5, "football") == "OT1"
def test_hockey_p1(self):
assert format_period(1, "hockey") == "P1"
def test_hockey_p3(self):
assert format_period(3, "hockey") == "P3"
def test_hockey_ot(self):
assert format_period(4, "hockey") == "OT1"
def test_baseball_inning(self):
assert format_period(7, "baseball") == "INN 7"
def test_unknown_sport(self):
result = format_period(2, "unknown")
assert "2" in result
# ---------------------------------------------------------------------------
# is_live_game / is_final_game / is_upcoming_game
# ---------------------------------------------------------------------------
class TestGameStatusHelpers:
def test_is_live_game_true(self):
assert is_live_game("In Progress") is True
assert is_live_game("halftime") is True
assert is_live_game("overtime") is True
def test_is_live_game_false(self):
assert is_live_game("Final") is False
assert is_live_game("Scheduled") is False
def test_is_final_game_true(self):
assert is_final_game("Final") is True
assert is_final_game("COMPLETED") is True
def test_is_final_game_false(self):
assert is_final_game("In Progress") is False
def test_is_upcoming_game_true(self):
assert is_upcoming_game("Scheduled") is True
assert is_upcoming_game("upcoming") is True
def test_is_upcoming_game_false(self):
assert is_upcoming_game("Final") is False
assert is_upcoming_game("In Progress") is False
# ---------------------------------------------------------------------------
# sanitize_filename
# ---------------------------------------------------------------------------
class TestSanitizeFilename:
def test_removes_invalid_chars(self):
result = sanitize_filename('file<>:"/\\|?*.txt')
assert "<" not in result
assert ">" not in result
assert ":" not in result
def test_collapses_underscores(self):
result = sanitize_filename("file___name")
assert "__" not in result
def test_strips_leading_trailing(self):
result = sanitize_filename("_file_")
assert not result.startswith("_")
assert not result.endswith("_")
def test_normal_filename_unchanged(self):
result = sanitize_filename("my_logo")
assert result == "my_logo"
# ---------------------------------------------------------------------------
# truncate_text
# ---------------------------------------------------------------------------
class TestTruncateText:
def test_no_truncation_needed(self):
assert truncate_text("hello", 10) == "hello"
def test_truncation_adds_suffix(self):
result = truncate_text("hello world", 8)
assert result.endswith("...")
assert len(result) == 8
def test_exact_length(self):
assert truncate_text("hello", 5) == "hello"
def test_custom_suffix(self):
result = truncate_text("hello world", 8, suffix="~")
assert result.endswith("~")
# ---------------------------------------------------------------------------
# parse_boolean
# ---------------------------------------------------------------------------
class TestParseBoolean:
def test_true_bool(self):
assert parse_boolean(True) is True
def test_false_bool(self):
assert parse_boolean(False) is False
def test_int_1(self):
assert parse_boolean(1) is True
def test_int_0(self):
assert parse_boolean(0) is False
def test_string_true(self):
for val in ("true", "True", "TRUE", "1", "yes", "on", "enabled"):
assert parse_boolean(val) is True, f"Expected True for {val!r}"
def test_string_false(self):
for val in ("false", "False", "0", "no", "off", "disabled"):
assert parse_boolean(val) is False, f"Expected False for {val!r}"
def test_none_returns_false(self):
assert parse_boolean(None) is False # type: ignore[arg-type]

310
test/test_vegas_config.py Normal file
View File

@@ -0,0 +1,310 @@
"""
Tests for src/vegas_mode/config.py
Covers VegasModeConfig: from_config, to_dict, get_frame_interval,
is_plugin_included, get_ordered_plugins, validate, update.
"""
import pytest
from src.vegas_mode.config import VegasModeConfig
# ---------------------------------------------------------------------------
# Default construction
# ---------------------------------------------------------------------------
class TestVegasModeConfigDefaults:
def test_default_disabled(self):
cfg = VegasModeConfig()
assert cfg.enabled is False
def test_default_scroll_speed(self):
cfg = VegasModeConfig()
assert cfg.scroll_speed == 50.0
def test_default_separator_width(self):
cfg = VegasModeConfig()
assert cfg.separator_width == 32
def test_default_target_fps(self):
cfg = VegasModeConfig()
assert cfg.target_fps == 125
def test_default_plugin_order_empty(self):
cfg = VegasModeConfig()
assert cfg.plugin_order == []
def test_default_excluded_plugins_empty(self):
cfg = VegasModeConfig()
assert len(cfg.excluded_plugins) == 0
# ---------------------------------------------------------------------------
# from_config
# ---------------------------------------------------------------------------
class TestFromConfig:
def _cfg(self, **kwargs) -> dict:
return {"display": {"vegas_scroll": kwargs}}
def test_enabled_flag(self):
cfg = VegasModeConfig.from_config(self._cfg(enabled=True))
assert cfg.enabled is True
def test_scroll_speed(self):
cfg = VegasModeConfig.from_config(self._cfg(scroll_speed=80.0))
assert cfg.scroll_speed == 80.0
def test_separator_width(self):
cfg = VegasModeConfig.from_config(self._cfg(separator_width=16))
assert cfg.separator_width == 16
def test_plugin_order(self):
cfg = VegasModeConfig.from_config(self._cfg(plugin_order=["a", "b", "c"]))
assert cfg.plugin_order == ["a", "b", "c"]
def test_excluded_plugins(self):
cfg = VegasModeConfig.from_config(self._cfg(excluded_plugins=["x", "y"]))
assert "x" in cfg.excluded_plugins
assert "y" in cfg.excluded_plugins
def test_target_fps(self):
cfg = VegasModeConfig.from_config(self._cfg(target_fps=60))
assert cfg.target_fps == 60
def test_buffer_ahead(self):
cfg = VegasModeConfig.from_config(self._cfg(buffer_ahead=3))
assert cfg.buffer_ahead == 3
def test_min_max_cycle_duration(self):
cfg = VegasModeConfig.from_config(self._cfg(min_cycle_duration=30, max_cycle_duration=120))
assert cfg.min_cycle_duration == 30
assert cfg.max_cycle_duration == 120
def test_defaults_when_missing(self):
cfg = VegasModeConfig.from_config({})
assert cfg.enabled is False
assert cfg.scroll_speed == 50.0
def test_frame_based_scrolling(self):
cfg = VegasModeConfig.from_config(self._cfg(frame_based_scrolling=False))
assert cfg.frame_based_scrolling is False
# ---------------------------------------------------------------------------
# to_dict
# ---------------------------------------------------------------------------
class TestToDict:
def test_roundtrip(self):
original = VegasModeConfig(
enabled=True,
scroll_speed=75.0,
separator_width=24,
plugin_order=["a", "b"],
excluded_plugins={"z"},
target_fps=100,
)
d = original.to_dict()
assert d["enabled"] is True
assert d["scroll_speed"] == 75.0
assert d["separator_width"] == 24
assert d["plugin_order"] == ["a", "b"]
assert "z" in d["excluded_plugins"]
assert d["target_fps"] == 100
def test_excluded_plugins_is_list(self):
cfg = VegasModeConfig(excluded_plugins={"x"})
d = cfg.to_dict()
assert isinstance(d["excluded_plugins"], list)
def test_all_keys_present(self):
d = VegasModeConfig().to_dict()
for key in ("enabled", "scroll_speed", "separator_width", "plugin_order",
"excluded_plugins", "target_fps", "buffer_ahead",
"frame_based_scrolling", "scroll_delay",
"dynamic_duration_enabled", "min_cycle_duration", "max_cycle_duration"):
assert key in d
# ---------------------------------------------------------------------------
# get_frame_interval
# ---------------------------------------------------------------------------
class TestGetFrameInterval:
def test_125fps(self):
cfg = VegasModeConfig(target_fps=125)
assert abs(cfg.get_frame_interval() - 1.0 / 125) < 1e-9
def test_60fps(self):
cfg = VegasModeConfig(target_fps=60)
assert abs(cfg.get_frame_interval() - 1.0 / 60) < 1e-6
def test_zero_fps_guarded(self):
cfg = VegasModeConfig(target_fps=0)
# Should not raise ZeroDivisionError (max(1, fps) guard)
result = cfg.get_frame_interval()
assert result == 1.0
# ---------------------------------------------------------------------------
# is_plugin_included
# ---------------------------------------------------------------------------
class TestIsPluginIncluded:
def test_not_excluded_is_included(self):
cfg = VegasModeConfig(excluded_plugins={"bad_plugin"})
assert cfg.is_plugin_included("good_plugin") is True
def test_excluded_plugin_not_included(self):
cfg = VegasModeConfig(excluded_plugins={"bad_plugin"})
assert cfg.is_plugin_included("bad_plugin") is False
def test_empty_exclusions_all_included(self):
cfg = VegasModeConfig()
assert cfg.is_plugin_included("anything") is True
# ---------------------------------------------------------------------------
# get_ordered_plugins
# ---------------------------------------------------------------------------
class TestGetOrderedPlugins:
def test_natural_order_when_no_order_configured(self):
cfg = VegasModeConfig()
available = ["a", "b", "c"]
result = cfg.get_ordered_plugins(available)
assert result == ["a", "b", "c"]
def test_explicit_order_followed(self):
cfg = VegasModeConfig(plugin_order=["c", "a", "b"])
available = ["a", "b", "c"]
result = cfg.get_ordered_plugins(available)
assert result == ["c", "a", "b"]
def test_unavailable_plugins_skipped(self):
cfg = VegasModeConfig(plugin_order=["c", "x", "a"])
available = ["a", "b", "c"]
result = cfg.get_ordered_plugins(available)
assert "x" not in result
assert result[:2] == ["c", "a"]
def test_excluded_plugins_removed(self):
cfg = VegasModeConfig(excluded_plugins={"b"})
available = ["a", "b", "c"]
result = cfg.get_ordered_plugins(available)
assert "b" not in result
def test_unordered_available_appended(self):
cfg = VegasModeConfig(plugin_order=["a"])
available = ["a", "b", "c"]
result = cfg.get_ordered_plugins(available)
assert result[0] == "a"
assert "b" in result
assert "c" in result
def test_empty_available(self):
cfg = VegasModeConfig(plugin_order=["a"])
result = cfg.get_ordered_plugins([])
assert result == []
# ---------------------------------------------------------------------------
# validate
# ---------------------------------------------------------------------------
class TestValidate:
def test_valid_config_no_errors(self):
cfg = VegasModeConfig()
errors = cfg.validate()
assert errors == []
def test_scroll_speed_too_low(self):
cfg = VegasModeConfig(scroll_speed=0.5)
errors = cfg.validate()
assert any("scroll_speed" in e for e in errors)
def test_scroll_speed_too_high(self):
cfg = VegasModeConfig(scroll_speed=300.0)
errors = cfg.validate()
assert any("scroll_speed" in e for e in errors)
def test_separator_width_negative(self):
cfg = VegasModeConfig(separator_width=-1)
errors = cfg.validate()
assert any("separator_width" in e for e in errors)
def test_separator_width_too_large(self):
cfg = VegasModeConfig(separator_width=200)
errors = cfg.validate()
assert any("separator_width" in e for e in errors)
def test_target_fps_too_low(self):
cfg = VegasModeConfig(target_fps=10)
errors = cfg.validate()
assert any("target_fps" in e for e in errors)
def test_target_fps_too_high(self):
cfg = VegasModeConfig(target_fps=300)
errors = cfg.validate()
assert any("target_fps" in e for e in errors)
def test_buffer_ahead_too_low(self):
cfg = VegasModeConfig(buffer_ahead=0)
errors = cfg.validate()
assert any("buffer_ahead" in e for e in errors)
def test_buffer_ahead_too_high(self):
cfg = VegasModeConfig(buffer_ahead=10)
errors = cfg.validate()
assert any("buffer_ahead" in e for e in errors)
def test_multiple_errors_returned(self):
cfg = VegasModeConfig(scroll_speed=0.1, target_fps=5)
errors = cfg.validate()
assert len(errors) >= 2
# ---------------------------------------------------------------------------
# update
# ---------------------------------------------------------------------------
class TestUpdate:
def _wrap(self, **kwargs) -> dict:
return {"display": {"vegas_scroll": kwargs}}
def test_update_enabled(self):
cfg = VegasModeConfig(enabled=False)
cfg.update(self._wrap(enabled=True))
assert cfg.enabled is True
def test_update_scroll_speed(self):
cfg = VegasModeConfig(scroll_speed=50.0)
cfg.update(self._wrap(scroll_speed=90.0))
assert cfg.scroll_speed == 90.0
def test_update_separator_width(self):
cfg = VegasModeConfig(separator_width=32)
cfg.update(self._wrap(separator_width=8))
assert cfg.separator_width == 8
def test_update_plugin_order(self):
cfg = VegasModeConfig(plugin_order=[])
cfg.update(self._wrap(plugin_order=["x", "y"]))
assert cfg.plugin_order == ["x", "y"]
def test_update_excluded_plugins(self):
cfg = VegasModeConfig()
cfg.update(self._wrap(excluded_plugins=["skip_me"]))
assert "skip_me" in cfg.excluded_plugins
def test_update_ignores_missing_keys(self):
cfg = VegasModeConfig(scroll_speed=50.0)
cfg.update(self._wrap(target_fps=80)) # only fps, not speed
assert cfg.scroll_speed == 50.0
assert cfg.target_fps == 80
def test_empty_update_no_change(self):
cfg = VegasModeConfig(scroll_speed=50.0)
cfg.update({})
assert cfg.scroll_speed == 50.0

View File

@@ -617,7 +617,8 @@ class TestDottedKeyNormalization:
'leagues': {'eng.1': {'enabled': True, 'favorite_teams': []}},
}
schema_mgr.merge_with_defaults.side_effect = lambda config, defaults: {**defaults, **config}
schema_mgr.validate_config_against_schema.return_value = []
# Must be a (bool, list) tuple: the endpoint does is_valid, errors = validate_config_against_schema(...)
schema_mgr.validate_config_against_schema.return_value = (True, [])
api_v3.schema_manager = schema_mgr
request_data = {
@@ -679,7 +680,7 @@ class TestDottedKeyNormalization:
'leagues': {'eng.1': {'favorite_teams': []}},
}
schema_mgr.merge_with_defaults.side_effect = lambda config, defaults: {**defaults, **config}
schema_mgr.validate_config_against_schema.return_value = []
schema_mgr.validate_config_against_schema.return_value = (True, [])
api_v3.schema_manager = schema_mgr
request_data = {

View File

@@ -224,20 +224,14 @@ class TestStateReconciliation(unittest.TestCase):
with open(manifest_path, 'w') as f:
json.dump({"version": "1.0.0", "name": "Plugin 1"}, f)
# Mock save_config to track calls
saved_configs = []
def save_config(config):
saved_configs.append(config)
self.config_manager.save_config = save_config
# Run reconciliation
result = self.reconciler.reconcile_state()
# Verify fix was attempted
# config.json is the source of truth for enabled state. The fix syncs
# the state manager to match config (config says True → state set True),
# rather than overwriting the config with the stale state value.
self.assertEqual(len(result.inconsistencies_fixed), 1)
self.assertEqual(len(saved_configs), 1)
self.assertEqual(saved_configs[0]["plugin1"]["enabled"], False)
self.state_manager.set_plugin_enabled.assert_called_once_with("plugin1", True)
def test_multiple_inconsistencies(self):
"""Test reconciliation with multiple inconsistencies."""

View File

@@ -2,8 +2,11 @@ from flask import Flask, request, redirect, url_for, jsonify, Response, send_fro
import json
import logging
import os
import queue
import shutil
import sys
import subprocess
import threading
import time
from pathlib import Path
from datetime import datetime, timedelta
@@ -22,6 +25,9 @@ from src.plugin_system.state_manager import PluginStateManager
from src.plugin_system.operation_history import OperationHistory
from src.plugin_system.health_monitor import PluginHealthMonitor
_JOURNALCTL = shutil.which('journalctl')
_SYSTEMCTL = shutil.which('systemctl')
# Create Flask app
app = Flask(__name__)
app.secret_key = os.urandom(24)
@@ -204,24 +210,12 @@ def serve_plugin_asset(plugin_id, filename):
# Use send_from_directory to serve the file
return send_from_directory(str(assets_dir), filename, mimetype=content_type)
except Exception as e:
# Log the exception with full traceback server-side
import traceback
except Exception:
app.logger.exception('Error serving plugin asset file')
# Return generic error message to client (avoid leaking internal details)
# Only include detailed error information when in debug mode
if app.debug:
return jsonify({
'status': 'error',
'message': str(e),
'traceback': traceback.format_exc()
}), 500
else:
return jsonify({
'status': 'error',
'message': 'Internal server error'
}), 500
return jsonify({
'status': 'error',
'message': 'Internal server error'
}), 500
# Prime psutil CPU measurement once at startup so interval=None returns a real value
try:
@@ -342,35 +336,25 @@ def not_found_error(error):
@app.errorhandler(500)
def internal_error(error):
"""Handle 500 errors."""
import traceback
error_details = traceback.format_exc()
# Log the error
import logging
logger = logging.getLogger('web_interface')
logger.error(f"Internal server error: {error}", exc_info=True)
# Return user-friendly error (hide internal details in production)
logger.error("Internal server error", exc_info=True)
return jsonify({
'status': 'error',
'error_code': 'INTERNAL_ERROR',
'message': 'An internal error occurred',
'details': error_details if app.debug else None
'message': 'An internal error occurred; see logs for details',
}), 500
@app.errorhandler(Exception)
def handle_exception(error):
"""Handle all unhandled exceptions."""
import traceback
import logging
logger = logging.getLogger('web_interface')
logger.error(f"Unhandled exception: {error}", exc_info=True)
logger.error("Unhandled exception", exc_info=True)
return jsonify({
'status': 'error',
'error_code': 'UNKNOWN_ERROR',
'message': str(error) if app.debug else 'An error occurred',
'details': traceback.format_exc() if app.debug else None
'message': 'An error occurred; see logs for details',
}), 500
# Captive portal redirect middleware
@@ -407,6 +391,22 @@ def captive_portal_redirect():
# Redirect to lightweight captive portal setup page (not the full UI)
return redirect(url_for('pages_v3.captive_setup'), code=302)
# Append a content-version query param (file mtime) to every static URL so the
# long-lived `immutable` cache (see add_security_headers below) is actually safe:
# when a static file changes its URL changes, so browsers refetch it. Without
# this, edited JS/CSS were served immutable under an unchanging URL and never
# reached clients until a manual cache clear.
@app.url_defaults
def add_static_version(endpoint, values):
if endpoint == 'static' and values.get('filename'):
try:
file_path = os.path.join(app.static_folder, values['filename'])
values['v'] = int(os.path.getmtime(file_path))
except OSError:
# File missing (e.g. plugin asset not yet installed) — skip versioning.
pass
# Add security headers and caching to all responses
@app.after_request
def add_security_headers(response):
@@ -435,13 +435,53 @@ def add_security_headers(response):
return response
# SSE helper function
def sse_response(generator_func):
"""Helper to create SSE responses"""
def generate():
for data in generator_func():
yield f"data: {json.dumps(data)}\n\n"
return Response(generate(), mimetype='text/event-stream')
class _StreamBroadcaster:
"""Fan-out broadcaster: one background generator thread pushes to all SSE clients.
This means N browser tabs share one generator instead of each running their own,
keeping PIL encodes / subprocess forks constant regardless of how many tabs are open.
"""
def __init__(self, generator_factory):
self._generator_factory = generator_factory
self._clients: set = set()
self._lock = threading.Lock()
self._thread: threading.Thread | None = None
def subscribe(self) -> queue.Queue:
q: queue.Queue = queue.Queue(maxsize=5)
with self._lock:
self._clients.add(q)
if not (self._thread and self._thread.is_alive()):
self._thread = threading.Thread(target=self._broadcast, daemon=True)
self._thread.start()
return q
def unsubscribe(self, q: queue.Queue) -> None:
with self._lock:
self._clients.discard(q)
def _broadcast(self):
for data in self._generator_factory():
with self._lock:
if not self._clients:
# No subscribers — exit so the thread doesn't spin indefinitely.
# subscribe() will restart it when a new client arrives.
break
for q in self._clients:
try:
q.put_nowait(data)
except queue.Full:
# Client is reading too slowly; drop the oldest item and
# deliver the latest so the queue never stalls the client.
try:
q.get_nowait()
except queue.Empty:
pass
try:
q.put_nowait(data)
except queue.Full:
pass
# System status generator for SSE
def system_status_generator():
@@ -472,12 +512,13 @@ def system_status_generator():
# Check if display service is running (cached to avoid per-client subprocess forks)
now = time.time()
if (now - _ledmatrix_service_cache['timestamp']) >= _LEDMATRIX_SERVICE_CACHE_TTL:
try:
result = subprocess.run(['systemctl', 'is-active', 'ledmatrix'],
capture_output=True, text=True, timeout=2)
_ledmatrix_service_cache['active'] = result.stdout.strip() == 'active'
except (subprocess.SubprocessError, OSError):
pass
if _SYSTEMCTL:
try:
result = subprocess.run([_SYSTEMCTL, 'is-active', 'ledmatrix'],
capture_output=True, text=True, timeout=2)
_ledmatrix_service_cache['active'] = result.stdout.strip() == 'active'
except (subprocess.SubprocessError, OSError) as e:
app.logger.warning("systemctl status check failed: %s", e)
_ledmatrix_service_cache['timestamp'] = now
service_active = _ledmatrix_service_cache['active']
@@ -492,7 +533,8 @@ def system_status_generator():
}
yield status
except Exception as e:
yield {'error': str(e)}
app.logger.error("SSE generator error", exc_info=True)
yield {'error': 'An error occurred; see server logs'}
time.sleep(10) # Update every 10 seconds (reduced frequency for better performance)
# Display preview generator for SSE
@@ -555,7 +597,8 @@ def display_preview_generator():
}
except Exception as e:
yield {'error': str(e)}
app.logger.error("SSE generator error", exc_info=True)
yield {'error': 'An error occurred; see server logs'}
time.sleep(1.0) # Check once per second — halves PIL encode overhead vs 0.5s
@@ -567,8 +610,13 @@ def logs_generator():
# Get recent logs from journalctl (simplified version)
# Note: User should be in systemd-journal group to read logs without sudo
try:
if not _JOURNALCTL:
yield {'timestamp': time.time(), 'logs': 'journalctl not found; cannot read logs'}
time.sleep(60)
continue
result = subprocess.run(
['journalctl', '-u', 'ledmatrix.service', '-n', '50', '--no-pager'],
[_JOURNALCTL, '-u', 'ledmatrix.service', '-u', 'ledmatrix-web.service',
'-n', '50', '--no-pager', '--output=short-iso'],
capture_output=True, text=True, timeout=5
)
@@ -584,7 +632,7 @@ def logs_generator():
# No logs available
logs_data = {
'timestamp': time.time(),
'logs': 'No logs available from ledmatrix service'
'logs': 'No logs available from ledmatrix or ledmatrix-web service'
}
yield logs_data
else:
@@ -598,36 +646,68 @@ def logs_generator():
except subprocess.TimeoutExpired:
# Timeout - just skip this update
pass
except Exception as e:
except Exception:
app.logger.error("Error running journalctl", exc_info=True)
error_data = {
'timestamp': time.time(),
'logs': f'Error running journalctl: {str(e)}'
'logs': 'Error running journalctl; see server logs'
}
yield error_data
except Exception as e:
except Exception:
app.logger.error("Unexpected error in logs generator", exc_info=True)
error_data = {
'timestamp': time.time(),
'logs': f'Unexpected error in logs generator: {str(e)}'
'logs': 'Unexpected error in logs generator; see server logs'
}
yield error_data
time.sleep(5) # Update every 5 seconds (reduced frequency for better performance)
# One broadcaster per stream — shared across all SSE clients
_stats_broadcaster = _StreamBroadcaster(system_status_generator)
_display_broadcaster = _StreamBroadcaster(display_preview_generator)
_logs_broadcaster = _StreamBroadcaster(logs_generator)
def _sse_stream(broadcaster: _StreamBroadcaster) -> Response:
"""Return a streaming SSE response backed by a shared broadcaster."""
q = broadcaster.subscribe()
def generate():
try:
while True:
try:
data = q.get(timeout=30)
yield f"data: {json.dumps(data)}\n\n"
except queue.Empty:
# Send an SSE comment heartbeat to keep the connection alive
# through proxies that close idle connections.
yield ": heartbeat\n\n"
except GeneratorExit:
pass
finally:
broadcaster.unsubscribe(q)
return Response(generate(), mimetype='text/event-stream')
# SSE endpoints
@app.route('/api/v3/stream/stats')
def stream_stats():
return sse_response(system_status_generator)
return _sse_stream(_stats_broadcaster)
@app.route('/api/v3/stream/display')
def stream_display():
return sse_response(display_preview_generator)
return _sse_stream(_display_broadcaster)
@app.route('/api/v3/stream/logs')
def stream_logs():
return sse_response(logs_generator)
return _sse_stream(_logs_broadcaster)
# Exempt SSE streams from CSRF and add rate limiting
# Exempt SSE streams from CSRF and apply a generous rate limit.
# SSE connections are long-lived HTTP requests, not repeated API calls, so the
# tight "20 per minute" default would be exhausted quickly on reconnects.
if csrf:
csrf.exempt(stream_stats)
csrf.exempt(stream_display)
@@ -635,9 +715,9 @@ if csrf:
# Note: api_v3 blueprint is exempted above after registration
if limiter:
limiter.limit("20 per minute")(stream_stats)
limiter.limit("20 per minute")(stream_display)
limiter.limit("20 per minute")(stream_logs)
limiter.limit("200 per minute")(stream_stats)
limiter.limit("200 per minute")(stream_display)
limiter.limit("200 per minute")(stream_logs)
# Main route - redirect to v3 interface as default
@app.route('/')
@@ -716,6 +796,41 @@ def _run_startup_reconciliation() -> None:
"manual 'Reconcile' action to resolve.",
len(result.inconsistencies_manual),
)
# Write status file so the web UI can surface unresolved issues as a
# banner without the user having to read journalctl. Mirrors the
# hw_status pattern (/tmp/led_matrix_hw_status.json).
import json as _json, tempfile as _tempfile, os as _os
_recon_status = {
"done": True,
"successful": result.reconciliation_successful,
"fixed_count": len(result.inconsistencies_fixed),
"unresolved": [
{
"plugin_id": inc.plugin_id,
"type": inc.inconsistency_type.value,
"description": inc.description,
}
for inc in result.inconsistencies_manual
],
}
_recon_path = _os.path.join(_tempfile.gettempdir(), "ledmatrix_reconciliation.json")
_tmp = None
try:
if not _os.path.islink(_recon_path):
_fd, _tmp = _tempfile.mkstemp(dir=_tempfile.gettempdir(), prefix=".led_recon_")
with _os.fdopen(_fd, "w") as _f:
_json.dump(_recon_status, _f)
_os.replace(_tmp, _recon_path)
_tmp = None # Rename succeeded; nothing to clean up
except (OSError, ValueError, TypeError) as _e:
_logger.warning("[Reconciliation] Could not write status file: %s", _e)
finally:
if _tmp is not None and _os.path.exists(_tmp):
try:
_os.unlink(_tmp)
except OSError:
pass
except Exception as e:
_logger.error("[Reconciliation] Error: %s", e, exc_info=True)
finally:

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,14 @@ from flask import Blueprint, render_template, flash
from markupsafe import escape
import json
import logging
import os
import os.path
import re
from pathlib import Path
# Strict allowlists for URL-derived values used in path and script operations.
_SAFE_PLUGIN_ID_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$')
_SAFE_WEB_UI_FILE_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}\.html$')
from src.web_interface.secret_helpers import mask_secret_fields
logger = logging.getLogger(__name__)
@@ -84,10 +91,11 @@ def load_partial(partial_name):
elif partial_name == 'operation-history':
return _load_operation_history_partial()
else:
return f"Partial '{partial_name}' not found", 404
return "Partial not found", 404
except Exception as e:
return f"Error loading partial '{partial_name}': {str(e)}", 500
logger.error("Error loading partial %s", partial_name, exc_info=True)
return "Error loading partial", 500
@pages_v3.route('/partials/plugin-config/<plugin_id>')
@@ -95,8 +103,102 @@ def load_plugin_config_partial(plugin_id):
"""Load plugin configuration partial via HTMX - server-side rendered form"""
try:
return _load_plugin_config_partial(plugin_id)
except Exception as e:
return f'<div class="text-red-500 p-4">Error loading plugin config: {escape(str(e))}</div>', 500
except Exception:
logger.error("Error loading plugin config partial for %s", plugin_id, exc_info=True)
return '<div class="text-red-500 p-4">Error loading plugin config; see logs for details</div>', 500
@pages_v3.route('/plugin-ui/<plugin_id>/web-ui/<path:filename>')
def serve_plugin_web_ui(plugin_id, filename):
"""Serve a plugin's web_ui/ HTML fragment as a standalone page.
Wraps the fragment with a minimal HTML page that injects window.PLUGIN_ID
and loads Tailwind CSS so the fragment runs correctly in a sandboxed iframe.
"""
# Validate URL-derived values against strict allowlists before any path or
# script operations.
if not _SAFE_PLUGIN_ID_RE.match(plugin_id):
return 'Invalid plugin ID', 400, {'Content-Type': 'text/plain'}
if not _SAFE_WEB_UI_FILE_RE.match(filename):
return 'Invalid filename', 400, {'Content-Type': 'text/plain'}
# os.path.basename() is the CodeQL-recognised path sanitizer used throughout
# this codebase (see plugin_loader.py). Applying it here breaks the taint
# chain even though the allowlist above already prevents path separators.
safe_id = os.path.basename(plugin_id)
safe_fn = os.path.basename(filename)
if not safe_id or not safe_fn:
return 'Invalid path component', 400, {'Content-Type': 'text/plain'}
if not pages_v3.plugin_manager:
return 'Plugin manager not available', 503, {'Content-Type': 'text/plain'}
try:
_plugins_base = Path(pages_v3.plugin_manager.plugins_dir).resolve()
# Reconstruct from sanitised basename — CodeQL-approved pattern.
_plugin_dir = (_plugins_base / safe_id).resolve()
_plugin_dir.relative_to(_plugins_base) # containment guard
# Mirror PluginManager's ledmatrix- prefix fallback.
if not _plugin_dir.exists():
_alt_id = os.path.basename(f'ledmatrix-{safe_id}')
_alt = (_plugins_base / _alt_id).resolve()
try:
_alt.relative_to(_plugins_base)
_plugin_dir = _alt
except ValueError:
pass
web_ui_path = (_plugin_dir / 'web_ui' / safe_fn).resolve()
web_ui_path.relative_to(_plugin_dir / 'web_ui') # second guard
if not web_ui_path.exists():
return 'Not found', 404, {'Content-Type': 'text/plain'}
fragment = web_ui_path.read_text(encoding='utf-8')
# json.dumps wraps the value in quotes. Replace HTML meta-chars with
# their JS Unicode escape sequences so the value cannot close or escape
# the enclosing <script> tag.
# r'<' is the 6-char literal string <, which JavaScript
# interprets as <. This is the standard JSON-in-HTML hardening pattern.
safe_plugin_id_js = (
json.dumps(safe_id)
.replace('<', '\\u003c')
.replace('>', '\\u003e')
.replace('&', '\\u0026')
)
page = (
'<!DOCTYPE html>\n'
'<html lang="en">\n'
'<head>\n'
'<meta charset="UTF-8">\n'
'<meta name="viewport" content="width=device-width,initial-scale=1">\n'
'<script>\n'
# Inject plugin context before the fragment runs.
# plugin_id is validated to [a-zA-Z0-9_-] above, so this is safe,
# but we also Unicode-escape HTML meta-chars as defence in depth.
f' window.PLUGIN_ID = {safe_plugin_id_js};\n'
'</script>\n'
# Tailwind v2 CDN — same version used by the parent LEDMatrix UI
'<link rel="stylesheet" '
'href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.2.19/tailwind.min.css" '
'crossorigin="anonymous">\n'
'<style>body{margin:0;padding:0;background:#fff;}</style>\n'
'</head>\n'
'<body>\n'
+ fragment +
'\n</body>\n</html>'
)
return page, 200, {'Content-Type': 'text/html; charset=utf-8'}
except ValueError:
return 'Forbidden', 403, {'Content-Type': 'text/plain'}
except Exception:
logger.error('Error serving plugin web_ui %s/%s', plugin_id, filename, exc_info=True)
return 'Error serving file', 500, {'Content-Type': 'text/plain'}
def _load_overview_partial():
"""Load overview partial with system stats"""
@@ -107,7 +209,8 @@ def _load_overview_partial():
return render_template('v3/partials/overview.html',
main_config=main_config)
except Exception as e:
return f"Error: {str(e)}", 500
logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
def _load_general_partial():
"""Load general settings partial"""
@@ -117,7 +220,8 @@ def _load_general_partial():
return render_template('v3/partials/general.html',
main_config=main_config)
except Exception as e:
return f"Error: {str(e)}", 500
logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
def _load_display_partial():
"""Load display settings partial"""
@@ -127,7 +231,8 @@ def _load_display_partial():
return render_template('v3/partials/display.html',
main_config=main_config)
except Exception as e:
return f"Error: {str(e)}", 500
logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
def _load_durations_partial():
"""Load display durations partial"""
@@ -137,7 +242,8 @@ def _load_durations_partial():
return render_template('v3/partials/durations.html',
main_config=main_config)
except Exception as e:
return f"Error: {str(e)}", 500
logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
def _load_schedule_partial():
"""Load schedule settings partial"""
@@ -153,7 +259,8 @@ def _load_schedule_partial():
dim_schedule_config=dim_schedule_config,
normal_brightness=normal_brightness)
except Exception as e:
return f"Error: {str(e)}", 500
logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
def _load_weather_partial():
@@ -164,7 +271,8 @@ def _load_weather_partial():
return render_template('v3/partials/weather.html',
main_config=main_config)
except Exception as e:
return f"Error: {str(e)}", 500
logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
def _load_stocks_partial():
"""Load stocks configuration partial"""
@@ -174,7 +282,8 @@ def _load_stocks_partial():
return render_template('v3/partials/stocks.html',
main_config=main_config)
except Exception as e:
return f"Error: {str(e)}", 500
logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
def _load_plugins_partial():
"""Load plugins management partial"""
@@ -208,7 +317,7 @@ def _load_plugins_partial():
plugin_info.update(fresh_manifest)
except Exception as e:
# If we can't read the fresh manifest, use the cached one
print(f"Warning: Could not read fresh manifest for {plugin_id}: {e}")
logger.warning("Could not read fresh manifest for plugin: %s", plugin_id)
# Get enabled status from config (source of truth)
# Read from config file first, fall back to plugin instance if config doesn't have the key
@@ -256,12 +365,13 @@ def _load_plugins_partial():
'branch': branch
})
except Exception as e:
print(f"Error loading plugin data: {e}")
logger.error("Error loading plugin data", exc_info=True)
return render_template('v3/partials/plugins.html',
plugins=plugins_data)
except Exception as e:
return f"Error: {str(e)}", 500
logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
def _load_fonts_partial():
"""Load fonts management partial"""
@@ -271,14 +381,16 @@ def _load_fonts_partial():
return render_template('v3/partials/fonts.html',
fonts=fonts_data)
except Exception as e:
return f"Error: {str(e)}", 500
logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
def _load_logs_partial():
"""Load logs viewer partial"""
try:
return render_template('v3/partials/logs.html')
except Exception as e:
return f"Error: {str(e)}", 500
logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
def _load_raw_json_partial():
"""Load raw JSON editor partial"""
@@ -295,14 +407,16 @@ def _load_raw_json_partial():
main_config_path=pages_v3.config_manager.get_config_path(),
secrets_config_path=pages_v3.config_manager.get_secrets_path())
except Exception as e:
return f"Error: {str(e)}", 500
logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
def _load_backup_restore_partial():
"""Load backup & restore partial."""
try:
return render_template('v3/partials/backup_restore.html')
except Exception as e:
return f"Error: {str(e)}", 500
logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
@pages_v3.route('/setup')
def captive_setup():
@@ -314,21 +428,24 @@ def _load_wifi_partial():
try:
return render_template('v3/partials/wifi.html')
except Exception as e:
return f"Error: {str(e)}", 500
logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
def _load_cache_partial():
"""Load cache management partial"""
try:
return render_template('v3/partials/cache.html')
except Exception as e:
return f"Error: {str(e)}", 500
logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
def _load_operation_history_partial():
"""Load operation history partial"""
try:
return render_template('v3/partials/operation_history.html')
except Exception as e:
return f"Error: {str(e)}", 500
logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
def _load_plugin_config_partial(plugin_id):
@@ -336,6 +453,11 @@ def _load_plugin_config_partial(plugin_id):
Load plugin configuration partial - server-side rendered form.
This replaces the client-side generateConfigForm() JavaScript.
"""
# Sanitize with basename (CodeQL-recognized sanitizer) then regex-validate format
plugin_id = os.path.basename(plugin_id or '')
if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9._\-:]*$', plugin_id):
return '<div class="text-red-500 p-4">Invalid plugin ID</div>', 400
try:
if not pages_v3.plugin_manager:
return '<div class="text-red-500 p-4">Plugin manager not available</div>', 500
@@ -344,80 +466,85 @@ def _load_plugin_config_partial(plugin_id):
if plugin_id.startswith('starlark:'):
return _load_starlark_config_partial(plugin_id[len('starlark:'):])
# Resolve and validate all plugin paths against the plugins base directory
_plugins_base = Path(pages_v3.plugin_manager.plugins_dir).resolve()
_plugin_dir = (_plugins_base / plugin_id).resolve()
try:
_plugin_dir.relative_to(_plugins_base)
except ValueError:
return '<div class="text-red-500 p-4">Invalid plugin ID</div>', 400
# Try to get plugin info first
plugin_info = pages_v3.plugin_manager.get_plugin_info(plugin_id)
# If not found, re-discover plugins (handles plugins added after startup)
if not plugin_info:
pages_v3.plugin_manager.discover_plugins()
plugin_info = pages_v3.plugin_manager.get_plugin_info(plugin_id)
if not plugin_info:
return f'<div class="text-red-500 p-4">Plugin "{escape(plugin_id)}" not found</div>', 404
return '<div class="text-red-500 p-4">Plugin not found</div>', 404
# Get plugin instance (may be None if not loaded)
plugin_instance = pages_v3.plugin_manager.get_plugin(plugin_id)
# Get plugin configuration from config file
config = {}
if pages_v3.config_manager:
full_config = pages_v3.config_manager.load_config()
config = full_config.get(plugin_id, {})
# Load uploaded images from metadata file if images field exists in schema
# This ensures uploaded images appear even if config hasn't been saved yet
schema_path_temp = Path(pages_v3.plugin_manager.plugins_dir) / plugin_id / "config_schema.json"
schema_path_temp = _plugin_dir / "config_schema.json"
if schema_path_temp.exists():
try:
with open(schema_path_temp, 'r', encoding='utf-8') as f:
temp_schema = json.load(f)
# Check if schema has an images field with x-widget: file-upload
if (temp_schema.get('properties', {}).get('images', {}).get('x-widget') == 'file-upload' or
temp_schema.get('properties', {}).get('images', {}).get('x_widget') == 'file-upload'):
# Load metadata file
# Get PROJECT_ROOT relative to this file
project_root = Path(__file__).parent.parent.parent
metadata_file = project_root / 'assets' / 'plugins' / plugin_id / 'uploads' / '.metadata.json'
if metadata_file.exists():
_assets_base = (Path(__file__).parent.parent.parent / 'assets' / 'plugins').resolve()
metadata_file = (_assets_base / plugin_id / 'uploads' / '.metadata.json').resolve()
try:
metadata_file.relative_to(_assets_base)
except ValueError:
metadata_file = None
if metadata_file and metadata_file.exists():
try:
with open(metadata_file, 'r', encoding='utf-8') as mf:
metadata = json.load(mf)
# Convert metadata dict to list of image objects
images_from_metadata = list(metadata.values())
# Only use metadata images if config doesn't have images or config images is empty
if not config.get('images') or len(config.get('images', [])) == 0:
config['images'] = images_from_metadata
else:
# Merge: add metadata images that aren't already in config
config_image_ids = {img.get('id') for img in config.get('images', []) if img.get('id')}
new_images = [img for img in images_from_metadata if img.get('id') not in config_image_ids]
if new_images:
config['images'] = config.get('images', []) + new_images
except Exception as e:
print(f"Warning: Could not load metadata for {plugin_id}: {e}")
logger.warning("Could not load plugin upload metadata: %s", e)
except Exception as e: # nosec B110 - metadata pre-load is optional; schema loads fully below
logger.debug("Metadata pre-load skipped for plugin %s: %s", plugin_id, e)
# Get plugin schema
schema = {}
schema_path = Path(pages_v3.plugin_manager.plugins_dir) / plugin_id / "config_schema.json"
schema_path = _plugin_dir / "config_schema.json"
if schema_path.exists():
try:
with open(schema_path, 'r', encoding='utf-8') as f:
schema = json.load(f)
except Exception as e:
print(f"Warning: Could not load schema for {plugin_id}: {e}")
logger.warning("Could not load schema for plugin: %s", e)
# Get web UI actions from plugin manifest
web_ui_actions = []
manifest_path = Path(pages_v3.plugin_manager.plugins_dir) / plugin_id / "manifest.json"
manifest_path = _plugin_dir / "manifest.json"
if manifest_path.exists():
try:
with open(manifest_path, 'r', encoding='utf-8') as f:
manifest = json.load(f)
web_ui_actions = manifest.get('web_ui_actions', [])
except Exception as e:
print(f"Warning: Could not load manifest for {plugin_id}: {e}")
logger.warning("Could not load manifest for plugin: %s", e)
# Mask secret fields before rendering template (fail closed — never leak secrets)
schema_properties = schema.get('properties') if isinstance(schema, dict) else None
@@ -453,20 +580,24 @@ def _load_plugin_config_partial(plugin_id):
)
except Exception as e:
import traceback
traceback.print_exc()
return f'<div class="text-red-500 p-4">Error loading plugin config: {escape(str(e))}</div>', 500
logger.error("Error loading plugin config partial for %s", plugin_id, exc_info=True)
return '<div class="text-red-500 p-4">Error loading plugin config; see logs for details</div>', 500
def _load_starlark_config_partial(app_id):
"""Load configuration partial for a Starlark app."""
# Sanitize with basename (CodeQL-recognized sanitizer) then regex-validate format
app_id = os.path.basename(app_id or '')
if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9_\-]*$', app_id):
return '<div class="text-red-500 p-4">Invalid app ID</div>', 400
try:
starlark_plugin = pages_v3.plugin_manager.get_plugin('starlark-apps') if pages_v3.plugin_manager else None
if starlark_plugin and hasattr(starlark_plugin, 'apps'):
app = starlark_plugin.apps.get(app_id)
if not app:
return f'<div class="text-red-500 p-4">Starlark app not found: {app_id}</div>', 404
return '<div class="text-red-500 p-4">Starlark app not found</div>', 404
return render_template(
'v3/partials/starlark_config.html',
app_id=app_id,
@@ -482,36 +613,45 @@ def _load_starlark_config_partial(app_id):
)
# Standalone: read from manifest file
manifest_file = Path(__file__).resolve().parent.parent.parent / 'starlark-apps' / 'manifest.json'
starlark_base = (Path(__file__).resolve().parent.parent.parent / 'starlark-apps').resolve()
manifest_file = starlark_base / 'manifest.json'
if not manifest_file.exists():
return f'<div class="text-red-500 p-4">Starlark app not found: {app_id}</div>', 404
return '<div class="text-red-500 p-4">Starlark app not found</div>', 404
with open(manifest_file, 'r') as f:
manifest = json.load(f)
app_data = manifest.get('apps', {}).get(app_id)
if not app_data:
return f'<div class="text-red-500 p-4">Starlark app not found: {app_id}</div>', 404
return '<div class="text-red-500 p-4">Starlark app not found</div>', 404
# Load schema from schema.json if it exists
# Load schema from schema.json if it exists — validate path stays within starlark_base
schema = None
schema_file = Path(__file__).resolve().parent.parent.parent / 'starlark-apps' / app_id / 'schema.json'
if schema_file.exists():
schema_file = (starlark_base / app_id / 'schema.json').resolve()
try:
schema_file.relative_to(starlark_base)
except ValueError:
schema_file = None
if schema_file and schema_file.exists():
try:
with open(schema_file, 'r') as f:
schema = json.load(f)
except (OSError, json.JSONDecodeError) as e:
logger.warning(f"[Pages V3] Could not load schema for {app_id}: {e}", exc_info=True)
logger.warning("Could not load starlark schema for app: %s", e)
# Load config from config.json if it exists
# Load config from config.json if it exists — validate path stays within starlark_base
config = {}
config_file = Path(__file__).resolve().parent.parent.parent / 'starlark-apps' / app_id / 'config.json'
if config_file.exists():
config_file = (starlark_base / app_id / 'config.json').resolve()
try:
config_file.relative_to(starlark_base)
except ValueError:
config_file = None
if config_file and config_file.exists():
try:
with open(config_file, 'r') as f:
config = json.load(f)
except (OSError, json.JSONDecodeError) as e:
logger.warning(f"[Pages V3] Could not load config for {app_id}: {e}", exc_info=True)
logger.warning("Could not load starlark config for app: %s", e)
return render_template(
'v3/partials/starlark_config.html',
@@ -528,5 +668,5 @@ def _load_starlark_config_partial(app_id):
)
except Exception as e:
logger.exception(f"[Pages V3] Error loading starlark config for {app_id}")
return f'<div class="text-red-500 p-4">Error loading starlark config: {str(e)}</div>', 500
logger.error("[Pages V3] Error loading starlark config for app", exc_info=True)
return '<div class="text-red-500 p-4">Error loading starlark config; see logs for details</div>', 500

View File

@@ -1,4 +1,4 @@
/* global showNotification, updateSystemStats, htmx */
/* global showNotification, updateSystemStats, updateDisplayPreview, htmx */
// LED Matrix v3 JavaScript
// Additional helpers for HTMX and Alpine.js integration
@@ -51,7 +51,8 @@ document.body.addEventListener('htmx:afterRequest', function(event) {
}
});
// SSE reconnection helper
// SSE reconnection helper — closes and reopens both SSE streams,
// reattaching the open/error handlers defined in base.html.
window.reconnectSSE = function() {
if (window.statsSource) {
window.statsSource.close();
@@ -60,14 +61,18 @@ window.reconnectSSE = function() {
const data = JSON.parse(event.data);
if (typeof updateSystemStats === 'function') updateSystemStats(data);
};
if (window._statsOpenHandler) window.statsSource.addEventListener('open', window._statsOpenHandler);
if (window._statsErrorHandler) window.statsSource.addEventListener('error', window._statsErrorHandler);
}
if (window.displaySource) {
window.displaySource.close();
window.displaySource = new EventSource('/api/v3/stream/display');
window.displaySource.onmessage = function() {
// Handle display updates
window.displaySource.onmessage = function(event) {
const data = JSON.parse(event.data);
if (typeof updateDisplayPreview === 'function') updateDisplayPreview(data);
};
if (window._displayErrorHandler) window.displaySource.addEventListener('error', window._displayErrorHandler);
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -51,8 +51,10 @@
sanitizeValue(value) {
// Base implementation - widgets should override for specific needs
if (typeof value === 'string') {
// Basic XSS prevention
return value.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
// Strip all HTML tags via the DOM parser to prevent XSS
const div = document.createElement('div');
div.textContent = value;
return div.textContent;
}
return value;
}

View File

@@ -0,0 +1,291 @@
/**
* LEDMatrix File Upload Single Widget
*
* Single-image upload for string fields. Uploads to the plugin's asset folder
* and sets the string field value to the returned relative path.
* Designed for per-item image fields within array-table rows.
*
* The plugin_id is injected automatically from the template context
* via options.pluginId — no need to specify it in the schema.
*
* Schema example (any plugin):
* {
* "image_path": {
* "type": "string",
* "x-widget": "file-upload-single",
* "x-upload-config": {
* "allowed_types": ["image/png", "image/jpeg", "image/bmp", "image/gif"],
* "max_size_mb": 5
* }
* }
* }
*
* @module FileUploadSingleWidget
*/
(function() {
'use strict';
if (typeof window.LEDMatrixWidgets === 'undefined') {
console.error('[FileUploadSingleWidget] LEDMatrixWidgets registry not found. Load registry.js first.');
return;
}
const base = window.BaseWidget ? new window.BaseWidget('FileUploadSingle', '1.0.0') : null;
function escapeHtml(text) {
if (base) return base.escapeHtml(text);
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
return String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
}
function triggerChange(fieldId, value) {
if (base) {
base.triggerChange(fieldId, value);
} else {
document.dispatchEvent(new CustomEvent('widget-change', {
detail: { fieldId, value },
bubbles: true,
cancelable: true
}));
}
}
function isImagePath(path) {
if (!path) return false;
return /\.(png|jpg|jpeg|bmp|gif)$/i.test(path);
}
function safeSetHTML(target, html) {
target.textContent = '';
// createContextualFragment parses html relative to the document context
// without executing scripts — a widely recognised safe insertion method.
const frag = document.createRange().createContextualFragment(html);
target.appendChild(frag);
}
window.LEDMatrixWidgets.register('file-upload-single', {
name: 'File Upload Single Widget',
version: '1.0.0',
render: function(container, config, value, options) {
const fieldId = sanitizeId(options.fieldId || container.id || 'file_upload_single');
const uploadConfig = config['x-upload-config'] || config['x_upload_config'] || {};
const allowedTypes = (uploadConfig.allowed_types || ['image/png', 'image/jpeg', 'image/bmp', 'image/gif']).join(',');
const maxSizeMb = uploadConfig.max_size_mb || 5;
const pluginId = options.pluginId || '';
const currentValue = value || '';
const hasImage = isImagePath(currentValue);
let html = `<div id="${fieldId}_widget" class="file-upload-single-widget" data-field-id="${fieldId}" data-plugin-id="${escapeHtml(pluginId)}">`;
// Hidden input carries the actual string value
html += `<input type="hidden" id="${fieldId}" name="${escapeHtml(options.name || fieldId)}" value="${escapeHtml(currentValue)}">`;
// Preview area (shown when a value is set)
html += `<div id="${fieldId}_preview" class="${hasImage ? '' : 'hidden'} flex items-center space-x-3 mb-2 p-2 bg-gray-50 rounded border border-gray-200">`;
html += `<img id="${fieldId}_thumb" src="/${escapeHtml(currentValue)}" alt="Preview"
class="w-12 h-12 object-cover rounded"
onerror="this.style.display='none';document.getElementById('${fieldId}_thumb_placeholder').style.display='flex'">`;
html += `<div id="${fieldId}_thumb_placeholder" style="display:none" class="w-12 h-12 bg-gray-200 rounded flex items-center justify-center">
<i class="fas fa-image text-gray-400 text-lg"></i>
</div>`;
html += `<div class="flex-1 min-w-0">
<p id="${fieldId}_filename" class="text-xs text-gray-600 truncate">${escapeHtml(currentValue.split('/').pop() || '')}</p>
<p id="${fieldId}_fullpath" class="text-xs text-gray-400">${escapeHtml(currentValue)}</p>
</div>`;
html += `<button type="button"
onclick="window.LEDMatrixWidgets.getHandlers('file-upload-single').onClear('${fieldId}')"
class="flex-shrink-0 text-red-400 hover:text-red-600 p-1" title="Remove image">
<i class="fas fa-times"></i>
</button>`;
html += '</div>';
// Upload drop zone — keyboard accessible via tabindex + Enter/Space
html += `<div id="${fieldId}_drop_zone"
class="border-2 border-dashed border-gray-300 rounded-lg p-3 text-center hover:border-blue-400 transition-colors cursor-pointer"
role="button" tabindex="0"
aria-label="${hasImage ? 'Replace image' : 'Upload image'}"
ondrop="window.LEDMatrixWidgets.getHandlers('file-upload-single').onDrop(event, '${fieldId}')"
ondragover="event.preventDefault()"
onclick="document.getElementById('${fieldId}_file_input').click()"
onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();document.getElementById('${fieldId}_file_input').click();}">
<input type="file"
id="${fieldId}_file_input"
accept="${escapeHtml(allowedTypes)}"
style="display:none"
data-field-id="${fieldId}"
data-plugin-id="${escapeHtml(pluginId)}"
data-max-size-mb="${maxSizeMb}"
data-allowed-types="${escapeHtml(allowedTypes)}"
onchange="window.LEDMatrixWidgets.getHandlers('file-upload-single').onFileSelect(event, '${fieldId}')">
<i class="fas fa-cloud-upload-alt text-xl text-gray-400 mb-1"></i>
<p class="text-xs text-gray-500">${hasImage ? 'Click to replace image' : 'Click or drag to upload image'}</p>
<p class="text-xs text-gray-400">Max ${maxSizeMb}MB</p>
</div>`;
// Status area for upload feedback
html += `<div id="${fieldId}_status" class="mt-1 text-xs hidden"></div>`;
html += '</div>';
safeSetHTML(container, html);
},
getValue: function(fieldId) {
const safeId = sanitizeId(fieldId);
const input = document.getElementById(safeId);
return input ? input.value : '';
},
setValue: function(fieldId, value) {
const safeId = sanitizeId(fieldId);
const hidden = document.getElementById(safeId);
const preview = document.getElementById(`${safeId}_preview`);
const thumb = document.getElementById(`${safeId}_thumb`);
const thumbPlaceholder = document.getElementById(`${safeId}_thumb_placeholder`);
const filename = document.getElementById(`${safeId}_filename`);
const dropZone = document.getElementById(`${safeId}_drop_zone`);
if (hidden) hidden.value = value || '';
const hasImage = isImagePath(value);
if (preview) preview.classList.toggle('hidden', !hasImage);
if (thumb && hasImage) {
thumb.src = `/${value}`;
thumb.style.display = '';
if (thumbPlaceholder) thumbPlaceholder.style.display = 'none';
}
if (filename) filename.textContent = hasImage ? value.split('/').pop() : '';
const fullpath = document.getElementById(`${safeId}_fullpath`);
if (fullpath) fullpath.textContent = value || '';
// Update drop zone hint text
const hint = dropZone ? dropZone.querySelector('p') : null;
if (hint) hint.textContent = hasImage ? 'Click to replace image' : 'Click or drag to upload image';
},
handlers: {
onFileSelect: function(event, fieldId) {
const files = event.target.files;
if (files && files.length > 0) {
window.LEDMatrixWidgets.getHandlers('file-upload-single').uploadFile(fieldId, files[0]);
}
},
onDrop: function(event, fieldId) {
event.preventDefault();
const files = event.dataTransfer.files;
if (files && files.length > 0) {
window.LEDMatrixWidgets.getHandlers('file-upload-single').uploadFile(fieldId, files[0]);
}
},
onClear: function(fieldId) {
const widget = window.LEDMatrixWidgets.get('file-upload-single');
widget.setValue(fieldId, '');
triggerChange(fieldId, '');
// Reset file input so the same file can be re-selected
const fileInput = document.getElementById(`${sanitizeId(fieldId)}_file_input`);
if (fileInput) fileInput.value = '';
},
uploadFile: async function(fieldId, file) {
const safeId = sanitizeId(fieldId);
const fileInput = document.getElementById(`${safeId}_file_input`);
const statusDiv = document.getElementById(`${safeId}_status`);
const notifyFn = window.showNotification || console.log;
// Read config from the file input data attributes
const pluginId = (fileInput && fileInput.dataset.pluginId) || '';
const maxSizeMb = parseFloat((fileInput && fileInput.dataset.maxSizeMb) || '5');
const allowedTypes = ((fileInput && fileInput.dataset.allowedTypes) || 'image/png,image/jpeg,image/bmp,image/gif')
.split(',').map(t => t.trim());
if (!pluginId) {
notifyFn('Plugin ID not set — cannot upload', 'error');
return;
}
// Validate type
if (!allowedTypes.includes(file.type)) {
notifyFn(`File type "${file.type}" not allowed`, 'error');
return;
}
// Validate size
if (file.size > maxSizeMb * 1024 * 1024) {
notifyFn(`File exceeds ${maxSizeMb}MB limit`, 'error');
return;
}
// Show uploading status — use DOM methods to avoid innerHTML with dynamic data
if (statusDiv) {
statusDiv.className = 'mt-1 text-xs text-gray-500';
statusDiv.textContent = '';
const spinner = document.createElement('i');
spinner.className = 'fas fa-spinner fa-spin mr-1';
statusDiv.appendChild(spinner);
statusDiv.appendChild(document.createTextNode('Uploading…'));
}
const formData = new FormData();
formData.append('plugin_id', pluginId);
formData.append('files', file);
try {
const response = await fetch('/api/v3/plugins/assets/upload', {
method: 'POST',
body: formData
});
if (!response.ok) {
const body = await response.text();
throw new Error(`Server error ${response.status}: ${body}`);
}
const data = await response.json();
if (data.status === 'success' && data.uploaded_files && data.uploaded_files.length > 0) {
const uploadedPath = data.uploaded_files[0].path;
const widget = window.LEDMatrixWidgets.get('file-upload-single');
widget.setValue(fieldId, uploadedPath);
triggerChange(fieldId, uploadedPath);
if (statusDiv) {
statusDiv.className = 'mt-1 text-xs text-green-600';
statusDiv.textContent = '';
const icon = document.createElement('i');
icon.className = 'fas fa-check-circle mr-1';
statusDiv.appendChild(icon);
statusDiv.appendChild(document.createTextNode('Uploaded successfully'));
setTimeout(() => { statusDiv.className = 'mt-1 text-xs hidden'; statusDiv.textContent = ''; }, 3000);
}
notifyFn('Image uploaded successfully', 'success');
} else {
throw new Error(data.message || 'Upload failed');
}
} catch (error) {
if (statusDiv) {
statusDiv.className = 'mt-1 text-xs text-red-600';
statusDiv.textContent = '';
const errIcon = document.createElement('i');
errIcon.className = 'fas fa-exclamation-circle mr-1';
statusDiv.appendChild(errIcon);
statusDiv.appendChild(document.createTextNode(error.message || 'Upload failed'));
}
notifyFn(`Upload error: ${error.message}`, 'error');
} finally {
if (fileInput) fileInput.value = '';
}
}
}
});
console.log('[FileUploadSingleWidget] File upload single widget registered');
})();

View File

@@ -0,0 +1,783 @@
/**
* JsonFileManager — reusable JSON file management widget for LEDMatrix plugins.
*
* Usage via config_schema.json:
* "file_manager": {
* "type": "null",
* "title": "Data Files",
* "x-widget": "json-file-manager",
* "x-widget-config": {
* "actions": {
* "list": "list-files", // required
* "get": "get-file", // required for editing
* "save": "save-file", // required for editing
* "upload": "upload-file", // optional
* "delete": "delete-file", // optional
* "create": "create-file", // optional
* "toggle": "toggle-category" // optional
* },
* "upload_hint": "Hint text under the drop zone",
* "directory_label": "of_the_day/",
* "create_fields": [
* { "key": "category_name", "label": "Category Name",
* "placeholder": "my_words", "pattern": "^[a-z0-9_]+$",
* "hint": "Used as filename" },
* { "key": "display_name", "label": "Display Name",
* "placeholder": "My Words" }
* ],
* "toggle_key": "category_name"
* }
* }
*
* No CDN dependencies. Works on all modern browsers.
*/
(function () {
'use strict';
class JsonFileManager {
constructor(container, config, pluginId) {
// Prevent duplicate instances on the same container
if (container._jfmInstance) {
container._jfmInstance._destroy();
}
container._jfmInstance = this;
this.el = container;
this.pluginId = pluginId;
this.actions = config.actions || {};
this.uploadHint = config.upload_hint || '';
this.dirLabel = config.directory_label || '';
this.createFields = config.create_fields || [];
this.toggleKey = config.toggle_key || null;
// Unique prefix for all DOM IDs in this instance
this._uid = 'jfm_' + Array.from(crypto.getRandomValues(new Uint8Array(4)), b => b.toString(16).padStart(2, '0')).join('');
// Mutable state
this._editFile = null;
this._deleteFile = null;
this._keyHandler = this._onKey.bind(this);
this._inject();
this._bind();
this._loadList();
}
// ── Lifecycle ────────────────────────────────────────────────────────
_destroy() {
document.removeEventListener('keydown', this._keyHandler);
this.el._jfmInstance = null;
}
// ── DOM Injection ────────────────────────────────────────────────────
_inject() {
const u = this._uid;
const hasUpload = !!this.actions.upload;
const hasCreate = !!this.actions.create;
const hasDelete = !!this.actions.delete;
this.el.innerHTML = this._css(u) + `
<div id="${u}" class="jfm">
<div class="jfm-header">
<div class="jfm-header-left">
<span class="jfm-title">Data Files</span>
${this.dirLabel ? `<code class="jfm-dir">${this._esc(this.dirLabel)}</code>` : ''}
</div>
<div class="jfm-header-right">
${hasCreate ? `<button type="button" class="jfm-btn jfm-btn-primary jfm-btn-sm" data-jfm="open-create">+ New File</button>` : ''}
<button type="button" class="jfm-btn jfm-btn-ghost jfm-btn-sm" data-jfm="refresh" title="Refresh file list">&#8635;</button>
</div>
</div>
<div id="${u}-list" class="jfm-list">
<div class="jfm-loading"><span class="jfm-spin"></span> Loading…</div>
</div>
${hasUpload ? `
<div class="jfm-upload-wrap">
<input type="file" accept=".json" id="${u}-fileinput" tabindex="-1">
<div class="jfm-dropzone" id="${u}-dropzone" data-jfm="open-picker" role="button" tabindex="0"
aria-label="Upload JSON file">
<span class="jfm-drop-icon">&#128193;</span>
<p class="jfm-drop-primary">Drop a JSON file here, or click to browse</p>
${this.uploadHint ? `<p class="jfm-drop-hint">${this._esc(this.uploadHint)}</p>` : ''}
</div>
</div>` : ''}
<!-- ── Edit modal ─────────────────────────────────────── -->
<div class="jfm-modal" id="${u}-edit-modal" role="dialog" aria-modal="true" hidden>
<div class="jfm-modal-box jfm-modal-wide">
<div class="jfm-modal-head">
<span id="${u}-edit-title" class="jfm-modal-title">Edit file</span>
<div class="jfm-modal-tools">
<button type="button" class="jfm-btn jfm-btn-ghost jfm-btn-sm" data-jfm="fmt">Format</button>
<button type="button" class="jfm-btn jfm-btn-ghost jfm-btn-sm" data-jfm="validate">Validate</button>
<button type="button" class="jfm-close-btn" data-jfm="close-edit" aria-label="Close">&times;</button>
</div>
</div>
<div id="${u}-edit-err" class="jfm-err-bar" hidden></div>
<textarea id="${u}-editor" class="jfm-editor"
spellcheck="false" autocomplete="off"
autocorrect="off" autocapitalize="off"
aria-label="JSON editor"></textarea>
<div class="jfm-modal-foot">
<span id="${u}-charcount" class="jfm-stat"></span>
<button type="button" class="jfm-btn jfm-btn-ghost" data-jfm="close-edit">Cancel</button>
<button type="button" class="jfm-btn jfm-btn-primary" data-jfm="save" id="${u}-save-btn">Save</button>
</div>
</div>
</div>
<!-- ── Delete modal ───────────────────────────────────── -->
${hasDelete ? `
<div class="jfm-modal" id="${u}-del-modal" role="dialog" aria-modal="true" hidden>
<div class="jfm-modal-box">
<div class="jfm-modal-head">
<span class="jfm-modal-title">Delete file</span>
<button type="button" class="jfm-close-btn" data-jfm="close-del" aria-label="Close">&times;</button>
</div>
<div class="jfm-modal-body">
<p>Delete <strong id="${u}-del-name"></strong>?</p>
<p class="jfm-muted">This permanently removes the file and its entry from the plugin configuration.</p>
</div>
<div class="jfm-modal-foot">
<button type="button" class="jfm-btn jfm-btn-ghost" data-jfm="close-del">Cancel</button>
<button type="button" class="jfm-btn jfm-btn-danger" data-jfm="confirm-del" id="${u}-del-btn">Delete</button>
</div>
</div>
</div>` : ''}
<!-- ── Create modal ───────────────────────────────────── -->
${hasCreate ? `
<div class="jfm-modal" id="${u}-create-modal" role="dialog" aria-modal="true" hidden>
<div class="jfm-modal-box">
<div class="jfm-modal-head">
<span class="jfm-modal-title">Create new file</span>
<button type="button" class="jfm-close-btn" data-jfm="close-create" aria-label="Close">&times;</button>
</div>
<div class="jfm-modal-body">
${this.createFields.map(f => `
<div class="jfm-field">
<label for="${u}-cf-${this._esc(f.key)}">${this._esc(f.label)}</label>
<input type="text" id="${u}-cf-${this._esc(f.key)}"
placeholder="${this._esc(f.placeholder || '')}"
${f.pattern ? `pattern="${this._esc(f.pattern)}"` : ''}>
${f.hint ? `<span class="jfm-hint">${this._esc(f.hint)}</span>` : ''}
</div>`).join('')}
</div>
<div class="jfm-modal-foot">
<button type="button" class="jfm-btn jfm-btn-ghost" data-jfm="close-create">Cancel</button>
<button type="button" class="jfm-btn jfm-btn-primary" data-jfm="do-create" id="${u}-create-btn">Create</button>
</div>
</div>
</div>` : ''}
</div>`; // end #${u}
// Cache frequently-used elements
this._root = document.getElementById(u);
this._listEl = document.getElementById(`${u}-list`);
this._editorEl = document.getElementById(`${u}-editor`);
this._editModal = document.getElementById(`${u}-edit-modal`);
this._delModal = document.getElementById(`${u}-del-modal`);
this._createModal = document.getElementById(`${u}-create-modal`);
this._dropzone = document.getElementById(`${u}-dropzone`);
this._fileInput = document.getElementById(`${u}-fileinput`);
}
_css(u) {
return `<style>
#${u}{font-family:inherit;color:#111827;}
#${u} *{box-sizing:border-box;}
/* Header */
#${u} .jfm-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:.875rem;gap:.5rem;}
#${u} .jfm-header-left{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;}
#${u} .jfm-title{font-size:.9375rem;font-weight:600;color:#111827;}
#${u} .jfm-dir{font-size:.75rem;color:#6b7280;background:#f3f4f6;padding:.125rem .375rem;border-radius:.25rem;font-family:monospace;}
#${u} .jfm-header-right{display:flex;gap:.375rem;align-items:center;flex-shrink:0;}
/* Buttons */
#${u} .jfm-btn{display:inline-flex;align-items:center;gap:.25rem;padding:.4375rem .875rem;border-radius:.375rem;border:1px solid #d1d5db;background:#fff;color:#374151;font-size:.875rem;font-weight:500;cursor:pointer;transition:background .12s,border-color .12s,opacity .12s;line-height:1.25;}
#${u} .jfm-btn:hover:not(:disabled){background:#f9fafb;border-color:#9ca3af;}
#${u} .jfm-btn:focus-visible{outline:2px solid #3b82f6;outline-offset:1px;}
#${u} .jfm-btn:disabled{opacity:.5;cursor:not-allowed;}
#${u} .jfm-btn-sm{padding:.3125rem .625rem;font-size:.8125rem;}
#${u} .jfm-btn-primary{background:#3b82f6;border-color:#3b82f6;color:#fff;}
#${u} .jfm-btn-primary:hover:not(:disabled){background:#2563eb;border-color:#2563eb;}
#${u} .jfm-btn-danger{background:#ef4444;border-color:#ef4444;color:#fff;}
#${u} .jfm-btn-danger:hover:not(:disabled){background:#dc2626;border-color:#dc2626;}
#${u} .jfm-btn-ghost{background:transparent;border-color:transparent;color:#6b7280;}
#${u} .jfm-btn-ghost:hover:not(:disabled){background:#f3f4f6;color:#374151;}
#${u} .jfm-close-btn{display:flex;align-items:center;justify-content:center;width:2rem;height:2rem;border:none;background:none;color:#9ca3af;font-size:1.25rem;cursor:pointer;border-radius:.25rem;padding:0;line-height:1;}
#${u} .jfm-close-btn:hover{background:#f3f4f6;color:#374151;}
/* File list */
#${u} .jfm-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:.625rem;margin-bottom:1rem;min-height:5rem;}
#${u} .jfm-loading{grid-column:1/-1;display:flex;align-items:center;justify-content:center;gap:.5rem;padding:2rem;color:#6b7280;font-size:.875rem;}
#${u} .jfm-empty{grid-column:1/-1;text-align:center;padding:2.5rem 1rem;color:#9ca3af;}
#${u} .jfm-empty-icon{font-size:2.25rem;margin-bottom:.625rem;}
#${u} .jfm-empty-title{font-weight:600;color:#374151;margin:0 0 .25rem;}
#${u} .jfm-empty-sub{font-size:.875rem;margin:0;}
/* File cards */
#${u} .jfm-card{border:1px solid #e5e7eb;border-radius:.5rem;padding:.875rem;background:#fff;display:flex;flex-direction:column;gap:.5rem;transition:border-color .15s,box-shadow .15s;}
#${u} .jfm-card:hover{border-color:#93c5fd;box-shadow:0 2px 8px rgba(59,130,246,.1);}
#${u} .jfm-card.jfm-off{opacity:.6;}
#${u} .jfm-card-top{display:flex;justify-content:space-between;align-items:flex-start;gap:.5rem;}
#${u} .jfm-card-name{font-weight:600;font-size:.9375rem;word-break:break-word;color:#111827;flex:1;}
#${u} .jfm-card-meta{font-size:.75rem;color:#6b7280;display:flex;flex-direction:column;gap:.125rem;line-height:1.5;}
#${u} .jfm-card-actions{display:flex;gap:.375rem;padding-top:.5rem;border-top:1px solid #f3f4f6;margin-top:.125rem;}
#${u} .jfm-card-actions .jfm-btn{flex:1;justify-content:center;}
#${u} .jfm-card-actions .jfm-del{flex:0 0 auto;}
/* Toggle */
#${u} .jfm-toggle{display:flex;align-items:center;gap:.3125rem;font-size:.75rem;color:#6b7280;white-space:nowrap;flex-shrink:0;}
#${u} .jfm-toggle input[type=checkbox]{width:.9375rem;height:.9375rem;cursor:pointer;accent-color:#22c55e;margin:0;}
/* Upload zone */
#${u} .jfm-upload-wrap{margin-top:.25rem;}
#${u} input[type=file]#${u}-fileinput{position:absolute;left:-9999px;width:1px;height:1px;opacity:0;}
#${u} .jfm-dropzone{border:2px dashed #d1d5db;border-radius:.5rem;padding:1.25rem 1rem;text-align:center;cursor:pointer;transition:border-color .15s,background .15s;background:#f9fafb;user-select:none;}
#${u} .jfm-dropzone:hover,#${u} .jfm-dropzone:focus-visible,#${u} .jfm-dropzone.jfm-over{border-color:#3b82f6;background:#eff6ff;border-style:solid;outline:none;}
#${u} .jfm-drop-icon{font-size:1.75rem;display:block;margin-bottom:.375rem;}
#${u} .jfm-drop-primary{font-size:.875rem;color:#374151;margin:0 0 .25rem;}
#${u} .jfm-drop-hint{font-size:.75rem;color:#9ca3af;margin:0;}
/* Modals */
#${u} .jfm-modal{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:9999;display:flex;align-items:center;justify-content:center;padding:1rem;backdrop-filter:blur(1px);}
#${u} .jfm-modal[hidden]{display:none;}
#${u} .jfm-modal-box{background:#fff;border-radius:.5rem;box-shadow:0 20px 40px rgba(0,0,0,.15);display:flex;flex-direction:column;width:100%;max-width:440px;max-height:92vh;}
#${u} .jfm-modal-wide{max-width:880px;}
#${u} .jfm-modal-head{display:flex;justify-content:space-between;align-items:center;padding:.875rem 1.125rem;border-bottom:1px solid #e5e7eb;flex-shrink:0;gap:.5rem;}
#${u} .jfm-modal-title{font-weight:600;font-size:.9375rem;color:#111827;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
#${u} .jfm-modal-tools{display:flex;gap:.25rem;align-items:center;flex-shrink:0;}
#${u} .jfm-modal-body{padding:1.125rem;overflow-y:auto;flex:1;}
#${u} .jfm-modal-foot{display:flex;gap:.5rem;justify-content:flex-end;align-items:center;padding:.75rem 1.125rem;border-top:1px solid #e5e7eb;flex-shrink:0;background:#f9fafb;border-radius:0 0 .5rem .5rem;}
#${u} .jfm-stat{margin-right:auto;font-size:.75rem;color:#9ca3af;font-variant-numeric:tabular-nums;}
/* JSON editor */
#${u} .jfm-editor{display:block;width:100%;min-height:400px;height:58vh;max-height:64vh;resize:vertical;font-family:'Courier New',Consolas,ui-monospace,monospace;font-size:.8rem;line-height:1.55;padding:.75rem 1rem;border:none;border-radius:0;outline:none;white-space:pre;overflow:auto;color:#1e293b;background:#fafafa;tab-size:2;}
#${u} .jfm-err-bar{background:#fef2f2;border-bottom:1px solid #fecaca;color:#991b1b;font-size:.8125rem;padding:.5rem 1.125rem;flex-shrink:0;line-height:1.4;}
#${u} .jfm-err-bar[hidden]{display:none;}
/* Create form */
#${u} .jfm-field{margin-bottom:.875rem;}
#${u} .jfm-field:last-child{margin-bottom:0;}
#${u} .jfm-field label{display:block;font-size:.875rem;font-weight:500;color:#374151;margin-bottom:.3125rem;}
#${u} .jfm-field input{width:100%;padding:.4375rem .75rem;border:1px solid #d1d5db;border-radius:.375rem;font-size:.875rem;color:#111827;background:#fff;}
#${u} .jfm-field input:focus{outline:none;border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.12);}
#${u} .jfm-hint{display:block;font-size:.75rem;color:#9ca3af;margin-top:.25rem;}
#${u} .jfm-muted{font-size:.875rem;color:#6b7280;margin-top:.375rem;}
/* Spinner */
#${u} .jfm-spin{display:inline-block;width:.9rem;height:.9rem;border:2px solid #e5e7eb;border-top-color:#3b82f6;border-radius:50%;animation:jfm-spin-${u} .6s linear infinite;vertical-align:middle;}
@keyframes jfm-spin-${u}{to{transform:rotate(360deg);}}
</style>`;
}
// ── Event Binding ────────────────────────────────────────────────────
_bind() {
// Delegated clicks on the widget root
this._root.addEventListener('click', this._onClick.bind(this));
this._root.addEventListener('change', this._onChange.bind(this));
// Drag-and-drop on the dropzone
if (this._dropzone) {
this._dropzone.addEventListener('dragover', e => {
e.preventDefault();
this._dropzone.classList.add('jfm-over');
});
this._dropzone.addEventListener('dragleave', () => {
this._dropzone.classList.remove('jfm-over');
});
this._dropzone.addEventListener('drop', e => {
e.preventDefault();
this._dropzone.classList.remove('jfm-over');
const file = e.dataTransfer?.files[0];
if (file) this._uploadFile(file);
});
// Keyboard activation of drop zone
this._dropzone.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this._fileInput?.click();
}
});
}
// Modal backdrop clicks
[this._editModal, this._delModal, this._createModal].forEach(m => {
if (m) m.addEventListener('click', e => { if (e.target === m) this._closeAll(); });
});
// Editor: char count + Tab indent
if (this._editorEl) {
this._editorEl.addEventListener('input', () => this._updateStat());
this._editorEl.addEventListener('keydown', e => {
if (e.key === 'Tab') {
e.preventDefault();
const s = this._editorEl.selectionStart;
const end = this._editorEl.selectionEnd;
const v = this._editorEl.value;
this._editorEl.value = v.slice(0, s) + ' ' + v.slice(end);
this._editorEl.selectionStart = this._editorEl.selectionEnd = s + 2;
this._updateStat();
}
});
}
// Global keyboard shortcuts
document.addEventListener('keydown', this._keyHandler);
}
_onKey(e) {
const editOpen = this._editModal && !this._editModal.hidden;
const delOpen = this._delModal && !this._delModal.hidden;
const createOpen = this._createModal && !this._createModal.hidden;
if (e.key === 'Escape') {
if (editOpen) { this._closeEdit(); return; }
if (delOpen) { this._closeDel(); return; }
if (createOpen) { this._closeCreate(); return; }
}
if ((e.ctrlKey || e.metaKey) && e.key === 's' && editOpen) {
e.preventDefault();
this._doSave();
}
}
_onClick(e) {
const btn = e.target.closest('[data-jfm]');
if (!btn) return;
const action = btn.dataset.jfm;
switch (action) {
case 'refresh': this._loadList(); break;
case 'open-picker': this._fileInput?.click(); break;
case 'open-create': this._openCreate(); break;
case 'close-edit': this._closeEdit(); break;
case 'close-del': this._closeDel(); break;
case 'close-create': this._closeCreate(); break;
case 'fmt': this._formatJson(); break;
case 'validate': this._validateJson(); break;
case 'save': this._doSave(); break;
case 'confirm-del': this._doDelete(); break;
case 'do-create': this._doCreate(); break;
case 'edit-file': {
const card = btn.closest('[data-jfm-file]');
if (card) this._openEdit(card.dataset.jfmFile);
break;
}
case 'del-file': {
const card = btn.closest('[data-jfm-file]');
if (card) this._openDel(card.dataset.jfmFile);
break;
}
}
}
_onChange(e) {
// Toggle checkbox
if (e.target.classList.contains('jfm-toggle-cb')) {
const catName = e.target.dataset.cat;
const enabled = e.target.checked;
this._doToggle(catName, enabled, e.target);
}
// File input
if (e.target === this._fileInput) {
const file = e.target.files?.[0];
if (file) this._uploadFile(file);
e.target.value = '';
}
}
// ── API helper ───────────────────────────────────────────────────────
async _api(actionKey, params) {
const actionId = Object.prototype.hasOwnProperty.call(this.actions, actionKey) ? this.actions[actionKey] : undefined;
if (!actionId) throw new Error(`Action "${actionKey}" not configured`);
const body = { plugin_id: this.pluginId, action_id: actionId };
if (params !== undefined) body.params = params;
const r = await fetch('/api/v3/plugins/action', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!r.ok) throw new Error('Server error ' + r.status);
const ct = r.headers.get('content-type') || '';
if (!ct.includes('application/json')) {
const txt = await r.text();
throw new Error('Unexpected response: ' + txt.slice(0, 120));
}
return r.json();
}
// ── File List ────────────────────────────────────────────────────────
async _loadList() {
this._listEl.innerHTML = `<div class="jfm-loading"><span class="jfm-spin"></span> Loading…</div>`;
try {
const data = await this._api('list');
if (data.status !== 'success') throw new Error(data.message || 'Load failed');
this._renderList(data.files || []);
} catch (err) {
this._listEl.innerHTML = `
<div class="jfm-empty">
<div class="jfm-empty-icon">&#9888;</div>
<p class="jfm-empty-title">Failed to load files</p>
<p class="jfm-empty-sub">${this._esc(err.message)}</p>
</div>`;
}
}
_renderList(files) {
if (!files.length) {
this._listEl.innerHTML = `
<div class="jfm-empty">
<div class="jfm-empty-icon">&#128193;</div>
<p class="jfm-empty-title">No files yet</p>
<p class="jfm-empty-sub">Upload or create a JSON file to get started</p>
</div>`;
return;
}
this._listEl.innerHTML = files.map(f => this._card(f)).join('');
}
_card(f) {
const enabled = f.enabled !== false;
const displayName = this._esc(f.display_name || f.filename);
const filename = this._esc(f.filename);
const catName = this.toggleKey ? this._esc(f[this.toggleKey] || '') : '';
const showToggle = !!(this.actions.toggle && this.toggleKey && f[this.toggleKey]);
const hasEdit = !!this.actions.get && !!this.actions.save;
const hasDelete = !!this.actions.delete;
return `
<div class="jfm-card${enabled ? '' : ' jfm-off'}" data-jfm-file="${filename}">
<div class="jfm-card-top">
<span class="jfm-card-name" title="${filename}">${displayName}</span>
${showToggle ? `
<label class="jfm-toggle" title="${enabled ? 'Enabled — click to disable' : 'Disabled — click to enable'}">
<input type="checkbox" class="jfm-toggle-cb" data-cat="${catName}" ${enabled ? 'checked' : ''}>
<span>${enabled ? 'On' : 'Off'}</span>
</label>` : ''}
</div>
<div class="jfm-card-meta">
<span>&#128196; ${filename}</span>
<span>&#128202; ${f.entry_count ?? 0} entries &middot; ${this._fmtSize(f.size || 0)}</span>
<span>&#128337; ${this._fmtDate(f.modified)}</span>
</div>
<div class="jfm-card-actions">
${hasEdit ? `<button type="button" class="jfm-btn jfm-btn-sm" data-jfm="edit-file">&#9998; Edit</button>` : ''}
${hasDelete ? `<button type="button" class="jfm-btn jfm-btn-danger jfm-btn-sm jfm-del" data-jfm="del-file" title="Delete file">&#128465;</button>` : ''}
</div>
</div>`;
}
// ── Edit flow ────────────────────────────────────────────────────────
async _openEdit(filename) {
this._editFile = filename;
document.getElementById(`${this._uid}-edit-title`).textContent = `Edit: ${filename}`;
this._clearErr();
this._editorEl.value = 'Loading…';
this._updateStat();
this._editModal.hidden = false;
try {
const data = await this._api('get', { filename });
if (data.status !== 'success') throw new Error(data.message || 'Load failed');
this._editorEl.value = JSON.stringify(data.content, null, 2);
this._updateStat();
this._editorEl.focus();
this._editorEl.setSelectionRange(0, 0);
this._editorEl.scrollTop = 0;
} catch (err) {
this._showErr('Failed to load file: ' + err.message);
this._editorEl.value = '';
}
}
_closeEdit() {
if (this._editModal) this._editModal.hidden = true;
this._editFile = null;
this._clearErr();
}
_formatJson() {
try {
const parsed = JSON.parse(this._editorEl.value);
this._editorEl.value = JSON.stringify(parsed, null, 2);
this._updateStat();
this._clearErr();
} catch (err) {
this._showErr('Invalid JSON — ' + err.message);
}
}
_validateJson() {
try {
const parsed = JSON.parse(this._editorEl.value);
const n = (typeof parsed === 'object' && parsed !== null) ? Object.keys(parsed).length : '?';
this._clearErr();
this._notify(`Valid JSON — ${n} top-level keys`, 'success');
} catch (err) {
this._showErr('Invalid JSON — ' + err.message);
}
}
async _doSave() {
if (!this._editFile) return;
let contentStr;
try {
const parsed = JSON.parse(this._editorEl.value);
contentStr = JSON.stringify(parsed, null, 2);
} catch (err) {
this._showErr('Cannot save — fix JSON first: ' + err.message);
return;
}
const btn = document.getElementById(`${this._uid}-save-btn`);
this._busy(btn, 'Saving…');
try {
const data = await this._api('save', { filename: this._editFile, content: contentStr });
if (data.status !== 'success') throw new Error(data.message || 'Save failed');
this._notify('File saved', 'success');
this._closeEdit();
this._loadList();
} catch (err) {
this._showErr('Save failed: ' + err.message);
} finally {
this._idle(btn, 'Save');
}
}
// ── Delete flow ──────────────────────────────────────────────────────
_openDel(filename) {
this._deleteFile = filename;
const el = document.getElementById(`${this._uid}-del-name`);
if (el) el.textContent = filename;
if (this._delModal) this._delModal.hidden = false;
}
_closeDel() {
if (this._delModal) this._delModal.hidden = true;
this._deleteFile = null;
}
async _doDelete() {
if (!this._deleteFile) return;
const btn = document.getElementById(`${this._uid}-del-btn`);
this._busy(btn, 'Deleting…');
try {
const data = await this._api('delete', { filename: this._deleteFile });
if (data.status !== 'success') throw new Error(data.message || 'Delete failed');
this._notify('File deleted', 'success');
this._closeDel();
this._loadList();
} catch (err) {
this._notify('Delete failed: ' + err.message, 'error');
} finally {
this._idle(btn, 'Delete');
}
}
// ── Create flow ──────────────────────────────────────────────────────
_openCreate() {
if (!this._createModal) return;
this.createFields.forEach(f => {
const el = document.getElementById(`${this._uid}-cf-${f.key}`);
if (el) el.value = '';
});
this._createModal.hidden = false;
const first = this.createFields[0];
if (first) document.getElementById(`${this._uid}-cf-${first.key}`)?.focus();
}
_closeCreate() {
if (this._createModal) this._createModal.hidden = true;
}
async _doCreate() {
const params = {};
for (const f of this.createFields) {
const el = document.getElementById(`${this._uid}-cf-${f.key}`);
const val = (el?.value || '').trim();
// display_name may be blank — auto-derived from category_name below
if (!val && f.key !== 'display_name') {
this._notify(`"${f.label}" is required`, 'error');
el?.focus();
return;
}
if (f.pattern && val && el && el.validity.patternMismatch) {
this._notify(`"${f.label}" format is invalid`, 'error');
el?.focus();
return;
}
if (val) params[f.key] = val;
}
// Auto-derive display_name from category_name when left blank
if (!params.display_name && params.category_name) {
params.display_name = params.category_name.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
}
const btn = document.getElementById(`${this._uid}-create-btn`);
this._busy(btn, 'Creating…');
try {
const data = await this._api('create', params);
if (data.status !== 'success') throw new Error(data.message || 'Create failed');
this._notify('File created', 'success');
this._closeCreate();
this._loadList();
} catch (err) {
this._notify('Create failed: ' + err.message, 'error');
} finally {
this._idle(btn, 'Create');
}
}
// ── Upload ───────────────────────────────────────────────────────────
async _uploadFile(file) {
if (!file.name.endsWith('.json')) {
this._notify('Please select a .json file', 'error');
return;
}
let content;
try {
content = await file.text();
JSON.parse(content); // client-side validation
} catch (err) {
this._notify('Invalid JSON: ' + err.message, 'error');
return;
}
if (this._dropzone) this._dropzone.style.opacity = '.5';
try {
const data = await this._api('upload', { filename: file.name, content });
if (data.status !== 'success') throw new Error(data.message || 'Upload failed');
this._notify(`"${file.name}" uploaded`, 'success');
this._loadList();
} catch (err) {
this._notify('Upload failed: ' + err.message, 'error');
} finally {
if (this._dropzone) this._dropzone.style.opacity = '';
}
}
// ── Toggle ───────────────────────────────────────────────────────────
async _doToggle(catName, enabled, checkbox) {
checkbox.disabled = true;
try {
const params = { enabled };
if (this.toggleKey) params[this.toggleKey] = catName;
const data = await this._api('toggle', params);
if (data.status !== 'success') throw new Error(data.message || 'Toggle failed');
this._notify(enabled ? 'Category enabled' : 'Category disabled', 'success');
this._loadList();
} catch (err) {
this._notify('Toggle failed: ' + err.message, 'error');
checkbox.checked = !enabled; // revert
checkbox.disabled = false;
}
}
// ── Helpers ──────────────────────────────────────────────────────────
_closeAll() {
this._closeEdit();
this._closeDel();
this._closeCreate();
}
_updateStat() {
const v = this._editorEl?.value || '';
const lines = v ? v.split('\n').length : 0;
const el = document.getElementById(`${this._uid}-charcount`);
if (el) el.textContent = `${lines.toLocaleString()} lines · ${v.length.toLocaleString()} chars`;
}
_showErr(msg) {
const el = document.getElementById(`${this._uid}-edit-err`);
if (el) { el.textContent = msg; el.hidden = false; }
}
_clearErr() {
const el = document.getElementById(`${this._uid}-edit-err`);
if (el) { el.textContent = ''; el.hidden = true; }
}
_notify(msg, type) {
if (typeof window.showNotification === 'function') {
window.showNotification(msg, type || 'info');
} else {
console.info(`[JsonFileManager] ${type || 'info'}: ${msg}`);
}
}
_busy(btn, label) {
if (!btn) return;
btn._jfmOrigText = btn.textContent;
btn.disabled = true;
btn.textContent = '';
const spin = document.createElement('span');
spin.className = 'jfm-spin';
btn.appendChild(spin);
btn.appendChild(document.createTextNode(' ' + label));
}
_idle(btn, label) {
if (!btn) return;
btn.disabled = false;
btn.textContent = btn._jfmOrigText !== undefined ? btn._jfmOrigText : label;
delete btn._jfmOrigText;
}
_esc(str) {
const d = document.createElement('div');
d.textContent = String(str ?? '');
return d.innerHTML;
}
_fmtSize(bytes) {
if (!bytes) return '0 B';
const i = Math.min(Math.floor(Math.log2(bytes + 1) / 10), 2);
const unit = ['B', 'KB', 'MB'][i];
const val = bytes / Math.pow(1024, i);
return (i ? val.toFixed(1) : val) + ' ' + unit;
}
_fmtDate(str) {
if (!str) return '—';
try {
return new Date(str).toLocaleDateString(undefined, {
month: 'short', day: 'numeric', year: 'numeric'
});
} catch { return str; }
}
}
// ── Widget registry integration ──────────────────────────────────────────
window.JsonFileManager = JsonFileManager;
if (typeof window.LEDMatrixWidgets !== 'undefined') {
window.LEDMatrixWidgets.register('json-file-manager', {
name: 'JSON File Manager',
version: '1.0.0',
render(container, config, _value, options) {
new JsonFileManager(container, config || {}, options?.pluginId || '');
},
getValue() { return null; },
setValue() {}
});
console.log('[JsonFileManager] Registered with LEDMatrixWidgets');
} else {
console.log('[JsonFileManager] Loaded (LEDMatrixWidgets registry not available)');
}
})();

View File

@@ -0,0 +1,797 @@
/**
* Plugin File Manager Widget
*
* Reusable inline file manager for plugins that manage files via the
* web_ui_actions system. Driven entirely by x-widget-config in the schema —
* no external HTML file or iframe needed.
*
* Any plugin can adopt this widget by:
* 1. Defining web_ui_actions in manifest.json (list, get, save, upload,
* delete, create, toggle) with ui_hidden: true
* 2. Adding x-widget: "plugin-file-manager" to a field in config_schema.json
* with x-widget-config mapping the action IDs
*
* Schema example:
* {
* "file_manager": {
* "type": "null",
* "title": "Data Files",
* "x-widget": "plugin-file-manager",
* "x-widget-config": {
* "actions": {
* "list": "list-files",
* "get": "get-file",
* "save": "save-file",
* "upload": "upload-file",
* "delete": "delete-file",
* "create": "create-file",
* "toggle": "toggle-category"
* },
* "upload_hint": "JSON files with day numbers 1365 as keys",
* "directory_label": "of_the_day/",
* "create_fields": [
* { "key": "category_name", "label": "Category Name",
* "placeholder": "e.g., my_words", "pattern": "^[a-z0-9_]+$",
* "hint": "Lowercase letters, numbers, underscores" },
* { "key": "display_name", "label": "Display Name",
* "placeholder": "e.g., My Words", "hint": "Optional — auto-generated if blank" }
* ]
* }
* }
* }
*
* @module PluginFileManagerWidget
*/
(function () {
'use strict';
if (typeof window.LEDMatrixWidgets === 'undefined') {
console.error('[PluginFileManager] LEDMatrixWidgets registry not found.');
return;
}
// ─── Inject widget-scoped styles once ────────────────────────────────────
if (!document.getElementById('pfm-styles')) {
const style = document.createElement('style');
style.id = 'pfm-styles';
style.textContent = `
.pfm-root { font-family: inherit; }
.pfm-header { display:flex; align-items:center; justify-content:space-between;
margin-bottom:.75rem; }
.pfm-title { font-size:1rem; font-weight:600; color:#111827; }
.pfm-dir { font-size:.75rem; color:#6b7280; margin-top:.125rem; }
.pfm-upload { border:2px dashed #d1d5db; border-radius:.5rem; padding:1.25rem;
text-align:center; cursor:pointer; transition:border-color .15s,background .15s; }
.pfm-upload:hover,.pfm-upload.dragover { border-color:#3b82f6; background:#eff6ff; }
.pfm-upload p { font-size:.875rem; color:#4b5563; margin:.25rem 0 0; }
.pfm-upload small { font-size:.75rem; color:#9ca3af; }
.pfm-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(260px,1fr));
gap:.75rem; margin-top:.75rem; }
.pfm-card { border:1px solid #e5e7eb; border-radius:.5rem; padding:.875rem;
background:#fff; transition:box-shadow .15s; }
.pfm-card:hover { box-shadow:0 1px 4px rgba(0,0,0,.1); }
.pfm-card.disabled { opacity:.55; }
.pfm-card-top { display:flex; align-items:center; justify-content:space-between;
margin-bottom:.5rem; }
.pfm-card-icon { width:2rem; height:2rem; background:#f3f4f6; border-radius:.375rem;
display:flex; align-items:center; justify-content:center;
color:#6b7280; font-size:1rem; }
.pfm-card-name { font-weight:600; color:#111827; font-size:.875rem; margin:.375rem 0 .125rem; }
.pfm-card-meta { font-size:.75rem; color:#6b7280; line-height:1.5; }
.pfm-card-actions { display:flex; gap:.375rem; margin-top:.625rem; }
.pfm-btn { display:inline-flex; align-items:center; gap:.25rem; padding:.375rem .75rem;
border-radius:.375rem; font-size:.8125rem; font-weight:500;
border:none; cursor:pointer; transition:background .15s; }
.pfm-btn-primary { background:#2563eb; color:#fff; flex:1; justify-content:center; }
.pfm-btn-primary:hover { background:#1d4ed8; }
.pfm-btn-danger { background:#dc2626; color:#fff; }
.pfm-btn-danger:hover { background:#b91c1c; }
.pfm-btn-secondary { background:#f3f4f6; color:#374151; border:1px solid #d1d5db; }
.pfm-btn-secondary:hover { background:#e5e7eb; }
.pfm-btn-sm { padding:.25rem .5rem; font-size:.75rem; }
.pfm-btn-create { background:#059669; color:#fff; }
.pfm-btn-create:hover { background:#047857; }
.pfm-toggle-wrap { display:flex; align-items:center; gap:.375rem; }
.pfm-toggle-label { font-size:.75rem; color:#6b7280; }
.pfm-toggle-cb { position:relative; display:inline-block; width:2rem; height:1.125rem; }
.pfm-toggle-cb input { opacity:0; width:0; height:0; }
.pfm-toggle-slider { position:absolute; inset:0; background:#d1d5db; border-radius:9999px;
cursor:pointer; transition:background .2s; }
.pfm-toggle-slider:before { content:''; position:absolute; height:.75rem; width:.75rem;
left:.1875rem; bottom:.1875rem; background:#fff;
border-radius:50%; transition:transform .2s; }
.pfm-toggle-cb input:checked + .pfm-toggle-slider { background:#10b981; }
.pfm-toggle-cb input:checked + .pfm-toggle-slider:before { transform:translateX(.875rem); }
.pfm-empty { text-align:center; padding:2rem; color:#9ca3af; }
.pfm-empty i { font-size:2rem; margin-bottom:.5rem; display:block; }
/* Modal */
.pfm-overlay { position:fixed; inset:0; background:rgba(0,0,0,.5);
display:flex; align-items:flex-start; justify-content:center;
z-index:9999; padding:2rem 1rem; overflow-y:auto; }
.pfm-modal { background:#fff; border-radius:.75rem; width:100%; max-width:56rem;
box-shadow:0 20px 50px rgba(0,0,0,.3); margin:auto; }
.pfm-modal-header { display:flex; align-items:center; justify-content:space-between;
padding:1rem 1.25rem; border-bottom:1px solid #e5e7eb; }
.pfm-modal-title { font-size:1rem; font-weight:600; color:#111827; }
.pfm-modal-body { padding:1.25rem; overflow-y:auto; max-height:70vh; }
.pfm-modal-footer { display:flex; justify-content:flex-end; gap:.5rem;
padding:.875rem 1.25rem; border-top:1px solid #e5e7eb;
background:#f9fafb; border-radius:0 0 .75rem .75rem; }
/* Entry table */
.pfm-table-wrap { overflow-x:auto; }
.pfm-table { width:100%; border-collapse:collapse; font-size:.8125rem; }
.pfm-table th { background:#f9fafb; text-align:left; padding:.5rem .625rem;
font-weight:600; color:#374151; border-bottom:1px solid #e5e7eb;
white-space:nowrap; position:sticky; top:0; }
.pfm-table td { padding:.375rem .625rem; border-bottom:1px solid #f3f4f6;
vertical-align:top; }
.pfm-table tr.today-row td { background:#fef9c3; }
.pfm-table td input, .pfm-table td textarea {
width:100%; border:1px solid #d1d5db; border-radius:.25rem;
padding:.25rem .375rem; font-size:.8125rem; font-family:inherit;
resize:vertical; background:#fff; }
.pfm-table td input:focus, .pfm-table td textarea:focus {
outline:none; border-color:#3b82f6; }
.pfm-day-col { width:3rem; text-align:center; font-weight:600;
color:#6b7280; white-space:nowrap; }
.pfm-pagination { display:flex; align-items:center; justify-content:space-between;
margin-top:.75rem; font-size:.8125rem; color:#6b7280; }
.pfm-page-jump { display:flex; align-items:center; gap:.375rem; font-size:.8125rem; }
.pfm-page-jump input { width:3.5rem; padding:.25rem .375rem; border:1px solid #d1d5db;
border-radius:.25rem; text-align:center; }
/* Form in create modal */
.pfm-field { margin-bottom:.875rem; }
.pfm-field label { display:block; font-size:.875rem; font-weight:500;
color:#374151; margin-bottom:.25rem; }
.pfm-field input { width:100%; padding:.4rem .625rem; border:1px solid #d1d5db;
border-radius:.375rem; font-size:.875rem; }
.pfm-field input:focus { outline:none; border-color:#3b82f6; }
.pfm-field-hint { font-size:.75rem; color:#9ca3af; margin-top:.2rem; }
.pfm-field-error { font-size:.75rem; color:#dc2626; margin-top:.2rem; }
/* Delete danger box */
.pfm-danger-box { background:#fef2f2; border:1px solid #fecaca;
border-radius:.5rem; padding:.875rem; font-size:.875rem;
color:#991b1b; }
`;
document.head.appendChild(style);
}
// ─── Safe HTML helper ─────────────────────────────────────────────────────
/**
* Parse html in a sandboxed DOMParser document (scripts never execute) and
* replace target's children with the result. All dynamic values in html
* must be escaped by the caller before passing here.
*/
function safeSetHTML(target, html) {
target.textContent = '';
// createContextualFragment parses html relative to the document context
// without executing scripts — a widely recognised safe insertion method.
const frag = document.createRange().createContextualFragment(html);
target.appendChild(frag);
}
// ─── Per-instance state ───────────────────────────────────────────────────
const _state = new Map(); // fieldId → { pluginId, actions, createFields, files, page, entriesPerPage, modal }
function getState(fieldId) {
if (!_state.has(fieldId)) _state.set(fieldId, {
pluginId: '', actions: {}, createFields: [], uploadHint: '',
directoryLabel: '', files: [], page: 1, entriesPerPage: 20,
currentModal: null
});
return _state.get(fieldId);
}
// ─── API helper ───────────────────────────────────────────────────────────
async function callAction(pluginId, actionId, params = {}) {
const resp = await fetch('/api/v3/plugins/action', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plugin_id: pluginId, action_id: actionId, params })
});
return resp.json();
}
function notify(msg, type) {
if (window.showNotification) window.showNotification(msg, type);
else console.log(`[PFM][${type}] ${msg}`);
}
function escHtml(s) {
const d = document.createElement('div');
d.textContent = String(s ?? '');
return d.innerHTML;
}
function formatSize(bytes) {
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
return (bytes / 1024).toFixed(2) + ' KB';
}
function formatDate(iso) {
try { return new Date(iso).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' }); }
catch { return iso; }
}
// ─── Core: load files ─────────────────────────────────────────────────────
async function loadFiles(fieldId) {
const st = getState(fieldId);
const root = document.getElementById(`${fieldId}_pfm`);
if (!root) return;
const grid = root.querySelector('.pfm-grid');
if (grid) safeSetHTML(grid, '<div class="pfm-empty"><i class="fas fa-spinner fa-spin"></i>Loading…</div>');
const data = await callAction(st.pluginId, st.actions.list).catch(() => null);
if (!data || data.status !== 'success') {
if (grid) safeSetHTML(grid, '<div class="pfm-empty"><i class="fas fa-exclamation-circle"></i>Failed to load files.</div>');
return;
}
st.files = data.files || [];
renderCards(fieldId);
}
// ─── Card grid ────────────────────────────────────────────────────────────
function renderCards(fieldId) {
const st = getState(fieldId);
const root = document.getElementById(`${fieldId}_pfm`);
if (!root) return;
const grid = root.querySelector('.pfm-grid');
if (!grid) return;
if (!st.files.length) {
safeSetHTML(grid, '<div class="pfm-empty"><i class="fas fa-folder-open"></i>No files yet. Create or upload one.</div>');
return;
}
// Remove any existing delegated listener before re-render
if (st._gridClickHandler) grid.removeEventListener('click', st._gridClickHandler);
if (st._gridChangeHandler) grid.removeEventListener('change', st._gridChangeHandler);
// Event delegation: handles edit/delete/toggle via data attributes so
// filenames and category names are never interpolated into JS string literals.
st._gridClickHandler = function(e) {
const btn = e.target.closest('[data-pfm-action]');
if (!btn) return;
const action = btn.dataset.pfmAction;
const fId = btn.dataset.pfmField;
if (action === 'edit') window._pfmOpenEdit(fId, btn.dataset.pfmFile);
if (action === 'delete') window._pfmOpenDelete(fId, btn.dataset.pfmFile);
};
st._gridChangeHandler = function(e) {
const inp = e.target.closest('[data-pfm-action="toggle"]');
if (!inp) return;
window._pfmToggle(inp.dataset.pfmField, inp.dataset.pfmCategory, inp.checked);
};
grid.addEventListener('click', st._gridClickHandler);
grid.addEventListener('change', st._gridChangeHandler);
// Build cards with DOM methods so no user-derived data flows through innerHTML.
grid.textContent = '';
const frag = document.createDocumentFragment();
st.files.forEach(function(f) {
const card = document.createElement('div');
card.className = 'pfm-card' + (f.enabled === false ? ' disabled' : '');
card.dataset.filename = f.filename;
card.dataset.category = f.category_name;
// Top row: label + optional toggle
const top = document.createElement('div');
top.className = 'pfm-card-top';
const lbl = document.createElement('span');
lbl.className = 'pfm-toggle-label';
lbl.textContent = f.enabled !== false ? 'Enabled' : 'Disabled';
top.appendChild(lbl);
if (st.actions.toggle) {
const tglLabel = document.createElement('label');
tglLabel.className = 'pfm-toggle-cb';
tglLabel.title = f.enabled !== false ? 'Click to disable' : 'Click to enable';
const tglInput = document.createElement('input');
tglInput.type = 'checkbox';
tglInput.checked = f.enabled !== false;
tglInput.dataset.pfmAction = 'toggle';
tglInput.dataset.pfmField = fieldId;
tglInput.dataset.pfmCategory = f.category_name;
const tglSlider = document.createElement('span');
tglSlider.className = 'pfm-toggle-slider';
tglLabel.appendChild(tglInput);
tglLabel.appendChild(tglSlider);
top.appendChild(tglLabel);
}
card.appendChild(top);
// Icon (static markup)
const icon = document.createElement('div');
icon.className = 'pfm-card-icon';
icon.innerHTML = '<i class="fas fa-file-code"></i>';
card.appendChild(icon);
// Name & meta — textContent avoids any HTML injection
const name = document.createElement('div');
name.className = 'pfm-card-name';
name.textContent = f.display_name || f.filename;
card.appendChild(name);
const meta = document.createElement('div');
meta.className = 'pfm-card-meta';
meta.appendChild(document.createTextNode(f.filename));
meta.appendChild(document.createElement('br'));
if (f.entry_count != null) {
meta.appendChild(document.createTextNode(f.entry_count + ' entries · ' + formatSize(f.size)));
}
meta.appendChild(document.createElement('br'));
meta.appendChild(document.createTextNode(formatDate(f.modified)));
card.appendChild(meta);
// Action buttons
const actions = document.createElement('div');
actions.className = 'pfm-card-actions';
if (st.actions.get && st.actions.save) {
const editBtn = document.createElement('button');
editBtn.className = 'pfm-btn pfm-btn-primary';
editBtn.dataset.pfmAction = 'edit';
editBtn.dataset.pfmField = fieldId;
editBtn.dataset.pfmFile = f.filename;
editBtn.innerHTML = '<i class="fas fa-edit"></i> Edit'; // static
actions.appendChild(editBtn);
}
if (st.actions.delete) {
const delBtn = document.createElement('button');
delBtn.className = 'pfm-btn pfm-btn-danger pfm-btn-sm';
delBtn.dataset.pfmAction = 'delete';
delBtn.dataset.pfmField = fieldId;
delBtn.dataset.pfmFile = f.filename;
delBtn.innerHTML = '<i class="fas fa-trash"></i>'; // static
actions.appendChild(delBtn);
}
card.appendChild(actions);
frag.appendChild(card);
});
grid.appendChild(frag);
}
// ─── Edit modal ───────────────────────────────────────────────────────────
window._pfmOpenEdit = async function (fieldId, filename) {
const st = getState(fieldId);
const overlay = createOverlay(fieldId);
// Build modal using DOM methods so filename never enters a JS string literal.
const modal = document.createElement('div');
modal.className = 'pfm-modal';
safeSetHTML(modal, `
<div class="pfm-modal-header">
<span class="pfm-modal-title"><i class="fas fa-edit mr-2"></i>${escHtml(filename)}</span>
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_modal_close">
<i class="fas fa-times"></i>
</button>
</div>
<div class="pfm-modal-body" id="${escHtml(fieldId)}_edit_body">
<div class="pfm-empty"><i class="fas fa-spinner fa-spin"></i>Loading…</div>
</div>
<div class="pfm-modal-footer">
<button class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_modal_cancel">Cancel</button>
<button class="pfm-btn pfm-btn-primary" id="${escHtml(fieldId)}_save_btn">
<i class="fas fa-save mr-1"></i>Save
</button>
</div>`;
overlay.appendChild(modal);
// Bind events after DOM insertion — filename captured in closure, not in HTML.
modal.querySelector(`#${CSS.escape(fieldId)}_modal_close`).addEventListener('click', () => window._pfmCloseModal(fieldId));
modal.querySelector(`#${CSS.escape(fieldId)}_modal_cancel`).addEventListener('click', () => window._pfmCloseModal(fieldId));
modal.querySelector(`#${CSS.escape(fieldId)}_save_btn`).addEventListener('click', () => window._pfmSave(fieldId, filename));
const data = await callAction(st.pluginId, st.actions.get, { filename }).catch(() => null);
const body = document.getElementById(`${fieldId}_edit_body`);
if (!data || data.status !== 'success' || !body) {
if (body) safeSetHTML(body, '<div class="pfm-empty" style="color:#dc2626">Failed to load file.</div>');
return;
}
const content = data.content || data.data || {};
st._editFilename = filename;
if (isTabular(content)) {
// Table path: track cell edits live in _editData
st._editData = content;
renderEntryTable(fieldId, body, content);
} else {
// Textarea path: _editData stays null; save() reads from the <textarea>
st._editData = null;
safeSetHTML(body, `
<textarea id="${escHtml(fieldId)}_json_ta" rows="20"
style="width:100%;font-family:monospace;font-size:.75rem;border:1px solid #d1d5db;border-radius:.375rem;padding:.5rem;"
>${escHtml(JSON.stringify(content, null, 2))}</textarea>
<div id="${escHtml(fieldId)}_json_err" style="color:#dc2626;font-size:.75rem;margin-top:.25rem;"></div>`;
}
};
function isTabular(data) {
if (typeof data !== 'object' || Array.isArray(data)) return false;
const keys = Object.keys(data);
if (!keys.length) return false;
const first = data[keys[0]];
if (typeof first !== 'object' || Array.isArray(first)) return false;
const entryKeys = Object.keys(first);
return entryKeys.length > 0 && entryKeys.length <= 8;
}
function renderEntryTable(fieldId, container, content) {
const st = getState(fieldId);
const entries = Object.entries(content).sort((a, b) => parseInt(a[0]) - parseInt(b[0]));
if (!entries.length) { container.textContent = 'No entries.'; return; }
const cols = Object.keys(entries[0][1]);
const MS_PER_DAY = 86400 * 1000; // eslint-disable-line no-magic-numbers -- 86400s/day is not magic
const todayDoy = Math.ceil((new Date() - new Date(new Date().getFullYear(), 0, 0)) / MS_PER_DAY);
const total = entries.length;
const perPage = st.entriesPerPage;
function buildPage(page) {
const start = (page - 1) * perPage; // eslint-disable-line no-magic-numbers
const pageEntries = entries.slice(start, start + perPage);
const totalPages = Math.ceil(total / perPage);
safeSetHTML(container, `
<div class="pfm-table-info" style="font-size:.75rem;color:#6b7280;margin-bottom:.375rem;">
${total} entries total
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm" style="margin-left:.5rem"
onclick="(function(){const targetPage=Math.ceil(${todayDoy}/${perPage});window._pfmTablePage('${fieldId}',targetPage);setTimeout(function(){const row=document.querySelector('tr[data-day=\\'${todayDoy}\\']');if(row)row.scrollIntoView({block:'center'});},60);})()">
<i class="fas fa-calendar-day"></i> Jump to today (day ${todayDoy})
</button>
</div>
<div id="${fieldId}_tbl_wrap" class="pfm-table-wrap" style="max-height:52vh;overflow-y:auto;">
<table class="pfm-table">
<thead>
<tr>
<th class="pfm-day-col">Day</th>
${cols.map(c => `<th>${escHtml(c.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()))}</th>`).join('')}
</tr>
</thead>
<tbody>
${pageEntries.map(([day, val]) => `
<tr data-day="${day}" class="${parseInt(day) === todayDoy ? 'today-row' : ''}">
<td class="pfm-day-col" style="user-select:none;">${escHtml(day)}</td>
${cols.map(col => {
const v = val[col] ?? '';
const isLong = String(v).length > 60 || col === 'description' || col === 'definition' || col === 'content';
return isLong
? `<td><textarea data-day="${day}" data-col="${escHtml(col)}" rows="2"
oninput="window._pfmCellEdit('${fieldId}','${day}','${escHtml(col)}',this.value)"
>${escHtml(String(v))}</textarea></td>`
: `<td><input type="text" data-day="${day}" data-col="${escHtml(col)}"
value="${escHtml(String(v))}"
oninput="window._pfmCellEdit('${fieldId}','${day}','${escHtml(col)}',this.value)"></td>`;
}).join('')}
</tr>`).join('')}
</tbody>
</table>
</div>
<div class="pfm-pagination">
<span>Page ${page} of ${totalPages}</span>
<div class="pfm-page-jump">
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm"
${page <= 1 ? 'disabled' : ''}
onclick="window._pfmTablePage('${fieldId}',${page - 1})"> Prev</button>
<span>Go to</span>
<input type="number" min="1" max="${totalPages}" value="${page}"
onchange="window._pfmTablePage('${fieldId}',+this.value)">
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm"
${page >= totalPages ? 'disabled' : ''}
onclick="window._pfmTablePage('${fieldId}',${page + 1})">Next </button>
</div>
</div>`;
st._tablePage = page;
st._tableEntries = entries;
st._tableCols = cols;
}
// Store buildPage in per-instance state so multiple instances don't
// clobber each other's pagination via a shared global.
st._buildPage = buildPage;
buildPage(st._tablePage || 1);
}
// Global dispatcher — resolves the per-instance buildPage from state so
// multiple plugin-file-manager instances don't clobber each other.
window._pfmTablePage = function (fId, p) {
const s = getState(fId);
if (s._buildPage) {
const total = s._tableEntries ? s._tableEntries.length : 0;
const totalP = Math.ceil(total / s.entriesPerPage) || 1;
s._buildPage(Math.max(1, Math.min(p, totalP)));
}
};
window._pfmCellEdit = function (fieldId, day, col, value) {
const st = getState(fieldId);
if (st._editData && st._editData[day]) st._editData[day][col] = value;
};
window._pfmSave = async function (fieldId, filename) {
const st = getState(fieldId);
const saveBtn = document.getElementById(`${fieldId}_save_btn`);
let content;
// Try getting from inline table data first, then textarea fallback
if (st._editData) {
content = st._editData;
} else {
const ta = document.getElementById(`${fieldId}_json_ta`);
if (!ta) return;
try { content = JSON.parse(ta.value); }
catch (e) {
const errEl = document.getElementById(`${fieldId}_json_err`);
if (errEl) errEl.textContent = 'Invalid JSON: ' + e.message;
return;
}
}
if (saveBtn) { saveBtn.disabled = true; (function(b){b.textContent='';const i=document.createElement('i');i.className='fas fa-spinner fa-spin mr-1';b.appendChild(i);b.appendChild(document.createTextNode('Saving…'));})(saveBtn); }
const result = await callAction(st.pluginId, st.actions.save, {
filename, content: JSON.stringify(content)
}).catch(() => ({ status: 'error', message: 'Network error' }));
if (saveBtn) { saveBtn.disabled = false; (function(b){b.textContent='';const i=document.createElement('i');i.className='fas fa-save mr-1';b.appendChild(i);b.appendChild(document.createTextNode('Save'));})(saveBtn); }
if (result.status === 'success') {
notify('File saved successfully', 'success');
window._pfmCloseModal(fieldId);
await loadFiles(fieldId);
} else {
notify('Save failed: ' + (result.message || 'Unknown error'), 'error');
}
};
// ─── Delete modal ─────────────────────────────────────────────────────────
window._pfmOpenDelete = function (fieldId, filename) {
const overlay = createOverlay(fieldId);
const modal = document.createElement('div');
modal.className = 'pfm-modal';
modal.style.maxWidth = '28rem';
safeSetHTML(modal, `
<div class="pfm-modal-header">
<span class="pfm-modal-title"><i class="fas fa-trash mr-2"></i>Delete File</span>
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_del_close">
<i class="fas fa-times"></i>
</button>
</div>
<div class="pfm-modal-body">
<div class="pfm-danger-box">
<strong>${escHtml(filename)}</strong> will be permanently deleted and removed
from the plugin configuration. This cannot be undone.
</div>
</div>
<div class="pfm-modal-footer">
<button class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_del_cancel">Cancel</button>
<button class="pfm-btn pfm-btn-danger" id="${escHtml(fieldId)}_del_confirm">
<i class="fas fa-trash mr-1"></i>Delete
</button>
</div>`;
overlay.appendChild(modal);
modal.querySelector(`#${CSS.escape(fieldId)}_del_close`).addEventListener('click', () => window._pfmCloseModal(fieldId));
modal.querySelector(`#${CSS.escape(fieldId)}_del_cancel`).addEventListener('click', () => window._pfmCloseModal(fieldId));
modal.querySelector(`#${CSS.escape(fieldId)}_del_confirm`).addEventListener('click', () => window._pfmConfirmDelete(fieldId, filename));
};
window._pfmConfirmDelete = async function (fieldId, filename) {
const st = getState(fieldId);
const result = await callAction(st.pluginId, st.actions.delete, { filename })
.catch(() => ({ status: 'error', message: 'Network error' }));
if (result.status === 'success') {
notify('File deleted', 'success');
window._pfmCloseModal(fieldId);
await loadFiles(fieldId);
} else {
notify('Delete failed: ' + (result.message || ''), 'error');
}
};
// ─── Create modal ─────────────────────────────────────────────────────────
window._pfmOpenCreate = function (fieldId) {
const st = getState(fieldId);
const fields = st.createFields;
const overlay = createOverlay(fieldId);
const modal = document.createElement('div');
modal.className = 'pfm-modal';
modal.style.maxWidth = '32rem';
safeSetHTML(modal, `
<div class="pfm-modal-header">
<span class="pfm-modal-title"><i class="fas fa-plus-circle mr-2"></i>Create New File</span>
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_cre_close">
<i class="fas fa-times"></i>
</button>
</div>
<div class="pfm-modal-body">
<div id="${escHtml(fieldId)}_create_err" class="pfm-field-error" style="margin-bottom:.5rem;"></div>
${fields.map(f => `
<div class="pfm-field">
<label for="${escHtml(fieldId)}_cf_${escHtml(f.key)}">${escHtml(f.label || f.key)}</label>
<input type="text" id="${escHtml(fieldId)}_cf_${escHtml(f.key)}"
placeholder="${escHtml(f.placeholder || '')}"
${f.pattern ? `pattern="${escHtml(f.pattern)}"` : ''}>
${f.hint ? `<div class="pfm-field-hint">${escHtml(f.hint)}</div>` : ''}
</div>`).join('')}
</div>
<div class="pfm-modal-footer">
<button class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_cre_cancel">Cancel</button>
<button class="pfm-btn pfm-btn-create" id="${escHtml(fieldId)}_create_btn">
<i class="fas fa-plus mr-1"></i>Create
</button>
</div>
</div>`;
overlay.appendChild(modal);
modal.querySelector(`#${CSS.escape(fieldId)}_cre_close`).addEventListener('click', () => window._pfmCloseModal(fieldId));
modal.querySelector(`#${CSS.escape(fieldId)}_cre_cancel`).addEventListener('click', () => window._pfmCloseModal(fieldId));
modal.querySelector(`#${CSS.escape(fieldId)}_create_btn`).addEventListener('click', () => window._pfmConfirmCreate(fieldId));
};
window._pfmConfirmCreate = async function (fieldId) {
const st = getState(fieldId);
const errEl = document.getElementById(`${fieldId}_create_err`);
const btn = document.getElementById(`${fieldId}_create_btn`);
const params = {};
for (const f of st.createFields) {
const inp = document.getElementById(`${fieldId}_cf_${f.key}`);
if (!inp) continue;
const val = inp.value.trim();
// Client-side pattern validation omitted — server-side create-file script validates.
params[f.key] = val;
}
if (btn) { btn.disabled = true; (function(b){b.textContent='';const i=document.createElement('i');i.className='fas fa-spinner fa-spin mr-1';b.appendChild(i);b.appendChild(document.createTextNode('Creating…'));})(btn); }
if (errEl) errEl.textContent = '';
const result = await callAction(st.pluginId, st.actions.create, params)
.catch(() => ({ status: 'error', message: 'Network error' }));
if (btn) { btn.disabled = false; (function(b){b.textContent='';const i=document.createElement('i');i.className='fas fa-plus mr-1';b.appendChild(i);b.appendChild(document.createTextNode('Create'));})(btn); }
if (result.status === 'success') {
notify('File created', 'success');
window._pfmCloseModal(fieldId);
await loadFiles(fieldId);
} else {
if (errEl) errEl.textContent = result.message || 'Create failed';
}
};
// ─── Toggle ───────────────────────────────────────────────────────────────
window._pfmToggle = async function (fieldId, categoryName, enabled) {
const st = getState(fieldId);
const result = await callAction(st.pluginId, st.actions.toggle, { category_name: categoryName, enabled })
.catch(() => ({ status: 'error' }));
if (result.status === 'success') {
notify(enabled ? `${categoryName} enabled` : `${categoryName} disabled`, 'success');
await loadFiles(fieldId);
} else {
notify('Toggle failed', 'error');
await loadFiles(fieldId); // revert UI
}
};
// ─── Upload ───────────────────────────────────────────────────────────────
window._pfmUpload = async function (fieldId, file) {
const st = getState(fieldId);
const notifyFn = window.showNotification || console.log;
if (!file.name.toLowerCase().endsWith('.json')) {
notifyFn('Only .json files can be uploaded', 'error'); return;
}
let content;
try { content = await file.text(); JSON.parse(content); }
catch { notifyFn('File contains invalid JSON', 'error'); return; }
const result = await callAction(st.pluginId, st.actions.upload, {
filename: file.name, content
}).catch(() => ({ status: 'error', message: 'Network error' }));
if (result.status === 'success') {
notify('File uploaded: ' + (result.filename || file.name), 'success');
await loadFiles(fieldId);
} else {
notify('Upload failed: ' + (result.message || ''), 'error');
}
};
// ─── Modal helpers ────────────────────────────────────────────────────────
function createOverlay(fieldId) {
window._pfmCloseModal(fieldId); // close any open modal first
const overlay = document.createElement('div');
overlay.className = 'pfm-overlay';
overlay.id = `${fieldId}_pfm_overlay`;
// Close on backdrop click
overlay.addEventListener('click', e => { if (e.target === overlay) window._pfmCloseModal(fieldId); });
document.body.appendChild(overlay);
getState(fieldId).currentModal = overlay;
return overlay;
}
window._pfmCloseModal = function (fieldId) {
const st = getState(fieldId);
if (st.currentModal) { st.currentModal.remove(); st.currentModal = null; }
st._editData = null;
st._editFilename = null;
};
// ─── Widget registration ──────────────────────────────────────────────────
window.LEDMatrixWidgets.register('plugin-file-manager', {
name: 'Plugin File Manager Widget',
version: '1.0.0',
render: function (container, config, value, options) {
const fieldId = (options.fieldId || container.id || 'pfm').replace(/[^a-zA-Z0-9_-]/g, '_');
const wc = config['x-widget-config'] || {};
const actions = wc.actions || {};
const pluginId = options.pluginId || '';
const st = getState(fieldId);
Object.assign(st, {
pluginId,
actions,
createFields: wc.create_fields || [],
uploadHint: wc.upload_hint || 'Upload JSON files',
directoryLabel: wc.directory_label || ''
});
safeSetHTML(container, `
<div class="pfm-root" id="${fieldId}_pfm">
<div class="pfm-header">
<div>
<div class="pfm-title">File Explorer</div>
${st.directoryLabel ? `<div class="pfm-dir">Manage files in <code>${escHtml(st.directoryLabel)}</code></div>` : ''}
</div>
<div style="display:flex;gap:.375rem;">
${actions.create ? `
<button class="pfm-btn pfm-btn-create"
onclick="window._pfmOpenCreate('${fieldId}')">
<i class="fas fa-plus mr-1"></i>New File
</button>` : ''}
</div>
</div>
${actions.upload ? `
<div class="pfm-upload" id="${fieldId}_upload_zone"
onclick="document.getElementById('${fieldId}_file_input').click()"
ondragover="event.preventDefault();this.classList.add('dragover')"
ondragleave="this.classList.remove('dragover')"
ondrop="this.classList.remove('dragover');event.preventDefault();
if(event.dataTransfer.files[0])window._pfmUpload('${fieldId}',event.dataTransfer.files[0])">
<input type="file" id="${fieldId}_file_input" accept=".json"
style="display:none"
onchange="if(this.files[0])window._pfmUpload('${fieldId}',this.files[0]);this.value=''">
<i class="fas fa-cloud-upload-alt" style="font-size:1.5rem;color:#9ca3af;"></i>
<p>Drag and drop or click to upload</p>
<small>${escHtml(st.uploadHint)}</small>
</div>` : ''}
<div class="pfm-grid">
<div class="pfm-empty"><i class="fas fa-spinner fa-spin"></i>Loading…</div>
</div>
</div>`;
loadFiles(fieldId);
},
getValue: function () { return null; }, // file ops are immediate; nothing to submit
setValue: function (fieldId) { loadFiles(fieldId); }
});
console.log('[PluginFileManager] plugin-file-manager widget registered');
})();

View File

@@ -0,0 +1,166 @@
/**
* LEDMatrix Time Picker Widget
*
* Single time selection using the browser's native time input.
* Returns a string in HH:MM (24-hour) format.
*
* Schema example:
* {
* "target_time": {
* "type": "string",
* "x-widget": "time-picker",
* "default": "00:00",
* "x-options": {
* "placeholder": "Select time",
* "clearable": true
* }
* }
* }
*
* @module TimePickerWidget
*/
(function() {
'use strict';
const base = window.BaseWidget ? new window.BaseWidget('TimePicker', '1.0.0') : null;
function escapeHtml(text) {
if (base) return base.escapeHtml(text);
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
return String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
}
function triggerChange(fieldId, value) {
if (base) {
base.triggerChange(fieldId, value);
} else {
document.dispatchEvent(new CustomEvent('widget-change', {
detail: { fieldId, value },
bubbles: true,
cancelable: true
}));
}
}
function safeSetHTML(target, html) {
target.textContent = '';
// createContextualFragment parses html relative to the document context
// without executing scripts — a widely recognised safe insertion method.
const frag = document.createRange().createContextualFragment(html);
target.appendChild(frag);
}
window.LEDMatrixWidgets.register('time-picker', {
name: 'Time Picker Widget',
version: '1.0.0',
render: function(container, config, value, options) {
const fieldId = sanitizeId(options.fieldId || container.id || 'time_picker');
const xOptions = config['x-options'] || config['x_options'] || {};
const placeholder = xOptions.placeholder || '';
const clearable = xOptions.clearable === true;
const disabled = xOptions.disabled === true;
const required = xOptions.required === true;
const currentValue = value || '';
let html = `<div id="${fieldId}_widget" class="time-picker-widget" data-field-id="${fieldId}">`;
html += '<div class="flex items-center">';
html += `
<div class="relative flex-1">
<input type="time"
id="${fieldId}_input"
name="${escapeHtml(options.name || fieldId)}"
value="${escapeHtml(currentValue)}"
${placeholder ? `placeholder="${escapeHtml(placeholder)}"` : ''}
${disabled ? 'disabled' : ''}
${required ? 'required' : ''}
onchange="window.LEDMatrixWidgets.getHandlers('time-picker').onChange('${fieldId}')"
class="form-input w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 ${disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white'} text-black pr-10">
<div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
<i class="fas fa-clock text-gray-400"></i>
</div>
</div>
`;
if (clearable && !disabled) {
html += `
<button type="button"
id="${fieldId}_clear"
onclick="window.LEDMatrixWidgets.getHandlers('time-picker').onClear('${fieldId}')"
class="ml-2 inline-flex items-center px-2 py-2 text-gray-400 hover:text-gray-600 ${currentValue ? '' : 'hidden'}"
title="Clear">
<i class="fas fa-times"></i>
</button>
`;
}
html += '</div>';
html += `<div id="${fieldId}_error" class="text-sm text-red-600 mt-1 hidden"></div>`;
html += '</div>';
safeSetHTML(container, html);
},
getValue: function(fieldId) {
const safeId = sanitizeId(fieldId);
const input = document.getElementById(`${safeId}_input`);
return input ? input.value : '';
},
setValue: function(fieldId, value) {
const safeId = sanitizeId(fieldId);
const input = document.getElementById(`${safeId}_input`);
const clearBtn = document.getElementById(`${safeId}_clear`);
if (input) input.value = value || '';
if (clearBtn) clearBtn.classList.toggle('hidden', !value);
},
validate: function(fieldId) {
const safeId = sanitizeId(fieldId);
const input = document.getElementById(`${safeId}_input`);
const errorEl = document.getElementById(`${safeId}_error`);
if (!input) return { valid: true, errors: [] };
const isValid = input.checkValidity();
if (errorEl) {
if (!isValid) {
errorEl.textContent = input.validationMessage;
errorEl.classList.remove('hidden');
input.classList.add('border-red-500');
} else {
errorEl.classList.add('hidden');
input.classList.remove('border-red-500');
}
}
return { valid: isValid, errors: isValid ? [] : [input.validationMessage] };
},
handlers: {
onChange: function(fieldId) {
const widget = window.LEDMatrixWidgets.get('time-picker');
const safeId = sanitizeId(fieldId);
const clearBtn = document.getElementById(`${safeId}_clear`);
const value = widget.getValue(fieldId);
if (clearBtn) clearBtn.classList.toggle('hidden', !value);
widget.validate(fieldId);
triggerChange(fieldId, value);
},
onClear: function(fieldId) {
const widget = window.LEDMatrixWidgets.get('time-picker');
widget.setValue(fieldId, '');
widget.validate(fieldId); // refresh required/error state
triggerChange(fieldId, '');
}
}
});
console.log('[TimePickerWidget] Time picker widget registered');
})();

View File

@@ -1442,9 +1442,14 @@ function renderInstalledPlugins(plugins) {
return;
}
// Helper function to escape attributes for use in HTML
// Helper function to escape values for use in HTML attributes
const escapeAttr = (text) => {
return (text || '').replace(/'/g, "\\'").replace(/"/g, '&quot;');
return (text || '')
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
};
// Helper function to escape for JavaScript strings (use JSON.stringify for proper escaping)
@@ -3441,6 +3446,28 @@ function generateFieldHtml(key, prop, value, prefix = '') {
html += `<option value="${option}" ${selected}>${option}</option>`;
});
html += `</select>`;
} else if (prop['x-widget'] === 'json-file-manager') {
// Reusable JSON file manager widget (no CDN, keyboard shortcuts, configurable actions)
const widgetConfig = prop['x-widget-config'] || {};
const pluginId = currentPluginConfig?.pluginId || window.currentPluginConfig?.pluginId || '';
const safeFieldId = (fullKey || 'file_manager').replace(/[^a-zA-Z0-9_-]/g, '_');
html += `<div id="${safeFieldId}_jfm_mount"></div>`;
setTimeout(() => {
const mount = document.getElementById(`${safeFieldId}_jfm_mount`);
if (!mount) return;
// Destroy the previous instance for this mount only — leave other instances intact
window.__jfmInstances = window.__jfmInstances || {};
const prev = window.__jfmInstances[safeFieldId];
if (prev?._destroy) prev._destroy();
if (typeof JsonFileManager !== 'undefined') {
window.__jfmInstances[safeFieldId] = new JsonFileManager(mount, widgetConfig, pluginId);
} else {
window.__jfmInstances[safeFieldId] = null;
mount.innerHTML = '<p style="color:#dc2626;font-size:.875rem;">json-file-manager widget not loaded. Check base.html includes json-file-manager.js.</p>';
}
}, 150);
} else if (prop['x-widget'] === 'custom-html') {
// Custom HTML widget - load HTML from plugin directory
const htmlFile = prop['x-html-file'];
@@ -4507,6 +4534,8 @@ function syncFormToJson() {
// Deep merge with existing config to preserve nested structures
function deepMerge(target, source) {
for (const key in source) {
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
if (!Object.prototype.hasOwnProperty.call(source, key)) continue;
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
if (!target[key] || typeof target[key] !== 'object' || Array.isArray(target[key])) {
target[key] = {};
@@ -7473,17 +7502,28 @@ setTimeout(function() {
console.log('installed-plugins-grid not found yet, will retry via event listeners');
}
// Also try to attach install button handler after a delay (fallback)
// Also try to attach install button handler after a delay (fallback).
// Only run if the install button element is already in the DOM (i.e. the
// plugins partial has been loaded); otherwise the htmx:afterSettle listener
// below handles it when the tab is first visited.
setTimeout(() => {
if (typeof window.attachInstallButtonHandler === 'function') {
console.log('[FALLBACK] Attempting to attach install button handler...');
if (typeof window.attachInstallButtonHandler === 'function' &&
document.getElementById('install-plugin-from-url')) {
window.attachInstallButtonHandler();
} else {
console.warn('[FALLBACK] attachInstallButtonHandler not available on window');
}
}, 500);
}, 200);
// Re-run install button wiring after HTMX settles the plugins tab content.
// Guard with element check so it only fires when the plugins partial is in the DOM,
// preventing spurious warnings on other tab loads.
document.addEventListener('htmx:afterSettle', function() {
if (document.getElementById('install-plugin-from-url') &&
typeof window.attachInstallButtonHandler === 'function') {
window.attachInstallButtonHandler();
}
});
// ─── Starlark Apps Integration ──────────────────────────────────────────────
(function() {

View File

@@ -136,6 +136,7 @@
setTimeout(function() {
if (typeof htmx !== 'undefined') {
console.log('HTMX loaded from fallback');
window.dispatchEvent(new Event('htmx:ready'));
// Load extensions after core loads
loadScript(sseSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/sse.js' : '/static/v3/js/htmx-sse.js');
loadScript(jsonEncSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/json-enc.js' : '/static/v3/js/htmx-json-enc.js');
@@ -152,6 +153,7 @@
}
} else {
console.log('HTMX loaded successfully');
window.dispatchEvent(new Event('htmx:ready'));
// Load extensions after core loads
loadScript(sseSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/sse.js' : '/static/v3/js/htmx-sse.js');
loadScript(jsonEncSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/json-enc.js' : '/static/v3/js/htmx-json-enc.js');
@@ -349,6 +351,19 @@
}
}
});
// Mark tab containers as loaded once their content settles, so switching
// away and back doesn't re-fetch. Scoped to the "loadtab" trigger (tab
// containers only) so modals and plugin config panels can still reload.
document.body.addEventListener('htmx:afterSettle', function(event) {
if (event.detail && event.detail.target) {
var target = event.detail.target;
var trigger = target.getAttribute('hx-trigger') || '';
if (trigger.includes('loadtab')) {
target.setAttribute('data-loaded', 'true');
}
}
});
} else {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', setupScriptExecution);
@@ -411,6 +426,9 @@
.then(html => {
clearTimeout(timeout);
content.innerHTML = html;
if (typeof htmx !== 'undefined') {
htmx.process(content);
}
// Trigger full initialization chain
if (window.pluginManager) {
window.pluginManager.initialized = false;
@@ -430,7 +448,7 @@
}
// Fallback if HTMX doesn't load within 5 seconds
setTimeout(() => {
var _pluginsFallbackTimer = setTimeout(() => {
if (typeof htmx === 'undefined') {
console.warn('HTMX not loaded after 5 seconds, using direct fetch for plugins');
// Load plugins tab content directly regardless of active tab,
@@ -438,6 +456,7 @@
loadPluginsDirect();
}
}, 5000);
window.addEventListener('htmx:ready', function() { clearTimeout(_pluginsFallbackTimer); }, { once: true });
</script>
<!-- Alpine.js app function - defined early so it's available when Alpine initializes -->
<script>
@@ -847,7 +866,7 @@
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<!-- Custom v3 styles -->
<link rel="stylesheet" href="{{ url_for('static', filename='v3/app.css') }}?v=20260216b">
<link rel="stylesheet" href="{{ url_for('static', filename='v3/app.css') }}">
</head>
<body x-data="app()" class="bg-gray-50 min-h-screen">
<!-- Header -->
@@ -1010,7 +1029,7 @@
<div id="tab-content" class="space-y-6">
<!-- Overview tab -->
<div x-show="activeTab === 'overview'" x-transition>
<div id="overview-content" hx-get="/v3/partials/overview" hx-trigger="revealed" hx-swap="innerHTML" hx-on::htmx:response-error="loadOverviewDirect()">
<div id="overview-content" hx-get="/v3/partials/overview" hx-trigger="loadtab" hx-swap="innerHTML" hx-on::htmx:response-error="loadOverviewDirect()">
<div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
@@ -1030,6 +1049,9 @@
.then(html => {
overviewContent.innerHTML = html;
overviewContent.setAttribute('data-loaded', 'true');
if (typeof htmx !== 'undefined') {
htmx.process(overviewContent);
}
// Re-initialize Alpine.js for the new content
if (window.Alpine) {
window.Alpine.initTree(overviewContent);
@@ -1058,7 +1080,7 @@
});
// Also try direct load if HTMX doesn't load within 5 seconds
setTimeout(() => {
var _overviewFallbackTimer = setTimeout(() => {
if (typeof htmx === 'undefined') {
console.warn('HTMX not loaded after 5 seconds, using direct fetch for content');
const appElement = document.querySelector('[x-data="app()"]');
@@ -1070,11 +1092,12 @@
}
}
}, 5000);
window.addEventListener('htmx:ready', function() { clearTimeout(_overviewFallbackTimer); }, { once: true });
</script>
<!-- General tab -->
<div x-show="activeTab === 'general'" x-transition>
<div id="general-content" hx-get="/v3/partials/general" hx-trigger="revealed" hx-swap="innerHTML">
<div id="general-content" hx-get="/v3/partials/general" hx-trigger="loadtab" hx-swap="innerHTML">
<div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
@@ -1092,7 +1115,7 @@
<div x-show="activeTab === 'wifi'" x-transition>
<div id="wifi-content"
hx-get="/v3/partials/wifi"
hx-trigger="revealed"
hx-trigger="loadtab"
hx-swap="innerHTML"
hx-on::htmx:response-error="loadWifiDirect()">
<div class="animate-pulse">
@@ -1143,7 +1166,7 @@
<!-- Schedule tab -->
<div x-show="activeTab === 'schedule'" x-transition>
<div id="schedule-content" hx-get="/v3/partials/schedule" hx-trigger="revealed" hx-swap="innerHTML">
<div id="schedule-content" hx-get="/v3/partials/schedule" hx-trigger="loadtab" hx-swap="innerHTML">
<div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
@@ -1159,7 +1182,7 @@
<!-- Display tab -->
<div x-show="activeTab === 'display'" x-transition>
<div id="display-content" hx-get="/v3/partials/display" hx-trigger="revealed" hx-swap="innerHTML">
<div id="display-content" hx-get="/v3/partials/display" hx-trigger="loadtab" hx-swap="innerHTML">
<div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
@@ -1174,7 +1197,7 @@
<!-- Backup & Restore tab -->
<div x-show="activeTab === 'backup-restore'" x-transition>
<div id="backup-restore-content" hx-get="/v3/partials/backup-restore" hx-trigger="revealed" hx-swap="innerHTML">
<div id="backup-restore-content" hx-get="/v3/partials/backup-restore" hx-trigger="loadtab" hx-swap="innerHTML">
<div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
@@ -1186,7 +1209,7 @@
<!-- Config Editor tab -->
<div x-show="activeTab === 'config-editor'" x-transition>
<div id="config-editor-content" hx-get="/v3/partials/raw-json" hx-trigger="revealed" hx-swap="innerHTML">
<div id="config-editor-content" hx-get="/v3/partials/raw-json" hx-trigger="loadtab" hx-swap="innerHTML">
<div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
@@ -1201,7 +1224,7 @@
<!-- Plugins tab -->
<div x-show="activeTab === 'plugins'" x-transition>
<div id="plugins-content" hx-get="/v3/partials/plugins" hx-trigger="revealed" hx-swap="innerHTML"
<div id="plugins-content" hx-get="/v3/partials/plugins" hx-trigger="loadtab" hx-swap="innerHTML"
hx-on::response-error="loadPluginsDirect()">
<div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6">
@@ -1218,7 +1241,7 @@
<!-- Fonts tab -->
<div x-show="activeTab === 'fonts'" x-transition>
<div id="fonts-content" hx-get="/v3/partials/fonts" hx-trigger="revealed" hx-swap="innerHTML">
<div id="fonts-content" hx-get="/v3/partials/fonts" hx-trigger="loadtab" hx-swap="innerHTML">
<div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
@@ -1233,7 +1256,7 @@
<!-- Logs tab -->
<div x-show="activeTab === 'logs'" x-transition>
<div id="logs-content" hx-get="/v3/partials/logs" hx-trigger="revealed" hx-swap="innerHTML">
<div id="logs-content" hx-get="/v3/partials/logs" hx-trigger="loadtab" hx-swap="innerHTML">
<div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
@@ -1245,7 +1268,7 @@
<!-- Cache tab -->
<div x-show="activeTab === 'cache'" x-transition>
<div id="cache-content" hx-get="/v3/partials/cache" hx-trigger="revealed" hx-swap="innerHTML">
<div id="cache-content" hx-get="/v3/partials/cache" hx-trigger="loadtab" hx-swap="innerHTML">
<div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
@@ -1257,7 +1280,7 @@
<!-- Operation History tab -->
<div x-show="activeTab === 'operation-history'" x-transition>
<div id="operation-history-content" hx-get="/v3/partials/operation-history" hx-trigger="revealed" hx-swap="innerHTML">
<div id="operation-history-content" hx-get="/v3/partials/operation-history" hx-trigger="loadtab" hx-swap="innerHTML">
<div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
@@ -1346,34 +1369,64 @@
<!-- SSE connection for real-time updates -->
<script>
// Connect to SSE streams
const statsSource = new EventSource('/api/v3/stream/stats');
const displaySource = new EventSource('/api/v3/stream/display');
// Assign to window so reconnectSSE() in app.js can reach them.
window.statsSource = new EventSource('/api/v3/stream/stats');
window.displaySource = new EventSource('/api/v3/stream/display');
statsSource.onmessage = function(event) {
window.statsSource.onmessage = function(event) {
const data = JSON.parse(event.data);
updateSystemStats(data);
};
displaySource.onmessage = function(event) {
window.displaySource.onmessage = function(event) {
const data = JSON.parse(event.data);
updateDisplayPreview(data);
};
// Connection status
statsSource.addEventListener('open', function() {
document.getElementById('connection-status').innerHTML = `
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
<span class="text-gray-600">Connected</span>
`;
});
function _setConnectionStatus(connected, reconnecting) {
const el = document.getElementById('connection-status');
if (!el) return;
if (connected) {
el.innerHTML = `
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
<span class="text-gray-600">Connected</span>
`;
} else if (reconnecting) {
el.innerHTML = `
<div class="w-2 h-2 bg-yellow-500 rounded-full animate-pulse"></div>
<span class="text-gray-600">Reconnecting…</span>
`;
} else {
el.innerHTML = `
<div class="w-2 h-2 bg-red-500 rounded-full"></div>
<span class="text-gray-600" title="Connection lost — try refreshing the page">Disconnected</span>
`;
}
}
statsSource.addEventListener('error', function() {
document.getElementById('connection-status').innerHTML = `
<div class="w-2 h-2 bg-red-500 rounded-full"></div>
<span class="text-gray-600">Disconnected</span>
`;
});
var _statsErrorCount = 0;
// Named on window so reconnectSSE() in app.js can reattach them after
// replacing the EventSource instances.
window._statsOpenHandler = function() {
_statsErrorCount = 0;
_setConnectionStatus(true, false);
};
window._statsErrorHandler = function() {
_statsErrorCount++;
// EventSource readyState 0 = CONNECTING (auto-retrying), 2 = CLOSED
var reconnecting = window.statsSource.readyState === EventSource.CONNECTING;
_setConnectionStatus(false, reconnecting && _statsErrorCount <= 3);
};
window._displayErrorHandler = function() {
// Display stream errors don't change the status badge but log to console
// so failures aren't completely silent.
console.warn('LEDMatrix: display preview stream error (readyState=' + window.displaySource.readyState + ')');
};
window.statsSource.addEventListener('open', window._statsOpenHandler);
window.statsSource.addEventListener('error', window._statsErrorHandler);
window.displaySource.addEventListener('error', window._displayErrorHandler);
function updateSystemStats(data) {
// Update CPU in header
@@ -1807,23 +1860,53 @@
},
loadTabContent(tab) {
// Try to load content for the active tab
const contentEl = document.getElementById(tab + '-content');
// data-loaded: already fetched. data-loading: a fetch is queued or in
// flight. Both guard against re-entry so a panel loads exactly once, even
// if the tab is reopened before an in-progress (or polling) load settles.
if (!contentEl || contentEl.hasAttribute('data-loaded') || contentEl.hasAttribute('data-loading')) return;
const url = contentEl.getAttribute('hx-get');
if (!url) return;
contentEl.setAttribute('data-loading', 'true');
// htmx.ajax issues the request and swaps the response into the panel
// directly, so it works even before htmx has wired up the element's
// hx-trigger listeners. data-loaded is stamped on success so the panel
// loads once; the activeTab check drops loads for a tab the user navigated
// away from while htmx was still loading (avoids fetching hidden panels).
const swap = contentEl.getAttribute('hx-swap') || 'innerHTML';
const load = () => {
if (this.activeTab !== tab || contentEl.hasAttribute('data-loaded')) {
contentEl.removeAttribute('data-loading');
return;
}
return htmx.ajax('GET', url, { target: contentEl, swap: swap })
.then(() => contentEl.setAttribute('data-loaded', 'true'))
.catch(() => {}) // leave unstamped on failure so it can retry
.finally(() => contentEl.removeAttribute('data-loading'));
};
if (typeof htmx !== 'undefined') {
const contentId = tab + '-content';
const contentEl = document.getElementById(contentId);
if (contentEl && !contentEl.hasAttribute('data-loaded')) {
// Trigger HTMX load
htmx.trigger(contentEl, 'revealed');
}
} else {
// HTMX not available, use direct fetch
console.warn('HTMX not available, using direct fetch for tab:', tab);
if (tab === 'overview' && typeof loadOverviewDirect === 'function') {
loadOverviewDirect();
} else if (tab === 'wifi' && typeof loadWifiDirect === 'function') {
loadWifiDirect();
}
load();
return;
}
// htmx is loaded from a CDN and may not be ready yet. Poll until it is,
// then load; if it never arrives, fall back to a direct fetch.
let tries = 0;
const timer = setInterval(() => {
if (typeof htmx !== 'undefined') {
clearInterval(timer);
load();
} else if (++tries > 100) { // ~10s
clearInterval(timer);
contentEl.removeAttribute('data-loading');
if (tab === 'overview' && typeof loadOverviewDirect === 'function') loadOverviewDirect();
else if (tab === 'wifi' && typeof loadWifiDirect === 'function') loadWifiDirect();
else if (tab === 'plugins' && typeof loadPluginsDirect === 'function') loadPluginsDirect();
}
}, 100);
},
async loadInstalledPlugins() {
@@ -4566,6 +4649,9 @@
<script src="{{ url_for('static', filename='v3/js/widgets/timezone-selector.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/plugin-loader.js') }}" defer></script>
<!-- Reusable JSON file manager widget (used by of-the-day and others via x-widget: json-file-manager) -->
<script src="{{ url_for('static', filename='v3/js/widgets/json-file-manager.js') }}" defer></script>
<!-- Legacy plugins_manager.js (for backward compatibility during migration) -->
<script src="{{ url_for('static', filename='v3/plugins_manager.js') }}?v=20260307" defer></script>

View File

@@ -73,7 +73,7 @@
<button hx-post="/api/v3/system/action"
hx-vals='{"action": "start_display"}'
hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Display started', event.detail.xhr.responseJSON.status || 'success'); }"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Display started',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700">
<i class="fas fa-play mr-2"></i>
Start Display
@@ -82,7 +82,7 @@
<button hx-post="/api/v3/system/action"
hx-vals='{"action": "stop_display"}'
hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Display stopped', event.detail.xhr.responseJSON.status || 'success'); }"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Display stopped',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700">
<i class="fas fa-stop mr-2"></i>
Stop Display
@@ -91,7 +91,7 @@
<button hx-post="/api/v3/system/action"
hx-vals='{"action": "git_pull"}'
hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Code update completed', event.detail.xhr.responseJSON.status || 'info'); }"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Code update completed',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
<i class="fas fa-download mr-2"></i>
Update Code
@@ -101,7 +101,7 @@
hx-vals='{"action": "reboot_system"}'
hx-confirm="Are you sure you want to reboot the system?"
hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'System rebooting...', event.detail.xhr.responseJSON.status || 'info'); }"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='System rebooting...',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-yellow-600 hover:bg-yellow-700">
<i class="fas fa-power-off mr-2"></i>
Reboot System

View File

@@ -843,6 +843,14 @@ async function updateFontPreview() {
return;
}
// BDF bitmap fonts cannot be rendered server-side — skip the API call
if (family.toLowerCase().endsWith('.bdf')) {
previewImage.style.display = 'none';
loadingText.style.display = 'block';
loadingText.textContent = 'Preview not available for BDF bitmap fonts';
return;
}
// Show loading state
loadingText.textContent = 'Loading preview...';
loadingText.style.display = 'block';

View File

@@ -1,3 +1,66 @@
<!-- Reconciliation warning banner: shown when startup reconciliation found stale plugin config entries -->
<div id="reconciliation-banner" class="bg-yellow-50 border border-yellow-300 rounded-lg p-4 mb-4 flex items-start" style="display:none !important" role="alert">
<div class="flex-shrink-0 mr-3 mt-0.5">
<i class="fas fa-exclamation-triangle text-yellow-500"></i>
</div>
<div class="flex-1">
<p class="text-sm font-medium text-yellow-800">Plugin Config Warning</p>
<p class="text-sm text-yellow-700 mt-1" id="reconciliation-banner-text"></p>
</div>
<button type="button" onclick="window.dismissReconciliationBanner()" class="ml-4 flex-shrink-0 text-yellow-500 hover:text-yellow-700" aria-label="Dismiss">
<i class="fas fa-times"></i>
</button>
</div>
<script>
(function () {
var DISMISS_KEY = 'ledmatrix-recon-dismissed';
var _recon_timer = null;
function checkReconciliation() {
fetch('/api/v3/plugins/reconciliation-status')
.then(function (r) { return r.json(); })
.then(function (resp) {
var d = resp.data || {};
if (!d.done) {
// Reconciliation still running — poll again shortly
_recon_timer = setTimeout(checkReconciliation, 2000);
return;
}
_recon_timer = null;
if (!d.unresolved || d.unresolved.length === 0) return;
var key = d.unresolved.map(function (i) { return i.plugin_id; }).sort().join(',');
if (sessionStorage.getItem(DISMISS_KEY) === key) return;
var ids = d.unresolved.map(function (i) { return i.plugin_id; }).join(', ');
document.getElementById('reconciliation-banner-text').textContent =
'Stale plugin config entries found: ' + ids +
'. Remove them from config.json or reinstall via the Plugin Store.';
var banner = document.getElementById('reconciliation-banner');
banner.dataset.dismissKey = key;
banner.style.setProperty('display', 'flex', 'important');
})
.catch(function () {});
}
checkReconciliation();
window.dismissReconciliationBanner = function () {
var banner = document.getElementById('reconciliation-banner');
banner.style.setProperty('display', 'none', 'important');
if (_recon_timer !== null) {
clearTimeout(_recon_timer);
_recon_timer = null;
}
// Persist dismissal immediately so the banner won't reappear on reload
// even if the background sync fetch below fails.
var key = banner.dataset.dismissKey;
if (key) {
try { sessionStorage.setItem(DISMISS_KEY, key); } catch (e) {}
}
// Background sync only — do not rely on this for DISMISS_KEY or hiding.
fetch('/api/v3/plugins/reconciliation-status').catch(function () {});
};
}());
</script>
<div class="bg-white rounded-lg shadow p-6">
<div class="border-b border-gray-200 pb-4 mb-6">
<h2 class="text-lg font-semibold text-gray-900">System Overview</h2>
@@ -88,7 +151,7 @@
<button hx-post="/api/v3/system/action"
hx-vals='{"action": "start_display"}'
hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Display started', event.detail.xhr.responseJSON.status || 'success'); }"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Display started',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-green-600 hover:bg-green-700">
<i class="fas fa-play mr-2"></i>
Start Display
@@ -97,7 +160,7 @@
<button hx-post="/api/v3/system/action"
hx-vals='{"action": "stop_display"}'
hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Display stopped', event.detail.xhr.responseJSON.status || 'success'); }"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Display stopped',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-red-600 hover:bg-red-700">
<i class="fas fa-stop mr-2"></i>
Stop Display
@@ -107,7 +170,7 @@
hx-vals='{"action": "git_pull"}'
hx-confirm="This will stash any local changes and update the code. Continue?"
hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Code update completed', event.detail.xhr.responseJSON.status || 'info'); }"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Code update completed',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50">
<i class="fas fa-download mr-2"></i>
Update Code
@@ -117,7 +180,7 @@
hx-vals='{"action": "reboot_system"}'
hx-confirm="Are you sure you want to reboot the system?"
hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'System rebooting...', event.detail.xhr.responseJSON.status || 'info'); }"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='System rebooting...',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-yellow-600 hover:bg-yellow-700">
<i class="fas fa-power-off mr-2"></i>
Reboot System
@@ -127,7 +190,7 @@
hx-vals='{"action": "shutdown_system"}'
hx-confirm="Are you sure you want to shut down the system? This will power off the Raspberry Pi."
hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'System shutting down...', event.detail.xhr.responseJSON.status || 'info'); }"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='System shutting down...',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-red-800 hover:bg-red-900">
<i class="fas fa-power-off mr-2"></i>
Shutdown System
@@ -136,7 +199,7 @@
<button hx-post="/api/v3/system/action"
hx-vals='{"action": "restart_display_service"}'
hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Display service restarted', event.detail.xhr.responseJSON.status || 'success'); }"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Display service restarted',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50">
<i class="fas fa-redo mr-2"></i>
Restart Display Service
@@ -145,7 +208,7 @@
<button hx-post="/api/v3/system/action"
hx-vals='{"action": "restart_web_service"}'
hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Web service restarted', event.detail.xhr.responseJSON.status || 'success'); }"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Web service restarted',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50">
<i class="fas fa-redo mr-2"></i>
Restart Web Service

View File

@@ -9,7 +9,8 @@
{% set field_id = (plugin_id ~ '-' ~ full_key)|replace('.', '-')|replace('_', '-') %}
{% set label = prop.title if prop.title else key|replace('_', ' ')|title %}
{% set description = prop.description if prop.description else '' %}
{% set field_type = prop.type if prop.type is string else (prop.type[0] if prop.type is iterable else 'string') %}
{% set _pt = prop.get('type') %}
{% set field_type = _pt if (_pt is string) else ((_pt | first) if (_pt and _pt is iterable and _pt is not string) else 'string') %}
{# Handle nested objects - check for widget first #}
{% if field_type == 'object' %}
@@ -496,15 +497,31 @@
{% endif %}
<div class="array-table-container mt-1" data-field-id="{{ field_id }}" data-full-key="{{ full_key }}" data-max-items="{{ max_items }}" data-plugin-id="{{ plugin_id }}">
<table class="min-w-full divide-y divide-gray-200 border border-gray-300 rounded-lg">
<div style="overflow-x:auto">
<table class="divide-y divide-gray-200 border border-gray-300 rounded-lg" style="min-width:max-content;width:100%">
<thead class="bg-gray-50">
<tr>
{% for col_name in display_columns %}
{% set col_def = item_properties.get(col_name, {}) %}
{% set col_title = col_def.get('title', col_name|replace('_', ' ')|title) %}
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ col_title }}</th>
{% set col_xwidget = col_def.get('x-widget') or col_def.get('x_widget', '') %}
{% set col_enum = col_def.get('enum', []) %}
{% set _raw_ctype = col_def.get('type', 'string') %}
{% if _raw_ctype is iterable and _raw_ctype is not string %}
{% set col_ctype = (_raw_ctype | reject('equalto','null') | list | first) or 'string' %}
{% else %}
{% set col_ctype = _raw_ctype or 'string' %}
{% endif %}
{% if col_xwidget == 'date-picker' %}{% set col_min_w = '140px' %}
{% elif col_xwidget == 'time-picker' %}{% set col_min_w = '115px' %}
{% elif col_xwidget == 'file-upload-single' %}{% set col_min_w = '200px' %}
{% elif col_enum %}{% set col_min_w = '90px' %}
{% elif col_ctype == 'boolean' %}{% set col_min_w = '60px' %}
{% elif col_ctype in ['integer', 'number'] %}{% set col_min_w = '80px' %}
{% else %}{% set col_min_w = '110px' %}{% endif %}
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" style="min-width:{{ col_min_w }}">{{ col_title }}</th>
{% endfor %}
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider w-20">Actions</th>
<th class="px-3 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider" style="min-width:90px">Actions</th>
</tr>
</thead>
<tbody id="{{ field_id }}_tbody" class="bg-white divide-y divide-gray-200">
@@ -513,9 +530,24 @@
<tr class="array-table-row" data-index="{{ item_index }}">
{% for col_name in display_columns %}
{% set col_def = item_properties.get(col_name, {}) %}
{% set col_type = col_def.get('type', 'string') %}
{# Normalize nullable types e.g. ["null","integer"] → "integer" #}
{% set _raw_type = col_def.get('type', 'string') %}
{% if _raw_type is iterable and _raw_type is not string %}
{% set col_type = (_raw_type | reject('equalto','null') | list | first) or 'string' %}
{% else %}
{% set col_type = _raw_type or 'string' %}
{% endif %}
{% set col_xwidget = col_def.get('x-widget') or col_def.get('x_widget', '') %}
{% set col_enum = col_def.get('enum', []) %}
{% set col_value = item.get(col_name, col_def.get('default', '')) %}
<td class="px-4 py-3 whitespace-nowrap">
{% if col_xwidget == 'date-picker' %}{% set td_min_w = '140px' %}
{% elif col_xwidget == 'time-picker' %}{% set td_min_w = '115px' %}
{% elif col_xwidget == 'file-upload-single' %}{% set td_min_w = '200px' %}
{% elif col_enum %}{% set td_min_w = '90px' %}
{% elif col_type == 'boolean' %}{% set td_min_w = '60px' %}
{% elif col_type in ['integer', 'number'] %}{% set td_min_w = '80px' %}
{% else %}{% set td_min_w = '110px' %}{% endif %}
<td class="px-3 py-3 whitespace-nowrap" style="min-width:{{ td_min_w }};vertical-align:middle">
{% if col_type == 'boolean' %}
<input type="hidden" name="{{ full_key }}.{{ item_index }}.{{ col_name }}" value="false">
<input type="checkbox"
@@ -532,6 +564,43 @@
{% if col_type == 'integer' %}step="1"{% else %}step="any"{% endif %}
class="block w-20 px-2 py-1 border border-gray-300 rounded text-sm text-center"
{% if col_def.get('description') %}title="{{ col_def.get('description') }}"{% endif %}>
{% elif col_enum %}
<select name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
class="block w-full px-2 py-1 border border-gray-300 rounded text-sm bg-white">
{% for opt in col_enum %}{% if opt is not none %}
<option value="{{ opt }}" {% if col_value == opt or (col_value is none and col_def.get('default') == opt) %}selected{% endif %}>{{ opt }}</option>
{% endif %}{% endfor %}
</select>
{% elif col_xwidget == 'date-picker' %}
<input type="date"
name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
value="{{ col_value if col_value is not none else '' }}"
class="block w-full px-2 py-1 border border-gray-300 rounded text-sm">
{% elif col_xwidget == 'time-picker' %}
<input type="time"
name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
value="{{ col_value if col_value is not none else '00:00' }}"
class="block w-full px-2 py-1 border border-gray-300 rounded text-sm">
{% elif col_xwidget == 'file-upload-single' %}
{% set cell_input_id = field_id ~ '_' ~ item_index ~ '_' ~ col_name %}
<div class="flex items-center gap-1">
{% if col_value %}<img src="/{{ col_value }}" class="w-6 h-6 object-cover rounded flex-shrink-0" onerror="this.style.display='none'">{% endif %}
<input type="text"
id="{{ cell_input_id }}"
name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
value="{{ col_value if col_value is not none else '' }}"
class="block w-20 px-1 py-1 border border-gray-300 rounded text-xs"
placeholder="path…">
<label class="cursor-pointer flex-shrink-0 inline-flex items-center px-1 py-1 bg-blue-50 border border-blue-200 rounded text-xs text-blue-600 hover:bg-blue-100" title="Upload image">
<i class="fas fa-upload"></i>
<input type="file"
accept="image/png,image/jpeg,image/bmp,image/gif"
style="display:none"
data-plugin-id="{{ plugin_id }}"
data-target-input="{{ cell_input_id }}"
onchange="(function(e){ const t=document.getElementById('{{ cell_input_id }}'); const p=t.previousElementSibling && t.previousElementSibling.tagName==='IMG' ? t.previousElementSibling : null; window.handleArrayTableImageUpload(e,t,p,'{{ plugin_id }}'); })(event)">
</label>
</div>
{% else %}
<input type="text"
name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
@@ -544,13 +613,60 @@
{% endif %}
</td>
{% endfor %}
<td class="px-4 py-3 whitespace-nowrap text-center">
{# Actions cell: delete + optional edit button for advanced props #}
{% set has_advanced = namespace(value=false) %}
{% for k in item_properties.keys() %}{% if k not in display_columns and k != 'id' %}{% set has_advanced.value = true %}{% endif %}{% endfor %}
<td class="px-3 py-3 whitespace-nowrap text-center" style="min-width:90px;vertical-align:middle">
<button type="button"
onclick="removeArrayTableRow(this)"
class="text-red-600 hover:text-red-800 px-2 py-1">
<i class="fas fa-trash"></i>
</button>
{% if has_advanced.value %}
<button type="button"
onclick="openArrayTableRowEditor(this)"
class="text-blue-500 hover:text-blue-700 px-2 py-1 ml-1"
title="Edit layout, style and other advanced properties">
<i class="fas fa-sliders-h"></i>
</button>
{% endif %}
</td>
{# Hidden cell: flat hidden inputs for non-displayed props (layout, style, etc.) #}
{% if has_advanced.value %}
{% set adv_schema = namespace(d={}) %}
{% for k, v in item_properties.items() %}{% if k not in display_columns and k != 'id' %}{% set _ = adv_schema.d.update({k: v}) %}{% endif %}{% endfor %}
<td style="display:none" class="array-table-advanced-data"
data-prop-schema='{{ adv_schema.d|tojson }}'>
{% for prop_name, prop_schema in adv_schema.d.items() %}
{% set prop_type = prop_schema.get('type', 'string') %}
{% if prop_type == 'object' and prop_schema.get('properties') %}
{% for sub_name, sub_schema in prop_schema.get('properties', {}).items() %}
{% set sub_val = item.get(prop_name, {}).get(sub_name) %}
{% set sub_default = sub_schema.get('default') %}
{% set final_val = sub_val if sub_val is not none else sub_default %}
<input type="hidden"
name="{{ full_key }}.{{ item_index }}.{{ prop_name }}.{{ sub_name }}"
data-nested-prop="{{ prop_name }}.{{ sub_name }}"
data-prop-type="{{ sub_schema.get('type', 'string') }}"
data-prop-schema='{{ sub_schema|tojson }}'
value="{{ final_val if final_val is not none else '' }}">
{% endfor %}
{% else %}
{% set prop_val = item.get(prop_name) %}
{% set prop_default = prop_schema.get('default') %}
{% set final_val = prop_val if prop_val is not none else prop_default %}
<input type="hidden"
name="{{ full_key }}.{{ item_index }}.{{ prop_name }}"
data-nested-prop="{{ prop_name }}"
data-prop-type="{{ prop_schema.get('type', 'string') }}"
data-prop-schema='{{ prop_schema|tojson }}'
value="{{ final_val if final_val is not none else '' }}">
{% endif %}
{% endfor %}
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
@@ -562,11 +678,58 @@
data-max-items="{{ max_items }}"
data-plugin-id="{{ plugin_id }}"
data-item-properties='{% set ns = namespace(d={}) %}{% for k in display_columns %}{% if k in item_properties %}{% set _ = ns.d.update({k: item_properties[k]}) %}{% endif %}{% endfor %}{{ ns.d|tojson }}'
data-full-item-properties='{{ item_properties|tojson }}'
data-display-columns='{{ display_columns|tojson }}'
class="mt-3 px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md"
{% if array_value|length >= max_items %}disabled style="opacity: 0.5;"{% endif %}>
<i class="fas fa-plus mr-1"></i> Add Item
</button>
</div>{# end overflow-x:auto wrapper #}
</div>
{% elif x_widget == 'color-picker' %}
{# RGB color array: R / G / B number inputs + visual swatch + sync'd hex picker #}
{% set color_arr = value if value is not none and value is iterable and value is not string else (prop.default if prop.default is defined and prop.default is iterable and prop.default is not string else [255, 255, 255]) %}
{% set r_val = color_arr[0] if color_arr|length > 0 else 255 %}
{% set g_val = color_arr[1] if color_arr|length > 1 else 255 %}
{% set b_val = color_arr[2] if color_arr|length > 2 else 255 %}
{% set hex_val = '#%02x%02x%02x' % (r_val|int, g_val|int, b_val|int) %}
<div class="flex items-center gap-3 flex-wrap mt-1" id="{{ field_id }}_color_row">
<input type="color"
id="{{ field_id }}_hex"
value="{{ hex_val }}"
class="h-9 w-12 cursor-pointer rounded border border-gray-300"
title="Color picker"
oninput="(function(h){var r=parseInt(h.slice(1,3),16),g=parseInt(h.slice(3,5),16),b=parseInt(h.slice(5,7),16);document.getElementById('{{ field_id }}_r').value=r;document.getElementById('{{ field_id }}_g').value=g;document.getElementById('{{ field_id }}_b').value=b;})(this.value)">
<div class="flex items-center gap-1">
<label class="text-xs text-gray-500 font-medium">R</label>
<input type="number" min="0" max="255" step="1"
id="{{ field_id }}_r"
name="{{ full_key }}.0"
value="{{ r_val }}"
class="w-16 px-2 py-1 border border-gray-300 rounded text-sm text-center"
oninput="(function(){var r=+document.getElementById('{{ field_id }}_r').value||0,g=+document.getElementById('{{ field_id }}_g').value||0,b=+document.getElementById('{{ field_id }}_b').value||0;document.getElementById('{{ field_id }}_hex').value='#'+[r,g,b].map(function(n){return n.toString(16).padStart(2,'0')}).join('')})()">
</div>
<div class="flex items-center gap-1">
<label class="text-xs text-gray-500 font-medium">G</label>
<input type="number" min="0" max="255" step="1"
id="{{ field_id }}_g"
name="{{ full_key }}.1"
value="{{ g_val }}"
class="w-16 px-2 py-1 border border-gray-300 rounded text-sm text-center"
oninput="(function(){var r=+document.getElementById('{{ field_id }}_r').value||0,g=+document.getElementById('{{ field_id }}_g').value||0,b=+document.getElementById('{{ field_id }}_b').value||0;document.getElementById('{{ field_id }}_hex').value='#'+[r,g,b].map(function(n){return n.toString(16).padStart(2,'0')}).join('')})()">
</div>
<div class="flex items-center gap-1">
<label class="text-xs text-gray-500 font-medium">B</label>
<input type="number" min="0" max="255" step="1"
id="{{ field_id }}_b"
name="{{ full_key }}.2"
value="{{ b_val }}"
class="w-16 px-2 py-1 border border-gray-300 rounded text-sm text-center"
oninput="(function(){var r=+document.getElementById('{{ field_id }}_r').value||0,g=+document.getElementById('{{ field_id }}_g').value||0,b=+document.getElementById('{{ field_id }}_b').value||0;document.getElementById('{{ field_id }}_hex').value='#'+[r,g,b].map(function(n){return n.toString(16).padStart(2,'0')}).join('')})()">
</div>
<div class="w-8 h-8 rounded border border-gray-300 flex-shrink-0"
style="background-color: rgb({{ r_val }}, {{ g_val }}, {{ b_val }})"
title="Color preview"></div>
</div>
{% else %}
{# Generic array-of-objects would go here if needed in the future #}
@@ -625,7 +788,19 @@
name="{{ full_key }}"
value="{{ str_value }}">
</div>
{% elif str_widget in ['text-input', 'textarea', 'select-dropdown', 'toggle-switch', 'radio-group', 'date-picker', 'slider', 'color-picker', 'email-input', 'url-input', 'password-input', 'font-selector'] %}
{% elif str_widget == 'json-file-manager' %}
{# Embedded file manager — plugin's web_ui/file_manager.html served via /v3/plugin-ui/ route #}
<div class="mt-1 rounded-lg border border-gray-200 overflow-hidden">
<iframe id="{{ field_id }}_frame"
src="/v3/plugin-ui/{{ plugin_id }}/web-ui/file_manager.html"
style="width:100%;height:640px;border:none;"
title="File Manager for {{ plugin_id }}"></iframe>
</div>
<p class="text-xs text-amber-600 mt-2 flex items-center">
<i class="fas fa-info-circle mr-1"></i>
Changes in the file manager save immediately — no need to click Save Configuration.
</p>
{% elif str_widget in ['text-input', 'textarea', 'select-dropdown', 'toggle-switch', 'radio-group', 'date-picker', 'time-picker', 'slider', 'color-picker', 'email-input', 'url-input', 'password-input', 'font-selector', 'file-upload-single', 'plugin-file-manager'] %}
{# Render widget container #}
<div id="{{ field_id }}_container" class="{{ str_widget }}-container"></div>
<script>
@@ -642,7 +817,9 @@
'enum': {{ (prop.enum or [])|tojson|safe }},
'minimum': {{ prop.minimum|tojson if prop.minimum is defined else 'null' }},
'maximum': {{ prop.maximum|tojson if prop.maximum is defined else 'null' }},
'x-options': {{ (prop.get('x-options') or prop.get('x_options') or {})|tojson|safe }}
'x-options': {{ (prop.get('x-options') or prop.get('x_options') or {})|tojson|safe }},
'x-upload-config': {{ (prop.get('x-upload-config') or prop.get('x_upload_config') or {})|tojson|safe }},
'x-widget-config': {{ (prop.get('x-widget-config') or prop.get('x_widget_config') or {})|tojson|safe }}
};
widget.render(container, config, value, { fieldId: '{{ field_id }}', name: '{{ full_key }}', pluginId: '{{ plugin_id }}' });
}
@@ -863,15 +1040,28 @@
</div>
</div>
{# Web UI Actions (if any) #}
{% if web_ui_actions %}
{# Web UI Actions — hide if schema has a dedicated file-manager widget,
or if every action is marked ui_hidden in the manifest. #}
{% set has_file_manager_widget = namespace(value=false) %}
{% for _fk, _fp in schema.get('properties', {}).items() %}
{% if (_fp.get('x-widget') or _fp.get('x_widget')) in ('json-file-manager', 'plugin-file-manager') %}
{% set has_file_manager_widget.value = true %}
{% endif %}
{% endfor %}
{% set visible_actions = [] %}
{% for _a in web_ui_actions %}
{% if not _a.get('ui_hidden', false) %}
{% set _ = visible_actions.append(_a) %}
{% endif %}
{% endfor %}
{% if visible_actions and not has_file_manager_widget.value %}
<div class="mt-6 pt-4 border-t border-gray-200">
<h3 class="text-md font-medium text-gray-900 mb-3">Plugin Actions</h3>
{% if web_ui_actions[0].section_description %}
<p class="text-sm text-gray-600 mb-4">{{ web_ui_actions[0].section_description }}</p>
{% if visible_actions[0].section_description %}
<p class="text-sm text-gray-600 mb-4">{{ visible_actions[0].section_description }}</p>
{% endif %}
<div class="space-y-3">
{% for action in web_ui_actions %}
{% for action in visible_actions %}
{% set action_id = "action-" ~ action.id ~ "-" ~ loop.index0 %}
{% set status_id = "action-status-" ~ action.id ~ "-" ~ loop.index0 %}
{% set bg_color = action.color or 'blue' %}