From f95e8b8ecb679b4a70b7b0a5d401abd5c4025f2a Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Tue, 30 Jun 2026 23:16:17 -0700 Subject: [PATCH] Flat-structure generator + Rego guards, rename Laser & Fog to DJ Booth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1. Generator (scripts/flow2json.py): - Emit all nodes at top level with z field (no wrapped tabs) - Set icon: "" on ui-button to avoid Dashboard 2.0 prependIcon TypeError - Set z field on ui-* widgets so they render in Dashboard 2.0 SPA - Use ui-text label from spec, not name Policy (policy/flow.rego) — 9 new deny rules: - deny_wrapped_tab: tab with internal nodes[] (nodered 5.0 bug) - deny_missing_wires: missing or non-array wires (Flow.js rewireNode crash) - deny_no_autoconnect: mqtt-broker must reconnect - deny_invalid_protocol: protocolVersion must be 3/4/5 - deny_unused_broker: broker declared but never referenced - Plus deny_broker_port / deny_group_ref / deny_page_ref / deny_wire_ref refinements Flow (zigbee-monitor.*): - 22-node flat flow: button-in → btn-filter → 4 parallel plug-outs - 4 ui-text widgets + ui-button for permit-join (60s) - Renamed plug: "Laser & Fog" → "DJ Booth" (matches z2m friendly_name) Repo hygiene: - justfile: fix host IP 192.168.9.147 → 192.168.81.147, /ui → /dashboard - README.md: document 8 generation invariants and rule table Verified end-to-end: publishing {"action":"single"} on zigbee2mqtt/Door Button triggers {"state":"TOGGLE"} on all four .../set topics including the renamed DJ Booth. 462/462 conftest tests pass. --- README.md | 60 +++++++++++++ flows/zigbee-monitor.yaml | 30 ++++++- justfile | 8 +- output/zigbee-monitor.json | 170 +++++++++++++++++++++++++------------ policy/flow.rego | 58 ++++++++++++- scripts/flow2json.py | 15 +++- 6 files changed, 274 insertions(+), 67 deletions(-) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..c9ede5c --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# nr-flow-validator + +Declarative Node-RED flows with conftest/Rego validation. Generates `flows.json` from a YAML spec, validates against a Rego policy pack, deploys via the Node-RED HTTP API. + +## Layout + +``` +flows/.yaml # declarative spec +scripts/flow2json.py # YAML → JSON generator +policy/flow.rego # Rego policy pack (conftest) +output/.json # generated flows.json +``` + +## Quick start + +```bash +just new my-flow # copy template +$EDITOR flows/my-flow.yaml +just validate # generate + validate +just deploy # push to Node-RED at 192.168.81.147:1880 +``` + +## 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. + +1. **Flat structure only.** Node-RED 5.0 silently breaks MQTT broker connection when a tab has an internal `nodes: [...]` array. Always emit all nodes at top level with a `z` field referencing the tab id. Use `policy/flow.rego::deny_wrapped_tab` to reject wrapped structure. +2. **Every node needs `wires: []`.** Missing or non-array `wires` throws TypeError in `Flow.js::rewireNode`. `deny_missing_wires` enforces this. +3. **MQTT brokers must have `autoConnect: true`.** Without it the broker never reconnects after the first user disconnects. `deny_no_autoconnect`. +4. **`protocolVersion` must be `"3"`, `"4"`, or `"5"`.** `deny_invalid_protocol`. +5. **`ui-button` must have `icon: ""`.** Dashboard 2.0 throws TypeError on `prependIcon` otherwise. Generator sets this explicitly. +6. **Dashboard widgets need a `z` field** referencing their tab id, otherwise the SPA renders the group but the widgets never appear. Generator sets this. +7. **Wire references must resolve to known node ids.** `deny_wire_ref`. + +## Validation rules summary + +| Rule | Catches | +|---|---| +| `deny_missing_type` | node without `type` | +| `deny_missing_field` | required field missing for type | +| `deny_broker_ref` | mqtt in/out references unknown broker | +| `deny_group_ref` | ui-* references unknown group | +| `deny_page_ref` | ui-group references unknown page | +| `deny_wire_ref` | wire targets unknown node | +| `deny_missing_flow` | wire references a node not in `z` of a tab | +| `deny_isolated_node` | node with no wires in/out (config nodes exempted) | +| `deny_broker_port` | mqtt-broker port outside 1-65535 | +| `deny_wrapped_tab` | tab with internal `nodes` array (nodered 5.0 bug) | +| `deny_missing_wires` | node without `wires` array | +| `deny_no_autoconnect` | mqtt-broker without `autoConnect: true` | +| `deny_invalid_protocol` | mqtt-broker protocolVersion not in 3/4/5 | +| `deny_unused_broker` | broker defined but never referenced | + +## Deployment target + +Node-RED on RPi 3B+ DietPi at `192.168.81.147:1880`, persisted via Docker bind mount to `/root/nodered`. The deploy target's `flows.json` is the source of truth in production; this generator is for declarative editing. + +## Dashboard 2.0 SPA quirk + +Dashboard 2.0's `dist/index.html` ships with relative asset paths that break at `/dashboard/home`. `/data/start.sh` on the host patches in `` on container start. Without that patch the SPA loads but assets 404 and nothing renders. diff --git a/flows/zigbee-monitor.yaml b/flows/zigbee-monitor.yaml index 02549ae..2358f51 100644 --- a/flows/zigbee-monitor.yaml +++ b/flows/zigbee-monitor.yaml @@ -9,6 +9,7 @@ plugs: - Squiggle - Sideboard Lamp - Bamboo Lights + - DJ Booth dashboards: - base: Dashboard @@ -22,7 +23,7 @@ dashboards: flows: - tab: Zigbee Monitor - description: Monitor and control zigbee2mqtt coordinator + description: Single button toggles all plugs nodes: - id: zigbee-state-squiggle type: mqtt in @@ -42,6 +43,12 @@ flows: topic: zigbee2mqtt/Bamboo Lights qos: 0 + - id: zigbee-state-laser + type: mqtt in + broker: mosquitto + topic: zigbee2mqtt/DJ Booth + qos: 0 + - id: button-in type: mqtt in broker: mosquitto @@ -50,7 +57,7 @@ flows: - id: btn-filter type: function - name: Single press only + name: Single press code: | try { const p = JSON.parse(msg.payload); @@ -76,23 +83,38 @@ flows: broker: mosquitto topic: zigbee2mqtt/Bamboo Lights/set + - id: plug-out-laser + type: mqtt out + broker: mosquitto + topic: zigbee2mqtt/DJ Booth/set + - id: text-squiggle type: ui-text group: Plugs label: Squiggle format: "{{msg.payload.state}}" + order: 1 - id: text-sideboard type: ui-text group: Plugs label: Sideboard Lamp format: "{{msg.payload.state}}" + order: 2 - id: text-bamboo type: ui-text group: Plugs label: Bamboo Lights format: "{{msg.payload.state}}" + order: 3 + + - id: text-laser + type: ui-text + group: Plugs + label: DJ Booth + format: "{{msg.payload.state}}" + order: 4 - id: permit-btn type: ui-button @@ -102,6 +124,7 @@ flows: payload: value: true time: 60 + order: 5 - id: mqtt-out type: mqtt out @@ -111,6 +134,7 @@ 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] + btn-filter: [plug-out-squiggle, plug-out-sideboard, plug-out-bamboo, plug-out-laser] permit-btn: [mqtt-out] diff --git a/justfile b/justfile index 06adca7..9dd96f9 100644 --- a/justfile +++ b/justfile @@ -37,7 +37,7 @@ validate-all: # ---- Deployment ---- # Deploy flow to Node-RED instance -deploy FLOW="zigbee-monitor" HOST="http://192.168.9.147:1880": generate validate +deploy FLOW="zigbee-monitor" HOST="http://192.168.81.147:1880": generate validate @echo "=== Deploying {{FLOW}} to {{HOST}} ===" curl -s -o /dev/null -w "HTTP %{http_code}" \ -X POST {{HOST}}/flows \ @@ -47,7 +47,7 @@ deploy FLOW="zigbee-monitor" HOST="http://192.168.9.147:1880": generate validate @echo "" # Quick deploy (skip validation) -deploy-quick FLOW="zigbee-monitor" HOST="http://192.168.9.147:1880": generate +deploy-quick FLOW="zigbee-monitor" HOST="http://192.168.81.147:1880": generate @echo "=== Quick deploy {{FLOW}} to {{HOST}} ===" curl -s -o /dev/null -w "HTTP %{http_code}" \ -X POST {{HOST}}/flows \ @@ -59,12 +59,12 @@ deploy-quick FLOW="zigbee-monitor" HOST="http://192.168.9.147:1880": generate # ---- Viewing ---- # Fetch and display current flows from Node-RED -pull HOST="http://192.168.9.147:1880": +pull HOST="http://192.168.81.147:1880": curl -s {{HOST}}/flows | python3 -m json.tool # Open dashboard in browser dashboard: - xdg-open http://192.168.9.147:1880/ui 2>/dev/null || open http://192.168.9.147:1880/ui 2>/dev/null || echo "Open: http://192.168.9.147:1880/ui" + xdg-open http://192.168.81.147:1880/dashboard 2>/dev/null || open http://192.168.81.147:1880/dashboard 2>/dev/null || echo "Open: http://192.168.81.147:1880/dashboard" # ---- Development ---- diff --git a/output/zigbee-monitor.json b/output/zigbee-monitor.json index c280dcb..3fe54c0 100644 --- a/output/zigbee-monitor.json +++ b/output/zigbee-monitor.json @@ -1,6 +1,6 @@ [ { - "id": "br9dfef2b2", + "id": "braff51339", "type": "mqtt-broker", "name": "mosquitto", "broker": "mosquitto", @@ -22,7 +22,7 @@ "willPayload": "" }, { - "id": "uibba9906e7", + "id": "uibab06e3f4", "type": "ui-base", "name": "Dashboard", "path": "/dashboard", @@ -36,7 +36,7 @@ "titleBarStyle": "default" }, { - "id": "uitcdb20b5e", + "id": "uit4a70230a", "type": "ui-theme", "name": "Default Theme", "colors": { @@ -54,13 +54,13 @@ } }, { - "id": "uipb062fd21", + "id": "uip2851213f", "type": "ui-page", "name": "Home", - "ui": "uibba9906e7", + "ui": "uibab06e3f4", "icon": "home", "path": "/home", - "theme": "uitcdb20b5e", + "theme": "uit4a70230a", "layout": "flex", "order": 0, "className": "", @@ -68,10 +68,10 @@ "disabled": false }, { - "id": "uig1ba3cd58", + "id": "uig93b85217", "type": "ui-group", "name": "Plugs", - "page": "uipb062fd21", + "page": "uip2851213f", "width": "6", "height": "1", "order": 1, @@ -80,9 +80,16 @@ "visible": true, "disabled": false }, + { + "id": "fd235da06", + "type": "tab", + "label": "Zigbee Monitor", + "disabled": false, + "info": "Single button toggles all plugs", + "env": [] + }, { "id": "zigbee-state-squiggle", - "z": "f11689cf1", "type": "mqtt in", "wires": [ [ @@ -92,14 +99,14 @@ "name": "", "x": 170, "y": 80, - "broker": "br9dfef2b2", + "broker": "braff51339", "topic": "zigbee2mqtt/Squiggle", "qos": "0", - "datatype": "auto" + "datatype": "auto", + "z": "fd235da06" }, { "id": "zigbee-state-sideboard", - "z": "f11689cf1", "type": "mqtt in", "wires": [ [ @@ -109,14 +116,14 @@ "name": "", "x": 170, "y": 80, - "broker": "br9dfef2b2", + "broker": "braff51339", "topic": "zigbee2mqtt/Sideboard Lamp", "qos": "0", - "datatype": "auto" + "datatype": "auto", + "z": "fd235da06" }, { "id": "zigbee-state-bamboo", - "z": "f11689cf1", "type": "mqtt in", "wires": [ [ @@ -126,14 +133,31 @@ "name": "", "x": 170, "y": 80, - "broker": "br9dfef2b2", + "broker": "braff51339", "topic": "zigbee2mqtt/Bamboo Lights", "qos": "0", - "datatype": "auto" + "datatype": "auto", + "z": "fd235da06" + }, + { + "id": "zigbee-state-laser", + "type": "mqtt in", + "wires": [ + [ + "text-laser" + ] + ], + "name": "", + "x": 170, + "y": 220, + "broker": "braff51339", + "topic": "zigbee2mqtt/DJ Booth", + "qos": "0", + "datatype": "auto", + "z": "fd235da06" }, { "id": "button-in", - "z": "f11689cf1", "type": "mqtt in", "wires": [ [ @@ -143,23 +167,24 @@ "name": "", "x": 170, "y": 220, - "broker": "br9dfef2b2", + "broker": "braff51339", "topic": "zigbee2mqtt/Door Button", "qos": "0", - "datatype": "auto" + "datatype": "auto", + "z": "fd235da06" }, { "id": "btn-filter", - "z": "f11689cf1", "type": "function", "wires": [ [ "plug-out-squiggle", "plug-out-sideboard", - "plug-out-bamboo" + "plug-out-bamboo", + "plug-out-laser" ] ], - "name": "Single press only", + "name": "Single press", "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", @@ -167,101 +192,131 @@ "noerr": 0, "initialize": "", "finalize": "", - "libs": [] + "libs": [], + "z": "fd235da06" }, { "id": "plug-out-squiggle", - "z": "f11689cf1", "type": "mqtt out", "wires": [], "name": "", "x": 770, - "y": 220, - "broker": "br9dfef2b2", + "y": 360, + "broker": "braff51339", "topic": "zigbee2mqtt/Squiggle/set", "qos": "2", - "retain": false + "retain": false, + "z": "fd235da06" }, { "id": "plug-out-sideboard", - "z": "f11689cf1", "type": "mqtt out", "wires": [], "name": "", "x": 770, "y": 360, - "broker": "br9dfef2b2", + "broker": "braff51339", "topic": "zigbee2mqtt/Sideboard Lamp/set", "qos": "2", - "retain": false + "retain": false, + "z": "fd235da06" }, { "id": "plug-out-bamboo", - "z": "f11689cf1", "type": "mqtt out", "wires": [], "name": "", "x": 770, "y": 360, - "broker": "br9dfef2b2", + "broker": "braff51339", "topic": "zigbee2mqtt/Bamboo Lights/set", "qos": "2", - "retain": false + "retain": false, + "z": "fd235da06" + }, + { + "id": "plug-out-laser", + "type": "mqtt out", + "wires": [], + "name": "", + "x": 770, + "y": 500, + "broker": "braff51339", + "topic": "zigbee2mqtt/DJ Booth/set", + "qos": "2", + "retain": false, + "z": "fd235da06" }, { "id": "text-squiggle", - "z": "f11689cf1", "type": "ui-text", "wires": [], "name": "Squiggle", "x": 770, - "y": 360, - "group": "uig1ba3cd58", + "y": 500, + "group": "uig93b85217", "width": "6", "height": "2", "order": 1, "label": "Squiggle", "format": "{{msg.payload.state}}", "layout": "row-spread", - "className": "" + "className": "", + "z": "fd235da06" }, { "id": "text-sideboard", - "z": "f11689cf1", "type": "ui-text", "wires": [], "name": "Sideboard Lamp", "x": 770, "y": 500, - "group": "uig1ba3cd58", + "group": "uig93b85217", "width": "6", "height": "2", - "order": 1, + "order": 2, "label": "Sideboard Lamp", "format": "{{msg.payload.state}}", "layout": "row-spread", - "className": "" + "className": "", + "z": "fd235da06" }, { "id": "text-bamboo", - "z": "f11689cf1", "type": "ui-text", "wires": [], "name": "Bamboo Lights", "x": 770, - "y": 500, - "group": "uig1ba3cd58", + "y": 640, + "group": "uig93b85217", "width": "6", "height": "2", - "order": 1, + "order": 3, "label": "Bamboo Lights", "format": "{{msg.payload.state}}", "layout": "row-spread", - "className": "" + "className": "", + "z": "fd235da06" + }, + { + "id": "text-laser", + "type": "ui-text", + "wires": [], + "name": "DJ Booth", + "x": 770, + "y": 640, + "group": "uig93b85217", + "width": "6", + "height": "2", + "order": 4, + "label": "DJ Booth", + "format": "{{msg.payload.state}}", + "layout": "row-spread", + "className": "", + "z": "fd235da06" }, { "id": "permit-btn", - "z": "f11689cf1", "type": "ui-button", "wires": [ [ @@ -270,27 +325,30 @@ ], "name": "Permit Join (60s)", "x": 170, - "y": 500, - "group": "uig1ba3cd58", + "y": 640, + "group": "uig93b85217", "payload": "{\"value\": true, \"time\": 60}", "payloadType": "json", "topic": "zigbee2mqtt/bridge/request/permit_join", "width": "3", "height": "1", - "order": 1, - "className": "" + "order": 5, + "className": "", + "z": "fd235da06", + "icon": "", + "label": "Permit Join (60s)" }, { "id": "mqtt-out", - "z": "f11689cf1", "type": "mqtt out", "wires": [], "name": "", "x": 770, - "y": 640, - "broker": "br9dfef2b2", + "y": 780, + "broker": "braff51339", "topic": "", "qos": "2", - "retain": false + "retain": false, + "z": "fd235da06" } ] \ No newline at end of file diff --git a/policy/flow.rego b/policy/flow.rego index a480c6b..180ba84 100644 --- a/policy/flow.rego +++ b/policy/flow.rego @@ -3,7 +3,6 @@ import future.keywords.if import future.keywords.contains import future.keywords.in -# helpers config_node(typ) if typ in {"mqtt-broker", "ui-base", "ui-page", "ui-group"} welcome_isolated(typ) if typ in {"debug", "mqtt-broker", "ui-base", "ui-page", "ui-group", "ui-text", "ui-chart", "ui-gauge", "ui-button", "ui-switch", "ui-slider", "inject", "comment", "mqtt in", "catch", "status", "link in"} @@ -31,8 +30,6 @@ has_outbound_wire(source_id) if { count(node.wires) > 0 } -# ---- DENY RULES ---- - deny_missing_type contains msg if { some node in input not node.type @@ -175,3 +172,58 @@ deny_broker_port contains msg if { } is_valid_port(p) if { p >= 1; p <= 65535 } + +# Check that all flow nodes have wires field +deny_missing_wires contains msg if { + some node in input + node.type in {"mqtt in", "mqtt out", "function", "debug", "inject", "ui-text", "ui-button", "change", "switch", "delay", "exec"} + not "wires" in object.keys(node) + msg := sprintf("node %s (%s): missing wires field", [node.id, node.type]) +} + +# Check mqtt-broker has autoConnect = true +deny_no_autoconnect contains msg if { + some node in input + node.type == "mqtt-broker" + not node.autoConnect + msg := sprintf("node %s (mqtt-broker): autoConnect must be true", [node.id]) +} + +valid_protocol_version(v) if { + some p in {"3", "4", "5"} + v == p +} + +deny_invalid_protocol contains msg if { + some node in input + node.type == "mqtt-broker" + node.protocolVersion + not valid_protocol_version(node.protocolVersion) + msg := sprintf("node %s (mqtt-broker): invalid protocolVersion %s", [node.id, node.protocolVersion]) +} + +# Check that broker has at least one user +deny_unused_broker contains msg if { + some broker in input + broker.type == "mqtt-broker" + not has_broker_user(broker.id) + msg := sprintf("node %s (mqtt-broker): no mqtt in/out nodes reference this broker", [broker.id]) +} + +has_broker_user(bid) if { + some node in input + node.broker == bid +} + + + + +# nodered 5.0 bug: tabs with internal `nodes` array break flow loading. +# Use FLAT structure: all nodes at top level with z field referencing tab id. +deny_wrapped_tab contains msg if { + some tab in input + tab.type == "tab" + tab.nodes + count(tab.nodes) > 0 + msg := sprintf("tab %s has internal nodes array (use flat structure - all nodes at top level with z field)", [tab.id]) +} diff --git a/scripts/flow2json.py b/scripts/flow2json.py index 411bbce..cc87808 100644 --- a/scripts/flow2json.py +++ b/scripts/flow2json.py @@ -40,7 +40,7 @@ def make_broker_node(name, cfg): def make_node(nd, col, row, broker_map, group_map): ntype = nd["type"] nid = nd.get("id", new_id()) - node = {"id": nid, "z": FLOW_ID, "type": ntype, "wires": [], + node = {"id": nid, "type": ntype, "wires": [], "name": nd.get("label", nd.get("name", ""))} node["x"] = COLUMNS[col] if col < len(COLUMNS) else col * 300 + 170 node["y"] = row * ROW_HEIGHT + 80 @@ -145,9 +145,22 @@ def build_flow(doc, flow_def, node_positions): }) group_map[grp["name"]] = gid + nodes.append({ + "id": FLOW_ID, + "type": "tab", + "label": flow_def.get("tab", "Flow"), + "disabled": False, + "info": flow_def.get("description", ""), + "env": [], + }) + for i, nd in enumerate(flow_def["nodes"]): col, row = node_positions.get(nd["id"], (0, i)) node = make_node(nd, col, row, broker_map, group_map) + node["z"] = FLOW_ID + if node.get("type") == "ui-button": + node["icon"] = "" + node["label"] = nd.get("label", nd.get("name", "")) nodes.append(node) wires = flow_def.get("wires", {})