Skip to content

feat: reliable delivery to deep-sleeping devices + PIPE_WRITE sliding-window transfer#61

Open
davelee98 wants to merge 18 commits into
OpenDisplay:feat/clean-portfrom
davelee98:feat/pipe-partial
Open

feat: reliable delivery to deep-sleeping devices + PIPE_WRITE sliding-window transfer#61
davelee98 wants to merge 18 commits into
OpenDisplay:feat/clean-portfrom
davelee98:feat/pipe-partial

Conversation

@davelee98

@davelee98 davelee98 commented Jul 13, 2026

Copy link
Copy Markdown

Summary

Builds on feat/clean-port to add reliable delivery to deep-sleeping devices and a PIPE_WRITE sliding-window transfer path, plus the supporting sleep-awareness, delivery-queue, and diagnostics plumbing.

Targets feat/clean-port (not main) since it depends on the 3.0 renderer migration. Scope: 22 files, +2,802 / −95, no file deletions.

What's included

Deep-sleep awareness & reliable delivery

  • sleep.py: sleep-state awareness, availability tracking, setup-from-cache
  • delivery.py: delivery manager with queued uploads, a bounded delivery deadline (600s), loud failures, and capped retries
  • Probe-before-queue for image sends to probably-asleep devices
  • OTA gating and sleep diagnostics (diagnostics.py)

PIPE_WRITE sliding-window transfer (requires py-opendisplay 7.12.0)

  • Exposes sliding-window transfer options (blocks_per_ack, max_queue_size)
  • Bounds connect paths and total connection lifetime with wall-clock deadlines; surfaces auth failures instead of masking them

Sensors & entities

  • binary_sensor.py: availability / sleep binary sensors
  • last-seen now sourced from the bluetooth stack rather than the gated callback

Dependency pins

  • py-opendisplay[silabs-ota]==7.12.0 — contains the merged PIPE_WRITE / partial-region work (py-opendisplay #128)
  • odl-renderer==0.5.12

Testing

  • New unit tests (mocked HA, no hardware): test_delivery.py, test_sleep.py, test_services.py, test_last_seen.py, test_config_flow.py
  • Static: manifest is valid JSON, all integration sources compile, both requirement pins resolve on PyPI
  • Hardware validation of the PIPE_WRITE path is still pending

🤖 Generated with Claude Code

davelee98 and others added 17 commits July 6, 2026 19:39
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>
@davelee98 davelee98 requested a review from g4bri3lDev as a code owner July 13, 2026 13:51
@davelee98 davelee98 changed the base branch from main to feat/clean-port July 13, 2026 14:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant