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>
132 lines
4.9 KiB
Python
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()
|