Skip to content

feat: rewrite integration (3.0.0)#31

Draft
g4bri3lDev wants to merge 93 commits into
mainfrom
feat/clean-port
Draft

feat: rewrite integration (3.0.0)#31
g4bri3lDev wants to merge 93 commits into
mainfrom
feat/clean-port

Conversation

@g4bri3lDev

@g4bri3lDev g4bri3lDev commented May 24, 2026

Copy link
Copy Markdown
Member

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 imagegen engine; 3.0 talks directly to OpenDisplay BLE devices via py-opendisplay and renders with odl-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

  • BLE OTA firmware install: in 2.x the firmware update entity could only show that an update was available; 3.0 adds installing it. Silabs EFR32BG22 devices 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_image service: uploads a media-source image or a direct image URL
  • Touch event entities for OpenDisplay touch controllers
  • activate_buzzer service (Flex)
  • Per-device BLE RSSI diagnostic sensor (disabled by default)
  • Per-device landing link for the "Visit device" button
  • Automatic re-sync on reboot: when a device reboots (e.g. after a firmware install or config change), the integration detects it from the advertised reboot flag and reloads to re-read the firmware version and configuration
  • Partial refresh: upload_image/drawcustom support flicker-free diff updates via the device's partial-region protocol, with automatic fallback to a full refresh
  • Deep-sleep delivery: content sent to a sleeping battery-powered device is queued and delivered on its next wake instead of being dropped; entry setup can run entirely from cache while the device is dark

Ported from 2.x (reworked onto the new stack)

  • drawcustom: now renders via odl-renderer with voluptuous schema validation at the service boundary
  • activate_led (was setled): redesigned with up to 3 independent RGB color steps, each with its own flash count and timing (Flex)
  • Firmware update entity: shows installed vs. latest GitHub-release version with release notes (display-only in 2.x; the new install capability is listed above)
  • Display content image entity: shows the last image rendered to each device, updated after every upload_image/drawcustom (including dry-run)
  • Font directory search: .ttf files in /config/www/fonts, /config/media/fonts, or /media/fonts, referenced by name in drawcustom
  • Diagnostic sensors: battery voltage/percentage, temperature, last-seen

Fixes

  • Upload error messages now include the underlying cause
  • BLE END ACK timeout raised to 90s, resolving false upload failures on slow displays
  • Buzzer command ID corrected (py-opendisplay 7.3.2)
  • Icon rendering uses Pillow native anchors (odl-renderer 0.5.9)
  • Accept legacy drawcustom field values from pre-3.0 configs
  • Defer BLE connect until discovery is confirmed
  • Drop board revision from the reported hardware version

Breaking changes

  • All drawcustom element types and field names follow the odl-renderer schema. See the odl-renderer docs
  • Removes OpenEPaperLink (OEPL) and ATC support

Requirements

  • Home Assistant 2026.4.0 or later
  • py-opendisplay[silabs-ota] 7.12.0, odl-renderer 0.5.12

Closes

Closes #27
Closes #28
Closes #29
Closes #40
Closes #43

Changelog by pre-release

3.0.0-beta.1

  • Replaced imagegen with odl-renderer
  • drawcustom service + service-boundary validation; font_dirs wired up
  • upload_image service
  • Touch event entities
  • Requires Home Assistant 2026.4.0+

3.0.0-beta.2

  • Firmware update entity (installed vs. latest GitHub release)
  • activate_led and activate_buzzer services (Flex)
  • RSSI and last-seen diagnostic sensors (all devices, disabled by default)
  • Fix: upload error messages include underlying details
  • Fix: BLE END ACK timeout raised to 90s

3.0.0-beta.3

  • Display content image entity (last rendered image, updates on dry-run too)
  • activate_led redesigned with 3 independent RGB color steps
  • Fix: buzzer command ID (py-opendisplay 7.3.2)
  • Fix: icon rendering via Pillow native anchors (odl-renderer 0.5.9)

3.0.0-beta.4

  • BLE OTA firmware install (Silabs EFR32BG22, direct-connection only; nRF intentionally not offered over a Bluetooth proxy)
  • upload_image accepts a direct image URL
  • Accept legacy drawcustom field values from pre-3.0 configs
  • Defer BLE connect until discovery is confirmed
  • Drop board revision from hardware version
  • Per-device landing link for the "Visit device" button
  • py-opendisplay bumped to 7.8.0

3.0.0-beta.5

3.0.0-beta.6

