Files
ChuckandClaude Opus 5.5 07abd87d5e fix(sports): scroll and Vegas cards name the printed date's own weekday (#747)
With scroll_card.date_format "weekday", a Friday 8 PM ET game read
"Sat Oct 2" on the scroll and Vegas cards.

Cause: the extractor prints the "M/D" in the plugin's resolved zone (its
own setting, then the global one, then the system zone), but the card is
handed only the plugin's config. Its timezone ships as "", so
card_tzinfo fell back to UTC and the weekday belonged to the UTC date:
the next day for evening games in the Americas, the previous day for
morning games east of UTC (Auckland, Kiritimati).

Fix: every zone is within a day of UTC, so the printed date is the
start's UTC date or a neighbour of it. _format_date_as now takes the game
and names the weekday of whichever of those days has the printed month
and day, falling back to the zone-based weekday only when the start
cannot place the date (no offset, unparseable, or more than a day away).
The switch-mode scorebug shares the formatter and passes the game too, so
the twins stay identical; it already used the resolved zone and draws
what it drew before. Public signatures are unchanged.

Tests cover US DST end, New Year's Eve, both sides of the date line, NZ
DST start and UTC+14. The twins test's weekday pin is updated: the drawn
date now agrees, and only the bare weekday helpers still differ.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:19:05 -04:00
..

src/common

Helpers shared by core and plugins. This page lists every module, what it is for, and whether plugins are expected to import it.

