Files
LEDMatrix/src/common/sports_shared.py
T
ChuckandClaude Opus 5 69d408b321 feat(core): one per-element display-customization framework, wired into the web UI (#566)
* fix(sports): rebuild un-shared faces through the pinned layout engine

unshare_element_fonts re-instantiates a duplicate font face so two
elements can be told apart by id(). It did so through bare
ImageFont.truetype, which takes PIL's default layout engine rather than
the one src/common/font_layout.py pins. Raqm and Basic disagree on
fractional advances -- that disagreement is the reason the pin exists,
having broken golden images across machines -- so a rebuilt face could
measure differently from the shared face it replaced, on any host where
Raqm is installed.

These were the only two call sites in src/ bypassing the pin.

The guard asserts that the rebuild goes through the pinned loader rather
than comparing engine values: where Raqm is absent, bare truetype returns
BASIC anyway, so an engine comparison passes whether or not the pin is
honoured. The first draft of this test did exactly that and passed with
the bug reintroduced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(web): drop the two dead client-side config-form renderers

generateConfigForm and generateSimpleConfigForm (580 lines) were defined
on the Alpine component and never called: server-side Jinja replaced them,
as pages_v3.py:641 records. Nothing in any template invokes them -- there
is no x-html in the templates and no bracket access on the component.

They carried their own x-widget dispatch, which made them an active trap:
the next person adding a widget would reasonably think both renderers
needed updating.

plugins/config_manager.js (PluginConfigManager, 133 lines) goes for the
same reason -- loaded on every page from base.html, referenced only by
itself and by an archived doc.

Kept, having checked them: widgets/example-color-picker.js is the worked
example docs/widget-guide.md points plugin authors at, and
widgets/plugin-loader.js is the client half of a documented feature
(manifest-declared plugin widgets) whose server route is missing --
soccer-scoreboard already ships a widgets/custom-leagues.js that this
loader is meant to fetch. That is an unfinished feature to complete, not
dead code to delete.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(web): serve plugin-declared widgets, and actually ask for them

LEDMatrixWidgets.loadPluginWidget has always fetched
/static/plugin-widgets/<plugin>/<widget>.js, and docs/widget-guide.md has
always documented that path, but nothing served it. soccer-scoreboard has
shipped a 17KB widgets/custom-leagues.js since August that could never
load. Both halves were missing, not just the route:

- serve_plugin_widget serves the script from the plugin's widgets/
  directory as text/javascript. The manifest is the allowlist -- only a
  widget the plugin declares is reachable -- so installing a plugin does
  not publish everything it ships. Path handling mirrors the sibling
  serve_plugin_web_ui: allowlist regexes, os.path.basename, resolve() +
  relative_to() containment, and the ledmatrix- prefix fallback. The
  declared script name is guarded too, since it comes from the plugin
  rather than the request.

- The config form never requested one. Its x-widget dispatch is a
  hardcoded list of core widget names, so a plugin's own widget fell
  through to a plain text input. An unrecognised x-widget on a string
  field now asks ensureWidget() for it. The text input stays as the
  fallback and is removed only once the widget has actually rendered, so
  a missing or broken widget costs the user an editor rather than their
  configured value on the next save.

- manifest_schema.json gains "widgets", so the declaration is validated
  rather than merely tolerated by additionalProperties.

Verified in a browser against the real partial: a declared widget loads,
registers and renders, and its field posts exactly one value; a field
whose widget 404s keeps its text input and still posts its value.

Not addressed: loadPluginWidgetsFromManifest still has no caller. The
per-field ensureWidget path is lazier and is what the form now uses, so
that bulk helper is dead weight -- worth removing, but left alone here
rather than inventing a call site for it.

Known limitation, documented: only string-typed fields take this path.
object/array/boolean/number fields and enums are dispatched by the
template's own branches, which still only know core widgets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(element-style): a wrong-size BDF now keeps its font, not its size

BDF fonts are fixed-size bitmap strikes: FreeType accepts only the pixel
size baked into the file and raises for anything else. 32 of the 35
shipped fonts are BDF, so a size picked in the web UI usually is not a
valid strike -- and load_font caught that failure with its generic
"unloadable font" handler, which substitutes PressStart2P. Asking for
5x7.bdf at size 10 therefore rendered a completely different typeface,
silently.

It now falls back to the file's own native size instead, which is what
SportsCore._load_custom_font_from_element_config has always done. The
native size is read via FontManager._read_bdf_native_size rather than a
fourth copy of that parser, matching how core.py already delegates.

Also here, because they are the same code path:

- native_bdf_size() is exposed for the web UI, which needs to know when a
  size field can take effect at all. None means "free choice".
- ElementStyle.font_size now reports the size actually realised rather
  than the one requested. Callers lay out from it, and reserving space
  for a size nothing was drawn at is how this surfaces.
- The module font cache is a bounded LRU (256) instead of an unbounded
  dict. The display process runs for weeks and every config save can add
  a (font, size) pair; every other hot cache in the codebase is bounded
  this way.

Untouched configs are unaffected: the shipped classic fonts are the three
TTFs, so nothing was hitting the substitution path by default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(element-style): per-mode style and offset overrides

Lets one element be styled differently per situation -- a scoreboard's
live / upcoming / recent cards, weather's current / hourly / daily
screens -- under customization.modes.<mode>.

The mode is bound at construction rather than passed per call. That is
what makes this cheap to adopt: SportsUpcoming and SportsRecent are
already separate instances with distinct SKIN_MODE values, so binding
once makes every existing style()/offset_value() call site mode-aware
without editing any of them. A per-call mode argument exists for the rare
host that renders more than one mode.

The two layers answer different questions, deliberately:

- The base layer keeps the existing "differs from the schema default"
  rule, because the save flow writes the full default object into
  config.json whether or not the user touched it.
- A mode layer is pure override -- its fields default to None, so
  presence is intent. Nothing writes into it unasked, so there is nothing
  for the stricter rule to protect against.

None therefore means inherit, and has to stay distinct from 0: a mode
y_offset of 0 means "sit at the base position", not "no preference".
This is the same distinction scroll_card.switch_* draws with "inherit".

A malformed mode value falls back to the resolved base value rather than
to the caller's default -- caught by the degradation tests, which is what
they are for: resolving the mode first let one bad string in a mode block
silently discard a good base offset.

With no modes block, and for every existing caller, resolution is
unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(element-style): declare per-mode overrides in config_schema.json

A plugin adds "x-style-modes": ["live", "upcoming", "recent"] alongside
its x-style-elements declaration and gets a customization.modes.<mode>
group per mode, with every field of every declared element repeated as an
override.

Those override fields are typed nullable and default to null, which is
the whole trick. The save flow writes schema defaults into config.json
wholesale, so giving a mode field the base element's default would make
every mode a frozen copy of the base the first time a user pressed Save,
and the base would stop reaching them. Null means inherit. The mutation
test for this is explicit: with concrete defaults, a base font_size of 14
resolves as 10 with user_forced set.

min/max from the declaration carry into the mode blocks, so an
out-of-range override is rejected by validation rather than clamped
silently at render time.

Also: the emitted font field now carries "x-widget": "font-selector". The
widget already shipped and the config form already allowlisted it -- the
hint was simply never emitted, so the field rendered as a bare text box
that the user had to type a font filename into.

Verified through the real SchemaManager path -- load_schema, defaults
extraction, merge_with_defaults, validation, then resolution -- rather
than against a hand-built dict, since the thing at risk is what that
pipeline does to a null.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web): render the config form from the schema the save route validates

The form read config_schema.json with a raw json.load while
api_v3.save_plugin_config went through SchemaManager. Those are not the
same schema: SchemaManager applies expand_style_elements, which turns a
compact customization.x-style-elements declaration into the per-element
blocks the form knows how to render.

Without it, that customization object has an x-style-elements key and no
"properties", so the template's object branch matched nothing and the
section rendered as empty space -- while saving still validated against
the expanded shape. of-the-day ships the compact form, so its
customization section has been invisible in the web UI.

pages_v3 gains a schema_manager the way it already has config_manager and
plugin_manager. use_cache=False matches the save route, so an edited
schema is not served stale during plugin development. The raw read stays
as a fallback for callers that register this blueprint without one.

Checked before making the change: load_schema does nothing here except
read, validate and expand -- inject_skin_selector is a separate method it
does not call -- so this is not a behaviour change for schemas without
the declaration.

The test pair renders the same compact schema with and without a
SchemaManager, so it documents exactly what was broken as well as what is
fixed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(web): style-editor widget -- a row per element instead of 65 accordions

Rendered element by element, a realistic scoreboard's customization block
is 65 nested sections, and reaching one per-mode font size takes five
levels of expanding. The widget collapses that to one compact row per
element -- font, size, colour, X, Y -- with a tab per declared mode.

It emits ordinary inputs under the same dotted names the generic renderer
would produce, so the save/validate/merge pipeline is untouched: no hidden
JSON blob and no new server-side parsing. It is driven entirely by the
schema block it is handed, so fields added to the schema later appear
without editing the widget. If it fails to load or throws, the generic
nested rendering it replaces is left in place.

Fixing two things the save path got wrong for nullable fields, found by
posting what the widget actually emits:

- The indexed-array recombiner (text_color.0/.1/.2 -> one list) compared
  the declared type to the string 'array', so a per-mode colour, typed
  ["array", "null"], was never reassembled and failed validation on save.
  _parse_form_value_with_schema had the same comparison.
- A blank nullable field became [] rather than None, which then failed the
  minItems the colour array declares. Null is the inherit sentinel, so it
  has to survive.

