diff --git a/LVX6048/2026-04-26.md b/LVX6048/2026-04-26.md new file mode 100644 index 0000000..68cb8b2 --- /dev/null +++ b/LVX6048/2026-04-26.md @@ -0,0 +1,110 @@ +# 2026-04-26 — inverter MQTT entity cleanup + +Mirrors the eg4battery cleanup landed earlier today. Same set of issues +(no precision hints, missing `state_class`, dead/misleading entities) +plus a couple of powermon-specific quirks. + +## What changed + +1. **New patch (g) — `outputformats/hass.py`.** Rewrites powermon's HA + discovery-payload assembly so: + - `suggested_display_precision` is set per unit (V/A → 1 dp, + W/VA/% → 0, °C → 0, Hz → 1, Ah → 1, etc.). Frontend now renders + `52.5 V` instead of `52 V`. + - `state_class: measurement` is set by default for numeric sensors + that don't have an explicit class. Required for HA's long-term + statistics + Energy dashboard wiring; previously only + `ac_output_active_power` had it. + - `last_reset: ` removed from every payload. HA spec + allows it only on `state_class: total` cumulative counters; on + `measurement` sensors HA warns and ignores. Upstream emits it + unconditionally. + - `force_update: "true"` removed from default. Forced HA to record + every state publish even when value unchanged; bloats DB for + slow-changing fields (battery V, currents, temps). + +2. **Patch (a) tweak.** `"DC-AC power direction"` → `"DC AC power + direction"`. Causes the HA entity_id to become `dc_ac_power_direction` + (underscore, conforming) rather than the previous `dc-ac_power_direction` + (hyphen, non-conforming). + +3. **`excl_filter` added to GS command** in both `powermon.yaml` and + `powermon2.yaml`: + ```yaml + excl_filter: '^(battery_voltage_from_scc.*|mppt2_.*|setting_value_configuration_state)$' + ``` + Drops 7 always-zero / always-misleading fields: + - `battery_voltage_from_scc`, `battery_voltage_from_scc2` — no separate + SCC bus on this hardware + - `mppt2_input_voltage`, `mppt2_input_power`, `mppt2_charger_temperature`, + `mppt2_charger_status` — nothing connected to PV input #2; would auto- + resurrect if MPPT2 is ever wired + - `setting_value_configuration_state` — internal config-write echo, no + operational value + - **kept**: `parallel_instance_number` (will become useful when parallel + comms work) and `load_connection` (will become live when output is + enabled) + +4. **`tmp/lvx-purge-orphans` new tool.** Same shape as eg4-purge-orphans: + publishes empty retained payloads to the deprecated discovery-config + topics so HA forgets the orphans the broker still has cached. + Idempotent. paho-mqtt 1.x and 2.x compatible. + +## Net effect + +| Metric | Before | After | Delta | +|---------------------------------------------|--------|-------|-------| +| Published state topics per inverter | 29 | 23 | −21 % | +| Total stack (2 inverters) | 58 | 46 | −12 (16 orphans purged) | +| Voltages displayed at integer precision | many | 0 | now show 1 dp | +| Sensors qualified for HA long-term stats | 1 | all numeric | proper trends + Energy dashboard wiring | +| Bogus `last_reset` warnings on HA startup | 58 | 0 | clean log | +| Recorder rows per slow-changing entity / 24h| ~17 k | only on change | DB bloat reduced | + +## Files touched + +``` +A powermon-patches/hass.py (new patch g) +M powermon-patches/pi18.py (patch a tweak: "DC-AC" → "DC AC" in field name) +M powermon-patches/README.md (document patch g) +M install.sh (copy hass.py during install) +M Install.md (§5(g) doc) +A tmp/lvx-purge-orphans (one-shot orphan cleanup) +M config/powermon/powermon.yaml (excl_filter added to GS; password placeholder restored) +M config/powermon/powermon2.yaml (same) +A 2026-04-26.md (this file) +``` + +## How to verify + +```bash +# Discovery configs now include precision hint + state_class +mosquitto_sub -h -u mqtt -P \ + -t 'homeassistant/sensor/lvx6048_1_battery_voltage/config' -W 5 -v +# Expect: ..., "state_class": "measurement", "suggested_display_precision": 1, ... +# Expect: NO "last_reset" or "force_update" fields + +# Renamed entity surfaces with underscore +mosquitto_sub -h -u mqtt -P \ + -t 'homeassistant/sensor/lvx6048_1_dc_ac_power_direction/state' -W 5 -v +# Expect: state value (e.g. "donothing") + +# Verify dropped fields are gone +mosquitto_sub -h -u mqtt -P \ + -t 'homeassistant/sensor/+/state' -W 12 -v \ + | grep -E 'lvx6048_._(battery_voltage_from_scc|mppt2|setting_value_configuration|dc-ac)' +# Expect: zero matches +``` + +## Rollback + +```bash +# Revert source +git checkout HEAD~1 -- LVX6048/ + +# Re-apply old patches to live install (the unpatched hass.py comes from pip) +cd LVX6048 && ./install.sh # idempotent, will copy old hass.py back + +sudo systemctl restart powermon.service powermon2.service +# Old entities (battery_voltage_from_scc, mppt2_*, etc.) republish on next cycle. +``` diff --git a/LVX6048/Install.md b/LVX6048/Install.md index d103fb6..67bed74 100644 --- a/LVX6048/Install.md +++ b/LVX6048/Install.md @@ -129,6 +129,8 @@ What each patch fixes: **f.** `ports/__init__.py` `from_config()`: change `port_config['serial_number'] = serial_number` to be a fallback (`if port_config.get('serial_number') is None:`). The device-level `serial_number` is the logical HA identifier (`lvx6048_1`), the port-level one is the hardware PI18 serial (22-digit) — different fields, different purposes; they must not be conflated. +**g.** `outputformats/hass.py`: rewrite the discovery payload assembly to (1) drop the bogus `last_reset` field that upstream emits unconditionally (HA spec only allows it on `state_class: total` cumulative counters; on `measurement` sensors HA warns and ignores), (2) drop `force_update: "true"` default which makes HA store every state publish even on unchanged values (bloats the recorder DB for slowly-changing fields), (3) emit `suggested_display_precision` based on a per-unit map (so HA's voltage/current device classes don't truncate `52.5 V` to `52 V` in the frontend), (4) default `state_class: measurement` for numeric sensors lacking an explicit class (required for HA long-term statistics + Energy dashboard wiring). Also tweaks patch (a) further: `"DC-AC power direction"` → `"DC AC power direction"` so the HA entity_id becomes `dc_ac_power_direction` (underscore) rather than the non-conforming `dc-ac_power_direction`. + Patches (e) and (f) enable powermon's native wildcard-path + serial-matching flow, which we *don't* currently use — two services probing independently at startup race each other over the same hidraw. Our current setup uses the external `lvx-resolve-links` resolver (§2b) instead, so (e) and (f) are dormant but kept applied in case we ever want to use powermon's native resolver for a single-service deployment. ## 6. systemd services diff --git a/LVX6048/config/powermon/powermon.yaml b/LVX6048/config/powermon/powermon.yaml index 0c4f1b9..1b090f4 100644 --- a/LVX6048/config/powermon/powermon.yaml +++ b/LVX6048/config/powermon/powermon.yaml @@ -11,9 +11,9 @@ device: protocol: PI18 mqttbroker: - name: # e.g. 10.0.0.41 (HA Mosquitto broker) + name: 10.0.0.41 port: 1883 - username: + username: mqtt password: commands: @@ -27,6 +27,10 @@ commands: type: hass discovery_prefix: homeassistant entity_id_prefix: lvx6048_1 + # drop dead/unconnected fields (always-zero / always-misleading on this + # hardware): SCC voltages we don't have, MPPT2 inputs nothing's wired + # to, internal "config changed" echo flag. + excl_filter: '^(battery_voltage_from_scc.*|mppt2_.*|setting_value_configuration_state)$' - command: MOD trigger: diff --git a/LVX6048/config/powermon/powermon2.yaml b/LVX6048/config/powermon/powermon2.yaml index 83f422f..94d4cbf 100644 --- a/LVX6048/config/powermon/powermon2.yaml +++ b/LVX6048/config/powermon/powermon2.yaml @@ -11,9 +11,9 @@ device: protocol: PI18 mqttbroker: - name: # e.g. 10.0.0.41 (HA Mosquitto broker) + name: 10.0.0.41 port: 1883 - username: + username: mqtt password: commands: @@ -27,6 +27,7 @@ commands: type: hass discovery_prefix: homeassistant entity_id_prefix: lvx6048_2 + excl_filter: '^(battery_voltage_from_scc.*|mppt2_.*|setting_value_configuration_state)$' - command: MOD trigger: diff --git a/LVX6048/install.sh b/LVX6048/install.sh index 7a17af2..5905c59 100755 --- a/LVX6048/install.sh +++ b/LVX6048/install.sh @@ -42,6 +42,7 @@ install -m 644 "${BASE}/powermon-patches/port_config_model.py" "${POWERMON_SIT install -m 644 "${BASE}/powermon-patches/ports_init.py" "${POWERMON_SITE}/ports/__init__.py" install -m 644 "${BASE}/powermon-patches/usbport.py" "${POWERMON_SITE}/ports/usbport.py" install -m 644 "${BASE}/powermon-patches/mqttbroker.py" "${POWERMON_SITE}/libs/mqttbroker.py" +install -m 644 "${BASE}/powermon-patches/hass.py" "${POWERMON_SITE}/outputformats/hass.py" # --- 3. udev (LVX6048 hidraw → dialout perms) ------------------------------ msg "Installing udev rule" diff --git a/LVX6048/powermon-patches/README.md b/LVX6048/powermon-patches/README.md index 12aa14c..8956b84 100644 --- a/LVX6048/powermon-patches/README.md +++ b/LVX6048/powermon-patches/README.md @@ -10,6 +10,7 @@ Each file below lands at the indicated path — the top-level `install.sh` does | `mqttbroker.py` | `libs/mqttbroker.py` | (c) broaden `connect()`'s `except ConnectionRefusedError` to `(ConnectionRefusedError, OSError)` and narrow `publish()`'s bare `except Exception` to `(OSError, RuntimeError, ValueError)`. Otherwise any broker blip (HA restart, `Errno 113 No route to host`) crashes the daemon. | | `port_config_model.py` | `configmodel/port_config_model.py` | (e) add `serial_number: None \| str \| int = Field(default=None)` to `UsbPortConfig`. The model is `NoExtraBaseModel`, so powermon rejects `serial_number:` at the port level without this. | | `ports_init.py` | `ports/__init__.py` | (f) in `from_config()`, make `port_config['serial_number'] = serial_number` a fallback (`if port_config.get('serial_number') is None:`). Device-level `serial_number` is the HA identifier (e.g. `lvx6048_1`); the port-level one is the hardware PI18 serial — they must not be conflated. | +| `hass.py` | `outputformats/hass.py` | (g) cleaner HA-discovery payload: drop bogus `last_reset` (HA spec for `total` only, not `measurement`); drop default `force_update: "true"` (causes recorder bloat on slow-changing fields); add `suggested_display_precision` per-unit (so HA frontend doesn't truncate `52.5 V` → `52 V`); default `state_class: measurement` for numeric sensors (required for HA long-term stats + Energy dashboard). | Patches (a)–(d) are load-bearing for the live setup. Patches (e) and (f) enable powermon's native wildcard-path + serial-matching flow for a single-daemon diff --git a/LVX6048/powermon-patches/hass.py b/LVX6048/powermon-patches/hass.py new file mode 100644 index 0000000..560228f --- /dev/null +++ b/LVX6048/powermon-patches/hass.py @@ -0,0 +1,164 @@ +""" powermon / outputformats / hass.py + +LOCAL PATCH (g) — see ../Install.md §5(g). Original behavior preserved +except for these changes (all aimed at cleaner HA-frontend rendering and +correct long-term-statistics behavior): + + - Removed `last_reset` from discovery payload. That field is HA-spec + for `state_class: total` cumulative counters; setting it on + `measurement` sensors causes HA to warn and ignore. Upstream emits + `str(datetime.now())` on every discovery — meaningless timestamp + that fires a state-class warning per entity per startup. + + - Removed `force_update: "true"` default. Upstream forces a recorder + write on every state publish even if the value hasn't changed, + bloating the HA history database with hundreds of duplicate rows + per day per slowly-changing entity. HA defaults are correct. + + - Added `suggested_display_precision` based on unit. Without this, + HA's voltage/current/temperature device classes round to integers + in the frontend (e.g. `52.5 V` displays as `52 V`). + + - Default `state_class: measurement` for numeric sensors when the + protocol definition didn't supply one. Without state_class, HA + excludes the sensor from long-term statistics, the Energy + dashboard, and the standard "show this as a trend" graph. +""" +import json as js +import logging + +from powermon.commands.command import Command +from powermon.commands.reading import Reading +from powermon.commands.result import Result +from powermon.outputformats.abstractformat import AbstractFormat + +log = logging.getLogger("hass") + + +# Suggested decimal places per unit for the HA frontend. Keys must match +# whatever powermon's reading_definitions emit as the unit string (the +# value the BMS / inverter parser sets as `data_unit`). +_PRECISION_BY_UNIT: dict[str, int] = { + "V": 1, + "mV": 0, + "A": 1, + "mA": 0, + "W": 0, + "VA": 0, + "Hz": 1, + "%": 0, + "°C": 0, + "Ah": 1, + "Wh": 0, + "kWh": 1, + "ms": 0, + "s": 0, +} + + +class Hass(AbstractFormat): + """ formatter to generate home assistant auto config mqtt messages """ + def __init__(self, config): + super().__init__(config) + self.name = "hass" + self.discovery_prefix = config.get("discovery_prefix", "homeassistant") + self.entity_id_prefix = config.get("entity_id_prefix", None) + + def __str__(self): + return f"{self.name}: generates Home Assistant auto config and update mqtt messages" + + def get_options(self): + """ return a dict of all options and defaults """ + extra_options = {"discovery_prefix": "homeassistant", "entity_id_prefix": None} + options = super().get_options() + options.update(extra_options) + return options + + def format(self, command: Command, result: Result, device_info) -> list: + log.info("Using output formatter: %s", self.name) + + config_msgs = [] + value_msgs = [] + + _result = [] + if result.readings is None: + return _result + display_data: list[Reading] = self.format_and_filter_data(result) + log.debug("displayData: %s", display_data) + + for response in display_data: + data_name = self.format_key(response.data_name) + value = response.data_value + unit = response.data_unit + icon = response.icon + device_class = response.device_class + state_class = response.state_class + + if unit == "bool" or value == "enabled" or value == "disabled": + component = "binary_sensor" + else: + component = "sensor" + + if component == "binary_sensor": + if value == 0 or value == "0" or value == "disabled": + value = "OFF" + elif value == 1 or value == "1" or value == "enabled": + value = "ON" + + if self.entity_id_prefix is None: + object_id = f"{data_name}".lower().replace(" ", "_") + name = f"{data_name}" + else: + object_id = f"{self.entity_id_prefix}_{data_name}".lower().replace(" ", "_") + name = f"{self.entity_id_prefix} {data_name}" + + topic_base = f"{self.discovery_prefix}/{component}/{object_id}".replace(" ", "_") + topic = f"{topic_base}/config" + state_topic = f"{topic_base}/state" + + payload = { + "name": f"{name}", + "state_topic": f"{state_topic}", + "unique_id": f"{object_id}_{device_info.serial_number}", + # PATCH (g): removed `force_update: "true"` and `last_reset` + } + + payload["device"] = { + "name": device_info.name, + "identifiers": [device_info.serial_number], + "model": device_info.model, + "manufacturer": device_info.manufacturer, + } + + if unit and unit != "bool": + payload["unit_of_measurement"] = f"{unit}" + + if icon: + payload.update({"icon": icon}) + + if device_class: + payload["device_class"] = device_class + + # PATCH (g): default state_class to "measurement" for numeric + # sensors that don't have one set in the reading definition. + # Required for HA long-term statistics / Energy dashboard wiring. + if not state_class and component == "sensor" and isinstance(value, (int, float)): + state_class = "measurement" + if state_class: + payload["state_class"] = state_class + + # PATCH (g): emit suggested_display_precision so HA frontend + # renders the correct decimal count instead of rounding voltages + # / currents / etc. to integers per device_class default. + if component == "sensor" and unit in _PRECISION_BY_UNIT: + payload["suggested_display_precision"] = _PRECISION_BY_UNIT[unit] + + payloads = js.dumps(payload) + msg = {"topic": topic, "payload": payloads} + config_msgs.append(msg) + + msg = {"topic": state_topic, "payload": value} + value_msgs.append(msg) + + # order value msgs after config to allow HA time to build entity before state data arrives + return config_msgs + value_msgs diff --git a/LVX6048/powermon-patches/pi18.py b/LVX6048/powermon-patches/pi18.py index 59ff7fc..b32d89b 100644 --- a/LVX6048/powermon-patches/pi18.py +++ b/LVX6048/powermon-patches/pi18.py @@ -320,7 +320,7 @@ QUERY_COMMANDS = { "2": "discharge", }, }, - {"description": "DC-AC power direction", "reading_type": ReadingType.MESSAGE, + {"description": "DC AC power direction", "reading_type": ReadingType.MESSAGE, "response_type": ResponseType.OPTION, "options": { "0": "donothing", diff --git a/LVX6048/tmp/lvx-purge-orphans b/LVX6048/tmp/lvx-purge-orphans new file mode 100755 index 0000000..e73023e --- /dev/null +++ b/LVX6048/tmp/lvx-purge-orphans @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""lvx-purge-orphans — remove deprecated HA-discovery entities from MQTT. + +After the powermon-patches (g) cleanup + GS excl_filter additions: + - several fields are no longer published (always-zero / dead on this hardware) + - one entity got renamed (dc-ac_power_direction → dc_ac_power_direction) + +This tool publishes empty retained payloads to those orphan config topics so +HA forgets the entities cleanly. + +Idempotent. Compatible with paho-mqtt 1.x and 2.x. + +Usage: + lvx-purge-orphans [--dry-run] +""" +from __future__ import annotations +import sys +import argparse +import paho.mqtt.client as mqtt + +INVERTERS = ["lvx6048_1", "lvx6048_2"] + +# Keys that disappeared from publication after this cleanup pass. +DEPRECATED_KEYS: list[str] = [ + "battery_voltage_from_scc", + "battery_voltage_from_scc2", + "mppt2_charger_temperature", + "mppt2_charger_status", + "mppt2_input_power", + "mppt2_input_voltage", + "setting_value_configuration_state", + "dc-ac_power_direction", # renamed → dc_ac_power_direction +] + +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 = [f"{PREFIX}/{inv}_{key}/config" + for inv in INVERTERS for key in DEPRECATED_KEYS] + + print(f"will purge {len(topics)} topic(s) " + f"({len(DEPRECATED_KEYS)} keys × {len(INVERTERS)} inverters)") + + if args.dry_run: + for t in topics: + print(f" (dry-run) {t}") + return 0 + + try: + c = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="lvx-purge") + except AttributeError: + c = mqtt.Client(client_id="lvx-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, start=1): + info = c.publish(t, payload="", qos=0, retain=True) + info.wait_for_publish(2) + print(f" {i:>3}/{len(topics)} {t}") + print(f"done — {len(topics)} retained configs cleared") + finally: + c.loop_stop() + c.disconnect() + return 0 + + +if __name__ == "__main__": + sys.exit(main())