Rules for the package:

  • Every module must import without display hardware: nothing here may import src.display_manager or src.plugin_system at module level (test/test_common_is_hardware_free.py). That keeps plugins that use it loadable by the web preview, scripts/check_plugin.py and tests on a laptop.
  • A plugin that imports a module added in a given core release must declare that release as its minimum (ledmatrix_min_version in the manifest's versions entry). The "Since" column gives the release; "—" means it predates 3.1.0, "n/a" that plugins should not import it.
  • from src.common import ... re-exports APIHelper, ScrollHelper, LogoHelper, TextHelper, scroll_config (plus ScrollSettings, configure_scroll, resolve_scroll_settings, refresh_hz_from_config) and the adaptive layout names below (__init__.py). Each is imported on first use, so import src.common or a submodule import stays cheap; add a new re-export to _LAZY there as well as __all__.

Summary

Module For Plugins import it? Since
api_helper HTTP GET/POST with caching and rate limiting Yes —
bdf_font Load and draw BDF bitmap fonts Yes, if drawing BDF text directly 3.5.0
espn_dates Fetch ESPN scoreboards across a date range Yes (scoreboards) 3.5.0
favorite_team_check Log why a favourite team code shows nothing Yes (scoreboards) 3.6.0
fetch_service Pooled, merged, budgeted and counted HTTP for core fetch paths No, core-internal (reached through api_helper and espn_dates) n/a
font_layout Reproducible TrueType loading, crisp sizes Yes 3.4.0
frame_timing Timing of every presented frame, stall watchdog No, core-internal n/a
json_body Parse a response body as JSON, with orjson if installed Optional (large payloads) 3.5.0
logo_helper Load, resize and cache team logos Yes —
path_safety Turn request-supplied names into safe paths No, core-internal n/a
permission_utils File modes and shared-group ownership Rarely —
render_gate Keep background Python off the GIL while the panel swaps No, core-internal n/a
scroll_config Plugin scroll config → configured ScrollHelper Yes (scrollers) 3.4.0
scroll_helper Pre-rendered horizontal scrolling Yes —
snapshot_policy When to write the web preview frame No, core-internal n/a
sports_card Scoreboard card settings, colours, fonts, dates Yes (scoreboards) 3.3.0
sports_card_wrappers The game renderer's sports_card delegations Yes (scoreboards) 3.7.0
sports_celebration Draw a scoreboard's score/win celebration Yes (scoreboards) 3.7.0
sports_display_rules Which games a scoreboard shows, for how long, and its scorebug date line Yes (scoreboards) 3.8.0
sports_fetch Scoreboard season fetch, lookback and live-odds decisions Yes (scoreboards) 3.7.0
sports_font_path Find a scoreboard's bundled font whatever the cwd Yes (scoreboards) 3.8.0
sports_game_renderer Scoreboard scroll/Vegas card geometry Yes (scoreboards) 3.3.0
sports_helpers Small helpers every scoreboard sports.py copies Yes (scoreboards) 3.5.0
sports_live_scroll Rebuild a live scroll strip mid-cycle without moving it Yes (scoreboards) 3.8.0
sports_plugin_host Helpers of a scoreboard's plugin class (manager.py) Yes (scoreboards) 3.8.0
sports_scroll Scoreboard scroll-display orchestration Yes (scoreboards) 3.2.0
sports_shared Sport-independent sports.py methods Yes (scoreboards) 3.3.0
sports_vegas Live Vegas cards: keys, card cache, sticky odds, finished games Yes (scoreboards) 3.8.0
sports_timezone Which timezone a scoreboard draws start times in Yes (scoreboards) 3.6.0
sync_manager Leader/follower sync between two displays No, core-internal n/a
text_helper Outlined text, wrapping, measurement Yes —

The sports_* mixin and card modules hold code the scoreboard plugins used to carry as identical copies. Each module docstring lists what a host class must provide. The plan behind them is in docs/SPORTS_UNIFICATION.md.

Adaptive layout and images

src/adaptive_layout.py and src/adaptive_images.py live outside this package but are re-exported from src.common. They are the recommended way to lay out a plugin that renders legibly on any panel size. Every BasePlugin already has self.layout, self.draw_fit() and self.draw_image():

regs = scoreboard_regions(self.layout.bounds, ctx=self.layout)
self.draw_image(away_logo, regs.away_slot, mode="fill_height",
                crop_to_ink=True, cache_key=f"logo:{abbr}")
self.draw_fit(score_text, regs.score_area)     # largest crisp font that fits

Key pieces: Region, the font ladders LADDER_GRID / LADDER_ARCADE, LayoutContext (fit_text, fit_image, by_tier, px), and scoreboard_regions() / media_row(). Guide: docs/ADAPTIVE_LAYOUT.md.

Modules

api_helper

api_helper.py. APIHelper(cache_manager=None, ...): get() and post() with retries, optional caching through the cache manager, and a minimum interval between requests (set_rate_limit()). Also has fetch_espn_scoreboard(), fetch_espn_standings() and fetch_espn_rankings().

bdf_font

bdf_font.py. The one BDF loader and rasterizer. load_bdf_face(path, size) returns (face, realised_px), falling back to the file's native strike when it has none at size; draw_bdf_text(draw, text, x, y, face, color) draws top-left anchored onto a PIL ImageDraw the same way the panel does. read_bdf_native_size(path) and clear_face_cache() round it out. Faces are cached per thread (FreeType faces are not thread-safe). DisplayManager, FontManager, element_style and the plugin test harness all use it. Most plugins get BDF text through display_manager.draw_text() or FontManager and never import this.

espn_dates

espn_dates.py. ESPN's site API rejects dates= ranges and truncates results when limit is above 500. fetch_espn_scoreboard() splits a range into month and day requests ESPN accepts and merges the results; espn_date_chunks(), fetch_espn_date_chunks(), clamp_espn_limit() and merge_scoreboard_payloads() are the pieces. Every request goes through fetch_service, the chunks counted against the plugin that asked. Scoreboard plugins also bundle a copy for older cores.

favorite_team_check

favorite_team_check.py. FavoriteTeamCheck(logger, leagues), where leagues maps a league key to (display name, ESPN sport/league path). schedule(league_key, favorites) checks the configured favourite team codes against ESPN's team list once per league, on a daemon thread, and logs a bad code with the nearest real one, or says the league has nothing on yet; reset() re-arms it after a config edit. Diagnostics only: every failure is swallowed. Scoreboard plugins also bundle a copy for older cores.

fetch_service

fetch_service.py. Core-internal for now. Every core fetch path -- APIHelper.get/post, espn_dates (so every scoreboard's ESPN scoreboard fetch and SportsFetchMixin), BackgroundDataService and BaseOddsManager -- calls fetch_get(session, url, ...) instead of session.get(url, ...). Same arguments, return value and exceptions; on top it shares one connection pool per host per retry policy (share_connection_pool), merges identical GETs in flight, applies per-host token buckets (fetch_service.rate_limits in config.json; ESPN gets 20/s, burst 200), revalidates with server-sent ETag/Last-Modified and counts requests per plugin and per host. The display publishes the counters (FetchStatsPublisher) for GET /api/v3/plugins/fetch-stats. Which plugin made a request comes from plugin_scope(), set by the plugin executor, or else from the plugin directory on the stack. See docs/PLUGIN_API_REFERENCE.md.

font_layout

font_layout.py. load_truetype(path, size) is ImageFont.truetype with PIL's Basic layout engine pinned, so text lays out the same whether or not the host Pillow has libraqm; use it for anything drawn to the panel or compared against a golden image. crisp_size() gives the size a bundled face renders on whole pixels at. resolve_asset_path() resolves assets/fonts/... against the install root rather than the working directory.

frame_timing

frame_timing.py. Core-internal. DisplayManager records every presented frame in a FrameTimingRecorder, which writes cumulative late-frame counters and histograms to /dev/shm for scripts/frame_soak.py and scripts/render_bench.py. StallWatchdog logs the stack of whatever holds up a scroll. See docs/SCROLL_PERFORMANCE.md.

json_body

json_body.py. response_json(response) is response.json() parsed by orjson when it is installed, falling back to the stdlib parser (and requests' own error) otherwise. For multi-MB payloads such as a season schedule, where the parse holds the GIL and freezes the display. A plugin that also runs on older cores should guard the import, as espn_dates does.

logo_helper

logo_helper.py. LogoHelper(display_width, display_height, ...): load_logo(), load_logo_with_download(), get_logo_variations(), normalize_abbreviation(), with an in-memory cache.

path_safety

path_safety.py. Core-internal, used by web handlers that open files named in a request. safe_path_component(value) returns the value if it is one harmless path segment, else None; resolve_under(base, *parts) returns the resolved path, or None if a part is unsafe or the result would leave base; safe_relative_parts() splits a relative path the same way. Both return the sanitised value rather than a boolean, so a caller cannot check one string and open another.

permission_utils

permission_utils.py. The modes and ownership that let the root display service and the web user share files: ensure_directory_permissions(), ensure_file_permissions(), the get_*_mode() functions, ensure_shared_group_ownership(), sudo_remove_directory() and install_requirements_file() (the sudo safe_pip_install.sh path). ConfigManager, CacheManager and the store already call these; a plugin needs them only when it creates its own files outside the cache. See docs/PERMISSIONS.md.

render_gate

render_gate.py. Core-internal. RenderGate is opened by the render thread around each vsync swap; a background thread inside gate.yielding() (Vegas's prefetch) parks while the gate is closed, so the render thread finds the GIL free when its refresh arrives. It never parks a thread holding a guarded lock or inside logging, threading or import code.

scroll_config

scroll_config.py. configure(scroll_helper, plugin_config=, global_config=, display_manager=, plugin_logger=) reads a plugin's scroll settings, snaps the speed to a whole number of pixels per panel refresh, puts the helper in fixed-step mode and returns ScrollSettings. Pass settings.frame_hold to display_manager.set_scrolling_state(True, frame_hold=...) or the scroll runs too fast. resolve() does the calculation without touching a helper. See docs/SCROLL_PERFORMANCE.md.

scroll_helper

scroll_helper.py. ScrollHelper(display_width, display_height, logger=None): build a wide image once (create_scrolling_image() or set_scrolling_image()), then per frame update_scroll_position() and get_visible_portion(); is_scroll_complete(), calculate_dynamic_duration() and get_dynamic_duration() for timing. Configure it with scroll_config rather than the set_* methods. Vegas mode reads a plugin's scroll_helper image when the plugin has no get_vegas_content().

snapshot_policy

snapshot_policy.py. Core-internal. decide() tells DisplayManager whether to write /tmp/led_matrix_preview.png, only touch its mtime, or skip, based on whether a browser is watching the preview. The web health check reads the file's age, and the web preview stream checks its mtime every VIEWER_POLL_INTERVAL.

sports_card

sports_card.py. Free functions taking config, fonts and logger explicitly: card options (scroll_card_option(), vs_text(), upcoming_center_mode()), colours (element_color(), font_color(), score_color_for(), recent_score_color()), favourite-team rules (favorite_teams_for(), side_is_favorite(), favorite_result()), dates (format_game_date(), format_game_time(), card_tzinfo()) and font sizes (schema_font_size(), resolve_font_size()). A plugin keeps its own method and delegates the body.

sports_card_wrappers

sports_card_wrappers.py. SportsCardWrappersMixin: the one-line methods a scoreboard's game renderer uses to call sports_card with its own config and logger (_vs_text(), _element_color(), _format_game_date(), ... seventeen in all), under their existing names. They are what sports_game_renderer's mixin expects its host to provide. No __init__ and no state.

sports_celebration

sports_celebration.py. SportsCelebrationMixin draws the full-screen takeover a scoreboard shows when a team scores or wins (_draw_celebration_layout(celebration)): a backdrop in the scoring team's colours read off its crest, scenery, confetti, the headline and the score. The colour helpers are free functions (logo_palette(), lift_color(), mix_color(), ...). Deciding when to celebrate stays in the plugin, which builds the celebration dict the docstring describes.

sports_display_rules

sports_display_rules.py. Two SportsCore mixins: SportsCardOptionsMixin (_card_option(), which never lets the upcoming scorebug lose both its date and time, and _recent_date_text(); list it before SportsCoreSharedMixin) and SportsGameRulesMixin (_filtered_or_all(), the no-favourites quality filter that fails open, and _effective_live_duration(), the shorter dwell for a non-favourite live game).

sports_fetch

sports_fetch.py. SportsFetchMixin: the SportsCore methods that decide which requests a scoreboard makes -- _fetch_season_directly() (a season, in chunks ESPN accepts), _background_fetches_espn_ranges(), _needs_previous_day() (the live lookback) and _wants_live_odds() (odds only for games near the screen).

sports_font_path

sports_font_path.py. resolve_font_path(path): the path as given when it exists (relative to the cwd), else font_layout.resolve_asset_path(path). What the scoreboards' _resolve_font_path copies return on a core that ships it.

sports_game_renderer

sports_game_renderer.py. SportsGameRendererMixin: the scroll/Vegas card geometry (centre gap, logo slot, layout offsets, upcoming-card date and time). No __init__ and no state; add it as a base class of the plugin's game renderer and override what differs.

sports_helpers

sports_helpers.py. Free functions clamp_window(), clamp_seconds(), logo_needs_refresh(), spread_weighted_order(), and SportsHelpersMixin with the scoreboards' _mode_customization, _setting_int, _reset_dwell_on_reentry, _next_switch_index, _odds_color and _upcoming_date_and_time_text under their existing names. Nothing in core uses it.

sports_live_scroll

sports_live_scroll.py. SportsLiveScrollMixin: keeps a live scroll strip current. It fingerprints the live games (the clock and the display pipeline's own keys excluded, via the host's LIVE_VOLATILE_FIELDS), rebuilds when they change, rate-limited by what a rebuild costs, and _preserving_scroll_position() keeps the marquee where it was. Pairs with SportsPluginHostMixin, whose _dispatch_switch_refresh() it uses.

sports_plugin_host

sports_plugin_host.py. SportsPluginHostMixin: helpers of a scoreboard's BasePlugin subclass. get_vegas_priority_weight() (more Vegas slots while a favourite plays, found across every plugin's data shape), _dispatch_switch_refresh() (a manager refresh on a daemon thread, so display() never waits on the network), get_vegas_content_type() and small dynamic-duration helpers. List it before BasePlugin.

sports_scroll

sports_scroll.py. SportsScrollDisplay and SportsScrollDisplayManager: the scroll-display orchestration the scoreboards share (Vegas items, dynamic duration, frame loop), paced through scroll_config. Subclasses supply prepare_scroll_content() and set SCROLL_LEAGUE_KEYS; see the module docstring for an example. prepare_and_display() rewinds a recent or upcoming strip whose games, rankings, config, panel size and date are unchanged instead of calling prepare_scroll_content() again, with one display per slate (game type and leagues).

sports_shared

sports_shared.py. SportsCoreSharedMixin, SportsLiveSharedMixin, SportsRecentSharedMixin: the sports.py methods that were identical in every scoreboard (game selection and rotation, fonts, colours, dates, the switch-mode upcoming card). The docstring lists the attributes the host class must have and the three methods deliberately left out.

sports_vegas

sports_vegas.py. What a scoreboard needs for live Vegas cards (one element per game, swapped in place while it scrolls): game_key(), game_fingerprint(), dedupe_games(), VegasCardCache (draws a card only when its fingerprint changes), StickyOdds (keeps a card's odds through a live poll that left them out), and finished_games() / with_finished_games() (a game that just went final keeps its card, showing FINAL). SportsScrollDisplay.build_vegas_elements() in sports_scroll puts them together; a scoreboard not built on it (UFC) uses them directly.

sports_timezone

sports_timezone.py. resolve_timezone_name(config, plugin_manager, cache_manager, log, *, plugin_label, writeback_fixed_in=None) and resolve_timezone(...) (the same as a pytz zone): the plugin's own timezone, then the global one via either manager's config_manager, then the host's zone (system_timezone_name()), then UTC. plugin_label names the plugin in the warning logged when nothing resolves; writeback_fixed_in is for a plugin that once wrote "UTC" into the saved config (a bare plugin-level "UTC" is then ignored when another source disagrees). Scoreboard plugins also bundle a copy for older cores.

sync_manager

sync_manager.py. Core-internal. DisplaySyncManager links two displays as leader and follower (sync.role in config) over UDP port 5765, plus TCP on the next port for scroll images. The leader drives the scroll and sends the follower its part of each frame; a follower falls back to its own plugins when the leader goes quiet. Rows and columns must match. Created by DisplayController; works with any plugin.

text_helper

text_helper.py. TextHelper(font_dir=None, ...): load_fonts(), draw_text_with_outline(), get_text_width(), get_text_dimensions(), center_text(), wrap_text(), draw_multiline_text(), create_text_image().

draw_text_outlined(draw, xy, text, font, fill, outline_color=(0, 0, 0), offsets=OUTLINE_SQUARE) (Unreleased) draws the text in outline_color at each offset, then in fill on top: the same pixels as one draw.text per offset, but the string is rasterized once. OUTLINE_SQUARE is the eight-sided one-pixel outline the scoreboards draw, OUTLINE_CROSS the four-sided one. Fractional coordinates (a whole-pixel float such as 52.0 is fine), multiline text, fonts other than a plain FreeTypeFont, image modes other than RGB, RGBA and L, and a subclassed or replaced draw.text take the draw.text loop unchanged. TextHelper.draw_text_with_outline() and the scoreboards' SportsCoreSharedMixin._draw_text_with_outline() use it. A plugin that also runs on older cores should guard the import and keep its own loop as the fallback.

Logging

Modules here create their logger with logging.getLogger(__name__), which is the same logger src.logging_config.get_logger(__name__) returns. The helper classes (APIHelper, LogoHelper, ScrollHelper, TextHelper) and espn_dates take an optional logger. In a plugin, pass self.logger: it is created by get_logger(..., plugin_id=...) in BasePlugin, so messages carry the plugin id.

Adding a module

  • Keep it importable without hardware (see the test above).
  • Give it a module docstring that says what it is for and, if it is a mixin, what the host class must provide.
  • Add it to the table on this page and, if plugins may import it, to the CHANGELOG with the release to floor on.