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"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
|
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. |
|
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? |
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)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.12.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 documented