3.0.0-beta.7

  • drawcustom gains rotation, mirror (h/v/hv) and pivot on every element, plus lenient string handling for visible (odl-renderer 0.5.10)
  • use_measured_palette exposed to drawcustom; default dithering changed to Burkes
  • Partial refresh support (refresh_type: partial) for upload_image/drawcustom, with automatic fallback to full refresh
  • Deep-sleep delivery: content sent to a sleeping device is queued and delivered flicker-free on its next wake instead of being dropped; entry setup can run entirely from cache for a dark device; new sleep_mode/missed_cycles/queue_timeout options and "Update pending" sensor
  • Probe-before-queue: image sends to a probably-asleep device try one short connect before falling back to the queue
  • OTA install fails fast with a clear error when requested on a provably-asleep device
  • Per-MAC BLE lock serializes image/LED/buzzer/OTA operations on a tag
  • Image prepare (dither/encode/compress) now runs in the executor instead of blocking the event loop
  • Wake-drain deadline raised 30s → 600s; timeouts fail loudly instead of retrying silently; delivery gives up after 5 attempts
  • All BLE connect paths now bounded by a wall-clock deadline
  • OTA/config-flow/setup auth failures now trigger reauth instead of raising
  • Fix: last_seen sourced from the Bluetooth stack directly (was freezing)
  • New sliding-window transfer tuning options (blocks_per_ack, max_queue_size)
  • py-opendisplay bumped to 7.12.0, odl-renderer bumped to 0.5.12
  • Docs: supported_types.md links to the odl-renderer README instead of duplicating it; gray levels and YAML hex-quoting documented

g4bri3lDev added 29 commits May 24, 2026 14:12
… 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.
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:

@LordMike LordMike May 29, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"

g4bri3lDev and others added 25 commits July 6, 2026 17:17
Adds "partial" as a refresh mode for upload_image and drawcustom. A
PartialState per config entry tracks the last uploaded frame + etag;
partial refreshes diff against it and update flicker-free via the 0x76
protocol, while full/fast refreshes re-baseline the state so the next
partial diffs against the frame actually on the panel.

