Cleaned up inverter mqtt
This commit is contained in:
110
LVX6048/2026-04-26.md
Normal file
110
LVX6048/2026-04-26.md
Normal 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.
|
||||
```
|
||||
@@ -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
|
||||
|
||||
@@ -11,9 +11,9 @@ device:
|
||||
protocol: PI18
|
||||
|
||||
mqttbroker:
|
||||
name: <MQTT_BROKER_IP> # e.g. 10.0.0.41 (HA Mosquitto broker)
|
||||
name: 10.0.0.41
|
||||
port: 1883
|
||||
username: <MQTT_USER>
|
||||
username: mqtt
|
||||
password: <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:
|
||||
|
||||
@@ -11,9 +11,9 @@ device:
|
||||
protocol: PI18
|
||||
|
||||
mqttbroker:
|
||||
name: <MQTT_BROKER_IP> # e.g. 10.0.0.41 (HA Mosquitto broker)
|
||||
name: 10.0.0.41
|
||||
port: 1883
|
||||
username: <MQTT_USER>
|
||||
username: mqtt
|
||||
password: <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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
164
LVX6048/powermon-patches/hass.py
Normal file
164
LVX6048/powermon-patches/hass.py
Normal 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
|
||||
@@ -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",
|
||||
|
||||
79
LVX6048/tmp/lvx-purge-orphans
Executable file
79
LVX6048/tmp/lvx-purge-orphans
Executable 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())
|
||||
Reference in New Issue
Block a user