Skip to content

Give plugins access to device-wide config, and a global scroll frame rate - #424

Merged
ChuckBuilds merged 2 commits into
mainfrom
feat/plugin-global-config
Aug 2, 2026
Merged

Give plugins access to device-wide config, and a global scroll frame rate#424
ChuckBuilds merged 2 commits into
mainfrom
feat/plugin-global-config

Conversation

@ChuckBuilds

@ChuckBuilds ChuckBuilds commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Problem

The sports scoreboards in ledmatrix-plugins read a shared scroll frame rate like this:

global_config=getattr(self, 'global_config', {}) or {}

Nothing ever sets that attribute. The loader constructs plugins with only plugin_id, config, display_manager, cache_manager, plugin_manager (plugin_loader.py:671); global_config appears zero times in all of src/; no plugin manager assigns it; and BasePlugin has no __getattr__ to synthesize it. So the lookup always evaluates to {}.

The consequence: ten scroll_display.py copies thread target_fps through to ScrollHelper, and none of it can ever fire on any core. The plumbing is complete and correct on the plugin side, waiting on a source that doesn't exist.

There was also nothing to find even if it were delivered — the only target_fps in the template is display.vegas_scroll.target_fps, which is Vegas-scoped. A device-wide setting did not exist.

Worth noting for reviewers: I first tried to confirm this from a running rig's logs, but ScrollHelper.set_target_fps logs at debug, so absent log lines prove nothing. The conclusion rests on the static chain above.

What this adds

BasePlugin.global_config — resolves the full config via plugin_manager.config_manager, then cache_manager.config_manager, then {}. That order mirrors the timezone helpers the sports plugins already ship (8d33894).

Two deliberate hardening choices:

  • Exceptions are caught and logged at debug. A broken or unreadable config must never stop a plugin from loading.
  • A non-dict result is rejected. Callers .get() this and feed the result into numeric code, so handing back whatever a stub or half-built manager returned would fail later and further from the cause.

A top-level target_fps (default 100), on the General tab, validated 30–200 on save. Those bounds match ScrollHelper.set_target_fps, which clamps silently — rejecting at the API means a value that would have been quietly altered gets reported, instead of appearing to save and then behaving differently.

The setter is not incidental

A read-only property would have broken six shipped plugins. news, stock-news, ledmatrix-stocks, ledmatrix-elections, ledmatrix-leaderboard and nfl-draft all do:

self.global_config = config.get('global', {})

Against a getter-only property that raises property 'global_config' of 'NewsTickerPlugin' object has no setter and those plugins stop loading. I reproduced it, then pinned it with a test. Assignment now wins over the resolved value, preserving their existing behaviour exactly.

Similarly, target_fps is deliberately 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-tab save would silently switch autostart off. There's a regression test for that too.

Verification

End-to-end, with no plugin-side change:

config.json -> BasePlugin.global_config -> scroll_display's existing block -> ScrollHelper
ScrollHelper.target_fps: 120 -> 100

Repeated on real hardware (devpi, 512×64, 22 plugins): set target_fps: 90 in the live config.json, confirmed the property resolved it (90, and timezone alongside it), and confirmed the deployed baseball-scoreboard path drove ScrollHelper 120 -> 90. The four global_config-assigning plugins installed there (ledmatrix-stocks, news, ledmatrix-leaderboard, stock-news) all loaded clean, no tracebacks — the setter proven in situ. The rig was then restored byte-identical and restarted.

15 new tests. Each was checked to fail without the implementation rather than passing vacuously — the --apply-style trap of a test that can't fail. Suite: 1441 passed. Four 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, verified by stashing.

What this does not do

It does not unify scroll_display.py. The convergence plan calls for the core to ship one, and that's the reason the target_fps change had to be written ten times. But the copies have genuinely diverged along the three documented lineages:

pair differing lines
soccer ↔ nrl 14
soccer ↔ afl 119
soccer ↔ hockey 524
soccer ↔ football 582
soccer ↔ basketball 655
soccer ↔ baseball 666

Reconciling 500–670 line divergences is a project, not a PR, and doing it badly would be worse than not doing it. This PR delivers the piece that unblocks work already written; the unification is the follow-up.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

