li3: decode status flags, fix info-sensor expire_after, restart bluetooth after first failed scan
- status_text: decode the 24-bit "status" field into human-readable flag names (e.g. "Cell Temp Low, Low Voltage") instead of raw hex, using the bit table pulled from com.lithionics.bms's array/advanced resource (dumped with aapt -- the app's own StatusCodeTable class references stale/wrong resource IDs and can't be trusted for this). Applied to "status" only; last_fault_code is a lifetime latch that accumulates many bits over time and isn't meaningfully summarized the same way. - Fixed the info sensors (total_consumed, firmware_version, serial_number, etc.) showing "Unavailable" in HA a minute after connecting: they were getting the same expire_after as the periodic telemetry sensors, but the $info line the come from is only sent once per BLE connection, so HA's expire timer always fired. These are retained-topic diagnostics meant to hold their last value indefinitely; expire_after now only applies to the periodic SENSORS group. - STUCK_DISCOVERY_THRESHOLD 3 -> 1: restart bluetooth.service after the first failed scan instead of waiting for three, cutting reconnect time roughly in third. Still rate-limited via BLUETOOTH_RESTART_COOLDOWN_S. Also removed the per-line "RAW ..." debug print (was flooding the journal). All three changes verified live on host wan via journalctl/mosquitto_sub.
This commit is contained in:
@@ -282,8 +282,8 @@ script's docstring for the full field layout.
|
|||||||
systemd restarts. This Pi's onboard Bluetooth adapter (Cypress/CYW43) occasionally
|
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
|
wedges bluetoothd's discovery state after a run of failed connects to this specific
|
||||||
device (`Discovering` stays `yes` forever, every subsequent connect fails with
|
device (`Discovering` stays `yes` forever, every subsequent connect fails with
|
||||||
`le-connection-abort-by-local`) — after 3 consecutive scan failures the daemon
|
`le-connection-abort-by-local`) — as soon as a scan fails the daemon restarts
|
||||||
restarts `bluetooth.service` itself to clear it, rate-limited to once per 5 min so it
|
`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).
|
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
|
- 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.
|
`van-li3-battery` holds it, the Li3 phone app can't connect simultaneously.
|
||||||
|
|||||||
+51
-14
@@ -11,10 +11,11 @@
|
|||||||
# occasionally wedges its discovery state after a run of failed connection
|
# occasionally wedges its discovery state after a run of failed connection
|
||||||
# attempts (Discovering stays "yes" forever, and every subsequent connect
|
# attempts (Discovering stays "yes" forever, and every subsequent connect
|
||||||
# fails with le-connection-abort-by-local) — observed happening from our own
|
# fails with le-connection-abort-by-local) — observed happening from our own
|
||||||
# repeated scan/connect cycling, not anything external. After several
|
# repeated scan/connect cycling, not anything external. We restart
|
||||||
# consecutive failures we restart bluetooth.service ourselves to clear that,
|
# bluetooth.service ourselves as soon as a scan fails to clear that, rate-
|
||||||
# rate-limited so we don't do it so often it disrupts the other BLE gear on
|
# limited (BLUETOOTH_RESTART_COOLDOWN_S) so we don't do it so often it
|
||||||
# this hub (motion sensors, IR remote, etc. also served by bluetoothd here).
|
# 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
|
# Only exits (letting systemd Restart=always give us a fresh process) on
|
||||||
# MQTT-level failure, which shouldn't happen in normal operation.
|
# 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
|
from bleak import BleakClient, BleakScanner
|
||||||
|
|
||||||
BLE_RETRY_DELAY_S = 8
|
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
|
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"
|
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")
|
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):
|
def parse_line(line):
|
||||||
"""Cs-series main telemetry line (no prefix character)."""
|
"""Cs-series main telemetry line (no prefix character)."""
|
||||||
@@ -158,7 +190,7 @@ def parse_info_line(line):
|
|||||||
return None
|
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
|
numeric = precision is not None
|
||||||
payload = {
|
payload = {
|
||||||
"name": name,
|
"name": name,
|
||||||
@@ -172,8 +204,9 @@ def _sensor_config(key, name, unit, device_class, precision, topic_state, device
|
|||||||
else f"{{{{ value_json.{key} }}}}"
|
else f"{{{{ value_json.{key} }}}}"
|
||||||
),
|
),
|
||||||
"device": device_info,
|
"device": device_info,
|
||||||
"expire_after": publish_interval_s * 4,
|
|
||||||
}
|
}
|
||||||
|
if expire_after is not None:
|
||||||
|
payload["expire_after"] = expire_after
|
||||||
if numeric:
|
if numeric:
|
||||||
payload["suggested_display_precision"] = precision
|
payload["suggested_display_precision"] = precision
|
||||||
payload["state_class"] = "measurement"
|
payload["state_class"] = "measurement"
|
||||||
@@ -189,7 +222,10 @@ def publish_discovery(mqc):
|
|||||||
}
|
}
|
||||||
for key, name, unit, device_class, precision in SENSORS:
|
for key, name, unit, device_class, precision in SENSORS:
|
||||||
topic = f"{discovery_prefix}/sensor/{device_id}/{key}/config"
|
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)
|
mqc.publish(topic, json.dumps(payload), retain=True)
|
||||||
|
|
||||||
status_topic = f"{discovery_prefix}/sensor/{device_id}/status/config"
|
status_topic = f"{discovery_prefix}/sensor/{device_id}/status/config"
|
||||||
@@ -197,7 +233,7 @@ def publish_discovery(mqc):
|
|||||||
"name": "Status",
|
"name": "Status",
|
||||||
"unique_id": f"{device_id}_status",
|
"unique_id": f"{device_id}_status",
|
||||||
"state_topic": state_topic,
|
"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,
|
"device": device_info,
|
||||||
"expire_after": publish_interval_s * 4,
|
"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
|
# cell4_voltage=34013.63) — publish only the status in that case. Trace
|
||||||
# ("&" line) fields come from a separate message and are published
|
# ("&" line) fields come from a separate message and are published
|
||||||
# regardless.
|
# regardless.
|
||||||
if cs_reading["status"] != "000000":
|
status = cs_reading["status"]
|
||||||
payload = {"status": cs_reading["status"]}
|
if status != "000000":
|
||||||
|
payload = {"status": status}
|
||||||
else:
|
else:
|
||||||
payload = {
|
payload = {
|
||||||
"voltage": cs_reading["voltage"],
|
"voltage": cs_reading["voltage"],
|
||||||
@@ -236,14 +273,15 @@ def build_state_payload(cs_reading, trace_fields):
|
|||||||
"soc": cs_reading["soc_pct"],
|
"soc": cs_reading["soc_pct"],
|
||||||
"bms_temperature": cs_reading["bms_temp_f"],
|
"bms_temperature": cs_reading["bms_temp_f"],
|
||||||
"battery_temperature": cs_reading["batt_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)
|
payload.update(trace_fields)
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def restart_bluetooth_service():
|
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)
|
subprocess.run(["systemctl", "restart", "bluetooth.service"], check=False)
|
||||||
time.sleep(3)
|
time.sleep(3)
|
||||||
|
|
||||||
@@ -285,7 +323,6 @@ async def stream_from_device(dev, mqc):
|
|||||||
line = line.strip()
|
line = line.strip()
|
||||||
if not line:
|
if not line:
|
||||||
continue
|
continue
|
||||||
print("RAW", line)
|
|
||||||
if line.startswith("&"):
|
if line.startswith("&"):
|
||||||
trace = parse_trace_line(line)
|
trace = parse_trace_line(line)
|
||||||
if trace:
|
if trace:
|
||||||
|
|||||||
Reference in New Issue
Block a user