diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea1472e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +output/ diff --git a/README.md b/README.md index 9fa990c..d237a9e 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,21 @@ just deploy # push to Node-RED at 192.168.81.147:1880 See `infra/README.md` for the full Docker stack and rebuild procedure. +## Diagnostics + +All diagnostic commands live in the justfile. Run `just --list` to see them. + +- `just nr-show` — live flow summary (node count, functions, button wiring, mqtt-out topics) +- `just nr-diff` — diff deployed `/flows` against `output/.json` (ignores random uuid ids) +- `just mqtt-button [action]` — E2E test: publish a button action and watch which `/set` topics fire +- `just z2m-devices` — paired zigbee devices + group membership +- `just mqtt-pub TOPIC PAYLOAD` / `just mqtt-sub TOPIC` / `just mqtt-watch` — raw MQTT +- `just nr-logs` / `just z2m-logs` / `just mqtt-logs` — container log tails +- `just restart SERVICE` / `just restart-all` — docker restart +- `just rpi CMD` — run any command on the RPi over SSH + +`scripts/mqtt.py` and `scripts/diag.py` are the underlying helpers. Override the target host with `just RPI_HOST=user@host mqtt-button single`. + ## Generation invariants These are enforced by `policy/flow.rego` and burned in from real bugs found in deployment. The generator follows them by construction; the policy is the second line of defense. diff --git a/flows/zigbee-monitor.yaml b/flows/zigbee-monitor.yaml index 2358f51..82c3c8f 100644 --- a/flows/zigbee-monitor.yaml +++ b/flows/zigbee-monitor.yaml @@ -5,10 +5,14 @@ brokers: host: mosquitto port: 1883 -plugs: +# Plugs toggled on brief single press +single_plugs: - Squiggle - Sideboard Lamp - Bamboo Lights + +# Plugs toggled on press-hold-release (action: "release" after a "hold") +hold_plugs: - DJ Booth dashboards: @@ -23,7 +27,7 @@ dashboards: flows: - tab: Zigbee Monitor - description: Single button toggles all plugs + description: Button dispatches single vs hold-release to different plug sets nodes: - id: zigbee-state-squiggle type: mqtt in @@ -43,7 +47,7 @@ flows: topic: zigbee2mqtt/Bamboo Lights qos: 0 - - id: zigbee-state-laser + - id: zigbee-state-djbooth type: mqtt in broker: mosquitto topic: zigbee2mqtt/DJ Booth @@ -55,9 +59,9 @@ flows: topic: zigbee2mqtt/Door Button qos: 0 - - id: btn-filter + - id: single-filter type: function - name: Single press + name: Single click code: | try { const p = JSON.parse(msg.payload); @@ -68,6 +72,19 @@ flows: } catch (e) {} return null; + - id: hold-filter + type: function + name: Hold release + code: | + try { + const p = JSON.parse(msg.payload); + if (p.action === 'release') { + msg.payload = { state: 'TOGGLE' }; + return msg; + } + } catch (e) {} + return null; + - id: plug-out-squiggle type: mqtt out broker: mosquitto @@ -83,7 +100,7 @@ flows: broker: mosquitto topic: zigbee2mqtt/Bamboo Lights/set - - id: plug-out-laser + - id: plug-out-djbooth type: mqtt out broker: mosquitto topic: zigbee2mqtt/DJ Booth/set @@ -109,7 +126,7 @@ flows: format: "{{msg.payload.state}}" order: 3 - - id: text-laser + - id: text-djbooth type: ui-text group: Plugs label: DJ Booth @@ -134,7 +151,8 @@ flows: zigbee-state-squiggle: [text-squiggle] zigbee-state-sideboard: [text-sideboard] zigbee-state-bamboo: [text-bamboo] - zigbee-state-laser: [text-laser] - button-in: [btn-filter] - btn-filter: [plug-out-squiggle, plug-out-sideboard, plug-out-bamboo, plug-out-laser] + zigbee-state-djbooth: [text-djbooth] + button-in: [single-filter, hold-filter] + single-filter: [plug-out-squiggle, plug-out-sideboard, plug-out-bamboo] + hold-filter: [plug-out-djbooth] permit-btn: [mqtt-out] diff --git a/infra/zigbee2mqtt/configuration.yaml b/infra/zigbee2mqtt/configuration.yaml index c56424e..922af93 100644 --- a/infra/zigbee2mqtt/configuration.yaml +++ b/infra/zigbee2mqtt/configuration.yaml @@ -20,7 +20,6 @@ groups: - Squiggle - Sideboard Lamp - Bamboo Lights - - DJ Booth devices: '0xffffb40e0604fbd0': friendly_name: Squiggle diff --git a/justfile b/justfile index 9dd96f9..05dd6ce 100644 --- a/justfile +++ b/justfile @@ -79,3 +79,78 @@ watch FLOW="zigbee-monitor": new NAME: @cp flows/zigbee-monitor.yaml flows/{{NAME}}.yaml @echo "Created flows/{{NAME}}.yaml — edit and run 'just validate {{NAME}}'" + +# ---- Diagnostics ---- +# All commands target the live RPi at $RPI_HOST (default root@192.168.81.147). +# Override with: just RPI_HOST=user@host mqtt-pub topic msg + +RPI_HOST := "root@192.168.81.147" +MQTT_HOST := "192.168.81.147" +MQTT_PORT := "1883" +RPI_DIR := "/root" +NR_PORT := "1880" + +# Run a command on the RPi over SSH +rpi CMD *ARGS: + ssh -o ConnectTimeout=5 {{RPI_HOST}} '{{CMD}} {{ARGS}}' + +# Publish to MQTT broker on the RPi +mqtt-pub TOPIC PAYLOAD: + uv run --with paho-mqtt python3 scripts/mqtt.py pub --topic '{{TOPIC}}' --payload '{{PAYLOAD}}' + +# Subscribe to MQTT broker on the RPi for N seconds +mqtt-sub TOPIC SECONDS="5": + uv run --with paho-mqtt python3 scripts/mqtt.py sub --topic '{{TOPIC}}' --seconds {{SECONDS}} + +# Publish a button action and watch what /set topics fire (E2E test) +mqtt-button ACTION="single": + uv run --with paho-mqtt python3 scripts/diag.py mqtt-button --action {{ACTION}} + +# Show live Node-RED flow summary (node count, function names, button wires, mqtt-out topics) +nr-show: + python3 scripts/diag.py show + +# Diff deployed Node-RED flow against repo (excluding random uuid ids) +nr-diff FLOW="zigbee-monitor": generate + python3 scripts/diag.py diff output/{{FLOW}}.json + +# Tail Node-RED container logs +nr-logs: + ssh {{RPI_HOST}} 'docker logs --tail 50 -f nodered' + +# Tail zigbee2mqtt container logs +z2m-logs: + ssh {{RPI_HOST}} 'docker logs --tail 50 -f zigbee2mqtt' + +# Tail mosquitto container logs +mqtt-logs: + ssh {{RPI_HOST}} 'docker logs --tail 50 -f mosquitto' + +# Restart a service container +restart SERVICE: + ssh {{RPI_HOST}} 'docker restart {{SERVICE}}' + +# Restart the full stack +restart-all: + ssh {{RPI_HOST}} 'docker restart mosquitto zigbee2mqtt nodered' + +# Show paired zigbee devices and their friendly names +z2m-devices: + ssh {{RPI_HOST}} 'cat {{RPI_DIR}}/zigbee2mqtt/configuration.yaml' > /tmp/z2m.yaml + python3 scripts/diag.py z2m-devices /tmp/z2m.yaml + +# Show per-device persisted state from z2m +z2m-state: + ssh {{RPI_HOST}} 'cat {{RPI_DIR}}/zigbee2mqtt/state.json' | python3 -m json.tool | head -60 + +# Open the Node-RED editor +nr-editor: + xdg-open http://{{MQTT_HOST}}:{{NR_PORT}} 2>/dev/null || open http://{{MQTT_HOST}}:{{NR_PORT}} 2>/dev/null || echo "Open: http://{{MQTT_HOST}}:{{NR_PORT}}" + +# Open the Dashboard 2.0 SPA +nr-dashboard: + xdg-open http://{{MQTT_HOST}}:{{NR_PORT}}/dashboard 2>/dev/null || open http://{{MQTT_HOST}}:{{NR_PORT}}/dashboard 2>/dev/null || echo "Open: http://{{MQTT_HOST}}:{{NR_PORT}}/dashboard" + +# Watch live MQTT traffic (matches the z2m base_topic by default) +mqtt-watch TOPIC="zigbee2mqtt/#": + uv run --with paho-mqtt python3 scripts/mqtt.py watch --topic '{{TOPIC}}' diff --git a/output/zigbee-monitor.json b/output/zigbee-monitor.json index 3fe54c0..9e2c92e 100644 --- a/output/zigbee-monitor.json +++ b/output/zigbee-monitor.json @@ -1,6 +1,6 @@ [ { - "id": "braff51339", + "id": "brdf4ae094", "type": "mqtt-broker", "name": "mosquitto", "broker": "mosquitto", @@ -22,7 +22,7 @@ "willPayload": "" }, { - "id": "uibab06e3f4", + "id": "uib11a5cfce", "type": "ui-base", "name": "Dashboard", "path": "/dashboard", @@ -36,7 +36,7 @@ "titleBarStyle": "default" }, { - "id": "uit4a70230a", + "id": "uit87ac5318", "type": "ui-theme", "name": "Default Theme", "colors": { @@ -54,13 +54,13 @@ } }, { - "id": "uip2851213f", + "id": "uip5130977b", "type": "ui-page", "name": "Home", - "ui": "uibab06e3f4", + "ui": "uib11a5cfce", "icon": "home", "path": "/home", - "theme": "uit4a70230a", + "theme": "uit87ac5318", "layout": "flex", "order": 0, "className": "", @@ -68,10 +68,10 @@ "disabled": false }, { - "id": "uig93b85217", + "id": "uig5860869f", "type": "ui-group", "name": "Plugs", - "page": "uip2851213f", + "page": "uip5130977b", "width": "6", "height": "1", "order": 1, @@ -81,11 +81,11 @@ "disabled": false }, { - "id": "fd235da06", + "id": "fbc766ae4", "type": "tab", "label": "Zigbee Monitor", "disabled": false, - "info": "Single button toggles all plugs", + "info": "Button dispatches single vs hold-release to different plug sets", "env": [] }, { @@ -99,11 +99,11 @@ "name": "", "x": 170, "y": 80, - "broker": "braff51339", + "broker": "brdf4ae094", "topic": "zigbee2mqtt/Squiggle", "qos": "0", "datatype": "auto", - "z": "fd235da06" + "z": "fbc766ae4" }, { "id": "zigbee-state-sideboard", @@ -116,11 +116,11 @@ "name": "", "x": 170, "y": 80, - "broker": "braff51339", + "broker": "brdf4ae094", "topic": "zigbee2mqtt/Sideboard Lamp", "qos": "0", "datatype": "auto", - "z": "fd235da06" + "z": "fbc766ae4" }, { "id": "zigbee-state-bamboo", @@ -133,58 +133,58 @@ "name": "", "x": 170, "y": 80, - "broker": "braff51339", + "broker": "brdf4ae094", "topic": "zigbee2mqtt/Bamboo Lights", "qos": "0", "datatype": "auto", - "z": "fd235da06" + "z": "fbc766ae4" }, { - "id": "zigbee-state-laser", + "id": "zigbee-state-djbooth", "type": "mqtt in", "wires": [ [ - "text-laser" + "text-djbooth" ] ], "name": "", "x": 170, "y": 220, - "broker": "braff51339", + "broker": "brdf4ae094", "topic": "zigbee2mqtt/DJ Booth", "qos": "0", "datatype": "auto", - "z": "fd235da06" + "z": "fbc766ae4" }, { "id": "button-in", "type": "mqtt in", "wires": [ [ - "btn-filter" + "single-filter", + "hold-filter" ] ], "name": "", "x": 170, "y": 220, - "broker": "braff51339", + "broker": "brdf4ae094", "topic": "zigbee2mqtt/Door Button", "qos": "0", "datatype": "auto", - "z": "fd235da06" + "z": "fbc766ae4" }, { - "id": "btn-filter", + "id": "single-filter", "type": "function", "wires": [ [ "plug-out-squiggle", "plug-out-sideboard", - "plug-out-bamboo", - "plug-out-laser" + "plug-out-bamboo" ] ], - "name": "Single press", + "name": "Single click", "x": 450, "y": 220, "func": "try {\n const p = JSON.parse(msg.payload);\n if (p.action === 'single') {\n msg.payload = { state: 'TOGGLE' };\n return msg;\n }\n} catch (e) {}\nreturn null;\n", @@ -193,7 +193,26 @@ "initialize": "", "finalize": "", "libs": [], - "z": "fd235da06" + "z": "fbc766ae4" + }, + { + "id": "hold-filter", + "type": "function", + "wires": [ + [ + "plug-out-djbooth" + ] + ], + "name": "Hold release", + "x": 450, + "y": 360, + "func": "try {\n const p = JSON.parse(msg.payload);\n if (p.action === 'release') {\n msg.payload = { state: 'TOGGLE' };\n return msg;\n }\n} catch (e) {}\nreturn null;\n", + "outputs": 1, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "z": "fbc766ae4" }, { "id": "plug-out-squiggle", @@ -202,11 +221,11 @@ "name": "", "x": 770, "y": 360, - "broker": "braff51339", + "broker": "brdf4ae094", "topic": "zigbee2mqtt/Squiggle/set", "qos": "2", "retain": false, - "z": "fd235da06" + "z": "fbc766ae4" }, { "id": "plug-out-sideboard", @@ -215,11 +234,11 @@ "name": "", "x": 770, "y": 360, - "broker": "braff51339", + "broker": "brdf4ae094", "topic": "zigbee2mqtt/Sideboard Lamp/set", "qos": "2", "retain": false, - "z": "fd235da06" + "z": "fbc766ae4" }, { "id": "plug-out-bamboo", @@ -227,25 +246,25 @@ "wires": [], "name": "", "x": 770, - "y": 360, - "broker": "braff51339", + "y": 500, + "broker": "brdf4ae094", "topic": "zigbee2mqtt/Bamboo Lights/set", "qos": "2", "retain": false, - "z": "fd235da06" + "z": "fbc766ae4" }, { - "id": "plug-out-laser", + "id": "plug-out-djbooth", "type": "mqtt out", "wires": [], "name": "", "x": 770, "y": 500, - "broker": "braff51339", + "broker": "brdf4ae094", "topic": "zigbee2mqtt/DJ Booth/set", "qos": "2", "retain": false, - "z": "fd235da06" + "z": "fbc766ae4" }, { "id": "text-squiggle", @@ -254,7 +273,7 @@ "name": "Squiggle", "x": 770, "y": 500, - "group": "uig93b85217", + "group": "uig5860869f", "width": "6", "height": "2", "order": 1, @@ -262,7 +281,7 @@ "format": "{{msg.payload.state}}", "layout": "row-spread", "className": "", - "z": "fd235da06" + "z": "fbc766ae4" }, { "id": "text-sideboard", @@ -270,8 +289,8 @@ "wires": [], "name": "Sideboard Lamp", "x": 770, - "y": 500, - "group": "uig93b85217", + "y": 640, + "group": "uig5860869f", "width": "6", "height": "2", "order": 2, @@ -279,7 +298,7 @@ "format": "{{msg.payload.state}}", "layout": "row-spread", "className": "", - "z": "fd235da06" + "z": "fbc766ae4" }, { "id": "text-bamboo", @@ -288,7 +307,7 @@ "name": "Bamboo Lights", "x": 770, "y": 640, - "group": "uig93b85217", + "group": "uig5860869f", "width": "6", "height": "2", "order": 3, @@ -296,16 +315,16 @@ "format": "{{msg.payload.state}}", "layout": "row-spread", "className": "", - "z": "fd235da06" + "z": "fbc766ae4" }, { - "id": "text-laser", + "id": "text-djbooth", "type": "ui-text", "wires": [], "name": "DJ Booth", "x": 770, "y": 640, - "group": "uig93b85217", + "group": "uig5860869f", "width": "6", "height": "2", "order": 4, @@ -313,7 +332,7 @@ "format": "{{msg.payload.state}}", "layout": "row-spread", "className": "", - "z": "fd235da06" + "z": "fbc766ae4" }, { "id": "permit-btn", @@ -325,8 +344,8 @@ ], "name": "Permit Join (60s)", "x": 170, - "y": 640, - "group": "uig93b85217", + "y": 780, + "group": "uig5860869f", "payload": "{\"value\": true, \"time\": 60}", "payloadType": "json", "topic": "zigbee2mqtt/bridge/request/permit_join", @@ -334,7 +353,7 @@ "height": "1", "order": 5, "className": "", - "z": "fd235da06", + "z": "fbc766ae4", "icon": "", "label": "Permit Join (60s)" }, @@ -345,10 +364,10 @@ "name": "", "x": 770, "y": 780, - "broker": "braff51339", + "broker": "brdf4ae094", "topic": "", "qos": "2", "retain": false, - "z": "fd235da06" + "z": "fbc766ae4" } ] \ No newline at end of file diff --git a/scripts/diag.py b/scripts/diag.py new file mode 100644 index 0000000..c62a334 --- /dev/null +++ b/scripts/diag.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Live diagnostic helpers — invoked by justfile diagnostics recipes.""" +import argparse +import json +import re +import sys +import time +import urllib.request + +try: + import paho.mqtt.client as mqtt +except ImportError: + mqtt = None +import yaml + + +def show(): + url = "http://192.168.81.147:1880/flows" + with urllib.request.urlopen(url, timeout=5) as r: + d = json.load(r) + print(f"nodes: {len(d)}") + funcs = [n.get("name", "?") for n in d if n.get("type") == "function"] + print(f"functions: {funcs}") + btn = [n for n in d if n.get("type") == "mqtt in" and n.get("topic") == "zigbee2mqtt/Door Button"] + if btn: + print(f"button-in wires: {btn[0].get('wires')}") + out_targets = set() + for n in d: + for wire in n.get("wires", []): + for tid in wire: + tgt = next((x for x in d if x["id"] == tid), None) + if tgt and tgt.get("type") == "mqtt out": + out_targets.add(tgt.get("topic", "?")) + print(f"mqtt-out topics: {sorted(out_targets)}") + + +def diff(flow_path): + with urllib.request.urlopen("http://192.168.81.147:1880/flows", timeout=5) as r: + deployed = json.load(r) + with open(flow_path) as f: + generated = json.load(f) + + def norm(n): + out = dict(n) + for k in ("id", "broker", "group", "page", "theme", "ui", "z"): + if isinstance(out.get(k), str): + out[k] = re.sub(r"[a-f0-9]+", "X", out[k]) + if "wires" in out and isinstance(out["wires"], list): + out["wires"] = [ + [re.sub(r"[a-f0-9]+", "X", x) for x in w] for w in out["wires"] + ] + return json.dumps(out, sort_keys=True) + + a = sorted([norm(n) for n in generated]) + b = sorted([norm(n) for n in deployed]) + if a == b: + print(f"MATCH: deployed flow structurally identical to {flow_path}") + return 0 + import difflib + for line in difflib.unified_diff(a, b, lineterm=""): + print(line) + return 1 + + +def z2m_devices(z2m_yaml_path): + with open(z2m_yaml_path) as f: + d = yaml.safe_load(f) + print("Devices:") + for ieee, info in d.get("devices", {}).items(): + print(f" {info.get('friendly_name'):20s} {ieee}") + print("Groups:") + for gid, info in d.get("groups", {}).items(): + print(f" #{gid} {info.get('friendly_name')}: {info.get('devices')}") + + +def mqtt_pub(host, port, topic, payload): + client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2) + client.connect(host, port, 60) + client.loop_start() + info = client.publish(topic, payload) + info.wait_for_publish(timeout=5) + client.loop_stop() + client.disconnect() + + +def mqtt_button(host, port, action, watch_topic, watch_secs): + received = [] + + def on_message(c, u, msg): + received.append((msg.topic, msg.payload.decode())) + + sub = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2) + sub.on_message = on_message + sub.connect(host, port, 60) + sub.loop_start() + sub.subscribe(watch_topic) + + time.sleep(1) + + pub = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2) + pub.connect(host, port, 60) + pub.loop_start() + info = pub.publish("zigbee2mqtt/Door Button", json.dumps({"action": action})) + info.wait_for_publish(timeout=5) + + deadline = time.monotonic() + watch_secs + while time.monotonic() < deadline: + time.sleep(0.1) + + sub.loop_stop() + sub.disconnect() + pub.loop_stop() + pub.disconnect() + + print(f"=== Published action={action!r} → {watch_topic} (watched {watch_secs}s) ===") + if not received: + print("(no messages received)") + return + for topic, payload in received: + print(f" {topic} {payload}") + + +def main(): + ap = argparse.ArgumentParser() + sub = ap.add_subparsers(dest="cmd", required=True) + sub.add_parser("show", help="Show live Node-RED flow summary") + pdiff = sub.add_parser("diff", help="Diff deployed against repo JSON") + pdiff.add_argument("flow_path") + pz2m = sub.add_parser("z2m-devices", help="Show paired zigbee devices and groups") + pz2m.add_argument("z2m_yaml_path") + pbutton = sub.add_parser("mqtt-button", help="Publish a button action and watch /set topics") + pbutton.add_argument("--host", default="192.168.81.147") + pbutton.add_argument("--port", type=int, default=1883) + pbutton.add_argument("--action", default="single") + pbutton.add_argument("--watch-topic", default="zigbee2mqtt/+/set") + pbutton.add_argument("--watch-secs", type=float, default=4.0) + args = ap.parse_args() + if args.cmd == "show": + show() + elif args.cmd == "diff": + sys.exit(diff(args.flow_path)) + elif args.cmd == "z2m-devices": + z2m_devices(args.z2m_yaml_path) + elif args.cmd == "mqtt-button": + mqtt_button(args.host, args.port, args.action, args.watch_topic, args.watch_secs) + + +if __name__ == "__main__": + main() diff --git a/scripts/mqtt.py b/scripts/mqtt.py new file mode 100644 index 0000000..5bf8a5e --- /dev/null +++ b/scripts/mqtt.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""MQTT diagnostic helpers — invoked by justfile mqtt-* recipes.""" +import argparse +import sys +import time + +import paho.mqtt.client as mqtt + + +def pub(host, port, topic, payload): + c = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2) + c.connect(host, port, 60) + c.loop_start() + info = c.publish(topic, payload) + info.wait_for_publish(timeout=5) + c.loop_stop() + c.disconnect() + + +def sub(host, port, topic, seconds): + got = [] + + def on_msg(client, userdata, msg): + got.append((msg.topic, msg.payload.decode())) + + c = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2) + c.on_message = on_msg + c.connect(host, port, 60) + c.loop_start() + c.subscribe(topic) + end = time.monotonic() + seconds + while time.monotonic() < end: + time.sleep(0.1) + c.loop_stop() + c.disconnect() + for t, p in got: + print(t, p) + + +def watch(host, port, topic): + def on_msg(client, userdata, msg): + print(msg.topic, msg.payload.decode()) + + c = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2) + c.on_message = on_msg + c.connect(host, port, 60) + c.loop_start() + c.subscribe(topic) + try: + while True: + time.sleep(0.5) + except KeyboardInterrupt: + pass + c.loop_stop() + c.disconnect() + + +def main(): + ap = argparse.ArgumentParser() + sub_p = ap.add_subparsers(dest="cmd", required=True) + + ppub = sub_p.add_parser("pub", help="Publish a single message") + ppub.add_argument("--host", default="192.168.81.147") + ppub.add_argument("--port", type=int, default=1883) + ppub.add_argument("--topic", required=True) + ppub.add_argument("--payload", required=True) + + psub = sub_p.add_parser("sub", help="Subscribe for N seconds and print messages") + psub.add_argument("--host", default="192.168.81.147") + psub.add_argument("--port", type=int, default=1883) + psub.add_argument("--topic", required=True) + psub.add_argument("--seconds", type=float, default=5.0) + + pwatch = sub_p.add_parser("watch", help="Subscribe forever and print messages") + pwatch.add_argument("--host", default="192.168.81.147") + pwatch.add_argument("--port", type=int, default=1883) + pwatch.add_argument("--topic", default="zigbee2mqtt/#") + + args = ap.parse_args() + if args.cmd == "pub": + pub(args.host, args.port, args.topic, args.payload) + elif args.cmd == "sub": + sub(args.host, args.port, args.topic, args.seconds) + elif args.cmd == "watch": + watch(args.host, args.port, args.topic) + + +if __name__ == "__main__": + main()