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
|
mqtt: MQTTConfig
|
||||||
packs: list[PackConfig]
|
packs: list[PackConfig]
|
||||||
cell_count: int = 16 # active mode only
|
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:
|
def load_config(path: Path) -> AppConfig:
|
||||||
@@ -121,6 +122,7 @@ def load_config(path: Path) -> AppConfig:
|
|||||||
mqtt=MQTTConfig(**mqtt_raw),
|
mqtt=MQTTConfig(**mqtt_raw),
|
||||||
packs=[PackConfig(**p) for p in raw["packs"]],
|
packs=[PackConfig(**p) for p in raw["packs"]],
|
||||||
cell_count=raw.get("cell_count", 16),
|
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
|
return v - 0x10000 if v & 0x8000 else v
|
||||||
|
|
||||||
|
|
||||||
def decode_eg4_modbus_regs(regs: list[int]) -> dict[str, Any]:
|
def decode_eg4_modbus_regs(regs: list[int], expose_raw: bool = False) -> dict[str, Any]:
|
||||||
"""Decode the 47-reg read-holding-regs response from an LP4V2 BMS.
|
"""Decode the read-holding-regs response from an LP4V2 BMS.
|
||||||
Emits named HA entities where meaning is known; raw register_NN
|
Emits named HA entities. If `expose_raw` is True, also emits
|
||||||
passthrough for the rest."""
|
`register_NN` entities for every position — useful when refining
|
||||||
|
the register map; defaults to off to keep HA Devices uncluttered."""
|
||||||
out: dict[str, Any] = {}
|
out: dict[str, Any] = {}
|
||||||
# always emit raw registers — invaluable for future refinement
|
if expose_raw:
|
||||||
for i, v in enumerate(regs):
|
for i, v in enumerate(regs):
|
||||||
out[f"register_{i:02d}"] = v
|
out[f"register_{i:02d}"] = v
|
||||||
|
|
||||||
if len(regs) < 47:
|
if len(regs) < 47:
|
||||||
return out
|
return out
|
||||||
@@ -365,13 +368,9 @@ def decode_eg4_modbus_regs(regs: list[int]) -> dict[str, Any]:
|
|||||||
out["cycle_count"] = regs[39]
|
out["cycle_count"] = regs[39]
|
||||||
out["battery_mode"] = regs[40]
|
out["battery_mode"] = regs[40]
|
||||||
|
|
||||||
# BMS firmware version — regs 41 & 42 appear to hold version codes; emit
|
# regs 41-42: u16 version codes — superseded by `firmware_version` ASCII
|
||||||
# the raw u16s alongside a decimal representation for easier HA display
|
# decode below; available via expose_raw if needed
|
||||||
out["bms_version_hi"] = regs[41]
|
# reg 46: ~1.25 Hz uptime counter — noisy, available via expose_raw if needed
|
||||||
out["bms_version_lo"] = regs[42]
|
|
||||||
|
|
||||||
# reg 46 increments ~1.25 Hz on live bus — likely uptime in deciseconds
|
|
||||||
out["uptime_ds"] = regs[46]
|
|
||||||
|
|
||||||
# --- block-2 strings (regs 105..123) — fetched on the second Modbus read ---
|
# --- block-2 strings (regs 105..123) — fetched on the second Modbus read ---
|
||||||
if len(regs) >= 124:
|
if len(regs) >= 124:
|
||||||
@@ -698,9 +697,6 @@ _FIELD_META.update({
|
|||||||
"cell_count": (None, None, "measurement", "mdi:numeric"),
|
"cell_count": (None, None, "measurement", "mdi:numeric"),
|
||||||
"remaining_ah": ("Ah", None, "measurement", "mdi:battery-clock"),
|
"remaining_ah": ("Ah", None, "measurement", "mdi:battery-clock"),
|
||||||
"battery_mode": (None, None, None, "mdi:state-machine"),
|
"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"),
|
"model": (None, None, None, "mdi:battery-outline"),
|
||||||
"firmware_version": (None, None, None, "mdi:chip"),
|
"firmware_version": (None, None, None, "mdi:chip"),
|
||||||
"firmware_date": (None, None, None, "mdi:calendar"),
|
"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))
|
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:
|
class MQTTPublisher:
|
||||||
def __init__(self, cfg: MQTTConfig, dry_run: bool = False):
|
def __init__(self, cfg: MQTTConfig, dry_run: bool = False):
|
||||||
self._cfg = cfg
|
self._cfg = cfg
|
||||||
@@ -765,6 +793,8 @@ class MQTTPublisher:
|
|||||||
if device_class is not None: cfg["device_class"] = device_class
|
if device_class is not None: cfg["device_class"] = device_class
|
||||||
if state_class is not None: cfg["state_class"] = state_class
|
if state_class is not None: cfg["state_class"] = state_class
|
||||||
if icon is not None: cfg["icon"] = icon
|
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"
|
topic = f"{self._cfg.discovery_prefix}/sensor/{pack_name}_{key}/config"
|
||||||
payload = json.dumps(cfg)
|
payload = json.dumps(cfg)
|
||||||
if self._dry_run:
|
if self._dry_run:
|
||||||
@@ -920,7 +950,7 @@ def run_modbus_per_pack(cfg: AppConfig, publisher: MQTTPublisher,
|
|||||||
if p.name not in pollers:
|
if p.name not in pollers:
|
||||||
raise RuntimeError(f"no poller configured for {p.name}")
|
raise RuntimeError(f"no poller configured for {p.name}")
|
||||||
regs = pollers[p.name].poll()
|
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)
|
publisher.publish_pack(p.name, readings)
|
||||||
st.response_count += 1
|
st.response_count += 1
|
||||||
if not st.ok and st.consecutive_errors > 0:
|
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