Initial commit: Xiaoxiang Smart BMS Home Assistant integration
- BLE GATT communication via bleak (UART-over-GATT, service 0xff00) - Parses 0x03 (general info) and 0x04 (cell voltages) frames - Protocol verified against official RS485/UART spec V4 - DataUpdateCoordinator with 5s poll interval (configurable 2-60s) - Auto-reconnect on BLE disconnect - Config flow: BT auto-discovery + manual MAC entry + options flow - Sensors: voltage, current, power, SoC, capacity, cycles, temps, cells, cell delta - HACS-ready (hacs.json, manifest.json) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
"""Xiaoxiang Smart BMS — Home Assistant integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import CONF_ADDRESS, CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL, DOMAIN
|
||||
from .coordinator import BmsCoordinator
|
||||
|
||||
PLATFORMS = ["sensor"]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up the BMS integration from a config entry."""
|
||||
address = entry.data[CONF_ADDRESS]
|
||||
poll_interval = entry.options.get(CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL)
|
||||
|
||||
coordinator = BmsCoordinator(hass, address, poll_interval)
|
||||
await coordinator.async_setup()
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
# Reload the entry when options (e.g. poll interval) change
|
||||
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload the BMS integration."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unload_ok:
|
||||
coordinator: BmsCoordinator = hass.data[DOMAIN].pop(entry.entry_id)
|
||||
await coordinator.async_teardown()
|
||||
return unload_ok
|
||||
|
||||
|
||||
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Reload the entry when options are changed."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
@@ -0,0 +1,192 @@
|
||||
"""BLE GATT communication handler for the Xiaoxiang Smart BMS."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import struct
|
||||
|
||||
from bleak import BleakClient, BleakError
|
||||
|
||||
from .const import (
|
||||
FRAME_END,
|
||||
FRAME_START,
|
||||
RX_CHAR_UUID,
|
||||
TX_CHAR_UUID,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Full frame layout:
|
||||
# [0xDD] [CMD] [STATUS] [PAYLOAD_LEN] [PAYLOAD...] [CHK_HI] [CHK_LO] [0x77]
|
||||
# Header = 4 bytes, trailer = 3 bytes (checksum × 2 + end marker)
|
||||
_HEADER_LEN = 4
|
||||
_TRAILER_LEN = 3
|
||||
|
||||
|
||||
class BmsBluetoothHandler:
|
||||
"""Manages BLE connection and protocol framing for a Xiaoxiang BMS device."""
|
||||
|
||||
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
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._client is not None and self._client.is_connected
|
||||
|
||||
async def connect(self) -> None:
|
||||
"""Open BLE connection and start notifications."""
|
||||
if self.is_connected:
|
||||
return
|
||||
_LOGGER.debug("Connecting to BMS at %s", self._address)
|
||||
self._client = BleakClient(
|
||||
self._address,
|
||||
disconnected_callback=self._on_disconnect,
|
||||
)
|
||||
await self._client.connect()
|
||||
await self._client.start_notify(RX_CHAR_UUID, self._on_notify)
|
||||
_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
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Frame reception
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _on_notify(self, _char, data: bytearray) -> None:
|
||||
"""Accumulate BLE notification chunks into complete protocol frames.
|
||||
|
||||
BLE max payload is 20 bytes (default MTU), so a single BMS frame
|
||||
(up to ~50 bytes for 16 cells) arrives across several notifications.
|
||||
We buffer until we can calculate and verify the expected frame length.
|
||||
"""
|
||||
self._buffer.extend(data)
|
||||
|
||||
# Discard leading garbage until we see a frame start byte
|
||||
while self._buffer and self._buffer[0] != FRAME_START:
|
||||
self._buffer.pop(0)
|
||||
|
||||
# Need at least the 4-byte header to know payload length
|
||||
if len(self._buffer) < _HEADER_LEN:
|
||||
return
|
||||
|
||||
payload_len = self._buffer[3]
|
||||
expected_total = _HEADER_LEN + payload_len + _TRAILER_LEN
|
||||
|
||||
if len(self._buffer) < expected_total:
|
||||
return # still waiting for more chunks
|
||||
|
||||
frame = bytes(self._buffer[:expected_total])
|
||||
del self._buffer[:expected_total]
|
||||
|
||||
if frame[-1] != FRAME_END:
|
||||
_LOGGER.warning("BMS frame missing end marker, discarding: %s", frame.hex())
|
||||
return
|
||||
|
||||
if frame[2] != 0x00:
|
||||
_LOGGER.warning("BMS returned error status 0x%02X for cmd 0x%02X",
|
||||
frame[2], frame[1])
|
||||
return
|
||||
|
||||
_LOGGER.debug("BMS frame received (cmd=0x%02X, len=%d)", frame[1], payload_len)
|
||||
self._response_data = frame
|
||||
self._response_event.set()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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 with self._lock:
|
||||
self._response_event.clear()
|
||||
self._response_data = None
|
||||
try:
|
||||
await self._client.write_gatt_char(TX_CHAR_UUID, command, response=False)
|
||||
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
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Frame parsers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def parse_general_info(frame: bytes) -> dict:
|
||||
"""Parse a 0x03 general info response frame.
|
||||
|
||||
Payload byte offsets (frame[4] is payload[0]):
|
||||
0-1 Total voltage uint16 BE ÷100 → V
|
||||
2-3 Current int16 BE ÷100 → A (positive = charging, negative = discharging)
|
||||
4-5 Residual capacity uint16 BE ÷100 → Ah
|
||||
6-7 Nominal capacity uint16 BE ÷100 → Ah
|
||||
8-9 Cycle count uint16 BE
|
||||
10-11 Production date (ignored)
|
||||
12-15 Balance status (ignored)
|
||||
16-17 Protection status (ignored)
|
||||
18 Software version (ignored)
|
||||
19 State of charge uint8 %
|
||||
20 MOS status uint8
|
||||
21 Cell count uint8
|
||||
22 Temp probe count uint8
|
||||
23+ Temperatures uint16 BE each (raw − 2731) ÷ 10 → °C
|
||||
"""
|
||||
p = frame[_HEADER_LEN:-_TRAILER_LEN]
|
||||
|
||||
temp_count = p[22]
|
||||
temperatures: list[float] = []
|
||||
for i in range(temp_count):
|
||||
raw = struct.unpack_from(">H", p, 23 + i * 2)[0]
|
||||
temperatures.append(round((raw - 2731) / 10.0, 1))
|
||||
|
||||
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),
|
||||
"residual_capacity": round(struct.unpack_from(">H", p, 4)[0] / 100.0, 2),
|
||||
"nominal_capacity": round(struct.unpack_from(">H", p, 6)[0] / 100.0, 2),
|
||||
"cycle_count": struct.unpack_from(">H", p, 8)[0],
|
||||
"state_of_charge": p[19],
|
||||
"cell_count": p[21],
|
||||
"temperatures": temperatures,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def parse_cell_info(frame: bytes) -> dict:
|
||||
"""Parse a 0x04 cell voltage response frame.
|
||||
|
||||
Per spec: frame[3] (the header length byte) = cell_count × 2.
|
||||
The payload contains ONLY the voltage bytes — no count byte.
|
||||
0+ Cell voltages uint16 BE each unit mV ÷1000 → V
|
||||
"""
|
||||
count = frame[3] // 2 # header length byte = N_cells × 2
|
||||
p = frame[_HEADER_LEN:-_TRAILER_LEN]
|
||||
voltages: list[float] = []
|
||||
for i in range(count):
|
||||
raw = struct.unpack_from(">H", p, i * 2)[0]
|
||||
voltages.append(round(raw / 1000.0, 3))
|
||||
return {"cell_voltages": voltages}
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Config flow for the Xiaoxiang Smart BMS integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.bluetooth import (
|
||||
BluetoothServiceInfoBleak,
|
||||
async_discovered_service_info,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
|
||||
from .const import (
|
||||
CONF_ADDRESS,
|
||||
CONF_POLL_INTERVAL,
|
||||
DEFAULT_POLL_INTERVAL,
|
||||
DOMAIN,
|
||||
MAX_POLL_INTERVAL,
|
||||
MIN_POLL_INTERVAL,
|
||||
UART_SERVICE_UUID,
|
||||
)
|
||||
|
||||
|
||||
class XiaoxiangBmsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for the Xiaoxiang Smart BMS."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._address: str | None = None
|
||||
self._name: str | None = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Auto-discovery path (HA sees BMS advertising matching service UUID)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def async_step_bluetooth(
|
||||
self, discovery_info: BluetoothServiceInfoBleak
|
||||
) -> FlowResult:
|
||||
"""Handle BLE auto-discovery."""
|
||||
self._address = discovery_info.address
|
||||
self._name = discovery_info.name or f"Xiaoxiang BMS ({discovery_info.address})"
|
||||
|
||||
await self.async_set_unique_id(discovery_info.address)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
self.context["title_placeholders"] = {"name": self._name}
|
||||
return await self.async_step_confirm()
|
||||
|
||||
async def async_step_confirm(self, user_input: dict | None = None) -> FlowResult:
|
||||
"""Confirm auto-discovered device."""
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(
|
||||
title=self._name,
|
||||
data={CONF_ADDRESS: self._address},
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="confirm",
|
||||
description_placeholders={
|
||||
"name": self._name,
|
||||
"address": self._address,
|
||||
},
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Manual path (user goes to Integrations → Add → Xiaoxiang BMS)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def async_step_user(self, user_input: dict | None = None) -> FlowResult:
|
||||
"""Handle manual setup. Shows discovered BMS devices as a dropdown if found."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
address = user_input[CONF_ADDRESS].upper().strip()
|
||||
await self.async_set_unique_id(address)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=f"Xiaoxiang BMS ({address})",
|
||||
data={CONF_ADDRESS: address},
|
||||
)
|
||||
|
||||
# Build a dropdown of already-discovered BMS devices (filtered by service UUID)
|
||||
discovered: dict[str, str] = {
|
||||
info.address: f"{info.name or 'Xiaoxiang BMS'} ({info.address})"
|
||||
for info in async_discovered_service_info(self.hass)
|
||||
if UART_SERVICE_UUID in (info.service_uuids or [])
|
||||
}
|
||||
|
||||
if discovered:
|
||||
schema = vol.Schema({vol.Required(CONF_ADDRESS): vol.In(discovered)})
|
||||
else:
|
||||
schema = vol.Schema({
|
||||
vol.Required(CONF_ADDRESS): str,
|
||||
})
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=schema,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Options flow (change poll interval after setup)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry: config_entries.ConfigEntry) -> BmsOptionsFlow:
|
||||
return BmsOptionsFlow(config_entry)
|
||||
|
||||
|
||||
class BmsOptionsFlow(config_entries.OptionsFlow):
|
||||
"""Allow the user to change the poll interval after initial setup."""
|
||||
|
||||
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
|
||||
self._config_entry = config_entry
|
||||
|
||||
async def async_step_init(self, user_input: dict | None = None) -> FlowResult:
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(title="", data=user_input)
|
||||
|
||||
current = self._config_entry.options.get(CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL)
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=vol.Schema({
|
||||
vol.Required(CONF_POLL_INTERVAL, default=current): vol.All(
|
||||
int, vol.Range(min=MIN_POLL_INTERVAL, max=MAX_POLL_INTERVAL)
|
||||
),
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Constants for the Xiaoxiang Smart BMS integration."""
|
||||
|
||||
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
|
||||
|
||||
# GATT UUIDs (Xiaoxiang BMS UART-over-GATT)
|
||||
UART_SERVICE_UUID = "0000ff00-0000-1000-8000-00805f9b34fb"
|
||||
RX_CHAR_UUID = "0000ff01-0000-1000-8000-00805f9b34fb" # BMS → HA (notify)
|
||||
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
|
||||
|
||||
# Frame markers
|
||||
FRAME_START = 0xDD
|
||||
FRAME_END = 0x77
|
||||
|
||||
# Response command IDs (byte 1 of frame)
|
||||
CMD_ID_GENERAL = 0x03
|
||||
CMD_ID_CELL = 0x04
|
||||
@@ -0,0 +1,93 @@
|
||||
"""DataUpdateCoordinator for the Xiaoxiang Smart BMS."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .bluetooth_handler import BmsBluetoothHandler
|
||||
from .const import CMD_CELL, CMD_GENERAL, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BmsCoordinator(DataUpdateCoordinator[dict]):
|
||||
"""Polls the BMS over BLE and distributes data to all sensor entities."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
address: str,
|
||||
poll_interval: int,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(seconds=poll_interval),
|
||||
)
|
||||
self.address = address
|
||||
self._handler = BmsBluetoothHandler(address)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def async_setup(self) -> None:
|
||||
"""Connect to the BMS. Called once during config entry setup."""
|
||||
await self._handler.connect()
|
||||
|
||||
async def async_teardown(self) -> None:
|
||||
"""Disconnect cleanly. Called on entry unload."""
|
||||
await self._handler.disconnect()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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()
|
||||
except Exception as exc:
|
||||
raise UpdateFailed(f"BMS reconnect failed: {exc}") from exc
|
||||
|
||||
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)")
|
||||
except UpdateFailed:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise UpdateFailed(f"BLE communication error: {exc}") from exc
|
||||
|
||||
data = BmsBluetoothHandler.parse_general_info(general_frame)
|
||||
data.update(BmsBluetoothHandler.parse_cell_info(cell_frame))
|
||||
|
||||
# Derived fields
|
||||
data["power"] = round(data["voltage"] * data["current"], 2)
|
||||
|
||||
if data["cell_voltages"]:
|
||||
v_max = max(data["cell_voltages"])
|
||||
v_min = min(data["cell_voltages"])
|
||||
data["cell_delta"] = round((v_max - v_min) * 1000, 1)
|
||||
else:
|
||||
data["cell_delta"] = None
|
||||
|
||||
_LOGGER.debug(
|
||||
"BMS data: %.2fV %.2fA %d%% %.2fAh %d cells",
|
||||
data["voltage"],
|
||||
data["current"],
|
||||
data["state_of_charge"],
|
||||
data["residual_capacity"],
|
||||
len(data["cell_voltages"]),
|
||||
)
|
||||
return data
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"domain": "xiaoxiang_bms",
|
||||
"name": "Xiaoxiang Smart BMS",
|
||||
"version": "1.0.0",
|
||||
"config_flow": true,
|
||||
"dependencies": ["bluetooth"],
|
||||
"bluetooth": [
|
||||
{"service_uuid": "0000ff00-0000-1000-8000-00805f9b34fb"}
|
||||
],
|
||||
"requirements": [],
|
||||
"codeowners": [],
|
||||
"iot_class": "local_polling",
|
||||
"documentation": "https://github.com/Jnnshschl/AndroidBMSApp"
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
"""Sensor entities for the Xiaoxiang Smart BMS integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
PERCENTAGE,
|
||||
UnitOfElectricCurrent,
|
||||
UnitOfElectricPotential,
|
||||
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 .coordinator import BmsCoordinator
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Static sensor definitions — one entity per key in coordinator.data
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
STATIC_SENSORS: tuple[SensorEntityDescription, ...] = (
|
||||
SensorEntityDescription(
|
||||
key="voltage",
|
||||
name="Voltage",
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=2,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="current",
|
||||
name="Current",
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=2,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="power",
|
||||
name="Power",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="state_of_charge",
|
||||
name="State of Charge",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="residual_capacity",
|
||||
name="Remaining Capacity",
|
||||
native_unit_of_measurement="Ah",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=2,
|
||||
icon="mdi:battery-charging",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="nominal_capacity",
|
||||
name="Nominal Capacity",
|
||||
native_unit_of_measurement="Ah",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=2,
|
||||
icon="mdi:battery",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="cycle_count",
|
||||
name="Cycle Count",
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
icon="mdi:battery-sync",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="cell_delta",
|
||||
name="Cell Voltage Delta",
|
||||
native_unit_of_measurement="mV",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=1,
|
||||
icon="mdi:battery-alert-variant",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Platform setup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> 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)
|
||||
for description in STATIC_SENSORS
|
||||
]
|
||||
|
||||
# Temperature sensors — count determined from first poll
|
||||
for i in range(len(coordinator.data.get("temperatures", []))):
|
||||
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,
|
||||
)
|
||||
)
|
||||
|
||||
# Cell voltage sensors — count determined from first poll
|
||||
for i in range(len(coordinator.data.get("cell_voltages", []))):
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class BmsStaticSensor(CoordinatorEntity[BmsCoordinator], SensorEntity):
|
||||
"""A sensor whose key maps directly to a scalar value in coordinator.data."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
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)
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
return self.coordinator.data.get(self.entity_description.key)
|
||||
|
||||
|
||||
class BmsDynamicSensor(CoordinatorEntity[BmsCoordinator], SensorEntity):
|
||||
"""A sensor that reads an element from a list inside coordinator.data.
|
||||
|
||||
Used for per-cell voltages and per-probe temperatures, whose count is
|
||||
only known after the first successful BMS poll.
|
||||
"""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
|
||||
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_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
|
||||
if device_class == SensorDeviceClass.VOLTAGE:
|
||||
self._attr_suggested_display_precision = 3
|
||||
elif device_class == SensorDeviceClass.TEMPERATURE:
|
||||
self._attr_suggested_display_precision = 1
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
values: list = self.coordinator.data.get(self._data_key, [])
|
||||
if self._index < len(values):
|
||||
return values[self._index]
|
||||
return None
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Xiaoxiang Smart BMS",
|
||||
"description": "Enter the Bluetooth address of your BMS, or select a discovered device from the dropdown.",
|
||||
"data": {
|
||||
"address": "Bluetooth Address"
|
||||
},
|
||||
"data_description": {
|
||||
"address": "MAC address of the BMS (e.g. AA:BB:CC:DD:EE:FF). Tip: pair the device first via your system Bluetooth settings."
|
||||
}
|
||||
},
|
||||
"confirm": {
|
||||
"title": "Confirm Xiaoxiang BMS",
|
||||
"description": "Connect to **{name}** ({address})?\n\nMake sure the BMS is powered on and within Bluetooth range."
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "This BMS is already configured."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Unable to connect to the BMS. Check the address and try again."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "BMS Options",
|
||||
"description": "Adjust the polling interval. Lower values give more accurate real-time readings but use more Bluetooth bandwidth.",
|
||||
"data": {
|
||||
"poll_interval": "Poll interval (seconds)"
|
||||
},
|
||||
"data_description": {
|
||||
"poll_interval": "How often to request data from the BMS (2–60 seconds). Default is 5 s for solar/energy monitoring."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user