li3: decode status flags, fix info-sensor expire_after, restart bluetooth after first failed scan

- status_text: decode the 24-bit "status" field into human-readable flag
  names (e.g. "Cell Temp Low, Low Voltage") instead of raw hex, using the
  bit table pulled from com.lithionics.bms's array/advanced resource (dumped
  with aapt -- the app's own StatusCodeTable class references stale/wrong
  resource IDs and can't be trusted for this). Applied to "status" only;
  last_fault_code is a lifetime latch that accumulates many bits over time
  and isn't meaningfully summarized the same way.

- Fixed the info sensors (total_consumed, firmware_version, serial_number,
  etc.) showing "Unavailable" in HA a minute after connecting: they were
  getting the same expire_after as the periodic telemetry sensors, but the
  $info line the come from is only sent once per BLE connection, so HA's
  expire timer always fired. These are retained-topic diagnostics meant to
  hold their last value indefinitely; expire_after now only applies to the
  periodic SENSORS group.

- STUCK_DISCOVERY_THRESHOLD 3 -> 1: restart bluetooth.service after the
  first failed scan instead of waiting for three, cutting reconnect time
  roughly in third. Still rate-limited via BLUETOOTH_RESTART_COOLDOWN_S.

Also removed the per-line "RAW ..." debug print (was flooding the journal).

All three changes verified live on host wan via journalctl/mosquitto_sub.
This commit is contained in:
Andreas Wrede
2026-08-18 10:18:56 -04:00
parent 382f99b508
commit e5e776405a
2 changed files with 53 additions and 16 deletions
+51 -14
View File
@@ -11,10 +11,11 @@
# occasionally wedges its discovery state after a run of failed connection
# attempts (Discovering stays "yes" forever, and every subsequent connect
# fails with le-connection-abort-by-local) — observed happening from our own
# repeated scan/connect cycling, not anything external. After several
# consecutive failures we restart bluetooth.service ourselves to clear that,
# rate-limited so we don't do it so often it disrupts the other BLE gear on
# this hub (motion sensors, IR remote, etc. also served by bluetoothd here).
# repeated scan/connect cycling, not anything external. We restart
# bluetooth.service ourselves as soon as a scan fails to clear that, rate-
# limited (BLUETOOTH_RESTART_COOLDOWN_S) so we don't do it so often it
# disrupts the other BLE gear on this hub (motion sensors, IR remote, etc.
# also served by bluetoothd here).
#
# Only exits (letting systemd Restart=always give us a fresh process) on
# MQTT-level failure, which shouldn't happen in normal operation.
@@ -29,7 +30,7 @@ import paho.mqtt.client as mqtt
from bleak import BleakClient, BleakScanner
BLE_RETRY_DELAY_S = 8
STUCK_DISCOVERY_THRESHOLD = 3 # consecutive scan failures before we intervene
STUCK_DISCOVERY_THRESHOLD = 1 # consecutive scan failures before we intervene
BLUETOOTH_RESTART_COOLDOWN_S = 300 # don't restart bluetooth.service more than this often
config_path = sys.argv[1] if len(sys.argv) > 1 else "/etc/van-li3/config.json"
@@ -94,6 +95,37 @@ INFO_SENSORS = [
info_topic = cfg.get("info_topic", f"van/{device_id}/info")
# 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)."""
@@ -158,7 +190,7 @@ def parse_info_line(line):
return None
def _sensor_config(key, name, unit, device_class, precision, topic_state, device_info):
def _sensor_config(key, name, unit, device_class, precision, topic_state, device_info, expire_after=None):
numeric = precision is not None
payload = {
"name": name,
@@ -172,8 +204,9 @@ def _sensor_config(key, name, unit, device_class, precision, topic_state, device
else f"{{{{ value_json.{key} }}}}"
),
"device": device_info,
"expire_after": publish_interval_s * 4,
}
if expire_after is not None:
payload["expire_after"] = expire_after
if numeric:
payload["suggested_display_precision"] = precision
payload["state_class"] = "measurement"
@@ -189,7 +222,10 @@ def publish_discovery(mqc):
}
for key, name, unit, device_class, precision in SENSORS:
topic = f"{discovery_prefix}/sensor/{device_id}/{key}/config"
payload = _sensor_config(key, name, unit, device_class, precision, state_topic, device_info)
payload = _sensor_config(
key, name, unit, device_class, precision, state_topic, device_info,
expire_after=publish_interval_s * 4,
)
mqc.publish(topic, json.dumps(payload), retain=True)
status_topic = f"{discovery_prefix}/sensor/{device_id}/status/config"
@@ -197,7 +233,7 @@ def publish_discovery(mqc):
"name": "Status",
"unique_id": f"{device_id}_status",
"state_topic": state_topic,
"value_template": "{{ 'OK' if value_json.status == '000000' else value_json.status }}",
"value_template": "{{ value_json.status_text }}",
"device": device_info,
"expire_after": publish_interval_s * 4,
}
@@ -223,8 +259,9 @@ def build_state_payload(cs_reading, trace_fields):
# 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"]}
status = cs_reading["status"]
if status != "000000":
payload = {"status": status}
else:
payload = {
"voltage": cs_reading["voltage"],
@@ -236,14 +273,15 @@ def build_state_payload(cs_reading, trace_fields):
"soc": cs_reading["soc_pct"],
"bms_temperature": cs_reading["bms_temp_f"],
"battery_temperature": cs_reading["batt_temp_f"],
"status": cs_reading["status"],
"status": status,
}
payload["status_text"] = decode_status(status)
payload.update(trace_fields)
return payload
def restart_bluetooth_service():
print("too many consecutive scan failures; restarting bluetooth.service")
print("scan failed; restarting bluetooth.service")
subprocess.run(["systemctl", "restart", "bluetooth.service"], check=False)
time.sleep(3)
@@ -285,7 +323,6 @@ async def stream_from_device(dev, mqc):
line = line.strip()
if not line:
continue
print("RAW", line)
if line.startswith("&"):
trace = parse_trace_line(line)
if trace: