Compare commits

...
Author SHA1 Message Date
Claude 969863eebd feat(sports): opt-in celebration and rotation capabilities
Phase B2 of the sports unification. Both features exist in only some of
the nine scoreboards, so they ship as capabilities the plugin composes,
never as `if self.<feature>_enabled` branches inside the base classes: a
sport that does not opt in has none of this code in its MRO.

CelebrationMixin (afl, nrl, soccer, football)
The two lineages spelled this differently -- _check_for_goal /
celebrate_opponent_goals vs _check_for_score / celebrate_opponent_scores
-- but the bodies were identical apart from three things, each now a
seam rather than a branch:
  - wording -> score_phrase() / win_phrase() hooks
  - follow-up suppression -> COALESCE_SCORING_SEQUENCE, on for football
    where a touchdown lands as +6 then +1, off where two increments are
    two real goals
  - team identity -> _favorite_key, so nrl matches on team id without
    core learning why its abbreviations are ambiguous
Both config spellings are read, so a plugin adopting the mixin keeps
working with the keys already in its published schema.

Rotation strategies
The three "dialects" turned out to be one algorithm (SWRR) in two
shapes: an incremental picker holding state across calls, and a
precomputed per-cycle list. They agree within a cycle and differ only at
the boundary, so core ships both behind a name registry rather than
declaring a winner. weight_for is supplied by the host, so rotation.py
never learns what a favorite is; an unknown name degrades to "simple"
because it arrives from user config.

Each strategy is checked against a verbatim transcription of the plugin
code it replaces, over every live-game shape up to four games -- the
differential B5 will delete the bundled copies on the strength of.

185 new tests. Gates: 648 core unit, 60 plugin safety.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
2026-08-01 16:52:51 +00:00
Claude 98a7c769ec fix(sports): harden the live game-over check and font/log init
Follow-up review findings on the promoted base-class methods.

_is_game_really_over:
- `period` present-but-None raised TypeError on `None >= FINAL_PERIOD`,
  taking down the whole live-update pass (_detect_stale_games has no
  try/except). Same failure shape as the null `period_text` already fixed.
- An expired clock spelled "00:00" normalizes to "0000", which matched
  none of the hand-listed literals, so a finished game with a two-digit
  minute clock stayed on the scoreboard forever. Compare numerically.

SportsCore:
- _load_fonts kept the cwd-relative "assets/fonts/..." literals the
  _font_root() seam exists to remove, so every scoreboard font degraded
  to PIL's default face outside the install root.
- _should_log read self._last_warning_time unguarded while only an
  unrelated method initialized it lazily; the first warning of a run
  raised AttributeError. Initialize it in __init__.

Also documents that game_update_timestamps is written by subclasses, not
by the base class, so the staleness branch is inert until B5 adoption.

14 new tests. Gates: 463 core unit, 60 plugin safety.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
2026-08-01 16:45:04 +00:00
Claude 2486bdb249 fix(sports): stop dropping hockey and baseball events on optional feed keys
Both bugs were pinned AS-IS by the B0 characterization suite so this
phase could change them knowingly. Both fixes are adoptions of code the
corresponding plugins already ship, not new inventions.

Hockey: the extractor read competitor["statistics"] unguarded, so a
competitor arriving without that array raised KeyError inside the
generator and the WHOLE event was discarded -- valid scores and status
included. Shot/save counts now default to 0, which is already what the
suite expects for an empty statistics array.

Baseball: for live games the extractor read game_event["status"], the
event TOP-LEVEL status, to get the inning. Real ESPN events duplicate
status there, but MiLB events (synthesized from the MLB Stats API into
an ESPN-like shape) populate only the competition-level one, so the
lookup raised a bare KeyError and dropped the event. It now reads the
competition-level status that _extract_game_details_common has already
validated, so it cannot be missing at that point.

The two characterization tests that pinned the old behaviour are
rewritten to assert the fix rather than deleted, so the suite still
documents the edge case -- and still totals 94.

CHANGELOG records these plus the live-clock change from aaabc61 under
Changed/Fixed, since all three are user-visible. The two new promotion
suites join the CI unit job (449 tests).

Verified: unit job 449 passed, plugin-safety job 60 passed, and the
hockey (16) and baseball (24) plugin harnesses render clean at every
panel size.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
2026-08-01 16:25:32 +00:00
Claude aaabc614cc feat(sports): promote the nine universal methods into the base classes
Phase B1b of docs/SPORTS_UNIFICATION.md. Every method here is present in
all nine bundled plugin sports.py copies and absent from core, so this is
reuse of code the fleet already agreed on — not new behavior. The
promotions are inert until B5: the plugins' own overrides still run.

SportsCore: cleanup, _get_layout_offset, _load_custom_font_from_element_config
SportsUpcoming: _select_games_for_display
SportsRecent: _get_zero_clock_duration, _clear_zero_clock_tracking,
              _select_recent_games_for_display
SportsLive: _is_game_really_over, _detect_stale_games

Where the copies disagreed, the canonical form was chosen on evidence and
the genuine per-sport differences became seams rather than branches:

- _favorite_key(game, side) -- NRL matches favorites on team id because its
  abbreviations are ambiguous (NEW is both Newcastle Knights and New
  Zealand Warriors). Default is the abbreviation; NRL overrides. Core never
  learns the string nrl.
- FINAL_PERIOD / CLOCK_COUNTS_DOWN -- hockey ends in P3, and soccer/afl/nrl
  clocks count UP, so 0:00 means kickoff, not expiry.
- _config_schema_path() / _font_root() -- plugin-supplied locations, never
  derived from this module's __file__.

BEHAVIOR CHANGE (baseball, ufc): the rejected variant coerced a missing or
non-str clock to the literal 0:00 and then declared the game over at
period >= 4. MLB has no game clock and period is the inning, so live games
were being evicted from the 5th inning onward; UFC likewise. The promoted
variant skips the clock check when the clock is unusable -- it fails safe
(keeps showing the game) instead of failing destructive.

Also fixes a regression from the package move in e591cec: the bodies were
byte-identical but __file__ gained a directory, so _resolve_project_path's
parents[2] silently began resolving to <root>/src instead of the repo root.
Both it and _font_root now derive from a single _INSTALL_ROOT constant, so
a future move needs one line changed rather than two hand-counted depths.
Tests assert the resolved values, not the index.

The font loader takes baseball's body (BDF memo cache + native-strike
retry) under hockey's Optional signature -- the older lineage is the
correct one here, and basketball's positional str default breaks on an
explicit None. It resolves through _font_root rather than the cwd, so it
does not reintroduce the bug just fixed for FontManager, and delegates to
FontManager for the alias table and BDF header parse instead of shipping
second copies. cleanup gained the two new font caches and still leaves
background_service alone -- it is a process-wide singleton.

Verified: 111 new tests (48 core + 59 modes + 4 install-root regression);
characterization + skin suites still exactly 94, unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
2026-08-01 16:23:41 +00:00
Claude e591ceca48 refactor(sports): convert sports.py into a package (pure move)
Phase B1a of docs/SPORTS_UNIFICATION.md. src/base_classes/sports.py
becomes a package so the upcoming capability modules have a home and
diffs show their blast radius:

  sports/__init__.py   re-exports the public API
  sports/core.py       SportsCore
  sports/modes.py      SportsUpcoming / SportsRecent / SportsLive

No logic change: the 1515 class-body lines are byte-identical to the
original (verified by concatenating the two modules and diffing against
HEAD). Only module docstrings and the redistributed import blocks are
new. MRO and __abstractmethods__ are unchanged, and every existing
import site — including 'from src.base_classes.sports import SportsCore'
in the sport subclasses, the skin tests, and the characterization
suite — resolves through the package __init__.

One test edit was required: the characterization suite monkeypatched
'src.base_classes.sports.get_background_service', which is no longer a
module attribute on a package. Retargeted to
'src.base_classes.sports.core.get_background_service' — the module whose
globals SportsCore.__init__ actually resolves, so the patch is effective
exactly as before. No test logic or assertion changed.

Also adds docs/SPORTS_UNIFICATION.md: the architecture for the whole
B1-B5 sequence — how upgradability (guarded imports, capability probing,
frozen view-model keys, the sunset rule), reusability (promote only what
all nine copies share), and modularity (capabilities as opt-in mixins
rather than config branches, variants as named strategies, sport-unique
code as declared override points) are kept as three separate mechanisms.

Verified: characterization + skin 94 passed; the 10-file unit suite 338
passed; test/plugins 60 passed — all identical to pre-change counts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
2026-08-01 16:07:46 +00:00
Claude 64e8f87f86 ci: restrict the test workflow's GITHUB_TOKEN to contents:read
CodeQL flagged the new unit-tests job for running with the default
unrestricted token; the pre-existing job had the same exposure. Both
jobs only check out the repo and run pytest, so a workflow-level
contents:read is sufficient.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
2026-08-01 14:16:51 +00:00
Claude 1a0792225b test: characterization suite for src/base_classes/sports.py ahead of unification
Pins current behavior before the planned merge of the nine drifted
plugin copies back into this ancestor: the _extract_game_details_common
key contract per sport (reusing GUARANTEED_KEYS from the skin tests),
update() flows for upcoming/recent/live against cache-seeded fixtures
under frozen time, rendering smoke per mode class, and guard rails on
the skin-system seam.

Five surprising behaviors are pinned AS-IS and flagged in comments so
the merge changes them knowingly or not at all: is_upcoming also
matching status.type.name; hockey dropping events whose competitors
lack 'statistics'; baseball reading the event-level status for innings;
no past-date filter in upcoming; and favorites-only mode with an empty
favorites list showing nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
2026-08-01 14:14:34 +00:00
Claude 5fe9c07f80 feat: ship src/element_style — the per-element style resolver plugins already expect
Three plugins (of-the-day, ledmatrix-music, football-scoreboard) import
src.element_style behind guarded try/except with classic fallbacks, but
the module never existed in core, so the richer per-element styling UI
those code paths implement has been dormant. This lands it:

- ElementStyleResolver.style() resolves per-element font/size/color with
  the key semantic the consumers encode: a config value counts as
  user-forced only when it differs from the schema default (the web UI
  bakes defaults into config.json on save), and untouched configs
  resolve to exactly the caller's classic values — byte-identical
  rendering, proven by of-the-day's committed goldens passing unchanged.
- defaults_from_schema_file parses both declaration forms (the compact
  x-style-elements map and hand-written customization blocks).
- expand_style_elements() expands x-style-elements into full config
  blocks; schema_manager.load_schema() applies it (guarded, no-op for
  schemas without the declaration) so the config form and defaults
  merging see the expanded UI.
- Fonts resolve cwd-independently with (path, size) caching; .bdf loads
  via freetype like FontManager; nothing in the module raises out of
  style().

Verified: 31 new unit tests; of-the-day's previously-skipped 9-test
spec suite now runs and passes; football's resolver tests pass (27);
music's 38 plugin tests pass; schema-manager suites pass (43).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
2026-08-01 14:14:18 +00:00
Claude dc659940ab ci: enroll the core unit suites in a dedicated job
The existing workflow ran only the three plugin-harness suites; the
skin-system, font-manager, data-source, extractor, scroll-helper,
adaptive-layout, and loader-compat suites (266 tests) existed but never
ran in CI, so a refactor of src/base_classes or src/common could regress
them silently. Also enrolls the new sports characterization and
element-style suites landing in this branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
2026-08-01 14:08:18 +00:00
Claude 3f4e915af7 docs: seed CHANGELOG.md with the module-availability release discipline
The plugins monorepo's sunset rule ('delete a bundled fallback copy only
when the manifest floors on the first core release shipping the module')
needs core module additions recorded against version numbers. Seeds the
changelog at 3.1.0 and documents the discipline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
2026-08-01 14:08:18 +00:00
Claude 1baebd2d09 fix(fonts): resolve asset paths against the install root, not the cwd
FontManager built its catalog from cwd-relative paths ('assets/fonts'),
so any process started outside the install root — the plugin safety
harness on CI being the recurring case — found no fonts and silently
degraded every plugin to PIL's default face. Several plugins grew
per-plugin workarounds for exactly this (countdown, text-display,
tide-display in the plugins monorepo).

Catalog population now falls back to the install root derived from this
module's location when the cwd-relative path is missing; behavior when
running from the install root is unchanged. Verified: resolve_font
returns the real FreeType face from a foreign cwd, and the full unit
suites (266 tests) pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
2026-08-01 14:08:17 +00:00
5b45f35888 Vegas mode: reclaim dead space and pace the rotation (#423)
* Vegas mode: reclaim dead space and pace the rotation

On a wide panel Vegas mode spent much of its time showing black. At 50px/s
on a 512px display, one display width of blank is 10.2 seconds, which makes
several long-standing behaviours expensive:

- ScrollHelper prepended a full display width of black as an "initial gap",
  charged once per cycle — 10.2s of black at the start of every rotation.
- Plugins without get_vegas_content() are captured off a full-display canvas,
  so their blank margins entered the ticker too. Measured: of-the-day drew
  35px of "No Data" on a 512px canvas (92% blank), youtube-stats 142px of
  content with 185px of black either side. Only the scroll_helper path had
  any trimming.
- Cycle transitions deliberately pushed a blank frame and then recomposed
  synchronously: 84ms at best, 4.8s at worst, every millisecond of it black.
- buffer_ahead doubled as the cycle size, so a 21-plugin install showed 3
  plugins per cycle and took ~7 cycles to come around.
- separator_width was applied between every image rather than at plugin
  boundaries, so a per-row ticker like the F1 scoreboard (116 images, which
  it renders 4px apart internally) got a 32px chasm between each row — and
  the width budget didn't count those gaps, so the plugin quietly occupied
  far more of the panel than intended.

Changes:

- src/vegas_mode/geometry.py: numpy column-ink primitives shared by the
  trimmer and the audit tool, so the number reported is the number acted on.
  A Python per-column loop over a 17,000px strip is far too slow for the
  render path.
- PluginAdapter trims every content path, not just scroll_helper. Only outer
  edges are cropped: interior blank columns are the plugin's own layout
  (logo left, score right) and closing them would corrupt the design. A
  plugin on a non-black background is inherently unaffected.
- ScrollHelper.create_scrolling_image takes an explicit lead_gap, still
  defaulting to display_width so the many standalone-ticker callers are
  unchanged. Vegas passes lead_in_width (default 0).
- Cycle end holds the last rendered frame instead of blanking, turning the
  recompose into a brief freeze rather than the panel switching off.
- plugins_per_cycle (default 6) is split from buffer_ahead, which goes back
  to being only a prefetch low-water mark.
- max_plugin_width_ratio (default 3x display width) caps one plugin's share
  of a cycle. Overflow is deferred, not discarded: a rotation offset advances
  each fetch so later rows appear on subsequent cycles. Single oversized
  images are cropped at a blank column so the cut misses glyphs.
- Composition groups images by plugin: rows are joined by intra_plugin_gap
  (default 8) and separator_width applies only between plugins. The width
  budget now counts those gaps.
- Plugin data updates no longer run on the Vegas render path.

All new settings are user-configurable in Display -> Vegas Scroll, including
min/max cycle duration and dynamic duration, which previously existed in code
but were reachable only by hand-editing config.json.

Measured with scripts/dev/vegas_audit.py on a 512x64 panel:

  mean ink coverage    42.7% -> 69.4%
  fully blank           5.9% -> 0%
  reads as empty        13.6% -> 0%
  worst blank stretch    4.8s -> 0s
  full rotation          414s -> 123s
  plugins per cycle         3 -> 6

Note the metric choice: a "fully blank" scan (>=95% black viewport) reported
only 0.4% and badly understated the problem, because two full-width segments
with mid-canvas content never fully blank the viewport — they hold it at ~28%.
window_coverage_stats grades every viewport position by how much ink it
carries, which is what tracks perceived dead time.

Known remaining: cycle transitions still freeze ~3.5s while the next cycle is
fetched. Fixing that needs background prefetch, which is deferred because the
fallback-capture path mutates the shared display_manager.image and racing it
against the render loop risks torn frames.

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

* Drop unused Optional import from the vegas audit script

Flagged by Codacy (F401). Any, Dict and List are all still used.

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

* Align Vegas API bounds with validate(), fix audit config plumbing

Both from review feedback on #423.

The web API's accepted ranges disagreed with VegasModeConfig.validate(),
which is what actually gates Vegas starting:

  scroll_speed      1-100  -> 1-200   (a slider value of 150 returned 400)
  separator_width   0-500  -> 0-128
  target_fps        1-200  -> 30-200
  buffer_ahead      1-20   -> 1-5

The three loose ones were the dangerous direction: the value saved with a
200, then VegasModeCoordinator.start() failed validation with only a log
line, so the ticker silently never ran. The UI already matched validate() in
all four cases, so the API was the odd one out.

test_vegas_api_bounds_match_validate parses the numeric_fields map out of
api_v3 and asserts every bound against validate(), plus that validate()
accepts both endpoints and rejects just outside them, so these cannot drift
apart again. That test immediately caught a missing upper bound on
min_plugin_width, now added — unbounded it would drop every segment and
leave a blank ticker.

Separately, vegas_audit.py constructed PluginAdapter without the config, so
it fell back to VegasModeConfig() defaults and would report trimming and
width-budget behaviour that differed from the user's config.json. It now
passes the loaded config exactly as the coordinator does. This is the same
class of drift the explicit lead_gap and grouping arguments already guard
against. Output is unchanged on a rig whose config matches the defaults.

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

* Vegas mode: render plugins narrower, space rows by measured separation

Trimming reclaims blank margins but cannot compact a layout that genuinely
spans the display — a five-column forecast, a progress bar drawn at 100%
width, a stat block with the panel's whole width between its elements. Those
need the plugin to make different layout decisions, which means telling it the
screen is narrower while it renders.

DisplayManager.render_size() presents a smaller logical canvas for the
duration of a Vegas content fetch, reusing the same _LogicalMatrix
indirection double-sided mode already relies on so plugins see a consistent
size from every accessor. Plugins that size themselves from matrix.width need
no changes at all; one that wants to be explicit can read the new
BasePlugin.get_vegas_render_width().

Width is a percentage so a single setting travels across panel sizes:
vegas_scroll.render_width_pct globally, or vegas_width_pct in an individual
plugin's config. Measured on a 512x64 panel with real data:

  ledmatrix-weather   1536px -> 576px   (forecast becomes narrow cards)
  youtube-stats        353px -> 199px   (2% blank left, so genuinely compact)
  geochron             453px -> 153px   (ink density rises to 100%)
  ledmatrix-flights    950px -> 740px

The youtube-stats figure is the clearest evidence the layout itself changed
rather than being cropped: at full width the content had to be trimmed from
512px to 353px, whereas at 40% it arrives with almost no blank to reclaim.

Row spacing is now measured rather than added. A flat gap gets it wrong in
both directions at once — content drawn flush to its own edges ends up nearly
touching (reported for recent sports scores, which sat 8px apart), while
content already carrying wide margins gets pushed even further out.
separation_gap() measures the blank each pair already has and adds only the
shortfall, up to min_content_separation (default 24). intra_plugin_gap stays
as a floor applied regardless.

Two tests shipped in the previous commit encoded the old flat-gap arithmetic
and are updated to the measured semantics, including one renamed to reflect
that zero intra_plugin_gap alone no longer butts rows together.

Also fixes a real bug found while testing: the harness display manager had no
render_size(), and because the adapter catches broadly that surfaced as "no
content" rather than an error, silently dropping five plugins. Added the
context to VisualTestDisplayManager for parity, and _render_at() now degrades
to a no-op on any display manager lacking it, so a third-party or older
harness loses the narrowing rather than the content.

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

* Vegas mode: end cycles before the wrap, keep the width budget honest

Three fixes, the first a regression from lead_in_width defaulting to 0.

get_visible_portion wraps: once scroll_position + display_width passes the end
of the strip it fills the right of the frame from the *head* of the same strip.
So the final display_width of travel showed the cycle's first plugin re-entering
on the right while its last plugin exited on the left, and the recompose that
followed replaced both at once. On a 512px panel at 50px/s that was 10.2s of
two plugins on screen at once, ending in a hard cut — reported as the ticker
"switching mid-scroll" from F1 to news.

That used to be invisible because the strip began with a full display_width of
blank, so the wrapped-in region was black. Removing that blank (it was 10s of
dead panel per cycle) exposed the wrap. Cycles now end one display width
earlier, before any wrapped content appears, clamped for strips no wider than
the display so they don't complete instantly and spin the recompose loop.

Verified on hardware: a 3936px strip now completes at 68.5s, exactly
(3936 - 512) / 50.

Second, auto_trim=False also skipped the width budget, which is an unrelated
concern — turning off margin cropping should not let one plugin hold the panel
for minutes. Seen in the field: the F1 scoreboard contributed 116 images and
14,848px untouched, giving a 33,821px cycle (11 minutes of content). The budget
now applies regardless of trimming; with it restored that cycle is 6,362px.

Third, the budget accounted for row gaps using the flat intra_plugin_gap while
the compositor had moved to measured separation, so it under-counted by up to
(min_content_separation - intra_plugin_gap) per row and a many-row plugin
overran its cap. Both now use the same separation_gap() rule, and a test
asserts the composed block fits the budget end to end rather than trusting the
two paths to agree.

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

* Fix IndexError in find_blank_cut when the cut lands on the image edge

A cut position after the last column is legitimate — _crop_to_budget asks for
min(start + budget, img.width), which equals the width whenever the remaining
strip is shorter than the budget. find_blank_cut clamped target to width but
then walked leftwards starting at target itself, so ink[width] raised
IndexError.

Caught on hardware: it killed the ledmatrix-stocks fetch, and because
_fetch_plugin_content catches broadly that surfaced as the plugin silently
contributing nothing for the cycle.

Only reachable on the second or later pass of the rotating window over a single
oversized image, which is why the existing tests missed it — they all exercised
the first pass, where start is 0 and start + budget is comfortably inside the
image. Added TestRotationAcrossMultipleCycles, which walks the window round
several times and asserts content is never lost, plus direct coverage of
find_blank_cut at and beyond the image edge.

Both bounds now stop at width - 1 so neither direction can index past the end.

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

* Only cut oversized segments at real gaps between items

The width-budget crop snapped to the nearest blank column, and in rendered text
the gap between two characters is a single column. So a cut routinely landed
inside a word: the cycle showed "Wednesda" and the orphaned "y" turned up as a
lone floating letter in the next cycle, positioned after whatever plugin
happened to precede it.

Measured on the clock-simple segment to confirm: its blank runs are
[1, 1, 1, 1, 1, 8, 8] — five single-column letter gaps, every one of which
find_blank_cut would happily have chosen.

Cuts now only land in a run of at least min_cut_gap blank columns (default 6),
which excludes letter spacing while still finding the gaps plugins put between
items (the stocks ticker uses 32px, baseball 48px). Where no boundary falls
inside the budget the cut waits for the next one and overruns, because
splitting an item is worse than a slightly long segment.

Continuous content is treated differently on purpose: an image with no internal
gaps is a map or a chart, where any column is as good as another, so it is still
cut to the budget exactly. The gap rule protects discrete items; letting a solid
image escape the cap in its name would be wrong.

blank_runs() is vectorised — 48ms for a 17,000px strip, against seconds for a
per-column Python loop.

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

* Hold capture_mode for every plugin render, not just narrowed ones

The native content path only entered capture_mode when it was also narrowing
the canvas, so at full width — which is every plugin without a vegas_width_pct
override, i.e. most of them — a plugin calling update_display() while building
its Vegas content wrote straight to the hardware. That is a visible flash
mid-scroll, and it lines up with the flash reported at cycle transitions, when
several plugins are fetched back to back.

Suppression is now unconditional; the narrowing context stays separate because
it is already a no-op at full width.

Both contexts are reached through helpers that degrade to nullcontext when the
display manager lacks them. That matters more than it looks: the adapter's
handlers are deliberately broad, so an AttributeError from a missing context
does not surface as an error — it surfaces as the plugin contributing nothing.
Making the call unconditional without this turned 44 tests red for exactly that
reason, all of them reporting lost content rather than the real cause.

The test double now provides capture_mode and render_size too, so tests
exercise the real contexts instead of silently taking the degraded path.

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

* Vegas mode: one continuous strip instead of swapping cycles

A cycle used to be a discrete strip that got replaced: motion stopped, every
pixel was substituted at once, and the next group started with the viewport
already full. That is the freeze, the flash and the jump.

The strip is now extended rather than replaced. ScrollHelper gains
append_content(), which adds items on the right without touching
scroll_position or total_distance_scrolled, so motion continues and the next
group simply arrives from the right. Because completion is measured against
total_scroll_width, extending also defers completion — there is no longer a
cycle boundary to see.

drop_scrolled_prefix() reclaims what has gone past, keeping the strip bounded
however long Vegas runs (observed 5,000-11,000px against an unbounded strip
otherwise). It shifts total_distance_scrolled and total_scroll_width together so
the completion arithmetic is unchanged, and refuses to run while the viewport is
wrapping: wrapping reads the head of the strip into the right of the frame, so
trimming the head there would visibly change the picture. A test caught that.

Groups are prepared off the render thread. The constraint is that the canvas and
the matrix proxy are process-wide mutable state, so narrowing or capturing
through them from another thread would corrupt the frame the render loop is
pushing. get_content() therefore takes offscreen_only: the background thread uses
only paths that avoid the canvas, and anything needing it is marked and picked up
on the render thread. That puts the expensive work (native renders of leaderboard
and baseball cards, seconds each) in the background and leaves the cheap work
(display capture, 40-600ms) in the foreground.

DisplayManager's capture flag is now thread-local. As a shared flag, a background
capture would have suppressed the render loop's own frame pushes for its
duration, freezing the panel precisely when the point was to avoid a freeze.

Canvas-bound plugins are drained one at a time rather than as a batch: six at
once held the render thread for 1.75s. Drains are also spaced by two seconds
while the lookahead is healthy, since taking them back to back turns one long
stall into a run of short ones. When the strip is genuinely running short the
throttle is ignored, because content matters more than smoothness there.

Measured on hardware: zero cycle-complete swaps, drains landing 2-4s apart,
lookahead holding at 1,200-3,500px, no errors.

Set continuous_scroll false to restore the swap behaviour; the old path is intact.

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

* Pace the Vegas frame loop adaptively: 31.5 -> 78.7 fps

The loop slept a fixed frame_interval on top of however long the frame took, so
at a measured 31.6ms per frame a flat 8ms of that was pure idle — a quarter of
the budget spent not rendering. It now sleeps only the remainder of the budget.

Measured on hardware: 31.5 fps to 78.7 fps sustained, with CPU going *down* from
150% to 127%. Scroll speed is unchanged at 49.9px/s against a configured 50,
because motion is derived from elapsed time rather than frame count — this buys
smoothness, not speed.

Worth recording what the bottleneck was not: the per-frame render path measures
0.34ms in total (0.18ms for the numpy slice, 0.17ms for the dirty-tracking
digest), which is a theoretical 2900 fps. Optimising any of that would have been
wasted effort. The frame was idle, not busy.

Also nices the prefetch thread. Its work is PIL and numpy that releases the GIL,
so the scheduler can act on the priority, and without it the prefetch competes
for the same cores as the render loop.

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

* Sub-pixel scrolling: motion at the frame rate, not the pixel rate

With integer positioning the number of distinct frames per second equals the
scroll speed in px/s, however fast the loop renders. Measured at 50px/s and
78.7fps, 36% of frames were byte-identical: the extra frames cost work and
bought no motion, and what was left was 50 discrete 1px steps a second.

Two things were wrong with the pre-existing sub-pixel support. get_visible_portion
never consulted sub_pixel_scrolling — it always took the integer path, so the flag
and _get_visible_portion_subpixel were dead code. And that implementation needed
scipy.ndimage.shift, which is not installed on the target devices (HAS_SCIPY is
False there), so it would not have interpolated even if reached. Verified both:
positions 1000.0 and 1000.5 produced identical frames either way.

Blending is now wired up and implemented with numpy. Two details make it
affordable: slice cached_array directly instead of building two PIL images only
to convert them straight back (the naive version measured 15x the integer path),
and use fixed-point uint16 multiply-add rather than float32, which suits the Pi's
cores and gives finer weighting than the panel can resolve. Result 0.939ms
against 0.237ms — 0.70ms added per frame, a 1065fps ceiling.

Measured on hardware: 81.2 fps with blending on, against 78.7 with it off, so no
cost within noise — and every frame is now a distinct position rather than one in
three being a repeat.

The trade is a slight horizontal softening of text, since each frame blends two
positions. Set smooth_scroll false for maximum crispness.

Also benchmarked and cleared as non-issues: extending the strip costs 9.4ms on an
11,000px strip and trimming 2.5ms, both under one frame at this rate.

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

* Add overflow handling: keep ordered content whole instead of rotating a window

The width budget split any oversized plugin by advancing a window each cycle.
That is right for interchangeable items — news headlines, odds, stock prices —
but wrong for ordered content: a league table showed ranks 1-6, then resumed at
7 two rotations later, which reads as out of order and out of context. Nobody
needs rank 23 in a ticker; they need the top of the table, every time.

overflow_mode chooses between them:

  rotate   — advance a window each cycle so everything is seen eventually
             (unchanged default)
  truncate — always show the start and drop the rest, keeping ordered content
             coherent. Records no window state, so every pass starts at the top.

Per-plugin vegas_overflow overrides the global setting, since one install has
both kinds of plugin. Also adds per-plugin vegas_max_width_screens, so content
that must stay whole can be given more room — or uncapped with 0 — without
lifting the cap on every ticker.

Applied on the test rig: f1-scoreboard and ledmatrix-leaderboard set to
truncate, and baseball given 4.5 screens because it was showing 8 of 9 games
when the whole slate needed only a little more room. Verified: F1 now reports
"the first 10 of 116 ... the rest are not shown", baseball has dropped out of
the budget log entirely, and stocks, odds-ticker and stock-news still rotate.

Also corrects the crop log, which claimed "window advances next cycle"
unconditionally and so misreported truncated crops. A test now pins the
behaviour behind the message: truncate must leave no offset recorded.

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

* Stop Vegas mode showing last night's games as if they were live

A game that was live in the evening was still being drawn as live the next
morning. Two faults combined to freeze plugin visuals indefinitely.

PR #291 added a call to plugin_adapter.invalidate_plugin_scroll_cache() so
a plugin's own cached scroll image would be rebuilt from fresh data. That
method was never implemented. hot_swap_content() wraps the call in a broad
except, so every hot swap has raised AttributeError and been swallowed
silently ever since — which is why the visuals it was meant to keep fresh
never were.

Continuous scrolling then removed the only path that reached it at all:
should_recompose() and hot_swap_content() are called from the
non-continuous branch of run_frame(), and continuous_scroll defaults to
True. So on a default install the pending-update flags were set by the
update tick, never consumed, and grew without bound.

Together these froze content completely, because refetching is not enough
on its own: the sports plugins' get_vegas_content() regenerates only "if
the cache is empty", so take_next_group() kept receiving the same picture
however often it asked.

Fixed by:

- Implementing invalidate_plugin_scroll_cache(). It covers both layouts —
  a helper directly on the plugin (stocks, news, odds-ticker) and one
  owned by a scroll-display manager (the sports scoreboards, which is the
  shape that produced this bug) — and clears cached_image and
  cached_array together, since the array is the image's numpy mirror.

- Adding StreamManager.invalidate_pending_updates() and calling it from
  the continuous branch. It only drops the caches; the plugin recomposes
  when it next comes round in the rotation. process_updates() is wrong
  here: it refetches synchronously and merges into the active buffer that
  continuous mode bypasses, and hot_swap_content() rebuilds and
  repositions the whole strip, which is the freeze-and-jump this mode
  exists to avoid.

Tests assert the fix rather than the implementation: 14 of the 17 new
tests fail without it. Includes the wiring itself, since the regression
was a call that was simply absent, and a check that the scroll position is
untouched so this cannot regress into the swap's visible jump.

All Vegas suites pass (355 tests). test_display_controller_vegas_tick.py
still cannot be collected off-device for want of rgbmatrix, identically
with and without this change.

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

* Fix two CodeRabbit-flagged test assertions in vegas density tests

test_prepared_group_is_used_without_refetching had a tautological final
assertion; now checks stream.calls directly. test_no_partial_letter_at_either_edge
required both crop edges to be blank, but the left edge here is always the
crop's start position with no lead-in gap in word_strip, so it legitimately
carries ink — only the right edge is an actual cut and needs the check.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-31 09:40:38 -04:00
e2acbfb566 fix(web): don't validate double-sided settings when the feature is disabled (#422)
* fix(web): don't validate double-sided settings when the feature is disabled

Saving anything on the Display tab failed with a 400 when double-sided
mode was off:

    Double-sided copies (2) must divide chain length (3) evenly

The Display form posts every field in one request, including
double_sided_copies (default 2) and double_sided_axis, whether or not
the Enabled checkbox is ticked. The server block was gated only on "is
any double-sided field present in the payload" — it wrote
ds_config['enabled'] but never read it. A user with chain_length: 3 and
the untouched default copies: 2 was locked out of saving any display
setting at all: brightness, GPIO slowdown, Vegas, sync.

Gate the checks on the enabled flag:

- Divisibility against chain_length/parallel is hardware-relational and
  only runs when the feature is on.
- Structural checks (copies parses as an int in 2..8, axis in the
  whitelist) still 400 when enabled; when disabled they drop the value
  and leave the stored one untouched rather than rejecting the save.

The runtime already gated correctly (_resolve_double_sided returns None
when disabled), so nothing there changes.

Also in the Display tab:

- Hide Copies / Split Axis until Enabled is ticked, mirroring the Vegas
  Scroll pattern. Hidden rather than disabled, so the fields keep
  submitting and the server still sees an 'off' state to persist.
- Fix the save toast: the form's handler read xhr.responseJSON, a jQuery
  property that doesn't exist on a native XMLHttpRequest, so it was
  always undefined and every save reported a green "Display settings
  saved" — even the 400s. Parse responseText and use the real status.

Tests: two existing double-sided tests asserted 200 on payloads that the
divisibility check (added later, in #373) turns into 400s; the first now
supplies matching hardware values and the second passes as written now
that a disabled save skips the check. Added coverage for the reported
regression, for bad values while disabled, and for the check still
firing when enabled.

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

* fix(web): treat only 2xx as a successful display save

Review feedback on #422.

- showDisplaySaveResult tested `xhr.status >= 400` for failure, so a
  network error — which reports status 0 — was waved through as
  "Display settings saved". That's the same class of false-success bug
  this branch set out to fix. Test the 2xx range instead, and let a
  response body refine a successful verdict without overturning a
  failed one.
- Annotate the _copies_fits_hardware helper, matching the annotated
  helpers already in api_v3.py.
- Cover the vertical divisibility branch: chain_length 2 would divide
  evenly, so only parallel 3 can produce the rejection, which pins the
  branch to the right hardware dimension.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-28 16:54:42 -04:00
3872a68ff7 Surface plugin update availability on the plugins page (#421)
* Surface plugin update availability on the plugins page

The plugin manager page already showed each installed plugin's version and
had an Update button, but nothing told users an update actually existed —
they had to guess. The store manager already compares the installed
manifest version against the registry's latest_version for its reinstall
decision; this surfaces that same signal in the UI.

- api_v3 /plugins/installed now returns `latest_version` (from the registry
  cache, no extra network call) and an `update_available` flag computed by a
  new semver-aware helper `_is_plugin_update_available`. A locally modified
  plugin whose version is ahead of the registry is not flagged.
- The installed-plugin card shows "vX.Y.Z available" next to the installed
  version, and the Update button becomes emphasized ("Update to vX.Y.Z" with
  a gentle pulse) when a newer version is published — mirroring the app's own
  update banner styling.
- Added tests for the helper and the endpoint fields.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EW8yDbsk8EceDpq6Hjgqj

* Catch InvalidVersion specifically in plugin update comparison

Address review feedback: the version comparison caught a blind `except
Exception` (ruff BLE001). Split the two failure modes and catch each
specifically — ImportError for a missing `packaging` (a core dependency)
and InvalidVersion for an unparseable version string — while preserving
the existing "surface the mismatch" fallback behavior. Added a test for
the unparseable-version path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EW8yDbsk8EceDpq6Hjgqj

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 08:06:36 -04:00
989162d28f fix(web): custom-feed logo upload uses the wrong request/response contract (#420)
Every custom-feed logo upload has been failing: handleCustomFeedLogoUpload
posts the file under field name "file" and reads the response from
data.data.files, but the backend endpoint it calls
(api_v3.upload_plugin_asset, /api/v3/plugins/assets/upload) requires the
field name "files" (checks 'files' not in request.files, 400s "No files
provided" otherwise) and returns the result in a top-level "uploaded_files"
key - there is no nested "data" wrapper in the response at all. Confirmed
by reading the endpoint directly, and cross-checked against
file-upload-single.js, a sibling widget that uses the correct contract
against the same endpoint.

- formData.append('file', file) -> formData.append('files', file)
- data.data.files / data.data.files[0] -> data.uploaded_files /
  data.uploaded_files[0]

No other call sites in this file used the stale contract (grepped for both
patterns after the fix - zero remaining). The response entries' 'path' and
'id' fields (both read further down in the same handler) are unaffected -
only the wrapper shape was wrong.

Found incidentally while re-verifying a CodeRabbit review on an unrelated
PR (#417) that had deleted a differently-named dead file
(custom-feeds-helpers.js) with the same bug; this widget (custom-feeds.js)
is the live code path and was never touched by that PR.

Validation: brace/paren balance check; explicit assertions that the old
field name and response shape no longer appear anywhere in the file. No
Python changed, no existing tests cover this endpoint's client flow.


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

Signed-off-by: Chuck <33324927+ChuckBuilds@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 11:07:36 -04:00
cdf03fb107 Add skin system: user-installable visual overlays for sports scoreboards (#419)
* Add skin system: user-installable visual overlays for sports scoreboards

Skins restyle a scoreboard's live/recent/upcoming rendering while the
host plugin keeps doing data fetching, scheduling, caching, live
priority, and vegas mode — the anti-fork alternative for users who only
want a different layout.

- src/skin_system/: ScoreboardSkin API, SkinContext (canvas + adaptive
  layout + logo/font helpers), discovery/loading runtime with API major
  version gating and per-skin module namespacing
- src/base_classes/sports.py: _render_game() seam at the three
  _draw_scorebug_layout call sites; skin-first with built-in fallback,
  3-strikes session disable, slow-render warning; per-mode skin config
- scripts/validate_skin.py: headless multi-mode/multi-size validator
  with bundled per-sport fixtures (no hardware or network needed)
- skins/example-classic-baseball/: working reference skin
- Web UI: served-schema Visual Skin dropdown (validation never
  enum-restricted, so uninstalled skins can't invalidate configs) and
  GET /api/v3/skins
- Store: registry entries with type "skin" install to skins/
- docs/SKIN_SYSTEM.md (architecture), docs/CREATING_SKINS.md (author
  guide incl. Claude Code prompt), view-model contract locked by tests

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

* Address review feedback on skin system

- skin_runtime: cache the entry module so the 2nd/3rd load of the same
  skin (live/recent/upcoming hosts) doesn't re-execute it with unbound
  sibling aliases; rebind cached sibling modules to their bare names
  around entry import and restore prior bindings after; include per-
  manifest mtimes in the discovery cache fingerprint so in-place skin
  updates are picked up
- sports.py: count render_skin_card exceptions toward the 3-strike
  session disable
- store_manager: validate skin ids (pattern + resolved-path containment
  in skins/), reject registry/manifest id mismatches, and stage+validate
  downloads in a temp sibling before replacing an existing skin
- schema_manager: leave the schema untouched when the configured skin
  value is a per-mode mapping (a string dropdown could overwrite it)
- validate_skin.py: reject non-positive sizes and non-object --options
  at parse time; support --output-dir outside the repo; type annotations
- example skin: validate accent_color once at load with logged fallback
- fixtures: pregame 0-0 scores in football/hockey upcoming fixtures
- api /skins: rely on the self-invalidating discovery cache instead of
  force_refresh
- docs: valid JSON manifest example, load_logo caching semantics spelled
  out, language ids on fenced blocks
- tests: view-model contract test now exercises the real extractor;
  regression test for repeated same-skin loads with sibling modules

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-18 11:07:08 -04:00
6a9d8014e5 Tag plugin logs structurally and surface the active plugin in System Logs (#418)
- get_logger() now returns a PluginLoggerAdapter when given a plugin_id,
  so every plugin log call is stamped with plugin_id automatically instead
  of only calls that explicitly passed extra={'plugin_id': ...}. This makes
  the "[Plugin: x]" prefix reliable in the journalctl-backed log stream.
- display_controller publishes the currently active mode/plugin to the
  shared cache whenever it changes, exposed via a new
  GET /api/v3/display/current-status endpoint.
- System Logs page: adds a "Now showing" banner backed by that endpoint, a
  plugin filter dropdown (populated from parsed log lines), a plugin badge
  per log entry, and fixes log parsing to handle the short-iso timestamp
  format journalctl actually returns (the old regex only matched syslog
  timestamps, so level/plugin extraction silently never ran).

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-18 10:59:36 -04:00
c90129285c Web UI: mobile navigation, guided onboarding, basic/advanced config tiering + performance (#417)
* chore(web): remove dead legacy client-side plugin-config generator (~2,300 lines)

Plugin config forms have been rendered server-side (plugin_config.html via
GET /partials/plugin-config/<id>) since the HTMX migration; the old
client-side generator survived as unreachable code. Verified dead by call
graph, not by naming: showPluginConfigModal and showGithubTokenInstructions
have zero callers anywhere in templates or JS, and everything removed here
is reachable only from those two roots.

Removed:
- plugins_manager.js: showPluginConfigModal, generatePluginConfigForm,
  generateFormFromSchema, generateFieldHtml, generateSimpleConfigForm,
  handlePluginConfigSubmit, the modal's JSON-editor view (initJsonEditor,
  switchPluginConfigView, syncFormToJson/JsonToForm, saveConfigFromJsonEditor,
  resetPluginConfigToDefaults, displayValidationErrors, closePluginConfigModal,
  savePluginConfiguration, currentPluginConfigState), their exclusive helpers
  (getSchemaPropertyType, escapeCssSelector, dotToNested, collectBooleanFields,
  normalizeFormDataForConfig, flattenConfig, loadCustomHtmlWidget), the
  orphaned-modal cleanup block, the modal's listener wiring, and the
  never-invoked showGithubTokenInstructions/closeInstructionsModal pair.
- plugins.html: the #plugin-config-modal markup those functions drove.
- base.html: the deprecated pluginConfigData() component and the
  window.PluginConfigHelpers shim (only ever called by pluginConfigData).

Deliberately kept, verified still live:
- renderArrayObjectItem, getSchemaProperty, escapeHtml/escapeAttribute
  (window-exposed for the top-level array-of-objects handlers the
  server-rendered form uses), toggleNestedSection, addKeyValuePair/
  addArrayObjectItem families, executePluginAction, and
  window.currentPluginConfig = null init (file-upload.js and
  executePluginAction read it, optional-chained).
- app()'s internal generateConfigForm/generateSimpleConfigForm methods in
  base.html: unreachable now but embedded in the live Alpine component;
  excising methods from a live object is deferred to keep this change
  zero-risk.

Validation: every deletion seam inspected line-by-line; Jinja parse of both
templates passes; repo-wide sweep confirms zero remaining references to any
deleted function or element id (deleted ranges contained no Jinja tags).

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

* feat(web): mobile navigation drawer + responsive CSS gap fixes

Phones previously got the desktop layout squeezed: ~12 system tabs plus one
tab per installed plugin wrapped into many rows of small pill buttons, and
the header's settings-search and system-stats widgets were dropped entirely
(hidden below their breakpoints, never relocated).

- Off-canvas nav drawer below md: the existing nav markup (system tab row +
  #plugin-tabs-row, including dynamically injected plugin tabs) is wrapped in
  a #site-nav container that CSS repositions into a slide-in drawer on small
  screens. Same DOM nodes, same @click handlers, nothing duplicated. Tabs
  become full-width rows with 44px+ touch targets. A hamburger button
  (md:hidden) in the header and a backdrop toggle the new mobileNavOpen
  Alpine state (added to both app() definitions, mirroring activeTab).
  Clicking any tab, a search result, or the backdrop closes the drawer.
  At md+ hard CSS guards make all drawer styles inert - desktop renders
  exactly as before.
- Header widgets relocated, not hidden: placeHeaderWidgets() in app.js moves
  the #settings-search-wrap and #system-stats nodes (same elements, listeners
  intact - both are looked up by id from SSE/search code, so they must never
  be duplicated) into the drawer below md and back into the header above it,
  via a matchMedia listener.
- Fixed 13 breakpoint utility classes that templates referenced but app.css
  never defined (sm:block, sm:grid-cols-2, sm:text-sm, md:block, md:w-auto,
  lg:block, lg:flex, lg:w-64, xl:grid-cols-2/3, 2xl:grid-cols-2/3/4). This
  was a live bug: 'hidden sm:block' on the search box and 'hidden lg:flex'
  on the stats meant BOTH were invisible at every screen width. Audit method
  (repeatable): diff classes used in templates vs defined in app.css.
- Mobile modal sizing: one global rule caps .modal-content at 95vw/90vh with
  internal scroll below 640px - covers every modal without per-template
  changes.
- Horizontal-scroll affordance: pure-CSS edge-fade shadows on
  .overflow-x-auto containers (scrolling-shadows technique), plus larger
  in-table touch targets below md.

Validation: breakpoint used-vs-defined audit now returns zero gaps; Jinja
parse of base.html passes; all changes to desktop behavior are additive
(new utilities) or scoped inside max-width media queries.

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

* feat(web): x-advanced schema flag groups plugin config fields under a collapsed Advanced Settings section

Plugin config pages show every schema property at equal visual priority,
which overwhelms first-time users. Plugin authors can now add
"x-advanced": true to any flat (non-object) property in config_schema.json
to move it into one collapsed "Advanced Settings (N)" section rendered after
the basic fields - progressive disclosure with zero loss of control.

Implementation: the main render loop in plugin_config.html splits ordered
properties into basic/advanced tiers; the advanced group reuses the exact
.nested-section/.nested-content/toggleSection() shell that nested object
sections already use, so the settings search's expand-on-match behavior
works on advanced fields with no JS changes. Object-type properties ignore
the flag (they already render as their own collapsible sections). No
backend change needed: jsonschema ignores unknown x-* keywords exactly as
it does for x-widget/x-propertyOrder.

Documented in docs/widget-guide.md alongside the other x-* extensions.

Validation (rendered with real Jinja, not just parsed):
- synthetic schema with 2 advanced fields: basic fields render before the
  section, advanced inside the collapsed shell, count badge correct,
  x-advanced on an object property correctly ignored
- schema without any x-advanced: output is identical to the pre-change
  template (whitespace-normalized diff against git HEAD's version)

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

* feat(web): Display Settings basic/advanced split + live total-resolution readout

The Hardware Configuration card showed ~17 fields at equal priority; a new
user only needs 7 of them to get a correctly-sized, correctly-colored image
(rows, cols, chain_length, parallel, brightness, hardware_mapping,
led_rgb_sequence). The other 10 (multiplexing, panel_type, row_address_type,
gpio_slowdown, rp1_rio, scan_mode, pwm_bits, pwm_dither_bits,
pwm_lsb_nanoseconds, limit_refresh_rate_hz) now live in a collapsed
"Advanced Hardware Settings" section using the same nested-section shell as
plugin config forms, so toggleSection() and settings-search auto-expand work
unchanged. led_rgb_sequence moved up beside brightness/hardware_mapping
(2-col grid became 3-col). No field was removed or renamed; the form still
posts the same names to /api/v3/config/main.

Also adds a live "Your display: W x H pixels" readout under the four sizing
fields (width = cols x chain_length, height = rows x parallel - the exact
math the chain-length tooltip describes in prose), recomputed client-side on
every input event, no round-trip.

Deviation from plan, deliberate: disable_hardware_pulsing / inverse_colors /
show_refresh_rate stay in their separate "Display Options" card rather than
moving across cards - relocating fields between form sections risks
regressions for no decluttering gain in the card users complained about.

Validation (real Jinja render): all 17 hardware fields present exactly once,
basic fields render before the advanced section and the 10 advanced fields
inside it, div count balanced (71/71), readout + recompute script present.

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

* feat(web): plugin install auto-enables + persistent restart nudge

Getting a plugin onto the display used to take three disconnected manual
steps: install from the store, flip its enable toggle, then restart the
display service - with no in-UI hint that steps 2 and 3 were needed (only
docs/GETTING_STARTED.md mentions it).

- installPlugin() now enables the plugin immediately on successful install
  (owner-confirmed behavior change: always auto-enable, no opt-out; users
  who don't want it running toggle it off as before), then shows a
  persistent toast ("... restart the display to show it") with an inline
  "Restart Now" button wired to the existing restartDisplay() - the same
  function the three existing Restart Display buttons call.
- notification.js: show() accepts optional { actionLabel, onAction } to
  render one inline action button per toast. Callbacks are stored per
  notification id and cleaned up on dismiss; a new triggerAction() public
  method runs the callback and dismisses. The global showNotification()
  shorthand now forwards a full options object as its second argument
  (legacy type-string calls unchanged).

Scope note: applies to the plugin store's install path (window.installPlugin).
The custom-registry install path keeps its existing behavior.

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

* feat(web): dismissible Getting Started checklist on Overview

New users land on a dense multi-tab dashboard with no suggested order of
operations (the only guided flow is the WiFi captive portal). This adds a
non-gating checklist card at the top of Overview with five steps, each a
deep link that switches to the right tab (and closes the mobile nav drawer):

1. Set panel size            -> Display tab   (done: rows/cols/chain_length > 0)
2. Set timezone/location     -> General tab   (done: differs from template
                                               defaults America/New_York / Tampa)
3. Install a plugin          -> Plugins tab   (done: /api/v3/plugins/installed
                                               non-empty)
4. Enable a plugin           -> Plugins tab   (done: any installed plugin enabled)
5. Configure it              -> Plugins tab   (done: first enabled plugin has >=1
                                               saved value differing from its
                                               schema defaults)

Steps 1-2 are computed server-side in Jinja from main_config (already in the
partial's context); 3-5 client-side from existing endpoints. No new backend
state: dismissal persists in localStorage (mirroring the reconciliation
banner's sessionStorage pattern one section up); deep links use the same
_x_dataStack app-data access as settings-search.js. Disclosed heuristic
limit: values left at legitimate defaults (a user actually in Tampa) read
as "not done".

Validation: real Jinja render across 3 config variants confirms the
server-side done-flags flip correctly; div balance intact; /plugins/config
response shape (config dict directly in .data) verified against api_v3.py.

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

* feat(display): drag-and-drop plugin rotation order for the primary display mode

The primary rotation's order was invisible and unconfigurable: modes are
registered in parallel-load COMPLETION order, so rotation order actually
varied between restarts. Only the niche Vegas Scroll mode had a working
order UI. This adds real, persisted ordering end to end:

Backend:
- config.template.json: new display.plugin_rotation_order (default [],
  fully backward compatible).
- display_controller.py: _apply_plugin_rotation_order() rebuilds
  available_modes grouped by plugin per the configured list (each plugin's
  modes keep their declared order; unlisted plugins follow in existing
  relative order; empty config = exact no-op). Applied at startup after
  parallel load and after live enable/disable reconcile (before the
  existing _resync_mode_index_after_change, which preserves the current
  mode). Mirrors vegas_mode get_ordered_plugins() semantics.
- api_v3.py save_main_config: accepts plugin_rotation_order as a JSON
  array (same parse/guard pattern as vegas_plugin_order).

Frontend:
- New shared widget static/v3/js/widgets/plugin-order-list.js: the Vegas
  section's drag-and-drop list factored out verbatim (native HTML5 drag
  events, saved-order-first rendering, hidden-input JSON sync),
  parameterized by container/order-input/optional exclude-checkbox/badge.
- display.html: Vegas section now calls the shared module; its ~130-line
  inline copy of the same logic is deleted.
- durations.html: new "Rotation Order" card above the durations grid using
  the same module, posting plugin_rotation_order with the existing form.

Deviation from plan, deliberate: durations stay as their own mode-keyed
grid rather than inline in the drag rows - verified display_durations keys
are MODE names (display_controller.py resolves duration per mode_key), not
plugin ids, and one plugin can own several modes, so the planned 1:1
inline pairing was wrong.

Validation: py_compile on both Python files; _apply_plugin_rotation_order
unit-tested standalone (configured order applied, empty-config no-op,
unknown ids skipped - 3/3); both templates render with balanced divs, the
hidden input carries the saved order, and the old inline implementation is
confirmed gone; config.template.json parses.

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

* feat(web): serve the interface at / — /v3 kept as a legacy alias

The user-visible URL no longer carries the interface version: the pages
blueprint is now registered un-prefixed (primary) AND at /v3 (second
registration, name='pages_v3_legacy'), so:

- http://<device>/ serves the interface directly (the old @app.route('/')
  redirect is removed — the blueprint's own index takes its place)
- every existing /v3/... bookmark and all the hardcoded /v3/partials/...
  fetches in templates/JS keep working verbatim through the alias mount —
  zero template/JS churn, zero broken links
- url_for('pages_v3.*') resolves against the primary registration, so all
  server-side redirects (captive portal detection endpoints) now emit
  un-prefixed URLs
- the AP-mode captive-portal allowlist learned the un-prefixed page paths
  (/setup, /partials/, /settings/, /plugin-ui/) so setup-mode requests
  don't redirect-loop
- /api/v3 and the templates/v3, static/v3 directories are deliberately
  untouched (internal, invisible to users; owner-confirmed scope)

Validation: dual registration mechanics tested against real Flask (test
client): /, /v3, /v3/ redirect, partials and /setup reachable on both
mounts, url_for yields un-prefixed paths; py_compile passes.

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

* perf(web): stream the preview PNG raw instead of PIL decode + re-encode

display_preview_generator() opened each changed snapshot with PIL and
re-encoded it to PNG just to base64 it — but /tmp/led_matrix_preview.png
already IS a PNG, written atomically by the display service (tmp file +
os.replace in display_manager.py), so a partially-written file can never be
observed. Read the bytes and base64 them directly: identical payload
(front-end consumes data:image/png;base64 — verified in base.html), one
full image decode+encode per frame less on the same Pi that's driving the
matrix. The existing mtime skip and viewer-marker throttling are unchanged
(they already covered the "skip unchanged frames" concern).

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

* perf(web): gzip response compression via flask-compress

The interface ships a ~5,000-line HTML shell and >20k lines of JS
uncompressed; on phone/WiFi that dominates load time. Flask-Compress
gzips/brotlis compressible responses transparently.

- Optional dependency, same graceful pattern as flask-limiter: missing
  package = uncompressed responses, no crash.
- SSE safety verified empirically against the real package (1.24): an
  actual streamed text/event-stream response comes back with no
  Content-Encoding while a large HTML response gzips — the display
  preview / stats / logs streams are unaffected.
- Added flask-compress>=1.14 to web_interface/requirements.txt.

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

* perf(web): gate verbose console logging behind the existing pluginDebug switch

plugins_manager.js, base.html's inline scripts, and app.js emitted 198
console.log calls in production - including per-interaction [DEBUG] dumps -
costing main-thread time and drowning real errors in noise.

- New window.debugLog() gate defined in base.html's first inline script
  (before any other script runs): forwards to console.log only when
  localStorage.pluginDebug === 'true' - the SAME switch plugins_manager.js
  already used for its _PLUGIN_DEBUG_EARLY logs, so existing debug workflow
  docs stay valid. Exposed as window.LEDMATRIX_DEBUG for other scripts.
- Mechanically rewrote console.log( -> debugLog( in plugins_manager.js
  (127), base.html (64), app.js (7). Verified no occurrences lived inside
  string literals before rewriting; console.error/console.warn untouched.
- app.js's no-Alpine showNotification fallback restored to console.info -
  it's a user-facing last resort, not debug output.

Both load paths are safe: the gate is the first inline <script> in <head>,
and every rewritten file loads deferred after it.

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

* perf(web): vendor CDN assets locally — LAN-speed loads, fully offline-capable

Font Awesome, CodeMirror, and htmx were fetched from cdnjs/unpkg on every
fresh page load, adding third-party round-trips on a device that often
lives on a local network (and htmx was local-only in AP mode, meaning two
different loading behaviors to reason about).

- Vendored pinned copies under static/v3/vendor/: Font Awesome 6.0.0
  (css/all.min.css + the 8 webfonts it references relatively) and
  CodeMirror 5.65.2 (core, javascript mode, closebrackets/matchbrackets
  addons, base + monokai css) - ~1.1 MB total, exact versions the CDN tags
  pinned.
- htmx + sse + json-enc extensions now load from the existing local copies
  (verified 1.9.10, matching the CDN pin) on EVERY network, not just AP
  mode; the pinned CDN copies remain as a one-shot rescue fallback,
  mirroring the pattern Alpine already used. The convoluted isAPMode
  source-flipping logic collapses away.
- Dropped the CDN preconnect/dns-prefetch hints (no longer on the critical
  path).
- Fixed a latent bug while relinking CodeMirror: the loader requested
  mode/json/json.min.js, which does not exist on cdnjs (HTTP 404 verified)
  - it 404'd on every JSON-editor open. JSON highlighting comes from the
  javascript mode; the phantom entry is removed.

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

* perf(web): extract 3,850 lines of inline JS from base.html to cacheable static files

base.html shipped ~4,200 lines of inline JavaScript inside the HTML
document, re-downloaded and re-parsed on every page load (gzip helps the
transfer, but inline scripts can never be browser-cached). The four
largest blocks - none containing any Jinja syntax, verified by scanning
every inline block for {{ }} / {% %} - now live as static files served
with the app's existing mtime-versioned immutable caching:

- js/htmx-config.js (246 lines): HTMX swap/script-execution config,
  toggleSection helpers
- js/app-early.js (346 lines): early helpers + the app() stub that must
  precede Alpine init
- js/app-shell.js (2,997 lines): SSE wiring + the full Alpine app()
  implementation and tab logic
- js/custom-feeds-helpers.js (262 lines): custom-feeds table helpers

Each replacement <script src> is CLASSIC (no defer/async) at the exact
position of the inline block it replaces - identical execution timing and
DOM visibility to inline scripts, so relative ordering with the deferred
scripts and with each other is unchanged. base.html drops from ~4,940 to
1,079 lines.

Validation: extraction proven lossless by programmatically reassembling
the four files back into the template and comparing against git HEAD -
byte-for-byte identical. Jinja parse passes; script open/close tags
balanced (53/53, after excluding a literal "<script>" inside an HTML
comment).

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

* fix(web): size the live preview from the PNG's real dimensions, not config

The initial SSE render sized the preview image (and both overlay canvases)
from the server-reported config dimensions (cols x chain_length,
rows x parallel), while the scale slider's re-render path sized from
img.naturalWidth/naturalHeight. Whenever the snapshot PNG's actual size
disagrees with the config (stale config, display service not restarted
after a hardware change), the initial render stretched the image at a
fractional ratio - blurry despite image-rendering: pixelated - and
touching the scale slider "fixed" it. Reported live on the devpi test rig.

Both paths now size from the loaded image's natural dimensions inside
img.onload (which also removes a transient wrong-size flash between
src assignment and load). The meta label now reports the true snapshot
size. The preview card also gets overflow-x-auto so on narrow screens a
wide preview scrolls at its exact pixel-perfect size instead of being
squeezed into the viewport (fractional downscaling of pixel art also
reads as blur).

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

* feat(web): fold Display Options into the advanced dropdown; Vegas above Double-Sided

Owner-requested layout refinement of the Display Settings tab:

- The "Display Options" card (disable_hardware_pulsing, inverse_colors,
  show_refresh_rate, use_short_date_format, Dynamic Duration) moves inside
  the collapsed advanced section, now titled "Advanced Hardware & Display
  Options (15)". Hidden form fields still submit with the form, and
  settings search still auto-expands the section on match, so nothing is
  lost - the tab just leads with the essentials.
- The "Vegas Scroll Mode" section moves above "Double-Sided Display".
  New section order: Hardware (+ advanced dropdown) > Vegas Scroll >
  Double-Sided > Multi-Display Sync.

Validation (real Jinja render): all 23 field names present exactly once,
divs balanced (70/70), the five Display Options fields render inside the
advanced section's bounds, and section markers confirm the new order.

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

* feat(web): Getting Started items are manually checkable; card auto-hides when complete

Two gaps reported from live testing on the devpi rig:

1. The timezone/location step never showed done for a user whose real
   timezone IS the shipped default (America/New_York) - the heuristic can
   only detect difference-from-default, not "user saved this". Clicking an
   item's checkbox now toggles it done manually (persisted per browser in
   localStorage), so any heuristic false-negative is one tap to clear.
   Clicking the item text still deep-links to its tab.
2. The card now hides itself automatically once every step is done
   (auto-detected or manually checked) - previously it stayed until the X
   was clicked.

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

* chore(web): CI cleanup — declare debugLog global, fix entity-unescape order, drop v3 from UI branding

- Add debugLog to the /* global */ headers of the six JS files that call it
  (defined in base.html's first inline script) — resolves the wall of
  "'debugLog' is not defined" ESLint errors failing the Codacy check.
- Fix the two js/double-escaping CodeQL alerts in app-shell.js: the
  entity-unescape chains decoded &amp; before &lt;/&gt;, so a value
  containing a pre-escaped "&amp;lt;" wrongly double-decoded to "<".
  &amp; now decodes last (standard order). Pre-existing bug, made visible
  when the inline scripts moved into scannable .js files.
- Page title / header drop the "- v3" suffix, matching the de-versioned
  user-facing URL.

The remaining 7 CodeQL alerts are pre-existing patterns newly visible to
scanning (CodeQL doesn't see inline template JS): 4 github.com/htmx.org
URL-substring checks (the htmx ones match error-message text, not URLs —
false positives in context) and 1 innerHTML XSS-through-DOM in the GitHub
install flow. Triage/fix deferred to a focused follow-up rather than
expanding this PR.

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

* fix(web): add the missing nav tab for the Rotation & Durations page

The durations partial (/partials/durations) has existed as a route with no
nav tab and no content panel referencing it - an orphaned page. That made
the new rotation-order UI unreachable through the interface (caught by the
owner testing on the rig; my endpoint-level tests fetched the partial by
URL and never noticed the missing entry point).

- New "Rotation" tab (fa-rotate icon, verified present in the vendored
  FA 6.0.0 css) between Display and Backup & Restore, wired exactly like
  the other tabs (#durations-content + hx-get + loadtab; loadTabContent()
  is fully generic, so no JS changes needed).
- Page heading updated from "Display Durations" to "Rotation & Durations"
  to match its content since the rotation-order card landed.

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

* fix(web): Rotation & Durations page lists every enabled plugin's screens

The durations grid looped over display.display_durations, which nothing has
ever populated (verified {} on a real production install) - so the page
rendered no duration fields at all. Worse, its inputs posted bare mode
names, which save_main_config's endswith('_duration') filter silently
dropped: the page was broken in both directions, unnoticed because it was
also unreachable (previous commit).

- pages_v3._load_durations_partial now builds one entry per display mode of
  every ENABLED plugin via plugin_manager.get_plugin_display_modes()
  (falling back to the plugin id), overlaid with saved values, defaulting
  to the display controller's 30s. Grouped per plugin, sorted by name.
  Saved keys not owned by any enabled plugin stay visible under "Other
  saved entries" instead of vanishing.
- durations.html renders the grouped inputs, named duration__<mode_key>
  (mode keys are arbitrary, so they can't use the *_duration suffix
  convention), with an explanatory empty state when no plugins are enabled.
- api_v3.save_main_config accepts the new duration__<mode> fields and
  writes them into display.display_durations under the bare mode key -
  exactly what the display controller reads
  (display_durations.get(mode_key, 30)).

Validation: py_compile both blueprints; Jinja render with 3 groups asserts
grouped inputs, saved-value overlay, stale-entry group, empty state, and
div balance.

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

* feat(web): restart-pending banner, unsaved-changes guard, installable web app

Three usability improvements from live testing feedback:

- Restart-pending banner: every successful POST to /api/v3/config/main
  (display hardware, rotation/durations, general settings) now raises a
  persistent banner - "Configuration saved, restart the display to apply" -
  with a Restart Now button that posts restart_display_service directly.
  Backed by sessionStorage so it survives tab switches and reloads until
  restarted or dismissed. Plugin config saves are deliberately excluded:
  they apply live via the display process's config watcher.
- Unsaved-changes guard: plugin config panels are Alpine x-if templates,
  so navigating away destroys the panel and revisiting re-fetches it -
  edits were silently discarded. Forms now mark themselves dirty on input
  (cleared on successful submit), a capture-phase click handler confirms
  before a lossy tab switch, and beforeunload guards full page unloads.
  System tabs (x-show, persistent DOM) are exempt - no false prompts.
- Installable web app: manifest.json (standalone display, dark theme) +
  generated LED-grid icons (192/512 maskable + 180 apple-touch), linked
  from base.html. "Add to Home Screen" now yields an app-like fullscreen
  experience; no service worker, so zero behavioral risk.

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

* test(web): smoke tests + static-analysis audits for the web UI

Guardrails so this branch's fix classes can't regress silently:

- test_web_smoke.py (24 tests): boots the pages blueprint with the same
  dual registration app.py uses and asserts every page/partial returns 200
  with its load-bearing markers (nav wiring, getting-started card, advanced
  section, rotation order card, per-mode duration inputs), the /v3 legacy
  alias serves everything, all critical static assets (incl. vendored
  fontawesome/codemirror, PWA manifest/icons) are served, durations group
  per plugin with the leftover bucket, and the advanced-hardware section
  really contains the tuning fields. Would have caught this session's
  unreachable-durations-page and orphaned-tab bugs instantly.
- test_web_static_audit.py (3 tests): (1) every responsive utility class
  referenced in templates is actually defined in app.css - the
  silently-no-op class bug that left the header search box invisible at
  every width; (2) every url_for('static', ...) reference points to a real
  file; (3) any JS file calling the debugLog global declares it in a
  /* global */ header.

All 40 web tests pass (24 + 3 new, 13 existing) under pytest + Flask.

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

* feat(web): floating live preview + per-plugin "Preview on display", drawer a11y

Preview-while-configuring:
- Floating mini preview (fixed, bottom-right) available on every tab except
  Overview, fed from the same SSE display stream by updateDisplayPreview -
  no new connections. Collapses to a round toggle button; open/closed state
  persists in localStorage; hides on Overview where the full preview lives.
- "Preview on display" button on every plugin config page header: runs that
  plugin on the real display for 60 seconds via the existing
  /display/on-demand/start API and opens the floating preview, closing the
  configure -> see-the-result loop.

Drawer/nav accessibility:
- aria-current="page" tracks the active tab (system + dynamic plugin tabs,
  matched via their Alpine @click expression), updated from the activeTab
  watcher so search deep-links and checklist navigation are covered too.
- Escape closes the mobile drawer and returns focus to the hamburger;
  opening the drawer moves focus to its first tab.

Validation: all 40 web tests pass; Jinja parse + div balance on both
touched templates.

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

* fix(web): resolve Codacy findings — DOM building over innerHTML, Map callbacks, misc lint

Verified each of the 27 reported findings against current code; all fixed
except one rule class skipped with reason below.

- app-early.js: plugin tab buttons are now built with createElement/
  createTextNode instead of innerHTML template strings (icon class and name
  come from plugin manifests - semi-trusted input; the old code escaped the
  name but interpolated the icon class). Both construction sites. Also the
  forEach arrow no longer returns tab.remove()'s value.
- plugin-order-list.js: rows, empty state, and error state all built with
  DOM APIs - the file no longer contains innerHTML at all (the now-unneeded
  escapeHtml/escapeAttr helpers are removed); MODE_LABELS is a Map so the
  vegas-mode lookup can't hit prototype properties.
- notification.js: actionCallbacks is a Map (get/set/delete) instead of a
  plain object - resolves the object-injection-sink and dynamic-delete
  findings; triggerAction also type-checks the callback.
- htmx-config.js: unused catch binding dropped; var -> const in the
  afterSettle handler; the swapped-<script> re-execution reads/writes
  textContent instead of innerHTML; the diagnostic form payload uses a
  null-prototype object so a field named __proto__ can't pollute.
- custom-feeds-helpers.js DELETED (with its script tag): all three of its
  functions (addCustomFeedRow, removeCustomFeedRow,
  handleCustomFeedLogoUpload) are shadowed by the deferred
  widgets/custom-feeds.js window assignments, which always win at call time
  - the copies were dead even when they lived inline in base.html. This
  also resolves the unused-function and unused-variable findings there.

Skipped: 4x "Non-serializable expression must be wrapped with $(...)" in
app-early.js - that rule targets code crossing a browser-automation
serialization boundary (e.g. page.evaluate); these are ordinary arrow
functions in plain browser code with no such boundary.

Validation: all 40 web tests pass (incl. the static-asset reference audit,
which confirms no template still points at the deleted file); Jinja parse OK.

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

* feat(web): update flow installs changed Python dependencies automatically

The in-app updater (Update Now banner -> git_pull action) stashed, pulled,
and purged plugins - but never touched Python dependencies. Any release
adding a package (e.g. this branch's flask-compress, which lives in
web_interface/requirements.txt) silently required an SSH session and a
manual pip install that most users will never do.

- git_pull now records HEAD before pulling; after a successful pull it
  diffs old..new and, if requirements.txt or web_interface/requirements.txt
  changed, installs exactly those via _pip_install_requirements - the same
  vetted root-visible sudo path the Tools-tab buttons use (with its
  existing graceful fallback when the sudo wrapper isn't configured).
  Results are appended to the update toast; a failure points the user at
  the Tools-tab button instead of failing the whole update.
- install_base_requirements (Tools tab) now also installs
  web_interface/requirements.txt - previously it only covered the root
  file, so web-only dependencies were unreachable from the UI entirely.

No install happens when the pull was already-up-to-date or when no
requirements file changed, so routine updates stay fast.

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

* fix(web): address CodeRabbit review — validation, a11y, perf, and privacy fixes

Verified each finding against current code. Fixed:

- api_v3: plugin_rotation_order is now strictly validated (JSON list of
  strings, 400 with a descriptive message otherwise) and popped from the
  payload before any further handling.
- display_controller: _apply_plugin_rotation_order defensively ignores a
  non-list value (keeps the existing rotation, logs a warning) and drops
  non-string entries; new logs carry the [DisplayController] prefix.
  Unit-tested both defensive paths.
- app.py: snapshot-read handler narrowed to OSError with debug logging;
  flask-compress ImportError now emits one structured warning with the
  install remedy.
- htmx-config: the response-error logger prints form FIELD NAMES only -
  values (API keys, passwords) never reach the console.
- plugin-order-list: saved order/exclusions normalized with Array.isArray
  (a saved "null" previously crashed .forEach); each row gained
  keyboard/touch-accessible move-up/move-down buttons (HTML5 drag events
  don't fire on most mobile browsers) that reorder and syncInputs()
  immediately alongside native drag.
- app-shell: window.installedPlugins setter always takes the new list
  (same-ID metadata/enabled updates were silently dropped); tab rebuild
  stays gated on ID changes. LED dot renderer reads the frame with ONE
  getImageData call instead of one per pixel (~9,200/frame at 192x48).
- plugins_manager: togglePlugin returns its request promise resolving the
  API outcome; the install flow now shows the "installed and enabled"
  toast (with Restart Now) only after enablement succeeds, and a warning
  without a restart offer when it fails.
- a11y: hamburger aria-label flips Open/Close with drawer state; both
  Advanced-section toggle buttons declare aria-controls/aria-expanded and
  the shared toggleSection() keeps aria-expanded in sync; move buttons
  have per-plugin aria-labels.
- Rotation/Vegas order-list bootstraps cap their retries (~5s) and show a
  reload hint instead of spinning forever; Alpine app-state lookups prefer
  [x-data="app()"] with a generic fallback.

Skipped, with reasons:
- executePluginAction arg order: caller (plugin_config.html) already
  passes (actionId, index, pluginId) matching the signature exactly.
- generateFieldHtml XSS, entity-unescape blocks, dotToNested pollution,
  and "app.loadInstalledPlugins" in app-shell: all inside the legacy
  client-side config cluster whose entry points are shadowed by
  plugins_manager.js / replaced by server-rendered forms (zero live
  callers, verified) - queued for wholesale deletion in the follow-up
  rather than patching dead code.
- custom-feeds-helpers.js findings (3): file was deleted in a prior commit.
- console.error/warn override removal and afterSwap script re-execution
  removal: deliberate pre-existing workarounds every partial's inline
  init currently depends on; reworking them safely needs isolated testing
  (follow-up), and the error suppression is already double-gated
  (insertBefore AND htmx match).
- "move durations bootstrap into a bundle": inline partial-scoped init is
  the established pattern for HTMX partials in this codebase.

Validation: all 40 web tests pass; py_compile on all touched Python; all
touched templates parse; rotation-order defensive paths unit-tested.

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

* chore(web): fix remaining real Codacy findings (2 of 6)

- htmx-config.js: two more unused catch bindings dropped (optional catch
  binding), matching the earlier fix.
- app-early.js: second forEach arrow (the stub updatePluginTabs copy)
  braced so the callback no longer returns tab.remove()'s value.

The other 4 findings ("Non-serializable expression must be wrapped with
$(...)") are deliberately NOT "fixed": that rule belongs to a
browser-automation (WebdriverIO-style) lint context and is misfiring on
ordinary arrow-function constants. Converting them to function
declarations would look compliant but BREAK the code - all four arrows
intentionally capture the enclosing Alpine component's `this` for the
stub-to-full enhancement logic. The right remedy is disabling that
pattern for this repo in Codacy's Code Patterns settings (or dismissing
the four findings), not a code change.

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

* fix(web): floating preview shows a frame immediately on open + is resizable

The floating preview opened empty and stayed empty until the display next
CHANGED - the SSE stream only pushes frames on change, and the panel only
consumed frames while already open, so the connection's initial frame
(sent while the panel was closed) was dropped. Reported from mobile
testing as "the button doesn't work".

- updateDisplayPreview now caches the latest frame globally regardless of
  panel state; opening the panel populates the image from that cache
  instantly, then live frames take over.
- Resizable: a size button cycles 192/256/384/512px presets (persisted per
  browser; works on touch), and desktop additionally gets a native drag
  handle (CSS resize: both). The image is fluid within the panel; on
  phones the panel is capped to the viewport width. The size icon
  (fa-up-right-and-down-left-from-center) is verified present in the
  vendored FA 6.0.0.

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

* fix(web): stop duration fields leaking into config root; isolate per-file dependency installs; fix test fixture leak

Verified each finding against current code.

- api_v3 save_main_config: both duration blocks (*_duration suffix fields
  and the newer duration__<mode> fields) only READ from `data`, never
  removed the keys. The generic "remaining keys" merge later in the same
  function has no skip-list entry for either pattern, so every duration
  field was ALSO written a second time as a bogus top-level config key
  (e.g. "clock_duration": 30 and "duration__mlb_live": 42 sitting at
  config root, alongside the correct nested
  display.display_durations.<key>). Confirmed by tracing the full
  function. Fixed by popping each handled key from `data` (same pattern
  already used for plugin_rotation_order) and validating strictly: a
  non-integer duration now returns 400 with a message naming the
  offending field/mode instead of silently logging and moving on (for the
  *_duration fields, which previously had zero validation at all).
- api_v3 dependency-install loops (git_pull's post-update sync and
  install_base_requirements): _pip_install_requirements can raise
  subprocess.TimeoutExpired or OSError (confirmed: install_requirements_file
  in permission_utils.py never catches either internally, despite its
  docstring's "never raises on non-zero exit" only covering return codes).
  Both loops previously let one file's exception either abort the whole
  try block (skipping the second requirements file entirely) or propagate
  uncaught. Each file's install is now in its own try/except, so a timeout
  or OSError on one file is recorded as a labeled failure and the loop
  continues to the next file.
- test_web_smoke.py: the `client` fixture mutated the module-level
  pages_v3 Blueprint singleton's config_manager/plugin_manager directly
  with no teardown - since pages_v3 is shared across the whole pytest
  process (test_web_settings_ui.py touches the same attributes), this
  fixture's mocks could leak into whichever test ran next. Now saves the
  originals, yields the client, and restores them in a finally block.

Validation: py_compile passes; all 40 web tests pass with the now-generator
fixture.

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

* fix(web): repair garbled Advanced Hardware section description

An earlier sed-based text update concatenated the old and new copies of
this description instead of replacing one with the other, leaving a
duplicated sentence with the &mdash; entity broken into ".mdash;" (visible
as literal "mdash;" text on the page). Restored to one clean sentence.

Other findings from this review were already fixed in a prior commit
(installedPlugins setter) or are confirmed dead code with zero live
callers (executePluginAction/dotToNested/entity-unescape/generateFieldHtml,
all reachable only from the two unused savePluginConfig copies in
app-shell.js - grepped every template, no references) - same legacy
cluster flagged in earlier review passes, still queued for a dedicated
deletion follow-up rather than patched in place here.

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

* fix(web): remove redundant htmx:afterSwap script re-execution (was double-executing every partial's inline script)

Re-verified this CodeRabbit finding, previously deferred as "needs isolated
testing" - traced it to a confirmed, active bug rather than a style
concern:

htmx 1.9.10's own config defaults to allowScriptTags: true (confirmed in
the vendored htmx.min.js, which itself contains the same clone-and-reinsert
<script> mechanism internally). This means htmx ALREADY re-executes every
<script> tag in swapped content by default, exactly like a browser
navigating to a new page. The custom htmx:afterSwap listener in
htmx-config.js did the identical clone-and-reinsert a SECOND time on top of
htmx's own handling - so every inline <script> block in every HTMX-loaded
partial (overview, display, durations, plugin config, etc. - most partials
have one) executed twice per load.

Confirmed safe to delete outright, not just narrow: grepped every hand-written
JS file for a manual `dispatchEvent(... 'htmx:afterSwap' ...)` that might
have relied on this handler for a non-htmx code path (e.g. the direct-fetch
fallbacks like loadOverviewDirect) - none exists, so nothing depended on
this listener specifically; htmx's native handling covers every real
htmx-driven swap on its own.

Left in place, unchanged: the console.error/console.warn global override
a few lines up in the same file, which suppresses known-noisy
HTMX-timing-race messages. That one is a legitimate anti-pattern too
(broad substring matching can mask unrelated errors) but redesigning it
needs care to preserve real diagnostics while still hiding the specific
harmless races it targets - a scoped follow-up, not a same-day deletion
like this confirmed-duplicate handler.

Validation: all 27 fast web tests pass; JS brace/paren balance sanity
checked (no local Node/browser available in this sandbox to execute the
file directly - verify manually in-browser before merge).

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

* chore(display): add missing [DisplayController] prefix to the reconcile-complete log

Re-verifying the full CodeRabbit findings list against current code
surfaced one still-open item: the nitpick asked for the prefix on BOTH
rotation-related log lines, but only "Applied plugin rotation order" got
it in the earlier pass - "Plugin reconcile complete" was missed. No
message/argument/level change, matching the finding's own scope.

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

* fix(web): repair dead /v3/logs link on the display hardware-error banner

The "Logs tab" link in the display-settings simulation-mode banner was a
real <a href> to /v3/logs, but no such route has ever existed (log content
is loaded client-side via activeTab, not a dedicated page route) - the link
404'd regardless of the /v3 prefix change. Switch it to the same
activeTab-switching pattern the real nav uses.

* fix(web): raise display Rows field max from 64 to 128

Cols already allowed up to 128; Rows was capped at 64, which rejects
valid larger panel configurations (e.g. 128-row tile chains). No
server-side schema enforces a rows max, so this was purely an
overly-strict HTML input attribute.

* fix(web): remove redundant htmx.org substring check flagged by CodeQL

CodeQL flags .includes('htmx.org') as "incomplete URL substring
sanitization" - a false positive here, since this string is only ever
matched against console.error/warn message text to decide whether to
suppress a known-harmless HTMX timing-race log line, not used for any
URL-trust/redirect decision. The check was also redundant: 'htmx' is
already a substring of 'htmx.org', so the plain .includes('htmx') check
right next to it already covers every case the removed check did.

* fix(web): restore htmx script re-execution timing that Alpine x-data depends on

Removing the custom htmx:afterSwap script-reexecution handler (in a prior
commit, as a "duplicate execution" cleanup) broke every partial whose Alpine
x-data component function is defined by an inline <script> in that same
partial (e.g. wifi.html's wifiSetup()) - confirmed live via browser console:
"Alpine Expression Error: wifiSetup is not defined" on every field in the
WiFi tab.

Root cause: htmx's own native script execution runs during its "settle"
phase (~20ms after swap, per htmx's own defaultSettleDelay), but Alpine's
MutationObserver evaluates x-data on newly-inserted elements synchronously,
right as the swap lands - before settle. So the inline <script> defining
wifiSetup() was still un-run when Alpine tried to call it, and Alpine does
not retry a failed x-data evaluation later once the function does become
defined.

Fix: re-execute swapped <script> tags ourselves on htmx:afterSwap (which
fires synchronously, before settle, beating Alpine's observer), and disable
htmx's own native script re-execution (htmx.config.allowScriptTags = false)
so the same script doesn't also run a second time during settle - restoring
correct timing without reintroducing the original double-execution bug.

Also in this commit:
- fix XSS: unescaped repoUrl in a title attribute in renderSavedRepositories
- replace .includes('github.com') substring checks with real URL hostname
  validation (CodeQL: incomplete URL substring sanitization)

* fix(web): wait for async plugin install to finish before auto-enabling it

Confirmed live: installing hockey-scoreboard logged "installation queued"
(success) immediately followed by "enabling it failed" with a 404 "Plugin
not found" from /api/v3/plugins/toggle.

/api/v3/plugins/install runs the actual clone + plugin-manager discovery
asynchronously via an operation queue when one is configured - the response
installPlugin() was checking only means the operation was queued, not that
the plugin is installed yet. Calling togglePlugin() right after that
response 404s because plugin_manager hasn't discovered the new plugin.

Fix: reuse the same operation-polling mechanism uninstallPlugin() already
has (generalized pollOperationStatus() to take onComplete/onFailed/onTimeout
callbacks instead of hardcoding uninstall behavior) so installPlugin() waits
for the operation to actually complete before enabling it. Falls back to
enabling immediately when no operation_id is returned (direct/synchronous
install path, no queue configured).

* fix(web): resolve remaining valid findings from latest review pass

- custom-feeds.js: fix asset-upload contract mismatch (field name "file" ->
  "files", response read from top-level "uploaded_files" not "data.files") -
  same bug already fixed in this file on a separate branch/PR (#420), which
  this branch never received since they're independent PRs off main
- custom-feeds.js: add aria-label to the two icon-only "remove feed" buttons
- custom-feeds.js: move file-input reset into .finally() so a failed upload
  doesn't leave the input stuck holding the file, blocking retry of the
  same file
- app-shell.js: fix executePluginAction(pluginId, actionId) parameter
  order/count mismatch vs. its callers' (actionId, index, pluginId) -
  currently masked by plugins_manager.js's correct version overwriting this
  one at load time (classic vs. deferred script order), but worth fixing
  outright since it's an isolated, self-contained reassignment (not inside
  the Alpine app() object literal) and removes a latent footgun
- overview.html: align Alpine-state resolution with settings-search.js's
  two-tier getAppData() (also check appEl.__x.$data, not just _x_dataStack)

Verified already addressed by earlier passes (no change needed):
plugin_rotation_order validation, DisplayController log prefixes, togglePlugin
returning its promise for install-flow chaining, installedPlugins setter
always updating state, mobile-nav aria-label, toggleSection aria-expanded
sync, PluginOrderList bounded init retries (both display.html and
durations.html), plugin-order-list.js Array.isArray validation, batched
getImageData in the LED-dot preview renderer, app.py exception narrowing/
logging, form-submission log redaction.

Confirmed dead code, skipped (unreachable - zero template/JS callers,
verified via full-repo grep): dotToNested prototype-pollution hardening,
generateFieldHtml HTML-injection hardening, and the HTML-entity-unescape
block in JSON parsing - all three live only inside app-shell.js's two
legacy savePluginConfig implementations (one Alpine-method, one standalone),
neither of which any template or script calls. The real, live plugin-config
path is server-rendered via GET /partials/plugin-config/<id>.

Explicitly NOT reverted: the htmx:afterSwap script-execution listener. An
earlier finding batch asked to remove it as "duplicate" htmx behavior; that
was tried and reverted this session after live testing on hardware proved
it broke every partial whose Alpine x-data depends on an inline <script>
in the same partial (confirmed: WiFi tab hard-failed with "wifiSetup is not
defined"). Removing it again would reintroduce that regression.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 20:32:07 -04:00
112 changed files with 20374 additions and 7491 deletions
+45
View File
@@ -5,6 +5,10 @@ on:
push:
branches: [main]
# Both jobs only check out the repo and run pytest.
permissions:
contents: read
jobs:
plugin-safety:
name: Plugin safety harness + unit tests
@@ -31,3 +35,44 @@ jobs:
test/plugins/test_harness.py \
test/plugins/test_visual_rendering.py \
test/plugins/test_plugin_matrix.py
unit-tests:
name: Core unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
with:
python-version: "3.12"
cache: pip
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt -r requirements-test.txt
pip install RGBMatrixEmulator
# Safety net for the shared sports/scroll/style infrastructure. These
# suites existed but were not enrolled in CI, so a refactor of
# src/base_classes or src/common could regress them silently. Enrolled
# explicitly (not `pytest test/`) so known hardware-only suites don't
# break CI; grow this list as more suites are made headless.
- name: Run core unit suites
run: |
pytest --no-cov \
test/test_skin_system.py \
test/test_font_manager.py \
test/test_data_sources.py \
test/test_api_extractors.py \
test/test_scroll_helper.py \
test/test_scroll_helper_continuous.py \
test/test_adaptive_layout.py \
test/test_loader_compat_warning.py \
test/test_sports_base_characterization.py \
test/test_element_style.py \
test/test_sports_core_promotions.py \
test/test_sports_modes_promotions.py \
test/test_sports_capabilities.py
+1
View File
@@ -48,3 +48,4 @@ config/backups/
# Starlark apps runtime storage (installed .star files and cached renders)
/starlark-apps/
skin_renders/
+103
View File
@@ -0,0 +1,103 @@
# Changelog
Notable changes to the LEDMatrix core. The version below is the value of
`src.__version__`, which the plugin loader reports to compatibility checks and
which plugin manifests reference via `ledmatrix_min_version`.
**Why this file exists:** the plugin monorepo bundles fallback copies of several
core modules (see `docs/plugin-development/08-shared-sports-code.md` in
[ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins)). A plugin
may delete its bundled copy only when its manifest floors on the first core
release that ships the module — which requires module additions to be recorded
here, against a version number. When you add a module plugins will import via
`src.*`, note it in the Unreleased section and bump `src/__init__.py` in the
release that ships it.
## Unreleased
### Added
- `src/element_style.py` — per-element style resolver backing the
`x-style-elements` config-schema extension. Already consumed (behind guarded
imports with classic fallbacks) by the `of-the-day`, `ledmatrix-music`, and
`football-scoreboard` plugins.
- Core unit-test CI job enrolling the previously unenrolled suites (skin
system, data sources, API extractors, scroll helper, adaptive layout, loader
compatibility warning) plus new characterization tests for
`src/base_classes/sports.py` ahead of the shared sports-code unification.
- `src/base_classes/sports/``sports.py` is now a package (`core.py` +
`modes.py`). The import path is unchanged: `from src.base_classes.sports
import SportsCore` still works.
- Nine methods promoted onto the sports base classes from the plugins'
bundled copies, plus the override points `_favorite_key`,
`_config_schema_path` and `_font_root` and the class attributes
`FINAL_PERIOD` / `CLOCK_COUNTS_DOWN`. See `docs/SPORTS_UNIFICATION.md`.
A plugin may start calling these once its manifest floors
`ledmatrix_min_version` at the release that ships them.
- `src/base_classes/sports/capabilities/` — opt-in capabilities for the sports
scoreboards, composed by inheritance rather than gated by config branches
inside the base classes:
- `CelebrationMixin` — the score/win takeover, merging the goal and score
dialects behind the `score_phrase()` / `win_phrase()` hooks, the
`COALESCE_SCORING_SEQUENCE` class attribute and the `_favorite_key` seam.
Reads both the `celebrate_opponent_goals` and `celebrate_opponent_scores`
config spellings. Sports that do not mix it in have none of this code in
their MRO.
- `RotationStrategy` + a name registry (`swrr`, `weighted`, `simple`,
plus `register_rotation_strategy` for plugin-supplied orderings). Each
built-in is verified against a verbatim transcription of the plugin
implementation it replaces. An unknown name degrades to `simple`.
### Changed
- **Live games are no longer dropped when the feed omits a game clock.**
`SportsLive._is_game_really_over` previously (in the baseball and UFC
plugin lineages) coerced a missing or non-string clock to the literal
`"0:00"` and then treated the game as finished once `period >= 4`. Baseball
has no game clock and `period` is the inning, so live MLB games disappeared
from the scoreboard from the 5th inning onward; UFC was affected the same
way. The clock check is now skipped when the clock is unusable, and the
period threshold is the per-sport `FINAL_PERIOD` (hockey ends in P3).
Sports whose clocks count up — soccer, AFL, NRL — set
`CLOCK_COUNTS_DOWN = False` and never run the check at all, since `0:00`
there means kickoff rather than expiry.
### Fixed
- `FontManager` resolves `assets/fonts` against the core install root instead
of the process working directory, so font loading works when the process
starts elsewhere (e.g. the plugin safety harness on CI).
- Hockey events whose competitors carry no `statistics` array are no longer
discarded. The extractor read `competitor["statistics"]` unguarded, so a
`KeyError` inside the generator dropped the entire event despite valid
scores and status; shot counts now fall back to `0`.
- Live baseball events that populate status only at the competition level are
no longer discarded. The extractor read the event top-level
`game_event["status"]` for the inning; real ESPN events duplicate it, but
MiLB events synthesized from the MLB Stats API do not, so the lookup raised
a bare `KeyError`. It now reads the already-validated competition-level
status.
- `SportsLive._is_game_really_over` no longer crashes the live-update pass when
a feed sends an explicit null `period`. `None >= FINAL_PERIOD` raised
`TypeError`, and the only caller (`_detect_stale_games`) has no `try/except`
— the same failure shape as the already-fixed null `period_text`.
- An expired clock spelled `"00:00"` now ends the game. The check compared the
colon-stripped clock against a hand-listed set of literals, which `"0000"` is
not a member of, so a finished game with a two-digit-minute clock stayed on
the scoreboard indefinitely. The comparison is now numeric.
- `SportsCore._load_fonts` resolves `assets/fonts` through the `_font_root()`
seam instead of the process working directory. Started outside the install
root, every scoreboard font silently degraded to PIL's default bitmap face.
- `SportsCore._should_log` no longer raises `AttributeError` on the first
warning of a run; `_last_warning_time` is initialized in `__init__` rather
than lazily by an unrelated method.
- `SportsCore._resolve_project_path` resolved relative logo directories
against `<root>/src` instead of the repo root after `sports.py` became a
package — the class bodies moved byte-identically but `__file__` gained a
directory. Both it and `_font_root` now derive from one `_INSTALL_ROOT`
constant.
## 3.1.0
Baseline for this changelog. Highlights already shipped at this version:
skin system for sports scoreboards (#419), Vegas continuous-scroll overhaul
(#423), plugin update surfacing (#421).
+8
View File
@@ -31,6 +31,14 @@
- Plugin configs stored in `config/config.json`, NOT in plugin directories — safe across reinstalls
- Third-party plugins can use their own repo URL with empty `plugin_path`
## Skin System (visual overlays for sports scoreboards)
- Skins live in `skins/<skin-id>/` (skin.json + skin.py), NOT in plugin dirs — plugin reinstall deletes plugin dirs
- Core: `src/skin_system/` (ScoreboardSkin, SkinContext, runtime); hook: `SportsCore._render_game()` in `src/base_classes/sports.py`
- Skins render onto `ctx.canvas` only; fallback to built-in renderer on `False`/exception (3 strikes disables for session)
- View-model guaranteed keys are frozen (see `test/test_skin_system.py::TestViewModelContract`) — renaming keys in `_extract_game_details_common` or sport extractors breaks published skins
- Validate skins headlessly: `python scripts/validate_skin.py --skin <id>`; docs: `docs/SKIN_SYSTEM.md`, `docs/CREATING_SKINS.md`
- Skins are NOT monorepo plugins: no manifest bump / update_registry.py needed
## Common Pitfalls
- paho-mqtt 2.x needs `callback_api_version=mqtt.CallbackAPIVersion.VERSION1` for v1 compat
- BasePlugin uses `get_logger()` from `src.logging_config`, not standard `logging.getLogger()`
+10
View File
@@ -440,6 +440,16 @@ See the [Plugin Store documentation](https://github.com/ChuckBuilds/ledmatrix-pl
For plugin development, check out the [Hello World Plugin](https://github.com/ChuckBuilds/ledmatrix-hello-world) repository as a starter template.
### Visual Skins for Scoreboards
Want a different look for a sports scoreboard without forking the plugin?
**Skins** restyle the live/recent/upcoming screens while the plugin keeps
handling data, scheduling, caching, and vegas mode. Install one with
`git clone <skin repo> skins/<skin-id>`, select it in the plugin's config,
and you're done — see [docs/SKIN_SYSTEM.md](docs/SKIN_SYSTEM.md) (how it
works) and [docs/CREATING_SKINS.md](docs/CREATING_SKINS.md) (build your own,
including a ready-made Claude Code prompt).
2. **Built-in Managers Deprecated**: The built-in managers (hockey, football, stocks, etc.) are now deprecated and have been moved to the plugin system. **You must install replacement plugins from the Plugin Store** in the web interface instead. The plugin system provides the same functionality with better maintainability and extensibility.
</details>
+20 -1
View File
@@ -121,6 +121,7 @@
"axis": "horizontal"
},
"display_durations": {},
"plugin_rotation_order": [],
"use_short_date_format": true,
"vegas_scroll": {
"enabled": false,
@@ -129,7 +130,25 @@
"plugin_order": [],
"excluded_plugins": [],
"target_fps": 125,
"buffer_ahead": 2
"buffer_ahead": 2,
"intra_plugin_gap": 8,
"render_width_pct": 100,
"min_content_separation": 24,
"min_cut_gap": 6,
"continuous_scroll": true,
"smooth_scroll": true,
"extend_threshold_screens": 2.0,
"auto_trim": true,
"trim_threshold": 10,
"content_padding": 8,
"min_plugin_width": 8,
"lead_in_width": 0,
"plugins_per_cycle": 6,
"max_plugin_width_ratio": 3.0,
"overflow_mode": "rotate",
"dynamic_duration_enabled": true,
"min_cycle_duration": 60,
"max_cycle_duration": 240
}
},
"sync": {
+242
View File
@@ -0,0 +1,242 @@
# Creating Skins
A skin restyles a sports scoreboard (live / recent / upcoming) without
forking the plugin: the plugin keeps fetching data, scheduling, caching, and
doing vegas mode; your skin only draws. Architecture background:
[SKIN_SYSTEM.md](SKIN_SYSTEM.md).
## Quick start
```bash
cp -r skins/example-classic-baseball skins/my-skin
# edit skins/my-skin/skin.json -> set id ("my-skin"), name, author, class_name
# edit skins/my-skin/skin.py -> rename the class, start restyling
python scripts/validate_skin.py --skin my-skin
```
The validator renders your skin against bundled fixture games at several
panel sizes with **no hardware, no network, no running service**, saves PNGs
(plus 4x previews) to `skin_renders/`, and fails loudly on errors. Iterate:
edit → validate → look at the PNGs.
To see it on your matrix, add to your plugin's section in `config/config.json`:
```json
"baseball-scoreboard": {
"skin": "my-skin",
"skin_options": { }
}
```
or pick it from the **Visual Skin** dropdown in the web UI (it appears once a
matching skin is installed). `"skin"` also accepts a per-mode mapping:
`{"live": "my-skin", "recent": "built-in"}`.
## The manifest (`skin.json`)
```json
{
"id": "my-skin",
"name": "My Skin",
"version": "1.0.0",
"author": "you",
"description": "What it looks like",
"skin_api_version": "1.0.0",
"targets": {
"sports": ["baseball"],
"sport_keys": ["mlb", "milb"],
"plugins": []
},
"entry_point": "skin.py",
"class_name": "MySkin",
"modes": ["live", "recent", "upcoming"],
"preview": "preview.png"
}
```
Field notes: `id` must equal the directory name; `skin_api_version`'s major
version must match the host's `SKIN_API_VERSION` or the skin is refused at
load; `targets` takes sport families (`sports`), exact sport keys
(`sport_keys`), and/or exact plugin ids (`plugins`) — any match applies.
## The renderer (`skin.py`)
```python
from src.skin_system.skin_base import ScoreboardSkin, SkinContext
class MySkin(ScoreboardSkin):
def render_live(self, ctx: SkinContext, game: dict) -> bool:
score = f"{game.get('away_score', '0')}-{game.get('home_score', '0')}"
fit = ctx.layout.fit_text(score, ctx.layout.bounds)
ctx.draw_fit(fit, ctx.layout.bounds)
return True # True = "I drew it"; False = use the built-in layout
```
Implement only the modes you care about — anything else falls back to the
plugin's built-in rendering. Return `False` to decline a specific game (e.g.
a layout that only makes sense while a game is live).
### The rules (they keep your skin from breaking the display)
1. **Draw only onto `ctx.canvas`** (via the helpers or `ctx.draw`). Never
reassign `ctx.canvas`, never touch the display or call any update method.
2. **No I/O in render paths.** No network, no file loads per frame —
`render_live` runs every display pass, and a slow render stalls the whole
matrix (the host warns at >150 ms). Use `ctx.load_logo` (cached) and
`cache_key=` for images.
3. **Derive everything from `(ctx, game)`.** Skins must be stateless: the
live/recent/upcoming modes each get their own instance.
4. **Always `.get()` optional keys.** Only the guaranteed keys below are
promised to exist.
5. **Never hardcode pixel positions for the panel.** Use `ctx.width`/
`ctx.height`, `ctx.layout` regions and `fit_text` — your skin will be run
at sizes you didn't test (64x32, 128x64, vegas cards).
6. **No third-party dependencies.** Stdlib + PIL + what `ctx` provides.
A skin that raises 3 renders in a row is disabled until the service restarts
(the built-in layout takes over), so a bug is cosmetic — but check your logs.
## SkinContext reference
| Member | What it is |
|---|---|
| `ctx.canvas` / `ctx.draw` | Fresh RGB `PIL.Image` at display size + its `ImageDraw` (raw-PIL escape hatch) |
| `ctx.width`, `ctx.height` | Canvas size — the only size truth |
| `ctx.layout` | `LayoutContext` (see [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md)): `bounds`, `fit_text`, `fit_text_proportional`, `fit_image`, `px`, `by_tier` |
| `ctx.draw_fit(fit, box, color, align, valign)` | Draw a `fit_text` result aligned in a `Region` (handles BDF fonts) |
| `ctx.draw_text(text, x, y, color, font)` | Positioned text (handles BDF fonts) |
| `ctx.draw_image(img, box, mode, align, valign, cache_key)` | Fit + paste an image with alpha; no-ops on `None` |
| `ctx.load_logo("home" \| "away")` | Team logo as RGBA, or `None` (always handle `None`). Cached after first use; see note below |
| `ctx.draw_text_outlined(text, (x, y), font, fill, outline_color)` | The classic scorebug outlined text (TTF fonts only) |
| `ctx.fonts` | The host's font dict — keys `score`, `time`, `team`, `status`, `detail`, `rank` |
| `ctx.options` | Your user's `skin_options` from config |
| `ctx.sport`, `ctx.view_model_version`, `ctx.logger` | Context metadata + logger |
**A note on `ctx.load_logo` vs the no-I/O rule:** `load_logo` is the one
sanctioned exception. It goes through the host's logo cache — after the
first call per team it's a pure in-memory lookup. If a logo file is missing
on disk, the *first* call may download it, exactly like the built-in
renderer does for the same game (a skin is never worse than built-in here).
Always pass a stable `cache_key` when drawing it, never load image files
yourself in a render path, and always handle `None`.
The default layout idiom — carve regions, then fit text into them:
```python
from src.adaptive_layout import scoreboard_regions
regions = scoreboard_regions(ctx.layout.bounds, ctx=ctx.layout)
ctx.draw_image(ctx.load_logo("away"), regions.away_slot, cache_key=f"logo:{game.get('away_abbr')}")
ctx.draw_image(ctx.load_logo("home"), regions.home_slot, cache_key=f"logo:{game.get('home_abbr')}")
fit = ctx.layout.fit_text("3-5", regions.score_area)
ctx.draw_fit(fit, regions.score_area)
```
`Region` supports `split_h`/`split_v`/`inset`/`top_band`/`bottom_band`/
`left_col`/`right_col` for custom carves. Raw `ctx.draw.rectangle/polygon/
ellipse/...` is always available for custom marks (see the bases diamond in
the example skin).
## The game view model
Guaranteed for every sport (view model v1.0 — renaming these breaks skins and
is treated as a breaking change upstream):
| Key | Notes |
|---|---|
| `id` | Event id (string) |
| `status_text` | Display-ready status, e.g. `"Final"`, `"7:30 PM"`, `"Bot 7th"` |
| `is_live`, `is_final`, `is_upcoming`, `is_halftime` | Booleans |
| `game_date`, `game_time` | Pre-formatted local date/time strings |
| `start_time_utc` | UTC `datetime` |
| `home_abbr`, `away_abbr` | Team abbreviations (can be 25 chars — fit, don't assume) |
| `home_id`, `away_id` | Team ids |
| `home_score`, `away_score` | **Strings**, not ints |
| `home_record`, `away_record` | `"58-33"` or `""` (0-0 records are blanked) |
| `home_logo_path`, `away_logo_path` | Prefer `ctx.load_logo` over touching these |
Sport extras (present for that sport, still `.get()` defensively):
- **baseball**: `inning` (int), `inning_half` (`"top"`/`"bottom"`), `balls`,
`strikes`, `outs` (ints), `bases_occupied` (`[first, second, third]`
booleans), `series_summary` (str)
- **football**: `period`, `period_text`, `clock`, `home_timeouts`,
`away_timeouts`, `down_distance_text`, `down_distance_text_long`,
`is_redzone`, `possession`, `possession_indicator` (`"home"`/`"away"`),
`scoring_event`
- **basketball**: `period`, `period_text`, `clock`
- **hockey**: `period`, `period_text`, `clock`, `power_play`, `penalties`,
`home_shots`, `away_shots`
Optional everywhere (only when the user enabled the feature): `odds` (dict),
`series_summary`, rankings-related fields.
Fixture copies of these dicts live in `src/skin_system/fixtures/` — that's
exactly what the validator feeds your skin.
## Vegas mode
You get vegas support for free: vegas captures the normal display output,
which is already your skin's rendering. Optionally implement
`render_vegas_card(ctx, game)` to return a purpose-built card at
`ctx.width x ctx.height` (sizes vary — never assume 128x32).
## Building a skin with Claude Code
Skins are ideal Claude Code projects: small, isolated, and verifiable with
one command. Paste this to start:
> You are building a **display skin** for LEDMatrix — a visual overlay for a
> sports scoreboard on a small LED matrix (commonly 128x32 or 64x32 pixels).
> First read `docs/CREATING_SKINS.md` and the reference skin in
> `skins/example-classic-baseball/`.
>
> Rules:
> - Create/modify files ONLY under `skins/<my-skin-id>/`. Do NOT modify
> anything in `src/`, `scripts/`, the plugins, or any other skin.
> - Render only from the `game` dict and `ctx` helpers. No network calls, no
> per-frame file I/O, no new pip dependencies, no touching the display —
> draw onto `ctx.canvas` and return True.
> - Use `ctx.layout` regions and `fit_text` for positioning so the skin works
> at any panel size; use `.get()` for every optional game key.
> - After every change run
> `python scripts/validate_skin.py --skin <my-skin-id>` and LOOK at the
> PNGs it writes to `skin_renders/` (the `_x4.png` files are easiest to
> read). Iterate until it passes and looks right at both 128x32 and 64x32.
>
> What I want it to look like: <describe your layout — where logos, score,
> status go; colors; what shows during live vs upcoming vs final>
Tips that keep Claude (and you) out of trouble:
- One mode at a time: get `render_live` right before touching the others —
unimplemented modes automatically use the built-in look.
- Ask for edge-case renders: long team abbreviations, missing logos
(`ctx.load_logo` returning `None`), 0-0 records, extra innings/OT.
- If the render looks cramped at 64x32, ask Claude to use
`ctx.layout.by_tier(...)` to drop elements on small panels rather than
shrinking everything.
- Never let it "fix" a problem by editing `src/` — if the skin can't do
something within its directory, that's a feature request, not a workaround.
## Pre-publish checklist
- [ ] `python scripts/validate_skin.py --skin <id> --size 128x32 --size 64x32 --size 128x64` passes
- [ ] Looked at every PNG in `skin_renders/` — nothing clipped or overlapping
- [ ] Handles a missing logo (`None`) without crashing — temporarily point a
fixture's logo path at a nonexistent file to test
- [ ] Long abbreviations (`"TA&M"`, 45 chars) don't overflow
- [ ] No render warning above the time budget
- [ ] `skin.json`: `id` matches the directory, `version` set,
`skin_api_version` matches the host, targets correct
- [ ] `preview.png` added (grab your favorite `_x4` render)
- [ ] Tested on real hardware if you have it — a Pi is much slower than your
dev machine
Distribute by publishing the directory as a git repo (users
`git clone <repo> skins/<id>`), or submit it to the plugin registry as an
entry with `"type": "skin"` (see [SKIN_SYSTEM.md](SKIN_SYSTEM.md) §Distribution).
**Trust note:** a skin is Python running inside the display service — the
same trust level as a plugin. Review code before installing skins from
others.
+6
View File
@@ -10,6 +10,12 @@ This guide explains how to set up a development workflow for plugins that are ma
> scale. Existing plugins keep their classic rendering unless they adopt
> those APIs; nothing migrates automatically.
> **Just want a different look for an existing sports scoreboard?** You may
> not need a plugin at all — a **skin** restyles the live/recent/upcoming
> rendering while the plugin keeps handling data, scheduling, caching, and
> vegas mode, in ~100 lines of drawing code. See
> [CREATING_SKINS.md](CREATING_SKINS.md).
## Overview
When developing plugins in separate repositories, you need a way to:
+170
View File
@@ -0,0 +1,170 @@
# Skin System Architecture
Skins are user-installable **visual overlays** for the sports scoreboards.
A skin replaces only the *look* of a scoreboard — the host plugin keeps doing
data fetching, scheduling, caching, dedup, live-priority takeover, and vegas
mode. If you only want to **build** a skin, read
[CREATING_SKINS.md](CREATING_SKINS.md); this document explains how the system
works and why it is shaped this way.
## Why skins instead of forks
Before skins, changing a scoreboard's layout meant forking the whole plugin
(e.g. the community MLB scoreboard fork). The fork gets the new look but loses
everything the maintained plugin keeps earning: duration/scheduling behavior,
vegas mode support, caching and background-fetch improvements, bug fixes. It
also silently drifts: every upstream improvement now has to be re-ported by
hand.
A skin inverts that trade. The plugin remains stock and keeps updating through
the store; the skin is ~100 lines of pure rendering code that receives the
plugin's already-fetched data each frame. Uninstalling the skin (or the skin
crashing) simply restores the built-in look.
```text
(unchanged) (the skin seam)
ESPN API ──► update() ──► game view model ──► _render_game() ──► display
fetching (a dict) │ │
caching │ └─ built-in
scheduling └─ skin.render_<mode>(ctx, game)
live priority draws onto ctx.canvas
```
## The render funnel
Every sports scoreboard (baseball, football, basketball, hockey — anything
built on `src/base_classes/sports.py`) renders through exactly one seam:
`SportsCore._render_game(game, force_clear)`.
1. The mode class's `display()` (live, `SportsUpcoming`, `SportsRecent`)
picks `self.current_game` and calls `_render_game`.
2. `_render_game` lazily loads the configured skin (once, on first render —
a broken skin can never block plugin startup).
3. If a skin is active, the host builds a `SkinContext` — a fresh black
canvas at the current display size plus layout/font/logo helpers — and
calls the skin's `render_live` / `render_recent` / `render_upcoming`
with a **copy** of the game dict.
4. If the skin returns `True`, the canvas is composited onto the display.
If it returns `False`, isn't implemented for that mode, or raises, the
built-in `_draw_scorebug_layout` runs instead.
Key properties that fall out of this design:
- **Per-mode fallback.** A skin that only implements `render_live` gets the
stock recent/upcoming screens for free.
- **Three strikes.** A skin that raises 3 times in a row is disabled for the
rest of the session (one loud error log per failure); the display never
goes dark. Restarting the service re-arms it.
- **Copies, not references.** Skins receive a shallow copy of the game dict,
so a buggy skin cannot corrupt the plugin's scheduling state.
- **Vegas mode works untouched.** Vegas capture falls back to grabbing the
regular `display()` output, which is already skin-rendered. Skins can
additionally implement `render_vegas_card` for purpose-built scroll cards,
and hosts can call `SportsCore.render_skin_card(game, size)` to use it.
- **Hot-loop caution.** `render_live` runs every display-loop pass during a
live game. The host logs a warning when a skin render exceeds 150 ms, and
`scripts/validate_skin.py` enforces a budget at development time — but
Python cannot forcibly time-out a stuck render, so a skin that blocks
(network I/O, giant image ops) stalls the display. This is why the rules
in CREATING_SKINS.md ban I/O in render paths.
## The view model contract
The `game` dict a skin receives is the plugin's already-extracted view model
(`SportsCore._extract_game_details_common` plus per-sport extras from
`src/base_classes/{baseball,basketball,football,hockey}.py`).
- **Guaranteed keys (view model v1.0)** — always present for every sport:
`id`, `game_time`, `game_date`, `start_time_utc` (a UTC `datetime`),
`status_text`, `is_live`, `is_final`, `is_upcoming`, `is_halftime`,
`home_abbr`/`away_abbr`, `home_id`/`away_id`, `home_score`/`away_score`
(**strings**), `home_logo_path`/`away_logo_path`, `home_record`/`away_record`.
- **Sport extras** — documented per sport in CREATING_SKINS.md (e.g. baseball
adds `inning`, `inning_half`, `balls`, `strikes`, `outs`, `bases_occupied`).
- **Optional keys** (`odds`, rankings, `series_summary`, …) are present only
when the feature is enabled — skins must always use `.get()`.
Versioning policy: additive changes bump the minor version
(`VIEW_MODEL_VERSION` in `src/skin_system/skin_base.py`, surfaced to skins as
`ctx.view_model_version`); renaming or removing a guaranteed key requires a
major bump plus a compat shim. `test/test_skin_system.py::TestViewModelContract`
fails CI if a guaranteed key disappears from the extractor.
Separately, `SKIN_API_VERSION` versions the Python API (`ScoreboardSkin`,
`SkinContext`). The loader refuses a skin whose manifest declares a different
major version and falls back to the built-in renderer with a clear
"skin needs an update" log line.
## Package layout and lifecycle
```text
skins/<skin-id>/
skin.json # manifest (required)
skin.py # ScoreboardSkin subclass (required)
preview.png # optional, shown by the web UI
assets/ # optional skin-local images
helpers.py ... # optional extra modules (namespaced per skin at import)
```
Skins live in the central `skins/` directory — deliberately **not** inside the
plugin's directory, because plugin reinstall/update deletes the whole plugin
directory and a skin must survive that. One skin can also target several
plugins (mlb + milb).
Lifecycle: discovered lazily on first render → manifest validated → API major
version gated → module imported under a namespaced `sys.modules` key (two
skins can both ship a `helpers.py`, same scheme plugins use) → instantiated
with `(manifest, options)`. Every failure logs and falls back to built-in.
Skins should be **stateless**: the live, recent, and upcoming mode classes
each hold their own skin instance, so derive everything from `(ctx, game)`.
## Selection and configuration
Inside the plugin's own config section in `config/config.json`:
```json
"baseball-scoreboard": {
"skin": "retro-baseball",
"skin_options": { "accent_color": [255, 80, 0] }
}
```
`"skin"` is either one id for all modes or a per-mode mapping
(`{"live": "retro-baseball", "recent": "built-in"}`). Absent, empty, or
`"built-in"` means the stock renderer. Because this rides the plugin's config
section, it persists across plugin reinstalls like every other setting.
The web UI shows a **Visual Skin** dropdown for plugins that have matching
skins installed: `SchemaManager.inject_skin_selector` adds an enum to the
*served* schema only. Validation never sees the enum — so a config that
references an uninstalled skin stays valid (rendering just falls back), and
the currently-configured value is always kept selectable. `GET /api/v3/skins`
lists installed skins (optionally filtered by `?plugin_id=`).
## Distribution
- **Manual:** `git clone <skin repo> skins/<skin-id>` — that's the whole
install. No manifest bumps, no `update_registry.py`; skins are not monorepo
plugins.
- **Store:** registry entries with `"type": "skin"` install through the same
`plugins.json` pipeline; `PluginStoreManager` routes them to `skins/`,
validates `skin.json` (including the API major version) instead of
`manifest.json`, and never installs dependencies — skins are render-only
(stdlib + PIL + the provided context, no third-party packages in v1).
## Trust model
A skin is Python executing inside the display service — **exactly the same
trust level as a plugin**, even though "skin" sounds cosmetic. Only install
skins from sources you'd be willing to install a plugin from.
## v2 directions (not in v1)
- A generic `BasePlugin` opt-in (`render_with_skin()`) so non-sports plugins
(weather, music) can offer skinnable layouts; `skin_runtime` is already
sports-agnostic in anticipation.
- Store UI: preview gallery, one-click install from the skin browser.
- An update path for git-cloned skins (today: re-clone or store reinstall).
- Animation support in skins (today the API is one frame per render call;
stateful tricks work but are at-your-own-risk).
+196
View File
@@ -0,0 +1,196 @@
# Sports Code Unification — Architecture
How the nine sports scoreboard plugins converge onto shared core code **without**
becoming nine clients of a god class.
## The problem
Nine plugins (`afl`, `baseball`, `basketball`, `football`, `hockey`, `lacrosse`,
`nrl`, `soccer`, `ufc`) each ship a ~3,000-line `sports.py` descended from this
repo's `src/base_classes/sports.py`. They have drifted into three lineages, and
only 28 of the 66 methods appearing across them are present in all nine. One
logical fix (the UTC start-time bug) cost 75 files.
Merging everything into one base class would fix the duplication and create a
worse problem: a single 2,500-line class that all nine plugins inherit, where any
change has a nine-plugin blast radius and per-sport behavior survives only as
`if self.sport == "hockey"` branches.
## Three properties, three mechanisms
These are independent concerns. Conflating them is what produces god classes.
### Upgradability — a plugin keeps working across core versions
| Rule | Mechanism |
|---|---|
| Plugin loads on a core that predates a module | Guarded import with a bundled fallback (`try: from src.X import Y / except ModuleNotFoundError: from y import Y`) |
| Plugin loads on a core that predates a *method* | Capability probing — `hasattr(SportsCore, "_detect_stale_games")` — never a version comparison. The loader's compat check is advisory-only (it logs and continues), so probing is the real protection. |
| Core changes never break a plugin's rendering | The **view-model contract**: `_extract_game_details_common` returns a dict whose `GUARANTEED_KEYS` are frozen by `test/test_skin_system.py::TestViewModelContract`. Keys may be added, never renamed or removed. |
| A plugin can drop its bundled copy safely | The **sunset rule**: only when its manifest floors `ledmatrix_min_version` at the first core release shipping the module (recorded in `CHANGELOG.md`). |
The core API is **additive-only**. A method the plugins call is never removed or
given a new required parameter; new behavior arrives as new methods with
defaults, or as capabilities they opt into.
### Reusability — write once, nine plugins benefit
Only code that is **identical in intent across all nine** moves into the base
class. That set is small and knowable — it is exactly the methods present in every
copy today (phase B1 below). Everything else stays where it is until it earns
promotion.
### Modularity — a change to one feature cannot reach a plugin that doesn't use it
This is the property the naive merge destroys, and it is enforced structurally:
1. **Capabilities are separate modules composed by inheritance, not config
branches inside the base class.** Hockey has no celebrations, so
`HockeyLive` does not inherit `CelebrationMixin` — the celebration code is not
merely disabled for hockey, it is *not in hockey's MRO at all*. No shared
state, no dead branches, no risk. Contrast with
`if self.celebrations_enabled:` inside `SportsLive`, where a bug in
celebration code can still crash a plugin that never wanted the feature.
2. **Variant behavior is a strategy object chosen by name, not a branch.**
Live rotation exists in three dialects across the lineages; core ships all
three behind `rotation_strategy: "swrr" | "weighted" | "simple"` and a plugin
may register its own. Core never learns sport names.
3. **Sport-specific behavior is a documented override point.** The base class
declares the seam; the plugin fills it. Basketball's tournament-round parsing
and baseball's BDF sizing stay in their plugins forever — they are not
candidates for promotion, and core must never grow a branch for them.
4. **Files bound the blast radius.** Capabilities live in their own modules so a
diff shows at a glance which plugins a change can reach.
## Layering
```
src/base_classes/sports/
__init__.py re-exports the public API (import path unchanged)
core.py SportsCore — fetch, cache, config, logos, fonts, odds,
view-model extraction, the skin seam
modes.py SportsUpcoming / SportsRecent / SportsLive
capabilities/
celebrations.py CelebrationMixin (opt-in: 4 of 9 plugins)
rotation.py RotationStrategy + registry
```
`from src.base_classes.sports import SportsCore` keeps working — the package
`__init__` re-exports, so the conversion is invisible to every existing importer.
## Override points (the plugin-facing seam)
The base class calls these; plugins implement or override them. This table is the
contract — additions require a default implementation, removals require a
deprecation cycle.
| Hook | Purpose | Default |
|---|---|---|
| `_fetch_data()` | Sport's schedule source | abstract |
| `_extract_game_details(event)` | Sport-specific view-model fields on top of the common ones | delegates to `_extract_game_details_common` |
| `_draw_scorebug_layout(game, force_clear)` | Sport's card rendering | base layout |
| `_custom_scorebug_layout(game, draw)` | Per-sport overlay on the base layout | no-op |
| `render_skin_card(game, size)` | Skin-system entry point | built-in fallback |
| `score_phrase(points, team_abbr)` | Celebration wording (`"GOOOOAAALLL!"` vs `"TOUCHDOWN!"`). `points` is the score delta, which sports with variable-value scores use to name the play | `"<abbr> SCORES!"` — only consulted when `CelebrationMixin` is present |
| `win_phrase(team_abbr)` | Win-celebration wording | `"<abbr> WINS!"` — mixin only |
| `_favorite_key(game, side)` | Which view-model field identifies a team for favorites matching | `game["<side>_abbr"]` |
| `_config_schema_path()` | Plugin's `config_schema.json` — returning it routes `_get_layout_offset` through the `src.element_style` resolver (and gives it the defaults to compare against) | `None`, i.e. the classic inline `customization.layout` read |
| `_font_root()` | Directory to resolve `assets/fonts` against | core install root |
Two class attributes serve the same purpose for values that are per-sport
constants rather than behavior:
| Attribute | Meaning | Default |
|---|---|---|
| `FINAL_PERIOD` | Period at/after which a zero clock can mean "over" | `4` (hockey overrides to `3`) |
| `CLOCK_COUNTS_DOWN` | Whether `0:00` means "expired" | `True` (soccer/afl/nrl override to `False` — their clocks count up, so `0:00` is kickoff) |
| `COALESCE_SCORING_SEQUENCE` | Fold score increments arriving during an active celebration into that one celebration | `False` (football overrides to `True` — a touchdown lands as +6, then +1 for the extra point) |
### Why these are seams and not branches
`_favorite_key` exists because NRL abbreviations are **not unique** — "NEW" is both
Newcastle Knights and New Zealand Warriors, "CAN" both Canberra and Canterbury —
so NRL matches favorites on team ID. Flattening every plugin to abbreviations
would silently select the wrong club for NRL users. The base declares the seam,
NRL fills it, and core never learns the string `"nrl"`.
`CLOCK_COUNTS_DOWN` exists for the same reason in the opposite direction: a
soccer clock reading `0:00` means the match has not kicked off, so running the
clock-expiry branch there would evict live games.
`COALESCE_SCORING_SEQUENCE` is the third of the same kind. In football one
scoring play arrives as two score updates, so the follow-up must be folded into
the first celebration; in soccer two increments a few seconds apart are two real
goals, and folding them would swallow one. Neither default is "right" — which is
precisely why it is a declared per-sport constant rather than a hidden
assumption baked into the shared body.
## Capabilities
```
capabilities/
celebrations.py CelebrationMixin opt-in: afl, nrl, soccer, football
rotation.py RotationStrategy + registry
```
**`CelebrationMixin`** merges the two dialects the lineages grew
(`_check_for_goal`/`celebrate_opponent_goals` vs
`_check_for_score`/`celebrate_opponent_scores`). Their bodies were identical
apart from three things, each now a seam: wording (`score_phrase`), follow-up
suppression (`COALESCE_SCORING_SEQUENCE`), and team identity (`_favorite_key`,
so NRL matches on id). Both config spellings are read, so a plugin adopting the
mixin keeps working with the keys already in its published schema.
Mix it in **before** the mode class — `class SoccerLive(CelebrationMixin,
SportsLive)` — so the celebration `display()` runs first and falls through to
the scorebug via `super()`.
**Rotation strategies.** The three "dialects" turned out to be one algorithm
(Smooth Weighted Round-Robin) in two shapes: an incremental picker holding state
across calls (afl/nrl/soccer) and a precomputed per-cycle list
(football/baseball/basketball, and hockey with a different loop shape). They
agree within a cycle and differ only at the boundary — the incremental form has
no restart seam — so core ships both rather than declaring a winner:
```python
self.rotation = get_rotation_strategy("swrr", weight_for=self._live_weight)
```
`weight_for` is supplied by the host, so the *favorites* policy stays with the
plugin and `rotation.py` never learns what a favorite is. An unknown strategy
name degrades to `simple` rather than raising: the name comes from user config,
and a typo should cost the boost, not the scoreboard. A plugin needing an
ordering core does not ship calls `register_rotation_strategy` instead of core
growing a branch.
`test_sports_capabilities.py` checks each strategy against a **verbatim
transcription** of the plugin code it replaces, over every live-game shape up to
four games. That differential is what B5 deletes the bundled copies on the
strength of.
## Phases
| Phase | Scope | Risk control |
|---|---|---|
| **B0** ✅ | Characterization tests, CI unit job, `element_style`, font cwd fix, CHANGELOG discipline | — |
| **B1** ✅ | Promote the nine universal methods; convert `sports.py` → package | Characterization suite must stay green; no behavior change intended |
| **B2** ✅ | `CelebrationMixin` + rotation strategies as opt-in capabilities | Plugins that don't opt in have zero new code in their MRO; strategies checked against verbatim plugin transcriptions |
| **B3** | Upstream `ScrollDisplay` as `src/common/sports_scroll.py`, reading `global_config['target_fps']` natively | Plugin copies remain until sunset |
| **B4** | Bump to 3.2.0, record modules in CHANGELOG, migrate `ledmatrix_min``ledmatrix_min_version` | Gives plugins a version to floor on |
| **B5** | Pilot one plugin per lineage (hockey, soccer, football) on guarded core imports; then the remaining six; then delete bundled copies | Pilot soaks before rollout; harness + golden suites gate each |
## Rules for contributors
- **Promote on evidence, not intuition.** A method moves to core when every copy
has it and they agree on intent. Otherwise it stays in the plugins.
- **Never add a sport name to core.** If core needs to know which sport it is,
the design is wrong — add an override point instead.
- **A capability that is not opted into must not execute.** If you find yourself
writing `if self.<capability>_enabled` inside a base class, it belongs in a
mixin.
- **Touch the view-model keys only additively.** Published skins depend on them.
- **Every promotion lands with the characterization suite green**, and every
pilot adoption lands with that plugin's harness and golden suites green.
+34
View File
@@ -206,6 +206,40 @@ To use an existing widget in your plugin's `config_schema.json`, simply add the
The widget will be automatically rendered when the plugin configuration form is loaded.
## Marking Fields as Advanced (`x-advanced`)
Add `"x-advanced": true` to any top-level, non-object property to move it out
of the main form and into a single collapsed **Advanced Settings** section at
the bottom of the plugin's configuration page:
```json
{
"properties": {
"city": {
"type": "string",
"title": "City"
},
"request_timeout": {
"type": "integer",
"default": 10,
"description": "HTTP timeout in seconds",
"x-advanced": true
}
}
}
```
Guidelines:
- Use it for fine-tuning knobs most users never touch (timeouts, retry
behavior, cache TTLs, styling overrides). Anything a first-time user must
set to get the plugin working should stay basic.
- Nothing is hidden permanently — the section expands on click, and the
settings search finds and auto-expands advanced fields like any others.
- The flag is ignored on `object`-type properties (they already render as
their own collapsible sections) and is safely ignored by older cores, so
adding it never breaks compatibility.
## Creating Custom Widgets
### Step 1: Create Widget File
+384
View File
@@ -0,0 +1,384 @@
#!/usr/bin/env python3
"""
Vegas Mode Density Audit
Reports how much of the Vegas ticker is actually showing something. Loads the
real enabled plugins, pulls each one's content through the real
``PluginAdapter``, composes the strip through the real ``ScrollHelper``, then
measures the result.
The headline number is the **dead-frame ratio**: the fraction of viewport
positions across a full cycle that are effectively blank. Because the panel
only ever shows ``display_width`` columns at a time, a blank stretch wider than
the viewport is a stretch where the display looks switched off — so this ratio
tracks perceived dead time rather than just counting unlit pixels.
Runs entirely off-hardware, so it is safe to run alongside a live display.
Usage:
# Audit every enabled plugin at the display size from config.json
python scripts/dev/vegas_audit.py
# Specific plugins, dump each segment as a PNG for eyeballing
python scripts/dev/vegas_audit.py -p of-the-day,youtube-stats --dump-dir /tmp/vg
# Machine-readable, for before/after comparison
python scripts/dev/vegas_audit.py --json > after.json
"""
import argparse
import json
import logging
import os
import sys
import time
from pathlib import Path
from typing import Any, Dict, List
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
# Must precede any src import that may reach for hardware.
os.environ.setdefault('EMULATOR', 'true')
from PIL import Image # noqa: E402
from src.common.scroll_helper import ScrollHelper # noqa: E402
from src.plugin_system.testing.loading import ( # noqa: E402
build_full_config,
find_plugin_dir,
load_manifest,
)
from src.vegas_mode.config import VegasModeConfig # noqa: E402
from src.vegas_mode.geometry import ( # noqa: E402
DEFAULT_INK_THRESHOLD,
column_has_ink,
content_bounds,
dead_window_stats,
window_coverage_stats,
)
from src.vegas_mode.plugin_adapter import PluginAdapter # noqa: E402
# Sampling stride for the dead-window scan. A full cycle can be 30,000px wide;
# 4px granularity keeps the scan instant while staying well under the ~10px a
# single scroll step ever covers, so no dead stretch is missed.
DEAD_SCAN_STEP = 4
def load_main_config(path: Path) -> Dict[str, Any]:
with open(path, 'r') as fh:
return json.load(fh)
def display_size_from_config(config: Dict[str, Any]) -> tuple:
"""Derive the logical ticker size the way DisplayManager does."""
hw = config.get('display', {}).get('hardware', {})
cols = int(hw.get('cols', 64))
chain = int(hw.get('chain_length', 1))
rows = int(hw.get('rows', 32))
parallel = int(hw.get('parallel', 1))
return cols * chain, rows * parallel
def enabled_plugin_ids(config: Dict[str, Any]) -> List[str]:
"""Plugin IDs that are enabled in config, excluding non-plugin sections."""
ids = []
for key, value in config.items():
if isinstance(value, dict) and value.get('enabled') is True:
ids.append(key)
return ids
def instantiate(plugin_id: str, display_manager, cache_manager, plugin_manager):
"""Load one plugin offline. Returns the instance or None."""
from src.plugin_system.plugin_loader import PluginLoader
search_dirs = [
str(PROJECT_ROOT / 'plugin-repos'),
str(PROJECT_ROOT / 'plugins'),
]
plugin_dir = find_plugin_dir(plugin_id, search_dirs)
if not plugin_dir:
return None
try:
manifest = load_manifest(Path(plugin_dir))
cfg = build_full_config(Path(plugin_dir))
instance, _ = PluginLoader().load_plugin(
plugin_id=plugin_id,
manifest=manifest,
plugin_dir=Path(plugin_dir),
config=cfg,
display_manager=display_manager,
cache_manager=cache_manager,
plugin_manager=plugin_manager,
install_deps=False,
)
return instance
except Exception as exc: # noqa: BLE001 - audit tool must survive any plugin
print(f" ! {plugin_id}: load failed ({type(exc).__name__}: {exc})",
file=sys.stderr)
return None
def join_rows(images: List[Image.Image], gap: int) -> Image.Image:
"""Concatenate one plugin's rows, matching RenderPipeline._join_plugin_rows."""
if len(images) == 1:
return images[0]
gap = max(0, gap)
width = sum(img.width for img in images) + gap * (len(images) - 1)
height = max(img.height for img in images)
block = Image.new('RGB', (width, height), (0, 0, 0))
x = 0
for img in images:
block.paste(img, (x, 0))
x += img.width + gap
return block
def measure_segment(images: List[Image.Image], display_width: int,
scroll_speed: float, threshold: int) -> Dict[str, Any]:
"""Geometry of one plugin's contribution to the ticker."""
total_width = sum(img.width for img in images)
combined = Image.new('RGB', (max(1, total_width), images[0].height))
x = 0
for img in images:
combined.paste(img, (x, 0))
x += img.width
ink = column_has_ink(combined, threshold)
bounds = content_bounds(combined, threshold)
ink_cols = int(ink.sum())
return {
'images': len(images),
'width_px': total_width,
'ink_cols': ink_cols,
'ink_pct': round(100.0 * ink_cols / total_width, 1) if total_width else 0.0,
'lead_black_px': bounds[0] if bounds else total_width,
'trail_black_px': (total_width - 1 - bounds[1]) if bounds else 0,
'seconds_on_screen': round(total_width / scroll_speed, 1) if scroll_speed else 0.0,
'widths': [img.width for img in images],
}
def main() -> int:
parser = argparse.ArgumentParser(
description='Audit Vegas mode content density')
parser.add_argument('--config', default=str(PROJECT_ROOT / 'config' / 'config.json'),
help='Path to main config.json')
parser.add_argument('-p', '--plugins', default=None,
help='Comma-separated plugin IDs (default: all enabled)')
parser.add_argument('--width', type=int, default=None,
help='Override display width (default: from config hardware)')
parser.add_argument('--height', type=int, default=None,
help='Override display height (default: from config hardware)')
parser.add_argument('--dump-dir', default=None,
help='Write each segment and the composed strip as PNGs here')
parser.add_argument('--threshold', type=int, default=DEFAULT_INK_THRESHOLD,
help=f'Ink threshold (default: {DEFAULT_INK_THRESHOLD})')
parser.add_argument('--per-cycle', type=int, default=None,
help='Plugins composed per cycle '
'(default: buffer_ahead + 1, matching production)')
parser.add_argument('--json', action='store_true',
help='Emit JSON instead of a text report')
args = parser.parse_args()
config = load_main_config(Path(args.config))
vegas = VegasModeConfig.from_config(config)
cfg_w, cfg_h = display_size_from_config(config)
width = args.width or cfg_w
height = args.height or cfg_h
speed = vegas.scroll_speed
if args.plugins:
plugin_ids = [p.strip() for p in args.plugins.split(',') if p.strip()]
else:
plugin_ids = vegas.get_ordered_plugins(enabled_plugin_ids(config))
dump_dir = Path(args.dump_dir) if args.dump_dir else None
if dump_dir:
dump_dir.mkdir(parents=True, exist_ok=True)
from src.plugin_system.testing import (
MockCacheManager, MockPluginManager, VisualTestDisplayManager,
)
display_manager = VisualTestDisplayManager(width=width, height=height)
cache_manager = MockCacheManager()
plugin_manager = MockPluginManager()
# Pass the loaded config, exactly as VegasModeCoordinator does. Omitting it
# makes PluginAdapter fall back to VegasModeConfig() defaults, so the audit
# would silently report trimming and width-budget behaviour that differs
# from the user's config.json — the same drift the lead_gap and grouping
# arguments below exist to avoid.
adapter = PluginAdapter(display_manager, vegas)
if not args.json:
print(f"Vegas audit — display {width}x{height}, scroll {speed:g}px/s, "
f"separator {vegas.separator_width}px")
print(f"One display width = {width / speed:.1f}s of screen time\n")
results: List[Dict[str, Any]] = []
segments: List[Image.Image] = []
for plugin_id in plugin_ids:
started = time.time()
instance = instantiate(plugin_id, display_manager, cache_manager, plugin_manager)
if instance is None:
results.append({'plugin': plugin_id, 'status': 'load_failed'})
continue
plugin_manager.plugins[plugin_id] = instance
adapter.invalidate_cache(plugin_id)
try:
images = adapter.get_content(instance, plugin_id)
except Exception as exc: # noqa: BLE001
results.append({'plugin': plugin_id, 'status': 'fetch_error',
'error': f'{type(exc).__name__}: {exc}'})
continue
fetch_ms = round((time.time() - started) * 1000)
if not images:
results.append({'plugin': plugin_id, 'status': 'no_content',
'fetch_ms': fetch_ms})
if not args.json:
print(f" {plugin_id:28s} NO CONTENT ({fetch_ms}ms)")
continue
entry = {'plugin': plugin_id, 'status': 'ok', 'fetch_ms': fetch_ms}
entry.update(measure_segment(images, width, speed, args.threshold))
results.append(entry)
segments.extend(images)
if dump_dir:
for idx, img in enumerate(images):
img.save(dump_dir / f"{plugin_id}__{idx:02d}.png")
if not args.json:
print(f" {plugin_id:28s} {entry['width_px']:>6d}px "
f"{entry['images']:>2d} img ink {entry['ink_pct']:>5.1f}% "
f"lead {entry['lead_black_px']:>4d} tail {entry['trail_black_px']:>4d} "
f"{entry['seconds_on_screen']:>6.1f}s ({fetch_ms}ms)")
summary: Dict[str, Any] = {
'display_width': width,
'display_height': height,
'scroll_speed': speed,
'separator_width': vegas.separator_width,
'plugins_audited': len(plugin_ids),
'plugins_with_content': sum(1 for r in results if r.get('status') == 'ok'),
}
# Production composes only the plugins sitting in the active buffer, so
# measuring one giant strip of every plugin would hide the per-cycle costs
# (most importantly the leading gap, which is charged once per cycle).
# Group the segments the way the running service does.
per_cycle = max(1, args.per_cycle or vegas.plugins_per_cycle)
cycles: List[Dict[str, Any]] = []
with_content = [r for r in results if r.get('status') == 'ok']
if segments:
logger = logging.getLogger('vegas_audit')
seg_index = 0
for start in range(0, len(with_content), per_cycle):
group = with_content[start:start + per_cycle]
# Mirror RenderPipeline: each plugin's rows are joined by
# intra_plugin_gap into one block, and separator_width is applied
# only between blocks. Measuring a flat list here would report gaps
# the service does not emit.
blocks: List[Image.Image] = []
for entry in group:
count = entry['images']
rows = segments[seg_index:seg_index + count]
seg_index += count
if rows:
blocks.append(join_rows(rows, vegas.intra_plugin_gap))
if not blocks:
continue
# ScrollHelper logs unconditionally, so it needs a real logger.
helper = ScrollHelper(width, height, logger)
helper.create_scrolling_image(
content_items=blocks,
item_gap=vegas.separator_width,
element_gap=0,
# Must match RenderPipeline. Omitting this made the audit
# measure a full-display-width leading gap the service no
# longer emits, overstating dead space by 512px per cycle.
lead_gap=vegas.lead_in_width,
)
composed = helper.cached_image
if composed is None:
continue
dead = dead_window_stats(composed, width, args.threshold, step=DEAD_SCAN_STEP)
cover = window_coverage_stats(
composed, width, args.threshold, step=DEAD_SCAN_STEP)
if dump_dir:
composed.save(dump_dir / f"_cycle{len(cycles):02d}.png")
cycles.append({
'plugins': [e['plugin'] for e in group],
'width_px': composed.width,
'seconds': round(composed.width / speed, 1) if speed else 0.0,
'dead_pct': round(100 * dead.dead_ratio, 1),
'longest_dead_seconds': round(
dead.longest_dead_run * DEAD_SCAN_STEP / speed, 1) if speed else 0.0,
'mean_ink_pct': round(100 * cover.mean_ink_ratio, 1),
'sparse_pct': round(100 * cover.sparse_ratio, 1),
'longest_sparse_seconds': round(
cover.longest_sparse_run * DEAD_SCAN_STEP / speed, 1) if speed else 0.0,
})
if cycles:
total_px = sum(c['width_px'] for c in cycles)
# Weight each cycle by its width so a long cycle counts proportionally.
summary.update({
'cycles': len(cycles),
'total_px': total_px,
'full_rotation_seconds': round(total_px / speed, 1) if speed else 0.0,
'dead_pct': round(
sum(c['dead_pct'] * c['width_px'] for c in cycles) / total_px, 1),
'mean_ink_pct': round(
sum(c['mean_ink_pct'] * c['width_px'] for c in cycles) / total_px, 1),
'sparse_pct': round(
sum(c['sparse_pct'] * c['width_px'] for c in cycles) / total_px, 1),
'worst_dead_seconds': max(c['longest_dead_seconds'] for c in cycles),
'worst_sparse_seconds': max(c['longest_sparse_seconds'] for c in cycles),
})
if args.json:
print(json.dumps({'summary': summary, 'cycles': cycles, 'plugins': results},
indent=2))
else:
print(f"\n Cycles ({per_cycle} plugins each, as production composes them):")
for idx, cyc in enumerate(cycles):
print(f" [{idx}] {cyc['width_px']:>6d}px {cyc['seconds']:>6.1f}s "
f"ink {cyc['mean_ink_pct']:>5.1f}% blank {cyc['dead_pct']:>5.1f}% "
f"worst blank {cyc['longest_dead_seconds']:>5.1f}s "
f"| {', '.join(cyc['plugins'])}")
print(f"\n {'-' * 66}")
print(f" full rotation {summary.get('full_rotation_seconds', 0):>7.1f}s "
f"over {summary.get('cycles', 0)} cycles")
print(f" mean ink coverage {summary.get('mean_ink_pct', 0):>7.1f}% "
f"(higher is better; target >25%)")
print(f" fully blank {summary.get('dead_pct', 0):>7.1f}% (target <2%)")
print(f" reads as empty {summary.get('sparse_pct', 0):>7.1f}% (target <15%)")
print(f" worst blank stretch {summary.get('worst_dead_seconds', 0):>7.1f}s "
f"(target <1.5s)")
print(f" plugins w/ content {summary.get('plugins_with_content', 0):>7d}"
f" of {summary['plugins_audited']}")
return 0
if __name__ == '__main__':
raise SystemExit(main())
+248
View File
@@ -0,0 +1,248 @@
#!/usr/bin/env python3
"""
Headless skin validator — render a skin against bundled fixture games at
multiple panel sizes without hardware, a network, or a running service.
python scripts/validate_skin.py --skin my-skin
python scripts/validate_skin.py --skin my-skin --sport baseball \
--size 128x32 --size 64x32 --output-dir /tmp/skin_renders
For each (mode x size) it checks: the manifest loads and its API version
matches, the render raises no exception, the canvas isn't blank, and the
render finishes inside a time budget (warn — the live renderer runs every
display-loop pass, and a Pi is far slower than your dev machine). PNGs are
saved (native plus 4x nearest-neighbor previews) so you can eyeball the
result. Exit code is non-zero when any check fails.
"""
import argparse
import json
import logging
import sys
import time
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PROJECT_ROOT))
from PIL import Image, ImageDraw, ImageFont # noqa: E402
FIXTURES_DIR = PROJECT_ROOT / "src" / "skin_system" / "fixtures"
MODES = ("live", "recent", "upcoming")
SPORTS = ("baseball", "basketball", "football", "hockey")
RENDER_BUDGET_S = 0.100
class FixtureHost:
"""Stands in for a SportsCore instance: fonts, logger, logo loading,
outlined text — everything build_context needs, no network."""
def __init__(self, sport: str, skin_options: dict) -> None:
self.sport = sport
self.sport_key = sport
self.skin_options = skin_options
self.logger = logging.getLogger(f"validate_skin.{sport}")
self.fonts = self._load_fonts()
self._logo_cache = {}
self.display_manager = None # build_context is always given a size
def _load_fonts(self) -> dict:
"""Load the SportsCore font set (TTF, with PIL default fallback)."""
fonts = {}
try:
press = str(PROJECT_ROOT / "assets/fonts/PressStart2P-Regular.ttf")
small = str(PROJECT_ROOT / "assets/fonts/4x6-font.ttf")
fonts['score'] = ImageFont.truetype(press, 10)
fonts['time'] = ImageFont.truetype(press, 8)
fonts['team'] = ImageFont.truetype(press, 8)
fonts['status'] = ImageFont.truetype(small, 6)
fonts['detail'] = ImageFont.truetype(small, 6)
fonts['rank'] = ImageFont.truetype(press, 10)
except IOError:
default = ImageFont.load_default()
for key in ('score', 'time', 'team', 'status', 'detail', 'rank'):
fonts[key] = default
return fonts
def _load_and_resize_logo(self, team_id: str, team_abbrev: str,
logo_path, logo_url) -> "Image.Image | None":
"""Load a fixture logo from disk (no downloads), cached per team."""
if team_abbrev in self._logo_cache:
return self._logo_cache[team_abbrev]
path = Path(logo_path)
if not path.is_absolute():
path = PROJECT_ROOT / path
if not path.exists():
return None
logo = Image.open(path).convert('RGBA')
self._logo_cache[team_abbrev] = logo
return logo
def _draw_text_with_outline(self, draw: "ImageDraw.ImageDraw", text: str,
position: tuple, font,
fill: tuple = (255, 255, 255),
outline_color: tuple = (0, 0, 0)) -> None:
"""Classic outlined scorebug text, same as SportsCore's helper."""
x, y = position
for dx, dy in [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1),
(1, -1), (1, 0), (1, 1)]:
draw.text((x + dx, y + dy), text, font=font, fill=outline_color)
draw.text((x, y), text, font=font, fill=fill)
def load_fixture(sport: str, mode: str) -> dict:
with open(FIXTURES_DIR / f"{sport}_{mode}.json", encoding="utf-8") as f:
game = json.load(f)
# Real view models carry start_time_utc as a UTC datetime, not a string.
if isinstance(game.get("start_time_utc"), str):
from datetime import datetime
game["start_time_utc"] = datetime.fromisoformat(game["start_time_utc"])
return game
def parse_size(value: str) -> "tuple[int, int]":
try:
w_text, h_text = value.lower().split("x")
w, h = int(w_text), int(h_text)
except ValueError as exc:
raise argparse.ArgumentTypeError(f"size must look like 128x32, got {value!r}") from exc
if w <= 0 or h <= 0:
raise argparse.ArgumentTypeError(f"size dimensions must be positive, got {value!r}")
return w, h
def parse_options(value: str) -> dict:
try:
options = json.loads(value)
except json.JSONDecodeError as exc:
raise argparse.ArgumentTypeError(f"options must be valid JSON: {exc.msg}") from exc
if not isinstance(options, dict):
raise argparse.ArgumentTypeError("options must be a JSON object")
return options
def display_path(path: Path) -> str:
"""Repo-relative when inside the repo, absolute otherwise (--output-dir
may point anywhere, e.g. /tmp/skin_renders)."""
try:
return str(path.relative_to(PROJECT_ROOT))
except ValueError:
return str(path)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--skin", required=True, help="skin id (directory name under skins/)")
parser.add_argument("--sport", choices=SPORTS,
help="fixture sport (default: first sport the skin targets, else baseball)")
parser.add_argument("--size", action="append", type=parse_size, dest="sizes",
metavar="WxH", help="panel size to render at (repeatable; default 128x32 and 64x32)")
parser.add_argument("--output-dir", type=Path,
default=PROJECT_ROOT / "skin_renders",
help="where rendered PNGs are written")
parser.add_argument("--options", type=parse_options, default={},
help="skin_options JSON to pass the skin")
args = parser.parse_args()
sizes = args.sizes or [(128, 32), (64, 32)]
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
from src.skin_system import skin_runtime
from src.skin_system.skin_base import SKIN_API_VERSION
skins = skin_runtime.discover_skins()
manifest = skins.get(args.skin)
if manifest is None:
print(f"FAIL: skin '{args.skin}' not found under {skin_runtime.get_skins_directory()}")
if skins:
print(f" installed skins: {', '.join(sorted(skins))}")
return 1
sport = args.sport
if sport is None:
declared = skin_runtime.skin_targets(manifest)[0]
sport = next((s for s in declared if s in SPORTS), "baseball")
skin = skin_runtime.load_skin(args.skin, sport=sport, sport_key=sport,
options=args.options)
if skin is None:
print(f"FAIL: skin '{args.skin}' did not load "
f"(see log above; host API is {SKIN_API_VERSION})")
return 1
host = FixtureHost(sport, args.options)
args.output_dir.mkdir(parents=True, exist_ok=True)
failures = 0
rendered = 0
for mode in MODES:
game = load_fixture(sport, mode)
render = getattr(skin, f"render_{mode}")
for width, height in sizes:
label = f"{mode}@{width}x{height}"
try:
# Warm-up render absorbs one-time font/image loads, second
# render is the one timed against the budget.
ctx = skin_runtime.build_context(host, game, size=(width, height))
handled = render(ctx, dict(game))
if handled:
ctx = skin_runtime.build_context(host, game, size=(width, height))
started = time.monotonic()
handled = render(ctx, dict(game))
elapsed = time.monotonic() - started
else:
elapsed = 0.0
except Exception as e:
print(f"FAIL {label}: render raised {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
failures += 1
continue
if not handled:
print(f"skip {label}: render_{mode} returned False (built-in renderer would be used)")
continue
if ctx.canvas.size != (width, height):
print(f"FAIL {label}: canvas was replaced/resized to {ctx.canvas.size} — draw onto ctx.canvas, never reassign it")
failures += 1
continue
if ctx.canvas.convert("L").getbbox() is None:
print(f"FAIL {label}: canvas is blank — render returned True but drew nothing")
failures += 1
continue
if elapsed > RENDER_BUDGET_S:
print(f"WARN {label}: render took {elapsed * 1000:.0f}ms "
f"(budget {RENDER_BUDGET_S * 1000:.0f}ms; a Pi is much slower than this machine)")
out = args.output_dir / f"{args.skin}_{sport}_{mode}_{width}x{height}.png"
ctx.canvas.save(out)
preview = ctx.canvas.resize((width * 4, height * 4), Image.NEAREST)
preview.save(out.with_name(out.stem + "_x4.png"))
print(f"ok {label}: {elapsed * 1000:.0f}ms -> {display_path(out)}")
rendered += 1
# Vegas card, once per mode at the first size (optional API)
try:
width, height = sizes[0]
ctx = skin_runtime.build_context(host, game, size=(width, height))
card = skin.render_vegas_card(ctx, dict(game))
if card is not None:
out = args.output_dir / f"{args.skin}_{sport}_{mode}_vegas.png"
card.save(out)
print(f"ok {mode} vegas card -> {display_path(out)}")
except Exception as e:
print(f"FAIL {mode} vegas card: {type(e).__name__}: {e}")
failures += 1
if rendered == 0 and failures == 0:
print(f"FAIL: skin '{args.skin}' rendered nothing — no render_<mode> returned True")
return 1
print(f"\n{'FAILED' if failures else 'PASSED'}: {rendered} renders, {failures} failures "
f"(PNGs in {args.output_dir})")
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())
+23
View File
@@ -0,0 +1,23 @@
# skins/
User-installable **visual skins** for the sports scoreboards. Each
subdirectory is one skin:
```text
skins/<skin-id>/
skin.json # manifest
skin.py # renderer (a ScoreboardSkin subclass)
preview.png # optional
```
- Install a skin: `git clone <skin repo> skins/<skin-id>` (or via the Plugin
Store for registry entries with `"type": "skin"`).
- Select it: set `"skin": "<skin-id>"` in the plugin's section of
`config/config.json`, or use the web UI's Visual Skin dropdown.
- Build one: start from `example-classic-baseball/` and read
[docs/CREATING_SKINS.md](../docs/CREATING_SKINS.md). Validate with
`python scripts/validate_skin.py --skin <skin-id>`.
Skins survive plugin reinstalls/updates (that's why they live here and not in
the plugin's directory). A skin is Python at the same trust level as a
plugin — review before installing.
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

+25
View File
@@ -0,0 +1,25 @@
{
"id": "example-classic-baseball",
"name": "Example: Classic Baseball",
"version": "1.0.0",
"author": "LEDMatrix",
"description": "Reference skin: a restyled baseball scorebug demonstrating the skin API. Copy this directory to start your own skin.",
"skin_api_version": "1.0.0",
"targets": {
"sports": [
"baseball"
],
"sport_keys": [
"mlb",
"milb"
]
},
"entry_point": "skin.py",
"class_name": "ClassicBaseballSkin",
"modes": [
"live",
"recent",
"upcoming"
],
"preview": "preview.png"
}
+131
View File
@@ -0,0 +1,131 @@
"""
Example: Classic Baseball — the reference skin.
Shows the whole skin API surface on purpose: adaptive regions
(scoreboard_regions), fitted text (ctx.layout.fit_text + ctx.draw_fit),
logos (ctx.load_logo + ctx.draw_image), raw PIL (ctx.draw for the bases
diamond), and per-user options (ctx.options). Everything is derived from
ctx and the game dict — a skin holds no state, does no I/O, and never
touches the display.
Copy this directory to skins/<your-skin-id>/, rename the class and the
manifest fields, and run:
python scripts/validate_skin.py --skin <your-skin-id>
"""
from src.adaptive_layout import LADDER_GRID, scoreboard_regions
from src.skin_system.skin_base import ScoreboardSkin, SkinContext
DEFAULT_ACCENT = (255, 200, 0)
class ClassicBaseballSkin(ScoreboardSkin):
"""Reference baseball skin: classic scorebug with bases/outs/count."""
def __init__(self, manifest: dict, options: dict):
super().__init__(manifest, options)
# Validate user options once at load time (fail fast, fall back
# gracefully) rather than surprising every render.
accent = self.options.get("accent_color", DEFAULT_ACCENT)
if (isinstance(accent, (list, tuple)) and len(accent) == 3
and all(isinstance(c, int) and 0 <= c <= 255 for c in accent)):
self._accent_color = tuple(accent)
else:
import logging
logging.getLogger(__name__).error(
"accent_color must be three 0-255 integers, got %r; using default", accent)
self._accent_color = DEFAULT_ACCENT
# -- shared pieces ----------------------------------------------------
def _accent(self, ctx: SkinContext) -> tuple:
"""Users can recolor the skin from config via skin_options."""
return self._accent_color
def _draw_card(self, ctx: SkinContext, game: dict, status: str,
center_lines: list, detail: str) -> None:
"""The common card: logos left/right, status on top, the given
center content, detail along the bottom."""
regions = scoreboard_regions(ctx.layout.bounds, ctx=ctx.layout)
ctx.draw_image(ctx.load_logo("away"), regions.away_slot,
cache_key=f"logo:{game.get('away_abbr')}")
ctx.draw_image(ctx.load_logo("home"), regions.home_slot,
cache_key=f"logo:{game.get('home_abbr')}")
if status:
fit = ctx.layout.fit_text(status, regions.status_band, LADDER_GRID)
ctx.draw_fit(fit, regions.status_band, color=self._accent(ctx))
if center_lines:
rows = regions.score_area.split_v(*[1] * len(center_lines))
for line, row in zip(center_lines, rows):
if line:
fit = ctx.layout.fit_text(line, row, LADDER_GRID)
ctx.draw_fit(fit, row)
if detail:
fit = ctx.layout.fit_text(detail, regions.detail_band, LADDER_GRID)
ctx.draw_fit(fit, regions.detail_band, color=(160, 160, 160))
def _draw_bases_and_outs(self, ctx: SkinContext, game: dict) -> None:
"""Raw-PIL escape hatch: a bases diamond + out dots in the bottom
band, sized from the layout scale so it works on any panel."""
size = ctx.layout.px(3, minimum=2) # half-diagonal of one base
gap = ctx.layout.px(1)
cx = ctx.width // 2
cy = ctx.height - (size * 2) - 1
bases = game.get("bases_occupied") or [False, False, False]
# (dx, dy) per base: first (right), second (top), third (left)
offsets = [(size + gap, 0), (0, -(size + gap)), (-(size + gap), 0)]
for occupied, (dx, dy) in zip(bases, offsets):
x, y = cx + dx, cy + dy
diamond = [(x, y - size), (x + size, y), (x, y + size), (x - size, y)]
if occupied:
ctx.draw.polygon(diamond, fill=self._accent(ctx))
else:
ctx.draw.polygon(diamond, outline=(110, 110, 110))
outs = min(int(game.get("outs") or 0), 3)
r = max(1, size - 1)
for i in range(3):
x = cx + (i - 1) * (2 * r + 2 * gap)
y = ctx.height - r - 1
dot = [x - r, y - r, x + r, y + r]
if i < outs:
ctx.draw.ellipse(dot, fill=(255, 255, 255))
else:
ctx.draw.ellipse(dot, outline=(110, 110, 110))
# -- the three modes --------------------------------------------------
def render_live(self, ctx: SkinContext, game: dict) -> bool:
half = "" if game.get("inning_half") == "top" else ""
inning = game.get("inning") or ""
status = f"{half}{inning}" if inning else game.get("status_text", "")
score = f"{game.get('away_score', '0')}-{game.get('home_score', '0')}"
count = f"{game.get('balls', 0)}-{game.get('strikes', 0)}"
self._draw_card(ctx, game, status, [score], "")
self._draw_bases_and_outs(ctx, game)
# Ball-strike count in the top-left corner, over the away logo.
fit = ctx.layout.fit_text(count, (ctx.width // 4, ctx.layout.px(8, minimum=6)), LADDER_GRID)
ctx.draw_fit(fit, ctx.layout.bounds.top_band(fit.height + 1).left_col(fit.width + 2),
color=(200, 200, 200))
return True
def render_recent(self, ctx: SkinContext, game: dict) -> bool:
score = f"{game.get('away_score', '0')}-{game.get('home_score', '0')}"
self._draw_card(ctx, game, game.get("status_text", "Final"),
[score], game.get("series_summary", ""))
return True
def render_upcoming(self, ctx: SkinContext, game: dict) -> bool:
matchup = f"{game.get('away_abbr', '')}@{game.get('home_abbr', '')}"
self._draw_card(ctx, game, game.get("game_date", ""),
[matchup, game.get("game_time", "")],
f"{game.get('away_record', '')} {game.get('home_record', '')}".strip())
return True
+8 -2
View File
@@ -164,7 +164,13 @@ class Baseball(SportsCore):
# Get game state information
if status_state == "in":
# For live games, get detailed state
inning = game_event["status"].get(
# Use the competition-level `status` already validated by
# _extract_game_details_common. Real ESPN events duplicate
# status at the event top level, but MiLB events (synthesized
# from the MLB Stats API into an ESPN-like shape) populate
# only the competition-level one, so the top-level lookup
# raised a bare KeyError and dropped the event.
inning = status.get(
"period", 1
) # Get inning from status period
@@ -187,7 +193,7 @@ class Baseball(SportsCore):
if "end" in status_detail or "end" in status_short:
inning_half = "top"
inning = (
game_event["status"].get("period", 1) + 1
status.get("period", 1) + 1
) # Use period and increment for next inning
if is_favorite_game:
self.logger.debug(
+11 -4
View File
@@ -38,10 +38,17 @@ class Hockey(SportsCore):
status = competition["status"]
powerplay = False
penalties = ""
# A competitor may legitimately arrive without a "statistics"
# array (pre-game feeds, and some in-progress ones). Reading it
# unguarded raised KeyError inside the generator and dropped the
# WHOLE event, discarding valid scores and status. Default to an
# empty list so the saves/shots figures fall back to 0 instead.
home_stats = home_team.get("statistics", [])
away_stats = away_team.get("statistics", [])
home_team_saves = next(
(
int(c["displayValue"])
for c in home_team["statistics"]
for c in home_stats
if c.get("name") == "saves"
),
0,
@@ -49,7 +56,7 @@ class Hockey(SportsCore):
home_team_saves_per = next(
(
float(c["displayValue"])
for c in home_team["statistics"]
for c in home_stats
if c.get("name") == "savePct"
),
0.0,
@@ -57,7 +64,7 @@ class Hockey(SportsCore):
away_team_saves = next(
(
int(c["displayValue"])
for c in away_team["statistics"]
for c in away_stats
if c.get("name") == "saves"
),
0,
@@ -65,7 +72,7 @@ class Hockey(SportsCore):
away_team_saves_per = next(
(
float(c["displayValue"])
for c in away_team["statistics"]
for c in away_stats
if c.get("name") == "savePct"
),
0.0,
+17
View File
@@ -0,0 +1,17 @@
"""Sports scoreboard base classes.
Formerly the single module ``src/base_classes/sports.py``; now a package so
capabilities can be composed instead of accumulating in one class. See
docs/SPORTS_UNIFICATION.md for the architecture. The import path is
unchanged: ``from src.base_classes.sports import SportsCore`` still works.
"""
from .core import SportsCore
from .modes import SportsLive, SportsRecent, SportsUpcoming
__all__ = [
"SportsCore",
"SportsUpcoming",
"SportsRecent",
"SportsLive",
]
@@ -0,0 +1,32 @@
"""Opt-in capabilities for the sports scoreboards.
Each module here is a feature that only *some* sports want. They are composed
by inheritance (mixins) or selected by name (strategies) — never enabled by an
``if self.<feature>_enabled:`` branch inside the base classes.
The distinction matters: hockey has no celebrations, so ``HockeyLive`` does not
inherit :class:`~.celebrations.CelebrationMixin` and the celebration code is not
in hockey's MRO at all. A bug in it cannot reach a plugin that never opted in.
See ``docs/SPORTS_UNIFICATION.md`` for the full rationale.
"""
from .celebrations import CelebrationMixin
from .rotation import (
RotationStrategy,
SimpleRotation,
SmoothWeightedRotation,
WeightedCycleRotation,
get_rotation_strategy,
register_rotation_strategy,
)
__all__ = [
"CelebrationMixin",
"RotationStrategy",
"SimpleRotation",
"SmoothWeightedRotation",
"WeightedCycleRotation",
"get_rotation_strategy",
"register_rotation_strategy",
]
@@ -0,0 +1,379 @@
"""Score / win celebration takeover — an opt-in capability.
Four of the nine scoreboards celebrate (afl, nrl, soccer, football); the other
five do not. This is a **mixin** rather than a flag inside ``SportsLive`` so the
five that do not opt in have none of this code in their MRO: a bug here cannot
reach hockey, and hockey's config never grows keys it ignores.
Usage — mix in *before* the mode class so its ``display`` runs first::
class SoccerLive(CelebrationMixin, SportsLive):
def score_phrase(self, points, team_abbr):
return secrets.choice(("GOOOOAAALLL!", f"{team_abbr} SCORES!"))
The two lineages spelled this differently (``_check_for_goal`` /
``celebrate_opponent_goals`` in the soccer lineage, ``_check_for_score`` /
``celebrate_opponent_scores`` in football) but the bodies were identical apart
from three things, each of which is a seam here rather than a branch:
* **wording** — :meth:`score_phrase`, the hook football uses to say "TOUCHDOWN"
from the points delta and soccer uses to say "GOOOOAAALLL";
* **follow-up suppression** — :attr:`COALESCE_SCORING_SEQUENCE`, on for football
where a touchdown lands as +6 then +1 a few seconds later, off elsewhere where
two quick goals are two real events;
* **team identity** — matching goes through ``_favorite_key``, so nrl can match
on team id (its abbreviations are ambiguous) without core knowing why.
The config keys are read under both spellings, so a plugin adopting the mixin
keeps working with the ``*_goals`` keys already in its published schema.
"""
from __future__ import annotations
import re
import time
from typing import Any, Dict, List, Optional
from PIL import Image, ImageDraw
class CelebrationMixin:
"""Full-screen takeover when a tracked team scores or wins."""
#: Collapse increments that land while a celebration is already on screen
#: into that one celebration. True for sports where a single scoring play
#: arrives as more than one score update (football: touchdown +6, then the
#: extra point +1). False where consecutive increments are distinct events —
#: suppressing there would swallow a real goal.
COALESCE_SCORING_SEQUENCE = False
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
mode_config = getattr(self, "mode_config", {}) or {}
self.celebration_enabled = mode_config.get("celebration_enabled", True)
self.celebration_duration = mode_config.get("celebration_duration", 8)
# Both spellings: the soccer lineage ships `celebrate_opponent_goals`,
# football ships `celebrate_opponent_scores`. Whichever the plugin's
# schema declares is the one its users have set.
self.celebrate_opponent_scores = mode_config.get(
"celebrate_opponent_scores",
mode_config.get("celebrate_opponent_goals", False),
)
# Per-game score baselines: {game_id: {"away": int, "home": int}}
self._score_baselines: Dict[str, Dict[str, int]] = {}
# The active celebration (a game *snapshot*, so a win survives the game
# leaving live_games) or None. See _start_celebration for the shape.
self.active_celebration: Optional[Dict[str, Any]] = None
# ------------------------------------------------------------------
# Override points
# ------------------------------------------------------------------
def score_phrase(self, points: int, team_abbr: str) -> str:
"""The wording for a score celebration.
``points`` is the score delta that triggered it, which sports with
variable-value scores use to name the play. The default is deliberately
sport-neutral; every celebrating plugin overrides it.
"""
return f"{team_abbr} SCORES!"
def win_phrase(self, team_abbr: str) -> str:
"""The wording for a win celebration."""
return f"{team_abbr} WINS!"
def _is_favorite(self, key: Optional[str]) -> bool:
"""Whether ``key`` (whatever ``_favorite_key`` returns) is a favorite."""
return bool(self.favorite_teams) and key in self.favorite_teams
# ------------------------------------------------------------------
# Detection
# ------------------------------------------------------------------
@staticmethod
def _score_to_int(score) -> Optional[int]:
"""Coerce an ESPN score value (str / int / dict) to an int, or None."""
try:
if score is None:
return None
if isinstance(score, str):
s = score.strip()
if not s:
return None
try:
return int(float(s))
except ValueError:
numbers = re.findall(r"\d+", s)
return int(numbers[0]) if numbers else None
if isinstance(score, dict):
return int(float(score.get("value", score.get("displayValue", 0))))
return int(float(score))
except (ValueError, TypeError):
return None
def _should_celebrate_for(self, game: Dict, side: str) -> bool:
"""Whether a score by ``side`` in ``game`` should trigger a celebration."""
if self._is_favorite(self._favorite_key(game, side)):
return True
if not self.favorite_teams:
# No favorites configured: the user opted to show this game, so
# celebrate any score in it.
return True
# Favorites exist but this team isn't one -> it's the opponent.
return self.celebrate_opponent_scores
def has_active_celebration(self) -> bool:
"""True while a celebration is within its display window."""
celebration = self.active_celebration
return bool(celebration) and (
time.time() - celebration["started_at"] < self.celebration_duration
)
def _check_for_score(self, game: Dict) -> None:
"""Compare a live game's score against its baseline and arm a
celebration when a celebratable team's score increases."""
if not self.celebration_enabled:
return
game_id = game.get("id")
if not game_id:
return
away = self._score_to_int(game.get("away_score"))
home = self._score_to_int(game.get("home_score"))
if away is None or home is None:
return
baseline = self._score_baselines.get(game_id)
# Always refresh the baseline: a first sighting must never celebrate (a
# game already in progress at boot would false-fire), and a decrement
# (VAR, a correction) just re-bases silently.
self._score_baselines[game_id] = {"away": away, "home": home}
if baseline is None:
return
away_delta = away - baseline["away"]
home_delta = home - baseline["home"]
if away_delta <= 0 and home_delta <= 0:
return
# One takeover per scoring sequence, where the sport has such a thing.
# The baseline is already advanced above, so nothing re-fires later.
if self.COALESCE_SCORING_SEQUENCE and self.has_active_celebration():
return
scored_side = None
points = 0
if away_delta > 0 and self._should_celebrate_for(game, "away"):
scored_side, points = "away", away_delta
if scored_side is None and home_delta > 0 and self._should_celebrate_for(
game, "home"
):
scored_side, points = "home", home_delta
if scored_side is None:
return
self._start_celebration(
game,
"score",
scored_side=scored_side,
team_abbr=game.get(f"{scored_side}_abbr", ""),
away_score=away,
home_score=home,
points=points,
)
def _check_for_win(self, game: Dict) -> None:
"""When a game we were tracking live goes final, arm a win celebration
if a favorite won. Fires at most once per game."""
if not self.celebration_enabled:
return
game_id = game.get("id")
if not game_id:
return
# Only celebrate wins for games we actually watched go live: one seen
# for the first time already-final (the board started after full time)
# has no baseline and must not fire.
if game_id not in self._score_baselines:
return
# Consume the baseline so this can only fire once.
self._score_baselines.pop(game_id, None)
away = self._score_to_int(game.get("away_score"))
home = self._score_to_int(game.get("home_score"))
if away is None or home is None:
return
if away > home:
winner_side = "away"
elif home > away:
winner_side = "home"
else:
return # draw -> no win celebration
# Wins are gated strictly on favorites: every game ends, so the
# "no favorites -> celebrate all" score fallback would be far too noisy.
if not self._is_favorite(self._favorite_key(game, winner_side)):
return
self._start_celebration(
game,
"win",
scored_side=winner_side,
team_abbr=game.get(f"{winner_side}_abbr", ""),
away_score=away,
home_score=home,
)
def _start_celebration(
self,
game: Dict,
kind: str,
scored_side: str,
team_abbr: str,
away_score: int,
home_score: int,
points: int = 0,
) -> None:
"""Arm a celebration. ``scored_side`` ('away'/'home') is the side whose
score digit gets highlighted."""
phrase = (
self.win_phrase(team_abbr)
if kind == "win"
else self.score_phrase(points, team_abbr)
)
self.active_celebration = {
"kind": kind,
"game": dict(game), # snapshot: survives the game leaving live_games
"scored_side": scored_side,
"team_abbr": team_abbr,
"away_score": away_score,
"home_score": home_score,
"started_at": time.time(),
"phrase": phrase,
}
# Pin focus to the involved game so the post-celebration scorebug
# resumes on it.
self.current_game = dict(game)
self.logger.info(
f"Celebration ({kind}) armed: {phrase} "
f"[{game.get('away_abbr')} {away_score}-{home_score} {game.get('home_abbr')}]"
)
# ------------------------------------------------------------------
# Rendering
# ------------------------------------------------------------------
def _fit_font(self, draw, text: str, max_width: int, fonts: List):
"""The first font whose rendered ``text`` fits ``max_width``, falling
back to the last (smallest) font."""
for font in fonts:
if draw.textlength(text, font=font) <= max_width - 2:
return font
return fonts[-1]
def _draw_celebration_layout(
self, celebration: Dict, force_clear: bool = False
) -> None:
"""Render the full-screen score/win takeover."""
if force_clear:
self.display_manager.clear()
display_width = (
self.display_manager.matrix.width
if hasattr(self.display_manager, "matrix") and self.display_manager.matrix
else self.display_width
)
display_height = (
self.display_manager.matrix.height
if hasattr(self.display_manager, "matrix") and self.display_manager.matrix
else self.display_height
)
elapsed = time.time() - celebration["started_at"]
game = celebration["game"]
# Background: a brief color flash for the first ~1.2s, then black.
bg = (0, 0, 0, 255)
if elapsed < 1.2 and int(elapsed / 0.2) % 2 == 0:
bg = (12, 12, 48, 255)
main_img = Image.new("RGBA", (display_width, display_height), bg)
overlay = Image.new("RGBA", (display_width, display_height), (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay)
# Logos at the edges (best-effort: a logo failure must not blank the
# celebration).
try:
center_y = display_height // 2
home_logo = self._load_and_resize_logo(
game.get("home_id"), game.get("home_abbr"),
game.get("home_logo_path"), game.get("home_logo_url"),
)
away_logo = self._load_and_resize_logo(
game.get("away_id"), game.get("away_abbr"),
game.get("away_logo_path"), game.get("away_logo_url"),
)
if home_logo:
main_img.paste(
home_logo,
(display_width - home_logo.width + 2, center_y - home_logo.height // 2),
home_logo,
)
if away_logo:
main_img.paste(
away_logo, (-2, center_y - away_logo.height // 2), away_logo
)
except Exception as e:
self.logger.debug(f"Celebration logo load failed: {e}")
# Phrase across the top, shrunk to fit the panel width.
phrase = celebration["phrase"]
phrase_font = self._fit_font(
draw, phrase, display_width, [self.fonts["time"], self.fonts["status"]]
)
phrase_width = draw.textlength(phrase, font=phrase_font)
self._draw_text_with_outline(
draw, phrase, ((display_width - phrase_width) // 2, 1), phrase_font
)
# Score centered low, with the scoring/winning side's digit pulsing in a
# highlight color so the change reads at a glance.
away_text = str(celebration["away_score"])
home_text = str(celebration["home_score"])
score_font = self.fonts["score"]
segments = [
(away_text, celebration["scored_side"] == "away"),
("-", False),
(home_text, celebration["scored_side"] == "home"),
]
total_width = sum(draw.textlength(seg, font=score_font) for seg, _ in segments)
highlight = (255, 255, 0) if int(elapsed * 4) % 2 == 0 else (255, 170, 0)
x = (display_width - total_width) // 2
y = display_height - 14
for seg, is_highlight in segments:
color = highlight if is_highlight else (255, 255, 255)
self._draw_text_with_outline(draw, seg, (int(x), y), score_font, fill=color)
x += draw.textlength(seg, font=score_font)
main_img = Image.alpha_composite(main_img, overlay).convert("RGB")
self.display_manager.image = main_img
self.display_manager.update_display()
def display(self, force_clear: bool = False) -> bool:
"""Render an active celebration as a full-screen takeover; otherwise
defer to the normal live scorebug."""
if not self.is_enabled:
return False
celebration = self.active_celebration
if celebration:
if time.time() - celebration["started_at"] < self.celebration_duration:
try:
self._draw_celebration_layout(celebration, force_clear)
return True
except Exception as e:
self.logger.error(f"Error drawing celebration: {e}", exc_info=True)
else:
self.active_celebration = None
# Reset the dwell so the scorebug resumes on the scoring/winning
# game for a full duration before rotation can move on.
self.last_game_switch = time.time()
return super().display(force_clear)
@@ -0,0 +1,228 @@
"""Live-rotation strategies — which live game to show next.
The nine plugin copies grew three spellings of this, and the survey behind
``docs/SPORTS_UNIFICATION.md`` found they are all the *same* Smooth Weighted
Round-Robin algorithm in two shapes:
* an **incremental picker** that holds weight state across calls and answers
"what next?" one game at a time (afl / nrl / soccer's ``_swrr_advance``), and
* a **precomputed cycle** that returns a full list of game ids up front
(football / baseball / basketball's ``_build_weighted_schedule`` and hockey's
``_build_rotation_schedule``, which differ only in loop shape).
They agree *within* a cycle — SWRR is deterministic — and differ only at cycle
boundaries, where the incremental form has no seam and the precomputed form
restarts. That is a real behavioral difference, so core ships both rather than
declaring a winner, and a plugin picks one by name:
self.rotation = get_rotation_strategy("swrr", weight_for=self._live_weight)
Core never learns which sport is asking. A plugin with a genuinely novel
ordering registers its own strategy instead of core growing a branch::
register_rotation_strategy("my-order", MyRotation)
"""
from __future__ import annotations
from typing import Callable, Dict, List, Optional, Type
def _game_id(game: Dict) -> Optional[str]:
"""The rotation key for a game, or None if it has no usable id."""
return game.get("id")
class RotationStrategy:
"""Base class for live-rotation ordering.
Subclasses implement :meth:`schedule`; :meth:`next_game` has a working
default derived from it. Strategies whose natural shape is incremental
override :meth:`next_game` instead and derive :meth:`schedule`.
:param weight_for: callable mapping a game dict to a positive integer
weight — how many turns it gets per turn of a weight-1 game. Supplied by
the host so the *favorites* policy stays with the plugin and this module
stays free of any notion of what a favorite is. Defaults to equal
weights, which makes every strategy a plain round robin.
"""
#: Name this strategy is registered under. Set by :func:`register_rotation_strategy`.
name: str = ""
def __init__(self, weight_for: Optional[Callable[[Dict], int]] = None):
self._weight_for = weight_for or (lambda game: 1)
def weights(self, games: List[Dict]) -> Dict[str, int]:
"""``{game_id: weight}`` for games that have an id, in ``games`` order.
A weight below 1 is clamped up: a zero or negative weight would starve
a game out of the rotation entirely, which no caller means to express
and which would make ``total_weight`` collapse.
"""
weights: Dict[str, int] = {}
for game in games:
gid = _game_id(game)
if gid is None:
continue
try:
weight = int(self._weight_for(game))
except (TypeError, ValueError):
weight = 1
weights[gid] = max(1, weight)
return weights
def schedule(self, games: List[Dict]) -> List[str]:
"""Game ids in display order for one cycle. Ids may repeat."""
raise NotImplementedError
def next_game(self, games: List[Dict]) -> Optional[Dict]:
"""The next game to display, or None when there is nothing to show."""
order = self.schedule(games)
if not order:
return None
by_id = {gid: g for g in games if (gid := _game_id(g)) is not None}
return by_id.get(order[0])
def reset(self) -> None:
"""Drop any accumulated state. Stateless strategies need do nothing."""
class SimpleRotation(RotationStrategy):
"""Plain round robin: every live game once per cycle, weights ignored.
The fallback for a plugin that wants strictly even rotation regardless of
favorites.
"""
def schedule(self, games: List[Dict]) -> List[str]:
return [gid for g in games if (gid := _game_id(g)) is not None]
class WeightedCycleRotation(RotationStrategy):
"""Precomputed SWRR cycle — the football / baseball / basketball / hockey shape.
Returns a full cycle of ``sum(weights)`` ids with repeats spaced evenly
rather than clumped, highest weight scheduled first. When no game carries a
boost the cycle degenerates to a single pass in ``games`` order, which is
exactly the plain round robin it replaced.
"""
def schedule(self, games: List[Dict]) -> List[str]:
weights = self.weights(games)
if not weights:
return []
total_weight = sum(weights.values())
if total_weight <= len(weights):
# No boost in effect — plain order, one pass. (Also the guard that
# keeps the loop below from being O(total_weight) for nothing.)
return list(weights)
current = {gid: 0 for gid in weights}
order: List[str] = []
for _ in range(total_weight):
for gid, weight in weights.items():
current[gid] += weight
picked = max(current, key=lambda gid: current[gid])
current[picked] -= total_weight
order.append(picked)
return order
class SmoothWeightedRotation(RotationStrategy):
"""Incremental SWRR — the afl / nrl / soccer shape.
Weight state persists across calls, so there is no fixed-length cycle and
therefore no clustering seam at a cycle boundary. A game seen for the first
time starts at weight 0 and receives its full weight on the next call, so a
favorite's game that has just gone live naturally wins the first pick after
it appears — "queued first on refresh" without a special-cased branch.
State for games no longer live is dropped on each call, so a long-running
board does not accumulate entries for finished games.
"""
def __init__(self, weight_for: Optional[Callable[[Dict], int]] = None):
super().__init__(weight_for)
self._current: Dict[str, int] = {}
def reset(self) -> None:
self._current = {}
def next_game(self, games: List[Dict]) -> Optional[Dict]:
if not games:
return None
weights = self.weights(games)
if not weights:
return None
# Keep state only for games still live.
self._current = {
gid: value for gid, value in self._current.items() if gid in weights
}
for gid, weight in weights.items():
self._current[gid] = self._current.get(gid, 0) + weight
total_weight = sum(weights.values())
# Iterate in `games` order so ties break toward the feed's ordering,
# which is what the plugin copies did and what makes the no-boost case
# identical to a plain round robin.
ids_in_order = [gid for g in games if (gid := _game_id(g)) in weights]
best = max(ids_in_order, key=lambda gid: self._current[gid])
self._current[best] -= total_weight
return next(g for g in games if _game_id(g) == best)
def schedule(self, games: List[Dict]) -> List[str]:
"""One cycle's worth of picks, without disturbing live state.
Derived by running the picker forward on a copy, so the returned order
is exactly what repeated :meth:`next_game` calls would produce from the
current state — callers can use it to preview or log the rotation
without perturbing it.
"""
weights = self.weights(games)
if not weights:
return []
preview = SmoothWeightedRotation(self._weight_for)
preview._current = dict(self._current)
order: List[str] = []
for _ in range(sum(weights.values())):
picked = preview.next_game(games)
if picked is None:
break
order.append(_game_id(picked))
return order
_REGISTRY: Dict[str, Type[RotationStrategy]] = {}
def register_rotation_strategy(name: str, factory: Type[RotationStrategy]) -> None:
"""Register a rotation strategy under ``name``.
A plugin needing an ordering core does not ship registers it here rather
than core growing a sport-specific branch. Re-registering a name replaces
it, so a plugin may also override a built-in for itself.
"""
if not name:
raise ValueError("rotation strategy name must be a non-empty string")
factory.name = name
_REGISTRY[name] = factory
def get_rotation_strategy(
name: str, weight_for: Optional[Callable[[Dict], int]] = None
) -> RotationStrategy:
"""Build the strategy registered under ``name``.
Falls back to ``"simple"`` for an unknown name rather than raising: the name
arrives from user config, and a typo should cost the boost, not the
scoreboard.
"""
factory = _REGISTRY.get(name) or _REGISTRY["simple"]
return factory(weight_for=weight_for)
register_rotation_strategy("simple", SimpleRotation)
register_rotation_strategy("weighted", WeightedCycleRotation)
register_rotation_strategy("swrr", SmoothWeightedRotation)
File diff suppressed because it is too large Load Diff
@@ -1,651 +1,25 @@
"""The three display modes layered on SportsCore: SportsUpcoming,
SportsRecent and SportsLive. Split out of the former
``src/base_classes/sports.py``; see docs/SPORTS_UNIFICATION.md.
"""
import logging
import os
import tempfile
import time
from abc import ABC, abstractmethod
from abc import abstractmethod
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
import pytz
import requests
from PIL import Image, ImageDraw, ImageFont
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from src.background_data_service import get_background_service
# Import new architecture components (individual classes will import what they need)
from src.base_classes.api_extractors import APIDataExtractor
from src.base_classes.data_sources import DataSource
from src.cache_manager import CacheManager
from src.display_manager import DisplayManager
from src.dynamic_team_resolver import DynamicTeamResolver
from src.logo_downloader import LogoDownloader, download_missing_logo
try:
from src.base_odds_manager import BaseOddsManager as OddsManager
except ImportError:
OddsManager = None
from .core import SportsCore
class SportsCore(ABC):
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str):
self.logger = logger
self.config = config
self.cache_manager = cache_manager
self.config_manager = self.cache_manager.config_manager
if OddsManager:
try:
self.odds_manager = OddsManager(
self.cache_manager, self.config_manager)
except Exception as e:
self.logger.warning(f"Failed to initialize OddsManager: {e}")
self.odds_manager = None
else:
self.odds_manager = None
self.logger.warning("OddsManager not available - odds functionality disabled")
self.display_manager = display_manager
self.display_width = self.display_manager.matrix.width
self.display_height = self.display_manager.matrix.height
self.sport_key = sport_key
self.sport = None
self.league = None
# Initialize new architecture components (will be overridden by sport-specific classes)
self.sport_config = None
self.api_extractor: APIDataExtractor
self.data_source: DataSource
self.mode_config = config.get(f"{sport_key}_scoreboard", {}) # Changed config key
self.is_enabled: bool = self.mode_config.get("enabled", False)
self.show_odds: bool = self.mode_config.get("show_odds", False)
# Use LogoDownloader to get the correct default logo directory for this sport
default_logo_dir = Path(LogoDownloader().get_logo_directory(sport_key))
self.logo_dir = self._initialize_logo_dir(default_logo_dir)
self.update_interval: int = self.mode_config.get(
"update_interval_seconds", 60)
self.show_records: bool = self.mode_config.get('show_records', False)
self.show_ranking: bool = self.mode_config.get('show_ranking', False)
# Number of games to show (instead of time-based windows)
self.recent_games_to_show: int = self.mode_config.get(
"recent_games_to_show", 5) # Show last 5 games
self.upcoming_games_to_show: int = self.mode_config.get(
"upcoming_games_to_show", 10) # Show next 10 games
self.show_favorite_teams_only: bool = self.mode_config.get("show_favorite_teams_only", False)
self.show_all_live: bool = self.mode_config.get("show_all_live", False)
self.session = requests.Session()
retry_strategy = Retry(
total=5, # increased number of retries
backoff_factor=1, # increased backoff factor
# added 429 to retry list
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["GET", "HEAD", "OPTIONS"]
)
adapter = HTTPAdapter(max_retries=retry_strategy)
self.session.mount("https://", adapter)
self.session.mount("http://", adapter)
self._logo_cache = {}
# Set up headers
self.headers = {
'User-Agent': 'LEDMatrix/1.0 (https://github.com/yourusername/LEDMatrix; contact@example.com)',
'Accept': 'application/json',
'Accept-Language': 'en-US,en;q=0.9',
'Accept-Encoding': 'gzip, deflate, br',
'Connection': 'keep-alive'
}
self.last_update = 0
self.current_game = None
self.fonts = self._load_fonts()
# Initialize dynamic team resolver and resolve favorite teams
self.dynamic_resolver = DynamicTeamResolver()
raw_favorite_teams = self.mode_config.get("favorite_teams", [])
self.favorite_teams = self.dynamic_resolver.resolve_teams(raw_favorite_teams, sport_key)
# Log dynamic team resolution
if raw_favorite_teams != self.favorite_teams:
self.logger.info(f"Resolved dynamic teams: {raw_favorite_teams} -> {self.favorite_teams}")
else:
self.logger.info(f"Favorite teams: {self.favorite_teams}")
self.logger.setLevel(logging.INFO)
# Initialize team rankings cache
self._team_rankings_cache = {}
self._rankings_cache_timestamp = 0
self._rankings_cache_duration = 3600 # Cache rankings for 1 hour
# Initialize background data service with optimized settings
# Hardcoded for memory optimization: 1 worker, 30s timeout, 3 retries
self.background_service = get_background_service(self.cache_manager, max_workers=1)
self.background_fetch_requests = {} # Track background fetch requests
self.background_enabled = True
self.logger.info("Background service enabled with 1 worker (memory optimized)")
def _initialize_logo_dir(self, configured_path: Path) -> Path:
"""Resolve and ensure a writable logo directory, falling back when necessary."""
downloader = LogoDownloader()
resolved_configured = self._resolve_project_path(configured_path)
candidates = [resolved_configured] + self._get_logo_directory_fallbacks(resolved_configured)
for candidate in candidates:
candidate_path = self._resolve_project_path(candidate)
if downloader.ensure_logo_directory(str(candidate_path)):
if candidate_path != resolved_configured:
self.logger.warning(
"Configured logo directory '%s' is not writable; using fallback '%s'",
resolved_configured,
candidate_path,
)
return candidate_path
self.logger.error(
"Unable to find a writable logo directory. Logos may fail to download (last attempted: %s)",
resolved_configured,
)
return resolved_configured
def _resolve_project_path(self, path: Path) -> Path:
"""Convert relative paths to absolute ones rooted at the project directory."""
if path.is_absolute():
return path
project_root = Path(__file__).resolve().parents[2]
return (project_root / path).resolve()
def _get_logo_directory_fallbacks(self, configured_dir: Path) -> List[Path]:
"""Return fallback directories to try when the configured directory is not writable."""
fallbacks: List[Path] = []
env_override = os.environ.get("LEDMATRIX_LOGO_DIR")
if env_override:
env_path = Path(env_override)
if not env_path.is_absolute():
env_path = self._resolve_project_path(env_path)
fallbacks.append(env_path / self.sport_key)
cache_dir = getattr(self.cache_manager, "cache_dir", None)
if cache_dir:
fallbacks.append(Path(cache_dir) / "logos" / self.sport_key)
try:
fallbacks.append(Path.home() / ".ledmatrix" / "logos" / self.sport_key)
except RuntimeError as e:
self.logger.debug("Could not resolve home directory (expected for service users): %s", e)
fallbacks.append(Path(tempfile.gettempdir()) / "ledmatrix_logos" / self.sport_key)
unique_fallbacks: List[Path] = []
seen = set()
for candidate in fallbacks:
if candidate == configured_dir:
continue
if candidate not in seen:
unique_fallbacks.append(candidate)
seen.add(candidate)
return unique_fallbacks
def _get_season_schedule_dates(self) -> tuple[str, str]:
return "", ""
def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None:
"""Placeholder draw method - subclasses should override."""
# This base method will be simple, subclasses provide specifics
try:
img = Image.new('RGB', (self.display_width, self.display_height), (0, 0, 0))
draw = ImageDraw.Draw(img)
status = game.get("status_text", "N/A")
self._draw_text_with_outline(draw, status, (2, 2), self.fonts['status'])
self.display_manager.image.paste(img, (0, 0))
# Don't call update_display here, let subclasses handle it after drawing
except Exception as e:
self.logger.error(f"Error in base _draw_scorebug_layout: {e}", exc_info=True)
def display(self, force_clear: bool = False) -> bool:
"""Common display method for all NCAA FB managers""" # Updated docstring
if not self.is_enabled: # Check if module is enabled
return False
if not self.current_game:
# Clear display if force_clear is True, even when there's no content
# This prevents black screens when switching to modes with no content
if force_clear:
try:
self.display_manager.clear()
self.display_manager.update_display()
except Exception as e:
self.logger.debug(f"Error clearing display when no content: {e}")
current_time = time.time()
if not hasattr(self, '_last_warning_time'):
self._last_warning_time = 0
if current_time - getattr(self, '_last_warning_time', 0) > 300:
self.logger.warning(f"No game data available to display in {self.__class__.__name__}")
setattr(self, '_last_warning_time', current_time)
return False
try:
self._draw_scorebug_layout(self.current_game, force_clear)
# display_manager.update_display() should be called within subclass draw methods
# or after calling display() in the main loop. Let's keep it out of the base display.
return True
except Exception as e:
self.logger.error(f"Error during display call in {self.__class__.__name__}: {e}", exc_info=True)
return False
def _load_fonts(self):
"""Load fonts used by the scoreboard."""
fonts = {}
try:
fonts['score'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10)
fonts['time'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
fonts['team'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
fonts['status'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) # Using 4x6 for status
fonts['detail'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) # Added detail font
fonts['rank'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10)
logging.info("Successfully loaded fonts") # Changed log prefix
except IOError:
logging.warning("Fonts not found, using default PIL font.") # Changed log prefix
fonts['score'] = ImageFont.load_default()
fonts['time'] = ImageFont.load_default()
fonts['team'] = ImageFont.load_default()
fonts['status'] = ImageFont.load_default()
fonts['detail'] = ImageFont.load_default()
fonts['rank'] = ImageFont.load_default()
return fonts
def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any], width: int, height: int) -> None:
"""Draw odds with dynamic positioning - only show negative spread and position O/U based on favored team."""
home_team_odds = odds.get('home_team_odds', {})
away_team_odds = odds.get('away_team_odds', {})
home_spread = home_team_odds.get('spread_odds')
away_spread = away_team_odds.get('spread_odds')
# Get top-level spread as fallback
top_level_spread = odds.get('spread')
# If we have a top-level spread and the individual spreads are None or 0, use the top-level
if top_level_spread is not None:
if home_spread is None or home_spread == 0.0:
home_spread = top_level_spread
if away_spread is None:
away_spread = -top_level_spread
# Determine which team is favored (has negative spread)
home_favored = home_spread is not None and home_spread < 0
away_favored = away_spread is not None and away_spread < 0
# Only show the negative spread (favored team)
favored_spread = None
favored_side = None
if home_favored:
favored_spread = home_spread
favored_side = 'home'
self.logger.debug(f"Home team favored with spread: {favored_spread}")
elif away_favored:
favored_spread = away_spread
favored_side = 'away'
self.logger.debug(f"Away team favored with spread: {favored_spread}")
else:
self.logger.debug("No clear favorite - spreads: home={home_spread}, away={away_spread}")
# Show the negative spread on the appropriate side
if favored_spread is not None:
spread_text = str(favored_spread)
font = self.fonts['detail'] # Use detail font for odds
if favored_side == 'home':
# Home team is favored, show spread on right side
spread_width = draw.textlength(spread_text, font=font)
spread_x = width - spread_width # Top right
spread_y = 0
self._draw_text_with_outline(draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0))
self.logger.debug(f"Showing home spread '{spread_text}' on right side")
else:
# Away team is favored, show spread on left side
spread_x = 0 # Top left
spread_y = 0
self._draw_text_with_outline(draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0))
self.logger.debug(f"Showing away spread '{spread_text}' on left side")
# Show over/under on the opposite side of the favored team
over_under = odds.get('over_under')
if over_under is not None:
ou_text = f"O/U: {over_under}"
font = self.fonts['detail'] # Use detail font for odds
ou_width = draw.textlength(ou_text, font=font)
if favored_side == 'home':
# Home team is favored, show O/U on left side (opposite of spread)
ou_x = 0 # Top left
ou_y = 0
self.logger.debug(f"Showing O/U '{ou_text}' on left side (home favored)")
elif favored_side == 'away':
# Away team is favored, show O/U on right side (opposite of spread)
ou_x = width - ou_width # Top right
ou_y = 0
self.logger.debug(f"Showing O/U '{ou_text}' on right side (away favored)")
else:
# No clear favorite, show O/U in center
ou_x = (width - ou_width) // 2
ou_y = 0
self.logger.debug(f"Showing O/U '{ou_text}' in center (no clear favorite)")
self._draw_text_with_outline(draw, ou_text, (ou_x, ou_y), font, fill=(0, 255, 0))
def _draw_text_with_outline(self, draw, text, position, font, fill=(255, 255, 255), outline_color=(0, 0, 0)):
"""Draw text with a black outline for better readability."""
x, y = position
for dx, dy in [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]:
draw.text((x + dx, y + dy), text, font=font, fill=outline_color)
draw.text((x, y), text, font=font, fill=fill)
def _load_and_resize_logo(self, team_id: str, team_abbrev: str, logo_path: Path, logo_url: str | None ) -> Optional[Image.Image]:
"""Load and resize a team logo, with caching and automatic download if missing."""
self.logger.debug(f"Logo path: {logo_path}")
if team_abbrev in self._logo_cache:
self.logger.debug(f"Using cached logo for {team_abbrev}")
return self._logo_cache[team_abbrev]
try:
# Try different filename variations first (for cases like TA&M vs TAANDM)
actual_logo_path = None
filename_variations = LogoDownloader.get_logo_filename_variations(team_abbrev)
for filename in filename_variations:
test_path = logo_path.parent / filename
if test_path.exists():
actual_logo_path = test_path
self.logger.debug(f"Found logo at alternative path: {actual_logo_path}")
break
# If no variation found, try to download missing logo
if not actual_logo_path and not logo_path.exists():
self.logger.info(f"Logo not found for {team_abbrev} at {logo_path}. Attempting to download.")
# Try to download the logo from ESPN API (this will create placeholder if download fails)
download_missing_logo(self.sport_key, team_id, team_abbrev, logo_path, logo_url)
actual_logo_path = logo_path
# Use the original path if no alternative was found
if not actual_logo_path:
actual_logo_path = logo_path
# Only try to open the logo if the file exists
if os.path.exists(actual_logo_path):
logo = Image.open(actual_logo_path)
else:
self.logger.error(f"Logo file still doesn't exist at {actual_logo_path} after download attempt")
return None
if logo.mode != 'RGBA':
logo = logo.convert('RGBA')
max_width = int(self.display_width * 1.5)
max_height = int(self.display_height * 1.5)
logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)
self._logo_cache[team_abbrev] = logo
return logo
except Exception as e:
self.logger.error(f"Error loading logo for {team_abbrev}: {e}", exc_info=True)
return None
def _fetch_odds(self, game: Dict) -> None:
"""Fetch odds for a specific game using the new architecture."""
try:
if not self.show_odds:
return
if not self.odds_manager:
return
# Determine update interval based on game state
is_live = game.get('is_live', False)
update_interval = self.mode_config.get("live_odds_update_interval", 60) if is_live \
else self.mode_config.get("odds_update_interval", 3600)
# Fetch odds using OddsManager
odds_data = self.odds_manager.get_odds(
sport=self.sport,
league=self.league,
event_id=game['id'],
update_interval_seconds=update_interval,
)
if odds_data:
game['odds'] = odds_data
self.logger.debug(f"Successfully fetched and attached odds for game {game['id']}")
else:
self.logger.debug(f"No odds data returned for game {game['id']}")
except Exception as e:
self.logger.error(f"Error fetching odds for game {game.get('id', 'N/A')}: {e}")
def _get_timezone(self):
try:
timezone_str = self.config.get('timezone', 'UTC')
return pytz.timezone(timezone_str)
except pytz.UnknownTimeZoneError:
return pytz.utc
def _should_log(self, warning_type: str, cooldown: int = 60) -> bool:
"""Check if we should log a warning based on cooldown period."""
current_time = time.time()
if current_time - self._last_warning_time > cooldown:
self._last_warning_time = current_time
return True
return False
def _fetch_team_rankings(self) -> Dict[str, int]:
"""Fetch team rankings using the new architecture components."""
current_time = time.time()
# Check if we have cached rankings that are still valid
if (self._team_rankings_cache and
current_time - self._rankings_cache_timestamp < self._rankings_cache_duration):
return self._team_rankings_cache
try:
data = self.data_source.fetch_standings(self.sport, self.league)
rankings = {}
rankings_data = data.get('rankings', [])
if rankings_data:
# Use the first ranking (usually AP Top 25)
first_ranking = rankings_data[0]
teams = first_ranking.get('ranks', [])
for team_data in teams:
team_info = team_data.get('team', {})
team_abbr = team_info.get('abbreviation', '')
current_rank = team_data.get('current', 0)
if team_abbr and current_rank > 0:
rankings[team_abbr] = current_rank
# Cache the results
self._team_rankings_cache = rankings
self._rankings_cache_timestamp = current_time
self.logger.debug(f"Fetched rankings for {len(rankings)} teams")
return rankings
except Exception as e:
self.logger.error(f"Error fetching team rankings: {e}")
return {}
def _extract_game_details_common(self, game_event: Dict) -> tuple[Dict | None, Dict | None, Dict | None, Dict | None, Dict | None]:
if not game_event:
return None, None, None, None, None
try:
competition = game_event["competitions"][0]
status = competition["status"]
competitors = competition["competitors"]
game_date_str = game_event["date"]
situation = competition.get("situation")
start_time_utc = None
try:
# Parse the datetime string
if game_date_str.endswith('Z'):
game_date_str = game_date_str.replace('Z', '+00:00')
dt = datetime.fromisoformat(game_date_str)
# Ensure the datetime is UTC-aware (fromisoformat may create timezone-aware but not pytz.UTC)
if dt.tzinfo is None:
# If naive, assume it's UTC
start_time_utc = dt.replace(tzinfo=pytz.UTC)
else:
# Convert to pytz.UTC for consistency
start_time_utc = dt.astimezone(pytz.UTC)
except ValueError:
logging.warning(f"Could not parse game date: {game_date_str}")
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:
self.logger.warning(f"Could not find home or away team in event: {game_event.get('id')}")
return None, None, None, None, None
try:
home_abbr = home_team["team"]["abbreviation"]
except KeyError:
home_abbr = home_team["team"]["name"][:3]
try:
away_abbr = away_team["team"]["abbreviation"]
except KeyError:
away_abbr = away_team["team"]["name"][:3]
# Check if this is a favorite team game BEFORE doing expensive logging
is_favorite_game = (home_abbr in self.favorite_teams or away_abbr in self.favorite_teams)
# Only log debug info for favorite team games
if is_favorite_game:
self.logger.debug(f"Processing favorite team game: {game_event.get('id')}")
self.logger.debug(f"Found teams: {away_abbr}@{home_abbr}, Status: {status['type']['name']}, State: {status['type']['state']}")
game_time, game_date = "", ""
if start_time_utc:
local_time = start_time_utc.astimezone(self._get_timezone())
game_time = local_time.strftime("%I:%M%p").lstrip('0')
# Check date format from config
use_short_date_format = self.config.get('display', {}).get('use_short_date_format', False)
if use_short_date_format:
game_date = local_time.strftime("%-m/%-d")
else:
game_date = self.display_manager.format_date_with_ordinal(local_time)
home_record = home_team.get('records', [{}])[0].get('summary', '') if home_team.get('records') else ''
away_record = away_team.get('records', [{}])[0].get('summary', '') if away_team.get('records') else ''
# Don't show "0-0" records - set to blank instead
if home_record in {"0-0", "0-0-0"}:
home_record = ''
if away_record in {"0-0", "0-0-0"}:
away_record = ''
details = {
"id": game_event.get("id"),
"game_time": game_time,
"game_date": game_date,
"start_time_utc": start_time_utc,
"status_text": status["type"]["shortDetail"], # e.g., "Final", "7:30 PM", "Q1 12:34"
"is_live": status["type"]["state"] == "in",
"is_final": status["type"]["state"] == "post",
"is_upcoming": (status["type"]["state"] == "pre" or
status["type"]["name"].lower() in ['scheduled', 'pre-game', 'status_scheduled']),
"is_halftime": status["type"]["state"] == "halftime" or status["type"]["name"] == "STATUS_HALFTIME", # Added halftime check
"is_period_break": status["type"]["name"] == "STATUS_END_PERIOD", # Added Period Break check
"home_abbr": home_abbr,
"home_id": home_team["id"],
"home_score": home_team.get("score", "0"),
"home_logo_path": self.logo_dir / Path(f"{LogoDownloader.normalize_abbreviation(home_abbr)}.png"),
"home_logo_url": home_team["team"].get("logo"),
"home_record": home_record,
"away_record": away_record,
"away_abbr": away_abbr,
"away_id": away_team["id"],
"away_score": away_team.get("score", "0"),
"away_logo_path": self.logo_dir / Path(f"{LogoDownloader.normalize_abbreviation(away_abbr)}.png"),
"away_logo_url": away_team["team"].get("logo"),
"is_within_window": True, # Whether game is within display window
}
return details, home_team, away_team, status, situation
except Exception as e:
# Log the problematic event structure if possible
logging.error(f"Error extracting game details: {e} from event: {game_event.get('id')}", exc_info=True)
return None, None, None, None, None
@abstractmethod
def _extract_game_details(self, game_event: dict) -> dict | None:
details, _, _, _, _ = self._extract_game_details_common(game_event)
return details
@abstractmethod
def _fetch_data(self) -> Optional[Dict]:
pass
def _fetch_todays_games(self) -> Optional[Dict]:
"""Fetch only today's games for live updates (not entire season)."""
try:
tz = pytz.timezone("America/New_York") # Use full name (not "EST") for DST support
now = datetime.now(tz)
yesterday = now - timedelta(days=1)
formatted_date = now.strftime("%Y%m%d")
formatted_date_yesterday = yesterday.strftime("%Y%m%d")
# Fetch todays games only
url = f"https://site.api.espn.com/apis/site/v2/sports/{self.sport}/{self.league}/scoreboard"
response = self.session.get(url, params={"dates": f"{formatted_date_yesterday}-{formatted_date}", "limit": 1000}, headers=self.headers, timeout=10)
response.raise_for_status()
data = response.json()
events = data.get('events', [])
self.logger.info(f"Fetched {len(events)} todays games for {self.sport} - {self.league}")
return {'events': events}
except requests.exceptions.RequestException as e:
self.logger.error(f"API error fetching todays games for {self.sport} - {self.league}: {e}")
return None
def _get_weeks_data(self) -> Optional[Dict]:
"""
Get partial data for immediate display while background fetch is in progress.
This fetches current/recent games only for quick response.
"""
try:
# Fetch current week and next few days for immediate display
now = datetime.now(pytz.utc)
immediate_events = []
start_date = now + timedelta(weeks=-2)
end_date = now + timedelta(weeks=1)
date_str = f"{start_date.strftime('%Y%m%d')}-{end_date.strftime('%Y%m%d')}"
url = f"https://site.api.espn.com/apis/site/v2/sports/{self.sport}/{self.league}/scoreboard"
response = self.session.get(url, params={"dates": date_str, "limit": 1000},headers=self.headers, timeout=10)
response.raise_for_status()
data = response.json()
immediate_events = data.get('events', [])
if immediate_events:
self.logger.info(f"Fetched {len(immediate_events)} events {date_str}")
return {'events': immediate_events}
except requests.exceptions.RequestException as e:
self.logger.warning(f"Error fetching this weeks games for {self.sport} - {self.league} - {date_str}: {e}")
return None
def _custom_scorebug_layout(self, game: dict, draw_overlay: ImageDraw.ImageDraw):
pass
class SportsUpcoming(SportsCore):
SKIN_MODE = "upcoming"
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str):
super().__init__(config, display_manager, cache_manager, logger, sport_key)
self.upcoming_games = [] # Store all fetched upcoming games initially
@@ -660,6 +34,71 @@ class SportsUpcoming(SportsCore):
self.last_game_switch = 0
self.game_display_duration = 15 # Display each upcoming game for 15 seconds
def _select_games_for_display(
self, processed_games: List[Dict], favorite_teams: List[str]
) -> List[Dict]:
"""
Single-pass game selection with proper deduplication and counting.
When a game involves two favorite teams, it counts toward BOTH teams' limits.
This prevents unexpected game counts from the multi-pass algorithm.
Team identity goes through the ``_favorite_key`` override point rather
than reading ``home_abbr``/``away_abbr`` directly, because abbreviations
are not unique in every league (NRL matches on team ID instead).
"""
sorted_games = sorted(
processed_games,
key=lambda g: g.get("start_time_utc")
or datetime.max.replace(tzinfo=timezone.utc),
)
if not favorite_teams:
return sorted_games
selected_games = []
selected_ids = set()
team_counts = {team: 0 for team in favorite_teams}
for game in sorted_games:
game_id = game.get("id")
if game_id in selected_ids:
continue
home = self._favorite_key(game, "home")
away = self._favorite_key(game, "away")
home_fav = home in favorite_teams
away_fav = away in favorite_teams
if not home_fav and not away_fav:
continue
home_needs = home_fav and team_counts[home] < self.upcoming_games_to_show
away_needs = away_fav and team_counts[away] < self.upcoming_games_to_show
if home_needs or away_needs:
selected_games.append(game)
selected_ids.add(game_id)
if home_fav:
team_counts[home] += 1
if away_fav:
team_counts[away] += 1
self.logger.debug(
f"Selected game {away}@{home}: team_counts={team_counts}"
)
if all(c >= self.upcoming_games_to_show for c in team_counts.values()):
self.logger.debug("All favorite teams satisfied, stopping selection")
break
self.logger.info(
f"Selected {len(selected_games)} games for {len(favorite_teams)} "
f"favorite teams: {team_counts}"
)
return selected_games
def update(self):
"""Update upcoming games data."""
if not self.is_enabled: return
@@ -973,7 +412,7 @@ class SportsUpcoming(SportsCore):
self.logger.debug(f"Switched to game index {self.current_game_index}")
if self.current_game:
self._draw_scorebug_layout(self.current_game, force_clear)
self._render_game(self.current_game, force_clear)
return True
# update_display() is called within _draw_scorebug_layout for upcoming
return False
@@ -984,6 +423,7 @@ class SportsUpcoming(SportsCore):
class SportsRecent(SportsCore):
SKIN_MODE = "recent"
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str):
super().__init__(config, display_manager, cache_manager, logger, sport_key)
@@ -994,6 +434,96 @@ class SportsRecent(SportsCore):
self.update_interval = self.mode_config.get("recent_update_interval", 3600) # Check for recent games every hour
self.last_game_switch = 0
self.game_display_duration = 15 # Display each recent game for 15 seconds
# Tracks when each game was first seen with an expired clock, keyed by
# game id. Promoted alongside the zero-clock helpers below; without it
# the first _get_zero_clock_duration() call raises AttributeError.
self._zero_clock_timestamps: Dict[str, float] = {} # Track games at 0:00
# -- Zero-clock tracking ------------------------------------------------
# Byte-identical in all nine plugin copies. Note that afl/nrl/soccer define
# these but never call them — their clocks count up, so 0:00 means kickoff
# rather than expiry (see CLOCK_COUNTS_DOWN on SportsLive). That makes the
# pair a future `CountdownClockMixin` candidate so it stops appearing in the
# MRO of sports that cannot use it — B2 work, not now.
def _get_zero_clock_duration(self, game_id: str) -> float:
"""Track how long a game has been at 0:00 clock."""
current_time = time.time()
if game_id not in self._zero_clock_timestamps:
self._zero_clock_timestamps[game_id] = current_time
return 0.0
return current_time - self._zero_clock_timestamps[game_id]
def _clear_zero_clock_tracking(self, game_id: str) -> None:
"""Clear tracking when game clock moves away from 0:00 or game ends."""
if game_id in self._zero_clock_timestamps:
del self._zero_clock_timestamps[game_id]
def _select_recent_games_for_display(
self, processed_games: List[Dict], favorite_teams: List[str]
) -> List[Dict]:
"""
Single-pass game selection for recent games with proper deduplication.
When a game involves two favorite teams, it counts toward BOTH teams' limits.
Games are sorted by most recent first.
Team identity goes through the ``_favorite_key`` override point rather
than reading ``home_abbr``/``away_abbr`` directly, because abbreviations
are not unique in every league (NRL matches on team ID instead).
"""
sorted_games = sorted(
processed_games,
key=lambda g: g.get("start_time_utc")
or datetime.min.replace(tzinfo=timezone.utc),
reverse=True,
)
if not favorite_teams:
return sorted_games
selected_games = []
selected_ids = set()
team_counts = {team: 0 for team in favorite_teams}
for game in sorted_games:
game_id = game.get("id")
if game_id in selected_ids:
continue
home = self._favorite_key(game, "home")
away = self._favorite_key(game, "away")
home_fav = home in favorite_teams
away_fav = away in favorite_teams
if not home_fav and not away_fav:
continue
home_needs = home_fav and team_counts[home] < self.recent_games_to_show
away_needs = away_fav and team_counts[away] < self.recent_games_to_show
if home_needs or away_needs:
selected_games.append(game)
selected_ids.add(game_id)
if home_fav:
team_counts[home] += 1
if away_fav:
team_counts[away] += 1
self.logger.debug(
f"Selected recent game {away}@{home}: team_counts={team_counts}"
)
if all(c >= self.recent_games_to_show for c in team_counts.values()):
self.logger.debug("All favorite teams satisfied, stopping selection")
break
self.logger.info(
f"Selected {len(selected_games)} recent games for {len(favorite_teams)} "
f"favorite teams: {team_counts}"
)
return selected_games
def update(self):
"""Update recent games data."""
@@ -1274,7 +804,7 @@ class SportsRecent(SportsCore):
self.logger.debug(f"Switched to game index {self.current_game_index}")
if self.current_game:
self._draw_scorebug_layout(self.current_game, force_clear)
self._render_game(self.current_game, force_clear)
return True
# update_display() is called within _draw_scorebug_layout for recent
return False
@@ -1284,6 +814,17 @@ class SportsRecent(SportsCore):
return False
class SportsLive(SportsCore):
# Per-sport constants for the "is this live game actually over?" check.
# These are values, not behavior, so they are class attributes rather than
# override points (see docs/SPORTS_UNIFICATION.md "Override points").
#
# FINAL_PERIOD: the period at/after which an expired clock can mean "over".
# 4 for four-quarter sports; hockey overrides to 3.
# CLOCK_COUNTS_DOWN: whether "0:00" means the clock expired. False for
# sports whose clock counts up (soccer/afl/nrl), where 0:00 is kickoff —
# running the expiry branch there would evict games that just started.
FINAL_PERIOD = 4
CLOCK_COUNTS_DOWN = True
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str):
super().__init__(config, display_manager, cache_manager, logger, sport_key)
@@ -1301,11 +842,117 @@ class SportsLive(SportsCore):
self.count_log_interval = 5 # Only log count data every 5 seconds
# Initialize test_mode - defaults to False (live mode)
self.test_mode = self.mode_config.get("test_mode", False)
# Freshness bookkeeping for _detect_stale_games(). The base class only
# *reads* this map; a subclass's update() stamps entries as it ingests a
# feed: {game_id: {"clock": ts, "score": ts, "last_seen": ts}}.
# Until a subclass writes "last_seen", the staleness branch of
# _detect_stale_games is inert and only the game-over check applies.
self.game_update_timestamps = {}
self.stale_game_timeout = self.mode_config.get("stale_game_timeout", 300) # 5 minutes default
@abstractmethod
def _test_mode_update(self) -> None:
return
def _is_game_really_over(self, game: Dict) -> bool:
"""Check if a game appears to be over even if API says it's live.
Two independent signals:
1. ``period_text`` says "final" universal across every sport.
2. The clock has expired at/after :attr:`FINAL_PERIOD` only meaningful
where :attr:`CLOCK_COUNTS_DOWN` is true.
Fails *safe*: anything ambiguous returns False and the game keeps being
displayed. The only caller, :meth:`_detect_stale_games`, removes games
on a True, so a false positive silently drops a live game.
"""
game_str = f"{game.get('away_abbr')}@{game.get('home_abbr')}"
# `period_text` may be present-but-None; `or ""` keeps that from raising
# AttributeError — the caller has no try/except around this call.
period_text = (game.get("period_text") or "").lower()
if "final" in period_text:
self.logger.debug(
f"_is_game_really_over({game_str}): "
f"returning True - 'final' in period_text='{period_text}'"
)
return True
if not self.CLOCK_COUNTS_DOWN:
# Count-up clock: 0:00 means the match has not started.
self.logger.debug(
f"_is_game_really_over({game_str}): returning False "
f"(count-up clock, period_text='{period_text}')"
)
return False
raw_clock = game.get("clock")
# `or 0` rather than a get() default: feeds routinely send an explicit
# null period, and `None >= FINAL_PERIOD` raises TypeError — which would
# take down the whole live-update pass, since the only caller
# (_detect_stale_games) has no try/except around it.
period = game.get("period") or 0
# Only check clock-based finish if we have a valid clock string. A
# missing or non-string clock is NOT coerced to "0:00": sports without a
# game clock (e.g. baseball, where `period` is the inning) would
# otherwise be declared over from the FINAL_PERIOD-th period onward.
if isinstance(raw_clock, str) and raw_clock.strip() and period >= self.FINAL_PERIOD:
clock = raw_clock
# Compare numerically rather than against a literal set: feeds spell
# an expired clock "0:00", ":00" and "00:00" depending on sport, and
# a membership test silently misses every spelling not listed.
clock_normalized = clock.replace(":", "").strip()
if clock_normalized.isdigit() and int(clock_normalized) == 0:
self.logger.debug(
f"_is_game_really_over({game_str}): "
f"returning True - clock at 0:00 (clock='{clock}', period={period})"
)
return True
self.logger.debug(
f"_is_game_really_over({game_str}): returning False"
)
return False
def _detect_stale_games(self, games: List[Dict]) -> None:
"""Remove games that appear stale or haven't updated.
Mutates ``games`` **in place** and returns None. Removal is by value
(``list.remove`` uses ``dict.__eq__``), so two structurally-equal game
dicts in the same list would drop the first occurrence.
"""
current_time = time.time()
for game in games[:]: # Copy list to iterate safely
game_id = game.get("id")
if not game_id:
continue
# Check if game data is stale
timestamps = self.game_update_timestamps.get(game_id, {})
last_seen = timestamps.get("last_seen", 0)
if last_seen > 0 and current_time - last_seen > self.stale_game_timeout:
self.logger.warning(
f"Removing stale game {game.get('away_abbr')}@{game.get('home_abbr')} "
f"(last seen {int(current_time - last_seen)}s ago)"
)
games.remove(game)
if game_id in self.game_update_timestamps:
del self.game_update_timestamps[game_id]
continue
# Also check if game appears to be over
if self._is_game_really_over(game):
self.logger.debug(
f"Removing game that appears over: {game.get('away_abbr')}@{game.get('home_abbr')} "
f"(clock={game.get('clock')}, period={game.get('period')}, period_text={game.get('period_text')})"
)
games.remove(game)
if game_id in self.game_update_timestamps:
del self.game_update_timestamps[game_id]
def update(self):
"""Update live game data and handle game switching."""
if not self.is_enabled:
+203 -12
View File
@@ -110,20 +110,30 @@ class ScrollHelper:
self.is_scrolling = False
self.scroll_complete = False
def create_scrolling_image(self, content_items: list,
def create_scrolling_image(self, content_items: list,
item_gap: int = 32,
element_gap: int = 16) -> Image.Image:
element_gap: int = 16,
lead_gap: Optional[int] = None) -> Image.Image:
"""
Create a wide image containing all content items for scrolling.
Args:
content_items: List of PIL Images to include in scroll
item_gap: Gap between different items
element_gap: Gap between elements within an item
lead_gap: Blank columns before the first item. Defaults to a full
display width, which makes a standalone ticker scroll in from
off-screen. Callers that loop many plugins back-to-back (Vegas
mode) pass a smaller value, since a full display width of black
reads as the panel being switched off at the start of every
cycle.
Returns:
PIL Image containing all content arranged horizontally
"""
if lead_gap is None:
lead_gap = self.display_width
lead_gap = max(0, int(lead_gap))
if not content_items:
# Create empty image if no content
# Still set total_scroll_width to 0 to indicate no scrollable content
@@ -144,13 +154,13 @@ class ScrollHelper:
total_width += element_gap * len(content_items)
# Add initial gap before first item
total_width += self.display_width
total_width += lead_gap
# Create the full scrolling image
full_image = Image.new('RGB', (total_width, self.display_height), (0, 0, 0))
# Position items
current_x = self.display_width # Start with initial gap
current_x = lead_gap # Start with initial gap
for i, img in enumerate(content_items):
# Paste the item image
@@ -338,13 +348,72 @@ class ScrollHelper:
"""
if not self.cached_image or self.cached_array is None:
return None
# Use integer pixel positioning for high FPS scrolling (like stock ticker)
start_x_int = int(self.scroll_position)
end_x_int = start_x_int + self.display_width
# Fast integer pixel path (no interpolation - high frame rate provides smoothness)
# Integer positioning quantises motion to whole pixels, so the number of
# distinct frames per second equals the scroll speed in px/s, no matter
# how fast the loop renders. At 50px/s and 78fps that made 36% of frames
# identical: the extra frames cost work and bought nothing. Blending
# between the two neighbouring positions gives motion at the frame rate
# instead of the step rate.
if self.sub_pixel_scrolling:
fractional = self.scroll_position - start_x_int
if fractional > 0.0:
return self._blend_visible_portion(start_x_int, fractional)
return self._get_visible_portion_integer(start_x_int, end_x_int)
def _blend_visible_portion(self, start_x: int, fractional: float) -> Image.Image:
"""
Linear blend between the frames at ``start_x`` and ``start_x + 1``.
Implemented with numpy rather than scipy.ndimage.shift: scipy is not
installed on the target devices (HAS_SCIPY is False there), which is why
the pre-existing sub-pixel path was dead code — get_visible_portion never
consulted the flag, and the scipy fallback would not have interpolated
anyway.
Args:
start_x: Left column of the earlier of the two frames
fractional: How far between the two, in [0, 1)
Returns:
The blended frame
"""
width = self.display_width
strip_width = self.cached_array.shape[1]
if start_x + width + 1 <= strip_width:
# Slice the backing array directly. Going via
# _get_visible_portion_integer would build two PIL images only for
# them to be converted straight back to arrays, which measured 15x
# the cost of the integer path.
near = self.cached_array[:, start_x:start_x + width]
far = self.cached_array[:, start_x + 1:start_x + 1 + width]
else:
# Close enough to the end that one of the slices wraps; let the
# integer path handle that and pay the conversion. Continuous mode
# extends the strip before reaching here, so this is the rare case.
near = np.asarray(
self._get_visible_portion_integer(start_x, start_x + width))
far = np.asarray(
self._get_visible_portion_integer(start_x + 1, start_x + 1 + width))
# Fixed-point rather than float32: integer multiply-add on uint16 is
# markedly faster than float maths on the Pi's ARM cores, and 8 bits of
# weight is finer than the panel can show.
weight = int(fractional * 256.0)
blended = (
(near.astype(np.uint16) * (256 - weight)
+ far.astype(np.uint16) * weight) >> 8
).astype(np.uint8)
return Image.frombytes(
'RGB', (width, self.display_height),
np.ascontiguousarray(blended).tobytes()
)
def _get_visible_portion_integer(self, start_x: int, end_x: int) -> Image.Image:
"""Fast integer pixel extraction (no interpolation).
@@ -638,6 +707,128 @@ class ScrollHelper:
"""
return self.scroll_complete
def append_content(self, content_items: list,
item_gap: int = 32,
element_gap: int = 0) -> bool:
"""
Append items to the right of the existing strip, preserving scroll state.
Lets a caller keep one continuous strip instead of replacing it. Vegas
mode uses this so the next group of plugins scrolls in from the right
rather than the strip being swapped out underneath the viewer — a swap
shows as a flash and a hard cut to already-full-screen content.
``scroll_position`` and ``total_distance_scrolled`` are untouched, so
motion continues uninterrupted; only the strip gets longer. Because
completion is measured against ``total_scroll_width``, extending the
strip also defers completion, which is the intent.
Args:
content_items: Images to append, in order
item_gap: Gap between appended items, and between the existing
content and the first appended item
element_gap: Extra gap after each item, mirroring
create_scrolling_image
Returns:
True if content was appended
"""
if not content_items:
return False
if self.cached_image is None or self.cached_array is None:
# Nothing to extend yet — this is just the first build.
self.create_scrolling_image(
content_items, item_gap=item_gap, element_gap=element_gap, lead_gap=0)
return True
gap = max(0, item_gap)
addition_width = (
sum(img.width for img in content_items)
+ gap * len(content_items) # one leading gap per item
+ element_gap * len(content_items)
)
addition = Image.new('RGB', (addition_width, self.display_height), (0, 0, 0))
x = 0
for img in content_items:
x += gap # separate from whatever precedes
addition.paste(img, (x, 0))
x += img.width + element_gap
# numpy concatenate then one conversion back, rather than allocating a
# full-width PIL image and pasting twice: the strip can be tens of
# thousands of columns wide and this runs on the render path.
self.cached_array = np.concatenate(
(self.cached_array, np.array(addition)), axis=1)
self.cached_image = Image.fromarray(self.cached_array)
self.total_scroll_width = self.cached_image.width
self.scroll_complete = False
self.logger.info(
"Appended %d item(s) (%dpx) to scroll strip: now %dpx, position %.0f",
len(content_items), addition_width, self.total_scroll_width,
self.scroll_position
)
return True
def drop_scrolled_prefix(self, keep_before: int = 0) -> int:
"""
Discard columns that have already scrolled past, to bound memory.
A continuously extended strip would otherwise grow without limit. All
the positional state is shifted by the amount removed so the visible
frame and the completion arithmetic are unchanged:
``total_distance_scrolled`` and ``total_scroll_width`` both shrink by the
same amount, preserving their difference.
Args:
keep_before: Columns to retain behind the current position, as a
safety margin against a caller reading slightly behind it
Returns:
Number of columns actually removed
"""
if self.cached_image is None or self.cached_array is None:
return 0
# While the viewport wraps, get_visible_portion fills its right-hand side
# from the *head* of the strip, so trimming the head would change what
# is on screen. Continuous mode extends before ever reaching that state;
# refusing here keeps "trimming is invisible" true unconditionally.
if self.scroll_position + self.display_width > self.cached_image.width:
return 0
cut = int(self.scroll_position) - max(0, keep_before)
if cut <= 0:
return 0
# Never trim so far that the remaining strip is narrower than the
# viewport, or get_visible_portion has nothing to slice.
cut = min(cut, max(0, self.cached_image.width - self.display_width))
if cut <= 0:
return 0
# .copy() so the original buffer is released rather than kept alive by
# a numpy view.
self.cached_array = self.cached_array[:, cut:].copy()
self.cached_image = Image.fromarray(self.cached_array)
self.total_scroll_width = self.cached_image.width
self.scroll_position -= cut
self.total_distance_scrolled = max(0.0, self.total_distance_scrolled - cut)
self.logger.debug(
"Dropped %dpx of scrolled strip: now %dpx, position %.0f",
cut, self.total_scroll_width, self.scroll_position
)
return cut
def remaining_unscrolled(self) -> int:
"""Columns of strip still to the right of the viewport."""
if self.cached_image is None:
return 0
return max(0, self.total_scroll_width - int(self.scroll_position)
- self.display_width)
def reset_scroll(self) -> None:
"""
Reset scroll position to beginning.
+72 -1
View File
@@ -381,6 +381,10 @@ class DisplayController:
logger.debug("%d plugin(s) disabled in config", disabled_count)
logger.info("Plugin system initialized in %.3f seconds", time.time() - plugin_time)
# Parallel loading appends modes in load-completion order, which
# varies between restarts; apply the user's configured rotation
# order (no-op when not configured).
self._apply_plugin_rotation_order()
logger.info("Total available modes: %d", len(self.available_modes))
logger.info("Available modes: %s", self.available_modes)
@@ -1133,6 +1137,29 @@ class DisplayController:
remaining = self.on_demand_expires_at - time.time()
return max(0.0, remaining)
def _publish_current_mode_state(self) -> None:
"""Publish the currently active display mode/plugin to cache for the web UI."""
try:
state = {
'mode': self.current_display_mode,
'plugin_id': self.mode_to_plugin_id.get(self.current_display_mode),
'mode_index': self.current_mode_index,
'total_modes': len(self.available_modes),
'on_demand_active': self.on_demand_active,
'is_display_active': self.is_display_active,
'last_updated': time.time(),
}
self.cache_manager.set('display_current_state', state)
self._last_published_mode = self.current_display_mode
except (OSError, RuntimeError, ValueError, TypeError) as err:
logger.error("Failed to publish current display state: %s", err, exc_info=True)
def _publish_current_mode_state_if_changed(self) -> None:
"""Publish current mode state only when it actually changed, to avoid
writing to the shared cache on every render tick."""
if self.current_display_mode != getattr(self, '_last_published_mode', None):
self._publish_current_mode_state()
def _publish_on_demand_state(self) -> None:
"""Publish current on-demand state to cache for external consumers."""
try:
@@ -1652,6 +1679,7 @@ class DisplayController:
logger.info("Starting display with cached data (fast startup mode)")
self.current_display_mode = self.available_modes[self.current_mode_index] if self.available_modes else 'none'
logger.info(f"Initial mode set to: {self.current_display_mode} (index: {self.current_mode_index}, total modes: {len(self.available_modes)})")
self._publish_current_mode_state()
while True:
# Apply plugin enable/disable edits saved via the web UI. The
@@ -1712,9 +1740,11 @@ class DisplayController:
logger.debug(f"Error clearing display when inactive: {e}")
logger.info(f"Display not active (is_display_active={self.is_display_active}), sleeping...")
self._publish_current_mode_state()
self._sleep_with_plugin_updates(60)
continue
self._publish_current_mode_state_if_changed()
logger.debug("Display active, processing mode: %s", self.current_display_mode)
# Plugins update on their own schedules - no forced sync updates needed
@@ -2843,11 +2873,52 @@ class DisplayController:
except Exception as e:
logger.error("Plugin reconcile: error enabling %s: %s", plugin_id, e, exc_info=True)
# Newly enabled plugins were appended at the end; put them in the
# configured rotation slot before resyncing the index.
self._apply_plugin_rotation_order()
self._resync_mode_index_after_change(previous_mode)
logger.info("Plugin reconcile complete: +%s -%s (%d modes)",
logger.info("[DisplayController] Plugin reconcile complete: +%s -%s (%d modes)",
sorted(to_add), sorted(to_remove), len(self.available_modes))
return True
def _apply_plugin_rotation_order(self) -> None:
"""Reorder available_modes to follow display.plugin_rotation_order.
The configured value is a list of plugin ids; their modes rotate in
that order (each plugin's own modes keep their declared order), with
any enabled-but-unlisted plugins appended afterwards in their current
relative order. An empty/missing list leaves available_modes exactly
as built (today's behavior). Mirrors vegas_mode/config.py's
get_ordered_plugins() semantics for the primary rotation.
"""
configured = (self.config.get("display", {}) or {}).get("plugin_rotation_order", []) or []
# Defensive: hand-edited or migrated configs may hold a non-list or
# non-string entries; keep the existing rotation rather than applying
# a garbage order.
if not isinstance(configured, list):
logger.warning("[DisplayController] Ignoring invalid plugin_rotation_order (not a list): %r",
type(configured).__name__)
return
configured = [p for p in configured if isinstance(p, str)]
if not configured or not self.available_modes:
return
ordered_ids = [p for p in configured if p in self.plugin_display_modes]
new_modes: List[str] = []
for plugin_id in ordered_ids:
for mode in self.plugin_display_modes[plugin_id]:
if mode in self.available_modes and mode not in new_modes:
new_modes.append(mode)
# Unlisted plugins' modes (and any mode not attributable to a plugin)
# follow in their existing relative order.
for mode in self.available_modes:
if mode not in new_modes:
new_modes.append(mode)
if new_modes != self.available_modes:
self.available_modes = new_modes
logger.info("[DisplayController] Applied plugin rotation order %s -> modes: %s",
configured, self.available_modes)
def _resync_mode_index_after_change(self, previous_mode: Optional[str]) -> None:
"""Clamp rotation state after available_modes changed. Stays on the
previous mode if it survived, otherwise restarts cleanly within range."""
+70 -2
View File
@@ -186,8 +186,14 @@ class DisplayManager:
self.config = config or {}
self._force_fallback = force_fallback
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
# Per-thread capture state. update_display() and clear() skip hardware
# writes while the *calling* thread is capturing content off-screen.
#
# Thread-local rather than a plain flag because Vegas mode prepares
# upcoming content on a background thread: a shared flag set there would
# suppress the render loop's own frame pushes for the duration, freezing
# the panel exactly when the point was to avoid a freeze.
self._capture_state = threading.local()
# Double-sided mode state (resolved in _setup_matrix). When disabled,
# the logical image is blitted to the matrix unchanged.
self._double_sided = None # dict {copies, axis, logical_width, logical_height} or None
@@ -520,6 +526,15 @@ class DisplayManager:
except Exception as e:
logger.error(f"Error drawing test pattern: {e}", exc_info=True)
@property
def _capture_mode_active(self) -> bool:
"""True while the calling thread is capturing content off-screen."""
return getattr(self._capture_state, 'active', False)
@_capture_mode_active.setter
def _capture_mode_active(self, value: bool) -> None:
self._capture_state.active = bool(value)
@contextmanager
def capture_mode(self):
"""Suppress hardware output during off-screen content capture.
@@ -536,6 +551,59 @@ class DisplayManager:
finally:
self._capture_mode_active = False
@contextmanager
def render_size(self, width: int, height: Optional[int] = None):
"""Temporarily present a smaller logical canvas to plugins.
Plugins lay out against ``display_manager.matrix.width`` (and the
``width``/``height`` properties, which defer to it), so the only way to
get a *narrower layout* rather than a cropped one is to tell the plugin
the screen is narrower while it renders. Trimming after the fact cannot
fix a forecast spread across five columns or a progress bar drawn at
100% width those need the plugin to make different layout decisions.
Vegas mode uses this so a plugin can occupy a fraction of a wide panel
and still look deliberately composed. Reuses the same _LogicalMatrix
indirection that double-sided mode relies on, so plugins see a
consistent size from every accessor.
Only meaningful inside :meth:`capture_mode` this swaps the shared
image buffer, so the render loop must not be writing to it concurrently.
Args:
width: Logical width to report, clamped to at least 1 and to the
real panel width (a larger canvas would overflow the hardware).
height: Logical height, defaulting to the current height.
"""
real_matrix = self.matrix
prev_image = getattr(self, 'image', None)
prev_draw = getattr(self, 'draw', None)
current_w = self.width
current_h = self.height
target_w = max(1, min(int(width), current_w))
target_h = max(1, min(int(height) if height else current_h, current_h))
if target_w == current_w and target_h == current_h:
# Nothing to do; avoid pointless wrapping and buffer churn.
yield
return
try:
if real_matrix is not None:
self.matrix = _LogicalMatrix(real_matrix, target_w, target_h)
# With no hardware, the width/height properties fall through to
# self.image, so swapping the buffer below is enough on its own.
self.image = Image.new('RGB', (target_w, target_h))
self.draw = ImageDraw.Draw(self.image)
yield
finally:
self.matrix = real_matrix
if prev_image is not None:
self.image = prev_image
if prev_draw is not None:
self.draw = prev_draw
def _composite_double_sided(self):
"""Tile the logical screen across the full physical chain.
+621
View File
@@ -0,0 +1,621 @@
"""
Shared per-element style resolution for plugins (the x-style-elements system).
Plugins expose user-customizable text styling font, size, color, and x/y
pixel offsets per named element through their ``config_schema.json``. Two
declaration forms exist in the plugin ecosystem:
- The compact ``x-style-elements`` map on the ``customization`` object
(of-the-day is the reference). ``expand_style_elements()`` turns it into
the full per-element property blocks the web-UI config form renders.
- The manual ``customization`` block: hand-written per-element objects with
``font`` / ``font_size`` / ``text_color`` defaults (the scoreboards,
ledmatrix-music). No expansion needed the defaults are read as-is.
At render time a plugin builds an ``ElementStyleResolver`` from its config
and the schema-file defaults, then asks for each element's resolved style::
from src.element_style import ElementStyleResolver, defaults_from_schema_file
resolver = ElementStyleResolver(config, defaults_from_schema_file(schema_path))
title = resolver.style('title_text', classic_font='PressStart2P-Regular.ttf',
classic_size=8, classic_color=(255, 255, 255))
# title.font (PIL font / freetype.Face), title.color (RGB tuple),
# title.offset ((dx, dy)), title.user_forced, title.user_forced_color
The central subtlety is what "the user set it" means. The web UI's save flow
(``schema_manager.merge_with_defaults``) writes the FULL schema-default
object into ``config.json`` on every save, whether or not the user touched
the styling section so a value merely being *present* in config is not an
override. A value only counts as user-forced when it genuinely differs from
the schema default for that element. When nothing is forced, ``style()``
returns exactly the ``classic_*`` values the caller passes (the plugin's
pre-customization styling), so an untouched config renders byte-identically
to the classic code path. Note the classic values and the schema defaults
may legitimately differ (e.g. football's status_text: schema declares 4x6,
the classic loader fell back to PressStart) the schema default is the
override *reference*, the classic values are the *fallback*.
``style()`` never raises: any malformed config value degrades to the classic
style with a logged warning. Font faces are cached module-wide by
(resolved path, size), and font files resolve independently of the caller's
cwd (cwd ``assets/fonts/`` first for compatibility, then the core install
root derived from this module's own location).
"""
import copy
import json
import logging
import os
from dataclasses import dataclass
from typing import Any, Dict, Optional, Tuple, Union
from PIL import ImageFont
try:
import freetype
except ImportError: # pragma: no cover - freetype ships with the core
freetype = None
logger = logging.getLogger(__name__)
# Core install root (the directory that contains src/ and assets/fonts/),
# derived from this file so fonts resolve regardless of the caller's cwd.
_CORE_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_FONTS_SUBDIR = os.path.join('assets', 'fonts')
# Last-resort font when a requested file can't be found or loaded.
_FALLBACK_FONT_NAME = 'PressStart2P-Regular.ttf'
# (resolved absolute path, size) -> loaded font face. BDF faces are stateful
# in principle, but the core's own FontManager shares faces the same way.
_font_cache: Dict[Tuple[str, int], Any] = {}
# Config keys a style element block carries, in schema/UI order.
_STYLE_KEYS = ('font', 'font_size', 'text_color')
@dataclass(frozen=True)
class ElementStyle:
"""A fully resolved style for one named element."""
font: Any # PIL ImageFont or freetype.Face
color: Tuple[int, int, int] # resolved RGB
offset: Tuple[int, int] # user layout (x, y) offset, default (0, 0)
font_name: str # resolved font filename
font_size: int # resolved pixel size
user_forced: bool # font or size genuinely overridden
user_forced_color: bool # color genuinely overridden
# ---------------------------------------------------------------------------
# Font loading (cwd-independent, cached)
# ---------------------------------------------------------------------------
def resolve_font_path(font_name: str) -> Optional[str]:
"""Locate a font file by name, independent of the caller's cwd.
Tries, in order: an absolute path as given; ``assets/fonts/<name>``
relative to the cwd (the classic loaders' behavior, kept first so a
process running from a different checkout keeps its own fonts); then
``assets/fonts/<name>`` under the core install root. Returns an
absolute path, or None when the file doesn't exist anywhere.
"""
if not font_name or not isinstance(font_name, str):
return None
if os.path.isabs(font_name):
return font_name if os.path.isfile(font_name) else None
candidates = (
os.path.join(os.getcwd(), _FONTS_SUBDIR, font_name),
os.path.join(_CORE_ROOT, _FONTS_SUBDIR, font_name),
)
for candidate in candidates:
if os.path.isfile(candidate):
return os.path.abspath(candidate)
return None
def load_font(font_name: str, size: int) -> Any:
"""Load a font by filename at a pixel size, with caching and fallback.
``.bdf`` files load as ``freetype.Face`` (matching FontManager), other
files through ``PIL.ImageFont.truetype``. A missing or unloadable font
degrades to ``PressStart2P-Regular.ttf`` at the requested size, then to
PIL's built-in default — this function never raises.
"""
try:
size = max(1, int(size))
except (TypeError, ValueError):
size = 8
path = resolve_font_path(font_name)
if path is None:
logger.warning("Font file not found: %s, using fallback", font_name)
return _load_fallback_font(size)
cache_key = (path, size)
cached = _font_cache.get(cache_key)
if cached is not None:
return cached
try:
if path.lower().endswith('.bdf'):
if freetype is None:
raise RuntimeError("freetype not available for BDF fonts")
face = freetype.Face(path)
# Character size in 1/64th points at 72dpi == pixel size.
face.set_char_size(size * 64, size * 64, 72, 72)
font: Any = face
else:
font = ImageFont.truetype(path, size)
except Exception as e:
logger.warning("Error loading font %s at %spx: %s, using fallback",
path, size, e)
return _load_fallback_font(size)
_font_cache[cache_key] = font
return font
def _load_fallback_font(size: int) -> Any:
"""PressStart2P at the requested size, else PIL's built-in default."""
path = resolve_font_path(_FALLBACK_FONT_NAME)
if path is not None:
cache_key = (path, size)
cached = _font_cache.get(cache_key)
if cached is not None:
return cached
try:
font = ImageFont.truetype(path, size)
_font_cache[cache_key] = font
return font
except Exception as e:
logger.error("Error loading fallback font: %s", e)
return ImageFont.load_default()
# ---------------------------------------------------------------------------
# Schema parsing
# ---------------------------------------------------------------------------
def expand_style_elements(schema: Dict[str, Any]) -> Dict[str, Any]:
"""Expand a ``customization.x-style-elements`` declaration into the full
per-element property blocks the web-UI config form renders.
Each declared element becomes an object with ``font`` / ``font_size`` /
``text_color`` properties (only the sub-fields the declaration carries),
tagged ``x-style-managed: true``; elements declaring ``offsets: true``
additionally get an entry under ``customization.layout`` with
``x_offset`` / ``y_offset`` integers defaulting to 0. Hand-written
element blocks with the same key are left untouched.
Returns the schema unchanged (same object) when there is nothing to
expand; otherwise returns an expanded deep copy. Never raises.
"""
try:
customization = schema.get('properties', {}).get('customization')
if not isinstance(customization, dict):
return schema
declaration = customization.get('x-style-elements')
if not isinstance(declaration, dict) or not declaration:
return schema
expanded = copy.deepcopy(schema)
customization = expanded['properties']['customization']
customization.setdefault('type', 'object')
props = customization.setdefault('properties', {})
layout_props: Dict[str, Any] = {}
for element_key, spec in declaration.items():
if not isinstance(spec, dict):
continue
if element_key not in props:
props[element_key] = _element_block_from_spec(element_key, spec)
if spec.get('offsets'):
layout_props[element_key] = _offset_block_from_spec(
element_key, spec)
if layout_props:
layout = props.setdefault('layout', {
'type': 'object',
'title': 'Layout Offsets',
'description': 'Pixel offsets applied to each element '
'(positive x moves right, positive y moves down)',
'x-advanced': True,
'properties': {},
'additionalProperties': False,
})
layout.setdefault('properties', {})
for element_key, block in layout_props.items():
layout['properties'].setdefault(element_key, block)
return expanded
except Exception as e:
logger.warning("Error expanding x-style-elements: %s", e)
return schema
def _element_block_from_spec(element_key: str,
spec: Dict[str, Any]) -> Dict[str, Any]:
"""Build one expanded per-element schema block from its declaration."""
properties: Dict[str, Any] = {}
order = []
font_spec = spec.get('font')
if isinstance(font_spec, dict):
font_prop: Dict[str, Any] = {
'type': 'string',
'title': 'Font Family',
'x-advanced': True,
}
if 'default' in font_spec:
font_prop['default'] = font_spec['default']
if isinstance(font_spec.get('enum'), list):
font_prop['enum'] = list(font_spec['enum'])
properties['font'] = font_prop
order.append('font')
size_spec = spec.get('size')
if isinstance(size_spec, dict):
size_prop: Dict[str, Any] = {
'type': 'integer',
'title': 'Font Size',
'description': 'Font size in pixels',
'x-advanced': True,
}
if 'default' in size_spec:
size_prop['default'] = size_spec['default']
if 'min' in size_spec:
size_prop['minimum'] = size_spec['min']
if 'max' in size_spec:
size_prop['maximum'] = size_spec['max']
properties['font_size'] = size_prop
order.append('font_size')
color_spec = spec.get('color')
if isinstance(color_spec, dict):
color_prop: Dict[str, Any] = {
'type': 'array',
'title': 'Text Color',
'items': {'type': 'integer', 'minimum': 0, 'maximum': 255},
'minItems': 3,
'maxItems': 3,
'x-widget': 'color-picker',
}
if 'default' in color_spec:
color_prop['default'] = list(color_spec['default'])
properties['text_color'] = color_prop
order.append('text_color')
return {
'type': 'object',
'title': spec.get('title', element_key),
'x-style-managed': True,
'x-propertyOrder': order,
'additionalProperties': False,
'properties': properties,
}
def _offset_block_from_spec(element_key: str,
spec: Dict[str, Any]) -> Dict[str, Any]:
"""Build one layout.<element> offset block (x/y, default 0)."""
axis = {
'type': 'integer',
'default': 0,
'x-advanced': True,
}
return {
'type': 'object',
'title': spec.get('title', element_key),
'x-style-managed': True,
'additionalProperties': False,
'properties': {
'x_offset': dict(axis, title='X Offset'),
'y_offset': dict(axis, title='Y Offset'),
},
}
def defaults_from_schema(schema: Dict[str, Any]) -> Dict[str, Any]:
"""Extract per-element style defaults from a config schema dict.
Understands both declaration forms: the compact ``x-style-elements``
map, and hand-written per-element blocks under
``customization.properties`` (their ``font`` / ``font_size`` /
``text_color`` property defaults). Returns a config-shaped dict::
{"customization": {"<element>": {"font": ..., "font_size": ...,
"text_color": [...]}, ...}}
Elements with no declared defaults are omitted. Never raises.
"""
elements: Dict[str, Dict[str, Any]] = {}
try:
customization = schema.get('properties', {}).get('customization')
if not isinstance(customization, dict):
return {'customization': elements}
declaration = customization.get('x-style-elements')
if isinstance(declaration, dict):
for element_key, spec in declaration.items():
if not isinstance(spec, dict):
continue
defaults: Dict[str, Any] = {}
font_spec = spec.get('font')
if isinstance(font_spec, dict) and 'default' in font_spec:
defaults['font'] = font_spec['default']
size_spec = spec.get('size')
if isinstance(size_spec, dict) and 'default' in size_spec:
defaults['font_size'] = size_spec['default']
color_spec = spec.get('color')
if isinstance(color_spec, dict) and 'default' in color_spec:
defaults['text_color'] = list(color_spec['default'])
if defaults:
elements[element_key] = defaults
properties = customization.get('properties')
if isinstance(properties, dict):
for element_key, block in properties.items():
if element_key == 'layout' or element_key in elements:
continue
if not isinstance(block, dict):
continue
block_props = block.get('properties')
if not isinstance(block_props, dict):
continue
defaults = {}
for style_key in _STYLE_KEYS:
prop = block_props.get(style_key)
if isinstance(prop, dict) and 'default' in prop:
defaults[style_key] = prop['default']
if defaults:
elements[element_key] = defaults
except Exception as e:
logger.warning("Error extracting style defaults from schema: %s", e)
return {'customization': elements}
def defaults_from_schema_file(schema_path: Union[str, os.PathLike]) -> Dict[str, Any]:
"""``defaults_from_schema`` for a schema file on disk. A missing or
malformed file yields empty defaults (with a logged warning) every
configured value then counts as a user override, which is the safe
degradation. Never raises."""
try:
with open(schema_path, 'r', encoding='utf-8') as f:
schema = json.load(f)
if not isinstance(schema, dict):
raise ValueError("schema is not a JSON object")
except Exception as e:
logger.warning("Could not read style defaults from %s: %s",
schema_path, e)
return {'customization': {}}
return defaults_from_schema(schema)
# ---------------------------------------------------------------------------
# Resolver
# ---------------------------------------------------------------------------
def _normalize_color(value: Any) -> Optional[Tuple[int, int, int]]:
"""An (r, g, b) tuple of ints in 0..255, or None for anything else."""
if isinstance(value, (list, tuple)) and len(value) == 3:
try:
rgb = tuple(int(c) for c in value)
except (TypeError, ValueError):
return None
if all(0 <= c <= 255 for c in rgb):
return rgb # type: ignore[return-value]
return None
class ElementStyleResolver:
"""Resolves per-element user styling against schema defaults.
Built from a plugin's live config dict and the defaults extracted from
its own ``config_schema.json`` (``defaults_from_schema_file``). The
config dict is held by reference as ``_config`` consumers compare
identity (``resolver._config is not self.config``) to decide when a
resolver must be rebuilt after ``on_config_change`` swaps the dict.
A configured font/size/color counts as user-forced only when it differs
from the schema default (see module docstring); otherwise ``style()``
returns the caller's classic values verbatim, keeping untouched configs
byte-identical to pre-customization rendering.
"""
def __init__(self, config: Optional[Dict[str, Any]],
defaults: Optional[Dict[str, Any]] = None):
# Keep the exact object for identity-based invalidation, even if the
# caller hands us something odd; reads are guarded.
self._config = config
if isinstance(defaults, dict):
element_defaults = defaults.get('customization', {})
else:
element_defaults = {}
self._defaults: Dict[str, Any] = (
element_defaults if isinstance(element_defaults, dict) else {})
self._memo: Dict[Any, ElementStyle] = {}
# -- internal accessors -------------------------------------------------
def _customization(self) -> Dict[str, Any]:
config = self._config if isinstance(self._config, dict) else {}
customization = config.get('customization', {})
return customization if isinstance(customization, dict) else {}
def _element_config(self, element_key: str) -> Dict[str, Any]:
element = self._customization().get(element_key, {})
return element if isinstance(element, dict) else {}
def _element_defaults(self, element_key: str) -> Dict[str, Any]:
defaults = self._defaults.get(element_key, {})
return defaults if isinstance(defaults, dict) else {}
# -- public API ---------------------------------------------------------
def style(self, element_key: str,
classic_font: str = _FALLBACK_FONT_NAME,
classic_size: int = 8,
classic_color: Optional[Tuple[int, int, int]] = None) -> ElementStyle:
"""Resolve one element's style. Never raises.
Args:
element_key: Key under ``config['customization']`` (e.g.
``'title_text'``).
classic_font: Font filename the plugin's classic (pre-
customization) code used for this element.
classic_size: Classic pixel size.
classic_color: Classic RGB color, or None when the caller only
cares about the font (``.color`` then falls back to the
schema default color, else white).
Returns:
ElementStyle with the loaded font face, RGB color, (x, y)
offset, and the ``user_forced`` / ``user_forced_color`` flags.
"""
try:
memo_key = (element_key, classic_font, classic_size,
_normalize_color(classic_color) or classic_color)
memoized = self._memo.get(memo_key)
if memoized is not None:
return memoized
except Exception:
memo_key = None
try:
resolved = self._resolve(element_key, classic_font,
classic_size, classic_color)
except Exception as e:
logger.warning("Error resolving style for element '%s': %s"
"using classic style", element_key, e)
resolved = self._classic_style(classic_font, classic_size,
classic_color)
if memo_key is not None:
self._memo[memo_key] = resolved
return resolved
def offset(self, element_key: str) -> Tuple[int, int]:
"""The user's ``customization.layout.<element>`` (x, y) pixel
offset, defaulting to (0, 0). Never raises."""
return (self.offset_value(element_key, 'x_offset', 0),
self.offset_value(element_key, 'y_offset', 0))
def offset_value(self, element_key: str, axis: str, default: int = 0) -> int:
"""One ``customization.layout.<element>.<axis>`` value as an int.
``axis`` is usually ``'x_offset'`` / ``'y_offset'`` but any key is
honored (e.g. the scoreboards' ``'away_x_offset'``). Numeric
strings are coerced; anything else degrades to ``default``. Never
raises.
"""
try:
layout = self._customization().get('layout', {})
if not isinstance(layout, dict):
return int(default)
element = layout.get(element_key, {})
if not isinstance(element, dict):
return int(default)
value = element.get(axis, default)
if isinstance(value, bool):
return int(default)
if isinstance(value, (int, float)):
return int(value)
if isinstance(value, str):
try:
return int(float(value))
except (TypeError, ValueError):
logger.warning(
"Invalid layout offset for %s.%s: %r, using %s",
element_key, axis, value, default)
return int(default)
return int(default)
except Exception as e:
logger.warning("Error reading layout offset %s.%s: %s",
element_key, axis, e)
try:
return int(default)
except (TypeError, ValueError):
return 0
# -- resolution internals -----------------------------------------------
def _resolve(self, element_key: str, classic_font: str,
classic_size: int,
classic_color: Optional[Tuple[int, int, int]]) -> ElementStyle:
element_config = self._element_config(element_key)
element_defaults = self._element_defaults(element_key)
# Font family: forced only when it differs from the schema default
# (falling back to the classic font as the reference when the
# schema declares none).
default_font = element_defaults.get('font', classic_font)
configured_font = element_config.get('font')
font_forced = (isinstance(configured_font, str) and configured_font
and configured_font != default_font)
# Font size: same rule, with defensive int coercion.
default_size = self._coerce_size(
element_defaults.get('font_size'), None)
if default_size is None:
default_size = self._coerce_size(classic_size, 8)
configured_size = self._coerce_size(element_config.get('font_size'),
None)
size_forced = (configured_size is not None
and configured_size != default_size)
font_name = configured_font if font_forced else classic_font
font_size = configured_size if size_forced else self._coerce_size(
classic_size, 8)
user_forced = bool(font_forced or size_forced)
# Color: forced only when it differs from the schema default (or,
# absent one, from the classic color).
default_color = _normalize_color(element_defaults.get('text_color'))
configured_color = _normalize_color(element_config.get('text_color'))
reference_color = (default_color if default_color is not None
else _normalize_color(classic_color))
color_forced = (configured_color is not None
and configured_color != reference_color)
if color_forced:
color = configured_color
else:
color = (_normalize_color(classic_color) or default_color
or (255, 255, 255))
return ElementStyle(
font=load_font(font_name, font_size),
color=color,
offset=self.offset(element_key),
font_name=font_name,
font_size=font_size,
user_forced=user_forced,
user_forced_color=bool(color_forced),
)
def _classic_style(self, classic_font: str, classic_size: int,
classic_color: Optional[Tuple[int, int, int]]) -> ElementStyle:
"""The untouched fallback style — used when resolution itself
fails, so ``style()`` can keep its never-raises promise."""
size = self._coerce_size(classic_size, 8)
return ElementStyle(
font=load_font(classic_font, size),
color=_normalize_color(classic_color) or (255, 255, 255),
offset=(0, 0),
font_name=classic_font,
font_size=size,
user_forced=False,
user_forced_color=False,
)
@staticmethod
def _coerce_size(value: Any, default: Optional[int]) -> Optional[int]:
"""An int pixel size, or ``default`` for None/garbage."""
if value is None or isinstance(value, bool):
return default
try:
size = int(value)
except (TypeError, ValueError):
return default
return size if size > 0 else default
+21 -1
View File
@@ -659,6 +659,25 @@ class FontManager:
# ==================== Font Discovery ====================
@staticmethod
def _resolve_asset_path(relative_path: str) -> str:
"""Resolve a repo-relative asset path independently of the process cwd.
Prefers the working directory (preserving behavior when the process
runs from the install root), then falls back to the install root
derived from this module's own location. Without the fallback, any
process started outside the install root (e.g. the plugin safety
harness on CI) silently loses every font and degrades to PIL's
default face.
"""
if os.path.exists(relative_path):
return relative_path
install_root = Path(__file__).resolve().parent.parent
candidate = install_root / relative_path
if candidate.exists():
return str(candidate)
return relative_path
def _initialize_fonts(self):
"""Initialize font catalog and validate configuration."""
self._scan_fonts_directory()
@@ -667,7 +686,7 @@ class FontManager:
def _scan_fonts_directory(self):
"""Scan assets/fonts directory for available fonts."""
fonts_dir = "assets/fonts"
fonts_dir = self._resolve_asset_path("assets/fonts")
if not os.path.exists(fonts_dir):
logger.warning(f"Fonts directory not found: {fonts_dir}")
return
@@ -683,6 +702,7 @@ class FontManager:
def _register_common_fonts(self):
"""Register common font aliases from common_fonts dictionary."""
for family_name, font_path in self.common_fonts.items():
font_path = self._resolve_asset_path(font_path)
# Check if font file exists
if os.path.exists(font_path):
# Register the common font name (overrides auto-generated name if exists)
+26 -8
View File
@@ -139,23 +139,41 @@ def setup_logging(
sys.stderr.write(f"Warning: Could not set up file logging to {log_file}: {e}\n")
def get_logger(name: str, plugin_id: Optional[str] = None) -> logging.Logger:
class PluginLoggerAdapter(logging.LoggerAdapter):
"""LoggerAdapter that stamps every record with its plugin_id.
A plain `logging.Logger` attribute (the old approach) is never copied
onto individual `LogRecord`s, so `ContextualFormatter`/`StructuredFormatter`
only ever saw `plugin_id` on calls that explicitly passed
`extra={'plugin_id': ...}` (i.e. `log_with_context`). This adapter injects
it into `extra` on every call, so `self.logger.info(...)` in plugin code
is tagged automatically.
"""
def process(self, msg, kwargs):
extra = dict(kwargs.get('extra') or {})
extra.setdefault('plugin_id', self.extra.get('plugin_id'))
kwargs['extra'] = extra
return msg, kwargs
def get_logger(name: str, plugin_id: Optional[str] = None):
"""
Get a logger with consistent configuration.
Args:
name: Logger name (typically __name__)
plugin_id: Optional plugin ID for automatic context
Returns:
Configured logger instance
Configured logger instance (or a PluginLoggerAdapter when plugin_id
is given, which supports the same .debug/.info/.warning/.error API)
"""
logger = logging.getLogger(name)
# Add plugin_id as attribute for formatters
if plugin_id:
logger.plugin_id = plugin_id
return PluginLoggerAdapter(logger, {'plugin_id': plugin_id})
return logger
+37 -1
View File
@@ -86,7 +86,9 @@ class BasePlugin(ABC):
self.display_manager: Any = display_manager
self.cache_manager: Any = cache_manager
self.plugin_manager: Any = plugin_manager
self.logger: logging.Logger = get_logger(f"plugin.{plugin_id}", plugin_id=plugin_id)
# get_logger returns a PluginLoggerAdapter here (plugin_id given), which
# stamps every record with plugin_id so it survives into formatted output.
self.logger = get_logger(f"plugin.{plugin_id}", plugin_id=plugin_id)
self.enabled: bool = config.get("enabled", True)
self.logger.info("Initialized plugin: %s", plugin_id)
@@ -503,6 +505,40 @@ class BasePlugin(ABC):
# -------------------------------------------------------------------------
# Vegas scroll mode support
# -------------------------------------------------------------------------
def get_vegas_render_width(self) -> int:
"""
Width the Vegas ticker wants this plugin's content to occupy.
On a wide panel a layout built to fill the screen reads as sparse in a
ticker a forecast spread over five columns, a progress bar drawn at
100% width, a stat block with the panel's whole width between its
elements. Vegas asks for a narrower render so the plugin can choose a
tighter arrangement instead of being cropped afterwards.
Vegas also narrows ``display_manager`` for the duration of the call, so
a plugin that already sizes itself from ``matrix.width`` needs no
changes. Read this only when you size content some other way.
Controlled by the plugin's own ``vegas_width_pct`` config value, else
the global ``display.vegas_scroll.render_width_pct``.
Returns:
Target width in pixels. Outside a Vegas content request, the full
display width.
"""
requested = getattr(self, '_vegas_render_width', None)
if isinstance(requested, int) and requested > 0:
return requested
display_manager = getattr(self, 'display_manager', None)
matrix = getattr(display_manager, 'matrix', None)
if matrix is not None and getattr(matrix, 'width', None):
return int(matrix.width)
width = getattr(display_manager, 'width', None)
if callable(width):
width = width()
return int(width) if width else 128
def get_vegas_content(self) -> Optional[Any]:
"""
Get content for Vegas-style continuous scroll mode.
+71 -1
View File
@@ -115,7 +115,17 @@ class SchemaManager:
if not isinstance(schema, dict):
self.logger.error(f"Invalid schema format for {plugin_id}: not a dictionary")
return None
# Expand any customization.x-style-elements declaration into the
# full per-element style blocks (font/size/color + layout
# offsets) the web-UI config form renders. No-op for schemas
# without the declaration; never raises.
try:
from src.element_style import expand_style_elements
schema = expand_style_elements(schema)
except ImportError:
pass
# Cache the schema
self._schema_cache[plugin_id] = schema
@@ -284,6 +294,19 @@ class SchemaManager:
"type": "boolean",
"default": False,
"description": "Enable live priority takeover when plugin has live content"
},
# Skin selection (docs/SKIN_SYSTEM.md). Deliberately NOT an
# enum here: validation must keep passing when a configured
# skin gets uninstalled (rendering falls back to built-in).
# The install-dependent enum is injected only at serve time
# (inject_skin_selector) for the web UI dropdown.
"skin": {
"type": ["string", "object", "null"],
"description": "Visual skin id, or a per-mode mapping like {\"live\": \"my-skin\"}"
},
"skin_options": {
"type": "object",
"description": "Options passed through to the selected skin"
}
}
@@ -354,6 +377,53 @@ class SchemaManager:
self.logger.error(error_msg)
return False, [error_msg]
def inject_skin_selector(self, schema: Dict[str, Any], plugin_id: str,
current_value: Any = None) -> Dict[str, Any]:
"""Return a copy of a plugin's schema with a "skin" dropdown added
when installed skins target this plugin (docs/SKIN_SYSTEM.md).
Serve-time only validation never sees this enum, so a config
referencing an uninstalled skin stays valid (rendering falls back
to the built-in layout). The currently-configured value is always
included in the enum for the same reason: the dropdown must be able
to display a selection whose skin was removed.
"""
# A per-mode mapping ({"live": ..., "recent": ...}) can't be edited
# through a string dropdown — injecting one would let the form save
# a string over the mapping. Leave the schema alone; per-mode users
# edit via the raw JSON config editor.
if isinstance(current_value, dict):
return schema
try:
from src.skin_system import skin_runtime
matching = skin_runtime.skins_for_plugin(plugin_id)
except Exception as e:
self.logger.debug(f"Skin discovery failed for {plugin_id}: {e}")
return schema
choices = sorted(matching.keys())
if isinstance(current_value, str) and current_value and \
current_value != "built-in" and current_value not in choices:
choices.append(current_value)
if not choices:
return schema
enhanced = copy.deepcopy(schema)
enhanced.setdefault("properties", {})
if "skin" not in enhanced["properties"]:
names = {sid: (matching.get(sid, {}).get("name") or sid) for sid in choices}
enhanced["properties"]["skin"] = {
"type": "string",
"title": "Visual Skin",
"description": "Replace this scoreboard's look with an installed skin "
"(data, scheduling, and vegas mode are unaffected)",
"enum": ["built-in", *choices],
"enumNames": ["Built-in", *(names[sid] for sid in choices)],
"default": "built-in"
}
return enhanced
def _format_validation_error(self, error: ValidationError, plugin_id: Optional[str] = None) -> str:
"""
Format a validation error into a readable message.
+160 -3
View File
@@ -1214,6 +1214,11 @@ class PluginStoreManager:
self.logger.error(f"Plugin not found in registry: {plugin_id}")
return False
# Visual skins share the registry but install to skins/, not to a
# plugin directory (docs/SKIN_SYSTEM.md)
if (plugin_info.get('type') or 'plugin') == 'skin':
return self._install_skin_from_info(plugin_id, plugin_info, branch)
repo_url = plugin_info.get('repo')
if not repo_url:
self.logger.error(f"Plugin {plugin_id} missing repository URL")
@@ -2254,19 +2259,171 @@ class PluginStoreManager:
return None
_SKIN_ID_PATTERN = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$')
def _resolve_skin_target(self, skin_id: str) -> Optional[Path]:
"""Validate an externally-supplied skin id and resolve it to a path
strictly inside the skins directory. Returns None (after logging)
for ids that are malformed or would escape the directory registry
entries and manifests are external input and must not be able to
write or delete outside skins/."""
from src.skin_system import skin_runtime
if not isinstance(skin_id, str) or not self._SKIN_ID_PATTERN.match(skin_id) \
or '..' in skin_id:
self.logger.error(f"Rejecting unsafe skin id: {skin_id!r}")
return None
skins_dir = skin_runtime.get_skins_directory().resolve()
target = (skins_dir / skin_id).resolve()
if target.parent != skins_dir:
self.logger.error(f"Skin id {skin_id!r} escapes the skins directory; rejecting")
return None
return target
def _install_skin_from_info(self, skin_id: str, skin_info: Dict,
branch: Optional[str] = None) -> bool:
"""Install a registry entry of type "skin" into skins/<id>/.
Reuses the plugin download machinery (git / monorepo zip / archive)
but validates skin.json instead of manifest.json and never installs
dependencies skins are render-only (stdlib + PIL + the provided
SkinContext), which is also what keeps them safe to iterate on.
Downloads into a staging directory and validates there; the
existing installation is only replaced after the new one passes,
so a failed download or bad manifest can't destroy a working skin.
"""
from src.skin_system import skin_runtime
from src.skin_system.skin_base import SKIN_API_VERSION
repo_url = skin_info.get('repo')
if not repo_url:
self.logger.error(f"Skin {skin_id} missing repository URL")
return False
target = self._resolve_skin_target(skin_id)
if target is None:
return False
skins_dir = target.parent
skins_dir.mkdir(parents=True, exist_ok=True)
# Leading "_" keeps staging invisible to skin discovery
staging = skins_dir / f"_staging-{skin_id}"
if staging.exists() and not self._safe_remove_directory(staging):
return False
subpath = skin_info.get('plugin_path')
branch_candidates = self._distinct_sequence([
branch,
skin_info.get('branch'),
skin_info.get('default_branch'),
skin_info.get('last_commit_branch'),
'main',
'master'
])
try:
branch_used = None
if subpath:
for candidate in branch_candidates:
download_url = f"{repo_url}/archive/refs/heads/{candidate}.zip"
if self._install_from_monorepo(download_url, subpath, staging):
branch_used = candidate
break
else:
branch_used = self._install_via_git(repo_url, staging, branch_candidates)
if branch_used is None and not staging.exists():
for candidate in branch_candidates:
download_url = f"{repo_url}/archive/refs/heads/{candidate}.zip"
if self._install_via_download(download_url, staging):
branch_used = candidate
break
if branch_used is None and not staging.exists():
self.logger.error(f"Failed to install skin {skin_id} via git or archive download")
return False
try:
with open(staging / 'skin.json', 'r', encoding='utf-8') as f:
manifest = json.load(f)
except (OSError, json.JSONDecodeError) as e:
self.logger.error(f"Skin {skin_id} has no valid skin.json: {e}")
return False
missing = [k for k in ('id', 'name', 'version', 'skin_api_version', 'class_name')
if not manifest.get(k)]
if missing:
self.logger.error(f"Skin {skin_id} manifest missing fields: {missing}")
return False
# Unlike plugins, a mismatched id is rejected rather than
# renamed: the manifest id is external input, and the registry
# id is what the user asked to install.
if manifest['id'] != skin_id:
self.logger.error(
f"Skin manifest id {manifest['id']!r} doesn't match registry id "
f"{skin_id!r}; not installing")
return False
def _api_major(v):
try:
return int(str(v).split('.')[0])
except (ValueError, IndexError):
return None
if _api_major(manifest['skin_api_version']) != _api_major(SKIN_API_VERSION):
self.logger.error(
f"Skin {skin_id} targets skin API {manifest['skin_api_version']} but this "
f"LEDMatrix provides {SKIN_API_VERSION}; not installing")
return False
# Validated — swap into place
if target.exists() and not self._safe_remove_directory(target):
self.logger.error(f"Could not replace existing skin directory: {target}")
return False
shutil.move(str(staging), str(target))
skin_runtime.discover_skins(force_refresh=True)
self.logger.info(f"Successfully installed skin: {skin_id} (branch: {branch_used})")
return True
finally:
if staging.exists():
self._safe_remove_directory(staging)
def uninstall_skin(self, skin_id: str) -> bool:
"""Remove an installed skin. Plugin configs referencing it keep
validating; rendering falls back to the built-in layout."""
from src.skin_system import skin_runtime
target = self._resolve_skin_target(skin_id)
if target is None:
return False
if not target.exists():
self.logger.info(f"Skin {skin_id} not found (already uninstalled)")
return True
if self._safe_remove_directory(target):
skin_runtime.discover_skins(force_refresh=True)
self.logger.info(f"Successfully uninstalled skin: {skin_id}")
return True
return False
def uninstall_plugin(self, plugin_id: str) -> bool:
"""
Uninstall a plugin by removing its directory.
Args:
plugin_id: Plugin identifier
Returns:
True if uninstalled successfully (or already not installed)
"""
plugin_path = self._find_plugin_path(plugin_id)
if plugin_path is None or not plugin_path.exists():
# A skin id passed to the plugin uninstall path (the store UI
# uses one uninstall flow) removes the skin instead
skin_target = self._resolve_skin_target(plugin_id) \
if self._SKIN_ID_PATTERN.match(str(plugin_id)) else None
if skin_target is not None and skin_target.exists():
return self.uninstall_skin(plugin_id)
self.logger.info(f"Plugin {plugin_id} not found (already uninstalled)")
return True # Already uninstalled, consider this success
@@ -15,6 +15,7 @@ PIL Image canvas and draws text using the actual project fonts.
import math
import os
import time
from contextlib import contextmanager
from pathlib import Path
from typing import Any, List, Optional, Tuple
@@ -62,6 +63,9 @@ class VisualTestDisplayManager:
# Matrix proxy (plugins access display_manager.matrix.width/height)
self.matrix = _MatrixProxy(width, height)
# Set while inside capture_mode(); mirrors DisplayManager's flag.
self._capture_mode_active = False
# Scrolling state (interface compat, no-op)
self._scrolling_state = {
'is_scrolling': False,
@@ -174,6 +178,50 @@ class VisualTestDisplayManager:
"""No-op for hardware; marks that display was updated."""
self.update_called = True
@contextmanager
def render_size(self, width: int, height: Optional[int] = None):
"""
Interface parity with DisplayManager.render_size().
Vegas mode narrows the canvas so plugins lay out compactly instead of
being cropped. The harness must offer the same context or that path
cannot be exercised offline and because the adapter catches broadly,
a missing method shows up as "no content" rather than an error.
"""
prev_image = self.image
prev_draw = self.draw
prev_w, prev_h = self._width, self._height
target_w = max(1, min(int(width), prev_w))
target_h = max(1, min(int(height) if height else prev_h, prev_h))
try:
self._width, self._height = target_w, target_h
self.matrix = _MatrixProxy(target_w, target_h)
self.image = Image.new('RGB', (target_w, target_h), (0, 0, 0))
self.draw = ImageDraw.Draw(self.image)
yield
finally:
self._width, self._height = prev_w, prev_h
self.matrix = _MatrixProxy(prev_w, prev_h)
self.image = prev_image
self.draw = prev_draw
@contextmanager
def capture_mode(self):
"""
Interface parity with DisplayManager.capture_mode().
There is no hardware to suppress here, but Vegas mode's PluginAdapter
wraps every off-screen content fetch in this context, so the harness
must provide it for that code path to be exercisable in tests.
"""
self._capture_mode_active = True
try:
yield
finally:
self._capture_mode_active = False
def draw_text(self, text: str, x: Optional[int] = None, y: Optional[int] = None,
color: Tuple[int, int, int] = (255, 255, 255), small_font: bool = False,
font: Optional[Any] = None, centered: bool = False) -> None:
+31
View File
@@ -0,0 +1,31 @@
"""
Skin system: user-installable visual overlays for sports scoreboards.
A skin replaces only the rendering of a scoreboard (live / recent /
upcoming) while the host plugin keeps doing data fetching, scheduling,
caching, live priority, and vegas mode. See docs/SKIN_SYSTEM.md.
"""
from src.skin_system.skin_base import (
SKIN_API_VERSION,
VIEW_MODEL_VERSION,
ScoreboardSkin,
SkinContext,
)
from src.skin_system.skin_runtime import (
build_context,
discover_skins,
get_skins_directory,
load_skin,
)
__all__ = [
"SKIN_API_VERSION",
"VIEW_MODEL_VERSION",
"ScoreboardSkin",
"SkinContext",
"build_context",
"discover_skins",
"get_skins_directory",
"load_skin",
]
@@ -0,0 +1,39 @@
{
"id": "401570001",
"game_time": "7:05PM",
"game_date": "Jul 16th",
"start_time_utc": "2026-07-16T23:05:00+00:00",
"status_text": "Bot 7th",
"is_live": true,
"is_final": false,
"is_upcoming": false,
"is_halftime": false,
"is_period_break": false,
"home_abbr": "LAD",
"home_id": "19",
"home_score": "5",
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
"home_logo_url": null,
"home_record": "58-33",
"away_abbr": "SF",
"away_id": "26",
"away_score": "3",
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
"away_logo_url": null,
"away_record": "49-42",
"is_within_window": true,
"status": "STATUS_IN_PROGRESS",
"status_state": "in",
"inning": 7,
"inning_half": "bottom",
"balls": 3,
"strikes": 2,
"outs": 2,
"bases_occupied": [
true,
true,
true
],
"start_time": "2026-07-16T23:05:00Z",
"series_summary": "LAD leads 2-1"
}
@@ -0,0 +1,39 @@
{
"id": "401570001",
"game_time": "7:05PM",
"game_date": "Jul 16th",
"start_time_utc": "2026-07-16T23:05:00+00:00",
"status_text": "Final",
"is_live": false,
"is_final": true,
"is_upcoming": false,
"is_halftime": false,
"is_period_break": false,
"home_abbr": "LAD",
"home_id": "19",
"home_score": "5",
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
"home_logo_url": null,
"home_record": "58-33",
"away_abbr": "SF",
"away_id": "26",
"away_score": "3",
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
"away_logo_url": null,
"away_record": "49-42",
"is_within_window": true,
"status": "STATUS_FINAL",
"status_state": "post",
"inning": 9,
"inning_half": "top",
"balls": 0,
"strikes": 0,
"outs": 3,
"bases_occupied": [
false,
false,
false
],
"start_time": "2026-07-16T23:05:00Z",
"series_summary": "Series tied 2-2"
}
@@ -0,0 +1,39 @@
{
"id": "401570001",
"game_time": "7:05PM",
"game_date": "Jul 16th",
"start_time_utc": "2026-07-16T23:05:00+00:00",
"status_text": "7:05 PM",
"is_live": false,
"is_final": false,
"is_upcoming": true,
"is_halftime": false,
"is_period_break": false,
"home_abbr": "LAD",
"home_id": "19",
"home_score": "0",
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
"home_logo_url": null,
"home_record": "58-33",
"away_abbr": "SF",
"away_id": "26",
"away_score": "0",
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
"away_logo_url": null,
"away_record": "49-42",
"is_within_window": true,
"status": "STATUS_SCHEDULED",
"status_state": "pre",
"inning": 0,
"inning_half": "top",
"balls": 0,
"strikes": 0,
"outs": 0,
"bases_occupied": [
false,
false,
false
],
"start_time": "2026-07-16T23:05:00Z",
"series_summary": ""
}
@@ -0,0 +1,28 @@
{
"id": "401570001",
"game_time": "7:05PM",
"game_date": "Jul 16th",
"start_time_utc": "2026-07-16T23:05:00+00:00",
"status_text": "Q4 2:34",
"is_live": true,
"is_final": false,
"is_upcoming": false,
"is_halftime": false,
"is_period_break": false,
"home_abbr": "OKC",
"home_id": "19",
"home_score": "5",
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
"home_logo_url": null,
"home_record": "",
"away_abbr": "MIN",
"away_id": "26",
"away_score": "3",
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
"away_logo_url": null,
"away_record": "",
"is_within_window": true,
"period": 4,
"period_text": "Q4",
"clock": "2:34"
}
@@ -0,0 +1,28 @@
{
"id": "401570001",
"game_time": "7:05PM",
"game_date": "Jul 16th",
"start_time_utc": "2026-07-16T23:05:00+00:00",
"status_text": "Final",
"is_live": false,
"is_final": true,
"is_upcoming": false,
"is_halftime": false,
"is_period_break": false,
"home_abbr": "OKC",
"home_id": "19",
"home_score": "5",
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
"home_logo_url": null,
"home_record": "",
"away_abbr": "MIN",
"away_id": "26",
"away_score": "3",
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
"away_logo_url": null,
"away_record": "",
"is_within_window": true,
"period": 4,
"period_text": "Final",
"clock": "0:00"
}
@@ -0,0 +1,28 @@
{
"id": "401570001",
"game_time": "7:05PM",
"game_date": "Jul 16th",
"start_time_utc": "2026-07-16T23:05:00+00:00",
"status_text": "7:05 PM",
"is_live": false,
"is_final": false,
"is_upcoming": true,
"is_halftime": false,
"is_period_break": false,
"home_abbr": "OKC",
"home_id": "19",
"home_score": "0",
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
"home_logo_url": null,
"home_record": "",
"away_abbr": "MIN",
"away_id": "26",
"away_score": "0",
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
"away_logo_url": null,
"away_record": "",
"is_within_window": true,
"period": 0,
"period_text": "",
"clock": "0:00"
}
@@ -0,0 +1,36 @@
{
"id": "401570001",
"game_time": "7:05PM",
"game_date": "Jul 16th",
"start_time_utc": "2026-07-16T23:05:00+00:00",
"status_text": "Q3 8:12",
"is_live": true,
"is_final": false,
"is_upcoming": false,
"is_halftime": false,
"is_period_break": false,
"home_abbr": "KC",
"home_id": "19",
"home_score": "21",
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
"home_logo_url": null,
"home_record": "58-33",
"away_abbr": "BUF",
"away_id": "26",
"away_score": "17",
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
"away_logo_url": null,
"away_record": "49-42",
"is_within_window": true,
"period": 3,
"period_text": "Q3",
"clock": "8:12",
"home_timeouts": 2,
"away_timeouts": 3,
"down_distance_text": "3rd & 4",
"down_distance_text_long": "3rd & 4 at KC 22",
"is_redzone": true,
"possession": "12",
"possession_indicator": "away",
"scoring_event": null
}
@@ -0,0 +1,36 @@
{
"id": "401570001",
"game_time": "7:05PM",
"game_date": "Jul 16th",
"start_time_utc": "2026-07-16T23:05:00+00:00",
"status_text": "Final",
"is_live": false,
"is_final": true,
"is_upcoming": false,
"is_halftime": false,
"is_period_break": false,
"home_abbr": "KC",
"home_id": "19",
"home_score": "21",
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
"home_logo_url": null,
"home_record": "58-33",
"away_abbr": "BUF",
"away_id": "26",
"away_score": "17",
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
"away_logo_url": null,
"away_record": "49-42",
"is_within_window": true,
"period": 4,
"period_text": "Final",
"clock": "0:00",
"home_timeouts": 0,
"away_timeouts": 0,
"down_distance_text": "",
"down_distance_text_long": "",
"is_redzone": false,
"possession": null,
"possession_indicator": null,
"scoring_event": null
}
@@ -0,0 +1,36 @@
{
"id": "401570001",
"game_time": "7:05PM",
"game_date": "Jul 16th",
"start_time_utc": "2026-07-16T23:05:00+00:00",
"status_text": "7:05 PM",
"is_live": false,
"is_final": false,
"is_upcoming": true,
"is_halftime": false,
"is_period_break": false,
"home_abbr": "KC",
"home_id": "19",
"home_score": "0",
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
"home_logo_url": null,
"home_record": "58-33",
"away_abbr": "BUF",
"away_id": "26",
"away_score": "0",
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
"away_logo_url": null,
"away_record": "49-42",
"is_within_window": true,
"period": 0,
"period_text": "",
"clock": "0:00",
"home_timeouts": 3,
"away_timeouts": 3,
"down_distance_text": "",
"down_distance_text_long": "",
"is_redzone": false,
"possession": null,
"possession_indicator": null,
"scoring_event": null
}
+32
View File
@@ -0,0 +1,32 @@
{
"id": "401570001",
"game_time": "7:05PM",
"game_date": "Jul 16th",
"start_time_utc": "2026-07-16T23:05:00+00:00",
"status_text": "P3 14:55",
"is_live": true,
"is_final": false,
"is_upcoming": false,
"is_halftime": false,
"is_period_break": false,
"home_abbr": "COL",
"home_id": "19",
"home_score": "2",
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
"home_logo_url": null,
"home_record": "58-33",
"away_abbr": "VGK",
"away_id": "26",
"away_score": "2",
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
"away_logo_url": null,
"away_record": "49-42",
"is_within_window": true,
"period": 3,
"period_text": "P3",
"clock": "14:55",
"power_play": true,
"penalties": [],
"home_shots": 27,
"away_shots": 31
}
@@ -0,0 +1,32 @@
{
"id": "401570001",
"game_time": "7:05PM",
"game_date": "Jul 16th",
"start_time_utc": "2026-07-16T23:05:00+00:00",
"status_text": "Final/OT",
"is_live": false,
"is_final": true,
"is_upcoming": false,
"is_halftime": false,
"is_period_break": false,
"home_abbr": "COL",
"home_id": "19",
"home_score": "3",
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
"home_logo_url": null,
"home_record": "58-33",
"away_abbr": "VGK",
"away_id": "26",
"away_score": "2",
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
"away_logo_url": null,
"away_record": "49-42",
"is_within_window": true,
"period": 5,
"period_text": "Final/OT",
"clock": "0:00",
"power_play": false,
"penalties": [],
"home_shots": 35,
"away_shots": 33
}
@@ -0,0 +1,32 @@
{
"id": "401570001",
"game_time": "7:05PM",
"game_date": "Jul 16th",
"start_time_utc": "2026-07-16T23:05:00+00:00",
"status_text": "7:05 PM",
"is_live": false,
"is_final": false,
"is_upcoming": true,
"is_halftime": false,
"is_period_break": false,
"home_abbr": "COL",
"home_id": "19",
"home_score": "0",
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
"home_logo_url": null,
"home_record": "58-33",
"away_abbr": "VGK",
"away_id": "26",
"away_score": "0",
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
"away_logo_url": null,
"away_record": "49-42",
"is_within_window": true,
"period": 0,
"period_text": "",
"clock": "0:00",
"power_play": false,
"penalties": [],
"home_shots": 0,
"away_shots": 0
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 444 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 446 B

+171
View File
@@ -0,0 +1,171 @@
"""
Skin API: the classes a skin author works with.
A skin is a directory under skins/<skin-id>/ containing a skin.json
manifest and a Python module exposing a ScoreboardSkin subclass. The
host (a sports scoreboard's base classes) builds a SkinContext per
render and calls render_live / render_recent / render_upcoming with the
game view model. The skin draws onto ctx.canvas and returns True; the
host composites the canvas onto the display. A skin never talks to the
display, the network, or the plugin directly.
Skin API Version: 1.0.0
View Model Version: 1.0
"""
from abc import ABC
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, Optional, Tuple, Union
from PIL import Image, ImageDraw
try:
import freetype
except ImportError: # pragma: no cover - freetype ships with the project deps
freetype = None
from src.adaptive_layout import FitResult, LayoutContext, Region
# Major must match a skin manifest's skin_api_version major or the skin
# is refused at load time (renames/removals bump major; additions minor).
SKIN_API_VERSION = "1.0.0"
# Version of the guaranteed `game` dict keys (see docs/CREATING_SKINS.md).
VIEW_MODEL_VERSION = "1.0"
def _draw_bdf_text_on(draw: ImageDraw.ImageDraw, text: str, x: int, y: int,
color: Tuple[int, int, int], face: Any,
clip_w: int, clip_h: int) -> None:
"""Render a freetype BDF face glyph-by-glyph onto an arbitrary canvas.
DisplayManager._draw_bdf_text only draws onto the panel image; skins
draw onto their own canvas, so the fitted-font path (fit_text can
return freetype faces) needs this standalone equivalent.
"""
try:
ascender_px = face.size.ascender >> 6
except Exception:
ascender_px = 0
baseline_y = y + ascender_px
for char in text:
face.load_char(char)
bitmap = face.glyph.bitmap
glyph_left = face.glyph.bitmap_left
glyph_top = face.glyph.bitmap_top
for i in range(bitmap.rows):
for j in range(bitmap.width):
byte_index = i * bitmap.pitch + (j // 8)
if byte_index < len(bitmap.buffer) and \
bitmap.buffer[byte_index] & (1 << (7 - (j % 8))):
px = x + glyph_left + j
py = baseline_y - glyph_top + i
if 0 <= px < clip_w and 0 <= py < clip_h:
draw.point((px, py), fill=color)
x += face.glyph.advance.x >> 6
@dataclass
class SkinContext:
"""Everything a skin may touch during one render call.
The canvas is a fresh RGB image sized to the current display (or
vegas card). Draw onto it via the helpers below or raw ``draw``;
never call display/update methods the host composites the canvas.
"""
canvas: Image.Image
draw: ImageDraw.ImageDraw
layout: LayoutContext
width: int
height: int
fonts: Dict[str, Any]
options: Dict[str, Any]
logger: Any
sport: Optional[str] = None
view_model_version: str = VIEW_MODEL_VERSION
# load_logo("home") / load_logo("away") -> RGBA PIL image or None.
# Bound to the current game; hits the host's logo cache (never loads
# from disk twice), downloads missing logos like the built-in layout.
load_logo: Callable[[str], Optional[Image.Image]] = field(default=lambda side: None)
# draw_text_outlined(text, (x, y), font, fill=..., outline_color=...)
# — the classic scorebug outlined text, drawn onto this canvas.
# TTF fonts only (ctx.fonts values are TTF); for ladder-fitted fonts
# use draw_fit / draw_text, which handle BDF faces too.
draw_text_outlined: Callable[..., None] = field(default=lambda *a, **k: None)
def draw_text(self, text: str, x: int, y: int,
color: Tuple[int, int, int] = (255, 255, 255),
font: Any = None) -> None:
"""Draw text at a top-left position, handling both PIL fonts and
the freetype BDF faces that layout.fit_text can return."""
if font is None:
font = self.fonts.get('time')
if freetype is not None and isinstance(font, freetype.Face):
_draw_bdf_text_on(self.draw, text, int(x), int(y), color, font,
self.width, self.height)
else:
self.draw.text((int(x), int(y)), text, font=font, fill=color)
def draw_fit(self, fit: FitResult, box: Union[Region, Tuple[int, int]],
color: Tuple[int, int, int] = (255, 255, 255),
align: str = "center", valign: str = "center") -> None:
"""Draw a layout.fit_text() result aligned within a Region — the
canvas-local equivalent of adaptive_layout.draw_fitted_text."""
region = box if isinstance(box, Region) else Region(0, 0, box[0], box[1])
x, y = region.align_xy(fit.width, fit.height, align, valign)
self.draw_text(fit.text, x, y - fit.y_offset, color=color, font=fit.font)
def draw_image(self, img: Optional[Image.Image],
box: Union[Region, Tuple[int, int]], *,
mode: str = "contain", align: str = "center",
valign: str = "center", cache_key: Any = None) -> None:
"""Fit an image (a logo, art) into a Region and paste it, honoring
alpha. Silently no-ops on None so `ctx.draw_image(ctx.load_logo(
'home'), ...)` stays safe when a logo is missing."""
if img is None:
return
region = box if isinstance(box, Region) else Region(0, 0, box[0], box[1])
fitted = self.layout.fit_image(img, region, mode=mode,
cache_key=cache_key)
result = fitted.image # fit_image returns an ImageFitResult (always RGBA)
if result is None:
return
x, y = region.align_xy(result.width, result.height, align, valign)
self.canvas.paste(result, (int(x), int(y)), result)
class ScoreboardSkin(ABC):
"""Base class for scoreboard skins.
Override only the modes you want to restyle; any mode you leave
unimplemented (or return False from) falls back to the plugin's
built-in renderer, so a live-only skin still gets recent/upcoming
screens for free.
Skins should be stateless: three host instances (live, recent,
upcoming) each hold their own skin instance, and a render must be
derivable from (ctx, game) alone.
"""
SKIN_API_VERSION = SKIN_API_VERSION
def __init__(self, manifest: Dict[str, Any], options: Dict[str, Any]):
self.manifest = manifest
self.options = options or {}
def render_live(self, ctx: SkinContext, game: Dict[str, Any]) -> bool:
return False
def render_recent(self, ctx: SkinContext, game: Dict[str, Any]) -> bool:
return False
def render_upcoming(self, ctx: SkinContext, game: Dict[str, Any]) -> bool:
return False
def render_vegas_card(self, ctx: SkinContext,
game: Dict[str, Any]) -> Optional[Image.Image]:
"""Render one vegas scroll card at ctx.width x ctx.height. Return
the finished image, or None to let the host use its default vegas
rendering (which captures the regular display output)."""
return None
+352
View File
@@ -0,0 +1,352 @@
"""
Skin runtime: discovery, validation, loading, and context building.
Deliberately generic this module knows nothing about sports beyond
passing a `sport` label through; the sports flavor lives in skin_base
(ScoreboardSkin) and in the hosts that call build_context.
Every failure path here logs and returns None: a broken or missing skin
must never take down the plugin that references it the host falls
back to its built-in renderer.
"""
import importlib.util
import json
import sys
import threading
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
from PIL import Image, ImageDraw
from src.adaptive_layout import LayoutContext
from src.logging_config import get_logger
from src.skin_system.skin_base import (
SKIN_API_VERSION,
ScoreboardSkin,
SkinContext,
)
logger = get_logger(__name__)
_REQUIRED_MANIFEST_FIELDS = ("id", "name", "version", "skin_api_version", "class_name")
_DEFAULT_ENTRY_POINT = "skin.py"
_lock = threading.RLock()
# skins_dir -> (fingerprint, {skin_id: manifest+path})
_discovery_cache: Dict[str, Tuple[Tuple, Dict[str, Dict[str, Any]]]] = {}
_shared_layout_font_manager: Optional[Any] = None
def _get_font_manager() -> Any:
"""Shared FontManager for skin LayoutContexts. SportsCore hosts don't
carry a plugin_manager, so skins share one module-level FontManager
the same shape as base_plugin._fallback_font_manager, constructed
directly so rendering never has to import the whole plugin system."""
global _shared_layout_font_manager
if _shared_layout_font_manager is None:
from src.font_manager import FontManager
_shared_layout_font_manager = FontManager({})
return _shared_layout_font_manager
def get_skins_directory() -> Path:
"""Central skins directory: <project_root>/skins. Lives outside the
plugin directories on purpose plugin reinstall/update deletes the
whole plugin directory, and a skin must survive that."""
return Path(__file__).resolve().parents[2] / "skins"
def _major(version: str) -> Optional[int]:
try:
return int(str(version).split(".")[0])
except (ValueError, AttributeError, IndexError):
return None
def _read_manifest(skin_dir: Path) -> Optional[Dict[str, Any]]:
manifest_path = skin_dir / "skin.json"
if not manifest_path.is_file():
return None
try:
with open(manifest_path, "r", encoding="utf-8") as f:
manifest = json.load(f)
except (OSError, json.JSONDecodeError) as e:
logger.error("Skin manifest %s is unreadable: %s", manifest_path, e)
return None
missing = [k for k in _REQUIRED_MANIFEST_FIELDS if not manifest.get(k)]
if missing:
logger.error("Skin manifest %s missing required fields: %s",
manifest_path, ", ".join(missing))
return None
if manifest["id"] != skin_dir.name:
logger.warning("Skin manifest id %r does not match directory name %r",
manifest["id"], skin_dir.name)
manifest["_skin_dir"] = str(skin_dir)
return manifest
def _discovery_fingerprint(skins_dir: Path) -> Optional[Tuple]:
"""Cache key for a skins directory: its mtime plus every skin.json's
(path, mtime). The directory mtime alone misses in-place manifest edits
(a skin updated without adding/removing entries)."""
try:
parts = [skins_dir.stat().st_mtime]
for manifest_path in sorted(skins_dir.glob("*/skin.json")):
parts.append((str(manifest_path), manifest_path.stat().st_mtime))
return tuple(parts)
except OSError:
return None
def discover_skins(skins_dir: Optional[Path] = None,
force_refresh: bool = False) -> Dict[str, Dict[str, Any]]:
"""Return {skin_id: manifest} for every valid skin package installed.
Cached per directory and invalidated when the directory or any
skin.json changes; pass force_refresh to bypass.
"""
skins_dir = Path(skins_dir) if skins_dir else get_skins_directory()
cache_key = str(skins_dir)
fingerprint = _discovery_fingerprint(skins_dir)
if fingerprint is None:
return {}
with _lock:
cached = _discovery_cache.get(cache_key)
if cached and not force_refresh and cached[0] == fingerprint:
return dict(cached[1])
skins: Dict[str, Dict[str, Any]] = {}
for entry in sorted(skins_dir.iterdir()):
if not entry.is_dir() or entry.name.startswith((".", "_")):
continue
manifest = _read_manifest(entry)
if manifest:
skins[manifest["id"]] = manifest
_discovery_cache[cache_key] = (fingerprint, skins)
return dict(skins)
def skin_targets(manifest: Dict[str, Any]) -> Tuple[list, list]:
"""(sports, sport_keys) a skin declares it supports."""
targets = manifest.get("targets") or {}
return (list(targets.get("sports") or []),
list(targets.get("sport_keys") or []))
def skin_matches_target(manifest: Dict[str, Any], sport: Optional[str],
sport_key: Optional[str]) -> bool:
"""True when the skin declares support for this sport family or exact
sport key. A skin with no targets at all matches everything."""
sports, sport_keys = skin_targets(manifest)
if not sports and not sport_keys:
return True
if sport and sport in sports:
return True
if sport_key and sport_key in sport_keys:
return True
return False
def skins_for_plugin(plugin_id: str,
skins: Optional[Dict[str, Dict[str, Any]]] = None) -> Dict[str, Dict[str, Any]]:
"""Installed skins that plausibly apply to a plugin, for UI dropdowns.
A skin matches when the plugin id is listed in targets.plugins, or any
declared sport / sport_key appears as a token of the plugin id (so a
skin targeting sports=["baseball"] matches "baseball-scoreboard", and
sport_keys=["milb"] matches "milb-scoreboard")."""
if skins is None:
skins = discover_skins()
tokens = set(str(plugin_id).lower().replace("-", "_").split("_"))
matched = {}
for skin_id, manifest in skins.items():
targets = manifest.get("targets") or {}
if plugin_id in (targets.get("plugins") or []):
matched[skin_id] = manifest
continue
sports, sport_keys = skin_targets(manifest)
if any(str(t).lower() in tokens for t in sports + sport_keys):
matched[skin_id] = manifest
return matched
def _load_skin_module(skin_id: str, skin_dir: Path, entry_point: str) -> Optional[Any]:
"""Import the skin's entry module under a namespaced sys.modules key,
namespacing its sibling .py files the same way the collision-
avoidance scheme plugins use (plugin_loader._namespace_plugin_modules),
so two skins can both ship a helpers.py.
The entry module is cached: the live/recent/upcoming hosts all load
the same skin, and only the first load executes any code. (A skin
whose *code* changed on disk needs a service restart to take effect
Python modules can't be safely hot-swapped.)
"""
entry_path = skin_dir / entry_point
if not entry_path.is_file():
logger.error("Skin '%s' entry point not found: %s", skin_id, entry_path)
return None
module_name = f"_skin_{skin_id}_{Path(entry_point).stem}"
with _lock:
cached_entry = sys.modules.get(module_name)
if cached_entry is not None:
return cached_entry
# Import siblings under their namespaced alias, and *bind* the bare
# name (cached or fresh) so `import helpers` inside the entry module
# resolves to this skin's copy. The bare bindings are transient —
# restored below so another skin's identically-named sibling can't
# be shadowed by ours.
replaced_bare: Dict[str, Any] = {}
try:
for sibling in skin_dir.glob("*.py"):
if sibling.name == entry_point:
continue
alias = f"_skin_{skin_id}_{sibling.stem}"
module = sys.modules.get(alias)
if module is None:
spec = importlib.util.spec_from_file_location(alias, sibling)
if not spec or not spec.loader:
continue
module = importlib.util.module_from_spec(spec)
sys.modules[alias] = module
replaced_bare.setdefault(sibling.stem, sys.modules.get(sibling.stem))
sys.modules[sibling.stem] = module
try:
spec.loader.exec_module(module)
except Exception as e:
logger.error("Skin '%s' sibling module %s failed to import: %s",
skin_id, sibling.name, e, exc_info=True)
sys.modules.pop(alias, None)
return None
else:
replaced_bare.setdefault(sibling.stem, sys.modules.get(sibling.stem))
sys.modules[sibling.stem] = module
try:
spec = importlib.util.spec_from_file_location(module_name, entry_path)
if not spec or not spec.loader:
logger.error("Skin '%s': could not create import spec for %s",
skin_id, entry_path)
return None
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
return module
except Exception as e:
sys.modules.pop(module_name, None)
logger.error("Skin '%s' failed to import: %s", skin_id, e, exc_info=True)
return None
finally:
for bare_name, previous in replaced_bare.items():
if previous is None:
sys.modules.pop(bare_name, None)
else:
sys.modules[bare_name] = previous
def load_skin(skin_id: str, sport: Optional[str] = None,
sport_key: Optional[str] = None,
options: Optional[Dict[str, Any]] = None,
skins_dir: Optional[Path] = None) -> Optional[ScoreboardSkin]:
"""Load and instantiate a skin. Returns None (after logging why) on
any failure callers treat None as 'use the built-in renderer'."""
skins = discover_skins(skins_dir)
manifest = skins.get(skin_id)
if manifest is None:
logger.warning("Skin '%s' is configured but not installed under %s; "
"using built-in renderer",
skin_id, skins_dir or get_skins_directory())
return None
manifest_major = _major(manifest.get("skin_api_version"))
api_major = _major(SKIN_API_VERSION)
if manifest_major != api_major:
logger.error("Skin '%s' targets skin API %s but this LEDMatrix "
"provides %s — the skin needs an update; using "
"built-in renderer",
skin_id, manifest.get("skin_api_version"), SKIN_API_VERSION)
return None
if not skin_matches_target(manifest, sport, sport_key):
# Soft: the user explicitly configured it, so warn but load anyway
# (a baseball skin may render an acceptable generic scoreboard).
logger.warning("Skin '%s' does not declare support for sport=%r / "
"sport_key=%r; loading anyway", skin_id, sport, sport_key)
skin_dir = Path(manifest["_skin_dir"])
module = _load_skin_module(skin_id, skin_dir,
manifest.get("entry_point", _DEFAULT_ENTRY_POINT))
if module is None:
return None
class_name = manifest["class_name"]
skin_class = getattr(module, class_name, None)
if skin_class is None or not isinstance(skin_class, type) or \
not issubclass(skin_class, ScoreboardSkin):
logger.error("Skin '%s': %s is missing or not a ScoreboardSkin subclass",
skin_id, class_name)
return None
try:
return skin_class(manifest, options or {})
except Exception as e:
logger.error("Skin '%s' failed to instantiate: %s", skin_id, e, exc_info=True)
return None
def build_context(host: Any, game: Dict[str, Any],
size: Optional[Tuple[int, int]] = None) -> SkinContext:
"""Build a SkinContext for one render call.
`host` is a SportsCore-style object: display_manager, fonts, logger,
sport, skin_options, _load_and_resize_logo, _draw_text_with_outline.
`size` overrides the canvas size (vegas cards); default is the
current display size read live from the display manager.
"""
if size is not None:
width, height = int(size[0]), int(size[1])
else:
dm = host.display_manager
width = getattr(dm, "width", None) or dm.matrix.width
height = getattr(dm, "height", None) or dm.matrix.height
canvas = Image.new("RGB", (width, height), (0, 0, 0))
draw = ImageDraw.Draw(canvas)
layout = LayoutContext(width, height, _get_font_manager())
def load_logo(side: str) -> Optional[Image.Image]:
if side not in ("home", "away"):
return None
try:
logo_path = game.get(f"{side}_logo_path")
if logo_path is not None and not isinstance(logo_path, Path):
logo_path = Path(logo_path)
return host._load_and_resize_logo(
game.get(f"{side}_id"), game.get(f"{side}_abbr"),
logo_path, game.get(f"{side}_logo_url"))
except Exception as e:
host.logger.warning("Skin logo load failed for %s: %s", side, e)
return None
def draw_text_outlined(text, position, font, fill=(255, 255, 255),
outline_color=(0, 0, 0)):
host._draw_text_with_outline(draw, text, position, font,
fill=fill, outline_color=outline_color)
return SkinContext(
canvas=canvas,
draw=draw,
layout=layout,
width=width,
height=height,
fonts=dict(host.fonts),
options=dict(getattr(host, "skin_options", {}) or {}),
logger=host.logger,
sport=getattr(host, "sport", None),
load_logo=load_logo,
draw_text_outlined=draw_text_outlined,
)
+222
View File
@@ -21,6 +21,94 @@ class VegasModeConfig:
scroll_speed: float = 50.0 # Pixels per second
separator_width: int = 32 # Gap between plugins (pixels)
# Fraction of the panel width a plugin is told it has while rendering for
# the ticker, as a percentage. Trimming can only remove blank margins; it
# cannot compact a layout that genuinely spans the display — a five-column
# forecast, a full-width progress bar, a centred stat block with the panel's
# whole width between its elements. Rendering at a narrower size makes the
# plugin choose a tighter layout instead. 100 disables it.
render_width_pct: int = 100
# Minimum blank columns guaranteed between adjacent content, measured from
# actual ink rather than added blindly. A flat additive gap leaves
# card-style content nearly touching when the cards are drawn flush to their
# own edges, while padding out content that already has wide margins.
min_content_separation: int = 24
# Gap between rows contributed by the *same* plugin. separator_width marks
# the handoff from one plugin to the next; applying it between every image
# forced a 32px chasm between each row of a per-row ticker (the F1
# scoreboard renders its own rows 4px apart), which both looked wrong and
# silently inflated the width that plugin occupied.
intra_plugin_gap: int = 8
# Content density
#
# Plugins that render onto a full-display canvas contribute that whole
# canvas to the ticker, blank margins included. On a wide panel that is the
# dominant source of dead air: a plugin drawing 35px of text on a 512px
# canvas otherwise buys 9.5s of black at 50px/s. Trimming reclaims it.
auto_trim: bool = True
trim_threshold: int = 10 # Per-channel value a pixel must exceed to be "ink"
content_padding: int = 8 # Blank columns kept either side of trimmed content
min_plugin_width: int = 8 # Segments narrower than this after trim are dropped
# Columns of blank lead-in before the first item of a cycle. ScrollHelper
# defaults this to a full display width, which reads as the display being
# switched off at the start of every cycle.
lead_in_width: int = 0
# Blend between neighbouring pixel positions so motion happens at the frame
# rate rather than the scroll speed. With integer positioning the number of
# distinct frames per second equals scroll_speed, so at 50px/s the motion is
# 50 discrete 1px steps however fast the loop runs. The trade is a slight
# horizontal softening of text, since each frame is a blend of two positions.
smooth_scroll: bool = True
# Keep one continuous strip, extending it with the next group of plugins as
# the scroll approaches the end, instead of composing a fresh strip and
# swapping it in. A swap stops the motion, substitutes every pixel at once
# and restarts with the viewport already full — read as a freeze, a flash
# and a jump. Extending means the next group simply scrolls in from the
# right. Set false to restore the swap behaviour.
continuous_scroll: bool = True
# Extend once the unscrolled remainder falls below this many screen widths.
# Needs to be more than one so the join is prepared before it is on screen.
extend_threshold_screens: float = 2.0
# How many plugins are composed into one scroll cycle. Kept separate from
# buffer_ahead (which is only a prefetch low-water mark) because the two
# were previously the same number: a buffer_ahead of 2 meant just 3 plugins
# per cycle, so a 20-plugin install took seven cycles to come around.
plugins_per_cycle: int = 6
# Minimum run of blank columns that counts as a boundary between items when
# an oversized segment has to be narrowed. Measured on rendered text, the
# gaps between characters are a single column while gaps between items are
# 8px and up, so anything above 1 stops a cut landing inside a word. Cutting
# mid-word orphaned the tail into the next cycle, which showed up as a lone
# letter floating between two unrelated plugins.
min_cut_gap: int = 6
# What to do when a plugin's content exceeds its width budget.
#
# "rotate" — advance a window each cycle so everything is seen eventually.
# Right for interchangeable items: news headlines, odds, stocks.
# "truncate" — always show the start. Right for ordered content, where a
# window into the middle is meaningless: a league table that
# shows ranks 1-6 then resumes at 7 two rotations later reads
# as out of order and out of context.
#
# Override per plugin with vegas_overflow.
overflow_mode: str = "rotate"
# Cap on one plugin's share of a cycle, as a multiple of display width.
# A single ticker returning 7,000px would otherwise hold the panel for over
# two minutes. Overflow is deferred to later cycles rather than discarded.
# 0 disables the cap.
max_plugin_width_ratio: float = 3.0
# Plugin management
plugin_order: List[str] = field(default_factory=list)
excluded_plugins: Set[str] = field(default_factory=set)
@@ -55,6 +143,24 @@ class VegasModeConfig:
enabled=vegas_config.get('enabled', False),
scroll_speed=float(vegas_config.get('scroll_speed', 50.0)),
separator_width=int(vegas_config.get('separator_width', 32)),
intra_plugin_gap=int(vegas_config.get('intra_plugin_gap', 8)),
render_width_pct=int(vegas_config.get('render_width_pct', 100)),
min_content_separation=int(
vegas_config.get('min_content_separation', 24)),
min_cut_gap=int(vegas_config.get('min_cut_gap', 6)),
smooth_scroll=vegas_config.get('smooth_scroll', True),
continuous_scroll=vegas_config.get('continuous_scroll', True),
extend_threshold_screens=float(
vegas_config.get('extend_threshold_screens', 2.0)),
auto_trim=vegas_config.get('auto_trim', True),
trim_threshold=int(vegas_config.get('trim_threshold', 10)),
content_padding=int(vegas_config.get('content_padding', 8)),
min_plugin_width=int(vegas_config.get('min_plugin_width', 8)),
lead_in_width=int(vegas_config.get('lead_in_width', 0)),
plugins_per_cycle=int(vegas_config.get('plugins_per_cycle', 6)),
max_plugin_width_ratio=float(
vegas_config.get('max_plugin_width_ratio', 3.0)),
overflow_mode=str(vegas_config.get('overflow_mode', 'rotate')),
plugin_order=list(vegas_config.get('plugin_order', [])),
excluded_plugins=set(vegas_config.get('excluded_plugins', [])),
target_fps=int(vegas_config.get('target_fps', 125)),
@@ -72,6 +178,21 @@ class VegasModeConfig:
'enabled': self.enabled,
'scroll_speed': self.scroll_speed,
'separator_width': self.separator_width,
'intra_plugin_gap': self.intra_plugin_gap,
'render_width_pct': self.render_width_pct,
'min_content_separation': self.min_content_separation,
'min_cut_gap': self.min_cut_gap,
'smooth_scroll': self.smooth_scroll,
'continuous_scroll': self.continuous_scroll,
'extend_threshold_screens': self.extend_threshold_screens,
'auto_trim': self.auto_trim,
'trim_threshold': self.trim_threshold,
'content_padding': self.content_padding,
'min_plugin_width': self.min_plugin_width,
'lead_in_width': self.lead_in_width,
'plugins_per_cycle': self.plugins_per_cycle,
'max_plugin_width_ratio': self.max_plugin_width_ratio,
'overflow_mode': self.overflow_mode,
'plugin_order': self.plugin_order,
'excluded_plugins': list(self.excluded_plugins),
'target_fps': self.target_fps,
@@ -157,6 +278,74 @@ class VegasModeConfig:
if self.buffer_ahead > 5:
errors.append(f"buffer_ahead must be <= 5, got {self.buffer_ahead}")
if not 10 <= self.render_width_pct <= 100:
errors.append(
"render_width_pct must be between 10 and 100, "
f"got {self.render_width_pct}")
if not 0 <= self.min_content_separation <= 256:
errors.append(
"min_content_separation must be between 0 and 256, "
f"got {self.min_content_separation}")
if not 1.0 <= self.extend_threshold_screens <= 10.0:
errors.append(
"extend_threshold_screens must be between 1.0 and 10.0, "
f"got {self.extend_threshold_screens}")
if not 1 <= self.min_cut_gap <= 128:
errors.append(
"min_cut_gap must be between 1 and 128, "
f"got {self.min_cut_gap}")
if self.intra_plugin_gap < 0:
errors.append(
f"intra_plugin_gap must be >= 0, got {self.intra_plugin_gap}")
if self.intra_plugin_gap > 128:
errors.append(
f"intra_plugin_gap must be <= 128, got {self.intra_plugin_gap}")
if not 0 <= self.trim_threshold <= 254:
errors.append(
f"trim_threshold must be between 0 and 254, got {self.trim_threshold}")
if self.content_padding < 0:
errors.append(
f"content_padding must be >= 0, got {self.content_padding}")
if self.content_padding > 128:
errors.append(
f"content_padding must be <= 128, got {self.content_padding}")
if self.min_plugin_width < 0:
errors.append(
f"min_plugin_width must be >= 0, got {self.min_plugin_width}")
# Bounded because every segment narrower than this is dropped — an
# unbounded value would discard every plugin and leave a blank ticker.
if self.min_plugin_width > 512:
errors.append(
f"min_plugin_width must be <= 512, got {self.min_plugin_width}")
if self.lead_in_width < 0:
errors.append(
f"lead_in_width must be >= 0, got {self.lead_in_width}")
if self.plugins_per_cycle < 1:
errors.append(
f"plugins_per_cycle must be >= 1, got {self.plugins_per_cycle}")
if self.plugins_per_cycle > 50:
errors.append(
f"plugins_per_cycle must be <= 50, got {self.plugins_per_cycle}")
if self.overflow_mode not in ('rotate', 'truncate'):
errors.append(
"overflow_mode must be 'rotate' or 'truncate', "
f"got {self.overflow_mode!r}")
if self.max_plugin_width_ratio < 0:
errors.append(
"max_plugin_width_ratio must be >= 0 "
f"(0 disables the cap), got {self.max_plugin_width_ratio}")
return errors
def update(self, new_config: Dict[str, Any]) -> None:
@@ -174,6 +363,39 @@ class VegasModeConfig:
self.scroll_speed = float(vegas_config['scroll_speed'])
if 'separator_width' in vegas_config:
self.separator_width = int(vegas_config['separator_width'])
if 'intra_plugin_gap' in vegas_config:
self.intra_plugin_gap = int(vegas_config['intra_plugin_gap'])
if 'render_width_pct' in vegas_config:
self.render_width_pct = int(vegas_config['render_width_pct'])
if 'min_content_separation' in vegas_config:
self.min_content_separation = int(
vegas_config['min_content_separation'])
if 'min_cut_gap' in vegas_config:
self.min_cut_gap = int(vegas_config['min_cut_gap'])
if 'smooth_scroll' in vegas_config:
self.smooth_scroll = vegas_config['smooth_scroll']
if 'continuous_scroll' in vegas_config:
self.continuous_scroll = vegas_config['continuous_scroll']
if 'extend_threshold_screens' in vegas_config:
self.extend_threshold_screens = float(
vegas_config['extend_threshold_screens'])
if 'auto_trim' in vegas_config:
self.auto_trim = vegas_config['auto_trim']
if 'trim_threshold' in vegas_config:
self.trim_threshold = int(vegas_config['trim_threshold'])
if 'content_padding' in vegas_config:
self.content_padding = int(vegas_config['content_padding'])
if 'min_plugin_width' in vegas_config:
self.min_plugin_width = int(vegas_config['min_plugin_width'])
if 'lead_in_width' in vegas_config:
self.lead_in_width = int(vegas_config['lead_in_width'])
if 'plugins_per_cycle' in vegas_config:
self.plugins_per_cycle = int(vegas_config['plugins_per_cycle'])
if 'max_plugin_width_ratio' in vegas_config:
self.max_plugin_width_ratio = float(
vegas_config['max_plugin_width_ratio'])
if 'overflow_mode' in vegas_config:
self.overflow_mode = str(vegas_config['overflow_mode'])
if 'plugin_order' in vegas_config:
self.plugin_order = list(vegas_config['plugin_order'])
if 'excluded_plugins' in vegas_config:
+64 -13
View File
@@ -64,7 +64,7 @@ class VegasModeCoordinator:
self.plugin_manager = plugin_manager
# Initialize components
self.plugin_adapter = PluginAdapter(display_manager)
self.plugin_adapter = PluginAdapter(display_manager, self.vegas_config)
self.stream_manager = StreamManager(
self.vegas_config,
plugin_manager,
@@ -233,6 +233,11 @@ class VegasModeCoordinator:
self._should_stop = False
self._start_time = time.time()
# Line up the next group immediately, so the first extension is already
# warm rather than stalling the scroll to fetch it.
if self.vegas_config.continuous_scroll:
self.render_pipeline.start_prefetch()
logger.info("Vegas mode started")
return True
@@ -301,16 +306,43 @@ class VegasModeCoordinator:
if has_pending_update:
self._apply_pending_config()
# Check if we need to start a new cycle
if self.render_pipeline.is_cycle_complete():
if not self.render_pipeline.start_new_cycle():
logger.warning("Failed to start new Vegas cycle")
return False
self.stats['cycles_completed'] += 1
if self.vegas_config.continuous_scroll:
# Drop cached content for plugins whose data just changed, so the
# next time each comes round it is composed from current data. The
# swap path's hot_swap_content() does this via process_updates(),
# but it also rebuilds and repositions the whole strip, which is
# the freeze-and-jump this mode exists to avoid. Without this the
# pending-update flags are never consumed and a segment keeps
# rendering whatever it was first built from — last night's live
# game still shown as live the next morning.
self.render_pipeline.refresh_updated_plugins()
# Check for hot-swap opportunities
if self.render_pipeline.should_recompose():
self.render_pipeline.hot_swap_content()
# Extend the strip before the scroll can reach its end, so the next
# group arrives from the right and motion never stops. No cycle
# boundary, so no freeze, no substitution and no restart with the
# viewport already full.
# Trickle in the plugins that can only be fetched here, one per
# frame, before considering a further extension.
if self.render_pipeline.has_deferred():
self.render_pipeline.drain_deferred()
elif self.render_pipeline.needs_extension():
if self.render_pipeline.extend_scroll_content():
self.stats['cycles_completed'] += 1
elif self.render_pipeline.is_cycle_complete():
# Extension failed and the strip has run out: fall back to
# the swap rather than sitting on a dead frame.
self.render_pipeline.start_new_cycle()
else:
# Check if we need to start a new cycle
if self.render_pipeline.is_cycle_complete():
if not self.render_pipeline.start_new_cycle():
logger.warning("Failed to start new Vegas cycle")
return False
self.stats['cycles_completed'] += 1
# Check for hot-swap opportunities
if self.render_pipeline.should_recompose():
self.render_pipeline.hot_swap_content()
# Render frame
return self.render_pipeline.render_frame()
@@ -337,7 +369,14 @@ class VegasModeCoordinator:
self._update_static_mode_plugins()
frame_interval = self.vegas_config.get_frame_interval()
duration = self.render_pipeline.get_dynamic_duration()
if self.vegas_config.continuous_scroll:
# The strip is continuously extended and trimmed, so its width says
# nothing about how long to run. This is only how often control
# returns to the display controller; interrupts are still checked
# every few frames, so it costs nothing to make it a fixed period.
duration = float(self.vegas_config.max_cycle_duration)
else:
duration = self.render_pipeline.get_dynamic_duration()
start_time = time.time()
frame_count = 0
fps_log_interval = 5.0 # Log FPS every 5 seconds
@@ -347,6 +386,8 @@ class VegasModeCoordinator:
logger.info("Starting Vegas iteration for %.1fs", duration)
while True:
frame_started = time.time()
# Check for STATIC mode plugin that should pause scroll
static_plugin = self._check_static_plugin_trigger()
if static_plugin:
@@ -367,8 +408,14 @@ class VegasModeCoordinator:
# Paused for live priority - let caller handle
return False
# Sleep for frame interval
time.sleep(frame_interval)
# Sleep only the remainder of the frame budget. This used to sleep
# the whole interval on top of however long the frame took, so at a
# measured 31.6ms per frame a fixed 8ms of that was pure idle — a
# quarter of the budget spent not rendering. Subtracting the work
# already done keeps the pacing target while reclaiming that time,
# and yields the GIL either way so other threads still run.
frame_elapsed = time.time() - frame_started
time.sleep(max(0.0, frame_interval - frame_elapsed))
# Increment frame count and check for interrupt periodically
frame_count += 1
@@ -505,6 +552,10 @@ class VegasModeCoordinator:
# Update components
self.render_pipeline.update_config(new_vegas_config)
self.stream_manager.config = new_vegas_config
self.plugin_adapter.config = new_vegas_config
# Cached segments were trimmed under the old settings, so drop them
# or a changed trim/padding value would not visibly take effect.
self.plugin_adapter.invalidate_cache()
# Force refresh of stream manager to pick up plugin_order/buffer changes
self.stream_manager._last_refresh = 0
+474
View File
@@ -0,0 +1,474 @@
"""
Geometry primitives for Vegas Mode.
Pure, side-effect-free measurements over PIL images. Two consumers:
- ``PluginAdapter`` trims the blank margins plugins bake into their content
before it enters the ticker (see ``trim_to_content``).
- ``scripts/dev/vegas_audit.py`` reports how much of the composed ticker is
dead space (see ``dead_window_stats``).
Keeping both on the same primitives means the number the audit reports is the
number the trimmer acted on.
All column scans go through numpy: a Python-level per-column loop over a
17,000px-wide ticker image takes seconds, which is far too slow for the render
path.
"""
from typing import List, NamedTuple, Optional, Tuple
import numpy as np
from PIL import Image
# A pixel counts as "ink" when any channel exceeds this. Chosen to ignore the
# 1-2/255 noise that JPEG-sourced logos and alpha compositing leave behind in
# nominally black areas, while still treating any deliberately drawn dark grey
# as real content.
DEFAULT_INK_THRESHOLD = 10
# A window counts as "dead" when this fraction of its columns carry no ink.
DEFAULT_DEAD_WINDOW_RATIO = 0.95
def column_has_ink(img: Image.Image, threshold: int = DEFAULT_INK_THRESHOLD) -> np.ndarray:
"""
Return a boolean array, one entry per image column, True where the column
contains at least one pixel brighter than ``threshold`` in any channel.
Args:
img: Image to scan (converted to RGB internally)
threshold: Per-channel value a pixel must exceed to count as ink
Returns:
Bool array of shape (width,)
"""
arr = np.asarray(img if img.mode == 'RGB' else img.convert('RGB'))
if arr.ndim != 3:
# Degenerate/empty image — treat every column as blank.
return np.zeros(img.width, dtype=bool)
# Collapse rows and channels: a column is ink if any pixel in it is bright.
return arr.max(axis=(0, 2)) > threshold
def content_bounds(
img: Image.Image, threshold: int = DEFAULT_INK_THRESHOLD
) -> Optional[Tuple[int, int]]:
"""
Find the first and last columns containing ink.
Args:
img: Image to measure
threshold: Ink threshold
Returns:
(first_col, last_col) inclusive, or None if the image is entirely blank
"""
ink = column_has_ink(img, threshold)
if not ink.any():
return None
first = int(ink.argmax())
last = len(ink) - 1 - int(ink[::-1].argmax())
return first, last
class TrimResult(NamedTuple):
"""Outcome of a ``trim_to_content`` call."""
image: Optional[Image.Image] # None when the source was entirely blank
original_width: int
trimmed_left: int
trimmed_right: int
@property
def is_blank(self) -> bool:
"""True when the source image carried no ink at all."""
return self.image is None
@property
def width(self) -> int:
"""Width after trimming (0 for a blank source)."""
return 0 if self.image is None else self.image.width
@property
def removed(self) -> int:
"""Total columns removed."""
return self.trimmed_left + self.trimmed_right
def trim_to_content(
img: Image.Image,
threshold: int = DEFAULT_INK_THRESHOLD,
padding: int = 0,
) -> TrimResult:
"""
Crop blank columns off the left and right edges of an image.
Only the outer edges are considered. Blank columns *between* two pieces of
content are deliberately preserved those are the plugin's own layout
(e.g. a logo on the left and a score on the right), and closing them up
would corrupt the design rather than reclaim dead space.
A plugin drawing on a non-black background is unaffected: every column of a
filled background carries ink, so there is nothing to trim.
Args:
img: Image to trim
threshold: Ink threshold
padding: Columns of the original blank margin to keep on each side, as
breathing room. Capped at what the margin actually contains, so
this never widens the image beyond its original bounds.
Returns:
TrimResult. When the image is entirely blank, ``image`` is None and the
caller decides whether to skip the plugin.
"""
bounds = content_bounds(img, threshold)
if bounds is None:
return TrimResult(None, img.width, 0, 0)
first, last = bounds
pad = max(0, padding)
left = max(0, first - pad)
right = min(img.width, last + 1 + pad)
if left == 0 and right == img.width:
return TrimResult(img, img.width, 0, 0)
cropped = img.crop((left, 0, right, img.height))
return TrimResult(cropped, img.width, left, img.width - right)
def edge_blank(
img: Image.Image, threshold: int = DEFAULT_INK_THRESHOLD
) -> Tuple[int, int]:
"""
Blank column counts at the left and right edges of an image.
Used to space items by *measured* separation rather than a flat added gap.
A fixed gap gets this wrong in both directions at once: card-style content
drawn flush to its own edges ends up nearly touching its neighbour, while
content that already carries wide margins gets pushed even further apart.
Args:
img: Image to measure
threshold: Ink threshold
Returns:
(left_blank, right_blank). For an entirely blank image both are the
full width, since there is no ink to be close to.
"""
bounds = content_bounds(img, threshold)
if bounds is None:
return img.width, img.width
first, last = bounds
return first, img.width - 1 - last
def separation_gap(
left_img: Image.Image,
right_img: Image.Image,
target: int,
minimum: int = 0,
threshold: int = DEFAULT_INK_THRESHOLD,
) -> int:
"""
Columns to insert between two images so their ink is ``target`` apart.
Only the shortfall is added: if the two images already carry enough blank
at the facing edges, nothing (beyond ``minimum``) is inserted.
Args:
left_img: Image on the left
right_img: Image on the right
target: Desired blank columns between the two pieces of ink
minimum: Floor applied regardless of what the images already have
threshold: Ink threshold
Returns:
Number of columns to insert, never negative
"""
existing = edge_blank(left_img, threshold)[1] + edge_blank(right_img, threshold)[0]
return max(minimum, target - existing, 0)
def blank_runs(
img: Image.Image,
min_run: int,
threshold: int = DEFAULT_INK_THRESHOLD,
) -> List[Tuple[int, int]]:
"""
Find maximal runs of blank columns at least ``min_run`` wide.
Distinguishes item boundaries from letter spacing. Measured on real
rendered text, the gaps *between characters* are a single column, while the
gaps a plugin puts *between items* are 8px and up (the stocks ticker uses
32px, baseball 48px). Treating any blank column as a cut point therefore
slices words in half; requiring a run excludes letter spacing.
Args:
img: Image to scan
min_run: Minimum consecutive blank columns to qualify
threshold: Ink threshold
Returns:
List of (start, end) half-open column ranges, in left-to-right order
"""
blank = ~column_has_ink(img, threshold)
if not blank.any():
return []
# Vectorised run detection: pad with False so runs touching either edge get
# a boundary, then read starts and ends off the first difference. A Python
# loop here would be far too slow on a 17,000px ticker strip.
padded = np.concatenate(([False], blank, [False]))
diff = np.diff(padded.astype(np.int8))
starts = np.flatnonzero(diff == 1)
ends = np.flatnonzero(diff == -1)
long_enough = (ends - starts) >= max(1, min_run)
return list(zip(starts[long_enough].tolist(), ends[long_enough].tolist()))
def find_item_boundary(
img: Image.Image,
target: int,
min_run: int,
threshold: int = DEFAULT_INK_THRESHOLD,
) -> Optional[int]:
"""
Find the column nearest ``target`` that sits inside a gap between items.
Used to narrow an oversized segment without cutting through a word. Only
runs of at least ``min_run`` blank columns are considered, so the
single-column gaps between characters are never chosen cutting there
orphaned the tail of a word into the following cycle, which is how a lone
"y" from "Wednesday" ended up floating between two unrelated plugins.
Args:
img: Image to cut
target: Preferred cut column
min_run: Minimum blank-run width that counts as an item boundary
threshold: Ink threshold
Returns:
A column inside a qualifying gap, or None when the image has no such
gap at all in which case the caller must not cut it.
"""
runs = blank_runs(img, min_run, threshold)
if not runs:
return None
# Nearest point of the nearest run. For a run left of target that is its
# end (content resumes just after), for a run right of target its start
# (content stopped just before) — the right choice in both directions.
def clamp_to_run(run: Tuple[int, int]) -> int:
start, end = run
return max(start, min(target, end - 1))
return min((clamp_to_run(r) for r in runs), key=lambda c: abs(c - target))
def find_blank_cut(
img: Image.Image,
target: int,
search_radius: int,
threshold: int = DEFAULT_INK_THRESHOLD,
) -> int:
"""
Find a column near ``target`` that carries no ink, so an image can be cut
there without slicing through a glyph or logo.
Used when a single oversized segment has to be narrowed to fit a width
budget. Cutting at an arbitrary column would leave half a character
hanging at the panel edge; snapping to the nearest gap hides the cut.
Args:
img: Image to cut
target: Preferred cut column
search_radius: How far either side of ``target`` to look
threshold: Ink threshold
Returns:
A blank column within the search window, or ``target`` clamped to the
image bounds when the window contains no blank column at all.
"""
width = img.width
target = max(0, min(target, width))
if search_radius <= 0 or width == 0:
return target
ink = column_has_ink(img, threshold)
# target may legitimately equal width (a cut after the last column), but
# there is no column to inspect there, so both bounds stop at width - 1.
lo = max(0, min(target - search_radius, width - 1))
hi = max(0, min(target + search_radius, width - 1))
# Walk outwards from target so the nearest gap wins.
for offset in range(0, search_radius + 1):
right = target + offset
if lo <= right <= hi and not ink[right]:
return right
left = target - offset
if lo <= left <= hi and not ink[left]:
return left
return target
class DeadWindowStats(NamedTuple):
"""How much of a composed ticker reads as blank to a viewer."""
total_windows: int
dead_windows: int
longest_dead_run: int # consecutive dead windows (i.e. scroll steps)
@property
def dead_ratio(self) -> float:
"""Fraction of viewport positions that are effectively blank."""
if self.total_windows <= 0:
return 0.0
return self.dead_windows / self.total_windows
def dead_window_stats(
img: Image.Image,
viewport_width: int,
threshold: int = DEFAULT_INK_THRESHOLD,
dead_ratio: float = DEFAULT_DEAD_WINDOW_RATIO,
step: int = 1,
) -> DeadWindowStats:
"""
Slide a viewport across a composed ticker image and count how many
positions are effectively blank.
This models what the viewer actually experiences: the ticker is only ever
seen ``viewport_width`` columns at a time, so a stretch of blank wider than
the viewport becomes a period where the panel looks switched off. Measuring
per-window rather than per-column is what makes the result correspond to
perceived dead time.
Args:
img: Composed ticker image
viewport_width: Display width in pixels
threshold: Ink threshold
dead_ratio: Fraction of blank columns for a window to count as dead
step: Column stride between sampled windows. 1 is exact; larger values
trade precision for speed on very wide images.
Returns:
DeadWindowStats. ``longest_dead_run`` is in units of ``step`` columns,
so multiply by ``step`` for pixels.
"""
if viewport_width <= 0 or img.width <= 0:
return DeadWindowStats(0, 0, 0)
ink = column_has_ink(img, threshold)
step = max(1, step)
# Prefix sum of ink counts lets each window be evaluated in constant time,
# instead of re-summing viewport_width columns per position.
prefix = np.concatenate(([0], np.cumsum(ink)))
# Only whole windows are sampled; a partial tail window would report
# artificially dead because it has fewer columns to draw ink from.
last_start = img.width - viewport_width
if last_start < 0:
# Image narrower than the viewport — evaluate it as a single window.
blank_cols = len(ink) - int(prefix[-1])
is_dead = blank_cols >= dead_ratio * len(ink)
return DeadWindowStats(1, 1 if is_dead else 0, 1 if is_dead else 0)
starts = np.arange(0, last_start + 1, step)
ink_counts = prefix[starts + viewport_width] - prefix[starts]
blank_counts = viewport_width - ink_counts
dead = blank_counts >= dead_ratio * viewport_width
longest = _longest_true_run(dead)
return DeadWindowStats(len(starts), int(dead.sum()), longest)
class CoverageStats(NamedTuple):
"""How well-filled the viewport stays as the ticker scrolls past."""
total_windows: int
mean_ink_ratio: float # average fraction of the viewport carrying ink
min_ink_ratio: float # worst viewport position in the cycle
sparse_windows: int # positions below the "looks empty" threshold
longest_sparse_run: int # consecutive sparse positions, in steps
@property
def sparse_ratio(self) -> float:
"""Fraction of viewport positions that read as near-empty."""
if self.total_windows <= 0:
return 0.0
return self.sparse_windows / self.total_windows
def window_coverage_stats(
img: Image.Image,
viewport_width: int,
threshold: int = DEFAULT_INK_THRESHOLD,
sparse_ink_ratio: float = 0.10,
step: int = 1,
) -> CoverageStats:
"""
Measure how full the viewport stays across a whole scroll cycle.
``dead_window_stats`` only catches viewport positions that are *entirely*
blank. That misses the more common complaint: a position holding one narrow
sliver of content at the very edge, with the other 90% black. Such a
position is not "dead" by that definition but still looks switched off.
This function grades every position by how much ink it carries, so
"there is always something to see" becomes measurable.
Args:
img: Composed ticker image
viewport_width: Display width in pixels
threshold: Ink threshold
sparse_ink_ratio: A position with less than this fraction of inked
columns counts as reading near-empty
step: Column stride between sampled positions
Returns:
CoverageStats
"""
if viewport_width <= 0 or img.width <= 0:
return CoverageStats(0, 0.0, 0.0, 0, 0)
ink = column_has_ink(img, threshold)
step = max(1, step)
prefix = np.concatenate(([0], np.cumsum(ink)))
last_start = img.width - viewport_width
if last_start < 0:
ratio = float(prefix[-1]) / viewport_width
sparse = ratio < sparse_ink_ratio
return CoverageStats(1, ratio, ratio, 1 if sparse else 0, 1 if sparse else 0)
starts = np.arange(0, last_start + 1, step)
ratios = (prefix[starts + viewport_width] - prefix[starts]) / viewport_width
sparse_flags = ratios < sparse_ink_ratio
return CoverageStats(
total_windows=len(starts),
mean_ink_ratio=float(ratios.mean()),
min_ink_ratio=float(ratios.min()),
sparse_windows=int(sparse_flags.sum()),
longest_sparse_run=_longest_true_run(sparse_flags),
)
def _longest_true_run(flags: np.ndarray) -> int:
"""Length of the longest consecutive run of True in a boolean array."""
if flags.size == 0 or not flags.any():
return 0
# Reset a running counter at every False by subtracting the cumulative max
# of the counter's value at the preceding False positions.
idx = np.arange(len(flags))
not_flag = ~flags
# For each position, the index of the most recent False at or before it.
last_false = np.maximum.accumulate(np.where(not_flag, idx, -1))
run_lengths = idx - last_false
return int(run_lengths[flags].max())
+535 -16
View File
@@ -8,9 +8,16 @@ implement get_vegas_content() and fallback capture of display() output.
import logging
import threading
import time
from contextlib import nullcontext
from typing import Optional, List, Any, Tuple, Union, TYPE_CHECKING
from PIL import Image
from src.vegas_mode.geometry import (
blank_runs,
separation_gap,
trim_to_content,
)
if TYPE_CHECKING:
from src.plugin_system.base_plugin import BasePlugin
@@ -26,14 +33,21 @@ class PluginAdapter:
2. Fallback: Capture display_manager.image after calling plugin.display()
"""
def __init__(self, display_manager: Any):
def __init__(self, display_manager: Any, config: Optional[Any] = None):
"""
Initialize the plugin adapter.
Args:
display_manager: DisplayManager instance for fallback capture
config: VegasModeConfig controlling trim behaviour. When omitted,
trimming runs with the dataclass defaults, so existing callers
and tests keep working unchanged.
"""
self.display_manager = display_manager
if config is None:
from src.vegas_mode.config import VegasModeConfig
config = VegasModeConfig()
self.config = config
# Handle both property and method access patterns
self.display_width = (
display_manager.width() if callable(display_manager.width)
@@ -49,12 +63,18 @@ class PluginAdapter:
self._cache_lock = threading.Lock()
self._cache_ttl = 5.0 # Cache for 5 seconds
# Per-plugin rotation offset, so a plugin whose content exceeds its
# width budget shows a different slice on each cycle rather than
# always the same opening items.
self._item_offsets: dict = {}
logger.info(
"PluginAdapter initialized: display=%dx%d",
self.display_width, self.display_height
)
def get_content(self, plugin: 'BasePlugin', plugin_id: str) -> Optional[List[Image.Image]]:
def get_content(self, plugin: 'BasePlugin', plugin_id: str,
offscreen_only: bool = False) -> Optional[List[Image.Image]]:
"""
Get scrollable content from a plugin.
@@ -63,6 +83,13 @@ class PluginAdapter:
Args:
plugin: Plugin instance to get content from
plugin_id: Plugin identifier for logging
offscreen_only: Skip every path that touches the shared display
canvas, for callers running off the render thread. The canvas
and the matrix proxy are process-wide mutable state, so
narrowing or capturing through them from another thread would
corrupt the frame the render loop is pushing. Returns None when
the plugin can only be served that way, leaving the caller to
fetch it on the render thread.
Returns:
List of PIL Images representing plugin content, or None if no content
@@ -86,32 +113,38 @@ class PluginAdapter:
has_native = hasattr(plugin, 'get_vegas_content')
logger.info("[%s] Has get_vegas_content: %s", plugin_id, has_native)
if has_native:
content = self._get_native_content(plugin, plugin_id)
content = self._get_native_content(plugin, plugin_id, offscreen_only)
if content:
total_width = sum(img.width for img in content)
logger.info(
"[%s] Native content SUCCESS: %d images, %dpx total",
plugin_id, len(content), total_width
)
self._cache_content(plugin_id, content)
return content
return self._finalize(content, plugin_id, 'native', plugin)
logger.info("[%s] Native content returned None", plugin_id)
# Try to get scroll_helper's cached image (for scrolling plugins like stocks/odds)
has_scroll_helper = hasattr(plugin, 'scroll_helper')
logger.info("[%s] Has scroll_helper: %s", plugin_id, has_scroll_helper)
content = self._get_scroll_helper_content(plugin, plugin_id)
content = self._get_scroll_helper_content(plugin, plugin_id, offscreen_only)
if content:
total_width = sum(img.width for img in content)
logger.info(
"[%s] ScrollHelper content SUCCESS: %d images, %dpx total",
plugin_id, len(content), total_width
)
self._cache_content(plugin_id, content)
return content
return self._finalize(content, plugin_id, 'scroll_helper', plugin)
if has_scroll_helper:
logger.info("[%s] ScrollHelper content returned None", plugin_id)
if offscreen_only:
# Display capture needs the shared canvas; leave it to the caller.
logger.info(
"[%s] Needs display capture, deferring to the render thread",
plugin_id
)
return None
# Fall back to display capture
logger.info("[%s] Trying fallback display capture...", plugin_id)
content = self._capture_display_content(plugin, plugin_id)
@@ -121,8 +154,7 @@ class PluginAdapter:
"[%s] Fallback capture SUCCESS: %d images, %dpx total",
plugin_id, len(content), total_width
)
self._cache_content(plugin_id, content)
return content
return self._finalize(content, plugin_id, 'fallback', plugin)
logger.warning(
"[%s] NO CONTENT from any method (native=%s, scroll_helper=%s, fallback=tried)",
@@ -130,8 +162,397 @@ class PluginAdapter:
)
return None
def _finalize(
self, images: List[Image.Image], plugin_id: str, source: str,
plugin: Optional['BasePlugin'] = None
) -> Optional[List[Image.Image]]:
"""
Trim dead space off a segment, then cache it.
Every content path funnels through here so trimming is applied
uniformly. Previously only the scroll_helper path had its margins
stripped, which left plugins that render onto a full-display canvas
contributing their entire blank canvas to the ticker.
Each image is trimmed independently because compose_scroll_content()
treats every image as its own item and inserts separator_width between
them so a per-image trim is what makes that separator the real gap.
Args:
images: Raw content from one of the fetch paths
plugin_id: Plugin identifier for logging
source: Which path produced the content, for logging
Returns:
Trimmed image list, or None if nothing worth showing remains
"""
if not self.config.auto_trim:
# Trimming is off, but the width budget is a separate concern —
# turning off margin cropping should not let one plugin hold the
# panel for minutes. Skipping it here previously let a 14,848px
# segment through untouched.
kept = self._apply_width_budget(list(images), plugin_id, plugin)
self._cache_content(plugin_id, kept)
return kept
original_width = sum(img.width for img in images)
kept: List[Image.Image] = []
dropped_blank = 0
for img in images:
result = trim_to_content(
img,
threshold=self.config.trim_threshold,
padding=self.config.content_padding,
)
if result.is_blank:
dropped_blank += 1
continue
kept.append(result.image)
if not kept:
logger.info(
"[%s] All %d image(s) from %s were blank — contributing nothing",
plugin_id, len(images), source
)
return None
trimmed_width = sum(img.width for img in kept)
if trimmed_width < self.config.min_plugin_width:
logger.info(
"[%s] Trimmed content %dpx is below min_plugin_width %dpx — skipping",
plugin_id, trimmed_width, self.config.min_plugin_width
)
return None
if trimmed_width != original_width or dropped_blank:
logger.info(
"[%s] Trimmed %s content: %dpx -> %dpx (%.0f%% reclaimed), "
"%d image(s) kept, %d blank dropped",
plugin_id, source, original_width, trimmed_width,
100.0 * (original_width - trimmed_width) / original_width
if original_width else 0.0,
len(kept), dropped_blank
)
kept = self._apply_width_budget(kept, plugin_id, plugin)
self._cache_content(plugin_id, kept)
return kept
def _capture(self):
"""
Context manager suppressing hardware writes while plugin render code runs.
Degrades to a no-op when the display manager predates capture_mode. As
with _render_at, losing the suppression risks a visible flash, whereas
raising would be swallowed by the broad handlers upstream and drop the
plugin's content entirely — much worse.
"""
capture_mode = getattr(self.display_manager, 'capture_mode', None)
if capture_mode is None:
logger.debug(
"display_manager has no capture_mode(); plugin writes during "
"content capture may reach the panel"
)
return nullcontext()
return capture_mode()
def _render_at(self, width: int):
"""
Context manager narrowing the plugin-facing canvas to ``width``.
Degrades to a no-op when the display manager predates render_size (a
third-party or older test harness). Losing the narrowing is a cosmetic
regression; raising here would be caught by the broad handlers upstream
and silently drop the plugin's content entirely.
"""
render_size = getattr(self.display_manager, 'render_size', None)
if render_size is None:
logger.debug(
"display_manager has no render_size(); Vegas width requests "
"will be ignored"
)
return nullcontext()
return render_size(width)
def resolve_render_width(self, plugin: 'BasePlugin', plugin_id: str) -> int:
"""
Width to tell a plugin it has while it renders for the ticker.
Resolution order, most specific first:
1. the plugin's own ``vegas_width_pct`` config value
2. the global ``vegas_scroll.render_width_pct``
3. the full panel width
A percentage rather than an absolute width so one setting travels
across panel sizes.
Args:
plugin: Plugin instance, consulted for a per-plugin override
plugin_id: Plugin identifier for logging
Returns:
Target width in pixels, never wider than the panel
"""
pct = self.config.render_width_pct
plugin_cfg = getattr(plugin, 'config', None)
if isinstance(plugin_cfg, dict):
raw = plugin_cfg.get('vegas_width_pct')
if raw not in (None, ''):
try:
candidate = int(raw)
except (TypeError, ValueError):
logger.warning(
"[%s] Invalid vegas_width_pct %r, ignoring", plugin_id, raw)
else:
if 10 <= candidate <= 100:
pct = candidate
else:
logger.warning(
"[%s] vegas_width_pct %d out of range 10-100, ignoring",
plugin_id, candidate)
if pct >= 100:
return self.display_width
return max(1, int(self.display_width * pct / 100))
def _row_gap(self, left: Image.Image, right: Image.Image) -> int:
"""
Gap the compositor will insert between two of a plugin's rows.
Mirrors RenderPipeline._join_plugin_rows so the width budget measures
what will actually be rendered.
"""
return separation_gap(
left, right,
target=max(0, self.config.min_content_separation),
minimum=max(0, self.config.intra_plugin_gap),
threshold=self.config.trim_threshold,
)
def _plugin_setting(self, plugin: 'BasePlugin', key: str):
"""Read a per-plugin config override, or None if absent."""
plugin_cfg = getattr(plugin, 'config', None)
if not isinstance(plugin_cfg, dict):
return None
value = plugin_cfg.get(key)
return None if value in (None, '') else value
def resolve_overflow_mode(self, plugin: 'BasePlugin', plugin_id: str) -> str:
"""
How to handle content that exceeds this plugin's width budget.
'rotate' advances a window each cycle so everything is seen eventually,
which suits interchangeable items. 'truncate' always shows the start,
which suits ordered content a league table that shows ranks 1-6 and
then resumes at 7 two rotations later reads as out of order, and nobody
needs rank 23 in a ticker anyway.
Per-plugin ``vegas_overflow`` wins over the global ``overflow_mode``.
"""
raw = self._plugin_setting(plugin, 'vegas_overflow')
if raw is not None:
candidate = str(raw).strip().lower()
if candidate in ('rotate', 'truncate'):
return candidate
logger.warning(
"[%s] Invalid vegas_overflow %r, expected 'rotate' or 'truncate'",
plugin_id, raw
)
return self.config.overflow_mode
def _width_budget(self, plugin: Optional['BasePlugin'] = None,
plugin_id: str = '') -> int:
"""
Maximum columns one plugin may occupy in a cycle. 0 means unlimited.
A per-plugin ``vegas_max_width_screens`` overrides the global ratio, so
content that has to stay whole can be given room (or uncapped with 0)
without lifting the cap on every ticker.
"""
ratio = self.config.max_plugin_width_ratio
if plugin is not None:
raw = self._plugin_setting(plugin, 'vegas_max_width_screens')
if raw is not None:
try:
candidate = float(raw)
except (TypeError, ValueError):
logger.warning(
"[%s] Invalid vegas_max_width_screens %r, ignoring",
plugin_id, raw
)
else:
if candidate >= 0:
ratio = candidate
else:
logger.warning(
"[%s] vegas_max_width_screens must be >= 0, got %s",
plugin_id, candidate
)
if ratio <= 0:
return 0
return int(self.display_width * ratio)
def _apply_width_budget(
self, images: List[Image.Image], plugin_id: str,
plugin: Optional['BasePlugin'] = None
) -> List[Image.Image]:
"""
Hold one plugin to its share of a cycle.
A ticker returning 7,000px would otherwise own the panel for over two
minutes, which defeats the point of a rotation. Overflow is deferred
rather than discarded: the starting offset advances each time this
plugin is fetched, so later items appear on subsequent cycles instead
of never being seen.
Args:
images: Trimmed images for this plugin
plugin_id: Plugin identifier, used to track its rotation offset
Returns:
Images that fit the budget, starting from the plugin's current
rotation offset.
"""
budget = self._width_budget(plugin, plugin_id)
mode = (self.resolve_overflow_mode(plugin, plugin_id)
if plugin is not None else self.config.overflow_mode)
# Count the gaps the compositor will actually insert, not just the
# pixels of the rows — otherwise a plugin with many rows quietly
# occupies far more of the panel than its budget allows. These must use
# the same measured rule as RenderPipeline._join_plugin_rows; assuming
# the flat intra_plugin_gap here under-counted by up to
# (min_content_separation - intra_plugin_gap) per row.
total = sum(img.width for img in images) + sum(
self._row_gap(images[i], images[i + 1]) for i in range(len(images) - 1)
)
if not budget or total <= budget:
# Fits, so reset rotation — the whole segment is being shown.
self._item_offsets.pop(plugin_id, None)
return images
if len(images) == 1:
return [self._crop_to_budget(images[0], budget, plugin_id, mode)]
if mode == 'truncate':
# Ordered content: always show from the top. Deliberately does not
# advance the offset, so the same opening items appear every time
# rather than the viewer being shown the middle of a ranked list.
start = 0
else:
start = self._item_offsets.get(plugin_id, 0) % len(images)
selected: List[Image.Image] = []
used = 0
consumed = 0
# Walk forward from the rotation offset, taking whole items only, so a
# cut never lands in the middle of one.
for step in range(len(images)):
img = images[(start + step) % len(images)]
cost = img.width
if selected:
cost += self._row_gap(selected[-1], img)
if selected and used + cost > budget:
break
selected.append(img)
used += cost
consumed += 1
if mode == 'truncate':
logger.info(
"[%s] Width budget %dpx: showing the first %d of %d row(s) "
"(%dpx incl. gaps); the rest are not shown (overflow=truncate)",
plugin_id, budget, len(selected), len(images), used
)
else:
self._item_offsets[plugin_id] = (start + consumed) % len(images)
logger.info(
"[%s] Width budget %dpx: showing %d of %d row(s) (%dpx incl. gaps) "
"from offset %d; remainder deferred to a later cycle",
plugin_id, budget, len(selected), len(images), used, start
)
return selected
def _crop_to_budget(
self, img: Image.Image, budget: int, plugin_id: str,
mode: str = 'rotate'
) -> Image.Image:
"""
Narrow a single oversized image to the budget, advancing a window
through it across cycles.
The cut is snapped to the nearest blank column so it does not slice
through a glyph or logo and leave half a character at the panel edge.
"""
if mode == 'truncate':
# Always the start of the strip, so a ranked table is never entered
# from the middle.
offset = 0
else:
offset = self._item_offsets.get(plugin_id, 0)
if offset >= img.width:
offset = 0
# Cut only where the plugin left a real gap between items. Snapping to
# any blank column used to pick the single-column gaps between
# characters, splitting a word and orphaning its tail into the next
# cycle — a lone "y" from "Wednesday" floating between two unrelated
# plugins. Overshooting the budget is the lesser evil.
min_run = max(2, self.config.min_cut_gap)
gaps = blank_runs(img, min_run, self.config.trim_threshold)
if not gaps:
# No internal gaps means continuous content — a map, a chart, a
# photo — where any column is as good as any other, so cut to the
# budget exactly. The gap rule exists to protect discrete items
# (words, ticker entries); it would be wrong to let a solid image
# escape the cap in its name.
end = min(offset + budget, img.width)
if mode != 'truncate':
self._item_offsets[plugin_id] = 0 if end >= img.width else end
logger.info(
"[%s] Width budget %dpx: cropped continuous %dpx image to "
"[%d:%d] (no item gaps of %dpx+ to align to)%s",
plugin_id, budget, img.width, offset, end, min_run,
"" if mode != 'truncate' else "; showing the start only"
)
return img.crop((offset, 0, end, img.height))
# Cut mid-gap so the content either side keeps some breathing room.
cuts = sorted({0, img.width} | {(a + b) // 2 for a, b in gaps})
start = max((c for c in cuts if c <= offset), default=0)
later = [c for c in cuts if c > start]
if not later:
end = img.width
else:
within = [c for c in later if c <= start + budget]
# No boundary inside the budget: take the next one and overrun,
# because the alternative is cutting through an item.
end = max(within) if within else min(later)
if mode != 'truncate':
# Next cycle resumes where this one stopped; wrap when the strip ends.
self._item_offsets[plugin_id] = 0 if end >= img.width else end
logger.info(
"[%s] Width budget %dpx: cropped single %dpx image to [%d:%d] "
"(%dpx) at item boundaries, %s",
plugin_id, budget, img.width, start, end, end - start,
"showing the start only (overflow=truncate)"
if mode == 'truncate' else "window advances next cycle"
)
return img.crop((start, 0, end, img.height))
def _get_native_content(
self, plugin: 'BasePlugin', plugin_id: str
self, plugin: 'BasePlugin', plugin_id: str, offscreen_only: bool = False
) -> Optional[List[Image.Image]]:
"""
Get content via plugin's native get_vegas_content() method.
@@ -145,7 +566,40 @@ class PluginAdapter:
"""
try:
logger.info("[%s] Native: calling get_vegas_content()", plugin_id)
result = plugin.get_vegas_content()
# Tell the plugin how much width the ticker wants it to use, and
# narrow the canvas for the duration of the call. A plugin that
# sizes its own images from display_manager.matrix.width picks up
# the narrower value with no changes of its own; one that wants to
# be explicit can read get_vegas_render_width().
render_width = self.resolve_render_width(plugin, plugin_id)
if render_width != self.display_width:
logger.info(
"[%s] Native: requesting %dpx instead of %dpx",
plugin_id, render_width, self.display_width
)
plugin._vegas_render_width = render_width
try:
# capture_mode unconditionally, even at full width. Building
# Vegas content is an off-screen operation, but a plugin is free
# to call update_display() while doing it — and outside
# capture_mode that write lands on the hardware, flashing the
# panel mid-scroll. The narrowing context is separate because it
# is a no-op at full width.
if offscreen_only:
# _render_at swaps the shared canvas, so it is unsafe here.
# _vegas_render_width is set regardless: a plugin reading
# get_vegas_render_width() still gets its narrow size, and
# one that only reads matrix.width renders full width and is
# trimmed instead.
with self._capture():
result = plugin.get_vegas_content()
else:
with self._capture(), self._render_at(render_width):
result = plugin.get_vegas_content()
finally:
plugin._vegas_render_width = None
if result is None:
logger.info("[%s] Native: get_vegas_content() returned None", plugin_id)
@@ -223,7 +677,7 @@ class PluginAdapter:
return None
def _get_scroll_helper_content(
self, plugin: 'BasePlugin', plugin_id: str
self, plugin: 'BasePlugin', plugin_id: str, offscreen_only: bool = False
) -> Optional[List[Image.Image]]:
"""
Get content from plugin's scroll_helper if available.
@@ -257,6 +711,13 @@ class PluginAdapter:
"[%s] scroll_helper.cached_image is None, triggering content generation",
plugin_id
)
if offscreen_only:
# Generating it calls display(), which needs the canvas.
logger.info(
"[%s] scroll_helper cache empty; deferring generation "
"to the render thread", plugin_id
)
return None
# Try to trigger scroll content generation
cached_image = self._trigger_scroll_content_generation(
plugin, plugin_id, scroll_helper
@@ -405,7 +866,7 @@ class PluginAdapter:
# Save display state to restore after
original_image = self.display_manager.image.copy()
with self.display_manager.capture_mode():
with self._capture():
# Method 1: Try _create_scrolling_display (stocks pattern)
if hasattr(plugin, '_create_scrolling_display'):
logger.info(
@@ -497,7 +958,18 @@ class PluginAdapter:
# Clear and call plugin display — use capture_mode to suppress hardware writes
# that plugins may trigger internally via update_display().
with self.display_manager.capture_mode():
#
# render_size narrows the canvas the plugin lays out against, so a
# plugin that spreads across the whole panel produces a compact
# arrangement rather than one that has to be cropped afterwards.
render_width = self.resolve_render_width(plugin, plugin_id)
if render_width != self.display_width:
logger.info(
"[%s] Fallback: rendering at %dpx instead of %dpx",
plugin_id, render_width, self.display_width
)
with self._capture(), self._render_at(render_width):
self.display_manager.clear()
logger.info("[%s] Fallback: display cleared, calling display()", plugin_id)
@@ -531,7 +1003,7 @@ class PluginAdapter:
plugin_id
)
# Try once more with force_clear=True
with self.display_manager.capture_mode():
with self._capture(), self._render_at(render_width):
self.display_manager.clear()
plugin.display(force_clear=True)
captured = self.display_manager.image.copy()
@@ -663,6 +1135,53 @@ class PluginAdapter:
else:
self._content_cache.clear()
def invalidate_plugin_scroll_cache(
self, plugin: 'BasePlugin', plugin_id: str
) -> bool:
"""
Drop a plugin's own cached scroll image so its visual is rebuilt.
Invalidating only this adapter's cache is not enough. A plugin that
composes a scroll strip hands back the *same* image every time until its
own cache is cleared the sports plugins' ``get_vegas_content()``
regenerates only "if the cache is empty" so without this a segment
keeps rendering whatever data it was first built from. That is how a
game that was live last night can still be displayed as live the next
morning.
Two layouts to cover: a helper directly on the plugin (stocks, news,
odds-ticker) and one owned by a scroll-display manager (the sports
scoreboards). ``cached_image`` and ``cached_array`` must be cleared
together, since the array is the image's numpy mirror and code paths
read whichever is convenient.
Returns:
True if a cache was found and cleared.
"""
cleared = False
for owner in (plugin, getattr(plugin, '_scroll_manager', None),
getattr(plugin, 'scroll_manager', None)):
if owner is None:
continue
helper = getattr(owner, 'scroll_helper', None)
if helper is None:
continue
try:
if getattr(helper, 'cached_image', None) is not None:
helper.cached_image = None
cleared = True
if getattr(helper, 'cached_array', None) is not None:
helper.cached_array = None
cleared = True
except Exception: # pylint: disable=broad-except
logger.exception(
"[%s] Could not clear scroll cache on %s",
plugin_id, type(owner).__name__
)
if cleared:
logger.debug("[%s] Cleared plugin scroll cache", plugin_id)
return cleared
def get_content_type(self, plugin: 'BasePlugin', plugin_id: str) -> str:
"""
Get the type of content a plugin provides.
+365 -52
View File
@@ -6,6 +6,7 @@ Uses the existing ScrollHelper for numpy-optimized scroll operations.
"""
import logging
import os
import time
import threading
from collections import deque
@@ -14,6 +15,7 @@ from PIL import Image
from src.common.scroll_helper import ScrollHelper
from src.vegas_mode.config import VegasModeConfig
from src.vegas_mode.geometry import separation_gap
from src.vegas_mode.stream_manager import StreamManager
if TYPE_CHECKING:
@@ -34,6 +36,10 @@ class RenderPipeline:
- Track scroll cycle completion
"""
# Minimum gap between fetches of canvas-bound plugins, so their individual
# stalls land in separate moments rather than one run of hitches.
DEFERRED_DRAIN_INTERVAL = 2.0
def __init__(
self,
config: VegasModeConfig,
@@ -66,10 +72,6 @@ class RenderPipeline:
else display_manager.height
)
# Reusable blank frame for cycle-end pushes (allocated lazily,
# re-blacked before each reuse)
self._blank_frame = None
# ScrollHelper for optimized scrolling
self.scroll_helper = ScrollHelper(
self.display_width,
@@ -85,6 +87,14 @@ class RenderPipeline:
self._staging_scroll_image: Optional[Image.Image] = None
self._buffer_lock = threading.Lock()
# Group prepared off the render thread, waiting to be appended.
self._prepared_group = None
# Plugins that need the shared canvas, appended one at a time.
self._deferred_queue: List[str] = []
self._last_drain_time = 0.0
self._prefetch_thread: Optional[threading.Thread] = None
self._prefetch_lock = threading.Lock()
# Render state
self._is_rendering = False
self._cycle_complete = False
@@ -114,6 +124,7 @@ class RenderPipeline:
"""Configure ScrollHelper with current settings."""
self.scroll_helper.set_frame_based_scrolling(self.config.frame_based_scrolling)
self.scroll_helper.set_scroll_delay(self.config.scroll_delay)
self.scroll_helper.set_sub_pixel_scrolling(self.config.smooth_scroll)
# Config scroll_speed is always pixels per second, but ScrollHelper
# interprets it differently based on frame_based_scrolling mode:
@@ -141,23 +152,37 @@ class RenderPipeline:
True if composition successful
"""
try:
# Get all buffered content
images = self.stream_manager.get_all_content_for_composition()
# Content grouped by plugin, so a separator can be placed at the
# plugin boundaries only.
grouped = self.stream_manager.get_grouped_content_for_composition()
if not images:
if not grouped:
logger.warning("No content available for composition")
return False
# Add separator gaps between images
content_with_gaps = []
for i, img in enumerate(images):
content_with_gaps.append(img)
# Collapse each plugin's rows into a single block, joined by
# intra_plugin_gap. ScrollHelper applies one uniform gap between the
# items it is given, so handing it one item per plugin is what makes
# separator_width mean "between plugins" instead of "between every
# row". Without this, a per-row ticker such as the F1 scoreboard got
# the full separator between each of its ~116 rows.
blocks = []
total_rows = 0
for plugin_id, images in grouped:
total_rows += len(images)
blocks.append(self._join_plugin_rows(images))
# Create scrolling image via ScrollHelper
# Create scrolling image via ScrollHelper.
#
# lead_gap is explicit because ScrollHelper otherwise prepends a
# full display width of black — appropriate for a standalone ticker
# scrolling in from off-screen, but in Vegas mode it is charged
# once per cycle and reads as the panel switching off.
self.scroll_helper.create_scrolling_image(
content_items=content_with_gaps,
content_items=blocks,
item_gap=self.config.separator_width,
element_gap=0
element_gap=0,
lead_gap=self.config.lead_in_width
)
# Verify scroll image was created successfully
@@ -177,11 +202,16 @@ class RenderPipeline:
self._cycle_complete = False
logger.info(
"Composed scroll image: %dx%d, %d plugins, %d items",
"Composed scroll image: %dx%d, %d plugin block(s), %d rows, "
"separator=%dpx between plugins, rows spaced to %dpx of ink "
"(min added %dpx)",
self.scroll_helper.cached_image.width if self.scroll_helper.cached_image else 0,
self.display_height,
len(self._segments_in_scroll),
len(images)
len(blocks),
total_rows,
self.config.separator_width,
self.config.min_content_separation,
self.config.intra_plugin_gap,
)
return True
@@ -191,6 +221,264 @@ class RenderPipeline:
logger.exception("Error composing scroll content")
return False
def needs_extension(self) -> bool:
"""
Whether the strip should be extended with the next group of plugins.
Cheap enough to call every frame: it is arithmetic over cached state.
"""
if not self.config.continuous_scroll or not self.scroll_helper.cached_image:
return False
threshold = int(self.display_width * self.config.extend_threshold_screens)
return self.scroll_helper.remaining_unscrolled() <= threshold
def start_prefetch(self) -> None:
"""
Begin preparing the next group in the background, if not already doing so.
This is what makes the join seamless rather than merely continuous:
fetching a group costs 0.5-4.8s (rendering leaderboard and baseball cards
dominates), and doing it on the render thread stalls the scroll for that
long. Off the render thread there is a whole group's scroll time to work
in, so by the time the strip needs extending the content is already sat
waiting.
Only paths that avoid the shared display canvas run here; anything
needing it is marked and picked up on the render thread, where it is
safe. Those are the cheap ones display capture measured 12-14ms
against seconds for the native renders.
"""
if not self.config.continuous_scroll:
return
with self._prefetch_lock:
if self._prefetch_thread is not None and self._prefetch_thread.is_alive():
return
if self._prepared_group is not None:
return # already have one waiting
def _work():
# Deprioritise against the render loop. Linux applies nice
# per-thread, and the heavy lifting here is PIL and numpy work
# that releases the GIL, so the scheduler can actually act on
# it — without this the prefetch competes for the same cores and
# costs frames.
try:
os.nice(10)
except (OSError, AttributeError):
pass
try:
group = self.stream_manager.take_next_group(offscreen_only=True)
except Exception:
logger.exception("Background prefetch failed")
group = []
with self._prefetch_lock:
self._prepared_group = group
self._prefetch_thread = threading.Thread(
target=_work, daemon=True, name="vegas-strip-prefetch")
self._prefetch_thread.start()
def drain_deferred(self) -> bool:
"""
Fetch one queued canvas-bound plugin and append it to the strip.
Called once per frame. These plugins cannot be prepared off the render
thread display capture and scroll-content generation both need the
shared canvas so each costs roughly 290ms here. Doing one at a time
spreads that out instead of stalling for the whole group at once, and the
strip's lookahead means nothing runs dry while they arrive.
The cost is that a deferred plugin appears slightly after the group it
came with, which is a fair trade for a smooth scroll.
Returns:
True if a plugin was appended
"""
if not self._deferred_queue:
return False
# Space the drains out. Each costs 40-600ms, and taking them back to
# back turns one long stall into a train of short ones — barely better.
# With a healthy lookahead there is no hurry, so wait a beat between
# them; when the strip is actually running short, fetch immediately.
threshold = int(self.display_width * self.config.extend_threshold_screens)
urgent = self.scroll_helper.remaining_unscrolled() <= threshold
if not urgent:
now = time.time()
if now - self._last_drain_time < self.DEFERRED_DRAIN_INTERVAL:
return False
self._last_drain_time = now
else:
self._last_drain_time = time.time()
plugin_id = self._deferred_queue.pop(0)
plugins = getattr(self.stream_manager.plugin_manager, 'plugins', {})
plugin = plugins.get(plugin_id)
if plugin is None:
return False
try:
images = self.stream_manager.plugin_adapter.get_content(plugin, plugin_id)
except Exception:
logger.exception("[%s] Error fetching deferred content", plugin_id)
return False
if not images:
return False
appended = self.scroll_helper.append_content(
content_items=[self._join_plugin_rows(images)],
item_gap=self.config.separator_width,
element_gap=0,
)
if appended:
with self._buffer_lock:
self._active_scroll_image = self.scroll_helper.cached_image
logger.info(
"[%s] Appended deferred content: strip now %dpx, %dpx ahead",
plugin_id, self.scroll_helper.total_scroll_width,
self.scroll_helper.remaining_unscrolled()
)
return appended
def has_deferred(self) -> bool:
"""Whether any canvas-bound plugins are still queued."""
return bool(self._deferred_queue)
def _claim_prepared_group(self):
"""Take the prefetched group, if one is ready."""
with self._prefetch_lock:
group = self._prepared_group
self._prepared_group = None
return group
def extend_scroll_content(self) -> bool:
"""
Append the next group of plugins to the strip, without interrupting motion.
This is what replaces the swap. Scroll position is untouched, so the new
content simply arrives from the right; there is no substitution to see
and no restart with the viewport already full.
Consumed columns behind the viewport are then released, keeping the strip
bounded however long Vegas runs.
Returns:
True if the strip was extended
"""
try:
grouped = self._claim_prepared_group()
if grouped is None:
# Nothing prepared (first extension, or prefetch still running).
# Fetch inline; the scroll hitches, but content keeps flowing.
logger.info("No prepared group ready; fetching inline")
grouped = self.stream_manager.take_next_group()
if not grouped:
logger.warning("No content available to extend the scroll strip")
return False
# Plugins the background thread had to defer need the shared canvas,
# so they can only be fetched here. Queue them rather than doing all
# of them now: measured, six in one go held the render thread for
# 1.75s. They are trickled in one per frame by drain_deferred(),
# which the strip's lookahead comfortably absorbs.
deferred = [pid for pid, images in grouped if images is None]
if deferred:
self._deferred_queue.extend(deferred)
logger.info(
"Queued %d plugin(s) needing the render thread: %s",
len(deferred), ', '.join(deferred)
)
grouped = [(pid, imgs) for pid, imgs in grouped if imgs]
if not grouped:
# Everything in this group is queued; the queue will extend the
# strip as it drains, so this is not a failure.
logger.info("Whole group deferred; strip will extend as it drains")
self.start_prefetch()
return bool(deferred)
blocks = []
total_rows = 0
for _plugin_id, images in grouped:
total_rows += len(images)
blocks.append(self._join_plugin_rows(images))
appended = self.scroll_helper.append_content(
content_items=blocks,
item_gap=self.config.separator_width,
element_gap=0,
)
if not appended:
return False
# Keep a screen's worth behind the viewport as a safety margin.
self.scroll_helper.drop_scrolled_prefix(keep_before=self.display_width)
with self._buffer_lock:
self._active_scroll_image = self.scroll_helper.cached_image
self._segments_in_scroll = [pid for pid, _ in grouped]
self.stats['composition_count'] += 1
self.stats['extensions'] = self.stats.get('extensions', 0) + 1
logger.info(
"Extended scroll strip with %d plugin block(s), %d rows: "
"strip now %dpx, %dpx still ahead of the viewport",
len(blocks), total_rows, self.scroll_helper.total_scroll_width,
self.scroll_helper.remaining_unscrolled()
)
# Line up the group after this one straight away, so it is ready
# well before the strip runs short again.
self.start_prefetch()
return True
except (ValueError, TypeError, OSError, RuntimeError):
logger.exception("Error extending scroll content")
return False
def _join_plugin_rows(self, images: List[Image.Image]) -> Image.Image:
"""
Concatenate one plugin's images into a single block.
Args:
images: That plugin's content, in order
Returns:
A single image with the rows laid out left to right, separated by
``intra_plugin_gap``. Returned unchanged when there is only one row,
which is the common case and avoids a pointless copy.
"""
if len(images) == 1:
return images[0]
floor = max(0, self.config.intra_plugin_gap)
target = max(0, self.config.min_content_separation)
threshold = self.config.trim_threshold
# Space by measured separation, not a flat gap. Rows drawn flush to
# their own edges (sports score cards) would otherwise end up nearly
# touching, while rows that already carry wide margins would be pushed
# needlessly further apart.
gaps = [
separation_gap(images[i], images[i + 1], target, floor, threshold)
for i in range(len(images) - 1)
]
width = sum(img.width for img in images) + sum(gaps)
height = max(img.height for img in images)
block = Image.new('RGB', (width, height), (0, 0, 0))
x = 0
for i, img in enumerate(images):
block.paste(img, (x, 0))
x += img.width + (gaps[i] if i < len(gaps) else 0)
return block
def render_frame(self) -> bool:
"""
Render a single frame to the display.
@@ -211,21 +499,33 @@ class RenderPipeline:
# Determine if the cycle is done.
#
# scroll_helper considers a cycle complete only after
# total_distance_scrolled >= total_scroll_width + display_width.
# That extra display_width of travel causes a "wrap-around" phase
# where scroll_position resets to ~0 and the first plugin's content
# re-enters from the right — the user sees this 2-3 s of re-entry
# as "a plugin partially displaying before the next one starts."
# get_visible_portion wraps: once scroll_position + display_width
# passes the end of the strip it fills the right-hand side of the
# frame from the *head* of the same strip. So the last
# display_width of travel shows the cycle's first plugin re-entering
# on the right while its last plugin exits on the left, and the
# recompose that follows then replaces both at once. That reads as
# the ticker "switching mid-scroll".
#
# We end the cycle as soon as total_distance_scrolled reaches
# total_scroll_width (the wrap-around point), before any second-pass
# content becomes visible. The scroll_helper's own is_scroll_complete()
# check is kept as a fallback for any edge-cases where that threshold
# is never hit.
# This used to be hidden because the strip began with a full
# display_width of blank, so the wrapped-in region was black.
# lead_in_width now defaults to 0 (that blank was 10s of dead panel
# at 50px/s), which exposed the wrap — so the cycle has to end
# before it, one display width earlier.
#
# A strip no wider than the display never wraps, and subtracting
# would make the cycle complete instantly, so clamp in that case.
# In continuous mode there is no cycle to complete: the strip is
# extended before the scroll can reach its end, so the wrap is never
# entered and motion never stops. The completion path below stays for
# the swap behaviour and as a backstop if an extension fails.
wrap_point = self.scroll_helper.total_scroll_width
if wrap_point > self.display_width:
wrap_point -= self.display_width
at_wrap_point = (
not self._cycle_complete and
self.scroll_helper.total_distance_scrolled >= self.scroll_helper.total_scroll_width
self.scroll_helper.total_distance_scrolled >= wrap_point
)
if at_wrap_point or self.scroll_helper.is_scroll_complete():
@@ -236,24 +536,17 @@ class RenderPipeline:
"Scroll cycle complete after %.1fs",
time.time() - self._cycle_start_time
)
# Push blank immediately so the hardware never shows any
# post-wrap content while the coordinator recomposes the
# next cycle (~100 ms). The blank is allocated once and
# reused across cycle wraps (fresh paste each time in case
# a consumer drew on the previous one).
try:
if self._blank_frame is None or self._blank_frame.size != (
self.display_width, self.display_height):
self._blank_frame = Image.new(
'RGB', (self.display_width, self.display_height))
else:
self._blank_frame.paste(
(0, 0, 0),
(0, 0, self.display_width, self.display_height))
self.display_manager.image = self._blank_frame
self.display_manager.update_display()
except Exception:
logger.exception("Failed to write blank frame to display at cycle end")
# Deliberately leave the last rendered frame on the panel.
#
# This used to push a blank frame so no post-wrap content
# could be seen while the next cycle was composed. But
# recomposing is synchronous and fetches plugin content:
# measured 84ms at best and 4.8s at worst on a 512px panel,
# and every millisecond of it was black. Holding the last
# frame instead turns that into a brief freeze, which reads
# as far less broken than the display switching off. The
# frame is already past the end of the content, so there is
# no second-pass content to leak.
return True # Cycle done; coordinator starts new cycle next frame
# Get visible portion
@@ -336,6 +629,25 @@ class RenderPipeline:
return False
def refresh_updated_plugins(self) -> bool:
"""
Let changed plugin data reach the strip without interrupting motion.
Used instead of :meth:`hot_swap_content` when scrolling continuously.
The swap rebuilds the whole image and repositions the scroll, which is
visible as a freeze and a jump; the strip is extended here rather than
replaced, so it is enough to drop the stale caches and let the plugin
recompose when it next comes round.
Returns:
True if any plugin's cached content was dropped.
"""
try:
return bool(self.stream_manager.invalidate_pending_updates())
except Exception: # pylint: disable=broad-except
logger.exception("Failed to refresh updated plugins")
return False
def hot_swap_content(self) -> bool:
"""
Hot-swap to new composed content.
@@ -415,11 +727,12 @@ class RenderPipeline:
result = self.compose_scroll_content()
if result and self.sync_manager:
# When sync is active, start the leader at display_width instead of 0.
# This skips the initial black gap so the leader immediately shows content.
# The follower starts at position 0 (the gap) which looks like a clean
# blank transition rather than near-end content wrapping around.
self.scroll_helper.scroll_position = float(self.display_width)
# When sync is active, start the leader past the lead-in gap so it
# immediately shows content, leaving the follower on the blank gap
# for a clean transition rather than near-end content wrapping
# around. This tracks lead_in_width rather than assuming a full
# display width of gap, which is no longer the default.
self.scroll_helper.scroll_position = float(self.config.lead_in_width)
if result and self.sync_manager:
# Signal follower that a new cycle started (triggers its own rebuild)
+146 -13
View File
@@ -14,7 +14,7 @@ Supports three display modes:
import logging
import threading
import time
from typing import Optional, List, Dict, Any, Deque, TYPE_CHECKING
from typing import Optional, List, Dict, Any, Deque, Tuple, TYPE_CHECKING
from collections import deque
from dataclasses import dataclass, field
from PIL import Image
@@ -116,8 +116,11 @@ class StreamManager:
logger.warning("No plugins available for Vegas scroll")
return False
# Prefetch initial content
self._prefetch_content(count=min(self.config.buffer_ahead + 1, len(self._ordered_plugins)))
# Fill the buffer to a whole cycle's worth of plugins. This used to be
# buffer_ahead + 1, which conflated prefetch depth with cycle size and
# meant a 20-plugin install only showed 3 plugins before recomposing.
self._prefetch_content(
count=min(self.config.plugins_per_cycle, len(self._ordered_plugins)))
logger.info(
"StreamManager initialized with %d plugins, %d segments buffered",
@@ -198,6 +201,47 @@ class StreamManager:
logger.debug("Plugin %s marked for update", plugin_id)
def invalidate_pending_updates(self) -> List[str]:
"""
Drop cached content for plugins whose data changed, without refetching.
The continuous-scroll counterpart to :meth:`process_updates`. That method
belongs to the swap path: it refetches immediately and merges into the
active buffer, which continuous mode bypasses entirely, and doing that
work on the render thread would hitch the scroll.
Here it is enough to clear the caches and let the plugin come round in
the rotation, which recomposes it from current data a moment later. Left
uncalled, ``_pending_updates`` simply accumulates and no visual ever
refreshes a game that was live last night keeps being drawn as live.
Returns:
The plugin ids whose caches were dropped.
"""
with self._buffer_lock:
if not self._pending_updates:
return []
updated = list(self._pending_updates.keys())
self._pending_updates.clear()
plugins = getattr(self.plugin_manager, 'plugins', {})
for plugin_id in updated:
try:
self.plugin_adapter.invalidate_cache(plugin_id)
plugin = plugins.get(plugin_id)
if plugin is not None:
self.plugin_adapter.invalidate_plugin_scroll_cache(
plugin, plugin_id)
except Exception: # pylint: disable=broad-except
logger.exception(
"[%s] Could not invalidate cached content", plugin_id)
logger.info(
"Vegas: dropped cached content for %d updated plugin(s): %s",
len(updated), ', '.join(updated)
)
return updated
def has_pending_updates(self) -> bool:
"""Check if any plugins have pending updates awaiting processing."""
with self._buffer_lock:
@@ -385,7 +429,7 @@ class StreamManager:
return
for _ in range(count):
if len(self._active_buffer) >= self.config.buffer_ahead + 1:
if len(self._active_buffer) >= self.config.plugins_per_cycle:
break
# Ensure index is valid (guard against empty list)
@@ -521,28 +565,117 @@ class StreamManager:
logger.debug("Refreshed content for %s in staging buffer", plugin_id)
def _ensure_buffer_filled(self) -> None:
"""Ensure buffer has enough content prefetched."""
if len(self._active_buffer) < self.config.buffer_ahead:
needed = self.config.buffer_ahead - len(self._active_buffer)
self._prefetch_content(count=needed)
"""
Top the buffer back up after segments have been served.
buffer_ahead is the low-water mark only; plugins_per_cycle is the
ceiling and is enforced inside _prefetch_content.
"""
low_water = min(self.config.buffer_ahead, self.config.plugins_per_cycle)
if len(self._active_buffer) < low_water:
self._prefetch_content(count=low_water - len(self._active_buffer))
def get_all_content_for_composition(self) -> List[Image.Image]:
"""
Get all buffered content as a flat list of images.
Used when composing the full scroll image.
Skips STATIC segments as they don't have images to compose.
Prefer get_grouped_content_for_composition(): flattening loses the
plugin boundaries, which is what tells the compositor where a
separator belongs and where it does not.
Returns:
List of all images in buffer order
"""
all_images = []
for _plugin_id, images in self.get_grouped_content_for_composition():
all_images.extend(images)
return all_images
def get_grouped_content_for_composition(self) -> List[Tuple[str, List[Image.Image]]]:
"""
Get buffered content grouped by the plugin that produced it.
The grouping matters: separator_width is meant to mark the handoff from
one plugin to the next, not to sit between every row a single plugin
contributes. A per-row ticker like the F1 scoreboard returns over a
hundred images that it renders 4px apart internally, so flattening them
into one list and applying a uniform gap forced 32px between each of
its rows both inconsistent with how the plugin looks standalone, and
a large hidden addition to the width it occupies.
Skips STATIC segments, which trigger a pause rather than contributing
scroll content, and segments left with no images.
Returns:
List of (plugin_id, images) in buffer order
"""
grouped: List[Tuple[str, List[Image.Image]]] = []
with self._buffer_lock:
for segment in self._active_buffer:
# Skip STATIC segments - they trigger pauses, not scroll content
if segment.display_mode != VegasDisplayMode.STATIC:
all_images.extend(segment.images)
return all_images
if segment.display_mode == VegasDisplayMode.STATIC:
continue
if not segment.images:
continue
grouped.append((segment.plugin_id, list(segment.images)))
return grouped
def take_next_group(
self, count: Optional[int] = None, offscreen_only: bool = False
) -> List[Tuple[str, Optional[List[Image.Image]]]]:
"""
Fetch and hand over the next slice of the rotation.
For continuous scrolling, where the strip is extended rather than
replaced. Advances the rotation index so plugins come round in order
across an unbroken strip, and bypasses the active buffer entirely that
buffer exists to stage a *replacement* cycle, which continuous mode has
no use for.
Args:
count: Number of plugins to gather, defaulting to plugins_per_cycle
offscreen_only: Only use content paths that avoid the shared display
canvas, for use off the render thread
Returns:
Ordered list of (plugin_id, images). ``images`` is None when the
plugin could not be served under ``offscreen_only``, so the caller
can fetch just those on the render thread while keeping the order.
"""
if count is None:
count = self.config.plugins_per_cycle
self.refresh()
with self._buffer_lock:
if not self._ordered_plugins:
return []
total = len(self._ordered_plugins)
ids = []
for _ in range(min(max(1, count), total)):
ids.append(self._ordered_plugins[self._prefetch_index])
self._prefetch_index = (self._prefetch_index + 1) % total
plugins = getattr(self.plugin_manager, 'plugins', {})
group: List[Tuple[str, Optional[List[Image.Image]]]] = []
for plugin_id in ids:
plugin = plugins.get(plugin_id)
if not plugin:
continue
try:
images = self.plugin_adapter.get_content(
plugin, plugin_id, offscreen_only=offscreen_only)
except Exception:
logger.exception("[%s] ERROR fetching content", plugin_id)
self.stats['fetch_errors'] += 1
continue
if images:
self.stats['segments_fetched'] += 1
group.append((plugin_id, images if images else None))
return group
def advance_cycle(self) -> None:
"""
+396
View File
@@ -0,0 +1,396 @@
"""
Tests for src.element_style the shared per-element style resolver behind
the x-style-elements system.
The contract under test (defined by the plugin consumers: of-the-day,
ledmatrix-music, football-scoreboard):
- defaults_from_schema_file parses BOTH declaration forms the compact
x-style-elements map and hand-written customization blocks.
- expand_style_elements turns an x-style-elements declaration into the full
per-element blocks (plus layout offsets) the web-UI form renders.
- A config value counts as user-forced only when it genuinely differs from
the schema default; untouched (or schema-default-populated) configs
resolve to EXACTLY the classic font/size/color, keeping rendering
byte-identical.
- style() never raises; malformed input degrades to the classic style.
"""
import json
import os
import pytest
from PIL import ImageFont
from src.element_style import (
ElementStyleResolver,
defaults_from_schema,
defaults_from_schema_file,
expand_style_elements,
load_font,
resolve_font_path,
)
# ---------------------------------------------------------------------------
# Schema fixtures
# ---------------------------------------------------------------------------
# Compact declaration form (of-the-day's shape).
STYLE_ELEMENTS_SCHEMA = {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": False},
"customization": {
"type": "object",
"x-style-elements": {
"title_text": {
"title": "Title",
"font": {"default": "PressStart2P-Regular.ttf"},
"size": {"default": 8, "min": 4, "max": 16},
"color": {"default": [255, 255, 255]},
"offsets": True,
},
"body_text": {
"title": "Body Text",
"font": {"default": "4x6-font.ttf"},
"size": {"default": 6, "min": 4, "max": 12},
"color": {"default": [200, 200, 200]},
"offsets": True,
},
},
},
},
}
# Manual declaration form (the scoreboards' / music's shape).
MANUAL_SCHEMA = {
"type": "object",
"properties": {
"customization": {
"type": "object",
"properties": {
"status_text": {
"type": "object",
"properties": {
"font": {"type": "string",
"default": "4x6-font.ttf"},
"font_size": {"type": "integer", "default": 6},
},
},
"score_text": {
"type": "object",
"properties": {
"font": {"type": "string",
"default": "PressStart2P-Regular.ttf"},
"font_size": {"type": "integer", "default": 10},
"text_color": {"type": "array",
"default": [255, 255, 0]},
},
},
"layout": {"type": "object", "properties": {}},
},
},
},
}
@pytest.fixture
def style_schema_path(tmp_path):
path = tmp_path / "config_schema.json"
path.write_text(json.dumps(STYLE_ELEMENTS_SCHEMA))
return str(path)
@pytest.fixture
def manual_schema_path(tmp_path):
path = tmp_path / "config_schema.json"
path.write_text(json.dumps(MANUAL_SCHEMA))
return str(path)
def _resolver(config, schema_path):
return ElementStyleResolver(config, defaults_from_schema_file(schema_path))
# ---------------------------------------------------------------------------
# Schema parsing
# ---------------------------------------------------------------------------
class TestDefaultsFromSchema:
def test_x_style_elements_defaults(self, style_schema_path):
defaults = defaults_from_schema_file(style_schema_path)
cust = defaults["customization"]
assert cust["title_text"] == {"font": "PressStart2P-Regular.ttf",
"font_size": 8,
"text_color": [255, 255, 255]}
assert cust["body_text"]["font_size"] == 6
assert cust["body_text"]["text_color"] == [200, 200, 200]
def test_manual_block_defaults(self, manual_schema_path):
defaults = defaults_from_schema_file(manual_schema_path)
cust = defaults["customization"]
assert cust["status_text"] == {"font": "4x6-font.ttf", "font_size": 6}
assert cust["score_text"]["text_color"] == [255, 255, 0]
assert "layout" not in cust
def test_missing_file_degrades_to_empty(self, tmp_path):
defaults = defaults_from_schema_file(str(tmp_path / "nope.json"))
assert defaults == {"customization": {}}
def test_malformed_file_degrades_to_empty(self, tmp_path):
path = tmp_path / "bad.json"
path.write_text("{not json")
assert defaults_from_schema_file(str(path)) == {"customization": {}}
def test_schema_without_customization(self):
assert defaults_from_schema({"properties": {}}) == {"customization": {}}
class TestExpandStyleElements:
def test_expansion_generates_blocks(self):
expanded = expand_style_elements(STYLE_ELEMENTS_SCHEMA)
cust = expanded["properties"]["customization"]["properties"]
title = cust["title_text"]
assert title["x-style-managed"] is True
assert title["properties"]["font"]["default"] == \
"PressStart2P-Regular.ttf"
assert title["properties"]["font_size"]["default"] == 8
assert title["properties"]["font_size"]["minimum"] == 4
assert title["properties"]["font_size"]["maximum"] == 16
assert cust["body_text"]["properties"]["text_color"]["default"] == \
[200, 200, 200]
def test_expansion_generates_layout_offsets(self):
expanded = expand_style_elements(STYLE_ELEMENTS_SCHEMA)
layout = expanded["properties"]["customization"]["properties"]["layout"]
assert "title_text" in layout["properties"]
offsets = layout["properties"]["body_text"]["properties"]
assert offsets["x_offset"]["default"] == 0
assert offsets["y_offset"]["default"] == 0
def test_input_schema_not_mutated(self):
before = json.dumps(STYLE_ELEMENTS_SCHEMA, sort_keys=True)
expand_style_elements(STYLE_ELEMENTS_SCHEMA)
assert json.dumps(STYLE_ELEMENTS_SCHEMA, sort_keys=True) == before
def test_no_declaration_returns_same_object(self):
assert expand_style_elements(MANUAL_SCHEMA) is MANUAL_SCHEMA
empty = {"properties": {}}
assert expand_style_elements(empty) is empty
def test_garbage_input_never_raises(self):
bad = {"properties": {"customization": {"x-style-elements": "nope"}}}
assert expand_style_elements(bad) is bad
# ---------------------------------------------------------------------------
# Classic identity: untouched configs resolve to the classic style
# ---------------------------------------------------------------------------
class TestClassicIdentity:
def test_bare_config_resolves_classic(self, style_schema_path):
r = _resolver({}, style_schema_path)
style = r.style("title_text", classic_font="PressStart2P-Regular.ttf",
classic_size=8, classic_color=(255, 255, 255))
assert style.font_name == "PressStart2P-Regular.ttf"
assert style.font_size == 8
assert style.color == (255, 255, 255)
assert style.offset == (0, 0)
assert not style.user_forced
assert not style.user_forced_color
assert isinstance(style.font, ImageFont.FreeTypeFont)
assert style.font.size == 8
def test_schema_populated_config_is_not_an_override(self, style_schema_path):
# The web UI's save flow writes the full schema defaults into config
# on every save — that must not count as a user override.
config = {"customization": {
"title_text": {"font": "PressStart2P-Regular.ttf", "font_size": 8,
"text_color": [255, 255, 255]},
"layout": {"title_text": {"x_offset": 0, "y_offset": 0}},
}}
style = _resolver(config, style_schema_path).style(
"title_text", classic_font="PressStart2P-Regular.ttf",
classic_size=8, classic_color=(255, 255, 255))
assert not style.user_forced
assert not style.user_forced_color
assert style.font_size == 8
assert style.color == (255, 255, 255)
assert style.offset == (0, 0)
def test_schema_default_falls_back_to_classic_not_schema_font(
self, manual_schema_path):
# Classic values and schema defaults can legitimately differ
# (football's status_text: schema says 4x6, classic loader used
# PressStart). A schema-default config value must yield the CLASSIC
# font, byte-identical to the old loader.
config = {"customization": {"status_text": {"font": "4x6-font.ttf",
"font_size": 6}}}
style = _resolver(config, manual_schema_path).style(
"status_text", classic_font="PressStart2P-Regular.ttf",
classic_size=6)
assert not style.user_forced
assert style.font_name == "PressStart2P-Regular.ttf"
assert style.font_size == 6
def test_same_font_object_from_cache(self, style_schema_path):
r = _resolver({}, style_schema_path)
s1 = r.style("title_text", classic_font="PressStart2P-Regular.ttf",
classic_size=8)
s2 = ElementStyleResolver({}, {}).style(
"title_text", classic_font="PressStart2P-Regular.ttf",
classic_size=8)
assert s1.font is s2.font
# ---------------------------------------------------------------------------
# User overrides engage
# ---------------------------------------------------------------------------
class TestUserOverrides:
def test_font_override(self, style_schema_path):
config = {"customization": {"title_text": {"font": "4x6-font.ttf"}}}
style = _resolver(config, style_schema_path).style(
"title_text", classic_font="PressStart2P-Regular.ttf",
classic_size=8)
assert style.user_forced
assert style.font_name == "4x6-font.ttf"
assert style.font_size == 8 # size untouched -> classic
def test_size_override(self, style_schema_path):
config = {"customization": {"title_text": {
"font": "PressStart2P-Regular.ttf", "font_size": 16}}}
style = _resolver(config, style_schema_path).style(
"title_text", classic_font="PressStart2P-Regular.ttf",
classic_size=8)
assert style.user_forced
assert style.font_name == "PressStart2P-Regular.ttf"
assert style.font_size == 16
assert style.font.size == 16
def test_size_override_detected_vs_schema_default(self, manual_schema_path):
# font_size 8 differs from the schema default 6 -> forced.
config = {"customization": {"status_text": {"font": "4x6-font.ttf",
"font_size": 8}}}
style = _resolver(config, manual_schema_path).style(
"status_text", classic_font="PressStart2P-Regular.ttf",
classic_size=6)
assert style.user_forced
assert style.font_size == 8
def test_color_override(self, style_schema_path):
config = {"customization": {"title_text": {"text_color": [255, 0, 0]}}}
style = _resolver(config, style_schema_path).style(
"title_text", classic_font="PressStart2P-Regular.ttf",
classic_size=8, classic_color=(255, 255, 255))
assert style.user_forced_color
assert not style.user_forced
assert style.color == (255, 0, 0)
def test_offsets(self, style_schema_path):
config = {"customization": {"layout": {
"title_text": {"x_offset": 4, "y_offset": -2}}}}
r = _resolver(config, style_schema_path)
assert r.offset("title_text") == (4, -2)
assert r.offset("body_text") == (0, 0)
style = r.style("title_text", classic_font="PressStart2P-Regular.ttf",
classic_size=8)
assert style.offset == (4, -2)
def test_offset_value_arbitrary_axis_and_strings(self, style_schema_path):
# The scoreboards read non-standard axes (away_x_offset) and configs
# can carry numeric strings/floats.
config = {"customization": {"layout": {"records": {
"away_x_offset": "3", "home_x_offset": 2.7}}}}
r = _resolver(config, style_schema_path)
assert r.offset_value("records", "away_x_offset", 0) == 3
assert r.offset_value("records", "home_x_offset", 0) == 2
assert r.offset_value("records", "missing_axis", 5) == 5
# ---------------------------------------------------------------------------
# Defensive degradation
# ---------------------------------------------------------------------------
class TestDegradation:
@pytest.mark.parametrize("config", [
None,
{"customization": "not a dict"},
{"customization": {"title_text": "not a dict"}},
{"customization": {"title_text": {"font": 42, "font_size": "huge",
"text_color": "red"}}},
{"customization": {"layout": {"title_text": {"x_offset": "junk"}}}},
])
def test_bad_config_degrades_to_classic(self, config, style_schema_path):
style = _resolver(config, style_schema_path).style(
"title_text", classic_font="PressStart2P-Regular.ttf",
classic_size=8, classic_color=(10, 20, 30))
assert not style.user_forced
assert not style.user_forced_color
assert style.font_name == "PressStart2P-Regular.ttf"
assert style.font_size == 8
assert style.color == (10, 20, 30)
assert style.offset == (0, 0)
def test_unknown_font_falls_back(self, style_schema_path):
config = {"customization": {"title_text": {"font": "no-such.ttf"}}}
style = _resolver(config, style_schema_path).style(
"title_text", classic_font="PressStart2P-Regular.ttf",
classic_size=8)
# The override IS honored as forced, but the face degrades safely.
assert style.user_forced
assert style.font is not None
def test_empty_defaults_treats_config_as_reference_to_classic(self):
# No schema defaults at all: a config value equal to the classic
# value is not forced; a different one is.
r = ElementStyleResolver(
{"customization": {"e": {"font": "4x6-font.ttf"}}}, {})
assert not r.style("e", classic_font="4x6-font.ttf",
classic_size=6).user_forced
assert r.style("e", classic_font="PressStart2P-Regular.ttf",
classic_size=6).user_forced
# ---------------------------------------------------------------------------
# Resolver plumbing the consumers rely on
# ---------------------------------------------------------------------------
class TestResolverPlumbing:
def test_config_identity_exposed(self, style_schema_path):
# Consumers rebuild the resolver when the config dict is swapped:
# `resolver._config is not self.config`.
config = {"customization": {}}
r = _resolver(config, style_schema_path)
assert r._config is config
def test_font_path_resolution_is_cwd_independent(self, tmp_path,
monkeypatch):
monkeypatch.chdir(tmp_path) # no assets/fonts under cwd
path = resolve_font_path("PressStart2P-Regular.ttf")
assert path is not None and os.path.isfile(path)
font = load_font("PressStart2P-Regular.ttf", 8)
assert isinstance(font, ImageFont.FreeTypeFont)
def test_bdf_font_loads_as_freetype_face(self):
import freetype
font = load_font("5x7.bdf", 7)
assert isinstance(font, freetype.Face)
def test_schema_manager_expands_on_load(self, tmp_path):
# The web-UI form path: SchemaManager.load_schema serves the
# expanded schema so the style blocks actually appear in the UI.
from src.plugin_system.schema_manager import SchemaManager
plugin_dir = tmp_path / "plugins" / "styled"
plugin_dir.mkdir(parents=True)
(plugin_dir / "config_schema.json").write_text(
json.dumps(STYLE_ELEMENTS_SCHEMA))
(plugin_dir / "manifest.json").write_text(json.dumps({
"id": "styled", "config_schema": "config_schema.json"}))
manager = SchemaManager(plugins_dir=tmp_path / "plugins",
project_root=tmp_path)
schema = manager.load_schema("styled")
assert schema is not None
cust = schema["properties"]["customization"]["properties"]
assert cust["title_text"]["x-style-managed"] is True
assert "title_text" in cust["layout"]["properties"]
+336
View File
@@ -0,0 +1,336 @@
"""
Tests for ScrollHelper's continuous-strip primitives.
append_content extends the strip to the right without disturbing motion, and
drop_scrolled_prefix reclaims what has already gone past. Together they let a
caller keep one endless strip instead of swapping a new one in, which is what
shows as a flash and a hard cut to already-full-screen content.
"""
import numpy as np
import pytest
from PIL import Image
from src.common.scroll_helper import ScrollHelper
from src.vegas_mode.geometry import column_has_ink
W, H = 128, 32
def helper():
return ScrollHelper(W, H)
def block(width, colour=(255, 255, 255), height=H):
return Image.new('RGB', (width, height), colour)
class TestAppendContent:
def test_first_append_builds_the_strip(self):
sh = helper()
assert sh.append_content([block(100)], item_gap=0)
assert sh.cached_image is not None
assert sh.total_scroll_width == sh.cached_image.width
def test_strip_grows_by_content_plus_gaps(self):
sh = helper()
sh.create_scrolling_image([block(100)], item_gap=0, element_gap=0, lead_gap=0)
assert sh.cached_image.width == 100
sh.append_content([block(50)], item_gap=10, element_gap=0)
# one leading gap of 10 then the 50px block
assert sh.cached_image.width == 160
assert sh.total_scroll_width == 160
def test_scroll_position_is_preserved(self):
sh = helper()
sh.create_scrolling_image([block(400)], item_gap=0, element_gap=0, lead_gap=0)
sh.scroll_position = 137.0
sh.total_distance_scrolled = 137.0
sh.append_content([block(200)], item_gap=16)
assert sh.scroll_position == 137.0
assert sh.total_distance_scrolled == 137.0
def test_appending_defers_completion(self):
sh = helper()
sh.create_scrolling_image([block(200)], item_gap=0, element_gap=0, lead_gap=0)
sh.scroll_complete = True
sh.append_content([block(200)], item_gap=0)
assert not sh.scroll_complete
assert sh.total_distance_scrolled < sh.total_scroll_width
def test_existing_pixels_are_untouched(self):
sh = helper()
original = block(80, (10, 200, 10))
sh.create_scrolling_image([original], item_gap=0, element_gap=0, lead_gap=0)
before = sh.cached_image.crop((0, 0, 80, H)).tobytes()
sh.append_content([block(40, (200, 10, 10))], item_gap=8)
assert sh.cached_image.crop((0, 0, 80, H)).tobytes() == before
def test_appended_content_sits_after_the_gap(self):
sh = helper()
sh.create_scrolling_image([block(50)], item_gap=0, element_gap=0, lead_gap=0)
sh.append_content([block(30)], item_gap=12)
ink = column_has_ink(sh.cached_image)
assert ink[:50].all()
assert not ink[50:62].any() # the 12px gap
assert ink[62:92].all()
def test_array_and_image_stay_consistent(self):
# get_visible_portion slices cached_array but bounds-checks against
# cached_image.width, so a mismatch corrupts frames.
sh = helper()
sh.create_scrolling_image([block(200)], item_gap=0, element_gap=0, lead_gap=0)
sh.append_content([block(100)], item_gap=8)
assert sh.cached_array.shape[1] == sh.cached_image.width
assert sh.cached_array.shape[0] == sh.cached_image.height
def test_visible_portion_still_renders_after_append(self):
sh = helper()
sh.create_scrolling_image([block(300)], item_gap=0, element_gap=0, lead_gap=0)
sh.append_content([block(300)], item_gap=8)
sh.scroll_position = 250.0
frame = sh.get_visible_portion()
assert frame is not None and frame.size == (W, H)
def test_empty_append_is_a_no_op(self):
sh = helper()
sh.create_scrolling_image([block(100)], item_gap=0, element_gap=0, lead_gap=0)
assert sh.append_content([]) is False
assert sh.cached_image.width == 100
def test_repeated_appends_accumulate(self):
sh = helper()
sh.append_content([block(100)], item_gap=0)
for _ in range(5):
sh.append_content([block(100)], item_gap=0)
assert sh.cached_image.width == 600
class TestDropScrolledPrefix:
def test_removes_consumed_columns(self):
sh = helper()
sh.create_scrolling_image([block(1000)], item_gap=0, element_gap=0, lead_gap=0)
sh.scroll_position = 500.0
sh.total_distance_scrolled = 500.0
removed = sh.drop_scrolled_prefix(keep_before=0)
assert removed == 500
assert sh.cached_image.width == 500
assert sh.scroll_position == 0.0
def test_keeps_the_requested_margin(self):
sh = helper()
sh.create_scrolling_image([block(1000)], item_gap=0, element_gap=0, lead_gap=0)
sh.scroll_position = 500.0
sh.drop_scrolled_prefix(keep_before=100)
assert sh.scroll_position == 100.0
assert sh.cached_image.width == 600
def test_completion_difference_is_preserved(self):
# total_distance_scrolled and total_scroll_width must shift together, or
# trimming would spuriously complete or un-complete the cycle.
sh = helper()
sh.create_scrolling_image([block(1000)], item_gap=0, element_gap=0, lead_gap=0)
sh.scroll_position = 600.0
sh.total_distance_scrolled = 600.0
before = sh.total_scroll_width - sh.total_distance_scrolled
sh.drop_scrolled_prefix(keep_before=0)
assert sh.total_scroll_width - sh.total_distance_scrolled == before
def test_never_trims_below_the_viewport(self):
sh = helper()
sh.create_scrolling_image([block(200)], item_gap=0, element_gap=0, lead_gap=0)
sh.scroll_position = 190.0
sh.drop_scrolled_prefix(keep_before=0)
assert sh.cached_image.width >= W
def test_no_op_before_anything_has_scrolled(self):
sh = helper()
sh.create_scrolling_image([block(500)], item_gap=0, element_gap=0, lead_gap=0)
assert sh.drop_scrolled_prefix(keep_before=0) == 0
assert sh.cached_image.width == 500
def test_no_op_with_no_strip(self):
assert helper().drop_scrolled_prefix() == 0
def test_visible_frame_is_unchanged_by_trimming(self):
# The whole point: trimming is invisible. Same pixels on screen before
# and after. Position chosen so the viewport is well clear of the end,
# i.e. not wrapping.
sh = helper()
items = [block(200, (255, 0, 0)), block(200, (0, 255, 0)),
block(200, (0, 0, 255))]
sh.create_scrolling_image(items, item_gap=20, element_gap=0, lead_gap=0)
sh.scroll_position = 300.0
before = sh.get_visible_portion().tobytes()
assert sh.drop_scrolled_prefix(keep_before=0) > 0, "trim should have run"
after = sh.get_visible_portion().tobytes()
assert after == before
def test_refuses_to_trim_while_the_viewport_wraps(self):
# Wrapping reads the head of the strip into the right of the frame, so
# trimming the head there would visibly change the picture.
sh = helper()
sh.create_scrolling_image([block(200)], item_gap=0, element_gap=0, lead_gap=0)
sh.scroll_position = 150.0 # 150 + 128 > 200, so wrapping
before = sh.get_visible_portion().tobytes()
assert sh.drop_scrolled_prefix(keep_before=0) == 0
assert sh.get_visible_portion().tobytes() == before
def test_array_and_image_stay_consistent_after_trim(self):
sh = helper()
sh.create_scrolling_image([block(900)], item_gap=0, element_gap=0, lead_gap=0)
sh.scroll_position = 400.0
sh.drop_scrolled_prefix(keep_before=0)
assert sh.cached_array.shape[1] == sh.cached_image.width
class TestRemainingUnscrolled:
def test_counts_content_right_of_the_viewport(self):
sh = helper()
sh.create_scrolling_image([block(500)], item_gap=0, element_gap=0, lead_gap=0)
assert sh.remaining_unscrolled() == 500 - W
def test_shrinks_as_the_strip_scrolls(self):
sh = helper()
sh.create_scrolling_image([block(500)], item_gap=0, element_gap=0, lead_gap=0)
sh.scroll_position = 200.0
assert sh.remaining_unscrolled() == 500 - 200 - W
def test_never_negative(self):
sh = helper()
sh.create_scrolling_image([block(200)], item_gap=0, element_gap=0, lead_gap=0)
sh.scroll_position = 500.0
assert sh.remaining_unscrolled() == 0
def test_zero_with_no_strip(self):
assert helper().remaining_unscrolled() == 0
def test_grows_when_content_is_appended(self):
sh = helper()
sh.create_scrolling_image([block(600)], item_gap=0, element_gap=0, lead_gap=0)
sh.scroll_position = 100.0
before = sh.remaining_unscrolled()
assert before > 0, "fixture should leave content ahead of the viewport"
sh.append_content([block(400)], item_gap=0)
assert sh.remaining_unscrolled() == before + 400
class TestContinuousScrollingEndToEnd:
def test_strip_can_be_extended_indefinitely_at_bounded_size(self):
"""The invariant that makes this viable: extend + trim keeps the strip
bounded while motion never stops."""
sh = helper()
sh.create_scrolling_image([block(600)], item_gap=0, element_gap=0, lead_gap=0)
widths = []
for _ in range(20):
sh.scroll_position += 200
sh.total_distance_scrolled += 200
if sh.remaining_unscrolled() < 2 * W:
sh.append_content([block(600)], item_gap=16)
sh.drop_scrolled_prefix(keep_before=W)
widths.append(sh.cached_image.width)
# A frame must always be renderable.
assert sh.get_visible_portion() is not None
assert max(widths) < 3000, f"strip grew unbounded: max {max(widths)}"
assert not sh.scroll_complete, "continuous strip should never complete"
class TestSubPixelBlending:
"""
Integer positioning quantises motion to whole pixels, so distinct frames per
second equals scroll speed regardless of frame rate at 50px/s and 78fps,
36% of frames were identical. Blending between neighbouring positions gives
motion at the frame rate instead.
"""
def _strip(self, width=2000):
rng = np.random.default_rng(0)
arr = (rng.random((H, width, 3)) * 255).astype(np.uint8)
sh = helper()
sh.create_scrolling_image([Image.fromarray(arr)],
item_gap=0, element_gap=0, lead_gap=0)
return sh
def _frame(self, sh, pos, subpixel):
sh.sub_pixel_scrolling = subpixel
sh.scroll_position = pos
return np.asarray(sh.get_visible_portion()).astype(int)
def test_integer_mode_ignores_the_fraction(self):
sh = self._strip()
a = self._frame(sh, 500.0, False)
b = self._frame(sh, 500.9, False)
assert np.array_equal(a, b), "integer positioning should not move sub-pixel"
def test_blending_moves_within_a_pixel(self):
sh = self._strip()
a = self._frame(sh, 500.0, True)
b = self._frame(sh, 500.5, True)
assert not np.array_equal(a, b)
def test_zero_fraction_matches_the_integer_frame(self):
# No interpolation to do, so it must be pixel-identical and take the
# cheap path.
sh = self._strip()
assert np.array_equal(self._frame(sh, 700.0, True),
self._frame(sh, 700.0, False))
def test_blend_is_monotonic_between_neighbours(self):
# Marching the fraction from 0 to 1 should approach the next integer
# frame, not wander.
sh = self._strip()
target = self._frame(sh, 501.0, False)
dists = []
for frac in (0.0, 0.25, 0.5, 0.75):
f = self._frame(sh, 500.0 + frac, True)
dists.append(np.abs(f - target).mean())
assert dists == sorted(dists, reverse=True), f"not converging: {dists}"
def test_blend_endpoints_bracket_the_two_frames(self):
sh = self._strip()
near = self._frame(sh, 500.0, False)
far = self._frame(sh, 501.0, False)
mid = self._frame(sh, 500.5, True)
# Every blended pixel must lie between its two sources.
lo = np.minimum(near, far)
hi = np.maximum(near, far)
assert (mid >= lo - 1).all() and (mid <= hi + 1).all()
def test_output_size_and_mode_are_unchanged(self):
sh = self._strip()
sh.sub_pixel_scrolling = True
sh.scroll_position = 300.4
frame = sh.get_visible_portion()
assert frame.size == (W, H)
assert frame.mode == 'RGB'
def test_works_near_the_end_of_the_strip(self):
# One of the two slices wraps here; must not raise or missize.
sh = self._strip(width=600)
sh.sub_pixel_scrolling = True
sh.scroll_position = float(600 - W // 2) + 0.5
frame = sh.get_visible_portion()
assert frame is not None and frame.size == (W, H)
def test_works_at_the_very_last_column(self):
sh = self._strip(width=600)
sh.sub_pixel_scrolling = True
sh.scroll_position = 599.5
assert sh.get_visible_portion().size == (W, H)
@pytest.mark.parametrize("frac", [0.01, 0.1, 0.33, 0.5, 0.67, 0.9, 0.99])
def test_never_raises_across_the_fraction_range(self, frac):
sh = self._strip()
sh.sub_pixel_scrolling = True
sh.scroll_position = 400.0 + frac
assert sh.get_visible_portion().size == (W, H)
+452
View File
@@ -0,0 +1,452 @@
"""Tests for the skin system: discovery, version gating, fallback
semantics, module isolation, and the view-model contract."""
import json
import logging
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from PIL import Image, ImageFont
# src.base_classes.sports transitively imports the hardware matrix driver;
# stub it so the fallback-semantics tests can import SportsCore off-device.
sys.modules.setdefault("rgbmatrix", MagicMock())
from src.skin_system import skin_runtime
from src.skin_system.skin_base import (
SKIN_API_VERSION,
ScoreboardSkin,
SkinContext,
)
PROJECT_ROOT = Path(__file__).resolve().parents[1]
FIXTURES_DIR = PROJECT_ROOT / "src" / "skin_system" / "fixtures"
# The v1.0 guaranteed view-model keys (docs/CREATING_SKINS.md). Renaming
# or removing any of these is a breaking change to every published skin:
# it requires a VIEW_MODEL_VERSION major bump and a compat shim.
GUARANTEED_KEYS = [
"id", "game_time", "game_date", "start_time_utc", "status_text",
"is_live", "is_final", "is_upcoming", "is_halftime",
"home_abbr", "home_id", "home_score", "home_logo_path", "home_record",
"away_abbr", "away_id", "away_score", "away_logo_path", "away_record",
]
def write_skin(skins_dir: Path, skin_id: str, *, api_version: str = SKIN_API_VERSION,
body: str = None, extra_files: dict = None,
class_name: str = "TestSkin") -> Path:
skin_dir = skins_dir / skin_id
skin_dir.mkdir(parents=True)
manifest = {
"id": skin_id, "name": skin_id, "version": "1.0.0",
"skin_api_version": api_version, "class_name": class_name,
"targets": {"sports": ["baseball"]},
}
(skin_dir / "skin.json").write_text(json.dumps(manifest))
if body is None:
body = (
"from src.skin_system.skin_base import ScoreboardSkin\n"
f"class {class_name}(ScoreboardSkin):\n"
" def render_live(self, ctx, game):\n"
" ctx.draw.rectangle([0, 0, 4, 4], fill=(255, 0, 0))\n"
" return True\n"
)
(skin_dir / "skin.py").write_text(body)
for name, content in (extra_files or {}).items():
(skin_dir / name).write_text(content)
return skin_dir
class TestDiscovery:
def test_discovers_valid_skin(self, tmp_path):
write_skin(tmp_path, "my-skin")
skins = skin_runtime.discover_skins(tmp_path, force_refresh=True)
assert "my-skin" in skins
assert skins["my-skin"]["_skin_dir"].endswith("my-skin")
def test_skips_manifest_missing_required_fields(self, tmp_path):
skin_dir = tmp_path / "broken"
skin_dir.mkdir()
(skin_dir / "skin.json").write_text(json.dumps({"id": "broken"}))
assert skin_runtime.discover_skins(tmp_path, force_refresh=True) == {}
def test_skips_unreadable_manifest_and_non_skin_dirs(self, tmp_path):
(tmp_path / "not-a-skin").mkdir()
bad = tmp_path / "bad-json"
bad.mkdir()
(bad / "skin.json").write_text("{nope")
write_skin(tmp_path, "good-skin")
skins = skin_runtime.discover_skins(tmp_path, force_refresh=True)
assert list(skins) == ["good-skin"]
def test_missing_directory_is_empty(self, tmp_path):
assert skin_runtime.discover_skins(tmp_path / "nope") == {}
def test_example_skin_in_repo_is_discoverable(self):
skins = skin_runtime.discover_skins(force_refresh=True)
assert "example-classic-baseball" in skins
class TestLoadSkin:
def test_loads_and_instantiates(self, tmp_path):
write_skin(tmp_path, "my-skin")
skin = skin_runtime.load_skin("my-skin", sport="baseball",
skins_dir=tmp_path)
assert isinstance(skin, ScoreboardSkin)
def test_unknown_skin_returns_none(self, tmp_path):
assert skin_runtime.load_skin("ghost", skins_dir=tmp_path) is None
def test_api_major_mismatch_is_refused(self, tmp_path):
write_skin(tmp_path, "old-skin", api_version="99.0.0")
assert skin_runtime.load_skin("old-skin", skins_dir=tmp_path) is None
def test_target_mismatch_still_loads(self, tmp_path):
write_skin(tmp_path, "my-skin") # targets baseball
skin = skin_runtime.load_skin("my-skin", sport="hockey",
skins_dir=tmp_path)
assert skin is not None # soft warning, not a hard block
def test_import_error_returns_none(self, tmp_path):
write_skin(tmp_path, "crashy", body="raise RuntimeError('boom')\n")
assert skin_runtime.load_skin("crashy", skins_dir=tmp_path) is None
def test_wrong_class_returns_none(self, tmp_path):
write_skin(tmp_path, "classless", body="x = 1\n")
assert skin_runtime.load_skin("classless", skins_dir=tmp_path) is None
def test_options_are_passed_through(self, tmp_path):
write_skin(tmp_path, "my-skin")
skin = skin_runtime.load_skin("my-skin", skins_dir=tmp_path,
options={"accent": [1, 2, 3]})
assert skin.options == {"accent": [1, 2, 3]}
def test_sibling_modules_are_isolated_between_skins(self, tmp_path):
helper = "VALUE = {!r}\n"
body = (
"import helpers\n"
"from src.skin_system.skin_base import ScoreboardSkin\n"
"class TestSkin(ScoreboardSkin):\n"
" def render_live(self, ctx, game):\n"
" ctx.logger.info(helpers.VALUE)\n"
" self.helper_value = helpers.VALUE\n"
" return False\n"
)
write_skin(tmp_path, "skin-a", body=body,
extra_files={"helpers.py": helper.format("A")})
write_skin(tmp_path, "skin-b", body=body,
extra_files={"helpers.py": helper.format("B")})
skin_a = skin_runtime.load_skin("skin-a", skins_dir=tmp_path)
skin_b = skin_runtime.load_skin("skin-b", skins_dir=tmp_path)
ctx = _make_context()
skin_a.render_live(ctx, {})
skin_b.render_live(ctx, {})
assert skin_a.helper_value == "A"
assert skin_b.helper_value == "B"
def test_same_skin_loads_repeatedly_with_siblings(self, tmp_path):
"""The live/recent/upcoming hosts each load the same skin — the
2nd and 3rd loads must still resolve sibling modules (regression:
cached siblings used to be skipped without rebinding)."""
body = (
"import reload_helpers\n"
"from src.skin_system.skin_base import ScoreboardSkin\n"
"class TestSkin(ScoreboardSkin):\n"
" def render_live(self, ctx, game):\n"
" self.helper_value = reload_helpers.VALUE\n"
" return False\n"
)
write_skin(tmp_path, "reload-skin", body=body,
extra_files={"reload_helpers.py": "VALUE = 'R'\n"})
ctx = _make_context()
for _ in range(3):
skin = skin_runtime.load_skin("reload-skin", skins_dir=tmp_path)
assert skin is not None
skin.render_live(ctx, {})
assert skin.helper_value == "R"
def _make_host(fonts=None):
host = MagicMock()
host.sport = "baseball"
host.sport_key = "mlb"
host.skin_options = {"accent": True}
host.fonts = fonts or {"time": ImageFont.load_default()}
host.logger = logging.getLogger("test_skin_system")
host.display_manager.width = 128
host.display_manager.height = 32
return host
def _make_context(width=128, height=32):
host = _make_host()
return skin_runtime.build_context(host, {}, size=(width, height))
class TestBuildContext:
def test_context_shape(self):
host = _make_host()
game = {"home_abbr": "LAD", "away_abbr": "SF"}
ctx = skin_runtime.build_context(host, game)
assert (ctx.width, ctx.height) == (128, 32)
assert ctx.canvas.size == (128, 32)
assert ctx.sport == "baseball"
assert ctx.options == {"accent": True}
assert ctx.layout.bounds.w == 128
def test_explicit_size_overrides_display(self):
ctx = skin_runtime.build_context(_make_host(), {}, size=(64, 64))
assert ctx.canvas.size == (64, 64)
def test_load_logo_binds_game_and_survives_failure(self):
host = _make_host()
host._load_and_resize_logo.side_effect = RuntimeError("disk gone")
ctx = skin_runtime.build_context(
host, {"home_id": "1", "home_abbr": "LAD",
"home_logo_path": "x.png", "home_logo_url": None})
assert ctx.load_logo("home") is None # exception swallowed
assert ctx.load_logo("elsewhere") is None # bad side rejected
def test_draw_helpers_draw_on_canvas(self):
ctx = _make_context()
ctx.draw_text("HI", 2, 2, font=ImageFont.load_default())
fit = ctx.layout.fit_text("42", ctx.layout.bounds)
ctx.draw_fit(fit, ctx.layout.bounds)
logo = Image.new("RGBA", (16, 16), (255, 0, 0, 255))
ctx.draw_image(logo, ctx.layout.bounds.left_col(20))
ctx.draw_image(None, ctx.layout.bounds) # None must no-op
assert ctx.canvas.convert("L").getbbox() is not None
class _FallbackProbe:
"""Bare-bones SportsCore stand-in that exercises the real _render_game."""
def __init__(self, skin):
from src.base_classes.sports import SportsCore
self._cls = SportsCore
self.SKIN_MODE = "live"
self.logger = logging.getLogger("test_skin_system")
self.sport = "baseball"
self.sport_key = "mlb"
self.skin_options = {}
self.fonts = {"time": ImageFont.load_default()}
self._skin = skin
self._skin_load_attempted = True
self._skin_failures = 0
self._skin_slow_renders = 0
self._skin_config = "test-skin"
self.display_manager = MagicMock()
self.display_manager.width = 128
self.display_manager.height = 32
self.display_manager.image = Image.new("RGB", (128, 32))
self.builtin_calls = 0
def _resolve_skin_id(self):
return "test-skin"
def _draw_scorebug_layout(self, game, force_clear=False):
self.builtin_calls += 1
def _render_game(self, game, force_clear=False):
from src.base_classes.sports import SportsCore
SportsCore._render_game(self, game, force_clear)
def _get_skin(self):
return self._skin
class TestRenderGameFallback:
def test_skin_handles_render(self):
class GoodSkin(ScoreboardSkin):
def render_live(self, ctx, game):
ctx.draw.rectangle([0, 0, 10, 10], fill=(0, 255, 0))
return True
probe = _FallbackProbe(GoodSkin({}, {}))
probe._render_game({"status_text": "Q1"})
assert probe.builtin_calls == 0
probe.display_manager.update_display.assert_called_once()
assert probe.display_manager.image.convert("L").getbbox() is not None
def test_skin_declining_falls_back(self):
probe = _FallbackProbe(ScoreboardSkin({}, {})) # all renders -> False
probe._render_game({"status_text": "Q1"})
assert probe.builtin_calls == 1
def test_no_skin_falls_back(self):
probe = _FallbackProbe(None)
probe._render_game({"status_text": "Q1"})
assert probe.builtin_calls == 1
def test_three_strikes_disables_skin(self):
class BrokenSkin(ScoreboardSkin):
calls = 0
def render_live(self, ctx, game):
BrokenSkin.calls += 1
raise ValueError("kaboom")
probe = _FallbackProbe(BrokenSkin({}, {}))
for i in range(5):
probe._render_game({"status_text": "Q1"})
# every render fell back to the built-in layout...
assert probe.builtin_calls == 5
# ...and the skin stopped being called after the 3rd failure
assert BrokenSkin.calls == 3
assert probe._skin_failures == 3
def test_skin_cannot_mutate_callers_game_dict(self):
class MutatingSkin(ScoreboardSkin):
def render_live(self, ctx, game):
game.clear()
game["hacked"] = True
return True
probe = _FallbackProbe(MutatingSkin({}, {}))
game = {"status_text": "Q1", "home_score": "3"}
probe._render_game(game)
assert game == {"status_text": "Q1", "home_score": "3"}
class TestSkinModeResolution:
def _core(self, skin_config, mode="live"):
from src.base_classes.sports import SportsCore
probe = _FallbackProbe(None)
probe.SKIN_MODE = mode
probe._skin_config = skin_config
return SportsCore._resolve_skin_id(probe)
def test_plain_id_applies_to_all_modes(self):
assert self._core("retro", "live") == "retro"
assert self._core("retro", "recent") == "retro"
def test_per_mode_mapping(self):
cfg = {"live": "retro", "recent": "built-in"}
assert self._core(cfg, "live") == "retro"
assert self._core(cfg, "recent") is None
assert self._core(cfg, "upcoming") is None
def test_builtin_and_empty_mean_none(self):
assert self._core("built-in") is None
assert self._core("") is None
assert self._core(None) is None
class TestViewModelContract:
@pytest.mark.parametrize("sport", ["baseball", "basketball", "football", "hockey"])
@pytest.mark.parametrize("mode", ["live", "recent", "upcoming"])
def test_fixtures_carry_all_guaranteed_keys(self, sport, mode):
with open(FIXTURES_DIR / f"{sport}_{mode}.json") as f:
game = json.load(f)
missing = [k for k in GUARANTEED_KEYS if k not in game]
assert not missing, f"{sport}_{mode} fixture missing {missing}"
def test_extractor_produces_guaranteed_keys(self):
"""The real extractor's output must be a superset of the documented
contract this is the test that catches accidental renames."""
import pytz
from src.base_classes.sports import SportsCore
event = {
"id": "401570001",
"date": "2026-07-16T23:05:00Z",
"competitions": [{
"status": {"type": {"name": "STATUS_IN_PROGRESS", "state": "in",
"shortDetail": "Bot 7th"}},
"competitors": [
{"homeAway": "home", "id": "19",
"team": {"abbreviation": "LAD"}, "score": "5",
"records": [{"summary": "58-33"}]},
{"homeAway": "away", "id": "26",
"team": {"abbreviation": "SF"}, "score": "3",
"records": [{"summary": "49-42"}]},
],
}],
}
probe = MagicMock()
probe.logger = logging.getLogger("test_skin_system")
probe.favorite_teams = []
probe.config = {}
probe.logo_dir = Path("assets/logos")
probe._get_timezone.return_value = pytz.utc
probe.display_manager.format_date_with_ordinal.return_value = "Jul 16th"
details, _, _, _, _ = SportsCore._extract_game_details_common(probe, event)
assert details is not None
missing = [k for k in GUARANTEED_KEYS if k not in details]
assert not missing, (
f"_extract_game_details_common no longer emits {missing}. "
"These keys are part of the frozen skin view-model contract "
"(VIEW_MODEL_VERSION) — renaming or removing them breaks every "
"published skin. Add a compat shim or bump the major version.")
class TestPluginMatching:
def test_matches_by_sport_token_and_sport_key(self, tmp_path):
write_skin(tmp_path, "bb-skin") # targets sports=["baseball"]
skins = skin_runtime.discover_skins(tmp_path, force_refresh=True)
assert "bb-skin" in skin_runtime.skins_for_plugin("baseball-scoreboard", skins)
assert "bb-skin" not in skin_runtime.skins_for_plugin("football-scoreboard", skins)
def test_matches_by_explicit_plugin_list(self, tmp_path):
skin_dir = write_skin(tmp_path, "exact-skin")
manifest = json.loads((skin_dir / "skin.json").read_text())
manifest["targets"] = {"plugins": ["my-custom-plugin"]}
(skin_dir / "skin.json").write_text(json.dumps(manifest))
skins = skin_runtime.discover_skins(tmp_path, force_refresh=True)
assert "exact-skin" in skin_runtime.skins_for_plugin("my-custom-plugin", skins)
assert "exact-skin" not in skin_runtime.skins_for_plugin("baseball-scoreboard", skins)
class TestSchemaInjection:
def _manager(self):
from src.plugin_system.schema_manager import SchemaManager
return SchemaManager()
def test_injects_enum_with_installed_and_configured_skins(self):
sm = self._manager()
schema = {"type": "object", "properties": {}}
out = sm.inject_skin_selector(schema, "baseball-scoreboard",
current_value="gone-skin")
enum = out["properties"]["skin"]["enum"]
assert enum[0] == "built-in"
assert "example-classic-baseball" in enum
# an uninstalled-but-configured skin must stay selectable so the
# saved config never becomes invalid in the UI
assert "gone-skin" in enum
assert "skin" not in schema["properties"] # source schema untouched
def test_no_matching_skins_leaves_schema_alone(self):
sm = self._manager()
schema = {"type": "object", "properties": {}}
out = sm.inject_skin_selector(schema, "totally-unrelated-plugin")
assert "skin" not in out.get("properties", {})
def test_validation_accepts_skin_keys_without_enum(self):
sm = self._manager()
schema = {"type": "object", "properties": {"foo": {"type": "string"}}}
ok, errors = sm.validate_config_against_schema(
{"skin": "any-id-even-uninstalled", "skin_options": {"x": 1}},
schema, "baseball-scoreboard")
assert ok, errors
ok, errors = sm.validate_config_against_schema(
{"skin": {"live": "a", "recent": "built-in"}}, schema, "p")
assert ok, errors
class TestExampleSkin:
@pytest.mark.parametrize("mode", ["live", "recent", "upcoming"])
@pytest.mark.parametrize("size", [(128, 32), (64, 32), (128, 64)])
def test_renders_all_modes_and_sizes(self, mode, size):
skin = skin_runtime.load_skin("example-classic-baseball", sport="baseball")
assert skin is not None
host = _make_host()
host._load_and_resize_logo.return_value = Image.new("RGBA", (32, 32), (200, 0, 0, 255))
with open(FIXTURES_DIR / f"baseball_{mode}.json") as f:
game = json.load(f)
ctx = skin_runtime.build_context(host, game, size=size)
assert getattr(skin, f"render_{mode}")(ctx, game) is True
assert ctx.canvas.convert("L").getbbox() is not None
+634
View File
@@ -0,0 +1,634 @@
"""Characterization tests for src/base_classes/sports.py.
These tests PIN the current behavior of SportsCore / SportsUpcoming /
SportsRecent / SportsLive ahead of the sports-unification merge (features
from nine drifted plugin copies are about to be folded in). They assert
what the code DOES today, not what it should do a few pinned behaviors
look like bugs and are flagged inline with "PINNED AS-IS".
Coverage:
- `_extract_game_details_common` + the four sport extractors
(football/hockey/baseball/basketball) against realistic ESPN scoreboard
events (adapted from the ledmatrix-plugins monorepo test fixtures).
The output must remain a superset of the frozen skin view-model
contract (GUARANTEED_KEYS, imported from test_skin_system).
- update() flow for concrete SportsUpcoming/SportsRecent/SportsLive
subclasses: population, favorite-team filtering, empty/failed-fetch
tolerance. All offline: `_fetch_data` reads a pre-seeded mocked cache
and every instance's requests session raises ConnectionError.
- Rendering smoke: one `display()` per mode class at 128x32 draws
non-zero ink onto a real PIL image.
- Guard rails: the skin-system seam methods on SportsCore must survive
the merge.
"""
import logging
import sys
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import MagicMock
import pytest
import pytz
import requests
from freezegun import freeze_time
from PIL import Image
# src.base_classes.sports transitively imports the hardware matrix driver;
# stub it so these tests can import the sports base classes off-device.
sys.modules.setdefault("rgbmatrix", MagicMock())
from src.base_classes.baseball import Baseball
from src.base_classes.basketball import Basketball
from src.base_classes.football import Football
from src.base_classes.hockey import Hockey, HockeyLive
from src.base_classes.sports import (
SportsCore,
SportsLive,
SportsRecent,
SportsUpcoming,
)
# Reuse the frozen v1.0 skin view-model contract rather than redeclaring it.
from test.test_skin_system import GUARANTEED_KEYS
SPORT_CLASSES = [Football, Hockey, Baseball, Basketball]
SPORT_IDS = ["football", "hockey", "baseball", "basketball"]
# All update()-flow tests run at this frozen instant so the 21-day
# SportsRecent window and time.time() interval gates are deterministic.
FROZEN_NOW = "2026-01-20 12:00:00"
# ---------------------------------------------------------------------------
# ESPN scoreboard event builders (shape adapted from the monorepo fixtures,
# e.g. ledmatrix-plugins/plugins/hockey-scoreboard/test/fixtures/mock.json:
# team-shaped competitors with status/score/records).
# ---------------------------------------------------------------------------
def _competitor(abbr, team_id, score, home_away, record="30-10-5"):
return {
"homeAway": home_away,
"id": team_id,
"score": score,
"team": {
"id": team_id,
"abbreviation": abbr,
"name": abbr.title(),
"displayName": abbr.title(),
"logo": None,
},
"records": [{"summary": record}],
# The hockey extractor iterates competitor["statistics"] and
# returns None for the whole event when the key is absent (see
# test_hockey_event_without_statistics_returns_none).
"statistics": [],
}
def make_event(event_id, state, date, home=("TB", "20", "3"),
away=("DAL", "9", "2"), period=2, clock="12:45",
name=None, short_detail=None, situation=None,
home_record="30-10-5", away_record="25-14-6"):
"""Build a realistic ESPN scoreboard event in the given state
('in' / 'post' / 'pre')."""
defaults = {
"in": ("STATUS_IN_PROGRESS", f"P{period} {clock}"),
"post": ("STATUS_FINAL", "Final"),
"pre": ("STATUS_SCHEDULED", "1/15 - 6:30 PM"),
}
default_name, default_detail = defaults[state]
status = {
"clock": 0.0,
"displayClock": clock,
"period": period,
"type": {
"id": "2",
"name": name or default_name,
"state": state,
"completed": state == "post",
"description": short_detail or default_detail,
"detail": short_detail or default_detail,
"shortDetail": short_detail or default_detail,
},
}
competition = {
"id": event_id,
"date": date,
"status": status,
"competitors": [
_competitor(home[0], home[1], home[2], "home", home_record),
_competitor(away[0], away[1], away[2], "away", away_record),
],
}
if situation is not None:
competition["situation"] = situation
return {
"id": event_id,
"date": date,
"name": f"{away[0]} at {home[0]}",
"shortName": f"{away[0]} @ {home[0]}",
"competitions": [competition],
# Real ESPN payloads duplicate status at the event top level; the
# baseball extractor reads it there for live innings.
"status": status,
}
def make_probe(favorites=None):
"""Bare-bones SportsCore stand-in for exercising the real extractors
unbound (same pattern as TestViewModelContract in test_skin_system)."""
probe = MagicMock()
probe.logger = logging.getLogger("test_sports_base_characterization")
probe.favorite_teams = list(favorites or [])
probe.config = {}
probe.logo_dir = Path("assets/logos")
probe._get_timezone.return_value = pytz.utc
probe.display_manager.format_date_with_ordinal.return_value = "Jan 15th"
# The sport extractors call self._extract_game_details_common — route
# it to the real implementation instead of a MagicMock.
probe._extract_game_details_common = (
lambda event: SportsCore._extract_game_details_common(probe, event))
return probe
def extract(sport_cls, event, favorites=None):
return sport_cls._extract_game_details(make_probe(favorites), event)
# ---------------------------------------------------------------------------
# 1. _extract_game_details_common contract, per wired sport
# ---------------------------------------------------------------------------
class TestExtractGameDetailsContract:
@pytest.mark.parametrize("sport_cls", SPORT_CLASSES, ids=SPORT_IDS)
def test_live_event_guaranteed_keys_and_values(self, sport_cls):
event = make_event("401", "in", "2026-01-15T18:30:00Z")
details = extract(sport_cls, event)
assert details is not None
missing = [k for k in GUARANTEED_KEYS if k not in details]
assert not missing, (
f"{sport_cls.__name__} extractor no longer emits {missing}"
"these keys are the frozen skin view-model contract.")
assert details["id"] == "401"
assert details["home_abbr"] == "TB"
assert details["away_abbr"] == "DAL"
assert details["home_id"] == "20"
assert details["away_id"] == "9"
assert details["home_score"] == "3"
assert details["away_score"] == "2"
assert details["home_record"] == "30-10-5"
assert details["away_record"] == "25-14-6"
assert details["is_live"] is True
assert details["is_final"] is False
assert details["is_upcoming"] is False
assert details["status_text"] == "P2 12:45"
assert details["start_time_utc"] == datetime(
2026, 1, 15, 18, 30, tzinfo=timezone.utc)
# Sport-specific formatting of the same event:
if sport_cls in (Football, Basketball):
assert details["period_text"] == "Q2"
assert details["clock"] == "12:45"
elif sport_cls is Hockey:
assert details["period_text"] == "P2"
assert details["clock"] == "12:45"
else: # Baseball keys inning/status instead of period_text
assert details["inning"] == 2
assert details["status_state"] == "in"
@pytest.mark.parametrize("sport_cls", SPORT_CLASSES, ids=SPORT_IDS)
def test_final_event_classification(self, sport_cls):
event = make_event("402", "post", "2026-01-14T00:00:00Z",
home=("BOS", "1", "4"), away=("TOR", "21", "2"),
period=3, clock="0:00")
details = extract(sport_cls, event)
assert details is not None
assert details["is_final"] is True
assert details["is_live"] is False
assert details["is_upcoming"] is False
assert details["home_score"] == "4"
assert details["away_score"] == "2"
if sport_cls in (Football, Hockey, Basketball):
assert details["period_text"] == "Final"
@pytest.mark.parametrize("sport_cls", SPORT_CLASSES, ids=SPORT_IDS)
def test_upcoming_event_classification(self, sport_cls):
event = make_event("403", "pre", "2026-01-15T18:30:00Z",
home=("NYR", "13", "0"), away=("PIT", "16", "0"),
period=0, clock="0:00")
details = extract(sport_cls, event)
assert details is not None
assert details["is_upcoming"] is True
assert details["is_live"] is False
assert details["is_final"] is False
# Local time formatting (probe timezone is UTC): 18:30Z -> 6:30PM,
# date rendered through display_manager.format_date_with_ordinal.
assert details["game_time"] == "6:30PM"
assert details["game_date"] == "Jan 15th"
def test_halftime_state_flags(self):
# is_halftime keys off name STATUS_HALFTIME (or state "halftime")
# while state "in" still counts as live.
event = make_event("404", "in", "2026-01-15T18:30:00Z",
name="STATUS_HALFTIME", short_detail="Halftime")
details, *_ = SportsCore._extract_game_details_common(
make_probe(), event)
assert details["is_live"] is True
assert details["is_halftime"] is True
def test_state_name_conflict_is_both_final_and_upcoming(self):
# PINNED AS-IS (looks like a bug): is_upcoming also matches on
# status.type.name ('scheduled'/'pre-game'/'status_scheduled'), so
# an event with state="post" but name="Scheduled" reports BOTH
# is_final and is_upcoming True.
event = make_event("405", "post", "2026-01-14T00:00:00Z",
name="Scheduled")
details, *_ = SportsCore._extract_game_details_common(
make_probe(), event)
assert details["is_final"] is True
assert details["is_upcoming"] is True
def test_zero_zero_record_blanked(self):
event = make_event("406", "pre", "2026-01-15T18:30:00Z",
home_record="0-0", away_record="0-0-0")
details, *_ = SportsCore._extract_game_details_common(
make_probe(), event)
assert details["home_record"] == ""
assert details["away_record"] == ""
def test_missing_abbreviation_uses_name_prefix(self):
event = make_event("407", "pre", "2026-01-15T18:30:00Z")
for comp in event["competitions"][0]["competitors"]:
del comp["team"]["abbreviation"]
comp["team"]["name"] = "Sharks" if comp["homeAway"] == "home" \
else "Penguins"
details, *_ = SportsCore._extract_game_details_common(
make_probe(), event)
assert details["home_abbr"] == "Sha"
assert details["away_abbr"] == "Pen"
def test_empty_or_malformed_event_returns_none_tuple(self):
probe = make_probe()
assert SportsCore._extract_game_details_common(probe, {}) == \
(None, None, None, None, None)
assert SportsCore._extract_game_details_common(probe, None) == \
(None, None, None, None, None)
# Malformed event (no competitions) is swallowed, not raised.
assert SportsCore._extract_game_details_common(
probe, {"id": "999"}) == (None, None, None, None, None)
def test_football_live_situation_fields(self):
event = make_event(
"408", "in", "2026-01-15T18:30:00Z",
situation={
"shortDownDistanceText": "3rd & 4",
"downDistanceText": "3rd & 4 at TB 30",
"isRedZone": False,
"possession": "20",
"homeTimeouts": 2,
"awayTimeouts": 3,
})
details = extract(Football, event)
assert details["down_distance_text"] == "3rd & 4"
assert details["down_distance_text_long"] == "3rd & 4 at TB 30"
assert details["possession"] == "20"
assert details["possession_indicator"] == "home" # matches home id
assert details["home_timeouts"] == 2
assert details["away_timeouts"] == 3
def test_hockey_live_power_play_and_default_shots(self):
event = make_event("409", "in", "2026-01-15T18:30:00Z",
situation={"isPowerPlay": True, "penalties": ""})
details = extract(Hockey, event)
assert details["power_play"] is True
# Empty statistics arrays -> save-percentage math yields 0 shots.
assert details["home_shots"] == 0
assert details["away_shots"] == 0
def test_hockey_event_without_statistics_still_extracts(self):
# FIXED (was pinned as returning None): the hockey extractor used to
# iterate competitor["statistics"] unguarded, so a competitor without
# the key raised KeyError internally and the WHOLE event was dropped
# despite valid scores and status. It now defaults to an empty list,
# matching the behaviour already shipped in the hockey plugin, so the
# event survives with zeroed shot counts -- the same values
# test_hockey_live_power_play_and_default_shots already expects for an
# EMPTY statistics array.
event = make_event("410", "in", "2026-01-15T18:30:00Z")
for comp in event["competitions"][0]["competitors"]:
del comp["statistics"]
details = extract(Hockey, event)
assert details is not None
assert details["home_abbr"] == "TB"
assert details["away_abbr"] == "DAL"
assert details["home_score"] == "3"
assert details["home_shots"] == 0
assert details["away_shots"] == 0
def test_baseball_live_inning_and_count(self):
event = make_event(
"411", "in", "2026-07-16T23:05:00Z",
home=("LAD", "19", "5"), away=("SF", "26", "3"),
period=7, short_detail="Bot 7th",
situation={
"count": {"balls": 2, "strikes": 1},
"outs": 2,
"onFirst": True,
"onSecond": False,
"onThird": True,
})
details = extract(Baseball, event)
assert details["inning"] == 7 # from top-level status period
assert details["inning_half"] == "bottom"
assert details["balls"] == 2
assert details["strikes"] == 1
assert details["outs"] == 2
assert details["bases_occupied"] == [True, False, True]
assert details["status"] == "status_in_progress"
assert details["series_summary"] == ""
def test_baseball_live_without_top_level_status_still_extracts(self):
# FIXED (was pinned as returning None): the baseball extractor read
# game_event["status"] -- the event TOP-LEVEL status -- for the
# inning, so an otherwise-valid live event lacking that duplicate key
# was dropped entirely. Real ESPN events carry status in both places,
# but MiLB events (synthesized from the MLB Stats API into an
# ESPN-like shape) populate only the competition-level one. It now
# reads the competition-level `status` that
# _extract_game_details_common has already validated, so it can never
# be missing at that point.
event = make_event("412", "in", "2026-07-16T23:05:00Z", period=7)
del event["status"]
details = extract(Baseball, event)
assert details is not None
assert details["inning"] == 7
# ---------------------------------------------------------------------------
# 2. update() flow on concrete subclasses (offline, cache-fed)
# ---------------------------------------------------------------------------
class _UpcomingHarness(Hockey, SportsUpcoming):
"""Cheapest concrete SportsUpcoming: hockey extractor + cache-fed data."""
def _fetch_data(self):
return self.cache_manager.get(f"{self.sport_key}_schedule")
class _RecentHarness(Hockey, SportsRecent):
def _fetch_data(self):
return self.cache_manager.get(f"{self.sport_key}_schedule")
class _LiveHarness(HockeyLive):
def _fetch_data(self):
return self.cache_manager.get(f"{self.sport_key}_schedule")
def make_schedule():
"""A mixed schedule around the frozen 'now' of 2026-01-20."""
return {"events": [
# Final 6 days ago — inside the recent 21-day window.
make_event("9001", "post", "2026-01-14T00:00:00Z",
home=("BOS", "1", "4"), away=("TOR", "21", "2"),
period=3, clock="0:00"),
# Live game.
make_event("9002", "in", "2026-01-15T00:30:00Z",
home=("TB", "20", "3"), away=("DAL", "9", "2")),
# Two scheduled games.
make_event("9003", "pre", "2026-01-16T00:00:00Z",
home=("NYR", "13", "0"), away=("PIT", "16", "0"),
period=0),
make_event("9004", "pre", "2026-01-17T00:00:00Z",
home=("BOS", "1", "0"), away=("MTL", "10", "0"),
period=0),
# Final from November — outside the recent 21-day window.
make_event("9005", "post", "2025-11-01T00:00:00Z",
home=("SEA", "124292", "1"), away=("VAN", "22", "5"),
period=3, clock="0:00"),
]}
@pytest.fixture
def build_manager(monkeypatch, tmp_path):
"""Factory for concrete sports managers: mocked display/cache managers,
logo dir redirected to tmp, background service stubbed, and the
requests session rigged to prove nothing hits the network."""
monkeypatch.setattr(
SportsCore, "_initialize_logo_dir", lambda self, configured: tmp_path)
monkeypatch.setattr(
"src.base_classes.sports.core.get_background_service",
lambda *args, **kwargs: MagicMock())
def build(cls, schedule, **mode_cfg):
config = {
"timezone": "UTC",
"display": {},
"nhl_scoreboard": {"enabled": True, **mode_cfg},
}
display_manager = MagicMock()
display_manager.matrix.width = 128
display_manager.matrix.height = 32
display_manager.width = 128
display_manager.height = 32
display_manager.image = Image.new("RGB", (128, 32))
display_manager.format_date_with_ordinal.side_effect = (
lambda dt: dt.strftime("%b %d"))
cache_manager = MagicMock()
cache_manager.get.return_value = schedule
cache_manager.cache_dir = str(tmp_path)
manager = cls(config, display_manager, cache_manager,
logging.getLogger("test_sports_base_characterization"),
"nhl")
# Safety net: any accidental network fetch must fail loudly.
manager.session = MagicMock()
manager.session.get.side_effect = requests.exceptions.ConnectionError(
"characterization tests are offline")
return manager
return build
def _ids(games):
return [g["id"] for g in games]
@freeze_time(FROZEN_NOW)
class TestUpcomingUpdateFlow:
def test_populates_games_list_sorted_by_start_time(self, build_manager):
manager = build_manager(_UpcomingHarness, make_schedule())
manager.update()
# PINNED AS-IS: SportsUpcoming filters purely on is_upcoming
# (state 'pre') — there is NO date filter, so 'pre' games whose
# start time is already in the past (9003/9004 vs frozen 1/20)
# are still shown.
assert _ids(manager.games_list) == ["9003", "9004"]
assert manager.current_game["id"] == "9003"
def test_filters_by_favorite_teams(self, build_manager):
manager = build_manager(_UpcomingHarness, make_schedule(),
show_favorite_teams_only=True,
favorite_teams=["BOS"])
manager.update()
assert _ids(manager.games_list) == ["9004"]
assert manager.current_game["id"] == "9004"
def test_favorites_only_with_no_favorites_shows_nothing(
self, build_manager):
# PINNED AS-IS: show_favorite_teams_only=True with an empty
# favorite_teams list drops every game rather than falling back
# to showing all games.
manager = build_manager(_UpcomingHarness, make_schedule(),
show_favorite_teams_only=True,
favorite_teams=[])
manager.update()
assert manager.games_list == []
assert manager.current_game is None
def test_caps_at_upcoming_games_to_show(self, build_manager):
manager = build_manager(_UpcomingHarness, make_schedule(),
upcoming_games_to_show=1)
manager.update()
assert _ids(manager.games_list) == ["9003"]
def test_tolerates_empty_events_list(self, build_manager):
manager = build_manager(_UpcomingHarness, {"events": []})
manager.update() # must not raise
assert manager.games_list == []
assert manager.current_game is None
def test_tolerates_fetch_returning_none(self, build_manager):
manager = build_manager(_UpcomingHarness, None)
manager.update() # must not raise
assert manager.games_list == []
assert manager.current_game is None
def test_disabled_manager_update_is_noop(self, build_manager):
manager = build_manager(_UpcomingHarness, make_schedule(),
enabled=False)
manager.update()
assert manager.games_list == []
manager.cache_manager.get.assert_not_called()
@freeze_time(FROZEN_NOW)
class TestRecentUpdateFlow:
def test_populates_only_finals_within_21_day_window(self, build_manager):
manager = build_manager(_RecentHarness, make_schedule())
manager.update()
# 9001 (final, 6 days old) kept; 9005 (final, ~80 days old)
# excluded by the 21-day cutoff; live/pre games excluded.
assert _ids(manager.games_list) == ["9001"]
assert manager.current_game["id"] == "9001"
assert manager.current_game["is_final"] is True
def test_filters_by_favorite_teams(self, build_manager):
manager = build_manager(_RecentHarness, make_schedule(),
show_favorite_teams_only=True,
favorite_teams=["TOR"])
manager.update()
assert _ids(manager.games_list) == ["9001"]
stranger = build_manager(_RecentHarness, make_schedule(),
show_favorite_teams_only=True,
favorite_teams=["XXX"])
stranger.update()
assert stranger.games_list == []
assert stranger.current_game is None
def test_tolerates_empty_events_list(self, build_manager):
manager = build_manager(_RecentHarness, {"events": []})
manager.update() # must not raise
assert manager.games_list == []
assert manager.current_game is None
@freeze_time(FROZEN_NOW)
class TestLiveUpdateFlow:
def test_selects_only_live_games(self, build_manager):
manager = build_manager(_LiveHarness, make_schedule())
manager.update()
assert _ids(manager.live_games) == ["9002"]
assert manager.current_game["id"] == "9002"
assert manager.current_game["is_live"] is True
def test_no_live_games_clears_current_game(self, build_manager):
schedule = {"events": [
make_event("9001", "post", "2026-01-14T00:00:00Z"),
make_event("9003", "pre", "2026-01-16T00:00:00Z", period=0),
]}
manager = build_manager(_LiveHarness, schedule)
manager.update()
assert manager.live_games == []
assert manager.current_game is None
# ---------------------------------------------------------------------------
# 3. Rendering smoke — one display() per mode class at 128x32
# ---------------------------------------------------------------------------
def _fake_logo(*args, **kwargs):
return Image.new("RGBA", (24, 24), (180, 30, 30, 255))
@freeze_time(FROZEN_NOW)
class TestRenderingSmoke:
def _assert_rendered(self, manager):
manager.display_manager.update_display.assert_called()
assert manager.display_manager.image.convert("L").getbbox() is not None
def test_upcoming_display_draws_ink(self, build_manager):
manager = build_manager(_UpcomingHarness, make_schedule())
manager.update()
manager._load_and_resize_logo = _fake_logo
assert manager.display(force_clear=True) is True
self._assert_rendered(manager)
def test_recent_display_draws_ink(self, build_manager):
manager = build_manager(_RecentHarness, make_schedule())
manager.update()
manager._load_and_resize_logo = _fake_logo
assert manager.display(force_clear=True) is True
self._assert_rendered(manager)
def test_live_display_draws_ink(self, build_manager):
manager = build_manager(_LiveHarness, make_schedule())
manager.update()
manager._load_and_resize_logo = _fake_logo
assert manager.display(force_clear=True) is True
self._assert_rendered(manager)
def test_draw_scorebug_layout_direct_call_does_not_raise(
self, build_manager):
# The base-class placeholder renderer must also stay callable.
manager = build_manager(_UpcomingHarness, make_schedule())
game = manager._extract_game_details(make_schedule()["events"][2])
manager._load_and_resize_logo = _fake_logo
SportsCore._draw_scorebug_layout(manager, game)
assert manager.display_manager.image.convert("L").getbbox() is not None
# ---------------------------------------------------------------------------
# 4. Guard rails — seams the merge must not silently drop
# ---------------------------------------------------------------------------
class TestGuardRails:
def test_skin_seam_methods_survive(self):
for name in ("_resolve_skin_id", "_get_skin", "_render_game",
"render_skin_card"):
assert callable(getattr(SportsCore, name, None)), (
f"SportsCore.{name} is part of the skin-system seam "
"(src/skin_system) — the sports-unification merge must "
"keep it.")
def test_skin_mode_per_class(self):
assert SportsCore.SKIN_MODE == "live"
assert SportsUpcoming.SKIN_MODE == "upcoming"
assert SportsRecent.SKIN_MODE == "recent"
assert SportsLive.SKIN_MODE == "live" # inherits the default
def test_core_display_and_extractor_seams_survive(self):
for name in ("display", "_draw_scorebug_layout",
"_extract_game_details_common", "update"):
owner = SportsCore if name != "update" else SportsUpcoming
assert callable(getattr(owner, name, None)), name
+764
View File
@@ -0,0 +1,764 @@
"""Tests for the opt-in sports capabilities (phase B2).
Two properties matter beyond "the code works":
1. **Opting out is structural.** A mode class that does not mix in
``CelebrationMixin`` must have none of its attributes or methods not
merely a disabled flag. ``TestOptOutIsStructural`` asserts that directly,
because it is the property the whole mixin design exists to buy.
2. **The promoted behavior matches the plugin copies.** These bodies came from
afl/soccer/nrl (goal dialect) and football (score dialect); the tests pin
the reconciled behavior of both, including the three seams where the
lineages genuinely disagreed.
See docs/SPORTS_UNIFICATION.md.
"""
import sys
from unittest.mock import MagicMock
import pytest
sys.modules.setdefault("rgbmatrix", MagicMock())
from src.base_classes.sports.capabilities import ( # noqa: E402
CelebrationMixin,
RotationStrategy,
SimpleRotation,
SmoothWeightedRotation,
WeightedCycleRotation,
get_rotation_strategy,
register_rotation_strategy,
)
def game(gid, home="HOM", away="AWY", home_score=0, away_score=0, **extra):
g = {
"id": gid,
"home_abbr": home,
"away_abbr": away,
"home_id": f"{gid}-h",
"away_id": f"{gid}-a",
"home_score": home_score,
"away_score": away_score,
}
g.update(extra)
return g
# ---------------------------------------------------------------------------
# Rotation strategies
# ---------------------------------------------------------------------------
def boost(favorites, factor=3):
"""A weight_for callable of the shape the plugins supply."""
return lambda g: factor if g.get("home_abbr") in favorites else 1
class TestRegistry:
@pytest.mark.parametrize("name,cls", [
("simple", SimpleRotation),
("weighted", WeightedCycleRotation),
("swrr", SmoothWeightedRotation),
])
def test_builtin_names_resolve(self, name, cls):
assert isinstance(get_rotation_strategy(name), cls)
def test_unknown_name_falls_back_to_simple(self):
"""The name comes from user config; a typo should cost the boost, not
the scoreboard."""
assert isinstance(get_rotation_strategy("typo"), SimpleRotation)
def test_a_plugin_can_register_its_own(self):
class MyRotation(SimpleRotation):
pass
register_rotation_strategy("test-only", MyRotation)
try:
assert isinstance(get_rotation_strategy("test-only"), MyRotation)
assert MyRotation.name == "test-only"
finally:
from src.base_classes.sports.capabilities import rotation
rotation._REGISTRY.pop("test-only", None)
def test_empty_name_is_rejected(self):
with pytest.raises(ValueError):
register_rotation_strategy("", SimpleRotation)
def test_weight_for_is_optional(self):
"""Default weights are equal, so every strategy degenerates to a plain
round robin the pre-boost behavior."""
games = [game("a"), game("b"), game("c")]
for name in ("simple", "weighted", "swrr"):
assert get_rotation_strategy(name).schedule(games) == ["a", "b", "c"]
class TestWeights:
def test_games_without_an_id_are_skipped(self):
strategy = get_rotation_strategy("weighted")
assert strategy.weights([game("a"), {"home_abbr": "X"}]) == {"a": 1}
@pytest.mark.parametrize("bad", [0, -5])
def test_non_positive_weights_are_clamped_to_one(self, bad):
"""A zero weight would starve the game out of the rotation entirely and
collapse total_weight no caller means that."""
strategy = get_rotation_strategy("weighted", weight_for=lambda g: bad)
assert strategy.weights([game("a")]) == {"a": 1}
@pytest.mark.parametrize("bad", [None, "three", object()])
def test_unusable_weights_fall_back_to_one(self, bad):
strategy = get_rotation_strategy("weighted", weight_for=lambda g: bad)
assert strategy.weights([game("a")]) == {"a": 1}
class TestSimpleRotation:
def test_one_pass_in_feed_order(self):
games = [game("a"), game("b"), game("c")]
assert SimpleRotation().schedule(games) == ["a", "b", "c"]
def test_weights_are_ignored(self):
games = [game("a", home="FAV"), game("b")]
strategy = SimpleRotation(weight_for=boost({"FAV"}, 5))
assert strategy.schedule(games) == ["a", "b"]
def test_empty(self):
assert SimpleRotation().schedule([]) == []
assert SimpleRotation().next_game([]) is None
class TestWeightedCycleRotation:
def test_no_boost_is_a_single_pass(self):
games = [game("a"), game("b"), game("c")]
strategy = WeightedCycleRotation(weight_for=boost({"NONE"}))
assert strategy.schedule(games) == ["a", "b", "c"]
def test_favorite_gets_boost_many_slots(self):
games = [game("a", home="FAV"), game("b")]
order = WeightedCycleRotation(weight_for=boost({"FAV"}, 3)).schedule(games)
assert len(order) == 4
assert order.count("a") == 3
assert order.count("b") == 1
def test_repeats_are_spaced_not_clumped(self):
"""The point of SWRR over naive repetition: 'aaab' is what we must NOT
produce."""
games = [game("a", home="FAV"), game("b")]
order = WeightedCycleRotation(weight_for=boost({"FAV"}, 3)).schedule(games)
assert order != ["a", "a", "a", "b"]
assert order[0] == "a", "highest weight is scheduled first"
def test_is_stateless_across_calls(self):
games = [game("a", home="FAV"), game("b")]
strategy = WeightedCycleRotation(weight_for=boost({"FAV"}, 3))
assert strategy.schedule(games) == strategy.schedule(games)
def test_next_game_returns_the_first_of_the_cycle(self):
games = [game("a"), game("b", home="FAV")]
strategy = WeightedCycleRotation(weight_for=boost({"FAV"}, 4))
assert strategy.next_game(games)["id"] == "b"
def test_empty(self):
assert WeightedCycleRotation().schedule([]) == []
class TestSmoothWeightedRotation:
def test_no_boost_is_plain_round_robin(self):
games = [game("a"), game("b"), game("c")]
strategy = SmoothWeightedRotation()
assert [strategy.next_game(games)["id"] for _ in range(6)] == [
"a", "b", "c", "a", "b", "c"]
def test_favorite_wins_the_share_over_a_long_run(self):
games = [game("a", home="FAV"), game("b")]
strategy = SmoothWeightedRotation(weight_for=boost({"FAV"}, 3))
picks = [strategy.next_game(games)["id"] for _ in range(40)]
assert picks.count("a") == 30
assert picks.count("b") == 10
def test_no_clustering_seam_across_cycle_boundaries(self):
"""The property that motivates keeping this strategy separate from the
precomputed one: state persists, so there is no restart every N picks
and therefore no place where repeats bunch up."""
games = [game("a", home="FAV"), game("b")]
strategy = SmoothWeightedRotation(weight_for=boost({"FAV"}, 3))
picks = [strategy.next_game(games)["id"] for _ in range(40)]
assert "aaaa" not in "".join(picks)
def test_a_new_favorite_is_queued_first(self):
"""A favorite's game that has just gone live starts at weight 0, gets
its full weight on the next call, and so wins the first pick after it
appears without a special-cased branch."""
games = [game("a"), game("b")]
strategy = SmoothWeightedRotation(weight_for=boost({"FAV"}, 5))
for _ in range(3):
strategy.next_game(games)
games.append(game("c", home="FAV"))
assert strategy.next_game(games)["id"] == "c"
def test_state_for_games_no_longer_live_is_dropped(self):
games = [game("a"), game("b")]
strategy = SmoothWeightedRotation()
strategy.next_game(games)
strategy.next_game([game("a")])
assert set(strategy._current) == {"a"}
def test_reset_clears_state(self):
games = [game("a"), game("b")]
strategy = SmoothWeightedRotation()
strategy.next_game(games)
strategy.reset()
assert strategy._current == {}
assert strategy.next_game(games)["id"] == "a"
def test_schedule_previews_without_perturbing_state(self):
games = [game("a", home="FAV"), game("b")]
strategy = SmoothWeightedRotation(weight_for=boost({"FAV"}, 3))
preview = strategy.schedule(games)
actual = [strategy.next_game(games)["id"] for _ in range(len(preview))]
assert preview == actual
def test_empty(self):
assert SmoothWeightedRotation().next_game([]) is None
assert SmoothWeightedRotation().schedule([]) == []
def test_games_without_ids_are_ignored(self):
assert SmoothWeightedRotation().next_game([{"home_abbr": "X"}]) is None
class TestStrategiesAgreeWithinACycle:
"""The survey's core finding: the 'three dialects' are one algorithm. They
must produce the same order within a cycle; they differ only at the
boundary, which is why both shapes survive."""
@pytest.mark.parametrize("factor", [2, 3, 5])
def test_first_cycle_matches(self, factor):
games = [game("a", home="FAV"), game("b"), game("c")]
weight_for = boost({"FAV"}, factor)
assert (SmoothWeightedRotation(weight_for=weight_for).schedule(games)
== WeightedCycleRotation(weight_for=weight_for).schedule(games))
# ---------------------------------------------------------------------------
# Differential: core strategies vs. the plugin implementations they replace
# ---------------------------------------------------------------------------
BOOST = 3
def _is_fav(g):
return g.get("home_abbr") == "FAV"
def _weight_for(g):
return BOOST if _is_fav(g) else 1
class _PluginSwrr:
"""afl / nrl / soccer ``_swrr_advance``, transcribed verbatim."""
favorite_live_boost = BOOST
def _is_favorite_game(self, g):
return _is_fav(g)
def advance(self, games):
if not games:
return None
weights = {}
for g in games:
gid = g.get("id")
if gid is None:
continue
weights[gid] = self.favorite_live_boost if self._is_favorite_game(g) else 1
if not weights:
return None
if not hasattr(self, "_swrr_weights"):
self._swrr_weights = {}
self._swrr_weights = {
gid: w for gid, w in self._swrr_weights.items() if gid in weights}
for gid, w in weights.items():
self._swrr_weights[gid] = self._swrr_weights.get(gid, 0) + w
total_weight = sum(weights.values())
ids_in_order = [g.get("id") for g in games if g.get("id") in weights]
best_gid = max(ids_in_order, key=lambda gid: self._swrr_weights[gid])
self._swrr_weights[best_gid] -= total_weight
return next(g for g in games if g.get("id") == best_gid)
def _plugin_weighted_schedule(games):
"""football / baseball / basketball ``_build_weighted_schedule``, verbatim."""
if not games:
return []
weights = {g["id"]: (BOOST if _is_fav(g) else 1) for g in games}
total_weight = sum(weights.values())
if total_weight <= len(games):
return [g["id"] for g in games]
current_weight = {gid: 0 for gid in weights}
schedule = []
for _ in range(total_weight):
for gid in weights:
current_weight[gid] += weights[gid]
picked = max(current_weight, key=lambda gid: current_weight[gid])
current_weight[picked] -= total_weight
schedule.append(picked)
return schedule
def _plugin_rotation_schedule(games):
"""hockey ``_build_rotation_schedule``, transcribed verbatim."""
weights = [(g["id"], BOOST if _is_fav(g) else 1) for g in games]
total_weight = sum(w for _, w in weights)
if not weights or total_weight <= 0:
return [g["id"] for g in games]
current_weights = {gid: 0 for gid, _ in weights}
schedule = []
for _ in range(total_weight):
best_id, best_current = None, None
for gid, w in weights:
current_weights[gid] += w
if best_current is None or current_weights[gid] > best_current:
best_id, best_current = gid, current_weights[gid]
current_weights[best_id] -= total_weight
schedule.append(best_id)
return schedule
def _cases():
"""Every live-game shape up to 4 games: each either a favorite or not.
Exhaustive rather than random so the gate is deterministic a rotation
regression must fail the same way on every run.
"""
import itertools
for size in range(1, 5):
for flags in itertools.product(("FAV", "OTH"), repeat=size):
yield [game(f"g{i}", home=abbr) for i, abbr in enumerate(flags)]
class TestMatchesThePluginImplementations:
"""The promotion is only safe if these reproduce the plugin copies exactly.
B5 deletes the bundled copies on the strength of this: each core strategy is
checked against the verbatim source it replaces, over every live-game shape
up to four games.
"""
@pytest.mark.parametrize("games", list(_cases()))
def test_swrr_matches_the_incremental_plugin_picker(self, games):
plugin = _PluginSwrr()
core = SmoothWeightedRotation(weight_for=_weight_for)
# 60 picks: long enough to cross many cycle boundaries, where a
# state-handling divergence would show up.
assert ([plugin.advance(games)["id"] for _ in range(60)]
== [core.next_game(games)["id"] for _ in range(60)])
@pytest.mark.parametrize("games", list(_cases()))
def test_weighted_matches_the_football_lineage(self, games):
assert (_plugin_weighted_schedule(games)
== WeightedCycleRotation(weight_for=_weight_for).schedule(games))
@pytest.mark.parametrize("games", list(_cases()))
def test_weighted_matches_hockeys_loop_shape(self, games):
assert (_plugin_rotation_schedule(games)
== WeightedCycleRotation(weight_for=_weight_for).schedule(games))
# ---------------------------------------------------------------------------
# Celebrations
# ---------------------------------------------------------------------------
class _FakeLive:
"""Stand-in for SportsLive: just the surface the mixin touches."""
def __init__(self, mode_config=None, favorite_teams=None):
self.mode_config = mode_config or {}
self.favorite_teams = favorite_teams or []
self.logger = MagicMock()
self.display_manager = MagicMock()
self.is_enabled = True
self.current_game = None
self.last_game_switch = 0
self.display_calls = []
def _favorite_key(self, game, side):
return game.get(f"{side}_abbr")
def display(self, force_clear=False):
self.display_calls.append(force_clear)
return True
class _Celebrating(CelebrationMixin, _FakeLive):
pass
class _Coalescing(CelebrationMixin, _FakeLive):
COALESCE_SCORING_SEQUENCE = True
def score_phrase(self, points, team_abbr):
return "TOUCHDOWN!" if points >= 6 else f"{team_abbr} FIELD GOAL!"
class _ById(CelebrationMixin, _FakeLive):
"""The nrl shape: ambiguous abbreviations, so favorites match on team id."""
def _favorite_key(self, game, side):
return game.get(f"{side}_id")
@pytest.fixture
def celebrating():
def _build(cls=_Celebrating, mode_config=None, favorites=None):
return cls(mode_config=mode_config, favorite_teams=favorites)
return _build
class TestOptOutIsStructural:
"""The property the mixin design exists to buy: a class that does not opt in
has none of this code not a disabled flag, not an unused attribute."""
def test_a_non_celebrating_class_has_no_celebration_surface(self):
plain = _FakeLive()
for attribute in ("active_celebration", "_score_baselines",
"celebration_enabled", "celebration_duration",
"_check_for_score", "_check_for_win",
"has_active_celebration", "_draw_celebration_layout"):
assert not hasattr(plain, attribute), (
f"{attribute} leaked onto a class that never opted in")
def test_the_mixin_is_absent_from_a_non_celebrating_mro(self):
assert CelebrationMixin not in _FakeLive.__mro__
assert CelebrationMixin in _Celebrating.__mro__
def test_mixin_does_not_require_the_base_to_know_about_it(self):
"""SportsLive must carry no celebration hooks — that would be the
god-class shape the mixin replaces."""
from src.base_classes.sports import SportsLive
source = __import__("inspect").getsource(SportsLive)
assert "celebration" not in source.lower()
class TestCelebrationConfig:
def test_defaults(self, celebrating):
manager = celebrating()
assert manager.celebration_enabled is True
assert manager.celebration_duration == 8
assert manager.celebrate_opponent_scores is False
assert manager.active_celebration is None
def test_reads_the_goal_spelling_of_the_opponent_key(self, celebrating):
"""The soccer lineage's published schema says `celebrate_opponent_goals`;
adopting the mixin must not silently reset users' setting."""
manager = celebrating(mode_config={"celebrate_opponent_goals": True})
assert manager.celebrate_opponent_scores is True
def test_reads_the_score_spelling_of_the_opponent_key(self, celebrating):
manager = celebrating(mode_config={"celebrate_opponent_scores": True})
assert manager.celebrate_opponent_scores is True
def test_score_spelling_wins_when_both_are_present(self, celebrating):
manager = celebrating(mode_config={"celebrate_opponent_scores": False,
"celebrate_opponent_goals": True})
assert manager.celebrate_opponent_scores is False
class TestScoreDetection:
def test_first_sighting_never_celebrates(self, celebrating):
"""A game already in progress at boot must not false-fire."""
manager = celebrating()
manager._check_for_score(game("g1", home_score=3, away_score=1))
assert manager.active_celebration is None
assert manager._score_baselines["g1"] == {"away": 1, "home": 3}
def test_increment_arms_a_celebration(self, celebrating):
manager = celebrating()
manager._check_for_score(game("g1", home_score=0, away_score=0))
manager._check_for_score(game("g1", home_score=1, away_score=0))
assert manager.active_celebration["kind"] == "score"
assert manager.active_celebration["scored_side"] == "home"
def test_no_change_does_not_fire(self, celebrating):
manager = celebrating()
manager._check_for_score(game("g1", home_score=2))
manager._check_for_score(game("g1", home_score=2))
assert manager.active_celebration is None
def test_decrement_rebases_silently(self, celebrating):
"""A disallowed goal / correction must not celebrate, and must not leave
a stale baseline that fires on the way back up."""
manager = celebrating()
manager._check_for_score(game("g1", home_score=2))
manager._check_for_score(game("g1", home_score=1))
assert manager.active_celebration is None
assert manager._score_baselines["g1"]["home"] == 1
def test_disabled_never_fires(self, celebrating):
manager = celebrating(mode_config={"celebration_enabled": False})
manager._check_for_score(game("g1", home_score=0))
manager._check_for_score(game("g1", home_score=1))
assert manager.active_celebration is None
def test_game_without_an_id_is_ignored(self, celebrating):
manager = celebrating()
manager._check_for_score({"home_score": 1, "away_score": 0})
assert manager.active_celebration is None
@pytest.mark.parametrize("score", [None, "", "not-a-number-at-all"])
def test_unusable_scores_are_ignored(self, celebrating, score):
manager = celebrating()
manager._check_for_score(game("g1", home_score=score))
assert manager._score_baselines == {}
@pytest.mark.parametrize("raw,expected", [
("7", 7), (7, 7), (7.0, 7), (" 7 ", 7), ("7 (SO)", 7),
({"value": 7}, 7), ({"displayValue": "7"}, 7),
])
def test_score_coercion(self, raw, expected):
assert CelebrationMixin._score_to_int(raw) == expected
def test_away_side_is_detected(self, celebrating):
manager = celebrating()
manager._check_for_score(game("g1", away_score=0))
manager._check_for_score(game("g1", away_score=1))
assert manager.active_celebration["scored_side"] == "away"
class TestWhoGetsCelebrated:
def test_no_favorites_celebrates_everyone(self, celebrating):
"""The user opted to show this game at all, so any score in it counts."""
manager = celebrating(favorites=[])
manager._check_for_score(game("g1", home="XXX", home_score=0))
manager._check_for_score(game("g1", home="XXX", home_score=1))
assert manager.active_celebration is not None
def test_favorite_scores(self, celebrating):
manager = celebrating(favorites=["FAV"])
manager._check_for_score(game("g1", home="FAV", home_score=0))
manager._check_for_score(game("g1", home="FAV", home_score=1))
assert manager.active_celebration is not None
def test_opponent_suppressed_by_default(self, celebrating):
manager = celebrating(favorites=["FAV"])
manager._check_for_score(game("g1", home="OPP", away="FAV", home_score=0))
manager._check_for_score(game("g1", home="OPP", away="FAV", home_score=1))
assert manager.active_celebration is None
def test_opponent_celebrated_when_opted_in(self, celebrating):
manager = celebrating(mode_config={"celebrate_opponent_scores": True},
favorites=["FAV"])
manager._check_for_score(game("g1", home="OPP", away="FAV", home_score=0))
manager._check_for_score(game("g1", home="OPP", away="FAV", home_score=1))
assert manager.active_celebration is not None
def test_matching_goes_through_the_favorite_key_seam(self, celebrating):
"""nrl matches on team id because its abbreviations are ambiguous
('NEW' is both Newcastle and New Zealand). Core must not care why."""
manager = celebrating(_ById, favorites=["g1-h"])
manager._check_for_score(game("g1", home="NEW", home_score=0))
manager._check_for_score(game("g1", home="NEW", home_score=1))
assert manager.active_celebration is not None
def test_favorite_key_seam_also_excludes(self, celebrating):
manager = celebrating(_ById, favorites=["someone-else"])
manager._check_for_score(game("g1", home="NEW", home_score=0))
manager._check_for_score(game("g1", home="NEW", home_score=1))
assert manager.active_celebration is None
class TestPhrasing:
def test_default_phrase_is_sport_neutral(self, celebrating):
manager = celebrating()
manager._check_for_score(game("g1", home="HOM", home_score=0))
manager._check_for_score(game("g1", home="HOM", home_score=1))
assert manager.active_celebration["phrase"] == "HOM SCORES!"
def test_score_phrase_hook_sees_the_points_delta(self, celebrating):
manager = celebrating(_Coalescing)
manager._check_for_score(game("g1", home_score=0))
manager._check_for_score(game("g1", home_score=6))
assert manager.active_celebration["phrase"] == "TOUCHDOWN!"
def test_score_phrase_hook_distinguishes_smaller_plays(self, celebrating):
manager = celebrating(_Coalescing)
manager._check_for_score(game("g1", home_score=0))
manager._check_for_score(game("g1", home_score=3))
assert manager.active_celebration["phrase"] == "HOM FIELD GOAL!"
def test_win_phrase(self, celebrating):
manager = celebrating(favorites=["HOM"])
manager._check_for_score(game("g1", home_score=1))
manager._check_for_win(game("g1", home_score=2, away_score=1))
assert manager.active_celebration["phrase"] == "HOM WINS!"
class TestCoalescing:
def test_off_by_default_two_goals_are_two_celebrations(self, celebrating):
"""Soccer/afl/nrl: consecutive increments are distinct events, so
suppressing the second would swallow a real goal."""
manager = celebrating()
manager._check_for_score(game("g1", home_score=0))
manager._check_for_score(game("g1", home_score=1))
first = manager.active_celebration["started_at"]
manager._check_for_score(game("g1", home_score=2))
assert manager.active_celebration["started_at"] != first
assert manager.active_celebration["home_score"] == 2
def test_on_suppresses_the_extra_point_follow_up(self, celebrating):
"""Football: a touchdown lands as +6, then +1 seconds later. One
takeover per scoring sequence."""
manager = celebrating(_Coalescing)
manager._check_for_score(game("g1", home_score=0))
manager._check_for_score(game("g1", home_score=6))
armed = manager.active_celebration
manager._check_for_score(game("g1", home_score=7))
assert manager.active_celebration is armed
def test_suppression_still_advances_the_baseline(self, celebrating):
"""Nothing may re-fire once the window closes."""
manager = celebrating(_Coalescing)
manager._check_for_score(game("g1", home_score=0))
manager._check_for_score(game("g1", home_score=6))
manager._check_for_score(game("g1", home_score=7))
assert manager._score_baselines["g1"]["home"] == 7
class TestWinDetection:
def test_win_requires_a_baseline(self, celebrating):
"""A game seen for the first time already-final (board started after
full time) must not fire."""
manager = celebrating(favorites=["HOM"])
manager._check_for_win(game("g1", home_score=3, away_score=1))
assert manager.active_celebration is None
def test_win_fires_once_only(self, celebrating):
manager = celebrating(favorites=["HOM"])
manager._check_for_score(game("g1", home_score=1))
manager._check_for_win(game("g1", home_score=3, away_score=1))
manager.active_celebration = None
manager._check_for_win(game("g1", home_score=3, away_score=1))
assert manager.active_celebration is None
def test_draw_does_not_celebrate(self, celebrating):
manager = celebrating(favorites=["HOM"])
manager._check_for_score(game("g1", home_score=1))
manager._check_for_win(game("g1", home_score=2, away_score=2))
assert manager.active_celebration is None
def test_win_is_gated_strictly_on_favorites(self, celebrating):
"""Unlike scores, a win with no favorites configured does NOT celebrate:
every game ends, so the fallback would be constant noise."""
manager = celebrating(favorites=[])
manager._check_for_score(game("g1", home_score=1))
manager._check_for_win(game("g1", home_score=3, away_score=1))
assert manager.active_celebration is None
def test_losing_favorite_does_not_celebrate(self, celebrating):
manager = celebrating(favorites=["HOM"])
manager._check_for_score(game("g1", home_score=1))
manager._check_for_win(game("g1", home_score=1, away_score=4))
assert manager.active_celebration is None
def test_away_favorite_wins(self, celebrating):
manager = celebrating(favorites=["AWY"])
manager._check_for_score(game("g1", away_score=1))
manager._check_for_win(game("g1", home_score=1, away_score=4))
assert manager.active_celebration["scored_side"] == "away"
class TestCelebrationSnapshot:
def test_the_game_is_snapshotted_not_referenced(self, celebrating):
"""A win must survive the game leaving live_games."""
manager = celebrating()
live = game("g1", home_score=0)
manager._check_for_score(live)
live = game("g1", home_score=1)
manager._check_for_score(live)
live["home_abbr"] = "MUTATED"
assert manager.active_celebration["game"]["home_abbr"] == "HOM"
def test_focus_is_pinned_to_the_involved_game(self, celebrating):
manager = celebrating()
manager._check_for_score(game("g1", home_score=0))
manager._check_for_score(game("g1", home_score=1))
assert manager.current_game["id"] == "g1"
class TestDisplayTakeover:
def test_no_celebration_defers_to_the_scorebug(self, celebrating):
manager = celebrating()
assert manager.display(force_clear=True) is True
assert manager.display_calls == [True]
def test_active_celebration_takes_over(self, celebrating):
manager = celebrating()
manager._check_for_score(game("g1", home_score=0))
manager._check_for_score(game("g1", home_score=1))
manager._draw_celebration_layout = MagicMock()
assert manager.display() is True
assert manager.display_calls == [], "the scorebug must not also render"
manager._draw_celebration_layout.assert_called_once()
def test_expired_celebration_clears_and_defers(self, celebrating):
manager = celebrating(mode_config={"celebration_duration": 0})
manager._check_for_score(game("g1", home_score=0))
manager._check_for_score(game("g1", home_score=1))
assert manager.display() is True
assert manager.active_celebration is None
assert manager.display_calls == [False]
def test_expiry_resets_the_dwell_clock(self, celebrating):
"""So the scorebug resumes on the scoring game for a full duration
before rotation can move on."""
manager = celebrating(mode_config={"celebration_duration": 0})
manager._check_for_score(game("g1", home_score=0))
manager._check_for_score(game("g1", home_score=1))
manager.display()
assert manager.last_game_switch > 0
def test_a_render_failure_falls_through_to_the_scorebug(self, celebrating):
"""A broken celebration must never blank the display."""
manager = celebrating()
manager._check_for_score(game("g1", home_score=0))
manager._check_for_score(game("g1", home_score=1))
manager._draw_celebration_layout = MagicMock(side_effect=RuntimeError("boom"))
assert manager.display() is True
assert manager.display_calls == [False]
def test_disabled_manager_renders_nothing(self, celebrating):
manager = celebrating()
manager.is_enabled = False
assert manager.display() is False
class TestFitFont:
def test_returns_the_first_font_that_fits(self, celebrating):
manager = celebrating()
draw = MagicMock()
draw.textlength.side_effect = [100, 20]
big, small = MagicMock(), MagicMock()
assert manager._fit_font(draw, "GOAL", 50, [big, small]) is small
def test_falls_back_to_the_smallest(self, celebrating):
manager = celebrating()
draw = MagicMock()
draw.textlength.return_value = 999
big, small = MagicMock(), MagicMock()
assert manager._fit_font(draw, "GOAL", 50, [big, small]) is small
class TestCapabilityExports:
@pytest.mark.parametrize("name", [
"CelebrationMixin", "RotationStrategy", "SimpleRotation",
"SmoothWeightedRotation", "WeightedCycleRotation",
"get_rotation_strategy", "register_rotation_strategy",
])
def test_public_name_is_importable(self, name):
"""Plugins import these behind a guarded fallback; the names are the
contract."""
import src.base_classes.sports.capabilities as capabilities
assert hasattr(capabilities, name)
def test_rotation_strategy_base_requires_a_schedule(self):
with pytest.raises(NotImplementedError):
RotationStrategy().schedule([game("a")])
+590
View File
@@ -0,0 +1,590 @@
"""Tests for the methods promoted onto SportsCore from the nine bundled
plugin copies of ``sports.py`` (phase B1 of docs/SPORTS_UNIFICATION.md).
Three methods and their seams land here:
- ``cleanup()`` byte-identical in all nine copies. The tests pin the
ordering (session close, then caches, then the completion log) and the
deliberate *omission*: the process-wide background service must never be
shut down by one unloading plugin.
- ``_get_layout_offset()`` football's resolver-backed variant, with the
classic inline config read as the fallback used by every plugin that
doesn't hand core a ``_config_schema_path()``.
- ``_load_custom_font_from_element_config()`` baseball's body (the only
copy that handles BDF strikes correctly) under hockey's wider signature,
resolving font files through the ``_font_root()`` seam instead of the
process cwd.
"""
import ast
import json
import logging
import os
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from PIL import Image, ImageFont
# src.base_classes.sports transitively imports the hardware matrix driver;
# stub it so these tests can import the sports base classes off-device.
sys.modules.setdefault("rgbmatrix", MagicMock())
from src.base_classes.sports import SportsCore
LOGGER = logging.getLogger("test_sports_core_promotions")
CORE_ROOT = Path(__file__).resolve().parents[1]
FONTS_DIR = CORE_ROOT / "assets" / "fonts"
TTF_NAME = "PressStart2P-Regular.ttf"
BDF_NAME = "5x7.bdf" # a BDF whose only valid strike is 7px
BDF_NATIVE_SIZE = 7
class _StubSports(SportsCore):
"""Minimal concrete SportsCore — the abstract methods are never called
by anything under test here."""
def _fetch_data(self):
return None
def _extract_game_details(self, game_event):
return None
@pytest.fixture
def build(monkeypatch, tmp_path):
"""Factory for real SportsCore instances: logo dir redirected to tmp and
the process-wide background service replaced with a MagicMock so the
tests can assert nothing ever calls it."""
monkeypatch.setattr(
SportsCore, "_initialize_logo_dir", lambda self, configured: tmp_path)
monkeypatch.setattr(
"src.base_classes.sports.core.get_background_service",
lambda *args, **kwargs: MagicMock())
def _build(config=None, cls=_StubSports):
display_manager = MagicMock()
display_manager.matrix.width = 128
display_manager.matrix.height = 32
display_manager.width = 128
display_manager.height = 32
display_manager.image = Image.new("RGB", (128, 32))
cache_manager = MagicMock()
cache_manager.cache_dir = str(tmp_path)
return cls(config if config is not None else {"timezone": "UTC"},
display_manager, cache_manager, LOGGER, "nhl")
return _build
def probe(config=None):
"""Unbound-call stand-in for hosts we don't need a full instance for
(same pattern as make_probe in test_sports_base_characterization)."""
host = MagicMock()
host.logger = LOGGER
host.config = config if config is not None else {}
host._font_cache = {}
host._bdf_native_size_cache = {}
host._config_schema_path.return_value = None
host._font_root.side_effect = lambda: SportsCore._font_root(host)
host._resolve_font_path.side_effect = (
lambda name: SportsCore._resolve_font_path(host, name))
return host
def offset(host, element, axis, default=0):
return SportsCore._get_layout_offset(host, element, axis, default)
def load_font(host, *args, **kwargs):
return SportsCore._load_custom_font_from_element_config(host, *args, **kwargs)
# ---------------------------------------------------------------------------
# 1. cleanup()
# ---------------------------------------------------------------------------
class TestCleanup:
def test_closes_session_and_clears_all_caches(self, build):
manager = build()
session = MagicMock()
manager.session = session
manager._logo_cache["TB"] = object()
manager._font_cache[("PressStart2P-Regular.ttf", 8)] = object()
manager._bdf_native_size_cache["assets/fonts/5x7.bdf"] = 7
manager.cleanup()
session.close.assert_called_once_with()
assert manager._logo_cache == {}
# Promoted alongside the font loader: these hold PIL faces and are
# an unbounded leak across enable/disable cycles if never released.
assert manager._font_cache == {}
assert manager._bdf_native_size_cache == {}
def test_second_cleanup_is_a_noop(self, build):
manager = build()
manager.session = MagicMock()
manager._logo_cache["TB"] = object()
manager._font_cache[("x", 8)] = object()
manager.cleanup()
manager.cleanup() # must not raise on already-released state
assert manager._logo_cache == {}
assert manager._font_cache == {}
assert manager._bdf_native_size_cache == {}
assert manager.session.close.call_count == 2
def test_does_not_shut_down_the_shared_background_service(self, build):
# get_background_service() hands out a PROCESS-WIDE singleton shared
# by every scoreboard. One plugin unloading must not stop background
# fetching for the other eight — cleanup() touches it not at all.
manager = build()
service = manager.background_service
manager.session = MagicMock()
manager.cleanup()
assert service.shutdown.called is False
assert service.stop.called is False
assert service.method_calls == [], (
"cleanup() called into the shared background service: "
f"{service.method_calls}")
def test_completion_is_logged_even_when_session_close_raises(self, build, caplog):
manager = build()
manager.session = MagicMock()
manager.session.close.side_effect = RuntimeError("socket already gone")
manager._logo_cache["TB"] = object()
with caplog.at_level(logging.DEBUG, logger=LOGGER.name):
manager.cleanup()
messages = [r.message for r in caplog.records]
assert any("Error closing session" in m for m in messages)
# Ordering is load-bearing: the caches still get cleared and the
# completion log still fires after a failed close.
assert manager._logo_cache == {}
assert any("cleanup completed" in m for m in messages)
def test_tolerates_missing_attributes(self):
# The hasattr guards exist so a partially constructed instance (an
# __init__ that raised) can still be cleaned up.
host = MagicMock(spec=["logger"])
host.logger = LOGGER
SportsCore.cleanup(host)
# ---------------------------------------------------------------------------
# 2. _get_layout_offset() + the _config_schema_path() seam
# ---------------------------------------------------------------------------
def layout_config(element, axis, value):
return {"customization": {"layout": {element: {axis: value}}}}
class TestLayoutOffsetClassicPath:
"""The default path: _config_schema_path() returns None, so offsets come
from the inline customization.layout read every plugin ships today."""
def test_config_schema_path_defaults_to_none(self, build):
manager = build()
assert manager._config_schema_path() is None
def test_reads_configured_int(self):
host = probe(layout_config("home_logo", "x_offset", 5))
assert offset(host, "home_logo", "x_offset") == 5
def test_float_is_truncated_to_int(self):
host = probe(layout_config("score", "y_offset", 2.9))
result = offset(host, "score", "y_offset")
assert result == 2 and isinstance(result, int)
def test_numeric_string_is_coerced(self):
host = probe(layout_config("score", "x_offset", "-3.5"))
assert offset(host, "score", "x_offset") == -3
def test_unconfigured_element_and_axis_use_default(self):
host = probe(layout_config("score", "x_offset", 5))
assert offset(host, "status_text", "x_offset", 7) == 7
assert offset(host, "score", "y_offset", -1) == -1
assert offset(probe(), "score", "x_offset", 4) == 4
def test_non_numeric_string_degrades_to_default(self):
host = probe(layout_config("score", "x_offset", "left"))
assert offset(host, "score", "x_offset", 3) == 3
def test_unsupported_type_degrades_to_default(self):
host = probe(layout_config("score", "x_offset", {"nested": 1}))
assert offset(host, "score", "x_offset", 2) == 2
host = probe(layout_config("score", "x_offset", None))
assert offset(host, "score", "x_offset", 2) == 2
def test_broken_config_object_degrades_to_default(self):
host = probe()
host.config = "not a dict"
assert offset(host, "score", "x_offset", 6) == 6
def test_boolean_counts_as_one(self):
# PINNED AS-IS: the classic read predates the resolver and treats a
# bool as its int value (True -> 1). See the resolver test below for
# the stricter, more correct handling.
host = probe(layout_config("score", "x_offset", True))
assert offset(host, "score", "x_offset", 4) == 1
class TestLayoutOffsetResolverPath:
"""When a plugin supplies its config_schema.json, offsets resolve through
src.element_style instead."""
@pytest.fixture
def schema_path(self, tmp_path):
path = tmp_path / "config_schema.json"
path.write_text(json.dumps({
"type": "object",
"properties": {
"customization": {
"type": "object",
"properties": {
"layout": {"type": "object", "properties": {}},
},
},
},
}))
return str(path)
def host(self, schema_path, config):
host = probe(config)
host._config_schema_path.return_value = schema_path
del host._style_resolver_cached # MagicMock auto-attrs otherwise
host._style_resolver_cached = None
return host
def test_reads_configured_offsets(self, schema_path):
host = self.host(schema_path, layout_config("home_logo", "x_offset", 5))
assert offset(host, "home_logo", "x_offset") == 5
def test_numeric_string_is_coerced(self, schema_path):
host = self.host(schema_path, layout_config("score", "x_offset", "-3.5"))
assert offset(host, "score", "x_offset") == -3
def test_missing_value_uses_default(self, schema_path):
host = self.host(schema_path, layout_config("score", "x_offset", 5))
assert offset(host, "score", "y_offset", 9) == 9
def test_bad_input_degrades_to_default(self, schema_path):
host = self.host(schema_path, layout_config("score", "x_offset", "left"))
assert offset(host, "score", "x_offset", 3) == 3
host = self.host(schema_path, {"customization": {"layout": "nope"}})
assert offset(host, "score", "x_offset", 3) == 3
def test_boolean_is_rejected_unlike_the_classic_path(self, schema_path):
# The intended behavior difference: a bool is not a pixel offset, so
# the resolver returns the default where the classic read returns 1.
host = self.host(schema_path, layout_config("score", "x_offset", True))
assert offset(host, "score", "x_offset", 4) == 4
def test_resolver_is_cached_and_rebuilt_when_config_is_swapped(self, schema_path):
host = self.host(schema_path, layout_config("score", "x_offset", 5))
assert offset(host, "score", "x_offset") == 5
first = host._style_resolver_cached
assert offset(host, "score", "x_offset") == 5
assert host._style_resolver_cached is first
# on_config_change swaps the dict object; the resolver must follow.
host.config = layout_config("score", "x_offset", 11)
assert offset(host, "score", "x_offset") == 11
assert host._style_resolver_cached is not first
def test_missing_schema_file_still_resolves_offsets(self, tmp_path):
# Offsets don't depend on schema defaults, so an unreadable schema
# must not cost the plugin its layout customization.
host = self.host(str(tmp_path / "absent.json"),
layout_config("score", "x_offset", 5))
assert offset(host, "score", "x_offset") == 5
# ---------------------------------------------------------------------------
# 3. _load_custom_font_from_element_config() + the _font_root() seam
# ---------------------------------------------------------------------------
class TestFontRootSeam:
def test_default_font_root_is_the_core_install_root(self, build):
manager = build()
assert Path(manager._font_root()) == CORE_ROOT
assert (Path(manager._font_root()) / "assets" / "fonts").is_dir()
def test_resolve_font_path_honors_an_overridden_root(self, tmp_path):
fonts = tmp_path / "assets" / "fonts"
fonts.mkdir(parents=True)
(fonts / "Bundled.ttf").write_bytes(b"not really a font")
host = probe()
host._font_root.side_effect = lambda: str(tmp_path)
assert SportsCore._resolve_font_path(host, "Bundled.ttf") == str(
fonts / "Bundled.ttf")
def test_unknown_font_returns_the_familiar_relative_path(self):
host = probe()
assert SportsCore._resolve_font_path(host, "Nope.ttf") == os.path.join(
"assets", "fonts", "Nope.ttf")
class TestFontLoaderSignature:
"""Hockey's signature is the only safe superset: basketball's positional
``default_font: str`` blows up on an explicit None."""
def test_two_arg_call(self):
font = load_font(probe(), {"font": TTF_NAME, "font_size": 10})
assert isinstance(font, ImageFont.FreeTypeFont)
assert font.size == 10
def test_default_size_is_used_when_config_omits_it(self):
assert load_font(probe(), {}, 12).size == 12
def test_three_positional_args(self):
font = load_font(probe(), {}, 6, "4x6-font.ttf")
assert isinstance(font, ImageFont.FreeTypeFont)
assert font.size == 6
assert font.path.endswith("4x6-font.ttf")
def test_explicit_default_font_none(self):
# The regression this signature guards: os.path.join(..., None).
font = load_font(probe(), {"font_size": 9}, default_font=None)
assert isinstance(font, ImageFont.FreeTypeFont)
assert font.path.endswith(TTF_NAME)
def test_config_font_wins_over_default_font(self):
font = load_font(probe(), {"font": TTF_NAME}, 8, "4x6-font.ttf")
assert font.path.endswith(TTF_NAME)
def test_string_font_size_is_coerced(self):
assert load_font(probe(), {"font": TTF_NAME, "font_size": "11"}).size == 11
class TestFontLoaderBehavior:
def test_family_alias_resolves_through_the_font_manager_catalog(self):
# "press_start" is a FontManager catalog family, not a filename; the
# promoted loader must not carry its own duplicate alias table.
host = probe()
font = load_font(host, {"font": "press_start", "font_size": 8})
assert font.path.endswith(TTF_NAME)
assert ("PressStart2P-Regular.ttf", 8) in host._font_cache
def test_memo_cache_returns_the_same_face(self):
host = probe()
first = load_font(host, {"font": TTF_NAME, "font_size": 8})
second = load_font(host, {"font": TTF_NAME, "font_size": 8})
assert first is second
assert len(host._font_cache) == 1
# A different size is a different face.
assert load_font(host, {"font": TTF_NAME, "font_size": 9}) is not first
assert len(host._font_cache) == 2
def test_bdf_loads_at_its_native_strike_when_the_request_misses(self):
# BDF is a fixed-size bitmap format: FreeType raises "invalid pixel
# size" for anything but the file's own strike. Baseball's retry is
# the only copy that gets this right.
host = probe()
font = load_font(host, {"font": BDF_NAME, "font_size": 8})
assert isinstance(font, ImageFont.FreeTypeFont)
assert font.size == BDF_NATIVE_SIZE
assert font.path.endswith(BDF_NAME)
assert set(host._bdf_native_size_cache.values()) == {BDF_NATIVE_SIZE}
# The retried face is memoized under the REQUESTED size.
assert host._font_cache[(BDF_NAME, 8)] is font
def test_bdf_at_its_native_size_needs_no_retry(self):
host = probe()
font = load_font(host, {"font": BDF_NAME, "font_size": BDF_NATIVE_SIZE})
assert font.size == BDF_NATIVE_SIZE
assert host._bdf_native_size_cache == {}
def test_bdf_strike_lookup_is_memoized(self, monkeypatch):
calls = []
real = SportsCore.__module__
def counting(path):
calls.append(path)
from src.font_manager import FontManager
return FontManager._read_bdf_native_size(path)
monkeypatch.setattr(f"{real}._read_bdf_native_size", counting)
host = probe()
load_font(host, {"font": BDF_NAME, "font_size": 8})
host._font_cache.clear() # force the load path again
load_font(host, {"font": BDF_NAME, "font_size": 8})
assert len(calls) == 1
def test_missing_font_falls_back_and_caches_the_fallback(self, caplog):
host = probe()
with caplog.at_level(logging.WARNING, logger=LOGGER.name):
font = load_font(host, {"font": "DoesNotExist.ttf", "font_size": 8})
assert isinstance(font, ImageFont.FreeTypeFont)
assert font.path.endswith(TTF_NAME)
assert any("Font file not found" in r.message for r in caplog.records)
# Cached under the requested name so a misconfiguration costs one
# disk probe, not one per frame.
assert host._font_cache[("DoesNotExist.ttf", 8)] is font
def test_unknown_extension_falls_back(self):
host = probe()
font = load_font(host, {"font": "AUTHORS", "font_size": 8})
assert font.path.endswith(TTF_NAME)
def test_fallback_honors_the_supplied_default_font(self):
host = probe()
font = load_font(host, {"font": "DoesNotExist.ttf"}, 6, "4x6-font.ttf")
assert font.path.endswith("4x6-font.ttf")
class TestFontLoaderCwdIndependence:
"""The bug the _font_root() seam exists to prevent: every plugin copy
joins 'assets/fonts' onto the process cwd, so a process started anywhere
else silently degrades to PIL's default bitmap face (the same defect
already fixed in FontManager see CHANGELOG Unreleased/Fixed)."""
@pytest.mark.parametrize("font_name,expected_size",
[(TTF_NAME, 8), (BDF_NAME, BDF_NATIVE_SIZE)])
def test_fonts_load_from_an_unrelated_cwd(self, monkeypatch, font_name,
expected_size):
monkeypatch.chdir("/")
host = probe()
font = load_font(host, {"font": font_name, "font_size": 8})
assert isinstance(font, ImageFont.FreeTypeFont), (
f"{font_name} degraded to PIL's default face when the process "
"runs outside the install root")
assert font.size == expected_size
assert Path(font.path) == FONTS_DIR / font_name
def test_fallback_font_also_survives_an_unrelated_cwd(self, monkeypatch):
monkeypatch.chdir("/")
font = load_font(probe(), {"font": "DoesNotExist.ttf", "font_size": 8})
assert isinstance(font, ImageFont.FreeTypeFont)
assert Path(font.path) == FONTS_DIR / TTF_NAME
@pytest.mark.parametrize("key", ["score", "time", "team", "status",
"detail", "rank"])
def test_load_fonts_survives_an_unrelated_cwd(self, monkeypatch, key):
"""`_load_fonts` had the same cwd-relative literals the seam exists to
remove; every scoreboard font silently became PIL's default bitmap face
when the process started outside the install root."""
monkeypatch.chdir("/")
fonts = SportsCore._load_fonts(probe())
assert isinstance(fonts[key], ImageFont.FreeTypeFont), (
f"fonts['{key}'] degraded to PIL's default face outside the "
"install root")
class TestShouldLogCooldown:
"""`_should_log` reads `self._last_warning_time` unguarded, so it must be
initialized in __init__ otherwise the first warning of a run raises
AttributeError instead of logging."""
def test_cooldown_clock_is_initialized(self, build):
assert build()._last_warning_time == 0
def test_first_call_logs_then_cools_down(self, build):
manager = build()
assert manager._should_log("api", cooldown=60) is True
assert manager._should_log("api", cooldown=60) is False
def test_cooldown_expires(self, build):
manager = build()
assert manager._should_log("api", cooldown=60) is True
manager._last_warning_time -= 61
assert manager._should_log("api", cooldown=60) is True
# ---------------------------------------------------------------------------
# 4. Seam guard rails
# ---------------------------------------------------------------------------
class TestPromotedSeamsExist:
@pytest.mark.parametrize("name", [
"cleanup", "_get_layout_offset", "_load_custom_font_from_element_config",
"_config_schema_path", "_font_root", "_resolve_font_path",
])
def test_method_is_callable_on_the_base_class(self, name):
assert callable(getattr(SportsCore, name, None)), (
f"SportsCore.{name} is part of the promoted plugin-facing seam "
"(docs/SPORTS_UNIFICATION.md) — plugins probe for it with "
"hasattr before delegating.")
def test_no_sport_names_leaked_into_core(self):
"""core.py must never branch on which sport it is (prose and skin-id
examples in docstrings are fine executable code is not)."""
tree = ast.parse((CORE_ROOT / "src" / "base_classes" / "sports"
/ "core.py").read_text())
docstrings = set()
for node in ast.walk(tree):
if isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef,
ast.AsyncFunctionDef)):
first = node.body[0] if node.body else None
if (isinstance(first, ast.Expr)
and isinstance(first.value, ast.Constant)
and isinstance(first.value.value, str)):
docstrings.add(id(first.value))
tokens = []
for node in ast.walk(tree):
if isinstance(node, ast.Constant) and isinstance(node.value, str):
if id(node) not in docstrings:
tokens.append(node.value)
elif isinstance(node, ast.Name):
tokens.append(node.id)
elif isinstance(node, ast.Attribute):
tokens.append(node.attr)
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef,
ast.ClassDef)):
tokens.append(node.name)
haystack = " ".join(tokens).lower()
for sport in ("afl", "nrl", "hockey", "baseball", "basketball",
"football", "lacrosse", "soccer", "ufc"):
assert sport not in haystack, (
f"core.py code mentions '{sport}' — core must never learn "
"sport names; add an override point instead.")
class TestInstallRootResolution:
"""Guards the depth bug the sports.py -> package move introduced.
The move was byte-identical in every class body, but `__file__` gained a
directory, so `Path(__file__).resolve().parents[2]` silently changed from
the repo root to `<root>/src`. Textual identity is not semantic identity
when code measures its own location: these tests assert the resolved
values, not the index.
"""
def test_install_root_is_the_repo_root(self):
from src.base_classes.sports.core import _INSTALL_ROOT
# The repo root is the directory that actually holds src/ and assets/.
assert (_INSTALL_ROOT / "src").is_dir()
assert (_INSTALL_ROOT / "src" / "base_classes" / "sports").is_dir()
assert _INSTALL_ROOT.name != "src", (
"_INSTALL_ROOT resolved to src/ — the parents[] depth is off by "
"one, which is exactly the regression the package move caused.")
def test_resolve_project_path_roots_at_repo_not_src(self):
from src.base_classes.sports.core import SportsCore, _INSTALL_ROOT
resolved = SportsCore._resolve_project_path(None, Path("assets/fonts"))
assert resolved == _INSTALL_ROOT / "assets" / "fonts"
assert "src" not in resolved.relative_to(_INSTALL_ROOT).parts
def test_absolute_paths_pass_through_unchanged(self):
from src.base_classes.sports.core import SportsCore
absolute = Path("/tmp/some/logo/dir")
assert SportsCore._resolve_project_path(None, absolute) == absolute
def test_font_root_and_project_path_share_one_anchor(self):
"""Both consumers must derive from the same constant, so a future
move needs exactly one line changed rather than two."""
from src.base_classes.sports.core import SportsCore, _INSTALL_ROOT
assert SportsCore._font_root(None) == str(_INSTALL_ROOT)
+585
View File
@@ -0,0 +1,585 @@
"""Tests for the methods promoted onto SportsUpcoming / SportsRecent /
SportsLive from the nine plugin copies (phase B1 of the sports unification;
see docs/SPORTS_UNIFICATION.md).
Covered promotions:
- SportsRecent: `_get_zero_clock_duration` / `_clear_zero_clock_tracking`
(+ the `_zero_clock_timestamps` initializer).
- SportsLive: `_is_game_really_over` / `_detect_stale_games`
(+ `game_update_timestamps` / `stale_game_timeout`, and the
`FINAL_PERIOD` / `CLOCK_COUNTS_DOWN` class attributes).
- SportsUpcoming: `_select_games_for_display`.
- SportsRecent: `_select_recent_games_for_display`.
The live pair is the risk centre: `_detect_stale_games` is the only caller
that *removes* games, so `_is_game_really_over` returning a false positive
silently drops a live game from the display. The canonical form deliberately
declines to treat a missing clock as 0:00 the plugin variant that did so
dropped clockless sports (baseball) from the FINAL_PERIOD-th period onward.
That regression is pinned by
`test_missing_clock_at_late_period_is_not_over`.
"""
import logging
import sys
import time
from datetime import datetime, timezone
from unittest.mock import MagicMock
import pytest
import requests
from freezegun import freeze_time
from PIL import Image
# src.base_classes.sports transitively imports the hardware matrix driver;
# stub it so these tests can import the sports base classes off-device.
sys.modules.setdefault("rgbmatrix", MagicMock())
from src.base_classes.hockey import Hockey, HockeyLive
from src.base_classes.sports import (
SportsCore,
SportsLive,
SportsRecent,
SportsUpcoming,
)
# ---------------------------------------------------------------------------
# Harnesses
# ---------------------------------------------------------------------------
class _UpcomingHarness(Hockey, SportsUpcoming):
"""Cheapest concrete SportsUpcoming: hockey extractor + cache-fed data.
`_favorite_key` is inherited from SportsCore these harnesses
deliberately do NOT define it, so the selection tests exercise the real
seam rather than a local stand-in.
"""
def _fetch_data(self):
return None
class _RecentHarness(Hockey, SportsRecent):
def _fetch_data(self):
return None
class _LiveHarness(HockeyLive):
def _fetch_data(self):
return None
class _ThreePeriodLiveHarness(_LiveHarness):
"""Hockey-shaped: regulation ends after period 3."""
FINAL_PERIOD = 3
class _CountUpLiveHarness(_LiveHarness):
"""Soccer/AFL/NRL-shaped: the clock counts up, so 0:00 is kickoff."""
CLOCK_COUNTS_DOWN = False
class _IdFavoriteUpcomingHarness(_UpcomingHarness):
"""NRL-shaped: abbreviations are ambiguous, so favorites match on team id."""
def _favorite_key(self, game, side):
team_id = game.get(f"{side}_id")
return str(team_id) if team_id is not None else None
class _IdFavoriteRecentHarness(_RecentHarness):
def _favorite_key(self, game, side):
team_id = game.get(f"{side}_id")
return str(team_id) if team_id is not None else None
@pytest.fixture
def build_manager(monkeypatch, tmp_path):
"""Factory for concrete sports managers: mocked display/cache managers,
logo dir redirected to tmp, background service stubbed, and the requests
session rigged to prove nothing hits the network."""
monkeypatch.setattr(
SportsCore, "_initialize_logo_dir", lambda self, configured: tmp_path)
monkeypatch.setattr(
"src.base_classes.sports.core.get_background_service",
lambda *args, **kwargs: MagicMock())
def build(cls, **mode_cfg):
config = {
"timezone": "UTC",
"display": {},
"nhl_scoreboard": {"enabled": True, **mode_cfg},
}
display_manager = MagicMock()
display_manager.matrix.width = 128
display_manager.matrix.height = 32
display_manager.width = 128
display_manager.height = 32
display_manager.image = Image.new("RGB", (128, 32))
cache_manager = MagicMock()
cache_manager.get.return_value = None
cache_manager.cache_dir = str(tmp_path)
manager = cls(config, display_manager, cache_manager,
logging.getLogger("test_sports_modes_promotions"),
"nhl")
manager.session = MagicMock()
manager.session.get.side_effect = requests.exceptions.ConnectionError(
"promotion tests are offline")
return manager
return build
def game(game_id="1", home="BOS", away="TOR", start=None, home_id=None,
away_id=None, **extra):
g = {
"id": game_id,
"home_abbr": home,
"away_abbr": away,
"home_id": home_id,
"away_id": away_id,
"start_time_utc": start,
}
g.update(extra)
return g
def at(day, hour=12):
return datetime(2026, 1, day, hour, tzinfo=timezone.utc)
def _ids(games):
return [g["id"] for g in games]
# ---------------------------------------------------------------------------
# Tier 1 — zero-clock tracking (SportsRecent)
# ---------------------------------------------------------------------------
class TestZeroClockTracking:
def test_initializer_present_and_empty(self, build_manager):
manager = build_manager(_RecentHarness)
assert manager._zero_clock_timestamps == {}
def test_first_call_returns_zero_and_starts_tracking(self, build_manager):
manager = build_manager(_RecentHarness)
assert manager._get_zero_clock_duration("g1") == 0.0
assert "g1" in manager._zero_clock_timestamps
def test_subsequent_call_returns_elapsed_seconds(self, build_manager):
manager = build_manager(_RecentHarness)
with freeze_time("2026-01-20 12:00:00") as frozen:
assert manager._get_zero_clock_duration("g1") == 0.0
frozen.tick(45)
assert manager._get_zero_clock_duration("g1") == pytest.approx(45.0)
frozen.tick(15)
assert manager._get_zero_clock_duration("g1") == pytest.approx(60.0)
def test_tracking_is_per_game(self, build_manager):
manager = build_manager(_RecentHarness)
with freeze_time("2026-01-20 12:00:00") as frozen:
manager._get_zero_clock_duration("g1")
frozen.tick(30)
assert manager._get_zero_clock_duration("g2") == 0.0
assert manager._get_zero_clock_duration("g1") == pytest.approx(30.0)
def test_clear_resets_tracking(self, build_manager):
manager = build_manager(_RecentHarness)
with freeze_time("2026-01-20 12:00:00") as frozen:
manager._get_zero_clock_duration("g1")
frozen.tick(30)
manager._clear_zero_clock_tracking("g1")
assert "g1" not in manager._zero_clock_timestamps
# Restarts from zero after clearing.
assert manager._get_zero_clock_duration("g1") == 0.0
def test_clear_unknown_game_is_a_noop(self, build_manager):
manager = build_manager(_RecentHarness)
manager._clear_zero_clock_tracking("never-seen") # must not raise
assert manager._zero_clock_timestamps == {}
# ---------------------------------------------------------------------------
# Tier 2a — _is_game_really_over (SportsLive)
# ---------------------------------------------------------------------------
class TestIsGameReallyOver:
def test_missing_clock_at_late_period_is_not_over(self, build_manager):
"""THE baseball regression: no `clock` key at all, period 7.
The rejected variant coerced a missing clock to the literal "0:00" and
declared the game over dropping every MLB game from the 5th inning
onward, since baseball has no game clock and `period` is the inning.
A missing clock must fail safe.
"""
manager = build_manager(_LiveHarness)
g = game(period=7, period_text="Top 7th")
assert "clock" not in g
assert manager._is_game_really_over(g) is False
def test_none_clock_at_late_period_is_not_over(self, build_manager):
manager = build_manager(_LiveHarness)
assert manager._is_game_really_over(
game(clock=None, period=7, period_text="Top 7th")) is False
def test_non_string_clock_at_late_period_is_not_over(self, build_manager):
manager = build_manager(_LiveHarness)
assert manager._is_game_really_over(
game(clock=0, period=9, period_text="Bot 9th")) is False
def test_blank_clock_at_late_period_is_not_over(self, build_manager):
manager = build_manager(_LiveHarness)
assert manager._is_game_really_over(
game(clock=" ", period=5, period_text="5th")) is False
@pytest.mark.parametrize("period_text", ["Final", "final", "Final/OT",
"FINAL", "Final - SO"])
def test_final_period_text_is_over(self, build_manager, period_text):
manager = build_manager(_LiveHarness)
assert manager._is_game_really_over(
game(clock="12:00", period=2, period_text=period_text)) is True
def test_none_period_text_does_not_raise(self, build_manager):
"""All nine plugin copies called `.lower()` on `game.get("period_text", "")`,
which is None when the key is present-but-None; `_detect_stale_games`
has no try/except around the call."""
manager = build_manager(_LiveHarness)
assert manager._is_game_really_over(
game(clock="12:00", period=2, period_text=None)) is False
def test_missing_period_text_does_not_raise(self, build_manager):
manager = build_manager(_LiveHarness)
assert manager._is_game_really_over(game(clock="12:00", period=2)) is False
@pytest.mark.parametrize(
"clock", ["0:00", ":00", "00", "000", " 0:00 ", "00:00", "0", "0000"])
def test_expired_clock_at_final_period_is_over(self, build_manager, clock):
"""Every spelling of a zeroed clock counts, not a hand-listed few.
"00:00" is the one that motivated comparing numerically: it normalizes
to "0000", which matched none of the literals the plugin copies listed,
so a two-digit-minute expired clock kept the game on screen forever.
"""
manager = build_manager(_LiveHarness)
assert manager._is_game_really_over(
game(clock=clock, period=4, period_text="Q4")) is True
def test_none_period_at_expired_clock_does_not_raise(self, build_manager):
"""`period` present-but-None: `None >= FINAL_PERIOD` is a TypeError, and
`_detect_stale_games` has no try/except the same failure shape as the
`period_text` case above."""
manager = build_manager(_LiveHarness)
assert manager._is_game_really_over(
game(clock="0:00", period=None, period_text="Q4")) is False
def test_none_period_with_running_clock_does_not_raise(self, build_manager):
manager = build_manager(_LiveHarness)
assert manager._is_game_really_over(
game(clock="8:12", period=None, period_text="Q2")) is False
def test_expired_clock_after_final_period_is_over(self, build_manager):
manager = build_manager(_LiveHarness)
assert manager._is_game_really_over(
game(clock="0:00", period=5, period_text="OT")) is True
def test_expired_clock_before_final_period_is_not_over(self, build_manager):
manager = build_manager(_LiveHarness)
assert manager._is_game_really_over(
game(clock="0:00", period=3, period_text="Q3")) is False
@pytest.mark.parametrize("clock", [":40", "0:40", "1:00"])
def test_running_clock_is_not_over(self, build_manager, clock):
"""Sub-minute clocks like ':40' are legitimate, not expired."""
manager = build_manager(_LiveHarness)
assert manager._is_game_really_over(
game(clock=clock, period=4, period_text="Q4")) is False
def test_defaults_are_four_period_countdown(self):
assert SportsLive.FINAL_PERIOD == 4
assert SportsLive.CLOCK_COUNTS_DOWN is True
def test_final_period_override_three(self, build_manager):
"""Hockey-shaped subclass: regulation ends after period 3."""
manager = build_manager(_ThreePeriodLiveHarness)
assert manager.FINAL_PERIOD == 3
assert manager._is_game_really_over(
game(clock="0:00", period=3, period_text="P3")) is True
assert manager._is_game_really_over(
game(clock="0:00", period=2, period_text="P2")) is False
# And the unmodified default still requires period 4.
assert build_manager(_LiveHarness)._is_game_really_over(
game(clock="0:00", period=3, period_text="P3")) is False
def test_count_up_clock_never_expires(self, build_manager):
"""Soccer/AFL/NRL: 0:00 means kickoff, so the clock branch must not run."""
manager = build_manager(_CountUpLiveHarness)
assert manager.CLOCK_COUNTS_DOWN is False
for period in (1, 2, 4, 9):
assert manager._is_game_really_over(
game(clock="0:00", period=period, period_text="1st Half")) is False
def test_count_up_clock_still_honors_final_text(self, build_manager):
manager = build_manager(_CountUpLiveHarness)
assert manager._is_game_really_over(
game(clock="0:00", period=2, period_text="Final")) is True
# ---------------------------------------------------------------------------
# Tier 2b — _detect_stale_games (SportsLive)
# ---------------------------------------------------------------------------
class TestDetectStaleGames:
def test_initializer_defaults(self, build_manager):
manager = build_manager(_LiveHarness)
assert manager.game_update_timestamps == {}
assert manager.stale_game_timeout == 300
def test_stale_timeout_is_configurable(self, build_manager):
manager = build_manager(_LiveHarness, stale_game_timeout=42)
assert manager.stale_game_timeout == 42
def test_mutates_caller_list_in_place_and_returns_none(self, build_manager):
manager = build_manager(_LiveHarness)
fresh = game("1", period_text="P2", clock="10:00", period=2)
over = game("2", period_text="Final", clock="0:00", period=3)
games = [fresh, over]
original = games
result = manager._detect_stale_games(games)
assert result is None
assert games is original # same object, mutated in place
assert _ids(games) == ["1"]
def test_evicts_only_past_timeout_games(self, build_manager):
manager = build_manager(_LiveHarness)
with freeze_time("2026-01-20 12:00:00"):
now = time.time()
manager.game_update_timestamps = {
"1": {"last_seen": now - 10}, # fresh
"2": {"last_seen": now - 299}, # just inside the timeout
"3": {"last_seen": now - 301}, # past the timeout
}
games = [game("1", period_text="P1", clock="10:00", period=1),
game("2", period_text="P1", clock="10:00", period=1),
game("3", period_text="P1", clock="10:00", period=1)]
manager._detect_stale_games(games)
assert _ids(games) == ["1", "2"]
assert "3" not in manager.game_update_timestamps
assert set(manager.game_update_timestamps) == {"1", "2"}
def test_unknown_last_seen_is_never_stale(self, build_manager):
"""last_seen == 0 (or no entry) means 'never recorded', not 'ancient'."""
manager = build_manager(_LiveHarness)
games = [game("1", period_text="P1", clock="10:00", period=1),
game("2", period_text="P1", clock="10:00", period=1)]
manager.game_update_timestamps = {"1": {"last_seen": 0}}
manager._detect_stale_games(games)
assert _ids(games) == ["1", "2"]
def test_removes_games_that_are_really_over(self, build_manager):
manager = build_manager(_LiveHarness)
manager.game_update_timestamps = {"2": {"last_seen": 0}}
games = [game("1", period_text="Q2", clock="5:00", period=2),
game("2", period_text="Final", clock="0:00", period=4)]
manager._detect_stale_games(games)
assert _ids(games) == ["1"]
assert "2" not in manager.game_update_timestamps
def test_keeps_clockless_late_game(self, build_manager):
"""The end-to-end form of the baseball regression: a clockless game in
the 7th must survive the removal path."""
manager = build_manager(_LiveHarness)
games = [game("mlb-1", period=7, period_text="Top 7th")]
manager._detect_stale_games(games)
assert _ids(games) == ["mlb-1"]
def test_games_without_id_are_skipped(self, build_manager):
manager = build_manager(_LiveHarness)
no_id = {"home_abbr": "BOS", "away_abbr": "TOR",
"period_text": "Final", "clock": "0:00", "period": 4}
games = [no_id]
manager._detect_stale_games(games)
# `continue` fires before the "really over" check, so it stays.
assert games == [no_id]
def test_empty_list_is_tolerated(self, build_manager):
manager = build_manager(_LiveHarness)
games = []
assert manager._detect_stale_games(games) is None
assert games == []
def test_removal_is_by_value_not_identity(self, build_manager):
"""Sharp edge worth pinning: `list.remove` compares with `dict.__eq__`,
so two structurally-equal dicts drop the FIRST occurrence."""
manager = build_manager(_LiveHarness)
first = game("1", period_text="Final", clock="0:00", period=4)
twin = dict(first)
games = [first, twin]
manager._detect_stale_games(games)
# Both entries are removed here (two iterations, two removals), but the
# first removal deletes `first`, not the dict being iterated.
assert games == []
# ---------------------------------------------------------------------------
# Tier 3a — _select_games_for_display (SportsUpcoming)
# ---------------------------------------------------------------------------
class TestSelectGamesForDisplay:
def test_no_favorites_returns_all_sorted_ascending(self, build_manager):
manager = build_manager(_UpcomingHarness)
games = [game("late", start=at(20)), game("early", start=at(10)),
game("mid", start=at(15))]
assert _ids(manager._select_games_for_display(games, [])) == [
"early", "mid", "late"]
def test_missing_start_time_sorts_last(self, build_manager):
manager = build_manager(_UpcomingHarness)
games = [game("none", start=None), game("early", start=at(10))]
assert _ids(manager._select_games_for_display(games, [])) == [
"early", "none"]
def test_filters_to_favorite_teams(self, build_manager):
manager = build_manager(_UpcomingHarness)
games = [game("1", home="BOS", away="TOR", start=at(10)),
game("2", home="NYR", away="PIT", start=at(11)),
game("3", home="MTL", away="BOS", start=at(12))]
assert _ids(manager._select_games_for_display(games, ["BOS"])) == ["1", "3"]
def test_respects_upcoming_games_to_show_per_team(self, build_manager):
manager = build_manager(_UpcomingHarness, upcoming_games_to_show=2)
games = [game(str(i), home="BOS", away="TOR", start=at(10 + i))
for i in range(5)]
assert _ids(manager._select_games_for_display(games, ["BOS"])) == ["0", "1"]
def test_game_between_two_favorites_counts_for_both(self, build_manager):
manager = build_manager(_UpcomingHarness, upcoming_games_to_show=1)
games = [game("shared", home="BOS", away="TOR", start=at(10)),
game("bos2", home="BOS", away="NYR", start=at(11)),
game("tor2", home="TOR", away="PIT", start=at(12))]
# "shared" fills both BOS's and TOR's single slot, so nothing else fits.
assert _ids(manager._select_games_for_display(
games, ["BOS", "TOR"])) == ["shared"]
def test_deduplicates_by_game_id(self, build_manager):
manager = build_manager(_UpcomingHarness, upcoming_games_to_show=5)
g = game("dupe", home="BOS", away="TOR", start=at(10))
assert _ids(manager._select_games_for_display(
[g, dict(g)], ["BOS"])) == ["dupe"]
def test_non_favorite_games_excluded(self, build_manager):
manager = build_manager(_UpcomingHarness)
games = [game("1", home="NYR", away="PIT", start=at(10))]
assert manager._select_games_for_display(games, ["BOS"]) == []
def test_favorite_key_seam_supports_id_matching(self, build_manager):
"""The NRL case: two clubs share the abbreviation 'NEW', so favorites
must be matched on team id. Only the seam changes the promoted
method is identical."""
games = [
game("knights", home="NEW", away="SYD", home_id=1, away_id=2,
start=at(10)),
game("warriors", home="NEW", away="SYD", home_id=99, away_id=2,
start=at(11)),
]
abbr_manager = build_manager(_UpcomingHarness)
# Abbreviation matching cannot tell the two "NEW" clubs apart.
assert _ids(abbr_manager._select_games_for_display(games, ["NEW"])) == [
"knights", "warriors"]
id_manager = build_manager(_IdFavoriteUpcomingHarness)
assert _ids(id_manager._select_games_for_display(games, ["99"])) == [
"warriors"]
def test_favorite_key_none_never_matches(self, build_manager):
"""`_favorite_key` returning None (missing id) must not match, even
against a favorites list holding the string 'None'."""
manager = build_manager(_IdFavoriteUpcomingHarness)
games = [game("1", home="BOS", away="TOR", start=at(10))] # no ids
assert manager._select_games_for_display(games, ["None"]) == []
# ---------------------------------------------------------------------------
# Tier 3b — _select_recent_games_for_display (SportsRecent)
# ---------------------------------------------------------------------------
class TestSelectRecentGamesForDisplay:
def test_no_favorites_returns_all_sorted_descending(self, build_manager):
manager = build_manager(_RecentHarness)
games = [game("early", start=at(10)), game("late", start=at(20)),
game("mid", start=at(15))]
assert _ids(manager._select_recent_games_for_display(games, [])) == [
"late", "mid", "early"]
def test_missing_start_time_sorts_last(self, build_manager):
manager = build_manager(_RecentHarness)
games = [game("none", start=None), game("late", start=at(20))]
assert _ids(manager._select_recent_games_for_display(games, [])) == [
"late", "none"]
def test_respects_recent_games_to_show_per_team(self, build_manager):
manager = build_manager(_RecentHarness, recent_games_to_show=2)
games = [game(str(i), home="BOS", away="TOR", start=at(10 + i))
for i in range(5)]
# Most recent first.
assert _ids(manager._select_recent_games_for_display(
games, ["BOS"])) == ["4", "3"]
def test_game_between_two_favorites_counts_for_both(self, build_manager):
manager = build_manager(_RecentHarness, recent_games_to_show=1)
games = [game("shared", home="BOS", away="TOR", start=at(20)),
game("bos2", home="BOS", away="NYR", start=at(19)),
game("tor2", home="TOR", away="PIT", start=at(18))]
assert _ids(manager._select_recent_games_for_display(
games, ["BOS", "TOR"])) == ["shared"]
def test_deduplicates_by_game_id(self, build_manager):
manager = build_manager(_RecentHarness, recent_games_to_show=5)
g = game("dupe", home="BOS", away="TOR", start=at(10))
assert _ids(manager._select_recent_games_for_display(
[g, dict(g)], ["BOS"])) == ["dupe"]
def test_non_favorite_games_excluded(self, build_manager):
manager = build_manager(_RecentHarness)
games = [game("1", home="NYR", away="PIT", start=at(10))]
assert manager._select_recent_games_for_display(games, ["BOS"]) == []
def test_favorite_key_seam_supports_id_matching(self, build_manager):
games = [
game("knights", home="NEW", away="SYD", home_id=1, away_id=2,
start=at(10)),
game("warriors", home="NEW", away="SYD", home_id=99, away_id=2,
start=at(11)),
]
id_manager = build_manager(_IdFavoriteRecentHarness)
assert _ids(id_manager._select_recent_games_for_display(
games, ["99"])) == ["warriors"]
# ---------------------------------------------------------------------------
# Seam / inertness guards
# ---------------------------------------------------------------------------
class TestPromotionShape:
def test_promoted_methods_live_on_the_right_classes(self):
assert hasattr(SportsRecent, "_get_zero_clock_duration")
assert hasattr(SportsRecent, "_clear_zero_clock_tracking")
assert hasattr(SportsRecent, "_select_recent_games_for_display")
assert hasattr(SportsUpcoming, "_select_games_for_display")
assert hasattr(SportsLive, "_is_game_really_over")
assert hasattr(SportsLive, "_detect_stale_games")
def test_modes_does_not_define_the_favorite_key_seam(self):
"""`_favorite_key` is a SportsCore override point. modes.py must call
it, never define it this test fails if the seam is added in the
wrong file."""
import src.base_classes.sports.modes as modes
for cls in (SportsUpcoming, SportsRecent, SportsLive):
assert "_favorite_key" not in vars(cls)
assert "_favorite_key" not in modes.__dict__
+227
View File
@@ -0,0 +1,227 @@
"""
Regression tests: changed plugin data must reach the strip in continuous mode.
Two faults combined to freeze Vegas content indefinitely.
PR #291 added a call to ``plugin_adapter.invalidate_plugin_scroll_cache()`` so a
plugin's *own* cached scroll image would be rebuilt from fresh data. The method
was never implemented, and ``hot_swap_content()`` wraps the call in a broad
except, so every hot swap raised AttributeError and was silently swallowed.
Continuous scrolling then removed the only path that reached it at all:
``should_recompose()``/``hot_swap_content()`` are called from the non-continuous
branch, while ``continuous_scroll`` defaults to True.
Together, a plugin composed its scroll image once and handed back the same
picture forever, because the sports plugins' ``get_vegas_content()`` regenerates
only when its cache is empty. Symptom: a game that was live last night is still
drawn as live the following morning.
"""
from types import SimpleNamespace
from unittest.mock import MagicMock
import numpy as np
from PIL import Image
from src.vegas_mode.config import VegasModeConfig
from src.vegas_mode.plugin_adapter import PluginAdapter
from src.vegas_mode.render_pipeline import RenderPipeline
from src.vegas_mode.stream_manager import StreamManager
class FakeDisplayManager:
width = 64
height = 32
def _helper():
"""A stand-in ScrollHelper holding both halves of its cache."""
image = Image.new('RGB', (128, 32), (10, 20, 30))
return SimpleNamespace(cached_image=image, cached_array=np.array(image))
class TestInvalidatePluginScrollCache:
"""The method PR #291 called but never defined."""
def test_method_exists(self):
# It was called for months without existing; the broad except in
# hot_swap_content() meant nothing ever surfaced.
assert hasattr(PluginAdapter, 'invalidate_plugin_scroll_cache')
def test_clears_helper_attached_to_the_plugin(self):
adapter = PluginAdapter(FakeDisplayManager(), VegasModeConfig())
helper = _helper()
plugin = SimpleNamespace(scroll_helper=helper)
assert adapter.invalidate_plugin_scroll_cache(plugin, 'stocks') is True
assert helper.cached_image is None
assert helper.cached_array is None
def test_clears_helper_owned_by_a_scroll_manager(self):
# The sports scoreboards keep theirs on _scroll_manager, which is the
# layout that produced the reported stale-scores bug.
adapter = PluginAdapter(FakeDisplayManager(), VegasModeConfig())
helper = _helper()
plugin = SimpleNamespace(_scroll_manager=SimpleNamespace(scroll_helper=helper))
assert adapter.invalidate_plugin_scroll_cache(plugin, 'baseball') is True
assert helper.cached_image is None
assert helper.cached_array is None
def test_clears_both_halves_together(self):
# cached_array is the image's numpy mirror; leaving one behind lets a
# reader pick up content the other no longer has.
adapter = PluginAdapter(FakeDisplayManager(), VegasModeConfig())
helper = _helper()
adapter.invalidate_plugin_scroll_cache(
SimpleNamespace(scroll_helper=helper), 'news')
assert (helper.cached_image, helper.cached_array) == (None, None)
def test_plugin_without_a_helper_is_not_an_error(self):
adapter = PluginAdapter(FakeDisplayManager(), VegasModeConfig())
assert adapter.invalidate_plugin_scroll_cache(SimpleNamespace(), 'clock') is False
class TestInvalidatePendingUpdates:
def _manager(self, plugins):
stream = StreamManager(
VegasModeConfig(),
SimpleNamespace(plugins=plugins),
MagicMock(),
)
stream.plugin_adapter = MagicMock()
return stream
def test_drops_caches_for_updated_plugins(self):
helper = _helper()
plugin = SimpleNamespace(scroll_helper=helper)
stream = self._manager({'baseball': plugin})
stream.mark_plugin_updated('baseball')
assert stream.invalidate_pending_updates() == ['baseball']
stream.plugin_adapter.invalidate_cache.assert_called_once_with('baseball')
stream.plugin_adapter.invalidate_plugin_scroll_cache.assert_called_once_with(
plugin, 'baseball')
def test_pending_flags_are_consumed(self):
# Left unconsumed they accumulate forever and nothing ever refreshes.
stream = self._manager({'baseball': SimpleNamespace()})
stream.mark_plugin_updated('baseball')
assert stream.has_pending_updates() is True
stream.invalidate_pending_updates()
assert stream.has_pending_updates() is False
assert stream.invalidate_pending_updates() == []
def test_no_pending_updates_does_no_work(self):
stream = self._manager({})
assert stream.invalidate_pending_updates() == []
stream.plugin_adapter.invalidate_cache.assert_not_called()
def test_a_failing_plugin_does_not_stop_the_others(self):
stream = self._manager({'a': SimpleNamespace(), 'b': SimpleNamespace()})
stream.mark_plugin_updated('a')
stream.mark_plugin_updated('b')
stream.plugin_adapter.invalidate_cache.side_effect = [
RuntimeError('boom'), None]
assert sorted(stream.invalidate_pending_updates()) == ['a', 'b']
assert stream.plugin_adapter.invalidate_cache.call_count == 2
class TestContinuousModeReachesTheRefresh:
def _pipeline(self):
stream = MagicMock()
stream.get_buffer_status.return_value = {'staging_count': 0}
return RenderPipeline(VegasModeConfig(), FakeDisplayManager(), stream), stream
def test_refresh_delegates_to_the_stream_manager(self):
pipeline, stream = self._pipeline()
stream.invalidate_pending_updates.return_value = ['baseball']
assert pipeline.refresh_updated_plugins() is True
def test_refresh_reports_false_when_nothing_changed(self):
pipeline, stream = self._pipeline()
stream.invalidate_pending_updates.return_value = []
assert pipeline.refresh_updated_plugins() is False
def test_refresh_never_raises_into_the_render_loop(self):
pipeline, stream = self._pipeline()
stream.invalidate_pending_updates.side_effect = RuntimeError('boom')
assert pipeline.refresh_updated_plugins() is False
def test_refresh_does_not_reposition_the_scroll(self):
# The whole point of preferring this over hot_swap_content(): that path
# rebuilds and repositions, which reads as a freeze then a jump.
pipeline, stream = self._pipeline()
stream.invalidate_pending_updates.return_value = ['baseball']
pipeline.scroll_helper.scroll_position = 1234
pipeline.refresh_updated_plugins()
assert pipeline.scroll_helper.scroll_position == 1234
stream.swap_buffers.assert_not_called()
stream.process_updates.assert_not_called()
class TestCoordinatorWiring:
"""
The regression itself: continuous mode has to *call* the refresh.
should_recompose()/hot_swap_content() sit in the non-continuous branch, and
continuous_scroll defaults to True, so before this fix the refresh was
simply never reached on a default install.
"""
def _coordinator(self, continuous):
import threading
from src.vegas_mode.coordinator import VegasModeCoordinator
config = VegasModeConfig()
config.continuous_scroll = continuous
# Built without __init__ so the test exercises run_frame's branching
# without standing up a display, stream and render stack.
coordinator = VegasModeCoordinator.__new__(VegasModeCoordinator)
coordinator.vegas_config = config
coordinator.render_pipeline = MagicMock()
coordinator.render_pipeline.has_deferred.return_value = False
coordinator.render_pipeline.needs_extension.return_value = False
coordinator.render_pipeline.is_cycle_complete.return_value = False
coordinator.render_pipeline.should_recompose.return_value = False
coordinator.stream_manager = MagicMock()
coordinator.stats = {'cycles_completed': 0}
coordinator._state_lock = threading.Lock()
coordinator._is_active = True
coordinator._is_paused = False
coordinator._should_stop = False
coordinator._pending_config_update = False
coordinator._live_priority_check = None
coordinator._interrupt_check = None
coordinator.sync_manager = None
return coordinator
def test_continuous_mode_refreshes_updated_plugins_every_frame(self):
coordinator = self._coordinator(continuous=True)
coordinator.run_frame()
coordinator.render_pipeline.refresh_updated_plugins.assert_called_once()
def test_continuous_mode_does_not_use_the_disruptive_swap(self):
coordinator = self._coordinator(continuous=True)
coordinator.run_frame()
coordinator.render_pipeline.hot_swap_content.assert_not_called()
def test_swap_mode_still_uses_hot_swap(self):
# The non-continuous path must keep its original behaviour.
coordinator = self._coordinator(continuous=False)
coordinator.render_pipeline.should_recompose.return_value = True
coordinator.run_frame()
coordinator.render_pipeline.hot_swap_content.assert_called_once()
coordinator.render_pipeline.refresh_updated_plugins.assert_not_called()
def test_a_frame_is_still_rendered_either_way(self):
for continuous in (True, False):
coordinator = self._coordinator(continuous=continuous)
coordinator.run_frame()
coordinator.render_pipeline.render_frame.assert_called_once()
File diff suppressed because it is too large Load Diff
+370
View File
@@ -0,0 +1,370 @@
"""Tests for Vegas mode geometry primitives."""
import numpy as np
import pytest
from PIL import Image
from src.vegas_mode.geometry import (
DEFAULT_INK_THRESHOLD,
column_has_ink,
content_bounds,
dead_window_stats,
edge_blank,
find_blank_cut,
separation_gap,
trim_to_content,
window_coverage_stats,
)
def make_img(width, height=8, fill=(0, 0, 0)):
return Image.new('RGB', (width, height), fill)
def paint(img, x0, x1, color=(255, 255, 255)):
"""Fill columns [x0, x1) with a colour."""
block = Image.new('RGB', (x1 - x0, img.height), color)
img.paste(block, (x0, 0))
return img
class TestColumnHasInk:
def test_all_black_has_no_ink(self):
assert not column_has_ink(make_img(16)).any()
def test_marks_only_painted_columns(self):
img = paint(make_img(16), 4, 8)
ink = column_has_ink(img)
assert ink.tolist() == [False] * 4 + [True] * 4 + [False] * 8
def test_threshold_is_exclusive(self):
# A pixel exactly at the threshold is not ink; one above it is.
at = paint(make_img(4), 0, 4, (DEFAULT_INK_THRESHOLD,) * 3)
above = paint(make_img(4), 0, 4, (DEFAULT_INK_THRESHOLD + 1,) * 3)
assert not column_has_ink(at).any()
assert column_has_ink(above).all()
def test_single_bright_channel_counts(self):
img = paint(make_img(4), 1, 2, (0, 0, 200))
assert column_has_ink(img).tolist() == [False, True, False, False]
def test_one_lit_pixel_lights_the_column(self):
img = make_img(4, height=8)
img.putpixel((2, 5), (255, 255, 255))
assert column_has_ink(img).tolist() == [False, False, True, False]
class TestContentBounds:
def test_blank_returns_none(self):
assert content_bounds(make_img(16)) is None
def test_finds_inclusive_bounds(self):
assert content_bounds(paint(make_img(20), 5, 12)) == (5, 11)
def test_full_width_content(self):
assert content_bounds(paint(make_img(10), 0, 10)) == (0, 9)
def test_spans_interior_gap(self):
img = paint(make_img(30), 2, 5)
paint(img, 20, 25)
assert content_bounds(img) == (2, 24)
class TestTrimToContent:
def test_blank_image_reports_blank(self):
result = trim_to_content(make_img(512))
assert result.is_blank
assert result.image is None
assert result.width == 0
assert result.original_width == 512
def test_trims_both_edges(self):
result = trim_to_content(paint(make_img(512), 100, 150))
assert not result.is_blank
assert result.width == 50
assert result.trimmed_left == 100
assert result.trimmed_right == 362
assert result.removed == 462
def test_preserves_interior_gap(self):
# Two content blocks with a wide blank between them: the gap is the
# plugin's layout and must survive trimming.
img = paint(make_img(400), 50, 80)
paint(img, 300, 330)
result = trim_to_content(img)
assert result.width == 280 # 50..329 inclusive
assert column_has_ink(result.image).sum() == 60
def test_full_width_content_is_returned_unchanged(self):
img = paint(make_img(128), 0, 128)
result = trim_to_content(img)
assert result.image is img
assert result.removed == 0
def test_non_black_background_is_never_trimmed(self):
# A plugin drawing on a dark-but-not-black background fills every
# column with ink, so there is nothing to reclaim.
result = trim_to_content(make_img(256, fill=(0, 0, 40)))
assert result.removed == 0
assert result.width == 256
def test_padding_keeps_margin_up_to_what_exists(self):
result = trim_to_content(paint(make_img(512), 100, 150), padding=8)
assert result.trimmed_left == 92
assert result.width == 66 # 50 content + 8 each side
def test_padding_cannot_widen_beyond_original(self):
# Content starts 2px in; padding of 8 can only reclaim the 2 available.
result = trim_to_content(paint(make_img(64), 2, 60), padding=8)
assert result.trimmed_left == 0
assert result.trimmed_right == 0
assert result.width == 64
def test_height_is_preserved(self):
result = trim_to_content(paint(make_img(200, height=64), 10, 20))
assert result.image.height == 64
def test_real_world_of_the_day_case(self):
# Measured on devpi: "No Data" occupying 35px of a 512px canvas.
result = trim_to_content(paint(make_img(512, height=64), 4, 39))
assert result.width == 35
assert result.removed == 477
class TestDeadWindowStats:
def test_fully_inked_ticker_has_no_dead_windows(self):
stats = dead_window_stats(paint(make_img(400), 0, 400), viewport_width=100)
assert stats.dead_windows == 0
assert stats.dead_ratio == 0.0
assert stats.longest_dead_run == 0
def test_fully_blank_ticker_is_all_dead(self):
stats = dead_window_stats(make_img(400), viewport_width=100)
assert stats.total_windows == 301
assert stats.dead_windows == 301
assert stats.dead_ratio == 1.0
assert stats.longest_dead_run == 301
def test_leading_blank_run_is_measured(self):
# 512px of black then solid content: windows fully inside the black
# stretch are dead. With a 100px viewport, starts 0..412 exist and a
# window is dead while it holds >=95 blank columns.
img = paint(make_img(1024), 512, 1024)
stats = dead_window_stats(img, viewport_width=100)
assert stats.dead_windows == 418 # starts 0..417 keep >=95 blank cols
assert stats.longest_dead_run == 418
def test_narrow_content_island_still_leaves_dead_windows(self):
# 35px of content in a 512px field, viewed 100px at a time: no window
# can be 95% blank once it overlaps 35 lit columns, but the windows
# clear of it are dead.
img = paint(make_img(512), 100, 135)
stats = dead_window_stats(img, viewport_width=100)
assert stats.dead_windows > 0
assert stats.dead_ratio == pytest.approx(
stats.dead_windows / stats.total_windows
)
def test_step_reduces_sampling(self):
img = paint(make_img(1000), 500, 1000)
exact = dead_window_stats(img, viewport_width=100, step=1)
strided = dead_window_stats(img, viewport_width=100, step=10)
assert strided.total_windows < exact.total_windows
# Same underlying shape, so the ratios should stay close.
assert strided.dead_ratio == pytest.approx(exact.dead_ratio, abs=0.02)
def test_image_narrower_than_viewport_is_one_window(self):
stats = dead_window_stats(make_img(50), viewport_width=100)
assert stats.total_windows == 1
assert stats.dead_windows == 1
def test_zero_viewport_is_handled(self):
stats = dead_window_stats(make_img(50), viewport_width=0)
assert stats.total_windows == 0
assert stats.dead_ratio == 0.0
def test_longest_run_picks_the_larger_of_two_gaps(self):
# Short blank gap, content, then a long blank gap.
img = make_img(1000)
paint(img, 150, 400)
paint(img, 500, 520)
stats = dead_window_stats(img, viewport_width=100)
# The 400..500 gap is only 100 wide; the tail from 520 is 480 wide.
assert stats.longest_dead_run >= 380
class TestWindowCoverageStats:
def test_solid_content_is_fully_covered(self):
stats = window_coverage_stats(paint(make_img(600), 0, 600), viewport_width=100)
assert stats.mean_ink_ratio == 1.0
assert stats.min_ink_ratio == 1.0
assert stats.sparse_windows == 0
def test_blank_strip_is_entirely_sparse(self):
stats = window_coverage_stats(make_img(600), viewport_width=100)
assert stats.mean_ink_ratio == 0.0
assert stats.sparse_ratio == 1.0
def test_catches_sliver_windows_that_dead_ratio_misses(self):
# Narrow content islands separated by more than the viewport. A window
# holding one whole 40px island carries 472 blank columns — under the
# 486 needed to count as "dead" — yet only 7.8% ink, so it still reads
# as an empty panel. Coverage must flag strictly more positions than
# the dead-window scan does.
img = paint(make_img(2000), 0, 40)
paint(img, 1000, 1040)
dead = dead_window_stats(img, viewport_width=512)
cover = window_coverage_stats(img, viewport_width=512, sparse_ink_ratio=0.10)
assert cover.sparse_windows > dead.dead_windows
assert cover.min_ink_ratio == 0.0
def test_adjacent_full_width_segments_stay_partially_covered(self):
# Documents why the dead-window scan alone understated the problem:
# two 512px segments with mid-canvas content never fully blank the
# viewport, they just hold it at a thin ~28%.
img = paint(make_img(1024), 185, 330)
paint(img, 697, 842)
dead = dead_window_stats(img, viewport_width=512)
cover = window_coverage_stats(img, viewport_width=512)
assert dead.dead_windows == 0
assert cover.mean_ink_ratio == pytest.approx(0.283, abs=0.01)
def test_min_ink_ratio_finds_the_worst_position(self):
# A wide blank tail guarantees at least one totally empty viewport.
img = paint(make_img(1200), 0, 200)
stats = window_coverage_stats(img, viewport_width=200)
assert stats.min_ink_ratio == 0.0
assert stats.mean_ink_ratio > 0.0
def test_sparse_threshold_is_respected(self):
# 40 inked columns in a 200px viewport = 20% coverage everywhere the
# island is fully inside the window.
img = paint(make_img(400), 100, 140)
lenient = window_coverage_stats(img, viewport_width=200, sparse_ink_ratio=0.05)
strict = window_coverage_stats(img, viewport_width=200, sparse_ink_ratio=0.50)
assert strict.sparse_windows > lenient.sparse_windows
def test_step_approximates_exact_scan(self):
img = paint(make_img(2000), 300, 500)
paint(img, 1200, 1400)
exact = window_coverage_stats(img, viewport_width=512, step=1)
strided = window_coverage_stats(img, viewport_width=512, step=4)
assert strided.mean_ink_ratio == pytest.approx(exact.mean_ink_ratio, abs=0.01)
def test_zero_viewport_is_handled(self):
stats = window_coverage_stats(make_img(50), viewport_width=0)
assert stats.total_windows == 0
assert stats.sparse_ratio == 0.0
def test_image_narrower_than_viewport(self):
stats = window_coverage_stats(paint(make_img(50), 0, 50), viewport_width=100)
assert stats.total_windows == 1
assert stats.mean_ink_ratio == pytest.approx(0.5)
class TestLongestRunHelper:
@pytest.mark.parametrize("flags,expected", [
([], 0),
([False, False], 0),
([True], 1),
([True, True, False, True], 2),
([False, True, True, True, False, True], 3),
([True, True, True], 3),
])
def test_run_lengths(self, flags, expected):
from src.vegas_mode.geometry import _longest_true_run
assert _longest_true_run(np.array(flags, dtype=bool)) == expected
class TestEdgeBlank:
def test_measures_both_edges(self):
assert edge_blank(paint(make_img(100), 20, 60)) == (20, 40)
def test_flush_content_has_no_blank(self):
assert edge_blank(paint(make_img(50), 0, 50)) == (0, 0)
def test_blank_image_reports_full_width_both_sides(self):
# No ink means nothing to be close to.
assert edge_blank(make_img(64)) == (64, 64)
class TestSeparationGap:
def test_flush_edges_get_the_full_target(self):
a = paint(make_img(50), 0, 50)
b = paint(make_img(50), 0, 50)
assert separation_gap(a, b, target=24) == 24
def test_existing_margins_reduce_the_added_gap(self):
# 8px blank on each facing edge already covers 16 of the 24 target.
a = paint(make_img(50), 0, 42)
b = paint(make_img(50), 8, 50)
assert separation_gap(a, b, target=24) == 8
def test_ample_existing_margin_adds_nothing(self):
a = paint(make_img(100), 0, 60)
b = paint(make_img(100), 40, 100)
assert separation_gap(a, b, target=24) == 0
def test_minimum_is_a_floor(self):
a = paint(make_img(100), 0, 60)
b = paint(make_img(100), 40, 100)
assert separation_gap(a, b, target=24, minimum=4) == 4
def test_never_negative(self):
a = paint(make_img(200), 0, 10)
b = paint(make_img(200), 190, 200)
assert separation_gap(a, b, target=8) == 0
def test_sports_card_case_gets_real_separation(self):
# The reported problem: cards drawn edge to edge sat 8px apart under a
# flat gap; measured separation lifts them to the 24px target.
card = paint(make_img(150), 0, 150)
assert separation_gap(card, card, target=24, minimum=8) == 24
class TestFindBlankCut:
def test_snaps_to_the_nearest_gap(self):
img = paint(make_img(200), 0, 90)
paint(img, 110, 200)
# 100 is inside the 90..110 gap already.
assert find_blank_cut(img, 100, 20) == 100
def test_walks_outwards_to_find_a_gap(self):
img = paint(make_img(200), 0, 95)
paint(img, 105, 200)
cut = find_blank_cut(img, 90, 20)
assert 95 <= cut < 105
def test_solid_ink_returns_the_target(self):
assert find_blank_cut(paint(make_img(200), 0, 200), 100, 20) == 100
def test_target_at_image_width_does_not_index_past_the_end(self):
# A cut after the last column is legal. Indexing ink[width] raised
# IndexError in the field, losing that plugin's content for the cycle.
# Reached once the rotation offset advances so start + budget lands
# exactly on the image width.
img = paint(make_img(1840), 0, 1840)
assert find_blank_cut(img, 1840, 32) == 1840
def test_target_past_image_width_is_clamped(self):
img = paint(make_img(100), 0, 100)
assert find_blank_cut(img, 500, 32) == 100
def test_target_at_width_with_a_trailing_gap_snaps_back(self):
# Content 0..179, blank 180..199. The nearest blank column to 200 is
# 199, not the start of the gap — nearest is what keeps the cut as
# close as possible to the requested budget.
img = paint(make_img(200), 0, 180)
assert find_blank_cut(img, 200, 32) == 199
def test_zero_radius_returns_the_target(self):
assert find_blank_cut(paint(make_img(100), 0, 100), 50, 0) == 50
def test_negative_target_is_clamped_to_zero(self):
assert find_blank_cut(paint(make_img(100), 0, 100), -20, 8) == 0
@pytest.mark.parametrize("target", [0, 1, 50, 99, 100])
def test_never_raises_across_the_range(self, target):
img = paint(make_img(100), 0, 100)
cut = find_blank_cut(img, target, 16)
assert 0 <= cut <= 100
+152 -2
View File
@@ -146,6 +146,11 @@ class TestConfigAPI:
def test_save_double_sided_settings(self, client, mock_config_manager):
"""Double-sided form fields are persisted under display.double_sided."""
# 2 copies on the vertical axis needs parallel to be a multiple of 2.
mock_config_manager.load_config.return_value['display']['hardware'] = {
'chain_length': 2, 'parallel': 2,
}
response = client.post(
'/api/v3/config/main',
data={
@@ -175,6 +180,91 @@ class TestConfigAPI:
assert ds['enabled'] is False
assert ds['copies'] == 4
def test_save_double_sided_disabled_skips_divisibility_check(self, client, mock_config_manager):
"""A copies/chain_length mismatch must not block saves while disabled.
The Display form posts copies/axis on every save, so validating them
with the feature off locked users out of every other display setting.
"""
mock_config_manager.load_config.return_value['display']['hardware'] = {
'chain_length': 3, 'parallel': 1,
}
response = client.post(
'/api/v3/config/main',
data={
'double_sided_copies': '2',
'double_sided_axis': 'horizontal',
'brightness': '75',
},
content_type='application/x-www-form-urlencoded',
)
assert response.status_code == 200
ds = mock_config_manager.save_config_atomic.call_args[0][0]['display']['double_sided']
assert ds['enabled'] is False
assert ds['copies'] == 2
def test_save_double_sided_enabled_enforces_divisibility(self, client, mock_config_manager):
"""The same mismatch is still rejected once the feature is turned on."""
mock_config_manager.load_config.return_value['display']['hardware'] = {
'chain_length': 3, 'parallel': 1,
}
response = client.post(
'/api/v3/config/main',
data={
'double_sided_enabled': 'true',
'double_sided_copies': '2',
'double_sided_axis': 'horizontal',
},
content_type='application/x-www-form-urlencoded',
)
assert response.status_code == 400
assert 'chain length' in response.get_json()['message']
mock_config_manager.save_config_atomic.assert_not_called()
def test_save_double_sided_vertical_checks_parallel(self, client, mock_config_manager):
"""The vertical axis is checked against parallel, not chain_length."""
mock_config_manager.load_config.return_value['display']['hardware'] = {
'chain_length': 2, 'parallel': 3,
}
response = client.post(
'/api/v3/config/main',
data={
'double_sided_enabled': 'true',
'double_sided_copies': '2',
'double_sided_axis': 'vertical',
},
content_type='application/x-www-form-urlencoded',
)
# chain_length 2 would divide evenly — only parallel 3 rejects this.
assert response.status_code == 400
assert 'parallel' in response.get_json()['message']
mock_config_manager.save_config_atomic.assert_not_called()
def test_save_double_sided_disabled_ignores_bad_values(self, client, mock_config_manager):
"""While disabled, unusable copies/axis are dropped rather than rejected."""
mock_config_manager.load_config.return_value['display']['double_sided'] = {
'enabled': True, 'copies': 2, 'axis': 'horizontal',
}
response = client.post(
'/api/v3/config/main',
data={'double_sided_copies': 'abc', 'double_sided_axis': 'diagonal'},
content_type='application/x-www-form-urlencoded',
)
assert response.status_code == 200
ds = mock_config_manager.save_config_atomic.call_args[0][0]['display']['double_sided']
assert ds['enabled'] is False
# Stored values left untouched rather than overwritten with junk.
assert ds['copies'] == 2
assert ds['axis'] == 'horizontal'
def test_save_double_sided_invalid_copies_rejected(self, client, mock_config_manager):
"""copies < 2 is rejected with a 400 before any save."""
response = client.post(
@@ -372,11 +462,71 @@ class TestPluginsAPI:
}
response = client.get('/api/v3/plugins/installed')
assert response.status_code == 200
data = json.loads(response.data)
assert isinstance(data, (list, dict))
def test_installed_plugins_report_update_available(self, client, mock_plugin_manager):
"""Installed-plugin entries surface latest_version + update_available
by comparing the on-disk manifest version to the registry."""
from web_interface.blueprints.api_v3 import api_v3
api_v3.plugin_manager = mock_plugin_manager
# No on-disk manifest to merge — keep the version we hand in below.
mock_plugin_manager.plugins_dir = '/nonexistent-plugins-dir'
mock_plugin_manager.get_all_plugin_info.return_value = [
{'id': 'weather', 'name': 'Weather', 'version': '1.0.0'}
]
# Avoid touching plugin instances (Vegas hooks, enabled fallback).
mock_plugin_manager.get_plugin.return_value = None
# Registry advertises a newer version than the installed one.
api_v3.plugin_store_manager.get_registry_info.return_value = {
'verified': True, 'latest_version': '1.2.0'
}
response = client.get('/api/v3/plugins/installed')
assert response.status_code == 200
payload = json.loads(response.data)
entry = payload['data']['plugins'][0]
assert entry['version'] == '1.0.0'
assert entry['latest_version'] == '1.2.0'
assert entry['update_available'] is True
def test_installed_plugins_no_update_when_current(self, client, mock_plugin_manager):
"""No update is flagged when installed version matches the registry."""
from web_interface.blueprints.api_v3 import api_v3
api_v3.plugin_manager = mock_plugin_manager
mock_plugin_manager.plugins_dir = '/nonexistent-plugins-dir'
mock_plugin_manager.get_all_plugin_info.return_value = [
{'id': 'weather', 'name': 'Weather', 'version': '1.2.0'}
]
mock_plugin_manager.get_plugin.return_value = None
api_v3.plugin_store_manager.get_registry_info.return_value = {
'verified': True, 'latest_version': '1.2.0'
}
response = client.get('/api/v3/plugins/installed')
assert response.status_code == 200
entry = json.loads(response.data)['data']['plugins'][0]
assert entry['latest_version'] == '1.2.0'
assert entry['update_available'] is False
def test_is_plugin_update_available_helper(self):
"""Unit-level checks for the semver-aware update comparison."""
from web_interface.blueprints.api_v3 import _is_plugin_update_available
assert _is_plugin_update_available('1.0.0', '1.0.1') is True
assert _is_plugin_update_available('1.0.1', '1.0.1') is False
# Local build ahead of the registry must not be flagged.
assert _is_plugin_update_available('2.0.0', '1.9.9') is False
# Missing either side yields no signal.
assert _is_plugin_update_available('', '1.0.0') is False
assert _is_plugin_update_available('1.0.0', '') is False
# Unparseable version differing from the installed one surfaces the
# mismatch rather than hiding a possible update.
assert _is_plugin_update_available('1.0.0', 'not-a-semver') is True
def test_get_plugin_health(self, client, mock_plugin_manager):
"""Test getting plugin health information."""
from web_interface.blueprints.api_v3 import api_v3
+184
View File
@@ -0,0 +1,184 @@
"""
Web-UI smoke tests: every page, partial, and critical static asset must render.
These boot the pages blueprint with the same dual registration app.py uses
(un-prefixed primary + /v3 legacy alias) and assert each surface returns 200
with its load-bearing markers present. They exist to catch, in CI, the class
of regression that only shows up when a real request renders a real template:
a broken partial, a missing tab wiring, a renamed element id that JS depends
on, or a static asset that stopped being served.
"""
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from flask import Flask
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
SMOKE_CONFIG = {
"web_display_autostart": True,
"timezone": "America/Chicago",
"location": {"city": "Dallas", "state": "Texas", "country": "US"},
"plugin_system": {
"auto_discover": True,
"auto_load_enabled": True,
"development_mode": False,
"plugins_directory": "plugin-repos",
},
"schedule": {},
"dim_schedule": {"dim_brightness": 30},
"sync": {"role": "standalone", "port": 5765, "follower_position": "left"},
"clock": {"enabled": True},
"ledmatrix-weather": {"enabled": True},
"display": {
"hardware": {
"rows": 32, "cols": 64, "chain_length": 2, "parallel": 1,
"brightness": 95, "hardware_mapping": "adafruit-hat-pwm",
"led_rgb_sequence": "RGB", "multiplexing": 0, "panel_type": "",
"row_address_type": 0, "scan_mode": 0, "pwm_bits": 9,
"pwm_dither_bits": 1, "pwm_lsb_nanoseconds": 130,
"limit_refresh_rate_hz": 120, "disable_hardware_pulsing": False,
"inverse_colors": False, "show_refresh_rate": False,
},
"runtime": {"gpio_slowdown": 3, "rp1_rio": 0},
"double_sided": {"enabled": False, "copies": 2, "axis": "horizontal"},
"use_short_date_format": False,
"dynamic_duration": {"max_duration_seconds": 180},
"vegas_scroll": {
"enabled": False, "scroll_speed": 50, "separator_width": 32,
"target_fps": 125, "buffer_ahead": 2,
"plugin_order": [], "excluded_plugins": [],
},
"display_durations": {"stale_saved_mode": 45},
"plugin_rotation_order": ["ledmatrix-weather", "clock"],
},
}
PLUGIN_MODES = {
"clock": ["clock"],
"ledmatrix-weather": ["weather_current", "weather_daily"],
}
@pytest.fixture
def client():
base = PROJECT_ROOT / "web_interface"
app = Flask(
__name__,
template_folder=str(base / "templates"),
static_folder=str(base / "static"),
)
app.config["TESTING"] = True
from web_interface.blueprints import pages_v3 as pv
# pages_v3 is a module-level Blueprint singleton shared by the whole test
# process (test_web_settings_ui.py mutates the same attributes) - save
# the originals and restore them on teardown so this fixture can't leak
# its mocks into tests that run afterward.
original_config_manager = getattr(pv.pages_v3, "config_manager", None)
original_plugin_manager = getattr(pv.pages_v3, "plugin_manager", None)
mock_cm = MagicMock()
mock_cm.load_config.return_value = SMOKE_CONFIG
mock_cm.get_raw_file_content.return_value = SMOKE_CONFIG
mock_cm.get_config_path.return_value = "config/config.json"
mock_cm.get_secrets_path.return_value = "config/config_secrets.json"
pv.pages_v3.config_manager = mock_cm
mock_pm = MagicMock()
mock_pm.plugins = {}
mock_pm.get_all_plugin_info.return_value = [
{"id": "clock", "name": "Clock"},
{"id": "ledmatrix-weather", "name": "Weather"},
]
mock_pm.get_plugin_display_modes.side_effect = (
lambda pid: PLUGIN_MODES.get(pid, [])
)
pv.pages_v3.plugin_manager = mock_pm
# Same dual registration as web_interface/app.py: un-prefixed primary,
# /v3 kept as a working legacy alias.
app.register_blueprint(pv.pages_v3, url_prefix="")
app.register_blueprint(pv.pages_v3, url_prefix="/v3", name="pages_v3_legacy")
try:
yield app.test_client()
finally:
pv.pages_v3.config_manager = original_config_manager
pv.pages_v3.plugin_manager = original_plugin_manager
# (path, [markers that must appear in the body])
PAGES = [
("/", ["site-nav", "mobileNavOpen", 'rel="manifest"',
"restart-pending-banner", "activeTab = 'durations'"]),
("/partials/overview", ["getting-started-card", "displayImage"]),
("/partials/general", ["timezone"]),
("/partials/display", ["display-section-advanced-hardware",
"display-resolution-value", "vegas_scroll_label"]),
("/partials/durations", ["rotation_plugin_order", "duration__clock",
"duration__weather_current",
"duration__stale_saved_mode"]),
("/partials/schedule", ["schedule"]),
]
@pytest.mark.parametrize("path,markers", PAGES, ids=[p for p, _ in PAGES])
def test_page_renders_with_markers(client, path, markers):
resp = client.get(path)
assert resp.status_code == 200, f"{path} -> {resp.status_code}"
body = resp.get_data(as_text=True)
for marker in markers:
assert marker in body, f"{path}: missing marker {marker!r}"
@pytest.mark.parametrize("path", [p for p, _ in PAGES if p != "/"])
def test_legacy_v3_alias_serves_the_same_partials(client, path):
assert client.get("/v3" + path).status_code == 200
STATIC_ASSETS = [
"/static/v3/app.css",
"/static/v3/app.js",
"/static/v3/manifest.json",
"/static/v3/icons/icon-192.png",
"/static/v3/js/app-shell.js",
"/static/v3/js/app-early.js",
"/static/v3/js/htmx-config.js",
"/static/v3/js/widgets/plugin-order-list.js",
"/static/v3/js/widgets/notification.js",
"/static/v3/vendor/fontawesome/css/all.min.css",
"/static/v3/vendor/codemirror/codemirror.min.js",
]
@pytest.mark.parametrize("asset", STATIC_ASSETS)
def test_static_asset_served(client, asset):
resp = client.get(asset)
assert resp.status_code == 200, f"{asset} -> {resp.status_code}"
assert len(resp.data) > 0
def test_durations_page_groups_by_plugin(client):
"""One duration input per display mode of each enabled plugin, plus the
leftover group for saved keys no enabled plugin owns."""
body = client.get("/partials/durations").get_data(as_text=True)
assert body.count("duration__") >= 2 * len(
[m for modes in PLUGIN_MODES.values() for m in modes]
) # each mode: id= and name=
assert "Other saved entries" in body
def test_display_advanced_section_contains_tuning_fields(client):
body = client.get("/partials/display").get_data(as_text=True)
adv = body.find('id="display-section-advanced-hardware"')
adv_close = body.find("/#display-section-advanced-hardware")
assert 0 < adv < adv_close
for field in ["multiplexing", "pwm_bits", "inverse_colors"]:
pos = body.find(f'name="{field}"')
assert adv < pos < adv_close, f"{field} not inside the advanced section"
+85
View File
@@ -0,0 +1,85 @@
"""
Static-analysis audits for the web UI, as tests so CI enforces them.
1. Breakpoint utility audit: app.css hand-maintains a Tailwind-style utility
subset, so a template can reference a responsive class (e.g. sm:block)
that no CSS rule defines it silently no-ops. This once left the header
search box and system stats invisible at every screen width. The audit
diffs classes used in templates against classes defined in app.css.
2. Asset reference audit: every url_for('static', filename=...) in the
templates must point to a file that exists, so a renamed/moved asset
can't ship as a broken <script>/<link>/<img>.
3. debugLog globals audit: any static JS file calling debugLog() (a global
defined in base.html) must declare it in a /* global */ header so linting
stays clean and the dependency is explicit.
"""
import re
from pathlib import Path
PROJECT_ROOT = Path(__file__).parent.parent
WEB = PROJECT_ROOT / "web_interface"
TEMPLATES = WEB / "templates"
STATIC = WEB / "static"
APP_CSS = STATIC / "v3" / "app.css"
BP_PREFIXES = ("sm", "md", "lg", "xl", "2xl")
def _template_files():
return sorted(TEMPLATES.rglob("*.html"))
def test_every_used_breakpoint_class_is_defined():
used = set()
class_attr = re.compile(r'class="([^"]*)"')
bp_class = re.compile(r"\b(%s):[A-Za-z0-9_.-]+" % "|".join(BP_PREFIXES))
for path in _template_files():
for attr in class_attr.findall(path.read_text()):
for m in bp_class.finditer(attr):
used.add(m.group(0))
css = APP_CSS.read_text()
defined = {
m.group(0).lstrip(".").replace("\\:", ":")
for m in re.finditer(
r"\.(%s)\\:[A-Za-z0-9_-]+" % "|".join(BP_PREFIXES), css
)
}
missing = sorted(used - defined)
assert not missing, (
"Responsive utility classes referenced in templates but never defined "
f"in app.css (they silently no-op): {missing}"
)
def test_every_static_url_for_points_to_a_real_file():
ref = re.compile(
r"url_for\(\s*['\"]static['\"]\s*,\s*filename\s*=\s*['\"]([^'\"]+)['\"]"
)
missing = []
for path in _template_files():
for filename in ref.findall(path.read_text()):
if not (STATIC / filename).is_file():
missing.append(f"{path.relative_to(PROJECT_ROOT)}: {filename}")
assert not missing, f"Templates reference missing static assets: {missing}"
def test_js_files_calling_debuglog_declare_the_global():
undeclared = []
for path in sorted((STATIC / "v3").rglob("*.js")):
if "vendor" in path.parts:
continue
text = path.read_text()
# Calls debugLog( but neither defines it nor declares the global
calls = re.search(r"(?<![.\w])debugLog\(", text)
defines = "window.debugLog" in text
declares = re.search(r"/\*\s*global[^*]*\bdebugLog\b", text)
if calls and not defines and not declares:
undeclared.append(str(path.relative_to(PROJECT_ROOT)))
assert not undeclared, (
f"JS files call debugLog() without a /* global debugLog */ header: {undeclared}"
)
+47 -27
View File
@@ -59,6 +59,20 @@ except ImportError:
# flask-limiter not installed, rate limiting disabled
limiter = None
# Enable gzip/brotli response compression (Flask-Compress skips streaming
# responses, so the SSE endpoints are unaffected). Optional, like limiter:
# missing package just means uncompressed responses.
try:
from flask_compress import Compress
Compress(app)
except ImportError:
logging.getLogger(__name__).warning(
"flask-compress not installed - responses will be served uncompressed. "
"Install it with the Tools tab's 'Install Base Requirements' button or "
"'pip install flask-compress'."
)
# Import cache functions from separate module to avoid circular imports
# Initialize plugin managers - read plugins directory from config
@@ -176,7 +190,12 @@ except Exception as _hm_err: # pragma: no cover - defensive startup guard
"Could not enable plugin health/metrics for web UI: %s", _hm_err
)
app.register_blueprint(pages_v3, url_prefix='/v3')
# Pages are served un-prefixed (the interface lives at /); the /v3 mount is a
# legacy alias kept so existing bookmarks and the hardcoded /v3/partials/...
# fetches in templates/JS keep working unchanged. url_for('pages_v3.*')
# resolves against the primary (un-prefixed) registration.
app.register_blueprint(pages_v3, url_prefix='')
app.register_blueprint(pages_v3, url_prefix='/v3', name='pages_v3_legacy')
app.register_blueprint(api_v3, url_prefix='/api/v3')
# Route to serve plugin asset files (registered on main app, not blueprint, for /assets/... path)
@@ -407,7 +426,11 @@ def captive_portal_redirect():
# List of paths that should NOT be redirected (allow normal operation)
allowed_paths = [
'/v3', # Main interface and all sub-paths (includes /v3/setup)
'/v3', # Legacy-prefixed interface and all sub-paths
'/setup', # Captive setup page itself (un-prefixed mount)
'/partials/', # HTMX partials (un-prefixed mount)
'/settings/', # Settings search index (un-prefixed mount)
'/plugin-ui/', # Plugin-provided web UI assets (un-prefixed mount)
'/api/v3/', # All API endpoints
'/static/', # Static files (CSS, JS, images)
'/hotspot-detect.html', # iOS/macOS detection
@@ -606,8 +629,6 @@ def system_status_generator():
def display_preview_generator():
"""Generate display preview updates from snapshot file"""
import base64
from PIL import Image
import io
snapshot_path = "/tmp/led_matrix_preview.png" # nosec B108 - fixed path matches display_manager; only read here
# Viewer marker: this generator only runs while the broadcaster has
@@ -649,24 +670,26 @@ def display_preview_generator():
# Only read if file is new or has been updated
if last_modified is None or current_modified > last_modified:
try:
# Read and encode the image
with Image.open(snapshot_path) as img:
# Convert to PNG and encode as base64
buffer = io.BytesIO()
img.save(buffer, format='PNG')
img_str = base64.b64encode(buffer.getvalue()).decode('utf-8')
preview_data = {
'timestamp': time.time(),
'width': width,
'height': height,
'image': img_str
}
last_modified = current_modified
yield preview_data
except Exception: # nosec B110 - SSE preview file may be mid-write; transient error, skip this update
# File might be being written, skip this update
pass
# The snapshot is already a PNG, written atomically by
# the display service (tmp + os.replace in
# display_manager), so pass the raw bytes straight
# through instead of PIL-decoding and re-encoding —
# identical payload, much less CPU on the Pi.
with open(snapshot_path, 'rb') as f:
img_str = base64.b64encode(f.read()).decode('utf-8')
preview_data = {
'timestamp': time.time(),
'width': width,
'height': height,
'image': img_str
}
last_modified = current_modified
yield preview_data
except OSError:
# Transient filesystem race (file rotated/replaced
# between mtime check and read); skip this update.
app.logger.debug("Preview snapshot read failed; skipping frame", exc_info=True)
else:
# No snapshot available
yield {
@@ -799,11 +822,8 @@ if limiter:
limiter.limit("200 per minute")(stream_display)
limiter.limit("200 per minute")(stream_logs)
# Main route - redirect to v3 interface as default
@app.route('/')
def index():
"""Redirect to v3 interface"""
return redirect(url_for('pages_v3.index'))
# The pages blueprint's index now serves '/' directly (see the un-prefixed
# blueprint registration above), so no redirect route is needed here.
@app.route('/favicon.ico')
def favicon():
+369 -32
View File
@@ -13,7 +13,7 @@ import uuid
import logging
from datetime import datetime
from pathlib import Path
from typing import Dict, Any
from typing import Dict, Any, Optional
from urllib.parse import urlparse, urlunparse
logger = logging.getLogger(__name__)
@@ -120,6 +120,33 @@ def _get_plugin_version(plugin_id: str) -> str:
logger.warning("[PluginVersion] Invalid JSON in manifest for %s at %s: %s", plugin_id, manifest_path, e)
return ''
def _is_plugin_update_available(installed_version: str, latest_version: str) -> bool:
"""Return True when the registry's ``latest_version`` is strictly newer
than the installed version.
Uses PEP 440 / semver-aware comparison so a locally modified plugin whose
version is *ahead* of the published registry is not flagged as needing an
update. If either version string can't be parsed, falls back to a plain
inequality check (any difference is surfaced so the user can reconcile).
"""
if not installed_version or not latest_version:
return False
if installed_version == latest_version:
return False
try:
from packaging.version import parse as _parse_version, InvalidVersion
except ImportError:
# packaging is a core dependency, but if it's somehow unavailable we
# can't compare semantically — surface the mismatch we already know
# exists (the two strings differ).
return True
try:
return _parse_version(latest_version) > _parse_version(installed_version)
except InvalidVersion:
# Unparseable version string: we can't tell direction, so surface the
# mismatch rather than silently hiding a potential update.
return True
def _ensure_cache_manager():
"""Ensure cache manager is initialized."""
global cache_manager
@@ -837,16 +864,15 @@ def save_main_config():
ds_config = current_config['display']['double_sided']
# Enabled checkbox: omitted from the form when unchecked.
ds_config['enabled'] = _coerce_to_bool(data.get('double_sided_enabled'))
# The Display form posts copies/axis on every save regardless of this
# checkbox, so when the feature is off we accept the values without
# rejecting the whole save — otherwise a stale copies/chain_length
# mismatch locks the user out of every other display setting.
enabled = _coerce_to_bool(data.get('double_sided_enabled'))
ds_config['enabled'] = enabled
if 'double_sided_copies' in data and data['double_sided_copies'] not in ('', None):
try:
copies = int(data['double_sided_copies'])
except (ValueError, TypeError):
return jsonify({'status': 'error', 'message': "Double-sided copies must be an integer"}), 400
if not (2 <= copies <= 8):
return jsonify({'status': 'error', 'message': "Double-sided copies must be between 2 and 8"}), 400
# Validate divisibility against the relevant hardware dimension.
def _copies_fits_hardware(copies: int) -> Optional[str]:
"""Error message if copies doesn't divide the panel evenly, else None."""
# Use axis from this request if provided, else from stored config.
hw = current_config.get('display', {}).get('hardware', {})
effective_axis = (data.get('double_sided_axis')
@@ -854,22 +880,53 @@ def save_main_config():
if effective_axis == 'horizontal':
chain_length = int(hw.get('chain_length', 2) or 2)
if chain_length % copies != 0:
return jsonify({'status': 'error', 'message': f"Double-sided copies ({copies}) must divide chain length ({chain_length}) evenly"}), 400
return f"Double-sided copies ({copies}) must divide chain length ({chain_length}) evenly"
elif effective_axis == 'vertical':
parallel = int(hw.get('parallel', 1) or 1)
if parallel % copies != 0:
return jsonify({'status': 'error', 'message': f"Double-sided copies ({copies}) must divide parallel ({parallel}) evenly"}), 400
ds_config['copies'] = copies
return f"Double-sided copies ({copies}) must divide parallel ({parallel}) evenly"
return None
if 'double_sided_copies' in data and data['double_sided_copies'] not in ('', None):
copies = None
try:
copies = int(data['double_sided_copies'])
except (ValueError, TypeError):
if enabled:
return jsonify({'status': 'error', 'message': "Double-sided copies must be an integer"}), 400
if copies is not None and not (2 <= copies <= 8):
if enabled:
return jsonify({'status': 'error', 'message': "Double-sided copies must be between 2 and 8"}), 400
# Disabled: leave the stored value alone rather than writing junk.
copies = None
if copies is not None:
# Divisibility is a hardware-relational check — only meaningful
# when the feature is actually on.
if enabled:
fit_error = _copies_fits_hardware(copies)
if fit_error:
return jsonify({'status': 'error', 'message': fit_error}), 400
ds_config['copies'] = copies
if 'double_sided_axis' in data:
axis = data['double_sided_axis']
if axis not in ('horizontal', 'vertical'):
return jsonify({'status': 'error', 'message': "Double-sided axis must be 'horizontal' or 'vertical'"}), 400
ds_config['axis'] = axis
if enabled:
return jsonify({'status': 'error', 'message': "Double-sided axis must be 'horizontal' or 'vertical'"}), 400
else:
ds_config['axis'] = axis
# Handle Vegas scroll mode settings
vegas_fields = ['vegas_scroll_enabled', 'vegas_scroll_speed', 'vegas_separator_width',
'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order', 'vegas_excluded_plugins']
'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order', 'vegas_excluded_plugins',
'vegas_auto_trim', 'vegas_trim_threshold', 'vegas_content_padding',
'vegas_min_plugin_width', 'vegas_lead_in_width', 'vegas_plugins_per_cycle',
'vegas_max_plugin_width_ratio', 'vegas_dynamic_duration_enabled',
'vegas_min_cycle_duration', 'vegas_max_cycle_duration',
'vegas_intra_plugin_gap', 'vegas_render_width_pct',
'vegas_min_content_separation', 'vegas_min_cut_gap',
'vegas_continuous_scroll', 'vegas_extend_threshold_screens',
'vegas_smooth_scroll', 'vegas_overflow_mode']
if any(k in data for k in vegas_fields):
if 'display' not in current_config:
@@ -884,13 +941,85 @@ def save_main_config():
# was submitted (any vegas field present) but enabled key is missing,
# the checkbox was unchecked and we should set enabled=False
vegas_config['enabled'] = _coerce_to_bool(data.get('vegas_scroll_enabled'))
vegas_config['auto_trim'] = _coerce_to_bool(data.get('vegas_auto_trim'))
vegas_config['dynamic_duration_enabled'] = _coerce_to_bool(
data.get('vegas_dynamic_duration_enabled'))
vegas_config['continuous_scroll'] = _coerce_to_bool(
data.get('vegas_continuous_scroll'))
vegas_config['smooth_scroll'] = _coerce_to_bool(
data.get('vegas_smooth_scroll'))
# Handle numeric settings with validation
# max_plugin_width_ratio is the one fractional setting, so it is
# handled outside the integer loop below.
if data.get('vegas_overflow_mode') not in ('', None):
mode = str(data['vegas_overflow_mode']).strip().lower()
if mode not in ('rotate', 'truncate'):
return jsonify({
'status': 'error',
'message': "Invalid value for vegas_overflow_mode: "
"must be 'rotate' or 'truncate'"
}), 400
vegas_config['overflow_mode'] = mode
if data.get('vegas_extend_threshold_screens') not in ('', None):
try:
screens = float(data['vegas_extend_threshold_screens'])
except (ValueError, TypeError):
return jsonify({
'status': 'error',
'message': "Invalid value for vegas_extend_threshold_screens: "
"must be a number"
}), 400
if not (1.0 <= screens <= 10.0):
return jsonify({
'status': 'error',
'message': "Invalid value for vegas_extend_threshold_screens: "
"must be between 1.0 and 10.0"
}), 400
vegas_config['extend_threshold_screens'] = screens
if data.get('vegas_max_plugin_width_ratio') not in ('', None):
try:
ratio = float(data['vegas_max_plugin_width_ratio'])
except (ValueError, TypeError):
return jsonify({
'status': 'error',
'message': "Invalid value for vegas_max_plugin_width_ratio: "
"must be a number"
}), 400
if not (0 <= ratio <= 20):
return jsonify({
'status': 'error',
'message': "Invalid value for vegas_max_plugin_width_ratio: "
"must be between 0 and 20 (0 disables the cap)"
}), 400
vegas_config['max_plugin_width_ratio'] = ratio
# Handle numeric settings with validation.
#
# These bounds must match VegasModeConfig.validate(), which is what
# actually gates Vegas starting. Where they were looser, a value
# saved with a 200 and then made VegasModeCoordinator.start() bail
# out with only a log line, so the ticker silently never ran.
# Where they were tighter (scroll_speed capped at 100 against a
# slider that goes to 200), a legitimate value was rejected with a
# 400. See test_vegas_api_bounds_match_validate.
numeric_fields = {
'vegas_scroll_speed': ('scroll_speed', 1, 100),
'vegas_separator_width': ('separator_width', 0, 500),
'vegas_target_fps': ('target_fps', 1, 200),
'vegas_buffer_ahead': ('buffer_ahead', 1, 20),
'vegas_scroll_speed': ('scroll_speed', 1, 200),
'vegas_separator_width': ('separator_width', 0, 128),
'vegas_intra_plugin_gap': ('intra_plugin_gap', 0, 128),
'vegas_render_width_pct': ('render_width_pct', 10, 100),
'vegas_min_content_separation': ('min_content_separation', 0, 256),
'vegas_min_cut_gap': ('min_cut_gap', 1, 128),
'vegas_target_fps': ('target_fps', 30, 200),
'vegas_buffer_ahead': ('buffer_ahead', 1, 5),
'vegas_trim_threshold': ('trim_threshold', 0, 254),
'vegas_content_padding': ('content_padding', 0, 128),
'vegas_min_plugin_width': ('min_plugin_width', 0, 512),
'vegas_lead_in_width': ('lead_in_width', 0, 2048),
'vegas_plugins_per_cycle': ('plugins_per_cycle', 1, 50),
'vegas_min_cycle_duration': ('min_cycle_duration', 5, 3600),
'vegas_max_cycle_duration': ('max_cycle_duration', 10, 3600),
}
for field_name, (config_key, min_val, max_val) in numeric_fields.items():
if field_name in data:
@@ -961,8 +1090,31 @@ def save_main_config():
return jsonify({"status": "error", "message": "sync_follower_position must be left or right"}), 400
current_config["sync"]["follower_position"] = pos_val
# Handle display durations
duration_fields = [k for k in data.keys() if k.endswith('_duration') or k in ['default_duration', 'transition_duration']]
# Handle primary rotation order: must be a JSON array of plugin-id
# strings. Reject anything else with a 400 rather than silently
# coercing, so a buggy client can't clear or corrupt the saved order.
if 'plugin_rotation_order' in data:
raw_order = data.pop('plugin_rotation_order')
try:
parsed = json.loads(raw_order) if isinstance(raw_order, str) else raw_order
except (json.JSONDecodeError, TypeError, ValueError):
return jsonify({'status': 'error',
'message': 'plugin_rotation_order must be valid JSON'}), 400
if not isinstance(parsed, list) or not all(isinstance(p, str) for p in parsed):
return jsonify({'status': 'error',
'message': 'plugin_rotation_order must be a list of plugin-id strings'}), 400
if 'display' not in current_config:
current_config['display'] = {}
current_config['display']['plugin_rotation_order'] = parsed
# Handle display durations. Popped from `data` (not just read) so
# they can never also fall through to the generic "remaining keys"
# merge near the end of this function, which would otherwise write
# them AGAIN as bogus top-level config keys (e.g. "clock_duration": 30
# sitting at config root alongside the correct
# display.display_durations.clock_duration).
duration_fields = [k for k in list(data.keys())
if k.endswith('_duration') or k in ('default_duration', 'transition_duration')]
if duration_fields:
if 'display' not in current_config:
current_config['display'] = {}
@@ -970,8 +1122,36 @@ def save_main_config():
current_config['display']['display_durations'] = {}
for field in duration_fields:
if field in data:
current_config['display']['display_durations'][field] = int(data[field])
raw_value = data.pop(field)
try:
int_value = int(raw_value)
except (ValueError, TypeError):
return jsonify({'status': 'error',
'message': f"Invalid duration for {field}: must be an integer"}), 400
current_config['display']['display_durations'][field] = int_value
# Per-mode durations from the Rotation & Durations page, posted as
# duration__<mode_key> (mode keys are arbitrary plugin mode names, so
# they can't use the suffix convention above). Same pop-and-validate
# treatment, for the same reason.
mode_duration_fields = [k for k in list(data.keys()) if k.startswith('duration__')]
if mode_duration_fields:
if 'display' not in current_config:
current_config['display'] = {}
if 'display_durations' not in current_config['display']:
current_config['display']['display_durations'] = {}
for field in mode_duration_fields:
raw_value = data.pop(field)
mode_key = field[len('duration__'):]
if not mode_key:
continue
try:
int_value = int(raw_value)
except (ValueError, TypeError):
return jsonify({'status': 'error',
'message': f"Invalid duration for mode '{mode_key}': must be an integer"}), 400
current_config['display']['display_durations'][mode_key] = int_value
# Handle plugin configurations dynamically
# Any key that matches a plugin ID should be saved as plugin config
@@ -1639,6 +1819,16 @@ def execute_system_action():
except subprocess.TimeoutExpired:
logger.warning("git stash timed out, proceeding with pull")
# Record HEAD before the pull so dependency changes can be detected
old_head = None
try:
_pre = subprocess.run(['git', 'rev-parse', 'HEAD'],
capture_output=True, text=True, timeout=10, cwd=project_dir)
if _pre.returncode == 0:
old_head = _pre.stdout.strip()
except subprocess.TimeoutExpired:
logger.warning("git rev-parse timed out before pull")
# Perform the git pull
result = subprocess.run(
['git', 'pull', '--rebase'],
@@ -1655,6 +1845,54 @@ def execute_system_action():
pull_message = f"Code updated successfully. Local changes were automatically stashed.{stash_info}"
if result.stdout and "Already up to date" not in result.stdout:
pull_message = f"Code updated successfully.{stash_info}"
# Keep Python dependencies in sync automatically: if the pull
# changed a requirements file, install it now — users updating
# from the web UI (most of them) never SSH in to pip install.
# Installs go through the same root-visible path as the
# Tools-tab buttons (_pip_install_requirements).
dep_notes = []
try:
_post = subprocess.run(['git', 'rev-parse', 'HEAD'],
capture_output=True, text=True, timeout=10, cwd=project_dir)
new_head = _post.stdout.strip() if _post.returncode == 0 else None
if old_head and new_head and old_head != new_head:
diff = subprocess.run(
['git', 'diff', '--name-only', f'{old_head}..{new_head}'],
capture_output=True, text=True, timeout=15, cwd=project_dir)
changed = set(diff.stdout.split()) if diff.returncode == 0 else set()
for rel in ('requirements.txt', 'web_interface/requirements.txt'):
req_path = PROJECT_ROOT / rel
if rel not in changed or not req_path.exists():
continue
# Each file's install is isolated: a timeout or
# OSError (e.g. the sudo wrapper/interpreter
# missing) on one file must not abort the other.
try:
r = _pip_install_requirements(req_path, timeout=180)
if r.returncode == 0:
dep_notes.append(f"Dependencies from {rel} updated.")
else:
dep_notes.append(
f"Dependency install from {rel} failed — "
"run Install Base Requirements from the Tools tab.")
logger.warning("post-update pip install failed for %s: %s",
rel, _truncate_output(r.stdout, r.stderr))
except subprocess.TimeoutExpired:
dep_notes.append(
f"Dependency install from {rel} timed out — "
"run Install Base Requirements from the Tools tab.")
logger.warning("post-update pip install timed out for %s", rel)
except OSError as install_err:
dep_notes.append(
f"Dependency install from {rel} failed — "
"run Install Base Requirements from the Tools tab.")
logger.warning("post-update pip install errored for %s: %s",
rel, install_err)
except subprocess.TimeoutExpired:
logger.warning("post-update dependency sync timed out")
if dep_notes:
pull_message += " " + " ".join(dep_notes)
# A `git pull` restores built-in plugins (committed under
# plugin-repos/) even if the user uninstalled them. Re-remove
# any the user previously uninstalled so the update doesn't
@@ -1685,14 +1923,36 @@ def execute_system_action():
result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix-web.service'],
capture_output=True, text=True, timeout=10)
elif action == 'install_base_requirements':
req_file = PROJECT_ROOT / 'requirements.txt'
if not req_file.exists():
# Base + web interface requirements: flask-compress and friends
# live in web_interface/requirements.txt, not the root file.
req_files = [f for f in (PROJECT_ROOT / 'requirements.txt',
PROJECT_ROOT / 'web_interface' / 'requirements.txt')
if f.exists()]
if not req_files:
return jsonify({'status': 'error', 'message': 'No requirements.txt found at project root'})
result = _pip_install_requirements(req_file, timeout=120)
outputs = []
all_ok = True
for req_file in req_files:
label = req_file.relative_to(PROJECT_ROOT)
# Isolate each file's install: a timeout or OSError on one
# (e.g. requirements.txt) must not abort the rest of the
# loop (e.g. web_interface/requirements.txt never attempted).
try:
result = _pip_install_requirements(req_file, timeout=120)
all_ok = all_ok and result.returncode == 0
outputs.append(f"== {label} ==\n" + _truncate_output(result.stdout, result.stderr))
except subprocess.TimeoutExpired:
all_ok = False
outputs.append(f"== {label} ==\nTimed out after 120s")
logger.warning("install_base_requirements timed out for %s", label)
except OSError as install_err:
all_ok = False
outputs.append(f"== {label} ==\nFailed: {install_err}")
logger.warning("install_base_requirements errored for %s: %s", label, install_err)
return jsonify({
'status': 'success' if result.returncode == 0 else 'error',
'message': 'Base requirements installed successfully' if result.returncode == 0 else 'pip install failed',
'output': _truncate_output(result.stdout, result.stderr)
'status': 'success' if all_ok else 'error',
'message': 'Base requirements installed successfully' if all_ok else 'pip install failed',
'output': "\n".join(outputs)
})
elif action == 'install_plugin_requirements':
active_pm = getattr(api_v3, 'plugin_manager', None)
@@ -2111,9 +2371,12 @@ def get_installed_plugins():
if enabled is None:
enabled = plugin_instance.enabled if plugin_instance else True
# Verified from registry (no network call)
# Verified + latest published version from registry (no network call)
store_info = api_v3.plugin_store_manager.get_registry_info(plugin_id)
verified = store_info.get('verified', False) if store_info else False
latest_version = store_info.get('latest_version', '') if store_info else ''
installed_version = plugin_info.get('version', '')
update_available = _is_plugin_update_available(installed_version, latest_version)
# Local git info (single subprocess on cache miss, zero on hit)
plugin_path = Path(api_v3.plugin_manager.plugins_dir) / plugin_id
@@ -2160,6 +2423,8 @@ def get_installed_plugins():
'id': plugin_id,
'name': plugin_info.get('name', plugin_id),
'version': plugin_info.get('version', ''),
'latest_version': latest_version,
'update_available': update_available,
'author': plugin_info.get('author', 'Unknown'),
'category': plugin_info.get('category', 'General'),
'description': plugin_info.get('description', 'No description available'),
@@ -5262,6 +5527,18 @@ def get_plugin_schema():
schema = schema_mgr.load_schema(plugin_id, use_cache=True)
if schema:
# Offer installed visual skins as a dropdown (returns a copy;
# the cached schema and validation are never enum-restricted)
try:
current_skin = None
if api_v3.config_manager:
config = api_v3.config_manager.load_config()
current_skin = config.get(plugin_id, {}).get('skin')
injected = schema_mgr.inject_skin_selector(schema, plugin_id, current_skin)
if isinstance(injected, dict):
schema = injected
except Exception:
logger.debug('Skin selector injection failed for %s', plugin_id, exc_info=True)
return jsonify({'status': 'success', 'data': {'schema': schema}})
# Return a simple default schema if file not found
@@ -5290,6 +5567,43 @@ def get_plugin_schema():
logger.error('Error in get_plugin_schema', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
@api_v3.route('/skins', methods=['GET'])
def list_skins():
"""List installed visual skins (docs/SKIN_SYSTEM.md).
Optional ?plugin_id=... filters to skins matching that plugin.
"""
try:
from src.skin_system import skin_runtime
plugin_id = request.args.get('plugin_id')
if plugin_id:
skins = skin_runtime.skins_for_plugin(plugin_id)
else:
# The discovery cache self-invalidates on directory/manifest
# mtime changes, so no force_refresh — keeps Pi disk I/O down.
skins = skin_runtime.discover_skins()
payload = []
for skin_id, manifest in sorted(skins.items()):
skin_dir = Path(manifest['_skin_dir'])
preview = manifest.get('preview')
payload.append({
'id': skin_id,
'name': manifest.get('name', skin_id),
'version': manifest.get('version'),
'author': manifest.get('author'),
'description': manifest.get('description', ''),
'skin_api_version': manifest.get('skin_api_version'),
'targets': manifest.get('targets', {}),
'modes': manifest.get('modes', []),
'has_preview': bool(preview and (skin_dir / preview).is_file()),
})
return jsonify({'status': 'success', 'data': {'skins': payload}})
except Exception:
logger.error('Error in list_skins', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
@api_v3.route('/plugins/config/reset', methods=['POST'])
def reset_plugin_config():
"""Reset plugin configuration to schema defaults"""
@@ -6891,6 +7205,29 @@ def list_plugin_assets():
logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
@api_v3.route('/display/current-status', methods=['GET'])
def get_current_display_status():
"""Return the display mode/plugin currently intended to be shown.
Published by the display process (display_controller._publish_current_mode_state)
to the shared cache whenever the active mode changes, so the web UI (e.g. the
System Logs page) can show what's on screen without querying the display
process directly.
"""
try:
cache = _ensure_cache_manager()
state = cache.get('display_current_state', max_age=120)
if state is None:
state = {
'mode': None,
'plugin_id': None,
'last_updated': None,
}
return jsonify({'status': 'success', 'data': state})
except Exception:
logger.error('Error in get_current_display_status', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
@api_v3.route('/logs', methods=['GET'])
def get_logs():
"""Get system logs from journalctl"""
+41 -2
View File
@@ -397,12 +397,51 @@ def _load_display_partial():
return "Error loading partial", 500
def _load_durations_partial():
"""Load display durations partial"""
"""Load rotation & durations partial.
Builds one duration entry per display mode of every enabled plugin
(falling back to the display controller's 30s default), overlaid with any
values saved in display.display_durations. Historically the template only
looped over saved keys, and nothing ever populated them, so the page
rendered empty.
"""
try:
if pages_v3.config_manager:
main_config = pages_v3.config_manager.load_config()
duration_groups = []
covered_keys = set()
if pages_v3.plugin_manager:
try:
pages_v3.plugin_manager.discover_plugins()
saved = (main_config.get('display', {}) or {}).get('display_durations', {}) or {}
infos = sorted(pages_v3.plugin_manager.get_all_plugin_info(),
key=lambda i: (i.get('name') or i.get('id') or '').lower())
for info in infos:
pid = info.get('id')
if not pid or not (main_config.get(pid, {}) or {}).get('enabled', False):
continue
modes = pages_v3.plugin_manager.get_plugin_display_modes(pid) or [pid]
covered_keys.update(modes)
duration_groups.append({
'plugin_id': pid,
'plugin_name': info.get('name') or pid,
'modes': [{'key': m, 'value': saved.get(m, 30)} for m in modes],
})
# Saved keys not owned by any enabled plugin (disabled or
# uninstalled plugins) stay visible rather than vanishing.
leftovers = [{'key': k, 'value': v} for k, v in saved.items()
if k not in covered_keys]
if leftovers:
duration_groups.append({
'plugin_id': '',
'plugin_name': 'Other saved entries',
'modes': leftovers,
})
except Exception:
logger.warning("durations: could not enumerate plugin modes", exc_info=True)
return render_template('v3/partials/durations.html',
main_config=main_config)
main_config=main_config,
duration_groups=duration_groups)
except Exception as e:
logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
+1
View File
@@ -7,6 +7,7 @@ flask>=3.1.3,<4.0.0
werkzeug>=3.1.6,<4.0.0
flask-wtf>=1.2.0 # CSRF protection (optional for local-only, but recommended)
flask-limiter>=3.5.0 # Rate limiting (prevent accidental abuse)
flask-compress>=1.14 # gzip/brotli response compression (big win for the large JS/HTML over WiFi)
# WebSocket support for plugins
# Note: Web interface uses Server-Sent Events (SSE) for real-time updates, not WebSockets
+192
View File
@@ -413,6 +413,9 @@ a, button, input, select, textarea {
/* Responsive breakpoints */
@media (min-width: 640px) {
.sm\:px-6 { padding-left: 1.5rem; padding-right: 1.5rem; }
.sm\:block { display: block; }
.sm\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.sm\:text-sm { font-size: 0.875rem; line-height: 1.25rem; }
}
@media (min-width: 768px) {
@@ -421,6 +424,8 @@ a, button, input, select, textarea {
.md\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.md\:flex { display: flex; }
.md\:hidden { display: none; }
.md\:block { display: block; }
.md\:w-auto { width: auto; }
}
@media (min-width: 1024px) {
@@ -431,9 +436,14 @@ a, button, input, select, textarea {
.lg\:px-8 { padding-left: 2rem; padding-right: 2rem; }
.lg\:gap-x-3 { column-gap: 0.75rem; }
.lg\:gap-x-6 { column-gap: 1.5rem; }
.lg\:block { display: block; }
.lg\:flex { display: flex; }
.lg\:w-64 { width: 16rem; }
}
@media (min-width: 1280px) {
.xl\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.xl\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.xl\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.xl\:grid-cols-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.xl\:grid-cols-6 { grid-template-columns: repeat(6, minmax(0, 1fr)); }
@@ -446,6 +456,9 @@ a, button, input, select, textarea {
}
@media (min-width: 1536px) {
.2xl\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.2xl\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.2xl\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.2xl\:grid-cols-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.2xl\:grid-cols-6 { grid-template-columns: repeat(6, minmax(0, 1fr)); }
.2xl\:grid-cols-7 { grid-template-columns: repeat(7, minmax(0, 1fr)); }
@@ -456,6 +469,129 @@ a, button, input, select, textarea {
.2xl\:space-x-8 > * + * { margin-left: 2rem; }
}
/* ===== Mobile navigation drawer =====
Below md the #site-nav wrapper becomes an off-canvas drawer; at md and up
none of these rules apply and the nav renders exactly as before. */
@media (max-width: 767.98px) {
.site-nav {
position: fixed;
top: 0;
left: 0;
bottom: 0;
z-index: 60;
width: min(85vw, 320px);
background-color: var(--color-surface);
border-right: 1px solid var(--color-border);
transform: translateX(-100%);
transition: transform 0.25s ease;
overflow-y: auto;
padding: 1rem;
-webkit-overflow-scrolling: touch;
}
.site-nav.open {
transform: translateX(0);
box-shadow: 0 0 24px rgba(0, 0, 0, 0.25);
}
/* Tabs become full-width rows with >=44px touch targets */
.site-nav .nav-tab {
display: flex;
width: 100%;
align-items: center;
gap: 0.5rem;
text-align: left;
padding: 0.75rem 1rem;
min-height: 44px;
}
.site-nav nav.-mb-px {
display: block;
}
.nav-backdrop {
position: fixed;
inset: 0;
z-index: 55;
background-color: rgba(0, 0, 0, 0.4);
}
/* Header widgets relocated into the drawer (see placeHeaderWidgets in
app.js). The originals carry `hidden`/breakpoint classes tuned for the
header, so re-enable them explicitly in the drawer context. */
#drawer-widgets #settings-search-wrap {
display: block !important;
margin-bottom: 1rem;
}
#drawer-widgets #settings-search-wrap input {
width: 100%;
}
#drawer-widgets #settings-search-results {
position: static;
width: 100%;
max-height: 50vh;
margin-top: 0.25rem;
}
#drawer-widgets #system-stats {
display: flex !important;
justify-content: space-between;
margin-bottom: 1rem;
}
/* Larger touch targets inside horizontally scrolling tables */
.overflow-x-auto table button {
min-width: 44px;
min-height: 44px;
}
.overflow-x-auto table input:not([type="checkbox"]),
.overflow-x-auto table select {
min-height: 40px;
}
.overflow-x-auto table input[type="checkbox"] {
width: 1.25rem;
height: 1.25rem;
}
}
@media (min-width: 768px) {
/* Hard guards: even if mobileNavOpen was left true when the viewport
crossed the breakpoint, the drawer/backdrop must render as plain
in-flow nav on desktop. */
.site-nav {
position: static;
transform: none;
width: auto;
padding: 0;
border-right: none;
box-shadow: none;
background-color: transparent;
overflow-y: visible;
}
.nav-backdrop {
display: none !important;
}
#drawer-widgets {
display: none;
}
}
/* Mobile modal sizing: every .modal-content dialog fits the viewport with
internal scrolling instead of overflowing it. */
@media (max-width: 640px) {
.modal-content {
width: 95vw !important;
max-width: 95vw !important;
max-height: 90vh;
overflow-y: auto;
}
}
/* Edge-fade hint that a container scrolls horizontally (pure CSS,
Lea Verou scrolling-shadows technique backgrounds sit behind content). */
.overflow-x-auto {
background:
linear-gradient(90deg, var(--color-surface) 30%, rgba(255, 255, 255, 0)) left / 24px 100%,
linear-gradient(270deg, var(--color-surface) 30%, rgba(255, 255, 255, 0)) right / 24px 100%,
radial-gradient(farthest-side at 0 50%, rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0)) left / 12px 100%,
radial-gradient(farthest-side at 100% 50%, rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0)) right / 12px 100%;
background-repeat: no-repeat;
background-attachment: local, local, scroll, scroll;
}
/* HTMX loading states */
.htmx-request .loading {
display: inline-block;
@@ -1198,6 +1334,21 @@ button.bg-white {
color: #93c5fd;
}
/* Installed-plugin "Update" button when a newer version is published.
A gentle pulsing ring draws the eye without being noisy. */
.plugin-update-available {
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.6);
animation: plugin-update-pulse 2s ease-out infinite;
}
@keyframes plugin-update-pulse {
0% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.55); }
70% { box-shadow: 0 0 0 6px rgba(59, 130, 246, 0); }
100% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0); }
}
@media (prefers-reduced-motion: reduce) {
.plugin-update-available { animation: none; }
}
/* Under-voltage / throttling warning banner */
.power-warning-banner {
background-color: #fef2f2;
@@ -1220,3 +1371,44 @@ button.bg-white {
[data-theme="dark"] .power-warning-banner-dismiss {
color: #fca5a5;
}
/* ===== Floating live preview (all tabs except Overview) ===== */
.floating-preview {
position: fixed;
right: 1rem;
bottom: 1rem;
z-index: 70;
background-color: #111827;
border: 1px solid #374151;
border-radius: 0.5rem;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
overflow: hidden;
/* Desktop: draggable resize handle (bottom-left visually, since the
panel is anchored to the right). Touch devices use the size button. */
resize: both;
min-width: 160px;
max-width: 90vw;
}
.floating-preview img {
background-color: #000;
}
.floating-preview-toggle {
position: fixed;
right: 1rem;
bottom: 1rem;
z-index: 70;
width: 44px;
height: 44px;
border-radius: 9999px;
background-color: var(--color-primary);
color: #ffffff;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
align-items: center;
justify-content: center;
}
@media (max-width: 640px) {
/* Whatever size is chosen, never wider than the phone viewport */
.floating-preview {
max-width: calc(100vw - 2rem);
}
}
+281 -9
View File
@@ -1,4 +1,4 @@
/* global showNotification, updateSystemStats, updateDisplayPreview, htmx */
/* global showNotification, updateSystemStats, updateDisplayPreview, htmx, debugLog */
// LED Matrix v3 JavaScript
// Additional helpers for HTMX and Alpine.js integration
@@ -12,8 +12,8 @@ window.showNotification = function(message, type = 'info') {
});
document.dispatchEvent(event);
} else {
// Fallback notification
console.log(`${type}: ${message}`);
// Fallback notification — user-facing last resort, so never gated
console.info(`${type}: ${message}`);
}
};
@@ -49,6 +49,111 @@ document.body.addEventListener('htmx:afterRequest', function(event) {
// Not JSON, ignore
}
}
// Main-config saves (display hardware, rotation/durations, general) only
// take effect after a display-service restart — surface the reminder
// banner. Plugin config saves apply live and are deliberately excluded.
try {
const cfg = event.detail.requestConfig;
if (cfg && cfg.verb === 'post' &&
(cfg.path || '').includes('/api/v3/config/main') &&
response && response.status >= 200 && response.status < 300) {
window.showRestartPending();
}
} catch { /* banner is best-effort */ }
});
// ===== Unsaved-changes guard =====
// Plugin config panels are Alpine x-if templates: navigating away DESTROYS
// the panel and revisiting re-fetches it, silently discarding any edits.
// (System tabs use x-show + data-loaded and persist, so they're exempt.)
// Track dirty forms and confirm before a lossy navigation.
(function() {
function markDirty(e) {
const form = e.target && e.target.closest ? e.target.closest('form') : null;
if (form) form.setAttribute('data-dirty', '');
}
document.body.addEventListener('input', markDirty);
document.body.addEventListener('change', markDirty);
// A successful submit makes the form clean again
document.body.addEventListener('htmx:afterRequest', function(event) {
const xhr = event.detail.xhr;
const form = event.detail.elt && event.detail.elt.closest ? event.detail.elt.closest('form') : null;
if (form && xhr && xhr.status >= 200 && xhr.status < 300) {
form.removeAttribute('data-dirty');
}
});
// Capture phase so this runs before Alpine's bubbling @click switches tabs
document.addEventListener('click', function(e) {
const tabBtn = e.target && e.target.closest ? e.target.closest('.nav-tab') : null;
if (!tabBtn) return;
const lossy = Array.prototype.filter.call(
document.querySelectorAll('.plugin-config-tab form[data-dirty]'),
function(f) { return f.offsetParent !== null; }
);
if (lossy.length === 0) return;
if (!window.confirm('You have unsaved plugin settings — leaving this page will discard them. Leave anyway?')) {
e.stopPropagation();
e.preventDefault();
}
}, true);
// Full page unload loses every panel's edits
window.addEventListener('beforeunload', function(e) {
const dirty = Array.prototype.some.call(
document.querySelectorAll('form[data-dirty]'),
function(f) { return f.offsetParent !== null; }
);
if (dirty) {
e.preventDefault();
e.returnValue = '';
}
});
})();
// ===== Restart-pending banner =====
// Shown after restart-requiring saves; persists across tab switches (and
// reloads, via sessionStorage) until the display restarts or it's dismissed.
window.showRestartPending = function() {
try { sessionStorage.setItem('ledmatrix-restart-pending', '1'); } catch { /* private browsing */ }
const banner = document.getElementById('restart-pending-banner');
if (banner) banner.style.display = 'block';
};
window.dismissRestartPending = function() {
try { sessionStorage.removeItem('ledmatrix-restart-pending'); } catch { /* no-op */ }
const banner = document.getElementById('restart-pending-banner');
if (banner) banner.style.display = 'none';
};
window.restartPendingNow = function() {
const btn = document.getElementById('restart-pending-btn');
if (btn) btn.disabled = true;
fetch('/api/v3/system/action', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'restart_display_service' })
})
.then(r => r.json())
.then(data => {
showNotification(data.message || 'Display restarting…', data.status || 'success');
window.dismissRestartPending();
})
.catch(err => {
showNotification('Error restarting display: ' + err.message, 'error');
})
.finally(() => { if (btn) btn.disabled = false; });
};
document.addEventListener('DOMContentLoaded', function() {
try {
if (sessionStorage.getItem('ledmatrix-restart-pending') === '1') {
const banner = document.getElementById('restart-pending-banner');
if (banner) banner.style.display = 'block';
}
} catch { /* no-op */ }
});
// SSE reconnection helper — closes and reopens both SSE streams,
@@ -246,13 +351,13 @@ window.performanceMonitor = {
logMetrics: function() {
const metrics = this.getMetrics();
console.group('Performance Metrics');
console.log('DOM Content Loaded:', metrics.domContentLoaded?.toFixed(2) || 'N/A', 'ms');
console.log('Load Complete:', metrics.loadComplete?.toFixed(2) || 'N/A', 'ms');
console.log('First Paint:', metrics.firstPaint?.toFixed(2) || 'N/A', 'ms');
console.log('First Contentful Paint:', metrics.firstContentfulPaint?.toFixed(2) || 'N/A', 'ms');
console.log('Resources:', metrics.resourceCount || 0, 'files,', (metrics.totalResourceSize / 1024).toFixed(2) || '0', 'KB');
debugLog('DOM Content Loaded:', metrics.domContentLoaded?.toFixed(2) || 'N/A', 'ms');
debugLog('Load Complete:', metrics.loadComplete?.toFixed(2) || 'N/A', 'ms');
debugLog('First Paint:', metrics.firstPaint?.toFixed(2) || 'N/A', 'ms');
debugLog('First Contentful Paint:', metrics.firstContentfulPaint?.toFixed(2) || 'N/A', 'ms');
debugLog('Resources:', metrics.resourceCount || 0, 'files,', (metrics.totalResourceSize / 1024).toFixed(2) || '0', 'KB');
if (Object.keys(metrics.measures || {}).length > 0) {
console.log('Custom Measures:', metrics.measures);
debugLog('Custom Measures:', metrics.measures);
}
console.groupEnd();
}
@@ -273,3 +378,170 @@ document.addEventListener('DOMContentLoaded', function() {
}, 100);
});
});
// ===== Floating live preview =====
// A mini preview of the display, available on every tab except Overview
// (which has the full-size one). Open/closed state persists per browser;
// frames arrive via the existing SSE stream (updateDisplayPreview in
// app-shell.js feeds #floating-preview-img).
window.toggleFloatingPreview = function(open) {
try { localStorage.setItem('ledmatrix-floating-preview', open ? '1' : '0'); } catch { /* no-op */ }
window.updateFloatingPreviewVisibility();
};
// Preset widths the size button cycles through (px). Desktop users can also
// drag the panel's native resize handle (CSS resize: both).
const FLOATING_PREVIEW_SIZES = [192, 256, 384, 512];
window.applyFloatingPreviewSize = function() {
const panel = document.getElementById('floating-preview');
if (!panel) return;
let size = 256;
try { size = parseInt(localStorage.getItem('ledmatrix-floating-preview-size'), 10) || 256; } catch { /* no-op */ }
panel.style.width = size + 'px';
// Clear any manual drag-resize height so the image's aspect ratio rules
panel.style.height = '';
};
window.cycleFloatingPreviewSize = function() {
let size = 256;
try { size = parseInt(localStorage.getItem('ledmatrix-floating-preview-size'), 10) || 256; } catch { /* no-op */ }
const idx = FLOATING_PREVIEW_SIZES.indexOf(size);
const next = FLOATING_PREVIEW_SIZES[(idx + 1) % FLOATING_PREVIEW_SIZES.length];
try { localStorage.setItem('ledmatrix-floating-preview-size', String(next)); } catch { /* no-op */ }
window.applyFloatingPreviewSize();
};
window.updateFloatingPreviewVisibility = function(tab) {
const panel = document.getElementById('floating-preview');
const toggle = document.getElementById('floating-preview-toggle');
if (!panel || !toggle) return;
let active = tab;
if (!active) {
const el = document.querySelector('[x-data="app()"]') || document.querySelector('[x-data]');
const data = el && el._x_dataStack && el._x_dataStack[0];
active = data && data.activeTab;
}
const onOverview = active === 'overview';
let open = false;
try { open = localStorage.getItem('ledmatrix-floating-preview') === '1'; } catch { /* no-op */ }
const showPanel = !onOverview && open;
panel.style.display = showPanel ? 'block' : 'none';
toggle.style.display = (!onOverview && !open) ? 'flex' : 'none';
if (showPanel) {
window.applyFloatingPreviewSize();
// Show the last cached frame immediately — SSE only pushes on
// display changes, so a freshly opened panel would otherwise stay
// empty until the next change.
const img = document.getElementById('floating-preview-img');
if (img && !img.src && window._lastPreviewFrame) {
img.src = 'data:image/png;base64,' + window._lastPreviewFrame;
}
}
};
document.addEventListener('DOMContentLoaded', function() {
window.updateFloatingPreviewVisibility();
});
// Run a plugin on the real display for 60s via the existing on-demand API
// and open the floating preview so the effect is visible while configuring.
window.previewPluginNow = function(pluginId) {
fetch('/api/v3/display/on-demand/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plugin_id: pluginId, duration: 60 })
})
.then(r => r.json())
.then(data => {
showNotification(data.message || ('Previewing ' + pluginId + ' for 60 seconds'),
data.status || 'success');
if (data.status === 'success') window.toggleFloatingPreview(true);
})
.catch(err => {
showNotification('Preview failed: ' + err.message, 'error');
});
};
// ===== Nav accessibility =====
// aria-current tracks the active tab. Buttons are matched by their Alpine
// @click expression ("activeTab = '<tab>'"), which works for both the static
// system tabs and the dynamically injected plugin tabs.
window.updateNavAriaCurrent = function(tab) {
document.querySelectorAll('.nav-tab').forEach(function(btn) {
const expr = btn.getAttribute('@click') || btn.getAttribute('x-on:click') || '';
const isCurrent = expr.indexOf("activeTab = '" + tab + "'") !== -1;
if (isCurrent) {
btn.setAttribute('aria-current', 'page');
} else {
btn.removeAttribute('aria-current');
}
});
};
// Escape closes the mobile nav drawer and returns focus to the hamburger;
// opening the drawer moves focus to its first tab.
(function() {
function appData() {
const el = document.querySelector('[x-data="app()"]') || document.querySelector('[x-data]');
return el && el._x_dataStack && el._x_dataStack[0];
}
document.addEventListener('keydown', function(e) {
if (e.key !== 'Escape') return;
const data = appData();
if (data && data.mobileNavOpen) {
data.mobileNavOpen = false;
const burger = document.querySelector('[aria-controls="site-nav"]');
if (burger) burger.focus();
}
});
document.addEventListener('click', function(e) {
const burger = e.target && e.target.closest
? e.target.closest('[aria-controls="site-nav"]') : null;
if (!burger) return;
// The click handler toggles mobileNavOpen; focus the first tab once
// the drawer has slid in (matches the CSS transition timing).
setTimeout(function() {
const data = appData();
if (data && data.mobileNavOpen) {
const first = document.querySelector('#site-nav .nav-tab');
if (first) first.focus();
}
}, 120);
});
})();
// ===== Mobile nav: header-widget relocation =====
// Below the md breakpoint the settings-search box and system-stats block are
// MOVED (same DOM nodes, listeners intact) from the header into the nav
// drawer's #drawer-widgets slot; at md and up they move back. Single-instance
// constraint: settings-search.js and the SSE stats updater both look these
// elements up by id, so they must never be duplicated.
window.placeHeaderWidgets = function() {
const drawer = document.getElementById('drawer-widgets');
const header = document.getElementById('header-widgets');
const search = document.getElementById('settings-search-wrap');
const stats = document.getElementById('system-stats');
if (!drawer || !header) return;
const desktop = window.matchMedia('(min-width: 768px)').matches;
if (desktop) {
// Restore original header order: search before the theme toggle,
// stats as the last item.
const themeToggle = document.getElementById('theme-toggle');
if (search && search.parentElement !== header) {
header.insertBefore(search, themeToggle || null);
}
if (stats && stats.parentElement !== header) {
header.appendChild(stats);
}
} else {
if (search && search.parentElement !== drawer) drawer.appendChild(search);
if (stats && stats.parentElement !== drawer) drawer.appendChild(stats);
}
};
document.addEventListener('DOMContentLoaded', function() {
window.placeHeaderWidgets();
window.matchMedia('(min-width: 768px)').addEventListener('change', window.placeHeaderWidgets);
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

+356
View File
@@ -0,0 +1,356 @@
/* global debugLog */
// Early helpers and the app() stub (must run before Alpine init)
// Extracted from templates/v3/base.html so browsers cache it as a static asset.
// Helper function to get installed plugins with fallback
// Must be defined before app() function that uses it
async function getInstalledPluginsSafe() {
if (window.PluginAPI && window.PluginAPI.getInstalledPlugins) {
try {
const plugins = await window.PluginAPI.getInstalledPlugins();
// Ensure plugins is always an array
const pluginsArray = Array.isArray(plugins) ? plugins : [];
return { status: 'success', data: { plugins: pluginsArray } };
} catch (error) {
console.error('Error using PluginAPI.getInstalledPlugins, falling back to direct fetch:', error);
// Fall through to direct fetch
}
}
// Fallback to direct fetch if PluginAPI not loaded
const response = await fetch('/api/v3/plugins/installed');
return await response.json();
}
// Global event listener for pluginsUpdated - works even if Alpine isn't ready yet
// This ensures tabs update when plugins_manager.js loads plugins
document.addEventListener('pluginsUpdated', function(event) {
debugLog('[GLOBAL] Received pluginsUpdated event:', event.detail?.plugins?.length || 0, 'plugins');
const plugins = event.detail?.plugins || [];
// Update window.installedPlugins
window.installedPlugins = plugins;
// Try to update Alpine component if it exists (only if using full implementation)
if (window.Alpine) {
const appElement = document.querySelector('[x-data="app()"]');
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
const appComponent = appElement._x_dataStack[0];
appComponent.installedPlugins = plugins;
// Only call updatePluginTabs if it's the full implementation (has _doUpdatePluginTabs)
if (typeof appComponent.updatePluginTabs === 'function' &&
appComponent.updatePluginTabs.toString().includes('_doUpdatePluginTabs')) {
debugLog('[GLOBAL] Updating plugin tabs via Alpine component (full implementation)');
appComponent.updatePluginTabs();
return; // Full implementation handles it, don't do direct update
}
}
}
// Only do direct DOM update if full implementation isn't available yet
const pluginTabsRow = document.getElementById('plugin-tabs-row');
const pluginTabsNav = pluginTabsRow?.querySelector('nav');
if (pluginTabsRow && pluginTabsNav && plugins.length > 0) {
// Clear existing plugin tabs (except Plugin Manager)
const existingTabs = pluginTabsNav.querySelectorAll('.plugin-tab');
existingTabs.forEach(tab => { tab.remove(); });
// Add tabs for each installed plugin
plugins.forEach(plugin => {
const tabButton = document.createElement('button');
tabButton.type = 'button';
tabButton.setAttribute('data-plugin-id', plugin.id);
tabButton.className = `plugin-tab nav-tab`;
tabButton.onclick = function() {
// Try to set activeTab via Alpine if available
if (window.Alpine) {
const appElement = document.querySelector('[x-data="app()"]');
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
appElement._x_dataStack[0].activeTab = plugin.id;
// Only call updatePluginTabStates if it exists
if (typeof appElement._x_dataStack[0].updatePluginTabStates === 'function') {
appElement._x_dataStack[0].updatePluginTabStates();
}
}
}
};
// Built with DOM APIs (no innerHTML): the icon class and
// name come from plugin manifests, which are only
// semi-trusted input.
const tabIcon = document.createElement('i');
tabIcon.className = plugin.icon || 'fas fa-puzzle-piece';
tabButton.textContent = '';
tabButton.appendChild(tabIcon);
tabButton.appendChild(document.createTextNode(plugin.name || plugin.id));
pluginTabsNav.appendChild(tabButton);
});
debugLog('[GLOBAL] Updated plugin tabs directly:', plugins.length, 'tabs added');
}
});
// Guard flag to prevent duplicate stub-to-full enhancement
window._appEnhanced = false;
// Define app() function early so Alpine can find it when it initializes
// This is a complete implementation that will work immediately
(function() {
const isAPMode = window.location.hostname === '192.168.4.1' ||
window.location.hostname.startsWith('192.168.4.');
// Create the app function - will be enhanced by full implementation later
window.app = function() {
return {
activeTab: isAPMode ? 'wifi' : 'overview',
mobileNavOpen: false,
installedPlugins: [],
init() {
// Try to enhance immediately with full implementation
const tryEnhance = () => {
if (window._appEnhanced) return true;
if (typeof window.app === 'function') {
const fullApp = window.app();
// Check if this is the full implementation (has updatePluginTabs with proper implementation)
if (fullApp && typeof fullApp.updatePluginTabs === 'function' && fullApp.updatePluginTabs.toString().includes('_doUpdatePluginTabs')) {
window._appEnhanced = true;
// Preserve runtime state that should not be reset
const preservedPlugins = this.installedPlugins;
const preservedTab = this.activeTab;
const defaultTab = isAPMode ? 'wifi' : 'overview';
const wasInitialized = this._initialized;
Object.assign(this, fullApp);
// Restore runtime state if non-default
if (preservedPlugins && preservedPlugins.length > 0) {
this.installedPlugins = preservedPlugins;
}
if (preservedTab && preservedTab !== defaultTab) {
this.activeTab = preservedTab;
}
if (wasInitialized) {
this._initialized = wasInitialized;
}
// Only call init if not already initialized
if (typeof this.init === 'function' && !this._initialized) {
this.init();
}
return true;
}
}
return false;
};
// Set up event listener for pluginsUpdated in stub (only if not already enhanced)
// The full implementation will have its own listener, so we only need this for the stub
if (!this._pluginsUpdatedListenerSet) {
const handlePluginsUpdated = (event) => {
debugLog('[STUB] Received pluginsUpdated event:', event.detail?.plugins?.length || 0, 'plugins');
const plugins = event.detail?.plugins || [];
// Only update if we're still in stub mode (not enhanced yet)
if (typeof this.updatePluginTabs === 'function' && !this.updatePluginTabs.toString().includes('_doUpdatePluginTabs')) {
this.installedPlugins = plugins;
if (this.$nextTick && typeof this.$nextTick === 'function') {
this.$nextTick(() => {
this.updatePluginTabs();
});
} else {
setTimeout(() => {
this.updatePluginTabs();
}, 100);
}
}
};
document.addEventListener('pluginsUpdated', handlePluginsUpdated);
this._pluginsUpdatedListenerSet = true;
debugLog('[STUB] init: Set up pluginsUpdated event listener');
}
// Try immediately - if full implementation is already loaded, use it right away
if (!tryEnhance()) {
// Full implementation not ready yet, load plugins directly while waiting
this.loadInstalledPluginsDirectly();
// Try again very soon to enhance with full implementation
setTimeout(tryEnhance, 10);
// Also set up a periodic check to update tabs if plugins get loaded by plugins_manager.js
let retryCount = 0;
const maxRetries = 20; // Check for 2 seconds (20 * 100ms)
const checkAndUpdateTabs = () => {
if (retryCount >= maxRetries) {
// Fallback: if plugins_manager.js hasn't loaded after 2 seconds, fetch directly
if (!window.installedPlugins || window.installedPlugins.length === 0) {
debugLog('[STUB] checkAndUpdateTabs: Fallback - fetching plugins directly after timeout');
this.loadInstalledPluginsDirectly();
}
return;
}
// Check if plugins are available (either from window or component)
const plugins = window.installedPlugins || this.installedPlugins || [];
if (plugins.length > 0) {
debugLog('[STUB] checkAndUpdateTabs: Found', plugins.length, 'plugins, updating tabs');
this.installedPlugins = plugins;
if (typeof this.updatePluginTabs === 'function') {
this.updatePluginTabs();
}
} else {
retryCount++;
setTimeout(checkAndUpdateTabs, 100);
}
};
// Start checking after a short delay
setTimeout(checkAndUpdateTabs, 200);
} else {
// Full implementation loaded, but still set up fallback timer
setTimeout(() => {
if (!window.installedPlugins || window.installedPlugins.length === 0) {
debugLog('[STUB] init: Fallback timer - fetching plugins directly');
this.loadInstalledPluginsDirectly();
}
}, 2000);
}
},
// Direct plugin loading for stub (before full implementation loads)
async loadInstalledPluginsDirectly() {
try {
debugLog('[STUB] loadInstalledPluginsDirectly: Starting...');
// Ensure DOM is ready
const ensureDOMReady = () => {
return new Promise((resolve) => {
if (document.readyState === 'complete' || document.readyState === 'interactive') {
// Use requestAnimationFrame to ensure DOM is painted
requestAnimationFrame(() => {
setTimeout(resolve, 50); // Small delay to ensure rendering
});
} else {
document.addEventListener('DOMContentLoaded', () => {
requestAnimationFrame(() => {
setTimeout(resolve, 50);
});
});
}
});
};
await ensureDOMReady();
const data = await getInstalledPluginsSafe();
if (data.status === 'success') {
const plugins = data.data.plugins || [];
debugLog('[STUB] loadInstalledPluginsDirectly: Loaded', plugins.length, 'plugins');
// Update both component and window
this.installedPlugins = plugins;
window.installedPlugins = plugins;
// Dispatch event so global listener can update tabs
document.dispatchEvent(new CustomEvent('pluginsUpdated', {
detail: { plugins: plugins }
}));
debugLog('[STUB] loadInstalledPluginsDirectly: Dispatched pluginsUpdated event');
// Update tabs if we have the method - use $nextTick if available
if (typeof this.updatePluginTabs === 'function') {
if (this.$nextTick && typeof this.$nextTick === 'function') {
this.$nextTick(() => {
this.updatePluginTabs();
});
} else {
// Fallback: wait a bit for DOM
setTimeout(() => {
this.updatePluginTabs();
}, 100);
}
}
} else {
console.warn('[STUB] loadInstalledPluginsDirectly: Failed to load plugins:', data.message);
}
} catch (error) {
console.error('[STUB] loadInstalledPluginsDirectly: Error loading plugins:', error);
}
},
// Stub methods that will be replaced by full implementation
loadTabContent: function(tab) {},
loadInstalledPlugins: async function() {
// Try to use global function if available, otherwise use direct loading
if (typeof window.loadInstalledPlugins === 'function') {
await window.loadInstalledPlugins();
// Update tabs after loading (window.installedPlugins should be set by the global function)
if (window.installedPlugins && Array.isArray(window.installedPlugins)) {
this.installedPlugins = window.installedPlugins;
this.updatePluginTabs();
}
} else if (typeof window.pluginManager?.loadInstalledPlugins === 'function') {
await window.pluginManager.loadInstalledPlugins();
// Update tabs after loading
if (window.installedPlugins && Array.isArray(window.installedPlugins)) {
this.installedPlugins = window.installedPlugins;
this.updatePluginTabs();
}
} else {
// Fallback to direct loading (which already calls updatePluginTabs)
await this.loadInstalledPluginsDirectly();
}
},
updatePluginTabs: function() {
// Basic implementation for stub - will be replaced by full implementation
// Debounce to prevent multiple rapid calls
if (this._updatePluginTabsTimeout) {
clearTimeout(this._updatePluginTabsTimeout);
}
this._updatePluginTabsTimeout = setTimeout(() => {
debugLog('[STUB] updatePluginTabs: Executing with', this.installedPlugins?.length || 0, 'plugins');
const pluginTabsRow = document.getElementById('plugin-tabs-row');
const pluginTabsNav = pluginTabsRow?.querySelector('nav');
if (!pluginTabsRow || !pluginTabsNav) {
console.warn('[STUB] updatePluginTabs: Plugin tabs container not found');
return;
}
if (!this.installedPlugins || this.installedPlugins.length === 0) {
debugLog('[STUB] updatePluginTabs: No plugins to display');
return;
}
// Check if tabs are already correct by comparing plugin IDs
const existingTabs = pluginTabsNav.querySelectorAll('.plugin-tab');
const existingIds = Array.from(existingTabs).map(tab => tab.getAttribute('data-plugin-id')).sort().join(',');
const currentIds = this.installedPlugins.map(p => p.id).sort().join(',');
if (existingIds === currentIds && existingTabs.length === this.installedPlugins.length) {
debugLog('[STUB] updatePluginTabs: Tabs already match, skipping update');
return;
}
// Clear existing plugin tabs (except Plugin Manager)
existingTabs.forEach(tab => { tab.remove(); });
debugLog('[STUB] updatePluginTabs: Cleared', existingTabs.length, 'existing tabs');
// Add tabs for each installed plugin
this.installedPlugins.forEach(plugin => {
const tabButton = document.createElement('button');
tabButton.type = 'button';
tabButton.setAttribute('data-plugin-id', plugin.id);
tabButton.className = `plugin-tab nav-tab ${this.activeTab === plugin.id ? 'nav-tab-active' : ''}`;
tabButton.onclick = () => {
this.activeTab = plugin.id;
if (typeof this.updatePluginTabStates === 'function') {
this.updatePluginTabStates();
}
};
// DOM APIs instead of innerHTML: manifest
// icon/name are semi-trusted input.
const tabIcon = document.createElement('i');
tabIcon.className = plugin.icon || 'fas fa-puzzle-piece';
tabButton.textContent = '';
tabButton.appendChild(tabIcon);
tabButton.appendChild(document.createTextNode(plugin.name || plugin.id));
pluginTabsNav.appendChild(tabButton);
});
debugLog('[STUB] updatePluginTabs: Added', this.installedPlugins.length, 'plugin tabs');
}, 100);
},
showNotification: function(message, type) {},
escapeHtml: function(text) { return String(text || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
};
};
})();
File diff suppressed because it is too large Load Diff
+256
View File
@@ -0,0 +1,256 @@
/* global debugLog */
// HTMX swap/script-execution configuration and section toggle helpers
// Extracted from templates/v3/base.html so browsers cache it as a static asset.
// Configure HTMX to evaluate scripts in swapped content and fix insertBefore errors
(function() {
function setupScriptExecution() {
if (document.body) {
// Fix HTMX insertBefore errors by validating targets before swap
document.body.addEventListener('htmx:beforeSwap', function(event) {
try {
const target = event.detail.target;
if (!target) {
console.warn('[HTMX] Target is null, skipping swap');
event.detail.shouldSwap = false;
return false;
}
// Check if target is a valid DOM element
if (!(target instanceof Element)) {
console.warn('[HTMX] Target is not a valid Element, skipping swap');
event.detail.shouldSwap = false;
return false;
}
// Check if target has a parent node (required for insertBefore)
if (!target.parentNode) {
console.warn('[HTMX] Target has no parent node, skipping swap');
event.detail.shouldSwap = false;
return false;
}
// Ensure target is in the DOM
if (!document.body.contains(target) && !document.head.contains(target)) {
console.warn('[HTMX] Target is not in DOM, skipping swap');
event.detail.shouldSwap = false;
return false;
}
// Additional check: ensure parent is also in DOM
if (target.parentNode && !document.body.contains(target.parentNode) && !document.head.contains(target.parentNode)) {
console.warn('[HTMX] Target parent is not in DOM, skipping swap');
event.detail.shouldSwap = false;
return false;
}
// All checks passed, allow swap
return true;
} catch (e) {
// If validation fails, cancel swap
console.warn('[HTMX] Error validating target:', e);
event.detail.shouldSwap = false;
return false;
}
});
// Suppress HTMX insertBefore errors and other noisy errors - they're harmless but noisy
const originalError = console.error;
const originalWarn = console.warn;
console.error = function(...args) {
const errorStr = args.join(' ');
const errorStack = args.find(arg => arg && typeof arg === 'string' && arg.includes('htmx')) || '';
// Suppress HTMX insertBefore errors (comprehensive check)
// These occur when HTMX tries to swap content but the target element is null
// Usually happens due to timing/race conditions and is harmless
if (errorStr.includes("insertBefore") ||
errorStr.includes("Cannot read properties of null") ||
errorStr.includes("reading 'insertBefore'")) {
// Check if it's from HTMX by looking at stack trace or error string
// Also check the call stack if available
const isHtmxError = errorStr.includes('htmx') ||
errorStack.includes('htmx') ||
args.some(arg => {
if (typeof arg === 'string') {
return arg.includes('htmx');
}
// Check error objects for stack traces
if (arg && typeof arg === 'object' && arg.stack) {
return arg.stack.includes('htmx');
}
return false;
});
if (isHtmxError) {
return; // Suppress - this is a harmless HTMX timing/race condition issue
}
}
// Suppress script execution errors from malformed HTML
if (errorStr.includes("Failed to execute 'appendChild' on 'Node'") ||
errorStr.includes("Failed to execute 'insertBefore' on 'Node'")) {
if (errorStr.includes('Unexpected token')) {
return; // Suppress malformed HTML errors
}
}
originalError.apply(console, args);
};
console.warn = function(...args) {
const warnStr = args.join(' ');
// Suppress Permissions-Policy warnings (harmless browser warnings)
if (warnStr.includes('Permissions-Policy header') ||
warnStr.includes('Unrecognized feature') ||
warnStr.includes('Origin trial controlled feature') ||
warnStr.includes('browsing-topics') ||
warnStr.includes('run-ad-auction') ||
warnStr.includes('join-ad-interest-group') ||
warnStr.includes('private-state-token') ||
warnStr.includes('private-aggregation') ||
warnStr.includes('attribution-reporting')) {
return; // Suppress - these are harmless browser feature warnings
}
originalWarn.apply(console, args);
};
// Handle HTMX errors gracefully with detailed logging
document.body.addEventListener('htmx:responseError', function(event) {
const detail = event.detail;
const xhr = detail.xhr;
const target = detail.target;
// Enhanced error logging
console.error('HTMX response error:', {
status: xhr?.status,
statusText: xhr?.statusText,
url: xhr?.responseURL,
target: target?.id || target?.tagName,
responseText: xhr?.responseText
});
// For form submissions, log field names only — values
// may contain API keys, passwords, or other secrets
// that must never reach the console.
if (target && target.tagName === 'FORM') {
const formData = new FormData(target);
const fieldNames = [];
for (const [key] of formData.entries()) {
fieldNames.push(key);
}
console.error('Form fields (values redacted):', fieldNames);
// Try to parse error response for validation details
if (xhr?.responseText) {
try {
const errorData = JSON.parse(xhr.responseText);
console.error('Error details:', {
message: errorData.message,
details: errorData.details,
validation_errors: errorData.validation_errors,
context: errorData.context
});
} catch {
console.error('Error response (non-JSON):', xhr.responseText.substring(0, 500));
}
}
}
});
document.body.addEventListener('htmx:swapError', function(event) {
// Log but don't break the app
console.warn('HTMX swap error:', event.detail);
});
// Execute <script> tags in swapped content ourselves, on
// htmx:afterSwap (synchronous, right after the swap) rather
// than relying on htmx's own script handling, which runs
// during its later "settle" phase (~20ms after swap, per
// htmx's defaultSettleDelay). Alpine's MutationObserver
// processes newly-inserted x-data elements synchronously
// as soon as the swap lands, which is BEFORE htmx's settle
// phase - so any partial whose x-data component function
// (e.g. wifiSetup()) is defined by an inline <script> in
// that same partial would have that script still un-run
// when Alpine evaluates x-data, permanently failing with
// "wifiSetup is not defined" (Alpine does not retry).
// Disable htmx's own native script re-execution so the
// same script doesn't also run a second time via settle.
if (typeof htmx !== 'undefined' && htmx.config) {
htmx.config.allowScriptTags = false;
}
document.body.addEventListener('htmx:afterSwap', function(event) {
const target = event.detail && event.detail.target;
if (!target || !(target instanceof Element)) return;
target.querySelectorAll('script').forEach(function(oldScript) {
const newScript = document.createElement('script');
for (const attr of oldScript.attributes) {
newScript.setAttribute(attr.name, attr.value);
}
newScript.textContent = oldScript.textContent;
oldScript.replaceWith(newScript);
});
});
// 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) {
const target = event.detail.target;
const trigger = target.getAttribute('hx-trigger') || '';
if (trigger.includes('loadtab')) {
target.setAttribute('data-loaded', 'true');
}
}
});
} else {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', setupScriptExecution);
} else {
setTimeout(setupScriptExecution, 100);
}
}
}
setupScriptExecution();
// Section toggle function - define early so it's available for HTMX-loaded content
window.toggleSection = function(sectionId) {
const section = document.getElementById(sectionId);
const icon = document.getElementById(sectionId + '-icon');
if (!section) {
console.warn('toggleSection: Could not find section for', sectionId);
return;
}
if (!icon) {
console.warn('toggleSection: Could not find icon for', sectionId);
return;
}
// Check if currently hidden by checking both class and computed display
const hasHiddenClass = section.classList.contains('hidden');
const computedDisplay = window.getComputedStyle(section).display;
const isHidden = hasHiddenClass || computedDisplay === 'none';
if (isHidden) {
// Show the section - remove hidden class and explicitly set display to block
section.classList.remove('hidden');
section.style.display = 'block';
icon.classList.remove('fa-chevron-right');
icon.classList.add('fa-chevron-down');
} else {
// Hide the section - add hidden class and set display to none
section.classList.add('hidden');
section.style.display = 'none';
icon.classList.remove('fa-chevron-down');
icon.classList.add('fa-chevron-right');
}
// Keep assistive tech in sync: any toggle button that declares
// aria-controls for this section mirrors the expanded state.
const controlBtn = document.querySelector(`[aria-controls="${sectionId}"]`);
if (controlBtn) {
controlBtn.setAttribute('aria-expanded', String(isHidden));
}
};
})();
@@ -173,7 +173,14 @@
function setActiveTab(tab) {
var data = getAppData();
if (data) { data.activeTab = tab; return true; }
if (data) {
data.activeTab = tab;
// Navigating from a search result should also dismiss the mobile
// nav drawer (harmless no-op on desktop, where the drawer CSS
// doesn't apply).
if ('mobileNavOpen' in data) data.mobileNavOpen = false;
return true;
}
return false;
}
@@ -209,6 +209,7 @@
const removeButton = document.createElement('button');
removeButton.type = 'button';
removeButton.className = 'text-red-600 hover:text-red-800 px-2 py-1';
removeButton.setAttribute('aria-label', 'Remove feed');
removeButton.addEventListener('click', function() {
window.removeCustomFeedRow(this);
});
@@ -333,6 +334,7 @@
const removeButton = document.createElement('button');
removeButton.type = 'button';
removeButton.className = 'text-red-600 hover:text-red-800 px-2 py-1';
removeButton.setAttribute('aria-label', 'Remove feed');
removeButton.addEventListener('click', function() {
window.removeCustomFeedRow(this);
});
@@ -404,9 +406,18 @@
if (!file) return;
const formData = new FormData();
formData.append('file', file);
// Backend contract (see api_v3.upload_plugin_asset): the request
// field must be named "files" (it does request.files.getlist('files')
// and 400s with "No files provided" otherwise), and the response
// carries the result in a top-level "uploaded_files" key, not nested
// under "data". file-upload-single.js's working upload flow uses this
// same contract.
// Backend contract (api_v3.upload_plugin_asset): field must be named
// "files" (request.files.getlist('files')), and the response carries
// results in a top-level "uploaded_files" key, not nested under "data".
formData.append('files', file);
formData.append('plugin_id', pluginId);
fetch('/api/v3/plugins/assets/upload', {
method: 'POST',
body: formData
@@ -421,8 +432,8 @@
return response.json();
})
.then(data => {
if (data.status === 'success' && data.data && data.data.files && data.data.files.length > 0) {
const uploadedFile = data.data.files[0];
if (data.status === 'success' && data.uploaded_files && data.uploaded_files.length > 0) {
const uploadedFile = data.uploaded_files[0];
const row = document.querySelector(`#${fieldId}_tbody tr[data-index="${index}"]`);
if (row) {
const logoCell = row.querySelector('td:nth-child(3)');
@@ -495,8 +506,6 @@
// Append container to logoCell
logoCell.appendChild(container);
}
// Allow re-uploading the same file
event.target.value = '';
} else {
const notifyFn = window.showNotification || alert;
notifyFn('Upload failed: ' + (data.message || 'Unknown error'), 'error');
@@ -506,6 +515,12 @@
console.error('Upload error:', error);
const notifyFn = window.showNotification || alert;
notifyFn('Upload failed: ' + error.message, 'error');
})
.finally(() => {
// Reset regardless of outcome, so the same file can be re-selected
// to retry after a failure (browsers won't fire "change" again
// for an input that still holds that exact file).
event.target.value = '';
});
};
@@ -58,6 +58,10 @@
// Track active notifications
let activeNotifications = [];
// onAction callbacks for notifications with an inline action button,
// keyed by notification id (cleaned up on dismiss). A Map rather than a
// plain object so ids can never collide with prototype properties.
const actionCallbacks = new Map();
let notificationCounter = 0;
/**
@@ -113,6 +117,7 @@
// Remove from tracking array
activeNotifications = activeNotifications.filter(id => id !== notificationId);
actionCallbacks.delete(notificationId);
}
/**
@@ -158,6 +163,20 @@
html += `<span class="flex-1 text-sm">${escapeHtml(message)}</span>`;
// Optional inline action button (e.g. "Restart Now" on a restart nudge).
// The callback is stored by id and invoked via triggerAction, which
// also dismisses the notification.
if (options.actionLabel && typeof options.onAction === 'function') {
actionCallbacks.set(notificationId, options.onAction);
html += `
<button type="button"
onclick="window.LEDMatrixWidgets.get('notification').triggerAction('${notificationId}')"
class="flex-shrink-0 ml-2 px-3 py-1 text-xs font-semibold rounded-md bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors duration-150">
${escapeHtml(options.actionLabel)}
</button>
`;
}
if (dismissible) {
html += `
<button type="button"
@@ -227,6 +246,17 @@
removeNotification(notificationId);
},
/**
* Invoke a notification's onAction callback (see options.actionLabel /
* options.onAction on show) and dismiss it.
* @param {string} notificationId - Notification ID whose action to run
*/
triggerAction: function(notificationId) {
const cb = actionCallbacks.get(notificationId);
removeNotification(notificationId);
if (typeof cb === 'function') cb();
},
/**
* Clear all notifications
*/
@@ -262,9 +292,11 @@
}
});
// Global shorthand function (backwards compatible with existing code)
// Global shorthand function (backwards compatible with existing code).
// Accepts either the legacy type string or a full options object
// ({ type, duration, actionLabel, onAction, ... }).
window.showNotification = function(message, type = 'info') {
return showNotification(message, { type: type });
return showNotification(message, typeof type === 'string' ? { type: type } : (type || {}));
};
// Initialize container on load
@@ -0,0 +1,225 @@
/**
* Plugin Order List shared drag-and-drop reorder list of enabled plugins.
*
* Factored out of the Vegas Scroll section of display.html so both Vegas mode
* and the primary rotation (Durations tab) use one implementation. Renders
* one draggable row per enabled plugin into a container and keeps a hidden
* input's value in sync as a JSON array of plugin ids in display order.
*
* Usage:
* PluginOrderList.init({
* containerId: 'vegas_plugin_order', // rows render here
* orderInputId: 'vegas_plugin_order_value', // hidden input, JSON array of ids
* excludedInputId: 'vegas_excluded_plugins_value', // optional: adds an
* // include-checkbox per row; unchecked ids collect here (JSON array)
* showVegasModeBadge: true // optional: Scroll/Fixed/Static badge
* });
*
* The container re-renders from /api/v3/plugins/installed each init; the
* hidden input(s) must already hold the saved order/exclusions (JSON).
*/
(function() {
'use strict';
const MODE_LABELS = new Map([
['scroll', { label: 'Scroll', icon: 'fa-scroll', color: 'text-blue-600' }],
['fixed', { label: 'Fixed', icon: 'fa-square', color: 'text-green-600' }],
['static', { label: 'Static', icon: 'fa-pause', color: 'text-orange-600' }]
]);
function init(options) {
const container = document.getElementById(options.containerId);
const orderInput = document.getElementById(options.orderInputId);
const excludedInput = options.excludedInputId ? document.getElementById(options.excludedInputId) : null;
if (!container || !orderInput) return;
function syncInputs() {
const order = [];
const excluded = [];
container.querySelectorAll('.plugin-order-item').forEach(item => {
const pluginId = item.dataset.pluginId;
order.push(pluginId);
const checkbox = item.querySelector('.plugin-order-include');
if (checkbox && !checkbox.checked) excluded.push(pluginId);
});
orderInput.value = JSON.stringify(order);
if (excludedInput) excludedInput.value = JSON.stringify(excluded);
}
function setupDragAndDrop() {
let draggedItem = null;
container.querySelectorAll('.plugin-order-item').forEach(item => {
item.addEventListener('dragstart', function(e) {
draggedItem = this;
this.style.opacity = '0.5';
e.dataTransfer.effectAllowed = 'move';
});
item.addEventListener('dragend', function() {
this.style.opacity = '1';
draggedItem = null;
syncInputs();
});
item.addEventListener('dragover', function(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const rect = this.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
if (e.clientY < midY) {
this.style.borderTop = '2px solid #3b82f6';
this.style.borderBottom = '';
} else {
this.style.borderBottom = '2px solid #3b82f6';
this.style.borderTop = '';
}
});
item.addEventListener('dragleave', function() {
this.style.borderTop = '';
this.style.borderBottom = '';
});
item.addEventListener('drop', function(e) {
e.preventDefault();
this.style.borderTop = '';
this.style.borderBottom = '';
if (draggedItem && draggedItem !== this) {
const rect = this.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
if (e.clientY < midY) {
container.insertBefore(draggedItem, this);
} else {
container.insertBefore(draggedItem, this.nextSibling);
}
}
});
});
}
fetch('/api/v3/plugins/installed')
.then(response => response.json())
.then(data => {
const allPlugins = (data.data && data.data.plugins) || data.plugins || [];
const plugins = allPlugins.filter(p => p.enabled);
if (plugins.length === 0) {
const empty = document.createElement('p');
empty.className = 'text-sm text-gray-500 italic';
empty.textContent = 'No enabled plugins';
container.textContent = '';
container.appendChild(empty);
return;
}
let currentOrder = [];
let excluded = [];
try {
currentOrder = JSON.parse(orderInput.value || '[]');
if (excludedInput) excluded = JSON.parse(excludedInput.value || '[]');
} catch (e) {
console.error('Error parsing saved plugin order:', e);
}
// JSON.parse can succeed and still return null/objects
// (e.g. a saved value of "null"); normalize to arrays.
if (!Array.isArray(currentOrder)) currentOrder = [];
if (!Array.isArray(excluded)) excluded = [];
// Saved order first, then any newly enabled plugins.
const orderedPlugins = [];
currentOrder.forEach(id => {
const plugin = plugins.find(p => p.id === id);
if (plugin) orderedPlugins.push(plugin);
});
plugins.forEach(plugin => {
if (!orderedPlugins.find(p => p.id === plugin.id)) orderedPlugins.push(plugin);
});
// Rows are built with DOM APIs rather than innerHTML — plugin
// ids/names come from installed manifests (semi-trusted).
container.textContent = '';
orderedPlugins.forEach(plugin => {
const row = document.createElement('div');
row.className = 'flex items-center p-2 bg-gray-50 rounded border border-gray-200 cursor-move plugin-order-item';
row.dataset.pluginId = plugin.id;
row.draggable = true;
const grip = document.createElement('i');
grip.className = 'fas fa-grip-vertical text-gray-400 mr-3';
row.appendChild(grip);
if (excludedInput) {
const isExcluded = excluded.includes(plugin.id);
const label = document.createElement('label');
label.className = 'flex items-center flex-1';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'plugin-order-include h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded mr-2';
checkbox.checked = !isExcluded;
const name = document.createElement('span');
name.className = 'text-sm font-medium text-gray-700';
name.textContent = plugin.name || plugin.id;
label.appendChild(checkbox);
label.appendChild(name);
row.appendChild(label);
} else {
const name = document.createElement('span');
name.className = 'text-sm font-medium text-gray-700 flex-1';
name.textContent = plugin.name || plugin.id;
row.appendChild(name);
}
if (options.showVegasModeBadge) {
const vegasMode = plugin.vegas_mode || plugin.vegas_content_type || 'fixed';
const modeInfo = MODE_LABELS.get(vegasMode) || MODE_LABELS.get('fixed');
const badge = document.createElement('span');
badge.className = `text-xs ${modeInfo.color} ml-2`;
badge.title = `Vegas display mode: ${modeInfo.label}`;
const badgeIcon = document.createElement('i');
badgeIcon.className = `fas ${modeInfo.icon} mr-1`;
badge.appendChild(badgeIcon);
badge.appendChild(document.createTextNode(modeInfo.label));
row.appendChild(badge);
}
// Up/down buttons: touch- and keyboard-accessible
// reordering alongside native drag-and-drop (HTML5 drag
// events don't fire on most mobile browsers).
const pluginLabel = plugin.name || plugin.id;
[['up', 'fa-chevron-up', `Move ${pluginLabel} up`],
['down', 'fa-chevron-down', `Move ${pluginLabel} down`]].forEach(([dir, iconCls, ariaLabel]) => {
const moveBtn = document.createElement('button');
moveBtn.type = 'button';
moveBtn.className = 'plugin-order-move text-gray-400 hover:text-gray-700 px-2 py-1';
moveBtn.setAttribute('aria-label', ariaLabel);
const moveIcon = document.createElement('i');
moveIcon.className = `fas ${iconCls} text-xs`;
moveBtn.appendChild(moveIcon);
moveBtn.addEventListener('click', function() {
if (dir === 'up' && row.previousElementSibling) {
container.insertBefore(row, row.previousElementSibling);
} else if (dir === 'down' && row.nextElementSibling) {
container.insertBefore(row.nextElementSibling, row);
}
syncInputs();
moveBtn.focus();
});
row.appendChild(moveBtn);
});
container.appendChild(row);
});
setupDragAndDrop();
container.querySelectorAll('.plugin-order-include').forEach(checkbox => {
checkbox.addEventListener('change', syncInputs);
});
syncInputs();
})
.catch(error => {
console.error('Error fetching plugins:', error);
const err = document.createElement('p');
err.className = 'text-sm text-red-500';
err.textContent = 'Error loading plugins';
container.textContent = '';
container.appendChild(err);
});
}
window.PluginOrderList = { init: init };
})();
+24
View File
@@ -0,0 +1,24 @@
{
"name": "LED Matrix Control",
"short_name": "LEDMatrix",
"description": "Control panel for the LEDMatrix display",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#111827",
"theme_color": "#111827",
"icons": [
{
"src": "/static/v3/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/static/v3/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(P){var t={pairs:"()[]{}''\"\"",closeBefore:")]}'\":;>",triples:"",explode:"[]{}"},S=P.Pos;function k(e,n){return"pairs"==n&&"string"==typeof e?e:("object"==typeof e&&null!=e[n]?e:t)[n]}P.defineOption("autoCloseBrackets",!1,function(e,n,t){t&&t!=P.Init&&(e.removeKeyMap(i),e.state.closeBrackets=null),n&&(r(k(n,"pairs")),e.state.closeBrackets=n,e.addKeyMap(i))});var i={Backspace:function(e){var n=y(e);if(!n||e.getOption("disableInput"))return P.Pass;for(var t=k(n,"pairs"),r=e.listSelections(),i=0;i<r.length;i++){if(!r[i].empty())return P.Pass;var a=s(e,r[i].head);if(!a||t.indexOf(a)%2!=0)return P.Pass}for(i=r.length-1;0<=i;i--){var o=r[i].head;e.replaceRange("",S(o.line,o.ch-1),S(o.line,o.ch+1),"+delete")}},Enter:function(r){var e=y(r),n=e&&k(e,"explode");if(!n||r.getOption("disableInput"))return P.Pass;for(var i=r.listSelections(),t=0;t<i.length;t++){if(!i[t].empty())return P.Pass;var a=s(r,i[t].head);if(!a||n.indexOf(a)%2!=0)return P.Pass}r.operation(function(){var e=r.lineSeparator()||"\n";r.replaceSelection(e+e,null),O(r,-1),i=r.listSelections();for(var n=0;n<i.length;n++){var t=i[n].head.line;r.indentLine(t,null,!0),r.indentLine(t+1,null,!0)}})}};function r(e){for(var n=0;n<e.length;n++){var t=e.charAt(n),r="'"+t+"'";i[r]||(i[r]=function(n){return function(e){return function(i,e){var n=y(i);if(!n||i.getOption("disableInput"))return P.Pass;var t=k(n,"pairs"),r=t.indexOf(e);if(-1==r)return P.Pass;for(var a,o=k(n,"closeBefore"),s=k(n,"triples"),l=t.charAt(r+1)==e,c=i.listSelections(),f=r%2==0,h=0;h<c.length;h++){var u,d=c[h],p=d.head,g=i.getRange(p,S(p.line,p.ch+1));if(f&&!d.empty())u="surround";else if(!l&&f||g!=e)if(l&&1<p.ch&&0<=s.indexOf(e)&&i.getRange(S(p.line,p.ch-2),p)==e+e){if(2<p.ch&&/\bstring/.test(i.getTokenTypeAt(S(p.line,p.ch-2))))return P.Pass;u="addFour"}else if(l){d=0==p.ch?" ":i.getRange(S(p.line,p.ch-1),p);if(P.isWordChar(g)||d==e||P.isWordChar(d))return P.Pass;u="both"}else{if(!f||!(0===g.length||/\s/.test(g)||-1<o.indexOf(g)))return P.Pass;u="both"}else u=l&&function(e,n){var t=e.getTokenAt(S(n.line,n.ch+1));return/\bstring/.test(t.type)&&t.start==n.ch&&(0==n.ch||!/\bstring/.test(e.getTokenTypeAt(n)))}(i,p)?"both":0<=s.indexOf(e)&&i.getRange(p,S(p.line,p.ch+3))==e+e+e?"skipThree":"skip";if(a){if(a!=u)return P.Pass}else a=u}var v=r%2?t.charAt(r-1):e,b=r%2?e:t.charAt(r+1);i.operation(function(){if("skip"==a)O(i,1);else if("skipThree"==a)O(i,3);else if("surround"==a){for(var e=i.getSelections(),n=0;n<e.length;n++)e[n]=v+e[n]+b;i.replaceSelections(e,"around");for(e=i.listSelections().slice(),n=0;n<e.length;n++)e[n]=(t=e[n],r=void 0,r=0<P.cmpPos(t.anchor,t.head),{anchor:new S(t.anchor.line,t.anchor.ch+(r?-1:1)),head:new S(t.head.line,t.head.ch+(r?1:-1))});i.setSelections(e)}else"both"==a?(i.replaceSelection(v+b,null),i.triggerElectric(v+b),O(i,-1)):"addFour"==a&&(i.replaceSelection(v+v+v+v,"before"),O(i,1));var t,r})}(e,n)}}(t))}}function y(e){var n=e.state.closeBrackets;return n&&!n.override&&e.getModeAt(e.getCursor()).closeBrackets||n}function O(e,n){for(var t=[],r=e.listSelections(),i=0,a=0;a<r.length;a++){var o=r[a];o.head==e.getCursor()&&(i=a);o=o.head.ch||0<n?{line:o.head.line,ch:o.head.ch+n}:{line:o.head.line-1};t.push({anchor:o,head:o})}e.setSelections(t,i)}function s(e,n){n=e.getRange(S(n.line,n.ch-1),S(n.line,n.ch+1));return 2==n.length?n:null}r(t.pairs+"`")});
@@ -0,0 +1 @@
!function(t){"object"==typeof exports&&"object"==typeof module?t(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],t):t(CodeMirror)}(function(r){var u=/MSIE \d/.test(navigator.userAgent)&&(null==document.documentMode||document.documentMode<8),k=r.Pos,p={"(":")>",")":"(<","[":"]>","]":"[<","{":"}>","}":"{<","<":">>",">":"<<"};function y(t){return t&&t.bracketRegex||/[(){}[\]]/}function f(t,e,n){var r=t.getLineHandle(e.line),i=e.ch-1,c=n&&n.afterCursor;null==c&&(c=/(^| )cm-fat-cursor($| )/.test(t.getWrapperElement().className));var a=y(n),c=!c&&0<=i&&a.test(r.text.charAt(i))&&p[r.text.charAt(i)]||a.test(r.text.charAt(i+1))&&p[r.text.charAt(++i)];if(!c)return null;a=">"==c.charAt(1)?1:-1;if(n&&n.strict&&0<a!=(i==e.ch))return null;r=t.getTokenTypeAt(k(e.line,i+1)),n=o(t,k(e.line,i+(0<a?1:0)),a,r,n);return null==n?null:{from:k(e.line,i),to:n&&n.pos,match:n&&n.ch==c.charAt(0),forward:0<a}}function o(t,e,n,r,i){for(var c=i&&i.maxScanLineLength||1e4,a=i&&i.maxScanLines||1e3,o=[],h=y(i),l=0<n?Math.min(e.line+a,t.lastLine()+1):Math.max(t.firstLine()-1,e.line-a),s=e.line;s!=l;s+=n){var u=t.getLine(s);if(u){var f=0<n?0:u.length-1,m=0<n?u.length:-1;if(!(u.length>c))for(s==e.line&&(f=e.ch-(n<0?1:0));f!=m;f+=n){var g=u.charAt(f);if(h.test(g)&&(void 0===r||(t.getTokenTypeAt(k(s,f+1))||"")==(r||""))){var d=p[g];if(d&&">"==d.charAt(1)==0<n)o.push(g);else{if(!o.length)return{pos:k(s,f),ch:g};o.pop()}}}}}return s-n!=(0<n?t.lastLine():t.firstLine())&&null}function e(t,e,n){for(var r=t.state.matchBrackets.maxHighlightLineLength||1e3,i=n&&n.highlightNonMatching,c=[],a=t.listSelections(),o=0;o<a.length;o++){var h,l=a[o].empty()&&f(t,a[o].head,n);l&&(l.match||!1!==i)&&t.getLine(l.from.line).length<=r&&(h=l.match?"CodeMirror-matchingbracket":"CodeMirror-nonmatchingbracket",c.push(t.markText(l.from,k(l.from.line,l.from.ch+1),{className:h})),l.to&&t.getLine(l.to.line).length<=r&&c.push(t.markText(l.to,k(l.to.line,l.to.ch+1),{className:h})))}if(c.length){u&&t.state.focused&&t.focus();function s(){t.operation(function(){for(var t=0;t<c.length;t++)c[t].clear()})}if(!e)return s;setTimeout(s,800)}}function i(t){t.operation(function(){t.state.matchBrackets.currentlyHighlighted&&(t.state.matchBrackets.currentlyHighlighted(),t.state.matchBrackets.currentlyHighlighted=null),t.state.matchBrackets.currentlyHighlighted=e(t,!1,t.state.matchBrackets)})}function c(t){t.state.matchBrackets&&t.state.matchBrackets.currentlyHighlighted&&(t.state.matchBrackets.currentlyHighlighted(),t.state.matchBrackets.currentlyHighlighted=null)}r.defineOption("matchBrackets",!1,function(t,e,n){n&&n!=r.Init&&(t.off("cursorActivity",i),t.off("focus",i),t.off("blur",c),c(t)),e&&(t.state.matchBrackets="object"==typeof e?e:{},t.on("cursorActivity",i),t.on("focus",i),t.on("blur",c))}),r.defineExtension("matchBrackets",function(){e(this,!0)}),r.defineExtension("findMatchingBracket",function(t,e,n){return f(this,t,e=n||"boolean"==typeof e?n?(n.strict=e,n):e?{strict:!0}:null:e)}),r.defineExtension("scanForBracket",function(t,e,n,r){return o(this,t,e,n,r)})});
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
.cm-s-monokai.CodeMirror{background:#272822;color:#f8f8f2}.cm-s-monokai div.CodeMirror-selected{background:#49483e}.cm-s-monokai .CodeMirror-line::selection,.cm-s-monokai .CodeMirror-line>span::selection,.cm-s-monokai .CodeMirror-line>span>span::selection{background:rgba(73,72,62,.99)}.cm-s-monokai .CodeMirror-line::-moz-selection,.cm-s-monokai .CodeMirror-line>span::-moz-selection,.cm-s-monokai .CodeMirror-line>span>span::-moz-selection{background:rgba(73,72,62,.99)}.cm-s-monokai .CodeMirror-gutters{background:#272822;border-right:0}.cm-s-monokai .CodeMirror-guttermarker{color:#fff}.cm-s-monokai .CodeMirror-guttermarker-subtle{color:#d0d0d0}.cm-s-monokai .CodeMirror-linenumber{color:#d0d0d0}.cm-s-monokai .CodeMirror-cursor{border-left:1px solid #f8f8f0}.cm-s-monokai span.cm-comment{color:#75715e}.cm-s-monokai span.cm-atom{color:#ae81ff}.cm-s-monokai span.cm-number{color:#ae81ff}.cm-s-monokai span.cm-comment.cm-attribute{color:#97b757}.cm-s-monokai span.cm-comment.cm-def{color:#bc9262}.cm-s-monokai span.cm-comment.cm-tag{color:#bc6283}.cm-s-monokai span.cm-comment.cm-type{color:#5998a6}.cm-s-monokai span.cm-attribute,.cm-s-monokai span.cm-property{color:#a6e22e}.cm-s-monokai span.cm-keyword{color:#f92672}.cm-s-monokai span.cm-builtin{color:#66d9ef}.cm-s-monokai span.cm-string{color:#e6db74}.cm-s-monokai span.cm-variable{color:#f8f8f2}.cm-s-monokai span.cm-variable-2{color:#9effff}.cm-s-monokai span.cm-type,.cm-s-monokai span.cm-variable-3{color:#66d9ef}.cm-s-monokai span.cm-def{color:#fd971f}.cm-s-monokai span.cm-bracket{color:#f8f8f2}.cm-s-monokai span.cm-tag{color:#f92672}.cm-s-monokai span.cm-header{color:#ae81ff}.cm-s-monokai span.cm-link{color:#ae81ff}.cm-s-monokai span.cm-error{background:#f92672;color:#f8f8f0}.cm-s-monokai .CodeMirror-activeline-background{background:#373831}.cm-s-monokai .CodeMirror-matchingbracket{text-decoration:underline;color:#fff!important}
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More