Compare commits

..
Author SHA1 Message Date
Claude 7603728e5a fix(composer): close binding_source code injection, line-align, and project_root UnboundLocalError
Three findings from CodeRabbit's review of 6c23994b, all verified against
current code before fixing:

- manager.py.j2 interpolated binding.source unescaped into a Python comment
  (`pass  # dynamic_text binding_source "{{ el.binding_source }}" draws
  nothing`). A source string with a newline broke out of the comment; a
  crafted payload produces a clean, ast.parse-valid `import os` in the
  generated plugin (confirmed against the pre-fix template). This is now a
  fixed literal comment that never interpolates the value. Live now that
  composer_bp is registered. CWE-94.
- _alignElement moved a line's x0 (or y0) to the new position but left x1
  (or y1) behind, so aligning a line changed its shape instead of moving
  it. Both endpoints now translate by the same delta.
- web_interface/app.py only assigned project_root inside the relative-path
  branch of the plugins_dir resolution. An absolute plugin_system.plugins_
  directory (a supported config value) hit UnboundLocalError importing the
  module at all, since SchemaManager/composer_bp use project_root further
  down. Now assigned unconditionally before the branch.

Also extends BOUND_TYPES coverage in composer-app.js (_isBound,
removeConfigVar, _validateBeforeExport) from dynamic_text/progress_bar to
all six element types that carry a binding object (countdown, pips,
sparkline, gauge too) -- found by direct code reading against
ELEMENT_DEFAULTS in composer-canvas.js, not from a review comment. Without
it, those four types could export with an unbound config key with no
validation error, and deleting a config var they used gave no warning.

All four fixes have mutation-checked regression tests (fail against the
reverted code, pass with the fix): test_binding_source_cannot_break_out_of_the_comment_it_lands_in,
test_align_translates_both_line_endpoints_not_just_the_start,
test_app_plugins_dir_resolution.py, test_binding_checks_cover_every_bound_element_type.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 20:43:16 +00:00
Claude 6c23994b2f fix(composer): coerce remaining raw int() payload values, harden JS file/download handling
CodeRabbit flagged several payload-derived int() conversions in composer.py
that could raise ValueError instead of clamping like every other coerced
value in the module (_as_rgb_filter and its callers -- fillR/G/B, outR/G/B,
bgR/G/B, trackR/G/B -- plus min_width, lineSpacing, barWidth/Height,
start/endAngle, borderRadius, pip*, sparkline bar*, marquee gap/scrollSpeed).
Route them all through _safe_int for consistency with the rest of the module
and to avoid the generic 422 a raw ValueError produces.

Also:
- FileReader.onerror was unset in importDesign(), so a failed file read
  produced no status message.
- URL.revokeObjectURL() ran synchronously right after link.click() in both
  exportDesign() and generateZip(); deferred via setTimeout(..., 0) so the
  download reliably starts before the object URL is revoked.
- _module_level_code() (test helper) filtered ast.ImportFrom but not
  ast.Import, so a bare `import os` payload wouldn't be caught by the
  helper itself, even though downstream assertions still caught it.

Added regression tests for the fillR/G/B injection + clamping path, which
had no coverage (existing tests only covered the r/g/b _rgb_expr path).

Skipped as not worth the churn (CodeRabbit nitpicks, both "Trivial/Low value"):
- test_composer_empty_block.py's branch regex not matching digit-containing
  type names -- no such type exists today.
- test_composer_path_containment.py's `C.composer_bp.name and Flask(...)`
  truthiness guard -- cosmetic, blueprint name is never falsy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XKc832xpVEx3C3W5BVqQ5Z
2026-09-12 17:35:16 +00:00
Claude f12d11334a fix(composer): center divider strokes and clamp gauge radii in preview canvas
Two outstanding CodeRabbit outside-diff findings on this PR:

- The divider branch applied its 0.5px centering offset after scaling
  (`ay * s + 0.5`) instead of before it (`(ay + 0.5) * s`), so at SCALE>1
  the stroke bled into the preceding LED row/column instead of straddling
  its own.
- A small imported gauge with a wide lineWidth (e.g. width=1, height=1,
  lineWidth=3) produced a negative radius, which ctx.ellipse() throws
  IndexSizeError on, aborting render() for every element still to be
  drawn. Radii are now clamped to zero.

Verified both against current code (neither was fixed by later commits
on this branch) and mutation-checked: reverting either fix fails the new
test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 20:33:06 +00:00
Claude be7a7b7baf fix(composer): register composer_bp and close a second empty-block gap
composer_bp defined the whole Plugin Composer feature -- the /composer/
page and all its API routes -- but web_interface/app.py never imported or
registered it, so every composer URL 404'd in the running app (every
composer test builds its own minimal Flask app and registers the
blueprint directly, which is why this went unnoticed). Wire it up the
same way pages_v3/api_v3 are: import, set config_manager/plugin_manager/
plugins_dir/project_root, register_blueprint(url_prefix='/composer') --
matching the prefix composer.html and composer-app.js already hardcode.

Also closes the other still-open half of a CodeRabbit finding: manager.py.j2
already guards element types the template has no branch for, but a
dynamic_text element with binding.source other than 'config' hit the same
empty-if-block bug one level deeper (its own inner if produced nothing).
Added the same pass fallback.

Verified both against current code before fixing; the other 8 findings
from that review were already fixed in earlier commits on this branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 07:36:22 +00:00
t 406e68fba9 Merge remote-tracking branch 'origin/main' into HEAD 2026-09-07 11:35:44 -04:00
ChuckBuildsandClaude Opus 5 ea54e56bed chore(composer): silence Codacy's 18 findings, all of them false positives
Checked each one rather than blanket-suppressing.

