Cleaned up battery mqtt topics
This commit is contained in:
87
eg4battery/2026-04-26.md
Normal file
87
eg4battery/2026-04-26.md
Normal 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.)
|
||||
```
|
||||
@@ -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,14 +306,15 @@ 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
|
||||
for i, v in enumerate(regs):
|
||||
out[f"register_{i:02d}"] = v
|
||||
if expose_raw:
|
||||
for i, v in enumerate(regs):
|
||||
out[f"register_{i:02d}"] = v
|
||||
|
||||
if len(regs) < 47:
|
||||
return out
|
||||
@@ -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:
|
||||
|
||||
83
eg4battery/tmp/eg4-purge-orphans
Executable file
83
eg4battery/tmp/eg4-purge-orphans
Executable 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())
|
||||
Reference in New Issue
Block a user