diff --git a/eg4battery/2026-04-26.md b/eg4battery/2026-04-26.md new file mode 100644 index 0000000..1bf1572 --- /dev/null +++ b/eg4battery/2026-04-26.md @@ -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 + ``` + +## 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 -u mqtt -P \ + -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 -u mqtt -P \ + -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 -u mqtt -P \ + -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.) +``` diff --git a/eg4battery/bin/eg4-battery b/eg4battery/bin/eg4-battery index aa88989..766614e 100755 --- a/eg4battery/bin/eg4-battery +++ b/eg4battery/bin/eg4-battery @@ -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: diff --git a/eg4battery/tmp/eg4-purge-orphans b/eg4battery/tmp/eg4-purge-orphans new file mode 100755 index 0000000..3b09016 --- /dev/null +++ b/eg4battery/tmp/eg4-purge-orphans @@ -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 [--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())