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:
+59
-10
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user