Skip to content

feat: deep-sleep awareness and reliable delivery to sleeping devices#60

Closed
davelee98 wants to merge 12 commits into
OpenDisplay:feat/clean-portfrom
davelee98:feat/deep-sleep-ble-speed
Closed

feat: deep-sleep awareness and reliable delivery to sleeping devices#60
davelee98 wants to merge 12 commits into
OpenDisplay:feat/clean-portfrom
davelee98:feat/deep-sleep-ble-speed

Conversation

@davelee98

Copy link
Copy Markdown

Summary

Adds deep-sleep awareness and reliable image delivery to sleeping devices, on top of feat/clean-port.

  • Sleep awareness (sleep.py, binary_sensor.py): track whether a device is asleep, expose availability and a sleep binary sensor, and set up from cache so entities survive restarts without a live connection.
  • Delivery manager (delivery.py): queue image uploads for probably-asleep devices and deliver on wake, with a 600s delivery deadline, loud failure, and a retry cap of 5.
  • Probe before queue: for image sends to probably-asleep devices, probe first so we only queue when the device is actually unreachable.
  • OTA gating + sleep diagnostics: gate OTA on wake state and surface sleep diagnostics.
  • last_seen fix: source last_seen from the Bluetooth stack rather than the gated callback.

Tests

New coverage under tests/: test_delivery.py, test_sleep.py, test_services.py, test_last_seen.py.

Notes

Internal investigation/planning notes were intentionally left out of this PR.

🤖 Generated with Claude Code

davelee98 and others added 12 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>
@davelee98 davelee98 requested a review from g4bri3lDev as a code owner July 11, 2026 05:08
@davelee98

Copy link
Copy Markdown
Author

can resolve conflict in manifest.json after py-opendisplay PR is merged and released.

@davelee98

Copy link
Copy Markdown
Author

superseded. closing.

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