From e5e776405ab840ea3e7663d4c1c8d98b13529fb0 Mon Sep 17 00:00:00 2001 From: Andreas Wrede Date: Tue, 18 Aug 2026 10:18:56 -0400 Subject: [PATCH] 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. --- README.md | 4 +-- li3/van-li3-battery | 65 +++++++++++++++++++++++++++++++++++---------- 2 files changed, 53 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 6ef4a73..e0f8148 100644 --- a/README.md +++ b/README.md @@ -282,8 +282,8 @@ script's docstring for the full field layout. systemd restarts. This Pi's onboard Bluetooth adapter (Cypress/CYW43) occasionally wedges bluetoothd's discovery state after a run of failed connects to this specific device (`Discovering` stays `yes` forever, every subsequent connect fails with - `le-connection-abort-by-local`) — after 3 consecutive scan failures the daemon - restarts `bluetooth.service` itself to clear it, rate-limited to once per 5 min so it + `le-connection-abort-by-local`) — as soon as a scan fails the daemon restarts + `bluetooth.service` itself to clear it, rate-limited to once per 5 min so it doesn't repeatedly disrupt the AP's other BLE gear (motion sensors, IR remote). - This BLE module accepts only **one central connection at a time** — while `van-li3-battery` holds it, the Li3 phone app can't connect simultaneously. diff --git a/li3/van-li3-battery b/li3/van-li3-battery index 4e3c3ab..5d82a85 100755 --- a/li3/van-li3-battery +++ b/li3/van-li3-battery @@ -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: