Files
soilMoistureSystem/tools/mqtt_watch.py
T
mattandClaude f6630c547c Add EMQX broker setup guide and bring-up watcher
Target broker is EMQX 5.x at 192.168.1.31.

Probed its current state and documented it in docs/MQTT_SETUP.md: all
listeners up, retained messages working, management API protected (401),
but the MQTT data plane has no authenticator -- anonymous connects are
accepted, and so are arbitrary made-up credentials. Until an authenticator
exists, the broker account values in the local config header are decorative
and a successful connection proves nothing about them.

The guide covers creating the password-based authenticator, separate
soil-node and homeassistant accounts, per-account ACLs (including setting
no-match to Deny, without which the rules are cosmetic), pointing the HA
MQTT integration at EMQX, and a CONNACK/PubSubClient troubleshooting table.

tools/mqtt_watch.py watches the broker directly during bring-up. It answers
what the HA UI cannot: whether the retained discovery configs are actually
on the broker. Those are what HA rebuilds entities from after a restart, so
a node can look healthy today and disappear on the next HA restart if they
are missing. It reads its broker account secret from a file or environment
variable rather than argv, which is visible process-wide.

The broker IP is deliberately kept out of the committed template header,
since the Gitea repo is public.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-14 14:17:02 -07:00

132 lines
4.9 KiB
Python

#!/usr/bin/env python3
"""Watch a broker for soil-node traffic and report what Home Assistant will see.
Answers the two questions that actually matter during bring-up, which the HA
UI cannot answer because it only shows you the end result:
1. Is the node publishing at all, and to the topics we expect?
2. Are the retained discovery configs present on the broker? HA rebuilds its
entities from those on every restart, so if they are missing the node
works today and vanishes the next time HA is restarted.
Requires paho-mqtt: pip install paho-mqtt
Password is read from a file or the MQTT_PASSWORD env var, never a CLI flag --
argv is visible to every process on the box and lands in shell history.
"""
import argparse
import json
import os
import sys
import time
try:
import paho.mqtt.client as mqtt
except ImportError:
sys.exit("paho-mqtt not installed: pip install paho-mqtt")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--host", default="192.168.1.31")
ap.add_argument("--port", type=int, default=1883)
ap.add_argument("--user", default=None)
ap.add_argument(
"--password-file",
default=None,
help="file containing the password; or set MQTT_PASSWORD in the environment",
)
ap.add_argument("--seconds", type=int, default=90)
args = ap.parse_args()
password = None
if args.password_file:
with open(args.password_file) as fh:
password = fh.read().strip()
elif os.environ.get("MQTT_PASSWORD"):
password = os.environ["MQTT_PASSWORD"]
discovery = {}
states = {}
def on_connect(client, userdata, flags, rc, properties=None):
if rc != 0:
print("connect failed, rc=%s" % rc)
return
print("connected to %s:%d\n" % (args.host, args.port))
client.subscribe("homeassistant/sensor/#", qos=0)
client.subscribe("soil/#", qos=0)
def on_message(client, userdata, msg):
topic = msg.topic
if topic.startswith("homeassistant/"):
# Retained discovery config. An empty payload is a deletion.
if not msg.payload:
discovery.pop(topic, None)
return
if topic not in discovery:
discovery[topic] = True
try:
cfg = json.loads(msg.payload)
name = cfg.get("name", "?")
unit = cfg.get("unit_of_meas", "")
except (ValueError, TypeError):
name, unit = "<unparseable>", ""
flag = "retained" if msg.retain else "LIVE (not retained!)"
print(" discovery %-58s %s %s [%s]" % (topic, name, unit, flag))
elif topic.endswith("/state"):
states[topic] = msg.payload
try:
data = json.loads(msg.payload)
except ValueError:
print(" state %s <non-JSON payload>" % topic)
return
moisture = {k: v for k, v in data.items() if k.startswith("moisture_")}
unknown = [k for k, v in moisture.items() if v is None]
print(
" state %s node=%s rssi=%s"
% (topic, data.get("node_id"), data.get("wifi_signal"))
)
print(" moisture: %s" % moisture)
if unknown:
print(
" NOTE %d probe(s) reporting null (out of calibrated "
"range -> 'unknown' in HA): %s" % (len(unknown), ", ".join(unknown))
)
elif topic.endswith("/availability"):
print(" avail %s -> %s" % (topic, msg.payload.decode(errors="replace")))
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="soil-watch")
if args.user:
client.username_pw_set(args.user, password)
client.on_connect = on_connect
client.on_message = on_message
client.connect(args.host, args.port, 15)
client.loop_start()
try:
time.sleep(args.seconds)
except KeyboardInterrupt:
pass
client.loop_stop()
client.disconnect()
print("\n--- summary after %ds ---" % args.seconds)
# 6 moisture + 6 raw mV + wifi_signal + uptime = 14 on the mains profile;
# the battery profile adds battery_level and battery_voltage.
print("discovery configs seen : %d" % len(discovery))
print(" expect 14 per mains node, 16 per battery node")
print("state topics seen : %d" % len(states))
if not discovery:
print(
"\nNo discovery configs. Either no node has booted since the broker last\n"
"started, or the node cannot reach the broker. Retained configs should\n"
"replay to this script the instant it subscribes, even if the node is\n"
"currently asleep -- if they don't, HA will not have them either."
)
if __name__ == "__main__":
main()