Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
07f27c0
chore: remove dead files with zero importers
claude Jul 31, 2026
65a99ff
perf: cache per-render font loads in of-the-day, web-ui-info, on-air
claude Jul 31, 2026
ee0127a
docs: shared sports code lineage map, scroll-key semantics, 8-size ha…
claude Jul 31, 2026
0a13c8c
feat: standardize font/size/color customization and x-advanced scroll…
claude Jul 31, 2026
cbfa98a
feat: honor global target_fps in elections, stocks, nfl-draft, march-…
claude Jul 31, 2026
ee4aba1
test: harness fixtures for 8 plugins, goldens for 6; fix of-the-day 6…
claude Jul 31, 2026
dc996e0
chore: bump versions for all plugins changed so far
claude Jul 31, 2026
fd81aed
fix: address review findings on FPS sourcing, panel clamps, and fixtures
claude Jul 31, 2026
cb3c87b
Merge origin/main: re-apply version bumps over #234's manifest bumps
claude Jul 31, 2026
54fecfe
fix(text-display): resolve font_path against the core install, not ju…
claude Jul 31, 2026
cf93709
perf(football-scoreboard): cache the record font instead of reloading…
claude Jul 31, 2026
640c081
perf(hockey,lacrosse): cache record/shots fonts instead of reloading …
claude Jul 31, 2026
b155a0d
chore: bump football (2.10.0) and lacrosse (1.6.0) for the font-cachi…
claude Jul 31, 2026
e79659a
perf(afl,baseball,soccer): cache record/ranking font instead of reloa…
claude Aug 1, 2026
4cd3be1
perf(basketball,nrl,ufc,baseball): finish the record-font caching sweep
claude Aug 1, 2026
8201d52
refactor(sports): prefer the core-shipped odds manager with a bundled…
claude Aug 1, 2026
197034a
fix(countdown): load family fonts cwd-independently so CI goldens match
claude Aug 1, 2026
dfcf717
feat(sports): honor the global target_fps in every scoreboard's scrol…
claude Aug 1, 2026
2e29f48
fix: address review findings on measurement fallback and registry dates
claude Aug 1, 2026
d87ac45
ci: don't echo unvalidated plugin ids in the harness failure summary
claude Aug 1, 2026
ef573df
fix(ufc): enable the scoreboard managers; test: mock fixtures for six…
claude Aug 1, 2026
209b7b8
fix: address CodeRabbit review findings across scoreboards, tide, cou…
claude Aug 1, 2026
8d95c68
docs: record how plugins read device-wide settings
claude Aug 1, 2026
cd835dc
fix: address CodeRabbit round-2 findings (import narrowing, docs, pro…
claude Aug 1, 2026
c764bd1
chore: reclassify the scoreboard releases as PATCH
claude Aug 1, 2026
a87391f
fix(clock-simple): stop an oversized date clipping at both ends
claude Aug 1, 2026
6426dbf
Merge remote-tracking branch 'origin/main' into claude/ledmatrix-impr…
claude Aug 1, 2026
3e34878
docs: add the validated scroll-display adoption recipe
claude Aug 1, 2026
9bccb6b
fix: sync f1 release metadata, correct the doc's import example
claude Aug 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions .github/workflows/test-plugins.yml
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,10 @@ jobs:
run: |
set -e
fail=0
failed_ids=""
for pid in $IDS; do
case "$pid" in
'' | *[!a-z0-9._-]*) echo "::error::invalid plugin id '$pid'"; fail=1; continue ;;
'' | *[!a-z0-9._-]*) echo "::error::invalid plugin id (redacted)"; fail=1; failed_ids="$failed_ids (invalid-id)"; continue ;;
esac
pdir="plugins-repo/plugins/$pid"
[ -d "$pdir" ] || { echo "::notice::$pid removed, skipping"; continue; }
Expand All @@ -142,9 +143,15 @@ jobs:
# A failure here is a real problem (the plugin can't load), so let it fail.
if [ -f "$pdir/requirements.txt" ]; then pip install -r "$pdir/requirements.txt"; fi
python core/scripts/check_plugin.py --plugin "$pid" \
--plugin-dir "$PWD/plugins-repo/plugins" || fail=1
--plugin-dir "$PWD/plugins-repo/plugins" || { fail=1; failed_ids="$failed_ids $pid"; }
echo "::endgroup::"
done
# Summary at the very end so it survives log tail-truncation on long runs.
if [ "$fail" -ne 0 ]; then
echo "::error::Safety harness failed for:$failed_ids"
else
echo "Safety harness passed for all changed plugins."
fi
exit $fail

