diff --git a/custom_components/xiaoxiang_bms/__init__.py b/custom_components/xiaoxiang_bms/__init__.py index c7891d3..feaa3cf 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"] +PLATFORMS = ["sensor", "binary_sensor"] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/custom_components/xiaoxiang_bms/binary_sensor.py b/custom_components/xiaoxiang_bms/binary_sensor.py new file mode 100644 index 0000000..e65bfed --- /dev/null +++ b/custom_components/xiaoxiang_bms/binary_sensor.py @@ -0,0 +1,161 @@ +"""Binary sensor entities for the Xiaoxiang Smart BMS integration.""" +from __future__ import annotations + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import BmsCoordinator + +# --------------------------------------------------------------------------- +# Entity descriptions +# --------------------------------------------------------------------------- + +BINARY_SENSORS: tuple[BinarySensorEntityDescription, ...] = ( + # -- MOS gate status -------------------------------------------------- + BinarySensorEntityDescription( + key="mos_charge_enabled", + name="Charge MOSFET", + device_class=BinarySensorDeviceClass.BATTERY_CHARGING, + icon="mdi:battery-charging", + ), + BinarySensorEntityDescription( + key="mos_discharge_enabled", + name="Discharge MOSFET", + device_class=BinarySensorDeviceClass.POWER, + icon="mdi:power-plug", + ), + # -- Cell balancing --------------------------------------------------- + BinarySensorEntityDescription( + key="balance_active", + name="Cell Balancing", + device_class=BinarySensorDeviceClass.RUNNING, + icon="mdi:battery-sync", + ), + # -- Protection flags (True = protection triggered = problem) --------- + BinarySensorEntityDescription( + key="prot_cell_overvolt", + name="Cell Over-Voltage", + device_class=BinarySensorDeviceClass.PROBLEM, + icon="mdi:battery-arrow-up", + ), + BinarySensorEntityDescription( + key="prot_cell_undervolt", + name="Cell Under-Voltage", + device_class=BinarySensorDeviceClass.PROBLEM, + icon="mdi:battery-arrow-down", + ), + BinarySensorEntityDescription( + key="prot_pack_overvolt", + name="Pack Over-Voltage", + device_class=BinarySensorDeviceClass.PROBLEM, + icon="mdi:battery-arrow-up", + ), + BinarySensorEntityDescription( + key="prot_pack_undervolt", + name="Pack Under-Voltage", + device_class=BinarySensorDeviceClass.PROBLEM, + icon="mdi:battery-arrow-down", + ), + BinarySensorEntityDescription( + key="prot_charge_overtemp", + name="Charge Over-Temperature", + device_class=BinarySensorDeviceClass.PROBLEM, + icon="mdi:thermometer-alert", + ), + BinarySensorEntityDescription( + key="prot_charge_undertemp", + name="Charge Under-Temperature", + device_class=BinarySensorDeviceClass.PROBLEM, + icon="mdi:thermometer-alert", + ), + BinarySensorEntityDescription( + key="prot_discharge_overtemp", + name="Discharge Over-Temperature", + device_class=BinarySensorDeviceClass.PROBLEM, + icon="mdi:thermometer-alert", + ), + BinarySensorEntityDescription( + key="prot_discharge_undertemp", + name="Discharge Under-Temperature", + device_class=BinarySensorDeviceClass.PROBLEM, + icon="mdi:thermometer-alert", + ), + BinarySensorEntityDescription( + key="prot_charge_overcurrent", + name="Charge Over-Current", + device_class=BinarySensorDeviceClass.PROBLEM, + icon="mdi:current-ac", + ), + BinarySensorEntityDescription( + key="prot_discharge_overcurrent", + name="Discharge Over-Current", + device_class=BinarySensorDeviceClass.PROBLEM, + icon="mdi:current-ac", + ), + BinarySensorEntityDescription( + key="prot_short_circuit", + name="Short Circuit", + device_class=BinarySensorDeviceClass.PROBLEM, + icon="mdi:flash-alert", + ), + BinarySensorEntityDescription( + key="prot_frontend_ic_error", + name="Frontend IC Error", + device_class=BinarySensorDeviceClass.PROBLEM, + icon="mdi:chip", + ), + BinarySensorEntityDescription( + key="prot_software_lock", + name="Software Lock", + device_class=BinarySensorDeviceClass.PROBLEM, + icon="mdi:lock-alert", + ), +) + + +# --------------------------------------------------------------------------- +# Platform setup +# --------------------------------------------------------------------------- + +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( + BmsBinarySensor(coordinator, description) + for description in BINARY_SENSORS + ) + + +# --------------------------------------------------------------------------- +# Entity class +# --------------------------------------------------------------------------- + +class BmsBinarySensor(CoordinatorEntity[BmsCoordinator], BinarySensorEntity): + """A binary sensor backed by a boolean key in coordinator.data.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: BmsCoordinator, + description: BinarySensorEntityDescription, + ) -> None: + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.address}_{description.key}" + self._attr_device_info = coordinator.device_info + + @property + def is_on(self) -> bool | None: + return self.coordinator.data.get(self.entity_description.key) diff --git a/custom_components/xiaoxiang_bms/bluetooth_handler.py b/custom_components/xiaoxiang_bms/bluetooth_handler.py index 2c9da02..7db59e2 100644 --- a/custom_components/xiaoxiang_bms/bluetooth_handler.py +++ b/custom_components/xiaoxiang_bms/bluetooth_handler.py @@ -172,6 +172,10 @@ class BmsBluetoothHandler: raw = struct.unpack_from(">H", p, 23 + i * 2)[0] temperatures.append(round((raw - 2731) / 10.0, 1)) + balance = struct.unpack_from(">H", p, 12)[0] | struct.unpack_from(">H", p, 14)[0] + prot = struct.unpack_from(">H", p, 16)[0] + mos = p[20] + return { "voltage": round(struct.unpack_from(">H", p, 0)[0] / 100.0, 2), "current": round(struct.unpack_from(">h", p, 2)[0] / 100.0, 2), @@ -181,8 +185,33 @@ class BmsBluetoothHandler: "state_of_charge": p[19], "cell_count": p[21], "temperatures": temperatures, + # MOS status + "mos_charge_enabled": bool(mos & 0x01), + "mos_discharge_enabled": bool(mos & 0x02), + # Cell balancing (any cell currently balancing) + "balance_active": balance != 0, + # Protection flags (bit per event, True = protection triggered) + "prot_cell_overvolt": bool(prot & (1 << 0)), + "prot_cell_undervolt": bool(prot & (1 << 1)), + "prot_pack_overvolt": bool(prot & (1 << 2)), + "prot_pack_undervolt": bool(prot & (1 << 3)), + "prot_charge_overtemp": bool(prot & (1 << 4)), + "prot_charge_undertemp": bool(prot & (1 << 5)), + "prot_discharge_overtemp": bool(prot & (1 << 6)), + "prot_discharge_undertemp": bool(prot & (1 << 7)), + "prot_charge_overcurrent": bool(prot & (1 << 8)), + "prot_discharge_overcurrent": bool(prot & (1 << 9)), + "prot_short_circuit": bool(prot & (1 << 10)), + "prot_frontend_ic_error": bool(prot & (1 << 11)), + "prot_software_lock": bool(prot & (1 << 12)), } + @staticmethod + def parse_version(frame: bytes) -> str: + """Parse a 0x05 hardware version response frame into an ASCII string.""" + p = frame[_HEADER_LEN:-_TRAILER_LEN] + return p.decode("ascii", errors="replace").strip("\x00").strip() + @staticmethod def parse_cell_info(frame: bytes) -> dict: """Parse a 0x04 cell voltage response frame. diff --git a/custom_components/xiaoxiang_bms/const.py b/custom_components/xiaoxiang_bms/const.py index 60980b5..7035263 100644 --- a/custom_components/xiaoxiang_bms/const.py +++ b/custom_components/xiaoxiang_bms/const.py @@ -17,6 +17,7 @@ TX_CHAR_UUID = "0000ff02-0000-1000-8000-00805f9b34fb" # HA → BMS (write) # Request frames: [0xDD, 0xA5, CMD, 0x00, CHK_HI, CHK_LO, 0x77] CMD_GENERAL = bytes([0xDD, 0xA5, 0x03, 0x00, 0xFF, 0xFD, 0x77]) # pack info CMD_CELL = bytes([0xDD, 0xA5, 0x04, 0x00, 0xFF, 0xFC, 0x77]) # cell voltages +CMD_VERSION = bytes([0xDD, 0xA5, 0x05, 0x00, 0xFF, 0xFB, 0x77]) # hardware version string # Frame markers FRAME_START = 0xDD @@ -25,3 +26,4 @@ FRAME_END = 0x77 # Response command IDs (byte 1 of frame) CMD_ID_GENERAL = 0x03 CMD_ID_CELL = 0x04 +CMD_ID_VERSION = 0x05 diff --git a/custom_components/xiaoxiang_bms/coordinator.py b/custom_components/xiaoxiang_bms/coordinator.py index ba5886e..9dcd6c3 100644 --- a/custom_components/xiaoxiang_bms/coordinator.py +++ b/custom_components/xiaoxiang_bms/coordinator.py @@ -6,10 +6,11 @@ from datetime import timedelta from homeassistant.components.bluetooth import async_ble_device_from_address from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .bluetooth_handler import BmsBluetoothHandler -from .const import CMD_CELL, CMD_GENERAL, DOMAIN +from .const import CMD_CELL, CMD_GENERAL, CMD_VERSION, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -31,6 +32,20 @@ class BmsCoordinator(DataUpdateCoordinator[dict]): ) self.address = address self._handler = BmsBluetoothHandler(address) + self.hw_version: str | None = None # populated on first successful poll + + # ------------------------------------------------------------------ + # Device info — centralised so sensor + binary_sensor share it + # ------------------------------------------------------------------ + + @property + def device_info(self) -> DeviceInfo: + return DeviceInfo( + identifiers={(DOMAIN, self.address)}, + name="Xiaoxiang Smart BMS", + manufacturer="Xiaoxiang", + model=self.hw_version or "Smart BMS", + ) # ------------------------------------------------------------------ # Lifecycle @@ -39,8 +54,7 @@ class BmsCoordinator(DataUpdateCoordinator[dict]): def _get_ble_device(self): """Resolve the best available BLE device via HA's Bluetooth subsystem. - This automatically uses ESPHome BLE proxies if they can reach the BMS - and the local adapter cannot (or vice versa). + 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: @@ -79,6 +93,14 @@ class BmsCoordinator(DataUpdateCoordinator[dict]): 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 except Exception as exc: @@ -89,6 +111,7 @@ class BmsCoordinator(DataUpdateCoordinator[dict]): # Derived fields data["power"] = round(data["voltage"] * data["current"], 2) + data["energy_stored"] = round(data["voltage"] * data["residual_capacity"] / 1000, 3) if data["cell_voltages"]: v_max = max(data["cell_voltages"]) @@ -98,11 +121,12 @@ class BmsCoordinator(DataUpdateCoordinator[dict]): data["cell_delta"] = None _LOGGER.debug( - "BMS data: %.2fV %.2fA %d%% %.2fAh %d cells", + "BMS data: %.2fV %.2fA %d%% %.2fAh %.3fkWh %d cells", data["voltage"], data["current"], data["state_of_charge"], data["residual_capacity"], + data["energy_stored"], len(data["cell_voltages"]), ) return data diff --git a/custom_components/xiaoxiang_bms/sensor.py b/custom_components/xiaoxiang_bms/sensor.py index 2b466de..5407ed2 100644 --- a/custom_components/xiaoxiang_bms/sensor.py +++ b/custom_components/xiaoxiang_bms/sensor.py @@ -12,19 +12,19 @@ from homeassistant.const import ( PERCENTAGE, UnitOfElectricCurrent, UnitOfElectricPotential, + UnitOfEnergy, UnitOfPower, UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import CONF_ADDRESS, DOMAIN +from .const import DOMAIN from .coordinator import BmsCoordinator # --------------------------------------------------------------------------- -# Static sensor definitions — one entity per key in coordinator.data +# Static sensor definitions — one entity per scalar key in coordinator.data # --------------------------------------------------------------------------- STATIC_SENSORS: tuple[SensorEntityDescription, ...] = ( @@ -52,6 +52,15 @@ STATIC_SENSORS: tuple[SensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, ), + SensorEntityDescription( + key="energy_stored", + name="Energy Stored", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY_STORAGE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=3, + icon="mdi:battery-charging", + ), SensorEntityDescription( key="state_of_charge", name="State of Charge", @@ -103,10 +112,9 @@ async def async_setup_entry( ) -> None: """Create all BMS sensor entities after the first successful poll.""" coordinator: BmsCoordinator = hass.data[DOMAIN][entry.entry_id] - address: str = entry.data[CONF_ADDRESS] entities: list[SensorEntity] = [ - BmsStaticSensor(coordinator, description, address) + BmsStaticSensor(coordinator, description) for description in STATIC_SENSORS ] @@ -115,14 +123,12 @@ async def async_setup_entry( entities.append( BmsDynamicSensor( coordinator=coordinator, - address=address, unique_key=f"temperature_{i + 1}", friendly_name=f"Temperature {i + 1}", data_key="temperatures", index=i, unit=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, - icon=None, ) ) @@ -131,33 +137,18 @@ async def async_setup_entry( entities.append( BmsDynamicSensor( coordinator=coordinator, - address=address, unique_key=f"cell_voltage_{i + 1}", friendly_name=f"Cell {i + 1} Voltage", data_key="cell_voltages", index=i, unit=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, - icon=None, ) ) async_add_entities(entities) -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -def _device_info(address: str) -> DeviceInfo: - return DeviceInfo( - identifiers={(DOMAIN, address)}, - name="Xiaoxiang Smart BMS", - manufacturer="Xiaoxiang", - model="Smart BMS", - ) - - # --------------------------------------------------------------------------- # Entity classes # --------------------------------------------------------------------------- @@ -171,12 +162,11 @@ class BmsStaticSensor(CoordinatorEntity[BmsCoordinator], SensorEntity): self, coordinator: BmsCoordinator, description: SensorEntityDescription, - address: str, ) -> None: super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{address}_{description.key}" - self._attr_device_info = _device_info(address) + self._attr_unique_id = f"{coordinator.address}_{description.key}" + self._attr_device_info = coordinator.device_info @property def native_value(self): @@ -184,9 +174,9 @@ class BmsStaticSensor(CoordinatorEntity[BmsCoordinator], SensorEntity): class BmsDynamicSensor(CoordinatorEntity[BmsCoordinator], SensorEntity): - """A sensor that reads an element from a list inside coordinator.data. + """A sensor that reads an indexed element from a list inside coordinator.data. - Used for per-cell voltages and per-probe temperatures, whose count is + Used for per-cell voltages and per-probe temperatures whose count is only known after the first successful BMS poll. """ @@ -196,25 +186,21 @@ class BmsDynamicSensor(CoordinatorEntity[BmsCoordinator], SensorEntity): def __init__( self, coordinator: BmsCoordinator, - address: str, unique_key: str, friendly_name: str, data_key: str, index: int, unit: str, device_class: SensorDeviceClass | None, - icon: str | None, ) -> None: super().__init__(coordinator) self._data_key = data_key self._index = index - self._attr_unique_id = f"{address}_{unique_key}" + self._attr_unique_id = f"{coordinator.address}_{unique_key}" self._attr_name = friendly_name self._attr_native_unit_of_measurement = unit self._attr_device_class = device_class - self._attr_device_info = _device_info(address) - if icon: - self._attr_icon = icon + self._attr_device_info = coordinator.device_info if device_class == SensorDeviceClass.VOLTAGE: self._attr_suggested_display_precision = 3 elif device_class == SensorDeviceClass.TEMPERATURE: