Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
70b680d
chore(dev): hardware-free local HA harness with fabricated displays
schlomo Aug 30, 2026
79b32b6
feat(designer): vendor odl-drawcustom-designer + js-yaml from npm, pi…
schlomo Aug 30, 2026
f61504c
chore(dev): native-Python dev harness -- no Docker, minimal integrations
schlomo Aug 30, 2026
7c90760
fix(services): expand Home Assistant templates in drawcustom payloads
schlomo Aug 30, 2026
3722b3b
feat(designer): synchronous render endpoint for the designer preview
schlomo Aug 30, 2026
9264d3f
feat(designer): mount vendored 2.x designer as an HA sidebar panel
schlomo Aug 30, 2026
f7b69d9
docs(designer): document architecture, preview isolation, render endp…
schlomo Aug 30, 2026
e851f26
fix(designer): contain keyboard events, add virtual-display preview, …
schlomo Aug 31, 2026
16fc039
fix(dev): remove broken HA_PORT, fix mono-alignment warning, pin imag…
schlomo Aug 31, 2026
7701e3d
fix(designer): scope keyboard containment to editable targets and bar…
schlomo Aug 31, 2026
dc71864
docs(designer): document render endpoint's device_id/display precedence
schlomo Aug 31, 2026
924140f
docs(designer): document the `?`/non-alphanumeric gap in keyboard con…
schlomo Aug 31, 2026
74a8e9d
fix(designer): make beforeunload registration testable, loud-disclose…
schlomo Aug 31, 2026
a21b1cd
chore(dev): consolidate dev/ into one dev/ha entry point
schlomo Aug 31, 2026
af1d173
ci(preview): fork-only HACS preview release on designer-* pushes
schlomo Aug 31, 2026
4c8edf2
fix(designer): tier-2 rotation fix, rotate-on-send, font resolveAsset
schlomo Aug 31, 2026
cf9228b
fix(designer): render preview must target the logical surface, not th…
schlomo Aug 31, 2026
064327c
feat(designer): pin designer 3.0.0 and send what the canvas shows
schlomo Aug 31, 2026
3d76856
docs(designer): document the designer's access model and pin it in tests
schlomo Aug 31, 2026
8974504
feat(designer): resolve host-side images, not just fonts
schlomo Aug 31, 2026
58fbda5
fix(drawcustom): decode payload images off the event loop
schlomo Aug 31, 2026
d212db8
fix(drawcustom): show the frame the panel was given, not the one it w…
schlomo Aug 31, 2026
081f707
docs(designer): explain what Orientation means, and the image asset r…
schlomo Aug 31, 2026
74a9527
fix(drawcustom): make a dry run preview the frame it would actually send
schlomo Sep 1, 2026
5a3774b
feat(designer): vendor designer 3.2.0
schlomo Sep 1, 2026
cc55afb
fix(designer): vendor designer 3.2.1
schlomo Sep 1, 2026
e262b0f
fix(designer): vendor designer 3.3.0
schlomo Sep 1, 2026
1e009ea
fix(designer): make the render endpoint device-facing, matching Send
schlomo Sep 1, 2026
9687f9a
feat(designer): pin designer 3.4.1, adopt hostOwnsAssets, pad preview…
schlomo Sep 1, 2026
3d59bc7
fix(designer): pin designer 3.4.2 (release-pipeline unification only)
schlomo Sep 1, 2026
e47f712
fix(ci): stop zero-padding the preview version — HA blocks invalid se…
schlomo Sep 3, 2026
ae4d651
test(ci): add regression coverage for the invalid-preview-version inc…
schlomo Sep 3, 2026
45d8f59
feat(ci): make fork preview releases generic to any fork and any branch
schlomo Sep 3, 2026
f77c260
docs: add AGENTS.md so automated reviewers stop guessing repo policy
schlomo Sep 3, 2026
f3779d7
fix(designer): pin designer 3.4.3 (docs-only release, no embed-surfac…
schlomo Sep 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Copilot instructions

See [`AGENTS.md`](../AGENTS.md) at the repo root — the canonical agent
instructions (repo-specific Python/commit/test/generated-file facts) live
there, not here.
169 changes: 169 additions & 0 deletions .github/workflows/preview-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
name: Preview Release

# Fork-only automation: cuts a HACS-installable preview build on every push
# to any branch in a fork (other than the fork's own default branch), so a
# pushed branch is immediately pickable by HACS for manual testing without
# waiting for the real release-please.yml flow (which only fires on `main`
# pushes -- see that workflow's own `on:` block -- and only after a PR
# actually merges upstream).
#
# Generalized (2026-09-03, round 2) from a version hard-gated to one fork's
# `owner/repo` and one branch prefix (`designer-*`) to something every fork
# gets automatically, no per-fork edits:
#
# - `github.event.repository.fork` is the generic "is this a fork, not the
# upstream repo" discriminator -- true for every fork, false for
# upstream, so releases here never collide with upstream's real
# release-please releases or spam its release list. Verified available on
# a real push-event payload for this repository, not assumed: `gh api
# repos/<owner>/<repo> --jq '{fork, default_branch}'` against this very
# repo returns `{"default_branch":"main","fork":true}` -- Actions'
# `github.event.repository` is a deserialization of that same GitHub
# "repository" object (identical shape whether returned by the REST API
# or embedded in a webhook payload; see
# https://docs.github.com/en/webhooks/webhook-events-and-payloads#push),
# so both fields are present on the real event this job runs from. This
# push itself is further, live confirmation: the job only runs at all if
# `github.event.repository.fork` was truthy on the actual payload.
# - `github.event.repository.default_branch` (not a hardcoded `main`)
# excludes the fork's own default branch, which tracks upstream --
# syncing it must never cut a preview. Both checks are job-level `if:`
# conditions, not `on.push` filters: `branches:` glob patterns are
# static and can't reference `default_branch`, which differs per fork.
# - `branches: ['**']` (any branch, still excludes tag pushes) replaces the
# old `designer-*`-only filter -- every branch in a fork gets a preview
# build, not just ones with a specific prefix; that was this workflow's
# whole reason to be forked-only in the first place.
#
# A branch name is otherwise unconstrained free text (slashes, dots,
# uppercase, leading digits, ...) and gets embedded in a semver prerelease
# identifier below -- scripts/preview_version.py's
# `sanitize_branch_for_version` maps ANY branch name onto something Home
# Assistant's own version check accepts (tested in
# tests/test_preview_version.py against every shape a contributor could
# actually push), rather than narrowing the trigger to branch patterns that
# happen to already be safe.
on:
push:
branches:
- '**'

jobs:
preview-release:
if: >-
github.event.repository.fork &&
github.ref_name != github.event.repository.default_branch
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4

# Compute the preview version/name and stamp manifest.json IN THIS
# CHECKOUT ONLY, never committed/pushed: the runner's checkout is
# thrown away after the job, so these edits never reach git.
#
# `version`: without it, HA's integration card shows the last
# released "Version 3.0.2" for every preview install, indistinguishable
# from the real release (maintainer tier-2 finding) -- patching it
# makes the HA UI and logs identify the exact preview build.
#
# `name`: a fork's build otherwise looks identical to every other
# fork's and to upstream's -- "OpenDisplay" -- everywhere Home
# Assistant shows it (Devices & Services, the integration detail
# page, log lines), because that string comes from THIS SAME
# manifest.json, baked into the installed zip below. Appending
# `(fork: <owner>/<repo>)` from `github.repository` makes a fork
# self-identifying with no hand-edited GitHub repo description (the
# previous, per-fork-manual approach). This deliberately does NOT
# touch `hacs.json` or `manifest.json`'s `domain`:
# - `domain` must stay `opendisplay` so a fork still replaces the
# stock integration rather than installing alongside it.
# - hacs.json's list-card name/description in the HACS UI are read
# LIVE from the repository's git tree (hacs.json's own "name" key
# if set, else this same manifest's "name" -- both via GitHub's
# API at whatever ref HACS resolves) -- never from a release
# ZIP asset, and hacs.json isn't even packaged into the zip
# (zip step below only touches custom_components/opendisplay/,
# hacs.json lives at repo root). A checkout-only stamp in a job
# that never commits cannot reach either of those, regardless of
# which ref HACS reads -- confirmed against HACS's own source
# (hacs/integration, `repositories/base.py`: `display_name`
# returns `repository_manifest.name` -- i.e. hacs.json's tracked
# "name" -- unconditionally if set, before ever looking at any
# manifest; `description` is a separate field populated from the
# GitHub repository object, not from any file in the repo at
# all). What DOES reach the user unconditionally: Home
# Assistant's own UI, which reads the manifest baked into the
# zip HACS actually downloads and installs -- the same mechanism
# already proven by the version stamp above.
#
# Both compositions live in scripts/preview_version.py (regression-
# tested against Home Assistant's own AwesomeVersion loader check in
# tests/test_preview_version.py) -- not reimplemented here, so the
# tested procedure IS the shipped procedure. DO NOT zero-pad
# run_number, and do NOT embed a raw, unsanitized branch name: a
# leading zero, or a branch containing "/" or a dozen other
# characters, makes the prerelease identifier invalid semver, and
# Home Assistant then refuses to load the integration AT ALL: "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." Every service the integration provides disappears
# (`opendisplay.drawcustom` not found) and automations break.
# Padding was tried once to fix UI ordering (v2.9 reading as newer
# than v2.10) and reverted for this reason; the ordering cosmetics
# are not worth an unloadable integration.
#
# Fails loudly (`jq -e`, unset -u) if .version/.name are ever
# missing/renamed rather than silently zipping an unpatched manifest.
- name: Compute preview metadata and stamp manifest
run: |
set -euo pipefail
base_version="$(jq -er .version custom_components/opendisplay/manifest.json)"
base_name="$(jq -er .name custom_components/opendisplay/manifest.json)"
branch="${{ github.ref_name }}"
run_number="${{ github.run_number }}"
repository="${{ github.repository }}"
preview_version="$(python3 scripts/preview_version.py version "$base_version" "$branch" "$run_number")"
preview_name="$(python3 scripts/preview_version.py name "$base_name" "$repository")"
echo "PREVIEW_VERSION=${preview_version}" >> "$GITHUB_ENV"
jq --arg v "$preview_version" --arg n "$preview_name" \
'.version = $v | .name = $n' \
custom_components/opendisplay/manifest.json > /tmp/manifest.json
mv /tmp/manifest.json custom_components/opendisplay/manifest.json

# Same zip, same command, same working directory as release-please.yml's
# own "Create zip" step -- this IS what HACS's zip_release install
# path (hacs.json) expects, not a preview-specific format. Zips the
# manifest stamped above, so the shipped preview build self-identifies.
- name: Create zip
run: |
cd custom_components/opendisplay
zip opendisplay.zip -r ./

# Tag reuses PREVIEW_VERSION computed above (base manifest version --
# not bumped, this is a preview of unreleased work, not a
# release-please release -- plus the sanitized branch name and this
# workflow's own run number), so pushes to different branches -- or
# repeated pushes to the same one -- never collide on a tag, and the
# tag itself is always a legal git ref name (sanitize_branch_for_version
# restricts the branch component to `[0-9A-Za-z-]`, a strict subset of
# legal tag characters -- see tests/test_preview_version.py's
# `assert_valid_git_tag`, which checks every test-matrix case with
# `git check-ref-format` itself). Matches the maintainer's own manual
# precedent (v3.0.2-designer.1, cut by hand from 74a8e9d) in shape,
# not literally: that one used a shortened branch label by hand, this
# uses the real (sanitized) branch name so two different branches'
# tags can't collide.
- name: Create preview release
env:
GH_TOKEN: ${{ github.token }}
run: |
branch="${{ github.ref_name }}"
sha="$(git rev-parse --short HEAD)"
tag="v${PREVIEW_VERSION}"
gh release create "$tag" \
--target "$branch" \
--title "Preview build: ${branch}@${sha}" \
--notes "Automated preview build of ${branch}@${sha} for HACS testing — not an upstream release." \
custom_components/opendisplay/opendisplay.zip
9 changes: 9 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ jobs:
- name: Install
run: uv sync --no-default-groups --group ${{ matrix.group }}

# tests/js/ (plain node --test, no npm/package.json anywhere in this
# repo) is the ONLY coverage for the designer panel's keyboard-
# containment fix -- runs nowhere else. Once per matrix run (not
# HA-version-dependent) is enough, same rationale as "Upload
# coverage" below only running on one leg.
- name: Run JS tests
if: matrix.leg == 'latest'
run: scripts/test-js

- name: Run tests
run: >
uv run --no-default-groups --group ${{ matrix.group }}
Expand Down
56 changes: 56 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Agent instructions

This file exists so an automated contributor or reviewer has this repo's
own facts on hand instead of guessing. Concrete example: a review bot
flagged `except AttributeError, IndexError, TypeError, ValueError:` in
`designer/image_entity.py` as invalid Python 2-style syntax — it is legal
Python 3 here, exactly the kind of version-knowledge false positive this
file exists to prevent.

Start with [`CONTRIBUTING.md`](CONTRIBUTING.md) — setup, the two-leg test
story, HA component requirements, translations, commit/release rules. This
file adds only what a tool with no memory of this repo tends to get wrong.

## Python

Floor is 3.14.2 (`pyproject.toml`'s `requires-python`), so 3.14 syntax is
in scope, not a mistake. [PEP 758](https://peps.python.org/pep-0758/)
legalizes unparenthesized `except A, B, C:`, already used in
`custom_components/opendisplay/__init__.py` and
`custom_components/opendisplay/designer/image_entity.py` — do not "fix" it
to `except (A, B, C):` or flag it as Python 2 syntax.

## Commits

Every commit on a branch, not just the PR title, must be a [Conventional
Commit](https://www.conventionalcommits.org/) — PRs merge with a merge
commit, and release-please reads each commit's type to decide the release.
A runtime dependency bump (`py-opendisplay`, `odl-renderer`) is
`fix:`/`feat:`, never `chore:`, or it ships silently, unreleased.

## Tests

`scripts/test` and `scripts/test --min-ha` both gate (`--min-ha` against
`hacs.json`'s floor HA version, plain `scripts/test` against the newest); a
`--min-ha`-only failure is a bug to fix or a reason to raise the floor,
never one to weaken or skip that leg. `scripts/lint` runs ruff. A missing
module after an HA bump is usually a *component* requirement (invisible to
`uv`, HA installs it at runtime) — pin it by hand; `scripts/ha-component-reqs`
prints the current set. `dev/ha run` brings up a real, disposable,
hardware-free Home Assistant against this checkout.

## Generated — do not hand-edit

- `custom_components/opendisplay/designer/frontend/vendor/` — regenerate
only via `scripts/update-designer-vendor.py` (checksum-verified).
- `custom_components/opendisplay/translations/*.json` except `en.json` —
written by `.github/workflows/translate.yml`; manual corrections are
fingerprinted and protected (`.github/translation-state.json`).
- `uv.lock` — regenerate with `uv lock`/`uv sync`.
- `manifest.json`'s `"version"` — written by release-please
(`.release-please-config.json`); the rest of the manifest is hand-edited.

## CI

`.github/workflows/preview-release.yml` cuts installable HACS builds from
branches pushed to a **fork** only — inert on this repository itself.
46 changes: 44 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,55 @@ against `pyproject.toml`.

### Running the integration

To try changes against a real device, symlink the component into a Home
Assistant checkout and start it from there:
Three ways to try changes against a real, running Home Assistant, picked by
what you already have available — the first two put
`custom_components/opendisplay` in front of a real `hass` process, so a
debugger attaches directly either way, same as any other native Python
program.

**You already have a Home Assistant checkout and real OpenDisplay
hardware**: symlink the component in and start Home Assistant from there:

```bash
ln -s "$PWD/custom_components/opendisplay" /path/to/core/config/custom_components/
```

**You don't have either** (no live HA, no OpenDisplay hardware): `dev/ha`
is this repo's own disposable-Home-Assistant harness — one entry point,
`dev/ha <subcommand>`, native Python (`uv run hass` under the hood, no
Docker, no container runtime; you never type the `uv run` yourself).
`dev/ha inject` fabricates config entries for a few realistic panels
(small mono / medium BWR / large BWRY) that set up entirely from cache —
no BLE connection, no pairing needed.

```bash
dev/ha run # bring up HA, onboard
dev/ha stop # stop (storage can't be rewritten under a live process)
dev/ha inject # fabricate 3 devices
dev/ha run # bring HA back up
```

See [`dev/README.md`](dev/README.md) for the full workflow (including
`dev/ha`'s other subcommands — `logs`, `token`, `snapshot`/`restore` for
carrying a real device's state between instances), why no BLE discovery
ever happens (the harness's `configuration.yaml` never loads the
`bluetooth` integration — no `default_config`, no explicit `bluetooth:`
key), and the real-hardware snapshot/restore path (`dev/ha snapshot`/
`dev/ha restore`) if you do have a device but want to capture its state
for a teammate who doesn't.

**You want someone else (or a fresh, un-instrumented Home Assistant) to try
your change without building anything**: push your branch to your fork.
`.github/workflows/preview-release.yml` cuts an installable HACS build from
it automatically — add your fork as a HACS custom repository once, and
every push after that is a new pickable release, self-identifying as your
fork's build (`OpenDisplay (fork: <owner>/<repo>)`, visible on Home
Assistant's Devices & Services page) so it's never confused with the real
release or another fork's. See the "Preview releases" section of [`dev/README.md`](dev/README.md) for
exactly what gets stamped, how a branch name is made version-safe, and what
this can't do (it never touches your fork's default branch, and it never
affects HACS's own repository list card).

## Translations

English is written by hand; every other language is filled in by
Expand Down
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Each device is set up over Bluetooth and appears with:

| | |
|---|---|
| **Display content** | an image entity showing the last frame sent, or the one queued for a sleeping tag |
| **Display content** | an image entity showing the last frame sent, or the one queued for a sleeping tag — exactly as the panel received it, in the panel's own pixel grid and rotation |
| **Sensors** | temperature, humidity (on tags with an SHT40), battery level and voltage, signal strength, last seen |
| **Buttons and touch** | event entities for physical buttons and touch controllers |
| **Firmware** | an update entity that flashes new firmware over Bluetooth |
Expand Down Expand Up @@ -64,6 +64,14 @@ announced itself over mDNS, falling back to Bluetooth otherwise.
2. Copy it to your [`custom_components` folder](https://developers.home-assistant.io/docs/creating_integration_file_structure/#where-home-assistant-looks-for-integrations)
3. Restart Home Assistant

Recent Home Assistant releases also ship their own built-in `opendisplay`
integration. A `custom_components/opendisplay` install like this one always
takes precedence over that built-in one for the whole `opendisplay` domain —
this is normal, expected custom-component behavior (not specific to this
integration), and Home Assistant logs a one-time warning about it
("We found a custom integration opendisplay which has not been tested by
Home Assistant...") on every boot as a reminder, not an error.

## Configuration

Devices are discovered automatically once they are in range, over Bluetooth or
Expand Down Expand Up @@ -139,6 +147,10 @@ data:
Every element type and field is documented in
[the drawcustom guide](docs/drawcustom/supported_types.md).

**Prefer a visual editor?** The "OpenDisplay Designer" sidebar panel is a
drag-and-drop drawcustom editor with a live, server-rendered preview — see
[`docs/designer.md`](docs/designer.md).

### Send an existing image

```yaml
Expand Down
2 changes: 2 additions & 0 deletions custom_components/opendisplay/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
from .const import CONF_CACHED_STATE, CONF_ENCRYPTION_KEY, DOMAIN, SETUP_DEADLINE_S
from .coordinator import OpenDisplayCoordinator
from .delivery import DeliveryManager
from .designer import async_setup_designer
from .services import async_setup_services
from .sleep import SleepProfile

Expand Down Expand Up @@ -200,6 +201,7 @@ def _get_encryption_key(entry: OpenDisplayConfigEntry) -> bytes | None:
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the OpenDisplay integration."""
async_setup_services(hass)
await async_setup_designer(hass)
return True


Expand Down
Loading
Loading