Compare commits

...
19 Commits
Author SHA1 Message Date
ChuckBuildsandClaude Opus 5 56ba235f4c feat(tools): manage the MQTT bridge and Pixlet editor from the Tools tab
PR #544's change, ported onto the api_v3 package split (#553). Identical
behaviour; only the placement of the new code differs.

The original added 508 lines to web_interface/blueprints/api_v3.py, which #553
deletes, so every hunk of it would conflict irreconcilably. Ported by AST:
26 new top-level items sorted to where the split puts each kind --

  __init__.py   2 imports, 11 constants, 7 helpers
  starlark.py   4 routes  (/starlark/editor/{apps,status,start,stop})
  misc.py       2 routes  (/integrations/mqtt-bridge{,/config})

Everything outside api_v3.py -- the Tools partial, the installer scripts, the
JS tests -- applied unchanged.

Routes: 111 from the split plus these 6 = 117, and the url-map snapshot is
regenerated to match, which is exactly what test_api_v3_url_map.py is designed
to make you do when routes are added.

Full Python suite: 4,278 passed, 68 skipped, 0 failed. The JS tests this PR
ships could not be run here -- node is not installed on this machine -- so
test/js/dom/test_tools_sections.js is unverified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9
2026-09-09 19:52:43 -04:00
ChuckBuildsandClaude Opus 5 60d5d19898 refactor(api-v3): split the 10,469-line blueprint into a package
web_interface/blueprints/api_v3.py held 111 routes, 56 helpers and 181
functions in one module -- 9% of the core by line count and three times the
next largest file. It becomes a package of nine route modules grouped by path
segment, plus __init__.py for the shared imports, constants, Blueprint and
helpers.

Every route module decorates the SAME api_v3 Blueprint object, so endpoint
names stay api_v3.<function>, the URL map is unchanged and app.py is untouched.
Verified: 111 routes before, 111 after, byte-identical rules, endpoints and
methods, and every endpoint still on the one blueprint.

  plugins   3,867   config    1,178   starlark  692   system  619
  fonts       452   misc        398   wifi      361   display 326   backup 212
  __init__  1,787 (imports, constants, Blueprint, 56 helpers)

Two things the URL-map check could not catch, both found by running the suite:

1. PROJECT_ROOT = Path(__file__).parent.parent.parent. Moving the code one
   directory deeper made that resolve to web_interface/ instead of the project
   root. Nothing failed at import; it surfaced as ~110 tests failing with 404s
   and "installation script not found", because every path built from it was
   one level too shallow. Now parents[3], and test_api_v3_url_map.py asserts
   PROJECT_ROOT/run.py exists so the next move cannot repeat it.

2. Module-attribute patching. Tests do
   monkeypatch.setattr(api_v3_module, "_BACKUP_EXPORT_DIR", ...) and a route
   module that binds such a name by value never sees the patch. The shared code
   therefore stays in __init__.py rather than moving to a _common submodule --
   it has to live on the module the tests patch -- and the eleven names tests
   patch are read back through the package (_pkg.X) instead of bound by value.
   Those eleven were found by AST-scanning every setattr in the test tree, not
   by guessing; "time" is among them, used to drive a fake clock through the
   second-resolution credential-backup filenames.

Test changes are confined to what genuinely moved: patch targets that now name
the owning route module, imports of helpers, and six tests that scan the api_v3
source as a file and now read the package directory.

Full suite: 4,278 passed, 68 skipped, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9
2026-09-09 19:41:02 -04:00
ChuckandClaude Opus 5 a29c84208e fix(scroll): advance whole pixels per frame, not per wall-clock second (#545)
* fix(scroll): advance whole pixels per frame, not per wall-clock second

Smooth motion is not a frame-rate property, and measuring it as one is why
this survived three rounds of fixes. odds-ticker's frame timing is excellent
-- 100.0 fps, 10.00ms median, 0% stalls, worst in-scroll frame 19.95ms -- and
it still visibly stuttered.

What the eye judges is whether the strip advances the same number of whole
pixels on every presented frame. update_scroll_position derived position from
scroll_speed * delta_time and get_visible_portion truncated it with int(), so
jitter in delta_time decided which side of a pixel boundary the position
landed on. The live windows show why that matters: a rock-steady 100.0 fps
whose individual frames still range 5.6ms to 15.2ms, which at 100 px/s is
0.57px to 1.44px of movement.

Run the measured frame times through the real helper and 5.8% of frames
advance 0 or 2 pixels instead of 1 -- about six hitches a second. A frame that
moves nothing followed by one that jumps two is exactly what micro-stutter
looks like.

It is worst at a crisp speed, which is the part that stings: at 100 px/s on a
100Hz panel the accumulator sits exactly on integer boundaries, so
sub-millisecond jitter flips it either way and the motion beats at around
50Hz. Snapping to the crisp ladder fixes the average and the wall clock then
throws away the per-frame uniformity the ladder was bought for.

So when scroll_config snaps to a crisp speed it now also puts the helper in
fixed-step mode: each presented frame advances exactly pixels_per_frame and no
clock is consulted. 100% of frames move by the same amount, whatever the
jitter.

This is only correct because SwapOnVSync blocks until the panel has taken the
frame, which makes the frame count a truer clock than time.time(). Before the
swap was locked to vsync it would have run at whatever speed the loop spun at.
Related: frame-based mode used to step discretely and was converted to
elapsed-time accumulation earlier in this series, because its threshold
comparison flipped on jitter. That was right for the code as it stood -- but
it treated the symptom, replacing a broken discrete step with a smooth-looking
accumulator instead of asking why a wall clock was involved at all.

Non-crisp speeds keep pacing off time, and set_scroll_speed() clears the fixed
step so a legacy caller changing speed is not silently ignored.

Trade-off worth naming: speed is now tied to the presentation rate rather than
to real time. If the loop cannot keep up with the panel the scroll runs slow
rather than jumping to catch up. That is the better failure -- uniform motion
at a slightly wrong speed beats correct average speed with a hitch six times a
second -- and a loop that cannot hit the resolved rate is a measurement
problem for the crisp ladder, not something to paper over with uneven steps.

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

* test(scroll): make the time-based pin actually pin something

Review caught that test_time_based_stepping_is_what_it_replaces could pass
against perfectly uniform motion, and it was right.

update_scroll_position sets last_update_time on its way through, so the very
first call sees a delta_time of zero and moves nothing in time-based mode.
_advances counted that synthetic frame, which put a guaranteed zero in every
histogram -- enough on its own to satisfy "uneven > 0". The test asserting the
defect exists would have passed after the defect was gone.

The first call is now primed and discarded, and the assertion is a proportion
rather than "more than zero": against these frame times the old path misses
roughly one frame in twenty, so 1% is well below the real rate and far above
anything a stray frame could produce.

Re-measured with the artefact removed, the numbers in the PR description are
unchanged: 5.85% of frames uneven before (114 zero-advance and 120 double
frames in 4000), 0.00% after.

Also fills in the docstrings the review flagged: everything in the new test
file, plus three pre-existing one-liners in scroll_config that the diff
touched.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:25:45 -04:00
ChuckandClaude Opus 5 1198615d19 test: install PyYAML so the starlark route tests can load the plugin (#546)
Tests has been red on main since #535. All 13 failures in
test/web_interface/test_starlark_pixlet_routes.py are the same
ModuleNotFoundError: No module named 'yaml'.

The test loads plugin-repos/starlark-apps/tronbyte_repository.py by path --
deliberately, "the way the blueprint does", since the core web blueprint
really does exec that plugin module -- and the plugin imports yaml.

Nothing is undeclared. The plugin's own requirements.txt already pins
PyYAML>=6.0.2, and on a real rig the plugin store installs it. CI installs
only requirements.txt and requirements-test.txt, so a core test that reaches
into a plugin gets none of the plugin's dependencies.

PyYAML goes in the test requirements rather than the core ones because it is
not a core dependency: nothing in src/ or web_interface/ imports yaml. This is
the same shape as the psutil entry directly above it -- a package the core does
not require, installed so a test can exercise a real path instead of a stub.

Verified locally: with yaml available the file goes from 13 failures to 64
passing. (One unrelated failure remains on Windows only, where os.geteuid does
not exist.)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:25:30 -04:00
ChuckandClaude Opus 5 f8e2e89edc refactor(sports): put the scoreboards on the shared scroll resolver (#542)
* refactor(sports): put the scoreboards on the shared scroll resolver

Eight sports scoreboards -- afl, baseball, basketball, football, hockey,
lacrosse, nrl, soccer -- scrolled through this module's own pacing while the
other eleven scrolling plugins went through src/common/scroll_config. Two
implementations of the same job, and this one was on the losing side of every
difference.

It never called set_scrolling_state. Two consequences, both of which this
release's work was about:

- The frame hold is applied through that call, so a speed the crisp ladder
  could render in whole pixels still presented a new frame every refresh.
- Core only runs deferred updates while nothing is scrolling. Believing
  nothing was, it ran blocking work in the middle of these scrolls.

The default is non-crisp today: scroll_speed 50.0 with scroll_delay 0.01 is
50 px/s, which on a 100Hz panel is half a pixel per refresh. That cannot
render as motion -- it alternates 0px and 1px steps and judders at a 50Hz
beat, on every scoreboard, out of the box. Resolved through the ladder it
stays 50 px/s and holds each frame for two refreshes: same speed, whole-pixel
motion.

The stepping disagreement that used to justify a separate module is gone.
scroll_config avoided frame-based mode because it stepped on a wall clock at
1/scroll_delay with scroll_delay set to the frame period, so the decision sat
on its own threshold and flipped on sub-millisecond jitter. That branch now
accumulates elapsed time, identical arithmetic to the time-based one, so the
two differ only in the units the speed arrives in.

What is NOT shared, and must not be: the two modules read identically-named
keys with different meanings. Here scroll_speed is px/SECOND and scroll_delay
only converts to px/frame; in scroll_config scroll_speed is px per STEP, so
px/s is speed/delay. Passing this module's settings dict to the resolver turns
50 px/s into 5000, clamped to 500 -- a tenfold speed-up everywhere. So
_get_scroll_settings keeps sole ownership of reading sports config, including
the league merging, and hands the resolver a plain px/s. A test pins that
specific number, because it is the mistake the refactor invites.

MIN/MAX_PIXELS_PER_FRAME are gone; the resolver bounds speed and the helper
clamps FPS. _resolve_target_fps stays, re-purposed: under the old model that
key was the rate frames were presented at, so it is the faithful translation
into the refresh the ladder is computed against, used when no hardware
refresh is configured.

Speed changes for panels that are not 100Hz: 50 px/s becomes 60 at 60Hz
(+20%) and 48 at 120Hz (-4%). At 100Hz it is unchanged.

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

* fix(display): drop the frame hold when a scroll times out, not just when it says so

set_scrolling_state(False) clears the hold. The other way a scroll ends is
is_currently_scrolling() deciding, after scroll_inactivity_threshold of
silence, that it is over -- which is what happens when the rotation moves on
mid-scroll or a plugin is torn down. That path cleared the flag and kept the
hold, so every later plugin, scrolling or static, was presented at refresh/N
by whoever scrolled last, until something called the explicit stop.

The method's own docstring already states the rule this breaks: the hold "must
not outlive the scroll that asked for it". The timeout was the exception it
did not cover.

Pre-existing, but reachable by three plugins before and eleven after the
sports scoreboards moved onto the shared resolver, so it belongs with that
change. The test ages the activity timestamp past the threshold rather than
sleeping.

Also adds scripts/sports_scroll_check.py. The sports scroll path is per-league
opt-in, so a rig showing static game cards never constructs a
SportsScrollDisplay and none of its pacing can be observed from a normal run
-- which is exactly what happened when this change was first put on hardware:
26 minutes, zero sports scroll lines. The script drives the path directly with
synthetic games and asserts the three things the resolver is meant to buy: the
speed lands on whole pixels, the hold is published, and it is released after.
It never starts or stops the display service, matching scroll_speeds.py, so a
crash here cannot leave the panel dark.

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

* fix(scripts): refuse to grab the panel while the display service has it

The module docstring already said to stop ledmatrix first. Nothing enforced
it, and running the script against a live service is not a harmless mistake:
rpi-rgb-led-matrix configures GPIO directions and the hardware PWM inside
RGBMatrix(), and when the root check fails it calls exit() from C with no
cleanup. The service keeps rendering and swapping onto pins that have been
reconfigured underneath it, so the panel goes black while every diagnostic
says the display is healthy -- fresh framebuffer, every pixel lit, "RGB Matrix
initialized successfully", nothing in the log. A restart fixes it, once you
work out that is what happened.

Found the hard way: this is what took the panel down on the test rig, not the
change the script was written to verify.

--fallback skips the check, since it never opens the matrix. --force is there
for anyone who means it.

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

* chore(scripts): annotate the subprocess call the way this repo already does

Codacy fails a PR on one new issue, and bandit B404 fires on any subprocess
import. scripts/run_plugin_tests.py carries the same suppression with the same
justification -- list-form argv, no shell -- so this follows it rather than
inventing a second convention.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:13:04 -04:00
ChuckandClaude Opus 5 4423ec33d5 feat(plugins): search, filter and sort for Installed Plugins, on a shared ListFilter helper (#540)
* feat(plugins): add search, filter and sort to Installed Plugins, on a shared helper

The Installed Plugins grid had no way to narrow it down: no search, no way to
see only what's enabled, disabled, or out of date. On a rig with a couple dozen
plugins that means scrolling the whole grid to find one.

The two sections below it already solved this, twice, independently — the
Plugin Store and Starlark Apps carried a copy-paste fork of the same ~600 lines
(filter state, apply-filters-and-sort, page renderer, pagination strip,
active-filter badge, listener wiring). Rather than add a third copy, this
extracts the shared machinery and builds the new toolbar on it.

New: web_interface/static/v3/js/plugins/list_filter.js — ListFilter.create()
owns debounced search, filter axes, sort, the active-filter count, Clear, and
optional pagination/persistence. Callers keep their own card markup via a
`render` callback. Three control types cover every axis the page uses: pills
(new), select (store category, starlark author) and cycle (the tri-state
All -> Installed -> Not Installed button).

Installed Plugins gets a compact toolbar: search box, one-click All / Enabled /
Disabled / Updates pills, and a sort dropdown (A-Z, Z-A, updates first,
recently updated, category). Filters reset on load, so you never come back to a
mysteriously short list. No new CSS — this is the first consumer of the
.filter-pill rules already sitting unused in app.css.

renderInstalledPlugins() is split so it still publishes canonical state while
renderInstalledCards() draws only the visible subset; the filtered list is
never assigned to window.installedPlugins, which the toggle handler,
isStorePluginInstalled(), runUpdateAllPlugins() and the Alpine config tabs all
read as their source of truth. Toggling a plugin while filtered pins its card
so it doesn't vanish from under the cursor.

The Store and Starlark migrations are behaviour-preserving: same element ids,
same localStorage keys (storeSort/storePerPage, starlarkSort/starlarkPerPage),
same tri-state button markup, same pagination. Verified by differential tests
that run the old and new implementations side by side against identical
fixtures and compare every observable after each interaction. The only visible
change is the pagination attribute (data-store-page/data-starlark-page ->
data-list-page), which nothing outside its own click handler referenced.

Net -156 lines in plugins_manager.js while adding a feature.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

* fix(plugins): keep raw search text, and stop the store search refetching

Two review findings from CodeRabbit on #540.

Do not write the trimmed search value back into the input. setSearch() trimmed
before storing, and syncControls() then copied that trimmed value back over what
the user had typed. Pausing longer than the debounce after typing a space
deleted the space (and reset the caret), making multi-word terms effectively
untypable. The raw text is now kept alongside the trimmed one: filtering and
activeCount() still use the trimmed value, while the input keeps exactly what
was typed.

Remove the legacy #plugin-search / #plugin-category listeners in
initializePlugins(). They bound searchPluginStore as the event handler, so the
DOM event arrived as its `fetchCommitInfo` argument — always truthy, which
skipped the cached-filter fast path and refetched /api/v3/plugins/store/list
with commit info on every keystroke burst and category change. The store's
ListFilter controller already filters the cached list, which is what those two
controls should do. This double-binding predates this PR (the old code guarded
with _listenerSetup and _storeFilterInit, two different flags, so both sets
stayed live); it is fixed here because the refactor owns that wiring now.

Both fixes are covered by tests that fail without them: the trailing-space
regressions in the installed-plugins DOM suite, and a new whole-file jsdom test
that counts fetches while typing (1 request at init, 0 thereafter; previously
1 -> 2 -> 3 -> 5).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

* fix(plugins): build pagination via DOM APIs, drop computed member access

Addresses the five Codacy security findings, all in list_filter.js.

Pagination no longer assembles an HTML string (3 findings: 2 critical + 1 high,
"unsafe assignment to innerHTML"). The interpolated values were only page
integers and local class constants, so there was no injection path, but
concatenating markup into innerHTML is the pattern the scanners flag and
createElement is no less clear. Each button now also owns its click listener
directly instead of the container being re-queried afterwards, and the strip is
cleared with textContent = '' rather than by assigning empty markup. No
innerHTML assignment remains in the file.

haystack() now walks Object.entries(item) and keeps the configured fields,
instead of reading item[field] per field ("generic object injection sink").
Field order no longer drives the haystack order, which is irrelevant to the
substring test. matches() iterates controls with for...of instead of an index
("variable assigned to object injection sink").

The rendered pagination is unchanged: same buttons, labels, page numbers,
disabled states and classes. The old-vs-new differential tests now compare
pagination structurally (tag, text, page, disabled, sorted class list) rather
than as an HTML string, since building nodes legitimately serialises
differently — «/» as characters rather than &laquo;/&raquo;, disabled="" rather
than a bare attribute. That comparison is stronger than the string one it
replaces, and the real-DOM suite still drives the actual page buttons.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

* fix(plugins): keep configured field order when building the search haystack

The previous commit swapped item[field] for Object.entries(item) to clear a
static-analysis object-injection warning, and in doing so changed the order of
the haystack: entries follow the object's own key insertion order, not the
configured `fields` order. Since the values are concatenated, that order decides
which values end up adjacent, so a multi-word query spanning a field boundary
matched differently. For store fields [name, description, author, id, ...] and
API objects keyed {id, name, description, author, ...}, "bob plugin-01" matched
before and stopped matching after.

That contradicted the behaviour-preservation claim for the store and starlark
migrations, and the differential tests missed it because every fixture query was
a single word.

Values now come out of a Map built from Object.entries, iterated in `fields`
order: the original haystack is restored, and there is still no computed member
access for the analyser to flag.

Regression coverage for the ordering itself, at both levels:
  - unit: phrases spanning name->id and category->tags, plus the reverse
    (object-key) order asserted NOT to match
  - differential: the same class of query compared old-vs-new, with a guard that
    the phrase actually matches something so a mutual zero-result cannot pass
    vacuously

Verified both fail without this fix (3 unit, 2 differential) and pass with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

* test(web): add JS suites for ListFilter and the plugin-manager grids

No JS toolchain exists in this repo, so these are plain node scripts with no
framework: each prints ok/FAIL lines and exits non-zero. `node test/js/run_all.js`
runs everything, skipping the DOM suites (rather than failing) when jsdom is
absent or nothing is listening, so it stays useful in a bare checkout.

  unit/test_list_filter.js    ListFilter search/filter/sort/count/sticky, driven
                              through the installed-plugins config eval'd
                              verbatim out of plugins_manager.js so the test
                              cannot drift from the real configuration
  unit/test_render_cards.js   renderInstalledCards markup, both empty states,
                              and escaping of hostile plugin metadata
  dom/test_installed_dom.js   the toolbar in a real DOM, including the HTMX
                              partial re-swap and a getComputedStyle check that
                              .filter-pill[data-active] matches what we emit
  dom/test_store_dom.js       store pagination, per-page, category, tri-state
                              Installed button, persistence across a re-boot
  dom/test_no_double_fetch.js loads the whole plugins_manager.js and counts
                              requests, so a keystroke cannot refetch the store

The DOM suites deliberately fetch the partial and the plugin data from a running
web interface instead of using fixtures, so a renamed element id or a changed
payload shape fails them loudly. Point them at a rig with a full plugin set when
it matters (BASE=http://host:5000); a dev box with two plugins installed passes
while exercising very little.

Several assertions exist to stop specific bugs recurring: trailing spaces
surviving the search debounce, a query spanning two adjacent search fields
(haystack field order is load-bearing), and window.installedPlugins staying at
full length while the grid is filtered. Others guard against passing vacuously —
counting only non-skeleton cards, and checking a search phrase matches something
before comparing two result sets.

The old-vs-new differential suites that verified the store and starlark
migrations are not included: they compared against the pre-refactor code, which
now exists only in git history.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 20:04:47 -04:00
ChuckandClaude Opus 5 26769ee37f fix(starlark): the store authenticated with a key nothing writes (#541)
#535 restored the thirteen routes, so the store stopped answering 404 --
and still would not load. Confirmed against a running device before
anything was changed: /repository/browse answers 200 with 1000 apps in
27s, so the routes are fine. Two things underneath them are not.

**The store never used the token the user configured.** The three
repository routes read `github_token` off config.json. Nothing writes
that key -- it is not in config.template.json, no setting offers it, and
it appears nowhere else in the codebase. The configured token goes to
config_secrets.json as `github.api_token`, which PluginStoreManager
loads and every other GitHub caller uses. So the store could never be
authenticated: 60 requests/hour, on the same per-IP budget 48 installed
plugins spend on update checks, while the 5000 the user had already
configured sat unused. On the device, /plugins/store/github-status
reported authenticated with a limit of 5000 at the same moment
/starlark/repository/browse reported 60, with 18 left. The store going
blank was that 60 running out.

**Every failure looked identical.** list_all_apps_cached turned any
listing failure -- rate limit, DNS, timeout, non-200 -- into an empty
app list, and the route sent that out as `status: success`, so a rate
limit and an empty repository drew the same blank grid with no error
anywhere. It now returns the reason, the route answers 502 with it, and
a failure is no longer cached as an empty repository for two hours.

The guard for a bad response was itself a crash: _make_request catches
`(json.JSONDecodeError, ValueError)` but `json` was never imported, so
evaluating the tuple raises NameError and the guard written for exactly
this case never ran. Reachable whenever something on the path answers
with HTML -- a captive portal, a proxy page, a DNS-hijacking router.

Seventeen handlers answered 5xx with no detail at all.
test_no_api_v3_handler_discards_its_exception is meant to prevent that
across api_v3, but it matched one exact message string, and all thirteen
Starlark routes wrote their own wording. The guard now keys on the shape
that matters: if it returns 5xx, it says why. The 15 pre-existing
non-Starlark functions are listed as a set that may shrink, never grow.

**The listing was capped at 1000 and did not say so.** The contents API
truncates a directory silently; tronbyt/apps has 1075 app directories,
so the store showed a truncated repository and looked complete doing it.
Now listed via the git trees API, which reports `truncated`, with the
contents API kept as a fallback.

Not addressed: the 27-second cold load -- 1075 manifests fetched five at
a time behind skeleton placeholders -- which is probably the largest part
of what "does not load" feels like, and wants its own change.

25 new tests.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 20:04:27 -04:00
ChuckandClaude Opus 5 968b953a51 fix(display): pin one text layout engine, and give the 5x7 BDF face a size (#539)
* fix(display): pin one text layout engine, and give the 5x7 face a size

Two ways a font could render differently on two machines running the same
code, both found while diagnosing four plugins whose golden images passed on
the machine that generated them and failed everywhere else.

**Layout engine.** `ImageFont.truetype` picks its engine at load time: Raqm
where the host Pillow was built with libraqm, Basic otherwise. The two round
fractional glyph advances differently. `PressStart2P-Regular.ttf` at 8px has
whole-pixel advances, so they agree — which is why most of the fleet matched
everywhere and hid this. `4x6-font.ttf` at 6px does not: glyph positions drift
cumulatively along a run, and the four plugins that draw body text in it
(geochron, of-the-day, christmas-countdown, ledmatrix-weather's almanac) are
exactly the four whose goldens travelled badly.

Every core font load now goes through `src/common/font_layout.load_truetype`,
which pins the Basic engine, so a render depends on the font file and the size
and nothing else. Basic gives up complex-script shaping and kerning pairs;
neither applies to bitmap-grid faces on an LED panel. Output is unchanged on a
host without libraqm.

**Zero font height.** `DisplayManager` built the 5x7 BDF face with
`freetype.Face(path)` and never called `set_char_size`, so `face.size.height`
stayed 0 and `get_font_height()` returned 0 for it — callers stacking rows by
`prev_y + prev_height + gap` drew two lines on top of each other. The
start-up line `Calendar font size: 0 pixels` has been printing the symptom all
along. `font_manager._load_bdf_font` already called `set_char_size`, so
whether measurement worked depended on which path loaded the face.

`DisplayManager` now sets it too, and `get_font_height()` falls back to the
strike the file declares rather than returning a zero line height.

Fixes ChuckBuilds/ledmatrix-plugins#397
Refs ChuckBuilds/ledmatrix-plugins#371, #375, #378, #391

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

* fix(display): give the startup banner a rung that fits a full address at 64px

CI caught what pinning the layout engine exposed rather than caused.
`_fitting_font` walks PressStart2P then 4x6 at 6px, and "255.255.255.255" --
the widest thing the startup banner ever shows -- measures 66px at 4x6/6px
against the 62 a 64x32 panel has to give. It used to squeak in only because
the measurement depended on which layout engine the host Pillow happened to
have; with the engine pinned it does not, so the rung the worst case actually
needs is now in the ladder instead of implied: 4x6 at 5px, which measures 51.

The fallback was wrong in the same place. When nothing in the ladder fit, it
returned `self.font` -- the *widest* option, and precisely how "Initializing"
came to run off the side of a 64px panel to begin with. It returns the
narrowest face that loaded now.

test/test_initializing_screen.py: 34 passed.

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

* fix(display): name the exceptions the BDF strike read can raise

Codacy flagged the try/except/pass. It was already narrow in intent -- a
malformed strike table on the measurement path must degrade to "size unknown"
rather than take the display down -- but a bare `except Exception: pass` says
neither of those things and hides a genuinely broken font behind a silent 8px
fallback. It now catches what reading `available_sizes` can actually raise and
logs which face failed.

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

* chore: drop logo PNGs the render harness downloaded into the worktree

These are fetched at runtime by the logo cache; they are not source, and they
rode in on a `git add -A` while I was running check_plugin.py against this
branch. Nothing in the change needs them.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 09:40:00 -04:00
ChuckandClaude Opus 5 e23f1f45d3 feat(starlark,on-demand): the third-party fixes worth taking, plus a Home Assistant MQTT bridge (#538)
* feat(starlark,on-demand): the third-party fixes worth taking, plus an MQTT bridge

Analysis of ant456/ledmatrix-fixes-repo, a third-party collection of
patches and services built while running this project on Starlark apps
under MQTT control. Its patches are whole-file copies taken against an
older tree, so applying them as written would revert #523's frame
pacing, #534's display() bool returns and the GitHub token masking in
plugins_manager.js. Three of its claimed fixes are already in main, and
its api_v3 Starlark routes are #535's. What follows is the rest --
verified against current code, and reimplemented where the patch's
approach did not hold up.

**On-demand display.** `pinned` reached the controller from the API, was
stored on it and republished in the status payload, but never narrowed
the rotation -- a pinned request still cycled every mode its plugin
owns. Right for a sports plugin, whose modes are views of one subject;
wrong for a plugin whose modes are unrelated, which is every Starlark
app. Now honoured, and it survives a restart.

Restarting while on-demand was active loaded *only* the on-demand
plugin, so normal rotation had nothing to return to for the life of the
process -- and a restart mid-session is routine, since that is how an
update is applied. The panel came back cycling one plugin's modes with
no way out but clearing the cache by hand. Every enabled plugin loads
now; on-demand still resumes on its saved mode.

Stop requests are exempt from the duplicate guards on purpose, so that a
second click stops a mode a race left running -- which means consuming
the mailbox is the only thing that ends one. It was never consumed, so
the same stop was re-read and re-processed on every poll, forever. Both
paths now share one compare-before-delete helper.

**Starlark rendering.** `extract_schema` parsed the source with a regex,
which can only see option lists written out literally: an app whose
dropdown is filled from a live API call inside `get_schema()` came back
empty, and the config form offered nothing to pick. Now runs `pixlet
schema`, which executes the app, and falls back to the parser when
Pixlet is absent, too old for the subcommand, or the app fails to run.
The third-party patch replaced the parser outright and hardcoded
/usr/local/bin/pixlet; this keeps the fallback and the binary search.

A `|` in a config value was dropped by a shell-metacharacter filter,
though the command is a list with no shell involved -- and apps do use
it as a separator inside one value. The key went missing silently and
the app rendered its own "not configured" screen with nothing to say
why. And a 0-byte render was reported as success: Pixlet exits 0 and
writes nothing when an app has no content, which read downstream as a
working app drawing a black panel.

**Starlark display.** `display()` ignored the mode it was called with,
so a specific app could not be addressed. It now accepts `display_mode`
-- which is the whole mechanism, since the controller inspects the
signature before passing it. Found while there: `_select_next_app` ran
only while `current_app` was unset, so with several apps installed the
first was picked once and shown forever while the rest were rendered on
schedule and never displayed. And `enable_scrolling` was missing, so
multi-frame apps were called once per rotation slot and never advanced
past frame one.

**GET /api/v3/display/modes.** Every mode that can be requested
on-demand, with the plugin that owns it. Nothing exposed this, so
anything driving the display from outside the web UI read each plugin's
manifest.json off disk and reimplemented PluginManager's fallbacks. It
also triggers discovery, which is otherwise lazy and normally happens
because a person opened the dashboard.

**integrations/mqtt_bridge.** Home Assistant control over MQTT
Discovery: a mode select, a stop button, power, brightness. Rewritten
against the API rather than the filesystem, so it needs no read access
to config.json and cannot drift from the web UI. paho-mqtt 2.x
VERSION2, TLS, an availability topic that is also the last will, and
secrets from the environment.

**Two opt-in extras.** A DNS single-request unit, for glibc's parallel
A/AAAA lookup stalling ~5s per name on routers that answer only the A
query -- which makes any plugin calling an external API slow and
Starlark apps, which have a render timeout, fail outright. And a Pixlet
config editor: a script you run and Ctrl+C rather than the third-party
version's always-on unauthenticated Flask service, since it stops the
display for the length of a session. Neither is installed by default.

Long Starlark app names now wrap instead of overflowing their card.

115 new tests across 5 files. Also unblocked
test_starlark_display_contract.py, which was silently skipping wherever
fcntl is absent. Whole suite: no new failures against main.

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

* fix(mqtt_bridge): the five issues Codacy flagged on this branch

All in the new bridge, all real:

  * requests floor was 2.31.0, which carries CVE-2024-35195,
    CVE-2024-47081 and CVE-2026-25645. Raised to >=2.33.0,<3.0.0, which
    is what the project's own requirements.txt already pins.
  * `import time` was never used.
  * `"mqtt_password": None` in DEFAULTS read as a hardcoded credential.
    It is the "no password configured" default; marked nosec B105, the
    convention used elsewhere in the repo.

Also dropped an unused `build_app` from the display-modes test imports.

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

* fix: the review findings on this PR

Nine of CodeRabbit's ten, plus the CodeQL alert. The tenth is wrong and
is answered below.

**One bad config section blanked the whole mode list.**
`/display/modes` read `full_config.get(plugin_id, {}).get('enabled')`,
so a non-dict under a plugin id -- a shape DisplayController already
guards, so it happens -- raised AttributeError mid-loop and answered 500
with no modes at all. Every MQTT bridge entity is built from that list.
Now skipped with a warning.

**The DNS scripts reported success they had not earned.** Three separate
paths: `resolvconf -u` failing was swallowed by `|| true`; the
systemd-resolved branch exited 0 without applying anything, so the
oneshot unit recorded success while the workaround was inactive; and the
installer's `|| echo` turned a failed start into "installation
complete." with exit 0. All three now fail loudly. `single-request` is a
glibc resolv.conf option with no resolved.conf equivalent, so on those
hosts the honest answer is that it cannot be applied.

A NetworkManager-generated resolv.conf is regenerated on connection
changes, not only at boot, and the unit is oneshot with RemainAfterExit
-- so the option can vanish mid-boot with nothing to put it back. Now
detected and stated plainly rather than implied to be permanent.

**`Before=` does not order a manual restart.** It only orders units
already in the same transaction, so `systemctl restart ledmatrix` could
bypass the fix. install_dns_fix.sh now writes a ledmatrix.service
drop-in with Wants= and After=. Wants=, not Requires=: a DNS workaround
failing should not stop the display.

**The Pixlet editor's `--lan` is gone.** `pixlet serve` has no
authentication, and a printed warning is not access control. Loopback
only, with the SSH port-forward in the header where the flag used to be
documented -- SSH does the authenticating and nothing is left listening.

**The MQTT example config now defaults to TLS** on 8883. The installer
copies it verbatim, and without TLS the broker password and every
command cross the network in cleartext. A plaintext broker is still
supported and documented, and the bridge warns once at startup when a
password is configured without TLS.

**Not taken: "the upstream Pixlet CLI has no `schema` subcommand."**
Upstream tidbyt/pixlet has none, but `scripts/download_pixlet.sh`
installs `tronbyt/pixlet`, whose `cmd/schema.go` is
`schema [PATH]` -> JSON on stdout, built on
`runtime.NewAppletFromPath`, so it does execute `get_schema()`. That is
exactly what extract_schema_via_pixlet calls. A binary without the
subcommand exits non-zero and falls back to the source parser, which is
already covered by a test.

**CodeQL stack-trace exposure: not taken either.** I removed `details`
first and that broke
test_web_error_detail.py::test_no_api_v3_handler_discards_its_exception,
which enforces `describe_exception` across all ~75 handlers -- written
because a device with failing storage answered "see logs for details"
from the log viewer itself. describe_exception redacts credentials; the
trade-off is the project's and is already made. Restored, with the
reasoning in a comment.

11 new tests. Whole suite: no new failures against main, 4127 passed.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 08:34:52 -04:00
ChuckandClaude Opus 5 793b988d33 fix(starlark): toggle the app id the list published, and store a relocatable star_file (#537)
The two review nitpicks left over from #535. Both are still on main after
that merge; the five findings alongside them landed with it.

**The toggle could not find what the list had just shown.**
`_starlark_virtual_plugins` publishes the raw manifest key as
`starlark:<key>`, and `_toggle_starlark_app` passed it back through
`_validate_and_sanitize_app_id`, which lowercases and rewrites every
character outside `[a-z0-9_]`. An app stored as `My-App` was listed as
`starlark:My-App` and looked up as `my_app`, so toggling an app the page
had drawn a moment earlier answered 404. Keys written by
`_install_star_file` are already sanitised, so this only shows up for
manifests written by the starlark-apps plugin itself or edited by hand.

`_validate_starlark_app_path` rejects traversal without rewriting, so it
is the check to use here -- listing and toggling now agree on one key.
The updater also uses `setdefault` rather than indexing: the app is
loaded but its on-disk entry need not exist, and `_update_manifest_safe`
does not catch `KeyError`, so that escaped as a 500 rather than writing
the entry.

**`star_file` was stored absolute.** Readers join it to the app's own
directory -- `_standalone_render_starlark_app` does `app_dir /
app_data.get('star_file', f'{app_id}.star')` -- so the key's default is a
bare filename and an absolute value gave it a second meaning. Since
`Path.__truediv__` discards the left side when the right is absolute,
the manifest was pinned to whatever PROJECT_ROOT installed it, and a
moved or redeployed install could not find its own file. Storing
`dest.name` matches the default and stays relocatable. Read paths are
unchanged, so manifests already holding an absolute path keep working.

7 new tests. Whole suite: no new failures against main, 4013 passed
against 4007.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 16:35:56 -04:00
Chuck 50258635a8 fix(starlark): restore the API routes #330 dropped (#535)
The Pixlet install button reported "Pixlet install failed: Resource not found" -- Flask's 404 handler, because the route did not exist. #253 added thirteen Starlark routes; #330 rewrote api_v3.py and dropped all of them, along with the `starlark:<app_id>` entries that surface installed apps in the plugins list and the toggle branch that enables them.

Restores all thirteen routes, the plugin-list entries and the toggle path, so Pixlet installs, the app store browses and installs, and an installed app can be managed like any other plugin.

Not a straight revert. Three error paths stopped returning exception text to the caller; the manifest write moved off a shared temp filename that two concurrent writers could interleave; both dynamic importers stopped leaving half-initialised modules in sys.modules; the config update rolls back when the save fails; the toggle checks that persistence succeeded; and the path check returns the validated path instead of a boolean so callers stop re-joining the raw value. New tests no longer reach GitHub.

Verified on a 256x64 Pi: Pixlet installs and runs (v0.53.1), the store lists 1000 apps, install/toggle/uninstall round-trip, and traversal and command-injection probes are rejected at every entry.

25 CodeQL alerts dismissed as verified false positives -- path-injection where traversal is blocked, and one list-form subprocess with no shell. Both classes already present on main.

Full core suite: 3981 passed.
2026-09-07 16:06:09 -04:00
ChuckandClaude Opus 5 c9289e3a1d fix(store): update_plugin silently did nothing for four installed plugins (#536)
install_plugin() deliberately renames a plugin's directory to the MANIFEST id
when it differs from the REGISTRY id, so registry `stocks` lands in
`ledmatrix-stocks/`. Every lookup in _find_plugin_path() is by directory name,
so update_plugin("stocks") found nothing, logged "Plugin not installed", and
returned False.

Nothing surfaced that to the user. Clicking update in the web UI was a no-op
with no error, and the plugin stayed on a stale version indefinitely. Four
installed plugins hit this on a real device -- leaderboard, music, stocks and
weather -- found because a scripted update of eleven plugins failed on exactly
those four.

Adds a manifest-id scan as the LAST step of the resolution chain, so the two
documented lookups above it (configured dir, then the sibling plugins/
fallback) keep their exact meaning and ordering. That ordering is pinned by
test_discovery_path_contract.py, which characterises the divergence between
the three resolvers on purpose; this extends the chain rather than reordering
it. Directories renamed aside with '.standalone-backup-' during an install or
rollback are skipped, since matching one would report a half-finished install
as a live plugin.

Also adds scripts/audit_render_path.py, which walks the call graph from
display() and reports blocking calls reachable from it. display() runs on the
render thread, so anything slow there stalls the panel; on a vsync-paced loop
a single 15ms call drops a frame and a network round trip freezes the marquee.
Two instances were already found the slow way, by reading frame-time
histograms -- odds-ticker reading the scoreboard cache per frame, and
soccer-scoreboard timing out inside update(). The audit finds that shape in
the source instead. It is a heuristic and says so: a hit behind an interval
check may be fine.

It currently flags 23 calls across six plugins. The clearest is
ledmatrix-music, whose display() falls back to an inline
requests.get(timeout=5) when album art has not been prefetched -- a deliberate
"show the art rather than go blank" tradeoff by its author, but up to five
seconds of frozen panel. Reported, not changed; that is its owner's call.

185 store tests pass. Three of the six new tests fail without the fix.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 15:55:46 -04:00
ChuckandClaude Opus 5 d12323e7f1 perf(scroll): pace frames to the panel — 44→100 fps, stalls 14% → 0.02% (#523)
* perf(scroll): pace frames to the panel, not to a fixed sleep

Scrolling ran at 44-46 fps on a 2x128x64 chain and 14-17% of frames took
41-53ms, which reads as judder. Four independent causes, each measured on
the hardware; details and the diagnostic recipe are in
docs/SCROLL_PERFORMANCE.md.

The high-FPS loop slept a flat 8ms after every render. display() has
already blocked on the panel's vsync by then, so that sleep was added to a
wait that had happened: ~4ms of render plus 8ms put each iteration at ~12ms
against a 10ms refresh grid, so every swap missed a refresh and the loop
settled at 50fps while asking for 125 -- with no headroom, so a further
14% of frames slipped again. It now sleeps only the remainder, with a 1ms
floor so plugin threads still get the GIL.

ScrollHelper stepped position on a wall clock at 1/scroll_delay steps per
second. Plugins set scroll_delay to the frame period, so that comparison
sat exactly on its own threshold: a frame arriving a hair early moved zero
pixels and rendered an identical frame, dirty-tracking skipped the swap, it
returned in ~2ms, and the beat repeated. No scroll_delay value tunes that
out -- a shorter delay trades stalled frames for periodic double-steps.
Both modes now accumulate elapsed time at the same configured speed, so
position stays proportional to real time.

Sub-pixel blending goes back to off by default. It renders a half-step by
mixing two adjacent columns, which on a coarse panel showing pixel-font
text alternates crisp and smeared frames and reads as shimmer -- visibly
worse than integer stepping on the hardware. Vegas mode still opts in.

disk_cache uses orjson when importable, falling back to the stdlib. Encoding
a ~1MB record drops from 14.8ms to 5.4ms end-to-end, and that work holds the
GIL while a marquee is on screen. display_manager also checksummed the whole
framebuffer twice per frame (dirty tracking, then the preview snapshot); the
snapshot now takes the checksum the caller already computed.

New src/common/scroll_config.py resolves scroll settings in one place. Five
ticker plugins each hand-rolled this and disagreed: odds-ticker ranked the
deprecated scroll_pixels_per_second above the documented scroll_speed/delay
pair, and because that key carries a schema default the documented settings
were dead for every user (ChuckBuilds/ledmatrix-plugins#408), while
ledmatrix-leaderboard read the same key only as a fallback. The resolver also
warns when a speed will not advance a whole number of pixels per refresh,
which is the property that actually determines whether a scroll looks smooth.

scripts/build_rgbmatrix_nogil.sh rebuilds the rgbmatrix binding so it
releases the GIL. Upstream declares SwapOnVSync without nogil, unlike
SetPixel/Clear/Fill beside it, so the render thread held the GIL for the
whole vsync wait and starved background threads into long uninterruptible
bursts. The script patches, builds and self-verifies into a scratch tree;
--install backs up the original and rolls back if the service does not come
back healthy.

Measured after: 100 fps locked, no stalls observed, render thread down from
51% to 19% of one core.

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

* fix(display): keep the panel swap locked to vsync while scrolling

Dirty tracking skipped SwapOnVSync for byte-identical frames. That is the
right call for static content, but SwapOnVSync is also what paces the render
loop, so skipping it skips the wait for the panel: a duplicate frame returns
in ~8ms instead of ~10ms on a 100Hz panel, advances the strip only 0.8px
instead of 1.0px, and so makes the next frame more likely to repeat as well.
The effect sustains itself once it starts.

Measured over 20 minutes on a 2x128x64 chain, both scrollers configured
identically at 100 px/s:

    leaderboard   10ms x35, 11ms x3            (clean)
    odds-ticker   10ms x26, 8ms x7, 15ms x5    (~20% duplicates mid-scroll)

The duplicates were not end-of-cycle idling -- 38% of fast frames fell within
90s of a scroll completion against 35% of normal frames, a null result. The
trigger is per-frame work: odds does more of it, and more variably, so it is
first to land a frame that advances less than a whole pixel.

Pushing an identical frame costs one canvas copy. Falling out of vsync lock
costs smooth motion. Static content is untouched, because
is_currently_scrolling() expires on its own inactivity threshold -- covered
by test_stale_scrolling_state_stops_forcing_pushes so a plugin that stops
scrolling without saying so cannot pin the panel into always-push.

Also de-flakes test_snapshot_still_written_on_skip, which asserted a strict
mtime increase between two writes that can land in the same filesystem tick;
it failed about two runs in three on Windows regardless of the code under
test. The file is now backdated before the check.

156 tests pass on the Pi. Not yet confirmed by eye on the panel.

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

* fix(scroll): report the frame-time tail, and stop the row-major blit

Two problems, both found by looking at the panel rather than the metric.

The frame-stats line reported ONE instantaneous frame every 5 seconds --
about 1 frame in 500 -- printed beside a 100-frame average. Both hide exactly
the fault they are used to chase: a 2ms duplicate and a 21ms double-wait
average to precisely 10ms, so a ticker stalling on half its frames still
reports a healthy "Avg FPS: 100.0". That reading cost several rounds of
chasing the wrong layer. The line now aggregates every frame since the last
log and reports median, p95, max, min, and explicit stall and skip rates
(past 1.5x the median missed a refresh; under half never reached the panel,
because dirty tracking skipped the swap so the frame never waited on vsync).

On the hardware this now reads:

    leaderboard  100.0 fps over 501 frames | median 10.00ms p95 10.05ms
                 max 10.34ms | stalls 0 (0.0%) skips 0 (0.0%)

The binding rebuild's blit patch becomes opt-in (RGB_PATCH_BLIT=1, default
off). Reordering that loop to row-major changes what a torn frame looks like:
column-major tearing shows as a vertical seam, row-major as a horizontal split
between the panel's upper and lower halves. On a 1/32 scan panel that reads as
a one-pixel fold across the middle of every panel, which is what was reported
on hardware and what went away when the blit was reverted. All of the measured
gain comes from the SwapOnVSync change, so the risky half is simply not worth
taking; the header says so.

Also fixes --install resolving its paths against $HOME, which is /root under
sudo, so it looked in /root/rgbmatrix-nogil-build and died with "no built
module found" on a machine where the build had just succeeded. It now resolves
SUDO_USER's home. Both build paths are verified on the Pi: default yields one
GIL-release site, RGB_PATCH_BLIT=1 yields two.

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

* feat(scroll): let users pick a crisp speed for their own panel

Whole-pixel motion was previously only available at multiples of the refresh
rate -- 100, 200, 300 px/s on a 100Hz panel. 100 px/s crosses a 256px panel in
2.6s, which is brisk for reading, and everything slower had to blend (blur) or
repeat frames unevenly (judder). There was no way to ask for 50 px/s and get
clean motion.

SwapOnVSync takes a framerate_fraction the display manager never passed. It
holds each frame for N panel refreshes; the panel keeps refreshing at its full
rate throughout, so holding costs nothing in flicker and only changes how often
a NEW image is presented. That turns 50 px/s into one whole pixel every second
refresh instead of half a pixel every refresh.

The crisp speeds are therefore refresh_hz / hold * pixels_per_frame, and that
ladder depends on the panel: a Pi Zero on a long chain has a different set of
good speeds from a Pi 4 on a short one. crisp_ladder() enumerates them and
solve_crisp() picks the best match for a requested speed.

solve_crisp weights motion quality rather than picking the numerically nearest
entry, which matters more than it sounds. Asked for 30 px/s, nearest-by-value
answers 28.6 -- 2px jumps at 14fps -- over 33.3, which is single-pixel motion
at 33fps and obviously better on the panel. The target is also clamped into the
ladder's range first, because relative error saturates near 1.0 for a target
far outside it and the quality penalty would otherwise answer "10000 px/s" with
the slowest entry.

configure() snaps to the ladder and applies the hold when given a display
manager. Without one the hold silently cannot happen and motion falls back to
fractional pixels, so it warns rather than failing quietly. set_frame_hold()
resets to 1 when scrolling stops, so one plugin's pacing cannot leak into
whatever is on screen next.

scripts/scroll_speeds.py is the user-facing part: it prints the ladder for the
configured rate, measures what the panel ACTUALLY manages (--measure, for
hardware that cannot reach its configured limit), highlights the nearest option
to a wanted speed, and demos one live. It never starts or stops the display
service itself -- doing that inside a script stranded the panel twice today.

Speeds below ~20 px/s remain stepped regardless. That is the pixel pitch, not a
software limit.

Also fixes the dirty-tracking test spy, which stubbed SwapOnVSync with a
single-argument function and would have masked the new call as a failed push,
and rewrites a configure() test that had started passing for the wrong reason:
it asserted a judder warning, which snapping now prevents, and was matching the
unrelated "hold could not be applied" warning instead.

183 tests pass on the Pi.

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

* fix(scroll): tie the frame hold to the scroll, not the plugin

The hold applied in configure() never reached the panel. Plugins share one
display manager, and set_scrolling_state(False) -- fired whenever ANY other
plugin finishes its scroll -- reset the hold to 1. A hold set once at plugin
construction was therefore always gone by the time that plugin rendered.

The symptom was a log line that lied. ledmatrix-stocks reported

    Scroll configured: 50.0 px/s (1px every 2 refreshes = 50.0 fps, smooth)

while the panel measured 100.0 fps, median 10.00ms. Config, resolution and
snapping were all correct; only the pacing silently was not applied.

set_scrolling_state(is_scrolling, frame_hold=1) now carries it, so the hold
lives exactly as long as the scroll that asked for it. configure() reports the
value as ScrollSettings.frame_hold instead of applying it -- applying it behind
the caller's back could never have been right on a shared display manager.
Existing callers are unaffected; the default keeps one frame per refresh.

Verified on hardware: stocks at 50 px/s now measures

    50.0 fps over 251 frames | median 20.00ms p95 20.09ms | stalls 0 skips 0

20.00ms being exactly two refreshes, with the panel still refreshing at 100Hz
underneath so flicker is unchanged.

test_another_plugin_stopping_does_not_strand_a_hold pins the interaction that
broke this.

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

* fix(scroll,cache): resolve CodeRabbit review on #523

Eight findings, all reproduced before fixing.

scroll_config.configure() read the refresh rate *after* resolve() had
already used it. resolve() fills in target_fps, pixels_per_frame and the
judder warning from that rate, so on a 60Hz panel every one of them
described 100Hz -- and with snap_to_crisp=False nothing downstream
corrected it, so set_target_fps() paced the helper to 100 FPS. The rate
is now settled first, and falls back to the global config rather than
straight to the default.

refresh_hz_from_config() used `(cfg.get("display") or {}).get(...)`,
which raises AttributeError when either level is truthy but not a
mapping -- out of a function whose whole contract is a rate or a default.

The frame-stats line reported the upper-middle sample as the median and
the 96th sorted sample as p95 of 100. Both are also thresholds (stalls
at 1.5x the median, skips at 0.5x), so the counts were biased too. The
arithmetic is now in frame_stats()/format_frame_stats(), testable
without a clock.

configure()'s docstring and docs/SCROLL_PERFORMANCE.md still said it
applies the frame hold and warns when it cannot. It deliberately does
neither since "tie the frame hold to the scroll, not the plugin"; a
caller following the old text would omit set_scrolling_state() and slow
snapped speeds would still present every refresh.

disk_cache had no policy for non-finite floats: orjson writes null,
the stdlib writes NaN/Infinity, and orjson then rejects those legacy
files so DiskCache.get deleted them as corrupt. One behaviour on both
paths now -- write null, keep legacy records readable. allow_nan=False
detects the values; the replacement walk runs only when there is one,
so the ordinary write path is byte-identical and pays nothing.

build_rgbmatrix_nogil.sh picked the build artifact with a glob piped to
`head -1`, which sorts cpython-311 ahead of cpython-313, so a stale .so
staged in from the source tree was installed as core.so while the GIL
check -- which reads the generated core.cpp, not the .so -- still passed.
It now requires the current interpreter's exact ABI name and fails
closed. Its systemctl calls were also unchecked under `set -uo pipefail`:
a failed stop left the old service running, the following start
succeeded as a no-op, and the health check reported SUCCESS for a
binding that was never loaded.

orjson floor raised to 3.11.6 for CVE-2025-67221 (unbounded recursion
in dumps); it covers the project's Python 3.10-3.13 range.

Adds test/test_cache_nonfinite_floats.py (14) plus regression tests in
test_scroll_config.py and test_scroll_helper.py. 9 of the cache tests
and 9 of the scroll_config tests fail against the pre-fix code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

* test(harness): keep the visual double's signature tied to production

Moves set_scrolling_state's frame_hold into the test double here, where
DisplayManager gains it, rather than in #534 where it arrived a PR early.
CodeRabbit flagged the #534 version correctly: a double that accepts an
argument production does not lets the call pass every harness run and
raise TypeError on the panel, which is the one failure a safety harness
exists to prevent.

The drift has now gone both ways across two branches -- double behind
production on this branch, double ahead of it on #534 -- so it is pinned
instead of remembered. test_display_double_parity.py compares the two
signatures and fails with the direction of the drift named. It reads the
files with ast rather than importing them, because display_manager
imports rgbmatrix at module scope and this check should hold on a laptop
and in CI as well as on a Pi.

Plugins begin passing frame_hold in ledmatrix-plugins#462, which is why
production and the double both need it before that lands.

Full suite: 3889 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 13:37:54 -04:00
ChuckandClaude Opus 5 a0d3e64099 fix: ten defects found validating the whole plugin fleet on hardware (#534)
* fix(core): register tom_thumb, accept frame_hold in the test double, wire api_v3's managers

Three independent fixes found while validating every plugin on a 256x64 rig.

FontManager never registered tom_thumb even though assets/fonts/tom-thumb.bdf
ships with the core, so every plugin offering it logged "Font family
'tom_thumb' not found" (16 warnings per countdown render) and had to carry a
private loader to use a bundled font. Closes #524.

VisualTestDisplayManager.set_scrolling_state() lacked the frame_hold parameter
that DisplayManager gained, so any plugin passing it died with TypeError at
render time and failed every size. Nine plugins now make that call;
ledmatrix-stocks and ledmatrix-leaderboard were failing outright and the other
seven only passed because their scroll path was unreachable without data.
Closes #525.

api_v3 declared module-level config_manager/plugin_manager = None that nothing
ever assigned -- app.py sets the blueprint attributes, which the other 150+
call sites use. Three sites read the decoys, so /health reported the config
unreadable and the plugin system uninitialised (making "degraded" permanent and
unreachable-by-design) and /display/current fell back to a hardcoded 128x64 on
every rig. The decoys are removed rather than assigned, so a bare name is now a
NameError at test time instead of a silent None. The same function's first-call
uptime was computed from two separate clock reads and came out negative.
Closes #529.

Verified on the rig: both previously-failing plugins render, the tom_thumb
warnings are gone, /health reports "healthy" with all three checks passing, and
/display/current reports the real 256x64.

* fix(core): unique snapshot temp name, honour on-demand requests, skip empty starlark

The preview snapshot wrote through a fixed "<snapshot>.tmp". /tmp is
world-writable and sticky, and the display service runs as a different user
from the tooling, so a leftover temp owned by anyone else became unopenable
even by root -- fs.protected_regular refuses O_CREAT on a foreign file in a
sticky directory. The preview and the health check's liveness proxy then froze
until someone deleted the file by hand; on the test rig that meant 23 hours of
a healthy display reporting "hardware: stale". Now uses tempfile.mkstemp with
cleanup on failure, matching the hardware-status write a few hundred lines
above. Closes #528.

_poll_on_demand_requests read its mailbox with max_age=3600, and get() defaults
the in-memory TTL to max_age -- so the first request was pinned in memory for an
hour and every later poll returned that stale copy. No second on-demand request
was honoured until the service restarted, while the API kept returning 200.
get() already documents memory_ttl=0 for exactly this cross-process case.
The consumed request is also now deleted: leaving it on disk meant a restart
replayed the previous request, activated it, and ignored the one the caller had
just made. Closes #530.

starlark-apps returned None from display() when it has no app to show, which is
the state of every install without Pixlet and of a fresh one before any app is
added. The controller only skips on a boolean False, so that held a black panel
for the full display_duration instead of rotating on. Closes #456 (core side).

Verified on the rig: two consecutive on-demand requests with no restart between
them are both activated, where the second was previously dropped in silence.

* perf(harness): share one cache across a plugin's renders

_instantiate built a fresh MockCacheManager for every (size, mode), and that
mock is a per-instance in-memory dict, so each render was a cold start. A plugin
that fetches per game or per player re-fetched everything N times over --
baseball-scoreboard at one size took 840s for nine renders where the arithmetic
said ~72s, and at eight sizes it exceeded a 900s timeout.

The second and later renders also never exercised the cache-hit path, which is
what a running rig executes almost all of the time, so a caching regression
could not be caught here.

The cache is now built once per render_plugin_matrix call and threaded down.
The display manager stays per-render -- the bounds checking depends on that --
so only fetched data is shared.

Measured on the rig, same render counts and same goldens:
  tide-display         2s -> 1s   (32 renders)
  cricket-scoreboard  10s -> 3s   (24 renders)
No pass/fail change across tide-display, cricket-scoreboard, clock-simple,
geochron, christmas-countdown, of-the-day, web-ui-info and incoming-packages.

Closes #533.

* fix(scripts): run standalone plugin tests instead of collecting nothing

run_plugin_tests.py discovered every plugin test file and handed the lot to
pytest. Most plugin tests are standalone scripts -- module-level main() plus an
`if __name__ == "__main__"` guard, signalling through an exit code -- and pytest
collects zero items from those. The run printed how many files it had *found*,
then "no tests ran", and exited without executing any of them. On a rig with all
44 first-party plugins that is 151 of 248 files.

Files are now classified and each kind runs under the right runner: pytest for
real test modules, subprocess for scripts, honouring the 0 pass / 2 skip / 1
fail convention ledmatrix-plugins' own runner established (a script that wants a
tty or an LED matrix is a skip, not a regression).

Before:
    $ python3 scripts/run_plugin_tests.py -p countdown -d ~/LEDMatrix/plugin-repos
    Found 1 test file(s)
    collected 0 items
    no tests ran in 0.31s                      rc=0

After:
    Found 1 test file(s) -- 0 collectable, 1 standalone script(s)
    1 passed, 0 skipped, 0 failed (scripts)    rc=0

Verified across three shapes: countdown (1 script), jellyfin-now-playing and
pomodoro-timer (pytest only, 16 and 42 tests), and ledmatrix-flights (11 files
split 4 collectable / 7 scripts, all seven of which had never run).

Closes #532.

Running the flights scripts for the first time also surfaced four genuinely
failing tests there, hidden by the mirror-image bug in the plugins repo's own
runner -- filed as ChuckBuilds/ledmatrix-plugins#464 and #465.

* fix(harness): give an empty-looking mode a few frames before warning about it

check_plugin's "drew nothing but display() returned X" warning fired on a single
frame, rendered with force_clear=True, under a frozen clock. All three defeat a
scrolling plugin, whose first frame is legitimately its blank scroll-in buffer.
Across 44 first-party plugins, 60 of 76 warnings were false -- the rate at which
people stop reading a warning, which matters because the true positives are
real: a mode that draws nothing and does not return False holds a blank panel
for its whole display duration.

An apparently-empty frame is now re-driven for up to 48 more frames with
force_clear=False (force_clear means "reset the scroll", so repeating it would
redraw frame 1 for ever) and with the clock advancing -- freezegun's factory
where time is frozen, a real sleep where it is not, since scroll position is
usually a function of elapsed time. The first frame that draws content replaces
the result.

The clock is moved back afterwards. It is shared by every render in the matrix,
so time borrowed by the probe leaked into later modes and drifted their goldens
-- f1_upcoming picked up 5 spurious drifts before this was restored.

Measured on the rig:

                        empty warns          check
                        before  after
  f1-scoreboard            42      0    48 PASS / 0 FAIL, goldens intact
  ledmatrix-elections      16      0    16 PASS / 0 FAIL
  on-air                    8      8    true positive, kept
  nfl-draft                 8      8    true positive, kept
  clock-simple/geochron/    0      0    unchanged
  christmas-countdown

58 false positives gone, both true positives kept, no golden regressions. Cost
is confined to modes that really are blank: plugins that draw immediately are
unchanged (clock-simple and tide-display still 2s), while on-air -- eight
deliberately blank modes -- goes to 21s.

Closes #527.

* fix(harness): load nested schema defaults, and merge caller config at leaf level

load_config_defaults read only top-level properties. An object property carries
its defaults on its children, not on itself, so everything nested was dropped --
2,386 defaults across 37 of 44 plugins, soccer-scoreboard alone losing 539 of
565. render_plugin_matrix's comment says the plugin then "behaves like a real
install", which for most of the fleet it did not.

_defaults_from_properties now recurses. merge_config deep-merges the caller's
config onto the result so an override lands at the leaf: a shallow merge would
let -c '{"nhl": {"enabled": true}}' replace the whole nhl subtree and discard
every other nhl default, which is the same class of bug being fixed here.

Measured before/after across all 49 installed plugins on the rig: **no render
changed** -- identical PASS/FAIL counts, byte-identical output, goldens intact.
Plugins already fall back to the same values internally via config.get(key,
default), so supplying them explicitly agrees with what they were doing. The
defaults really are arriving now:

  ufc-scoreboard        9 -> 87 defaults
  ledmatrix-flights    51 -> 95
  masters-tournament   10 -> 51
  cricket-scoreboard   22 -> 50
  tide-display         12 -> 18

and hockey-scoreboard, which used to load nhl.enabled=None, now gets
nhl.enabled=True with its full display_modes block.

Caveat worth carrying: the eight plugins with the most nested config
(soccer, baseball, basketball, hockey, lacrosse, football, afl, nrl -- 1,634 of
the 2,386 dropped defaults, 68%) could not be measured. They import
src.common.sports_shared, which the test rig's core branch predates, so they
fail to load there identically before and after. Re-run this comparison against
a core that has that module before trusting the "nothing changed" result for
them; those are exactly the plugins whose renders should change most.

Closes #531.

* refactor: narrow the exception handlers this branch introduced

Codacy flagged the new code; it passes on other recent PRs, so the finding is
mine. Four of the five broad `except Exception` clauses I added were catching
far more than they needed to, which is the same shape as several bugs this
branch fixes -- hello-world's TypeError sat invisible for exactly this reason.

  freezer() / move_to() / tick()   -> (AttributeError, TypeError, ValueError)
  cache_manager.delete()           -> (OSError, AttributeError, KeyError)

The fifth stays broad and now says why: it wraps a call into a plugin's own
display(), which can raise anything, and the first frame has already rendered --
so a failure there must not turn a good result into an error.

Verified against a checkout of main: f1-scoreboard 48 PASS / 0 FAIL with 0 empty
warnings, on-air keeps its 8 true positives, clock-simple 8 PASS. geochron shows
7 golden drifts both before and after this branch, so it is not from these
changes -- its committed goldens predate #521's 1-bit text rendering.

* fix: resolve CodeRabbit review and Codacy findings on #534

CodeRabbit raised six; all six were real.

The test double had drifted ahead of production. VisualTestDisplayManager
accepted set_scrolling_state(frame_hold=...) while DisplayManager did not,
so such a call passed every harness run and would raise TypeError on the
panel -- the one failure a safety harness exists to prevent. frame_hold
belongs to the change that adds it to DisplayManager (#523), so it moves
there and the double matches main again.

The harness swallowed exceptions from re-rendered frames. _settle_loop
re-renders a mode that came back blank, to give a scroll time to draw;
returning silently on a crash meant a mode that renders one good frame
and then explodes was reported as passing. Recorded on result.error now,
keeping the captured frame so the failure stays inspectable.

starlark-apps display() returned True after _display_frame() failed, so
the controller held a dead frame for the whole display_duration instead
of rotating on. _display_frame now returns bool on all three paths.

run_plugin_tests.py used env.setdefault for PYTHONPATH and
LEDMATRIX_CORE, so an inherited value won and the subprocess imported a
different core than the one under test -- ledmatrix-plugins#467 exactly.
Prepends PROJECT_ROOT and sets LEDMATRIX_CORE unconditionally.

The on-demand mailbox is polled after every frame, ~125x/second on a
scrolling mode, and the read is deliberately uncached, so it was that
many disk reads per second to find nothing. Floored at 250ms, which is
imperceptible for a web-UI click. Consuming it also deleted whatever was
present rather than what had just been processed, so a request posted
while the previous one was in flight was thrown away and never ran; the
delete is now keyed by request_id. That narrows the window rather than
closing it -- a true atomic claim needs a primitive the cache layer does
not offer, and the code says so rather than implying otherwise.

Codacy's 2 criticals were bandit B404/B603 on the subprocess call added
to run_plugin_tests.py. Fixed interpreter, argument list, no shell;
annotated with the repo's existing nosec convention. Bandit is clean on
the file.

Adds test/test_on_demand_mailbox.py (8), test_starlark_display_contract.py
(4) and two settle cases in test_harness_empty_claimed.py. 4, 4 and 2 of
those fail against the pre-fix code. Full suite: 3961 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

* chore: satisfy Codacy's subprocess checks on the new test runner

Codacy runs Bandit and Opengrep (its Semgrep fork). The new
subprocess.run in scripts/run_plugin_tests.py trips three patterns, on
two different lines:

  Bandit   B404 on the import, B603 on the call
  Opengrep dangerous-subprocess-use-audit          on the run( line
           dangerous-subprocess-use-tainted-env-args on the argv line

A nosemgrep applies only to its own line, so the call line and the argv
line each need one; a single comment on the call covered neither rule
fully. Suppression is the right answer here rather than a rewrite: the
interpreter is sys.executable, the arguments are a list, and no shell is
involved, so there is nothing to word-split or expand.

Matches the pair the rest of the repo already uses for this shape --
permission_utils.py, plugin_loader.py, install_dependencies_apt.py.

Codacy: 0 new issues, up to standards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

* chore: leave visual_display_manager untouched so #523 can merge

The only change this branch made to that file was a docstring, and it
collided with #523's rewrite of the same method -- so #534 and #523 each
merged cleanly against main but conflicted with each other. Reverted to
main's text; #523 owns this method and adds frame_hold to it.

The note the docstring carried ('frame_hold arrives in #523') would have
been stale the moment #523 landed anyway. The parity test in #523 is
what actually keeps the two signatures honest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

* chore: add the Ruff suppression nosec/nosemgrep do not cover

Ruff reports S603 on the same call Bandit and Opengrep do, and none of
the three suppressions covers the others. Confirmed the precondition
first: path comes from discover_plugin_tests(), which globs test files
inside the repo, and the call is a fixed interpreter with a list argv
and no shell.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 13:37:38 -04:00
ChuckClaude Opus 5coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
696acdbc7b feat(render_plugin): add --display-mode so multi-mode plugins can be rendered (#522)
* feat(render_plugin): add --display-mode so multi-mode plugins can be rendered

render_plugin.py always called plugin.display(force_clear=True) with no mode.
A plugin that declares one display mode is fine, but the sports scoreboards
declare three or more and keep their per-mode state on sub-managers; their
no-argument path selects nothing and returns False, so the render came out
blank with nothing to say why. Measured on nrl-scoreboard with identical
seeded state:

  live.display() directly                 True,  1892 lit pixels
  plugin.display(display_mode="nrl_live") True,  1892 lit pixels
  plugin.display()                        False,    0 lit pixels

--display-mode passes the requested mode through. It is only passed when
asked for, so the many plugins whose display() takes no display_mode keep
working untouched, and a plugin that declares modes but does not accept the
argument degrades to its default screen with a warning rather than a
TypeError.

This is what lets the plugin READMEs show a scoreboard at all, and it also
unblocks screens like birdnet_stats and the weather plugin's hourly, daily and
almanac modes, which could previously only be described in prose.

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

* Only fall back when plugin display rejects display_mode

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-09-06 17:28:22 -04:00
ChuckandClaude Opus 5 91d15a8943 fix(display): draw text 1-bit, so glyphs stay crisp on the LED grid (#521)
An LED panel has no partial brightness. PIL defaults ImageDraw's fontmode to
"L", which anti-aliases TrueType glyphs into a grey fringe the panel can only
round off -- a 4px glyph arrives smeared into 3px.

DisplayManager creates its shared `draw` in six places and set fontmode at
none of them, while _load_fonts loads extra_small_font as 4x6-font.ttf at
size 6. Measured at draw time, that face at that size puts 74% of its lit
pixels at partial coverage. Every plugin drawing small text through the
shared draw inherited the blur; geochron was the case that surfaced it.

The harness's VisualDisplayManager had the same gap, which mattered more than
it looks: goldens were recording anti-aliased text that production would not
produce, so the harness could not have caught this. Fixing only production
left geochron still blurry under the harness -- that is how the second site
was found.

Both are set to "1" so the harness renders what the panel renders.


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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 16:02:04 -04:00
Chuck 6bea1a7c21 fix(sports): say when the schema cannot be read, instead of failing silently (#520)
_schema_font_size swallowed every exception and cached an empty dict. That is
not cosmetic. With no schema, a configured font size can no longer be compared
against the schema default, so every size is treated as a deliberate user
choice and skips the snap to the font's pixel grid -- which renders
4x6-font.ttf at 6 instead of 7: a 3px-wide glyph instead of 4px.

That shipped. On a 256x64 panel it made the odds, the team records and the date
row hard to read, and it was found by a user counting pixels on a photo of the
panel rather than by anything here. The cause (_plugin_dir returning None under
the real plugin loader) is fixed in #519; this makes the same class of failure
audible next time:

    Orphan: could not read config_schema.json (FileNotFoundError: ...); every
    font size will be treated as user-chosen and will skip its pixel grid
    snap. Font sizes may render a pixel narrow.

The message names the consequence, not just the error, because the error alone
does not suggest "your fonts are a pixel narrow".

Logged rather than raised: an unreadable schema must not stop a plugin
rendering. The cache is built once per class (per schema path in sports_card),
so this cannot repeat per frame.

Scope deliberately small. An audit of the three shared modules found 23 handlers
that swallow and return a default, but all 23 catch specific types -- TypeError,
ValueError, ImportError -- turning bad config values into defaults, which is
what they are for. Of 77 broad handlers across the font and odds paths, 74
already log. Only these two were both broad and silent.
2026-09-04 16:01:50 -04:00
Chuck 0730d95200 fix(sports): let the plugin declare its own directory, don't deduce it (#519)
_plugin_dir() returned None on every device. The consequence was silent and
reached the panel:

    _plugin_dir()       -> None
    _schema_font_size() -> None for every element
    -> a configured size equal to the schema default stops looking like a
       default and is treated as a deliberate user choice
    -> the snap to the font's pixel grid is skipped
    -> 4x6-font.ttf renders at 6 instead of 7: 3px-wide glyphs, not 4px

On a 256x64 panel that made the odds, the team records and the date row hard to
read. Both `odds` and `detail` were affected -- anything resolving a
grid-snapped schema default was a pixel narrow.

Why it was invisible here. PluginLoader._namespace_plugin_modules renames every
bare module a plugin brought in (sports, game_renderer, ...) to
"_plg_<plugin_id>_<module>" and REMOVES the bare sys.modules entry, so two
plugins owning a module of the same name cannot collide. A class defined in
sports.py still reports __module__ == "sports", but sys.modules["sports"] is
gone, so walking the MRO for a module with a __file__ finds nothing.

Every test here imported plugins directly, which leaves the bare entry in
place, so the walk succeeded. The safety harness loads plugins its own way and
never reproduced it either. It was found by a user counting pixels on the
panel.

The directory is now declared by the plugin (_PLUGIN_DIR) and only deduced as
a fallback, for hosts that declare nothing -- the plugins' own probe harnesses
build classes with type().

Verified on hardware, which is the only place the original failure appeared:
before, the live service logged plugin_dir=None and 4x6-font.ttf@6 for all six
football managers; after, plugin_dir resolves and both odds and detail are @7.

Five regression tests, including the production shape: a class whose __module__
is absent from sys.modules still resolves via its declared directory, and the
precondition that the MRO walk alone returns None is pinned so the test keeps
meaning something if the fallback changes.
2026-09-03 17:01:30 -04:00
Chuck 32d637a446 fix(store): read the core version from disk, not from a stale import (#518)
* fix(store): read the core version from disk, not from a stale import

Updating the core to 3.3.0 and then updating plugins refused all eight sports
scoreboards:

    Refusing to install nrl-scoreboard: NRL Scoreboard supports LEDMatrix
    >=3.3.0, but this system is running 3.2.0.

while src/__init__.py on that machine read 3.3.0. Observed on hardware, not
theorised.

The gate ran `from src import __version__ as core_version`, which binds
whatever the process loaded at start. The plugin store's gate lives in the web
UI, a long-lived service of its own, and the update route deliberately restarts
nothing -- it replaces files on disk and asks the user to restart. Its prompt
named only the *display* service, so a user who followed it left the web
process holding the previous number.

Stale by exactly one release is the case that bites: every plugin flooring on
the release you just installed is refused, blaming a core version that is
already correct on disk. It reads as a broken plugin store. 3.3.0 is the first
release where this hits a whole family at once, since all eight scoreboards
floor there.

compatibility.current_core_version() reads the version from the file instead,
falling back to the imported value on any failure -- so it can only ever be as
correct as before, never worse. All four gate call sites use it: three in
store_manager (install, the git-pull update path, install_from_url) and one in
plugin_loader's advisory warning.

The restart prompt now names both services.

Twelve tests, including the hardware failure itself: a process holding 3.2.0
while disk says 3.3.0 refuses hockey, and reading fresh allows it. The inverse
is asserted too -- a genuinely old core still refuses, so the gate has not
become permissive. One test greps both modules for the old import-bound read;
reintroducing that line fails it, which is what stops this coming back.

Not changed: web_interface/__init__.py also imports __version__, but for
display rather than gating, and the API endpoint already reports a fresh
git describe.

* fix: drop the unused os import

Left over from a first draft that joined paths by hand before this used
pathlib. Flagged by CodeRabbit on #518; confirmed dead -- no os. reference
remains in the module.
2026-09-03 16:06:40 -04:00
104 changed files with 22327 additions and 9662 deletions
+4
View File
@@ -49,3 +49,7 @@ config/backups/
# Starlark apps runtime storage (installed .star files and cached renders)
/starlark-apps/
skin_renders/
# JS test deps (test/js)
node_modules/
package-lock.json
+51 -1
View File
@@ -33,7 +33,7 @@ All endpoints return JSON responses with a standard format:
> The API blueprint is mounted at `/api/v3` (`web_interface/app.py:199`).
> SSE stream endpoints (`/api/v3/stream/*`) are defined directly on the
> Flask app at `app.py:799-809`. There are 94 routes total — see
> Flask app at `app.py:799-809`. There are 111 routes total — see
> `web_interface/blueprints/api_v3.py` for the canonical list.
---
@@ -223,6 +223,56 @@ Get the current display state and preview image.
}
```
### List Display Modes
**GET** `/api/v3/display/modes`
Every display mode that can be requested on-demand, with the plugin that owns
it. This is the list the force-display dialog offers.
Send the reported `plugin_id` alongside `mode` when starting an on-demand
display: `/display/on-demand/start` falls back to `find_plugin_for_mode` when
`plugin_id` is omitted, and that lookup only sees modes declared in a static
manifest — a plugin whose modes are generated (each installed Starlark app is
one) returns 404 there.
Triggers plugin discovery, which is otherwise lazy — so a caller that never
opens the dashboard still gets the full list.
**Query Parameters**:
- `include_disabled` (optional): `1` to include modes belonging to disabled
plugins. They are still valid on-demand targets — the controller enables the
plugin for the duration of the request — and are reported with
`"enabled": false`.
**Response**:
```json
{
"status": "success",
"data": {
"modes": [
{
"mode": "nfl_live",
"plugin_id": "football-scoreboard",
"plugin_name": "Football Scoreboard",
"name": "nfl_live",
"enabled": true
},
{
"mode": "clock-simple",
"plugin_id": "clock-simple",
"plugin_name": "Simple Clock",
"name": "Simple Clock",
"enabled": true
}
]
}
}
```
`name` is a label for a dropdown: a single-mode plugin's own name, or the raw
mode string for a multi-mode plugin, since there is no per-mode name anywhere.
### On-Demand Display Status
**GET** `/api/v3/display/on-demand/status`
+266
View File
@@ -0,0 +1,266 @@
# Scroll Performance
How scrolling is paced on this hardware, what was wrong with it, and how to
configure a plugin so its marquee is smooth.
Measured on a Raspberry Pi 4 driving a 2×128×64 chain (256×64 logical) at
`limit_refresh_rate_hz: 100`. Numbers below come from that panel.
| | before | after |
|---|---|---|
| scroll frame rate | 44–46 fps | **100 fps, locked** |
| frames ≥ 45 ms | 14–17% | none observed |
| dominant frame time | 20 ms | **10 ms** |
| disk cache write (~1 MB) | 14.8 ms | **5.4 ms** |
---
## The one rule that matters
**Motion is smooth when the strip advances a whole number of pixels per panel
refresh.**
Advancing one pixel per refresh on a 100 Hz panel gives 100 px/s. Slower crisp
speeds come from holding each frame for several refreshes -- 50 px/s is one
pixel every second refresh -- which is covered under *Choosing a speed* below.
A speed that lands on no such combination has to do one of two bad things:
- **blend** two adjacent columns to render a half-step — on pixel-font text
this alternates crisp and smeared frames and reads as shimmer, or as the
text jumping a pixel ahead of itself;
- **repeat** a frame — the strip stands still, then jumps, which reads as
judder.
Neither is tunable away. Pick a speed that divides evenly.
`src.common.scroll_config` solves this for you: `configure()` snaps a requested
speed to the nearest one the panel can actually show in whole pixels, and
`scripts/scroll_speeds.py` prints the full ladder for your hardware.
## Choosing a speed
The crisp speeds are not a fixed list -- they depend on how fast *your* panel
refreshes, which depends on its size, `pwm_bits`, `gpio_slowdown` and the Pi
model. A Pi Zero driving a long chain has a completely different set of good
speeds from a Pi 4 driving a short one.
```bash
# what can this panel do? (reads your configured refresh rate)
python3 scripts/scroll_speeds.py
# what does it ACTUALLY manage, rather than what is configured?
sudo systemctl stop ledmatrix
sudo python3 scripts/scroll_speeds.py --measure
sudo systemctl start ledmatrix
# highlight the closest option to the speed you want
python3 scripts/scroll_speeds.py --want 45
# try one on the panel
sudo systemctl stop ledmatrix
sudo python3 scripts/scroll_speeds.py --demo 50
sudo systemctl start ledmatrix
```
Sample ladder for a 100 Hz panel:
```
20.0 px/s (1px every 5 refreshes = 20.0 fps, slightly stepped)
25.0 px/s (1px every 4 refreshes = 25.0 fps, slightly stepped)
33.3 px/s (1px every 3 refreshes = 33.3 fps, smooth)
50.0 px/s (1px every 2 refreshes = 50.0 fps, smooth)
66.7 px/s (2px every 3 refreshes = 33.3 fps, smooth)
100.0 px/s (1px every 1 refresh = 100.0 fps, smooth)
```
### How a slow speed stays crisp
`SwapOnVSync(canvas, framerate_fraction)` holds each frame for N panel
refreshes. **The panel keeps refreshing at its full rate either way**, so
holding a frame costs nothing in flicker -- it only changes how often a *new*
image is presented. That is what allows 50 px/s to be one whole pixel every
second refresh, instead of half a pixel every refresh (which has no good
rendering, only a choice between blur and judder).
`scroll_config.configure()` snaps the requested speed to the nearest entry on
the ladder and reports the hold that speed needs. It does **not** apply the
hold: the hold belongs to a scroll, not to a plugin's lifetime, and plugins
share one display manager -- one set at construction is reset the moment any
other plugin finishes scrolling. Apply it yourself when the scroll starts:
```python
settings = scroll_config.configure(
self.scroll_helper,
plugin_config=self.config,
global_config=self.global_config,
display_manager=self.display_manager, # supplies the panel refresh rate
)
# ...then, each time this plugin begins scrolling:
self.display_manager.set_scrolling_state(True, frame_hold=settings.frame_hold)
```
Passing `display_manager` only lets `configure` read the true refresh rate from
`display.hardware`, which a plugin config cannot see. Skipping the
`set_scrolling_state` call is the mistake that matters: the speed still
resolves, but the panel keeps presenting a new frame every refresh, so a slow
snapped speed falls back to fractional pixels. Pass `snap_to_crisp=False` to
keep an exact requested speed and accept the artefacts.
Speeds slower than about 20 px/s are stepped no matter what, because a 1-pixel
advance at 20 fps is simply a coarse increment. That is the pixel pitch, not a
software limit; the only way to move in smaller increments is sub-pixel
blending, which this display does not tolerate (see above).
## Configuring a plugin
Use the shared resolver rather than reading config keys yourself:
```python
from src.common import scroll_config
settings = scroll_config.configure(
self.scroll_helper,
plugin_config=self.config,
global_config=self.global_config,
refresh_hz=scroll_config.refresh_hz_from_config(self.global_config),
plugin_logger=self.logger,
)
```
It resolves every config shape in one place, applies the speed, and returns
what it did. Precedence, highest first:
1. `display_options.scroll_speed` + `scroll_delay` — **the recommended form**
2. `display.scroll_speed` + `scroll_delay` — deprecated shape
3. `scroll_speed` + `scroll_delay` at the root — legacy flat
4. `scroll_pixels_per_second` — deprecated
5. the global `display` block
6. the built-in default (100 px/s)
`scroll_speed` is pixels per frame and `scroll_delay` is the frame period in
seconds, so the pair means `scroll_speed / scroll_delay` px/s. The recommended
config for a 100 Hz panel:
```json
"display_options": { "scroll_speed": 1.0, "scroll_delay": 0.01 }
```
### Why the deprecated key ranks below the explicit pair
Because some plugins give `scroll_pixels_per_second` a **schema default**, and
schema defaults are merged into plugin config. Ranking it above the pair means
it is always present and always wins, so the documented settings become
unreachable. That is a real, shipped bug — see
[ledmatrix-plugins#408](https://github.com/ChuckBuilds/ledmatrix-plugins/issues/408).
If you are writing a plugin: do not give a deprecated key a schema default.
## What was actually wrong
Four independent faults, each found by measurement.
### 1. The frame loop slept on top of a wait it had already done
`display_controller.py` ran the high-FPS loop as `render → SwapOnVSync (blocks
to the panel's refresh) → time.sleep(0.008) → plugin ticks`. The sleep was
unconditional and added to a wait that had already happened. Render work
measured ~4 ms, so each iteration cost ~12 ms against a 10 ms refresh grid —
every swap missed a refresh and landed on the next one. The loop settled at
exactly 50 fps while asking for 125, with no headroom, so ~14% of frames
slipped a further refresh.
Now the loop sleeps only the remainder of the frame budget, with a 1 ms floor
so plugin threads still get the GIL.
### 2. `SwapOnVSync` held the GIL while blocking
The rgbmatrix binding declares it without `nogil` (unlike `SetPixel`, `Clear`
and `Fill` immediately above it in `cppinc.pxd`), so the render thread held the
GIL for the entire vsync wait — most of every frame. Background threads were
starved into long uninterruptible bursts; a 1.5 MB API response costs ~17 ms to
parse and ~18 ms to re-encode for the cache, and `json.raw_decode` cannot be
preempted mid-document. Those bursts are what the render loop then waited on.
Fixed by rebuilding the binding: `scripts/build_rgbmatrix_nogil.sh`.
### 3. Sub-pixel blending was wrong for this display
Enabling it made things worse, not better — see the rule at the top. It is off
by default and only Vegas mode opts in via `set_sub_pixel_scrolling(True)`.
### 4. Frame-based stepping raced the vsync clock
Frame-based mode gated motion on a wall clock at `1/scroll_delay` steps per
second. Plugins set `scroll_delay` to the frame period, which puts that
comparison exactly on its own threshold: a frame arriving a hair early moved
zero pixels and rendered an identical frame, which dirty-tracking skipped, so
it returned in ~2 ms and the beat repeated. No `scroll_delay` value tunes this
out — a shorter delay just trades stalled frames for periodic double-steps.
`ScrollHelper` now accumulates elapsed time in both modes at the same
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`.
Look at the **distribution** instead:
```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
```
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 |
Then confirm what the plugin actually loaded — config edits do not always reach
the running code:
```bash
journalctl -u ledmatrix --since "-5min" --no-pager | grep -iE "px/s|px/frame"
```
If a plugin logs its scroll config **twice** with different modes, the second
line is what is running.
## Rebuilding the binding
```bash
bash scripts/build_rgbmatrix_nogil.sh # build into a scratch dir
sudo bash scripts/build_rgbmatrix_nogil.sh --install
sudo bash scripts/build_rgbmatrix_nogil.sh --rollback
```
The build never touches the installed module. `--install` backs up the original
to `~/rgbmatrix-core.so.ORIGINAL` first, and rolls back automatically if the
service does not come back healthy. Requires `build-essential`; Cython is
installed into a cached venv under `~/.cache/ledmatrix-cython`.
Re-run it after upgrading `rpi-rgb-led-matrix`, since a library upgrade
replaces the patched binding.
## Faster JSON
`src/cache/disk_cache.py` uses `orjson` when it is importable and falls back to
the stdlib otherwise, so it is optional:
```bash
sudo pip3 install --break-system-packages orjson
```
Encoding is where it pays — about 7× on this hardware. Decoding gains far less
(~1.3× on large payloads) because the cost there is building Python objects,
not scanning text. That is also why moving parsing to a subprocess does not
help: `pickle.loads` of the same payload costs 8.1 ms against `json.loads` at
10.9 ms, so the work just moves rather than disappearing.
+1
View File
@@ -0,0 +1 @@
bridge_config.json
+118
View File
@@ -0,0 +1,118 @@
# Home Assistant MQTT Bridge
Control the matrix from Home Assistant: force any plugin or mode on demand,
turn the display on and off, and set brightness — as real HA entities, not
hand-written `mqtt.publish` calls.
The bridge owns no display logic. It subscribes to one command topic and
turns each message into a call against the same `api_v3` routes the web UI
uses, so behaviour lives in one place. It talks to the API over HTTP only —
no filesystem access — so it can run on the Pi or anywhere that can reach
the web interface.
## What appears in Home Assistant
On connect the bridge publishes [MQTT Discovery](https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery)
config, so the matrix shows up under **Settings → Devices & Services → MQTT**
with no YAML:
| Entity | Does |
|---|---|
| `select.ledmatrix_display_mode` | Every mode across enabled plugins. Choosing one force-displays it. |
| `button.ledmatrix_stop_display` | Back to normal rotation. |
| `switch.ledmatrix_power` | Starts/stops the display service. |
| `number.ledmatrix_brightness` | 0–100. |
State is read back from the API every 30 seconds, so the entities also track
changes made from the web UI or an on-demand window expiring on its own.
All four share an availability topic that is the bridge's MQTT last will:
if the bridge dies, HA greys the controls out rather than leaving them
looking live but inert.
## Raw commands
For anything the entities do not cover, publish JSON to the command topic
(`ledmatrix/command` by default):
```jsonc
// Force a mode. plugin_id is optional — the bridge fills it in from
// /api/v3/display/modes.
{"action": "display", "mode": "nfl_live"}
// duration is seconds; pinned holds this one mode instead of rotating
// through every mode the plugin owns. Pin Starlark apps, where each mode
// is an unrelated widget; leave a sports plugin unpinned so live/recent/
// upcoming still cycle.
{"action": "display", "plugin_id": "starlark-apps", "mode": "aquarium",
"duration": 300, "pinned": true}
{"action": "stop_display"}
{"action": "power", "state": "on"}
{"action": "brightness", "value": 75}
// Re-read the mode list and re-publish discovery, after installing a plugin
{"action": "refresh"}
```
Every command publishes its outcome to `<command_topic>/status`, and current
state to `<command_topic>/state`.
## Requirements
- A LEDMatrix install with its web interface reachable (default `http://localhost:5000`)
- An MQTT broker that Home Assistant is also connected to
- Python 3 with `paho-mqtt` 2.x and `requests`
## Install
```bash
sudo ./scripts/install/install_mqtt_bridge.sh
```
That copies `bridge_config.example.json` to `bridge_config.json` on first
run, installs the dependencies, and enables `ledmatrix-mqtt-bridge.service`.
Edit the config with your broker details and re-run it.
```json
{
"mqtt_host": "192.168.1.10",
"mqtt_port": 8883,
"mqtt_username": "ledmatrix",
"mqtt_password": null,
"mqtt_topic": "ledmatrix/command",
"mqtt_tls": true,
"ledmatrix_api_base": "http://localhost:5000"
}
```
**TLS is on by default.** Without it the broker password and every display
command cross the network in cleartext. If your broker only listens on plain
1883 — which the Mosquitto add-on does out of the box — set `"mqtt_tls": false`
and `"mqtt_port": 1883`. The bridge logs a warning at startup when a password
is configured without TLS.
`bridge_config.json` is gitignored. Any key can also be supplied through the
environment as `LEDMATRIX_MQTT_<KEY>` (`LEDMATRIX_MQTT_MQTT_PASSWORD`, say),
which keeps a broker password out of a file on disk — put it in a systemd
drop-in with `Environment=` or `EnvironmentFile=` instead.
Set `mqtt_tls: true` for a broker with TLS. `mqtt_tls_insecure` skips
certificate verification and exists only for a self-signed broker on a
trusted LAN; it logs a warning when used.
To run it in the foreground while setting things up:
```bash
python3 integrations/mqtt_bridge/ledmatrix_mqtt_bridge.py --config integrations/mqtt_bridge/bridge_config.json
```
## Notes
- Only one thing can be on-demand at a time — the same constraint the web UI has.
- Forcing a mode restarts the display service, so the panel blanks for a moment.
- The mode list comes from `/api/v3/display/modes`, which triggers plugin
discovery itself. Discovery is lazy and normally happens because somebody
opened the dashboard; without that endpoint a bridge that never does would
see an empty list.
- Brightness writes `display.hardware.brightness` through `/api/v3/config/main`.
The display service picks it up on its next restart, not instantly.
@@ -0,0 +1,13 @@
{
"mqtt_host": "192.168.1.10",
"mqtt_port": 8883,
"mqtt_username": "ledmatrix",
"mqtt_password": null,
"mqtt_client_id": "ledmatrix-mqtt-bridge",
"mqtt_topic": "ledmatrix/command",
"mqtt_tls": true,
"ledmatrix_api_base": "http://localhost:5000",
"request_timeout": 15,
"on_demand_duration": null,
"log_level": "INFO"
}
@@ -0,0 +1,548 @@
#!/usr/bin/env python3
"""Control a LEDMatrix display from Home Assistant over MQTT.
The bridge owns no display logic. It subscribes to one command topic and
turns each message into a call against the same api_v3 routes the web UI
uses, so behaviour stays in one place and this stays a translation layer.
On connect it publishes Home Assistant MQTT Discovery config, so a matrix
appears in HA as real entities rather than something you drive with
`mqtt.publish` by hand:
select.ledmatrix_display_mode every mode across enabled plugins;
choosing one force-displays it
button.ledmatrix_stop_display back to normal rotation
switch.ledmatrix_power the display service, on or off
number.ledmatrix_brightness 0-100
Anything the entities do not cover is still reachable by publishing JSON
to the command topic:
{"action": "display", "mode": "nfl_live"}
{"action": "display", "plugin_id": "starlark-apps", "mode": "aquarium",
"duration": 300, "pinned": true}
{"action": "stop_display"}
{"action": "power", "state": "on" | "off"}
{"action": "brightness", "value": 75}
{"action": "refresh"} re-publish discovery after installing a plugin
Every command publishes its result to <command_topic>/status.
Run it with `python3 ledmatrix_mqtt_bridge.py [--config PATH]`, or install
ledmatrix-mqtt-bridge.service.
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import signal
import sys
import threading
from typing import Any, Callable, Dict, List, Optional
import requests
logger = logging.getLogger("ledmatrix-mqtt-bridge")
DISCOVERY_PREFIX = "homeassistant"
DEVICE_ID = "ledmatrix"
DEVICE_INFO = {
"identifiers": [DEVICE_ID],
"name": "LEDMatrix",
"manufacturer": "ChuckBuilds",
"model": "LEDMatrix Display",
}
DEFAULTS = {
"mqtt_host": "localhost",
"mqtt_port": 1883,
"mqtt_username": None,
"mqtt_password": None, # nosec B105 - "no password configured", not a credential
"mqtt_client_id": "ledmatrix-mqtt-bridge",
"mqtt_topic": "ledmatrix/command",
"mqtt_tls": False,
"mqtt_tls_insecure": False,
"ledmatrix_api_base": "http://localhost:5000",
"request_timeout": 15,
"on_demand_duration": None,
"log_level": "INFO",
}
class ConfigError(Exception):
"""The bridge cannot start with the configuration it was given."""
def load_config(path: str) -> Dict[str, Any]:
"""Read bridge_config.json, overlaid on DEFAULTS.
Every value may also come from the environment as LEDMATRIX_MQTT_<KEY>,
which is how a password stays out of a file that has to be world-readable
for the service user.
"""
config = dict(DEFAULTS)
if os.path.isfile(path):
with open(path, encoding="utf-8") as handle:
try:
loaded = json.load(handle)
except json.JSONDecodeError as err:
raise ConfigError(f"{path} is not valid JSON: {err}") from err
if not isinstance(loaded, dict):
raise ConfigError(f"{path} must contain a JSON object")
config.update(loaded)
else:
logger.warning("No config file at %s - using defaults and environment", path)
for key in DEFAULTS:
env_value = os.environ.get(f"LEDMATRIX_MQTT_{key.upper()}")
if env_value is not None:
config[key] = env_value
for key in ("mqtt_port", "request_timeout"):
try:
config[key] = int(config[key])
except (TypeError, ValueError) as err:
raise ConfigError(f"{key} must be a whole number, got {config[key]!r}") from err
for key in ("mqtt_tls", "mqtt_tls_insecure"):
config[key] = str(config[key]).lower() in ("1", "true", "yes", "on")
if config.get("mqtt_password") == "REPLACE_WITH_YOUR_ACTUAL_MQTT_PASSWORD":
raise ConfigError(
"mqtt_password is still the example placeholder - set a real password, "
"or remove the key if your broker allows anonymous connections")
return config
class LEDMatrixClient:
"""The api_v3 calls the bridge needs, and nothing else.
Everything goes through the HTTP API rather than the filesystem, so the
bridge does not have to live on the Pi, does not need read access to
config.json, and cannot drift from the web UI's own behaviour.
"""
def __init__(self, api_base: str, timeout: int = 15,
session: Optional[requests.Session] = None):
self.api_base = api_base.rstrip("/")
self.timeout = timeout
self.session = session or requests.Session()
def _call(self, method: str, path: str, **kwargs) -> Dict[str, Any]:
url = f"{self.api_base}/api/v3{path}"
response = self.session.request(method, url, timeout=self.timeout, **kwargs)
try:
body = response.json()
except ValueError:
body = {}
if response.status_code >= 400 or body.get("status") == "error":
message = body.get("message") or f"HTTP {response.status_code}"
raise RuntimeError(f"{method} {path} failed: {message}")
return body.get("data", body)
def list_modes(self) -> List[Dict[str, Any]]:
"""Every display mode that can be force-displayed, newest discovery.
/display/modes triggers plugin discovery itself, which matters because
discovery is lazy: a bridge that never opens the dashboard would
otherwise see nothing at all.
"""
return self._call("GET", "/display/modes").get("modes", [])
def display_status(self) -> Dict[str, Any]:
return self._call("GET", "/display/on-demand/status")
def start_on_demand(self, mode: str, plugin_id: Optional[str] = None,
duration: Optional[int] = None, pinned: bool = False) -> Dict[str, Any]:
payload: Dict[str, Any] = {"mode": mode, "pinned": pinned}
if plugin_id:
# find_plugin_for_mode only sees modes declared in a static
# manifest, so a plugin whose modes are generated -- each installed
# Starlark app is one -- 404s when plugin_id is omitted. Sending it
# skips that lookup. /display/modes reports it for every mode.
payload["plugin_id"] = plugin_id
if duration:
payload["duration"] = int(duration)
return self._call("POST", "/display/on-demand/start", json=payload)
def stop_on_demand(self) -> Dict[str, Any]:
return self._call("POST", "/display/on-demand/stop", json={})
def set_power(self, on: bool) -> Dict[str, Any]:
action = "start_display" if on else "stop_display"
return self._call("POST", "/system/action", json={"action": action})
def get_brightness(self) -> Optional[int]:
config = self._call("GET", "/config/main")
value = config.get("display", {}).get("hardware", {}).get("brightness")
try:
return int(value)
except (TypeError, ValueError):
return None
def set_brightness(self, value: int) -> Dict[str, Any]:
return self._call("POST", "/config/main", json={"brightness": int(value)})
class CommandHandler:
"""Turns one decoded MQTT payload into one API call.
Kept free of MQTT so it can be tested against a fake client: the failure
modes worth pinning are all in here (an unknown mode, an out-of-range
brightness, a mode name that needs its plugin_id attached).
"""
def __init__(self, client: LEDMatrixClient, default_duration: Optional[int] = None):
self.client = client
self.default_duration = default_duration
self._modes_by_name: Dict[str, Dict[str, Any]] = {}
def refresh_modes(self) -> List[Dict[str, Any]]:
modes = self.client.list_modes()
self._modes_by_name = {m["mode"]: m for m in modes}
# Home Assistant's select shows labels, so accept them back as well --
# otherwise picking "Simple Clock" in a dashboard is not a mode name.
for entry in modes:
self._modes_by_name.setdefault(entry.get("name") or entry["mode"], entry)
return modes
@property
def known_modes(self) -> List[Dict[str, Any]]:
return list({id(v): v for v in self._modes_by_name.values()}.values())
def handle(self, payload: Dict[str, Any]) -> Dict[str, Any]:
action = payload.get("action")
handlers: Dict[str, Callable[[Dict[str, Any]], Dict[str, Any]]] = {
"display": self._display,
"stop_display": lambda _p: self._ok(self.client.stop_on_demand()),
"power": self._power,
"brightness": self._brightness,
"refresh": lambda _p: self._ok({"modes": len(self.refresh_modes())}),
}
handler = handlers.get(action)
if handler is None:
return self._error(f"Unknown action {action!r}; expected one of "
f"{', '.join(sorted(handlers))}")
try:
return handler(payload)
except (requests.RequestException, RuntimeError) as err:
logger.error("Command %s failed: %s", action, err)
return self._error(str(err))
def _display(self, payload: Dict[str, Any]) -> Dict[str, Any]:
mode = payload.get("mode")
plugin_id = payload.get("plugin_id")
if not mode and not plugin_id:
return self._error("display requires 'mode' or 'plugin_id'")
known = self._modes_by_name.get(mode) if mode else None
if known is None and mode and not plugin_id:
# One retry against a fresh listing: a plugin installed since the
# last refresh is the common reason a valid mode looks unknown.
self.refresh_modes()
known = self._modes_by_name.get(mode)
if known is not None:
mode = known["mode"]
plugin_id = plugin_id or known.get("plugin_id")
duration = payload.get("duration", self.default_duration)
pinned = bool(payload.get("pinned", False))
result = self.client.start_on_demand(
mode=mode, plugin_id=plugin_id, duration=duration, pinned=pinned)
return self._ok(result, mode=mode, plugin_id=plugin_id)
def _power(self, payload: Dict[str, Any]) -> Dict[str, Any]:
state = str(payload.get("state", "")).strip().lower()
if state not in ("on", "off"):
return self._error("power requires 'state' of 'on' or 'off'")
return self._ok(self.client.set_power(state == "on"), state=state)
def _brightness(self, payload: Dict[str, Any]) -> Dict[str, Any]:
raw = payload.get("value")
try:
value = int(float(raw))
except (TypeError, ValueError):
return self._error(f"brightness requires a number, got {raw!r}")
if not 0 <= value <= 100:
return self._error(f"brightness must be between 0 and 100, got {value}")
return self._ok(self.client.set_brightness(value), value=value)
@staticmethod
def _ok(result: Any, **extra) -> Dict[str, Any]:
return {"status": "success", "result": result, **extra}
@staticmethod
def _error(message: str) -> Dict[str, Any]:
return {"status": "error", "message": message}
def discovery_messages(command_topic: str, state_topic: str, availability_topic: str,
mode_labels: List[str]) -> List[Dict[str, Any]]:
"""The retained MQTT Discovery configs, as {topic, payload} pairs.
Pure, so the entity shapes can be asserted without a broker. Every entity
shares one availability topic, which is also the bridge's last will -- HA
then shows the matrix as unavailable when the bridge dies, instead of
leaving stale controls that silently do nothing.
"""
common = {
"device": DEVICE_INFO,
"availability_topic": availability_topic,
"payload_available": "online",
"payload_not_available": "offline",
}
return [
{
"topic": f"{DISCOVERY_PREFIX}/select/{DEVICE_ID}/display_mode/config",
"payload": {
**common,
"name": "Display Mode",
"unique_id": f"{DEVICE_ID}_display_mode",
"command_topic": command_topic,
"command_template": '{"action": "display", "mode": "{{ value }}"}',
"state_topic": state_topic,
"value_template": "{{ value_json.mode }}",
"options": mode_labels,
"icon": "mdi:view-dashboard",
},
},
{
"topic": f"{DISCOVERY_PREFIX}/button/{DEVICE_ID}/stop_display/config",
"payload": {
**common,
"name": "Stop Display",
"unique_id": f"{DEVICE_ID}_stop_display",
"command_topic": command_topic,
"payload_press": '{"action": "stop_display"}',
"icon": "mdi:stop",
},
},
{
"topic": f"{DISCOVERY_PREFIX}/switch/{DEVICE_ID}/power/config",
"payload": {
**common,
"name": "Power",
"unique_id": f"{DEVICE_ID}_power",
"command_topic": command_topic,
"payload_on": '{"action": "power", "state": "on"}',
"payload_off": '{"action": "power", "state": "off"}',
"state_topic": state_topic,
"value_template": "{{ 'ON' if value_json.power else 'OFF' }}",
"state_on": "ON",
"state_off": "OFF",
"icon": "mdi:power",
},
},
{
"topic": f"{DISCOVERY_PREFIX}/number/{DEVICE_ID}/brightness/config",
"payload": {
**common,
"name": "Brightness",
"unique_id": f"{DEVICE_ID}_brightness",
"command_topic": command_topic,
"command_template": '{"action": "brightness", "value": {{ value }}}',
"state_topic": state_topic,
"value_template": "{{ value_json.brightness }}",
"min": 0,
"max": 100,
"step": 1,
"icon": "mdi:brightness-6",
},
},
]
def warn_if_cleartext(config: Dict[str, Any]) -> bool:
"""Say so, once, when a broker password is going over an unencrypted link.
The shipped example has TLS on, so reaching here means somebody turned it
off deliberately -- which is legitimate (the Mosquitto add-on is plaintext
on 1883) but should not be silent when there is a password to lose. Returns
whether it warned, so the decision is testable without a broker.
"""
if config.get("mqtt_tls") or not config.get("mqtt_password"):
return False
logger.warning(
'mqtt_tls is off and a password is set: the broker password and every '
'command are sent unencrypted. Set "mqtt_tls": true (port 8883 on most '
'brokers) unless this is a trusted, isolated network.')
return True
def read_state(client: LEDMatrixClient) -> Dict[str, Any]:
"""The state every entity reads, so HA opens on real values.
Each field is fetched independently: a matrix with its display service
stopped still has a brightness worth showing, and one unreachable field
should not blank the rest.
"""
state: Dict[str, Any] = {"power": False, "mode": None, "brightness": None}
try:
status = client.display_status()
state["power"] = bool(status.get("service", {}).get("active"))
on_demand = status.get("state", {})
if on_demand.get("active"):
state["mode"] = on_demand.get("mode")
except (requests.RequestException, RuntimeError) as err:
logger.debug("Could not read display status: %s", err)
try:
state["brightness"] = client.get_brightness()
except (requests.RequestException, RuntimeError) as err:
logger.debug("Could not read brightness: %s", err)
return state
class Bridge:
"""MQTT wiring around CommandHandler."""
def __init__(self, config: Dict[str, Any]):
self.config = config
self.command_topic = config["mqtt_topic"]
self.status_topic = f"{self.command_topic}/status"
self.state_topic = f"{self.command_topic}/state"
self.availability_topic = f"{self.command_topic}/availability"
self.client = LEDMatrixClient(config["ledmatrix_api_base"], config["request_timeout"])
self.handler = CommandHandler(self.client, config.get("on_demand_duration"))
self._stop = threading.Event()
self._mqtt = None
# -- MQTT callbacks (paho-mqtt 2.x VERSION2 signatures) ------------------
def _on_connect(self, client, _userdata, _flags, reason_code, _properties=None):
if getattr(reason_code, "is_failure", reason_code != 0):
logger.error("MQTT connection refused: %s", reason_code)
return
logger.info("Connected to MQTT broker; subscribing to %s", self.command_topic)
client.subscribe(self.command_topic, qos=1)
client.publish(self.availability_topic, "online", qos=1, retain=True)
# Re-publish on every reconnect, not just the first connect: a broker
# restart drops retained discovery configs, and HA would otherwise be
# left with entities it can no longer describe.
self.publish_discovery()
self.publish_state()
def _on_message(self, _client, _userdata, message):
try:
payload = json.loads(message.payload.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as err:
logger.warning("Ignoring unparseable message on %s: %s", message.topic, err)
self._publish(self.status_topic, {"status": "error", "message": f"bad payload: {err}"})
return
if not isinstance(payload, dict):
self._publish(self.status_topic,
{"status": "error", "message": "payload must be a JSON object"})
return
logger.info("Command: %s", payload)
result = self.handler.handle(payload)
self._publish(self.status_topic, result)
# The API applies changes asynchronously (the controller polls its
# mailbox), so read state back rather than assuming the command took.
self.publish_state()
# -- publishing ---------------------------------------------------------
def _publish(self, topic: str, payload: Any, retain: bool = False) -> None:
if self._mqtt is None:
return
body = payload if isinstance(payload, str) else json.dumps(payload)
self._mqtt.publish(topic, body, qos=1, retain=retain)
def publish_discovery(self) -> None:
try:
modes = self.handler.refresh_modes()
except (requests.RequestException, RuntimeError) as err:
logger.error("Could not list display modes: %s", err)
modes = self.handler.known_modes
labels = sorted({m.get("name") or m["mode"] for m in modes})
for message in discovery_messages(self.command_topic, self.state_topic,
self.availability_topic, labels):
self._publish(message["topic"], message["payload"], retain=True)
logger.info("Published discovery for %d display mode(s)", len(labels))
def publish_state(self) -> None:
self._publish(self.state_topic, read_state(self.client), retain=True)
# -- lifecycle ----------------------------------------------------------
def run(self) -> int:
try:
import paho.mqtt.client as mqtt
except ImportError:
logger.error("paho-mqtt is not installed: pip install -r requirements.txt")
return 1
# VERSION2 is the current callback API. The compatibility note in
# CLAUDE.md is about code written against the v1 signatures; this file
# is written against v2 and requires paho-mqtt >= 2.0.
self._mqtt = mqtt.Client(
mqtt.CallbackAPIVersion.VERSION2,
client_id=self.config["mqtt_client_id"])
if self.config.get("mqtt_username"):
self._mqtt.username_pw_set(self.config["mqtt_username"],
self.config.get("mqtt_password"))
if self.config.get("mqtt_tls"):
self._mqtt.tls_set()
if self.config.get("mqtt_tls_insecure"):
logger.warning("TLS certificate verification is disabled (mqtt_tls_insecure)")
self._mqtt.tls_insecure_set(True)
else:
warn_if_cleartext(self.config)
self._mqtt.will_set(self.availability_topic, "offline", qos=1, retain=True)
self._mqtt.on_connect = self._on_connect
self._mqtt.on_message = self._on_message
logger.info("Connecting to %s:%s", self.config["mqtt_host"], self.config["mqtt_port"])
try:
self._mqtt.connect(self.config["mqtt_host"], self.config["mqtt_port"], keepalive=60)
except OSError as err:
logger.error("Could not reach the MQTT broker: %s", err)
return 1
self._mqtt.loop_start()
try:
while not self._stop.wait(30):
# HA is told the truth about state that changed outside the
# bridge -- somebody using the web UI, or an on-demand window
# expiring on its own.
self.publish_state()
finally:
self._publish(self.availability_topic, "offline", retain=True)
self._mqtt.loop_stop()
self._mqtt.disconnect()
return 0
def stop(self, *_args) -> None:
logger.info("Shutting down")
self._stop.set()
def main(argv: Optional[List[str]] = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument(
"--config",
default=os.path.join(os.path.dirname(os.path.abspath(__file__)), "bridge_config.json"),
help="Path to bridge_config.json (default: alongside this script)")
args = parser.parse_args(argv)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s")
try:
config = load_config(args.config)
except ConfigError as err:
logger.error("%s", err)
return 1
logging.getLogger().setLevel(str(config.get("log_level", "INFO")).upper())
bridge = Bridge(config)
signal.signal(signal.SIGTERM, bridge.stop)
signal.signal(signal.SIGINT, bridge.stop)
return bridge.run()
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,5 @@
# Floors are security floors, not API floors. requests < 2.33.0 carries
# CVE-2024-35195, CVE-2024-47081 and CVE-2026-25645; matches the pin in the
# project's own requirements.txt.
paho-mqtt>=2.0.0,<3.0.0
requests>=2.33.0,<3.0.0
+49 -10
View File
@@ -194,6 +194,15 @@ class StarlarkAppsPlugin(BasePlugin):
Each installed app becomes a dynamic display mode.
"""
#: Starlark apps are animations: a .webp render carries per-frame delays
#: and _display_frame advances at most one frame per call. The controller
#: reads this attribute to decide whether a mode needs its high-FPS loop;
#: without it display() was called once per rotation slot, so a multi-frame
#: app showed a single frame and never moved. static-image is force-run at
#: high FPS for the same reason (GIFs), but that plugin is special-cased by
#: name in the controller and this one has to declare it.
enable_scrolling = True
def __init__(self, plugin_id: str, config: Dict[str, Any],
display_manager, cache_manager, plugin_manager):
"""Initialize the Starlark Apps plugin."""
@@ -681,38 +690,62 @@ class StarlarkAppsPlugin(BasePlugin):
if app.is_enabled() and app.should_render(current_time):
self._render_app(app, force=False)
def display(self, force_clear: bool = False) -> None:
def display(self, display_mode: Optional[str] = None, force_clear: bool = False) -> bool:
"""
Display current Starlark app.
This method is called during the display rotation.
Displays frames from the currently active app.
`display_mode` names the app to show when it matches an installed
app_id. The controller passes the mode it is rotating to and inspects
this signature to decide whether to, so accepting it is what lets a
specific app be addressed -- including by an on-demand request pinned
to one app. Anything else (the plugin id itself, when the plugin
exposes no per-app modes) falls through to normal rotation.
Returns False when there is no app to show -- which is the state of
every install without Pixlet, and of a fresh one before any app is
added. The display controller only skips a mode on a boolean False
(it checks isinstance(result, bool)), so returning None held a black
panel for the full display_duration instead of rotating on.
"""
try:
if force_clear:
self.display_manager.clear()
# If no current app, try to select one
if not self.current_app:
if display_mode and display_mode in self.apps:
self.current_app = self.apps[display_mode]
elif force_clear or not self.current_app:
# Advance on entry to the mode. _select_next_app only ran when
# current_app was unset, so the first enabled app was picked
# once and then shown forever -- every other installed app was
# rendered on schedule and never displayed. force_clear is the
# controller's "we just switched to you" signal (it is reset
# immediately after this call), so one app gets each turn.
self._select_next_app()
if not self.current_app:
# No apps available
self.logger.debug("No Starlark apps to display")
return
return False
# Render app if needed
if not self.current_app.frames:
success = self._render_app(self.current_app, force=True)
if not success:
self.logger.error(f"Failed to render app: {self.current_app.app_id}")
return
return False
# Display current frame
self._display_frame()
# Display current frame. The result is propagated: a failed frame
# update is not a displayed frame, and returning True regardless
# told the controller the mode had rendered, so it held the dead
# frame for the whole display_duration instead of rotating on.
return self._display_frame()
except Exception as e:
self.logger.error(f"Error displaying Starlark app: {e}")
return False
def _select_next_app(self) -> None:
"""Select the next enabled app for display."""
@@ -835,10 +868,13 @@ class StarlarkAppsPlugin(BasePlugin):
self.logger.error(f"Error loading frames for {app.app_id}: {e}")
return False
def _display_frame(self) -> None:
"""Display the current frame of the current app."""
def _display_frame(self) -> bool:
"""Display the current frame of the current app.
:returns: whether a frame actually reached the display manager.
"""
if not self.current_app or not self.current_app.frames:
return
return False
try:
current_time = time.time()
@@ -856,8 +892,11 @@ class StarlarkAppsPlugin(BasePlugin):
)
self.current_app.last_frame_time = current_time
return True
except Exception as e:
self.logger.error(f"Error displaying frame: {e}")
return False
def install_app(self, app_id: str, star_file_path: str, metadata: Optional[Dict[str, Any]] = None, assets_dir: Optional[str] = None) -> bool:
"""
+98 -10
View File
@@ -264,10 +264,18 @@ class PixletRenderer:
else:
value_str = str(value)
# Validate value doesn't contain dangerous shell metacharacters
# Block: backticks, $(), pipes, redirects, semicolons, ampersands, null bytes
# Allow: most printable chars including spaces, quotes, brackets, braces
if re.search(r'[`$|<>&;\x00]|\$\(', value_str):
# Validate value doesn't contain dangerous shell metacharacters.
# Kept as defence in depth only: cmd is a list and there is no
# shell=True below, so nothing here is ever interpreted by a
# shell. That made the list worth trimming rather than growing
# -- "|" is a normal character inside a config value, and apps
# do use it as a separator (a PennDOT sign id is
# "I-476 North|175659"). Blocking it dropped the whole key
# silently, and the app then rendered its own "not configured"
# screen with nothing to say why.
# Block: backticks, $(), redirects, semicolons, ampersands, null bytes
# Allow: most printable chars including spaces, quotes, brackets, braces, pipes
if re.search(r'[`$<>&;\x00]|\$\(', value_str):
logger.warning(f"Skipping config value with unsafe shell characters for key {key}: {value_str}")
continue
@@ -299,13 +307,21 @@ class PixletRenderer:
)
if result.returncode == 0:
if os.path.isfile(output_path):
logger.debug(f"Successfully rendered: {star_file} -> {output_path}")
return True, None
else:
if not os.path.isfile(output_path):
error = "Rendering succeeded but output file not found"
logger.error(error)
return False, error
# Pixlet exits 0 and writes a 0-byte file when the app renders
# nothing -- an app whose config leaves it with no content to
# show does exactly that. Treating existence alone as success
# handed the caller a file with no frames in it, which reads
# downstream as a working app that draws a black panel.
if os.path.getsize(output_path) == 0:
error = "Rendering produced an empty (0-byte) file - the app rendered no content"
logger.error(error)
return False, error
logger.debug(f"Successfully rendered: {star_file} -> {output_path}")
return True, None
else:
error = f"Pixlet failed (exit {result.returncode}): {result.stderr}"
logger.error(error)
@@ -319,11 +335,76 @@ class PixletRenderer:
logger.exception("Rendering exception")
return False, "Rendering failed - see logs for details"
#: Schema extraction runs an app's own get_schema(), which may make a
#: network call. Short enough that a hung app does not stall an upload,
#: long enough for a real API round trip on a slow connection.
SCHEMA_TIMEOUT = 20
def extract_schema_via_pixlet(self, star_file: str) -> Optional[Dict[str, Any]]:
"""Ask Pixlet itself for the app's schema, or None if it cannot say.
`pixlet schema` executes get_schema() instead of reading it, which is
the only way to see options an app computes at runtime -- a dropdown
whose choices come from a live API call has no option list anywhere in
the source for the regex parser below to find, so that parser reports
an empty dropdown and the config form offers nothing to pick.
Pixlet's own field keys are remapped to the ones the rest of this
plugin and the config UI already use ("typeOf"/"desc"), so the two
extractors return the same shape and callers cannot tell them apart.
"""
if not self.pixlet_binary:
return None
try:
result = subprocess.run(
[self.pixlet_binary, "schema", star_file],
capture_output=True, text=True, timeout=self.SCHEMA_TIMEOUT,
cwd=self._get_safe_working_directory(star_file),
)
except subprocess.TimeoutExpired:
logger.warning(
"pixlet schema timed out after %ss for %s - get_schema() may be "
"making a slow network call", self.SCHEMA_TIMEOUT, star_file)
return None
except (subprocess.SubprocessError, OSError) as e:
logger.warning(f"Could not run pixlet schema for {star_file}: {e}")
return None
if result.returncode != 0:
# Not an error worth failing on: older Pixlet builds have no
# `schema` subcommand at all, and the source parser still works.
logger.debug(
"pixlet schema exited %d for %s: %s",
result.returncode, star_file, (result.stderr or '').strip()[:300])
return None
try:
schema = json.loads(result.stdout)
except (json.JSONDecodeError, ValueError) as e:
logger.warning(f"pixlet schema returned unparseable output for {star_file}: {e}")
return None
if not isinstance(schema, dict) or not isinstance(schema.get("schema"), list):
logger.warning(f"pixlet schema returned an unexpected shape for {star_file}")
return None
for field in schema["schema"]:
if not isinstance(field, dict):
continue
if "type" in field and "typeOf" not in field:
field["typeOf"] = field.pop("type")
if "description" in field and "desc" not in field:
field["desc"] = field.pop("description")
return schema
def extract_schema(self, star_file: str) -> Tuple[bool, Optional[Dict[str, Any]], Optional[str]]:
"""
Extract configuration schema from a .star file by parsing source code.
Extract configuration schema from a .star file.
Supports:
Prefers `pixlet schema`, which runs the app and therefore sees options
it computes at runtime. Falls back to parsing the source when Pixlet is
unavailable, too old to have the subcommand, or the app fails to run --
that parser handles:
- Static field definitions (location, text, toggle, dropdown, color, datetime)
- Variable-referenced dropdown options
- Graceful degradation for unsupported field types
@@ -337,6 +418,13 @@ class PixletRenderer:
if not os.path.isfile(star_file):
return False, None, f"Star file not found: {star_file}"
schema = self.extract_schema_via_pixlet(star_file)
if schema is not None:
logger.debug(
"Extracted schema with %d field(s) from %s via pixlet schema",
len(schema.get('schema', [])), star_file)
return True, schema, None
try:
# Read .star file
with open(star_file, 'r', encoding='utf-8') as f:
+112 -28
View File
@@ -5,6 +5,7 @@ Handles interaction with the Tronbyte apps repository on GitHub.
Fetches app listings, metadata, and downloads .star files.
"""
import json
import logging
import time
import requests
@@ -49,6 +50,13 @@ class TronbyteRepository:
self.base_url = "https://api.github.com"
self.raw_url = "https://raw.githubusercontent.com"
# Why the last GitHub API call failed, in words a user can act on.
# _make_request used to log the reason and return a bare None, so
# every caller up the stack knew only that "something" went wrong --
# which is how an exhausted rate limit reached the store page as an
# empty grid with no explanation.
self.last_error: Optional[str] = None
self.session = requests.Session()
if github_token:
self.session.headers.update({
@@ -70,29 +78,50 @@ class TronbyteRepository:
Returns:
JSON response or None on error
"""
self.last_error = None
try:
response = self.session.get(url, timeout=timeout)
if response.status_code == 403:
# Rate limit exceeded
logger.warning("[Tronbyte Repo] GitHub API rate limit exceeded")
if response.status_code in (403, 429):
# 403 is both "rate limited" and "forbidden"; the remaining
# counter is what tells them apart, and the difference matters
# to whoever reads the message -- one is fixed by waiting or
# adding a token, the other is not.
remaining = response.headers.get('X-RateLimit-Remaining')
if remaining == '0':
self.last_error = (
"GitHub API rate limit exceeded"
f" ({response.headers.get('X-RateLimit-Limit', '?')} requests/hour"
f"{'' if self.github_token else ', unauthenticated'})."
" Add a GitHub token in settings, or wait for the limit to reset."
)
else:
self.last_error = f"GitHub refused the request ({response.status_code})"
logger.warning(f"[Tronbyte Repo] {self.last_error}")
return None
elif response.status_code == 404:
self.last_error = "Not found on GitHub"
logger.warning(f"[Tronbyte Repo] Resource not found: {url}")
return None
elif response.status_code != 200:
self.last_error = f"GitHub API error {response.status_code}"
logger.error(f"[Tronbyte Repo] GitHub API error: {response.status_code}")
return None
return response.json()
except requests.Timeout:
self.last_error = "Timed out reaching GitHub"
logger.error(f"[Tronbyte Repo] Request timeout: {url}")
return None
except requests.RequestException as e:
self.last_error = f"Network error reaching GitHub: {e.__class__.__name__}"
logger.error(f"[Tronbyte Repo] Request error: {e}", exc_info=True)
return None
except (json.JSONDecodeError, ValueError) as e:
# Reachable whenever something on the path answers with HTML --
# a captive portal, a proxy error page, a DNS-hijacking router.
self.last_error = "GitHub returned a response that was not JSON"
logger.error(f"[Tronbyte Repo] JSON parse error for {url}: {e}", exc_info=True)
return None
@@ -125,6 +154,61 @@ class TronbyteRepository:
logger.error(f"[Tronbyte Repo] Network error fetching raw file {file_path}: {e}", exc_info=True)
return None
def _list_app_dirs_via_trees(self) -> Optional[List[Dict[str, Any]]]:
"""App directories via the git trees API, or None on failure.
The contents API caps a directory listing at 1000 entries and says
nothing about having truncated it, so the store showed the first 1000
apps of a repository that has more and looked complete while doing it.
The trees API caps far higher and sets `truncated` when it does, at
the cost of one extra call to resolve the `apps` tree.
"""
repo = f"{self.base_url}/repos/{self.REPO_OWNER}/{self.REPO_NAME}"
root = self._make_request(f"{repo}/git/trees/{self.DEFAULT_BRANCH}")
if not isinstance(root, dict):
return None
apps_sha = next(
(e.get('sha') for e in root.get('tree', []) or []
if e.get('path') == self.APPS_PATH and e.get('type') == 'tree'),
None)
if not apps_sha:
self.last_error = f"No '{self.APPS_PATH}' directory in the repository"
return None
tree = self._make_request(f"{repo}/git/trees/{apps_sha}")
if not isinstance(tree, dict):
return None
if tree.get('truncated'):
logger.warning(
"[Tronbyte Repo] GitHub truncated the app tree; the listing is incomplete")
return [
{'id': e['path'], 'path': f"{self.APPS_PATH}/{e['path']}", 'url': None}
for e in tree.get('tree', []) or []
if e.get('type') == 'tree' and e.get('path') and not e['path'].startswith('.')
]
def _list_app_dirs_via_contents(self) -> Optional[List[Dict[str, Any]]]:
"""App directories via the contents API. Capped at 1000 entries."""
url = f"{self.base_url}/repos/{self.REPO_OWNER}/{self.REPO_NAME}/contents/{self.APPS_PATH}"
data = self._make_request(url)
if data is None:
return None
if not isinstance(data, list):
self.last_error = "GitHub returned an unexpected listing format"
return None
return [
{'id': item.get('name'), 'path': item.get('path'), 'url': item.get('url')}
for item in data
if item.get('type') == 'dir' and item.get('name')
and not item['name'].startswith('.')
]
def list_apps(self) -> Tuple[bool, Optional[List[Dict[str, Any]]], Optional[str]]:
"""
List all available apps in the repository.
@@ -132,26 +216,17 @@ class TronbyteRepository:
Returns:
Tuple of (success, apps_list, error_message)
"""
url = f"{self.base_url}/repos/{self.REPO_OWNER}/{self.REPO_NAME}/contents/{self.APPS_PATH}"
data = self._make_request(url)
if data is None:
return False, None, "Failed to fetch repository contents"
if not isinstance(data, list):
return False, None, "Invalid response format"
# Filter directories (apps)
apps = []
for item in data:
if item.get('type') == 'dir':
app_id = item.get('name')
if app_id and not app_id.startswith('.'):
apps.append({
'id': app_id,
'path': item.get('path'),
'url': item.get('url')
})
apps = self._list_app_dirs_via_trees()
if apps is None:
# Fall back rather than fail: the contents API was what shipped,
# so a trees-only outage should not take the store down with it.
trees_error = self.last_error
logger.warning(
f"[Tronbyte Repo] Trees listing failed ({trees_error}); "
"falling back to the contents API")
apps = self._list_app_dirs_via_contents()
if apps is None:
return False, None, self.last_error or trees_error or "Failed to fetch repository contents"
logger.info(f"Found {len(apps)} apps in repository")
return True, apps, None
@@ -267,14 +342,22 @@ class TronbyteRepository:
'categories': _apps_cache['categories'],
'authors': _apps_cache['authors'],
'count': len(_apps_cache['data']),
'cached': True
'cached': True,
'error': None,
}
# Fetch directory listing (1 GitHub API call)
# Fetch directory listing (a small number of GitHub API calls)
success, app_dirs, error = self.list_apps()
if not success or not app_dirs:
logger.error(f"Failed to list apps for bulk fetch: {error}")
return {'apps': [], 'categories': [], 'authors': [], 'count': 0, 'cached': False}
# Returning an empty list here used to read downstream as "the
# repository has no apps", and the route reported that as a
# success -- so a rate limit, a DNS failure and an empty
# repository were all drawn as the same blank grid. Hand the
# reason back instead and let the caller surface it.
reason = error or "No apps found in the repository"
logger.error(f"Failed to list apps for bulk fetch: {reason}")
return {'apps': [], 'categories': [], 'authors': [],
'count': 0, 'cached': False, 'error': reason}
logger.info(f"Bulk-fetching manifests for {len(app_dirs)} apps...")
@@ -341,7 +424,8 @@ class TronbyteRepository:
'categories': categories,
'authors': authors,
'count': len(apps_with_metadata),
'cached': False
'cached': False,
'error': None,
}
def download_star_file(self, app_id: str, output_path: Path, filename: Optional[str] = None) -> Tuple[bool, Optional[str]]:
+5
View File
@@ -7,3 +7,8 @@ freezegun>=1.2,<2 # deterministic time for golden-image tests
psutil>=6.0.0,<8.0.0 # optional at runtime; installed for tests so the
# /system/status endpoint's real path is exercised
mypy>=1.5.0,<2.0.0 # static type checking (also pinned in .pre-commit-config.yaml)
PyYAML>=6.0.2,<7.0.0 # not a core dependency: test_starlark_pixlet_routes loads
# plugin-repos/starlark-apps/tronbyte_repository.py the way
# the blueprint does, and that plugin imports yaml. The
# plugin declares it in its own requirements.txt, which the
# store installs on a real rig but CI never does.
+12
View File
@@ -55,6 +55,18 @@ packaging>=23.0,<27.0
# range as a hard dependency — keep the two in sync.
# pip install 'psutil>=6.0.0,<7.0.0'
#
# orjson — faster JSON for the disk cache
# (src/cache/disk_cache.py). Encoding a ~1MB cache
# record drops from ~12ms to ~1.6ms on a Pi 4, which
# matters because that work holds the GIL and stalls
# the render thread mid-scroll. Falls back to the
# stdlib json when missing — see docs/SCROLL_PERFORMANCE.md.
# The 3.11.6 floor is CVE-2025-67221: orjson.dumps did not
# limit recursion on deeply nested documents, and the disk
# cache encodes payloads parsed straight from third-party
# APIs. 3.11.6 covers the Python range above.
# pip install 'orjson>=3.11.6,<4.0'
#
# Flask-Limiter — request rate limiting in web_interface/app.py
# (accidental-abuse protection, not security). The
# web interface starts without rate limiting when
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""Find blocking work reachable from a plugin's render path.
`display()` runs on the render thread. Anything slow reached from it stalls the
panel for its whole duration, and on a vsync-paced loop that is immediately
visible: a single 15ms call on a 100Hz panel drops a frame, and a network round
trip freezes the marquee outright.
This has bitten twice already. odds-ticker called `_has_live_games()` every
frame, whose slow path read the scoreboard cache from disk and parsed JSON per
enabled league -- one stalled frame every few minutes. soccer-scoreboard timed
out inside `update()` during a cache refresh. Both were found by staring at
frame-time histograms, which is a slow way to find a bug that is visible in the
source.
The audit walks the call graph from `display()` through same-class `self.*`
methods and reports anything that reaches a known-blocking API. It is a
heuristic, not a proof: it cannot see through indirection, and a hit is not
automatically a bug -- a call guarded by an interval check may be fine. It is a
list of places worth a human look.
python3 scripts/audit_render_path.py # all plugins
python3 scripts/audit_render_path.py --dir plugin-repos # a specific tree
python3 scripts/audit_render_path.py --plugin odds-ticker
"""
from __future__ import annotations
import argparse
import ast
import sys
from pathlib import Path
#: Calls that can block for longer than a frame. Matched on the attribute or
#: function name, so `requests.get`, `self.session.get` and a bare `get` on a
#: requests-ish object all register.
BLOCKING = {
"get": "network or cache read",
"post": "network",
"put": "network",
"request": "network",
"urlopen": "network",
"read": "I/O",
"open": "file I/O",
"load": "JSON/file parse",
"loads": "JSON parse",
"dump": "file write",
"dumps": "serialise",
"sleep": "sleep on the render thread",
"run": "subprocess",
"check_output": "subprocess",
"connect": "network",
"download_logo": "network",
"_fetch": "fetch",
}
#: Names that make a hit far more likely to matter.
HIGH_SIGNAL = ("requests", "urllib", "session", "cache_manager", "subprocess",
"socket", "http")
RENDER_ENTRY = "display"
class Analyzer:
def __init__(self, tree: ast.AST):
self.methods: dict[str, ast.FunctionDef] = {}
for node in ast.walk(tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
self.methods.setdefault(node.name, node)
def calls_in(self, fn: ast.AST):
"""(self-method names called, blocking hits) inside one function."""
self_calls, hits = set(), []
for node in ast.walk(fn):
if not isinstance(node, ast.Call):
continue
func = node.func
if isinstance(func, ast.Attribute):
name = func.attr
base = ast.unparse(func.value) if hasattr(ast, "unparse") else ""
if base == "self" and name in self.methods:
self_calls.add(name)
continue
if name in BLOCKING:
hits.append((name, base, BLOCKING[name], node.lineno))
elif isinstance(func, ast.Name) and func.id in BLOCKING:
hits.append((func.id, "", BLOCKING[func.id], node.lineno))
return self_calls, hits
def reachable_from(self, entry: str, max_depth: int = 3):
"""Blocking hits reachable from `entry`, with the path that reaches them."""
if entry not in self.methods:
return []
found, seen = [], set()
stack = [(entry, [entry], 0)]
while stack:
name, path, depth = stack.pop()
if name in seen or depth > max_depth:
continue
seen.add(name)
self_calls, hits = self.calls_in(self.methods[name])
for hit in hits:
found.append((path, hit))
for callee in sorted(self_calls):
stack.append((callee, path + [callee], depth + 1))
return found
def audit_file(path: Path):
try:
tree = ast.parse(path.read_text(encoding="utf-8"))
except (OSError, SyntaxError):
return []
analyzer = Analyzer(tree)
return analyzer.reachable_from(RENDER_ENTRY)
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
root = Path(__file__).resolve().parent.parent
ap.add_argument("--dir", default=str(root / "plugin-repos"))
ap.add_argument("--plugin", help="only this plugin directory")
ap.add_argument("--all-hits", action="store_true",
help="include low-signal hits (open/read/load on locals)")
args = ap.parse_args()
base = Path(args.dir)
if not base.is_dir():
sys.exit("not a directory: %s" % base)
plugins = [base / args.plugin] if args.plugin else sorted(
d for d in base.iterdir() if d.is_dir())
total = 0
for plugin in plugins:
rows = []
for src in sorted(plugin.glob("*.py")):
if src.name.startswith("test_"):
continue
for path, (name, s_base, why, lineno) in audit_file(src):
signal = any(h in s_base.lower() for h in HIGH_SIGNAL)
if not signal and not args.all_hits:
continue
rows.append((src.name, lineno, "->".join(path),
("%s.%s" % (s_base, name)) if s_base else name, why))
if rows:
total += len(rows)
print("\n%s" % plugin.name)
for fname, lineno, chain, call, why in sorted(rows):
print(" %s:%-5d %-34s via %s" % (fname, lineno, call + " (" + why + ")", chain))
print("\n%d blocking call(s) reachable from display() across %d plugin(s)"
% (total, len(plugins)))
print("Heuristic: a hit guarded by an interval check may be fine. Look, do "
"not assume.")
if __name__ == "__main__":
main()
+330
View File
@@ -0,0 +1,330 @@
#!/usr/bin/env bash
#
# Rebuild the rgbmatrix Python binding so it releases the GIL.
#
# WHY THIS EXISTS
# ---------------
# The upstream binding declares FrameCanvas::SwapOnVSync WITHOUT `nogil`
# (cppinc.pxd), unlike SetPixel/Clear/Fill on the lines just above it.
# SwapOnVSync blocks until the panel's next vertical sync -- up to a full
# refresh period on every frame -- so the render thread was holding the GIL
# for most of every frame. Background threads (API fetches, JSON parsing,
# image decode) were starved into long uninterruptible bursts, which in turn
# made the render loop miss refreshes.
#
# Measured on a Pi 4 driving a 2x128x64 chain at limit_refresh_rate_hz=100:
#
# before ~44 fps average, 14-17% of frames 41-53ms
# after 100 fps, median 10.00ms, p95 10.05ms, 0% stalls
#
# The per-pixel blit (SetPixelsPillow) can also release the GIL and walk the
# Pillow buffer row-major, but that is OFF by default and you almost certainly
# want to leave it that way. Row-major changes what a partially-written frame
# looks like: column-major tearing shows as a vertical seam, row-major tearing
# shows as a horizontal split between the panel's upper and lower halves. On a
# 1/32 scan panel that reads as a one-pixel "fold" across the middle of every
# panel -- reported on hardware, and it went away when the blit was reverted.
# Enable with RGB_PATCH_BLIT=1 only if you have measured that you need it;
# essentially all of the gain above comes from the SwapOnVSync change alone.
#
# SAFETY
# ------
# Builds into a scratch directory; touches the installed module only in the
# --install step, and backs up the original first. Roll back at any time with:
#
# sudo bash scripts/build_rgbmatrix_nogil.sh --rollback
#
# USAGE
# bash scripts/build_rgbmatrix_nogil.sh # build only
# sudo bash scripts/build_rgbmatrix_nogil.sh --install
# sudo bash scripts/build_rgbmatrix_nogil.sh --rollback
#
set -uo pipefail
# Resolve the invoking user's home, not root's. --install runs under sudo,
# where $HOME is /root, so every default path below pointed somewhere the
# build had never written and the install died with "no built module found".
if [ -n "${SUDO_USER:-}" ]; then
OWNER_HOME="$(getent passwd "$SUDO_USER" | cut -d: -f6)"
fi
OWNER_HOME="${OWNER_HOME:-$HOME}"
SRC_TREE="${RGB_SRC_TREE:-$OWNER_HOME/LEDMatrix/rpi-rgb-led-matrix-master}"
BUILD_DIR="${RGB_BUILD_DIR:-$OWNER_HOME/rgbmatrix-nogil-build}"
VENV="${RGB_CYTHON_VENV:-$OWNER_HOME/.cache/ledmatrix-cython}"
BACKUP="${RGB_BACKUP:-$OWNER_HOME/rgbmatrix-core.so.ORIGINAL}"
PATCH_BLIT="${RGB_PATCH_BLIT:-0}"
die() { echo "FATAL: $*" >&2; exit 1; }
# This script runs under `set -uo pipefail` -- no -e -- so an unchecked
# systemctl failure is silently ignored. That matters most for `stop`: leaving
# the old service running means cp overwrites a module the running process has
# mapped, the following `start` succeeds as a no-op, and the health check sees
# an active unit and reports SUCCESS for a binding that was never loaded.
# A machine with no ledmatrix.service at all is a normal build host, so that
# case is skipped rather than treated as a failure.
service_present() { systemctl cat ledmatrix.service >/dev/null 2>&1; }
service_do() {
local verb="$1"
if ! service_present; then
echo " (no ledmatrix.service installed - skipping $verb)"
return 0
fi
systemctl "$verb" ledmatrix || die "systemctl $verb ledmatrix failed"
}
py_site() {
python3 -c 'import rgbmatrix, os; print(os.path.dirname(rgbmatrix.__file__))' 2>/dev/null
}
# The extension filename the interpreter that builds -- and then loads -- this
# module actually uses, e.g. core.cpython-313-aarch64-linux-gnu.so. The build
# venv is made with --system-site-packages from python3, so the two agree;
# falling back keeps --install working when the venv has been cleaned up.
abi_name() {
local py="$VENV/bin/python"
[ -x "$py" ] || py=python3
"$py" -c \
'import sysconfig; print("core" + sysconfig.get_config_var("EXT_SUFFIX"))' \
2>/dev/null
}
# Exactly the current interpreter's artifact, never merely the first one that
# sorts. Staging copies $SRC_TREE wholesale, so a core.cpython-*.so left in the
# source tree by an earlier build comes along for the ride; build_ext --inplace
# only ever overwrites the current ABI's name, and a glob piped to `head -1`
# sorts cpython-311 ahead of cpython-313. That installed a stale, unpatched
# module as core.so while the GIL check below -- which reads the freshly
# generated core.cpp, not the .so -- still reported success.
abi_so() {
local name path
name="$(abi_name)" || return 1
[ -n "$name" ] || return 1
path="$BUILD_DIR/bindings/python/rgbmatrix/$name"
[ -f "$path" ] || return 1
printf '%s\n' "$path"
}
do_rollback() {
local dst; dst="$(py_site)"
[ -n "$dst" ] || die "could not locate the installed rgbmatrix package"
[ -f "$BACKUP" ] || die "no backup at $BACKUP"
service_do stop
cp -a "$BACKUP" "$dst/core.so" || die "restore failed"
find "$dst" -name __pycache__ -type d -exec rm -rf {} + 2>/dev/null
service_do start
echo "rolled back to the original core.so"
exit 0
}
do_install() {
local so dst
so="$(abi_so)"; [ -n "$so" ] || die "no built module found - run the build first"
dst="$(py_site)"; [ -n "$dst" ] || die "could not locate the installed rgbmatrix package"
if [ ! -f "$BACKUP" ]; then
cp -a "$dst/core.so" "$BACKUP" || die "could not back up the original"
echo "backed up original core.so -> $BACKUP"
else
echo "backup already present at $BACKUP (keeping the true original)"
fi
service_do stop
cp "$so" "$dst/core.so" || die "install failed"
find "$dst" -name __pycache__ -type d -exec rm -rf {} + 2>/dev/null
service_do start
echo "waiting 25s for the display to come back..."
sleep 25
local healthy=1
systemctl is-active --quiet ledmatrix || healthy=0
if journalctl -u ledmatrix --since "40 sec ago" --no-pager \
| grep -qiE "Traceback|ImportError|Segmentation fault|undefined symbol"; then
healthy=0
fi
if [ "$healthy" = "1" ]; then
echo "SUCCESS - running on the rebuilt binding"
else
echo "UNHEALTHY - rolling back"
cp -a "$BACKUP" "$dst/core.so" \
|| echo "ROLLBACK FAILED: could not restore $BACKUP -> $dst/core.so" >&2
if service_present && ! systemctl restart ledmatrix; then
echo "ROLLBACK FAILED: ledmatrix did not restart - the display is" \
"down; restore manually with 'sudo bash $0 --rollback'" >&2
fi
journalctl -u ledmatrix --since "90 sec ago" --no-pager | tail -25
exit 1
fi
exit 0
}
case "${1:-}" in
--rollback) do_rollback ;;
--install) do_install ;;
"" ) ;;
*) die "unknown option: $1" ;;
esac
# ---------------------------------------------------------------- build ----
[ -d "$SRC_TREE" ] || die "matrix source tree not found at $SRC_TREE (set RGB_SRC_TREE)"
command -v g++ >/dev/null || die "g++ not installed (apt install build-essential)"
echo "==> staging a scratch copy at $BUILD_DIR"
rm -rf "$BUILD_DIR"
cp -r "$SRC_TREE" "$BUILD_DIR" || die "copy failed"
# Drop any extension artifacts that came across from the source tree. Nothing
# downstream should be able to pick one up, and build_ext --inplace can decide
# a copied .so is already up to date and skip the compile entirely.
find "$BUILD_DIR/bindings/python/rgbmatrix" -maxdepth 1 \
-name 'core*.so' -delete 2>/dev/null
echo "==> patching the bindings to release the GIL"
python3 - "$BUILD_DIR" "$PATCH_BLIT" <<'PYEOF' || die "patch failed"
import io
import sys
base = sys.argv[1] + "/bindings/python/rgbmatrix/"
patch_blit = len(sys.argv) > 2 and sys.argv[2] == "1"
# --- declare SwapOnVSync as nogil ---------------------------------------
p = base + "cppinc.pxd"
s = io.open(p, encoding="utf-8").read()
OLD_DECL = " FrameCanvas *SwapOnVSync(FrameCanvas*, uint8_t)\n"
NEW_DECL = " FrameCanvas *SwapOnVSync(FrameCanvas*, uint8_t) nogil\n"
if OLD_DECL in s:
io.open(p, "w", encoding="utf-8", newline="\n").write(s.replace(OLD_DECL, NEW_DECL, 1))
print(" cppinc.pxd: SwapOnVSync declared nogil")
elif NEW_DECL in s:
print(" cppinc.pxd: already nogil")
else:
sys.exit("could not find the SwapOnVSync declaration")
# --- release the GIL across the vsync wait ------------------------------
p = base + "core.pyx"
s = io.open(p, encoding="utf-8").read()
OLD_SWAP = (
" def SwapOnVSync(self, FrameCanvas newFrame, uint8_t framerate_fraction = 1):\n"
" return __createFrameCanvas("
"self.__matrix.SwapOnVSync(newFrame.__canvas, framerate_fraction))\n"
)
NEW_SWAP = (
" def SwapOnVSync(self, FrameCanvas newFrame, uint8_t framerate_fraction = 1):\n"
" # Blocks until the panel's next vertical sync. Holding the GIL\n"
" # across that wait starves every other Python thread for most of\n"
" # each frame. Pointers are hoisted into C locals so the blocking\n"
" # call itself needs no Python state.\n"
" cdef cppinc.RGBMatrix* matrix = self.__matrix\n"
" cdef cppinc.FrameCanvas* frame = newFrame.__canvas\n"
" cdef uint8_t fraction = framerate_fraction\n"
" cdef cppinc.FrameCanvas* swapped\n"
" with nogil:\n"
" swapped = matrix.SwapOnVSync(frame, fraction)\n"
" return __createFrameCanvas(swapped)\n"
)
if OLD_SWAP in s:
s = s.replace(OLD_SWAP, NEW_SWAP, 1)
print(" core.pyx: SwapOnVSync releases the GIL")
elif "swapped = matrix.SwapOnVSync(frame, fraction)" in s:
print(" core.pyx: SwapOnVSync already patched")
else:
sys.exit("could not find the SwapOnVSync body")
# --- optional: release the GIL across the blit --------------------------
OLD_BLIT = (
" buffer = get_pillow_buffer(image_capsule)\n"
"\n"
" for col in range(max(0, -xstart), min(width, frame_width - xstart)):\n"
" for row in range(max(0, -ystart), min(height, frame_height - ystart)):\n"
" pixel = buffer[row][col]\n"
" r = (pixel ) & 0xFF\n"
" g = (pixel >> 8) & 0xFF\n"
" b = (pixel >> 16) & 0xFF\n"
" my_canvas.SetPixel(xstart+col, ystart+row, r, g, b)\n"
)
NEW_BLIT = (
" buffer = get_pillow_buffer(image_capsule)\n"
"\n"
" # Bounds hoisted so the blit needs no Python state and can run\n"
" # without the GIL: it touches only a C buffer and a C++ canvas.\n"
" # NOTE: row-major order makes a torn frame show as a horizontal\n"
" # split across the panel's halves. See the header before enabling.\n"
" cdef int col_start = max(0, -xstart)\n"
" cdef int col_end = min(width, frame_width - xstart)\n"
" cdef int row_start = max(0, -ystart)\n"
" cdef int row_end = min(height, frame_height - ystart)\n"
"\n"
" with nogil:\n"
" for row in range(row_start, row_end):\n"
" for col in range(col_start, col_end):\n"
" pixel = buffer[row][col]\n"
" r = (pixel ) & 0xFF\n"
" g = (pixel >> 8) & 0xFF\n"
" b = (pixel >> 16) & 0xFF\n"
" my_canvas.SetPixel(xstart+col, ystart+row, r, g, b)\n"
)
if patch_blit:
if OLD_BLIT in s:
s = s.replace(OLD_BLIT, NEW_BLIT, 1)
print(" core.pyx: pixel blit releases the GIL, row-major")
elif "for row in range(row_start, row_end):" in s:
print(" core.pyx: blit already patched")
else:
sys.exit("could not find the SetPixelsPillow loop")
else:
print(" core.pyx: blit left unpatched (RGB_PATCH_BLIT=1 to enable)")
io.open(p, "w", encoding="utf-8", newline="\n").write(s)
PYEOF
echo "==> building librgbmatrix.a (this takes a few minutes)"
nice -n 10 make -C "$BUILD_DIR/lib" -j2 >/dev/null 2>&1 \
|| die "library build failed - rerun 'make -C $BUILD_DIR/lib' to see why"
[ -f "$BUILD_DIR/lib/librgbmatrix.a" ] || die "librgbmatrix.a was not produced"
echo "==> preparing Cython"
[ -d "$VENV" ] || python3 -m venv --system-site-packages "$VENV" || die "venv failed"
"$VENV/bin/pip" install --quiet cython || die "cython install failed"
cat > "$BUILD_DIR/bindings/python/setup.py" <<'EOF'
from setuptools import setup, Extension
from Cython.Build import cythonize
core = Extension(
"rgbmatrix.core",
sources=["rgbmatrix/core.pyx", "rgbmatrix/shims/pillow.c"],
include_dirs=["../../include", "rgbmatrix/shims"],
extra_objects=["../../lib/librgbmatrix.a"],
language="c++",
extra_compile_args=["-O3", "-Wall", "-fno-exceptions", "-std=c++11"],
extra_link_args=["-lrt", "-lm", "-lpthread"],
)
setup(name="rgbmatrix",
ext_modules=cythonize([core], language_level="3str",
compiler_directives={"binding": False}))
EOF
echo "==> compiling the extension"
( cd "$BUILD_DIR/bindings/python" && "$VENV/bin/python" setup.py build_ext --inplace ) \
>/dev/null 2>&1 || die "extension build failed"
SO="$(abi_so)" || true
[ -n "$SO" ] || die "no .so produced - expected $(abi_name) in $BUILD_DIR/bindings/python/rgbmatrix"
# Verify the GIL really is released before anyone installs this.
EXPECTED=1; [ "$PATCH_BLIT" = "1" ] && EXPECTED=2
PAIRS=$(grep -c "PyEval_SaveThread\|Py_UNBLOCK_THREADS" \
"$BUILD_DIR/bindings/python/rgbmatrix/core.cpp")
[ "$PAIRS" -ge "$EXPECTED" ] \
|| die "generated C++ has $PAIRS GIL-release sites, expected >= $EXPECTED"
echo
echo "BUILT: $SO"
echo " ($PAIRS GIL-release site(s) in the generated C++)"
echo
echo "Install with: sudo bash $0 --install"
echo "Roll back with: sudo bash $0 --rollback"
+86
View File
@@ -0,0 +1,86 @@
#!/bin/bash
# DNS single-request fix installation script.
#
# Optional. Install this only if plugins that call external APIs (Starlark
# apps, weather, sports, music) are timing out or feel slow to first paint
# while the network is otherwise fine. See the header of
# scripts/utils/apply_dns_single_request.sh for what it changes and why.
set -e
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
SERVICE_NAME="ledmatrix-dns-fix"
UNIT_SRC="$PROJECT_ROOT_DIR/systemd/$SERVICE_NAME.service"
UNIT_DEST="/etc/systemd/system/$SERVICE_NAME.service"
DROPIN_DIR="/etc/systemd/system/ledmatrix.service.d"
if [ "$EUID" -eq 0 ]; then
SYSTEMCTL_CMD="systemctl"
SUDO=""
else
SYSTEMCTL_CMD="sudo systemctl"
SUDO="sudo"
fi
echo "Installing LED Matrix DNS fix service"
echo "Project root directory: $PROJECT_ROOT_DIR"
if [ ! -f "$UNIT_SRC" ]; then
echo "✗ Missing unit file: $UNIT_SRC"
exit 1
fi
chmod +x "$PROJECT_ROOT_DIR/scripts/utils/apply_dns_single_request.sh"
echo "Installing $UNIT_DEST..."
sed "s|__PROJECT_ROOT_DIR__|$PROJECT_ROOT_DIR|g" "$UNIT_SRC" \
| $SUDO tee "$UNIT_DEST" > /dev/null
# Order ledmatrix.service after the fix. `Before=` in the unit itself only
# orders units already in the same transaction, so a plain
# `systemctl restart ledmatrix` would not wait for it -- and since this fix is
# opt-in, ledmatrix.service cannot carry the dependency in the repo.
# Wants=, not Requires=: a DNS workaround failing should not stop the display.
echo "Installing the ledmatrix.service ordering drop-in..."
$SUDO mkdir -p "$DROPIN_DIR"
printf '[Unit]\nWants=%s.service\nAfter=%s.service\n' "$SERVICE_NAME" "$SERVICE_NAME" \
| $SUDO tee "$DROPIN_DIR/10-dns-fix.conf" > /dev/null
$SYSTEMCTL_CMD daemon-reload
$SYSTEMCTL_CMD enable "$SERVICE_NAME.service"
# Do not mask a failure here. The unit exits non-zero when it could not apply
# the option -- a systemd-resolved host, an unwritable resolv.conf, a failed
# `resolvconf -u` -- and reporting "installation complete" over that would
# leave the operator believing a workaround is active when it is not.
START_STATUS=0
$SYSTEMCTL_CMD start "$SERVICE_NAME.service" || START_STATUS=$?
echo ""
if grep -qs "^options single-request$" /etc/resolv.conf; then
echo "✓ 'options single-request' is active in /etc/resolv.conf"
elif [ "$START_STATUS" -ne 0 ]; then
echo "✗ The DNS fix could not be applied on this host."
echo " The service reported why:"
echo " journalctl -u $SERVICE_NAME -n 20"
echo ""
echo " The unit is installed and will try again on the next boot. Nothing"
echo " else about your install has changed."
exit "$START_STATUS"
else
echo "⚠ 'options single-request' is not in /etc/resolv.conf yet."
echo " Check what the service reported:"
echo " journalctl -u $SERVICE_NAME -n 20"
fi
echo ""
echo "DNS fix installation complete."
echo ""
echo "Useful commands:"
echo " sudo systemctl status $SERVICE_NAME # Check status"
echo " sudo journalctl -u $SERVICE_NAME -n 50 # View logs"
echo " sudo systemctl disable --now $SERVICE_NAME # Undo the service"
echo " sudo rm $DROPIN_DIR/10-dns-fix.conf # Undo the ordering drop-in"
echo " # then remove the 'options single-request' line from /etc/resolv.conf"
echo ""
+64
View File
@@ -0,0 +1,64 @@
#!/bin/bash
# Home Assistant MQTT bridge installation script.
#
# Optional. Installs integrations/mqtt_bridge as a service so Home Assistant
# can force display modes, toggle power and set brightness over MQTT.
# See integrations/mqtt_bridge/README.md.
set -e
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
BRIDGE_DIR="$PROJECT_ROOT_DIR/integrations/mqtt_bridge"
SERVICE_NAME="ledmatrix-mqtt-bridge"
UNIT_SRC="$PROJECT_ROOT_DIR/systemd/$SERVICE_NAME.service"
UNIT_DEST="/etc/systemd/system/$SERVICE_NAME.service"
if [ "$EUID" -eq 0 ]; then
SYSTEMCTL_CMD="systemctl"
SUDO=""
else
SYSTEMCTL_CMD="sudo systemctl"
SUDO="sudo"
fi
echo "Installing LED Matrix MQTT bridge"
echo "Project root directory: $PROJECT_ROOT_DIR"
if [ ! -f "$BRIDGE_DIR/bridge_config.json" ]; then
cp "$BRIDGE_DIR/bridge_config.example.json" "$BRIDGE_DIR/bridge_config.json"
chmod 600 "$BRIDGE_DIR/bridge_config.json"
echo ""
echo "⚠ Created $BRIDGE_DIR/bridge_config.json from the example."
echo " Edit it with your broker details, then re-run this script."
echo " The service will refuse to start until the placeholder password is replaced."
echo ""
fi
echo "Installing Python dependencies..."
python3 -m pip install -r "$BRIDGE_DIR/requirements.txt" 2>/dev/null \
|| python3 -m pip install --break-system-packages -r "$BRIDGE_DIR/requirements.txt"
echo "Installing $UNIT_DEST..."
sed "s|__PROJECT_ROOT_DIR__|$PROJECT_ROOT_DIR|g" "$UNIT_SRC" \
| $SUDO tee "$UNIT_DEST" > /dev/null
$SYSTEMCTL_CMD daemon-reload
$SYSTEMCTL_CMD enable "$SERVICE_NAME.service"
$SYSTEMCTL_CMD restart "$SERVICE_NAME.service" || true
echo ""
if $SYSTEMCTL_CMD is-active --quiet "$SERVICE_NAME.service" 2>/dev/null; then
echo "✓ MQTT bridge is running"
echo " The matrix should appear in Home Assistant under Settings > Devices > MQTT."
else
echo "⚠ MQTT bridge is not running. Check the logs:"
echo " sudo journalctl -u $SERVICE_NAME -n 50"
fi
echo ""
echo "Useful commands:"
echo " sudo systemctl status $SERVICE_NAME"
echo " sudo journalctl -u $SERVICE_NAME -f"
echo " sudo systemctl disable --now $SERVICE_NAME # Undo"
echo ""
+23 -1
View File
@@ -52,6 +52,10 @@ def main() -> int:
parser.add_argument('--height', type=int, default=32, help='Display height (default: 32)')
parser.add_argument('--skip-update', action='store_true',
help='Skip calling update() (render display only)')
parser.add_argument('--display-mode', default=None,
help='Display mode to render, for plugins that declare '
'more than one in their manifest (e.g. nrl_live). '
'Omitted, the plugin picks its own default.')
args = parser.parse_args()
@@ -141,8 +145,26 @@ def main() -> int:
except Exception as e:
logger.warning("update() raised: %s — continuing to display()", e)
# A plugin that declares several display modes usually renders nothing
# useful without being told which one to draw: the scoreboards keep their
# state on per-mode sub-managers and their no-argument path returns False.
# Only pass the argument when asked for, so the many plugins whose display()
# takes no display_mode keep working untouched.
try:
plugin_instance.display(force_clear=True)
if args.display_mode:
try:
plugin_instance.display(display_mode=args.display_mode,
force_clear=True)
except TypeError as error:
if ("unexpected keyword argument" not in str(error)
or "display_mode" not in str(error)):
raise
logger.warning(
"%s.display() does not accept display_mode; rendering its "
"default screen instead", args.plugin)
plugin_instance.display(force_clear=True)
else:
plugin_instance.display(force_clear=True)
logger.debug("display() completed")
except Exception as e:
logger.error("Error in display(): %s", e)
+96 -6
View File
@@ -6,6 +6,9 @@ Discovers and runs tests for LEDMatrix plugins.
Supports both unittest and pytest.
"""
import os
import re
import subprocess # nosec B404 - list-form argv only, no shell # nosemgrep
import sys
import argparse
from pathlib import Path
@@ -68,6 +71,79 @@ def _find_tests_in_dir(directory: Path) -> list:
return sorted(set(test_files))
def _is_script_style(path) -> bool:
"""True when a test file is a standalone script, not a pytest module.
Most plugin tests are written as `def main()` plus an `if __name__ ==
"__main__"` guard and signal through an exit code. pytest collects zero
items from those, so handing them to pytest printed "no tests ran" and this
runner reported success over work it had not done -- 151 of 248 files on a
fully populated rig.
"""
try:
src = Path(path).read_text(encoding="utf-8", errors="replace")
except OSError:
return False
has_pytest_items = re.search(r"^\s*(def test_|class Test|async def test_)", src, re.M)
has_main_guard = "__main__" in src and "__name__" in src
return bool(has_main_guard and not has_pytest_items)
def run_script_tests(test_files: list, verbose: bool = False) -> int:
"""Run standalone test scripts, honouring the 0 pass / 2 skip / 1 fail
convention that ledmatrix-plugins' own runner established.
Scripts opt into skipping by printing "SKIP: <reason>" and exiting 2 --
a script that needs a tty or an LED matrix is not a regression.
"""
env = dict(os.environ)
# Prepend rather than setdefault. An inherited PYTHONPATH -- a developer's
# shell, a tox run, another checkout -- otherwise wins outright, and the
# subprocess imports a different copy of the core than the one under test.
# That is exactly the failure ledmatrix-plugins#467 describes, and it is
# invisible: the tests pass or fail against a tree nobody meant to test.
inherited = env.get("PYTHONPATH")
env["PYTHONPATH"] = (f"{PROJECT_ROOT}{os.pathsep}{inherited}"
if inherited else str(PROJECT_ROOT))
env["LEDMATRIX_CORE"] = str(PROJECT_ROOT)
passed = skipped = failed = 0
failures = []
for path in test_files:
try:
# Fixed interpreter (sys.executable) plus a test path this script
# discovered by globbing the repo; argument list, no shell, so
# nothing is word-split or expanded. Same suppression pair the
# rest of the repo uses for this shape (see permission_utils.py).
proc = subprocess.run( # noqa: S603 # nosec B603 - no shell invoked (list-form argv) # nosemgrep
[sys.executable, str(path)], # nosemgrep
cwd=str(Path(path).parent),
capture_output=True, text=True, env=env,
stdin=subprocess.DEVNULL, timeout=300,
)
rc = proc.returncode
tail = " | ".join((proc.stdout or proc.stderr or "").strip().splitlines()[-2:])[:200]
except subprocess.TimeoutExpired:
rc, tail = 1, "timed out after 300s"
if rc == 0:
passed += 1
label = "pass"
elif rc == 2:
skipped += 1
label = "SKIP"
else:
failed += 1
label = "FAIL"
failures.append(f"{Path(path).name}: exit {rc} | {tail}")
if verbose or rc != 0:
print(f" [{label}] {Path(path).name}" + (f" -- {tail}" if rc != 0 else ""))
print(f"\n{passed} passed, {skipped} skipped, {failed} failed (scripts)")
for f in failures:
print(f" - {f}", file=sys.stderr)
return 1 if failed else 0
def run_unittest_tests(test_files: list, verbose: bool = False) -> int:
"""
Run tests using unittest.
@@ -186,7 +262,12 @@ def main():
print("No test files found in plugins directory")
return 0
print(f"Found {len(test_files)} test file(s)")
scripts = [f for f in test_files if _is_script_style(f)]
modules = [f for f in test_files if f not in scripts]
print(f"Found {len(test_files)} test file(s)"
+ (f" -- {len(modules)} collectable, {len(scripts)} standalone script(s)"
if scripts else ""))
for test_file in test_files:
print(f" - {test_file}")
print()
@@ -200,11 +281,20 @@ def main():
except ImportError:
runner = 'unittest'
# Run tests
if runner == 'pytest':
return run_pytest_tests(test_files, args.verbose, args.coverage)
else:
return run_unittest_tests(test_files, args.verbose)
# Standalone scripts cannot be collected by pytest or unittest -- run them
# as the scripts they are. Doing this rather than silently collecting zero
# items is the whole point: this runner used to report success having
# executed nothing.
rc = 0
if scripts:
rc |= run_script_tests(scripts, args.verbose)
if modules:
if runner == 'pytest':
rc |= run_pytest_tests(modules, args.verbose, args.coverage)
else:
rc |= run_unittest_tests(modules, args.verbose)
return rc
if __name__ == '__main__':
+240
View File
@@ -0,0 +1,240 @@
#!/usr/bin/env python3
"""Show and try the scroll speeds your panel can display cleanly.
Motion looks smooth when the strip advances a WHOLE number of pixels per panel
refresh. Anything else has to blend two columns (which on pixel-font text reads
as shimmer) or repeat frames unevenly (which reads as judder). So the speeds
worth using are not arbitrary -- they are
refresh_hz / frame_hold * pixels_per_frame
for whole numbers of frame_hold and pixels_per_frame, and that ladder depends
on how fast YOUR panel actually refreshes. A Pi Zero driving a big chain will
have a completely different set of good speeds from a Pi 4 driving a small one.
# what can this panel do? (no hardware needed, uses your configured rate)
python3 scripts/scroll_speeds.py
# measure what the panel ACTUALLY manages, rather than what is configured
sudo systemctl stop ledmatrix
sudo python3 scripts/scroll_speeds.py --measure
sudo systemctl start ledmatrix
# what would a 60Hz panel offer?
python3 scripts/scroll_speeds.py --hz 60
# try one on the panel
sudo systemctl stop ledmatrix
sudo python3 scripts/scroll_speeds.py --demo 50
sudo systemctl start ledmatrix
This script never starts or stops the display service itself -- that is left to
you, so a crash here can never leave the panel dark.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.common import scroll_config # noqa: E402
CONFIG = Path(__file__).resolve().parent.parent / "config" / "config.json"
def load_hardware():
try:
with open(CONFIG, encoding="utf-8") as handle:
return (json.load(handle).get("display") or {}).get("hardware") or {}
except (OSError, ValueError):
return {}
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))
o.brightness = int(hardware.get("brightness", 80))
o.hardware_mapping = hardware.get("hardware_mapping", "regular")
o.pwm_bits = int(hardware.get("pwm_bits", 11))
o.pwm_dither_bits = int(hardware.get("pwm_dither_bits", 0))
o.pwm_lsb_nanoseconds = int(hardware.get("pwm_lsb_nanoseconds", 130))
o.led_rgb_sequence = hardware.get("led_rgb_sequence", "RGB")
o.scan_mode = int(hardware.get("scan_mode", 0))
o.row_address_type = int(hardware.get("row_address_type", 0))
o.multiplexing = int(hardware.get("multiplexing", 0))
o.gpio_slowdown = int(hardware.get("gpio_slowdown", 2))
o.limit_refresh_rate_hz = (
int(refresh_override) if refresh_override is not None
else int(hardware.get("limit_refresh_rate_hz", 0))
)
return o
def open_matrix(hardware, refresh_override=None):
"""Construct the matrix, or explain why it will not open."""
if os.geteuid() != 0:
sys.exit("this needs root for GPIO access - rerun with sudo")
try:
from rgbmatrix import RGBMatrix
except ImportError:
sys.exit("rgbmatrix is not installed on this machine")
try:
return RGBMatrix(options=build_options(hardware, refresh_override))
except Exception as exc: # pragma: no cover - hardware dependent
sys.exit(
"could not open the panel ({}).\n"
"If the display service is running it owns the GPIO - stop it first:\n"
" sudo systemctl stop ledmatrix".format(exc)
)
def measure_refresh(hardware, seconds=6.0):
"""Actual refresh rate, by running uncapped and timing the swaps.
SwapOnVSync blocks until the panel's next refresh, so an unthrottled loop
runs at exactly the panel's rate. This is what an older Pi or a longer
chain will really give you, as opposed to whatever limit_refresh_rate_hz
optimistically asks for.
"""
matrix = open_matrix(hardware, refresh_override=0)
canvas = matrix.CreateFrameCanvas()
canvas = matrix.SwapOnVSync(canvas) # discard the first, it includes setup
frames = 0
started = time.perf_counter()
while time.perf_counter() - started < seconds:
canvas = matrix.SwapOnVSync(canvas)
frames += 1
measured = frames / (time.perf_counter() - started)
matrix.Clear()
return measured
def demo(hardware, target, seconds):
"""Scroll text at the crisp speed nearest `target`."""
from PIL import Image, ImageDraw, ImageFont
from src.common.font_layout import load_truetype
hz = float(hardware.get("limit_refresh_rate_hz") or scroll_config.DEFAULT_REFRESH_HZ)
choice = scroll_config.solve_crisp(target, hz)
print("asked for {:.0f} px/s -> {}".format(target, choice.describe()))
matrix = open_matrix(hardware)
canvas = matrix.CreateFrameCanvas()
W, H = canvas.width, canvas.height
font = None
for path, size in (
(str(Path(__file__).resolve().parent.parent / "assets/fonts/PressStart2P-Regular.ttf"), 16),
("/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf", 26),
):
try:
font = load_truetype(path, size)
break
except OSError:
continue
if font is None:
font = ImageFont.load_default()
text = " {:.0f} px/s *** THE QUICK BROWN FOX JUMPS OVER THE LAZY DOG ***".format(
choice.pixels_per_second)
box = ImageDraw.Draw(Image.new("RGB", (8, 8))).textbbox((0, 0), text, font=font)
tw, th = box[2] - box[0], box[3] - box[1]
reps = max(2, (W * 3) // max(tw, 1) + 1)
strip = Image.new("RGB", (tw * reps, H), (0, 0, 0))
draw = ImageDraw.Draw(strip)
for i in range(reps):
draw.text((i * tw, (H - th) // 2 - box[1]), text, font=font, fill=(255, 210, 60))
offset = 0
frames = 0
started = time.time()
while time.time() - started < seconds:
window = strip.crop((offset, 0, offset + W, H))
if window.width < W:
whole = Image.new("RGB", (W, H), (0, 0, 0))
head = strip.crop((offset, 0, strip.width, H))
whole.paste(head, (0, 0))
whole.paste(strip.crop((0, 0, W - head.width, H)), (head.width, 0))
window = whole
canvas.SetImage(window)
canvas = matrix.SwapOnVSync(canvas, choice.frame_hold)
offset = (offset + choice.pixels_per_frame) % strip.width
frames += 1
elapsed = time.time() - started
print(" {} frames in {:.1f}s = {:.1f} fps = {:.1f} px/s actual".format(
frames, elapsed, frames / elapsed, frames * choice.pixels_per_frame / elapsed))
matrix.Clear()
def print_ladder(hz, highlight=None):
print("")
print("Whole-pixel scroll speeds at {:.1f}Hz refresh".format(hz))
print("(the panel refreshes at {:.0f}Hz for every one of these - holding a "
"frame costs no flicker)".format(hz))
print("")
for entry in scroll_config.crisp_ladder(hz):
if entry.pixels_per_second > hz * 3:
break
mark = " <-- nearest to {:.0f}".format(highlight) if (
highlight is not None
and entry.pixels_per_second == scroll_config.solve_crisp(highlight, hz).pixels_per_second
) else ""
print(" " + entry.describe() + mark)
print("")
print("Set one in config.json as pixels per second, e.g.")
print(' "display_options": {{"scroll_pixels_per_second": {:.0f}}}'.format(
scroll_config.solve_crisp(highlight if highlight else hz / 2, hz).pixels_per_second))
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--hz", type=float,
help="refresh rate to compute the ladder for (default: your config)")
ap.add_argument("--measure", action="store_true",
help="measure the panel's real refresh rate (needs root, service stopped)")
ap.add_argument("--demo", type=float, metavar="PXPS",
help="scroll text at the crisp speed nearest this (needs root)")
ap.add_argument("--seconds", type=float, default=15.0, help="demo duration")
ap.add_argument("--want", type=float, metavar="PXPS",
help="highlight the entry nearest this speed")
args = ap.parse_args()
hardware = load_hardware()
configured = float(hardware.get("limit_refresh_rate_hz") or 0)
if args.demo is not None:
demo(hardware, args.demo, args.seconds)
return
if args.measure:
measured = measure_refresh(hardware)
print("measured panel refresh: {:.1f}Hz".format(measured))
if configured:
print("configured limit_refresh_rate_hz: {:.0f}".format(configured))
if measured < configured * 0.95:
print(" -> the panel cannot reach the configured rate; the ladder")
print(" below uses what it actually manages")
print_ladder(measured, args.want)
return
hz = args.hz or configured or scroll_config.DEFAULT_REFRESH_HZ
if not args.hz and not configured:
print("no limit_refresh_rate_hz in config; assuming {:.0f}Hz".format(hz))
print("run with --measure to find your panel's real rate")
print_ladder(hz, args.want)
if __name__ == "__main__":
main()
+196
View File
@@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""Drive a sports scoreboard scroll on the panel and report what it did.
The eight sports scoreboards scroll through ``src/common/sports_scroll.py``,
and that path is per-league opt-in: a rig showing static game cards never
constructs a SportsScrollDisplay at all, so nothing about its pacing can be
observed from a normal run. This drives it directly, with synthetic games, so
the pacing can be measured without changing anyone's configuration.
What it checks is what the shared resolver is supposed to buy:
* the requested speed lands on a whole number of pixels per refresh
* the frame hold that makes that true is published to the display manager
* frames actually arrive at the interval the hold implies
sudo systemctl stop ledmatrix
sudo python3 scripts/sports_scroll_check.py --seconds 20
sudo systemctl start ledmatrix
Like scripts/scroll_speeds.py, this never starts or stops the display service
itself -- that is left to the caller, so a crash here cannot leave the panel
dark.
"""
from __future__ import annotations
import argparse
import json
import statistics
import subprocess # nosec B404 - list-form argv only, no shell # nosemgrep
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from PIL import Image # noqa: E402
from src.common.sports_scroll import SportsScrollDisplay # noqa: E402
from src.display_manager import DisplayManager # noqa: E402
class _Check(SportsScrollDisplay):
"""A scoreboard whose cards are plain blocks -- pacing is what matters."""
SCROLL_LEAGUE_KEYS = ("nfl",)
def prepare_scroll_content(self, games, game_type, leagues, rankings_cache=None):
width = self.display_height * 2
cards = []
for i, _ in enumerate(games):
card = Image.new("RGB", (width, self.display_height), (0, 0, 0))
shade = 40 + (i * 37) % 180
for x in range(2, width - 2):
for y in range(2, self.display_height - 2):
card.putpixel((x, y), (shade, 90, 220 - shade // 2))
cards.append(card)
self._current_games = list(games)
self._current_game_type = game_type
self._current_leagues = list(leagues)
self.scroll_helper.create_scrolling_image(content_items=cards, item_gap=24)
return bool(cards)
class _HoldSpy:
"""Records what the scroll publishes, without changing what it does."""
def __init__(self, display_manager):
self.dm = display_manager
self.calls = []
self._real = display_manager.set_scrolling_state
def __enter__(self):
def spy(is_scrolling, frame_hold=1):
self.calls.append((is_scrolling, frame_hold))
return self._real(is_scrolling, frame_hold=frame_hold)
self.dm.set_scrolling_state = spy
return self
def __exit__(self, *exc):
self.dm.set_scrolling_state = self._real
return False
MESSAGE = """ledmatrix is running and owns the panel's GPIO.
Stop it first, or this run can leave the display dark:
sudo systemctl stop ledmatrix
sudo python3 scripts/sports_scroll_check.py
sudo systemctl start ledmatrix
Use --fallback to check the pacing logic without the panel, or --force if
you really mean it."""
def _refuse_if_the_service_is_running(force):
"""Refuse to touch the panel while ledmatrix has it.
rpi-rgb-led-matrix configures GPIO directions and the hardware PWM inside
RGBMatrix(), and on the root check it calls exit() from C -- no cleanup.
Do that while the service is driving those same pins and the panel goes
dark while the service carries on rendering happily: fresh framebuffer,
every pixel lit, "RGB Matrix initialized successfully", nothing in the log.
A restart brings it back, but only once you work out that is what happened.
The module docstring says to stop the service first. This makes it true.
"""
if force:
return
try:
active = subprocess.run( # nosec B603 B607 - hardcoded systemctl args # nosemgrep
["systemctl", "is-active", "ledmatrix"],
capture_output=True, text=True).stdout.strip()
except OSError:
return # not a systemd box; nothing to protect
if active == "active":
sys.exit(MESSAGE)
def main():
ap = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--seconds", type=float, default=20.0)
ap.add_argument("--speed", type=float, default=None,
help="px/s to request; default is the module's own")
ap.add_argument("--games", type=int, default=6)
ap.add_argument("--force", action="store_true",
help="run even though the display service is up. It owns "
"the GPIO; expect a dark panel until you restart it.")
ap.add_argument("--fallback", action="store_true",
help="run without the panel. Driving the real matrix needs "
"root; this checks everything except the vsync pacing "
"-- what speed resolves to, that the hold is published, "
"and that it is released afterwards.")
args = ap.parse_args()
if not args.fallback:
_refuse_if_the_service_is_running(args.force)
root = Path(__file__).resolve().parent.parent
config = json.loads((root / "config" / "config.json").read_text(encoding="utf-8"))
display_manager = DisplayManager(config, force_fallback=args.fallback)
settings = {} if args.speed is None else {
"nfl": {"scroll_settings": {"scroll_speed": args.speed}}}
display = _Check(display_manager, settings, global_config=config)
resolved = display._scroll_settings
print("resolved: %s" % resolved.describe())
print("frame hold: %d refresh(es) per frame" % resolved.frame_hold)
if resolved.warning:
print("warning: %s" % resolved.warning)
display.prepare_scroll_content(
[{"id": "g%d" % i} for i in range(args.games)], "live", ["nfl"])
gaps, drawn = [], 0
last = None
with _HoldSpy(display_manager) as spy:
started = time.perf_counter()
while time.perf_counter() - started < args.seconds:
if not display.display_scroll_frame():
break
now = time.perf_counter()
if last is not None:
gaps.append((now - last) * 1000.0)
last = now
drawn += 1
display.clear()
if not gaps:
sys.exit("no frames were drawn -- the scroll never started")
gaps.sort()
expected = 1000.0 * resolved.frame_hold / (resolved.crisp.refresh_hz
if resolved.crisp else 100.0)
print("\n%d frames in %.1fs -> %.1f fps" % (
drawn, args.seconds, drawn / args.seconds))
print("frame gap median %.2fms p95 %.2fms max %.2fms (hold implies %.2fms)"
% (statistics.median(gaps), gaps[int(len(gaps) * 0.95)], gaps[-1], expected))
holds = {h for on, h in spy.calls if on}
print("published while scrolling: frame_hold=%s" % (sorted(holds) or "NOTHING"))
print("released on clear: %s" % any(not on for on, _ in spy.calls))
print("display manager hold now: %d (1 means released)"
% getattr(display_manager, "_frame_hold", -1))
if not holds:
sys.exit("FAIL: the scroll never told the core it was scrolling")
if holds != {resolved.frame_hold}:
sys.exit("FAIL: published %s but resolved %d" % (holds, resolved.frame_hold))
print("\nOK: the resolved hold reached the panel and was released after")
if __name__ == "__main__":
main()
+30
View File
@@ -9,6 +9,8 @@ This directory contains utility scripts for maintenance and system operations.
- **`wifi_monitor_daemon.py`** - Background daemon that monitors WiFi/Ethernet connection and manages access point mode
- **`cleanup_venv.sh`** - Cleans up Python virtual environment files
- **`clear_python_cache.sh`** - Clears Python cache files (__pycache__, *.pyc, etc.)
- **`pixlet_config_editor.sh`** - Opens Pixlet's own config UI for one installed Starlark app
- **`apply_dns_single_request.sh`** - Adds `options single-request` to the resolver (run by `ledmatrix-dns-fix.service`)
## Usage
@@ -25,3 +27,31 @@ This script is typically called by the systemd service (`ledmatrix-web.service`)
### WiFi Monitor Daemon
This daemon is typically run as a systemd service (`ledmatrix-wifi-monitor.service`) and automatically manages WiFi access point mode based on network connectivity.
### Pixlet Config Editor
Run it when you want Pixlet's own config form for a Starlark app -- live
render preview, cascading dropdowns -- rather than the LEDMatrix one.
```bash
./scripts/utils/pixlet_config_editor.sh # list installed apps
./scripts/utils/pixlet_config_editor.sh penndot_signs # edit, on localhost:8080
```
Deliberately not a service. It stops the display for the length of the
session and `pixlet serve` listens with no authentication, so it should only
be running while you are actually editing. It backs the config up first and
restarts the display on exit, however it exits.
It binds loopback only, with no flag to change that: anything that can reach
`pixlet serve` can rewrite the app's config, and a printed warning is not
access control. To edit from another machine, forward the port -- SSH does the
authenticating and nothing is left listening on the LAN:
```bash
ssh -L 8080:localhost:8080 pi@ledpi.local
```
### Apply DNS Single-Request Fix
Installed and run by `ledmatrix-dns-fix.service`; see `systemd/README.md`.
Safe to run by hand (`sudo ./scripts/utils/apply_dns_single_request.sh`) and
idempotent.
+94
View File
@@ -0,0 +1,94 @@
#!/bin/bash
#
# Add `options single-request` to the system resolver configuration.
#
# glibc's getaddrinfo() sends the A and AAAA queries for a name in
# parallel on one socket. Some routers answer the A query and drop the
# AAAA one, so the resolver waits out its full timeout -- about five
# seconds -- before returning an address that was already available.
# Disabling IPv6 in the kernel does not help: the resolver still asks.
#
# `single-request` makes it send the two queries one after the other,
# which those routers answer correctly. Anything on the matrix that
# calls an external API pays that five seconds per lookup otherwise, and
# a Starlark app with a render timeout will simply fail instead.
#
# Idempotent, and safe to run on a machine that does not need it. Run by
# ledmatrix-dns-fix.service on every boot, because whatever manages
# resolv.conf regenerates it and drops the option again.
#
# Usage: sudo ./scripts/utils/apply_dns_single_request.sh
set -eu
OPTION="options single-request"
RESOLVCONF_TAIL="/etc/resolvconf/resolv.conf.d/tail"
RESOLV_CONF="/etc/resolv.conf"
log() { echo "[dns-single-request] $*"; }
already_applied() {
grep -qs "^${OPTION}\$" "$1"
}
# resolvconf regenerates /etc/resolv.conf from these fragments, so the
# tail file is the only place an addition survives. Prefer it when the
# directory exists, whether or not resolvconf has run yet.
if [ -d "$(dirname "$RESOLVCONF_TAIL")" ]; then
if already_applied "$RESOLVCONF_TAIL"; then
log "already present in $RESOLVCONF_TAIL"
else
echo "$OPTION" >> "$RESOLVCONF_TAIL"
log "added to $RESOLVCONF_TAIL"
fi
# Only a missing resolvconf is ignorable. If it is present and the
# regeneration fails, /etc/resolv.conf still lacks the option, and
# reporting success would be a lie.
if command -v resolvconf >/dev/null 2>&1; then
if ! resolvconf -u; then
log "resolvconf -u failed; $RESOLV_CONF was not regenerated"
exit 1
fi
fi
fi
# systemd-resolved owns its stub file and rewrites anything appended to it,
# and `single-request` is a glibc resolv.conf option with no resolved.conf
# equivalent -- so there is nothing this script can do here. Exit non-zero:
# the unit would otherwise record success while the workaround is inactive,
# which is the failure mode this whole script exists to avoid.
if [ -L "$RESOLV_CONF" ] && readlink -f "$RESOLV_CONF" | grep -q "systemd"; then
log "$RESOLV_CONF is managed by systemd-resolved."
log "'options single-request' is a glibc resolv.conf option and has no"
log "resolved.conf equivalent, so it cannot be applied on this host."
log "If external API calls are slow, the workaround is to stop using the"
log "systemd-resolved stub (see 'man systemd-resolved', NSS/resolv.conf modes)."
exit 1
fi
if already_applied "$RESOLV_CONF"; then
log "already present in $RESOLV_CONF"
exit 0
fi
if [ ! -w "$RESOLV_CONF" ] && [ -e "$RESOLV_CONF" ]; then
log "cannot write $RESOLV_CONF (run with sudo?)"
exit 1
fi
# A NetworkManager-generated resolv.conf is regenerated on every connection
# change, not only at boot -- and this unit is oneshot with RemainAfterExit,
# so it will not re-run within the same boot to put the option back. Say so
# rather than implying the fix is permanent. Nothing is silently swallowed:
# the append below still happens and still works until the next renewal.
if grep -qs "Generated by NetworkManager" "$RESOLV_CONF" \
&& [ ! -d "$(dirname "$RESOLVCONF_TAIL")" ]; then
log "NOTE: $RESOLV_CONF is generated by NetworkManager and has no"
log "resolvconf tail directory to write to. The option is being added, but"
log "NetworkManager will drop it on the next connection renewal, and this"
log "unit does not run again until the next boot. If lookups go slow again"
log "before a reboot, re-run this script."
fi
echo "$OPTION" >> "$RESOLV_CONF"
log "added to $RESOLV_CONF"
+197
View File
@@ -0,0 +1,197 @@
#!/bin/bash
#
# Edit an installed Starlark app's config in Pixlet's own config UI.
#
# `pixlet serve` runs the app for real, so its form has working cascading
# dropdowns and option lists fetched live -- useful for an app whose choices
# only exist at runtime, or when you want to see the render change as you
# type. The LEDMatrix config form now reads the same runtime schema (see
# PixletRenderer.extract_schema_via_pixlet), so reach for this when you want
# Pixlet's live preview, not because the normal form is missing options.
#
# Deliberately a script you run and then Ctrl+C, not a service: it stops the
# display for the length of the session, and `pixlet serve` listens on a port
# with no authentication. Nothing here should be listening when you are not
# actually editing.
#
# Usage:
# ./scripts/utils/pixlet_config_editor.sh # list installed apps
# ./scripts/utils/pixlet_config_editor.sh <app_id> # edit
#
# Binds the LAN by default, matching the web interface, which already serves
# 0.0.0.0:5000 with no authentication -- anything that can reach this can
# already reconfigure the display there. `pixlet serve` has no authentication
# either, so treat both the same way: fine on a home network, not on an open
# one. Override the bind and the session length with:
#
# PIXLET_EDITOR_HOST=127.0.0.1 ./scripts/utils/pixlet_config_editor.sh <app>
# PIXLET_EDITOR_TIMEOUT=600 ./scripts/utils/pixlet_config_editor.sh <app>
#
# For loopback-only editing from another machine, forward the port instead:
#
# ssh -L 8080:localhost:8080 pi@ledpi.local
#
# The session always ends by itself after PIXLET_EDITOR_TIMEOUT seconds
# (default 30 minutes). The display is stopped while editing, so a session
# left open would otherwise leave the panel dark indefinitely -- the timeout
# is what makes it safe to start one from the web interface.
set -eu
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
APPS_DIR="$PROJECT_ROOT_DIR/starlark-apps"
PORT="${PIXLET_EDITOR_PORT:-8080}"
# LAN by default; see the header for why, and how to force loopback.
BIND_HOST="${PIXLET_EDITOR_HOST:-0.0.0.0}"
# Hard stop, so the display cannot be left off by a forgotten session.
EDITOR_TIMEOUT="${PIXLET_EDITOR_TIMEOUT:-1800}"
APP_ID="${1:-}"
list_apps() {
if [ -d "$APPS_DIR" ]; then
find "$APPS_DIR" -maxdepth 1 -mindepth 1 -type d -printf ' %f\n' 2>/dev/null | sort
fi
}
if [ -z "$APP_ID" ]; then
echo "Usage: $0 <app_id>"
echo ""
echo "Installed apps:"
list_apps || true
[ -n "$(list_apps)" ] || echo " (none found in $APPS_DIR)"
exit 1
fi
APP_DIR="$APPS_DIR/$APP_ID"
if [ ! -d "$APP_DIR" ]; then
echo "No such app: $APP_ID"
echo ""
echo "Installed apps:"
list_apps
exit 1
fi
STAR_FILE=$(find "$APP_DIR" -maxdepth 1 -iname "*.star" | head -1)
if [ -z "$STAR_FILE" ]; then
echo "No .star file found in $APP_DIR"
exit 1
fi
# Same search order the plugin itself uses: the bundled binary for this
# architecture first, then PATH -- so this works on an install that never put
# pixlet on PATH.
find_pixlet() {
local arch bundled
case "$(uname -s)-$(uname -m)" in
Linux-aarch64|Linux-arm64) arch="pixlet-linux-arm64" ;;
Linux-x86_64|Linux-amd64) arch="pixlet-linux-amd64" ;;
Darwin-arm64) arch="pixlet-darwin-arm64" ;;
Darwin-x86_64) arch="pixlet-darwin-amd64" ;;
*) arch="" ;;
esac
bundled="$PROJECT_ROOT_DIR/bin/pixlet/$arch"
if [ -n "$arch" ] && [ -x "$bundled" ]; then
echo "$bundled"
return 0
fi
command -v pixlet 2>/dev/null || return 1
}
PIXLET_BIN=$(find_pixlet) || {
echo "Pixlet not found. Install it with:"
echo " ./scripts/download_pixlet.sh"
exit 1
}
CONFIG_FILE="$APP_DIR/config.json"
if [ -f "$CONFIG_FILE" ]; then
cp "$CONFIG_FILE" "$CONFIG_FILE.backup"
echo "Backed up existing config to $CONFIG_FILE.backup"
else
echo "{}" > "$CONFIG_FILE"
fi
DISPLAY_WAS_RUNNING=false
if systemctl is-active --quiet ledmatrix 2>/dev/null; then
DISPLAY_WAS_RUNNING=true
fi
# Restart the display however this exits -- Ctrl+C, an error, or pixlet
# dying on its own. Leaving the panel dark because the editor crashed is the
# failure worth guarding against.
cleanup() {
echo ""
# Kill the serve child explicitly. `timeout` is started with --foreground so
# it shares this script's process group (without that it makes its own, and
# a group signal aimed at this script would orphan pixlet with the port
# still bound). Belt and braces: signal the recorded pid too, because a
# group signal only reaches it while the group is shared.
if [ -n "${SERVE_PID:-}" ] && kill -0 "$SERVE_PID" 2>/dev/null; then
kill -TERM "$SERVE_PID" 2>/dev/null || true
for _ in 1 2 3 4 5 6 7 8 9 10; do
kill -0 "$SERVE_PID" 2>/dev/null || break
sleep 0.3
done
kill -KILL "$SERVE_PID" 2>/dev/null || true
fi
if [ "$DISPLAY_WAS_RUNNING" = true ]; then
echo "Restarting the display service..."
sudo systemctl restart ledmatrix || echo "⚠ Could not restart ledmatrix - do it by hand"
fi
echo "Your config as it was before this session: $CONFIG_FILE.backup"
}
trap cleanup EXIT INT TERM
if [ "$DISPLAY_WAS_RUNNING" = true ]; then
echo "Stopping the display service so it does not read config.json mid-write..."
sudo systemctl stop ledmatrix
fi
if [ "$BIND_HOST" = "0.0.0.0" ]; then
REACH_HOST="$(hostname).local"
else
REACH_HOST="localhost"
fi
echo ""
echo "Editing: $APP_ID"
echo "App file: $STAR_FILE"
echo "URL: http://$REACH_HOST:$PORT/"
echo ""
if [ "$BIND_HOST" = "0.0.0.0" ]; then
echo "Reachable on the LAN, and pixlet serve has no authentication -- the"
echo "same footing as the web interface on port 5000. Set"
echo "PIXLET_EDITOR_HOST=127.0.0.1 to keep it to this machine."
else
echo "Listening on $BIND_HOST only. From another machine, forward the port:"
echo " ssh -L $PORT:localhost:$PORT $(whoami)@$(hostname)"
fi
echo ""
echo "Changes save straight to the real config as you make them."
echo "Press Ctrl+C when finished - the display restarts automatically."
echo "This session stops on its own after ${EDITOR_TIMEOUT}s regardless."
echo ""
cd "$APP_DIR"
# `timeout` owns the hard stop rather than the caller: the trap above restarts
# the display however this exits, so a session that outlives the person who
# started it still gives the panel back. Exit 124 is timeout's own code for
# "expired", which is a normal end here, not a failure.
# --foreground: stay in this script's process group so one signal reaches the
# whole session. Backgrounded + `wait` so the EXIT trap can run while the child
# is still alive; a foreground child would leave bash waiting on it instead.
timeout --foreground "$EDITOR_TIMEOUT" "$PIXLET_BIN" serve "$(basename "$STAR_FILE")" \
--host "$BIND_HOST" \
--port "$PORT" \
--no-browser \
--saveconfig "$CONFIG_FILE" &
SERVE_PID=$!
status=0
wait "$SERVE_PID" || status=$?
if [ "$status" -eq 124 ]; then
echo "Session reached its ${EDITOR_TIMEOUT}s limit."
status=0
fi
exit "$status"
+8 -6
View File
@@ -27,6 +27,8 @@ sys.path.insert(0, str(PROJECT_ROOT))
from PIL import Image, ImageDraw, ImageFont # noqa: E402
from src.common.font_layout import load_truetype # noqa: E402
FIXTURES_DIR = PROJECT_ROOT / "src" / "skin_system" / "fixtures"
MODES = ("live", "recent", "upcoming")
SPORTS = ("baseball", "basketball", "football", "hockey")
@@ -52,12 +54,12 @@ class FixtureHost:
try:
press = str(PROJECT_ROOT / "assets/fonts/PressStart2P-Regular.ttf")
small = str(PROJECT_ROOT / "assets/fonts/4x6-font.ttf")
fonts['score'] = ImageFont.truetype(press, 10)
fonts['time'] = ImageFont.truetype(press, 8)
fonts['team'] = ImageFont.truetype(press, 8)
fonts['status'] = ImageFont.truetype(small, 6)
fonts['detail'] = ImageFont.truetype(small, 6)
fonts['rank'] = ImageFont.truetype(press, 10)
fonts['score'] = load_truetype(press, 10)
fonts['time'] = load_truetype(press, 8)
fonts['team'] = load_truetype(press, 8)
fonts['status'] = load_truetype(small, 6)
fonts['detail'] = load_truetype(small, 6)
fonts['rank'] = load_truetype(press, 10)
except IOError:
default = ImageFont.load_default()
for key in ('score', 'time', 'team', 'status', 'detail', 'rank'):
+11 -10
View File
@@ -15,6 +15,7 @@ from typing import Any, Dict, List, Optional, Tuple
import pytz
import requests
from PIL import Image, ImageDraw, ImageFont
from src.common.font_layout import load_truetype
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
@@ -449,12 +450,12 @@ class SportsCore(ABC):
press_start = self._resolve_font_path("PressStart2P-Regular.ttf")
four_by_six = self._resolve_font_path("4x6-font.ttf")
try:
fonts['score'] = ImageFont.truetype(press_start, 10)
fonts['time'] = ImageFont.truetype(press_start, 8)
fonts['team'] = ImageFont.truetype(press_start, 8)
fonts['status'] = ImageFont.truetype(four_by_six, 6) # Using 4x6 for status
fonts['detail'] = ImageFont.truetype(four_by_six, 6) # Added detail font
fonts['rank'] = ImageFont.truetype(press_start, 10)
fonts['score'] = load_truetype(press_start, 10)
fonts['time'] = load_truetype(press_start, 8)
fonts['team'] = load_truetype(press_start, 8)
fonts['status'] = load_truetype(four_by_six, 6) # Using 4x6 for status
fonts['detail'] = load_truetype(four_by_six, 6) # Added detail font
fonts['rank'] = load_truetype(press_start, 10)
self.logger.info("Successfully loaded fonts")
except OSError:
# Name the directory we searched: the usual cause is an install
@@ -1031,7 +1032,7 @@ class SportsCore(ABC):
if os.path.exists(font_path):
# Try loading as TTF first (works for both TTF and some BDF files with PIL)
if font_path.lower().endswith('.ttf'):
font = ImageFont.truetype(font_path, font_size)
font = load_truetype(font_path, font_size)
self.logger.debug(f"Loaded font: {font_name} at size {font_size}")
self._font_cache[cache_key] = font
return font
@@ -1049,7 +1050,7 @@ class SportsCore(ABC):
# correct one: the newer copies call truetype() on a BDF at
# any size (which simply fails) or refuse BDF outright.
try:
font = ImageFont.truetype(font_path, font_size)
font = load_truetype(font_path, font_size)
self.logger.debug(f"Loaded BDF font: {font_name} at size {font_size}")
self._font_cache[cache_key] = font
return font
@@ -1061,7 +1062,7 @@ class SportsCore(ABC):
self._bdf_native_size_cache[font_path] = native_size
if native_size and native_size != font_size:
try:
font = ImageFont.truetype(font_path, native_size)
font = load_truetype(font_path, native_size)
self.logger.debug(
f"Loaded BDF font: {font_name} at its native size {native_size} "
f"(requested {font_size} isn't a valid strike for this file)"
@@ -1089,7 +1090,7 @@ class SportsCore(ABC):
_resolve_font_family_alias(base_default))
try:
if os.path.exists(default_font_path):
font = ImageFont.truetype(default_font_path, font_size)
font = load_truetype(default_font_path, font_size)
else:
self.logger.warning("Default font not found, using PIL default")
font = ImageFont.load_default()
+98 -8
View File
@@ -5,6 +5,7 @@ Handles persistent disk-based caching with atomic writes and error recovery.
"""
import json
import math
import os
import time
import tempfile
@@ -14,6 +15,11 @@ import zlib
from typing import Dict, Any, Optional, Protocol
from datetime import datetime
try: # optional: large speedup on the cache write path, see _dumps below
import orjson
except ImportError: # pragma: no cover - exercised on hosts without the wheel
orjson = None
# How old an abandoned write's temp file must be before the sweep removes it.
# A real write holds its temp file for milliseconds, so an hour is far beyond
# any in-flight write while still clearing the same day's debris. Deliberately
@@ -40,13 +46,97 @@ class CacheStrategyProtocol(Protocol):
class DateTimeEncoder(json.JSONEncoder):
"""JSON encoder that handles datetime objects."""
"""JSON encoder that handles datetime objects.
Retained for the stdlib fallback path and for any caller importing it.
"""
def default(self, obj: Any) -> Any:
if isinstance(obj, datetime):
return obj.isoformat()
return super().default(obj)
def _datetime_default(obj: Any) -> Any:
"""Serialise datetimes exactly as DateTimeEncoder did."""
if isinstance(obj, datetime):
return obj.isoformat()
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
def _replace_nonfinite(obj: Any) -> Any:
"""Non-finite floats -> None, matching what ``orjson.dumps`` writes.
Only reached once a strict pass has proved there is something to replace,
so the ordinary write path never pays for this walk.
"""
if isinstance(obj, float):
return obj if math.isfinite(obj) else None
if isinstance(obj, dict):
return {k: _replace_nonfinite(v) for k, v in obj.items()}
if isinstance(obj, (list, tuple)):
return [_replace_nonfinite(v) for v in obj]
return obj
# NON-FINITE FLOATS
# -----------------
# JSON has no NaN or Infinity. The stdlib emits them anyway as an extension;
# orjson refuses to and writes null. That divergence is not acceptable in a
# cache whose files outlive the decision of which encoder is installed, so the
# policy here is one behaviour on both paths:
#
# writing non-finite floats become null, whichever encoder is in use
# reading files already on disk that carry the stdlib's NaN/Infinity
# tokens stay readable, whichever encoder is in use
#
# Without the write half, installing orjson silently changed cached values.
# Without the read half, installing orjson turned every legacy record holding a
# NaN into a "corrupted cache file" that DiskCache.get logged as an error and
# deleted. Both halves are covered by test/test_cache_nonfinite_floats.py.
if orjson is not None:
# Encoding the cache record dominated the background fetch worker: on a
# Pi 4, stdlib json.dumps runs ~12ms per MB and holds the GIL for all of
# it, which stalls the render thread mid-scroll. orjson measures ~7x
# faster on the same payloads (11.9ms -> 1.6ms for 985KB). Decoding gains
# far less (~1.3x on large payloads) because the cost there is building
# the Python objects, not scanning the text, but it is still free to take.
#
# OPT_NON_STR_KEYS: stdlib json coerces int/float dict keys to strings;
# orjson raises without this, and cache records do carry numeric keys.
# OPT_PASSTHROUGH_DATETIME: orjson would otherwise emit its own RFC 3339
# form for datetimes instead of calling default(). Routing them through
# _datetime_default keeps byte-for-byte parity with the records already
# on disk.
_DUMPS_OPTS = orjson.OPT_NON_STR_KEYS | orjson.OPT_PASSTHROUGH_DATETIME
def _dumps(data: Any) -> bytes:
return orjson.dumps(data, default=_datetime_default, option=_DUMPS_OPTS)
def _loads(raw: bytes) -> Any:
try:
return orjson.loads(raw)
except orjson.JSONDecodeError:
# Legacy record written by the stdlib path, carrying NaN or
# Infinity. Genuinely malformed files raise again from here, as
# json.JSONDecodeError, which is what DiskCache.get expects.
return json.loads(raw)
else:
def _dumps(data: Any) -> bytes:
try:
return json.dumps(data, cls=DateTimeEncoder,
allow_nan=False).encode("utf-8")
except ValueError:
# allow_nan=False is what detects the non-finite values; the walk
# runs only now that we know there is one to replace.
return json.dumps(_replace_nonfinite(data), cls=DateTimeEncoder,
allow_nan=False).encode("utf-8")
def _loads(raw: bytes) -> Any:
return json.loads(raw)
class DiskCache:
"""Manages persistent disk-based cache."""
@@ -99,8 +189,8 @@ class DiskCache:
try:
with self._lock:
with open(cache_path, 'r', encoding='utf-8') as f:
record = json.load(f)
with open(cache_path, 'rb') as f:
record = _loads(f.read())
# Determine record timestamp (prefer embedded, else file mtime)
record_ts = None
@@ -189,12 +279,12 @@ class DiskCache:
# write path below, and cache files are machine-read only — indenting
# them just multiplied the bytes written to the SD card.
try:
payload = json.dumps(data, cls=DateTimeEncoder)
payload = _dumps(data)
except (TypeError, ValueError) as e:
self.logger.warning("Cache data for key '%s' not serializable: %s", key, e)
return
digest = zlib.adler32(payload.encode('utf-8'))
digest = zlib.adler32(payload)
try:
# Atomic write to avoid partial/corrupt files
@@ -242,7 +332,7 @@ class DiskCache:
# wear source (dozens of fsyncs/min on API-heavy
# installs) for data that can be re-downloaded.
try:
with os.fdopen(fd, 'w', encoding='utf-8') as tmp_file:
with os.fdopen(fd, 'wb') as tmp_file:
tmp_file.write(payload)
os.replace(tmp_path, cache_path)
self._write_digests[key] = digest
@@ -260,7 +350,7 @@ class DiskCache:
else:
# Fallback: direct write (not atomic, but better than failing)
try:
with open(cache_path, 'w', encoding='utf-8') as cache_file:
with open(cache_path, 'wb') as cache_file:
cache_file.write(payload)
self._write_digests[key] = digest
# Set proper permissions: 660 (rw-rw----) for group-readable cache files
@@ -290,7 +380,7 @@ class DiskCache:
# is a different path, so future sets must keep
# retrying the primary location.
fallback_path = os.path.join(fallback_dir, os.path.basename(cache_path))
with open(fallback_path, 'w', encoding='utf-8') as tmp_file:
with open(fallback_path, 'wb') as tmp_file:
tmp_file.write(payload)
# Set proper permissions: 660 (rw-rw----) for group-readable cache files
try:
+12
View File
@@ -23,6 +23,13 @@ from src.common.error_handler import (
)
from src.common.api_helper import APIHelper
from src.common.scroll_helper import ScrollHelper
from src.common import scroll_config
from src.common.scroll_config import (
ScrollSettings,
configure as configure_scroll,
resolve as resolve_scroll_settings,
refresh_hz_from_config,
)
from src.common.logo_helper import LogoHelper
from src.common.text_helper import TextHelper
@@ -60,6 +67,11 @@ __all__ = [
'log_and_raise',
'APIHelper',
'ScrollHelper',
'scroll_config',
'ScrollSettings',
'configure_scroll',
'resolve_scroll_settings',
'refresh_hz_from_config',
'LogoHelper',
'TextHelper',
# adaptive layout & images
+43
View File
@@ -0,0 +1,43 @@
"""One text layout engine, everywhere.
``PIL.ImageFont.truetype`` picks its layout engine at load time: Raqm when the
host Pillow was built with libraqm, Basic otherwise. The two disagree about
fractional glyph advances, so the *same* Pillow version renders the *same*
string differently depending on a build option of the host.
That is invisible for ``PressStart2P-Regular.ttf`` at 8px, whose advances are
whole pixels either way — which is why most of the fleet's golden images
matched on every machine. It is not invisible for ``4x6-font.ttf`` at 6px,
where the advances are fractional: glyph positions drift cumulatively along a
run, and the committed goldens for geochron, of-the-day, christmas-countdown
and ledmatrix-weather's almanac passed on the machine that generated them and
failed everywhere else (ChuckBuilds/ledmatrix-plugins#371, #375, #378, #391).
Pinning the Basic engine makes a render depend on the font file and the size,
and nothing else. Basic gives up complex-script shaping (Arabic, Indic) and
kerning pairs; neither applies to the bitmap-grid faces this project draws
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.
"""
from __future__ import annotations
from typing import Any, Union
from PIL import ImageFont
#: The engine every core font load pins. Named once so the reason above has a
#: single referent, and so a future change is one line.
LAYOUT_ENGINE = ImageFont.Layout.BASIC
def load_truetype(font: Union[str, Any], size: int, **kwargs: Any) -> ImageFont.FreeTypeFont:
"""``ImageFont.truetype`` with the layout engine pinned.
Same signature and same exceptions as the PIL call it replaces, so it is a
drop-in at every call site.
"""
kwargs.setdefault("layout_engine", LAYOUT_ENGINE)
return ImageFont.truetype(font, size, **kwargs)
+452
View File
@@ -0,0 +1,452 @@
"""One place that turns plugin config into a configured ScrollHelper.
Five ticker plugins each hand-rolled this resolution (odds-ticker, news and
ledmatrix-leaderboard reference the deprecated ``scroll_pixels_per_second``
key 16-18 times apiece), and they disagreed in ways that were invisible until
someone watched the panel:
* odds-ticker read ``scroll_pixels_per_second`` on the *recommended* config
path and let it override ``scroll_speed``/``scroll_delay``. Because that key
carries a schema default, the documented settings were dead for every user
-- see ChuckBuilds/ledmatrix-plugins#408.
* ledmatrix-leaderboard read the same key only as a fallback, so identical
config produced different speeds in the two plugins.
* stock-news derived px/frame from it via its own arithmetic.
What matters on the hardware
----------------------------
Motion is smooth when the strip advances a **whole number of pixels per panel
refresh**. On a 100Hz panel that means 100 px/s, 200 px/s, and so on. Anything
else has to either blend adjacent columns (which on pixel-font text reads as
shimmer) or repeat frames (which reads as judder). :func:`resolve` warns when
the requested speed will not divide evenly, because that is a real display
artefact and not a rounding detail.
Speed is always expressed to the helper as pixels per second and applied in
time-based mode. Frame-based stepping gates motion on a wall clock at
``1/scroll_delay`` steps per second; plugins set ``scroll_delay`` to the frame
period, which puts that comparison exactly on its own threshold and makes the
step count flip on sub-millisecond jitter. Accumulating elapsed time keeps
position proportional to real time instead.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, replace
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
#: Speed used when a plugin supplies nothing usable. One pixel per refresh on a
#: 100Hz panel, which is the slowest crisp scroll that hardware can show.
DEFAULT_PIXELS_PER_SECOND = 100.0
#: Bounds accepted from config. Below the floor a marquee appears frozen;
#: above the ceiling it outruns any panel's refresh and tears.
MIN_PIXELS_PER_SECOND = 1.0
MAX_PIXELS_PER_SECOND = 500.0
#: Assumed refresh when the caller does not say. Matches the usual
#: ``display.hardware.limit_refresh_rate_hz``.
DEFAULT_REFRESH_HZ = 100.0
#: How far px/s may sit from a whole number of pixels per refresh before it is
#: worth warning about. 0.05px per frame is invisible; a third of a pixel is not.
_WHOLE_PIXEL_TOLERANCE = 0.05
#: Longest a frame may be held before motion reads as a slideshow rather than
#: a scroll. 6 refreshes at 100Hz is ~17px/s, already visibly stepped.
MAX_FRAME_HOLD = 8
#: Largest whole-pixel jump per presented frame before motion looks like it is
#: teleporting rather than sliding.
MAX_PIXELS_PER_FRAME = 6
@dataclass(frozen=True)
class CrispSpeed:
"""A speed the panel can show with whole-pixel motion.
``pixels_per_second`` is always ``refresh_hz / frame_hold * pixels_per_frame``
exactly -- no rounding, no fractional pixel positions, so nothing has to be
blended or repeated unevenly.
:param frame_hold: refreshes each frame is held for. This is rgbmatrix's
``SwapOnVSync(canvas, framerate_fraction)``. The panel keeps refreshing
at full rate either way, so holding a frame costs nothing in flicker.
:param pixels_per_frame: whole pixels advanced per presented frame.
"""
pixels_per_second: float
frame_hold: int
pixels_per_frame: int
refresh_hz: float
@property
def frames_per_second(self) -> float:
"""Distinct frames per second: the refresh divided by the hold."""
return self.refresh_hz / self.frame_hold
@property
def steppiness(self) -> str:
"""Rough readability hint for this combination."""
if self.pixels_per_frame > 2:
return "jumpy"
if self.frames_per_second < 20:
return "stepped"
if self.frames_per_second < 30:
return "slightly stepped"
return "smooth"
def describe(self) -> str:
"""This speed as a line for the speed ladder, aligned for a column."""
return (
f"{self.pixels_per_second:6.1f} px/s "
f"({self.pixels_per_frame}px every {self.frame_hold} refresh"
f"{'es' if self.frame_hold != 1 else ' '} = "
f"{self.frames_per_second:5.1f} fps, {self.steppiness})"
)
def crisp_ladder(
refresh_hz: float = DEFAULT_REFRESH_HZ,
max_frame_hold: int = MAX_FRAME_HOLD,
max_pixels_per_frame: int = MAX_PIXELS_PER_FRAME,
):
"""Every whole-pixel speed this panel can show, slowest first.
Duplicates are collapsed keeping the gentlest option: 100 px/s is reachable
as 1px every refresh or 2px every 2nd refresh, and the former moves in
smaller increments, so that is the one worth offering.
"""
best = {}
for hold in range(1, max_frame_hold + 1):
for ppf in range(1, max_pixels_per_frame + 1):
pps = refresh_hz / hold * ppf
key = round(pps, 3)
candidate = CrispSpeed(pps, hold, ppf, refresh_hz)
incumbent = best.get(key)
if incumbent is None or ppf < incumbent.pixels_per_frame:
best[key] = candidate
return [best[k] for k in sorted(best)]
#: How much a bigger pixel step costs, as a fraction of the target speed.
#: Tuned so 66.7px/s (2px at 33fps) beats 50px/s (1px at 50fps) when 60 was
#: asked for, but 33.3px/s (1px, smooth) still beats 28.6px/s (2px at 14fps)
#: when 30 was asked for -- being 11% slow is worth far less than looking bad.
_STEP_PENALTY = 0.05
_SLOW_FPS_PENALTY = 0.25 # below 20fps
_LOWISH_FPS_PENALTY = 0.10 # below 25fps
def _quality_cost(candidate: "CrispSpeed", target: float) -> float:
"""Lower is better. Numeric closeness alone picks bad-looking speeds.
Nearest-by-value would answer "30 px/s" with 28.6 px/s -- which is 2px
jumps at 14fps -- over 33.3 px/s, which is single-pixel motion at 33fps and
obviously better on the panel. Proximity has to be traded against how the
motion actually reads.
"""
error = abs(candidate.pixels_per_second - target) / max(target, 1e-6)
cost = error + _STEP_PENALTY * (candidate.pixels_per_frame - 1)
fps = candidate.frames_per_second
if fps < 20:
cost += _SLOW_FPS_PENALTY
elif fps < 25:
cost += _LOWISH_FPS_PENALTY
return cost
def solve_crisp(
target_pixels_per_second: float,
refresh_hz: float = DEFAULT_REFRESH_HZ,
max_frame_hold: int = MAX_FRAME_HOLD,
max_pixels_per_frame: int = MAX_PIXELS_PER_FRAME,
) -> CrispSpeed:
"""The whole-pixel speed that will look best for what was asked for.
Not simply the nearest -- see :func:`_quality_cost`. Ties break toward the
smaller pixel step and the shorter hold.
"""
ladder = crisp_ladder(refresh_hz, max_frame_hold, max_pixels_per_frame)
# Clamp into the ladder's range first. Relative error saturates near 1.0
# for a target far outside it, so the quality penalty would dominate and
# answer "10000 px/s" with the *slowest* entry -- smooth, and useless.
target = min(max(target_pixels_per_second, ladder[0].pixels_per_second),
ladder[-1].pixels_per_second)
return min(
ladder,
key=lambda c: (round(_quality_cost(c, target), 6),
c.pixels_per_frame, c.frame_hold),
)
@dataclass(frozen=True)
class ScrollSettings:
"""The resolved outcome, and which config key produced it."""
pixels_per_second: float
source: str
target_fps: Optional[float] = None
pixels_per_frame: Optional[float] = None
warning: Optional[str] = None
#: The whole-pixel speed actually applied, when snapping was enabled.
crisp: Optional[CrispSpeed] = None
#: What the config asked for, before snapping.
requested_pixels_per_second: Optional[float] = None
@property
def frame_hold(self) -> int:
"""Refreshes to hold each frame for; pass to set_scrolling_state()."""
return self.crisp.frame_hold if self.crisp else 1
def describe(self) -> str:
"""One log line: the speed applied, and which config key produced it."""
text = f"{self.pixels_per_second:.1f} px/s (from {self.source})"
if self.pixels_per_frame is not None:
text += f" = {self.pixels_per_frame:.2f} px/frame"
if self.target_fps:
text += f" at {self.target_fps:.0f} fps"
return text
def _coerce(value: Any) -> Optional[float]:
"""A positive float, or None. Config reaches us with nulls and strings."""
if value is None or isinstance(value, bool):
return None
try:
number = float(value)
except (TypeError, ValueError):
return None
return number if number > 0 else None
def _from_speed_and_delay(block: Any) -> Optional[float]:
"""px/s from a ``scroll_speed`` (px/frame) + ``scroll_delay`` (s) pair."""
if not isinstance(block, dict):
return None
speed = _coerce(block.get("scroll_speed"))
delay = _coerce(block.get("scroll_delay"))
if speed is None or delay is None:
return None
return speed / delay
def resolve(
plugin_config: Optional[Dict[str, Any]] = None,
global_config: Optional[Dict[str, Any]] = None,
default_pixels_per_second: float = DEFAULT_PIXELS_PER_SECOND,
refresh_hz: Optional[float] = None,
) -> ScrollSettings:
"""Resolve one scroll speed from the several shapes plugins accept.
Precedence, highest first. The deprecated flat key sits *below* the
explicit pairs deliberately: it carries schema defaults in some plugins, so
ranking it above them silently disables the documented settings.
1. ``display_options.scroll_speed`` + ``scroll_delay`` (current)
2. ``display.scroll_speed`` + ``scroll_delay`` (deprecated shape)
3. ``scroll_speed`` + ``scroll_delay`` at the root (legacy flat)
4. ``scroll_pixels_per_second``, nested or flat (deprecated)
5. the global ``display`` block
6. ``default_pixels_per_second``
:param refresh_hz: panel refresh, used only to check whether the resolved
speed lands on whole pixels per frame and to fill in ``target_fps``.
"""
plugin_config = plugin_config or {}
global_config = global_config or {}
refresh = _coerce(refresh_hz) or DEFAULT_REFRESH_HZ
display_options = plugin_config.get("display_options")
display_block = plugin_config.get("display")
candidates = [
(_from_speed_and_delay(display_options), "display_options.scroll_speed/delay"),
(_from_speed_and_delay(display_block), "display.scroll_speed/delay"),
(_from_speed_and_delay(plugin_config), "scroll_speed/delay (root)"),
]
for block, label in (
(display_options, "display_options.scroll_pixels_per_second"),
(display_block, "display.scroll_pixels_per_second"),
(plugin_config, "scroll_pixels_per_second"),
):
if isinstance(block, dict):
candidates.append((_coerce(block.get("scroll_pixels_per_second")), label))
global_display = global_config.get("display")
candidates.append((_from_speed_and_delay(global_display), "global display.scroll_speed/delay"))
pixels_per_second = None
source = "default"
for value, label in candidates:
if value is not None:
pixels_per_second, source = value, label
break
if pixels_per_second is None:
pixels_per_second = default_pixels_per_second
clamped = max(MIN_PIXELS_PER_SECOND, min(MAX_PIXELS_PER_SECOND, pixels_per_second))
warning = None
if clamped != pixels_per_second:
warning = (
f"scroll speed {pixels_per_second:.1f} px/s out of range, "
f"clamped to {clamped:.1f}"
)
pixels_per_second = clamped
pixels_per_frame = pixels_per_second / refresh if refresh > 0 else None
if warning is None and pixels_per_frame is not None:
offset = abs(pixels_per_frame - round(pixels_per_frame))
if pixels_per_frame < 1.0 - _WHOLE_PIXEL_TOLERANCE or offset > _WHOLE_PIXEL_TOLERANCE:
suggestion = max(1.0, round(pixels_per_frame)) * refresh
warning = (
f"{pixels_per_second:.1f} px/s is {pixels_per_frame:.2f} px per "
f"refresh at {refresh:.0f}Hz, so some frames repeat and the "
f"scroll will judder; {suggestion:.0f} px/s divides evenly"
)
return ScrollSettings(
pixels_per_second=pixels_per_second,
source=source,
target_fps=refresh,
pixels_per_frame=pixels_per_frame,
warning=warning,
)
def configure(
scroll_helper: Any,
plugin_config: Optional[Dict[str, Any]] = None,
global_config: Optional[Dict[str, Any]] = None,
default_pixels_per_second: float = DEFAULT_PIXELS_PER_SECOND,
refresh_hz: Optional[float] = None,
plugin_logger: Optional[logging.Logger] = None,
display_manager: Any = None,
snap_to_crisp: bool = True,
) -> ScrollSettings:
"""Resolve the config and apply it to ``scroll_helper``.
Applied in time-based mode: see the module docstring for why frame-based
stepping is not used. ``hasattr`` guards keep this usable against older
ScrollHelper builds that a plugin may be running on.
:param display_manager: consulted for the panel's refresh rate only (it can
see display.hardware; a plugin cannot). The frame hold is NOT applied
here -- see the note in the body. The caller must pass
``settings.frame_hold`` to ``display_manager.set_scrolling_state(True,
...)`` when it starts scrolling, or a sub-refresh speed still presents
a new frame every refresh and the motion falls back to fractional
pixels.
:param snap_to_crisp: move the requested speed to the nearest speed the
panel can show in whole pixels. On by default because a speed that does
not divide evenly has no good rendering, only a choice of artefacts.
:returns: the settings applied, so the caller can log or assert on them.
"""
log = plugin_logger or logger
# Refresh rate, most authoritative first: what the caller passed, then the
# display manager (which can see display.hardware; a plugin cannot), then
# the global config, then the default.
#
# This has to be settled BEFORE resolve(), not after. resolve() uses the
# refresh to fill in target_fps, pixels_per_frame and the judder warning,
# so deriving it afterwards described a 100Hz panel to everyone running at
# 60 -- and with snap_to_crisp=False nothing downstream corrected it, so
# set_target_fps() paced the helper to 100 FPS on a 60Hz panel.
hz = _coerce(refresh_hz)
if hz is None and display_manager is not None:
hz = _coerce(getattr(display_manager, "refresh_hz", None))
if hz is None:
hz = refresh_hz_from_config(global_config)
settings = resolve(
plugin_config,
global_config,
default_pixels_per_second=default_pixels_per_second,
refresh_hz=hz,
)
applied = settings.pixels_per_second
choice = None
if snap_to_crisp:
choice = solve_crisp(settings.pixels_per_second, hz)
applied = choice.pixels_per_second
settings = replace(
settings,
pixels_per_second=applied,
requested_pixels_per_second=settings.pixels_per_second,
crisp=choice,
pixels_per_frame=float(choice.pixels_per_frame),
# Snapping resolves the whole-pixel problem the warning describes.
warning=None if settings.warning and "judder" in settings.warning
else settings.warning,
)
if hasattr(scroll_helper, "set_frame_based_scrolling"):
scroll_helper.set_frame_based_scrolling(False)
scroll_helper.set_scroll_speed(applied)
# A crisp speed is a whole number of pixels per presented frame, so step by
# that number rather than by speed * elapsed time. Snapping alone only
# fixes the average: the wall clock puts the accumulator back on an integer
# boundary every frame, where jitter of a fraction of a millisecond decides
# whether the pixel moves. That is what the ladder was bought to prevent.
if hasattr(scroll_helper, "set_pixels_per_frame"):
scroll_helper.set_pixels_per_frame(
choice.pixels_per_frame if choice else None)
if choice and hasattr(scroll_helper, "set_target_fps"):
scroll_helper.set_target_fps(choice.frames_per_second)
elif settings.target_fps and hasattr(scroll_helper, "set_target_fps"):
scroll_helper.set_target_fps(settings.target_fps)
# Deliberately NOT applied here. The hold belongs to a scroll, not to a
# plugin's lifetime: plugins share one display manager, and one left set at
# construction is reset the moment any other plugin finishes scrolling.
# Callers pass settings.frame_hold to set_scrolling_state(True, ...) when
# they start scrolling. configure() only reports what is needed.
if choice:
requested = settings.requested_pixels_per_second
if abs(requested - applied) > 0.05:
log.info(
"Scroll configured: %s (asked for %.1f px/s from %s; "
"nearest whole-pixel speed on a %.0fHz panel)",
choice.describe(), requested, settings.source, hz,
)
else:
log.info("Scroll configured: %s (from %s)",
choice.describe(), settings.source)
if choice.frame_hold > 1:
log.debug(
"Scroll needs a frame hold of %d - pass settings.frame_hold to "
"display_manager.set_scrolling_state(True, ...) each scroll",
choice.frame_hold,
)
else:
log.info("Scroll configured: %s", settings.describe())
if settings.warning:
log.warning("Scroll speed: %s", settings.warning)
return settings
def refresh_hz_from_config(global_config: Optional[Dict[str, Any]]) -> float:
"""The panel's refresh cap from the global config, or the default."""
if not isinstance(global_config, dict):
return DEFAULT_REFRESH_HZ
# Each level is checked for being a mapping rather than merely truthy: a
# malformed config where display or display.hardware is a string or a list
# raised AttributeError out of what is meant to be a total function with a
# default, taking down every caller that asked for the refresh rate.
display = global_config.get("display")
if not isinstance(display, dict):
return DEFAULT_REFRESH_HZ
hardware = display.get("hardware")
if not isinstance(hardware, dict):
return DEFAULT_REFRESH_HZ
return _coerce(hardware.get("limit_refresh_rate_hz")) or DEFAULT_REFRESH_HZ
+145 -23
View File
@@ -16,6 +16,7 @@ Features:
"""
import logging
import math
import time
from typing import Optional, Dict, Any
from PIL import Image
@@ -29,6 +30,49 @@ except ImportError:
HAS_SCIPY = False
def frame_stats(frame_times: list) -> Dict[str, Any]:
"""Summary statistics over one window of frame durations (seconds).
Split out of log_frame_rate() so the arithmetic can be tested without a
clock. Median and p95 are the real ones: the median takes both middle
samples on an even window, and p95 is nearest-rank, so a 100-frame window
reports the 95th sorted sample rather than the 96th. That matters twice
over, because the median is also the threshold the stall and skip counts
are measured against.
"""
window = sorted(frame_times)
n = len(window)
median = (window[n // 2] if n % 2
else (window[n // 2 - 1] + window[n // 2]) / 2.0)
mean = sum(window) / n
# Anything past 1.5x the median missed a panel refresh; anything under
# half of it never reached the panel at all (dirty tracking skipped the
# swap, so the frame did not wait for vsync).
return {
"frames": n,
"fps": (1.0 / mean) if mean > 0 else 0.0,
"median": median,
"p95": window[max(0, math.ceil(0.95 * n) - 1)],
"max": window[-1],
"min": window[0],
"stalls": sum(1 for f in window if f > median * 1.5),
"skips": sum(1 for f in window if f < median * 0.5),
}
def format_frame_stats(frame_times: list) -> str:
"""The one-line rendering of frame_stats(), in milliseconds."""
s = frame_stats(frame_times)
n = s["frames"]
return (
f"{s['fps']:.1f} fps over {n} frames | "
f"median {s['median'] * 1000:.2f}ms p95 {s['p95'] * 1000:.2f}ms "
f"max {s['max'] * 1000:.2f}ms min {s['min'] * 1000:.2f}ms | "
f"stalls {s['stalls']} ({100.0 * s['stalls'] / n:.1f}%) "
f"skips {s['skips']} ({100.0 * s['skips'] / n:.1f}%)"
)
class ScrollHelper:
"""
Helper class for scrolling text and image content on LED displays.
@@ -75,12 +119,26 @@ class ScrollHelper:
# Pre-allocated buffer for output frame (reused to avoid allocations)
self._frame_buffer: Optional[np.ndarray] = None
# Sub-pixel scrolling settings (disabled - using high FPS integer scrolling instead)
self.sub_pixel_scrolling = False # Disabled - use high frame rate for smoothness
# Sub-pixel scrolling: OFF by default, and that is deliberate.
# Blending renders a half-step by mixing two adjacent columns 50/50.
# On a high-resolution screen that reads as smooth motion; on a coarse
# LED matrix showing pixel-font text it does not. A one-pixel stroke
# becomes two half-brightness pixels, so frames alternate between crisp
# and smeared and the text appears to shimmer and jump a pixel ahead --
# tested on a 2x128x64 panel and clearly worse than integer stepping.
#
# The rule this display obeys: motion is smooth when it advances a
# whole number of pixels per refresh. Anything slower must either
# blend (blur) or repeat frames (judder); blending is the worse of the
# two here. Vegas mode still opts in via set_sub_pixel_scrolling().
self.sub_pixel_scrolling = False
self._last_integer_position = 0 # Cache for integer position to avoid repeated calculations
# Frame-based scrolling settings
self.frame_based_scrolling = False # If True, use scroll_delay to throttle and move scroll_speed pixels
self.frame_based_scrolling = False
#: Whole pixels to advance per presented frame, or None to pace
#: off elapsed time. See set_pixels_per_frame.
self.fixed_pixels_per_frame = None # If True, use scroll_delay to throttle and move scroll_speed pixels
self.last_step_time = 0.0 # Track last step time for frame-based throttling
# Time tracking for scroll updates
@@ -105,6 +163,9 @@ class ScrollHelper:
self.last_frame_time = time.time()
self.last_fps_log_time = time.time()
self.frame_times = []
# Every frame time since the last stats line, so the 5s summary can
# report the tail rather than one arbitrary sample. Cleared on log.
self._window: list = []
# Scrolling state management
self.is_scrolling = False
@@ -237,26 +298,44 @@ class ScrollHelper:
self.last_progress_log_time = current_time
# Update scroll position
if self.frame_based_scrolling:
if self.fixed_pixels_per_frame:
# One presented frame, one fixed whole-pixel step. No clock is
# consulted, so no jitter reaches the position and every frame
# moves the eye by the same amount. See set_pixels_per_frame.
pixels_to_move = self.fixed_pixels_per_frame
self.last_step_time = current_time
elif self.frame_based_scrolling:
# Frame-based: move fixed amount when scroll_delay has passed
# This matches stock ticker behavior: move pixels, then wait scroll_delay
# Initialize last_step_time on first call to prevent huge initial jump
if self.last_step_time == 0.0:
self.last_step_time = current_time
# Check if scroll_delay has passed
time_since_last_step = current_time - self.last_step_time
if time_since_last_step >= self.scroll_delay:
# Move pixels (can move multiple steps if lag occurred, but cap to prevent huge jumps)
steps = int(time_since_last_step / self.scroll_delay)
# Cap at reasonable number to prevent huge jumps from lag
max_steps = max(1, int(0.04 / self.scroll_delay)) # Limit to 0.04s (2 steps at 50 FPS) for smoother scrolling
steps = min(steps, max_steps)
pixels_to_move = self.scroll_speed * steps
# Update last_step_time, preserving fractional delay for smooth timing
self.last_step_time = current_time - (time_since_last_step % self.scroll_delay)
# Frame-based mode advances by elapsed time, exactly like the
# time-based branch below, at the same configured speed
# (scroll_speed px per scroll_delay seconds).
#
# It used to step discretely: 0, 1 or 2 whole pixels depending on
# whether a wall clock had passed scroll_delay. Plugins set
# scroll_delay to the target frame period, so that comparison sits
# exactly on its own threshold and the decision flips on sub-
# millisecond jitter -- a frame a hair early moved nothing and
# rendered an identical frame, a frame a hair late moved two
# pixels. Rounding the step count fixed the stalls but still
# discarded the remainder, so the error never corrected.
#
# Accumulating elapsed time keeps position exactly proportional to
# real time: jitter shifts a pixel boundary by a fraction of a
# frame instead of flipping a whole step, and nothing is lost or
# gained. This is what the one visibly smooth scroller on the
# hardware (the stock ticker) was already doing by virtue of never
# enabling frame-based mode.
if self.scroll_delay > 0:
pixels_per_second = self.scroll_speed / self.scroll_delay
else:
pixels_to_move = 0.0
pixels_per_second = self.scroll_speed * 100.0
pixels_to_move = pixels_per_second * delta_time
self.last_step_time = current_time
else:
# Time-based: move based on time delta (correct speed over time)
# scroll_speed is pixels per second
@@ -909,6 +988,13 @@ class ScrollHelper:
Args:
speed: Scroll speed (interpretation depends on frame_based_scrolling mode)
"""
# A speed set directly is a request to pace off that speed, so drop any
# fixed per-frame step left by an earlier configure(). scroll_config
# calls this first and set_pixels_per_frame second, so the crisp path
# is unaffected; what this protects is a legacy caller changing speed
# on a helper that scroll_config had already put in fixed-step mode,
# where the new speed would otherwise be silently ignored.
self.fixed_pixels_per_frame = None
if self.frame_based_scrolling:
# In frame-based mode, clamp to reasonable pixels per frame (0.1-5)
# Higher values cause visible jumps - 1-2 pixels/frame is ideal for smoothness
@@ -929,6 +1015,35 @@ class ScrollHelper:
self.scroll_delay = max(0.001, min(1.0, delay))
self.logger.debug(f"Scroll delay set to: {self.scroll_delay}")
def set_pixels_per_frame(self, pixels) -> None:
"""Advance exactly `pixels` per presented frame, ignoring the clock.
Pass None to go back to pacing off elapsed time.
Smooth motion is not a frame-rate property. The strip has to advance
the same number of WHOLE pixels every frame, and deriving that from a
wall clock cannot deliver it: the position accumulates
``speed * delta_time`` and is then truncated to a pixel, so any jitter
in delta_time lands either side of an integer boundary. Measured on
hardware at a rock-steady 100.0 fps, individual frames still ranged
5.6ms to 15.2ms -- 0.57px to 1.44px of movement -- and 53% of frames
advanced by something other than one pixel: about half moved nothing
at all and then jumped two. That is the micro-stutter, and it survived
every frame-timing fix because frame timing was never the problem.
It is worst precisely at a crisp speed. At 100 px/s on a 100Hz panel
the accumulator sits exactly on integer boundaries, so sub-millisecond
jitter flips it either way and the motion beats at around 50Hz.
Stepping per frame is only correct because SwapOnVSync blocks until
the panel has taken the frame, which makes the frame count a truer
clock than time.time(). Before the swap was locked to vsync this would
have run at whatever speed the loop happened to spin at.
"""
self.fixed_pixels_per_frame = int(pixels) if pixels else None
self.logger.debug("Fixed step set to: %s px/frame",
self.fixed_pixels_per_frame)
def set_target_fps(self, fps: float) -> None:
"""
Set the target frames per second for scrolling.
@@ -1018,17 +1133,24 @@ class ScrollHelper:
if len(self.frame_times) > 100:
self.frame_times.pop(0)
# Every frame since the last log, not just the last 100 and not just
# the one that happens to land on the 5s boundary. The old line
# reported a single instantaneous sample -- roughly 1 frame in 500 --
# which cannot see a stall that hits 1% of frames, and reported it
# next to an average that hides the same stall by construction (a 2ms
# 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:
avg_frame_time = sum(self.frame_times) / len(self.frame_times)
avg_fps = 1.0 / avg_frame_time if avg_frame_time > 0 else 0
instant_fps = 1.0 / frame_time if frame_time > 0 else 0
self.logger.info(f"Scroll frame stats - Avg FPS: {avg_fps:.1f}, "
f"Current FPS: {instant_fps:.1f}, "
f"Frame time: {frame_time*1000:.2f}ms")
self.logger.info(
"Scroll frame stats - %s",
format_frame_stats(self._window or [frame_time]),
)
self.last_fps_log_time = current_time
self.frame_count = 0
self._window = []
self.last_frame_time = current_time
self.frame_count += 1
+11 -1
View File
@@ -17,10 +17,13 @@ deliberate difference is `crisp_size`, which takes the seven-plugin guard
the extra guard only stops a None size raising TypeError.
"""
import logging
from datetime import datetime, timezone
from typing import Any, Dict, Optional, Tuple
from zoneinfo import ZoneInfo
logger = logging.getLogger(__name__)
__all__ = [
"ELEMENT_FOR_FONT", "FAVORITE_RESULT_COLOR_DEFAULTS", "FONT_NAME_ALIASES",
"FONT_PIXEL_GRID", "MONTH_ABBR", "WEEKDAY_ABBR",
@@ -395,7 +398,14 @@ def schema_font_size(schema_path: str, element_key) -> Optional[int]:
size = spec.get('properties', {}).get('font_size', {}).get('default')
if size is not None:
cache[key] = int(size)
except Exception:
except Exception as exc:
# See sports_shared._schema_font_size: an unreadable schema
# silently disables the pixel-grid snap for every element.
# Built once per schema path, so this cannot repeat per frame.
logger.warning(
"could not read %s (%s: %s); font sizes will skip their "
"pixel grid snap and may render a pixel narrow",
schema_path, type(exc).__name__, exc)
cache = {}
_SCHEMA_FONT_SIZE_CACHE[schema_path] = cache
return cache.get(element_key)
+83 -39
View File
@@ -43,6 +43,7 @@ from typing import Any, Dict, List, Optional
from PIL import Image
from src.common import scroll_config
from src.common.scroll_helper import ScrollHelper
logger = logging.getLogger(__name__)
@@ -58,13 +59,9 @@ DEFAULT_SCROLL_SETTINGS: Dict[str, Any] = {
"dynamic_duration": True,
}
#: Bounds on the px/second -> px/frame conversion, applied before the helper
#: sees the value. FPS is *not* clamped here — ScrollHelper.set_target_fps
#: already does that, and a second copy of the range would drift from it.
MIN_PIXELS_PER_FRAME = 0.1
MAX_PIXELS_PER_FRAME = 5.0
#: Pacing to assume when scroll_delay is 0, i.e. the plugin has not set one.
#: Only used to interpret this module's own px/frame config shape; the speed
#: bounds and the px/s -> px/frame conversion belong to scroll_config now.
ASSUMED_FPS_WHEN_UNPACED = 100.0
@@ -236,51 +233,75 @@ class SportsScrollDisplay:
"""Apply config to the scroll helper. Safe to call again after a change."""
settings = self._get_scroll_settings()
scroll_speed = self._coerce_float(settings.get("scroll_speed"), 50.0)
scroll_delay = self._coerce_float(settings.get("scroll_delay"), 0.01)
dynamic_duration = bool(settings.get("dynamic_duration", True))
self.scroll_helper.set_scroll_delay(scroll_delay)
self.scroll_helper.set_dynamic_duration_settings(
enabled=dynamic_duration,
min_duration=settings.get("min_duration", 30),
max_duration=settings.get("max_duration", 600),
buffer=0.2, # ensure the strip clears the panel completely
)
# Frame-based scrolling: motion advances per rendered frame rather than
# per wall-clock second, which is what makes the pacing stable.
self.scroll_helper.set_frame_based_scrolling(True)
# Config states speed in px/second; frame-based mode wants px/frame.
if scroll_delay > 0:
pixels_per_frame = scroll_speed * scroll_delay
else:
pixels_per_frame = scroll_speed / ASSUMED_FPS_WHEN_UNPACED
pixels_per_frame = max(
MIN_PIXELS_PER_FRAME, min(MAX_PIXELS_PER_FRAME, pixels_per_frame)
)
self.scroll_helper.set_scroll_speed(pixels_per_frame)
# Speed goes through scroll_config, which every other scrolling plugin
# already uses. What must NOT happen is handing it this module's
# settings dict: the two read the same key names with different
# meanings, and the collision is a factor of 1/scroll_delay.
#
# sports_scroll: scroll_speed is px/SECOND; scroll_delay is only the
# frame period used to reach px/frame.
# scroll_config: scroll_speed is px per STEP, so px/s = speed/delay.
#
# Passing {"scroll_speed": 50.0, "scroll_delay": 0.01} straight through
# resolves to 5000 px/s (clamped to 500) instead of 50. So this module
# keeps ownership of reading its own config -- _get_scroll_settings
# merges the league overrides -- and hands the resolver a plain px/s.
pixels_per_second = self._resolve_pixels_per_second(settings)
effective_pps = (
pixels_per_frame / scroll_delay
if scroll_delay > 0
else pixels_per_frame * ASSUMED_FPS_WHEN_UNPACED
resolved = scroll_config.configure(
self.scroll_helper,
plugin_config=None,
global_config=self.global_config,
default_pixels_per_second=pixels_per_second,
display_manager=self.display_manager,
plugin_logger=self.logger,
refresh_hz=self._resolve_refresh_hz(),
)
self._scroll_settings = resolved
self.logger.info(
f"ScrollHelper configured: {pixels_per_frame:.2f} px/frame, "
f"delay={scroll_delay}s (effective {effective_pps:.1f} px/s from "
f"{scroll_speed} px/s config), dynamic_duration={dynamic_duration}"
"ScrollHelper configured: %s (requested %.1f px/s), "
"dynamic_duration=%s",
resolved.describe(),
pixels_per_second, dynamic_duration,
)
# The reason this module exists upstream: the bundled copies hardcode
# ~100 FPS via scroll_delay and never consult the global target.
# No hasattr guard here, unlike the plugin copies: they probe because
# they may run against an older core, whereas this module ships in the
# same release as the ScrollHelper it calls. The helper clamps.
target_fps = self._resolve_target_fps()
if target_fps:
self.scroll_helper.set_target_fps(target_fps)
self.logger.info(f"Target FPS set to {target_fps}")
def _resolve_pixels_per_second(self, settings: Dict[str, Any]) -> float:
"""This module's config shape, expressed as plain pixels per second.
``scroll_speed`` is already px/s here. ``scroll_delay`` only matters
when a caller supplied px/frame instead, which the 0 case covers.
"""
scroll_speed = self._coerce_float(settings.get("scroll_speed"), 50.0)
scroll_delay = self._coerce_float(settings.get("scroll_delay"), 0.01)
if scroll_delay <= 0:
return scroll_speed * ASSUMED_FPS_WHEN_UNPACED
return scroll_speed
def _resolve_refresh_hz(self) -> Optional[float]:
"""The panel refresh the crisp ladder should be computed against.
Prefers the configured hardware refresh. Falls back to the global
``target_fps``/``scroll_target_fps`` this module has always honoured:
under the old model that key *was* the rate frames were presented at,
so it is the faithful translation for anyone who set it. Returning
None lets scroll_config apply its own default.
"""
hardware = scroll_config.refresh_hz_from_config(self.global_config)
if hardware and hardware != scroll_config.DEFAULT_REFRESH_HZ:
return hardware
return self._resolve_target_fps() or hardware or None
def _scroll_frame_hold(self) -> int:
"""Refreshes to hold each frame for, from the resolved settings."""
return getattr(getattr(self, "_scroll_settings", None), "frame_hold", 1)
# ------------------------------------------------------------------
# Frame pumping
@@ -305,6 +326,16 @@ class SportsScrollDisplay:
if not visible:
return False
# Tell the core the panel is scrolling, and for how many
# refreshes to hold each frame. Without this the frame hold is
# never applied -- so a speed the ladder made crisp still presents
# a new frame every refresh and judders -- and, because deferred
# updates only run while nothing is scrolling, core would run
# blocking work in the middle of this scroll.
if hasattr(self.display_manager, "set_scrolling_state"):
self.display_manager.set_scrolling_state(
True, frame_hold=self._scroll_frame_hold())
self.display_manager.image = visible
self.display_manager.update_display()
self._frame_count += 1
@@ -331,7 +362,19 @@ class SportsScrollDisplay:
def is_scroll_complete(self) -> bool:
"""True when the strip has scrolled fully past the panel."""
return self.scroll_helper.is_scroll_complete()
complete = self.scroll_helper.is_scroll_complete()
if complete:
self._release_scrolling_state()
return complete
def _release_scrolling_state(self) -> None:
"""Tell the core this display is no longer scrolling.
The scrolling flag and the frame hold are global to the display
manager, so leaving them set holds every other plugin's frames too.
"""
if hasattr(self.display_manager, "set_scrolling_state"):
self.display_manager.set_scrolling_state(False)
def reset_scroll(self) -> None:
"""Return the strip to its starting position, keeping the content."""
@@ -349,6 +392,7 @@ class SportsScrollDisplay:
self._vegas_content_items = []
self._is_scrolling = False
self._scroll_start_time = None
self._release_scrolling_state()
self.logger.debug("Scroll display cleared")
# ------------------------------------------------------------------
+64 -12
View File
@@ -86,6 +86,7 @@ from typing import Any, ClassVar, Dict, List, Optional, Tuple
import pytz
import requests
from PIL import Image, ImageDraw, ImageFont
from src.common.font_layout import load_truetype
logger = logging.getLogger(__name__)
@@ -211,17 +212,52 @@ class SportsCoreSharedMixin:
return desired
return max(grid, int(round(float(desired) / grid)) * grid)
#: Absolute path of this plugin's directory, declared by the plugin
#: itself. The mixin cannot work it out -- see _plugin_dir.
_PLUGIN_DIR: ClassVar[Optional[str]] = None
def _plugin_dir(self) -> Optional[str]:
"""Directory of the plugin that defines this class.
"""Directory of the plugin that owns this instance.
In sports.py these methods could just use ``__file__``. Here that is
src/common/, so the plugin's own directory has to be recovered from the
instance. ``type(self).__module__`` alone is not enough: SportsCore is
an ABC, so a subclass built with ``type(name, bases, ns)`` -- which the
plugins' own tests do -- reports its module as "abc". Walking the MRO
steps past those synthetic classes to the first one whose module sits
next to a config_schema.json, which is the real plugin.
src/common/, so the directory has to come from the plugin.
It is TOLD, not deduced. The first version walked the MRO for a class
whose module sits beside a config_schema.json. That works when a test
imports the plugin itself, and returns None under the real loader, for
a specific reason worth recording:
PluginLoader._namespace_plugin_modules renames every bare module a
plugin brought in (sports, game_renderer, ...) to
"_plg_<plugin_id>_<module>" and REMOVES the bare entry, so two
plugins owning a module of the same name cannot collide.
The class still reports ``__module__ == "sports"``, but
``sys.modules["sports"]`` no longer exists, so the walk finds no
__file__ and falls off the end. The failure was silent and expensive:
_plugin_dir() -> None
_schema_font_size() -> None for every element
-> a configured size equal to the schema default stops looking
like a default and is treated as a deliberate user choice
-> the snap to the font's pixel grid is skipped
-> 4x6-font.ttf renders at 6 instead of 7: 3px-wide glyphs
instead of 4px
On a 256x64 panel that made the odds, the team records and the date row
hard to read. It was found by a user counting pixels on the panel. No
gate here caught it: the tests imported plugins directly and the
safety harness loads them its own way, so neither reproduced the
loader's renaming.
The MRO walk stays as a fallback for hosts that declare no
_PLUGIN_DIR -- the plugins' own probe harnesses build classes with
``type()`` -- but it is no longer the primary answer.
"""
declared = getattr(self, "_PLUGIN_DIR", None)
if declared and os.path.isfile(os.path.join(declared, "config_schema.json")):
return declared
for cls in type(self).__mro__:
module = sys.modules.get(getattr(cls, "__module__", ""), None)
path = getattr(module, "__file__", None)
@@ -253,7 +289,23 @@ class SportsCoreSharedMixin:
size = spec.get('properties', {}).get('font_size', {}).get('default')
if size is not None:
cache[key] = int(size)
except Exception:
except Exception as exc:
# Say so. An unreadable schema is not cosmetic: every element's
# configured size then stops matching "the schema default", is
# treated as a deliberate user choice, and skips the snap to the
# font's pixel grid -- which renders 4x6-font.ttf at 6 instead
# of 7, a 3px-wide glyph instead of 4px. That shipped once,
# silently, and was found by a user counting pixels on a photo
# of the panel.
#
# Logged, not raised: a missing schema must not stop a plugin
# rendering. The cache is built once per class, so this cannot
# repeat per frame.
logger.warning(
"%s: could not read config_schema.json (%s: %s); every font "
"size will be treated as user-chosen and will skip its pixel "
"grid snap. Font sizes may render a pixel narrow.",
type(self).__name__, type(exc).__name__, exc)
cache = {}
self.__class__._SCHEMA_FONT_SIZES = cache
return cache.get(element_key)
@@ -661,11 +713,11 @@ class SportsCoreSharedMixin:
while size > grid:
if probe.textlength(
self._SCORE_PROBE_TEXT,
font=ImageFont.truetype(path, size)) <= budget:
font=load_truetype(path, size)) <= budget:
break
size -= grid
if size != getattr(fonts['score'], 'size', size):
fonts['score'] = ImageFont.truetype(path, size)
fonts['score'] = load_truetype(path, size)
self._score_grew = True
if not self._score_grew and not self._user_chose_size('score_text') \
@@ -685,7 +737,7 @@ class SportsCoreSharedMixin:
if _size <= current:
continue
_path = _resolve_font_path(f"assets/fonts/{_name}")
_candidate = ImageFont.truetype(_path, _size)
_candidate = load_truetype(_path, _size)
if probe.textlength(self._SCORE_PROBE_TEXT,
font=_candidate) <= budget:
fonts['score'] = _candidate
@@ -700,7 +752,7 @@ class SportsCoreSharedMixin:
if ceiling and size >= ceiling:
size = max(grid, ceiling - grid)
if size != getattr(fonts['time'], 'size', size):
fonts['time'] = ImageFont.truetype(path, size)
fonts['time'] = load_truetype(path, size)
except Exception:
self.logger.debug("Headline font scaling skipped", exc_info=True)
return fonts
+2 -1
View File
@@ -10,6 +10,7 @@ from pathlib import Path
from typing import Dict, List, Optional, Tuple, Union
from PIL import Image, ImageDraw, ImageFont
from src.common.font_layout import load_truetype
# Shared throwaway draw surface for measuring text without a target canvas.
_measure_draw = ImageDraw.Draw(Image.new("RGB", (1, 1)))
@@ -60,7 +61,7 @@ class TextHelper:
size = config['size']
if font_path.exists():
font = ImageFont.truetype(str(font_path), size)
font = load_truetype(str(font_path), size)
fonts[font_name] = font
self.logger.debug(f"Loaded font: {font_name} ({font_path}, size {size})")
else:
+189 -85
View File
@@ -198,6 +198,10 @@ class DisplayController:
# the main run loop reconciles (loads/unloads) on its own thread so
# mutating available_modes never races with rendering.
self._pending_plugin_reconcile = False
# Monotonic stamp of the last mailbox disk read; see
# _poll_on_demand_requests. None means "never polled", so the first
# call always goes through.
self._last_on_demand_poll: Optional[float] = None
self.on_demand_active = False
self.on_demand_mode: Optional[str] = None
self.on_demand_modes: List[str] = [] # All modes for the on-demand plugin
@@ -303,39 +307,7 @@ class DisplayController:
# Check for on-demand plugin filter from cache
on_demand_config = self.cache_manager.get('display_on_demand_config', max_age=3600)
on_demand_plugin_id = on_demand_config.get('plugin_id') if on_demand_config else None
if on_demand_plugin_id:
logger.info("On-demand mode detected during initialization: filtering to plugin '%s' only", on_demand_plugin_id)
# Only load the on-demand plugin, but ensure it's enabled
if on_demand_plugin_id not in discovered_plugins:
error_msg = f"On-demand plugin '{on_demand_plugin_id}' not found in discovered plugins"
logger.error(error_msg)
logger.warning("Falling back to normal mode (all enabled plugins)")
on_demand_plugin_id = None
enabled_plugins = [p for p in discovered_plugins if self.config.get(p, {}).get('enabled', False)]
else:
plugin_config = self.config.get(on_demand_plugin_id, {})
was_disabled = not plugin_config.get('enabled', False)
if was_disabled:
logger.info("Temporarily enabling plugin '%s' for on-demand mode", on_demand_plugin_id)
if on_demand_plugin_id not in self.config:
self.config[on_demand_plugin_id] = {}
self.config[on_demand_plugin_id]['enabled'] = True
enabled_plugins = [on_demand_plugin_id]
# Set on-demand state from cached config
self.on_demand_active = True
self.on_demand_plugin_id = on_demand_plugin_id
self.on_demand_mode = on_demand_config.get('mode')
self.on_demand_duration = on_demand_config.get('duration')
self.on_demand_pinned = on_demand_config.get('pinned', False)
self.on_demand_requested_at = on_demand_config.get('requested_at')
self.on_demand_expires_at = on_demand_config.get('expires_at')
self.on_demand_status = 'active'
self.on_demand_schedule_override = True
logger.info("On-demand mode: loading only plugin '%s'", on_demand_plugin_id)
else:
enabled_plugins = [p for p in discovered_plugins if self.config.get(p, {}).get('enabled', False)]
enabled_plugins = self._select_startup_plugins(discovered_plugins, on_demand_config)
# Count enabled plugins for progress tracking
enabled_count = len(enabled_plugins)
@@ -1267,12 +1239,119 @@ class DisplayController:
self.on_demand_schedule_override = False
self._publish_on_demand_state()
#: Shortest gap between mailbox disk reads. This is called after every
#: frame -- about 125 times a second on a scrolling mode -- and the read
#: below is deliberately uncached, so without a floor it was 125 disk reads
#: per second to find nothing. An on-demand request comes from a person
#: clicking in the web UI, so a quarter second of latency is not
#: perceptible, and it cuts the read rate by 30x.
ON_DEMAND_POLL_INTERVAL = 0.25
def _select_startup_plugins(self, discovered_plugins: List[str],
on_demand_config: Optional[Dict[str, Any]]) -> List[str]:
"""Which plugins to load at startup, restoring on-demand state if any.
Every normally-enabled plugin loads, on-demand or not. Loading only the
on-demand plugin left every other plugin unavailable for the rest of
the process's life whenever the service was restarted while on-demand
was still active -- and a restart during an on-demand session is
routine, since that is how updates and config changes are applied. The
panel came back cycling that one plugin's modes and nothing else, with
no way out but clearing the on-demand cache by hand.
On-demand still resumes on its saved mode; this only widens what gets
loaded, so normal rotation has somewhere to return to when it ends.
A plugin that is disabled in config but named by the on-demand request
is still enabled and added, since otherwise the mode being resumed
would have nothing behind it.
"""
enabled_plugins = [p for p in discovered_plugins
if self.config.get(p, {}).get('enabled', False)]
on_demand_plugin_id = on_demand_config.get('plugin_id') if on_demand_config else None
if not on_demand_plugin_id:
return enabled_plugins
if on_demand_plugin_id not in discovered_plugins:
logger.error("On-demand plugin '%s' not found in discovered plugins",
on_demand_plugin_id)
logger.warning("Falling back to normal mode (all enabled plugins)")
return enabled_plugins
if not self.config.get(on_demand_plugin_id, {}).get('enabled', False):
logger.info("Temporarily enabling plugin '%s' for on-demand mode", on_demand_plugin_id)
self.config.setdefault(on_demand_plugin_id, {})['enabled'] = True
if on_demand_plugin_id not in enabled_plugins:
enabled_plugins.append(on_demand_plugin_id)
# Restore on-demand state from the cached request so it resumes.
self.on_demand_active = True
self.on_demand_plugin_id = on_demand_plugin_id
self.on_demand_mode = on_demand_config.get('mode')
self.on_demand_duration = on_demand_config.get('duration')
self.on_demand_pinned = on_demand_config.get('pinned', False)
self.on_demand_requested_at = on_demand_config.get('requested_at')
self.on_demand_expires_at = on_demand_config.get('expires_at')
self.on_demand_status = 'active'
self.on_demand_schedule_override = True
logger.info("On-demand mode detected during initialization: resuming on plugin '%s'; "
"all %d enabled plugin(s) still load normally",
on_demand_plugin_id, len(enabled_plugins))
return enabled_plugins
def _consume_on_demand_request(self, request_id: str) -> None:
"""Remove the request we just handled from the mailbox.
Leaving it on disk meant a restart replayed the previous request: the
fresh controller read it, activated it and cached it, so the request
the caller had just made was ignored and the panel silently showed the
earlier plugin.
Compare before deleting. The web process can post a newer request
between the read and this delete; an unconditional delete threw that
one away and it was never processed -- the user's second click did
nothing. Re-reading uncached and only deleting our own request_id
leaves a newer request in the mailbox for the next poll instead.
This narrows the window rather than closing it: a request landing
between the re-read and the delete is still lost. Closing it properly
needs an atomic claim (a rename, or a compare-and-delete primitive)
that the cache layer does not currently offer, so the honest fix is a
smaller window plus this note, not a bigger lock. For start requests
processed_id still guards against reprocessing if the delete fails.
"""
try:
current = self.cache_manager.get('display_on_demand_request',
max_age=3600, memory_ttl=0)
if not current or current.get('request_id') == request_id:
self.cache_manager.delete('display_on_demand_request')
else:
logger.debug("Newer on-demand request %s arrived while processing "
"%s; leaving it in the mailbox",
current.get('request_id'), request_id)
except (OSError, AttributeError, KeyError) as err:
logger.debug("Could not clear the on-demand request mailbox: %s", err)
def _poll_on_demand_requests(self) -> None:
"""Poll cache for new on-demand requests from external controllers."""
now = time.monotonic()
if (self._last_on_demand_poll is not None
and now - self._last_on_demand_poll < self.ON_DEMAND_POLL_INTERVAL):
return
self._last_on_demand_poll = now
try:
# Use a long max_age (1 hour) to ensure requests aren't expired before processing
# The request_id check prevents duplicate processing
request = self.cache_manager.get('display_on_demand_request', max_age=3600)
# The request_id check prevents duplicate processing.
#
# memory_ttl=0 is required, not optional: this key is a mailbox the
# web process writes and this process reads. get() defaults the
# in-memory TTL to max_age, so without it the first request read was
# pinned in memory for the full hour and every later poll returned
# that stale copy -- meaning no second on-demand request was honoured
# for an hour, while the API still reported success.
request = self.cache_manager.get('display_on_demand_request',
max_age=3600, memory_ttl=0)
except (OSError, RuntimeError, ValueError, TypeError) as err:
logger.error("Failed to read on-demand request: %s", err, exc_info=True)
return
@@ -1299,6 +1378,13 @@ class DisplayController:
logger.debug("Stop request %s received but on-demand is not active", request_id)
# Still update request_id to acknowledge the request
self.on_demand_request_id = request_id
# Stop requests are deliberately exempt from the request_id/
# processed_id guards above, so that a second click stops a mode
# that a race left running. Consuming the mailbox is therefore the
# only thing that ends the request: without it the same stop was
# re-read and re-processed on every poll, forever, logging at
# ON_DEMAND_POLL_INTERVAL for the life of the process.
self._consume_on_demand_request(request_id)
return
# For start requests, check if already processed
@@ -1318,6 +1404,7 @@ class DisplayController:
# Mark as processed BEFORE processing (to prevent duplicate processing)
self.cache_manager.set('display_on_demand_processed_id', request_id, ttl=3600)
self.on_demand_request_id = request_id
self._consume_on_demand_request(request_id)
if action == 'start':
logger.info("Processing on-demand start request for plugin: %s", request.get('plugin_id'))
@@ -1360,17 +1447,14 @@ class DisplayController:
return modes[0]
return plugin_id
def _populate_on_demand_modes_from_plugin(self) -> None:
"""
Populate on_demand_modes from the on-demand plugin's display modes.
Called after plugin loading completes when on-demand state is restored from cache.
"""
if not self.on_demand_active or not self.on_demand_plugin_id:
return
def _on_demand_modes_for_plugin(self, plugin_id: str) -> List[str]:
"""Every loaded display mode belonging to `plugin_id`, in rotation order.
plugin_id = self.on_demand_plugin_id
# Get all modes for this plugin
Live modes that actually have content lead, then the rest, then live
modes with nothing to show -- so an on-demand request for a sports
plugin opens on a game in progress rather than an empty live screen.
Returns an empty list when the plugin has no loaded modes.
"""
plugin_modes = self.plugin_display_modes.get(plugin_id, [])
if not plugin_modes:
# Fallback: find all modes that belong to this plugin
@@ -1378,11 +1462,8 @@ class DisplayController:
# Filter to only include modes that exist in plugin_modes
available_plugin_modes = [m for m in plugin_modes if m in self.plugin_modes]
if not available_plugin_modes:
logger.warning("No valid display modes found for on-demand plugin '%s' after restoration", plugin_id)
self.on_demand_modes = []
return
return []
# Prioritize live modes if they exist and have content
live_modes = [m for m in available_plugin_modes if m.endswith('_live')]
@@ -1410,6 +1491,45 @@ class DisplayController:
# Only live modes available but no content - use them anyway
ordered_modes = live_modes
return ordered_modes
def _apply_on_demand_pin(self, ordered_modes: List[str], resolved_mode: Optional[str],
pinned: bool) -> List[str]:
"""Narrow an on-demand rotation to the single requested mode when pinned.
`pinned` reaches the controller from the API and was stored and
republished but never acted on, so a pinned request still rotated
through every mode the resolved plugin owns. That is the right default
for a sports plugin, whose modes are views of one subject
(nfl_live/nfl_recent/nfl_upcoming), and the wrong one for a plugin
whose modes are unrelated -- each Starlark app is its own widget, so
asking for one and getting all of them is not what was requested.
"""
if not pinned or not resolved_mode or resolved_mode not in ordered_modes:
return ordered_modes
return [resolved_mode]
def _populate_on_demand_modes_from_plugin(self) -> None:
"""
Populate on_demand_modes from the on-demand plugin's display modes.
Called after plugin loading completes when on-demand state is restored from cache.
"""
if not self.on_demand_active or not self.on_demand_plugin_id:
return
plugin_id = self.on_demand_plugin_id
ordered_modes = self._on_demand_modes_for_plugin(plugin_id)
if not ordered_modes:
logger.warning("No valid display modes found for on-demand plugin '%s' after restoration", plugin_id)
self.on_demand_modes = []
return
# A restart must not silently un-pin: the pin is part of the request
# being resumed, and it is restored from the same cached config above.
ordered_modes = self._apply_on_demand_pin(
ordered_modes, self.on_demand_mode, self.on_demand_pinned)
self.on_demand_modes = ordered_modes
# Set index to match the restored mode if available, otherwise start at 0
if self.on_demand_mode and self.on_demand_mode in ordered_modes:
@@ -1464,45 +1584,13 @@ class DisplayController:
if resolved_mode in self.available_modes:
self.current_mode_index = self.available_modes.index(resolved_mode)
# Get all modes for this plugin
plugin_modes = self.plugin_display_modes.get(resolved_plugin_id, [])
if not plugin_modes:
# Fallback: find all modes that belong to this plugin
plugin_modes = [mode for mode, pid in self.mode_to_plugin_id.items() if pid == resolved_plugin_id]
# Filter to only include modes that exist in plugin_modes
available_plugin_modes = [m for m in plugin_modes if m in self.plugin_modes]
if not available_plugin_modes:
ordered_modes = self._on_demand_modes_for_plugin(resolved_plugin_id)
if not ordered_modes:
logger.error("No valid display modes found for plugin '%s'", resolved_plugin_id)
self._set_on_demand_error("no-modes")
return
# Prioritize live modes if they exist and have content
live_modes = [m for m in available_plugin_modes if m.endswith('_live')]
other_modes = [m for m in available_plugin_modes if not m.endswith('_live')]
# Check if live modes have content
live_with_content = []
for live_mode in live_modes:
plugin_instance = self.plugin_modes.get(live_mode)
if plugin_instance and hasattr(plugin_instance, 'has_live_content'):
try:
if plugin_instance.has_live_content():
live_with_content.append(live_mode)
except Exception:
pass
# Build mode list: live modes with content first, then other modes, then live modes without content
if live_with_content:
ordered_modes = live_with_content + other_modes + [m for m in live_modes if m not in live_with_content]
else:
# No live content, skip live modes
ordered_modes = other_modes
if not ordered_modes:
# Only live modes available but no content - use them anyway
ordered_modes = live_modes
ordered_modes = self._apply_on_demand_pin(ordered_modes, resolved_mode, pinned)
self.on_demand_active = True
self.on_demand_mode = resolved_mode # Keep for backward compatibility
@@ -2405,6 +2493,7 @@ class DisplayController:
)
while True:
_frame_start = time.perf_counter()
try:
with self._display_lock_or_skip(plugin_id) as can_display:
if can_display:
@@ -2425,11 +2514,26 @@ class DisplayController:
# Multi-display sync: send follower frame after each render
self._send_follower_frame(manager_to_display)
time.sleep(display_interval)
self._tick_plugin_updates()
self._poll_on_demand_requests()
self._check_on_demand_expiration()
# Pace to the frame deadline rather than sleeping a flat
# interval on top of the work. display() has already
# blocked on the panel's vsync by this point, so an
# unconditional sleep is added to a wait that already
# happened. Measured on a 2x128x64 chain at
# limit_refresh_rate_hz=100: ~4ms of render plus a flat
# 8ms put each iteration at ~12ms against a 10ms refresh
# grid, so every swap missed a refresh and the loop
# settled at 50fps where display_interval asks for 125 --
# and with zero headroom, ~14% of frames slipped a
# further refresh, which is what reads as scroll stutter.
_remaining = display_interval - (time.perf_counter() - _frame_start)
# Yield even when the frame overran its budget, so plugin
# update threads and the web UI are not starved of the GIL.
time.sleep(_remaining if _remaining > 0 else 0.001)
if self.current_display_mode != active_mode:
logger.debug("Mode changed during high-FPS loop, breaking early")
break
+202 -23
View File
@@ -34,6 +34,7 @@ else:
from contextlib import contextmanager
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
from src.common.font_layout import load_truetype
import threading
import time
from collections import OrderedDict
@@ -55,6 +56,31 @@ from src.common.permission_utils import (
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO) # Set to INFO level
#: The strike 5x7.bdf is drawn at. FreeType renders a BDF at its own fixed
#: size regardless, but a Face needs an active size before its metrics --
#: and therefore get_font_height() -- report anything but 0.
_CALENDAR_FONT_PX = 7
def _bdf_native_size(face) -> int:
"""The pixel height a BDF Face declares, or 0 if it does not say.
Used only to rescue a Face that was built without ``set_char_size``, so a
zero line height never reaches layout code.
"""
try:
sizes = getattr(face, "available_sizes", None) or []
if sizes:
return int(getattr(sizes[0], "height", 0) or 0)
except (AttributeError, IndexError, TypeError, ValueError) as exc:
# This runs on the measurement path for a face the caller already
# holds, so a malformed strike table must degrade to "unknown" rather
# than take the display down. Say which face, so a font that is
# actually broken is diagnosable rather than silently 8px.
logger.debug("Could not read BDF strike size from %r: %s", face, exc)
return 0
class _LogicalMatrix:
"""Proxy that reports a logical (per-screen) size for a physical matrix.
@@ -240,6 +266,14 @@ class DisplayManager:
self._update_lock = threading.RLock()
# Scrolling state tracking for graceful updates
# How many panel refreshes each pushed frame is held for. 1 means a new
# frame every refresh. Higher values are how a scroll runs slower than
# one pixel per refresh WITHOUT fractional pixel positions: the panel
# keeps refreshing at full rate (so flicker is unchanged) but motion
# advances a whole pixel every Nth refresh instead of every one.
# See src/common/scroll_config.py and scripts/scroll_speeds.py.
self._frame_hold = 1
self._scrolling_state = {
'is_scrolling': False,
'last_scroll_activity': 0,
@@ -364,11 +398,12 @@ class DisplayManager:
# Create image with the (logical) display dimensions
self.image = Image.new('RGB', (self.matrix.width, self.matrix.height))
self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
logger.info(f"Image canvas created with dimensions: {self.matrix.width}x{self.matrix.height}")
# Initialize font with Press Start 2P
try:
self.font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
self.font = load_truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
logger.info("Initial Press Start 2P font loaded successfully")
except Exception as e:
logger.error(f"Failed to load initial font: {e}")
@@ -403,6 +438,7 @@ class DisplayManager:
self.image = Image.new('RGB', (fallback_width, fallback_height))
self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
# Simple fallback visualization so web UI shows a realistic canvas
try:
self.draw.rectangle([0, 0, fallback_width - 1, fallback_height - 1], outline=(255, 0, 0))
@@ -547,19 +583,33 @@ class DisplayManager:
pass
def _fitting_font(self, lines, width):
"""The largest font from the usual ladder that fits every line."""
"""The largest font from the usual ladder that fits every line.
The ladder ends at 4x6 at 5px because a full dotted-quad address --
"255.255.255.255", the widest this screen ever shows -- is 66px at
6px and a 64px panel has 62 to give it. That used to squeak in only
because the measurement depended on which text layout engine the host
Pillow had; with the engine pinned it does not, so the rung the
worst case actually needs is here rather than implied.
"""
candidates = [self.font,
("assets/fonts/4x6-font.ttf", 6)]
("assets/fonts/4x6-font.ttf", 6),
("assets/fonts/4x6-font.ttf", 5)]
narrowest = None
for candidate in candidates:
try:
font = candidate
if isinstance(candidate, tuple):
font = ImageFont.truetype(candidate[0], candidate[1])
font = load_truetype(candidate[0], candidate[1])
narrowest = font
if all(self.draw.textlength(t, font=font) <= width for t in lines):
return font
except (OSError, ValueError, AttributeError):
continue
return self.font
# Nothing fit. Return the smallest face that loaded, not self.font --
# falling back to the widest option is how "Initializing" ran off the
# side of a 64px panel in the first place.
return narrowest or self.font
def _draw_startup_banner(self, lines, width: int, height: int) -> None:
"""Centre `lines` over whatever the test pattern already drew.
@@ -705,6 +755,7 @@ class DisplayManager:
# self.image, so swapping the buffer below is enough on its own.
self.image = Image.new('RGB', (target_w, target_h))
self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
yield
finally:
self.matrix = real_matrix
@@ -768,16 +819,33 @@ class DisplayManager:
return # Skip hardware write — content is being captured off-screen
digest = None
frame_checksum = None
if self._dirty_tracking_enabled:
try:
brightness = getattr(self.matrix, 'brightness', None)
except AttributeError:
brightness = None
digest = (zlib.adler32(self.image.tobytes()), brightness)
if digest == self._last_pushed_digest:
frame_checksum = zlib.adler32(self.image.tobytes())
digest = (frame_checksum, brightness)
if digest == self._last_pushed_digest and not self.is_currently_scrolling():
# Nothing changed since the last push — the panel is
# already showing exactly this frame.
self._write_snapshot_if_due()
#
# Never taken mid-scroll, and that exception is the
# point. SwapOnVSync is what paces the render loop, so
# skipping it also skips the wait: a duplicate frame
# returns in ~8ms instead of ~10ms on a 100Hz panel,
# advances only 0.8px instead of 1.0px, and so makes
# the *next* frame more likely to repeat as well. That
# is self-sustaining -- measured at ~20% duplicate
# frames mid-scroll on the odds ticker, against
# essentially zero on a lighter plugin with identical
# scroll settings. Swapping an identical frame costs
# one canvas copy and keeps the loop locked to the
# panel; falling out of that lock costs smooth motion.
# Static content is unaffected: is_currently_scrolling()
# expires on its own inactivity threshold.
self._write_snapshot_if_due(frame_checksum)
return
# Copy the current image to the offscreen canvas. In double-sided
@@ -787,8 +855,10 @@ class DisplayManager:
else:
self.offscreen_canvas.SetImage(self.image)
# Swap buffers immediately
self.matrix.SwapOnVSync(self.offscreen_canvas)
# Swap buffers immediately. framerate_fraction holds the frame
# for N refreshes; SwapOnVSync blocks for all of them, which is
# what paces the render loop to the chosen frame rate.
self.matrix.SwapOnVSync(self.offscreen_canvas, self._frame_hold)
# Swap our canvas references
self.offscreen_canvas, self.current_canvas = self.current_canvas, self.offscreen_canvas
@@ -796,7 +866,7 @@ class DisplayManager:
self._last_pushed_digest = digest
# Write a snapshot for the web preview (throttled)
self._write_snapshot_if_due()
self._write_snapshot_if_due(frame_checksum)
except Exception as e:
logger.error(f"Error updating display: {e}")
@@ -814,6 +884,7 @@ class DisplayManager:
self.image = Image.new('RGB', (width, height))
self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
logger.debug("Cleared display in fallback mode")
return
@@ -825,6 +896,7 @@ class DisplayManager:
# Create a new black image
self.image = Image.new('RGB', (self.matrix.width, self.matrix.height))
self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
if not self._capture_mode_active:
# Clear both canvases and the underlying matrix to ensure no artifacts.
@@ -901,11 +973,11 @@ class DisplayManager:
self._text_width_cache.clear()
try:
# Load Press Start 2P font
self.regular_font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
self.regular_font = load_truetype("assets/fonts/PressStart2P-Regular.ttf", 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 = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
self.small_font = load_truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
logger.info("Press Start 2P small font loaded successfully")
# Load 5x7 BDF font for calendar events
@@ -918,6 +990,17 @@ class DisplayManager:
# Load with freetype for proper BDF handling
face = freetype.Face(self.calendar_font_path)
# A freshly constructed Face has no active size, so
# face.size.height is 0 until set_char_size is called -- and
# get_font_height() reads exactly that. Without this, every
# caller measuring the 5x7 face got 0 and stacked rows on top
# of one another; the "Calendar font size: 0 pixels" line
# below has been printing the symptom on every start-up.
# font_manager._load_bdf_font already does this; the two paths
# disagreed about whether a Face was usable for measurement.
# 5x7.bdf is a fixed strike, so FreeType renders 7px whatever
# is asked for -- this sets the metrics, not the raster.
face.set_char_size(_CALENDAR_FONT_PX * 64, _CALENDAR_FONT_PX * 64, 72, 72)
logger.info(f"5x7 calendar font loaded successfully from {self.calendar_font_path}")
logger.info(f"Calendar font size: {face.size.height >> 6} pixels")
@@ -938,7 +1021,7 @@ class DisplayManager:
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 = ImageFont.truetype(font_path, 6)
self.extra_small_font = load_truetype(font_path, 6)
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.")
@@ -996,7 +1079,13 @@ class DisplayManager:
try:
if isinstance(font, freetype.Face):
# For FreeType faces (BDF), the 'height' metric gives the recommended line spacing.
return font.size.height >> 6
height = font.size.height >> 6
if height:
return height
# A Face constructed without set_char_size reports 0, and a
# zero line height collapses every stacked row onto one line.
# Fall back to the strike the file declares.
return _bdf_native_size(font) or 8
else:
# For PIL TTF fonts, getmetrics() provides ascent and descent.
# The line height is the sum of ascent and descent.
@@ -1258,6 +1347,7 @@ class DisplayManager:
try:
self.image = Image.new('RGB', (self.width, self.height))
self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
except (OSError, RuntimeError, ValueError, MemoryError):
logger.debug("Canvas reset during cleanup failed", exc_info=True)
# Reset the singleton state when cleaning up
@@ -1274,12 +1364,65 @@ class DisplayManager:
return dt.strftime(f"%b %-d{suffix}")
def set_scrolling_state(self, is_scrolling: bool):
"""Set the current scrolling state. Call this when a display starts/stops scrolling."""
@property
def refresh_hz(self) -> float:
"""The panel's refresh rate in Hz, from the hardware config.
The authoritative place to ask, because a plugin only receives its own
config section and cannot see display.hardware. Scroll pacing needs
this: the speeds a panel can show in whole pixels are refresh_hz
divided by the frame hold, so getting it wrong silently produces
fractional-pixel motion. See src/common/scroll_config.py.
Note this is the configured *cap*, not necessarily what the panel
achieves -- scripts/scroll_speeds.py --measure reports the real rate.
"""
hardware = (self.config.get('display') or {}).get('hardware') or {}
try:
value = float(hardware.get('limit_refresh_rate_hz') or 0)
except (TypeError, ValueError):
value = 0.0
return value if value > 0 else 100.0
def set_frame_hold(self, refreshes: int) -> None:
"""Hold each pushed frame for this many panel refreshes (>=1).
Set by the scroll configuration so a plugin can run at, say, 50px/s on
a 100Hz panel as one whole pixel every second refresh, rather than half
a pixel every refresh (which has to be blended or repeated unevenly).
Reset to 1 whenever scrolling stops, so one plugin's pacing cannot
leak into the next thing on screen.
"""
try:
value = int(refreshes)
except (TypeError, ValueError):
logger.warning("Ignoring unusable frame hold: %r", refreshes)
return
self._frame_hold = max(1, min(255, value))
def set_scrolling_state(self, is_scrolling: bool, frame_hold: int = 1):
"""Set the current scrolling state, and this scroll's frame pacing.
Call this when a display starts or stops scrolling. ``frame_hold`` is
how many panel refreshes each frame is held for -- 2 gives one whole
pixel every second refresh, which is how a scroll runs at half the
refresh rate without fractional pixel positions.
The hold is set here rather than once at plugin construction because
it must not outlive the scroll that asked for it: plugins share one
display manager, so a hold left set by whoever scrolled last would
silently re-pace the next plugin. Passing it alongside the state makes
the lifetime exactly the scroll, and the default of 1 means any caller
that does not care gets a new frame every refresh.
"""
current_time = time.time()
self._scrolling_state['is_scrolling'] = is_scrolling
if is_scrolling:
self._scrolling_state['last_scroll_activity'] = current_time
self.set_frame_hold(frame_hold)
else:
self._frame_hold = 1
logger.debug(f"Scrolling state set to: {is_scrolling}")
def is_currently_scrolling(self) -> bool:
@@ -1293,6 +1436,14 @@ class DisplayManager:
# If we've been inactive for the threshold period, consider it not scrolling
if current_time - self._scrolling_state['last_scroll_activity'] > self._scrolling_state['scroll_inactivity_threshold']:
self._scrolling_state['is_scrolling'] = False
# Drop the hold with the state, exactly as set_scrolling_state(False)
# does. This path is the one a scroll takes when it ends without
# saying so -- the rotation moves on mid-scroll, or the plugin is
# torn down -- and leaving the hold set there means every later
# plugin, scrolling or static, is presented at refresh/N until
# somebody calls set_scrolling_state(False). The hold must not
# outlive the scroll that asked for it, however that scroll ends.
self._frame_hold = 1
return False
return True
@@ -1410,11 +1561,20 @@ class DisplayManager:
self._viewer_fresh = False
return self._viewer_fresh
def _write_snapshot_if_due(self) -> None:
def _write_snapshot_if_due(self, frame_checksum: Optional[int] = None) -> None:
"""Mirror the current frame to the preview snapshot when the policy
says it's worth it — see src/common/snapshot_policy.py. Unchanged
frames are never re-encoded; without viewers the cadence drops to
the idle keepalive."""
the idle keepalive.
Args:
frame_checksum: adler32 of the current frame, when the caller has
already computed one. Dirty tracking checksums every frame a
few lines above the call site, and re-deriving it here meant a
second tobytes() plus a second pass over the whole framebuffer
on every single frame — ~0.17ms per frame of the two combined
at 256x64, paid 100 times a second to reach the same number.
"""
try:
now = time.time()
viewer_fresh = self._viewer_is_fresh(now)
@@ -1424,7 +1584,8 @@ class DisplayManager:
self._last_snapshot_ts = 0.0
self._viewer_was_fresh = viewer_fresh
digest = zlib.adler32(self.image.tobytes())
digest = (frame_checksum if frame_checksum is not None
else zlib.adler32(self.image.tobytes()))
action = snapshot_policy.decide(
now, self._last_snapshot_ts, self._last_snapshot_touch_ts,
viewer_fresh, digest != self._last_snapshot_digest)
@@ -1447,12 +1608,30 @@ class DisplayManager:
if parent_dir and str(parent_dir) != '/tmp': # nosec B108 - guard to skip /tmp for permission ops
ensure_directory_permissions(parent_dir, get_assets_dir_mode())
self._snapshot_dir_prepared = True
# Write atomically: temp then replace
tmp_path = f"{self._snapshot_path}.tmp"
self.image.save(tmp_path, format='PNG')
# Write atomically: temp then replace. The temp name must be
# unique, not "<snapshot>.tmp": /tmp is world-writable and sticky,
# and this file is written by whichever user the display service
# runs as while tests and tooling run as someone else. A leftover
# fixed-name temp owned by another user is then unopenable even by
# root (fs.protected_regular refuses O_CREAT on a foreign file in a
# sticky dir), which froze the preview and the health check's
# liveness proxy until somebody deleted it by hand. Same pattern as
# the hardware-status write above.
_fd, tmp_path = tempfile.mkstemp(
dir=str(snapshot_path_obj.parent),
prefix=f".{snapshot_path_obj.name}.", suffix=".tmp")
try:
with os.fdopen(_fd, "wb") as _f:
self.image.save(_f, format='PNG')
os.chmod(tmp_path, 0o644)
os.replace(tmp_path, self._snapshot_path)
except Exception:
# Never leave the temp behind -- that is what made the failure
# permanent rather than transient.
try:
os.unlink(tmp_path)
except OSError:
pass
# Fallback to direct save if replace not supported
self.image.save(self._snapshot_path, format='PNG')
# Set proper file permissions after saving
+3 -2
View File
@@ -51,6 +51,7 @@ from dataclasses import dataclass
from typing import Any, Dict, Optional, Tuple, Union
from PIL import ImageFont
from src.common.font_layout import load_truetype
try:
import freetype
@@ -155,7 +156,7 @@ def load_font(font_name: str, size: int) -> Any:
face.set_char_size(size * 64, size * 64, 72, 72)
font: Any = face
else:
font = ImageFont.truetype(path, size)
font = load_truetype(path, size)
except Exception as e:
logger.warning("Error loading font %s at %spx: %s, using fallback",
path, size, e)
@@ -174,7 +175,7 @@ def _load_fallback_font(size: int) -> Any:
if cached is not None:
return cached
try:
font = ImageFont.truetype(path, size)
font = load_truetype(path, size)
_font_cache[cache_key] = font
return font
except Exception as e:
+5 -3
View File
@@ -38,6 +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 typing import Dict, Tuple, Optional, Union, Any, List
logger = logging.getLogger(__name__)
@@ -96,7 +97,8 @@ class FontManager:
self.common_fonts = {
"press_start": "assets/fonts/PressStart2P-Regular.ttf",
"four_by_six": "assets/fonts/4x6-font.ttf",
"five_by_seven": "assets/fonts/5x7.bdf"
"five_by_seven": "assets/fonts/5x7.bdf",
"tom_thumb": "assets/fonts/tom-thumb.bdf"
# Note: cozette_bdf removed - font file not available
# To re-enable: download cozette.bdf from https://github.com/the-moonwitch/Cozette
# and add: "cozette_bdf": "assets/fonts/cozette.bdf"
@@ -478,7 +480,7 @@ class FontManager:
if font_path.endswith('.bdf'):
font = self._load_bdf_font(font_path, size_px)
else:
font = ImageFont.truetype(font_path, size_px)
font = load_truetype(font_path, size_px)
except Exception as e:
logger.error(f"Error loading font {font_path}: {e}")
self.performance_stats["failed_loads"] += 1
@@ -858,7 +860,7 @@ class FontManager:
return {"valid": True, "type": "bdf", "family": "unknown"}
elif font_path.endswith('.ttf'):
# Try to load TTF font
ImageFont.truetype(font_path, 12)
load_truetype(font_path, 12)
return {"valid": True, "type": "ttf", "family": "unknown"}
else:
return {"valid": False, "error": "Unsupported font format"}
+2 -1
View File
@@ -14,6 +14,7 @@ import json
from typing import Dict, List, Optional, Tuple
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
from src.common.font_layout import load_truetype
from PIL.PngImagePlugin import PngInfo
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
@@ -747,7 +748,7 @@ class LogoDownloader:
# Try to load a font, fallback to default
try:
font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 12)
font = load_truetype("assets/fonts/PressStart2P-Regular.ttf", 12)
except (OSError, IOError):
try:
font = ImageFont.load_default()
+40
View File
@@ -28,6 +28,7 @@ fixes their version string. See `docs/SPORTS_UNIFICATION.md`, phase B4.
from __future__ import annotations
import re
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
# Below this, the core's self-reported version is not evidence of anything.
@@ -35,6 +36,45 @@ from typing import Any, Dict, Optional, Tuple
TRUSTWORTHY_FLOOR: Tuple[int, int, int] = (2, 0, 0)
# ``src/__init__.py`` relative to this file: src/plugin_system/ -> src/
_VERSION_FILE = Path(__file__).resolve().parent.parent / "__init__.py"
_VERSION_RE = re.compile(r'^__version__\s*=\s*["\']([^"\']+)["\']', re.M)
def current_core_version() -> str:
"""The core version as it is on disk right now, not as it was at import.
``from src import __version__`` binds whatever the process loaded at start.
The web UI runs as its own long-lived service (``ledmatrix-web.service``),
and updating the core replaces files on disk without restarting it -- the
update route says so explicitly and asks the user to restart. Its prompt
names the *display* service, so a user who follows it leaves the web
process holding the old number.
The plugin store's gate lives in that web process. Stale by one release is
exactly the case that matters: every plugin flooring on the release you
just installed gets refused, with a message blaming a core version that is
already correct on disk. 3.3.0 is the first release where that hits a whole
plugin family at once -- all eight sports scoreboards floor there.
Reading the file costs one stat and a small read per call, and only on the
install/update path. Any failure falls back to the imported value, so this
can only ever be as wrong as before, never worse.
"""
try:
text = _VERSION_FILE.read_text(encoding="utf-8")
match = _VERSION_RE.search(text)
if match:
return match.group(1)
except (OSError, UnicodeDecodeError):
pass
try:
from src import __version__ as imported
return imported
except Exception: # noqa: BLE001 - never let this raise
return "0.0.0"
def parse_semver(value: Any) -> Optional[Tuple[int, int, int]]:
"""Parse ``X.Y.Z`` (extra parts and suffixes ignored) into a comparable
3-tuple, or ``None`` when unparseable. A leading ``v`` is tolerated."""
+1 -1
View File
@@ -772,8 +772,8 @@ class PluginLoader:
newer than the running core. Advisory only — never raises — so a
plugin that guards optional features with try/except keeps working.
"""
from src import __version__ as core_version
from src.plugin_system import compatibility
core_version = compatibility.current_core_version()
compatible, _reason = compatibility.check(manifest, core_version)
if compatible:
+75 -3
View File
@@ -1467,8 +1467,11 @@ class PluginStoreManager:
# already had. Allowing it costs them a plugin that raises
# ModuleNotFoundError at load and is reported only as one line
# in the journal. See docs/SPORTS_UNIFICATION.md (phase B4/B6).
from src import __version__ as core_version
from src.plugin_system import compatibility
# On disk, not as imported: this process may predate the core
# update that made the plugin compatible. See
# compatibility.current_core_version.
core_version = compatibility.current_core_version()
compatible, reason = compatibility.check(manifest, core_version)
if not compatible:
@@ -1586,6 +1589,7 @@ class PluginStoreManager:
with open(manifest_path, 'r') as f:
manifest = json.load(f)
requested_id = plugin_id
plugin_id = plugin_id or manifest.get('id')
if not plugin_id:
return {
@@ -1612,8 +1616,8 @@ class PluginStoreManager:
#
# Before the move, so the `finally` below removes the temp tree and
# nothing half-installed is left behind.
from src import __version__ as core_version
from src.plugin_system import compatibility
core_version = compatibility.current_core_version()
compatible, reason = compatibility.check(manifest, core_version)
if not compatible:
@@ -1661,6 +1665,15 @@ class PluginStoreManager:
branch_info = f" (branch: {branch_used})" if branch_used else ""
self.logger.info(f"Successfully installed plugin from URL: {plugin_id}{branch_info}")
# User deliberately (re)installed this plugin -- clear any persistent
# uninstall record, exactly as install_plugin() does. Without this the
# id stays in config/uninstalled_plugins.json and
# purge_uninstalled_plugins(), which runs at every web-app startup,
# deletes the directory again: the plugin works for the rest of the
# session and is gone after the next reboot.
self.forget_uninstalled_plugin(
*(pid for pid in (requested_id, plugin_id, manifest.get('id')) if pid)
)
result = {
'success': True,
'plugin_id': plugin_id,
@@ -2421,6 +2434,65 @@ class PluginStoreManager:
except (OSError, ValueError):
pass
# Last resort: the directory name may differ from the id being looked
# up. install_plugin() deliberately renames a plugin's directory to the
# MANIFEST id when it differs from the REGISTRY id (see the rename near
# "doesn't match registry ID" above), so `stocks` in the registry lands
# in `ledmatrix-stocks/`. Every lookup above is by directory name, so
# update_plugin("stocks") found nothing and reported the plugin as not
# installed -- silently, and for good: the user sees no error and stays
# on a stale version. Four installed plugins hit this in practice
# (leaderboard, music, stocks, weather).
#
# Deliberately last so the two lookups above keep their exact meaning;
# this only runs when a direct hit already failed. See
# test_discovery_path_contract.py, which pins that ordering.
for search_dir in self._candidate_plugin_dirs():
match = self._find_by_manifest_id(search_dir, plugin_id)
if match is not None:
self.logger.debug(
"Resolved plugin '%s' to %s via its manifest id "
"(directory name differs from the id)", plugin_id, match)
return match
return None
def _candidate_plugin_dirs(self) -> List[Path]:
"""Directories that may hold installed plugins, configured one first."""
dirs = [self.plugins_dir]
try:
base = self.plugins_dir if self.plugins_dir.is_absolute() else self.plugins_dir.resolve()
sibling = base.parent / 'plugins'
if sibling != self.plugins_dir:
dirs.append(sibling)
except (OSError, ValueError):
pass
return [d for d in dirs if d.exists()]
@staticmethod
def _find_by_manifest_id(search_dir: Path, plugin_id: str) -> Optional[Path]:
"""A subdirectory of `search_dir` whose manifest declares `plugin_id`.
Skips half-finished installs: store_manager renames a directory aside
with '.standalone-backup-' during install and rollback, and treating
one as installed would resurrect a ghost plugin.
"""
try:
entries = sorted(search_dir.iterdir())
except (OSError, ValueError):
return None
for entry in entries:
if not entry.is_dir() or '.standalone-backup-' in entry.name:
continue
manifest = entry / 'manifest.json'
if not manifest.is_file():
continue
try:
with open(manifest, 'r', encoding='utf-8') as handle:
if json.load(handle).get('id') == plugin_id:
return entry
except (OSError, ValueError):
continue
return None
_SKIN_ID_PATTERN = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$')
@@ -2644,8 +2716,8 @@ class PluginStoreManager:
manifest_path, plugin_id, e)
return True
from src import __version__ as core_version
from src.plugin_system import compatibility
core_version = compatibility.current_core_version()
compatible, reason = compatibility.check(manifest, core_version)
if compatible:
+140 -12
View File
@@ -14,6 +14,8 @@ via BoundsCheckingDisplayManager, and golden-image comparison.
import contextlib
import http.client
import inspect
import time
from datetime import timedelta
import socket
import ssl
import urllib.error
@@ -25,7 +27,7 @@ from PIL import Image, ImageChops
from src.logging_config import get_logger
from .bounds_display_manager import BoundsCheckingDisplayManager
from .loading import load_config_defaults, load_manifest
from .loading import load_config_defaults, load_manifest, merge_config
from .sizes import DEFAULT_TEST_SIZES, safe_mode_filename, size_label
logger = get_logger("[Plugin Harness]")
@@ -116,14 +118,23 @@ def list_modes(plugin_instance: Any, manifest: Dict[str, Any], plugin_id: str) -
def _instantiate(plugin_id: str, manifest: Dict[str, Any], plugin_dir: Path,
config: Dict[str, Any], mock_data: Dict[str, Any],
display_manager: Any) -> Any:
"""Load and construct a plugin instance with mocked managers."""
display_manager: Any, cache_manager: Any = None) -> Any:
"""Load and construct a plugin instance with mocked managers.
Pass ``cache_manager`` to share one cache across the renders of a plugin.
Building a fresh one per (size, mode) made every render a cold start, so a
plugin that fetches per game or per player re-fetched everything N times --
baseball-scoreboard took 840s for nine renders where ~72s was the arithmetic
-- and the cache-hit path, which is what a running rig executes almost
always, was never exercised.
"""
from src.plugin_system.plugin_loader import PluginLoader
from src.plugin_system.testing import MockCacheManager, MockPluginManager
cache_manager = MockCacheManager()
for key, value in (mock_data or {}).items():
cache_manager.set(key, value)
if cache_manager is None:
cache_manager = MockCacheManager()
for key, value in (mock_data or {}).items():
cache_manager.set(key, value)
loader = PluginLoader()
plugin_instance, _module = loader.load_plugin(
@@ -160,6 +171,107 @@ def _render_mode(plugin_instance: Any, mode: str) -> Any:
return plugin_instance.display(force_clear=False)
# How many extra frames to drive before believing a mode really draws nothing.
# A scroll starts with its content off-panel, so frame 1 is legitimately blank;
# measured across the fleet, content appears by frame 2-4 (f1-scoreboard),
# frame 4 (ledmatrix-elections) and frame 38 at 64px (ledmatrix-leaderboard).
EMPTY_RECHECK_FRAMES = 48
# Seconds to advance the clock between those frames. Scroll position is usually
# driven by elapsed time, which a frozen clock never provides.
EMPTY_RECHECK_STEP = 0.05
def _has_content(image) -> bool:
"""True when any pixel is lit above the threshold."""
if image is None:
return False
return image.convert("L").point(
lambda p: 255 if p > _LIT_THRESHOLD else 0).getbbox() is not None
def _render_mode_again(plugin_instance: Any, mode: str) -> Any:
"""Draw one more frame WITHOUT force_clear.
_render_mode passes force_clear=True, which for a scrolling plugin means
"reset the scroll to the start" -- so repeating it would redraw frame 1 for
ever. The re-check needs the plugin to advance.
"""
sig = inspect.signature(plugin_instance.display)
if "display_mode" in sig.parameters:
return plugin_instance.display(force_clear=False, display_mode=mode)
return plugin_instance.display(force_clear=False)
def _settle_empty_frame(inst, mode, dm, result, freezer) -> None:
"""Give an apparently-empty mode a few frames to draw before believing it.
One frame is not evidence: a scroll's first frame is its blank scroll-in
buffer. Without this, every scrolling plugin was warned about -- 60 of 76
warnings on a 44-plugin rig were false, which is the rate at which people
stop reading a warning.
"""
if result.error is not None or result.display_returned is False:
return
if _has_content(result.image):
return
# The frozen clock is shared by every render in the matrix, so any time this
# probe borrows has to be given back -- otherwise a mode that scrolls in
# leaves the clock advanced and every later mode renders at the wrong
# instant, drifting its golden. Seen as 5 spurious f1_upcoming drifts.
resume_at = None
if freezer is not None:
try:
resume_at = freezer()
except (AttributeError, TypeError, ValueError):
# Not a freezegun factory, or a version whose factory is not
# callable. Only used to restore the clock, never load-bearing.
resume_at = None
try:
_settle_loop(inst, mode, dm, result, freezer)
finally:
if resume_at is not None:
try:
freezer.move_to(resume_at)
except (AttributeError, TypeError, ValueError):
pass
def _settle_loop(inst, mode, dm, result, freezer) -> None:
tick = getattr(freezer, "tick", None) if freezer is not None else None
for _ in range(EMPTY_RECHECK_FRAMES):
if tick is not None:
# timedelta rather than a bare float: freezegun has accepted a
# number only since 1.x, and a stale pin would raise here.
try:
tick(timedelta(seconds=EMPTY_RECHECK_STEP))
except (AttributeError, TypeError, ValueError):
# Pacing is best-effort; a freezegun that will not take a
# timedelta just means this probe runs without advancing time.
pass
else:
# No frozen clock, so the real one has to do the advancing. Without
# this the 48 frames run in microseconds, elapsed time stays ~0, and
# a scroll driven by elapsed time never moves -- which is exactly
# the plugin this check is trying not to slander.
time.sleep(EMPTY_RECHECK_STEP)
try:
result.display_returned = _render_mode_again(inst, mode)
except Exception as e: # noqa: BLE001
# Deliberately broad: this calls a plugin's display(), which can
# raise anything. Recorded rather than swallowed -- a mode that
# renders one good frame and then crashes on the next is broken,
# and returning silently here reported it as passing. The frame
# already captured stays on the result so the failure is still
# inspectable.
result.error = repr(e)
return
image = dm.get_image()
if _has_content(image):
result.image = image
result.overflow = dm.check_overflow()
return
def _freeze(freeze_time: Optional[str]):
"""Context manager that freezes wall-clock time when freeze_time is given,
so time-dependent plugins (clocks, countdowns) render deterministic goldens."""
@@ -193,7 +305,9 @@ def render_plugin_matrix(
manifest = load_manifest(plugin_dir)
# Start from config_schema.json defaults so the plugin behaves like a real
# install; explicit caller config still wins over a schema default.
config = {"enabled": True, **load_config_defaults(plugin_dir), **(config or {})}
config = merge_config(
merge_config({"enabled": True}, load_config_defaults(plugin_dir)),
config or {})
sizes = sizes or DEFAULT_TEST_SIZES
results: List[RenderResult] = []
@@ -202,25 +316,35 @@ def render_plugin_matrix(
# rendering a smaller one, instead of being clipped into a false pass.
extent = (max(w for w, _ in sizes), max(h for _, h in sizes))
with _freeze(freeze_time):
# One cache for the whole matrix: see _instantiate. The display manager
# stays per-render (the bounds checking depends on that); only fetched data
# is shared.
from src.plugin_system.testing import MockCacheManager
cache_manager = MockCacheManager()
for key, value in (mock_data or {}).items():
cache_manager.set(key, value)
with _freeze(freeze_time) as freezer:
for width, height in sizes:
results.extend(_render_size(
plugin_id, manifest, plugin_dir, config, mock_data or {},
width, height, run_update, extent,
width, height, run_update, extent, cache_manager, freezer,
))
return results
def _render_size(plugin_id, manifest, plugin_dir, config, mock_data,
width, height, run_update, extent) -> List[RenderResult]:
width, height, run_update, extent,
cache_manager=None, freezer=None) -> List[RenderResult]:
"""Render every mode at one size. A fresh instance per mode avoids state leaks."""
results: List[RenderResult] = []
# Discover modes once per size (instance build can depend on config).
try:
probe_dm = BoundsCheckingDisplayManager(width=width, height=height, overflow_extent=extent)
probe = _instantiate(plugin_id, manifest, plugin_dir, config, mock_data, probe_dm)
probe = _instantiate(plugin_id, manifest, plugin_dir, config, mock_data, probe_dm,
cache_manager)
modes = list_modes(probe, manifest, plugin_id)
except Exception as e: # noqa: BLE001 — surface any load failure as a result
return [RenderResult(plugin_id, width, height, "<load>", error=repr(e))]
@@ -229,7 +353,8 @@ def _render_size(plugin_id, manifest, plugin_dir, config, mock_data,
result = RenderResult(plugin_id, width, height, mode)
dm = BoundsCheckingDisplayManager(width=width, height=height, overflow_extent=extent)
try:
inst = _instantiate(plugin_id, manifest, plugin_dir, config, mock_data, dm)
inst = _instantiate(plugin_id, manifest, plugin_dir, config, mock_data, dm,
cache_manager)
if run_update:
try:
inst.update()
@@ -248,6 +373,9 @@ def _render_size(plugin_id, manifest, plugin_dir, config, mock_data,
result.display_returned = _render_mode(inst, mode)
result.image = dm.get_image()
result.overflow = dm.check_overflow()
# A blank first frame is not proof of a blank mode; see
# _settle_empty_frame.
_settle_empty_frame(inst, mode, dm, result, freezer)
except Exception as e: # noqa: BLE001 — a display crash is a real failure
result.error = repr(e)
results.append(result)
+40 -5
View File
@@ -33,6 +33,45 @@ def load_manifest(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
return json.load(f)
def _defaults_from_properties(properties: Dict[str, Any]) -> Dict[str, Any]:
"""Defaults for one `properties` block, recursing into nested objects.
An object property carries its defaults on its children, not on itself, so
reading only the top level dropped everything nested. That is most of the
fleet: config organised by league, or under customization/display_options,
lost 2,386 defaults across 37 of 44 plugins -- soccer-scoreboard alone lost
539 of 565 -- and the harness rendered them with a config no install would
ever have.
"""
defaults: Dict[str, Any] = {}
for key, prop in (properties or {}).items():
if not isinstance(prop, dict):
continue
if prop.get('type') == 'object' and isinstance(prop.get('properties'), dict):
nested = _defaults_from_properties(prop['properties'])
if nested:
defaults[key] = nested
elif 'default' in prop:
defaults[key] = prop['default']
return defaults
def merge_config(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
"""Deep-merge override onto base, without dropping sibling defaults.
A shallow merge would let `-c '{"nhl": {"enabled": true}}'` replace the whole
nhl subtree and silently discard every other nhl default -- the same class of
bug this function exists to fix.
"""
merged = dict(base)
for key, value in (override or {}).items():
if isinstance(value, dict) and isinstance(merged.get(key), dict):
merged[key] = merge_config(merged[key], value)
else:
merged[key] = value
return merged
def load_config_defaults(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
"""Extract default values from a plugin's config_schema.json (empty if none)."""
schema_path = Path(plugin_dir) / 'config_schema.json'
@@ -40,11 +79,7 @@ def load_config_defaults(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
return {}
with open(schema_path, 'r') as f:
schema = json.load(f)
defaults: Dict[str, Any] = {}
for key, prop in schema.get('properties', {}).items():
if isinstance(prop, dict) and 'default' in prop:
defaults[key] = prop['default']
return defaults
return _defaults_from_properties(schema.get('properties', {}))
def load_harness_spec(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
@@ -31,6 +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.logging_config import get_logger
@@ -70,6 +71,7 @@ class VisualTestDisplayManager:
# Canvas
self.image = Image.new('RGB', (width, height), (0, 0, 0))
self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows.
# Matrix proxy (plugins access display_manager.matrix.width/height)
self.matrix = _MatrixProxy(width, height)
@@ -140,8 +142,8 @@ class VisualTestDisplayManager:
# Press Start 2P — regular and small (both 8px)
ttf_path = str(fonts_dir / 'PressStart2P-Regular.ttf')
self.regular_font = ImageFont.truetype(ttf_path, 8)
self.small_font = ImageFont.truetype(ttf_path, 8)
self.regular_font = load_truetype(ttf_path, 8)
self.small_font = load_truetype(ttf_path, 8)
self.font = self.regular_font # alias used by some code paths
# 5x7 BDF font via freetype
@@ -161,7 +163,7 @@ class VisualTestDisplayManager:
# 4x6 extra small TTF
try:
xs_path = str(fonts_dir / '4x6-font.ttf')
self.extra_small_font = ImageFont.truetype(xs_path, 6)
self.extra_small_font = load_truetype(xs_path, 6)
except (FileNotFoundError, OSError) as e:
logger.debug("Extra small font not available, using fallback: %s", e)
self.extra_small_font = self.small_font
@@ -184,6 +186,7 @@ class VisualTestDisplayManager:
self.clear_called = True
self.image = Image.new('RGB', (self._width, self._height), (0, 0, 0))
self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows.
def update_display(self):
"""No-op for hardware; marks that display was updated."""
@@ -211,6 +214,7 @@ class VisualTestDisplayManager:
self.matrix = _MatrixProxy(target_w, target_h)
self.image = Image.new('RGB', (target_w, target_h), (0, 0, 0))
self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows.
yield
finally:
self._width, self._height = prev_w, prev_h
@@ -503,9 +507,18 @@ class VisualTestDisplayManager:
# Scrolling state (no-op interface compat)
# ------------------------------------------------------------------
def set_scrolling_state(self, is_scrolling: bool):
"""Set the current scrolling state (no-op for testing)."""
def set_scrolling_state(self, is_scrolling: bool, frame_hold: int = 1):
"""Set the current scrolling state (no-op for testing).
``frame_hold`` mirrors the DisplayManager signature this change adds.
The two are kept in step deliberately: a double that accepts arguments
production does not lets a call pass every harness run and then raise
TypeError on the panel, and a double that lacks one production has
fails every render of a plugin that legitimately paces its scroll.
Plugins begin passing it in ledmatrix-plugins#462.
"""
self._scrolling_state['is_scrolling'] = is_scrolling
self._scrolling_state['frame_hold'] = frame_hold
if is_scrolling:
self._scrolling_state['last_scroll_activity'] = time.time()
@@ -569,6 +582,7 @@ class VisualTestDisplayManager:
self.draw_calls = []
self.image = Image.new('RGB', (self._width, self._height), (0, 0, 0))
self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows.
self._scrolling_state = {
'is_scrolling': False,
'last_scroll_activity': 0,
@@ -582,3 +596,4 @@ class VisualTestDisplayManager:
"""Clean up resources."""
self.image = Image.new('RGB', (self._width, self._height), (0, 0, 0))
self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows.
+23
View File
@@ -19,12 +19,35 @@ This directory contains systemd service unit files for LEDMatrix services.
- Automatically enables/disables access point mode
- Uses `scripts/utils/wifi_monitor_daemon.py`
- **`ledmatrix-dns-fix.service`** - DNS single-request fix (optional)
- Re-applies `options single-request` to the resolver on every boot,
because whatever manages `resolv.conf` regenerates it and drops the
option again
- Works around glibc's parallel A/AAAA lookup stalling ~5s per name on
routers that answer only the A query, which makes any plugin calling an
external API slow or (for Starlark apps, which have a render timeout)
fail outright
- Uses `scripts/utils/apply_dns_single_request.sh`
- Install only if external API calls are timing out; it is not part of a
normal install
- **`ledmatrix-mqtt-bridge.service`** - Home Assistant MQTT bridge (optional)
- Exposes the display to Home Assistant over MQTT Discovery: force a mode,
stop on-demand, toggle power, set brightness
- Uses `integrations/mqtt_bridge/ledmatrix_mqtt_bridge.py`, which drives the
web API rather than the display directly
- Needs `integrations/mqtt_bridge/bridge_config.json`; see that directory's
README
## Installation
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_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)
- `install_mqtt_bridge.sh` installs `ledmatrix-mqtt-bridge.service` (opt-in)
## Manual Installation
+16
View File
@@ -0,0 +1,16 @@
[Unit]
Description=LED Matrix DNS single-request fix (works around slow A/AAAA lookups)
After=network-online.target NetworkManager.service
Wants=network-online.target
Before=ledmatrix.service
[Service]
Type=oneshot
ExecStart=__PROJECT_ROOT_DIR__/scripts/utils/apply_dns_single_request.sh
RemainAfterExit=yes
StandardOutput=journal
StandardError=journal
SyslogIdentifier=ledmatrix-dns-fix
[Install]
WantedBy=multi-user.target
+18
View File
@@ -0,0 +1,18 @@
[Unit]
Description=LED Matrix Home Assistant MQTT Bridge
After=network-online.target ledmatrix-web.service
Wants=network-online.target
[Service]
Type=simple
User=root
WorkingDirectory=__PROJECT_ROOT_DIR__
ExecStart=/usr/bin/python3 __PROJECT_ROOT_DIR__/integrations/mqtt_bridge/ledmatrix_mqtt_bridge.py
Restart=on-failure
RestartSec=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=ledmatrix-mqtt-bridge
[Install]
WantedBy=multi-user.target
+998
View File
@@ -0,0 +1,998 @@
[
[
"/api/v3/backup/<path:filename>",
"api_v3.backup_delete",
[
"DELETE",
"OPTIONS"
]
],
[
"/api/v3/backup/download/<path:filename>",
"api_v3.backup_download",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/backup/export",
"api_v3.backup_export",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/backup/list",
"api_v3.backup_list",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/backup/preview",
"api_v3.backup_preview",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/backup/restore",
"api_v3.backup_restore",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/backup/validate",
"api_v3.backup_validate",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/cache/delete",
"api_v3.delete_cache_file",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/cache/list",
"api_v3.list_cache_files",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/config/dim-schedule",
"api_v3.get_dim_schedule_config",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/config/dim-schedule",
"api_v3.save_dim_schedule_config",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/config/main",
"api_v3.get_main_config",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/config/main",
"api_v3.save_main_config",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/config/raw/main",
"api_v3.save_raw_main_config",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/config/raw/secrets",
"api_v3.save_raw_secrets_config",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/config/schedule",
"api_v3.get_schedule_config",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/config/schedule",
"api_v3.save_schedule_config",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/config/secrets",
"api_v3.get_secrets_config",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/display/current",
"api_v3.get_display_current",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/display/current-status",
"api_v3.get_current_display_status",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/display/modes",
"api_v3.get_display_modes",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/display/on-demand/start",
"api_v3.start_on_demand_display",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/display/on-demand/status",
"api_v3.get_on_demand_status",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/display/on-demand/stop",
"api_v3.stop_on_demand_display",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/errors/clear",
"api_v3.clear_old_errors",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/errors/plugin/<plugin_id>",
"api_v3.get_plugin_errors",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/errors/summary",
"api_v3.get_error_summary",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/fonts/<font_family>",
"api_v3.delete_font",
[
"DELETE",
"OPTIONS"
]
],
[
"/api/v3/fonts/catalog",
"api_v3.get_fonts_catalog",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/fonts/overrides",
"api_v3.get_fonts_overrides",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/fonts/overrides",
"api_v3.save_fonts_overrides",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/fonts/overrides/<element_key>",
"api_v3.delete_font_override",
[
"DELETE",
"OPTIONS"
]
],
[
"/api/v3/fonts/preview",
"api_v3.get_font_preview",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/fonts/tokens",
"api_v3.get_font_tokens",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/fonts/upload",
"api_v3.upload_font",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/hardware/status",
"api_v3.get_hardware_status",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/health",
"api_v3.get_health",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/integrations/mqtt-bridge",
"api_v3.get_mqtt_bridge",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/integrations/mqtt-bridge/config",
"api_v3.update_mqtt_bridge_config",
[
"OPTIONS",
"PUT"
]
],
[
"/api/v3/logs",
"api_v3.get_logs",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/plugins/<plugin_id>/static/<path:file_path>",
"api_v3.serve_plugin_static",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/plugins/action",
"api_v3.execute_plugin_action",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/plugins/assets/delete",
"api_v3.delete_plugin_asset",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/plugins/assets/list",
"api_v3.list_plugin_assets",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/plugins/assets/upload",
"api_v3.upload_plugin_asset",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/plugins/authenticate/spotify",
"api_v3.authenticate_spotify",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/plugins/authenticate/ytm",
"api_v3.authenticate_ytm",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/plugins/calendar/authenticate",
"api_v3.authenticate_calendar",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/plugins/calendar/list-calendars",
"api_v3.list_calendar_calendars",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/plugins/calendar/upload-credentials",
"api_v3.upload_calendar_credentials",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/plugins/config",
"api_v3.get_plugin_config",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/plugins/config",
"api_v3.save_plugin_config",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/plugins/config/reset",
"api_v3.reset_plugin_config",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/plugins/health",
"api_v3.get_plugin_health",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/plugins/health/<plugin_id>",
"api_v3.get_plugin_health_single",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/plugins/health/<plugin_id>/reset",
"api_v3.reset_plugin_health",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/plugins/install",
"api_v3.install_plugin",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/plugins/install-from-url",
"api_v3.install_plugin_from_url",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/plugins/installed",
"api_v3.get_installed_plugins",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/plugins/limits/<plugin_id>",
"api_v3.manage_plugin_limits",
[
"GET",
"HEAD",
"OPTIONS",
"POST"
]
],
[
"/api/v3/plugins/metrics",
"api_v3.get_plugin_metrics",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/plugins/metrics/<plugin_id>",
"api_v3.get_plugin_metrics_single",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/plugins/metrics/<plugin_id>/reset",
"api_v3.reset_plugin_metrics",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/plugins/of-the-day/json/delete",
"api_v3.delete_of_the_day_json",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/plugins/of-the-day/json/upload",
"api_v3.upload_of_the_day_json",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/plugins/operation/<operation_id>",
"api_v3.get_operation_status",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/plugins/operation/history",
"api_v3.clear_operation_history",
[
"DELETE",
"OPTIONS"
]
],
[
"/api/v3/plugins/operation/history",
"api_v3.get_operation_history",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/plugins/reconciliation-status",
"api_v3.get_reconciliation_status",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/plugins/registry-from-url",
"api_v3.get_registry_from_url",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/plugins/saved-repositories",
"api_v3.add_saved_repository",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/plugins/saved-repositories",
"api_v3.get_saved_repositories",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/plugins/saved-repositories",
"api_v3.remove_saved_repository",
[
"DELETE",
"OPTIONS"
]
],
[
"/api/v3/plugins/schema",
"api_v3.get_plugin_schema",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/plugins/state",
"api_v3.get_plugin_state",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/plugins/state/reconcile",
"api_v3.reconcile_plugin_state",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/plugins/store/github-status",
"api_v3.get_github_auth_status",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/plugins/store/list",
"api_v3.list_plugin_store",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/plugins/store/refresh",
"api_v3.refresh_plugin_store",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/plugins/toggle",
"api_v3.toggle_plugin",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/plugins/uninstall",
"api_v3.uninstall_plugin",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/plugins/update",
"api_v3.update_plugin",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/skins",
"api_v3.list_skins",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/starlark/apps",
"api_v3.get_starlark_apps",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/starlark/apps/<app_id>",
"api_v3.get_starlark_app",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/starlark/apps/<app_id>",
"api_v3.uninstall_starlark_app",
[
"DELETE",
"OPTIONS"
]
],
[
"/api/v3/starlark/apps/<app_id>/config",
"api_v3.get_starlark_app_config",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/starlark/apps/<app_id>/config",
"api_v3.update_starlark_app_config",
[
"OPTIONS",
"PUT"
]
],
[
"/api/v3/starlark/apps/<app_id>/render",
"api_v3.render_starlark_app",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/starlark/apps/<app_id>/toggle",
"api_v3.toggle_starlark_app",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/starlark/editor/apps",
"api_v3.list_pixlet_editor_apps",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/starlark/editor/start",
"api_v3.start_pixlet_editor",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/starlark/editor/status",
"api_v3.get_pixlet_editor_status",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/starlark/editor/stop",
"api_v3.stop_pixlet_editor",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/starlark/install-pixlet",
"api_v3.install_pixlet",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/starlark/repository/browse",
"api_v3.browse_tronbyte_repository",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/starlark/repository/categories",
"api_v3.get_tronbyte_categories",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/starlark/repository/install",
"api_v3.install_from_tronbyte_repository",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/starlark/status",
"api_v3.get_starlark_status",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/starlark/upload",
"api_v3.upload_starlark_app",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/sync/status",
"api_v3.get_sync_status",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/system/action",
"api_v3.execute_system_action",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/system/check-update",
"api_v3.check_for_update",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/system/git-branches",
"api_v3.get_git_branches",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/system/git-info",
"api_v3.get_git_info",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/system/status",
"api_v3.get_system_status",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/system/version",
"api_v3.get_system_version",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/wifi/ap/auto-enable",
"api_v3.get_auto_enable_ap_mode",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/wifi/ap/auto-enable",
"api_v3.set_auto_enable_ap_mode",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/wifi/ap/disable",
"api_v3.disable_ap_mode",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/wifi/ap/enable",
"api_v3.enable_ap_mode",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/wifi/connect",
"api_v3.connect_wifi",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/wifi/disconnect",
"api_v3.disconnect_wifi",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/wifi/radio",
"api_v3.get_wifi_radio",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/wifi/radio",
"api_v3.set_wifi_radio",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/wifi/scan",
"api_v3.scan_wifi_networks",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/wifi/status",
"api_v3.get_wifi_status",
[
"GET",
"HEAD",
"OPTIONS"
]
]
]
+66
View File
@@ -0,0 +1,66 @@
# Web-interface JS tests
Covers `web_interface/static/v3/js/plugins/list_filter.js` (the shared
search/filter/sort controller) and the plugin-manager grids that use it:
Installed Plugins, the Plugin Store, and Starlark Apps.
There is no JS toolchain in this repo, so these are plain node scripts with no
test framework. Each prints `ok`/`FAIL` lines and exits non-zero on failure.
## Running
```bash
cd test/js
npm install # jsdom, for the DOM suites only
node run_all.js
```
The unit suites need nothing but node. The DOM suites additionally need a
running web interface, because they test against the **real** server-rendered
HTML and the **real** API rather than fixtures:
```bash
# in another shell, from the repo root
EMULATOR=true python3 web_interface/app.py # http://localhost:5000
# or point the suites at a device
BASE=http://10.0.10.169:5000 node run_all.js
```
`run_all.js` skips the DOM suites (rather than failing) when jsdom is missing or
nothing is listening, so it stays useful in a bare checkout.
## The suites
| Suite | Needs a server | Covers |
|---|---|---|
| `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 |
| `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` |
| `dom/test_tools_sections.js` | yes | The Tools tab's MQTT bridge and Pixlet editor sections: form prefill, the write-only password (blank means unchanged), the running-session banner and countdown, and that the editor link points at the host you loaded the page from |
Point the DOM suites at a rig with a full plugin set when it matters — a dev box
with two plugins installed will pass while exercising very little.
## Notes for whoever changes this next
- The suites read the shipped files off disk and, for the DOM ones, the partial
from the running server. They do not keep their own copy of the markup, so
renaming an element id will fail them loudly rather than silently pass.
- `unit/test_list_filter.js` `eval`s a slice of `plugins_manager.js` located by
the text `function installedSortName(plugin)`. If that function is renamed,
fix the slice markers rather than pasting a copy of the config into the test.
- A few assertions exist specifically to stop earlier bugs coming back:
trailing spaces surviving the search debounce; a multi-word query that spans
two adjacent search fields (field order in the haystack is load-bearing);
`window.installedPlugins` staying at full length while the grid is filtered.
- Watch for assertions that can pass vacuously. Several here deliberately guard
against it — e.g. counting only non-skeleton cards, and asserting a search
phrase matches something before comparing two results.
The old-vs-new differential suites used to verify that the store and Starlark
migrations were behaviour-preserving are not included: they compared against the
pre-refactor implementation, which now only exists in git history. See PR #540
if that comparison ever needs redoing.
+279
View File
@@ -0,0 +1,279 @@
// Integration test in a REAL DOM (jsdom):
// - HTML comes from the running server's /partials/plugins (real template output)
// - list_filter.js is loaded as a real script
// - plugin data comes from the running server's real API
// - interactions are real dispatched DOM events on the real pill/select nodes
// This exercises HTML parsing, attribute reflection, event bubbling and
// delegation for real — none of which the hand-rolled shim could vouch for.
const fs = require('fs');
const http = require('http');
const { JSDOM, VirtualConsole } = require('jsdom');
const path = require('path');
const V3 = path.resolve(__dirname, '../../../web_interface/static/v3');
const BASE = process.env.BASE || 'http://localhost:5000';
function get(path) {
return new Promise((res, rej) => {
http.get(BASE + path, r => { let d = ''; r.on('data', c => d += c); r.on('end', () => res(d)); })
.on('error', rej);
});
}
(async () => {
const partial = await get('/partials/plugins');
const installed = JSON.parse(await get('/api/v3/plugins/installed')).data.plugins;
// Collected so an uncaught error inside a handler fails the run instead of
// silently vanishing.
const jsErrors = [];
const vc = new VirtualConsole();
vc.on('jsdomError', e => jsErrors.push(String(e.message || e)));
vc.on('error', (...a) => jsErrors.push('console.error: ' + a.join(' ')));
const dom = new JSDOM(
`<!doctype html><html><body><div id="app">${partial}</div></body></html>`,
{ runScripts: 'dangerously', virtualConsole: vc, url: BASE + '/' });
const { window } = dom;
const { document } = window;
// Minimal ambient globals the extracted block expects from plugins_manager.js.
window.pluginLog = () => {};
window.debugLog = () => {};
window.PLUGIN_DEBUG = false;
window.installedPlugins = installed;
window.escapeHtml = function (text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
};
// Load the helper as a real <script>.
const s = document.createElement('script');
s.textContent = fs.readFileSync(V3 + '/js/plugins/list_filter.js', 'utf8');
document.body.appendChild(s);
if (!window.ListFilter) { console.log('FAIL: list_filter.js did not expose window.ListFilter'); process.exit(1); }
// Pull the installed-plugins wiring out of plugins_manager.js verbatim and run
// it as a real script in this document.
const src = fs.readFileSync(V3 + '/plugins_manager.js', 'utf8');
const a = src.indexOf('function installedSortName(plugin)');
const b = src.indexOf('// Set up event delegation for plugin action buttons');
const c = src.indexOf('function handlePluginAction(event)');
const d = src.indexOf('function findInstalledPlugin(pluginId)');
if ([a, b, c, d].some(i => i < 0)) { console.log('FAIL: could not slice plugins_manager.js'); process.exit(1); }
const s2 = document.createElement('script');
s2.textContent = `
var installedPlugins = window.installedPlugins;
var escapeHtml = window.escapeHtml, pluginLog = window.pluginLog, debugLog = window.debugLog;
var PLUGIN_DEBUG = false;
${src.slice(a, b)}
${src.slice(b, c)}
${src.slice(c, d)}
window.__t = { getInstalledFilter, renderInstalledPlugins, setupInstalledFilterListeners,
applyInstalledFiltersAndRender };
`;
document.body.appendChild(s2);
if (jsErrors.length) { console.log('FAIL: errors while loading:\n ' + jsErrors.join('\n ')); process.exit(1); }
const T = window.__t;
const $ = id => document.getElementById(id);
const grid = () => $('installed-plugins-grid');
const cards = () => grid().querySelectorAll('.plugin-card:not(.installed-skeleton)').length;
const pill = v => document.querySelector(`#installed-filter-pills [data-installed-filter="${v}"]`);
const litPills = () => [...document.querySelectorAll('#installed-filter-pills [data-installed-filter]')]
.filter(b => b.getAttribute('data-active') === 'true')
.map(b => b.getAttribute('data-installed-filter'));
const countText = () => $('installed-count').textContent.trim();
const click = el => el.dispatchEvent(new window.MouseEvent('click', { bubbles: true, cancelable: true }));
const type = (el, v) => { el.value = v; el.dispatchEvent(new window.Event('input', { bubbles: true })); };
const change = (el, v) => { el.value = v; el.dispatchEvent(new window.Event('change', { bubbles: true })); };
const tick = ms => new Promise(r => setTimeout(r, ms));
let pass = 0, fail = 0;
const ok = (l, cond, extra) => cond ? (pass++, console.log(' ok ' + l))
: (fail++, console.log(' FAIL ' + l + (extra !== undefined ? ' → ' + JSON.stringify(extra) : '')));
console.log('\n── real-DOM integration (jsdom) ──');
console.log(` server gave ${installed.length} installed plugins: ` +
installed.map(p => `${p.id}(en=${p.enabled},upd=${!!p.update_available})`).join(' '));
// ── the markup the server actually rendered ────────────────────────────
ok('toolbar present in server HTML', !!$('installed-filter-bar'));
ok('4 pills parsed from server HTML',
document.querySelectorAll('#installed-filter-pills [data-installed-filter]').length === 4);
ok('sort dropdown has the 5 agreed options',
[...$('installed-sort').options].map(o => o.value).join(',') === 'a-z,z-a,status,recent,category',
[...$('installed-sort').options].map(o => o.value));
ok('skeletons present before first render', grid().querySelectorAll('.installed-skeleton').length === 3);
// ── first render ───────────────────────────────────────────────────────
T.setupInstalledFilterListeners();
T.renderInstalledPlugins(installed);
ok('skeletons removed after render', grid().querySelectorAll('.installed-skeleton').length === 0);
ok('one card per plugin', cards() === installed.length, cards());
ok('count text unfiltered', countText() === `${installed.length} installed`, countText());
ok('only the All pill is lit', litPills().join(',') === 'all', litPills());
ok('aria-pressed mirrors data-active', pill('all').getAttribute('aria-pressed') === 'true');
const enabledCount = installed.filter(p => p.enabled).length;
const disabledCount = installed.length - enabledCount;
const updCount = installed.filter(p => p.update_available).length;
// ── the CSS hook this feature depends on ───────────────────────────────
// .filter-pill[data-active="true"] had never been used by any code before
// this change, so confirm the attribute really flips on the real nodes.
click(pill('enabled'));
ok('Enabled pill lights up', litPills().join(',') === 'enabled', litPills());
ok('Enabled filters the grid', cards() === enabledCount, { cards: cards(), expect: enabledCount });
ok('count switches to "N of M shown"', countText() === `${enabledCount} of ${installed.length} shown`, countText());
ok('Clear button revealed', !$('installed-clear-filters').classList.contains('hidden'));
click(pill('disabled'));
ok('Disabled pill lights, Enabled unlights', litPills().join(',') === 'disabled', litPills());
ok('Disabled filters the grid', cards() === disabledCount, { cards: cards(), expect: disabledCount });
click(pill('updates'));
ok('Updates filters the grid', cards() === updCount, { cards: cards(), expect: updCount });
ok('updates badge count matches', $('installed-updates-count').textContent === String(updCount),
$('installed-updates-count').textContent);
ok('badge visible when >0', updCount === 0 || !$('installed-updates-count').classList.contains('hidden'));
click(pill('all'));
ok('back to All shows everything', cards() === installed.length);
ok('Clear button hidden again', $('installed-clear-filters').classList.contains('hidden'));
// ── debounced search, through the real event path ──────────────────────
const first = installed[0];
type($('installed-search'), first.name.slice(0, 4));
await tick(350);
ok('search narrows to a match', cards() >= 1 && cards() <= installed.length, cards());
ok('search clear ✕ revealed', !$('installed-search-clear').classList.contains('hidden'));
type($('installed-search'), 'zzz-no-such-plugin');
await tick(350);
ok('no matches → zero cards', cards() === 0);
ok('shows the filter empty state', /No plugins match your filters/.test(grid().textContent));
ok('does NOT say "No plugins installed"', !/No plugins installed/.test(grid().textContent));
// Clear button inside the empty state goes through the delegated handler.
const emptyClear = grid().querySelector('[data-action="clear-installed-filters"]');
ok('empty state offers a Clear button', !!emptyClear);
if (emptyClear) {
click(emptyClear);
ok('empty-state Clear restores the grid', cards() === installed.length, cards());
ok('empty-state Clear empties the search box', $('installed-search').value === '');
}
// ── regression: typing a space must survive the debounce ───────────────
// setSearch() trims for matching; if the trimmed value were written back into
// the input, pausing mid-phrase would delete the space you just typed and
// multi-word terms would be untypable.
const si = $('installed-search');
type(si, 'web ');
await tick(350);
ok('trailing space survives the debounce', si.value === 'web ', JSON.stringify(si.value));
type(si, 'web u');
await tick(350);
ok('can keep typing past the space', si.value === 'web u', JSON.stringify(si.value));
type(si, ' ');
await tick(350);
ok('whitespace-only is not treated as an active filter', cards() === installed.length, cards());
ok('whitespace-only text is still left in the box', si.value === ' ', JSON.stringify(si.value));
type(si, '');
await tick(350);
// ── sorts, via the real select ─────────────────────────────────────────
const names = () => [...grid().querySelectorAll('.plugin-card h4')].map(h => h.textContent.trim());
change($('installed-sort'), 'a-z');
const az = names();
change($('installed-sort'), 'z-a');
ok('z-a is the reverse of a-z', names().join('|') === [...az].reverse().join('|'),
{ az, za: names() });
for (const s of ['status', 'recent', 'category']) {
change($('installed-sort'), s);
ok(`sort "${s}" keeps every card`, cards() === installed.length, cards());
}
// ── Clear Filters resets all three axes at once ────────────────────────
type($('installed-search'), first.name.slice(0, 3));
await tick(350);
click(pill('enabled'));
change($('installed-sort'), 'z-a');
click($('installed-clear-filters'));
ok('Clear resets search box', $('installed-search').value === '');
ok('Clear resets sort select', $('installed-sort').value === 'a-z');
ok('Clear relights All', litPills().join(',') === 'all', litPills());
ok('Clear restores all cards', cards() === installed.length, cards());
ok('Clear hides itself', $('installed-clear-filters').classList.contains('hidden'));
// ── the state-leak guard, on a real DOM ────────────────────────────────
click(pill('updates'));
ok('grid is filtered', cards() === updCount);
ok('window.installedPlugins still full length', window.installedPlugins.length === installed.length,
window.installedPlugins.length);
click(pill('all'));
// ── HTMX partial re-swap: the path no unit test could reach ────────────
// Replace the section's markup wholesale (what hx-swap does when you leave
// and re-enter the tab), then re-run init exactly as initPluginsPage would.
click(pill('enabled'));
type($('installed-search'), first.name.slice(0, 3));
await tick(350);
type($('installed-search'), $('installed-search').value + ' '); // trailing space, on purpose
await tick(350);
const beforeSwap = { search: $('installed-search').value, pills: litPills().join(','), cards: cards() };
ok('pre-swap value retains its trailing space', /\s$/.test(beforeSwap.search), JSON.stringify(beforeSwap.search));
document.getElementById('app').innerHTML = partial; // fresh toolbar at defaults
ok('post-swap DOM starts at defaults',
$('installed-search').value === '' && litPills().join(',') === 'all');
T.setupInstalledFilterListeners(); // what initPluginsPage() calls
T.renderInstalledPlugins(window.installedPlugins);
ok('post-swap: search text restored', $('installed-search').value === beforeSwap.search,
{ got: $('installed-search').value, want: beforeSwap.search });
ok('post-swap: pill state restored', litPills().join(',') === beforeSwap.pills, litPills());
// Search AND pill are both still active, so the restored view must match the
// pre-swap view exactly — comparing to it avoids restating the filter logic.
ok('post-swap: same view restored', cards() === beforeSwap.cards,
{ got: cards(), want: beforeSwap.cards });
// and the freshly swapped controls must still be live
type($('installed-search'), '');
await tick(350);
ok('post-swap: clearing search re-widens to the pill alone', cards() === enabledCount, cards());
click(pill('all'));
ok('post-swap: pills still clickable', cards() === installed.length, cards());
click(pill('disabled'));
ok('post-swap: pill filters again', cards() === disabledCount, cards());
click(pill('all'));
type($('installed-search'), 'zzz-none');
await tick(350);
ok('post-swap: search still live', cards() === 0);
click(grid().querySelector('[data-action="clear-installed-filters"]'));
ok('post-swap: delegated Clear still wired', cards() === installed.length);
// ── does the stylesheet actually hook the markup? ──────────────────────
const css = fs.readFileSync(V3 + '/app.css', 'utf8');
const st = document.createElement('style');
st.textContent = css;
document.head.appendChild(st);
click(pill('enabled'));
const activeWeight = window.getComputedStyle(pill('enabled')).fontWeight;
const idleWeight = window.getComputedStyle(pill('disabled')).fontWeight;
ok('.filter-pill[data-active="true"] matches our markup (font-weight 600)',
activeWeight === '600', { active: activeWeight, idle: idleWeight });
ok('inactive pill does not pick up the active rule', idleWeight !== '600', idleWeight);
click(pill('all'));
ok('no uncaught JS errors anywhere', jsErrors.length === 0, jsErrors.slice(0, 4));
console.log(`\n${pass} passed, ${fail} failed\n`);
process.exit(fail ? 1 : 0);
})().catch(e => { console.log('HARNESS ERROR: ' + e.stack); process.exit(1); });
+132
View File
@@ -0,0 +1,132 @@
// Proves CodeRabbit's second finding is fixed: typing in the store search or
// changing the category must filter the CACHED list, not refetch
// /api/v3/plugins/store/list.
//
// This loads the WHOLE of plugins_manager.js into jsdom (not a slice), so the
// legacy initializePlugins() wiring is present if it still exists, and counts
// every fetch the page makes.
const fs = require('fs');
const http = require('http');
const { JSDOM, VirtualConsole } = require('jsdom');
const path = require('path');
const V3 = path.resolve(__dirname, '../../../web_interface/static/v3');
const BASE = process.env.BASE || 'http://localhost:5000';
const get = p => new Promise((res, rej) =>
http.get(BASE + p, r => { let d = ''; r.on('data', c => d += c); r.on('end', () => res(d)); }).on('error', rej));
(async () => {
const partial = await get('/partials/plugins');
const store = JSON.parse(await get('/api/v3/plugins/store/list'));
const installed = JSON.parse(await get('/api/v3/plugins/installed'));
const loadErrors = [];
const vc = new VirtualConsole(); // quiet, but keep real errors
vc.on('jsdomError', e => loadErrors.push(String(e.message || e).split('\n')[0]));
const dom = new JSDOM(`<!doctype html><html><body><div id="app">${partial}</div></body></html>`,
{ runScripts: 'dangerously', virtualConsole: vc, url: BASE + '/', pretendToBeVisual: true });
const { window } = dom;
// Record every request the page attempts; serve from the payloads above.
const calls = [];
window.fetch = (url, opts) => {
const u = String(url);
calls.push(u);
let body = { status: 'success', data: {} };
if (u.includes('/plugins/store/list')) body = store;
else if (u.includes('/plugins/installed')) body = installed;
return Promise.resolve({
ok: true, status: 200,
json: () => Promise.resolve(body),
text: () => Promise.resolve(JSON.stringify(body)),
});
};
window.EventSource = function () { return { addEventListener() {}, close() {} }; };
window.confirm = () => false;
window.alert = () => {};
window.scrollTo = () => {};
// Globals plugins_manager.js expects from app.js (it declares /* global debugLog */).
const s0 = window.document.createElement('script');
s0.textContent = `
window.debugLog = function () {};
window.showNotification = function () {};
window.showError = function () {};
window.updateSystemStatus = function () {};
window.Alpine = undefined;
window.htmx = { process: function () {}, ajax: function () {} };
`;
window.document.body.appendChild(s0);
const s = window.document.createElement('script');
s.textContent = fs.readFileSync(V3 + '/js/plugins/list_filter.js', 'utf8');
window.document.body.appendChild(s);
const s2 = window.document.createElement('script');
s2.textContent = fs.readFileSync(V3 + '/plugins_manager.js', 'utf8');
window.document.body.appendChild(s2);
let pass = 0, fail = 0;
const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
: (fail++, console.log(' FAIL ' + l + (x !== undefined ? ' → ' + JSON.stringify(x).slice(0, 300) : '')));
const tick = ms => new Promise(r => setTimeout(r, ms));
console.log('\n── store search must not refetch (CodeRabbit #2) ──');
if (loadErrors.length) console.log(' load errors: ' + loadErrors.slice(0, 3).join(' | '));
ok('whole plugins_manager.js evaluated', typeof window.initPluginsPage === 'function');
if (typeof window.initPluginsPage === 'function') window.initPluginsPage();
await tick(600);
const storeListCalls = () => calls.filter(u => u.includes('/plugins/store/list')).length;
const afterInit = storeListCalls();
console.log(` init issued ${afterInit} store/list request(s) — that part is expected`);
ok('init loaded the store at least once', afterInit >= 1, calls);
const search = window.document.getElementById('plugin-search');
const category = window.document.getElementById('plugin-category');
ok('store search input present', !!search);
// Type a realistic burst, then wait past both debounce windows (300 ms each).
const before = storeListCalls();
for (const v of ['w', 'we', 'wea', 'weat', 'weath', 'weathe', 'weather']) {
search.value = v;
search.dispatchEvent(new window.Event('input', { bubbles: true }));
await tick(40);
}
await tick(700);
ok('typing 7 characters issued ZERO new store/list fetches',
storeListCalls() === before, { before, after: storeListCalls(), calls: calls.slice(-4) });
const beforeCat = storeListCalls();
if (category) {
category.value = 'sports';
category.dispatchEvent(new window.Event('change', { bubbles: true }));
await tick(700);
ok('changing category issued ZERO new store/list fetches',
storeListCalls() === beforeCat, { before: beforeCat, after: storeListCalls() });
}
// ...and the filtering still actually happened.
// Clear both axes before counting: 'weather' AND category=sports legitimately
// matches nothing, so counting here would measure the filter, not the render.
const beforeClear = storeListCalls();
search.value = '';
search.dispatchEvent(new window.Event('input', { bubbles: true }));
if (category) { category.value = ''; category.dispatchEvent(new window.Event('change', { bubbles: true })); }
await tick(700);
ok('clearing the filters also issued ZERO fetches', storeListCalls() === beforeClear,
{ before: beforeClear, after: storeListCalls() });
// Count only real cards: the partial ships loading skeletons that also carry
// .plugin-card, which would let this pass on a dead page.
const grid = window.document.getElementById('plugin-store-grid');
const real = grid.querySelectorAll('.plugin-card:not(.animate-pulse)').length;
ok('the grid rendered real cards from the cache', real > 0, { real, html: grid.innerHTML.slice(0, 160) });
ok('no leftover _listenerSetup flag on the search input',
search && search._listenerSetup === undefined, search && search._listenerSetup);
console.log(`\n${pass} passed, ${fail} failed\n`);
process.exit(fail ? 1 : 0);
})().catch(e => { console.log('HARNESS ERROR: ' + e.stack.split('\n').slice(0, 6).join('\n')); process.exit(1); });
+221
View File
@@ -0,0 +1,221 @@
// Real-DOM (jsdom) test of the migrated Plugin Store toolbar, against the
// server's actual 48-plugin registry — enough data for 4 pages, so pagination,
// the ellipsis strip and per-page changes are exercised for real.
// The card renderer itself is stubbed: Step 2 did not touch it, and stubbing
// keeps the assertions on the parts that did change.
const fs = require('fs');
const http = require('http');
const { JSDOM, VirtualConsole } = require('jsdom');
const path = require('path');
const V3 = path.resolve(__dirname, '../../../web_interface/static/v3');
const BASE = process.env.BASE || 'http://localhost:5000';
const get = path => new Promise((res, rej) =>
http.get(BASE + path, r => { let d = ''; r.on('data', c => d += c); r.on('end', () => res(d)); }).on('error', rej));
function slice(src, a, b) {
const i = src.indexOf(a), j = src.indexOf(b, i + 1);
if (i < 0 || j < 0) throw new Error('cannot slice: ' + a);
return src.slice(i, j);
}
(async () => {
const partial = await get('/partials/plugins');
const storeJson = JSON.parse(await get('/api/v3/plugins/store/list'));
const storePlugins = (storeJson.data && storeJson.data.plugins) || storeJson.plugins || [];
const installed = JSON.parse(await get('/api/v3/plugins/installed')).data.plugins;
const jsErrors = [];
const vc = new VirtualConsole();
vc.on('jsdomError', e => jsErrors.push(String(e.message || e)));
const dom = new JSDOM(`<!doctype html><html><body><div id="app">${partial}</div></body></html>`,
{ runScripts: 'dangerously', virtualConsole: vc, url: BASE + '/' });
const { window } = dom;
const { document } = window;
const src = fs.readFileSync(V3 + '/plugins_manager.js', 'utf8');
function boot() {
const s = document.createElement('script');
// IIFE-wrapped so a second boot gets a fresh closure instead of clashing on
// the top-level `const ListFilter`; it still publishes via window.ListFilter.
s.textContent = '(function(){\n' + fs.readFileSync(V3 + '/js/plugins/list_filter.js', 'utf8') + '\n})();';
document.body.appendChild(s);
const s2 = document.createElement('script');
s2.textContent = `(function(){
${slice(src, 'const safeLocalStorage = {', '\n// ')}
var installedPlugins = ${JSON.stringify(installed)};
window.installedPlugins = installedPlugins;
var pluginStoreCache = ${JSON.stringify(storePlugins)};
window.__renderCalls = 0;
// Stand-in for renderPluginStore: real DOM nodes, minimal markup.
function renderPluginStore(plugins) {
window.__renderCalls++;
const grid = document.getElementById('plugin-store-grid');
grid.innerHTML = (plugins || []).map(p =>
'<div class="plugin-card" data-id="' + p.id + '"></div>').join('');
}
${slice(src, 'function isStorePluginInstalled(', '// Expose searchPluginStore')}
window.__t = { applyStoreFiltersAndSort, setupStoreFilterListeners, getStoreFilter };
})();`;
document.body.appendChild(s2);
}
boot();
if (jsErrors.length) { console.log('FAIL loading:\n ' + jsErrors.join('\n ')); process.exit(1); }
const T = window.__t;
const $ = id => document.getElementById(id);
const cards = () => $('plugin-store-grid').querySelectorAll('.plugin-card').length;
const ids = () => [...$('plugin-store-grid').querySelectorAll('.plugin-card')].map(c => c.dataset.id);
const info = () => $('store-results-info').textContent.trim();
const infoBot = () => $('store-results-info-bottom') ? $('store-results-info-bottom').textContent.trim() : null;
const pagTop = () => $('store-pagination-top').innerHTML;
const pageBtns = () => [...$('store-pagination-top').querySelectorAll('[data-list-page]')];
const clickPage = label => {
const b = pageBtns().find(x => x.textContent.trim() === String(label));
if (!b) throw new Error('no page button labelled ' + label);
b.dispatchEvent(new window.MouseEvent('click', { bubbles: true, cancelable: true }));
};
const click = el => el.dispatchEvent(new window.MouseEvent('click', { bubbles: true, cancelable: true }));
const change = (el, v) => { el.value = v; el.dispatchEvent(new window.Event('change', { bubbles: true })); };
const type = (el, v) => { el.value = v; el.dispatchEvent(new window.Event('input', { bubbles: true })); };
const tick = ms => new Promise(r => setTimeout(r, ms));
let pass = 0, fail = 0;
const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
: (fail++, console.log(' FAIL ' + l + (x !== undefined ? ' → ' + JSON.stringify(x).slice(0, 240) : '')));
console.log(`\n── store, real DOM, ${storePlugins.length} plugins from the live registry ──`);
T.setupStoreFilterListeners();
T.applyStoreFiltersAndSort();
const N = storePlugins.length;
ok('page 1 holds 12 cards', cards() === 12, cards());
ok('results info counts all ' + N, info() === `Showing 1–12 of ${N} plugins`, info());
ok('bottom info matches top', infoBot() === info(), { top: info(), bottom: infoBot() });
ok('pagination rendered', pageBtns().length > 0);
ok('per-page control shows 12', $('store-per-page').value === '12');
const totalPages = Math.ceil(N / 12);
ok(`last page button is ${totalPages}`,
pageBtns().some(b => b.textContent.trim() === String(totalPages)), pagTop().slice(0, 200));
ok('prev is disabled on page 1', pageBtns()[0].hasAttribute('disabled'));
const p1 = ids();
clickPage(2);
ok('page 2 renders 12 different cards', cards() === 12 && ids().join() !== p1.join());
ok('page 2 info', info() === `Showing 13–24 of ${N} plugins`, info());
clickPage(totalPages);
ok('last page info ends at ' + N, new RegExp(`–${N} of ${N} plugins$`).test(info()), info());
ok('next is disabled on the last page',
pageBtns()[pageBtns().length - 1].hasAttribute('disabled'));
ok('ellipsis appears for a far page', /&hellip;|…/.test(pagTop()), pagTop().slice(0, 120));
clickPage(1);
ok('back on page 1 shows the original slice', ids().join() === p1.join());
// ── per-page ───────────────────────────────────────────────────────────
change($('store-per-page'), '48');
ok('per-page 48 shows all in one page', cards() === Math.min(48, N), cards());
ok('pagination hidden when one page', pagTop().trim() === '' || pageBtns().length === 0, pagTop().slice(0, 80));
ok('per-page persisted', window.localStorage.getItem('storePerPage') === '48');
change($('store-per-page'), '12');
ok('back to 12 restores pagination', cards() === 12 && pageBtns().length > 0);
// ── sorts ──────────────────────────────────────────────────────────────
const firstOf = () => ids()[0];
change($('store-sort'), 'a-z');
const azFirst = firstOf();
change($('store-sort'), 'z-a');
ok('z-a changes the leading card', firstOf() !== azFirst, { az: azFirst, za: firstOf() });
ok('sort persisted to localStorage', window.localStorage.getItem('storeSort') === 'z-a');
for (const s of ['category', 'author', 'newest']) {
change($('store-sort'), s);
ok(`sort "${s}" still fills a page`, cards() === 12, cards());
}
change($('store-sort'), 'a-z');
// ── category ───────────────────────────────────────────────────────────
const cat = [...$('plugin-category').options].map(o => o.value).find(v => v && storePlugins.some(p => (p.category || '').toLowerCase() === v.toLowerCase()));
if (cat) {
change($('plugin-category'), cat);
const expect = storePlugins.filter(p => (p.category || '').toLowerCase() === cat.toLowerCase()).length;
ok(`category "${cat}" filters to ${expect}`, cards() === Math.min(expect, 12), { cards: cards(), expect });
ok('badge reports active filters', /filter/.test($('store-active-filters').textContent),
$('store-active-filters').textContent);
change($('plugin-category'), '');
ok('category cleared restores', cards() === 12);
} else { console.log(' -- no usable category option in the template, skipped'); }
// ── tri-state installed button ─────────────────────────────────────────
const instBtn = $('store-filter-installed');
ok('starts as All', /All/.test(instBtn.innerHTML) && instBtn.classList.contains('bg-white'), instBtn.innerHTML);
click(instBtn);
ok('→ Installed, green', /Installed/.test(instBtn.innerHTML) && instBtn.classList.contains('bg-green-50')
&& !instBtn.classList.contains('bg-white'), instBtn.innerHTML);
const instCount = storePlugins.filter(p => installed.some(i => i.id === p.id ||
(p.plugin_path && i.id === p.plugin_path.split('/').pop()))).length;
ok('Installed count matches the installed set', cards() === Math.min(instCount, 12), { cards: cards(), expect: instCount });
click(instBtn);
ok('→ Not Installed, red', /Not Installed/.test(instBtn.innerHTML) && instBtn.classList.contains('bg-red-50'), instBtn.innerHTML);
ok('Not Installed count is the complement', cards() === Math.min(N - instCount, 12), { cards: cards(), expect: N - instCount });
click(instBtn);
ok('→ back to All', /All/.test(instBtn.innerHTML) && instBtn.classList.contains('bg-white'));
// ── search ─────────────────────────────────────────────────────────────
const term = (storePlugins[0].name || storePlugins[0].id).slice(0, 4);
type($('plugin-search'), term);
await tick(400);
ok(`search "${term}" narrows the set`, cards() > 0 && cards() <= 12, cards());
type($('plugin-search'), 'zzz-definitely-nothing');
await tick(400);
ok('no matches → 0 cards', cards() === 0);
ok('no-match info text', info() === 'No plugins match your filters', info());
type($('plugin-search'), '');
await tick(400);
ok('search cleared restores', cards() === 12);
// ── clear filters ──────────────────────────────────────────────────────
change($('store-per-page'), '24');
change($('store-sort'), 'z-a');
if (cat) change($('plugin-category'), cat);
click(instBtn);
type($('plugin-search'), term);
await tick(400);
ok('clear button visible with filters active', !$('store-clear-filters').classList.contains('hidden'));
click($('store-clear-filters'));
ok('clear resets search', $('plugin-search').value === '');
ok('clear resets sort to a-z', $('store-sort').value === 'a-z');
ok('clear resets category', $('plugin-category').value === '');
ok('clear resets installed button to All', /All/.test(instBtn.innerHTML));
ok('clear PRESERVES per-page 24', $('store-per-page').value === '24', $('store-per-page').value);
ok('clear shows a full 24-card page', cards() === 24, cards());
ok('clear hides itself', $('store-clear-filters').classList.contains('hidden'));
// ── persistence across a reload ────────────────────────────────────────
change($('store-sort'), 'author');
change($('store-per-page'), '48');
const saved = { sort: window.localStorage.getItem('storeSort'), per: window.localStorage.getItem('storePerPage') };
ok('prefs written under the original keys', saved.sort === 'author' && saved.per === '48', saved);
// Re-swap the partial (fresh controls at defaults) and re-boot the module, as
// a page reload would.
document.getElementById('app').innerHTML = partial;
ok('fresh markup starts at defaults',
$('store-sort').value === 'a-z' && $('store-per-page').value === '12');
const controllerBefore = window.__t;
boot(); // fresh module instances, fresh localStorage read
ok('re-boot produced a new module instance', window.__t !== controllerBefore);
window.__t.setupStoreFilterListeners();
window.__t.applyStoreFiltersAndSort();
ok('reload restores sort=author into the control', $('store-sort').value === 'author', $('store-sort').value);
ok('reload restores per-page=48 into the control', $('store-per-page').value === '48', $('store-per-page').value);
ok('reload renders with the restored page size', cards() === Math.min(48, N), cards());
ok('no uncaught JS errors', jsErrors.length === 0, jsErrors.slice(0, 3));
console.log(`\n${pass} passed, ${fail} failed\n`);
process.exit(fail ? 1 : 0);
})().catch(e => { console.log('HARNESS ERROR: ' + e.stack); process.exit(1); });
+116
View File
@@ -0,0 +1,116 @@
// Real-DOM (jsdom) test of the two new Tools sections. The HTML is the actual
// server-rendered /partials/tools, and the payloads are the real API's, so a
// renamed field or a changed shape fails this rather than passing quietly.
const http = require('http');
const { JSDOM, VirtualConsole } = require('jsdom');
const BASE = process.env.BASE || 'http://localhost:5000';
const get = p => new Promise((res, rej) =>
http.get(BASE + p, r => { let d = ''; r.on('data', c => d += c); r.on('end', () => res(d)); }).on('error', rej));
(async () => {
const partial = await get('/partials/tools');
const bridge = JSON.parse(await get('/api/v3/integrations/mqtt-bridge'));
const apps = JSON.parse(await get('/api/v3/starlark/editor/apps'));
const errs = [];
const vc = new VirtualConsole();
vc.on('jsdomError', e => errs.push(String(e.message || e).split('\n')[0]));
// Controllable fetch: serve the real payloads, and let tests swap in others.
let editorStatus = { status: 'success', data: { running: false } };
let bridgePayload = bridge;
let onPut = null;
const stubFetch = (url, opts) => {
const u = String(url);
if (onPut && opts && opts.method === 'PUT') onPut(JSON.parse(opts.body));
let body = { status: 'success', data: {} };
if (u.includes('/integrations/mqtt-bridge')) body = bridgePayload;
else if (u.includes('/starlark/editor/status')) body = editorStatus;
else if (u.includes('/starlark/editor/apps')) body = apps;
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(body) });
};
// runScripts:'dangerously' so the partial's own <script> executes the way a
// browser runs it -- function declarations land on window. Evaluating the
// source by hand instead leaves helpers like escHtml off the global object
// and the page fails in ways it never would in a browser.
const dom = new JSDOM(`<!doctype html><html><body>${partial}</body></html>`,
{ runScripts: 'dangerously', virtualConsole: vc, url: BASE + '/',
beforeParse(w) { w.fetch = stubFetch; w.confirm = () => true; } });
const { window } = dom;
const tick = ms => new Promise(r => setTimeout(r, ms));
await tick(300);
const $ = id => window.document.getElementById(id);
let pass = 0, fail = 0;
const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
: (fail++, console.log(' FAIL ' + l + (x !== undefined ? ' → ' + JSON.stringify(x).slice(0, 200) : '')));
console.log('\n── Tools: MQTT bridge + Pixlet editor (real DOM) ──');
// ── MQTT bridge ────────────────────────────────────────────────────────
ok('bridge form rendered', !!$('mqtt-host'), $('mqtt-bridge-body').textContent.slice(0, 80));
ok('host prefilled from the API', $('mqtt-host').value === bridge.data.config.mqtt_host,
{ got: $('mqtt-host') && $('mqtt-host').value, want: bridge.data.config.mqtt_host });
ok('port prefilled', $('mqtt-port').value === String(bridge.data.config.mqtt_port));
ok('log level selected', $('mqtt-log-level').value === bridge.data.config.log_level);
ok('TLS checkbox matches', $('mqtt-tls').checked === !!bridge.data.config.mqtt_tls);
ok('password field is EMPTY', $('mqtt-password').value === '');
ok('password field is type=password', $('mqtt-password').type === 'password');
ok('no password value anywhere in the DOM',
!/s3cret|mqtt_password"\s*:\s*"/.test(window.document.body.innerHTML));
ok('state badge rendered', ($('mqtt-bridge-state').textContent || '').trim().length > 0,
$('mqtt-bridge-state').textContent);
ok('not-installed shows an Install button', !!$('btn-mqtt-install'));
ok('config path shown', $('mqtt-bridge-body').textContent.includes('bridge_config.json'));
ok('env override hint shown', $('mqtt-bridge-body').textContent.includes('LEDMATRIX_MQTT_'));
// The save body must omit the password when the field is blank.
let sent = null;
onPut = body => { sent = body; };
window.saveMqttBridge();
await tick(150);
ok('save omits password when left blank', sent && !('mqtt_password' in sent), sent && Object.keys(sent));
ok('save sends the edited fields', sent && sent.mqtt_host === bridge.data.config.mqtt_host, sent);
$('mqtt-password').value = 'typed-secret';
window.saveMqttBridge();
await tick(150);
ok('save includes password once typed', sent && sent.mqtt_password === 'typed-secret');
onPut = null;
// ── Pixlet editor, idle ────────────────────────────────────────────────
const appIds = (apps.data.apps || []).map(a => a.id);
ok('editor lists the apps on disk',
appIds.every(id => $('pixlet-editor-body').textContent.includes(id)), appIds);
ok('no session banner while idle', !$('pixlet-countdown'));
ok('warns that the display stops',
window.document.body.textContent.includes('display stops while a session is open'));
// ── Pixlet editor, running ─────────────────────────────────────────────
editorStatus = { status: 'success', data: {
running: true, app_id: 'test-editor-app', port: 8099, seconds_remaining: 1634,
timeout: 1800, host_bound: '0.0.0.0' } };
window.loadPixletEditor();
await tick(200);
ok('running session shows the banner', !!$('pixlet-countdown'));
ok('countdown formatted mm:ss', $('pixlet-countdown').textContent === '27:14',
$('pixlet-countdown') && $('pixlet-countdown').textContent);
ok('Stop button offered', !!$('btn-pixlet-stop'));
const link = [...window.document.querySelectorAll('#pixlet-editor-body a')].find(a => /Open the editor/.test(a.textContent));
ok('editor link present', !!link);
ok('link uses this host, not localhost — no tunnel needed',
!!link && link.href.includes(window.location.hostname) && link.href.includes(':8099'),
link && link.href);
ok('Edit buttons disabled while a session runs',
[...window.document.querySelectorAll('[id^="btn-pixlet-edit-"]')].every(b => b.disabled));
ok('banner names the app being edited',
$('pixlet-editor-body').textContent.includes('test-editor-app'));
ok('no uncaught JS errors', errs.length === 0, errs.slice(0, 3));
console.log(`\n${pass} passed, ${fail} failed\n`);
process.exit(fail ? 1 : 0);
})().catch(e => { console.log('HARNESS ERROR: ' + e.stack.split('\n').slice(0, 5).join('\n')); process.exit(1); });
+12
View File
@@ -0,0 +1,12 @@
{
"name": "ledmatrix-webui-js-tests",
"version": "1.0.0",
"private": true,
"description": "JS tests for the v3 web interface (ListFilter and the plugin-manager grids)",
"scripts": {
"test": "node run_all.js"
},
"devDependencies": {
"jsdom": "^24.1.3"
}
}
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env node
// Runs the web-interface JS suites.
//
// node run_all.js unit suites, plus DOM suites if a
// web interface is reachable
// BASE=http://10.0.10.169:5000 node run_all.js point the DOM suites at a rig
//
// Unit suites need nothing but node. The DOM suites need `npm install` (jsdom)
// and a running web interface, because they deliberately test against the real
// server-rendered HTML and the real API rather than fixtures.
const { spawnSync } = require('child_process');
const http = require('http');
const path = require('path');
const fs = require('fs');
const BASE = process.env.BASE || 'http://localhost:5000';
const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js'];
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
'dom/test_tools_sections.js'];
function reachable(url) {
return new Promise(res => {
const req = http.get(url, r => { r.resume(); res(r.statusCode < 500); });
req.on('error', () => res(false));
req.setTimeout(4000, () => { req.destroy(); res(false); });
});
}
function run(file) {
const r = spawnSync(process.execPath, [path.join(__dirname, file)],
{ stdio: 'inherit', cwd: __dirname, env: process.env });
return r.status === 0;
}
(async () => {
const results = [];
for (const f of UNIT) results.push([f, run(f)]);
const haveJsdom = fs.existsSync(path.join(__dirname, 'node_modules', 'jsdom'));
const up = await reachable(BASE + '/');
if (!haveJsdom) {
console.log(`\nSKIPPING DOM suites: jsdom not installed (run: npm install)\n`);
} else if (!up) {
console.log(`\nSKIPPING DOM suites: no web interface reachable at ${BASE}`);
console.log(` start one with: EMULATOR=true python3 web_interface/app.py`);
console.log(` or point at a rig: BASE=http://<host>:5000 node run_all.js\n`);
} else {
console.log(`\nDOM suites against ${BASE}\n`);
for (const f of DOM) results.push([f, run(f)]);
}
const failed = results.filter(([, ok]) => !ok);
console.log('\n─── summary ───');
results.forEach(([f, ok]) => console.log(` ${ok ? 'PASS' : 'FAIL'} ${f}`));
if (failed.length) { console.log(`\n${failed.length} suite(s) failed\n`); process.exit(1); }
console.log('\nall suites passed\n');
})();
+288
View File
@@ -0,0 +1,288 @@
// Functional test for ListFilter + the installed-plugins config extracted
// verbatim from plugins_manager.js. Runs under node with a minimal DOM shim.
const fs = require('fs');
const path = require('path');
const V3 = path.resolve(__dirname, '../../../web_interface/static/v3');
// ── minimal DOM shim ───────────────────────────────────────────────────────
class El {
constructor(id, attrs = {}) {
this.id = id; this._attrs = { ...attrs }; this.value = '';
this.textContent = ''; this.innerHTML = ''; this.title = '';
this._classes = new Set(); this.children = []; this._listeners = {};
this.parentEl = null;
const self = this;
this.classList = {
add: (...c) => c.forEach(x => self._classes.add(x)),
remove: (...c) => c.forEach(x => self._classes.delete(x)),
contains: c => self._classes.has(c),
toggle: (c, force) => {
const on = force === undefined ? !self._classes.has(c) : !!force;
if (on) self._classes.add(c); else self._classes.delete(c);
return on;
},
};
}
setAttribute(k, v) { this._attrs[k] = String(v); }
getAttribute(k) { return k in this._attrs ? this._attrs[k] : null; }
addEventListener(type, fn) { (this._listeners[type] ||= []).push(fn); }
fire(type, target) {
// Real DOM listeners get `this` === the element the listener is bound to;
// the sort/select handlers rely on that, so mirror it.
(this._listeners[type] || []).forEach(fn =>
fn.call(this, { target: target || this, currentTarget: this, key: undefined,
preventDefault() {}, stopPropagation() {} }));
}
append(child) { child.parentEl = this; this.children.push(child); return child; }
querySelectorAll(sel) {
const m = /^\[([^\]=]+)\]$/.exec(sel);
if (m) return this.children.filter(c => c.getAttribute(m[1]) !== null);
return [];
}
contains(node) { return node === this || this.children.includes(node); }
closest(sel) {
const m = /^\[([^\]=]+)\]$/.exec(sel);
let n = this;
while (n) { if (m && n.getAttribute(m[1]) !== null) return n; n = n.parentEl; }
return null;
}
}
const registry = new Map();
function mk(id, attrs) { const e = new El(id, attrs); if (id) registry.set(id, e); return e; }
global.document = {
getElementById: id => registry.get(id) || null,
querySelector: sel => {
let m = /^#([\w-]+)$/.exec(sel);
if (m) return registry.get(m[1]) || null;
m = /^#([\w-]+)\s+\[([\w-]+)="([^"]+)"\]$/.exec(sel);
if (m) {
const parent = registry.get(m[1]);
if (!parent) return null;
return parent.children.find(c => c.getAttribute(m[2]) === m[3]) || null;
}
return null;
},
dispatchEvent() {}, addEventListener() {},
};
global.CustomEvent = class { constructor(t, o) { this.type = t; Object.assign(this, o); } };
global.window = global;
// ── build the toolbar exactly as plugins.html declares it ─────────────────
mk('installed-search');
mk('installed-search-clear');
mk('installed-sort');
mk('installed-clear-filters');
mk('installed-count');
mk('installed-updates-count');
mk('installed-plugins-grid');
const pills = mk('installed-filter-pills');
['all', 'enabled', 'disabled', 'updates'].forEach(v => {
const b = new El(null, { 'data-installed-filter': v });
pills.append(b);
});
document.getElementById('installed-sort').value = 'a-z';
// ── load the helper ───────────────────────────────────────────────────────
const ListFilter = require(path.join(V3, 'js/plugins/list_filter.js'));
global.ListFilter = ListFilter;
// ── extract the installed-plugins config verbatim from plugins_manager.js ──
const src = fs.readFileSync(path.join(V3, 'plugins_manager.js'), 'utf8');
const start = src.indexOf('function installedSortName(plugin)');
const endMark = 'function setupInstalledFilterListeners()';
const end = src.indexOf(endMark);
if (start < 0 || end < 0) { console.error('FAIL: could not locate installed filter block'); process.exit(1); }
const block = src.slice(start, end);
// deps the block expects from its enclosing IIFE
let rendered = null;
global.installedPlugins = [];
global.pluginLog = () => {};
function renderInstalledCards(list, total) { rendered = { list, total }; }
eval(block); // defines installedSortName/comparators/getInstalledFilter/etc.
// ── fixture ───────────────────────────────────────────────────────────────
const P = (id, o) => Object.assign(
{ id, name: id, enabled: true, update_available: false, category: 'general',
description: '', author: 'someone', tags: [], version: '1.0.0', last_updated: null }, o);
const FIXTURE = [
P('zulu-clock', { name: 'Zulu Clock', enabled: true, category: 'time', last_updated: '2026-01-05' }),
P('alpha-weather', { name: 'Alpha Weather', enabled: false, category: 'weather', last_updated: '2026-03-11', update_available: true, latest_version: '2.0.0' }),
P('mid-stocks', { name: 'Mid Stocks', enabled: true, category: 'financial', last_updated: null, update_available: true, latest_version: '3.1.0' }),
P('no-date', { name: 'No Date Here', enabled: false, category: 'general', last_updated: null }),
P('taggy', { name: 'Taggy', enabled: true, category: 'media', last_updated: '2026-06-30', tags: ['hockey', 'nhl'] }),
P('bad-date', { name: 'Bad Date', enabled: true, category: 'general', last_updated: 'not-a-date' }),
];
global.installedPlugins = FIXTURE;
window.installedPlugins = FIXTURE;
const ctl = getInstalledFilter();
if (!ctl) { console.error('FAIL: controller not created'); process.exit(1); }
ctl.bind();
// ── assertions ────────────────────────────────────────────────────────────
let pass = 0, fail = 0;
function ok(label, cond, extra) {
if (cond) { pass++; console.log(' ok ' + label); }
else { fail++; console.log(' FAIL ' + label + (extra !== undefined ? ' → ' + JSON.stringify(extra) : '')); }
}
const ids = () => rendered.list.map(p => p.id);
const countText = () => document.getElementById('installed-count').textContent;
const badge = () => document.getElementById('installed-updates-count');
const pillOf = v => pills.children.find(c => c.getAttribute('data-installed-filter') === v);
const setPill = v => pills.fire('click', pillOf(v));
console.log('\n1. default state (no filters)');
ctl.apply();
ok('renders all 6', rendered.list.length === 6, ids());
ok('total is 6', rendered.total === 6);
ok('sorted A→Z', ids().join(',') === 'alpha-weather,bad-date,mid-stocks,no-date,taggy,zulu-clock', ids());
ok('count reads "6 installed"', countText() === '6 installed', countText());
ok('updates badge shows 2', badge().textContent === '2' && !badge().classList.contains('hidden'), badge().textContent);
ok('clear button hidden', document.getElementById('installed-clear-filters').classList.contains('hidden'));
console.log('\n2. pill: enabled / disabled partition');
setPill('enabled');
const enabledIds = ids();
ok('enabled → 4', enabledIds.length === 4, enabledIds);
ok('count reads "4 of 6 shown"', countText() === '4 of 6 shown', countText());
ok('clear button visible', !document.getElementById('installed-clear-filters').classList.contains('hidden'));
ok('enabled pill lit', pillOf('enabled').getAttribute('data-active') === 'true');
ok('all pill unlit', pillOf('all').getAttribute('data-active') === 'false');
ok('aria-pressed set', pillOf('enabled').getAttribute('aria-pressed') === 'true');
setPill('disabled');
const disabledIds = ids();
ok('disabled → 2', disabledIds.length === 2, disabledIds);
ok('partition is exact, no overlap/loss',
enabledIds.length + disabledIds.length === 6 &&
!enabledIds.some(i => disabledIds.includes(i)));
console.log('\n3. pill: updates');
setPill('updates');
ok('updates → only update_available', ids().join(',') === 'alpha-weather,mid-stocks', ids());
ok('badge count unaffected by filtering', badge().textContent === '2');
console.log('\n4. search');
setPill('all');
const searchEl = document.getElementById('installed-search');
function search(text) { searchEl.value = text; ctl.setSearch(text); }
search('weather');
ok('name match', ids().join(',') === 'alpha-weather', ids());
search('WEATHER');
ok('case-insensitive', ids().join(',') === 'alpha-weather', ids());
search('nhl');
ok('matches tags[]', ids().join(',') === 'taggy', ids());
search('financial');
ok('matches category', ids().join(',') === 'mid-stocks', ids());
search('someone');
ok('matches author → all 6', ids().length === 6);
search(' zulu ');
ok('trims whitespace', ids().join(',') === 'zulu-clock', ids());
search('zzzznope');
ok('no match → empty list, total preserved', rendered.list.length === 0 && rendered.total === 6);
ok('count reads "0 of 6 shown"', countText() === '0 of 6 shown', countText());
// The haystack joins fields in the CONFIGURED order (name, id, description,
// author, category, tags), so a query may straddle a field boundary. The
// fixtures deliberately insert `id` before `name`, so reading the object's own
// entry order instead would break this.
search('zulu clock zulu-clock');
ok('phrase spanning name→id matches (field order preserved)',
ids().join(',') === 'zulu-clock', ids());
search('zulu-clock zulu clock');
ok('the reverse (object key order) does NOT match', rendered.list.length === 0, ids());
search('taggy media');
ok('phrase spanning category→tags respects field order', ids().length === 0, ids());
search('');
ok('cleared → all 6 back', ids().length === 6);
ok('count back to "6 installed"', countText() === '6 installed', countText());
console.log('\n5. search + pill combine (AND)');
setPill('enabled');
search('a');
const both = rendered.list;
ok('all results enabled', both.every(p => p.enabled), ids());
ok('all results match "a"', both.every(p => (p.name + p.id + p.category + p.author).toLowerCase().includes('a')));
search('');
setPill('all');
console.log('\n6. sorts');
const sortEl = document.getElementById('installed-sort');
function sort(v) { sortEl.value = v; sortEl.fire('change'); }
sort('z-a');
ok('z-a reverses', ids().join(',') === 'zulu-clock,taggy,no-date,mid-stocks,bad-date,alpha-weather', ids());
sort('status');
const st = ids();
ok('status: updates first', st.slice(0, 2).sort().join(',') === 'alpha-weather,mid-stocks', st);
ok('status: disabled last', st[st.length - 1] === 'no-date', st);
sort('recent');
const rec = ids();
ok('recent: newest first', rec[0] === 'taggy' && rec[1] === 'alpha-weather' && rec[2] === 'zulu-clock', rec);
ok('recent: missing/unparseable dates last',
['bad-date', 'mid-stocks', 'no-date'].every(i => rec.indexOf(i) >= 3), rec);
ok('recent: nothing dropped', rec.length === 6);
sort('category');
const cat = ids();
ok('category groups', cat.join(',') === 'mid-stocks,bad-date,no-date,taggy,zulu-clock,alpha-weather', cat);
sort('a-z');
console.log('\n7. clear filters');
search('taggy'); setPill('updates'); sort('z-a');
ok('3 axes active', ctl.activeCount() === 3, ctl.activeCount());
document.getElementById('installed-clear-filters').fire('click');
ok('reset → all 6', ids().length === 6, ids());
ok('reset clears search input', searchEl.value === '');
ok('reset restores sort to a-z', ctl.state.sort === 'a-z' && sortEl.value === 'a-z');
ok('reset relights All pill', pillOf('all').getAttribute('data-active') === 'true');
ok('activeCount back to 0', ctl.activeCount() === 0);
console.log('\n8. sticky: the just-toggled card must not vanish');
setPill('enabled');
ok('zulu-clock visible while enabled', ids().includes('zulu-clock'));
ctl.sticky.add('zulu-clock');
FIXTURE[0].enabled = false; // simulate the toggle landing
ctl.apply();
ok('still visible after being disabled', ids().includes('zulu-clock'), ids());
ok('but total unchanged', rendered.total === 6);
setPill('enabled'); // user touches toolbar → sticky clears
ok('sticky cleared on toolbar use', !ids().includes('zulu-clock'), ids());
FIXTURE[0].enabled = true;
setPill('all');
console.log('\n9. state-leak guard: window.installedPlugins must stay whole');
search('taggy');
ok('grid shows 1', rendered.list.length === 1);
ok('window.installedPlugins still 6', window.installedPlugins.length === 6, window.installedPlugins.length);
search('');
console.log('\n10. updates badge when nothing needs updating');
const noUpd = FIXTURE.map(p => ({ ...p, update_available: false }));
window.installedPlugins = noUpd; global.installedPlugins = noUpd;
ctl.apply();
ok('badge hidden at 0', badge().classList.contains('hidden'));
ok('updates pill dimmed', pillOf('updates').classList.contains('opacity-50'));
ok('pill title explains', /No plugins have updates/.test(pillOf('updates').title), pillOf('updates').title);
window.installedPlugins = FIXTURE; global.installedPlugins = FIXTURE;
ctl.apply();
ok('badge back at 2', badge().textContent === '2' && !badge().classList.contains('hidden'));
ok('pill undimmed', !pillOf('updates').classList.contains('opacity-50'));
console.log('\n11. empty installed list');
window.installedPlugins = []; global.installedPlugins = [];
ctl.apply();
ok('renders nothing, total 0', rendered.list.length === 0 && rendered.total === 0);
ok('count reads "0 installed"', countText() === '0 installed', countText());
console.log('\n12. defensive: missing fields');
const ragged = [{ id: 'bare' }, { id: 'nulls', name: null, tags: null, category: null, last_updated: null }];
window.installedPlugins = ragged; global.installedPlugins = ragged;
sort('recent'); ctl.apply();
ok('no throw on ragged records, both rendered', rendered.list.length === 2, ids());
search('bare');
ok('search works on ragged records', ids().join(',') === 'bare', ids());
console.log(`\n${pass} passed, ${fail} failed\n`);
process.exit(fail ? 1 : 0);
+109
View File
@@ -0,0 +1,109 @@
// Verifies renderInstalledCards (extracted verbatim from plugins_manager.js):
// the two empty states, the card markup, and that it never publishes state.
const fs = require('fs');
const path = require('path');
const V3 = path.resolve(__dirname, '../../../web_interface/static/v3');
const src = fs.readFileSync(V3 + '/plugins_manager.js', 'utf8');
function slice(fromMark, toMark) {
const a = src.indexOf(fromMark);
const b = src.indexOf(toMark, a + 1);
if (a < 0 || b < 0) { console.error('FAIL: cannot locate ' + fromMark); process.exit(1); }
return src.slice(a, b);
}
const container = {
innerHTML: '',
querySelectorAll: () => [], // no skeletons in this harness
};
// escapeHtml() escapes via a detached element, so mirror what a browser does
// when you read innerHTML back off textContent: & < > are escaped, quotes are not.
class FakeEl {
set textContent(v) { this._t = String(v == null ? '' : v); }
get textContent() { return this._t || ''; }
get innerHTML() {
return (this._t || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
}
global.document = {
getElementById: id => (id === 'installed-plugins-grid' ? container : null),
createElement: () => new FakeEl(),
};
global.window = global;
global.pluginLog = () => {};
global.PLUGIN_DEBUG = false;
global.debugLog = () => {};
function setupInstalledEventDelegation() {} // stubbed; tested separately
eval(slice('function escapeHtml(text)', '\nfunction ', ));
eval(slice('function renderInstalledCards(plugins, total)',
'// Set up event delegation for plugin action buttons'));
let pass = 0, fail = 0;
const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
: (fail++, console.log(' FAIL ' + l + (x !== undefined ? ' → ' + JSON.stringify(x).slice(0, 300) : '')));
const SENTINEL = ['do', 'not', 'touch'];
window.installedPlugins = SENTINEL;
console.log('\n1. no plugins installed at all');
renderInstalledCards([], 0);
ok('shows "No plugins installed"', /No plugins installed/.test(container.innerHTML));
ok('does NOT show the filter empty state', !/No plugins match/.test(container.innerHTML));
ok('uses the plug icon', /fa-plug/.test(container.innerHTML));
console.log('\n2. plugins installed but none match the filters');
renderInstalledCards([], 12);
ok('shows "No plugins match your filters"', /No plugins match your filters/.test(container.innerHTML));
ok('does NOT claim nothing is installed', !/No plugins installed/.test(container.innerHTML));
ok('reports the installed total', /12 plugins installed/.test(container.innerHTML), container.innerHTML.match(/\d+ plugins? installed/));
ok('offers a Clear filters button', /data-action="clear-installed-filters"/.test(container.innerHTML));
ok('uses the filter icon', /fa-filter/.test(container.innerHTML));
console.log('\n3. singular/plural of the installed total');
renderInstalledCards([], 1);
ok('"1 plugin installed" (singular)', /1 plugin installed/.test(container.innerHTML) && !/1 plugins/.test(container.innerHTML));
console.log('\n4. real cards');
const plugins = [
{ id: 'alpha', name: 'Alpha', author: 'Ann', version: '1.0.0', category: 'weather',
description: 'Shows weather', enabled: true, tags: ['a', 'b'], verified: true },
{ id: 'beta', name: 'Beta', author: 'Bob', version: '1.0.0', latest_version: '2.0.0',
update_available: true, category: 'time', description: 'Clock', enabled: false },
];
renderInstalledCards(plugins, 5);
const html = container.innerHTML;
ok('renders 2 cards', (html.match(/class="plugin-card"/g) || []).length === 2);
ok('enabled card shows Enabled', /<span>Enabled<\/span>/.test(html));
ok('disabled card shows Disabled', /<span>Disabled<\/span>/.test(html));
ok('update badge shows target version', /v2\.0\.0 available/.test(html));
ok('update button labelled "Update to v2.0.0"', /Update to v2\.0\.0/.test(html));
ok('pulsing ring class on the outdated one', /plugin-update-available/.test(html));
ok('verified badge present', /Verified/.test(html));
ok('tags rendered', /badge-info">a</.test(html) && /badge-info">b</.test(html));
ok('per-plugin action hooks present',
/data-action="configure"/.test(html) && /data-action="update"/.test(html) &&
/data-action="uninstall"/.test(html) && /data-action="toggle"/.test(html));
ok('no empty state alongside cards', !/empty-state/.test(html));
console.log('\n5. it must not publish state');
ok('window.installedPlugins untouched', window.installedPlugins === SENTINEL, window.installedPlugins);
console.log('\n6. total defaults to list length when omitted');
renderInstalledCards([], undefined);
ok('omitted total + empty list → "no plugins installed"', /No plugins installed/.test(container.innerHTML));
console.log('\n7. XSS: hostile plugin metadata is escaped');
renderInstalledCards([{
id: 'evil', name: '<img src=x onerror=alert(1)>', author: '"><script>bad()</script>',
version: '1.0.0', category: 'x', description: '<b>nope</b>', enabled: true, tags: ['<i>t</i>'],
}], 1);
const evil = container.innerHTML;
ok('no raw <img', !/<img src=x/.test(evil));
ok('no raw <script', !/<script>bad/.test(evil));
ok('no raw <b> from description', !/<b>nope<\/b>/.test(evil));
ok('no raw <i> from tags', !/<i>t<\/i>/.test(evil));
ok('escaped entities present instead', /&lt;/.test(evil));
console.log(`\n${pass} passed, ${fail} failed\n`);
process.exit(fail ? 1 : 0);
+157
View File
@@ -0,0 +1,157 @@
"""GET /api/v3/display/modes -- the list of modes an on-demand request can name.
Anything driving the display from outside the web UI needs two things that no
existing endpoint gave it: the set of display modes, and which plugin owns each
one. /plugins/installed carries neither, so callers read every plugin's
manifest.json off disk and reimplemented PluginManager's own fallbacks.
"""
import json
from unittest.mock import MagicMock
import pytest
from test._api_v3_test_helpers import ( # noqa: F401 - fixtures
api_v3_client, api_v3_module,
)
MANIFESTS = {
'clock-simple': {'name': 'Simple Clock', 'display_modes': ['clock-simple']},
'football-scoreboard': {
'name': 'Football Scoreboard',
'display_modes': ['nfl_live', 'nfl_recent', 'nfl_upcoming'],
},
'ledmatrix-weather': {'name': 'Weather', 'display_modes': ['weather']},
}
CONFIG = {
'clock-simple': {'enabled': True},
'football-scoreboard': {'enabled': True},
'ledmatrix-weather': {'enabled': False},
}
@pytest.fixture
def client(api_v3_module, api_v3_client):
pm = api_v3_module.api_v3.plugin_manager
pm.plugin_manifests = MANIFESTS
pm.discover_plugins = MagicMock(return_value=list(MANIFESTS))
pm.get_plugin_display_modes = MagicMock(
side_effect=lambda pid: MANIFESTS[pid]['display_modes'])
api_v3_module.api_v3.config_manager.load_config = MagicMock(return_value=CONFIG)
return api_v3_client
def _modes(response):
return {m['mode']: m for m in response.get_json()['data']['modes']}
class TestTheModeListing:
def test_enabled_plugins_contribute_every_mode(self, client):
modes = _modes(client.get('/api/v3/display/modes'))
assert set(modes) == {'clock-simple', 'nfl_live', 'nfl_recent', 'nfl_upcoming'}
def test_each_mode_names_its_plugin(self, client):
"""on-demand/start's find_plugin_for_mode fallback cannot see generated
modes, so the caller has to send plugin_id -- it must come from here."""
modes = _modes(client.get('/api/v3/display/modes'))
assert modes['nfl_live']['plugin_id'] == 'football-scoreboard'
def test_disabled_plugins_are_left_out_by_default(self, client):
modes = _modes(client.get('/api/v3/display/modes'))
assert 'weather' not in modes
def test_disabled_plugins_can_be_asked_for(self, client):
"""They are still valid on-demand targets: the controller enables them
for the duration of the request."""
modes = _modes(client.get('/api/v3/display/modes?include_disabled=1'))
assert modes['weather']['enabled'] is False
def test_a_single_mode_plugin_is_labelled_with_its_own_name(self, client):
modes = _modes(client.get('/api/v3/display/modes'))
assert modes['clock-simple']['name'] == 'Simple Clock'
def test_a_multi_mode_plugin_labels_each_mode_distinctly(self, client):
"""There is no per-mode name anywhere, and three modes all called
"Football Scoreboard" are not a usable dropdown."""
modes = _modes(client.get('/api/v3/display/modes'))
assert modes['nfl_live']['name'] == 'nfl_live'
assert modes['nfl_live']['plugin_name'] == 'Football Scoreboard'
class TestItWorksForACallerThatNeverOpensTheDashboard:
def test_discovery_is_triggered(self, client, api_v3_module):
"""Discovery is lazy and normally runs because a person loaded the
dashboard; a bridge or script would otherwise get an empty list."""
client.get('/api/v3/display/modes')
api_v3_module.api_v3.plugin_manager.discover_plugins.assert_called_once()
def test_no_plugin_manager_is_a_clean_error(self, api_v3_module, api_v3_client):
api_v3_module.api_v3.plugin_manager = None
response = api_v3_client.get('/api/v3/display/modes')
assert response.status_code == 500
assert response.get_json()['status'] == 'error'
def test_a_plugin_with_no_declared_modes_still_appears(self, client, api_v3_module):
"""Its mode is its own id -- the same fallback the controller uses."""
pm = api_v3_module.api_v3.plugin_manager
pm.plugin_manifests = {'starlark-apps': {'name': 'Starlark Apps', 'display_modes': []}}
pm.get_plugin_display_modes = MagicMock(return_value=[])
api_v3_module.api_v3.config_manager.load_config = MagicMock(
return_value={'starlark-apps': {'enabled': True}})
modes = _modes(client.get('/api/v3/display/modes'))
assert modes['starlark-apps']['plugin_id'] == 'starlark-apps'
class TestOneBadConfigSectionDoesNotBlankTheList:
"""config.json can hold a non-dict under a plugin id.
DisplayController guards the same shape, so it happens in practice. Here it
used to raise AttributeError mid-loop and answer 500 with no modes at all --
and the MQTT bridge builds every one of its entities from this list, so one
hand-edited section would empty the Home Assistant dropdown.
"""
@pytest.fixture
def client_with_bad_section(self, api_v3_module, api_v3_client):
pm = api_v3_module.api_v3.plugin_manager
pm.plugin_manifests = MANIFESTS
pm.discover_plugins = MagicMock(return_value=list(MANIFESTS))
pm.get_plugin_display_modes = MagicMock(
side_effect=lambda pid: MANIFESTS[pid]['display_modes'])
api_v3_module.api_v3.config_manager.load_config = MagicMock(return_value={
'clock-simple': {'enabled': True},
'football-scoreboard': "true", # a string, not an object
'ledmatrix-weather': {'enabled': True},
})
return api_v3_client
def test_the_endpoint_still_answers(self, client_with_bad_section):
assert client_with_bad_section.get('/api/v3/display/modes').status_code == 200
def test_the_healthy_plugins_are_still_listed(self, client_with_bad_section):
modes = _modes(client_with_bad_section.get('/api/v3/display/modes'))
assert 'clock-simple' in modes and 'weather' in modes
def test_the_bad_section_is_treated_as_disabled(self, client_with_bad_section):
modes = _modes(client_with_bad_section.get('/api/v3/display/modes'))
assert 'nfl_live' not in modes
def test_a_failure_is_reported_the_way_every_other_handler_reports_one(
self, api_v3_module, api_v3_client):
"""describe_exception, per test_web_error_detail's contract -- an
opaque "see logs for details" is what that test exists to prevent."""
api_v3_module.api_v3.plugin_manager.discover_plugins = MagicMock(
side_effect=RuntimeError("disk is gone"))
resp = api_v3_client.get('/api/v3/display/modes')
assert resp.status_code == 500
assert 'disk is gone' in resp.get_json()['details']
def test_credentials_in_the_exception_are_redacted(self, api_v3_module, api_v3_client):
"""describe_exception is what makes returning detail safe."""
api_v3_module.api_v3.plugin_manager.discover_plugins = MagicMock(
side_effect=RuntimeError("GET https://x/y?api_key=SEC123 failed"))
body = api_v3_client.get('/api/v3/display/modes').get_json()
assert 'SEC123' not in json.dumps(body)
+8 -3
View File
@@ -106,7 +106,12 @@ class TestMissingBodyGivesTheDeclaredError:
class TestNoBodyReadContradictsItsOwnGuard:
SOURCE = Path(__file__).parent.parent / "web_interface/blueprints/api_v3.py"
PKG = Path(__file__).parent.parent / "web_interface/blueprints/api_v3"
@property
def _source(self) -> str:
"""api_v3 is a package; read every module of it."""
return "\n".join(p.read_text() for p in sorted(self.PKG.glob("*.py")))
def test_no_or_default_read_is_unguarded(self):
"""`get_json() or <default>` is a contradiction without silent=True.
@@ -115,7 +120,7 @@ class TestNoBodyReadContradictsItsOwnGuard:
means the call raises before the default can apply.
"""
offenders = [
line.strip() for line in self.SOURCE.read_text().splitlines()
line.strip() for line in self._source.splitlines()
if "request.get_json()" in line and " or " in line
]
assert offenders == [], (
@@ -124,7 +129,7 @@ class TestNoBodyReadContradictsItsOwnGuard:
def test_no_not_data_guard_is_unreachable(self):
"""A `if not data:` guard needs a read that can actually return None."""
lines = self.SOURCE.read_text().splitlines()
lines = self._source.splitlines()
offenders = []
for i, line in enumerate(lines):
if re.search(r"=\s*request\.get_json\(\)\s*$", line):
+136
View File
@@ -0,0 +1,136 @@
"""The /api/v3 URL map is a contract, and the split must not have moved it.
api_v3.py was one 10,469-line module and is now a package. Every route module
in it decorates the *same* Blueprint object, so this refactor was supposed to
be invisible from outside: same URLs, same endpoint names, same methods.
"Supposed to be" is the problem. A route function silently dropped during a
move -- a module that never gets imported, a decorator left behind -- costs
nothing at import time and fails only when someone hits the URL. So the map is
pinned here.
The snapshot is intentionally the *whole* map rather than a count. A count
passes when one route is deleted and another added, which is exactly the shape
a careless move produces.
If you are adding a route, this test is meant to fail: add the entry to
EXPECTED. If you are moving one between modules, it is meant to pass unchanged
-- endpoint names are `api_v3.<function>` regardless of which module the
function lives in, and that is the property that makes the package safe.
"""
import json
import os
import pytest
from flask import Flask
from web_interface.blueprints.api_v3 import api_v3
SNAPSHOT = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"fixtures", "api_v3_url_map.json")
def _current_map():
app = Flask(__name__)
app.register_blueprint(api_v3, url_prefix="/api/v3")
return sorted(
[r.rule, r.endpoint, sorted(r.methods)]
for r in app.url_map.iter_rules()
if r.endpoint != "static"
)
def test_the_url_map_matches_the_snapshot():
current = _current_map()
with open(SNAPSHOT, encoding="utf-8") as fh:
expected = json.load(fh)
cur = {(r, e) for r, e, _ in current}
exp = {(r, e) for r, e, _ in expected}
lost = sorted(exp - cur)
added = sorted(cur - exp)
assert not lost, (
f"{len(lost)} route(s) disappeared from /api/v3: {lost[:5]}. "
"A route lost in a module move costs nothing at import time and fails "
"only when someone hits the URL.")
assert not added, (
f"{len(added)} new route(s): {added[:5]}. If that is intended, "
f"regenerate {os.path.relpath(SNAPSHOT)}.")
# Methods too: a route that quietly loses POST is still a broken route.
cur_methods = {(r, e): m for r, e, m in current}
for rule, endpoint, methods in expected:
assert cur_methods[(rule, endpoint)] == methods, (
f"{rule} ({endpoint}) methods changed: "
f"{methods} -> {cur_methods[(rule, endpoint)]}")
def test_every_endpoint_is_on_the_one_blueprint():
"""The package must not fragment into several blueprints.
Splitting into per-domain *blueprints* would rename every endpoint from
`api_v3.foo` to `api_v3_plugins.foo` and break any url_for() that names
one. Keeping a single Blueprint object across the modules is what makes
the split a pure code move, so assert it rather than trusting it.
"""
for rule, endpoint, _ in _current_map():
assert endpoint.startswith("api_v3."), (
f"{rule} is registered as {endpoint}, not on the api_v3 blueprint")
def test_the_snapshot_is_not_empty():
"""Guards the failure mode this file exists to prevent.
An empty or truncated snapshot would make every assertion above pass
vacuously -- the same trap as a route map that imports no modules.
"""
with open(SNAPSHOT, encoding="utf-8") as fh:
expected = json.load(fh)
assert len(expected) > 100, (
f"snapshot has only {len(expected)} routes; it should have the whole "
"/api/v3 surface")
@pytest.mark.parametrize("module", [
"backup", "config", "display", "fonts", "misc",
"plugins", "starlark", "system", "wifi",
])
def test_every_route_module_contributes(module):
"""Each module must actually register something.
A module that fails to import, or that is left out of __init__, takes its
routes with it silently -- the package still imports and the app still
starts.
"""
import importlib
mod = importlib.import_module(f"web_interface.blueprints.api_v3.{module}")
routes = [n for n in dir(mod)
if callable(getattr(mod, n, None))
and getattr(getattr(mod, n), "__module__", "") == mod.__name__]
assert routes, f"{module}.py defines no view functions"
def test_project_root_points_at_the_project():
"""PROJECT_ROOT is derived from __file__, so moving the file breaks it.
The split moved this code from web_interface/blueprints/api_v3.py to
web_interface/blueprints/api_v3/_common.py -- one directory deeper -- and
`Path(__file__).parent.parent.parent` quietly began resolving to
web_interface/ instead of the project root. Nothing failed at import. It
surfaced as routes returning 404 and "installation script not found",
because every path built from it pointed one level too shallow.
A URL-map check cannot catch that: the routes were all registered, they
just could not find anything.
"""
from pathlib import Path
from web_interface.blueprints.api_v3 import PROJECT_ROOT
# The project root is the directory holding run.py and web_interface/.
assert (PROJECT_ROOT / "run.py").is_file(), (
f"PROJECT_ROOT is {PROJECT_ROOT}, which has no run.py; it is not the "
"project root")
assert (PROJECT_ROOT / "web_interface").is_dir()
assert PROJECT_ROOT == Path(__file__).resolve().parents[1]
+158
View File
@@ -0,0 +1,158 @@
"""Non-finite floats round-trip identically with and without orjson.
JSON has no NaN or Infinity. The stdlib emits them anyway as an extension;
orjson refuses to and writes null. Cache files outlive the decision of which
encoder is installed, so both halves of that gap are pinned here:
* writing -- installing orjson must not silently change what gets cached,
so the stdlib path writes null too;
* reading -- records already on disk carrying NaN or Infinity must stay
readable, or installing orjson turns each of them into a "corrupted cache
file" that DiskCache.get logs as an error and deletes.
"""
import importlib
import json
import math
import sys
from pathlib import Path
from unittest import mock
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.cache import disk_cache as disk_cache_module # noqa: E402
@pytest.fixture
def stdlib_cache():
"""The module as it loads on a host with no orjson wheel."""
# A None entry in sys.modules makes `import orjson` raise ImportError,
# which is the branch we want, whether or not orjson is really installed.
with mock.patch.dict(sys.modules, {"orjson": None}):
module = importlib.reload(disk_cache_module)
assert module.orjson is None
yield module
importlib.reload(disk_cache_module)
@pytest.fixture
def orjson_cache():
"""The module as it loads with orjson available."""
module = importlib.reload(disk_cache_module)
if module.orjson is None:
pytest.skip("orjson is not installed on this host")
return module
NON_FINITE = {"nan": float("nan"), "inf": float("inf"), "ninf": float("-inf")}
def _reject_constant(name):
"""Make json.loads as strict as orjson about NaN/Infinity tokens."""
raise AssertionError(f"non-spec JSON constant in output: {name}")
class TestWritePolicy:
"""Non-finite floats become null on whichever encoder is in use."""
def _assert_nulled(self, module):
record = module._loads(module._dumps(dict(NON_FINITE, ok=1.5)))
assert record["nan"] is None
assert record["inf"] is None
assert record["ninf"] is None
# Finite values are untouched.
assert record["ok"] == 1.5
def test_stdlib_writes_null(self, stdlib_cache):
self._assert_nulled(stdlib_cache)
def test_orjson_writes_null(self, orjson_cache):
self._assert_nulled(orjson_cache)
def test_stdlib_emits_spec_compliant_json(self, stdlib_cache):
# The point of the write half: bytes written without orjson must still
# parse once orjson is installed later. json.loads accepts the
# extension tokens, so it cannot show this -- assert on the bytes, and
# on a strict reader when there is one.
raw = stdlib_cache._dumps(dict(NON_FINITE))
assert b"NaN" not in raw
assert b"Infinity" not in raw
assert json.loads(raw, parse_constant=_reject_constant) == {
"nan": None, "inf": None, "ninf": None}
def test_nested_non_finite_are_replaced(self, stdlib_cache):
data = {"a": [1.0, float("nan"), {"b": float("inf")}], "c": (float("-inf"),)}
record = stdlib_cache._loads(stdlib_cache._dumps(data))
assert record["a"] == [1.0, None, {"b": None}]
assert record["c"] == [None]
def test_finite_payloads_are_byte_identical_to_the_old_encoder(self, stdlib_cache):
# allow_nan=False must not change ordinary output.
data = {"x": 1, "y": [1.5, "s", True, None], "z": {"k": 2.25}}
assert stdlib_cache._dumps(data) == json.dumps(
data, cls=stdlib_cache.DateTimeEncoder).encode("utf-8")
class TestReplaceNonFinite:
def test_leaves_ordinary_values_alone(self):
for value in (1, 1.5, "s", True, None, [], {}):
assert disk_cache_module._replace_nonfinite(value) == value
def test_replaces_every_non_finite_float(self):
for value in NON_FINITE.values():
assert disk_cache_module._replace_nonfinite(value) is None
def test_walks_nested_containers(self):
assert disk_cache_module._replace_nonfinite(
{"a": [{"b": float("nan")}]}) == {"a": [{"b": None}]}
class TestLegacyRecordsStayReadable:
"""Files written before orjson arrived still load."""
LEGACY = b'{"timestamp": 1000.0, "value": NaN, "other": Infinity}'
def test_stdlib_reads_legacy_tokens(self, stdlib_cache):
record = stdlib_cache._loads(self.LEGACY)
assert math.isnan(record["value"])
assert math.isinf(record["other"])
def test_orjson_falls_back_for_legacy_tokens(self, orjson_cache):
record = orjson_cache._loads(self.LEGACY)
assert math.isnan(record["value"])
assert math.isinf(record["other"])
def test_genuinely_malformed_files_still_raise(self, stdlib_cache):
with pytest.raises(json.JSONDecodeError):
stdlib_cache._loads(b'{"a": ')
def test_orjson_still_raises_for_malformed_files(self, orjson_cache):
with pytest.raises(json.JSONDecodeError):
orjson_cache._loads(b'{"a": ')
class TestDiskCacheEndToEnd:
def _cache(self, module, tmp_path):
return module.DiskCache(str(tmp_path))
def test_legacy_file_is_not_deleted_as_corrupt(self, orjson_cache, tmp_path):
cache = self._cache(orjson_cache, tmp_path)
path = cache.get_cache_path("legacy")
Path(path).write_bytes(
b'{"timestamp": %d, "value": NaN}' % int(__import__("time").time()))
record = cache.get("legacy", max_age=None)
assert record is not None, "legacy NaN record was treated as corrupt"
assert math.isnan(record["value"])
assert Path(path).exists(), "legacy NaN record was deleted"
def test_round_trip_through_set_and_get(self, stdlib_cache, tmp_path):
cache = self._cache(stdlib_cache, tmp_path)
cache.set("k", {"timestamp": __import__("time").time(),
"value": float("nan")})
record = cache.get("k", max_age=None)
assert record is not None
assert record["value"] is None
+2 -1
View File
@@ -130,7 +130,8 @@ def test_the_endpoint_itself_redacts():
app = flask.Flask(__name__)
try:
with app.test_request_context("/config/main"):
response = mod.get_main_config()
# get_main_config is a route; it lives in the config module now.
response = mod.config.get_main_config()
payload = response.get_json() if hasattr(response, "get_json") else _json.loads(response[0].data)
finally:
mod.api_v3.config_manager = previous
+122
View File
@@ -0,0 +1,122 @@
"""The gate must read the core version from disk, not from its own import.
`from src import __version__` binds whatever the process loaded at start. The
web UI is a long-lived service of its own (ledmatrix-web.service), and updating
the core replaces files on disk without restarting it -- the update route says
so and asks the user to restart, but its prompt named only the *display*
service, so a user who followed it left the web process holding the old number.
The plugin store's gate lives in that web process, so being stale by exactly
one release is the case that bites: every plugin flooring on the release you
just installed is refused, with a message blaming a core version that is
already correct on disk.
Observed on hardware: after updating a rig to 3.3.0 and restarting only the
display service, all eight sports scoreboards were refused with
"supports LEDMatrix >=3.3.0, but this system is running 3.2.0" while
src/__init__.py on that machine read 3.3.0.
"""
import importlib
import sys
import pytest
from src.plugin_system import compatibility
class TestCurrentCoreVersion:
def test_it_reads_the_file_rather_than_the_imported_value(self, monkeypatch, tmp_path):
# Simulate a process whose import predates the update: the module
# object says 3.2.0 while the file on disk says 3.3.0.
import src
monkeypatch.setattr(src, "__version__", "3.2.0")
fake = tmp_path / "__init__.py"
fake.write_text('__version__ = "3.3.0"\n', encoding="utf-8")
monkeypatch.setattr(compatibility, "_VERSION_FILE", fake)
assert compatibility.current_core_version() == "3.3.0"
def test_it_matches_the_real_file_by_default(self):
import src
assert compatibility.current_core_version() == src.__version__
@pytest.mark.parametrize("body", [
"__version__ = '3.4.1'\n",
'__version__="3.4.1"\n',
'"""doc"""\n\n__version__ = "3.4.1" # trailing comment\n',
])
def test_it_tolerates_the_ways_that_line_gets_written(self, monkeypatch, tmp_path, body):
fake = tmp_path / "__init__.py"
fake.write_text(body, encoding="utf-8")
monkeypatch.setattr(compatibility, "_VERSION_FILE", fake)
assert compatibility.current_core_version() == "3.4.1"
def test_a_missing_file_falls_back_to_the_import(self, monkeypatch, tmp_path):
# Never worse than before: an unreadable file returns what the old
# code would have returned.
import src
monkeypatch.setattr(src, "__version__", "3.2.0")
monkeypatch.setattr(compatibility, "_VERSION_FILE", tmp_path / "gone.py")
assert compatibility.current_core_version() == "3.2.0"
def test_a_file_without_the_line_falls_back(self, monkeypatch, tmp_path):
import src
monkeypatch.setattr(src, "__version__", "3.2.0")
fake = tmp_path / "__init__.py"
fake.write_text("# no version here\n", encoding="utf-8")
monkeypatch.setattr(compatibility, "_VERSION_FILE", fake)
assert compatibility.current_core_version() == "3.2.0"
def test_it_never_raises(self, monkeypatch, tmp_path):
# This runs on the install path; an exception here would surface as a
# failed update rather than a version mismatch.
bad = tmp_path / "__init__.py"
bad.write_bytes(b"\xff\xfe\x00 not utf-8 \xff")
monkeypatch.setattr(compatibility, "_VERSION_FILE", bad)
assert isinstance(compatibility.current_core_version(), str)
class TestTheBugItFixes:
def test_a_stale_import_no_longer_refuses_a_compatible_plugin(self, monkeypatch, tmp_path):
"""The exact hardware failure, as a test."""
import src
monkeypatch.setattr(src, "__version__", "3.2.0") # what the process holds
fake = tmp_path / "__init__.py"
fake.write_text('__version__ = "3.3.0"\n', encoding="utf-8") # what is on disk
monkeypatch.setattr(compatibility, "_VERSION_FILE", fake)
manifest = {"name": "Hockey Scoreboard", "min_ledmatrix_version": "3.3.0"}
stale_ok, _ = compatibility.check(manifest, src.__version__)
assert stale_ok is False, "precondition: the stale value is what refused it"
fresh_ok, reason = compatibility.check(
manifest, compatibility.current_core_version())
assert fresh_ok is True, f"the disk version must allow it, got: {reason}"
def test_it_still_refuses_when_the_core_really_is_too_old(self, monkeypatch, tmp_path):
# The gate must not become permissive: a genuinely old core still says no.
fake = tmp_path / "__init__.py"
fake.write_text('__version__ = "3.2.0"\n', encoding="utf-8")
monkeypatch.setattr(compatibility, "_VERSION_FILE", fake)
ok, reason = compatibility.check(
{"name": "Hockey", "min_ledmatrix_version": "3.3.0"},
compatibility.current_core_version())
assert ok is False
assert "3.2.0" in (reason or "")
class TestCallSites:
@pytest.mark.parametrize("module", [
"src.plugin_system.store_manager",
"src.plugin_system.plugin_loader",
])
def test_no_gate_binds_the_version_at_import(self, module):
"""Catch a future call site reintroducing the stale read."""
import inspect
mod = importlib.import_module(module)
source = inspect.getsource(mod)
assert "from src import __version__ as core_version" not in source, (
f"{module} binds __version__ at import; use "
f"compatibility.current_core_version() so a long-lived process "
f"sees a core update.")
+71
View File
@@ -116,6 +116,77 @@ class TestFallbackDivergence:
plugin_dir / "config_schema.json"
class TestRegistryIdVersusDirectoryName:
"""install_plugin() renames a plugin's directory to the MANIFEST id when it
differs from the REGISTRY id, so `stocks` in the registry lands in
`ledmatrix-stocks/`. Every path lookup is by directory name, so
update_plugin("stocks") used to find nothing and report the plugin as not
installed -- silently. Four installed plugins hit this in practice
(leaderboard, music, stocks, weather): the user clicks update, nothing
happens, no error, and they stay on a stale version indefinitely.
"""
def test_resolves_a_plugin_whose_directory_name_differs_from_its_id(self, tmp_path):
configured = tmp_path / "plugin-repos"
configured.mkdir()
plugin_dir = _write_plugin(configured, "stocks", dir_name="ledmatrix-stocks")
store = PluginStoreManager(plugins_dir=str(configured))
assert store._find_plugin_path("stocks") == plugin_dir
def test_direct_directory_hit_still_wins(self, tmp_path):
"""The manifest scan is a last resort. A directory named for the id
must still be preferred, so the two documented lookups above keep
their exact meaning."""
configured = tmp_path / "plugin-repos"
configured.mkdir()
direct = _write_plugin(configured, "demo", dir_name="demo")
# A second directory whose manifest claims the same id.
_write_plugin(configured, "demo", dir_name="zz-other-demo")
store = PluginStoreManager(plugins_dir=str(configured))
assert store._find_plugin_path("demo") == direct
def test_unknown_id_is_still_not_found(self, tmp_path):
configured = tmp_path / "plugin-repos"
configured.mkdir()
_write_plugin(configured, "stocks", dir_name="ledmatrix-stocks")
store = PluginStoreManager(plugins_dir=str(configured))
assert store._find_plugin_path("no-such-plugin") is None
def test_half_finished_installs_are_not_resurrected(self, tmp_path):
"""A directory renamed aside during install/rollback carries a valid
manifest. Matching one would report a ghost plugin as installed."""
configured = tmp_path / "plugin-repos"
configured.mkdir()
_write_plugin(configured, "ghost",
dir_name="ghost.standalone-backup-1234")
store = PluginStoreManager(plugins_dir=str(configured))
assert store._find_plugin_path("ghost") is None
def test_unreadable_manifest_does_not_break_the_scan(self, tmp_path):
configured = tmp_path / "plugin-repos"
configured.mkdir()
broken = configured / "broken-plugin"
broken.mkdir()
(broken / "manifest.json").write_text("{ not json")
wanted = _write_plugin(configured, "stocks", dir_name="ledmatrix-stocks")
store = PluginStoreManager(plugins_dir=str(configured))
assert store._find_plugin_path("stocks") == wanted
def test_sibling_plugins_dir_is_searched_too(self, tmp_path):
configured = tmp_path / "plugin-repos"
configured.mkdir()
legacy = _write_plugin(tmp_path / "plugins", "weather",
dir_name="ledmatrix-weather")
store = PluginStoreManager(plugins_dir=str(configured))
assert store._find_plugin_path("weather") == legacy
class TestStandaloneBackupContract:
def test_discovery_skips_backup_dirs(self, tmp_path):
plugins_dir = tmp_path / "plugins"
+177 -2
View File
@@ -47,12 +47,17 @@ class _SwapSpy:
def __init__(self, matrix):
self.matrix = matrix
self.count = 0
self.last_frame_hold = None
self._orig = matrix.SwapOnVSync
def __enter__(self):
def counting(canvas):
def counting(canvas, *args):
# *args carries framerate_fraction, which display_manager passes so
# a frame can be held for several refreshes. Signature must match
# the real binding or the spy hides a TypeError as a failed push.
self.count += 1
return self._orig(canvas)
self.last_frame_hold = args[0] if args else 1
return self._orig(canvas, *args)
self.matrix.SwapOnVSync = counting
return self
@@ -109,6 +114,11 @@ class TestDirtyTracking:
dm.draw.rectangle([0, 0, 30, 8], fill=(255, 255, 0))
dm.update_display() # push + snapshot write (first frame)
assert os.path.exists(dm._snapshot_path)
# Backdate the file so the "was it bumped?" check below cannot be
# defeated by filesystem mtime granularity -- on Windows two writes in
# the same tick get identical timestamps, which made this test fail
# roughly two runs in three regardless of the code under test.
os.utime(dm._snapshot_path, (time.time() - 60, time.time() - 60))
first_mtime = os.path.getmtime(dm._snapshot_path)
# Age the write/touch bookkeeping past TOUCH_INTERVAL so the next
@@ -125,6 +135,62 @@ class TestDirtyTracking:
assert os.path.getmtime(dm._snapshot_path) > first_mtime
class TestScrollLock:
"""Dirty tracking must not skip the panel push while a scroll is running.
SwapOnVSync is what paces the render loop, so skipping it also skips the
wait for the panel. A duplicate frame therefore returns early -- ~8ms
instead of ~10ms on a 100Hz panel -- which advances the strip only 0.8px
instead of 1.0px, which makes the NEXT frame more likely to be a duplicate
too. That is self-sustaining: measured at ~20% duplicate frames mid-scroll
on the odds ticker against essentially zero on a lighter plugin with
identical scroll settings. Pushing an identical frame costs one canvas
copy; falling out of vsync lock costs smooth motion.
"""
def test_identical_frames_still_push_while_scrolling(self, dm):
dm.draw.rectangle([0, 0, 12, 12], fill=(0, 0, 255))
dm.update_display()
dm.set_scrolling_state(True)
try:
with _SwapSpy(dm.matrix) as spy:
dm.update_display()
dm.update_display()
dm.update_display()
assert spy.count == 3, "scrolling must stay locked to the panel"
finally:
dm.set_scrolling_state(False)
def test_identical_frames_are_skipped_when_not_scrolling(self, dm):
"""The optimisation still applies to static content."""
dm.set_scrolling_state(False)
dm.draw.rectangle([0, 0, 14, 14], fill=(255, 0, 255))
dm.update_display()
with _SwapSpy(dm.matrix) as spy:
dm.update_display()
dm.update_display()
assert spy.count == 0
def test_stale_scrolling_state_stops_forcing_pushes(self, dm):
"""A plugin that stops scrolling without saying so must not pin the
panel into always-push forever. is_currently_scrolling() expires on
its own inactivity threshold, and the skip has to come back with it."""
dm.draw.rectangle([0, 0, 16, 16], fill=(0, 255, 255))
dm.update_display()
dm.set_scrolling_state(True)
try:
# Backdate the activity marker past the inactivity threshold.
dm._scrolling_state['last_scroll_activity'] = (
time.time() - dm._scrolling_state['scroll_inactivity_threshold'] - 1.0)
assert dm.is_currently_scrolling() is False
with _SwapSpy(dm.matrix) as spy:
dm.update_display()
dm.update_display()
assert spy.count == 0
finally:
dm.set_scrolling_state(False)
class TestKillSwitch:
def test_dirty_tracking_can_be_disabled(self, dm):
dm._dirty_tracking_enabled = False
@@ -160,3 +226,112 @@ class TestKillSwitch:
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-v"]))
class TestFrameHold:
"""Holding a frame for N refreshes is how a scroll runs slower than one
pixel per refresh without fractional pixel positions."""
def test_hold_reaches_swap_on_vsync(self, dm):
dm.set_scrolling_state(True)
dm.set_frame_hold(3)
try:
dm.draw.rectangle([0, 0, 9, 9], fill=(120, 0, 200))
with _SwapSpy(dm.matrix) as spy:
dm.update_display()
assert spy.count == 1
assert spy.last_frame_hold == 3
finally:
dm.set_scrolling_state(False)
def test_default_is_every_refresh(self, dm):
dm.set_scrolling_state(True)
try:
dm.draw.rectangle([0, 0, 11, 11], fill=(0, 120, 200))
with _SwapSpy(dm.matrix) as spy:
dm.update_display()
assert spy.last_frame_hold == 1
finally:
dm.set_scrolling_state(False)
def test_hold_resets_when_scrolling_stops(self, dm):
"""One plugin's pacing must not leak into whatever is on screen next."""
dm.set_scrolling_state(True)
dm.set_frame_hold(5)
dm.set_scrolling_state(False)
dm.set_scrolling_state(True)
try:
dm.draw.rectangle([0, 0, 13, 13], fill=(200, 120, 0))
with _SwapSpy(dm.matrix) as spy:
dm.update_display()
assert spy.last_frame_hold == 1
finally:
dm.set_scrolling_state(False)
@pytest.mark.parametrize("bad,expected", [(0, 1), (-4, 1), (None, 1), ("x", 1)])
def test_unusable_holds_are_ignored_or_floored(self, dm, bad, expected):
dm.set_frame_hold(bad)
assert dm._frame_hold == expected
class TestFrameHoldLifetime:
"""The hold must last exactly as long as the scroll that asked for it.
Plugins share one display manager. A hold applied at plugin construction is
wiped the moment any *other* plugin finishes scrolling, so by the time the
first plugin renders it is back to one pixel per refresh -- the speed reads
correct in the log and is wrong on the panel.
"""
def test_scrolling_state_carries_the_hold(self, dm):
dm.set_scrolling_state(True, frame_hold=4)
try:
dm.draw.rectangle([0, 0, 7, 7], fill=(10, 200, 10))
with _SwapSpy(dm.matrix) as spy:
dm.update_display()
assert spy.last_frame_hold == 4
finally:
dm.set_scrolling_state(False)
def test_a_scroll_that_times_out_does_not_strand_a_hold(self, dm):
"""The hold must go when the state does, however the scroll ended.
set_scrolling_state(False) is the polite exit. The other one is
is_currently_scrolling() deciding, after scroll_inactivity_threshold
of silence, that the scroll is over -- which is what happens when the
rotation moves on mid-scroll or a plugin is torn down. That path used
to clear the flag and keep the hold, so every later plugin, scrolling
or static, was presented at refresh/N by whoever scrolled last.
"""
dm.set_scrolling_state(True, frame_hold=5)
# Age the scroll past the inactivity threshold rather than sleeping.
dm._scrolling_state['last_scroll_activity'] -= (
dm._scrolling_state['scroll_inactivity_threshold'] + 1.0)
assert dm.is_currently_scrolling() is False
dm.draw.rectangle([0, 0, 5, 5], fill=(10, 10, 200))
with _SwapSpy(dm.matrix) as spy:
dm.update_display()
assert spy.last_frame_hold == 1, (
"a timed-out scroll left its frame hold behind; the next plugin "
"is being presented at a fraction of the refresh rate")
def test_another_plugin_stopping_does_not_strand_a_hold(self, dm):
dm.set_scrolling_state(True, frame_hold=3)
dm.set_scrolling_state(False) # some other plugin finishes
dm.set_scrolling_state(True) # a plugin that wants no hold
try:
dm.draw.rectangle([0, 0, 6, 6], fill=(200, 10, 10))
with _SwapSpy(dm.matrix) as spy:
dm.update_display()
assert spy.last_frame_hold == 1
finally:
dm.set_scrolling_state(False)
def test_default_keeps_previous_behaviour(self, dm):
"""Callers that never heard of frame holds get one frame per refresh."""
dm.set_scrolling_state(True)
try:
assert dm._frame_hold == 1
finally:
dm.set_scrolling_state(False)
+69
View File
@@ -0,0 +1,69 @@
"""The visual-test double must not drift from the real DisplayManager.
Both directions of drift are silent and both are damaging:
* the double accepts an argument production does not -- the call passes
every harness run and raises TypeError on the panel, which is precisely
the failure a safety harness exists to prevent;
* the double lacks an argument production has -- every plugin that
legitimately uses it fails every render, and the harness blames the
plugin.
`set_scrolling_state` has been each of those in turn across two branches, so
the parity is asserted rather than remembered.
Read with ast rather than imported: src/display_manager.py imports rgbmatrix
at module scope, which is absent anywhere without the panel library, and this
check should hold on a laptop and in CI as well as on a Pi.
"""
import ast
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
REAL = ROOT / "src" / "display_manager.py"
DOUBLE = ROOT / "src" / "plugin_system" / "testing" / "visual_display_manager.py"
#: Methods a plugin calls on whichever manager it is handed.
SHARED_METHODS = ["set_scrolling_state", "is_currently_scrolling"]
def _signature(path: Path, class_hint: str, method: str):
"""(name, default-repr) pairs for `method`, or None if it is absent."""
tree = ast.parse(path.read_text(encoding="utf-8"))
for node in ast.walk(tree):
if not isinstance(node, ast.ClassDef) or class_hint not in node.name:
continue
for item in node.body:
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)) and item.name == method:
args = [a.arg for a in item.args.args if a.arg != "self"]
pad = [None] * (len(args) - len(item.args.defaults))
defaults = pad + [ast.unparse(d) for d in item.args.defaults]
return list(zip(args, defaults))
return None
@pytest.mark.parametrize("method", SHARED_METHODS)
def test_the_double_matches_production(method):
real = _signature(REAL, "DisplayManager", method)
double = _signature(DOUBLE, "DisplayManager", method)
assert real is not None, f"DisplayManager lost {method}"
assert double is not None, \
f"the test double is missing {method}, so every plugin using it fails to render"
assert double == real, (
f"{method} has drifted: production takes {real}, the double takes {double}. "
"A double that is more permissive hides a production TypeError; one that "
"is less permissive fails plugins that are actually correct."
)
def test_frame_hold_is_accepted_by_both():
"""The specific argument that has drifted twice."""
for path, label in ((REAL, "DisplayManager"), (DOUBLE, "VisualTestDisplayManager")):
params = dict(_signature(path, "DisplayManager", "set_scrolling_state") or [])
assert "frame_hold" in params, f"{label} does not accept frame_hold"
assert params["frame_hold"] == "1", \
f"{label} must default frame_hold to 1 so existing callers are unaffected"
+58
View File
@@ -101,3 +101,61 @@ class TestSkippedResults:
r = _result(None, returned=None)
check_empty_claimed([r], strict=True)
assert r.empty_claimed is None
class TestSettleRecordsLaterFailures:
"""A mode that renders one good frame and then crashes is broken.
_settle_loop re-renders a mode that came back blank, to give a scroll or an
animation time to put something on the panel. Swallowing an exception from
those later frames meant the harness reported a passing result for a mode
that crashes as soon as it is asked for a second frame -- exactly the kind
of defect the harness exists to catch.
"""
class Boom:
"""Renders once, then raises."""
def __init__(self):
self.calls = 0
def display(self, force_clear=False):
self.calls += 1
raise RuntimeError("second frame exploded")
def _settle(self, inst, dm, result):
from src.plugin_system.testing import harness
harness._settle_loop(inst, "mode", dm, result, None)
def test_the_exception_is_recorded_on_the_result(self, monkeypatch):
from src.plugin_system.testing import harness
# Keep the probe short; this test is about the error, not the pacing.
monkeypatch.setattr(harness, "EMPTY_RECHECK_FRAMES", 1)
monkeypatch.setattr(harness, "EMPTY_RECHECK_STEP", 0)
result = _result(_blank())
assert result.error is None
self._settle(self.Boom(), _FakeDM(), result)
assert result.error is not None, "a crash on a later frame was swallowed"
assert "second frame exploded" in result.error
def test_the_already_captured_frame_is_kept(self, monkeypatch):
from src.plugin_system.testing import harness
monkeypatch.setattr(harness, "EMPTY_RECHECK_FRAMES", 1)
monkeypatch.setattr(harness, "EMPTY_RECHECK_STEP", 0)
image = _blank()
result = _result(image)
self._settle(self.Boom(), _FakeDM(), result)
assert result.image is image, "the good frame was discarded along with the error"
class _FakeDM:
"""Minimal display-manager double for _settle_loop."""
def get_image(self):
return _blank()
def check_overflow(self):
return None
+329
View File
@@ -0,0 +1,329 @@
"""The Home Assistant MQTT bridge, without a broker or a matrix.
The bridge is a translation layer: one MQTT payload in, one api_v3 call out.
Everything worth pinning is on that path -- attaching the plugin_id a mode
needs, rejecting values HA can produce but the API cannot take, and the
discovery configs HA reads once and caches -- so it is all reachable with a
fake API client and a pure function.
"""
import importlib.util
import json
import logging
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
BRIDGE_PATH = (Path(__file__).resolve().parent.parent
/ "integrations" / "mqtt_bridge" / "ledmatrix_mqtt_bridge.py")
@pytest.fixture(scope="module")
def bridge_module():
if not BRIDGE_PATH.exists():
pytest.skip("mqtt bridge is not present")
try:
spec = importlib.util.spec_from_file_location("ledmatrix_mqtt_bridge", BRIDGE_PATH)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
except ImportError as e:
pytest.skip(f"mqtt bridge dependencies are not installed here: {e}")
finally:
sys.modules.pop("ledmatrix_mqtt_bridge", None)
MODES = [
{"mode": "clock-simple", "plugin_id": "clock-simple",
"plugin_name": "Simple Clock", "name": "Simple Clock", "enabled": True},
{"mode": "nfl_live", "plugin_id": "football-scoreboard",
"plugin_name": "Football Scoreboard", "name": "nfl_live", "enabled": True},
{"mode": "aquarium", "plugin_id": "starlark-apps",
"plugin_name": "Starlark Apps", "name": "aquarium", "enabled": True},
]
@pytest.fixture
def client():
"""A stand-in for LEDMatrixClient that records what it was asked to do."""
fake = MagicMock()
fake.list_modes.return_value = list(MODES)
fake.start_on_demand.return_value = {"request_id": "r1"}
fake.stop_on_demand.return_value = {}
fake.set_power.return_value = {}
fake.set_brightness.return_value = {}
fake.get_brightness.return_value = 90
fake.display_status.return_value = {
"service": {"active": True},
"state": {"active": True, "mode": "nfl_live"},
}
return fake
@pytest.fixture
def handler(bridge_module, client):
h = bridge_module.CommandHandler(client)
h.refresh_modes()
return h
class TestDisplayCommands:
def test_a_mode_is_forced_on_demand(self, handler, client):
result = handler.handle({"action": "display", "mode": "nfl_live"})
assert result["status"] == "success"
assert client.start_on_demand.call_args.kwargs["mode"] == "nfl_live"
def test_the_owning_plugin_is_sent_with_the_mode(self, handler, client):
"""on-demand/start's find_plugin_for_mode fallback cannot resolve a
generated mode, so an omitted plugin_id 404s for every Starlark app."""
handler.handle({"action": "display", "mode": "aquarium"})
assert client.start_on_demand.call_args.kwargs["plugin_id"] == "starlark-apps"
def test_a_home_assistant_label_resolves_to_its_mode(self, handler, client):
"""The select entity shows labels, so that is what comes back."""
handler.handle({"action": "display", "mode": "Simple Clock"})
assert client.start_on_demand.call_args.kwargs["mode"] == "clock-simple"
def test_an_unknown_mode_refreshes_before_giving_up(self, handler, client):
"""A plugin installed since the last refresh is the usual reason."""
client.list_modes.return_value = MODES + [
{"mode": "new_app", "plugin_id": "starlark-apps", "name": "new_app"}]
handler.handle({"action": "display", "mode": "new_app"})
assert client.start_on_demand.call_args.kwargs["plugin_id"] == "starlark-apps"
def test_an_explicit_plugin_id_is_respected(self, handler, client):
handler.handle({"action": "display", "mode": "x", "plugin_id": "custom"})
assert client.start_on_demand.call_args.kwargs["plugin_id"] == "custom"
def test_duration_and_pinned_are_passed_through(self, handler, client):
handler.handle({"action": "display", "mode": "aquarium",
"duration": 300, "pinned": True})
kwargs = client.start_on_demand.call_args.kwargs
assert kwargs["duration"] == 300 and kwargs["pinned"] is True
def test_display_with_nothing_to_show_is_rejected(self, handler, client):
result = handler.handle({"action": "display"})
assert result["status"] == "error"
client.start_on_demand.assert_not_called()
def test_stop_returns_to_normal_rotation(self, handler, client):
assert handler.handle({"action": "stop_display"})["status"] == "success"
client.stop_on_demand.assert_called_once()
class TestPowerAndBrightness:
@pytest.mark.parametrize("state,expected", [("on", True), ("OFF", False), (" On ", True)])
def test_power_states_are_accepted(self, handler, client, state, expected):
assert handler.handle({"action": "power", "state": state})["status"] == "success"
client.set_power.assert_called_with(expected)
def test_an_unknown_power_state_is_rejected(self, handler, client):
assert handler.handle({"action": "power", "state": "maybe"})["status"] == "error"
client.set_power.assert_not_called()
def test_brightness_is_applied(self, handler, client):
assert handler.handle({"action": "brightness", "value": 75})["status"] == "success"
client.set_brightness.assert_called_with(75)
def test_a_float_from_home_assistant_is_accepted(self, handler, client):
"""The number entity publishes "75.0"; int() would raise on that."""
handler.handle({"action": "brightness", "value": "75.0"})
client.set_brightness.assert_called_with(75)
@pytest.mark.parametrize("value", [-1, 101, "bright", None])
def test_out_of_range_brightness_never_reaches_the_api(self, handler, client, value):
assert handler.handle({"action": "brightness", "value": value})["status"] == "error"
client.set_brightness.assert_not_called()
class TestFailuresAreReportedNotRaised:
"""A failed command must publish an error, not kill the MQTT loop."""
def test_an_api_error_becomes_an_error_result(self, handler, client):
client.start_on_demand.side_effect = RuntimeError("Display service is not running")
result = handler.handle({"action": "display", "mode": "nfl_live"})
assert result["status"] == "error"
assert "not running" in result["message"]
def test_an_unknown_action_lists_the_known_ones(self, handler):
result = handler.handle({"action": "explode"})
assert result["status"] == "error"
assert "brightness" in result["message"]
def test_a_missing_action_is_an_error(self, handler):
assert handler.handle({})["status"] == "error"
class TestDiscoveryPayloads:
"""HA reads these once and caches them; a wrong shape is a dead entity."""
@pytest.fixture
def messages(self, bridge_module):
return bridge_module.discovery_messages(
"ledmatrix/command", "ledmatrix/command/state",
"ledmatrix/command/availability", ["Simple Clock", "nfl_live"])
def test_every_entity_is_published(self, messages):
components = {m["topic"].split("/")[1] for m in messages}
assert components == {"select", "button", "switch", "number"}
def test_each_entity_has_a_stable_unique_id(self, messages):
"""Without one HA cannot let a user rename or reassign the entity."""
ids = [m["payload"]["unique_id"] for m in messages]
assert len(ids) == len(set(ids)) and all(ids)
def test_the_select_offers_the_modes_it_was_given(self, messages):
select = next(m for m in messages if "/select/" in m["topic"])
assert select["payload"]["options"] == ["Simple Clock", "nfl_live"]
def test_every_entity_shares_the_availability_topic(self, messages):
"""It is also the bridge's last will, so HA greys the controls out
when the bridge dies rather than leaving them silently inert."""
assert all(m["payload"]["availability_topic"] == "ledmatrix/command/availability"
for m in messages)
def test_every_entity_belongs_to_one_device(self, messages):
assert all(m["payload"]["device"]["identifiers"] == ["ledmatrix"] for m in messages)
def test_the_command_templates_are_valid_json_the_handler_accepts(self, messages, handler):
"""A template that renders malformed JSON fails only at runtime, in HA."""
select = next(m for m in messages if "/select/" in m["topic"])
rendered = select["payload"]["command_template"].replace("{{ value }}", "nfl_live")
assert handler.handle(json.loads(rendered))["status"] == "success"
number = next(m for m in messages if "/number/" in m["topic"])
rendered = number["payload"]["command_template"].replace("{{ value }}", "40")
assert handler.handle(json.loads(rendered))["status"] == "success"
def test_the_switch_payloads_are_valid_json_the_handler_accepts(self, messages, handler):
switch = next(m for m in messages if "/switch/" in m["topic"])
for key in ("payload_on", "payload_off"):
assert handler.handle(json.loads(switch["payload"][key]))["status"] == "success"
def test_the_button_payload_is_valid_json_the_handler_accepts(self, messages, handler):
button = next(m for m in messages if "/button/" in m["topic"])
assert handler.handle(json.loads(button["payload"]["payload_press"]))["status"] == "success"
class TestStateReporting:
def test_state_reflects_the_running_display(self, bridge_module, client):
state = bridge_module.read_state(client)
assert state == {"power": True, "mode": "nfl_live", "brightness": 90}
def test_normal_rotation_reports_no_forced_mode(self, bridge_module, client):
client.display_status.return_value = {
"service": {"active": True}, "state": {"active": False}}
assert bridge_module.read_state(client)["mode"] is None
def test_an_unreachable_field_does_not_blank_the_others(self, bridge_module, client):
"""A stopped display service still has a brightness worth showing."""
client.display_status.side_effect = RuntimeError("connection refused")
state = bridge_module.read_state(client)
assert state["brightness"] == 90 and state["power"] is False
class TestConfigLoading:
def test_defaults_apply_when_no_file_exists(self, bridge_module, tmp_path):
config = bridge_module.load_config(str(tmp_path / "absent.json"))
assert config["mqtt_topic"] == "ledmatrix/command"
assert config["mqtt_port"] == 1883
def test_the_file_overrides_defaults(self, bridge_module, tmp_path):
path = tmp_path / "bridge_config.json"
path.write_text(json.dumps({"mqtt_host": "broker.local", "mqtt_port": "8883"}))
config = bridge_module.load_config(str(path))
assert config["mqtt_host"] == "broker.local"
assert config["mqtt_port"] == 8883, "a port read from JSON must still be an int"
def test_the_environment_overrides_the_file(self, bridge_module, tmp_path, monkeypatch):
"""So a password need not sit in a file the service user can read."""
path = tmp_path / "bridge_config.json"
path.write_text(json.dumps({"mqtt_password": "from-file"}))
monkeypatch.setenv("LEDMATRIX_MQTT_MQTT_PASSWORD", "from-env")
assert bridge_module.load_config(str(path))["mqtt_password"] == "from-env"
def test_the_example_placeholder_password_is_refused(self, bridge_module, tmp_path):
"""Copying the example unedited must fail loudly, not silently fail to
authenticate against the broker."""
path = tmp_path / "bridge_config.json"
path.write_text(json.dumps(
{"mqtt_password": "REPLACE_WITH_YOUR_ACTUAL_MQTT_PASSWORD"}))
with pytest.raises(bridge_module.ConfigError):
bridge_module.load_config(str(path))
def test_malformed_json_is_a_clear_error(self, bridge_module, tmp_path):
path = tmp_path / "bridge_config.json"
path.write_text("{not json")
with pytest.raises(bridge_module.ConfigError):
bridge_module.load_config(str(path))
def test_a_non_numeric_port_is_a_clear_error(self, bridge_module, tmp_path):
path = tmp_path / "bridge_config.json"
path.write_text(json.dumps({"mqtt_port": "not-a-port"}))
with pytest.raises(bridge_module.ConfigError):
bridge_module.load_config(str(path))
class TestTheExampleConfigIsSecureByDefault:
"""The installer copies bridge_config.example.json verbatim on first run.
Without TLS the broker password and every display command cross the network
in cleartext, so the shipped default has to be the safe one -- a plaintext
broker is a deliberate edit, not something you get by not reading.
"""
@pytest.fixture
def example(self):
path = (Path(__file__).resolve().parent.parent
/ "integrations" / "mqtt_bridge" / "bridge_config.example.json")
if not path.exists():
pytest.skip("mqtt bridge example config is not present")
return json.loads(path.read_text(encoding="utf-8"))
def test_tls_is_on(self, example):
assert example["mqtt_tls"] is True
def test_the_port_is_the_tls_one(self, example):
"""1883 with mqtt_tls on would just fail to connect."""
assert example["mqtt_port"] == 8883
def test_certificate_verification_is_not_disabled(self, example):
assert example.get("mqtt_tls_insecure", False) is False
def test_no_password_ships_in_the_example(self, example):
assert example["mqtt_password"] is None
def test_the_example_loads(self, bridge_module, tmp_path):
"""It is copied verbatim, so it must survive load_config."""
path = (Path(__file__).resolve().parent.parent
/ "integrations" / "mqtt_bridge" / "bridge_config.example.json")
config = bridge_module.load_config(str(path))
assert config["mqtt_tls"] is True and config["mqtt_port"] == 8883
class TestCleartextIsCalledOut:
"""Turning TLS off is allowed -- the Mosquitto add-on is plaintext on 1883 --
but it should not be silent when a password is going over it."""
def test_a_password_without_tls_warns(self, bridge_module, caplog):
with caplog.at_level(logging.WARNING):
warned = bridge_module.warn_if_cleartext(
{"mqtt_tls": False, "mqtt_password": "hunter2"})
assert warned is True
assert "unencrypted" in caplog.text
def test_the_warning_does_not_repeat_the_password(self, bridge_module, caplog):
with caplog.at_level(logging.WARNING):
bridge_module.warn_if_cleartext({"mqtt_tls": False, "mqtt_password": "hunter2"})
assert "hunter2" not in caplog.text
def test_no_password_means_nothing_to_lose(self, bridge_module):
assert bridge_module.warn_if_cleartext(
{"mqtt_tls": False, "mqtt_password": None}) is False
def test_tls_on_does_not_warn(self, bridge_module):
assert bridge_module.warn_if_cleartext(
{"mqtt_tls": True, "mqtt_password": "hunter2"}) is False
+106
View File
@@ -0,0 +1,106 @@
"""The on-demand request mailbox: how often it is read, and how it is consumed.
The mailbox is a cache key the web process writes and the display process
reads. Two properties matter and neither is obvious from the call site:
* it is polled after every rendered frame, so an uncached read here is a
disk read at frame rate;
* consuming it must not throw away a request that arrived while the previous
one was being processed.
"""
from unittest.mock import MagicMock
import pytest
class TestPollingIsBounded:
"""_poll_on_demand_requests runs ~125x/second on a scrolling mode.
The read is deliberately uncached (memory_ttl=0) because a cached one
pinned the first request for an hour. That makes the call a real disk read,
so it needs a floor -- without one it was ~125 reads per second to find
nothing at all.
"""
def test_first_call_always_reads(self, test_display_controller):
c = test_display_controller
c.cache_manager.get = MagicMock(return_value=None)
c._poll_on_demand_requests()
assert c.cache_manager.get.call_count == 1
def test_immediate_second_call_does_not_read(self, test_display_controller):
c = test_display_controller
c.cache_manager.get = MagicMock(return_value=None)
c._poll_on_demand_requests()
for _ in range(50):
c._poll_on_demand_requests()
assert c.cache_manager.get.call_count == 1, "polling was not bounded"
def test_reads_again_once_the_interval_has_passed(self, test_display_controller, monkeypatch):
c = test_display_controller
c.cache_manager.get = MagicMock(return_value=None)
clock = {"t": 1000.0}
monkeypatch.setattr("src.display_controller.time.monotonic", lambda: clock["t"])
c._poll_on_demand_requests()
clock["t"] += c.ON_DEMAND_POLL_INTERVAL / 2
c._poll_on_demand_requests()
assert c.cache_manager.get.call_count == 1, "read before the interval elapsed"
clock["t"] += c.ON_DEMAND_POLL_INTERVAL
c._poll_on_demand_requests()
assert c.cache_manager.get.call_count == 2
def test_the_interval_is_short_enough_to_feel_instant(self, test_display_controller):
# A person clicking in the web UI must not notice the floor.
assert test_display_controller.ON_DEMAND_POLL_INTERVAL <= 0.5
class TestMailboxIsConsumedByIdentity:
"""Deleting whatever is in the mailbox loses a request that raced in."""
def _arrange(self, controller, first, later):
"""Mailbox returns `first`, then `later` on the pre-delete re-read."""
controller.on_demand_active = False
controller.on_demand_request_id = None
controller._last_on_demand_poll = None
reads = iter([first, later])
def fake_get(key, *a, **kw):
if key == 'display_on_demand_request':
return next(reads, later)
return None # processed-id lookup
controller.cache_manager.get = MagicMock(side_effect=fake_get)
controller.cache_manager.set = MagicMock()
controller.cache_manager.delete = MagicMock()
controller._activate_on_demand = MagicMock()
REQ_A = {'request_id': 'A', 'action': 'start', 'plugin_id': 'p', 'mode': 'm'}
REQ_B = {'request_id': 'B', 'action': 'start', 'plugin_id': 'p', 'mode': 'm'}
def test_own_request_is_deleted(self, test_display_controller):
c = test_display_controller
self._arrange(c, self.REQ_A, self.REQ_A)
c._poll_on_demand_requests()
c.cache_manager.delete.assert_called_once_with('display_on_demand_request')
def test_a_newer_request_is_left_for_the_next_poll(self, test_display_controller):
c = test_display_controller
self._arrange(c, self.REQ_A, self.REQ_B)
c._poll_on_demand_requests()
assert c.cache_manager.delete.call_count == 0, \
"request B was deleted without ever being processed"
def test_an_already_empty_mailbox_is_still_cleared(self, test_display_controller):
c = test_display_controller
self._arrange(c, self.REQ_A, None)
c._poll_on_demand_requests()
c.cache_manager.delete.assert_called_once_with('display_on_demand_request')
def test_the_request_is_still_processed(self, test_display_controller):
c = test_display_controller
self._arrange(c, self.REQ_A, self.REQ_B)
c._poll_on_demand_requests()
c._activate_on_demand.assert_called_once()
+208
View File
@@ -0,0 +1,208 @@
"""On-demand behaviour that a person can ask for but the controller ignored.
Three separate gaps, all reachable from the web UI's force-display dialog:
* `pinned` was accepted by the API, stored on the controller and published
back in the status payload, but never narrowed the rotation -- a pinned
request still cycled every mode its plugin owns;
* restarting while on-demand was active loaded *only* the on-demand plugin,
so normal rotation had nothing to return to for the life of the process;
* a stop request was exempt from the duplicate guards on purpose and was
never removed from the mailbox, so it was re-processed on every poll
forever.
"""
from unittest.mock import MagicMock
import pytest
def _plugin_with_modes(controller, plugin_id, modes):
"""Register `modes` as loaded modes belonging to `plugin_id`."""
controller.plugin_display_modes[plugin_id] = list(modes)
for mode in modes:
controller.plugin_modes[mode] = MagicMock(spec=[])
controller.mode_to_plugin_id[mode] = plugin_id
class TestPinnedNarrowsTheRotation:
"""A pinned request shows the one mode that was asked for.
Unpinned stays the default: a sports plugin's modes are views of one
subject, so rotating them is right. A Starlark plugin's modes are
unrelated widgets, so it is not.
"""
MODES = ['app_a', 'app_b', 'app_c']
def _activate(self, controller, pinned):
_plugin_with_modes(controller, 'starlark-apps', self.MODES)
controller.available_modes = list(self.MODES)
controller._activate_on_demand({
'request_id': 'r1',
'action': 'start',
'plugin_id': 'starlark-apps',
'mode': 'app_b',
'pinned': pinned,
})
def test_pinned_rotation_holds_the_requested_mode(self, test_display_controller):
c = test_display_controller
self._activate(c, pinned=True)
assert c.on_demand_modes == ['app_b']
def test_unpinned_still_rotates_the_whole_plugin(self, test_display_controller):
c = test_display_controller
self._activate(c, pinned=False)
assert set(c.on_demand_modes) == set(self.MODES)
def test_pinned_still_starts_on_the_requested_mode(self, test_display_controller):
c = test_display_controller
self._activate(c, pinned=True)
assert c.current_display_mode == 'app_b'
def test_the_pin_is_recorded_for_the_status_payload(self, test_display_controller):
c = test_display_controller
self._activate(c, pinned=True)
assert c.on_demand_pinned is True
def test_an_unresolvable_pin_does_not_empty_the_rotation(self, test_display_controller):
"""A pin naming a mode outside the plugin must not leave nothing to show."""
c = test_display_controller
_plugin_with_modes(c, 'starlark-apps', self.MODES)
assert c._apply_on_demand_pin(list(self.MODES), 'not_a_mode', True) == self.MODES
class TestPinSurvivesARestart:
"""The pin is part of the request being resumed, not a per-session flag."""
def test_restored_pinned_state_narrows_the_rotation(self, test_display_controller):
c = test_display_controller
_plugin_with_modes(c, 'starlark-apps', ['app_a', 'app_b', 'app_c'])
c.on_demand_active = True
c.on_demand_plugin_id = 'starlark-apps'
c.on_demand_mode = 'app_c'
c.on_demand_pinned = True
c._populate_on_demand_modes_from_plugin()
assert c.on_demand_modes == ['app_c']
def test_restored_unpinned_state_keeps_every_mode(self, test_display_controller):
c = test_display_controller
_plugin_with_modes(c, 'starlark-apps', ['app_a', 'app_b', 'app_c'])
c.on_demand_active = True
c.on_demand_plugin_id = 'starlark-apps'
c.on_demand_mode = 'app_c'
c.on_demand_pinned = False
c._populate_on_demand_modes_from_plugin()
assert set(c.on_demand_modes) == {'app_a', 'app_b', 'app_c'}
class TestRestartDoesNotStarveTheOtherPlugins:
"""Restarting mid-on-demand used to load only the on-demand plugin.
Restarts during an on-demand session are routine -- it is how an update or
a config change is applied -- and the panel came back cycling one plugin's
modes and nothing else until the on-demand cache was cleared by hand.
"""
DISCOVERED = ['clock', 'weather', 'starlark-apps', 'disabled-one']
@pytest.fixture
def controller(self, test_display_controller):
c = test_display_controller
c.config.update({
'clock': {'enabled': True},
'weather': {'enabled': True},
'starlark-apps': {'enabled': True},
'disabled-one': {'enabled': False},
})
return c
def test_every_enabled_plugin_still_loads(self, controller):
selected = controller._select_startup_plugins(
self.DISCOVERED, {'plugin_id': 'starlark-apps', 'mode': 'app_a'})
assert set(selected) == {'clock', 'weather', 'starlark-apps'}
def test_disabled_plugins_are_still_left_out(self, controller):
selected = controller._select_startup_plugins(
self.DISCOVERED, {'plugin_id': 'starlark-apps', 'mode': 'app_a'})
assert 'disabled-one' not in selected
def test_the_on_demand_state_is_still_restored(self, controller):
controller._select_startup_plugins(
self.DISCOVERED,
{'plugin_id': 'starlark-apps', 'mode': 'app_a', 'pinned': True})
assert controller.on_demand_active is True
assert controller.on_demand_plugin_id == 'starlark-apps'
assert controller.on_demand_mode == 'app_a'
assert controller.on_demand_pinned is True
def test_a_disabled_on_demand_plugin_is_enabled_and_loaded(self, controller):
"""Otherwise the mode being resumed has nothing behind it."""
selected = controller._select_startup_plugins(
self.DISCOVERED, {'plugin_id': 'disabled-one', 'mode': 'x'})
assert 'disabled-one' in selected
assert controller.config['disabled-one']['enabled'] is True
def test_an_unknown_on_demand_plugin_falls_back_to_normal(self, controller):
selected = controller._select_startup_plugins(
self.DISCOVERED, {'plugin_id': 'uninstalled', 'mode': 'x'})
assert set(selected) == {'clock', 'weather', 'starlark-apps'}
assert controller.on_demand_active is False
def test_no_on_demand_config_is_a_normal_startup(self, controller):
selected = controller._select_startup_plugins(self.DISCOVERED, None)
assert set(selected) == {'clock', 'weather', 'starlark-apps'}
assert controller.on_demand_active is False
class TestStopRequestsAreConsumed:
"""A stop request is exempt from the duplicate guards, so the mailbox
delete is the only thing that ends it."""
STOP = {'request_id': 'S1', 'action': 'stop'}
def _arrange(self, controller, active):
controller.on_demand_active = active
controller.on_demand_status = 'active' if active else 'idle'
controller._last_on_demand_poll = None
controller.cache_manager.get = MagicMock(
side_effect=lambda key, *a, **kw:
self.STOP if key == 'display_on_demand_request' else None)
controller.cache_manager.set = MagicMock()
controller.cache_manager.delete = MagicMock()
controller._clear_on_demand = MagicMock()
def test_a_handled_stop_is_removed_from_the_mailbox(self, test_display_controller):
c = test_display_controller
self._arrange(c, active=True)
c._poll_on_demand_requests()
c.cache_manager.delete.assert_called_once_with('display_on_demand_request')
def test_a_stop_arriving_while_idle_is_also_removed(self, test_display_controller):
"""Otherwise a stop sent to an idle display re-fires forever."""
c = test_display_controller
self._arrange(c, active=False)
c._poll_on_demand_requests()
c.cache_manager.delete.assert_called_once_with('display_on_demand_request')
def test_the_stop_is_still_acted_on(self, test_display_controller):
c = test_display_controller
self._arrange(c, active=True)
c._poll_on_demand_requests()
c._clear_on_demand.assert_called_once_with(reason='requested-stop')
def test_a_start_racing_in_behind_a_stop_is_not_discarded(self, test_display_controller):
"""The compare-before-delete applies to stops too."""
c = test_display_controller
self._arrange(c, active=True)
newer = {'request_id': 'S2', 'action': 'start', 'plugin_id': 'p', 'mode': 'm'}
reads = iter([self.STOP, newer])
c.cache_manager.get = MagicMock(
side_effect=lambda key, *a, **kw:
next(reads, newer) if key == 'display_on_demand_request' else None)
c._poll_on_demand_requests()
assert c.cache_manager.delete.call_count == 0
+210
View File
@@ -0,0 +1,210 @@
"""starlark-apps PixletRenderer: what reaches Pixlet, and what counts as a render.
Three things a Starlark app can do that the renderer got wrong:
* put a "|" in a config value -- a shell metacharacter filter dropped the
whole key, though the command is a list and no shell is involved;
* render nothing -- Pixlet exits 0 and writes a 0-byte file, which was
reported as a successful render;
* compute its schema at runtime -- the source parser can only read option
lists that are written out literally, so a dropdown fed by a live API call
came back empty.
"""
import importlib.util
import json
import subprocess
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
PLUGIN_DIR = Path(__file__).resolve().parent.parent / "plugin-repos" / "starlark-apps"
@pytest.fixture(scope="module")
def renderer_module():
if not PLUGIN_DIR.exists():
pytest.skip("starlark-apps plugin is not checked out")
sys.path.insert(0, str(PLUGIN_DIR))
try:
spec = importlib.util.spec_from_file_location(
"pixlet_renderer_under_test", PLUGIN_DIR / "pixlet_renderer.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
except Exception as e: # noqa: BLE001 - optional deps may be absent
pytest.skip(f"pixlet_renderer is not importable here: {e}")
finally:
sys.path.remove(str(PLUGIN_DIR))
@pytest.fixture
def renderer(renderer_module):
"""A renderer with a known binary and __init__'s binary search bypassed."""
r = renderer_module.PixletRenderer.__new__(renderer_module.PixletRenderer)
r.timeout = 30
r.pixlet_binary = "/usr/local/bin/pixlet"
return r
def _completed(returncode=0, stdout="", stderr=""):
return subprocess.CompletedProcess(args=[], returncode=returncode,
stdout=stdout, stderr=stderr)
class TestConfigValuesReachPixlet:
"""cmd is a list and there is no shell=True, so nothing here is ever
interpreted by a shell -- the filter is defence in depth, not a boundary."""
def _args_for(self, renderer, tmp_path, config):
star = tmp_path / "app.star"
star.write_text("# app", encoding="utf-8")
out = tmp_path / "out.webp"
def fake_run(cmd, **kw):
out.write_bytes(b"webp-bytes")
fake_run.cmd = cmd
return _completed()
with patch.object(subprocess, "run", side_effect=fake_run):
renderer.render(str(tmp_path / "app.star"), str(out), config=config)
return fake_run.cmd
def test_a_pipe_in_a_value_is_passed_through(self, renderer, tmp_path):
"""Real apps use "|" as a separator inside one config value."""
args = self._args_for(renderer, tmp_path, {"sign_id": "I-476 North|175659"})
assert "sign_id=I-476 North|175659" in args
def test_a_dropped_value_does_not_take_the_key_with_it(self, renderer, tmp_path):
args = self._args_for(renderer, tmp_path, {"sign_id": "I-476 North|175659"})
assert any(a.startswith("sign_id=") for a in args)
@pytest.mark.parametrize("value", [
"$(rm -rf /)",
"`whoami`",
"a;b",
"a&b",
"a>b",
"a<b",
])
def test_actual_shell_metacharacters_are_still_dropped(self, renderer, tmp_path, value):
args = self._args_for(renderer, tmp_path, {"k": value})
assert not any(a.startswith("k=") for a in args)
def test_an_invalid_key_is_still_dropped(self, renderer, tmp_path):
args = self._args_for(renderer, tmp_path, {"bad key!": "x"})
assert not any("bad key!" in a for a in args)
class TestAnEmptyRenderIsNotASuccess:
"""Pixlet exits 0 and writes 0 bytes when an app renders no content."""
def _render(self, renderer, tmp_path, write_bytes):
star = tmp_path / "app.star"
star.write_text("# app", encoding="utf-8")
out = tmp_path / "out.webp"
def fake_run(cmd, **kw):
out.write_bytes(write_bytes)
return _completed()
with patch.object(subprocess, "run", side_effect=fake_run):
return renderer.render(str(tmp_path / "app.star"), str(out))
def test_a_zero_byte_output_is_reported_as_a_failure(self, renderer, tmp_path):
ok, error = self._render(renderer, tmp_path, b"")
assert ok is False
assert "empty" in (error or "").lower()
def test_a_real_output_still_succeeds(self, renderer, tmp_path):
ok, error = self._render(renderer, tmp_path, b"RIFF....WEBP")
assert (ok, error) == (True, None)
def test_a_missing_output_still_fails(self, renderer, tmp_path):
star = tmp_path / "app.star"
star.write_text("# app", encoding="utf-8")
out = tmp_path / "out.webp"
with patch.object(subprocess, "run", return_value=_completed()):
ok, error = renderer.render(str(tmp_path / "app.star"), str(out))
assert ok is False
assert "not found" in (error or "").lower()
class TestSchemaComesFromPixletWhenItCan:
"""`pixlet schema` runs get_schema(); the source parser only reads it.
An app whose dropdown options come from a live API call has no option list
anywhere in its source, so the parser reports an empty dropdown and the
config form offers nothing to choose.
"""
PIXLET_OUTPUT = json.dumps({
"version": "1",
"schema": [
{"id": "roadway", "name": "Roadway", "type": "dropdown",
"description": "Pick a road", "options": [{"display": "I-476", "value": "476"}]},
],
})
def test_the_pixlet_output_is_used(self, renderer, tmp_path):
star = tmp_path / "app.star"
star.write_text("# no schema literal anywhere\n", encoding="utf-8")
with patch.object(subprocess, "run",
return_value=_completed(stdout=self.PIXLET_OUTPUT)):
ok, schema, error = renderer.extract_schema(str(star))
assert ok is True and error is None
assert schema["schema"][0]["options"][0]["value"] == "476"
def test_pixlet_field_keys_are_remapped_for_the_config_form(self, renderer, tmp_path):
"""The UI reads typeOf/desc; Pixlet emits type/description."""
star = tmp_path / "app.star"
star.write_text("#\n", encoding="utf-8")
with patch.object(subprocess, "run",
return_value=_completed(stdout=self.PIXLET_OUTPUT)):
_, schema, _ = renderer.extract_schema(str(star))
field = schema["schema"][0]
assert field["typeOf"] == "dropdown"
assert field["desc"] == "Pick a road"
assert "type" not in field and "description" not in field
@pytest.mark.parametrize("outcome", [
{"return_value": _completed(returncode=1, stderr="unknown command \"schema\"")},
{"return_value": _completed(stdout="not json at all")},
{"side_effect": subprocess.TimeoutExpired(cmd="pixlet", timeout=20)},
{"side_effect": OSError("no such binary")},
])
def test_it_falls_back_to_the_source_parser(self, renderer, tmp_path, outcome):
"""Older Pixlet has no `schema` subcommand, and an app can fail to run.
Neither should cost the schema an app does state literally."""
star = tmp_path / "app.star"
star.write_text(
'def get_schema():\n'
' return schema.Schema(version = "1", fields = [\n'
' schema.Text(id = "city", name = "City", desc = "Your city",'
' icon = "gear"),\n'
' ])\n',
encoding="utf-8")
with patch.object(subprocess, "run", **outcome):
ok, parsed, error = renderer.extract_schema(str(star))
assert ok is True and error is None
assert [f["id"] for f in parsed["schema"]] == ["city"]
def test_no_pixlet_binary_skips_straight_to_the_parser(self, renderer, tmp_path):
renderer.pixlet_binary = None
star = tmp_path / "app.star"
star.write_text("#\n", encoding="utf-8")
assert renderer.extract_schema_via_pixlet(str(star)) is None
def test_a_missing_star_file_is_still_an_error(self, renderer, tmp_path):
ok, schema, error = renderer.extract_schema(str(tmp_path / "gone.star"))
assert ok is False and schema is None and "not found" in error
def test_an_unexpected_pixlet_shape_is_not_trusted(self, renderer, tmp_path):
"""A bare list, or a dict with no schema array, must not reach the UI."""
star = tmp_path / "app.star"
star.write_text("#\n", encoding="utf-8")
with patch.object(subprocess, "run",
return_value=_completed(stdout='["not", "a", "schema"]')):
assert renderer.extract_schema_via_pixlet(str(star)) is None
+467
View File
@@ -0,0 +1,467 @@
"""Tests for the shared scroll configuration resolver."""
import logging
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.common.scroll_config import ( # noqa: E402
DEFAULT_PIXELS_PER_SECOND,
MAX_PIXELS_PER_FRAME,
crisp_ladder,
solve_crisp,
MAX_PIXELS_PER_SECOND,
MIN_PIXELS_PER_SECOND,
ScrollSettings,
configure,
refresh_hz_from_config,
resolve,
)
class FakeHelper:
"""Records what configure() applied."""
def __init__(self, with_optional=True):
self.speed = None
self.frame_based = None
self.target_fps = None
if not with_optional:
del FakeHelper.set_frame_based_scrolling
del FakeHelper.set_target_fps
def set_scroll_speed(self, speed):
self.speed = speed
def set_frame_based_scrolling(self, enabled):
self.frame_based = enabled
def set_target_fps(self, fps):
self.target_fps = fps
class FakeDisplayManager:
"""Records the frame hold configure() applies."""
def __init__(self):
self.hold = None
def set_frame_hold(self, refreshes):
self.hold = refreshes
class MinimalHelper:
"""An older helper exposing only set_scroll_speed."""
def __init__(self):
self.speed = None
def set_scroll_speed(self, speed):
self.speed = speed
class TestPrecedence:
def test_display_options_pair_wins(self):
s = resolve({"display_options": {"scroll_speed": 1.0, "scroll_delay": 0.01}})
assert s.pixels_per_second == 100.0
assert s.source == "display_options.scroll_speed/delay"
def test_display_block_used_when_options_absent(self):
s = resolve({"display": {"scroll_speed": 2.0, "scroll_delay": 0.01}})
assert s.pixels_per_second == 200.0
assert s.source == "display.scroll_speed/delay"
def test_root_pair_used_when_both_blocks_absent(self):
s = resolve({"scroll_speed": 1.0, "scroll_delay": 0.02})
assert s.pixels_per_second == 50.0
def test_pixels_per_second_used_when_no_pair_given(self):
s = resolve({"display_options": {"scroll_pixels_per_second": 120.0}})
assert s.pixels_per_second == 120.0
assert s.source == "display_options.scroll_pixels_per_second"
def test_global_display_is_the_last_resort_before_default(self):
s = resolve({}, {"display": {"scroll_speed": 1.0, "scroll_delay": 0.005}})
assert s.pixels_per_second == 200.0
def test_default_when_nothing_configured(self):
s = resolve({}, {})
assert s.pixels_per_second == DEFAULT_PIXELS_PER_SECOND
assert s.source == "default"
class TestDeprecatedKeyCannotOverrideExplicitPair:
"""Regression for ChuckBuilds/ledmatrix-plugins#408.
odds-ticker ranked scroll_pixels_per_second above the documented
scroll_speed/scroll_delay pair. Because that key carries a schema default,
the documented settings became unreachable for every user and the ticker
silently ran at the default speed. The pair must win.
"""
def test_pair_beats_pixels_per_second_in_the_same_block(self):
s = resolve(
{
"display_options": {
"scroll_speed": 1.0,
"scroll_delay": 0.01,
"scroll_pixels_per_second": 50.0, # schema default
}
}
)
assert s.pixels_per_second == 100.0
assert "scroll_speed/delay" in s.source
def test_pair_beats_pixels_per_second_in_an_outer_block(self):
s = resolve(
{
"display_options": {"scroll_speed": 1.0, "scroll_delay": 0.01},
"scroll_pixels_per_second": 50.0,
}
)
assert s.pixels_per_second == 100.0
class TestMalformedValues:
@pytest.mark.parametrize("bad", [None, "fast", "", {}, [], float("nan")])
def test_unusable_speed_falls_through(self, bad):
s = resolve({"display_options": {"scroll_speed": bad, "scroll_delay": 0.01}})
assert s.pixels_per_second == DEFAULT_PIXELS_PER_SECOND
@pytest.mark.parametrize("bad", [0, -5, 0.0])
def test_non_positive_values_fall_through(self, bad):
s = resolve({"display_options": {"scroll_pixels_per_second": bad}})
assert s.pixels_per_second == DEFAULT_PIXELS_PER_SECOND
def test_booleans_are_not_treated_as_numbers(self):
s = resolve({"display_options": {"scroll_pixels_per_second": True}})
assert s.pixels_per_second == DEFAULT_PIXELS_PER_SECOND
def test_zero_delay_does_not_divide_by_zero(self):
s = resolve({"display_options": {"scroll_speed": 1.0, "scroll_delay": 0}})
assert s.pixels_per_second == DEFAULT_PIXELS_PER_SECOND
def test_non_dict_blocks_are_ignored(self):
s = resolve({"display_options": "nonsense", "display": 5})
assert s.pixels_per_second == DEFAULT_PIXELS_PER_SECOND
class TestClamping:
def test_absurdly_fast_is_clamped(self):
s = resolve({"display_options": {"scroll_pixels_per_second": 100000.0}})
assert s.pixels_per_second == MAX_PIXELS_PER_SECOND
assert s.warning and "clamped" in s.warning
def test_absurdly_slow_is_clamped(self):
s = resolve({"display_options": {"scroll_pixels_per_second": 0.01}})
assert s.pixels_per_second == MIN_PIXELS_PER_SECOND
class TestWholePixelWarning:
"""The display rule: whole pixels per refresh, or it judders."""
def test_no_warning_when_speed_divides_evenly(self):
s = resolve({"display_options": {"scroll_pixels_per_second": 100.0}}, refresh_hz=100)
assert s.warning is None
assert s.pixels_per_frame == pytest.approx(1.0)
def test_no_warning_at_an_integer_multiple(self):
s = resolve({"display_options": {"scroll_pixels_per_second": 200.0}}, refresh_hz=100)
assert s.warning is None
def test_warns_on_a_half_pixel_per_frame(self):
s = resolve({"display_options": {"scroll_pixels_per_second": 50.0}}, refresh_hz=100)
assert s.warning is not None
assert "judder" in s.warning
assert "100 px/s" in s.warning
def test_warns_on_a_fractional_multiple(self):
s = resolve({"display_options": {"scroll_pixels_per_second": 130.0}}, refresh_hz=100)
assert s.warning is not None
def test_respects_a_non_default_refresh_rate(self):
s = resolve({"display_options": {"scroll_pixels_per_second": 150.0}}, refresh_hz=150)
assert s.warning is None
assert s.pixels_per_frame == pytest.approx(1.0)
class TestConfigure:
def test_applies_time_based_mode_and_speed(self):
helper = FakeHelper()
s = configure(helper, {"display_options": {"scroll_speed": 1.0, "scroll_delay": 0.01}})
assert helper.speed == 100.0
assert helper.frame_based is False, "must not use the wall-clock step gate"
assert helper.target_fps == 100.0
assert s.pixels_per_second == 100.0
def test_works_against_a_helper_without_optional_methods(self):
helper = MinimalHelper()
configure(helper, {"display_options": {"scroll_pixels_per_second": 100.0}})
assert helper.speed == 100.0
def test_snapping_removes_the_judder_warning(self, caplog):
"""50px/s cannot be shown in whole pixels at 100Hz *per refresh*, but it
can as 1px every 2nd refresh -- so once snapped there is nothing to warn
about. resolve() alone still warns; configure() resolves it."""
assert "judder" in resolve(
{"display_options": {"scroll_pixels_per_second": 50.0}},
refresh_hz=100).warning
helper, dm = FakeHelper(), FakeDisplayManager()
with caplog.at_level(logging.WARNING):
settings = configure(
helper, {"display_options": {"scroll_pixels_per_second": 50.0}},
display_manager=dm)
assert settings.warning is None
assert not [r for r in caplog.records if "judder" in r.getMessage()]
def test_reports_the_hold_without_applying_it(self):
"""The hold belongs to a scroll, not a plugin's lifetime -- one left set
at construction is wiped as soon as any other plugin stops scrolling.
configure() reports it; the caller passes it to set_scrolling_state."""
dm = FakeDisplayManager()
s = configure(FakeHelper(), {"display_options": {"scroll_pixels_per_second": 25.0}},
display_manager=dm)
assert s.frame_hold == 4, "25px/s at 100Hz is 1px every 4th refresh"
assert dm.hold is None, "must not apply the hold behind the caller's back"
def test_full_speed_needs_no_hold(self):
s = configure(FakeHelper(), {"display_options": {"scroll_pixels_per_second": 100.0}},
display_manager=FakeDisplayManager())
assert s.frame_hold == 1
def test_frame_hold_is_one_when_snapping_is_off(self):
s = configure(FakeHelper(), {"display_options": {"scroll_pixels_per_second": 50.0}},
snap_to_crisp=False)
assert s.frame_hold == 1
def test_snaps_to_the_nearest_crisp_speed_and_reports_both(self):
s = configure(FakeHelper(), {"display_options": {"scroll_pixels_per_second": 45.0}},
display_manager=FakeDisplayManager())
assert s.requested_pixels_per_second == 45.0
assert s.pixels_per_second == 50.0
assert s.crisp is not None and s.crisp.pixels_per_frame == 1
def test_snapping_can_be_turned_off(self):
s = configure(FakeHelper(), {"display_options": {"scroll_pixels_per_second": 45.0}},
snap_to_crisp=False)
assert s.pixels_per_second == 45.0
assert s.crisp is None
def test_helper_target_fps_matches_the_presentation_rate(self):
"""At a hold of 4 the panel still refreshes at 100Hz, but frames are
presented at 25/s -- that is the rate the helper should pace to."""
helper = FakeHelper()
configure(helper, {"display_options": {"scroll_pixels_per_second": 25.0}},
display_manager=FakeDisplayManager())
assert helper.target_fps == pytest.approx(25.0)
def test_returns_settings_describing_the_source(self):
s = configure(FakeHelper(), {"display_options": {"scroll_speed": 2.0, "scroll_delay": 0.01}})
assert isinstance(s, ScrollSettings)
assert "200.0 px/s" in s.describe()
class TestRefreshFromConfig:
def test_reads_the_hardware_limit(self):
assert refresh_hz_from_config(
{"display": {"hardware": {"limit_refresh_rate_hz": 150}}}
) == 150.0
@pytest.mark.parametrize("cfg", [None, {}, {"display": {}}, {"display": {"hardware": {}}},
"nonsense", {"display": {"hardware": {"limit_refresh_rate_hz": None}}}])
def test_falls_back_to_the_default(self, cfg):
assert refresh_hz_from_config(cfg) == 100.0
@pytest.mark.parametrize("cfg", [
{"display": "nonsense"},
{"display": ["nonsense"]},
{"display": 60},
{"display": {"hardware": "nonsense"}},
{"display": {"hardware": ["nonsense"]}},
{"display": {"hardware": 60}},
])
def test_malformed_nesting_falls_back_rather_than_raising(self, cfg):
# Truthy-but-not-a-mapping at either level used to reach .get() on a
# str/list and raise AttributeError out of a function whose whole
# contract is "a refresh rate, or the default".
assert refresh_hz_from_config(cfg) == 100.0
class TestCrispLadder:
"""Whole-pixel speeds available on a given panel."""
def test_every_entry_is_exactly_reachable(self):
for c in crisp_ladder(100):
assert c.pixels_per_second == pytest.approx(
c.refresh_hz / c.frame_hold * c.pixels_per_frame)
def test_sorted_slowest_first(self):
speeds = [c.pixels_per_second for c in crisp_ladder(100)]
assert speeds == sorted(speeds)
def test_no_duplicate_speeds(self):
speeds = [round(c.pixels_per_second, 3) for c in crisp_ladder(100)]
assert len(speeds) == len(set(speeds))
def test_duplicates_resolve_to_the_smaller_step(self):
"""100px/s is 1px every refresh or 2px every 2nd; prefer the former."""
entry = next(c for c in crisp_ladder(100)
if c.pixels_per_second == pytest.approx(100.0))
assert entry.pixels_per_frame == 1
assert entry.frame_hold == 1
def test_ladder_scales_with_the_panel(self):
assert any(c.pixels_per_second == pytest.approx(60.0)
for c in crisp_ladder(60))
assert any(c.pixels_per_second == pytest.approx(30.0)
for c in crisp_ladder(60))
def test_full_refresh_speed_is_present(self):
for hz in (60, 75, 100, 120):
assert any(c.pixels_per_second == pytest.approx(float(hz))
for c in crisp_ladder(hz))
class TestSolveCrisp:
def test_exact_targets_are_matched_exactly(self):
for target in (100, 50, 25, 20):
assert solve_crisp(target, 100).pixels_per_second == pytest.approx(target)
def test_prefers_smooth_motion_over_raw_proximity(self):
"""30 -> 33.3 (1px, 33fps), not 28.6 (2px at 14fps) which is nearer."""
got = solve_crisp(30, 100)
assert got.pixels_per_second == pytest.approx(33.333, abs=0.01)
assert got.pixels_per_frame == 1
def test_does_not_chase_a_jumpy_exact_match(self):
"""45 -> 50 (1px, smooth) beats 42.9 (3px at 14fps)."""
assert solve_crisp(45, 100).pixels_per_frame == 1
def test_allows_a_two_pixel_step_when_it_is_clearly_closer(self):
"""60 -> 66.7 (2px at 33fps) rather than 50 (1px) which is 17% slow."""
got = solve_crisp(60, 100)
assert got.pixels_per_second == pytest.approx(66.667, abs=0.01)
assert got.pixels_per_frame == 2
def test_result_is_always_on_the_ladder(self):
ladder = {round(c.pixels_per_second, 3) for c in crisp_ladder(100)}
for target in range(5, 205, 5):
assert round(solve_crisp(target, 100).pixels_per_second, 3) in ladder
def test_adapts_to_a_slower_panel(self):
got = solve_crisp(30, 60)
assert got.pixels_per_second == pytest.approx(30.0)
assert got.pixels_per_frame == 1
assert got.frame_hold == 2
def test_speeds_beyond_the_panel_clamp_to_the_fastest_available(self):
got = solve_crisp(10_000, 100)
assert got.frame_hold == 1
assert got.pixels_per_frame == MAX_PIXELS_PER_FRAME
def test_steppiness_labels_are_sane(self):
assert solve_crisp(100, 100).steppiness == "smooth"
assert crisp_ladder(100)[0].steppiness == "stepped"
class TestRefreshFromDisplayManager:
"""A plugin sees only its own config section, so the display manager is the
authoritative source for the panel's refresh rate."""
class DM:
def __init__(self, hz):
self.refresh_hz = hz
self.hold = None
def set_frame_hold(self, refreshes):
self.hold = refreshes
def test_uses_the_display_manager_rate(self):
dm = self.DM(60.0)
s = configure(FakeHelper(), {"display_options": {"scroll_pixels_per_second": 30.0}},
display_manager=dm)
# 30px/s on a 60Hz panel is 1px every 2nd refresh, exactly.
assert s.pixels_per_second == pytest.approx(30.0)
assert s.frame_hold == 2
def test_explicit_refresh_hz_wins_over_the_display_manager(self):
dm = self.DM(60.0)
s = configure(FakeHelper(), {"display_options": {"scroll_pixels_per_second": 25.0}},
display_manager=dm, refresh_hz=100)
assert s.frame_hold == 4, "25px/s at 100Hz is 1px every 4th refresh"
def test_missing_attribute_falls_back_to_the_default(self):
class Bare:
def set_frame_hold(self, refreshes):
self.hold = refreshes
bare = Bare()
s = configure(FakeHelper(), {"display_options": {"scroll_pixels_per_second": 50.0}},
display_manager=bare)
assert s.frame_hold == 2, "assumed 100Hz"
def test_reported_settings_describe_the_display_manager_rate(self):
"""Without snapping, nothing downstream corrects a wrong refresh rate.
The rate used to be read *after* resolve() had already filled in
target_fps, pixels_per_frame and the judder warning from the 100Hz
default -- so on a 60Hz panel every one of those described 100Hz, and
set_target_fps() paced the helper to 100 FPS.
"""
dm = self.DM(60.0)
helper = FakeHelper()
s = configure(helper, {"display_options": {"scroll_pixels_per_second": 30.0}},
display_manager=dm, snap_to_crisp=False)
assert s.target_fps == pytest.approx(60.0)
assert s.pixels_per_frame == pytest.approx(0.5), "30px/s over 60 frames"
assert helper.target_fps == pytest.approx(60.0)
def test_judder_warning_is_computed_at_the_real_refresh_rate(self):
dm = self.DM(60.0)
# 60px/s is exactly 1px per refresh at 60Hz -- crisp, no warning. At
# the 100Hz default it is 0.6px per refresh and would be flagged.
s = configure(FakeHelper(), {"display_options": {"scroll_pixels_per_second": 60.0}},
display_manager=dm, snap_to_crisp=False)
assert s.warning is None, s.warning
def test_global_config_supplies_the_rate_without_a_display_manager(self):
s = configure(
FakeHelper(),
{"display_options": {"scroll_pixels_per_second": 30.0}},
{"display": {"hardware": {"limit_refresh_rate_hz": 60}}},
snap_to_crisp=False,
)
assert s.target_fps == pytest.approx(60.0)
class TestFrameHoldIsReportedNotApplied:
"""configure() reports the hold; the caller applies it when it scrolls.
The hold belongs to a scroll, not to a plugin's lifetime -- plugins share
one display manager, so one set at construction is reset the moment any
other plugin stops scrolling.
"""
class RecordingDM:
refresh_hz = 100.0
def __init__(self):
self.calls = []
def set_scrolling_state(self, is_scrolling, frame_hold=1):
self.calls.append((is_scrolling, frame_hold))
def set_frame_hold(self, refreshes):
self.calls.append(("set_frame_hold", refreshes))
def test_configure_does_not_touch_the_display_manager(self):
dm = self.RecordingDM()
settings = configure(
FakeHelper(), {"display_options": {"scroll_pixels_per_second": 25.0}},
display_manager=dm)
assert dm.calls == [], "configure() must not apply the hold itself"
assert settings.frame_hold == 4, "but it must report what to apply"
+79 -1
View File
@@ -11,7 +11,11 @@ import time
from unittest.mock import patch
from PIL import Image
from src.common.scroll_helper import ScrollHelper
from src.common.scroll_helper import (
ScrollHelper,
format_frame_stats,
frame_stats,
)
DISPLAY_W = 64
@@ -315,3 +319,77 @@ class TestGetScrollInfo:
helper.scroll_position = 42.0
info = helper.get_scroll_info()
assert info["scroll_position"] == 42.0
class TestFrameStatsPercentiles:
"""The stats line is the instrument this whole scroll change is measured
with, so its median and p95 have to be the real ones.
Both are also thresholds: stalls are counted at 1.5x the median and skips
at 0.5x, so an off-by-one in the median biases the counts as well as the
printed numbers.
"""
# 100 samples of 1..100ms. True median 50.5ms (the mean of the two middle
# samples, not the upper one at 51ms); nearest-rank p95 is the 95th
# sample at 95ms, not the 96th at 96ms.
HUNDRED = [i / 1000.0 for i in range(1, 101)]
def test_even_window_median_averages_both_middle_samples(self):
assert frame_stats(self.HUNDRED)["median"] == pytest.approx(0.0505)
def test_p95_uses_nearest_rank(self):
assert frame_stats(self.HUNDRED)["p95"] == pytest.approx(0.095)
def test_odd_window_median_is_the_middle_sample(self):
times = [i / 1000.0 for i in range(1, 102)] # 101 samples
assert frame_stats(times)["median"] == pytest.approx(0.051)
def test_single_sample_window_does_not_index_out_of_range(self):
stats = frame_stats([0.010])
assert stats["median"] == pytest.approx(0.010)
assert stats["p95"] == pytest.approx(0.010)
assert stats["min"] == stats["max"] == pytest.approx(0.010)
def test_two_sample_window(self):
stats = frame_stats([0.010, 0.020])
assert stats["median"] == pytest.approx(0.015)
assert stats["p95"] == pytest.approx(0.020)
def test_input_order_does_not_matter(self):
assert frame_stats(list(reversed(self.HUNDRED))) == frame_stats(self.HUNDRED)
def test_caller_window_is_not_mutated(self):
times = [0.030, 0.010, 0.020]
frame_stats(times)
assert times == [0.030, 0.010, 0.020]
def test_stall_threshold_follows_the_median(self):
# Ten 10ms frames and two 30ms stalls: median 10ms, so >15ms is a
# stall -- exactly the two.
stats = frame_stats([0.010] * 10 + [0.030] * 2)
assert stats["stalls"] == 2
assert stats["skips"] == 0
def test_skips_are_frames_that_never_reached_the_panel(self):
stats = frame_stats([0.010] * 10 + [0.002] * 3)
assert stats["skips"] == 3
assert stats["stalls"] == 0
def test_fps_is_the_reciprocal_of_the_mean(self):
assert frame_stats([0.010] * 50)["fps"] == pytest.approx(100.0)
def test_formatted_line_reports_the_corrected_values(self):
line = format_frame_stats(self.HUNDRED)
assert "median 50.50ms" in line, line
assert "p95 95.00ms" in line, line
assert "over 100 frames" in line, line
def test_log_frame_rate_emits_the_line_and_clears_the_window(self, helper):
helper._window = [0.010] * 20
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 == []
+121 -48
View File
@@ -21,8 +21,6 @@ sys.modules.setdefault("rgbmatrix", MagicMock())
from src.common.sports_scroll import ( # noqa: E402
DEFAULT_SCROLL_SETTINGS,
MAX_PIXELS_PER_FRAME,
MIN_PIXELS_PER_FRAME,
SportsScrollDisplay,
SportsScrollDisplayManager,
)
@@ -187,29 +185,48 @@ class TestScrollSettings:
# ---------------------------------------------------------------------------
class TestConfigureScrollHelper:
def test_speed_is_converted_to_pixels_per_frame(self, build):
display = build({"nhl": {"scroll_settings": {
"scroll_speed": 100.0, "scroll_delay": 0.02}}})
# 100 px/s * 0.02 s/frame = 2 px/frame
display.scroll_helper.set_scroll_speed.assert_called_with(2.0)
"""Pacing now comes from scroll_config, the same resolver every other
scrolling plugin uses. What this class guards is the translation into it,
because the two modules read identically-named keys differently."""
def test_conversion_is_clamped_low(self, build):
display = build({"nhl": {"scroll_settings": {
"scroll_speed": 0.001, "scroll_delay": 0.001}}})
display.scroll_helper.set_scroll_speed.assert_called_with(MIN_PIXELS_PER_FRAME)
def test_config_speed_is_pixels_per_second_not_pixels_per_step(self, build):
"""The collision that makes a naive hand-off wrong by 1/scroll_delay.
def test_conversion_is_clamped_high(self, build):
sports config states scroll_speed in px/SECOND and uses scroll_delay
only to reach px/frame. scroll_config states it in px per STEP, so it
computes px/s as speed/delay. Handing this module's settings dict
straight to the resolver turns 50 px/s into 5000 px/s, which its own
bounds then clamp to 500 -- a tenfold speed-up on every scoreboard.
"""
display = build({"nhl": {"scroll_settings": {
"scroll_speed": 5000.0, "scroll_delay": 0.5}}})
display.scroll_helper.set_scroll_speed.assert_called_with(MAX_PIXELS_PER_FRAME)
"scroll_speed": 50.0, "scroll_delay": 0.01}}})
applied = display.scroll_helper.set_scroll_speed.call_args[0][0]
assert applied == pytest.approx(50.0, abs=1.0), (
f"applied {applied} px/s; 500 means the settings dict was passed "
"through to the resolver instead of a plain px/s")
def test_zero_delay_assumes_a_pacing_instead_of_dividing_by_zero(self, build):
display = build({"nhl": {"scroll_settings": {
"scroll_speed": 100.0, "scroll_delay": 0}}})
display.scroll_helper.set_scroll_speed.assert_called_with(1.0)
def test_speed_is_snapped_to_whole_pixel_motion(self, build):
"""50 px/s on a 100Hz panel is half a pixel per refresh, which cannot
render as motion -- it alternates 0 and 1 px steps and judders. The
ladder keeps the speed and holds each frame for two refreshes."""
display = build()
assert display._scroll_settings.frame_hold == 2
assert display._scroll_settings.pixels_per_second == pytest.approx(50.0)
def test_frame_based_scrolling_is_enabled(self, build):
build().scroll_helper.set_frame_based_scrolling.assert_called_once_with(True)
def test_the_resolved_hold_is_what_gets_published(self, build):
display = build()
assert display._scroll_frame_hold() == 2
def test_hold_defaults_to_one_before_anything_is_resolved(self, display_manager):
bare = SportsScrollDisplay.__new__(SportsScrollDisplay)
assert SportsScrollDisplay._scroll_frame_hold(bare) == 1
def test_stepping_is_time_based(self, build):
"""Frame-based mode gated motion on a wall clock at 1/scroll_delay
steps, with scroll_delay set to the frame period -- putting the
comparison exactly on its own threshold, so it flipped on sub-
millisecond jitter."""
build().scroll_helper.set_frame_based_scrolling.assert_called_once_with(False)
def test_dynamic_duration_settings_are_applied(self, build):
display = build({"nhl": {"scroll_settings": {
@@ -219,32 +236,38 @@ class TestConfigureScrollHelper:
assert kwargs["min_duration"] == 5
assert kwargs["max_duration"] == 50
def test_target_fps_reaches_the_helper(self, build):
"""The whole point of upstreaming: the bundled copies hardcode ~100 FPS
via scroll_delay and never consult the global target."""
display = build(global_config={"target_fps": 120})
display.scroll_helper.set_target_fps.assert_called_once_with(120.0)
def test_zero_delay_still_means_pixels_per_frame(self, build):
"""The one case where scroll_speed is not already px/s."""
display = build({"nhl": {"scroll_settings": {
"scroll_speed": 1.0, "scroll_delay": 0}}})
applied = display.scroll_helper.set_scroll_speed.call_args[0][0]
assert applied == pytest.approx(100.0, abs=1.0)
def test_global_target_fps_sets_the_refresh_the_ladder_uses(self, build):
"""Under the old model this key was the rate frames were presented at,
so it is the faithful translation of it into a panel refresh."""
display = build(global_config={"target_fps": 60})
assert display._resolve_refresh_hz() == 60.0
def test_legacy_key_is_honored(self, build):
display = build(global_config={"scroll_target_fps": 90})
display.scroll_helper.set_target_fps.assert_called_once_with(90.0)
assert display._resolve_refresh_hz() == 90.0
def test_modern_key_wins_over_legacy(self, build):
display = build(global_config={"target_fps": 120, "scroll_target_fps": 90})
display.scroll_helper.set_target_fps.assert_called_once_with(120.0)
assert display._resolve_refresh_hz() == 120.0
def test_absent_target_fps_leaves_config_pacing_alone(self, build):
build().scroll_helper.set_target_fps.assert_not_called()
def test_configured_hardware_refresh_wins_over_the_fps_target(self, build):
display = build(global_config={
"target_fps": 60,
"display": {"hardware": {"limit_refresh_rate_hz": 120}}})
assert display._resolve_refresh_hz() == 120.0
@pytest.mark.parametrize("bad", ["fast", None, {}, [], "", 0])
def test_unusable_target_fps_degrades_instead_of_raising(self, build, bad):
"""A malformed global config must cost the FPS target, not the display."""
display = build(global_config={"target_fps": bad})
display.scroll_helper.set_target_fps.assert_not_called()
def test_string_digits_are_accepted(self, build):
display = build(global_config={"target_fps": "120"})
display.scroll_helper.set_target_fps.assert_called_once_with(120.0)
assert display._scroll_settings.pixels_per_second > 0
@pytest.mark.parametrize("bad", [None, "fast", {}, []])
def test_unusable_scroll_speed_degrades_instead_of_crashing(self, build, bad):
@@ -252,24 +275,63 @@ class TestConfigureScrollHelper:
present with null reaches the arithmetic and raises inside __init__,
taking the whole display down before it renders anything."""
display = build({"nhl": {"scroll_settings": {"scroll_speed": bad}}})
# 50.0 px/s * 0.01 s/frame == 0.5 px/frame, i.e. the default speed.
display.scroll_helper.set_scroll_speed.assert_called_with(0.5)
applied = display.scroll_helper.set_scroll_speed.call_args[0][0]
assert applied == pytest.approx(50.0, abs=1.0)
@pytest.mark.parametrize("bad", [None, "slow", {}])
def test_unusable_scroll_delay_degrades_instead_of_crashing(self, build, bad):
display = build({"nhl": {"scroll_settings": {"scroll_delay": bad}}})
display.scroll_helper.set_scroll_delay.assert_called_with(0.01)
applied = display.scroll_helper.set_scroll_speed.call_args[0][0]
assert applied == pytest.approx(50.0, abs=1.0)
def test_numeric_strings_are_accepted(self, build):
display = build({"nhl": {"scroll_settings": {
"scroll_speed": "100", "scroll_delay": "0.02"}}})
display.scroll_helper.set_scroll_speed.assert_called_with(2.0)
applied = display.scroll_helper.set_scroll_speed.call_args[0][0]
assert applied == pytest.approx(100.0, abs=1.0)
class TestScrollingStateIsPublished:
"""The core has to be told, or the frame hold is never applied and
deferred work runs in the middle of the scroll."""
def _drawable(self, display):
display.scroll_helper.cached_image = Image.new("RGB", (400, 32))
display.scroll_helper.get_visible_portion.return_value = Image.new(
"RGB", (128, 32))
def test_a_drawn_frame_declares_the_hold(self, build, display_manager):
display = build()
self._drawable(display)
assert display.display_scroll_frame() is True
display_manager.set_scrolling_state.assert_called_with(
True, frame_hold=display._scroll_frame_hold())
def test_completion_releases_the_state(self, build, display_manager):
display = build()
display.scroll_helper.is_scroll_complete.return_value = True
assert display.is_scroll_complete() is True
display_manager.set_scrolling_state.assert_called_with(False)
def test_an_incomplete_scroll_does_not_release(self, build, display_manager):
display = build()
display.scroll_helper.is_scroll_complete.return_value = False
display.is_scroll_complete()
assert (False,) not in [c.args for c in
display_manager.set_scrolling_state.call_args_list]
def test_clearing_releases_the_state(self, build, display_manager):
display = build()
display.clear()
display_manager.set_scrolling_state.assert_called_with(False)
def test_an_older_core_without_the_call_is_tolerated(self, build):
"""Plugins ship independently of the core they run against."""
display = build()
del display.display_manager.set_scrolling_state
self._drawable(display)
assert display.display_scroll_frame() is True
def test_fps_clamping_is_left_to_the_helper(self, build):
"""Deliberately not clamped here — a second copy of the range would
drift from ScrollHelper.set_target_fps."""
display = build(global_config={"target_fps": 5000})
display.scroll_helper.set_target_fps.assert_called_once_with(5000.0)
# ---------------------------------------------------------------------------
@@ -429,7 +491,11 @@ class TestManager:
never honoring target_fps."""
child = manager.get_scroll_display("live")
assert child.global_config == {"target_fps": 120}
child.scroll_helper.set_target_fps.assert_called_once_with(120.0)
# The target is now the refresh the crisp ladder is computed against,
# so what proves the hand-off is that the child reasons about 120Hz --
# not the presentation rate, which the chosen hold divides down.
assert child._resolve_refresh_hz() == 120.0
child.scroll_helper.set_target_fps.assert_called_once()
def test_prepare_sets_the_active_type(self, manager):
assert manager.prepare_and_display([{"id": "g1"}], "live", ["nhl"]) is True
@@ -551,10 +617,17 @@ class TestAgainstTheRealScrollHelper:
)
def test_configuration_lands_on_the_real_helper(self, real):
helper = real.get_scroll_display("live").scroll_helper
assert helper.target_fps == 120.0
assert helper.frame_based_scrolling is True
assert helper.scroll_speed == pytest.approx(0.5) # 500 px/s * 0.001 s
display = real.get_scroll_display("live")
helper = display.scroll_helper
# 500 px/s on a 120Hz panel snaps to a whole number of pixels per
# refresh, and the helper is driven in px/s in time-based mode.
assert helper.frame_based_scrolling is False
assert helper.scroll_speed == pytest.approx(
display._scroll_settings.pixels_per_second)
assert helper.scroll_speed == pytest.approx(500.0, abs=25.0)
# target_fps is the presentation rate the chosen hold produces.
assert helper.target_fps == pytest.approx(
120.0 / display._scroll_settings.frame_hold, abs=1.0)
def test_a_strip_is_built_and_scrolls_to_completion(self, real):
import time
+55
View File
@@ -319,3 +319,58 @@ class TestLiveMixin:
assert not hasattr(h, "_empty_live_streak")
h._note_live_fetch(False)
assert h._empty_live_streak == 1
class TestPluginDirIsToldNotDeduced:
"""The regression that shipped: _plugin_dir returned None in production.
The first version walked the MRO for a class whose module sits beside a
config_schema.json. That passes when a test imports the plugin directly --
which is how it was verified -- and returns None under the real plugin
loader, which imports modules by a path that leaves no such entry on the
MRO.
Silent, and expensive: no schema means _schema_font_size returns None for
every element, so a configured size equal to the schema default stops
looking like a default, is treated as a deliberate choice, and skips the
grid snap. 4x6-font.ttf then renders at 6 rather than 7 -- 3px-wide glyphs
instead of 4px. On a 256x64 panel that made the odds, the records and the
date row illegible. A user counted the pixels; no gate here caught it.
"""
def test_a_declared_directory_is_used(self, tmp_path):
d = _write_plugin(tmp_path, "declared")
host = type("H", (SportsCoreSharedMixin,), {"_PLUGIN_DIR": str(d)})()
assert host._plugin_dir() == str(d)
def test_it_works_when_no_module_on_the_mro_helps(self, tmp_path, monkeypatch):
"""The production case: nothing on the MRO sits beside a schema."""
d = _write_plugin(tmp_path, "loaderstyle")
# A class whose module is not importable by name, as the loader produces.
cls = type("Loaded", (SportsCoreSharedMixin,), {"_PLUGIN_DIR": str(d)})
cls.__module__ = "a.module.name.that.is.not.in.sys.modules"
assert cls.__new__(cls)._plugin_dir() == str(d), (
"the declared directory must win when the MRO walk cannot help")
def test_without_it_the_mro_walk_would_have_failed(self):
# Pin the precondition, so this test still means something if the
# fallback is ever changed.
cls = type("Orphan", (SportsCoreSharedMixin,), {})
cls.__module__ = "not.a.real.module"
assert cls.__new__(cls)._plugin_dir() is None
def test_a_declared_directory_without_a_schema_is_not_trusted(self, tmp_path):
# A stale or wrong path must not shadow the fallback.
empty = tmp_path / "noschema"
empty.mkdir()
d = _write_plugin(tmp_path, "realone")
monkey = type("H", (SportsCoreSharedMixin,), {"_PLUGIN_DIR": str(empty)})
assert monkey.__new__(monkey)._plugin_dir() != str(empty)
def test_the_font_size_consequence(self, tmp_path):
"""End to end: a declared directory restores the schema lookup."""
d = _write_plugin(tmp_path, "sizeconseq")
host = type("H", (SportsCoreSharedMixin,), {"_PLUGIN_DIR": str(d)})()
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")
+205
View File
@@ -0,0 +1,205 @@
"""starlark-apps: display() must report what actually reached the panel.
The display controller skips a mode only on a boolean False. Returning True
after the frame update failed told it the mode had rendered, so it held a dead
frame for the whole display_duration instead of rotating on -- the same class
of defect as a display() that returns None.
"""
import sys
import types
from pathlib import Path
from unittest.mock import MagicMock
import pytest
PLUGIN_DIR = Path(__file__).resolve().parent.parent / "plugin-repos" / "starlark-apps"
@pytest.fixture(scope="module")
def manager_module():
if not PLUGIN_DIR.exists():
pytest.skip("starlark-apps plugin is not checked out")
sys.path.insert(0, str(PLUGIN_DIR))
# fcntl is POSIX-only and the manager imports it at module scope for
# manifest locking. None of the display paths under test touch it, so a
# stub is what keeps these assertions running on a developer's machine
# rather than skipping everywhere but the Pi and CI.
injected_fcntl = "fcntl" not in sys.modules
if injected_fcntl:
stub = types.ModuleType("fcntl")
stub.LOCK_EX = 2
stub.LOCK_UN = 8
stub.flock = lambda *a, **kw: None
sys.modules["fcntl"] = stub
try:
import importlib
spec = importlib.util.spec_from_file_location(
"starlark_manager_under_test", PLUGIN_DIR / "manager.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
except Exception as e: # noqa: BLE001 - optional deps (Pillow, pixlet) may be absent
pytest.skip(f"starlark-apps manager is not importable here: {e}")
finally:
sys.path.remove(str(PLUGIN_DIR))
if injected_fcntl:
sys.modules.pop("fcntl", None)
def _plugin(manager_module):
"""A manager with __init__ bypassed -- only display paths are under test."""
cls = manager_module.StarlarkAppsPlugin
inst = cls.__new__(cls)
inst.logger = MagicMock()
inst.display_manager = MagicMock()
inst.current_app = None
return inst
class _App:
def __init__(self, frames):
self.frames = frames
self.current_frame_index = 0
self.last_frame_time = 0.0
self.app_id = "app"
class TestDisplayFramePropagates:
def test_a_failed_update_returns_false(self, manager_module):
p = _plugin(manager_module)
p.current_app = _App([("frame", 100)])
p.display_manager.update_display.side_effect = RuntimeError("panel gone")
assert p._display_frame() is False
def test_a_good_update_returns_true(self, manager_module):
p = _plugin(manager_module)
p.current_app = _App([("frame", 100)])
assert p._display_frame() is True
def test_no_frames_returns_false(self, manager_module):
p = _plugin(manager_module)
p.current_app = _App([])
assert p._display_frame() is False
def test_display_reports_the_frame_failure(self, manager_module):
p = _plugin(manager_module)
p.current_app = _App([("frame", 100)])
p.display_manager.update_display.side_effect = RuntimeError("panel gone")
result = p.display()
assert result is False, "display() claimed success over a failed frame update"
assert isinstance(result, bool), "the controller only skips on a real bool"
class _SelectableApp(_App):
"""An app that display() can pick, render and show."""
def __init__(self, app_id, enabled=True):
super().__init__(frames=[("frame", 100)])
self.app_id = app_id
self._enabled = enabled
def is_enabled(self):
return self._enabled
def _plugin_with_apps(manager_module, *app_ids):
p = _plugin(manager_module)
p.apps = {app_id: _SelectableApp(app_id) for app_id in app_ids}
p._display_frame = lambda: True
return p
class TestDisplayModeSelectsTheApp:
"""The controller passes the mode it is rotating to; the plugin must use it.
It inspects display()'s signature to decide whether to pass display_mode at
all, so the parameter is the whole mechanism -- without it there is no way
to address one specific app, including from an on-demand request pinned to
one.
"""
def test_display_accepts_display_mode(self, manager_module):
import inspect
params = inspect.signature(manager_module.StarlarkAppsPlugin.display).parameters
assert "display_mode" in params, \
"the controller only passes display_mode to plugins whose signature accepts it"
def test_a_named_app_is_the_one_shown(self, manager_module):
p = _plugin_with_apps(manager_module, "aquarium", "printer", "nowplaying")
assert p.display(display_mode="printer") is True
assert p.current_app.app_id == "printer"
def test_a_named_app_wins_over_the_one_already_showing(self, manager_module):
p = _plugin_with_apps(manager_module, "aquarium", "printer")
p.display(display_mode="aquarium")
p.display(display_mode="printer")
assert p.current_app.app_id == "printer"
def test_an_unknown_mode_falls_back_to_rotation(self, manager_module):
"""The plugin id arrives here when no per-app modes are exposed."""
p = _plugin_with_apps(manager_module, "aquarium")
assert p.display(display_mode="starlark-apps") is True
assert p.current_app.app_id == "aquarium"
def test_force_clear_does_not_override_a_named_app(self, manager_module):
p = _plugin_with_apps(manager_module, "aquarium", "printer")
p.display(display_mode="printer", force_clear=True)
assert p.current_app.app_id == "printer"
class TestInstalledAppsTakeTurns:
"""Every installed app gets shown, not just whichever was picked first.
_select_next_app ran only while current_app was unset, so the first enabled
app was chosen once and displayed forever; the rest were rendered on
schedule and never reached the panel.
"""
def test_each_entry_to_the_mode_advances(self, manager_module):
p = _plugin_with_apps(manager_module, "a", "b", "c")
seen = []
for _ in range(3):
p.display(force_clear=True) # force_clear = "you are up"
seen.append(p.current_app.app_id)
assert seen == ["a", "b", "c"]
def test_the_rotation_wraps(self, manager_module):
p = _plugin_with_apps(manager_module, "a", "b")
seen = []
for _ in range(4):
p.display(force_clear=True)
seen.append(p.current_app.app_id)
assert seen == ["a", "b", "a", "b"]
def test_frames_within_one_slot_do_not_advance_the_app(self, manager_module):
"""Only entry to the mode rotates -- the high-FPS loop must not."""
p = _plugin_with_apps(manager_module, "a", "b", "c")
p.display(force_clear=True)
for _ in range(20):
p.display(force_clear=False)
assert p.current_app.app_id == "a"
def test_a_disabled_app_is_skipped(self, manager_module):
p = _plugin_with_apps(manager_module, "a", "b")
p.apps["a"]._enabled = False
p.display(force_clear=True)
assert p.current_app.app_id == "b"
def test_no_enabled_apps_reports_nothing_displayed(self, manager_module):
p = _plugin_with_apps(manager_module, "a")
p.apps["a"]._enabled = False
assert p.display(force_clear=True) is False
class TestAnimationsRunAtFrameRate:
def test_the_plugin_asks_for_the_high_fps_loop(self, manager_module):
"""The controller reads this attribute; a multi-frame app is otherwise
called once per rotation slot and never advances past frame one."""
plugin = manager_module.StarlarkAppsPlugin
assert getattr(plugin, "enable_scrolling", False) is True
+4 -1
View File
@@ -470,7 +470,10 @@ class TestApiBoundsMatchValidate:
"""Extract the numeric_fields map from api_v3 without importing Flask."""
import ast
import pathlib
src = pathlib.Path('web_interface/blueprints/api_v3.py').read_text()
# api_v3 is a package; the routes are spread across its modules.
src = '\n'.join(
p.read_text() for p in
sorted(pathlib.Path('web_interface/blueprints/api_v3').glob('*.py')))
tree = ast.parse(src)
for node in ast.walk(tree):
if not isinstance(node, ast.Assign):
+4 -4
View File
@@ -390,7 +390,7 @@ class TestConfigAPI:
class TestSystemAPI:
"""Test system API endpoints."""
@patch('web_interface.blueprints.api_v3.subprocess')
@patch('web_interface.blueprints.api_v3.system.subprocess')
def test_get_system_status(self, mock_subprocess, client):
"""Test getting system status."""
# The endpoint returns 503 without psutil, which is an optional
@@ -407,7 +407,7 @@ class TestSystemAPI:
data = json.loads(response.data)
assert 'service' in data or 'status' in data or 'active' in data
@patch('web_interface.blueprints.api_v3.subprocess')
@patch('web_interface.blueprints.api_v3.system.subprocess')
def test_get_system_version(self, mock_subprocess, client):
"""Test getting system version."""
mock_result = MagicMock()
@@ -421,7 +421,7 @@ class TestSystemAPI:
data = json.loads(response.data)
assert 'version' in data.get('data', {}) or 'version' in data
@patch('web_interface.blueprints.api_v3.subprocess')
@patch('web_interface.blueprints.api_v3.system.subprocess')
def test_execute_system_action(self, mock_subprocess, client):
"""Test executing system action."""
mock_result = MagicMock()
@@ -502,7 +502,7 @@ class TestDisplayAPI:
if response.status_code in [200, 201]:
assert api_v3.cache_manager.set.called
@patch('web_interface.blueprints.api_v3._ensure_cache_manager')
@patch('web_interface.blueprints.api_v3.display._ensure_cache_manager')
def test_stop_on_demand_display(self, mock_ensure_cache, client):
"""Test stopping on-demand display."""
+59 -5
View File
@@ -114,9 +114,62 @@ class TestHandlersCarryDetail:
"""
import ast
src = open("web_interface/blueprints/api_v3.py").read()
# api_v3 is a package; the routes are spread across its modules.
import pathlib
src = "\n".join(
p.read_text() for p in
sorted(pathlib.Path("web_interface/blueprints/api_v3").glob("*.py")))
tree = ast.parse(src)
generic = "An error occurred; see logs for details"
# This used to match one exact message string, so a handler that wrote
# its own wording was never checked. All thirteen Starlark routes did
# -- "Failed to browse repository" and friends -- and every one of them
# answered a 500 with no detail at all, which is how the app store
# spent three releases failing for reasons nobody could read. The rule
# is now the shape that matters: if it returns 5xx, it says why.
PRE_EXISTING = {
# Not part of this change. This set may shrink, never grow.
'backup_delete', 'backup_export', 'backup_list', 'backup_preview',
'backup_restore', 'backup_validate', 'checkout_branch',
'execute_system_action', 'get_git_branches', 'get_git_info',
'get_hardware_status', 'get_logs', 'get_system_status',
'get_system_version', 'scan_wifi_networks',
}
def enclosing_function(handler):
"""Innermost function containing `handler`."""
best = None
for fn in [n for n in ast.walk(tree)
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))]:
if any(h is handler for h in ast.walk(fn)):
if best is None or fn.lineno > best.lineno:
best = fn
return best.name if best else '<module>'
def only_catches_importerror(handler):
"""An `except ImportError` arm and nothing else.
A missing optional dependency is a configuration fact, not a
crash: the module name is the whole diagnosis and it is already
in the response, so a stack trace would be noise. Detail is still
required -- only the traceback log is excused.
"""
t = handler.type
names = ([t] if isinstance(t, ast.Name)
else list(t.elts) if isinstance(t, ast.Tuple) else [])
return bool(names) and all(
isinstance(n, ast.Name) and n.id == 'ImportError' for n in names)
def returns_5xx(handler):
for r in [n for n in ast.walk(handler) if isinstance(n, ast.Return)]:
v = r.value
if isinstance(v, ast.Tuple) and len(v.elts) == 2:
code = v.elts[1]
if (isinstance(code, ast.Constant)
and isinstance(code.value, int)
and 500 <= code.value < 600):
return True
return False
def logs_a_traceback(handler):
"""An error/exception-level log call carrying exc_info."""
@@ -161,11 +214,12 @@ class TestHandlersCarryDetail:
offenders = []
for h in [n for n in ast.walk(tree) if isinstance(n, ast.ExceptHandler)]:
seg = ast.get_source_segment(src, h) or ""
if generic not in seg:
if not returns_5xx(h):
continue
if enclosing_function(h) in PRE_EXISTING:
continue
missing = []
if not logs_a_traceback(h):
if not logs_a_traceback(h) and not only_catches_importerror(h):
missing.append("error-level log with exc_info")
if not returns_the_detail(h):
missing.append("describe_exception(e) in the response")
+199
View File
@@ -0,0 +1,199 @@
"""Smooth motion is a per-frame property, not a frame-rate one.
Frame-time statistics on this project have twice looked perfect while a
marquee still visibly stuttered. They measure the wrong thing. What the eye
judges is whether the strip advances the SAME number of whole pixels on every
presented frame, and that is what these tests measure.
The old stepping derived position from ``scroll_speed * delta_time`` and then
truncated it to a pixel, so any jitter in delta_time landed either side of an
integer boundary. Against the frame times measured on hardware -- a rock-steady
100.0 fps whose individual frames still ranged 5.6ms to 15.2ms -- about 5.8% of
frames advanced 0 or 2 pixels instead of 1. Roughly six hitches a second.
"""
import random
from collections import Counter
from unittest.mock import patch
import numpy as np
import pytest
from PIL import Image
from src.common import scroll_config
from src.common.scroll_helper import ScrollHelper
#: Wide enough that no test consumes the strip. A scroll that completes freezes
#: at the end, and counting those frozen frames as "did not advance" is how the
#: first version of this measurement talked itself into a wrong answer.
STRIP_WIDTH = 60000
class _Panel:
"""A display manager that only has to answer the refresh question."""
refresh_hz = 100.0
def set_frame_hold(self, refreshes):
"""Recorded so a test can assert on it; the panel is not involved."""
self.hold = refreshes
def _helper():
"""A helper with a strip long enough that no sample can consume it."""
helper = ScrollHelper(128, 64)
helper.cached_image = Image.new("RGB", (STRIP_WIDTH, 64))
helper.cached_array = np.zeros((64, STRIP_WIDTH, 3), dtype=np.uint8)
helper.total_scroll_width = STRIP_WIDTH
return helper
def _measured_frame_times(count, seed=7):
"""Frame gaps shaped like the ones logged on hardware.
100.0 fps overall and a 10.00ms median, with the short and long tails that
a real vsync-paced loop actually produces.
"""
rng = random.Random(seed)
out = []
for _ in range(count):
roll = rng.random()
if roll < 0.04:
out.append(rng.uniform(5.6, 7.5))
elif roll < 0.08:
out.append(rng.uniform(13.0, 15.2))
else:
out.append(rng.gauss(10.0, 0.35))
return out
def _advances(helper, frame_times):
"""Histogram of whole-pixel movement per presented frame.
The first call is primed and discarded. update_scroll_position sets
last_update_time on its way through, so the very first call sees a
delta_time of zero and moves nothing in time-based mode. Counting that
synthetic frame put a guaranteed zero in every histogram, which was enough
on its own to satisfy "time-based stepping produces uneven motion" -- the
pin below would have passed against perfectly uniform motion.
"""
counts, now = Counter(), 1000.0
with patch("src.common.scroll_helper.time.time") as clock:
clock.return_value = now
helper.update_scroll_position()
last = int(helper.scroll_position)
for gap_ms in frame_times:
now += gap_ms / 1000.0
clock.return_value = now
helper.update_scroll_position()
position = int(helper.scroll_position)
counts[position - last] += 1
last = position
assert not helper.is_scroll_complete(), (
"the strip ran out mid-measurement; a finished scroll freezes and its "
"frozen frames read as zero-advance")
return counts
class TestWholePixelStepping:
def test_a_crisp_speed_moves_the_same_pixels_every_frame(self):
"""The whole point: identical movement on every frame, jitter or not."""
helper = _helper()
scroll_config.configure(helper, plugin_config=None,
default_pixels_per_second=100.0,
display_manager=_Panel(), refresh_hz=100.0)
counts = _advances(helper, _measured_frame_times(3000))
assert set(counts) == {1}, (
"frames advanced %s px; at a crisp speed every frame must move the "
"same whole number, or the motion beats against the frame rate"
% sorted(counts))
def test_the_wall_clock_is_not_consulted_at_all(self):
"""Jitter must not reach the position, however violent."""
helper = _helper()
scroll_config.configure(helper, plugin_config=None,
default_pixels_per_second=100.0,
display_manager=_Panel(), refresh_hz=100.0)
savage = [1.0, 40.0, 2.0, 25.0, 0.5, 60.0] * 200
assert set(_advances(helper, savage)) == {1}
def test_two_pixels_per_frame_is_also_uniform(self):
"""Uniformity is not special to one pixel; 200 px/s steps by two."""
helper = _helper()
scroll_config.configure(helper, plugin_config=None,
default_pixels_per_second=200.0,
display_manager=_Panel(), refresh_hz=100.0)
assert helper.fixed_pixels_per_frame == 2
assert set(_advances(helper, _measured_frame_times(2000))) == {2}
def test_time_based_stepping_is_what_it_replaces(self):
"""Pin the defect, so a revert cannot pass this file quietly."""
helper = _helper()
helper.set_scroll_speed(100.0) # the old path: px/s off the clock
assert helper.fixed_pixels_per_frame is None
counts = _advances(helper, _measured_frame_times(3000))
total = sum(counts.values())
uneven = sum(n for px, n in counts.items() if px != 1)
# A proportion, not "more than zero". Against these frame times the
# old path misses roughly one frame in twenty; 1% is comfortably below
# that and still far above anything a stray frame could produce, so
# this fails if the defect is genuinely gone rather than merely rare.
assert uneven > total * 0.01, (
"time-based stepping produced only %d uneven frames in %d against "
"real measured frame times. If that is genuinely fixed elsewhere, "
"this test and the fixed-step mode both deserve re-examining"
% (uneven, total))
class TestItStaysOptIn:
def test_a_speed_that_is_not_crisp_keeps_pacing_off_time(self):
"""Without a whole-pixel step to take, elapsed time is still the best
available answer -- fixed stepping is opt-in, not a global switch."""
helper = _helper()
scroll_config.configure(helper, plugin_config=None,
default_pixels_per_second=100.0,
display_manager=_Panel(), refresh_hz=100.0,
snap_to_crisp=False)
assert helper.fixed_pixels_per_frame is None
def test_setting_a_speed_directly_drops_the_fixed_step(self):
"""Otherwise the new speed is silently ignored."""
helper = _helper()
scroll_config.configure(helper, plugin_config=None,
default_pixels_per_second=100.0,
display_manager=_Panel(), refresh_hz=100.0)
assert helper.fixed_pixels_per_frame == 1
helper.set_scroll_speed(37.0)
assert helper.fixed_pixels_per_frame is None
assert helper.scroll_speed == 37.0
@pytest.mark.parametrize("value", [None, 0])
def test_clearing_the_step_restores_time_based_motion(self, value):
"""Zero and None both mean "no fixed step", not "advance zero pixels"."""
helper = _helper()
helper.set_pixels_per_frame(3)
helper.set_pixels_per_frame(value)
assert helper.fixed_pixels_per_frame is None
def test_an_older_core_without_the_setter_still_configures(self):
"""Plugins ship independently of the core they run against."""
class Old:
"""A helper from before set_pixels_per_frame existed."""
scroll_speed = None
def set_scroll_speed(self, v):
"""The one call this old helper does understand."""
self.scroll_speed = v
def set_frame_based_scrolling(self, v):
"""Accepted and ignored; the mode is not what is under test."""
def set_target_fps(self, v):
"""Accepted and ignored; the rate is not what is under test."""
old = Old()
scroll_config.configure(old, plugin_config=None,
default_pixels_per_second=100.0,
display_manager=_Panel(), refresh_hz=100.0)
assert old.scroll_speed == 100.0
@@ -230,7 +230,7 @@ class TestPluginReinstall:
def test_missing_store_manager_is_reported_per_plugin(self, client, restore):
restore.return_value = FakeResult(plugins_to_install=[{"plugin_id": "clock"}])
api_v3.plugin_store_manager = None
with patch("web_interface.blueprints.api_v3.plugin_store_manager", None):
with patch("web_interface.blueprints.api_v3.backup.plugin_store_manager", None):
body = post(client).get_json()
assert body["data"]["plugins_failed"][0]["error"] == "Store manager unavailable"
@@ -77,7 +77,10 @@ class TestTheRoutesExistAtAll:
oauth = Path(project_root) / 'web_interface/static/v3/js/widgets/google-oauth.js'
assert '/api/v3/plugins/calendar/list-calendars' in picker.read_text(encoding='utf-8')
assert '/api/v3/plugins/calendar/authenticate' in oauth.read_text(encoding='utf-8')
source = (Path(project_root) / 'web_interface/blueprints/api_v3.py').read_text(encoding='utf-8')
# api_v3 is a package now, so the route strings are spread across its
# modules; read the whole directory rather than one file.
pkg = Path(project_root) / 'web_interface/blueprints/api_v3'
source = "\n".join(f.read_text(encoding='utf-8') for f in sorted(pkg.glob('*.py')))
assert "'/plugins/calendar/list-calendars'" in source
assert "'/plugins/calendar/authenticate'" in source
@@ -10,8 +10,8 @@ from pathlib import Path
import pytest
SOURCE = (Path(__file__).resolve().parents[2]
/ "web_interface" / "blueprints" / "api_v3.py")
API_V3_PKG = (Path(__file__).resolve().parents[2]
/ "web_interface" / "blueprints" / "api_v3")
#: Objects that still hold submitted secret values at the point these log
#: calls run. Interpolating one whole into a log message leaks credentials.
@@ -19,7 +19,7 @@ UNREDACTED = ("plugin_config", "secrets_config", "current_secrets")
def _logging_lines():
for number, line in enumerate(SOURCE.read_text(encoding="utf-8").splitlines(), 1):
for number, line in enumerate("\n".join(p.read_text(encoding="utf-8") for p in sorted(API_V3_PKG.glob("*.py"))).splitlines(), 1):
stripped = line.strip()
if stripped.startswith("#"):
continue
@@ -15,8 +15,18 @@ from pathlib import Path
from src.web_interface.secret_helpers import find_secret_fields, separate_secrets
API_V3_PATH = (Path(__file__).resolve().parents[2]
/ "web_interface" / "blueprints" / "api_v3.py")
API_V3_PKG = (Path(__file__).resolve().parents[2]
/ "web_interface" / "blueprints" / "api_v3")
def _api_v3_source() -> str:
"""Every module of the api_v3 package as one string.
It used to be a single file; the inline copies this guards against could
now reappear in any module of the package.
"""
return "\n".join(p.read_text(encoding="utf-8")
for p in sorted(API_V3_PKG.glob("*.py")))
# The migration is complete: any inline reimplementation is a regression.
EXPECTED_INLINE_COPIES = 0
@@ -24,7 +34,7 @@ EXPECTED_INLINE_COPIES = 0
class TestNoInlineCopies:
def _count(self, name: str) -> int:
source = API_V3_PATH.read_text(encoding="utf-8")
source = _api_v3_source()
return len(re.findall(rf"^\s*def {name}\(", source, flags=re.MULTILINE))
def test_no_inline_find_secret_fields(self):
@@ -46,7 +56,7 @@ class TestNoInlineCopies:
def test_canonical_import_present(self):
# Tripwire: the endpoints still need the helpers, so removing the
# import means either dead secret handling or a new local copy.
source = API_V3_PATH.read_text(encoding="utf-8")
source = _api_v3_source()
assert re.search(
r"from src\.web_interface\.secret_helpers import .*find_secret_fields",
source,
@@ -0,0 +1,750 @@
"""The Starlark routes the frontend calls must exist.
`plugins_manager.js` posts to /api/v3/starlark/install-pixlet and then reloads
/api/v3/starlark/status. Neither route existed: #330 rewrote api_v3.py and
dropped all thirteen Starlark routes that #253 had added, so both calls fell
through to Flask's 404 handler, which answers
{"status": "error", "message": "Resource not found"}
and the button reported "Pixlet install failed: Resource not found" -- a
message that names neither the resource nor the cause.
These assert the routes are registered and answer in the shape the frontend
reads, so a future rewrite of this file cannot silently drop them again.
"""
import json
import os
from unittest.mock import MagicMock, patch
import pytest
@pytest.fixture
def client():
from web_interface.app import app
app.config['TESTING'] = True
with app.test_client() as c:
yield c
class TestRoutesAreRegistered:
"""The failure was a missing route, so check the URL map directly.
All thirteen, not just the two the Pixlet button needs: #330 dropped the
lot, and the app store page is built on repository/browse,
repository/categories and repository/install, which 404 the same way.
"""
@pytest.mark.parametrize("rule,method", [
("/api/v3/starlark/install-pixlet", "POST"),
("/api/v3/starlark/status", "GET"),
("/api/v3/starlark/apps", "GET"),
("/api/v3/starlark/upload", "POST"),
("/api/v3/starlark/repository/browse", "GET"),
("/api/v3/starlark/repository/categories", "GET"),
("/api/v3/starlark/repository/install", "POST"),
("/api/v3/starlark/apps/<app_id>", "GET"),
("/api/v3/starlark/apps/<app_id>", "DELETE"),
("/api/v3/starlark/apps/<app_id>/config", "GET"),
("/api/v3/starlark/apps/<app_id>/config", "PUT"),
("/api/v3/starlark/apps/<app_id>/toggle", "POST"),
("/api/v3/starlark/apps/<app_id>/render", "POST"),
])
def test_route_exists(self, rule, method):
from web_interface.app import app
matches = [r for r in app.url_map.iter_rules()
if r.rule == rule and method in r.methods]
assert matches, (
f"{method} {rule} is not registered; the frontend calls it and "
"would get Flask's generic 'Resource not found'")
class TestInstallPixlet:
def test_it_does_not_404(self, client):
with patch('web_interface.blueprints.api_v3.starlark.subprocess.run') as run:
run.return_value = MagicMock(returncode=0, stdout="ok", stderr="")
resp = client.post('/api/v3/starlark/install-pixlet')
assert resp.status_code != 404, "the route is still missing"
assert resp.get_json().get('message') != 'Resource not found'
def test_success_is_reported_in_the_shape_the_button_reads(self, client):
with patch('web_interface.blueprints.api_v3.starlark.subprocess.run') as run:
run.return_value = MagicMock(returncode=0, stdout="done", stderr="")
resp = client.post('/api/v3/starlark/install-pixlet')
body = resp.get_json()
assert body['status'] == 'success', body
assert 'message' in body, "the JS shows data.message on success"
def test_a_failed_download_says_why(self, client):
with patch('web_interface.blueprints.api_v3.starlark.subprocess.run') as run:
run.return_value = MagicMock(returncode=1, stdout="", stderr="no such release")
resp = client.post('/api/v3/starlark/install-pixlet')
body = resp.get_json()
assert body['status'] == 'error'
assert 'no such release' in body['message'], \
"the installer's own stderr is what tells the user what went wrong"
def test_a_timeout_is_reported_rather_than_hanging(self, client):
import subprocess as sp
with patch('web_interface.blueprints.api_v3.starlark.subprocess.run',
side_effect=sp.TimeoutExpired(cmd='x', timeout=300)):
resp = client.post('/api/v3/starlark/install-pixlet')
assert resp.get_json()['status'] == 'error'
assert 'timed out' in resp.get_json()['message'].lower()
class TestStarlarkStatus:
def test_it_does_not_404(self, client):
resp = client.get('/api/v3/starlark/status')
assert resp.status_code != 404, "the route is still missing"
assert resp.get_json().get('message') != 'Resource not found'
def test_it_reports_pixlet_availability_without_the_plugin_loaded(self, client):
# The status call runs before install too -- it must answer even when
# starlark-apps is not loaded, which is the state a user is in when
# they press the install button for the first time.
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None):
resp = client.get('/api/v3/starlark/status')
body = resp.get_json()
assert body['status'] == 'success', body
assert 'pixlet_available' in body
assert body['plugin_loaded'] is False
class TestTheInstallerScriptIsActuallyThere:
def test_download_pixlet_script_exists_and_is_executable(self):
# install_pixlet chmods and runs this; a missing file is the one error
# it reports as a 404 of its own, which would look identical to the
# bug being fixed here.
import os
from pathlib import Path
from web_interface.blueprints.api_v3 import PROJECT_ROOT
script = Path(PROJECT_ROOT) / 'scripts' / 'download_pixlet.sh'
assert script.is_file(), f"{script} is missing; install_pixlet would 404"
assert os.access(script, os.R_OK)
class TestTheAppStoreFlow:
"""Browsing and installing from the Tronbyte repository.
These are the calls the app store page makes. Each returned the generic
"Resource not found" before this change, which is indistinguishable from
an empty store.
"""
@pytest.fixture
def offline_repo(self):
"""No live GitHub calls from the test suite.
browse and categories reach _get_tronbyte_repository_class() and then
list_all_apps_cached(); with a cold server-side cache that is a real
network request, which makes the run slow, rate-limitable, and able to
pass on a 500 because these assertions only check for a 404.
"""
repo = MagicMock()
# Matches what the real list_all_apps_cached returns; the handler
# indexes every one of these keys.
repo.return_value.list_all_apps_cached.return_value = {
'apps': [{'id': 'quoteoftheday', 'name': 'A Quote A Day',
'category': 'text'}],
'categories': ['text'],
'authors': ['someone'],
'count': 1,
'cached': True,
}
repo.return_value.get_rate_limit_info.return_value = {'remaining': 5000}
with patch('web_interface.blueprints.api_v3._get_tronbyte_repository_class',
return_value=repo):
yield repo
def test_browse_does_not_404(self, client, offline_repo):
resp = client.get('/api/v3/starlark/repository/browse')
assert resp.status_code != 404, "the store cannot list anything"
assert resp.get_json().get('message') != 'Resource not found'
def test_browse_returns_the_apps_the_store_lists(self, client, offline_repo):
resp = client.get('/api/v3/starlark/repository/browse')
body = resp.get_json()
assert body['status'] == 'success', body
assert any(a.get('id') == 'quoteoftheday' for a in body.get('apps', [])), body
def test_categories_does_not_404(self, client, offline_repo):
resp = client.get('/api/v3/starlark/repository/categories')
assert resp.status_code != 404
assert resp.get_json().get('message') != 'Resource not found'
def test_no_live_network_call_is_made(self, client, offline_repo):
client.get('/api/v3/starlark/repository/browse')
assert offline_repo.called, \
"the route did not go through the patched repository class"
def test_installed_apps_list_does_not_404(self, client):
resp = client.get('/api/v3/starlark/apps')
assert resp.status_code != 404
assert resp.get_json().get('message') != 'Resource not found'
def test_repository_install_rejects_a_missing_body_rather_than_404ing(self, client):
# A 400/422 here is the route working: it received the call and said
# what was wrong. A 404 means it was never reached at all.
resp = client.post('/api/v3/starlark/repository/install',
json={}, content_type='application/json')
assert resp.status_code != 404, "the install route is still missing"
assert resp.get_json().get('message') != 'Resource not found'
def test_upload_rejects_an_empty_post_rather_than_404ing(self, client):
resp = client.post('/api/v3/starlark/upload')
assert resp.status_code != 404
assert resp.get_json().get('message') != 'Resource not found'
class TestNoStarlarkRouteIsMissing:
"""A single check that the whole set is present.
#330 removed all thirteen at once by rewriting this file. One assertion
over the frontend's own list is what would have caught that.
"""
def test_every_endpoint_the_frontend_calls_is_registered(self):
import re
from pathlib import Path
from werkzeug.exceptions import MethodNotAllowed, NotFound
from web_interface.app import app
root = Path(__file__).resolve().parent.parent.parent
js = (root / 'web_interface' / 'static' / 'v3' / 'plugins_manager.js').read_text()
# The frontend builds some of these with template literals, e.g.
# `/api/v3/starlark/apps/${appId}/toggle`. Substitute a placeholder so
# the URL is concrete, then let Werkzeug match it the way a request
# would -- string comparison cannot see <app_id> rules.
raw = set(re.findall(r"[\'\"`](/api/v3/starlark/[^\'\"`\s]*)", js))
urls = set()
for u in raw:
u = re.sub(r"\$\{[^}]*\}", "probe", u)
urls.add(u.rstrip('/') or u)
assert urls, "found no starlark calls in the frontend -- did the file move?"
adapter = app.url_map.bind('localhost')
missing = []
for u in sorted(urls):
try:
adapter.match(u, method='GET')
except MethodNotAllowed:
pass # route exists, just not for GET -- fine
except NotFound:
missing.append(u)
assert not missing, f"the frontend calls these and they are not registered: {missing}"
class TestInstalledAppsAppearWithTheOtherPlugins:
"""An installed .star app must be manageable like any other plugin.
#253 surfaced installed apps in /plugins/installed as `starlark:<app_id>`
entries, so they could be seen and enabled/disabled from the same list as
everything else, and routed `starlark:` toggles to the Starlark manifest.
#330 removed both. The result was an app that installs successfully, then
appears nowhere and cannot be turned on or off.
"""
APPS = {'apps': {'quoteoftheday': {'name': 'A Quote A Day', 'enabled': True}}}
def test_an_installed_app_is_listed(self, client):
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \
patch('web_interface.blueprints.api_v3._read_starlark_manifest', return_value=self.APPS):
resp = client.get('/api/v3/plugins/installed')
ids = [p['id'] for p in resp.get_json()['data']['plugins']]
assert 'starlark:quoteoftheday' in ids, \
"an installed Starlark app does not appear among the plugins"
def test_the_entry_carries_what_the_ui_needs(self, client):
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \
patch('web_interface.blueprints.api_v3._read_starlark_manifest', return_value=self.APPS):
resp = client.get('/api/v3/plugins/installed')
entry = next(p for p in resp.get_json()['data']['plugins']
if p['id'] == 'starlark:quoteoftheday')
assert entry['name'] == 'A Quote A Day'
assert entry['enabled'] is True
assert entry['is_starlark_app'] is True, "the UI keys its Starlark handling off this"
assert entry['category'] == 'Starlark App'
def test_a_starlark_failure_does_not_empty_the_plugin_list(self, client):
# The virtual entries are appended to the real ones; a broken manifest
# must cost the Starlark rows, not everybody else's.
with patch('web_interface.blueprints.api_v3._get_starlark_plugin',
side_effect=RuntimeError('boom')):
resp = client.get('/api/v3/plugins/installed')
assert resp.status_code == 200
assert resp.get_json()['status'] == 'success'
def test_toggling_an_app_does_not_report_plugin_not_found(self, client):
written = {}
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \
patch('web_interface.blueprints.api_v3._read_starlark_manifest',
return_value={'apps': {'quoteoftheday': {'enabled': True}}}), \
patch('web_interface.blueprints.api_v3._write_starlark_manifest',
side_effect=lambda m: written.update(m) or True):
resp = client.post('/api/v3/plugins/toggle',
json={'plugin_id': 'starlark:quoteoftheday', 'enabled': False})
body = resp.get_json()
assert body['status'] == 'success', body
assert body['enabled'] is False
assert written['apps']['quoteoftheday']['enabled'] is False, \
"the manifest was not actually updated"
def test_toggling_an_unknown_app_says_so(self, client):
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \
patch('web_interface.blueprints.api_v3._read_starlark_manifest',
return_value={'apps': {}}):
resp = client.post('/api/v3/plugins/toggle',
json={'plugin_id': 'starlark:nope', 'enabled': True})
assert resp.status_code == 404
assert 'nope' in resp.get_json()['message']
def test_a_traversal_app_id_is_rejected_before_touching_the_manifest(self, client):
resp = client.post('/api/v3/plugins/toggle',
json={'plugin_id': 'starlark:../../etc/passwd', 'enabled': True})
assert resp.status_code == 400
assert 'traversal' in resp.get_json()['message']
def test_an_app_id_the_listing_published_can_be_toggled(self, client):
"""The id here is exactly what _starlark_virtual_plugins publishes.
It used to be re-slugified on the way back in -- lowercased, with every
character outside [a-z0-9_] replaced -- so an app stored as 'My-App'
was listed as 'starlark:My-App' and looked up as 'my_app'. Toggling an
app the page had just drawn answered 404.
"""
manifest = {'apps': {'My-App': {'name': 'My App', 'enabled': False}}}
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \
patch('web_interface.blueprints.api_v3._read_starlark_manifest',
return_value=manifest), \
patch('web_interface.blueprints.api_v3._write_starlark_manifest',
return_value=True) as write:
resp = client.post('/api/v3/plugins/toggle',
json={'plugin_id': 'starlark:My-App', 'enabled': True})
assert resp.status_code == 200, resp.get_json()
assert write.called, "the toggle never reached the manifest"
assert manifest['apps']['My-App']['enabled'] is True
def test_a_loaded_app_missing_from_the_manifest_does_not_500(self, client):
"""_update_manifest_safe does not catch KeyError, so indexing an entry
that is not on disk yet escaped as a 500 instead of writing it."""
app = MagicMock()
app.manifest = {'enabled': False}
plugin = MagicMock()
plugin.apps = {'demo': app}
written = {}
def run_updater(fn):
fn(written)
return True
plugin._update_manifest_safe.side_effect = run_updater
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=plugin):
resp = client.post('/api/v3/plugins/toggle',
json={'plugin_id': 'starlark:demo', 'enabled': True})
assert resp.status_code == 200, resp.get_json()
assert written['apps']['demo']['enabled'] is True
class TestTheManifestStaysRelocatable:
"""`star_file` is joined to the app's own directory by its readers.
_standalone_render_starlark_app does `app_dir / app_data.get('star_file',
f'{app_id}.star')`, so the key's default is a bare filename. Storing an
absolute path gave it a second meaning, and Path.__truediv__ discards the
left side when the right is absolute -- which pinned the manifest to the
PROJECT_ROOT that installed it.
"""
@pytest.fixture
def starlark_dir(self, tmp_path, monkeypatch):
from web_interface.blueprints import api_v3 as module
apps_dir = tmp_path / "starlark-apps"
apps_dir.mkdir()
monkeypatch.setattr(module, '_STARLARK_APPS_DIR', apps_dir)
monkeypatch.setattr(module, '_STARLARK_MANIFEST_FILE', apps_dir / 'manifest.json')
return apps_dir
def _install(self, tmp_path):
from web_interface.blueprints import api_v3 as module
source = tmp_path / "source.star"
source.write_text("# app")
with patch.object(module, '_get_pixlet_renderer_class',
side_effect=ImportError("no pixlet here")):
assert module._install_star_file('demo', str(source), {'name': 'Demo'})
return json.loads((module._STARLARK_MANIFEST_FILE).read_text())['apps']['demo']
def test_the_star_file_is_recorded_by_name(self, starlark_dir, tmp_path):
assert self._install(tmp_path)['star_file'] == 'demo.star'
def test_the_recorded_path_is_not_absolute(self, starlark_dir, tmp_path):
"""An absolute value survives a move only by accident."""
assert not os.path.isabs(self._install(tmp_path)['star_file'])
def test_the_value_resolves_against_the_app_directory(self, starlark_dir, tmp_path):
"""Which is the one thing every reader of this key does with it."""
entry = self._install(tmp_path)
assert (starlark_dir / 'demo' / entry['star_file']).is_file()
def test_it_matches_the_default_a_reader_falls_back_to(self, starlark_dir, tmp_path):
"""Stored and defaulted values must mean the same thing."""
assert self._install(tmp_path)['star_file'] == 'demo.star'
# ---------------------------------------------------------------------------
# The store loaded, then stopped loading, and nothing anywhere said why.
#
# #535 restored the routes, so the 404 was gone -- but two failure modes
# underneath it produce the same blank grid, and neither could be read from
# outside. On the device this was diagnosed on, /repository/browse answered
# 200 with 1000 apps in 27s while GitHub reported 18 of 60 unauthenticated
# requests remaining, with 48 installed plugins checking for updates against
# the same budget. When that budget runs out the store goes blank and says
# nothing at all.
# ---------------------------------------------------------------------------
def _repository_module():
"""Load tronbyte_repository.py the way the blueprint does."""
import importlib.util
import sys
from pathlib import Path
path = (Path(__file__).resolve().parents[2]
/ 'plugin-repos' / 'starlark-apps' / 'tronbyte_repository.py')
spec = importlib.util.spec_from_file_location('_tronbyte_repo_under_test', path)
module = importlib.util.module_from_spec(spec)
sys.modules['_tronbyte_repo_under_test'] = module
spec.loader.exec_module(module)
return module
class _Resp:
"""Enough of requests.Response for the paths under test."""
def __init__(self, status_code=200, payload=None, headers=None, raises=None):
self.status_code = status_code
self._payload = payload
self.headers = headers or {}
self._raises = raises
def json(self):
if self._raises is not None:
raise self._raises
return self._payload
class TestTheJsonGuardIsNotItselfACrash:
"""`except (json.JSONDecodeError, ValueError)` with no `import json`.
Evaluating that tuple raises NameError, so the guard written for exactly
this case never ran: a non-JSON body -- a captive portal, a proxy error
page, a DNS-hijacking router answering for api.github.com -- came out as
a 500 instead of the None the caller was written to handle.
"""
def test_a_non_json_body_returns_none(self):
repo = _repository_module().TronbyteRepository()
repo.session.get = lambda *a, **k: _Resp(
raises=ValueError("Expecting value: line 1 column 1 (char 0)"))
assert repo._make_request("https://api.github.com/anything") is None
def test_it_says_the_response_was_not_json(self):
repo = _repository_module().TronbyteRepository()
repo.session.get = lambda *a, **k: _Resp(raises=ValueError("nope"))
repo._make_request("https://api.github.com/anything")
assert 'JSON' in (repo.last_error or ''), repo.last_error
class TestAnExhaustedRateLimitSaysSo:
"""60 requests/hour unauthenticated, shared with every update check."""
def test_the_message_names_the_rate_limit(self):
repo = _repository_module().TronbyteRepository()
repo.session.get = lambda *a, **k: _Resp(
status_code=403,
headers={'X-RateLimit-Remaining': '0', 'X-RateLimit-Limit': '60'})
assert repo._make_request("https://api.github.com/anything") is None
assert 'rate limit' in (repo.last_error or '').lower(), repo.last_error
def test_it_mentions_being_unauthenticated_when_there_is_no_token(self):
repo = _repository_module().TronbyteRepository()
repo.session.get = lambda *a, **k: _Resp(
status_code=403,
headers={'X-RateLimit-Remaining': '0', 'X-RateLimit-Limit': '60'})
repo._make_request("https://api.github.com/anything")
assert 'unauthenticated' in (repo.last_error or ''), repo.last_error
def test_a_plain_403_is_not_reported_as_a_rate_limit(self):
repo = _repository_module().TronbyteRepository()
repo.session.get = lambda *a, **k: _Resp(
status_code=403, headers={'X-RateLimit-Remaining': '57'})
repo._make_request("https://api.github.com/anything")
assert 'rate limit' not in (repo.last_error or '').lower(), repo.last_error
class TestAFailedFetchIsNotAnEmptyRepository:
"""list_all_apps_cached turned every failure into an empty app list.
The route then reported that as a success, so a rate limit, a DNS failure
and a genuinely empty repository were all drawn as the same blank grid.
"""
def test_the_reason_comes_back_with_the_empty_list(self):
module = _repository_module()
repo = module.TronbyteRepository()
repo.list_apps = lambda: (False, None, "GitHub API rate limit exceeded")
result = repo.list_all_apps_cached()
assert result['count'] == 0
assert 'rate limit' in result['error'].lower(), result
def test_a_failure_is_not_cached_as_an_empty_repository(self):
module = _repository_module()
repo = module.TronbyteRepository()
repo.list_apps = lambda: (False, None, "boom")
repo.list_all_apps_cached()
assert module._apps_cache['data'] is None, \
"a failed fetch was cached, so the store stays empty for 2 hours"
def test_a_successful_fetch_reports_no_error(self):
module = _repository_module()
repo = module.TronbyteRepository()
repo.list_apps = lambda: (True, [{'id': 'a', 'path': 'apps/a'}], None)
repo._fetch_raw_file = lambda *a, **k: "name: A\nsummary: s\n"
assert repo.list_all_apps_cached().get('error') is None
class TestTheStoreReportsWhyItIsEmpty:
"""The route's half of the same failure."""
@pytest.fixture
def failing_repo(self):
repo = MagicMock()
repo.return_value.list_all_apps_cached.return_value = {
'apps': [], 'categories': [], 'authors': [], 'count': 0,
'cached': False,
'error': 'GitHub API rate limit exceeded (60 requests/hour, '
'unauthenticated).',
}
repo.return_value.get_rate_limit_info.return_value = {'remaining': 0}
with patch('web_interface.blueprints.api_v3._get_tronbyte_repository_class',
return_value=repo):
yield repo
def test_browse_does_not_call_a_failure_a_success(self, client, failing_repo):
body = client.get('/api/v3/starlark/repository/browse').get_json()
assert body['status'] == 'error', body
def test_browse_answers_502_not_200(self, client, failing_repo):
resp = client.get('/api/v3/starlark/repository/browse')
assert resp.status_code == 502, resp.get_json()
def test_the_reason_reaches_the_page(self, client, failing_repo):
body = client.get('/api/v3/starlark/repository/browse').get_json()
assert 'rate limit' in body['message'].lower(), body
def test_categories_reports_it_too(self, client, failing_repo):
resp = client.get('/api/v3/starlark/repository/categories')
assert resp.status_code == 502
assert 'rate limit' in resp.get_json()['message'].lower()
@pytest.fixture
def working_repo(self):
repo = MagicMock()
repo.return_value.list_all_apps_cached.return_value = {
'apps': [{'id': 'quoteoftheday'}], 'categories': [], 'authors': [],
'count': 1, 'cached': False, 'error': None,
}
repo.return_value.get_rate_limit_info.return_value = {'remaining': 57}
with patch('web_interface.blueprints.api_v3._get_tronbyte_repository_class',
return_value=repo):
yield repo
def test_a_working_fetch_is_still_a_success(self, client, working_repo):
resp = client.get('/api/v3/starlark/repository/browse')
assert resp.status_code == 200
assert resp.get_json()['status'] == 'success'
class TestACrashCarriesItsDetail:
"""Seventeen Starlark handlers answered 5xx with no detail at all."""
def test_browse_returns_the_exception_detail(self, client):
with patch('web_interface.blueprints.api_v3._get_tronbyte_repository_class',
side_effect=ImportError("No module named 'yaml'")):
body = client.get('/api/v3/starlark/repository/browse').get_json()
assert 'yaml' in body.get('details', ''), body
def test_status_returns_the_exception_detail(self, client):
with patch('web_interface.blueprints.api_v3._get_starlark_plugin',
side_effect=RuntimeError("plugin manager is not attached")):
body = client.get('/api/v3/starlark/status').get_json()
assert 'plugin manager is not attached' in body.get('details', ''), body
class TestTheListingIsNotCappedAtOneThousand:
"""The contents API caps a directory at 1000 entries and does not say so.
tronbyt/apps returns exactly 1000 through that endpoint, which is the cap
rather than the app count -- the store looked complete while showing a
truncated repository.
"""
def _repo_with_tree(self, module, count):
repo = module.TronbyteRepository()
entries = [{'path': 'app%04d' % i, 'type': 'tree'} for i in range(count)]
def fake_request(url, timeout=10):
if url.endswith('/git/trees/main'):
return {'tree': [{'path': 'apps', 'type': 'tree', 'sha': 'deadbeef'}]}
if url.endswith('/git/trees/deadbeef'):
return {'tree': entries, 'truncated': False}
raise AssertionError("unexpected request: %s" % url)
repo._make_request = fake_request
return repo
def test_more_than_a_thousand_apps_are_listed(self):
module = _repository_module()
repo = self._repo_with_tree(module, 1400)
ok, apps, err = repo.list_apps()
assert ok, err
assert len(apps) == 1400
def test_the_path_is_still_the_one_manifest_fetches_use(self):
module = _repository_module()
repo = self._repo_with_tree(module, 3)
_, apps, _ = repo.list_apps()
assert apps[0]['path'] == 'apps/app0000', apps[0]
def test_dotfiles_and_files_are_skipped(self):
module = _repository_module()
repo = module.TronbyteRepository()
def fake_request(url, timeout=10):
if url.endswith('/git/trees/main'):
return {'tree': [{'path': 'apps', 'type': 'tree', 'sha': 'x'}]}
return {'tree': [{'path': '.github', 'type': 'tree'},
{'path': 'realapp', 'type': 'tree'},
{'path': 'README.md', 'type': 'blob'}]}
repo._make_request = fake_request
_, apps, _ = repo.list_apps()
assert [a['id'] for a in apps] == ['realapp']
def test_it_falls_back_to_the_contents_api(self):
"""A trees outage must not take the store down with it."""
module = _repository_module()
repo = module.TronbyteRepository()
def fake_request(url, timeout=10):
if '/git/trees/' in url:
repo.last_error = "GitHub API error 500"
return None
return [{'name': 'fallbackapp', 'path': 'apps/fallbackapp', 'type': 'dir'}]
repo._make_request = fake_request
ok, apps, err = repo.list_apps()
assert ok, err
assert [a['id'] for a in apps] == ['fallbackapp']
def test_both_paths_failing_reports_the_reason(self):
module = _repository_module()
repo = module.TronbyteRepository()
def fake_request(url, timeout=10):
repo.last_error = "Timed out reaching GitHub"
return None
repo._make_request = fake_request
ok, apps, err = repo.list_apps()
assert not ok
assert 'Timed out' in err, err
class TestTheStoreUsesTheTokenTheUserConfigured:
"""The store authenticated with a key nothing ever writes.
The three repository routes read `github_token` off config.json. Nothing
writes that key: config.template.json has no such field, no setting
offers it, and the token the user actually configures goes to
config_secrets.json as `github.api_token`, which PluginStoreManager loads
and every other GitHub caller uses.
So the store ran unauthenticated at 60 requests/hour on the same per-IP
budget as 48 plugins' update checks, while the configured token sat
unused raising that same budget to 5000. On the device this was found on,
/plugins/store/github-status reported `authenticated: true` with a
rate_limit of 5000 while /starlark/repository/browse reported a limit of
60 -- the store going blank was that 60 running out.
"""
def test_the_store_managers_token_is_used(self):
from web_interface.blueprints import api_v3 as mod
with patch.object(mod.api_v3, 'plugin_store_manager',
MagicMock(github_token='ghp_configured')):
assert mod._starlark_github_token() == 'ghp_configured'
def test_a_hand_edited_config_key_still_works(self):
from web_interface.blueprints import api_v3 as mod
cfg = MagicMock()
cfg.load_config.return_value = {'github_token': 'ghp_by_hand'}
with patch.object(mod.api_v3, 'plugin_store_manager',
MagicMock(github_token=None)), \
patch.object(mod.api_v3, 'config_manager', cfg):
assert mod._starlark_github_token() == 'ghp_by_hand'
def test_no_token_anywhere_is_not_an_error(self):
from web_interface.blueprints import api_v3 as mod
cfg = MagicMock()
cfg.load_config.return_value = {}
with patch.object(mod.api_v3, 'plugin_store_manager',
MagicMock(github_token=None)), \
patch.object(mod.api_v3, 'config_manager', cfg):
assert mod._starlark_github_token() is None
def test_an_unreadable_config_does_not_take_the_store_down(self):
from web_interface.blueprints import api_v3 as mod
cfg = MagicMock()
cfg.load_config.side_effect = OSError("config.json is unreadable")
with patch.object(mod.api_v3, 'plugin_store_manager',
MagicMock(github_token=None)), \
patch.object(mod.api_v3, 'config_manager', cfg):
assert mod._starlark_github_token() is None
def test_browse_hands_the_token_to_the_repository(self, client):
from web_interface.blueprints import api_v3 as mod
repo = MagicMock()
repo.return_value.list_all_apps_cached.return_value = {
'apps': [], 'categories': [], 'authors': [], 'count': 0,
'cached': False, 'error': None,
}
repo.return_value.get_rate_limit_info.return_value = {'remaining': 4999}
with patch.object(mod.api_v3, 'plugin_store_manager',
MagicMock(github_token='ghp_configured')), \
patch('web_interface.blueprints.api_v3._get_tronbyte_repository_class',
return_value=repo):
client.get('/api/v3/starlark/repository/browse')
repo.assert_called_once_with(github_token='ghp_configured')
@@ -19,7 +19,12 @@ import re
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
API_V3 = PROJECT_ROOT / "web_interface" / "blueprints" / "api_v3.py"
# api_v3 is a package; a sudo systemctl call can live in any of its modules.
API_V3_PKG = PROJECT_ROOT / "web_interface" / "blueprints" / "api_v3"
def _api_v3_source() -> str:
return "\n".join(p.read_text() for p in sorted(API_V3_PKG.glob("*.py")))
SUDOERS_SCRIPT = PROJECT_ROOT / "scripts" / "install" / "configure_web_sudo.sh"
@@ -51,7 +56,7 @@ def _granted_systemctl_rules(script: str) -> set[tuple[str, str]]:
def test_every_sudo_systemctl_call_is_granted() -> None:
calls = _sudo_systemctl_calls(API_V3.read_text())
calls = _sudo_systemctl_calls(_api_v3_source())
rules = _granted_systemctl_rules(SUDOERS_SCRIPT.read_text())
assert calls, "expected to find sudo systemctl calls in api_v3.py"
@@ -68,7 +73,7 @@ def test_every_sudo_systemctl_call_is_granted() -> None:
def test_units_are_fully_qualified() -> None:
"""Privileged systemctl calls must name the unit as <name>.service so they
match the sudoers grants, which use the fully-qualified unit name."""
calls = _sudo_systemctl_calls(API_V3.read_text())
calls = _sudo_systemctl_calls(_api_v3_source())
unqualified = {(v, u) for v, u in calls if not u.endswith(".service")}
assert not unqualified, (
"sudo systemctl calls must use fully-qualified .service unit names: "
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+211
View File
@@ -0,0 +1,211 @@
"""Backup creation, listing and restore.
Routes decorate the shared `api_v3` Blueprint from ._common, so their
endpoint names are unchanged by living here.
"""
from web_interface.blueprints.api_v3 import (
PROJECT_ROOT, Path, _safe_backup_path, api_v3, datetime, json, jsonify,
logger, os, plugin_store_manager, request, tempfile,
)
import web_interface.blueprints.api_v3 as _pkg
# Read through the module rather than bound by value: tests patch these
# as module attributes, and a value binding would not see the patch.
# Several are also called from helpers that live in __init__, so the
# package is the only patch point that covers every caller.
@api_v3.route('/backup/preview', methods=['GET'])
def backup_preview():
"""Return a summary of what a new backup would include."""
try:
from src.backup_manager import preview_backup_contents
data = preview_backup_contents(PROJECT_ROOT)
return jsonify({'status': 'success', 'data': data})
except Exception as e:
logger.error("backup_preview failed: %s", e, exc_info=True)
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
@api_v3.route('/backup/list', methods=['GET'])
def backup_list():
"""List backup ZIPs stored in the export directory."""
try:
_pkg._BACKUP_EXPORT_DIR.mkdir(parents=True, exist_ok=True)
entries = []
for p in sorted(_pkg._BACKUP_EXPORT_DIR.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True):
if not p.is_file() or p.suffix != '.zip':
continue
st = p.stat()
entries.append({
'filename': p.name,
'size': st.st_size,
'created_at': datetime.fromtimestamp(st.st_mtime).strftime('%Y-%m-%d %H:%M:%S'),
})
return jsonify({'status': 'success', 'data': entries})
except Exception as e:
logger.error("backup_list failed: %s", e, exc_info=True)
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
@api_v3.route('/backup/export', methods=['POST'])
def backup_export():
"""Create a new backup ZIP and return its filename."""
try:
from src.backup_manager import create_backup
zip_path = create_backup(PROJECT_ROOT, output_dir=_pkg._BACKUP_EXPORT_DIR)
return jsonify({'status': 'success', 'filename': zip_path.name})
except Exception as e:
logger.error("backup_export failed: %s", e, exc_info=True)
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
@api_v3.route('/backup/validate', methods=['POST'])
def backup_validate():
"""Validate an uploaded backup ZIP and return its manifest."""
try:
from src.backup_manager import validate_backup
if 'backup_file' not in request.files:
return jsonify({'status': 'error', 'message': 'No backup_file in request'}), 400
f = request.files['backup_file']
with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as tmp:
tmp_path = tmp.name
f.save(tmp_path)
try:
ok, err_msg, manifest = validate_backup(Path(tmp_path))
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
if not ok:
logger.warning("Backup validation failed: %s", err_msg)
return jsonify({'status': 'error', 'message': 'Invalid or corrupted backup file'}), 400
safe_manifest = {
'schema_version': manifest.get('schema_version'),
'created_at': manifest.get('created_at'),
'ledmatrix_version': manifest.get('ledmatrix_version'),
'hostname': manifest.get('hostname'),
'contents': manifest.get('contents', []),
'detected_contents': manifest.get('detected_contents', []),
'plugins': manifest.get('plugins', []),
'total_uncompressed': manifest.get('total_uncompressed'),
'file_count': manifest.get('file_count'),
}
return jsonify({'status': 'success', 'data': safe_manifest})
except Exception as e:
logger.error("backup_validate failed: %s", e, exc_info=True)
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
@api_v3.route('/backup/restore', methods=['POST'])
def backup_restore():
"""Restore a backup ZIP with optional RestoreOptions."""
try:
from src.backup_manager import restore_backup, RestoreOptions
if 'backup_file' not in request.files:
return jsonify({'status': 'error', 'message': 'No backup_file in request'}), 400
f = request.files['backup_file']
options_raw = request.form.get('options', '{}')
try:
opts_dict = json.loads(options_raw)
except json.JSONDecodeError:
opts_dict = None
if not isinstance(opts_dict, dict):
# Every option defaults to True, so falling back to {} on a
# parse failure would silently perform a FULL restore —
# secrets and all — for a caller who asked for a narrow one
# and mis-serialized it. Refuse instead of guessing.
return jsonify({
'status': 'error',
'message': 'Invalid options: expected a JSON object',
}), 400
options = RestoreOptions(
restore_config=bool(opts_dict.get('restore_config', True)),
restore_secrets=bool(opts_dict.get('restore_secrets', True)),
restore_wifi=bool(opts_dict.get('restore_wifi', True)),
restore_fonts=bool(opts_dict.get('restore_fonts', True)),
restore_plugin_uploads=bool(opts_dict.get('restore_plugin_uploads', True)),
reinstall_plugins=bool(opts_dict.get('reinstall_plugins', True)),
)
with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as tmp:
tmp_path = tmp.name
f.save(tmp_path)
try:
result = restore_backup(Path(tmp_path), PROJECT_ROOT, options)
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
# Reinstall plugins if requested and store manager available
if options.reinstall_plugins and result.plugins_to_install:
psm = getattr(api_v3, 'plugin_store_manager', None) or plugin_store_manager
for plug in result.plugins_to_install:
pid = plug.get('plugin_id')
if not pid:
continue
try:
if psm and hasattr(psm, 'install_plugin'):
ok = psm.install_plugin(pid)
if ok:
result.plugins_installed.append(pid)
else:
result.plugins_failed.append({'plugin_id': pid, 'error': 'install_plugin returned False'})
else:
result.plugins_failed.append({'plugin_id': pid, 'error': 'Store manager unavailable'})
except Exception as pe:
logger.error(
"[Backup] Failed to reinstall plugin %r: %s", pid, pe, exc_info=True
)
result.plugins_failed.append({'plugin_id': pid, 'error': 'Installation failed; see server logs'})
# A restore that dropped files can still report success if the only
# failures were plugin reinstalls, since those don't touch result.errors.
if result.plugins_failed:
result.success = False
data = result.to_dict()
if not result.success:
# Name what failed, and what nonetheless landed. A restore is
# partial far more often than it is total -- a fresh install can
# leave config_secrets.json unwritable by the web service, so
# config restores and secrets do not. "Restore had errors" alone
# left the user unable to tell a wholly failed restore from one
# that quietly dropped their API keys.
failed_plugins = [
str(p.get('plugin_id')) for p in (result.plugins_failed or []) if p.get('plugin_id')
]
parts = []
if result.restored:
parts.append(f"restored: {', '.join(result.restored)}")
if result.errors:
parts.append(f"failed: {'; '.join(result.errors)}")
if failed_plugins:
parts.append(f"plugins not reinstalled: {', '.join(failed_plugins)}")
message = 'Restore incomplete — ' + ('. '.join(parts) if parts else 'see logs')
return jsonify({'status': 'error', 'message': message, 'data': data}), 500
return jsonify({'status': 'success', 'data': data})
except Exception as e:
logger.error("backup_restore failed: %s", e, exc_info=True)
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
@api_v3.route('/backup/download/<path:filename>', methods=['GET'])
def backup_download(filename):
"""Stream a backup ZIP to the browser."""
from flask import send_from_directory
if _safe_backup_path(filename) is None:
return jsonify({'status': 'error', 'message': 'Backup not found'}), 404
try:
# send_from_directory uses werkzeug safe_join internally — CodeQL-recognized sanitizer.
return send_from_directory(_pkg._BACKUP_EXPORT_DIR, filename, as_attachment=True)
except FileNotFoundError:
return jsonify({'status': 'error', 'message': 'Backup not found'}), 404
@api_v3.route('/backup/<path:filename>', methods=['DELETE'])
def backup_delete(filename):
"""Delete a stored backup ZIP."""
safe = _safe_backup_path(filename)
if safe is None:
return jsonify({'status': 'error', 'message': 'Backup not found'}), 404
# Enumerate the export directory and match by name so the unlink target is
# a filesystem-derived path rather than one constructed from user input.
try:
for entry in _pkg._BACKUP_EXPORT_DIR.iterdir():
if entry.is_file() and entry.name == safe.name:
entry.unlink()
return jsonify({'status': 'success'})
except OSError as e:
logger.error("backup_delete failed: %s", e, exc_info=True)
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
return jsonify({'status': 'error', 'message': 'Backup not found'}), 404
File diff suppressed because it is too large Load Diff
+326
View File
@@ -0,0 +1,326 @@
"""Display control, on-demand playback and preview.
Routes decorate the shared `api_v3` Blueprint from ._common, so their
endpoint names are unchanged by living here.
"""
from web_interface.blueprints.api_v3 import (
_ensure_cache_manager, _ensure_display_service_running,
_get_display_service_status, _stop_display_service, api_v3,
describe_exception, jsonify, logger, os, request, uuid,
)
import web_interface.blueprints.api_v3 as _pkg
# Read through the module rather than bound by value: tests patch these
# as module attributes, and a value binding would not see the patch.
# Several are also called from helpers that live in __init__, so the
# package is the only patch point that covers every caller.
@api_v3.route('/display/current', methods=['GET'])
def get_display_current():
"""Get current display state"""
try:
import base64
from PIL import Image
import io
snapshot_path = "/tmp/led_matrix_preview.png"
# Get display dimensions from config
try:
if api_v3.config_manager:
main_config = api_v3.config_manager.load_config()
hardware_config = main_config.get('display', {}).get('hardware', {})
cols = hardware_config.get('cols', 64)
chain_length = hardware_config.get('chain_length', 2)
rows = hardware_config.get('rows', 32)
parallel = hardware_config.get('parallel', 1)
width = cols * chain_length
height = rows * parallel
else:
width = 128
height = 64
except Exception:
width = 128
height = 64
# Try to read snapshot file
image_data = None
if os.path.exists(snapshot_path):
try:
with Image.open(snapshot_path) as img:
# Convert to PNG and encode as base64
buffer = io.BytesIO()
img.save(buffer, format='PNG')
image_data = base64.b64encode(buffer.getvalue()).decode('utf-8')
except Exception as img_err:
# File might be being written or corrupted, return None
pass
display_data = {
'timestamp': _pkg.time.time(),
'width': width,
'height': height,
'image': image_data # Base64 encoded image data or None if unavailable
}
return jsonify({'status': 'success', 'data': display_data})
except Exception as e:
logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/display/modes', methods=['GET'])
def get_display_modes():
"""Every display mode that can be requested on-demand, with its plugin.
/plugins/installed carries no mode information, so anything driving the
display from outside the web UI (the Home Assistant MQTT bridge, a script)
had to read each plugin's manifest.json off disk and reimplement the
fallbacks in PluginManager.get_plugin_display_modes to do it. This is the
same list the force-display dialog offers, from the source that owns it.
Knowing each mode's plugin_id also matters because /display/on-demand/start
falls back to find_plugin_for_mode when plugin_id is omitted, and that
lookup only sees modes declared in a static manifest -- a plugin whose
modes are generated (each installed Starlark app is one) 404s there.
Sending the plugin_id from this list skips the lookup entirely.
Query params:
include_disabled: '1' to list modes of disabled plugins too. They can
still be requested on-demand -- the controller enables the plugin
for the duration -- so they are reported with enabled: false
rather than omitted.
"""
try:
if not api_v3.plugin_manager:
return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500
# Discovery is lazy and normally triggered by whichever endpoint runs
# first, which is a person opening the dashboard. A caller that never
# visits it would otherwise see an empty list.
api_v3.plugin_manager.discover_plugins()
include_disabled = request.args.get('include_disabled') in ('1', 'true', 'True')
full_config = api_v3.config_manager.load_config() if api_v3.config_manager else {}
modes = []
for plugin_id, manifest in sorted(api_v3.plugin_manager.plugin_manifests.items()):
# A hand-edited or migrated config.json can hold a non-dict under a
# plugin id; DisplayController._reconcile guards the same shape, so
# it happens in practice. Without this, .get() raises AttributeError,
# the loop aborts and the endpoint answers 500 with no modes at all
# -- one bad section would blank every entity the MQTT bridge builds
# from this list.
plugin_config = full_config.get(plugin_id)
if not isinstance(plugin_config, dict):
if plugin_config is not None:
logger.warning(
"Config for plugin %r is %s, not an object; treating it as disabled",
plugin_id, type(plugin_config).__name__)
plugin_config = {}
enabled = bool(plugin_config.get('enabled', False))
if not enabled and not include_disabled:
continue
plugin_name = (manifest or {}).get('name') or plugin_id
plugin_modes = api_v3.plugin_manager.get_plugin_display_modes(plugin_id) or [plugin_id]
for mode in plugin_modes:
# A single-mode plugin's mode is the plugin, so its own name is
# the readable label. Multi-mode plugins have no per-mode name
# anywhere, so the raw mode string is the only thing to show.
modes.append({
'mode': mode,
'plugin_id': plugin_id,
'plugin_name': plugin_name,
'name': plugin_name if len(plugin_modes) == 1 else mode,
'enabled': enabled,
})
return jsonify({'status': 'success', 'data': {'modes': modes}})
except Exception as exc:
# describe_exception, not a bare message: test_web_error_detail.py
# enforces that every handler here returns it, because a device whose
# storage is failing otherwise answers "see logs for details" from the
# log viewer too. It redacts credentials out of the exception text.
# CodeQL flags this as stack-trace exposure across all ~75 handlers;
# it is the project's deliberate, reviewed trade-off.
logger.error('Error in get_display_modes', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(exc)}), 500
@api_v3.route('/display/on-demand/status', methods=['GET'])
def get_on_demand_status():
"""Return the current on-demand display state."""
try:
cache = _ensure_cache_manager()
state = cache.get('display_on_demand_state', max_age=120)
if state is None:
state = {
'active': False,
'status': 'idle',
'last_updated': None
}
service_status = _get_display_service_status()
return jsonify({
'status': 'success',
'data': {
'state': state,
'service': service_status
}
})
except Exception as exc:
logger.error('Error in get_on_demand_status', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(exc)}), 500
@api_v3.route('/display/on-demand/start', methods=['POST'])
def start_on_demand_display():
"""Request the display controller to run a specific plugin on-demand."""
try:
data = request.get_json(silent=True) or {}
plugin_id = data.get('plugin_id')
mode = data.get('mode')
duration = data.get('duration')
pinned = bool(data.get('pinned', False))
start_service = data.get('start_service', True)
if not plugin_id and not mode:
return jsonify({'status': 'error', 'message': 'plugin_id or mode is required'}), 400
resolved_plugin = plugin_id
resolved_mode = mode
if api_v3.plugin_manager:
if resolved_plugin and resolved_plugin not in api_v3.plugin_manager.plugin_manifests:
return jsonify({'status': 'error', 'message': f'Plugin {resolved_plugin} not found'}), 404
if resolved_plugin and not resolved_mode:
modes = api_v3.plugin_manager.get_plugin_display_modes(resolved_plugin)
resolved_mode = modes[0] if modes else resolved_plugin
elif resolved_mode and not resolved_plugin:
resolved_plugin = api_v3.plugin_manager.find_plugin_for_mode(resolved_mode)
if not resolved_plugin:
return jsonify({'status': 'error', 'message': f'Mode {resolved_mode} not found'}), 404
# Note: On-demand can work with disabled plugins - the display controller
# will temporarily enable them during initialization if needed
# We don't block the request here, but log it for debugging
if api_v3.config_manager and resolved_plugin:
config = api_v3.config_manager.load_config()
plugin_config = config.get(resolved_plugin, {})
if 'enabled' in plugin_config and not plugin_config.get('enabled', False):
logger.info(
"On-demand request for disabled plugin '%s' - will be temporarily enabled",
resolved_plugin,
)
# Set the on-demand request in cache FIRST (before starting service)
# This ensures the request is available when the service starts/restarts
cache = _ensure_cache_manager()
request_id = data.get('request_id') or str(uuid.uuid4())
request_payload = {
'request_id': request_id,
'action': 'start',
'plugin_id': resolved_plugin,
'mode': resolved_mode,
'duration': duration,
'pinned': pinned,
'timestamp': _pkg.time.time()
}
cache.set('display_on_demand_request', request_payload)
# Check if display service is running (or will be started)
service_status = _get_display_service_status()
service_was_running = service_status.get('active', False)
# Stop the display service first to ensure clean state when we will restart it
if service_was_running and start_service:
import _pkg.time as time_module
logger.debug("Stopping display service before starting on-demand mode")
_stop_display_service()
# Wait a brief moment for the service to fully stop
time_module.sleep(1.5)
logger.debug("Display service stopped, now starting with on-demand request")
if not service_status.get('active') and not start_service:
return jsonify({
'status': 'error',
'message': 'Display service is not running. Please start the display service or enable "Start Service" option.',
'service_status': service_status
}), 400
service_result = None
if start_service:
service_result = _ensure_display_service_running()
# Check if service actually started
if service_result and not service_result.get('active'):
return jsonify({
'status': 'error',
'message': 'Failed to start display service. Please check service logs or start it manually.',
'service_result': service_result
}), 500
# Service was restarted (or started fresh) with on-demand request in cache
# The display controller will read the request during initialization or when it polls
response_data = {
'request_id': request_id,
'plugin_id': resolved_plugin,
'mode': resolved_mode,
'duration': duration,
'pinned': pinned,
'service': service_result
}
return jsonify({'status': 'success', 'data': response_data})
except Exception as exc:
logger.error('Error in start_on_demand_display', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(exc)}), 500
@api_v3.route('/display/on-demand/stop', methods=['POST'])
def stop_on_demand_display():
"""Request the display controller to stop on-demand mode."""
try:
data = request.get_json(silent=True) or {}
stop_service = data.get('stop_service', False)
# Set the stop request in cache FIRST
# The display controller will poll this and restart without the on-demand filter
cache = _ensure_cache_manager()
request_id = data.get('request_id') or str(uuid.uuid4())
request_payload = {
'request_id': request_id,
'action': 'stop',
'timestamp': _pkg.time.time()
}
cache.set('display_on_demand_request', request_payload)
# Note: The display controller's _clear_on_demand() will handle the restart
# to restore normal operation with all plugins
service_result = None
if stop_service:
service_result = _stop_display_service()
return jsonify({
'status': 'success',
'data': {
'request_id': request_id,
'service': service_result
}
})
except Exception as exc:
logger.error('Error in stop_on_demand_display', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(exc)}), 500
@api_v3.route('/display/current-status', methods=['GET'])
def get_current_display_status():
"""Return the display mode/plugin currently intended to be shown.
Published by the display process (display_controller._publish_current_mode_state)
to the shared cache whenever the active mode changes, so the web UI (e.g. the
System Logs page) can show what's on screen without querying the display
process directly.
"""
try:
cache = _ensure_cache_manager()
state = cache.get('display_current_state', max_age=120)
if state is None:
state = {
'mode': None,
'plugin_id': None,
'last_updated': None,
}
return jsonify({'status': 'success', 'data': state})
except Exception as e:
logger.error('Error in get_current_display_status', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
+452
View File
@@ -0,0 +1,452 @@
"""Font catalogue, upload, preview and deletion.
Routes decorate the shared `api_v3` Blueprint from ._common, so their
endpoint names are unchanged by living here.
"""
from web_interface.blueprints.api_v3 import (
PROJECT_ROOT, Path, Response, SYSTEM_FONTS, api_v3, describe_exception,
jsonify, logger, os, re, request, validate_file_upload,
)
@api_v3.route('/fonts/catalog', methods=['GET'])
def get_fonts_catalog():
"""Get fonts catalog"""
try:
# Check cache first (5 minute TTL)
try:
from web_interface.cache import get_cached, set_cached
cached_result = get_cached('fonts_catalog', ttl_seconds=300)
if cached_result is not None:
return jsonify({'status': 'success', 'data': {'catalog': cached_result}})
except ImportError:
# Cache not available, continue without caching
get_cached = None
set_cached = None
# Try to import freetype, but continue without it if unavailable
try:
import freetype
freetype_available = True
except ImportError:
freetype_available = False
# Scan assets/fonts directory for actual font files
fonts_dir = PROJECT_ROOT / "assets" / "fonts"
catalog = {}
if fonts_dir.exists() and fonts_dir.is_dir():
for filename in os.listdir(fonts_dir):
if filename.endswith(('.ttf', '.otf', '.bdf')):
filepath = fonts_dir / filename
# Generate family name from filename (without extension)
family_name = os.path.splitext(filename)[0]
# Try to get font metadata using freetype (for TTF/OTF)
metadata = {}
if filename.endswith(('.ttf', '.otf')) and freetype_available:
try:
face = freetype.Face(str(filepath))
if face.valid:
# Get font family name from font file
family_name_from_font = face.family_name.decode('utf-8') if face.family_name else family_name
metadata = {
'family': family_name_from_font,
'style': face.style_name.decode('utf-8') if face.style_name else 'Regular',
'num_glyphs': face.num_glyphs,
'units_per_em': face.units_per_EM
}
# Use font's family name if available
if family_name_from_font:
family_name = family_name_from_font
except Exception:
# If freetype fails, use filename-based name
pass
# Store relative path from project root
relative_path = str(filepath.relative_to(PROJECT_ROOT))
font_type = 'ttf' if filename.endswith('.ttf') else 'otf' if filename.endswith('.otf') else 'bdf'
# Generate human-readable display name from family_name
display_name = family_name.replace('-', ' ').replace('_', ' ')
# Add space before capital letters for camelCase names
display_name = re.sub(r'([a-z])([A-Z])', r'\1 \2', display_name)
# Add space before numbers that follow letters
display_name = re.sub(r'([a-zA-Z])(\d)', r'\1 \2', display_name)
# Clean up multiple spaces
display_name = ' '.join(display_name.split())
# Use filename (without extension) as unique key to avoid collisions
# when multiple files share the same family_name from font metadata
catalog_key = os.path.splitext(filename)[0]
# Check if this is a system font (cannot be deleted)
is_system = catalog_key.lower() in SYSTEM_FONTS
catalog[catalog_key] = {
'filename': filename,
'family_name': family_name,
'display_name': display_name,
'path': relative_path,
'type': font_type,
'is_system': is_system,
'metadata': metadata if metadata else None
}
# Cache the result (5 minute TTL) if available
if set_cached:
try:
set_cached('fonts_catalog', catalog, ttl_seconds=300)
except Exception:
logger.error("[FontCatalog] Failed to cache fonts_catalog", exc_info=True)
return jsonify({'status': 'success', 'data': {'catalog': catalog}})
except Exception as e:
logger.error("%s failed", request.path, exc_info=True)
return jsonify({'status': 'error',
'message': 'An error occurred; see logs for details',
'details': describe_exception(e)}), 500
@api_v3.route('/fonts/tokens', methods=['GET'])
def get_font_tokens():
"""Get font size tokens"""
try:
# This would integrate with the actual font system
# For now, return sample tokens
tokens = {
'xs': 6,
'sm': 8,
'md': 10,
'lg': 12,
'xl': 14,
'xxl': 16
}
return jsonify({'status': 'success', 'data': {'tokens': tokens}})
except Exception as e:
logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/fonts/overrides', methods=['GET'])
def get_fonts_overrides():
"""Get font overrides"""
try:
# This would integrate with the actual font system
# For now, return empty overrides
overrides = {}
return jsonify({'status': 'success', 'data': {'overrides': overrides}})
except Exception as e:
logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/fonts/overrides', methods=['POST'])
def save_fonts_overrides():
"""Save font overrides"""
try:
data = request.get_json(silent=True)
if not data:
return jsonify({'status': 'error', 'message': 'No data provided'}), 400
# This would integrate with the actual font system
return jsonify({'status': 'success', 'message': 'Font overrides saved'})
except Exception as e:
logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/fonts/overrides/<element_key>', methods=['DELETE'])
def delete_font_override(element_key):
"""Delete font override"""
try:
# This would integrate with the actual font system
return jsonify({'status': 'success', 'message': f'Font override for {element_key} deleted'})
except Exception as e:
logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/fonts/upload', methods=['POST'])
def upload_font():
"""Upload font file"""
try:
if 'font_file' not in request.files:
return jsonify({'status': 'error', 'message': 'No font file provided'}), 400
font_file = request.files['font_file']
if font_file.filename == '':
return jsonify({'status': 'error', 'message': 'No file selected'}), 400
# Validate filename
is_valid, error_msg = validate_file_upload(
font_file.filename,
max_size_mb=10,
allowed_extensions=['.ttf', '.otf', '.bdf']
)
if not is_valid:
return jsonify({'status': 'error', 'message': error_msg}), 400
font_family = request.form.get('font_family', '')
if not font_family:
return jsonify({'status': 'error', 'message': 'Font file and family name required'}), 400
# Validate font family name
if not font_family.replace('_', '').replace('-', '').isalnum():
return jsonify({'status': 'error', 'message': 'Font family name must contain only letters, numbers, underscores, and hyphens'}), 400
# Save the font file to assets/fonts directory
fonts_dir = PROJECT_ROOT / "assets" / "fonts"
fonts_dir.mkdir(parents=True, exist_ok=True)
# Create filename from family name
original_ext = os.path.splitext(font_file.filename)[1].lower()
safe_filename = f"{font_family}{original_ext}"
filepath = fonts_dir / safe_filename
# Check if file already exists
if filepath.exists():
return jsonify({'status': 'error', 'message': f'Font with name {font_family} already exists'}), 400
# Save the file
font_file.save(str(filepath))
# Clear font catalog cache
try:
from web_interface.cache import delete_cached
delete_cached('fonts_catalog')
except ImportError as e:
logger.warning("[FontUpload] Cache module not available: %s", e)
except Exception:
logger.error("[FontUpload] Failed to clear fonts_catalog cache", exc_info=True)
return jsonify({
'status': 'success',
'message': f'Font {font_family} uploaded successfully',
'font_family': font_family,
'filename': safe_filename,
'path': f'assets/fonts/{safe_filename}'
})
except Exception as e:
logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/fonts/preview', methods=['GET'])
def get_font_preview() -> tuple[Response, int] | Response:
"""Generate a preview image of text rendered with a specific font"""
try:
from PIL import Image, ImageDraw, ImageFont
import io
import base64
# Limits to prevent DoS via large image generation on constrained devices
MAX_TEXT_CHARS = 100
MAX_TEXT_LINES = 3
MAX_DIM = 1024 # Max width or height in pixels
MAX_PIXELS = 500000 # Max total pixels (e.g., ~700x700)
font_filename = request.args.get('font', '')
text = request.args.get('text', 'Sample Text 123')
bg_color = request.args.get('bg', '000000')
fg_color = request.args.get('fg', 'ffffff')
# Validate text length and line count early
if len(text) > MAX_TEXT_CHARS:
return jsonify({'status': 'error', 'message': f'Text exceeds maximum length of {MAX_TEXT_CHARS} characters'}), 400
if text.count('\n') >= MAX_TEXT_LINES:
return jsonify({'status': 'error', 'message': f'Text exceeds maximum of {MAX_TEXT_LINES} lines'}), 400
# Safe integer parsing for size
try:
size = int(request.args.get('size', 12))
except (ValueError, TypeError, OverflowError):
return jsonify({'status': 'error', 'message': 'Invalid font size'}), 400
if not font_filename:
return jsonify({'status': 'error', 'message': 'Font filename required'}), 400
# Validate size
if size < 4 or size > 72:
return jsonify({'status': 'error', 'message': 'Font size must be between 4 and 72'}), 400
# Security: Validate font_filename to prevent path traversal
# Only allow alphanumeric, hyphen, underscore, and dot (for extension)
safe_name = Path(font_filename).name # Strip any directory components
if safe_name != font_filename or '..' in font_filename:
return jsonify({'status': 'error', 'message': 'Invalid font filename'}), 400
# Validate extension
allowed_extensions = ['.ttf', '.otf', '.bdf']
has_valid_ext = any(safe_name.lower().endswith(ext) for ext in allowed_extensions)
name_without_ext = safe_name.rsplit('.', 1)[0] if '.' in safe_name else safe_name
# Find the font file
fonts_dir = PROJECT_ROOT / "assets" / "fonts"
if not fonts_dir.exists():
return jsonify({'status': 'error', 'message': 'Fonts directory not found'}), 404
font_path = fonts_dir / safe_name
if not font_path.exists() and not has_valid_ext:
# Try finding by family name (without extension)
for ext in allowed_extensions:
potential_path = fonts_dir / f"{name_without_ext}{ext}"
if potential_path.exists():
font_path = potential_path
break
# Final security check: ensure path is within fonts_dir
try:
font_path.resolve().relative_to(fonts_dir.resolve())
except ValueError:
return jsonify({'status': 'error', 'message': 'Invalid font path'}), 400
if not font_path.exists():
return jsonify({'status': 'error', 'message': f'Font file not found: {font_filename}'}), 404
# Parse colors
try:
bg_rgb = tuple(int(bg_color[i:i+2], 16) for i in (0, 2, 4))
fg_rgb = tuple(int(fg_color[i:i+2], 16) for i in (0, 2, 4))
except (ValueError, IndexError):
bg_rgb = (0, 0, 0)
fg_rgb = (255, 255, 255)
# Load font
font = None
if str(font_path).endswith('.bdf'):
# BDF fonts require complex per-glyph rendering via freetype
# Return explicit error rather than showing misleading preview with default font
return jsonify({
'status': 'error',
'message': 'BDF font preview not supported. BDF fonts will render correctly on the LED matrix.'
}), 400
else:
# TTF/OTF fonts
try:
font = ImageFont.truetype(str(font_path), size)
except (IOError, OSError) as e:
# IOError/OSError raised for invalid/corrupt font files
logger.warning("[FontPreview] Failed to load font %s: %s", font_path, e)
font = ImageFont.load_default()
# Calculate text size
temp_img = Image.new('RGB', (1, 1))
temp_draw = ImageDraw.Draw(temp_img)
bbox = temp_draw.textbbox((0, 0), text, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
# Create image with padding
padding = 10
img_width = max(text_width + padding * 2, 100)
img_height = max(text_height + padding * 2, 30)
# Validate resulting image size to prevent memory/CPU spikes
if img_width > MAX_DIM or img_height > MAX_DIM:
return jsonify({'status': 'error', 'message': 'Requested image too large'}), 400
if img_width * img_height > MAX_PIXELS:
return jsonify({'status': 'error', 'message': 'Requested image too large'}), 400
img = Image.new('RGB', (img_width, img_height), bg_rgb)
draw = ImageDraw.Draw(img)
# Center text
x = (img_width - text_width) // 2
y = (img_height - text_height) // 2
draw.text((x, y), text, font=font, fill=fg_rgb)
# Convert to base64
buffer = io.BytesIO()
img.save(buffer, format='PNG')
buffer.seek(0)
img_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
return jsonify({
'status': 'success',
'data': {
'image': f'data:image/png;base64,{img_base64}',
'width': img_width,
'height': img_height
}
})
except Exception as e:
logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/fonts/<font_family>', methods=['DELETE'])
def delete_font(font_family: str) -> tuple[Response, int] | Response:
"""Delete a user-uploaded font file"""
try:
# Security: Validate font_family to prevent path traversal
# Reject if it contains path separators or ..
if '..' in font_family or '/' in font_family or '\\' in font_family:
return jsonify({'status': 'error', 'message': 'Invalid font family name'}), 400
# Only allow safe characters: alphanumeric, hyphen, underscore, dot
if not re.match(r'^[a-zA-Z0-9_\-\.]+$', font_family):
return jsonify({'status': 'error', 'message': 'Invalid font family name'}), 400
# Check if this is a system font (uses module-level SYSTEM_FONTS frozenset)
if font_family.lower() in SYSTEM_FONTS:
return jsonify({'status': 'error', 'message': 'Cannot delete system fonts'}), 403
# Find and delete the font file
fonts_dir = PROJECT_ROOT / "assets" / "fonts"
# Ensure fonts directory exists
if not fonts_dir.exists() or not fonts_dir.is_dir():
return jsonify({'status': 'error', 'message': 'Fonts directory not found'}), 404
deleted = False
deleted_filename = None
# Only try valid font extensions (no empty string to avoid matching directories)
for ext in ['.ttf', '.otf', '.bdf']:
potential_path = fonts_dir / f"{font_family}{ext}"
# Security: Verify path is within fonts_dir
try:
potential_path.resolve().relative_to(fonts_dir.resolve())
except ValueError:
continue # Path escapes fonts_dir, skip
if potential_path.exists() and potential_path.is_file():
potential_path.unlink()
deleted = True
deleted_filename = f"{font_family}{ext}"
break
if not deleted:
# Try case-insensitive match within fonts directory
font_family_lower = font_family.lower()
for filename in os.listdir(fonts_dir):
# Only consider files with valid font extensions
if not any(filename.lower().endswith(ext) for ext in ['.ttf', '.otf', '.bdf']):
continue
name_without_ext = os.path.splitext(filename)[0]
if name_without_ext.lower() == font_family_lower:
filepath = fonts_dir / filename
# Security: Verify path is within fonts_dir
try:
filepath.resolve().relative_to(fonts_dir.resolve())
except ValueError:
continue # Path escapes fonts_dir, skip
if filepath.is_file():
filepath.unlink()
deleted = True
deleted_filename = filename
break
if not deleted:
return jsonify({'status': 'error', 'message': f'Font not found: {font_family}'}), 404
# Clear font catalog cache
try:
from web_interface.cache import delete_cached
delete_cached('fonts_catalog')
except ImportError as e:
logger.warning("[FontDelete] Cache module not available: %s", e)
except Exception:
logger.error("[FontDelete] Failed to clear fonts_catalog cache", exc_info=True)
return jsonify({
'status': 'success',
'message': f'Font {deleted_filename} deleted successfully'
})
except Exception as e:
logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
+496
View File
@@ -0,0 +1,496 @@
"""Routes with no larger group of their own: errors, integrations,
cache, sync, skins, logs, health and hardware.
Routes decorate the shared `api_v3` Blueprint from ._common, so their
endpoint names are unchanged by living here.
"""
from web_interface.blueprints.api_v3 import (
ErrorCode, Path, _JOURNALCTL, _MQTT_BRIDGE_CONFIG, _MQTT_BRIDGE_DEFAULTS,
_MQTT_BRIDGE_DIR, _SUDO, _coerce_mqtt_bridge_value,
_get_display_service_status, _mqtt_bridge_service_state,
_read_mqtt_bridge_config, api_v3, contextlib, describe_exception,
error_response, get_error_aggregator, json, jsonify, logger, os, request,
subprocess, success_response, tempfile,
)
import web_interface.blueprints.api_v3 as _pkg
# Read through the module rather than bound by value: tests patch these
# as module attributes, and a value binding would not see the patch.
# Several are also called from helpers that live in __init__, so the
# package is the only patch point that covers every caller.
@api_v3.route('/health', methods=['GET'])
def get_health():
"""Get system health status"""
try:
health_status = {
'status': 'healthy',
'timestamp': _pkg.time.time(),
'services': {},
'checks': {}
}
# Check web interface service
# Stamp the start _pkg.time before measuring against it -- reading it with a
# fallback of _pkg.time.time() and only assigning afterwards made the very
# first call subtract two separate clock reads, reporting a small
# negative uptime.
if not hasattr(get_health, '_start_time'):
get_health._start_time = _pkg.time.time()
health_status['services']['web_interface'] = {
'status': 'running',
'uptime_seconds': _pkg.time.time() - get_health._start_time
}
# Check display service
display_service_status = _get_display_service_status()
health_status['services']['display_service'] = {
'status': 'active' if display_service_status.get('active') else 'inactive',
'details': display_service_status
}
# Check config file accessibility
try:
if api_v3.config_manager:
test_config = api_v3.config_manager.load_config()
health_status['checks']['config_file'] = {
'status': 'accessible',
'readable': True
}
else:
health_status['checks']['config_file'] = {
'status': 'unknown',
'readable': False
}
except Exception as e:
health_status['checks']['config_file'] = {
'status': 'error',
'readable': False,
'error': 'see logs for details'
}
# Check plugin system
try:
if api_v3.plugin_manager:
# Try to discover plugins (lightweight check)
plugin_count = len(api_v3.plugin_manager.get_available_plugins()) if hasattr(api_v3.plugin_manager, 'get_available_plugins') else 0
health_status['checks']['plugin_system'] = {
'status': 'operational',
'plugin_count': plugin_count
}
else:
health_status['checks']['plugin_system'] = {
'status': 'not_initialized'
}
except Exception as e:
health_status['checks']['plugin_system'] = {
'status': 'error',
'error': 'see logs for details'
}
# Check hardware connectivity (if display manager available)
try:
snapshot_path = "/tmp/led_matrix_preview.png"
if os.path.exists(snapshot_path):
# Check if snapshot is recent (updated in last 60 seconds)
mtime = os.path.getmtime(snapshot_path)
age_seconds = _pkg.time.time() - mtime
health_status['checks']['hardware'] = {
'status': 'connected' if age_seconds < 60 else 'stale',
'snapshot_age_seconds': round(age_seconds, 1)
}
else:
health_status['checks']['hardware'] = {
'status': 'no_snapshot',
'note': 'Display service may not be running'
}
except Exception as e:
health_status['checks']['hardware'] = {
'status': 'unknown',
'error': 'see logs for details'
}
# Determine overall health
all_healthy = all(
check.get('status') in ['accessible', 'operational', 'connected', 'running', 'active']
for check in health_status['checks'].values()
)
if not all_healthy:
health_status['status'] = 'degraded'
return jsonify({'status': 'success', 'data': health_status})
except Exception as e:
logger.error("%s failed", request.path, exc_info=True)
return jsonify({
'status': 'error',
'message': 'An error occurred; see logs for details',
'details': describe_exception(e),
'data': {'status': 'unhealthy'}
}), 500
@api_v3.route('/hardware/status', methods=['GET'])
def get_hardware_status():
"""Return LED matrix hardware initialization status written by display_manager at startup."""
status_path = "/tmp/led_matrix_hw_status.json" # nosec B108
try:
with open(status_path) as f:
hw_data = json.load(f)
return jsonify({"status": "success", "data": hw_data})
except FileNotFoundError:
return jsonify({"status": "success", "data": {"ok": None, "error": "Display service not yet started"}})
except PermissionError:
logger.warning("Permission denied reading hardware status file; display service may be running as a different user")
return jsonify({"status": "success", "data": {"ok": False, "error": "Hardware status temporarily unavailable"}})
except json.JSONDecodeError:
logger.error("Failed to parse hardware status file", exc_info=True)
return jsonify({"status": "success", "data": {"ok": False, "error": "Hardware status file corrupted"}})
except Exception:
logger.error("Unexpected error reading hardware status", exc_info=True)
return jsonify({"status": "error", "message": "Unable to read hardware status"}), 500
@api_v3.route('/skins', methods=['GET'])
def list_skins():
"""List installed visual skins (docs/SKIN_SYSTEM.md).
Optional ?plugin_id=... filters to skins matching that plugin.
"""
try:
from src.skin_system import skin_runtime
plugin_id = request.args.get('plugin_id')
if plugin_id:
skins = skin_runtime.skins_for_plugin(plugin_id)
else:
# The discovery cache self-invalidates on directory/manifest
# mtime changes, so no force_refresh — keeps Pi disk I/O down.
skins = skin_runtime.discover_skins()
payload = []
for skin_id, manifest in sorted(skins.items()):
skin_dir = Path(manifest['_skin_dir'])
preview = manifest.get('preview')
payload.append({
'id': skin_id,
'name': manifest.get('name', skin_id),
'version': manifest.get('version'),
'author': manifest.get('author'),
'description': manifest.get('description', ''),
'skin_api_version': manifest.get('skin_api_version'),
'targets': manifest.get('targets', {}),
'modes': manifest.get('modes', []),
'has_preview': bool(preview and (skin_dir / preview).is_file()),
})
return jsonify({'status': 'success', 'data': {'skins': payload}})
except Exception as e:
logger.error('Error in list_skins', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/logs', methods=['GET'])
def get_logs():
"""Get system logs from journalctl"""
try:
if not _JOURNALCTL:
return jsonify({'status': 'error', 'message': 'journalctl not found on this system'}), 503
# Get recent logs from journalctl
_cmd = ([_SUDO, _JOURNALCTL] if _SUDO else [_JOURNALCTL]) + [
'-u', 'ledmatrix.service', '-u', 'ledmatrix-web.service',
'-n', '100', '--no-pager', '--output=short-iso']
result = subprocess.run(
_cmd,
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
logs_text = result.stdout.strip()
return jsonify({
'status': 'success',
'data': {
'logs': logs_text if logs_text else 'No logs available from ledmatrix or ledmatrix-web service'
}
})
else:
return jsonify({
'status': 'error',
'message': f'Failed to get logs: {result.stderr}'
}), 500
except subprocess.TimeoutExpired:
return jsonify({
'status': 'error',
'message': 'Timeout while fetching logs'
}), 500
except Exception as e:
logger.error("%s failed", request.path, exc_info=True)
return jsonify({
'status': 'error',
'message': 'An error occurred; see logs for details',
'details': describe_exception(e)
}), 500
# Multi-Display Sync Endpoints
@api_v3.route('/sync/status', methods=['GET'])
def get_sync_status():
"""Return live multi-display sync status written by the display process."""
import os as _os
status_file = "/tmp/led_matrix_sync_status.json"
# Also surface config so the UI can show the configured role even before
# the display process has written a status file.
cfg_role = "standalone"
cfg_port = 5765
if api_v3.config_manager:
try:
cfg = api_v3.config_manager.load_config().get("sync", {})
cfg_role = cfg.get("role", "standalone")
cfg_port = int(cfg.get("port", 5765))
except Exception:
pass
if _os.path.exists(status_file):
try:
with open(status_file) as f:
live = json.load(f)
return jsonify({"status": "success", "data": live})
except Exception:
pass
# Status file not yet written — return config-only placeholder
return jsonify({
"status": "success",
"data": {
"role": cfg_role,
"port": cfg_port,
"state": "starting",
}
})
@api_v3.route('/cache/list', methods=['GET'])
def list_cache_files():
"""List all cache files with metadata"""
try:
if not api_v3.cache_manager:
# Initialize cache manager if not already initialized
from src.cache_manager import CacheManager
api_v3.cache_manager = CacheManager()
cache_files = api_v3.cache_manager.list_cache_files()
cache_dir = api_v3.cache_manager.get_cache_dir()
return jsonify({
'status': 'success',
'data': {
'cache_files': cache_files,
'cache_dir': cache_dir,
'total_files': len(cache_files)
}
})
except Exception as e:
logger.error('Error in list_cache_files', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/cache/delete', methods=['POST'])
def delete_cache_file():
"""Delete a specific cache file by key"""
try:
if not api_v3.cache_manager:
# Initialize cache manager if not already initialized
from src.cache_manager import CacheManager
api_v3.cache_manager = CacheManager()
data = request.get_json(silent=True)
if not data or 'key' not in data:
return jsonify({'status': 'error', 'message': 'cache key is required'}), 400
cache_key = data['key']
# Delete the cache file
api_v3.cache_manager.clear_cache(cache_key)
return jsonify({
'status': 'success',
'message': f'Cache file for key "{cache_key}" deleted successfully'
})
except Exception as e:
logger.error('Error in delete_cache_file', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/errors/summary', methods=['GET'])
def get_error_summary():
"""
Get summary of all errors for monitoring and debugging.
Returns error counts, detected patterns, and recent errors.
"""
try:
aggregator = get_error_aggregator()
summary = aggregator.get_error_summary()
return success_response(data=summary, message="Error summary retrieved")
except Exception as e:
logger.error(f"Error getting error summary: {e}", exc_info=True)
return error_response(
error_code=ErrorCode.SYSTEM_ERROR,
message="Failed to retrieve error summary",
status_code=500
)
@api_v3.route('/errors/plugin/<plugin_id>', methods=['GET'])
def get_plugin_errors(plugin_id):
"""
Get error health status for a specific plugin.
Args:
plugin_id: Plugin identifier
Returns health status and error statistics for the plugin.
"""
try:
aggregator = get_error_aggregator()
health = aggregator.get_plugin_health(plugin_id)
return success_response(data=health, message="Plugin health retrieved")
except Exception as e:
logger.error(f"Error getting plugin health for {plugin_id}: {e}", exc_info=True)
return error_response(
error_code=ErrorCode.SYSTEM_ERROR,
message=f"Failed to retrieve health for plugin {plugin_id}",
status_code=500
)
@api_v3.route('/errors/clear', methods=['POST'])
def clear_old_errors():
"""
Clear error records older than specified age.
Request body (optional):
max_age_hours: Maximum age in hours (default: 24, max: 8760 = 1 year)
"""
try:
data = request.get_json(silent=True) or {}
raw_max_age = data.get('max_age_hours', 24)
# Validate and coerce max_age_hours
try:
max_age_hours = int(raw_max_age)
if max_age_hours < 1:
return error_response(
error_code=ErrorCode.INVALID_INPUT,
message="max_age_hours must be at least 1",
context={'provided_value': raw_max_age},
status_code=400
)
if max_age_hours > 8760: # 1 year max
return error_response(
error_code=ErrorCode.INVALID_INPUT,
message="max_age_hours cannot exceed 8760 (1 year)",
context={'provided_value': raw_max_age},
status_code=400
)
except (ValueError, TypeError, OverflowError):
return error_response(
error_code=ErrorCode.INVALID_INPUT,
message="max_age_hours must be a valid integer",
context={'provided_value': str(raw_max_age)},
status_code=400
)
aggregator = get_error_aggregator()
cleared_count = aggregator.clear_old_records(max_age_hours=max_age_hours)
return success_response(
data={'cleared_count': cleared_count},
message=f"Cleared {cleared_count} error records older than {max_age_hours} hours"
)
except Exception as e:
logger.error(f"Error clearing old errors: {e}", exc_info=True)
return error_response(
error_code=ErrorCode.SYSTEM_ERROR,
message="Failed to clear old errors",
status_code=500
)
@api_v3.route('/integrations/mqtt-bridge', methods=['GET'])
def get_mqtt_bridge():
"""Bridge service state and its settings, minus the password."""
try:
config = _read_mqtt_bridge_config()
password = config.get('mqtt_password')
safe = {key: config.get(key, default)
for key, default in _MQTT_BRIDGE_DEFAULTS.items()}
return jsonify({
'status': 'success',
'data': {
'service': _mqtt_bridge_service_state(),
'config_exists': _MQTT_BRIDGE_CONFIG.is_file(),
'config_path': str(_MQTT_BRIDGE_CONFIG),
'config': safe,
# Enough to render "a password is set" without disclosing it.
'password_set': bool(password),
'env_override_prefix': 'LEDMATRIX_MQTT_',
}
})
except Exception as e:
logger.exception('Error reading MQTT bridge settings')
return jsonify({'status': 'error', 'message': 'Could not read bridge settings',
'details': describe_exception(e)}), 500
@api_v3.route('/integrations/mqtt-bridge/config', methods=['PUT'])
def update_mqtt_bridge_config():
"""Write bridge_config.json.
The password is write-only: omit it to leave whatever is stored alone, send
a value to replace it, or send clear_password to remove it. It is never
returned by the GET above, so a form that round-tripped it would otherwise
have to blank it on every save.
"""
try:
data = request.get_json(silent=True) or {}
if not isinstance(data, dict):
return jsonify({'status': 'error', 'message': 'Body must be a JSON object'}), 400
config = _read_mqtt_bridge_config()
existing_password = config.get('mqtt_password')
updates = {}
for key in _MQTT_BRIDGE_DEFAULTS:
if key not in data:
continue
value, err = _coerce_mqtt_bridge_value(key, data[key])
if err:
return jsonify({'status': 'error', 'message': err}), 400
updates[key] = value
config.update(updates)
if data.get('clear_password'):
config['mqtt_password'] = None
elif 'mqtt_password' in data and str(data['mqtt_password']) != '':
new_password = str(data['mqtt_password'])
if len(new_password) > 300:
return jsonify({'status': 'error', 'message': 'Password is too long'}), 400
config['mqtt_password'] = new_password
else:
config['mqtt_password'] = existing_password
if config.get('mqtt_password') and not config.get('mqtt_tls'):
logger.warning('MQTT bridge: a password is set without TLS; '
'credentials will cross the network in cleartext')
_MQTT_BRIDGE_DIR.mkdir(parents=True, exist_ok=True)
# Write via a temp file in the same directory so a crash mid-write
# cannot leave a half-written config the bridge would refuse to load.
fd, tmp_path = tempfile.mkstemp(dir=str(_MQTT_BRIDGE_DIR), prefix='.bridge_config.')
try:
with os.fdopen(fd, 'w', encoding='utf-8') as handle:
json.dump(config, handle, indent=2, sort_keys=True)
handle.write('\n')
os.chmod(tmp_path, 0o600)
os.replace(tmp_path, _MQTT_BRIDGE_CONFIG)
except Exception:
with contextlib.suppress(OSError):
os.unlink(tmp_path)
raise
service = _mqtt_bridge_service_state()
message = 'Bridge settings saved.'
if service['active']:
message += ' Restart the bridge for them to take effect.'
return jsonify({'status': 'success', 'message': message,
'data': {'password_set': bool(config.get('mqtt_password')),
'restart_required': service['active']}})
except Exception as e:
logger.exception('Error saving MQTT bridge settings')
return jsonify({'status': 'error', 'message': 'Could not save bridge settings',
'details': describe_exception(e)}), 500
File diff suppressed because it is too large Load Diff
+872
View File
@@ -0,0 +1,872 @@
"""Starlark / Tronbyte app management routes.
Routes decorate the shared `api_v3` Blueprint from ._common, so their
endpoint names are unchanged by living here.
"""
from web_interface.blueprints.api_v3 import (
PROJECT_ROOT, Path, _PIXLET_EDITOR_DEFAULT_PORT,
_PIXLET_EDITOR_DEFAULT_TIMEOUT, _PIXLET_EDITOR_MAX_TIMEOUT,
_PIXLET_EDITOR_SCRIPT, _PIXLET_EDITOR_STATE, _clear_pixlet_editor_state,
_find_pixlet_binary, _install_star_file, _pixlet_editor_alive,
_pixlet_editor_status, _read_pixlet_editor_state,
_standalone_render_starlark_app, _starlark_github_token,
_validate_and_sanitize_app_id, _validate_starlark_app_path,
_validate_timing_value, api_v3, contextlib, describe_exception, json,
jsonify, logger, os, request, shutil, signal, subprocess, tempfile,
)
import web_interface.blueprints.api_v3 as _pkg
# Read through the module rather than bound by value: tests patch these
# as module attributes, and a value binding would not see the patch.
# Several are also called from helpers that live in __init__, so the
# package is the only patch point that covers every caller.
@api_v3.route('/starlark/status', methods=['GET'])
def get_starlark_status():
"""Get Starlark plugin status and Pixlet availability."""
try:
starlark_plugin = _pkg._get_starlark_plugin()
if starlark_plugin:
info = starlark_plugin.get_info()
magnify_info = starlark_plugin.get_magnify_recommendation()
return jsonify({
'status': 'success',
'pixlet_available': info.get('pixlet_available', False),
'pixlet_version': info.get('pixlet_version'),
'installed_apps': info.get('installed_apps', 0),
'enabled_apps': info.get('enabled_apps', 0),
'current_app': info.get('current_app'),
'plugin_enabled': starlark_plugin.enabled,
'display_info': magnify_info
})
# Plugin not loaded - check Pixlet availability via shared resolver
# (respects user-configured pixlet_path, bundled binary, and system PATH)
full_config = api_v3.config_manager.load_config() if api_v3.config_manager else {}
pixlet_path = _find_pixlet_binary(full_config.get('starlark-apps', {}).get('pixlet_path'))
pixlet_available = pixlet_path is not None
# Read app counts from manifest
manifest = _pkg._read_starlark_manifest()
apps = manifest.get('apps', {})
installed_count = len(apps)
enabled_count = sum(1 for a in apps.values() if a.get('enabled', True))
return jsonify({
'status': 'success',
'pixlet_available': pixlet_available,
'pixlet_version': None,
'installed_apps': installed_count,
'enabled_apps': enabled_count,
'plugin_enabled': True,
'plugin_loaded': False,
'display_info': {}
})
except Exception as e:
logger.exception("[Starlark] get_starlark_status failed")
return jsonify({'status': 'error', 'message': 'Failed to get Starlark status', 'details': describe_exception(e)}), 500
@api_v3.route('/starlark/install-pixlet', methods=['POST'])
def install_pixlet():
"""Download and install Pixlet binary."""
try:
script_path = PROJECT_ROOT / 'scripts' / 'download_pixlet.sh'
if not script_path.exists():
return jsonify({'status': 'error', 'message': 'Installation script not found'}), 404
os.chmod(script_path, 0o755)
result = subprocess.run(
[str(script_path)],
cwd=str(PROJECT_ROOT),
capture_output=True,
text=True,
timeout=300
)
if result.returncode == 0:
logger.info("Pixlet downloaded successfully")
return jsonify({'status': 'success', 'message': 'Pixlet installed successfully!', 'output': result.stdout})
else:
return jsonify({'status': 'error', 'message': f'Failed to download Pixlet: {result.stderr}'}), 500
except subprocess.TimeoutExpired as err:
logger.exception("[Starlark] Pixlet download timed out")
return jsonify({'status': 'error', 'message': 'Download timed out',
'details': describe_exception(err)}), 500
except Exception as e:
logger.exception("[Starlark] install_pixlet failed")
return jsonify({'status': 'error', 'message': 'Failed to install Pixlet', 'details': describe_exception(e)}), 500
@api_v3.route('/starlark/apps', methods=['GET'])
def get_starlark_apps():
"""List all installed Starlark apps."""
try:
starlark_plugin = _pkg._get_starlark_plugin()
if starlark_plugin:
apps_list = []
for app_id, app_instance in starlark_plugin.apps.items():
apps_list.append({
'id': app_id,
'name': app_instance.manifest.get('name', app_id),
'enabled': app_instance.is_enabled(),
'has_frames': app_instance.frames is not None,
'render_interval': app_instance.get_render_interval(),
'display_duration': app_instance.get_display_duration(),
'config': app_instance.config,
'has_schema': app_instance.schema is not None,
'last_render_time': app_instance.last_render_time
})
return jsonify({'status': 'success', 'apps': apps_list, 'count': len(apps_list)})
# Standalone: read manifest from disk
manifest = _pkg._read_starlark_manifest()
apps_list = []
for app_id, app_data in manifest.get('apps', {}).items():
apps_list.append({
'id': app_id,
'name': app_data.get('name', app_id),
'enabled': app_data.get('enabled', True),
'has_frames': False,
'render_interval': app_data.get('render_interval', 300),
'display_duration': app_data.get('display_duration', 15),
'config': app_data.get('config', {}),
'has_schema': False,
'last_render_time': None
})
return jsonify({'status': 'success', 'apps': apps_list, 'count': len(apps_list)})
except Exception as e:
logger.exception("[Starlark] get_starlark_apps failed")
return jsonify({'status': 'error', 'message': 'Failed to get Starlark apps', 'details': describe_exception(e)}), 500
@api_v3.route('/starlark/apps/<app_id>', methods=['GET'])
def get_starlark_app(app_id):
"""Get details for a specific Starlark app."""
try:
# Validate app_id before any filesystem access
app_dir, error_msg = _validate_starlark_app_path(app_id)
if error_msg:
return jsonify({'status': 'error', 'message': error_msg}), 400
starlark_plugin = _pkg._get_starlark_plugin()
if starlark_plugin:
app = starlark_plugin.apps.get(app_id)
if not app:
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
return jsonify({
'status': 'success',
'app': {
'id': app_id,
'name': app.manifest.get('name', app_id),
'enabled': app.is_enabled(),
'config': app.config,
'schema': app.schema,
'render_interval': app.get_render_interval(),
'display_duration': app.get_display_duration(),
'has_frames': app.frames is not None,
'frame_count': len(app.frames) if app.frames else 0,
'last_render_time': app.last_render_time,
}
})
# Standalone: read from manifest
manifest = _pkg._read_starlark_manifest()
app_data = manifest.get('apps', {}).get(app_id)
if not app_data:
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
# Load schema from schema.json if it exists (path already validated above)
schema = None
schema_file = app_dir / 'schema.json'
if schema_file.exists():
try:
with open(schema_file, 'r') as f:
schema = json.load(f)
except (OSError, json.JSONDecodeError) as e:
logger.warning(f"Failed to load schema for {app_id}: {e}")
return jsonify({
'status': 'success',
'app': {
'id': app_id,
'name': app_data.get('name', app_id),
'enabled': app_data.get('enabled', True),
'config': app_data.get('config', {}),
'schema': schema,
'render_interval': app_data.get('render_interval', 300),
'display_duration': app_data.get('display_duration', 15),
'has_frames': False,
'frame_count': 0,
'last_render_time': None,
}
})
except Exception as e:
logger.exception("[Starlark] get_starlark_app failed")
return jsonify({'status': 'error', 'message': 'Failed to get Starlark app', 'details': describe_exception(e)}), 500
@api_v3.route('/starlark/upload', methods=['POST'])
def upload_starlark_app():
"""Upload and install a new Starlark app."""
try:
if 'file' not in request.files:
return jsonify({'status': 'error', 'message': 'No file uploaded'}), 400
file = request.files['file']
if not file.filename or not file.filename.endswith('.star'):
return jsonify({'status': 'error', 'message': 'File must have .star extension'}), 400
# Check file size (limit to 5MB for .star files)
file.seek(0, 2) # Seek to end
file_size = file.tell()
file.seek(0) # Reset to beginning
MAX_STAR_SIZE = 5 * 1024 * 1024 # 5MB
if file_size > MAX_STAR_SIZE:
return jsonify({'status': 'error', 'message': f'File too large (max 5MB, got {file_size/1024/1024:.1f}MB)'}), 400
app_name = request.form.get('name')
app_id_input = request.form.get('app_id')
filename_base = file.filename.replace('.star', '') if file.filename else None
app_id, app_id_error = _validate_and_sanitize_app_id(app_id_input, fallback_source=filename_base)
if app_id_error:
return jsonify({'status': 'error', 'message': f'Invalid app_id: {app_id_error}'}), 400
render_interval_input = request.form.get('render_interval')
render_interval = 300
if render_interval_input is not None:
render_interval, err = _validate_timing_value(render_interval_input, 'render_interval')
if err:
return jsonify({'status': 'error', 'message': err}), 400
render_interval = render_interval or 300
display_duration_input = request.form.get('display_duration')
display_duration = 15
if display_duration_input is not None:
display_duration, err = _validate_timing_value(display_duration_input, 'display_duration')
if err:
return jsonify({'status': 'error', 'message': err}), 400
display_duration = display_duration or 15
import tempfile
with tempfile.NamedTemporaryFile(delete=False, suffix='.star') as tmp:
file.save(tmp.name)
temp_path = tmp.name
try:
metadata = {'name': app_name or app_id, 'render_interval': render_interval, 'display_duration': display_duration}
starlark_plugin = _pkg._get_starlark_plugin()
if starlark_plugin:
success = starlark_plugin.install_app(app_id, temp_path, metadata)
else:
success = _install_star_file(app_id, temp_path, metadata)
if success:
return jsonify({'status': 'success', 'message': f'App installed: {app_id}', 'app_id': app_id})
else:
return jsonify({'status': 'error', 'message': 'Failed to install app'}), 500
finally:
try:
os.unlink(temp_path)
except OSError:
pass
except (OSError, IOError) as err:
# This used to withhold the detail because it names absolute paths on
# the device. A full disk and a bad permission are indistinguishable
# without it, though, and describe_exception redacts credentials and
# truncates -- the same trade-off every other handler here makes.
logger.exception("[Starlark] File error uploading starlark app: %s", err)
return jsonify({'status': 'error', 'message': 'File error during upload',
'details': describe_exception(err)}), 500
except ImportError as err:
logger.exception("[Starlark] Module load error uploading starlark app: %s", err)
return jsonify({'status': 'error', 'message': 'Failed to load app module', 'details': describe_exception(err)}), 500
except Exception as err:
logger.exception("[Starlark] Unexpected error uploading starlark app: %s", err)
return jsonify({'status': 'error', 'message': 'Failed to upload app', 'details': describe_exception(err)}), 500
@api_v3.route('/starlark/apps/<app_id>', methods=['DELETE'])
def uninstall_starlark_app(app_id):
"""Uninstall a Starlark app."""
try:
# Validate app_id before any filesystem access
app_dir, error_msg = _validate_starlark_app_path(app_id)
if error_msg:
return jsonify({'status': 'error', 'message': error_msg}), 400
starlark_plugin = _pkg._get_starlark_plugin()
if starlark_plugin:
success = starlark_plugin.uninstall_app(app_id)
else:
# Standalone: remove app dir and manifest entry. app_dir is the
# path _validate_starlark_app_path checked, not a fresh join.
import shutil
if app_dir.exists():
shutil.rmtree(app_dir)
manifest = _pkg._read_starlark_manifest()
manifest.get('apps', {}).pop(app_id, None)
success = _pkg._write_starlark_manifest(manifest)
if success:
return jsonify({'status': 'success', 'message': f'App uninstalled: {app_id}'})
else:
return jsonify({'status': 'error', 'message': 'Failed to uninstall app'}), 500
except Exception as e:
logger.exception("[Starlark] uninstall_starlark_app failed")
return jsonify({'status': 'error', 'message': 'Failed to uninstall Starlark app', 'details': describe_exception(e)}), 500
@api_v3.route('/starlark/apps/<app_id>/config', methods=['GET'])
def get_starlark_app_config(app_id):
"""Get configuration for a Starlark app."""
try:
# Validate app_id before any filesystem access
app_dir, error_msg = _validate_starlark_app_path(app_id)
if error_msg:
return jsonify({'status': 'error', 'message': error_msg}), 400
starlark_plugin = _pkg._get_starlark_plugin()
if starlark_plugin:
app = starlark_plugin.apps.get(app_id)
if not app:
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
return jsonify({'status': 'success', 'config': app.config, 'schema': app.schema})
# Standalone: read from config.json. app_dir is the path
# _validate_starlark_app_path checked, not a fresh join.
config_file = app_dir / "config.json"
if not app_dir.exists():
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
config = {}
if config_file.exists():
try:
with open(config_file, 'r') as f:
config = json.load(f)
except (OSError, json.JSONDecodeError) as e:
logger.warning(f"Failed to load config for {app_id}: {e}")
# Load schema from schema.json
schema = None
schema_file = app_dir / "schema.json"
if schema_file.exists():
try:
with open(schema_file, 'r') as f:
schema = json.load(f)
except Exception as e:
logger.warning(f"Failed to load schema for {app_id}: {e}")
return jsonify({'status': 'success', 'config': config, 'schema': schema})
except Exception as e:
logger.exception("[Starlark] get_starlark_app_config failed")
return jsonify({'status': 'error', 'message': 'Failed to get Starlark app config', 'details': describe_exception(e)}), 500
@api_v3.route('/starlark/apps/<app_id>/config', methods=['PUT'])
def update_starlark_app_config(app_id):
"""Update configuration for a Starlark app."""
try:
# Validate app_id before any filesystem access
app_dir, error_msg = _validate_starlark_app_path(app_id)
if error_msg:
return jsonify({'status': 'error', 'message': error_msg}), 400
data = request.get_json(silent=True)
if not data:
return jsonify({'status': 'error', 'message': 'No configuration provided'}), 400
if 'render_interval' in data:
val, err = _validate_timing_value(data['render_interval'], 'render_interval')
if err:
return jsonify({'status': 'error', 'message': err}), 400
data['render_interval'] = val
if 'display_duration' in data:
val, err = _validate_timing_value(data['display_duration'], 'display_duration')
if err:
return jsonify({'status': 'error', 'message': err}), 400
data['display_duration'] = val
starlark_plugin = _pkg._get_starlark_plugin()
if starlark_plugin:
app = starlark_plugin.apps.get(app_id)
if not app:
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
# Extract timing keys from data before updating config (they belong in manifest, not config)
render_interval = data.pop('render_interval', None)
display_duration = data.pop('display_duration', None)
# Snapshot before mutating. save_config() can fail, and the route
# answers 500 below when it does -- but the loaded app kept the new
# values anyway, so a later GET returned configuration that was
# never persisted and the plugin rendered with it.
prev_config = dict(app.config)
prev_manifest = dict(app.manifest)
# Update config with non-timing fields only
app.config.update(data)
# Update manifest with timing fields
timing_changed = False
if render_interval is not None:
app.manifest['render_interval'] = render_interval
timing_changed = True
if display_duration is not None:
app.manifest['display_duration'] = display_duration
timing_changed = True
saved = app.save_config()
if not saved:
app.config.clear(); app.config.update(prev_config)
app.manifest.clear(); app.manifest.update(prev_manifest)
if saved:
# Persist manifest if timing changed (same pattern as toggle endpoint)
if timing_changed:
try:
# Use safe manifest update to prevent race conditions
timing_updates = {}
if render_interval is not None:
timing_updates['render_interval'] = render_interval
if display_duration is not None:
timing_updates['display_duration'] = display_duration
def update_fn(manifest):
manifest['apps'][app_id].update(timing_updates)
starlark_plugin._update_manifest_safe(update_fn)
except Exception as e:
logger.warning(f"Failed to persist timing to manifest for {app_id}: {e}")
starlark_plugin._render_app(app, force=True)
return jsonify({'status': 'success', 'message': 'Configuration updated', 'config': app.config})
else:
return jsonify({'status': 'error', 'message': 'Failed to save configuration'}), 500
# Standalone: update both config.json and manifest
manifest = _pkg._read_starlark_manifest()
app_data = manifest.get('apps', {}).get(app_id)
if not app_data:
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
# Extract timing keys (they go in manifest, not config.json)
render_interval = data.pop('render_interval', None)
display_duration = data.pop('display_duration', None)
# Update manifest with timing values
if render_interval is not None:
app_data['render_interval'] = render_interval
if display_duration is not None:
app_data['display_duration'] = display_duration
# Load current config from config.json. app_dir is the path
# _validate_starlark_app_path checked, not a fresh join.
config_file = app_dir / "config.json"
current_config = {}
if config_file.exists():
try:
with open(config_file, 'r') as f:
current_config = json.load(f)
except Exception as e:
logger.warning(f"Failed to load config for {app_id}: {e}")
# Update config with new values (excluding timing keys)
current_config.update(data)
# Write updated config to config.json
try:
with open(config_file, 'w') as f:
json.dump(current_config, f, indent=2)
except Exception as e:
logger.error(f"Failed to save config.json for {app_id}: {e}")
logger.exception("Failed to save Starlark configuration for %r", app_id)
return jsonify({'status': 'error', 'message': 'Failed to save configuration',
'details': describe_exception(e)}), 500
# Also update manifest for backward compatibility
app_data.setdefault('config', {}).update(data)
if _pkg._write_starlark_manifest(manifest):
return jsonify({'status': 'success', 'message': 'Configuration updated', 'config': current_config})
else:
return jsonify({'status': 'error', 'message': 'Failed to save manifest'}), 500
except Exception as e:
logger.exception("[Starlark] update_starlark_app_config failed")
return jsonify({'status': 'error', 'message': 'Failed to update Starlark app config', 'details': describe_exception(e)}), 500
@api_v3.route('/starlark/apps/<app_id>/toggle', methods=['POST'])
def toggle_starlark_app(app_id):
"""Enable or disable a Starlark app."""
try:
data = request.get_json(silent=True) or {}
starlark_plugin = _pkg._get_starlark_plugin()
if starlark_plugin:
app = starlark_plugin.apps.get(app_id)
if not app:
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
enabled = data.get('enabled')
if enabled is None:
enabled = not app.is_enabled()
app.manifest['enabled'] = enabled
# Use safe manifest update to prevent race conditions
def update_fn(manifest):
manifest['apps'][app_id]['enabled'] = enabled
starlark_plugin._update_manifest_safe(update_fn)
return jsonify({'status': 'success', 'message': f"App {'enabled' if enabled else 'disabled'}", 'enabled': enabled})
# Standalone: update manifest directly
manifest = _pkg._read_starlark_manifest()
app_data = manifest.get('apps', {}).get(app_id)
if not app_data:
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
enabled = data.get('enabled')
if enabled is None:
enabled = not app_data.get('enabled', True)
app_data['enabled'] = enabled
if _pkg._write_starlark_manifest(manifest):
return jsonify({'status': 'success', 'message': f"App {'enabled' if enabled else 'disabled'}", 'enabled': enabled})
else:
return jsonify({'status': 'error', 'message': 'Failed to save'}), 500
except Exception as e:
logger.exception("[Starlark] toggle_starlark_app failed")
return jsonify({'status': 'error', 'message': 'Failed to toggle Starlark app', 'details': describe_exception(e)}), 500
@api_v3.route('/starlark/apps/<app_id>/render', methods=['POST'])
def render_starlark_app(app_id):
"""Force render a Starlark app."""
try:
app_dir, err = _validate_starlark_app_path(app_id)
if err:
return jsonify({'status': 'error', 'message': err}), 400
starlark_plugin = _pkg._get_starlark_plugin()
if starlark_plugin:
app = starlark_plugin.apps.get(app_id)
if not app:
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
success = starlark_plugin._render_app(app, force=True)
if success:
return jsonify({'status': 'success', 'message': 'App rendered',
'frame_count': len(app.frames) if app.frames else 0})
return jsonify({'status': 'error', 'message': 'Failed to render app'}), 500
# Web-service context: plugin not loaded, call pixlet directly
success, status_code, error = _standalone_render_starlark_app(app_id)
if success:
return jsonify({'status': 'success', 'message': 'App rendered successfully', 'frame_count': 0}), status_code
return jsonify({'status': 'error', 'message': error or 'Render failed', 'frame_count': 0}), status_code
except Exception as e:
logger.exception("[Starlark] render_starlark_app failed")
return jsonify({'status': 'error', 'message': 'Failed to render Starlark app', 'details': describe_exception(e)}), 500
@api_v3.route('/starlark/repository/browse', methods=['GET'])
def browse_tronbyte_repository():
"""Browse all apps in the Tronbyte repository (bulk cached fetch).
Returns ALL apps with metadata, categories, and authors.
Filtering/sorting/pagination is handled client-side.
Results are cached server-side for 2 hours.
"""
try:
TronbyteRepository = _pkg._get_tronbyte_repository_class()
repo = TronbyteRepository(github_token=_starlark_github_token())
result = repo.list_all_apps_cached()
rate_limit = repo.get_rate_limit_info()
# An upstream failure used to arrive here as an empty app list and go
# out as 'success', so the store drew an empty grid and said nothing.
# 502: the request was fine, GitHub was not.
if result.get('error'):
return jsonify({
'status': 'error',
'message': result['error'],
'rate_limit': rate_limit,
}), 502
return jsonify({
'status': 'success',
'apps': result['apps'],
'categories': result['categories'],
'authors': result['authors'],
'count': result['count'],
'cached': result['cached'],
'rate_limit': rate_limit,
})
except Exception as e:
logger.exception("[Starlark] browse_tronbyte_repository failed")
return jsonify({'status': 'error', 'message': 'Failed to browse repository', 'details': describe_exception(e)}), 500
@api_v3.route('/starlark/repository/install', methods=['POST'])
def install_from_tronbyte_repository():
"""Install an app from the Tronbyte repository."""
try:
data = request.get_json(silent=True)
if not data or 'app_id' not in data:
return jsonify({'status': 'error', 'message': 'app_id is required'}), 400
app_id, app_id_error = _validate_and_sanitize_app_id(data['app_id'])
if app_id_error:
return jsonify({'status': 'error', 'message': f'Invalid app_id: {app_id_error}'}), 400
TronbyteRepository = _pkg._get_tronbyte_repository_class()
import tempfile
repo = TronbyteRepository(github_token=_starlark_github_token())
success, metadata, error = repo.get_app_metadata(data['app_id'])
if not success:
return jsonify({'status': 'error', 'message': f'Failed to fetch app metadata: {error}'}), 404
with tempfile.NamedTemporaryFile(delete=False, suffix='.star') as tmp:
temp_path = tmp.name
try:
# Pass filename from metadata (e.g., "analog_clock.star" for analogclock app)
# Note: manifest uses 'fileName' (camelCase), not 'filename'
filename = metadata.get('fileName') if metadata else None
success, error = repo.download_star_file(data['app_id'], Path(temp_path), filename=filename)
if not success:
return jsonify({'status': 'error', 'message': f'Failed to download app: {error}'}), 500
# Download assets (images, sources, etc.) to a temp directory
import tempfile
temp_assets_dir = tempfile.mkdtemp()
try:
success_assets, error_assets = repo.download_app_assets(data['app_id'], Path(temp_assets_dir))
# Asset download is non-critical - log warning but continue if it fails
if not success_assets:
logger.warning(f"Failed to download assets for {data['app_id']}: {error_assets}")
render_interval = data.get('render_interval', 300)
ri, err = _validate_timing_value(render_interval, 'render_interval')
if err:
return jsonify({'status': 'error', 'message': err}), 400
render_interval = ri or 300
display_duration = data.get('display_duration', 15)
dd, err = _validate_timing_value(display_duration, 'display_duration')
if err:
return jsonify({'status': 'error', 'message': err}), 400
display_duration = dd or 15
install_metadata = {
'name': metadata.get('name', app_id) if metadata else app_id,
'render_interval': render_interval,
'display_duration': display_duration
}
starlark_plugin = _pkg._get_starlark_plugin()
if starlark_plugin:
success = starlark_plugin.install_app(app_id, temp_path, install_metadata, assets_dir=temp_assets_dir)
else:
success = _install_star_file(app_id, temp_path, install_metadata, assets_dir=temp_assets_dir)
finally:
# Clean up temp assets directory
import shutil
try:
shutil.rmtree(temp_assets_dir)
except OSError:
pass
if success:
return jsonify({'status': 'success', 'message': f'App installed: {metadata.get("name", app_id) if metadata else app_id}', 'app_id': app_id})
else:
return jsonify({'status': 'error', 'message': 'Failed to install app'}), 500
finally:
try:
os.unlink(temp_path)
except OSError:
pass
except Exception as e:
logger.exception("[Starlark] install_from_tronbyte_repository failed")
return jsonify({'status': 'error', 'message': 'Failed to install from repository', 'details': describe_exception(e)}), 500
@api_v3.route('/starlark/repository/categories', methods=['GET'])
def get_tronbyte_categories():
"""Get list of available app categories (uses bulk cache)."""
try:
TronbyteRepository = _pkg._get_tronbyte_repository_class()
repo = TronbyteRepository(github_token=_starlark_github_token())
result = repo.list_all_apps_cached()
if result.get('error'):
return jsonify({'status': 'error', 'message': result['error']}), 502
return jsonify({'status': 'success', 'categories': result['categories']})
except Exception as e:
logger.exception("[Starlark] get_tronbyte_categories failed")
return jsonify({'status': 'error', 'message': 'Failed to fetch categories', 'details': describe_exception(e)}), 500
@api_v3.route('/starlark/editor/apps', methods=['GET'])
def list_pixlet_editor_apps():
"""Apps on disk that the editor can open.
Read from the directory rather than the loaded plugin: the editor works on
files, and the plugin may not be loaded in this process at all.
"""
try:
apps = []
if _STARLARK_APPS_DIR.is_dir():
for entry in sorted(_STARLARK_APPS_DIR.iterdir()):
if not entry.is_dir():
continue
star_files = sorted(entry.glob('*.star'))
name = entry.name
manifest = entry / 'manifest.json'
if manifest.is_file():
try:
with open(manifest, encoding='utf-8') as handle:
data = json.load(handle)
if isinstance(data, dict):
name = data.get('name') or name
except (OSError, json.JSONDecodeError):
pass
apps.append({
'id': entry.name,
'name': name,
'editable': bool(star_files),
'has_config': (entry / 'config.json').is_file(),
})
return jsonify({'status': 'success', 'data': {
'apps': apps,
'apps_dir': str(_STARLARK_APPS_DIR),
'pixlet_available': _find_pixlet_binary() is not None,
}})
except Exception as e:
logger.exception('Error listing editor apps')
return jsonify({'status': 'error', 'message': 'Could not list apps',
'details': describe_exception(e)}), 500
@api_v3.route('/starlark/editor/status', methods=['GET'])
def get_pixlet_editor_status():
"""Whether a session is running, and how long it has left."""
try:
return jsonify({'status': 'success', 'data': _pixlet_editor_status()})
except Exception as e:
logger.exception('Error reading editor status')
return jsonify({'status': 'error', 'message': 'Could not read editor status',
'details': describe_exception(e)}), 500
@api_v3.route('/starlark/editor/start', methods=['POST'])
def start_pixlet_editor():
"""Start an editing session for one app."""
try:
data = request.get_json(silent=True) or {}
app_id = data.get('app_id')
app_dir, err = _validate_starlark_app_path(app_id or '')
if err or not app_dir:
return jsonify({'status': 'error', 'message': err or 'Invalid app_id'}), 400
if not app_dir.is_dir():
return jsonify({'status': 'error', 'message': f'No such app: {app_id}'}), 404
if not any(app_dir.glob('*.star')):
return jsonify({'status': 'error',
'message': f'{app_id} has no .star file to edit'}), 400
if not _PIXLET_EDITOR_SCRIPT.is_file():
return jsonify({'status': 'error', 'message': 'Editor script not found'}), 404
if _find_pixlet_binary() is None:
return jsonify({'status': 'error',
'message': 'Pixlet is not installed - install it first'}), 503
current = _pixlet_editor_status()
if current.get('running'):
return jsonify({'status': 'error',
'message': f"An editor session for '{current.get('app_id')}' is "
f"already running; stop it first"}), 409
try:
timeout_s = int(data.get('timeout') or _PIXLET_EDITOR_DEFAULT_TIMEOUT)
except (TypeError, ValueError):
return jsonify({'status': 'error', 'message': 'timeout must be a whole number'}), 400
if not 60 <= timeout_s <= _PIXLET_EDITOR_MAX_TIMEOUT:
return jsonify({'status': 'error',
'message': f'timeout must be between 60 and '
f'{_PIXLET_EDITOR_MAX_TIMEOUT} seconds'}), 400
try:
port = int(data.get('port') or _PIXLET_EDITOR_DEFAULT_PORT)
except (TypeError, ValueError):
return jsonify({'status': 'error', 'message': 'port must be a whole number'}), 400
if not 1024 <= port <= 65535:
return jsonify({'status': 'error', 'message': 'port must be between 1024 and 65535'}), 400
env = dict(os.environ)
env['PIXLET_EDITOR_PORT'] = str(port)
env['PIXLET_EDITOR_TIMEOUT'] = str(timeout_s)
# A browser reaching this endpoint is remote by definition, so the
# session has to listen on more than loopback to be usable at all.
env['PIXLET_EDITOR_HOST'] = '0.0.0.0'
log_path = Path(tempfile.gettempdir()) / 'ledmatrix_pixlet_editor.log'
log_handle = open(log_path, 'w', encoding='utf-8') # noqa: SIM115 - owned by the child
try:
# start_new_session so the script leads its own process group: the
# stop route signals the group, which is what lets the EXIT trap run
# and hand the display back.
process = subprocess.Popen( # nosec B603 - fixed script path, validated app_id
['/bin/bash', str(_PIXLET_EDITOR_SCRIPT), app_dir.name],
cwd=str(PROJECT_ROOT), env=env,
stdout=log_handle, stderr=subprocess.STDOUT,
start_new_session=True)
finally:
log_handle.close()
now = time.time()
state = {'pid': process.pid, 'app_id': app_dir.name, 'port': port,
'timeout': timeout_s, 'started_at': now, 'deadline': now + timeout_s,
'host': '0.0.0.0', 'log': str(log_path)}
try:
with open(_PIXLET_EDITOR_STATE, 'w', encoding='utf-8') as handle:
json.dump(state, handle)
except OSError as err:
logger.warning('Started an editor session but could not record it: %s', err)
logger.info('Pixlet editor started for %s on port %s (pid %s, %ss limit)',
app_dir.name, port, process.pid, timeout_s)
return jsonify({'status': 'success',
'message': f"Editing '{app_dir.name}'. The display is stopped until "
f"the session ends.",
'data': _pixlet_editor_status()})
except Exception as e:
logger.exception('Error starting the pixlet editor')
return jsonify({'status': 'error', 'message': 'Could not start the editor',
'details': describe_exception(e)}), 500
@api_v3.route('/starlark/editor/stop', methods=['POST'])
def stop_pixlet_editor():
"""End the running session and give the display back."""
try:
state = _read_pixlet_editor_state()
if not state or not _pixlet_editor_alive(state.get('pid')):
_clear_pixlet_editor_state()
return jsonify({'status': 'success', 'message': 'No editor session was running.',
'data': {'running': False}})
pid = int(state['pid'])
# SIGTERM the group, not the pid: bash forwards nothing to `timeout` and
# its child on its own, and the trap needs to run to restart the display.
try:
os.killpg(os.getpgid(pid), signal.SIGTERM)
except (ProcessLookupError, PermissionError, OSError) as err:
logger.debug('Could not signal the editor process group: %s', err)
with contextlib.suppress(ProcessLookupError, PermissionError):
os.kill(pid, signal.SIGTERM)
# Give the trap a moment to stop pixlet and restart ledmatrix.
deadline = time.time() + 10
while time.time() < deadline and _pixlet_editor_alive(pid):
time.sleep(0.25)
if _pixlet_editor_alive(pid):
logger.warning('Editor session %s ignored SIGTERM; sending SIGKILL. The display '
'may need a manual restart.', pid)
with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
os.killpg(os.getpgid(pid), signal.SIGKILL)
_clear_pixlet_editor_state()
return jsonify({'status': 'success',
'message': 'Editor stopped; the display is restarting.',
'data': {'running': False}})
except Exception as e:
logger.exception('Error stopping the pixlet editor')
return jsonify({'status': 'error', 'message': 'Could not stop the editor',
'details': describe_exception(e)}), 500
+619
View File
@@ -0,0 +1,619 @@
"""Service control, updates, versions and system status.
Routes decorate the shared `api_v3` Blueprint from ._common, so their
endpoint names are unchanged by living here.
"""
from web_interface.blueprints.api_v3 import (
Any, Dict, PROJECT_ROOT, Path, _GIT, _UPDATE_CHECK_TTL,
_describe_git_failure, _get_display_service_status, _git_current_branch,
_git_remote_branch_exists, _git_upstream, _pip_install_requirements,
_scrub_git_remote_url, _truncate_output, _update_check_cache,
_update_check_failed, api_v3, checkout_branch, describe_exception,
get_git_version, jsonify, logger, os, request, resolve_pull_command,
shutil, subprocess,
)
import web_interface.blueprints.api_v3 as _pkg
# Read through the module rather than bound by value: tests patch these
# as module attributes, and a value binding would not see the patch.
# Several are also called from helpers that live in __init__, so the
# package is the only patch point that covers every caller.
@api_v3.route('/system/status', methods=['GET'])
def get_system_status():
"""Get system status"""
try:
# Check cache first (10 second TTL for system status)
try:
from web_interface.cache import get_cached, set_cached
cached_result = get_cached('system_status', ttl_seconds=10)
if cached_result is not None:
return jsonify({'status': 'success', 'data': cached_result})
except ImportError:
# Cache not available, continue without caching
get_cached = None
set_cached = None
# Import psutil for system monitoring
try:
import psutil
except ImportError:
# Fallback if psutil not available
return jsonify({
'status': 'error',
'message': 'psutil not available for system monitoring'
}), 503
# Get system metrics using psutil
cpu_percent = psutil.cpu_percent(interval=0.1) # Short interval for responsiveness
memory = psutil.virtual_memory()
memory_percent = memory.percent
disk = psutil.disk_usage('/')
disk_percent = disk.percent
# Calculate uptime
boot_time = psutil.boot_time()
uptime_seconds = _pkg.time.time() - boot_time
uptime_hours = uptime_seconds / 3600
uptime_days = uptime_hours / 24
# Format uptime string
if uptime_days >= 1:
uptime_str = f"{int(uptime_days)}d {int(uptime_hours % 24)}h"
elif uptime_hours >= 1:
uptime_str = f"{int(uptime_hours)}h {int((uptime_seconds % 3600) / 60)}m"
else:
uptime_str = f"{int(uptime_seconds / 60)}m"
# Get CPU temperature (Raspberry Pi)
cpu_temp = None
try:
temp_file = '/sys/class/thermal/thermal_zone0/temp'
if os.path.exists(temp_file):
with open(temp_file, 'r') as f:
temp_millidegrees = int(f.read().strip())
cpu_temp = temp_millidegrees / 1000.0 # Convert to Celsius
except (IOError, ValueError, OSError):
# Temperature sensor not available or error reading
cpu_temp = None
# Get display service status
service_status = _get_display_service_status()
status = {
'timestamp': _pkg.time.time(),
'uptime': uptime_str,
'uptime_seconds': int(uptime_seconds),
'service_active': service_status.get('active', False),
'cpu_percent': round(cpu_percent, 1),
'memory_used_percent': round(memory_percent, 1),
'memory_total_mb': round(memory.total / (1024 * 1024), 1),
'memory_used_mb': round(memory.used / (1024 * 1024), 1),
# MemAvailable, not total-minus-used: it accounts for reclaimable
# page cache, so it is what actually predicts memory trouble. A
# board can read 70% "used" and be fine, or read the same and be
# about to fail fork(), and only this number tells them apart.
'memory_available_mb': round(memory.available / (1024 * 1024), 1),
'cpu_temp': round(cpu_temp, 1) if cpu_temp is not None else None,
'disk_used_percent': round(disk_percent, 1),
'disk_total_gb': round(disk.total / (1024 * 1024 * 1024), 1),
'disk_used_gb': round(disk.used / (1024 * 1024 * 1024), 1)
}
# Cache the result if available
if set_cached:
try:
set_cached('system_status', status, ttl_seconds=10)
except Exception:
pass # Cache write failed, but continue
return jsonify({'status': 'success', 'data': status})
except Exception as e:
logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/system/version', methods=['GET'])
def get_system_version():
"""Get LEDMatrix repository version"""
try:
version = get_git_version()
return jsonify({'status': 'success', 'data': {'version': version}})
except Exception as e:
logger.error("get_system_version failed: %s", e, exc_info=True)
return jsonify({'status': 'error', 'message': 'Unable to retrieve version'}), 500
@api_v3.route('/system/check-update', methods=['GET'])
def check_for_update():
"""Check whether a newer LEDMatrix commit is available on origin/main."""
now = _pkg.time.time()
if _update_check_cache['result'] and now - _update_check_cache['ts'] < _UPDATE_CHECK_TTL:
return jsonify(_update_check_cache['result'])
_safe: Dict[str, Any] = {'update_available': False, 'remote_sha': 'unknown', 'commits_behind': 0}
try:
cwd = str(PROJECT_ROOT)
fetch_result = subprocess.run(
['git', 'fetch', 'origin', 'main', '--quiet'],
capture_output=True, timeout=10, cwd=cwd,
)
if fetch_result.returncode != 0:
stderr = fetch_result.stderr.decode(errors='replace').strip()
logger.warning("check-update: git fetch failed (rc=%d): %s",
fetch_result.returncode, stderr)
failed = _update_check_failed(_describe_git_failure(stderr))
_update_check_cache['result'] = failed
_update_check_cache['ts'] = now
return jsonify(failed)
local = subprocess.run(
['git', 'rev-parse', 'HEAD'],
capture_output=True, text=True, timeout=5, cwd=cwd,
).stdout.strip()
remote = subprocess.run(
['git', 'rev-parse', 'origin/main'],
capture_output=True, text=True, timeout=5, cwd=cwd,
).stdout.strip()
if not local or not remote:
return jsonify(_safe)
if local == remote:
result: Dict[str, Any] = {'update_available': False, 'remote_sha': remote, 'commits_behind': 0}
else:
count_str = subprocess.run(
['git', 'rev-list', 'HEAD..origin/main', '--count'],
capture_output=True, text=True, timeout=5, cwd=cwd,
).stdout.strip()
count = int(count_str) if count_str.isdigit() else 0
result = {'update_available': count > 0, 'remote_sha': remote, 'commits_behind': count}
_update_check_cache['result'] = result
_update_check_cache['ts'] = now
return jsonify(result)
except Exception as e:
logger.warning("check-update failed: %s", e)
return jsonify(_update_check_failed(
"Could not check for updates; see logs for details."))
@api_v3.route('/system/action', methods=['POST'])
def execute_system_action():
"""Execute system actions (start/stop/reboot/etc)"""
try:
# HTMX sends data as form data, not JSON
data = request.get_json(silent=True) or {}
if not data:
# Try to get from form data if JSON fails
data = {
'action': request.form.get('action'),
'mode': request.form.get('mode')
}
if not data or 'action' not in data:
return jsonify({'status': 'error', 'message': 'Action required'}), 400
action = data['action']
mode = data.get('mode') # For on-demand modes
# Map actions to subprocess calls (similar to original implementation)
if action == 'start_display':
if mode:
# For on-demand modes, we would need to integrate with the display controller
# For now, just start the display service
try:
result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix.service'],
capture_output=True, text=True, timeout=10)
except subprocess.TimeoutExpired as e:
logger.error("start_display (%s) timed out: %s", mode, e)
return jsonify({'status': 'error', 'message': 'Command timed out', 'returncode': -1, 'stderr': 'timeout'})
logger.info("start_display (%s) returned code %d", mode, result.returncode)
if result.returncode != 0 and result.stderr:
logger.error("start_display (%s) stderr: %s", mode, result.stderr.strip())
resp = {
'status': 'success' if result.returncode == 0 else 'error',
'message': 'Display started' if result.returncode == 0 else 'Failed to start display',
}
if result.returncode != 0:
resp['returncode'] = result.returncode
resp['stderr'] = result.stderr.strip()
return jsonify(resp)
else:
result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix.service'],
capture_output=True, text=True, timeout=10)
elif action == 'stop_display':
result = subprocess.run(['sudo', 'systemctl', 'stop', 'ledmatrix.service'],
capture_output=True, text=True, timeout=10)
elif action == 'enable_autostart':
result = subprocess.run(['sudo', 'systemctl', 'enable', 'ledmatrix.service'],
capture_output=True, text=True, timeout=10)
elif action == 'disable_autostart':
result = subprocess.run(['sudo', 'systemctl', 'disable', 'ledmatrix.service'],
capture_output=True, text=True, timeout=10)
elif action == 'reboot_system':
result = subprocess.run(['sudo', 'reboot'],
capture_output=True, text=True, timeout=10)
elif action == 'shutdown_system':
result = subprocess.run(['sudo', 'poweroff'],
capture_output=True, text=True, timeout=10)
elif action == 'git_pull':
# Use PROJECT_ROOT instead of hardcoded path
project_dir = str(PROJECT_ROOT)
# Decide how to pull BEFORE stashing. If this checkout cannot be
# updated at all, stashing first would put the user's local changes
# away for an update that was never going to run.
pull_args, upstream_note, pull_error = resolve_pull_command(project_dir)
if pull_error:
logger.warning("git pull not attempted: %s", pull_error)
return jsonify({'status': 'error', 'message': pull_error})
# Check if there are local changes that need to be stashed
# Exclude plugins directory - plugins are separate repos and shouldn't be stashed with base project
# Use --untracked-files=no to skip untracked files check (much faster with symlinked plugins)
try:
status_result = subprocess.run(
['git', 'status', '--porcelain', '--untracked-files=no'],
capture_output=True,
text=True,
timeout=30,
cwd=project_dir
)
# Filter out any changes in plugins directory - plugins are separate repositories
# Git status format: XY filename (where X is status of index, Y is status of work tree)
status_lines = [line for line in status_result.stdout.strip().split('\n')
if line.strip() and 'plugins/' not in line]
has_changes = bool('\n'.join(status_lines).strip())
except subprocess.TimeoutExpired:
# If status check times out, assume there might be changes and proceed
# This is safer than failing the update
has_changes = True
status_result = type('obj', (object,), {'stdout': '', 'stderr': 'Status check timed out'})()
stash_info = ""
# Stash local changes if they exist (excluding plugins)
# Plugins are separate repositories and shouldn't be stashed with base project updates
if has_changes:
try:
# Use pathspec to exclude plugins directory from stash
stash_result = subprocess.run(
['git', 'stash', 'push', '-m', 'LEDMatrix auto-stash before update', '--', ':!plugins'],
capture_output=True,
text=True,
timeout=30,
cwd=project_dir
)
if stash_result.returncode == 0:
logger.debug("git stash: stashed local changes before pull")
stash_info = " Local changes were stashed."
else:
logger.warning("git stash failed before pull (returncode=%d)", stash_result.returncode)
except subprocess.TimeoutExpired:
logger.warning("git stash timed out, proceeding with pull")
# Record HEAD before the pull so dependency changes can be detected
old_head = None
try:
_pre = subprocess.run(['git', 'rev-parse', 'HEAD'],
capture_output=True, text=True, timeout=10, cwd=project_dir)
if _pre.returncode == 0:
old_head = _pre.stdout.strip()
except subprocess.TimeoutExpired:
logger.warning("git rev-parse timed out before pull")
# Whether the pull actually brought new code in. "Already up to
# date" is a success too, and prompting for a restart then would
# train users to ignore the prompt.
code_changed = False
# Perform the git pull. Branches without an upstream were given
# an explicit "origin <branch>" above so the update still works.
result = subprocess.run(
pull_args,
capture_output=True,
text=True,
timeout=60,
cwd=project_dir
)
# Give the branch tracking information so the next pull is a plain
# `git pull` — otherwise every update repeats the fallback.
if result.returncode == 0 and upstream_note:
branch = _git_current_branch(project_dir)
if branch:
try:
subprocess.run(
['git', 'branch', f'--set-upstream-to=origin/{branch}', branch],
capture_output=True, text=True, timeout=10, cwd=project_dir)
except (subprocess.TimeoutExpired, OSError) as exc:
logger.debug("could not set upstream for %s: %s", branch, exc)
# Return custom response for git_pull
if result.returncode == 0:
pull_message = "Code updated successfully."
if has_changes:
pull_message = f"Code updated successfully. Local changes were automatically stashed.{stash_info}"
if result.stdout and "Already up to date" not in result.stdout:
pull_message = f"Code updated successfully.{stash_info}"
if upstream_note:
pull_message = f"{pull_message} {upstream_note}"
# Keep Python dependencies in sync automatically: if the pull
# changed a requirements file, install it now — users updating
# from the web UI (most of them) never SSH in to pip install.
# Installs go through the same root-visible path as the
# Tools-tab buttons (_pip_install_requirements).
dep_notes = []
try:
_post = subprocess.run(['git', 'rev-parse', 'HEAD'],
capture_output=True, text=True, timeout=10, cwd=project_dir)
new_head = _post.stdout.strip() if _post.returncode == 0 else None
if old_head and new_head and old_head != new_head:
code_changed = True
diff = subprocess.run(
['git', 'diff', '--name-only', f'{old_head}..{new_head}'],
capture_output=True, text=True, timeout=15, cwd=project_dir)
changed = set(diff.stdout.split()) if diff.returncode == 0 else set()
for rel in ('requirements.txt', 'web_interface/requirements.txt'):
req_path = PROJECT_ROOT / rel
if rel not in changed or not req_path.exists():
continue
# Each file's install is isolated: a timeout or
# OSError (e.g. the sudo wrapper/interpreter
# missing) on one file must not abort the other.
try:
r = _pip_install_requirements(req_path, timeout=180)
if r.returncode == 0:
dep_notes.append(f"Dependencies from {rel} updated.")
else:
dep_notes.append(
f"Dependency install from {rel} failed — "
"run Install Base Requirements from the Tools tab.")
logger.warning("post-update pip install failed for %s: %s",
rel, _truncate_output(r.stdout, r.stderr))
except subprocess.TimeoutExpired:
dep_notes.append(
f"Dependency install from {rel} timed out — "
"run Install Base Requirements from the Tools tab.")
logger.warning("post-update pip install timed out for %s", rel)
except OSError as install_err:
dep_notes.append(
f"Dependency install from {rel} failed — "
"run Install Base Requirements from the Tools tab.")
logger.warning("post-update pip install errored for %s: %s",
rel, install_err)
except subprocess.TimeoutExpired:
logger.warning("post-update dependency sync timed out")
if dep_notes:
pull_message += " " + " ".join(dep_notes)
# A `git pull` restores built-in plugins (committed under
# plugin-repos/) even if the user uninstalled them. Re-remove
# any the user previously uninstalled so the update doesn't
# resurrect them.
if api_v3.plugin_store_manager:
try:
purged = api_v3.plugin_store_manager.purge_uninstalled_plugins()
if purged:
logger.info(
"Re-removed %d uninstalled plugin(s) restored by update: %s",
len(purged), ", ".join(purged),
)
except (OSError, RuntimeError) as purge_err:
logger.warning("Post-update plugin purge failed: %s", purge_err)
else:
logger.warning("git pull failed (returncode=%d): %s", result.returncode, result.stderr)
# Show git's own first line: "check logs" leaves the user with
# nothing to act on, and these failures are usually actionable
# (conflicting local commits, no upstream, network).
detail = next((ln.strip() for ln in (result.stderr or '').splitlines()
if ln.strip()), '')
pull_message = f"Update failed: {detail}" if detail else "Update failed; check logs for details"
# Nothing here restarts anything: the pull replaces files on
# disk while the display and web services keep running the code
# they loaded at boot. Without this the user is told the update
# succeeded and sees no change until they happen to reboot.
return jsonify({
'status': 'success' if result.returncode == 0 else 'error',
'message': pull_message,
'restart_required': bool(result.returncode == 0 and code_changed),
})
elif action == 'checkout_branch':
# Switch branches from the Tools tab. Needed because a checkout
# that predates tracking (or a restored backup) can leave the pi
# on a branch the update button cannot pull.
result_payload, http_status = checkout_branch(
str(PROJECT_ROOT), data.get('branch') or '', stash=bool(data.get('stash')))
return jsonify(result_payload), http_status
elif action == 'restart_display_service':
result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix.service'],
capture_output=True, text=True, timeout=10)
elif action == 'restart_web_service':
# Try to restart the web service (assuming it's ledmatrix-web.service)
result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix-web.service'],
capture_output=True, text=True, timeout=10)
elif action == 'install_base_requirements':
# Base + web interface requirements: flask-compress and friends
# live in web_interface/requirements.txt, not the root file.
req_files = [f for f in (PROJECT_ROOT / 'requirements.txt',
PROJECT_ROOT / 'web_interface' / 'requirements.txt')
if f.exists()]
if not req_files:
return jsonify({'status': 'error', 'message': 'No requirements.txt found at project root'})
outputs = []
all_ok = True
for req_file in req_files:
label = req_file.relative_to(PROJECT_ROOT)
# Isolate each file's install: a timeout or OSError on one
# (e.g. requirements.txt) must not abort the rest of the
# loop (e.g. web_interface/requirements.txt never attempted).
try:
result = _pip_install_requirements(req_file, timeout=120)
all_ok = all_ok and result.returncode == 0
outputs.append(f"== {label} ==\n" + _truncate_output(result.stdout, result.stderr))
except subprocess.TimeoutExpired:
all_ok = False
outputs.append(f"== {label} ==\nTimed out after 120s")
logger.warning("install_base_requirements timed out for %s", label)
except OSError as install_err:
all_ok = False
outputs.append(f"== {label} ==\nFailed: {install_err}")
logger.warning("install_base_requirements errored for %s: %s", label, install_err)
return jsonify({
'status': 'success' if all_ok else 'error',
'message': 'Base requirements installed successfully' if all_ok else 'pip install failed',
'output': "\n".join(outputs)
})
elif action == 'install_plugin_requirements':
active_pm = getattr(api_v3, 'plugin_manager', None)
if active_pm:
plugins_dir = Path(active_pm.plugins_dir)
else:
_cm = getattr(api_v3, 'config_manager', None)
_cfg = _cm.load_config() if _cm else {}
_dir_name = _cfg.get('plugin_system', {}).get('plugins_directory', 'plugin-repos')
plugins_dir = Path(_dir_name) if os.path.isabs(_dir_name) else PROJECT_ROOT / _dir_name
results = []
if plugins_dir.exists():
for p in sorted(plugins_dir.iterdir()):
req = p / 'requirements.txt'
if p.is_dir() and req.exists():
try:
r = _pip_install_requirements(req, timeout=60)
results.append({
'plugin': p.name,
'ok': r.returncode == 0,
'output': _truncate_output(r.stdout, r.stderr)
})
except subprocess.TimeoutExpired:
results.append({'plugin': p.name, 'ok': False, 'output': 'pip install timed out'})
except OSError as exc:
results.append({'plugin': p.name, 'ok': False, 'output': exc.strerror or 'OS error'})
ok_count = sum(1 for r in results if r['ok'])
all_ok = all(r['ok'] for r in results) if results else True
return jsonify({
'status': 'success' if all_ok else 'error',
'message': f'Processed {len(results)} plugin(s) — {ok_count} succeeded' if results else 'No plugin requirements.txt files found',
'details': results
})
elif action == 'force_git_reset':
if not _GIT:
return jsonify({'status': 'error', 'message': 'git not found on this system'}), 503
project_dir = str(PROJECT_ROOT)
fetch = subprocess.run(
[_GIT, 'fetch', 'origin'],
capture_output=True, text=True, timeout=30, cwd=project_dir
)
if fetch.returncode != 0:
return jsonify({'status': 'error', 'message': 'git fetch failed', 'output': fetch.stderr.strip()})
reset = subprocess.run(
[_GIT, 'reset', '--hard', 'origin/main'],
capture_output=True, text=True, timeout=30, cwd=project_dir
)
return jsonify({
'status': 'success' if reset.returncode == 0 else 'error',
'message': 'Reset to origin/main successfully' if reset.returncode == 0 else 'git reset failed',
'output': (reset.stdout + reset.stderr).strip()
})
elif action == 'clear_pycache':
cleared = 0
failed = 0
for d in PROJECT_ROOT.rglob('__pycache__'):
if d.is_dir():
try:
shutil.rmtree(d)
cleared += 1
except OSError:
failed += 1
msg = f'Cleared {cleared} __pycache__ directories'
if failed:
msg += f' ({failed} could not be removed)'
return jsonify({'status': 'success', 'message': msg})
else:
return jsonify({'status': 'error', 'message': 'Unknown action'}), 400
logger.info("system action '%s' returncode=%d", action, result.returncode)
if result.returncode != 0 and result.stderr:
logger.error("system action '%s' stderr: %s", action, result.stderr.strip())
resp = {
'status': 'success' if result.returncode == 0 else 'error',
'message': 'Action completed' if result.returncode == 0 else 'Action failed; check logs for details',
}
if result.returncode != 0:
resp['returncode'] = result.returncode
resp['stderr'] = result.stderr.strip()
return jsonify(resp)
except subprocess.TimeoutExpired as e:
logger.error("system action '%s' timed out: %s", action, e)
return jsonify({'status': 'error', 'message': 'Command timed out', 'returncode': -1, 'stderr': 'timeout'})
except Exception as e:
logger.error("execute_system_action failed: %s", e, exc_info=True)
return jsonify({'status': 'error', 'message': 'Action failed; see logs for details'}), 500
@api_v3.route('/system/git-info', methods=['GET'])
def get_git_info():
"""Return branch, dirty state, recent commits and remote URL for the Tools tab."""
if not _GIT:
return jsonify({'status': 'error', 'message': 'git not found on this system'}), 503
d = str(PROJECT_ROOT)
try:
branch = subprocess.run([_GIT, 'branch', '--show-current'], capture_output=True, text=True, timeout=10, cwd=d)
if branch.returncode != 0:
return jsonify({'status': 'error', 'message': f'git branch failed: {branch.stderr.strip()}'}), 500
status = subprocess.run([_GIT, 'status', '--short', '--untracked-files=no'], capture_output=True, text=True, timeout=15, cwd=d)
if status.returncode != 0:
return jsonify({'status': 'error', 'message': f'git status failed: {status.stderr.strip()}'}), 500
log = subprocess.run([_GIT, 'log', '--oneline', '-5'], capture_output=True, text=True, timeout=10, cwd=d)
remote = subprocess.run([_GIT, 'remote', 'get-url', 'origin'], capture_output=True, text=True, timeout=10, cwd=d)
branch_name = branch.stdout.strip()
upstream = _git_upstream(d)
return jsonify({
'branch': branch_name,
'dirty': bool(status.stdout.strip()),
'status': status.stdout.strip(),
'recent_commits': log.stdout.strip() if log.returncode == 0 else '',
'remote_url': _scrub_git_remote_url(remote.stdout.strip()) if remote.returncode == 0 else '',
# Surfaced so the Tools tab can warn before the user clicks Pull
# Latest, rather than after it fails.
'upstream': upstream,
'can_pull': bool(upstream) or _git_remote_branch_exists(d, branch_name),
})
except Exception as e:
logger.error("get_git_info failed: %s", e, exc_info=True)
return jsonify({'status': 'error', 'message': 'Failed to get git info'}), 500
@api_v3.route('/system/git-branches', methods=['GET'])
def get_git_branches():
"""List branches available to switch to, for the Tools tab picker."""
if not _GIT:
return jsonify({'status': 'error', 'message': 'git not found on this system'}), 503
d = str(PROJECT_ROOT)
try:
# Refresh remote refs so a branch created since the last fetch shows up.
subprocess.run([_GIT, 'fetch', 'origin', '--prune'],
capture_output=True, text=True, timeout=60, cwd=d)
local = subprocess.run([_GIT, 'for-each-ref', '--format=%(refname:short)', 'refs/heads'],
capture_output=True, text=True, timeout=15, cwd=d)
remote = subprocess.run([_GIT, 'for-each-ref', '--format=%(refname:short)', 'refs/remotes/origin'],
capture_output=True, text=True, timeout=15, cwd=d)
if local.returncode != 0:
return jsonify({'status': 'error', 'message': 'Could not list branches'}), 500
local_names = [b for b in local.stdout.split() if b]
remote_names = []
for ref in remote.stdout.split() if remote.returncode == 0 else []:
name = ref.split('origin/', 1)[-1]
# origin/HEAD is a symbolic alias, not a branch a user can pick.
if name and name != 'HEAD' and name not in local_names:
remote_names.append(name)
return jsonify({
'status': 'success',
'current': _git_current_branch(d),
'upstream': _git_upstream(d),
'local': sorted(local_names),
'remote_only': sorted(remote_names),
})
except subprocess.TimeoutExpired:
return jsonify({'status': 'error', 'message': 'Timed out talking to the remote'}), 504
except OSError as e:
logger.error("get_git_branches failed: %s", e, exc_info=True)
return jsonify({'status': 'error', 'message': 'Failed to list branches'}), 500
+361
View File
@@ -0,0 +1,361 @@
"""Wi-Fi scanning, connection and status routes.
Routes decorate the shared `api_v3` Blueprint from ._common, so their
endpoint names are unchanged by living here.
"""
from web_interface.blueprints.api_v3 import (
api_v3, describe_exception, jsonify, logger, request,
)
# WiFi Management Endpoints
@api_v3.route('/wifi/status', methods=['GET'])
def get_wifi_status():
"""Get current WiFi connection status"""
try:
from src.wifi_manager import WiFiManager
wifi_manager = WiFiManager()
status = wifi_manager.get_wifi_status()
# Get auto-enable setting from config
auto_enable_ap = wifi_manager.config.get("auto_enable_ap_mode", True) # Default: True (safe due to grace period)
return jsonify({
'status': 'success',
'data': {
'connected': status.connected,
'ssid': status.ssid,
'ip_address': status.ip_address,
'signal': status.signal,
'ap_mode_active': status.ap_mode_active,
'auto_enable_ap_mode': auto_enable_ap
}
})
except Exception as e:
logger.error("%s failed", request.path, exc_info=True)
return jsonify({
'status': 'error',
'message': 'An error occurred; see logs for details',
'details': describe_exception(e)
}), 500
@api_v3.route('/wifi/scan', methods=['GET'])
def scan_wifi_networks():
"""Scan for available WiFi networks
If AP mode is active, it will be temporarily disabled during scanning
and automatically re-enabled afterward. Users connected to the AP will
be briefly disconnected during this process.
"""
try:
from src.wifi_manager import WiFiManager
wifi_manager = WiFiManager()
# Check if AP mode is active before scanning (for user notification)
ap_was_active = wifi_manager._is_ap_mode_active()
# Perform the scan (this will handle AP mode disabling/enabling internally)
networks, _was_cached = wifi_manager.scan_networks()
# Convert to dict format
networks_data = [
{
'ssid': net.ssid,
'signal': net.signal,
'security': net.security,
'frequency': net.frequency
}
for net in networks
]
response_data = {
'status': 'success',
'data': networks_data
}
# Inform user if AP mode was temporarily disabled
if ap_was_active:
response_data['message'] = (
f'Found {len(networks_data)} networks. '
'Note: AP mode was temporarily disabled during scanning and has been re-enabled. '
'If you were connected to the setup network, you may need to reconnect.'
)
return jsonify(response_data)
except Exception as e:
logger.error("Error scanning WiFi networks", exc_info=True)
error_message = 'An error occurred while scanning WiFi networks; see logs for details'
# Provide more specific error messages for common issues
error_str = str(e).lower()
if 'permission' in error_str or 'sudo' in error_str:
error_message = (
'Permission error while scanning. '
'The WiFi scan requires appropriate permissions. '
'Please ensure the application has necessary privileges.'
)
elif 'timeout' in error_str:
error_message = (
'WiFi scan timed out. '
'The scan took too long to complete. '
'This may happen if the WiFi interface is busy or in use.'
)
elif 'no wifi' in error_str or 'not available' in error_str:
error_message = (
'WiFi scanning tools are not available. '
'Please ensure NetworkManager (nmcli) or iwlist is installed.'
)
return jsonify({
'status': 'error',
'message': error_message
}), 500
@api_v3.route('/wifi/connect', methods=['POST'])
def connect_wifi():
"""Connect to a WiFi network"""
try:
from src.wifi_manager import WiFiManager
data = request.get_json(silent=True)
if not data:
return jsonify({
'status': 'error',
'message': 'Request body is required'
}), 400
if 'ssid' not in data:
return jsonify({
'status': 'error',
'message': 'SSID is required'
}), 400
ssid = data['ssid']
if not ssid or not ssid.strip():
return jsonify({
'status': 'error',
'message': 'SSID cannot be empty'
}), 400
ssid = ssid.strip()
password = data.get('password', '') or ''
wifi_manager = WiFiManager()
success, message = wifi_manager.connect_to_network(ssid, password)
if success:
return jsonify({
'status': 'success',
'message': message
})
else:
return jsonify({
'status': 'error',
'message': message or 'Failed to connect to network'
}), 400
except Exception as e:
logger.error("Error connecting to WiFi", exc_info=True)
return jsonify({
'status': 'error',
'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
}), 500
@api_v3.route('/wifi/disconnect', methods=['POST'])
def disconnect_wifi():
"""Disconnect from the current WiFi network"""
try:
from src.wifi_manager import WiFiManager
wifi_manager = WiFiManager()
success, message = wifi_manager.disconnect_from_network()
if success:
return jsonify({
'status': 'success',
'message': message
})
else:
return jsonify({
'status': 'error',
'message': message or 'Failed to disconnect from network'
}), 400
except Exception as e:
logger.error("Error disconnecting from WiFi", exc_info=True)
return jsonify({
'status': 'error',
'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
}), 500
@api_v3.route('/wifi/ap/enable', methods=['POST'])
def enable_ap_mode():
"""Enable access point mode"""
try:
from src.wifi_manager import WiFiManager
wifi_manager = WiFiManager()
_force_raw = (request.get_json(silent=True) or {}).get('force', False)
force = _force_raw is True or (isinstance(_force_raw, str) and _force_raw.lower() in ('true', '1'))
success, message = wifi_manager.enable_ap_mode(force=force)
if success:
return jsonify({
'status': 'success',
'message': message
})
else:
return jsonify({
'status': 'error',
'message': message
}), 400
except Exception as e:
logger.error("%s failed", request.path, exc_info=True)
return jsonify({
'status': 'error',
'message': 'An error occurred; see logs for details',
'details': describe_exception(e)
}), 500
@api_v3.route('/wifi/ap/disable', methods=['POST'])
def disable_ap_mode():
"""Disable access point mode"""
try:
from src.wifi_manager import WiFiManager
wifi_manager = WiFiManager()
success, message = wifi_manager.disable_ap_mode()
if success:
return jsonify({
'status': 'success',
'message': message
})
else:
return jsonify({
'status': 'error',
'message': message
}), 400
except Exception as e:
logger.error("%s failed", request.path, exc_info=True)
return jsonify({
'status': 'error',
'message': 'An error occurred; see logs for details',
'details': describe_exception(e)
}), 500
@api_v3.route('/wifi/ap/auto-enable', methods=['GET'])
def get_auto_enable_ap_mode():
"""Get auto-enable AP mode setting"""
try:
from src.wifi_manager import WiFiManager
wifi_manager = WiFiManager()
auto_enable = wifi_manager.config.get("auto_enable_ap_mode", True) # Default: True (safe due to grace period)
return jsonify({
'status': 'success',
'data': {
'auto_enable_ap_mode': auto_enable
}
})
except Exception as e:
logger.error("%s failed", request.path, exc_info=True)
return jsonify({
'status': 'error',
'message': 'An error occurred; see logs for details',
'details': describe_exception(e)
}), 500
@api_v3.route('/wifi/ap/auto-enable', methods=['POST'])
def set_auto_enable_ap_mode():
"""Set auto-enable AP mode setting"""
try:
from src.wifi_manager import WiFiManager
data = request.get_json(silent=True)
if data is None or 'auto_enable_ap_mode' not in data:
return jsonify({
'status': 'error',
'message': 'auto_enable_ap_mode is required'
}), 400
auto_enable = bool(data['auto_enable_ap_mode'])
wifi_manager = WiFiManager()
wifi_manager.config["auto_enable_ap_mode"] = auto_enable
wifi_manager._save_config()
return jsonify({
'status': 'success',
'message': f'Auto-enable AP mode set to {auto_enable}',
'data': {
'auto_enable_ap_mode': auto_enable
}
})
except Exception as e:
logger.error("%s failed", request.path, exc_info=True)
return jsonify({
'status': 'error',
'message': 'An error occurred; see logs for details',
'details': describe_exception(e)
}), 500
@api_v3.route('/wifi/radio', methods=['GET'])
def get_wifi_radio():
"""Get current WiFi radio state (enabled/disabled) and wired-fallback status."""
try:
from src.wifi_manager import WiFiManager
wifi_manager = WiFiManager()
state = wifi_manager.get_wifi_radio_state()
return jsonify({
'status': 'success',
'data': state
})
except Exception as e:
logger.error("Error getting WiFi radio state", exc_info=True)
return jsonify({
'status': 'error',
'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
}), 500
@api_v3.route('/wifi/radio', methods=['POST'])
def set_wifi_radio():
"""Turn the WiFi radio on or off.
Body: {"enabled": bool, "force": bool (optional)}. Disabling is refused
unless Ethernet is connected or force=True, to avoid locking the user out
of this web interface.
"""
try:
from src.wifi_manager import WiFiManager
data = request.get_json(silent=True) or {}
if 'enabled' not in data:
return jsonify({
'status': 'error',
'message': 'enabled is required'
}), 400
# Parse defensively: bool("false") is True, so mirror the string-aware
# coercion used for `force` — the endpoint is a public contract, not just
# the shipped UI (which always sends real JSON booleans).
_enabled_raw = data['enabled']
enabled = _enabled_raw is True or (isinstance(_enabled_raw, str) and _enabled_raw.lower() in ('true', '1', 'yes'))
_force_raw = data.get('force', False)
force = _force_raw is True or (isinstance(_force_raw, str) and _force_raw.lower() in ('true', '1', 'yes'))
wifi_manager = WiFiManager()
success, message, reason = wifi_manager.set_wifi_radio(enabled, force=force)
if success:
return jsonify({
'status': 'success',
'message': message,
'data': wifi_manager.get_wifi_radio_state()
})
else:
return jsonify({
'status': 'error',
'message': message,
'reason': reason
}), 400
except Exception as e:
logger.error("Error setting WiFi radio state", exc_info=True)
return jsonify({
'status': 'error',
'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
}), 500
@@ -0,0 +1,484 @@
/**
* ListFilter — shared search / filter / sort controller for the card-grid
* sections of the Plugin Manager.
*
* The Installed Plugins, Plugin Store and Starlark Apps sections all need the
* same machinery: debounced text search over a few fields, a handful of filter
* axes, a sort dropdown, an active-filter count with a Clear button, and a
* re-render. This owns that machinery; the caller keeps ownership of its own
* card markup via the `render` callback.
*
* Usage:
*
* const ctl = ListFilter.create({
* getItems: () => window.installedPlugins || [],
* render: (visible, total) => renderCards(visible, total),
* search: { el: 'my-search', fields: ['name', 'id', 'tags'] },
* sort: { el: 'my-sort', default: 'a-z', comparators: { 'a-z': fn } },
* controls: [{ type: 'pills', el: '#my-pills', attr: 'data-my-filter',
* key: 'filter', default: 'all', test: (item, v) => true }],
* clearEl: 'my-clear',
* });
* ctl.bind(); // idempotent — safe to call after every HTMX partial swap
* ctl.apply(); // filter + sort + render
*
* Optional `pagination` slices the result set and renders page controls; the
* `render` callback then receives just the current page. Optional `persist`
* takes read/write callbacks so the caller — not this helper — owns its
* storage keys.
*
* Element references are DOM ids, except `controls[].el` which is a CSS
* selector for the pill container.
*/
const ListFilter = (function () {
'use strict';
function debounce(fn, wait) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), wait);
};
}
function byId(id) {
return id ? document.getElementById(id) : null;
}
// Filter axes compare against their default to decide "is this axis active",
// so null/undefined/'' must not be conflated with a real selection.
function sameValue(a, b) {
if (a === b) return true;
if (a === null || a === undefined) return b === null || b === undefined;
return false;
}
// Build the lowercased search haystack. Array fields (e.g. tags) are
// flattened in, matching the existing store/starlark search behaviour.
//
// Values are read out of a Map rather than via item[field], which keeps
// static analysers from flagging a computed member access as an
// object-injection sink. Iteration follows `fields`, NOT the object's own
// key order: the fields are concatenated, so their order decides which
// values end up adjacent, and a multi-word query can span a field boundary.
function haystack(item, fields) {
if (!item) return '';
const values = new Map(Object.entries(item));
const parts = [];
(fields || []).forEach(field => {
const value = values.get(field);
if (Array.isArray(value)) {
value.forEach(v => { if (v) parts.push(String(v)); });
} else if (value) {
parts.push(String(value));
}
});
return parts.join(' ').toLowerCase();
}
function create(config) {
const cfg = config || {};
const searchCfg = cfg.search || null;
const sortCfg = cfg.sort || null;
const controls = Array.isArray(cfg.controls) ? cfg.controls : [];
const pageCfg = cfg.pagination || null;
const persistCfg = cfg.persist || null;
const idOf = typeof cfg.idOf === 'function' ? cfg.idOf : (item => item && item.id);
// Defaults double as the "inactive" value for each axis.
const defaults = {};
if (searchCfg) {
defaults.search = ''; // trimmed — what filtering and activeCount use
defaults.searchRaw = ''; // exactly what the user typed — what the input shows
}
if (sortCfg) defaults.sort = sortCfg.default !== undefined ? sortCfg.default : 'a-z';
controls.forEach(c => {
defaults[c.key] = c.default !== undefined ? c.default : null;
});
const state = Object.assign({}, defaults);
// page/perPage sit outside `defaults` on purpose: Clear Filters returns
// to page 1 but must NOT reset a per-page size the user chose.
if (pageCfg) {
state.page = 1;
state.perPage = pageCfg.defaultPerPage || 12;
}
// Seed persisted values. The caller supplies read()/write() so storage
// keys stay where they always were.
if (persistCfg && typeof persistCfg.read === 'function') {
const saved = persistCfg.read() || {};
if (sortCfg && saved.sort !== undefined && saved.sort !== null) state.sort = saved.sort;
if (pageCfg && saved.perPage) state.perPage = saved.perPage;
}
function persist() {
if (persistCfg && typeof persistCfg.write === 'function') persistCfg.write(state);
}
// Ids that stay visible even when they no longer match the active
// filters. Populated by the caller when the user acts on a card (e.g.
// toggling a plugin off while filtering by Enabled) so the card they
// just clicked doesn't vanish underneath the cursor. Cleared as soon as
// the user touches the toolbar.
const sticky = new Set();
function activeCount() {
let n = 0;
if (searchCfg && state.search) n++;
if (sortCfg && !sameValue(state.sort, defaults.sort)) n++;
controls.forEach(c => {
if (!sameValue(state[c.key], defaults[c.key])) n++;
});
return n;
}
function matches(item) {
if (searchCfg && state.search) {
if (!haystack(item, searchCfg.fields).includes(state.search.toLowerCase())) {
return false;
}
}
for (const c of controls) {
const value = state[c.key];
if (sameValue(value, defaults[c.key])) continue; // axis inactive
if (typeof c.test === 'function' && !c.test(item, value)) return false;
}
return true;
}
function compute() {
const all = (typeof cfg.getItems === 'function' ? cfg.getItems() : null) || [];
const total = all.length;
const list = all.filter(item => {
if (sticky.size > 0 && sticky.has(idOf(item))) return true;
return matches(item);
});
if (sortCfg && sortCfg.comparators) {
// An unrecognised sort key falls back to the default comparator,
// matching the switch-with-default the store code used.
const cmp = sortCfg.comparators[state.sort] || sortCfg.comparators[defaults.sort];
if (typeof cmp === 'function') list.sort(cmp);
}
return { list: list, total: total };
}
// Reflect current state back onto the controls, so programmatic changes
// and a fresh partial swap both land on a correctly-lit toolbar.
function syncControls() {
if (searchCfg) {
// The toolbar markup is rebuilt on every HTMX partial swap while
// this controller (and its state) survives — put the text back.
const el = byId(searchCfg.el);
const text = state.searchRaw !== undefined ? state.searchRaw : state.search;
if (el && el.value !== text) el.value = text;
}
if (sortCfg) {
const el = byId(sortCfg.el);
if (el && el.value !== state.sort) el.value = state.sort;
}
if (pageCfg && pageCfg.perPageEl) {
const el = byId(pageCfg.perPageEl);
if (el && el.value !== String(state.perPage)) el.value = String(state.perPage);
}
controls.forEach(c => {
const value = state[c.key];
if (c.type === 'pills') {
const container = document.querySelector(c.el);
if (!container) return;
container.querySelectorAll('[' + c.attr + ']').forEach(btn => {
const on = btn.getAttribute(c.attr) === String(value);
btn.setAttribute('data-active', on ? 'true' : 'false');
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
});
} else if (c.type === 'select') {
const el = byId(c.el);
if (el && el.value !== (value === null ? '' : value)) {
el.value = value === null ? '' : value;
}
} else if (c.type === 'cycle') {
const btn = byId(c.el);
// The caller renders cycle buttons so each section keeps its
// own label/icon/class treatment.
if (btn && typeof c.render === 'function') c.render(btn, value);
}
});
}
function updateChrome(list, total) {
const n = activeCount();
const countEl = byId(cfg.countEl);
if (countEl && typeof cfg.countFormat === 'function') {
countEl.textContent = cfg.countFormat(list.length, total, n > 0);
}
const activeEl = byId(cfg.activeCountEl);
if (activeEl) {
activeEl.classList.toggle('hidden', n === 0);
activeEl.textContent = n + ' filter' + (n !== 1 ? 's' : '') + ' active';
}
const clearEl = byId(cfg.clearEl);
if (clearEl) clearEl.classList.toggle('hidden', n === 0);
if (searchCfg && searchCfg.clearEl) {
const searchClear = byId(searchCfg.clearEl);
if (searchClear) searchClear.classList.toggle('hidden', !state.search);
}
syncControls();
if (typeof cfg.onChrome === 'function') cfg.onChrome(state, list, total);
}
// Page-number strip with leading/trailing ellipsis, producing the same
// controls the plugin store has always rendered.
//
// Built with createElement rather than by concatenating an HTML string.
// Nothing interpolated here is user-controlled — only page integers and
// these class constants — but assembling markup into innerHTML is the
// pattern static analysers flag as an XSS sink, and building nodes is no
// less clear. It also lets each button own its listener directly instead
// of re-querying the container afterwards.
const PAGE_BTN_CLASS = 'px-3 py-1 text-sm rounded-md border transition-colors';
const PAGE_ACTIVE_CLASS = 'bg-blue-600 text-white border-blue-600';
const PAGE_NORMAL_CLASS = 'bg-white text-gray-700 border-gray-300 hover:bg-gray-100 cursor-pointer';
const PAGE_DISABLED_CLASS = 'bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed';
function renderPagination(containerId, totalPages, currentPage) {
const container = byId(containerId);
if (!container) return;
// textContent = '' drops the previous strip without parsing markup.
container.textContent = '';
if (totalPages <= 1) return;
const goTo = target => {
if (target >= 1 && target <= totalPages && target !== currentPage) {
state.page = target;
// Page moves re-slice only; filters and sort are unchanged.
apply(true);
const grid = byId(pageCfg && pageCfg.scrollToEl);
if (grid && typeof grid.scrollIntoView === 'function') {
grid.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
};
const addPageButton = (label, target, variant) => {
const btn = document.createElement('button');
btn.className = PAGE_BTN_CLASS + ' ' + (
variant === 'active' ? PAGE_ACTIVE_CLASS
: variant === 'disabled' ? PAGE_DISABLED_CLASS
: PAGE_NORMAL_CLASS);
btn.setAttribute('data-list-page', String(target));
if (variant === 'disabled') btn.disabled = true;
btn.textContent = label;
btn.addEventListener('click', () => goTo(target));
container.appendChild(btn);
};
addPageButton('\u00ab', currentPage - 1, currentPage <= 1 ? 'disabled' : 'normal');
const pages = [];
pages.push(1);
if (currentPage > 3) pages.push('...');
for (let i = Math.max(2, currentPage - 1); i <= Math.min(totalPages - 1, currentPage + 1); i++) {
pages.push(i);
}
if (currentPage < totalPages - 2) pages.push('...');
if (totalPages > 1) pages.push(totalPages);
pages.forEach(entry => {
if (entry === '...') {
const gap = document.createElement('span');
gap.className = 'px-2 py-1 text-sm text-gray-400';
gap.textContent = '\u2026';
container.appendChild(gap);
} else {
addPageButton(String(entry), entry, entry === currentPage ? 'active' : 'normal');
}
});
addPageButton('\u00bb', currentPage + 1, currentPage >= totalPages ? 'disabled' : 'normal');
}
function apply(skipPageReset) {
const result = compute();
if (!pageCfg) {
updateChrome(result.list, result.total);
if (typeof cfg.render === 'function') cfg.render(result.list, result.total);
return result;
}
if (!skipPageReset) state.page = 1;
const total = result.list.length;
const totalPages = Math.max(1, Math.ceil(total / state.perPage));
if (state.page > totalPages) state.page = totalPages;
const start = (state.page - 1) * state.perPage;
const end = Math.min(start + state.perPage, total);
const pageItems = result.list.slice(start, end);
const info = total > 0
? (typeof pageCfg.infoFormat === 'function'
? pageCfg.infoFormat(start + 1, end, total)
: `Showing ${start + 1}\u2013${end} of ${total}`)
: (pageCfg.emptyText || 'No results match your filters');
[pageCfg.infoEl, pageCfg.infoBottomEl].forEach(id => {
const el = byId(id);
if (el) el.textContent = info;
});
renderPagination(pageCfg.topEl, totalPages, state.page);
renderPagination(pageCfg.bottomEl, totalPages, state.page);
updateChrome(result.list, result.total);
if (typeof cfg.render === 'function') cfg.render(pageItems, result.total);
return result;
}
function setSearch(value) {
// Keep the raw text so syncControls can put it back verbatim. Writing
// the trimmed value into the input would eat a trailing space (and
// reset the caret) mid-word, which makes multi-word terms untypable.
state.searchRaw = value || '';
state.search = state.searchRaw.trim();
sticky.clear();
apply();
}
function reset() {
// Only the filter axes reset; a chosen page size is a preference,
// not a filter, so it survives Clear Filters.
Object.assign(state, defaults);
if (pageCfg) state.page = 1;
sticky.clear();
if (searchCfg) {
const el = byId(searchCfg.el);
if (el) el.value = '';
}
persist();
syncControls();
apply();
}
function bind() {
if (searchCfg) {
const input = byId(searchCfg.el);
if (input && !input._listFilterInit) {
input._listFilterInit = true;
const run = debounce(() => setSearch(input.value), searchCfg.debounceMs || 300);
input.addEventListener('input', run);
input.addEventListener('keydown', e => {
if (e.key === 'Escape') {
input.value = '';
setSearch('');
}
});
}
const searchClear = searchCfg.clearEl ? byId(searchCfg.clearEl) : null;
if (searchClear && !searchClear._listFilterInit) {
searchClear._listFilterInit = true;
searchClear.addEventListener('click', () => {
const el = byId(searchCfg.el);
if (el) el.value = '';
setSearch('');
});
}
}
if (sortCfg) {
const el = byId(sortCfg.el);
if (el && !el._listFilterInit) {
el._listFilterInit = true;
el.addEventListener('change', function () {
state.sort = this.value;
sticky.clear();
persist();
apply();
});
}
}
controls.forEach(c => {
if (c.type === 'pills') {
const container = document.querySelector(c.el);
if (!container || container._listFilterInit) return;
container._listFilterInit = true;
// Delegated, so the pills survive any markup re-render.
container.addEventListener('click', event => {
const btn = event.target.closest('[' + c.attr + ']');
if (!btn || !container.contains(btn)) return;
state[c.key] = btn.getAttribute(c.attr);
sticky.clear();
apply();
});
} else if (c.type === 'select') {
const el = byId(c.el);
if (!el || el._listFilterInit) return;
el._listFilterInit = true;
el.addEventListener('change', function () {
state[c.key] = this.value;
sticky.clear();
apply();
});
} else if (c.type === 'cycle') {
const btn = byId(c.el);
if (!btn || btn._listFilterInit) return;
btn._listFilterInit = true;
const values = Array.isArray(c.values) ? c.values : [null];
btn.addEventListener('click', () => {
const at = values.findIndex(v => sameValue(v, state[c.key]));
state[c.key] = values[(at + 1) % values.length];
sticky.clear();
apply();
});
}
});
if (pageCfg && pageCfg.perPageEl) {
const el = byId(pageCfg.perPageEl);
if (el && !el._listFilterInit) {
el._listFilterInit = true;
el.addEventListener('change', function () {
state.perPage = parseInt(this.value) || (pageCfg.defaultPerPage || 12);
persist();
apply();
});
}
}
const clearEl = byId(cfg.clearEl);
if (clearEl && !clearEl._listFilterInit) {
clearEl._listFilterInit = true;
clearEl.addEventListener('click', reset);
}
}
return {
state: state,
sticky: sticky,
activeCount: activeCount,
bind: bind,
apply: apply,
reset: reset,
setSearch: setSearch,
syncControls: syncControls,
};
}
return { create: create };
})();
// Export
if (typeof module !== 'undefined' && module.exports) {
module.exports = ListFilter;
} else {
window.ListFilter = ListFilter;
}

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