Python (Opengrep, 4). The Jinja2 environment disables autoescaping on
purpose -- these templates emit Python, not HTML, and escaping a quote in
a plugin name would corrupt the generated source. The safety comes from
the values instead (_safe_int, _rgb_expr, _reject_source_breaking), which
test_composer_code_injection.py covers. Both the Environment( line and
the autoescape= line are reported separately, so each needs its own
nosemgrep. The two "Flask route directly returning a formatted string"
hits are not routes at all: _as_rgb_filter is a Jinja filter and
_rgb_tuple a private helper, both emitting a Python tuple literal with
every channel coerced to int first.

JavaScript (Biome + ESLint, 14). useQwikValidLexicalScope fired five
times on plain arrow-function consts -- it is a Qwik rule about the $()
serialization boundary, and this is Alpine.js. noUnusedVariables flagged
composerApp(), which the template calls as x-data="composerApp()", where
the linter cannot see it. The eight detect-object-injection hits are
array indices (this.elements[idx], rawVals[i]) or lookups on
module-private maps keyed by an internal element type; none takes an
attacker-supplied property name, so disabled per file with the reason
rather than eight times inline.

.codacy.yml only supports exclude_paths, so these have to be inline.
Matches the repo's existing "eslint-disable-line <rule> -- <reason>" form.

372 composer tests pass, including the 14 JS contract tests that parse
these two files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9
2026-09-07 11:20:47 -04:00
ChuckBuildsandClaude Opus 5 2f42d179f6 fix(composer): point the align toolbar at the anchor-clearing path
Two alignment implementations existed and the toolbar used the wrong one.

alignElement(dir) set el.x/el.y and stopped there. resolveAnchor turns
anchor='right' into `dim - val`, so with xAnchor='right' an "align left"
(el.x = 0) resolved to x = MATRIX_W and the element jumped to the far right
edge -- the opposite of what was asked. It also never touched el.x0/el.y0, so
a line's endpoints were left where they were.

_alignElement already did both correctly: it clears the anchor so the stored
value is absolute, and moves x0/y0 for lines. Its six wrappers -- alignLeft,
alignHCenter, alignRight, alignTop, alignVCenter, alignBottom -- existed and
had no callers at all.

All six toolbar buttons now call the wrappers, and the legacy method is
removed rather than left to drift back into use.

Tests: the toolbar calls each wrapper and no longer calls alignElement, the
legacy definition is gone, and _alignElement still clears the anchor and moves
line endpoints. Two of them fail against the previous markup.

Full suite 4062 passed, the one failure being test_install_lowmem
(pre-existing, awaiting #492).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
2026-08-22 15:13:27 -04:00
ChuckBuildsandClaude Opus 5 37fc1b56b5 fix(composer): coerce prefixed colour channels, non-finite numbers, marquee ids
Three more routes into the generated source, plus a fix to one of my own tests
that was checking the wrong branch.

Prefixed colour channels were interpolated raw
----------------------------------------------
Five tuples were built without coercion:

    p['fill_tuple']  = f"({el.get('r', 100)}, {el.get('g', 200)}, ...)"
    p['empty_tuple'] = f"({el.get('emptyR', 50)}, ...)"
    p['label_tuple'] = f"({el.get('labelR', 200)}, ...)"

so progress_bar, pips, sparkline and gauge took arbitrary expressions the same
way width/height did. Confirmed: every one of the five put __import__ into the
generated source. They now go through a new _rgb_tuple helper, which _rgb_expr
also delegates to.

The pre-existing colour test only covered r/g/b on a text element, which is why
the prefixed channels and these four types were never exercised.

Non-finite numbers escaped as a 500
-----------------------------------
json.loads accepts Infinity/-Infinity/NaN by default and Flask's get_json
passes them straight through, so a payload can hand _safe_int a non-finite
float. int(inf) raises OverflowError, which is neither ValueError nor
ComposerInputError, so it escaped both handlers and surfaced as a 500 with a
traceback rather than a 422. Verified end to end through Flask's parser.

Marquee ids reached the source as identifiers
---------------------------------------------
data_key is spliced UNQUOTED into variable names (_{{ data_key }}_text = ...)
and only '-' was normalised. A punctuated id landed in the generated source as
code. ast.parse caught it, so this was not exploitable, but the caller got an
opaque "Generated code has a syntax error" instead of being told the id was
unusable -- the same failure mode as the empty-block bug. Now restricted to
identifier characters and bounded to 64.

The line-anchor test was testing the wrong branch
-------------------------------------------------
test_line_branch_applies_the_anchor_offset searched the whole file for
"case 'line': {". getBoundingBox has one too and comes first, so the assertion
was reading the bounding-box branch: stripping the anchor offset from
_drawElement left all 11 checks green. Both line tests are now scoped to their
own function via tree-sitter, so they cannot be satisfied by the same branch.

Tests: 35 of the injection suite's checks fail against the reverted fixes; the
scoped line test fails when _drawElement's offset is removed. Full suite 4059
passed, the one failure being test_install_lowmem (pre-existing, awaiting #492).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
2026-08-22 14:35:30 -04:00
ChuckBuildsandClaude Opus 5 acc55ef119 fix(composer): scale strokes, anchor lines, snapshot state changes
Three review findings in the composer's JavaScript, all confirmed against the
code.

Stroke widths did not scale with SCALE
--------------------------------------
_drawElement scales all geometry by `s`, but left ctx.lineWidth in canvas
pixels, so at SCALE>1 every outline rendered thinner than one LED pixel and
the preview stopped matching the panel it is previewing. Fixed for rectangle,
ellipse, arc, rounded_rectangle, line, divider and progress_bar. Ellipse and
arc also inset their radii by half the scaled width -- a stroke straddles its
path, so without the inset the outline spills outside the element's bounds.
The gauge branch already did this; the rest now match it.

Selection handles and the grid stay in canvas pixels deliberately: they are
editor chrome, not LED geometry, and live in other functions.

`line` ignored anchors
----------------------
_drawElement resolves ax/ay for every element, but the line branch drew raw
el.x0/el.y0/el.x1/el.y1. Setting xAnchor or yAnchor moved every other element
type and left lines where they were. getBoundingBox had the same omission, so
even once a line moved its hit box would not have. Both now translate by
(ax - el.x0, ay - el.y0); ax resolves from el.x0 for a line, so that is
exactly the anchor offset.

Four state mutations skipped _snapshot
--------------------------------------
_snapshot serialises metadata and currentPreset and is the only caller of
_debouncedAutosave. onBgColorChange, setCustomSize, changePreset and
applyPresetLabel each changed exactly those values without calling it, so the
background colour and the canvas size were lost on reload and could not be
undone. Same defect already fixed in onColorChange.

The review named three; applyPresetLabel has it too -- it is the branch that
handles sizes absent from DISPLAY_PRESETS.

Snapshotting is on the user-driven path only. _applyState and loadTemplate
drive these with {silent: true} while restoring, and snapshotting there would
push restore steps onto the undo stack and re-autosave the state just loaded.

Tests
-----
No JS runner here, so test_composer_js_contracts.py asserts on the parse tree
via tree-sitter: both files parse, no bare `ctx.lineWidth = 1` inside
_drawElement, the line branch and its bounding box carry the anchor offset,
each of the five mutations snapshots, and the two preset paths keep their
!opts.silent guard ahead of the snapshot.

9 of its 11 checks fail against the previous JS. Full suite 3978 passed, the
one failure being test_install_lowmem (pre-existing, awaiting #492).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
2026-08-22 13:52:48 -04:00
ChuckBuildsandClaude Opus 5 1a0864e5d4 fix(composer): clamp width/height before they reach generated source
Code injection, found by chasing why a security test could not have caught it.

_preprocess_elements built the far corner of five shapes by interpolating the
payload's width/height straight into generated Python:

    w = el.get('width', 10)
    p['x2_expr'] = f"({x_expr}) + {w}"

so a rectangle with width='0 or __import__("os").system("id")' generated

    [0, 0, (0) + 0 or __import__("os").system("id"), (0) + 8],

inside a manager.py that /api/install writes to disk and the plugin loader
imports and executes. rectangle, arc, ellipse, rounded_rectangle and gauge all
share the pattern. Both fields now go through _safe_int, like every other
geometry value.

Unreachable today only because composer_bp is still unregistered -- the same
caveat as the docstring injection fixed earlier in this PR.

Why the existing test missed it
-------------------------------
test_a_non_numeric_geometry_value_cannot_reach_the_source drove its payloads
through a "line" element. manager.py.j2 has never had a `line` branch, so
_preprocess_elements produced nothing for it and no value it set could reach
the generated source. Every assertion passed trivially, against code that was
in fact vulnerable. The test has been vacuous since it was written; the
_RENDERABLE_ELEMENT_TYPES constant added in the previous commit only made the
cause legible.

It now runs across the five types that actually render, over x/y/width/height:
40 of those cases fail with the clamping reverted, where the old version
passed 100%.

A second test asserts every type used by the injection suite is in
_RENDERABLE_ELEMENT_TYPES, so the suite cannot quietly go vacuous again.

Also: _payload set "config_vars", but _generate_plugin_files reads
data['dataModel']['configVars']. Nothing passed through that key was ever
read. Fixed so config-var tests exercise the real path.

Full suite: 3967 passed, the one failure being test_install_lowmem
(pre-existing, awaiting #492).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
2026-08-22 13:11:47 -04:00
ChuckBuildsandClaude Opus 5 732c7d1a30 fix(composer): an element the template cannot draw broke generation
Reproduced from the review comment. A `group` element carrying minWidth
generated:

    if width >= 64:  # breakpoint: 64px+ displays only
    # <nothing>

manager.py.j2 wraps each element in the breakpoint and blink blocks, but the
body comes from the per-type branches -- and a type with no branch contributes
nothing, so the wrapper opens a block with no statements. ast.parse then fails
and the caller is told only "Generated code has a syntax error: expected an
indented block ... line 49", naming a line of generated source they never see.

Two defences:

- _preprocess_elements drops types the template has no branch for, alongside
  the existing `section` skip. This is the root cause: those elements should
  never have reached the template.
- The branch chain ends in `{% else %}pass`, so a type added to the canvas
  before its drawing branch exists degrades to a no-op rather than a plugin
  that will not parse.

The review also cited dynamic_text with binding_source != 'config'. That one
does not reproduce -- the branch emits a draw_text regardless -- which is why
an earlier attempt to reproduce this found nothing.

_RENDERABLE_ELEMENT_TYPES has to stay in step with the template: a type listed
with no branch emits an empty block again, and a branch missing from the list
is silently dropped from every generated plugin. A test asserts the two sets
are equal rather than trusting them to be maintained together.

Tests: 12 new, covering group/unknown/section against breakpoint, blink and
both nested, plus the set-equality and fallback checks. 7 fail with both
defences reverted. 172 composer tests pass; full suite 3862 passed, the one
failure being test_install_lowmem (pre-existing, awaiting #492).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
2026-08-22 12:16:44 -04:00
ChuckBuildsandClaude Opus 5 d42593e7ce Merge main into feat/plugin-composer, and fix three review findings
The branch was 57 commits behind and conflicting. I had put the rebase
aside earlier as needing the author's eyes, on the grounds that the PR is
+5091 lines -- but that was the wrong measure. The actual conflict was a
single hunk in app.css, where this branch adds .md\:inline and main added
.md\:block and .md\:w-auto at the same place. All three are kept.

Merging rather than rebasing: the branch is public and 57 commits behind,
so a rebase would rewrite shared history for a force-push.

Three findings fixed on top:

A missing `text` or `format` was a 500. `p` is a copy of the raw element
and the defaults were applied to the locals t1/fmt1 only, so an element
omitting either key left it absent, manager.py.j2 rendered
`{{ el.text | tojson }}` over a jinja2.Undefined, and tojson raised
TypeError -- which no handler catches:

    text without 'text':    TypeError: Object of type Undefined is not
                            JSON serializable
    clock without 'format': same

Both keys are now set explicitly. Verified: removing either assignment
fails 4 of the new tests.

E741 on my own injection-test file: two `for i, l in enumerate(...)`
loops, which ruff rejects and would fail a lint-gated build. Renamed.
Ruff now clean on all three files this PR touches.

Not done: registering composer_bp. This PR's own description gates it --
"Not yet wired up ... tracking as a follow-up", with an unchecked box for
"Register composer_bp in app.py before merging or exposing this route" --
so it is a deliberate decision, not an oversight. Confirmed the blueprint
appears in no register_blueprint call outside this branch's tests, which
also means the code-injection fixed earlier in this PR was never
reachable in a deployed instance. Worth fixing before the route is
exposed; not worth exposing the route to satisfy a review comment.

Verified on the merged tree: 3850 passed, 1 failed, 60 skipped. The
failure is test_install_lowmem's tmpfs assumption, which is fixed in #492
and not yet on main. The static audit now passes 3/3 -- the twelve
classes it flagged before were defined on main all along and only looked
missing because this branch was behind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
2026-08-21 20:51:14 -04:00
ChuckBuildsandClaude Opus 5 f0bef7784c fix(composer): eight editor bugs from review
All confirmed by reading the code rather than taken on trust.

Saved designs restored onto the wrong canvas. _buildPayload writes the
size as `preset`; _applyState read `state.currentPreset`, which is never
present, so changePreset(undefined) hit its `if (!preset) return` and did
nothing -- silently. A 256x64 design reopened at 128x32 with every
element misplaced. importDesign passed no size key at all, same result.
Both go through a new applyPresetLabel(), which also handles the custom
labels setCustomSize() writes ("200x50"): those are deliberately absent
from DISPLAY_PRESETS, so changePreset alone could never round-trip them.

Keyboard shortcuts hijacked text fields. The `inInput` guard sat below
the Ctrl/Cmd block, under a comment claiming combos "work everywhere".
In any input, Ctrl+C copied the selected *element* -- preventDefault
stopping the real copy -- Ctrl+V pasted an element, Ctrl+A could not
select the field contents, and Tab always moved the element selection,
so keyboard users could not reach the next input. Guard moved above both
blocks, and it now covers contenteditable too.

Resize handles were advertised on five shapes that ignored them. The
canvas drew handles for six element types; the editor gated resize and
hover on `type === 'rectangle'`. The list was also duplicated inside the
canvas. One exported RESIZABLE_TYPES now feeds all four sites.

Lines jumped on drag. addElement assigns x/y *before* spreading
ELEMENT_DEFAULTS, and the line defaults define only x0/y0 -- so a line
carries both, with x at canvas/4 and x0 at 0. Drag and nudge move x0/y0
only, so _getStoredPos preferring `x` handed the drag a base it never
updates.

Colour-picker edits were lost on reload. onColorChange mutated the
element but never set isDirty or called _snapshot, and _debouncedAutosave
only runs from _snapshot. applyPaletteColor did both; they match now.

Also: section elements drew nothing and reported a 0x0 box, so adding
"Section Label" from the palette looked broken and the element was
selectable only through the 3px hit-test padding -- they now draw their
label, with the bounding box using the same font fallback as the draw
call so the two agree. The gauge inset its arc radius by lw/2 where lw is
LED pixels and the radius is canvas pixels, then stroked at lw*s, so the
arc spilled outside its own bounding box at any scale above 1. And the
plugin id is encodeURIComponent'd before it becomes part of a request
path.

Verified: composer-app.js and composer-canvas.js parse cleanly under
tree-sitter (esprima cannot read this codebase -- it predates ??, and
fails identically on the unmodified files). Every symbol referenced
across module boundaries checked to exist. 156 Python composer tests
pass. The static-audit failure is the same 13 classes as before, all
defined on main and absent only because this branch is behind; nothing
here touches CSS or templates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
2026-08-21 20:14:52 -04:00
ChuckBuildsandClaude Opus 5 986f74e38b fix(composer): reject config keys that shadow plugin state
Five review findings, plus the two bandit reported.

Config variable keys were checked against an identifier regex only.
Python keywords slipped past it and were caught downstream by ast.parse,
but reported as

    Generated code has a syntax error: invalid syntax (<unknown>, line 17)

which names neither the field nor the value. They are now refused by
name, soft keywords ('match', 'case') included.

Worse, a key matching a BasePlugin attribute generated *valid* code that
silently clobbered plugin state. 'config' is the sharp one: the
assignment lands immediately after super().__init__(), so

    self.config = config.get("config", "x")

replaces the plugin's config dict with a string, and every later
self.config.get(...) fails at runtime. Refused now, along with logger,
display_manager, cache_manager, plugin_id, enabled, self and the
lifecycle method names. A test pins the ordering assumption that reserved
list rests on, so it fails if config vars are ever emitted before
super().__init__() instead.

Also:

- The silent `except Exception: pass` around manifest parsing now logs.
  It left "partial import produced nothing" indistinguishable from a
  malformed manifest. (bandit B110)
- list_plugins() called iterdir() on a directory that may not exist --
  a fresh install or a bad path returned 500 instead of an empty list.
- metadata.id is stripped in the two route handlers, matching
  _generate_plugin_files, which strips before validating. Without it
  " my-plugin " generated fine and then failed the id check at install,
  reading as a generator bug.
- The jinja Environment's autoescape=False now says why: these templates
  emit Python, and escaping a quote to &#34; inside generated code would
  break it. Safety comes from the values instead -- _safe_int, _rgb_expr
  and _reject_source_breaking, all covered by the injection suite.
  (bandit B701, marked nosec with that rationale)

bandit on composer.py: 2 findings -> 0.

Verified: 156 tests across the two composer suites. Removing either new
key check fails 9.

Not reproduced: the suggestion to emit `pass` so a conditional block is
never empty. 'line' and 'divider' render through a different template
branch and 'section' emits nothing at all, so no element type available
here produces an `if width >= N:` with an empty body. Left alone rather
than changing template output speculatively.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
2026-08-21 19:38:54 -04:00
ChuckBuildsandClaude Opus 5 e450a6dfb6 fix(composer): stop payload text reaching generated Python as code
Review flagged this as critical and it is: the composer builds manager.py
by interpolating payload values into source text, /api/install writes
that file into plugins_dir, and the plugin loader imports and executes
it. The ast.parse check further down rejects only *invalid* syntax, and
an injected `import os` is perfectly valid.

Confirmed against the code before this commit. A plugin name carrying a
triple quote closes the module docstring and everything after it becomes
module-level code:

    generated manager.py parses: True
    injected module-level statements: ['import os', 'PWNED = os.getuid()']

and a geometry value is interpolated verbatim, because the parameter is
annotated int but arrives as JSON:

    _compute_pos_expr('0 or __import__("os").system("id")', 'right', 'width')
      -> 'width - 0 or __import__("os").system("id")'
    generated source: x=0 or __import__("os").system("id"),

Three fixes. _safe_int coerces and optionally clamps, and
_compute_pos_expr applies it to its own argument -- which covers all
twenty-odd call sites at once rather than patching each. _rgb_expr does
the same for the eight colour interpolations, clamping channels to
0-255. Line endpoints and widths go through it too.

For the docstring, _reject_source_breaking refuses a plugin name
containing a quote, backslash or newline. Rejecting rather than escaping:
these are display names, none of that belongs in one, and a clear "Plugin
name cannot contain a double quote." beats silently mangling what the
user typed.

Verified: all three exploits now refused or neutered, and each defence
mutation-checked separately --

    coercion removed in _compute_pos_expr ->  8 failed
    docstring guard removed               ->  5 failed
    colour channels interpolated raw      -> 13 failed

87 tests, covering seven expression payloads across seven geometry
fields and three colour channels, five literal-breaking names, and the
clean case asserting a normal payload still yields no module-level
statements at all.

One aside: the first version of this test file put the exploit string
in its own module docstring, which closed it and made the file a syntax
error -- the same bug, one level up. It now describes the payload rather
than embedding it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
2026-08-21 19:03:13 -04:00
ChuckBuildsandClaude Opus 5 5133643600 fix(composer): build the font path from the allowlist entry
secure_filename cleared the plugin-directory alerts: CodeQL went from 19
to 5, and from 16 high-severity to 2. The two that remain are in
serve_font, which is gated by a frozenset of three exact filenames -- so
nothing was exploitable -- but the name reaching the filesystem was still
the request value.

It now comes from the matched allowlist entry. Identical strings, so
runtime behaviour is unchanged; the difference is that the filename is
provably a module constant rather than a guarded piece of user input.

The first test I wrote for this proved nothing. It asserted 404 on
traversal payloads, but Flask's router will not match a path segment
containing '/', and the rest 404 simply because no such file exists --
so removing the allowlist entirely still passed. Replaced with a readable
file planted next to the fonts:

    fonts/id_rsa.ttf  ->  404, body does not contain its contents

which fails with "a readable non-allowlisted file was served" the moment
the gate is removed.

45 tests.

Left alone: three medium py/stack-trace-exposure alerts on the
_generate_plugin_files handlers, which return str(exc) for
ComposerInputError. Its seven raise sites are all authored literals
("Author is required.", "Config variable key X is not a valid Python
identifier."), so no traceback or path is exposed. Clearing them means
either replacing that feedback with a generic string or restructuring
validation to return errors instead of raising -- a change to the
author's design, made blind, since CodeQL cannot be run locally to
confirm it would even work. That is a decision, not a cleanup.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
2026-08-21 18:56:00 -04:00
ChuckBuildsandClaude Opus 5 5929190e36 fix(composer): sanitise the id with the form CodeQL recognises
Previous attempt got the count from 22 down to 19 but left the 16
path-injection alerts untouched: CodeQL carries taint through
_plugin_dir's return value and does not treat an internal realpath /
commonpath guard as a sanitiser.

secure_filename is one it does model. It is also a no-op on every id the
regex accepts -- verified across the accepted alphabet, 4000 generated
ids, zero altered -- so it cannot rewrite a caller's id into a different
plugin's directory. The equality check makes that explicit: if it changes
anything, the id was not one we accept, and we refuse rather than
silently redirect.

Found a real bug while testing the layers separately: '.' resolved to the
plugins root, and install() calls shutil.rmtree(target) when force is
set, so an id of '.' would have deleted every installed plugin. The regex
blocks it today, but the containment layer was allowing candidate == base
on the grounds that the base is not "outside" itself. A plugin directory
must be a child, never the root.

That came out of writing the isolated tests. Removing containment did not
fail anything, because secure_filename rejects traversal first -- which
made a redundant layer look load-bearing. Each layer is now neutralised
in turn so the one under test is the only thing standing:

    containment removed        -> FAIL (13 payloads reach the base or past it)
    candidate == base allowed  -> FAIL ('.' resolves to the plugins root)
    commonpath -> startswith   -> FAIL (sibling "plugins-evil" accepted)
    secure_filename bypassed   -> pass, containment covers it

The last is honest rather than a gap: with containment in place the
sanitiser has nothing left to block, and its value here is CodeQL
recognition plus a second barrier if containment is ever weakened.

Also corrected an assertion in the previous commit's test, which counted
any non-None result as an escape. '....', '~' and 'a\..\..' are ordinary
directory names on Linux and resolve safely inside the base; treating
them as escapes made the test fail on correct code.

35 tests. The 5 test_web_api.py failures are pre-existing on this branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
2026-08-21 18:25:50 -04:00
ChuckBuildsandClaude Opus 5 79ba93f5a6 fix(composer): recognisable path containment, and define md:inline
Follow-up to the previous commit, which made the CodeQL count worse
rather than better: 19 alerts became 22. Two mistakes.

First, the containment check used `base not in candidate.parents`.
That is correct Python but not a form static analysis recognises, so
every path-injection alert stayed and _plugin_dir itself picked up two
more. It now uses os.path.realpath plus os.path.commonpath, which is
both the documented sanitiser shape and stricter than the obvious
alternative: "/x/plugins-evil" startswith "/x/plugins" but is a
different directory, and there is now a test that fails if anyone
swaps commonpath for startswith.

Second, raising ComposerInputError from _plugin_dir and returning
str(exc) added two new py/stack-trace-exposure alerts -- CodeQL flags
exception text reaching a response regardless of the exception's type.
_plugin_dir returns None instead and the three handlers answer with a
fixed literal. There is nothing a caller needs there beyond "that id is
not ok".

Also defines .md\:inline in app.css. composer.html marks five toolbar
button labels `hidden md:inline`, and the class was never defined, so
those labels were hidden at every width and the buttons stayed
icon-only. main's test_web_static_audit.py catches it -- the branch
predates that test, which is why it only surfaced now that CI checks
the merge:

    Responsive utility classes referenced in templates but never
    defined in app.css (they silently no-op): ['md:inline']

Verified against the merged state -- main's app.css plus this one line,
audited against this branch's templates: 3 passed. The other twelve
classes the audit flags locally are defined on main and are artifacts of
this branch being 54 commits behind.

33 containment tests. Mutation-checked twice: removing the containment
lets eight payloads escape, including /etc/passwd and
plugin/../../../../../../etc/shadow; swapping commonpath for startswith
fails the sibling-prefix test.

Not addressed: three py/stack-trace-exposure alerts on the
_generate_plugin_files handlers. Those return str(exc) for
ComposerInputError, whose seven raise sites are all authored literals
("Author is required.", "Config variable key X is not a valid Python
identifier."). Suppressing them means replacing useful validation
feedback with a generic string, which is a real cost to the user for a
scanner's benefit. Worth a decision rather than a silent downgrade.

The 5 test_web_api.py failures are pre-existing on this branch --
identical counts with these changes stashed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
2026-08-21 17:52:11 -04:00
ChuckBuildsandClaude Opus 5 e499efb1f0 fix(composer): resolve plugin paths at the filesystem boundary
CodeQL reports 19 alerts against this PR -- 16 high-severity
py/path-injection plus 3 py/stack-trace-exposure -- all in
web_interface/blueprints/composer.py, where a request-supplied plugin_id
reaches Path(plugins_dir) / plugin_id and the result is created, written
to, deleted with shutil.rmtree, and read back.

The path-injection alerts are false positives today. _PLUGIN_ID_RE is
fully anchored and permits only [a-z][a-z0-9-]{0,62}, so every traversal
payload is already rejected; I checked fourteen of them, including
../../etc/passwd, a/../../etc, /etc/passwd and encoded variants, and none
gets past it.

They are worth fixing anyway. The guarantee lived in a regex several
hundred lines from the path building, so relaxing that pattern later --
to allow an underscore, say -- would open a traversal with nothing at the
filesystem boundary to catch it. _plugin_dir() now resolves the candidate
and refuses anything that is not inside plugins_dir, and all three call
sites go through it. That is also the shape static analysis recognises,
which is why sixteen alerts landed on code that was already safe.

The regex anchor moves from $ to \Z. Python's $ also matches just before
a trailing newline, so "myplugin\n" was accepted and would have created a
directory whose name ends in one. Not traversal, but not a name anything
downstream should have to handle.

For the stack-trace exposure: the handlers returned str(exc) for any
ValueError out of _generate_plugin_files. The seven raises there are all
curated, user-facing validation messages, and they now use a
ComposerInputError subclass so they keep reaching the user verbatim. A
ValueError from anywhere else -- json, int(), a library -- is logged with
a traceback and answered generically, since its text can name internal
paths.

Verified: 32 tests covering fourteen traversal payloads and twelve
malformed ids. The key one re-runs every payload with the id pattern
deliberately loosened to allow slashes and dots; removing the containment
check fails it with

    these escaped the base with a loosened regex:
      [('/etc/passwd', '/etc/passwd'), ('//etc/passwd', '//etc/passwd')]

so the boundary is doing real work rather than shadowing the regex.

The 5 failures in test_web_api.py are unrelated and pre-existing on this
branch -- identical counts with these changes stashed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
2026-08-21 17:18:26 -04:00
ChuckBuildsandClaude Sonnet 5 cd7e16e58e fix(security): validate plugin_id before path construction in /api/install
CodeQL flagged 16 high-severity "path depends on user-provided value"
alerts. Investigated each:

- install_locally() (/api/install) built a filesystem path from
  metadata.id without validating it at that point -- it was only
  implicitly safe because _generate_plugin_files() validates the same
  field (re-extracted independently) earlier in the same request. That's
  a real gap: reorder or change that earlier call and it's an exploitable
  path traversal / arbitrary file write. Fixed by validating plugin_id
  directly against _PLUGIN_ID_RE at the point the path is built, matching
  the pattern already used correctly in validate_id() and load_plugin().
- The other 10 flagged locations (serve_font's allowlist check,
  validate_id, load_plugin and its downstream reads) were already
  guarded by an explicit check earlier in the same function -- false
  positives from CodeQL not modeling those as sanitizers.

Also fixed 2 of the 5 "stack trace exposed" warnings that were genuine:
install_locally() and load_plugin() returned raw OSError/Exception text
to the client in a 500 response; now logged server-side with a generic
client-facing message. The other 3 (generate_zip/install_locally/
preview_code returning str(ValueError) from _generate_plugin_files) are
deliberate, human-authored validation messages, not exception internals
-- left as-is.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
2026-07-14 17:23:25 -04:00
ChuckBuildsandClaude Sonnet 5 47e3021fc3 fix: address Codacy findings in the Composer blueprint
- Dropped a pointless f-string prefix (no placeholders) on the default
  plugin description.
- Replaced two bare except:pass/continue blocks (manifest.json listing,
  config_schema.json parsing) with a logged warning before falling
  through to the same skip-this-entry behavior -- same control flow,
  now visible in logs instead of silent.

Skipped as false positives (verified against actual usage, not fixed):
- Jinja2 Environment(autoescape=False) -- this env renders manager.py.j2,
  a Python source-code generator, never HTML; autoescaping would corrupt
  generated code. Flagged by a generic XSS rule that assumes all Jinja2
  environments render HTML.
- "Flask route directly returning a formatted string" on _as_rgb_filter
  -- that's a Jinja *filter* function, not a Flask route.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
2026-07-14 16:31:43 -04:00
ChuckBuildsandClaude Sonnet 5 e319540c6e feat(web): add Plugin Composer -- visual drag-and-drop plugin builder
Web UI (/composer/) for building a working LEDMatrix plugin without
writing Python: drop elements (text, time, date, countdown, scrolling
text, bar/waveform, groups, custom config variables) onto a canvas
matching the real panel's pixel grid, configure them with live preview,
then generate a real plugin (manager.py + manifest.json + config_schema.json)
from manager.py.j2 -- downloadable as a ZIP or installed directly.

NOTE: composer_bp is not yet registered in web_interface/app.py, so this
blueprint is currently inert. Split out of the original chore/dead-code-
removal commit, which had accidentally bundled this in alongside unrelated
dead-code deletions; app.py registration was not part of that commit
either and still needs to be added before this is reachable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
2026-07-14 16:27:50 -04:00
115 changed files with 16204 additions and 22585 deletions
-4
View File
@@ -49,7 +49,3 @@ config/backups/
# Starlark apps runtime storage (installed .star files and cached renders) # Starlark apps runtime storage (installed .star files and cached renders)
/starlark-apps/ /starlark-apps/
skin_renders/ skin_renders/
# JS test deps (test/js)
node_modules/
package-lock.json
+1 -51
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`). > The API blueprint is mounted at `/api/v3` (`web_interface/app.py:199`).
> SSE stream endpoints (`/api/v3/stream/*`) are defined directly on the > SSE stream endpoints (`/api/v3/stream/*`) are defined directly on the
> Flask app at `app.py:799-809`. There are 111 routes total — see > Flask app at `app.py:799-809`. There are 94 routes total — see
> `web_interface/blueprints/api_v3.py` for the canonical list. > `web_interface/blueprints/api_v3.py` for the canonical list.
--- ---
@@ -223,56 +223,6 @@ 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 ### On-Demand Display Status
**GET** `/api/v3/display/on-demand/status` **GET** `/api/v3/display/on-demand/status`
-266
View File
@@ -1,266 +0,0 @@
# 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
@@ -1 +0,0 @@
bridge_config.json
-118
View File
@@ -1,118 +0,0 @@
# 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.
@@ -1,13 +0,0 @@
{
"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"
}
@@ -1,548 +0,0 @@
#!/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())
@@ -1,5 +0,0 @@
# 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
+10 -49
View File
@@ -194,15 +194,6 @@ class StarlarkAppsPlugin(BasePlugin):
Each installed app becomes a dynamic display mode. 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], def __init__(self, plugin_id: str, config: Dict[str, Any],
display_manager, cache_manager, plugin_manager): display_manager, cache_manager, plugin_manager):
"""Initialize the Starlark Apps plugin.""" """Initialize the Starlark Apps plugin."""
@@ -690,62 +681,38 @@ class StarlarkAppsPlugin(BasePlugin):
if app.is_enabled() and app.should_render(current_time): if app.is_enabled() and app.should_render(current_time):
self._render_app(app, force=False) self._render_app(app, force=False)
def display(self, display_mode: Optional[str] = None, force_clear: bool = False) -> bool: def display(self, force_clear: bool = False) -> None:
""" """
Display current Starlark app. Display current Starlark app.
This method is called during the display rotation. This method is called during the display rotation.
Displays frames from the currently active app. 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: try:
if force_clear: if force_clear:
self.display_manager.clear() self.display_manager.clear()
if display_mode and display_mode in self.apps: # If no current app, try to select one
self.current_app = self.apps[display_mode] if not self.current_app:
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() self._select_next_app()
if not self.current_app: if not self.current_app:
# No apps available # No apps available
self.logger.debug("No Starlark apps to display") self.logger.debug("No Starlark apps to display")
return False return
# Render app if needed # Render app if needed
if not self.current_app.frames: if not self.current_app.frames:
success = self._render_app(self.current_app, force=True) success = self._render_app(self.current_app, force=True)
if not success: if not success:
self.logger.error(f"Failed to render app: {self.current_app.app_id}") self.logger.error(f"Failed to render app: {self.current_app.app_id}")
return False return
# Display current frame. The result is propagated: a failed frame # Display current frame
# update is not a displayed frame, and returning True regardless self._display_frame()
# 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: except Exception as e:
self.logger.error(f"Error displaying Starlark app: {e}") self.logger.error(f"Error displaying Starlark app: {e}")
return False
def _select_next_app(self) -> None: def _select_next_app(self) -> None:
"""Select the next enabled app for display.""" """Select the next enabled app for display."""
@@ -868,13 +835,10 @@ class StarlarkAppsPlugin(BasePlugin):
self.logger.error(f"Error loading frames for {app.app_id}: {e}") self.logger.error(f"Error loading frames for {app.app_id}: {e}")
return False return False
def _display_frame(self) -> bool: def _display_frame(self) -> None:
"""Display the current frame of the current app. """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: if not self.current_app or not self.current_app.frames:
return False return
try: try:
current_time = time.time() current_time = time.time()
@@ -892,11 +856,8 @@ class StarlarkAppsPlugin(BasePlugin):
) )
self.current_app.last_frame_time = current_time self.current_app.last_frame_time = current_time
return True
except Exception as e: except Exception as e:
self.logger.error(f"Error displaying frame: {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: def install_app(self, app_id: str, star_file_path: str, metadata: Optional[Dict[str, Any]] = None, assets_dir: Optional[str] = None) -> bool:
""" """
+10 -98
View File
@@ -264,18 +264,10 @@ class PixletRenderer:
else: else:
value_str = str(value) value_str = str(value)
# Validate value doesn't contain dangerous shell metacharacters. # Validate value doesn't contain dangerous shell metacharacters
# Kept as defence in depth only: cmd is a list and there is no # Block: backticks, $(), pipes, redirects, semicolons, ampersands, null bytes
# shell=True below, so nothing here is ever interpreted by a # Allow: most printable chars including spaces, quotes, brackets, braces
# shell. That made the list worth trimming rather than growing if re.search(r'[`$|<>&;\x00]|\$\(', value_str):
# -- "|" 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}") logger.warning(f"Skipping config value with unsafe shell characters for key {key}: {value_str}")
continue continue
@@ -307,21 +299,13 @@ class PixletRenderer:
) )
if result.returncode == 0: if result.returncode == 0:
if not os.path.isfile(output_path): if os.path.isfile(output_path):
logger.debug(f"Successfully rendered: {star_file} -> {output_path}")
return True, None
else:
error = "Rendering succeeded but output file not found" error = "Rendering succeeded but output file not found"
logger.error(error) logger.error(error)
return False, 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: else:
error = f"Pixlet failed (exit {result.returncode}): {result.stderr}" error = f"Pixlet failed (exit {result.returncode}): {result.stderr}"
logger.error(error) logger.error(error)
@@ -335,76 +319,11 @@ class PixletRenderer:
logger.exception("Rendering exception") logger.exception("Rendering exception")
return False, "Rendering failed - see logs for details" 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]]: def extract_schema(self, star_file: str) -> Tuple[bool, Optional[Dict[str, Any]], Optional[str]]:
""" """
Extract configuration schema from a .star file. Extract configuration schema from a .star file by parsing source code.
Prefers `pixlet schema`, which runs the app and therefore sees options Supports:
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) - Static field definitions (location, text, toggle, dropdown, color, datetime)
- Variable-referenced dropdown options - Variable-referenced dropdown options
- Graceful degradation for unsupported field types - Graceful degradation for unsupported field types
@@ -418,13 +337,6 @@ class PixletRenderer:
if not os.path.isfile(star_file): if not os.path.isfile(star_file):
return False, None, f"Star file not found: {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: try:
# Read .star file # Read .star file
with open(star_file, 'r', encoding='utf-8') as f: with open(star_file, 'r', encoding='utf-8') as f:
+28 -112
View File
@@ -5,7 +5,6 @@ Handles interaction with the Tronbyte apps repository on GitHub.
Fetches app listings, metadata, and downloads .star files. Fetches app listings, metadata, and downloads .star files.
""" """
import json
import logging import logging
import time import time
import requests import requests
@@ -50,13 +49,6 @@ class TronbyteRepository:
self.base_url = "https://api.github.com" self.base_url = "https://api.github.com"
self.raw_url = "https://raw.githubusercontent.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() self.session = requests.Session()
if github_token: if github_token:
self.session.headers.update({ self.session.headers.update({
@@ -78,50 +70,29 @@ class TronbyteRepository:
Returns: Returns:
JSON response or None on error JSON response or None on error
""" """
self.last_error = None
try: try:
response = self.session.get(url, timeout=timeout) response = self.session.get(url, timeout=timeout)
if response.status_code in (403, 429): if response.status_code == 403:
# 403 is both "rate limited" and "forbidden"; the remaining # Rate limit exceeded
# counter is what tells them apart, and the difference matters logger.warning("[Tronbyte Repo] GitHub API rate limit exceeded")
# 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 return None
elif response.status_code == 404: elif response.status_code == 404:
self.last_error = "Not found on GitHub"
logger.warning(f"[Tronbyte Repo] Resource not found: {url}") logger.warning(f"[Tronbyte Repo] Resource not found: {url}")
return None return None
elif response.status_code != 200: 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}") logger.error(f"[Tronbyte Repo] GitHub API error: {response.status_code}")
return None return None
return response.json() return response.json()
except requests.Timeout: except requests.Timeout:
self.last_error = "Timed out reaching GitHub"
logger.error(f"[Tronbyte Repo] Request timeout: {url}") logger.error(f"[Tronbyte Repo] Request timeout: {url}")
return None return None
except requests.RequestException as e: 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) logger.error(f"[Tronbyte Repo] Request error: {e}", exc_info=True)
return None return None
except (json.JSONDecodeError, ValueError) as e: 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) logger.error(f"[Tronbyte Repo] JSON parse error for {url}: {e}", exc_info=True)
return None return None
@@ -154,61 +125,6 @@ class TronbyteRepository:
logger.error(f"[Tronbyte Repo] Network error fetching raw file {file_path}: {e}", exc_info=True) logger.error(f"[Tronbyte Repo] Network error fetching raw file {file_path}: {e}", exc_info=True)
return None 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]]: def list_apps(self) -> Tuple[bool, Optional[List[Dict[str, Any]]], Optional[str]]:
""" """
List all available apps in the repository. List all available apps in the repository.
@@ -216,17 +132,26 @@ class TronbyteRepository:
Returns: Returns:
Tuple of (success, apps_list, error_message) Tuple of (success, apps_list, error_message)
""" """
apps = self._list_app_dirs_via_trees() url = f"{self.base_url}/repos/{self.REPO_OWNER}/{self.REPO_NAME}/contents/{self.APPS_PATH}"
if apps is None:
# Fall back rather than fail: the contents API was what shipped, data = self._make_request(url)
# so a trees-only outage should not take the store down with it. if data is None:
trees_error = self.last_error return False, None, "Failed to fetch repository contents"
logger.warning(
f"[Tronbyte Repo] Trees listing failed ({trees_error}); " if not isinstance(data, list):
"falling back to the contents API") return False, None, "Invalid response format"
apps = self._list_app_dirs_via_contents()
if apps is None: # Filter directories (apps)
return False, None, self.last_error or trees_error or "Failed to fetch repository contents" 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')
})
logger.info(f"Found {len(apps)} apps in repository") logger.info(f"Found {len(apps)} apps in repository")
return True, apps, None return True, apps, None
@@ -342,22 +267,14 @@ class TronbyteRepository:
'categories': _apps_cache['categories'], 'categories': _apps_cache['categories'],
'authors': _apps_cache['authors'], 'authors': _apps_cache['authors'],
'count': len(_apps_cache['data']), 'count': len(_apps_cache['data']),
'cached': True, 'cached': True
'error': None,
} }
# Fetch directory listing (a small number of GitHub API calls) # Fetch directory listing (1 GitHub API call)
success, app_dirs, error = self.list_apps() success, app_dirs, error = self.list_apps()
if not success or not app_dirs: if not success or not app_dirs:
# Returning an empty list here used to read downstream as "the logger.error(f"Failed to list apps for bulk fetch: {error}")
# repository has no apps", and the route reported that as a return {'apps': [], 'categories': [], 'authors': [], 'count': 0, 'cached': False}
# 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...") logger.info(f"Bulk-fetching manifests for {len(app_dirs)} apps...")
@@ -424,8 +341,7 @@ class TronbyteRepository:
'categories': categories, 'categories': categories,
'authors': authors, 'authors': authors,
'count': len(apps_with_metadata), '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]]: def download_star_file(self, app_id: str, output_path: Path, filename: Optional[str] = None) -> Tuple[bool, Optional[str]]:
-5
View File
@@ -7,8 +7,3 @@ 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 psutil>=6.0.0,<8.0.0 # optional at runtime; installed for tests so the
# /system/status endpoint's real path is exercised # /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) 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,18 +55,6 @@ packaging>=23.0,<27.0
# range as a hard dependency — keep the two in sync. # range as a hard dependency — keep the two in sync.
# pip install 'psutil>=6.0.0,<7.0.0' # 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 # Flask-Limiter — request rate limiting in web_interface/app.py
# (accidental-abuse protection, not security). The # (accidental-abuse protection, not security). The
# web interface starts without rate limiting when # web interface starts without rate limiting when
-159
View File
@@ -1,159 +0,0 @@
#!/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
@@ -1,330 +0,0 @@
#!/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
@@ -1,86 +0,0 @@
#!/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
@@ -1,64 +0,0 @@
#!/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 ""
+4 -94
View File
@@ -6,9 +6,6 @@ Discovers and runs tests for LEDMatrix plugins.
Supports both unittest and pytest. Supports both unittest and pytest.
""" """
import os
import re
import subprocess # nosec B404 - list-form argv only, no shell # nosemgrep
import sys import sys
import argparse import argparse
from pathlib import Path from pathlib import Path
@@ -71,79 +68,6 @@ def _find_tests_in_dir(directory: Path) -> list:
return sorted(set(test_files)) 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: def run_unittest_tests(test_files: list, verbose: bool = False) -> int:
""" """
Run tests using unittest. Run tests using unittest.
@@ -262,12 +186,7 @@ def main():
print("No test files found in plugins directory") print("No test files found in plugins directory")
return 0 return 0
scripts = [f for f in test_files if _is_script_style(f)] print(f"Found {len(test_files)} test file(s)")
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: for test_file in test_files:
print(f" - {test_file}") print(f" - {test_file}")
print() print()
@@ -281,20 +200,11 @@ def main():
except ImportError: except ImportError:
runner = 'unittest' runner = 'unittest'
# Standalone scripts cannot be collected by pytest or unittest -- run them # Run tests
# 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': if runner == 'pytest':
rc |= run_pytest_tests(modules, args.verbose, args.coverage) return run_pytest_tests(test_files, args.verbose, args.coverage)
else: else:
rc |= run_unittest_tests(modules, args.verbose) return run_unittest_tests(test_files, args.verbose)
return rc
if __name__ == '__main__': if __name__ == '__main__':
-240
View File
@@ -1,240 +0,0 @@
#!/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
@@ -1,196 +0,0 @@
#!/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,8 +9,6 @@ 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 - **`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 - **`cleanup_venv.sh`** - Cleans up Python virtual environment files
- **`clear_python_cache.sh`** - Clears Python cache files (__pycache__, *.pyc, etc.) - **`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 ## Usage
@@ -27,31 +25,3 @@ This script is typically called by the systemd service (`ledmatrix-web.service`)
### WiFi Monitor Daemon ### 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. 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
@@ -1,94 +0,0 @@
#!/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
@@ -1,197 +0,0 @@
#!/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"
+6 -8
View File
@@ -27,8 +27,6 @@ sys.path.insert(0, str(PROJECT_ROOT))
from PIL import Image, ImageDraw, ImageFont # noqa: E402 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" FIXTURES_DIR = PROJECT_ROOT / "src" / "skin_system" / "fixtures"
MODES = ("live", "recent", "upcoming") MODES = ("live", "recent", "upcoming")
SPORTS = ("baseball", "basketball", "football", "hockey") SPORTS = ("baseball", "basketball", "football", "hockey")
@@ -54,12 +52,12 @@ class FixtureHost:
try: try:
press = str(PROJECT_ROOT / "assets/fonts/PressStart2P-Regular.ttf") press = str(PROJECT_ROOT / "assets/fonts/PressStart2P-Regular.ttf")
small = str(PROJECT_ROOT / "assets/fonts/4x6-font.ttf") small = str(PROJECT_ROOT / "assets/fonts/4x6-font.ttf")
fonts['score'] = load_truetype(press, 10) fonts['score'] = ImageFont.truetype(press, 10)
fonts['time'] = load_truetype(press, 8) fonts['time'] = ImageFont.truetype(press, 8)
fonts['team'] = load_truetype(press, 8) fonts['team'] = ImageFont.truetype(press, 8)
fonts['status'] = load_truetype(small, 6) fonts['status'] = ImageFont.truetype(small, 6)
fonts['detail'] = load_truetype(small, 6) fonts['detail'] = ImageFont.truetype(small, 6)
fonts['rank'] = load_truetype(press, 10) fonts['rank'] = ImageFont.truetype(press, 10)
except IOError: except IOError:
default = ImageFont.load_default() default = ImageFont.load_default()
for key in ('score', 'time', 'team', 'status', 'detail', 'rank'): for key in ('score', 'time', 'team', 'status', 'detail', 'rank'):
+10 -11
View File
@@ -15,7 +15,6 @@ from typing import Any, Dict, List, Optional, Tuple
import pytz import pytz
import requests import requests
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
from src.common.font_layout import load_truetype
from requests.adapters import HTTPAdapter from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry from urllib3.util.retry import Retry
@@ -450,12 +449,12 @@ class SportsCore(ABC):
press_start = self._resolve_font_path("PressStart2P-Regular.ttf") press_start = self._resolve_font_path("PressStart2P-Regular.ttf")
four_by_six = self._resolve_font_path("4x6-font.ttf") four_by_six = self._resolve_font_path("4x6-font.ttf")
try: try:
fonts['score'] = load_truetype(press_start, 10) fonts['score'] = ImageFont.truetype(press_start, 10)
fonts['time'] = load_truetype(press_start, 8) fonts['time'] = ImageFont.truetype(press_start, 8)
fonts['team'] = load_truetype(press_start, 8) fonts['team'] = ImageFont.truetype(press_start, 8)
fonts['status'] = load_truetype(four_by_six, 6) # Using 4x6 for status fonts['status'] = ImageFont.truetype(four_by_six, 6) # Using 4x6 for status
fonts['detail'] = load_truetype(four_by_six, 6) # Added detail font fonts['detail'] = ImageFont.truetype(four_by_six, 6) # Added detail font
fonts['rank'] = load_truetype(press_start, 10) fonts['rank'] = ImageFont.truetype(press_start, 10)
self.logger.info("Successfully loaded fonts") self.logger.info("Successfully loaded fonts")
except OSError: except OSError:
# Name the directory we searched: the usual cause is an install # Name the directory we searched: the usual cause is an install
@@ -1032,7 +1031,7 @@ class SportsCore(ABC):
if os.path.exists(font_path): if os.path.exists(font_path):
# Try loading as TTF first (works for both TTF and some BDF files with PIL) # Try loading as TTF first (works for both TTF and some BDF files with PIL)
if font_path.lower().endswith('.ttf'): if font_path.lower().endswith('.ttf'):
font = load_truetype(font_path, font_size) font = ImageFont.truetype(font_path, font_size)
self.logger.debug(f"Loaded font: {font_name} at size {font_size}") self.logger.debug(f"Loaded font: {font_name} at size {font_size}")
self._font_cache[cache_key] = font self._font_cache[cache_key] = font
return font return font
@@ -1050,7 +1049,7 @@ class SportsCore(ABC):
# correct one: the newer copies call truetype() on a BDF at # correct one: the newer copies call truetype() on a BDF at
# any size (which simply fails) or refuse BDF outright. # any size (which simply fails) or refuse BDF outright.
try: try:
font = load_truetype(font_path, font_size) font = ImageFont.truetype(font_path, font_size)
self.logger.debug(f"Loaded BDF font: {font_name} at size {font_size}") self.logger.debug(f"Loaded BDF font: {font_name} at size {font_size}")
self._font_cache[cache_key] = font self._font_cache[cache_key] = font
return font return font
@@ -1062,7 +1061,7 @@ class SportsCore(ABC):
self._bdf_native_size_cache[font_path] = native_size self._bdf_native_size_cache[font_path] = native_size
if native_size and native_size != font_size: if native_size and native_size != font_size:
try: try:
font = load_truetype(font_path, native_size) font = ImageFont.truetype(font_path, native_size)
self.logger.debug( self.logger.debug(
f"Loaded BDF font: {font_name} at its native size {native_size} " f"Loaded BDF font: {font_name} at its native size {native_size} "
f"(requested {font_size} isn't a valid strike for this file)" f"(requested {font_size} isn't a valid strike for this file)"
@@ -1090,7 +1089,7 @@ class SportsCore(ABC):
_resolve_font_family_alias(base_default)) _resolve_font_family_alias(base_default))
try: try:
if os.path.exists(default_font_path): if os.path.exists(default_font_path):
font = load_truetype(default_font_path, font_size) font = ImageFont.truetype(default_font_path, font_size)
else: else:
self.logger.warning("Default font not found, using PIL default") self.logger.warning("Default font not found, using PIL default")
font = ImageFont.load_default() font = ImageFont.load_default()
+8 -98
View File
@@ -5,7 +5,6 @@ Handles persistent disk-based caching with atomic writes and error recovery.
""" """
import json import json
import math
import os import os
import time import time
import tempfile import tempfile
@@ -15,11 +14,6 @@ import zlib
from typing import Dict, Any, Optional, Protocol from typing import Dict, Any, Optional, Protocol
from datetime import datetime 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. # 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 # 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 # any in-flight write while still clearing the same day's debris. Deliberately
@@ -46,97 +40,13 @@ class CacheStrategyProtocol(Protocol):
class DateTimeEncoder(json.JSONEncoder): 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: def default(self, obj: Any) -> Any:
if isinstance(obj, datetime): if isinstance(obj, datetime):
return obj.isoformat() return obj.isoformat()
return super().default(obj) 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: class DiskCache:
"""Manages persistent disk-based cache.""" """Manages persistent disk-based cache."""
@@ -189,8 +99,8 @@ class DiskCache:
try: try:
with self._lock: with self._lock:
with open(cache_path, 'rb') as f: with open(cache_path, 'r', encoding='utf-8') as f:
record = _loads(f.read()) record = json.load(f)
# Determine record timestamp (prefer embedded, else file mtime) # Determine record timestamp (prefer embedded, else file mtime)
record_ts = None record_ts = None
@@ -279,12 +189,12 @@ class DiskCache:
# write path below, and cache files are machine-read only — indenting # write path below, and cache files are machine-read only — indenting
# them just multiplied the bytes written to the SD card. # them just multiplied the bytes written to the SD card.
try: try:
payload = _dumps(data) payload = json.dumps(data, cls=DateTimeEncoder)
except (TypeError, ValueError) as e: except (TypeError, ValueError) as e:
self.logger.warning("Cache data for key '%s' not serializable: %s", key, e) self.logger.warning("Cache data for key '%s' not serializable: %s", key, e)
return return
digest = zlib.adler32(payload) digest = zlib.adler32(payload.encode('utf-8'))
try: try:
# Atomic write to avoid partial/corrupt files # Atomic write to avoid partial/corrupt files
@@ -332,7 +242,7 @@ class DiskCache:
# wear source (dozens of fsyncs/min on API-heavy # wear source (dozens of fsyncs/min on API-heavy
# installs) for data that can be re-downloaded. # installs) for data that can be re-downloaded.
try: try:
with os.fdopen(fd, 'wb') as tmp_file: with os.fdopen(fd, 'w', encoding='utf-8') as tmp_file:
tmp_file.write(payload) tmp_file.write(payload)
os.replace(tmp_path, cache_path) os.replace(tmp_path, cache_path)
self._write_digests[key] = digest self._write_digests[key] = digest
@@ -350,7 +260,7 @@ class DiskCache:
else: else:
# Fallback: direct write (not atomic, but better than failing) # Fallback: direct write (not atomic, but better than failing)
try: try:
with open(cache_path, 'wb') as cache_file: with open(cache_path, 'w', encoding='utf-8') as cache_file:
cache_file.write(payload) cache_file.write(payload)
self._write_digests[key] = digest self._write_digests[key] = digest
# Set proper permissions: 660 (rw-rw----) for group-readable cache files # Set proper permissions: 660 (rw-rw----) for group-readable cache files
@@ -380,7 +290,7 @@ class DiskCache:
# is a different path, so future sets must keep # is a different path, so future sets must keep
# retrying the primary location. # retrying the primary location.
fallback_path = os.path.join(fallback_dir, os.path.basename(cache_path)) fallback_path = os.path.join(fallback_dir, os.path.basename(cache_path))
with open(fallback_path, 'wb') as tmp_file: with open(fallback_path, 'w', encoding='utf-8') as tmp_file:
tmp_file.write(payload) tmp_file.write(payload)
# Set proper permissions: 660 (rw-rw----) for group-readable cache files # Set proper permissions: 660 (rw-rw----) for group-readable cache files
try: try:
-12
View File
@@ -23,13 +23,6 @@ from src.common.error_handler import (
) )
from src.common.api_helper import APIHelper from src.common.api_helper import APIHelper
from src.common.scroll_helper import ScrollHelper 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.logo_helper import LogoHelper
from src.common.text_helper import TextHelper from src.common.text_helper import TextHelper
@@ -67,11 +60,6 @@ __all__ = [
'log_and_raise', 'log_and_raise',
'APIHelper', 'APIHelper',
'ScrollHelper', 'ScrollHelper',
'scroll_config',
'ScrollSettings',
'configure_scroll',
'resolve_scroll_settings',
'refresh_hz_from_config',
'LogoHelper', 'LogoHelper',
'TextHelper', 'TextHelper',
# adaptive layout & images # adaptive layout & images
-43
View File
@@ -1,43 +0,0 @@
"""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
@@ -1,452 +0,0 @@
"""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
+23 -145
View File
@@ -16,7 +16,6 @@ Features:
""" """
import logging import logging
import math
import time import time
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
from PIL import Image from PIL import Image
@@ -30,49 +29,6 @@ except ImportError:
HAS_SCIPY = False 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: class ScrollHelper:
""" """
Helper class for scrolling text and image content on LED displays. Helper class for scrolling text and image content on LED displays.
@@ -119,26 +75,12 @@ class ScrollHelper:
# Pre-allocated buffer for output frame (reused to avoid allocations) # Pre-allocated buffer for output frame (reused to avoid allocations)
self._frame_buffer: Optional[np.ndarray] = None self._frame_buffer: Optional[np.ndarray] = None
# Sub-pixel scrolling: OFF by default, and that is deliberate. # Sub-pixel scrolling settings (disabled - using high FPS integer scrolling instead)
# Blending renders a half-step by mixing two adjacent columns 50/50. self.sub_pixel_scrolling = False # Disabled - use high frame rate for smoothness
# 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 self._last_integer_position = 0 # Cache for integer position to avoid repeated calculations
# Frame-based scrolling settings # Frame-based scrolling settings
self.frame_based_scrolling = False self.frame_based_scrolling = False # If True, use scroll_delay to throttle and move scroll_speed pixels
#: 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 self.last_step_time = 0.0 # Track last step time for frame-based throttling
# Time tracking for scroll updates # Time tracking for scroll updates
@@ -163,9 +105,6 @@ class ScrollHelper:
self.last_frame_time = time.time() self.last_frame_time = time.time()
self.last_fps_log_time = time.time() self.last_fps_log_time = time.time()
self.frame_times = [] 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 # Scrolling state management
self.is_scrolling = False self.is_scrolling = False
@@ -298,44 +237,26 @@ class ScrollHelper:
self.last_progress_log_time = current_time self.last_progress_log_time = current_time
# Update scroll position # Update scroll position
if self.fixed_pixels_per_frame: if self.frame_based_scrolling:
# 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 # Frame-based: move fixed amount when scroll_delay has passed
# This matches stock ticker behavior: move pixels, then wait scroll_delay # This matches stock ticker behavior: move pixels, then wait scroll_delay
# Initialize last_step_time on first call to prevent huge initial jump # Initialize last_step_time on first call to prevent huge initial jump
if self.last_step_time == 0.0: if self.last_step_time == 0.0:
self.last_step_time = current_time self.last_step_time = current_time
# Frame-based mode advances by elapsed time, exactly like the # Check if scroll_delay has passed
# time-based branch below, at the same configured speed time_since_last_step = current_time - self.last_step_time
# (scroll_speed px per scroll_delay seconds). if time_since_last_step >= self.scroll_delay:
# # Move pixels (can move multiple steps if lag occurred, but cap to prevent huge jumps)
# It used to step discretely: 0, 1 or 2 whole pixels depending on steps = int(time_since_last_step / self.scroll_delay)
# whether a wall clock had passed scroll_delay. Plugins set # Cap at reasonable number to prevent huge jumps from lag
# scroll_delay to the target frame period, so that comparison sits max_steps = max(1, int(0.04 / self.scroll_delay)) # Limit to 0.04s (2 steps at 50 FPS) for smoother scrolling
# exactly on its own threshold and the decision flips on sub- steps = min(steps, max_steps)
# millisecond jitter -- a frame a hair early moved nothing and pixels_to_move = self.scroll_speed * steps
# rendered an identical frame, a frame a hair late moved two # Update last_step_time, preserving fractional delay for smooth timing
# pixels. Rounding the step count fixed the stalls but still self.last_step_time = current_time - (time_since_last_step % self.scroll_delay)
# 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: else:
pixels_per_second = self.scroll_speed * 100.0 pixels_to_move = 0.0
pixels_to_move = pixels_per_second * delta_time
self.last_step_time = current_time
else: else:
# Time-based: move based on time delta (correct speed over time) # Time-based: move based on time delta (correct speed over time)
# scroll_speed is pixels per second # scroll_speed is pixels per second
@@ -988,13 +909,6 @@ class ScrollHelper:
Args: Args:
speed: Scroll speed (interpretation depends on frame_based_scrolling mode) 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: if self.frame_based_scrolling:
# In frame-based mode, clamp to reasonable pixels per frame (0.1-5) # 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 # Higher values cause visible jumps - 1-2 pixels/frame is ideal for smoothness
@@ -1015,35 +929,6 @@ class ScrollHelper:
self.scroll_delay = max(0.001, min(1.0, delay)) self.scroll_delay = max(0.001, min(1.0, delay))
self.logger.debug(f"Scroll delay set to: {self.scroll_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: def set_target_fps(self, fps: float) -> None:
""" """
Set the target frames per second for scrolling. Set the target frames per second for scrolling.
@@ -1133,24 +1018,17 @@ class ScrollHelper:
if len(self.frame_times) > 100: if len(self.frame_times) > 100:
self.frame_times.pop(0) 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 # Log FPS every 5 seconds to avoid spam
if current_time - self.last_fps_log_time >= 5.0: if current_time - self.last_fps_log_time >= 5.0:
self.logger.info( avg_frame_time = sum(self.frame_times) / len(self.frame_times)
"Scroll frame stats - %s", avg_fps = 1.0 / avg_frame_time if avg_frame_time > 0 else 0
format_frame_stats(self._window or [frame_time]), 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.last_fps_log_time = current_time self.last_fps_log_time = current_time
self.frame_count = 0 self.frame_count = 0
self._window = []
self.last_frame_time = current_time self.last_frame_time = current_time
self.frame_count += 1 self.frame_count += 1
+39 -83
View File
@@ -43,7 +43,6 @@ from typing import Any, Dict, List, Optional
from PIL import Image from PIL import Image
from src.common import scroll_config
from src.common.scroll_helper import ScrollHelper from src.common.scroll_helper import ScrollHelper
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -59,9 +58,13 @@ DEFAULT_SCROLL_SETTINGS: Dict[str, Any] = {
"dynamic_duration": True, "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. #: 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 ASSUMED_FPS_WHEN_UNPACED = 100.0
@@ -233,75 +236,51 @@ class SportsScrollDisplay:
"""Apply config to the scroll helper. Safe to call again after a change.""" """Apply config to the scroll helper. Safe to call again after a change."""
settings = self._get_scroll_settings() 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)) dynamic_duration = bool(settings.get("dynamic_duration", True))
self.scroll_helper.set_scroll_delay(scroll_delay)
self.scroll_helper.set_dynamic_duration_settings( self.scroll_helper.set_dynamic_duration_settings(
enabled=dynamic_duration, enabled=dynamic_duration,
min_duration=settings.get("min_duration", 30), min_duration=settings.get("min_duration", 30),
max_duration=settings.get("max_duration", 600), max_duration=settings.get("max_duration", 600),
buffer=0.2, # ensure the strip clears the panel completely 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)
# Speed goes through scroll_config, which every other scrolling plugin # Config states speed in px/second; frame-based mode wants px/frame.
# already uses. What must NOT happen is handing it this module's if scroll_delay > 0:
# settings dict: the two read the same key names with different pixels_per_frame = scroll_speed * scroll_delay
# meanings, and the collision is a factor of 1/scroll_delay. else:
# pixels_per_frame = scroll_speed / ASSUMED_FPS_WHEN_UNPACED
# sports_scroll: scroll_speed is px/SECOND; scroll_delay is only the pixels_per_frame = max(
# frame period used to reach px/frame. MIN_PIXELS_PER_FRAME, min(MAX_PIXELS_PER_FRAME, pixels_per_frame)
# scroll_config: scroll_speed is px per STEP, so px/s = speed/delay. )
# self.scroll_helper.set_scroll_speed(pixels_per_frame)
# 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)
resolved = scroll_config.configure( effective_pps = (
self.scroll_helper, pixels_per_frame / scroll_delay
plugin_config=None, if scroll_delay > 0
global_config=self.global_config, else pixels_per_frame * ASSUMED_FPS_WHEN_UNPACED
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( self.logger.info(
"ScrollHelper configured: %s (requested %.1f px/s), " f"ScrollHelper configured: {pixels_per_frame:.2f} px/frame, "
"dynamic_duration=%s", f"delay={scroll_delay}s (effective {effective_pps:.1f} px/s from "
resolved.describe(), f"{scroll_speed} px/s config), dynamic_duration={dynamic_duration}"
pixels_per_second, dynamic_duration,
) )
def _resolve_pixels_per_second(self, settings: Dict[str, Any]) -> float: # The reason this module exists upstream: the bundled copies hardcode
"""This module's config shape, expressed as plain pixels per second. # ~100 FPS via scroll_delay and never consult the global target.
# No hasattr guard here, unlike the plugin copies: they probe because
``scroll_speed`` is already px/s here. ``scroll_delay`` only matters # they may run against an older core, whereas this module ships in the
when a caller supplied px/frame instead, which the 0 case covers. # same release as the ScrollHelper it calls. The helper clamps.
""" target_fps = self._resolve_target_fps()
scroll_speed = self._coerce_float(settings.get("scroll_speed"), 50.0) if target_fps:
scroll_delay = self._coerce_float(settings.get("scroll_delay"), 0.01) self.scroll_helper.set_target_fps(target_fps)
if scroll_delay <= 0: self.logger.info(f"Target FPS set to {target_fps}")
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 # Frame pumping
@@ -326,16 +305,6 @@ class SportsScrollDisplay:
if not visible: if not visible:
return False 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.image = visible
self.display_manager.update_display() self.display_manager.update_display()
self._frame_count += 1 self._frame_count += 1
@@ -362,19 +331,7 @@ class SportsScrollDisplay:
def is_scroll_complete(self) -> bool: def is_scroll_complete(self) -> bool:
"""True when the strip has scrolled fully past the panel.""" """True when the strip has scrolled fully past the panel."""
complete = self.scroll_helper.is_scroll_complete() return 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: def reset_scroll(self) -> None:
"""Return the strip to its starting position, keeping the content.""" """Return the strip to its starting position, keeping the content."""
@@ -392,7 +349,6 @@ class SportsScrollDisplay:
self._vegas_content_items = [] self._vegas_content_items = []
self._is_scrolling = False self._is_scrolling = False
self._scroll_start_time = None self._scroll_start_time = None
self._release_scrolling_state()
self.logger.debug("Scroll display cleared") self.logger.debug("Scroll display cleared")
# ------------------------------------------------------------------ # ------------------------------------------------------------------
+4 -5
View File
@@ -86,7 +86,6 @@ from typing import Any, ClassVar, Dict, List, Optional, Tuple
import pytz import pytz
import requests import requests
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
from src.common.font_layout import load_truetype
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -713,11 +712,11 @@ class SportsCoreSharedMixin:
while size > grid: while size > grid:
if probe.textlength( if probe.textlength(
self._SCORE_PROBE_TEXT, self._SCORE_PROBE_TEXT,
font=load_truetype(path, size)) <= budget: font=ImageFont.truetype(path, size)) <= budget:
break break
size -= grid size -= grid
if size != getattr(fonts['score'], 'size', size): if size != getattr(fonts['score'], 'size', size):
fonts['score'] = load_truetype(path, size) fonts['score'] = ImageFont.truetype(path, size)
self._score_grew = True self._score_grew = True
if not self._score_grew and not self._user_chose_size('score_text') \ if not self._score_grew and not self._user_chose_size('score_text') \
@@ -737,7 +736,7 @@ class SportsCoreSharedMixin:
if _size <= current: if _size <= current:
continue continue
_path = _resolve_font_path(f"assets/fonts/{_name}") _path = _resolve_font_path(f"assets/fonts/{_name}")
_candidate = load_truetype(_path, _size) _candidate = ImageFont.truetype(_path, _size)
if probe.textlength(self._SCORE_PROBE_TEXT, if probe.textlength(self._SCORE_PROBE_TEXT,
font=_candidate) <= budget: font=_candidate) <= budget:
fonts['score'] = _candidate fonts['score'] = _candidate
@@ -752,7 +751,7 @@ class SportsCoreSharedMixin:
if ceiling and size >= ceiling: if ceiling and size >= ceiling:
size = max(grid, ceiling - grid) size = max(grid, ceiling - grid)
if size != getattr(fonts['time'], 'size', size): if size != getattr(fonts['time'], 'size', size):
fonts['time'] = load_truetype(path, size) fonts['time'] = ImageFont.truetype(path, size)
except Exception: except Exception:
self.logger.debug("Headline font scaling skipped", exc_info=True) self.logger.debug("Headline font scaling skipped", exc_info=True)
return fonts return fonts
+1 -2
View File
@@ -10,7 +10,6 @@ from pathlib import Path
from typing import Dict, List, Optional, Tuple, Union from typing import Dict, List, Optional, Tuple, Union
from PIL import Image, ImageDraw, ImageFont 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. # Shared throwaway draw surface for measuring text without a target canvas.
_measure_draw = ImageDraw.Draw(Image.new("RGB", (1, 1))) _measure_draw = ImageDraw.Draw(Image.new("RGB", (1, 1)))
@@ -61,7 +60,7 @@ class TextHelper:
size = config['size'] size = config['size']
if font_path.exists(): if font_path.exists():
font = load_truetype(str(font_path), size) font = ImageFont.truetype(str(font_path), size)
fonts[font_name] = font fonts[font_name] = font
self.logger.debug(f"Loaded font: {font_name} ({font_path}, size {size})") self.logger.debug(f"Loaded font: {font_name} ({font_path}, size {size})")
else: else:
+85 -189
View File
@@ -198,10 +198,6 @@ class DisplayController:
# the main run loop reconciles (loads/unloads) on its own thread so # the main run loop reconciles (loads/unloads) on its own thread so
# mutating available_modes never races with rendering. # mutating available_modes never races with rendering.
self._pending_plugin_reconcile = False 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_active = False
self.on_demand_mode: Optional[str] = None self.on_demand_mode: Optional[str] = None
self.on_demand_modes: List[str] = [] # All modes for the on-demand plugin self.on_demand_modes: List[str] = [] # All modes for the on-demand plugin
@@ -307,7 +303,39 @@ class DisplayController:
# Check for on-demand plugin filter from cache # Check for on-demand plugin filter from cache
on_demand_config = self.cache_manager.get('display_on_demand_config', max_age=3600) on_demand_config = self.cache_manager.get('display_on_demand_config', max_age=3600)
enabled_plugins = self._select_startup_plugins(discovered_plugins, on_demand_config) 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)]
# Count enabled plugins for progress tracking # Count enabled plugins for progress tracking
enabled_count = len(enabled_plugins) enabled_count = len(enabled_plugins)
@@ -1239,119 +1267,12 @@ class DisplayController:
self.on_demand_schedule_override = False self.on_demand_schedule_override = False
self._publish_on_demand_state() 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: def _poll_on_demand_requests(self) -> None:
"""Poll cache for new on-demand requests from external controllers.""" """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: try:
# Use a long max_age (1 hour) to ensure requests aren't expired before processing # Use a long max_age (1 hour) to ensure requests aren't expired before processing
# The request_id check prevents duplicate processing. # The request_id check prevents duplicate processing
# request = self.cache_manager.get('display_on_demand_request', max_age=3600)
# 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: except (OSError, RuntimeError, ValueError, TypeError) as err:
logger.error("Failed to read on-demand request: %s", err, exc_info=True) logger.error("Failed to read on-demand request: %s", err, exc_info=True)
return return
@@ -1378,13 +1299,6 @@ class DisplayController:
logger.debug("Stop request %s received but on-demand is not active", request_id) logger.debug("Stop request %s received but on-demand is not active", request_id)
# Still update request_id to acknowledge the request # Still update request_id to acknowledge the request
self.on_demand_request_id = request_id 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 return
# For start requests, check if already processed # For start requests, check if already processed
@@ -1404,7 +1318,6 @@ class DisplayController:
# Mark as processed BEFORE processing (to prevent duplicate processing) # Mark as processed BEFORE processing (to prevent duplicate processing)
self.cache_manager.set('display_on_demand_processed_id', request_id, ttl=3600) self.cache_manager.set('display_on_demand_processed_id', request_id, ttl=3600)
self.on_demand_request_id = request_id self.on_demand_request_id = request_id
self._consume_on_demand_request(request_id)
if action == 'start': if action == 'start':
logger.info("Processing on-demand start request for plugin: %s", request.get('plugin_id')) logger.info("Processing on-demand start request for plugin: %s", request.get('plugin_id'))
@@ -1447,14 +1360,17 @@ class DisplayController:
return modes[0] return modes[0]
return plugin_id return plugin_id
def _on_demand_modes_for_plugin(self, plugin_id: str) -> List[str]: def _populate_on_demand_modes_from_plugin(self) -> None:
"""Every loaded display mode belonging to `plugin_id`, in rotation order.
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.
""" """
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
# Get all modes for this plugin
plugin_modes = self.plugin_display_modes.get(plugin_id, []) plugin_modes = self.plugin_display_modes.get(plugin_id, [])
if not plugin_modes: if not plugin_modes:
# Fallback: find all modes that belong to this plugin # Fallback: find all modes that belong to this plugin
@@ -1462,8 +1378,11 @@ class DisplayController:
# Filter to only include modes that exist in plugin_modes # 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] available_plugin_modes = [m for m in plugin_modes if m in self.plugin_modes]
if not available_plugin_modes: if not available_plugin_modes:
return [] logger.warning("No valid display modes found for on-demand plugin '%s' after restoration", plugin_id)
self.on_demand_modes = []
return
# Prioritize live modes if they exist and have content # Prioritize live modes if they exist and have content
live_modes = [m for m in available_plugin_modes if m.endswith('_live')] live_modes = [m for m in available_plugin_modes if m.endswith('_live')]
@@ -1491,45 +1410,6 @@ class DisplayController:
# Only live modes available but no content - use them anyway # Only live modes available but no content - use them anyway
ordered_modes = live_modes 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 self.on_demand_modes = ordered_modes
# Set index to match the restored mode if available, otherwise start at 0 # 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: if self.on_demand_mode and self.on_demand_mode in ordered_modes:
@@ -1584,13 +1464,45 @@ class DisplayController:
if resolved_mode in self.available_modes: if resolved_mode in self.available_modes:
self.current_mode_index = self.available_modes.index(resolved_mode) self.current_mode_index = self.available_modes.index(resolved_mode)
ordered_modes = self._on_demand_modes_for_plugin(resolved_plugin_id) # Get all modes for this plugin
if not ordered_modes: 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:
logger.error("No valid display modes found for plugin '%s'", resolved_plugin_id) logger.error("No valid display modes found for plugin '%s'", resolved_plugin_id)
self._set_on_demand_error("no-modes") self._set_on_demand_error("no-modes")
return return
ordered_modes = self._apply_on_demand_pin(ordered_modes, resolved_mode, pinned) # 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
self.on_demand_active = True self.on_demand_active = True
self.on_demand_mode = resolved_mode # Keep for backward compatibility self.on_demand_mode = resolved_mode # Keep for backward compatibility
@@ -2493,7 +2405,6 @@ class DisplayController:
) )
while True: while True:
_frame_start = time.perf_counter()
try: try:
with self._display_lock_or_skip(plugin_id) as can_display: with self._display_lock_or_skip(plugin_id) as can_display:
if can_display: if can_display:
@@ -2514,26 +2425,11 @@ class DisplayController:
# Multi-display sync: send follower frame after each render # Multi-display sync: send follower frame after each render
self._send_follower_frame(manager_to_display) self._send_follower_frame(manager_to_display)
time.sleep(display_interval)
self._tick_plugin_updates() self._tick_plugin_updates()
self._poll_on_demand_requests() self._poll_on_demand_requests()
self._check_on_demand_expiration() 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: if self.current_display_mode != active_mode:
logger.debug("Mode changed during high-FPS loop, breaking early") logger.debug("Mode changed during high-FPS loop, breaking early")
break break
+23 -196
View File
@@ -34,7 +34,6 @@ else:
from contextlib import contextmanager from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
from src.common.font_layout import load_truetype
import threading import threading
import time import time
from collections import OrderedDict from collections import OrderedDict
@@ -56,31 +55,6 @@ from src.common.permission_utils import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO) # Set to INFO level 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: class _LogicalMatrix:
"""Proxy that reports a logical (per-screen) size for a physical matrix. """Proxy that reports a logical (per-screen) size for a physical matrix.
@@ -266,14 +240,6 @@ class DisplayManager:
self._update_lock = threading.RLock() self._update_lock = threading.RLock()
# Scrolling state tracking for graceful updates # 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 = { self._scrolling_state = {
'is_scrolling': False, 'is_scrolling': False,
'last_scroll_activity': 0, 'last_scroll_activity': 0,
@@ -403,7 +369,7 @@ class DisplayManager:
# Initialize font with Press Start 2P # Initialize font with Press Start 2P
try: try:
self.font = load_truetype("assets/fonts/PressStart2P-Regular.ttf", 8) self.font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
logger.info("Initial Press Start 2P font loaded successfully") logger.info("Initial Press Start 2P font loaded successfully")
except Exception as e: except Exception as e:
logger.error(f"Failed to load initial font: {e}") logger.error(f"Failed to load initial font: {e}")
@@ -583,33 +549,19 @@ class DisplayManager:
pass pass
def _fitting_font(self, lines, width): 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, 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: for candidate in candidates:
try: try:
font = candidate font = candidate
if isinstance(candidate, tuple): if isinstance(candidate, tuple):
font = load_truetype(candidate[0], candidate[1]) font = ImageFont.truetype(candidate[0], candidate[1])
narrowest = font
if all(self.draw.textlength(t, font=font) <= width for t in lines): if all(self.draw.textlength(t, font=font) <= width for t in lines):
return font return font
except (OSError, ValueError, AttributeError): except (OSError, ValueError, AttributeError):
continue continue
# Nothing fit. Return the smallest face that loaded, not self.font -- return 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: def _draw_startup_banner(self, lines, width: int, height: int) -> None:
"""Centre `lines` over whatever the test pattern already drew. """Centre `lines` over whatever the test pattern already drew.
@@ -819,33 +771,16 @@ class DisplayManager:
return # Skip hardware write — content is being captured off-screen return # Skip hardware write — content is being captured off-screen
digest = None digest = None
frame_checksum = None
if self._dirty_tracking_enabled: if self._dirty_tracking_enabled:
try: try:
brightness = getattr(self.matrix, 'brightness', None) brightness = getattr(self.matrix, 'brightness', None)
except AttributeError: except AttributeError:
brightness = None brightness = None
frame_checksum = zlib.adler32(self.image.tobytes()) digest = (zlib.adler32(self.image.tobytes()), brightness)
digest = (frame_checksum, brightness) if digest == self._last_pushed_digest:
if digest == self._last_pushed_digest and not self.is_currently_scrolling():
# Nothing changed since the last push — the panel is # Nothing changed since the last push — the panel is
# already showing exactly this frame. # 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 return
# Copy the current image to the offscreen canvas. In double-sided # Copy the current image to the offscreen canvas. In double-sided
@@ -855,10 +790,8 @@ class DisplayManager:
else: else:
self.offscreen_canvas.SetImage(self.image) self.offscreen_canvas.SetImage(self.image)
# Swap buffers immediately. framerate_fraction holds the frame # Swap buffers immediately
# for N refreshes; SwapOnVSync blocks for all of them, which is self.matrix.SwapOnVSync(self.offscreen_canvas)
# what paces the render loop to the chosen frame rate.
self.matrix.SwapOnVSync(self.offscreen_canvas, self._frame_hold)
# Swap our canvas references # Swap our canvas references
self.offscreen_canvas, self.current_canvas = self.current_canvas, self.offscreen_canvas self.offscreen_canvas, self.current_canvas = self.current_canvas, self.offscreen_canvas
@@ -866,7 +799,7 @@ class DisplayManager:
self._last_pushed_digest = digest self._last_pushed_digest = digest
# Write a snapshot for the web preview (throttled) # Write a snapshot for the web preview (throttled)
self._write_snapshot_if_due(frame_checksum) self._write_snapshot_if_due()
except Exception as e: except Exception as e:
logger.error(f"Error updating display: {e}") logger.error(f"Error updating display: {e}")
@@ -973,11 +906,11 @@ class DisplayManager:
self._text_width_cache.clear() self._text_width_cache.clear()
try: try:
# Load Press Start 2P font # Load Press Start 2P font
self.regular_font = load_truetype("assets/fonts/PressStart2P-Regular.ttf", 8) self.regular_font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
logger.info("Press Start 2P font loaded successfully") logger.info("Press Start 2P font loaded successfully")
# Use the same font for small text (currently same size; adjust size here if needed) # Use the same font for small text (currently same size; adjust size here if needed)
self.small_font = load_truetype("assets/fonts/PressStart2P-Regular.ttf", 8) self.small_font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
logger.info("Press Start 2P small font loaded successfully") logger.info("Press Start 2P small font loaded successfully")
# Load 5x7 BDF font for calendar events # Load 5x7 BDF font for calendar events
@@ -990,17 +923,6 @@ class DisplayManager:
# Load with freetype for proper BDF handling # Load with freetype for proper BDF handling
face = freetype.Face(self.calendar_font_path) 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"5x7 calendar font loaded successfully from {self.calendar_font_path}")
logger.info(f"Calendar font size: {face.size.height >> 6} pixels") logger.info(f"Calendar font size: {face.size.height >> 6} pixels")
@@ -1021,7 +943,7 @@ class DisplayManager:
try: try:
font_path = "assets/fonts/4x6-font.ttf" font_path = "assets/fonts/4x6-font.ttf"
logger.info(f"Attempting to load 4x6 TTF font from: {font_path} at size 6") logger.info(f"Attempting to load 4x6 TTF font from: {font_path} at size 6")
self.extra_small_font = load_truetype(font_path, 6) self.extra_small_font = ImageFont.truetype(font_path, 6)
logger.info(f"4x6 TTF extra small font loaded successfully from {font_path}") logger.info(f"4x6 TTF extra small font loaded successfully from {font_path}")
except Exception as font_err: except Exception as font_err:
logger.error(f"Failed to load 4x6 TTF font: {font_err}. Falling back.") logger.error(f"Failed to load 4x6 TTF font: {font_err}. Falling back.")
@@ -1079,13 +1001,7 @@ class DisplayManager:
try: try:
if isinstance(font, freetype.Face): if isinstance(font, freetype.Face):
# For FreeType faces (BDF), the 'height' metric gives the recommended line spacing. # For FreeType faces (BDF), the 'height' metric gives the recommended line spacing.
height = font.size.height >> 6 return 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: else:
# For PIL TTF fonts, getmetrics() provides ascent and descent. # For PIL TTF fonts, getmetrics() provides ascent and descent.
# The line height is the sum of ascent and descent. # The line height is the sum of ascent and descent.
@@ -1364,65 +1280,12 @@ class DisplayManager:
return dt.strftime(f"%b %-d{suffix}") return dt.strftime(f"%b %-d{suffix}")
@property def set_scrolling_state(self, is_scrolling: bool):
def refresh_hz(self) -> float: """Set the current scrolling state. Call this when a display starts/stops scrolling."""
"""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() current_time = time.time()
self._scrolling_state['is_scrolling'] = is_scrolling self._scrolling_state['is_scrolling'] = is_scrolling
if is_scrolling: if is_scrolling:
self._scrolling_state['last_scroll_activity'] = current_time 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}") logger.debug(f"Scrolling state set to: {is_scrolling}")
def is_currently_scrolling(self) -> bool: def is_currently_scrolling(self) -> bool:
@@ -1436,14 +1299,6 @@ class DisplayManager:
# If we've been inactive for the threshold period, consider it not scrolling # 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']: if current_time - self._scrolling_state['last_scroll_activity'] > self._scrolling_state['scroll_inactivity_threshold']:
self._scrolling_state['is_scrolling'] = False 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 False
return True return True
@@ -1561,20 +1416,11 @@ class DisplayManager:
self._viewer_fresh = False self._viewer_fresh = False
return self._viewer_fresh return self._viewer_fresh
def _write_snapshot_if_due(self, frame_checksum: Optional[int] = None) -> None: def _write_snapshot_if_due(self) -> None:
"""Mirror the current frame to the preview snapshot when the policy """Mirror the current frame to the preview snapshot when the policy
says it's worth it — see src/common/snapshot_policy.py. Unchanged says it's worth it — see src/common/snapshot_policy.py. Unchanged
frames are never re-encoded; without viewers the cadence drops to 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: try:
now = time.time() now = time.time()
viewer_fresh = self._viewer_is_fresh(now) viewer_fresh = self._viewer_is_fresh(now)
@@ -1584,8 +1430,7 @@ class DisplayManager:
self._last_snapshot_ts = 0.0 self._last_snapshot_ts = 0.0
self._viewer_was_fresh = viewer_fresh self._viewer_was_fresh = viewer_fresh
digest = (frame_checksum if frame_checksum is not None digest = zlib.adler32(self.image.tobytes())
else zlib.adler32(self.image.tobytes()))
action = snapshot_policy.decide( action = snapshot_policy.decide(
now, self._last_snapshot_ts, self._last_snapshot_touch_ts, now, self._last_snapshot_ts, self._last_snapshot_touch_ts,
viewer_fresh, digest != self._last_snapshot_digest) viewer_fresh, digest != self._last_snapshot_digest)
@@ -1608,30 +1453,12 @@ class DisplayManager:
if parent_dir and str(parent_dir) != '/tmp': # nosec B108 - guard to skip /tmp for permission ops 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()) ensure_directory_permissions(parent_dir, get_assets_dir_mode())
self._snapshot_dir_prepared = True self._snapshot_dir_prepared = True
# Write atomically: temp then replace. The temp name must be # Write atomically: temp then replace
# unique, not "<snapshot>.tmp": /tmp is world-writable and sticky, tmp_path = f"{self._snapshot_path}.tmp"
# and this file is written by whichever user the display service self.image.save(tmp_path, format='PNG')
# 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: 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) os.replace(tmp_path, self._snapshot_path)
except Exception: 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 # Fallback to direct save if replace not supported
self.image.save(self._snapshot_path, format='PNG') self.image.save(self._snapshot_path, format='PNG')
# Set proper file permissions after saving # Set proper file permissions after saving
+2 -3
View File
@@ -51,7 +51,6 @@ from dataclasses import dataclass
from typing import Any, Dict, Optional, Tuple, Union from typing import Any, Dict, Optional, Tuple, Union
from PIL import ImageFont from PIL import ImageFont
from src.common.font_layout import load_truetype
try: try:
import freetype import freetype
@@ -156,7 +155,7 @@ def load_font(font_name: str, size: int) -> Any:
face.set_char_size(size * 64, size * 64, 72, 72) face.set_char_size(size * 64, size * 64, 72, 72)
font: Any = face font: Any = face
else: else:
font = load_truetype(path, size) font = ImageFont.truetype(path, size)
except Exception as e: except Exception as e:
logger.warning("Error loading font %s at %spx: %s, using fallback", logger.warning("Error loading font %s at %spx: %s, using fallback",
path, size, e) path, size, e)
@@ -175,7 +174,7 @@ def _load_fallback_font(size: int) -> Any:
if cached is not None: if cached is not None:
return cached return cached
try: try:
font = load_truetype(path, size) font = ImageFont.truetype(path, size)
_font_cache[cache_key] = font _font_cache[cache_key] = font
return font return font
except Exception as e: except Exception as e:
+3 -5
View File
@@ -38,7 +38,6 @@ import time
from collections import OrderedDict from collections import OrderedDict
from pathlib import Path from pathlib import Path
from PIL import ImageFont from PIL import ImageFont
from src.common.font_layout import load_truetype
from typing import Dict, Tuple, Optional, Union, Any, List from typing import Dict, Tuple, Optional, Union, Any, List
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -97,8 +96,7 @@ class FontManager:
self.common_fonts = { self.common_fonts = {
"press_start": "assets/fonts/PressStart2P-Regular.ttf", "press_start": "assets/fonts/PressStart2P-Regular.ttf",
"four_by_six": "assets/fonts/4x6-font.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 # Note: cozette_bdf removed - font file not available
# To re-enable: download cozette.bdf from https://github.com/the-moonwitch/Cozette # To re-enable: download cozette.bdf from https://github.com/the-moonwitch/Cozette
# and add: "cozette_bdf": "assets/fonts/cozette.bdf" # and add: "cozette_bdf": "assets/fonts/cozette.bdf"
@@ -480,7 +478,7 @@ class FontManager:
if font_path.endswith('.bdf'): if font_path.endswith('.bdf'):
font = self._load_bdf_font(font_path, size_px) font = self._load_bdf_font(font_path, size_px)
else: else:
font = load_truetype(font_path, size_px) font = ImageFont.truetype(font_path, size_px)
except Exception as e: except Exception as e:
logger.error(f"Error loading font {font_path}: {e}") logger.error(f"Error loading font {font_path}: {e}")
self.performance_stats["failed_loads"] += 1 self.performance_stats["failed_loads"] += 1
@@ -860,7 +858,7 @@ class FontManager:
return {"valid": True, "type": "bdf", "family": "unknown"} return {"valid": True, "type": "bdf", "family": "unknown"}
elif font_path.endswith('.ttf'): elif font_path.endswith('.ttf'):
# Try to load TTF font # Try to load TTF font
load_truetype(font_path, 12) ImageFont.truetype(font_path, 12)
return {"valid": True, "type": "ttf", "family": "unknown"} return {"valid": True, "type": "ttf", "family": "unknown"}
else: else:
return {"valid": False, "error": "Unsupported font format"} return {"valid": False, "error": "Unsupported font format"}
+1 -2
View File
@@ -14,7 +14,6 @@ import json
from typing import Dict, List, Optional, Tuple from typing import Dict, List, Optional, Tuple
from pathlib import Path from pathlib import Path
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
from src.common.font_layout import load_truetype
from PIL.PngImagePlugin import PngInfo from PIL.PngImagePlugin import PngInfo
from requests.adapters import HTTPAdapter from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry from urllib3.util.retry import Retry
@@ -748,7 +747,7 @@ class LogoDownloader:
# Try to load a font, fallback to default # Try to load a font, fallback to default
try: try:
font = load_truetype("assets/fonts/PressStart2P-Regular.ttf", 12) font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 12)
except (OSError, IOError): except (OSError, IOError):
try: try:
font = ImageFont.load_default() font = ImageFont.load_default()
-69
View File
@@ -1589,7 +1589,6 @@ class PluginStoreManager:
with open(manifest_path, 'r') as f: with open(manifest_path, 'r') as f:
manifest = json.load(f) manifest = json.load(f)
requested_id = plugin_id
plugin_id = plugin_id or manifest.get('id') plugin_id = plugin_id or manifest.get('id')
if not plugin_id: if not plugin_id:
return { return {
@@ -1665,15 +1664,6 @@ class PluginStoreManager:
branch_info = f" (branch: {branch_used})" if branch_used else "" branch_info = f" (branch: {branch_used})" if branch_used else ""
self.logger.info(f"Successfully installed plugin from URL: {plugin_id}{branch_info}") 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 = { result = {
'success': True, 'success': True,
'plugin_id': plugin_id, 'plugin_id': plugin_id,
@@ -2434,65 +2424,6 @@ class PluginStoreManager:
except (OSError, ValueError): except (OSError, ValueError):
pass 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 return None
_SKIN_ID_PATTERN = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$') _SKIN_ID_PATTERN = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$')
+9 -137
View File
@@ -14,8 +14,6 @@ via BoundsCheckingDisplayManager, and golden-image comparison.
import contextlib import contextlib
import http.client import http.client
import inspect import inspect
import time
from datetime import timedelta
import socket import socket
import ssl import ssl
import urllib.error import urllib.error
@@ -27,7 +25,7 @@ from PIL import Image, ImageChops
from src.logging_config import get_logger from src.logging_config import get_logger
from .bounds_display_manager import BoundsCheckingDisplayManager from .bounds_display_manager import BoundsCheckingDisplayManager
from .loading import load_config_defaults, load_manifest, merge_config from .loading import load_config_defaults, load_manifest
from .sizes import DEFAULT_TEST_SIZES, safe_mode_filename, size_label from .sizes import DEFAULT_TEST_SIZES, safe_mode_filename, size_label
logger = get_logger("[Plugin Harness]") logger = get_logger("[Plugin Harness]")
@@ -118,20 +116,11 @@ 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, def _instantiate(plugin_id: str, manifest: Dict[str, Any], plugin_dir: Path,
config: Dict[str, Any], mock_data: Dict[str, Any], config: Dict[str, Any], mock_data: Dict[str, Any],
display_manager: Any, cache_manager: Any = None) -> Any: display_manager: Any) -> Any:
"""Load and construct a plugin instance with mocked managers. """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.plugin_loader import PluginLoader
from src.plugin_system.testing import MockCacheManager, MockPluginManager from src.plugin_system.testing import MockCacheManager, MockPluginManager
if cache_manager is None:
cache_manager = MockCacheManager() cache_manager = MockCacheManager()
for key, value in (mock_data or {}).items(): for key, value in (mock_data or {}).items():
cache_manager.set(key, value) cache_manager.set(key, value)
@@ -171,107 +160,6 @@ def _render_mode(plugin_instance: Any, mode: str) -> Any:
return plugin_instance.display(force_clear=False) 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]): def _freeze(freeze_time: Optional[str]):
"""Context manager that freezes wall-clock time when freeze_time is given, """Context manager that freezes wall-clock time when freeze_time is given,
so time-dependent plugins (clocks, countdowns) render deterministic goldens.""" so time-dependent plugins (clocks, countdowns) render deterministic goldens."""
@@ -305,9 +193,7 @@ def render_plugin_matrix(
manifest = load_manifest(plugin_dir) manifest = load_manifest(plugin_dir)
# Start from config_schema.json defaults so the plugin behaves like a real # Start from config_schema.json defaults so the plugin behaves like a real
# install; explicit caller config still wins over a schema default. # install; explicit caller config still wins over a schema default.
config = merge_config( config = {"enabled": True, **load_config_defaults(plugin_dir), **(config or {})}
merge_config({"enabled": True}, load_config_defaults(plugin_dir)),
config or {})
sizes = sizes or DEFAULT_TEST_SIZES sizes = sizes or DEFAULT_TEST_SIZES
results: List[RenderResult] = [] results: List[RenderResult] = []
@@ -316,35 +202,25 @@ def render_plugin_matrix(
# rendering a smaller one, instead of being clipped into a false pass. # 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)) extent = (max(w for w, _ in sizes), max(h for _, h in sizes))
# One cache for the whole matrix: see _instantiate. The display manager with _freeze(freeze_time):
# 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: for width, height in sizes:
results.extend(_render_size( results.extend(_render_size(
plugin_id, manifest, plugin_dir, config, mock_data or {}, plugin_id, manifest, plugin_dir, config, mock_data or {},
width, height, run_update, extent, cache_manager, freezer, width, height, run_update, extent,
)) ))
return results return results
def _render_size(plugin_id, manifest, plugin_dir, config, mock_data, def _render_size(plugin_id, manifest, plugin_dir, config, mock_data,
width, height, run_update, extent, width, height, run_update, extent) -> List[RenderResult]:
cache_manager=None, freezer=None) -> List[RenderResult]:
"""Render every mode at one size. A fresh instance per mode avoids state leaks.""" """Render every mode at one size. A fresh instance per mode avoids state leaks."""
results: List[RenderResult] = [] results: List[RenderResult] = []
# Discover modes once per size (instance build can depend on config). # Discover modes once per size (instance build can depend on config).
try: try:
probe_dm = BoundsCheckingDisplayManager(width=width, height=height, overflow_extent=extent) 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) modes = list_modes(probe, manifest, plugin_id)
except Exception as e: # noqa: BLE001 — surface any load failure as a result except Exception as e: # noqa: BLE001 — surface any load failure as a result
return [RenderResult(plugin_id, width, height, "<load>", error=repr(e))] return [RenderResult(plugin_id, width, height, "<load>", error=repr(e))]
@@ -353,8 +229,7 @@ def _render_size(plugin_id, manifest, plugin_dir, config, mock_data,
result = RenderResult(plugin_id, width, height, mode) result = RenderResult(plugin_id, width, height, mode)
dm = BoundsCheckingDisplayManager(width=width, height=height, overflow_extent=extent) dm = BoundsCheckingDisplayManager(width=width, height=height, overflow_extent=extent)
try: 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: if run_update:
try: try:
inst.update() inst.update()
@@ -373,9 +248,6 @@ def _render_size(plugin_id, manifest, plugin_dir, config, mock_data,
result.display_returned = _render_mode(inst, mode) result.display_returned = _render_mode(inst, mode)
result.image = dm.get_image() result.image = dm.get_image()
result.overflow = dm.check_overflow() 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 except Exception as e: # noqa: BLE001 — a display crash is a real failure
result.error = repr(e) result.error = repr(e)
results.append(result) results.append(result)
+5 -40
View File
@@ -33,45 +33,6 @@ def load_manifest(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
return json.load(f) 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]: 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).""" """Extract default values from a plugin's config_schema.json (empty if none)."""
schema_path = Path(plugin_dir) / 'config_schema.json' schema_path = Path(plugin_dir) / 'config_schema.json'
@@ -79,7 +40,11 @@ def load_config_defaults(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
return {} return {}
with open(schema_path, 'r') as f: with open(schema_path, 'r') as f:
schema = json.load(f) schema = json.load(f)
return _defaults_from_properties(schema.get('properties', {})) 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
def load_harness_spec(plugin_dir: Union[str, Path]) -> Dict[str, Any]: def load_harness_spec(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
@@ -31,7 +31,6 @@ from pathlib import Path
from typing import Any, List, Optional, Tuple from typing import Any, List, Optional, Tuple
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
from src.common.font_layout import load_truetype
from src.logging_config import get_logger from src.logging_config import get_logger
@@ -142,8 +141,8 @@ class VisualTestDisplayManager:
# Press Start 2P — regular and small (both 8px) # Press Start 2P — regular and small (both 8px)
ttf_path = str(fonts_dir / 'PressStart2P-Regular.ttf') ttf_path = str(fonts_dir / 'PressStart2P-Regular.ttf')
self.regular_font = load_truetype(ttf_path, 8) self.regular_font = ImageFont.truetype(ttf_path, 8)
self.small_font = load_truetype(ttf_path, 8) self.small_font = ImageFont.truetype(ttf_path, 8)
self.font = self.regular_font # alias used by some code paths self.font = self.regular_font # alias used by some code paths
# 5x7 BDF font via freetype # 5x7 BDF font via freetype
@@ -163,7 +162,7 @@ class VisualTestDisplayManager:
# 4x6 extra small TTF # 4x6 extra small TTF
try: try:
xs_path = str(fonts_dir / '4x6-font.ttf') xs_path = str(fonts_dir / '4x6-font.ttf')
self.extra_small_font = load_truetype(xs_path, 6) self.extra_small_font = ImageFont.truetype(xs_path, 6)
except (FileNotFoundError, OSError) as e: except (FileNotFoundError, OSError) as e:
logger.debug("Extra small font not available, using fallback: %s", e) logger.debug("Extra small font not available, using fallback: %s", e)
self.extra_small_font = self.small_font self.extra_small_font = self.small_font
@@ -507,18 +506,9 @@ class VisualTestDisplayManager:
# Scrolling state (no-op interface compat) # Scrolling state (no-op interface compat)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def set_scrolling_state(self, is_scrolling: bool, frame_hold: int = 1): def set_scrolling_state(self, is_scrolling: bool):
"""Set the current scrolling state (no-op for testing). """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['is_scrolling'] = is_scrolling
self._scrolling_state['frame_hold'] = frame_hold
if is_scrolling: if is_scrolling:
self._scrolling_state['last_scroll_activity'] = time.time() self._scrolling_state['last_scroll_activity'] = time.time()
-23
View File
@@ -19,35 +19,12 @@ This directory contains systemd service unit files for LEDMatrix services.
- Automatically enables/disables access point mode - Automatically enables/disables access point mode
- Uses `scripts/utils/wifi_monitor_daemon.py` - 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 ## Installation
These service files are installed by the installation scripts in `scripts/install/`: These service files are installed by the installation scripts in `scripts/install/`:
- `install_service.sh` installs `ledmatrix.service` - `install_service.sh` installs `ledmatrix.service`
- `install_web_service.sh` installs `ledmatrix-web.service` - `install_web_service.sh` installs `ledmatrix-web.service`
- `install_wifi_monitor.sh` installs `ledmatrix-wifi-monitor.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 ## Manual Installation
-16
View File
@@ -1,16 +0,0 @@
[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
@@ -1,18 +0,0 @@
[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
@@ -1,998 +0,0 @@
[
[
"/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
@@ -1,66 +0,0 @@
# 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
@@ -1,279 +0,0 @@
// 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
@@ -1,132 +0,0 @@
// 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
@@ -1,221 +0,0 @@
// 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
@@ -1,116 +0,0 @@
// 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
@@ -1,12 +0,0 @@
{
"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
@@ -1,58 +0,0 @@
#!/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
@@ -1,288 +0,0 @@
// 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
@@ -1,109 +0,0 @@
// 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
@@ -1,157 +0,0 @@
"""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)
-60
View File
@@ -1,60 +0,0 @@
"""Regression test: POST /fonts/upload must enforce its own stated size limit.
validate_file_upload(filename, max_size_mb=10, allowed_extensions=[...]) reads
like it checks the upload's size, but it only ever validated the filename
(traversal characters, extension) -- max_size_mb was accepted and silently
ignored. Nothing else in the handler checked the actual upload size either,
so it saved whatever was posted to assets/fonts/<family><ext> regardless of
size, unlike the sibling .star and plugin-asset upload routes, which check
`file.tell()` against a stated limit before saving.
"""
import io
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
URL = "/api/v3/fonts/upload"
TEN_MB = 10 * 1024 * 1024
@pytest.fixture
def fonts_root(tmp_path):
# PROJECT_ROOT is bound by value in fonts.py, so it is patched there.
with patch("web_interface.blueprints.api_v3.fonts.PROJECT_ROOT", tmp_path):
yield tmp_path
def upload(client, content, filename="myfont.ttf", family="myfont"):
data = {
"font_file": (io.BytesIO(content), filename),
"font_family": family,
}
return client.post(URL, data=data, content_type="multipart/form-data")
class TestFontUploadSizeLimit:
def test_oversized_font_is_rejected(self, api_v3_client, fonts_root):
response = upload(api_v3_client, b"x" * (TEN_MB + 1))
assert response.status_code == 400
assert "too large" in response.get_json()["message"].lower()
assert not (fonts_root / "assets" / "fonts" / "myfont.ttf").exists(), (
"an oversized font was saved to disk before being rejected")
def test_a_font_right_at_the_limit_is_accepted(self, api_v3_client, fonts_root):
response = upload(api_v3_client, b"x" * TEN_MB,
filename="atlimit.ttf", family="atlimit")
assert response.status_code == 200, response.get_json()
assert (fonts_root / "assets" / "fonts" / "atlimit.ttf").exists()
def test_an_ordinary_small_font_is_still_accepted(self, api_v3_client, fonts_root):
response = upload(api_v3_client, b"fake font bytes",
filename="small.ttf", family="small")
assert response.status_code == 200, response.get_json()
assert (fonts_root / "assets" / "fonts" / "small.ttf").read_bytes() == b"fake font bytes"
-93
View File
@@ -1,93 +0,0 @@
"""Regression test: POST /display/on-demand/start restarting a running
service must not import a name that does not exist.
display.py has `import web_interface.blueprints.api_v3 as _pkg` and reads
mutable, test-patched attributes back through it (`_pkg.time.time()`,
`_pkg._get_starlark_plugin()`, ...) rather than binding them by value, per
the package's own docstring. One spot went further and wrote a genuine
`import` *statement* against that alias --
import _pkg.time as time_module
-- but `_pkg` is a local name bound by `import ... as _pkg` in this module,
not a real top-level package, so `import _pkg.time` is not something Python
can resolve; it raises ModuleNotFoundError. That line only runs when the
display service is already running and the caller also asked to (re)start
it, so this endpoint failed on exactly the restart path -- the one where a
cache write recording the new on-demand request had already happened.
The route wraps its body in `except Exception`, so the failure reached the
caller as a handled 500 with a generic message, not an unhandled crash --
but a 500 all the same on a request that should have restarted the service
and reported success.
"""
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
URL = "/api/v3/display/on-demand/start"
@pytest.fixture
def restart_path(api_v3_module):
"""Force the `service_was_running and start_service` branch.
plugin_manager and config_manager are set to None so the route takes
the simplest path to that branch rather than tripping over unrelated
MagicMock plumbing; _ensure_cache_manager, _get_display_service_status,
_stop_display_service and _ensure_display_service_running are bound by
value in display.py (see its own docstring), so they are patched on
that submodule rather than on the package.
"""
api_v3_module.api_v3.plugin_manager = None
api_v3_module.api_v3.config_manager = None
with patch("web_interface.blueprints.api_v3.display._ensure_cache_manager") as ensure_cache, \
patch("web_interface.blueprints.api_v3.display._get_display_service_status") as get_status, \
patch("web_interface.blueprints.api_v3.display._stop_display_service") as stop_service, \
patch("web_interface.blueprints.api_v3.display._ensure_display_service_running") as ensure_running:
ensure_cache.return_value = MagicMock()
# Active before the request: service_was_running becomes True.
get_status.return_value = {"active": True}
ensure_running.return_value = {"active": True}
yield {
"ensure_cache": ensure_cache,
"get_status": get_status,
"stop_service": stop_service,
"ensure_running": ensure_running,
}
class TestRestartingARunningService:
def test_it_does_not_500(self, api_v3_client, restart_path):
response = api_v3_client.post(
URL, json={"plugin_id": "weather", "start_service": True})
body = response.get_json()
assert response.status_code == 200, body
assert body["status"] == "success", body
def test_the_service_is_actually_stopped_and_restarted(
self, api_v3_client, restart_path):
api_v3_client.post(
URL, json={"plugin_id": "weather", "start_service": True})
restart_path["stop_service"].assert_called_once()
restart_path["ensure_running"].assert_called_once()
def test_a_service_that_was_not_running_is_not_stopped_first(
self, api_v3_client, restart_path):
# The buggy import sits inside `if service_was_running and
# start_service`, so it only ever fired on the restart path --
# this is the other side of that branch, unaffected either way,
# kept here so the branch condition itself stays covered.
restart_path["get_status"].return_value = {"active": False}
response = api_v3_client.post(
URL, json={"plugin_id": "weather", "start_service": True})
assert response.status_code == 200, response.get_json()
restart_path["stop_service"].assert_not_called()
+3 -8
View File
@@ -106,12 +106,7 @@ class TestMissingBodyGivesTheDeclaredError:
class TestNoBodyReadContradictsItsOwnGuard: class TestNoBodyReadContradictsItsOwnGuard:
PKG = Path(__file__).parent.parent / "web_interface/blueprints/api_v3" SOURCE = Path(__file__).parent.parent / "web_interface/blueprints/api_v3.py"
@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): def test_no_or_default_read_is_unguarded(self):
"""`get_json() or <default>` is a contradiction without silent=True. """`get_json() or <default>` is a contradiction without silent=True.
@@ -120,7 +115,7 @@ class TestNoBodyReadContradictsItsOwnGuard:
means the call raises before the default can apply. means the call raises before the default can apply.
""" """
offenders = [ offenders = [
line.strip() for line in self._source.splitlines() line.strip() for line in self.SOURCE.read_text().splitlines()
if "request.get_json()" in line and " or " in line if "request.get_json()" in line and " or " in line
] ]
assert offenders == [], ( assert offenders == [], (
@@ -129,7 +124,7 @@ class TestNoBodyReadContradictsItsOwnGuard:
def test_no_not_data_guard_is_unreachable(self): def test_no_not_data_guard_is_unreachable(self):
"""A `if not data:` guard needs a read that can actually return None.""" """A `if not data:` guard needs a read that can actually return None."""
lines = self._source.splitlines() lines = self.SOURCE.read_text().splitlines()
offenders = [] offenders = []
for i, line in enumerate(lines): for i, line in enumerate(lines):
if re.search(r"=\s*request\.get_json\(\)\s*$", line): if re.search(r"=\s*request\.get_json\(\)\s*$", line):
@@ -1,54 +0,0 @@
"""Regression test: per-day schedule validation errors must say "time", not
"_pkg.time".
The split into a package rewrote every bare `time` reference that needed to
read through the shared module as `_pkg.time` (see the package's own
docstring on why -- tests patch it, so it has to be read back live rather
than bound by value). That rewrite was mechanical and matched the substring
"time" inside string literals and comments too, so the user-facing message
"Invalid start time for {day}: ..."
came out as
"Invalid start _pkg.time for {day}: ..."
in both POST /config/schedule and POST /config/dim-schedule, for both the
start and end time of a per-day entry.
"""
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
@pytest.mark.parametrize("url", [
"/api/v3/config/schedule",
"/api/v3/config/dim-schedule",
])
class TestPerDayTimeErrorsAreNotCorrupted:
def test_invalid_start_time_message(self, api_v3_client, api_v3_module, url):
response = api_v3_client.post(url, json={
"mode": "per_day",
"monday_start": "not-a-time",
})
assert response.status_code == 400
message = response.get_json()["message"]
assert "_pkg" not in message, message
assert message.startswith("Invalid start time for monday:"), message
def test_invalid_end_time_message(self, api_v3_client, api_v3_module, url):
response = api_v3_client.post(url, json={
"mode": "per_day",
"monday_start": "07:00",
"monday_end": "not-a-time",
})
assert response.status_code == 400
message = response.get_json()["message"]
assert "_pkg" not in message, message
assert message.startswith("Invalid end time for monday:"), message
-136
View File
@@ -1,136 +0,0 @@
"""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]
-55
View File
@@ -229,61 +229,6 @@ class TestRadio:
assert api_v3_client.post(self.URL, json={"enabled": True}).status_code == 500 assert api_v3_client.post(self.URL, json={"enabled": True}).status_code == 500
class TestAutoEnableApMode:
URL = "/api/v3/wifi/ap/auto-enable"
def test_requires_the_field(self, api_v3_client, wifi_manager):
response = api_v3_client.post(self.URL, json={})
assert response.status_code == 400
@pytest.mark.parametrize("raw,expected", [
(True, True), (False, False),
("true", True), ("True", True), ("1", True), ("yes", True),
("false", False), ("False", False), ("0", False), ("no", False),
(1, True), (0, False),
])
def test_value_is_coerced_not_just_truthy(
self, api_v3_client, wifi_manager, raw, expected):
# Regression: bool(data['auto_enable_ap_mode']) meant a caller who
# sent the JSON string "false" got it stored as True — bool("false")
# is True, since any non-empty string is truthy.
wifi_manager.config = {}
response = api_v3_client.post(self.URL, json={"auto_enable_ap_mode": raw})
assert response.status_code == 200, response.get_json()
assert response.get_json()["data"]["auto_enable_ap_mode"] is expected
assert wifi_manager.config["auto_enable_ap_mode"] is expected
def test_a_string_false_does_not_enable_it(self, api_v3_client, wifi_manager):
# The exact shape of the bug.
wifi_manager.config = {}
api_v3_client.post(self.URL, json={"auto_enable_ap_mode": "false"})
assert wifi_manager.config["auto_enable_ap_mode"] is False
class TestRadioEnabledAndForceAcceptIntegers:
"""`{"enabled": 1}` / `{"enabled": 0}` used to be mishandled: the old
coercion was `raw is True or (isinstance(raw, str) and ...)`, and
`1 is True` is False in Python -- an int is never the `True` singleton
even though it equals it -- so a plain integer fell through to False
regardless of its value.
"""
URL = "/api/v3/wifi/radio"
@pytest.mark.parametrize("raw,expected", [(1, True), (0, False)])
def test_enabled_as_an_integer(self, api_v3_client, wifi_manager, raw, expected):
wifi_manager.set_wifi_radio.return_value = (True, "ok", None)
wifi_manager.get_wifi_radio_state.return_value = {}
api_v3_client.post(self.URL, json={"enabled": raw})
wifi_manager.set_wifi_radio.assert_called_once_with(expected, force=False)
@pytest.mark.parametrize("raw,expected", [(1, True), (0, False)])
def test_force_as_an_integer(self, api_v3_client, wifi_manager, raw, expected):
wifi_manager.set_wifi_radio.return_value = (True, "ok", None)
wifi_manager.get_wifi_radio_state.return_value = {}
api_v3_client.post(self.URL, json={"enabled": True, "force": raw})
wifi_manager.set_wifi_radio.assert_called_once_with(True, force=expected)
class TestNoRealNetworking: class TestNoRealNetworking:
def test_wifi_manager_is_never_constructed_for_real(self, api_v3_client): def test_wifi_manager_is_never_constructed_for_real(self, api_v3_client):
# Guard against a future refactor moving the import to module level, # Guard against a future refactor moving the import to module level,
+52
View File
@@ -0,0 +1,52 @@
"""web_interface/app.py resolves plugins_dir from config at import time.
`project_root` used to be assigned only inside the `else` branch (the
relative-path case). SchemaManager and composer_bp are wired up with
`project_root` further down the same module, so a device configured with an
*absolute* `plugin_system.plugins_directory` (a supported value -- see
CLAUDE.md) hit `UnboundLocalError` importing web_interface.app at all, taking
the whole web UI down.
app.py isn't imported directly by the test suite -- it has real side effects
(managers, background state) at import time -- so this extracts the exact
resolution block by its stable comment markers and executes it in isolation,
the same way the rest of the suite validates generated/templated code rather
than re-implementing it.
"""
from pathlib import Path
APP_PY = Path(__file__).resolve().parent.parent / "web_interface/app.py"
START_MARKER = "# Resolve plugin directory - handle both absolute and relative paths\n"
END_MARKER = "\nplugin_manager = PluginManager("
def _resolution_block() -> str:
src = APP_PY.read_text()
start = src.index(START_MARKER)
end = src.index(END_MARKER, start)
return src[start:end]
def test_resolution_block_still_matches_expected_markers():
"""If app.py is restructured enough that these markers move, the exec
below would silently test nothing -- fail loudly instead."""
block = _resolution_block()
assert "plugins_dir" in block and "os.path.isabs" in block
def test_absolute_plugins_directory_does_not_raise_unboundlocalerror():
import os
ns = {"os": os, "Path": Path, "__file__": str(APP_PY),
"plugins_dir_name": "/opt/ledmatrix-plugins"}
exec(compile(_resolution_block(), str(APP_PY), "exec"), ns) # noqa: S102
assert ns["project_root"] == APP_PY.parent.parent
assert ns["plugins_dir"] == Path("/opt/ledmatrix-plugins")
def test_relative_plugins_directory_still_resolves_under_project_root():
import os
ns = {"os": os, "Path": Path, "__file__": str(APP_PY),
"plugins_dir_name": "plugin-repos"}
exec(compile(_resolution_block(), str(APP_PY), "exec"), ns) # noqa: S102
assert ns["plugins_dir"] == ns["project_root"] / "plugin-repos"
-158
View File
@@ -1,158 +0,0 @@
"""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
@@ -0,0 +1,64 @@
"""composer_bp must actually be registered on the Flask app.
web_interface/blueprints/composer.py defines a full blueprint -- the
/composer/ page, /composer/api/generate, /composer/api/install, etc. -- but
nothing in web_interface/app.py imported or registered it. pages_v3 and
api_v3 both go through the same "import, wire managers, register_blueprint"
sequence; composer_bp had none of the three. The routes existed and worked
in isolation (every other composer test builds its own minimal Flask app
and registers composer_bp directly), but in the real running app every
composer URL -- including the page itself -- was a 404, and the frontend's
hardcoded '/composer/api/...' fetches (composer-app.js) had nothing to
reach.
app.py has import-time side effects (it loads config, builds managers, and
in some configurations starts background threads), so this scans the source
text rather than importing the module -- the same approach already used for
test_composer_js_contracts.py's structural checks.
"""
import re
from pathlib import Path
APP_PY = Path(__file__).resolve().parent.parent / "web_interface" / "app.py"
def _source() -> str:
return APP_PY.read_text()
def test_composer_blueprint_is_imported():
src = _source()
assert re.search(
r"from web_interface\.blueprints\.composer import composer_bp", src
), "app.py never imports composer_bp"
def test_composer_blueprint_is_registered_under_composer_prefix():
src = _source()
match = re.search(
r"app\.register_blueprint\(\s*composer_bp\s*,\s*url_prefix\s*=\s*"
r"['\"](?P<prefix>[^'\"]*)['\"]",
src,
)
assert match, "app.py never calls app.register_blueprint(composer_bp, ...)"
# composer.html and composer-app.js hardcode '/composer/api/...' and
# '/composer/api/fonts/...' -- any other prefix serves a page whose own
# fetches and @font-face all 404.
assert match.group("prefix") == "/composer"
def test_composer_blueprint_managers_are_wired_before_registration():
"""Registered with no managers set means every route 503s or crashes.
composer.py initialises config_manager/plugin_manager/plugins_dir/
project_root to None at import time specifically so app.py can set them
-- the same pattern api_v3 and pages_v3 use.
"""
src = _source()
register_match = re.search(r"app\.register_blueprint\(\s*composer_bp", src)
assert register_match, "app.py never registers composer_bp"
before_registration = src[: register_match.start()]
for attr in ("config_manager", "plugin_manager", "plugins_dir", "project_root"):
assert re.search(rf"composer_bp\.{attr}\s*=", before_registration), (
f"composer_bp.{attr} is never set before registration"
)
+323
View File
@@ -0,0 +1,323 @@
"""The composer generates Python that the plugin loader imports and executes.
/api/install writes the generated manager.py into plugins_dir and the loader
imports it, so anything the payload can splice into that source runs on the
device. The ast.parse check in _generate_plugin_files rejects only *invalid*
syntax -- an injected `import os` is perfectly valid and passed it.
Two ways in, both confirmed against the code before it was fixed:
metadata.name = a name containing a triple-quote, a newline, then
`import os; PWNED = os.getuid()`, then another triple-quote
-> closes the module docstring; the rest became module-level statements
(spelled out rather than shown literally -- writing the payload into
this docstring closes *this* file's docstring, which is the bug)
element x = '0 or __import__("os").system("id")'
-> f-string interpolated it verbatim: x=0 or __import__("os").system("id")
"""
import ast
import re
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from web_interface.blueprints import composer as C # noqa: E402
BASE_META = {"id": "test-plugin", "name": "Clock", "author": "a",
"version": "1.0.0", "description": "d"}
#: Values that terminate a Python expression and start a new statement.
EXPR_PAYLOADS = [
'0 or __import__("os").system("id")',
'0);import os;os.system("id");(',
'__import__("subprocess").run(["id"])',
"0 if False else exec('x=1')",
"1e999", "nan", "0x41", "0__0",
]
#: Values that close a string literal in the generated source.
LITERAL_PAYLOADS = [
'Clock"""\nimport os; PWNED = os.getuid()\n"""',
"Clock'''\nimport os\n'''",
'Clock" + __import__("os").system("id") + "',
"Clock\\", "Clock\nimport os",
]
def _payload(**over):
# dataModel.configVars is the key _generate_plugin_files reads; "config_vars"
# was never looked at, so anything passed through it tested nothing.
p = {"metadata": dict(BASE_META), "elements": [],
"dataModel": {"configVars": over.pop("config_vars", [])}}
p["metadata"].update(over.pop("metadata", {}))
p.update(over)
return p
def _generated(payload):
return C._generate_plugin_files(payload)["manager.py"]
def _module_level_code(src):
"""Statements at module level that are not the docstring/imports/classes."""
tree = ast.parse(src)
out = []
for node in tree.body:
if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.ImportFrom, ast.Import)):
continue
if isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant):
continue # the docstring
out.append(ast.unparse(node))
return out
@pytest.mark.parametrize("payload", LITERAL_PAYLOADS)
def test_a_name_that_breaks_out_of_a_literal_is_refused(payload):
with pytest.raises(C.ComposerInputError):
_generated(_payload(metadata={"name": payload}))
#: Types with a drawing branch in manager.py.j2. An injection test using any
#: other type proves nothing: _preprocess_elements drops it, so its values
#: never reach the generated source and every assertion passes trivially.
#: This test previously used "line", which has never had a branch.
RENDERED_GEOMETRY_CASES = [
("rectangle", {"x": 0, "y": 0, "width": 10, "height": 8}),
("arc", {"x": 0, "y": 0, "width": 24, "height": 24}),
("ellipse", {"x": 0, "y": 0, "width": 24, "height": 12}),
("rounded_rectangle", {"x": 0, "y": 0, "width": 24, "height": 10}),
("gauge", {"x": 0, "y": 0, "width": 32, "height": 32}),
]
@pytest.mark.parametrize("etype,base", RENDERED_GEOMETRY_CASES)
@pytest.mark.parametrize("evil", EXPR_PAYLOADS)
@pytest.mark.parametrize("field", ["x", "y", "width", "height"])
def test_a_non_numeric_geometry_value_cannot_reach_the_source(etype, base, evil, field):
"""width/height were interpolated raw into the generated source.
p['x2_expr'] = f"({x_expr}) + {w}" with w straight off the payload, so a
rectangle with width='0 or __import__("os").system("id")' produced
[0, 0, (0) + 0 or __import__("os").system("id"), (0) + 8],
in a manager.py that /api/install writes to disk and the loader imports.
"""
el = {"type": etype, "id": "e1", **base}
el[field] = evil
src = _generated(_payload(elements=[el]))
assert "__import__" not in src, f"{etype}.{field}={evil!r} reached the generated source"
assert "os.system" not in src
assert not _module_level_code(src), \
f"{etype}.{field}={evil!r} produced module-level statements: {_module_level_code(src)}"
def test_every_injection_case_uses_a_type_that_actually_renders():
"""Guards against the whole suite quietly going vacuous again.
An element type with no template branch is dropped before generation, so
an injection test written against one asserts nothing and still passes.
"""
used = {etype for etype, _ in RENDERED_GEOMETRY_CASES}
missing = used - set(C._RENDERABLE_ELEMENT_TYPES)
assert not missing, f"injection tests use non-rendering types: {sorted(missing)}"
@pytest.mark.parametrize("evil", EXPR_PAYLOADS)
@pytest.mark.parametrize("channel", ["r", "g", "b"])
def test_a_non_numeric_colour_channel_cannot_reach_the_source(evil, channel):
el = {"type": "text", "id": "t1", "x": 0, "y": 0, "text": "hi",
"font": "press_start", "r": 255, "g": 255, "b": 255}
el[channel] = evil
src = _generated(_payload(elements=[el]))
assert "__import__" not in src and "os.system" not in src
assert not _module_level_code(src)
def test_colour_channels_are_clamped_to_a_byte():
el = {"type": "text", "id": "t1", "x": 0, "y": 0, "text": "hi",
"font": "press_start", "r": 99999, "g": -5, "b": 128}
src = _generated(_payload(elements=[el]))
assert "(255, 0, 128)" in src, "channels were not clamped to 0-255"
#: fillR/fillG/fillB (and outR/G/B, bgR/G/B, trackR/G/B) go through
#: _as_fill_filter -> _as_rgb_filter, a separate path from _rgb_expr above.
#: It used raw int() until it was found to raise ValueError on a non-numeric
#: channel instead of clamping like every other coerced value in this module.
@pytest.mark.parametrize("evil", EXPR_PAYLOADS)
@pytest.mark.parametrize("channel", ["fillR", "fillG", "fillB"])
def test_a_non_numeric_fill_channel_cannot_reach_the_source(evil, channel):
el = {"type": "rectangle", "id": "r1", "x": 0, "y": 0, "width": 10, "height": 8,
"fillR": 0, "fillG": 0, "fillB": 128}
el[channel] = evil
src = _generated(_payload(elements=[el]))
assert "__import__" not in src and "os.system" not in src
assert not _module_level_code(src)
def test_fill_channels_are_clamped_to_a_byte():
el = {"type": "rectangle", "id": "r1", "x": 0, "y": 0, "width": 10, "height": 8,
"fillR": 99999, "fillG": -5, "fillB": 128}
src = _generated(_payload(elements=[el]))
assert "(255, 0, 128)" in src, "fill channels were not clamped to 0-255"
def test_the_generated_module_still_has_no_top_level_statements():
"""The clean case: a normal payload produces only imports and a class."""
el = {"type": "text", "id": "t1", "x": 4, "y": 4, "text": "hi",
"font": "press_start", "r": 1, "g": 2, "b": 3}
src = _generated(_payload(elements=[el]))
assert not _module_level_code(src)
assert "(1, 2, 3)" in src
# --- config variable keys ---------------------------------------------------
def _with_key(key):
return {"metadata": dict(BASE_META), "elements": [],
"dataModel": {"configVars": [{"key": key, "type": "string",
"default": "x", "label": "L"}]}}
@pytest.mark.parametrize("key", ["class", "def", "import", "None", "True",
"lambda", "pass", "match", "case"])
def test_a_keyword_config_key_is_named_in_the_error(key):
"""ast.parse already rejected these, but as an unhelpful line number.
"Generated code has a syntax error: invalid syntax (line 17)" tells the
user nothing about which field to fix.
"""
with pytest.raises(C.ComposerInputError) as exc:
_generated(_with_key(key))
assert key in str(exc.value) and "keyword" in str(exc.value).lower()
@pytest.mark.parametrize("key", ["config", "logger", "display_manager",
"cache_manager", "plugin_id", "enabled",
"self", "update", "display"])
def test_a_reserved_attribute_config_key_is_refused(key):
"""These generate *valid* Python that silently clobbers plugin state.
The worst is `config`: the assignment lands right after super().__init__(),
so `self.config = config.get("config", "x")` replaces the plugin's config
dict with a string and every later self.config.get(...) fails at runtime.
"""
with pytest.raises(C.ComposerInputError) as exc:
_generated(_with_key(key))
assert key in str(exc.value) and "reserved" in str(exc.value).lower()
@pytest.mark.parametrize("key", ["brightness", "my_var", "_private", "x1",
"update_interval_seconds"])
def test_ordinary_config_keys_are_still_accepted(key):
src = _generated(_with_key(key))
assert f"self.{key} = config.get(" in src
def test_the_generated_config_assignment_does_not_precede_super_init():
"""Guards the reasoning behind the reserved list, not just the list."""
src = _generated(_with_key("brightness"))
body = src.splitlines()
super_at = next(i for i, line in enumerate(body) if "super().__init__(" in line)
assign_at = next(i for i, line in enumerate(body)
if "self.brightness = config.get(" in line)
assert assign_at > super_at, (
"config vars are assigned before super().__init__(); the reserved-name "
"list assumes they land after it")
# --- optional keys ----------------------------------------------------------
@pytest.mark.parametrize("el_type,missing", [
("text", "text"), ("text", "text2"), ("clock", "format"),
])
def test_an_element_missing_an_optional_key_does_not_500(el_type, missing):
"""`p` is a copy of the raw element, so an absent key stays absent.
The defaults were applied to locals only, so manager.py.j2 rendered
`{{ el.text | tojson }}` over a jinja2.Undefined and tojson raised
TypeError -- which no handler catches, making a missing key a 500 rather
than a validation error or a sensible default.
"""
el = {"type": el_type, "id": "e1", "x": 0, "y": 0, "font": "press_start"}
src = _generated(_payload(elements=[el]))
ast.parse(src) # must still be valid Python
assert "Undefined" not in src
def test_a_clock_without_a_format_uses_the_documented_default():
el = {"type": "clock", "id": "c1", "x": 0, "y": 0, "font": "press_start"}
src = _generated(_payload(elements=[el]))
assert '"%H:%M"' in src, "the %H:%M default did not reach the generated source"
#: (element type, channel key, base element) for colour channels that were
#: interpolated raw rather than through _rgb_expr/_safe_int. Prefixed channels
#: (emptyR/G/B, labelR/G/B) were the ones the original r/g/b test never reached.
RAW_COLOUR_CASES = [
("progress_bar", "r", {"x": 0, "y": 0}),
("progress_bar", "g", {"x": 0, "y": 0}),
("pips", "b", {"x": 0, "y": 0}),
("pips", "emptyR", {"x": 0, "y": 0}),
("pips", "emptyG", {"x": 0, "y": 0}),
("sparkline", "r", {"x": 0, "y": 0}),
("gauge", "labelR", {"x": 0, "y": 0, "width": 32, "height": 32}),
("gauge", "labelB", {"x": 0, "y": 0, "width": 32, "height": 32}),
]
@pytest.mark.parametrize("etype,channel,base", RAW_COLOUR_CASES)
@pytest.mark.parametrize("evil", EXPR_PAYLOADS)
def test_a_prefixed_colour_channel_cannot_reach_the_source(etype, channel, base, evil):
"""Five tuples were built with f"({el.get('r', 100)}, ...)" -- no coercion.
The pre-existing colour test only covered r/g/b on a text element, so the
prefixed channels and the four other types were never exercised.
"""
el = {"type": etype, "id": "e1", **base}
el[channel] = evil
src = _generated(_payload(elements=[el]))
assert "__import__" not in src, f"{etype}.{channel}={evil!r} reached the source"
assert "os.system" not in src
assert not _module_level_code(src)
@pytest.mark.parametrize("value", [float("inf"), float("-inf"), float("nan")])
@pytest.mark.parametrize("field", ["x", "y", "width", "height"])
def test_a_non_finite_dimension_does_not_escape_as_an_unhandled_error(field, value):
"""json.loads accepts Infinity/NaN and Flask passes them through, so a
payload can hand _safe_int a non-finite float. int(inf) raises
OverflowError -- neither ValueError nor ComposerInputError -- so it escaped
both handlers and surfaced as a 500 with a traceback instead of a 422."""
el = {"type": "rectangle", "id": "r1", "x": 0, "y": 0, "width": 10, "height": 8}
el[field] = value
src = _generated(_payload(elements=[el])) # must not raise
# A non-finite value must be replaced by the default, not spelled into the
# source. Word-boundary match: "info" in self.logger.info contains "inf".
assert not re.search(r"\b(inf|nan|Infinity|NaN)\b", src), \
f"{field}={value!r} leaked a non-finite literal into the source"
assert not _module_level_code(src)
@pytest.mark.parametrize("bad_id", [
'x = __import__("os").system("id") #',
"x\nimport os\n_y",
"x[0]",
"",
"a" * 200,
])
def test_a_marquee_id_cannot_become_code(bad_id):
"""data_key is spliced UNQUOTED into variable names
(_{{ data_key }}_text = ...), so a non-identifier id landed in the source
as code. ast.parse caught it, but the caller then got an opaque
"Generated code has a syntax error" rather than being told the id is bad."""
el = {"type": "marquee", "id": bad_id, "x": 0, "y": 0, "text": "hi"}
src = _generated(_payload(elements=[el])) # must not raise
assert "__import__(" not in src
assert "os.system(" not in src
assert not _module_level_code(src)
+124
View File
@@ -0,0 +1,124 @@
"""An element the template cannot draw must not produce an empty `if` block.
manager.py.j2 wraps each element in `if width >= N:` (breakpoint) and/or
`if int(time.time() * 2) % 2:` (blink), and the body comes from the per-type
branches. A type with no branch contributed nothing, so the wrapper opened a
block with no statements in it. ast.parse in _generate_plugin_files then
failed and the caller was told only:
Generated code has a syntax error: expected an indented block after
'if' statement on line 49
which names a line of generated source the user never sees. Confirmed against
the code before the fix with a `group` element carrying minWidth.
Two defences, both covered here: _preprocess_elements drops types the template
has no branch for, and the template emits a `pass` fallback so a type added to
the canvas before its branch exists degrades to a no-op instead of a broken
plugin.
"""
import ast
import re
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from web_interface.blueprints import composer as C # noqa: E402
TEMPLATE = (Path(__file__).resolve().parent.parent
/ "web_interface/templates/v3/composer/manager.py.j2")
BASE_META = {"id": "test-plugin", "name": "Clock", "author": "a",
"version": "1.0.0", "description": "d"}
def generate(element):
return C._generate_plugin_files({
"metadata": BASE_META,
"elements": [element],
"dataModel": {"configVars": []},
})
@pytest.mark.parametrize("wrapper", [
{"minWidth": 64}, # breakpoint block
{"blink": True}, # blink block
{"minWidth": 64, "blink": True}, # both, nested
])
@pytest.mark.parametrize("etype", ["group", "widget_9000", "section"])
def test_undrawable_element_does_not_break_generation(etype, wrapper):
element = {"type": etype, "x": 0, "y": 0, "color": "#ffffff", **wrapper}
files = generate(element) # must not raise ComposerInputError
assert "manager.py" in files
def test_drawable_element_still_renders_inside_a_breakpoint():
files = generate({"type": "text", "text": "hi", "x": 0, "y": 0,
"minWidth": 64, "color": "#ffffff"})
src = files["manager.py"]
assert "if width >= 64:" in src
assert "draw_text" in src
def test_renderable_types_match_the_template_branches():
"""The constant and the template must agree.
A type listed in the constant with no branch emits an empty block (the bug
above); a type with a branch but missing from the constant is silently
dropped from every generated plugin. Neither is visible without this check.
"""
branches = set(re.findall(r"el\.type == '([a-z_]+)'", TEMPLATE.read_text()))
assert branches == set(C._RENDERABLE_ELEMENT_TYPES)
def test_template_closes_the_branch_chain_with_a_fallback():
"""Belt and braces: even if the constant drifts, no empty block escapes."""
text = TEMPLATE.read_text()
assert "{% else %}" in text
assert "pass # element type" in text
@pytest.mark.parametrize("wrapper", [
{"minWidth": 64},
{"blink": True},
{"minWidth": 64, "blink": True},
])
def test_dynamic_text_with_non_config_binding_does_not_break_generation(wrapper):
"""dynamic_text only renders a body for binding.source == 'config'.
_preprocess_elements accepts any string as binding.source (it just
defaults a missing one to 'config'), so a 'live' or 'sensor' source --
anything a client sends that isn't literally 'config' -- hit the same
empty-if-block bug as an undrawable element type, just one level deeper:
the branch is taken, but its own inner `if` produced nothing.
"""
element = {"type": "dynamic_text", "x": 0, "y": 0, "color": "#ffffff",
"binding": {"source": "live", "key": "temperature"}, **wrapper}
files = generate(element) # must not raise ComposerInputError
assert "manager.py" in files
assert "non-config dynamic_text binding draws nothing" in files["manager.py"]
def test_binding_source_cannot_break_out_of_the_comment_it_lands_in():
"""binding.source used to be interpolated straight into a Python comment
(`pass # dynamic_text binding_source "{{ el.binding_source }}" draws
nothing`) with no escaping. A source string carrying a newline closed the
comment, and text on the following line(s), indented to match, became a
real statement in the generated plugin -- CWE-94, and live once
composer_bp was registered (confirmed: this exact payload produces a
manager.py containing a clean, ast.parse-valid `import os` against the
pre-fix template). The comment is now a fixed literal that never
interpolates the value at all.
"""
payload = "foo\n import os\n os.system('id') # "
element = {"type": "dynamic_text", "x": 0, "y": 0, "color": "#ffffff",
"binding": {"source": payload, "key": "temperature"}}
files = generate(element)
src = files["manager.py"]
assert "import os" not in src
assert "os.system" not in src
assert "non-config dynamic_text binding draws nothing" in src
ast.parse(src) # belt and braces: generate() already enforces this
+263
View File
@@ -0,0 +1,263 @@
"""Structural checks on the composer's JavaScript.
There is no JS test runner in this repo, but three defects here are structural
enough to assert on the parse tree, and each was a real bug:
- Stroke widths inside _drawElement were left in canvas pixels while the
geometry around them scaled by SCALE, so at SCALE>1 every outline rendered
thinner than one LED pixel and the preview stopped matching the panel.
- The `line` branch drew raw el.x0/el.y0/el.x1/el.y1, ignoring the anchor that
every other element type honours, so setting xAnchor moved everything except
lines -- and getBoundingBox had the same omission, leaving the hit box behind.
- Four methods mutated exactly what _snapshot() serialises (metadata,
currentPreset) without calling it. _snapshot is the only caller of
_debouncedAutosave, so those changes were lost on reload and could not be
undone.
"""
import re
from pathlib import Path
import pytest
tree_sitter = pytest.importorskip("tree_sitter")
tree_sitter_javascript = pytest.importorskip("tree_sitter_javascript")
JS_DIR = Path(__file__).resolve().parent.parent / "web_interface/static/v3/js/composer"
CANVAS = JS_DIR / "composer-canvas.js"
APP = JS_DIR / "composer-app.js"
def _function_source(path: Path, name: str) -> str:
"""Return the source of a top-level function declaration by name."""
src = path.read_bytes()
lang = tree_sitter.Language(tree_sitter_javascript.language())
tree = tree_sitter.Parser(lang).parse(src)
found = []
def walk(node):
if node.type == "function_declaration":
ident = node.child_by_field_name("name")
if ident is not None and src[ident.start_byte:ident.end_byte].decode() == name:
found.append(src[node.start_byte:node.end_byte].decode())
for c in node.children:
walk(c)
walk(tree.root_node)
assert found, f"{name} not found in {path.name}"
return found[0]
def _method_source(path: Path, name: str) -> str:
"""Return the source of a top-level object method by name."""
src = path.read_bytes()
lang = tree_sitter.Language(tree_sitter_javascript.language())
tree = tree_sitter.Parser(lang).parse(src)
found = []
def walk(node):
if node.type == "method_definition":
ident = node.child_by_field_name("name")
if ident is not None and src[ident.start_byte:ident.end_byte].decode() == name:
found.append(src[node.start_byte:node.end_byte].decode())
for c in node.children:
walk(c)
walk(tree.root_node)
assert found, f"{name} not found in {path.name}"
return found[0]
def test_both_files_parse():
lang = tree_sitter.Language(tree_sitter_javascript.language())
parser = tree_sitter.Parser(lang)
for path in (CANVAS, APP):
tree = parser.parse(path.read_bytes())
errors = []
def walk(node):
if node.type == "ERROR" or node.is_missing:
errors.append(node.start_point[0] + 1)
for c in node.children:
walk(c)
walk(tree.root_node)
assert not errors, f"{path.name} has parse errors at lines {errors}"
def test_element_strokes_scale_with_scale():
"""No bare `ctx.lineWidth = 1` inside _drawElement.
Selection handles and the grid are drawn in canvas pixels deliberately and
live in other functions, so this is scoped to the element drawing routine.
"""
body = _function_source(CANVAS, "_drawElement")
offenders = re.findall(r"ctx\.lineWidth\s*=\s*1\s*;", body)
assert not offenders, f"{len(offenders)} unscaled stroke width(s) in _drawElement"
def test_line_branch_applies_the_anchor_offset():
"""Scoped to _drawElement.
getBoundingBox has its own `case 'line': {` and appears first in the file,
so searching the whole text found *that* branch -- this assertion passed
with the draw branch's anchor offset removed. Verified: stripping it and
re-running gave 11/11 green.
"""
body = _function_source(CANVAS, "_drawElement")
line_branch = body[body.index("case 'line': {"):]
line_branch = line_branch[:line_branch.index("case 'divider'")]
assert "ax - el.x0" in line_branch and "ay - el.y0" in line_branch, \
"line drawing ignores xAnchor/yAnchor"
assert "moveTo(el.x0 * s" not in line_branch, \
"line still drawn from unanchored endpoints"
def test_line_bounding_box_applies_the_anchor_offset():
"""The companion to the above: scoped to getBoundingBox specifically, so
the two tests cannot both be satisfied by the same branch."""
body = _function_source(CANVAS, "getBoundingBox")
box = body[body.index("case 'line'"):]
box = box[:box.index("case 'divider'")]
assert "ax - el.x0" in box, "line bounding box ignores the anchor"
@pytest.mark.parametrize("method", [
"onBgColorChange", # mutates metadata.bgColor
"setCustomSize", # mutates currentPreset / MATRIX_W / MATRIX_H
"changePreset", # mutates currentPreset / MATRIX_W / MATRIX_H
"applyPresetLabel", # same, for sizes not in DISPLAY_PRESETS
"onColorChange", # the one that was already fixed — keeps it fixed
])
def test_state_mutations_take_a_snapshot(method):
body = _method_source(APP, method)
assert "_snapshot()" in body, \
f"{method} changes snapshotted state without calling _snapshot()"
assert "isDirty = true" in body, f"{method} does not mark the design dirty"
@pytest.mark.parametrize("method", ["changePreset", "applyPresetLabel"])
def test_restore_path_stays_snapshot_free(method):
"""_applyState and loadTemplate call these with {silent: true} while
restoring; snapshotting there would push restore steps onto the undo stack
and re-autosave the state just loaded."""
body = _method_source(APP, method)
assert "opts.silent" in body, f"{method} lost its silent guard"
snap = body.index("_snapshot()")
guard = body.index("!opts.silent")
assert guard < snap, f"{method} snapshots outside the !opts.silent guard"
TEMPLATE_HTML = (Path(__file__).resolve().parent.parent
/ "web_interface/templates/v3/composer.html")
#: The six toolbar buttons and the wrapper each must call.
ALIGN_BUTTONS = ["alignLeft", "alignHCenter", "alignRight",
"alignTop", "alignVCenter", "alignBottom"]
def test_alignment_buttons_use_the_anchor_clearing_path():
"""Two alignment implementations existed and the toolbar used the wrong one.
The legacy alignElement(dir) set el.x/el.y but left xAnchor/yAnchor in
place. resolveAnchor turns anchor='right' into `dim - val`, so "align left"
(el.x = 0) resolved to x = MATRIX_W -- the element jumped to the far right
edge instead. _alignElement clears the anchor first, so the stored value is
absolute, and it also updates el.x0/el.y0 so lines actually move.
"""
html = TEMPLATE_HTML.read_text()
for wrapper in ALIGN_BUTTONS:
assert f"{wrapper}()" in html, f"toolbar does not call {wrapper}()"
assert not re.search(r"[^_]alignElement\(", html), \
"toolbar still calls the legacy alignElement()"
def test_the_legacy_alignelement_is_gone():
"""Leaving it in place invites the toolbar drifting back to it."""
src = APP.read_text()
assert not re.search(r"^\s{4}alignElement\(dir\)", src, re.M), \
"legacy alignElement(dir) still defined"
def test_align_clears_the_anchor_and_moves_line_endpoints():
body = _method_source(APP, "_alignElement")
assert "xAnchor = null" in body and "yAnchor = null" in body, \
"_alignElement no longer clears the anchor, so aligning an anchored " \
"element resolves to the wrong edge"
assert "el.x0" in body and "el.y0" in body, \
"_alignElement no longer moves line endpoints"
def test_align_translates_both_line_endpoints_not_just_the_start():
"""Setting only x0 (or y0) left x1/y1 behind, so aligning a line changed
its shape instead of moving it -- e.g. a line from x0=20 to x1=50 aligned
right became x0=98, x1=50, stretching rather than translating it. Both
endpoints must move by the same delta."""
body = _method_source(APP, "_alignElement")
x_branch = body[body.index("if (axis === 'x')"):body.index("} else {")]
y_branch = body[body.index("} else {"):]
assert "el.x1" in x_branch, \
"_alignElement moves x0 but not x1 -- a line's shape changes, not its position"
assert "el.y1" in y_branch, \
"_alignElement moves y0 but not y1 -- a line's shape changes, not its position"
def test_divider_stroke_is_centered_in_led_pixels():
"""A 0.5 canvas-pixel offset (correct only at SCALE=1) was applied after
scaling instead of before it, so at SCALE>1 the stroke bled into the
preceding LED row/column instead of straddling its own."""
body = _function_source(CANVAS, "_drawElement")
divider_branch = body[body.index("case 'divider'"):]
divider_branch = divider_branch[:divider_branch.index("case 'pips'")]
assert "ay * s + 0.5" not in divider_branch, \
"divider still offsets by 0.5 canvas pixels after scaling"
assert "ax * s + 0.5" not in divider_branch, \
"divider still offsets by 0.5 canvas pixels after scaling"
assert "(ay + 0.5) * s" in divider_branch and "(ax + 0.5) * s" in divider_branch, \
"divider stroke is not centered within its LED pixel"
def test_gauge_radii_are_clamped_to_zero():
"""An imported design can carry a small gauge with a wide lineWidth --
width=1, height=1, lineWidth=3 sends a negative radius into
ctx.ellipse(), which throws IndexSizeError and aborts render() for every
element still to be drawn, not just the gauge."""
body = _function_source(CANVAS, "_drawElement")
gauge_branch = body[body.index("case 'gauge'"):]
assert re.search(r"Math\.max\(0,\s*rx\s*-\s*lwPx\s*/\s*2\)", gauge_branch), \
"gauge x-radius is not clamped to zero"
assert re.search(r"Math\.max\(0,\s*ry\s*-\s*lwPx\s*/\s*2\)", gauge_branch), \
"gauge y-radius is not clamped to zero"
assert "rx - lwPx / 2" not in re.sub(r"Math\.max\(0,\s*rx\s*-\s*lwPx\s*/\s*2\)", "", gauge_branch), \
"an unclamped gauge radius is still passed to ctx.ellipse()"
#: ELEMENT_DEFAULTS in composer-canvas.js gives exactly these types a
#: `binding` object; verified against that file rather than assumed.
BOUND_TYPES = ["dynamic_text", "progress_bar", "countdown", "pips", "sparkline", "gauge"]
def test_bound_types_constant_lists_every_bound_element_type():
"""ELEMENT_DEFAULTS in composer-canvas.js gives dynamic_text, progress_bar,
countdown, pips, sparkline and gauge a `binding` object. If a type is added
to (or removed from) that list without updating APP's BOUND_TYPES, the
checks below drift out of sync silently -- this pins the two together."""
src = APP.read_text()
match = re.search(r"const BOUND_TYPES = \[([^\]]*)\];", src)
assert match, "BOUND_TYPES constant not found in composer-app.js"
declared = {t.strip().strip("'\"") for t in match.group(1).split(",") if t.strip()}
assert declared == set(BOUND_TYPES), \
f"BOUND_TYPES {declared} does not match the bound element types {set(BOUND_TYPES)}"
@pytest.mark.parametrize("method", ["_isBound", "removeConfigVar", "_validateBeforeExport"])
def test_binding_checks_cover_every_bound_element_type(method):
"""Only dynamic_text and progress_bar were checked, so a countdown, pips,
sparkline or gauge element with an empty binding passed export validation
silently, and deleting a config var still used by one of them gave no
warning -- the plugin would read a now-missing key at runtime with no
indication why."""
body = _method_source(APP, method)
assert "BOUND_TYPES.includes(e.type)" in body, \
f"{method} does not check every bound element type via BOUND_TYPES"
assert "e.type === 'dynamic_text'" not in body, \
f"{method} still hardcodes only dynamic_text instead of BOUND_TYPES"
+225
View File
@@ -0,0 +1,225 @@
"""A composer plugin id must never resolve outside the plugins directory.
CodeQL reported sixteen high-severity py/path-injection alerts against
web_interface/blueprints/composer.py: a request-supplied plugin_id reaching
Path(plugins_dir) / plugin_id, which is then created, written to, deleted
(shutil.rmtree) and read back.
The id was already validated by an anchored regex, so every traversal payload
was in fact rejected. What was missing was the guarantee living *with* the path
building rather than in a regex several hundred lines away -- loosen that regex
later and the traversal opens silently, with nothing at the filesystem boundary
to catch it. _plugin_dir() closes that, and is the form static analysis can see.
"""
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from web_interface.blueprints import composer as C # noqa: E402
#: Anything that has ever been used to climb out of a directory.
TRAVERSAL = [
"../../etc/passwd", "..", ".", "a/../../etc", "good/../../..",
"/etc/passwd", "//etc/passwd", "a\\..\\..", "a%2f..%2f..",
"....//....//etc", "a/./../../etc", "~", "~root",
"plugin/../../../../../../etc/shadow",
]
#: Rejected for shape, not traversal -- but rejected all the same.
MALFORMED = ["", "A-upper", "1-leading-digit", "-leading-dash", "has_underscore",
"has space", "has.dot", "a" * 64, "plugin\n", "plugin\n../../etc",
"\n", "plug\x00in"]
@pytest.fixture
def plugins_dir(tmp_path, monkeypatch):
base = tmp_path / "plugin-repos"
base.mkdir()
monkeypatch.setattr(C.composer_bp, "plugins_dir", str(base), raising=False)
return base
@pytest.mark.parametrize("payload", TRAVERSAL)
def test_traversal_payloads_are_refused(plugins_dir, payload):
assert C._plugin_dir(payload) is None
@pytest.mark.parametrize("payload", MALFORMED)
def test_malformed_ids_are_refused(plugins_dir, payload):
assert C._plugin_dir(payload) is None
@pytest.mark.parametrize("payload", ["a", "my-plugin", "x9", "a" * 63])
def test_valid_ids_resolve_inside_the_base(plugins_dir, payload):
resolved = C._plugin_dir(payload)
assert resolved is not None, f"{payload!r} was rejected but is valid"
assert resolved.parent == plugins_dir.resolve(), (
f"{payload!r} resolved to {resolved}, outside {plugins_dir}")
def test_no_payload_can_escape_even_if_the_regex_is_loosened(plugins_dir, monkeypatch):
"""The containment check must stand on its own.
This is the whole point of resolving at the filesystem boundary: if the id
pattern is ever relaxed, traversal must still be impossible. Replace the
regex with one that permits slashes and dots, then re-run the payloads.
"""
import re
monkeypatch.setattr(C, "_PLUGIN_ID_RE", re.compile(r"\A[\w./\\~-]+\Z"))
import os
escaped = []
base = os.path.realpath(str(plugins_dir))
for payload in TRAVERSAL:
resolved = C._plugin_dir(payload)
if resolved is None:
continue
real = os.path.realpath(str(resolved))
if real != base and os.path.commonpath([base, real]) != base:
escaped.append((payload, real))
assert not escaped, f"these escaped the base with a loosened regex: {escaped}"
def test_a_sibling_directory_with_a_shared_prefix_is_not_inside(tmp_path, monkeypatch):
"""commonpath, not startswith.
"/x/plugins-evil" starts with "/x/plugins" but is a different directory, so
a prefix test would accept it.
"""
base = tmp_path / "plugins"
base.mkdir()
(tmp_path / "plugins-evil").mkdir()
monkeypatch.setattr(C.composer_bp, "plugins_dir", str(base), raising=False)
import re
# Neutralise the two layers in front so this exercises the containment
# check itself; otherwise secure_filename rejects the payload first and a
# startswith regression would go unnoticed here.
monkeypatch.setattr(C, "_PLUGIN_ID_RE", re.compile(r"\A[\w./\\~-]+\Z"))
monkeypatch.setattr(C, "secure_filename", lambda v: v)
assert C._plugin_dir("../plugins-evil") is None
def test_containment_still_holds_if_the_sanitiser_is_defeated(plugins_dir, monkeypatch):
"""Each layer is tested on its own, not just the stack.
secure_filename's equality guard rejects every traversal payload before the
containment check sees it, so removing containment does not fail the other
tests -- which would make it look load-bearing when it is not. Neutralise
the regex *and* the sanitiser, and the realpath/commonpath check must still
refuse everything on its own.
"""
import re
monkeypatch.setattr(C, "_PLUGIN_ID_RE", re.compile(r"\A[\w./\\~-]+\Z"))
monkeypatch.setattr(C, "secure_filename", lambda v: v)
import os
base = os.path.realpath(str(plugins_dir))
escaped = []
for payload in TRAVERSAL:
resolved = C._plugin_dir(payload)
if resolved is None:
continue
real = os.path.realpath(str(resolved))
# Inside the base is fine -- "...." and "~" are ordinary directory
# names on Linux, so they are not escapes. What must never happen is
# landing outside the base, or on the base itself: install() rmtrees
# its target, so the plugins root resolving to a "plugin" would wipe
# every installed plugin.
if real == base or os.path.commonpath([base, real]) != base:
escaped.append((payload, real))
assert not escaped, f"containment alone let these through: {escaped}"
def test_secure_filename_never_rewrites_an_accepted_id(plugins_dir):
"""The sanitiser must be a no-op on everything the regex accepts.
If secure_filename ever altered an accepted id, _plugin_dir would resolve
to a *different* plugin's directory than the caller asked for -- a silent
redirect, which is worse than a refusal. The guard turns that into a
refusal; this proves the guard never has to fire in practice.
"""
import random
from werkzeug.utils import secure_filename
random.seed(1)
alphabet = "abcdefghijklmnopqrstuvwxyz0123456789-"
altered = []
for _ in range(2000):
n = random.randint(1, 63)
cand = random.choice("abcdefghijklmnopqrstuvwxyz") + "".join(
random.choice(alphabet) for _ in range(n - 1))
if C._PLUGIN_ID_RE.match(cand) and secure_filename(cand) != cand:
altered.append((cand, secure_filename(cand)))
assert not altered, f"secure_filename rewrote accepted ids: {altered[:5]}"
def test_a_trailing_newline_is_not_a_valid_id():
r"""Python's `$` also matches before a trailing newline, so the original
`^...$` accepted "myplugin\n" and would have created a directory whose
name ends in one. \Z does not."""
assert C._PLUGIN_ID_RE.match("myplugin") is not None
assert C._PLUGIN_ID_RE.match("myplugin\n") is None
# --- font serving -----------------------------------------------------------
FONT_TRAVERSAL = [
"../../../etc/passwd", "../config/config.json", "..%2f..%2fetc%2fpasswd",
"PressStart2P-Regular.ttf/../../../etc/passwd", "/etc/passwd", "",
"PressStart2P-Regular.TTF", # case differs -> not the allowlisted name
"PressStart2P-Regular.ttf ", # trailing space
]
def test_serve_font_refuses_a_file_that_exists_but_is_not_allowlisted(monkeypatch, tmp_path):
"""The allowlist must be what refuses it, not a missing file.
Asserting 404 on traversal payloads proves nothing here: Flask's router
will not match a path segment containing '/', and everything else 404s
simply because no such file exists. Put a real, readable file next to the
fonts and confirm it is still refused -- that is the allowlist working.
"""
fonts = tmp_path / "assets" / "fonts"
fonts.mkdir(parents=True)
(fonts / "id_rsa.ttf").write_bytes(b"PRIVATE KEY")
monkeypatch.setattr(C.composer_bp, "project_root", str(tmp_path), raising=False)
app = __import__("flask").Flask(__name__)
app.register_blueprint(C.composer_bp)
with app.test_client() as client:
resp = client.get("/api/fonts/id_rsa.ttf")
assert resp.status_code == 404, (
"a readable non-allowlisted file was served; the allowlist is not gating")
assert b"PRIVATE KEY" not in resp.data
@pytest.mark.parametrize("payload", FONT_TRAVERSAL)
def test_serve_font_refuses_anything_not_allowlisted(payload, monkeypatch, tmp_path):
"""The name reaching the filesystem must come from the allowlist constant.
_ALLOWED_FONTS gates this endpoint, so nothing here was ever exploitable.
Building the path from the matched constant rather than the request value
is what makes that provable -- and it is why CodeQL reported two
high-severity py/path-injection alerts on an endpoint that was already
safe.
"""
monkeypatch.setattr(C.composer_bp, "project_root", str(tmp_path), raising=False)
app = C.composer_bp.name and __import__("flask").Flask(__name__)
app.register_blueprint(C.composer_bp)
with app.test_client() as client:
resp = client.get(f"/api/fonts/{payload}")
assert resp.status_code in (404, 405, 308), (
f"{payload!r} was not refused (status {resp.status_code})")
def test_serve_font_still_serves_each_allowlisted_font(monkeypatch, tmp_path):
fonts = tmp_path / "assets" / "fonts"
fonts.mkdir(parents=True)
monkeypatch.setattr(C.composer_bp, "project_root", str(tmp_path), raising=False)
app = __import__("flask").Flask(__name__)
app.register_blueprint(C.composer_bp)
for name in C._ALLOWED_FONTS:
(fonts / name).write_bytes(b"\x00\x01ttf")
with app.test_client() as client:
resp = client.get(f"/api/fonts/{name}")
assert resp.status_code == 200, f"{name} should be served, got {resp.status_code}"
+1 -39
View File
@@ -103,43 +103,6 @@ def test_non_dict_input_passes_through():
assert _redact_credentials(None) is None assert _redact_credentials(None) is None
def test_a_list_of_bare_credentials_is_redacted():
"""A credential-named key whose value is a list of scalars, not a list
of named-field dicts, used to pass through untouched: the dict branch
recursed into `v` on its own value only, so a bare string list item had
no field name to test and fell through the base case unredacted.
"""
config = {"tokens": ["abc123", "def456"], "timezone": "America/New_York"}
out = _redact_credentials(config)
assert out["tokens"] == ["", ""]
assert out["timezone"] == "America/New_York"
def test_a_list_nested_inside_a_credential_named_container_is_also_blanked():
"""The list fix applies at any depth under a credential-named key, not
only when the list is the key's direct value.
"""
config = {"auth": {"backup_tokens": ["a", "b"], "note": "keep"}}
out = _redact_credentials(config)
assert out["auth"]["backup_tokens"] == ["", ""]
assert out["auth"]["note"] == "keep"
def test_credential_shaped_container_with_named_fields_still_only_blanks_those():
"""Unchanged behaviour for the case the container test above already
covers: a dict whose sub-keys are semantically named fields is walked
normally, not blanket-blanked, so an ordinary field next to a
credential-named one survives.
"""
config = {"secrets": {"api_key": "k", "note": "keep", "list": ["a", "b"]}}
out = _redact_credentials(config)
assert out["secrets"]["api_key"] == ""
assert out["secrets"]["note"] == "keep"
# "list" isn't itself credential-named, so it is walked, not blanked --
# but it holds no credential-named field either, so it survives whole.
assert out["secrets"]["list"] == ["a", "b"]
def test_the_endpoint_itself_redacts(): def test_the_endpoint_itself_redacts():
"""Through the view function, not the helper. """Through the view function, not the helper.
@@ -167,8 +130,7 @@ def test_the_endpoint_itself_redacts():
app = flask.Flask(__name__) app = flask.Flask(__name__)
try: try:
with app.test_request_context("/config/main"): with app.test_request_context("/config/main"):
# get_main_config is a route; it lives in the config module now. response = mod.get_main_config()
response = mod.config.get_main_config()
payload = response.get_json() if hasattr(response, "get_json") else _json.loads(response[0].data) payload = response.get_json() if hasattr(response, "get_json") else _json.loads(response[0].data)
finally: finally:
mod.api_v3.config_manager = previous mod.api_v3.config_manager = previous
-71
View File
@@ -116,77 +116,6 @@ class TestFallbackDivergence:
plugin_dir / "config_schema.json" 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: class TestStandaloneBackupContract:
def test_discovery_skips_backup_dirs(self, tmp_path): def test_discovery_skips_backup_dirs(self, tmp_path):
plugins_dir = tmp_path / "plugins" plugins_dir = tmp_path / "plugins"
+2 -177
View File
@@ -47,17 +47,12 @@ class _SwapSpy:
def __init__(self, matrix): def __init__(self, matrix):
self.matrix = matrix self.matrix = matrix
self.count = 0 self.count = 0
self.last_frame_hold = None
self._orig = matrix.SwapOnVSync self._orig = matrix.SwapOnVSync
def __enter__(self): def __enter__(self):
def counting(canvas, *args): def counting(canvas):
# *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 self.count += 1
self.last_frame_hold = args[0] if args else 1 return self._orig(canvas)
return self._orig(canvas, *args)
self.matrix.SwapOnVSync = counting self.matrix.SwapOnVSync = counting
return self return self
@@ -114,11 +109,6 @@ class TestDirtyTracking:
dm.draw.rectangle([0, 0, 30, 8], fill=(255, 255, 0)) dm.draw.rectangle([0, 0, 30, 8], fill=(255, 255, 0))
dm.update_display() # push + snapshot write (first frame) dm.update_display() # push + snapshot write (first frame)
assert os.path.exists(dm._snapshot_path) 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) first_mtime = os.path.getmtime(dm._snapshot_path)
# Age the write/touch bookkeeping past TOUCH_INTERVAL so the next # Age the write/touch bookkeeping past TOUCH_INTERVAL so the next
@@ -135,62 +125,6 @@ class TestDirtyTracking:
assert os.path.getmtime(dm._snapshot_path) > first_mtime 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: class TestKillSwitch:
def test_dirty_tracking_can_be_disabled(self, dm): def test_dirty_tracking_can_be_disabled(self, dm):
dm._dirty_tracking_enabled = False dm._dirty_tracking_enabled = False
@@ -226,112 +160,3 @@ class TestKillSwitch:
if __name__ == "__main__": if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-v"])) 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
@@ -1,69 +0,0 @@
"""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,61 +101,3 @@ class TestSkippedResults:
r = _result(None, returned=None) r = _result(None, returned=None)
check_empty_claimed([r], strict=True) check_empty_claimed([r], strict=True)
assert r.empty_claimed is None 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
@@ -1,329 +0,0 @@
"""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
@@ -1,106 +0,0 @@
"""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
@@ -1,208 +0,0 @@
"""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
@@ -1,210 +0,0 @@
"""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
@@ -1,467 +0,0 @@
"""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"
+1 -79
View File
@@ -11,11 +11,7 @@ import time
from unittest.mock import patch from unittest.mock import patch
from PIL import Image from PIL import Image
from src.common.scroll_helper import ( from src.common.scroll_helper import ScrollHelper
ScrollHelper,
format_frame_stats,
frame_stats,
)
DISPLAY_W = 64 DISPLAY_W = 64
@@ -319,77 +315,3 @@ class TestGetScrollInfo:
helper.scroll_position = 42.0 helper.scroll_position = 42.0
info = helper.get_scroll_info() info = helper.get_scroll_info()
assert info["scroll_position"] == 42.0 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 == []
+48 -121
View File
@@ -21,6 +21,8 @@ sys.modules.setdefault("rgbmatrix", MagicMock())
from src.common.sports_scroll import ( # noqa: E402 from src.common.sports_scroll import ( # noqa: E402
DEFAULT_SCROLL_SETTINGS, DEFAULT_SCROLL_SETTINGS,
MAX_PIXELS_PER_FRAME,
MIN_PIXELS_PER_FRAME,
SportsScrollDisplay, SportsScrollDisplay,
SportsScrollDisplayManager, SportsScrollDisplayManager,
) )
@@ -185,48 +187,29 @@ class TestScrollSettings:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestConfigureScrollHelper: class TestConfigureScrollHelper:
"""Pacing now comes from scroll_config, the same resolver every other def test_speed_is_converted_to_pixels_per_frame(self, build):
scrolling plugin uses. What this class guards is the translation into it,
because the two modules read identically-named keys differently."""
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.
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": { display = build({"nhl": {"scroll_settings": {
"scroll_speed": 50.0, "scroll_delay": 0.01}}}) "scroll_speed": 100.0, "scroll_delay": 0.02}}})
applied = display.scroll_helper.set_scroll_speed.call_args[0][0] # 100 px/s * 0.02 s/frame = 2 px/frame
assert applied == pytest.approx(50.0, abs=1.0), ( display.scroll_helper.set_scroll_speed.assert_called_with(2.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_speed_is_snapped_to_whole_pixel_motion(self, build): def test_conversion_is_clamped_low(self, build):
"""50 px/s on a 100Hz panel is half a pixel per refresh, which cannot display = build({"nhl": {"scroll_settings": {
render as motion -- it alternates 0 and 1 px steps and judders. The "scroll_speed": 0.001, "scroll_delay": 0.001}}})
ladder keeps the speed and holds each frame for two refreshes.""" display.scroll_helper.set_scroll_speed.assert_called_with(MIN_PIXELS_PER_FRAME)
display = build()
assert display._scroll_settings.frame_hold == 2
assert display._scroll_settings.pixels_per_second == pytest.approx(50.0)
def test_the_resolved_hold_is_what_gets_published(self, build): def test_conversion_is_clamped_high(self, build):
display = build() display = build({"nhl": {"scroll_settings": {
assert display._scroll_frame_hold() == 2 "scroll_speed": 5000.0, "scroll_delay": 0.5}}})
display.scroll_helper.set_scroll_speed.assert_called_with(MAX_PIXELS_PER_FRAME)
def test_hold_defaults_to_one_before_anything_is_resolved(self, display_manager): def test_zero_delay_assumes_a_pacing_instead_of_dividing_by_zero(self, build):
bare = SportsScrollDisplay.__new__(SportsScrollDisplay) display = build({"nhl": {"scroll_settings": {
assert SportsScrollDisplay._scroll_frame_hold(bare) == 1 "scroll_speed": 100.0, "scroll_delay": 0}}})
display.scroll_helper.set_scroll_speed.assert_called_with(1.0)
def test_stepping_is_time_based(self, build): def test_frame_based_scrolling_is_enabled(self, build):
"""Frame-based mode gated motion on a wall clock at 1/scroll_delay build().scroll_helper.set_frame_based_scrolling.assert_called_once_with(True)
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): def test_dynamic_duration_settings_are_applied(self, build):
display = build({"nhl": {"scroll_settings": { display = build({"nhl": {"scroll_settings": {
@@ -236,38 +219,32 @@ class TestConfigureScrollHelper:
assert kwargs["min_duration"] == 5 assert kwargs["min_duration"] == 5
assert kwargs["max_duration"] == 50 assert kwargs["max_duration"] == 50
def test_zero_delay_still_means_pixels_per_frame(self, build): def test_target_fps_reaches_the_helper(self, build):
"""The one case where scroll_speed is not already px/s.""" """The whole point of upstreaming: the bundled copies hardcode ~100 FPS
display = build({"nhl": {"scroll_settings": { via scroll_delay and never consult the global target."""
"scroll_speed": 1.0, "scroll_delay": 0}}}) display = build(global_config={"target_fps": 120})
applied = display.scroll_helper.set_scroll_speed.call_args[0][0] display.scroll_helper.set_target_fps.assert_called_once_with(120.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): def test_legacy_key_is_honored(self, build):
display = build(global_config={"scroll_target_fps": 90}) display = build(global_config={"scroll_target_fps": 90})
assert display._resolve_refresh_hz() == 90.0 display.scroll_helper.set_target_fps.assert_called_once_with(90.0)
def test_modern_key_wins_over_legacy(self, build): def test_modern_key_wins_over_legacy(self, build):
display = build(global_config={"target_fps": 120, "scroll_target_fps": 90}) display = build(global_config={"target_fps": 120, "scroll_target_fps": 90})
assert display._resolve_refresh_hz() == 120.0 display.scroll_helper.set_target_fps.assert_called_once_with(120.0)
def test_configured_hardware_refresh_wins_over_the_fps_target(self, build): def test_absent_target_fps_leaves_config_pacing_alone(self, build):
display = build(global_config={ build().scroll_helper.set_target_fps.assert_not_called()
"target_fps": 60,
"display": {"hardware": {"limit_refresh_rate_hz": 120}}})
assert display._resolve_refresh_hz() == 120.0
@pytest.mark.parametrize("bad", ["fast", None, {}, [], "", 0]) @pytest.mark.parametrize("bad", ["fast", None, {}, [], "", 0])
def test_unusable_target_fps_degrades_instead_of_raising(self, build, bad): def test_unusable_target_fps_degrades_instead_of_raising(self, build, bad):
"""A malformed global config must cost the FPS target, not the display.""" """A malformed global config must cost the FPS target, not the display."""
display = build(global_config={"target_fps": bad}) display = build(global_config={"target_fps": bad})
assert display._scroll_settings.pixels_per_second > 0 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)
@pytest.mark.parametrize("bad", [None, "fast", {}, []]) @pytest.mark.parametrize("bad", [None, "fast", {}, []])
def test_unusable_scroll_speed_degrades_instead_of_crashing(self, build, bad): def test_unusable_scroll_speed_degrades_instead_of_crashing(self, build, bad):
@@ -275,63 +252,24 @@ class TestConfigureScrollHelper:
present with null reaches the arithmetic and raises inside __init__, present with null reaches the arithmetic and raises inside __init__,
taking the whole display down before it renders anything.""" taking the whole display down before it renders anything."""
display = build({"nhl": {"scroll_settings": {"scroll_speed": bad}}}) display = build({"nhl": {"scroll_settings": {"scroll_speed": bad}}})
applied = display.scroll_helper.set_scroll_speed.call_args[0][0] # 50.0 px/s * 0.01 s/frame == 0.5 px/frame, i.e. the default speed.
assert applied == pytest.approx(50.0, abs=1.0) display.scroll_helper.set_scroll_speed.assert_called_with(0.5)
@pytest.mark.parametrize("bad", [None, "slow", {}]) @pytest.mark.parametrize("bad", [None, "slow", {}])
def test_unusable_scroll_delay_degrades_instead_of_crashing(self, build, bad): def test_unusable_scroll_delay_degrades_instead_of_crashing(self, build, bad):
display = build({"nhl": {"scroll_settings": {"scroll_delay": bad}}}) display = build({"nhl": {"scroll_settings": {"scroll_delay": bad}}})
applied = display.scroll_helper.set_scroll_speed.call_args[0][0] display.scroll_helper.set_scroll_delay.assert_called_with(0.01)
assert applied == pytest.approx(50.0, abs=1.0)
def test_numeric_strings_are_accepted(self, build): def test_numeric_strings_are_accepted(self, build):
display = build({"nhl": {"scroll_settings": { display = build({"nhl": {"scroll_settings": {
"scroll_speed": "100", "scroll_delay": "0.02"}}}) "scroll_speed": "100", "scroll_delay": "0.02"}}})
applied = display.scroll_helper.set_scroll_speed.call_args[0][0] display.scroll_helper.set_scroll_speed.assert_called_with(2.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)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -491,11 +429,7 @@ class TestManager:
never honoring target_fps.""" never honoring target_fps."""
child = manager.get_scroll_display("live") child = manager.get_scroll_display("live")
assert child.global_config == {"target_fps": 120} assert child.global_config == {"target_fps": 120}
# The target is now the refresh the crisp ladder is computed against, child.scroll_helper.set_target_fps.assert_called_once_with(120.0)
# 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): def test_prepare_sets_the_active_type(self, manager):
assert manager.prepare_and_display([{"id": "g1"}], "live", ["nhl"]) is True assert manager.prepare_and_display([{"id": "g1"}], "live", ["nhl"]) is True
@@ -617,17 +551,10 @@ class TestAgainstTheRealScrollHelper:
) )
def test_configuration_lands_on_the_real_helper(self, real): def test_configuration_lands_on_the_real_helper(self, real):
display = real.get_scroll_display("live") helper = real.get_scroll_display("live").scroll_helper
helper = display.scroll_helper assert helper.target_fps == 120.0
# 500 px/s on a 120Hz panel snaps to a whole number of pixels per assert helper.frame_based_scrolling is True
# refresh, and the helper is driven in px/s in time-based mode. assert helper.scroll_speed == pytest.approx(0.5) # 500 px/s * 0.001 s
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): def test_a_strip_is_built_and_scrolls_to_completion(self, real):
import time import time
-205
View File
@@ -1,205 +0,0 @@
"""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
+1 -4
View File
@@ -470,10 +470,7 @@ class TestApiBoundsMatchValidate:
"""Extract the numeric_fields map from api_v3 without importing Flask.""" """Extract the numeric_fields map from api_v3 without importing Flask."""
import ast import ast
import pathlib import pathlib
# api_v3 is a package; the routes are spread across its modules. src = pathlib.Path('web_interface/blueprints/api_v3.py').read_text()
src = '\n'.join(
p.read_text() for p in
sorted(pathlib.Path('web_interface/blueprints/api_v3').glob('*.py')))
tree = ast.parse(src) tree = ast.parse(src)
for node in ast.walk(tree): for node in ast.walk(tree):
if not isinstance(node, ast.Assign): if not isinstance(node, ast.Assign):
+4 -4
View File
@@ -390,7 +390,7 @@ class TestConfigAPI:
class TestSystemAPI: class TestSystemAPI:
"""Test system API endpoints.""" """Test system API endpoints."""
@patch('web_interface.blueprints.api_v3.system.subprocess') @patch('web_interface.blueprints.api_v3.subprocess')
def test_get_system_status(self, mock_subprocess, client): def test_get_system_status(self, mock_subprocess, client):
"""Test getting system status.""" """Test getting system status."""
# The endpoint returns 503 without psutil, which is an optional # The endpoint returns 503 without psutil, which is an optional
@@ -407,7 +407,7 @@ class TestSystemAPI:
data = json.loads(response.data) data = json.loads(response.data)
assert 'service' in data or 'status' in data or 'active' in data assert 'service' in data or 'status' in data or 'active' in data
@patch('web_interface.blueprints.api_v3.system.subprocess') @patch('web_interface.blueprints.api_v3.subprocess')
def test_get_system_version(self, mock_subprocess, client): def test_get_system_version(self, mock_subprocess, client):
"""Test getting system version.""" """Test getting system version."""
mock_result = MagicMock() mock_result = MagicMock()
@@ -421,7 +421,7 @@ class TestSystemAPI:
data = json.loads(response.data) data = json.loads(response.data)
assert 'version' in data.get('data', {}) or 'version' in data assert 'version' in data.get('data', {}) or 'version' in data
@patch('web_interface.blueprints.api_v3.system.subprocess') @patch('web_interface.blueprints.api_v3.subprocess')
def test_execute_system_action(self, mock_subprocess, client): def test_execute_system_action(self, mock_subprocess, client):
"""Test executing system action.""" """Test executing system action."""
mock_result = MagicMock() mock_result = MagicMock()
@@ -502,7 +502,7 @@ class TestDisplayAPI:
if response.status_code in [200, 201]: if response.status_code in [200, 201]:
assert api_v3.cache_manager.set.called assert api_v3.cache_manager.set.called
@patch('web_interface.blueprints.api_v3.display._ensure_cache_manager') @patch('web_interface.blueprints.api_v3._ensure_cache_manager')
def test_stop_on_demand_display(self, mock_ensure_cache, client): def test_stop_on_demand_display(self, mock_ensure_cache, client):
"""Test stopping on-demand display.""" """Test stopping on-demand display."""
+5 -59
View File
@@ -114,62 +114,9 @@ class TestHandlersCarryDetail:
""" """
import ast import ast
# api_v3 is a package; the routes are spread across its modules. src = open("web_interface/blueprints/api_v3.py").read()
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) 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): def logs_a_traceback(handler):
"""An error/exception-level log call carrying exc_info.""" """An error/exception-level log call carrying exc_info."""
@@ -214,12 +161,11 @@ class TestHandlersCarryDetail:
offenders = [] offenders = []
for h in [n for n in ast.walk(tree) if isinstance(n, ast.ExceptHandler)]: for h in [n for n in ast.walk(tree) if isinstance(n, ast.ExceptHandler)]:
if not returns_5xx(h): seg = ast.get_source_segment(src, h) or ""
continue if generic not in seg:
if enclosing_function(h) in PRE_EXISTING:
continue continue
missing = [] missing = []
if not logs_a_traceback(h) and not only_catches_importerror(h): if not logs_a_traceback(h):
missing.append("error-level log with exc_info") missing.append("error-level log with exc_info")
if not returns_the_detail(h): if not returns_the_detail(h):
missing.append("describe_exception(e) in the response") missing.append("describe_exception(e) in the response")
-199
View File
@@ -1,199 +0,0 @@
"""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
@@ -140,41 +140,6 @@ class TestRequestValidation:
assert restore.call_args[0][2].restore_config is True assert restore.call_args[0][2].restore_config is True
class TestOptionsAreBooleanAware:
"""Regression: bool("false") is True in Python.
Every restore flag used bare bool() coercion, so a caller that sends its
options as JSON strings rather than real booleans -- a form field, a
hand-built request -- had `{"restore_secrets": "false"}` restore secrets
anyway, the opposite of what was asked. Fixed with the same
string-aware `_coerce_to_bool` already used for checkbox-style config
fields elsewhere in this package (config.py, plugins.py).
"""
@pytest.mark.parametrize("raw,expected", [
("false", False), ("False", False), ("FALSE", False),
("0", False),
("true", True), ("True", True), ("1", True),
])
def test_string_valued_flags_are_parsed_not_just_truthy(
self, client, restore, raw, expected):
post(client, options=json.dumps({"restore_secrets": raw}))
assert restore.call_args[0][2].restore_secrets is expected
def test_a_string_false_does_not_restore_secrets(self, client, restore):
# The exact shape of the bug: a truthy non-empty string coerced by
# bare bool() to True regardless of its contents.
post(client, options=json.dumps({"restore_secrets": "false"}))
assert restore.call_args[0][2].restore_secrets is False
def test_real_json_booleans_still_work(self, client, restore):
post(client, options=json.dumps({"restore_secrets": False,
"restore_config": True}))
options = restore.call_args[0][2]
assert options.restore_secrets is False
assert options.restore_config is True
class TestSuccess: class TestSuccess:
def test_success_returns_the_result(self, client, restore): def test_success_returns_the_result(self, client, restore):
restore.return_value = FakeResult(success=True, restored=["config", "secrets"]) restore.return_value = FakeResult(success=True, restored=["config", "secrets"])
@@ -265,7 +230,7 @@ class TestPluginReinstall:
def test_missing_store_manager_is_reported_per_plugin(self, client, restore): def test_missing_store_manager_is_reported_per_plugin(self, client, restore):
restore.return_value = FakeResult(plugins_to_install=[{"plugin_id": "clock"}]) restore.return_value = FakeResult(plugins_to_install=[{"plugin_id": "clock"}])
api_v3.plugin_store_manager = None api_v3.plugin_store_manager = None
with patch("web_interface.blueprints.api_v3.backup.plugin_store_manager", None): with patch("web_interface.blueprints.api_v3.plugin_store_manager", None):
body = post(client).get_json() body = post(client).get_json()
assert body["data"]["plugins_failed"][0]["error"] == "Store manager unavailable" assert body["data"]["plugins_failed"][0]["error"] == "Store manager unavailable"
@@ -77,10 +77,7 @@ class TestTheRoutesExistAtAll:
oauth = Path(project_root) / 'web_interface/static/v3/js/widgets/google-oauth.js' 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/list-calendars' in picker.read_text(encoding='utf-8')
assert '/api/v3/plugins/calendar/authenticate' in oauth.read_text(encoding='utf-8') assert '/api/v3/plugins/calendar/authenticate' in oauth.read_text(encoding='utf-8')
# api_v3 is a package now, so the route strings are spread across its source = (Path(project_root) / 'web_interface/blueprints/api_v3.py').read_text(encoding='utf-8')
# 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/list-calendars'" in source
assert "'/plugins/calendar/authenticate'" in source assert "'/plugins/calendar/authenticate'" in source
@@ -363,22 +360,6 @@ class TestDiagnosticsAreRedacted:
assert 'hunter2' not in error, error assert 'hunter2' not in error, error
assert '<redacted>' in error, error assert '<redacted>' in error, error
def test_script_stderr_is_redacted_in_the_log_too(self, tmp_path, caplog):
# Regression: the return value went through redact_text (asserted
# above), but the logger.error call right next to it logged `raw`
# verbatim -- a script that handles OAuth client secrets and can
# quote them in its stderr, landing unredacted in the log (CWE-532).
script = tmp_path / 'calendar_registration.py'
script.write_text(
'import sys\n'
'sys.stderr.write("boom client_secret=hunter2 more\\n")\n',
encoding='utf-8')
with caplog.at_level('ERROR', logger=mod.logger.name):
mod._run_calendar_registration(tmp_path, '')
logged = '\n'.join(r.getMessage() for r in caplog.records)
assert 'hunter2' not in logged, logged
assert '<redacted>' in logged, logged
def test_a_failing_script_payload_is_redacted(self, client): def test_a_failing_script_payload_is_redacted(self, client):
(client.plugin_dir / 'credentials.json').write_text('{}', encoding='utf-8') (client.plugin_dir / 'credentials.json').write_text('{}', encoding='utf-8')
(client.plugin_dir / 'calendar_registration.py').write_text( (client.plugin_dir / 'calendar_registration.py').write_text(
@@ -10,8 +10,8 @@ from pathlib import Path
import pytest import pytest
API_V3_PKG = (Path(__file__).resolve().parents[2] SOURCE = (Path(__file__).resolve().parents[2]
/ "web_interface" / "blueprints" / "api_v3") / "web_interface" / "blueprints" / "api_v3.py")
#: Objects that still hold submitted secret values at the point these log #: Objects that still hold submitted secret values at the point these log
#: calls run. Interpolating one whole into a log message leaks credentials. #: 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(): def _logging_lines():
for number, line in enumerate("\n".join(p.read_text(encoding="utf-8") for p in sorted(API_V3_PKG.glob("*.py"))).splitlines(), 1): for number, line in enumerate(SOURCE.read_text(encoding="utf-8").splitlines(), 1):
stripped = line.strip() stripped = line.strip()
if stripped.startswith("#"): if stripped.startswith("#"):
continue continue
@@ -15,18 +15,8 @@ from pathlib import Path
from src.web_interface.secret_helpers import find_secret_fields, separate_secrets from src.web_interface.secret_helpers import find_secret_fields, separate_secrets
API_V3_PKG = (Path(__file__).resolve().parents[2] API_V3_PATH = (Path(__file__).resolve().parents[2]
/ "web_interface" / "blueprints" / "api_v3") / "web_interface" / "blueprints" / "api_v3.py")
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. # The migration is complete: any inline reimplementation is a regression.
EXPECTED_INLINE_COPIES = 0 EXPECTED_INLINE_COPIES = 0
@@ -34,7 +24,7 @@ EXPECTED_INLINE_COPIES = 0
class TestNoInlineCopies: class TestNoInlineCopies:
def _count(self, name: str) -> int: def _count(self, name: str) -> int:
source = _api_v3_source() source = API_V3_PATH.read_text(encoding="utf-8")
return len(re.findall(rf"^\s*def {name}\(", source, flags=re.MULTILINE)) return len(re.findall(rf"^\s*def {name}\(", source, flags=re.MULTILINE))
def test_no_inline_find_secret_fields(self): def test_no_inline_find_secret_fields(self):
@@ -56,7 +46,7 @@ class TestNoInlineCopies:
def test_canonical_import_present(self): def test_canonical_import_present(self):
# Tripwire: the endpoints still need the helpers, so removing the # Tripwire: the endpoints still need the helpers, so removing the
# import means either dead secret handling or a new local copy. # import means either dead secret handling or a new local copy.
source = _api_v3_source() source = API_V3_PATH.read_text(encoding="utf-8")
assert re.search( assert re.search(
r"from src\.web_interface\.secret_helpers import .*find_secret_fields", r"from src\.web_interface\.secret_helpers import .*find_secret_fields",
source, source,
@@ -1,876 +0,0 @@
"""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'
class TestManifestLockPreventsLostUpdates:
"""The standalone manifest fallback (no plugin instance loaded) reads,
mutates and writes manifest.json with no coordination across requests.
Each write is atomic on its own (temp file + rename), but two concurrent
read-modify-write cycles can still race: both read the same starting
manifest, and the second write silently discards whatever the first one
added. _starlark_manifest_lock closes that window -- mirrors
StarlarkAppsPlugin._update_manifest_safe, which already does this when
the plugin instance is loaded.
"""
@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')
module._write_starlark_manifest({'apps': {}})
return apps_dir
def test_two_concurrent_updates_are_both_kept(self, starlark_dir):
import threading
import time as _time
from web_interface.blueprints import api_v3 as module
def add_app(app_id):
with module._starlark_manifest_lock():
manifest = module._read_starlark_manifest()
# Widen the window between read and write. Without the lock
# both threads read here before either writes, and whichever
# writes second overwrites the other's addition; with the
# lock, the second thread cannot even start its read until
# the first has written and released.
_time.sleep(0.05)
manifest.setdefault('apps', {})[app_id] = {'enabled': True}
module._write_starlark_manifest(manifest)
threads = [threading.Thread(target=add_app, args=(app_id,))
for app_id in ('a', 'b')]
for t in threads:
t.start()
for t in threads:
t.join(timeout=5)
manifest = module._read_starlark_manifest()
assert set(manifest['apps']) == {'a', 'b'}, (
"a concurrent update was lost: %r" % (manifest,))
def test_the_lock_is_reentrant_safe_across_sequential_calls(self, starlark_dir):
"""Not reentrant within one thread -- just that using it twice in a
row (the ordinary case: one request, then the next) works cleanly
and does not leak the lock file descriptor or leave it locked."""
from web_interface.blueprints import api_v3 as module
for app_id in ('first', 'second'):
with module._starlark_manifest_lock():
manifest = module._read_starlark_manifest()
manifest.setdefault('apps', {})[app_id] = {'enabled': True}
module._write_starlark_manifest(manifest)
manifest = module._read_starlark_manifest()
assert set(manifest['apps']) == {'first', 'second'}
class TestConfigAndManifestStayInSync:
"""Standalone-mode PUT /starlark/apps/<id>/config (no plugin instance
loaded) writes config.json and then the manifest. If the manifest write
fails after config.json was already written, the two disagree about
what was saved unless config.json is rolled back.
"""
@pytest.fixture
def app_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')
one_app_dir = apps_dir / 'demo'
one_app_dir.mkdir()
module._write_starlark_manifest({'apps': {'demo': {'name': 'Demo', 'enabled': True}}})
return one_app_dir
def test_manifest_write_failure_rolls_back_an_existing_config_json(self, client, app_dir):
config_file = app_dir / 'config.json'
config_file.write_text(json.dumps({'existing': 'value'}))
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \
patch('web_interface.blueprints.api_v3._write_starlark_manifest', return_value=False):
resp = client.put('/api/v3/starlark/apps/demo/config',
json={'new_field': 'x'})
assert resp.status_code == 500
assert json.loads(config_file.read_text()) == {'existing': 'value'}, (
"config.json kept the new value even though the manifest write "
"that was supposed to follow it failed")
def test_manifest_write_failure_removes_a_freshly_created_config_json(self, client, app_dir):
config_file = app_dir / 'config.json'
assert not config_file.exists()
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \
patch('web_interface.blueprints.api_v3._write_starlark_manifest', return_value=False):
resp = client.put('/api/v3/starlark/apps/demo/config',
json={'new_field': 'x'})
assert resp.status_code == 500
assert not config_file.exists(), (
"config.json was left behind even though the manifest write "
"that was supposed to follow it failed")
def test_success_updates_both_config_and_manifest(self, client, app_dir):
from web_interface.blueprints import api_v3 as module
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None):
resp = client.put('/api/v3/starlark/apps/demo/config',
json={'new_field': 'x'})
assert resp.status_code == 200, resp.get_json()
assert json.loads((app_dir / 'config.json').read_text())['new_field'] == 'x'
manifest = json.loads(module._STARLARK_MANIFEST_FILE.read_text())
assert manifest['apps']['demo']['config']['new_field'] == 'x'
# ---------------------------------------------------------------------------
# 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,12 +19,7 @@ import re
from pathlib import Path from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2] PROJECT_ROOT = Path(__file__).resolve().parents[2]
# api_v3 is a package; a sudo systemctl call can live in any of its modules. API_V3 = PROJECT_ROOT / "web_interface" / "blueprints" / "api_v3.py"
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" SUDOERS_SCRIPT = PROJECT_ROOT / "scripts" / "install" / "configure_web_sudo.sh"
@@ -56,7 +51,7 @@ def _granted_systemctl_rules(script: str) -> set[tuple[str, str]]:
def test_every_sudo_systemctl_call_is_granted() -> None: def test_every_sudo_systemctl_call_is_granted() -> None:
calls = _sudo_systemctl_calls(_api_v3_source()) calls = _sudo_systemctl_calls(API_V3.read_text())
rules = _granted_systemctl_rules(SUDOERS_SCRIPT.read_text()) rules = _granted_systemctl_rules(SUDOERS_SCRIPT.read_text())
assert calls, "expected to find sudo systemctl calls in api_v3.py" assert calls, "expected to find sudo systemctl calls in api_v3.py"
@@ -73,7 +68,7 @@ def test_every_sudo_systemctl_call_is_granted() -> None:
def test_units_are_fully_qualified() -> None: def test_units_are_fully_qualified() -> None:
"""Privileged systemctl calls must name the unit as <name>.service so they """Privileged systemctl calls must name the unit as <name>.service so they
match the sudoers grants, which use the fully-qualified unit name.""" match the sudoers grants, which use the fully-qualified unit name."""
calls = _sudo_systemctl_calls(_api_v3_source()) calls = _sudo_systemctl_calls(API_V3.read_text())
unqualified = {(v, u) for v, u in calls if not u.endswith(".service")} unqualified = {(v, u) for v, u in calls if not u.endswith(".service")}
assert not unqualified, ( assert not unqualified, (
"sudo systemctl calls must use fully-qualified .service unit names: " "sudo systemctl calls must use fully-qualified .service unit names: "
+8 -1
View File
@@ -83,11 +83,11 @@ plugin_system_config = config.get('plugin_system', {})
plugins_dir_name = plugin_system_config.get('plugins_directory', 'plugin-repos') plugins_dir_name = plugin_system_config.get('plugins_directory', 'plugin-repos')
# Resolve plugin directory - handle both absolute and relative paths # Resolve plugin directory - handle both absolute and relative paths
project_root = Path(__file__).parent.parent
if os.path.isabs(plugins_dir_name): if os.path.isabs(plugins_dir_name):
plugins_dir = Path(plugins_dir_name) plugins_dir = Path(plugins_dir_name)
else: else:
# If relative, resolve relative to the project root (LEDMatrix directory) # If relative, resolve relative to the project root (LEDMatrix directory)
project_root = Path(__file__).parent.parent
plugins_dir = project_root / plugins_dir_name plugins_dir = project_root / plugins_dir_name
plugin_manager = PluginManager( plugin_manager = PluginManager(
@@ -157,6 +157,7 @@ _health_monitor_initialized = False
# Register blueprints # Register blueprints
from web_interface.blueprints.pages_v3 import pages_v3 from web_interface.blueprints.pages_v3 import pages_v3
from web_interface.blueprints.api_v3 import api_v3 from web_interface.blueprints.api_v3 import api_v3
from web_interface.blueprints.composer import composer_bp
# Initialize managers in blueprints # Initialize managers in blueprints
pages_v3.config_manager = config_manager pages_v3.config_manager = config_manager
@@ -173,6 +174,11 @@ api_v3.operation_queue = operation_queue
api_v3.plugin_state_manager = plugin_state_manager api_v3.plugin_state_manager = plugin_state_manager
api_v3.operation_history = operation_history api_v3.operation_history = operation_history
api_v3.health_monitor = health_monitor api_v3.health_monitor = health_monitor
composer_bp.config_manager = config_manager
composer_bp.plugin_manager = plugin_manager
composer_bp.plugins_dir = plugins_dir
composer_bp.project_root = project_root
# Initialize cache manager for API endpoints # Initialize cache manager for API endpoints
from src.cache_manager import CacheManager from src.cache_manager import CacheManager
api_v3.cache_manager = CacheManager() api_v3.cache_manager = CacheManager()
@@ -200,6 +206,7 @@ except Exception as _hm_err: # pragma: no cover - defensive startup guard
app.register_blueprint(pages_v3, url_prefix='') app.register_blueprint(pages_v3, url_prefix='')
app.register_blueprint(pages_v3, url_prefix='/v3', name='pages_v3_legacy') app.register_blueprint(pages_v3, url_prefix='/v3', name='pages_v3_legacy')
app.register_blueprint(api_v3, url_prefix='/api/v3') app.register_blueprint(api_v3, url_prefix='/api/v3')
app.register_blueprint(composer_bp, url_prefix='/composer')
# Route to serve plugin asset files (registered on main app, not blueprint, for /assets/... path) # Route to serve plugin asset files (registered on main app, not blueprint, for /assets/... path)
@app.route('/assets/plugins/<plugin_id>/uploads/<path:filename>', methods=['GET']) @app.route('/assets/plugins/<plugin_id>/uploads/<path:filename>', methods=['GET'])
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-216
View File
@@ -1,216 +0,0 @@
"""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, _coerce_to_bool, _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
# _coerce_to_bool (not bare bool()) because a request can send these
# as JSON strings: bool("false") is True in Python, so a caller who
# explicitly asked to skip secrets would have had them restored
# anyway.
options = RestoreOptions(
restore_config=_coerce_to_bool(opts_dict.get('restore_config', True)),
restore_secrets=_coerce_to_bool(opts_dict.get('restore_secrets', True)),
restore_wifi=_coerce_to_bool(opts_dict.get('restore_wifi', True)),
restore_fonts=_coerce_to_bool(opts_dict.get('restore_fonts', True)),
restore_plugin_uploads=_coerce_to_bool(opts_dict.get('restore_plugin_uploads', True)),
reinstall_plugins=_coerce_to_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
@@ -1,326 +0,0 @@
"""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 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
-467
View File
@@ -1,467 +0,0 @@
"""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. validate_file_upload takes max_size_mb but only
# checks the filename/extension with it -- it never looks at the
# actual upload size, so the size limit below is enforced separately
# before the file is saved (same pattern as the .star upload above).
MAX_FONT_SIZE_MB = 10
is_valid, error_msg = validate_file_upload(
font_file.filename,
max_size_mb=MAX_FONT_SIZE_MB,
allowed_extensions=['.ttf', '.otf', '.bdf']
)
if not is_valid:
return jsonify({'status': 'error', 'message': error_msg}), 400
# Check file size (stated limit is MAX_FONT_SIZE_MB)
font_file.seek(0, 2) # Seek to end
file_size = font_file.tell()
font_file.seek(0) # Reset to beginning
max_font_size_bytes = MAX_FONT_SIZE_MB * 1024 * 1024
if file_size > max_font_size_bytes:
return jsonify({
'status': 'error',
'message': f'File too large (max {MAX_FONT_SIZE_MB}MB, got {file_size / 1024 / 1024:.1f}MB)'
}), 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
@@ -1,496 +0,0 @@
"""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

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