Skip to content

Commit d9011ec

Browse files
committed
Reconnect and recover after a Bluetooth disconnect (#3)
The door occasionally drops its BLE connection. Until HA was restarted this left it unusable in three ways, because nothing reconnected: - Commands failed with "characteristic ... was not found": the cover's controller kept writing through the client captured at setup while RunChickenDevice had built a replacement. Reconnect and repoint the controller at the live client before each command (the fix from #13). - Push updates (door-state changes the door reports on its own) stopped: GATT notifications only arrive over a live connection, and after a drop nothing re-established it or re-subscribed. Add active reconnection: RunChickenDevice takes a disconnect callback that fires on an unexpected drop; __init__ wires it to a coordinator refresh, which reconnects, re-subscribes notifications, and re-reads state. The notification callback is stored and re-subscribed on every reconnect. Lifecycle: async_disconnect() marks an expected disconnect and tears down the link on unload (suppressing reconnect), and the advertisement callback registration is now released via entry.async_on_unload.
1 parent 613b51e commit d9011ec

3 files changed

Lines changed: 92 additions & 22 deletions

File tree

custom_components/run_chicken/__init__.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -100,11 +100,13 @@ def async_handle_bluetooth_event(service_info: BluetoothServiceInfoBleak, change
100100
loop = asyncio.get_running_loop()
101101
loop.create_task(coordinator.async_request_refresh()) # noqa: RUF006
102102

103-
async_register_callback(
104-
hass,
105-
async_handle_bluetooth_event,
106-
BluetoothCallbackMatcher(address=address),
107-
BluetoothScanningMode.ACTIVE,
103+
entry.async_on_unload(
104+
async_register_callback(
105+
hass,
106+
async_handle_bluetooth_event,
107+
BluetoothCallbackMatcher(address=address),
108+
BluetoothScanningMode.ACTIVE,
109+
)
108110
)
109111

110112
def notification_callback(gatt_char: BleakGATTCharacteristic, payload: bytearray) -> None: # noqa: ARG001
@@ -116,11 +118,23 @@ def notification_callback(gatt_char: BleakGATTCharacteristic, payload: bytearray
116118

117119
await run_chicken_device.register_notification_callback(notification_callback)
118120

121+
# Notifications stop when the door drops the connection, so reconnect on an
122+
# unexpected disconnect. The coordinator refresh re-establishes the
123+
# connection, which re-subscribes notifications and re-reads the state.
124+
def _schedule_reconnect() -> None:
125+
_LOGGER.debug("Run-Chicken %s disconnected; scheduling reconnect", address)
126+
hass.async_create_task(coordinator.async_request_refresh(), f"{DOMAIN}_reconnect_{address}")
127+
128+
run_chicken_device.set_disconnect_callback(_schedule_reconnect)
129+
119130
return True
120131

121132

122-
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
133+
async def async_unload_entry(hass: HomeAssistant, entry: RunChickenConfigEntry) -> bool:
123134
"""Unload a config entry."""
135+
# Stop auto-reconnect and drop the connection before tearing down.
136+
await entry.runtime_data.async_disconnect()
137+
124138
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
125139
hass.data[DOMAIN].pop(entry.entry_id)
126140

custom_components/run_chicken/cover.py

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
CoverEntity,
1111
CoverEntityDescription,
1212
)
13-
from homeassistant.exceptions import ConfigEntryNotReady
13+
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
1414
from homeassistant.helpers.device_registry import (
1515
CONNECTION_BLUETOOTH,
1616
DeviceInfo,
@@ -102,23 +102,31 @@ def is_closed(self) -> bool | None:
102102
return None
103103
return self.coordinator.data.door_state is RunChickenDoorState.CLOSED
104104

105-
async def async_open_cover(self, **kwargs: Any) -> None: # noqa: ARG002
105+
async def _async_ready_controller(self) -> RunChickenController:
106106
"""
107-
Open the coop door via the BLE controller.
107+
Reconnect if needed and point the controller at the current client.
108108
109-
Ensures a connected `RunChickenCover` controller exists, then sends
110-
the open command to the device.
109+
After a disconnect the device builds a fresh client, so the controller's
110+
captured client goes stale and writes fail with "characteristic not
111+
found". Refreshing it here keeps commands aligned with the live client.
111112
"""
112-
await self.controller.open_cover()
113+
await self.run_chicken_device.ensure_client_connected()
114+
client = self.run_chicken_device.client
115+
if client is None:
116+
msg = "Run-Chicken device is not connected."
117+
raise HomeAssistantError(msg)
118+
self.controller.client = client
119+
return self.controller
113120

114-
async def async_close_cover(self, **kwargs: Any) -> None: # noqa: ARG002
115-
"""
116-
Close the coop door via the BLE controller.
121+
async def async_open_cover(self, **kwargs: Any) -> None: # noqa: ARG002
122+
"""Open the coop door, reconnecting first if the connection dropped."""
123+
controller = await self._async_ready_controller()
124+
await controller.open_cover()
117125

118-
Ensures a connected `RunChickenCover` controller exists, then sends
119-
the close command to the device.
120-
"""
121-
await self.controller.close_cover()
126+
async def async_close_cover(self, **kwargs: Any) -> None: # noqa: ARG002
127+
"""Close the coop door, reconnecting first if the connection dropped."""
128+
controller = await self._async_ready_controller()
129+
await controller.close_cover()
122130

123131
def _handle_coordinator_update(self) -> None:
124132
"""Handle data update."""

custom_components/run_chicken/run_chicken_ble/parser.py

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ def __init__(
4040

4141
self.ble_device: BLEDevice = ble_device
4242
self._client: BleakClient | None = client
43+
# Stored so notifications can be re-subscribed on every reconnect.
44+
self._notification_callback: Callable | None = None
45+
# Invoked on an unexpected disconnect so the owner can reconnect.
46+
self._disconnect_callback: Callable[[], None] | None = None
47+
# Set during teardown so we don't fight an intentional disconnect.
48+
self._expected_disconnect = False
4349

4450
if device_data is None:
4551
self._device_data: RunChickenDeviceData = RunChickenDeviceData(address=ble_device.address)
@@ -72,22 +78,61 @@ def _connected_read_char(self) -> tuple[BleakClient, BleakGATTCharacteristic]:
7278
raise UpdateFailed(msg)
7379
return self._client, char
7480

81+
def set_disconnect_callback(self, callback: Callable[[], None]) -> None:
82+
"""
83+
Register a callback invoked when the device disconnects unexpectedly.
84+
85+
Notifications only arrive while connected, so the owner uses this to
86+
re-establish the connection (which re-subscribes notifications).
87+
"""
88+
self._disconnect_callback = callback
89+
7590
async def register_notification_callback(self, callback: Callable) -> None:
76-
"""Register a callback to be called when the device sends a notification."""
77-
client, read_char = self._connected_read_char()
78-
await client.start_notify(read_char, callback)
91+
"""
92+
Register a callback for device notifications.
93+
94+
Notifications are only delivered over a live connection, so the callback
95+
is stored and automatically re-subscribed whenever the device reconnects.
96+
"""
97+
self._notification_callback = callback
98+
await self._async_subscribe_notifications()
99+
100+
async def _async_subscribe_notifications(self) -> None:
101+
"""Subscribe the stored notification callback on the current client, if any."""
102+
if self._notification_callback is None or self._client is None:
103+
return
104+
read_char = self._client.services.get_characteristic(READ_CHAR_UUID)
105+
if read_char is None:
106+
_LOGGER.warning("Read characteristic %s not found; cannot subscribe to notifications", READ_CHAR_UUID)
107+
return
108+
await self._client.start_notify(read_char, self._notification_callback)
109+
_LOGGER.debug("Subscribed to Run-Chicken notifications on %s", self._client.address)
79110

80111
async def ensure_client_connected(self) -> None:
81112
"""Ensure the client is connected."""
82113
if self._client is None or not self._client.is_connected:
83114
await self._get_client()
84115

116+
async def async_disconnect(self) -> None:
117+
"""Disconnect and suppress auto-reconnect; used during teardown."""
118+
self._expected_disconnect = True
119+
client = self._client
120+
self._client = None
121+
if client is not None and client.is_connected:
122+
await client.disconnect()
123+
85124
async def _get_client(self) -> BleakClient:
86125
"""Get the client from the ble device."""
126+
if self._expected_disconnect:
127+
msg = "Run-Chicken device is shutting down."
128+
raise UpdateFailed(msg)
87129

88130
def on_disconnect(client: BleakClient) -> None:
89131
_LOGGER.warning("Device %s disconnected unexpectedly", client.address)
90132
self._client = None
133+
# Notifications die with the connection; ask the owner to reconnect.
134+
if not self._expected_disconnect and self._disconnect_callback is not None:
135+
self._disconnect_callback()
91136

92137
if self.ble_device.address != self._device_data.address:
93138
self._client = None
@@ -108,6 +153,9 @@ def on_disconnect(client: BleakClient) -> None:
108153

109154
self._device_data.address = self._client.address
110155

156+
# Re-subscribe notifications so push updates resume after a reconnect.
157+
await self._async_subscribe_notifications()
158+
111159
return self._client
112160

113161
@staticmethod

0 commit comments

Comments
 (0)