From 382f99b5084770e845eb874e74a3aed51a1d1326 Mon Sep 17 00:00:00 2001 From: Andreas Wrede Date: Tue, 18 Aug 2026 09:56:32 -0400 Subject: [PATCH] li3: publish CAN-trace fields with telemetry, info fields as separate retained topic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decoded the "&" and "$" line formats from log1 by matching them against the decompiled com.lithionics.bms app: TraceFormat/InfoFormat gate on the raw line's first byte ('&'=trace, '$'=info) before BmsSeries.create() ever sees the row, which is why the earlier decode (MainBmsCsParameters) never touched them. "&" trace lines stream continuously (enabled by our own $traceon) and carry CAN-charger-bus fields: remaining_capacity, remaining_time, can_charger_ voltage/current, can_charger_status, can_status. These merge into the existing periodic state message alongside the Cs telemetry. "$" info is sent once per connection (response to $info) and never repeats: total_consumed, last_fault_code, highest/lowest_recorded_temp, firmware_ version, aging_factor_temp/soc, serial_number. Published to its own retained topic (van//info) instead of the periodic one, with its own discovery config (entity_category: diagnostic) — so HA keeps the last known value across BMS disconnects rather than expiring it. Deployed and verified live on host wan: info message publishes once on connect, state messages carry the merged fields, and 14 new HA entities registered with correct precision. --- li3/van-li3-battery | 209 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 162 insertions(+), 47 deletions(-) diff --git a/li3/van-li3-battery b/li3/van-li3-battery index 2bc9ef9..4e3c3ab 100755 --- a/li3/van-li3-battery +++ b/li3/van-li3-battery @@ -53,23 +53,50 @@ state_topic = cfg.get("state_topic", f"van/{device_id}/state") publish_interval_s = cfg.get("publish_interval_s", 15) SENSORS = [ - # (key, name, unit, device_class, value_template_field, display_precision) - ("voltage", "Pack Voltage", "V", "voltage", "voltage", 2), - ("cell1_voltage", "Cell 1 Voltage", "V", "voltage", "cell1", 2), - ("cell2_voltage", "Cell 2 Voltage", "V", "voltage", "cell2", 2), - ("cell3_voltage", "Cell 3 Voltage", "V", "voltage", "cell3", 2), - ("cell4_voltage", "Cell 4 Voltage", "V", "voltage", "cell4", 2), - ("current", "Current", "A", "current", "current_a", 2), - ("soc", "State of Charge", "%", "battery", "soc_pct", 0), - ("bms_temperature", "BMS Temperature", "°F", "temperature", "bms_temp_f", 1), - ("battery_temperature", "Battery Temperature", "°F", "temperature", "batt_temp_f", 1), + # (key, name, unit, device_class, display_precision) -- precision None means + # a non-numeric value (hex code, version string, serial number): published + # as-is with no unit, no rounding, no state_class. + ("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), + # From "&" trace lines (streamed continuously once $traceon is sent) -- + # CAN-charger bus fields. This battery has no CAN charger wired up, so + # can_charger_voltage/current are observed as a fixed sentinel and + # can_charger_status/can_status never change; published anyway since + # they're genuine decoded fields. + ("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 "$" info line, sent once by the device right after $info and never +# repeated -- published separately (own retained state topic) rather than +# merged into the periodic telemetry above. +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), +] + +info_topic = cfg.get("info_topic", f"van/{device_id}/info") + def parse_line(line): - line = line.strip() - if not line or line.startswith("&") or line.startswith("$"): - return None + """Cs-series main telemetry line (no prefix character).""" parts = line.split(",") try: f0 = int(parts[0]) @@ -94,6 +121,65 @@ def parse_line(line): 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 _sensor_config(key, name, unit, device_class, precision, topic_state, device_info): + numeric = precision is not None + payload = { + "name": name, + "unique_id": f"{device_id}_{key}", + "state_topic": topic_state, + "unit_of_measurement": unit, + "device_class": device_class, + "value_template": ( + f"{{{{ value_json.{key} | round({precision}) }}}}" + if numeric + else f"{{{{ value_json.{key} }}}}" + ), + "device": device_info, + "expire_after": publish_interval_s * 4, + } + if numeric: + payload["suggested_display_precision"] = precision + payload["state_class"] = "measurement" + return payload + + def publish_discovery(mqc): device_info = { "identifiers": [device_id], @@ -101,20 +187,9 @@ def publish_discovery(mqc): "manufacturer": "Lithionics", "model": "Li3 BMS", } - for key, name, unit, device_class, _field, precision in SENSORS: + for key, name, unit, device_class, precision in SENSORS: topic = f"{discovery_prefix}/sensor/{device_id}/{key}/config" - payload = { - "name": name, - "unique_id": f"{device_id}_{key}", - "state_topic": state_topic, - "unit_of_measurement": unit, - "device_class": device_class, - "value_template": f"{{{{ value_json.{key} | round({precision}) }}}}", - "suggested_display_precision": precision, - "device": device_info, - "expire_after": publish_interval_s * 4, - "state_class": "measurement", - } + payload = _sensor_config(key, name, unit, device_class, precision, state_topic, device_info) mqc.publish(topic, json.dumps(payload), retain=True) status_topic = f"{discovery_prefix}/sensor/{device_id}/status/config" @@ -128,20 +203,43 @@ def publish_discovery(mqc): } mqc.publish(status_topic, json.dumps(status_payload), retain=True) + for key, name, unit, device_class, precision in INFO_SENSORS: + topic = f"{discovery_prefix}/sensor/{device_id}/{key}/config" + payload = _sensor_config(key, name, unit, device_class, precision, info_topic, device_info) + payload["entity_category"] = "diagnostic" + mqc.publish(topic, json.dumps(payload), retain=True) -def build_state_payload(reading): - return { - "voltage": reading["voltage"], - "cell1_voltage": reading["cell1"], - "cell2_voltage": reading["cell2"], - "cell3_voltage": reading["cell3"], - "cell4_voltage": reading["cell4"], - "current": reading["current_a"], - "soc": reading["soc_pct"], - "bms_temperature": reading["bms_temp_f"], - "battery_temperature": reading["batt_temp_f"], - "status": reading["status"], - } + +def publish_info(mqc, info): + """Publish the once-per-connection '$info' fields as their own retained + message, separate from the periodic telemetry state.""" + mqc.publish(info_topic, json.dumps(info), retain=True) + print("published info:", info) + + +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. + if cs_reading["status"] != "000000": + payload = {"status": cs_reading["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": cs_reading["status"], + } + payload.update(trace_fields) + return payload def restart_bluetooth_service(): @@ -175,16 +273,33 @@ async def stream_from_device(dev, mqc): """Connect to `dev` and publish readings until it disconnects.""" buf = "" last_publish = 0.0 - latest = {} + latest_cs = {} + latest_trace = {} + info_published = False def notify_handler(_sender, data): - nonlocal buf + nonlocal buf, info_published buf += data.decode("utf-8", errors="replace") while "\r\n" in buf: line, buf = buf.split("\r\n", 1) - reading = parse_line(line) - if reading: - latest.update(reading) + line = line.strip() + if not line: + continue + print("RAW", line) + if line.startswith("&"): + trace = parse_trace_line(line) + if trace: + latest_trace.update(trace) + elif line.startswith("$"): + if not info_published: + info = parse_info_line(line) + if info: + publish_info(mqc, info) + info_published = True + else: + reading = parse_line(line) + if reading: + latest_cs.update(reading) async with BleakClient(dev, timeout=15) as client: print("connected") @@ -196,10 +311,10 @@ async def stream_from_device(dev, mqc): while client.is_connected: await asyncio.sleep(1) now = time.time() - if latest and (now - last_publish) >= publish_interval_s: - payload = build_state_payload(latest) + if latest_cs and (now - last_publish) >= publish_interval_s: + payload = build_state_payload(latest_cs, latest_trace) mqc.publish(state_topic, json.dumps(payload)) - print("published:", payload) +# print("published:", payload) last_publish = now print("disconnected")