Cleaned up battery mqtt topics

This commit is contained in:
2026-04-26 08:18:57 -04:00
parent e893be8c35
commit b526b10cf5
3 changed files with 218 additions and 18 deletions

87
eg4battery/2026-04-26.md Normal file
View File

@@ -0,0 +1,87 @@
# 2026-04-26 — MQTT entity cleanup
Pruned the published MQTT/HA entity set after noticing two issues:
**HA frontend rendering cell voltages as integers** (e.g. `3 V` instead of
`3.285 V`) and **208 entities per pack** flooding the HA Devices UI with
mostly-redundant `register_NN` debug data.
## What changed
1. **Per-field display precision.** Discovery configs now include
`suggested_display_precision`. HA frontend renders each field with the
appropriate decimal count.
| Field | Precision | Example |
|--------------------------------------|-----------|----------|
| `cell_NN_voltage`, `cell_voltage_min/max` | 3 | `3.285 V` |
| `pack_voltage`, `pack_current`, `max_current_limit` | 2 | `52.56 V` |
| `capacity_ah`, `remaining_ah` | 1 | `100.0 Ah`|
| `soc`, `soh`, `cycle_count`, `temperature_*`, `cell_*` (delta/index) | 0 | `100 %` |
2. **`expose_raw_registers: false`** new config option (default off).
Gates the `register_NN` raw-Modbus-register dump that was useful during
register-map reverse-engineering but is pure noise in production. Set
`expose_raw_registers: true` in `~/.config/eg4-battery/eg4-battery.yaml`
and restart the daemon to re-enable for diagnostics.
3. **Dropped three superseded named fields:**
- `bms_version_hi` / `bms_version_lo` — u16 dumps of regs 41-42, made
redundant by `firmware_version` (decoded ASCII string from regs 117-119)
- `uptime_ds` — bare counter, increments every second; no operational
value, only useful for raw debugging
4. **`tmp/eg4-purge-orphans` new tool.** Publishes empty retained payloads
to the deprecated discovery-config topics so HA forgets the orphaned
entities the broker still has cached. Idempotent; works against both
paho-mqtt 1.x and 2.x.
```bash
tmp/eg4-purge-orphans <broker_host> <user> <password>
```
## Net effect
| Metric | Before | After | Delta |
|-----------------------------------|--------|-------|--------|
| Published state topics per pack | 208 | 69 | 67 % |
| Published state topics across stack (3 packs) | 624 | 207 | 67 % |
| Orphan retained discovery configs purged from broker | — | 417 | one-time |
| Cell voltage display in HA frontend | `3 V` | `3.285 V` | precision restored |
## Files touched
```
M bin/eg4-battery
A tmp/eg4-purge-orphans
```
## How to verify
```bash
# Discovery configs now include precision hint
mosquitto_sub -h <broker> -u mqtt -P <pass> \
-t 'homeassistant/sensor/lifepower4_1_cell_01_voltage/config' -W 5 -v
# Expect: ..., "suggested_display_precision": 3, ...
# State values still flowing at full source resolution
mosquitto_sub -h <broker> -u mqtt -P <pass> \
-t 'homeassistant/sensor/lifepower4_1_cell_01_voltage/state' -W 5 -v
# Expect: 3.282 (or similar — 3-decimal mV-derived value)
# Confirm no register_NN, bms_version_*, or uptime_ds left
mosquitto_sub -h <broker> -u mqtt -P <pass> \
-t 'homeassistant/sensor/+/state' -W 12 -v \
| grep -E 'lifepower4_._(register|bms_version|uptime)'
# Expect: zero matches
```
## Rollback
```bash
git checkout HEAD~1 -- bin/eg4-battery
sudo install -m 755 bin/eg4-battery /usr/local/bin/eg4-battery
sudo systemctl restart eg4-battery.service
# Old register_NN/bms_version_*/uptime_ds will republish on next cycle.
# (To restore them in HA after rollback, the daemon's own publish_pack will
# recreate the discovery configs naturally — no purge undo needed.)
```

View File

