commit 26e1a0cebaa83c3ba6ef48e4f08109ebc9006e9e parent 4268ac4c08a7af653d4d74b8f5878ba3987b9cf2 Author: Stéphan Kochen <git@stephank.nl> Date: Wed, 02 Sep 2026 20:46:04 +0200 Share BLE gateway connection across hubs
diff --git a/custom_components/inlite/coordinator.py b/custom_components/inlite/coordinator.py index 728b582dab808c855eb5715a6cde9962e910bc15..c5b17fec4b88b0db184f2537eef64dcd9df88ad5 100644 --- a/custom_components/inlite/coordinator.py +++ b/custom_components/inlite/coordinator.py @@ -66,6 +66,8 @@ self._available = False self._ble_lock = asyncio.Lock() self._disconnect_timer: asyncio.TimerHandle | None = None self._ble_service_info: bluetooth.BluetoothServiceInfoBleak | None = None + self._client: BleakClientWithServiceCache | None = None + self._active_hub: InliteHub | None = None self._idle_disconnect_seconds = entry.options.get( CONF_IDLE_DISCONNECT, DEFAULT_IDLE_DISCONNECT_SECONDS ) @@ -127,7 +129,9 @@ return None async def _ensure_connected(self, hub: InliteHub) -> None: """Ensure the hub has an active BLE connection, reconnecting if needed.""" - if hub.is_connected: + if self._client is not None and self._client.is_connected: + if not hub.is_connected: + await hub.connect(client=self._client, subscribe=False) return info = self._find_ble_device() @@ -142,10 +146,30 @@ info.address, max_attempts=3, ) - connected = await hub.connect(client=client) + self._client = client + connected = await hub.connect( + client=client, notification_handler=self._dispatch_notification + ) if not connected: + self._client = None raise ConnectionError("Hub notification setup failed") + def _dispatch_notification(self, sender: object, data: bytearray) -> None: + """Route notifications from the one shared BLE subscription.""" + if self._active_hub is not None: + self._active_hub.handle_notification(sender, data) + + async def _disconnect_shared(self) -> None: + """Close the one gateway connection and detach every logical hub.""" + if self._client is not None: + try: + if self._client.is_connected: + await self._client.disconnect() + finally: + self._client = None + for hub in self._hubs.values(): + hub.detach_client() + def _schedule_idle_disconnect(self) -> None: """Schedule a disconnect after the idle timeout.""" self._cancel_idle_disconnect() @@ -163,8 +187,7 @@ async def _idle_disconnect(self) -> None: """Disconnect all hubs after idle timeout.""" async with self._ble_lock: - for hub in self._hubs.values(): - await hub.disconnect() + await self._disconnect_shared() _LOGGER.debug("Idle disconnect completed") async def _async_update_data(self) -> dict[int, dict[int, ZoneState]]: @@ -182,6 +205,7 @@ for device_id, hub in self._hubs.items(): for attempt in range(MAX_POLL_ATTEMPTS): try: await self._ensure_connected(hub) + self._active_hub = hub states = await hub.query_zone_states() all_states[device_id] = states self._available = True @@ -191,7 +215,7 @@ _LOGGER.warning( "Poll attempt %d/%d for hub 0x%04X failed: %s", attempt + 1, MAX_POLL_ATTEMPTS, device_id, err, ) - await hub.disconnect() + await self._disconnect_shared() if attempt < MAX_POLL_ATTEMPTS - 1: await asyncio.sleep(RETRY_BACKOFF_SECONDS) @@ -223,6 +247,7 @@ async with self._ble_lock: self._cancel_idle_disconnect() try: await self._ensure_connected(hub) + self._active_hub = hub result = await hub.set_outlet_mode(output_id, on) if result: self._schedule_idle_disconnect() @@ -232,14 +257,14 @@ _LOGGER.debug( "Command attempt %d/%d for hub 0x%04X zone %d: no ACK", attempt + 1, MAX_COMMAND_ATTEMPTS, device_id, output_id, ) - await hub.disconnect() + await self._disconnect_shared() except Exception as err: last_error = err _LOGGER.debug( "Command attempt %d/%d for hub 0x%04X zone %d failed: %s", attempt + 1, MAX_COMMAND_ATTEMPTS, device_id, output_id, err, ) - await hub.disconnect() + await self._disconnect_shared() # Backoff between retries (lock released so other operations can proceed) if attempt < MAX_COMMAND_ATTEMPTS - 1: @@ -256,6 +281,5 @@ async def async_shutdown(self) -> None: """Disconnect all hubs (called on unload).""" self._cancel_idle_disconnect() async with self._ble_lock: - for hub in self._hubs.values(): - await hub.disconnect() + await self._disconnect_shared() _LOGGER.debug("All hubs disconnected") diff --git a/custom_components/inlite/lib/inlite_ble/hub.py b/custom_components/inlite/lib/inlite_ble/hub.py index a5dc14fcd2bbde4fc82ef3a0996544082d06409f..351ce04d47c1f669cff6ecd62297d225f5f09a53 100644 --- a/custom_components/inlite/lib/inlite_ble/hub.py +++ b/custom_components/inlite/lib/inlite_ble/hub.py @@ -145,6 +145,8 @@ async def connect( self, device: BLEDevice | None = None, client: BleakClient | None = None, + subscribe: bool = True, + notification_handler: Callable[[Any, bytearray], None] | None = None, ) -> bool: """Connect to the hub and enable notifications. @@ -175,8 +177,10 @@ _LOGGER.info("Connected") # Subscribe to notifications on both bidirectional characteristics - await self._client.start_notify(CHAR_WRITE_UUID, self._on_notification) - await self._client.start_notify(CHAR_CONTINUATION_UUID, self._on_notification) + if subscribe: + handler = notification_handler or self._on_notification + await self._client.start_notify(CHAR_WRITE_UUID, handler) + await self._client.start_notify(CHAR_CONTINUATION_UUID, handler) _LOGGER.info("Notifications enabled") return True @@ -194,12 +198,21 @@ self._client = None self._loop = None _LOGGER.info("Disconnected") + def detach_client(self) -> None: + """Forget a shared client without disconnecting it.""" + self._client = None + self._loop = None + def _on_notification(self, sender: Any, data: bytearray) -> None: """Handle incoming BLE notifications. Bleak calls this from a background thread, so we use call_soon_threadsafe to schedule event-loop work safely. """ + self.handle_notification(sender, data) + + def handle_notification(self, sender: Any, data: bytearray) -> None: + """Process one notification. Called by the shared BLE dispatcher.""" raw = bytes(data) decrypted = self._crypto.decrypt_packet(raw) if decrypted is None: @@ -222,7 +235,8 @@ elif pkt_type == PKT_BLOCK_DATA_BLK: if len(payload) >= 3 and payload[1:3] == b"\x0c\x00": self._discovered_device_ids.add(decrypted["src_id"]) loop.call_soon_threadsafe(self._discovery_event.set) - loop.call_soon_threadsafe(self._parse_oob_broadcast, payload) + if decrypted["src_id"] == self._device_id: + loop.call_soon_threadsafe(self._parse_oob_broadcast, payload) if self._notification_callback: loop.call_soon_threadsafe(self._notification_callback, decrypted)