ha: add li3_battery HA integration, replacing van-li3-battery

The li3 BMS has a persistently marginal BLE link from any single fixed
vantage point on this Pi (onboard adapter, a USB dongle that turned out to
be BR/EDR-only, and the Athom ESPHome BT proxy even after moving it closer).
Move capture into HA proper so bluetooth.async_ble_device_from_address can
pick whichever known source currently has the device, instead of hardcoding
one. Protocol parsing ported verbatim from li3/van-li3-battery.

van-li3-battery is disabled on the Pi; its MQTT discovery entities were
cleared.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Andreas Wrede
2026-08-24 11:49:44 -04:00
co-authored by Claude Sonnet 5
parent 94e6a2d811
commit d25f48727f
9 changed files with 576 additions and 0 deletions
@@ -0,0 +1,27 @@
"""The Lithionics Li3 BMS integration."""
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .const import DOMAIN
from .coordinator import Li3Coordinator
PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
coordinator = Li3Coordinator(hass, entry.data["address"])
await coordinator.async_start()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
coordinator: Li3Coordinator = hass.data[DOMAIN].pop(entry.entry_id)
await coordinator.async_stop()
return unload_ok
@@ -0,0 +1,84 @@
"""Config flow for the Lithionics Li3 BMS integration."""
from __future__ import annotations
from typing import Any
import voluptuous as vol
from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak,
async_discovered_service_info,
)
from homeassistant.config_entries import ConfigFlow
from homeassistant.data_entry_flow import FlowResult
from .const import DOMAIN
class Li3ConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for a Lithionics Li3 BMS."""
VERSION = 1
def __init__(self) -> None:
self._discovery_info: BluetoothServiceInfoBleak | None = None
self._discovered: dict[str, str] = {}
async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfoBleak
) -> FlowResult:
"""Handle a discovered Li3 advertisement (from any Bluetooth source)."""
await self.async_set_unique_id(discovery_info.address)
self._abort_if_unique_id_configured()
self._discovery_info = discovery_info
self.context["title_placeholders"] = {"name": discovery_info.name}
return await self.async_step_bluetooth_confirm()
async def async_step_bluetooth_confirm(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
assert self._discovery_info is not None
if user_input is not None:
return self.async_create_entry(
title=self._discovery_info.name,
data={
"address": self._discovery_info.address,
"name": self._discovery_info.name,
},
)
return self.async_show_form(
step_id="bluetooth_confirm",
description_placeholders={"name": self._discovery_info.name},
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Manual entry, plus a dropdown of any Li3 already seen advertising."""
errors: dict[str, str] = {}
if user_input is not None:
address = user_input["address"]
await self.async_set_unique_id(address, raise_on_progress=False)
self._abort_if_unique_id_configured()
name = self._discovered.get(address, "Li3 Battery")
return self.async_create_entry(title=name, data={"address": address, "name": name})
current_addresses = self._async_current_ids()
for info in async_discovered_service_info(self.hass, connectable=True):
if info.address in current_addresses:
continue
if info.name and info.name.startswith("Li3-"):
self._discovered[info.address] = info.name
if not self._discovered:
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({vol.Required("address"): str}),
errors=errors,
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({vol.Required("address"): vol.In(self._discovered)}),
errors=errors,
)
+36
View File
@@ -0,0 +1,36 @@
"""Constants for the Lithionics Li3 BMS integration."""
DOMAIN = "li3_battery"
# (key, name, unit, device_class, display_precision) -- precision None means a
# non-numeric value (hex code, version string, serial number): published as-is,
# no rounding, no state_class. Mirrors van-li3-battery's SENSORS list.
SENSORS = [
("voltage", "Pack Voltage", "V", "voltage", 2),
("cell1_voltage", "Cell 1 Voltage", "V", "voltage", 2),
("cell2_voltage", "Cell 2 Voltage", "V", "voltage", 2),
("cell3_voltage", "Cell 3 Voltage", "V", "voltage", 2),
("cell4_voltage", "Cell 4 Voltage", "V", "voltage", 2),
("current", "Current", "A", "current", 2),
("soc", "State of Charge", "%", "battery", 0),
("bms_temperature", "BMS Temperature", "°F", "temperature", 1),
("battery_temperature", "Battery Temperature", "°F", "temperature", 1),
("remaining_capacity", "Remaining Capacity", "Ah", None, 0),
("remaining_time", "Remaining Time", "min", "duration", 0),
("can_charger_voltage", "CAN Charger Voltage", "V", "voltage", 1),
("can_charger_current", "CAN Charger Current", "A", "current", 1),
("can_charger_status", "CAN Charger Status", None, None, None),
("can_status", "CAN Status", None, None, None),
]
# From the one-time "$info" response -- published as diagnostic entities.
INFO_SENSORS = [
("total_consumed", "Lifetime Consumed", "Ah", None, 0),
("last_fault_code", "Last Fault Code", None, None, None),
("highest_recorded_temp", "Highest Recorded Temperature", "°F", "temperature", 0),
("lowest_recorded_temp", "Lowest Recorded Temperature", "°F", "temperature", 0),
("firmware_version", "Firmware Version", None, None, None),
("aging_factor_temp", "Aging Factor (Temp)", None, None, 0),
("aging_factor_soc", "Aging Factor (SOC)", None, None, 0),
("serial_number", "Serial Number", None, None, None),
]
@@ -0,0 +1,147 @@
"""Persistent BLE connection to one Li3 BMS.
Unlike a typical HA polling coordinator, the Li3 streams telemetry
continuously once connected, so this holds a long-lived connection instead of
connect/read/disconnect cycles. bluetooth.async_ble_device_from_address picks
whichever known Bluetooth source (the host's local adapter, or any connected
ESPHome Bluetooth proxy) currently has the device, so a single weak vantage
point no longer has to carry the whole link -- see vanlink's li3-battery
project memory for why that matters here.
"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Callable
from bleak import BleakClient
from bleak.exc import BleakError
from bleak_retry_connector import establish_connection
from homeassistant.components import bluetooth
from homeassistant.core import HomeAssistant, callback
from .parser import (
FFE1_CHAR_UUID,
build_state_payload,
parse_info_line,
parse_line,
parse_trace_line,
)
_LOGGER = logging.getLogger(__name__)
RETRY_DELAY_S = 8
class Li3Coordinator:
"""Owns the BLE connection to one Li3 BMS and fans out updates to entities."""
def __init__(self, hass: HomeAssistant, address: str) -> None:
self.hass = hass
self.address = address
self.data: dict = {}
self.info: dict = {}
self.available = False
self._listeners: list[Callable[[], None]] = []
self._task: asyncio.Task | None = None
self._stopping = False
@callback
def async_add_listener(self, update_callback: Callable[[], None]) -> Callable[[], None]:
self._listeners.append(update_callback)
def remove_listener() -> None:
self._listeners.remove(update_callback)
return remove_listener
def _notify_listeners(self) -> None:
for update_callback in list(self._listeners):
update_callback()
async def async_start(self) -> None:
self._stopping = False
self._task = self.hass.loop.create_task(self._run())
async def async_stop(self) -> None:
self._stopping = True
if self._task is not None:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
async def _run(self) -> None:
while not self._stopping:
ble_device = bluetooth.async_ble_device_from_address(
self.hass, self.address, connectable=True
)
if ble_device is None:
_LOGGER.debug(
"Li3 %s not currently visible to any Bluetooth source", self.address
)
await asyncio.sleep(RETRY_DELAY_S)
continue
try:
await self._stream(ble_device)
except asyncio.CancelledError:
raise
except (BleakError, EOFError, TimeoutError) as err:
_LOGGER.debug("Li3 %s connection error: %s", self.address, err)
self.available = False
self._notify_listeners()
await asyncio.sleep(RETRY_DELAY_S)
async def _stream(self, ble_device) -> None:
buf = ""
info_published = False
latest_cs: dict = {}
latest_trace: dict = {}
def notify_handler(_sender, data: bytearray) -> None:
nonlocal buf, info_published
buf += data.decode("utf-8", errors="replace")
while "\r\n" in buf:
line, buf = buf.split("\r\n", 1)
line = line.strip()
if not line:
continue
if line.startswith("&"):
trace = parse_trace_line(line)
if trace:
latest_trace.update(trace)
if latest_cs:
self.data = build_state_payload(latest_cs, latest_trace)
self._notify_listeners()
elif line.startswith("$"):
if not info_published:
info = parse_info_line(line)
if info:
self.info = info
info_published = True
self._notify_listeners()
else:
reading = parse_line(line)
if reading:
latest_cs.update(reading)
self.data = build_state_payload(latest_cs, latest_trace)
self._notify_listeners()
_LOGGER.debug("Connecting to Li3 %s", self.address)
client = await establish_connection(BleakClient, ble_device, ble_device.address)
try:
self.available = True
self._notify_listeners()
await client.start_notify(FFE1_CHAR_UUID, notify_handler)
await client.write_gatt_char(FFE1_CHAR_UUID, b"$traceon\r\n", response=False)
await asyncio.sleep(2)
await client.write_gatt_char(FFE1_CHAR_UUID, b"$info\r\n", response=False)
while client.is_connected and not self._stopping:
await asyncio.sleep(1)
finally:
self.available = False
if client.is_connected:
await client.disconnect()
@@ -0,0 +1,17 @@
{
"domain": "li3_battery",
"name": "Lithionics Li3 BMS",
"codeowners": ["@aew"],
"config_flow": true,
"dependencies": ["bluetooth"],
"documentation": "https://github.com/wrede/vanlink",
"iot_class": "local_push",
"requirements": ["bleak-retry-connector>=3.0.0"],
"version": "0.1.0",
"bluetooth": [
{
"local_name": "Li3-*",
"connectable": true
}
]
}
+133
View File
@@ -0,0 +1,133 @@
"""Lithionics Li3 BMS wire protocol.
Ported verbatim from vanlink's van-li3-battery (li3/van-li3-battery in the
vanlink repo). Protocol reverse-engineered from the com.lithionics.bms Android
app (BLEMaster / MainBmsCsParameters): classic HM-10 BLE-UART (service ffe0,
characteristic ffe1, notify+write, no pairing). On connect we send "$traceon"
then "$info"; the device then streams CRLF-terminated CSV telemetry lines
forever.
"""
from __future__ import annotations
FFE1_CHAR_UUID = "0000ffe1-0000-1000-8000-00805f9b34fb"
# The "status" field (main Cs telemetry line, and identically-coded but
# cumulative/latched "last_fault_code" from $info) is a 24-bit flag mask. Bit
# meanings pulled from the app's own "advanced" string-array resource
# (com.lithionics.bms base.apk, array/advanced -- dumped with aapt since the
# app's Kotlin StatusCodeTable class references stale/wrong resource IDs and
# can't be trusted). Index 0 = bit 23 (MSB) down to index 23 = bit 0 (LSB);
# blank entries are unused bits. Applied to "status" only -- last_fault_code
# is a lifetime latch (many bits accumulate over time) and isn't meaningfully
# summarized the same way.
STATUS_FLAGS = [
"", "BMS Temp High", "Overcurrent State", "Charge OFF", "Aux Input State",
"Cell Temp Low", "Cell Temp High", "AGSR State", "Temp Sensor Fault",
"CAN Charger Fault", "CAN Charger Present", "AC Power Present",
"Contactor Flutter", "Pre-Charge Fault", "Contactor Fault",
"Contactor State", "Power Off State", "Battery Protection", "Low Voltage",
"Reserve Range", "OptoLoop Open", "NeverDie Reserve", "Charge Detected",
"High Voltage",
]
def decode_status(hex_code):
try:
value = int(hex_code, 16)
except (ValueError, TypeError):
return hex_code
active = [
label for i, label in enumerate(STATUS_FLAGS)
if label and (value >> (23 - i)) & 1
]
return ", ".join(active) if active else "OK"
def parse_line(line):
"""Cs-series main telemetry line (no prefix character)."""
parts = line.split(",")
try:
f0 = int(parts[0])
except ValueError:
return None
if not (101 <= f0 <= 9999):
return None # not a Cs-series main telemetry line
try:
return {
"voltage": round(f0 * 0.01, 2),
"cell1": round(int(parts[1]) * 0.01, 2),
"cell2": round(int(parts[2]) * 0.01, 2),
"cell3": round(int(parts[3]) * 0.01, 2),
"cell4": round(int(parts[4]) * 0.01, 2),
"bms_temp_f": int(parts[5]),
"batt_temp_f": int(parts[6]),
"current_a": int(parts[7]),
"soc_pct": int(parts[8]),
"status": parts[9] if len(parts) > 9 else "?",
}
except (ValueError, IndexError):
return None
def parse_trace_line(line):
"""'&' trace line: &,batteryId,remaining,remainingTime,canChargerVoltage,
canChargerCurrent,canChargerStatus,canStatus"""
parts = line.split(",")
try:
return {
"remaining_capacity": int(parts[2]),
"remaining_time": int(parts[3]),
"can_charger_voltage": round(int(parts[4]) * 0.1, 1),
"can_charger_current": round(int(parts[5]) * 0.1, 1),
"can_charger_status": parts[6],
"can_status": parts[7],
}
except (ValueError, IndexError):
return None
def parse_info_line(line):
"""'$' info line (response to $info): $,totalConsumed,lastFaultCode,
highestRecordedTemp,lowestRecordedTemp,firmwareVersion,agingFactorTemp,
agingFactorSoc,serialNumber"""
parts = line.split(",")
try:
return {
"total_consumed": int(parts[1]),
"last_fault_code": parts[2],
"highest_recorded_temp": int(parts[3]),
"lowest_recorded_temp": int(parts[4]),
"firmware_version": parts[5],
"aging_factor_temp": int(parts[6]),
"aging_factor_soc": int(parts[7]),
"serial_number": parts[8],
}
except (ValueError, IndexError):
return None
def build_state_payload(cs_reading, trace_fields):
# A non-zero status means the rest of the Cs line's fields are unreliable
# (observed 2026-08-18: status '69' alongside e.g. current=341, soc=340,
# cell4_voltage=34013.63) -- publish only the status in that case. Trace
# ("&" line) fields come from a separate message and are published
# regardless.
status = cs_reading["status"]
if status != "000000":
payload = {"status": status}
else:
payload = {
"voltage": cs_reading["voltage"],
"cell1_voltage": cs_reading["cell1"],
"cell2_voltage": cs_reading["cell2"],
"cell3_voltage": cs_reading["cell3"],
"cell4_voltage": cs_reading["cell4"],
"current": cs_reading["current_a"],
"soc": cs_reading["soc_pct"],
"bms_temperature": cs_reading["bms_temp_f"],
"battery_temperature": cs_reading["batt_temp_f"],
"status": status,
}
payload["status_text"] = decode_status(status)
payload.update(trace_fields)
return payload
@@ -0,0 +1,98 @@
"""Sensor platform for the Lithionics Li3 BMS integration."""
from __future__ import annotations
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import DeviceInfo, EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN, INFO_SENSORS, SENSORS
from .coordinator import Li3Coordinator
_DEVICE_CLASS_MAP = {
"voltage": SensorDeviceClass.VOLTAGE,
"current": SensorDeviceClass.CURRENT,
"battery": SensorDeviceClass.BATTERY,
"temperature": SensorDeviceClass.TEMPERATURE,
"duration": SensorDeviceClass.DURATION,
}
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
coordinator: Li3Coordinator = hass.data[DOMAIN][entry.entry_id]
device_info = DeviceInfo(
identifiers={(DOMAIN, entry.data["address"])},
name=entry.data.get("name", "Li3 Battery"),
manufacturer="Lithionics",
model="Li3 BMS",
)
entities: list[SensorEntity] = [
Li3Sensor(coordinator, device_info, key, name, unit, device_class, precision, "data")
for key, name, unit, device_class, precision in SENSORS
]
entities.append(
Li3Sensor(coordinator, device_info, "status_text", "Status", None, None, None, "data")
)
entities.extend(
Li3Sensor(
coordinator, device_info, key, name, unit, device_class, precision, "info",
diagnostic=True,
)
for key, name, unit, device_class, precision in INFO_SENSORS
)
async_add_entities(entities)
class Li3Sensor(SensorEntity):
"""A single field of the Li3 BMS, read live from the coordinator."""
_attr_should_poll = False
_attr_has_entity_name = True
def __init__(
self,
coordinator: Li3Coordinator,
device_info: DeviceInfo,
key: str,
name: str,
unit: str | None,
device_class: str | None,
precision: int | None,
source: str,
diagnostic: bool = False,
) -> None:
self._coordinator = coordinator
self._key = key
self._source = source
self._attr_name = name
self._attr_native_unit_of_measurement = unit
self._attr_device_class = _DEVICE_CLASS_MAP.get(device_class or "")
self._attr_unique_id = f"{coordinator.address}_{key}"
self._attr_device_info = device_info
if precision is not None:
self._attr_suggested_display_precision = precision
self._attr_state_class = SensorStateClass.MEASUREMENT
if diagnostic:
self._attr_entity_category = EntityCategory.DIAGNOSTIC
def _current_source(self) -> dict:
return self._coordinator.data if self._source == "data" else self._coordinator.info
@property
def available(self) -> bool:
return self._key in self._current_source()
@property
def native_value(self):
return self._current_source().get(self._key)
async def async_added_to_hass(self) -> None:
self.async_on_remove(self._coordinator.async_add_listener(self._handle_update))
@callback
def _handle_update(self) -> None:
self.async_write_ha_state()
@@ -0,0 +1,17 @@
{
"config": {
"step": {
"bluetooth_confirm": {
"description": "Add the Li3 battery `{name}`?"
},
"user": {
"data": {
"address": "Device"
}
}
},
"abort": {
"already_configured": "This battery is already configured"
}
}
}
@@ -0,0 +1,17 @@
{
"config": {
"step": {
"bluetooth_confirm": {
"description": "Add the Li3 battery `{name}`?"
},
"user": {
"data": {
"address": "Device"
}
}
},
"abort": {
"already_configured": "This battery is already configured"
}
}
}