diff --git a/li3/van-li3-battery b/li3/van-li3-battery index 1bd33f1..ddce9f1 100755 --- a/li3/van-li3-battery +++ b/li3/van-li3-battery @@ -39,6 +39,7 @@ from bleak import BleakClient, BleakScanner BLE_RETRY_DELAY_S = 8 STUCK_DISCOVERY_THRESHOLD = 10 # consecutive scan failures before we intervene BLUETOOTH_RESTART_COOLDOWN_S = 300 # don't restart bluetooth.service more than this often +MQTT_STARTUP_TIMEOUT_S = 30 # exit (let systemd restart) if the broker never connects config_path = sys.argv[1] if len(sys.argv) > 1 else "/etc/van-li3/config.json" with open(config_path) as f: @@ -122,10 +123,19 @@ STATUS_FLAGS = [ ] -def decode_status(hex_code): +def status_value(status): + """Parse the raw hex status field to an int; None if unparseable. + Single source of truth for both the "is this OK" gate and the flag + decode, so the two can't disagree on e.g. "0" vs "000000".""" try: - value = int(hex_code, 16) + return int(status, 16) except (ValueError, TypeError): + return None + + +def decode_status(hex_code): + value = status_value(hex_code) + if value is None: return hex_code active = [ label for i, label in enumerate(STATUS_FLAGS) @@ -152,7 +162,7 @@ def parse_line(line): "cell4": round(int(parts[4]) * 0.01, 2), "bms_temp_f": int(parts[5]), "batt_temp_f": int(parts[6]), - "current_a": int(parts[7]), + "current_a": round(int(parts[7]) * 0.1, 1), # reported in 0.1A units "soc_pct": int(parts[8]), "status": parts[9] if len(parts) > 9 else "?", } @@ -231,7 +241,7 @@ def publish_discovery(mqc): topic = f"{discovery_prefix}/sensor/{device_id}/{key}/config" payload = _sensor_config( key, name, unit, device_class, precision, state_topic, device_info, - expire_after=publish_interval_s * 4, + expire_after=publish_interval_s * 8, ) mqc.publish(topic, json.dumps(payload), retain=True) @@ -242,7 +252,7 @@ def publish_discovery(mqc): "state_topic": state_topic, "value_template": "{{ value_json.status_text }}", "device": device_info, - "expire_after": publish_interval_s * 4, + "expire_after": publish_interval_s * 8, } mqc.publish(status_topic, json.dumps(status_payload), retain=True) @@ -267,7 +277,11 @@ def build_state_payload(cs_reading, trace_fields): # ("&" line) fields come from a separate message and are published # regardless. status = cs_reading["status"] - if status != "000000": + # "OK" == integer zero in ANY representation. Gate on the parsed int (not + # the raw string) so "0"/"0000"/"000000" all agree with decode_status, and + # an unparseable status (e.g. "?") is treated as unreliable and suppressed. + value = status_value(status) + if value is None or value != 0: payload = {"status": status} else: payload = { @@ -287,10 +301,20 @@ def build_state_payload(cs_reading, trace_fields): return payload -def restart_bluetooth_service(): +async def restart_bluetooth_service(): print("scan failed; restarting bluetooth.service") - subprocess.run(["systemctl", "restart", "bluetooth.service"], check=False) - time.sleep(3) + # Run systemctl + the settle delay off the event loop (worker thread) so + # they don't block the loop; paho drives its own socket thread regardless. + try: + await asyncio.to_thread( + subprocess.run, + ["systemctl", "restart", "bluetooth.service"], + check=False, + timeout=30, + ) + except (subprocess.SubprocessError, OSError) as e: + print("bluetooth restart failed:", e) + await asyncio.sleep(3) async def find_device(): @@ -308,7 +332,7 @@ async def find_device(): if consecutive_failures >= STUCK_DISCOVERY_THRESHOLD: now = time.time() if (now - last_bluetooth_restart) >= BLUETOOTH_RESTART_COOLDOWN_S: - restart_bluetooth_service() + await restart_bluetooth_service() last_bluetooth_restart = now consecutive_failures = 0 await asyncio.sleep(BLE_RETRY_DELAY_S) @@ -366,8 +390,33 @@ async def stream_from_device(dev, mqc): async def main(): mqc = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=client_id) mqc.username_pw_set(mq_user, mq_pw) + + # Reassert HA discovery on every (re)connect. If mosquitto restarts and + # loses its retained messages, the whole device disappears from HA until + # discovery is re-sent; doing it from here (paho thread) keeps the entities + # self-restoring. Idempotent on the very first connect. + def _reassert_discovery(client, *args): + publish_discovery(client) + + mqc.on_connect_callback_add(_reassert_discovery) + mqc.connect(broker, mqtt_port, keepalive=60) mqc.loop_start() + + # The one failure that makes this service pointless is "no broker at all": + # streaming BLE to a dead broker just wastes power and hides the fault. So + # give the connection a bounded startup window and exit (letting systemd + # restart us) if it never establishes. A good start's transient drops are + # left to paho's built-in auto-reconnect above. + deadline = time.monotonic() + MQTT_STARTUP_TIMEOUT_S + while not mqc.is_connected() and time.monotonic() < deadline: + await asyncio.sleep(1) + if not mqc.is_connected(): + print("ERROR: MQTT broker %s:%s not reachable; exiting" % (broker, mqtt_port)) + mqc.loop_stop() + mqc.disconnect() + sys.exit(1) + publish_discovery(mqc) while True: