Compare commits

..
Author SHA1 Message Date
ChuckBuildsandClaude Opus 5 6eaa50fd94 test(web): assert 400 exactly, and prove valid input is accepted
Both review points were right, and the first is the failure mode this file
exists to catch.

Accepting any 4xx meant a 404 would have passed. Renaming one of these routes
would have left the test green while it tested nothing -- the same "looks like
coverage, points somewhere safe" shape that hid the composer injections. Now
asserts exactly 400.

Both infinity signs are exercised for every route. int() raises OverflowError
either way, but only +Infinity was in the original report, and a guard that
special-cased the sign would have passed a one-sided test.

The valid-input test previously asserted "not a 400", which did not show what
it claimed: the mocked save path fails for any input, so that assertion held
whether or not validation had accepted the value. It now gives load_config a
real dict and stubs _save_config_atomic, so the endpoint reaches its success
response and the test can assert 200 -- which only happens if the value passed
validation.

8 of the 11 checks fail with OverflowError removed from the except tuples.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
2026-08-22 16:47:33 -04:00
ChuckBuildsandClaude Opus 5 0ca702bebf fix(web): reject non-finite JSON numbers instead of raising
POST /api/v3/config/dim-schedule with {"dim_brightness": Infinity} answered
500. So did /api/v3/errors/clear with max_age_hours, and /api/v3/config/main
with multiplexing or row_address_type.

json.loads accepts Infinity/-Infinity/NaN by default -- they are not valid
JSON, but Python's parser emits them -- and Flask's get_json passes them
straight through. int(float('inf')) raises OverflowError, which is neither
ValueError nor TypeError, so validation blocks that carefully caught those let
it past and Flask turned it into a 500.

The status code was not the real damage. dim-schedule answered with
CONFIG_SAVE_FAILED and suggested "Check file permissions on config directory"
and "Check available disk space" for what was an invalid number. Every one of
these sites already had a correct 400 response written; they just never
reached it.

NaN already returned 400, because int(nan) raises ValueError. That is why this
only ever showed up for the infinities, and why it survived: the obvious test
case passes.

OverflowError is now caught alongside ValueError/TypeError at the 27 sites in
this file whose try block performs a numeric coercion. An AST sweep confirms
no int()/float() of request-derived data is left outside a block that catches
it.

Verified end to end through Flask's test client rather than by reasoning about
the parser: all four routes returned 500 before and 400 after.

