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