All panel handling lives in the library: automatic fallback to a full
upload when partial is not possible, automatic full-frame region
expansion on panels that require it (partial_update_support=2,
OpenDisplay/Firmware#80), and identical frames skip the transfer
entirely. Legacy numeric refresh_type 2/3 map to partial.

Also fixes the compression gate for streaming-decompression-only
configs (bit 0x01 without ZIP): such panels uploaded every frame
uncompressed. Requires py-opendisplay 7.11.1 (partial support,
compression gates, and the epaper-dithering 5.0.8 tone=auto NaN fix
without which bilevel drawcustom frames render all-black).
feat: partial refresh support via py-opendisplay 7.11.1
Expose use_measured_palettes to Home Assistant; default drawcustom dithering to Burkes
py-opendisplay 7.11.2: epaper-dithering 5.0.9 (FFI validation, tone-map
NaN fix line), partial support, compression gate fixes.
odl-renderer 0.5.12: plot crash/hang fixes, text color/anchor + O(n)
fitting, color coercion tolerance, transform/font/QR perf.
chore: bump py-opendisplay to 7.11.2 and odl-renderer to 0.5.12
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Introduce the deep-sleep model that lets a battery-powered display's entry
survive while the device is dark:

- const.py: sleep_mode/missed_cycles/queue_timeout options, cached-state key,
  content delivered/expired event names, and the pending-state dispatcher
  signal.
- sleep.py: SleepProfile resolves the options override plus the device power
  config into is_sleepy, availability_interval and probably_asleep(); pure and
  unit-tested. deep_sleep_enabled is computed locally (power_mode == BATTERY
  and deep_sleep_time_seconds > 0) with no dependency on unreleased library
  API.
- __init__.py: cache every successful interrogation into entry.data; on a dark,
  sleepy device set up entirely from cache without connecting; apply
  async_set_fallback_availability_interval so entities stay available across
  sleep cycles. All non-cache paths keep the original
  ConfigEntryNotReady/ConfigEntryAuthFailed behavior.
- config_flow.py: OptionsFlowWithReload options flow (sleep_mode, missed_cycles,
  queue_timeout) that reloads the entry on change.
- strings/translations/icons: options form, pending sensor, and new error keys
  (grouped here for all phases to avoid churn).
- tests: pure unit tests for SleepProfile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Deliver content to a sleeping display at its next wake instead of dropping it:

- coordinator.py: async_subscribe_device_seen fires on every parsed
  advertisement (the wake rendezvous), mirroring async_subscribe_reboot.
- delivery.py: DeliveryManager owns pending slots (latest-wins upload +
  config-resync), drains them over a single BLE session on device-seen bounded
  by a ~30 s per-wake deadline, arms per-upload deadline timers that fire
  opendisplay_content_expired, fires opendisplay_content_delivered on success,
  and refreshes the entry cache after a resync.
- services.py: upload_image/drawcustom become SupportsResponse.OPTIONAL
  returning {status, expires_at}. A freshness gate queues directly when the tag
  is provably asleep; a live BLE connect/timeout failure on a sleepy device
  queues instead of raising. LED/buzzer fail fast with a device_sleeping error
  when provably asleep. Non-sleepy devices follow the original code paths.
- __init__.py: instantiate the manager, keep entities available across sleep,
  route the reboot edge to a config resync for sleepy devices (D7), and tear
  the manager down on unload. Add the binary_sensor platform.
- image.py: show the queued frame immediately with pending/queued_at attributes.
- binary_sensor.py: "Update pending" entity backed by the manager state.
- tests: DeliveryManager unit tests with mocked hass/coordinator/device.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- update.py: an install requested while the tag is provably asleep fails fast
  with a translated device_sleeping_ota error telling the user to wake it and
  retry. The multi-connection AppLoader flash cannot be driven reliably inside
  a wake window, so this is the minimal-correct gate rather than queuing the OTA
  into the delivery drain (documented limitation).
- diagnostics.py: report the resolved sleep profile, availability interval,
  config-resync flag, and delivery slot state (timestamps/attempts only, never
  queued image bytes).

Reboot-edge rework (D7) and encrypted-device reauth-on-delivery (Phase 3) are
implemented in __init__.py and delivery.py respectively, committed with Phase 2
to keep those modules import-consistent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Temporarily install py-opendisplay from the davelee98 fork's
feat/ble-speed branch so the BLE write-without-response change for 0x71
image data can be tested before a PyPI release. Revert to a versioned
pin (==7.12.0) once that release is published.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The wake-drain deadline of 30s was too short for a large image over a
slow BLE link (the device stays awake while connected, so it was HA's own
timeout aborting mid-transfer). Raise DELIVERY_DEADLINE_S to 600s.

On deadline timeout, split TimeoutError out of the quiet BLE-retry path:
record the attempt, log at ERROR, and raise HomeAssistantError so it is
not silently retried. py-opendisplay wraps its own read/connect timeouts
as BLETimeoutError, so a bare TimeoutError here can only be the drain
deadline firing.

Add MAX_DELIVERY_ATTEMPTS=5: after five failed wakes, _give_up_upload
drops the slot, cancels the expiry timer, sets last_error="failed", and
fires content_expired (attempts==5 distinguishes it from time expiry) so
an undeliverable frame stops retrying every wake until queue_timeout.

Tests cover the deadline raise-and-count path and the max-attempts giveup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… callback

last_seen read coordinator.data.last_seen, a time.time() snapshot written only
inside the de-dup/connectable-gated advertisement callback, so it froze while the
device was still being seen. Read async_last_service_info(..., connectable=False).time
instead -- the same _all_history source the Bluetooth advertisement monitor uses --
converted from the monotonic clock to wall time. Sensor-only; the coordinator
OpenDisplayUpdate.last_seen field is unchanged (still used by services/update).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The freshness gate treated probably_asleep as authoritative and queued
image sends without any connect attempt. That over-triggers: BLE adverts
are lossy (a busy scanner/proxy can miss an entire 10 s wake window), and
Silabs tags advertise continuously in EM2 while their power config reads
as sleepy. Meanwhile HA retains a connectable BLEDevice ~3-5 min after
the last advert, so a connect attempt stays possible long after the gate
flips at ~15 s.

Image sends now spend one short connect attempt (max_attempts=1,
timeout=5 s) before queuing. A dark ESP32 (radio fully off in deep
sleep) costs at most ~5 s vs the old doomed ~40 s budget the gate was
built to avoid, and pays zero device battery; a probe that lands holds
the tag awake and delivers live. A never-seen/long-gone tag has no
connectable BLEDevice and short-circuits to the queue at near-zero cost.

- New probe_before_queue option (default on) in the options flow,
  resolved via SleepProfile
- _async_connect_and_run gains optional connect_timeout/max_attempts,
  threaded to OpenDisplayDevice; all other call sites keep defaults
- Post-probe freshness re-check kicks notify_device_seen("post-probe")
  to close the advert-during-probe drain race
- LED/buzzer and OTA gates unchanged (still fail fast)
- New tests/test_services.py covering the send gate; D5 revision note

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat: probe before queue for sleeping devices
fix(sensor): source last_seen from the bluetooth stack, not the gated callback
These were internal investigation/planning notes, not user-facing docs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ue_size)

