Cleaned up inverter mqtt

This commit is contained in:
2026-04-26 08:49:05 -04:00
parent b526b10cf5
commit f771ec2b46
9 changed files with 367 additions and 5 deletions

110
LVX6048/2026-04-26.md Normal file
View File

@@ -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: <datetime>` 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 <broker> -u mqtt -P <pass> \
-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 <broker> -u mqtt -P <pass> \
-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 <broker> -u mqtt -P <pass> \
-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.
```

View File

@@ -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. **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. 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 ## 6. systemd services

View File

@@ -11,9 +11,9 @@ device:
protocol: PI18 protocol: PI18
mqttbroker: mqttbroker:
name: <MQTT_BROKER_IP> # e.g. 10.0.0.41 (HA Mosquitto broker) name: 10.0.0.41
port: 1883 port: 1883
username: <MQTT_USER> username: mqtt
password: <MQTT_PASSWORD> password: <MQTT_PASSWORD>
commands: commands:
@@ -27,6 +27,10 @@ commands:
type: hass type: hass
discovery_prefix: homeassistant discovery_prefix: homeassistant
entity_id_prefix: lvx6048_1 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 - command: MOD
trigger: trigger:

View File

@@ -11,9 +11,9 @@ device:
protocol: PI18 protocol: PI18
mqttbroker: mqttbroker:
name: <MQTT_BROKER_IP> # e.g. 10.0.0.41 (HA Mosquitto broker) name: 10.0.0.41
port: 1883 port: 1883
username: <MQTT_USER> username: mqtt
password: <MQTT_PASSWORD> password: <MQTT_PASSWORD>
commands: commands:
@@ -27,6 +27,7 @@ commands:
type: hass type: hass
discovery_prefix: homeassistant discovery_prefix: homeassistant
entity_id_prefix: lvx6048_2 entity_id_prefix: lvx6048_2
excl_filter: '^(battery_voltage_from_scc.*|mppt2_.*|setting_value_configuration_state)$'
- command: MOD - command: MOD
trigger: trigger:

View File

@@ -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/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/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/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) ------------------------------ # --- 3. udev (LVX6048 hidraw → dialout perms) ------------------------------
msg "Installing udev rule" msg "Installing udev rule"

View File

@@ -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. | | `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. | | `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. | | `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 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 powermon's native wildcard-path + serial-matching flow for a single-daemon

View File

@@ -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

View File

@@ -320,7 +320,7 @@ QUERY_COMMANDS = {
"2": "discharge", "2": "discharge",
}, },
}, },
{"description": "DC-AC power direction", "reading_type": ReadingType.MESSAGE, {"description": "DC AC power direction", "reading_type": ReadingType.MESSAGE,
"response_type": ResponseType.OPTION, "response_type": ResponseType.OPTION,
"options": { "options": {
"0": "donothing", "0": "donothing",

79
LVX6048/tmp/lvx-purge-orphans Executable file
View File

@@ -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 <broker> <user> <password> [--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())