@@ -96,6 +96,7 @@ class AppConfig:
mqtt: MQTTConfig
packs: list[PackConfig]
cell_count: int = 16 # active mode only
expose_raw_registers: bool = False # publish register_NN entities (modbus_per_pack)
def load_config(path: Path) -> AppConfig:
@@ -121,6 +122,7 @@ def load_config(path: Path) -> AppConfig:
mqtt=MQTTConfig(**mqtt_raw),
packs=[PackConfig(**p) for p in raw["packs"]],
cell_count=raw.get("cell_count", 16),
expose_raw_registers=raw.get("expose_raw_registers", False),
)
@@ -304,12 +306,13 @@ def _signed16(v: int) -> int:
return v - 0x10000 if v & 0x8000 else v
def decode_eg4_modbus_regs(regs: list[int]) -> dict[str, Any]:
"""Decode the 47-reg read-holding-regs response from an LP4V2 BMS.
Emits named HA entities where meaning is known; raw register_NN
passthrough for the rest."""
def decode_eg4_modbus_regs(regs: list[int], expose_raw: bool = False) -> dict[str, Any]:
"""Decode the read-holding-regs response from an LP4V2 BMS.
Emits named HA entities. If `expose_raw` is True, also emits
`register_NN` entities for every position — useful when refining
the register map; defaults to off to keep HA Devices uncluttered."""
out: dict[str, Any] = {}
# always emit raw registers — invaluable for future refinement
if expose_raw:
for i, v in enumerate(regs):
out[f"register_{i:02d}"] = v
@@ -365,13 +368,9 @@ def decode_eg4_modbus_regs(regs: list[int]) -> dict[str, Any]:
out["cycle_count"] = regs[39]
out["battery_mode"] = regs[40]
# BMS firmware version — regs 41 & 42 appear to hold version codes; emit
# the raw u16s alongside a decimal representation for easier HA display
out["bms_version_hi"] = regs[41]
out["bms_version_lo"] = regs[42]
# reg 46 increments ~1.25 Hz on live bus — likely uptime in deciseconds
out["uptime_ds"] = regs[46]
# regs 41-42: u16 version codes — superseded by `firmware_version` ASCII
# decode below; available via expose_raw if needed
# reg 46: ~1.25 Hz uptime counter — noisy, available via expose_raw if needed
# --- block-2 strings (regs 105..123) — fetched on the second Modbus read ---
if len(regs) >= 124:
@@ -698,9 +697,6 @@ _FIELD_META.update({
"cell_count": (None, None, "measurement", "mdi:numeric"),
"remaining_ah": ("Ah", None, "measurement", "mdi:battery-clock"),
"battery_mode": (None, None, None, "mdi:state-machine"),
"bms_version_hi": (None, None, None, "mdi:chip"),
"bms_version_lo": (None, None, None, "mdi:chip"),
"uptime_ds": (None, None, "total_increasing", "mdi:timer-outline"),
"model": (None, None, None, "mdi:battery-outline"),
"firmware_version": (None, None, None, "mdi:chip"),
"firmware_date": (None, None, None, "mdi:calendar"),
@@ -717,6 +713,38 @@ def field_meta(key: str) -> tuple[str | None, str | None, str | None, str | None
return _FIELD_META.get(key, (None, None, None, None))
# HA frontend display precision per field. Drives `suggested_display_precision`
# in the discovery config so the UI shows e.g. "3.285 V" instead of "3 V".
_FIELD_PRECISION: dict[str, int] = {
"pack_voltage": 2,
"pack_current": 2,
"max_current_limit": 2,
"soc": 0,
"soh": 0,
"cycle_count": 0,
"cell_voltage_min": 3,
"cell_voltage_max": 3,
"cell_voltage_delta_mv": 0,
"cell_lowest": 0,
"cell_highest": 0,
"cell_count": 0,
"capacity_ah": 1,
"remaining_ah": 1,
"error_code": 0,
"battery_mode": 0,
}
for _i in range(1, 17):
_FIELD_PRECISION[f"cell_{_i:02d}_voltage"] = 3
for _i in range(1, 7):
_FIELD_PRECISION[f"temperature_{_i}"] = 0
_FIELD_PRECISION["temperature_pcb"] = 0
def field_precision(key: str) -> int | None:
"""How many decimals HA's frontend should render. None = HA default."""
return _FIELD_PRECISION.get(key)
class MQTTPublisher:
def __init__(self, cfg: MQTTConfig, dry_run: bool = False):
self._cfg = cfg
@@ -765,6 +793,8 @@ class MQTTPublisher:
if device_class is not None: cfg["device_class"] = device_class
if state_class is not None: cfg["state_class"] = state_class
if icon is not None: cfg["icon"] = icon
precision = field_precision(key)
if precision is not None: cfg["suggested_display_precision"] = precision
topic = f"{self._cfg.discovery_prefix}/sensor/{pack_name}_{key}/config"
payload = json.dumps(cfg)
if self._dry_run:
@@ -920,7 +950,7 @@ def run_modbus_per_pack(cfg: AppConfig, publisher: MQTTPublisher,
if p.name not in pollers:
raise RuntimeError(f"no poller configured for {p.name}")
regs = pollers[p.name].poll()
readings = decode_eg4_modbus_regs(regs)
readings = decode_eg4_modbus_regs(regs, expose_raw=cfg.expose_raw_registers)
publisher.publish_pack(p.name, readings)
st.response_count += 1
if not st.ok and st.consecutive_errors > 0:

View File

@@ -0,0 +1,83 @@
#!/home/noise/.local/share/uv/tools/powermon/bin/python
"""eg4-purge-orphans — remove deprecated HA-discovery entities from MQTT.
After we drop fields from the daemon's published set (raw register_NN, the
superseded bms_version_hi/lo u16s, the noisy uptime_ds counter, etc.), the
old discovery configs remain RETAINED in the broker — so HA keeps the
orphaned entities forever.
This tool publishes an empty payload (with retain=true) to each orphaned
config topic, which tells HA "forget this entity" and clears the broker's
retained slot.
Idempotent + safe to re-run. Doesn't touch live entities (those get
re-published by the daemon every cycle).
Usage:
eg4-purge-orphans <broker> <user> <password> [--dry-run]
"""
from __future__ import annotations
import sys
import argparse
import paho.mqtt.client as mqtt
PACK_NAMES = ["lifepower4_1", "lifepower4_2", "lifepower4_3"]
# Build the topic list to purge.
DEPRECATED_KEYS: list[str] = []
for n in range(0, 136):
DEPRECATED_KEYS.append(f"register_{n:02d}") # 136 raw register entities
DEPRECATED_KEYS.append("bms_version_hi") # superseded by firmware_version
DEPRECATED_KEYS.append("bms_version_lo") # superseded by firmware_version
DEPRECATED_KEYS.append("uptime_ds") # noisy counter
PREFIX = "homeassistant/sensor"
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("host")
ap.add_argument("user")
ap.add_argument("password")
ap.add_argument("--dry-run", action="store_true",
help="print topics that would be purged, don't publish")
ap.add_argument("--port", type=int, default=1883)
args = ap.parse_args()
topics: list[str] = []
for pack in PACK_NAMES:
for key in DEPRECATED_KEYS:
topics.append(f"{PREFIX}/{pack}_{key}/config")
print(f"will purge {len(topics)} topic(s) "
f"({len(DEPRECATED_KEYS)} keys × {len(PACK_NAMES)} packs)")
if args.dry_run:
for t in topics[:5]:
print(f" (dry-run) {t}")
print(f" ... and {len(topics) - 5} more")
return 0
# Use legacy constructor — works on both paho-mqtt 1.x and 2.x
try:
c = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="eg4-purge")
except AttributeError:
c = mqtt.Client(client_id="eg4-purge")
c.username_pw_set(args.user, args.password)
c.connect(args.host, args.port, keepalive=30)
c.loop_start()
try:
for i, t in enumerate(topics):
info = c.publish(t, payload="", qos=0, retain=True)
info.wait_for_publish(2)
if (i + 1) % 50 == 0:
print(f" ...{i + 1}/{len(topics)}")
print(f"done — {len(topics)} retained configs cleared")
finally:
c.loop_stop()
c.disconnect()
return 0
if __name__ == "__main__":
sys.exit(main())