feat: rewrite integration (3.0.0) - #31
Conversation
… odl-renderer, bump py-opendisplay to 7.2.5)
Pass HA font search directories (/config/www/fonts, /config/media/fonts, /media/fonts) to generate_image() so users can reference custom fonts by name without absolute paths. Requires odl-renderer 0.5.8 which adds the font_dirs parameter to FontManager and generate_image().
- SCHEMA_DRAWCUSTOM validates payload, background, rotate, dither, refresh_type and dry-run at the service boundary rather than at runtime inside the renderer - _async_send_image centralises BLE connection, encryption key parsing and error handling; used by both upload_image and drawcustom
ImageGen was replaced by odl-renderer; rendering logic is now tested in the odl-renderer package itself.
Delete empty tests/ dir, requirements_test.txt, tests workflow, and pytest config now that all drawcustom tests have been removed.
Remove .claude/, .run/, ha_data_provider.md, and uv.lock from version control; add them to .gitignore.
…acker, recorder dependency)
The upload_error exception swallowed the original OpenDisplayError message, making it hard to diagnose failures. Pass str(err) as a translation placeholder so the underlying cause is shown in the HA notification.
Both sensors are diagnostic and disabled by default. All devices now load the sensor platform (moved Platform.SENSOR to _BASE_PLATFORMS), so non-flex devices get temperature, RSSI, and last-seen; battery sensors remain gated on power mode as before. The sensor value_fn signature is widened from AdvertisementData to OpenDisplayUpdate to give access to coordinator-level fields (RSSI, last_seen) alongside advertisement payload fields. Also syncs en.json with strings.json (adds translations for the new sensors, update entity, no_leds/no_buzzers exceptions, and the activate_led/activate_buzzer/drawcustom services that were missing).
- Replace single flat color/flash_count/loop_delay/inter_delay fields with three step groups (color1-3, flash_count1-3, loop_delay1-3, inter_delay1-3); steps 2 and 3 are skipped when flash_count is 0 - Use RGB color picker selector; voluptuous converts [R,G,B] to the firmware's packed 3R-3G-2B byte at validation time - Expose loop_delay and inter_delay in milliseconds (×100ms units) with slider selectors; voluptuous converts to firmware units - Brightness and repeats now use sliders
Adds a Display content image entity that updates via dispatcher signal after every successful upload_image or drawcustom call (including dry-run).
| name: Draw Custom Image | ||
| description: Draws a custom image on one or more E-Paper displays | ||
| target: | ||
| upload_image: |
There was a problem hiding this comment.
Would it make sense for upload_image to (also) take a url?
Or perhaps thats a new service.
My use case is that I have puppet set up with the ability to snapshot HA pages, so I have a URL that can produce the image I want. I want to push that URL.
My current approach is an ODL with a single image entity that covers the whole screen.. which is also still an option.
There was a problem hiding this comment.
Should be implemented now in (ef91895)
action: opendisplay.upload_image
data:
device_id: <your device>
image: "http://homeassistant.local:10000/home?viewport=1000x1000&format=jpeg"One requirement: the URL host has to be in allowlist_external_urls (this keeps the service from being tricked into fetching arbitrary/internal targets). Since puppet serves on a direct port (10000, no ingress) and handles its own HA auth, a one-time allowlist entry is all it needs:
homeassistant:
allowlist_external_urls:
- "http://homeassistant.local:10000"The scheduled run failed with 403 because GitHub Models access is a Copilot entitlement, and a repository GITHUB_TOKEN carries the organisation's entitlement rather than the triggering user's. This org is on the free plan with no Copilot seats, so the built-in token cannot reach GitHub Models however the workflow permissions are set, and the org settings offer nothing to enable. A personal token working locally was never evidence that CI would work. Both GitHub Models and OpenRouter speak the same chat/completions shape, so the endpoint, key and model are now a small provider table. Whichever API key is present decides which is used, OPENROUTER_API_KEY first; TRANSLATE_PROVIDER and TRANSLATE_MODEL override for one-off runs. OpenRouter needs no Copilot entitlement, has no 4000-token output cap, and costs roughly twelve cents for a full nine-language backfill, which is already done. Drops the now-pointless 'models: read' permission so the next reader does not assume it is load-bearing.
make translation workflow work without a Copilot plan
chore: update translations
The workflow regenerated FALLBACK_TAG_DEFINITIONS in const.py from the OpenEPaperLink tag type definitions, using scripts/fetch_tag_types.py and scripts/generate_tag_types.py. The 3.0 rewrite removed all three: both scripts and the FALLBACK_TAG_DEFINITIONS table they edit are gone from this branch. The workflow still succeeds on main only because main is still the old architecture; on this branch it fails at its first step, and it would start failing on main the moment this branch lands.
…ions chore: remove obsolete sync-tag-definitions workflow
forward measured_palette to prepare_image in drawcustom/upload_image
The patch component reached the update entity but not the device registry, so the device page still showed "2.25" for a device running 2.25.1, and diagnostics omitted the field entirely. The device registry now shares the update entity's formatter rather than building its own string, so the two cannot disagree — a mismatch between them is exactly what makes an update look permanently pending. The import is deferred because update.py imports from __init__. Bumps py-opendisplay to 7.14.1, the first release that parses the trailing patch byte. Without it the version dict has no patch key at all, the formatter falls back to the two-part form on every path, and a device on the newest firmware keeps offering an update it cannot clear. Adds a regression test comparing installed against latest the way the update entity does: 2.25.1 against tag 2.25.1 must report no update, while a genuinely newer release still does. Legacy two-part tags are covered too — x.y.0 ranks equal to a bare x.y, so devices on pre-SemVer releases do not gain a phantom update. Fixes #62
report the firmware patch version everywhere HA shows it
…e baseline The Create Release workflow was added three weeks before release-please and has never run once. It could not have: it writes to custom_components/open_display/manifest.json, an underscored path that does not exist, so its bump step fails immediately. Two further faults had it ever been used — its version regex allows no dot in the prerelease part, rejecting the beta.N form every pre-release uses, and it calls `gh release create` without --prerelease, which would publish a beta as stable and push it to every HACS user rather than only those opted in to betas. Releases on main are cut by release-please, and pre-releases are cut by hand from the dev branch. Neither uses this workflow, so it is removed rather than repaired. Also corrects the release-please baseline. It recorded 2.0.2, the last version release-please itself released, while the project has since published 3.0.0-beta.9 through the manual pre-release path that release-please never sees. Left stale, the first release after the dev branch merges anchors to 2.0.2 and can propose a version below what users already have. Tags are untouched: release-please's own releases are bare per include-v-in-tag, and the hand-cut betas keep their v prefix. The two series are each internally consistent.
drop the unused Create Release workflow and correct the release baseline
epaper-dithering 6.0.0 added DitherMode.DIZZY (value 9), error diffusion with a pseudo-random traversal instead of a raster scan. py-opendisplay re-exports DitherMode straight from epaper_dithering, and the service schemas build their validators from the enum via _str_to_int_enum, so "dizzy" was already accepted by upload_image and drawcustom. It was just missing from the hand-maintained UI metadata, which made it invisible in the Home Assistant service picker. Add it to both select option lists in services.yaml, add the label to strings.json, and document it in the drawcustom option table. Both selectors now match DitherMode element for element in enum order. Translations regenerate from strings.json via the translate workflow.
expose dizzy dithering in the service selectors
…NING The tag exposes a single BLE link, but serialization lived on runtime_data.ble_lock — per config entry and created at setup — so the two config-read paths connected unguarded: the config-flow probe (no entry exists yet) and the entry-setup interrogation (runs before runtime_data is constructed). Overlapping connects on a local BlueZ dongle are a real trigger for the getConnectionCount()==0/subscribers==1 state in the dongle-only config-read investigation (2026-07-16). Add ble_lock.py: a WeakValueDictionary registry of asyncio.Locks keyed by format_mac(address), plus a ble_connection(address, purpose) context manager that WARNs — naming both the waiting purpose and the current holder — when a second connection is attempted while the lock is held. The weak map is load-bearing: asyncio.Lock binds to the loop that first acquires it, and per-test event loops would otherwise inherit dead-loop locks through the shared ADDRESS constant. All five connect sites now acquire it: config-flow probe and setup interrogation (newly guarded, inside their existing deadlines), queued delivery drain (outside its timeout — polite unbounded wait preserved), service calls, and OTA (outer acquisition only; the nested app-mode and AppLoader connects stay unwrapped — the lock is not reentrant). RuntimeData.ble_lock is now required and set to the registry lock, so the reboot/unload drains work unchanged and the lock survives reloads. Tests: new test_ble_lock.py (case-variant sharing, contention WARNING, holder cleanup, weak collection); delivery/services suites assert the registry lock is held during connects and that a contended drain waits, warns once, and completes after release. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add WiFi-as-transport support alongside BLE, keyed on the shared BLE-MAC
identity so a WiFi-discovered tag dedups onto its BLE config entry.
- H1 manifest: register zeroconf _opendisplay._tcp.local.; bump the
py-opendisplay pin to 8.0.0 (also fixes the stale 7.12.0 pin).
- H2 config_flow: async_step_zeroconf reads the mac TXT, sets the
uppercase-colon unique_id (matching the raw BlueZ form; no format_mac,
no migration), merges host/port/tls onto an existing entry, and confirms
a brand-new WiFi device via a TCP reachability probe
(_async_test_connection_tcp, separate from the BLE probe).
- H3 transport.py: resolve_transport prefers WiFi when the entry has a
host and a fresh mDNS sighting, else BLE; async_run_with_fallback opens
the device and falls back to BLE on any WiFi connect/TLS/timeout/OSError
failure, re-running the same delivery. Wired into services and delivery.
- H4: no ble_lock change; the WiFi branch stays inside the MAC-keyed
(transport-neutral) per-MAC lock.
- H6 diagnostics: surface host/port/tls, last-used transport, mDNS
last-seen and communication_modes; feed notify_device_seen("mdns") and
a freshness timestamp from the zeroconf step (wake = BLE advert + mDNS).
New consts (CONF_HOST/PORT/TLS, DEFAULT_LAN_PORT/TLS_PORT, mDNS freshness
window, TCP connect timeout) and runtime fields (mdns_last_seen,
last_transport). Existing BLE-only entries have no host, so they resolve
to BLE unchanged. Tests updated (delivery/services now patch the transport
namespace) plus new test_transport.py and zeroconf config-flow cases.
Diagnostic binary sensor on the device page indicating whether the WiFi/LAN transport is enabled (a LAN host/IP is known for the tag). When enabled, the IP, port and TLS flag are exposed as state attributes. Gated to WiFi-capable/-configured tags (device carries a wifi_config packet, or a host is stored) so pure-BLE devices don't get a permanently-off entity.
The WiFi/LAN transport requires py-opendisplay's WiFi support, which is not yet released to PyPI. Temporarily pin the fork feature branch so this draft PR is testable; swap to a released ==X.Y.Z pin once the py-opendisplay WiFi PR lands and is published. No spaces around @ in the git URL (hassfest requirement).
…apping The frequency_hz help text described the old linear "400-12000 Hz" map. The firmware now selects the nearest quarter-tone note and octave-folds out-of-range pitches to protect the speaker. Update strings.json and translations/en.json in lockstep, and add drawcustom-style inline name/description tooltips to services.yaml for activate_buzzer and each field. No schema or handler change (frequency_hz is forwarded unchanged). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Boards with an SHT40 (reTerminal E1001/E1002/E1003/E1004, Sticky) broadcast the reading in the advertisement's dynamic block, so these entities need no connection and no polling -- the passive coordinator already has the data. Requires py-opendisplay 7.15.0, which added the decoder. Unlike the existing chip temperature these are primary entities: not diagnostic, and enabled by default. Ambient room temperature is what a user expects to find on the device. The block's offset within the dynamic area is per-board and cannot be assumed: E1001/E1002/E1004 use 1 while the firmware default is 7, and decoding at the wrong offset yields plausible-looking garbage (a button byte of 0x28 reads as -39.9 C). It is therefore taken from the device's own config and captured per entity at setup. Rename the chip temperature to "Chip temperature" so it is distinguishable from the new ambient one, which would otherwise take the same device-class name. This is a translation_key only; the entity key -- and so the unique_id -- stays "temperature", so existing entities keep their id and history. Also pass the configured button byte indices to AdvertisementTracker. Only the bytes a BinaryInputs packet claims are buttons; the rest belong to touch controllers and sensors and decode into valid-looking button reports, so without this a refreshed SHT40 reading emits phantom button transitions. event.py filters by byte_index before firing an entity, so nothing was user-visible, but the events were produced on every advertisement.
expose SHT40 ambient temperature and humidity
chore: update translations
WiFi/LAN transport with mDNS discovery and BLE fallback
chore: update translations
Summary
3.0.0 is a ground-up rewrite. 2.x was built around the OpenEPaperLink access-point/tag and ATC protocols with the internal
imagegenengine; 3.0 talks directly to OpenDisplay BLE devices viapy-opendisplayand renders withodl-renderer. Many 2.x services carry over (reworked onto the new stack); OEPL and ATC support is removed. Breaking changes below.New in 3.0
EFR32BG22devices are flashed over a direct BLE connection. nRF / ESP32 devices still show the available version but have no Install button (proxy-unreliable / no BLE OTA path)upload_imageservice: uploads a media-source image or a direct image URLactivate_buzzerservice (Flex)write_nfcservice: writes an NDEF record (URL, text, MIME, or Home Assistant tag) to a display's NFC chipSEVEN_COLOR(Spectra/ACeP) panels and the reTerminal E1004 (BWGBRY_SPLIT, dual-controller Spectra 6)upload_image/drawcustomsupport flicker-free diff updates via the device's partial-region protocol, with automatic fallback to a full refreshPorted from 2.x (reworked onto the new stack)
drawcustom: now renders viaodl-rendererwith voluptuous schema validation at the service boundaryactivate_led(wassetled): redesigned with up to 3 independent RGB color steps, each with its own flash count and timing (Flex)upload_image/drawcustom(including dry-run).ttffiles in/config/www/fonts,/config/media/fonts, or/media/fonts, referenced by name indrawcustomFixes
drawcustomfield values from pre-3.0 configsBreaking changes
drawcustomelement types and field names follow theodl-rendererschema. See the odl-renderer docsRequirements
py-opendisplay[silabs-ota]7.15.0,odl-renderer0.5.12Closes
Closes #27
Closes #28
Closes #29
Closes #40
Closes #43
Changelog by pre-release
3.0.0-beta.1
imagegenwithodl-rendererdrawcustomservice + service-boundary validation;font_dirswired upupload_imageservice3.0.0-beta.2
activate_ledandactivate_buzzerservices (Flex)3.0.0-beta.3
activate_ledredesigned with 3 independent RGB color steps3.0.0-beta.4
EFR32BG22, direct-connection only; nRF intentionally not offered over a Bluetooth proxy)upload_imageaccepts a direct image URLdrawcustomfield values from pre-3.0 configs3.0.0-beta.5
drawcustom90/270 rotation now transposes the canvas instead of scaling/centering it; regression from theimagegen→odl-rendererrewrite (in drawcustom, the rotate function does not work properly. Image is "scaled" and centered instead of just transposed on the x/y axis #43)3.0.0-beta.6
tone_compressionoption fordrawcustom, mirroringupload_image(Add tone compression to drawcustom #46)3.0.0-beta.7
drawcustomgainsrotation,mirror(h/v/hv) andpivoton every element, plus lenient string handling forvisible(odl-renderer 0.5.10)use_measured_paletteexposed todrawcustom; default dithering changed to Burkesrefresh_type: partial) forupload_image/drawcustom, with automatic fallback to full refreshsleep_mode/missed_cycles/queue_timeoutoptions and "Update pending" sensorlast_seensourced from the Bluetooth stack directly (was freezing)blocks_per_ack,max_queue_size)supported_types.mdlinks to the odl-renderer README instead of duplicating it; gray levels and YAML hex-quoting documented3.0.0-beta.8
write_nfcaction: writes an NDEF record to a display's NFC chip. Record types: URL, plain text, MIME data (e.g. vCards,text/vcarddefault), andha_tag, which writes a Home Assistant tag URL that the companion app auto-registers on first scan and that firestag_scannedfor automationsSEVEN_COLORscheme (Spectra/ACeP) now dither and upload correctly (colors uncalibrated until a panel is measured)BWGBRY_SPLITscheme for dual-controller Spectra 6 panels3.0.0-beta.9
2.20showed as2.2(fix: stop scaling firmware minor version by 10 #74), and the patch component was dropped entirely, so2.25.1showed as2.25and always compared older than the release it was already running (Fix: firmware patch version display #78, fix: report the firmware patch version everywhere HA shows it #83). The device page, update entity and diagnostics now all report the full three-part version (Showing incorrect FW version number #62)play_melodyaction: plays a multi-note melody on a device's buzzer.activate_buzzerstays the single-tone action (feat: add play_melody service for buzzer melodies #76)measured_palette: falsewas silently ignored indrawcustom/upload_image, so every frame was quantized against the measured panel palette. On calibrated panels this mis-inked flat UI colors (fix: forward measured_palette to prepare_image in drawcustom/upload_image #71, fixes measured_palette option is silently ignored by drawcustom/upload_image (not forwarded to prepare_image) #70)3.0.0-beta.10
drawcustomnow exposes dizzy dithering in the service selectors (feat: expose dizzy dithering in the service selectors #88)