- name: Nothing to check
Expand Down
25 changes: 25 additions & 0 deletions docs/plugin-development/03-advanced-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,31 @@ Scrolling plugins also coordinate with the loop through the display manager's
`set_scrolling_state`, `defer_update`, and `process_deferred_updates` so the core
knows a scroll is in progress.

### Scroll-speed key semantics (know which one you're using)

Historically, plugins adopted `scroll_speed` with **three incompatible unit
semantics**. They cannot be renamed without breaking users' saved configs, so
the rule is: keep your plugin's existing semantics, document them in the key's
`description`, and pick semantics (1) for new plugins.

1. **Pixels per frame** (`scroll_speed` ≈ 0.5–5, typically `1.0`) paired with
`scroll_delay` in seconds per frame (`0.01` ≈ 100 FPS). Used by
`text-display`, `ledmatrix-elections`, `ledmatrix-stocks`, `odds-ticker`,
`news`, `march-madness`, `stock-news`. Effective speed =
`scroll_speed / scroll_delay` px/s.
2. **Pixels per second** (`scroll_speed` ≈ 30–50). Used by the sports
scoreboards (their `scroll_display.py` converts internally via
`pixels_per_frame = scroll_speed * scroll_delay`) and `mqtt-notifications`
(delta-time integration).
3. **Frames-per-step divisor** (`scroll_speed` ≈ 1–20, **higher = slower** —
inverted!). The marquee advances one pixel every N core frames. Used by
`incoming-packages`, `jellyfin-now-playing`, and `ledmatrix-music`
(`text_scrolling.*.speed`).

`target_fps` (mechanism 1 above) is orthogonal: it paces how often frames
render, not how far each frame moves. Mark all of these keys `x-advanced` in
your schema — they are fine-tuning knobs.

---

