Skip to content

Serialize BLE access per tag with a per-MAC lock#53

Open
balloob wants to merge 1 commit into
OpenDisplay:feat/clean-portfrom
balloob:fix/per-mac-ble-lock
Open

Serialize BLE access per tag with a per-MAC lock#53
balloob wants to merge 1 commit into
OpenDisplay:feat/clean-portfrom
balloob:fix/per-mac-ble-lock

Conversation

@balloob

@balloob balloob commented Jul 5, 2026

Copy link
Copy Markdown

Problem

BLE access to a tag is not serialized across operations, so two automations hitting the same tag race for the single BLE link and surface a confusing HomeAssistantError("upload_error").

Only _async_upload_image self-serialized (cancelling a prior upload_task). But drawcustom/send_image, activate_led/activate_buzzer, and the OTA install flow each opened their own OpenDisplayDevice(...) connection with no coordination, and the library has no per-address lock. Separately, _async_reload_after_reboot waited only on upload_task, so a reboot-triggered reload could tear the connection out from under an in-flight drawcustom / LED / buzzer / OTA.

Fix

Introduce a per-MAC asyncio.Lock and hold it for the full lifetime of every BLE connection to a tag.

  • OpenDisplayRuntimeData.ble_lock: asyncio.Lock (default factory). A config entry maps 1:1 to a MAC (unique_id), so a per-entry lock is a per-MAC lock.
  • services._async_connect_and_run — the single chokepoint for drawcustom / send_image / upload / LED / buzzer — now holds ble_lock around the whole async with OpenDisplayDevice(...) block.
  • update.py OTA install holds the same ble_lock across the DFU trigger + AppLoader flash (the GitHub asset download stays outside the lock so it doesn't block other ops needlessly).

How the lock is keyed and scoped

  • Keyed per config entry, i.e. per MAC address. Different tags get different locks and run concurrently — they are never serialized against each other.
  • Scoped to the full connection lifetime: acquired before opening OpenDisplayDevice, released when the async with unwinds, including on error/cancellation.

Reconciling with the existing upload_task mechanism

  • upload_image keeps its latest-wins behaviour (a newer upload cancels an older running one instead of queuing). This composes with the lock without deadlock: the cancel + await happens before _async_send_image acquires ble_lock, so the cancelled task unwinds its async with ble_lock and releases the link before the new task takes it.
  • _async_reload_after_reboot and async_unload_entry now drain the lock (acquire then immediately release) to wait for any in-flight BLE op — upload, drawcustom, LED, buzzer, or OTA — instead of waiting only on upload_task.

Deadlock / concurrency analysis

  • No re-entrancy. No locked operation calls back into another locked operation. upload_imagesend_imageconnect_and_run takes the lock exactly once; OTA takes it once; LED/buzzer/drawcustom take it once.
  • Reload does not hold across unload. _async_reload_after_reboot drains (acquire/release) rather than holding the lock across async_reload, because async_reload calls async_unload_entry, which also drains the same lock — holding it would deadlock the reload. The two drains are sequential, never nested.
  • OTA + reboot interplay is safe. An OTA holds the lock; the reboot it causes schedules a reload that drains the lock and therefore waits for the OTA to finish instead of interrupting the flash.
  • Different MACs stay concurrent (separate locks).

Testing

No pytest harness ships with this integration (it runs inside full Home Assistant, and the opendisplay / odl_renderer libraries aren't part of the repo), so a repo test was not added. Instead:

  • All three changed modules byte-compile cleanly.
  • A standalone async model mirroring the exact lock structure verifies: (1) many concurrent ops on the same MAC serialize (max concurrent connections = 1); (2) ops on different MACs overlap; (3) the latest-wins upload cancel composes with the lock with no deadlock. All checks pass.

🤖 Generated with Claude Code

https://claude.ai/code/session_012g2e8mr132vcizx92WsgiR

BLE access to a tag was not serialized across operations. Only
_async_upload_image self-serialized (cancelling a prior upload_task);
drawcustom/send_image, LED, buzzer and the OTA flow each opened their own
OpenDisplayDevice connection with no coordination, and the library has no
per-address lock. Two automations hitting the same tag raced for the single
BLE link and surfaced a confusing HomeAssistantError("upload_error"). The
reboot-reload path also waited only on upload_task, so it could tear the
connection out from under an in-flight drawcustom/LED/buzzer/OTA.

Add an asyncio.Lock to OpenDisplayRuntimeData (one config entry == one MAC,
so this is a per-MAC lock) and hold it for the full connection lifetime of
every BLE operation: _async_connect_and_run (drawcustom/send_image, upload,
LED, buzzer) and the OTA install flow. Different MACs are not serialized
against each other. The upload_image latest-wins cancel is kept but its
cancel happens before the lock is taken, so it composes without deadlock.
_async_reload_after_reboot and async_unload_entry now drain the same lock
(acquire/release without holding across the reload) to wait for in-flight
BLE ops, replacing the upload_task-only wait.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012g2e8mr132vcizx92WsgiR
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