From 25b3e2dcc4bd2dd7340babface9fef0dd99d48bf Mon Sep 17 00:00:00 2001 From: vaku Date: Sat, 18 Jul 2026 01:15:03 +0300 Subject: [PATCH] Add MQTT monitor with Home Assistant autodiscovery Poll lifepo4wered-cli every 30s (configurable), publish all registers as one JSON state payload, and announce sensors/binary sensor via MQTT discovery. run.sh pulls broker credentials from the Supervisor mqtt service (Mosquitto addon) with manual overrides in addon options. Co-Authored-By: Claude Fable 5 --- README.md | 32 ++ .../specs/2026-07-18-mqtt-monitor-design.md | 65 ++++ hassio-addon-lifepo4wered/Dockerfile | 8 +- .../lifepo4wered_monitor.cpython-314.pyc | Bin 0 -> 10700 bytes hassio-addon-lifepo4wered/config.json | 18 +- .../lifepo4wered_monitor.py | 285 ++++++++++++++++++ hassio-addon-lifepo4wered/run.sh | 32 +- .../__pycache__/test_monitor.cpython-314.pyc | Bin 0 -> 7681 bytes tests/stubs/paho/__init__.py | 0 .../paho/__pycache__/__init__.cpython-314.pyc | Bin 0 -> 168 bytes tests/stubs/paho/mqtt/__init__.py | 0 .../mqtt/__pycache__/__init__.cpython-314.pyc | Bin 0 -> 173 bytes .../mqtt/__pycache__/client.cpython-314.pyc | Bin 0 -> 2519 bytes tests/stubs/paho/mqtt/client.py | 41 +++ tests/test_monitor.py | 97 ++++++ 15 files changed, 569 insertions(+), 9 deletions(-) create mode 100644 docs/superpowers/specs/2026-07-18-mqtt-monitor-design.md create mode 100644 hassio-addon-lifepo4wered/__pycache__/lifepo4wered_monitor.cpython-314.pyc create mode 100644 hassio-addon-lifepo4wered/lifepo4wered_monitor.py create mode 100644 tests/__pycache__/test_monitor.cpython-314.pyc create mode 100644 tests/stubs/paho/__init__.py create mode 100644 tests/stubs/paho/__pycache__/__init__.cpython-314.pyc create mode 100644 tests/stubs/paho/mqtt/__init__.py create mode 100644 tests/stubs/paho/mqtt/__pycache__/__init__.cpython-314.pyc create mode 100644 tests/stubs/paho/mqtt/__pycache__/client.cpython-314.pyc create mode 100644 tests/stubs/paho/mqtt/client.py create mode 100644 tests/test_monitor.py diff --git a/README.md b/README.md index 38bfbd6..33cf3f3 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,37 @@ # hassio-addon-lifepo4wered +Home Assistant OS addon for the [LiFePO4wered/Pi+](https://lifepo4wered.com/lifepo4wered-pi+.html) +UPS: runs `lifepo4wered-daemon` and publishes UPS stats to MQTT with Home +Assistant autodiscovery. + +## MQTT monitor + +`lifepo4wered_monitor.py` polls `lifepo4wered-cli get` (default every 30 s) +and publishes all registers as one JSON payload to `lifepo4wered/state`. +Entities appear automatically under one device, "LiFePO4wered/Pi+": + +- **Sensors**: input/battery/output voltage (V), output current (A) +- **Binary sensor** "External power": ON when `VIN >= VIN_THRESHOLD` + (i.e. OFF means running on battery) +- **Diagnostics**: thresholds, timers, watchdog and state registers +- **Availability**: `lifepo4wered/availability` with MQTT Last Will, so + entities go *unavailable* if the addon stops + +MQTT connection details are taken automatically from the Supervisor when the +Mosquitto addon is installed. Addon options: `poll_interval` (seconds) and +optional `mqtt_host`/`mqtt_port`/`mqtt_user`/`mqtt_password` overrides. + +### Testing off the Pi + +``` +python3 -m unittest discover -s tests # unit tests, no deps needed +LIFEPO4WERED_FAKE=1 MQTT_HOST=... python3 hassio-addon-lifepo4wered/lifepo4wered_monitor.py +``` + +`LIFEPO4WERED_FAKE=1` replaces the CLI with embedded sample data. + +## Setup notes + https://www.linkedin.com/pulse/creating-your-first-home-assistant-add-on-issac-goldstand diff --git a/docs/superpowers/specs/2026-07-18-mqtt-monitor-design.md b/docs/superpowers/specs/2026-07-18-mqtt-monitor-design.md new file mode 100644 index 0000000..2856d3f --- /dev/null +++ b/docs/superpowers/specs/2026-07-18-mqtt-monitor-design.md @@ -0,0 +1,65 @@ +# LiFePO4wered MQTT Monitor — Design + +Date: 2026-07-18. Approved by vaku in conversation. + +## Goal + +Publish LiFePO4wered/Pi+ UPS stats from the Home Assistant OS addon to MQTT, +with Home Assistant autodiscovery, so the UPS shows up as a device with sensors. + +## Approach + +A single Python script (`lifepo4wered_monitor.py`) inside the existing addon +container. It shells out to `lifepo4wered-cli get` (already built into the +image), parses the `KEY = value` output, and publishes to MQTT via `paho-mqtt`. +Bindings via ctypes/PyPI were considered and rejected: the CLI is already +present and one subprocess call reads everything at once. + +## Behavior + +- Poll every `POLL_INTERVAL` seconds (default 30). +- One retained JSON state payload per cycle on `lifepo4wered/state`; all + entities read it with `value_template` — one MQTT message per cycle. +- Discovery configs (retained) on `//lifepo4wered//config`, + republished on every (re)connect. All entities belong to one HA device + ("LiFePO4wered/Pi+", manufacturer Silicognition LLC). +- Main sensors: VIN, VBAT, VOUT (mV → V, device_class `voltage`), + IOUT (mA → A, device_class `current`). +- Binary sensor "External power" (device_class `power`): + `VIN >= VIN_THRESHOLD` → ON. +- Diagnostic entities (`entity_category: diagnostic`): threshold/config + registers (VBAT_MIN, VBAT_SHDN, VBAT_BOOT, VOUT_MAX, VIN_THRESHOLD scaled to + V; timers, watchdog, AUTO_BOOT, LED_STATE, TOUCH_STATE, PI_RUNNING raw; + RTC_TIME as timestamp). +- Skipped: identity/calibration registers (I2C_REG_VER, I2C_ADDRESS, DCO_*, + TOUCH_CAP_CYCLES/THRESHOLD/HYSTERESIS, *_OFFSET, CFG_WRITE, RTC_WAKE_TIME, + WAKE_TIME). +- Availability: MQTT LWT on `lifepo4wered/availability` (`online`/`offline`). + CLI failure (I2C hiccup): log, publish `offline`, retry next cycle. +- Config via env vars: MQTT_HOST, MQTT_PORT, MQTT_USER, MQTT_PASSWORD, + POLL_INTERVAL, DISCOVERY_PREFIX, MQTT_TOPIC_PREFIX. +- `LIFEPO4WERED_FAKE=1` uses embedded sample output instead of the CLI, for + testing off the Pi. + +## Addon integration + +- Dockerfile: add `python3` + `py3-paho-mqtt` (Alpine packages), copy script + and `run.sh`, `CMD ["/run.sh"]`. +- `run.sh` (bashio): pull MQTT host/port/user/password from Supervisor + services API (works out of the box with the Mosquitto addon), allow + overrides from addon options, start `lifepo4wered-daemon -f` in the + background, run the monitor in the foreground. +- `config.json`: add `"services": ["mqtt:need"]`, addon options + (`poll_interval`, optional MQTT overrides), bump version. + +## Error handling + +- paho built-in reconnect for broker drops; discovery republished on connect. +- 10 s subprocess timeout on the CLI; malformed lines skipped with a warning. +- Script compatible with paho-mqtt 1.x and 2.x callback APIs. + +## Testing + +- Unit tests (stdlib `unittest`) for output parsing and discovery payload + generation, runnable on any machine without paho or a broker. +- Fake mode for end-to-end runs against a real broker off the Pi. diff --git a/hassio-addon-lifepo4wered/Dockerfile b/hassio-addon-lifepo4wered/Dockerfile index 40ba5ac..06f4b67 100644 --- a/hassio-addon-lifepo4wered/Dockerfile +++ b/hassio-addon-lifepo4wered/Dockerfile @@ -9,11 +9,13 @@ FROM homeassistant/aarch64-base:latest ENV LANG C.UTF-8 WORKDIR / +RUN apk add --no-cache python3 py3-paho-mqtt COPY --from=build /tmp/LiFePO4wered-Pi/build/liblifepo4wered.so /usr/lib/liblifepo4wered.so COPY --from=build /tmp/LiFePO4wered-Pi/build/lifepo4wered-cli /usr/bin/lifepo4wered-cli COPY --from=build /tmp/LiFePO4wered-Pi/build/lifepo4wered-daemon /usr/sbin/lifepo4wered-daemon -# COPY start.sh /app/start.sh -# ENTRYPOINT ["/app/start.sh"] -CMD lifepo4wered-daemon -f +COPY lifepo4wered_monitor.py /usr/bin/lifepo4wered_monitor.py +COPY run.sh /run.sh +RUN chmod a+x /run.sh +CMD ["/run.sh"] LABEL io.hass.version="VERSION" io.hass.type="addon" io.hass.arch="aarch64" diff --git a/hassio-addon-lifepo4wered/__pycache__/lifepo4wered_monitor.cpython-314.pyc b/hassio-addon-lifepo4wered/__pycache__/lifepo4wered_monitor.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..60adc2640ace5e1929f8e5be6a83a55646c43a0a GIT binary patch literal 10700 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 value_json.VIN_THRESHOLD" + " else 'OFF' }}"), + ) + + for key, name in DIAG_VOLTAGE: + yield entity( + "sensor", key, name, + device_class="voltage", + unit_of_measurement="V", + entity_category="diagnostic", + suggested_display_precision=2, + value_template=millivolts(key), + ) + + for key, name in DIAG_RAW: + yield entity( + "sensor", key, name, + entity_category="diagnostic", + value_template="{{ value_json.%s }}" % key, + ) + + yield entity( + "sensor", "RTC_TIME", "UPS clock", + device_class="timestamp", + entity_category="diagnostic", + value_template="{{ value_json.RTC_TIME | as_datetime }}", + ) + + +def make_client(host, port, username, password, availability_topic): + client_kwargs = {"client_id": "lifepo4wered_monitor"} + try: + client = mqtt.Client( + callback_api_version=mqtt.CallbackAPIVersion.VERSION2, + **client_kwargs) + except AttributeError: # paho-mqtt 1.x + client = mqtt.Client(**client_kwargs) + if username: + client.username_pw_set(username, password or None) + client.will_set(availability_topic, "offline", retain=True) + client.connect(host, port, keepalive=60) + return client + + +def main(): + logging.basicConfig( + level=os.environ.get("LOG_LEVEL", "INFO").upper(), + format="%(asctime)s %(levelname)s %(message)s") + + host = os.environ.get("MQTT_HOST", "localhost") + port = int(os.environ.get("MQTT_PORT", "1883")) + username = os.environ.get("MQTT_USER", "") + password = os.environ.get("MQTT_PASSWORD", "") + interval = float(os.environ.get("POLL_INTERVAL", "30")) + discovery_prefix = os.environ.get("DISCOVERY_PREFIX", "homeassistant") + topic_prefix = os.environ.get("MQTT_TOPIC_PREFIX", "lifepo4wered") + + state_topic = topic_prefix + "/state" + availability_topic = topic_prefix + "/availability" + + if mqtt is None: + LOG.error("paho-mqtt is not installed") + return 1 + + def on_connect(client, userdata, flags, reason_code, properties=None): + LOG.info("Connected to MQTT broker %s:%d", host, port) + for topic, cfg in discovery_messages( + discovery_prefix, state_topic, availability_topic): + client.publish(topic, json.dumps(cfg), retain=True) + client.publish(availability_topic, "online", retain=True) + + client = make_client(host, port, username, password, availability_topic) + client.on_connect = on_connect + client.loop_start() + + running = True + + def stop(signum, frame): + nonlocal running + running = False + + signal.signal(signal.SIGTERM, stop) + signal.signal(signal.SIGINT, stop) + + online = True + while running: + try: + values = read_values() + except (subprocess.SubprocessError, OSError) as exc: + LOG.error("Failed to read UPS state: %s", exc) + if online: + client.publish(availability_topic, "offline", retain=True) + online = False + else: + if not online: + client.publish(availability_topic, "online", retain=True) + online = True + client.publish(state_topic, json.dumps(values), retain=True) + LOG.debug("Published %d values", len(values)) + # sleep in small steps so SIGTERM is handled promptly + deadline = time.monotonic() + interval + while running and time.monotonic() < deadline: + time.sleep(1) + + LOG.info("Shutting down") + client.publish(availability_topic, "offline", retain=True) + client.loop_stop() + client.disconnect() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/hassio-addon-lifepo4wered/run.sh b/hassio-addon-lifepo4wered/run.sh index c67f927..67f32e5 100644 --- a/hassio-addon-lifepo4wered/run.sh +++ b/hassio-addon-lifepo4wered/run.sh @@ -1,3 +1,33 @@ #!/usr/bin/with-contenv bashio -echo Hello world! +export POLL_INTERVAL="$(bashio::config 'poll_interval')" + +# Manual MQTT settings from addon options win; otherwise use the broker +# provided by the Supervisor (e.g. the Mosquitto addon). +if bashio::config.has_value 'mqtt_host'; then + export MQTT_HOST="$(bashio::config 'mqtt_host')" + if bashio::config.has_value 'mqtt_port'; then + export MQTT_PORT="$(bashio::config 'mqtt_port')" + fi + if bashio::config.has_value 'mqtt_user'; then + export MQTT_USER="$(bashio::config 'mqtt_user')" + fi + if bashio::config.has_value 'mqtt_password'; then + export MQTT_PASSWORD="$(bashio::config 'mqtt_password')" + fi +elif bashio::services.available "mqtt"; then + export MQTT_HOST="$(bashio::services mqtt 'host')" + export MQTT_PORT="$(bashio::services mqtt 'port')" + export MQTT_USER="$(bashio::services mqtt 'username')" + export MQTT_PASSWORD="$(bashio::services mqtt 'password')" +else + bashio::log.warning \ + "No MQTT broker configured and no mqtt service available;" \ + "trying localhost:1883" +fi + +bashio::log.info "Starting lifepo4wered-daemon" +lifepo4wered-daemon -f & + +bashio::log.info "Starting MQTT monitor (poll interval: ${POLL_INTERVAL}s)" +exec python3 /usr/bin/lifepo4wered_monitor.py diff --git a/tests/__pycache__/test_monitor.cpython-314.pyc b/tests/__pycache__/test_monitor.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ab252ba127bec9ac0f937db9dffac285032f406f GIT binary patch literal 7681 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 literal 0 HcmV?d00001 diff --git a/tests/stubs/paho/__init__.py b/tests/stubs/paho/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/stubs/paho/__pycache__/__init__.cpython-314.pyc b/tests/stubs/paho/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3ddf3a9423205a4f6e896caa988fa97a5a705287 GIT binary patch literal 168 zcmdPq5CH>>P{wCAAftgHh(Vb_lhJP_LlF~@{~08C%T>QDu}D87 zKQ|S~$S&1S&QD3z&&f5CH>>P{wCAAftgHh(Vb_lhJP_LlF~@{~08C%S*p3u}D87 zKQ|S~$S&1S&QD3z&&f8Mgpob;>M{rnyplhyf?O!O=uBwc=P7X_|1F2_nR3% zJJ4?uXg{sJ`_Yd%LjJ@-){Io?o&jZr*hG*SVrvtc(Dsre#MXz1og34WRYF%b18n(C zTh7*oNxks#cUOWaSad@xcIWBI#T!CcT)0u|WDr?%lvzHgf}VuV;)`^_w}j6Hw>p8C zr)^=m)+_@n8#T>KOFmn+;`VG9@OdlXmK(?LncuPmwZyz{%>|)vMeZV4W|ysFSFgSG zigV@7YuB7NCSSK)ZXO*MG!DFTR@%m6cR8eP>)g>%v*~52W3Q!y?jcZC$OlAd0D(SZ zirkDQj2T_zZA}zx9a7B6-51?3oOQj03sV!*KH~w6)=9HolqJW(a2!XPjjWC$=V>;IHncsr#qd-vBQ1CXmXwUKnmZX6tX?S7aM`AQy zX21lZ1d@YSKzvH>RtHxugZSh!LxY;$hF3}i()|#o-Co41BDarFpw18xhTUi9hDfLZ zdKDY!0$RcDxARIX+67osvWpYVTuOiH7S`n<`}5R&Q%3YCR)8qciy0{LUs=^``9KMlh+l^}IA`^hf)g%0){BOEz?g7rg>%z}seM(uJ^9;#u^+r&_MEwCd{I`VYI>WA!zTzA zj7we293dJe>snLMsGei{!6-{@?lYvOEX6M89m-lsWFsR#@1U`rwE1YxKm z${@=OL+ExG1+v%MEF=v6Gm5&AL}AQR4&{s~6SBPE`>`7aOFl!D~){1^b{=Q$3Ott`l_qvSI^%(f44HYI(cjI%U5q7ThrGr ztcg37vHLkvIhdf^8y@OA__kGJ_5wQZ38kFwOJg~>JrWupfwhl85$_(gN%B-Z8pSDC zv9pb(h6JkwC!HZ#WZnxYoP(Vanyr9mWzN9bG9gYJk0*dts;ri8l~=2`s&`5U{!M;7 ze=6n`wfz6bJguG4Dp1>LUG>^2V=M*y@MHKh)qCh*H*7tbCoh;*mYUgwJ0Quv!vf*U z{9M@P^XzH(VCPvYrmwKf$exaotQDGpRwYc@I30 z*Xnxk9vqQ-<1nZXFwBD?a1754E1@`uLUH3bYR{p-~>)1n?c+k4S75;EDtKux`?$<;re{3)Dc6EJ}R@nH~u\t". +""" + +import os + + +class CallbackAPIVersion: + VERSION1 = 1 + VERSION2 = 2 + + +class Client: + def __init__(self, *args, **kwargs): + self.on_connect = None + self._log = open(os.environ["STUB_MQTT_LOG"], "a") + + def username_pw_set(self, username, password=None): + pass + + def will_set(self, topic, payload=None, qos=0, retain=False): + pass + + def connect(self, host, port=1883, keepalive=60): + pass + + def loop_start(self): + if self.on_connect: + self.on_connect(self, None, {}, 0, None) + + def loop_stop(self): + pass + + def disconnect(self): + self._log.close() + + def publish(self, topic, payload=None, qos=0, retain=False): + self._log.write("%s\t%s\n" % (topic, payload)) + self._log.flush() diff --git a/tests/test_monitor.py b/tests/test_monitor.py new file mode 100644 index 0000000..eae1530 --- /dev/null +++ b/tests/test_monitor.py @@ -0,0 +1,97 @@ +import json +import os +import sys +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", + "hassio-addon-lifepo4wered")) + +import lifepo4wered_monitor as mon + + +class ParseOutputTest(unittest.TestCase): + def test_parses_sample_output(self): + values = mon.parse_cli_output(mon.SAMPLE_OUTPUT) + self.assertEqual(values["VIN"], 4709) + self.assertEqual(values["VBAT"], 3233) + self.assertEqual(values["IOUT"], 1355) + self.assertEqual(values["PI_RUNNING"], 1) + self.assertEqual(len(values), 36) + + def test_skips_malformed_lines(self): + values = mon.parse_cli_output("VIN = 5000\ngarbage line\nVBAT = abc\n") + self.assertEqual(values, {"VIN": 5000}) + + def test_handles_negative_values(self): + values = mon.parse_cli_output("IOUT_OFFSET = -12\n") + self.assertEqual(values["IOUT_OFFSET"], -12) + + +class DiscoveryTest(unittest.TestCase): + def setUp(self): + self.messages = dict(mon.discovery_messages( + discovery_prefix="homeassistant", + state_topic="lifepo4wered/state", + availability_topic="lifepo4wered/availability", + )) + + def test_main_voltage_sensor(self): + topic = "homeassistant/sensor/lifepo4wered/vin/config" + self.assertIn(topic, self.messages) + cfg = self.messages[topic] + self.assertEqual(cfg["device_class"], "voltage") + self.assertEqual(cfg["unit_of_measurement"], "V") + self.assertEqual(cfg["state_class"], "measurement") + self.assertIn("value_json.VIN", cfg["value_template"]) + self.assertNotIn("entity_category", cfg) + + def test_current_sensor(self): + cfg = self.messages["homeassistant/sensor/lifepo4wered/iout/config"] + self.assertEqual(cfg["device_class"], "current") + self.assertEqual(cfg["unit_of_measurement"], "A") + + def test_external_power_binary_sensor(self): + topic = "homeassistant/binary_sensor/lifepo4wered/external_power/config" + self.assertIn(topic, self.messages) + cfg = self.messages[topic] + self.assertEqual(cfg["device_class"], "power") + self.assertIn("VIN_THRESHOLD", cfg["value_template"]) + + def test_diagnostic_entities_are_categorized(self): + cfg = self.messages["homeassistant/sensor/lifepo4wered/vbat_shdn/config"] + self.assertEqual(cfg["entity_category"], "diagnostic") + self.assertEqual(cfg["unit_of_measurement"], "V") + + def test_skipped_registers_have_no_entity(self): + for key in ("i2c_reg_ver", "dco_rsel", "vbat_offset", "cfg_write"): + self.assertNotIn( + "homeassistant/sensor/lifepo4wered/%s/config" % key, + self.messages) + + def test_common_fields(self): + for topic, cfg in self.messages.items(): + self.assertEqual(cfg["state_topic"], "lifepo4wered/state") + self.assertEqual(cfg["availability_topic"], + "lifepo4wered/availability") + self.assertTrue(cfg["unique_id"].startswith("lifepo4wered_"), + topic) + self.assertEqual(cfg["device"]["identifiers"], ["lifepo4wered_pi"]) + json.dumps(cfg) # must be serializable + + def test_unique_ids_are_unique(self): + ids = [cfg["unique_id"] for cfg in self.messages.values()] + self.assertEqual(len(ids), len(set(ids))) + + +class ReadValuesTest(unittest.TestCase): + def test_fake_mode(self): + os.environ["LIFEPO4WERED_FAKE"] = "1" + try: + values = mon.read_values() + finally: + del os.environ["LIFEPO4WERED_FAKE"] + self.assertEqual(values["VOUT"], 4993) + + +if __name__ == "__main__": + unittest.main()