And two things the widget itself got wrong, found by looking at it:

- An unset base control fell back to the select's first option, so an
  untouched scoreboard claimed every element used 10x20.bdf -- and the
  size box then locked itself to that bitmap font's fixed size. Base
  controls now show the schema default; mode controls stay blank, because
  blank there means inherit.
- Elements arrived alphabetised (Detail and Odds above Score). Flask's
  JSON provider sorts keys, so declaration order has to be stated
  explicitly; expand_style_elements now emits x-propertyOrder, which the
  generic renderer already honoured too.

Size is disabled and shown as fixed for a bitmap font, using the
scalable/native_size the font catalog now reports.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(element-style): visibility, alignment and scale per element

Completes the customization vocabulary: hide an element, align it, and
resize a logo, alongside the font/size/colour/offset that already existed.
All three per mode.

They resolve to "change nothing" until the user asks for something -- True,
None and 1.0 -- rather than to whatever the schema declares. That is the
same invariant the font fields keep: a caller that honours them still
renders an untouched config exactly as it did before they existed. A
schema default therefore does not count as a choice, which matters because
the save flow writes that default into config either way.

scale sits in the layout block with the offsets rather than in the element
block, because it is geometry: a logo has a scale and no font. The widget's
columns come from the schema, so a logo row shows visibility, offsets and
scale and no empty font cell.

Two bugs found by the tests rather than by reading:

- A nullable enum needs null in its enum list, not just in its type. The
  mode copy of `align` defaulted to null and then failed its own schema, so
  a plugin declaring any enum field with modes could not save at all. Six
  tests failed on this before any of them reached what they were testing.
- defaults_from_schema only ever extracted font/font_size/text_color, so
  the schema defaults for the new fields were invisible to the resolver and
  a declared default read as a user choice.

Widget: the table scrolls horizontally and pins the element-name column.
Nine columns do not fit the config panel, and clipping them hid the offsets
entirely while scrolling them made every row anonymous.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(element-style): resolve elements under the names plugins actually use

Two naming conventions collided as the scoreboards grew. Counted across
the published schemas: the style block names elements with a _text suffix
(score_text, status_text, detail_text), while the layout block mostly uses
the bare noun (score, date, time, odds) -- except status_text, which kept
the suffix in seven plugins and lost it in two. records vs record splits
seven to two the same way.

A lookup now tries the exact name first and then the spellings that mean
the same thing. Exact-first is what makes this inert for any config that
already matches; the aliases only decide cases that resolved to nothing
before.

This is also what makes migrating to the compact declaration form safe.
That form uses one key for both blocks, so a scoreboard adopting it asks
for layout.score_text while its users have layout.score saved -- without
the aliases, every offset they had dialled in would silently become 0.

Applies to the style block, the layout block, the schema defaults and the
per-mode overrides, since the drift shows up in all four.

Not attempting to canonicalise on write: renaming keys in config.json
would break the plugins still reading the old spelling from their own
bundled code, and the drift costs a dict miss rather than correctness.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(plugins): BasePlugin.styles -- per-element styling every plugin inherits

Adopting the element-style system meant repeating three things in every
plugin: a guarded import, finding its own config_schema.json, and
rebuilding the resolver when on_config_change swapped the config dict.
This is those three things once, on the class all 45 plugins already
inherit from.

    title = self.styles.style('title_text',
                              classic_font='PressStart2P-Regular.ttf',
                              classic_size=8, classic_color=(255, 255, 255))

The classic_* arguments are the adoption contract: with nothing configured
they come back verbatim, so a plugin that switches to this renders exactly
as before until a user changes something.

A plugin with one instance per display mode sets STYLE_MODE on the class
and every existing lookup becomes mode-aware without a call site changing
-- which is the point of binding the mode to the resolver rather than
passing it per call. styles_for() covers a plugin that renders several
modes from one instance.

Schema discovery reads the concrete class's own module rather than this
file, because this file lives in src/plugin_system where no plugin schema
exists -- the same trap SportsCore._config_schema_path documents. The
first mutation test for that passed anyway: an installed plugin's module
directory and its entry under plugins_dir are the same path, so the test
could not tell the two apart. The case where they diverge is a plugin
symlinked in for development, and the test now forces that shape.

Getting discovery wrong is silent rather than loud: with no schema the
resolver has no defaults to compare against, so every configured value
reads as a deliberate override and the plugin quietly stops honouring its
own shipped styling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(element-style): adopt hand-written customization blocks, and widen the font list

Nineteen plugins spell their style elements out longhand instead of
declaring them -- football's block is 701 lines for seven elements -- and
predate this system entirely. Core now recognises that shape, so they pick
up the row-per-element editor and the real font picker on a core update
rather than on a plugin release. Checked against every published schema:
21 plugins adopt, and the defaults of each still validate against the
schema generated for it.

Detection requires *every* field in a block to be one this system
understands. A looser "has at least one style field" rule sweeps in
baseball's `count`, which carries a text_color beside geometry that means
nothing here. That distinction took three attempts to test: the first two
assertions passed under both rules, because an over-eager rule leaves a
fontless block looking untouched and only surfaces as an extra row in the
editor.

The hardcoded font enum is replaced rather than extended. Football lists
five of the thirty-five installed fonts, which is why a font a user
uploads can never appear in one. It is not a curated safe set -- it omits
some twenty other faces that fit the declared size cap just as well -- it
is the fonts that happened to exist when it was written.

Widening it does need a guard, though, and not the one the schema already
has: a bitmap font ignores font_size and renders at its size baked into
the file, so `maximum: 16` cannot stop a 27px face. The picker now filters
out fixed-size fonts taller than the element's own declared ceiling, which
drops exactly the four that would overflow a 32px panel and keeps the
other thirty.

Per-mode overrides stay opt-in: core cannot invent a plugin's display
modes, so `x-style-modes` remains the one line that unlocks them. Their
layout half covers every positionable element rather than only those with
a style block -- the two namespaces do not line up in a hand-written
schema, and football positions six things (logos, timeouts, possession)
that have no style block at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(web): remove the two Fonts-tab panels that reported invented data

"Element Font Overrides" let a user configure an override, showed a
success toast, and changed nothing. All three endpoints behind it were
stubs -- GET returned a hardcoded {}, POST and DELETE returned success
without calling anything -- each marked "This would integrate with the
actual font system".

Wiring them to FontManager would not have fixed it. The machinery there is
real (_load_overrides/_save_overrides persist config/font_overrides.json,
resolve_font applies them, and the countdown plugin genuinely consumes
it), but the panel's element dropdown offered eleven invented keys --
nfl.live.score, clock.time, weather.current -- that no plugin has ever
read. An override saved against one of those would have persisted
correctly and still done nothing.

"Detected Manager Fonts" goes for the same reason. It claimed to show
"fonts currently in use by managers (auto-detected)"; its own comment said
"we'll simulate this", and it listed every font in the catalog with a
hardcoded usage_count of 1 -- the panel beside it, with fabricated
numbers attached.

Per-element font choice now lives in each plugin's own config editor,
against the elements that plugin actually has, and covers size, colour,
offsets, visibility, alignment and scale rather than family and size.

Kept: the font library (upload, preview, delete), which works, and
/fonts/tokens, which is a stub but genuinely feeds the preview's size
dropdown. FontManager's override methods are untouched -- countdown uses
them.

Verified in a browser with the tab's JS running: no console errors, 35
fonts listed, upload and preview intact. Removing the panel meant unwiring
it from populateFontSelects too, which would otherwise have bailed out
early on the missing select and left the preview dropdown empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(sports): one reader for element colours and layout offsets

There were two copies of the per-element colour read and three of the
layout-offset read. They had already drifted -- the scroll-card renderer
carries a comment about having ignored offsets its own schema advertised
-- and each new capability had to be added to all of them or silently work
in some places and not others.

All of them now go through src.element_style, which is what carries the
alias handling and the per-mode lookup. That lands immediately for the
nine plugins importing these modules: a scoreboard asking for `score_text`
offsets finds the `layout.score` its users configured, and a Live instance
resolves its own colours through SKIN_MODE without any call site passing a
mode.

_normalize_color learned "#RRGGBB" in the process. The scoreboards' own
readers have always accepted it, so the shared one had to, or consolidating
would have quietly dropped a form users' configs may hold. _coerce_offset
picked up the non-finite guard the scroll-card reader had and the other two
did not.

_get_layout_offset is promoted onto SportsCoreSharedMixin. Each plugin
still carries its own copy in its bundled sports.py, which wins by MRO --
so adopting this is a deletion in the plugin, and until that deletion
nothing changes for it.

Note for whoever runs the suite next: test_display_dirty_tracking.py is
order-dependent. Fifteen of its tests failed in one full run and passed in
the next with no change in between, and pass in isolation. Pre-existing,
unrelated to this, but it makes a full-run diff untrustworthy until it is
fixed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(changelog): record the element-style work under Unreleased

This file's own preamble asks for it: a plugin may delete its bundled
fallback copy of a core module only when its manifest floors on the first
release that shipped that module, which requires the additions to be
recorded here against a version.

Names a plugin can now import and floor on -- the stateless layout_offset
and element_color readers, alias_keys, native_bdf_size, the resolver's mode
binding, BasePlugin.styles, and the promoted
SportsCoreSharedMixin._get_layout_offset -- plus the schema and web-UI
changes, the four fixes and the three removals.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(fonts): log the BDF native-size read failure instead of swallowing it

