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
ChuckClaude Opus 5coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
696acdbc7b feat(render_plugin): add --display-mode so multi-mode plugins can be rendered (#522)
* feat(render_plugin): add --display-mode so multi-mode plugins can be rendered

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

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

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

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

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

* Only fall back when plugin display rejects display_mode

---------

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

Five regression tests, including the production shape: a class whose __module__
is absent from sys.modules still resolves via its declared directory, and the
precondition that the MRO walk alone returns None is pinned so the test keeps
meaning something if the fallback changes.
2026-09-03 17:01:30 -04:00
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
19 changed files with 6716 additions and 11 deletions
+23 -1
View File
@@ -52,6 +52,10 @@ def main() -> int:
parser.add_argument('--height', type=int, default=32, help='Display height (default: 32)') parser.add_argument('--height', type=int, default=32, help='Display height (default: 32)')
parser.add_argument('--skip-update', action='store_true', parser.add_argument('--skip-update', action='store_true',
help='Skip calling update() (render display only)') help='Skip calling update() (render display only)')
parser.add_argument('--display-mode', default=None,
help='Display mode to render, for plugins that declare '
'more than one in their manifest (e.g. nrl_live). '
'Omitted, the plugin picks its own default.')
args = parser.parse_args() args = parser.parse_args()
@@ -141,8 +145,26 @@ def main() -> int:
except Exception as e: except Exception as e:
logger.warning("update() raised: %s — continuing to display()", e) logger.warning("update() raised: %s — continuing to display()", e)
# A plugin that declares several display modes usually renders nothing
# useful without being told which one to draw: the scoreboards keep their
# state on per-mode sub-managers and their no-argument path returns False.
# Only pass the argument when asked for, so the many plugins whose display()
# takes no display_mode keep working untouched.
try: try:
plugin_instance.display(force_clear=True) if args.display_mode:
try:
plugin_instance.display(display_mode=args.display_mode,
force_clear=True)
except TypeError as error:
if ("unexpected keyword argument" not in str(error)
or "display_mode" not in str(error)):
raise
logger.warning(
"%s.display() does not accept display_mode; rendering its "
"default screen instead", args.plugin)
plugin_instance.display(force_clear=True)
else:
plugin_instance.display(force_clear=True)
logger.debug("display() completed") logger.debug("display() completed")
except Exception as e: except Exception as e:
logger.error("Error in display(): %s", e) logger.error("Error in display(): %s", e)
+11 -1
View File
@@ -17,10 +17,13 @@ deliberate difference is `crisp_size`, which takes the seven-plugin guard
the extra guard only stops a None size raising TypeError. the extra guard only stops a None size raising TypeError.
""" """
import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Dict, Optional, Tuple from typing import Any, Dict, Optional, Tuple
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
logger = logging.getLogger(__name__)
__all__ = [ __all__ = [
"ELEMENT_FOR_FONT", "FAVORITE_RESULT_COLOR_DEFAULTS", "FONT_NAME_ALIASES", "ELEMENT_FOR_FONT", "FAVORITE_RESULT_COLOR_DEFAULTS", "FONT_NAME_ALIASES",
"FONT_PIXEL_GRID", "MONTH_ABBR", "WEEKDAY_ABBR", "FONT_PIXEL_GRID", "MONTH_ABBR", "WEEKDAY_ABBR",
@@ -395,7 +398,14 @@ def schema_font_size(schema_path: str, element_key) -> Optional[int]:
size = spec.get('properties', {}).get('font_size', {}).get('default') size = spec.get('properties', {}).get('font_size', {}).get('default')
if size is not None: if size is not None:
cache[key] = int(size) cache[key] = int(size)
except Exception: except Exception as exc:
# See sports_shared._schema_font_size: an unreadable schema
# silently disables the pixel-grid snap for every element.
# Built once per schema path, so this cannot repeat per frame.
logger.warning(
"could not read %s (%s: %s); font sizes will skip their "
"pixel grid snap and may render a pixel narrow",
schema_path, type(exc).__name__, exc)
cache = {} cache = {}
_SCHEMA_FONT_SIZE_CACHE[schema_path] = cache _SCHEMA_FONT_SIZE_CACHE[schema_path] = cache
return cache.get(element_key) return cache.get(element_key)
+59 -8
View File
@@ -211,17 +211,52 @@ class SportsCoreSharedMixin:
return desired return desired
return max(grid, int(round(float(desired) / grid)) * grid) return max(grid, int(round(float(desired) / grid)) * grid)
#: Absolute path of this plugin's directory, declared by the plugin
#: itself. The mixin cannot work it out -- see _plugin_dir.
_PLUGIN_DIR: ClassVar[Optional[str]] = None
def _plugin_dir(self) -> Optional[str]: def _plugin_dir(self) -> Optional[str]:
"""Directory of the plugin that defines this class. """Directory of the plugin that owns this instance.
In sports.py these methods could just use ``__file__``. Here that is In sports.py these methods could just use ``__file__``. Here that is
src/common/, so the plugin's own directory has to be recovered from the src/common/, so the directory has to come from the plugin.
instance. ``type(self).__module__`` alone is not enough: SportsCore is
an ABC, so a subclass built with ``type(name, bases, ns)`` -- which the It is TOLD, not deduced. The first version walked the MRO for a class
plugins' own tests do -- reports its module as "abc". Walking the MRO whose module sits beside a config_schema.json. That works when a test
steps past those synthetic classes to the first one whose module sits imports the plugin itself, and returns None under the real loader, for
next to a config_schema.json, which is the real plugin. a specific reason worth recording:
PluginLoader._namespace_plugin_modules renames every bare module a
plugin brought in (sports, game_renderer, ...) to
"_plg_<plugin_id>_<module>" and REMOVES the bare entry, so two
plugins owning a module of the same name cannot collide.
The class still reports ``__module__ == "sports"``, but
``sys.modules["sports"]`` no longer exists, so the walk finds no
__file__ and falls off the end. The failure was silent and expensive:
_plugin_dir() -> None
_schema_font_size() -> None for every element
-> a configured size equal to the schema default stops looking
like a default and is treated as a deliberate user choice
-> the snap to the font's pixel grid is skipped
-> 4x6-font.ttf renders at 6 instead of 7: 3px-wide glyphs
instead of 4px
On a 256x64 panel that made the odds, the team records and the date row
hard to read. It was found by a user counting pixels on the panel. No
gate here caught it: the tests imported plugins directly and the
safety harness loads them its own way, so neither reproduced the
loader's renaming.
The MRO walk stays as a fallback for hosts that declare no
_PLUGIN_DIR -- the plugins' own probe harnesses build classes with
``type()`` -- but it is no longer the primary answer.
""" """
declared = getattr(self, "_PLUGIN_DIR", None)
if declared and os.path.isfile(os.path.join(declared, "config_schema.json")):
return declared
for cls in type(self).__mro__: for cls in type(self).__mro__:
module = sys.modules.get(getattr(cls, "__module__", ""), None) module = sys.modules.get(getattr(cls, "__module__", ""), None)
path = getattr(module, "__file__", None) path = getattr(module, "__file__", None)
@@ -253,7 +288,23 @@ class SportsCoreSharedMixin:
size = spec.get('properties', {}).get('font_size', {}).get('default') size = spec.get('properties', {}).get('font_size', {}).get('default')
if size is not None: if size is not None:
cache[key] = int(size) cache[key] = int(size)
except Exception: except Exception as exc:
# Say so. An unreadable schema is not cosmetic: every element's
# configured size then stops matching "the schema default", is
# treated as a deliberate user choice, and skips the snap to the
# font's pixel grid -- which renders 4x6-font.ttf at 6 instead
# of 7, a 3px-wide glyph instead of 4px. That shipped once,
# silently, and was found by a user counting pixels on a photo
# of the panel.
#
# Logged, not raised: a missing schema must not stop a plugin
# rendering. The cache is built once per class, so this cannot
# repeat per frame.
logger.warning(
"%s: could not read config_schema.json (%s: %s); every font "
"size will be treated as user-chosen and will skip its pixel "
"grid snap. Font sizes may render a pixel narrow.",
type(self).__name__, type(exc).__name__, exc)
cache = {} cache = {}
self.__class__._SCHEMA_FONT_SIZES = cache self.__class__._SCHEMA_FONT_SIZES = cache
return cache.get(element_key) return cache.get(element_key)
+6
View File
@@ -364,6 +364,7 @@ class DisplayManager:
# Create image with the (logical) display dimensions # Create image with the (logical) display dimensions
self.image = Image.new('RGB', (self.matrix.width, self.matrix.height)) self.image = Image.new('RGB', (self.matrix.width, self.matrix.height))
self.draw = ImageDraw.Draw(self.image) self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
logger.info(f"Image canvas created with dimensions: {self.matrix.width}x{self.matrix.height}") logger.info(f"Image canvas created with dimensions: {self.matrix.width}x{self.matrix.height}")
# Initialize font with Press Start 2P # Initialize font with Press Start 2P
@@ -403,6 +404,7 @@ class DisplayManager:
self.image = Image.new('RGB', (fallback_width, fallback_height)) self.image = Image.new('RGB', (fallback_width, fallback_height))
self.draw = ImageDraw.Draw(self.image) self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
# Simple fallback visualization so web UI shows a realistic canvas # Simple fallback visualization so web UI shows a realistic canvas
try: try:
self.draw.rectangle([0, 0, fallback_width - 1, fallback_height - 1], outline=(255, 0, 0)) self.draw.rectangle([0, 0, fallback_width - 1, fallback_height - 1], outline=(255, 0, 0))
@@ -705,6 +707,7 @@ class DisplayManager:
# self.image, so swapping the buffer below is enough on its own. # self.image, so swapping the buffer below is enough on its own.
self.image = Image.new('RGB', (target_w, target_h)) self.image = Image.new('RGB', (target_w, target_h))
self.draw = ImageDraw.Draw(self.image) self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
yield yield
finally: finally:
self.matrix = real_matrix self.matrix = real_matrix
@@ -814,6 +817,7 @@ class DisplayManager:
self.image = Image.new('RGB', (width, height)) self.image = Image.new('RGB', (width, height))
self.draw = ImageDraw.Draw(self.image) self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
logger.debug("Cleared display in fallback mode") logger.debug("Cleared display in fallback mode")
return return
@@ -825,6 +829,7 @@ class DisplayManager:
# Create a new black image # Create a new black image
self.image = Image.new('RGB', (self.matrix.width, self.matrix.height)) self.image = Image.new('RGB', (self.matrix.width, self.matrix.height))
self.draw = ImageDraw.Draw(self.image) self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
if not self._capture_mode_active: if not self._capture_mode_active:
# Clear both canvases and the underlying matrix to ensure no artifacts. # Clear both canvases and the underlying matrix to ensure no artifacts.
@@ -1258,6 +1263,7 @@ class DisplayManager:
try: try:
self.image = Image.new('RGB', (self.width, self.height)) self.image = Image.new('RGB', (self.width, self.height))
self.draw = ImageDraw.Draw(self.image) self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
except (OSError, RuntimeError, ValueError, MemoryError): except (OSError, RuntimeError, ValueError, MemoryError):
logger.debug("Canvas reset during cleanup failed", exc_info=True) logger.debug("Canvas reset during cleanup failed", exc_info=True)
# Reset the singleton state when cleaning up # Reset the singleton state when cleaning up
@@ -70,6 +70,7 @@ class VisualTestDisplayManager:
# Canvas # Canvas
self.image = Image.new('RGB', (width, height), (0, 0, 0)) self.image = Image.new('RGB', (width, height), (0, 0, 0))
self.draw = ImageDraw.Draw(self.image) self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows.
# Matrix proxy (plugins access display_manager.matrix.width/height) # Matrix proxy (plugins access display_manager.matrix.width/height)
self.matrix = _MatrixProxy(width, height) self.matrix = _MatrixProxy(width, height)
@@ -184,6 +185,7 @@ class VisualTestDisplayManager:
self.clear_called = True self.clear_called = True
self.image = Image.new('RGB', (self._width, self._height), (0, 0, 0)) self.image = Image.new('RGB', (self._width, self._height), (0, 0, 0))
self.draw = ImageDraw.Draw(self.image) self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows.
def update_display(self): def update_display(self):
"""No-op for hardware; marks that display was updated.""" """No-op for hardware; marks that display was updated."""
@@ -211,6 +213,7 @@ class VisualTestDisplayManager:
self.matrix = _MatrixProxy(target_w, target_h) self.matrix = _MatrixProxy(target_w, target_h)
self.image = Image.new('RGB', (target_w, target_h), (0, 0, 0)) self.image = Image.new('RGB', (target_w, target_h), (0, 0, 0))
self.draw = ImageDraw.Draw(self.image) self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows.
yield yield
finally: finally:
self._width, self._height = prev_w, prev_h self._width, self._height = prev_w, prev_h
@@ -569,6 +572,7 @@ class VisualTestDisplayManager:
self.draw_calls = [] self.draw_calls = []
self.image = Image.new('RGB', (self._width, self._height), (0, 0, 0)) self.image = Image.new('RGB', (self._width, self._height), (0, 0, 0))
self.draw = ImageDraw.Draw(self.image) self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows.
self._scrolling_state = { self._scrolling_state = {
'is_scrolling': False, 'is_scrolling': False,
'last_scroll_activity': 0, 'last_scroll_activity': 0,
@@ -582,3 +586,4 @@ class VisualTestDisplayManager:
"""Clean up resources.""" """Clean up resources."""
self.image = Image.new('RGB', (self._width, self._height), (0, 0, 0)) self.image = Image.new('RGB', (self._width, self._height), (0, 0, 0))
self.draw = ImageDraw.Draw(self.image) self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows.
+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"
@@ -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}"
+55
View File
@@ -319,3 +319,58 @@ class TestLiveMixin:
assert not hasattr(h, "_empty_live_streak") assert not hasattr(h, "_empty_live_streak")
h._note_live_fetch(False) h._note_live_fetch(False)
assert h._empty_live_streak == 1 assert h._empty_live_streak == 1
class TestPluginDirIsToldNotDeduced:
"""The regression that shipped: _plugin_dir returned None in production.
The first version walked the MRO for a class whose module sits beside a
config_schema.json. That passes when a test imports the plugin directly --
which is how it was verified -- and returns None under the real plugin
loader, which imports modules by a path that leaves no such entry on the
MRO.
Silent, and expensive: no schema means _schema_font_size returns None for
every element, so a configured size equal to the schema default stops
looking like a default, is treated as a deliberate choice, and skips the
grid snap. 4x6-font.ttf then renders at 6 rather than 7 -- 3px-wide glyphs
instead of 4px. On a 256x64 panel that made the odds, the records and the
date row illegible. A user counted the pixels; no gate here caught it.
"""
def test_a_declared_directory_is_used(self, tmp_path):
d = _write_plugin(tmp_path, "declared")
host = type("H", (SportsCoreSharedMixin,), {"_PLUGIN_DIR": str(d)})()
assert host._plugin_dir() == str(d)
def test_it_works_when_no_module_on_the_mro_helps(self, tmp_path, monkeypatch):
"""The production case: nothing on the MRO sits beside a schema."""
d = _write_plugin(tmp_path, "loaderstyle")
# A class whose module is not importable by name, as the loader produces.
cls = type("Loaded", (SportsCoreSharedMixin,), {"_PLUGIN_DIR": str(d)})
cls.__module__ = "a.module.name.that.is.not.in.sys.modules"
assert cls.__new__(cls)._plugin_dir() == str(d), (
"the declared directory must win when the MRO walk cannot help")
def test_without_it_the_mro_walk_would_have_failed(self):
# Pin the precondition, so this test still means something if the
# fallback is ever changed.
cls = type("Orphan", (SportsCoreSharedMixin,), {})
cls.__module__ = "not.a.real.module"
assert cls.__new__(cls)._plugin_dir() is None
def test_a_declared_directory_without_a_schema_is_not_trusted(self, tmp_path):
# A stale or wrong path must not shadow the fallback.
empty = tmp_path / "noschema"
empty.mkdir()
d = _write_plugin(tmp_path, "realone")
monkey = type("H", (SportsCoreSharedMixin,), {"_PLUGIN_DIR": str(empty)})
assert monkey.__new__(monkey)._plugin_dir() != str(empty)
def test_the_font_size_consequence(self, tmp_path):
"""End to end: a declared directory restores the schema lookup."""
d = _write_plugin(tmp_path, "sizeconseq")
host = type("H", (SportsCoreSharedMixin,), {"_PLUGIN_DIR": str(d)})()
assert host._schema_font_size("score") == 16, (
"without the schema this is None, which is what made a configured "
"size look user-chosen and skipped the grid snap")
+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
+3
View File
@@ -426,6 +426,9 @@ a, button, input, select, textarea {
.md\:hidden { display: none; } .md\:hidden { display: none; }
.md\:block { display: block; } .md\:block { display: block; }
.md\:w-auto { width: auto; } .md\:w-auto { width: auto; }
/* composer.html labels its toolbar buttons `hidden md:inline`, so without
this the label is hidden at every width and the buttons stay icon-only. */
.md\:inline { display: inline; }
} }
@media (min-width: 1024px) { @media (min-width: 1024px) {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,817 @@
/**
* ComposerCanvas — stateless LED matrix canvas renderer.
*
* Coordinate system: LED pixels (integers). All drawing multiplies by SCALE.
* PIL draw.text(x,y) is top-left; canvas fillText(x,y) is baseline.
* → Canvas text cy = (actualY + fontSizePx) * SCALE
*
* Anchors: element x/y are offsets from their anchor point:
* xAnchor=null/'left' → x is fixed offset from left
* xAnchor='center' → x is offset from width/2
* xAnchor='right' → x is offset inward from right edge
* yAnchor follows the same pattern with 'top'/'middle'/'bottom'
*
* Breakpoints: elements with minWidth > currentMatrixW are rendered at 25% opacity.
*
* Resize handles: drawn on selected rectangles; 8 handles (corners + edge mids).
*/
/* eslint-disable security/detect-object-injection --
Every hit in this file is either an array index (this.elements[idx],
rawVals[i]) or a lookup on a module-private map keyed by an internal
element type (_HANDLE_CURSORS, ELEMENT_DEFAULTS, previewValues). None of
them takes an attacker-supplied property name, so none can reach a
prototype. Disabled per file rather than eight times inline. */
window.ComposerCanvas = (() => {
'use strict';
let _canvas = null;
let _ctx = null;
let _showGrid = true;
//: Element types the canvas draws resize handles for. Exported because the
//: editor has to gate its resize and hover behaviour on exactly this list --
//: the two had drifted, so handles appeared on five shapes that could not
//: actually be resized.
const RESIZABLE_TYPES = ['rectangle', 'rounded_rectangle', 'ellipse', 'arc',
'gauge', 'sparkline'];
const DISPLAY_PRESETS = [
{ label: '64×32', w: 64, h: 32 },
{ label: '128×32', w: 128, h: 32 },
{ label: '128×64', w: 128, h: 64 },
{ label: '256×32', w: 256, h: 32 },
{ label: '256×64', w: 256, h: 64 },
];
const FONT_MAP = {
press_start: { family: "'PressStart2P', monospace", sizePx: 8, charW: 8 },
four_by_six: { family: 'monospace', sizePx: 6, charW: 4 },
five_by_seven: { family: 'monospace', sizePx: 7, charW: 5 },
};
const ELEMENT_DEFAULTS = {
text: {
text: 'Hello', font: 'press_start',
r: 255, g: 255, b: 255,
text2: '', lineSpacing: 2, textAlign: 'left',
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
dynamic_text: {
binding: { source: 'config', key: '', format: null },
font: 'press_start', textAlign: 'left',
r: 255, g: 200, b: 100,
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
clock: {
format: '%H:%M', font: 'press_start',
r: 100, g: 255, b: 100,
format2: '', lineSpacing: 2, textAlign: 'left',
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
rectangle: {
width: 20, height: 8,
fillR: 0, fillG: 0, fillB: 128, hasFill: true,
outR: 255, outG: 255, outB: 255, hasOutline: true,
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
ellipse: {
width: 24, height: 12,
fillR: 0, fillG: 100, fillB: 200, hasFill: true,
outR: 100, outG: 180, outB: 255, hasOutline: true,
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
arc: {
width: 24, height: 24,
startAngle: 0, endAngle: 270, lineWidth: 2,
r: 255, g: 200, b: 0,
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
pixel: {
r: 255, g: 255, b: 255,
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
rounded_rectangle: {
width: 24, height: 10, borderRadius: 3,
fillR: 0, fillG: 80, fillB: 180, hasFill: true,
outR: 120, outG: 180, outB: 255, hasOutline: true,
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
line: {
x0: 0, y0: 16, x1: 63, y1: 16,
r: 180, g: 180, b: 180, lineWidth: 1,
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
divider: {
orientation: 'horizontal', y: 16, x: 64,
r: 100, g: 100, b: 100,
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
progress_bar: {
barWidth: 60, barHeight: 6,
binding: { source: 'config', key: '', format: null },
r: 80, g: 200, b: 80,
bgR: 30, bgG: 30, bgB: 30, hasBg: true,
outR: 100, outG: 100, outB: 100, hasOutline: true,
previewPct: 65,
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
countdown: {
binding: { source: 'config', key: '', format: null },
countdownFormat: 'dh',
font: 'four_by_six', textAlign: 'left',
r: 255, g: 180, b: 0,
previewText: '42d 3h',
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
marquee: {
text: 'Scrolling text', font: 'press_start',
r: 255, g: 255, b: 255,
scrollSpeed: 1, gap: 16, direction: 'left',
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
section: {
label: 'Section',
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
pips: {
count: 5, filled: 3, pipSize: 4, pipSpacing: 2,
r: 255, g: 200, b: 0,
emptyR: 50, emptyG: 50, emptyB: 50, showEmpty: true,
binding: { source: 'config', key: '', format: null },
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
sparkline: {
width: 40, height: 12,
barCount: 8, barSpacing: 1,
r: 80, g: 200, b: 120,
bgR: 30, bgG: 30, bgB: 30, hasBg: false,
binding: { source: 'config', key: '', format: null },
previewData: '0.3,0.6,0.4,0.8,0.5,0.9,0.7,0.85',
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
gauge: {
width: 32, height: 32,
startAngle: 135, endAngle: 45, lineWidth: 3,
binding: { source: 'config', key: '', format: null },
r: 80, g: 220, b: 80,
trackR: 40, trackG: 40, trackB: 40, hasTrack: true,
showLabel: true, font: 'four_by_six', labelR: 200, labelG: 200, labelB: 200,
previewPct: 65,
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
};
// ── Anchor resolution ────────────────────────────────────────────────
function resolveAnchor(val, anchor, dim) {
if (!anchor || anchor === 'left' || anchor === 'top') return val;
if (anchor === 'center' || anchor === 'middle') return Math.floor(dim / 2) + val;
if (anchor === 'right' || anchor === 'bottom') return dim - val;
return val;
}
function computeActualPos(el, matrixW, matrixH) {
const ax = resolveAnchor(el.x ?? el.x0 ?? 0, el.xAnchor, matrixW);
const ay = resolveAnchor(el.y ?? el.y0 ?? 0, el.yAnchor, matrixH);
return { x: ax, y: ay };
}
// ── Bounding box (LED pixel space) ──────────────────────────────────
function getBoundingBox(el, matrixW, matrixH) {
const { x: ax, y: ay } = computeActualPos(el, matrixW, matrixH);
const finfo = FONT_MAP[el.font] || FONT_MAP.press_start;
switch (el.type) {
case 'text': {
const t1 = el.text || '', t2 = el.text2 || '';
const w = Math.max(t1.length, t2.length) * finfo.charW;
const h = t2 ? finfo.sizePx * 2 + (el.lineSpacing ?? 2) : finfo.sizePx;
const bx = el.textAlign === 'center' ? ax - w / 2 : el.textAlign === 'right' ? ax - w : ax;
return { x: bx, y: ay, w, h };
}
case 'dynamic_text': {
const key = el.binding?.key || '?';
const w = (`{${key}}`).length * finfo.charW;
const bx = el.textAlign === 'center' ? ax - w / 2 : el.textAlign === 'right' ? ax - w : ax;
return { x: bx, y: ay, w, h: finfo.sizePx };
}
case 'clock': {
const t1 = el.format || '%H:%M', t2 = el.format2 || '';
const w = Math.max(t1.length, t2.length) * finfo.charW;
const h = t2 ? finfo.sizePx * 2 + (el.lineSpacing ?? 2) : finfo.sizePx;
const bx = el.textAlign === 'center' ? ax - w / 2 : el.textAlign === 'right' ? ax - w : ax;
return { x: bx, y: ay, w, h };
}
case 'countdown': {
const pt = el.previewText || '--d --h';
const w = pt.length * finfo.charW;
const bx = el.textAlign === 'center' ? ax - w / 2 : el.textAlign === 'right' ? ax - w : ax;
return { x: bx, y: ay, w, h: finfo.sizePx };
}
case 'rectangle':
case 'rounded_rectangle':
case 'ellipse':
case 'arc':
return { x: ax, y: ay, w: el.width, h: el.height };
case 'pixel':
return { x: ax, y: ay, w: 1, h: 1 };
case 'line': {
// Same anchor offset the draw path applies, or the hit box sits where
// the line used to be.
const dx = ax - el.x0, dy = ay - el.y0;
return {
x: Math.min(el.x0, el.x1) + dx, y: Math.min(el.y0, el.y1) + dy,
w: Math.max(1, Math.abs(el.x1 - el.x0)),
h: Math.max(1, Math.abs(el.y1 - el.y0)),
};
}
case 'divider':
return el.orientation === 'horizontal'
? { x: 0, y: ay, w: matrixW, h: 1 }
: { x: ax, y: 0, w: 1, h: matrixH };
case 'progress_bar':
return { x: ax, y: ay, w: el.barWidth ?? 60, h: el.barHeight ?? 6 };
case 'marquee': {
const mfinfo = FONT_MAP[el.font] || FONT_MAP.press_start;
return { x: 0, y: ay, w: matrixW, h: mfinfo.sizePx };
}
case 'gauge':
return { x: ax, y: ay, w: el.width ?? 32, h: el.height ?? 32 };
case 'sparkline':
return { x: ax, y: ay, w: el.width ?? 40, h: el.height ?? 12 };
case 'pips': {
const pc = el.count ?? 5, ps = el.pipSize ?? 4, pg = el.pipSpacing ?? 2;
return { x: ax, y: ay, w: pc * ps + (pc - 1) * pg, h: ps };
}
case 'section': {
// Was 0x0, so the element was unselectable except through the 3px
// hit-test padding and drew nothing at all -- a user adding one from
// the palette saw an empty canvas.
// Same font resolution as the draw case below, or the box will not
// match the glyphs: getBoundingBox's shared `finfo` falls back to
// press_start, and a section has no font of its own.
const sinfo = FONT_MAP[el.font] || FONT_MAP.four_by_six;
const label = el.label || 'Section';
return { x: ax, y: ay, w: label.length * sinfo.charW, h: sinfo.sizePx };
}
default:
return { x: ax, y: ay, w: 4, h: 4 };
}
}
// ── Resize handle support ─────────────────────────────────────────────
// Returns 8 handle points for a rectangle in LED pixel space
function _getRectHandles(el, matrixW, matrixH) {
const { x: ax, y: ay } = computeActualPos(el, matrixW, matrixH);
const w = el.width, h = el.height;
const cx = ax + w / 2, cy = ay + h / 2;
return {
nw: { x: ax, y: ay },
n: { x: cx, y: ay },
ne: { x: ax + w, y: ay },
w: { x: ax, y: cy },
e: { x: ax + w, y: cy },
sw: { x: ax, y: ay + h },
s: { x: cx, y: ay + h },
se: { x: ax + w, y: ay + h },
};
}
// Returns the handle direction under LED-space point (lx, ly), or null
function getResizeHandle(el, lx, ly, matrixW, matrixH) {
if (!RESIZABLE_TYPES.includes(el.type)) return null;
const handles = _getRectHandles(el, matrixW, matrixH);
const PAD = 4;
for (const [dir, pt] of Object.entries(handles)) {
if (Math.abs(lx - pt.x) <= PAD && Math.abs(ly - pt.y) <= PAD) return dir;
}
return null;
}
const _HANDLE_CURSORS = {
nw: 'nw-resize', n: 'n-resize', ne: 'ne-resize',
w: 'w-resize', e: 'e-resize',
sw: 'sw-resize', s: 's-resize', se: 'se-resize',
};
function getCursorForHandle(handle) {
return _HANDLE_CURSORS[handle] || 'crosshair';
}
// ── Hit test ─────────────────────────────────────────────────────────
function hitTest(el, lx, ly, matrixW, matrixH) {
const PAD = 3;
const bb = getBoundingBox(el, matrixW, matrixH);
return (
lx >= bb.x - PAD && lx <= bb.x + bb.w + PAD &&
ly >= bb.y - PAD && ly <= bb.y + bb.h + PAD
);
}
// ── Draw a single element ─────────────────────────────────────────────
function _drawElement(ctx, el, SCALE, matrixW, matrixH, opts = {}) {
const s = SCALE;
const { x: ax, y: ay } = computeActualPos(el, matrixW, matrixH);
const belowBreakpoint = el.minWidth > 0 && matrixW < el.minWidth;
const hidden = el.visible === false;
ctx.save();
if (hidden) ctx.globalAlpha = 0.12;
else if (belowBreakpoint) ctx.globalAlpha = 0.25;
// Blink animation: when blinkOff, fully hide blinking elements
if (el.blink) {
if (opts.blinkOff) { ctx.restore(); return; }
ctx.globalAlpha *= 0.55;
}
// Helper: compute draw X for text alignment
// biome-ignore lint/correctness/useQwikValidLexicalScope: not Qwik -- this is an Alpine.js component, and the rule is about Qwik's $() serialization boundary, which does not exist here.
const _textX = (text, finfo) => {
const tw = text.length * finfo.charW * s;
if (el.textAlign === 'center') return ax * s - tw / 2;
if (el.textAlign === 'right') return ax * s - tw;
return ax * s;
};
try {
switch (el.type) {
case 'section': {
// A design-time label: it marks a region for the author and is not
// emitted into the generated plugin. There was no case here at all,
// so adding "Section Label" from the palette drew nothing and left
// the user with an apparently broken control.
const sfinfo = FONT_MAP[el.font] || FONT_MAP.four_by_six;
ctx.font = `${sfinfo.sizePx * s}px ${sfinfo.family}`;
ctx.fillStyle = `rgba(${el.r ?? 120},${el.g ?? 120},${el.b ?? 120},0.85)`;
ctx.textBaseline = 'top';
ctx.fillText(el.label || 'Section', ax * s, ay * s);
break;
}
case 'text':
case 'dynamic_text':
case 'clock': {
const finfo = FONT_MAP[el.font] || FONT_MAP.press_start;
const key = el.binding?.key || '?';
const pv = opts.previewValues?.[key];
// Substitute {variable} tokens in text using previewValues
// biome-ignore lint/correctness/useQwikValidLexicalScope: not Qwik -- this is an Alpine.js component, and the rule is about Qwik's $() serialization boundary, which does not exist here.
const _subVars = str => (str || '').replace(/\{(\w+)\}/g, (_, k) => {
const v = opts.previewValues?.[k];
return v !== undefined && v !== '' ? String(v) : `{${k}}`;
});
const displayText =
el.type === 'text' ? _subVars(el.text || '')
: el.type === 'clock' ? (el.format || '%H:%M')
: (pv !== undefined && pv !== '' ? String(pv) : `{${key}}`);
ctx.font = `${finfo.sizePx * s}px ${finfo.family}`;
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
ctx.fillText(displayText, _textX(displayText, finfo), (ay + finfo.sizePx) * s);
// Second line (text and clock)
if (el.type === 'text' && el.text2) {
const t2 = _subVars(el.text2);
const y2 = ay + finfo.sizePx + (el.lineSpacing ?? 2);
ctx.fillText(t2, _textX(t2, finfo), (y2 + finfo.sizePx) * s);
}
if (el.type === 'clock' && el.format2) {
const y2 = ay + finfo.sizePx + (el.lineSpacing ?? 2);
ctx.fillText(el.format2, _textX(el.format2, finfo), (y2 + finfo.sizePx) * s);
}
break;
}
case 'countdown': {
const finfo = FONT_MAP[el.font] || FONT_MAP.press_start;
const t = el.previewText || '--d --h';
ctx.font = `${finfo.sizePx * s}px ${finfo.family}`;
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
ctx.fillText(t, _textX(t, finfo), (ay + finfo.sizePx) * s);
break;
}
case 'rectangle': {
const rx = ax * s, ry = ay * s;
const rw = el.width * s, rh = el.height * s;
if (el.hasFill) {
ctx.fillStyle = `rgb(${el.fillR},${el.fillG},${el.fillB})`;
ctx.fillRect(rx, ry, rw, rh);
}
if (el.hasOutline) {
ctx.strokeStyle = `rgb(${el.outR},${el.outG},${el.outB})`;
// 1 LED pixel, not 1 canvas pixel: at SCALE>1 an unscaled stroke
// renders thinner than the geometry it outlines.
ctx.lineWidth = s;
ctx.strokeRect(rx, ry, rw, rh);
}
break;
}
case 'ellipse': {
const cx = (ax + el.width / 2) * s;
const cy = (ay + el.height / 2) * s;
const lwPx = s; // 1 LED pixel
// Inset by half the stroke, which straddles the path, so the outline
// stays within the element's bounds.
const rx = Math.max(0, (el.width / 2) * s - (el.hasOutline ? lwPx / 2 : 0));
const ry = Math.max(0, (el.height / 2) * s - (el.hasOutline ? lwPx / 2 : 0));
ctx.beginPath();
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
if (el.hasFill) {
ctx.fillStyle = `rgb(${el.fillR},${el.fillG},${el.fillB})`;
ctx.fill();
}
if (el.hasOutline) {
ctx.strokeStyle = `rgb(${el.outR},${el.outG},${el.outB})`;
ctx.lineWidth = lwPx;
ctx.stroke();
}
break;
}
case 'arc': {
const cx = (ax + el.width / 2) * s;
const cy = (ay + el.height / 2) * s;
const lwPx = Math.max(1, el.lineWidth || 2) * s;
const rx = Math.max(0, (el.width / 2) * s - lwPx / 2);
const ry = Math.max(0, (el.height / 2) * s - lwPx / 2);
// PIL: 0°=right, clockwise. Canvas: same with anticlockwise=false
const startRad = (el.startAngle ?? 0) * Math.PI / 180;
const endRad = (el.endAngle ?? 270) * Math.PI / 180;
ctx.beginPath();
ctx.ellipse(cx, cy, rx, ry, 0, startRad, endRad, false);
ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`;
ctx.lineWidth = lwPx;
ctx.stroke();
break;
}
case 'pixel': {
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
ctx.fillRect(ax * s, ay * s, s, s);
break;
}
case 'rounded_rectangle': {
const rx = ax * s, ry = ay * s;
const rw = el.width * s, rh = el.height * s;
const rad = Math.min((el.borderRadius ?? 3) * s, rw / 2, rh / 2);
ctx.beginPath();
ctx.roundRect(rx, ry, rw, rh, rad);
if (el.hasFill) {
ctx.fillStyle = `rgb(${el.fillR},${el.fillG},${el.fillB})`;
ctx.fill();
}
if (el.hasOutline) {
ctx.strokeStyle = `rgb(${el.outR},${el.outG},${el.outB})`;
ctx.lineWidth = s;
ctx.stroke();
}
break;
}
case 'line': {
// ax/ay resolve from el.x0 for a line, so this is the anchor offset.
// Without it a line stayed put while every other type moved.
const dx = ax - el.x0, dy = ay - el.y0;
ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`;
ctx.lineWidth = Math.max(1, el.lineWidth || 1) * s;
ctx.beginPath();
ctx.moveTo((el.x0 + dx) * s, (el.y0 + dy) * s);
ctx.lineTo((el.x1 + dx) * s, (el.y1 + dy) * s);
ctx.stroke();
break;
}
case 'divider': {
const isH = (el.orientation || 'horizontal') === 'horizontal';
ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`;
ctx.lineWidth = s;
ctx.beginPath();
if (isH) {
ctx.moveTo(0, (ay + 0.5) * s);
ctx.lineTo(_canvas.width, (ay + 0.5) * s);
} else {
ctx.moveTo((ax + 0.5) * s, 0);
ctx.lineTo((ax + 0.5) * s, _canvas.height);
}
ctx.stroke();
break;
}
case 'pips': {
const pipCount = Math.max(1, el.count ?? 5);
const pvPips = opts.previewValues?.[el.binding?.key];
const filledN = pvPips !== undefined
? Math.max(0, Math.min(pipCount, Math.round(parseFloat(pvPips) || 0)))
: Math.max(0, Math.min(pipCount, el.filled ?? 3));
const ps = Math.max(1, el.pipSize ?? 4);
const pg = Math.max(0, el.pipSpacing ?? 2);
for (let i = 0; i < pipCount; i++) {
const isFilled = i < filledN;
if (!isFilled && !el.showEmpty) continue;
ctx.fillStyle = isFilled
? `rgb(${el.r},${el.g},${el.b})`
: `rgb(${el.emptyR ?? 50},${el.emptyG ?? 50},${el.emptyB ?? 50})`;
ctx.fillRect((ax + i * (ps + pg)) * s, ay * s, ps * s, ps * s);
}
break;
}
case 'sparkline': {
const slW = el.width ?? 40, slH = el.height ?? 12;
const count = Math.max(1, el.barCount ?? 8);
const spacing = el.barSpacing ?? 1;
const barW = Math.max(1, Math.floor((slW - spacing * (count - 1)) / count));
const rawVals = (el.previewData || '').split(',')
.map(v => parseFloat(v.trim())).filter(n => !isNaN(n));
while (rawVals.length < count) rawVals.push(0);
const maxV = Math.max(...rawVals.slice(0, count), 0.001);
const rx = ax * s, ry = ay * s;
if (el.hasBg) {
ctx.fillStyle = `rgb(${el.bgR ?? 30},${el.bgG ?? 30},${el.bgB ?? 30})`;
ctx.fillRect(rx, ry, slW * s, slH * s);
}
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
for (let i = 0; i < count; i++) {
const norm = Math.max(0, Math.min(1, rawVals[i] / maxV));
const barH = Math.max(1, Math.round(slH * norm));
const bx = rx + (barW + spacing) * i * s;
const by = ry + (slH - barH) * s;
ctx.fillRect(bx, by, barW * s, barH * s);
}
break;
}
case 'gauge': {
const gw = (el.width ?? 32), gh = (el.height ?? 32);
const cx = (ax + gw / 2) * s, cy = (ay + gh / 2) * s;
const rx = (gw / 2) * s, ry = (gh / 2) * s;
const lw = Math.max(1, (el.lineWidth ?? 3));
// rx/ry are canvas pixels ((gw/2)*s) but lw is LED pixels, so
// insetting by lw/2 under-corrected by the scale factor while the
// stroke was drawn at lw*s -- the arc spilled outside the element's
// reported bounding box at any SCALE > 1, and the preview stopped
// matching the generated PIL output.
const lwPx = lw * s;
const startDeg = el.startAngle ?? 135;
const endDeg = el.endAngle ?? 45;
// Arc sweep: from startDeg clockwise to endDeg (PIL convention)
const totalSweep = ((endDeg - startDeg) + 360) % 360 || 360;
const pvGauge = opts.previewValues?.[el.binding?.key];
const pct = pvGauge !== undefined
? Math.max(0, Math.min(100, parseFloat(pvGauge) || 0)) / 100
: Math.max(0, Math.min(100, el.previewPct ?? 65)) / 100;
const fillSweep = totalSweep * pct;
// A small imported gauge with a wide lineWidth would otherwise send
// a negative radius into ctx.ellipse(), which throws IndexSizeError
// and aborts the whole render() call.
const arx = Math.max(0, rx - lwPx / 2), ary = Math.max(0, ry - lwPx / 2);
// biome-ignore lint/correctness/useQwikValidLexicalScope: not Qwik -- this is an Alpine.js component, and the rule is about Qwik's $() serialization boundary, which does not exist here.
const toRad = deg => (deg - 90) * Math.PI / 180; // canvas 0=top, PIL 0=right → offset -90
// Track arc
if (el.hasTrack !== false) {
ctx.beginPath();
ctx.ellipse(cx, cy, arx, ary, 0, toRad(startDeg), toRad(startDeg + totalSweep), false);
ctx.strokeStyle = `rgb(${el.trackR ?? 40},${el.trackG ?? 40},${el.trackB ?? 40})`;
ctx.lineWidth = lwPx;
ctx.stroke();
}
// Fill arc
if (pct > 0) {
ctx.beginPath();
ctx.ellipse(cx, cy, arx, ary, 0, toRad(startDeg), toRad(startDeg + fillSweep), false);
ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`;
ctx.lineWidth = lwPx;
ctx.stroke();
}
// Centre label
if (el.showLabel) {
const gfinfo = FONT_MAP[el.font || 'four_by_six'] || FONT_MAP.four_by_six;
const labelText = Math.round(pct * 100) + '%';
ctx.font = `${gfinfo.sizePx * s}px ${gfinfo.family}`;
ctx.fillStyle = `rgb(${el.labelR ?? 200},${el.labelG ?? 200},${el.labelB ?? 200})`;
const ltw = ctx.measureText(labelText).width;
ctx.fillText(labelText, cx - ltw / 2, cy + (gfinfo.sizePx * s) / 2);
}
break;
}
case 'marquee': {
const finfo = FONT_MAP[el.font] || FONT_MAP.press_start;
const text = el.text || 'Scrolling text';
const tw = text.length * finfo.charW * s;
const gap = (el.gap ?? 16) * s;
const totalW = tw + gap;
const tick = opts.animTick ?? 0;
const speed = (el.scrollSpeed ?? 1) * 2;
const scrolled = (tick * speed) % totalW;
// left: text enters from right; right: text enters from left
const startX = el.direction === 'right'
? scrolled - tw
: matrixW * s - scrolled;
ctx.font = `${finfo.sizePx * s}px ${finfo.family}`;
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
// Clip to canvas width so text doesn't bleed outside
ctx.save();
ctx.beginPath();
ctx.rect(0, ay * s - 1, matrixW * s, (finfo.sizePx + 2) * s);
ctx.clip();
for (let i = -1; i <= 2; i++) {
ctx.fillText(text, startX + i * totalW, (ay + finfo.sizePx) * s);
}
ctx.restore();
break;
}
case 'progress_bar': {
const bw = el.barWidth ?? 60, bh = el.barHeight ?? 6;
const pvPb = opts.previewValues?.[el.binding?.key];
const pct = pvPb !== undefined
? Math.max(0, Math.min(100, parseFloat(pvPb) || 0)) / 100
: Math.max(0, Math.min(100, el.previewPct ?? 65)) / 100;
const rx = ax * s, ry = ay * s;
if (el.hasBg) {
ctx.fillStyle = `rgb(${el.bgR ?? 30},${el.bgG ?? 30},${el.bgB ?? 30})`;
ctx.fillRect(rx, ry, bw * s, bh * s);
}
const fillW = Math.max(0, Math.round(bw * pct));
if (fillW > 0) {
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
ctx.fillRect(rx, ry, fillW * s, bh * s);
}
if (el.hasOutline) {
ctx.strokeStyle = `rgb(${el.outR ?? 100},${el.outG ?? 100},${el.outB ?? 100})`;
ctx.lineWidth = s;
ctx.strokeRect(rx, ry, bw * s, bh * s);
}
break;
}
}
if (belowBreakpoint) {
ctx.globalAlpha = 0.6;
const bb = getBoundingBox(el, matrixW, matrixH);
ctx.font = `${Math.max(8, s * 2)}px monospace`;
ctx.fillStyle = '#facc15';
ctx.fillText(`≥${el.minWidth}px`, bb.x * s, (bb.y + 4) * s);
}
} finally {
ctx.restore();
}
}
// ── Selection indicator ──────────────────────────────────────────────
function _drawSelection(ctx, el, SCALE, matrixW, matrixH) {
const bb = getBoundingBox(el, matrixW, matrixH);
const PAD = 2, s = SCALE;
const rx = bb.x * s - PAD, ry = bb.y * s - PAD;
const rw = bb.w * s + PAD * 2, rh = bb.h * s + PAD * 2;
ctx.save();
ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = 1;
ctx.setLineDash([3, 2]);
ctx.strokeRect(rx, ry, rw, rh);
ctx.setLineDash([]);
if (el.xAnchor || el.yAnchor) {
ctx.font = `${Math.max(7, s)}px sans-serif`;
ctx.fillStyle = '#a78bfa';
const anchorText = [
el.xAnchor ? `x:${el.xAnchor[0]}` : '',
el.yAnchor ? `y:${el.yAnchor[0]}` : '',
].filter(Boolean).join(' ');
if (anchorText) ctx.fillText(anchorText, rx + 1, ry - 2);
}
// Resize handles: on rect, rounded rect, ellipse
if (RESIZABLE_TYPES.includes(el.type)) {
const handles = _getRectHandles(el, matrixW, matrixH);
const HS = 5;
ctx.fillStyle = 'white';
ctx.strokeStyle = '#2563eb';
ctx.lineWidth = 1;
for (const pt of Object.values(handles)) {
const hx = pt.x * s - HS / 2;
const hy = pt.y * s - HS / 2;
ctx.fillRect(hx, hy, HS, HS);
ctx.strokeRect(hx, hy, HS, HS);
}
} else {
// Corner dots for non-rectangle elements
ctx.fillStyle = '#3b82f6';
const HS = 4;
for (const [hx, hy] of [
[rx - HS / 2, ry - HS / 2], [rx + rw - HS / 2, ry - HS / 2],
[rx - HS / 2, ry + rh - HS / 2], [rx + rw - HS / 2, ry + rh - HS / 2],
]) ctx.fillRect(hx, hy, HS, HS);
}
ctx.restore();
}
// ── Dimension tooltip while dragging ─────────────────────────────────
function drawDragTooltip(ctx, el, SCALE, matrixW, matrixH) {
const bb = getBoundingBox(el, matrixW, matrixH);
const label = el.type === 'rectangle'
? `${el.width}×${el.height}`
: `${bb.x},${bb.y}`;
const s = SCALE;
ctx.save();
ctx.font = `${Math.max(9, s * 1.5)}px monospace`;
const tw = ctx.measureText(label).width;
const tx = bb.x * s, ty = (bb.y - 2) * s;
ctx.fillStyle = 'rgba(0,0,0,0.7)';
ctx.fillRect(tx - 2, ty - 10, tw + 4, 12);
ctx.fillStyle = 'white';
ctx.fillText(label, tx, ty);
ctx.restore();
}
// ── Public API ───────────────────────────────────────────────────────
function init(canvasEl) {
_canvas = canvasEl;
_ctx = canvasEl.getContext('2d');
}
function setGrid(show) { _showGrid = show; }
function updateCanvasSize(matrixW, matrixH, SCALE) {
if (!_canvas) return;
_canvas.width = matrixW * SCALE;
_canvas.height = matrixH * SCALE;
}
function render(elements, selectedId, matrixW, matrixH, SCALE, opts = {}) {
if (!_ctx) return;
const cW = matrixW * SCALE, cH = matrixH * SCALE;
const bg = opts.bgColor;
_ctx.fillStyle = bg ? `rgb(${bg.r},${bg.g},${bg.b})` : '#000';
_ctx.fillRect(0, 0, cW, cH);
if (_showGrid) {
_ctx.strokeStyle = 'rgba(255,255,255,0.07)';
_ctx.lineWidth = 0.5;
for (let x = SCALE; x < cW; x += SCALE) {
_ctx.beginPath(); _ctx.moveTo(x, 0); _ctx.lineTo(x, cH); _ctx.stroke();
}
for (let y = SCALE; y < cH; y += SCALE) {
_ctx.beginPath(); _ctx.moveTo(0, y); _ctx.lineTo(cW, y); _ctx.stroke();
}
}
for (const el of elements) _drawElement(_ctx, el, SCALE, matrixW, matrixH, opts);
if (opts.showRuler) {
_ctx.save();
_ctx.fillStyle = 'rgba(255,255,255,0.08)';
_ctx.fillRect(0, 0, cW, SCALE); // top strip
_ctx.fillRect(0, 0, SCALE, cH); // left strip
_ctx.strokeStyle = 'rgba(255,255,255,0.5)';
_ctx.fillStyle = 'rgba(255,255,255,0.6)';
_ctx.font = `${Math.max(5, SCALE - 1)}px monospace`;
const step = SCALE >= 4 ? 8 : 16;
for (let px = 0; px <= matrixW; px += step) {
const cx = px * SCALE;
const major = px % 32 === 0;
_ctx.lineWidth = 0.5;
_ctx.beginPath(); _ctx.moveTo(cx, 0); _ctx.lineTo(cx, major ? SCALE : SCALE * 0.5); _ctx.stroke();
if (major && px > 0 && px < matrixW - 4) _ctx.fillText(String(px), cx + 1, SCALE - 1);
}
for (let py = 0; py <= matrixH; py += step) {
const cy = py * SCALE;
const major = py % 32 === 0;
_ctx.beginPath(); _ctx.moveTo(0, cy); _ctx.lineTo(major ? SCALE : SCALE * 0.5, cy); _ctx.stroke();
if (major && py > 0 && py < matrixH - 4) _ctx.fillText(String(py), 1, cy + SCALE - 1);
}
_ctx.restore();
}
if (opts.showGuides) {
_ctx.save();
_ctx.strokeStyle = 'rgba(255,60,60,0.45)';
_ctx.lineWidth = 1;
_ctx.setLineDash([4, 3]);
const mx = Math.floor(cW / 2) + 0.5;
const my = Math.floor(cH / 2) + 0.5;
_ctx.beginPath(); _ctx.moveTo(mx, 0); _ctx.lineTo(mx, cH); _ctx.stroke();
_ctx.beginPath(); _ctx.moveTo(0, my); _ctx.lineTo(cW, my); _ctx.stroke();
_ctx.setLineDash([]);
_ctx.restore();
}
const sel = selectedId != null ? elements.find(e => e.id === selectedId) : null;
if (sel) {
_drawSelection(_ctx, sel, SCALE, matrixW, matrixH);
if (opts.showTooltip) drawDragTooltip(_ctx, sel, SCALE, matrixW, matrixH);
}
}
return {
init, render, setGrid, updateCanvasSize,
hitTest, getBoundingBox, computeActualPos, resolveAnchor,
getResizeHandle, getCursorForHandle,
ELEMENT_DEFAULTS, FONT_MAP, DISPLAY_PRESETS, RESIZABLE_TYPES,
};
})();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,302 @@
"""
{{ plugin_name }} — LEDMatrix Plugin
Generated by LEDMatrix Plugin Composer on {{ generated_date }}
Extension points:
update() → add HTTP/MQTT data-fetching logic here
_get_display_values() → map fetched data to display strings
display() → add new elements or adapt layout per display size
"""
from src.plugin_system.base_plugin import BasePlugin
{% if has_clock or has_countdown %}
from datetime import datetime
{% endif %}
{% if has_blink %}
import time
{% endif %}
{% if has_text_template %}
from collections import defaultdict
{% endif %}
class {{ class_name }}(BasePlugin):
def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_manager):
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
{% for var in config_vars %}
self.{{ var.key }} = config.get({{ var.key | tojson }}, {{ var.default | tojson }})
{% endfor %}
# Live data cache — populated by update(); always {} in static layouts
self._data = {}
def update(self):
"""Fetch and refresh display data.
For dynamic plugins: fetch from APIs/MQTT here and store in self._data.
_get_display_values() will read self._data to produce display strings.
"""
# --- Data sources (add fetch logic here for dynamic plugins) ---
pass
def _get_display_values(self):
"""Map config variables and live data to display-ready strings.
This is the single extension point for v2 data sources:
add self._data lookups here once update() populates them.
"""
return {
{% for var in config_vars %}
{{ var.key | tojson }}: str(self.{{ var.key }}),
{% endfor %}
}
def display(self, force_clear=False):
try:
{% if has_text_template %}
values = defaultdict(str, self._get_display_values())
{% else %}
values = self._get_display_values()
{% endif %}
if force_clear:
self.display_manager.clear()
width = self.display_manager.width
height = self.display_manager.height
{% if bg_color %}
self.display_manager.draw.rectangle([0, 0, width, height], fill={{ bg_color }})
{% endif %}
# ── Elements (rendered bottom to top) ──────────────────────────
{% for el in elements %}
{% set p = " " if el.min_width > 0 else " " %}
{% set pi = (p + " ") if el.blink else p %}
{% if el.min_width > 0 %}
if width >= {{ el.min_width }}: # breakpoint: {{ el.min_width }}px+ displays only
{% endif %}
{% if el.blink %}
{{ p }}if int(time.time() * 2) % 2:
{% endif %}
{% if el.type == 'text' %}
{{ pi }}self.display_manager.draw_text(
{% if el.text_is_template %}
{{ pi }} {{ el.text | tojson }}.format_map(values),
{% else %}
{{ pi }} {{ el.text | tojson }},
{% endif %}
{{ pi }} x={{ el.x_expr }}, y={{ el.y_expr }},
{{ pi }} color={{ el.rgb_tuple }},
{{ pi }} font=self.display_manager.{{ el.font_attr }},
{{ pi }})
{% if el.text2 %}
{{ pi }}self.display_manager.draw_text(
{% if el.text_is_template %}
{{ pi }} {{ el.text2 | tojson }}.format_map(values),
{% else %}
{{ pi }} {{ el.text2 | tojson }},
{% endif %}
{{ pi }} x={{ el.x2_expr }}, y={{ el.y2_expr }},
{{ pi }} color={{ el.rgb_tuple }},
{{ pi }} font=self.display_manager.{{ el.font_attr }},
{{ pi }})
{% endif %}
{% elif el.type == 'dynamic_text' %}
{% if el.binding_source == 'config' %}
{{ pi }}self.display_manager.draw_text(
{{ pi }} values.get({{ el.binding_key | tojson }}, ''),
{{ pi }} x={{ el.x_expr }}, y={{ el.y_expr }},
{{ pi }} color={{ el.rgb_tuple }},
{{ pi }} font=self.display_manager.{{ el.font_attr }},
{{ pi }})
{% else %}
{{ pi }}pass # non-config dynamic_text binding draws nothing
{% endif %}
{% elif el.type == 'clock' %}
{{ pi }}self.display_manager.draw_text(
{{ pi }} datetime.now().strftime({{ el.format | tojson }}),
{{ pi }} x={{ el.x_expr }}, y={{ el.y_expr }},
{{ pi }} color={{ el.rgb_tuple }},
{{ pi }} font=self.display_manager.{{ el.font_attr }},
{{ pi }})
{% if el.format2 %}
{{ pi }}self.display_manager.draw_text(
{{ pi }} datetime.now().strftime({{ el.format2 | tojson }}),
{{ pi }} x={{ el.x2_expr }}, y={{ el.y2_expr }},
{{ pi }} color={{ el.rgb_tuple }},
{{ pi }} font=self.display_manager.{{ el.font_attr }},
{{ pi }})
{% endif %}
{% elif el.type == 'countdown' %}
{{ pi }}_cd_target = float(values.get({{ el.binding_key | tojson }}, 0) or 0)
{{ pi }}_cd_secs = max(0.0, _cd_target - datetime.now().timestamp())
{% if el.countdown_format == 'dhms' %}
{{ pi }}_cd_d, _cd_rem = divmod(int(_cd_secs), 86400)
{{ pi }}_cd_h, _cd_rem = divmod(_cd_rem, 3600)
{{ pi }}_cd_m, _cd_s = divmod(_cd_rem, 60)
{{ pi }}_cd_str = f'{_cd_d}d {_cd_h:02d}:{_cd_m:02d}:{_cd_s:02d}'
{% elif el.countdown_format == 'hms' %}
{{ pi }}_cd_h, _cd_rem = divmod(int(_cd_secs), 3600)
{{ pi }}_cd_m, _cd_s = divmod(_cd_rem, 60)
{{ pi }}_cd_str = f'{_cd_h}h {_cd_m:02d}:{_cd_s:02d}'
{% elif el.countdown_format == 'dhm' %}
{{ pi }}_cd_d, _cd_rem = divmod(int(_cd_secs), 86400)
{{ pi }}_cd_h, _cd_m = divmod(_cd_rem // 60, 60)
{{ pi }}_cd_str = f'{_cd_d}d {_cd_h:02d}h {_cd_m:02d}m'
{% else %}
{{ pi }}_cd_d, _cd_rem = divmod(int(_cd_secs), 86400)
{{ pi }}_cd_h = _cd_rem // 3600
{{ pi }}_cd_str = f'{_cd_d}d {_cd_h}h'
{% endif %}
{{ pi }}self.display_manager.draw_text(
{{ pi }} _cd_str,
{{ pi }} x={{ el.x_expr }}, y={{ el.y_expr }},
{{ pi }} color={{ el.rgb_tuple }},
{{ pi }} font=self.display_manager.{{ el.font_attr }},
{{ pi }})
{% elif el.type == 'rectangle' %}
{{ pi }}self.display_manager.draw.rectangle(
{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}],
{{ pi }} fill={{ el.fill_tuple }},
{{ pi }} outline={{ el.outline_tuple }},
{{ pi }})
{% elif el.type == 'arc' %}
{{ pi }}self.display_manager.draw.arc(
{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}],
{{ pi }} start={{ el.start_angle }}, end={{ el.end_angle }},
{{ pi }} fill={{ el.rgb_tuple }},
{{ pi }} width={{ el.line_width }},
{{ pi }})
{% elif el.type == 'ellipse' %}
{{ pi }}self.display_manager.draw.ellipse(
{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}],
{{ pi }} fill={{ el.fill_tuple }},
{{ pi }} outline={{ el.outline_tuple }},
{{ pi }})
{% elif el.type == 'pixel' %}
{{ pi }}self.display_manager.draw.point(
{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}],
{{ pi }} fill={{ el.rgb_tuple }},
{{ pi }})
{% elif el.type == 'rounded_rectangle' %}
{{ pi }}self.display_manager.draw.rounded_rectangle(
{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}],
{{ pi }} radius={{ el.border_radius }},
{{ pi }} fill={{ el.fill_tuple }},
{{ pi }} outline={{ el.outline_tuple }},
{{ pi }})
{% elif el.type in ('line', 'divider') %}
{{ pi }}self.display_manager.draw.line(
{{ pi }} [{{ el.x0_expr }}, {{ el.y0_expr }}, {{ el.x1_expr }}, {{ el.y1_expr }}],
{{ pi }} fill={{ el.rgb_tuple }},
{{ pi }} width={{ el.line_width }},
{{ pi }})
{% elif el.type == 'pips' %}
{{ pi }}_pip_filled = max(0, min({{ el.pip_count }}, int(float(values.get({{ el.binding_key | tojson }}, 0) or 0))))
{{ pi }}for _pip_i in range({{ el.pip_count }}):
{{ pi }} _pip_x = ({{ el.x_expr }}) + _pip_i * ({{ el.pip_size }} + {{ el.pip_spacing }})
{{ pi }} _pip_color = {{ el.fill_tuple }} if _pip_i < _pip_filled else {{ el.empty_tuple }}
{% if not el.show_empty %}
{{ pi }} if _pip_i >= _pip_filled:
{{ pi }} continue
{% endif %}
{{ pi }} self.display_manager.draw.rectangle(
{{ pi }} [_pip_x, {{ el.y_expr }}, _pip_x + {{ el.pip_size }} - 1, ({{ el.y_expr }}) + {{ el.pip_size }} - 1],
{{ pi }} fill=_pip_color,
{{ pi }} )
{% elif el.type == 'sparkline' %}
{{ pi }}_sl_raw = str(values.get({{ el.binding_key | tojson }}, '') or '')
{{ pi }}_sl_vals = [float(v.strip()) for v in _sl_raw.split(',') if v.strip()][:{{ el.bar_count }}]
{{ pi }}_sl_vals += [0.0] * max(0, {{ el.bar_count }} - len(_sl_vals))
{{ pi }}_sl_max = max(_sl_vals) if any(_sl_vals) else 1.0
{{ pi }}_sl_bw = max(1, ({{ el.bar_width_px }} - {{ el.bar_spacing }} * ({{ el.bar_count }} - 1)) // {{ el.bar_count }})
{% if el.bg_tuple != 'None' %}
{{ pi }}self.display_manager.draw.rectangle(
{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, ({{ el.x_expr }}) + {{ el.bar_width_px }}, ({{ el.y_expr }}) + {{ el.bar_height_px }}],
{{ pi }} fill={{ el.bg_tuple }},
{{ pi }})
{% endif %}
{{ pi }}for _sl_i, _sl_v in enumerate(_sl_vals):
{{ pi }} _sl_norm = max(0.0, min(1.0, _sl_v / (_sl_max or 1)))
{{ pi }} _sl_bh = max(1, round({{ el.bar_height_px }} * _sl_norm))
{{ pi }} _sl_bx = ({{ el.x_expr }}) + (_sl_bw + {{ el.bar_spacing }}) * _sl_i
{{ pi }} _sl_by = ({{ el.y_expr }}) + {{ el.bar_height_px }} - _sl_bh
{{ pi }} self.display_manager.draw.rectangle(
{{ pi }} [_sl_bx, _sl_by, _sl_bx + _sl_bw - 1, _sl_by + _sl_bh - 1],
{{ pi }} fill={{ el.fill_tuple }},
{{ pi }} )
{% elif el.type == 'gauge' %}
{{ pi }}_gv = max(0.0, min(100.0, float(values.get({{ el.binding_key | tojson }}, 0) or 0)))
{{ pi }}_g_total = (({{ el.end_angle }} - {{ el.start_angle }}) % 360) or 360
{{ pi }}_g_sweep = _g_total * _gv / 100.0
{% if el.track_tuple != 'None' %}
{{ pi }}self.display_manager.draw.arc(
{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}],
{{ pi }} start={{ el.start_angle }}, end={{ el.start_angle }} + _g_total,
{{ pi }} fill={{ el.track_tuple }},
{{ pi }} width={{ el.line_width }},
{{ pi }})
{% endif %}
{{ pi }}if _g_sweep > 0:
{{ pi }} self.display_manager.draw.arc(
{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}],
{{ pi }} start={{ el.start_angle }}, end={{ el.start_angle }} + _g_sweep,
{{ pi }} fill={{ el.rgb_tuple }},
{{ pi }} width={{ el.line_width }},
{{ pi }} )
{% if el.show_label %}
{{ pi }}_g_cx = ({{ el.x_expr }}) + ({{ el.x2_expr }} - ({{ el.x_expr }})) // 2
{{ pi }}_g_cy = ({{ el.y_expr }}) + ({{ el.y2_expr }} - ({{ el.y_expr }})) // 2
{{ pi }}self.display_manager.draw_text(
{{ pi }} f'{int(_gv)}%',
{{ pi }} x=_g_cx, y=_g_cy,
{{ pi }} color={{ el.label_tuple }},
{{ pi }} font=self.display_manager.{{ el.font_attr }},
{{ pi }})
{% endif %}
{% elif el.type == 'marquee' %}
{{ pi }}_{{ el.data_key }}_text = {{ el.text | tojson }}
{{ pi }}_{{ el.data_key }}_tw = len(_{{ el.data_key }}_text) * {{ el.char_w }}
{{ pi }}_{{ el.data_key }}_x = int(self._data.get({{ el.data_key | tojson }}, width))
{% if el.direction == 'right' %}
{{ pi }}_{{ el.data_key }}_x += {{ el.scroll_speed }}
{{ pi }}if _{{ el.data_key }}_x > width:
{{ pi }} _{{ el.data_key }}_x = -(_{{ el.data_key }}_tw + {{ el.gap }})
{% else %}
{{ pi }}_{{ el.data_key }}_x -= {{ el.scroll_speed }}
{{ pi }}if _{{ el.data_key }}_x < -(_{{ el.data_key }}_tw + {{ el.gap }}):
{{ pi }} _{{ el.data_key }}_x = width
{% endif %}
{{ pi }}self._data[{{ el.data_key | tojson }}] = _{{ el.data_key }}_x
{{ pi }}self.display_manager.draw_text(
{{ pi }} _{{ el.data_key }}_text,
{{ pi }} x=_{{ el.data_key }}_x, y={{ el.y_expr }},
{{ pi }} color={{ el.rgb_tuple }},
{{ pi }} font=self.display_manager.{{ el.font_attr }},
{{ pi }})
{% elif el.type == 'progress_bar' %}
{{ pi }}_pb_x = {{ el.x_expr }}
{{ pi }}_pb_y = {{ el.y_expr }}
{{ pi }}_pb_pct = max(0.0, min(100.0, float(values.get({{ el.binding_key | tojson }}, 0) or 0))) / 100.0
{{ pi }}_pb_fill_w = int({{ el.bar_width }} * _pb_pct)
{{ pi }}self.display_manager.draw.rectangle(
{{ pi }} [_pb_x, _pb_y, _pb_x + {{ el.bar_width }}, _pb_y + {{ el.bar_height }}],
{{ pi }} fill={{ el.bg_tuple }},
{{ pi }} outline={{ el.outline_tuple }},
{{ pi }})
{{ pi }}if _pb_fill_w > 0:
{{ pi }} self.display_manager.draw.rectangle(
{{ pi }} [_pb_x, _pb_y, _pb_x + _pb_fill_w, _pb_y + {{ el.bar_height }}],
{{ pi }} fill={{ el.fill_tuple }},
{{ pi }} )
{% else %}
{{ pi }}pass # element type "{{ el.type }}" draws nothing
{% endif %}
{% endfor %}
# ── End elements ───────────────────────────────────────────────
self.display_manager.update_display()
except Exception as e:
self.logger.error('Display error: %s', e, exc_info=True)