Adds two options-flow settings for the new PIPE_WRITE windowed upload
protocol in py-opendisplay: "Blocks sent per acknowledgment" (default 8)
and "Maximum upload frames in flight" (default 16, range 1-32; 1
disables fast transfer and forces the classic one-frame-at-a-time
upload). Both are threaded from entry.options into the
OpenDisplayDevice constructor at the live-send site
(services._async_connect_and_run) and the wake-delivery site
(delivery._drain_once).

Devices on firmware without PIPE_WRITE support are unaffected: the
library probes once per connection and falls back to the legacy
protocol.

Note: manifest.json still pins py-opendisplay@feat/ble-speed; bump the
pin to the branch/release containing the pipe client before end-to-end
testing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sting

The sliding-window pipe client lives on feat/py-sliding-window (which
includes the earlier feat/ble-speed WNR work). Repoint the pin so the
new blocks_per_ack/max_queue_size options reach a constructor that
accepts them during end-to-end testing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…partial

Wrap _async_connect_and_run in asyncio.timeout(DELIVERY_DEADLINE_S) so a
wedged transfer can no longer hold ble_lock forever and block every later
operation on the same MAC; a breach surfaces as an upload_error. Lower
DEFAULT_BLOCKS_PER_ACK to 4 and pin py-opendisplay to feat/pipe-partial.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add wall-clock deadlines to the active BLE command paths that lacked one
so a wedged connection fails cleanly instead of hanging the dialog/setup:
- CONNECT_PROBE_DEADLINE_S (config-flow probe) and SETUP_DEADLINE_S
  (entry setup) in const.py, wrapping the connects in config_flow and
  __init__; OTA_INSTALL_DEADLINE_S bounds the BLE portion of update.

Surface OTA authentication failures that previously leaked (GAP 1):
update.async_install now catches Authentication*/ConfigEntryAuthFailed
from the DFU-trigger connect, starts reauth, and raises the standard
authentication_error instead of a raw exception.

Normalize auth-failure log levels:
- ERROR   malformed stored key (anomaly; blocks all comms until reauth)
- WARNING device rejected the key (recoverable; auto-reauth)
- DEBUG   interactive config-flow prompts (normal UX)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the temporary git+branch pin (feat/pipe-partial) with the
released py-opendisplay 7.12.0 on PyPI, which contains the merged
PIPE_WRITE sliding-window / partial-region work (upstream PR #128).
Bump odl-renderer to the latest 0.5.12.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
reliable delivery to deep-sleeping devices + PIPE_WRITE sliding-window transfer
@LordMike

Copy link
Copy Markdown
Contributor

I tried this branch on an HA instance with bleproxy set up so I can reach my display. The range is long, so the signal is not great, but it does send images eventually.

I called drawcustom with a tiny “Hello World” payload, and the service call took 25s before the image was queued. In another test it took 36s. I found that this is an opportunistic "device was seen recently, lets try direct delivery, and if not, queue". My question is: why not always queue image delivery? Why should the service call block for tens of seconds if it still cannot guarantee that the image was actually rendered?

Ideally, drawcustom would always render/prepare the image, put it in the queue, and return once the queue accepts it. Then the queue can immediately try to drain if the device appears to be online. That gives the service a consistent deterministic meaning: “the image was accepted for delivery.”

Right now drawcustom seems to be trying to be smart by opportunistically waiting for live delivery. But that makes the service contract misleading. If I see one call block until the display updates, I may reasonably start writing automations that assume “when drawcustom returns, the image has rendered.” But another call may instead return after only queueing the image, leaving the automation with a false assumption.

I think actual physical delivery should be represented by queue/delivery state, events, or an entity, not inferred from service-call duration. The service call itself should have one consistent meaning.

If it really must be, drawcustom could accept a queue parameter, which is one of three things: "always | never | opportunistic" where i respectively always queues, always delivers directly (or times out) or tries to deliver directly and then queue. But I think thats just complexity for no reason.

@LordMike

Copy link
Copy Markdown
Contributor

I made a PR for the above.

Another thing I found, the "last seen" becomes unavailable. Seems counter intuitive for a "last seen" to effectively say "I don't know".. if anything, that'd be more like a "is currently present".

Shouldn't last-seen remain available even after the device disappears for a while?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

5 participants