Add the OpenDisplay Designer v2 (visual drawcustom editor) as a sidebar panel - #127
Add the OpenDisplay Designer v2 (visual drawcustom editor) as a sidebar panel#127schlomo wants to merge 35 commits into
Conversation
One command (dev/run.sh) brings up homeassistant/home-assistant:stable in Docker with this branch's custom_components/opendisplay mounted read-only, onboards a dev user via HA's own public onboarding HTTP API, and prints the URL + next steps. Ported from a prior-art harness (dev-ha-harness branch, PR OpenDisplay#100's tree) onto v3.0.2 main, which has no designer panel yet -- the harness makes no assumption that one exists. New: dev/inject-displays.py writes fabricated OpenDisplay config entries (small mono 250x122, medium BWR 296x128, large BWRY 800x480 -- real py-opendisplay GlobalConfig/DisplayConfig geometries, config_to_json'd) straight into dev/ha-config/.storage/core.config_entries. No BLE, no config flow: __init__.py's async_setup_entry creates the device/entities itself from CONF_CACHED_STATE on every setup. Investigation-verified crux: this Docker container has no Bluetooth adapter, so async_ble_device_from_address() never has a discovered device to return -- it comes back None immediately, so the sleepy-cache fallback (_cache_setup_if_sleepy) is reached with no live-connect attempt at all, not even one that times out. Each fabricated entry also forces options[CONF_SLEEP_MODE]="on" so the sleepy branch is unconditional on top of a genuine battery/deep-sleep PowerOption. Idempotent: entries are matched and replaced by a title-prefix marker; --count controls how many (max 3 distinct geometries). Guards against running while the compose stack is up (storage must not be rewritten under a live HA process). Verified live end to end: onboarding, opendisplay.* services registered with zero entries, 3 fabricated devices boot with image/sensor/binary_sensor entities and no BLE hang, device registry attributes match the fabricated config exactly, and opendisplay.drawcustom dry-run against each device returns a real JPEG at that device's exact fabricated resolution (250x122, 296x128, 800x480) via entity_picture. Full account, including idempotency and re-run tests, in dev/README.md's "Verified vs UNVERIFIED". dev/snapshot.sh / dev/restore.sh / dev/verify-scripts.sh / dev/seed/fixtures ported unchanged for the real-hardware path. CONTRIBUTING.md documents the harness as an alternative to the existing symlink-into-a-live-HA workflow. pyproject.toml per-file-ignores T201 for dev/inject-displays.py (a CLI script; console output is the point). Local commit only, per instructions -- not pushed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nned
Home Assistant custom components cannot npm install at runtime, so both
the designer library and its js-yaml dependency ship vendored into the
repo. scripts/update-designer-vendor.py replaces hand-dropped release
blobs with a pinned, verifiable download for EACH: fetch the npm
registry's declared sha512 integrity for an exact version, download the
tarball, verify the actual bytes against that hash, then copy the files
the panel consumes into designer/frontend/vendor/. Both packages fail
loudly and write nothing to vendor/ on any network, integrity, or
tarball-layout mismatch.
Pinned: designer @2.6.3 (designer.lock.json's "designer" entry), js-yaml
@4.1.0 ("js_yaml" entry) -- js-yaml previously shipped as an unpinned
jsDelivr-rebundled blob with no integrity record and no license file of
its own; it now ships the genuine npm-published dist/js-yaml.mjs, verified
and licensed (LICENSE.js-yaml) the same way the designer bundle is.
Re-ran both --pin and --pin-js-yaml here against the live npm registry to
prove the script works.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Maintainer ruling 2026-08-30 (supersedes the Docker-based harness inherited
from dev-harness): maximum KISS, no container runtime dependency.
- dev/run.sh starts `uv run --group dev hass --config dev/ha-config`
directly, tracked by a PID file (dev/ha-config/.harness.pid) instead of
`docker compose`; dev/stop.sh sends SIGTERM (SIGKILL after a 20s grace
period) and removes the PID file. dev/inject-displays.py and
dev/restore.sh check/stop the same way instead of shelling out to
`docker compose ps`/`down`.
- dev/run.sh symlinks dev/ha-config/custom_components/opendisplay to this
checkout's custom_components/opendisplay on every start (guarded against
ln -sfn silently misbehaving over a real, non-symlink directory). Home
Assistant only looks for custom_components/ inside its own --config
directory (the old Docker bind-mount put it there implicitly) -- without
this, HA 2026.8's own core-shipped `opendisplay` integration silently
answers the domain instead and every fabricated entry sits in
setup_retry. Verified live both ways. (README.md gains a short note on
this too -- a real custom_components/opendisplay install always takes
precedence over HA's own built-in one; expected, not an error.)
- dev/ha-config/configuration.yaml drops `default_config:` for an explicit
minimal set (frontend/config/api + logger, every line justified inline)
-- no dhcp/ssdp/zeroconf/usb/bluetooth/cloud/mobile_app discovery
integrations get loaded. (A bare `opendisplay:` key was here through
several review rounds; removed -- see below.)
- dev/run.sh checks the resolved interpreter is >=3.14 before starting
(pyproject.toml's floor; __init__.py relies on PEP 758 unparenthesized
except-tuples, a SyntaxError below 3.14).
- dev/run.sh never claims success without checking again, right before
printing the banner: it re-verifies the tracked PID is alive AND the
port answers immediately before the banner -- a race where onboarding's
own HTTP calls succeed moments before a TCC/CoreBluetooth abort takes
the process down could otherwise print a false "HA is up". On failure it
detects the crash signature and prints matching remediation instead of a
generic error, via one shared `report_process_death_and_exit` helper.
The crash-signature detector keys ONLY on the macOS Python fault dump
being non-empty and containing `__TCC_CRASHING_DUE_TO_PRIVACY_VIOLATION__`
or `Fatal Python error` -- nothing else counts (narrowed twice across
review: first from "a .log.fault file exists" alone, which HA writes
empty on ordinary healthy boots too; then from also grepping the regular
log for a bare "bluetooth" substring, which routine, unrelated cascade
lines matched too, e.g. "Setup failed for 'bluetooth': Could not setup
dependencies", printing the TCC remediation for a plain port conflict
that had nothing to do with TCC -- reproduced live and removed entirely).
Live-tested all three paths: `kill -9` -> generic message, no TCC text,
correctly-empty fault dump reported; a healthy boot -> no false
detection; a real, naturally-occurring TCC abort during testing ->
correctly matched via the fault dump's own
`__TCC_CRASHING_DUE_TO_PRIVACY_VIOLATION__` content, TCC-specific
remediation printed.
- dev/internal/onboarding.sh's wedge check runs UNCONDITIONALLY, before
looking at what /api/onboarding itself reports -- a `{"done": ["user"]}`
onboarding-storage state makes that endpoint 200, not 404, so a check
scoped to only the 404 branch would miss it. Also covers a second wedge
shape: .storage/auth_provider.homeassistant present with neither
.storage/auth nor .storage/onboarding written yet (what a process killed
even earlier leaves behind). Both fabricated directly on disk and
confirmed live: both fail loudly with a `rm -rf`-and-retry instruction; a
genuinely pristine state confirmed to fall through unflagged.
- onboarding.sh also removes the google_translate/met/radio_browser/
shopping_list config entries onboarding's own core_config step creates
unconditionally (two of which make outbound network calls, at odds with
this harness's own no-network-probing rationale for dropping
default_config). The removal poll exits once a pass finds nothing new to
remove, rather than insisting on all four domains -- `met`'s own
async_step_onboarding aborts "no_home" against the placeholder location
an empty core_config POST leaves in place and never actually creates an
entry against this harness's own onboarding call, so an exit condition
that waited for it would burn every attempt every run. Confirmed live,
twice: removes exactly google_translate/radio_browser/shopping_list, no
stall.
- Found and documented (dev/README.md "macOS / CoreBluetooth caveat"): on
native macOS, opendisplay's bluetooth_adapters manifest dependency
touches real CoreBluetooth, and macOS's TCC framework can hard-abort any
process without an app-bundle Bluetooth entitlement -- reproduced
directly (Fatal Python error: Aborted /
__TCC_CRASHING_DUE_TO_PRIVACY_VIOLATION__), including in isolation with
bluetooth_adapters as the only config key and no opendisplay integration
at all. The abort is nondeterministic -- this session saw both real
aborts and clean successes across repeated attempts on the same machine
and configuration -- documented as exactly that, not a specific claimed
failure rate.
Folded in from later review rounds (dev-harness files only):
- `HA_URL` uses `127.0.0.1`, not `localhost` (tier-1 finding: macOS
resolves `localhost` to `::1` first, and HA's own auth/http stack can
mismatch across that split -- reproduced live as a white designer page
with `/auth/token` failures against `::1`). Every probe, printed URL,
and doc example derives from this one value.
- `HA_PORT` is now real: `dev/run.sh` generates
`dev/ha-config/http_port.yaml` (`server_port: $HA_PORT`, do-not-edit
header, regenerated every start, gitignored) and `configuration.yaml`
`!include`s it via a new `http:` key -- without this, `hass` always
bound its default 8123 regardless of `HA_PORT`, so a real 8123 conflict
on a non-default-port run went undiagnosed (and a resulting death got
misdiagnosed as the TCC crash above). Live-tested: `HA_PORT=8199`/`8299`
bound the real port; a second instance on the same busy port was
correctly refused by the preflight.
- Every wait/poll curl (`/manifest.json`, onboarding's own calls) carries
`--connect-timeout 5 --max-time 10`, so an HA that accepts the
connection but hangs mid-response can't turn a bounded wait unbounded.
- The bare `opendisplay:` YAML key is gone entirely (tier-1 finding,
maintainer hit live): it doesn't configure anything
(`cv.config_entry_only_config_schema`) and raises a visible red "does
not support YAML setup" Repair in the UI -- not just the harmless log
line an earlier version of this file assumed. HA calls
`async_setup()`/`async_setup_entry()` for the domain on its own once a
config entry exists, key or no key; the only real effect of removing it
is that the designer panel and `opendisplay.*` services are absent on a
totally pristine, zero-entry boot until the first entry exists (the
normal inject-displays.py workflow anyway). Live-tested: zero-entry boot
has no panel and no YAML-setup error in the log; after inject + restart,
both the panel and services register cleanly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fixes a day-one bug affecting every `opendisplay.drawcustom` call, not
something the designer work introduced: `docs/drawcustom/supported_types.md`
has always documented that "the integration ... additionally expands Home
Assistant templates in field values" before odl-renderer sees them --
odl-renderer's own code agrees with that premise (its comments say "Home
Assistant templates render to strings" before it's ever called) -- but no
code anywhere in this integration actually did that rendering. Grepping the
entire integration (`custom_components/opendisplay/*.py`) and its full git
history for `template`/`jinja` turned up nothing; `SCHEMA_DRAWCUSTOM`'s own
`payload` field is a bare `list` with no `cv.template`/`cv.template_complex`
typing either, so HA's service-call layer never had a hook to render it.
This was never implemented, not a regression.
Found via the maintainer's own manual TIER 1 test run of the designer PR
this branch also carries, reproduced with the designer's Load Demo
payload's templated icon field: a literal `{{ iif(...) }}` string reached
odl-renderer's icon lookup unevaluated (`Icon '{{ iif(...) }}' not found`).
Fixed here at the actual root -- the plain drawcustom service call
(`_drawcustom_for_device`) -- rather than only where it happened to
surface; the very next commit wires the designer's render endpoint to this
same fix.
New shared function, `render_payload_templates` (next to the existing
`tone_and_measured_palettes_from_call_data` -- same "one formula, every
call site" pattern this file already uses): recursively renders every
string field value that contains template syntax
(`homeassistant.helpers.template.is_template_string` +
`Template(value, hass).async_render()`) before `generate_image` ever sees
the payload. `async_render`'s default `parse_result=True` natively types a
simple literal result (a rendered `False`/`12` comes back as a Python
`bool`/`int`, not the string "False"/"12") -- verified safe against every
field that matters, including `visible`, whose own `_coerce_visible`
(odl-renderer's `core.py`) explicitly branches on `bool` as well as `str`.
A template that raises (a broken reference -- HA's own `is_state`/
`is_state_attr` degrade to a sensible default for a merely-missing entity
rather than raising, so this is not the common case) becomes a
`ServiceValidationError` naming the offending element's list index and
`type`, instead of either the original silent literal or an opaque
traceback from inside odl-renderer.
Tests (tests/test_services.py): a templated field referencing an existing
state renders evaluated, exercised through the REAL service call
(`hass.services.async_call(DOMAIN, "drawcustom", ...)`, not the helper
directly -- a test that only calls the shared helper would not prove
`_drawcustom_for_device` is actually wired to it); a genuinely broken
template raises `ServiceValidationError` naming the element, confirmed the
device never got an upload. Red-first: reverted the `elements=` call
site's use of `render_payload_templates` back to the raw payload, confirmed
both new tests fail, restored and confirmed green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Maintainer ruling 2026-08-30: "designer play must never impact anything
around a live display." Adds this integration's first authenticated HTTP
view -- POST /api/opendisplay/designer/render with
{device_id, payload, background?, dither?, rotate?} -> rendered PNG bytes.
Shares two calls with _drawcustom_for_device's send path (generate_image,
then prepare_image's dither/quantize step), called with the SAME
tone/use_measured_palettes values the send path derives for a call
carrying neither tone_compression nor measured_palette (tone="auto",
use_measured_palettes=False -- SCHEMA_DRAWCUSTOM's own defaults). Passing
neither kwarg at all would silently pick up prepare_image's own different
defaults (tone=0.0, use_measured_palettes=True) instead, rendering visibly
wrong on any measured-palette panel -- named explicitly in the module
docstring rather than left as a bare kwarg list, and pinned by a test on a
real measured-palette IC (0x21 + BWR) so the gap is pixel-observable, not
just a kwarg-inspection nicety. Both call sites (this endpoint and the
send path) share one derivation, tone_and_measured_palettes_from_call_data
(services.py), rather than each computing the formula independently -- a
future send-path change moves both, instead of one silently drifting from
the other.
Also renders Home Assistant templates in the payload before generate_image
sees it, via the previous commit's `render_payload_templates` (services.py)
-- the same function the plain drawcustom service now calls, so a designer
preview and a real send can never disagree about what a templated field
evaluates to. This endpoint's own contribution here is exactly the wiring:
render_payload_templates(hass, data["payload"]) ahead of generate_image,
with a `ServiceValidationError` from a broken template turned into its own
400 response (`{"message": "..."}`) naming the offending element -- kept
separate from the generic "render failed: invalid payload" 400 the
generate_image try/except below already returns, so a broken template
gets a specific, actionable message instead of the generic one.
Stops before the send-only tail: no image-entity write, no
SIGNAL_IMAGE_UPDATED dispatch, no BLE delivery, no log line above debug.
Unknown device_id -> 404; malformed body -> 400; render failure (bad
payload) -> 400, never a 500 (the exception's own text, which can carry a
local font-search-path detail, goes to the debug log only -- the response
gets a generic message). Payload capped at 1000 elements (400 above it):
generate_image runs on the event loop like the send path's own call does,
and an endpoint meant for live-preview-on-every-edit is a much higher-
frequency caller of it than a deliberate send.
custom_components/opendisplay/designer/__init__.py registers the view via
async_setup_designer(), wired into __init__.py's async_setup() alongside
async_setup_services -- the panel registration this same module will gain
follows in the next commit.
Tests (tests/test_designer_render.py): auth required, wrong device -> 404,
malformed/oversized payload -> 400, dither none vs ordered produce
different bytes, the prepare_image kwargs match the send path's own
derivation (not hardcoded independently of it -- the test validates
against the real SCHEMA_DRAWCUSTOM and calls the same shared function),
a real measured-palette-IC render matches the correct derivation and
differs from what prepare_image's bare defaults would have produced, one
unmocked smoke test against the real generate_image, the target's image
entity is provably unchanged (state/last_updated/entity_picture) before
and after a render, and (this endpoint's own template wiring) a templated
field referencing an existing state renders evaluated, one referencing a
missing state degrades to the template's own else-branch rather than
erroring, and a genuinely broken template returns 400 naming the element.
Red-first: verified failing with the view registration commented out (6/7
fail; only "device not found" coincidentally still passes, since a missing
route also 404s) before restoring it; separately verified the
tone/use_measured_palettes parity tests fail when those two kwargs are
omitted from the prepare_image call, and pass once restored -- confirmed
again after extracting the shared derivation function, a second
independent confirmation the fix (not a coincidental literal) is what the
tests actually pin. The three template-rendering tests confirmed red
against this endpoint's own wiring specifically: reverted this file's
render_payload_templates call site alone (the previous commit's function
and its own tests untouched) and confirmed all three fail; restored and
confirmed green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Registers the OpenDisplay Designer sidebar panel (panel_custom) and the
static view that serves its frontend assets, and wires the vendored
designer's 2.x host contract (targets/actions/status/preview seams) through
a plain custom-element wrapper.
- designer/capabilities.py + designer/image_entity.py: publish each
device's HostCapabilities shape (pixel size, render size, color scheme,
palette) as image-entity attributes, plus a white placeholder JPEG before
a first real frame exists. Wired into image.py's extra_state_attributes
and async_added_to_hass.
- designer/panel.py: the static asset view (frontend/vendor + panel JS) and
the cache-busting module URL. A request carrying the cache-busting `?v=`
token gets long-cache headers (the token itself is the invalidation); a
bare path gets the strict no-cache headers instead, since the same URL
can start answering different bytes after a vendor bump or a panel edit.
panel.js's own bare `import ... from '../vendor/...'` specifiers carry no
query string at all -- a relative ES-module import doesn't inherit its
importer's own query -- so the ~5.6MB designer bundle they pull in never
got the long-cache treatment; the view now rewrites those import
specifiers at serve time to carry the same token panel.js's own URL does
(tests/test_designer_panel.py covers this).
designer/__init__.py now registers both HTTP views and the sidebar panel
(panel_custom.async_register_panel), gated on hass.data so a reload
never double-registers.
- frontend/panel/opendisplay-designer-panel.js: the host wrapper --
buildTargets() (gated on a real published pixel_width, not just an
attributes dict existing) supplies `targets`; collectStates() supplies
`states`; a `send` host action carries drawcustom's device_id/payload;
`renderPreview` POSTs to the render endpoint from the previous commit and
hands the returned PNG bytes straight to the designer -- no dry-run, no
polling, no cache-busting, no entity-picture fetch: none of that machinery
exists in this version, because the render endpoint itself is the
isolation guarantee.
Also: `renderPreview` derives its `rotate` field from the designer's own
live canvas-orientation control (`context.display.rotation` vs. the
target's base capability rotation, `_rotateDeltaFor`) instead of always
sending 0 -- a rotated preview no longer letterboxes. `send`'s dither
fallback (when no preview has run yet) matches the designer's own default
('none'/flat, not 'ordered'). The host-push debounce now has a 1s
max-wait alongside its 250ms settle delay, so a busy HA instance can't
reset it forever and starve _pushHostData(). A mount failure now shows an
inline error in the panel, not just a toast that fades over a blank
screen. Per-mount state (selection, sending, YAML validity) resets on
disconnectedCallback so a remount never carries over stale state from a
previous mount. theme() no longer lets the OS's dark-mode preference
override an explicit light theme HA already resolved. Parent-element
style patches (position/height) are reverted on disconnect instead of
left in place.
- manifest.json: added `frontend`/`panel_custom` manifest dependencies (the
designer's own static assets/panel registration need them; `http` was
already present for the render endpoint). pyproject.toml gains
`home-assistant-frontend` in both dependency groups for exactly this
reason -- see the harness commit's message for how the gap was found
(a clean `.venv` rebuild, not an existing one that happened to already
have it).
- dev/run.sh's success checks (config entries `loaded`, panel static 200,
render endpoint 200 once entries exist) now have something real to check
against, and a shared `fetch_dev_access_token` helper to authenticate
with. The panel-static check polls for up to 15s rather than a single
shot: /manifest.json answering only means HA's own web server is up,
not that component-level async_setup() for opendisplay (which is what
registers this view) has finished -- especially when onboarding was
skipped near-instantly (already onboarded), a one-shot check raced that
window and failed spuriously on an otherwise-healthy boot, caught live
during this round's own testing and fixed before landing.
Live-verified end to end, repeatedly: entries reaching `loaded`, the
panel static view answering 200, and `render endpoint check passed`
against a real fabricated device, all before the success banner
printed -- and, reproducing the exact scenario this check exists for,
confirmed that making async_setup() unconditionally raise (simulating a
failed component load) is correctly caught by the panel-static-view
check instead of a false success banner.
Known, documented (not fixed here -- see docs/designer.md "Known gaps"):
no resolveAsset provider exists yet (a host-only font renders correctly
through Send/Display preview but shows a client-side render error in the
non-preview canvas view); Send still can't carry a live rotate or dither
value (HostActionContext has no equivalent to HostPreviewContext's
geometry/service fields -- upstream seam, filed in the PR body); and
capabilities.py's canvas palette (get_palette_for_display with no
use_measured argument, i.e. that function's own True default) disagrees
with drawcustom's own use_measured_palettes=False send default -- a
coherence question for the maintainers, not resolved unilaterally here.
Tests: tests/test_image.py asserts the image entity's attributes carry the
HostCapabilities shape (pixel/render size, color_map, available_colors);
tests/test_designer_panel.py covers the static view (panel JS and vendor
library served unauthenticated, unknown path -> 404, path traversal
rejected, vendor imports carry the cache-busting token) and that
panel_custom registration actually lands
(hass.data[DOMAIN]["designer"]["panel_registered"]). Red-first verified for
the panel-registration test (commented out the registration line, 1/5
failed; restored, 5/5 passed).
Full suite: 245/245 passed on both scripts/test legs (dev + --min-ha), lint
and format clean, node --check clean on the panel JS.
Folded in from later review rounds (dev/run.sh only -- this commit's
own designer/panel code untouched by these):
- The panel-registration poll checks process liveness on every failed
attempt, not just once outside the loop -- a dead process mid-poll
previously fell through to "integration failed to set up" instead of the
correct death diagnosis (reproduced live: widened the poll window with a
temporary, never-committed test delay in a scratch copy, killed the
process inside it, confirmed the death is now reported correctly).
- Every `|| true`-guarded step in the config-entries/render-endpoint
cascade (fetch_dev_access_token, the entries/device-id lookups) checks
liveness first and either reports a death or prints an explicit,
specific reason for the skip -- no silent fallthrough to the banner.
- `fetch_dev_access_token`'s own curls, and the entries/device-id/render-
status curls, all carry the same `--connect-timeout 5 --max-time 10`
(`--max-time 30` for the render-status curl specifically, since it does
real image-compositing work, not just a poll).
- Token-navigation wording corrected ("your own Profile page -> Security
tab", not "Developer Tools").
- Restructured for a later finding (the harness's own `opendisplay:` YAML
key was removed -- see the harness commit): whether config entries exist
is now determined BEFORE the panel-registration check, since whether the
panel is even expected to exist depends on entry_count once that key is
gone (async_setup_designer() only runs once a config entry exists,
with no bare YAML key to force it earlier). The panel/render checks are
required when entries are known to exist, an explicit non-fatal skip
when entries are confirmed zero, and a non-fatal "can't tell" note if
the token/entries fetch itself failed. A latent race surfaced by this
reordering was fixed the same way: the config-entries `not_loaded` check
is now a bounded poll (matching the panel check's own pattern), not a
single shot -- entry setup for several fabricated devices can still be
genuinely in flight a moment after the panel check already passed,
reproduced live (all 3 entries transiently `not_loaded`, confirmed
independently to reach `loaded` moments later).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…oint, vendoring
docs/designer.md covers: how the panel wrapper maps HA data onto the
designer's targets/states/actions/renderPreview seams; the preview-isolation
guarantee (why a live display's dashboard can never change from designer
play); the render endpoint's full contract (including the shared
tone/use_measured_palettes derivation, the element cap, and generate_image's
event-loop execution measured directly at that cap); the rotation-delta
derivation renderPreview uses; the vendored-library update procedure
(designer + js-yaml, both pinned); and the known gaps (rotation_degrees
base-vs-effective for Send specifically, Send's sticky dither memory, the
pre-existing dry-run dither gap, the canvas/send use_measured_palettes
disagreement, and the missing resolveAsset provider) rather than silently
working around any of them.
Also documents the render endpoint's template-expansion behavior (the two
`fix(services)`/`feat(designer)` commits above): values are expanded
server-side in Home Assistant's own sandboxed Jinja environment, a missing
state degrades rather than errors, and a broken template's `400` response
shape (`{"message": "drawcustom payload element <index> (type '<type>')
has an invalid template: <reason>"}`) is now its own row in the endpoint's
status table rather than folded into the generic malformed-payload `400`.
README.md (earlier commit) points readers at it from the drawcustom usage
section.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…warn before unsaved-work navigation
Maintainer's TIER 1 round 2 test run, findings 1/2/6 (priority order 1 and
2; 6 last since it's the smallest and least urgent of the three).
CRITICAL (finding 1) — typing in the YAML editor was largely unusable
inside the real HA panel: most keystrokes did nothing, and single letters
like e/d/c popped HA's own global quick-bar (entity/device/command search)
over the editor.
Root cause, verified directly against the installed home-assistant-frontend
package (20260826.1, re-verified against 20260729.7 after `uv run`'s own
floating resolution drifted the venv mid-round -- can-override-input.ts and
shortcuts.ts are byte-identical between the two, quick-bar-mixin.ts only
gained unrelated TypeScript typing, so this is a stable mechanism, not a
one-version fluke): HA registers its e/c/d/m/a/? shortcuts globally on
`window`, bubble phase, via tinykeys (src/common/keyboard/shortcuts.ts,
tinykeys(window, wrappedShortcuts), line 46 -- wired from
src/state/quick-bar-mixin.ts's _registerShortcut(), lines 104-139). The "is
the user typing in an editable field" gate, canOverrideAlphanumericInput
(src/common/dom/can-override-input.ts, the whole 36-line file), does NOT
check Element.isContentEditable at all -- only a fixed tag-name allowlist
(TEXTAREA/INPUT, plus HA-MENU/HA-CODE-EDITOR anywhere in composedPath()).
The vendored designer's own CodeMirror 6 instance is a plain
contenteditable div with none of those tag names, so the gate says "yes,
override" even mid-edit -- e/c/d fire and steal focus into HA's own
dialog, and every keystroke after that goes into THAT dialog instead of
back into the designer (this is "most keystrokes don't type" -- a
consequence of focus already stolen, not a second bug). Shadow-root
retargeting is NOT the mechanism, a common wrong guess: composedPath() is
retarget-proof by design.
Fix, host-side (can't patch home-assistant-frontend): containKeyEvents(host)
(frontend/panel/key-containment.js) stops keydown/keyup/keypress
propagation at the panel custom element, bubble phase, after the shadow
root's own listeners (CodeMirror included) run and before the event would
otherwise reach window. Never preventDefault -- native typing must proceed;
HA's shortcuts going quiet while the user works inside the designer is
intentional (maintainer ruling: designer owns its own surface).
tests/js/key-containment.test.mjs: plain `node --test`, zero dependencies
(this repo has no JS package manager at all) -- a hand-rolled 3-node bubble
chain is enough to exercise the real contract. Stops propagation inside the
mount, covers all three event types, leaves an out-of-mount keydown
untouched, disposer un-registers. Reverted to a no-op, confirmed the
relevant test fails, restored, confirmed green.
Live-verified in the real harness: typed "hello edc test 123"
character-by-character directly into the panel's YAML editor -- every
character landed, every e/d/c inline, no quick-bar interruption.
Finding 2 -- virtual-display preview failed with "Select a display to
preview": context.targetId is null for the designer's built-in "Virtual
display" pick (no HA device exists at all). The render endpoint now
accepts either device_id OR an explicit display: {width, height,
color_scheme?} spec -- generate_image/prepare_image never needed a device
object, only geometry + a palette. _synthetic_global_config (render.py)
builds a syntactically real GlobalConfig from the spec, reusing the same
harmless placeholder wiring values dev/inject-displays.py already uses to
fabricate a device with no real hardware. color_scheme defaults to MONO:
the designer's renderPreview context (HostPreviewContext.display) carries
only width/height/rotation -- the designer keeps its color-mode control
entirely inside its own chrome (ADR-018), so the host genuinely can't know
which one the user picked and does not guess. The panel's renderPreview
sends display: {width, height} from context.display whenever targetId is
null, rotate: 0 (already the oriented surface). Same auth, same payload
cap, same tone/measured-palette parity kwargs as the device path.
tests/test_designer_render.py: spec-mode 200 at the requested resolution,
an UNMOCKED smoke test against the real generate_image/prepare_image
(proves the synthetic config is actually accepted, not just syntactically
constructed), missing-both-fields 400, device-mode path unchanged. Reverted
to requiring device_id unconditionally, confirmed all three spec-mode tests
fail, restored, confirmed green.
Live-verified: POST {"display":{"width":384,"height":184},...} -> 200,
real PNG, exactly 384x184; omitting both device_id and display -> 400 with
the expected message; device-id path unaffected.
Finding 6 (interim) -- the maintainer lost real work navigating away with
unsaved changes; the designer's own export-aware dirty flag is filed as
designer#167, not landing yet. frontend/panel/unsaved-work.js's
makeBeforeUnloadHandler registers a beforeunload listener that warns
whenever getStatus().lastEditAt is non-null. Investigated and reporting
honestly: there is no equivalent hook for HA's own in-app sidebar
navigation (a SPA route swap, not a real page unload) -- the Custom
Elements spec has no cancelable "about to be removed" callback
(disconnectedCallback runs AFTER removal), and the designer's own host
contract (odl-drawcustom-designer.d.ts) exposes none either. Ships
beforeunload only; a sidebar-link click away from unsaved work is NOT
caught until designer#167 lands.
tests/js/unsaved-work.test.mjs: no warning before any edit or with no
handle at all, warns once an edit exists, reads the handle fresh on every
call (not a stale snapshot across a remount). Reverted hasUnsavedWork to
always return false, confirmed 3/6 tests fail, restored, confirmed all 10
JS tests (this file + key-containment's) green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e placeholder regression Maintainer's TIER 1 round 2 test run, findings 3/4/5. Finding 3 -- HA_PORT ripped out entirely. New evidence beyond the earlier KISS ruling: HA shows a "HTTP YAML configuration is ignored after migration" Repair once onboarding stores its own network config, meaning round 3's http_port.yaml !include mechanism was already silently ignored past the very first boot regardless of what it said -- broken by platform behavior, not a bug in how it was wired. Removed: the http_port.yaml generation in dev/run.sh, the !include in configuration.yaml, the HA_PORT env var and every reference to it (comments now explain the removal rather than silently dropping it, including in the historical round-3 "Live-tested detector paths" section, left in place as the record of what was actually run then). Port is 8123, full stop. Also: configuration.yaml gained `homeassistant: country: US`, killing the "Country not configured" Repair -- NOT wired through onboarding's own /api/onboarding/core_config call as first guessed, because that view's POST body is provably unused by CoreConfigOnboardingView (homeassistant/components/onboarding/views.py -- it only marks the step done and fires off google_translate/met/radio_browser/shopping_list, nothing reads a country out of the request). The YAML `homeassistant:` block is what async_process_ha_core_config actually reads CONF_COUNTRY from, verified directly against core_config.py. dev/README.md gained an "Expected warnings (harmless, cannot be removed)" section for the two Repairs no config change fixes: "not running on a supported system" (this harness's entire point is a bare `hass` process via `uv run`, not one of HA's supported installation types) and "custom integration ... has not been tested by Home Assistant" (unconditional for anything under custom_components/ not shipped in HA core). Live-verified: fresh scratch boot, log grepped for both warning strings -- neither present. HA_PORT confirmed gone (no env var overrides the port any more). Finding 4 -- Fabricated Small Mono Tag's 250px width triggered py-opendisplay's byte-alignment warning on every render (MONO direct-write sizes the upload from the raw pixel count and truncates the last rows when width isn't a multiple of 8; 250 % 8 == 2). dev/inject-displays.py now fabricates it at 200x200 (a real, common small-mono geometry -- Waveshare-class 1.54") instead of the 2.13"-class 250x122 -- 200 % 8 == 0. Commented on the DisplayConfig itself and in dev/README.md's fixture table for why; the older 250x122 mentions in this file's own historical "Verified vs UNVERIFIED" narrative are left as the record of what was actually run then. Live-verified: fresh boot with all 3 fabricated entries, log grepped for "not a multiple of" / "byte-aligned" -- neither present (was present on every render before this fix). Finding 5 -- a never-rendered image entity's more-info card threw a frontend TypeError (reading 'startTime') and showed empty, because async_image() returns None before anything has ever been sent/rendered. Checked what v3.0.x main's image.py actually does, as asked -- and found the fix already present: designer/image_entity.py's designer_on_entity_added (part of this same PR's earlier "mount vendored 2.x designer" commit -- see its own docstring: "publish a white placeholder JPEG before a first real frame exists") already generates a real white JPEG at the device's own resolution, called from image.py's async_added_to_hass. No production code change was needed; what WAS missing was a regression test pinning this behavior at the HTTP-visible level, since nothing had actually asserted it. tests/test_image.py: test_fresh_entity_serves_a_placeholder_not_an_empty_image -- a fresh entity (no send, no render) must publish entity_picture immediately, and that URL must resolve to a real, non-empty JPEG (SOI marker checked) with 200/image/jpeg. Reverted designer_on_entity_added to a no-op, confirmed this test fails, restored, confirmed green (and every other test_image.py case still passes). Live-verified: fetched entity_picture for a device with zero sends/renders -> 200, image/jpeg, 1303 real bytes, 200x200 (the new finding-4 fixture size) -- not empty, not a 4xx. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e letters, wire JS tests into the gate
Independent reviewer re-verification of the previous commit's keyboard-
containment fix (finding 1 of tier-1 round 2) found an undisclosed
blocker: the blanket `containKeyEvents` -- stopping propagation for EVERY
keydown/keyup/keypress at the panel host -- also silently killed the
vendored designer's OWN window-level keyboard shortcuts. Extracted and
verified directly against the vendored bundle (same discipline as the
HA-side root cause the previous commit already used, not assumed): the
designer registers exactly one window keydown listener
(`src/ui/lib/canvas-keyboard.ts`) handling Escape (deselect), Delete/
Backspace (delete element), and Arrow keys (nudge) by key name, plus
`src/ui/lib/undo-keyboard.ts`'s `Z$t`/`Q$t` predicates for undo
(ctrl/meta+z) and redo (ctrl+y / ctrl+shift+z). Since the panel's own host
element is always in the bubble path between the designer's canvas and
`window`, a blanket stop there broke every one of them -- undo, redo,
delete-element, arrow-nudge, deselect -- with nothing in the previous
commit's own writeup disclosing this tradeoff, because it hadn't been
found yet.
Fix: two conditions instead of a blanket stop, mirroring the designer's
OWN editable-target guard (`X$t` in canvas-keyboard.ts, reused here as
isEditableTarget: `.cm-editor` ancestor, INPUT/TEXTAREA/SELECT,
isContentEditable) so "should this reach the designer's own canvas
shortcuts" and "should this reach HA's quick-bar" agree on what counts as
"the user is editing text":
(a) the event's real origin (composedPath()[0], not the possibly-
retargeted event.target) is an editable target -- contained
regardless of which key, matching the designer's own intent (X$t
itself bails the canvas handler out entirely for an editable
target, so the designer doesn't want its own shortcuts firing
during a text edit either).
(b) the event is an UNMODIFIED alphanumeric key (no ctrl/meta/alt --
Shift is deliberately not treated as a modifier, erring toward more
containment) regardless of target -- the designer binds no bare
letters/digits anywhere, so nothing of its own breaks, and this is
what keeps a bare 'e'/'c'/'d' from opening HA's quick-bar even when
the CANVAS (not the editor) has focus, which (a) alone would not
catch.
Everything else -- Delete, Backspace, Escape, Arrow keys, and every
ctrl/meta combo including undo/redo -- now propagates to window from a
non-editable target, same as before this feature ever existed. Never
preventDefault, unchanged.
Residual tradeoff disclosed explicitly in key-containment.js's own doc
comment and PR-BODY.md (this is what the previous commit should have said
and didn't): bare-letter HA shortcuts are suppressed PANEL-WIDE by design,
not just while text-editing -- deliberate, since the designer has no
bare-letter shortcuts to protect and quick-bar-over-the-canvas would be
just as broken as quick-bar-over-the-editor. If HA ever binds a global
ctrl/meta-modified shortcut the designer also needs, this file needs a
third, narrower condition -- not guessed at pre-emptively here.
isEditableTarget duck-types (`typeof target.closest === 'function'`)
rather than `target instanceof HTMLElement`, which is what the designer's
own X$t checks -- deliberate divergence, disclosed in the function's own
doc comment: this file has no DOM to instanceof against under plain
`node --test` (no jsdom, no npm anywhere in this repo), and every real
target this ever sees is a genuine HTMLElement with a real .closest either
way, so the two checks agree for everything this function is actually
asked about.
Tests, red-first against the ACTUAL previous commit, not just a synthetic
revert: checked out 16fc039's key-containment.js verbatim (the blanket
version, still in this branch's own history one commit back), ran this
same rewritten test file against it -- exactly the 5 new designer-
shortcut-survival tests failed (Delete/ctrl+z/ctrl+shift+z/ArrowLeft/
Escape from a canvas-focus target must reach window), the 5 quick-bar-
suppression/disposer tests still passed (the blanket version also
suppressed the quick bar, just broke everything else too) -- restored the
selective fix, confirmed all 10 green. New coverage: a bare letter is
contained from an editor-shaped target AND a canvas-shaped target (the
canvas case is what a target-only guard would have missed), a bare letter
outside the mount still reaches window.
Also wires tests/js/ into the actual gate for the first time -- it was
the ONLY coverage for this fix and ran nowhere but by hand until now.
scripts/test-js (new, `node --test tests/js/*.test.mjs` -- a bare
directory argument doesn't discover files the way pytest's own discovery
does) runs at the top of scripts/test, and a "Run JS tests" step runs it
in CI (.github/workflows/test.yml), once per matrix run (gated on
matrix.leg == 'latest', not HA-version-dependent -- same rationale as the
existing "Upload coverage" step only running on one leg).
Live-verified in the real harness: clicked an element on the canvas and
pressed Delete -- removed; pressed ctrl+z -- undo restored it; typed
directly into the YAML editor (unaffected by this fix, still works, "edc"
included inline with no interruption). Both new commits independently
re-verified in isolated worktrees (scripts/test both legs, scripts/lint,
node --check).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Small, independent fix from the same review pass as the keyboard- containment fix: _SCHEMA's own comment (render.py) said "exactly one of device_id/display is required" -- inaccurate. Nothing in post() rejects a request carrying both; device_id wins silently (the `if device_id: ... else: ...` branch). Fixed the comment to say what actually happens. docs/designer.md's render-endpoint contract had never been updated for the `display` spec mode at all since it was added -- still showed only device_id in the request-body example. Added the `display` spec example, documented the device_id/display precedence explicitly (device_id wins; has no live caller today since the panel's own renderPreview always sends exactly one, gated on whether context.targetId is null -- documented for a future direct API caller or designer change, not because anything exercises it now), and added the missing-both-fields case to the `400` status-table row. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tainment Final maintainer-review NIT, comment-only, no behavior change: condition (b) in containKeyEvents (isUnmodifiedAlphanumeric) only matches [a-zA-Z0-9], so `?` and other non-alphanumeric printable keys still reach `window` from a non-editable (canvas) target -- HA's own `?` shortcuts-dialog CAN pop over the designer while the canvas, not the editor, has focus. Deliberate, not an oversight: the designer binds no `?` of its own either, so nothing of its own is at risk. Documented in key-containment.js's own "Residual tradeoff" doc comment, alongside the already-documented panel-wide bare-letter suppression, so both known gaps are in one place rather than one disclosed and one left implicit. scripts/lint and node --test re-run to confirm nothing moved (16/16 JS tests still pass, lint clean) -- this is a doc comment only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… in-app-nav is not covered
Maintainer report on the unsaved-work warning (finding 6, an earlier
commit this branch): "I wasn't able to trigger the browser level warning
on leaving the page." Investigated the coordinator's four root-cause
candidates in order, live in the harness, each backed by direct evidence:
1. Registration timing -- the panel wrapper's own `_mount()` call is
synchronous, not awaited; the beforeunload registration line runs
unconditionally right after it regardless of whether mount succeeded.
Not the cause.
2. Stale/destroyed handle at fire time -- a temporary diagnostic log
(never committed) confirmed live that at the moment beforeunload
actually fires, the handle is live and non-null and
getStatus().lastEditAt reads the real, current, non-null timestamp
after an edit. Not the cause.
3. preventDefault()/returnValue -- both present, both executed; confirmed
with a direct dispatchEvent test against the refactored code:
event.defaultPrevented was true after an edit, false with none. Not the
cause.
4. Does the handler fire at all -- yes, confirmed twice: once via the
diagnostic log during an actual page navigation (captured real console
output: "beforeunload fired. handle= true lastEditAt=..."), and again
via a direct window.dispatchEvent(new Event('beforeunload', {cancelable:
true})) against the refactored code. HA's outer app/service worker does
not interfere -- a service worker only sees fetch events, which fire
after the browser has already committed to navigating, not before.
Root cause: no code defect found. Every step of this module's own logic --
registration, live-state read, preventDefault()/returnValue -- executes
exactly as designed, verified live end to end, twice, independently. The
native "leave site?" dialog itself never appeared in this session's own
testing either, but that is the one thing genuinely UNTESTABLE from here:
this repo's own browser-automation tooling auto-dismisses beforeunload
prompts (standard behavior for automated test infrastructure, so tests
don't hang forever waiting for a human) regardless of whether the
underlying code is correct -- a real, unattended Chrome shows the dialog
under the exact preventDefault()+returnValue contract this code already
satisfies. Given the code is confirmed correct, the most likely
explanation for the report: the natural first instinct for "try to leave
the page" while manually testing a panel that lives inside HA's own
sidebar is to click ANOTHER SIDEBAR ITEM -- exactly the in-app-navigation
gap already documented (Custom Elements has no cancelable "about to be
removed" hook; the designer's own host contract exposes none either), just
not called out loudly enough to be seen before testing.
Fix applied despite finding no defect: refactored the registration itself
into installUnsavedWorkWarning(win, getHandle) (unsaved-work.js) -- bundles
window.addEventListener/removeEventListener with the handler the same way
containKeyEvents already does, so "is the listener actually registered on
connect, with a working handler, and actually removed on disconnect" is
now directly unit-testable against a fake window instead of only
verifiable by reading the call site and trusting it.
opendisplay-designer-panel.js's connectedCallback/disconnectedCallback now
call this one function each way instead of manually pairing
makeBeforeUnloadHandler with raw addEventListener/removeEventListener
calls.
Loud-disclosed the in-app-nav limitation in two places it was previously
easy to miss: unsaved-work.js's own doc comment now leads with a boxed
"IT DOES NOT FIRE FOR HA'S OWN IN-APP SIDEBAR NAVIGATION" callout instead
of it being one clause inside a longer paragraph, and PR-BODY.md's finding
6 writeup gained the same callout plus this investigation's full writeup.
Tests, red-first: tests/js/unsaved-work.test.mjs gained 4 new cases
(installUnsavedWorkWarning registers a listener on install; its handler
reads the LIVE handle at fire time, not a snapshot from install time; it's
a safe no-op if the handle is ever null/destroyed at fire time; the
disposer actually removes the listener). Reverted
installUnsavedWorkWarning to a no-op, confirmed exactly the 3
registration/read/dispose-dependent new tests fail (the 4th -- "safe no-op
with a null handle" -- correctly still passed, a no-op is trivially also a
no-op), restored, confirmed all 10 green (20 total with
key-containment.test.mjs).
Live-verified end to end with the refactored (non-diagnostic) code, in the
real harness: no edit -> window.dispatchEvent(new Event('beforeunload',
{cancelable: true})) -> defaultPrevented: false (correct, no nag); Load
Demo (a real edit, lastEditAt set) -> same dispatch -> defaultPrevented:
true (correct, would warn). A real navigate() call during the same
investigation captured the diagnostic log proving the exact same code
path fires under a genuine top-level navigation, not just a synthetic
dispatched event.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Maintainer ruling: "typing uv run --group dev ... is bad UX." Everything under dev/ is this branch's own (verified: dev/ does not exist at all on main), so nothing here is a compatibility shim for pre-existing users. New dev/ha dispatcher (bash, set -euo pipefail): run/stop/inject [--count N]/snapshot/restore/verify/logs/token, bare `dev/ha` or `dev/ha help` prints the subcommand list. Thin, not a rewrite: run/stop/snapshot/restore/verify exec the existing scripts (dev/run.sh, dev/stop.sh, dev/snapshot.sh, dev/restore.sh, dev/verify-scripts.sh) unchanged, so every behavior (liveness gates, the crash detector, wedge checks) lives in exactly the file it always did and every script still works standalone too. inject wraps `uv run --group dev python dev/inject-displays.py` so the user never types uv. logs/token are new: logs is `tail -f` the running instance's HA log; token mints a dev-user access token via the same login-flow logic dev/internal/onboarding.sh already used, or prints the manual Profile-page recipe if minting fails. dev/internal/lib.sh (new, sourced -- no shebang-exec bit, no set -euo pipefail of its own so it doesn't surprise a caller catching a failure right after sourcing): DEV_DIR/REPO_ROOT/HA_CONFIG/PID_FILE/LOG_FILE/ HA_URL/CURL_TIMEOUT and fetch_dev_access_token, extracted out of dev/run.sh so dev/ha's own `token` subcommand doesn't duplicate ~25 lines of login-flow curl/jq logic -- one source of truth instead of two copies to keep in sync. dev/run.sh now sources it instead of defining these itself; every other behavior in dev/run.sh is untouched. Docs coherence pass: dev/README.md's command examples now use dev/ha throughout (Quickstart, fabricated-devices, real-hardware snapshot/ restore, stop/logs/reset instructions); the third-round "Live-tested detector paths" section's HA_PORT-specific findings (that env var is gone -- an earlier commit) were folded into one closing note instead of scattered through 5 numbered findings, since exactly how it used to bind a port is no longer instructional, just historical (the still-relevant findings -- crash detector, panel-poll death, soft-skip messages, healthy-boot banner -- stay as their own verified findings, renumbered). CONTRIBUTING.md's "Running the integration" section now reads as one coherent choice (real HA + hardware -> symlink; neither -> dev/ha) instead of a symlink paragraph with a harness paragraph bolted on after it, with updated commands. dev/run.sh's own runtime banner text and dev/inject-displays.py's/dev/snapshot.sh's/dev/restore.sh's own printed messages were updated too -- not just the docs a reader might open, but what the tool itself tells you to type next. Gates: scripts/test both legs (255/255 pytest + 20/20 JS, JS suite runs first via the existing scripts/test-js wiring -- untouched by this commit), scripts/lint clean, bash -n + shellcheck clean on every touched shell file (dev/ha and dev/internal/lib.sh checked together, resolving the dynamic source path cleanly; dev/snapshot.sh/dev/restore.sh's own pre-existing SC1091/SC2034 noise around dev/internal/storage-files.sh confirmed identical before and after this commit -- not introduced by it, out of scope to fix here). Live-verified in a scratch worktree (throwaway port, maintainer's own port-8123 process confirmed untouched throughout -- same PID before and after): fresh `dev/ha run` -> clean boot; `dev/ha inject` -> 3 fabricated entries written, no uv typed; `dev/ha run` again -> panel registers, render check passes; opened the designer panel in a real browser session against this exact chain -- mounts and works; `dev/ha token` -> a real minted JWT; `dev/ha stop` -> clean shutdown, PID file removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds .github/workflows/preview-release.yml: on push to designer-* branches on this fork, builds opendisplay.zip exactly like release-please.yml's own zip step, stamps manifest.json's version to the computed preview version (checkout-local, never committed) so HA's integration card/logs identify the exact preview build, and publishes it as a GitHub release (v<manifest-version>-<branch>.<run>) with the zip attached for HACS's zip_release install path. Guarded on github.repository so it goes inert once this branch/PR merges upstream; trigger (designer-*) never overlaps release-please's own (main), so there is nothing to race regardless of the guard. Documents the behavior in dev/README.md: every gated push is immediately HACS-pickable, though HACS still needs a manual redownload per release. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tier-2 real-hardware round. Root-caused the maintainer's reported
rotated-display SERVER-preview bug: not a formula bug -- rotate =
target - base (mod 360) is exactly what _drawcustom_for_device's own
contract requires, and tests/test_rotation_parity.py proves the render
endpoint and the drawcustom send path already produce byte-identical,
correctly-dimensioned output across the full (base, rotate) matrix,
including test_esl5_3_5_real_hardware_acceptance_vector -- the
maintainer's own reported ESL 5 3.5" attributes (184x384 native,
rotation_degrees=0, physically mounted landscape) as the canonical
vector.
What the investigation did find and fix: Send never carried a rotate
value at all. Extracted rotateDeltaFor into its own tested module
(frontend/panel/rotation.js, tests/js/rotation.test.mjs pinning exact
delta values across {0,90,180,270}^2 plus the real ESL5 vector -- a
sign-flipped formula would still pass a dims-only parity check) and
wired it into Send with the same sticky-memory shape dither already
uses, closing the gap the maintainer's manual rotate:270 helper script
existed to work around.
Also ships the font resolveAsset endpoint (maintainer ruling: "if the
server renderer can use it, the client must get it mapped"):
designer/asset.py resolves a font by the same bare-name convention
odl_renderer.fonts.FontManager uses, against the same
_font_search_dirs the send/render pipeline already searches,
path-traversal-guarded like the static view. Images excluded (no
font-independent image search path in this integration) and rejected
with a 400 rather than silently answering null forever. Panel wrapper
now supplies MountOptions.resolveAsset. tests/test_designer_asset.py
covers auth/404/400/traversal/content-type and a live temp-directory
font-serving check.
docs/designer.md documents both: a new "Displays mounted rotated"
workflow section, the tier-2 root-cause writeup (including the
separately-investigated Resolution-field label finding, not a bug --
vendored third-party UI), and the asset endpoint's own section.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e device-facing grid Reviewer re-verification of the tier-2 rotation fix (commit 4c8edf2) rejected its "not a formula bug" conclusion and named the real defect: designer/render.py's preview call fed the real device's own capabilities and the request's rotate value into prepare_image, exactly like the send path -- correct for a real upload (device-facing rotation, fits to the raw native pixel grid) and wrong for preview, which must return the LOGICAL surface (HostPreviewDisplayGeometry, vendored .d.ts: "never the raw physical panel size, never a transform to apply"). For a base=0 display with a 90/270 orientation (the maintainer's real ESL 5 3.5"), no rotate value could make the old endpoint return the transposed logical surface at all -- it always answered the native, untransposed grid, which the designer letterboxed into its own canvas: sideways content, despite rotateDeltaFor computing the correct delta the whole time. Fixed: the preview call to prepare_image now passes an explicit synthetic DeviceCapabilities (logical-surface dims, rotation=0) and rotate=Rotation.ROTATE_0, instead of the real device capabilities plus the request's rotate -- device-facing rotation belongs only on the send path. config is still passed alongside, for panel_ic_type/palette derivation from the real display. tests/test_rotation_parity.py rewritten: the old suite asserted only endpoint bytes == send-path bytes, which both the buggy and the send code path satisfied identically (both funneled through the same device-facing prepare_image call) -- structurally blind to this bug. Now asserts three independent properties per (base, orientation) cell: dimensions against an independently-derived formula, content orientation via an asymmetric top-edge-bar payload (catches a dims-correct-but-rotated-the-wrong-way regression), and pipeline parity against the send path's own real generate_image output re-run through prepare_image with the logical surface as target (not endpoint-vs-send bytes). Red-first confirmed against 4c8edf2: 13/17 new assertions fail for every cell where the transpose actually applies. docs/designer.md and PR-BODY.md rewritten to the corrected two-bug story (Send never carried rotate at all, fixed first; preview returned the wrong grid, fixed here) in place of the retracted "not a formula bug" narrative. Sticky-rotate wording (code comments, docs, PR body) reworded to state plainly that Send without a preceding preview ships rotate:0 -- sideways on hardware -- rather than softened "matching a default" language. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bumps the vendored designer to 3.0.0 via scripts/update-designer-vendor.py (registry sha512 verified before any write) and consumes both halves of that release. The rename: HostCapabilities -> HostDisplaySpec with camelCase fields, HostTarget.capabilities -> .display, HostPreviewServiceOptions -> HostRenderOptions with context.service -> context.render, and HostPreviewDisplayGeometry -> HostDisplayGeometry. Home Assistant entity attributes stay snake_case (HA's convention, capabilities.py unchanged); the panel's displaySpecFromAttrs() is the single translation point. The point of the round: Send now reads context.display.rotation and context.render.dither live at click time. _lastPreviewRotate and _lastPreviewDitherHA are deleted, with every caveat comment and doc passage that existed to explain their staleness -- a Send with no preview behind it no longer ships rotate: 0 and sideways content on a rotated panel. Preview and Send build their requests from one module, frontend/panel/ drawcustom-request.js, so the two channels cannot drift. The rotation mapping, unchanged in substance and now written down where the conversion happens: the designer reports an absolute on-screen orientation, while `rotate` is a delta the device composes onto its stored base rotation, so rotate = (context.display.rotation - target.display.rotationDegrees) mod 360. Preview still asks the endpoint for the untouched logical surface; only the send path hands a device-facing rotation to prepare_image. Tests: tests/js/drawcustom-request.test.mjs pins that a send built from nothing but an action context carries the live values and that two consecutive sends follow the controls rather than each other; tests/test_rotation_parity.py gains test_send_without_preview_lands_right_side_up, which drives a real drawcustom call with no preview anywhere in it and asserts on the buffer handed to upload_prepared_image. Both red-first confirmed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The designer panel and its two data endpoints are available to every authenticated Home Assistant user, and that is deliberate. Nothing about that behaviour changes here -- what was missing was the contract being stated anywhere, and being pinned so it cannot drift. Panel visibility, endpoint authorization, and the exposure of the service being fronted are kept consistent with each other. The render endpoint fronts `opendisplay.drawcustom`, which any authenticated user can already call, and renders the same payload templates through the same shared helper, so it grants no capability its caller does not already have. Home Assistant templates are read-only: they cannot call services and cannot execute code. Worth knowing, and now said plainly in docs/designer.md: payload fields expand with the full template context, so a designer user can read entity states and attributes and perform registry lookups (`device_attr`, `area_name`, `integration_entities`) that the frontend normally surfaces only on admin config pages. Information disclosure only. A deployment that wants the designer restricted restricts it at the Home Assistant user level; the integration does not invent its own permission model. - docs/designer.md gains an "Access and exposure" section covering the above, and why the static asset view is unauthenticated by necessity (a `<script type="module">` import sends no Authorization header) and what it does and does not serve. - The `require_admin=False` call site says why it is deliberate. - Three tests pin the contract: an authenticated non-admin can use the render and asset endpoints, and the panel is not admin-only. The pre-existing requires-auth tests pin the other half, and a new test documents why the static view cannot be gated at all. Verified: the three new tests fail against a deliberately admin-gated build (401 vs 200, and require_admin True vs False), so they discriminate rather than merely restating today's behaviour; confirmed live against the dev/ha harness with a read-only user created over the WebSocket auth API. scripts/test 284/284 and scripts/test --min-ha 284/284 pass; scripts/lint clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A payload referencing an image by absolute path (`/media/logo.png`, the way a hand-written drawcustom payload does) rendered correctly on the server but showed as missing in the designer: the asset endpoint served `kind=font` only, and the panel short-circuited every other kind to "not supplied" without even asking. Reported from real hardware. `kind=image` now resolves the same local files the render and send paths resolve, and is deliberately stricter than the renderer is, because it returns file bytes to any authenticated user rather than drawing with them: * permitted roots are Home Assistant's own `allowlist_external_dirs` (`<config>/www`, every `media_dirs` entry, plus whatever the operator allowlisted) -- no path policy invented here; * containment is re-checked after `resolve()`, so `..` is collapsed and symlinks are followed first: a symlink inside a permitted root that points outside it is refused; * `http(s)://` sources are refused outright rather than proxied; * only files PIL can identify as images are served, so a media directory's non-image files stay unreadable; * every path-related refusal answers the same 404, so the endpoint is not an existence oracle. An image outside the permitted roots therefore still renders on send while showing the designer's explicit missing-asset state -- the safe direction of that mismatch, and documented as such. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Home Assistant's blocking-call detector fired on a real instance:
Detected blocking call to open with args ('/media/pohl89-480h.png',
'rb') inside the event loop by custom integration 'opendisplay'
`generate_image` is awaited on the loop, and a `dlimg` element whose `url`
is a local path reaches `odl_renderer.media_loader._load_from_file`, which
opens it with PIL right there. Both call sites -- the designer's render
endpoint (the reported offender) and `_drawcustom_for_device`'s own send
path -- now decode those sources in an executor first and hand the renderer
an already-loaded image, which `load_image` passes through untouched.
Transparent by construction: a source it cannot load is left exactly as it
was, so the renderer raises its own error, with its own message, at the
point it always did. Remote and `data:` sources are not touched at all.
Verified against a live instance: the warning above reproduces verbatim
with the fix reverted and is absent with it, on both call sites. The
accompanying tests assert the underlying property rather than the warning,
because Home Assistant does not install that detector under pytest
(`builtins.open` is registered `skip_for_tests=True`) -- they record which
thread the payload's image is opened on.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…as drawn on Reported from real hardware and reproduced without the designer, by calling `opendisplay.drawcustom` directly: with `rotate: 90` the wall was upside down while Home Assistant's image entity looked upright, and with `rotate: 270` both were upright. Same payload, same device. The entity's picture was built from the pre-rotation logical surface, and that surface is identical for two opposite orientations -- 90 and 270 produce the same canvas and differ only in which way it is turned onto the panel. So the preview could not tell them apart, and a wrong orientation was invisible everywhere in Home Assistant, discoverable only by walking to the display. It is now built from the buffer handed to `upload_prepared_image`: the post-rotation, post-fit, post-dither frame the panel actually receives. The picture is therefore in the panel's own pixel grid (a portrait panel mounted landscape shows a portrait picture with the content on its side), which is the point -- it changes when the orientation choice changes. The rotation mapping itself is unchanged and is correct: a panel has one mounting, so exactly one of any opposite pair is upright and flipping the sign would only move the upside-down case. Four-orientation characterisation tests pin what each one puts in the uploaded buffer, so a future change to the mapping is visible rather than silent. Affects every `drawcustom` and `upload_image` caller, not just the designer. `dry-run: true` builds no device buffer and still previews the canvas. The designer's own preview endpoint deliberately keeps returning the logical surface -- that is the designer's canvas, a different artifact. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ules Orientation describes how the panel is mounted, and the canvas always draws content upright -- so 0/180 look alike, 90/270 look alike, and only one of each pair matches any given wall. That is stated in plain language a user can check against their own display, together with the fix that makes the choice visible in Home Assistant. Also documents the asset endpoint's image half: the permitted roots, why they are narrower than the renderer's, and what to do about an image that sits outside them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Dry run should be honest of course, otherwise it won't be a dry run" (maintainer ruling). It wasn't: the dry-run branch published the pre-rotation, un-dithered canvas -- a picture of something no panel would ever be given. It ignored `dither` entirely (a gap the docs had recorded rather than fixed), and because the canvas is identical for two opposite orientations it could not show which way up the frame would land, which is exactly what a dry run is for. Preparation is now one shared step, `_prepare_for_device`, factored out of `_async_send_image` and called by both. Same rotate, same dither, same tone and measured-palette derivation, same device config, same compression decision -- derived inside the helper so neither caller can drift from the other. A dry run previews that buffer and returns a `dry_run` receipt. It still does nothing else a real send does: no upload, no queue entry, no partial-state reset, no other receipt. Verified live -- a dry run leaves the entity's `queued_at` untouched while a real send's queued frame is still pending. A preparation failure now propagates instead of being skipped, and that is deliberate: if the frame cannot be prepared for this device, the real send would fail the same way, so reporting it is the honest answer and the caller learns it without sending. The same applies to warnings -- a dry run against a panel whose width is not byte-aligned now surfaces the firmware truncation warning that only a real send used to emit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Moves the vendored designer pin from 3.0.0 to the newest published release, via the existing procedure (`scripts/update-designer-vendor.py --pin 3.2.0`) -- registry integrity verified before anything was written, lock entry updated, no vendor file hand-edited. The pin will keep moving at every opportunity while this work is in development, rather than aging in place until the end. What to look for on a live instance: 3.2.0 adds an "Always upright" hint beside the designer's Orientation control -- the same point the integration's own docs now make in prose, that the canvas always draws content upright so 0/180 and 90/270 look alike and only one of each pair matches a given wall. Panel wrapper re-verified against the new bundle rather than assumed compatible. The published `odl-drawcustom-designer.d.ts` is byte-identical between 3.0.0 and 3.2.0, and every type and member the wrapper reads or writes was checked to still exist: `mount`, the five `MountHandle` methods it calls, the ten `MountOptions` keys it passes, `HostTarget`, the ten `HostDisplaySpec` keys it writes, `HostActionContext`/`HostPreviewContext` with their `display`/`render` members, `HostAction` including `severity: 'caution'`, `DesignerStatus`'s `yamlValid`/`yamlErrorSummary`, and `EmbedTheme`. Nothing changed shape; no wrapper change was needed. The bundle's own `version` export reads 3.2.0, and the panel's cache-busting token is bumped so browsers pick the new bundle up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Moves the vendored pin from 3.2.0 to the newest published release, via the existing procedure (`scripts/update-designer-vendor.py --pin 3.2.1`) -- registry integrity verified before anything was written, lock entry updated, no vendor file hand-edited. What changes on screen, because this is a visible one: - multiline `offset_y` is now the per-line advance on the canvas, matching upstream `draw_multiline`. The canvas previously ignored it and used `spacing`, which upstream never reads -- so an existing multiline payload will look DIFFERENT in the designer than it did in 3.2.0. That is the fix: the canvas now agrees with what the server paints. - multiline anchors per line, defaulting to `lm` like upstream, instead of block-anchoring at `lt` -- this shifts a block's vertical origin by about half a line height. - `spacing` is gone from the multiline schema, so a payload still carrying it now fails validation and blocks the canvas until the key is removed. The designer-side import cleanup for that is still in review upstream and is NOT in 3.2.1. - the property panel can no longer drop a required field: clearing one is refused, and setting one to its default no longer deletes it from the YAML. - Clear all, Load Demo and Undo each rescue a document whose YAML is invalid, behind a confirmation; a host `setPayload()` re-push rescues one too. - `anchor` is offered on multiline in the property panel and completions. Panel wrapper re-verified rather than assumed compatible. The published `odl-drawcustom-designer.d.ts` is byte-identical to 3.2.0's, and every type and member the wrapper reads or writes was checked to still exist: `mount`, the six `MountHandle` methods it uses, the ten `MountOptions` keys it passes, `HostTarget`, the ten `HostDisplaySpec` keys it writes, `HostActionContext`/`HostPreviewContext` with their `display`/`render` members, `HostAction` including `severity: 'caution'`, `DesignerStatus`'s `yamlValid`/`yamlErrorSummary`, `AssetKind` and `EmbedTheme`. Nothing changed shape; no wrapper change was needed. The bundle's own `version` export reads 3.2.1, and the panel's cache-busting token is bumped so browsers pick the new bundle up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Moves the vendored pin from 3.2.1 to the newest published release, via the existing procedure (`scripts/update-designer-vendor.py --pin 3.3.0`) -- registry integrity verified before anything was written, lock entry updated, no vendor file hand-edited. (3.2.2 published in between and is superseded by this.) 3.3.0 smooths the rough edge 3.2.1 left behind, all on import: - a payload missing a vertical coordinate gets an explicit `0` materialized -- `text.y`, `multiline.y`, `line.y_start` -- with a dismissible notice naming what changed. An empty `y:` (YAML `null`, which the schema rejects) is fixed the same way. - a stale `spacing` key on a `multiline` is stripped rather than blocking the canvas. In 3.2.1 such a payload failed validation outright; this is the fix for that. - "import" means the share-link hash, the `payload` mount option, and a host `setPayload()` push. Text the user types or pastes is deliberately not rewritten, and neither is a restored session. - a `setPayload()` re-push now rescues an unparseable editor even when the pushed design equals the committed one. The last two change what a host push does, so the wrapper was audited for the obvious hazard: `getPayload()` may now return a normalized string rather than the exact bytes handed to `setPayload()`. Our wrapper cannot be affected -- it calls neither method. It passes a constant `payload: '[]\n'` at mount, receives payload YAML from the designer through `onAction` and `renderPreview`, and parses it; it never compares a string it pushed against one it read back. Its unsaved-work signal reads `getStatus().lastEditAt`, which the contract states is never bumped by a host push, so import normalization cannot make a freshly mounted panel claim unsaved work. Panel-vs-bundle re-verified: the published `odl-drawcustom-designer.d.ts` is byte-identical to 3.2.1's, and all 57 checked members the wrapper reads or writes still exist, none changed shape -- `mount`, the six `MountHandle` methods used, the ten `MountOptions` keys passed, `HostTarget`, the ten `HostDisplaySpec` keys written, `HostActionContext`/`HostPreviewContext` with their `display`/`render` members, `HostAction` including `severity: 'caution'`, `DesignerStatus`, `AssetKind` and `EmbedTheme`. No wrapper change needed. The bundle's own `version` export reads 3.3.0, and the panel's cache-busting token is bumped so browsers pick the new bundle up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The render endpoint's device_id path built a synthetic, device-less DeviceCapabilities describing the logical surface and always passed rotate=ROTATE_0, so an orientation 90 and orientation 270 preview were pixel-identical -- the maintainer flashed v2.9 and asked why a rotated mounting still looked the same both ways. The entity preview and a dry run already show the device-facing buffer (tier-2 round 3); this made the designer's own Display preview the one remaining view that could not catch a wrong orientation before Send. A device_id request now calls _prepare_for_device (services.py), the same helper the send and dry-run paths share: real device capabilities, rotate composed onto the device's own stored base exactly as the send path derives it. Preview, dry run and a real send now produce the identical device-facing buffer for identical inputs. The Virtual-display (display-spec) path is unchanged -- there is no HA device to be device-facing about, and the panel already sends rotate: 0 for it. This does not reintroduce the tier-2 round 2 shape bug it reverses: that bug was in feeding prepare_image a source image at the wrong shape, not in the rotate delta itself. generate_image's canvas is still built at the transposed logical surface via the same gen_width/gen_height formula the send path uses, so prepare_image's device-facing rotate-and-fit always lands exactly on the native grid with no distortion. Tests (tests/test_rotation_parity.py, tests/test_designer_render.py): - endpoint output is now the native device grid, with the bar landing on the edge implied by (base + rotate) % 360, across a (base, rotate) matrix - orientation 90 and 270 previews are proven 180 degrees apart - preview output matches _prepare_for_device's own pixel output, and a real send's prepared buffer byte-for-byte, for the same inputs - test_render_prepare_image_kwargs_match_send_path_derivation's patch target moved to services.prepare_image (render.py no longer calls prepare_image directly for a device_id request) docs/designer.md updated: the render endpoint's response is now described as device-facing for device_id requests (still the logical surface for Virtual display), with a dated note on the reversal and why it is safe. Verified: scripts/test (315 passed, up from baseline 312), scripts/test --min-ha (315 passed), scripts/lint clean, and manually against dev/ha against a real running HA instance (fabricated 296x128 BWR device, base rotation 0): the render endpoint's rotate=90 and rotate=270 responses land the bar on opposite edges (right vs left) and match the shape/edge of the dry-run's own published entity buffer for rotate=270. UNVERIFIED without hardware: the maintainer's own physical ESL 5 3.5" panel actually rendering upright/upside-down as expected on the wall. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… build number - Vendor designer 3.4.1 (was 3.3.0, newest on the registry): re-verified sha512 integrity via scripts/update-designer-vendor.py --pin 3.4.1, bumped DESIGNER_FRONTEND_BUILD to 20260901d so browsers load the new bundle. 3.4.1 also fixes the embedded panel header falling back to a commit SHA instead of the library's own APP_VERSION -- expected to show the designer version now, not independently verifiable without a browser. - Adopt the new (3.4.0) MountOptions.hostOwnsAssets with a Home-Assistant hint: designer-local uploads land in one browser's IndexedDB and never reach Home Assistant, so an uploaded asset renders in the designer and then fails on send. The hint names the directories designer/asset.py can actually resolve from (hass.config.allowlist_external_dirs, in practice /config/www and /media for images; a fonts/ subfolder under either root for fonts, matching services.py's _font_search_dirs) so the Content tab's read-only explorer tells the user where a file needs to live. - Re-verified the panel wrapper against the newly vendored .d.ts: 45 types/members it reads or writes, zero misses; the only shape change since 3.3.0 is the additive hostOwnsAssets key now adopted above. - Zero-pad the preview release run number (%03d) in .github/workflows/preview-release.yml so preview versions keep sorting correctly past v2.99 (maintainer ruling: unpadded numbers sort wrong, e.g. v2.9 read as newer than v2.10). Verified: scripts/test (315 passed), scripts/test --min-ha (315 passed), scripts/lint (all checks passed), scripts/test-js (40 passed, run as part of scripts/test). dev/ha run confirmed the panel still serves and the vendored bundle string now reads 3.4.1; the panel-visual header fix and Content-tab wording are UNVERIFIED without a browser. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Registry check: 3.4.2 is the newest published version, nothing newer.
scripts/update-designer-vendor.py --pin 3.4.2; lock entry:
designer: 3.4.2, sha512-NeIhmsHxzoGeJ/1zNBJkMZPQN1pZQcq5kIjgopBKg54XcGfgti/xLu5pRY73RzvKQayTWx2+urGNE44uiy/GEA==
js-yaml re-verified unchanged at 4.1.0.
The vendored .d.ts changed by 10 lines, all doc-comment wording only ("the
release pipeline" instead of naming an internal script) -- no type or
member added, removed, or reshaped; this release's only user-visible
content is the designer's own pipeline unification. Re-ran the panel-vs-
bundle member check: 45 types/members the panel wrapper reads or writes,
zero misses.
Bumped DESIGNER_FRONTEND_BUILD (20260901d -> 20260902a: new calendar day)
so browsers load the new vendor bytes, and updated docs/designer.md's pin
sentence and byte-identical-since-3.3.0 accounting.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…mver A leading zero in a prerelease numeric identifier is invalid semver, and Home Assistant rejects the manifest outright: The custom integration 'opendisplay' does not have a valid version key (3.0.2-designer-v2.012) in the manifest file and was blocked from loading. The integration then does not load at all, so every service it provides is gone — `opendisplay.drawcustom not found` — and any automation calling it fails. Reported from a live install running the v2.012 preview. The padding was added to fix release ordering in the HACS/GitHub UI (an unpadded v2.9 reads as newer than v2.10). That is cosmetic; an unloadable integration is not. Reverted, with the reason recorded next to the code so it is not reintroduced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ident commit e47f712 fixed a live outage (a zero-padded preview run number, "3.0.2-designer-v2.012", is invalid semver, so Home Assistant's loader refused to load the integration at all -- every opendisplay.* service vanished for ~40 minutes) but shipped without a test. Nothing in this repo checked that the thing the whole install depends on is actually loadable. Extracts the version-composition step preview-release.yml was doing inline into scripts/preview_version.py (pure `f"{base}-{branch}.{run_number}"`, no third-party deps -- the workflow has no Python venv) and has the workflow call it, so the tested procedure is the shipped procedure rather than a parallel reimplementation. tests/test_preview_version.py validates composed versions against Home Assistant's own check -- `AwesomeVersion(..., ensure_strategy=[...])` as called by homeassistant.loader.Integration.resolve_from_root, read straight out of the installed package rather than guessed -- not a hand-rolled regex. Confirmed red-first: reconstructing the exact pre-fix input ("012", what `printf '%03d' 12` produced) and asserting HA would accept it fails with the same AwesomeVersionStrategyException the maintainer's install logged. Also covers: existing prerelease in the base version, a hyphenated branch suffix, and (documented, not asserted-fixed since it is unreachable through this workflow's `designer-*` trigger glob) a branch name containing "/". Checked for other version-stamping paths: release-please (via .release-please-config.json's jsonpath extra-file) computes ordinary semver bumps itself and never composes a branch/run-number suffix, so it cannot reproduce this bug class. hacs.json is static. No other workflow or dev/ tooling stamps a version. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
preview-release.yml was hard-gated to one fork's `owner/repo` and one
branch prefix (`designer-*`), so it shipped upstream as inert scaffolding.
Generalizes it into a feature every fork gets automatically:
- `github.event.repository.fork` (true for any fork, false upstream)
replaces the hardcoded repository-name guard. Verified present on a real
push-event payload: `gh api repos/<owner>/<repo> --jq '{fork,
default_branch}'` against this repo returns both fields, and Actions'
`github.event.repository` is the same GitHub "repository" object shape
whether from the REST API or a webhook payload.
- `github.event.repository.default_branch` (not a hardcoded `main`)
excludes a fork's own default branch, which tracks upstream.
- The trigger widens from `branches: ['designer-*']` to `branches: ['**']`
-- any branch in a fork, not just one prefix.
A branch name is otherwise unconstrained free text and gets embedded in a
semver prerelease identifier. Per maintainer ruling, sanitize rather than
restrict the trigger: `scripts/preview_version.py` gains
`sanitize_branch_for_version`, mapping ANY branch name onto
`[0-9A-Za-z-]+`, non-empty, non-numeric-leading, length-capped -- so a
branch like `feat/foo` composes an HA-loadable version instead of
reproducing the zero-padding incident (`3.0.2-designer-v2.012`, leading
zero, HA blocked the whole manifest, every service disappeared) via a
different route. Red-first: composing `("3.0.2", "feat/foo", "7")`
unsanitized failed with `AwesomeVersionStrategyException: Strategy unknown
does not match [...] for 3.0.2-feat/foo.7` against Home Assistant's own
`AwesomeVersion` check; sanitized, every test-matrix case (slashes, dots,
underscores, mixed case, leading digit, leading-zero-numeric, hyphen runs,
separator-only names, a 200-char name, `designer-v2` itself unchanged)
passes both HA's validator and `git check-ref-format` for the resulting
tag.
Also stamps manifest.json's `name` -- `<name> (fork: <owner>/<repo>)` from
`github.repository`, checkout-only like the version stamp -- so a fork's
build self-identifies on Home Assistant's Devices & Services page with no
hand-edited GitHub repo description. Confirmed against HACS's own source
(hacs/integration `repositories/base.py`) that this is the right target:
`display_name` returns hacs.json's tracked "name" unconditionally if set,
read live from the git tree (never a release asset, and hacs.json isn't
even packaged into the zip) -- so neither hacs.json nor manifest.json can
be made to affect HACS's own list card by a checkout-only, never-committed
stamp. `domain` stays `opendisplay` so a fork still replaces the stock
integration.
Docs: CONTRIBUTING.md's "Running the integration" section gains a third
option (push your branch, get an installable build); dev/README.md's
"Preview releases" section is rewritten to describe the generic behavior
instead of one fork's specifics.
Swept dev/, scripts/, .github/, docs/ for other fork-specific hardcoding:
only dev/README.md needed generalizing (fixed above). The `@schlomo/
odl-drawcustom-designer` references in scripts/update-designer-vendor.py
and docs/designer.md name a real, separately-published npm package this
repo vendors -- unrelated to this workflow's fork identity, left as-is.
The historical incident string `3.0.2-designer-v2.012` in this workflow's
comments and scripts/preview_version.py's docstring is a verbatim citation
of what actually happened, not a functional assumption -- left as-is.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
There is a Python syntax error in custom_components/opendisplay/designer/image_entity.py (except AttributeError, ...:) that will break imports and must be fixed before merging.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds an “OpenDisplay Designer” sidebar panel for visually authoring and sending drawcustom payloads, plus supporting backend endpoints (render + asset resolution), panel static asset serving, and a dev/CI harness to test and distribute preview builds safely.
Changes:
- Introduces the designer panel integration surface: panel registration, static asset view, authenticated render/asset endpoints, and device capability exposure via image entity attributes.
- Vendors the designer frontend library (and
js-yaml) in-repo with a pinned+verified update script and cache-busting behavior for served modules. - Expands automated coverage: new Python tests for service/template expansion, preview-version semantics, asset security rules, image I/O off the event loop, plus JS unit tests for panel request-building and keyboard/unsaved-work behavior; adds a local dev harness and a fork-only “preview release” workflow.
File summaries
| File | Description |
|---|---|
| uv.lock | Adds home-assistant-frontend to support frontend/panel_custom dependency setup under tests. |
| tests/test_services.py | Adds tests for template expansion and broken-template validation on drawcustom. |
| tests/test_preview_version.py | Adds regression tests for preview version composition and HA loader acceptance. |
| tests/test_payload_image_loading.py | Adds tests asserting local image loads do not block the event loop, for both render endpoint and send path. |
| tests/test_image.py | Adds tests for designer capability attributes and placeholder image behavior on fresh entities. |
| tests/test_designer_panel.py | Adds tests for panel static asset serving, cache-busted vendor imports, and panel registration properties. |
| tests/test_designer_asset.py | Adds tests for authenticated asset endpoint (fonts + images) including traversal/symlink/allowlist protections. |
| tests/js/unsaved-work.test.mjs | Adds JS unit tests for beforeunload unsaved-work warning wiring and behavior. |
| tests/js/rotation.test.mjs | Adds JS unit tests pinning rotation delta calculation across all base/target combinations. |
| tests/js/key-containment.test.mjs | Adds JS unit tests for keyboard event containment (HA shortcut suppression without breaking designer shortcuts). |
| tests/js/drawcustom-request.test.mjs | Adds JS unit tests for render/send request builders (no stale preview state, WYSIWYG send). |
| tests/js/asset-request.test.mjs | Adds JS unit tests ensuring both font and image assets are routed to the endpoint with correct encoding. |
| scripts/update-designer-vendor.py | Adds a pinned, integrity-verified procedure to vendor npm tarball contents into the panel’s vendor/ dir. |
| scripts/test-js | Adds a Node-based runner for JS unit tests (node --test). |
| scripts/test | Runs scripts/test-js as part of the repo’s test gate script. |
| scripts/preview_version.py | Extracts preview manifest name/version composition into a testable script used by CI workflow. |
| scripts/ha-component-reqs | Updates component dependency roots to include frontend and panel_custom. |
| README.md | Documents built-in-vs-custom integration precedence and links to the designer docs. |
| pyproject.toml | Adds home-assistant-frontend to dependency groups; documents rationale; adds a ruff ignore for a dev CLI script. |
| dev/verify-scripts.sh | Adds fixture-based regression checks for dev storage snapshot/restore scripts. |
| dev/stop.sh | Adds harness stop helper to prevent rewriting storage under a live HA process. |
| dev/snapshot.sh | Adds snapshotting of opendisplay-related .storage records into dev/seed/. |
| dev/seed/fixtures/README.md | Documents synthetic storage fixtures used for script verification. |
| dev/seed/fixtures/entity_registry.json | Adds synthetic entity registry fixture data for snapshot/restore verification. |
| dev/seed/fixtures/device_registry.json | Adds synthetic device registry fixture data for snapshot/restore verification. |
| dev/seed/fixtures/config_entries.json | Adds synthetic config entries fixture data for snapshot/restore verification. |
| dev/seed/.gitignore | Prevents committing real snapshot data while allowing committed fixtures. |
| dev/restore.sh | Adds restore/upsert logic to merge seed snapshots back into a fresh dev instance. |
| dev/internal/storage-files.sh | Centralizes mapping of HA storage keys to filenames/paths/id fields used by snapshot/restore. |
| dev/internal/onboarding.sh | Adds scripted onboarding via HA API with wedge detection and cleanup of onboarding-added integrations. |
| dev/internal/lib.sh | Adds shared harness constants and best-effort dev token minting logic. |
| dev/inject-displays.py | Adds generation of fabricated devices written directly to .storage for no-hardware testing. |
| dev/ha-config/configuration.yaml | Adds minimal HA config for the harness (no default_config, includes frontend, etc.). |
| dev/ha-config/.gitignore | Ignores generated HA config state while tracking configuration.yaml. |
| dev/ha | Adds a single CLI dispatcher for harness operations (run/stop/inject/snapshot/restore/verify/logs/token). |
| custom_components/opendisplay/manifest.json | Adds frontend and panel_custom dependencies. |
| custom_components/opendisplay/image.py | Adds designer capability attributes and placeholder setup hook to the image entity. |
| custom_components/opendisplay/designer/panel.py | Adds static-file serving view for panel/vendor assets with cache-busting and safe path resolution. |
| custom_components/opendisplay/designer/image_entity.py | Adds designer hooks for capability attrs + placeholder image on first load. |
| custom_components/opendisplay/designer/frontend/vendor/THIRD_PARTY.md | Adds bundled dependency notices for the vendored designer library. |
| custom_components/opendisplay/designer/frontend/vendor/README.md | Documents vendored packages, pins, and update procedure. |
| custom_components/opendisplay/designer/frontend/vendor/NOTICE | Adds notice file for the vendored designer library and referenced materials. |
| custom_components/opendisplay/designer/frontend/vendor/LICENSE.js-yaml | Adds js-yaml MIT license text. |
| custom_components/opendisplay/designer/frontend/vendor/LICENSE | Adds Apache-2.0 license text for the designer library. |
| custom_components/opendisplay/designer/frontend/vendor/designer.lock.json | Adds pinned versions + npm integrity hashes for vendored packages. |
| custom_components/opendisplay/designer/frontend/panel/unsaved-work.js | Adds beforeunload unsaved-work warning implementation. |
| custom_components/opendisplay/designer/frontend/panel/rotation.js | Adds shared rotation delta derivation for preview/send. |
| custom_components/opendisplay/designer/frontend/panel/key-containment.js | Adds selective keyboard event containment to prevent HA global shortcuts from hijacking the editor. |
| custom_components/opendisplay/designer/frontend/panel/drawcustom-request.js | Adds pure request builders for render endpoint and service send, derived from live designer context. |
| custom_components/opendisplay/designer/frontend/panel/asset-request.js | Adds pure asset request URL builder supporting both font and image kinds. |
| custom_components/opendisplay/designer/capabilities.py | Adds capability serialization for the designer (dimensions, rotation, palette/color map, etc.). |
| custom_components/opendisplay/designer/asset.py | Adds authenticated asset endpoint resolving fonts by search dirs and images by allowlisted absolute paths. |
| custom_components/opendisplay/designer/init.py | Registers designer views and sidebar panel (non-admin) at integration setup. |
| custom_components/opendisplay/init.py | Calls async_setup_designer during integration setup. |
| CONTRIBUTING.md | Documents three ways to run/test (symlink, dev harness, fork preview releases). |
| .github/workflows/test.yml | Adds CI step to run JS unit tests (once on the latest leg). |
| .github/workflows/preview-release.yml | Adds fork-only workflow producing HACS-installable preview releases with safe version/name stamping. |
Review details
- Files reviewed: 58/68 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| try: | ||
| display = entry.runtime_data.device_config.displays[0] | ||
| width, height = int(display.pixel_width), int(display.pixel_height) | ||
| if width <= 0 or height <= 0: | ||
| width, height = 296, 128 | ||
| except AttributeError, IndexError, TypeError, ValueError: | ||
| width, height = 296, 128 |
There was a problem hiding this comment.
This is valid on every Python this project supports, so no change here — but it is a fair thing to double-check.
except A, B: without an as clause has been legal since Python 3.14 (PEP 758, unparenthesized except expressions). pyproject.toml sets requires-python = ">=3.14.2", so 3.13 and earlier are out of scope, and both CI legs (dev and the min-ha floor from hacs.json) import this module and pass.
It also matches the existing style in this repo — custom_components/opendisplay/__init__.py:127 already uses the same form:
except KeyError, ValueError, TypeError:Happy to parenthesize both if you would rather not rely on the 3.14 syntax, but I did not want to leave the designer module inconsistent with the rest of the integration.
No agent/AI instruction file existed anywhere in this repo — no AGENTS.md, no .github/copilot-instructions.md, no .cursor/, no CLAUDE.md. That absence is exactly why a review bot flagged `except AttributeError, IndexError, TypeError, ValueError:` in designer/image_entity.py as invalid Python 2-style syntax: this project's floor is Python 3.14.2 (pyproject.toml), where PEP 758 makes that form legal, and the codebase already uses it elsewhere (custom_components/opendisplay/__init__.py). AGENTS.md is the canonical file (points at CONTRIBUTING.md rather than duplicating it) covering: the Python-floor/PEP-758 fact above; Conventional Commits on every commit (not just the PR title) and why; the two pytest dependency groups and what a --min-ha-only failure means; HA component requirements being invisible to uv; which files in this repo are generated and must not be hand-edited (vendored designer JS, non-English translations, uv.lock, manifest.json's version field); and that preview releases fire on forks only. .github/copilot-instructions.md is a five-line pointer at it, for the tool that reads that path specifically. Every rule traces to an existing file in this repo (pyproject.toml, CONTRIBUTING.md, hacs.json, .release-please-config.json, the workflows, scripts/update-designer-vendor.py, scripts/translate.py) -- nothing here is invented policy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e change) Vendored via scripts/update-designer-vendor.py --pin 3.4.3 -- no hand edits to vendor/ or the lock. Registry checked at run time: 3.4.3 is the newest published version (dist-tags.latest), nothing newer has landed. Verified rather than assumed: this is the first release under the designer repo's new "every push to main bumps at least a patch" rule, applied to a docs-only change. Diffed both npm tarballs directly -- odl-drawcustom-designer.d.ts is byte-identical to 3.4.2's, and the only line that changed in the built bundle is its own embedded build-identity constant (commit SHA and version string). package.json's version field was the only other change. No behavioural difference, no embed-surface change. Re-ran the panel-vs-bundle member check against the (unchanged) 3.4.3 .d.ts: every type/member the panel wrapper reads or writes -- mount, all 5 MountHandle methods used, all 11 MountOptions keys, all 10 HostDisplaySpec keys, HostActionContext/HostPreviewContext's targetId/display/render, HostAction's id/label/icon/severity/ disabledReason, HostTarget's id/label/display, DesignerStatus's yamlValid/yamlErrorSummary, EmbedTheme's light/dark -- 42 members checked, zero MISS. Bumped panel.py's DESIGNER_FRONTEND_BUILD cache-bust token (20260902a -> 20260903a): the vendored bundle changed but panel.js's own mtime doesn't cover that, per the token's own doc comment. Updated the pin sentence and the per-release verification narrative in docs/designer.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds a visual editor for
drawcustompayloads as a Home Assistant sidebarpanel: pick a display, compose a layout on canvas or in YAML, preview what it
will actually show, and send it. The editor —
@schlomo/odl-drawcustom-designer— is new here, vendored from npm behind a thin panel wrapper this repo owns;
it never talks to Home Assistant directly. Builds on
@jonasniesner's exploration in #100 — a
fresh implementation, not a rebase. (Closes #100)
Try it out now via HACS
The quickest way to judge this is to run it:
(category: Integration). Manual repo:
schlomo/OD_Home_Assistant_IntegrationIt uses the same
opendisplaydomain, so it replaces the stock integrationwhile installed and you can go back by reinstalling the official one.
Fixed in this integration along the way
Wiring the panel to real hardware surfaced bugs that have nothing to do with
the designer:
from the pre-rotation image, so two opposite mountings produced an
identical picture. Pre-existing, not introduced by this PR; the preview is
now the frame actually sent to the panel.
a payload referenced a local image, in the send path and again in the new
render endpoint. Fixed in both.
dry-run: truepreviewed a picture no panel would ever receive — itignored
ditherand rotation. It now shows exactly what a real send wouldproduce.
drawcustomcarried no rotation at all unless a preview had run first,so a design could ship sideways.
this integration, from the same files a real send uses. Previously only
fonts worked.
Decisions we made — tell us if you'd rather do it differently
None of these are load-bearing for the feature; they are the choices we had
to make to ship it, and we are happy to change any of them.
registry's own checksum before the files are written, rather than a
submodule or a build step — so a plain checkout stays runnable. If you'd
prefer the files fetched at release time instead of committed, we'll
implement that.
translates your entity attributes into the editor's own contract, so the
editor knows nothing about Home Assistant. If you'd rather the editor
spoke your shapes directly, that's a different seam and we can discuss it.
remembered preview — otherwise changing the orientation with preview off
would ship stale values.
fronts
opendisplay.drawcustom, which any authenticated user can alreadycall, and templates are expanded read-only. Gating the panel more tightly
than the service it fronts seemed inconsistent rather than safer — easy to
flip if you disagree.
an upload lives in one browser and never reaches Home Assistant, so it
would render on canvas and then fail on send. The editor's Content tab
stays visible read-only and points at where a file actually belongs
(
/config/wwwor/media, fonts in afontssubfolder).Known limitations
background: whiteandrefresh_type: full; the rest ofthe option set needs the editor's contract to grow first.
choice, so Preview and Send compute the difference themselves.
I/O is fixed), bounded by an element cap.
elsewhere still renders on Send but shows as missing in the editor.
Authorship
This work — code, tests, docs, and this description — was written by
Claude (Anthropic), directed by @schlomo, who reviewed it and is submitting it, and is accountable for this PR.
Dev harness & test automation
We needed a way to start a test HA installation with this integration and decided to add a very slim and focused framework for this in the
dev/folder.dev/hais the main script. It boots a real, local Home Assistant with this integration installed —native Python via
uv, no Docker — and seeds it with fabricated OpenDisplaydisplays in three geometries and colour schemes (mono, BWR, BWRY), via the
same cache-fallback path a sleepy device uses, so no BLE connection is
attempted. That gives a live integration, entities, and the designer panel to
exercise end to end without hardware; BLE delivery itself needs real
hardware.
Also new: preview builds. Push a branch to any fork and the workflow cuts an
installable HACS build automatically, self-identifying as that fork
(
OpenDisplay (fork: <owner>/<repo>)in Devices & Services) — so a reviewercan try someone's change without building anything. It never runs on the
upstream repository.
New test-side: a JS suite for the panel's browser logic that
scripts/testnow runs next to pytest, plus tests for the panel's rotation and request
building. Your existing suites carry the rest — 358 pytest and 40 JS pass, on
the current Home Assistant and on the 2026.7.0 floor leg, lint clean. Every
fix above has a failing-first test, pixel-pinned against the real send
path.
@schlomo verified on real OpenDisplay ESLs what tests cannot: correct
rotation on the wall, the HA preview matching the panel, and
/mediaimageresolution.