li3: correct status gate and current scaling; harden BLE/MQTT recovery

- status gate and decode_status now both key off one parsed int
  (0 == OK in any repr; unparseable = unreliable), so they can't diverge
  on "0"/"00"/"0000"
- main-pack current parsed at its real 0.1 A scale (was raw)
- run `systemctl restart bluetooth` + settle delay off the event loop
  (asyncio.to_thread, timeout=30; errors logged, not fatal); call site awaits
- bounded MQTT startup: exit (let systemd restart) if broker never connects
  within MQTT_STARTUP_TIMEOUT_S
- re-assert HA discovery on every (re)connect so entities survive a
  mosquitto restart that drops retained config
- expire_after *4 -> *8 so routine scan misses don't flap the battery
  to unavailable
This commit is contained in:
2026-09-27 09:55:18 -04:00
parent d1354bdd09
commit 72487cfdc4
+59 -10
View File
@@ -39,6 +39,7 @@ from bleak import BleakClient, BleakScanner
BLE_RETRY_DELAY_S = 8 BLE_RETRY_DELAY_S = 8
STUCK_DISCOVERY_THRESHOLD = 10 # consecutive scan failures before we intervene STUCK_DISCOVERY_THRESHOLD = 10 # consecutive scan failures before we intervene
BLUETOOTH_RESTART_COOLDOWN_S = 300 # don't restart bluetooth.service more than this often 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" config_path = sys.argv[1] if len(sys.argv) > 1 else "/etc/van-li3/config.json"
with open(config_path) as f: 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: try:
value = int(hex_code, 16) return int(status, 16)
except (ValueError, TypeError): except (ValueError, TypeError):
return None
def decode_status(hex_code):
value = status_value(hex_code)
if value is None:
return hex_code return hex_code
active = [ active = [
label for i, label in enumerate(STATUS_FLAGS) label for i, label in enumerate(STATUS_FLAGS)
@@ -152,7 +162,7 @@ def parse_line(line):
"cell4": round(int(parts[4]) * 0.01, 2), "cell4": round(int(parts[4]) * 0.01, 2),
"bms_temp_f": int(parts[5]), "bms_temp_f": int(parts[5]),
"batt_temp_f": int(parts[6]), "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]), "soc_pct": int(parts[8]),
"status": parts[9] if len(parts) > 9 else "?", "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" topic = f"{discovery_prefix}/sensor/{device_id}/{key}/config"
payload = _sensor_config( payload = _sensor_config(
key, name, unit, device_class, precision, state_topic, device_info, 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) mqc.publish(topic, json.dumps(payload), retain=True)
@@ -242,7 +252,7 @@ def publish_discovery(mqc):
"state_topic": state_topic, "state_topic": state_topic,
"value_template": "{{ value_json.status_text }}", "value_template": "{{ value_json.status_text }}",
"device": device_info, "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) 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 # ("&" line) fields come from a separate message and are published
# regardless. # regardless.
status = cs_reading["status"] 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} payload = {"status": status}
else: else:
payload = { payload = {
@@ -287,10 +301,20 @@ def build_state_payload(cs_reading, trace_fields):
return payload return payload
def restart_bluetooth_service(): async def restart_bluetooth_service():
print("scan failed; restarting bluetooth.service") print("scan failed; restarting bluetooth.service")
subprocess.run(["systemctl", "restart", "bluetooth.service"], check=False) # Run systemctl + the settle delay off the event loop (worker thread) so
time.sleep(3) # 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(): async def find_device():
@@ -308,7 +332,7 @@ async def find_device():
if consecutive_failures >= STUCK_DISCOVERY_THRESHOLD: if consecutive_failures >= STUCK_DISCOVERY_THRESHOLD:
now = time.time() now = time.time()
if (now - last_bluetooth_restart) >= BLUETOOTH_RESTART_COOLDOWN_S: if (now - last_bluetooth_restart) >= BLUETOOTH_RESTART_COOLDOWN_S:
restart_bluetooth_service() await restart_bluetooth_service()
last_bluetooth_restart = now last_bluetooth_restart = now
consecutive_failures = 0 consecutive_failures = 0
await asyncio.sleep(BLE_RETRY_DELAY_S) await asyncio.sleep(BLE_RETRY_DELAY_S)
@@ -366,8 +390,33 @@ async def stream_from_device(dev, mqc):
async def main(): async def main():
mqc = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=client_id) mqc = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=client_id)
mqc.username_pw_set(mq_user, mq_pw) 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.connect(broker, mqtt_port, keepalive=60)
mqc.loop_start() 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) publish_discovery(mqc)
while True: while True: