li3: publish CAN-trace fields with telemetry, info fields as separate retained topic
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/<device_id>/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.
This commit is contained in:
+162
-47
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user