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: