Compare commits

...
21 Commits
Author SHA1 Message Date
ChuckandClaude Opus 5 9e3f184d81 docs(changelog): complete 3.4.0 with weekly auto-updates and scroll timing fix (#584)
* docs(changelog): complete 3.4.0 with weekly auto-updates and scroll timing fix

#581 (weekly automatic updates) and #582 (scroll frame-stats idle gap)
merged after the 3.4.0 section was written in #580. v3.4.0 will be tagged
on main including both, so they belong in 3.4.0.

Also record src.common.font_layout (#539, #565), a src.* module plugins
may import that shipped in 3.4.0 but was never listed, and mark
display_geometry and auto_update_setup as core-internal.

Correct the 3.3.0 historical note: remote tags v3.3.0 (bc2dbf38) and
v3.3.1 (32d637a4) both report "3.3.0" and both ship sports_shared.py.
The "3.2.0" claim came from a stale local tag.

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

* docs(changelog): cover the whole 3.4.0 release since v3.3.1

The 3.4.0 section listed plugin-facing API and per-element customization
but not the rest of what merged since v3.3.1. Group it under subheadings:
Install and updates, Scrolling, Plugins, Web interface, Tools and
security, Fixes, and put the existing customization block under its own
heading.

Omitted on purpose: #569 (fixes a regression and an editor race in the
unreleased per-element framework), #570 (no runtime change), and
test-only, refactor and dev-tooling PRs.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 15:24:38 -04:00
ChuckandClaude Opus 5 869e36fb2f feat(web): weekly automatic updates with health check and rollback (#581)
* feat(web): weekly automatic updates with health check and rollback

A General-tab toggle (off by default) checks for and installs LEDMatrix and
plugin updates once a week, overnight in the configured timezone.

- Pre-update checks skip (and report) instead of forcing: local edits or
  commits, merge/live rebase, no upstream, low disk, missing health check, or
  a version that was already rolled back. An abandoned rebase (HEAD back on a
  branch) is cleared, since it would otherwise block every pull.
- The pull reuses the Update Code path (now perform_core_update(), which
  reports dependency install failures as data).
- ledmatrix-update-verify.service, started via a .path unit from a request
  file, restarts the services from its own cgroup, requires them to come up
  and stay up, and otherwise resets to the previous commit and reinstalls the
  previous requirements. It runs a copy of the checker taken before the pull.
- No SSH needed: switching the toggle on restarts the display service, which
  (as root) installs the two units from the repo templates for the web user.
  first_time_install.sh installs them too and takes --enable-auto-update /
  LEDMATRIX_AUTO_UPDATE (passed through by one-shot-install.sh).
- Plugins update after the code passes its check; failures, blocks and
  rollbacks raise an Overview banner and show under the toggle.

Tested end to end on a Pi: web-UI setup, a good update, a broken web service
and a broken display (both rolled back), a blocked local edit, and an
abandoned rebase found on the device.

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

* chore(auto-update): address static-analysis findings

- Replace the subprocess.CompletedProcess the verifier fabricated for a
  command that could not start with a plain namedtuple; nothing is executed
  there, but the scanner flags any CompletedProcess built from variables.
- Mark the subprocess imports with the repo's standard B404 annotation (all
  calls are list-form argv, no shell).
- Mark the rollback-failed message as not SQL (B608 matched its wording).

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

* fix(auto-update): CI failures on Linux

- Keep the setup result when chown fails. CI runs as a non-root user, where
  chown to the web user raises; that discarded the result file, so the
  General tab would never learn whether setup worked. Regression test added.
- Register the two new /api/v3/system/auto-update routes in the URL map
  snapshot.
- Use utility classes app.css defines (space-y-1, hover:text-red-600).

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

* fix(auto-update): address review feedback

- Health check: a failed restart command no longer lets the check run
  against the still-running old process; it counts as a failure (and after a
  rollback, as a failed rollback). An unreadable restart count is never
  treated as stable, since a crash loop looks healthy between attempts.
- Installer writes the auto_update setting to a temp file and swaps it in,
  keeping mode and owner, so a running config watcher never reads a
  truncated config.json.
- Verify unit quotes its command-line paths (install folders with spaces);
  setup refuses folder names systemd would reinterpret (%, quotes,
  backslashes, control characters) and says so on the General tab.
- The auto-update status route no longer returns exception text.

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

* fix(auto-update): keep error detail in the status route's 500

test_web_error_detail requires every 5xx handler to log the traceback and
return describe_exception(e), which redacts credentials, so failures are
diagnosable from the web UI. Dropping it for CodeQL broke that policy.

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

* fix(auto-update): dismiss route rejects non-object JSON with 400

A JSON array or scalar body made `.get('alert_id')` raise, returning 500.

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

* fix(auto-update): let the app-wide handler answer status-route errors

CodeQL (py/stack-trace-exposure, #709) flagged the route's own except,
which returned describe_exception(e). web_interface/app.py's error handler
already logs the traceback and returns the same redacted detail for any
unhandled exception, so the local copy is removed: same response, no new
exception-to-response flow, and test_web_error_detail's policy still holds.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 10:58:57 -04:00
ChuckandClaude Opus 5 d01da3bd9f fix(scroll): stop timing the idle gap between scrolls as a frame (#582)
ScrollHelper.last_frame_time was set once in __init__ and thereafter only
at the end of log_frame_rate(). Nothing re-armed it when a scroll began, so
the first frame of every scroll was timed against the last frame of the
*previous* one and the whole idle period between them was recorded as a
single frame.

Measured over 3 hours on a 256x64 Pi 4, that produced 31 windows reading

    Scroll frame stats - 0.0 fps over 1 frames | median 136776.02ms
    p95 136776.02ms max 136776.02ms min 136776.02ms | stalls 0 (0.0%)

and -- worse, because it is not obviously wrong -- put the same gap in the
max field of otherwise healthy windows, where the worst values were 537s
and 604s. It also counted as one stall per scroll start: at ~500 frames to
a window that is ~0.2%, against measured stall rates of 0.07-0.16%. The
stall rate is the number used to judge whether a scroll change worked, and
it was the same order of magnitude as its own artefact.

The first frame of a scroll has no predecessor, so it has no frame time.
last_frame_time is now None until one is rendered, and reset_scroll() puts
it back -- the same treatment last_update_time already gets three lines
above, for the same reason. reset_scroll() alone is not enough, because the
scrollers actually emitting these lines never call it, so a sample at or
past the 5s log interval is dropped as well: nothing that renders a scroll
takes that long over one frame. Seeding also restarts the window timer, or
the boundary is already overdue when the second frame arrives and every
scroll opens by reporting a window of exactly one frame. A window whose
samples were all dropped now logs nothing rather than reporting the gap.

docs/SCROLL_PERFORMANCE.md documented the diagnostic in terms of a
"Frame time: N ms" line that 6031e705 replaced with the aggregate, so its
grep matched nothing on any rig. The section now describes the line that is
actually emitted, reads duplicate frames off skips and a below-median
result rather than a 2ms mode, and adds a command that ranks every scroller
by p95 -- verified against 3 hours of journal, where it reproduces
src.base_odds_manager at p95 44.08ms against 10.19ms for the two scrollers
already on src/common/scroll_config.py.

requirements.txt still offered scipy for the sub-pixel interpolation path
deleted in #570. Installing it has no effect; the entry says so.


Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 18:44:52 -04:00
ChuckandClaude Opus 5 814c21de1c chore: mark skins unsupported, fix stale docs and preview size, prepare 3.4.0 (#580)
* chore: mark skins unsupported, fix stale docs and preview size, prepare 3.4.0

Skins: no current scoreboard plugin builds on src.base_classes, so the only
skin hook (SportsCore._render_game) never runs. The plugin schema endpoint no
longer injects the Visual Skin dropdown, the store hides and refuses
"type": "skin" registry entries, and GET /api/v3/skins reports
supported: false with a message. Stored skin config still loads and saves.
src/skin_system/ and its tests are unchanged apart from the support flag.

Docs: check_plugin.py/render_plugin.py examples use --plugin; document
BasePlugin.get_update_interval() and its interaction with the manifest
update_interval; CLAUDE.md drops the stale template line number and
recommends display_manager.width/height.

Preview size: new src/display_geometry.py holds the size computation and
defaults DisplayManager uses (double-sided applied, chain_length default 2).
The web preview, /display/current, Starlark magnify default, sync handshake
and two dev scripts use it.

Release: __version__ 3.4.0, CHANGELOG 3.4.0 section plus a 3.3.0 tag note.

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

* fix: address CodeRabbit review on #580

- Preview fallbacks (SSE stream and /display/current) use logical_size({})
  (128x32, the shared default) instead of a hard-coded 128x64.
- display_geometry treats a non-mapping display/hardware block as missing,
  so a malformed config.json falls back to defaults instead of raising
  AttributeError (which turned the Starlark render into an HTTP 500).
- Docs: the static update interval falls back manifest -> plugin config
  -> 60s, in both the API reference and the architecture spec.

Not taken: validating double_sided copies against chain_length/parallel.
An orientation Rotate: or U-mapper pixel mapper decides which axis panels
lie on, so the counts would reject working setups (the existing
vertical-split test is one).

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

* fix(display_geometry): a non-finite hardware size raises ValueError, not OverflowError

CodeRabbit flagged the Starlark magnify default in
_standalone_render_starlark_app for truthy non-mapping display values. That
case was already handled by a9e1bd0b (_display/_hardware treat a non-mapping
block as missing, covered by test_non_mapping_display_config_uses_the_defaults),
and the magnify it produces from the 128x32 defaults is the same as from 64x32.

Checking the same path found one input that still escaped: Python's JSON
parser accepts Infinity, and int(inf) raises OverflowError, which neither the
Starlark path (TypeError, ValueError) nor the preview stream in app.py caught,
so a hand-edited "rows": Infinity returned HTTP 500. physical_size now raises
ValueError for it, matching its documented contract, so every caller's
existing fallback applies. DisplayManager already caught Exception.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 18:36:58 -04:00
ChuckandClaude Opus 5 914bf2002f fix(install): grant and harden safe_pip_install.sh in first_time_install.sh (#579)
first_time_install.sh granted the web user safe_plugin_rm.sh but not
safe_pip_install.sh, unlike scripts/install/configure_web_sudo.sh. On devices
set up only by the first-time installer, install_requirements_file could not
use the root wrapper and fell back to a user-level install that root-run
ledmatrix.service may not see.

Also harden both sudo-granted helpers to root:root 755. first_time_install.sh
never did this, and Step 11's project-wide chown to the user would undo it if
placed in Step 10, so it runs at the end of Step 11.1.

Add a test that parses the ledmatrix_web sudoers rules from both installers
and asserts they grant the same commands, and that every granted helper is
hardened (after the chown, in first_time_install.sh).

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 17:45:03 -04:00
ChuckandClaude Opus 5 47afaaac2b fix(install): build rgbmatrix on ARMv6 Pi Zero / Pi 1, and stop locking users out of the submodule (#577)
The pinned rpi-rgb-led-matrix commit emits the ARMv7-only `dmb ishst`
instruction in lib/rp1/rp1_rio_backend.cc, guarded only by __arm__, so the
build fails on every ARMv6 board ("selected processor does not support
`dmb ishst' in ARM mode"). Bump the pin to upstream 1ee4f76, which merges
12d839f (guard on __ARM_ARCH >= 7) plus docs only.

The installer also needed two changes for that bump to reach anyone:

- git pull never moves an existing submodule checkout, so a device that
  already failed would keep building the broken commit. The build step now
  moves the checkout forward to the pin — never backward or sideways (a
  `git submodule update --remote` checkout is left alone), and never fatal.
- The submodule git commands ran as root on the user's clone (git's SUDO_UID
  exemption allows it), leaving .git/modules/rpi-rgb-led-matrix-master
  root-owned and the user unable to run git in it. They now run as the
  project directory's owner, and root-owned leftovers are handed back.
  Root-owned installs keep running as root.

test/test_install_rgb_checkout.py covers the non-root sync scenarios under
the installer's strict mode, checks that every called _helper is defined
before use, and pins the one-shot-install.sh -> first_time_install.sh
contract. Verified the tests fail on five deliberate mutations. Root/owner
scenarios were exercised manually under WSL Ubuntu, and the library was
cross-compiled for arm1176jzf-s at both pins (old: rp1_rio_backend.cc fails
at line 120; new: 16/16 sources compile).

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 17:38:17 -04:00
ChuckandClaude Opus 5 6d1cbfb70b fix(web): plugin config page survives stored values the schema outgrew (#578)
Two stored shapes broke the config form:

* A scalar under a field that is now an object. News' dynamic_duration
  was a boolean and is becoming an object; render_nested_section did
  `key in true` and the whole page failed to render. Look into dicts
  only, and carry a legacy boolean over as the object's `enabled`, so
  the next save upgrades it without switching the feature off.
* A custom feed logo with a path but no id. The template always emitted
  an empty `logo.id` input, which the save route parsed to null, failing
  the id's string type on every save. Emit it only when there is an id,
  as custom-feeds.js already does.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:03:15 -04:00
ChuckandClaude Opus 5 8e6d7c280f test(element-style): cover the stateless element_color clamp path (#576)
#569 fixed _normalize_color and #572 covered the resolver path. That test's own
docstring notes the resolver "normalizes colour separately from element_color",
and the other path had no test: the stateless element_color(), which
src.common.sports_card delegates to and which every one of the nine scoreboard
plugins takes for each per-element colour it draws.

That is the path that regressed. element_color() moved here with the per-element
customization framework, the coercion rejected out-of-range components where the
reader it replaced clamped them, and a rejection reads as "not configured" -- so
one component over 255 painted the element white while the user's colour sat in
their config. Every scoreboard's test_element_text_colors.py failed on it, and
it took two plugin PRs red on CI to surface.

Six cases: clamping, in-range untouched, hex, unparseable fallback, missing
element, and agreement with sports_card.coerce_rgb. The last is the point --
the two shared readers disagreed about the same value, so this asserts against
coerce_rgb directly rather than restating the arithmetic, and any future move
of element_color has to keep them consistent.

Verified by mutation: restoring the rejecting coercion fails two of the six,
alongside the resolver test from #572.

Tests only; no source change.


Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 13:27:10 -04:00
ChuckandClaude Opus 5 dac71afedc fix(web): full-height plugin config form and full-width plugin card descriptions (#573)
* fix(web): let the plugin config form use the full page height

The form wrapper has carried `max-h-96 overflow-y-auto` since #145, but
the class was a no-op until #568 defined `.max-h-96` in app.css. That
silently capped the whole config form at 24rem with a nested scrollbar.
Drop the cap so the form flows naturally and the page scrolls.

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

* fix(web): give installed plugin card descriptions the full card width

The enable/disable toggle was a flex sibling of the whole text column
(name, metadata, description), so it reserved its width for the full
height of the card body. Descriptions wrapped into a narrow strip,
leaving blank space under the toggle and making cards very tall.

Move the toggle into a header row with just the name and badges, and
render the metadata and description below at full width.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 13:27:00 -04:00
ChuckandClaude Opus 5 a6b3384032 fix(web): show the action script's error in the file-manager widgets (#574)
A failing plugin action returns a 400 whose JSON body carries the
script's own message, but both file-manager widgets threw it away:
plugin-file-manager's toggle always said "Toggle failed", and
json-file-manager's request helper threw "Server error 400" before
reading the body. That hid of-the-day's "Category ... not found in
config", which is why its toggles looked broken for no reason.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 13:26:46 -04:00
ChuckandClaude Opus 5 11bf39cd66 fix(web): two plugin config saves that always returned 400 (geochron, news) (#575)
* fix(web): render widget-less arrays of objects as a table, not comma text

An array of objects with no x-widget (geochron's `cities`) fell through to
the comma-separated text input. Jinja joined each item as a Python dict
repr, the save route read them back as a list of strings, and the schema
rejected them -- so every save of the plugin returned 400 "Configuration
validation failed", whatever setting was changed.

Default such arrays to the existing array-table widget, which already
edits arrays of objects and posts `field.N.key` inputs the save route
rebuilds into a list.

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

* fix(web): don't leave an empty object stub in array items on save

The unchecked-checkbox pass walked into every nested object of an array
item looking for booleans, creating it when absent. A news custom feed
with no logo came out with `logo: {}`, which fails the logo's
`required: [id, path]`, so every save of the news plugin returned 400.

Recurse into a scratch dict instead and attach it only if a boolean was
actually set in it.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 13:04:11 -04:00
ChuckandClaude Sonnet 5 bc60b41445 test(element-style): cover ElementStyleResolver's colour clamp path (#572)
* fix(colour): clamp out-of-range text_color components instead of dropping them

_normalize_color returned None for a triple with a component outside 0..255,
and None means "not configured" to element_color -- so configuring
[300, 0, 20] silently handed the element its *default* colour rather than red.
Every scoreboard reads its per-element colours through this path, so the bug
reached all eight.

It is also the odd one out: sports_card.coerce_rgb and
SportsShared._coerce_rgb both clamp, and core's own test is named
test_coerce_rgb_clamps_rather_than_rejecting. The rejecting normaliser arrived
with the shared readers in 82a65ad2 (#425) while the eight plugins' colour
tests kept asserting the clamping behaviour they had before, so the two sides
have disagreed ever since.

Clamped inline rather than delegating to coerce_rgb: sports_card already
imports element_style, so importing back would be circular.

Adds the core assertion whose absence let this drift -- element_color had no
test covering an out-of-range component.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(element-style): cover ElementStyleResolver's own colour clamp path

CodeRabbit flagged that the new sports_card clamp regression test only
exercises element_color(); ElementStyleResolver._resolve() normalizes
configured colours through a separate call to the same _normalize_color,
comparing against a schema/classic reference to decide user_forced_color.
Add a resolver-level case so a future regression in that path (e.g. going
back to rejecting out-of-range components instead of clamping) is caught
too.

Mutation-checked: fails if _normalize_color rejects instead of clamps.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 13:03:23 -04:00
ChuckandClaude Opus 5 f9b1f87e8d fix: clamp colour components, and let the style editor actually take over (#569)
* fix(element-style): clamp out-of-range colour components instead of rejecting

A regression this framework shipped. The eight scoreboards used to read their
colours through sports_card.coerce_rgb, which clamps; routing them through the
shared element_color sent them through _normalize_color, which rejected any
component outside 0..255 and fell back to the default. So a configured
[999, -5, 20] -- a typo'd bright red -- rendered white instead of (255, 0, 20).

Their own test_element_text_colors.py caught it: one case of nineteen, in all
eight plugins, failing only once the core change reached main.

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

* fix(web): the style editor takes over its own blocks -- and gets to at all

Two defects, both found by rendering the real partial in a browser rather than
by reading the code.

It was losing a race to its own fields. The hand-off guard asked "do any
fallback controls differ from their server-rendered defaults?" as a proxy for
"is someone editing this?". But the fallback holds this block's own font
fields, and the font-selector widget populates them on the same 50ms timer --
so a plain page load, with nobody touching anything, raced into "dirty" and the
editor removed itself, leaving the 701-line accordion form it exists to
replace. Measured: seven customization.*.font selects dirty ~60ms after
injection, clean again by 400ms. The question is whether a *person* typed, and
event.isTrusted answers exactly that; the listeners now go on synchronously,
because the edit worth protecting can happen before initWidget runs.

It took over too much. Taking over removed the whole fallback section, but a
customization block can hold more than styling -- football keeps
favorite_result_colors there -- so that removed the only UI those fields had,
and the editor also rendered them as an element, giving every row an "enabled"
and three colour columns. Core now marks the blocks it recognises as styling
(the compact declaration already did; hand-written adoption did not), the
widget renders only those, and the template drops only the children the widget
reports owning.

Verified on football's real schema: 28 rows across four mode tabs, columns
Element/Font/Size/Colour/X/Y, favorite_result_colors still editable with its
ten inputs, no duplicated field names, no console errors.

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

* fix(web): style editor no longer drops layout-only fields it never rendered

CodeRabbit flagged elementKeys() in style-editor.js: render() claims the
whole customization.layout child as the widget's own (removing it from the
generic fallback renderer, since posting the same offset twice is worse),
but elementKeys() only listed keys that also have their own top-level style
block. A hand-written schema can put a key under layout that never got one
-- a logo, a timeout indicator, a possession arrow with a position but no
font or colour -- and that key's only control silently disappeared: no row
in the style editor's table (elementKeys never listed it) and no fallback
section either (layout was removed wholesale).

elementKeys() now appends any layout-declared key not already covered by a
style element, so table() renders a row for it (layout columns only, no
style columns) and the wholesale layout ownership claim stays truthful.

Verified against current code before fixing. New regression test
(test/js/unit/test_style_editor_element_keys.js, following this repo's
existing eval-extraction pattern for testing widget JS without a browser)
fails against the reverted function and passes with the fix; added to
run_all.js and the suite table in test/js/README.md.

Full pytest suite: 4887 passed, 62 skipped, 2 failed -- both the
pre-existing Europe/Kiev/Asia/Calcutta tzdata-alias gap on this sandbox,
identical on origin/main, unrelated to this change.

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

* fix(web): style editor no longer strands leaf-valued layout fields

A prior fix on this PR made elementKeys() append any layout-only key with
no style block of its own (a logo, a timeout indicator, a possession
arrow), so table() draws a row for it instead of losing it when the
wholesale `layout` claim removes the generic fallback. That covers a
layout-only key shaped like an object (x_offset/y_offset, ...), because
columnsFor() only ever produced columns from a key's *sub-fields*.

It missed the case where the layout-only key's own value is itself a
leaf -- a plain "show_logo" boolean directly under layout, no x/y object
underneath. elementKeys() still lists it (any row: no matching column),
so it renders as an uneditable blank row and its only control -- the
generic fallback checkbox -- is still gone. Confirmed by executing the
real widget's render() against a synthetic schema in Node (a DOM-stub
harness, not committed): the field's name never appeared as an <input>.

columnsFor() now gives such a leaf key a column keyed to itself
('layout-leaf'), and elementRow() binds it to the leaf's own path
(customization.layout.<key>, matching the name the fallback would have
used) instead of leaving every cell blank.

New regression test (test/js/unit/test_style_editor_layout_leaf_columns.js,
following this PR's existing eval-extraction pattern) checks the leaf
column is produced, is self-keyed, doesn't duplicate, and that a schema
with no leaf-valued layout key is unaffected; wired into run_all.js and
the suite table in test/js/README.md.

test/js/run_all.js: 84 + 6 + 6 = all suites passed (jsdom unavailable
here, DOM suites skip as before). Python suite untouched by this change;
test_style_editor_extra_fields.py, test_style_editor_save_roundtrip.py
and the one PIL-dependent style_editor_takeover.py case fail identically
before this commit -- missing flask/PIL in this sandbox, not this PR.

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

* fix(web): keep layout-leaf style-editor columns distinct from name collisions

columnsFor() keyed a layout-only leaf field's column by its bare field
name. If an unrelated element's style block or another element's layout
axis block happened to declare a sub-field with that same name, the
`!seen.has(key)` guard skipped creating the leaf's column, silently
dropping its only control again -- the same failure the leaf-column fix
was meant to close, just reached through a name collision (CodeRabbit
review on 324a7ea).

Key layout-leaf columns under a namespaced id so they can never be
shadowed by an unrelated column sharing their name.

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

* fix(web): CSS.escape() the owned key before it becomes a selector

container.dataset.ownedKeys round-trips schema property keys through a
DOM dataset attribute, and the takeover handoff spliced each one
straight into '[data-child-key="' + k + '"]' with no escaping --
inconsistent with this codebase's own convention elsewhere
(plugin-file-manager.js, app-shell.js's escapeCssSelector) for building
a selector from a dynamic value. A key containing a quote or backslash
would break the selector or be steerable; Codacy's static analysis
flagged this pattern (1 high ErrorProne finding on PR #569, current
head at the time) as a new issue, though its dashboard is unreachable
from this sandbox (egress to app.codacy.com is blocked) and the
check-run API returned no detail text -- verified and fixed by reading
the diff directly rather than the tool's own description.

Added a source-assertion regression test alongside this file's
existing ones (this behavior lives in an inline script no Python test
executes).

Full suite: 4888 passed, 62 skipped, 2 failed -- both the pre-existing
Europe/Kiev/Asia/Calcutta tzdata-alias gap in this sandbox, identical
on origin/main, unrelated to this change.

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

* feat(web): every advertised layout offset gets a control in the style editor

The editor took the whole layout section over but matched offsets to style
rows by exact key. A hand-written schema's two blocks were never named alike --
football styles score_text but positions score -- so of football's eleven
positionable things only status_text had a control. Score, odds, both logos,
timeouts, possession, down-and-distance, date, time and records were options
the schema advertised and the renderer reads, reachable nowhere in the UI.

Core now resolves each style element's layout key through alias_keys, the map
the resolver already reads offsets with, and records it as x-layout-key. The
widget reads that rather than carrying a second copy of the rules, and posts
under the key the schema declares: football's own offset reader looks up
layout.score, so a value saved as layout.score_text would be kept and never
drawn. Layout entries no style element claims get an "Other positions" table
with its own columns, in every mode panel as well as the base one, in the order
the plugin declared them.

Verified in a browser against football's real schema: 92 of 92 layout fields
(23 base, 23 per mode) rendered exactly once under their declared names, none
posted under a style key, no duplicated field names, favorite_result_colors
still editable.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 12:48:36 -04:00
ChuckandClaude Opus 5 7e580dc005 fix(wifi): make Connect work from the setup AP (#571)
* fix(wifi): make Connect work from the setup AP

Joining a network from LEDMatrix-Setup has to take the AP down first, which
drops the phone that sent the request. The connect endpoint answered only
after the attempt finished, so the browser never got a reply and the WiFi
tab's Connect button appeared to do nothing.

- /wifi/connect answers 202 immediately while the AP is active and connects
  in a background thread; the result (never the password) is reported via
  /wifi/status as last_connect_attempt. A second connect while one is
  pending gets 409.
- connect_to_network holds a /tmp flag for the attempt; the monitor daemon
  skips AP management while it is fresh. Previously the daemon's
  disconnected counter, accumulated over the whole AP session, re-enabled
  the AP on its next tick in the middle of the connect.
- The WiFi tab and captive setup page explain the handoff up front, and on
  reopening show why the last attempt failed. The wrong-password message
  now works: the route sets the error_type the captive page checks.

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

* fix(wifi): serialize connect attempts on both paths

Addresses CodeRabbit review on #571:

- Check for a pending attempt before branching on AP state. A background
  attempt takes the AP down long before it finishes, so a second click
  used to bypass the 409 and start a competing synchronous connect.
- Record pending for the synchronous (non-AP) path too, so two requests
  can't overlap and have the first clear the daemon's in-progress flag
  while the second is still connecting.
- Clear the pending state if the background thread fails to start, rather
  than refusing every later request until restart.
- Say the setup network returns "within a few minutes": a stale flag plus
  the daemon's grace period can take longer than one.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 12:40:41 -04:00
ChuckandClaude Opus 5 d51f7ada14 chore(scroll): drop the dead sub-pixel path, and two dev-tooling papercuts (#570)
Three independent changes, none of which alter runtime behaviour.

1. Remove ScrollHelper._get_visible_portion_subpixel and
   _interpolate_subpixel (162 lines). get_visible_portion dispatches only to
   _blend_visible_portion, so _get_visible_portion_subpixel had no caller, and
   _interpolate_subpixel was reachable only from inside it -- a closed island.
   _blend_visible_portion's own docstring already records that the scipy path
   it replaced was dead; the replacement landed but the corpse stayed.

2. scripts/check_plugin.py: also search ../ledmatrix-plugins/plugins. The
   scoreboards live in the sibling checkout, so --all silently skipped every
   one of them and only --plugin-dir reached them.

3. .gitignore: ignore config/.config_secrets.json.tmp.*, which the suite
   leaves behind several of per run.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 09:42:50 -04:00
ChuckandClaude Opus 5 d1e821c625 fix(web): harden, polish and optimize the web UI per the Sept 2026 audit (#568)
* fix(web): harden, polish and optimize the web UI per the September 2026 audit

Works through docs/archive/WEB_UI_AUDIT_2026-09.md (health 8/20).

Implementation integrity (P0)
- app.css now defines every utility class the templates and JS use,
  including .hidden, so the ~145 JS show/hide toggles work. Button reset,
  and base component rules (.btn, .form-control) wrapped in :where() so
  utility classes on the same element win. New static-audit test fails
  when a used utility class has no rule.

Accessibility
- Focus rings render (the old ring rule referenced undefined variables);
  one :focus-visible outline everywhere; skip link; labelled nav landmarks.
- Shared dialog helper (js/utils/dialog.js): role/aria-modal, focus trap,
  Escape, focus return, applied to every modal.
- Named icon-only buttons and labelled ~70 form fields.
- Toasts announced once; errors persist >= 10s; one showNotification.
- Captive WiFi page: live region, timeouts, dark mode, 16px inputs.

Performance (Pi Zero 2 W)
- SSE streams and tab timers pause when hidden or off-tab; the display
  stream only runs while a preview is visible. app-shell.js deferred.
- Widget scripts served as one versioned bundle (/assets/widgets.js):
  52 -> 21 script tags, 66 -> 35 requests on first load.
- Stdlib gzip fallback when flask-compress is missing: first-load JS/CSS
  1358 KB -> 291 KB on the wire. SSE untouched.

Theming and responsive
- File managers, form fields and Fonts upload on theme tokens; bare
  inputs themed in dark mode; no more white surfaces.
- No horizontal overflow at 375px on any tab; 44px touch targets on
  coarse pointers; reduced-motion respected; header title truncates.

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

* fix(web): clear Codacy findings on #568

- json-file-manager: focus-trap releases kept in a Map (no dynamic
  property access or delete; no value-returning forEach callback)
- notification / schedule-picker: style and day-label lookups via Map
- app.js: move the pending-queue assignment out of the expression
- diff_viewer / error_handler: named function declarations instead of
  arrow consts

No behavior change.

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

* test: check the OAuth widget ships in the widget bundle

base.html no longer tags widget scripts one by one; they load through
/assets/widgets.js. Assert the page requests the bundle and the bundle
contains google-oauth.js, which is what the test was protecting.

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

* fix(web): address review feedback on #568

- widget bundle version fingerprints every file (name, mtime_ns, size)
- gzip fallback appends Accept-Encoding to an existing Vary header
- dialog helper: releasing a non-top dialog no longer moves focus out of
  the dialog the user is in
- labels: file-upload targets its file input; fallback config fields get
  label for/id pairs; native color input has a fallback name
- utility audit also reads class names inside bound :class expressions

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

* fix(web): give the native color-picker input an accessible name

CodeRabbit flagged this on PR #568 as an outside-diff finding (never
posted inline, so it was missed in the round of fixes that addressed
the other 6 review comments). The <input type="color"> only carried a
title attribute; screen readers don't reliably announce title, and
there's no other label naming the control when showHexInput is false.

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

* fix(web): clear Codacy findings in app-shell.js

- drop the unused catch binding on the SSE JSON parse
- move the pending-notification queue assignment out of the expression

No behavior change.

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

* fix(web): contain plugin widgets/ dir and bound style-editor retries

From CodeRabbit review on #568 (code that arrived with the main merge):
- serve_plugin_widget resolves widgets/ with resolve_under before
  resolving the manifest script under it, so a symlinked widgets
  directory can't become the containment base (CWE-22). New test.
- style-editor init stops polling after ~10s when the widget never
  registers and leaves the plain fallback fields in place.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 09:42:24 -04:00
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
ChuckandClaude Opus 5 92ac231138 fix(fonts): load 4x6 on its pixel grid, from any working directory (#565)
* fix(fonts): load 4x6 on its pixel grid, from any working directory

`extra_small_font` loaded 4x6-font.ttf at 6, off the face's 7px grid.
Under `draw.fontmode = "1"` the mono rasteriser thresholds each glyph at
50% coverage, so every glyph lost its fourth column and deformed:
christmas-countdown rendered "UNTIL" as "VM1JL". The advance is 5px at
both sizes, so snapping to 7 reflows nothing.

- Sizes in DisplayManager._load_fonts go through crisp_size() instead of
  literals. crisp_size / FONT_PIXEL_GRID / FONT_NAME_ALIASES move to
  src/common/font_layout.py; sports_card re-exports them.
- Mirror the fix in VisualTestDisplayManager, the harness's fork of
  _load_fonts. Without it every golden is blessed at the old size.
- Resolve bundled font paths against the install root, not the cwd.
  FontManager._resolve_asset_path now delegates to
  font_layout.resolve_asset_path (kept by name; plugins probe for it).
- The startup banner's middle rung snaps to 7; the 5 rung stays off-grid
  on purpose (the only size that fits a dotted quad on 64px).
- loading.py reads all plugin JSON as UTF-8 (cp1252 on Windows aborted
  check_plugin.py on a 0x9d byte).
- check_plugin.py reports in ASCII and never dies on an unencodable char.

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

* fix(fonts): resolve relative asset paths from the install root, not the cwd

resolve_asset_path checked os.path.exists(relative_path) unconditionally,
so a relative asset path was still resolved against the process cwd first
-- exactly the dependency this module exists to remove. An unrelated
working directory that happens to contain assets/fonts/4x6-font.ttf (a
stale checkout, a copied assets folder, another project) would shadow the
real bundled font instead of the install root ever being consulted.

Only an absolute path is now returned as-is; a relative path always
resolves against _INSTALL_ROOT first, matching the docstring's stated
contract. FontManager._resolve_asset_path delegates to this function, so
it's covered by the same fix.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 10:47:01 -04:00
ChuckandClaude Opus 5 772258f73e docs: add PRODUCT.md and September 2026 web UI audit (#567)
* docs: add PRODUCT.md product context for web UI design work

Captures durable product truth (users, positioning, operating context,
constraints, principles) so design passes on the web control panel share
one source. Open decisions (offline-only, CSS build step, WCAG target)
are recorded as undecided rather than adopted.

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

* docs: add PRODUCT.md and September 2026 web UI audit

PRODUCT.md captures durable product context (users, positioning,
operating context, constraints, principles) for web UI design work.
Open decisions (offline-only, CSS build step, WCAG target) are recorded
as undecided rather than adopted.

docs/archive/WEB_UI_AUDIT_2026-09.md records the technical audit of
web_interface/ (8/20): the hand-rolled Tailwind subset in app.css leaves
333 used utility classes undefined (including .hidden), focus rings never
render, modals lack dialog semantics, and SSE/polling never pause. Includes
a verified-and-rejected section so the cache-busting false positive is not
re-raised.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 16:53:45 -04:00
ChuckandClaude Opus 5 9ad7528c9b fix(config): stop same-second backups overwriting each other (#564)
* fix(config): stop same-second backups overwriting each other

A backup's version is its identity. save_config_atomic() hands the path
back, rollback_config(backup_version=...) looks that version up, and the
paired secrets backup is found by reusing the same string.

The version was stamped at second granularity, so two saves inside the
same second produced the same filename and the second shutil.copy2()
silently overwrote the first backup. The path a caller was still holding
then pointed at different content, and rolling back to it restored the
wrong config. A user saving twice in quick succession lost a restore
point with no error.

list_backups() made it worse. It parsed the version off Path.stem, which
drops only the last dot-component, so for config.json.backup.20240101_120000
parts was ['config', 'json', 'backup'] and parts[-2] was 'json' -- never
'backup'. The filename branch was unreachable: every backup fell through
to the mtime fallback and reported a second-granularity restamp of its
mtime rather than the name on disk, so a unique filename alone would not
have been enough for rollback to find the right version.

Stamp microseconds, and never overwrite an existing backup -- on a
collision bump a -N suffix rather than lose a restore point. Parse the
version off the exact glob prefix so it round-trips with the filename,
still reading the legacy second-granularity format so restore points that
predate this keep working.

Two tests had encoded the bug:

  - test_multiple_config_changes asserted a rollback produced plugin1=45
    with plugin2=15, a state no single backup ever held -- 45 was only in
    the second backup, 15 only in the first. It passed because the two
    saves collided onto one file, so the first version resolved to the
    second's content. Corrected to the state that backup actually holds.

  - test_backup_rotation asserted against a hardcoded max of 3 while
    setUp configured 5, and still passed: every save in its loop collapsed
    onto a single filename, so there was only ever one backup to count and
    rotation was never exercised. It now asks the manager for its limit
    and overshoots it.

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

* fix(config): fold collision suffix into ordering, close backup-path race

_parse_backup_version() stripped any trailing "-segment" unconditionally,
so a collision-suffixed backup parsed to the exact same timestamp as its
sibling and list_backups() had no deterministic way to order them. Only
strip the suffix when it's numeric, and fold it back in as extra
microseconds so same-tick collisions sort newest-first reliably.

_create_backup() also checked backup_path.exists() before shutil.copy2(),
which two concurrent callers can both pass for the same path -- the second
copy2() then silently destroys the first call's restore point. Reserve
each path (config and, when configured, secrets) with exclusive file
creation instead of a check-then-copy, retrying on a real conflict.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 16:13:50 -04:00
ChuckandClaude Opus 5 6b3028ad58 test: isolate DisplayManager globals across modules, and name the failure (#563)
Follow-up to #562. That commit fixed the actual cause of the intermittent
15-test failure in test_display_dirty_tracking.py -- the emulator's fixed TCP
port 8888, a machine-wide singleton that a concurrent pytest process takes
away. This adds the two things that would have made it a five-minute
diagnosis instead of a long one, and closes the other door into the same
failure.

Confirmed the module is order-independent as it stands, on this checkout:

  pytest test/ -q, three times          115 failed / 4464 passed / 63 skipped,
                                        byte-identical failure sets, the
                                        module 21/21 passed each time
  module forced last (197 files first)  identical failure set
  module forced first                   identical failure set
  module after each of test_display_manager, test_display_controller,
    test_display_controller_vegas_tick, test_skin_system, test_sports_scroll,
    test_initial_update_budget, test_display_double_parity,
    test_initializing_screen                                     all pass
  four concurrent processes on the file                          21/21 each

And reproduced the original, to be sure the diagnosis in #562 is the whole
story. Holding 0.0.0.0:8888 from a separate process:

  HEAD's test/conftest.py        21 passed
  pre-#562 test/conftest.py      15 failed, 6 passed

The 15/6 split is not arbitrary: the six survivors are the only tests in the
file that never touch dm.matrix.

conftest.py: DisplayManager is a process-wide singleton and the RGBMatrix /
RGBMatrixOptions names it constructs through are module globals, bound once at
import. All three are shared by every test module in the run, so a module that
leaves an instance in _instance -- or leaves patch('src.display_manager.
RGBMatrix') standing -- changes what the NEXT module builds, invisibly, and
only in a full run. A module-scoped autouse fixture now resets the singleton
and restores either binding if a patch outlived its module. Module-scoped
rather than per-test so that files sharing one manager across their own tests
keep doing so; only the leak across the module boundary is cut. Autouse
fixtures are set up ahead of requested ones, so this is finalised after a
module's own DisplayManager fixture. Verified with a throwaway pair of probe
modules -- one leaks a patch and a singleton, the next asserts both are clean
-- which passed and were then removed.

test_display_dirty_tracking.py: _setup_matrix() swallows every construction
failure and falls back to matrix=None, so a broken environment arrived as
fifteen identical "'NoneType' object has no attribute 'SwapOnVSync'" errors
naming neither the fixture nor the cause. The fixture now fails once, and
says where to look; under a held port it reads

    DisplayManager fell back to matrix=None: RGBMatrix construction raised...
    Known causes: the emulator adapter losing a fixed TCP port to another
    process -- see pytest_configure in test/conftest.py -- or a
    patch('src.display_manager.RGBMatrix') leaked from an earlier test module.

with WinError 10048 in the captured log directly above it.

No regressions: full suite with both changes is 115 failed / 4464 passed /
63 skipped, failure set identical to the pre-change baseline. The 115 is the
pre-existing Windows-environment baseline (os.geteuid, POSIX modes, fcntl);
CI on Linux remains authoritative.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 16:13:38 -04:00
151 changed files with 16535 additions and 3907 deletions
+3
View File
@@ -5,6 +5,9 @@ __pycache__/
# Secrets
config/config_secrets.json
# Atomic writes leave these behind when a save or a test is interrupted;
# the suite drops several per run.
config/.config_secrets.json.tmp.*
config/config.json
config/config.json.backup
config/wifi_config.json
+252
View File
@@ -17,8 +17,260 @@ release that ships it.
accepts both, but the store flags the old spelling as deprecated
(`store_manager.py`) and only the new one is in `schema/manifest_schema.json`.
## Unreleased
## 3.4.0
Plugin-facing changes since 3.3.0 (tag `v3.3.1`) not covered further down:
- `BasePlugin.get_update_interval()` (#555) — return seconds to override the
manifest's `update_interval` at runtime (e.g. poll fast only while a game is
live), or `None` to keep it. Clamped to at least 5 seconds; a raising or
non-numeric return is ignored. Called every scheduling tick, so keep it
cheap. Older cores never call it. See `docs/PLUGIN_API_REFERENCE.md`.
- `src.common.scroll_config` (#523) — turns a plugin's scroll config into a
configured `ScrollHelper` in one place, replacing per-plugin resolution that
disagreed between tickers, and warns when a speed won't advance whole pixels
per panel refresh. Floor on 3.4.0 to import it.
- **Skins are marked unsupported.** No current scoreboard plugin builds on
`src.base_classes`, so the skin hook (`SportsCore._render_game`) never runs.
The web UI no longer shows the Visual Skin dropdown, the store hides and
refuses `"type": "skin"` entries, and `GET /api/v3/skins` reports
`"supported": false`. Saved `skin` config values still load and save.
`src/skin_system/` is unchanged.
- **Web preview size** now comes from `src/display_geometry.py`, the same
computation `DisplayManager` uses: double-sided setups preview one screen,
and a missing `chain_length` defaults to 2 everywhere (the Starlark magnify
default and the sync handshake used 1). The module is core-internal: plugins
keep reading `display_manager.width`/`height`.
- `src.common.font_layout` (#539, #565) — `load_truetype()` is
`ImageFont.truetype` with the layout engine pinned, so text lays out the same
whether or not the host's Pillow was built with libraqm; `crisp_size()` and
`FONT_PIXEL_GRID` give the size a bundled face renders on whole pixels at
(`sports_card` still re-exports them); `resolve_asset_path()` resolves
`assets/fonts/...` against the install root, not the working directory.
Floor on 3.4.0 to import it. Relatedly, `DisplayManager` now draws text
1-bit (#521), so golden images recorded against 3.3.x may need regenerating.
### Install and updates
**Weekly automatic updates (#581), off by default.** Switching on
*Automatically check for and install updates once a week* on the General tab
(or `first_time_install.sh --enable-auto-update` / `LEDMATRIX_AUTO_UPDATE=1`)
updates the core and then every installed plugin once a week, preferably 2–5 AM
local time. It follows the branch the checkout tracks — `main` on a standard
install — so a device gets whatever has merged there, not only tagged releases.
See `docs/WEB_INTERFACE_GUIDE.md`.
- The core step is skipped, with the reason shown, when the checkout has local
edits or commits, a rebase or merge is in progress, the branch has no
upstream, less than 300 MB is free, or that commit was already rolled back.
- After pulling, `ledmatrix-update-verify.service` restarts the services and
requires the web interface to answer and the display to stay up. If they
don't, or the new requirements fail to install, it resets to the previous
commit, reinstalls its requirements and restarts again. Anything but success
shows under the toggle and as a banner on Overview.
- Plugins update through the Plugin Store even when the core step is skipped,
fails or is rolled back. A plugin version whose `ledmatrix_min_version` is
above the device's core is held back, not installed. When the core did
update, plugins wait for its health check, and are left alone if that check
never reports or the rollback fails.
- No SSH is needed: switching the toggle on restarts the display service, which
installs the health-check units (`src/auto_update_setup.py`, core-internal
and not a plugin API).
Installer and service fixes:
- rgbmatrix builds on ARMv6 boards (Pi Zero, Pi 1); an existing checkout is
moved forward to the new pin and no longer left root-owned (#577).
- `first_time_install.sh` grants the web user `safe_pip_install.sh`, as
`configure_web_sudo.sh` already did, so plugin requirements install where
the display service can see them (#579).
- The web interface starts when `web_display_autostart` is missing or
`config.json` is unreadable; only an explicit `false` keeps it down (#556).
- Installers render every systemd unit from its `systemd/` template, so the
boot-time unit-drift warning can clear, non-root installs included (#547).
### Scrolling
- **Frame pacing (#523).** The loop waits only for the rest of each panel
refresh instead of a flat 8 ms: 44–46 fps → 100 fps, and slow frames 14% →
0.02%, on a 2×128×64 chain. Sub-pixel blending is off by default again (it
shimmered on pixel fonts; Vegas mode still opts in).
- **Whole-pixel steps (#545).** At a speed `scroll_config` can render in whole
pixels, every frame advances by exactly the same amount, removing about six
hitches a second. A loop that can't keep up now scrolls slightly slow rather
than jumping.
- The eight sports scoreboards scroll through `scroll_config` too (#542): the
default 50 px/s holds each frame for two refreshes instead of alternating
0 px and 1 px steps.
- **Frame stats ignore the pause between scrolls (#582).** The `Scroll frame
stats` log line counted the idle wait before each scroll as one frame,
inflating `max` and the stall rate. `docs/SCROLL_PERFORMANCE.md` now
describes the line actually logged.
### Plugins
- `FontManager` registers the bundled `tom_thumb` font, so plugins no longer
need a private loader (#534).
- The test harness's `set_scrolling_state()` accepts `frame_hold`, as
`DisplayManager`'s does (#534).
- A `display()` with nothing to draw should return `False`, the only value the
controller skips on; starlark-apps now does, rather than holding a black
panel (#534).
- Starlark apps may set `render_width`/`render_height` in their `config.json`
to render at their own canvas size instead of Pixlet's 64×32 (#552).
- `scripts/render_plugin.py --display-mode <mode>` renders one mode of a
multi-mode plugin; scoreboards previously rendered blank (#522).
- Scoreboards resolve their own directory under the real plugin loader
(declare `_PLUGIN_DIR`), so 4x6 text snaps to its 7px grid instead of
rendering a pixel narrow, and an unreadable schema is logged (#519, #520).
`DisplayManager` loads 4x6 on that grid too (#565).
- The 5x7 BDF face reports a real height, so rows stacked by
`get_font_height()` no longer overlap (#539).
- `LogoHelper` remembers a missing logo instead of warning every rotation
(#548), and the decoded sports logo cache is bounded (#559).
### Web interface
- Installed Plugins has search, All / Enabled / Disabled / Updates filters and
sort (#540).
- Hardened and polished per the September 2026 audit (#568): utility classes
such as `.hidden` actually exist, focus rings, labels and modal focus
trapping, dark theme throughout, no overflow at phone width, and background
streams pause when hidden, with first-load JS/CSS down from 1358 KB to 291 KB.
- WiFi Connect works from the LEDMatrix-Setup hotspot: the page is answered
before the hotspot drops, and reopening it shows why an attempt failed (#571).
- Pixlet install, the Starlark app store and app toggles work again (#535,
#537); the store uses the configured GitHub token and reports a rate limit
instead of drawing a blank grid (#541).
- Plugin config: geochron and news saves no longer always fail (#575), the page
survives stored values the schema outgrew (#578), the form uses the full page
height (#573), and file-manager widgets show the script's error (#574).
- The live status stream reports real disk usage and available memory (#558);
a system action refused for want of passwordless sudo says so and names
`configure_web_sudo.sh` (#560).
### Tools and security
- **CodeQL triage (#561):** 129 of 134 alerts fixed. Three were exploitable
path-handling flaws in the web interface and are closed; web UI escapers now
escape quotes, and URL fields refuse script schemes. Path checks share
`src/common/path_safety.py` (core-internal).
- **Home Assistant MQTT bridge** (`integrations/mqtt_bridge`, #538): mode
select, stop, power and brightness over MQTT Discovery.
- **Tools tab** manages the MQTT bridge and the Pixlet editor (#554); the
editor stays on loopback when `PIXLET_EDITOR_HOST` says so.
### Fixes
- Updating a plugin whose directory is named for its manifest id (leaderboard,
music, stocks, weather) silently did nothing (#536).
- Plugin reconciliation no longer reports working plugins as stale or replaces
their config with a stub, and the Overview banner advises each case correctly
(#557).
- Two config saves in the same second no longer share one backup, so rollback
restores the version asked for (#564).
- On-demand: a second request is honoured without a restart (#534), a pinned
request stays on its mode, and restarting mid-session loads every plugin
again (#538).
- `/health` and `/display/current` report real state, and the preview no longer
freezes on a leftover snapshot temp file (#534).
### Per-element display customization
**Per-element display customization, and the last mile of it into the web UI.**
A user can set the font, size, colour, position, visibility and alignment of
individual display elements per plugin -- and, where a plugin has display
modes, separately per mode.
New public API a plugin may import via `src.*` (floor on the release that
ships this):
- `src.element_style.layout_offset(config, element, axis, default, mode)` and
`element_color(config, element, default, mode)` — the stateless reads the
scoreboard helpers share. There were three copies of the offset read and two
of the colour read; these are the one implementation, and they carry the
element-name aliasing and the per-mode lookup.
- `src.element_style.alias_keys(element)` — the names one element may be stored
under. The style block names elements `score_text` while the layout block
says `score`, and `records`/`record` and `status_text`/`status` split seven
to two across the published schemas. A lookup tries the exact name first, so
this is inert for a config that already matches.
- `src.element_style.element_visible(config, element, default, mode)`,
`element_align(...)` and `element_scale(...)` — the stateless reads for the
three knobs the resolver already understood but no draw path consumed, so an
element could be marked hidden in the web UI and still render.
- `SportsCoreSharedMixin._draw_text_with_outline(..., element="score_text")` —
naming the element resolves its colour by name and honours its visibility
toggle. Without a name the colour is inferred from font-object identity,
which cannot separate two elements sharing a face; that is the case every
bitmap font is in, because a `freetype.Face` cannot be re-instantiated, and
it is how a BDF-rendered element silently lost a configured colour. Shared
faces now resolve when exactly one sharer has a colour set.
- `LogoHelper.load_logo(..., scale=)` — applies a user's image scale, and keys
the cache on the scaled box so two elements scaled differently cannot be
served each other's image.
- `src.element_style.native_bdf_size(font)` — the one pixel size a bitmap font
can render at, or None for a scalable one. The web UI needs this to know
whether a size control can take effect at all.
- `ElementStyleResolver(config, defaults, mode=...)` plus `visible`, `align`
and `scale` on `ElementStyle`. The mode binds to the resolver rather than
being passed per call, so a plugin with one instance per mode makes every
existing lookup mode-aware by setting one class attribute.
- `BasePlugin.styles` / `styles_for(mode)` / `STYLE_MODE` — the accessor every
plugin inherits, so adopting this is no longer a guarded import plus schema
discovery plus resolver invalidation in each plugin.
- `SportsCoreSharedMixin._get_layout_offset` — promoted from the plugins'
bundled copies. Each still carries its own, which wins by MRO, so adopting
it is a deletion.
Schema and web UI:
- A `customization` block is now rendered by a composite style editor: one row
per element rather than nested accordions, with a tab per declared mode.
Plugins that hand-wrote their style blocks get it without a plugin release;
`x-style-elements` and `x-style-modes` declare it compactly.
- Font fields become a real picker rather than a hardcoded `enum`, so a font
the user uploads is selectable. Bitmap fonts taller than the element's
declared size ceiling are filtered out, because a bitmap font ignores
`font_size` and renders at its own size.
- `/static/plugin-widgets/<plugin>/<widget>.js` serves a plugin's own web-UI
widgets. The client half and the docs already existed; nothing served them.
Fixed:
- A bitmap font asked for a size it has no strike for fell back to
*PressStart2P* — a different typeface — rather than to its own native size.
32 of the 35 shipped fonts are bitmap, so this was reachable for most font
choices.
- The plugin config form read `config_schema.json` directly while the save
route read it through `SchemaManager`. Only the latter expands a compact
`x-style-elements` declaration, so a plugin using that form had a
customization section that rendered as empty space.
- `unshare_element_fonts` rebuilt faces through bare `ImageFont.truetype`,
bypassing the layout engine `src/common/font_layout.py` pins. These were the
only two call sites in `src/` doing so.
- The form parser compared a schema type to a bare string, so a nullable field
(`["array", "null"]`) never had its indexed colour inputs recombined, and a
blank one became `[]` rather than null.
Removed:
- The Fonts tab's "Element Font Overrides" panel and its three endpoints. They
reported success and saved nothing, and the element keys the panel offered
(`nfl.live.score`, `clock.time`) are read by no plugin, so wiring them to the
real `FontManager` methods would still have changed nothing on the panel.
Per-element font choice now lives in each plugin's own config editor.
- "Detected Manager Fonts", which listed every installed font with a hardcoded
usage count.
- Two dead client-side config-form renderers in `app-shell.js` (~580 lines) and
the legacy `plugins/config_manager.js`, superseded by server-side rendering.
## 3.3.0
Historical note: tags `v3.3.0` and `v3.3.1` both report `__version__` "3.3.0" and both ship `src/common/sports_shared.py`, so a "3.3.0" floor always means a core with `sports_shared`.
**The release the sports scoreboards floor on to delete their bundled copies.**
3.2.0 shipped the unified sports library and made `ledmatrix_min_version`
enforceable; this ships the last three shared modules and completes the store
+7 -4
View File
@@ -6,7 +6,7 @@
- `config/config.json` — User plugin configuration (persists across plugin reinstalls)
- `plugin-repos/` — **Default** plugin install directory used by the
Plugin Store, set by `plugin_system.plugins_directory` in
`config.json` (default per `config/config.template.json:167`).
`config.json` (default per `config/config.template.json`).
Not gitignored.
- `plugins/` — Legacy/dev plugin location. Gitignored (`plugins/*`).
Used by `scripts/dev/dev_plugin_setup.sh` for symlinks. The plugin
@@ -23,7 +23,7 @@
- Each plugin needs: `manifest.json`, `config_schema.json`, `manager.py`, `requirements.txt`
- Plugin instantiation args: `plugin_id, config, display_manager, cache_manager, plugin_manager`
- Config schemas use JSON Schema Draft-7
- Display dimensions: always read dynamically from `self.display_manager.matrix.width/height`
- Display dimensions: always read dynamically from `self.display_manager.width/height` — not `display_manager.matrix.width/height`, because `matrix` is `None` when hardware init fails (the properties fall back to the canvas size)
- Secrets: namespaced by plugin id in `config/config_secrets.json`, declared
via `"x-secret": true` in the plugin's config schema, and deep-merged into
the plugin's config dict at load time — plugins read them with plain
@@ -45,9 +45,12 @@
- Plugin configs stored in `config/config.json`, NOT in plugin directories — safe across reinstalls
- Third-party plugins can use their own repo URL with empty `plugin_path`
## Skin System (visual overlays for sports scoreboards)
## Skin System (visual overlays for sports scoreboards) — NOT SUPPORTED YET
- Skins do not render with the current scoreboard plugins: the only hook is `SportsCore._render_game()` in `src/base_classes/sports/core.py`, and no current scoreboard plugin (monorepo or third-party registry) builds on `src.base_classes`
- So core doesn't offer them: no Visual Skin dropdown (`get_plugin_schema` skips `inject_skin_selector`), the store hides/refuses `"type": "skin"` entries, `GET /api/v3/skins` reports `"supported": false`. Switch: `SKINS_RENDER_SUPPORTED` in `src/skin_system/__init__.py`
- Stored `skin` / `skin_options` config values must keep loading and saving (base schema allows them; form saves deep-merge over the stored section)
- Skins live in `skins/<skin-id>/` (skin.json + skin.py), NOT in plugin dirs — plugin reinstall deletes plugin dirs
- Core: `src/skin_system/` (ScoreboardSkin, SkinContext, runtime); hook: `SportsCore._render_game()` in `src/base_classes/sports/core.py`
- Core: `src/skin_system/` (ScoreboardSkin, SkinContext, runtime); keep it and its tests
- Skins render onto `ctx.canvas` only; fallback to built-in renderer on `False`/exception (3 strikes disables for session)
- View-model guaranteed keys are frozen (see `test/test_skin_system.py::TestViewModelContract`) — renaming keys in `_extract_game_details_common` or sport extractors breaks published skins
- Validate skins headlessly: `python scripts/validate_skin.py --skin <id>`; docs: `docs/SKIN_SYSTEM.md`, `docs/CREATING_SKINS.md`
+74
View File
@@ -0,0 +1,74 @@
# Product
<!-- impeccable:product-schema 1 -->
## Platform
web
## Users
Designed novice-first, with power tools kept within reach.
- **Primary: hobbyist builders.** People who assembled an LED matrix panel on a Raspberry Pi, often by following the install video, and are frequently new to Linux and the Pi. They set the display up once (panel size, timezone, WiFi), install and enable a few plugins, then come back occasionally to tweak what the panel shows. They usually reach the control panel from a phone or laptop on their home network, sometimes as an installed home-screen app.
- **Secondary: tinkerers and plugin developers.** Comfortable with SSH, `config.json`, and GitHub. They lean on the Config Editor, Logs, Cache, Operation History, Tools, GitHub-repo installs, and per-plugin config while building or debugging. Their tools must stay reachable without sitting in the novice's path.
## Product Purpose
LEDMatrix turns a Raspberry Pi and an RGB LED matrix panel into an information-rich display (clock, weather, calendar, sports scores, stocks, music, and more) through a plugin platform. The web control panel ("LED Matrix Control") is where the display gets configured, extended, and kept healthy.
Success means a builder gets from a freshly flashed Pi to a working, personalized display without needing a terminal, and can keep it running (updates, recovery, troubleshooting) the same way.
## Positioning
Four strengths define LEDMatrix, and future work must protect all of them:
1. **Plugin ecosystem.** The core ships only `starlark-apps` and `web-ui-info`; everything else comes from the built-in Plugin Store (the official `ledmatrix-plugins` monorepo), third-party GitHub repos, or Starlark (Tidbyt-style) apps. Each installed plugin gets its own configuration tab, generated from its schema.
2. **Runs on tiny Pis.** The UI is served by the same device that drives the matrix, on boards as small as the Pi Zero 2 W (512 MB), Pi 3/3B+, and the 1 GB Pi 4.
3. **Recovers without SSH.** WiFi access-point fallback with a captive setup page, backup & restore, in-UI updates, live logs, diagnostics, service control, and plugin health let users fix problems from the browser.
4. **Open and community-led.** GPL-3.0, a Discord community, and contributions welcome. The maintainer (ChuckBuilds) builds in public and openly relies on AI development tools.
## Operating Context
- **Access.** Served on the local network at `http://<pi-ip>:5000` by the `ledmatrix-web` service. It is installable as a PWA (`web_interface/static/v3/manifest.json`, short name "LEDMatrix").
- **First run.** When the Pi has no network it creates its own WiFi access point, so the captive setup page (`templates/v3/captive_setup.html`) may be the very first screen a user sees, on a phone, with no internet connection.
- **Navigation.**
- System tabs: Overview, General, WiFi, Schedule, Display, Rotation, Config Editor, Backup & Restore, Fonts, Logs, Cache, Operation History, Tools.
- A second row holds Plugin Manager (with the Plugin Store), Starlark Apps, and one tab per installed plugin.
- **Live data.** The Overview shows system stats (CPU, memory, temperature, power/throttling) and a live display preview, streamed over SSE.
- **Getting Started checklist.** The Overview's first-run checklist runs: set panel size → set timezone → install a plugin → enable it → configure it.
- **Development.** `python3 scripts/dev_server.py` gives a browser preview without the display loop; `python3 run.py -e` runs the full display in emulator mode.
## Capabilities and Constraints
- **Hard constraint: plugin UI compatibility.** Third-party plugins rely on JSON Schema (Draft-7) generated config forms, the widget registry (`static/v3/js/widgets/`), `x-secret` fields, and plugin web-UI actions. UI changes must keep these working.
- **Config storage.** Plugin configuration lives in `config/config.json` and secrets in `config/config_secrets.json`, never in plugin directories, so configs survive reinstalls.
- **Stack.** An existing Flask + HTMX + Alpine.js app with Jinja templates (`web_interface/templates/v3/`) and static JS/CSS (`web_interface/static/v3/`), with self-hosted vendor assets.
- **Terminology.** Plugin, Plugin Store, Starlark app, rotation, display duration, Vegas Scroll Mode, skin, on-demand, AP mode.
- **Open decisions** (offered during init, not adopted as constraints):
- Whether the UI must work fully offline, with no CDN fallbacks at runtime.
- Whether a Node/CSS build step is acceptable for contributors.
- Whether a formal accessibility standard (e.g. WCAG 2.2 AA) is a requirement.
## Brand Commitments
- **Names.** The product is "LEDMatrix" and the web UI is titled "LED Matrix Control". The maintainer brand is ChuckBuilds.
- **Voice.** Friendly, honest, and learning-in-public, as in the README.
- **App icons.** They live in `web_interface/static/v3/icons/`.
No other visual identity has been made binding.
## Evidence on Hand
- **Photos.** Real photographs of running displays are linked in `README.md` (clock, weather, calendar, NHL/MLB/NFL/NCAA, stocks, music).
- **Video.** YouTube install and walkthrough videos from ChuckBuilds.
- **Docs.** Extensive documentation in `docs/`, e.g. `WEB_INTERFACE_GUIDE.md`, `GETTING_STARTED.md`, `WIFI_NETWORK_SETUP.md`, `LOW_MEMORY_BOARDS.md`, `PLUGIN_STORE_GUIDE.md`.
- **Absences.** There are no testimonials, user counts, or benchmark figures. Do not fabricate them.
## Product Principles
1. **Novice path first, power one click away.** Default views serve the first-time builder, while advanced tools stay discoverable for tinkerers.
2. **Never strand the user at a terminal.** Every setup, recovery, and troubleshooting task has a browser path, including from the AP-mode captive page.
3. **Respect the Pi.** Every feature is paid for in memory and CPU on a Pi Zero 2 W that is also driving the display.
4. **The ecosystem is the product.** Plugins, including third-party ones, must feel first-class and keep working across core UI changes.
5. **Honest and welcoming.** Plain language, truthful status, and no overstated claims, in keeping with an open, community-built project.
+6 -7
View File
@@ -463,13 +463,12 @@ For plugin development, check out the [Hello World Plugin](https://github.com/Ch
### Visual Skins for Scoreboards
Want a different look for a sports scoreboard without forking the plugin?
**Skins** restyle the live/recent/upcoming screens while the plugin keeps
handling data, scheduling, caching, and vegas mode. Install one with
`git clone <skin repo> skins/<skin-id>`, select it in the plugin's config,
and you're done — see [docs/SKIN_SYSTEM.md](docs/SKIN_SYSTEM.md) (how it
works) and [docs/CREATING_SKINS.md](docs/CREATING_SKINS.md) (build your own,
including a ready-made Claude Code prompt).
**Not supported yet.** Skins are meant to restyle a sports scoreboard's
live/recent/upcoming screens without forking the plugin, but the current
scoreboard plugins don't render them: a selected skin has no effect. The web
UI doesn't offer skin install or selection for that reason. The skin system
and its docs stay in place for when scoreboards adopt it; see
[docs/SKIN_SYSTEM.md](docs/SKIN_SYSTEM.md) for why.
2. **Built-in Managers Deprecated**: The built-in managers (hockey, football, stocks, etc.) are now deprecated and have been moved to the plugin system. **You must install replacement plugins from the Plugin Store** in the web interface instead. The plugin system provides the same functionality with better maintainability and extensibility.
</details>
+3
View File
@@ -1,5 +1,8 @@
{
"web_display_autostart": true,
"auto_update": {
"enabled": false
},
"schedule": {
"enabled": false,
"mode": "per-day",
+2 -2
View File
@@ -223,8 +223,8 @@ The harness already renders every plugin at a spread of sizes (now
including 96x48):
```bash
python scripts/check_plugin.py <plugin-dir> --sizes 64x32,128x32,96x48,128x64,256x64
python scripts/render_plugin.py <plugin-dir> --width 96 --height 48
python scripts/check_plugin.py --plugin <plugin-id> --sizes 64x32,128x32,96x48,128x64,256x64
python scripts/render_plugin.py --plugin <plugin-id> --width 96 --height 48
```
`BoundsCheckingDisplayManager` flags right/bottom overflow and now records
+17 -5
View File
@@ -1,5 +1,14 @@
# Creating Skins
> **Not supported yet: skins don't render with the current scoreboard
> plugins.** The only render hook is `SportsCore._render_game()` in
> `src/base_classes/sports/core.py`, and no current scoreboard (monorepo or
> third-party) builds on `src.base_classes`, so a skin you build here passes
> `validate_skin.py` but never appears on the matrix. The web UI and Plugin
> Store don't offer skins for that reason. Details:
> [SKIN_SYSTEM.md](SKIN_SYSTEM.md#status-not-supported-yet). The guide below
> stays accurate for the skin API itself.
A skin restyles a sports scoreboard (live / recent / upcoming) without
forking the plugin: the plugin keeps fetching data, scheduling, caching, and
doing vegas mode; your skin only draws. Architecture background:
@@ -19,7 +28,9 @@ panel sizes with **no hardware, no network, no running service**, saves PNGs
(plus 4x previews) to `skin_renders/`, and fails loudly on errors. Iterate:
edit → validate → look at the PNGs.
To see it on your matrix, add to your plugin's section in `config/config.json`:
To select it, add to your plugin's section in `config/config.json` (this is
stored and validated, but has no visible effect until a scoreboard uses the
skin hook — see the note at the top):
```json
"baseball-scoreboard": {
@@ -28,8 +39,8 @@ To see it on your matrix, add to your plugin's section in `config/config.json`:
}
```
or pick it from the **Visual Skin** dropdown in the web UI (it appears once a
matching skin is installed). `"skin"` also accepts a per-mode mapping:
The web UI's **Visual Skin** dropdown is hidden while skins are unsupported.
`"skin"` also accepts a per-mode mapping:
`{"live": "my-skin", "recent": "built-in"}`.
## The manifest (`skin.json`)
@@ -234,8 +245,9 @@ Tips that keep Claude (and you) out of trouble:
dev machine
Distribute by publishing the directory as a git repo (users
`git clone <repo> skins/<id>`), or submit it to the plugin registry as an
entry with `"type": "skin"` (see [SKIN_SYSTEM.md](SKIN_SYSTEM.md) §Distribution).
`git clone <repo> skins/<id>`). Registry entries with `"type": "skin"` are
hidden and refused by the Plugin Store while skins are unsupported (see
[SKIN_SYSTEM.md](SKIN_SYSTEM.md) §Distribution).
**Trust note:** a skin is Python running inside the display service — the
same trust level as a plugin. Review code before installing skins from
+45 -1
View File
@@ -36,7 +36,11 @@ self.enabled # Boolean enabled status
#### `update() -> None`
Fetch/update data for this plugin. Called based on `update_interval` specified in the plugin's manifest.
Fetch/update data for this plugin. Called on the plugin's update interval:
the value `get_update_interval()` returns when it returns a number, otherwise
the static interval: the `update_interval` in the plugin's manifest, else
`update_interval` in the plugin's section of `config.json`, else 60 seconds
(see [`get_update_interval()`](#get_update_interval---optionalfloat) below).
**Example**:
```python
@@ -109,6 +113,46 @@ Called when plugin is enabled.
Called when plugin is disabled.
#### `get_update_interval() -> Optional[float]`
How often this plugin wants `update()` called right now, in seconds. The
manifest's `update_interval` is one static number; override this when the
right cadence depends on state only the plugin knows, e.g. poll every 15s
while a game is live and fall back to the manifest value otherwise.
**Returns**: seconds as a number, or `None` (the default) for no opinion.
How `PluginManager` (`_get_plugin_update_interval` in
`src/plugin_system/plugin_manager.py`) resolves the interval on each
scheduling tick:
1. It calls `get_update_interval()`. A number wins over everything below.
Values under `PluginManager.MIN_DYNAMIC_UPDATE_INTERVAL` (5 seconds) are
raised to it.
2. If the hook returns `None`, raises, or returns something that isn't a
finite number (a `bool`, a string, NaN, infinity), it is ignored and the
static interval applies: the manifest's `update_interval`, else
`update_interval` in the plugin's section of `config.json`, else 60
seconds.
The static value is cached per plugin until the plugin is loaded or
unloaded again, so editing `update_interval` in config takes effect on the
next reload. The hook's return value is never cached: it is called on every
tick of the display loop, so keep it to attribute reads (no config lookups,
no I/O, no locks a fetch might hold) and don't let it raise.
**Example**:
```python
def get_update_interval(self):
# Fast while something is live, manifest default otherwise.
if any(m.live_games for m in self._live_managers):
return self.config.get("live_update_interval", 15)
return None
```
Added in core 3.4.0; older cores never call it, so a plugin that relies on
it should floor `ledmatrix_min_version` at `3.4.0`.
#### `get_display_duration() -> float`
Get display duration for this plugin. Can be overridden for dynamic durations.
+18 -1
View File
@@ -189,7 +189,9 @@ class BasePlugin(ABC):
def update(self) -> None:
"""
Fetch/update data for this plugin.
Called based on update_interval in manifest.
Called every get_update_interval() seconds when that returns a
number, otherwise at the static interval: the manifest's
update_interval, else the plugin config's update_interval, else 60s.
"""
pass
@@ -204,6 +206,21 @@ class BasePlugin(ABC):
"""
pass
def get_update_interval(self) -> Optional[float]:
"""
Seconds until update() should run again, decided at runtime.
Return None (the default) to use the static interval.
PluginManager._get_plugin_update_interval calls this on every
scheduling tick. A number overrides the manifest and is clamped up
to PluginManager.MIN_DYNAMIC_UPDATE_INTERVAL (5s); None, a raise,
or a non-finite/non-numeric value falls back to the manifest's
update_interval, then the plugin config's update_interval, then
60s. The static value is cached until the plugin reloads; the hook
is not cached, so it must be cheap and must not raise.
"""
return None
def get_display_duration(self) -> float:
"""
Get the display duration for this plugin instance.
+5 -5
View File
@@ -10,11 +10,11 @@ This guide explains how to set up a development workflow for plugins that are ma
> scale. Existing plugins keep their classic rendering unless they adopt
> those APIs; nothing migrates automatically.
> **Just want a different look for an existing sports scoreboard?** You may
> not need a plugin at all — a **skin** restyles the live/recent/upcoming
> rendering while the plugin keeps handling data, scheduling, caching, and
> vegas mode, in ~100 lines of drawing code. See
> [CREATING_SKINS.md](CREATING_SKINS.md).
> **Want a different look for an existing sports scoreboard?** Skins are
> meant for that, but they are **not supported yet**: the current scoreboard
> plugins don't render them (see [SKIN_SYSTEM.md](SKIN_SYSTEM.md#status-not-supported-yet)).
> For now, change the look through the plugin's own display settings or its
> code.
## Overview
+135
View File
@@ -0,0 +1,135 @@
# Per-element styling for plugin authors
Users want to change the font, size and colour of individual things on screen,
nudge them a few pixels, hide the ones they do not care about, and scale a logo
down. This is the one system that does that, and a plugin joins it by
**declaring elements in its `config_schema.json`** — not by writing a style
resolver, a font cache or a web form.
The short version:
```jsonc
"customization": {
"type": "object",
"title": "Display Customization",
"x-style-elements": {
"score_text": {
"title": "Score",
"font": { "default": "PressStart2P-Regular.ttf" },
"size": { "default": 10, "min": 4, "max": 16 },
"color": { "default": [255, 255, 255] },
"offsets": true,
"visible": true,
"align": true
},
"home_logo": { "title": "Home logo", "offsets": true, "scale": true }
},
"x-style-modes": ["live", "upcoming", "recent"]
}
```
That is the whole declaration. The core expands it into a full JSON Schema, the
web UI renders a compact style editor with a row per element, and the values
land in `config.json` under the keys you named.
## What each key does
| Key | Effect |
|---|---|
| `font` | Font picker listing every shipped **and user-uploaded** font. |
| `size` | Number field. `min`/`max` also cap which fixed-size fonts are offered. |
| `color` | Colour swatch; stored as `[r, g, b]`. |
| `offsets` | X/Y nudge, stored under `customization.layout.<element>`. |
| `visible` | Show/hide toggle. |
| `align` | `left` / `center` / `right`. |
| `scale` | Size multiplier, for logos and images. Also under `layout`. |
`x-style-modes` is optional. Declare it and every element gains a per-mode
override tab — a scoreboard can then style its live, upcoming and recent cards
separately. **A mode field left blank means "inherit", not zero.**
## Reading the values
Every plugin inherits `BasePlugin.styles`, which finds your `config_schema.json`
on its own:
```python
style = self.styles.style(
"score_text",
classic_font="PressStart2P-Regular.ttf", # what you shipped
classic_size=10,
classic_color=(255, 255, 255),
)
if style.visible:
draw.text((x + style.offset[0], y + style.offset[1]),
text, font=style.font, fill=style.color)
```
For a specific mode, use `self.styles_for("recent")`, or set
`STYLE_MODE = "recent"` on the class and keep calling `self.styles`.
### The one rule that matters
**Pass your shipped values as the `classic_*` arguments.** The resolver returns
them verbatim unless the user actually changed something, which is what keeps an
untouched install rendering byte-identically. It can tell the difference because
a value only counts as user-forced when it *differs from the schema default* —
the save path writes the full default object into `config.json` on every save,
so "present in config" proves nothing.
Never compare against the default yourself; that rule lives in exactly one place.
### Stateless readers
For helpers handed a config dict rather than a plugin instance:
```python
from src.element_style import (element_color, element_visible,
element_align, element_scale, layout_offset)
colour = element_color(config, "score_text", (255, 255, 255), mode)
shown = element_visible(config, "records", True, mode)
dy = layout_offset(config, "score", "y_offset", 0, mode)
```
## Sports scoreboards
`SportsCoreSharedMixin` wires most of this up already. Two things to know:
* **Name your draws.** `_draw_text_with_outline(..., element="score_text")`
resolves the colour by name *and* honours the visibility toggle. Without it
the colour has to be guessed 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.
* **Modes are free.** Live/upcoming/recent are separate instances, so setting
`SKIN_MODE` on each is enough; no call site passes a mode.
## Adopting an existing hand-written block
If your schema already spells out `font` / `font_size` / `text_color` per
element longhand, **you do not need to change anything**. The core recognises
that shape and upgrades it in place: the style editor, the real font picker
(including uploaded fonts) and per-mode overrides all appear on a core update.
Add `x-style-modes` if you want the mode tabs.
## Fonts, and why size is sometimes locked
32 of the 35 shipped fonts are fixed-strike BDF bitmaps: they render at exactly
one pixel size and ignore `font_size`. The picker knows which, and the editor
locks the size field to the native size and labels it `fixed`. A font too tall
for the `max` you declared is not offered at all.
Uploaded fonts (Fonts tab) land in `assets/fonts/` and appear in the picker
automatically.
## Checklist
1. Declare `x-style-elements` (and `x-style-modes` if you have modes).
2. Read through `self.styles`, passing your shipped values as `classic_*`.
3. Honour `style.visible`, `style.offset` and `style.scale` where they apply.
4. Confirm an untouched config renders identically:
`python scripts/check_plugin.py --plugin <id>`.
5. Monorepo plugins: bump `manifest.json` and run `python update_registry.py`.
See also: [docs/PLUGIN_CONFIGURATION_GUIDE.md](PLUGIN_CONFIGURATION_GUIDE.md),
[docs/FONT_MANAGER.md](FONT_MANAGER.md).
+4 -2
View File
@@ -45,6 +45,8 @@ Going deeper:
- [PLUGIN_CONFIG_QUICK_START.md](PLUGIN_CONFIG_QUICK_START.md) — minimal config you need
- [PLUGIN_CONFIGURATION_GUIDE.md](PLUGIN_CONFIGURATION_GUIDE.md) — schema design
- [PLUGIN_ELEMENT_STYLING.md](PLUGIN_ELEMENT_STYLING.md) — let users restyle,
move, hide and scale individual elements (per display mode, if you have them)
- [PLUGIN_CONFIGURATION_TABS.md](PLUGIN_CONFIGURATION_TABS.md) — multi-tab UI configs
- [PLUGIN_CONFIG_ARCHITECTURE.md](PLUGIN_CONFIG_ARCHITECTURE.md) — how the config system works
- [PLUGIN_CONFIG_CORE_PROPERTIES.md](PLUGIN_CONFIG_CORE_PROPERTIES.md) — properties every plugin honors
@@ -54,8 +56,8 @@ Going deeper:
- [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) — Vegas scroll, on-demand display,
cache management, background services, permissions
- [FONT_MANAGER.md](FONT_MANAGER.md) — font system
- [SKIN_SYSTEM.md](SKIN_SYSTEM.md) — skin architecture for sports scoreboards
- [CREATING_SKINS.md](CREATING_SKINS.md) — writing and validating a skin
- [SKIN_SYSTEM.md](SKIN_SYSTEM.md) — skin architecture for sports scoreboards (not supported yet: current scoreboards don't render skins)
- [CREATING_SKINS.md](CREATING_SKINS.md) — writing and validating a skin (same caveat)
## Reference
+46 -11
View File
@@ -203,26 +203,61 @@ configured speed, so position stays proportional to real time.
## Diagnosing a juddery scroller
**`Avg FPS` will lie to you.** It is a 100-frame moving average, and a 2 ms
duplicate frame plus a 21 ms double-wait average to exactly 10 ms. A ticker
that is stalling on half its frames still reports a healthy `100.0`.
**An average will lie to you.** A 2 ms duplicate frame and a 21 ms double-wait
mean exactly 10 ms, so a ticker stalling on half its frames still averages to a
healthy 100 fps. The stats line reports the tail for that reason — read the
percentiles, not the fps.
Look at the **distribution** instead:
Every scroller emits one line every 5 seconds covering *every* frame in that
window, tagged with the plugin it came from:
```bash
journalctl -u ledmatrix --since "-10min" --no-pager \
| grep -oE "Frame time: [0-9.]+ms" | awk '{print $3}' | sed 's/ms//' \
| awk '{printf "%.0f\n", $1}' | sort -n | uniq -c
journalctl -u ledmatrix --since "-10min" --no-pager | grep "Scroll frame stats"
```
```
[Plugin: news] Scroll frame stats - 100.0 fps over 501 frames | median 10.00ms
p95 10.11ms max 12.03ms min 7.98ms | stalls 0 (0.0%) skips 0 (0.0%)
```
Reading it, on a 100 Hz panel:
| you see | it means |
|---|---|
| everything at 10 ms | healthy |
| a mode at ~2 ms | **duplicate frames** — the swap was skipped because the image did not change. The scroller is advancing less than one pixel per frame. |
| a mode at 20/30/50 ms | frames missing refreshes — per-frame work is overrunning, or a background thread is holding the GIL |
| `Avg FPS` above 100 | duplicates present, unless the scroll cycle has completed and is idling |
| median 10 ms, p95 within ~0.5 ms of it | healthy — locked to the panel |
| p95 or max at 20/30/50 ms | frames missing refreshes — per-frame work is overrunning, or a background thread is holding the GIL |
| non-zero **skips**, or a median *below* 10 ms | **duplicate frames** — the swap was skipped because the image did not change, so the frame never waited on vsync. The scroller is advancing less than one pixel per frame. |
| non-zero **stalls** | frames past 1.5× the median, which is the measure of judder that survives averaging |
`stalls` and `skips` are both counted against that window's own median, so they
stay meaningful on a panel running at any refresh rate.
To rank every scroller at once rather than reading lines one at a time:
```bash
journalctl -u ledmatrix --since "-3h" --no-pager | grep "Scroll frame stats" \
| sed -E 's/.*- (\S+) - (\[Plugin: [^]]+\] )?Scroll.*median ([0-9.]+)ms p95 ([0-9.]+)ms.*/\1 \3 \4/' \
| awk '$2 < 1000 {n[$1]++; m[$1]+=$2; p[$1]+=$3} END {for (k in n)
printf "%-28s %5d windows median %6.2fms p95 %6.2fms\n", k, n[k], m[k]/n[k], p[k]/n[k]}' \
| sort -k7 -rn
```
The `$2 < 1000` guard drops windows whose median is a whole second or more.
Those are not frames. Until the idle-gap fix in `log_frame_rate()`, the first
frame of every scroll was timed against the end of the *previous* scroll, so
the gap between them was recorded as one enormous sample — it landed in the
`max` field of otherwise healthy windows and counted as one stall per scroll,
roughly 0.2% at 500 frames to a window, which is the same order as the real
stall rates it sat beside. Current builds emit none, but the guard costs
nothing and keeps the command honest against older journals.
A scroller whose p95 sits several times its median is the one to fix, and it is
usually the one doing the most per-frame work rather than the one configured
worst. Measured over 20 minutes with two scrollers set identically at 100 px/s,
the leaderboard held 10 ms flat while the odds ticker spent ~20% of its frames
on duplicates. Same settings, different render cost: odds does more per-frame
work, and more variably, so it is first to land a frame that advances less than
a whole pixel. Check the render path before the config.
Then confirm what the plugin actually loaded — config edits do not always reach
the running code:
+48 -12
View File
@@ -1,5 +1,35 @@
# Skin System Architecture
## Status: not supported yet
**Skins don't render with the current scoreboard plugins.** The skin system
below works in isolation (it loads, validates and renders skins in
`scripts/validate_skin.py` and `test/test_skin_system.py`), but nothing on a
running display calls it:
- The only render hook is `SportsCore._render_game()` in
`src/base_classes/sports/core.py`.
- None of the current scoreboard plugins build on `src.base_classes`. The
official scoreboards in the `ledmatrix-plugins` monorepo, and the
third-party scoreboards in the plugin registry, carry their own sports and
rendering code (with the shared `src/common/sports_*` helpers) and never
reach `SportsCore._render_game()`.
So a skin can be dropped into `skins/` and named in a plugin's config, but the
scoreboard keeps drawing its built-in layout. Until a scoreboard adopts the
hook, core does not offer skins to users:
- The plugin config page shows no **Visual Skin** dropdown.
- The Plugin Store hides registry entries with `"type": "skin"` and refuses
to install one (`POST /api/v3/plugins/install` answers 400 with the reason).
- `GET /api/v3/skins` still lists what is in `skins/`, with
`"supported": false` and a `message`.
- A config that already contains `"skin"` / `"skin_options"` still loads,
validates and saves unchanged; the value is simply unused.
The rest of this document describes the design as built, for whoever wires a
scoreboard to it.
Skins are user-installable **visual overlays** for the sports scoreboards.
A skin replaces only the *look* of a scoreboard — the host plugin keeps doing
data fetching, scheduling, caching, dedup, live-priority takeover, and vegas
@@ -32,8 +62,10 @@ crashing) simply restores the built-in look.
## The render funnel
Every sports scoreboard (baseball, football, basketball, hockey — anything
built on the `src/base_classes/sports/` package, `core.py`) renders through exactly one seam:
A sports scoreboard built on the `src/base_classes/sports/` package
(`core.py`) renders through exactly one seam. No current scoreboard plugin is
built on it (see [Status](#status-not-supported-yet)), so for them this seam is
never reached:
`SportsCore._render_game(game, force_clear)`.
1. The mode class's `display()` (live, `SportsUpcoming`, `SportsRecent`)
@@ -135,22 +167,26 @@ Inside the plugin's own config section in `config/config.json`:
`"built-in"` means the stock renderer. Because this rides the plugin's config
section, it persists across plugin reinstalls like every other setting.
The web UI shows a **Visual Skin** dropdown for plugins that have matching
skins installed: `SchemaManager.inject_skin_selector` adds an enum to the
*served* schema only. Validation never sees the enum — so a config that
references an uninstalled skin stays valid (rendering just falls back), and
the currently-configured value is always kept selectable. `GET /api/v3/skins`
lists installed skins (optionally filtered by `?plugin_id=`).
`SchemaManager.inject_skin_selector` can add a **Visual Skin** enum to the
*served* schema for plugins with matching skins installed. While skins are
unsupported the plugin schema endpoint does not call it, so the dropdown is
not shown. Validation never sees the enum either way: the base schema allows
any `skin` value, so a config that references an uninstalled skin stays valid.
`GET /api/v3/skins` lists installed skins (optionally filtered by
`?plugin_id=`) and reports `"supported": false`.
## Distribution
- **Manual:** `git clone <skin repo> skins/<skin-id>` — that's the whole
install. No manifest bumps, no `update_registry.py`; skins are not monorepo
plugins.
- **Store:** registry entries with `"type": "skin"` install through the same
`plugins.json` pipeline; `PluginStoreManager` routes them to `skins/`,
validates `skin.json` (including the API major version) instead of
`manifest.json`, and never installs dependencies — skins are render-only
- **Store (disabled while unsupported):** registry entries with
`"type": "skin"` are hidden from the store list and refused on install.
`PluginStoreManager._install_skin_from_info` is kept: once
`SKINS_RENDER_SUPPORTED` in `src/skin_system/__init__.py` is true, such
entries install through the same `plugins.json` pipeline, land in `skins/`,
are validated against `skin.json` (including the API major version) instead
of `manifest.json`, and never install dependencies — skins are render-only
(stdlib + PIL + the provided context, no third-party packages in v1).
## Trust model
+27
View File
@@ -96,6 +96,33 @@ Configure basic system settings:
- **Plugin System Settings** — including the `plugins_directory` (default
`plugin-repos/`) used by the plugin loader
- **Autostart** options for the display service
- **Automatic updates** — once a week, update LEDMatrix and every installed
plugin with a newer version. Off by default. Runs 2–5 AM local time when
possible, otherwise within a day of being due. The last result and next
check are shown under the toggle, and anything other than success raises a
banner on **Overview**.
- *Checks first:* the code update is skipped, with the reason shown, if
tracked files were edited locally, the checkout has local commits, a
rebase/merge is in progress, the branch has no upstream, less than 300 MB
is free, or the newest version already failed once. A failed fetch is
retried the next day.
- *Health check and rollback:* after pulling, `ledmatrix-update-verify.service`
restarts the services and checks that the web interface responds and the
display (if it was running) stays up. If not — or if the new dependencies
failed to install — it resets to the previous commit, reinstalls the
previous dependencies and restarts again. A running display is restarted;
a stopped one stays stopped.
- *Plugins* update through the Plugin Store, which refuses versions that need
a newer LEDMatrix and restores the old copy when an install fails. When the
code changed, plugins wait until it passes its health check. Plugins are
not health-checked after updating.
- *Setup needs no SSH.* Turning the toggle on restarts the display service,
which installs the health check (`ledmatrix-update-verify.path` and
`.service`); the General tab shows when it is ready, or why setup failed.
Until then only plugins update. New installs set it up during
installation and can switch updates on with
`first_time_install.sh --enable-auto-update` (or `LEDMATRIX_AUTO_UPDATE=1`,
which `one-shot-install.sh` passes through), or at the installer's prompt.
Click **Save** to write changes to `config/config.json`. Most changes
require a display service restart from **Overview**.
+164
View File
@@ -0,0 +1,164 @@
# Web UI Technical Audit — September 2026
Scope: `web_interface/` (Flask + HTMX + Alpine, `templates/v3/`, `static/v3/`).
Method: Impeccable design detector, code review (accessibility; performance,
theming, responsive), and a live pass on the running app at desktop and
375px mobile widths in light and dark themes. Severe claims were verified
against the live page; one was rejected (see below). No code was changed.
Product context: see [`PRODUCT.md`](../../PRODUCT.md).
## Health score: 8/20 (Poor)
| # | Dimension | Score | Key finding |
|---|-----------|-------|-------------|
| 1 | Accessibility | 2 | Focus rings never render; modals have no dialog semantics or focus management |
| 2 | Performance | 2 | ~1.2 MB JS (≈250 KB gzip) on every page; SSE streams and polling never pause |
| 3 | Responsive | 2 | Mobile drawer works; header title wraps to 3 lines; many ~24px touch targets |
| 4 | Theming | 1 | Tokens exist but hex dominates; dark mode is a class-by-class patch with leaks |
| 5 | Implementation integrity | 1 | Templates use Tailwind classes that don't exist in the stylesheet |
## Implementation integrity verdict: fail
There is no Tailwind build. `static/v3/app.css` is a hand-written subset of
Tailwind, while templates and JS are authored as if full Tailwind were loaded.
- **333 of 516 utility class names used have no CSS rule** (2,582 uses),
confirmed against the live stylesheets. Top offenders: `border` (250),
`text-gray-700` (183), `block` (148), `mr-1` (127), `hidden` (79),
`py-1`, `text-blue-600`, `px-2`, `text-center`, `hover:bg-blue-700`,
`divide-y`, `uppercase`, `font-mono`.
- **`.hidden` has never existed in `app.css`**, so the 145
`classList.add/remove/toggle('hidden')` calls across 26 files do nothing.
Visible proof: the header shows both the moon and sun theme icons.
- **15 classes are defined only under `[data-theme="dark"]`** (e.g.
`bg-blue-50`, `bg-red-50`, `bg-yellow-50`, `border-blue-200`,
`text-red-700`), so tinted notice boxes are unstyled in light mode.
- Visible damage: the Getting Started checklist (`partials/overview.html:96-117`)
renders native gray outset buttons in both themes; 32 visible buttons on
the Plugin Manager page render with default browser chrome; search icons
overlap inputs; error/diff modal backdrops are transparent
(`bg-gray-500 bg-opacity-75` undefined).
Other drift:
- Four competing `showNotification` definitions (`app.js:6`,
`app-shell.js:2464`, `widgets/notification.js:298`, `partials/fonts.html:249`)
— the winner depends on load order — plus 53 `alert()`/`confirm()` calls.
- At least four modal implementations (on-demand modal in `base.html:1032`,
Tailwind-UI style in `error_handler.js`/`diff_viewer.js`, `.jfm-*`/`.pfm-*`
with injected CSS, ad-hoc modals in `plugins_manager.js`).
- `.btn` mixed with ~90 hand-assembled color-utility button combos.
- SSE wiring duplicated in `app-shell.js:5-60` and `app.js:186-205`.
## Findings by severity
### P0
**Undefined utility layer** (above). Every show/hide toggle and every layout
built from missing classes silently fails; root cause of most visual bugs.
Fix: replace the hand-rolled subset with a real, purged Tailwind build
(with dark-mode variants), or at minimum define the high-use missing classes
(`hidden`, `border`, `block`, spacing/text utilities) and a button reset.
→ `/impeccable harden`
### P1
- **Focus rings never render.** `focus:ring-2` (`app.css:289-291`) references
`--tw-ring-inset` and `--tw-ring-offset-width`, which are never defined, so
the `box-shadow` is invalid. `focus:outline-none` (18 uses) does remove the
outline. `peer-focus:ring-4` has no rule, so the plugin enable toggle
(`plugins_manager.js:1586-1593`, `sr-only` checkbox) shows no focus.
WCAG 2.4.7.
- **Modals lack dialog semantics.** Only `json-file-manager.js` has
`role="dialog"`/`aria-modal`/Escape/initial focus; none trap focus or
return it. On-demand (`base.html:1032`), error (`error_handler.js:164-205`),
diff (`diff_viewer.js:211-214`), plugin file manager
(`plugin-file-manager.js:372-392, 576-599`), array-table editor
(`array-table.js:460-467`) have none of it. WCAG 2.1.2 / 4.1.2.
- **Unnamed controls.** ~16 icon-only buttons with no accessible name, e.g.
`base.html:1036`, `plugins.html:173,210`, `plugin_config.html:485,656`,
`number-input.js:102,129`, `text-input.js:120`, `date-picker.js:95`,
`time-picker.js:100`, `password-input.js:141`. ~115 of 245 form fields have
no label (hotspots: `plugin_config.html` 19, `starlark_config.html` 14,
`plugins.html` 11); confirmed live on the 11 store search/sort/filter inputs.
- **Captive WiFi setup page** (first-run surface): `#msg` status has no live
region, and `outline:none` is replaced by a 15%-alpha shadow
(`captive_setup.html:16,48`).
- **Background traffic never stops.** `/stream/stats` and `/stream/display`
SSE stay open on every tab (display frames push with no preview visible).
Tab timers keep running after leaving the tab (`display.html:1046` 5s,
`logs.html:222` 5s, `tools.html:987` 15s, `plugins_manager.js:1882` 15s,
update check `base.html:1196` 30min). Only `tools.html:999` checks
`visibilitychange`. Costly on a Pi Zero 2 W.
- **Page weight.** 47 script tags on every page, including all 33 widgets
(`base.html:984-1018`). `app-shell.js` (177 KB) is render-blocking
(`base.html:956`); `plugins_manager.js` is 277 KB.
- **Dark mode leaks.** `plugin-file-manager.js` (53 hex) and
`json-file-manager.js` (63 hex, e.g. `.jfm-modal-box{background:#fff}`)
inject CSS that ignores `data-theme`; `.form-control` hard-codes
`#fff`/`#111827` (`app.css:668-671`). `app.css` has 186 hex + 46 rgb
literals vs 94 `var(--…)` uses.
### P2
- Toasts: `role="alert"` inside an `aria-live="polite"` container
(`notification.js:78,156`) → double/assertive announcements; auto-dismiss 4s.
- `prefers-reduced-motion` covers 3 animations; ~106 `animate-pulse`/`fa-spin`
uses, `modalSlideIn`, and toast slides ignore it.
- Mobile: header title wraps to three lines and spills out of the header;
~33 plugin-card buttons are `text-xs px-2 py-1` (~24px); `#logs-container`
forced to 400/350px with `!important` (`app.css:399-411`).
- Logs panel contrast: `text-gray-400` on `bg-gray-900` ≈ 3.9:1
(`logs.html:75,86`).
- Three unnamed nested `<nav>` landmarks (`base.html:474,477,548`); no skip link.
- Plugin lists fully rebuilt via `innerHTML` on every filter change
(`plugins_manager.js:1554, 3784, 3993, 4389, 5905`); `logs.html:225` adds a
reflow-forcing resize listener on every partial load.
### P3
- No `loading="lazy"` on images; Font Awesome `font-display:block`.
- Unpinned `alpinejs@3.x.x` unpkg fallback (`base.html:241`).
- `widgets/example-color-picker.js` is not loaded anywhere.
- Detector: 3px accent stripe on `.plugin-card::before` (`app.css:721`).
## Verified and rejected
- **"Static assets are never cache-busted" (raised as P0): false.**
`app.py:491` (`@app.url_defaults add_static_version`) appends file mtime as
`?v=` to every static URL; the live HTML confirms it. The manual
`?v=20260307` on two script tags is merely redundant.
- Light-mode gray text contrast is mostly fine: `app.css` remaps grays darker
(4.8–10:1).
- Detector `gray-on-color` hits at `app.css:84,285` and `broken-image` hits
(JS-populated `src`) are not real rendered issues.
## What works
- Theme set before first paint, follows OS preference, `data-theme` + tokens.
- Mobile drawer: Escape closes it, focus returns to the hamburger, 44px rows.
- `aria-current="page"` on nav tabs; real `<header>` and `<main>`.
- Status colors always paired with text; nearly all images have alt text.
- `toggle-switch.js` uses `role="switch"`; vendor assets self-hosted.
## Open decisions (block the P0 fix approach)
Recorded as undecided in `PRODUCT.md`:
- Must the UI work fully offline (no CDN fallbacks)?
- Is a Node/CSS build step acceptable for contributors?
- Is WCAG 2.2 AA a formal requirement?
## Recommended order
1. **[P0] `/impeccable harden`** — fix the utility layer (real Tailwind build
or define missing classes + button reset).
2. **[P1] `/impeccable harden`** — focus-ring variables and `peer-focus`;
one shared accessible modal helper; name icon buttons and label fields;
live region on the captive page.
3. **[P1] `/impeccable optimize`** — pause SSE/timers on hidden tab or page;
load widget scripts on demand.
4. **[P1] `/impeccable colorize`** — move file-manager CSS and `.form-control`
onto theme tokens.
5. **[P2] `/impeccable adapt`** — header wrap, touch targets, log height.
6. **[P2] `/impeccable animate`** — reduced-motion alternatives.
7. **`/impeccable polish`** — final pass.
+51 -11
View File
@@ -285,7 +285,9 @@ Guidelines:
### Step 1: Create Widget File
Create a JavaScript file in your plugin directory. The recommended location is `widgets/[widget-name].js`:
Create a JavaScript file in your plugin's `widgets/` directory, named
`widgets/[widget-name].js`. The directory is not optional: it is the only
place the core will serve a widget from.
```javascript
// Ensure LEDMatrixWidgets registry is available
@@ -366,7 +368,29 @@ window.LEDMatrixWidgets.register('my-custom-widget', {
});
```
### Step 2: Reference Widget in Schema
### Step 2: Declare the Widget in `manifest.json`
The manifest is the allowlist. A widget is served only if the plugin declares
it, so shipping a file under `widgets/` does not by itself publish it:
```json
{
"widgets": [
{
"name": "my-custom-widget",
"script": "my-custom-widget.js",
"description": "What this widget is for"
}
]
}
```
`name` is what you use in `x-widget` and in the URL. `script` is optional and
defaults to `[name].js`; it must be a plain filename directly inside
`widgets/` (no paths). Both are validated against
`schema/manifest_schema.json`.
### Step 3: Reference Widget in Schema
In your plugin's `config_schema.json`:
@@ -383,15 +407,30 @@ In your plugin's `config_schema.json`:
}
```
### Step 3: Widget Loading
### Step 4: Widget Loading
The widget will be automatically loaded when the plugin configuration form is rendered. The system will:
The widget is loaded on demand when the plugin's configuration form renders a
field that references it. The system will:
1. Check if widget is registered in the core registry
2. If not found, attempt to load from plugin directory: `/static/plugin-widgets/[plugin-id]/[widget-name].js`
3. Render the widget using the registered `render` function
1. Check whether the widget is already registered in the core registry.
2. If not, fetch it from `/static/plugin-widgets/[plugin-id]/[widget-name].js`.
That route serves the declared `script` from your plugin's `widgets/`
directory, as `text/javascript`.
3. Render it by calling the `render` function your script registered.
**Note:** Currently, widgets are server-side rendered via Jinja2 templates. Custom widgets registered via the registry will have their handlers available, but full client-side rendering is a future enhancement.
The fetch uses a dynamic `import()`, so the file must parse as an ES module.
A plain IIFE does — modules are strict mode, so avoid sloppy-mode constructs.
**If the widget fails to load** (not declared, file missing, script throws, or
it never calls `register`), the field falls back to a plain text input holding
the current value. This is deliberate: a broken widget costs the user an
editor, not their configured value.
**Limitation:** the on-demand path applies to `string`-typed fields (the
default branch of the config-form renderer). Fields typed `object`, `array`,
`boolean`, `integer` or `number`, and fields whose `enum` is set, are
dispatched by the server-side template to its own built-in renderers, so a
plugin-supplied `x-widget` on one of those is ignored today.
## Widget API Reference
@@ -497,10 +536,11 @@ See [`web_interface/static/v3/js/widgets/example-color-picker.js`](../web_interf
- ✅ Plugin widget loading system implemented
**Current Behavior:**
- Widgets are server-side rendered via Jinja2 templates (existing behavior preserved)
- Core widgets are server-side rendered via Jinja2 templates (existing behavior preserved)
- Widget handlers are registered and available globally
- Custom widgets can be created and registered
- Full client-side rendering is a future enhancement
- Custom widgets can be created, declared in `manifest.json`, and are served
and rendered on demand for `string`-typed fields
- Plugin widgets on non-string fields are not dispatched yet (see Step 4)
**Backwards Compatibility:**
- All existing plugins using widgets continue to work without changes
+142 -6
View File
@@ -125,6 +125,72 @@ fi
# Get the home directory of the actual user
USER_HOME=$(eval echo ~$ACTUAL_USER)
# --- rpi-rgb-led-matrix checkout helpers -------------------------------------
# Run git as whoever owns the project directory. Run as root against a
# user-owned repo, git refuses it ("dubious ownership"), and anything it does
# create — such as .git/modules/<submodule> — ends up root-owned, locking the
# user out of their own checkout. A root-owned install keeps running as root.
_rgb_repo_owner() {
stat -c %U "$PROJECT_ROOT_DIR" 2>/dev/null || echo root
}
_git_as_repo_owner() {
local owner
owner=$(_rgb_repo_owner)
if [ "$(id -u)" = "0" ] && [ "$owner" != "root" ] && command -v sudo >/dev/null 2>&1; then
sudo -u "$owner" -H git "$@"
else
git "$@"
fi
}
# Earlier installer versions ran the submodule git commands as root, leaving
# root-owned files the repo owner (and so _git_as_repo_owner) cannot write.
_reclaim_rgb_checkout() {
local owner path
owner=$(_rgb_repo_owner)
if [ "$(id -u)" != "0" ] || [ "$owner" = "root" ]; then
return 0
fi
for path in "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" "$PROJECT_ROOT_DIR/.git/modules/rpi-rgb-led-matrix-master"; do
if [ -e "$path" ]; then
chown -R "$owner:" "$path" 2>/dev/null || true
fi
done
}
# `git pull` on the main repo never moves an existing submodule checkout, so a
# submodule bump (e.g. the ARMv6 build fix for Pi Zero/1) would never reach a
# device installed before it. Move the checkout forward to the pinned commit —
# but never backward or sideways: a user who ran `git submodule update --remote`
# is newer than the pin and is left alone. Never fatal.
_sync_rgb_submodule() {
local sub="$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" pinned current
if [ ! -f "$PROJECT_ROOT_DIR/.gitmodules" ] || ! grep -q "rpi-rgb-led-matrix" "$PROJECT_ROOT_DIR/.gitmodules" \
|| [ ! -e "$sub/.git" ]; then
return 0
fi
if ! pinned=$(_git_as_repo_owner -C "$PROJECT_ROOT_DIR" rev-parse "HEAD:rpi-rgb-led-matrix-master" 2>/dev/null) \
|| [ -z "$pinned" ]; then
return 0
fi
current=$(_git_as_repo_owner -C "$sub" rev-parse HEAD 2>/dev/null) || current=""
if [ "$current" = "$pinned" ]; then
return 0
fi
if [ -n "$current" ] && _git_as_repo_owner -C "$sub" cat-file -e "${pinned}^{commit}" 2>/dev/null \
&& ! _git_as_repo_owner -C "$sub" merge-base --is-ancestor "$current" "$pinned" 2>/dev/null; then
echo "rpi-rgb-led-matrix-master is at ${current:0:7}, not behind the pinned ${pinned:0:7}; leaving it as is"
return 0
fi
echo "Updating rpi-rgb-led-matrix-master to the pinned commit ${pinned:0:7}..."
if ! _git_as_repo_owner -C "$PROJECT_ROOT_DIR" submodule update --init --recursive rpi-rgb-led-matrix-master; then
echo "⚠ Could not update rpi-rgb-led-matrix-master to the pinned commit; building the existing checkout"
fi
return 0
}
# --- end rpi-rgb-led-matrix checkout helpers ---------------------------------
# Determine the Project Root Directory (where this script is located)
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")" && pwd)
@@ -154,6 +220,8 @@ SKIP_PERF=${LEDMATRIX_SKIP_PERF:-0}
SKIP_REBOOT_PROMPT=${LEDMATRIX_SKIP_REBOOT_PROMPT:-0}
SKIP_SWAP=${LEDMATRIX_SKIP_SWAP:-0}
BUILD_JOBS_OVERRIDE=${LEDMATRIX_BUILD_JOBS:-}
# Weekly automatic updates: 1 on, 0 off, empty = ask (interactive) or leave as is.
AUTO_UPDATE=${LEDMATRIX_AUTO_UPDATE:-}
usage() {
cat <<USAGE
@@ -168,12 +236,15 @@ Options:
--skip-swap Never add temporary swap for the C++ build
--build-jobs N Compile the C++ library with N parallel jobs
(default: scaled to available RAM)
--enable-auto-update Turn on weekly automatic updates (with health
check and automatic rollback)
--no-auto-update Leave weekly automatic updates off
-h, --help Show this help message and exit
Environment variables (same effect as flags):
LEDMATRIX_ASSUME_YES=1, RPI_RGB_FORCE_REBUILD=1, LEDMATRIX_SKIP_SOUND=1,
LEDMATRIX_SKIP_PERF=1, LEDMATRIX_SKIP_REBOOT_PROMPT=1,
LEDMATRIX_SKIP_SWAP=1, LEDMATRIX_BUILD_JOBS=N
LEDMATRIX_SKIP_SWAP=1, LEDMATRIX_BUILD_JOBS=N, LEDMATRIX_AUTO_UPDATE=1|0
Low-memory devices:
On a Pi with under 2GB of RAM the C++ build is limited to fewer parallel
@@ -191,6 +262,8 @@ while [ $# -gt 0 ]; do
--skip-perf) SKIP_PERF=1 ;;
--no-reboot-prompt) SKIP_REBOOT_PROMPT=1 ;;
--skip-swap) SKIP_SWAP=1 ;;
--enable-auto-update) AUTO_UPDATE=1 ;;
--no-auto-update) AUTO_UPDATE=0 ;;
--build-jobs)
shift
if [ $# -eq 0 ]; then echo "--build-jobs requires a number"; usage; exit 1; fi
@@ -797,6 +870,50 @@ else
echo "✓ Main config file already exists"
fi
# Weekly automatic updates (General tab -> Automatic Updates). Off unless asked
# for: --enable-auto-update / LEDMATRIX_AUTO_UPDATE=1, or "y" at the prompt when
# installing interactively. Only an explicit choice changes the setting, so
# re-running the installer with -y never switches it silently.
if [ -z "$AUTO_UPDATE" ] && [ "$ASSUME_YES" != "1" ] && [ -t 0 ]; then
read -p "Automatically check for and install LEDMatrix updates once a week, with automatic rollback if an update breaks something? (y/N): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then AUTO_UPDATE=1; else AUTO_UPDATE=0; fi
fi
if [ "$AUTO_UPDATE" = "1" ] || [ "$AUTO_UPDATE" = "0" ]; then
if python3 - "$PROJECT_ROOT_DIR/config/config.json" "$AUTO_UPDATE" <<'PY'
import json, os, sys, tempfile
path, enabled = sys.argv[1], sys.argv[2] == "1"
with open(path, encoding="utf-8") as f:
config = json.load(f)
if not isinstance(config.get("auto_update"), dict):
config["auto_update"] = {}
config["auto_update"]["enabled"] = enabled
# Written beside the original and swapped in whole: the display service's
# config watcher may be running and must never read a half-written file.
original = os.stat(path)
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(os.path.abspath(path)), prefix=".config.")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(config, f, indent=4)
f.write("\n")
f.flush()
os.fsync(f.fileno())
os.chmod(tmp, original.st_mode & 0o777)
if hasattr(os, "chown"):
os.chown(tmp, original.st_uid, original.st_gid)
os.replace(tmp, path)
except BaseException:
if os.path.exists(tmp):
os.unlink(tmp)
raise
PY
then
if [ "$AUTO_UPDATE" = "1" ]; then echo "✓ Weekly automatic updates enabled"; else echo "✓ Weekly automatic updates off"; fi
else
echo "⚠ Could not set auto_update in config/config.json; turn it on from the General tab instead"
fi
fi
# Create config_secrets.json from template if missing
if [ ! -f "$PROJECT_ROOT_DIR/config/config_secrets.json" ]; then
if [ -f "$PROJECT_ROOT_DIR/config/config_secrets.template.json" ]; then
@@ -1038,8 +1155,9 @@ else
# so git clone doesn't fail with "destination path already exists".
_clone_rpi_rgb() {
rm -rf "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master"
git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
_git_as_repo_owner clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
}
_reclaim_rgb_checkout
if [ ! -d "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" ]; then
echo "rpi-rgb-led-matrix-master not found. Initializing git submodule..."
cd "$PROJECT_ROOT_DIR"
@@ -1047,7 +1165,7 @@ else
# Try to initialize submodule if .gitmodules exists
if [ -f "$PROJECT_ROOT_DIR/.gitmodules" ] && grep -q "rpi-rgb-led-matrix" "$PROJECT_ROOT_DIR/.gitmodules"; then
echo "Initializing rpi-rgb-led-matrix submodule..."
if ! retry git submodule update --init --recursive rpi-rgb-led-matrix-master; then
if ! retry _git_as_repo_owner submodule update --init --recursive rpi-rgb-led-matrix-master; then
echo "⚠ Submodule init failed, cloning directly from GitHub..."
retry _clone_rpi_rgb
fi
@@ -1066,12 +1184,14 @@ else
cd "$PROJECT_ROOT_DIR"
rm -rf rpi-rgb-led-matrix-master
if [ -f "$PROJECT_ROOT_DIR/.gitmodules" ] && grep -q "rpi-rgb-led-matrix" "$PROJECT_ROOT_DIR/.gitmodules"; then
retry git submodule update --init --recursive rpi-rgb-led-matrix-master
retry _git_as_repo_owner submodule update --init --recursive rpi-rgb-led-matrix-master
else
retry _clone_rpi_rgb
fi
fi
_sync_rgb_submodule
# Add temporary swap on low-memory devices so the compiler survives.
CURRENT_STEP="Prepare the low-memory build environment"
if [ "$LOWMEM_AVAILABLE" = "1" ] && [ "$SKIP_SWAP" != "1" ]; then
@@ -1272,7 +1392,7 @@ if [ -f "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh" ]; then
fi
fi
if [ ! -f "/etc/systemd/system/ledmatrix-web.service" ] || [ "$NEEDS_UPDATE" = true ]; then
if [ ! -f "/etc/systemd/system/ledmatrix-web.service" ] || [ ! -f "/etc/systemd/system/ledmatrix-update-verify.path" ] || [ "$NEEDS_UPDATE" = true ]; then
bash "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh"
# Ensure systemd sees any new/changed unit files
systemctl daemon-reload || true
@@ -1288,7 +1408,7 @@ echo ""
CURRENT_STEP="Harden systemd unit file permissions"
echo "Step 8.1: Setting systemd unit file permissions..."
echo "-----------------------------------------------"
for unit in "/etc/systemd/system/ledmatrix.service" "/etc/systemd/system/ledmatrix-web.service" "/etc/systemd/system/ledmatrix-wifi-monitor.service"; do
for unit in "/etc/systemd/system/ledmatrix.service" "/etc/systemd/system/ledmatrix-web.service" "/etc/systemd/system/ledmatrix-wifi-monitor.service" "/etc/systemd/system/ledmatrix-update-verify.service" "/etc/systemd/system/ledmatrix-update-verify.path"; do
if [ -f "$unit" ]; then
chown root:root "$unit" || true
chmod 644 "$unit" || true
@@ -1416,6 +1536,9 @@ $ACTUAL_USER ALL=(ALL) NOPASSWD: $PYTHON_PATH $PROJECT_ROOT_DIR/display_controll
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/start_display.sh
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/stop_display.sh
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/scripts/fix_perms/safe_plugin_rm.sh *
# Install a requirements.txt as root via vetted helper, so packages are visible
# to root-run ledmatrix.service (not just the web interface's own user).
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/scripts/fix_perms/safe_pip_install.sh *
EOF
if [ -n "$JOURNALCTL_PATH" ]; then
cat >> /tmp/ledmatrix_web_sudoers << EOF
@@ -1623,6 +1746,19 @@ chmod 755 "$PROJECT_ROOT_DIR/scripts/install/install_service.sh" "$PROJECT_ROOT_
# Re-apply special permissions for config directory (lost during normalization)
chmod 2775 "$PROJECT_ROOT_DIR/config" || true
# Harden the sudo-granted helper scripts: root-owned, not writable by the web
# user (matches scripts/install/configure_web_sudo.sh). The sudoers rules in
# Step 10 run these as root, so a user-owned copy is a root shell for whoever
# can edit it. This must come after Step 11's project-wide chown to
# $ACTUAL_USER, which would otherwise hand them straight back.
for helper in safe_plugin_rm.sh safe_pip_install.sh; do
HELPER_PATH="$PROJECT_ROOT_DIR/scripts/fix_perms/$helper"
if [ -f "$HELPER_PATH" ]; then
chown root:root "$HELPER_PATH" || echo "⚠ Could not set ownership on $HELPER_PATH"
chmod 755 "$HELPER_PATH" || echo "⚠ Could not set permissions on $HELPER_PATH"
fi
done
echo "✓ Project file permissions normalized"
echo ""
+8 -4
View File
@@ -43,10 +43,14 @@ packaging>=23.0,<27.0
# full feature set, or skip them for a minimal install.
# ───────────────────────────────────────────────────────────────────────
#
# scipy — sub-pixel interpolation in
# src/common/scroll_helper.py for smoother
# scrolling. Falls back to a simpler shift algorithm.
# pip install 'scipy>=1.10.0,<2.0.0'
# scipy — nothing, as of #570. It was listed for the sub-pixel
# interpolation path in src/common/scroll_helper.py, but
# get_visible_portion never consulted HAS_SCIPY, so that
# path was dead before it was deleted. The blend that
# replaced it is numpy-only. Do not install it expecting
# smoother scrolling: sub-pixel blending is off by default
# because it reads worse on a coarse panel, not because it
# is missing a library. See docs/SCROLL_PERFORMANCE.md.
#
# psutil — per-plugin resource monitoring in
# src/plugin_system/resource_monitor.py. The monitor
+24
View File
@@ -257,6 +257,30 @@
},
"description": "Web UI action definitions"
},
"widgets": {
"type": "array",
"description": "Custom web-UI widgets this plugin provides. Each entry is served at /static/plugin-widgets/<plugin-id>/<name>.js from the plugin's widgets/ directory; only declared widgets are served. Reference one from config_schema.json with \"x-widget\": \"<name>\".",
"items": {
"type": "object",
"required": ["name"],
"properties": {
"name": {
"type": "string",
"pattern": "^[a-zA-Z0-9_-]{1,64}$",
"description": "Widget name, as used in x-widget and in the URL."
},
"script": {
"type": "string",
"pattern": "^[a-zA-Z0-9_-]{1,64}\\.js$",
"description": "Filename inside widgets/. Defaults to <name>.js."
},
"description": {
"type": "string",
"description": "Human-readable summary shown to plugin authors."
}
}
}
},
"ledmatrix_version": {
"type": "string",
"description": "Deprecated: Use compatible_versions instead. LEDMatrix version this plugin targets"
+30 -2
View File
@@ -35,6 +35,31 @@ sys.path.insert(0, str(PROJECT_ROOT))
os.environ['EMULATOR'] = 'true'
def _make_output_encoding_safe() -> None:
"""Stop an unencodable character from killing the run.
This script's own report is ASCII, but it echoes text it does not control
-- plugin ids, mode names and exception messages -- and a Windows console
is cp1252, which cannot encode most of what a plugin might put there. The
default 'strict' error handler turns that into a UnicodeEncodeError from
inside `print`, so a rendering run that had already succeeded exited
non-zero with a traceback instead of printing its results.
'replace' degrades the offending character to '?' and keeps going; the
encoding itself is left alone so output still matches the terminal.
"""
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(errors='replace')
except (AttributeError, ValueError, OSError):
# Not a reconfigurable TextIOWrapper (redirected, wrapped by a
# test harness). Nothing to do -- this is best-effort hardening.
pass
_make_output_encoding_safe()
from src.logging_config import get_logger # noqa: E402
from src.plugin_system.testing.loading import ( # noqa: E402
build_full_config, find_plugin_dir, load_harness_spec, load_manifest,
@@ -53,6 +78,9 @@ logger = get_logger("[Check Plugin]")
DEFAULT_SEARCH_DIRS = [
str(PROJECT_ROOT / 'plugins'),
str(PROJECT_ROOT / 'plugin-repos'),
# The scoreboards live in the sibling ledmatrix-plugins checkout, not
# in this repo. Without this, --all silently skips every one of them.
str(PROJECT_ROOT.parent / 'ledmatrix-plugins' / 'plugins'),
]
@@ -178,7 +206,7 @@ def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
status = "PASS"
detail = ""
if r.golden_checked:
detail = " (golden ✓)"
detail = " (golden ok)"
if r.update_error is not None:
detail += f" (update warn: {r.update_error})"
if r.fill_checked and r.fill_ok is None and r.fill_extent:
@@ -196,7 +224,7 @@ def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
status, detail = "FAIL", f" overflow bbox={r.overflow}"
elif r.golden_ok is False:
status = "FAIL"
detail = f" golden drift: {r.golden_diff_pixels}px (max Δ={r.golden_max_delta})"
detail = f" golden drift: {r.golden_diff_pixels}px (max delta={r.golden_max_delta})"
elif r.fill_ok is False:
ex, ey = r.fill_extent or (0.0, 0.0)
status = "FAIL"
+2 -6
View File
@@ -72,12 +72,8 @@ def load_main_config(path: Path) -> Dict[str, Any]:
def display_size_from_config(config: Dict[str, Any]) -> tuple:
"""Derive the logical ticker size the way DisplayManager does."""
hw = config.get('display', {}).get('hardware', {})
cols = int(hw.get('cols', 64))
chain = int(hw.get('chain_length', 1))
rows = int(hw.get('rows', 32))
parallel = int(hw.get('parallel', 1))
return cols * chain, rows * parallel
from src.display_geometry import logical_size
return logical_size(config)
def enabled_plugin_ids(config: Dict[str, Any]) -> List[str]:
+18
View File
@@ -95,6 +95,19 @@ else
exit 1
fi
# Health check / rollback units for automatic updates; see install_web_service.sh.
for VERIFY_UNIT in ledmatrix-update-verify.service ledmatrix-update-verify.path; do
if [ -f "$PROJECT_ROOT_DIR/systemd/$VERIFY_UNIT" ]; then
VERIFY_UNIT_TMP=$(mktemp)
if sed "s|__PROJECT_ROOT_DIR__|$ESCAPED_PROJECT_ROOT_DIR|g; s|__USER__|$ESCAPED_ACTUAL_USER|g" "$PROJECT_ROOT_DIR/systemd/$VERIFY_UNIT" > "$VERIFY_UNIT_TMP"; then
sudo cp "$VERIFY_UNIT_TMP" "/etc/systemd/system/$VERIFY_UNIT"
else
echo "WARNING: failed to render $VERIFY_UNIT; automatic code updates will stay paused." >&2
fi
rm -f "$VERIFY_UNIT_TMP"
fi
done
echo "Reloading systemd daemon for web service..."
sudo systemctl daemon-reload
@@ -102,6 +115,11 @@ if [ -f "/etc/systemd/system/ledmatrix-web.service" ]; then
echo "Enabling ledmatrix-web.service to start on boot..."
sudo systemctl enable ledmatrix-web.service
if [ -f /etc/systemd/system/ledmatrix-update-verify.path ]; then
echo "Enabling ledmatrix-update-verify.path (automatic update health check)..."
sudo systemctl enable --now ledmatrix-update-verify.path || echo "WARNING: could not enable ledmatrix-update-verify.path; automatic code updates will stay paused" >&2
fi
echo "Starting ledmatrix-web.service..."
sudo systemctl start ledmatrix-web.service
+23
View File
@@ -46,6 +46,22 @@ ESCAPED_ACTUAL_USER=$(sed_escape_replacement "$ACTUAL_USER")
sed "s|__PROJECT_ROOT_DIR__|$ESCAPED_PROJECT_ROOT_DIR|g; s|__USER__|$ESCAPED_ACTUAL_USER|g" \
"$TEMPLATE" > /etc/systemd/system/ledmatrix-web.service
# Health check and rollback for the web UI's automatic updates. Its own unit so
# it survives the web service restart it performs; never enabled -- the web
# interface starts it after an update. Without it, automatic code updates
# stay paused rather than running with nothing to undo them.
for VERIFY_UNIT in ledmatrix-update-verify.service ledmatrix-update-verify.path; do
VERIFY_TEMPLATE="$PROJECT_ROOT_DIR/systemd/$VERIFY_UNIT"
if [ -f "$VERIFY_TEMPLATE" ]; then
echo "Writing unit file to /etc/systemd/system/$VERIFY_UNIT"
sed "s|__PROJECT_ROOT_DIR__|$ESCAPED_PROJECT_ROOT_DIR|g; s|__USER__|$ESCAPED_ACTUAL_USER|g" \
"$VERIFY_TEMPLATE" > "/etc/systemd/system/$VERIFY_UNIT"
chmod 644 "/etc/systemd/system/$VERIFY_UNIT"
else
echo "WARNING: $VERIFY_TEMPLATE not found; automatic code updates will stay paused"
fi
done
# Ensure cache directory exists with proper permissions
# This is a fallback for older systemd versions that don't support CacheDirectory
# Systemd 239+ will automatically create it via CacheDirectory directive
@@ -80,6 +96,13 @@ systemctl daemon-reload
echo "Enabling ledmatrix-web.service..."
systemctl enable ledmatrix-web.service
# The path unit is what starts the health check after an automatic update.
if [ -f /etc/systemd/system/ledmatrix-update-verify.path ]; then
echo "Enabling ledmatrix-update-verify.path..."
systemctl enable --now ledmatrix-update-verify.path || \
echo "WARNING: could not enable ledmatrix-update-verify.path; automatic code updates will stay paused"
fi
# Start the service
echo "Starting ledmatrix-web.service..."
systemctl start ledmatrix-web.service
+1
View File
@@ -408,6 +408,7 @@ main() {
# which would silently reinstate the duplicate apt update.
sudo -E env TMPDIR=/tmp LEDMATRIX_ASSUME_YES=1 \
LEDMATRIX_APT_UPDATED="${LEDMATRIX_APT_UPDATED:-0}" \
LEDMATRIX_AUTO_UPDATE="${LEDMATRIX_AUTO_UPDATE:-}" \
bash ./first_time_install.sh -y </dev/null
fi
INSTALL_EXIT_CODE=$?
+7 -4
View File
@@ -59,10 +59,13 @@ def build_options(hardware, refresh_override=None):
from rgbmatrix import RGBMatrixOptions
o = RGBMatrixOptions()
o.rows = int(hardware.get("rows", 32))
o.cols = int(hardware.get("cols", 64))
o.chain_length = int(hardware.get("chain_length", 1))
o.parallel = int(hardware.get("parallel", 1))
from src.display_geometry import (
DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_PARALLEL, DEFAULT_ROWS,
)
o.rows = int(hardware.get("rows", DEFAULT_ROWS))
o.cols = int(hardware.get("cols", DEFAULT_COLS))
o.chain_length = int(hardware.get("chain_length", DEFAULT_CHAIN_LENGTH))
o.parallel = int(hardware.get("parallel", DEFAULT_PARALLEL))
o.brightness = int(hardware.get("brightness", 80))
o.hardware_mapping = hardware.get("hardware_mapping", "regular")
o.pwm_bits = int(hardware.get("pwm_bits", 11))
+277
View File
@@ -0,0 +1,277 @@
#!/usr/bin/env python3
"""Check that an automatic LEDMatrix update left the device working; roll it back if not.
Started by the web interface's weekly updater (web_interface/auto_update.py)
through ledmatrix-update-verify.service, right after it pulls new code. It has
to run outside the web service: checking the update means restarting that
service, and a check running inside it would be killed by its own restart.
It must not be the code it is checking, either. The updater copies this file
to data/auto_update_verifier.py *before* pulling and the unit runs that copy,
so a broken update cannot break its own rollback. Standard library only for
the same reason: the rollback cannot depend on packages the update changed.
The updater leaves data/auto_update_pending.json:
{"status": "pending", "old_head": ..., "new_head": ...,
"display_was_active": bool, "dependency_failures": [...], "created_at": ...}
This moves its status to "verifying" and then to one of "success",
"rolled_back" or "rollback_failed", with "reason" and "detail" saying why.
The web interface reports that outcome and raises a banner for anything but
success.
"""
import json
import os
import subprocess # nosec B404 - list-form argv only, no shell # nosemgrep
import sys
from collections import namedtuple
import tempfile
import time
import traceback
import urllib.error
import urllib.request
from pathlib import Path
PENDING_NAME = 'auto_update_pending.json'
REQUIREMENT_FILES = ('requirements.txt', 'web_interface/requirements.txt')
WEB_HEALTH_URL = 'http://127.0.0.1:5000/api/v3/system/version'
#: How long the services get to come up after a restart...
HEALTH_TIMEOUT_SECONDS = 180
#: ...and how long they must then stay up. Restart=on-failure makes a crash
#: loop look healthy between attempts, so a single "is-active" proves nothing.
STABLE_SECONDS = 45
POLL_SECONDS = 5
PIP_TIMEOUT_SECONDS = 600
#: sudoers matches the exact command line, so bash is named by path, the same
#: candidates src/common/permission_utils.install_requirements_file tries.
BASH_CANDIDATES = ('/usr/bin/bash', '/bin/bash')
#: What a command that could not run at all reports: its callers only read
#: these three fields, the same ones a completed subprocess has.
_Failed = namedtuple('_Failed', 'returncode stdout stderr')
def pending_path(project_root):
return Path(project_root) / 'data' / PENDING_NAME
def read_pending(path):
try:
with open(path, 'r', encoding='utf-8') as f:
data = json.load(f)
return data if isinstance(data, dict) else None
except (OSError, ValueError):
return None
def write_pending(path, data):
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix='.auto_update_pending_')
try:
with os.fdopen(fd, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2)
os.replace(tmp, path)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
def _web_responds(url=WEB_HEALTH_URL):
try:
with urllib.request.urlopen(url, timeout=5) as resp: # nosec B310 - fixed loopback URL
return resp.status == 200
except (urllib.error.URLError, OSError, ValueError):
return False
def _short(sha):
return (sha or 'unknown')[:7]
class Verifier:
def __init__(self, project_root, run=subprocess.run, sleep=time.sleep,
clock=time.monotonic, web_responds=_web_responds, log=None):
self.project_root = Path(project_root)
self.pending_file = pending_path(project_root)
self.run = run
self.sleep = sleep
self.clock = clock
self.web_responds = web_responds
self.log = log or (lambda msg: print(f'[auto-update-verify] {msg}', flush=True))
def _run(self, args, timeout=60):
try:
return self.run(args, cwd=str(self.project_root), capture_output=True,
text=True, timeout=timeout)
except (subprocess.SubprocessError, OSError) as e:
return _Failed(returncode=1, stdout='', stderr=str(e))
# -- services ---------------------------------------------------------
def service_active(self, unit):
return self._run(['systemctl', 'is-active', unit], timeout=10).stdout.strip() == 'active'
def restart_count(self, unit):
out = self._run(['systemctl', 'show', '-p', 'NRestarts', '--value', unit],
timeout=10).stdout.strip()
return int(out) if out.isdigit() else None
def restart(self, unit):
result = self._run(['sudo', '-n', 'systemctl', 'restart', f'{unit}.service'], timeout=90)
if result.returncode != 0:
self.log(f'restarting {unit} failed: {(result.stderr or "").strip()}')
return result.returncode == 0
def restart_services(self, display):
"""Restart what should be running. False if any restart command failed."""
ok = True
# A display the user had stopped stays stopped.
if display:
ok = self.restart('ledmatrix') and ok
return self.restart('ledmatrix-web') and ok
def wait_healthy(self, display):
"""None once the services are up and stay up, else what went wrong."""
deadline = self.clock() + HEALTH_TIMEOUT_SECONDS + STABLE_SECONDS
healthy_since = baseline = None
web = disp = False
count_known = True
while self.clock() < deadline:
web = self.web_responds()
disp = self.service_active('ledmatrix') if display else True
restarts = self.restart_count('ledmatrix') if display else None
# Without a restart count a crash loop looks healthy between
# attempts, so an unreadable count never counts as stable.
count_known = not display or restarts is not None
if web and disp and count_known and (healthy_since is None or restarts == baseline):
if healthy_since is None:
healthy_since, baseline = self.clock(), restarts
elif self.clock() - healthy_since >= STABLE_SECONDS:
return None
else:
healthy_since = None
self.sleep(POLL_SECONDS)
problems = []
if not web:
problems.append('the web interface did not respond')
if not disp:
problems.append('the display service did not stay running')
if web and disp and not count_known:
problems.append("the display service's restart count could not be read")
return '; '.join(problems) or 'the display service kept restarting'
# -- rollback ---------------------------------------------------------
def changed_requirements(self, old, new):
result = self._run(['git', 'diff', '--name-only', old, new])
# If the diff is unavailable, reinstall both rather than guess.
changed = set(result.stdout.split()) if result.returncode == 0 else set(REQUIREMENT_FILES)
return [rel for rel in REQUIREMENT_FILES if rel in changed]
def install_requirements(self, rel):
wrapper = self.project_root / 'scripts' / 'fix_perms' / 'safe_pip_install.sh'
req = self.project_root / rel
if not req.exists():
return True
for bash in BASH_CANDIDATES:
result = self._run(['sudo', '-n', bash, str(wrapper), str(req)],
timeout=PIP_TIMEOUT_SECONDS)
if result.returncode == 0:
return True
return False
def rollback(self, pending):
"""Reset to the previous commit and its dependencies. Returns (ok, detail)."""
old, new = pending.get('old_head'), pending.get('new_head')
if not old:
return False, 'the commit to roll back to is unknown'
requirements = self.changed_requirements(old, new) if new else list(REQUIREMENT_FILES)
# Safe to --hard: the updater refuses to run with local edits to
# tracked files, so the only thing this discards is the update.
result = self._run(['git', 'reset', '--hard', old], timeout=120)
if result.returncode != 0:
return False, (f'"git reset --hard {old}" failed: '
f'{(result.stderr or result.stdout or "").strip()}')
failed = [rel for rel in requirements if not self.install_requirements(rel)]
if failed:
return True, ('reinstalling the previous dependencies from ' + ', '.join(failed)
+ ' failed; run Install Base Requirements from the Tools tab')
return True, ''
# -- the check itself -------------------------------------------------
def _finish(self, pending, status, reason=None, detail=None):
pending.update({'status': status, 'reason': reason, 'detail': detail or None,
'finished_at': time.time()})
write_pending(self.pending_file, pending)
self.log(' '.join(p for p in (status, reason or '', detail or '') if p))
def verify(self):
pending = read_pending(self.pending_file)
if not pending or pending.get('status') != 'pending':
self.log('no update is waiting to be verified')
return 0
pending['status'] = 'verifying'
write_pending(self.pending_file, pending)
display = bool(pending.get('display_was_active'))
dependency_failures = pending.get('dependency_failures') or []
if dependency_failures:
# Never restart onto code whose packages did not install.
reason = 'installing its dependencies failed (' + ', '.join(dependency_failures) + ')'
elif not self.restart_services(display):
# The old process may still be answering; checking it would pass
# an update that never started.
reason = 'restarting the services failed'
else:
reason = self.wait_healthy(display)
if reason is None:
self._finish(pending, 'success')
return 0
self.log(f'update to {_short(pending.get("new_head"))} is unhealthy ({reason}); '
f'rolling back to {_short(pending.get("old_head"))}')
ok, detail = self.rollback(pending)
if not ok:
self._finish(pending, 'rollback_failed', reason, detail)
return 1
still = (self.wait_healthy(display) if self.restart_services(display)
else 'restarting the services failed')
if still:
self._finish(pending, 'rollback_failed', reason,
f'still unhealthy after rolling back: {still}'
+ (f'; {detail}' if detail else ''))
return 1
self._finish(pending, 'rolled_back', reason, detail)
return 0
def main(argv):
if len(argv) != 2:
print('usage: auto_update_verify.py PROJECT_ROOT', file=sys.stderr)
return 2
verifier = Verifier(Path(argv[1]))
try:
return verifier.verify()
except Exception as e:
traceback.print_exc()
# Whatever happened, the web interface must not be left thinking the
# check is still running.
try:
pending = read_pending(verifier.pending_file) or {}
if pending.get('status') in ('pending', 'verifying'):
pending.update({'status': 'rollback_failed', 'reason': 'the health check crashed',
'detail': str(e), 'finished_at': time.time()})
write_pending(verifier.pending_file, pending)
except OSError:
pass
return 1
if __name__ == '__main__':
sys.exit(main(sys.argv))
+8 -3
View File
@@ -1,5 +1,10 @@
# skins/
> **Not supported yet.** The current scoreboard plugins don't render skins,
> so a skin placed here and selected in config has no effect, and the web UI
> and Plugin Store don't offer them. See
> [docs/SKIN_SYSTEM.md](../docs/SKIN_SYSTEM.md#status-not-supported-yet).
User-installable **visual skins** for the sports scoreboards. Each
subdirectory is one skin:
@@ -10,10 +15,10 @@ skins/<skin-id>/
preview.png # optional
```
- Install a skin: `git clone <skin repo> skins/<skin-id>` (or via the Plugin
Store for registry entries with `"type": "skin"`).
- Install a skin: `git clone <skin repo> skins/<skin-id>`. The Plugin Store
refuses registry entries with `"type": "skin"` while skins don't render.
- Select it: set `"skin": "<skin-id>"` in the plugin's section of
`config/config.json`, or use the web UI's Visual Skin dropdown.
`config/config.json`. The web UI no longer shows a Visual Skin dropdown.
- Build one: start from `example-classic-baseball/` and read
[docs/CREATING_SKINS.md](../docs/CREATING_SKINS.md). Validate with
`python scripts/validate_skin.py --skin <skin-id>`.
+1 -1
View File
@@ -4,5 +4,5 @@ LEDMatrix Display System
Core source package for the LED Matrix Display project.
"""
__version__ = "3.3.0"
__version__ = "3.4.0"
+221
View File
@@ -0,0 +1,221 @@
"""Install the automatic-update health check, from the display service.
The weekly updater (web_interface/auto_update.py) will not update LEDMatrix
code unless ledmatrix-update-verify.path and .service are installed: they
restart the services after an update and roll it back if the device is
unhealthy. Installing units takes root and the web interface is not root, and
"SSH in and run an installer" means most people never get updates with a
safety net.
The display service already runs this repository's code as root, so it
installs them -- but only while the user has automatic updates turned on, only
these two units, rendered from the repository's templates for the web
interface's own user, and it reports what happened in
data/auto_update_setup.json for the General tab. It grants nothing new: the
units run as the web user, who can already change the code this process runs.
Called at display startup; the web interface restarts the display service
when the toggle is switched on, so setup happens straight away. Refreshing a
unit whose template changed happens the same way, which is why this compares
content rather than only checking that the files exist.
"""
import json
import logging
import os
import re
import subprocess # nosec B404 - list-form argv only, no shell # nosemgrep
import tempfile
import time
from pathlib import Path
logger = logging.getLogger(__name__)
PROJECT_ROOT = Path(__file__).resolve().parent.parent
SYSTEMD_DIR = Path('/etc/systemd/system')
SERVICE_UNIT = 'ledmatrix-update-verify.service'
PATH_UNIT = 'ledmatrix-update-verify.path'
UNITS = (SERVICE_UNIT, PATH_UNIT)
WEB_UNIT = 'ledmatrix-web.service'
RESULT_REL = Path('data') / 'auto_update_setup.json'
_USER_RE = re.compile(r'^[a-z_][a-z0-9_-]{0,31}$')
class SetupError(Exception):
"""A reason setup cannot proceed, worded for the General tab."""
def _is_root():
return hasattr(os, 'geteuid') and os.geteuid() == 0
def _lookup_ids(user):
try:
import pwd
entry = pwd.getpwnam(user)
return entry.pw_uid, entry.pw_gid
except (ImportError, KeyError):
return None
def _directive(text, key):
match = re.search(rf'^{key}=(.*)$', text or '', re.M)
return match.group(1).strip() if match else None
def _read(path):
try:
return Path(path).read_text(encoding='utf-8')
except OSError:
return None
def is_enabled(config):
return bool((config.get('auto_update') or {}).get('enabled', False))
class UpdateHelperSetup:
def __init__(self, project_root=PROJECT_ROOT, systemd_dir=SYSTEMD_DIR, run=subprocess.run,
is_root=_is_root, lookup_ids=_lookup_ids, clock=time.time):
self.project_root = Path(project_root)
self.systemd_dir = Path(systemd_dir)
self.run = run
self.is_root = is_root
self.lookup_ids = lookup_ids
self.clock = clock
self.result_file = self.project_root / RESULT_REL
self._web_ids = None
def _systemctl(self, *args):
return self.run(['systemctl', *args], capture_output=True, text=True, timeout=60)
def _check(self, result, what):
if result.returncode != 0:
raise SetupError(f'"{what}" failed: {(result.stderr or result.stdout or "").strip()}')
def path_active(self):
try:
return self._systemctl('is-active', PATH_UNIT).stdout.strip() == 'active'
except (subprocess.SubprocessError, OSError):
return False
def ensure(self, config):
"""Install or refresh the units while automatic updates are on.
Returns the result recorded for the General tab, or None when there
was nothing to do (updates off, or not a systemd host at all).
"""
if not is_enabled(config) or not self.systemd_dir.is_dir():
return None
try:
changed = self._install()
except SetupError as e:
return self._report('failed', str(e))
except (OSError, subprocess.SubprocessError) as e:
return self._report('failed', f'Could not install the update health check: {e}')
if changed:
return self._report('installed', 'Installed the update health check.')
return self._report('installed', 'The update health check is installed.', quiet=True)
def _install(self):
if not self.is_root():
raise SetupError('The display service is not running as root, so it cannot install the '
'update health check. Run "sudo ./scripts/install/install_web_service.sh" once.')
web_text = _read(self.systemd_dir / WEB_UNIT)
if web_text is None:
raise SetupError('The web interface service (ledmatrix-web.service) is not installed.')
user = _directive(web_text, 'User') or 'root'
ids = self.lookup_ids(user) if _USER_RE.match(user) else None
if ids is None:
raise SetupError(f'The web interface runs as "{user}", which is not a usable account.')
self._web_ids = ids
workdir = _directive(web_text, 'WorkingDirectory')
if not workdir or Path(workdir).resolve() != self.project_root.resolve():
raise SetupError(f'The web interface service runs from {workdir or "an unknown folder"}, '
f'not {self.project_root}.')
# Spaces are fine -- the templates quote every command-line path --
# but systemd expands % specifiers, and a quote, backslash or line
# break would be reinterpreted in a unit file. (On Windows, where the
# tests also run, a backslash is the path separator, not a name.)
root_text = str(self.project_root)
unsafe = set('%"') | ({'\\'} if os.sep == '/' else set())
if any(ch in unsafe or ord(ch) < 32 for ch in root_text):
raise SetupError(f'LEDMatrix is installed in {root_text!r}, a folder name systemd cannot use '
'in a unit file. Move it to a path without %, quotes, backslashes or '
'control characters.')
rendered = {}
for name in UNITS:
template = _read(self.project_root / 'systemd' / name)
if template is None:
raise SetupError(f'The unit template systemd/{name} is missing.')
rendered[name] = (template.replace('__PROJECT_ROOT_DIR__', str(self.project_root))
.replace('__USER__', user))
# The templates are ordinary repository files. Whatever they say,
# this root process only installs a service that runs as the web user
# and a path unit that starts exactly that service.
if _directive(rendered[SERVICE_UNIT], 'User') != user:
raise SetupError(f'systemd/{SERVICE_UNIT} does not run as the web interface user; '
'refusing to install it.')
if _directive(rendered[PATH_UNIT], 'Unit') != SERVICE_UNIT:
raise SetupError(f'systemd/{PATH_UNIT} does not start {SERVICE_UNIT}; refusing to install it.')
changed = [name for name in UNITS if _read(self.systemd_dir / name) != rendered[name]]
for name in changed:
self._write_unit(self.systemd_dir / name, rendered[name])
if changed:
self._check(self._systemctl('daemon-reload'), 'systemctl daemon-reload')
self._check(self._systemctl('enable', PATH_UNIT), f'systemctl enable {PATH_UNIT}')
self._check(self._systemctl('restart', PATH_UNIT), f'systemctl restart {PATH_UNIT}')
elif not self.path_active():
self._check(self._systemctl('enable', '--now', PATH_UNIT), f'systemctl enable --now {PATH_UNIT}')
changed = [PATH_UNIT]
if not self.path_active():
raise SetupError(f'{PATH_UNIT} did not start; see "journalctl -u {PATH_UNIT}".')
return bool(changed)
def _write_unit(self, path, text):
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f'.{path.name}.')
try:
with os.fdopen(fd, 'w', encoding='utf-8') as f:
f.write(text)
os.chmod(tmp, 0o644)
os.replace(tmp, path)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
def _report(self, status, message, quiet=False):
previous = None
try:
previous = json.loads(_read(self.result_file) or 'null')
except ValueError:
pass
if quiet and isinstance(previous, dict) and previous.get('status') == status:
return previous # nothing new; don't rewrite it on every boot
result = {'status': status, 'message': message, 'at': self.clock()}
(logger.info if status == 'installed' else logger.warning)("Automatic update setup: %s", message)
try:
self.result_file.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=str(self.result_file.parent), prefix='.auto_update_setup_')
with os.fdopen(fd, 'w', encoding='utf-8') as f:
json.dump(result, f, indent=2)
os.chmod(tmp, 0o644)
if self._web_ids and hasattr(os, 'chown'):
# Only root can give the file away; the result is readable
# (0644) either way, so a failed chown must not lose it.
try:
os.chown(tmp, *self._web_ids)
except OSError:
pass
os.replace(tmp, self.result_file)
except OSError as e:
logger.warning("Could not record automatic update setup result: %s", e)
return result
def ensure_update_helper(config):
return UpdateHelperSetup().ensure(config)
+84 -1
View File
@@ -20,11 +20,23 @@ with on an LED panel.
Use :func:`load_truetype` in place of ``ImageFont.truetype`` anywhere the
result is drawn to a panel or compared against a golden image.
The module also owns the other two things that decide whether a bundled face
renders reproducibly, for the same reason — they are properties of the font
file, not of whoever is drawing with it:
* :func:`crisp_size` and :data:`FONT_PIXEL_GRID` — the size each face renders
on whole pixels at. ``4x6-font.ttf`` has a 7px grid, which is why the 6 that
reads as its natural size is the wrong number everywhere it appears.
* :func:`resolve_asset_path` — ``assets/fonts/...`` resolved against the
install root rather than the process cwd.
"""
from __future__ import annotations
from typing import Any, Union
import os
from pathlib import Path
from typing import Any, Dict, Union
from PIL import ImageFont
@@ -41,3 +53,74 @@ def load_truetype(font: Union[str, Any], size: int, **kwargs: Any) -> ImageFont.
"""
kwargs.setdefault("layout_engine", LAYOUT_ENGINE)
return ImageFont.truetype(font, size, **kwargs)
# --------------------------------------------------------------------------
# Bundled-asset path resolution
# --------------------------------------------------------------------------
#: The install root, derived from this module's own location
#: (``<root>/src/common/font_layout.py``) rather than from the process cwd.
_INSTALL_ROOT = Path(__file__).resolve().parents[2]
def resolve_asset_path(relative_path: str) -> str:
"""Resolve a repo-relative asset path independently of the process cwd.
Prefers the path as given — so an absolute path is returned untouched and
behaviour is unchanged wherever the cwd already happened to be the install
root — then the install root derived above, then the original string so a
caller that wants to raise and fall back still can.
Without the fallback, any process started outside the install root (the
plugin safety harness, a manual ``python run.py`` from ``$HOME``, a unit
file written without ``WorkingDirectory``) silently loses every font and
degrades to PIL's default face.
"""
if os.path.isabs(relative_path) and os.path.exists(relative_path):
return relative_path
candidate = _INSTALL_ROOT / relative_path
if candidate.exists():
return str(candidate)
return relative_path
# --------------------------------------------------------------------------
# Pixel-grid snapping
# --------------------------------------------------------------------------
#: Family aliases the web UI may write, mapped to the shipped filename.
FONT_NAME_ALIASES: Dict[str, str] = {
"press_start": "PressStart2P-Regular.ttf",
"four_by_six": "4x6-font.ttf",
}
#: Pixel grid each face renders crisply on. Off-grid sizes anti-alias, which
#: on an LED matrix is a dim lamp rather than a soft edge — and worse under
#: ``draw.fontmode = "1"``, where the mono rasteriser thresholds each glyph at
#: 50% coverage: an off-grid 4x6 glyph renders 3px wide instead of 4, so W/M
#: and 0/8 stop being distinguishable. Off-grid sizes also make ``getlength``
#: return a FreeType-dependent fractional advance, which is how two panels on
#: one config centre the same string differently.
FONT_PIXEL_GRID: Dict[str, int] = {
"PressStart2P-Regular.ttf": 8,
"4x6-font.ttf": 7,
}
def crisp_size(font_file, desired, aliases=None, grid_table=None):
"""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.
``aliases`` and ``grid_table`` default to the shared tables; a plugin that
ships an extra face can pass its own without forking this.
"""
aliases = FONT_NAME_ALIASES if aliases is None else aliases
grid_table = FONT_PIXEL_GRID if grid_table is None else grid_table
font_file = aliases.get(font_file, font_file)
grid = grid_table.get(font_file)
if not grid or not desired or desired <= 0:
return desired
return max(grid, int(round(float(desired) / grid)) * grid)
+67 -16
View File
@@ -35,6 +35,29 @@ from src.common.permission_utils import (
# trade for not re-warning about a file nobody is going to add.
MISSING_LOGO_RECHECK_SECONDS = 3600.0
#: Bounds on a user-supplied logo scale. Wide enough to be useful, closed
#: enough that a typo cannot ask for a 4000px image on a 64px panel.
MIN_LOGO_SCALE = 0.05
MAX_LOGO_SCALE = 8.0
def _usable_scale(scale) -> float:
"""A scale that can be applied, or 1.0.
Anything unusable -- None, a string, zero, a negative, NaN, infinity --
means "as shipped", because the alternative is a blank panel from a
mistyped number.
"""
try:
value = float(scale)
except (TypeError, ValueError):
return 1.0
if value != value or value in (float('inf'), float('-inf')):
return 1.0
if value < MIN_LOGO_SCALE or value > MAX_LOGO_SCALE:
return 1.0
return value
# Well above any real team logo; bounds what a remote URL can write to disk.
@@ -88,18 +111,23 @@ class LogoHelper:
'Accept': 'image/*',
})
def load_logo(self, team_abbr: str, logo_path: Union[str, Path],
max_width: Optional[int] = None,
max_height: Optional[int] = None) -> Optional[Image.Image]:
def load_logo(self, team_abbr: str, logo_path: Union[str, Path],
max_width: Optional[int] = None,
max_height: Optional[int] = None,
scale: float = 1.0) -> Optional[Image.Image]:
"""
Load and resize a team logo.
Args:
team_abbr: Team abbreviation for caching
logo_path: Path to the logo file
max_width: Maximum width (defaults to display_width * 1.5)
max_height: Maximum height (defaults to display_height * 1.5)
scale: User's size multiplier for this image, from
``customization.layout.<element>.scale``. 1.0 is untouched and
takes exactly the path it always did. Callers hold the config,
so they resolve the element name; this only applies the number.
Returns:
PIL Image object or None if loading fails
@@ -115,6 +143,12 @@ class LogoHelper:
max_width = int(self.display_width * 1.5)
if max_height is None:
max_height = int(self.display_height * 1.5)
scale = _usable_scale(scale)
if scale != 1.0:
max_width = max(1, int(round(max_width * scale)))
max_height = max(1, int(round(max_height * scale)))
# The key carries the scaled box, so two elements scaled differently
# cannot be served each other's image.
cache_key = f"{team_abbr}_{logo_path}_{max_width}x{max_height}"
if cache_key in self._logo_cache:
self.logger.debug(f"Using cached logo for {team_abbr}")
@@ -146,7 +180,8 @@ class LogoHelper:
logo = logo.convert('RGBA')
# Resize if needed
logo = self._resize_logo(logo, max_width, max_height)
logo = self._resize_logo(logo, max_width, max_height,
allow_upscale=scale > 1.0)
# Cache the logo
self._cache_logo(cache_key, logo)
@@ -158,10 +193,11 @@ class LogoHelper:
self.logger.error(f"Error loading logo for {team_abbr}: {e}")
return None
def load_logo_with_download(self, team_abbr: str, logo_path: Union[str, Path],
def load_logo_with_download(self, team_abbr: str, logo_path: Union[str, Path],
logo_url: Optional[str] = None,
max_width: Optional[int] = None,
max_height: Optional[int] = None) -> Optional[Image.Image]:
max_height: Optional[int] = None,
scale: float = 1.0) -> Optional[Image.Image]:
"""
Load logo with automatic download if missing.
@@ -181,7 +217,8 @@ class LogoHelper:
# failed download does not count: it wears the real logo's filename, so
# trusting the file's existence is what left teams as grey boxes.
if logo_path.exists() and not self._is_stale_placeholder(logo_path):
return self.load_logo(team_abbr, logo_path, max_width, max_height)
return self.load_logo(team_abbr, logo_path, max_width, max_height,
scale)
# Download if URL provided and file doesn't exist
if logo_url:
@@ -193,7 +230,8 @@ class LogoHelper:
# from the cache before touching the disk -- so without this the
# real logo would not appear until the process restarted.
self._invalidate_cached_logo(team_abbr, logo_path)
return self.load_logo(team_abbr, logo_path, max_width, max_height)
return self.load_logo(team_abbr, logo_path, max_width, max_height,
scale)
except Exception as e:
self.logger.error(f"Failed to download logo for {team_abbr}: {e}")
# The retry failed, so restart the back-off. The stale
@@ -328,18 +366,31 @@ class LogoHelper:
),
}
def _resize_logo(self, logo: Image.Image, max_width: Optional[int] = None,
max_height: Optional[int] = None) -> Image.Image:
"""Resize logo to fit display dimensions."""
def _resize_logo(self, logo: Image.Image, max_width: Optional[int] = None,
max_height: Optional[int] = None,
allow_upscale: bool = False) -> Image.Image:
"""Resize logo to fit display dimensions.
``allow_upscale`` is only set when the user asked for a scale above 1:
the fit rule is "never larger than the box", and growing an image
nobody asked to grow would change every existing render.
"""
if max_width is None:
max_width = int(self.display_width * 1.5)
if max_height is None:
max_height = int(self.display_height * 1.5)
# Only resize if necessary
if logo.width <= max_width and logo.height <= max_height:
return logo
if not allow_upscale or not logo.width or not logo.height:
return logo
ratio = min(max_width / logo.width, max_height / logo.height)
if ratio <= 1:
return logo
return logo.resize((max(1, int(logo.width * ratio)),
max(1, int(logo.height * ratio))),
Image.Resampling.LANCZOS)
# Maintain aspect ratio
logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)
return logo
+53 -173
View File
@@ -30,6 +30,12 @@ except ImportError:
HAS_SCIPY = False
# How often the frame-stats line is emitted, and therefore also the ceiling
# on a believable frame time: a scroll that renders at all cannot take this
# long over one frame, so a sample this large is an idle gap between scrolls.
FPS_LOG_INTERVAL = 5.0
def frame_stats(frame_times: list) -> Dict[str, Any]:
"""Summary statistics over one window of frame durations (seconds).
@@ -158,9 +164,11 @@ class ScrollHelper:
self.last_progress_log_time: Optional[float] = None
self.progress_log_interval = 5.0 # seconds
# Frame rate tracking
# Frame rate tracking. last_frame_time is None until the first frame
# of a scroll is rendered -- see log_frame_rate() for why timing from
# construction (or from the end of the previous scroll) is wrong.
self.frame_count = 0
self.last_frame_time = time.time()
self.last_frame_time: Optional[float] = None
self.last_fps_log_time = time.time()
self.frame_times = []
# Every frame time since the last stats line, so the 5s summary can
@@ -534,168 +542,6 @@ class ScrollHelper:
return Image.frombytes('RGB', _size, self._frame_buffer.tobytes())
def _get_visible_portion_subpixel(self, start_x_int: int, fractional: float) -> Image.Image:
"""
Get visible portion with sub-pixel interpolation for smooth scrolling.
Uses bilinear interpolation to blend between pixels.
"""
# We need to extract a region that's 1 pixel wider to allow for interpolation
start_x = start_x_int
end_x = start_x_int + self.display_width + 1
# Check if we need wrap-around
if end_x <= self.cached_image.width:
# Normal case: extract region with 1 extra pixel for interpolation
source_region = self.cached_array[:, start_x:end_x]
# Use bilinear interpolation for sub-pixel shifting
if HAS_SCIPY:
# Use scipy for high-quality sub-pixel shifting
shifted = shift(source_region, (0, -fractional, 0), mode='nearest', order=1, prefilter=False)
# Extract the display_width portion
frame_array = shifted[:, :self.display_width].astype(np.uint8)
else:
# Fallback: simple linear interpolation using numpy
# Blend between current and next pixel based on fractional part
frame_array = self._interpolate_subpixel(source_region, fractional)
return Image.fromarray(frame_array)
else:
# Wrap-around case with sub-pixel
# Use pre-allocated buffer
if self._frame_buffer is None or self._frame_buffer.shape != (self.display_height, self.display_width, 3):
self._frame_buffer = np.zeros((self.display_height, self.display_width, 3), dtype=np.uint8)
width1 = self.cached_image.width - start_x
if width1 > 0:
# First part from end of image
# Need width1 + 1 pixels for interpolation
source1_width = min(width1 + 1, self.cached_image.width - start_x)
source1 = self.cached_array[:, start_x:start_x + source1_width]
if HAS_SCIPY:
shifted1 = shift(source1, (0, -fractional, 0), mode='nearest', order=1, prefilter=False)
# Ensure we get exactly width1 pixels, padding if necessary
if shifted1.shape[1] >= width1:
self._frame_buffer[:, :width1] = shifted1[:, :width1].astype(np.uint8)
else:
# Shifted array is smaller - pad with zeros or repeat last pixel
actual_width = shifted1.shape[1]
self._frame_buffer[:, :actual_width] = shifted1.astype(np.uint8)
if actual_width < width1:
# Pad with last pixel
self._frame_buffer[:, actual_width:width1] = shifted1[:, -1:].astype(np.uint8)
else:
interpolated1 = self._interpolate_subpixel(source1, fractional, output_width=width1)
# Ensure exact width match
if interpolated1.shape[1] == width1:
self._frame_buffer[:, :width1] = interpolated1
else:
# Handle size mismatch
copy_width = min(width1, interpolated1.shape[1])
self._frame_buffer[:, :copy_width] = interpolated1[:, :copy_width]
if copy_width < width1:
self._frame_buffer[:, copy_width:width1] = interpolated1[:, -1:]
# Second part from beginning
remaining_width = self.display_width - width1
if remaining_width > 0:
source2 = self.cached_array[:, :remaining_width + 1]
if HAS_SCIPY:
shifted2 = shift(source2, (0, -fractional, 0), mode='nearest', order=1, prefilter=False)
# Ensure we get exactly remaining_width pixels
if shifted2.shape[1] >= remaining_width:
self._frame_buffer[:, width1:width1 + remaining_width] = shifted2[:, :remaining_width].astype(np.uint8)
else:
# Shifted array is smaller - pad if necessary
actual_width = shifted2.shape[1]
self._frame_buffer[:, width1:width1 + actual_width] = shifted2.astype(np.uint8)
if actual_width < remaining_width:
self._frame_buffer[:, width1 + actual_width:width1 + remaining_width] = shifted2[:, -1:].astype(np.uint8)
else:
interpolated2 = self._interpolate_subpixel(source2, fractional, output_width=remaining_width)
# Ensure exact width match
if interpolated2.shape[1] == remaining_width:
self._frame_buffer[:, width1:] = interpolated2
else:
copy_width = min(remaining_width, interpolated2.shape[1])
self._frame_buffer[:, width1:width1 + copy_width] = interpolated2[:, :copy_width]
if copy_width < remaining_width:
self._frame_buffer[:, width1 + copy_width:width1 + remaining_width] = interpolated2[:, -1:]
else:
# Edge case: wrap to beginning
source = self.cached_array[:, :self.display_width + 1]
if HAS_SCIPY:
shifted = shift(source, (0, -fractional, 0), mode='nearest', order=1, prefilter=False)
# Ensure we get exactly display_width pixels
if shifted.shape[1] >= self.display_width:
self._frame_buffer = shifted[:, :self.display_width].astype(np.uint8)
else:
# Shifted array is smaller - pad if necessary
actual_width = shifted.shape[1]
self._frame_buffer[:, :actual_width] = shifted.astype(np.uint8)
if actual_width < self.display_width:
self._frame_buffer[:, actual_width:] = shifted[:, -1:].astype(np.uint8)
else:
interpolated = self._interpolate_subpixel(source, fractional, output_width=self.display_width)
# _interpolate_subpixel now always returns exact width, so this should work
self._frame_buffer = interpolated
return Image.fromarray(self._frame_buffer)
def _interpolate_subpixel(self, source: np.ndarray, fractional: float, output_width: Optional[int] = None) -> np.ndarray:
"""
Simple linear interpolation for sub-pixel positioning.
Blends between adjacent pixels based on fractional offset.
Args:
source: Source array to interpolate (width should be at least output_width + 1)
fractional: Fractional part of scroll position (0.0-1.0)
output_width: Desired output width (defaults to display_width)
Returns:
Interpolated array of shape (height, output_width, 3) - ALWAYS exactly output_width
"""
if output_width is None:
output_width = self.display_width
# Always return exactly output_width pixels, padding if necessary
result = np.zeros((source.shape[0], output_width, 3), dtype=np.uint8)
# Ensure we have enough source pixels for interpolation
if source.shape[1] < 2:
# Very small source - just copy what we have and pad
copy_width = min(source.shape[1], output_width)
result[:, :copy_width] = source[:, :copy_width].astype(np.uint8)
if copy_width < output_width:
# Pad with last pixel
result[:, copy_width:] = source[:, -1:].astype(np.uint8)
return result
# Calculate how many pixels we can actually interpolate
# Need at least 2 pixels to interpolate, so max output is source.shape[1] - 1
max_interpolated_width = source.shape[1] - 1
interpolated_width = min(output_width, max_interpolated_width)
if interpolated_width > 0:
# Extract pixels at x and x+1 for interpolation
pixels_x = source[:, :interpolated_width].astype(np.float32)
pixels_x1 = source[:, 1:interpolated_width + 1].astype(np.float32)
# Linear interpolation
interpolated = pixels_x * (1.0 - fractional) + pixels_x1 * fractional
# Clip and convert back to uint8
interpolated = np.clip(interpolated, 0, 255).astype(np.uint8)
# Copy interpolated portion to result
result[:, :interpolated_width] = interpolated
# If we need more pixels than we can interpolate, pad with last pixel
if interpolated_width < output_width:
result[:, interpolated_width:] = source[:, -1:].astype(np.uint8)
return result
def calculate_dynamic_duration(self) -> int:
"""
Calculate display duration based on content width and scroll settings.
@@ -926,6 +772,10 @@ class ScrollHelper:
# Reset last_update_time to prevent large delta_time on next update
# This ensures smooth scrolling after reset without jumping ahead
self.last_update_time = now
# Same reasoning for the frame-rate clock: the first frame after a
# reset has no predecessor in this scroll, and timing it against the
# last frame of the previous one measures the idle gap between them.
self.last_frame_time = None
self.logger.debug("Scroll position reset")
def reset(self) -> None:
@@ -1124,11 +974,37 @@ class ScrollHelper:
Log frame rate statistics for performance monitoring.
"""
current_time = time.time()
# The first frame of a scroll has no predecessor, so it has no frame
# time. Measuring one anyway records the whole idle gap since the last
# scroll as a single frame: on hardware that produced windows reading
# "0.0 fps over 1 frames | median 136776.02ms", and -- worse, because
# it is not obviously wrong -- put that gap in the max field of
# otherwise healthy windows and counted it as one stall per scroll.
# At ~500 frames to a window that is ~0.2%, which is the same order as
# the real stall rates being measured, so the number could not be
# trusted at all. Seed the clock and take no sample.
if self.last_frame_time is None:
self.last_frame_time = current_time
# Restart the window with the scroll. Otherwise the boundary is
# already long overdue when the second frame arrives, and the new
# scroll opens by reporting a window of exactly one frame.
self.last_fps_log_time = current_time
return
# Calculate instantaneous frame time
frame_time = current_time - self.last_frame_time
# A caller that scrolls without ever calling reset_scroll() never arms
# the sentinel above, so catch the same gap by its size. Nothing that
# renders a scroll produces a frame longer than the log interval; a
# sample that large is an idle period, not a frame.
if frame_time >= FPS_LOG_INTERVAL:
self.last_frame_time = current_time
return
self.frame_times.append(frame_time)
# Keep only last 100 frames for average
if len(self.frame_times) > 100:
self.frame_times.pop(0)
@@ -1141,17 +1017,21 @@ class ScrollHelper:
# duplicate and a 21ms double-wait mean exactly 10ms). Chasing scroll
# judder needs the tail, so keep the window and report percentiles.
self._window.append(frame_time)
# Log FPS every 5 seconds to avoid spam
if current_time - self.last_fps_log_time >= 5.0:
self.logger.info(
"Scroll frame stats - %s",
format_frame_stats(self._window or [frame_time]),
)
if current_time - self.last_fps_log_time >= FPS_LOG_INTERVAL:
# An empty window means every sample in this interval was dropped
# as an idle gap. There is nothing to report, and reporting the
# gap itself is the bug above.
if self._window:
self.logger.info(
"Scroll frame stats - %s",
format_frame_stats(self._window),
)
self.last_fps_log_time = current_time
self.frame_count = 0
self._window = []
self.last_frame_time = current_time
self.frame_count += 1
+71 -58
View File
@@ -22,6 +22,10 @@ from datetime import datetime, timezone
from typing import Any, Dict, Optional, Tuple
from zoneinfo import ZoneInfo
from src.common.font_layout import ( # noqa: F401 - re-exported, see below
FONT_NAME_ALIASES, FONT_PIXEL_GRID, crisp_size,
)
logger = logging.getLogger(__name__)
__all__ = [
@@ -52,18 +56,12 @@ FAVORITE_RESULT_COLOR_DEFAULTS: Dict[str, Tuple[int, int, int]] = {
"tie": (255, 200, 0),
}
#: Family aliases the web UI may write, mapped to the shipped filename.
FONT_NAME_ALIASES: Dict[str, str] = {
"press_start": "PressStart2P-Regular.ttf",
"four_by_six": "4x6-font.ttf",
}
#: Pixel grid each face renders crisply on. Off-grid sizes anti-alias, which
#: on an LED matrix is a dim lamp rather than a soft edge.
FONT_PIXEL_GRID: Dict[str, int] = {
"PressStart2P-Regular.ttf": 8,
"4x6-font.ttf": 7,
}
# Re-exported rather than defined: the grid tables and the snapping rule are
# properties of the font files, which the display core needs too (it loads the
# same two faces in DisplayManager._load_fonts). They live in
# src/common/font_layout.py so there is one definition; they stay in this
# module's namespace and __all__ so the eight scoreboards that delegate to
# `sports_card.crisp_size` are untouched.
MONTH_ABBR = ("Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
@@ -99,38 +97,71 @@ def upcoming_center_mode(config: Optional[Dict[str, Any]]) -> str:
# ---------------------------------------------------------------------------
def element_color(config: Optional[Dict[str, Any]], element: str,
default: Tuple[int, int, int] = (255, 255, 255)):
"""Per-element text colour from customization.<element>.text_color."""
default: Tuple[int, int, int] = (255, 255, 255),
mode: Optional[str] = None):
"""Per-element text colour from customization.<element>.text_color.
Delegated rather than reimplemented: there were two copies of this
read and three of the offset read, and the shared one also resolves
the element under the names plugins actually use (the layout block
says `score` where the style block says `score_text`) and honours a
per-mode override. Hex strings are still accepted.
"""
from src.element_style import element_color as _shared
return _shared(config, element, default, mode)
def resolve_font_color(config: Optional[Dict[str, Any]],
fonts: Optional[Dict[str, Any]], font,
default: Tuple[int, int, int],
element_for_font: Dict[str, str],
mode: Optional[str] = None):
"""Colour for whichever element owns this face.
Identity matching is a stand-in for the element name, used where the draw
site only ever received a font. Prefer ``element=`` on the draw call; this
is the fallback for the sites that have not been annotated yet.
One object can legitimately belong to several elements -- a size resolver
can land two of them on the same face, and a BDF face cannot be un-shared
at all because ``freetype.Face`` objects cannot be rebuilt from a path.
Those draws used to go out white, which is how an element rendered in any
of the 32 shipped bitmap fonts could silently lose a colour the user had
set. So ambiguity is now narrowed before it is given up on: among the
elements sharing a face, a single configured colour is the only thing the
user can have meant, and several that agree mean the same thing. Only a
genuine disagreement falls back to *default*.
The element vocabulary is a parameter because the two callers disagree
about it -- the mixin's map says ``team_text`` where this module's says
``team_name`` -- and quietly re-pointing either at the other's names would
change which colour setting a live install honours.
"""
try:
cfg = (config or {}).get("customization", {}).get(element, {})
value = cfg.get("text_color")
if isinstance(value, (list, tuple)) and len(value) == 3:
return tuple(max(0, min(255, int(c))) for c in value)
if isinstance(value, str) and value.startswith("#") and len(value) == 7:
return tuple(int(value[i:i + 2], 16) for i in (1, 3, 5))
except (TypeError, ValueError):
fonts = fonts or {}
matches = [element for key, element in element_for_font.items()
if fonts.get(key) is font]
if len(matches) == 1:
return element_color(config, matches[0], default, mode)
if len(matches) > 1:
configured = []
for element in matches:
colour = element_color(config, element, None, mode)
if colour is not None and colour not in configured:
configured.append(colour)
if len(configured) == 1:
return configured[0]
except (AttributeError, TypeError):
pass
return default
def font_color(config: Optional[Dict[str, Any]], fonts: Optional[Dict[str, Any]],
font, default: Tuple[int, int, int] = (255, 255, 255)):
"""Colour for whichever element owns this face.
Matched on identity, and deliberately gives up when one object is
shared: the last-resort font path can hand the same face to several
keys, and there is no right answer for which element's colour that is.
White is what those draws used before, so ambiguity costs nothing.
"""
try:
fonts = fonts or {}
matches = [element for key, element in ELEMENT_FOR_FONT.items()
if fonts.get(key) is font]
if len(matches) == 1:
return element_color(config, matches[0], default)
except (AttributeError, TypeError):
pass
return default
font, default: Tuple[int, int, int] = (255, 255, 255),
mode: Optional[str] = None):
"""Colour for whichever element owns this face, by this module's map."""
return resolve_font_color(config, fonts, font, default, ELEMENT_FOR_FONT,
mode)
def coerce_rgb(value, fallback):
@@ -357,24 +388,6 @@ def format_game_time(config: Optional[Dict[str, Any]], time_text: str) -> str:
_SCHEMA_FONT_SIZE_CACHE: Dict[str, Dict[str, int]] = {}
def crisp_size(font_file, desired, aliases=None, grid_table=None):
"""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.
``aliases`` and ``grid_table`` default to the shared tables; a plugin
that ships an extra face can pass its own without forking this.
"""
aliases = FONT_NAME_ALIASES if aliases is None else aliases
grid_table = FONT_PIXEL_GRID if grid_table is None else grid_table
font_file = aliases.get(font_file, font_file)
grid = grid_table.get(font_file)
if not grid or not desired or desired <= 0:
return desired
return max(grid, int(round(float(desired) / grid)) * grid)
def schema_font_size(schema_path: str, element_key) -> Optional[int]:
"""The font_size this plugin's config_schema.json declares, or None.
@@ -448,7 +461,7 @@ def unshare_element_fonts(logger, fonts):
path) are left shared, and their draws stay white as before.
"""
try:
from PIL import ImageFont as _IF
from src.common.font_layout import load_truetype as _load
except ImportError: # pragma: no cover
return fonts
seen = {}
@@ -463,7 +476,7 @@ def unshare_element_fonts(logger, fonts):
if not path or not size:
continue
try:
fonts[key] = _IF.truetype(path, size)
fonts[key] = _load(path, size)
except (OSError, ValueError, TypeError):
logger.debug(
"Could not un-share the %s face; it keeps the default colour", key)
+3 -13
View File
@@ -134,19 +134,9 @@ class SportsGameRendererMixin:
the element on the scroll/Vegas card too -- previously the schema
advertised these offsets but this renderer ignored them.
"""
try:
layout = (self.config or {}).get("customization", {}).get("layout", {})
value = (layout.get(element) or {}).get(axis, default)
if isinstance(value, bool):
return default
if isinstance(value, (int, float)):
return int(value) if math.isfinite(value) else default
if isinstance(value, str):
parsed = float(value)
return int(parsed) if math.isfinite(parsed) else default
except (TypeError, ValueError, OverflowError):
pass
return default
from src.element_style import layout_offset
return layout_offset(self.config, element, axis, default,
getattr(self, "SKIN_MODE", None))
# ---- upcoming cards ------------------------------------------------
+100 -31
View File
@@ -191,7 +191,8 @@ class SportsCoreSharedMixin:
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"])
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:
@@ -507,8 +508,10 @@ class SportsCoreSharedMixin:
+ 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"]
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
@@ -757,18 +760,71 @@ class SportsCoreSharedMixin:
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."""
try:
cfg = (self.config or {}).get("customization", {}).get(element, {})
value = cfg.get("text_color")
if isinstance(value, (list, tuple)) and len(value) == 3:
return tuple(max(0, min(255, int(c))) for c in value)
if isinstance(value, str) and value.startswith("#") and len(value) == 7:
return tuple(int(value[i:i + 2], 16) for i in (1, 3, 5))
except (TypeError, ValueError):
pass
return default
"""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.
@@ -787,7 +843,7 @@ class SportsCoreSharedMixin:
path) are left shared, and their draws stay white as before.
"""
try:
from PIL import ImageFont as _IF
from src.common.font_layout import load_truetype as _load
except ImportError: # pragma: no cover
return fonts
seen = {}
@@ -802,7 +858,7 @@ class SportsCoreSharedMixin:
if not path or not size:
continue
try:
fonts[key] = _IF.truetype(path, size)
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)
@@ -811,25 +867,31 @@ class SportsCoreSharedMixin:
def _font_color(self, font, default: Tuple[int, int, int] = (255, 255, 255)):
"""Colour for whichever element owns this face.
Matched on identity, and deliberately gives up when one object is
shared: the last-resort font path can hand the same face to several
keys, and there is no right answer for which element's colour that is.
White is what those draws used before, so ambiguity costs nothing.
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``.
"""
try:
fonts = getattr(self, "fonts", None) or {}
matches = [element for key, element in self._ELEMENT_FOR_FONT.items()
if fonts.get(key) is font]
if len(matches) == 1:
return self._element_color(matches[0], default)
except (AttributeError, TypeError):
pass
return default
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)
self, draw, text, position, font, fill=None, outline_color=(0, 0, 0),
element=None
):
"""Draw text with a black outline for better readability."""
"""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.
@@ -839,7 +901,14 @@ class SportsCoreSharedMixin:
# 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 fill is None:
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
+5 -3
View File
@@ -32,6 +32,8 @@ from typing import Callable, Optional
import numpy as np
from PIL import Image
from src.display_geometry import DEFAULT_CHAIN_LENGTH
# Raw-frame wire format: 8-byte magic + 4-byte header + raw RGB pixels
# Much faster than PNG: no encode/decode, negligible CPU, same UDP packet size
_RAW_MAGIC = b'SYNC_RAW'
@@ -194,7 +196,7 @@ class DisplaySyncManager:
local_cols = hw.get("cols", 64)
peer_rows = int(msg.get("rows", 0))
peer_cols = int(msg.get("cols", 0))
peer_chain = int(msg.get("chain", 1))
peer_chain = int(msg.get("chain", DEFAULT_CHAIN_LENGTH))
compatible = peer_rows == local_rows and peer_cols == local_cols
@@ -589,7 +591,7 @@ class DisplaySyncManager:
"t": "hello",
"rows": hw.get("rows", 32),
"cols": hw.get("cols", 64),
"chain": hw.get("chain_length", 1),
"chain": hw.get("chain_length", DEFAULT_CHAIN_LENGTH),
}).encode("utf-8")
heartbeat = json.dumps({"t": "hb"}).encode("utf-8")
dest = ("<broadcast>", self.port)
@@ -660,7 +662,7 @@ class DisplaySyncManager:
"port": self.port,
"local_rows": hw.get("rows", 32),
"local_cols": hw.get("cols", 64),
"local_chain": hw.get("chain_length", 1),
"local_chain": hw.get("chain_length", DEFAULT_CHAIN_LENGTH),
}
if self.role == SyncRole.STANDALONE:
+112 -22
View File
@@ -7,9 +7,10 @@ and enable recovery from failed saves.
import json
import os
import re
import shutil
import tempfile
from datetime import datetime
from datetime import datetime, timedelta
from pathlib import Path
from typing import Dict, Any, Optional, List, Tuple
from dataclasses import dataclass
@@ -19,6 +20,19 @@ from src.exceptions import ConfigError
from src.logging_config import get_logger
from src.common.permission_utils import ensure_shared_group_ownership
# Version stamp in a backup's filename: config.json.backup.<version>.
BACKUP_VERSION_FORMAT = "%Y%m%d_%H%M%S_%f"
# Backups written before the format gained microseconds. Still read, never
# written, so existing restore points on a rig stay usable after an upgrade.
LEGACY_BACKUP_VERSION_FORMAT = "%Y%m%d_%H%M%S"
# The numeric collision suffix _create_backup() appends to break a same-tick
# tie: config.json.backup.<version>-<N>. Only digits count as this suffix, so
# a hand-copied or renamed backup that happens to end in "-something" isn't
# mistaken for one and silently mis-parsed.
_BACKUP_COLLISION_SUFFIX_RE = re.compile(r"^(?P<base>.+)-(?P<collision>\d+)$")
class SaveResultStatus(Enum):
"""Status of a save operation."""
@@ -246,23 +260,34 @@ class AtomicConfigManager:
if not self.backup_dir.exists():
return backups
# Look for backup files (format: config.json.backup.YYYYMMDD_HHMMSS)
# Look for backup files (format: config.json.backup.<version>)
config_name = self.config_path.name
backup_pattern = f"{config_name}.backup.*"
for backup_file in self.backup_dir.glob(backup_pattern):
try:
# Extract timestamp from filename
# Format: config.json.backup.20240101_120000
parts = backup_file.stem.split('.')
if len(parts) >= 3 and parts[-2] == 'backup':
timestamp_str = parts[-1]
timestamp = datetime.strptime(timestamp_str, "%Y%m%d_%H%M%S")
else:
# Fallback: use file modification time
# The version reported here is what rollback_config() matches
# against, so it has to be the exact string in the filename.
#
# It did not used to be. This read .stem, which drops only the
# last dot-component, so for config.json.backup.20240101_120000
# parts was ['config', 'json', 'backup'] and parts[-2] was
# 'json' -- never 'backup'. The filename branch could not be
# reached, every backup fell through to the mtime fallback, and
# the version was a second-granularity restamp of the mtime
# rather than the name on disk. Two backups a second apart could
# therefore report the same version, and rollback would pick
# whichever the glob happened to yield first.
# Strip the exact prefix the glob just matched, so a config
# whose own name contains '.backup.' can't shift the split.
timestamp_str = backup_file.name[len(f"{config_name}.backup."):]
timestamp = self._parse_backup_version(timestamp_str)
if timestamp is None:
# Not a version this code wrote (hand-copied, renamed).
# Order it by mtime, but keep the on-disk version string so
# it can still be named in a rollback.
timestamp = datetime.fromtimestamp(backup_file.stat().st_mtime)
timestamp_str = timestamp.strftime("%Y%m%d_%H%M%S")
# Validate backup file
is_valid = self._validate_backup_file(backup_file)
@@ -283,6 +308,35 @@ class AtomicConfigManager:
return backups
@staticmethod
def _parse_backup_version(version: str) -> Optional[datetime]:
"""
Parse the ``<version>`` of a ``config.json.backup.<version>`` filename
into the time the backup was taken, or None if it is not a version this
class wrote.
Accepts the current microsecond format and the legacy second-granularity
one, with or without the ``-N`` suffix _create_backup() appends to break
a collision. When that suffix is present, N is folded into the result
as extra microseconds so same-tick collisions still sort in the order
they were created rather than tying.
"""
if not version:
return None
base = version
collision = 0
match = _BACKUP_COLLISION_SUFFIX_RE.match(version)
if match:
base = match.group('base')
collision = int(match.group('collision'))
for fmt in (BACKUP_VERSION_FORMAT, LEGACY_BACKUP_VERSION_FORMAT):
try:
parsed = datetime.strptime(base, fmt)
except ValueError:
continue
return parsed + timedelta(microseconds=collision) if collision else parsed
return None
def validate_config_file(self, config_path: Optional[str] = None) -> ValidationResult:
"""
Validate a configuration file.
@@ -303,19 +357,55 @@ class AtomicConfigManager:
return None
try:
# Generate backup filename with timestamp
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
# Generate backup filename with timestamp.
#
# This id is the backup's identity: save_config_atomic() returns the
# path, rollback_config(backup_version=...) looks the version up, and
# the paired secrets backup is found by reusing the same string. At
# second granularity two saves inside the same second produced the
# same filename, so the second copy2() below silently overwrote the
# first backup -- the path a caller was still holding then pointed at
# different content, and rolling back to it restored the wrong
# config. Microseconds make that collision vanishingly unlikely.
config_name = self.config_path.name
backup_filename = f"{config_name}.backup.{timestamp}"
backup_path = self.backup_dir / backup_filename
backup_secrets = bool(self.secrets_path and self.secrets_path.exists())
# exists() then copy2() is two steps: two concurrent callers can
# both see the path as free and pick the same one, so the second
# copy2() silently destroys the first call's restore point.
# Reserve the filename(s) with exclusive creation instead -- that
# is atomic, so only one caller can ever win a given timestamp.
# Each retry bumps the collision suffix, so this always
# terminates and stays compatible with _parse_backup_version().
collision = 0
while True:
timestamp = datetime.now().strftime(BACKUP_VERSION_FORMAT)
if collision:
timestamp = f"{timestamp}-{collision}"
backup_path = self.backup_dir / f"{config_name}.backup.{timestamp}"
secrets_backup_path = (
self.backup_dir / f"{self.secrets_path.name}.backup.{timestamp}"
if backup_secrets else None
)
try:
backup_path.touch(exist_ok=False)
except FileExistsError:
collision += 1
continue
if secrets_backup_path is not None:
try:
secrets_backup_path.touch(exist_ok=False)
except FileExistsError:
backup_path.unlink(missing_ok=True)
collision += 1
continue
break
# Copy config file to backup
shutil.copy2(self.config_path, backup_path)
# Also backup secrets file if it exists
if self.secrets_path and self.secrets_path.exists():
secrets_backup_filename = f"{self.secrets_path.name}.backup.{timestamp}"
secrets_backup_path = self.backup_dir / secrets_backup_filename
if secrets_backup_path is not None:
shutil.copy2(self.secrets_path, secrets_backup_path)
# Rotate old backups
+9
View File
@@ -119,6 +119,15 @@ class DisplayController:
# validator.raise_on_errors() # Uncomment to fail fast on errors
except Exception as e:
logger.warning(f"Startup validation could not be completed: {e}")
# Automatic updates need their health-check units, and this is the
# one root process running project code, so it installs them while
# automatic updates are on. See src/auto_update_setup.py.
try:
from src.auto_update_setup import ensure_update_helper
ensure_update_helper(self.config)
except Exception as e:
logger.warning("Automatic update setup could not be completed: %s", e)
config_time = time.time()
self.display_manager = DisplayManager(self.config)
+144
View File
@@ -0,0 +1,144 @@
"""Display size from config: the one computation every caller shares.
``DisplayManager`` sizes its canvas from ``display.hardware`` plus
``display.double_sided``. The web preview, the Starlark magnify default and
the multi-display sync handshake used to re-derive that size themselves,
each with its own defaults (``chain_length`` fell back to 2 in one place and
1 in three others) and none of them applying double-sided mode. They now all
call this module.
Kept free of hardware imports on purpose: the web interface imports it, and
``display_manager`` pulls in ``rgbmatrix``.
"""
import logging
from typing import Any, Dict, Mapping, Optional, Tuple
logger = logging.getLogger(__name__)
# Match config/config.template.json's display.hardware block.
DEFAULT_ROWS = 32
DEFAULT_COLS = 64
DEFAULT_CHAIN_LENGTH = 2
DEFAULT_PARALLEL = 1
def _display(config: Optional[Mapping[str, Any]]) -> Mapping[str, Any]:
# A hand-edited config.json can hold anything here; treat a non-mapping
# like a missing block so callers get the defaults, not AttributeError.
display = (config or {}).get('display')
return display if isinstance(display, Mapping) else {}
def _hardware(config: Optional[Mapping[str, Any]]) -> Mapping[str, Any]:
hw = _display(config).get('hardware')
return hw if isinstance(hw, Mapping) else {}
def physical_size(config: Optional[Mapping[str, Any]]) -> Tuple[int, int]:
"""Width and height of the whole panel chain, in pixels.
``cols * chain_length`` by ``rows * parallel``. Raises ``ValueError`` or
``TypeError`` on a non-numeric value, as ``DisplayManager`` does; callers
decide their own fallback.
A non-finite value (``Infinity``, which Python's JSON parser accepts in a
hand-edited config.json) raises ``ValueError`` too, not ``OverflowError``,
so every caller's existing fallback catches it.
"""
hw = _hardware(config)
try:
rows = int(hw.get('rows', DEFAULT_ROWS))
cols = int(hw.get('cols', DEFAULT_COLS))
chain_length = int(hw.get('chain_length', DEFAULT_CHAIN_LENGTH))
parallel = int(hw.get('parallel', DEFAULT_PARALLEL))
except OverflowError as e:
raise ValueError(f"display.hardware size is not finite: {e}") from e
return max(1, cols * chain_length), max(1, rows * parallel)
def resolve_double_sided(physical_width: int, physical_height: int,
ds_config: Dict[str, Any],
quiet: bool = False) -> Optional[Dict[str, Any]]:
"""Validate the ``display.double_sided`` config against the physical size.
Returns a dict ``{copies, axis, logical_width, logical_height}`` when the
feature is enabled and the physical panel divides evenly into ``copies``
along the chosen axis, otherwise ``None`` (single-screen behaviour). Bad
config is logged and disabled rather than raised — a misconfigured panel
should still light up.
Only pixels are checked, not whole panels: ``chain_length`` and
``parallel`` don't say which axis a panel lies on once an orientation
``Rotate:`` or U-mapper ``pixel_mapper_config`` rearranges the chain.
``quiet`` suppresses the log lines, for callers that run on every web
request and would otherwise repeat them on each poll.
"""
def _log(level, *args):
if not quiet:
logger.log(level, *args)
if not isinstance(ds_config, dict) or not ds_config.get('enabled', False):
return None
copies = ds_config.get('copies', 2)
if not isinstance(copies, int) or copies < 2:
_log(logging.WARNING,
"double_sided: 'copies' must be an integer >= 2 (got %r); "
"disabling double-sided mode", copies)
return None
axis = ds_config.get('axis', 'horizontal')
if axis not in ('horizontal', 'vertical'):
_log(logging.WARNING,
"double_sided: 'axis' must be 'horizontal' or 'vertical' "
"(got %r); defaulting to 'horizontal'", axis)
axis = 'horizontal'
# Horizontal splits the chain (panels side by side); vertical splits the
# parallel outputs (panels stacked). The split axis must divide evenly.
if axis == 'horizontal':
if physical_width % copies != 0:
_log(logging.WARNING,
"double_sided: physical width %d is not divisible by copies "
"%d; disabling double-sided mode", physical_width, copies)
return None
logical_width = physical_width // copies
logical_height = physical_height
else:
if physical_height % copies != 0:
_log(logging.WARNING,
"double_sided: physical height %d is not divisible by copies "
"%d; disabling double-sided mode", physical_height, copies)
return None
logical_width = physical_width
logical_height = physical_height // copies
_log(logging.INFO,
"double_sided enabled: %d copies on %s axis — logical screen %dx%d "
"tiled across physical %dx%d", copies, axis, logical_width,
logical_height, physical_width, physical_height)
return {
'copies': copies,
'axis': axis,
'logical_width': logical_width,
'logical_height': logical_height,
}
def logical_size(config: Optional[Mapping[str, Any]],
quiet: bool = True) -> Tuple[int, int]:
"""The size plugins draw at and the web preview shows.
The physical size, divided by ``double_sided.copies`` along its axis when
double-sided mode is enabled and valid — the same answer
``DisplayManager.width``/``height`` give.
"""
width, height = physical_size(config)
ds = resolve_double_sided(width, height,
_display(config).get('double_sided') or {},
quiet=quiet)
if ds is not None:
return ds['logical_width'], ds['logical_height']
return width, height
+74 -78
View File
@@ -34,7 +34,11 @@ else:
from contextlib import contextmanager
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
from src.common.font_layout import load_truetype
from src.common.font_layout import crisp_size, load_truetype, resolve_asset_path
from src.display_geometry import (
DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_PARALLEL, DEFAULT_ROWS,
physical_size, resolve_double_sided,
)
import threading
import time
from collections import OrderedDict
@@ -123,62 +127,10 @@ class _LogicalMatrix:
setattr(object.__getattribute__(self, "_matrix"), name, value)
def _resolve_double_sided(physical_width: int, physical_height: int,
ds_config: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Validate the ``display.double_sided`` config against the physical size.
Returns a dict ``{copies, axis, logical_width, logical_height}`` when the
feature is enabled and the physical panel divides evenly into ``copies``
along the chosen axis, otherwise ``None`` (single-screen behaviour). Bad
config is logged and disabled rather than raised — a misconfigured panel
should still light up.
"""
if not isinstance(ds_config, dict) or not ds_config.get('enabled', False):
return None
copies = ds_config.get('copies', 2)
if not isinstance(copies, int) or copies < 2:
logger.warning(
"double_sided: 'copies' must be an integer >= 2 (got %r); "
"disabling double-sided mode", copies)
return None
axis = ds_config.get('axis', 'horizontal')
if axis not in ('horizontal', 'vertical'):
logger.warning(
"double_sided: 'axis' must be 'horizontal' or 'vertical' "
"(got %r); defaulting to 'horizontal'", axis)
axis = 'horizontal'
# Horizontal splits the chain (panels side by side); vertical splits the
# parallel outputs (panels stacked). The split axis must divide evenly.
if axis == 'horizontal':
if physical_width % copies != 0:
logger.warning(
"double_sided: physical width %d is not divisible by copies "
"%d; disabling double-sided mode", physical_width, copies)
return None
logical_width = physical_width // copies
logical_height = physical_height
else:
if physical_height % copies != 0:
logger.warning(
"double_sided: physical height %d is not divisible by copies "
"%d; disabling double-sided mode", physical_height, copies)
return None
logical_width = physical_width
logical_height = physical_height // copies
logger.info(
"double_sided enabled: %d copies on %s axis — logical screen %dx%d "
"tiled across physical %dx%d", copies, axis, logical_width,
logical_height, physical_width, physical_height)
return {
'copies': copies,
'axis': axis,
'logical_width': logical_width,
'logical_height': logical_height,
}
# Moved to src/display_geometry.py so the web preview, Starlark magnify and
# sync handshake compute the display size exactly as DisplayManager does
# without importing rgbmatrix. Aliased here for existing callers.
_resolve_double_sided = resolve_double_sided
class DisplayManager:
@@ -327,10 +279,10 @@ class DisplayManager:
runtime_config = self.config.get('display', {}).get('runtime', {})
# Basic hardware settings
options.rows = hardware_config.get('rows', 32)
options.cols = hardware_config.get('cols', 64)
options.chain_length = hardware_config.get('chain_length', 2)
options.parallel = hardware_config.get('parallel', 1)
options.rows = hardware_config.get('rows', DEFAULT_ROWS)
options.cols = hardware_config.get('cols', DEFAULT_COLS)
options.chain_length = hardware_config.get('chain_length', DEFAULT_CHAIN_LENGTH)
options.parallel = hardware_config.get('parallel', DEFAULT_PARALLEL)
options.hardware_mapping = hardware_config.get('hardware_mapping', 'adafruit-hat-pwm')
# Performance and stability settings
@@ -403,7 +355,9 @@ class DisplayManager:
# Initialize font with Press Start 2P
try:
self.font = load_truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
self.font = load_truetype(
self._font_asset(self._PRESS_START),
crisp_size(self._PRESS_START, 8))
logger.info("Initial Press Start 2P font loaded successfully")
except Exception as e:
logger.error(f"Failed to load initial font: {e}")
@@ -419,13 +373,7 @@ class DisplayManager:
# Create a fallback image for web preview using configured dimensions when available
self.matrix = None
try:
hardware_config = self.config.get('display', {}).get('hardware', {}) if self.config else {}
rows = int(hardware_config.get('rows', 32))
cols = int(hardware_config.get('cols', 64))
chain_length = int(hardware_config.get('chain_length', 2))
parallel = int(hardware_config.get('parallel', 1))
fallback_width = max(1, cols * chain_length)
fallback_height = max(1, rows * parallel)
fallback_width, fallback_height = physical_size(self.config)
# Mirror double-sided in fallback so the preview shows one screen.
ds_config = self.config.get('display', {}).get('double_sided', {}) if self.config else {}
ds = _resolve_double_sided(fallback_width, fallback_height, ds_config)
@@ -592,9 +540,19 @@ class DisplayManager:
Pillow had; with the engine pinned it does not, so the rung the
worst case actually needs is here rather than implied.
"""
# The middle rung is on the 7px grid; the bottom one is deliberately
# not. 4x6 advances the same whether it is asked for 6 or 7 -- the
# dotted quad is 66px at both -- so the middle rung costs no width and
# gains the fourth column in every glyph, which is the difference
# between reading an address off a wall and guessing at it. The 5 rung
# is the exception this screen needs: it drops the advance to 4px and
# the quad to 51px, the only rung that fits a 64px panel, and no
# on-grid size does that. It is the one place in the core that draws
# 4x6 off-grid on purpose.
candidates = [self.font,
("assets/fonts/4x6-font.ttf", 6),
("assets/fonts/4x6-font.ttf", 5)]
(self._font_asset(self._FOUR_BY_SIX),
crisp_size(self._FOUR_BY_SIX, 6)),
(self._font_asset(self._FOUR_BY_SIX), 5)]
narrowest = None
for candidate in candidates:
try:
@@ -966,6 +924,28 @@ class DisplayManager:
except Exception as e:
logger.error(f"Error drawing BDF text: {e}", exc_info=True)
#: The bundled faces, and the size each is *asked* for. Every size here is
#: run through `crisp_size`, so a number that drifts off the face's pixel
#: grid is snapped rather than rendered anti-aliased -- see the note on
#: `extra_small_font` below.
_FONT_DIR = "assets/fonts"
_PRESS_START = "PressStart2P-Regular.ttf"
_FOUR_BY_SIX = "4x6-font.ttf"
@classmethod
def _font_asset(cls, filename: str) -> str:
"""Install-root-relative path to a bundled face.
`_load_fonts` named these relative to the process cwd, which holds
under the packaged systemd unit (WorkingDirectory is the install root)
and nowhere else: the plugin safety harness, `python run.py` from
$HOME, or a unit file written without WorkingDirectory all loaded
nothing and fell through to `ImageFont.load_default()`. That failure is
silent -- the panel just renders in PIL's default face at whatever size
the layout was computed for.
"""
return resolve_asset_path(f"{cls._FONT_DIR}/{filename}")
def _load_fonts(self):
"""Load fonts with proper error handling."""
# Font objects get new id()s after reload, so the text-width cache would
@@ -973,16 +953,17 @@ class DisplayManager:
self._text_width_cache.clear()
try:
# Load Press Start 2P font
self.regular_font = load_truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
press_start = self._font_asset(self._PRESS_START)
self.regular_font = load_truetype(press_start, crisp_size(self._PRESS_START, 8))
logger.info("Press Start 2P font loaded successfully")
# Use the same font for small text (currently same size; adjust size here if needed)
self.small_font = load_truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
self.small_font = load_truetype(press_start, crisp_size(self._PRESS_START, 8))
logger.info("Press Start 2P small font loaded successfully")
# Load 5x7 BDF font for calendar events
try:
self.calendar_font_path = "assets/fonts/5x7.bdf"
self.calendar_font_path = self._font_asset("5x7.bdf")
logger.info(f"Attempting to load 5x7 font from: {self.calendar_font_path}")
if not os.path.exists(self.calendar_font_path):
@@ -1017,11 +998,26 @@ class DisplayManager:
self.bdf_5x7_font = self.calendar_font
logger.info(f"Assigned calendar_font (type: {type(self.bdf_5x7_font).__name__}) to bdf_5x7_font.")
# Load 4x6 font as extra_small_font
# Load 4x6 font as extra_small_font.
#
# Asked for 6 -- the size the face's name suggests -- for years,
# and 6 is off its 7px pixel grid. Plugins draw this face with
# `draw.fontmode = "1"`, and the mono rasteriser thresholds each
# glyph at 50% coverage, so off-grid every glyph came out 3px wide
# instead of 4. The lost column deforms the letterforms rather than
# merely thinning them: christmas-countdown rendered "UNTIL" as
# "VM1JL" and "CHRISTMAS" as "CHAJS1MAS", and zero loses the left
# half of its bowl. Those renders were committed as golden images.
#
# `crisp_size` snaps it to 7. The advance is unchanged -- 5px per
# glyph at either size -- so nothing reflows and no layout gets
# tighter; a string is at most a pixel or two wider because the
# last glyph finally occupies the width it was always given.
try:
font_path = "assets/fonts/4x6-font.ttf"
logger.info(f"Attempting to load 4x6 TTF font from: {font_path} at size 6")
self.extra_small_font = load_truetype(font_path, 6)
font_path = self._font_asset(self._FOUR_BY_SIX)
size = crisp_size(self._FOUR_BY_SIX, 6)
logger.info(f"Attempting to load 4x6 TTF font from: {font_path} at size {size}")
self.extra_small_font = load_truetype(font_path, size)
logger.info(f"4x6 TTF extra small font loaded successfully from {font_path}")
except Exception as font_err:
logger.error(f"Failed to load 4x6 TTF font: {font_err}. Falling back.")
+1013 -79
View File
File diff suppressed because it is too large Load Diff
+8 -14
View File
@@ -38,7 +38,7 @@ import time
from collections import OrderedDict
from pathlib import Path
from PIL import ImageFont
from src.common.font_layout import load_truetype
from src.common.font_layout import load_truetype, resolve_asset_path
from typing import Dict, Tuple, Optional, Union, Any, List
logger = logging.getLogger(__name__)
@@ -665,20 +665,14 @@ class FontManager:
def _resolve_asset_path(relative_path: str) -> str:
"""Resolve a repo-relative asset path independently of the process cwd.
Prefers the working directory (preserving behavior when the process
runs from the install root), then falls back to the install root
derived from this module's own location. Without the fallback, any
process started outside the install root (e.g. the plugin safety
harness on CI) silently loses every font and degrades to PIL's
default face.
Thin delegate to :func:`src.common.font_layout.resolve_asset_path`,
which holds the one definition (``DisplayManager._load_fonts`` needs
the same resolution and must not import this class for it). The method
stays because plugins probe for it by name to share the core's notion
of "install root" -- see the `_resolve_font_path` helpers in the
scoreboard plugins.
"""
if os.path.exists(relative_path):
return relative_path
install_root = Path(__file__).resolve().parent.parent
candidate = install_root / relative_path
if candidate.exists():
return str(candidate)
return relative_path
return resolve_asset_path(relative_path)
def _initialize_fonts(self):
"""Initialize font catalog and validate configuration."""
+164
View File
@@ -12,11 +12,47 @@ from abc import ABC, abstractmethod
from enum import Enum
from typing import Dict, Any, Optional, List
import logging
import os
import sys
from src.logging_config import get_logger
_shared_fallback_font_manager: Optional[Any] = None
#: Distinguishes "not looked up yet" from "looked up and not found", so a
#: plugin with no schema does not re-scan the disk on every frame.
_UNSET_SCHEMA_PATH = object()
class _NullStyleResolver:
"""Stand-in for ElementStyleResolver when the module is unavailable.
Only reachable on a core that predates src.element_style, which
``styles`` degrades to rather than raising: every lookup returns the
caller's classic values, which is what the plugin drew before styling
existed.
"""
def __init__(self, config: Any) -> None:
self._config = config
def style(self, element_key: str, classic_font: str = None,
classic_size: int = 8, classic_color: Any = None,
mode: Optional[str] = None) -> Any:
from types import SimpleNamespace
return SimpleNamespace(
font=None, color=classic_color or (255, 255, 255), offset=(0, 0),
font_name=classic_font, font_size=classic_size,
user_forced=False, user_forced_color=False,
visible=True, align=None, scale=1.0)
def offset(self, element_key: str, mode: Optional[str] = None) -> tuple:
return (0, 0)
def offset_value(self, element_key: str, axis: str, default: int = 0,
mode: Optional[str] = None) -> int:
return default
def _fallback_font_manager() -> Any:
"""Shared FontManager for environments (unit tests, mocks) where the
@@ -63,6 +99,12 @@ class BasePlugin(ABC):
API_VERSION = "1.0.0"
#: Which ``customization.modes.<mode>`` overrides :attr:`styles` applies.
#: A plugin with one instance per display mode (the scoreboards' live /
#: upcoming / recent classes) sets this and every existing style lookup
#: becomes mode-aware without changing a call site.
STYLE_MODE: Optional[str] = None
def __init__(
self,
plugin_id: str,
@@ -260,6 +302,128 @@ class BasePlugin(ABC):
self._layout_font_generation = generation
return context
@property
def styles(self) -> Any:
"""
The user's per-element styling: fonts, sizes, colours, offsets,
visibility, alignment and scale, resolved against this plugin's own
config_schema.json.
Every consumer of src.element_style used to repeat the same three
things -- a guarded import, finding its own schema file, and
rebuilding the resolver when on_config_change swapped the config
dict. This is those three things, once.
Ask for a style by element name, passing what the plugin drew before
the user could customise anything::
title = self.styles.style('title_text',
classic_font='PressStart2P-Regular.ttf',
classic_size=8,
classic_color=(255, 255, 255))
x, y = title.offset
self.display_manager.draw_text(text, x=x, y=y,
font=title.font, color=title.color)
The classic_* arguments matter: when the user has chosen nothing,
they come back verbatim, so a plugin that adopts this renders
identically until someone actually changes a setting.
A plugin whose display has modes (a scoreboard's live/upcoming/
recent, weather's current/hourly/daily) sets ``STYLE_MODE`` on the
class, and every lookup here honours the matching
``customization.modes.<mode>`` overrides without any call site
passing a mode. Use :meth:`styles_for` for a one-off mode.
Never raises: with no schema on disk, or with the element-style
module unavailable, lookups fall back to the classic values.
"""
resolver = getattr(self, "_style_resolver", None)
# The config dict is swapped wholesale by on_config_change, so
# identity is the invalidation signal -- the same check the sports
# base classes use.
if resolver is not None and resolver._config is self.config:
return resolver
resolver = self._build_style_resolver(getattr(self, "STYLE_MODE", None))
self._style_resolver = resolver
return resolver
def styles_for(self, mode: Optional[str]) -> Any:
""":attr:`styles`, bound to ``mode`` instead of ``STYLE_MODE``.
For a plugin that renders more than one mode from one instance. A
plugin with an instance per mode should set ``STYLE_MODE`` instead
and leave its call sites alone.
"""
cache = getattr(self, "_style_resolvers_by_mode", None)
if cache is None or getattr(self, "_style_resolver_config", None) is not self.config:
cache = {}
self._style_resolvers_by_mode = cache
self._style_resolver_config = self.config
if mode not in cache:
cache[mode] = self._build_style_resolver(mode)
return cache[mode]
def _build_style_resolver(self, mode: Optional[str]) -> Any:
"""Construct a resolver for this plugin's config and schema."""
try:
from src.element_style import (ElementStyleResolver,
defaults_from_schema_file)
except ImportError: # pragma: no cover - core always ships it
return _NullStyleResolver(self.config)
schema_path = self._config_schema_path()
defaults = (defaults_from_schema_file(schema_path) if schema_path
else {})
return ElementStyleResolver(self.config, defaults, mode=mode)
def _config_schema_path(self) -> Optional[str]:
"""This plugin's config_schema.json, or None.
Looked up from the concrete class's own module rather than from this
file: a plugin's subclass lives in its plugin directory, while this
module lives in src/plugin_system, where no plugin schema exists.
Falls back to the configured plugins directory, including the
ledmatrix- prefix form the loader accepts.
Returning None is safe, not fatal -- the resolver then has no
defaults to compare against, so every configured value counts as a
deliberate override, which is the conservative reading.
"""
cached = getattr(self, "_config_schema_path_cache", _UNSET_SCHEMA_PATH)
if cached is not _UNSET_SCHEMA_PATH:
return cached
path = None
try:
for candidate in self._schema_path_candidates():
if candidate and os.path.isfile(candidate):
path = candidate
break
except Exception as exc: # pragma: no cover - defensive
self.logger.debug("Could not locate config_schema.json: %s", exc)
self._config_schema_path_cache = path
return path
def _schema_path_candidates(self) -> list:
"""Where a plugin's schema might be, best guess first."""
candidates = []
module = sys.modules.get(type(self).__module__)
module_file = getattr(module, "__file__", None)
if module_file:
candidates.append(os.path.join(
os.path.dirname(os.path.abspath(module_file)),
"config_schema.json"))
plugins_dir = getattr(self.plugin_manager, "plugins_dir", None)
if plugins_dir:
for plugin_id in (self.plugin_id, f"ledmatrix-{self.plugin_id}"):
candidates.append(os.path.join(
str(plugins_dir), os.path.basename(plugin_id),
"config_schema.json"))
return candidates
def draw_fit(self, text: str, box: Any,
color: tuple = (255, 255, 255),
ladder: Optional[Any] = None,
+9 -2
View File
@@ -1338,9 +1338,16 @@ class PluginStoreManager:
self.logger.error(f"Plugin not found in registry: {plugin_id}")
return False
# Visual skins share the registry but install to skins/, not to a
# plugin directory (docs/SKIN_SYSTEM.md)
# Visual skins share the registry. _install_skin_from_info can put one
# in skins/, but no current scoreboard plugin renders skins, so the
# store refuses them rather than installing something that does
# nothing (docs/SKIN_SYSTEM.md). Manual installs under skins/ and
# uninstall_skin are unaffected.
if (plugin_info.get('type') or 'plugin') == 'skin':
from src.skin_system import SKINS_RENDER_SUPPORTED, SKINS_UNSUPPORTED_MESSAGE
if not SKINS_RENDER_SUPPORTED:
self.logger.error(f"Not installing skin {plugin_id}: {SKINS_UNSUPPORTED_MESSAGE}")
return False
return self._install_skin_from_info(plugin_id, plugin_info, branch)
repo_url = plugin_info.get('repo')
+14 -5
View File
@@ -25,11 +25,20 @@ def find_plugin_dir(plugin_id: str, search_dirs: Sequence[Union[str, Path]]) ->
def load_manifest(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
"""Load and return manifest.json from a plugin directory."""
"""Load and return manifest.json from a plugin directory.
Read as UTF-8 explicitly, not in the platform default encoding: JSON is
UTF-8 by RFC 8259, but `open()` honours the locale, which is cp1252 on
Windows. A manifest carrying any non-ASCII byte (an em dash in a
description, a degree sign in a mode name) therefore raised
UnicodeDecodeError and aborted the whole `check_plugin.py --all` run on the
byte rather than failing just that plugin. The three sibling loaders below
read JSON from the same plugin trees and had the same bug.
"""
manifest_path = Path(plugin_dir) / 'manifest.json'
if not manifest_path.exists():
raise FileNotFoundError(f"No manifest.json in {plugin_dir}")
with open(manifest_path, 'r') as f:
with open(manifest_path, 'r', encoding='utf-8') as f:
return json.load(f)
@@ -77,7 +86,7 @@ def load_config_defaults(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
schema_path = Path(plugin_dir) / 'config_schema.json'
if not schema_path.exists():
return {}
with open(schema_path, 'r') as f:
with open(schema_path, 'r', encoding='utf-8') as f:
schema = json.load(f)
return _defaults_from_properties(schema.get('properties', {}))
@@ -106,7 +115,7 @@ def load_harness_spec(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
spec_path = Path(plugin_dir) / 'test' / 'harness.json'
if not spec_path.exists():
return {}
with open(spec_path, 'r') as f:
with open(spec_path, 'r', encoding='utf-8') as f:
spec = json.load(f)
# Resolve mock_data path and inline its contents for convenience.
@@ -120,7 +129,7 @@ def load_harness_spec(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
f"harness.json references mock_data '{mock_rel}' but "
f"{mock_path} does not exist"
)
with open(mock_path, 'r') as mf:
with open(mock_path, 'r', encoding='utf-8') as mf:
spec['mock_data_contents'] = json.load(mf)
return spec
@@ -31,7 +31,7 @@ from pathlib import Path
from typing import Any, List, Optional, Tuple
from PIL import Image, ImageDraw, ImageFont
from src.common.font_layout import load_truetype
from src.common.font_layout import crisp_size, load_truetype
from src.logging_config import get_logger
@@ -141,9 +141,10 @@ class VisualTestDisplayManager:
fonts_dir = project_root / 'assets' / 'fonts'
# Press Start 2P — regular and small (both 8px)
ttf_path = str(fonts_dir / 'PressStart2P-Regular.ttf')
self.regular_font = load_truetype(ttf_path, 8)
self.small_font = load_truetype(ttf_path, 8)
press_start = 'PressStart2P-Regular.ttf'
ttf_path = str(fonts_dir / press_start)
self.regular_font = load_truetype(ttf_path, crisp_size(press_start, 8))
self.small_font = load_truetype(ttf_path, crisp_size(press_start, 8))
self.font = self.regular_font # alias used by some code paths
# 5x7 BDF font via freetype
@@ -160,10 +161,15 @@ class VisualTestDisplayManager:
self.calendar_font = self.small_font
self.bdf_5x7_font = self.small_font
# 4x6 extra small TTF
# 4x6 extra small TTF, snapped to the face's 7px grid exactly as
# DisplayManager._load_fonts does. Sizing this independently is how
# the harness would render -- and bless goldens -- in a face the
# panel never uses: at the off-grid 6 this asked for, every glyph
# loses its fourth column under `draw.fontmode = "1"`.
try:
xs_path = str(fonts_dir / '4x6-font.ttf')
self.extra_small_font = load_truetype(xs_path, 6)
four_by_six = '4x6-font.ttf'
xs_path = str(fonts_dir / four_by_six)
self.extra_small_font = load_truetype(xs_path, crisp_size(four_by_six, 6))
except (FileNotFoundError, OSError) as e:
logger.debug("Extra small font not available, using fallback: %s", e)
self.extra_small_font = self.small_font
+13 -1
View File
@@ -6,7 +6,19 @@ upcoming) while the host plugin keeps doing data fetching, scheduling,
caching, live priority, and vegas mode. See docs/SKIN_SYSTEM.md.
"""
from src.skin_system.skin_base import (
# Skins are not offered to users yet. The only render hook is
# SportsCore._render_game in src/base_classes/sports/core.py, and none of the
# current scoreboard plugins (monorepo or third-party) build on
# src.base_classes, so a selected skin never draws. The web UI and store
# read these instead of offering install/selection; stored "skin" config
# values still load and save. See docs/SKIN_SYSTEM.md.
SKINS_RENDER_SUPPORTED = False
SKINS_UNSUPPORTED_MESSAGE = (
"Skins aren't supported yet: the current scoreboard plugins don't render "
"them. Installed skins and saved skin settings are kept but have no effect."
)
from src.skin_system.skin_base import ( # noqa: E402
SKIN_API_VERSION,
VIEW_MODEL_VERSION,
ScoreboardSkin,
+2
View File
@@ -81,6 +81,8 @@ class StartupValidator:
_UNITS = (
("systemd/ledmatrix.service", "/etc/systemd/system/ledmatrix.service"),
("systemd/ledmatrix-web.service", "/etc/systemd/system/ledmatrix-web.service"),
("systemd/ledmatrix-update-verify.service", "/etc/systemd/system/ledmatrix-update-verify.service"),
("systemd/ledmatrix-update-verify.path", "/etc/systemd/system/ledmatrix-update-verify.path"),
)
def _validate_systemd_units(self) -> None:
+39 -1
View File
@@ -714,9 +714,24 @@ class WiFiManager:
_IP_FORWARD_SAVE_PATH = Path("/tmp/ledmatrix_ip_forward_saved") # nosec B108 - process-specific named file; device is single-user RPi
# Written when AP mode is manually force-enabled; prevents daemon auto-disable
_FORCE_AP_FLAG_PATH = Path("/tmp/ledmatrix_force_ap_active") # nosec B108 - process-specific named file; device is single-user RPi
# Written by the web process while connect_to_network runs. Joining a network
# from the setup AP takes the AP down first, and the monitor daemon (a separate
# process) would otherwise see "disconnected" on its next tick and bring the
# AP straight back up mid-connect.
_CONNECT_IN_PROGRESS_FLAG_PATH = Path("/tmp/ledmatrix_wifi_connect_in_progress") # nosec B108 - process-specific named file; device is single-user RPi
# Longest a connect can legitimately take (AP teardown, nmcli's 30s timeout,
# verification, restore). An older flag was left by a process that died.
_CONNECT_FLAG_MAX_AGE_SECONDS = 180
# Ensures the startup stale-flag cleanup runs once per process, not per instantiation
_startup_cleanup_done: bool = False
def _connect_in_progress(self) -> bool:
try:
age = time.time() - self._CONNECT_IN_PROGRESS_FLAG_PATH.stat().st_mtime
except OSError:
return False
return age < self._CONNECT_FLAG_MAX_AGE_SECONDS
def _validate_ap_config(self) -> Tuple[str, int]:
"""Return a sanitized (ssid, channel) pair from config, falling back to defaults."""
ssid = str(self.config.get("ap_ssid", DEFAULT_AP_SSID))
@@ -1270,6 +1285,21 @@ class WiFiManager:
logger.warning("Rejected WiFi connect request: %s", error)
return False, error
try:
self._CONNECT_IN_PROGRESS_FLAG_PATH.touch()
except OSError as e:
logger.warning(f"Could not create connect-in-progress flag: {e}")
try:
return self._connect_validated(ssid, password)
finally:
try:
self._CONNECT_IN_PROGRESS_FLAG_PATH.unlink(missing_ok=True)
except OSError as e:
# Never mask the connect result; the age limit retires the flag.
logger.warning(f"Could not remove connect-in-progress flag: {e}")
def _connect_validated(self, ssid: str, password: str) -> Tuple[bool, str]:
"""connect_to_network after validation, with the in-progress flag held."""
# Save current connection info for failsafe restoration
original_connection = None
original_ssid = None
@@ -2690,7 +2720,15 @@ address=/detectportal.firefox.com/192.168.4.1
if self._disconnected_checks > 0:
logger.debug("Network connected, resetting disconnected check counter")
self._disconnected_checks = 0
if self._connect_in_progress():
# A connect has just taken the AP down on purpose. Leave the
# radio alone, and restart the grace period so a failed attempt
# (which re-enables the AP itself) isn't followed by a flap.
logger.debug("WiFi connect in progress; skipping AP management this check")
self._disconnected_checks = 0
return False
# Only enable AP if we've had enough consecutive disconnected checks
should_have_ap = (auto_enable and
is_disconnected and
+12 -1
View File
@@ -14,6 +14,16 @@ This directory contains systemd service unit files for LEDMatrix services.
- Starts automatically on boot if `web_display_autostart` is enabled
- Uses `scripts/utils/start_web_conditionally.py`
- **`ledmatrix-update-verify.service`** / **`.path`** - Automatic update health check and rollback
- The path unit starts the service when the web interface creates
`data/auto_update_verify.request` after a weekly automatic update
- Installed by the installers, or by the display service when automatic
updates are turned on in the web UI (`src/auto_update_setup.py`)
- Restarts the services, checks they stay up, and otherwise resets to the
previous commit and its dependencies
- Runs `data/auto_update_verifier.py`, a copy of
`scripts/utils/auto_update_verify.py` taken before the update
- **`ledmatrix-wifi-monitor.service`** - WiFi monitor daemon service
- Monitors WiFi/Ethernet connectivity
- Automatically enables/disables access point mode
@@ -43,7 +53,8 @@ This directory contains systemd service unit files for LEDMatrix services.
These service files are installed by the installation scripts in `scripts/install/`:
- `install_service.sh` installs `ledmatrix.service`
- `install_web_service.sh` installs `ledmatrix-web.service`
- `install_web_service.sh` installs `ledmatrix-web.service` and the
`ledmatrix-update-verify` service and path units
- `install_wifi_monitor.sh` installs `ledmatrix-wifi-monitor.service`
- `install_dns_fix.sh` installs `ledmatrix-dns-fix.service` (opt-in, not run
by the normal installer)
+20
View File
@@ -0,0 +1,20 @@
# This is a template. Do not install it directly -- it is installed with
# ledmatrix-update-verify.service by scripts/install/install_web_service.sh,
# or by the display service when automatic updates are turned on
# (src/auto_update_setup.py). Substitutes:
# __PROJECT_ROOT_DIR__ -> the directory the project is installed in
#
# Starts the automatic update health check when the web interface asks for it
# by creating data/auto_update_verify.request. A path unit rather than a
# sudoers rule: the web interface needs no extra privilege to trigger it, and
# the service it starts runs as the web interface's own user.
[Unit]
Description=Watch for LED Matrix automatic update health check requests
[Path]
PathExists=__PROJECT_ROOT_DIR__/data/auto_update_verify.request
Unit=ledmatrix-update-verify.service
[Install]
WantedBy=multi-user.target
+31
View File
@@ -0,0 +1,31 @@
# This is a template. Do not install it directly -- run
# scripts/install/install_web_service.sh (or install_service.sh); the display
# service also installs it when automatic updates are turned on
# (src/auto_update_setup.py). Substitutes:
# __PROJECT_ROOT_DIR__ -> the directory the project is installed in
# __USER__ -> the user the web interface runs as
#
# Health check and rollback for the web interface's weekly automatic update
# (web_interface/auto_update.py). A separate unit because the check restarts
# ledmatrix-web.service, which would kill it if it ran there.
#
# Never enabled itself: ledmatrix-update-verify.path starts it when the web
# interface creates data/auto_update_verify.request. It runs as the web
# interface's own user, and ExecStart points at the copy the updater makes
# before pulling, so the update being checked cannot replace its checker.
# Command-line paths are quoted so an install folder with spaces still works.
[Unit]
Description=LED Matrix automatic update health check and rollback
[Service]
Type=oneshot
User=__USER__
WorkingDirectory=__PROJECT_ROOT_DIR__
# Consume the request first, so the path unit cannot start this again.
ExecStartPre=/bin/rm -f "__PROJECT_ROOT_DIR__/data/auto_update_verify.request"
ExecStart=/usr/bin/python3 "__PROJECT_ROOT_DIR__/data/auto_update_verifier.py" "__PROJECT_ROOT_DIR__"
TimeoutStartSec=30min
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=ledmatrix-update-verify
+56 -1
View File
@@ -7,7 +7,7 @@ Provides common fixtures for mocking core components and test setup.
import pytest
import sys
from pathlib import Path
from unittest.mock import Mock, MagicMock
from unittest.mock import Mock, MagicMock, NonCallableMock
from typing import Dict, Any, Optional
# Add project root to path
@@ -70,6 +70,61 @@ def pytest_unconfigure(config):
shutil.rmtree(tmp_dir, ignore_errors=True)
# DisplayManager is a process-wide singleton, and the RGBMatrix /
# RGBMatrixOptions names it builds through are module globals bound once at
# import to either the emulator or the hardware library. All three are shared
# by every test module in the run, so a module that leaves a live instance in
# _instance -- or leaves patch('src.display_manager.RGBMatrix') standing --
# changes what the NEXT module constructs. None of that is visible when the
# affected file is run on its own; it surfaces as a full-suite failure that
# does not reproduce. The full-run failure diff is how a change is confirmed
# non-regressive, so it has to mean the same thing on every run.
_PRISTINE_MATRIX_BINDINGS = {}
@pytest.fixture(autouse=True, scope="module")
def _reset_display_manager_globals():
"""Undo any DisplayManager global state a test module leaves behind.
Autouse fixtures are set up ahead of the fixtures a test asks for, so this
one is finalised after them -- including after a module-scoped fixture that
owns a real DisplayManager (test_display_dirty_tracking.py's ``dm``).
Module-scoped rather than per-test: files that deliberately share one
manager across their own tests keep doing so; only the leak across the
module boundary is cut.
"""
# Record the real bindings on the way in, while no patch of this module's
# is active yet -- reading them on the way out would record the leak.
_remember_matrix_bindings()
yield
dm_mod = sys.modules.get("src.display_manager")
if dm_mod is None:
return # Module never imported it; nothing to reset.
# An instance left here is what the next module's DisplayManager() call
# gets back -- potentially one built against a MagicMock matrix.
dm_mod.DisplayManager._instance = None
dm_mod.DisplayManager._initialized = False
# Put a binding back if a patch outlived the module that started it.
# Restoring rather than failing: a leak reported against an innocent module
# later in the run is the diagnosis problem, not the fix for it.
for name, pristine in _PRISTINE_MATRIX_BINDINGS.items():
if isinstance(getattr(dm_mod, name, None), NonCallableMock):
setattr(dm_mod, name, pristine)
def _remember_matrix_bindings():
dm_mod = sys.modules.get("src.display_manager")
if dm_mod is None:
return
for name in ("RGBMatrix", "RGBMatrixOptions"):
current = getattr(dm_mod, name, None)
if current is not None and not isinstance(current, NonCallableMock):
_PRISTINE_MATRIX_BINDINGS.setdefault(name, current)
@pytest.fixture
def mock_display_manager():
"""Create a mock DisplayManager for testing."""
+988 -996
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -36,6 +36,9 @@ nothing is listening, so it stays useful in a bare checkout.
|---|---|---|
| `unit/test_list_filter.js` | no | `ListFilter` search/filter/sort/count/sticky, and the installed-plugins config **extracted verbatim** from `plugins_manager.js` so the test can't drift from it |
| `unit/test_render_cards.js` | no | `renderInstalledCards` markup, both empty states, and HTML-escaping of hostile plugin metadata |
| `unit/test_style_editor_element_keys.js` | no | `elementKeys()`/`styleRows()`/`positionRows()` from `widgets/style-editor.js`: every `customization.layout` entry gets exactly one row -- paired with its style element through core's `x-layout-key` (so `score` belongs to `score_text`, not a second row), or a position row of its own, leaves included -- since the widget claims the whole `layout` block from the generic fallback renderer |
| `unit/test_style_editor_layout_leaf_columns.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only key whose own value is a leaf (no x/y sub-object, e.g. a `show_logo` toggle) gets a self-keyed column instead of a blank, uneditable row |
| `unit/test_style_editor_layout_leaf_collision.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only leaf key still gets its own column even when its name collides with an unrelated element's style sub-field or another layout axis's sub-field |
| `dom/test_installed_dom.js` | yes | The toolbar in a real DOM: pill/search/sort interaction, the HTMX partial re-swap, and a `getComputedStyle` check that `.filter-pill[data-active]` really matches the emitted markup |
| `dom/test_store_dom.js` | yes | Store pagination, per-page, category, tri-state Installed button, and persistence across a re-boot, against the live registry |
| `dom/test_no_double_fetch.js` | yes | Loads the **whole** `plugins_manager.js` and counts requests: typing in the store search must filter the cached list, not refetch `/api/v3/plugins/store/list` |
+3 -1
View File
@@ -15,7 +15,9 @@ const fs = require('fs');
const BASE = process.env.BASE || 'http://localhost:5000';
const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js',
'unit/test_html_escaping.js'];
'unit/test_html_escaping.js', 'unit/test_style_editor_element_keys.js',
'unit/test_style_editor_layout_leaf_columns.js',
'unit/test_style_editor_layout_leaf_collision.js'];
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
'dom/test_tools_sections.js'];
@@ -0,0 +1,126 @@
// Regression test for how style-editor.js decides which rows to draw,
// extracted verbatim from the shipped widget (own/ownObj/elementKeys/
// styleRows/positionRows have no DOM dependency) so the test can't drift from
// the real implementation.
//
// render() claims the whole `layout` child of a customization schema as the
// widget's own -- removing it from the generic fallback renderer entirely,
// because posting the same offset twice from two controls is worse than
// owning slightly too much. That is only safe when every entry under `layout`
// gets exactly one row:
//
// * styleRows(): one per style element, paired with the layout entry that
// holds its offsets. The pairing is not by exact name -- a hand-written
// schema styles 'score_text' but positions 'score' -- so core records it
// as x-layout-key, resolved through the same alias map the renderer reads
// offsets with.
// * positionRows(): one per layout entry no style element claimed -- a logo,
// a timeout indicator, a possession arrow, or a bare leaf like a
// show_logo toggle.
//
// This contract replaced an earlier one in which elementKeys() itself
// appended layout-only keys. That compared names exactly, so an aliased entry
// ('score' for 'score_text') was listed a second time and its offsets got two
// controls posting the same field.
const fs = require('fs');
const path = require('path');
const V3 = path.resolve(__dirname, '../../../web_interface/static/v3');
const src = fs.readFileSync(path.join(V3, 'js/widgets/style-editor.js'), 'utf8');
const start = src.indexOf('function own(obj, key)');
const endMark = 'function control(opts)';
const end = src.indexOf(endMark);
if (start === -1 || end === -1) {
console.error('FAIL: could not locate own()..positionRows() in style-editor.js -- slice markers need updating');
process.exit(1);
}
eval(src.slice(start, end)); // defines own/ownObj/elementKeys/styleRows/positionRows
let failures = 0;
function ok(desc, cond) {
if (cond) { console.log(`ok - ${desc}`); }
else { console.log(`FAIL - ${desc}`); failures++; }
}
function layoutKeysOf(rows) { return rows.map(function (r) { return r.layoutKey; }); }
// score_text is styled and positioned under an alias; odds_text has no
// offsets at all; possession is positioned but never styled; show_logo is a
// bare leaf under layout.
const schema = {
properties: {
score_text: {
type: 'object', 'x-style-managed': true, 'x-layout-key': 'score',
properties: { font: { type: 'string' }, text_color: { type: 'array' } },
},
odds_text: {
type: 'object', 'x-style-managed': true,
properties: { font: { type: 'string' } },
},
layout: {
type: 'object',
'x-propertyOrder': ['score', 'possession', 'show_logo'],
properties: {
score: { type: 'object', properties: { x_offset: {}, y_offset: {} } },
possession: { type: 'object', properties: { x_offset: {}, y_offset: {} } },
show_logo: { type: 'boolean', default: true },
},
},
},
};
const keys = elementKeys(schema);
ok('elementKeys lists the style elements', keys.join(',') === 'score_text,odds_text');
ok('layout and modes containers are never treated as elements',
keys.indexOf('layout') === -1 && keys.indexOf('modes') === -1);
const styled = styleRows(schema);
ok('a style element is paired with the layout entry core resolved for it',
styled[0].key === 'score_text' && styled[0].layoutKey === 'score');
ok('a style element with no offsets claims nothing', styled[1].layoutKey === null);
const positions = positionRows(schema);
ok('a layout-only object entry gets a position row',
layoutKeysOf(positions).indexOf('possession') !== -1);
ok('a layout-only leaf gets a position row too',
layoutKeysOf(positions).indexOf('show_logo') !== -1);
ok('an entry claimed through an alias is not listed again as a position',
layoutKeysOf(positions).indexOf('score') === -1);
ok('positions follow the declared order',
layoutKeysOf(positions).join(',') === 'possession,show_logo');
const every = layoutKeysOf(styled).filter(Boolean).concat(layoutKeysOf(positions));
ok('every layout entry gets exactly one row',
every.length === new Set(every).size
&& every.slice().sort().join(',') === ['possession', 'score', 'show_logo'].join(','));
// A schema with no layout block at all must behave exactly as before --
// the overwhelmingly common case must not gain a phantom row.
const noLayout = {
properties: {
score_text: {
type: 'object', 'x-style-managed': true,
properties: { font: { type: 'string' } },
},
},
};
ok('a schema without a layout block has no positions',
elementKeys(noLayout).join(',') === 'score_text' && positionRows(noLayout).length === 0);
// Without an annotation (the compact declaration, or a schema that never went
// through expansion) the two blocks share one key.
const unannotated = {
properties: {
home_logo: { type: 'object', 'x-style-managed': true, properties: {} },
layout: {
type: 'object',
properties: { home_logo: { type: 'object', properties: { x_offset: {}, scale: {} } } },
},
},
};
ok('an exact-name layout entry is claimed without an annotation',
styleRows(unannotated)[0].layoutKey === 'home_logo'
&& positionRows(unannotated).length === 0);
if (failures) { console.log(`\n${failures} failure(s)`); process.exit(1); }
console.log('\nall checks passed');
@@ -0,0 +1,76 @@
// Regression test for style-editor.js's columnsFor(), extracted verbatim
// from the shipped widget (see test_style_editor_layout_leaf_columns.js for
// the base case this builds on).
//
// A layout-only leaf key (e.g. a bare "scale" toggle directly under
// `layout`, no x/y object underneath) used to be keyed in the internal
// `seen` map by its own bare field name. If some *other* element's style
// block, or another element's layout axis block, happened to declare a
// sub-field with that exact same name (e.g. "scale" is also a real
// COLUMN_ORDER axis name most elements use), the `!seen.has(key)` guard
// skipped creating the leaf's column -- it silently reused the existing
// 'element'/'layout' column instead. That column's row binding in
// elementRow() then looked the field up under the wrong schema location for
// the leaf's own row, found nothing, and rendered a blank cell: the same
// "silently disappears" bug the leaf-column fix was meant to close, just
// reached through a name collision instead of a missing column altogether.
// This pins the fix: layout-leaf columns are keyed under a namespaced id so
// they can never be shadowed by an unrelated column sharing their name.
const fs = require('fs');
const path = require('path');
const V3 = path.resolve(__dirname, '../../../web_interface/static/v3');
const src = fs.readFileSync(path.join(V3, 'js/widgets/style-editor.js'), 'utf8');
const start = src.indexOf('function own(obj, key)');
const endMark = 'function control(opts)';
const end = src.indexOf(endMark);
if (start === -1 || end === -1) {
console.error('FAIL: could not locate own()/columnsFor() in style-editor.js -- slice markers need updating');
process.exit(1);
}
eval(src.slice(start, end)); // defines own/ownObj/elementKeys/columnsFor
let failures = 0;
function ok(desc, cond) {
if (cond) { console.log(`ok - ${desc}`); }
else { console.log(`FAIL - ${desc}`); failures++; }
}
// `possession` is a real style element whose layout axis block declares a
// "scale" sub-field, which seeds seen.set('scale', 'layout'). `timeout_flag`
// is layout-only and a bare leaf valued directly under layout, but its own
// key is *also* "scale" -- an unrelated collision with that same name.
const schema = {
properties: {
possession: {
type: 'object',
'x-style-managed': true,
properties: { font: { type: 'string' } },
},
scale: {
// The collision: a second element literally named "scale", entirely
// unrelated to the layout-only leaf below sharing that string.
type: 'boolean',
},
layout: {
type: 'object',
properties: {
possession: { type: 'object', properties: { scale: { type: 'number' } } },
scale: { type: 'boolean', default: true },
},
},
},
};
const columns = columnsFor(schema);
const leafColumns = columns.filter(function (c) { return c.where === 'layout-leaf'; });
ok('the colliding layout axis sub-field still gets its own column',
columns.some(function (c) { return c.key === 'scale' && c.where === 'layout'; }));
ok('the layout-only leaf key still gets its own column despite the name collision',
leafColumns.length === 1 && leafColumns[0].key === 'scale');
ok('the leaf column is distinct from the colliding element/layout column',
columns.filter(function (c) { return c.key === 'scale'; }).length === 2);
if (failures) { console.log(`\n${failures} failure(s)`); process.exit(1); }
console.log('\nall checks passed');
@@ -0,0 +1,89 @@
// Regression test for style-editor.js's columnsFor(), extracted verbatim
// from the shipped widget (own/ownObj/elementKeys/columnsFor have no DOM
// dependency) so the test can't drift from the real implementation.
//
// elementKeys() already appends a layout-only key that never got its own
// top-level style block (see test_style_editor_element_keys.js), so table()
// draws a row for it. But render() still claims the whole `layout` child as
// the widget's own regardless of what that row actually shows -- and
// columnsFor() only produced a column for a layout-only key whose own value
// is an *object* with sub-fields (x_offset, y_offset, ...). A hand-written
// schema can instead put a plain leaf value directly under layout -- a
// "show_logo" toggle, a timeout indicator's on/off flag -- with no x/y
// object underneath it. That key still got a row (data-element="show_logo"),
// but zero columns ever matched it, so the row rendered with every cell
// blank: no control, and no way back to the fallback since layout was
// removed wholesale. This pins the fix: such a key gets its own
// self-keyed 'layout-leaf' column.
const fs = require('fs');
const path = require('path');
const V3 = path.resolve(__dirname, '../../../web_interface/static/v3');
const src = fs.readFileSync(path.join(V3, 'js/widgets/style-editor.js'), 'utf8');
const start = src.indexOf('function own(obj, key)');
const endMark = 'function control(opts)';
const end = src.indexOf(endMark);
if (start === -1 || end === -1) {
console.error('FAIL: could not locate own()/columnsFor() in style-editor.js -- slice markers need updating');
process.exit(1);
}
eval(src.slice(start, end)); // defines own/ownObj/elementKeys/columnsFor
let failures = 0;
function ok(desc, cond) {
if (cond) { console.log(`ok - ${desc}`); }
else { console.log(`FAIL - ${desc}`); failures++; }
}
// score_text is a real style element; possession is layout-only but shaped
// like an object (x/y); show_logo is layout-only and a bare leaf value.
const schema = {
properties: {
score_text: {
type: 'object',
'x-style-managed': true,
properties: { font: { type: 'string' }, text_color: { type: 'array' } },
},
layout: {
type: 'object',
properties: {
score_text: { type: 'object', properties: { x_offset: {}, y_offset: {} } },
possession: { type: 'object', properties: { x_offset: {}, y_offset: {} } },
show_logo: { type: 'boolean', default: true },
},
},
},
};
const columns = columnsFor(schema);
const byKey = {};
columns.forEach(function (c) { byKey[c.key] = c; });
ok('an element style field still gets its own column', byKey.font && byKey.font.where === 'element');
ok('a layout-only object key still contributes its x/y columns',
byKey.x_offset && byKey.x_offset.where === 'layout');
ok('a layout-only leaf key gets a column keyed to itself',
!!byKey.show_logo);
ok('...marked as a layout-leaf column, not a shared sub-field',
byKey.show_logo && byKey.show_logo.where === 'layout-leaf');
ok('nothing is duplicated', columns.length === new Set(columns.map(function (c) { return c.key; })).size);
// A schema with no leaf-valued layout key must not gain a phantom column --
// this is the common case (every existing scoreboard) and must be unaffected.
const noLeaf = {
properties: {
score_text: {
type: 'object', 'x-style-managed': true,
properties: { font: { type: 'string' } },
},
layout: {
type: 'object',
properties: { score_text: { type: 'object', properties: { x_offset: {} } } },
},
},
};
ok('a schema with only object-shaped layout keys gets no layout-leaf column',
columnsFor(noLeaf).every(function (c) { return c.where !== 'layout-leaf'; }));
if (failures) { console.log(`\n${failures} failure(s)`); process.exit(1); }
console.log('\nall checks passed');
+126
View File
@@ -26,6 +26,9 @@ def wifi_manager():
"""Patch WiFiManager where it is defined; yield the instance mock."""
with patch("src.wifi_manager.WiFiManager") as cls:
instance = MagicMock()
# A bare MagicMock is truthy, which would send every connect down
# the setup-AP background path.
instance._is_ap_mode_active.return_value = False
cls.return_value = instance
yield instance
@@ -97,6 +100,129 @@ class TestConnect:
assert "RuntimeError" in body["details"]
class TestConnectThroughSetupAp:
"""With the setup AP up, the phone making the request is connected through
the very network the connect tears down. A synchronous answer can never
arrive, so the route answers 202 first and connects in the background."""
URL = "/api/v3/wifi/connect"
@pytest.fixture(autouse=True)
def ap_active(self, api_v3_module, wifi_manager, monkeypatch):
from web_interface.blueprints.api_v3 import wifi as wifi_routes
wifi_manager._is_ap_mode_active.return_value = True
monkeypatch.setattr(wifi_routes, "_AP_HANDOFF_DELAY_SECONDS", 0)
monkeypatch.setattr(wifi_routes, "_last_connect_attempt", None)
self.spawned = []
monkeypatch.setattr(wifi_routes, "_spawn", self.spawned.append)
self.routes = wifi_routes
def _run_spawned(self):
assert len(self.spawned) == 1
self.spawned.pop()()
def test_answers_202_before_connecting(self, api_v3_client, wifi_manager):
response = api_v3_client.post(self.URL, json={"ssid": "HomeNet", "password": "hunter22"})
assert response.status_code == 202
assert response.get_json()["status"] == "pending"
wifi_manager.connect_to_network.assert_not_called()
assert self.routes._last_connect_snapshot()["state"] == "pending"
def test_background_result_is_reported_by_status(self, api_v3_client, wifi_manager):
wifi_manager.connect_to_network.return_value = (True, "Connected to HomeNet")
api_v3_client.post(self.URL, json={"ssid": "HomeNet", "password": "hunter22"})
self._run_spawned()
wifi_manager.connect_to_network.assert_called_once_with("HomeNet", "hunter22")
wifi_manager.config = {}
wifi_manager.get_wifi_status.return_value = MagicMock(
connected=True, ssid="HomeNet", ip_address="10.0.0.5", signal=70,
ap_mode_active=False)
attempt = api_v3_client.get("/api/v3/wifi/status").get_json()["data"]["last_connect_attempt"]
assert attempt["ssid"] == "HomeNet"
assert attempt["state"] == "success"
assert "hunter22" not in str(attempt)
def test_wrong_password_is_flagged(self, api_v3_client, wifi_manager):
wifi_manager.connect_to_network.return_value = (
False, "wrong_password: Secrets were required, but not provided")
api_v3_client.post(self.URL, json={"ssid": "HomeNet", "password": "hunter22"})
self._run_spawned()
attempt = self.routes._last_connect_snapshot()
assert attempt["state"] == "failed"
assert attempt["error_type"] == "wrong_password"
assert attempt["message"] == "Incorrect password for HomeNet"
def test_manager_exception_is_recorded_as_a_failure(self, api_v3_client, wifi_manager):
wifi_manager.connect_to_network.side_effect = RuntimeError("nmcli vanished")
api_v3_client.post(self.URL, json={"ssid": "HomeNet"})
self._run_spawned()
attempt = self.routes._last_connect_snapshot()
assert attempt["state"] == "failed"
assert "RuntimeError" in attempt["message"]
def test_a_second_connect_while_one_is_pending_is_refused(self, api_v3_client, wifi_manager):
api_v3_client.post(self.URL, json={"ssid": "HomeNet"})
response = api_v3_client.post(self.URL, json={"ssid": "OtherNet"})
assert response.status_code == 409
assert len(self.spawned) == 1
def test_pending_is_honoured_after_the_ap_has_gone_down(self, api_v3_client, wifi_manager):
# The background attempt tears the AP down long before it finishes; a
# second click must not slip through as a synchronous connect.
api_v3_client.post(self.URL, json={"ssid": "HomeNet"})
wifi_manager._is_ap_mode_active.return_value = False
response = api_v3_client.post(self.URL, json={"ssid": "OtherNet"})
assert response.status_code == 409
wifi_manager.connect_to_network.assert_not_called()
def test_a_thread_that_fails_to_start_does_not_leave_pending_behind(
self, api_v3_client, wifi_manager, monkeypatch):
def refuse(_target):
raise RuntimeError("can't start new thread")
monkeypatch.setattr(self.routes, "_spawn", refuse)
assert api_v3_client.post(self.URL, json={"ssid": "HomeNet"}).status_code == 500
assert self.routes._last_connect_snapshot() is None
monkeypatch.setattr(self.routes, "_spawn", self.spawned.append)
assert api_v3_client.post(self.URL, json={"ssid": "HomeNet"}).status_code == 202
class TestConnectSerializedWithoutAp:
"""Without the AP the route connects synchronously, but still one attempt
at a time: a second connect_to_network would remove the in-progress flag
the daemon relies on when the first finishes."""
URL = "/api/v3/wifi/connect"
@pytest.fixture(autouse=True)
def no_prior_attempt(self, api_v3_module, monkeypatch):
from web_interface.blueprints.api_v3 import wifi as wifi_routes
monkeypatch.setattr(wifi_routes, "_last_connect_attempt", None)
self.routes = wifi_routes
def test_a_connect_during_a_synchronous_connect_is_refused(
self, api_v3_client, wifi_manager):
inner = []
def connect(ssid, password):
if not inner:
inner.append(api_v3_client.post(self.URL, json={"ssid": "OtherNet"}))
return True, f"Connected to {ssid}"
wifi_manager.connect_to_network.side_effect = connect
response = api_v3_client.post(self.URL, json={"ssid": "HomeNet"})
assert response.status_code == 200
assert inner[0].status_code == 409
wifi_manager.connect_to_network.assert_called_once_with("HomeNet", "")
assert self.routes._last_connect_snapshot()["state"] == "success"
def test_an_exception_does_not_leave_pending_behind(self, api_v3_client, wifi_manager):
wifi_manager.connect_to_network.side_effect = RuntimeError("nmcli gone")
assert api_v3_client.post(self.URL, json={"ssid": "HomeNet"}).status_code == 500
assert self.routes._last_connect_snapshot()["state"] == "failed"
class TestDisconnect:
URL = "/api/v3/wifi/disconnect"
+683
View File
@@ -0,0 +1,683 @@
"""Weekly automatic updates (web_interface/auto_update.py).
The updater pulls code and restarts services unattended. These pin down that
it runs only when asked and due, refuses to touch a checkout it could damage,
never leaves new code running without a health check, and reports every
failure instead of logging it and moving on.
Git runs for real against a throwaway origin/device clone pair; systemd, sudo
and the health check service are faked.
"""
import json
import shutil
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from web_interface import auto_update as au # noqa: E402
# Local time is pinned to UTC below, so 03:00 is inside the quiet hours.
NIGHT = datetime(2026, 9, 14, 3, 0, tzinfo=timezone.utc).timestamp()
NOON = datetime(2026, 9, 14, 12, 0, tzinfo=timezone.utc).timestamp()
ON = {'auto_update': {'enabled': True}}
OFF = {'auto_update': {'enabled': False}}
needs_git = pytest.mark.skipif(shutil.which('git') is None, reason='git not installed')
@pytest.fixture(autouse=True)
def local_is_utc(monkeypatch):
"""Pin 'local time' so quiet-hours checks don't depend on the host zone."""
monkeypatch.setattr(au, '_zone', lambda name: timezone.utc)
def git(cwd, *args):
result = subprocess.run(['git', '-c', 'user.name=t', '-c', 'user.email=t@example.com', *args],
cwd=str(cwd), capture_output=True, text=True)
assert result.returncode == 0, result.stderr
return result.stdout.strip()
class Repo:
"""An origin, a checkout that publishes to it, and the device's clone."""
def __init__(self, tmp):
self.seed = tmp / 'seed'
self.seed.mkdir()
git(self.seed, 'init', '-q', '-b', 'main')
(self.seed / 'app.py').write_text('v = 1\n')
git(self.seed, 'add', '.')
git(self.seed, 'commit', '-qm', 'one')
self.origin = tmp / 'origin.git'
git(tmp, 'clone', '-q', '--bare', str(self.seed), str(self.origin))
git(self.seed, 'remote', 'add', 'origin', str(self.origin))
self.device = tmp / 'device'
git(tmp, 'clone', '-q', str(self.origin), str(self.device))
def head(self):
return git(self.device, 'rev-parse', 'HEAD')
def publish(self, text='v = 2\n'):
(self.seed / 'app.py').write_text(text)
git(self.seed, 'commit', '-qam', 'next')
git(self.seed, 'push', '-q', 'origin', 'main')
return git(self.seed, 'rev-parse', 'HEAD')
def real_pull(device, **extra):
def core_update():
before = git(device, 'rev-parse', 'HEAD')
r = subprocess.run(['git', 'pull', '-q', '--rebase', '--autostash'],
cwd=str(device), capture_output=True, text=True)
after = git(device, 'rev-parse', 'HEAD')
result = {'status': 'success' if r.returncode == 0 else 'error',
'message': 'Code updated successfully.' if r.returncode == 0 else r.stderr,
'restart_required': before != after, 'dependency_failures': []}
result.update(extra)
return result
return core_update
class FakeStore:
def __init__(self, plugins_dir, versions, bump=(), fail=()):
self.plugins_dir = str(plugins_dir)
self.bump, self.fail = set(bump), set(fail)
for pid, version in versions.items():
d = Path(plugins_dir) / pid
d.mkdir(parents=True)
(d / 'manifest.json').write_text(json.dumps({'id': pid, 'version': version}))
self.updated_calls = []
def list_installed_plugins(self):
return [p.name for p in Path(self.plugins_dir).iterdir()]
def _get_local_git_info(self, path):
return None
def update_plugin(self, pid):
self.updated_calls.append(pid)
if pid in self.fail:
return False
if pid in self.bump:
path = Path(self.plugins_dir) / pid / 'manifest.json'
m = json.loads(path.read_text())
m['version'] = '9.9.9'
path.write_text(json.dumps(m))
return True
class Harness:
"""An AutoUpdater on a real clone.
``helper`` is whether the health check's path unit is active; ``pickup``
whether the health check actually starts when asked. Starting it is
simulated the way systemd does it: the request file is consumed
(ExecStartPre) and the verifier marks the pending update "verifying".
"""
def __init__(self, tmp, repo, *, helper=True, pickup=True, core_update=None, store=None,
disk_free=10 ** 12, display_active=True, enabled=True, clock=NIGHT):
self.sudo, self.restarts, self.now = [], [], clock
self.state_at_handoff = None
def run(args, **kwargs):
if args[0] == 'sudo':
self.sudo.append(list(args))
return subprocess.CompletedProcess(args, 0, stdout='', stderr='')
return subprocess.run(args, **kwargs)
def sleep(seconds):
updater = self.updater
if self.state_at_handoff is None:
self.state_at_handoff = au.load_state(updater.state_file)
if pickup and updater.request_file.exists():
updater.request_file.unlink()
pending = au._read_json(updater.pending_file)
pending['status'] = 'verifying'
au._write_json(updater.pending_file, pending)
self.config = {'auto_update': {'enabled': enabled}, 'timezone': 'UTC'}
cm = MagicMock()
cm.load_config.return_value = self.config
self.updater = au.AutoUpdater(
config_manager=cm, core_update=core_update or real_pull(repo.device),
store_manager=store, project_root=repo.device, clock=lambda: self.now,
restart=self.restarts.append, run=run,
service_active=lambda unit: display_active, helper_ready=lambda: helper,
disk_free=lambda path: disk_free, sleep=sleep)
@property
def state(self):
return au.load_state(self.updater.state_file)
@property
def pending(self):
return au._read_json(self.updater.pending_file)
def write_pending(self, **fields):
au._write_json(self.updater.pending_file, fields)
def stub_updater(tmp_path, outcome='up_to_date', message='ok', store=None, enabled=True, clock=NIGHT):
"""For scheduling and plugin tests: the core update is stubbed out."""
cm = MagicMock()
cm.load_config.return_value = {'auto_update': {'enabled': enabled}, 'timezone': 'UTC'}
restarts = []
updater = au.AutoUpdater(config_manager=cm, core_update=MagicMock(), store_manager=store,
project_root=tmp_path, clock=lambda: clock,
restart=restarts.append)
updater.update_core = MagicMock(return_value={'outcome': outcome, 'message': message})
return updater, restarts
# -- scheduling ---------------------------------------------------------------
class TestIsDue:
def test_not_before_next_due(self):
assert not au.is_due(100, 200, 3)
def test_in_quiet_hours_once_due(self):
assert au.is_due(200, 200, 3)
def test_waits_for_quiet_hours(self):
assert not au.is_due(200, 100, 12)
def test_gives_up_waiting_after_grace(self):
assert au.is_due(100 + au.QUIET_HOURS_GRACE_SECONDS, 100, 12)
class TestTick:
def test_disabled_never_runs(self, tmp_path):
updater, _ = stub_updater(tmp_path, enabled=False)
assert updater.tick() is False
updater.update_core.assert_not_called()
def test_enabling_at_noon_waits_for_the_night(self, tmp_path):
updater, _ = stub_updater(tmp_path, clock=NOON)
assert updater.tick() is False
updater.update_core.assert_not_called()
assert au.load_state(updater.state_file)['next_due'] == NOON
def test_runs_when_due_and_schedules_a_week_out(self, tmp_path):
updater, _ = stub_updater(tmp_path)
assert updater.tick() is True
state = au.load_state(updater.state_file)
assert state['next_due'] == NIGHT + au.UPDATE_INTERVAL_SECONDS
assert updater.tick() is False, "ran twice in one night"
def test_transient_error_retries_next_day(self, tmp_path):
updater, _ = stub_updater(tmp_path, outcome='error', message='no network')
updater.tick()
state = au.load_state(updater.state_file)
assert state['next_due'] == NIGHT + au.RETRY_AFTER_FAILURE_SECONDS
assert state['alert']['message'] == 'no network'
def test_blocked_keeps_weekly_cadence_but_alerts(self, tmp_path):
updater, _ = stub_updater(tmp_path, outcome='blocked', message='local edits')
updater.tick()
state = au.load_state(updater.state_file)
assert state['next_due'] == NIGHT + au.UPDATE_INTERVAL_SECONDS
assert 'local edits' in state['alert']['message']
# -- checks before touching the checkout ---------------------------------------
@needs_git
class TestPreflightRefuses:
def test_nothing_new_is_up_to_date(self, tmp_path):
repo = Repo(tmp_path)
h = Harness(tmp_path, repo)
result = h.updater.run()
assert result['core_outcome'] == 'up_to_date'
assert result['status'] == 'success'
assert h.sudo == [] and 'alert' not in h.state
def test_without_the_health_check_code_is_not_touched(self, tmp_path):
repo = Repo(tmp_path)
old = repo.head()
repo.publish()
h = Harness(tmp_path, repo, helper=False)
result = h.updater.run()
assert result['core_outcome'] == 'blocked'
assert repo.head() == old
assert 'health check is set up' in result['core_message']
assert h.state['alert']
def test_local_edits_are_never_stashed(self, tmp_path):
repo = Repo(tmp_path)
old = repo.head()
repo.publish()
(repo.device / 'app.py').write_text('mine\n')
h = Harness(tmp_path, repo)
result = h.updater.run()
assert result['core_outcome'] == 'blocked'
assert 'app.py' in result['core_message']
assert repo.head() == old
assert (repo.device / 'app.py').read_text() == 'mine\n'
assert git(repo.device, 'stash', 'list') == ''
def test_local_commits_block(self, tmp_path):
repo = Repo(tmp_path)
repo.publish()
(repo.device / 'extra.txt').write_text('x\n')
git(repo.device, 'add', 'extra.txt')
git(repo.device, 'commit', '-qm', 'local')
mine = repo.head()
h = Harness(tmp_path, repo)
result = h.updater.run()
assert result['core_outcome'] == 'blocked'
assert 'local commit' in result['core_message']
assert repo.head() == mine
def test_a_rebase_really_in_progress_blocks(self, tmp_path):
repo = Repo(tmp_path)
repo.publish('v = 2\n')
(repo.device / 'app.py').write_text('v = mine\n')
git(repo.device, 'commit', '-qam', 'local')
git(repo.device, 'fetch', '-q')
stopped = subprocess.run(['git', '-c', 'user.name=t', '-c', 'user.email=t@example.com',
'rebase', 'origin/main'], cwd=str(repo.device), capture_output=True, text=True)
assert stopped.returncode != 0, "the rebase should stop on the conflict"
result = Harness(tmp_path, repo).updater.run()
assert result['core_outcome'] == 'blocked'
assert 'rebase is in progress' in result['core_message']
assert (repo.device / '.git' / 'rebase-merge').exists(), "a live rebase must be left alone"
def test_an_abandoned_rebase_is_cleared_and_the_update_runs(self, tmp_path):
"""Found on a real device: a pull stopped mid-rebase a week earlier,
HEAD was later checked out to a branch, and the leftover rebase
directory would have blocked every automatic update forever."""
repo = Repo(tmp_path)
leftover = repo.device / '.git' / 'rebase-merge'
leftover.mkdir()
(leftover / 'head-name').write_text('refs/heads/main\n')
new = repo.publish()
result = Harness(tmp_path, repo).updater.run()
assert not leftover.exists()
assert result['core_outcome'] == 'verifying'
assert repo.head() == new
def test_a_merge_in_progress_blocks(self, tmp_path):
repo = Repo(tmp_path)
repo.publish()
(repo.device / '.git' / 'MERGE_HEAD').write_text(repo.head() + '\n')
result = Harness(tmp_path, repo).updater.run()
assert result['core_outcome'] == 'blocked'
assert 'merge is in progress' in result['core_message']
def test_low_disk_blocks(self, tmp_path):
repo = Repo(tmp_path)
repo.publish()
result = Harness(tmp_path, repo, disk_free=10 * 1024 * 1024).updater.run()
assert result['core_outcome'] == 'blocked'
assert 'disk space' in result['core_message']
def test_unreachable_origin_retries_tomorrow(self, tmp_path):
repo = Repo(tmp_path)
git(repo.device, 'remote', 'set-url', 'origin', str(tmp_path / 'nowhere'))
h = Harness(tmp_path, repo)
result = h.updater.run()
assert result['core_outcome'] == 'error'
assert h.state['next_due'] == NIGHT + au.RETRY_AFTER_FAILURE_SECONDS
def test_version_already_rolled_back_is_not_retried(self, tmp_path):
repo = Repo(tmp_path)
old = repo.head()
bad = repo.publish()
h = Harness(tmp_path, repo)
au.save_state({'rolled_back_head': bad}, h.updater.state_file)
result = h.updater.run()
assert result['core_outcome'] == 'up_to_date'
assert 'rolled back before' in result['core_message']
assert repo.head() == old and h.sudo == []
# -- the update and its hand-off to the health check ---------------------------
@needs_git
class TestUpdateIsVerified:
def test_pull_hands_off_to_the_health_check(self, tmp_path):
repo = Repo(tmp_path)
old = repo.head()
new = repo.publish()
store = FakeStore(tmp_path / 'plugins', {'weather': '1.0.0'}, bump={'weather'})
h = Harness(tmp_path, repo, store=store)
result = h.updater.run()
assert result['core_outcome'] == 'verifying' and result['status'] == 'pending'
assert repo.head() == new
assert h.pending == {**h.pending, 'status': 'verifying', 'old_head': old, 'new_head': new,
'display_was_active': True, 'dependency_failures': []}
assert not h.updater.request_file.exists(), "the request is consumed when the check starts"
assert h.sudo == [], "triggering the health check needs no privilege"
assert h.restarts == [], "restarts belong to the health check, not the web process"
assert store.updated_calls == [], "plugins must wait until the new code is verified"
assert h.state['plugins_pending'] is True
assert h.updater.verifier_copy.read_text() == au.VERIFIER_SOURCE.read_text()
def test_state_is_saved_before_the_health_check_restarts_this_process(self, tmp_path):
repo = Repo(tmp_path)
repo.publish()
h = Harness(tmp_path, repo)
h.updater.run()
assert h.state_at_handoff['last_result']['core_outcome'] == 'verifying'
assert h.state_at_handoff['plugins_pending'] is True
def test_dependency_failures_reach_the_health_check(self, tmp_path):
repo = Repo(tmp_path)
repo.publish()
h = Harness(tmp_path, repo,
core_update=real_pull(repo.device, dependency_failures=['requirements.txt']))
h.updater.run()
assert h.pending['dependency_failures'] == ['requirements.txt']
def test_a_health_check_that_never_starts_means_the_update_is_undone(self, tmp_path):
repo = Repo(tmp_path)
old = repo.head()
repo.publish()
h = Harness(tmp_path, repo, pickup=False)
result = h.updater.run()
assert repo.head() == old
assert result['core_outcome'] == 'blocked'
assert 'did not start' in result['core_message']
assert h.pending is None and not h.updater.request_file.exists()
assert h.state['alert']
def test_failed_pull_retries_tomorrow(self, tmp_path):
repo = Repo(tmp_path)
old = repo.head()
repo.publish()
h = Harness(tmp_path, repo,
core_update=lambda: {'status': 'error', 'message': 'Update failed: network'})
result = h.updater.run()
assert result['core_outcome'] == 'error'
assert repo.head() == old
assert h.state['next_due'] == NIGHT + au.RETRY_AFTER_FAILURE_SECONDS
def test_failed_pull_that_moved_head_is_rolled_back(self, tmp_path):
repo = Repo(tmp_path)
old = repo.head()
repo.publish()
pull = real_pull(repo.device)
def half_failed():
pull()
return {'status': 'error', 'message': 'Update failed: interrupted'}
result = Harness(tmp_path, repo, core_update=half_failed).updater.run()
assert repo.head() == old
assert 'rolled back' in result['core_message']
# -- reporting the health check's outcome --------------------------------------
@needs_git
class TestHealthCheckOutcome:
def _after_update(self, tmp_path, **pending):
repo = Repo(tmp_path)
store = FakeStore(tmp_path / 'plugins', {'weather': '1.0.0'}, bump={'weather'})
h = Harness(tmp_path, repo, store=store)
au.save_state({'plugins_pending': True, 'next_due': NIGHT + au.UPDATE_INTERVAL_SECONDS,
'last_result': {'core_outcome': 'verifying', 'status': 'pending',
'plugins_deferred': True}}, h.updater.state_file)
h.write_pending(old_head='a' * 40, new_head='b' * 40, created_at=NIGHT, **pending)
return h, store
def test_waits_while_the_check_runs(self, tmp_path):
h, store = self._after_update(tmp_path, status='verifying')
h.now = NIGHT + 60
assert h.updater.tick() is False
assert store.updated_calls == [] and h.pending
def test_success_then_runs_the_deferred_plugins(self, tmp_path):
h, store = self._after_update(tmp_path, status='success')
assert h.updater.tick() is True
result = h.state['last_result']
assert result['core_outcome'] == 'updated' and result['status'] == 'success'
assert result['plugins_updated'] == ['weather']
assert h.restarts == ['ledmatrix']
assert 'alert' not in h.state and h.pending is None
def test_rollback_alerts_and_remembers_the_bad_commit(self, tmp_path):
h, store = self._after_update(tmp_path, status='rolled_back',
reason='the web interface did not respond')
h.updater.tick()
assert 'rolled back' in h.state['alert']['message']
assert 'the web interface did not respond' in h.state['alert']['message']
assert h.state['rolled_back_head'] == 'b' * 40
assert store.updated_calls == ['weather'], "the old code is healthy; plugins may update"
def test_failed_rollback_alerts_and_skips_plugins(self, tmp_path):
h, store = self._after_update(tmp_path, status='rollback_failed',
reason='display down', detail='reset failed')
h.updater.tick()
assert f'git reset --hard {"a" * 40}' in h.state['alert']['message']
assert store.updated_calls == []
assert h.state['plugins_pending'] is False
def test_a_check_that_never_reports_back_alerts(self, tmp_path):
h, _ = self._after_update(tmp_path, status='verifying')
h.now = NIGHT + au.VERIFY_LOST_SECONDS + 1
h.updater.tick()
assert h.state['last_result']['core_outcome'] == 'lost'
assert 'never reported back' in h.state['alert']['message']
def test_dismissed_alert_stays_hidden_until_a_new_one(self, tmp_path):
h, _ = self._after_update(tmp_path, status='rolled_back', reason='x')
h.updater.tick()
alert_id = h.state['alert']['id']
def status():
return au.describe_status(h.config, state_file=h.updater.state_file,
pending_file=h.updater.pending_file,
setup_file=tmp_path / 'no-setup.json', helper=lambda: True)
assert status()['alert_id'] == alert_id
au.dismiss_alert(alert_id, h.updater.state_file)
assert status()['alert'] is None
state = h.state
state['alert'] = {'id': 'newer', 'message': 'again'}
au.save_state(state, h.updater.state_file)
assert status()['alert'] == 'again'
def test_describe_status_reports_setup(tmp_path):
setup = tmp_path / 'setup.json'
setup.write_text(json.dumps({'status': 'failed', 'message': 'not root'}))
kwargs = dict(state={}, state_file=tmp_path / 's.json', pending_file=tmp_path / 'p.json',
setup_file=setup)
on = au.describe_status(ON, helper=lambda: False, **kwargs)
assert on['verifier_installed'] is False
assert (on['setup_status'], on['setup_message']) == ('failed', 'not root')
def never(): raise AssertionError("systemctl asked while updates are off")
assert au.describe_status(OFF, helper=never, **kwargs)['verifier_installed'] is None
# -- setting it up from the web UI ----------------------------------------------
class TestSetupFromTheWebUI:
def _host(self, monkeypatch, ready=False, active=True, restart_ok=True):
restarts = []
monkeypatch.setattr(au, 'helper_ready', lambda: ready)
monkeypatch.setattr(au, '_service_active', lambda unit: active)
monkeypatch.setattr(au, 'restart_service', lambda unit: restarts.append(unit) or restart_ok)
return restarts
def test_switching_on_restarts_the_display_to_finish_setup(self, monkeypatch):
restarts = self._host(monkeypatch)
note = au.start_setup_if_needed(False, ON)
assert restarts == ['ledmatrix']
assert 'display is restarting' in note
@pytest.mark.parametrize('was_enabled, config, ready', [
(True, ON, False), # already on: an unrelated save must not restart the display
(False, OFF, False), # still off
(False, ON, True), # already set up
])
def test_nothing_to_do(self, monkeypatch, was_enabled, config, ready):
restarts = self._host(monkeypatch, ready=ready)
assert au.start_setup_if_needed(was_enabled, config) is None
assert restarts == []
def test_a_stopped_display_is_not_started(self, monkeypatch):
restarts = self._host(monkeypatch, active=False)
assert 'next time the display service starts' in au.start_setup_if_needed(False, ON)
assert restarts == []
def test_a_failed_restart_says_so(self, monkeypatch):
self._host(monkeypatch, restart_ok=False)
assert 'Could not restart' in au.start_setup_if_needed(False, ON)
# -- plugins --------------------------------------------------------------------
class TestPlugins:
def test_nothing_changed_restarts_nothing(self, tmp_path):
store = FakeStore(tmp_path / 'plugins', {'clock': '1.0.0'})
updater, restarts = stub_updater(tmp_path, store=store)
updater.run()
assert store.updated_calls == ['clock']
assert restarts == []
def test_plugin_update_restarts_display_only(self, tmp_path):
store = FakeStore(tmp_path / 'plugins', {'clock': '1.0.0', 'weather': '1.0.0'},
bump={'weather'})
updater, restarts = stub_updater(tmp_path, store=store)
result = updater.run()
assert result['plugins_updated'] == ['weather']
assert restarts == ['ledmatrix']
def test_plugin_failure_alerts_but_keeps_weekly_cadence(self, tmp_path):
store = FakeStore(tmp_path / 'plugins', {'zip-only': '1.0.0'}, fail={'zip-only'})
updater, _ = stub_updater(tmp_path, store=store)
result = updater.run()
assert result['plugins_failed'] == ['zip-only'] and result['status'] == 'error'
state = au.load_state(updater.state_file)
assert 'zip-only' in state['alert']['message']
assert state['next_due'] == NIGHT + au.UPDATE_INTERVAL_SECONDS
def test_local_only_plugins_are_left_alone(self, tmp_path):
store = FakeStore(tmp_path / 'plugins', {'mine': '1.0.0'})
path = tmp_path / 'plugins' / 'mine' / 'manifest.json'
path.write_text(json.dumps({'id': 'mine', 'version': '1.0.0', 'local_only': True}))
updater, _ = stub_updater(tmp_path, store=store)
updater.run()
assert store.updated_calls == []
def test_restart_service_skips_a_stopped_unit(monkeypatch):
calls = []
def run(args, **kwargs):
calls.append(args)
return MagicMock(stdout='inactive\n', returncode=3, stderr='')
monkeypatch.setattr(au.subprocess, 'run', run)
assert au.restart_service('ledmatrix') is False
assert calls == [['systemctl', 'is-active', 'ledmatrix']], (
"a display the user stopped must not be started by an update")
def test_core_update_names_the_requirement_files_that_failed():
"""The health check refuses code whose dependencies did not install, so
the failure has to arrive as data, not only as a sentence in the message."""
from web_interface.blueprints import api_v3 as pkg
from web_interface.blueprints.api_v3 import system
heads = iter(['aaa111\n', 'bbb222\n'])
def run(args, **kwargs):
if args[:2] == ['git', 'rev-parse'] and args[-1] == 'HEAD':
out = next(heads, 'bbb222\n')
elif args[:2] == ['git', 'diff']:
out = 'requirements.txt\n'
elif '@{u}' in args:
out = 'origin/main\n'
else:
out = ''
return subprocess.CompletedProcess(args, 0, stdout=out, stderr='')
pkg.api_v3.plugin_store_manager = None
failed_install = subprocess.CompletedProcess([], 1, stdout='', stderr='boom')
with patch.object(pkg.subprocess, 'run', run), \
patch.object(system, '_pip_install_requirements', return_value=failed_install):
result = system.perform_core_update()
assert result['status'] == 'success'
assert result['dependency_failures'] == ['requirements.txt']
# -- web routes -------------------------------------------------------------------
@pytest.fixture
def api_client(monkeypatch):
from flask import Flask
from web_interface.blueprints.api_v3 import api_v3
app = Flask(__name__)
app.register_blueprint(api_v3, url_prefix='/api/v3')
cm = MagicMock()
cm.load_config.return_value = {'timezone': 'UTC', 'auto_update': {'enabled': True}}
cm.get_raw_file_content.return_value = {}
cm.save_config_atomic.return_value = MagicMock(status=MagicMock(value='success'), message=None)
api_v3.config_manager = cm
api_v3.plugin_manager = MagicMock(plugins={})
# Never restart a real display from a test run.
setup_calls = []
monkeypatch.setattr(au, 'start_setup_if_needed',
lambda was_enabled, config: setup_calls.append(was_enabled) or None)
return app.test_client(), cm, setup_calls
class TestStatusRoutes:
def test_status_carries_the_alert(self, api_client, monkeypatch):
client, _, _ = api_client
monkeypatch.setattr(au, 'describe_status', lambda config: {'alert': 'rolled back', 'alert_id': '7'})
data = client.get('/api/v3/system/auto-update').get_json()['data']
assert data == {'alert': 'rolled back', 'alert_id': '7'}
@pytest.mark.parametrize('body', [{}, [1], 'x', 5])
def test_dismiss_needs_an_id_in_a_json_object(self, api_client, body):
client, _, _ = api_client
assert client.post('/api/v3/system/auto-update/dismiss', json=body).status_code == 400
def test_dismiss(self, api_client, monkeypatch):
client, _, _ = api_client
calls = []
monkeypatch.setattr(au, 'dismiss_alert', calls.append)
assert client.post('/api/v3/system/auto-update/dismiss', json={'alert_id': '7'}).status_code == 200
assert calls == ['7']
class TestSettingsSave:
def _saved(self, cm):
return cm.save_config_atomic.call_args[0][0]
def test_checked_toggle_saves_enabled(self, api_client):
client, cm, setup_calls = api_client
cm.load_config.return_value = {'timezone': 'UTC'}
resp = client.post('/api/v3/config/main', json={'timezone': 'UTC', 'auto_update_enabled': 'true'})
assert resp.status_code == 200
saved = self._saved(cm)
assert saved['auto_update'] == {'enabled': True}
assert 'auto_update_enabled' not in saved, "the form field must not leak into config.json"
assert setup_calls == [False], "setup is told the toggle was previously off"
def test_unchecked_toggle_on_general_save_disables(self, api_client):
client, cm, setup_calls = api_client
resp = client.post('/api/v3/config/main', json={'timezone': 'UTC'})
assert resp.status_code == 200
assert self._saved(cm)['auto_update'] == {'enabled': False}
assert setup_calls == [True]
def test_setup_note_reaches_the_user(self, api_client, monkeypatch):
client, cm, _ = api_client
cm.load_config.return_value = {'timezone': 'UTC'}
monkeypatch.setattr(au, 'start_setup_if_needed',
lambda was_enabled, config: 'Finishing automatic update setup: the display is restarting.')
body = client.post('/api/v3/config/main',
json={'timezone': 'UTC', 'auto_update_enabled': 'true'}).get_json()
assert 'the display is restarting' in body['message']
+214
View File
@@ -0,0 +1,214 @@
"""Installing the automatic-update health check from the display service.
src/auto_update_setup.py is how a user who never opens a terminal gets updates
with a safety net: it runs as root inside the display service and writes
systemd units. So it has to install exactly the right thing when asked, do
nothing when not asked, refuse templates that would run as anyone but the web
user, and say why whenever it could not finish.
"""
import json
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from src import auto_update_setup as aus # noqa: E402
ON = {'auto_update': {'enabled': True}}
class FakeSystemctl:
def __init__(self, fail=(), activates=True):
self.calls = []
self.fail = set(fail)
self.activates = activates
self.active = False
def __call__(self, args, **kwargs):
assert args[0] == 'systemctl', args
self.calls.append(args[1:])
verb = args[1]
if verb == 'is-active':
return subprocess.CompletedProcess(args, 0 if self.active else 3,
stdout='active\n' if self.active else 'inactive\n', stderr='')
if verb in self.fail:
return subprocess.CompletedProcess(args, 1, stdout='', stderr=f'{verb} refused')
if verb in ('restart', 'enable') and self.activates and (verb == 'restart' or '--now' in args):
self.active = True
return subprocess.CompletedProcess(args, 0, stdout='', stderr='')
def project(tmp_path, user='hdpi', workdir=None, name='LEDMatrix'):
root = tmp_path / name
(root / 'systemd').mkdir(parents=True)
for name in aus.UNITS:
shutil.copy(ROOT / 'systemd' / name, root / 'systemd' / name)
etc = tmp_path / 'etc'
etc.mkdir()
(etc / aus.WEB_UNIT).write_text(
f'[Service]\nUser={user}\nWorkingDirectory={workdir or root}\n', encoding='utf-8')
return root, etc
def setup(root, etc, systemctl, root_user=True):
return aus.UpdateHelperSetup(project_root=root, systemd_dir=etc, run=systemctl,
is_root=lambda: root_user,
lookup_ids=lambda user: None if user == 'ghost' else (1000, 1000),
clock=lambda: 1234.0)
def result(root):
return json.loads((root / aus.RESULT_REL).read_text())
def test_does_nothing_while_updates_are_off(tmp_path):
root, etc = project(tmp_path)
systemctl = FakeSystemctl()
assert setup(root, etc, systemctl).ensure({'auto_update': {'enabled': False}}) is None
assert systemctl.calls == []
assert not (etc / aus.SERVICE_UNIT).exists()
assert not (root / aus.RESULT_REL).exists()
def test_installs_both_units_for_the_web_user(tmp_path):
root, etc = project(tmp_path)
systemctl = FakeSystemctl()
out = setup(root, etc, systemctl).ensure(ON)
assert out['status'] == 'installed' and result(root)['status'] == 'installed'
service = (etc / aus.SERVICE_UNIT).read_text()
assert 'User=hdpi' in service
assert f'ExecStart=/usr/bin/python3 "{root}/data/auto_update_verifier.py" "{root}"' in service
assert f'PathExists={root}/data/auto_update_verify.request' in (etc / aus.PATH_UNIT).read_text()
assert '__' not in service
assert ['daemon-reload'] in systemctl.calls
assert ['enable', aus.PATH_UNIT] in systemctl.calls
assert systemctl.active
def test_second_start_changes_nothing(tmp_path):
root, etc = project(tmp_path)
systemctl = FakeSystemctl()
setup(root, etc, systemctl).ensure(ON)
first = result(root)
systemctl.calls.clear()
setup(root, etc, systemctl).ensure(ON)
assert systemctl.calls == [['is-active', aus.PATH_UNIT], ['is-active', aus.PATH_UNIT]]
assert result(root) == first, "an unchanged setup must not rewrite its result every boot"
def test_a_changed_template_is_reinstalled(tmp_path):
root, etc = project(tmp_path)
systemctl = FakeSystemctl()
setup(root, etc, systemctl).ensure(ON)
template = root / 'systemd' / aus.SERVICE_UNIT
template.write_text(template.read_text() + '\n# newer\n')
systemctl.calls.clear()
assert setup(root, etc, systemctl).ensure(ON)['message'] == 'Installed the update health check.'
assert (etc / aus.SERVICE_UNIT).read_text().endswith('# newer\n')
assert ['daemon-reload'] in systemctl.calls
def test_a_template_that_would_not_run_as_the_web_user_is_refused(tmp_path):
root, etc = project(tmp_path)
template = root / 'systemd' / aus.SERVICE_UNIT
template.write_text(template.read_text().replace('User=__USER__', 'User=root'))
systemctl = FakeSystemctl()
out = setup(root, etc, systemctl).ensure(ON)
assert out['status'] == 'failed' and 'refusing' in out['message']
assert not (etc / aus.SERVICE_UNIT).exists()
assert systemctl.calls == []
def test_a_path_unit_that_starts_something_else_is_refused(tmp_path):
root, etc = project(tmp_path)
template = root / 'systemd' / aus.PATH_UNIT
template.write_text(template.read_text().replace('Unit=ledmatrix-update-verify.service', 'Unit=other.service'))
out = setup(root, etc, FakeSystemctl()).ensure(ON)
assert out['status'] == 'failed' and not (etc / aus.PATH_UNIT).exists()
@pytest.mark.parametrize('case, expected', [
('not_root', 'not running as root'),
('no_web_unit', 'not installed'),
('other_folder', 'runs from'),
('bad_user', 'not a usable account'),
])
def test_reports_why_it_could_not_install(tmp_path, case, expected):
root, etc = project(tmp_path, user='ghost' if case == 'bad_user' else 'hdpi',
workdir=tmp_path / 'elsewhere' if case == 'other_folder' else None)
if case == 'no_web_unit':
(etc / aus.WEB_UNIT).unlink()
out = setup(root, etc, FakeSystemctl(), root_user=case != 'not_root').ensure(ON)
assert out['status'] == 'failed' and expected in out['message']
assert result(root)['message'] == out['message']
assert not (etc / aus.SERVICE_UNIT).exists()
def test_a_path_unit_that_will_not_start_is_a_failure(tmp_path):
root, etc = project(tmp_path)
out = setup(root, etc, FakeSystemctl(activates=False)).ensure(ON)
assert out['status'] == 'failed' and 'did not start' in out['message']
def test_systemctl_errors_are_reported(tmp_path):
root, etc = project(tmp_path)
out = setup(root, etc, FakeSystemctl(fail={'daemon-reload'})).ensure(ON)
assert out['status'] == 'failed' and 'daemon-reload refused' in out['message']
def test_not_a_systemd_host_is_left_alone(tmp_path):
root, _ = project(tmp_path)
systemctl = FakeSystemctl()
assert setup(root, tmp_path / 'no-etc', systemctl).ensure(ON) is None
assert systemctl.calls == []
def test_a_folder_with_spaces_is_quoted_in_the_commands(tmp_path):
root, etc = project(tmp_path, name='LED Matrix')
assert setup(root, etc, FakeSystemctl()).ensure(ON)['status'] == 'installed'
service = (etc / aus.SERVICE_UNIT).read_text()
assert f'ExecStartPre=/bin/rm -f "{root}/data/auto_update_verify.request"' in service
assert f'ExecStart=/usr/bin/python3 "{root}/data/auto_update_verifier.py" "{root}"' in service
def test_a_folder_name_systemd_would_reinterpret_is_refused(tmp_path):
root, etc = project(tmp_path, name='LED%Matrix')
systemctl = FakeSystemctl()
out = setup(root, etc, systemctl).ensure(ON)
assert out['status'] == 'failed' and 'systemd cannot use' in out['message']
assert not (etc / aus.SERVICE_UNIT).exists() and systemctl.calls == []
def test_installer_sets_the_toggle_without_a_half_written_config(tmp_path):
"""first_time_install.sh may run while the display service watches
config.json; the file must be replaced whole, never truncated in place."""
import re
text = (ROOT / 'first_time_install.sh').read_text(encoding='utf-8').replace('\r\n', '\n')
script = next(s for s in re.findall(r"<<'PY'\n(.*?)\nPY\n", text, re.S) if 'auto_update' in s)
assert 'os.replace(' in script
config = tmp_path / 'config.json'
config.write_text(json.dumps({'timezone': 'UTC', 'auto_update': {'enabled': False}}))
run = subprocess.run([sys.executable, '-', str(config), '1'], input=script, text=True,
capture_output=True)
assert run.returncode == 0, run.stderr
assert json.loads(config.read_text()) == {'timezone': 'UTC', 'auto_update': {'enabled': True}}
assert [p.name for p in tmp_path.iterdir()] == ['config.json'], "a temp file was left behind"
def test_the_result_is_kept_when_it_cannot_be_given_to_the_web_user(tmp_path, monkeypatch):
"""Caught by CI, which runs as a non-root Linux user: chown needs root, and
a failed chown used to discard the result, so the General tab never
learned whether setup worked."""
def refuse(*args):
raise PermissionError('Operation not permitted')
monkeypatch.setattr(aus.os, 'chown', refuse, raising=False)
root, etc = project(tmp_path)
out = setup(root, etc, FakeSystemctl()).ensure(ON)
assert out['status'] == 'installed'
assert result(root) == out
+248
View File
@@ -0,0 +1,248 @@
"""The automatic update's health check and rollback (scripts/utils/auto_update_verify.py).
This is what stands between an unattended update and a device that no longer
works, so each way an update can fail is exercised against a real git repo:
the new commit is checked out, the services are "restarted", and whether they
come up healthy depends on which commit they were restarted onto. Only
systemd, sudo and the HTTP check are faked.
"""
import importlib.util
import re
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
_spec = importlib.util.spec_from_file_location(
'auto_update_verify', ROOT / 'scripts' / 'utils' / 'auto_update_verify.py')
av = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(av)
pytestmark = pytest.mark.skipif(shutil.which('git') is None, reason='git not installed')
def git(cwd, *args):
result = subprocess.run(['git', '-c', 'user.name=t', '-c', 'user.email=t@example.com', *args],
cwd=str(cwd), capture_output=True, text=True)
assert result.returncode == 0, result.stderr
return result.stdout.strip()
def done(args, stdout='', rc=0):
return subprocess.CompletedProcess(args, rc, stdout=stdout, stderr='' if rc == 0 else 'failed')
def updated_repo(tmp_path, new_requirements=False):
"""A checkout on a new commit, with the commit it came from."""
repo = tmp_path / 'LEDMatrix'
repo.mkdir()
git(repo, 'init', '-q', '-b', 'main')
(repo / 'app.py').write_text('v = 1\n')
(repo / 'requirements.txt').write_text('requests\n')
git(repo, 'add', '.')
git(repo, 'commit', '-qm', 'old')
old = git(repo, 'rev-parse', 'HEAD')
(repo / 'app.py').write_text('v = 2\n')
if new_requirements:
(repo / 'requirements.txt').write_text('requests\nnewthing\n')
git(repo, 'commit', '-qam', 'new')
return repo, old, git(repo, 'rev-parse', 'HEAD')
class FakeHost:
"""systemd, sudo and the web interface.
Services run whatever commit was checked out when they were last
restarted; ``failure`` says how they misbehave on the new commit
("display_down", "web_down", "crash_loop") or on any commit ("always").
"""
def __init__(self, repo, bad_head, failure=None, pip_ok=True, restart_failures=0, count_readable=True):
self.repo, self.bad_head, self.failure, self.pip_ok = repo, bad_head, failure, pip_ok
self.restart_failures = restart_failures # how many restart commands fail, first to last
self.count_readable = count_readable
self.running_head = None # not restarted yet: still the old, healthy code
self.restarts = [] # (unit, commit it was restarted onto)
self.pip_installs = []
self.now = 0.0
self.nrestarts = 0
def broken(self, kind):
if self.running_head is None:
return False
return self.failure == 'always' or (self.running_head == self.bad_head and self.failure == kind)
def run(self, args, **kwargs):
if args[0] == 'git':
return subprocess.run(args, **kwargs)
if args[:2] == ['systemctl', 'is-active']:
return done(args, 'failed\n' if self.broken('display_down') else 'active\n')
if args[:2] == ['systemctl', 'show']:
if not self.count_readable:
return done(args, rc=1)
if self.broken('crash_loop'):
self.nrestarts += 1
return done(args, f'{self.nrestarts}\n')
if args[0] == 'sudo' and any(a.endswith('safe_pip_install.sh') for a in args):
self.pip_installs.append(args[-1])
return done(args, rc=0 if self.pip_ok else 1)
if args[:4] == ['sudo', '-n', 'systemctl', 'restart']:
if self.restart_failures:
self.restart_failures -= 1
return done(args, rc=1) # the old process keeps running
self.running_head = git(self.repo, 'rev-parse', 'HEAD')
self.restarts.append((args[4], self.running_head))
return done(args)
raise AssertionError(f'unexpected command: {args}')
def web_responds(self):
return not self.broken('web_down')
def sleep(self, seconds):
self.now += seconds
def verifier(self):
return av.Verifier(self.repo, run=self.run, sleep=self.sleep, clock=lambda: self.now,
web_responds=self.web_responds, log=lambda msg: None)
def check(tmp_path, failure=None, new_requirements=False, pip_ok=True, restart_failures=0,
count_readable=True, **pending):
repo, old, new = updated_repo(tmp_path, new_requirements)
fields = {'status': 'pending', 'old_head': old, 'new_head': new,
'display_was_active': True, 'dependency_failures': []}
fields.update(pending)
av.write_pending(av.pending_path(repo), fields)
host = FakeHost(repo, new, failure, pip_ok, restart_failures, count_readable)
code = host.verifier().verify()
result = av.read_pending(av.pending_path(repo))
return code, result, host, git(repo, 'rev-parse', 'HEAD'), old, new
def test_a_healthy_update_is_kept(tmp_path):
code, result, host, head, old, new = check(tmp_path)
assert code == 0 and result['status'] == 'success'
assert head == new
assert host.restarts == [('ledmatrix.service', new), ('ledmatrix-web.service', new)]
@pytest.mark.parametrize('failure, reason', [
('display_down', 'the display service did not stay running'),
('web_down', 'the web interface did not respond'),
('crash_loop', 'the display service kept restarting'),
])
def test_an_unhealthy_update_is_rolled_back(tmp_path, failure, reason):
code, result, host, head, old, new = check(tmp_path, failure)
assert code == 0
assert result['status'] == 'rolled_back' and result['reason'] == reason
assert head == old
assert host.restarts[-2:] == [('ledmatrix.service', old), ('ledmatrix-web.service', old)]
def test_a_stopped_display_is_not_started(tmp_path):
code, result, host, head, old, new = check(tmp_path, display_was_active=False)
assert result['status'] == 'success'
assert [unit for unit, _ in host.restarts] == ['ledmatrix-web.service']
def test_failed_dependencies_roll_back_before_anything_restarts_onto_them(tmp_path):
code, result, host, head, old, new = check(tmp_path, dependency_failures=['requirements.txt'])
assert result['status'] == 'rolled_back'
assert 'requirements.txt' in result['reason']
assert head == old
assert all(commit == old for _, commit in host.restarts), host.restarts
def test_rollback_reinstalls_the_previous_dependencies(tmp_path):
code, result, host, head, old, new = check(tmp_path, 'display_down', new_requirements=True)
assert result['status'] == 'rolled_back' and result['detail'] is None
assert host.pip_installs == [str(tmp_path / 'LEDMatrix' / 'requirements.txt')]
def test_a_failed_dependency_reinstall_is_reported(tmp_path):
code, result, host, head, old, new = check(tmp_path, 'display_down', new_requirements=True,
pip_ok=False)
assert result['status'] == 'rolled_back' and head == old
assert 'Install Base Requirements' in result['detail']
def test_still_broken_after_rolling_back_is_rollback_failed(tmp_path):
code, result, host, head, old, new = check(tmp_path, 'always')
assert code == 1 and result['status'] == 'rollback_failed'
assert 'still unhealthy after rolling back' in result['detail']
def test_a_failed_restart_is_not_mistaken_for_a_healthy_update(tmp_path):
"""When the restart command itself fails the old process keeps answering,
so checking it would pass an update that never started."""
code, result, host, head, old, new = check(tmp_path, restart_failures=1)
assert result['status'] == 'rolled_back'
assert result['reason'] == 'restarting the services failed'
assert head == old
def test_restarts_that_keep_failing_after_rollback_are_rollback_failed(tmp_path):
code, result, host, head, old, new = check(tmp_path, restart_failures=100)
assert code == 1 and result['status'] == 'rollback_failed'
assert 'restarting the services failed' in result['detail']
def test_an_unreadable_restart_count_is_never_called_stable(tmp_path):
"""With no restart count a crash loop looks healthy between attempts."""
code, result, host, head, old, new = check(tmp_path, count_readable=False)
assert result['status'] != 'success'
assert 'restart count could not be read' in result['reason']
def test_nothing_pending_does_nothing(tmp_path):
repo, _, _ = updated_repo(tmp_path)
host = FakeHost(repo, None)
assert host.verifier().verify() == 0
assert host.restarts == []
def test_a_crash_is_recorded_not_left_as_running(tmp_path, monkeypatch):
repo, old, new = updated_repo(tmp_path)
av.write_pending(av.pending_path(repo), {'status': 'pending', 'old_head': old, 'new_head': new})
def boom(self):
raise RuntimeError('boom')
monkeypatch.setattr(av.Verifier, 'verify', boom)
assert av.main(['auto_update_verify.py', str(repo)]) == 1
result = av.read_pending(av.pending_path(repo))
assert result['status'] == 'rollback_failed' and result['detail'] == 'boom'
def test_units_installers_and_updater_agree():
"""The request file, the copied verifier, the unit names and the places
that install them must all line up, or the health check silently never
starts on real devices -- and code updates stay paused."""
from web_interface import auto_update as au
from src import auto_update_setup as aus
from src.startup_validator import StartupValidator
service = (ROOT / 'systemd' / aus.SERVICE_UNIT).read_text(encoding='utf-8')
path = (ROOT / 'systemd' / aus.PATH_UNIT).read_text(encoding='utf-8')
request = f'__PROJECT_ROOT_DIR__/{au.REQUEST_REL.as_posix()}'
assert f'PathExists={request}' in path
assert f'Unit={aus.SERVICE_UNIT}' in path
assert f'ExecStartPre=/bin/rm -f "{request}"' in service, "the request must be consumed or the path unit re-fires"
assert f'"__PROJECT_ROOT_DIR__/{au.VERIFIER_COPY_REL.as_posix()}" "__PROJECT_ROOT_DIR__"' in service
assert au.PENDING_REL.name == av.PENDING_NAME
assert au.PATH_UNIT == aus.PATH_UNIT and au.SETUP_RESULT_REL == aus.RESULT_REL
for installer in ('scripts/install/install_web_service.sh', 'scripts/install/install_service.sh'):
text = (ROOT / installer).read_text(encoding='utf-8')
assert 'ledmatrix-update-verify.service ledmatrix-update-verify.path' in text, installer
assert 'enable --now ledmatrix-update-verify.path' in text, installer
for unit in aus.UNITS:
assert (f'systemd/{unit}', f'/etc/systemd/system/{unit}') in StartupValidator._UNITS
# Triggering takes no privilege any more; no sudoers rule should linger.
for sudoers in ('scripts/install/configure_web_sudo.sh', 'first_time_install.sh'):
assert not re.search(r'NOPASSWD:.*update-verify', (ROOT / sudoers).read_text(encoding='utf-8')), sudoers
+224
View File
@@ -0,0 +1,224 @@
"""BasePlugin.styles -- the per-element styling seam every plugin inherits.
Before this, each consumer of src.element_style repeated the same three
things: a guarded import, finding its own config_schema.json, and rebuilding
the resolver when on_config_change swapped the config dict. Getting the
second one wrong is silent -- the resolver simply 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.
"""
import json
import sys
import types
import pytest
from unittest.mock import MagicMock
from src.plugin_system.base_plugin import BasePlugin
SCHEMA = {
"type": "object",
"properties": {
"customization": {
"type": "object",
"x-style-modes": ["live", "recent"],
"x-style-elements": {
"score_text": {
"title": "Score",
"font": {"default": "PressStart2P-Regular.ttf"},
"size": {"default": 10, "min": 4, "max": 16},
"color": {"default": [255, 255, 255]},
"offsets": True,
},
},
},
},
}
def _plugin_class(module_name, module_file, **attrs):
"""A plugin class whose module sits in a plugin directory, as a real
one does -- that is what schema discovery keys on."""
module = types.ModuleType(module_name)
module.__file__ = str(module_file)
sys.modules[module_name] = module
namespace = dict(attrs)
namespace["update"] = lambda self: None
namespace["display"] = lambda self, force_clear=False: None
cls = type("DemoPlugin", (BasePlugin,), namespace)
cls.__module__ = module_name
return cls
@pytest.fixture
def plugin_dir(tmp_path):
d = tmp_path / "demo"
d.mkdir()
(d / "config_schema.json").write_text(json.dumps(SCHEMA), encoding="utf-8")
return d
@pytest.fixture
def make(tmp_path, plugin_dir, request):
created = []
def _make(config, plugin_id="demo", schema_in_module_dir=True, **attrs):
name = f"demo_module_{len(created)}_{id(request)}"
module_file = (plugin_dir if schema_in_module_dir
else tmp_path / "elsewhere") / "manager.py"
module_file.parent.mkdir(parents=True, exist_ok=True)
cls = _plugin_class(name, module_file, **attrs)
created.append(name)
pm = MagicMock()
pm.plugins_dir = str(tmp_path)
return cls(plugin_id, config, MagicMock(), MagicMock(), pm)
try:
yield _make
finally:
for name in created:
sys.modules.pop(name, None)
CONFIG = {"customization": {
"score_text": {"font_size": 12},
"layout": {"score_text": {"y_offset": -3}},
"modes": {"live": {"score_text": {"font_size": 16}}},
}}
class TestSchemaDiscovery:
def test_it_finds_the_schema_beside_the_plugin_module(self, make, plugin_dir):
"""Not beside base_plugin.py, which lives in src/plugin_system where
no plugin schema exists."""
p = make({})
assert p._config_schema_path() == str(plugin_dir / "config_schema.json")
def test_it_falls_back_to_the_plugins_directory(self, make):
p = make({}, schema_in_module_dir=False)
assert p._config_schema_path() is not None
def test_it_accepts_the_ledmatrix_prefix_form(self, tmp_path, make):
(tmp_path / "ledmatrix-music").mkdir()
(tmp_path / "ledmatrix-music" / "config_schema.json").write_text(
json.dumps(SCHEMA), encoding="utf-8")
p = make({}, plugin_id="music", schema_in_module_dir=False)
assert "ledmatrix-music" in p._config_schema_path()
def test_a_missing_schema_is_not_fatal(self, tmp_path, make):
p = make({}, plugin_id="ghost", schema_in_module_dir=False)
# 'elsewhere' has no schema and neither does tmp_path/ghost
assert p._config_schema_path() is None
assert p.styles.style("score_text", classic_size=8).font_size == 8
def test_the_module_directory_wins_over_the_plugins_directory(
self, tmp_path, plugin_dir):
"""These two are the same path for an installed plugin, so the test
has to force them apart -- and they genuinely diverge for a plugin
symlinked in for development, where the module lives outside the
configured plugins directory. Sourcing the path from this module's
own __file__ instead would find neither, then quietly fall through
to whatever the plugins directory happened to hold.
"""
dev_dir = tmp_path / "dev-checkout"
dev_dir.mkdir()
dev_schema = json.loads(json.dumps(SCHEMA))
(dev_schema["properties"]["customization"]["x-style-elements"]
["score_text"]["size"]["default"]) = 99
(dev_dir / "config_schema.json").write_text(json.dumps(dev_schema),
encoding="utf-8")
cls = _plugin_class("demo_dev_checkout", dev_dir / "manager.py")
try:
pm = MagicMock()
pm.plugins_dir = str(tmp_path) # holds demo/config_schema.json
p = cls("demo", {}, MagicMock(), MagicMock(), pm)
assert p._config_schema_path() == str(dev_dir / "config_schema.json")
# and the difference is observable: 99 is this schema's default,
# so configuring 99 must read as "not a choice".
p.on_config_change({"customization": {"score_text": {"font_size": 99}}})
assert p.styles.style("score_text", classic_size=10).font_size == 10
finally:
sys.modules.pop("demo_dev_checkout", None)
def test_the_lookup_is_cached(self, make):
"""A miss must not re-scan the disk on every frame."""
p = make({})
first = p._config_schema_path()
assert p._config_schema_path() is first
class TestResolution:
def test_an_untouched_config_gets_the_classic_values(self, make):
"""The invariant that makes adopting this safe."""
style = make({}).styles.style(
"score_text", classic_font="4x6-font.ttf", classic_size=6,
classic_color=(1, 2, 3))
assert (style.font_name, style.font_size, style.color) == (
"4x6-font.ttf", 6, (1, 2, 3))
assert style.user_forced is False
def test_a_users_choice_comes_through(self, make):
style = make(CONFIG).styles.style("score_text", classic_size=10)
assert style.font_size == 12
assert style.offset == (0, -3)
def test_style_mode_binds_without_touching_call_sites(self, make):
"""The whole point: a per-mode subclass sets one attribute and its
existing lookups become mode-aware."""
assert make(CONFIG).styles.style(
"score_text", classic_size=10).font_size == 12
assert make(CONFIG, STYLE_MODE="live").styles.style(
"score_text", classic_size=10).font_size == 16
def test_styles_for_handles_a_one_off_mode(self, make):
p = make(CONFIG)
assert p.styles_for("live").style("score_text", classic_size=10).font_size == 16
assert p.styles_for("recent").style("score_text", classic_size=10).font_size == 12
assert p.styles_for(None).style("score_text", classic_size=10).font_size == 12
class TestInvalidation:
def test_the_resolver_is_reused(self, make):
p = make(CONFIG)
assert p.styles is p.styles
def test_a_config_swap_rebuilds_it(self, make):
"""on_config_change replaces the dict rather than mutating it, so
identity is the signal."""
p = make(CONFIG)
assert p.styles.style("score_text", classic_size=10).font_size == 12
p.on_config_change({"customization": {"score_text": {"font_size": 6}}})
assert p.styles.style("score_text", classic_size=10).font_size == 6
def test_a_config_swap_rebuilds_the_per_mode_cache_too(self, make):
p = make(CONFIG)
assert p.styles_for("live").style("score_text", classic_size=10).font_size == 16
p.on_config_change({"customization": {
"modes": {"live": {"score_text": {"font_size": 5}}}}})
assert p.styles_for("live").style("score_text", classic_size=10).font_size == 5
class TestDegradation:
# config=None is not in this list on purpose: BasePlugin.__init__ reads
# config.get("enabled") before styles exists, so a None config is a
# constructor precondition rather than something styles should absorb.
@pytest.mark.parametrize("config", [
{}, {"customization": None}, {"customization": "nonsense"},
{"customization": {"score_text": "nonsense"}},
{"customization": {"layout": "nonsense"}},
{"customization": {"modes": "nonsense"}},
])
def test_a_hostile_config_still_resolves(self, config, make):
style = make(config).styles.style("score_text", classic_size=7)
assert style.font_size == 7
def test_it_works_without_a_plugin_manager(self, plugin_dir):
cls = _plugin_class("demo_no_pm", plugin_dir / "manager.py")
try:
p = cls("demo", {}, MagicMock(), MagicMock(), None)
assert p.styles.style("score_text", classic_size=8).font_size == 8
finally:
sys.modules.pop("demo_no_pm", None)
+25
View File
@@ -0,0 +1,25 @@
"""Regression: the native color input in the color-picker widget must have
an accessible name independent of showHexInput.
CodeRabbit flagged (PR #568) that the <input type="color"> only carried a
`title` attribute -- screen readers don't reliably announce `title`, and
when showHexInput is false there is no other label naming the control.
"""
from pathlib import Path
WIDGET_JS = (
Path(__file__).resolve().parent.parent
/ "web_interface" / "static" / "v3" / "js" / "widgets" / "color-picker.js"
)
def test_native_color_input_has_aria_label():
source = WIDGET_JS.read_text(encoding="utf-8")
start = source.index('<input type="color"')
end = source.index(">", start)
tag = source[start:end]
assert 'aria-label=' in tag, (
"native color <input> lost its accessible name; screen readers need "
"aria-label since it has no associated <label> and showHexInput can "
"be false"
)
+14
View File
@@ -44,6 +44,20 @@ def dm(tmp_path_factory):
# this session; the individual tests that care still override it further.
manager._snapshot_path = str(
tmp_path_factory.mktemp("dirty_tracking") / "led_matrix_preview.png")
# _setup_matrix() swallows every construction failure and falls back to
# matrix=None, so a broken environment reaches the tests as fifteen
# identical "'NoneType' object has no attribute 'SwapOnVSync'" errors that
# name neither this fixture nor the real cause. Fail here instead, once,
# and say where to look.
if manager.matrix is None:
pytest.fail(
"DisplayManager fell back to matrix=None: RGBMatrix construction "
"raised (the 'Failed to initialize RGB Matrix' log line above "
"carries the reason). Known causes: the emulator adapter losing a "
"fixed TCP port to another process -- see pytest_configure in "
"test/conftest.py, which pins the port-free 'raw' adapter -- or a "
"patch('src.display_manager.RGBMatrix') leaked from an earlier "
"test module.")
yield manager
DisplayManager._instance = None
DisplayManager._initialized = False
+144
View File
@@ -0,0 +1,144 @@
"""The web preview must be the size DisplayManager actually renders at.
Before src/display_geometry.py, the preview endpoints computed
``cols * chain_length`` by ``rows * parallel`` themselves, ignored
``display.double_sided`` (so a double-sided panel previewed two screens side
by side), and the ``chain_length`` fallback was 2 in DisplayManager but 1 in
the Starlark magnify default and the sync handshake.
"""
import json
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from flask import Flask
from src.display_geometry import (
DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_PARALLEL, DEFAULT_ROWS,
logical_size, physical_size,
)
REPO_ROOT = Path(__file__).resolve().parents[1]
def _config(hardware=None, double_sided=None):
display = {'hardware': hardware or {}}
if double_sided is not None:
display['double_sided'] = double_sided
return {'display': display}
def test_defaults_match_the_config_template():
template = json.loads(
(REPO_ROOT / 'config' / 'config.template.json').read_text(encoding='utf-8'))
hw = template['display']['hardware']
assert (DEFAULT_ROWS, DEFAULT_COLS, DEFAULT_CHAIN_LENGTH, DEFAULT_PARALLEL) == (
hw['rows'], hw['cols'], hw['chain_length'], hw['parallel'])
def test_missing_hardware_uses_the_template_defaults():
assert physical_size({}) == (128, 32)
assert logical_size({}) == (128, 32)
def test_parallel_multiplies_height():
cfg = _config({'rows': 32, 'cols': 64, 'chain_length': 2, 'parallel': 2})
assert physical_size(cfg) == (128, 64)
def test_double_sided_horizontal_previews_one_screen():
cfg = _config({'rows': 32, 'cols': 64, 'chain_length': 4, 'parallel': 1},
{'enabled': True, 'copies': 2, 'axis': 'horizontal'})
assert physical_size(cfg) == (256, 32)
assert logical_size(cfg) == (128, 32)
def test_double_sided_vertical_splits_parallel():
cfg = _config({'rows': 32, 'cols': 64, 'chain_length': 2, 'parallel': 2},
{'enabled': True, 'copies': 2, 'axis': 'vertical'})
assert logical_size(cfg) == (128, 32)
def test_double_sided_that_does_not_divide_falls_back_to_physical():
cfg = _config({'rows': 32, 'cols': 64, 'chain_length': 2, 'parallel': 1},
{'enabled': True, 'copies': 3, 'axis': 'horizontal'})
assert logical_size(cfg) == (128, 32)
def test_disabled_double_sided_is_ignored():
cfg = _config({'chain_length': 4}, {'enabled': False, 'copies': 2})
assert logical_size(cfg) == (256, 32)
@pytest.mark.parametrize('display', ['oops', ['a'], 1, {'hardware': 'oops'},
{'hardware': ['a']}])
def test_non_mapping_display_config_uses_the_defaults(display):
assert physical_size({'display': display}) == (128, 32)
assert logical_size({'display': display}) == (128, 32)
@pytest.mark.parametrize('key', ['rows', 'cols', 'chain_length', 'parallel'])
def test_infinite_hardware_value_raises_value_error(key):
# json.loads accepts Infinity; int(inf) raises OverflowError, which the
# Starlark magnify default and the preview stream did not catch (HTTP 500).
cfg = _config({key: json.loads('Infinity')})
with pytest.raises(ValueError):
physical_size(cfg)
with pytest.raises(ValueError):
logical_size(cfg)
@pytest.fixture
def display_client(monkeypatch):
from web_interface.blueprints.api_v3 import api_v3
config_manager = MagicMock()
monkeypatch.setattr(api_v3, 'config_manager', config_manager, raising=False)
app = Flask(__name__)
app.config['TESTING'] = True
app.register_blueprint(api_v3, url_prefix='/api/v3')
with app.test_client() as client:
yield client, config_manager
def test_display_current_reports_the_logical_size(display_client):
client, config_manager = display_client
config_manager.load_config.return_value = _config(
{'rows': 32, 'cols': 64, 'chain_length': 4, 'parallel': 2},
{'enabled': True, 'copies': 2, 'axis': 'horizontal'})
data = client.get('/api/v3/display/current').get_json()['data']
assert (data['width'], data['height']) == (128, 64)
def test_display_current_defaults_chain_length_like_display_manager(display_client):
client, config_manager = display_client
config_manager.load_config.return_value = _config({'rows': 32, 'cols': 64})
data = client.get('/api/v3/display/current').get_json()['data']
assert (data['width'], data['height']) == (64 * DEFAULT_CHAIN_LENGTH, 32)
def test_display_current_falls_back_to_the_shared_default(display_client):
client, config_manager = display_client
config_manager.load_config.side_effect = ValueError('unreadable')
data = client.get('/api/v3/display/current').get_json()['data']
assert (data['width'], data['height']) == logical_size({}) == (128, 32)
def test_preview_callers_do_not_rederive_the_size():
"""Every preview/size caller goes through display_geometry, so none of
them can drift back to a private chain_length default."""
for rel in ('web_interface/app.py',
'web_interface/blueprints/api_v3/display.py',
'web_interface/blueprints/api_v3/__init__.py',
'src/common/sync_manager.py',
'src/display_manager.py'):
source = (REPO_ROOT / rel).read_text(encoding='utf-8')
assert "get('chain_length', 1)" not in source, rel
assert 'get("chain_length", 1)' not in source, rel
assert 'cols * chain_length' not in source, rel
+921 -2
View File
@@ -16,6 +16,7 @@ ledmatrix-music, football-scoreboard):
- style() never raises; malformed input degrades to the classic style.
"""
import copy
import json
import os
@@ -23,7 +24,11 @@ import pytest
from PIL import ImageFont
from src.element_style import (
alias_keys,
element_color,
ElementStyleResolver,
_load_font_sized,
native_bdf_size,
defaults_from_schema,
defaults_from_schema_file,
expand_style_elements,
@@ -173,10 +178,25 @@ class TestExpandStyleElements:
expand_style_elements(STYLE_ELEMENTS_SCHEMA)
assert json.dumps(STYLE_ELEMENTS_SCHEMA, sort_keys=True) == before
def test_no_declaration_returns_same_object(self):
assert expand_style_elements(MANUAL_SCHEMA) is MANUAL_SCHEMA
def test_a_schema_with_nothing_to_expand_is_returned_as_is(self):
"""No needless deep copy when there is nothing to do."""
empty = {"properties": {}}
assert expand_style_elements(empty) is empty
no_style = {"properties": {"customization": {"type": "object",
"properties": {"favorite_result_colors": {"type": "object",
"properties": {"win_color": {"type": "array"}}}}}}}
assert expand_style_elements(no_style) is no_style
def test_a_hand_written_block_is_adopted(self):
"""Nineteen plugins spell their style elements out longhand rather
than declaring them, and predate this system entirely. They pick up
the editor and the font picker on a core update rather than on a
plugin release."""
expanded = expand_style_elements(MANUAL_SCHEMA)
assert expanded is not MANUAL_SCHEMA
customization = expanded["properties"]["customization"]
assert customization["x-widget"] == "style-editor"
assert "x-style-elements" not in MANUAL_SCHEMA["properties"]["customization"]
def test_garbage_input_never_raises(self):
bad = {"properties": {"customization": {"x-style-elements": "nope"}}}
@@ -287,6 +307,18 @@ class TestUserOverrides:
assert not style.user_forced
assert style.color == (255, 0, 0)
def test_color_override_clamps_out_of_range_components(self, style_schema_path):
"""The resolver path normalizes colour separately from element_color
(see src/element_style.py's _resolve) -- a rejection there reads as
"not configured" and the element would silently get the default
colour back instead of the clamped one the user asked for."""
config = {"customization": {"title_text": {"text_color": [999, -5, 20]}}}
style = _resolver(config, style_schema_path).style(
"title_text", classic_font="PressStart2P-Regular.ttf",
classic_size=8, classic_color=(255, 255, 255))
assert style.user_forced_color
assert style.color == (255, 0, 20)
def test_offsets(self, style_schema_path):
config = {"customization": {"layout": {
"title_text": {"x_offset": 4, "y_offset": -2}}}}
@@ -340,6 +372,19 @@ class TestDegradation:
# The override IS honored as forced, but the face degrades safely.
assert style.user_forced
assert style.font is not None
assert not isinstance(style.font, tuple)
def test_missing_font_load_returns_a_font_not_a_nested_tuple(self):
# Regression: the missing-font path in _load_font_sized used to wrap
# _load_fallback_font's own (font, size) tuple a second time, so
# load_font() returned a (font, size) tuple where callers (draw.text,
# layout math) expect a font object.
font, size = _load_font_sized("definitely-not-a-real-font.ttf", 8)
assert not isinstance(font, tuple)
assert size == 8
single = load_font("definitely-not-a-real-font.ttf", 8)
assert not isinstance(single, tuple)
def test_empty_defaults_treats_config_as_reference_to_classic(self):
# No schema defaults at all: a config value equal to the classic
@@ -410,3 +455,877 @@ class TestResolverPlumbing:
cust = schema["properties"]["customization"]["properties"]
assert cust["title_text"]["x-style-managed"] is True
assert "title_text" in cust["layout"]["properties"]
class TestBdfSizing:
"""A BDF is a fixed-size bitmap strike, not a scalable outline.
FreeType accepts only the exact pixel size baked into the file and raises
for anything else, and 32 of the 35 shipped fonts are BDF -- so a size
picked in the web UI usually is not a valid strike. This used to fall
through to the generic font-load except and return *PressStart2P*, so
asking for 5x7.bdf at size 10 silently rendered a different typeface.
It now falls back to the file's own size instead, matching what
SportsCore._load_custom_font_from_element_config already did.
"""
def test_a_valid_strike_loads_at_that_size(self):
import freetype
font, realised = _load_font_sized("5x7.bdf", 7)
assert isinstance(font, freetype.Face)
assert realised == 7
@pytest.mark.parametrize("requested", [4, 10, 16, 32])
def test_a_wrong_size_keeps_the_font_and_snaps_the_size(self, requested):
"""The regression: the face must still be 5x7, not a substitute."""
import freetype
font, realised = _load_font_sized("5x7.bdf", requested)
assert isinstance(font, freetype.Face), (
"a BDF asked for a bad size used to come back as a PIL "
"PressStart2P face -- a different font entirely")
assert realised == 7
def test_the_reported_size_is_what_was_realised(self, style_schema_path):
"""ElementStyle.font_size drives caller layout, so it must not report
a size nothing was drawn at."""
config = {"customization": {"title_text": {"font": "5x7.bdf",
"font_size": 20}}}
style = ElementStyleResolver(
config, defaults_from_schema_file(style_schema_path)
).style("title_text", classic_font="PressStart2P-Regular.ttf",
classic_size=8)
assert style.font_name == "5x7.bdf"
assert style.font_size == 7
def test_native_bdf_size_reads_the_file(self):
assert native_bdf_size("5x7.bdf") == 7
@pytest.mark.parametrize("name", ["PressStart2P-Regular.ttf",
"no-such-font.bdf", "", None])
def test_native_bdf_size_is_none_when_size_is_a_free_choice(self, name):
"""None is the web UI's signal that the size field stays editable."""
assert native_bdf_size(name) is None
def test_a_scalable_font_realises_the_requested_size(self):
for size in (6, 8, 13):
font, realised = _load_font_sized("PressStart2P-Regular.ttf", size)
assert realised == size
assert not isinstance(font, type(None))
class TestFontCacheIsBounded:
"""The display process runs for weeks and every config save can add a new
(font, size) pair; this cache was unbounded, against the house style of
every other hot cache in the codebase."""
def test_the_cache_evicts_past_its_bound(self):
import src.element_style as es
es._font_cache.clear()
try:
for size in range(1, es._FONT_CACHE_MAX + 40):
load_font("PressStart2P-Regular.ttf", size)
assert len(es._font_cache) <= es._FONT_CACHE_MAX
finally:
es._font_cache.clear()
def test_a_repeated_load_is_the_same_object(self):
import src.element_style as es
es._font_cache.clear()
try:
first = load_font("PressStart2P-Regular.ttf", 8)
assert load_font("PressStart2P-Regular.ttf", 8) is first
finally:
es._font_cache.clear()
def test_eviction_is_least_recently_used(self):
import src.element_style as es
es._font_cache.clear()
try:
oldest = load_font("PressStart2P-Regular.ttf", 1)
for size in range(2, es._FONT_CACHE_MAX + 1):
load_font("PressStart2P-Regular.ttf", size)
# Touch the oldest so it is no longer the eviction candidate,
# then overflow by one.
assert load_font("PressStart2P-Regular.ttf", 1) is oldest
load_font("PressStart2P-Regular.ttf", es._FONT_CACHE_MAX + 1)
assert load_font("PressStart2P-Regular.ttf", 1) is oldest
finally:
es._font_cache.clear()
class TestModes:
"""Per-mode overrides: one element styled differently per situation.
The motivating case is a scoreboard, where the score wants a bigger font
on a live card than on an upcoming one. Live/Upcoming/Recent are already
separate instances with distinct SKIN_MODE values, so the mode is bound
to the resolver rather than threaded through every call site.
A mode layer is pure override: its fields default to None, meaning
inherit. That is why None and 0 must stay distinct -- a mode y_offset of
0 means "sit at the base position", not "no preference".
"""
def _resolver(self, config, mode=None, schema=None):
defaults = defaults_from_schema_file(schema) if schema else {}
return ElementStyleResolver(config, defaults, mode=mode)
def test_no_mode_block_resolves_exactly_as_before(self, style_schema_path):
config = {"customization": {"title_text": {"font_size": 12}}}
plain = self._resolver(config, schema=style_schema_path)
moded = self._resolver(config, mode="live", schema=style_schema_path)
a = plain.style("title_text", classic_size=8)
b = moded.style("title_text", classic_size=8)
assert (a.font_name, a.font_size, a.color) == (b.font_name, b.font_size,
b.color)
def test_a_mode_overrides_the_base_size(self, style_schema_path):
config = {"customization": {
"title_text": {"font_size": 12},
"modes": {"live": {"title_text": {"font_size": 16}}}}}
r = self._resolver(config, mode="live", schema=style_schema_path)
assert r.style("title_text", classic_size=8).font_size == 16
assert r.style("title_text", classic_size=8).user_forced is True
def test_a_different_mode_is_unaffected(self, style_schema_path):
config = {"customization": {
"title_text": {"font_size": 12},
"modes": {"live": {"title_text": {"font_size": 16}}}}}
r = self._resolver(config, mode="upcoming", schema=style_schema_path)
assert r.style("title_text", classic_size=8).font_size == 12
def test_two_resolvers_share_a_config_and_differ_by_mode(
self, style_schema_path):
"""The SportsUpcoming / SportsRecent case: same config dict, two
instances, two answers."""
config = {"customization": {
"title_text": {"font_size": 12},
"modes": {"live": {"title_text": {"font_size": 16}},
"recent": {"title_text": {"font_size": 6}}}}}
live = self._resolver(config, mode="live", schema=style_schema_path)
recent = self._resolver(config, mode="recent", schema=style_schema_path)
assert live.style("title_text", classic_size=8).font_size == 16
assert recent.style("title_text", classic_size=8).font_size == 6
def test_a_mode_overrides_font_and_colour(self, style_schema_path):
config = {"customization": {
"modes": {"live": {"title_text": {"font": "4x6-font.ttf",
"text_color": [1, 2, 3]}}}}}
st = self._resolver(config, mode="live",
schema=style_schema_path).style(
"title_text", classic_font="PressStart2P-Regular.ttf",
classic_color=(255, 255, 255))
assert st.font_name == "4x6-font.ttf"
assert st.color == (1, 2, 3)
assert st.user_forced and st.user_forced_color
def test_an_unset_mode_field_inherits_rather_than_resetting(
self, style_schema_path):
"""Only font_size is overridden; the colour must survive."""
config = {"customization": {
"title_text": {"text_color": [9, 9, 9]},
"modes": {"live": {"title_text": {"font_size": 16}}}}}
st = self._resolver(config, mode="live",
schema=style_schema_path).style(
"title_text", classic_color=(255, 255, 255))
assert st.font_size == 16
assert st.color == (9, 9, 9)
def test_a_null_mode_field_means_inherit(self, style_schema_path):
config = {"customization": {
"title_text": {"font_size": 12},
"modes": {"live": {"title_text": {"font_size": None}}}}}
st = self._resolver(config, mode="live",
schema=style_schema_path).style("title_text")
assert st.font_size == 12
def test_a_per_call_mode_overrides_the_bound_one(self, style_schema_path):
config = {"customization": {"modes": {
"live": {"title_text": {"font_size": 16}},
"recent": {"title_text": {"font_size": 6}}}}}
r = self._resolver(config, mode="live", schema=style_schema_path)
assert r.style("title_text").font_size == 16
assert r.style("title_text", mode="recent").font_size == 6
def test_the_memo_does_not_leak_between_modes(self, style_schema_path):
config = {"customization": {"modes": {
"live": {"title_text": {"font_size": 16}},
"recent": {"title_text": {"font_size": 6}}}}}
r = self._resolver(config, mode="live", schema=style_schema_path)
assert r.style("title_text").font_size == 16
assert r.style("title_text", mode="recent").font_size == 6
assert r.style("title_text").font_size == 16
def test_mode_is_exposed(self):
assert ElementStyleResolver({}, {}, mode="live").mode == "live"
assert ElementStyleResolver({}, {}).mode is None
class TestModeOffsets:
def _r(self, config, mode=None):
return ElementStyleResolver(config, {}, mode=mode)
def test_a_mode_offset_wins(self):
config = {"customization": {
"layout": {"score": {"y_offset": -2}},
"modes": {"live": {"layout": {"score": {"y_offset": 5}}}}}}
assert self._r(config, "live").offset_value("score", "y_offset") == 5
def test_an_absent_mode_offset_inherits_the_base(self):
config = {"customization": {
"layout": {"score": {"y_offset": -2}},
"modes": {"live": {"layout": {"score": {"x_offset": 1}}}}}}
r = self._r(config, "live")
assert r.offset_value("score", "y_offset") == -2
assert r.offset_value("score", "x_offset") == 1
def test_an_explicit_zero_is_an_override_not_an_absence(self):
"""The reason None is the inherit sentinel: 0 has to mean something."""
config = {"customization": {
"layout": {"score": {"y_offset": -2}},
"modes": {"live": {"layout": {"score": {"y_offset": 0}}}}}}
assert self._r(config, "live").offset_value("score", "y_offset") == 0
def test_a_null_mode_offset_inherits(self):
config = {"customization": {
"layout": {"score": {"y_offset": -2}},
"modes": {"live": {"layout": {"score": {"y_offset": None}}}}}}
assert self._r(config, "live").offset_value("score", "y_offset") == -2
def test_offset_pair_is_mode_aware(self):
config = {"customization": {
"layout": {"score": {"x_offset": 1, "y_offset": 2}},
"modes": {"live": {"layout": {"score": {"y_offset": 9}}}}}}
assert self._r(config, "live").offset("score") == (1, 9)
def test_an_arbitrary_axis_is_mode_aware(self):
"""The scoreboards use away_x_offset / home_x_offset."""
config = {"customization": {
"layout": {"records": {"away_x_offset": 3}},
"modes": {"recent": {"layout": {"records": {"away_x_offset": 7}}}}}}
assert self._r(config, "recent").offset_value(
"records", "away_x_offset") == 7
@pytest.mark.parametrize("modes", [
None, "nonsense", {"live": "nonsense"}, {"live": {"layout": 5}},
{"live": {"layout": {"score": {"y_offset": "bad"}}}},
])
def test_garbage_modes_degrade_to_the_base(self, modes):
config = {"customization": {"layout": {"score": {"y_offset": -2}},
"modes": modes}}
assert self._r(config, "live").offset_value("score", "y_offset") == -2
@pytest.mark.parametrize("modes", [
None, "nonsense", {"live": "nonsense"},
{"live": {"title_text": "nonsense"}},
{"live": {"title_text": {"font_size": "huge", "text_color": "red"}}},
])
def test_garbage_mode_styles_degrade(self, modes, style_schema_path):
config = {"customization": {"title_text": {"font_size": 12},
"modes": modes}}
st = ElementStyleResolver(
config, defaults_from_schema_file(style_schema_path),
mode="live").style("title_text", classic_size=8)
assert st.font_size == 12
MODE_SCHEMA = {
"type": "object",
"additionalProperties": False,
"properties": {
"enabled": {"type": "boolean", "default": False},
"customization": {
"type": "object",
"x-style-modes": ["live", "recent"],
"x-style-elements": {
"score_text": {
"title": "Score",
"font": {"default": "PressStart2P-Regular.ttf"},
"size": {"default": 10, "min": 4, "max": 16},
"color": {"default": [255, 255, 255]},
"offsets": True,
},
},
},
},
}
class TestModeSchemaEmission:
def _expanded(self):
return expand_style_elements(MODE_SCHEMA)["properties"]["customization"]
def test_a_modes_block_is_emitted_per_declared_mode(self):
assert sorted(self._expanded()["properties"]["modes"]["properties"]) == [
"live", "recent"]
def test_mode_fields_are_nullable_and_default_to_null(self):
"""Null is the inherit sentinel. A concrete default here would turn
every mode into a copy of the base the moment the user pressed Save,
because the save flow writes schema defaults into config wholesale."""
live = self._expanded()["properties"]["modes"]["properties"]["live"]
block = live["properties"]["score_text"]["properties"]
for name, prop in block.items():
assert prop["default"] is None, name
assert "null" in prop["type"], name
def test_mode_offsets_are_nullable_too(self):
live = self._expanded()["properties"]["modes"]["properties"]["live"]
axes = live["properties"]["layout"]["properties"]["score_text"]["properties"]
assert set(axes) == {"x_offset", "y_offset"}
for prop in axes.values():
assert prop["default"] is None
assert "null" in prop["type"]
def test_the_base_block_keeps_its_real_defaults(self):
block = self._expanded()["properties"]["score_text"]["properties"]
assert block["font_size"]["default"] == 10
assert block["font"]["default"] == "PressStart2P-Regular.ttf"
def test_the_font_field_asks_for_the_font_selector_widget(self):
"""The widget already existed and was already allowlisted by the
config form; without the hint the field was a bare text box the user
had to type a filename into."""
block = self._expanded()["properties"]["score_text"]["properties"]
assert block["font"]["x-widget"] == "font-selector"
def test_no_declared_modes_emits_no_modes_block(self):
schema = copy.deepcopy(MODE_SCHEMA)
del schema["properties"]["customization"]["x-style-modes"]
expanded = expand_style_elements(schema)["properties"]["customization"]
assert "modes" not in expanded["properties"]
@pytest.mark.parametrize("modes", ["nonsense", [], [None], [""], 5, {}])
def test_garbage_mode_declarations_do_not_break_expansion(self, modes):
schema = copy.deepcopy(MODE_SCHEMA)
schema["properties"]["customization"]["x-style-modes"] = modes
expanded = expand_style_elements(schema)["properties"]["customization"]
assert "score_text" in expanded["properties"]
def test_the_input_schema_is_not_mutated(self):
expand_style_elements(MODE_SCHEMA)
assert "properties" not in MODE_SCHEMA["properties"]["customization"]
class TestModeSaveRoundTrip:
"""The path a real save takes: schema -> defaults -> merge -> validate ->
resolve. The risk being covered is that the save flow writes schema
defaults into config.json wholesale, so a mode block has to survive that
still meaning "inherit"."""
@pytest.fixture
def plugin(self, tmp_path):
from src.plugin_system.schema_manager import SchemaManager
pdir = tmp_path / "plugins" / "demo"
pdir.mkdir(parents=True)
(pdir / "config_schema.json").write_text(json.dumps(MODE_SCHEMA),
encoding="utf-8")
sm = SchemaManager(plugins_dir=tmp_path / "plugins",
project_root=tmp_path)
schema = sm.load_schema("demo", use_cache=False)
return sm, schema, sm.extract_defaults_from_schema(schema), pdir
def test_a_save_leaves_mode_blocks_meaning_inherit(self, plugin):
sm, schema, defaults, pdir = plugin
merged = sm.merge_with_defaults(
{"enabled": True, "customization": {"score_text": {"font_size": 14}}},
defaults)
live = merged["customization"]["modes"]["live"]["score_text"]
assert live == {"font": None, "font_size": None, "text_color": None}
ok, errors = sm.validate_config_against_schema(merged, schema)
assert ok, errors
style = ElementStyleResolver(
merged, defaults_from_schema_file(str(pdir / "config_schema.json")),
mode="live").style("score_text",
classic_font="PressStart2P-Regular.ttf",
classic_size=10)
assert style.font_size == 14, "the base must still reach the mode"
def test_a_real_mode_override_validates_and_wins(self, plugin):
sm, schema, defaults, pdir = plugin
merged = sm.merge_with_defaults({"customization": {
"score_text": {"font_size": 14},
"modes": {"live": {"score_text": {"font_size": 16},
"layout": {"score_text": {"y_offset": -3}}}},
}}, defaults)
ok, errors = sm.validate_config_against_schema(merged, schema)
assert ok, errors
schema_defaults = defaults_from_schema_file(
str(pdir / "config_schema.json"))
live = ElementStyleResolver(merged, schema_defaults, mode="live")
recent = ElementStyleResolver(merged, schema_defaults, mode="recent")
assert live.style("score_text", classic_size=10).font_size == 16
assert live.offset("score_text") == (0, -3)
assert recent.style("score_text", classic_size=10).font_size == 14
assert recent.offset("score_text") == (0, 0)
def test_a_mode_size_outside_the_declared_range_is_rejected(self, plugin):
"""min/max from the declaration must carry into the mode blocks."""
sm, schema, defaults, _ = plugin
merged = sm.merge_with_defaults(
{"customization": {"modes": {"live": {"score_text": {
"font_size": 99}}}}}, defaults)
ok, _errors = sm.validate_config_against_schema(merged, schema)
assert not ok
EXTRA_SCHEMA = {
"type": "object",
"properties": {
"customization": {
"type": "object",
"x-style-modes": ["live"],
"x-style-elements": {
"score_text": {
"title": "Score",
"font": {"default": "PressStart2P-Regular.ttf"},
"size": {"default": 10},
"color": {"default": [255, 255, 255]},
"visible": True,
"align": {"default": "center"},
"offsets": True,
},
"home_logo": {
"title": "Home Logo",
"offsets": True,
"visible": True,
"scale": {"default": 1.0, "min": 0.25, "max": 4},
},
},
},
},
}
@pytest.fixture
def extra_schema_path(tmp_path):
path = tmp_path / "extra_schema.json"
path.write_text(json.dumps(EXTRA_SCHEMA), encoding="utf-8")
return str(path)
class TestVisibleAlignScale:
"""visible / align / scale all resolve to "change nothing" until asked.
That is the same invariant the font fields keep: a caller that honours
these must still render an untouched config exactly as it did before
they existed, so the neutral values are True / None / 1.0 rather than
whatever the schema happens to declare.
"""
def _style(self, config, mode=None, schema=None):
defaults = defaults_from_schema_file(schema) if schema else {}
return ElementStyleResolver(config, defaults, mode=mode).style(
"score_text", classic_font="PressStart2P-Regular.ttf",
classic_size=10, classic_color=(255, 255, 255))
def test_an_untouched_config_is_neutral(self, extra_schema_path):
st = self._style({}, schema=extra_schema_path)
assert (st.visible, st.align, st.scale) == (True, None, 1.0)
def test_a_schema_default_is_not_a_choice(self, extra_schema_path):
"""align defaults to 'center' in the schema, and the save flow writes
that into config -- which must not read as the user asking for it."""
config = {"customization": {"score_text": {"align": "center",
"visible": True}}}
st = self._style(config, schema=extra_schema_path)
assert st.align is None, "the plugin keeps its own alignment"
assert st.visible is True
def test_hiding_an_element(self, extra_schema_path):
config = {"customization": {"score_text": {"visible": False}}}
assert self._style(config, schema=extra_schema_path).visible is False
def test_a_real_alignment_choice_comes_through(self, extra_schema_path):
config = {"customization": {"score_text": {"align": "right"}}}
assert self._style(config, schema=extra_schema_path).align == "right"
@pytest.mark.parametrize("written,expected", [
("left", "left"), ("RIGHT", "right"), (" center ", "center"),
("centre", "center"), ("middle", "center"),
("sideways", None), ("", None), (5, None), (None, None),
])
def test_alignment_coercion(self, written, expected, extra_schema_path):
config = {"customization": {"score_text": {"align": written}}}
assert self._style(config, schema=extra_schema_path).align == expected
@pytest.mark.parametrize("written,expected", [
(2, 2.0), (0.5, 0.5), ("1.5", 1.5),
(0, 1.0), (-3, 1.0), # nonsense degrades, never inverts
(999, 10.0), # clamped: the panel is 32px tall
("huge", 1.0), (None, 1.0), (True, 1.0),
])
def test_scale_coercion(self, written, expected, extra_schema_path):
config = {"customization": {"layout": {"score_text": {"scale": written}}}}
assert self._style(config, schema=extra_schema_path).scale == expected
def test_scale_reads_from_the_layout_block(self, extra_schema_path):
"""scale is geometry, so it sits with the offsets -- a logo has a
scale and no font."""
config = {"customization": {"layout": {"score_text": {"scale": 2}}}}
assert self._style(config, schema=extra_schema_path).scale == 2.0
@pytest.mark.parametrize("field,value,attr,expected", [
("visible", False, "visible", False),
("align", "right", "align", "right"),
])
def test_a_mode_overrides_them(self, field, value, attr, expected,
extra_schema_path):
config = {"customization": {"modes": {
"live": {"score_text": {field: value}}}}}
st = self._style(config, mode="live", schema=extra_schema_path)
assert getattr(st, attr) == expected
def test_a_mode_overrides_scale(self, extra_schema_path):
config = {"customization": {
"layout": {"score_text": {"scale": 2}},
"modes": {"live": {"layout": {"score_text": {"scale": 3}}}}}}
assert self._style(config, mode="live",
schema=extra_schema_path).scale == 3.0
def test_an_unset_mode_field_inherits_the_base(self, extra_schema_path):
config = {"customization": {
"score_text": {"visible": False},
"modes": {"live": {"score_text": {"align": "right"}}}}}
st = self._style(config, mode="live", schema=extra_schema_path)
assert st.visible is False and st.align == "right"
class TestExtraFieldSchema:
def _props(self):
return expand_style_elements(
EXTRA_SCHEMA)["properties"]["customization"]["properties"]
def test_only_declared_subfields_are_emitted(self):
"""home_logo declares no font, so it gets no font control."""
assert list(self._props()["home_logo"]["properties"]) == ["visible"]
def test_a_text_element_gets_the_text_fields(self):
assert list(self._props()["score_text"]["properties"]) == [
"font", "font_size", "text_color", "visible", "align"]
def test_scale_is_emitted_with_the_offsets(self):
layout = self._props()["layout"]["properties"]
assert list(layout["home_logo"]["properties"]) == [
"x_offset", "y_offset", "scale"]
assert list(layout["score_text"]["properties"]) == [
"x_offset", "y_offset"], "scale is opt-in"
def test_declared_scale_bounds_are_kept(self):
scale = self._props()["layout"]["properties"]["home_logo"]["properties"]["scale"]
assert (scale["minimum"], scale["maximum"]) == (0.25, 4)
def test_align_offers_only_valid_choices(self):
align = self._props()["score_text"]["properties"]["align"]
assert align["enum"] == ["left", "center", "right"]
def test_the_mode_copies_are_nullable(self):
live = self._props()["modes"]["properties"]["live"]["properties"]
assert live["score_text"]["properties"]["visible"]["default"] is None
assert "null" in live["score_text"]["properties"]["visible"]["type"]
scale = live["layout"]["properties"]["home_logo"]["properties"]["scale"]
assert scale["default"] is None and "null" in scale["type"]
class TestAliasKeys:
"""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), while the layout block
mostly uses the bare noun (score, date, 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.
Renaming config keys to fix that would orphan whatever offsets users had
already dialled in, so lookups try the alternatives instead.
"""
@pytest.mark.parametrize("key,expected", [
("score_text", ("score_text", "score")),
("status", ("status", "status_text")),
("status_text", ("status_text", "status")),
("records", ("records", "record")),
("record", ("record", "records")),
("rank_text", ("rank_text", "ranking", "rank")),
("team_name", ("team_name", "team")),
])
def test_the_spellings_tried(self, key, expected):
assert alias_keys(key) == expected
def test_the_exact_name_is_always_first(self):
for key in ("score_text", "status", "records", "home_logo"):
assert alias_keys(key)[0] == key
def test_an_explicit_entry_replaces_the_suffix_rule(self):
"""'records' must not also generate the meaningless 'records_text'."""
assert "records_text" not in alias_keys("records")
@pytest.mark.parametrize("key", ["", None, 5, [], {}])
def test_nonsense_keys_yield_nothing(self, key):
assert alias_keys(key) == ()
class TestAliasedLookup:
def _r(self, config, mode=None):
return ElementStyleResolver(config, {}, mode=mode)
def test_a_compact_plugin_finds_offsets_saved_under_the_bare_noun(self):
"""The migration case: a scoreboard moving to the compact form asks
for score_text offsets, and its users wrote layout.score."""
config = {"customization": {"layout": {"score": {"y_offset": -3}}}}
assert self._r(config).offset("score_text") == (0, -3)
def test_status_and_status_text_find_each_other(self):
written_long = {"customization": {"layout": {"status_text": {"x_offset": 2}}}}
written_short = {"customization": {"layout": {"status": {"x_offset": 4}}}}
assert self._r(written_long).offset("status") == (2, 0)
assert self._r(written_short).offset("status_text") == (4, 0)
def test_record_and_records_find_each_other(self):
config = {"customization": {"layout": {"records": {"away_x_offset": 5}}}}
assert self._r(config).offset_value("record", "away_x_offset") == 5
def test_an_exact_match_beats_an_alias(self):
"""Nothing changes for a config that already uses the right name."""
config = {"customization": {"layout": {
"score": {"y_offset": 1}, "score_text": {"y_offset": 9}}}}
assert self._r(config).offset("score_text") == (0, 9)
assert self._r(config).offset("score") == (0, 1)
def test_style_blocks_alias_too(self, style_schema_path):
config = {"customization": {"title": {"font_size": 13}}}
st = ElementStyleResolver(
config, defaults_from_schema_file(style_schema_path)
).style("title_text", classic_size=8)
assert st.font_size == 13
def test_a_mode_block_aliases_too(self):
config = {"customization": {
"layout": {"score": {"y_offset": -3}},
"modes": {"live": {"layout": {"score": {"y_offset": 7}}}}}}
assert self._r(config, "live").offset("score_text") == (0, 7)
def test_an_unrelated_element_is_unaffected(self):
config = {"customization": {"layout": {"home_logo": {"x_offset": 3}}}}
r = self._r(config)
assert r.offset("home_logo") == (3, 0)
assert r.offset("away_logo") == (0, 0)
def test_scale_is_found_through_an_alias(self, extra_schema_path):
config = {"customization": {"layout": {"score": {"scale": 2}}}}
st = ElementStyleResolver(
config, defaults_from_schema_file(extra_schema_path)
).style("score_text")
assert st.scale == 2.0
HANDWRITTEN = {
"type": "object",
"properties": {
"customization": {
"type": "object",
"title": "Display Customization",
"properties": {
"score_text": {
"type": "object",
"title": "Game Score",
"properties": {
"font": {
"type": "string",
"enum": ["PressStart2P-Regular.ttf", "4x6-font.ttf",
"5by7.regular.ttf", "5x7.bdf", "4x6.bdf"],
"default": "PressStart2P-Regular.ttf",
},
"font_size": {"type": "integer", "minimum": 4,
"maximum": 16, "default": 10},
"text_color": {"type": "array", "minItems": 3,
"maxItems": 3, "default": [255, 255, 255]},
},
},
"favorite_result_colors": {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": False},
"win_color": {"type": "array", "default": [0, 255, 0]},
},
},
# baseball's 'count' shape: one field this system knows,
# next to geometry that means nothing to it.
"count": {
"type": "object",
"properties": {
"text_color": {"type": "array", "default": [0, 255, 0]},
"y_offset": {"type": "integer", "default": 2},
},
},
"layout": {
"type": "object",
"properties": {
"score": {"type": "object", "properties": {
"x_offset": {"type": "integer", "default": 0},
"y_offset": {"type": "integer", "default": 0}}},
"home_logo": {"type": "object", "properties": {
"x_offset": {"type": "integer", "default": 0}}},
},
},
},
},
},
}
class TestHandWrittenAdoption:
"""Nineteen plugins spell their style elements out longhand -- football's
block is 701 lines for seven elements -- and predate this system. Core
recognises that shape so they pick up the editor and the real font picker
on a core update rather than on a plugin release.
"""
def _customization(self, schema=None):
return expand_style_elements(
schema or HANDWRITTEN)["properties"]["customization"]
def test_the_block_gets_the_composite_editor(self):
assert self._customization()["x-widget"] == "style-editor"
def test_the_declared_order_is_stated_explicitly(self):
"""Flask's JSON provider sorts keys, so without this the elements
reach the browser alphabetised."""
order = self._customization()["x-propertyOrder"]
assert order[0] == "score_text"
def test_a_block_that_is_not_styling_is_left_alone(self):
"""favorite_result_colors, baseball's bases/outs/player_card and the
stocks blocks all carry fields this system knows nothing about."""
block = self._customization()["properties"]["favorite_result_colors"]
assert "x-style-managed" not in block
assert block["properties"]["win_color"]["default"] == [0, 255, 0]
def test_a_block_that_merely_shares_a_field_is_left_alone(self):
"""The reason detection requires *every* field to be one this system
understands. baseball's 'count' carries a text_color beside a
y_offset that means nothing here.
Asserted on the element list rather than on the block itself: an
over-eager rule leaves a fontless block looking untouched, and only
shows up as an extra row in the editor and an extra per-mode
override group.
"""
schema = copy.deepcopy(HANDWRITTEN)
schema["properties"]["customization"]["x-style-modes"] = ["live"]
live = expand_style_elements(schema)["properties"]["customization"][
"properties"]["modes"]["properties"]["live"]["properties"]
assert "score_text" in live
assert "count" not in live, (
"'count' is not a style element -- it shares one field and "
"carries geometry this system does not understand")
assert "favorite_result_colors" not in live
def test_the_hardcoded_font_list_is_replaced_by_the_picker(self):
"""The reason an uploaded font could never appear in one of these."""
font = self._customization()["properties"]["score_text"]["properties"]["font"]
assert "enum" not in font
assert font["x-widget"] == "font-selector"
def test_the_picker_inherits_the_declared_size_ceiling(self):
"""A bitmap font ignores font_size and renders at its own baked-in
size, so a field capped at 16 must not offer a 27px face."""
font = self._customization()["properties"]["score_text"]["properties"]["font"]
assert font["x-options"]["maxFixedSize"] == 16
def test_the_users_existing_font_choice_stays_valid(self):
font = self._customization()["properties"]["score_text"]["properties"]["font"]
assert font["default"] == "PressStart2P-Regular.ttf"
assert font["type"] == "string"
def test_the_input_schema_is_not_mutated(self):
expand_style_elements(HANDWRITTEN)
original = HANDWRITTEN["properties"]["customization"]
assert "x-widget" not in original
assert "enum" in original["properties"]["score_text"]["properties"]["font"]
def test_no_style_blocks_means_no_expansion(self):
schema = {"properties": {"customization": {"type": "object",
"properties": {"favorite_result_colors": {"type": "object",
"properties": {"win_color": {"type": "array"}}}}}}}
assert expand_style_elements(schema) is schema
class TestAdoptedModes:
"""Per-mode overrides stay opt-in: core cannot invent a plugin's list of
display modes. Declaring x-style-modes is the one line that unlocks them
for a hand-written block."""
def _live(self):
schema = copy.deepcopy(HANDWRITTEN)
schema["properties"]["customization"]["x-style-modes"] = ["live", "recent"]
return expand_style_elements(schema)["properties"]["customization"][
"properties"]["modes"]["properties"]["live"]["properties"]
def test_modes_are_not_invented(self):
assert "modes" not in self._customization_props()
def _customization_props(self):
return expand_style_elements(HANDWRITTEN)["properties"]["customization"]["properties"]
def test_declaring_modes_generates_them(self):
assert "score_text" in self._live()
def test_mode_fields_are_nullable(self):
size = self._live()["score_text"]["properties"]["font_size"]
assert size["default"] is None and "null" in size["type"]
def test_every_positionable_element_gets_per_mode_offsets(self):
"""The two namespaces do not line up in a hand-written schema: this
one styles 'score_text' but positions 'score', and positions a logo
that has no style block at all. Keying the layout off the style
elements would have left both without a per-mode offset."""
layout = self._live()["layout"]["properties"]
assert set(layout) == {"score", "home_logo"}
def test_the_mode_font_picker_keeps_the_ceiling(self):
font = self._live()["score_text"]["properties"]["font"]
assert font["x-options"]["maxFixedSize"] == 16
assert "null" in font["type"]
class TestElementColorClamps:
"""An out-of-range component is clamped, not thrown away.
#569 fixed this in _normalize_color and #572 covered the resolver path,
whose own test notes that "the resolver path normalizes colour separately
from element_color". This covers the other one: the stateless element_color()
that src.common.sports_card delegates to, which is the path all nine
scoreboard plugins take for every per-element colour they draw.
It regressed once already. element_color() moved here with the per-element
customization framework, the coercion rejected out-of-range components where
the reader it replaced clamped them, and rejection reads as "not configured"
-- so a single component over 255 painted the element white while the user's
chosen colour sat in their config. Every scoreboard's
test_element_text_colors.py failed on it.
"""
CFG = {"customization": {
"rank_text": {"text_color": [999, -5, 20]},
"score_text": {"text_color": "#FF0000"},
"period_text": {"text_color": [0, 255, 255]},
"team_name": {"text_color": "nonsense"},
}}
def test_out_of_range_components_are_clamped(self):
assert element_color(self.CFG, "rank_text") == (255, 0, 20)
def test_in_range_values_are_unchanged(self):
assert element_color(self.CFG, "period_text") == (0, 255, 255)
def test_hex_still_parses(self):
assert element_color(self.CFG, "score_text") == (255, 0, 0)
def test_an_unparseable_value_still_falls_back(self):
assert element_color(self.CFG, "team_name") == (255, 255, 255)
def test_a_missing_element_still_falls_back(self):
assert element_color(self.CFG, "detail_text") == (255, 255, 255)
def test_it_agrees_with_coerce_rgb(self):
from src.common.sports_card import coerce_rgb
for value in ([999, -5, 20], [300, 300, 300], [-1, -1, -1], [0, 128, 255]):
cfg = {"customization": {"rank_text": {"text_color": value}}}
assert element_color(cfg, "rank_text") == coerce_rgb(value, (0, 0, 0)), value
+187
View File
@@ -0,0 +1,187 @@
"""Visibility, alignment and scale reach the draw.
The resolver has understood these three since the framework landed, but nothing
on the scoreboard path consumed them: an element could be marked hidden in the
web UI and still render. This covers the readers, the mixin accessors the draw
paths use, and the one shared logo-sizing seam.
The rule throughout is that an untouched config takes exactly the path it took
before -- so each test that asserts an effect has a sibling asserting the
default is inert.
"""
import logging
import sys
from pathlib import Path
import pytest
from PIL import Image
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
from src.common.logo_helper import LogoHelper # noqa: E402
from src.common.sports_shared import SportsCoreSharedMixin # noqa: E402
from src.element_style import ( # noqa: E402
element_align, element_scale, element_visible)
HIDDEN = {"customization": {"records": {"visible": False}}}
ALIGNED = {"customization": {"score_text": {"align": "right"}}}
SCALED = {"customization": {"layout": {"home_logo": {"scale": 0.5}}}}
class TestTheReaders:
def test_an_element_is_visible_unless_it_says_otherwise(self):
assert element_visible({}, "records") is True
assert element_visible(HIDDEN, "records") is False
def test_a_mode_can_hide_what_the_base_shows(self):
cfg = {"customization": {"records": {"visible": True},
"modes": {"recent": {"records": {"visible": False}}}}}
assert element_visible(cfg, "records") is True
assert element_visible(cfg, "records", mode="recent") is False
def test_a_mode_that_says_nothing_inherits(self):
"""None is the inherit sentinel and must not read as False."""
cfg = {"customization": {"records": {"visible": False},
"modes": {"live": {"records": {"visible": None}}}}}
assert element_visible(cfg, "records", mode="live") is False
def test_alignment_is_unset_by_default(self):
assert element_align({}, "score_text") is None
assert element_align(ALIGNED, "score_text") == "right"
def test_scale_reads_the_layout_block(self):
assert element_scale({}, "home_logo") == 1.0
assert element_scale(SCALED, "home_logo") == 0.5
def test_scale_takes_a_per_mode_override(self):
cfg = {"customization": {"layout": {"home_logo": {"scale": 0.5}},
"modes": {"live": {"layout": {"home_logo": {"scale": 2.0}}}}}}
assert element_scale(cfg, "home_logo", mode="live") == 2.0
assert element_scale(cfg, "home_logo") == 0.5
@pytest.mark.parametrize("cfg", [
None, {}, "nonsense", {"customization": "nonsense"},
{"customization": {"records": "nonsense"}},
{"customization": {"records": {"visible": "maybe"}}},
])
def test_a_hostile_config_never_raises(self, cfg):
assert element_visible(cfg, "records") is True
assert element_align(cfg, "records") is None
assert element_scale(cfg, "records") == 1.0
class _Draw:
"""Records what would have been drawn."""
def __init__(self):
self.calls = []
self.fontmode = ""
def text(self, position, text, font=None, fill=None):
self.calls.append({"position": position, "text": text, "fill": fill})
class _Host(SportsCoreSharedMixin):
def __init__(self, config, mode=None):
self.config = config
self.fonts = {}
self.logger = logging.getLogger("test")
self.SKIN_MODE = mode
class TestTheDrawPath:
def test_a_hidden_element_is_not_drawn_at_all(self):
draw = _Draw()
_Host(HIDDEN)._draw_text_with_outline(
draw, "9-1", (0, 0), None, element="records")
assert draw.calls == [], (
"a hidden element must produce no draw, outline included")
def test_a_visible_element_still_draws(self):
draw = _Draw()
_Host({})._draw_text_with_outline(
draw, "9-1", (0, 0), None, element="records")
assert draw.calls, "the default must be inert"
def test_naming_the_element_resolves_its_colour(self):
draw = _Draw()
cfg = {"customization": {"score_text": {"text_color": [1, 2, 3]}}}
_Host(cfg)._draw_text_with_outline(
draw, "21", (0, 0), None, element="score_text")
# Last call is the text itself; the earlier eight are the outline.
assert draw.calls[-1]["fill"] == (1, 2, 3)
def test_an_explicit_fill_still_wins(self):
"""Odds colours and the favourite-result tint mean something the
palette does not."""
draw = _Draw()
cfg = {"customization": {"score_text": {"text_color": [1, 2, 3]}}}
_Host(cfg)._draw_text_with_outline(
draw, "21", (0, 0), None, fill=(9, 9, 9), element="score_text")
assert draw.calls[-1]["fill"] == (9, 9, 9)
def test_a_mode_hides_only_that_mode(self):
cfg = {"customization": {"modes": {"recent": {"records": {"visible": False}}}}}
recent, live = _Draw(), _Draw()
_Host(cfg, "recent")._draw_text_with_outline(
recent, "9-1", (0, 0), None, element="records")
_Host(cfg, "live")._draw_text_with_outline(
live, "9-1", (0, 0), None, element="records")
assert recent.calls == []
assert live.calls
class TestAlignment:
def test_unset_leaves_the_caller_where_it_was(self):
"""These draws carry per-sport nudges; a centre recomputed here would
not be the same pixel."""
assert _Host({})._aligned_x("score_text", 20, 64, 22) == 22
def test_right_pushes_to_the_edge(self):
assert _Host(ALIGNED)._aligned_x("score_text", 20, 64, 22) == 44
def test_left_goes_to_zero(self):
cfg = {"customization": {"score_text": {"align": "left"}}}
assert _Host(cfg)._aligned_x("score_text", 20, 64, 22) == 0
def test_text_wider_than_the_panel_is_not_pushed_off(self):
assert _Host(ALIGNED)._aligned_x("score_text", 100, 64, 0) == 0
class TestLogoScale:
@pytest.fixture
def logo(self, tmp_path):
path = tmp_path / "team.png"
Image.new("RGBA", (40, 40), (255, 0, 0, 255)).save(path)
return path
def test_an_unscaled_load_is_unchanged(self, logo):
helper = LogoHelper(display_width=64, display_height=32)
plain = helper.load_logo("AAA", logo, 32, 32)
assert plain.size == (32, 32)
def test_a_half_scale_logo_is_half_the_box(self, logo):
helper = LogoHelper(display_width=64, display_height=32)
small = helper.load_logo("AAA", logo, 32, 32, scale=0.5)
assert small.size == (16, 16)
def test_scaling_up_grows_the_logo(self, logo):
"""The fit rule never grows an image, so this only happens when the
user asks for it."""
helper = LogoHelper(display_width=64, display_height=32)
big = helper.load_logo("AAA", logo, 64, 64, scale=2.0)
assert big.size[0] > 40
def test_two_scales_do_not_share_a_cache_entry(self, logo):
helper = LogoHelper(display_width=64, display_height=32)
full = helper.load_logo("AAA", logo, 32, 32)
half = helper.load_logo("AAA", logo, 32, 32, scale=0.5)
assert full.size == (32, 32) and half.size == (16, 16)
@pytest.mark.parametrize("bad", [0, -1, None, "big", float("inf"),
float("nan")])
def test_an_unusable_scale_is_ignored(self, logo, bad):
helper = LogoHelper(display_width=64, display_height=32)
assert helper.load_logo("AAA", logo, 32, 32, scale=bad).size == (32, 32)
+178
View File
@@ -0,0 +1,178 @@
"""The bundled faces load on their pixel grid, from any working directory.
`4x6-font.ttf` renders crisply at multiples of 7, not at the 6 its name
suggests. Plugins draw it with ``draw.fontmode = "1"``, and the mono rasteriser
thresholds each glyph at 50% coverage: a glyph asked for at 6 loses its fourth
column, so "UNTIL" rendered as "VM1JL" and "CHRISTMAS" as "CHAJS1MAS" on real
panels. The committed golden images for christmas-countdown encoded exactly
that, because the harness sized the face independently of the display core and
so agreed with it about the wrong number.
These tests pin the three things that failure needed:
* the size comes from `crisp_size`, not a literal;
* `VisualTestDisplayManager` -- the harness's deliberate fork of
`DisplayManager` -- agrees with it, so a golden blessed by the harness
matches what the panel draws;
* the paths resolve against the install root, so a run from another directory
loads the real face instead of silently falling back to
`ImageFont.load_default()`.
"""
import os
import subprocess
import sys
from pathlib import Path
import pytest
from PIL import Image, ImageDraw
os.environ.setdefault("EMULATOR", "true")
from src.common.font_layout import ( # noqa: E402
FONT_PIXEL_GRID, crisp_size, load_truetype, resolve_asset_path,
)
PROJECT_ROOT = Path(__file__).resolve().parents[1]
FOUR_BY_SIX = "4x6-font.ttf"
PRESS_START = "PressStart2P-Regular.ttf"
class TestCrispSize:
"""The snapping rule itself."""
def test_the_natural_looking_six_is_off_grid(self):
# The whole bug in one line: 6 is not a size this face renders on.
assert crisp_size(FOUR_BY_SIX, 6) == 7
def test_press_start_is_unchanged_at_eight(self):
assert crisp_size(PRESS_START, 8) == 8
def test_an_unknown_face_is_never_second_guessed(self):
assert crisp_size("SomeUserUpload.ttf", 11) == 11
@pytest.mark.parametrize("face,grid", sorted(FONT_PIXEL_GRID.items()))
def test_every_known_face_snaps_to_its_own_grid(self, face, grid):
for desired in range(1, grid * 4):
assert crisp_size(face, desired) % grid == 0
class TestGlyphsKeepTheirFourthColumn:
"""Why the grid matters, measured rather than asserted from the docs."""
@staticmethod
def _ink_width(font, ch):
img = Image.new("L", (32, 16), 0)
draw = ImageDraw.Draw(img)
draw.fontmode = "1" # what the plugins draw with
draw.text((2, 2), ch, font=font, fill=255)
box = img.getbbox()
return 0 if box is None else box[2] - box[0]
@pytest.mark.parametrize("ch", list("WM08"))
def test_on_grid_is_a_full_four_pixels_wide(self, ch):
path = resolve_asset_path(f"assets/fonts/{FOUR_BY_SIX}")
on_grid = load_truetype(path, crisp_size(FOUR_BY_SIX, 6))
off_grid = load_truetype(path, 6)
assert self._ink_width(on_grid, ch) == 4
# The regression this guards: the same glyph one pixel narrower.
assert self._ink_width(off_grid, ch) == 3
class TestAssetPathsIgnoreTheWorkingDirectory:
"""A run from anywhere else must not degrade to the default face."""
def test_resolves_from_an_unrelated_directory(self, tmp_path):
# Resolution must not depend on the cwd of the *test* process either,
# so this runs in a child with tmp_path as its working directory.
code = (
"from src.common.font_layout import resolve_asset_path;"
"import os;"
"p = resolve_asset_path('assets/fonts/4x6-font.ttf');"
"print(os.path.isabs(p) and os.path.exists(p))"
)
env = dict(os.environ, PYTHONPATH=str(PROJECT_ROOT))
out = subprocess.run( # nosec B603 - fixed argv, no shell
[sys.executable, "-c", code],
cwd=tmp_path, env=env, capture_output=True, text=True, timeout=120,
)
assert out.stdout.strip() == "True", out.stderr
def test_a_same_named_file_in_the_cwd_does_not_shadow_the_bundled_asset(
self, tmp_path,
):
# A relative path is a repo-relative asset name, not a cwd-relative
# one: an unrelated decoy that happens to sit at the same relative
# path in whatever directory the process was started from must not
# be preferred over the real bundled font.
decoy_dir = tmp_path / "assets" / "fonts"
decoy_dir.mkdir(parents=True)
decoy = decoy_dir / FOUR_BY_SIX
decoy.write_bytes(b"not a real font")
code = (
"from src.common.font_layout import resolve_asset_path;"
"print(resolve_asset_path('assets/fonts/4x6-font.ttf'))"
)
env = dict(os.environ, PYTHONPATH=str(PROJECT_ROOT))
out = subprocess.run( # nosec B603 - fixed argv, no shell
[sys.executable, "-c", code],
cwd=tmp_path, env=env, capture_output=True, text=True, timeout=120,
)
resolved = out.stdout.strip()
assert resolved != str(decoy), out.stderr
assert resolved == str(PROJECT_ROOT / "assets" / "fonts" / FOUR_BY_SIX)
def test_an_absolute_path_is_returned_untouched(self):
absolute = str(PROJECT_ROOT / "assets" / "fonts" / FOUR_BY_SIX)
assert resolve_asset_path(absolute) == absolute
def test_font_manager_shares_the_one_definition(self):
# Plugins probe for this method by name to borrow the core's notion of
# "install root"; it must stay, and must agree with the free function.
from src.font_manager import FontManager
rel = f"assets/fonts/{FOUR_BY_SIX}"
assert FontManager._resolve_asset_path(rel) == resolve_asset_path(rel)
class TestTheHarnessForkAgreesWithTheCore:
"""The divergence that let the wrong rendering be blessed as golden.
`VisualTestDisplayManager` is a deliberate fork of `DisplayManager` that
runs without hardware, and the harness renders every golden through it. It
carries its own `_load_fonts`, so a size fixed in one and not the other
means CI compares panels against a face no panel uses.
"""
@staticmethod
def _sizes(dm):
return {name: getattr(dm, name).size
for name in ("regular_font", "small_font", "extra_small_font")}
def test_extra_small_font_is_on_grid_in_the_harness(self):
from src.plugin_system.testing.visual_display_manager import (
VisualTestDisplayManager,
)
dm = VisualTestDisplayManager(width=128, height=32)
assert dm.extra_small_font.size == crisp_size(FOUR_BY_SIX, 6) == 7
def test_the_fork_and_the_core_load_the_same_sizes(self):
from unittest.mock import MagicMock, patch
from src.display_manager import DisplayManager
from src.plugin_system.testing.visual_display_manager import (
VisualTestDisplayManager,
)
fork = VisualTestDisplayManager(width=128, height=32)
with patch("src.display_manager.RGBMatrix") as matrix, \
patch("src.display_manager.RGBMatrixOptions"):
instance = MagicMock()
instance.width, instance.height = 128, 32
matrix.return_value = instance
DisplayManager._instance = None
core = DisplayManager.__new__(DisplayManager)
core._text_width_cache = {}
core._load_fonts()
assert self._sizes(core) == self._sizes(fork)
+238
View File
@@ -0,0 +1,238 @@
"""
Tests for the rpi-rgb-led-matrix checkout helpers in first_time_install.sh, and
for the contract between scripts/install/one-shot-install.sh and that script.
Background: the submodule was pinned to a commit whose RP1 backend emits the
ARMv7-only `dmb ishst` instruction, so the build failed on every ARMv6 board
(Pi Zero / Zero W / Pi 1). Bumping the pin fixes fresh installs, but `git pull`
never moves an existing submodule checkout, and earlier installers ran the
submodule git commands as root, leaving .git/modules/<submodule> root-owned and
the user locked out of their own checkout.
These cover everything that runs without root. The ownership-repair path
(root, a second user, chown) mutates the system, so -- as with the swap helpers
in test_install_lowmem.py -- it is exercised manually instead.
"""
import os
import re
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
INSTALLER = ROOT / "first_time_install.sh"
ONE_SHOT = ROOT / "scripts" / "install" / "one-shot-install.sh"
BEGIN = "# --- rpi-rgb-led-matrix checkout helpers"
END = "# --- end rpi-rgb-led-matrix checkout helpers"
SUB = "rpi-rgb-led-matrix-master"
pytestmark = pytest.mark.skipif(
not sys.platform.startswith("linux"), reason="exercises the installer's bash under Linux"
)
def helper_block() -> str:
text = INSTALLER.read_text(encoding="utf-8")
assert text.count(BEGIN) == 1 and text.count(END) == 1, "helper block markers missing or duplicated"
return text[text.index(BEGIN): text.index(END)]
def git(*args: str, cwd: Path, env: dict) -> str:
result = subprocess.run(["git", *args], cwd=cwd, env=env, capture_output=True, text=True)
assert result.returncode == 0, f"git {' '.join(args)} failed: {result.stderr}"
return result.stdout.strip()
@pytest.fixture
def git_env(tmp_path):
config = tmp_path / "gitconfig"
config.write_text(
"[user]\n\tname = t\n\temail = t@t\n"
"[protocol \"file\"]\n\tallow = always\n"
"[init]\n\tdefaultBranch = master\n"
"[advice]\n\tdetachedHead = false\n",
encoding="utf-8",
)
return {
"PATH": "/usr/bin:/bin:/usr/sbin:/sbin",
"HOME": str(tmp_path),
"GIT_CONFIG_GLOBAL": str(config),
"GIT_CONFIG_NOSYSTEM": "1",
}
@pytest.fixture
def upstream(tmp_path, git_env):
"""A stand-in for hzeller/rpi-rgb-led-matrix with commits A -> B -> C."""
up = tmp_path / "upstream"
up.mkdir()
git("init", "-q", ".", cwd=up, env=git_env)
commits = {}
for name, filename in (("A", "Makefile"), ("B", "b"), ("C", "c")):
(up / filename).write_text(name, encoding="utf-8")
git("add", ".", cwd=up, env=git_env)
git("commit", "-qm", name, cwd=up, env=git_env)
commits[name] = git("rev-parse", "HEAD", cwd=up, env=git_env)
return up, commits
@pytest.fixture
def make_project(tmp_path, git_env, upstream):
"""make_project(checkout) -> project pinned at B, submodule checked out at `checkout`."""
up, commits = upstream
def _make(checkout: str) -> Path:
project = tmp_path / f"project-{checkout}"
project.mkdir()
git("init", "-q", ".", cwd=project, env=git_env)
git("submodule", "add", "-q", str(up), SUB, cwd=project, env=git_env)
git("checkout", "-q", commits["B"], cwd=project / SUB, env=git_env)
git("add", SUB, cwd=project, env=git_env)
git("commit", "-qm", "pin", cwd=project, env=git_env)
git("checkout", "-q", commits[checkout], cwd=project / SUB, env=git_env)
return project
return _make
def run_helpers(snippet: str, project: Path, env: dict) -> subprocess.CompletedProcess:
"""Run a snippet against the helpers under the installer's own strict mode."""
script = (
"set -Eeuo pipefail\n"
"trap 'echo ERR_TRAP_FIRED >&2; exit 99' ERR\n"
f"{helper_block()}\n"
f'PROJECT_ROOT_DIR="{project}"\n'
f"{snippet}\n"
)
return subprocess.run(["bash", "-c", script], capture_output=True, text=True, env=env)
def head_of(project: Path, env: dict) -> str:
return git("rev-parse", "HEAD", cwd=project / SUB, env=env)
class TestInstallerStructure:
def test_installer_is_syntactically_valid(self):
result = subprocess.run(["bash", "-n", str(INSTALLER)], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
def test_helper_block_defines_the_helpers(self):
block = helper_block()
for name in ("_rgb_repo_owner", "_git_as_repo_owner", "_reclaim_rgb_checkout", "_sync_rgb_submodule"):
assert re.search(rf"^{name}\(\) \{{", block, re.M), f"{name} is not defined in the helper block"
def test_every_called_underscore_function_is_defined_before_use(self):
# A renamed helper with a stale call site aborts the installer with
# "command not found" under set -e -- the users this protects are the
# ones least able to recover from that.
lines = INSTALLER.read_text(encoding="utf-8").splitlines()
defined_at = {}
for number, line in enumerate(lines, 1):
match = re.match(r"\s*(_[A-Za-z0-9_]+)\(\) \{", line)
if match:
defined_at.setdefault(match.group(1), number)
# A name in command position; `_name=value` is an assignment and
# `_name() {` a definition, so neither counts as a call.
call = re.compile(r"(?:^\s*|\bretry\s+|\bif\s+!?\s*|&&\s*|\|\|\s*|\$\()(_[A-Za-z0-9_]+)\b(?!\(\)|=|\[)")
for number, line in enumerate(lines, 1):
if line.lstrip().startswith("#"):
continue
for name in call.findall(line):
if name.startswith("_") and not re.match(r"_[A-Z0-9_]+$", name): # skip $_VARS
assert name in defined_at, f"line {number} calls undefined function {name}"
# Functions defined inside another function body run later;
# top-level calls must come after the definition.
if not line.startswith((" ", "\t")):
assert defined_at[name] < number, f"line {number} calls {name} before it is defined"
class TestSyncSubmodule:
def test_checkout_behind_the_pin_is_moved_forward(self, make_project, upstream, git_env):
project = make_project("A")
result = run_helpers("_reclaim_rgb_checkout; _sync_rgb_submodule", project, git_env)
assert result.returncode == 0, result.stderr
assert head_of(project, git_env) == upstream[1]["B"]
def test_checkout_newer_than_the_pin_is_left_alone(self, make_project, upstream, git_env):
# What `git submodule update --remote` -- advice the installer itself
# prints for Pi 5 -- leaves behind. Must never be rolled back.
project = make_project("C")
result = run_helpers("_sync_rgb_submodule", project, git_env)
assert result.returncode == 0, result.stderr
assert head_of(project, git_env) == upstream[1]["C"]
assert "leaving it as is" in result.stdout
def test_checkout_at_the_pin_is_a_silent_no_op(self, make_project, upstream, git_env):
project = make_project("B")
result = run_helpers("_sync_rgb_submodule", project, git_env)
assert result.returncode == 0, result.stderr
assert result.stdout == ""
assert head_of(project, git_env) == upstream[1]["B"]
def test_unreachable_pin_warns_and_keeps_the_checkout(self, make_project, upstream, git_env):
project = make_project("A")
ghost = "0123456789abcdef0123456789abcdef01234567"
git("update-index", "--cacheinfo", f"160000,{ghost},{SUB}", cwd=project, env=git_env)
git("commit", "-qm", "ghost pin", cwd=project, env=git_env)
result = run_helpers("_sync_rgb_submodule", project, git_env)
assert result.returncode == 0, result.stderr
assert "ERR_TRAP_FIRED" not in result.stderr
assert "Could not update" in result.stdout
assert head_of(project, git_env) == upstream[1]["A"]
def test_local_files_blocking_the_checkout_are_not_clobbered(self, make_project, git_env):
project = make_project("A")
(project / SUB / "b").write_text("mine", encoding="utf-8") # B adds a tracked `b`
result = run_helpers("_sync_rgb_submodule", project, git_env)
assert result.returncode == 0, result.stderr
assert (project / SUB / "b").read_text(encoding="utf-8") == "mine"
def test_directory_without_git_metadata_is_a_no_op(self, make_project, git_env):
project = make_project("A")
subprocess.run(["rm", "-rf", str(project / SUB / ".git"), str(project / ".git" / "modules")], check=True)
result = run_helpers("_sync_rgb_submodule", project, git_env)
assert result.returncode == 0, result.stderr
assert result.stdout == ""
def test_project_without_the_submodule_configured_is_a_no_op(self, tmp_path, git_env):
project = tmp_path / "plain"
(project / SUB).mkdir(parents=True)
result = run_helpers("_sync_rgb_submodule", project, git_env)
assert result.returncode == 0, result.stderr
assert result.stdout == ""
class TestOwnershipHelpersWithoutRoot:
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="covers the non-root path")
def test_reclaim_is_a_no_op_and_git_runs_directly(self, make_project, git_env):
project = make_project("B")
result = run_helpers("_reclaim_rgb_checkout; _git_as_repo_owner --version", project, git_env)
assert result.returncode == 0, result.stderr
assert result.stdout.startswith("git version")
class TestOneShotContract:
"""one-shot-install.sh clones the repo and hands off to first_time_install.sh."""
def test_one_shot_invokes_the_installer_at_the_repo_root(self):
one_shot = ONE_SHOT.read_text(encoding="utf-8")
assert "first_time_install.sh -y" in one_shot
assert INSTALLER.is_file()
def test_installer_accepts_everything_the_one_shot_passes(self):
one_shot = ONE_SHOT.read_text(encoding="utf-8")
installer = INSTALLER.read_text(encoding="utf-8")
assert re.search(r"^\s*-y\|--yes\)", installer, re.M), "installer no longer parses -y"
for var in sorted(set(re.findall(r"\b(LEDMATRIX_[A-Z_]+)=", one_shot))):
assert var in installer, f"one-shot passes {var}, which first_time_install.sh never reads"
def test_installer_initialises_the_submodule_itself(self):
# The one-shot clones without --recurse-submodules, so the installer is
# the only thing that ever fetches the matrix library.
assert "--recurse-submodules" not in ONE_SHOT.read_text(encoding="utf-8")
installer = INSTALLER.read_text(encoding="utf-8")
assert re.search(rf"submodule update --init --recursive {SUB}", installer)
+72
View File
@@ -387,9 +387,81 @@ class TestFrameStatsPercentiles:
def test_log_frame_rate_emits_the_line_and_clears_the_window(self, helper):
helper._window = [0.010] * 20
helper.last_frame_time = time.time() # arm the clock; see below
helper.last_fps_log_time = 0.0 # force the 5s boundary
with patch.object(helper.logger, "info") as info:
helper.log_frame_rate()
assert info.called
assert "Scroll frame stats" in info.call_args[0][0]
assert helper._window == []
class TestIdleGapIsNotAFrame:
"""The first frame of a scroll has no predecessor, so timing one measures
the idle gap since the last scroll rather than a frame.
Left in, that gap lands in the max field of otherwise healthy windows and
counts as one stall per scroll start -- about 0.2% at 500 frames to a
window, which is the same order as the real stall rates it sits next to.
The stall rate is the number used to judge whether a scroll change worked,
so it has to be clean.
"""
def test_clock_starts_unarmed(self, helper):
assert helper.last_frame_time is None
def test_first_frame_seeds_the_clock_without_sampling(self, helper):
helper.log_frame_rate()
assert helper.last_frame_time is not None
assert helper._window == []
assert helper.frame_times == []
def test_second_frame_is_sampled(self, helper):
helper.log_frame_rate()
helper.log_frame_rate()
assert len(helper._window) == 1
def test_reset_scroll_disarms_the_clock(self, helper):
helper.log_frame_rate()
assert helper.last_frame_time is not None
helper.reset_scroll()
assert helper.last_frame_time is None
def test_gap_longer_than_the_log_interval_is_dropped(self, helper):
"""Covers callers that scroll without ever calling reset_scroll()."""
helper.last_frame_time = time.time() - 137.0 # a real observed gap
helper.log_frame_rate()
assert helper._window == []
assert helper.frame_times == []
def test_a_gap_does_not_reach_the_stats_line(self, helper):
helper.last_frame_time = time.time() - 137.0
helper.last_fps_log_time = 0.0 # the 5s boundary is also due
with patch.object(helper.logger, "info") as info:
helper.log_frame_rate()
assert not info.called, "the idle gap was reported as a frame"
def test_window_of_only_gaps_logs_nothing(self, helper):
"""A window whose every sample was dropped has nothing to report --
and reporting the gap itself is the bug this guards."""
helper.last_fps_log_time = 0.0
with patch.object(helper.logger, "info") as info:
helper.log_frame_rate() # seeds
helper.last_frame_time = time.time() - 137.0
helper.log_frame_rate() # dropped
assert not info.called
def test_seeding_restarts_the_window_timer(self, helper):
"""A new scroll should not open by reporting a one-frame window."""
helper.last_fps_log_time = 0.0 # boundary long overdue
helper.log_frame_rate() # seeds
with patch.object(helper.logger, "info") as info:
helper.log_frame_rate() # first real frame
assert not info.called
assert len(helper._window) == 1
def test_a_normal_frame_still_counts(self, helper):
helper.last_frame_time = time.time() - 0.010
helper.log_frame_rate()
assert len(helper._window) == 1
assert helper._window[0] == pytest.approx(0.010, abs=0.005)
+156 -3
View File
@@ -53,6 +53,14 @@ class TestColour:
cfg = {"customization": {"score_text": {"text_color": value}}}
assert C.element_color(cfg, "score_text", (9, 9, 9)) == (9, 9, 9)
def test_out_of_range_components_are_clamped_not_rejected(self):
"""The readers this replaced clamped, and the eight scoreboards' own
tests pin it. Rejecting instead turned a typo'd [999, -5, 20] -- a
bright red -- into the default white, which is a colour the user never
asked for rather than the one they nearly asked for."""
cfg = {"customization": {"score_text": {"text_color": [999, -5, 20]}}}
assert C.element_color(cfg, "score_text", (9, 9, 9)) == (255, 0, 20)
def test_coerce_rgb_clamps_rather_than_rejecting(self):
assert C.coerce_rgb([300, -5, 20], (0, 0, 0)) == (255, 0, 20)
@@ -65,11 +73,37 @@ class TestColour:
cfg = {"customization": {"score_text": {"text_color": [4, 5, 6]}}}
assert C.font_color(cfg, {"score": a, "team": b}, a) == (4, 5, 6)
def test_a_shared_face_gives_up_rather_than_guessing(self):
"""One object used for two elements has no single right colour."""
def test_a_shared_face_takes_the_one_colour_that_was_configured(self):
"""A face shared by two elements used to go out white even when only
one of them had a colour set -- and a bitmap face is *always* shared,
because a freetype.Face cannot be re-instantiated to un-share it. That
is how an element in any of the 32 shipped BDF fonts silently lost the
colour its picker had offered all along. One configured colour among
the sharers is the only thing the user can have meant."""
shared = object()
cfg = {"customization": {"score_text": {"text_color": [4, 5, 6]}}}
assert C.font_color(cfg, {"score": shared, "team": shared}, shared) == (255, 255, 255)
assert C.font_color(cfg, {"score": shared, "team": shared},
shared) == (4, 5, 6)
def test_a_shared_face_still_gives_up_when_the_colours_disagree(self):
"""Two different answers is the case with no right answer."""
shared = object()
cfg = {"customization": {"score_text": {"text_color": [4, 5, 6]},
"team_name": {"text_color": [7, 8, 9]}}}
assert C.font_color(cfg, {"score": shared, "team": shared},
shared) == (255, 255, 255)
def test_sharers_that_agree_resolve_to_that_colour(self):
shared = object()
cfg = {"customization": {"score_text": {"text_color": [4, 5, 6]},
"team_name": {"text_color": [4, 5, 6]}}}
assert C.font_color(cfg, {"score": shared, "team": shared},
shared) == (4, 5, 6)
def test_an_unconfigured_shared_face_keeps_the_default(self):
shared = object()
assert C.font_color({}, {"score": shared, "team": shared}, shared,
(9, 9, 9)) == (9, 9, 9)
class TestFavourites:
@@ -193,3 +227,122 @@ class TestTables:
def test_month_and_weekday_tables_are_complete(self):
assert len(C.MONTH_ABBR) == 12 and len(C.WEEKDAY_ABBR) == 7
class TestUnshareElementFonts:
"""Re-instantiated faces must match the ones they replace.
``unshare_element_fonts`` rebuilds a duplicate face purely so two elements
can be told apart by ``id()``. That is only safe while the rebuilt face
lays text out identically -- and PIL's two layout engines disagree on
fractional advances, which is why ``src.common.font_layout`` exists and
pins one. Rebuilding through bare ``ImageFont.truetype`` took the default
engine instead, so a re-instantiated face could measure differently from
the shared face it replaced.
"""
def _face(self, size=10):
from src.common.font_layout import load_truetype
path = os.path.join("assets", "fonts", "PressStart2P-Regular.ttf")
return load_truetype(path, size)
def test_rebuilt_face_goes_through_the_pinned_loader(self, log, monkeypatch):
"""Asserted on the loader, not on the resulting engine value.
PIL only selects Raqm when it is installed; where it is not, a bare
``ImageFont.truetype`` returns BASIC too, so comparing engine values
passes on those machines whether or not the pin is honoured -- this
test did exactly that before it was rewritten. Spying on the pinned
loader fails on every machine when the pin is bypassed, which is the
point: the cross-machine mismatch font_layout exists to prevent
cannot be reproduced on a Raqm-less runner.
"""
import src.common.font_layout as fl
# Build the face BEFORE patching: _face() loads through the same
# pinned loader, so patching first would let the fixture's own call
# satisfy the assertion and the test would pass either way.
shared = self._face()
calls = []
real = fl.load_truetype
def spy(font, size, **kwargs):
calls.append((font, size))
return real(font, size, **kwargs)
monkeypatch.setattr(fl, "load_truetype", spy)
fonts = {"score": shared, "time": shared}
C.unshare_element_fonts(log, fonts)
assert fonts["time"] is not shared, "the duplicate should have been rebuilt"
assert calls, "the rebuild must go through the pinned loader"
def test_rebuilt_face_keeps_the_shared_faces_layout_engine(self, log):
shared = self._face()
fonts = {"score": shared, "time": shared}
C.unshare_element_fonts(log, fonts)
assert fonts["time"].layout_engine == shared.layout_engine
def test_rebuilt_face_measures_identically(self, log):
shared = self._face()
fonts = {"score": shared, "time": shared}
C.unshare_element_fonts(log, fonts)
text = "88-88"
assert (fonts["time"].getlength(text)
== shared.getlength(text)), "metrics must not shift"
def test_distinct_faces_are_left_alone(self, log):
a, b = self._face(10), self._face(8)
fonts = {"score": a, "time": b}
C.unshare_element_fonts(log, fonts)
assert fonts["score"] is a and fonts["time"] is b
class TestSharedElementReaders:
"""The scoreboards' colour and offset reads now go through one
implementation.
There were two copies of the colour read and three of the offset read.
They had already drifted: the scroll-card renderer carries a comment
noting it used to ignore offsets its own schema advertised. Sharing one
reader is what lets a fix (or an alias, or a per-mode override) reach
the full-screen scorebug and the scroll card together.
"""
CONFIG = {
"customization": {
"score_text": {"text_color": [255, 200, 0]},
"status": {"text_color": "#00ff00"},
"layout": {"score": {"y_offset": -3}},
"modes": {
"live": {"score_text": {"text_color": [255, 0, 0]},
"layout": {"score": {"y_offset": 7}}},
},
},
}
def test_colour_reads_the_configured_value(self, log):
assert C.element_color(self.CONFIG, "score_text") == (255, 200, 0)
def test_hex_colours_are_still_accepted(self, log):
"""These readers have always taken '#RRGGBB' as well as [r, g, b],
so the shared one had to learn it rather than the callers losing it."""
assert C.element_color(self.CONFIG, "status") == (0, 255, 0)
def test_colour_resolves_through_an_alias(self, log):
"""The style block says status_text where this config says status."""
assert C.element_color(self.CONFIG, "status_text") == (0, 255, 0)
def test_a_mode_overrides_the_colour(self, log):
assert C.element_color(self.CONFIG, "score_text",
mode="live") == (255, 0, 0)
def test_an_unset_element_gets_the_default(self, log):
assert C.element_color(self.CONFIG, "nothing", (1, 2, 3)) == (1, 2, 3)
@pytest.mark.parametrize("config", [
None, {}, "nonsense", {"customization": "nonsense"},
{"customization": {"score_text": "nonsense"}},
{"customization": {"score_text": {"text_color": "not a colour"}}},
])
def test_a_hostile_config_gives_the_default(self, config, log):
assert C.element_color(config, "score_text", (9, 9, 9)) == (9, 9, 9)
+45
View File
@@ -256,3 +256,48 @@ class TestContract:
h._draw_upcoming_center(_draw(), {})
h._draw_upcoming_game_status(_draw(), {})
h.set_rankings_cache({})
class TestLayoutOffsetsAreShared:
"""The scroll/Vegas card reads offsets through the same function the
full-screen scorebug does.
This renderer already carries a comment about having ignored offsets its
own schema advertised -- it was the third copy of this read. Sharing one
means the alias handling and per-mode overrides land here too, without
this file knowing about either.
"""
CONFIG = {"customization": {
"layout": {"score": {"y_offset": -3}},
"modes": {"live": {"layout": {"score": {"y_offset": 7}}}},
}}
def _host(self, config, mode=None):
attrs = {"config": config}
if mode:
attrs["SKIN_MODE"] = mode
return type("Card", (SportsGameRendererMixin,), attrs)()
def test_it_reads_the_configured_offset(self):
assert self._host(self.CONFIG)._layout_offset("score", "y_offset") == -3
def test_it_resolves_through_an_alias(self):
"""A caller asking for score_text finds the layout.score the user
configured -- the two namespaces disagree by convention."""
assert self._host(self.CONFIG)._layout_offset(
"score_text", "y_offset") == -3
def test_skin_mode_selects_the_per_mode_offset(self):
assert self._host(self.CONFIG, "live")._layout_offset(
"score", "y_offset") == 7
def test_a_host_without_a_skin_mode_gets_the_base(self):
assert self._host(self.CONFIG)._layout_offset("score", "y_offset") == -3
@pytest.mark.parametrize("written", [
float("inf"), float("nan"), True, "bad", None, [], {},
])
def test_nonsense_degrades_to_the_default(self, written):
config = {"customization": {"layout": {"score": {"y_offset": written}}}}
assert self._host(config)._layout_offset("score", "y_offset", 4) == 4
+88
View File
@@ -374,3 +374,91 @@ class TestPluginDirIsToldNotDeduced:
assert host._schema_font_size("score") == 16, (
"without the schema this is None, which is what made a configured "
"size look user-chosen and skipped the grid snap")
class TestUnshareKeepsTheLayoutEngine:
"""``_unshare_element_fonts`` rebuilds a face; the rebuild must be pinned.
This body did move verbatim from the plugins, but it is the one that
rebuilds a font, and it rebuilt through bare ``ImageFont.truetype`` --
taking PIL's default layout engine rather than the one
``src.common.font_layout`` pins. Raqm and Basic disagree on fractional
advances, so a re-instantiated face could measure differently from the
shared face it replaced, on any machine where Raqm is installed. That is
invisible on a Raqm-less runner, which is why this asserts on the loader
rather than on the resulting engine value.
"""
def _host(self):
import logging
return type("H", (SportsCoreSharedMixin,), {
"logger": logging.getLogger("test_sports_shared")})()
def _face(self, size=10):
from src.common.font_layout import load_truetype
return load_truetype(
os.path.join("assets", "fonts", "PressStart2P-Regular.ttf"), size)
def test_rebuild_goes_through_the_pinned_loader(self, monkeypatch):
import src.common.font_layout as fl
# Built before patching: _face() uses the same loader, so patching
# first would let the fixture's own call satisfy the assertion.
shared = self._face()
calls = []
real = fl.load_truetype
def spy(font, size, **kwargs):
calls.append((font, size))
return real(font, size, **kwargs)
monkeypatch.setattr(fl, "load_truetype", spy)
fonts = {"score": shared, "time": shared}
self._host()._unshare_element_fonts(fonts)
assert fonts["time"] is not shared, "the duplicate should have been rebuilt"
assert calls, "the rebuild must go through the pinned loader"
def test_rebuilt_face_measures_like_the_one_it_replaced(self):
shared = self._face()
fonts = {"score": shared, "time": shared}
self._host()._unshare_element_fonts(fonts)
assert fonts["time"].getlength("88-88") == shared.getlength("88-88")
class TestPromotedLayoutOffset:
"""_get_layout_offset moved here so every scoreboard reads offsets the
way the scroll card does.
Each plugin still carries its own copy in its bundled sports.py, which
wins by MRO. That is the migration property: adopting this is a
deletion in the plugin, and until that deletion nothing changes.
"""
CONFIG = {"customization": {
"layout": {"score": {"y_offset": -3}},
"modes": {"recent": {"layout": {"score": {"y_offset": 9}}}},
}}
def _host(self, **attrs):
return type("H", (SportsCoreSharedMixin,),
dict({"config": self.CONFIG}, **attrs))()
def test_it_reads_the_configured_offset(self):
assert self._host()._get_layout_offset("score", "y_offset") == -3
def test_it_resolves_through_an_alias(self):
"""What a plugin gains by deleting its own copy: the style block
says score_text where the layout block says score."""
assert self._host()._get_layout_offset("score_text", "y_offset") == -3
def test_skin_mode_selects_the_per_mode_offset(self):
assert self._host(SKIN_MODE="recent")._get_layout_offset(
"score", "y_offset") == 9
def test_a_plugins_own_copy_still_wins(self):
"""Until a plugin deletes its copy, this changes nothing for it."""
host = self._host(
_get_layout_offset=lambda self, element, axis, default=0: 99)
assert host._get_layout_offset("score", "y_offset") == 99
def test_an_unset_offset_is_the_default(self):
assert self._host()._get_layout_offset("nothing", "y_offset", 5) == 5
+15 -22
View File
@@ -814,28 +814,21 @@ class TestFontsAPI:
data = json.loads(response.data)
assert 'tokens' in data.get('data', {}) or 'data' in data
def test_get_fonts_overrides(self, client):
"""Test getting font overrides."""
response = client.get('/api/v3/fonts/overrides')
assert response.status_code == 200
data = json.loads(response.data)
assert 'overrides' in data.get('data', {}) or 'data' in data
def test_save_fonts_overrides(self, client):
"""Test saving font overrides."""
request_data = {
'weather': 'small',
'clock': 'regular'
}
response = client.post(
'/api/v3/fonts/overrides',
data=json.dumps(request_data),
content_type='application/json'
)
assert response.status_code == 200
def test_the_font_override_endpoints_are_gone(self, client):
"""They reported success without doing anything: GET returned a
hardcoded {}, POST and DELETE saved and deleted nothing. The panel
they served offered eleven element keys -- nfl.live.score,
clock.time -- that no plugin has ever read, so wiring them to the
real FontManager methods would still have changed nothing on the
panel. Per-element font choice lives in each plugin's own config
editor now, against the elements that plugin actually has."""
# 405, not 404: the path still matches DELETE /fonts/<font_family>,
# which now reads "overrides" as a font name. Nothing is routed to a
# handler for GET or POST, which is what matters here.
assert client.get('/api/v3/fonts/overrides').status_code == 405
assert client.post('/api/v3/fonts/overrides',
data=json.dumps({}),
content_type='application/json').status_code == 405
class TestAPIErrorHandling:
+12
View File
@@ -164,6 +164,18 @@ def test_static_asset_served(client, asset):
assert len(resp.data) > 0
def test_widget_bundle_is_served_and_requested(client):
"""base.html loads every widget through one versioned bundle request."""
page = client.get("/").get_data(as_text=True)
assert "/assets/widgets.js?v=" in page
resp = client.get("/assets/widgets.js")
assert resp.status_code == 200
assert resp.mimetype == "application/javascript"
body = resp.get_data(as_text=True)
assert body.index("/* registry.js */") < body.index("/* notification.js */")
assert "/* json-file-manager.js */" in body
def test_durations_page_groups_by_plugin(client):
"""One duration input per display mode of each enabled plugin, plus the
leftover group for saved keys no enabled plugin owns."""
+81 -4
View File
@@ -37,11 +37,11 @@ def test_every_used_breakpoint_class_is_defined():
class_attr = re.compile(r'class="([^"]*)"')
bp_class = re.compile(r"\b(%s):[A-Za-z0-9_.-]+" % "|".join(BP_PREFIXES))
for path in _template_files():
for attr in class_attr.findall(path.read_text()):
for attr in class_attr.findall(path.read_text(encoding="utf-8")):
for m in bp_class.finditer(attr):
used.add(m.group(0))
css = APP_CSS.read_text()
css = APP_CSS.read_text(encoding="utf-8")
defined = {
m.group(0).lstrip(".").replace("\\:", ":")
for m in re.finditer(
@@ -56,13 +56,90 @@ def test_every_used_breakpoint_class_is_defined():
)
_VARIANTS = (
r"(?:(?:sm|md|lg|xl|2xl|hover|focus|focus-visible|active|disabled|first|last"
r"|group-hover|peer-checked|peer-focus|placeholder|file):)*"
)
_UTILITY = re.compile(
"^" + _VARIANTS + r"-?(?:"
r"(?:block|inline|inline-block|inline-flex|flex|grid|hidden|table|contents"
r"|static|fixed|absolute|relative|sticky|truncate|sr-only|transform"
r"|uppercase|lowercase|capitalize|italic|underline|line-through|border|shadow"
r"|rounded|transition|grow|shrink|group|peer)"
r"|(?:m|p)[trblxy]?-[\w./]+"
r"|(?:w|h|min-w|min-h|max-w|max-h|gap|gap-x|gap-y|space-x|space-y|top|right"
r"|bottom|left|inset|inset-x|inset-y|z|opacity|duration|scale|rotate"
r"|translate-x|translate-y|grid-cols|col-span|leading|tracking)-[\w./\[\]]+"
r"|(?:text|bg|border|divide|ring|placeholder|accent)-(?:[a-z]+-\d{2,3}(?:/\d+)?"
r"|white|black|transparent|xs|sm|base|md|lg|xl|[2-5]xl|left|center|right"
r"|opacity-\d+|[trblxy](?:-\d)?|\d|dashed|dotted|offset-\d)"
r"|font-(?:mono|sans|serif|thin|light|normal|medium|semibold|bold|extrabold)"
r"|select-(?:none|all|text|auto)"
r"|(?:rounded|shadow|items|justify|self|flex|whitespace|break|object|list"
r"|overflow|overflow-x|overflow-y|cursor|pointer-events|appearance"
r"|ease|divide|line-clamp)-[a-z0-9]+(?:-[a-z0-9]+)?(?:\[[^\]]+\])?"
r")$"
)
def _css_light_classes(css):
"""Classes that get a rule outside [data-theme="dark"]."""
css = re.sub(r"/\*.*?\*/", "", css, flags=re.S)
defined = set()
for block in re.finditer(r"([^{}]+)\{", css):
for sel in block.group(1).split(","):
sel = sel.strip()
if sel.startswith("@") or sel.startswith('[data-theme="dark"]'):
continue
for m in re.finditer(r"\.((?:\\.|[\w-])+)", sel):
defined.add(m.group(1).replace("\\", ""))
return defined
def test_every_used_utility_class_is_defined():
"""app.css is the whole stylesheet (no Tailwind build), so a utility class
it doesn't define silently does nothing. `.hidden` was missing for years,
which broke every JS show/hide toggle. Scans templates and static JS."""
attr = re.compile(r"""(?:class|className)\s*[=:]\s*(["'`])(.*?)\1""", re.S)
class_list = re.compile(r"classList\.(?:add|remove|toggle)\(([^)]*)\)")
template_expr = re.compile(r"\{\{.*?\}\}|\{%.*?%\}|\$\{[^}]*\}", re.S)
files = list(_template_files()) + [
p for p in (STATIC / "v3").rglob("*.js")
if "vendor" not in p.parts and not p.name.endswith(".min.js")
]
used = {}
for path in files:
text = template_expr.sub(" ", path.read_text(encoding="utf-8"))
chunks = [m.group(2) for m in attr.finditer(text)]
chunks += re.findall(r"""['"]([^'"]*)['"]""", " ".join(
m.group(1) for m in class_list.finditer(text)))
# :class="{ 'hidden': open }" and ternaries hold class names inside
# string literals; read those as well as the raw chunk.
bound = re.compile(r""":class\s*=\s*"([^"]*)""")
for m in bound.finditer(text):
chunks.append(m.group(1))
for chunk in chunks:
candidates = [chunk] + re.findall(r"""['`]([^'`]*)['`]""", chunk)
for candidate in candidates:
for token in candidate.split():
if _UTILITY.match(token):
used.setdefault(token, path.relative_to(PROJECT_ROOT))
defined = _css_light_classes(APP_CSS.read_text(encoding="utf-8"))
missing = sorted(f"{cls} ({used[cls]})" for cls in used if cls not in defined)
assert not missing, (
"Utility classes used in templates/JS but not defined in app.css "
f"(they silently no-op): {missing}"
)
def test_every_static_url_for_points_to_a_real_file():
ref = re.compile(
r"url_for\(\s*['\"]static['\"]\s*,\s*filename\s*=\s*['\"]([^'\"]+)['\"]"
)
missing = []
for path in _template_files():
for filename in ref.findall(path.read_text()):
for filename in ref.findall(path.read_text(encoding="utf-8")):
if not (STATIC / filename).is_file():
missing.append(f"{path.relative_to(PROJECT_ROOT)}: {filename}")
assert not missing, f"Templates reference missing static assets: {missing}"
@@ -73,7 +150,7 @@ def test_js_files_calling_debuglog_declare_the_global():
for path in sorted((STATIC / "v3").rglob("*.js")):
if "vendor" in path.parts:
continue
text = path.read_text()
text = path.read_text(encoding="utf-8")
# Calls debugLog( but neither defines it nor declares the global
calls = re.search(r"(?<![.\w])debugLog\(", text)
defines = "window.debugLog" in text
+131
View File
@@ -0,0 +1,131 @@
"""The two installers that write /etc/sudoers.d/ledmatrix_web must agree.
first_time_install.sh (Step 10, a heredoc) and scripts/install/configure_web_sudo.sh
(a block of echo lines) each generate the web user's sudo allow-list. They
drifted: configure_web_sudo.sh granted scripts/fix_perms/safe_pip_install.sh
but first_time_install.sh did not, so on a device set up only by the first-time
installer permission_utils.install_requirements_file could not use the root
wrapper and fell back to a user-level install that root-run ledmatrix.service
may not see (and the auto-update rollback reported its reinstall as failed).
This compares the granted command sets after normalising the spellings that
differ between the files but expand identically at install time:
$WEB_USER/$ACTUAL_USER, $PROJECT_ROOT/$PROJECT_ROOT_DIR, and the helper-path
variables configure_web_sudo.sh defines ($SAFE_RM_PATH, ...).
It also checks that every fix_perms helper granted via sudo is hardened to
root:root in both scripts -- and, in first_time_install.sh, after Step 11's
project-wide chown to the user, which would otherwise undo it.
"""
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
FIRST_TIME = ROOT / "first_time_install.sh"
CONFIGURE = ROOT / "scripts" / "install" / "configure_web_sudo.sh"
#: Grants that intentionally exist in only one installer, as normalised
#: commands. There are none today; add one here with a reason rather than
#: loosening the comparison.
ONLY_IN_FIRST_TIME = frozenset()
ONLY_IN_CONFIGURE = frozenset()
def _text(path):
return path.read_text(encoding="utf-8", errors="replace")
def _web_sudoers_section(path):
"""The part of the script that writes the ledmatrix_web allow-list.
first_time_install.sh also writes other files later (WiFi permissions are
delegated to a separate script, but keep this robust against future
additions), so restrict it to Step 10.
"""
text = _text(path)
if path == FIRST_TIME:
start = text.index('CURRENT_STEP="Configure passwordless sudo access"')
end = text.index('CURRENT_STEP="Configure WiFi management permissions"')
return text[start:end]
return text
def _variables(text):
"""Simple NAME="..." assignments, so $SAFE_RM_PATH can be expanded."""
return {m.group(1): m.group(2)
for m in re.finditer(r'^\s*([A-Z_]+)="([^"$]*\$[^"]*)"\s*$', text, re.M)}
def _normalise(command, variables):
for _ in range(3): # helper paths reference $PROJECT_ROOT
command = re.sub(r"\$\{?([A-Z][A-Z0-9_]*)\}?",
lambda m: variables.get(m.group(1), m.group(0)), command)
command = command.replace("$PROJECT_ROOT_DIR", "$PROJECT_ROOT")
return " ".join(command.split())
def _grants(path):
"""{(tags, command)} for every ledmatrix_web rule the script writes."""
section = _web_sudoers_section(path)
variables = _variables(_text(path))
grants = set()
for line in section.splitlines():
m = re.search(r'\$(?:WEB_USER|ACTUAL_USER) ALL=\(ALL\) (NOPASSWD:(?:NOEXEC:)?)\s*(.*)$',
line)
if not m:
continue
command = m.group(2).rstrip().rstrip('"').rstrip()
grants.add((m.group(1), _normalise(command, variables)))
return grants
def test_both_installers_generate_rules():
# Guards against the parser silently matching nothing in either file.
assert len(_grants(FIRST_TIME)) >= 15
assert len(_grants(CONFIGURE)) >= 15
def test_installers_grant_the_same_commands():
first = _grants(FIRST_TIME)
configure = _grants(CONFIGURE)
only_first = {c for c in first - configure if c[1] not in ONLY_IN_FIRST_TIME}
only_configure = {c for c in configure - first if c[1] not in ONLY_IN_CONFIGURE}
assert not only_first and not only_configure, (
"ledmatrix_web sudoers drift between installers:\n"
f" only in first_time_install.sh: {sorted(only_first)}\n"
f" only in configure_web_sudo.sh: {sorted(only_configure)}")
def test_pip_install_helper_is_granted():
wanted = ("NOPASSWD:", "$BASH_PATH $PROJECT_ROOT/scripts/fix_perms/safe_pip_install.sh *")
assert wanted in _grants(FIRST_TIME)
assert wanted in _grants(CONFIGURE)
def _granted_helpers():
helpers = set()
for _, command in _grants(FIRST_TIME) | _grants(CONFIGURE):
m = re.search(r"scripts/fix_perms/([\w.-]+\.sh)", command)
if m:
helpers.add(m.group(1))
return helpers
def test_every_granted_helper_is_hardened_in_configure_web_sudo():
text = _text(CONFIGURE)
variables = _variables(text)
hardened = {Path(_normalise(m.group(2), variables)).name
for m in re.finditer(r"sudo (chown root:root|chmod 755) \"?([^\"\s]+)", text)
if m.group(1).startswith("chown")}
assert _granted_helpers() <= hardened, _granted_helpers() - hardened
def test_every_granted_helper_is_hardened_in_first_time_install_after_chown():
text = _text(FIRST_TIME)
project_chown = text.index('-exec chown -h "$ACTUAL_USER:$ACTUAL_USER"')
loop = re.search(r"for helper in ([^;]+); do\n(.*?)\ndone", text, re.S)
assert loop, "no helper-hardening loop in first_time_install.sh"
assert "chown root:root" in loop.group(2) and "chmod 755" in loop.group(2)
assert loop.start() > project_chown, (
"helper hardening runs before Step 11's project-wide chown, which undoes it")
assert _granted_helpers() <= set(loop.group(1).split())
+73 -40
View File
@@ -1,66 +1,99 @@
"""Guard: every widget JS file must be loaded by base.html or explicitly allowlisted.
"""Guard: every widget JS file must ship in the widget bundle or be allowlisted.
Widget files register themselves with LEDMatrixWidgets at load time; a file
that exists but is never <script>-included silently breaks any plugin whose
config schema declares that widget (the field renders as an empty container
that polls the registry forever). base.html's widget list is maintained by
hand, so this test keeps it honest.
that exists but is never loaded silently breaks any plugin whose config schema
declares that widget (the field renders as an empty container that polls the
registry forever).
base.html loads them as one concatenated request (web_interface/widget_bundle.py),
so BUNDLE_ORDER is the hand-maintained list this test keeps honest. It also
checks that base.html actually requests the bundle, and that the bundle's
concatenation order puts the registry and base class first.
"""
import re
import sys
from pathlib import Path
from typing import Set
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
from web_interface import widget_bundle # noqa: E402
WIDGETS_DIR = PROJECT_ROOT / 'web_interface' / 'static' / 'v3' / 'js' / 'widgets'
BASE_HTML = PROJECT_ROOT / 'web_interface' / 'templates' / 'v3' / 'base.html'
# Matches url_for('static', filename='...') inside actual <script> tags.
SCRIPT_SRC_RE = re.compile(
r"""<script\s[^>]*src="\{\{\s*url_for\(\s*'static'\s*,\s*filename='([^']+)'\s*\)\s*\}\}[^"]*"""
)
# Files that must NOT be script-included, with the reason.
ALLOWLIST = {
# Documentation example (docs/widget-guide.md); registers the name
# 'color-picker' and would shadow the real color-picker.js if loaded.
'example-color-picker.js',
}
# Files that must NOT be loaded, with the reason.
ALLOWLIST = set(widget_bundle.EXCLUDED)
def _included_widget_scripts() -> Set[str]:
"""Return widget JS basenames referenced by real <script> tags in base.html."""
base_html = BASE_HTML.read_text(encoding='utf-8')
return {
Path(filename).name
for filename in SCRIPT_SRC_RE.findall(base_html)
if filename.startswith('v3/js/widgets/')
}
def _bundled_names() -> Set[str]:
return set(widget_bundle.BUNDLE_ORDER)
def test_every_widget_script_is_included_in_base_html() -> None:
"""Every non-allowlisted widget file must be loaded by a <script> tag."""
def test_every_widget_script_is_bundled() -> None:
"""Every non-allowlisted widget file must be in BUNDLE_ORDER."""
assert WIDGETS_DIR.is_dir(), f'Widget directory missing: {WIDGETS_DIR}'
included = _included_widget_scripts()
assert included, 'No widget <script> tags found in base.html — regex or template drift?'
bundled = _bundled_names()
assert bundled, 'BUNDLE_ORDER is empty — widget_bundle drift?'
missing = [
js_file.name
for js_file in sorted(WIDGETS_DIR.glob('*.js'))
if js_file.name not in ALLOWLIST and js_file.name not in included
if js_file.name not in ALLOWLIST and js_file.name not in bundled
]
assert not missing, (
'Widget files exist but are never <script>-included in base.html '
'(plugins declaring these widgets get blank config fields): '
+ ', '.join(missing)
+ '. Add a script tag to base.html or add the file to ALLOWLIST '
'with a reason.'
'Widget files exist but are never loaded (plugins declaring these '
'widgets get blank config fields): ' + ', '.join(missing)
+ '. Add the file to BUNDLE_ORDER in web_interface/widget_bundle.py, '
'or to EXCLUDED with a reason.'
)
def test_allowlisted_widgets_are_not_included() -> None:
"""Allowlisted (must-not-load) widget files must stay out of base.html."""
included = _included_widget_scripts()
wrongly_included = [name for name in ALLOWLIST if name in included]
def test_allowlisted_widgets_are_not_bundled() -> None:
"""Allowlisted (must-not-load) widget files stay out of the bundle."""
wrongly_included = sorted(ALLOWLIST & _bundled_names())
assert not wrongly_included, (
'Allowlisted (must-not-load) widget files are script-included in '
'base.html: ' + ', '.join(wrongly_included)
'Allowlisted (must-not-load) widget files are in the bundle: '
+ ', '.join(wrongly_included)
)
def test_bundle_order_lists_only_existing_files() -> None:
"""A renamed or deleted widget must not linger in BUNDLE_ORDER."""
stale = [name for name in widget_bundle.BUNDLE_ORDER
if not (WIDGETS_DIR / name).is_file()]
assert not stale, f'BUNDLE_ORDER names files that do not exist: {stale}'
def test_registry_and_base_widget_load_first() -> None:
"""Widgets call into the registry and base class as they load."""
order = widget_bundle.BUNDLE_ORDER
assert order[0] == 'registry.js', order[:3]
assert order[1] == 'base-widget.js', order[:3]
def test_base_html_requests_the_bundle() -> None:
"""base.html must load the bundle (and no longer tag widgets one by one)."""
base_html = BASE_HTML.read_text(encoding='utf-8')
assert 'widgets_bundle_url()' in base_html, (
'base.html does not request the widget bundle'
)
per_file = re.findall(
r"""<script\s[^>]*src="\{\{\s*url_for\(\s*'static'\s*,\s*"""
r"""filename='(v3/js/widgets/[^']+)'""",
base_html,
)
assert not per_file, (
'base.html still loads widget files individually alongside the '
f'bundle (they would run twice): {per_file}'
)
def test_bundle_concatenates_every_file() -> None:
"""The built bundle contains each file, separated so sources can't merge."""
body, version = widget_bundle.build_bundle()
assert version and version == widget_bundle.bundle_version()
for name in widget_bundle.BUNDLE_ORDER:
assert f'/* {name} */' in body, f'{name} missing from the built bundle'
for name in ALLOWLIST:
assert f'/* {name} */' not in body, f'{name} must not be bundled'
+51
View File
@@ -40,6 +40,57 @@ def _wire(wm, connected, ethernet, ap_active):
wm._save_cached_scan = MagicMock()
wm._FORCE_AP_FLAG_PATH = MagicMock()
wm._FORCE_AP_FLAG_PATH.exists.return_value = False
wm._connect_in_progress = MagicMock(return_value=False)
class TestConnectInProgressHoldsOffTheDaemon:
"""Connecting from the setup AP takes the AP down before joining. The
daemon had already counted the whole AP session as "disconnected", so its
next tick re-enabled the AP in the middle of the connect."""
def test_no_ap_enable_while_a_connect_is_running(self, wm):
_wire(wm, connected=False, ethernet=False, ap_active=False)
wm._connect_in_progress.return_value = True
wm._disconnected_checks = 10 # accumulated while the AP was up
changed, *_ = wm.check_and_manage_ap_mode_with_state()
assert changed is False
wm.enable_ap_mode.assert_not_called()
assert wm._disconnected_checks == 0
def test_grace_period_restarts_after_the_connect(self, wm):
_wire(wm, connected=False, ethernet=False, ap_active=False)
wm._connect_in_progress.return_value = True
wm._disconnected_checks = 10
wm.check_and_manage_ap_mode_with_state()
wm._connect_in_progress.return_value = False
wm.check_and_manage_ap_mode_with_state()
wm.enable_ap_mode.assert_not_called()
def test_flag_is_held_for_the_connect_and_removed_after(self, wm, tmp_path):
flag = tmp_path / "connecting"
wm._CONNECT_IN_PROGRESS_FLAG_PATH = flag
seen = []
wm._connect_validated = lambda ssid, pw: seen.append(flag.exists()) or (True, "ok")
assert wm.connect_to_network("HomeNet", "hunter22") == (True, "ok")
assert seen == [True]
assert not flag.exists()
def test_flag_is_removed_when_the_connect_raises(self, wm, tmp_path):
flag = tmp_path / "connecting"
wm._CONNECT_IN_PROGRESS_FLAG_PATH = flag
wm._connect_validated = MagicMock(side_effect=RuntimeError("nmcli gone"))
with pytest.raises(RuntimeError):
wm.connect_to_network("HomeNet", "hunter22")
assert not flag.exists()
def test_a_stale_flag_from_a_dead_process_is_ignored(self, wm, tmp_path):
flag = tmp_path / "connecting"
flag.touch()
wm._CONNECT_IN_PROGRESS_FLAG_PATH = flag
assert WiFiManager._connect_in_progress(wm) is True
old = flag.stat().st_mtime - WiFiManager._CONNECT_FLAG_MAX_AGE_SECONDS - 1
os.utime(flag, (old, old))
assert WiFiManager._connect_in_progress(wm) is False
class TestWithState:
@@ -134,6 +134,7 @@ class TestConnectEndpointSurfacesTheRefusal:
def wifi_manager(self):
with patch("src.wifi_manager.WiFiManager") as cls:
instance = MagicMock()
instance._is_ap_mode_active.return_value = False
cls.return_value = instance
yield instance
@@ -83,19 +83,24 @@ class TestConfigFlowsIntegration(unittest.TestCase):
def test_backup_rotation(self):
"""Test that backup rotation works correctly."""
max_backups = 3
# Create multiple backups
for i in range(5):
# setUp built the manager with max_backups=5; ask it rather than
# restating the number, which is how this drifted in the first place.
max_backups = self.atomic_manager.max_backups
# Overshoot the limit so rotation actually has something to remove.
for i in range(max_backups + 3):
config = {"test": f"value_{i}"}
result = self.atomic_manager.save_config_atomic(config, create_backup=True)
self.assertEqual(result.status, SaveResultStatus.SUCCESS)
# List backups
# Rotation should have trimmed the excess, and kept exactly the limit.
#
# This used to assert against a hardcoded 3 while setUp configured 5,
# and still passed -- because every save in the loop landed in the same
# second and collapsed onto a single backup filename, so there was only
# ever one backup to count and rotation was never exercised at all.
backups = self.atomic_manager.list_backups()
# Verify only max_backups are kept
self.assertLessEqual(len(backups), max_backups)
self.assertEqual(max_backups, len(backups))
def test_validation_failure_triggers_rollback(self):
"""Test that validation failure triggers automatic rollback."""
@@ -148,10 +153,21 @@ class TestConfigFlowsIntegration(unittest.TestCase):
rollback_success = self.atomic_manager.rollback_config(backup_version=None)
self.assertTrue(rollback_success)
# Verify rollback
# Verify rollback.
#
# result1's backup was taken *before* the first save, so it holds the
# config setUp wrote -- plugin1=30, plugin2=15 -- and neither change.
#
# This used to assert plugin1 == 45, a state no single backup ever held:
# 45 was only ever in result2's backup, and 15 only in result1's. It
# passed because both saves landed in the same second, so both backups
# were written to one filename and "result1's version" resolved to
# result2's content. Once the backup id became unique the rollback
# started returning the version actually asked for, and the assertion
# -- not the rollback -- was what had to change.
rolled_back_config = self.config_manager.load_config()
self.assertEqual(rolled_back_config["plugin1"]["display_duration"], 45)
self.assertEqual(rolled_back_config["plugin2"]["display_duration"], 15) # Original value
self.assertEqual(rolled_back_config["plugin1"]["display_duration"], 30)
self.assertEqual(rolled_back_config["plugin2"]["display_duration"], 15)
if __name__ == '__main__':
+12 -2
View File
@@ -25,6 +25,7 @@ from web_interface.blueprints.api_v3 import ( # noqa: E402
_parse_form_value,
_get_schema_property,
_set_nested_value,
_SKIP_FIELD,
)
@@ -221,12 +222,21 @@ class TestSetNestedValue:
_set_nested_value(config, "fifa.world.enabled", True)
assert config == {"fifa.world": {"enabled": True}}
def test_none_does_not_overwrite_existing(self):
def test_none_overwrites_existing(self):
# Regression: None is a real value here (the per-mode "inherit the
# base" override for a nullable field, or a blank indexed color
# channel) and must replace whatever was already stored. Only the
# _SKIP_FIELD sentinel means "leave it alone" -- see the next test.
config = {"a": 1}
_set_nested_value(config, "a", None)
assert config == {"a": 1}
assert config == {"a": None}
def test_none_sets_missing_key(self):
config = {}
_set_nested_value(config, "a", None)
assert config == {"a": None}
def test_skip_field_does_not_overwrite_existing(self):
config = {"a": 1}
_set_nested_value(config, "a", _SKIP_FIELD)
assert config == {"a": 1}
@@ -0,0 +1,201 @@
"""An array of objects with no ``x-widget`` must survive a save of its own form.
Geochron's ``cities`` is a list of ``{name, lat, lon, timezone}`` objects and
names no widget. The template sent that to the comma-separated text input,
which renders each city as a Python dict repr joined by commas. Saving the
untouched form posted those back as a list of strings, the schema rejected
them, and *every* save of the plugin failed with 400 "Configuration
validation failed" -- no setting on the page could be changed.
So the test renders the real form, submits what a browser would, and requires
the save to succeed with the cities intact.
"""
import json
import sys
from html.parser import HTMLParser
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from flask import Flask
from werkzeug.datastructures import MultiDict
PROJECT_ROOT = Path(__file__).parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
CITIES = [
{"name": "New York", "lat": 40.71, "lon": -74.01, "timezone": "America/New_York"},
{"name": "Tokyo", "lat": 35.68, "lon": 139.65, "timezone": "Asia/Tokyo"},
]
SCHEMA = {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": False},
"timezone": {"type": ["string", "null"], "default": None},
"show_cities": {"type": "boolean", "default": True},
"cities": {
"type": "array",
"maxItems": 8,
"items": {
"type": "object",
"properties": {
"name": {"type": "string", "maxLength": 20},
"lat": {"type": "number", "minimum": -90, "maximum": 90},
"lon": {"type": "number", "minimum": -180, "maximum": 180},
"timezone": {"type": "string"},
},
"required": ["name", "lat", "lon"],
"additionalProperties": False,
},
"default": CITIES,
},
},
"required": ["enabled"],
"additionalProperties": False,
}
STORED = {"enabled": True, "timezone": None, "show_cities": True,
"cities": CITIES}
class _FormFields(HTMLParser):
"""Collect what a browser would submit from the rendered form."""
def __init__(self):
super().__init__()
self.pairs = []
self._select = None
self._select_first = None
self._select_chosen = None
def handle_starttag(self, tag, attrs):
a = dict(attrs)
name = a.get("name")
if tag == "input" and name and "disabled" not in a:
if a.get("type") in ("checkbox", "radio") and "checked" not in a:
return
if a.get("type") in ("button", "submit", "file"):
return
self.pairs.append((name, a.get("value", "")))
elif tag == "select" and name:
self._select, self._select_first, self._select_chosen = name, None, None
elif tag == "option" and self._select:
if self._select_first is None:
self._select_first = a.get("value", "")
if "selected" in a:
self._select_chosen = a.get("value", "")
def handle_endtag(self, tag):
if tag == "select" and self._select:
chosen = self._select_chosen if self._select_chosen is not None else self._select_first
self.pairs.append((self._select, chosen or ""))
self._select = None
def _render_form():
from jinja2 import Environment, FileSystemLoader, select_autoescape
env = Environment(
loader=FileSystemLoader(str(PROJECT_ROOT / "web_interface" / "templates")),
autoescape=select_autoescape(["html"]),
)
plugin = {"id": "demo", "name": "Demo", "description": "", "enabled": True,
"author": "me", "version": "1.0.0"}
return env.get_template("v3/partials/plugin_config.html").render(
plugin=plugin, schema=SCHEMA, config=json.loads(json.dumps(STORED)))
@pytest.fixture
def post(tmp_path):
from src.plugin_system.schema_manager import SchemaManager
from web_interface.blueprints import api_v3 as a
originals = {k: getattr(a.api_v3, k, None)
for k in ("config_manager", "schema_manager", "plugin_manager")}
pdir = tmp_path / "plugins" / "demo"
pdir.mkdir(parents=True)
(pdir / "config_schema.json").write_text(json.dumps(SCHEMA), encoding="utf-8")
store = {"demo": json.loads(json.dumps(STORED))}
cm = MagicMock()
cm.load_config.side_effect = lambda: json.loads(json.dumps(store))
cm.get_raw_file_content.return_value = {}
cm.get_config_path.return_value = str(tmp_path / "config.json")
def _save(cfg, **_kw):
store.clear()
store.update(cfg)
return type("R", (), {"status": type("S", (), {"value": "success"})(),
"message": None})()
cm.save_config_atomic.side_effect = _save
a.api_v3.config_manager = cm
a.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path / "plugins",
project_root=tmp_path)
pm = MagicMock()
pm.plugins = {}
pm.get_plugin.return_value = None
a.api_v3.plugin_manager = pm
app = Flask(__name__)
app.config["TESTING"] = True
app.register_blueprint(a.api_v3, url_prefix="/api/v3")
client = app.test_client()
def _post(pairs):
resp = client.post("/api/v3/plugins/config?plugin_id=demo",
data=MultiDict(pairs))
return resp, store.get("demo", {})
try:
yield _post
finally:
for k, v in originals.items():
setattr(a.api_v3, k, v)
def test_the_form_does_not_flatten_objects_into_text():
html = _render_form()
assert "{&#39;name&#39;" not in html and "{'name'" not in html, (
"cities rendered as a dict repr in a comma-separated text box")
def test_saving_the_untouched_form_succeeds(post):
parser = _FormFields()
parser.feed(_render_form())
resp, cfg = post(parser.pairs)
assert resp.status_code == 200, resp.get_json()
assert cfg["cities"] == CITIES
def test_an_item_without_its_optional_object_does_not_gain_an_empty_one(post):
"""News: a custom feed with no logo. The unchecked-checkbox pass walked
into ``logo`` to look for booleans and left ``logo: {}`` behind, which
fails the logo's ``required: [id, path]`` -- 400 on every save."""
from web_interface.blueprints.api_v3 import _set_missing_booleans_to_false
props = {"feeds": {"type": "object", "properties": {"custom_feeds": {
"type": "array",
"items": {"type": "object", "properties": {
"name": {"type": "string"},
"enabled": {"type": "boolean"},
"logo": {"type": "object", "required": ["id", "path"],
"properties": {"id": {"type": "string"},
"path": {"type": "string"}}},
"extra": {"type": "object",
"properties": {"flag": {"type": "boolean"}}},
}},
}}}}
cfg = {"feeds": {"custom_feeds": [{"name": "Verge", "enabled": True}]}}
_set_missing_booleans_to_false(
cfg, props, {"feeds.custom_feeds.0.name"}, sections={"feeds"})
item = cfg["feeds"]["custom_feeds"][0]
assert "logo" not in item
# A nested object that really does hold an unchecked box still gets it.
assert item["extra"] == {"flag": False}
assert item["enabled"] is False
@@ -99,9 +99,15 @@ class TestTheRoutesExistAtAll:
assert "'google-oauth'" in allow_list_line[0], allow_list_line[0]
def test_the_widget_script_is_served(self):
# Widgets load through one bundle (web_interface/widget_bundle.py), so
# the page must request the bundle and the bundle must carry the file.
from web_interface import widget_bundle
base = (Path(project_root) / 'web_interface/templates/v3/base.html'
).read_text(encoding='utf-8')
assert 'widgets/google-oauth.js' in base
assert 'widgets_bundle_url()' in base
assert 'google-oauth.js' in widget_bundle.BUNDLE_ORDER
body, _version = widget_bundle.build_bundle()
assert '/* google-oauth.js */' in body
def test_the_status_line_is_announced(self):
# Every message the widget gives arrives after an async call, so a
@@ -6,8 +6,12 @@ import unittest
import tempfile
import shutil
import json
import threading
from datetime import datetime, timedelta
from pathlib import Path
from unittest.mock import patch
import src.config_manager_atomic as atomic_module
from src.config_manager_atomic import AtomicConfigManager, SaveResultStatus
@@ -103,6 +107,217 @@ class TestAtomicConfigManager(unittest.TestCase):
self.assertEqual(result.status, SaveResultStatus.SUCCESS)
class TestBackupVersionsAreUnique(unittest.TestCase):
"""
A backup's version is its identity: save_config_atomic() hands the path
back, rollback_config(backup_version=...) looks it up, and the paired
secrets backup is found by reusing the string. Two saves in the same second
used to produce the same filename, so the second overwrote the first and a
rollback to the earlier version restored the later content.
"""
def setUp(self):
self.temp_dir = Path(tempfile.mkdtemp())
self.config_path = self.temp_dir / "config.json"
self.backup_dir = self.temp_dir / "backups"
self.config_path.write_text(json.dumps({"duration": 15}))
self.manager = AtomicConfigManager(
config_path=str(self.config_path),
backup_dir=str(self.backup_dir),
max_backups=10,
)
def tearDown(self):
shutil.rmtree(self.temp_dir)
def _version_of(self, backup_path):
return Path(backup_path).name.split('.backup.', 1)[-1]
def test_two_backups_in_the_same_second_are_two_files(self):
first = self.manager._create_backup()
self.config_path.write_text(json.dumps({"duration": 99}))
second = self.manager._create_backup()
self.assertNotEqual(first, second)
self.assertEqual(2, len(list(self.backup_dir.glob("config.json.backup.*"))))
def test_a_later_backup_does_not_overwrite_an_earlier_one(self):
first = self.manager._create_backup()
self.config_path.write_text(json.dumps({"duration": 99}))
self.manager._create_backup()
# The path the caller is still holding must hold what was backed up.
self.assertEqual({"duration": 15}, json.loads(Path(first).read_text()))
def test_rollback_to_an_earlier_version_restores_that_version(self):
result1 = self.manager.save_config_atomic({"duration": 45}, create_backup=True)
self.manager.save_config_atomic({"duration": 20}, create_backup=True)
# result1's backup was taken before that save, so it holds duration 15.
self.assertTrue(
self.manager.rollback_config(
backup_version=self._version_of(result1.backup_path)
)
)
self.assertEqual({"duration": 15}, json.loads(self.config_path.read_text()))
def test_the_reported_version_is_the_one_in_the_filename(self):
# rollback_config() matches on this string, so it has to be the name on
# disk and not a restamp of the file's mtime.
self.manager._create_backup()
self.config_path.write_text(json.dumps({"duration": 99}))
self.manager._create_backup()
backups = self.manager.list_backups()
self.assertEqual(2, len(backups))
for backup in backups:
self.assertEqual(self._version_of(backup.path), backup.version)
self.assertEqual(2, len({b.version for b in backups}))
def test_backups_are_ordered_newest_first_within_the_same_second(self):
older = self.manager._create_backup()
newer = self.manager._create_backup()
listed = [b.path for b in self.manager.list_backups()]
self.assertEqual([newer, older], listed)
def test_a_legacy_second_granularity_backup_is_still_restorable(self):
# Backups written before the version gained microseconds must stay
# usable, or an upgrade silently strips a rig's restore points.
legacy = self.backup_dir / "config.json.backup.20240101_120000"
legacy.write_text(json.dumps({"duration": 7}))
versions = {b.version for b in self.manager.list_backups()}
self.assertIn("20240101_120000", versions)
self.assertTrue(self.manager.rollback_config(backup_version="20240101_120000"))
self.assertEqual({"duration": 7}, json.loads(self.config_path.read_text()))
def test_an_unrecognized_backup_name_is_still_listed_and_restorable(self):
# Hand-copied or renamed files get ordered by mtime, but keep the
# version string they have on disk so they can still be named.
odd = self.backup_dir / "config.json.backup.hand-copied"
odd.write_text(json.dumps({"duration": 3}))
self.assertIn("hand-copied", {b.version for b in self.manager.list_backups()})
self.assertTrue(self.manager.rollback_config(backup_version="hand-copied"))
self.assertEqual({"duration": 3}, json.loads(self.config_path.read_text()))
def test_a_collision_suffix_never_costs_a_restore_point(self):
# Freeze the clock so every backup wants the identical filename.
frozen = datetime(2026, 1, 2, 3, 4, 5, 678901)
class FrozenDatetime(datetime):
@classmethod
def now(cls, tz=None):
return frozen
paths = []
with patch.object(atomic_module, 'datetime', FrozenDatetime):
for i in range(3):
self.config_path.write_text(json.dumps({"duration": i}))
paths.append(self.manager._create_backup())
self.assertEqual(3, len(set(paths)))
self.assertEqual(
[0, 1, 2],
[json.loads(Path(p).read_text())["duration"] for p in paths],
)
def test_collision_suffixed_backups_list_newest_first(self):
# All three share one frozen timestamp and differ only by their -N
# suffix. Unless N is folded into the sort key, they parse to the
# identical datetime and a stable sort leaves them in whatever order
# the filesystem glob happened to yield -- not necessarily creation
# order.
frozen = datetime(2026, 1, 2, 3, 4, 5, 678901)
class FrozenDatetime(datetime):
@classmethod
def now(cls, tz=None):
return frozen
created = []
with patch.object(atomic_module, 'datetime', FrozenDatetime):
for i in range(3):
self.config_path.write_text(json.dumps({"duration": i}))
created.append(self.manager._create_backup())
listed = [b.path for b in self.manager.list_backups()]
self.assertEqual(list(reversed(created)), listed)
def test_concurrent_backups_at_the_same_instant_never_collide(self):
# exists() followed by copy2() is two steps: two threads can both see
# a path as free before either creates it, and the second copy2()
# then silently destroys the first thread's restore point. Freezing
# the clock forces every thread to want the same filename.
frozen = datetime(2026, 3, 4, 5, 6, 7, 890123)
class FrozenDatetime(datetime):
@classmethod
def now(cls, tz=None):
return frozen
secrets_path = self.temp_dir / "secrets.json"
secrets_path.write_text(json.dumps({"token": "abc"}))
manager = AtomicConfigManager(
config_path=str(self.config_path),
secrets_path=str(secrets_path),
backup_dir=str(self.backup_dir),
max_backups=50,
)
n_threads = 8
barrier = threading.Barrier(n_threads)
results = [None] * n_threads
def worker(i):
barrier.wait()
results[i] = manager._create_backup()
with patch.object(atomic_module, 'datetime', FrozenDatetime):
threads = [threading.Thread(target=worker, args=(i,)) for i in range(n_threads)]
for t in threads:
t.start()
for t in threads:
t.join()
self.assertNotIn(None, results)
self.assertEqual(n_threads, len(set(results)))
config_backups = list(self.backup_dir.glob("config.json.backup.*"))
secrets_backups = list(self.backup_dir.glob("secrets.json.backup.*"))
self.assertEqual(n_threads, len(config_backups))
self.assertEqual(n_threads, len(secrets_backups))
for backup in config_backups:
self.assertEqual({"duration": 15}, json.loads(backup.read_text()))
class TestParseBackupVersion(unittest.TestCase):
"""
_parse_backup_version() only recognizes its own ``-N`` collision suffix
when N is numeric, and folds N into the returned timestamp so
same-instant collisions still sort deterministically.
"""
def test_numeric_collision_suffix_is_folded_into_the_timestamp(self):
base = "20260102_030405_678901"
parsed_base = AtomicConfigManager._parse_backup_version(base)
parsed_collided = AtomicConfigManager._parse_backup_version(f"{base}-2")
self.assertEqual(parsed_base + timedelta(microseconds=2), parsed_collided)
def test_a_nonnumeric_suffix_is_not_mistaken_for_a_collision_marker(self):
# "-manual" is not a suffix this class ever writes. Stripping it
# anyway would either misparse the base or silently pick the wrong
# timestamp for a hand-named backup that happens to end in a
# dash-word.
self.assertIsNone(
AtomicConfigManager._parse_backup_version(
"20260102_030405_678901-manual"
)
)
if __name__ == '__main__':
unittest.main()
+68
View File
@@ -0,0 +1,68 @@
"""The stdlib gzip fallback must compress the UI's text assets.
flask-compress is optional (app.py tolerates its absence). Without a fallback a
Pi missing it ships ~1.2 MB of uncompressed JavaScript to a phone over WiFi on
every first load and every update. These pin the fallback's contract: text
assets compress and round-trip byte-for-byte, clients that don't ask for gzip
get the original bytes, and small or non-text responses are left alone.
"""
import gzip
from pathlib import Path
import pytest
STATIC = Path(__file__).resolve().parents[2] / "web_interface" / "static"
@pytest.fixture
def client(monkeypatch):
import web_interface.app as web_app
# Exercise the fallback even on machines that do have flask-compress.
monkeypatch.setattr(web_app, "_HAVE_FLASK_COMPRESS", False)
web_app.app.config["TESTING"] = True
with web_app.app.test_client() as c:
yield c
@pytest.mark.parametrize("url,path", [
("/static/v3/plugins_manager.js", "v3/plugins_manager.js"),
("/static/v3/app.css", "v3/app.css"),
])
def test_static_text_asset_is_gzipped_and_round_trips(client, url, path):
resp = client.get(url, headers={"Accept-Encoding": "gzip, deflate"})
assert resp.status_code == 200
assert resp.headers.get("Content-Encoding") == "gzip"
assert "Accept-Encoding" in resp.headers.get("Vary", "")
original = (STATIC / path).read_bytes()
assert gzip.decompress(resp.data) == original
assert len(resp.data) < len(original) / 2
assert int(resp.headers["Content-Length"]) == len(resp.data)
def test_repeat_request_serves_identical_compressed_bytes(client):
headers = {"Accept-Encoding": "gzip"}
first = client.get("/static/v3/js/app-shell.js", headers=headers).data
second = client.get("/static/v3/js/app-shell.js", headers=headers).data
assert first == second
def test_client_without_gzip_gets_the_original_bytes(client):
resp = client.get("/static/v3/app.css", headers={"Accept-Encoding": "identity"})
assert resp.status_code == 200
assert "Content-Encoding" not in resp.headers
assert resp.data == (STATIC / "v3/app.css").read_bytes()
def test_binary_assets_are_not_recompressed(client):
resp = client.get("/static/v3/icons/icon-192.png", headers={"Accept-Encoding": "gzip"})
assert resp.status_code == 200
assert "Content-Encoding" not in resp.headers
def test_widget_bundle_is_gzipped_and_immutable(client):
resp = client.get("/assets/widgets.js", headers={"Accept-Encoding": "gzip"})
assert resp.status_code == 200
assert resp.headers.get("Content-Encoding") == "gzip"
assert "immutable" in resp.headers.get("Cache-Control", "")
assert b"/* registry.js */" in gzip.decompress(resp.data)
@@ -0,0 +1,249 @@
"""A save must only switch off checkboxes the caller actually saw.
An HTML checkbox posts nothing when unchecked, so the save route walks the
schema and forces every boolean missing from the form to ``False``. That is
right for the rendered form and wrong for everything else: a caller that posts
four fields -- a script, the MQTT bridge, 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.
That is not hypothetical. Posting four ``customization.*`` keys to a live
device switched off ``nfl.enabled``, ``ncaa_fb.enabled`` and every display-mode
toggle in one request.
The fix keeps both halves working, so both are pinned here:
* the rendered form reports the 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 such marker only touches objects it actually posted a field
from.
"""
import json
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from flask import Flask
from werkzeug.datastructures import MultiDict
PROJECT_ROOT = Path(__file__).parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
SCHEMA = {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": True},
"nfl": {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": True},
"live_priority": {"type": "boolean", "default": True},
"update_interval": {"type": "integer", "default": 3600},
# A section with nothing but checkboxes: when they are all
# unchecked it posts no keys at all.
"display_modes": {
"type": "object",
"properties": {
"show_live": {"type": "boolean", "default": True},
"show_recent": {"type": "boolean", "default": True},
},
},
},
},
"customization": {
"type": "object",
"properties": {
"score_text": {
"type": "object",
"properties": {
"visible": {"type": "boolean", "default": True},
"text_color": {
"type": "array",
"items": {"type": "integer"},
"minItems": 3, "maxItems": 3,
"default": [255, 255, 255],
},
},
},
},
},
},
}
STORED = {
"enabled": True,
"nfl": {
"enabled": True,
"live_priority": True,
"update_interval": 3600,
"display_modes": {"show_live": True, "show_recent": True},
},
"customization": {"score_text": {"visible": True,
"text_color": [255, 255, 255]}},
}
class _SaveOk:
status = type("S", (), {"value": "success"})()
message = None
@pytest.fixture
def post(tmp_path):
"""POST form data to the real save route; yields the stored config."""
from src.plugin_system.schema_manager import SchemaManager
from web_interface.blueprints import api_v3 as a
originals = {k: getattr(a.api_v3, k, None)
for k in ("config_manager", "schema_manager", "plugin_manager")}
pdir = tmp_path / "plugins" / "demo"
pdir.mkdir(parents=True)
(pdir / "config_schema.json").write_text(json.dumps(SCHEMA), encoding="utf-8")
store = {"demo": json.loads(json.dumps(STORED))}
cm = MagicMock()
cm.load_config.side_effect = lambda: json.loads(json.dumps(store))
cm.get_config_path.return_value = str(tmp_path / "config.json")
def _save(cfg, **_kw):
store.clear()
store.update(cfg)
return _SaveOk()
cm.save_config_atomic.side_effect = _save
a.api_v3.config_manager = cm
a.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path / "plugins",
project_root=tmp_path)
pm = MagicMock()
pm.plugins = {}
pm.get_plugin.return_value = None
a.api_v3.plugin_manager = pm
app = Flask(__name__)
app.config["TESTING"] = True
app.register_blueprint(a.api_v3, url_prefix="/api/v3")
client = app.test_client()
def _post(form):
# A list of pairs, not a dict: __rendered_section repeats, and that
# repetition is the whole point of the marker.
data = MultiDict(form) if isinstance(form, list) else form
resp = client.post("/api/v3/plugins/config?plugin_id=demo", data=data)
return resp, store.get("demo", {})
try:
yield _post
finally:
for k, v in originals.items():
setattr(a.api_v3, k, v)
# What the style editor posts when someone changes one colour and nothing else.
PARTIAL = [
("customization.score_text.text_color.0", "255"),
("customization.score_text.text_color.1", "0"),
("customization.score_text.text_color.2", "255"),
]
class TestAPartialPost:
def test_it_does_not_disable_another_section(self, post):
"""The live-device regression, in one assertion."""
_post = post
resp, cfg = _post(list(PARTIAL))
assert resp.status_code == 200, resp.get_json()
assert cfg["nfl"]["enabled"] is True
assert cfg["nfl"]["live_priority"] is True
assert cfg["nfl"]["display_modes"] == {"show_live": True,
"show_recent": True}
def test_it_still_applies_what_was_posted(self, post):
_post = post
_resp, cfg = _post(list(PARTIAL))
assert cfg["customization"]["score_text"]["text_color"] == [255, 0, 255]
def test_it_leaves_a_sibling_checkbox_it_never_mentioned(self, post):
"""`visible` lives beside the colour, but the post carried no field
from that object -- only from the colour array inside it."""
_post = post
_resp, cfg = _post(list(PARTIAL))
assert cfg["customization"]["score_text"]["visible"] is True
def test_it_does_clear_a_box_in_an_object_it_posted_to(self, post):
"""Evidence, not guesswork: a field from `display_modes` was posted,
so the checkbox missing from that same object really is unchecked."""
_post = post
_resp, cfg = _post([("nfl.display_modes.show_live", "true")])
assert cfg["nfl"]["display_modes"] == {"show_live": True,
"show_recent": False}
assert cfg["nfl"]["enabled"] is True
class TestTheRenderedForm:
"""With section markers the old behaviour must be exactly preserved."""
def test_an_unchecked_box_is_still_cleared(self, post):
_post = post
resp, cfg = _post([("__rendered_section", "nfl"),
("__rendered_section", "customization"),
("enabled", "true"),
("nfl.update_interval", "3600"),
("nfl.display_modes.show_live", "true")])
assert resp.status_code == 200, resp.get_json()
assert cfg["nfl"]["enabled"] is False
assert cfg["nfl"]["live_priority"] is False
assert cfg["nfl"]["display_modes"]["show_recent"] is False
def test_a_section_of_only_checkboxes_all_unchecked_is_cleared(self, post):
"""It posts no keys of its own, so only the marker can vouch for it."""
_post = post
_resp, cfg = _post([("__rendered_section", "nfl"),
("nfl.update_interval", "3600")])
assert cfg["nfl"]["display_modes"] == {"show_live": False,
"show_recent": False}
def test_a_section_the_form_did_not_draw_is_untouched(self, post):
_post = post
_resp, cfg = _post([("__rendered_section", "nfl"),
("nfl.update_interval", "3600")])
assert cfg["customization"]["score_text"]["visible"] is True
def test_the_marker_is_not_written_into_the_config(self, post):
"""It describes the submission; it is not a config path. Unknown form
keys are otherwise stored verbatim."""
_post = post
_resp, cfg = _post([("__rendered_section", "nfl"),
("nfl.update_interval", "3600")])
assert "__rendered_section" not in cfg
assert not [k for k in cfg if k.startswith("__")]
class TestTheFormEmitsTheMarker:
"""The fix only works if the rendered form actually reports its sections."""
def test_every_top_level_section_is_reported(self, tmp_path):
import re
from jinja2 import Environment, FileSystemLoader
templates = PROJECT_ROOT / "web_interface" / "templates"
env = Environment(loader=FileSystemLoader(str(templates)))
source = (templates / "v3" / "partials" / "plugin_config.html").read_text(
encoding="utf-8")
assert '__rendered_section' in source, (
"the form must tell the save route which sections it drew")
# It has to cover the advanced tier too, or collapsing a section into
# Advanced Settings would quietly stop its checkboxes clearing.
marker = re.search(r"\{% for key in ([^%]+) %\}\s*"
r"<input type=\"hidden\" name=\"__rendered_section\"",
source)
assert marker, "marker loop not found"
assert "tiers.basic" in marker.group(1)
assert "tiers.advanced" in marker.group(1)
@@ -0,0 +1,180 @@
"""Stored values the current schema no longer describes must not break the form.
Two cases from the news plugin:
* ``global.dynamic_duration`` used to be a boolean and is now an object. An
install that has not saved since still holds ``true``; rendering the nested
section did ``key in true`` and the whole config page failed to load.
* A custom feed logo stored with a ``path`` but no ``id``. The template always
emitted an empty ``logo.id`` input, which the save route parsed to ``None``
and the schema rejected (``Expected type string``) -- 400 on every save.
"""
import json
import sys
from html.parser import HTMLParser
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from flask import Flask
from werkzeug.datastructures import MultiDict
PROJECT_ROOT = Path(__file__).parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
SCHEMA = {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": True},
"global": {"type": "object", "properties": {
"dynamic_duration": {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": True},
"min_duration_seconds": {"type": "integer", "default": 30,
"minimum": 10, "maximum": 300},
},
"additionalProperties": False,
},
}, "additionalProperties": False},
"feeds": {"type": "object", "properties": {
"custom_feeds": {
"type": "array",
"x-widget": "custom-feeds",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"url": {"type": "string"},
"enabled": {"type": "boolean", "default": True},
"logo": {"type": "object", "required": ["path"],
"properties": {"id": {"type": "string"},
"path": {"type": "string"}}},
},
"required": ["name", "url"],
"additionalProperties": False,
},
},
}, "additionalProperties": False},
},
"required": ["enabled"],
"additionalProperties": False,
}
LOGO_PATH = "assets/plugins/news/uploads/verge.png"
STORED = {
"enabled": True,
"global": {"dynamic_duration": True},
"feeds": {"custom_feeds": [{"name": "Verge",
"url": "https://www.theverge.com/rss/index.xml",
"enabled": True,
"logo": {"path": LOGO_PATH}}]},
}
class _FormFields(HTMLParser):
"""Collect what a browser would submit from the rendered form."""
def __init__(self):
super().__init__()
self.pairs = []
def handle_starttag(self, tag, attrs):
a = dict(attrs)
if tag != "input" or not a.get("name") or "disabled" in a:
return
if a.get("type") in ("checkbox", "radio") and "checked" not in a:
return
if a.get("type") in ("button", "submit", "file"):
return
self.pairs.append((a["name"], a.get("value", "")))
def _render():
from jinja2 import Environment, FileSystemLoader, select_autoescape
env = Environment(
loader=FileSystemLoader(str(PROJECT_ROOT / "web_interface" / "templates")),
autoescape=select_autoescape(["html"]),
)
plugin = {"id": "demo", "name": "Demo", "description": "", "enabled": True,
"author": "me", "version": "1.0.0"}
return env.get_template("v3/partials/plugin_config.html").render(
plugin=plugin, schema=SCHEMA, config=json.loads(json.dumps(STORED)))
@pytest.fixture
def post(tmp_path):
from src.plugin_system.schema_manager import SchemaManager
from web_interface.blueprints import api_v3 as a
originals = {k: getattr(a.api_v3, k, None)
for k in ("config_manager", "schema_manager", "plugin_manager")}
pdir = tmp_path / "plugins" / "demo"
pdir.mkdir(parents=True)
(pdir / "config_schema.json").write_text(json.dumps(SCHEMA), encoding="utf-8")
store = {"demo": json.loads(json.dumps(STORED))}
cm = MagicMock()
cm.load_config.side_effect = lambda: json.loads(json.dumps(store))
cm.get_raw_file_content.return_value = {}
cm.get_config_path.return_value = str(tmp_path / "config.json")
def _save(cfg, **_kw):
store.clear()
store.update(cfg)
return type("R", (), {"status": type("S", (), {"value": "success"})(),
"message": None})()
cm.save_config_atomic.side_effect = _save
a.api_v3.config_manager = cm
a.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path / "plugins",
project_root=tmp_path)
pm = MagicMock()
pm.plugins = {}
pm.get_plugin.return_value = None
a.api_v3.plugin_manager = pm
app = Flask(__name__)
app.config["TESTING"] = True
app.register_blueprint(a.api_v3, url_prefix="/api/v3")
client = app.test_client()
def _post(pairs):
resp = client.post("/api/v3/plugins/config?plugin_id=demo",
data=MultiDict(pairs))
return resp, store.get("demo", {})
try:
yield _post
finally:
for k, v in originals.items():
setattr(a.api_v3, k, v)
def test_a_scalar_stored_under_an_object_field_still_renders():
html = _render()
assert 'name="global.dynamic_duration.min_duration_seconds"' in html
def test_a_logo_without_an_id_posts_no_id_field():
parser = _FormFields()
parser.feed(_render())
names = [name for name, _ in parser.pairs]
assert "feeds.custom_feeds.0.logo.path" in names
assert "feeds.custom_feeds.0.logo.id" not in names
def test_saving_that_form_succeeds_and_upgrades_the_legacy_value(post):
parser = _FormFields()
parser.feed(_render())
resp, cfg = post(parser.pairs)
assert resp.status_code == 200, resp.get_json()
assert isinstance(cfg["global"]["dynamic_duration"], dict)
# The legacy `true` was the on switch; upgrading must not turn it off.
assert cfg["global"]["dynamic_duration"]["enabled"] is True
assert cfg["global"]["dynamic_duration"]["min_duration_seconds"] == 30
assert cfg["feeds"]["custom_feeds"][0]["logo"] == {"path": LOGO_PATH}
@@ -0,0 +1,182 @@
"""The config form must render the same schema the save route validates.
The form used to read config_schema.json with a raw json.load while
api_v3.save_plugin_config went through SchemaManager. That is not a stylistic
difference: 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 the customization object has an
x-style-elements key and no ``properties``, so the template's object branch
matched nothing and the whole section rendered as empty space -- while saving
still validated against the expanded shape.
of-the-day ships the compact form, so this was live.
"""
import json
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from flask import Flask
PROJECT_ROOT = Path(__file__).parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
COMPACT_SCHEMA = {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": False},
"customization": {
"type": "object",
"title": "Display Customization",
"x-style-modes": ["live", "recent"],
"x-style-elements": {
"title_text": {
"title": "Title",
"font": {"default": "PressStart2P-Regular.ttf"},
"size": {"default": 8, "min": 4, "max": 16},
"color": {"default": [255, 255, 255]},
"offsets": True,
},
},
},
},
}
MANUAL_SCHEMA = {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": False},
"customization": {
"type": "object",
"title": "Display Customization",
"properties": {
"title_text": {
"type": "object",
"title": "Title",
"properties": {
"font_size": {"type": "integer", "default": 8},
},
},
},
},
},
}
@pytest.fixture
def render(tmp_path):
"""Render a plugin's config partial, with or without a SchemaManager."""
from web_interface.blueprints import pages_v3 as pv
from src.plugin_system.schema_manager import SchemaManager
orig_pm = getattr(pv.pages_v3, "plugin_manager", None)
orig_cm = getattr(pv.pages_v3, "config_manager", None)
orig_sm = getattr(pv.pages_v3, "schema_manager", None)
def _render(schema, with_schema_manager=True, plugin_id="demo"):
pdir = tmp_path / "plugins" / plugin_id
pdir.mkdir(parents=True, exist_ok=True)
(pdir / "config_schema.json").write_text(json.dumps(schema),
encoding="utf-8")
(pdir / "manifest.json").write_text(
json.dumps({"id": plugin_id, "name": plugin_id}), encoding="utf-8")
pm = MagicMock()
pm.plugins_dir = str(tmp_path / "plugins")
pm.get_plugin_info.return_value = {"id": plugin_id, "name": plugin_id}
pm.get_plugin.return_value = None
pv.pages_v3.plugin_manager = pm
cm = MagicMock()
cm.load_config.return_value = {plugin_id: {"enabled": True}}
pv.pages_v3.config_manager = cm
pv.pages_v3.schema_manager = (
SchemaManager(plugins_dir=tmp_path / "plugins",
project_root=tmp_path)
if with_schema_manager else None)
base = PROJECT_ROOT / "web_interface"
app = Flask(__name__,
template_folder=str(base / "templates"),
static_folder=str(base / "static"))
app.config["TESTING"] = True
app.register_blueprint(pv.pages_v3, url_prefix="")
resp = app.test_client().get(f"/partials/plugin-config/{plugin_id}")
assert resp.status_code == 200, resp.status_code
return resp.get_data(as_text=True)
try:
yield _render
finally:
pv.pages_v3.plugin_manager = orig_pm
pv.pages_v3.config_manager = orig_cm
pv.pages_v3.schema_manager = orig_sm
class TestCompactDeclarationRenders:
def test_the_element_fields_appear(self, render):
"""This is the regression: the section used to render empty."""
body = render(COMPACT_SCHEMA)
assert 'name="customization.title_text.font_size"' in body
# The font field is widget-rendered, so its <select name=...> is
# created client-side; what the server emits is the container and
# the key handed to the widget.
assert "name: 'customization.title_text.font'" in body
def test_the_colour_field_renders_as_rgb_inputs(self, render):
body = render(COMPACT_SCHEMA)
for channel in range(3):
assert f'name="customization.title_text.text_color.{channel}"' in body
def test_the_font_field_uses_the_font_selector_widget(self, render):
body = render(COMPACT_SCHEMA)
assert "LEDMatrixWidgets.get('font-selector')" in body
def test_layout_offsets_appear(self, render):
body = render(COMPACT_SCHEMA)
assert 'name="customization.layout.title_text.x_offset"' in body
assert 'name="customization.layout.title_text.y_offset"' in body
def test_declared_modes_appear(self, render):
body = render(COMPACT_SCHEMA)
assert 'name="customization.modes.live.title_text.font_size"' in body
assert 'name="customization.modes.recent.title_text.font_size"' in body
def test_mode_layout_offsets_appear(self, render):
body = render(COMPACT_SCHEMA)
assert ('name="customization.modes.live.layout.title_text.y_offset"'
in body)
class TestWithoutASchemaManager:
"""Several callers register this blueprint without a schema_manager; the
raw read stays as their fallback."""
def test_a_manual_block_still_renders(self, render):
body = render(MANUAL_SCHEMA, with_schema_manager=False)
assert 'name="customization.title_text.font_size"' in body
def test_a_compact_declaration_renders_nothing_without_expansion(self, render):
"""Documents precisely what the fix buys: the fallback path cannot
expand, so this is what every plugin using the compact form saw."""
body = render(COMPACT_SCHEMA, with_schema_manager=False)
assert 'name="customization.title_text.font"' not in body
class TestRenderAndSaveAgree:
def test_the_form_posts_names_the_save_path_recognises(self, render):
"""The rendered field names must resolve against the same expanded
schema the save route looks them up in."""
from web_interface.blueprints.api_v3 import _get_schema_property
from src.element_style import expand_style_elements
render(COMPACT_SCHEMA) # ensure it renders at all
schema = expand_style_elements(COMPACT_SCHEMA)
for path in ("customization.title_text.font",
"customization.title_text.font_size",
"customization.layout.title_text.x_offset",
"customization.modes.live.title_text.font_size"):
assert _get_schema_property(schema, path) is not None, path
@@ -0,0 +1,282 @@
"""The server half of the plugin-supplied widget feature.
``LEDMatrixWidgets.loadPluginWidget`` (static/v3/js/widgets/plugin-loader.js)
has always fetched ``/static/plugin-widgets/<plugin>/<widget>.js``, and
docs/widget-guide.md has always documented that path, but nothing served it --
so a plugin could declare a widget, ship the file, and still never load it.
soccer-scoreboard has shipped exactly that since August.
The load-bearing property here is that the manifest is the allowlist. A plugin
directory is attacker-influenced in the sense that matters -- plugins are
user-installed, and the store installs them -- so "serve files from the plugin
directory" would publish everything a plugin ships. Only a widget the manifest
declares is reachable, and only from that plugin's widgets/ directory.
"""
import json
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from flask import Flask
PROJECT_ROOT = Path(__file__).parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
WIDGET_BODY = "(function(){ window.LEDMatrixWidgets.register('custom-leagues', {}); })();\n"
_UNSET = object()
def _make_plugin(plugins_dir, plugin_id="soccer-scoreboard", widgets=None,
files=None):
"""Write a plugin directory with a manifest and a widgets/ folder."""
d = plugins_dir / plugin_id
(d / "widgets").mkdir(parents=True)
manifest = {"id": plugin_id, "name": plugin_id, "version": "1.0.0"}
if widgets is not None:
manifest["widgets"] = widgets
(d / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
for name, body in (files or {}).items():
(d / "widgets" / name).write_text(body, encoding="utf-8")
return d
@pytest.fixture
def make_client(tmp_path):
"""Build a test client whose plugin manager points at a temp plugins dir.
pages_v3 is a module-level Blueprint singleton shared across the test
process, so the original plugin_manager is restored on teardown.
"""
from web_interface.blueprints import pages_v3 as pv
original_pm = getattr(pv.pages_v3, "plugin_manager", None)
def _build(plugins_dir=None, plugin_manager=_UNSET):
base = PROJECT_ROOT / "web_interface"
app = Flask(__name__,
template_folder=str(base / "templates"),
static_folder=str(base / "static"))
app.config["TESTING"] = True
if plugin_manager is _UNSET:
plugin_manager = MagicMock()
plugin_manager.plugins_dir = str(plugins_dir or tmp_path)
pv.pages_v3.plugin_manager = plugin_manager
app.register_blueprint(pv.pages_v3, url_prefix="")
return app.test_client()
try:
yield _build
finally:
pv.pages_v3.plugin_manager = original_pm
URL = "/static/plugin-widgets/{}/{}.js"
class TestDeclaredWidgetIsServed:
def test_a_declared_widget_is_served(self, tmp_path, make_client):
_make_plugin(tmp_path,
widgets=[{"name": "custom-leagues",
"script": "custom-leagues.js"}],
files={"custom-leagues.js": WIDGET_BODY})
r = make_client().get(URL.format("soccer-scoreboard", "custom-leagues"))
assert r.status_code == 200
assert r.get_data(as_text=True) == WIDGET_BODY
def test_it_is_served_as_javascript(self, tmp_path, make_client):
"""The loader uses dynamic import(); a wrong MIME type is refused."""
_make_plugin(tmp_path,
widgets=[{"name": "custom-leagues",
"script": "custom-leagues.js"}],
files={"custom-leagues.js": WIDGET_BODY})
r = make_client().get(URL.format("soccer-scoreboard", "custom-leagues"))
assert "javascript" in r.headers["Content-Type"]
def test_script_defaults_to_the_widget_name(self, tmp_path, make_client):
_make_plugin(tmp_path, widgets=[{"name": "custom-leagues"}],
files={"custom-leagues.js": WIDGET_BODY})
r = make_client().get(URL.format("soccer-scoreboard", "custom-leagues"))
assert r.status_code == 200
def test_the_ledmatrix_prefix_fallback_resolves(self, tmp_path, make_client):
"""PluginManager resolves 'music' to 'ledmatrix-music'; so must this."""
_make_plugin(tmp_path, plugin_id="ledmatrix-music",
widgets=[{"name": "deck", "script": "deck.js"}],
files={"deck.js": WIDGET_BODY})
r = make_client().get(URL.format("music", "deck"))
assert r.status_code == 200
class TestTheManifestIsTheAllowlist:
def test_an_undeclared_file_in_widgets_is_not_served(self, tmp_path, make_client):
"""The whole point: shipping a file does not publish it."""
_make_plugin(tmp_path, widgets=[],
files={"secrets.js": "const KEY='hunter2';"})
r = make_client().get(URL.format("soccer-scoreboard", "secrets"))
assert r.status_code == 404
assert "hunter2" not in r.get_data(as_text=True)
def test_a_manifest_with_no_widgets_key_serves_nothing(self, tmp_path, make_client):
_make_plugin(tmp_path, files={"anything.js": WIDGET_BODY})
r = make_client().get(URL.format("soccer-scoreboard", "anything"))
assert r.status_code == 404
def test_a_declared_widget_whose_file_is_missing_is_404(self, tmp_path, make_client):
_make_plugin(tmp_path, widgets=[{"name": "ghost", "script": "ghost.js"}])
r = make_client().get(URL.format("soccer-scoreboard", "ghost"))
assert r.status_code == 404
def test_a_malformed_manifest_serves_nothing(self, tmp_path, make_client):
d = tmp_path / "broken"
(d / "widgets").mkdir(parents=True)
(d / "manifest.json").write_text("{not json", encoding="utf-8")
(d / "widgets" / "w.js").write_text(WIDGET_BODY, encoding="utf-8")
assert make_client().get(URL.format("broken", "w")).status_code == 404
class TestPathTraversal:
@pytest.mark.parametrize("plugin_id", ["../etc", "..%2f..", "a/b", "a\\b", ""])
def test_a_hostile_plugin_id_never_reaches_the_filesystem(
self, plugin_id, tmp_path, make_client):
r = make_client().get(URL.format(plugin_id, "custom-leagues"))
assert r.status_code in (400, 404), r.status_code
@pytest.mark.parametrize("widget", ["../manifest", "..%2fsecret", "a/b"])
def test_a_hostile_widget_name_never_reaches_the_filesystem(
self, widget, tmp_path, make_client):
_make_plugin(tmp_path, widgets=[{"name": "custom-leagues"}],
files={"custom-leagues.js": WIDGET_BODY})
r = make_client().get(URL.format("soccer-scoreboard", widget))
assert r.status_code in (400, 404), r.status_code
def test_a_manifest_cannot_escape_the_widgets_directory(self, tmp_path, make_client):
"""A hostile manifest is the traversal vector the URL allowlist can't
cover: the script name comes from the plugin, not the request."""
(tmp_path / "loot.js").write_text("const KEY='hunter2';", encoding="utf-8")
_make_plugin(tmp_path,
widgets=[{"name": "evil", "script": "../../loot.js"}])
r = make_client().get(URL.format("soccer-scoreboard", "evil"))
assert r.status_code == 404
assert "hunter2" not in r.get_data(as_text=True)
def test_a_symlinked_widgets_directory_cannot_escape(self, tmp_path, make_client):
"""widgets/ itself is contained before the script is resolved under it,
so a symlink out of the plugin can't become the containment base."""
outside = tmp_path / "outside"
outside.mkdir()
(outside / "loot.js").write_text("const KEY='hunter2';", encoding="utf-8")
d = tmp_path / "soccer-scoreboard"
d.mkdir()
manifest = {"id": "soccer-scoreboard", "name": "soccer-scoreboard",
"version": "1.0.0",
"widgets": [{"name": "loot", "script": "loot.js"}]}
(d / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
try:
(d / "widgets").symlink_to(outside, target_is_directory=True)
except (OSError, NotImplementedError):
pytest.skip("symlinks unavailable on this platform")
r = make_client().get(URL.format("soccer-scoreboard", "loot"))
assert r.status_code == 404
assert "hunter2" not in r.get_data(as_text=True)
class TestDegradation:
def test_an_unknown_plugin_is_404(self, tmp_path, make_client):
assert make_client().get(URL.format("nope", "w")).status_code == 404
def test_no_plugin_manager_is_503(self, make_client):
r = make_client(plugin_manager=None).get(URL.format("any", "w"))
assert r.status_code == 503
def _schema_with_widget(widget_name):
return {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": False},
"leagues": {"type": "string", "default": "eng.1",
"x-widget": widget_name},
},
}
@pytest.fixture
def config_form(tmp_path):
"""Render a plugin's config partial with a temp plugin on disk."""
from web_interface.blueprints import pages_v3 as pv
orig_pm = getattr(pv.pages_v3, "plugin_manager", None)
orig_cm = getattr(pv.pages_v3, "config_manager", None)
def _render(plugin_id="soccer-scoreboard", schema=None, widgets=None,
files=None):
d = _make_plugin(tmp_path, plugin_id, widgets=widgets, files=files)
(d / "config_schema.json").write_text(
json.dumps(schema or {"type": "object", "properties": {}}),
encoding="utf-8")
pm = MagicMock()
pm.plugins_dir = str(tmp_path)
pm.get_plugin_info.return_value = {"id": plugin_id, "name": plugin_id}
pm.get_plugin.return_value = None
pv.pages_v3.plugin_manager = pm
cm = MagicMock()
cm.load_config.return_value = {plugin_id: {"enabled": True}}
pv.pages_v3.config_manager = cm
base = PROJECT_ROOT / "web_interface"
app = Flask(__name__,
template_folder=str(base / "templates"),
static_folder=str(base / "static"))
app.config["TESTING"] = True
app.register_blueprint(pv.pages_v3, url_prefix="")
return app.test_client().get(f"/partials/plugin-config/{plugin_id}")
try:
yield _render
finally:
pv.pages_v3.plugin_manager = orig_pm
pv.pages_v3.config_manager = orig_cm
class TestTheFormRequestsPluginWidgets:
"""Without this the feature is still dead: the route can serve a widget,
but nothing ever asks for one. The server-side form only knows a hardcoded
list of core widget names, so a plugin's own x-widget fell through to a
plain text input and was never fetched."""
def test_an_unknown_widget_name_triggers_a_plugin_load(self, config_form):
r = config_form(schema=_schema_with_widget("custom-leagues"),
widgets=[{"name": "custom-leagues"}],
files={"custom-leagues.js": WIDGET_BODY})
assert r.status_code == 200
body = r.get_data(as_text=True)
assert "ensureWidget" in body
assert '"custom-leagues"' in body
def test_the_text_input_remains_as_the_fallback(self, config_form):
"""A widget that fails to load must not cost the user their value."""
r = config_form(schema=_schema_with_widget("custom-leagues"),
widgets=[{"name": "custom-leagues"}],
files={"custom-leagues.js": WIDGET_BODY})
body = r.get_data(as_text=True)
assert 'name="leagues"' in body
assert 'value="eng.1"' in body
def test_a_plain_string_field_asks_for_no_widget(self, config_form):
r = config_form(schema={"type": "object", "properties": {
"leagues": {"type": "string", "default": "eng.1"}}})
assert "ensureWidget" not in r.get_data(as_text=True)
def test_a_core_widget_does_not_take_the_plugin_path(self, config_form):
r = config_form(schema=_schema_with_widget("font-selector"))
body = r.get_data(as_text=True)
assert "ensureWidget" not in body
assert "LEDMatrixWidgets.get('font-selector')" in body
@@ -0,0 +1,212 @@
"""visible / align / scale, from the form the widget renders to the style the
display resolves.
These three are the difference between "restyle the text" and "lay the card
out", and each has a way of going quietly wrong in a form post:
- ``visible`` is a boolean, and an unchecked checkbox posts nothing at all,
so the widget carries the value in a hidden field. The save path has to
turn those strings into real booleans, or config.json grows "false"
strings that are truthy everywhere they are read.
- ``align`` is an enum, so an invalid value must not reach the renderer.
- ``scale`` is a float living in the layout block rather than the element
block, because a logo has a scale and no font.
"""
import json
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from flask import Flask
PROJECT_ROOT = Path(__file__).parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
SCHEMA = {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": True},
"customization": {
"type": "object",
"x-style-modes": ["live"],
"x-style-elements": {
"score_text": {
"title": "Score",
"font": {"default": "PressStart2P-Regular.ttf"},
"size": {"default": 10, "min": 4, "max": 16},
"color": {"default": [255, 255, 255]},
"visible": True,
"align": {"default": "center"},
"offsets": True,
},
"home_logo": {
"title": "Home Logo",
"offsets": True,
"visible": True,
"scale": {"default": 1.0, "min": 0.25, "max": 4},
},
},
},
},
}
class _SaveOk:
status = type("S", (), {"value": "success"})()
message = None
@pytest.fixture
def post(tmp_path):
from src.plugin_system.schema_manager import SchemaManager
from web_interface.blueprints import api_v3 as a
originals = {k: getattr(a.api_v3, k, None)
for k in ("config_manager", "schema_manager", "plugin_manager")}
pdir = tmp_path / "plugins" / "demo"
pdir.mkdir(parents=True)
(pdir / "config_schema.json").write_text(json.dumps(SCHEMA), encoding="utf-8")
store = {"demo": {"enabled": True}}
cm = MagicMock()
cm.load_config.side_effect = lambda: json.loads(json.dumps(store))
cm.get_config_path.return_value = str(tmp_path / "config.json")
def _save(cfg, **_kw):
store.clear()
store.update(cfg)
return _SaveOk()
cm.save_config_atomic.side_effect = _save
a.api_v3.config_manager = cm
a.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path / "plugins",
project_root=tmp_path)
pm = MagicMock()
pm.plugins = {}
pm.get_plugin.return_value = None
a.api_v3.plugin_manager = pm
app = Flask(__name__)
app.config["TESTING"] = True
app.register_blueprint(a.api_v3, url_prefix="/api/v3")
client = app.test_client()
def _post(extra):
form = {
"enabled": "true",
"customization.score_text.font": "PressStart2P-Regular.ttf",
"customization.score_text.font_size": "10",
"customization.score_text.text_color.0": "255",
"customization.score_text.text_color.1": "255",
"customization.score_text.text_color.2": "255",
"customization.score_text.visible": "true",
"customization.score_text.align": "center",
"customization.layout.score_text.x_offset": "0",
"customization.layout.score_text.y_offset": "0",
"customization.home_logo.visible": "true",
"customization.layout.home_logo.x_offset": "0",
"customization.layout.home_logo.y_offset": "0",
"customization.layout.home_logo.scale": "1",
}
form.update(extra)
resp = client.post("/api/v3/plugins/config?plugin_id=demo", data=form)
return resp, store.get("demo", {})
def _style(cfg, element="score_text", mode=None):
from src.element_style import (ElementStyleResolver,
defaults_from_schema_file)
defaults = defaults_from_schema_file(str(pdir / "config_schema.json"))
return ElementStyleResolver(cfg, defaults, mode=mode).style(
element, classic_font="PressStart2P-Regular.ttf", classic_size=10,
classic_color=(255, 255, 255))
try:
yield _post, _style
finally:
for k, v in originals.items():
setattr(a.api_v3, k, v)
class TestVisible:
def test_a_checked_box_stores_a_real_boolean(self, post):
_post, _ = post
resp, cfg = _post({})
assert resp.status_code == 200, resp.get_json()
stored = cfg["customization"]["score_text"]["visible"]
assert stored is True, f"stored {stored!r}, not a bool"
def test_hiding_an_element_survives_the_round_trip(self, post):
_post, _style = post
resp, cfg = _post({"customization.score_text.visible": "false"})
assert resp.status_code == 200, resp.get_json()
assert cfg["customization"]["score_text"]["visible"] is False
assert _style(cfg).visible is False
def test_the_default_is_not_read_as_a_choice(self, post):
"""The save flow writes the schema default in either way."""
_post, _style = post
_resp, cfg = _post({})
assert _style(cfg).visible is True
class TestAlign:
def test_a_choice_reaches_the_display(self, post):
_post, _style = post
resp, cfg = _post({"customization.score_text.align": "right"})
assert resp.status_code == 200, resp.get_json()
assert _style(cfg).align == "right"
def test_the_schema_default_resolves_to_no_preference(self, post):
"""'center' is what the schema declares, so the plugin keeps doing
whatever it already did rather than being told to centre."""
_post, _style = post
_resp, cfg = _post({})
assert _style(cfg).align is None
def test_an_invalid_alignment_is_rejected_on_save(self, post):
_post, _ = post
resp, _cfg = _post({"customization.score_text.align": "sideways"})
assert resp.status_code == 400
class TestScale:
def test_a_logo_scale_saves_into_the_layout_block(self, post):
_post, _style = post
resp, cfg = _post({"customization.layout.home_logo.scale": "2.5"})
assert resp.status_code == 200, resp.get_json()
assert cfg["customization"]["layout"]["home_logo"]["scale"] == 2.5
assert _style(cfg, "home_logo").scale == 2.5
def test_a_scale_outside_the_declared_range_is_rejected(self, post):
_post, _ = post
resp, _cfg = _post({"customization.layout.home_logo.scale": "99"})
assert resp.status_code == 400
def test_an_element_without_a_declared_scale_stays_neutral(self, post):
_post, _style = post
_resp, cfg = _post({})
assert _style(cfg, "score_text").scale == 1.0
class TestPerMode:
def test_a_mode_can_hide_what_the_base_shows(self, post):
_post, _style = post
resp, cfg = _post({"customization.modes.live.score_text.visible": "false",
"customization.modes.live.score_text.align": "",
"customization.modes.live.score_text.font": "",
"customization.modes.live.score_text.font_size": ""})
assert resp.status_code == 200, resp.get_json()
assert _style(cfg).visible is True
assert _style(cfg, mode="live").visible is False
def test_a_blank_mode_boolean_means_inherit(self, post):
_post, _style = post
resp, cfg = _post({"customization.score_text.visible": "false",
"customization.modes.live.score_text.visible": ""})
assert resp.status_code == 200, resp.get_json()
assert cfg["customization"]["modes"]["live"]["score_text"]["visible"] is None
assert _style(cfg, mode="live").visible is False, "inherits the base"
@@ -0,0 +1,222 @@
"""The style editor's form data must survive the real save route.
The widget posts ordinary dotted field names so the existing pipeline needs no
new parsing -- but per-mode override fields are typed ``["integer", "null"]``
and ``["array", "null"]``, and two places in that pipeline compared the
declared type to a bare string:
- the indexed-array recombiner (``text_color.0/.1/.2`` -> one list) skipped
anything whose type was not exactly ``'array'``, so a per-mode colour was
never reassembled;
- ``_parse_form_value_with_schema`` did the same, and turned a blank nullable
field into ``[]`` rather than ``None`` -- which then failed the ``minItems``
the colour array declares.
Both would have surfaced as "saving a scoreboard's per-mode colour fails
validation", with nothing in the widget to suggest why. This posts what the
widget really emits and asserts on what lands in config, then on what the
resolver makes of it.
"""
import json
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from flask import Flask
PROJECT_ROOT = Path(__file__).parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
SCHEMA = {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": True},
"customization": {
"type": "object",
"x-style-modes": ["live", "recent"],
"x-style-elements": {
"score_text": {
"title": "Score",
"font": {"default": "PressStart2P-Regular.ttf"},
"size": {"default": 10, "min": 4, "max": 16},
"color": {"default": [255, 255, 255]},
"offsets": True,
},
},
},
},
}
class _SaveOk:
status = type("S", (), {"value": "success"})()
message = None
@pytest.fixture
def post(tmp_path):
"""POST form data to the real save route; yields the stored config."""
from src.plugin_system.schema_manager import SchemaManager
from web_interface.blueprints import api_v3 as a
originals = {k: getattr(a.api_v3, k, None)
for k in ("config_manager", "schema_manager", "plugin_manager")}
pdir = tmp_path / "plugins" / "demo"
pdir.mkdir(parents=True)
(pdir / "config_schema.json").write_text(json.dumps(SCHEMA), encoding="utf-8")
store = {"demo": {"enabled": True}}
cm = MagicMock()
cm.load_config.side_effect = lambda: json.loads(json.dumps(store))
cm.get_config_path.return_value = str(tmp_path / "config.json")
def _save(cfg, **_kw):
store.clear()
store.update(cfg)
return _SaveOk()
cm.save_config_atomic.side_effect = _save
a.api_v3.config_manager = cm
a.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path / "plugins",
project_root=tmp_path)
pm = MagicMock()
pm.plugins = {}
pm.get_plugin.return_value = None
a.api_v3.plugin_manager = pm
app = Flask(__name__)
app.config["TESTING"] = True
app.register_blueprint(a.api_v3, url_prefix="/api/v3")
client = app.test_client()
def _post(form):
resp = client.post("/api/v3/plugins/config?plugin_id=demo", data=form)
return resp, store.get("demo", {})
try:
yield _post, pdir
finally:
for k, v in originals.items():
setattr(a.api_v3, k, v)
BASE_FORM = {
"enabled": "true",
"customization.score_text.font": "PressStart2P-Regular.ttf",
"customization.score_text.font_size": "12",
"customization.score_text.text_color.0": "255",
"customization.score_text.text_color.1": "200",
"customization.score_text.text_color.2": "0",
"customization.layout.score_text.x_offset": "0",
"customization.layout.score_text.y_offset": "0",
}
# A mode the user left alone: every field blank. Blank colours are simply
# absent, because the widget disables those inputs rather than posting "".
BLANK_MODE = {
"customization.modes.recent.score_text.font": "",
"customization.modes.recent.score_text.font_size": "",
"customization.modes.recent.layout.score_text.x_offset": "",
"customization.modes.recent.layout.score_text.y_offset": "",
}
class TestSaveRoundTrip:
def test_the_base_element_saves(self, post):
_post, _ = post
resp, cfg = _post(dict(BASE_FORM))
assert resp.status_code == 200, resp.get_json()
assert cfg["customization"]["score_text"] == {
"font": "PressStart2P-Regular.ttf",
"font_size": 12,
"text_color": [255, 200, 0],
}
def test_a_blank_mode_saves_as_null_not_zero(self, post):
"""Null is the inherit sentinel; 0 or [] would pin the mode."""
_post, _ = post
resp, cfg = _post(dict(BASE_FORM, **BLANK_MODE))
assert resp.status_code == 200, resp.get_json()
recent = cfg["customization"]["modes"]["recent"]
assert recent["score_text"] == {"font": None, "font_size": None,
"text_color": None}
assert recent["layout"]["score_text"] == {"x_offset": None,
"y_offset": None}
def test_a_partly_set_mode_keeps_the_rest_inheriting(self, post):
_post, _ = post
resp, cfg = _post(dict(
BASE_FORM,
**{"customization.modes.live.score_text.font": "",
"customization.modes.live.score_text.font_size": "16",
"customization.modes.live.layout.score_text.x_offset": "",
"customization.modes.live.layout.score_text.y_offset": "-3"}))
assert resp.status_code == 200, resp.get_json()
live = cfg["customization"]["modes"]["live"]
assert live["score_text"]["font_size"] == 16
assert live["score_text"]["font"] is None
assert live["score_text"]["text_color"] is None
assert live["layout"]["score_text"] == {"x_offset": None,
"y_offset": -3}
def test_a_per_mode_colour_is_reassembled_into_a_list(self, post):
"""The recombiner used to skip this: its type is ["array", "null"],
not "array", so the three indexed inputs were never joined."""
_post, _ = post
resp, cfg = _post(dict(
BASE_FORM,
**{"customization.modes.live.score_text.text_color.0": "0",
"customization.modes.live.score_text.text_color.1": "255",
"customization.modes.live.score_text.text_color.2": "0"}))
assert resp.status_code == 200, resp.get_json()
assert (cfg["customization"]["modes"]["live"]["score_text"]["text_color"]
== [0, 255, 0])
def test_all_blank_colour_channels_mean_inherit(self, post):
"""A hand-written form (or an older widget) can still post three
empty strings; they must not become [] and fail minItems."""
_post, _ = post
resp, cfg = _post(dict(
BASE_FORM,
**{"customization.modes.live.score_text.text_color.0": "",
"customization.modes.live.score_text.text_color.1": "",
"customization.modes.live.score_text.text_color.2": ""}))
assert resp.status_code == 200, resp.get_json()
assert (cfg["customization"]["modes"]["live"]["score_text"]["text_color"]
is None)
class TestWhatTheDisplayThenRenders:
"""The point of the round trip: what the resolver makes of what was saved."""
def _styles(self, cfg, pdir):
from src.element_style import (ElementStyleResolver,
defaults_from_schema_file)
defaults = defaults_from_schema_file(str(pdir / "config_schema.json"))
out = {}
for mode in (None, "live", "recent"):
r = ElementStyleResolver(cfg, defaults, mode=mode)
style = r.style("score_text",
classic_font="PressStart2P-Regular.ttf",
classic_size=10, classic_color=(255, 255, 255))
out[mode] = (style.font_size, style.color, r.offset("score_text"))
return out
def test_a_mode_override_reaches_the_display_and_the_rest_inherits(self, post):
_post, pdir = post
_resp, cfg = _post(dict(
BASE_FORM, **BLANK_MODE,
**{"customization.modes.live.score_text.font": "",
"customization.modes.live.score_text.font_size": "16",
"customization.modes.live.layout.score_text.x_offset": "",
"customization.modes.live.layout.score_text.y_offset": "-3"}))
styles = self._styles(cfg, pdir)
assert styles[None] == (12, (255, 200, 0), (0, 0))
# live overrides size and y, and inherits the colour it never set
assert styles["live"] == (16, (255, 200, 0), (0, -3))
assert styles["recent"] == (12, (255, 200, 0), (0, 0))
@@ -0,0 +1,251 @@
"""When the style editor takes over a customization block, it takes over
exactly its own part of it -- and it actually gets to take over at all.
Two defects this pins, both found by rendering the real partial in a browser
rather than by reading the code:
1. The hand-off guard asked "do any fallback controls differ from their
server-rendered defaults?" as a proxy for "is someone editing this?". The
fallback contains this block's own font fields, and the font-selector
widget populates them on the same 50ms timer -- so a plain page load with
nobody touching anything raced into "dirty" (seven customization.*.font
selects, measured ~60ms after injection) and the style editor removed
itself, leaving the accordion form it exists to replace. The question is
whether a *person* typed, and isTrusted answers exactly that.
2. Taking over used to remove the whole fallback section. A customization
block can hold more than styling -- football keeps favorite_result_colors
there -- so that removed the only UI those fields had.
These assert on the template source because the behaviour lives in an inline
script that no Python test executes; the browser check that found both is not
something CI runs.
"""
import re
import sys
from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
TEMPLATE = (PROJECT_ROOT / "web_interface" / "templates" / "v3" / "partials"
/ "plugin_config.html")
@pytest.fixture(scope="module")
def source():
return TEMPLATE.read_text(encoding="utf-8")
class TestTheHandOffGuard:
def test_it_asks_whether_a_person_typed(self, source):
assert "isTrusted" in source, (
"a programmatic change by a sibling widget must not read as a "
"user edit -- that is what removed the editor on a plain load")
assert "userEdited" in source
def test_it_no_longer_diffs_values_against_defaults(self, source):
assert "fallbackIsDirty" not in source, (
"value-diffing cannot distinguish a user's edit from another "
"widget populating this block's own fields")
assert "defaultSelected" not in source
def test_the_listeners_are_attached_synchronously(self, source):
"""The edit worth protecting can happen before initWidget runs, so
the listeners cannot wait for it.
Scoped to this branch: the template defines an initWidget for every
widget it can render, and the first one in the file belongs to a
different branch entirely.
"""
start = source.index("obj_widget == 'style-editor'")
branch = source[start:source.index("{% elif prop.properties %}", start)]
assert "watchForRealEdits" in branch
assert branch.index("watchForRealEdits") < branch.index("function initWidget"), (
"the watcher must be installed above this branch's initWidget, "
"and invoked immediately rather than from inside it")
def test_the_guard_still_backs_off_for_a_real_edit(self, source):
assert re.search(r"if \(userEdited\) \{ container\.remove\(\); return; \}",
source), "a genuine in-progress edit must still win"
class TestItTakesOverOnlyItsOwnBlocks:
def test_children_are_individually_addressable(self, source):
assert 'data-child-key="{{ nested_key }}"' in source, (
"without a handle per child the only options are removing the "
"whole section or none of it")
def test_removal_is_driven_by_what_the_widget_reported(self, source):
assert "ownedKeys" in source
assert "fallback.querySelector(" in source
def test_an_unowned_child_survives(self, source):
"""The empty-section case still removes the lot, so a block that is
entirely styling looks exactly as it did before."""
assert "!fallback.querySelector('[data-child-key]')" in source
def test_the_owned_key_is_escaped_before_it_becomes_a_selector(self, source):
"""ownedKeys comes back off a dataset attribute, not a literal --
splicing it into '[data-child-key="' + k + '"]' unescaped breaks (or
is steerable) on a key containing a quote or backslash. The rest of
this codebase (plugin-file-manager.js, app-shell.js) always routes a
dynamic value through CSS.escape() before it lands in a selector."""
assert "CSS.escape(k)" in source, (
"an owned key must be CSS.escape()'d before being interpolated "
"into the data-child-key attribute selector")
class TestTheSchemaSaysWhichBlocksAreStyling:
def test_adopted_blocks_are_marked_and_others_are_not(self):
from src.element_style import expand_style_elements
schema = {
"type": "object",
"properties": {
"customization": {
"type": "object",
"properties": {
"score_text": {
"type": "object",
"properties": {
"font": {"type": "string",
"default": "PressStart2P-Regular.ttf"},
"font_size": {"type": "integer", "default": 10},
"text_color": {"type": "array",
"default": [255, 255, 255]},
},
},
# Not styling: a feature that happens to live here.
"favorite_result_colors": {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": False},
"win_color": {"type": "array",
"default": [0, 255, 0]},
},
},
},
},
},
}
props = expand_style_elements(schema)["properties"]["customization"]["properties"]
assert props["score_text"]["x-style-managed"] is True
assert "x-style-managed" not in props["favorite_result_colors"]
# Football's real layout keys, in its schema's order. The style block and the
# layout block were written years apart and never agreed on names.
FOOTBALL_LAYOUT = ["home_logo", "away_logo", "score", "status_text", "date",
"time", "down_distance", "timeouts", "possession",
"records", "odds"]
def _offsets(*axes):
return {"type": "object",
"properties": {a: {"type": "integer", "default": 0} for a in axes}}
def _style_block():
return {"type": "object", "properties": {
"font": {"type": "string", "default": "PressStart2P-Regular.ttf"},
"font_size": {"type": "integer", "default": 10},
"text_color": {"type": "array", "default": [255, 255, 255]},
}}
def _football_shaped():
layout = {k: _offsets("x_offset", "y_offset") for k in FOOTBALL_LAYOUT}
layout["records"] = _offsets("away_x_offset", "home_x_offset", "y_offset")
return {"type": "object", "properties": {"customization": {
"type": "object",
"x-style-modes": ["live", "recent"],
"properties": {
**{k: _style_block() for k in (
"score_text", "period_text", "team_name", "status_text",
"detail_text", "odds_text", "rank_text")},
"layout": {"type": "object", "properties": layout},
}}}}
class TestEveryAdvertisedOffsetGetsAControl:
"""The style editor took the layout section over but only drew offsets
whose layout key matched a style key exactly. In football that was
status_text alone: score, odds, both logos, timeouts, possession,
down-and-distance, date, time and records -- options the schema
advertises and the renderer reads -- had no control anywhere."""
def _customization(self):
from src.element_style import expand_style_elements
return expand_style_elements(_football_shaped())[
"properties"]["customization"]
def test_a_style_element_is_told_where_its_offsets_live(self):
"""Resolved in core through the same alias map the resolver reads
offsets with, so the editor cannot drift from the renderer."""
props = self._customization()["properties"]
assert props["score_text"]["x-layout-key"] == "score"
assert props["odds_text"]["x-layout-key"] == "odds"
assert props["status_text"]["x-layout-key"] == "status_text"
def test_an_element_with_no_offsets_claims_nothing(self):
props = self._customization()["properties"]
for key in ("period_text", "detail_text", "team_name", "rank_text"):
assert "x-layout-key" not in props[key], key
def test_the_mode_copies_carry_it_too(self):
live = self._customization()["properties"]["modes"]["properties"][
"live"]["properties"]
assert live["score_text"]["x-layout-key"] == "score"
def test_positions_keep_the_order_the_plugin_declared(self):
"""Flask sorts keys when it serialises the schema, which would
otherwise list the logos after the date."""
c = self._customization()
assert c["properties"]["layout"]["x-propertyOrder"] == FOOTBALL_LAYOUT
live_layout = c["properties"]["modes"]["properties"]["live"][
"properties"]["layout"]
assert live_layout["x-propertyOrder"] == FOOTBALL_LAYOUT
def test_every_layout_entry_is_claimed_or_listed_as_a_position(self):
"""Mirrors the widget's split: a layout entry either belongs to a
style row or gets a row of its own. Nothing is left over."""
props = self._customization()["properties"]
layout = props["layout"]["properties"]
claimed = {props[k]["x-layout-key"] for k in props
if isinstance(props[k], dict) and "x-layout-key" in props[k]}
positions = [k for k in props["layout"]["x-propertyOrder"]
if k not in claimed]
assert claimed | set(positions) == set(layout)
assert positions == ["home_logo", "away_logo", "date", "time",
"down_distance", "timeouts", "possession",
"records"]
def test_the_widget_reads_the_annotation_and_lists_positions(self):
js = (PROJECT_ROOT / "web_interface" / "static" / "v3" / "js"
/ "widgets" / "style-editor.js").read_text(encoding="utf-8")
assert "x-layout-key" in js, (
"the alias rules live in element_style.py; the widget must read "
"their result rather than carry a second copy")
assert "function positionRows" in js
def test_a_leaf_under_layout_is_never_claimed_as_offsets(self):
"""A show_logo toggle straight under layout has no x/y object, so it
cannot hold an element's offsets. Claiming it would give that row no
layout columns and remove the leaf from the positions list -- the
field would lose its only control."""
from src.element_style import expand_style_elements
schema = _football_shaped()
cust = schema["properties"]["customization"]
# A style element whose alias ("show_logo") names a leaf.
cust["properties"]["show_logo_text"] = _style_block()
cust["properties"]["layout"]["properties"]["show_logo"] = {
"type": "boolean", "default": True}
props = expand_style_elements(schema)["properties"]["customization"][
"properties"]
assert "x-layout-key" not in props["show_logo_text"]
assert "show_logo" in props["layout"]["x-propertyOrder"]
+113 -12
View File
@@ -4,6 +4,7 @@ import logging
import os
import queue
import re
import gzip
import shutil
import sys
import subprocess
@@ -69,10 +70,12 @@ except ImportError:
# Enable gzip/brotli response compression (Flask-Compress skips streaming
# responses, so the SSE endpoints are unaffected). Optional, like limiter:
# missing package just means uncompressed responses.
_HAVE_FLASK_COMPRESS = False
try:
from flask_compress import Compress
Compress(app)
_HAVE_FLASK_COMPRESS = True
except ImportError:
logging.getLogger(__name__).warning(
"flask-compress not installed - responses will be served uncompressed. "
@@ -168,6 +171,7 @@ pages_v3.config_manager = config_manager
pages_v3.plugin_manager = plugin_manager
pages_v3.plugin_store_manager = plugin_store_manager
pages_v3.saved_repositories_manager = saved_repositories_manager
pages_v3.schema_manager = schema_manager
api_v3.config_manager = config_manager
api_v3.plugin_manager = plugin_manager
@@ -499,6 +503,80 @@ def add_static_version(endpoint, values):
pass
# Gzip fallback for when flask-compress isn't installed. Without it the UI
# ships ~1.2 MB of uncompressed JS to a phone over WiFi. Compressed bytes are
# cached per URL+version, so the Pi compresses each asset once, not per request.
# URL-versioned assets: safe to cache as immutable (and to gzip once).
# /assets/ is the widget bundle from pages_v3 (also reachable under /v3).
_VERSIONED_ASSET_PREFIXES = ('/static/', '/assets/', '/v3/assets/')
_GZIP_MIN_BYTES = 1024
_GZIP_TYPES = (
'text/html', 'text/css', 'text/plain', 'text/javascript',
'application/javascript', 'application/json', 'image/svg+xml',
)
_GZIP_CACHE_MAX_BYTES = 4 * 1024 * 1024
_gzip_cache = {}
_gzip_cache_bytes = 0
_gzip_cache_lock = threading.Lock()
@app.after_request
def compress_text_responses(response):
"""Gzip text responses when flask-compress is absent (SSE untouched)."""
if _HAVE_FLASK_COMPRESS or response.status_code != 200:
return response
if 'Content-Encoding' in response.headers:
return response
# send_file responses report is_streamed (they wrap a file) but have a
# known size; genuinely streamed bodies (SSE, generators) are left alone.
# SSE is also excluded by its text/event-stream mimetype below.
if response.is_streamed and not response.direct_passthrough:
return response
mimetype = (response.mimetype or '').lower()
if mimetype not in _GZIP_TYPES:
return response
if 'gzip' not in (request.headers.get('Accept-Encoding') or '').lower():
return response
cache_key = None
if request.path.startswith(_VERSIONED_ASSET_PREFIXES):
cache_key = (request.full_path, response.headers.get('Last-Modified'))
with _gzip_cache_lock:
cached = _gzip_cache.get(cache_key)
if cached is not None:
return _apply_gzip(response, cached)
# send_file responses stream from disk; materialize before compressing.
if response.direct_passthrough:
response.direct_passthrough = False
data = response.get_data()
if len(data) < _GZIP_MIN_BYTES:
return response
compressed = gzip.compress(data, compresslevel=6)
if len(compressed) >= len(data):
return response
if cache_key is not None:
global _gzip_cache_bytes
with _gzip_cache_lock:
if _gzip_cache_bytes + len(compressed) <= _GZIP_CACHE_MAX_BYTES:
_gzip_cache[cache_key] = compressed
_gzip_cache_bytes += len(compressed)
return _apply_gzip(response, compressed)
def _apply_gzip(response, compressed):
response.set_data(compressed)
response.headers['Content-Encoding'] = 'gzip'
response.headers['Content-Length'] = str(len(compressed))
if 'accept-encoding' not in (response.headers.get('Vary') or '').lower():
response.headers.add('Vary', 'Accept-Encoding')
etag = response.headers.get('ETag')
if etag and 'gzip' not in etag:
response.headers['ETag'] = etag.rstrip('"') + '-gzip"'
return response
# Add security headers and caching to all responses
@app.after_request
def add_security_headers(response):
@@ -510,7 +588,7 @@ def add_security_headers(response):
response.headers['X-XSS-Protection'] = '1; mode=block'
# Add caching headers for static assets
if request.path.startswith('/static/'):
if request.path.startswith(_VERSIONED_ASSET_PREFIXES):
# Cache static assets for 1 year (with versioning via query params)
response.headers['Cache-Control'] = 'public, max-age=31536000, immutable'
response.headers['Expires'] = (datetime.now() + timedelta(days=365)).strftime('%a, %d %b %Y %H:%M:%S GMT')
@@ -670,18 +748,13 @@ def display_preview_generator():
except OSError:
pass # display side treats a missing marker as "no viewer"
# Get display dimensions from config
# Get display dimensions from config: the logical size DisplayManager
# renders at, so double-sided setups preview one screen
from src.display_geometry import logical_size
try:
main_config = config_manager.load_config()
cols = main_config.get('display', {}).get('hardware', {}).get('cols', 64)
chain_length = main_config.get('display', {}).get('hardware', {}).get('chain_length', 2)
rows = main_config.get('display', {}).get('hardware', {}).get('rows', 32)
parallel = main_config.get('display', {}).get('hardware', {}).get('parallel', 1)
width = cols * chain_length
height = rows * parallel
except (KeyError, TypeError, ValueError, ConfigError):
width = 128
height = 64
width, height = logical_size(config_manager.load_config())
except (KeyError, TypeError, ValueError, AttributeError, ConfigError):
width, height = logical_size({})
while True:
try:
@@ -974,8 +1047,36 @@ def check_health_monitor():
_reconciliation_started = True
_threading.Thread(target=_run_startup_reconciliation, daemon=True).start()
_auto_updater = None
def start_auto_update_scheduler():
"""Start the weekly auto-update thread (web_interface/auto_update.py).
Called by the launchers, not at import: tests and dev tools import this
module, and none of them should ever git pull. Idle unless
auto_update.enabled is set, so it is safe to always start.
"""
global _auto_updater
if _auto_updater is not None:
return _auto_updater
from web_interface.auto_update import AutoUpdater
from web_interface.blueprints.api_v3.system import perform_core_update
_auto_updater = AutoUpdater(
config_manager=config_manager,
core_update=perform_core_update,
store_manager=plugin_store_manager,
plugin_manager=plugin_manager,
schema_manager=schema_manager,
operation_history=operation_history,
)
_auto_updater.start()
return _auto_updater
if __name__ == '__main__':
import os as _os
start_auto_update_scheduler()
# threaded=True is Flask's default since 1.0 but stated explicitly so that
# long-lived /api/v3/stream/* SSE connections don't starve other requests.
# Debug mode is off by default; opt in with FLASK_DEBUG=1 in the environment.
+700
View File
@@ -0,0 +1,700 @@
"""Weekly automatic updates: LEDMatrix code, then installed plugins.
Turned on by ``auto_update.enabled`` in config.json (General tab, or the
installer's --enable-auto-update). Off by default: an update pulls new code and
restarts the display, which nobody should get without asking for it.
Nothing here is allowed to leave a device broken without saying so:
* **Checks first.** The code update is skipped, and the reason reported, when
the checkout has local edits or commits, a rebase or merge is in progress,
the branch has no upstream, disk is low, the newest commit was already
rolled back once, or the health check is not set up.
* **Verified, and rolled back.** The pull itself is the Overview "Update Code"
path (``perform_core_update``). Restarting and checking the result is handed
to ledmatrix-update-verify.service (scripts/utils/auto_update_verify.py),
started through ledmatrix-update-verify.path by a request file, which
survives the web service restart, confirms both services come up and stay
up, and otherwise resets to the previous commit and its dependencies.
* **Set up without SSH.** Those units are installed by the installer, or by the
display service when the toggle is switched on (src/auto_update_setup.py).
* **Plugins after the code.** Plugins use the Plugin Store's own update, which
refuses versions this core cannot run and restores the old copy when an
install fails. When the code changed they wait until it passed its check,
so they are never gated against code that is about to be rolled back.
* **Loud failures.** Anything other than success is written to the state file,
shown under the toggle and raised as a banner on the Overview tab.
The schedule survives restarts through data/auto_update_state.json (gitignored).
"""
import importlib.util
import json
import logging
import os
import shutil
import subprocess # nosec B404 - list-form argv only, no shell # nosemgrep
import tempfile
import threading
import time
from datetime import datetime
from pathlib import Path
logger = logging.getLogger(__name__)
PROJECT_ROOT = Path(__file__).resolve().parent.parent
STATE_REL = Path('data') / 'auto_update_state.json'
PENDING_REL = Path('data') / 'auto_update_pending.json'
REQUEST_REL = Path('data') / 'auto_update_verify.request'
SETUP_RESULT_REL = Path('data') / 'auto_update_setup.json'
VERIFIER_COPY_REL = Path('data') / 'auto_update_verifier.py'
VERIFIER_SOURCE = PROJECT_ROOT / 'scripts' / 'utils' / 'auto_update_verify.py'
STATE_FILE = PROJECT_ROOT / STATE_REL
PENDING_FILE = PROJECT_ROOT / PENDING_REL
SETUP_RESULT_FILE = PROJECT_ROOT / SETUP_RESULT_REL
VERIFY_UNIT = 'ledmatrix-update-verify'
PATH_UNIT = f'{VERIFY_UNIT}.path'
UPDATE_INTERVAL_SECONDS = 7 * 24 * 3600
#: A failure that is probably transient (usually no network) is retried the
#: next day rather than a week later.
RETRY_AFTER_FAILURE_SECONDS = 24 * 3600
#: Local hours an update prefers, since it restarts the display...
QUIET_HOURS = range(2, 5)
#: ...but a device that is never powered at night must still get updates.
QUIET_HOURS_GRACE_SECONDS = 24 * 3600
CHECK_INTERVAL_SECONDS = 30 * 60
#: Let the web service settle after boot (and never pull in a crash loop).
STARTUP_DELAY_SECONDS = 10 * 60
MIN_FREE_BYTES = 300 * 1024 * 1024
#: How long the health check gets to pick up a request before the update is undone.
HANDOFF_TIMEOUT_SECONDS = 90
#: The health check takes a few minutes at most; far past that, it is lost.
VERIFY_LOST_SECONDS = 45 * 60
#: Core outcomes that need the user's attention.
ERROR_OUTCOMES = frozenset({'error', 'blocked', 'rolled_back', 'rollback_failed', 'lost'})
HELPER_MISSING_MESSAGE = (
"LEDMatrix code updates are paused until the update health check is set up. "
"The display service sets it up when it starts while automatic updates are on, "
"so restart the display from the Overview tab. If this keeps happening, the "
"setup result under Automatic Updates on the General tab says why. Plugin "
"updates still run."
)
_STATE_LOCK = threading.RLock()
def is_enabled(config):
return bool((config.get('auto_update') or {}).get('enabled', False))
def _read_json(path):
try:
with open(path, 'r', encoding='utf-8') as f:
data = json.load(f)
return data if isinstance(data, dict) else None
except (OSError, ValueError):
return None
def _write_json(path, data):
path = Path(path)
tmp = None
try:
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f'.{path.stem}_')
with os.fdopen(fd, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2)
os.replace(tmp, path)
tmp = None
finally:
if tmp and os.path.exists(tmp):
try:
os.unlink(tmp)
except OSError:
pass
def _unlink(path):
try:
Path(path).unlink()
except OSError:
pass
def load_state(state_file=STATE_FILE):
return _read_json(state_file) or {}
def save_state(state, state_file=STATE_FILE):
with _STATE_LOCK:
try:
_write_json(state_file, state)
except OSError as e:
logger.warning("Could not save auto-update state: %s", e)
def dismiss_alert(alert_id, state_file=STATE_FILE):
with _STATE_LOCK:
state = load_state(state_file)
state['alert_dismissed'] = str(alert_id)
save_state(state, state_file)
def _zone(tz_name):
try:
from zoneinfo import ZoneInfo
return ZoneInfo(tz_name) if tz_name else None
except Exception:
return None
def _local_datetime(ts, tz_name):
return datetime.fromtimestamp(ts, _zone(tz_name))
def _short(sha):
return (sha or 'unknown')[:7]
def is_due(now, next_due, local_hour):
"""Due once ``next_due`` has passed, in quiet hours or after the grace period."""
if next_due is None or now < next_due:
return False
return local_hour in QUIET_HOURS or now >= next_due + QUIET_HOURS_GRACE_SECONDS
def _plugin_fingerprint(store_manager, plugin_dir):
"""(manifest version, git sha): what changing tells us an update landed."""
version = None
try:
with open(plugin_dir / 'manifest.json', 'r', encoding='utf-8') as f:
manifest = json.load(f)
if manifest.get('local_only'):
return None
version = manifest.get('version')
except (OSError, ValueError):
pass
sha = None
try:
git_info = store_manager._get_local_git_info(plugin_dir)
sha = git_info.get('sha') if git_info else None
except Exception:
logger.debug("git info unavailable for %s", plugin_dir, exc_info=True)
return (version, sha)
def update_plugins(store_manager, operation_history=None):
"""Update every installed plugin that has an update. Returns (updated, failed)."""
updated, failed = [], []
plugins_dir = Path(store_manager.plugins_dir)
for plugin_id in sorted(store_manager.list_installed_plugins()):
plugin_dir = plugins_dir / plugin_id
before = _plugin_fingerprint(store_manager, plugin_dir)
if before is None:
continue # local_only: managed by hand, never from the registry
try:
ok = store_manager.update_plugin(plugin_id)
except Exception:
logger.exception("Automatic update of plugin %s raised", plugin_id)
ok = False
if not ok:
failed.append(plugin_id)
status = 'failed'
elif _plugin_fingerprint(store_manager, plugin_dir) != before:
updated.append(plugin_id)
status = 'success'
else:
continue # already current; not worth a history entry every week
if operation_history:
try:
operation_history.record_operation(
'update', plugin_id=plugin_id, status=status,
details={'automatic': True})
except Exception:
logger.debug("Could not record auto-update history", exc_info=True)
return updated, failed
def _service_active(unit):
try:
result = subprocess.run(['systemctl', 'is-active', unit],
capture_output=True, text=True, timeout=5)
return result.stdout.strip() == 'active'
except (subprocess.SubprocessError, OSError):
return False
def helper_ready():
"""True when the health check can be triggered (its path unit is watching)."""
return _service_active(PATH_UNIT)
def restart_service(unit):
"""Restart a unit only if it is running, via the sudoers-allowed command."""
if not _service_active(unit):
return False
try:
result = subprocess.run(['sudo', '-n', 'systemctl', 'restart', f'{unit}.service'],
capture_output=True, text=True, timeout=30)
except (subprocess.SubprocessError, OSError) as e:
logger.warning("Auto-update could not restart %s: %s", unit, e)
return False
if result.returncode != 0:
logger.warning("Auto-update restart of %s failed: %s", unit, result.stderr.strip())
return result.returncode == 0
def start_setup_if_needed(was_enabled, config):
"""After settings are saved: switching updates on finishes setting them up.
The health check units are installed by the display service at startup
(src/auto_update_setup.py), so switching the toggle on restarts it.
Returns a note for the save message, or None.
"""
if was_enabled or not is_enabled(config) or helper_ready():
return None
if not _service_active('ledmatrix'):
return 'Automatic update setup finishes the next time the display service starts.'
if restart_service('ledmatrix'):
return 'Finishing automatic update setup: the display is restarting.'
return 'Could not restart the display to finish automatic update setup; restart it from the Overview tab.'
def _load_verifier(path):
spec = importlib.util.spec_from_file_location('ledmatrix_auto_update_verifier', str(path))
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
class AutoUpdater:
"""Decides when an automatic update is due and runs it."""
def __init__(self, config_manager, core_update, store_manager=None,
plugin_manager=None, schema_manager=None, operation_history=None,
project_root=PROJECT_ROOT, state_file=None, clock=time.time,
restart=restart_service, run=subprocess.run,
service_active=_service_active, helper_ready=helper_ready,
verifier_source=VERIFIER_SOURCE, disk_free=None, sleep=time.sleep):
self.config_manager = config_manager
self.core_update = core_update
self.store_manager = store_manager
self.plugin_manager = plugin_manager
self.schema_manager = schema_manager
self.operation_history = operation_history
self.project_root = Path(project_root)
self.state_file = Path(state_file) if state_file else self.project_root / STATE_REL
self.pending_file = self.project_root / PENDING_REL
self.request_file = self.project_root / REQUEST_REL
self.verifier_copy = self.project_root / VERIFIER_COPY_REL
self.verifier_source = Path(verifier_source)
self.clock = clock
self.restart = restart
self.run_command = run
self.service_active = service_active
self.helper_ready = helper_ready
self.disk_free = disk_free or (lambda path: shutil.disk_usage(path).free)
self.sleep = sleep
self._stop = threading.Event()
self._thread = None
# -- scheduling ---------------------------------------------------------
def tick(self):
"""Do whatever is due. Returns True if an update ran."""
now = self.clock()
state = load_state(self.state_file)
# Before the enabled check: a verification started before the user
# switched updates off still has to be reported.
if self._finalize_verification(state, now) == 'waiting':
return False
try:
config = self.config_manager.load_config()
except Exception:
logger.debug("Auto-update could not load config", exc_info=True)
return False
if not is_enabled(config):
return False
if state.get('plugins_pending'):
self._run_deferred_plugins(state, now)
return True
if state.get('next_due') is None:
# Just switched on: due now, which in practice means the next
# quiet-hours window rather than the moment Save was clicked.
state['next_due'] = now
save_state(state, self.state_file)
local_hour = _local_datetime(now, config.get('timezone')).hour
if not is_due(now, state['next_due'], local_hour):
return False
self.run(state)
return True
def run(self, state=None):
state = load_state(self.state_file) if state is None else state
logger.info("Automatic update starting")
try:
core = self.update_core(state)
except Exception as e:
logger.exception("Automatic core update raised")
core = {'outcome': 'error', 'message': f'The LEDMatrix update failed unexpectedly: {e}'}
deferred = core['outcome'] == 'verifying'
updated, failed = ([], []) if deferred else self._update_plugins()
self._store_run(state, core, updated, failed)
logger.info("Automatic update: core %s (%s); plugins updated=%s failed=%s%s",
core['outcome'], core['message'], updated, failed,
'; plugins wait for the health check' if deferred else '')
# Code restarts belong to the health check; this only covers plugins.
if updated:
self.restart('ledmatrix')
return state['last_result']
def _store_run(self, state, core, updated, failed):
now = self.clock()
deferred = core['outcome'] == 'verifying'
state.update({
'last_run': now,
'next_due': now + (RETRY_AFTER_FAILURE_SECONDS if core['outcome'] == 'error'
else UPDATE_INTERVAL_SECONDS),
'plugins_pending': deferred,
'last_result': {
'core_outcome': core['outcome'],
'core_message': core['message'],
'plugins_updated': updated,
'plugins_failed': failed,
'plugins_deferred': deferred,
},
})
self._apply_result(state, now)
save_state(state, self.state_file)
# -- core update --------------------------------------------------------
def _git(self, *args, timeout=60):
return self.run_command(['git', *args], cwd=str(self.project_root),
capture_output=True, text=True, timeout=timeout)
def _count(self, rev_range):
out = self._git('rev-list', '--count', rev_range).stdout.strip()
return int(out) if out.isdigit() else 0
def preflight(self, state):
"""Decide whether the code update may run.
Returns ``(outcome, message, info)`` with outcome ``ready``,
``up_to_date``, ``blocked`` (needs the user) or ``error`` (retry soon).
"""
if not self.helper_ready():
return 'blocked', HELPER_MISSING_MESSAGE, {}
git_dir = self._git('rev-parse', '--git-dir')
if git_dir.returncode != 0:
return 'blocked', 'LEDMatrix is not installed as a git checkout, so it cannot update itself.', {}
git_dir = Path(git_dir.stdout.strip())
if not git_dir.is_absolute():
git_dir = self.project_root / git_dir
rebase_dirs = [git_dir / name for name in ('rebase-merge', 'rebase-apply') if (git_dir / name).exists()]
if rebase_dirs:
# A rebase really in progress leaves HEAD detached. With HEAD back
# on a branch it was abandoned -- typically a pull that stopped on
# a conflict, then a checkout -- and it makes every later pull
# fail. It holds no work (local edits are checked separately
# below), so clear it rather than strand a device nobody SSHes into.
if self._git('symbolic-ref', '-q', 'HEAD').returncode != 0:
return 'blocked', ('A git rebase is in progress in the LEDMatrix folder. '
'Finish or abort it; automatic updates will not touch it.'), {}
cleared = self._git('rebase', '--quit')
if cleared.returncode != 0 or any(path.exists() for path in rebase_dirs):
return 'blocked', ('An abandoned git rebase in the LEDMatrix folder could not be cleared: '
f'{(cleared.stderr or "").strip() or "unknown error"}.'), {}
logger.warning("Cleared an abandoned git rebase from %s", ', '.join(str(p) for p in rebase_dirs))
if (git_dir / 'MERGE_HEAD').exists():
return 'blocked', ('A git merge is in progress in the LEDMatrix folder. '
'Finish or abort it; automatic updates will not touch it.'), {}
if self._git('rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}').returncode != 0:
return 'blocked', ('The current branch has no upstream to update from (or HEAD is detached). '
'Use Update Code once, or Tools -> Switch branch.'), {}
# Mode-only changes are ignored: older installers chmod tracked
# scripts, and --autostash in the pull carries those across. Plugin
# folders are separate installs, as in perform_core_update.
status = self._git('-c', 'core.fileMode=false', 'status', '--porcelain', '--untracked-files=no')
changed = [line[3:] for line in status.stdout.splitlines()
if line.strip() and 'plugins/' not in line and 'plugin-repos/' not in line]
if status.returncode != 0 or changed:
example = f' (for example {changed[0]})' if changed else ''
return 'blocked', (f'{len(changed) or "Some"} tracked file(s) in the LEDMatrix folder were edited '
f'locally{example}. Automatic updates will not stash your changes; '
'commit or revert them, or update manually with Update Code.'), {}
free = self.disk_free(str(self.project_root))
if free < MIN_FREE_BYTES:
return 'blocked', (f'Only {free // (1024 * 1024)} MB of disk space is free; an update '
f'needs at least {MIN_FREE_BYTES // (1024 * 1024)} MB.'), {}
fetch = self._git('fetch', '--quiet', timeout=120)
if fetch.returncode != 0:
detail = next((ln.strip() for ln in (fetch.stderr or '').splitlines() if ln.strip()), '')
return 'error', f'Could not check for LEDMatrix updates: {detail or "git fetch failed"}.', {}
ahead = self._count('@{u}..HEAD')
if ahead:
return 'blocked', (f'This checkout has {ahead} local commit(s) that are not upstream. '
'Automatic updates will not rebase them; update manually with Update Code.'), {}
if not self._count('HEAD..@{u}'):
return 'up_to_date', 'LEDMatrix is already up to date.', {}
upstream = self._git('rev-parse', '@{u}').stdout.strip()
if upstream and upstream == state.get('rolled_back_head'):
return 'up_to_date', (f'The newest LEDMatrix version ({_short(upstream)}) failed its health '
'check and was rolled back before; waiting for a newer one.'), {}
head = self._git('rev-parse', 'HEAD').stdout.strip()
return 'ready', '', {'old_head': head, 'upstream_head': upstream}
def update_core(self, state):
try:
outcome, message, info = self.preflight(state)
except (subprocess.SubprocessError, OSError) as e:
return {'outcome': 'error', 'message': f'Pre-update checks failed: {e}.'}
if outcome != 'ready':
return {'outcome': outcome, 'message': message}
old_head = info['old_head']
display_was_active = self.service_active('ledmatrix')
try:
# Copied before pulling, so the checker is the known-good version.
self.verifier_copy.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(self.verifier_source, self.verifier_copy)
verifier = _load_verifier(self.verifier_copy)
except Exception as e:
return {'outcome': 'error', 'message': f'Could not prepare the update health check: {e}.'}
try:
core = self.core_update()
except Exception as e:
logger.exception("perform_core_update raised")
core = {'status': 'error', 'message': f'Update failed: {e}'}
new_head = self._git('rev-parse', 'HEAD').stdout.strip()
pending = {
'status': 'pending',
'old_head': old_head,
'new_head': new_head,
'display_was_active': display_was_active,
'dependency_failures': list(core.get('dependency_failures') or []),
'created_at': self.clock(),
}
def rollback():
return verifier.Verifier(self.project_root, run=self.run_command).rollback(pending)
if core.get('status') != 'success':
message = core.get('message') or 'The LEDMatrix update failed.'
if new_head and new_head != old_head:
ok, detail = rollback()
message += (' The partial update was rolled back.' if ok
else f' Rolling back the partial update also failed: {detail}.')
if not ok:
return {'outcome': 'rollback_failed', 'message': message}
return {'outcome': 'error', 'message': message}
if new_head == old_head:
return {'outcome': 'up_to_date', 'message': core.get('message') or 'LEDMatrix is already up to date.'}
handoff = {'outcome': 'verifying',
'message': (f'Updated LEDMatrix from {_short(old_head)} to {_short(new_head)}; '
'restarting and checking the services.')}
# Recorded before the handoff: the health check restarts this process.
self._store_run(state, handoff, [], [])
try:
_write_json(self.pending_file, pending)
self.request_file.write_text(new_head, encoding='utf-8')
picked_up = self._wait_for_pickup()
except OSError as e:
logger.error("Could not request the update health check: %s", e)
picked_up = False
if picked_up:
return handoff
# No health check means no update: undo it while the old code is
# still the code that is running.
logger.error("%s did not start the health check; rolling the update back", PATH_UNIT)
_unlink(self.request_file)
_unlink(self.pending_file)
ok, detail = rollback()
if not ok:
return {'outcome': 'rollback_failed',
'message': (f'The update health check did not start, and rolling back to '
f'{_short(old_head)} failed: {detail}.')}
return {'outcome': 'blocked',
'message': ('The update health check did not start, so the update was undone. '
f'Check "systemctl status {PATH_UNIT}".'
+ (f' Note: {detail}.' if detail else ''))}
def _wait_for_pickup(self):
for _ in range(HANDOFF_TIMEOUT_SECONDS):
pending = _read_json(self.pending_file)
if pending and pending.get('status') != 'pending':
return True
self.sleep(1)
pending = _read_json(self.pending_file)
return bool(pending and pending.get('status') != 'pending')
def _finalize_verification(self, state, now):
"""Fold the health check's outcome into the state. None, 'waiting' or 'done'."""
pending = _read_json(self.pending_file)
if pending is None:
return None
status = pending.get('status')
old, new = pending.get('old_head'), pending.get('new_head')
reason, detail = pending.get('reason'), pending.get('detail')
if status in ('pending', 'verifying'):
if now - float(pending.get('created_at') or now) < VERIFY_LOST_SECONDS:
return 'waiting'
outcome = 'lost'
message = (f'LEDMatrix was updated to {_short(new)}, but its health check never reported back, '
'so it is not known whether the device is healthy. '
f'See "journalctl -u {VERIFY_UNIT}".')
elif status == 'success':
outcome = 'updated'
message = (f'Updated LEDMatrix from {_short(old)} to {_short(new)}; '
'the services restarted and stayed healthy.')
elif status == 'rolled_back':
outcome = 'rolled_back'
message = (f'The LEDMatrix update to {_short(new)} was rolled back to {_short(old)} because '
f'{reason or "it failed its health check"}.' + (f' Note: {detail}.' if detail else ''))
state['rolled_back_head'] = new
else:
outcome = 'rollback_failed'
message = (f'The LEDMatrix update to {_short(new)} failed ({reason or "unknown reason"}) and ' # nosec B608 - user-facing message, not SQL # nosemgrep
f'could not be rolled back: {detail or "unknown error"}. The device may need '
f'attention: run "git reset --hard {old}" in the LEDMatrix folder, then restart '
'the display and web services.')
result = state.setdefault('last_result', {})
result.update({'core_outcome': outcome, 'core_message': message})
if outcome not in ('updated', 'rolled_back'):
# The device is in an unknown state; don't pile plugin changes on it.
state['plugins_pending'] = False
result['plugins_deferred'] = False
self._apply_result(state, now)
save_state(state, self.state_file)
_unlink(self.pending_file)
(logger.info if outcome == 'updated' else logger.error)("Automatic update: %s", message)
return 'done'
# -- plugins and reporting ----------------------------------------------
def _update_plugins(self):
if not self.store_manager:
return [], []
updated, failed = update_plugins(self.store_manager, self.operation_history)
for plugin_id in updated:
if self.schema_manager:
self.schema_manager.invalidate_cache(plugin_id)
if updated and self.plugin_manager:
try:
self.plugin_manager.discover_plugins()
except Exception:
logger.debug("discover_plugins after auto-update failed", exc_info=True)
return updated, failed
def _run_deferred_plugins(self, state, now):
state['plugins_pending'] = False
updated, failed = self._update_plugins()
result = state.setdefault('last_result', {})
result.update({'plugins_updated': updated, 'plugins_failed': failed, 'plugins_deferred': False})
self._apply_result(state, now)
save_state(state, self.state_file)
if updated:
self.restart('ledmatrix')
@staticmethod
def _apply_result(state, now):
result = state.get('last_result') or {}
outcome = result.get('core_outcome')
failed = result.get('plugins_failed') or []
if outcome in ERROR_OUTCOMES or failed:
result['status'] = 'error'
elif outcome == 'verifying':
result['status'] = 'pending'
else:
result['status'] = 'success'
if result['status'] == 'error':
parts = []
if outcome in ERROR_OUTCOMES:
parts.append(result.get('core_message') or 'The LEDMatrix update failed.')
if failed:
parts.append('These plugins failed to update: ' + ', '.join(failed) + '.')
state['alert'] = {'id': str(int(now * 1000)), 'message': ' '.join(parts)}
elif result['status'] == 'success':
state.pop('alert', None)
# -- thread -------------------------------------------------------------
def start(self):
if self._thread and self._thread.is_alive():
return
self._thread = threading.Thread(target=self._loop, name='auto-update', daemon=True)
self._thread.start()
def stop(self):
self._stop.set()
def _loop(self):
if self._stop.wait(STARTUP_DELAY_SECONDS):
return
while True:
try:
self.tick()
except Exception:
logger.exception("Automatic update check failed")
if self._stop.wait(CHECK_INTERVAL_SECONDS):
return
def describe_status(config, state=None, state_file=STATE_FILE, pending_file=PENDING_FILE,
setup_file=SETUP_RESULT_FILE, helper=None):
"""What the General tab and the Overview banner show."""
state = load_state(state_file) if state is None else state
tz = config.get('timezone')
fmt = '%b %d, %Y %I:%M %p'
pending = _read_json(pending_file) or {}
setup = _read_json(setup_file) or {}
enabled = is_enabled(config)
out = {
'last_run': None, 'summary': None, 'status': None, 'next_due': None,
'alert': None, 'alert_id': None,
# Only asked while enabled: it runs systemctl, on every page load.
'verifier_installed': (helper or helper_ready)() if enabled else None,
'setup_status': setup.get('status'),
'setup_message': setup.get('message'),
'verifying': pending.get('status') in ('pending', 'verifying'),
}
if state.get('last_run'):
out['last_run'] = _local_datetime(state['last_run'], tz).strftime(fmt)
result = state.get('last_result') or {}
out['status'] = result.get('status')
parts = [result.get('core_message') or 'No result recorded.']
if result.get('plugins_updated'):
parts.append('Plugins updated: ' + ', '.join(result['plugins_updated']) + '.')
if result.get('plugins_failed'):
parts.append('Plugins that failed to update: ' + ', '.join(result['plugins_failed']) + '.')
if result.get('plugins_deferred') and state.get('plugins_pending'):
parts.append('Plugin updates run once the code update passes its health check.')
out['summary'] = ' '.join(parts)
if enabled and state.get('next_due'):
out['next_due'] = _local_datetime(state['next_due'], tz).strftime(fmt)
alert = state.get('alert')
if alert and alert.get('id') != state.get('alert_dismissed'):
out['alert'], out['alert_id'] = alert.get('message'), alert.get('id')
return out

Some files were not shown because too many files have changed in this diff Show More