* fix(core): font zip cache, monotonic timers, resolver back-off, and other core/common fixes - font_manager: a .zip font URL is served as its extracted font after a restart (the cached-file check returned the archive first); downloads use requests with a 30s timeout into a temp file + os.replace. - api_helper / sync_manager: rate-limit and heartbeat/leader timeouts use time.monotonic(); last_request_time and the status file's ts stay wall-clock. set_on_new_cycle docstring no longer claims core uses it. - logo_helper: the placeholder uses the same scaled box as a real logo. - permission_utils: one _sudo_bash_candidates() helper (with the sudoers exact-argv rationale) shared by sudo_remove_directory, which now retries the next bash path on a sudo refusal, and install_requirements_file. - dynamic_team_resolver: failed/empty fetch backs off 5 min; duplicate INFO log and contradictory docstring example fixed. - element_style: scale default looked up through element aliases. - background_data_service: cache-hit callback runs outside the lock. - config_arrays: union-aware type check (["array","null"]); stale dotToNested() reference removed. - auto_update_setup: non-dict auto_update reads as off; temp result file unlinked when the write fails. - exceptions: constructors copy the caller's context dict. - logging_config: StructuredFormatter json.dumps(default=str). - error_aggregator: removed unused export_path/export_to_file/_auto_export. - Docstrings: validate_file_upload max_size_mb, raise_on_errors. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sync): retry the status-file rename like the other atomic writers On Windows os.replace can fail with "Access is denied" while a scanner briefly holds the target open; config_manager_atomic._replace already retries that (and re-raises at once on other platforms). The sync status writer called os.replace directly, which made test_concurrent_writers_each_use_their_own_temp_file flaky on Windows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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_managerorsrc.plugin_systemat module level (test/test_common_is_hardware_free.py). That keeps plugins that use it loadable by the web preview,scripts/check_plugin.pyand 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_versionin the manifest'sversionsentry). 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-exportsAPIHelper,ScrollHelper,LogoHelper,TextHelper,scroll_config(plusScrollSettings,configure_scroll,resolve_scroll_settings,refresh_hz_from_config) and the adaptive layout names below (__init__.py).
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 | Unreleased |
espn_dates |
Fetch ESPN scoreboards across a date range | Yes (scoreboards) | 3.5.0 |
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) | Unreleased |
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_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_scroll |
Scoreboard scroll-display orchestration | Yes (scoreboards) | 3.2.0 |
sports_shared |
Sport-independent sports.py methods |
Yes (scoreboards) | 3.3.0 |
sync_manager |
Leader/follower sync between two displays | No, core-internal | n/a |
text_helper |
Outlined text, wrapping, measurement | Yes | — |
The four 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.
Scoreboard plugins also bundle a copy for older cores.
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.
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_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_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.
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.
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().
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.