The bdf-native-size lookup in get_fonts_catalog() caught any exception
and silently discarded it. Every other guarded read added in this PR
(the manifest parse in _declared_widget_script, the SchemaManager
fallback in _load_plugin_config_partial) logs before falling through
to the same degraded behavior. This one didn't, which is the shape a
silent-exception-swallow lint rule flags. Behavior is unchanged --
native_size still comes back None -- but a corrupt or unreadable BDF
file now leaves a trace.

Verified: font-related tests (140) and the full suite still pass,
with only the 2 pre-existing Europe/Kiev/Asia/Calcutta tzdata-alias
failures already present on origin/main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: address CodeRabbit findings on the style-editor/font-selector PR

- Fix _load_font_sized double-wrapping the (font, size) tuple on the
  missing-font path, which handed callers a tuple instead of a font.
- Fix _set_nested_value skipping an explicit None when the key already
  existed, which silently kept stale overrides when a user cleared a
  nullable per-mode field or blanked all channels of an indexed color.
- Preserve BDF scalable/native_size metadata through fetchFontCatalog's
  catalog-format mapping so maxFixedSize filtering actually applies.
- Stop caching an empty array on a failed font-catalog fetch so a later
  call can retry instead of being stuck with the failed result.
- Keep a saved font selected in the style editor even when it no longer
  fits a newly declared maxFixedSize, instead of silently deselecting it.
- Don't drop in-progress user edits to fallback fields when a plugin
  widget finishes loading asynchronously and takes over the form.
- Tighten the removed font-override endpoint test to assert 405, not
  just != 200.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web): a partial save no longer switches off checkboxes it never showed

An HTML checkbox posts nothing when unchecked, so the save route walked the
schema and forced every boolean missing from the form to False. That is right
for the rendered form and wrong for every other caller: a script, the MQTT
bridge or a curl against the documented endpoint never rendered a checkbox, and
reading its silence as "all off" turns a one-field save into a mass disable.

Found on hardware. Posting four customization.* keys to a live device switched
off nfl.enabled, ncaa_fb.enabled and every display-mode toggle in one request.

The form now reports the top-level sections it drew (__rendered_section), and
inside those an absent checkbox still means unchecked -- including a section
whose only fields are checkboxes that are all off, which no heuristic could
recover. A post with no marker only touches objects it actually posted a field
from. Meta fields are dropped before form keys are treated as config paths,
because unknown keys are otherwise written straight into config.json.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(sports): resolve element colour by name, and honour visible/align/scale

Two of the three gaps this framework shipped with.

Colour by name. A draw resolved its colour by comparing the *identity* of the
font object it was handed, which cannot tell two elements apart when they share
a face -- so those draws went out white. Every bitmap font is in that case,
because a freetype.Face cannot be re-instantiated to un-share it, which is how
an element rendered in any of the 32 shipped BDF fonts silently lost a colour
its picker had offered all along. _draw_text_with_outline now takes
element="score_text" and reads the colour by name; the identity path remains
for un-annotated callers, but narrows before giving up -- one configured colour
among the sharers is the only thing the user can have meant.

Visible, align and scale. The resolver has understood these since the
framework landed and nothing consumed them: an element could be marked hidden
in the web UI and still render. Adds the stateless readers, the mixin
accessors, and a scale parameter on the one shared logo-sizing seam (keyed into
the cache, so two elements scaled differently cannot be served each other's
image). Naming an element in a draw also honours its visibility.

Untouched configs are unaffected: every new parameter defaults to today's
behaviour, and all ten affected plugins render pixel-identically to main across
every harness size.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(plugins): how to declare styleable elements; harden the widget's lookups

The plugin-author guide for the compact x-style-elements declaration -- what
each key does, how to read values back without breaking the "user-forced only
when it differs from the default" rule, and why a hand-written block needs no
changes to be adopted.

Also clears the static-analysis findings on style-editor.js. Every lookup in
that file is keyed by something out of a schema or a saved config, so a key of
__proto__ or constructor would walk the prototype chain and hand back a
function instead of a schema; reads now go through an own-property helper. The
panel registry became a list, and the flagged vars moved to their function
roots.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web): clear the remaining static-analysis findings

Five, all on lines this branch touched.

The Python one is not a new defect: _set_missing_booleans_to_false's first
parameter was always named `config`, which shadows the `config` submodule
imported for its side effects at the bottom of this module. Editing the
signature simply put the existing warning on a changed line. The parameter is
the plugin's config dict, so `plugin_config` is what it should have been called
anyway; callers pass it positionally and are unaffected.

The JavaScript ones are the object-injection rule firing on reads keyed by
data. own() now goes through a property descriptor, so the one unavoidable
data-keyed read is no longer a computed member access; at() consumes its path
instead of indexing it; and the column set is a Map, which has no prototype to
pollute and needs no guarded reads at all.

Verified the widget still renders identically against football's real schema:
29 element rows, all four mode tabs, values populated, no console errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web): drop the hasOwnProperty alias the descriptor read made redundant

own() now reads through Object.getOwnPropertyDescriptor, so the alias it used to call has no remaining reference.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 11:53:50 -04:00

1375 lines
64 KiB
Python