## Vegas mode (continuous marquee)
Expand Down
5 changes: 4 additions & 1 deletion docs/plugin-development/07-testing-ci-and-registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,10 @@ collisions across N plugins.`
## The safety harness

Each plugin can expose multiple screens and must render on every supported matrix
size (64×32, 128×32, 128×64, 256×32). The harness lives in the **core** repo
size. The harness's default test matrix covers eight sizes — 64×32, 128×32,
64×64, 96×48, 128×64, 256×32, 128×96, and 256×128 (see `DEFAULT_TEST_SIZES` in
the core's `src/plugin_system/testing/sizes.py`; a plugin's
`test/harness.json` can override the list). The harness lives in the **core** repo
(`LEDMatrix/scripts/check_plugin.py`) and renders every screen at every size,
failing on crashes, content drawn past the panel edge, or visual drift vs.
committed golden images.
Expand Down
181 changes: 181 additions & 0 deletions docs/plugin-development/08-shared-sports-code.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
# 8. Shared Sports Code — Lineages, Drift, and the Convergence Plan

The nine sports scoreboards (`afl`, `baseball`, `basketball`, `football`,
`hockey`, `lacrosse`, `nrl`, `soccer`, `ufc`) each ship their **own copy** of a
family of shared-shape modules:

| Module | Copies | Notes |
|---|---|---|
| `sports.py` | 9 | `SportsCore` / `SportsUpcoming` / `SportsRecent` / `SportsLive` |
| `scroll_display.py` | 10 | + `f1-scoreboard` (a reduced rewrite) |
| `data_sources.py` | 9 | soccer's copy is byte-equivalent to the core's |
| `base_odds_manager.py` | 9 | ufc's is a genuine MMA fork (athlete odds) |
| `game_renderer.py` | 8 | |
| `dynamic_team_resolver.py` | 8 | true forks — different constructor signatures |
| `logo_downloader.py` | 6 | five other plugins already import `src.logo_downloader` |

None of these copies are identical. **Any fix to a shared-shape file must be
applied to every lineage member in the same PR** — the cautionary example is
commit `8d33894` (the UTC start-time fix), which required touching **75 files**
because one logical change had to be replicated across ten plugins.

## The three lineages

The copies did not drift randomly; they form three families. When porting a
fix, find your plugin's lineage siblings first — their copies are close enough
to share a patch, while cross-lineage copies usually are not.

1. **soccer / afl / nrl** — the newest lineage (~3,160 lines). Uniquely has
SWRR (smooth weighted round-robin) live rotation (`_swrr_advance`) and
goal celebrations spelled `_check_for_goal` / `_should_celebrate_goal_for`.
Favorite/live-duration helpers live on `SportsCore`.
2. **football** — has score celebrations spelled `_check_for_score` /
`_should_celebrate_score_for` / `_score_phrase`, the adaptive-layout
scorebug (`_adaptive_scorebug`, `layout_mode` config), and is the only
`sports.py` that imports `src.element_style` and `game_renderer`.
3. **hockey / lacrosse / baseball / basketball / ufc** — the oldest lineage
(2,400–2,900 lines). No celebration code. Live rotation via
`_build_weighted_schedule` (baseball/basketball/football) or
`_build_rotation_schedule` (hockey). Favorite/live-duration helpers live on
`SportsLive`.

The common ancestor is the **core repo's** `src/base_classes/sports.py` (same
four classes, plus a core-only skin system the plugin copies lack). Only 28 of
the 66 methods appearing across the nine copies are present in all of them.

## Convergence direction

The long-term home for this code is the core repo, so a fix lands once and
every scoreboard benefits. Convergence happens module by module, gated on what
the core actually ships:

- **Already converged:** `logo_downloader` (afl, nrl, ufc, basketball, soccer
import `src.logo_downloader`); `odds-ticker` uses `src.*` for everything and
ships no local copies — it is the model citizen.
- **Converging now:** `base_odds_manager`. The eight non-UFC scoreboards import
it guardedly, preferring the core's version:

```python
try:
from src.base_odds_manager import BaseOddsManager # core-shipped
except ModuleNotFoundError as exc:
# Fall back only when the CORE module is absent. A bare `except
# ImportError` would also swallow a failure raised *inside* a core
# module that is present, silently loading the bundled copy and hiding
# a broken core install.
if exc.name not in {"src", "src.base_odds_manager"}:
raise
from base_odds_manager import BaseOddsManager # bundled fallback
```

Both branches are module-level (entry-point load time), so they are safe
under the loader's bare-name isolation rules (see doc 07 / CLAUDE.md module
naming). The local copy stays until the sunset rule below is met.
- **Not converging (documented forks):** `dynamic_team_resolver` (plugin copies
take `cache_manager` in the constructor; the core's does not — different
API), ufc's `base_odds_manager` (MMA athlete-odds fork), and — until the core
ships a unified version — `sports.py` / `scroll_display.py` /
`game_renderer.py` themselves.

## Device-wide settings: read them from the core, not a copy

Cross-cutting settings are the other half of this problem. `self.config` is
only the plugin's own slice, so a device-wide value like the scroll frame rate
has no copy to converge — it simply wasn't reachable.

The core now exposes the whole config on `BasePlugin`:

```python
fps = getattr(self, 'global_config', {}).get('target_fps')
```

Resolution is `plugin_manager.config_manager` then `cache_manager.config_manager`,
returning `{}` when neither exists. Always go through
`getattr(self, 'global_config', {})` as above so a plugin still loads on a core
that predates the property. Treat the result as **read-only** — it is the live
config dict, and mutating it has bitten this repo before (a plugin writing
`self.config["timezone"]` back persisted a stale `"UTC"` for every consumer).

Assignment still works and overrides the resolved value, which is what
`news`, `stock-news`, `ledmatrix-stocks`, `ledmatrix-elections`,
`ledmatrix-leaderboard` and `nfl-draft` rely on when they set
`self.global_config = config.get('global', {})`.

## The sunset rule

A plugin may **delete** its local copy of a converged module only when both are
true:

1. The plugin's manifest declares `ledmatrix_min_version` **at or above the
first core release that ships the module** (check the core CHANGELOG; the
core exposes its version as `src.__version__`).
2. The safety harness passes with the local copy removed.

Until then, keep the guarded try-core/except-local import: the loader's
compatibility check is **advisory only** (it logs a warning and never blocks),
so the `except ImportError` fallback is the real protection for users running
an older core.

### Worked example: the scroll display

Core 3.2.0 ships `src/common/sports_scroll.py`, which holds the *orchestration*
half of `scroll_display.py` — scroll-helper configuration, frame pumping,
completion, settings resolution, and native `global_config['target_fps']`
support. The *content* half stays per-plugin, permanently: a survey of the eight
copies that share a shape found `prepare_scroll_content` has eight distinct
bodies (145 lines, 53% similar at worst) because each draws its own game card.
Same method name, different job.

Once a plugin floors at 3.2.0, the adoption is mechanical:

```python
from src.common.sports_scroll import SportsScrollDisplay, SportsScrollDisplayManager

