Split button: single→3 plugs, hold-release→DJ Booth only
zigbee-monitor.yaml: - Single click (action=single) toggles Squiggle, Sideboard Lamp, Bamboo Lights - Press-hold-release (action=release after hold) toggles DJ Booth only - Two filter functions: "Single click" and "Hold release", button-in fan-out wires to both - DJ Booth added to zigbee-state-in and ui-text widgets for visibility - "plugs:" list replaced with "single_plugs:" and "hold_plugs:" for declarative clarity (generator doesn't use them, but documents intent) infra/zigbee2mqtt/configuration.yaml: - Removed DJ Booth from "all_plugs" group (now Squiggle, Sideboard Lamp, Bamboo Lights only) - Pushed same change to live /root/zigbee2mqtt/configuration.yaml and restarted zigbee2mqtt container Diagnostics in justfile + scripts/: justfile: - Constants: RPI_HOST, MQTT_HOST, MQTT_PORT, RPI_DIR, NR_PORT - rpi, mqtt-pub, mqtt-sub, mqtt-watch, mqtt-button - nr-show, nr-diff, nr-logs, nr-editor, nr-dashboard - z2m-devices, z2m-logs, z2m-state - mqtt-logs, restart, restart-all scripts/mqtt.py: - MQTT publish/subscribe/watch helpers using paho-mqtt v2.x - Run via uv run --with paho-mqtt (no global install needed) scripts/diag.py: - nr-show: live flow summary with mqtt-out topic list - nr-diff: structural diff deployed vs repo, normalizes random uuids - z2m-devices: paired devices + group membership - mqtt-button: publish action and watch /set topics (E2E test) Verified end-to-end via `just mqtt-button`: single → 3 messages on .../set (Squiggle, Sideboard Lamp, Bamboo Lights) release → 1 message on .../set (DJ Booth)
This commit is contained in:
@@ -0,0 +1 @@
|
||||
output/
|
||||
@@ -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/<name>.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.
|
||||
|
||||
+28
-10
@@ -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]
|
||||
|
||||
@@ -20,7 +20,6 @@ groups:
|
||||
- Squiggle
|
||||
- Sideboard Lamp
|
||||
- Bamboo Lights
|
||||
- DJ Booth
|
||||
devices:
|
||||
'0xffffb40e0604fbd0':
|
||||
friendly_name: Squiggle
|
||||
|
||||
@@ -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}}'
|
||||
|
||||
+72
-53
@@ -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"
|
||||
}
|
||||
]
|
||||
+149
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user