Tests: five Infinity cases (which fail against the previous except tuples),
two NaN cases pinned so narrowing the tuple cannot quietly break them, and a
check that ordinary input is not rejected by the widened guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
2026-08-22 16:09:45 -04:00
12 changed files with 112 additions and 6260 deletions
+85
View File
@@ -0,0 +1,85 @@
"""Non-finite JSON numbers must be rejected, not raise.
json.loads accepts Infinity/-Infinity/NaN by default (they are not valid JSON,
but Python's parser emits them) and Flask's get_json passes them straight
through. int(float('inf')) raises OverflowError, which is neither ValueError
nor TypeError -- so validation blocks that carefully caught those let it
through and Flask turned it into a 500.
The damage was not the status code. /config/dim-schedule answered with
CONFIG_SAVE_FAILED and suggested "Check file permissions on config directory"
and "Check available disk space" for what was actually an invalid number.
NaN already returned 400 (int(nan) raises ValueError), which is why this only
showed up for the infinities.
"""
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
#: (route, field) that returned 500 before OverflowError was caught. Both
#: infinity signs are exercised: int() raises OverflowError for either, but
#: only one of them was in the original report, and a guard that special-cased
#: the sign would pass a one-sided test.
NON_FINITE_ROUTES = [
('/api/v3/config/dim-schedule', 'dim_brightness'),
('/api/v3/errors/clear', 'max_age_hours'),
('/api/v3/config/main', 'multiplexing'),
('/api/v3/config/main', 'row_address_type'),
]
NON_FINITE_CASES = [
(route, '{"%s": %s}' % (field, literal))
for route, field in NON_FINITE_ROUTES
for literal in ('Infinity', '-Infinity')
]
@pytest.mark.parametrize("route,body", NON_FINITE_CASES)
def test_infinity_is_a_client_error_not_a_server_error(api_v3_client, route, body):
"""Exactly 400, not merely "some 4xx".
Accepting any 4xx would let a 404 pass, so renaming one of these routes
would leave the test green while testing nothing -- the failure mode this
whole file exists to catch.
"""
response = api_v3_client.post(route, data=body, content_type='application/json')
assert response.status_code == 400, (
f"{route} with {body} answered {response.status_code}; expected 400"
)
@pytest.mark.parametrize("route,body", [
('/api/v3/config/dim-schedule', '{"dim_brightness": NaN}'),
('/api/v3/errors/clear', '{"max_age_hours": NaN}'),
])
def test_nan_is_also_a_client_error(api_v3_client, route, body):
"""int(nan) raises ValueError so this path already worked -- pinned so a
refactor that narrows the except tuple cannot quietly break it."""
response = api_v3_client.post(route, data=body, content_type='application/json')
assert response.status_code == 400
def test_a_valid_number_is_accepted(api_v3_client, api_v3_module, monkeypatch):
"""Prove the widened except did not start swallowing ordinary input.
Asserting "not a 400" would not show that: the mocked save path fails for
any input, so the assertion would hold even if validation had rejected the
value. Give load_config a real dict and stub the atomic save, and the
endpoint reaches its success response -- which only happens if 30 passed
validation.
"""
api_v3_module.api_v3.config_manager.load_config.return_value = {}
monkeypatch.setattr(api_v3_module, '_save_config_atomic',
lambda *a, **k: (True, ''))
response = api_v3_client.post(
'/api/v3/config/dim-schedule',
data='{"dim_brightness": 30}',
content_type='application/json',
)
assert response.status_code == 200, response.get_data(as_text=True)[:200]
-301
View File
@@ -1,301 +0,0 @@
"""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)):
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"
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)
-80
View File
@@ -1,80 +0,0 @@
"""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 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
-187
View File
@@ -1,187 +0,0 @@
"""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"
-225
View File
@@ -1,225 +0,0 @@
"""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}"
+27 -27
View File
@@ -597,7 +597,7 @@ def save_dim_schedule_config():
dim_brightness = 30 dim_brightness = 30
else: else:
dim_brightness = int(dim_brightness_raw) dim_brightness = int(dim_brightness_raw)
except (ValueError, TypeError): except (ValueError, TypeError, OverflowError):
return error_response( return error_response(
ErrorCode.VALIDATION_ERROR, ErrorCode.VALIDATION_ERROR,
"dim_brightness must be an integer between 0 and 100", "dim_brightness must be an integer between 0 and 100",
@@ -797,7 +797,7 @@ def save_main_config():
}), 400 }), 400
try: try:
target_fps = int(raw_target_fps) target_fps = int(raw_target_fps)
except (ValueError, TypeError): except (ValueError, TypeError, OverflowError):
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'message': "Invalid value for target_fps: must be an integer" 'message': "Invalid value for target_fps: must be an integer"
@@ -867,7 +867,7 @@ def save_main_config():
mux_val = int(data['multiplexing']) mux_val = int(data['multiplexing'])
if mux_val < 0 or mux_val > 22: if mux_val < 0 or mux_val > 22:
return jsonify({'status': 'error', 'message': f"Invalid multiplexing value '{data['multiplexing']}'. Must be an integer from 0 to 22."}), 400 return jsonify({'status': 'error', 'message': f"Invalid multiplexing value '{data['multiplexing']}'. Must be an integer from 0 to 22."}), 400
except (ValueError, TypeError): except (ValueError, TypeError, OverflowError):
return jsonify({'status': 'error', 'message': f"Invalid multiplexing value '{data['multiplexing']}'. Must be an integer from 0 to 22."}), 400 return jsonify({'status': 'error', 'message': f"Invalid multiplexing value '{data['multiplexing']}'. Must be an integer from 0 to 22."}), 400
# Validate pixel_mapper_config (free-form mapper string, e.g. "U-mapper;Rotate:90") # Validate pixel_mapper_config (free-form mapper string, e.g. "U-mapper;Rotate:90")
@@ -885,7 +885,7 @@ def save_main_config():
rat_val = int(data['row_address_type']) rat_val = int(data['row_address_type'])
if rat_val < 0 or rat_val > 4: if rat_val < 0 or rat_val > 4:
return jsonify({'status': 'error', 'message': f"Invalid row_address_type '{data['row_address_type']}'. Must be an integer from 0 to 4."}), 400 return jsonify({'status': 'error', 'message': f"Invalid row_address_type '{data['row_address_type']}'. Must be an integer from 0 to 4."}), 400
except (ValueError, TypeError): except (ValueError, TypeError, OverflowError):
return jsonify({'status': 'error', 'message': f"Invalid row_address_type '{data['row_address_type']}'. Must be an integer from 0 to 4."}), 400 return jsonify({'status': 'error', 'message': f"Invalid row_address_type '{data['row_address_type']}'. Must be an integer from 0 to 4."}), 400
# Handle hardware settings # Handle hardware settings
@@ -910,7 +910,7 @@ def save_main_config():
if rp1_val not in (0, 1): if rp1_val not in (0, 1):
return jsonify({'status': 'error', 'message': "rp1_rio must be 0 (PIO) or 1 (RIO)"}), 400 return jsonify({'status': 'error', 'message': "rp1_rio must be 0 (PIO) or 1 (RIO)"}), 400
current_config['display']['runtime']['rp1_rio'] = rp1_val current_config['display']['runtime']['rp1_rio'] = rp1_val
except (ValueError, TypeError): except (ValueError, TypeError, OverflowError):
return jsonify({'status': 'error', 'message': "rp1_rio must be 0 or 1"}), 400 return jsonify({'status': 'error', 'message': "rp1_rio must be 0 or 1"}), 400
# Handle checkboxes - coerce to bool to ensure proper JSON types # Handle checkboxes - coerce to bool to ensure proper JSON types
@@ -963,7 +963,7 @@ def save_main_config():
copies = None copies = None
try: try:
copies = int(data['double_sided_copies']) copies = int(data['double_sided_copies'])
except (ValueError, TypeError): except (ValueError, TypeError, OverflowError):
if enabled: if enabled:
return jsonify({'status': 'error', 'message': "Double-sided copies must be an integer"}), 400 return jsonify({'status': 'error', 'message': "Double-sided copies must be an integer"}), 400
if copies is not None and not (2 <= copies <= 8): if copies is not None and not (2 <= copies <= 8):
@@ -1036,7 +1036,7 @@ def save_main_config():
if data.get('vegas_extend_threshold_screens') not in ('', None): if data.get('vegas_extend_threshold_screens') not in ('', None):
try: try:
screens = float(data['vegas_extend_threshold_screens']) screens = float(data['vegas_extend_threshold_screens'])
except (ValueError, TypeError): except (ValueError, TypeError, OverflowError):
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'message': "Invalid value for vegas_extend_threshold_screens: " 'message': "Invalid value for vegas_extend_threshold_screens: "
@@ -1053,7 +1053,7 @@ def save_main_config():
if data.get('vegas_max_plugin_width_ratio') not in ('', None): if data.get('vegas_max_plugin_width_ratio') not in ('', None):
try: try:
ratio = float(data['vegas_max_plugin_width_ratio']) ratio = float(data['vegas_max_plugin_width_ratio'])
except (ValueError, TypeError): except (ValueError, TypeError, OverflowError):
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'message': "Invalid value for vegas_max_plugin_width_ratio: " 'message': "Invalid value for vegas_max_plugin_width_ratio: "
@@ -1101,7 +1101,7 @@ def save_main_config():
continue continue
try: try:
int_value = int(raw_value) int_value = int(raw_value)
except (ValueError, TypeError): except (ValueError, TypeError, OverflowError):
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'message': f"Invalid value for {field_name}: must be an integer" 'message': f"Invalid value for {field_name}: must be an integer"
@@ -1153,7 +1153,7 @@ def save_main_config():
if not (1024 <= port_val <= 65535): if not (1024 <= port_val <= 65535):
return jsonify({'status': 'error', 'message': "sync_port must be between 1024 and 65535"}), 400 return jsonify({'status': 'error', 'message': "sync_port must be between 1024 and 65535"}), 400
current_config['sync']['port'] = port_val current_config['sync']['port'] = port_val
except (ValueError, TypeError): except (ValueError, TypeError, OverflowError):
return jsonify({'status': 'error', 'message': "sync_port must be an integer"}), 400 return jsonify({'status': 'error', 'message': "sync_port must be an integer"}), 400
if "sync_follower_position" in data: if "sync_follower_position" in data:
@@ -1197,7 +1197,7 @@ def save_main_config():
raw_value = data.pop(field) raw_value = data.pop(field)
try: try:
int_value = int(raw_value) int_value = int(raw_value)
except (ValueError, TypeError): except (ValueError, TypeError, OverflowError):
return jsonify({'status': 'error', return jsonify({'status': 'error',
'message': f"Invalid duration for {field}: must be an integer"}), 400 'message': f"Invalid duration for {field}: must be an integer"}), 400
current_config['display']['display_durations'][field] = int_value current_config['display']['display_durations'][field] = int_value
@@ -1220,7 +1220,7 @@ def save_main_config():
continue continue
try: try:
int_value = int(raw_value) int_value = int(raw_value)
except (ValueError, TypeError): except (ValueError, TypeError, OverflowError):
return jsonify({'status': 'error', return jsonify({'status': 'error',
'message': f"Invalid duration for mode '{mode_key}': must be an integer"}), 400 'message': f"Invalid duration for mode '{mode_key}': must be an integer"}), 400
current_config['display']['display_durations'][mode_key] = int_value current_config['display']['display_durations'][mode_key] = int_value
@@ -5118,7 +5118,7 @@ def save_plugin_config():
converted_array.append(int(v)) converted_array.append(int(v))
else: else:
converted_array.append(float(v)) converted_array.append(float(v))
except (ValueError, TypeError): except (ValueError, TypeError, OverflowError):
converted_array.append(v) converted_array.append(v)
else: else:
converted_array.append(v) converted_array.append(v)
@@ -5143,7 +5143,7 @@ def save_plugin_config():
converted_array.append(int(v)) converted_array.append(int(v))
else: else:
converted_array.append(float(v)) converted_array.append(float(v))
except (ValueError, TypeError): except (ValueError, TypeError, OverflowError):
converted_array.append(v) converted_array.append(v)
else: else:
converted_array.append(v) converted_array.append(v)
@@ -5180,7 +5180,7 @@ def save_plugin_config():
converted_array.append(int(v)) converted_array.append(int(v))
else: else:
converted_array.append(float(v)) converted_array.append(float(v))
except (ValueError, TypeError): except (ValueError, TypeError, OverflowError):
converted_array.append(v) converted_array.append(v)
else: else:
converted_array.append(v) converted_array.append(v)
@@ -5204,7 +5204,7 @@ def save_plugin_config():
converted_array.append(int(v)) converted_array.append(int(v))
else: else:
converted_array.append(float(v)) converted_array.append(float(v))
except (ValueError, TypeError): except (ValueError, TypeError, OverflowError):
converted_array.append(v) converted_array.append(v)
else: else:
converted_array.append(v) converted_array.append(v)
@@ -5371,7 +5371,7 @@ def save_plugin_config():
if isinstance(v, str): if isinstance(v, str):
try: try:
converted.append(int(v) if item_type == 'integer' else float(v)) converted.append(int(v) if item_type == 'integer' else float(v))
except (ValueError, TypeError): except (ValueError, TypeError, OverflowError):
converted.append(v) converted.append(v)
else: else:
converted.append(v) converted.append(v)
@@ -5496,7 +5496,7 @@ def save_plugin_config():
try: try:
normalized[key] = int(value_stripped) normalized[key] = int(value_stripped)
continue continue
except (ValueError, TypeError): except (ValueError, TypeError, OverflowError):
pass pass
elif isinstance(value, (int, float)): elif isinstance(value, (int, float)):
normalized[key] = int(value) normalized[key] = int(value)
@@ -5514,7 +5514,7 @@ def save_plugin_config():
try: try:
normalized[key] = float(value_stripped) normalized[key] = float(value_stripped)
continue continue
except (ValueError, TypeError): except (ValueError, TypeError, OverflowError):
pass pass
elif isinstance(value, (int, float)): elif isinstance(value, (int, float)):
normalized[key] = float(value) normalized[key] = float(value)
@@ -5569,7 +5569,7 @@ def save_plugin_config():
try: try:
normalized_array.append(int(v)) normalized_array.append(int(v))
continue continue
except (ValueError, TypeError): except (ValueError, TypeError, OverflowError):
pass pass
elif isinstance(v, (int, float)): elif isinstance(v, (int, float)):
normalized_array.append(int(v)) normalized_array.append(int(v))
@@ -5579,7 +5579,7 @@ def save_plugin_config():
try: try:
normalized_array.append(float(v)) normalized_array.append(float(v))
continue continue
except (ValueError, TypeError): except (ValueError, TypeError, OverflowError):
pass pass
elif isinstance(v, (int, float)): elif isinstance(v, (int, float)):
normalized_array.append(float(v)) normalized_array.append(float(v))
@@ -5595,7 +5595,7 @@ def save_plugin_config():
if isinstance(v, str): if isinstance(v, str):
try: try:
normalized_array.append(int(v)) normalized_array.append(int(v))
except (ValueError, TypeError): except (ValueError, TypeError, OverflowError):
normalized_array.append(v) normalized_array.append(v)
elif isinstance(v, (int, float)): elif isinstance(v, (int, float)):
normalized_array.append(int(v)) normalized_array.append(int(v))
@@ -5609,7 +5609,7 @@ def save_plugin_config():
if isinstance(v, str): if isinstance(v, str):
try: try:
normalized_array.append(float(v)) normalized_array.append(float(v))
except (ValueError, TypeError): except (ValueError, TypeError, OverflowError):
normalized_array.append(v) normalized_array.append(v)
else: else:
normalized_array.append(v) normalized_array.append(v)
@@ -5632,7 +5632,7 @@ def save_plugin_config():
if isinstance(value, str): if isinstance(value, str):
try: try:
normalized[key] = int(value) normalized[key] = int(value)
except (ValueError, TypeError): except (ValueError, TypeError, OverflowError):
normalized[key] = value normalized[key] = value
else: else:
normalized[key] = value normalized[key] = value
@@ -5641,7 +5641,7 @@ def save_plugin_config():
if isinstance(value, str): if isinstance(value, str):
try: try:
normalized[key] = float(value) normalized[key] = float(value)
except (ValueError, TypeError): except (ValueError, TypeError, OverflowError):
normalized[key] = value normalized[key] = value
else: else:
normalized[key] = value normalized[key] = value
@@ -6779,7 +6779,7 @@ def get_font_preview() -> tuple[Response, int] | Response:
# Safe integer parsing for size # Safe integer parsing for size
try: try:
size = int(request.args.get('size', 12)) size = int(request.args.get('size', 12))
except (ValueError, TypeError): except (ValueError, TypeError, OverflowError):
return jsonify({'status': 'error', 'message': 'Invalid font size'}), 400 return jsonify({'status': 'error', 'message': 'Invalid font size'}), 400
if not font_filename: if not font_filename:
@@ -8360,7 +8360,7 @@ def clear_old_errors():
context={'provided_value': raw_max_age}, context={'provided_value': raw_max_age},
status_code=400 status_code=400
) )
except (ValueError, TypeError): except (ValueError, TypeError, OverflowError):
return error_response( return error_response(
error_code=ErrorCode.INVALID_INPUT, error_code=ErrorCode.INVALID_INPUT,
message="max_age_hours must be a valid integer", message="max_age_hours must be a valid integer",
File diff suppressed because it is too large Load Diff
-3
View File
@@ -426,9 +426,6 @@ 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
@@ -1,804 +0,0 @@
/**
* 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).
*/
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
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
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 * s + 0.5);
ctx.lineTo(_canvas.width, ay * s + 0.5);
} else {
ctx.moveTo(ax * s + 0.5, 0);
ctx.lineTo(ax * s + 0.5, _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;
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, rx - lwPx / 2, ry - lwPx / 2, 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, rx - lwPx / 2, ry - lwPx / 2, 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
@@ -1,300 +0,0 @@
"""
{{ 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 }})
{% 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)