class ScrollDisplay(SportsScrollDisplay):
# The ladder the local _get_scroll_settings used to hardcode, same order.
SCROLL_LEAGUE_KEYS = ("nhl", "ncaa_mens", "ncaam_hockey")

def scroll_settings_defaults(self):
# Only where this plugin's defaults differ from core's.
return {**super().scroll_settings_defaults(), "game_card_width": 128}

def _load_separator_icons(self): ... # per-sport
def prepare_scroll_content(self, games, game_type, leagues, rankings=None): ...

class ScrollDisplayManager(SportsScrollDisplayManager):
display_class = ScrollDisplay
```

Delete the local `__init__`, `_configure_scroll_helper`, `_get_scroll_settings`,
`display_scroll_frame`, `_log_scroll_progress`, `is_scroll_complete`,
`reset_scroll`, `get_scroll_info`, `clear`, and the whole manager body except
methods that genuinely differ. Keep `_determine_game_type` if your plugin
supports `'mixed'` scrolls.

**Measured on hockey-scoreboard** against a core carrying 3.2.0: 691 → 289
lines, and all 16 harness renders (8 sizes × 2 screens) byte-for-byte identical
to the pre-adoption run. That byte-comparison is the acceptance gate — run the
harness before and after and `diff -r` the two output directories.

Two things to watch when you do this:

- **Check the imports you inherited.** `_load_separator_icons` uses `os.path`
even though nothing else in the trimmed file does; dropping `import os` with
the rest is an easy way to break the plugin at load time.
- **The base always constructs a `ScrollHelper`**, so `if not self.scroll_helper`
guards inherited from the old copy are dead. Harmless, but delete them rather
than leaving a check that can never fire.

## Rules for future changes

- **Fix all lineage members in one PR.** Grep every copy of the file you're
changing; the CI harness runs on every changed plugin, so a complete sweep
gets full coverage automatically.
- **Keep the copies structurally aligned within a lineage** — gratuitous
refactors in one copy make the next cross-copy patch harder.
- **New shared functionality goes to the core first** when possible, with a
guarded import and a classic fallback in the plugins (the
`src.element_style` / `src.adaptive_layout` adoption pattern).
- **Never introduce a deferred (function-scoped or subpackage) bare-name import
of a shared-shape module** — that is exactly the collision case
`scripts/check_module_collisions.py` exists to catch.
Loading
Loading