"""The sports.py surface that is byte-identical in every scoreboard.
Nine plugins ship their own ``sports.py`` -- 41,326 lines in total. Comparing
executable ASTs across the eight that share a lineage, 48 method bodies are
byte-identical in all eight: 1,007 lines carried in eight copies, so 8,056
duplicated lines that must be edited eight times to fix once.
They are the parts with no sport in them. The selection and rotation engine
(``_round_robin_favorites``, ``_favorites_first``, ``_compose_selection``,
``_check_ranking_coverage``, ``_game_divisions``, ``_normalise_quality``), the
font/colour/date subsystem (``_scale_headline_fonts``, ``_scorebug_font``,
``_resolve_font_size``, ``_format_game_date``, ``_font_color``), and the
switch-mode upcoming card (``_draw_upcoming_center_switch``). Nothing here knows
what an inning or a possession is.
Mixins rather than free functions, because every one of these reads host state
-- ``self.config``, ``self.fonts``, ``self.logger``, ``self.display_width``.
Rewriting 48 bodies into free functions would be a rewrite, not a move; as
mixins the bodies move verbatim, which is what keeps the renders identical.
THREE OF THE 48 ARE DELIBERATELY LEFT BEHIND
--------------------------------------------
Byte-identical bodies are not automatically safe to move: a body can bind a
module-level name that differs per plugin, and then it only *looks* the same.
- ``_get_timezone`` calls ``resolve_timezone``, imported from a per-plugin
module (``hockey_timezone``, ``soccer_timezone``, ...). All eight of those
differ -- each carries its own ``_WRITEBACK_FIXED_IN`` version -- so moving
the caller here would silently bind every scoreboard to one plugin's copy.
- ``_extract_game_details`` and ``_fetch_data`` are ``@abstractmethod`` stubs.
They are the sport-specific contract; satisfying them from a mixin would let a
plugin instantiate without implementing its own sport.
``_resolve_font_path`` went the other way: it is a module-level function in
sports.py rather than a method, identical in all eight, and ``_scale_headline_fonts``
needs it -- so it is inlined below rather than left behind.
WHAT A HOST MUST PROVIDE
------------------------
Enumerated by walking every ``self.<attr>`` the mixins read and subtracting what
they define, so this list is derived rather than remembered. Everything below is
supplied by all eight scoreboards today.
State: ``config``, ``fonts``, ``logger``, ``display_width``, ``display_height``,
``display_manager``, ``league``, ``sport``, ``mode_config``, ``session``,
``headers``, ``favorite_teams``, ``games_list``, ``current_game_index``,
``last_game_switch``, ``last_update``, ``update_interval``,
``no_data_interval``, ``game_display_duration``, ``stale_game_timeout``,
``other_games_min_quality``, ``schedule_lookback_days``,
``schedule_lookahead_days``, ``game_update_timestamps``,
``_zero_clock_timestamps``, ``_logo_cache``, ``_selection_pools``,
``_ranking_coverage_logged_at``, ``_empty_live_streak``, ``_last_warning_time``,
``_score_grew``.
Methods that stay per-plugin, because they are not identical across the eight
(or, for ``_get_timezone``, because they bind per-plugin modules):
``_get_layout_offset``, ``_by_importance``, ``_other_games_window``,
``_upcoming_date_and_time_text``, ``_extract_game_details_common``,
``_load_division_team_ids``, ``_get_timezone``, ``_is_favorite_game``,
``_is_game_really_over``, ``_is_ranked_game``, ``_passes_other_filters``.
Of the fourteen shared class constants, thirteen are identical everywhere and
live here. Only ``_SCORE_PROBE_TEXT`` varies -- afl and basketball reach three digits
a side and override it, the same two that override ``_SCORE_PROBE`` on
``SportsGameRendererMixin``.
DELIBERATELY NOT MERGED WITH sports_card
----------------------------------------
Fourteen of these have same-named twins in ``src/common/sports_card.py``, which
the scoreboards' ``game_renderer.py`` already uses. They are NOT wired together
here. Only five are provably equivalent by source comparison; the other nine
differ in ways inspection cannot settle, and a wrong guess silently changes what
every scoreboard draws. Merging them needs differential testing against both
implementations, and is left for its own change.
"""
from __future__ import annotations
import logging
import os
import sys
import time
from datetime import datetime, timedelta, timezone
from typing import Any, ClassVar, Dict, List, Optional, Tuple
import pytz
import requests
from PIL import Image, ImageDraw, ImageFont
from src.common.font_layout import load_truetype
logger = logging.getLogger(__name__)
# How long a live mode may stretch its poll interval when nothing is happening,
# and the streak lengths that earn each stretch. Identical in all eight plugins.
_IDLE_SHORT_STREAK = 6
_IDLE_SHORT_FACTOR = 2
_IDLE_LONG_STREAK = 24
_IDLE_LONG_FACTOR = 6
_DEFAULT_LIVE_IDLE_MAX_SECONDS = 900
def _resolve_font_path(path: str) -> str:
"""Resolve a bundled font path without depending on the process cwd.
These fonts ship with the LEDMatrix core, and every call site here named
them relative to the working directory. That holds under the packaged
systemd unit, whose WorkingDirectory is the install root, and breaks
everywhere else -- the plugin safety harness, a manual run from $HOME, a
unit file written without WorkingDirectory. The failure is quiet: the
load raises, the caller falls back, and the scoreboard renders in PIL's
default face instead of the pixel font it was laid out for.
Resolution order matches the core's own resolver: the path as given
first, so behaviour is unchanged wherever it already worked and a
configured absolute path is returned untouched, then the core install
root, then the original string so callers still raise and fall back
exactly as they do today.
"""
if os.path.exists(path):
return path
try:
import src.font_manager as _core_fonts
# The core grew this resolver in ChuckBuilds/LEDMatrix#425. Use it
# when it is there so both repos stay on one definition of "install
# root"; older cores fall through to the equivalent derivation below.
manager = getattr(_core_fonts, "FontManager", None)
resolver = getattr(manager, "_resolve_asset_path", None)
if resolver is not None:
resolved = resolver(path)
if resolved and os.path.exists(resolved):
return resolved
root = os.path.dirname(os.path.dirname(os.path.abspath(_core_fonts.__file__)))
candidate = os.path.join(root, path)
if os.path.exists(candidate):
return candidate
except (ImportError, AttributeError, OSError):
# No core on the path (standalone tooling), a core laid out
# differently, or an unreadable install. Returning the original keeps
# the caller's existing fallback intact.
return path
return path
class SportsCoreSharedMixin:
"""The ``SportsCore`` bodies identical in all eight scoreboards."""
#: Design height the font scale is expressed against.
_FONT_DESIGN_HEIGHT: ClassVar[int] = 32
#: Fraction of the centre strip a score may grow into.
_SCORE_GROWTH_BUDGET: ClassVar[float] = 0.65
#: Widest score the scorebug sizes itself to hold. Leagues that reach three
#: digits a side override this with "000-000".
_SCORE_PROBE_TEXT: ClassVar[str] = "00-00"
#: Whether this sport's scorebug draws a score at all.
_DRAWS_SCORE: ClassVar[bool] = False
#: Fallback (font, size) rungs for a score that will not fit.
_NARROW_SCORE_RUNGS: ClassVar[Tuple[Tuple[str, int], ...]] = (
("4x6-font.ttf", 14), ("4x6-font.ttf", 7))
#: Hard ceiling on score growth, in multiples of the configured size.
_SCORE_MAX_GROWTH: ClassVar[int] = 2
#: Which colour setting owns each font slot.
_ELEMENT_FOR_FONT: ClassVar[Dict[str, str]] = {
"score": "score_text", "time": "period_text", "team": "team_text",
"detail": "detail_text", "status": "status_text"}
#: Default tint for a favourite team's finished game.
FAVORITE_RESULT_COLOR_DEFAULTS: ClassVar[Dict[str, Tuple[int, int, int]]] = {
"win": (0, 255, 0), "loss": (255, 0, 0), "tie": (255, 200, 0)}
_MONTH_ABBR: ClassVar[Tuple[str, ...]] = (
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
_WEEKDAY_ABBR: ClassVar[Tuple[str, ...]] = (
"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
#: Bitmap fonts snap to their native pixel grid.
_FONT_PIXEL_GRID: ClassVar[Dict[str, int]] = {
"PressStart2P-Regular.ttf": 8, "4x6-font.ttf": 7}
_FONT_NAME_ALIASES: ClassVar[Dict[str, str]] = {
"press_start": "PressStart2P-Regular.ttf", "four_by_six": "4x6-font.ttf"}
#: Accepted values for the other-games quality filter.
_QUALITY_CHOICES: ClassVar[frozenset] = frozenset({"any", "ranked"})
#: How long to stay quiet between ranking-coverage warnings.
_RANKING_COVERAGE_SECONDS: ClassVar[int] = 60 * 60
def _get_season_schedule_dates(self) -> tuple[str, str]:
return "", ""
def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None:
"""Placeholder draw method - subclasses should override."""
# This base method will be simple, subclasses provide specifics
try:
img = Image.new("RGB", (self.display_width, self.display_height), (0, 0, 0))
draw = ImageDraw.Draw(img)
status = game.get("status_text", "N/A")
self._draw_text_with_outline(draw, status, (2, 2), self.fonts["status"],
element="status_text")
self.display_manager.image.paste(img, (0, 0))
# Don't call update_display here, let subclasses handle it after drawing
except Exception as e:
self.logger.error(
f"Error in base _draw_scorebug_layout: {e}", exc_info=True
)
@classmethod
def _crisp_size(cls, font_file, desired):
"""Snap *desired* to the nearest size *font_file* renders crisply at.
A face with no known grid is returned unchanged, so a user-supplied
font is never second-guessed.
"""
font_file = cls._FONT_NAME_ALIASES.get(font_file, font_file)
grid = cls._FONT_PIXEL_GRID.get(font_file)
if not grid or not desired or desired <= 0:
return desired
return max(grid, int(round(float(desired) / grid)) * grid)
#: Absolute path of this plugin's directory, declared by the plugin
#: itself. The mixin cannot work it out -- see _plugin_dir.
_PLUGIN_DIR: ClassVar[Optional[str]] = None
def _plugin_dir(self) -> Optional[str]:
"""Directory of the plugin that owns this instance.
In sports.py these methods could just use ``__file__``. Here that is
src/common/, so the directory has to come from the plugin.
It is TOLD, not deduced. The first version walked the MRO for a class
whose module sits beside a config_schema.json. That works when a test
imports the plugin itself, and returns None under the real loader, for
a specific reason worth recording:
PluginLoader._namespace_plugin_modules renames every bare module a
plugin brought in (sports, game_renderer, ...) to
"_plg_<plugin_id>_<module>" and REMOVES the bare entry, so two
plugins owning a module of the same name cannot collide.
The class still reports ``__module__ == "sports"``, but
``sys.modules["sports"]`` no longer exists, so the walk finds no
__file__ and falls off the end. The failure was silent and expensive:
_plugin_dir() -> None
_schema_font_size() -> None for every element
-> a configured size equal to the schema default stops looking
like a default and is treated as a deliberate user choice
-> the snap to the font's pixel grid is skipped
-> 4x6-font.ttf renders at 6 instead of 7: 3px-wide glyphs
instead of 4px
On a 256x64 panel that made the odds, the team records and the date row
hard to read. It was found by a user counting pixels on the panel. No
gate here caught it: the tests imported plugins directly and the
safety harness loads them its own way, so neither reproduced the
loader's renaming.
The MRO walk stays as a fallback for hosts that declare no
_PLUGIN_DIR -- the plugins' own probe harnesses build classes with
``type()`` -- but it is no longer the primary answer.
"""
declared = getattr(self, "_PLUGIN_DIR", None)
if declared and os.path.isfile(os.path.join(declared, "config_schema.json")):
return declared
for cls in type(self).__mro__:
module = sys.modules.get(getattr(cls, "__module__", ""), None)
path = getattr(module, "__file__", None)
if not path:
continue
directory = os.path.dirname(os.path.abspath(path))
if os.path.isfile(os.path.join(directory, "config_schema.json")):
return directory
return None
def _schema_font_size(self, element_key):
"""The font_size this plugin's config_schema.json declares, or None."""
if not element_key:
return None
cache = getattr(self.__class__, '_SCHEMA_FONT_SIZES', None)
if cache is None:
cache = {}
try:
import json
directory = self._plugin_dir()
if directory is None:
raise FileNotFoundError("no config_schema.json on the MRO")
with open(os.path.join(directory, 'config_schema.json')) as fh:
schema = json.load(fh)
props = (schema.get('properties', {})
.get('customization', {})
.get('properties', {}))
for key, spec in props.items():
size = spec.get('properties', {}).get('font_size', {}).get('default')
if size is not None:
cache[key] = int(size)
except Exception as exc:
# Say so. An unreadable schema is not cosmetic: every element's
# configured size then stops matching "the schema default", is
# treated as a deliberate user choice, and skips the snap to the
# font's pixel grid -- which renders 4x6-font.ttf at 6 instead
# of 7, a 3px-wide glyph instead of 4px. That shipped once,
# silently, and was found by a user counting pixels on a photo
# of the panel.
#
# Logged, not raised: a missing schema must not stop a plugin
# rendering. The cache is built once per class, so this cannot
# repeat per frame.
logger.warning(
"%s: could not read config_schema.json (%s: %s); every font "
"size will be treated as user-chosen and will skip its pixel "
"grid snap. Font sizes may render a pixel narrow.",
type(self).__name__, type(exc).__name__, exc)
cache = {}
self.__class__._SCHEMA_FONT_SIZES = cache
return cache.get(element_key)
def _resolve_font_size(self, element_config, element_key, default_size, font_name):
"""Size to render at: the user's choice, or a grid-snapped default.
A configured size counts as a real choice only when it differs from
the schema default. The web UI writes the whole schema default block
on every save, so "font_size == schema default" carries no intent and
would otherwise pin every install to an anti-aliased size forever.
"""
configured = (element_config or {}).get('font_size')
if configured is not None:
try:
configured = int(configured)
if configured != self._schema_font_size(element_key):
return configured
except (TypeError, ValueError):
pass
return self._crisp_size(font_name, default_size)
def _card_option(self, key: str, default: Any = None) -> Any:
"""Read one key from the scroll_card config block."""
block = (self.config or {}).get("scroll_card")
if isinstance(block, dict) and block.get(key) is not None:
return block.get(key)
return default
def _switch_upcoming_center(self) -> str:
"""Middle of the full-screen upcoming scorebug: 'vs', 'date_time' or 'none'."""
mode = str(self._card_option("switch_upcoming_center", "date_time")
or "date_time").lower()
if mode == "inherit":
mode = str(self._card_option("upcoming_center", "vs") or "vs").lower()
return mode if mode in ("vs", "date_time", "none") else "date_time"
def _vs_text(self) -> str:
"""Separator drawn between the teams -- "VS", "@", "at", anything."""
return str(self._card_option("vs_text", "VS"))
def _switch_date_format(self) -> str:
"""Date style for the full-screen scorebug.
Its own key rather than the shared ``date_format`` because the two
displays disagree about the default: the scroll card renders "Sep 19"
while _extract_game_details_common emits "9/19", the "numeric" style,
and this scorebug has always drawn it. Reading the shared key here
would restyle every existing panel on update -- and "leave it alone
when unset" is not available, because the core merges schema defaults
into the config on every load, so the key is never actually unset.
"inherit" opts into the scroll and Vegas setting.
"""
fmt = str(self._card_option("switch_date_format", "numeric") or "numeric").lower()
if fmt == "inherit":
fmt = str(self._card_option("date_format", "abbrev") or "abbrev").lower()
return fmt
def _format_game_date(self, date_text: str, game: Optional[Dict] = None) -> str:
"""Format an upcoming date per scroll_card.switch_date_format."""
raw = str(date_text or "").strip()
if not raw:
return raw
fmt = self._switch_date_format()
if fmt == "numeric":
return raw
parts = raw.replace("-", "/").split("/")
if not (len(parts) >= 2 and parts[0].strip().isdigit() and parts[1].strip().isdigit()):
return raw
month, day = int(parts[0]), int(parts[1])
if not 1 <= month <= 12:
return raw
name = self._MONTH_ABBR[month - 1]
if fmt == "numeric_day_first":
return f"{day}/{month}"
if fmt == "day_first":
return f"{day} {name}"
if fmt == "weekday":
weekday = self._weekday_for(game)
return f"{weekday} {name} {day}" if weekday else f"{name} {day}"
return f"{name} {day}"
def _weekday_for(self, game: Optional[Dict]) -> str:
"""Weekday abbreviation from the game's start time, or ''."""
if not game:
return ""
raw = game.get("start_time_utc") or game.get("start_time")
if not raw:
return ""
try:
start = raw if isinstance(raw, datetime) else datetime.fromisoformat(
str(raw).replace("Z", "+00:00"))
return self._WEEKDAY_ABBR[start.astimezone(self._get_timezone()).weekday()]
except (ValueError, TypeError, OverflowError):
return ""
def _format_game_time(self, time_text: str) -> str:
"""Return the time as-is (12h) or converted to 24h."""
raw = str(time_text or "").strip()
if not raw or str(self._card_option("time_format", "12h")) != "24h":
return raw
cleaned = raw.upper().replace(" ", "")
meridiem = "AM" if cleaned.endswith("AM") else "PM" if cleaned.endswith("PM") else ""
if not meridiem:
return raw
try:
hh, _, mm = cleaned[:-2].partition(":")
hour, minute = int(hh), int(mm or 0)
except ValueError:
return raw
if not (0 <= hour <= 12 and 0 <= minute <= 59):
return raw
hour = hour % 12 + (12 if meridiem == "PM" else 0)
return f"{hour:02d}:{minute:02d}"
def _scorebug_font(self, draw, text: str, width: int):
"""The face this scorebug draws its date and time in.
Always the "time" face, which is what this display has used for both
rows for as long as it has existed: changing switch_upcoming_center
moves the two lines around, it is not meant to restyle them, so the
type stays put while the placement changes.
The single exception is text that cannot fit the panel at all. Only
the "weekday" date can do that -- "Fri Sep 19" measures 80px in an
8px face, on a board 64px wide -- and the smaller "detail" face is a
better answer there than running off both edges. Every other date and
time this display can produce fits, so in practice the face never
changes; it is a floor, not a style rule.
"""
font = self.fonts["time"]
if not text:
return font
try:
if draw.textlength(text, font=font) + 2 <= width:
return font
except (TypeError, ValueError):
return font
return self.fonts.get("detail") or font
def _draw_upcoming_center_switch(self, draw, game: Dict, center_y: int,
game_date: str, game_time: str,
display_width: Optional[int] = None,
display_height: Optional[int] = None,
date_element: str = 'date',
time_element: str = 'time',
second_row_y_offset: bool = True) -> bool:
"""Draw the middle of the full-screen upcoming scorebug.
Returns True when the header above it ("Next Game", or the league
name) should still be drawn. In "vs" and "none" the date and time move
out of the middle and into the top and bottom slots, mirroring the
scroll card -- and the top slot is where the header used to be, so the
caller drops it.
``date_element``/``time_element``/``second_row_y_offset`` exist only so
the layout-offset keys stay exactly what each plugin's schema
advertises; this sport's defaults are the common case.
"""
width = self.display_width if display_width is None else display_width
height = self.display_height if display_height is None else display_height
mode = self._switch_upcoming_center()
date_text, time_text = self._upcoming_date_and_time_text(
game_date, game_time, game)
swapped = bool(self._card_option("swap_date_time", False))
if mode == "date_time":
# Historically the date sat at center_y - 7 with the time 9px
# under it, and the time's row was derived from the date's, so a
# date y_offset moved the pair. Both still hold; the slots only
# trade places when swap_date_time is set, and hiding one line
# leaves the other where it was rather than re-centering the stack.
slots = [(time_element, time_text), (date_element, date_text)] if swapped \
else [(date_element, date_text), (time_element, time_text)]
row_y = center_y - 7
for index, (element, text) in enumerate(slots):
if index:
row_y += 9
if second_row_y_offset:
row_y += self._get_layout_offset(element, 'y_offset')
else:
row_y += self._get_layout_offset(element, 'y_offset')
if not text:
continue
font = self._scorebug_font(draw, text, width)
text_width = draw.textlength(text, font=font)
text_x = ((width - text_width) // 2
+ self._get_layout_offset(element, 'x_offset'))
self._draw_text_with_outline(
draw, text, (text_x, row_y), font
)
return True
if mode == "vs":
vs_text = self._vs_text()
if vs_text:
vs_width = draw.textlength(vs_text, font=self.fonts["score"])
vs_x = ((width - vs_width) // 2
+ self._get_layout_offset('score', 'x_offset'))
vs_y = (center_y - 3
+ self._get_layout_offset('score', 'y_offset'))
vs_x = self._aligned_x('score_text', vs_width, width, vs_x)
self._draw_text_with_outline(
draw, vs_text, (vs_x, vs_y), self.fonts["score"],
element="score_text"
)
# "vs" and "none" both push the date and time out to the edges, time
# on top unless swap_date_time says otherwise -- the same order the
# scroll card uses.
if swapped:
top_element, top_text = date_element, date_text
bottom_element, bottom_text = time_element, time_text
else:
top_element, top_text = time_element, time_text
bottom_element, bottom_text = date_element, date_text
if top_text:
top_font = self._scorebug_font(draw, top_text, width)
top_width = draw.textlength(top_text, font=top_font)
top_x = ((width - top_width) // 2
+ self._get_layout_offset(top_element, 'x_offset'))
top_y = 1 + self._get_layout_offset(top_element, 'y_offset')
self._draw_text_with_outline(
draw, top_text, (top_x, top_y), top_font
)
if bottom_text:
bottom_font = self._scorebug_font(draw, bottom_text, width)
bottom_width = draw.textlength(bottom_text, font=bottom_font)
bottom_x = ((width - bottom_width) // 2
+ self._get_layout_offset(bottom_element, 'x_offset'))
# Measured, not a fixed offset: the detail font is 6px in most
# plugins and 10px in soccer and nrl, where a fixed -7 ran the
# date off the panel.
ink_bottom = draw.textbbox((0, 0), bottom_text, font=bottom_font)[3]
bottom_y = (max(0, height - ink_bottom - 1)
+ self._get_layout_offset(bottom_element, 'y_offset'))
self._draw_text_with_outline(
draw, bottom_text, (bottom_x, bottom_y), bottom_font
)
return False
@staticmethod
def _coerce_rgb(value, fallback):
"""Turn a configured [R, G, B] list into a clamped (r, g, b) tuple."""
# Checked before unpacking: a 3-character string ("123") would otherwise
# iterate into three digits and yield a colour rather than the fallback.
if not isinstance(value, (list, tuple)) or len(value) != 3:
return fallback
try:
r, g, b = (max(0, min(255, int(channel))) for channel in value)
except (TypeError, ValueError):
return fallback
return (r, g, b)
@staticmethod
def _side_is_favorite(game: Dict, side: str, favorites: set) -> bool:
"""Is the home/away side of this game a favorite team?
Both the abbreviation and the ESPN id are checked, because a couple of
leagues (NRL) match favorites by id where abbreviations collide.
"""
for key in (f"{side}_abbr", f"{side}_id"):
value = game.get(key)
if value is not None and str(value).strip().upper() in favorites:
return True
return False
def _favorite_result(self, game: Dict) -> Optional[str]:
"""Say how the favorite team did in a finished game.
Returns 'win', 'loss' or 'tie', or None when there is no single team
to root for: no favorites configured, neither side is a favorite, or
*both* are -- a favorite-vs-favorite game has no losing side worth
flagging in red. Also None when the scores are not usable numbers.
"""
favorites = getattr(self, "favorite_teams", None) or []
favorites = {str(team).strip().upper() for team in favorites if str(team).strip()}
if not favorites:
return None
home_fav = self._side_is_favorite(game, "home", favorites)
away_fav = self._side_is_favorite(game, "away", favorites)
if home_fav == away_fav:
return None
try:
# int(float(...)) to match GameRenderer._side_score exactly -- the
# two paths must agree on what counts as a usable score.
home_score = int(float(str(game.get("home_score", "")).strip()))
away_score = int(float(str(game.get("away_score", "")).strip()))
except (TypeError, ValueError):
return None
if home_score == away_score:
return "tie"
favorite_score, other_score = (
(home_score, away_score) if home_fav else (away_score, home_score)
)
return "win" if favorite_score > other_score else "loss"
def _recent_score_color(self, game: Dict, default):
"""Fill color for a finished game's score, per favorite_result_colors."""
try:
settings = (self.config.get("customization") or {}).get(
"favorite_result_colors"
) or {}
if not settings.get("enabled", False):
return default
result = self._favorite_result(game)
if result is None:
return default
return self._coerce_rgb(
settings.get(f"{result}_color"),
self.FAVORITE_RESULT_COLOR_DEFAULTS[result],
)
except Exception:
self.logger.debug(
"Could not resolve favorite result color", exc_info=True
)
return default
def _score_font_size(self) -> int:
"""Pixel size the score is currently drawn at."""
return getattr(self.fonts.get("score"), "size", 8) or 8
def _time_font_size(self) -> int:
"""Pixel size the clock/date face is currently drawn at."""
return getattr(self.fonts.get("time"), "size", 8) or 8
def _user_chose_size(self, element_key: str) -> bool:
"""True when customization.<element>.font_size is a real choice.
The web UI's save flow writes the whole schema default block into
config.json on every save, whether or not the user touched that
section, so a size merely being PRESENT carries no intent. Only one
that differs from the schema default does.
"""
element = (self.config.get('customization', {}) or {}).get(element_key) or {}
configured = element.get('font_size')
if configured is None:
return False
try:
return int(configured) != self._schema_font_size(element_key)
except (TypeError, ValueError):
return False
def _grid_scaled_size(self, font):
"""(path, grid, size) for *font* regrown to this panel's height.
None when the panel is at or below the design height (nothing to do),
or when the face has no known pixel grid -- a user-supplied font is
never second-guessed, because we do not know what it renders crisply
at.
"""
path = getattr(font, 'path', None)
base = getattr(font, 'size', None)
if not base or not isinstance(path, str):
return None
face = os.path.basename(path)
grid = self._FONT_PIXEL_GRID.get(self._FONT_NAME_ALIASES.get(face, face))
if not grid:
return None
scale = float(self.display_height) / (self._FONT_DESIGN_HEIGHT or 32)
if scale <= 1.0:
return None
return path, grid, max(int(base), int(self._crisp_size(face, base * scale)))
def _scale_headline_fonts(self, fonts):
"""Grow the score with the panel, and hold the clock/date below it.
The score is the one number the card exists to show, and it was the
only element not sized from the panel. Worse, it was not even bigger
than its neighbours: PressStart2P renders crisply on an 8px grid, so
the 10px default snapped to 8 -- the same 8 the period/clock above it
and the game date below it are drawn at. Three lines of identical
type, none of them the headline, which is what makes the score read as
lower priority than the time and the date rather than the point of the
card.
So the score is sized from display_height and snapped to its face's
pixel grid (off the grid FreeType anti-aliases the strokes, and on an
LED matrix a part-lit pixel is a dim lamp rather than a soft edge),
then stepped back down that grid until it fits its share of the width.
The clock/date face is regrown the same way but held at least one grid
step below the score, so the ranking between them is visible rather
than implied.
A 32-tall panel scales by exactly 1.0 and is left byte-identical; a
size the user set explicitly is never overridden.
"""
self._score_grew = False
if not self._DRAWS_SCORE:
# No score on this screen, so none of the sizing below is for it.
return fonts
try:
scaled = None if self._user_chose_size('score_text') else \
self._grid_scaled_size(fonts.get('score'))
if scaled is not None:
path, grid, size = scaled
base = getattr(fonts['score'], 'size', size) or size
size = min(size, base * self._SCORE_MAX_GROWTH)
probe = ImageDraw.Draw(Image.new('RGB', (4, 4)))
budget = self.display_width * self._SCORE_GROWTH_BUDGET
# Measured from a fixed five-character score rather than the
# live one, so the card does not resize when a side passes 9.
while size > grid:
if probe.textlength(
self._SCORE_PROBE_TEXT,
font=load_truetype(path, size)) <= budget:
break
size -= grid
if size != getattr(fonts['score'], 'size', size):
fonts['score'] = load_truetype(path, size)
self._score_grew = True
if not self._score_grew and not self._user_chose_size('score_text') \
and self.display_height > self._FONT_DESIGN_HEIGHT:
# PressStart2P could not grow inside the budget -- its next crisp
# size is simply too wide for this panel. A narrower face still
# can: 4x6-font at 14px is nearly as tall as PressStart2P at 16
# and about half as wide. This matters beyond the score itself,
# because a card whose score never grows never reserves the
# centre either, so its logos stay at the uncapped 1.5x and are
# drawn straight over the score -- which is what a three-digit
# basketball score does on a 128x64 board.
probe = ImageDraw.Draw(Image.new('RGB', (4, 4)))
budget = self.display_width * self._SCORE_GROWTH_BUDGET
current = getattr(fonts.get('score'), 'size', 0) or 0
for _name, _size in self._NARROW_SCORE_RUNGS:
if _size <= current:
continue
_path = _resolve_font_path(f"assets/fonts/{_name}")
_candidate = load_truetype(_path, _size)
if probe.textlength(self._SCORE_PROBE_TEXT,
font=_candidate) <= budget:
fonts['score'] = _candidate
self._score_grew = True
break
scaled = None if self._user_chose_size('period_text') else \
self._grid_scaled_size(fonts.get('time'))
if scaled is not None:
path, grid, size = scaled
ceiling = getattr(fonts.get('score'), 'size', 0) or 0
if ceiling and size >= ceiling:
size = max(grid, ceiling - grid)
if size != getattr(fonts['time'], 'size', size):
fonts['time'] = load_truetype(path, size)
except Exception:
self.logger.debug("Headline font scaling skipped", exc_info=True)
return fonts
def _get_layout_offset(self, element: str, axis: str,
default: int = 0) -> int:
"""X/Y nudge for one element, from ``customization.layout``.
Promoted here so every scoreboard reads offsets the same way the
scroll card does. Each plugin still carries its own copy in its
bundled sports.py, which wins by MRO until that copy is deleted --
deleting it is what buys the alias handling (a plugin asking for
``score_text`` finds the ``score`` its users configured) and the
per-mode overrides, since this resolves through SKIN_MODE.
"""
from src.element_style import layout_offset
return layout_offset(self.config, element, axis, default,
getattr(self, "SKIN_MODE", None))
def _element_color(self, element: str, default: Tuple[int, int, int] = (255, 255, 255)):
"""Per-element text colour from customization.<element>.text_color.
Mode-aware through SKIN_MODE, so Live and Recent instances of the
same scoreboard resolve their own colours without any call site
passing a mode.
"""
from src.element_style import element_color as _shared
return _shared(self.config, element, default,
getattr(self, "SKIN_MODE", None))
def _element_visible(self, element: str, default: bool = True) -> bool:
"""Whether ``customization.<element>.visible`` allows this draw.
Mode-aware like the colour read, so a user can hide the records on the
recent card and keep them on the upcoming one.
"""
from src.element_style import element_visible
return element_visible(self.config, element, default,
getattr(self, "SKIN_MODE", None))
def _element_align(self, element: str, default: Optional[str] = None):
"""``customization.<element>.align``: 'left', 'center' or 'right'."""
from src.element_style import element_align
return element_align(self.config, element, default,
getattr(self, "SKIN_MODE", None))
def _element_scale(self, element: str, default: float = 1.0) -> float:
"""``customization.layout.<element>.scale`` -- logos, mostly."""
from src.element_style import element_scale
return element_scale(self.config, element, default,
getattr(self, "SKIN_MODE", None))
def _aligned_x(self, element: str, text_width: float, container_width: int,
centered_x: float) -> float:
"""Where a run of text starts, honouring ``align``.
Unset means "leave it exactly where it was", so this returns the
caller's own x rather than re-deriving a centre: these draws have
accumulated per-sport nudges and a centre computed here would not be
the same pixel.
"""
align = self._element_align(element)
if not align:
return centered_x
if align == 'left':
return 0
if align == 'right':
return max(0, container_width - text_width)
return centered_x
def _unshare_element_fonts(self, fonts):
"""Give each colourable element its own face object.
The colour a draw gets is resolved from the face it was handed, and
several of these loaders legitimately hand one object to more than one
element -- a size resolver that lands two elements on the same face, a
fallback that fills every key from one default, football's narrowing
step that deliberately shrinks the clock along with the score. Sharing
the object makes the element ambiguous and the colour unresolvable.
Re-instantiating from the same path and size gives a distinct object
with identical metrics, so nothing about the rendering changes; only
the ability to tell two elements apart does. Faces that cannot be
rebuilt (a BDF loaded through freetype.Face, anything without a usable
path) are left shared, and their draws stay white as before.
"""
try:
from src.common.font_layout import load_truetype as _load
except ImportError: # pragma: no cover
return fonts
seen = {}
for key in self._ELEMENT_FOR_FONT:
font = fonts.get(key)
if font is None:
continue
if id(font) not in seen:
seen[id(font)] = key
continue
path, size = getattr(font, "path", None), getattr(font, "size", None)
if not path or not size:
continue
try:
fonts[key] = _load(path, size)
except (OSError, ValueError, TypeError):
self.logger.debug(
"Could not un-share the %s face; it keeps the default colour", key)
return fonts
def _font_color(self, font, default: Tuple[int, int, int] = (255, 255, 255)):
"""Colour for whichever element owns this face.
The fallback for draw sites that were only ever handed a font. Prefer
``element=`` on :meth:`_draw_text_with_outline`, which needs none of
this. Shared with the scroll card's copy so the narrowing rule that
rescues bitmap-font colours lives in one place; the element vocabulary
stays this class's own, because its map says ``team_text`` where
sports_card's says ``team_name``.
"""
from src.common.sports_card import resolve_font_color
return resolve_font_color(
getattr(self, "config", None), getattr(self, "fonts", None), font,
default, self._ELEMENT_FOR_FONT, getattr(self, "SKIN_MODE", None))
def _draw_text_with_outline(
self, draw, text, position, font, fill=None, outline_color=(0, 0, 0),
element=None
):
"""Draw text with a black outline for better readability.
Pass ``element`` (``"score_text"``, ``"status_text"``, ...) wherever the
caller knows what it is drawing: the colour is then read by name, which
is exact. Without it the colour has to be inferred from the identity of
the font object, which cannot tell two elements apart when they share a
face -- the case every bitmap font is in, because a ``freetype.Face``
cannot be re-instantiated.
"""
# Disable anti-aliasing: pixel/bitmap fonts (e.g. PressStart2P) get
# anti-aliased into dim partial-lit pixels on a 1:1 LED matrix, muddying
# glyphs. 1-bit mode keeps strokes crisp.
# Defaults to the configured colour for whichever element owns
# this face rather than to white, so customization.<element>.text_color
# reaches every draw. The schema has offered those pickers all along
# and they only ever changed the font. An explicit fill still wins:
# the odds colours and the favourite-result score tint mean something
# the palette does not.
if element is not None:
# Named, so both questions can be answered exactly: whether this
# element is meant to be on screen at all, and what colour it is.
if not self._element_visible(element):
return
if fill is None:
fill = self._element_color(element)
elif fill is None:
fill = self._font_color(font)
draw.fontmode = "1"
x, y = position
for dx, dy in [
(-1, -1),
(-1, 0),
(-1, 1),
(0, -1),
(0, 1),
(1, -1),
(1, 0),
(1, 1),
]:
draw.text((x + dx, y + dy), text, font=font, fill=outline_color)
draw.text((x, y), text, font=font, fill=fill)
def _should_log(self, warning_type: str, cooldown: int = 60) -> bool:
"""Check if we should log a warning based on cooldown period."""
current_time = time.time()
if current_time - self._last_warning_time > cooldown:
self._last_warning_time = current_time
return True
return False
def _get_weeks_data(self) -> Optional[Dict]:
"""
Get partial data for immediate display while background fetch is in progress.
This fetches current/recent games only for quick response.
"""
try:
# Fetch current week and next few days for immediate display
now = datetime.now(pytz.utc)
immediate_events = []
start_date = now - timedelta(days=self.schedule_lookback_days)
end_date = now + timedelta(days=self.schedule_lookahead_days)
date_str = f"{start_date.strftime('%Y%m%d')}-{end_date.strftime('%Y%m%d')}"
url = f"https://site.api.espn.com/apis/site/v2/sports/{self.sport}/{self.league}/scoreboard"
response = self.session.get(
url,
params={"dates": date_str, "limit": 1000},
headers=self.headers,
timeout=10,
)
response.raise_for_status()
data = response.json()
immediate_events = data.get("events", [])
if immediate_events:
self.logger.info(f"Fetched {len(immediate_events)} events {date_str}")
return {"events": immediate_events}
except requests.exceptions.RequestException as e:
self.logger.warning(
f"Error fetching this weeks games for {self.sport} - {self.league} - {date_str}: {e}"
)
return None
def _custom_scorebug_layout(self, game: dict, draw_overlay: ImageDraw.ImageDraw):
pass
def cleanup(self):
"""Clean up resources when plugin is unloaded."""
# Close HTTP session
if hasattr(self, 'session') and self.session:
try:
self.session.close()
except Exception as e:
self.logger.warning(f"Error closing session: {e}")
# Clear caches
if hasattr(self, '_logo_cache'):
self._logo_cache.clear()
self.logger.info(f"{self.__class__.__name__} cleanup completed")
def _game_divisions(self, game: Dict) -> Optional[set]:
"""Divisions of BOTH sides, or None when they cannot be told.
Both sides are collected, but the caller only needs ONE of them to sit
in a checked division. Requiring every participant read as "FBS games
only" and removed a ranked side hosting an FCS school -- which is still
a game involving a team the viewer checked the box for, and on a real
Week 2 slate it silently dropped five of the twenty ranked matchups.
What the checkbox is for is keeping FCS-versus-FCS out of a board
configured for FBS, and that still holds: a game with no checked
division on either side is dropped.
"""
divisions = self._load_division_team_ids()
if not any(divisions.values()):
return None
try:
ids = [int(game.get("home_id")), int(game.get("away_id"))]
except (TypeError, ValueError):
return None
present = set()
for team_id in ids:
for name in ("fbs", "fcs"):
if team_id in divisions.get(name, set()):
present.add(name)
break
else:
present.add("other")
return present
def _league_has_rankings(self) -> bool:
"""Only college leagues publish a poll; everyone else 404s.
This gate matters more than it looks. _fetch_team_rankings only
short-circuits when the cache is non-empty, so a failed fetch leaves it
empty and the next update tries again -- at a 30s interval that is
~2,900 pointless requests a day, per league, all of them 404s.
"""
league = (self.league or "").lower()
return "college" in league or "ncaa" in league
@staticmethod
def _normalise_divisions(raw) -> List[str]:
"""Division names from config, in the shape the filter expects.
A hand-edited config can hold "fbs" where the schema says ["fbs"], and
list("fbs") is ['f', 'b', 's'] -- three names that match no division, so
every non-favourite game is rejected by a setting the user believes says
the opposite. An empty list is left empty: that means "no division
filter" and is a legitimate choice, not a mistake to correct.
"""
if isinstance(raw, str):
raw = [raw]
try:
items = list(raw or [])
except TypeError:
return []
return [str(d).strip().lower() for d in items if str(d).strip()]
def _round_robin_favorites(self, games: List[Dict], limit: int) -> List[Dict]:
"""Each favourite team's next game before any team's second one.
Taking the soonest N favourite games spends the slots on whoever plays
most often. Walked across a real season with two favourites and a limit
of 2, nine days of it showed Auburn twice and Georgia not at all --
Auburn played either side of a Georgia bye, so both slots went to
Auburn. The other-games pool already refuses to do this; favourites
were still doing it.
Depth is kept where there is room: one favourite with three slots still
gets its next three games, because the round-robin only comes back for
a team's second game once every team has had a first.
A game between two favourites is picked once and counts for both.
"""
if limit <= 0 or not games:
return []
wanted = [t for t in (self.favorite_teams or []) if t]
if len(wanted) < 2:
return games[:limit] # nothing to share the slots between
# Which side of a game belongs to which favourite is a per-lineage
# question: NRL matches on ESPN team IDs because its abbreviations are
# not unique ("NEW" is both Newcastle and New Zealand), while the rest
# match on abbreviation. Ask for the lineage's own matcher rather than
# assuming, or this silently groups nothing and every slot goes empty.
matcher = getattr(self, "_team_in", None)
if not callable(matcher):
matcher = None # an is-None test narrows for static analysis
if matcher is None:
def belongs(game, team):
return team in (game.get("home_abbr"), game.get("away_abbr"))
else:
def belongs(game, team):
return bool(matcher(game.get("home_id"), [team])
or matcher(game.get("away_id"), [team]))
queues = {team: [] for team in wanted}
for game in games: # already in kickoff order
for team in wanted:
if belongs(game, team):
queues[team].append(game)
picked, taken = [], set()
while len(picked) < limit:
progressed = False
for team in wanted:
queue = queues[team]
while queue and queue[0].get("id") in taken:
queue.pop(0)
if queue and len(picked) < limit:
game = queue.pop(0)
taken.add(game.get("id"))
picked.append(game)
progressed = True
if not progressed:
break # every queue is empty
return picked
def _normalise_quality(self, raw) -> str:
"""other_games_min_quality, as one of the values the code implements.
An unusable value used to fall through every branch of
_passes_other_filters and silently mean "any" -- a quality bar the
board believes it has and does not.
"""
value = str(raw or "").strip().lower()
if value in self._QUALITY_CHOICES:
return value
if value == "broadcast":
# Retired in football-scoreboard 3.0.0 and now here. Measured
# against a real Week 1 and Week 2 college slate it passed 174 of
# 175 games: ESPN publishes a broadcaster for nearly everything
# now, ESPN+ included, so the tier read as a quality bar and
# behaved as "any". Boards holding it get the bar they thought
# they were getting.
self.logger.warning(
"%s: other_games_min_quality 'broadcast' has been retired -- "
"it let through nearly every game -- using 'ranked'. Change "
"the setting to clear this.", getattr(self, "sport_key", "?"),
)
return "ranked"
self.logger.warning(
"%s: ignoring unusable other_games_min_quality=%r, using 'ranked'",
getattr(self, "sport_key", "?"), raw,
)
return "ranked"
def _check_ranking_coverage(self, games: List[Dict]) -> None:
"""Say so when a loaded poll matches nothing on the schedule.
The table is keyed by the abbreviation the RANKINGS endpoint returns and
matched against the one the SCOREBOARD endpoint returns. Nothing
guarantees the two agree, and if they ever stop agreeing the filter
quietly removes every non-favourite game -- no exception, no log line,
just a shorter board. That is the same shape as the bug where rankings
were never loading at all, which survived until someone went looking.
Throttled to once an hour: selection runs on every update.
"""
if self.other_games_min_quality != "ranked":
return
rankings = getattr(self, "_team_rankings_cache", None) or {}
if not rankings or not games:
return
if any(self._is_ranked_game(g) for g in games):
return
now = time.monotonic()
# Zero means never logged, not "logged at the epoch". monotonic() counts
# from an arbitrary origin -- on a freshly booted board it is a few
# hundred seconds -- so comparing against 0 swallowed the first warning
# for the first hour of uptime, which is exactly when a misconfigured
# board is being watched. CI caught this; a machine with days of uptime
# cannot.
if (self._ranking_coverage_logged_at
and now - self._ranking_coverage_logged_at < self._RANKING_COVERAGE_SECONDS):
return
self._ranking_coverage_logged_at = now
self.logger.warning(
"%s: %d ranked teams loaded, but none of the %d other games match "
"one -- the quality filter is removing every non-favourite game. "
"Ranked abbreviations look like: %s",
self.league, len(rankings), len(games),
", ".join(sorted(rankings)[:8]),
)
def _favorites_first(
self,
processed_games: List[Dict],
favorite_limit: int,
other_limit: int,
newest_first: bool = False,
) -> List[Dict]:
"""Favourite games first, then a bounded number of everything else.
This is the middle setting the plugin was missing. `show_favorite_teams_only`
used to be the whole story: on, and you saw nothing but your teams; off,
and your teams were ignored entirely -- the selection just took the next
N games league-wide, so a UGA fan with 946 upcoming college games in the
window saw UGA about as often as chance allowed.
Both counts are TOTALS here, not per-team. In favourites-only mode
`upcoming_games_to_show` is a per-team budget, which is reasonable when
the list is your own teams; applied to a dynamic group it is not. With
AP_TOP_10 resolving to a dozen teams, three games each is 28 distinct
cards before a single non-favourite is added. A total keeps the rotation
the length the user asked for.
"""
if newest_first:
def key(g):
return g.get("start_time_utc") or datetime.min.replace(tzinfo=timezone.utc)
ordered = sorted(processed_games, key=key, reverse=True)
else:
def key(g):
return g.get("start_time_utc") or datetime.max.replace(tzinfo=timezone.utc)
ordered = sorted(processed_games, key=key)
favorites, others, unfiltered = [], [], []
for game in ordered:
if self._is_favorite_game(game):
favorites.append(game) # never filtered: your team is your team
continue
unfiltered.append(game)
if self._passes_other_filters(game):
others.append(game)
self._check_ranking_coverage(unfiltered)
self._selection_pools = {
"favorites": favorites,
"others": self._by_importance(others, newest_first),
"unfiltered": self._by_importance(unfiltered, newest_first),
"favorite_limit": favorite_limit,
"other_limit": other_limit,
"newest_first": newest_first,
}
return self._compose_selection()
def _compose_selection(self) -> List[Dict]:
"""Favourites plus the current slice of others, in schedule order.
Split out of _favorites_first so the slice can be re-cut between
fetches. The pools are settled -- which games exist, and which of them
are worth a slot -- while WHICH of the others is on screen is a display
decision, and gating it on the fetch made the rotation interval a lie:
update() returns early until upcoming_update_interval has passed, so a
four-minute rotation actually stepped fifteen windows once an hour.
Same lesson as _advance_live_game_if_due further down this file.
"""
pools = self._selection_pools
favorites, others = pools["favorites"], pools["others"]
favorite_limit, other_limit = pools["favorite_limit"], pools["other_limit"]
newest_first = pools["newest_first"]
if newest_first:
def key(g):
return g.get("start_time_utc") or datetime.min.replace(tzinfo=timezone.utc)
else:
def key(g):
return g.get("start_time_utc") or datetime.max.replace(tzinfo=timezone.utc)
selected = self._round_robin_favorites(favorites, max(0, favorite_limit))
selected.extend(self._other_games_window(others, max(0, other_limit)))
if not selected and other_limit > 0:
# Nothing survived at all: your teams are not playing inside the
# schedule window AND the filters removed every other game. Each
# check fails open on missing data, but a filter working exactly as
# asked can still match nothing on a given day, and with no
# favourite game left there is nothing to carry the mode -- an empty
# list is a blank panel, not a short one. Same whole-list fallback
# `_filtered_or_all` makes for a board with no favourites at all.
# `other_limit` of 0 is an explicit "favourites only", so that one
# is left to go quiet as asked.
selected = self._other_games_window(pools["unfiltered"], max(0, other_limit))
# Re-sort so the card order still reads as a schedule. Selection decides
# WHICH games; it should not reorder them into favourites-then-others,
# which would show next week's UGA game before tonight's.
selected.sort(key=key, reverse=newest_first)
return selected
class SportsLiveSharedMixin:
"""The ``SportsLive`` bodies identical in all eight scoreboards."""
def _detect_stale_games(self, games: List[Dict]) -> None:
"""Remove games that appear stale or haven't updated."""
current_time = time.time()
for game in games[:]: # Copy list to iterate safely
game_id = game.get("id")
if not game_id:
continue
# Check if game data is stale
timestamps = self.game_update_timestamps.get(game_id, {})
last_seen = timestamps.get("last_seen", 0)
if last_seen > 0 and current_time - last_seen > self.stale_game_timeout:
self.logger.warning(
f"Removing stale game {game.get('away_abbr')}@{game.get('home_abbr')} "
f"(last seen {int(current_time - last_seen)}s ago)"
)
games.remove(game)
if game_id in self.game_update_timestamps:
del self.game_update_timestamps[game_id]
continue
# Also check if game appears to be over
if self._is_game_really_over(game):
self.logger.debug(
f"Removing game that appears over: {game.get('away_abbr')}@{game.get('home_abbr')} "
f"(clock={game.get('clock')}, period={game.get('period')}, period_text={game.get('period_text')})"
)
games.remove(game)
if game_id in self.game_update_timestamps:
del self.game_update_timestamps[game_id]
def _idle_live_interval(self) -> int:
"""How long to wait before looking for live games again, when there are none.
Escalates the longer nothing turns up, and any live game resets it, so
an in-season gap between games costs at most one escalated wait while
an out-of-season league stops polling on a live cadence entirely.
Capped rather than unbounded: the cost of backing off is how late the
first game after a quiet spell is noticed, and past the cap the saving
stops being worth that.
"""
streak = getattr(self, "_empty_live_streak", 0)
base = self.no_data_interval
ceiling = getattr(self, "live_idle_max_interval",
_DEFAULT_LIVE_IDLE_MAX_SECONDS)
# The ceiling bounds the un-escalated interval too. The two settings are
# independent integers with no cross-validation, so base > ceiling is a
# reachable config -- and returning base unclamped there made the wait
# *shrink* as the streak grew (3600s at streak 0, 900s at streak 24),
# the opposite of what the setting named "maximum" promises.
if streak >= _IDLE_LONG_STREAK:
return min(int(base * _IDLE_LONG_FACTOR), ceiling)
if streak >= _IDLE_SHORT_STREAK:
return min(int(base * _IDLE_SHORT_FACTOR), ceiling)
return min(base, ceiling)
def _note_live_fetch(self, found_live: bool) -> None:
"""Record whether a look for live games found any."""
if found_live:
if getattr(self, "_empty_live_streak", 0):
self.logger.info(
"Live games found after %d empty check(s); back to the "
"live update interval", self._empty_live_streak)
self._empty_live_streak = 0
else:
self._empty_live_streak = getattr(self, "_empty_live_streak", 0) + 1
class SportsRecentSharedMixin:
"""The ``SportsRecent`` bodies identical in all eight scoreboards."""
def __init__(
self,
config: Dict[str, Any],
display_manager,
cache_manager,
logger: logging.Logger,
sport_key: str,
):
super().__init__(config, display_manager, cache_manager, logger, sport_key)
self.games_list = [] # Filtered list for display (favorite teams)
self.current_game_index = 0
self.last_update = 0
self.update_interval = self.mode_config.get(
"recent_update_interval", 3600
) # Check for recent games every hour
self.last_game_switch = 0
self.game_display_duration = self.mode_config.get("recent_game_duration", 15)
self._zero_clock_timestamps: Dict[str, float] = {} # Track games at 0:00
def _get_zero_clock_duration(self, game_id: str) -> float:
"""Track how long a game has been at 0:00 clock."""
current_time = time.time()
if game_id not in self._zero_clock_timestamps:
self._zero_clock_timestamps[game_id] = current_time
return 0.0
return current_time - self._zero_clock_timestamps[game_id]
def _clear_zero_clock_tracking(self, game_id: str) -> None:
"""Clear tracking when game clock moves away from 0:00 or game ends."""
if game_id in self._zero_clock_timestamps:
del self._zero_clock_timestamps[game_id]