lvx, eg4, and evse

This commit is contained in:
2026-05-01 16:56:37 -04:00
parent 7688fc1dd3
commit e1b180e230
4 changed files with 492 additions and 2 deletions

View File

@@ -6,9 +6,14 @@ Description=Periodic re-resolve of LVX6048 hidraw symlinks (transient-failure ba
# idempotent — runs that don't change anything are no-ops. # idempotent — runs that don't change anything are no-ops.
[Timer] [Timer]
OnBootSec=2min # Wall-clock schedule: fire every 5 minutes regardless of resolver service
OnUnitActiveSec=5min # state. We previously used OnBootSec= + OnUnitActiveSec= which interacted
# badly with the resolver's RemainAfterExit=yes — once the unit was active,
# systemd considered the trigger condition met and stopped re-scheduling
# (observed on 2026-04-29: only 3 runs over 30 min, then nothing).
OnCalendar=*:0/5
AccuracySec=15s AccuracySec=15s
Persistent=true
Unit=lvx-resolve-links.service Unit=lvx-resolve-links.service
[Install] [Install]

83
openevse/Install.md Normal file
View File

@@ -0,0 +1,83 @@
# Install
Single one-shot. No daemon, no systemd unit, no service to keep running.
## Prerequisites
- OpenEVSE WiFi firmware already configured to publish to the broker
(`mqtt_enabled:true`, `mqtt_connected:1` in `/status` JSON).
- Broker reachable from the machine you're running this on.
- `uv` installed and on `$PATH` (the script is a PEP-723 inline-deps
script — `uv` resolves `paho-mqtt` and `pyyaml` on first run).
## 1. Verify OpenEVSE is publishing
```bash
mosquitto_sub -h <BROKER> -u <USER> -P <PASS> -t 'openevse/#' -W 5 -v
```
Expect a steady stream of `openevse/{power,voltage,amp,...}` plus retained
`openevse/announce/<id>`, `openevse/config`, etc. If nothing arrives, the
EVSE isn't talking to the broker — fix that first via the OpenEVSE web UI
at `http://<EVSE>/`.
## 2. Preview the discovery payloads (optional)
```bash
~/solar/openevse/bin/openevse-publish-discovery --dry-run --device-id a048
```
Prints the 23 topics + JSON payloads it would publish without connecting.
## 3. Publish
Broker creds default-load from `~/.config/powermon/powermon.yaml`:
```bash
~/solar/openevse/bin/openevse-publish-discovery
```
The tool reads the retained `openevse/announce/<id>` payload to discover
the device id automatically. Override broker / device id if needed:
```bash
~/solar/openevse/bin/openevse-publish-discovery \
--host 10.0.0.41 --user mqtt --password '...' \
--device-id a048
```
Re-running is idempotent — the broker just overwrites the retained payloads
in place.
## 4. Verify
The 23 retained discovery configs:
```bash
mosquitto_sub -h <BROKER> -u <USER> -P <PASS> \
-t 'homeassistant/sensor/+/config' \
-t 'homeassistant/binary_sensor/+/config' \
-W 5 -F '[r=%r] %t' | grep openevse | sort -u
```
Expect 20 sensor + 3 binary_sensor entries, all `[r=1]`.
In HA: **Settings → Devices & Services → MQTT** should show "OpenEVSE-a048"
as a new device. If not, force a reload:
```bash
mosquitto_pub -h <BROKER> -u <USER> -P <PASS> \
-t 'homeassistant/status' -m 'online'
```
HA listens on this topic and re-scans retained discovery on receipt.
## Rollback
```bash
~/solar/openevse/bin/openevse-publish-discovery --purge
```
Publishes empty retained payloads to all 23 config topics; HA forgets the
entities on next reload. The OpenEVSE-side `openevse/<key>` topics are not
touched (they're not ours to clear).

101
openevse/README.md Normal file
View File

@@ -0,0 +1,101 @@
# OpenEVSE → Home Assistant (MQTT discovery)
The OpenEVSE WiFi firmware natively publishes raw values to `openevse/<key>`
on whichever MQTT broker you've configured it against, but it does **not**
publish HA discovery configs. Without those, HA can't see any of it unless
you hand-write `mqtt: sensor:` blocks in `configuration.yaml`.
This package fixes that with a single one-shot tool that publishes retained
`homeassistant/.../config` payloads pointing at the OpenEVSE state topics
the firmware already provides. No daemon, no broker proxy — HA subscribes
to OpenEVSE's own topics directly once discovery is in place.
## Status: live
23 entities exposed for the charger at `10.0.0.249` (device id `a048`):
```
sensor.openevse_power W live charging power
sensor.openevse_voltage V
sensor.openevse_amp A (firmware reports mA; converted in HA)
sensor.openevse_pilot A pilot current the EVSE is signalling
sensor.openevse_max_current A hardware/soft-limit ceiling
sensor.openevse_session_energy Wh
sensor.openevse_total_energy Wh total_increasing → Energy dashboard
sensor.openevse_total_day kWh total_increasing
sensor.openevse_total_week kWh
sensor.openevse_total_month kWh
sensor.openevse_total_year kWh
sensor.openevse_session_elapsed s
sensor.openevse_uptime s diagnostic
sensor.openevse_temp °C (firmware reports tenths-°C; converted)
sensor.openevse_temp_max °C
sensor.openevse_status text "active" / "disabled" / "sleeping" / ...
sensor.openevse_state # numeric state code
sensor.openevse_srssi dBm diagnostic — Wi-Fi signal
sensor.openevse_freeram B diagnostic
sensor.openevse_total_switches # diagnostic — total_increasing
binary_sensor.openevse_vehicle plug — car connected
binary_sensor.openevse_evse_connected connectivity (diagnostic)
binary_sensor.openevse_manual_override
```
Availability is wired to OpenEVSE's retained announce topic
(`openevse/announce/<id>`), which the firmware uses as its LWT — entities
flip to "Unavailable" if the EVSE drops off the broker.
## How it works
1. `bin/openevse-publish-discovery` connects to the broker, reads the
retained `openevse/announce/<id>` payload to learn the device id and
metadata (name, http URL).
2. Builds 23 `homeassistant/{sensor,binary_sensor}/openevse_<key>/config`
payloads — one per entity. Each declares `state_topic` pointing at the
matching `openevse/<key>` topic, plus `device_class`, `unit_of_measurement`,
`state_class`, `value_template` (where unit conversion is needed),
`suggested_display_precision`, and a shared `availability` block.
3. Publishes them with QoS 0, `retain=true`. HA picks them up immediately
via its existing `homeassistant/#` subscription.
The two unit conversions baked into discovery payloads (so HA never sees
raw units):
| Source topic | Raw value | HA value |
|------------------------|----------------|----------|
| `openevse/amp` | milliamps | A (`/ 1000`) |
| `openevse/temp[_max]` | tenths-°C | °C (`/ 10`) |
## What's in the box
```
openevse/
├── README.md ← you are here
├── Install.md ← run order, verification, rollback
└── bin/
└── openevse-publish-discovery ← one-shot (PEP-723 uv inline-script)
```
## Quick start
Broker creds default-load from `~/.config/powermon/powermon.yaml`
(`mqttbroker.{name,port,username,password}`), so on this host:
```bash
~/solar/openevse/bin/openevse-publish-discovery
```
That's it. To remove every entity later:
```bash
~/solar/openevse/bin/openevse-publish-discovery --purge
```
See [`Install.md`](./Install.md) for verification steps, dry-run preview,
and CLI overrides if your broker isn't the one in `powermon.yaml`.
## Related packages
- [`../LVX6048/`](../LVX6048/) — same broker, same discovery pattern (via
patched powermon).
- [`../eg4battery/`](../eg4battery/) — same broker, same discovery pattern
(via `eg4-battery` daemon).

View File

@@ -0,0 +1,301 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "paho-mqtt>=2.0",
# "pyyaml>=6.0",
# ]
# ///
"""openevse-publish-discovery — wire an already-running OpenEVSE charger into
Home Assistant via retained MQTT-discovery configs.
The OpenEVSE WiFi firmware publishes raw values to `openevse/<key>` (no JSON
wrapping, no HA discovery). HA can't see them unless either (a) someone adds
manual `mqtt: sensor:` blocks in `configuration.yaml` or (b) retained
`homeassistant/sensor/openevse_<key>/config` payloads exist on the broker.
This tool publishes the latter — a one-shot, idempotent.
After running, HA discovers the charger as a single device and exposes the
~22 entities below. Re-run any time to refresh; use --purge to remove all
of them (e.g. before uninstalling).
Discovery prefix and device id are read from the OpenEVSE announce topic
(`openevse/announce/<id>`, retained at connect-time) so we follow whatever
the firmware reports rather than hard-coding. Falls back to CLI flags if
no announce is on the broker (e.g. EVSE was offline when you ran this).
Defaults: broker host/user/pass loaded from
~/.config/powermon/powermon.yaml if present (since powermon already
needs them). Override on the CLI as needed.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
from pathlib import Path
import paho.mqtt.client as mqtt
import yaml
DEFAULT_PREFIX = "openevse"
DEFAULT_HA_PREFIX = "homeassistant"
DEFAULT_BROKER_CONFIG = Path.home() / ".config" / "powermon" / "powermon.yaml"
# (key, friendly name, unit, device_class, state_class, value_template, icon, suggested_display_precision)
#
# value_template is Jinja applied to the *string* the OpenEVSE publishes:
# - openevse/amp → milliamps (e.g. "17380") — convert to A
# - openevse/temp[_max] → tenths-°C (e.g. "208") — convert to °C
# Other fields are already in the unit HA expects.
SENSORS: list[tuple] = [
# --- live charging ----------------------------------------------------
("power", "Power", "W", "power", "measurement", None, "mdi:flash", 0),
("voltage", "Voltage", "V", "voltage", "measurement", None, "mdi:lightning-bolt", 0),
("amp", "Current", "A", "current", "measurement",
"{{ (value | float / 1000) | round(2) }}", "mdi:current-ac", 2),
("pilot", "Pilot Current", "A", "current", "measurement", None, "mdi:current-ac", 0),
("max_current", "Max Current", "A", "current", "measurement", None, "mdi:current-ac", 0),
# --- energy counters --------------------------------------------------
("session_energy","Session Energy", "Wh", "energy", "total_increasing", None, "mdi:battery-charging", 1),
("total_energy", "Total Energy", "Wh", "energy", "total_increasing", None, "mdi:counter", 1),
("total_day", "Energy Today", "kWh", "energy", "total_increasing", None, "mdi:counter", 3),
("total_week", "Energy This Week", "kWh", "energy", "total_increasing", None, "mdi:counter", 3),
("total_month", "Energy This Month","kWh", "energy", "total_increasing", None, "mdi:counter", 3),
("total_year", "Energy This Year", "kWh", "energy", "total_increasing", None, "mdi:counter", 3),
# --- time -------------------------------------------------------------
("session_elapsed","Session Elapsed", "s", "duration", "measurement", None, "mdi:timer-outline", 0),
("uptime", "Uptime", "s", "duration", "measurement", None, "mdi:clock-outline", 0),
# --- temperature ------------------------------------------------------
("temp", "Temperature", "°C", "temperature", "measurement",
"{{ (value | float / 10) | round(1) }}", "mdi:thermometer", 1),
("temp_max", "Max Temperature", "°C", "temperature", "measurement",
"{{ (value | float / 10) | round(1) }}", "mdi:thermometer-alert", 1),
# --- status -----------------------------------------------------------
("status", "Status", None, None, None, None, "mdi:ev-station", None),
("state", "State Code", None, None, "measurement", None, "mdi:counter", 0),
# --- diagnostics ------------------------------------------------------
("srssi", "Wi-Fi RSSI", "dBm", "signal_strength","measurement", None, "mdi:wifi", 0),
("freeram", "Free RAM", "B", "data_size", "measurement", None, "mdi:memory", 0),
("total_switches","Total Switches", None, None, "total_increasing",None, "mdi:counter", 0),
]
# Binary sensors: OpenEVSE publishes "0"/"1" strings.
# (key, friendly name, device_class, icon)
BINARY_SENSORS: list[tuple] = [
("vehicle", "Vehicle Connected", "plug", "mdi:car-electric"),
("evse_connected", "EVSE Connected", "connectivity", "mdi:ev-plug-type1"),
("manual_override", "Manual Override", None, "mdi:hand-back-right"),
]
ENTITY_CATEGORY_DIAG = {
"uptime", "srssi", "freeram", "total_switches", "evse_connected",
}
def _load_broker_defaults(path: Path) -> dict[str, object]:
"""Pick host/port/user/password from powermon.yaml if present."""
if not path.is_file():
return {}
try:
data = yaml.safe_load(path.read_text()) or {}
except yaml.YAMLError:
return {}
broker = data.get("mqttbroker") or {}
out = {}
if isinstance(broker.get("name"), str): out["host"] = broker["name"]
if isinstance(broker.get("port"), int): out["port"] = broker["port"]
if isinstance(broker.get("username"), str): out["user"] = broker["username"]
if isinstance(broker.get("password"), str): out["password"] = broker["password"]
return out
def _make_client(client_id: str) -> mqtt.Client:
try:
return mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=client_id)
except AttributeError: # paho-mqtt 1.x
return mqtt.Client(client_id=client_id)
def _read_announce(host: str, port: int, user: str, password: str,
prefix: str, timeout: float = 3.0) -> dict | None:
"""Subscribe to the announce wildcard and grab the retained payload, if any."""
seen: dict | None = None
c = _make_client("openevse-announce-reader")
c.username_pw_set(user, password)
def on_message(_client, _ud, msg):
nonlocal seen
try:
seen = json.loads(msg.payload.decode())
seen["_topic"] = msg.topic
except (UnicodeDecodeError, json.JSONDecodeError):
pass
c.on_message = on_message
c.connect(host, port, keepalive=10)
c.subscribe(f"{prefix}/announce/+", qos=0)
c.loop_start()
deadline = time.monotonic() + timeout
while seen is None and time.monotonic() < deadline:
time.sleep(0.05)
c.loop_stop()
c.disconnect()
return seen
def _build_device(announce: dict | None, device_id: str) -> dict:
"""Build the HA `device` block, populated from announce when available."""
name = (announce or {}).get("name") or f"openevse-{device_id}"
http = (announce or {}).get("http")
dev: dict = {
"identifiers": [f"openevse_{device_id}"],
"name": name,
"manufacturer": "OpenEVSE",
"model": "OpenEVSE WiFi (ESP32)",
}
if http:
dev["configuration_url"] = http
return dev
def _common_disco(prefix: str, key: str, device_id: str, dev: dict,
ann_topic: str) -> dict:
return {
"name": None, # set per entity
"state_topic": f"{prefix}/{key}",
"unique_id": f"openevse_{device_id}_{key}",
"object_id": f"openevse_{key}",
"device": dev,
"availability": [{
"topic": ann_topic,
"value_template": '{{ "online" if value_json.state == "connected" else "offline" }}',
"payload_available": "online",
"payload_not_available": "offline",
}],
}
def _sensor_topic(ha_prefix: str, key: str) -> str:
return f"{ha_prefix}/sensor/openevse_{key}/config"
def _binary_topic(ha_prefix: str, key: str) -> str:
return f"{ha_prefix}/binary_sensor/openevse_{key}/config"
def build_payloads(prefix: str, ha_prefix: str, device_id: str,
announce: dict | None) -> list[tuple[str, dict]]:
"""Return [(topic, payload_dict), ...] for every discovery config."""
dev = _build_device(announce, device_id)
ann_topic = (announce or {}).get("_topic") or f"{prefix}/announce/{device_id}"
out: list[tuple[str, dict]] = []
for key, name, unit, dclass, sclass, vt, icon, prec in SENSORS:
cfg = _common_disco(prefix, key, device_id, dev, ann_topic)
cfg["name"] = name
if unit is not None: cfg["unit_of_measurement"] = unit
if dclass is not None: cfg["device_class"] = dclass
if sclass is not None: cfg["state_class"] = sclass
if vt is not None: cfg["value_template"] = vt
if icon is not None: cfg["icon"] = icon
if prec is not None: cfg["suggested_display_precision"] = prec
if key in ENTITY_CATEGORY_DIAG:
cfg["entity_category"] = "diagnostic"
out.append((_sensor_topic(ha_prefix, key), cfg))
for key, name, dclass, icon in BINARY_SENSORS:
cfg = _common_disco(prefix, key, device_id, dev, ann_topic)
cfg["name"] = name
cfg["payload_on"] = "1"
cfg["payload_off"] = "0"
if dclass is not None: cfg["device_class"] = dclass
if icon is not None: cfg["icon"] = icon
if key in ENTITY_CATEGORY_DIAG:
cfg["entity_category"] = "diagnostic"
out.append((_binary_topic(ha_prefix, key), cfg))
return out
def main() -> int:
defaults = _load_broker_defaults(DEFAULT_BROKER_CONFIG)
ap = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
ap.add_argument("--host", default=defaults.get("host"),
help=f"broker host (default: from {DEFAULT_BROKER_CONFIG} → mqttbroker.name)")
ap.add_argument("--port", type=int, default=defaults.get("port", 1883))
ap.add_argument("--user", default=defaults.get("user"))
ap.add_argument("--password", default=defaults.get("password"))
ap.add_argument("--prefix", default=DEFAULT_PREFIX,
help=f"OpenEVSE MQTT topic prefix (default: {DEFAULT_PREFIX})")
ap.add_argument("--ha-prefix", default=DEFAULT_HA_PREFIX,
help=f"HA discovery prefix (default: {DEFAULT_HA_PREFIX})")
ap.add_argument("--device-id",
help="device id; if omitted, read from announce topic")
ap.add_argument("--purge", action="store_true",
help="publish empty retained payloads to clear all entities")
ap.add_argument("--dry-run", action="store_true",
help="print what would be published, don't connect")
args = ap.parse_args()
if not args.host:
ap.error("no --host given and no broker config found")
if args.user is None or args.password is None:
ap.error("--user / --password required (or populate ~/.config/powermon/powermon.yaml)")
announce = None
if not args.dry_run:
announce = _read_announce(args.host, args.port, args.user, args.password,
args.prefix)
device_id = args.device_id
if not device_id and announce:
# announce topic is openevse/announce/<id>
topic = announce.get("_topic", "")
device_id = topic.rsplit("/", 1)[-1] or None
if not device_id:
ap.error("could not determine device id from announce topic; "
"pass --device-id (e.g. last 4 of MAC, like 'a048')")
payloads = build_payloads(args.prefix, args.ha_prefix, device_id, announce)
action = "purge" if args.purge else "publish"
print(f"{action} {len(payloads)} HA discovery config(s) for openevse "
f"device_id={device_id!r}, broker={args.host}:{args.port}")
if announce is not None:
print(f" announce: {announce.get('name', '?')} ({announce.get('http', 'no http')})")
elif not args.dry_run:
print(" (no retained announce found — entities will be marked unavailable "
"until OpenEVSE next reconnects to the broker)")
if args.dry_run:
for topic, body in payloads:
shown = "<purge>" if args.purge else json.dumps(body, separators=(",", ":"))
print(f" {topic}\n {shown}")
return 0
c = _make_client("openevse-publish-discovery")
c.username_pw_set(args.user, args.password)
c.connect(args.host, args.port, keepalive=30)
c.loop_start()
try:
for i, (topic, body) in enumerate(payloads, start=1):
payload = "" if args.purge else json.dumps(body, separators=(",", ":"))
info = c.publish(topic, payload=payload, qos=0, retain=True)
info.wait_for_publish(2)
print(f" {i:>3}/{len(payloads)} {topic}")
finally:
c.loop_stop()
c.disconnect()
print(f"done — {len(payloads)} retained config(s) {'cleared' if args.purge else 'written'}")
return 0
if __name__ == "__main__":
sys.exit(main())