Summary by CodeRabbit

  • New Features

    • Added a device-wide scroll frame rate setting with a default of 100 FPS.
    • The setting can be configured through the general settings interface.
    • Plugins can access and override shared device configuration when needed.
  • Bug Fixes

    • Added validation to accept frame rates from 30 to 200 FPS only.
    • Invalid values are rejected while other general settings remain unchanged.
    • Configuration handling now safely falls back when shared settings are unavailable.

…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
@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@ChuckBuilds, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 41 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: cb342de6-7450-4fb3-94c5-5f60abd0b298

📥 Commits

Reviewing files that changed from the base of the PR and between 809d676 and 99d0ee2.

📒 Files selected for processing (4)
  • src/plugin_system/base_plugin.py
  • test/test_plugin_system.py
  • test/test_web_api.py
  • web_interface/blueprints/api_v3.py
📝 Walkthrough

Walkthrough

Adds device-wide target_fps configuration with UI and API validation. Exposes BasePlugin.global_config with fallback and override behavior. Adds tests for resolution, persistence, validation, boundaries, and setting preservation.

Changes

Target FPS configuration

Layer / File(s) Summary
Global configuration resolution
config/config.template.json, src/plugin_system/base_plugin.py, test/test_plugin_system.py
Adds the target_fps template value and the BasePlugin.global_config property with fallback, validation, and override behavior.
Target FPS web configuration
web_interface/templates/v3/partials/general.html, web_interface/blueprints/api_v3.py, test/test_web_api.py
Adds the 30–200 FPS form field, validates submitted values, stores valid integers in the root configuration, and preserves unrelated settings.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant GeneralForm
  participant save_main_config
  participant RootConfiguration
  GeneralForm->>save_main_config: Submit target_fps
  save_main_config->>save_main_config: Validate integer from 30 through 200
  save_main_config->>RootConfiguration: Store target_fps
  RootConfiguration-->>save_main_config: Persisted configuration
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 37.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes both primary changes: plugin access to device-wide configuration and a global scroll frame rate.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/plugin-global-config

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

ChuckBuilds pushed a commit to ChuckBuilds/ledmatrix-plugins that referenced this pull request Aug 1, 2026
target_fps had no copy to converge -- self.config is only the plugin's own
slice, so a device-wide value simply wasn't reachable, which is why the
scoreboards' getattr(self, 'global_config', ...) always saw {}.

Documents the core-side property added in ChuckBuilds/LEDMatrix#424: the
resolution order, reading it as getattr(...) so plugins still load on older
cores, that it is read-only (mutating the live config has bitten this repo
before), and that assignment still overrides it -- which news, stock-news,
ledmatrix-stocks, ledmatrix-elections, ledmatrix-leaderboard and nfl-draft
depend on.

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

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 9 complexity · 0 duplication

