Files
LEDMatrix/src/common
ChuckandClaude Opus 5.5 f6c0fe55d9 fix(core): font zip cache, monotonic timers, resolver back-off, and other core/common fixes (#654)
* 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>
2026-09-28 10:40:16 -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).

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.