From 03b63c476a375e53214b25ad20fc21a0d6c8400e Mon Sep 17 00:00:00 2001 From: Jannis Christiani Date: Sat, 11 Apr 2026 19:57:22 +0200 Subject: [PATCH] Add request retries and nominal capacity override number entity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retries (bluetooth_handler): - request() now retries up to 3× with 0.5s pause between attempts - Tries Write With Response first, falls back to Write Without Response automatically if the characteristic rejects it — handles both BMS variants Number entity (number.py): - "Nominal Capacity (Override)" lets user correct a stale BMS capacity value (e.g. after a cell upgrade) without PC software - Value is restored across HA restarts via RestoreEntity - Immediately patches coordinator.data so the sensor reflects it without waiting for the next poll Co-Authored-By: Claude Sonnet 4.6 --- custom_components/xiaoxiang_bms/__init__.py | 2 +- .../xiaoxiang_bms/bluetooth_handler.py | 60 +++++++--- custom_components/xiaoxiang_bms/number.py | 112 ++++++++++++++++++ 3 files changed, 158 insertions(+), 16 deletions(-) create mode 100644 custom_components/xiaoxiang_bms/number.py diff --git a/custom_components/xiaoxiang_bms/__init__.py b/custom_components/xiaoxiang_bms/__init__.py index feaa3cf..b45d833 100644 --- a/custom_components/xiaoxiang_bms/__init__.py +++ b/custom_components/xiaoxiang_bms/__init__.py @@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant from .const import CONF_ADDRESS, CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL, DOMAIN from .coordinator import BmsCoordinator -PLATFORMS = ["sensor", "binary_sensor"] +PLATFORMS = ["sensor", "binary_sensor", "number"] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/custom_components/xiaoxiang_bms/bluetooth_handler.py b/custom_components/xiaoxiang_bms/bluetooth_handler.py index 7db59e2..a148e4f 100644 --- a/custom_components/xiaoxiang_bms/bluetooth_handler.py +++ b/custom_components/xiaoxiang_bms/bluetooth_handler.py @@ -123,22 +123,52 @@ class BmsBluetoothHandler: # Request / response # ------------------------------------------------------------------ - async def request(self, command: bytes, timeout: float = 5.0) -> bytes | None: - """Send a command frame and wait for the corresponding response frame.""" + async def request( + self, + command: bytes, + timeout: float = 5.0, + retries: int = 3, + ) -> bytes | None: + """Send a command frame and wait for the corresponding response frame. + + 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. + """ async with self._lock: - self._response_event.clear() - self._response_data = None - try: - await self._client.write_gatt_char(TX_CHAR_UUID, command, response=True) - except BleakError as exc: - _LOGGER.error("BLE write failed: %s", exc) - return None - try: - await asyncio.wait_for(self._response_event.wait(), timeout) - return self._response_data - except asyncio.TimeoutError: - _LOGGER.warning("BMS response timeout (cmd=%s)", command.hex()) - return None + 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 + ) + except BleakError: + # Characteristic may not support Write With Response — try without + try: + await self._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) + if attempt < retries: + await asyncio.sleep(0.5) + continue + + try: + 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, + ) + if attempt < retries: + await asyncio.sleep(0.5) + + return None # ------------------------------------------------------------------ # Frame parsers diff --git a/custom_components/xiaoxiang_bms/number.py b/custom_components/xiaoxiang_bms/number.py new file mode 100644 index 0000000..941d9b9 --- /dev/null +++ b/custom_components/xiaoxiang_bms/number.py @@ -0,0 +1,112 @@ +"""Number entities for the Xiaoxiang Smart BMS integration. + +Provides user-configurable overrides for BMS values that may be stale or +incorrect in the BMS firmware itself (e.g. nominal capacity after a cell +upgrade). Overrides are stored in HA and shadow the BMS-reported value +inside coordinator.data so all other sensors stay consistent. +""" +from __future__ import annotations + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import BmsCoordinator + +NUMBER_ENTITIES: tuple[NumberEntityDescription, ...] = ( + NumberEntityDescription( + key="nominal_capacity_override", + name="Nominal Capacity (Override)", + device_class=NumberDeviceClass.ENERGY_STORAGE, + native_unit_of_measurement="Ah", + native_min_value=1, + native_max_value=2000, + native_step=0.1, + mode=NumberMode.BOX, + entity_category=EntityCategory.CONFIG, + icon="mdi:battery-edit", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + coordinator: BmsCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + BmsNumberEntity(coordinator, description) + for description in NUMBER_ENTITIES + ) + + +class BmsNumberEntity(CoordinatorEntity[BmsCoordinator], NumberEntity, RestoreEntity): + """A number entity whose value overrides a field in coordinator.data. + + On change: immediately patches coordinator.data so the corresponding + sensor reflects the new value without waiting for the next poll. + On HA restart: restores the last set value from state history. + """ + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: BmsCoordinator, + description: NumberEntityDescription, + ) -> None: + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.address}_{description.key}" + self._attr_device_info = coordinator.device_info + self._override_value: float | None = None + + # ------------------------------------------------------------------ + # State restoration across HA restarts + # ------------------------------------------------------------------ + + async def async_added_to_hass(self) -> None: + await super().async_added_to_hass() + if (last_state := await self.async_get_last_state()) is not None: + try: + self._override_value = float(last_state.state) + self._apply_override() + except (ValueError, TypeError): + pass + + # ------------------------------------------------------------------ + # Entity interface + # ------------------------------------------------------------------ + + @property + def native_value(self) -> float | None: + if self._override_value is not None: + return self._override_value + # Fall back to BMS-reported value while no override is set + data_key = self.entity_description.key.replace("_override", "") + return self.coordinator.data.get(data_key) + + async def async_set_native_value(self, value: float) -> None: + self._override_value = round(value, 1) + self._apply_override() + self.async_write_ha_state() + + def _apply_override(self) -> None: + """Patch coordinator.data so dependent sensors update immediately.""" + if self.coordinator.data and self._override_value is not None: + data_key = self.entity_description.key.replace("_override", "") + self.coordinator.data[data_key] = self._override_value + # Recalculate energy_stored since nominal_capacity changed + if data_key == "nominal_capacity" and "voltage" in self.coordinator.data: + pass # energy_stored uses residual_capacity, not nominal — no recalc needed