From 3fc25c083b978077285d8d2694ca838ad7236046 Mon Sep 17 00:00:00 2001 From: Jannis Christiani Date: Sat, 11 Apr 2026 20:09:43 +0200 Subject: [PATCH] =?UTF-8?q?Switch=20to=20connect=E2=86=92poll=E2=86=92disc?= =?UTF-8?q?onnect=20per=20cycle=20(single-connection=20BMS=20fix)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The BMS only allows one simultaneous BLE connection. Keeping a persistent connection blocked the mobile app from connecting at all. Changes: - BmsBluetoothHandler: replace connect()/disconnect()/request() public API with a single poll() method that owns the full connect→read→disconnect lifecycle. Connection is held only for the duration of one data fetch. - Coordinator: gutted connection-state management — _async_update_data now just calls handler.poll() and processes results. No reconnect loop needed. - Poll interval defaults: 15s default, 10s min, 300s max. The BLE connect overhead (~2s) makes sub-10s intervals impractical. Co-Authored-By: Claude Sonnet 4.6 --- .../xiaoxiang_bms/bluetooth_handler.py | 102 +++++++----------- custom_components/xiaoxiang_bms/const.py | 6 +- .../xiaoxiang_bms/coordinator.py | 85 ++++++--------- 3 files changed, 79 insertions(+), 114 deletions(-) diff --git a/custom_components/xiaoxiang_bms/bluetooth_handler.py b/custom_components/xiaoxiang_bms/bluetooth_handler.py index daf00a9..cb8a6c0 100644 --- a/custom_components/xiaoxiang_bms/bluetooth_handler.py +++ b/custom_components/xiaoxiang_bms/bluetooth_handler.py @@ -25,67 +25,55 @@ _TRAILER_LEN = 3 class BmsBluetoothHandler: - """Manages BLE connection and protocol framing for a Xiaoxiang BMS device.""" + """Protocol framing and parsing for a Xiaoxiang BMS device. + + Designed for a connect → poll → disconnect pattern: the BMS only allows + one simultaneous BLE connection, so we hold it only for the duration of + a single data fetch and release it immediately after. + """ def __init__(self, address: str) -> None: self._address = address - self._client: BleakClient | None = None self._buffer = bytearray() self._response_event = asyncio.Event() self._response_data: bytes | None = None self._lock = asyncio.Lock() # ------------------------------------------------------------------ - # Connection management + # High-level poll — the only entry point the coordinator needs # ------------------------------------------------------------------ - @property - def is_connected(self) -> bool: - return self._client is not None and self._client.is_connected + async def poll( + self, + ble_device: BLEDevice, + commands: list[bytes], + timeout: float = 5.0, + retries: int = 3, + ) -> list[bytes | None]: + """Connect, send each command in sequence, disconnect. - async def connect(self, ble_device: BLEDevice) -> None: - """Open BLE connection and start notifications. - - Accepts a BLEDevice resolved by HA's Bluetooth subsystem so that - ESPHome BLE proxies are used transparently alongside local adapters. + The BMS only supports a single BLE connection at a time. By connecting + only during the active read window and disconnecting immediately after, + the mobile app (or any other client) can connect freely between polls. """ - if self.is_connected: - return - _LOGGER.debug("Connecting to BMS at %s (via %s)", self._address, ble_device.name) - client = BleakClient( - ble_device, - disconnected_callback=self._on_disconnect, - ) + _LOGGER.debug("Polling BMS at %s", self._address) + client = BleakClient(ble_device) try: await client.connect() await client.start_notify(RX_CHAR_UUID, self._on_notify) - except Exception: - # Ensure a failed connect never leaves a broken client behind — - # next poll will start fresh + # Give the BMS a moment to register the subscription before + # we start sending commands + await asyncio.sleep(0.5) + return [ + await self._request(client, cmd, timeout, retries) + for cmd in commands + ] + finally: try: await client.disconnect() except Exception: pass - self._client = None - raise - # Give the BMS a moment to register the notification subscription - # before we start sending commands — avoids dropped first response - self._client = client - await asyncio.sleep(0.5) - _LOGGER.debug("Connected to BMS at %s", self._address) - - async def disconnect(self) -> None: - """Close the BLE connection cleanly.""" - if self._client: - try: - await self._client.disconnect() - except Exception: - pass - self._client = None - - def _on_disconnect(self, _client: BleakClient) -> None: - _LOGGER.debug("BMS at %s disconnected", self._address) - self._client = None + self._buffer.clear() # ------------------------------------------------------------------ # Frame reception @@ -131,36 +119,30 @@ class BmsBluetoothHandler: self._response_event.set() # ------------------------------------------------------------------ - # Request / response + # Request / response (private — used inside poll()) # ------------------------------------------------------------------ - async def request( + async def _request( self, + client: BleakClient, command: bytes, - timeout: float = 5.0, - retries: int = 3, + timeout: float, + retries: int, ) -> bytes | None: - """Send a command frame and wait for the corresponding response frame. + """Send one command and wait for the response frame, with retries. - Retries up to `retries` times with a short pause between attempts to - handle occasional BLE packet loss or a slow BMS response. - Tries Write With Response first; if that raises a GATT error the BMS - likely only supports Write Without Response, so we fall back silently. + Tries Write With Response first; falls back to Write Without Response + if the characteristic rejects it — covers both BMS firmware variants. """ async with self._lock: for attempt in range(1, retries + 1): self._response_event.clear() self._response_data = None try: - await self._client.write_gatt_char( - TX_CHAR_UUID, command, response=True - ) + await client.write_gatt_char(TX_CHAR_UUID, command, response=True) except BleakError: - # Characteristic may not support Write With Response — try without try: - await self._client.write_gatt_char( - TX_CHAR_UUID, command, response=False - ) + await client.write_gatt_char(TX_CHAR_UUID, command, response=False) except BleakError as exc: _LOGGER.error("BLE write failed (attempt %d/%d): %s", attempt, retries, exc) @@ -172,10 +154,8 @@ class BmsBluetoothHandler: await asyncio.wait_for(self._response_event.wait(), timeout) return self._response_data except asyncio.TimeoutError: - _LOGGER.warning( - "BMS response timeout (cmd=0x%s, attempt %d/%d)", - command.hex(), attempt, retries, - ) + _LOGGER.warning("BMS timeout (cmd=0x%s, attempt %d/%d)", + command.hex(), attempt, retries) if attempt < retries: await asyncio.sleep(0.5) diff --git a/custom_components/xiaoxiang_bms/const.py b/custom_components/xiaoxiang_bms/const.py index 7035263..e47988e 100644 --- a/custom_components/xiaoxiang_bms/const.py +++ b/custom_components/xiaoxiang_bms/const.py @@ -5,9 +5,9 @@ DOMAIN = "xiaoxiang_bms" CONF_ADDRESS = "address" CONF_POLL_INTERVAL = "poll_interval" -DEFAULT_POLL_INTERVAL = 5 # seconds — fast for solar/energy monitoring -MIN_POLL_INTERVAL = 2 -MAX_POLL_INTERVAL = 60 +DEFAULT_POLL_INTERVAL = 15 # seconds — each poll does a full BLE connect/disconnect +MIN_POLL_INTERVAL = 10 # below this the BMS has no breathing room between polls +MAX_POLL_INTERVAL = 300 # GATT UUIDs (Xiaoxiang BMS UART-over-GATT) UART_SERVICE_UUID = "0000ff00-0000-1000-8000-00805f9b34fb" diff --git a/custom_components/xiaoxiang_bms/coordinator.py b/custom_components/xiaoxiang_bms/coordinator.py index 9dcd6c3..e3b0abb 100644 --- a/custom_components/xiaoxiang_bms/coordinator.py +++ b/custom_components/xiaoxiang_bms/coordinator.py @@ -16,7 +16,11 @@ _LOGGER = logging.getLogger(__name__) class BmsCoordinator(DataUpdateCoordinator[dict]): - """Polls the BMS over BLE and distributes data to all sensor entities.""" + """Polls the BMS over BLE and distributes data to all sensor entities. + + Uses a connect → read → disconnect pattern on every poll so the BMS's + single BLE connection slot is free between updates (mobile app access). + """ def __init__( self, @@ -32,10 +36,10 @@ class BmsCoordinator(DataUpdateCoordinator[dict]): ) self.address = address self._handler = BmsBluetoothHandler(address) - self.hw_version: str | None = None # populated on first successful poll + self.hw_version: str | None = None # ------------------------------------------------------------------ - # Device info — centralised so sensor + binary_sensor share it + # Device info — shared by sensor, binary_sensor, number platforms # ------------------------------------------------------------------ @property @@ -51,60 +55,44 @@ class BmsCoordinator(DataUpdateCoordinator[dict]): # Lifecycle # ------------------------------------------------------------------ - def _get_ble_device(self): - """Resolve the best available BLE device via HA's Bluetooth subsystem. - - Automatically uses ESPHome BLE proxies if they can reach the BMS. - """ - device = async_ble_device_from_address(self.hass, self.address, connectable=True) - if device is None: - raise UpdateFailed( - f"BMS ({self.address}) not reachable by any Bluetooth adapter or proxy" - ) - return device - async def async_setup(self) -> None: - """No-op — connection is established lazily on the first poll.""" + """No-op — no persistent connection to establish.""" async def async_teardown(self) -> None: - """Disconnect cleanly. Called on entry unload.""" - await self._handler.disconnect() + """No-op — each poll disconnects itself.""" # ------------------------------------------------------------------ # Poll # ------------------------------------------------------------------ async def _async_update_data(self) -> dict: - """Fetch data from the BMS. Reconnects automatically if disconnected.""" - if not self._handler.is_connected: - _LOGGER.debug("BMS not connected, attempting reconnect…") - try: - await self._handler.connect(self._get_ble_device()) - except UpdateFailed: - raise - except Exception as exc: - raise UpdateFailed(f"BMS reconnect failed: {exc}") from exc + """Connect to the BMS, fetch all data, disconnect.""" + device = async_ble_device_from_address(self.hass, self.address, connectable=True) + if device is None: + raise UpdateFailed( + f"BMS ({self.address}) not reachable — check Bluetooth adapter / proxy" + ) + + # Fetch hardware version once; skip on subsequent polls + commands = [CMD_GENERAL, CMD_CELL] + if self.hw_version is None: + commands.append(CMD_VERSION) try: - general_frame = await self._handler.request(CMD_GENERAL) - if general_frame is None: - raise UpdateFailed("No response to general info request (0x03)") - - cell_frame = await self._handler.request(CMD_CELL) - if cell_frame is None: - raise UpdateFailed("No response to cell info request (0x04)") - - # Fetch hardware version string once — used in DeviceInfo model field - if self.hw_version is None: - version_frame = await self._handler.request(CMD_VERSION) - if version_frame: - self.hw_version = BmsBluetoothHandler.parse_version(version_frame) - _LOGGER.debug("BMS hardware version: %s", self.hw_version) - - except UpdateFailed: - raise + responses = await self._handler.poll(device, commands) except Exception as exc: - raise UpdateFailed(f"BLE communication error: {exc}") from exc + raise UpdateFailed(f"BMS poll failed: {exc}") from exc + + general_frame, cell_frame = responses[0], responses[1] + + if general_frame is None: + raise UpdateFailed("No response to general info request (0x03)") + if cell_frame is None: + raise UpdateFailed("No response to cell info request (0x04)") + + if self.hw_version is None and len(responses) > 2 and responses[2]: + self.hw_version = BmsBluetoothHandler.parse_version(responses[2]) + _LOGGER.debug("BMS hardware version: %s", self.hw_version) data = BmsBluetoothHandler.parse_general_info(general_frame) data.update(BmsBluetoothHandler.parse_cell_info(cell_frame)) @@ -122,11 +110,8 @@ class BmsCoordinator(DataUpdateCoordinator[dict]): _LOGGER.debug( "BMS data: %.2fV %.2fA %d%% %.2fAh %.3fkWh %d cells", - data["voltage"], - data["current"], - data["state_of_charge"], - data["residual_capacity"], - data["energy_stored"], + data["voltage"], data["current"], data["state_of_charge"], + data["residual_capacity"], data["energy_stored"], len(data["cell_voltages"]), ) return data