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:
co-authored by
Claude Sonnet 5
parent
94e6a2d811
commit
d25f48727f
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user