Metric Results
Complexity 9
Duplication 0

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/plugin_system/base_plugin.py`:
- Around line 202-203: Update the plugin-manager configuration selection in the
surrounding method to accept any dictionary, including an empty one, so it
retains priority over cache_manager.config_manager; replace the truthiness
requirement while preserving the non-dictionary fallback behavior. Add a
regression test covering an empty plugin-manager configuration alongside a
populated cache-manager configuration and assert the empty dictionary is
returned.

In `@test/test_plugin_system.py`:
- Around line 354-359: Update test_template_ships_a_global_target_fps to assert
that the shipped config’s target_fps value equals 100, rather than only checking
that it is an integer.

In `@web_interface/blueprints/api_v3.py`:
- Around line 755-768: Update the target_fps validation block to reject JSON
floats and booleans before calling int(), while preserving acceptance of valid
integer values and the existing documented 400 error response. Add an API
regression test covering target_fps=90.5 and verify it returns the
integer-validation error without updating current_config.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d3ae3d27-931f-4890-8ead-60377f4c543e

📥 Commits

Reviewing files that changed from the base of the PR and between 5b45f35 and 809d676.

📒 Files selected for processing (6)
  • config/config.template.json
  • src/plugin_system/base_plugin.py
  • test/test_plugin_system.py
  • test/test_web_api.py
  • web_interface/blueprints/api_v3.py
  • web_interface/templates/v3/partials/general.html

Comment thread src/plugin_system/base_plugin.py
Comment thread test/test_plugin_system.py Outdated
Comment thread web_interface/blueprints/api_v3.py
- 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
@ChuckBuilds
ChuckBuilds merged commit 83f20b6 into main Aug 2, 2026
20 checks passed
ChuckBuilds added a commit that referenced this pull request Aug 2, 2026
#425 was squash-merged, so #426's base content now lives in main under a
different SHA. Reconciled the three resulting conflicts:

- .github/workflows/test.yml: kept #426's superset test list (its unification
  suites plus test_element_style.py); main had only element_style from #425.
- CHANGELOG.md: kept #426's version, which promotes main's "Unreleased"
  section into the "3.2.0" release and is a verified superset of main's
  content (no lines dropped).
- test_sports_base_characterization.py: kept #426's side for all three blocks.
  Two are semantic, not cosmetic: #425 pinned the pre-fix behaviour
  (hockey/baseball events dropped -> *_returns_none), while #426 carries the
  actual fixes (2486bdb, 2eea7a7) and updated the tests to *_still_extracts.
  Taking main's side would fail against #426's fixed code. The third is the
  get_background_service import path, which is .core in #426 after the package
  split. Also refreshed a stale comment that still named the removed test.

#424's global_config plumbing merges in cleanly; verified the interlock end to
end on the merged tree (global_config target_fps -> SportsScrollDisplay 90.0).
435 tests pass across the affected suites; the lone failure
(test_get_system_status) is one of the four pre-existing on main.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
ChuckBuilds added a commit to ChuckBuilds/ledmatrix-plugins that referenced this pull request Aug 2, 2026
… plugin fleet (#239)

* chore: remove dead files with zero importers

hockey-scoreboard/base_classes.py and scoreboard_renderer.py are stale
near-duplicates of hockey.py's live code; basketball_helpers.py is an
unused font-loading helper. Nothing imports any of them.

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

* perf: cache per-render font loads in of-the-day, web-ui-info, on-air

- of-the-day: the classic-fallback fonts (used when the core lacks
  src.element_style) were reloaded from disk on every render; they are
  config-independent, so load once and reuse.
- web-ui-info: display() reloaded the 4x6 font on every call.
- on-air: the shrink-to-fit path reloaded the scaled TTF each frame for
  wide labels; now memoized by (path, size).

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

* docs: shared sports code lineage map, scroll-key semantics, 8-size harness matrix

- New 08-shared-sports-code.md: the three scoreboard lineages, per-module
  drift table, guarded convergence pattern, sunset rule for local copies,
  and the fix-all-lineage-members-in-one-PR rule (commit 8d33894 as the
  cautionary example).
- 03-advanced-features.md: document the three incompatible scroll_speed
  unit semantics (px/frame, px/second, inverted frames-per-step divisor).
- 07-testing-ci-and-registry.md: the harness default matrix is 8 sizes,
  not 4.

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

* feat: standardize font/size/color customization and x-advanced scroll keys

Accessibility rollout, additive only — default rendering is byte-identical
in every plugin (custom faces load only when config differs from the
schema default, so web-UI merged-defaults configs are unaffected):

- clock-simple: honor the customization font/font_size keys the schema
  already declared (code previously read only text_color); existing
  golden images pass unchanged.
- news: per-element headline_text/source_text font+size; source line
  color was previously hardcoded (150,150,150) and is now configurable.
- mqtt-notifications: message_text font face+size (single text style).
- countdown: name_font_family — the name line previously had per-element
  size/color but shared the value line's font face.
- tide-display: tide_text/label_text font+size+color, routed by the
  existing palette constants; chart colors untouched.
- youtube-stats: channel_name/subscriber_count/view_count font+size+
  color (plugin previously had zero styling config).
- x-advanced added to scroll_speed/scroll_delay fine-tuning keys in
  nrl, ufc, elections, stocks, leaderboard, news, odds-ticker,
  march-madness, text-display schemas (UI hint only).

text-display and on-air deliberately unchanged: their existing config
surface already covers font face, size, and colors.

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

* feat: honor global target_fps in elections, stocks, nfl-draft, march-madness, text-display

Non-scoreboard half of the global-FPS rollout (canonical pattern from
ledmatrix-leaderboard): read global_config target_fps/scroll_target_fps,
probe set_target_fps with hasattr, clamp 30-200 with a frame_time_target
fallback for older ScrollHelper builds.

- elections, stocks: adopt the block (previously ignored the global FPS).
- nfl-draft: also set an explicit scroll_delay (pacing was previously
  left at the helper default and unconfigured).
- march-madness: plugin-level display_options.target_fps still wins,
  falls back to the global; added the missing older-core fallback branch.
- text-display: added the hasattr guard + fallback (its 240 ceiling was
  already clamped to 200 inside the core helper).

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

* test: harness fixtures for 8 plugins, goldens for 6; fix of-the-day 64px overflow

Deterministic test/harness.json for countdown, christmas-countdown,
7-segment-clock, text-display, static-image, web-ui-info, of-the-day,
on-air (config + freeze_time archetype; static-image renders a bundled
test-pattern PNG). Golden images committed for the six deterministic
renderers — every golden was generated twice and byte-compared before
committing. No goldens for web-ui-info (renders the host IP) or on-air
(active state is event-driven).

The of-the-day fixture immediately exposed a real bug: on 64px-wide
panels the title (PressStart2P@8) and its underline drew past the right
edge, and body lines ran past the bottom on 64x32. Titles now ellipsize
to the panel width, the underline is clamped inside the panel, and body
lines stop before the bottom edge. Wider panels render identically.

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

* chore: bump versions for all plugins changed so far

Sports scoreboards still receiving font-cache/odds/fps work (afl,
baseball, football, soccer, lacrosse, f1) will be bumped with those
changes.

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

* fix: address review findings on FPS sourcing, panel clamps, and fixtures

- elections/stocks/nfl-draft/march-madness: source the FPS target from
  the plugin config's 'global' section (the news/leaderboard convention)
  instead of a never-set attribute; elections also refreshes it in
  on_config_change.
- text-display: single _apply_target_fps helper used by init and
  on_config_change — the latter previously called set_target_fps
  unconditionally and would raise AttributeError on older cores; the
  stored value is now the effective 30-200 clamped rate.
- of-the-day: clamp title_x after the user layout offset; guard
  _fit_title against a panel too narrow for the ellipsis itself.
- clock-simple: clamp the combined time+AM/PM block to start on-panel
  when a user-selected font exceeds the width.
- youtube-stats: truncate the channel name by measured width and derive
  row height from the selected fonts when a custom font is set (identical
  layout at the monospace default); validate color length in _rgb.
- tide-display: same color-length validation; unfold the one-line
  try/except flagged by Ruff E701.
- countdown fixture: pin font/color schema defaults explicitly so a
  future default change cannot silently invalidate the goldens.

All affected plugins re-verified with the harness; clock-simple,
countdown, and of-the-day goldens pass unchanged.

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

* fix(text-display): resolve font_path against the core install, not just cwd

CI runs the safety harness from the workspace root rather than the core
checkout, so the plugin's relative font_path missed assets/fonts and fell
back to PIL's default font — mismatching the committed goldens (generated
with the real font). Add a resolution strategy that walks up from the
display manager's module location to find the core's assets, making font
loading cwd-independent. Goldens now pass from both working directories.

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

* perf(football-scoreboard): cache the record font instead of reloading per frame

The scorebug draw paths (SportsUpcoming/SportsRecent in sports.py,
FootballLive in football.py) reloaded 4x6-font.ttf from disk on every
rendered frame when records/rankings are shown; game_renderer's
_draw_records_or_rankings did the same unconditionally. The face is now
cached once in _load_fonts (fonts['record']) with a lazy memo in the
renderer, using the accessor pattern ufc-scoreboard already ships.

Rendering is pixel-identical: the full adaptive-layout and score-
celebration golden suites (46 tests) pass unchanged.

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

* perf(hockey,lacrosse): cache record/shots fonts instead of reloading per frame

Same treatment as football-scoreboard: fonts['record'] and fonts['shots']
cached in _load_fonts, scorebug draw sites use the accessor pattern, and
game_renderer's fallback disk load is memoized (its detail-font primary
lookup — a real lineage difference from football — is preserved).

Also fixes a latent crash: hockey.py's shots font load had no try/except,
so a missing 4x6-font.ttf killed the live render instead of degrading.

Harness output is byte-identical to the pre-change baselines for both
plugins; offline test scripts (favorite-live-boost, non-favorite-live-
duration, timezone-resolution) all pass.

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

* chore: bump football (2.10.0) and lacrosse (1.6.0) for the font-caching change

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

* perf(afl,baseball,soccer): cache record/ranking font instead of reloading per frame

Completes the font-caching sweep started for football/hockey/lacrosse
(cf93709, 640c081). Same treatment across the three remaining
sports-scoreboard lineages:

- afl-scoreboard: sports.py had three separate uncached reload sites
  (SportsUpcoming/SportsRecent ranking overlays); game_renderer.py was
  already caching fonts["record"] in _load_fonts. Added the same cache
  to sports.py's _load_fonts and switched all three sites to the
  self.fonts.get("record") accessor.
- baseball-scoreboard: sports.py had two uncached reload sites; fixed
  the same way. game_renderer.py already reused fonts['detail'], no
  change needed there.
- soccer-scoreboard: sports.py had three uncached reload sites, and
  game_renderer.py's _draw_records_or_rankings reloaded unconditionally
  on every call (no fonts-dict entry at all) -- given the football
  game_renderer lazy-memo treatment (getattr(self, '_record_font', ...)).

f1-scoreboard checked and needs no change: its renderer loads all fonts
once in __init__ (f1_renderer.py:180), no per-frame reload pattern
exists there.

Verified pixel-identical: rendered afl/baseball/soccer live+recent+
upcoming at 128x64 with show_records and show_ranking forced on, before
and after the change (via git stash) -- byte-for-byte identical PNGs
in all cases. Full safety harness (all 8 sizes) passes for all three
plugins with no new warnings.

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

* perf(basketball,nrl,ufc,baseball): finish the record-font caching sweep

Completes the lineage-wide font-caching change (football, hockey,
lacrosse, afl, soccer landed earlier):

- basketball: record + tournament-date fonts cached in _load_fonts;
  game_renderer memoizes its record font.
- nrl: record font cached; game_renderer memoized.
- ufc: upcoming/recent scorebug paths use the cached record font the
  plugin already loaded for its live layout.
- baseball: (name,size)-keyed memo in _load_custom_font_from_element_config
  plus a BDF native-size cache, collapsing the per-frame 10-rung font
  ladder walks in the traditional-scoreboard and at-bat screens into
  dict lookups; the record-font block moves after the config fallback so
  it is set on every path.

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

* refactor(sports): prefer the core-shipped odds manager with a bundled fallback

The eight non-UFC scoreboards imported their local base_odds_manager copy
unconditionally. They now try src.base_odds_manager first (a functional
superset: adds cache_ttl support) and fall back to the bundled copy on
cores that don't ship it. Both branches are module-level, so they stay
collision-safe under the loader's bare-name isolation.

In afl/nrl/soccer/basketball manager.py the odds import sat inside the
combined BasePlugin guard, so a missing odds module would have nulled
BasePlugin (and in nrl's case NameError'd — its except branch never set
BaseOddsManager). It now has its own nested guard.

UFC keeps its local copy unconditionally: it is a genuine MMA fork
(athlete odds), not a drifted duplicate. Local copies stay bundled per
the sunset rule in docs/plugin-development/08-shared-sports-code.md.

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

* fix(countdown): load family fonts cwd-independently so CI goldens match

The safety harness on CI runs from the workspace root, not the core
checkout, so the core FontManager's cwd-relative assets/fonts scan came
up empty and resolve_font silently degraded to PIL's default face —
drifting every committed golden (the named failure in the last two
safety runs). The type of the degraded font gives no signal (current
Pillow's load_default() is itself a FreeTypeFont), so the miss is
detected via the manager's font catalog, and the family's real file is
then resolved against the core install the display manager was loaded
from (same strategy text-display already uses). Verified: goldens pass
from both the core root and a foreign cwd.

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

* feat(sports): honor the global target_fps in every scoreboard's scroll mode

The ten scroll_display.py files paced frames only from scroll_delay, so
the global smooth-scrolling FPS setting (target_fps / scroll_target_fps)
silently never reached the sports scoreboards. ScrollDisplay and
ScrollDisplayManager now accept a trailing global_config kwarg (threaded
from every manager construction site), and _configure_scroll_helper
applies the canonical set_target_fps block with the clamped
frame_time_target fallback for older cores. When no global value is set,
pacing is unchanged. Also removes the dead _get_target_fps helpers in
afl/nrl/soccer whose value was computed but never applied, and bumps f1
to 1.8.0 (its only change in this PR).

Verified: harness green on all ten plugins (f1's committed goldens pass
unchanged); football adaptive/celebration and soccer celebration pytest
suites green.

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

* fix: address review findings on measurement fallback and registry dates

- tide-display: when display-manager measurement fails, fall back to the
  drawing font's own getbbox metrics before the 4px/char estimate, so a
  custom face never draws wider than the width reported.
- text-display, countdown: log module-relative font-probe failures at
  debug instead of swallowing them silently.
- afl/baseball manifests carried a stale last_updated (2026-07-17) that
  the registry echoed; set to 2026-07-31 and teach update_registry.py to
  sync last_updated even when latest_version is unchanged, so catalog
  timestamps can no longer drift from manifests.

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

* ci: don't echo unvalidated plugin ids in the harness failure summary

Plugin ids are derived from PR file paths, so in the invalid-id branch
the raw string is fork-controllable; echoing it into workflow commands
is needless exposure. Redact it and record a placeholder in the summary
instead — valid ids (the useful signal) are unaffected.

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

* fix(ufc): enable the scoreboard managers; test: mock fixtures for six scoreboards

The UFC managers pass sport_key='ufc_scoreboard' and SportsCore looked up
config under f'{sport_key}_scoreboard' — the nonexistent
'ufc_scoreboard_scoreboard' — so mode_config was always empty,
is_enabled always False, and every UFC screen rendered blank. The lookup
now uses the key the adapter actually writes ('ufc_scoreboard'). Found
while building harness fixtures: no cache/mock/config contents could
make the managers render.

Adds deterministic harness fixtures (config + cached-schedule mock data
+ frozen clock) for baseball, basketball, football, hockey, lacrosse,
and soccer, so the safety harness renders real game cards for
recent/upcoming (and live where the plugin's live path reads the cache:
basketball, soccer, plus baseball via its test_mode passthrough) instead
of blank no-data screens. Where the live path is a direct network fetch
with no cache read (hockey, football, lacrosse), live is disabled in the
fixture with the reason documented in each harness.json. Verified: all
sizes PASS and two consecutive runs are byte-identical for every plugin.

UFC gets no fixture yet: its fighter-headshot loader has no negative
caching and no config toggle, so offline runs spend ~15s of retries per
headshot per render — needs a small plugin change first (follow-up).

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

* fix: address CodeRabbit review findings across scoreboards, tide, countdown

- nrl manager: remove a stray unconditional BaseOddsManager = None that
  overwrote a successful bundled-fallback import on older cores (critical
  review catch).
- soccer + afl sports.py: move the cached record-font block after the
  config/fallback branches so it is set on every path, matching the other
  seven copies.
- hockey manager: drop the duplicate ScrollDisplayManager construction;
  the first instance (which enable_scrolling reads) now persists instead
  of being silently replaced.
- all ten scroll_display.py: coerce target_fps to float before comparing
  so a malformed global config degrades to scroll_delay pacing instead of
  raising in __init__.
- tide-display: resolve custom fonts against the core install (cwd-
  independent, same strategy as text-display/countdown) and clamp the
  three label positions that could go negative or overflow with large
  custom fonts.
- baseball sports.py: narrow the BDF strike-size retry to OSError and
  cache the fallback-default font under the requested key so a
  misconfigured font stops hitting the disk per frame.
- countdown: log per-candidate font-load failures instead of silently
  continuing, and memoize the FontManager catalog-miss check that was
  statting the filesystem on every render.

The bare-name fallback module rename suggestion is deliberately not
applied — module-level bare imports are collision-safe under the core
loader's isolation rules (see the review reply and 08-shared-sports-code.md).

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

* docs: record how plugins read device-wide settings

target_fps had no copy to converge -- self.config is only the plugin's own
slice, so a device-wide value simply wasn't reachable, which is why the
scoreboards' getattr(self, 'global_config', ...) always saw {}.

Documents the core-side property added in ChuckBuilds/LEDMatrix#424: the
resolution order, reading it as getattr(...) so plugins still load on older
cores, that it is read-only (mutating the live config has bitten this repo
before), and that assignment still overrides it -- which news, stock-news,
ledmatrix-stocks, ledmatrix-elections, ledmatrix-leaderboard and nfl-draft
depend on.

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

* fix: address CodeRabbit round-2 findings (import narrowing, docs, probe logging)

- The odds-manager convergence guards now catch ModuleNotFoundError and
  check exc.name, so only an absent core module triggers the bundled
  fallback; an import failure from inside src.base_odds_manager (missing
  dependency) surfaces instead of being masked. Applied to all eight
  sports.py guards and the four manager.py nested guards; verified both
  branches with meta-path simulations.
- 08-shared-sports-code.md: the global_config example now uses the
  getattr form the same section mandates.
- tide-display: the module-relative font probe logs failures at debug
  level instead of a blind except/pass.

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

* chore: reclassify the scoreboard releases as PATCH

CONTRIBUTING.md scopes MINOR to new features and schema additions. None of
these ten plugins gained either: the changes are font-load caching, the
odds-manager import switch, target_fps threading, and test fixtures. The only
schema edits (nrl, ufc) add `x-advanced` UI hints, not options. That makes
them PATCH.

Each new version is set one patch above **main's** current version rather
than above the branch's, which also fixes a collision: soccer had been bumped
to 2.5.0 here while main independently released its own 2.5.0, so two
different sets of changes shared a version number. Now 2.5.1.

plugins.json could not simply be regenerated -- update_registry.py refuses to
lower a version, so it skipped all ten and left the registry advertising the
old MINOR numbers. Reset the generated file to main's state and regenerated
from there, which the tool accepts as an increase. Verified afterwards that
every latest_version moves up and none moves down.

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

* fix(clock-simple): stop an oversized date clipping at both ends

The date and weekday draws passed no x, so draw_text auto-centred them with
(width - text_width) // 2 and no clamp. _fit_date returns its shortest
candidate even when that still overflows -- reachable now that the font is
user-configurable -- and a negative x clips the string at *both* ends, losing
the leading characters rather than just the tail.

Adds _centered_x, which clamps to 0 so overflow clips on the right only. It
returns None when measuring raises, deferring to draw_text's own centring
rather than guessing, mirroring _text_fits assuming a fit so a measurement
failure never hides content.

The time and AM/PM paths already clamp (max(0, ...) on time_x, and
max(0, min(...)) on ampm_x), so this closes the remaining case.

Defaults are unchanged: all three committed goldens still match byte for byte,
harness green at all 8 sizes. Verified the fix directly -- "Aug 1st" at 84px
on a 64px panel gives draw_text x=-10 vs _centered_x x=0.

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

* docs: add the validated scroll-display adoption recipe

Core 3.2.0 ships src/common/sports_scroll.py, the orchestration half of
scroll_display.py. The content half (prepare_scroll_content,
_load_separator_icons) stays per-plugin permanently -- a survey of the
eight copies that share a shape found eight distinct bodies, because
each draws its own game card. Same method name, different job.

Documents the mechanical adoption once a plugin floors at 3.2.0, the
byte-comparison acceptance gate, and the two gotchas the hockey pilot
surfaced (the inherited os.path use in _load_separator_icons, and the
now-dead scroll_helper guards).

Measured on hockey-scoreboard: 691 -> 289 lines, 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: sync f1 release metadata, correct the doc's import example

Review follow-ups on #239.

f1-scoreboard's manifest disagreed with itself: last_updated said
2026-07-28 while its newest versions[] entry for the same 1.7.1 said
2026-08-01. update_registry.py mirrors last_updated into the catalog, so
the stale date propagated there too. Set it from the release entry and
regenerated plugins.json.

The 08-shared-sports-code doc still showed `except ImportError` for the
base_odds_manager guard, but the code was narrowed to
ModuleNotFoundError with a name check in an earlier round -- so the doc
was teaching the pattern its own examples no longer use. A bare
ImportError also swallows failures raised *inside* a core module that is
present, silently loading the bundled copy and hiding a broken install.

clock-simple's font loader caught bare Exception; narrowed to OSError,
which is what FreeType raises for a missing, unreadable or malformed
face. All three committed goldens pass unchanged.

Left alone:
- The measurement fallback in clock-simple stays broad on purpose. It
  runs on the render path and deliberately degrades to draw_text's own
  centring; letting a measurement hiccup propagate would blank a clock.
- soccer-scoreboard's _check_favorite_teams blocking update() is real,
  but it arrived in #233 (49b0fe2) from main and is not this PR's code.
- Five other manifests carry the same date drift from earlier PRs;
  out of scope here.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant