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>
This commit is contained in:
1 parent
ad9c457ea7
commit
f6630c547c
3 files changed
+312
No files matched your search
@@ -0,0 +1,131 @@
|
||||
#!/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()
|
||||
Reference in new issue
Block a user