Compare commits

...
Author SHA1 Message Date
ChuckBuildsandClaude Opus 5 183e23edb3 docs(changelog): record the compatibility gate in 3.2.0
The 3.2.0 section described the unified sports library but none of the
install-path work that landed in #428 and #431 -- which matters more than a
normal changelog omission, because the sunset rule keys on this section to
tell plugin authors what a given floor buys them.

The headline addition: 3.2.0 is the first release that *enforces*
ledmatrix_min_version. Before it the floor was advisory, so a plugin could
declare one and still be delivered to a core that could not run it. That is
the property B6 waits on, and it is now stated where a plugin author will
look for it -- along with the caveat that a core reporting below 2.0.0 is
treated as unknown rather than old and is never blocked.

Also records compatibility.py (and that it does not yet read
compatible_versions), check_release_version.py and its workflow, the
install-preservation fix, the reentrant-lock deadlock fix, and the
web_interface version re-export.

No version bump: 3.2.0 is unreleased, so this describes the release being
cut rather than a new one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
2026-08-03 16:54:53 -04:00
970ca2d04f feat(store): refuse to install a plugin that needs a newer core (re-target of #429) (#431)
* feat(store): refuse to install a plugin that needs a newer core

`ledmatrix_min_version` was decoration. The loader logged an advisory warning
and continued; the store never compared the core version at all, so a routine
"update" delivered a plugin that could not run. That is the gap phase B6 (the
sports-unification sunset) cannot be done over: deleting a plugin's bundled
fallback while nothing enforces the floor hands un-updated users a scoreboard
that raises ModuleNotFoundError at load and is reported only as one line in
the journal.

The gate lives in install_plugin, after the manifest is on disk and before
dependencies are installed. That is the earliest knowable point -- the
registry carries no compatibility field, so the floor is not visible until
the files are down -- and it is also the chokepoint: _reinstall_with_rollback
calls install_plugin, so a refused *update* restores the version the user
already had, for free.

Floor resolution and the comparison move to src/plugin_system/compatibility.py,
shared with the loader so the two cannot drift. Both read all four spellings
published manifests use, including the deprecated `ledmatrix_min`.

Refusal requires evidence. An undeclared floor, an unparseable version on
either side, or a core below TRUSTWORTHY_FLOOR (2.0.0) all allow the install.
That last one is deliberate and load-bearing: the v3.1.0 release reports
__version__ = "1.0.0" while nearly every published manifest floors at 2.0.0,
so a strict gate would lock those users out of the plugin store entirely --
much worse than the problem being solved. They stay unprotected until they
update the core, which is also what fixes their version string.

Verified: 782 core unit tests pass, including 25 new ones and the existing
loader-warning suite unchanged (the refactor is behavior-preserving). The
install tests drive the real install_plugin path with the download stubbed --
the allow and refuse cases differ only in the declared floor, so the refusal
is demonstrably the gate and not an earlier bail-out.

Follow-ups, deliberately not in this PR: surfacing the reason in the store UI
rather than only the log, and publishing the floor in plugins.json so the
store can refuse before downloading.

Phase B4 in docs/SPORTS_UNIFICATION.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5

* fix(store): a failed install must not destroy the plugin it replaced

Found while validating the compatibility gate. `_install_plugin_impl` deletes
the existing plugin directory *before* downloading, so any failure after that
point leaves the user with nothing. `_reinstall_with_rollback` protects the
update path exactly this way; a direct `install_plugin` had no equivalent.

The gate made this reachable in a new way: a plugin whose declared floor
exceeds the running core is now refused *after* the old copy is already gone.
Floors are hand-written and can be over-declared, so the refusal could remove
a plugin that had been working fine on that core.

install_plugin is now a thin wrapper that renames any existing install aside,
delegates to _install_plugin_impl, and restores it on failure -- including
when the implementation raises, which is re-raised after the restore. It is a
pass-through when nothing is installed and when called from
_reinstall_with_rollback, which has already moved the old copy aside; a test
pins that so the two mechanisms cannot start nesting.

The aside name embeds '.standalone-backup-' because
plugin_manager._scan_directory_for_plugins keys on exactly that substring to
skip backups. A different name would have made the backup discoverable as a
duplicate plugin; a test pins that too.

789 core unit tests pass, including 7 new ones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5

* fix(store): serialize concurrent installs, and make the lock reentrant

Second bug found while validating the previous commit on hardware.

install_plugin's new set-aside/restore had no lock. The web UI runs Flask
threaded, so a double-clicked Install button gives two threads the same
plugin_id; interleaved, one thread's restore deletes the other's freshly
installed copy. _reinstall_with_rollback already guards exactly this with a
per-plugin lock, and install_plugin needs the same one.

Taking that lock naively deadlocks. _reinstall_with_rollback holds it across
its call to install_plugin, and threading.Lock is not reentrant -- so the
request thread hangs forever on the standard monorepo update path
(update_plugin -> _reinstall_with_rollback -> install_plugin), which is to say
on every plugin update. Verified by reverting to a plain Lock: the regression
test times out after 10s instead of passing.

The per-plugin locks are now RLocks, and install_plugin holds one for its
whole set-aside/install/restore sequence.

Verified on devpi (Pi, Python 3.13.5, real registry and network):
- update_plugin on an up-to-date plugin: True in 5.4s
- update_plugin forced through the full reinstall-with-rollback path:
  True in 13.1s, correct version restored, old copy replaced, no backup
  directories left behind
- install -> reinstall-over-existing -> failed-reinstall-restores: all pass
  against real downloads
- 22 plugins load, no tracebacks, web API and UI 200, steady-state journal
  50 lines/min

791 core unit tests pass, including 2 new concurrency tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5

* ci: run the new suites, and check tag/version agreement at release time

These were split out of #428/#429 because the token pushing them lacked the
`workflow` scope. Folding them in here rather than opening a stacked PR --
#429 was merged into its stacked base after that base had already been
squash-merged, so its content never reached main, and one such near-miss is
enough.

All three enrolled suites exist on this branch: test_version_consistency.py
came with #428 and is on main; the other two arrive with the commits above.
Enrolling them in a separate PR would have either raced with this one on
test.yml or briefly pointed CI at files main did not have.

- test.yml: enroll test_version_consistency, test_plugin_compatibility_gate
  and test_install_preserves_existing in the core unit job. Until now these
  32 tests existed but nothing ran them automatically.

- release-version-check.yml: run scripts/check_release_version.py on pushed
  v* tags and published releases, plus workflow_dispatch so a tag can be
  checked *before* it is created. No dependencies -- it reads src/__init__.py
  and CHANGELOG.md only.

Verified: both workflow files parse, and the release check still passes for
v3.2.0 against this tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 16:52:42 -04:00
f2b246ef03 fix(version): make the core version have exactly one answer (#428)
* fix(version): make the core version have exactly one answer

Plugin compatibility floors compare against src.__version__, so that string
has to be trustworthy. It has not been. v3.1.0 was tagged 2026-05-31 while
src/__init__.py still said "1.0.0"; the bump did not land until 2026-07-12.
Every device installed from that release reports 1.0.0, which is below the
(2, 0, 0) floor in PluginLoader._warn_if_incompatible -- so those users are
silently exempt from every plugin compatibility warning.

web_interface carried a third answer, a hardcoded "3.0.0" that nothing read
and that had drifted two majors from the core. It now re-exports the
canonical value, so it cannot disagree again.

Adds:

- test/test_version_consistency.py (enrolled in the core unit CI job):
  src.__version__ is parseable semver, matches the newest CHANGELOG heading,
  the CHANGELOG's headings are unique and descending, and web_interface
  tracks the core. src.plugin_system.__version__ is deliberately excluded --
  it versions the plugin API and moves independently.

- scripts/check_release_version.py + a release-version-check workflow that
  asserts the tag, the CHANGELOG and src.__version__ agree. Runs on pushed
  v* tags and published releases, and via workflow_dispatch so a tag can be
  checked *before* it is created:

      python scripts/check_release_version.py v3.2.0

Verified: 757 core unit tests pass including the four new ones; the script
exits 0 for v3.2.0 and non-zero for both a mismatched tag (v3.1.0) and a
non-semver one (v2.5); web_interface and web_interface.app still import.

Prerequisite for cutting v3.2.0 -- phase B4 in docs/SPORTS_UNIFICATION.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5

* fix(version): address review — regex strictness, OSError, stale doc claim

From CodeRabbit on #428, all three valid:

- The module docstring claimed the tag check "runs at release time in
  .github/workflows/release-version-check.yml". That workflow is held back to
  a follow-up PR (the pushing token lacks the `workflow` scope), so the claim
  was false as written. Both files now describe the script as a manual
  pre-flight and say the CI wiring is still to come.

- `\d` also matches non-ASCII decimal digits, which int() happily parses, and
  `\s` matches newlines -- so "##\n3.2.0" read as a version heading. Patterns
  now use [0-9] and [ \t], kept in step across the test and the script, with a
  regression test pinning both behaviours.

- A missing or unreadable CHANGELOG.md raised OSError out of read_text() and
  printed a traceback. In a release gate that reads as "the tooling is
  broken"; it now reports the path and a recovery action and exits 1.

Verified: v3.2.0 passes, a mismatched tag exits 1, and a missing CHANGELOG
exits 1 with the new message instead of a traceback. 5 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 15:21:47 -04:00
963ab8292a docs(sports): split adoption from sunset, and say what makes the sunset safe (#427)
* docs(sports): split adoption from sunset, and say what makes the sunset safe

The phase table folded two steps with very different risk profiles into one
B5: adopting core imports (safe by construction -- the guarded import keeps
the bundled fallback) and deleting the bundled copies (removes the fallback,
so the import becomes a hard dependency). They are now B5 and B6.

Reading the enforcement path showed the declared floor protects nobody today:

- PluginLoader._warn_if_incompatible is advisory, and skips entirely when the
  parsed core version is below 2.0.0.
- The v3.1.0 release ships __version__ = "1.0.0" -- the tag was cut
  2026-05-31 and the string was not bumped until 2026-07-12 -- so the skip
  matches exactly the users most likely to be behind.
- Neither StoreManager.install_plugin nor .update_plugin compares the core
  version, so a store update delivers a plugin that floors above the core.

Verified against a v3.1.0 worktree: sports_scroll, element_style and the
sports package are absent there, and the import fails with
exc.name == 'src.common.sports_scroll' -- a guard set of {"src"} does not
match it.

B4 therefore grows to include making the version number trustworthy and
adding the install/update gate; B6 waits on that gate having shipped and
reached users. Also adds an ordered "What's next" and the durable lessons
this migration paid for.

Documentation only; no code changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5

* docs(sports): address review — test matrix, and compatible_versions

Both CodeRabbit findings on #427 hold up against the code; one sub-point was
already moot.

1. The compatibility regression test was described as one case (bundled copy
   removed on an old core) when it needs four. The case that actually matters
   is the one that was missing: an adopted plugin loading *with* its bundled
   copy on an old core, which is the entire basis for claiming B5 is safe to
   run ahead of the gate. Now a 2x2 table.

   The assertion was also wrong. "Fails loudly and specifically" is
   aspirational -- PluginManager.load_plugin catches ModuleNotFoundError, so
   nothing propagates and it fails into PluginState.ERROR with one log line.
   A test expecting a raise would pass for the wrong reason. Specified as
   PluginState.ERROR plus the exact missing module path, which is also what
   the rest of this document already says the failure looks like.

2. `compatible_versions` -- not `ledmatrix_min_version` -- is the canonical
   contract: schema/manifest_schema.json requires it, all 42 published
   manifests carry it, and it holds semver ranges ([">=2.0.0"] in 41,
   [">=1.0.0"] in 7-segment-clock). The gate as merged reads only the floor.

   Harmless today: no manifest uses an upper bound, and the two fields agree
   everywhere except 7-segment-clock. But the schema's range syntax permits
   upper bounds, so a plugin declaring ["2.0.0 - 2.9.9"] would be installed on
   3.2.0 regardless. Recorded as a named gap the gate must close before B6,
   and the migration step now has to reconcile both fields across every
   manifest the gate can refuse.

   Skipped, with reason: the finding also asked to migrate the deprecated
   top-level `ledmatrix_version`. No manifest carries it -- verified across
   all 42 -- so there is nothing to migrate.

Documentation only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 15:21:34 -04:00
16fbb7ebeb fix(install): survive the rgbmatrix build on low-memory Pis (#430)
* fix(install): survive the rgbmatrix build on low-memory Pis

The one-shot installer failed at Step 6 on a 1GB Pi with "Failed building
wheel for rgbmatrix", and told the user to install build tools they already
had. The real cause was the kernel OOM killer.

Upstream's pyproject.toml declares no [tool.scikit-build] options, so
scikit-build-core drives Ninja at its default of nproc+2 jobs -- six
concurrent compiles on a 4-core Pi. CMakeLists.txt compiles the same 14
sources three times (~45 translation units), two of them Cython-generated
C++ where a single cc1plus peaks near 800MB. That does not fit in 512MB-1GB
of RAM.

Add scripts/install/lib_lowmem.sh and wire it into the installer:

- Cap build parallelism at max(1, min(cores, RAM/768)) via
  CMAKE_BUILD_PARALLEL_LEVEL, which is what cmake --build actually reads.
  MAKEFLAGS is ignored by Ninja and is set only as a Makefile-generator
  fallback. A 4GB Pi 4 still gets 4 jobs; 512MB and 1GB boards get 1.
- Add a temporary swapfile sized to bring RAM+swap to 3GB (capped at 2GB),
  removed once the build finishes. An EXIT trap is the backstop for the
  error path. Nothing is written to /etc/fstab or /etc/dphys-swapfile.
  Existing swap is measured excluding zram, which is compressed RAM and so
  does not help a build OOM.
- Keep pip's build tree off tmpfs. Debian 13 mounts /tmp as tmpfs, so the
  default held the whole C++ build tree in RAM alongside the compiler.
- Diagnose OOM failures from the build log and the kernel ring buffer,
  instead of always blaming missing build tools. The OOM killer writes
  nothing to pip's output, which is why this was misreported.
- Report RAM and the chosen job count in the Step 1 preflight, and emit a
  heartbeat during the compile so a deliberately serial 15-25 minute build
  does not look like a hang.

New flags --skip-swap and --build-jobs N, with LEDMATRIX_SKIP_SWAP and
LEDMATRIX_BUILD_JOBS equivalents.

Also skip the duplicate apt-get update that the one-shot installer and
first_time_install.sh each ran a minute apart, and complete the
dphys-swapfile advice in diagnose_dependencies.sh with the CONF_MAXSWAP
line, without which raising CONF_SWAPSIZE above 2048 is silently clamped.

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

* fix(install): address review findings on the low-memory build path

Three fixes from PR review:

- Validate --build-jobs / LEDMATRIX_BUILD_JOBS before check_memory's
  fallback return. When lib_lowmem.sh is absent that return also honoured
  the override, so a non-numeric value skipped validation and instead blew
  up later in an arithmetic test in Step 6 with a generic error.
- Fall back to the default TMPDIR when the disk-backed build directory
  cannot be created, rather than pointing the build at a path that does
  not exist. A nearly-full disk is the likely cause on exactly the devices
  this targets.
- Pass LEDMATRIX_APT_UPDATED explicitly to the sudo child instead of
  relying on -E, which a sudoers env_reset/env_keep policy can strip.

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

* fix(install): don't add 30s to every rgbmatrix build

The build progress heartbeat slept for the full 30s report interval
before re-checking whether the compile had finished, so every build paid
up to 30 seconds of dead wall time -- including fast ones on a Pi 4/5 and
every --force-rebuild run.

Poll every 2s and report every 30s instead. Measured: 30s of overhead on
an instant build drops to 2s, with heartbeats still emitted on the same
schedule.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-03 15:21:18 -04:00
21825cbfbc Sports unification phases 1–2: package split, promoted methods, opt-in capabilities (#426)
* 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

* 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

* 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

* 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

* 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

* 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

* 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

* 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

* 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

* 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

* 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

* feat(scroll): upstream the scroll orchestration layer; release 3.2.0

Phases B3 and B4.

B3 -- src/common/sports_scroll.py is deliberately NOT a superset of the
ten plugin scroll_display.py copies. A method-level comparison of the
eight that share a shape (f1 and ufc are genuine forks) found a sharp
split, and the module is drawn along it:

  promoted   orchestration -- get_all_vegas_content_items is identical
             in all eight; clear_all, get_scroll_info,
             get_dynamic_duration, is_complete and display_frame are
             96-100% similar
  promoted   settings -- one algorithm; the copies differ only in which
             league keys they walk, so the ladder is data
             (SCROLL_LEAGUE_KEYS) rather than a body per sport
  NOT        content -- prepare_scroll_content has 8 distinct bodies
             across 8 plugins (145 lines, 53% similar at worst) and
             _load_separator_icons 7 (6% at worst)

Same name, different job: prepare_scroll_content draws *this sport's*
game card. Merging those eight bodies would be exactly the mistake the
promotion rule exists to prevent, so the base raises NotImplementedError
rather than rendering something plausible -- a base that rendered
something would let a plugin ship a silently blank scroll.

The one behavior added over the plugin copies is native
global_config['target_fps'] support. The bundled copies hardcode ~100
FPS via scroll_delay and never consult the global target; Part A
threaded it through each copy by hand, and this makes that threading
legacy compatibility rather than the mechanism.

66 tests, including three against the real ScrollHelper rather than a
double -- a suite built entirely on MagicMock would sail straight past a
rename in the helper.

B4 -- bump src/__init__.py to 3.2.0 and close the CHANGELOG's Unreleased
section against it. This is the number the sunset rule keys on: the
first core release shipping the unified sports library, and therefore
the floor a plugin sets ledmatrix_min_version to before deleting its
bundled copies. The version bump and the changelog release heading move
together on purpose -- separating them would leave a commit whose
changelog announces 3.2.0 while the code still reports 3.1.0.

Nothing here changes what an existing plugin loads; adoption is B5.

Gates: 714 core unit, 66 plugin safety.

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

* fix(sports): per-type warning cooldowns and font-load logging

Follow-ups from the second review pass, both on already-fixed findings:

_should_log accepted a warning_type and ignored it, sharing one
timestamp across every kind of warning -- so an API-error warning
silenced an unrelated cache warning for the next minute, and whichever
fired first won. Cooldowns are now keyed by type. Nothing in core calls
this method, so no behavior regressed; _last_warning_time is kept in
step for subclasses that read it directly.

_load_fonts logged through the module-level logger, dropping the manager
context, and had no return type hint. It now uses self.logger (set well
before _load_fonts runs) and names the directory it searched -- the bare
"Fonts not found" sent people hunting for a font-format problem when the
actual cause is an install missing assets/fonts.

Gates: 717 core unit, 66 plugin safety.

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

* docs: record the validated hockey scroll-display pilot for B5

B5 cannot ship until this PR merges and 3.2.0 exists -- a plugin cannot
floor ledmatrix_min_version at a release that does not exist, and an
unguarded src.common.sports_scroll import would break every user on
3.1.0.

The pilot has been validated ahead of that gate: hockey's
scroll_display.py adopted against a core carrying 3.2.0 goes from 691 to
289 lines with all 16 harness renders byte-for-byte identical.

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

* fix(sports): harden the B2/B3 capabilities against bad config and subclasses

Review pass on the phase B2-B4 changes. Every fix here is the same shape
as the crashes this PR already fixed in the hockey and baseball
extractors: a config or feed value that is present-but-wrong reaching
arithmetic or a comparison on a path with no guard.

celebrations:
- celebration_duration is coerced and floored at init. It is compared
  numerically in display() *outside* any try block, so a string from a
  hand-edited config propagated a TypeError straight out; zero or
  negative armed a celebration that could never render.
- A render failure now disarms instead of staying armed. It previously
  retried the same broken render on every frame for the rest of the
  window -- a traceback per frame, and no scorebug either.
- prune_score_baselines() for the live set. Only _check_for_win removed
  entries, so a game that left the live list any other way leaked its
  baseline and the dict grew all season.
- display() reuses has_active_celebration() rather than repeating its
  window comparison, and log lines carry a [Celebrations] prefix.

rotation:
- MAX_WEIGHT ceiling. A cycle is sum(weights) long and each step scans
  every game, so an unbounded weight from a misread config spins the
  display thread -- on a Pi that stalls rendering outright.
- register_rotation_strategy rejects a non-subclass factory at
  registration instead of failing frames later inside schedule().
- schedule() previews through type(self), so a subclass overriding
  next_game is previewed with its own ordering -- which is what the
  method promises.

sports_scroll:
- scroll_speed / scroll_delay coerced. dict.get(key, default) only helps
  when the key is absent; present-but-null reached the multiplication
  inside __init__ and the display failed to construct at all.
- update_scroll_position and get_visible_portion moved inside the try.
  They ran outside it, so a raise there reached the plugin's frame loop
  despite the comment promising none can.
- prepare_and_display guards the subclass call, so one sport's bad
  payload cannot take down the shared orchestration for the others.
- _current_game_type spells "nothing active" as "" in both classes; the
  manager said None while the display said "".

Not taken: the report that baseball's favorite-team debug path still
reads event-level status. Verified against current code -- there are no
remaining game_event["status"] reads in that file; it was fixed in
2486bdb and the finding is stale.

Gates: 747 core unit, 66 plugin safety.

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

* fix(baseball): don't drop favourite MiLB games on the diagnostic path

The competition-level status fallback fixed the inning lookup, but the
favourite-team debug block a few lines above still read the event top-level
game_event["status"]. MiLB events (synthesized from the MLB Stats API into an
ESPN-like shape) populate only the competition-level status, so the identical
event that extracted fine for a non-favourite raised KeyError and returned
None once the team was a favourite.

Worst possible shape for the bug: it only hit the games the user cared most
about, and only on the path meant to help diagnose them. The existing
regression test missed it because it never passes favourites, so
is_favorite_game was False and the block never ran.

Uses the validated competition-level `status`, which
_extract_game_details_common guarantees is present by that point. Adds a
favourites-passing companion test; confirmed it reproduces the KeyError
without the fix.

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

* docs(sports): type-hint game_update_timestamps to match its sibling

Addresses the last remaining sub-point on the modes.py review thread. The
design finding itself is already handled: the base class documents that it
only reads game_update_timestamps and that a subclass's update() owns writing
"last_seen" (and afl/etc. do, so stale-game eviction works in practice). The
one concrete gap was the missing annotation -- _zero_clock_timestamps is typed
Dict[str, float] while this nested map had none. Now Dict[str, Dict[str, float]].

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

* Address CodeRabbit review: font-name traversal + offline test guard

Two Minor findings from CodeRabbit's first review of this PR.

- resolve_font_path: reject relative font names carrying path components.
  font_name comes from plugin config, which the web UI writes; a value like
  "../../config/config.json" escaped assets/fonts/ after os.path.join and let
  a config probe arbitrary paths for existence (disclosure unlikely, since
  Pillow/freetype reject non-font files, but the probe is real). Relative
  names must now be bare filenames (os.path.basename(name) == name); absolute
  paths keep their existing isfile() gate. Test confirms the traversal
  resolved the real config.json before the guard.

- build_manager fixture: patch requests.Session.get BEFORE constructing the
  manager. Construction creates both SportsCore.session and the
  ESPNDataSource.session; the old code only replaced manager.session after
  the fact, leaving data_source.session real and able to reach the network on
  an accidental fetch. Patching the class makes every session built in the
  fixture offline.

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

* test(celebrations): make the expiry tests actually test expiry

CodeRabbit (Major) on the merge re-review: celebration_duration is clamped to
a 1.0s floor, so the two expiry tests that configured 0 and expected instant
expiration never actually hit the expiry branch. They passed only because
_draw_celebration_layout raises in the harness (no real fonts) and its
exception branch clears the celebration the same way -- so they were really
re-testing the render-failure path, not expiry.

Now use a valid 1s duration, backdate started_at past the window, and mock
_draw_celebration_layout with assert_not_called() so an expired celebration
provably does NOT render. Verified discriminating: both fail if
has_active_celebration is forced to never expire.

Production code unchanged -- the expiry logic was already correct; only the
tests were mismodelling it.

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-08-02 12:39:33 -04:00
82a65ad2a2 Sports unification phase 0: safety net, cwd-independent fonts, element_style (#425)
* 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

* 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

* 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

* 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

* 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

* 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

* Address CodeRabbit review: font-name traversal + offline test guard

Two Minor findings from CodeRabbit's first review of this PR.

- resolve_font_path: reject relative font names carrying path components.
  font_name comes from plugin config, which the web UI writes; a value like
  "../../config/config.json" escaped assets/fonts/ after os.path.join and let
  a config probe arbitrary paths for existence (disclosure unlikely, since
  Pillow/freetype reject non-font files, but the probe is real). Relative
  names must now be bare filenames (os.path.basename(name) == name); absolute
  paths keep their existing isfile() gate. Test confirms the traversal
  resolved the real config.json before the guard.

- build_manager fixture: patch requests.Session.get BEFORE constructing the
  manager. Construction creates both SportsCore.session and the
  ESPNDataSource.session; the old code only replaced manager.session after
  the fact, leaving data_source.session real and able to reach the network on
  an accidental fetch. Patching the class makes every session built in the
  fixture offline.

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-08-02 12:01:56 -04:00
83f20b64fe Give plugins access to device-wide config, and a global scroll frame rate (#424)
* Give plugins access to device-wide config, and a global scroll frame rate

The sports scoreboards read `getattr(self, 'global_config', {})` to find a
shared scroll frame rate, but nothing ever set that attribute: the loader
constructs plugins with only plugin_id/config/display_manager/cache_manager/
plugin_manager (plugin_loader.py:671), `global_config` appears nowhere in
src/, no plugin manager assigns it, and BasePlugin has no __getattr__ to
synthesize it. The lookup always returned {}, so the ten scroll_display.py
copies that thread target_fps through to ScrollHelper could never fire on any
core. There was also no global target_fps to find -- the only one in the
template is display.vegas_scroll.target_fps, which is Vegas-scoped.

Adds the missing half:

- `BasePlugin.global_config` resolves the full config via
  plugin_manager.config_manager, then cache_manager.config_manager, then {}.
  Same order the sports timezone helpers already use. Exceptions are swallowed
  to debug so an unreadable config can never stop a plugin loading, and a
  non-dict result is rejected rather than handed to callers that will .get()
  it and feed the result to numeric code.
- A top-level `target_fps` (default 100), exposed on the General tab and
  validated 30-200 on save to match ScrollHelper.set_target_fps -- which
  clamps silently, so a rejected save reports a value that would otherwise
  appear to save and then behave differently.

The property has a setter deliberately. news, stock-news, ledmatrix-stocks,
ledmatrix-elections, ledmatrix-leaderboard and nfl-draft all assign
`self.global_config = config.get('global', {})`; without a setter that raises
"property has no setter" and those six plugins stop loading. Reproduced, then
pinned with a test.

target_fps is also kept out of the `is_general_update` key list: that branch
treats a missing web_display_autostart as an unchecked box, so counting a
target_fps-only POST as a General save would silently switch autostart off.

Verified end to end: config.json -> BasePlugin.global_config ->
scroll_display's existing block -> ScrollHelper.target_fps 120 -> 100, with no
plugin-side change needed. Suite 1441 passed; the 4 failures
(test_display_dirty_tracking, test_web_api::test_get_system_status, two in
test_state_reconciliation) are pre-existing and reproduce identically on a
clean tree.

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

* Address review findings on target_fps validation and config resolution

- Reject floats and bools before int() in the target_fps save path. A JSON
  body can carry them, where int(90.5) silently stored 90 and true stored 1.
  Form posts send strings, so '90.5' already failed in int().
- Assert the template's target_fps is 100, not merely an int, so the
  documented default is actually pinned.
- Empty-config precedence: keeping the `and config` check deliberately, now
  spelled out in the comment and covered by a test. Both managers default to
  the same config/config.json, so falling through cannot pick up a different
  file's settings; treating {} as an answer would instead return {} when the
  first manager simply hasn't loaded yet, silently disabling every setting
  read through the property -- the failure this property exists to fix.

Suite 1446 passed. The float-rejection test was checked to fail without the
guard.

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

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 10:40:12 -04: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
59 changed files with 15191 additions and 915 deletions
@@ -0,0 +1,40 @@
name: Release version check
# A release tag, the CHANGELOG, and src.__version__ must agree. They have not
# always: v3.1.0 was tagged while src/__init__.py still said "1.0.0", which
# silently exempted every device installed from that release from plugin
# compatibility warnings. See docs/SPORTS_UNIFICATION.md (phase B4).
on:
push:
tags: ["v*"]
release:
types: [published]
# Pre-flight: run this against the tag you are about to create.
workflow_dispatch:
inputs:
tag:
description: "Tag to check (e.g. v3.2.0)"
required: true
type: string
permissions:
contents: read
jobs:
version-matches-tag:
name: Tag matches src.__version__
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"
# No dependencies: the script reads src/__init__.py and CHANGELOG.md only.
- name: Assert the tag, CHANGELOG and src.__version__ agree
run: python scripts/check_release_version.py "${TAG}"
env:
TAG: ${{ inputs.tag || github.ref_name }}
+49
View File
@@ -5,6 +5,10 @@ on:
push: push:
branches: [main] branches: [main]
# Both jobs only check out the repo and run pytest.
permissions:
contents: read
jobs: jobs:
plugin-safety: plugin-safety:
name: Plugin safety harness + unit tests name: Plugin safety harness + unit tests
@@ -31,3 +35,48 @@ jobs:
test/plugins/test_harness.py \ test/plugins/test_harness.py \
test/plugins/test_visual_rendering.py \ test/plugins/test_visual_rendering.py \
test/plugins/test_plugin_matrix.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 \
test/test_sports_scroll.py \
test/test_version_consistency.py \
test/test_plugin_compatibility_gate.py \
test/test_install_preserves_existing.py
+176
View File
@@ -0,0 +1,176 @@
# 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.
**Use `ledmatrix_min_version` in manifests, not `ledmatrix_min`.** The loader
accepts both, but the store flags the old spelling as deprecated
(`store_manager.py`) and only the new one is in `schema/manifest_schema.json`.
## 3.2.0
**The first release shipping the unified sports library.** This is the version
a sports plugin floors `ledmatrix_min_version` at before deleting its bundled
copy of `sports.py`, `scroll_display.py`, `data_sources.py` or
`base_odds_manager.py` — the sunset rule in
`docs/plugin-development/08-shared-sports-code.md` keys on exactly this number.
Adoption is deliberately staged: the modules below ship here, plugins adopt them
behind guarded imports, and only then do the bundled copies go away. Nothing in
this release changes what an existing plugin loads.
**This is also the first release that *enforces* `ledmatrix_min_version`.**
Before it, the floor was advisory — the loader logged a warning and continued,
and the plugin store never compared the core version at all, so an update could
deliver a plugin that could not run. From 3.2.0 the store refuses such an
install. That matters for the sunset rule: a plugin may only delete its bundled
fallback once the cores in the field actually enforce the floor, which means
waiting for 3.2.0 to be widely installed rather than merely released. See
`docs/SPORTS_UNIFICATION.md`, phase B6.
One deliberate exception: a core reporting a version below `2.0.0` is treated as
*unknown* rather than old and is never blocked. The v3.1.0 release ships
`__version__ = "1.0.0"` (the tag was cut before the string was bumped), and
nearly every published manifest floors at `2.0.0` — so blocking on that number
would lock those users out of the plugin store entirely.
### 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`.
- `src/common/sports_scroll.py``SportsScrollDisplay` and
`SportsScrollDisplayManager`, the shared scroll **orchestration** layer for
the sports scoreboards, plus native support for
`global_config['target_fps']` (the bundled plugin copies hardcode ~100 FPS
via `scroll_delay` and never consult the global target). Content building
(`prepare_scroll_content`, `_load_separator_icons`) is per-sport and stays an
override point — see `docs/SPORTS_UNIFICATION.md` for where the line falls
and why.
- `src/plugin_system/compatibility.py` — the single place that answers "can this
plugin run on this core?", shared by the loader (advisory, at load time) and
the store (blocking, at install/update time) so the two cannot drift. Reads
every spelling published manifests use, including the deprecated
`versions[].ledmatrix_min`. It does **not** yet evaluate `compatible_versions`,
which is the schema-required field and can express upper bounds; closing that
is tracked in `docs/SPORTS_UNIFICATION.md` before B6.
- `scripts/check_release_version.py` and a `Release version check` workflow —
assert that a tag, the newest CHANGELOG heading and `src.__version__` agree,
on pushed `v*` tags and published releases. Runnable via `workflow_dispatch`
to check a tag *before* creating it. Added because `v3.1.0` was tagged six
weeks before `src/__init__.py` was bumped to match, which is why devices
installed from that release report `1.0.0`.
### Changed
- `src/__init__.py` bumped to **3.2.0** — the number the sunset rule keys on.
- **The plugin store refuses an incompatible install.**
`StoreManager.install_plugin` now checks the downloaded manifest's declared
floor against `src.__version__` and refuses when the plugin needs a newer
core. The check sits in `install_plugin` because `_reinstall_with_rollback`
calls it, so a refused *update* restores the version the user already had.
Refusal requires evidence: an undeclared floor, an unparseable version on
either side, or an untrustworthy core version all allow the install.
- **A failed install no longer destroys the plugin it replaced.**
`install_plugin` previously deleted the existing plugin directory before
downloading, so any later failure — a dropped connection, a malformed
manifest, or the new compatibility refusal — left the user with nothing. The
existing copy is now set aside and restored if the install fails, matching
the protection `_reinstall_with_rollback` already gave the update path.
- `web_interface.__version__` re-exports `src.__version__` instead of carrying
its own hardcoded `"3.0.0"`, which had drifted two majors from the core.
- **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
- **Plugin updates could hang the web request thread.** The per-plugin reinstall
locks were non-reentrant, and `_reinstall_with_rollback` holds one across its
call to `install_plugin` — which now takes the same lock to protect the
set-aside/restore above. That nesting deadlocked
`update_plugin → _reinstall_with_rollback → install_plugin`, the standard
path for every monorepo plugin update. The locks are now `RLock`s.
- `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).
+3 -2
View File
@@ -141,6 +141,7 @@ The system supports live, recent, and upcoming game information for multiple spo
sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh
``` ```
- Pi 5 config: leave `rp1_rio` at `0` (PIO mode, default) and set `gpio_slowdown` to `1` or `2`. - Pi 5 config: leave `rp1_rio` at `0` (PIO mode, default) and set `gpio_slowdown` to `1` or `2`.
- **1GB models (Pi 3B / 3B+) and other low-memory boards**: supported, but the `rpi-rgb-led-matrix` C++ build needs more memory than the Pi has. The installer detects this automatically, compiles with fewer parallel jobs, and adds a temporary swapfile for the build which it removes afterwards. Expect that step to take 15-25 minutes instead of 2-5, and leave at least **3GB free** on the SD card. If you manage swap yourself, opt out with `--skip-swap`. To pin the compiler down further, use `--build-jobs 1`.
### RGB Matrix Bonnet / HAT ### RGB Matrix Bonnet / HAT
@@ -314,12 +315,12 @@ curl -fsSL https://raw.githubusercontent.com/ChuckBuilds/LEDMatrix/main/scripts/
``` ```
This one-shot installer will automatically: This one-shot installer will automatically:
- Check system prerequisites (network, disk space, sudo access) - Check system prerequisites (network, disk space, memory, sudo access)
- Install required system packages (git, python3, build tools, etc.) - Install required system packages (git, python3, build tools, etc.)
- Clone or update the LEDMatrix repository - Clone or update the LEDMatrix repository
- Run the complete first-time installation script - Run the complete first-time installation script
The installation process typically takes 10-30 minutes depending on your internet connection and Pi model. All errors are reported explicitly with actionable fixes. The installation process typically takes 10-30 minutes depending on your internet connection and Pi model. Pi 3B/3B+ and other 1GB boards land at the top of that range, because the C++ library is compiled serially to stay within available memory. All errors are reported explicitly with actionable fixes.
**Note:** The script is safe to run multiple times and will handle existing installations gracefully. **Note:** The script is safe to run multiple times and will handle existing installations gracefully.
+20 -1
View File
@@ -88,6 +88,7 @@
} }
}, },
"timezone": "America/New_York", "timezone": "America/New_York",
"target_fps": 100,
"location": { "location": {
"city": "Tampa", "city": "Tampa",
"state": "Florida", "state": "Florida",
@@ -130,7 +131,25 @@
"plugin_order": [], "plugin_order": [],
"excluded_plugins": [], "excluded_plugins": [],
"target_fps": 125, "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": { "sync": {
+387
View File
@@ -0,0 +1,387 @@
# 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**: its manifest must floor `ledmatrix_min_version` at the first core release shipping the module (recorded in `CHANGELOG.md`) — *necessary but not sufficient*. Nothing enforces that floor today, so the copy also waits for the B6 gate below. |
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
src/common/
sports_scroll.py SportsScrollDisplay / …Manager — scroll orchestration
(content building stays in the plugins)
```
`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. When a plugin needs an
ordering that core does not ship, it calls `register_rotation_strategy` to add
its own — rather than core growing a branch for it.
`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.
## Scroll display — where the promotion line falls
`src/common/sports_scroll.py` is deliberately *not* a superset of the ten
`scroll_display.py` copies. A method-level comparison of the eight that share a
shape (f1 and ufc are genuine forks) found a sharp split:
| Layer | Evidence | Outcome |
|---|---|---|
| Orchestration — `get_all_vegas_content_items`, `clear_all`, `get_scroll_info`, `get_dynamic_duration`, `is_complete`, `display_frame` | identical to 96100% similar across all eight | **promoted** |
| Settings — `_get_scroll_settings` | one algorithm; the copies differ *only* in which league keys they walk | **promoted**, with the ladder as data (`SCROLL_LEAGUE_KEYS`) |
| Content — `prepare_scroll_content`, `_load_separator_icons` | 8 distinct bodies across 8 plugins (145 lines, 53% similar at worst); icons 6% | **override point, permanently** |
Same name, different job: `prepare_scroll_content` draws *this sport's* game
card. Merging the eight bodies would be the exact mistake the promotion rule
exists to prevent, so the base class raises `NotImplementedError` rather than
rendering something plausible — a base that rendered *something* would let a
plugin ship a silently blank scroll.
The one behavior the upstreamed version adds is native
`global_config['target_fps']` support. The bundled copies hardcode ~100 FPS via
`scroll_delay = 0.01` and never consult the global smooth-scrolling target;
Part A threaded it through each copy by hand, and this makes that threading
legacy compatibility rather than the mechanism.
## Phases
B0B3 are merged and shipping in core 3.2.0. Everything that remains is
**rollout**, and it splits into three phases with very different risk profiles.
The original plan folded the last two together; they are separated here because
one of them is safe by construction and the other is not.
| Phase | Scope | Status | Gate |
|---|---|---|---|
| **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 green; no behavior change intended |
| **B2** | `CelebrationMixin` + rotation strategies as opt-in capabilities | ✅ | Non-adopters have zero new code in their MRO; strategies checked against verbatim plugin transcriptions |
| **B3** | Upstream the scroll **orchestration** layer as `src/common/sports_scroll.py`, reading `global_config['target_fps']` natively | ✅ | Content building stays per-sport |
| **B4** | Ship 3.2.0 *and* make version reporting trustworthy | ⏳ **next** | Tag, release, and `src.__version__` agree; compatibility gate merged |
| **B5** | Adoption — guarded core imports: three pilots, then the remaining six. **Bundled copies stay.** | after B4 | Per plugin: harness + goldens byte-identical, then a device soak |
| **B6** | Sunset — delete the bundled copies | **blocked** | B4's gate shipped *and* in users' hands (see below) |
### B4 — what "ship 3.2.0" actually requires
Cutting the tag is the small part. The version *number* has to become something
a floor can be trusted against, and today it is not:
- **The tag and `src.__version__` have never agreed.** `v3.1.0` was tagged
2026-05-31; `__version__` only became `"3.1.0"` on 2026-07-12 (`7f7f0d64`).
The v3.1.0 release therefore reports `__version__ = "1.0.0"`.
- **Which silences the compatibility warning entirely for that population.**
`PluginLoader._warn_if_incompatible` skips the check when the parsed core
version is below `(2, 0, 0)` — an anti-spam guard that, given the above,
matches exactly the users most likely to be behind.
- **Nothing enforces a floor anyway.** The check is advisory (it logs and
continues), and neither `StoreManager.install_plugin` nor
`StoreManager.update_plugin` compares the core version at all — `update_plugin`
compares the plugin's manifest version against the registry's
`latest_version` and nothing else.
So B4 is: tag and release 3.2.0; make the tag, the release, and `__version__`
agree, and keep them agreeing; reconsider the `< 2.0.0` skip; migrate manifests
from `ledmatrix_min` to `ledmatrix_min_version`; and add the install/update
compatibility gate that B6 depends on.
#### Two fields express compatibility, and the gate only reads one
`compatible_versions` is the canonical contract: `schema/manifest_schema.json`
**requires** it, all 42 published manifests carry it, and it holds semver
*ranges*`[">=2.0.0"]` in 41 of them, `[">=1.0.0"]` in `7-segment-clock`.
`ledmatrix_min_version` is the optional per-release floor inside `versions[]`.
The gate as merged reads only the floor. Today that is harmless: no manifest
uses an upper bound, and the two fields agree everywhere except
`7-segment-clock` (`>=1.0.0` against a `2.0.0` floor). But the fields *can*
disagree, and the range syntax the schema already permits includes upper bounds
— a plugin declaring `["2.0.0 - 2.9.9"]` means "not compatible with 3.x" and
the gate would install it on 3.2.0 regardless.
**Before B6, the gate must evaluate `compatible_versions` as well**, and the
manifest migration must reconcile the two fields rather than only renaming the
floor. Deciding which wins when they disagree is part of that work; the safe
default is the more restrictive.
(The schema also deprecates a top-level `ledmatrix_version` in favour of
`compatible_versions`. No manifest still carries it, so there is nothing to
migrate there.)
### B5 — adoption is safe by construction
A plugin adopting core imports keeps its bundled copy and reaches it through the
guarded import (see the Upgradability table above). On a core that ships the
module the plugin uses core code; on one that doesn't it falls back and behaves
exactly as it does today. There is no version of this step that breaks a user,
which is why it does not wait for B6's gate.
The hockey scroll-display pilot is **already validated**: adopted against a core
carrying 3.2.0, `scroll_display.py` went from 691 to 289 lines and all 16 harness
renders (8 sizes × 2 screens) came out byte-for-byte identical to the
pre-adoption run. That byte-comparison is the acceptance gate for every
adoption. The recipe and its two gotchas are in the plugins repo's
`docs/plugin-development/08-shared-sports-code.md`.
### B6 — why the sunset needs more than a version floor
Deleting a bundled copy removes the fallback, so the guarded import becomes a
hard dependency. On a core without the module the plugin raises
`ModuleNotFoundError` at load; `PluginManager.load_plugin` catches it, records
`PluginState.ERROR`, logs one line, and continues. Nothing crashes — the user
simply loses that scoreboard, with no visible explanation.
Verified against a `v3.1.0` worktree: `src/common/sports_scroll.py`,
`src/element_style.py` and the `src/base_classes/sports/` package are all absent
there, and the import fails with `exc.name == 'src.common.sports_scroll'`. Guard
sets must name that exact dotted path — `{"src"}` alone does not match it.
Combined with the B4 findings, a plugin that deletes its copy today reaches an
un-updated user through a normal store update, fails to load, and warns nobody.
**B6 therefore waits for B4's compatibility gate to have shipped and to have
been in users' hands long enough that the population running a core without it
is small.** The bundled copies cost disk space; deleting them early costs
scoreboards, silently. That trade is not close.
Before the first sunset, add a **compatibility regression test**. It has to
cover four cases, not one — B5's safety claim and B6's failure mode are
different propositions and only the second is obvious:
| | bundled copy present | bundled copy removed |
|---|---|---|
| **pinned old core** | **loads** — this is B5's whole guarantee, that the guarded import falls back | `PluginState.ERROR`, and the recorded error names the exact missing module |
| **current core** | loads, using core code | loads, using core code |
The top-left cell is the one worth writing first: nothing in the suite currently
proves that an adopted plugin still works on a core that predates the module,
which is the entire basis for saying B5 is safe to run ahead of the gate.
Assert the old-core/removed-copy case as `PluginState.ERROR` **plus the missing
module path**, not as an uncaught exception. `PluginManager.load_plugin` catches
`ModuleNotFoundError`, so nothing propagates — a test expecting a raise would
pass for the wrong reason on a core where the module is merely broken rather
than absent. "Fails loudly" is aspirational, not what the code does today: it
fails into `ERROR` state with one log line, which is precisely why B6 needs the
gate rather than trusting the failure to be noticed.
The same suite should exercise the install/update gate, since it is the other
half of the guarantee.
## What's next
In order. Each step is independently useful and independently revertible.
1. **Tag and publish v3.2.0.** The code is already on `main` (`21825cbf`).
Nothing else blocks this, and it is what makes `ledmatrix_min_version:
"3.2.0"` refer to something real.
2. **Make the version number honest.** Have the release process assert that the
tag, the GitHub release, and `src.__version__` agree — a check in CI is
cheaper than the confusion of the last two releases. Then revisit the
`< 2.0.0` skip in `_warn_if_incompatible`, which currently silences the
warning for the users who most need it.
3. **Add the compatibility gate** to `StoreManager.install_plugin` and
`.update_plugin`: refuse a plugin whose declared floor exceeds
`src.__version__`, and surface the reason in the store UI rather than only
the log. This is the single change that turns the floor from documentation
into a guarantee, and B6 depends on it.
4. **Migrate the manifests** to `ledmatrix_min_version`, and reconcile them with
`compatible_versions` (see above — that field is the required, canonical one,
and the gate does not read it yet). Currently 28 plugins spell the floor both
ways across their `versions[]` entries, 12 use only the old spelling, and 2
only the new. Scope the sweep to the nine sports plugins if a 42-plugin
version-bump wave isn't worth it — but the `compatible_versions` half has to
cover every manifest the gate can refuse, or define explicit legacy handling,
before the gate is allowed to block anything.
5. **Run B5 adoption** — hockey, soccer, football, then the remaining six.
Bundled copies stay. Byte-identical harness output per plugin, then a soak.
6. **Only then plan B6**, with the compatibility regression test described above
in CI first.
## How to keep this project healthy
Lessons this migration paid for, worth applying beyond it:
- **A version number is a promise; keep it in one place.** Three different
answers to "what version am I on" (tag, release, `__version__`) is what made
the floor untrustworthy. Assert their agreement mechanically.
- **Advisory checks protect nobody.** If a rule matters, enforce it where the
action happens — the install path, not a log line the user will never read.
If it doesn't matter enough to enforce, don't write the rule.
- **Prefer failures that are loud and early.** A plugin that dies at load with
one journal line is indistinguishable, to a user, from a plugin that was never
installed. Surface plugin health in the UI.
- **Keep the two repos' rules in sync deliberately.** The sunset rule lives in
both this file and the plugins repo's
`docs/plugin-development/08-shared-sports-code.md`. When one changes, change
the other in the same PR — drift between them is how a contributor ends up
following a rule that was superseded.
- **Measure before and after, on real hardware.** Byte-identical harness renders
and a device soak caught what unit tests could not. Reserve "it should be
fine" for things you have actually looked at.
## 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.
+64
View File
@@ -82,6 +82,70 @@ python3 web_interface/start.py
## Common Issues by Category ## Common Issues by Category
### Installation & Build Issues
#### Step 6 fails: "Failed building wheel for rgbmatrix"
**Symptoms:**
```
note: This error originates from a subprocess, and is likely not a problem with pip.
ERROR: Failed building wheel for rgbmatrix
Failed to build rgbmatrix
✗ Failed to install rpi-rgb-led-matrix Python package
```
**Cause:**
Almost always the kernel's out-of-memory killer, not missing build tools. The
`rpi-rgb-led-matrix` library compiles roughly 45 C++ translation units, two of
them Cython-generated — a single `cc1plus` on those can peak near 800MB. The
build system defaults to running several of those at once, which exceeds RAM on
512MB and 1GB boards. Because the OOM killer writes nothing to pip's output, the
failure looks like a toolchain problem, and `sudo apt install -y
python-dev-is-python3 cmake build-essential` will report everything is already
up to date.
**How to confirm:**
```bash
dmesg -T | grep -i "out of memory" # look for "Killed process ... (cc1plus)"
free -h # total RAM and swap
```
**Fix:**
Current versions of the installer handle this automatically: they cap build
parallelism based on available RAM and add a temporary swapfile for the build,
removing it when the build finishes. If you are on an older checkout, or the
temporary swapfile could not be created, either force a serial compile:
```bash
sudo ./first_time_install.sh --build-jobs 1
```
or add permanent swap and re-run the installer, which resumes at Step 6:
```bash
sudo apt install -y dphys-swapfile
sudo sed -i 's/^#\?CONF_SWAPSIZE=.*/CONF_SWAPSIZE=2048/' /etc/dphys-swapfile
sudo sed -i 's/^#\?CONF_MAXSWAP=.*/CONF_MAXSWAP=2048/' /etc/dphys-swapfile
sudo dphys-swapfile swapoff && sudo dphys-swapfile setup && sudo dphys-swapfile swapon
sudo ./first_time_install.sh
```
`CONF_MAXSWAP` matters: it defaults to 2048 and silently clamps `CONF_SWAPSIZE`,
so setting only `CONF_SWAPSIZE` to a larger value has no effect.
**Related:**
- The installer needs roughly 3GB free on the card to place the swapfile. If
disk is tight it will say so and skip the swapfile: `sudo apt clean` first.
- `sudo bash scripts/check_system_compatibility.sh` reports RAM and disk.
- `sudo bash scripts/diagnose_dependencies.sh` dumps build-dependency state.
---
### Web Interface & Service Issues ### Web Interface & Service Issues
#### Service Not Running/Starting #### Service Not Running/Starting
+220 -9
View File
@@ -152,6 +152,8 @@ ASSUME_YES=${LEDMATRIX_ASSUME_YES:-0}
SKIP_SOUND=${LEDMATRIX_SKIP_SOUND:-0} SKIP_SOUND=${LEDMATRIX_SKIP_SOUND:-0}
SKIP_PERF=${LEDMATRIX_SKIP_PERF:-0} SKIP_PERF=${LEDMATRIX_SKIP_PERF:-0}
SKIP_REBOOT_PROMPT=${LEDMATRIX_SKIP_REBOOT_PROMPT:-0} SKIP_REBOOT_PROMPT=${LEDMATRIX_SKIP_REBOOT_PROMPT:-0}
SKIP_SWAP=${LEDMATRIX_SKIP_SWAP:-0}
BUILD_JOBS_OVERRIDE=${LEDMATRIX_BUILD_JOBS:-}
usage() { usage() {
cat <<USAGE cat <<USAGE
@@ -163,11 +165,21 @@ Options:
--skip-sound Skip sound module configuration --skip-sound Skip sound module configuration
--skip-perf Skip performance tweaks (isolcpus/audio) --skip-perf Skip performance tweaks (isolcpus/audio)
--no-reboot-prompt Do not prompt for reboot at the end --no-reboot-prompt Do not prompt for reboot at the end
--skip-swap Never add temporary swap for the C++ build
--build-jobs N Compile the C++ library with N parallel jobs
(default: scaled to available RAM)
-h, --help Show this help message and exit -h, --help Show this help message and exit
Environment variables (same effect as flags): Environment variables (same effect as flags):
LEDMATRIX_ASSUME_YES=1, RPI_RGB_FORCE_REBUILD=1, LEDMATRIX_SKIP_SOUND=1, LEDMATRIX_ASSUME_YES=1, RPI_RGB_FORCE_REBUILD=1, LEDMATRIX_SKIP_SOUND=1,
LEDMATRIX_SKIP_PERF=1, LEDMATRIX_SKIP_REBOOT_PROMPT=1 LEDMATRIX_SKIP_PERF=1, LEDMATRIX_SKIP_REBOOT_PROMPT=1,
LEDMATRIX_SKIP_SWAP=1, LEDMATRIX_BUILD_JOBS=N
Low-memory devices:
On a Pi with under 2GB of RAM the C++ build is limited to fewer parallel
jobs and a temporary swapfile is added for the duration of the build, then
removed. Without this the compiler is killed by the kernel out-of-memory
killer on 512MB and 1GB models.
USAGE USAGE
} }
@@ -178,12 +190,38 @@ while [ $# -gt 0 ]; do
--skip-sound) SKIP_SOUND=1 ;; --skip-sound) SKIP_SOUND=1 ;;
--skip-perf) SKIP_PERF=1 ;; --skip-perf) SKIP_PERF=1 ;;
--no-reboot-prompt) SKIP_REBOOT_PROMPT=1 ;; --no-reboot-prompt) SKIP_REBOOT_PROMPT=1 ;;
--skip-swap) SKIP_SWAP=1 ;;
--build-jobs)
shift
if [ $# -eq 0 ]; then echo "--build-jobs requires a number"; usage; exit 1; fi
BUILD_JOBS_OVERRIDE="$1"
;;
-h|--help) usage; exit 0 ;; -h|--help) usage; exit 0 ;;
*) echo "Unknown option: $1"; usage; exit 1 ;; *) echo "Unknown option: $1"; usage; exit 1 ;;
esac esac
shift shift
done done
# Low-memory build helpers (job sizing, temporary swap, OOM detection).
# Sourced rather than inlined so the sizing logic can be unit-tested; if the
# file is missing we fall back to the historical behaviour rather than failing
# the install.
LOWMEM_LIB="$PROJECT_ROOT_DIR/scripts/install/lib_lowmem.sh"
LOWMEM_AVAILABLE=0
if [ -f "$LOWMEM_LIB" ]; then
# shellcheck source=scripts/install/lib_lowmem.sh
. "$LOWMEM_LIB"
LOWMEM_AVAILABLE=1
else
echo "$LOWMEM_LIB not found; skipping low-memory build protections."
lm_remove_build_swap() { return 0; }
fi
# Remove the temporary build swapfile no matter how the script ends. Step 6
# tears it down itself; this is the backstop for the error path, since
# on_error ends in `exit` and EXIT traps still run.
trap 'lm_remove_build_swap' EXIT
# Helpers # Helpers
retry() { retry() {
local attempt=1 local attempt=1
@@ -263,15 +301,144 @@ check_disk_space() {
fi fi
} }
# Decide how much memory Step 6's C++ build may use, and say so up front.
#
# Sets TOTAL_RAM_MB, TOTAL_SWAP_MB, BUILD_JOBS and LOW_RAM for later steps.
check_memory() {
command -v nproc >/dev/null 2>&1 && CPU_CORES=$(nproc) || CPU_CORES=1
# Validated up front rather than trusted: a non-numeric value would other-
# wise survive as far as an arithmetic test in Step 6 and fail there with a
# generic error. This must precede the fallback return below, which also
# honours the override.
if [ -n "$BUILD_JOBS_OVERRIDE" ]; then
if ! echo "$BUILD_JOBS_OVERRIDE" | grep -qE '^[1-9][0-9]*$'; then
echo "✗ Invalid build job count: '$BUILD_JOBS_OVERRIDE' (expected a positive integer)"
exit 1
fi
fi
if [ "$LOWMEM_AVAILABLE" != "1" ]; then
TOTAL_RAM_MB=0
TOTAL_SWAP_MB=0
LOW_RAM=0
BUILD_JOBS=${BUILD_JOBS_OVERRIDE:-$CPU_CORES}
return 0
fi
TOTAL_RAM_MB=$(lm_total_ram_mb)
TOTAL_SWAP_MB=$(lm_total_swap_mb)
# Test hook: exercise the low-memory path on a machine that has plenty.
if [ -n "${LEDMATRIX_FORCE_LOW_RAM:-}" ] && [ "${LEDMATRIX_FORCE_LOW_RAM}" != "0" ]; then
TOTAL_RAM_MB="${LEDMATRIX_FORCE_LOW_RAM}"
echo "⚠ LEDMATRIX_FORCE_LOW_RAM set: pretending this device has ${TOTAL_RAM_MB}MB of RAM"
fi
LOW_RAM=0
if [ "$TOTAL_RAM_MB" -gt 0 ] && [ "$TOTAL_RAM_MB" -lt 2048 ]; then
LOW_RAM=1
fi
if [ -n "$BUILD_JOBS_OVERRIDE" ]; then
BUILD_JOBS="$BUILD_JOBS_OVERRIDE"
else
BUILD_JOBS=$(lm_build_jobs "$TOTAL_RAM_MB" "$CPU_CORES")
fi
echo "System memory: ${TOTAL_RAM_MB}MB RAM, ${TOTAL_SWAP_MB}MB swap, ${CPU_CORES} core(s)"
if [ "$LOW_RAM" = "1" ]; then
echo "⚠ Low-memory device detected."
echo " The rpi-rgb-led-matrix C++ build in Step 6 will use ${BUILD_JOBS} parallel job(s)"
echo " instead of all cores, and a temporary swapfile will be added for the build"
echo " and removed afterwards. Without this the compiler is killed by the kernel"
echo " out-of-memory killer. Expect Step 6 to take 15-25 minutes."
if [ "$SKIP_SWAP" = "1" ]; then
echo " Temporary swap is disabled (--skip-swap); the build may still run out of memory."
fi
else
echo "✓ Memory sufficient for the rpi-rgb-led-matrix build (${BUILD_JOBS} parallel job(s))"
fi
}
# Compile and install the rgbmatrix Python package.
#
# CMAKE_BUILD_PARALLEL_LEVEL is the setting that actually caps the compile:
# upstream's pyproject.toml declares no [tool.scikit-build] options, so
# scikit-build-core drives Ninja through `cmake --build`, which reads this
# variable. Ninja's own default is nproc+2, i.e. six concurrent cc1plus
# processes on a 4-core Pi. MAKEFLAGS is ignored by Ninja and is set only to
# cover the Makefile-generator fallback if ninja-build is somehow absent.
#
# BUILD_TMPDIR redirects pip's build tree off tmpfs where applicable — see
# where it is computed in Step 6.
run_rgbmatrix_build() {
local jobs="$1" out="$2"
local pid elapsed=0
TMPDIR="${BUILD_TMPDIR:-${TMPDIR:-/tmp}}" \
CMAKE_BUILD_PARALLEL_LEVEL="$jobs" \
MAKEFLAGS="-j${jobs}" \
python3 -m pip install --break-system-packages . > "$out" 2>&1 &
pid=$!
# The build's output is captured to a file, so without a heartbeat a serial
# compile on a 1GB Pi looks like a 20-minute hang and invites a Ctrl-C.
#
# Polled at a short interval but reported every 30s: polling at the report
# interval instead would add most of that interval to the wall time of
# every build, including fast ones on a Pi 4/5.
while kill -0 "$pid" 2>/dev/null; do
sleep 2
elapsed=$((elapsed + 2))
if [ "$((elapsed % 30))" -eq 0 ] && kill -0 "$pid" 2>/dev/null; then
printf ' ... still compiling (%dm%02ds elapsed)\n' "$((elapsed / 60))" "$((elapsed % 60))"
fi
done
wait "$pid"
}
# Explain a failed rgbmatrix build. The kernel OOM killer writes nothing to the
# build's own output, which is why this used to be reported as a missing
# build-tools problem and sent users chasing packages they already had.
print_rgbmatrix_build_failure() {
local out="$1"
if [ "$LOWMEM_AVAILABLE" = "1" ] && lm_build_failed_on_oom "$out"; then
echo "✗ The rpi-rgb-led-matrix build was killed: the system ran out of memory."
echo " This is NOT a missing build-tools problem — the C++ compiler ran out of RAM."
echo " RAM: ${TOTAL_RAM_MB}MB Swap: $(lm_total_swap_mb)MB Parallel jobs used: ${BUILD_JOBS}"
if [ -n "${LM_SWAP_SKIP_REASON:-}" ]; then
echo " No temporary swap was added: ${LM_SWAP_SKIP_REASON}"
fi
echo ""
echo " Try one of these, then re-run this script (it resumes at Step 6):"
echo " 1. Force a single compile job:"
echo " sudo ./first_time_install.sh --build-jobs 1"
echo " 2. Add permanent swap, if the temporary swapfile could not be created:"
echo " sudo apt install -y dphys-swapfile"
echo " sudo sed -i 's/^#\\?CONF_SWAPSIZE=.*/CONF_SWAPSIZE=2048/' /etc/dphys-swapfile"
echo " sudo sed -i 's/^#\\?CONF_MAXSWAP=.*/CONF_MAXSWAP=2048/' /etc/dphys-swapfile"
echo " sudo dphys-swapfile swapoff && sudo dphys-swapfile setup && sudo dphys-swapfile swapon"
echo " 3. Free up disk space so a larger swapfile fits: sudo apt clean"
else
echo "✗ Failed to install rpi-rgb-led-matrix Python package"
echo " Ensure build tools are installed:"
echo " sudo apt install -y python-dev-is-python3 cmake build-essential"
fi
}
echo "" echo ""
echo "This script will perform the following steps:" echo "This script will perform the following steps:"
echo "1. Install system dependencies" echo "1. Check prerequisites (network, disk, memory) and install system dependencies"
echo "2. Fix cache permissions" echo "2. Fix cache permissions"
echo "3. Fix assets directory permissions" echo "3. Fix assets directory permissions"
echo "3.1. Fix plugin directory permissions" echo "3.1. Fix plugin directory permissions"
echo "4. Ensure configuration files exist" echo "4. Ensure configuration files exist"
echo "5. Install Python project dependencies (requirements.txt)" echo "5. Install Python project dependencies (requirements.txt)"
echo "6. Build and install rpi-rgb-led-matrix and test import" echo "6. Build and install rpi-rgb-led-matrix and test import"
echo " (compiles C++; low-memory Pis get temporary swap and a serial build)"
echo "7. Install web interface dependencies" echo "7. Install web interface dependencies"
echo "7.5. Install main LED Matrix service" echo "7.5. Install main LED Matrix service"
echo "8. Install web interface service" echo "8. Install web interface service"
@@ -315,9 +482,16 @@ echo "----------------------------------------"
# Pre-flight checks before APT operations # Pre-flight checks before APT operations
check_network check_network
check_disk_space check_disk_space
check_memory
# Update package list # Update package list. The one-shot installer refreshes the lists moments
apt_update # before invoking this script and exports LEDMATRIX_APT_UPDATED=1, so skip the
# duplicate refresh on that path.
if [ "${LEDMATRIX_APT_UPDATED:-0}" = "1" ]; then
echo "Package lists already refreshed by the one-shot installer; skipping apt update."
else
apt_update
fi
# Install required system packages # Install required system packages
echo "Installing Python packages and dependencies..." echo "Installing Python packages and dependencies..."
@@ -902,29 +1076,66 @@ else
fi fi
fi fi
# Add temporary swap on low-memory devices so the compiler survives.
CURRENT_STEP="Prepare the low-memory build environment"
if [ "$LOWMEM_AVAILABLE" = "1" ] && [ "$SKIP_SWAP" != "1" ]; then
lm_ensure_build_swap "$(lm_swap_needed_mb "$TOTAL_RAM_MB" "$TOTAL_SWAP_MB")"
elif [ "$SKIP_SWAP" = "1" ]; then
LM_SWAP_SKIP_REASON="disabled with --skip-swap"
fi
# pip builds in $TMPDIR. Debian 13 mounts /tmp as tmpfs, so the default
# would hold the entire C++ build tree in RAM — competing with the very
# compiler we are trying to keep under the memory limit.
BUILD_TMPDIR=""
if [ "$LOWMEM_AVAILABLE" = "1" ]; then
_disk_tmp=$(lm_disk_backed_tmpdir)
if [ -n "$_disk_tmp" ]; then
BUILD_TMPDIR="$_disk_tmp/ledmatrix-build"
# If this fails (a nearly-full disk being the likely cause on
# exactly the devices this targets), fall back to the default
# rather than pointing the build at a path that does not exist.
if mkdir -p "$BUILD_TMPDIR" 2>/dev/null; then
echo "Building in $BUILD_TMPDIR (TMPDIR is memory-backed; keeping the build tree on disk)"
else
echo "⚠ Could not create $BUILD_TMPDIR; falling back to the default TMPDIR"
BUILD_TMPDIR=""
fi
fi
fi
CURRENT_STEP="Build and install rpi-rgb-led-matrix"
pushd "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" >/dev/null pushd "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" >/dev/null
echo "Installing rpi-rgb-led-matrix Python package (scikit-build-core + cmake)..." echo "Installing rpi-rgb-led-matrix Python package (scikit-build-core + cmake)..."
echo " Build deps required: python-dev-is-python3 cmake" echo " Build deps required: python-dev-is-python3 cmake"
echo " This compiles C++ — may take 2-5 minutes on Pi 4/5..." echo " Compiling C++ with ${BUILD_JOBS} parallel job(s)..."
if [ "$BUILD_JOBS" -le 1 ]; then
echo " Deliberately serial to stay within this device's memory — expect 15-25 minutes."
else
echo " This may take 2-5 minutes on a Pi 4/5..."
fi
BUILD_OUTPUT=$(mktemp) BUILD_OUTPUT=$(mktemp)
BUILD_SUCCESS=false BUILD_SUCCESS=false
if python3 -m pip install --break-system-packages . > "$BUILD_OUTPUT" 2>&1; then if run_rgbmatrix_build "$BUILD_JOBS" "$BUILD_OUTPUT"; then
BUILD_SUCCESS=true BUILD_SUCCESS=true
fi fi
cat "$BUILD_OUTPUT" >> "$LOG_FILE" cat "$BUILD_OUTPUT" >> "$LOG_FILE"
if [ "$BUILD_SUCCESS" != true ]; then if [ "$BUILD_SUCCESS" != true ]; then
echo "✗ Failed to install rpi-rgb-led-matrix Python package" print_rgbmatrix_build_failure "$BUILD_OUTPUT"
echo " Ensure build tools are installed:"
echo " sudo apt install -y python-dev-is-python3 cmake build-essential"
echo "" echo ""
echo "-- Last 50 lines of build output --" echo "-- Last 50 lines of build output --"
tail -n 50 "$BUILD_OUTPUT" tail -n 50 "$BUILD_OUTPUT"
rm -f "$BUILD_OUTPUT" rm -f "$BUILD_OUTPUT"
if [ -n "$BUILD_TMPDIR" ]; then rm -rf "$BUILD_TMPDIR"; fi
popd >/dev/null popd >/dev/null
lm_remove_build_swap
exit 1 exit 1
fi fi
rm -f "$BUILD_OUTPUT" rm -f "$BUILD_OUTPUT"
if [ -n "$BUILD_TMPDIR" ]; then rm -rf "$BUILD_TMPDIR"; fi
popd >/dev/null popd >/dev/null
# Hand the memory back well before Step 14's reboot.
lm_remove_build_swap
else else
echo "✗ rpi-rgb-led-matrix-master directory not found at $PROJECT_ROOT_DIR" echo "✗ rpi-rgb-led-matrix-master directory not found at $PROJECT_ROOT_DIR"
echo "Failed to initialize submodule or clone repository" echo "Failed to initialize submodule or clone repository"
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env python3
"""Assert that a release tag, the CHANGELOG, and `src.__version__` all agree.
Run it *before* creating a tag to check yourself:
python scripts/check_release_version.py v3.2.0
Wiring it into CI (on pushed `v*` tags and published releases) is a follow-up
PR, so for now it is a manual pre-flight: run it before creating the tag and a
mismatch shows up here rather than as a silent wrong answer on user devices.
Why this exists: `v3.1.0` was tagged 2026-05-31 while `src/__init__.py` still
said `"1.0.0"`; the bump to `"3.1.0"` did not land until 2026-07-12. Devices
installed from that release report `1.0.0`, which is below the `(2, 0, 0)` floor
in `PluginLoader._warn_if_incompatible`, so they are silently exempt from every
plugin compatibility warning. Plugin `ledmatrix_min_version` floors are only as
trustworthy as this agreement. See `docs/SPORTS_UNIFICATION.md`, phase B4.
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT))
# [0-9] rather than \d, and [ \t] rather than \s: \d also matches non-ASCII
# decimal digits (which int() parses), and \s matches newlines, so "##\n3.2.0"
# would otherwise read as a version heading. Keep these in step with
# test/test_version_consistency.py.
SEMVER = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+$")
HEADING = re.compile(
r"^##[ \t]+(?P<version>[0-9]+\.[0-9]+\.[0-9]+)[ \t]*$", re.MULTILINE)
def normalize(tag: str) -> str:
"""`v3.2.0` and `3.2.0` are the same release; tags here carry the `v`."""
return tag[1:] if tag.startswith("v") else tag
def newest_changelog_version(changelog: Path) -> str | None:
"""Newest version heading, or None when there is none.
Raises OSError if the file cannot be read; main() turns that into a clear
message rather than a traceback, because this runs as a release gate and a
traceback there reads as "the tooling is broken", not "your CHANGELOG is
missing".
"""
headings = HEADING.findall(changelog.read_text(encoding="utf-8"))
return headings[0] if headings else None
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"tag",
help="Release tag to check, with or without the leading 'v' (e.g. v3.2.0)",
)
args = parser.parse_args()
from src import __version__ as core_version
tag_version = normalize(args.tag)
changelog_path = REPO_ROOT / "CHANGELOG.md"
problems: list[str] = []
try:
changelog_version = newest_changelog_version(changelog_path)
except OSError as e:
print(
f"Release version check FAILED for tag {args.tag}:\n"
f" - could not read {changelog_path}: {e}\n"
f" Restore the file (git checkout -- CHANGELOG.md) and re-run.",
file=sys.stderr,
)
return 1
if not SEMVER.match(tag_version):
problems.append(
f"tag {args.tag!r} is not vX.Y.Z. Older tags (v2.5) predate this "
"check; new releases must be full semver so floors can parse them."
)
if not SEMVER.match(core_version):
problems.append(f"src.__version__ is {core_version!r}, which is not X.Y.Z")
if tag_version != core_version:
problems.append(
f"tag says {tag_version} but src.__version__ says {core_version}. "
"Bump src/__init__.py to match the tag before releasing — devices "
"report __version__, not the tag, and plugin floors compare "
"against it."
)
if changelog_version is None:
problems.append("CHANGELOG.md has no '## X.Y.Z' version heading")
elif changelog_version != core_version:
problems.append(
f"CHANGELOG.md's newest heading is {changelog_version} but "
f"src.__version__ is {core_version}. Plugin authors read the "
"CHANGELOG to pick a ledmatrix_min_version floor."
)
if problems:
print(f"Release version check FAILED for tag {args.tag}:", file=sys.stderr)
for problem in problems:
print(f" - {problem}", file=sys.stderr)
return 1
print(
f"OK: tag {args.tag}, src.__version__ {core_version}, and the CHANGELOG "
"all agree."
)
return 0
if __name__ == "__main__":
sys.exit(main())
+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())
+6 -2
View File
@@ -156,9 +156,13 @@ echo ""
echo "6. Check disk space - building packages requires temporary space" echo "6. Check disk space - building packages requires temporary space"
echo " df -h" echo " df -h"
echo "" echo ""
echo "7. For slow builds, increase swap space:" echo "7. For slow builds or out-of-memory kills, increase swap space."
echo " first_time_install.sh already adds temporary swap on low-memory devices;"
echo " this makes it permanent. Set CONF_MAXSWAP too - it defaults to 2048 and"
echo " silently clamps CONF_SWAPSIZE, so raising CONF_SWAPSIZE alone does nothing."
echo " sudo dphys-swapfile swapoff" echo " sudo dphys-swapfile swapoff"
echo " sudo nano /etc/dphys-swapfile # Set CONF_SWAPSIZE=2048" echo " sudo sed -i 's/^#\\?CONF_SWAPSIZE=.*/CONF_SWAPSIZE=2048/' /etc/dphys-swapfile"
echo " sudo sed -i 's/^#\\?CONF_MAXSWAP=.*/CONF_MAXSWAP=2048/' /etc/dphys-swapfile"
echo " sudo dphys-swapfile setup" echo " sudo dphys-swapfile setup"
echo " sudo dphys-swapfile swapon" echo " sudo dphys-swapfile swapon"
echo "" echo ""
+283
View File
@@ -0,0 +1,283 @@
#!/bin/bash
#
# Low-memory build helpers for the LED Matrix installer.
#
# Sourced by first_time_install.sh. These live in a separate, sourceable file
# so the pure sizing/detection functions can be unit-tested
# (test/test_install_lowmem.py); first_time_install.sh itself is not sourceable
# because it self-elevates and runs top to bottom.
#
# Why this exists: the rgbmatrix build compiles ~45 C++ translation units, two
# of them Cython-generated (a single cc1plus on those peaks around 400-800MB at
# -O3). Upstream's pyproject.toml sets no [tool.scikit-build] options, so
# scikit-build-core uses Ninja at its default of nproc+2 jobs -- six concurrent
# compiles on a 4-core Pi. On a 512MB-1GB Pi the OOM killer reaps cc1plus and
# pip reports only "Failed building wheel for rgbmatrix".
#
# The caller runs under `set -Eeuo pipefail` with an ERR trap, and these are
# invoked from the middle of numbered steps, so nothing here may call exit and
# the swap helpers must always return 0.
# Overridable so tests can point at fixture files instead of /proc.
LM_MEMINFO="${LM_MEMINFO:-/proc/meminfo}"
LM_SWAPS="${LM_SWAPS:-/proc/swaps}"
# Temporary swapfile created for the build and removed afterwards. Deliberately
# never added to /etc/fstab: a malformed fstab can leave a novice with an
# unbootable Pi, and this swap only needs to outlive the compile.
LM_SWAPFILE="${LM_SWAPFILE:-/var/swap.ledmatrix-install}"
# Bring RAM + real swap up to this much before compiling, capped per swapfile.
LM_SWAP_TARGET_MB="${LM_SWAP_TARGET_MB:-3072}"
LM_SWAP_MAX_MB="${LM_SWAP_MAX_MB:-2048}"
# Worst-case cc1plus footprint on the Cython translation unit, used to size
# build parallelism against available RAM.
LM_MB_PER_JOB="${LM_MB_PER_JOB:-768}"
# Set to 1 once swap is live, so lm_remove_build_swap (wired up as an EXIT
# trap) knows whether there is anything to undo.
LM_TEMP_SWAP_ACTIVE=0
# Human-readable reason no swapfile was created, quoted back in the failure
# message so a user who still OOMs is told why the safety net was absent.
LM_SWAP_SKIP_REASON=""
# ---------------------------------------------------------------------------
# Pure helpers (no side effects; unit-tested)
# ---------------------------------------------------------------------------
# Total physical RAM in MB, or 0 if it cannot be determined.
lm_total_ram_mb() {
# Defaults are re-resolved here as well as at source time so the function
# stays safe under the installer's `set -u`.
awk '/^MemTotal:/ {printf "%d\n", $2 / 1024; found = 1; exit} END {if (!found) print 0}' \
"${LM_MEMINFO:-/proc/meminfo}" 2>/dev/null || echo 0
}
# Total swap in MB, EXCLUDING zram devices.
#
# zram swap is compressed RAM: it consumes the very resource that is already
# exhausted and does nothing for a build OOM. Counting it would let a
# zram-enabled image decide it has enough swap and then fail exactly as before.
lm_total_swap_mb() {
awk 'NR > 1 && $1 !~ /^\/dev\/zram/ {total += $3} END {printf "%d\n", total / 1024}' \
"${LM_SWAPS:-/proc/swaps}" 2>/dev/null || echo 0
}
# lm_build_jobs <ram_mb> <cores> -> max(1, min(cores, ram_mb / LM_MB_PER_JOB))
#
# Computed from RAM alone and never RAM+swap: handing out extra jobs because
# swap exists just guarantees SD-card thrash, which is far slower than
# compiling serially.
lm_build_jobs() {
local ram_mb="${1:-0}" cores="${2:-1}" jobs
local per_job="${LM_MB_PER_JOB:-768}"
if [ "$cores" -lt 1 ]; then
cores=1
fi
jobs=$(( ram_mb / per_job ))
if [ "$jobs" -lt 1 ]; then
jobs=1
fi
if [ "$jobs" -gt "$cores" ]; then
jobs="$cores"
fi
echo "$jobs"
}
# lm_swap_needed_mb <ram_mb> <existing_swap_mb> -> swapfile size in MB, or 0.
#
# Brings RAM + real swap up to LM_SWAP_TARGET_MB, capped at LM_SWAP_MAX_MB and
# rounded up to a 256MB multiple. Machines with enough memory get 0 and are
# left completely untouched.
lm_swap_needed_mb() {
local ram_mb="${1:-0}" swap_mb="${2:-0}" needed
local target="${LM_SWAP_TARGET_MB:-3072}" max="${LM_SWAP_MAX_MB:-2048}"
needed=$(( target - ram_mb - swap_mb ))
if [ "$needed" -le 0 ]; then
echo 0
return 0
fi
if [ "$needed" -gt "$max" ]; then
needed="$max"
fi
echo $(( ( (needed + 255) / 256 ) * 256 ))
}
# lm_build_failed_on_oom <build_output_file> -> 0 if the build was OOM-killed.
#
# Two independent evidence sources, because neither alone is reliable: the
# compiler sometimes reports its own allocation failure, but when the kernel
# OOM killer fires it writes nothing to the build's stdout. That silence is
# exactly why the old handler misdiagnosed this as missing build tools.
lm_build_failed_on_oom() {
local build_output="${1:-}" kernel_log=""
if [ -n "$build_output" ] && [ -f "$build_output" ]; then
if grep -qiE 'cc1plus: out of memory|virtual memory exhausted|Cannot allocate memory|MemoryError|fatal error: Killed signal terminated program|signal 9' \
"$build_output"; then
return 0
fi
fi
# LM_KERNEL_LOG_FILE lets tests supply a fixture instead of the real kernel
# ring buffer, which on a shared CI machine may hold unrelated OOM events.
if [ -n "${LM_KERNEL_LOG_FILE:-}" ]; then
if [ -f "$LM_KERNEL_LOG_FILE" ]; then
kernel_log=$(cat "$LM_KERNEL_LOG_FILE" 2>/dev/null || true)
fi
elif command -v dmesg >/dev/null 2>&1; then
kernel_log=$(dmesg -T 2>/dev/null || dmesg 2>/dev/null || true)
fi
if [ -z "$kernel_log" ] && [ -z "${LM_KERNEL_LOG_FILE:-}" ] && command -v journalctl >/dev/null 2>&1; then
kernel_log=$(journalctl -k --since "30 min ago" --no-pager 2>/dev/null || true)
fi
if [ -n "$kernel_log" ]; then
if printf '%s\n' "$kernel_log" | tail -n 300 | \
grep -qiE 'Out of memory: Kill|oom_kill|oom-kill|Killed process'; then
return 0
fi
fi
return 1
}
# lm_disk_backed_tmpdir [candidate] -> a disk-backed temp dir, or nothing.
#
# pip builds in $TMPDIR. Debian 13 mounts /tmp as tmpfs, so the default puts the
# whole C++ build tree in RAM, competing with the compiler we are already trying
# to keep under the limit. Prints a replacement only when the current TMPDIR is
# memory-backed and the candidate is not; otherwise prints nothing and the
# caller keeps its default.
lm_disk_backed_tmpdir() {
local candidate="${1:-/var/tmp}"
local current="${TMPDIR:-/tmp}"
local current_fs="" candidate_fs=""
current_fs=$(lm_fstype_of "$current")
case "$current_fs" in
tmpfs|ramfs) ;;
*) return 0 ;;
esac
candidate_fs=$(lm_fstype_of "$candidate")
case "$candidate_fs" in
tmpfs|ramfs|"") return 0 ;;
esac
echo "$candidate"
}
# Filesystem type backing a path, or empty if it cannot be determined.
lm_fstype_of() {
local path="${1:-/}"
if command -v findmnt >/dev/null 2>&1; then
findmnt -no FSTYPE --target "$path" 2>/dev/null | head -n 1
return 0
fi
if command -v stat >/dev/null 2>&1; then
stat -f -c %T "$path" 2>/dev/null | head -n 1
return 0
fi
return 0
}
# ---------------------------------------------------------------------------
# Swap management (requires root; not unit-tested)
# ---------------------------------------------------------------------------
# lm_ensure_build_swap <needed_mb>
#
# Always returns 0. On any refusal it sets LM_SWAP_SKIP_REASON and leaves the
# system untouched -- swap is a safety net for the build, never a precondition.
lm_ensure_build_swap() {
local needed_mb="${1:-0}"
local swap_dir free_mb budget
LM_SWAP_SKIP_REASON=""
if [ "$needed_mb" -le 0 ]; then
LM_SWAP_SKIP_REASON="not needed (RAM and existing swap are sufficient)"
return 0
fi
if ! command -v mkswap >/dev/null 2>&1 || ! command -v swapon >/dev/null 2>&1; then
LM_SWAP_SKIP_REASON="mkswap/swapon are not available on this system"
echo "⚠ Cannot add build swap: $LM_SWAP_SKIP_REASON"
return 0
fi
# Clear a stale swapfile left by a run that was killed before its cleanup
# ran, so this is safe to call repeatedly.
if [ -e "$LM_SWAPFILE" ]; then
echo "Removing a leftover swapfile from a previous run: $LM_SWAPFILE"
swapoff "$LM_SWAPFILE" >/dev/null 2>&1 || true
rm -f "$LM_SWAPFILE" || true
fi
# Keep a working margin for the build tree itself; never eat the last GB.
swap_dir=$(dirname "$LM_SWAPFILE")
free_mb=$(df -m "$swap_dir" 2>/dev/null | awk 'NR==2{print $4}')
free_mb=${free_mb:-0}
budget=$(( free_mb - 1024 ))
if [ "$budget" -lt 256 ]; then
LM_SWAP_SKIP_REASON="only ${free_mb}MB free on ${swap_dir}, need about $(( needed_mb + 1024 ))MB"
echo "⚠ Skipping the build swapfile: $LM_SWAP_SKIP_REASON"
return 0
fi
if [ "$needed_mb" -gt "$budget" ]; then
echo "⚠ Trimming the build swapfile from ${needed_mb}MB to leave 1GB free on ${swap_dir}"
needed_mb=$(( ( budget / 256 ) * 256 ))
fi
echo "Adding a temporary ${needed_mb}MB swapfile for the build: $LM_SWAPFILE"
echo " This is removed automatically once the build finishes."
# fallocate can produce a sparse file that mkswap rejects, and is not
# supported on every filesystem; dd always yields a usable file.
if ! fallocate -l "${needed_mb}M" "$LM_SWAPFILE" 2>/dev/null; then
if ! dd if=/dev/zero of="$LM_SWAPFILE" bs=1M count="$needed_mb" status=none 2>/dev/null; then
LM_SWAP_SKIP_REASON="could not allocate ${needed_mb}MB at $LM_SWAPFILE"
echo "$LM_SWAP_SKIP_REASON"
rm -f "$LM_SWAPFILE" || true
return 0
fi
fi
chmod 600 "$LM_SWAPFILE" || true
if ! mkswap "$LM_SWAPFILE" >/dev/null 2>&1; then
LM_SWAP_SKIP_REASON="mkswap failed on $LM_SWAPFILE"
echo "$LM_SWAP_SKIP_REASON"
rm -f "$LM_SWAPFILE" || true
return 0
fi
if ! swapon "$LM_SWAPFILE" >/dev/null 2>&1; then
LM_SWAP_SKIP_REASON="swapon failed on $LM_SWAPFILE"
echo "$LM_SWAP_SKIP_REASON"
rm -f "$LM_SWAPFILE" || true
return 0
fi
LM_TEMP_SWAP_ACTIVE=1
echo "✓ Temporary build swap active (${needed_mb}MB; total swap is now $(lm_total_swap_mb)MB)"
return 0
}
# Remove the temporary swapfile. Safe to call unconditionally and repeatedly.
#
# Wired up as an EXIT trap, so it must never return non-zero -- a failing trap
# would surface as a spurious installer error.
lm_remove_build_swap() {
if [ "${LM_TEMP_SWAP_ACTIVE:-0}" != "1" ]; then
return 0
fi
LM_TEMP_SWAP_ACTIVE=0
echo "Removing the temporary build swapfile: $LM_SWAPFILE"
swapoff "$LM_SWAPFILE" >/dev/null 2>&1 || true
rm -f "$LM_SWAPFILE" || true
return 0
}
+39 -3
View File
@@ -145,6 +145,34 @@ check_disk_space() {
fi fi
} }
# Report available memory so the user knows what to expect before the wait.
#
# Informational only — first_time_install.sh does the real work of capping
# build parallelism and adding temporary swap. Never fatal: a low-RAM Pi is
# supported, it is just slower.
check_memory() {
CURRENT_STEP="Memory check"
if [ ! -r /proc/meminfo ]; then
print_warning "Cannot read /proc/meminfo, skipping memory check"
return 0
fi
TOTAL_RAM_MB=$(awk '/^MemTotal:/ {printf "%d\n", $2 / 1024; exit}' /proc/meminfo 2>/dev/null || echo 0)
TOTAL_RAM_MB=${TOTAL_RAM_MB:-0}
if [ "$TOTAL_RAM_MB" -eq 0 ]; then
print_warning "Could not determine system memory, continuing"
elif [ "$TOTAL_RAM_MB" -lt 2048 ]; then
print_warning "Low memory: ${TOTAL_RAM_MB}MB RAM"
echo " The rpi-rgb-led-matrix C++ build needs more memory than this Pi has."
echo " The installer will compile with fewer parallel jobs and add a temporary"
echo " swapfile for the build, removing it afterwards. That step will take"
echo " 15-25 minutes rather than the usual 2-5."
else
print_success "Memory sufficient: ${TOTAL_RAM_MB}MB RAM"
fi
}
# Ensure sudo access # Ensure sudo access
check_sudo() { check_sudo() {
CURRENT_STEP="Sudo access check" CURRENT_STEP="Sudo access check"
@@ -204,7 +232,7 @@ main() {
print_step "LED Matrix One-Shot Installation" print_step "LED Matrix One-Shot Installation"
echo "This script will:" echo "This script will:"
echo " 1. Check prerequisites (network, disk space, sudo)" echo " 1. Check prerequisites (network, disk space, memory, sudo)"
echo " 2. Install system dependencies (git, python3, build tools)" echo " 2. Install system dependencies (git, python3, build tools)"
echo " 3. Clone the LEDMatrix repository" echo " 3. Clone the LEDMatrix repository"
echo " 4. Run the first-time installation script" echo " 4. Run the first-time installation script"
@@ -213,6 +241,7 @@ main() {
# Check prerequisites # Check prerequisites
check_network check_network
check_disk_space check_disk_space
check_memory
check_sudo check_sudo
# Note: /tmp permissions are checked and fixed inline before running first_time_install.sh # Note: /tmp permissions are checked and fixed inline before running first_time_install.sh
# (only if actually wrong, not preemptively) # (only if actually wrong, not preemptively)
@@ -228,12 +257,14 @@ main() {
exit 1 exit 1
fi fi
# Update package list first # Update package list first. first_time_install.sh is told the lists are
# already fresh so it does not repeat this a minute later.
if [ "$EUID" -eq 0 ]; then if [ "$EUID" -eq 0 ]; then
retry apt-get update -qq retry apt-get update -qq
else else
retry sudo apt-get update -qq retry sudo apt-get update -qq
fi fi
export LEDMATRIX_APT_UPDATED=1
# Install git and curl (needed for cloning and the script itself) # Install git and curl (needed for cloning and the script itself)
if ! command -v git >/dev/null 2>&1 || ! command -v curl >/dev/null 2>&1; then if ! command -v git >/dev/null 2>&1 || ! command -v curl >/dev/null 2>&1; then
@@ -372,7 +403,12 @@ main() {
# Pass both -y flag AND environment variable for non-interactive mode # Pass both -y flag AND environment variable for non-interactive mode
# This ensures it works even if the script re-executes itself with sudo # This ensures it works even if the script re-executes itself with sudo
# Also ensure stdin is properly handled for non-interactive mode # Also ensure stdin is properly handled for non-interactive mode
sudo -E env TMPDIR=/tmp LEDMATRIX_ASSUME_YES=1 bash ./first_time_install.sh -y </dev/null # LEDMATRIX_APT_UPDATED is passed explicitly rather than relying on
# -E: a sudoers env_reset/env_keep policy can strip exported variables,
# which would silently reinstate the duplicate apt update.
sudo -E env TMPDIR=/tmp LEDMATRIX_ASSUME_YES=1 \
LEDMATRIX_APT_UPDATED="${LEDMATRIX_APT_UPDATED:-0}" \
bash ./first_time_install.sh -y </dev/null
fi fi
INSTALL_EXIT_CODE=$? INSTALL_EXIT_CODE=$?
trap 'on_error $LINENO' ERR # Re-enable ERR trap trap 'on_error $LINENO' ERR # Re-enable ERR trap
+1 -1
View File
@@ -4,5 +4,5 @@ LEDMatrix Display System
Core source package for the LED Matrix Display project. Core source package for the LED Matrix Display project.
""" """
__version__ = "3.1.0" __version__ = "3.2.0"
+14 -3
View File
@@ -151,7 +151,12 @@ class Baseball(SportsCore):
# Only log detailed information for favorite teams # Only log detailed information for favorite teams
if is_favorite_game: if is_favorite_game:
self.logger.debug(f"Full status data: {game_event['status']}") # Use the validated competition-level `status` here too. MiLB
# events carry no event-level one, so this debug line raised a
# KeyError and dropped the very games it was meant to help
# diagnose -- and only for favourites, which is the worst way
# for it to fail.
self.logger.debug(f"Full status data: {status}")
self.logger.debug(f"Status type: {game_status}, State: {status_state}") self.logger.debug(f"Status type: {game_status}, State: {status_state}")
self.logger.debug(f"Status detail: {status['type'].get('detail', '')}") self.logger.debug(f"Status detail: {status['type'].get('detail', '')}")
self.logger.debug( self.logger.debug(
@@ -164,7 +169,13 @@ class Baseball(SportsCore):
# Get game state information # Get game state information
if status_state == "in": if status_state == "in":
# For live games, get detailed state # 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 "period", 1
) # Get inning from status period ) # Get inning from status period
@@ -187,7 +198,7 @@ class Baseball(SportsCore):
if "end" in status_detail or "end" in status_short: if "end" in status_detail or "end" in status_short:
inning_half = "top" inning_half = "top"
inning = ( inning = (
game_event["status"].get("period", 1) + 1 status.get("period", 1) + 1
) # Use period and increment for next inning ) # Use period and increment for next inning
if is_favorite_game: if is_favorite_game:
self.logger.debug( self.logger.debug(
+11 -4
View File
@@ -38,10 +38,17 @@ class Hockey(SportsCore):
status = competition["status"] status = competition["status"]
powerplay = False powerplay = False
penalties = "" 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( home_team_saves = next(
( (
int(c["displayValue"]) int(c["displayValue"])
for c in home_team["statistics"] for c in home_stats
if c.get("name") == "saves" if c.get("name") == "saves"
), ),
0, 0,
@@ -49,7 +56,7 @@ class Hockey(SportsCore):
home_team_saves_per = next( home_team_saves_per = next(
( (
float(c["displayValue"]) float(c["displayValue"])
for c in home_team["statistics"] for c in home_stats
if c.get("name") == "savePct" if c.get("name") == "savePct"
), ),
0.0, 0.0,
@@ -57,7 +64,7 @@ class Hockey(SportsCore):
away_team_saves = next( away_team_saves = next(
( (
int(c["displayValue"]) int(c["displayValue"])
for c in away_team["statistics"] for c in away_stats
if c.get("name") == "saves" if c.get("name") == "saves"
), ),
0, 0,
@@ -65,7 +72,7 @@ class Hockey(SportsCore):
away_team_saves_per = next( away_team_saves_per = next(
( (
float(c["displayValue"]) float(c["displayValue"])
for c in away_team["statistics"] for c in away_stats
if c.get("name") == "savePct" if c.get("name") == "savePct"
), ),
0.0, 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,418 @@
"""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)
# Coerced and floored at init: this value is compared numerically on the
# display path, where a string from a hand-edited config would raise
# TypeError outside any try block, and a zero or negative value would
# arm a celebration that can never render.
raw_duration = mode_config.get("celebration_duration", 8)
try:
self.celebration_duration = max(1.0, float(raw_duration))
except (TypeError, ValueError):
self.logger.warning(
"[Celebrations] Unusable celebration_duration %r; using 8s. "
"Set a positive number of seconds.",
raw_duration,
)
self.celebration_duration = 8.0
# 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 prune_score_baselines(self, live_games: List[Dict]) -> None:
"""Drop baselines for games no longer live.
Only :meth:`_check_for_win` removes entries, and it only fires for games
seen to go final. A game that vanishes from the live list any other way
— postponed, dropped by the feed, or simply still live when the board
restarts — leaves its baseline behind forever, so on a board that runs
all season the dict grows without bound.
Call this from ``update()`` with the current live set, alongside the
equivalent pruning in :meth:`SmoothWeightedRotation.next_game`.
"""
live_ids = {g.get("id") for g in live_games}
self._score_baselines = {
gid: baseline
for gid, baseline in self._score_baselines.items()
if gid in live_ids
}
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"[Celebrations] {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"[Celebrations] 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 self.has_active_celebration():
try:
self._draw_celebration_layout(celebration, force_clear)
return True
except Exception as e:
self.logger.error(
f"[Celebrations] Error drawing celebration: {e}", exc_info=True
)
# Disarm rather than retry: the same render would fail on
# every frame for the rest of the window, logging a
# traceback each time and leaving the scorebug off screen.
self.active_celebration = None
self.last_game_switch = time.time()
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,246 @@
"""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 = ""
#: Ceiling on a per-game weight. A cycle is ``sum(weights)`` long and each
#: step scans every game, so an unbounded weight — a misread config field,
#: say — would spin the display thread for an unbounded time. On a Pi that
#: stalls rendering outright, so the bound is clamped like the floor is.
MAX_WEIGHT = 16
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. It is clamped down at
:attr:`MAX_WEIGHT` for the reason documented there.
"""
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] = min(self.MAX_WEIGHT, 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 []
# type(self), not this class: a subclass that overrides next_game must
# be previewed through its own ordering, or the returned order is not
# the one repeated next_game calls would produce — which is exactly
# what this method promises.
preview = type(self)(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``.
When a plugin needs an ordering that core does not ship, it registers its
own here instead of 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")
# Fail at registration, not at the first schedule() call several frames
# later, where the cause is no longer on the stack.
if not (isinstance(factory, type) and issubclass(factory, RotationStrategy)):
raise TypeError(
f"rotation strategy {name!r} must be a RotationStrategy subclass, "
f"got {factory!r}"
)
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
File diff suppressed because it is too large Load Diff
+196 -5
View File
@@ -112,7 +112,8 @@ class ScrollHelper:
def create_scrolling_image(self, content_items: list, def create_scrolling_image(self, content_items: list,
item_gap: int = 32, 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. Create a wide image containing all content items for scrolling.
@@ -120,10 +121,19 @@ class ScrollHelper:
content_items: List of PIL Images to include in scroll content_items: List of PIL Images to include in scroll
item_gap: Gap between different items item_gap: Gap between different items
element_gap: Gap between elements within an item 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: Returns:
PIL Image containing all content arranged horizontally 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: if not content_items:
# Create empty image if no content # Create empty image if no content
# Still set total_scroll_width to 0 to indicate no scrollable 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) total_width += element_gap * len(content_items)
# Add initial gap before first item # Add initial gap before first item
total_width += self.display_width total_width += lead_gap
# Create the full scrolling image # Create the full scrolling image
full_image = Image.new('RGB', (total_width, self.display_height), (0, 0, 0)) full_image = Image.new('RGB', (total_width, self.display_height), (0, 0, 0))
# Position items # 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): for i, img in enumerate(content_items):
# Paste the item image # Paste the item image
@@ -339,13 +349,72 @@ class ScrollHelper:
if not self.cached_image or self.cached_array is None: if not self.cached_image or self.cached_array is None:
return None return None
# Use integer pixel positioning for high FPS scrolling (like stock ticker)
start_x_int = int(self.scroll_position) start_x_int = int(self.scroll_position)
end_x_int = start_x_int + self.display_width 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) 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: def _get_visible_portion_integer(self, start_x: int, end_x: int) -> Image.Image:
"""Fast integer pixel extraction (no interpolation). """Fast integer pixel extraction (no interpolation).
@@ -638,6 +707,128 @@ class ScrollHelper:
""" """
return self.scroll_complete 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: def reset_scroll(self) -> None:
""" """
Reset scroll position to beginning. Reset scroll position to beginning.
+485
View File
@@ -0,0 +1,485 @@
"""Shared scroll-display scaffolding for the sports scoreboards.
Ten plugins ship a `scroll_display.py`. A method-level comparison of the eight
that share a shape (f1 and ufc are genuine forks) found a sharp split, and this
module is drawn along it rather than around all of it:
* The **orchestration layer is converged** ``get_all_vegas_content_items`` is
byte-identical in all eight, and ``clear_all``, ``get_scroll_info``,
``get_dynamic_duration``, ``is_complete`` and ``display_frame`` are 96-100%
similar. That is what lives here.
* The **content layer has genuinely diverged** ``prepare_scroll_content`` has
eight distinct bodies across eight plugins (145 lines, 53% similarity at
worst) and ``_load_separator_icons`` seven (6% at worst). Those build each
sport's game cards and icon strip; they are *not* drift to be merged but
per-sport rendering. They stay override points here, permanently.
Promoting the content layer would be exactly the mistake
``docs/SPORTS_UNIFICATION.md`` warns against merging on the intuition that
same-named methods are the same method. Same name, different job.
The one behavior this module adds over the plugin copies is native support for
``global_config['target_fps']``: the bundled copies hardcode ~100 FPS via
``scroll_delay=0.01`` and never consult the global smooth-scrolling target. A
plugin inheriting from here gets it for free.
Usage::
class HockeyScrollDisplay(SportsScrollDisplay):
SCROLL_LEAGUE_KEYS = ("nhl", "ncaa_mens", "ncaam_hockey")
def prepare_scroll_content(self, games, game_type, leagues, rankings=None):
... # build this sport's cards
class HockeyScrollDisplayManager(SportsScrollDisplayManager):
display_class = HockeyScrollDisplay
"""
from __future__ import annotations
import logging
import time
from typing import Any, Dict, List, Optional
from PIL import Image
from src.common.scroll_helper import ScrollHelper
logger = logging.getLogger(__name__)
#: Defaults every copy agreed on. A subclass overrides
#: :meth:`SportsScrollDisplay.scroll_settings_defaults` to change them —
#: the soccer lineage uses a 24px gap and min/max duration keys instead.
DEFAULT_SCROLL_SETTINGS: Dict[str, Any] = {
"scroll_speed": 50.0,
"scroll_delay": 0.01,
"gap_between_games": 48,
"show_league_separators": True,
"dynamic_duration": True,
}
#: Bounds on the px/second -> px/frame conversion, applied before the helper
#: sees the value. FPS is *not* clamped here — ScrollHelper.set_target_fps
#: already does that, and a second copy of the range would drift from it.
MIN_PIXELS_PER_FRAME = 0.1
MAX_PIXELS_PER_FRAME = 5.0
#: Pacing to assume when scroll_delay is 0, i.e. the plugin has not set one.
ASSUMED_FPS_WHEN_UNPACED = 100.0
class SportsScrollDisplay:
"""One scrolling strip of game cards.
Subclasses supply the content (:meth:`prepare_scroll_content`) and,
optionally, the per-sport league ladder and separator icons. Everything
else helper configuration, frame pumping, completion, state is here.
"""
#: Config keys to walk when looking for per-league ``scroll_settings``,
#: most-preferred first. A sport's own league names, which is the *only*
#: reason the eight copies of ``_get_scroll_settings`` differ. Empty means
#: the plugin has no per-league scroll settings.
SCROLL_LEAGUE_KEYS: tuple = ()
#: Config block holding scroll settings when the plugin keeps them in one
#: place rather than per league (the afl/nrl/soccer shape).
SCROLL_CONFIG_KEY: Optional[str] = None
def __init__(
self,
display_manager,
config: Dict[str, Any],
custom_logger: Optional[logging.Logger] = None,
global_config: Optional[Dict[str, Any]] = None,
):
"""
:param display_manager: the core display manager
:param config: the plugin's configuration
:param custom_logger: the plugin's logger, so scroll lines are attributed
:param global_config: the LEDMatrix global config the source of
``target_fps``. Optional so an older caller that does not pass it
keeps working at the config-derived pacing.
"""
self.display_manager = display_manager
self.config = config
self.logger = custom_logger or logger
self.global_config = global_config or {}
if getattr(display_manager, "matrix", None) is not None:
self.display_width = display_manager.matrix.width
self.display_height = display_manager.matrix.height
else:
self.display_width = getattr(display_manager, "width", 128)
self.display_height = getattr(display_manager, "height", 32)
self.scroll_helper = ScrollHelper(
self.display_width, self.display_height, self.logger
)
self._configure_scroll_helper()
self._logo_cache: Dict[str, Image.Image] = {}
self._separator_icons: Dict[str, Image.Image] = {}
self._load_separator_icons()
self._current_games: List[Dict] = []
self._current_game_type: str = ""
self._current_leagues: List[str] = []
self._vegas_content_items: List[Image.Image] = []
self._is_scrolling = False
self._scroll_start_time: Optional[float] = None
self._last_log_time: float = 0
self._log_interval: float = 5.0
self._frame_count: int = 0
self._fps_sample_start: float = time.time()
# ------------------------------------------------------------------
# Override points
# ------------------------------------------------------------------
def prepare_scroll_content(
self,
games: List[Dict],
game_type: str,
leagues: List[str],
rankings_cache: Optional[Dict[str, int]] = None,
) -> bool:
"""Render ``games`` into one wide image and hand it to the scroll helper.
**Per-sport by nature, not by drift** the eight plugin copies have
eight different bodies because each draws its own card. Implementations
build the strip, hand it over with
``self.scroll_helper.set_scrolling_image(...)`` (or
``create_scrolling_image(...)`` from a list of cards), and record
``self._current_games`` / ``_current_game_type`` / ``_current_leagues``.
:returns: True when there is content to scroll.
"""
raise NotImplementedError(
f"{type(self).__name__} must implement prepare_scroll_content(); "
"it builds this sport's game cards and is not shared code."
)
def _load_separator_icons(self) -> None:
"""Populate ``self._separator_icons``. Per-sport; no-op by default."""
def scroll_settings_defaults(self) -> Dict[str, Any]:
"""The baseline scroll settings before any config is applied."""
defaults = dict(DEFAULT_SCROLL_SETTINGS)
defaults["game_card_width"] = self.display_width
return defaults
# ------------------------------------------------------------------
# Settings
# ------------------------------------------------------------------
def _get_scroll_settings(self, league: Optional[str] = None) -> Dict[str, Any]:
"""Resolve scroll settings: defaults, then the most specific override.
Precedence: the named ``league``, then each entry of
:attr:`SCROLL_LEAGUE_KEYS` in order, then :attr:`SCROLL_CONFIG_KEY`.
The eight plugin copies implement exactly this and differ only in which
league names they walk which is why the ladder is data here rather
than a body per sport.
"""
settings = self.scroll_settings_defaults()
candidates: List[str] = []
if league:
candidates.append(league)
candidates.extend(self.SCROLL_LEAGUE_KEYS)
for key in candidates:
override = (self.config.get(key) or {}).get("scroll_settings")
if override:
return {**settings, **override}
if self.SCROLL_CONFIG_KEY:
override = self.config.get(self.SCROLL_CONFIG_KEY) or {}
if override:
return {**settings, **override}
return settings
def _resolve_target_fps(self) -> Optional[float]:
"""The global smooth-scrolling FPS target, or None to keep config pacing.
Coerced before use: a malformed value in the global config must degrade
to the existing ``scroll_delay`` pacing, never raise on a display path.
"""
raw = self.global_config.get("target_fps") or self.global_config.get(
"scroll_target_fps"
)
try:
return float(raw) if raw is not None else None
except (TypeError, ValueError):
self.logger.debug("Ignoring unusable target_fps: %r", raw)
return None
def _coerce_float(self, value: Any, default: float) -> float:
"""A usable float from config, or ``default``.
``dict.get(key, default)`` only helps when the key is *absent*; a key
present with ``null`` or a string returns that value verbatim and blows
up in the arithmetic below inside ``__init__``, so the whole display
fails to construct.
"""
if value is None:
return default
try:
return float(value)
except (TypeError, ValueError):
self.logger.warning(
"Ignoring unusable scroll setting %r; using %s", value, default
)
return default
def _configure_scroll_helper(self) -> None:
"""Apply config to the scroll helper. Safe to call again after a change."""
settings = self._get_scroll_settings()
scroll_speed = self._coerce_float(settings.get("scroll_speed"), 50.0)
scroll_delay = self._coerce_float(settings.get("scroll_delay"), 0.01)
dynamic_duration = bool(settings.get("dynamic_duration", True))
self.scroll_helper.set_scroll_delay(scroll_delay)
self.scroll_helper.set_dynamic_duration_settings(
enabled=dynamic_duration,
min_duration=settings.get("min_duration", 30),
max_duration=settings.get("max_duration", 600),
buffer=0.2, # ensure the strip clears the panel completely
)
# Frame-based scrolling: motion advances per rendered frame rather than
# per wall-clock second, which is what makes the pacing stable.
self.scroll_helper.set_frame_based_scrolling(True)
# Config states speed in px/second; frame-based mode wants px/frame.
if scroll_delay > 0:
pixels_per_frame = scroll_speed * scroll_delay
else:
pixels_per_frame = scroll_speed / ASSUMED_FPS_WHEN_UNPACED
pixels_per_frame = max(
MIN_PIXELS_PER_FRAME, min(MAX_PIXELS_PER_FRAME, pixels_per_frame)
)
self.scroll_helper.set_scroll_speed(pixels_per_frame)
effective_pps = (
pixels_per_frame / scroll_delay
if scroll_delay > 0
else pixels_per_frame * ASSUMED_FPS_WHEN_UNPACED
)
self.logger.info(
f"ScrollHelper configured: {pixels_per_frame:.2f} px/frame, "
f"delay={scroll_delay}s (effective {effective_pps:.1f} px/s from "
f"{scroll_speed} px/s config), dynamic_duration={dynamic_duration}"
)
# The reason this module exists upstream: the bundled copies hardcode
# ~100 FPS via scroll_delay and never consult the global target.
# No hasattr guard here, unlike the plugin copies: they probe because
# they may run against an older core, whereas this module ships in the
# same release as the ScrollHelper it calls. The helper clamps.
target_fps = self._resolve_target_fps()
if target_fps:
self.scroll_helper.set_target_fps(target_fps)
self.logger.info(f"Target FPS set to {target_fps}")
# ------------------------------------------------------------------
# Frame pumping
# ------------------------------------------------------------------
def display_scroll_frame(self) -> bool:
"""Advance and render one frame.
:returns: True if a frame was drawn; False when there is no content or
the frame could not be rendered.
"""
if not self.scroll_helper.cached_image:
return False
try:
# Inside the try, not before it: advancing the position and cropping
# the visible slice are as capable of raising as the display push,
# and the promise below is that no frame failure reaches the
# plugin's loop.
self.scroll_helper.update_scroll_position()
visible = self.scroll_helper.get_visible_portion()
if not visible:
return False
self.display_manager.image = visible
self.display_manager.update_display()
self._frame_count += 1
self.scroll_helper.log_frame_rate()
self._log_scroll_progress()
except Exception:
# A display failure must not propagate into the plugin's loop.
self.logger.exception("Error displaying scroll frame")
return False
return True
def _log_scroll_progress(self) -> None:
"""Emit a throttled progress line."""
now = time.time()
if now - self._last_log_time < self._log_interval:
return
self._last_log_time = now
elapsed = now - self._fps_sample_start
fps = self._frame_count / elapsed if elapsed > 0 else 0.0
self.logger.debug(
f"Scrolling {len(self._current_games)} {self._current_game_type} "
f"game(s) at {fps:.1f} FPS"
)
def is_scroll_complete(self) -> bool:
"""True when the strip has scrolled fully past the panel."""
return self.scroll_helper.is_scroll_complete()
def reset_scroll(self) -> None:
"""Return the strip to its starting position, keeping the content."""
self.scroll_helper.reset_scroll()
self._frame_count = 0
self._fps_sample_start = time.time()
self.logger.debug("Scroll position reset")
def clear(self) -> None:
"""Drop cached content and reset tracking state."""
self.scroll_helper.clear_cache()
self._current_games = []
self._current_game_type = ""
self._current_leagues = []
self._vegas_content_items = []
self._is_scrolling = False
self._scroll_start_time = None
self.logger.debug("Scroll display cleared")
# ------------------------------------------------------------------
# Introspection
# ------------------------------------------------------------------
def get_dynamic_duration(self) -> int:
"""How long this content needs to scroll fully, in seconds."""
return self.scroll_helper.get_dynamic_duration()
def has_cached_content(self) -> bool:
"""Whether content is prepared and ready to scroll."""
return bool(self.scroll_helper.cached_image)
def get_current_game_count(self) -> int:
return len(self._current_games)
def get_current_leagues(self) -> List[str]:
return list(self._current_leagues)
def get_scroll_info(self) -> Dict[str, Any]:
"""Helper state plus this display's tracking state, for logging/debug."""
info = self.scroll_helper.get_scroll_info()
info.update(
{
"game_count": len(self._current_games),
"game_type": self._current_game_type,
"leagues": self._current_leagues,
"is_scrolling": self._is_scrolling,
}
)
return info
class SportsScrollDisplayManager:
"""One :class:`SportsScrollDisplay` per game type ('live'/'recent'/'upcoming').
Subclasses set :attr:`display_class`; everything else was near-identical
across the eight plugin copies.
"""
#: The SportsScrollDisplay subclass to instantiate per game type.
display_class = SportsScrollDisplay
def __init__(
self,
display_manager,
config: Dict[str, Any],
custom_logger: Optional[logging.Logger] = None,
global_config: Optional[Dict[str, Any]] = None,
):
self.display_manager = display_manager
self.config = config
self.logger = custom_logger or logger
self.global_config = global_config or {}
self._scroll_displays: Dict[str, SportsScrollDisplay] = {}
# "" rather than None, matching SportsScrollDisplay's own empty value —
# both are falsy, so `game_type or self._current_game_type` behaved
# either way, but two spellings of "nothing active" across two classes
# is a trap for anyone comparing state between them.
self._current_game_type: str = ""
def get_scroll_display(self, game_type: str) -> SportsScrollDisplay:
"""The display for ``game_type``, created on first use."""
if game_type not in self._scroll_displays:
self._scroll_displays[game_type] = self.display_class(
self.display_manager,
self.config,
self.logger,
global_config=self.global_config,
)
return self._scroll_displays[game_type]
def prepare_and_display(
self,
games: List[Dict],
game_type: str,
leagues: List[str],
rankings_cache: Optional[Dict[str, int]] = None,
) -> bool:
"""Build content for ``game_type`` and make it the active strip."""
scroll_display = self.get_scroll_display(game_type)
try:
success = scroll_display.prepare_scroll_content(
games, game_type, leagues, rankings_cache
)
except Exception:
# prepare_scroll_content is subclass-implemented and builds cards
# straight from feed data, which is exactly where this PR's other
# crashes came from. One sport's bad payload must not take down the
# shared orchestration for the others.
self.logger.exception(
"Error preparing scroll content for game_type=%s", game_type
)
return False
if success:
self._current_game_type = game_type
return success
def display_frame(self, game_type: Optional[str] = None) -> bool:
"""Advance the active strip (or a named one) by one frame."""
game_type = game_type or self._current_game_type
if not game_type:
return False
scroll_display = self._scroll_displays.get(game_type)
if scroll_display is None:
return False
return scroll_display.display_scroll_frame()
def is_complete(self, game_type: Optional[str] = None) -> bool:
"""True when the strip has finished — including when there isn't one,
so a caller waiting on completion is never wedged."""
game_type = game_type or self._current_game_type
if not game_type:
return True
scroll_display = self._scroll_displays.get(game_type)
if scroll_display is None:
return True
return scroll_display.is_scroll_complete()
def clear_all(self) -> None:
"""Clear every display and forget which one was active."""
for scroll_display in self._scroll_displays.values():
scroll_display.clear()
self._current_game_type = ""
def get_all_vegas_content_items(self) -> List[Image.Image]:
"""Every display's Vegas items, for splicing into the marquee."""
items: List[Image.Image] = []
for scroll_display in self._scroll_displays.values():
vegas_items = getattr(scroll_display, "_vegas_content_items", None)
if vegas_items:
items.extend(vegas_items)
return items
+70 -2
View File
@@ -186,8 +186,14 @@ class DisplayManager:
self.config = config or {} self.config = config or {}
self._force_fallback = force_fallback self._force_fallback = force_fallback
self._suppress_test_pattern = suppress_test_pattern self._suppress_test_pattern = suppress_test_pattern
# When True, update_display() and clear() skip hardware writes (used during off-screen content capture) # Per-thread capture state. update_display() and clear() skip hardware
self._capture_mode_active = False # 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, # Double-sided mode state (resolved in _setup_matrix). When disabled,
# the logical image is blitted to the matrix unchanged. # the logical image is blitted to the matrix unchanged.
self._double_sided = None # dict {copies, axis, logical_width, logical_height} or None self._double_sided = None # dict {copies, axis, logical_width, logical_height} or None
@@ -520,6 +526,15 @@ class DisplayManager:
except Exception as e: except Exception as e:
logger.error(f"Error drawing test pattern: {e}", exc_info=True) 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 @contextmanager
def capture_mode(self): def capture_mode(self):
"""Suppress hardware output during off-screen content capture. """Suppress hardware output during off-screen content capture.
@@ -536,6 +551,59 @@ class DisplayManager:
finally: finally:
self._capture_mode_active = False 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): def _composite_double_sided(self):
"""Tile the logical screen across the full physical chain. """Tile the logical screen across the full physical chain.
+628
View File
@@ -0,0 +1,628 @@
"""
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
# A relative name must be a bare filename. font_name comes from plugin
# config, which the web UI writes; a value like "../../config/config.json"
# would otherwise escape assets/fonts/ once joined and let a config probe
# arbitrary paths for existence. os.path.basename collapses any such value
# to its last component, so a name that isn't already bare is rejected.
if os.path.basename(font_name) != font_name:
return 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 ==================== # ==================== 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): def _initialize_fonts(self):
"""Initialize font catalog and validate configuration.""" """Initialize font catalog and validate configuration."""
self._scan_fonts_directory() self._scan_fonts_directory()
@@ -667,7 +686,7 @@ class FontManager:
def _scan_fonts_directory(self): def _scan_fonts_directory(self):
"""Scan assets/fonts directory for available fonts.""" """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): if not os.path.exists(fonts_dir):
logger.warning(f"Fonts directory not found: {fonts_dir}") logger.warning(f"Fonts directory not found: {fonts_dir}")
return return
@@ -683,6 +702,7 @@ class FontManager:
def _register_common_fonts(self): def _register_common_fonts(self):
"""Register common font aliases from common_fonts dictionary.""" """Register common font aliases from common_fonts dictionary."""
for family_name, font_path in self.common_fonts.items(): for family_name, font_path in self.common_fonts.items():
font_path = self._resolve_asset_path(font_path)
# Check if font file exists # Check if font file exists
if os.path.exists(font_path): if os.path.exists(font_path):
# Register the common font name (overrides auto-generated name if exists) # Register the common font name (overrides auto-generated name if exists)
+105
View File
@@ -145,6 +145,77 @@ class BasePlugin(ABC):
""" """
raise NotImplementedError("Plugins must implement display()") raise NotImplementedError("Plugins must implement display()")
# -------------------------------------------------------------------------
# Global (whole-device) configuration
# -------------------------------------------------------------------------
@property
def global_config(self) -> Dict[str, Any]:
"""
The full LEDMatrix configuration, for reading device-wide settings.
``self.config`` is only this plugin's own slice, so cross-cutting
settings ``target_fps``, ``timezone``, ``location`` were previously
unreachable from a plugin without reaching into a manager by hand.
Resolution order mirrors the timezone helpers the sports plugins
already ship: ``plugin_manager.config_manager`` first (the cores that
hang it there), then ``cache_manager.config_manager``. Returns ``{}``
when neither is available, so callers can use plain ``.get()`` without
guarding, and a plugin on a core that predates this property still
loads ``getattr(self, 'global_config', {})`` simply yields the
default.
Treat as read-only: the returned dict is the live config the core is
using, so mutating it edits every other consumer's view and can be
persisted back to disk.
Assignment is still allowed and wins over the resolved value. Several
shipped plugins (news, stock-news, ledmatrix-stocks, ledmatrix-
elections, ledmatrix-leaderboard, nfl-draft) set
``self.global_config`` to their own ``config['global']`` sub-dict; a
property without a setter would raise AttributeError and stop those
plugins loading.
Example:
fps = self.global_config.get('target_fps')
"""
override = getattr(self, '_global_config_override', None)
if override is not None:
return override
for owner in (self.plugin_manager, self.cache_manager):
config_manager = getattr(owner, 'config_manager', None)
if config_manager is None:
continue
try:
config = config_manager.get_config()
except Exception:
# A broken or unreadable config must never stop a plugin from
# loading; fall through to the next source, then to {}.
self.logger.debug(
"Could not read global config from %s",
type(owner).__name__, exc_info=True,
)
continue
# Only a real mapping is usable: callers do .get() on this and feed
# the result to numeric code, so handing back whatever a stub or a
# half-built manager returned would fail later and further away.
#
# An empty dict is treated as "nothing here yet" rather than a
# valid answer, so resolution continues to the next source. Both
# managers default to the same config/config.json, so falling
# through cannot pick up a different file's settings -- but it does
# rescue the case where the first manager simply hasn't loaded yet,
# which would otherwise return {} and silently disable every
# setting read through this property.
if isinstance(config, dict) and config:
return config
return {}
@global_config.setter
def global_config(self, value: Dict[str, Any]) -> None:
"""Let a plugin substitute its own view (see the getter's docstring)."""
self._global_config_override = value
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Adaptive layout support (opt-in) # Adaptive layout support (opt-in)
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -505,6 +576,40 @@ class BasePlugin(ABC):
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Vegas scroll mode support # 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]: def get_vegas_content(self) -> Optional[Any]:
""" """
Get content for Vegas-style continuous scroll mode. Get content for Vegas-style continuous scroll mode.
+102
View File
@@ -0,0 +1,102 @@
"""One place that answers "can this plugin run on this core?".
Two callers ask that question and they must not drift apart:
- `PluginLoader._warn_if_incompatible` at load time, **advisory**. A plugin
already on disk keeps loading regardless, because the guarded-import pattern
means most incompatibilities degrade rather than break.
- `PluginStoreManager.install_plugin` at install/update time, **blocking**.
This is the point where refusing costs the user nothing (they keep the
version they already had) and allowing can cost them a plugin that fails to
load with only a log line to explain it.
## The trustworthiness problem
The core's own `__version__` has not always been right. `v3.1.0` was tagged
2026-05-31 while `src/__init__.py` still said `"1.0.0"`; the bump landed
2026-07-12. Devices installed from that release report `1.0.0` below the
floor that essentially every published plugin declares.
So a core reporting a version below `TRUSTWORTHY_FLOOR` is treated as
**unknown, not old**: it neither warns nor blocks. Blocking on it would be far
worse than the problem being solved nearly every manifest in the ecosystem
floors at `2.0.0`, so a strict gate would stop those users installing *any*
plugin. They are unprotected until they update the core, which is also what
fixes their version string. See `docs/SPORTS_UNIFICATION.md`, phase B4.
"""
from __future__ import annotations
from typing import Any, Dict, Optional, Tuple
# Below this, the core's self-reported version is not evidence of anything.
# See the module docstring.
TRUSTWORTHY_FLOOR: Tuple[int, int, int] = (2, 0, 0)
def parse_semver(value: Any) -> Optional[Tuple[int, int, int]]:
"""Parse ``X.Y.Z`` (extra parts and suffixes ignored) into a comparable
3-tuple, or ``None`` when unparseable. A leading ``v`` is tolerated."""
if not isinstance(value, str):
return None
parts = value.strip().lstrip('v').split('.')
try:
nums = [int(''.join(ch for ch in p if ch.isdigit()) or 0) for p in parts[:3]]
except ValueError:
return None
while len(nums) < 3:
nums.append(0)
return tuple(nums) # type: ignore[return-value]
def declared_min_version(manifest: Dict[str, Any]) -> Optional[str]:
"""The core version this plugin says it needs, or ``None`` if it doesn't say.
Checked in order of specificity. `ledmatrix_min` is the deprecated spelling
of `ledmatrix_min_version` (`store_manager._validate_manifest_fields` flags
it); both are read because a large share of published manifests still carry
the old one.
"""
declared = (
manifest.get('min_ledmatrix_version')
or (manifest.get('requires') or {}).get('min_ledmatrix_version')
)
if declared:
return declared
versions = manifest.get('versions') or []
if versions and isinstance(versions[0], dict):
return (versions[0].get('ledmatrix_min_version')
or versions[0].get('ledmatrix_min'))
return None
def check(manifest: Dict[str, Any], core_version: str) -> Tuple[bool, Optional[str]]:
"""Return ``(compatible, reason)``.
``compatible`` is False **only** when the plugin declares a parseable floor,
the core reports a parseable and trustworthy version, and the floor is
genuinely above it. Every uncertain case resolves to compatible: an
undeclared floor, an unparseable version on either side, or a core whose
version is below `TRUSTWORTHY_FLOOR`. Refusing on a guess would break
working installs, which is the more expensive mistake here.
``reason`` is user-facing text, present only when incompatible.
"""
declared = declared_min_version(manifest)
needed = parse_semver(declared)
if needed is None:
return True, None
current = parse_semver(core_version)
if current is None or current < TRUSTWORTHY_FLOOR:
return True, None
if needed > current:
name = manifest.get('name') or manifest.get('id') or 'This plugin'
return False, (
f"{name} requires LEDMatrix {declared} or newer, but this system is "
f"running {core_version}. Update LEDMatrix first, then install it."
)
return True, None
+17 -26
View File
@@ -702,34 +702,25 @@ class PluginLoader:
newer than the running core. Advisory only never raises so a newer than the running core. Advisory only never raises so a
plugin that guards optional features with try/except keeps working. plugin that guards optional features with try/except keeps working.
""" """
declared = ( from src import __version__ as core_version
manifest.get('min_ledmatrix_version') from src.plugin_system import compatibility
or manifest.get('requires', {}).get('min_ledmatrix_version')
) compatible, _reason = compatibility.check(manifest, core_version)
if not declared: if compatible:
versions = manifest.get('versions') or [] # Distinguish "fine" from "couldn't tell" for anyone reading logs:
if versions and isinstance(versions[0], dict): # a core below the trustworthy floor is skipped, not cleared.
declared = (versions[0].get('ledmatrix_min_version') current = compatibility.parse_semver(core_version)
or versions[0].get('ledmatrix_min')) if current is None or current < compatibility.TRUSTWORTHY_FLOOR:
needed = self._parse_semver(declared) self.logger.debug(
if needed is None: "Skipping version compatibility check for %s: core __version__ "
"(%s) is below the ecosystem floor", plugin_id, core_version)
return return
from src import __version__ as core_version declared = compatibility.declared_min_version(manifest)
current = self._parse_semver(core_version) self.logger.warning(
# Anti-spam guard: if the core's own version number is stale (below "Plugin %s declares min LEDMatrix version %s but this core is %s"
# the ecosystem floor every shipped plugin declares), comparing would "features it relies on may be missing; update the core or expect "
# warn on nearly everything — skip with a debug note instead. "degraded fallbacks", plugin_id, declared, core_version)
if current is None or current < (2, 0, 0):
self.logger.debug(
"Skipping version compatibility check for %s: core __version__ "
"(%s) is below the ecosystem floor", plugin_id, core_version)
return
if needed > current:
self.logger.warning(
"Plugin %s declares min LEDMatrix version %s but this core is %s"
"features it relies on may be missing; update the core or expect "
"degraded fallbacks", plugin_id, declared, core_version)
def load_plugin( def load_plugin(
self, self,
+10
View File
@@ -116,6 +116,16 @@ class SchemaManager:
self.logger.error(f"Invalid schema format for {plugin_id}: not a dictionary") self.logger.error(f"Invalid schema format for {plugin_id}: not a dictionary")
return None 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 # Cache the schema
self._schema_cache[plugin_id] = schema self._schema_cache[plugin_id] = schema
+117 -4
View File
@@ -149,18 +149,27 @@ class PluginStoreManager:
# loser can end up renaming the winner's in-progress install aside # loser can end up renaming the winner's in-progress install aside
# mid-download, stealing its own rollback safety net. Keyed by # mid-download, stealing its own rollback safety net. Keyed by
# plugin_id so unrelated plugins still update concurrently. # plugin_id so unrelated plugins still update concurrently.
self._reinstall_locks: Dict[str, threading.Lock] = {} # Reentrant: install_plugin takes this lock, and _reinstall_with_rollback
# holds it across its call to install_plugin. A plain Lock would
# self-deadlock on that nesting.
self._reinstall_locks: Dict[str, "threading.RLock"] = {}
self._reinstall_locks_guard = threading.Lock() self._reinstall_locks_guard = threading.Lock()
# Ensure plugins directory exists # Ensure plugins directory exists
self.plugins_dir.mkdir(exist_ok=True) self.plugins_dir.mkdir(exist_ok=True)
def _get_reinstall_lock(self, plugin_id: str) -> threading.Lock: def _get_reinstall_lock(self, plugin_id: str):
"""Lazily create (or fetch) the per-plugin reinstall lock.""" """Lazily create (or fetch) the per-plugin reinstall lock.
Reentrant by necessity: `install_plugin` acquires it to protect its
set-aside/restore, and `_reinstall_with_rollback` holds it across its
own call to `install_plugin`. With a plain `Lock` that nesting
deadlocks the request thread.
"""
with self._reinstall_locks_guard: with self._reinstall_locks_guard:
lock = self._reinstall_locks.get(plugin_id) lock = self._reinstall_locks.get(plugin_id)
if lock is None: if lock is None:
lock = threading.Lock() lock = threading.RLock()
self._reinstall_locks[plugin_id] = lock self._reinstall_locks[plugin_id] = lock
return lock return lock
@@ -1192,6 +1201,90 @@ class PluginStoreManager:
return next((p for p in plugins if p.get('id') == plugin_id), None) return next((p for p in plugins if p.get('id') == plugin_id), None)
def install_plugin(self, plugin_id: str, branch: Optional[str] = None) -> bool: def install_plugin(self, plugin_id: str, branch: Optional[str] = None) -> bool:
"""Install a plugin, keeping any existing install until the new one is
known good.
`_install_plugin_impl` deletes the existing directory *before*
downloading, so every failure after that point a dropped connection, a
malformed manifest, or the compatibility gate refusing the new version
left the user with no plugin at all. `_reinstall_with_rollback` gives the
*update* path exactly this protection; a direct install had none, and the
compatibility gate added a new way to reach it.
Pass-through when nothing is installed, and when called from
`_reinstall_with_rollback`, which has already moved the old copy aside.
The aside name embeds '.standalone-backup-' so plugin discovery
(`plugin_manager._scan_directory_for_plugins`) skips it even though it
still holds a manifest.json.
Held under the per-plugin reinstall lock for the same reason
`_reinstall_with_rollback` is: the web UI runs Flask with
threaded=True, so a double-clicked Install button gives two threads the
same plugin_id. Interleaved, one thread's restore would delete the
other's freshly installed copy. The lock is reentrant because the
rollback path already holds it when it calls in here.
"""
with self._get_reinstall_lock(plugin_id):
plugin_path = self.plugins_dir / plugin_id
if not plugin_path.exists():
return self._install_plugin_impl(plugin_id, branch)
backup_path = plugin_path.with_name(
f"{plugin_path.name}.standalone-backup-preinstall")
if backup_path.exists() and not self._safe_remove_directory(backup_path):
# Can't stage a safety net. Better to attempt the install than
# to refuse outright, which is what callers got before this
# existed.
self.logger.warning(
"Could not clear stale pre-install backup for %s at %s; "
"installing without a rollback net", plugin_id, backup_path)
return self._install_plugin_impl(plugin_id, branch)
try:
plugin_path.rename(backup_path)
except OSError as e:
self.logger.warning(
"Could not set aside existing install of %s (%s); "
"installing without a rollback net", plugin_id, e)
return self._install_plugin_impl(plugin_id, branch)
try:
installed = self._install_plugin_impl(plugin_id, branch)
except Exception:
self._restore_preinstall_backup(plugin_id, plugin_path, backup_path)
raise
if installed:
if not self._safe_remove_directory(backup_path):
self.logger.warning(
"Install of %s succeeded but the previous copy at %s "
"could not be removed; it will be cleared on the next "
"install", plugin_id, backup_path)
return True
self._restore_preinstall_backup(plugin_id, plugin_path, backup_path)
return False
def _restore_preinstall_backup(
self, plugin_id: str, plugin_path: Path, backup_path: Path
) -> None:
"""Put the previous install back after a failed (re)install."""
self.logger.error(
"Install of %s failed; restoring the previous version", plugin_id)
try:
if plugin_path.exists():
# Partial download debris from the failed install.
self._safe_remove_directory(plugin_path)
backup_path.rename(plugin_path)
self.logger.info("Restored previous install of %s", plugin_id)
except OSError as e:
self.logger.error(
"CRITICAL: could not restore %s from %s: %s. The previous "
"install is preserved there — rename it back manually.",
plugin_id, backup_path, e)
def _install_plugin_impl(self, plugin_id: str, branch: Optional[str] = None) -> bool:
""" """
Install a plugin from the official registry. Always installs the latest commit Install a plugin from the official registry. Always installs the latest commit
from the repository's default branch (or specified branch). from the repository's default branch (or specified branch).
@@ -1333,6 +1426,26 @@ class PluginStoreManager:
self._safe_remove_directory(plugin_path) self._safe_remove_directory(plugin_path)
return False return False
# Refuse a plugin that needs a newer core than this one. The
# registry carries no compatibility field, so the floor is only
# knowable once the files are down — checking here, before
# dependency installation, is the earliest possible point.
#
# Refusing costs the user nothing: on an update this returns
# False and _reinstall_with_rollback restores the version they
# already had. Allowing it costs them a plugin that raises
# ModuleNotFoundError at load and is reported only as one line
# in the journal. See docs/SPORTS_UNIFICATION.md (phase B4/B6).
from src import __version__ as core_version
from src.plugin_system import compatibility
compatible, reason = compatibility.check(manifest, core_version)
if not compatible:
self.logger.error(
"Refusing to install %s: %s", plugin_id, reason)
self._safe_remove_directory(plugin_path)
return False
if 'entry_point' not in manifest: if 'entry_point' not in manifest:
manifest['entry_point'] = 'manager.py' manifest['entry_point'] = 'manager.py'
manifest_modified = True manifest_modified = True
@@ -15,6 +15,7 @@ PIL Image canvas and draws text using the actual project fonts.
import math import math
import os import os
import time import time
from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from typing import Any, List, Optional, Tuple from typing import Any, List, Optional, Tuple
@@ -62,6 +63,9 @@ class VisualTestDisplayManager:
# Matrix proxy (plugins access display_manager.matrix.width/height) # Matrix proxy (plugins access display_manager.matrix.width/height)
self.matrix = _MatrixProxy(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) # Scrolling state (interface compat, no-op)
self._scrolling_state = { self._scrolling_state = {
'is_scrolling': False, 'is_scrolling': False,
@@ -174,6 +178,50 @@ class VisualTestDisplayManager:
"""No-op for hardware; marks that display was updated.""" """No-op for hardware; marks that display was updated."""
self.update_called = True 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, 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, color: Tuple[int, int, int] = (255, 255, 255), small_font: bool = False,
font: Optional[Any] = None, centered: bool = False) -> None: font: Optional[Any] = None, centered: bool = False) -> None:
+222
View File
@@ -21,6 +21,94 @@ class VegasModeConfig:
scroll_speed: float = 50.0 # Pixels per second scroll_speed: float = 50.0 # Pixels per second
separator_width: int = 32 # Gap between plugins (pixels) 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 management
plugin_order: List[str] = field(default_factory=list) plugin_order: List[str] = field(default_factory=list)
excluded_plugins: Set[str] = field(default_factory=set) excluded_plugins: Set[str] = field(default_factory=set)
@@ -55,6 +143,24 @@ class VegasModeConfig:
enabled=vegas_config.get('enabled', False), enabled=vegas_config.get('enabled', False),
scroll_speed=float(vegas_config.get('scroll_speed', 50.0)), scroll_speed=float(vegas_config.get('scroll_speed', 50.0)),
separator_width=int(vegas_config.get('separator_width', 32)), 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', [])), plugin_order=list(vegas_config.get('plugin_order', [])),
excluded_plugins=set(vegas_config.get('excluded_plugins', [])), excluded_plugins=set(vegas_config.get('excluded_plugins', [])),
target_fps=int(vegas_config.get('target_fps', 125)), target_fps=int(vegas_config.get('target_fps', 125)),
@@ -72,6 +178,21 @@ class VegasModeConfig:
'enabled': self.enabled, 'enabled': self.enabled,
'scroll_speed': self.scroll_speed, 'scroll_speed': self.scroll_speed,
'separator_width': self.separator_width, '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, 'plugin_order': self.plugin_order,
'excluded_plugins': list(self.excluded_plugins), 'excluded_plugins': list(self.excluded_plugins),
'target_fps': self.target_fps, 'target_fps': self.target_fps,
@@ -157,6 +278,74 @@ class VegasModeConfig:
if self.buffer_ahead > 5: if self.buffer_ahead > 5:
errors.append(f"buffer_ahead must be <= 5, got {self.buffer_ahead}") 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 return errors
def update(self, new_config: Dict[str, Any]) -> None: def update(self, new_config: Dict[str, Any]) -> None:
@@ -174,6 +363,39 @@ class VegasModeConfig:
self.scroll_speed = float(vegas_config['scroll_speed']) self.scroll_speed = float(vegas_config['scroll_speed'])
if 'separator_width' in vegas_config: if 'separator_width' in vegas_config:
self.separator_width = int(vegas_config['separator_width']) 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: if 'plugin_order' in vegas_config:
self.plugin_order = list(vegas_config['plugin_order']) self.plugin_order = list(vegas_config['plugin_order'])
if 'excluded_plugins' in vegas_config: if 'excluded_plugins' in vegas_config:
+64 -13
View File
@@ -64,7 +64,7 @@ class VegasModeCoordinator:
self.plugin_manager = plugin_manager self.plugin_manager = plugin_manager
# Initialize components # Initialize components
self.plugin_adapter = PluginAdapter(display_manager) self.plugin_adapter = PluginAdapter(display_manager, self.vegas_config)
self.stream_manager = StreamManager( self.stream_manager = StreamManager(
self.vegas_config, self.vegas_config,
plugin_manager, plugin_manager,
@@ -233,6 +233,11 @@ class VegasModeCoordinator:
self._should_stop = False self._should_stop = False
self._start_time = time.time() 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") logger.info("Vegas mode started")
return True return True
@@ -301,16 +306,43 @@ class VegasModeCoordinator:
if has_pending_update: if has_pending_update:
self._apply_pending_config() self._apply_pending_config()
# Check if we need to start a new cycle if self.vegas_config.continuous_scroll:
if self.render_pipeline.is_cycle_complete(): # Drop cached content for plugins whose data just changed, so the
if not self.render_pipeline.start_new_cycle(): # next time each comes round it is composed from current data. The
logger.warning("Failed to start new Vegas cycle") # swap path's hot_swap_content() does this via process_updates(),
return False # but it also rebuilds and repositions the whole strip, which is
self.stats['cycles_completed'] += 1 # 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 # Extend the strip before the scroll can reach its end, so the next
if self.render_pipeline.should_recompose(): # group arrives from the right and motion never stops. No cycle
self.render_pipeline.hot_swap_content() # 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 # Render frame
return self.render_pipeline.render_frame() return self.render_pipeline.render_frame()
@@ -337,7 +369,14 @@ class VegasModeCoordinator:
self._update_static_mode_plugins() self._update_static_mode_plugins()
frame_interval = self.vegas_config.get_frame_interval() 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() start_time = time.time()
frame_count = 0 frame_count = 0
fps_log_interval = 5.0 # Log FPS every 5 seconds fps_log_interval = 5.0 # Log FPS every 5 seconds
@@ -347,6 +386,8 @@ class VegasModeCoordinator:
logger.info("Starting Vegas iteration for %.1fs", duration) logger.info("Starting Vegas iteration for %.1fs", duration)
while True: while True:
frame_started = time.time()
# Check for STATIC mode plugin that should pause scroll # Check for STATIC mode plugin that should pause scroll
static_plugin = self._check_static_plugin_trigger() static_plugin = self._check_static_plugin_trigger()
if static_plugin: if static_plugin:
@@ -367,8 +408,14 @@ class VegasModeCoordinator:
# Paused for live priority - let caller handle # Paused for live priority - let caller handle
return False return False
# Sleep for frame interval # Sleep only the remainder of the frame budget. This used to sleep
time.sleep(frame_interval) # 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 # Increment frame count and check for interrupt periodically
frame_count += 1 frame_count += 1
@@ -505,6 +552,10 @@ class VegasModeCoordinator:
# Update components # Update components
self.render_pipeline.update_config(new_vegas_config) self.render_pipeline.update_config(new_vegas_config)
self.stream_manager.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 # Force refresh of stream manager to pick up plugin_order/buffer changes
self.stream_manager._last_refresh = 0 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 logging
import threading import threading
import time import time
from contextlib import nullcontext
from typing import Optional, List, Any, Tuple, Union, TYPE_CHECKING from typing import Optional, List, Any, Tuple, Union, TYPE_CHECKING
from PIL import Image from PIL import Image
from src.vegas_mode.geometry import (
blank_runs,
separation_gap,
trim_to_content,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from src.plugin_system.base_plugin import BasePlugin from src.plugin_system.base_plugin import BasePlugin
@@ -26,14 +33,21 @@ class PluginAdapter:
2. Fallback: Capture display_manager.image after calling plugin.display() 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. Initialize the plugin adapter.
Args: Args:
display_manager: DisplayManager instance for fallback capture 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 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 # Handle both property and method access patterns
self.display_width = ( self.display_width = (
display_manager.width() if callable(display_manager.width) display_manager.width() if callable(display_manager.width)
@@ -49,12 +63,18 @@ class PluginAdapter:
self._cache_lock = threading.Lock() self._cache_lock = threading.Lock()
self._cache_ttl = 5.0 # Cache for 5 seconds 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( logger.info(
"PluginAdapter initialized: display=%dx%d", "PluginAdapter initialized: display=%dx%d",
self.display_width, self.display_height 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. Get scrollable content from a plugin.
@@ -63,6 +83,13 @@ class PluginAdapter:
Args: Args:
plugin: Plugin instance to get content from plugin: Plugin instance to get content from
plugin_id: Plugin identifier for logging 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: Returns:
List of PIL Images representing plugin content, or None if no content 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') has_native = hasattr(plugin, 'get_vegas_content')
logger.info("[%s] Has get_vegas_content: %s", plugin_id, has_native) logger.info("[%s] Has get_vegas_content: %s", plugin_id, has_native)
if 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: if content:
total_width = sum(img.width for img in content) total_width = sum(img.width for img in content)
logger.info( logger.info(
"[%s] Native content SUCCESS: %d images, %dpx total", "[%s] Native content SUCCESS: %d images, %dpx total",
plugin_id, len(content), total_width plugin_id, len(content), total_width
) )
self._cache_content(plugin_id, content) return self._finalize(content, plugin_id, 'native', plugin)
return content
logger.info("[%s] Native content returned None", plugin_id) logger.info("[%s] Native content returned None", plugin_id)
# Try to get scroll_helper's cached image (for scrolling plugins like stocks/odds) # Try to get scroll_helper's cached image (for scrolling plugins like stocks/odds)
has_scroll_helper = hasattr(plugin, 'scroll_helper') has_scroll_helper = hasattr(plugin, 'scroll_helper')
logger.info("[%s] Has scroll_helper: %s", plugin_id, has_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: if content:
total_width = sum(img.width for img in content) total_width = sum(img.width for img in content)
logger.info( logger.info(
"[%s] ScrollHelper content SUCCESS: %d images, %dpx total", "[%s] ScrollHelper content SUCCESS: %d images, %dpx total",
plugin_id, len(content), total_width plugin_id, len(content), total_width
) )
self._cache_content(plugin_id, content) return self._finalize(content, plugin_id, 'scroll_helper', plugin)
return content
if has_scroll_helper: if has_scroll_helper:
logger.info("[%s] ScrollHelper content returned None", plugin_id) 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 # Fall back to display capture
logger.info("[%s] Trying fallback display capture...", plugin_id) logger.info("[%s] Trying fallback display capture...", plugin_id)
content = self._capture_display_content(plugin, plugin_id) content = self._capture_display_content(plugin, plugin_id)
@@ -121,8 +154,7 @@ class PluginAdapter:
"[%s] Fallback capture SUCCESS: %d images, %dpx total", "[%s] Fallback capture SUCCESS: %d images, %dpx total",
plugin_id, len(content), total_width plugin_id, len(content), total_width
) )
self._cache_content(plugin_id, content) return self._finalize(content, plugin_id, 'fallback', plugin)
return content
logger.warning( logger.warning(
"[%s] NO CONTENT from any method (native=%s, scroll_helper=%s, fallback=tried)", "[%s] NO CONTENT from any method (native=%s, scroll_helper=%s, fallback=tried)",
@@ -130,8 +162,397 @@ class PluginAdapter:
) )
return None 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( def _get_native_content(
self, plugin: 'BasePlugin', plugin_id: str self, plugin: 'BasePlugin', plugin_id: str, offscreen_only: bool = False
) -> Optional[List[Image.Image]]: ) -> Optional[List[Image.Image]]:
""" """
Get content via plugin's native get_vegas_content() method. Get content via plugin's native get_vegas_content() method.
@@ -145,7 +566,40 @@ class PluginAdapter:
""" """
try: try:
logger.info("[%s] Native: calling get_vegas_content()", plugin_id) 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: if result is None:
logger.info("[%s] Native: get_vegas_content() returned None", plugin_id) logger.info("[%s] Native: get_vegas_content() returned None", plugin_id)
@@ -223,7 +677,7 @@ class PluginAdapter:
return None return None
def _get_scroll_helper_content( 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]]: ) -> Optional[List[Image.Image]]:
""" """
Get content from plugin's scroll_helper if available. 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", "[%s] scroll_helper.cached_image is None, triggering content generation",
plugin_id 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 # Try to trigger scroll content generation
cached_image = self._trigger_scroll_content_generation( cached_image = self._trigger_scroll_content_generation(
plugin, plugin_id, scroll_helper plugin, plugin_id, scroll_helper
@@ -405,7 +866,7 @@ class PluginAdapter:
# Save display state to restore after # Save display state to restore after
original_image = self.display_manager.image.copy() original_image = self.display_manager.image.copy()
with self.display_manager.capture_mode(): with self._capture():
# Method 1: Try _create_scrolling_display (stocks pattern) # Method 1: Try _create_scrolling_display (stocks pattern)
if hasattr(plugin, '_create_scrolling_display'): if hasattr(plugin, '_create_scrolling_display'):
logger.info( logger.info(
@@ -497,7 +958,18 @@ class PluginAdapter:
# Clear and call plugin display — use capture_mode to suppress hardware writes # Clear and call plugin display — use capture_mode to suppress hardware writes
# that plugins may trigger internally via update_display(). # 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() self.display_manager.clear()
logger.info("[%s] Fallback: display cleared, calling display()", plugin_id) logger.info("[%s] Fallback: display cleared, calling display()", plugin_id)
@@ -531,7 +1003,7 @@ class PluginAdapter:
plugin_id plugin_id
) )
# Try once more with force_clear=True # 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() self.display_manager.clear()
plugin.display(force_clear=True) plugin.display(force_clear=True)
captured = self.display_manager.image.copy() captured = self.display_manager.image.copy()
@@ -663,6 +1135,53 @@ class PluginAdapter:
else: else:
self._content_cache.clear() 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: def get_content_type(self, plugin: 'BasePlugin', plugin_id: str) -> str:
""" """
Get the type of content a plugin provides. 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 logging
import os
import time import time
import threading import threading
from collections import deque from collections import deque
@@ -14,6 +15,7 @@ from PIL import Image
from src.common.scroll_helper import ScrollHelper from src.common.scroll_helper import ScrollHelper
from src.vegas_mode.config import VegasModeConfig from src.vegas_mode.config import VegasModeConfig
from src.vegas_mode.geometry import separation_gap
from src.vegas_mode.stream_manager import StreamManager from src.vegas_mode.stream_manager import StreamManager
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -34,6 +36,10 @@ class RenderPipeline:
- Track scroll cycle completion - 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__( def __init__(
self, self,
config: VegasModeConfig, config: VegasModeConfig,
@@ -66,10 +72,6 @@ class RenderPipeline:
else display_manager.height 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 # ScrollHelper for optimized scrolling
self.scroll_helper = ScrollHelper( self.scroll_helper = ScrollHelper(
self.display_width, self.display_width,
@@ -85,6 +87,14 @@ class RenderPipeline:
self._staging_scroll_image: Optional[Image.Image] = None self._staging_scroll_image: Optional[Image.Image] = None
self._buffer_lock = threading.Lock() 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 # Render state
self._is_rendering = False self._is_rendering = False
self._cycle_complete = False self._cycle_complete = False
@@ -114,6 +124,7 @@ class RenderPipeline:
"""Configure ScrollHelper with current settings.""" """Configure ScrollHelper with current settings."""
self.scroll_helper.set_frame_based_scrolling(self.config.frame_based_scrolling) 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_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 # Config scroll_speed is always pixels per second, but ScrollHelper
# interprets it differently based on frame_based_scrolling mode: # interprets it differently based on frame_based_scrolling mode:
@@ -141,23 +152,37 @@ class RenderPipeline:
True if composition successful True if composition successful
""" """
try: try:
# Get all buffered content # Content grouped by plugin, so a separator can be placed at the
images = self.stream_manager.get_all_content_for_composition() # 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") logger.warning("No content available for composition")
return False return False
# Add separator gaps between images # Collapse each plugin's rows into a single block, joined by
content_with_gaps = [] # intra_plugin_gap. ScrollHelper applies one uniform gap between the
for i, img in enumerate(images): # items it is given, so handing it one item per plugin is what makes
content_with_gaps.append(img) # 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( self.scroll_helper.create_scrolling_image(
content_items=content_with_gaps, content_items=blocks,
item_gap=self.config.separator_width, 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 # Verify scroll image was created successfully
@@ -177,11 +202,16 @@ class RenderPipeline:
self._cycle_complete = False self._cycle_complete = False
logger.info( 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.scroll_helper.cached_image.width if self.scroll_helper.cached_image else 0,
self.display_height, self.display_height,
len(self._segments_in_scroll), len(blocks),
len(images) total_rows,
self.config.separator_width,
self.config.min_content_separation,
self.config.intra_plugin_gap,
) )
return True return True
@@ -191,6 +221,264 @@ class RenderPipeline:
logger.exception("Error composing scroll content") logger.exception("Error composing scroll content")
return False 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: def render_frame(self) -> bool:
""" """
Render a single frame to the display. Render a single frame to the display.
@@ -211,21 +499,33 @@ class RenderPipeline:
# Determine if the cycle is done. # Determine if the cycle is done.
# #
# scroll_helper considers a cycle complete only after # get_visible_portion wraps: once scroll_position + display_width
# total_distance_scrolled >= total_scroll_width + display_width. # passes the end of the strip it fills the right-hand side of the
# That extra display_width of travel causes a "wrap-around" phase # frame from the *head* of the same strip. So the last
# where scroll_position resets to ~0 and the first plugin's content # display_width of travel shows the cycle's first plugin re-entering
# re-enters from the right — the user sees this 2-3 s of re-entry # on the right while its last plugin exits on the left, and the
# as "a plugin partially displaying before the next one starts." # 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 # This used to be hidden because the strip began with a full
# total_scroll_width (the wrap-around point), before any second-pass # display_width of blank, so the wrapped-in region was black.
# content becomes visible. The scroll_helper's own is_scroll_complete() # lead_in_width now defaults to 0 (that blank was 10s of dead panel
# check is kept as a fallback for any edge-cases where that threshold # at 50px/s), which exposed the wrap — so the cycle has to end
# is never hit. # 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 = ( at_wrap_point = (
not self._cycle_complete and 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(): if at_wrap_point or self.scroll_helper.is_scroll_complete():
@@ -236,24 +536,17 @@ class RenderPipeline:
"Scroll cycle complete after %.1fs", "Scroll cycle complete after %.1fs",
time.time() - self._cycle_start_time time.time() - self._cycle_start_time
) )
# Push blank immediately so the hardware never shows any # Deliberately leave the last rendered frame on the panel.
# post-wrap content while the coordinator recomposes the #
# next cycle (~100 ms). The blank is allocated once and # This used to push a blank frame so no post-wrap content
# reused across cycle wraps (fresh paste each time in case # could be seen while the next cycle was composed. But
# a consumer drew on the previous one). # recomposing is synchronous and fetches plugin content:
try: # measured 84ms at best and 4.8s at worst on a 512px panel,
if self._blank_frame is None or self._blank_frame.size != ( # and every millisecond of it was black. Holding the last
self.display_width, self.display_height): # frame instead turns that into a brief freeze, which reads
self._blank_frame = Image.new( # as far less broken than the display switching off. The
'RGB', (self.display_width, self.display_height)) # frame is already past the end of the content, so there is
else: # no second-pass content to leak.
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")
return True # Cycle done; coordinator starts new cycle next frame return True # Cycle done; coordinator starts new cycle next frame
# Get visible portion # Get visible portion
@@ -336,6 +629,25 @@ class RenderPipeline:
return False 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: def hot_swap_content(self) -> bool:
""" """
Hot-swap to new composed content. Hot-swap to new composed content.
@@ -415,11 +727,12 @@ class RenderPipeline:
result = self.compose_scroll_content() result = self.compose_scroll_content()
if result and self.sync_manager: if result and self.sync_manager:
# When sync is active, start the leader at display_width instead of 0. # When sync is active, start the leader past the lead-in gap so it
# This skips the initial black gap so the leader immediately shows content. # immediately shows content, leaving the follower on the blank gap
# The follower starts at position 0 (the gap) which looks like a clean # for a clean transition rather than near-end content wrapping
# blank transition rather than near-end content wrapping around. # around. This tracks lead_in_width rather than assuming a full
self.scroll_helper.scroll_position = float(self.display_width) # 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: if result and self.sync_manager:
# Signal follower that a new cycle started (triggers its own rebuild) # 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 logging
import threading import threading
import time 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 collections import deque
from dataclasses import dataclass, field from dataclasses import dataclass, field
from PIL import Image from PIL import Image
@@ -116,8 +116,11 @@ class StreamManager:
logger.warning("No plugins available for Vegas scroll") logger.warning("No plugins available for Vegas scroll")
return False return False
# Prefetch initial content # Fill the buffer to a whole cycle's worth of plugins. This used to be
self._prefetch_content(count=min(self.config.buffer_ahead + 1, len(self._ordered_plugins))) # 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( logger.info(
"StreamManager initialized with %d plugins, %d segments buffered", "StreamManager initialized with %d plugins, %d segments buffered",
@@ -198,6 +201,47 @@ class StreamManager:
logger.debug("Plugin %s marked for update", plugin_id) 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: def has_pending_updates(self) -> bool:
"""Check if any plugins have pending updates awaiting processing.""" """Check if any plugins have pending updates awaiting processing."""
with self._buffer_lock: with self._buffer_lock:
@@ -385,7 +429,7 @@ class StreamManager:
return return
for _ in range(count): 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 break
# Ensure index is valid (guard against empty list) # 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) logger.debug("Refreshed content for %s in staging buffer", plugin_id)
def _ensure_buffer_filled(self) -> None: def _ensure_buffer_filled(self) -> None:
"""Ensure buffer has enough content prefetched.""" """
if len(self._active_buffer) < self.config.buffer_ahead: Top the buffer back up after segments have been served.
needed = self.config.buffer_ahead - len(self._active_buffer)
self._prefetch_content(count=needed) 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]: def get_all_content_for_composition(self) -> List[Image.Image]:
""" """
Get all buffered content as a flat list of images. 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. 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: Returns:
List of all images in buffer order List of all images in buffer order
""" """
all_images = [] 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: with self._buffer_lock:
for segment in self._active_buffer: for segment in self._active_buffer:
# Skip STATIC segments - they trigger pauses, not scroll content if segment.display_mode == VegasDisplayMode.STATIC:
if segment.display_mode != VegasDisplayMode.STATIC: continue
all_images.extend(segment.images) if not segment.images:
return all_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: def advance_cycle(self) -> None:
""" """
+412
View File
@@ -0,0 +1,412 @@
"""
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)
@pytest.mark.parametrize("hostile", [
"../../config/config.json",
"../secrets.txt",
"sub/dir/font.ttf",
"..",
])
def test_relative_font_name_with_path_components_is_rejected(self, hostile):
# font_name comes from plugin config (web-UI writable); a relative name
# carrying path separators would escape assets/fonts/ after os.path.join
# and let a config probe arbitrary paths. Only bare filenames resolve.
assert resolve_font_path(hostile) is None
def test_bare_filename_still_resolves(self):
# The guard must not reject legitimate bare names.
assert resolve_font_path("PressStart2P-Regular.ttf") is not None
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"]
+209
View File
@@ -0,0 +1,209 @@
"""
Tests for scripts/install/lib_lowmem.sh, the installer's low-memory helpers.
Background: the rgbmatrix build compiles ~45 C++ translation units, two of them
Cython-generated. Upstream's pyproject.toml sets no [tool.scikit-build] options,
so scikit-build-core drives Ninja at its default of nproc+2 jobs -- six
concurrent cc1plus on a 4-core Pi. On 512MB and 1GB models the OOM killer reaps
the compiler and pip reports only "Failed building wheel for rgbmatrix", which
the installer used to misreport as a missing-build-tools problem.
These cover the pure sizing/detection functions. The swap-management functions
need root and mutate the system, so they are exercised manually instead.
"""
import subprocess
from pathlib import Path
import pytest
LIB = Path(__file__).resolve().parent.parent / "scripts" / "install" / "lib_lowmem.sh"
def run_lib(snippet: str, env: dict | None = None) -> subprocess.CompletedProcess:
"""Source the helper library and run a snippet against it."""
script = f". {LIB}\n{snippet}"
return subprocess.run(
["bash", "-c", script],
capture_output=True,
text=True,
env={"PATH": "/usr/bin:/bin:/usr/sbin:/sbin", **(env or {})},
)
def call(fn: str, *args: object, env: dict | None = None) -> str:
joined = " ".join(str(a) for a in args)
result = run_lib(f"{fn} {joined}", env=env)
assert result.returncode == 0, f"{fn} failed: {result.stderr}"
return result.stdout.strip()
class TestLibraryLoads:
def test_library_exists_and_is_syntactically_valid(self):
assert LIB.is_file(), f"{LIB} is missing"
result = subprocess.run(["bash", "-n", str(LIB)], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
def test_sourcing_is_safe_under_strict_mode(self):
# first_time_install.sh runs under `set -Eeuo pipefail` with an ERR
# trap, so sourcing must not trip either.
result = run_lib("set -Eeuo pipefail\ntrap 'exit 99' ERR\necho ok")
assert result.returncode == 0, result.stderr
assert "ok" in result.stdout
class TestBuildJobs:
@pytest.mark.parametrize(
"ram_mb,cores,expected",
[
(512, 4, 1), # Pi Zero 2 W - must serialize
(1024, 4, 1), # Pi 3B/3B+ - the device from the bug report
(2048, 4, 2),
(4096, 4, 4), # core-capped
(8192, 4, 4), # core-capped
(2048, 1, 1), # single-core machine
],
)
def test_jobs_scale_with_ram_and_cap_at_cores(self, ram_mb, cores, expected):
assert call("lm_build_jobs", ram_mb, cores) == str(expected)
def test_never_returns_zero_jobs(self):
assert call("lm_build_jobs", 0, 4) == "1"
def test_treats_zero_cores_as_one(self):
assert call("lm_build_jobs", 8192, 0) == "1"
class TestSwapSizing:
@pytest.mark.parametrize(
"ram_mb,existing_swap_mb,expected",
[
(512, 0, 2048), # capped at LM_SWAP_MAX_MB
(1024, 0, 2048), # matches the workaround the reporter found
(2048, 0, 1024),
(2048, 1024, 0), # existing swap already covers it
(4096, 0, 0), # untouched on machines that already work
(8192, 0, 0),
],
)
def test_swap_target_scales_with_ram(self, ram_mb, existing_swap_mb, expected):
assert call("lm_swap_needed_mb", ram_mb, existing_swap_mb) == str(expected)
def test_result_is_a_multiple_of_256mb(self):
# 3072 - 900 = 2172, which must round up rather than produce an odd size.
assert int(call("lm_swap_needed_mb", 900, 0)) % 256 == 0
class TestSwapDetection:
def test_zram_swap_is_excluded(self, tmp_path):
# zram swap is compressed RAM: counting it would let a zram-enabled
# image skip provisioning and then OOM exactly as before.
swaps = tmp_path / "swaps"
swaps.write_text(
"Filename\t\t\t\tType\t\tSize\t\tUsed\t\tPriority\n"
"/dev/zram0 partition\t1048572\t\t0\t\t100\n"
"/var/swap file\t\t524284\t\t0\t\t-2\n"
)
assert call("lm_total_swap_mb", env={"LM_SWAPS": str(swaps)}) == "511"
def test_zram_only_system_reports_no_usable_swap(self, tmp_path):
swaps = tmp_path / "swaps"
swaps.write_text(
"Filename\t\t\t\tType\t\tSize\t\tUsed\t\tPriority\n"
"/dev/zram0 partition\t1048572\t\t0\t\t100\n"
)
assert call("lm_total_swap_mb", env={"LM_SWAPS": str(swaps)}) == "0"
def test_ram_is_read_from_meminfo(self, tmp_path):
meminfo = tmp_path / "meminfo"
# A real Pi 3B+ reports this; 948204/1024 truncates to 925.
meminfo.write_text("MemTotal: 948204 kB\nMemFree: 123456 kB\n")
assert call("lm_total_ram_mb", env={"LM_MEMINFO": str(meminfo)}) == "925"
def test_missing_files_report_zero_rather_than_failing(self, tmp_path):
missing = str(tmp_path / "nope")
assert call("lm_total_ram_mb", env={"LM_MEMINFO": missing}) == "0"
assert call("lm_total_swap_mb", env={"LM_SWAPS": missing}) == "0"
class TestOomDetection:
"""The regression tests for the misdiagnosis in the bug report."""
def _check(self, tmp_path, build_log: str, kernel_log: str = "") -> bool:
build_file = tmp_path / "build.log"
build_file.write_text(build_log)
kernel_file = tmp_path / "kernel.log"
kernel_file.write_text(kernel_log)
result = run_lib(
f"lm_build_failed_on_oom {build_file}",
env={"LM_KERNEL_LOG_FILE": str(kernel_file)},
)
return result.returncode == 0
@pytest.mark.parametrize(
"line",
[
"c++: fatal error: Killed signal terminated program cc1plus",
"cc1plus: out of memory allocating 65536 bytes",
"virtual memory exhausted: Cannot allocate memory",
"error: command '/usr/bin/c++' died with signal 9",
],
)
def test_detects_compiler_reported_memory_failures(self, tmp_path, line):
log = f"[15/45] Building CXX object core.cpp.o\n{line}\nninja: build stopped.\n"
assert self._check(tmp_path, log) is True
def test_detects_oom_visible_only_in_the_kernel_log(self, tmp_path):
# The OOM killer writes nothing to the build's stdout. This silence is
# precisely why the old handler blamed missing build tools.
build_log = (
"[15/45] Building CXX object core.cpp.o\n"
"ninja: build stopped: subcommand failed.\n"
"ERROR: Failed building wheel for rgbmatrix\n"
)
kernel_log = (
"[12345.6] Out of memory: Killed process 4242 (cc1plus) "
"total-vm:812345kB, anon-rss:764000kB\n"
)
assert self._check(tmp_path, build_log, kernel_log) is True
def test_does_not_flag_a_genuine_missing_build_tool(self, tmp_path):
build_log = (
"CMake Error at CMakeLists.txt:12 (find_package):\n"
" Could NOT find Python (missing: Development.Module)\n"
"fatal error: Python.h: No such file or directory\n"
"ERROR: Failed building wheel for rgbmatrix\n"
)
assert self._check(tmp_path, build_log) is False
def test_does_not_flag_a_network_failure(self, tmp_path):
build_log = (
"WARNING: Retrying after connection broken by 'NewConnectionError'\n"
"ERROR: Could not install packages due to an OSError\n"
)
assert self._check(tmp_path, build_log) is False
def test_handles_a_missing_build_log(self, tmp_path):
kernel_file = tmp_path / "kernel.log"
kernel_file.write_text("")
result = run_lib(
f"lm_build_failed_on_oom {tmp_path / 'absent.log'}",
env={"LM_KERNEL_LOG_FILE": str(kernel_file)},
)
assert result.returncode == 1
class TestDiskBackedTmpdir:
def test_returns_nothing_when_tmpdir_is_already_disk_backed(self, tmp_path):
# tmp_path is on the regular filesystem, so the default must be kept.
assert call("lm_disk_backed_tmpdir", env={"TMPDIR": str(tmp_path)}) == ""
def test_redirects_away_from_a_memory_backed_tmpdir(self):
# Debian 13 mounts /tmp as tmpfs, which would otherwise hold the whole
# C++ build tree in RAM alongside the compiler.
shm = Path("/dev/shm")
if not shm.is_dir():
pytest.skip("/dev/shm not available")
result = run_lib("lm_disk_backed_tmpdir", env={"TMPDIR": str(shm)})
assert result.returncode == 0
assert result.stdout.strip() in ("", "/var/tmp")
+222
View File
@@ -0,0 +1,222 @@
"""A failed (re)install must not destroy the working plugin it replaced.
`_install_plugin_impl` deletes the existing plugin directory *before* it
downloads anything, so any failure after that point used to leave the user with
nothing. The update path was protected `_reinstall_with_rollback` renames the
old copy aside first but a direct `install_plugin` was not, and the
compatibility gate added a new way to fail late: a plugin whose declared floor
exceeds the running core is now refused *after* the old copy is already gone.
Concretely, without the wrapper: a user on core 3.1.0 with a working
hockey-scoreboard clicks Install; the new manifest floors at 3.2.0; the gate
refuses; the plugin they had is deleted. Floors are hand-written and can be
over-declared, so this could remove a plugin that was working fine.
"""
import json
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from src.plugin_system.store_manager import PluginStoreManager
@pytest.fixture
def store(tmp_path):
plugins_dir = tmp_path / "plugin-repos"
plugins_dir.mkdir()
mgr = PluginStoreManager(plugins_dir=str(plugins_dir))
mgr.logger = MagicMock()
return mgr, plugins_dir
def _existing_install(plugins_dir: Path, plugin_id: str, marker: str) -> Path:
path = plugins_dir / plugin_id
path.mkdir(parents=True)
(path / "manifest.json").write_text(
json.dumps({"id": plugin_id, "name": plugin_id, "class_name": "P",
"display_modes": ["a"], "version": "1.0.0"}),
encoding="utf-8")
(path / "marker.txt").write_text(marker, encoding="utf-8")
return path
class TestFailedInstallPreservesPrevious:
def test_failed_install_restores_the_old_copy(self, store, monkeypatch):
mgr, plugins_dir = store
path = _existing_install(plugins_dir, "hockey-scoreboard", "the-original")
monkeypatch.setattr(mgr, "_install_plugin_impl", lambda *a, **k: False)
assert mgr.install_plugin("hockey-scoreboard") is False
assert path.exists(), "the previous install must be restored"
assert (path / "marker.txt").read_text() == "the-original"
def test_raising_install_restores_and_reraises(self, store, monkeypatch):
mgr, plugins_dir = store
path = _existing_install(plugins_dir, "hockey-scoreboard", "the-original")
def boom(*a, **k):
raise RuntimeError("network died mid-install")
monkeypatch.setattr(mgr, "_install_plugin_impl", boom)
with pytest.raises(RuntimeError):
mgr.install_plugin("hockey-scoreboard")
assert path.exists()
assert (path / "marker.txt").read_text() == "the-original"
def test_successful_install_clears_the_backup(self, store, monkeypatch):
mgr, plugins_dir = store
_existing_install(plugins_dir, "hockey-scoreboard", "the-original")
def succeed(plugin_id, branch=None):
_existing_install(plugins_dir, plugin_id, "the-new-one")
return True
monkeypatch.setattr(mgr, "_install_plugin_impl", succeed)
assert mgr.install_plugin("hockey-scoreboard") is True
assert (plugins_dir / "hockey-scoreboard" / "marker.txt").read_text() == "the-new-one"
leftovers = [p.name for p in plugins_dir.iterdir() if "backup" in p.name]
assert not leftovers, f"backup left behind: {leftovers}"
def test_backup_name_is_invisible_to_plugin_discovery(self, store, monkeypatch):
"""A backup that discovery can see becomes a duplicate plugin entry;
the marker '.standalone-backup-' is what makes it skip."""
mgr, plugins_dir = store
_existing_install(plugins_dir, "hockey-scoreboard", "the-original")
seen = {}
def capture(plugin_id, branch=None):
seen["dirs"] = sorted(p.name for p in plugins_dir.iterdir())
return False
monkeypatch.setattr(mgr, "_install_plugin_impl", capture)
mgr.install_plugin("hockey-scoreboard")
backups = [d for d in seen["dirs"] if d != "hockey-scoreboard"]
assert backups, "expected the old copy to be set aside during install"
for name in backups:
assert ".standalone-backup-" in name, (
f"{name} would be picked up by "
"plugin_manager._scan_directory_for_plugins as a real plugin")
def test_fresh_install_is_a_pass_through(self, store, monkeypatch):
"""Nothing installed means nothing to protect; don't create stray dirs."""
mgr, plugins_dir = store
calls = []
monkeypatch.setattr(
mgr, "_install_plugin_impl",
lambda *a, **k: calls.append(a) or True)
assert mgr.install_plugin("brand-new") is True
assert calls, "the implementation must still be called"
assert list(plugins_dir.iterdir()) == []
def test_stale_backup_from_a_crash_does_not_block(self, store, monkeypatch):
mgr, plugins_dir = store
_existing_install(plugins_dir, "hockey-scoreboard", "the-original")
stale = plugins_dir / "hockey-scoreboard.standalone-backup-preinstall"
stale.mkdir()
(stale / "junk.txt").write_text("from a previous crash", encoding="utf-8")
monkeypatch.setattr(mgr, "_install_plugin_impl", lambda *a, **k: False)
assert mgr.install_plugin("hockey-scoreboard") is False
assert (plugins_dir / "hockey-scoreboard" / "marker.txt").read_text() == "the-original"
class TestUpdatePathStillWorks:
def test_reinstall_with_rollback_is_not_double_wrapped(self, store, monkeypatch):
"""_reinstall_with_rollback moves the plugin aside itself, so by the
time install_plugin runs there is nothing at the original path and the
wrapper must be a pass-through rather than staging a second backup."""
mgr, plugins_dir = store
path = _existing_install(plugins_dir, "hockey-scoreboard", "the-original")
observed = {}
def impl(plugin_id, branch=None):
observed["dirs"] = sorted(p.name for p in plugins_dir.iterdir())
return False
monkeypatch.setattr(mgr, "_install_plugin_impl", impl)
assert mgr._reinstall_with_rollback("hockey-scoreboard", path) is False
# Exactly one aside directory existed during the attempt — rollback's.
assert observed["dirs"] == ["hockey-scoreboard.standalone-backup-migrating"]
# And the user still has their plugin.
assert (plugins_dir / "hockey-scoreboard" / "marker.txt").read_text() == "the-original"
class TestConcurrency:
"""The web UI runs Flask threaded, so a double-clicked Install button puts
two threads on the same plugin_id. `_reinstall_with_rollback` already
guarded against this; the install wrapper has to as well, or one thread's
restore deletes the other's freshly installed copy."""
def test_rollback_calling_install_does_not_deadlock(self, store, monkeypatch):
"""The rollback path holds the per-plugin lock across its call to
install_plugin. A non-reentrant lock would hang the request thread
forever this test would time out rather than fail."""
import threading
mgr, plugins_dir = store
path = _existing_install(plugins_dir, "hockey-scoreboard", "the-original")
monkeypatch.setattr(
mgr, "_install_plugin_impl",
lambda pid, branch=None: bool(_existing_install(plugins_dir, pid, "new")))
done = threading.Event()
result = {}
def run():
result["ok"] = mgr._reinstall_with_rollback("hockey-scoreboard", path)
done.set()
t = threading.Thread(target=run, daemon=True)
t.start()
assert done.wait(timeout=10), (
"install_plugin deadlocked when called from _reinstall_with_rollback "
"— the per-plugin lock must be reentrant"
)
assert result["ok"] is True
def test_concurrent_installs_serialize(self, store, monkeypatch):
"""Two threads installing the same plugin must not interleave their
set-aside/restore, and the survivor must be a complete install."""
import threading
mgr, plugins_dir = store
_existing_install(plugins_dir, "hockey-scoreboard", "the-original")
in_flight = []
overlap = []
def slow_impl(plugin_id, branch=None):
in_flight.append(1)
if len(in_flight) > 1:
overlap.append(1)
threading.Event().wait(0.05)
_existing_install(plugins_dir, plugin_id, "installed")
in_flight.pop()
return True
monkeypatch.setattr(mgr, "_install_plugin_impl", slow_impl)
threads = [threading.Thread(target=mgr.install_plugin,
args=("hockey-scoreboard",), daemon=True)
for _ in range(2)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=10)
assert not t.is_alive(), "concurrent install hung"
assert not overlap, "two installs of the same plugin ran concurrently"
assert (plugins_dir / "hockey-scoreboard" / "marker.txt").exists()
leftovers = [p.name for p in plugins_dir.iterdir() if "backup" in p.name]
assert not leftovers, f"backup left behind: {leftovers}"
+232
View File
@@ -0,0 +1,232 @@
"""The install/update gate, and the shared compatibility rules behind it.
Before this existed, `ledmatrix_min_version` was decoration: the loader logged
an advisory warning and the store never looked at the core version at all, so a
routine store update happily delivered a plugin that could not run. Deleting a
plugin's bundled fallback under those conditions would have handed un-updated
users a scoreboard that fails to load with one line in the journal.
The rules being pinned here, in priority order:
1. Refuse only on **evidence**. Undeclared floor, unparseable version on either
side, or a core whose self-reported version is untrustworthy allow. A
wrong refusal breaks a working install; a wrong allowance degrades to the
behavior we already had.
2. A core below `TRUSTWORTHY_FLOOR` is *unknown*, not old. The v3.1.0 release
reports `1.0.0` while nearly every manifest floors at `2.0.0`; blocking on
that number would stop those users installing anything at all.
3. The loader and the store must agree, because they read the same manifests.
"""
import json
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from src.plugin_system import compatibility
# --------------------------------------------------------------------------
# Floor resolution — every spelling published plugins actually use
# --------------------------------------------------------------------------
class TestDeclaredMinVersion:
def test_top_level_min_ledmatrix_version(self):
assert compatibility.declared_min_version(
{"min_ledmatrix_version": "3.2.0"}) == "3.2.0"
def test_requires_block(self):
assert compatibility.declared_min_version(
{"requires": {"min_ledmatrix_version": "3.1.0"}}) == "3.1.0"
def test_versions_array_new_spelling(self):
assert compatibility.declared_min_version(
{"versions": [{"ledmatrix_min_version": "3.2.0"}]}) == "3.2.0"
def test_versions_array_deprecated_spelling(self):
"""Most published manifests still say `ledmatrix_min`; ignoring it
would silently exempt them from the gate."""
assert compatibility.declared_min_version(
{"versions": [{"ledmatrix_min": "2.0.0"}]}) == "2.0.0"
def test_absent(self):
assert compatibility.declared_min_version({"id": "x"}) is None
def test_requires_present_but_null(self):
assert compatibility.declared_min_version({"requires": None}) is None
# --------------------------------------------------------------------------
# The decision itself
# --------------------------------------------------------------------------
class TestCheck:
def test_blocks_when_plugin_needs_a_newer_core(self):
ok, reason = compatibility.check(
{"name": "Hockey Scoreboard", "min_ledmatrix_version": "3.2.0"}, "3.1.0")
assert ok is False
assert "3.2.0" in reason and "3.1.0" in reason
assert "Hockey Scoreboard" in reason
def test_allows_equal_version(self):
ok, _ = compatibility.check({"min_ledmatrix_version": "3.2.0"}, "3.2.0")
assert ok is True
def test_allows_newer_core(self):
ok, _ = compatibility.check({"min_ledmatrix_version": "3.2.0"}, "4.0.0")
assert ok is True
def test_allows_when_no_floor_declared(self):
ok, reason = compatibility.check({"id": "x"}, "3.2.0")
assert ok is True and reason is None
def test_untrustworthy_core_version_allows_everything(self):
"""The v3.1.0 release reports 1.0.0. Nearly every manifest floors at
2.0.0, so blocking here would stop those users installing any plugin
at all strictly worse than the problem being solved."""
ok, reason = compatibility.check(
{"min_ledmatrix_version": "3.2.0"}, "1.0.0")
assert ok is True and reason is None
def test_unparseable_core_version_allows(self):
ok, _ = compatibility.check({"min_ledmatrix_version": "3.2.0"}, "not-a-version")
assert ok is True
def test_unparseable_floor_allows(self):
ok, _ = compatibility.check({"min_ledmatrix_version": {"nope": 1}}, "3.2.0")
assert ok is True
def test_v_prefix_tolerated_on_both_sides(self):
ok, _ = compatibility.check({"min_ledmatrix_version": "v3.3.0"}, "v3.2.0")
assert ok is False
@pytest.mark.parametrize("floor,core,expected_ok", [
("3.2.0", "3.2.1", True),
("3.2.1", "3.2.0", False),
("3.10.0", "3.9.0", False), # numeric compare, not lexical
("3.9.0", "3.10.0", True),
])
def test_ordering(self, floor, core, expected_ok):
ok, _ = compatibility.check({"min_ledmatrix_version": floor}, core)
assert ok is expected_ok
# --------------------------------------------------------------------------
# The gate in install_plugin
# --------------------------------------------------------------------------
def _write_plugin(plugins_dir: Path, plugin_id: str, manifest: dict) -> Path:
path = plugins_dir / plugin_id
path.mkdir(parents=True)
(path / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
(path / "manager.py").write_text("class P: pass\n", encoding="utf-8")
return path
@pytest.fixture
def store(tmp_path, monkeypatch):
"""A PluginStoreManager whose download step is stubbed to drop a plugin
directory in place, so the test exercises the post-download validation
path without touching the network."""
from src.plugin_system.store_manager import PluginStoreManager
plugins_dir = tmp_path / "plugin-repos"
plugins_dir.mkdir()
mgr = PluginStoreManager(plugins_dir=str(plugins_dir))
mgr.logger = MagicMock()
return mgr, plugins_dir
class TestInstallGate:
"""`install_plugin` is the chokepoint: `_reinstall_with_rollback` calls it,
so gating there covers updates too, and a refused update restores the
version the user already had."""
def _install_with_manifest(self, store, manifest, core_version, monkeypatch):
mgr, plugins_dir = store
plugin_id = manifest["id"]
monkeypatch.setattr(
mgr, "get_plugin_info",
lambda *a, **k: {"repo": "https://example.invalid/r",
"plugin_path": f"plugins/{plugin_id}",
"branch": "main"})
# Stand in for the download: put the files where install_plugin expects.
monkeypatch.setattr(
mgr, "_install_from_monorepo",
lambda *a, **k: bool(_write_plugin(plugins_dir, plugin_id, manifest)))
monkeypatch.setattr(mgr, "_install_from_monorepo_api", lambda *a, **k: False)
monkeypatch.setattr(mgr, "_install_dependencies", lambda *a, **k: True)
import src
monkeypatch.setattr(src, "__version__", core_version)
return mgr.install_plugin(plugin_id), plugins_dir / plugin_id
def test_refuses_and_leaves_nothing_behind(self, store, monkeypatch):
manifest = {
"id": "needs-newer", "name": "Needs Newer", "class_name": "P",
"display_modes": ["a"], "min_ledmatrix_version": "9.9.9",
}
ok, path = self._install_with_manifest(store, manifest, "3.2.0", monkeypatch)
assert ok is False, "install must refuse a plugin that needs a newer core"
assert not path.exists(), (
"a refused install must not leave a half-installed directory — "
"plugin discovery would pick it up and fail to load it")
def test_allows_a_compatible_plugin(self, store, monkeypatch):
manifest = {
"id": "fine", "name": "Fine", "class_name": "P",
"display_modes": ["a"], "min_ledmatrix_version": "3.0.0",
}
ok, path = self._install_with_manifest(store, manifest, "3.2.0", monkeypatch)
assert ok is True
assert (path / "manifest.json").exists()
def test_untrustworthy_core_does_not_block_installs(self, store, monkeypatch):
"""Regression guard for the worst possible outcome of this feature:
users on the v3.1.0 release (which reports 1.0.0) must not be locked
out of the plugin store entirely."""
manifest = {
"id": "floored", "name": "Floored", "class_name": "P",
"display_modes": ["a"], "versions": [{"ledmatrix_min": "2.0.0"}],
}
ok, path = self._install_with_manifest(store, manifest, "1.0.0", monkeypatch)
assert ok is True, (
"a core below the trustworthy floor must not block installs — "
"nearly every published manifest floors at 2.0.0")
assert (path / "manifest.json").exists()
class TestLoaderAndStoreAgree:
"""Both read the same manifests; a disagreement means one of them is
lying to the user."""
@pytest.mark.parametrize("manifest,core,expected", [
({"min_ledmatrix_version": "3.2.0"}, "3.1.0", False),
({"versions": [{"ledmatrix_min": "2.0.0"}]}, "3.2.0", True),
({"versions": [{"ledmatrix_min_version": "9.0.0"}]}, "3.2.0", False),
({}, "3.2.0", True),
])
def test_same_verdict(self, manifest, core, expected):
from src.plugin_system.plugin_loader import PluginLoader
store_ok, _ = compatibility.check(manifest, core)
assert store_ok is expected
# The loader resolves the floor through the same helper, so a
# divergence in spelling handling would show up here.
loader_needed = compatibility.parse_semver(
compatibility.declared_min_version(manifest))
current = compatibility.parse_semver(core)
loader_would_warn = (
loader_needed is not None
and current is not None
and current >= compatibility.TRUSTWORTHY_FLOOR
and loader_needed > current
)
assert loader_would_warn is (not expected)
assert hasattr(PluginLoader, "_warn_if_incompatible")
+120
View File
@@ -252,3 +252,123 @@ class TestBasePlugin:
plugin = ConcretePlugin("test", config, mock_display_manager, mock_cache_manager, None) plugin = ConcretePlugin("test", config, mock_display_manager, mock_cache_manager, None)
assert plugin.has_live_priority() is True assert plugin.has_live_priority() is True
class TestBasePluginGlobalConfig:
"""global_config exposes device-wide settings that self.config cannot.
The sports scoreboards read `getattr(self, 'global_config', {})` to find
the shared target_fps; before this property existed nothing ever set that
attribute, so the lookup silently returned {} and the setting could never
take effect on any core.
"""
@staticmethod
def _plugin(display_manager, cache_manager, plugin_manager=None):
from src.plugin_system.base_plugin import BasePlugin
class ConcretePlugin(BasePlugin):
def update(self): pass
def display(self, force_clear=False): pass
return ConcretePlugin(
"test", {"enabled": True}, display_manager, cache_manager, plugin_manager
)
@staticmethod
def _manager_with(config):
"""A stand-in manager exposing config_manager.get_config()."""
manager = MagicMock()
manager.config_manager.get_config.return_value = config
return manager
def test_reads_config_from_plugin_manager(self, mock_display_manager, mock_cache_manager):
plugin = self._plugin(
mock_display_manager, mock_cache_manager,
self._manager_with({"target_fps": 100}),
)
assert plugin.global_config["target_fps"] == 100
def test_falls_back_to_cache_manager(self, mock_display_manager):
# The core that hangs config_manager off the cache manager instead.
cache_manager = self._manager_with({"target_fps": 75})
plugin = self._plugin(mock_display_manager, cache_manager, plugin_manager=None)
assert plugin.global_config["target_fps"] == 75
def test_plugin_manager_wins_over_cache_manager(self, mock_display_manager):
plugin = self._plugin(
mock_display_manager,
self._manager_with({"target_fps": 75}),
self._manager_with({"target_fps": 100}),
)
assert plugin.global_config["target_fps"] == 100
def test_empty_plugin_manager_config_falls_through(self, mock_display_manager):
"""An empty first source means "not loaded yet", not "the answer".
Both managers default to the same config/config.json, so falling
through cannot pick up a different file. Returning {} here instead
would silently disable every setting read through this property --
the exact failure this property exists to fix.
"""
plugin = self._plugin(
mock_display_manager,
self._manager_with({"target_fps": 100}), # cache_manager
self._manager_with({}), # plugin_manager: empty
)
assert plugin.global_config["target_fps"] == 100
def test_returns_empty_dict_when_no_config_manager(self, mock_display_manager):
# Plain objects: no config_manager attribute at all.
plugin = self._plugin(mock_display_manager, object(), object())
assert plugin.global_config == {}
def test_unreadable_config_does_not_raise(self, mock_display_manager):
# A plugin must still load when the config on disk is broken.
broken = MagicMock()
broken.config_manager.get_config.side_effect = OSError("unreadable")
plugin = self._plugin(mock_display_manager, broken, broken)
assert plugin.global_config == {}
def test_non_dict_config_is_rejected(self, mock_display_manager):
# A stub or half-built manager can return a non-mapping; handing that
# back would blow up later in numeric code, far from the cause.
plugin = self._plugin(
mock_display_manager, object(), self._manager_with("not-a-dict")
)
assert plugin.global_config == {}
def test_missing_property_degrades_to_default(self, mock_display_manager, mock_cache_manager):
# How plugins actually call it, so a plugin written against this core
# still loads on one that predates the property.
plugin = self._plugin(mock_display_manager, mock_cache_manager, object())
assert getattr(plugin, "global_config", {}).get("target_fps") is None
def test_plugin_may_still_assign_global_config(self, mock_display_manager, mock_cache_manager):
# news, stock-news, ledmatrix-stocks, ledmatrix-elections,
# ledmatrix-leaderboard and nfl-draft all do exactly this. Without a
# setter the property raises "has no setter" and those plugins stop
# loading entirely.
from src.plugin_system.base_plugin import BasePlugin
class AssigningPlugin(BasePlugin):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.global_config = self.config.get("global", {})
def update(self): pass
def display(self, force_clear=False): pass
plugin = AssigningPlugin(
"news", {"enabled": True, "global": {"scroll_speed": 2}},
mock_display_manager, mock_cache_manager, self._manager_with({"target_fps": 100}),
)
# The plugin's own value wins over the resolved config.
assert plugin.global_config == {"scroll_speed": 2}
def test_template_ships_a_global_target_fps(self):
# The plumbing is useless if the setting isn't in the shipped config.
import json
with open("config/config.template.json") as fh:
template = json.load(fh)
assert template.get("target_fps") == 100
+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)
+660
View File
@@ -0,0 +1,660 @@
"""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 reads competitor["statistics"] for shot counts;
# it now defaults to an empty list when the key is absent rather than
# dropping the whole event (see
# test_hockey_event_without_statistics_still_extracts).
"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
def test_baseball_live_without_top_level_status_extracts_for_favorites(self):
# The favourite-team branch logs the status payload for diagnostics and
# read the same event top-level key the test above proves can be
# absent. So the identical MiLB event that extracts fine for a
# non-favourite raised KeyError and was dropped once the team WAS a
# favourite -- the worst shape for the bug, since it only hit the games
# the user cared most about, and only on the diagnostic path that was
# supposed to help debug them.
event = make_event("413", "in", "2026-07-16T23:05:00Z", period=7)
del event["status"]
details = extract(Baseball, event, favorites=["TB"])
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())
# Rig requests.Session BEFORE any manager is built. Construction creates
# both SportsCore.session and the ESPNDataSource.session; replacing only
# manager.session after the fact (below) leaves data_source.session real,
# so an accidental fetch during or right after construction could reach
# the network. Patching the class makes every session created here raise.
def _offline_get(*args, **kwargs):
raise requests.exceptions.ConnectionError(
"characterization tests are offline")
monkeypatch.setattr(requests.Session, "get", _offline_get)
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
+880
View File
@@ -0,0 +1,880 @@
"""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
import time
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)
@pytest.mark.parametrize("bad", [
SimpleRotation(), # an instance, not the class
str, # unrelated class
lambda **kw: None, # a factory function
])
def test_a_non_strategy_factory_is_rejected(self, bad):
"""Fail at registration, not several frames later inside schedule(),
where the cause is no longer on the stack."""
with pytest.raises(TypeError):
register_rotation_strategy("bad-factory", bad)
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}
def test_huge_weights_are_clamped(self):
"""A cycle is sum(weights) long and each step scans every game, so an
unbounded weight from a misread config spins the display thread."""
strategy = get_rotation_strategy("weighted", weight_for=lambda g: 10_000)
assert strategy.weights([game("a")]) == {"a": RotationStrategy.MAX_WEIGHT}
def test_a_clamped_cycle_stays_bounded(self):
strategy = get_rotation_strategy("weighted", weight_for=lambda g: 10_000)
order = strategy.schedule([game("a"), game("b")])
assert len(order) == 2 * RotationStrategy.MAX_WEIGHT
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_preview_uses_the_subclass_ordering(self):
"""schedule() promises the order repeated next_game calls produce. Built
from the base class, a subclass that overrides next_game gets a preview
of the wrong algorithm."""
class Reversed(SmoothWeightedRotation):
def next_game(self, games):
return super().next_game(list(reversed(games)))
games = [game("a"), game("b"), game("c")]
strategy = Reversed()
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
@pytest.mark.parametrize("bad", ["eight", None, {}, []])
def test_unusable_duration_falls_back(self, celebrating, bad):
"""The duration is compared numerically on the display path, outside
any try block a string from a hand-edited config would propagate a
TypeError straight out of display()."""
manager = celebrating(mode_config={"celebration_duration": bad})
assert manager.celebration_duration == 8.0
@pytest.mark.parametrize("bad", [0, -5])
def test_non_positive_duration_is_floored(self, celebrating, bad):
"""Zero or negative would arm a celebration that can never render."""
manager = celebrating(mode_config={"celebration_duration": bad})
assert manager.celebration_duration == 1.0
def test_numeric_string_duration_is_accepted(self, celebrating):
assert celebrating(
mode_config={"celebration_duration": "12"}).celebration_duration == 12.0
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):
# A short-but-valid duration: celebration_duration is clamped to a 1.0s
# floor, so a config of 0 does NOT expire on the next frame. Backdate
# started_at past the window to exercise the real expiry branch —
# otherwise the celebration is still active and this only passes
# because _draw_celebration_layout happens to raise in the harness
# (that path is covered by test_a_render_failure_falls_through).
manager = celebrating(mode_config={"celebration_duration": 1})
manager._check_for_score(game("g1", home_score=0))
manager._check_for_score(game("g1", home_score=1))
manager.active_celebration["started_at"] = time.time() - 2 # past the 1s window
# Mock the layout so a render can't raise: otherwise the exception
# branch clears the celebration too, and this test would pass whether
# or not expiry actually fired. An expired celebration must NOT render.
manager._draw_celebration_layout = MagicMock()
assert manager.display() is True
manager._draw_celebration_layout.assert_not_called()
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": 1})
manager._check_for_score(game("g1", home_score=0))
manager._check_for_score(game("g1", home_score=1))
manager.active_celebration["started_at"] = time.time() - 2 # past the 1s window
manager._draw_celebration_layout = MagicMock() # expiry, not a render failure
manager.display()
manager._draw_celebration_layout.assert_not_called()
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_a_render_failure_disarms_rather_than_retrying(self, celebrating):
"""Left armed, the same render fails on every frame for the rest of the
window a traceback per frame, and no scorebug."""
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"))
manager.display()
assert manager.active_celebration is None
manager.display()
assert manager._draw_celebration_layout.call_count == 1
class TestBaselinePruning:
"""`_score_baselines` gains an entry per game and only _check_for_win ever
removed one, so a board running all season grows the dict without bound."""
def test_prunes_games_no_longer_live(self, celebrating):
manager = celebrating()
for gid in ("g1", "g2", "g3"):
manager._check_for_score(game(gid, home_score=1))
manager.prune_score_baselines([game("g2")])
assert set(manager._score_baselines) == {"g2"}
def test_keeps_every_still_live_game(self, celebrating):
manager = celebrating()
for gid in ("g1", "g2"):
manager._check_for_score(game(gid, home_score=1))
manager.prune_score_baselines([game("g1"), game("g2")])
assert set(manager._score_baselines) == {"g1", "g2"}
def test_empty_live_set_clears_everything(self, celebrating):
manager = celebrating()
manager._check_for_score(game("g1", home_score=1))
manager.prune_score_baselines([])
assert manager._score_baselines == {}
def test_pruning_does_not_disturb_a_surviving_baseline(self, celebrating):
manager = celebrating()
manager._check_for_score(game("g1", home_score=2))
manager.prune_score_baselines([game("g1")])
manager._check_for_score(game("g1", home_score=3))
assert manager.active_celebration is not None, (
"pruning must not drop a live game's baseline and re-trigger the "
"first-sighting suppression")
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")])
+613
View File
@@ -0,0 +1,613 @@
"""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._warning_cooldowns["api"] -= 61
assert manager._should_log("api", cooldown=60) is True
def test_cooldowns_are_tracked_per_warning_type(self, build):
"""The parameter was accepted and ignored: one shared timestamp meant an
API warning silenced an unrelated cache warning for the next minute."""
manager = build()
assert manager._should_log("api", cooldown=60) is True
assert manager._should_log("cache", cooldown=60) is True
assert manager._should_log("api", cooldown=60) is False
assert manager._should_log("cache", cooldown=60) is False
def test_one_type_expiring_does_not_free_another(self, build):
manager = build()
manager._should_log("api")
manager._should_log("cache")
manager._warning_cooldowns["api"] -= 61
assert manager._should_log("api") is True
assert manager._should_log("cache") is False
def test_legacy_single_clock_field_is_kept_in_step(self, build):
"""Subclasses in the plugin copies read _last_warning_time directly."""
manager = build()
manager._should_log("api")
assert manager._last_warning_time == manager._warning_cooldowns["api"]
# ---------------------------------------------------------------------------
# 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__
+585
View File
@@ -0,0 +1,585 @@
"""Tests for src/common/sports_scroll.py (phase B3).
The module is drawn along the split the survey found: the orchestration layer
is shared, the content layer is not. Two things are worth asserting beyond
"it works":
* ``prepare_scroll_content`` must stay an override point a base class that
quietly rendered *something* would let a plugin ship a blank scroll.
* ``target_fps`` must actually reach the helper. That is the whole reason this
module exists upstream; the bundled plugin copies hardcode ~100 FPS.
"""
import logging
import sys
from unittest.mock import MagicMock
import pytest
from PIL import Image
sys.modules.setdefault("rgbmatrix", MagicMock())
from src.common.sports_scroll import ( # noqa: E402
DEFAULT_SCROLL_SETTINGS,
MAX_PIXELS_PER_FRAME,
MIN_PIXELS_PER_FRAME,
SportsScrollDisplay,
SportsScrollDisplayManager,
)
LOGGER = logging.getLogger("test.sports_scroll")
@pytest.fixture
def display_manager():
manager = MagicMock()
manager.matrix.width = 128
manager.matrix.height = 32
return manager
@pytest.fixture(autouse=True)
def fake_scroll_helper(monkeypatch):
"""Replace ScrollHelper with a recording double.
The real helper is covered by test_scroll_helper.py; here what matters is
*which* calls this module makes and with what values.
"""
created = []
def _factory(width, height, logger):
helper = MagicMock()
helper.width, helper.height = width, height
helper.cached_image = None
helper.is_scroll_complete.return_value = False
helper.get_dynamic_duration.return_value = 42
helper.get_scroll_info.return_value = {"position": 0}
created.append(helper)
return helper
monkeypatch.setattr("src.common.sports_scroll.ScrollHelper", _factory)
return created
class _Display(SportsScrollDisplay):
"""A minimal concrete subclass, as a plugin would write it."""
SCROLL_LEAGUE_KEYS = ("nhl", "ncaa_mens")
def prepare_scroll_content(self, games, game_type, leagues, rankings_cache=None):
self._current_games = list(games)
self._current_game_type = game_type
self._current_leagues = list(leagues)
self.scroll_helper.cached_image = Image.new("RGB", (400, 32))
return bool(games)
class _Manager(SportsScrollDisplayManager):
display_class = _Display
@pytest.fixture
def build(display_manager):
def _build(config=None, global_config=None, cls=_Display):
return cls(display_manager, config or {}, LOGGER, global_config=global_config)
return _build
# ---------------------------------------------------------------------------
# Construction
# ---------------------------------------------------------------------------
class TestConstruction:
def test_dimensions_come_from_the_matrix(self, build):
display = build()
assert (display.display_width, display.display_height) == (128, 32)
def test_dimensions_fall_back_when_there_is_no_matrix(self, display_manager):
display_manager.matrix = None
display_manager.width, display_manager.height = 256, 64
display = _Display(display_manager, {}, LOGGER)
assert (display.display_width, display.display_height) == (256, 64)
def test_final_fallback_dimensions(self):
"""A display manager exposing neither must not crash construction."""
bare = MagicMock(spec=[])
display = _Display(bare, {}, LOGGER)
assert (display.display_width, display.display_height) == (128, 32)
def test_global_config_is_optional(self, build):
"""An older caller that doesn't pass it keeps working."""
assert build().global_config == {}
def test_state_starts_empty(self, build):
display = build()
assert display._current_games == []
assert display._vegas_content_items == []
assert display.get_current_game_count() == 0
# ---------------------------------------------------------------------------
# Settings resolution — the league ladder
# ---------------------------------------------------------------------------
class TestScrollSettings:
def test_defaults_when_nothing_is_configured(self, build):
settings = build()._get_scroll_settings()
for key, value in DEFAULT_SCROLL_SETTINGS.items():
assert settings[key] == value
def test_card_width_defaults_to_the_panel_width(self, build):
assert build()._get_scroll_settings()["game_card_width"] == 128
def test_named_league_wins(self, build):
display = build({"nhl": {"scroll_settings": {"scroll_speed": 10}},
"ncaa_mens": {"scroll_settings": {"scroll_speed": 20}}})
assert display._get_scroll_settings("ncaa_mens")["scroll_speed"] == 20
def test_ladder_is_walked_in_order(self, build):
"""The only reason the eight plugin copies differed: which league keys
to try, and in what order."""
display = build({"ncaa_mens": {"scroll_settings": {"scroll_speed": 20}},
"nhl": {"scroll_settings": {"scroll_speed": 10}}})
assert display._get_scroll_settings()["scroll_speed"] == 10
def test_ladder_falls_through_to_the_next_key(self, build):
display = build({"ncaa_mens": {"scroll_settings": {"scroll_speed": 20}}})
assert display._get_scroll_settings()["scroll_speed"] == 20
def test_overrides_merge_onto_defaults(self, build):
display = build({"nhl": {"scroll_settings": {"scroll_speed": 10}}})
settings = display._get_scroll_settings()
assert settings["scroll_speed"] == 10
assert settings["gap_between_games"] == DEFAULT_SCROLL_SETTINGS[
"gap_between_games"]
def test_empty_override_does_not_shadow_the_next_candidate(self, build):
display = build({"nhl": {"scroll_settings": {}},
"ncaa_mens": {"scroll_settings": {"scroll_speed": 20}}})
assert display._get_scroll_settings()["scroll_speed"] == 20
def test_single_block_config_shape(self, build):
"""The afl/nrl/soccer shape: one scroll_mode block, no league concept."""
class _Single(_Display):
SCROLL_LEAGUE_KEYS = ()
SCROLL_CONFIG_KEY = "scroll_mode"
display = build({"scroll_mode": {"scroll_speed": 33}}, cls=_Single)
assert display._get_scroll_settings()["scroll_speed"] == 33
def test_defaults_are_overridable_by_a_subclass(self, build):
class _Wide(_Display):
def scroll_settings_defaults(self):
return {**super().scroll_settings_defaults(),
"gap_between_games": 24}
assert build(cls=_Wide)._get_scroll_settings()["gap_between_games"] == 24
def test_a_null_league_block_is_tolerated(self, build):
"""`config['nhl'] = None` appears in hand-edited configs."""
display = build({"nhl": None})
assert (display._get_scroll_settings()["scroll_speed"]
== DEFAULT_SCROLL_SETTINGS["scroll_speed"])
# ---------------------------------------------------------------------------
# Helper configuration — including the reason this module exists upstream
# ---------------------------------------------------------------------------
class TestConfigureScrollHelper:
def test_speed_is_converted_to_pixels_per_frame(self, build):
display = build({"nhl": {"scroll_settings": {
"scroll_speed": 100.0, "scroll_delay": 0.02}}})
# 100 px/s * 0.02 s/frame = 2 px/frame
display.scroll_helper.set_scroll_speed.assert_called_with(2.0)
def test_conversion_is_clamped_low(self, build):
display = build({"nhl": {"scroll_settings": {
"scroll_speed": 0.001, "scroll_delay": 0.001}}})
display.scroll_helper.set_scroll_speed.assert_called_with(MIN_PIXELS_PER_FRAME)
def test_conversion_is_clamped_high(self, build):
display = build({"nhl": {"scroll_settings": {
"scroll_speed": 5000.0, "scroll_delay": 0.5}}})
display.scroll_helper.set_scroll_speed.assert_called_with(MAX_PIXELS_PER_FRAME)
def test_zero_delay_assumes_a_pacing_instead_of_dividing_by_zero(self, build):
display = build({"nhl": {"scroll_settings": {
"scroll_speed": 100.0, "scroll_delay": 0}}})
display.scroll_helper.set_scroll_speed.assert_called_with(1.0)
def test_frame_based_scrolling_is_enabled(self, build):
build().scroll_helper.set_frame_based_scrolling.assert_called_once_with(True)
def test_dynamic_duration_settings_are_applied(self, build):
display = build({"nhl": {"scroll_settings": {
"dynamic_duration": False, "min_duration": 5, "max_duration": 50}}})
_, kwargs = display.scroll_helper.set_dynamic_duration_settings.call_args
assert kwargs["enabled"] is False
assert kwargs["min_duration"] == 5
assert kwargs["max_duration"] == 50
def test_target_fps_reaches_the_helper(self, build):
"""The whole point of upstreaming: the bundled copies hardcode ~100 FPS
via scroll_delay and never consult the global target."""
display = build(global_config={"target_fps": 120})
display.scroll_helper.set_target_fps.assert_called_once_with(120.0)
def test_legacy_key_is_honored(self, build):
display = build(global_config={"scroll_target_fps": 90})
display.scroll_helper.set_target_fps.assert_called_once_with(90.0)
def test_modern_key_wins_over_legacy(self, build):
display = build(global_config={"target_fps": 120, "scroll_target_fps": 90})
display.scroll_helper.set_target_fps.assert_called_once_with(120.0)
def test_absent_target_fps_leaves_config_pacing_alone(self, build):
build().scroll_helper.set_target_fps.assert_not_called()
@pytest.mark.parametrize("bad", ["fast", None, {}, [], "", 0])
def test_unusable_target_fps_degrades_instead_of_raising(self, build, bad):
"""A malformed global config must cost the FPS target, not the display."""
display = build(global_config={"target_fps": bad})
display.scroll_helper.set_target_fps.assert_not_called()
def test_string_digits_are_accepted(self, build):
display = build(global_config={"target_fps": "120"})
display.scroll_helper.set_target_fps.assert_called_once_with(120.0)
@pytest.mark.parametrize("bad", [None, "fast", {}, []])
def test_unusable_scroll_speed_degrades_instead_of_crashing(self, build, bad):
"""`.get(key, default)` only helps when the key is *absent*. A key
present with null reaches the arithmetic and raises inside __init__,
taking the whole display down before it renders anything."""
display = build({"nhl": {"scroll_settings": {"scroll_speed": bad}}})
# 50.0 px/s * 0.01 s/frame == 0.5 px/frame, i.e. the default speed.
display.scroll_helper.set_scroll_speed.assert_called_with(0.5)
@pytest.mark.parametrize("bad", [None, "slow", {}])
def test_unusable_scroll_delay_degrades_instead_of_crashing(self, build, bad):
display = build({"nhl": {"scroll_settings": {"scroll_delay": bad}}})
display.scroll_helper.set_scroll_delay.assert_called_with(0.01)
def test_numeric_strings_are_accepted(self, build):
display = build({"nhl": {"scroll_settings": {
"scroll_speed": "100", "scroll_delay": "0.02"}}})
display.scroll_helper.set_scroll_speed.assert_called_with(2.0)
def test_fps_clamping_is_left_to_the_helper(self, build):
"""Deliberately not clamped here — a second copy of the range would
drift from ScrollHelper.set_target_fps."""
display = build(global_config={"target_fps": 5000})
display.scroll_helper.set_target_fps.assert_called_once_with(5000.0)
# ---------------------------------------------------------------------------
# The content seam
# ---------------------------------------------------------------------------
class TestContentIsAnOverridePoint:
def test_base_refuses_to_render(self, display_manager):
"""Eight plugins have eight different bodies for this; a base class that
rendered *something* would let a plugin ship a silently blank scroll."""
display = SportsScrollDisplay(display_manager, {}, LOGGER)
with pytest.raises(NotImplementedError, match="prepare_scroll_content"):
display.prepare_scroll_content([], "live", [])
def test_the_error_names_the_offending_class(self, display_manager):
class Incomplete(SportsScrollDisplay):
pass
with pytest.raises(NotImplementedError, match="Incomplete"):
Incomplete(display_manager, {}, LOGGER).prepare_scroll_content(
[], "live", [])
def test_separator_icons_default_to_a_no_op(self, build):
assert build()._separator_icons == {}
# ---------------------------------------------------------------------------
# Frame pumping
# ---------------------------------------------------------------------------
class TestFramePumping:
def test_no_content_means_no_frame(self, build):
assert build().display_scroll_frame() is False
def test_a_frame_is_drawn_and_pushed(self, build):
display = build()
display.prepare_scroll_content([{"id": "g1"}], "live", ["nhl"])
display.scroll_helper.get_visible_portion.return_value = Image.new(
"RGB", (128, 32))
assert display.display_scroll_frame() is True
display.scroll_helper.update_scroll_position.assert_called_once()
display.display_manager.update_display.assert_called_once()
def test_no_visible_portion_means_no_frame(self, build):
display = build()
display.prepare_scroll_content([{"id": "g1"}], "live", ["nhl"])
display.scroll_helper.get_visible_portion.return_value = None
assert display.display_scroll_frame() is False
def test_a_display_failure_is_contained(self, build):
"""A display error must not propagate into the plugin's render loop."""
display = build()
display.prepare_scroll_content([{"id": "g1"}], "live", ["nhl"])
display.scroll_helper.get_visible_portion.return_value = Image.new(
"RGB", (128, 32))
display.display_manager.update_display.side_effect = RuntimeError("boom")
assert display.display_scroll_frame() is False
@pytest.mark.parametrize("failing", ["update_scroll_position",
"get_visible_portion"])
def test_a_scroll_helper_failure_is_contained_too(self, build, failing):
"""These ran outside the try, so a raise there reached the caller's
frame loop despite the stated promise that none can."""
display = build()
display.prepare_scroll_content([{"id": "g1"}], "live", ["nhl"])
getattr(display.scroll_helper, failing).side_effect = RuntimeError("boom")
assert display.display_scroll_frame() is False
def test_frames_are_counted(self, build):
display = build()
display.prepare_scroll_content([{"id": "g1"}], "live", ["nhl"])
display.scroll_helper.get_visible_portion.return_value = Image.new(
"RGB", (128, 32))
for _ in range(3):
display.display_scroll_frame()
assert display._frame_count == 3
def test_progress_logging_is_throttled(self, build):
display = build()
display._log_interval = 10_000
display._last_log_time = 0
display._log_scroll_progress()
first = display._last_log_time
display._log_scroll_progress()
assert display._last_log_time == first
class TestLifecycle:
def test_reset_keeps_content_but_rewinds(self, build):
display = build()
display.prepare_scroll_content([{"id": "g1"}], "live", ["nhl"])
display._frame_count = 9
display.reset_scroll()
display.scroll_helper.reset_scroll.assert_called_once()
assert display._frame_count == 0
assert display._current_games, "reset must not drop content"
def test_clear_drops_everything(self, build):
display = build()
display.prepare_scroll_content([{"id": "g1"}], "live", ["nhl"])
display._vegas_content_items = [Image.new("RGB", (8, 8))]
display.clear()
display.scroll_helper.clear_cache.assert_called_once()
assert display._current_games == []
assert display._current_game_type == ""
assert display._vegas_content_items == []
def test_completion_delegates_to_the_helper(self, build):
display = build()
display.scroll_helper.is_scroll_complete.return_value = True
assert display.is_scroll_complete() is True
def test_dynamic_duration_delegates(self, build):
assert build().get_dynamic_duration() == 42
def test_has_cached_content(self, build):
display = build()
assert display.has_cached_content() is False
display.prepare_scroll_content([{"id": "g1"}], "live", ["nhl"])
assert display.has_cached_content() is True
def test_scroll_info_merges_helper_and_local_state(self, build):
display = build()
display.prepare_scroll_content([{"id": "g1"}], "live", ["nhl"])
info = display.get_scroll_info()
assert info["position"] == 0 # from the helper
assert info["game_count"] == 1 # from this display
assert info["leagues"] == ["nhl"]
def test_leagues_are_returned_as_a_copy(self, build):
display = build()
display.prepare_scroll_content([{"id": "g1"}], "live", ["nhl"])
display.get_current_leagues().append("mutated")
assert display.get_current_leagues() == ["nhl"]
# ---------------------------------------------------------------------------
# Manager
# ---------------------------------------------------------------------------
class TestManager:
@pytest.fixture
def manager(self, display_manager):
return _Manager(display_manager, {}, LOGGER, global_config={"target_fps": 120})
def test_displays_are_created_lazily_and_reused(self, manager):
first = manager.get_scroll_display("live")
assert manager.get_scroll_display("live") is first
assert isinstance(first, _Display)
def test_each_game_type_gets_its_own(self, manager):
assert manager.get_scroll_display("live") is not manager.get_scroll_display(
"recent")
def test_global_config_is_threaded_to_children(self, manager):
"""A missed hand-off here is exactly how the plugin copies ended up
never honoring target_fps."""
child = manager.get_scroll_display("live")
assert child.global_config == {"target_fps": 120}
child.scroll_helper.set_target_fps.assert_called_once_with(120.0)
def test_prepare_sets_the_active_type(self, manager):
assert manager.prepare_and_display([{"id": "g1"}], "live", ["nhl"]) is True
assert manager._current_game_type == "live"
def test_failed_prepare_does_not_become_active(self, manager):
assert manager.prepare_and_display([], "live", ["nhl"]) is False
assert not manager._current_game_type
def test_a_raising_subclass_does_not_escape_the_orchestration(self, manager):
"""prepare_scroll_content is subclass code building cards from feed
data. One sport's bad payload must not take down the others."""
display = manager.get_scroll_display("live")
display.prepare_scroll_content = MagicMock(side_effect=KeyError("status"))
assert manager.prepare_and_display([{"id": "g1"}], "live", ["nhl"]) is False
assert not manager._current_game_type
def test_empty_game_type_sentinel_matches_the_display(self, manager):
"""Both classes must spell 'nothing active' the same way; two spellings
across two classes is a trap for anyone comparing their state."""
manager.prepare_and_display([{"id": "g1"}], "live", ["nhl"])
manager.clear_all()
assert (manager._current_game_type
== manager.get_scroll_display("live")._current_game_type == "")
def test_display_frame_uses_the_active_type(self, manager):
manager.prepare_and_display([{"id": "g1"}], "live", ["nhl"])
display = manager.get_scroll_display("live")
display.scroll_helper.get_visible_portion.return_value = Image.new(
"RGB", (128, 32))
assert manager.display_frame() is True
def test_display_frame_with_no_active_type(self, manager):
assert manager.display_frame() is False
def test_display_frame_for_an_unknown_type(self, manager):
assert manager.display_frame("never-prepared") is False
def test_completion_is_true_when_there_is_nothing_to_scroll(self, manager):
"""A caller waiting on completion must never be wedged by absence."""
assert manager.is_complete() is True
assert manager.is_complete("never-prepared") is True
def test_completion_delegates_to_the_active_display(self, manager):
manager.prepare_and_display([{"id": "g1"}], "live", ["nhl"])
manager.get_scroll_display("live").scroll_helper \
.is_scroll_complete.return_value = True
assert manager.is_complete() is True
def test_clear_all_clears_every_display(self, manager):
manager.prepare_and_display([{"id": "g1"}], "live", ["nhl"])
manager.prepare_and_display([{"id": "g2"}], "recent", ["nhl"])
manager.clear_all()
assert not manager._current_game_type
for game_type in ("live", "recent"):
assert manager.get_scroll_display(game_type)._current_games == []
def test_vegas_items_are_collected_across_displays(self, manager):
for game_type in ("live", "recent"):
manager.get_scroll_display(game_type)._vegas_content_items = [
Image.new("RGB", (8, 8))]
assert len(manager.get_all_vegas_content_items()) == 2
def test_vegas_collection_tolerates_empty_displays(self, manager):
manager.get_scroll_display("live")
assert manager.get_all_vegas_content_items() == []
def test_display_class_is_the_subclass_seam(self, display_manager):
class Other(_Display):
pass
class OtherManager(SportsScrollDisplayManager):
display_class = Other
manager = OtherManager(display_manager, {}, LOGGER)
assert isinstance(manager.get_scroll_display("live"), Other)
# ---------------------------------------------------------------------------
# Integration — against the real ScrollHelper
# ---------------------------------------------------------------------------
class _RealDisplay(SportsScrollDisplay):
"""Builds a strip the way a plugin would, via the real helper API."""
SCROLL_LEAGUE_KEYS = ("nhl",)
def prepare_scroll_content(self, games, game_type, leagues, rankings_cache=None):
settings = self._get_scroll_settings()
self.scroll_helper.create_scrolling_image(
[Image.new("RGB", (100, 32), (20, 20, 20)) for _ in games],
item_gap=settings["gap_between_games"],
)
self._current_games = list(games)
self._current_game_type = game_type
self._current_leagues = list(leagues)
return bool(games)
class _RealManager(SportsScrollDisplayManager):
display_class = _RealDisplay
class TestAgainstTheRealScrollHelper:
"""The mocked tests above pin *which* calls this module makes; these pin
that those calls exist and mean what we think. Without this, a rename in
ScrollHelper would sail past a suite built entirely on MagicMock."""
@pytest.fixture
def real(self, display_manager, monkeypatch):
from src.common.scroll_helper import ScrollHelper
monkeypatch.setattr("src.common.sports_scroll.ScrollHelper", ScrollHelper)
return _RealManager(
display_manager,
{"nhl": {"scroll_settings": {"scroll_speed": 500.0,
"scroll_delay": 0.001}}},
LOGGER,
global_config={"target_fps": 120},
)
def test_configuration_lands_on_the_real_helper(self, real):
helper = real.get_scroll_display("live").scroll_helper
assert helper.target_fps == 120.0
assert helper.frame_based_scrolling is True
assert helper.scroll_speed == pytest.approx(0.5) # 500 px/s * 0.001 s
def test_a_strip_is_built_and_scrolls_to_completion(self, real):
import time
assert real.prepare_and_display(
[{"id": "a"}, {"id": "b"}, {"id": "c"}], "live", ["nhl"]) is True
display = real.get_scroll_display("live")
# 3 cards of 100px + gaps, so the strip is wider than the 128px panel.
assert display.scroll_helper.cached_image.width > 128
# Frame-based scrolling is wall-clock gated on scroll_delay, so the
# loop has to actually pass time rather than spin.
deadline = time.time() + 20
while not real.is_complete() and time.time() < deadline:
real.display_frame()
time.sleep(0.0012)
# Asserted separately so a host too slow to sustain the frame rate
# reports a timeout rather than looking like a scrolling defect.
assert time.time() < deadline, (
"scroll did not finish within 20s — the host may be too slow to "
"sustain the configured frame rate")
assert real.is_complete() is True
assert display.scroll_helper.scroll_position > 128
def test_dynamic_duration_is_a_real_number(self, real):
real.prepare_and_display([{"id": "a"}], "live", ["nhl"])
assert real.get_scroll_display("live").get_dynamic_duration() > 0
+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
+113
View File
@@ -0,0 +1,113 @@
"""Version reporting must have exactly one answer.
`src.__version__` is the canonical core version. The plugin loader compares
plugin `ledmatrix_min_version` floors against it, and the plugin ecosystem
floors on the number recorded in `CHANGELOG.md` so if those two disagree, a
plugin can declare a floor that is satisfied by a core which does not actually
ship the module it needs.
This has already gone wrong once. The `v3.1.0` tag was cut 2026-05-31, but
`src/__init__.py` was not bumped from `"1.0.0"` to `"3.1.0"` until 2026-07-12,
six weeks later. Every device installed from that release reports `1.0.0`,
which is below the `(2, 0, 0)` floor in `PluginLoader._warn_if_incompatible`
so those users get no compatibility warning at all. See
`docs/SPORTS_UNIFICATION.md` (phase B4).
A tag is not available here, so the tag half of the check lives in
`scripts/check_release_version.py`. Wiring that script into CI (on pushed `v*`
tags and published releases) is a follow-up PR; until it lands, run it by hand
before tagging:
python scripts/check_release_version.py v3.2.0
Note: `src.plugin_system.__version__` is deliberately NOT checked. That module
versions the *plugin API* (it sits beside `__api_version__` and is documented as
such), which moves independently of the core version.
"""
import re
from pathlib import Path
import pytest
import src
REPO_ROOT = Path(__file__).resolve().parents[1]
CHANGELOG = REPO_ROOT / "CHANGELOG.md"
# [0-9] rather than \d: \d also matches non-ASCII decimal digits, which int()
# happily parses, so a heading in Arabic-Indic numerals would pass the pattern
# and then mismatch confusingly. [ \t] rather than \s for the same class of
# reason -- \s matches newlines, so "##\n3.2.0" would read as a heading.
SEMVER = re.compile(r"^([0-9]+)\.([0-9]+)\.([0-9]+)$")
# Version headings look like "## 3.2.0". A leading "## Unreleased" section is
# allowed and skipped -- it is where module additions are staged before a bump.
HEADING = re.compile(
r"^##[ \t]+(?P<version>[0-9]+\.[0-9]+\.[0-9]+)[ \t]*$", re.MULTILINE)
def test_core_version_is_semver():
"""A floor comparison parses this string; it has to be parseable."""
assert SEMVER.match(src.__version__), (
f"src.__version__ is {src.__version__!r}, which is not X.Y.Z. "
"The loader's floor comparison cannot parse it."
)
def test_changelog_documents_the_current_version():
"""The newest versioned CHANGELOG heading is the version we claim to be.
Plugins floor on the version recorded in the CHANGELOG as first shipping a
module. If the code says 3.2.0 and the CHANGELOG's newest entry is 3.1.0,
that record points at the wrong release.
"""
text = CHANGELOG.read_text(encoding="utf-8")
headings = HEADING.findall(text)
assert headings, "CHANGELOG.md has no '## X.Y.Z' version headings"
newest = headings[0]
assert newest == src.__version__, (
f"src.__version__ is {src.__version__!r} but the newest CHANGELOG "
f"heading is {newest!r}. Bump one to match the other: the CHANGELOG is "
"what plugin authors read to pick a ledmatrix_min_version floor."
)
def test_changelog_versions_are_ordered_and_unique():
"""A duplicated or out-of-order heading makes 'first release shipping X'
ambiguous, which is exactly the question the sunset rule asks."""
text = CHANGELOG.read_text(encoding="utf-8")
versions = [tuple(int(p) for p in v.split(".")) for v in HEADING.findall(text)]
duplicates = {v for v in versions if versions.count(v) > 1}
assert not duplicates, f"CHANGELOG.md has duplicate version headings: {duplicates}"
assert versions == sorted(versions, reverse=True), (
"CHANGELOG.md version headings are not in descending order; "
f"got {['.'.join(map(str, v)) for v in versions]}"
)
def test_web_interface_version_tracks_the_core():
"""web_interface used to carry its own hardcoded "3.0.0", a third answer to
'what version is this'. It now re-exports the canonical one."""
web_interface = pytest.importorskip(
"web_interface", reason="web_interface needs Flask, which is optional here"
)
assert getattr(web_interface, "__version__", None) == src.__version__, (
"web_interface.__version__ has drifted from src.__version__; it should "
"re-export the canonical value rather than hardcode its own."
)
def test_heading_pattern_is_strict_about_digits_and_whitespace():
"""`\\d` also matches non-ASCII decimal digits and `\\s` matches newlines,
either of which would let a malformed heading through and then fail the
comparison with a confusing message. Pin the tightened patterns."""
assert HEADING.findall("## 3.2.0\n") == ["3.2.0"]
assert HEADING.findall("##\t3.2.0 \n") == ["3.2.0"]
# A bare "##" whose version sits on the next line is not a heading.
assert HEADING.findall("##\n3.2.0\n") == []
# Arabic-Indic digits parse via int() but are not our version format.
assert HEADING.findall("## ٣.٢.٠\n") == []
assert SEMVER.match("٣.٢.٠") is None
+72
View File
@@ -167,6 +167,78 @@ class TestConfigAPI:
'enabled': True, 'copies': 2, 'axis': 'vertical', 'enabled': True, 'copies': 2, 'axis': 'vertical',
} }
def test_save_target_fps(self, client, mock_config_manager):
"""The device-wide scroll frame rate persists as a top-level int."""
response = client.post(
'/api/v3/config/main',
data={'target_fps': '90'},
content_type='application/x-www-form-urlencoded',
)
assert response.status_code == 200
saved = mock_config_manager.save_config_atomic.call_args[0][0]
# Must be the coerced int, not the raw form string -- the generic
# remaining-keys loop would otherwise write '90' back over it.
assert saved['target_fps'] == 90
def test_save_target_fps_alone_does_not_reset_other_general_settings(
self, client, mock_config_manager):
"""A target_fps-only POST must not be treated as a full General-tab save.
The general branch reads web_display_autostart as an unchecked-checkbox
(absent means False), so counting target_fps as a general update would
silently switch autostart off for anyone setting only the frame rate.
"""
mock_config_manager.load_config.return_value['web_display_autostart'] = True
response = client.post(
'/api/v3/config/main',
data={'target_fps': '90'},
content_type='application/x-www-form-urlencoded',
)
assert response.status_code == 200
saved = mock_config_manager.save_config_atomic.call_args[0][0]
assert saved['web_display_autostart'] is True
@pytest.mark.parametrize('value', [90.5, 90.0, True])
def test_save_target_fps_rejects_non_integer_json(self, client, mock_config_manager, value):
"""int() would truncate silently: 90.5 -> 90, True -> 1.
Only JSON can carry these; a form post sends '90.5', which int()
already rejects.
"""
response = client.post(
'/api/v3/config/main',
data=json.dumps({'target_fps': value}),
content_type='application/json',
)
assert response.status_code == 400
@pytest.mark.parametrize('value', ['20', '250', 'fast'])
def test_save_target_fps_rejects_out_of_range(self, client, mock_config_manager, value):
"""Values ScrollHelper would silently clamp are reported instead."""
response = client.post(
'/api/v3/config/main',
data={'target_fps': value},
content_type='application/x-www-form-urlencoded',
)
assert response.status_code == 400
def test_save_target_fps_accepts_bounds(self, client, mock_config_manager):
"""Both endpoints of the documented range are valid."""
for value in ('30', '200'):
response = client.post(
'/api/v3/config/main',
data={'target_fps': value},
content_type='application/x-www-form-urlencoded',
)
assert response.status_code == 200, f"{value} should be accepted"
saved = mock_config_manager.save_config_atomic.call_args[0][0]
assert saved['target_fps'] == int(value)
def test_save_double_sided_unchecked_disables(self, client, mock_config_manager): def test_save_double_sided_unchecked_disables(self, client, mock_config_manager):
"""An omitted 'enabled' checkbox is saved as disabled, not left stale.""" """An omitted 'enabled' checkbox is saved as disabled, not left stale."""
response = client.post( response = client.post(
+6 -1
View File
@@ -2,5 +2,10 @@
LED Matrix Web Interface V3 LED Matrix Web Interface V3
Modern web interface for controlling the LED Matrix display Modern web interface for controlling the LED Matrix display
""" """
__version__ = "3.0.0"
# Re-exported, never hardcoded. This used to carry its own "3.0.0", a third
# answer to "what version is this" alongside the tag and src.__version__ —
# and disagreeing version numbers are what made plugin compatibility floors
# untrustworthy (see docs/SPORTS_UNIFICATION.md, phase B4).
from src import __version__ # noqa: F401
+117 -7
View File
@@ -747,6 +747,36 @@ def save_main_config():
if 'timezone' in data: if 'timezone' in data:
current_config['timezone'] = data['timezone'] current_config['timezone'] = data['timezone']
# Device-wide scroll frame rate, read by plugins via
# BasePlugin.global_config. Bounds match ScrollHelper.set_target_fps,
# which clamps silently -- rejecting here instead means a value that
# would have been quietly altered is reported rather than appearing to
# save and then behaving differently.
if 'target_fps' in data and data['target_fps'] not in ('', None):
raw_target_fps = data['target_fps']
# A JSON body can carry real floats and bools, where int() would
# silently truncate: 90.5 would save as 90, and true as 1. Reject
# them rather than storing a value the user did not ask for. Form
# posts arrive as strings, so '90.5' still fails in int() below.
if isinstance(raw_target_fps, (bool, float)):
return jsonify({
'status': 'error',
'message': "Invalid value for target_fps: must be an integer"
}), 400
try:
target_fps = int(raw_target_fps)
except (ValueError, TypeError):
return jsonify({
'status': 'error',
'message': "Invalid value for target_fps: must be an integer"
}), 400
if not (30 <= target_fps <= 200):
return jsonify({
'status': 'error',
'message': "Invalid value for target_fps: must be between 30 and 200"
}), 400
current_config['target_fps'] = target_fps
# Handle location settings # Handle location settings
if 'city' in data or 'state' in data or 'country' in data: if 'city' in data or 'state' in data or 'country' in data:
if 'location' not in current_config: if 'location' not in current_config:
@@ -918,7 +948,15 @@ def save_main_config():
# Handle Vegas scroll mode settings # Handle Vegas scroll mode settings
vegas_fields = ['vegas_scroll_enabled', 'vegas_scroll_speed', 'vegas_separator_width', 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 any(k in data for k in vegas_fields):
if 'display' not in current_config: if 'display' not in current_config:
@@ -933,13 +971,85 @@ def save_main_config():
# was submitted (any vegas field present) but enabled key is missing, # was submitted (any vegas field present) but enabled key is missing,
# the checkbox was unchecked and we should set enabled=False # the checkbox was unchecked and we should set enabled=False
vegas_config['enabled'] = _coerce_to_bool(data.get('vegas_scroll_enabled')) 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 = { numeric_fields = {
'vegas_scroll_speed': ('scroll_speed', 1, 100), 'vegas_scroll_speed': ('scroll_speed', 1, 200),
'vegas_separator_width': ('separator_width', 0, 500), 'vegas_separator_width': ('separator_width', 0, 128),
'vegas_target_fps': ('target_fps', 1, 200), 'vegas_intra_plugin_gap': ('intra_plugin_gap', 0, 128),
'vegas_buffer_ahead': ('buffer_ahead', 1, 20), '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(): for field_name, (config_key, min_val, max_val) in numeric_fields.items():
if field_name in data: if field_name in data:
@@ -1202,7 +1312,7 @@ def save_main_config():
if key in ['timezone', 'city', 'state', 'country', if key in ['timezone', 'city', 'state', 'country',
'web_display_autostart', 'auto_discover', 'web_display_autostart', 'auto_discover',
'auto_load_enabled', 'development_mode', 'auto_load_enabled', 'development_mode',
'plugins_directory']: 'plugins_directory', 'target_fps']:
continue continue
# Skip fields that are already handled above in their own named sections. # Skip fields that are already handled above in their own named sections.
# Without this, every form field name lands as a top-level config key too. # Without this, every form field name lands as a top-level config key too.
@@ -425,7 +425,7 @@
</div> </div>
<div class="form-group" id="setting-display-vegas_separator_width" data-setting-key="display.vegas_scroll.separator_width"> <div class="form-group" id="setting-display-vegas_separator_width" data-setting-key="display.vegas_scroll.separator_width">
<label for="vegas_separator_width" class="block text-sm font-medium text-gray-700">Separator Width (pixels){{ ui.help_tip('Blank gap inserted between each plugin block in the ticker (0128 px).\nDefault: 32. Larger values make the boundary between plugins clearer.', 'Separator Width') }}</label> <label for="vegas_separator_width" class="block text-sm font-medium text-gray-700">Separator Width (pixels){{ ui.help_tip('Blank gap where one plugin hands off to the next (0128 px).\nDefault: 32. Larger values make the boundary between plugins clearer. This does not apply between rows of the same plugin — see Row Gap for that.', 'Separator Width') }}</label>
<input type="number" <input type="number"
id="vegas_separator_width" id="vegas_separator_width"
name="vegas_separator_width" name="vegas_separator_width"
@@ -436,6 +436,44 @@
</div> </div>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group" id="setting-display-vegas_intra_plugin_gap" data-setting-key="display.vegas_scroll.intra_plugin_gap">
<label for="vegas_intra_plugin_gap" class="block text-sm font-medium text-gray-700">Row Gap (pixels){{ ui.help_tip('Extra gap always added between rows contributed by the same plugin (0128 px).\nDefault: 8. This is a floor on top of Row Separation below, which does most of the work. Set both to 0 to butt rows directly together.', 'Row Gap') }}</label>
<input type="number"
id="vegas_intra_plugin_gap"
name="vegas_intra_plugin_gap"
value="{{ main_config.display.get('vegas_scroll', {}).get('intra_plugin_gap', 8) }}"
min="0"
max="128"
class="form-control">
</div>
<div class="form-group" id="setting-display-vegas_min_content_separation" data-setting-key="display.vegas_scroll.min_content_separation">
<label for="vegas_min_content_separation" class="block text-sm font-medium text-gray-700">Row Separation (pixels){{ ui.help_tip('Blank space guaranteed between rows of the same plugin, measured from the actual content rather than added blindly (0256 px).\nDefault: 24. Rows already carrying wide margins get nothing added; rows drawn right up to their own edges — sports score cards, for instance — get the full amount, so they no longer look like they are touching. Raise it if items still feel cramped.', 'Row Separation') }}</label>
<input type="number"
id="vegas_min_content_separation"
name="vegas_min_content_separation"
value="{{ main_config.display.get('vegas_scroll', {}).get('min_content_separation', 24) }}"
min="0"
max="256"
class="form-control">
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group" id="setting-display-vegas_render_width_pct" data-setting-key="display.vegas_scroll.render_width_pct">
<label for="vegas_render_width_pct" class="block text-sm font-medium text-gray-700">Plugin Render Width (%){{ ui.help_tip('How much of the screen width each plugin is told it has while drawing for the ticker (10100%).\nDefault: 100 (unchanged). Lowering it makes plugins choose a tighter layout rather than being cropped — a weather forecast becomes narrow cards instead of five columns spread across the panel. Useful on wide displays. Override per plugin with the vegas_width_pct setting in that plugin\'s own configuration.', 'Plugin Render Width') }}</label>
<input type="number"
id="vegas_render_width_pct"
name="vegas_render_width_pct"
value="{{ main_config.display.get('vegas_scroll', {}).get('render_width_pct', 100) }}"
min="10"
max="100"
step="5"
class="form-control">
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group" id="setting-display-vegas_target_fps" data-setting-key="display.vegas_scroll.target_fps"> <div class="form-group" id="setting-display-vegas_target_fps" data-setting-key="display.vegas_scroll.target_fps">
<label for="vegas_target_fps" class="block text-sm font-medium text-gray-700">Target FPS{{ ui.help_tip('Frames per second the Vegas ticker aims to render.\nHigher = smoother scrolling but more CPU. Default: 125 (smoothest). Drop to 60/90 if the Pi runs hot.', 'Target FPS') }}</label> <label for="vegas_target_fps" class="block text-sm font-medium text-gray-700">Target FPS{{ ui.help_tip('Frames per second the Vegas ticker aims to render.\nHigher = smoother scrolling but more CPU. Default: 125 (smoothest). Drop to 60/90 if the Pi runs hot.', 'Target FPS') }}</label>
@@ -456,6 +494,194 @@
</div> </div>
</div> </div>
<!-- Cycle Pacing -->
<div class="mt-4 pt-4 border-t border-gray-200">
<h4 class="text-sm font-medium text-gray-900 mb-3">Cycle Pacing</h4>
<p class="text-sm text-gray-600 mb-3">How long one pass through the ticker lasts, and how many plugins it covers.</p>
<div class="form-group mb-4" id="setting-display-vegas_continuous_scroll" data-setting-key="display.vegas_scroll.continuous_scroll">
<label class="flex items-center">
<input type="checkbox"
id="vegas_continuous_scroll"
name="vegas_continuous_scroll"
{% if main_config.display.get('vegas_scroll', {}).get('continuous_scroll', True) %}checked{% endif %}
class="form-checkbox">
<span class="ml-2 text-sm text-gray-700">Scroll continuously between groups{{ ui.help_tip('Keep one endless strip, extending it with the next group of plugins as the scroll approaches the end, so they simply arrive from the right. Default: on.\nWith this off the ticker builds a fresh strip and swaps it in, which stops the motion, replaces everything at once and restarts with the screen already full — a freeze, a flash and a jump.', 'Continuous Scroll') }}</span>
</label>
</div>
<div class="form-group mb-4" id="setting-display-vegas_smooth_scroll" data-setting-key="display.vegas_scroll.smooth_scroll">
<label class="flex items-center">
<input type="checkbox"
id="vegas_smooth_scroll"
name="vegas_smooth_scroll"
{% if main_config.display.get('vegas_scroll', {}).get('smooth_scroll', True) %}checked{% endif %}
class="form-checkbox">
<span class="ml-2 text-sm text-gray-700">Smooth sub-pixel motion{{ ui.help_tip('Blend between neighbouring pixel positions so the ticker moves once per rendered frame instead of once per pixel. Default: on.\nWithout it, motion happens only as often as the scroll speed in pixels per second — at 50 px/s that is 50 steps a second however fast the display renders, which reads as a slight judder. The trade is that text softens very slightly horizontally, since each frame blends two positions. Turn it off if you prefer maximum crispness.', 'Smooth Scrolling') }}</span>
</label>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div class="form-group" id="setting-display-vegas_extend_threshold_screens" data-setting-key="display.vegas_scroll.extend_threshold_screens">
<label for="vegas_extend_threshold_screens" class="block text-sm font-medium text-gray-700">Extend When (screens left){{ ui.help_tip('How much unscrolled content triggers loading the next group, measured in screen widths (1.010.0).\nDefault: 2. Higher loads earlier and leaves more slack, at the cost of holding more content in memory. Only applies when Continuous Scroll is on.', 'Extend Threshold') }}</label>
<input type="number"
id="vegas_extend_threshold_screens"
name="vegas_extend_threshold_screens"
value="{{ main_config.display.get('vegas_scroll', {}).get('extend_threshold_screens', 2.0) }}"
min="1"
max="10"
step="0.5"
class="form-control">
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group" id="setting-display-vegas_plugins_per_cycle" data-setting-key="display.vegas_scroll.plugins_per_cycle">
<label for="vegas_plugins_per_cycle" class="block text-sm font-medium text-gray-700">Plugins Per Cycle{{ ui.help_tip('How many plugins are composed into one pass of the ticker (150).\nDefault: 6. Higher means more variety before the ticker restarts, and fewer recompose pauses. Lower means each plugin comes around sooner.', 'Plugins Per Cycle') }}</label>
<input type="number"
id="vegas_plugins_per_cycle"
name="vegas_plugins_per_cycle"
value="{{ main_config.display.get('vegas_scroll', {}).get('plugins_per_cycle', 6) }}"
min="1"
max="50"
class="form-control">
</div>
<div class="form-group" id="setting-display-vegas_overflow_mode" data-setting-key="display.vegas_scroll.overflow_mode">
<label for="vegas_overflow_mode" class="block text-sm font-medium text-gray-700">When A Plugin Is Too Wide{{ ui.help_tip('What to do when a plugin has more content than its width allowance.\nRotate through it: show a different slice each time round, so everything is seen eventually. Right for interchangeable items like news headlines, odds or stock prices.\nShow the start only: always display from the beginning and drop the rest. Right for ordered content — a league table that shows ranks 1-6 and then resumes at 7 two rotations later reads as out of order.\nDefault: rotate. Override for one plugin with vegas_overflow in its own settings.', 'Overflow Handling') }}</label>
<select id="vegas_overflow_mode" name="vegas_overflow_mode" class="form-control">
<option value="rotate" {% if main_config.display.get('vegas_scroll', {}).get('overflow_mode', 'rotate') == 'rotate' %}selected{% endif %}>Rotate through it (default)</option>
<option value="truncate" {% if main_config.display.get('vegas_scroll', {}).get('overflow_mode', 'rotate') == 'truncate' %}selected{% endif %}>Show the start only</option>
</select>
</div>
<div class="form-group" id="setting-display-vegas_max_plugin_width_ratio" data-setting-key="display.vegas_scroll.max_plugin_width_ratio">
<label for="vegas_max_plugin_width_ratio" class="block text-sm font-medium text-gray-700">Max Plugin Width (screens){{ ui.help_tip('Caps how much of one cycle a single plugin may occupy, measured in screen widths (020).\nDefault: 3. A long ticker such as a news feed or leaderboard is trimmed to this and the remainder shown on later cycles, so one plugin cannot hold the display for minutes. Set 0 for no limit.', 'Max Plugin Width') }}</label>
<input type="number"
id="vegas_max_plugin_width_ratio"
name="vegas_max_plugin_width_ratio"
value="{{ main_config.display.get('vegas_scroll', {}).get('max_plugin_width_ratio', 3.0) }}"
min="0"
max="20"
step="0.5"
class="form-control">
</div>
</div>
<div class="form-group mt-4" id="setting-display-vegas_dynamic_duration_enabled" data-setting-key="display.vegas_scroll.dynamic_duration_enabled">
<label class="flex items-center">
<input type="checkbox"
id="vegas_dynamic_duration_enabled"
name="vegas_dynamic_duration_enabled"
{% if main_config.display.get('vegas_scroll', {}).get('dynamic_duration_enabled', True) %}checked{% endif %}
class="form-checkbox">
<span class="ml-2 text-sm text-gray-700">Size cycle time to the content{{ ui.help_tip('When on, each cycle runs just long enough to scroll all its content past, clamped to the min and max below.\nWhen off, the max is always used. Default: on.', 'Dynamic Cycle Duration') }}</span>
</label>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-2">
<div class="form-group" id="setting-display-vegas_min_cycle_duration" data-setting-key="display.vegas_scroll.min_cycle_duration">
<label for="vegas_min_cycle_duration" class="block text-sm font-medium text-gray-700">Min Cycle Time (seconds){{ ui.help_tip('Shortest a single ticker pass may last (53600 s).\nDefault: 60.', 'Min Cycle Time') }}</label>
<input type="number"
id="vegas_min_cycle_duration"
name="vegas_min_cycle_duration"
value="{{ main_config.display.get('vegas_scroll', {}).get('min_cycle_duration', 60) }}"
min="5"
max="3600"
class="form-control">
</div>
<div class="form-group" id="setting-display-vegas_max_cycle_duration" data-setting-key="display.vegas_scroll.max_cycle_duration">
<label for="vegas_max_cycle_duration" class="block text-sm font-medium text-gray-700">Max Cycle Time (seconds){{ ui.help_tip('Longest a single ticker pass may last before it restarts with fresh content (103600 s).\nThis is the setting that caps total Vegas scroll time. Default: 240. Lower it if the ticker feels like it takes too long to come back around.', 'Max Cycle Time') }}</label>
<input type="number"
id="vegas_max_cycle_duration"
name="vegas_max_cycle_duration"
value="{{ main_config.display.get('vegas_scroll', {}).get('max_cycle_duration', 240) }}"
min="10"
max="3600"
class="form-control">
</div>
</div>
</div>
<!-- Dead Space -->
<div class="mt-4 pt-4 border-t border-gray-200">
<h4 class="text-sm font-medium text-gray-900 mb-3">Dead Space</h4>
<p class="text-sm text-gray-600 mb-3">Plugins that draw onto a full-screen canvas contribute all the empty space around their content. Trimming reclaims it so the ticker stays full.</p>
<div class="form-group mb-4" id="setting-display-vegas_auto_trim" data-setting-key="display.vegas_scroll.auto_trim">
<label class="flex items-center">
<input type="checkbox"
id="vegas_auto_trim"
name="vegas_auto_trim"
{% if main_config.display.get('vegas_scroll', {}).get('auto_trim', True) %}checked{% endif %}
class="form-checkbox">
<span class="ml-2 text-sm text-gray-700">Trim empty edges from plugin content{{ ui.help_tip('Crops blank columns from the left and right of each plugin block before it enters the ticker. Space between two pieces of content inside a block is left alone, so layouts are not altered. Default: on.', 'Auto Trim') }}</span>
</label>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group" id="setting-display-vegas_content_padding" data-setting-key="display.vegas_scroll.content_padding">
<label for="vegas_content_padding" class="block text-sm font-medium text-gray-700">Content Padding (pixels){{ ui.help_tip('Blank columns kept either side of trimmed content, so it does not butt against the separator (0128 px).\nDefault: 8.', 'Content Padding') }}</label>
<input type="number"
id="vegas_content_padding"
name="vegas_content_padding"
value="{{ main_config.display.get('vegas_scroll', {}).get('content_padding', 8) }}"
min="0"
max="128"
class="form-control">
</div>
<div class="form-group" id="setting-display-vegas_lead_in_width" data-setting-key="display.vegas_scroll.lead_in_width">
<label for="vegas_lead_in_width" class="block text-sm font-medium text-gray-700">Lead-In Gap (pixels){{ ui.help_tip('Blank space before the first plugin of each cycle (02048 px).\nDefault: 0. Anything approaching your screen width reads as the display switching off at the start of every cycle.', 'Lead-In Gap') }}</label>
<input type="number"
id="vegas_lead_in_width"
name="vegas_lead_in_width"
value="{{ main_config.display.get('vegas_scroll', {}).get('lead_in_width', 0) }}"
min="0"
max="2048"
class="form-control">
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<div class="form-group" id="setting-display-vegas_trim_threshold" data-setting-key="display.vegas_scroll.trim_threshold">
<label for="vegas_trim_threshold" class="block text-sm font-medium text-gray-700">Trim Threshold{{ ui.help_tip('How bright a pixel must be to count as content rather than empty space (0254).\nDefault: 10, which ignores the near-black noise left by image compression. Raise it if very dark artwork is being kept; lower it if dark detail is being cropped.', 'Trim Threshold') }}</label>
<input type="number"
id="vegas_trim_threshold"
name="vegas_trim_threshold"
value="{{ main_config.display.get('vegas_scroll', {}).get('trim_threshold', 10) }}"
min="0"
max="254"
class="form-control">
</div>
<div class="form-group" id="setting-display-vegas_min_plugin_width" data-setting-key="display.vegas_scroll.min_plugin_width">
<label for="vegas_min_plugin_width" class="block text-sm font-medium text-gray-700">Min Plugin Width (pixels){{ ui.help_tip('Plugin blocks narrower than this after trimming are skipped for that cycle (0512 px).\nDefault: 8. Raise it to hide plugins showing only a tiny placeholder such as "No Data" until they have real content.', 'Min Plugin Width') }}</label>
<input type="number"
id="vegas_min_plugin_width"
name="vegas_min_plugin_width"
value="{{ main_config.display.get('vegas_scroll', {}).get('min_plugin_width', 8) }}"
min="0"
max="512"
class="form-control">
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<div class="form-group" id="setting-display-vegas_min_cut_gap" data-setting-key="display.vegas_scroll.min_cut_gap">
<label for="vegas_min_cut_gap" class="block text-sm font-medium text-gray-700">Min Cut Gap (pixels){{ ui.help_tip('When a plugin is too wide for its share of a cycle and has to be narrowed, the cut is only made where there is at least this much blank space (1128 px).\nDefault: 6. The gaps between letters are about 1px wide, so a smaller value lets a cut land inside a word and orphan its last letter into the next cycle. Raise it if cuts still land awkwardly. Continuous images such as maps have no gaps and are cut to fit regardless.', 'Min Cut Gap') }}</label>
<input type="number"
id="vegas_min_cut_gap"
name="vegas_min_cut_gap"
value="{{ main_config.display.get('vegas_scroll', {}).get('min_cut_gap', 6) }}"
min="1"
max="128"
class="form-control">
</div>
</div>
</div>
<!-- Plugin Order Section --> <!-- Plugin Order Section -->
<div class="mt-4 pt-4 border-t border-gray-200"> <div class="mt-4 pt-4 border-t border-gray-200">
<h4 class="text-sm font-medium text-gray-900 mb-3">Plugin Order</h4> <h4 class="text-sm font-medium text-gray-900 mb-3">Plugin Order</h4>
@@ -49,6 +49,18 @@
<label for="timezone" class="block text-sm font-medium text-gray-700">Timezone{{ ui.help_tip('Time zone used for clocks, schedules, and time-based content.\nChoose the zone where the display physically lives so on/off schedules fire at the correct local time.', 'Timezone') }}</label> <label for="timezone" class="block text-sm font-medium text-gray-700">Timezone{{ ui.help_tip('Time zone used for clocks, schedules, and time-based content.\nChoose the zone where the display physically lives so on/off schedules fire at the correct local time.', 'Timezone') }}</label>
<div id="timezone_container" class="mt-1"></div> <div id="timezone_container" class="mt-1"></div>
</div> </div>
<!-- Scroll frame rate (device-wide) -->
<div class="form-group" id="setting-general-target-fps" data-setting-key="target_fps">
<label for="target_fps" class="block text-sm font-medium text-gray-700">Scroll Frame Rate{{ ui.help_tip('Frames per second for scrolling content, applied across plugins that scroll.\nHigher is smoother but uses more CPU; lower frees CPU but looks steppier.\nRange 30-200. Default: 100.', 'Scroll Frame Rate') }}</label>
<input type="number"
id="target_fps"
name="target_fps"
value="{{ main_config.target_fps or 100 }}"
min="30"
max="200"
class="form-control">
</div>
<script> <script>
(function() { (function() {
// Track if already initialized to prevent re-render // Track if already initialized to prevent re-render