#!/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 = "", "" 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 " % 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()