Flat-structure generator + Rego guards, rename Laser & Fog to DJ Booth

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.
This commit is contained in:
2026-06-30 23:16:17 -07:00
parent 517f3dbdae
commit f95e8b8ecb
6 changed files with 274 additions and 67 deletions
+60
View File
@@ -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/<name>.yaml # declarative spec
scripts/flow2json.py # YAML → JSON generator
policy/flow.rego # Rego policy pack (conftest)
output/<name>.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 `<base href="/dashboard/">` on container start. Without that patch the SPA loads but assets 404 and nothing renders.
+27 -3
View File
@@ -9,6 +9,7 @@ plugs:
- Squiggle - Squiggle
- Sideboard Lamp - Sideboard Lamp
- Bamboo Lights - Bamboo Lights
- DJ Booth
dashboards: dashboards:
- base: Dashboard - base: Dashboard
@@ -22,7 +23,7 @@ dashboards:
flows: flows:
- tab: Zigbee Monitor - tab: Zigbee Monitor
description: Monitor and control zigbee2mqtt coordinator description: Single button toggles all plugs
nodes: nodes:
- id: zigbee-state-squiggle - id: zigbee-state-squiggle
type: mqtt in type: mqtt in
@@ -42,6 +43,12 @@ flows:
topic: zigbee2mqtt/Bamboo Lights topic: zigbee2mqtt/Bamboo Lights
qos: 0 qos: 0
- id: zigbee-state-laser
type: mqtt in
broker: mosquitto
topic: zigbee2mqtt/DJ Booth
qos: 0
- id: button-in - id: button-in
type: mqtt in type: mqtt in
broker: mosquitto broker: mosquitto
@@ -50,7 +57,7 @@ flows:
- id: btn-filter - id: btn-filter
type: function type: function
name: Single press only name: Single press
code: | code: |
try { try {
const p = JSON.parse(msg.payload); const p = JSON.parse(msg.payload);
@@ -76,23 +83,38 @@ flows:
broker: mosquitto broker: mosquitto
topic: zigbee2mqtt/Bamboo Lights/set topic: zigbee2mqtt/Bamboo Lights/set
- id: plug-out-laser
type: mqtt out
broker: mosquitto
topic: zigbee2mqtt/DJ Booth/set
- id: text-squiggle - id: text-squiggle
type: ui-text type: ui-text
group: Plugs group: Plugs
label: Squiggle label: Squiggle
format: "{{msg.payload.state}}" format: "{{msg.payload.state}}"
order: 1
- id: text-sideboard - id: text-sideboard
type: ui-text type: ui-text
group: Plugs group: Plugs
label: Sideboard Lamp label: Sideboard Lamp
format: "{{msg.payload.state}}" format: "{{msg.payload.state}}"
order: 2
- id: text-bamboo - id: text-bamboo
type: ui-text type: ui-text
group: Plugs group: Plugs
label: Bamboo Lights label: Bamboo Lights
format: "{{msg.payload.state}}" 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 - id: permit-btn
type: ui-button type: ui-button
@@ -102,6 +124,7 @@ flows:
payload: payload:
value: true value: true
time: 60 time: 60
order: 5
- id: mqtt-out - id: mqtt-out
type: mqtt out type: mqtt out
@@ -111,6 +134,7 @@ flows:
zigbee-state-squiggle: [text-squiggle] zigbee-state-squiggle: [text-squiggle]
zigbee-state-sideboard: [text-sideboard] zigbee-state-sideboard: [text-sideboard]
zigbee-state-bamboo: [text-bamboo] zigbee-state-bamboo: [text-bamboo]
zigbee-state-laser: [text-laser]
button-in: [btn-filter] 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] permit-btn: [mqtt-out]
+4 -4
View File
@@ -37,7 +37,7 @@ validate-all:
# ---- Deployment ---- # ---- Deployment ----
# Deploy flow to Node-RED instance # 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}} ===" @echo "=== Deploying {{FLOW}} to {{HOST}} ==="
curl -s -o /dev/null -w "HTTP %{http_code}" \ curl -s -o /dev/null -w "HTTP %{http_code}" \
-X POST {{HOST}}/flows \ -X POST {{HOST}}/flows \
@@ -47,7 +47,7 @@ deploy FLOW="zigbee-monitor" HOST="http://192.168.9.147:1880": generate validate
@echo "" @echo ""
# Quick deploy (skip validation) # 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}} ===" @echo "=== Quick deploy {{FLOW}} to {{HOST}} ==="
curl -s -o /dev/null -w "HTTP %{http_code}" \ curl -s -o /dev/null -w "HTTP %{http_code}" \
-X POST {{HOST}}/flows \ -X POST {{HOST}}/flows \
@@ -59,12 +59,12 @@ deploy-quick FLOW="zigbee-monitor" HOST="http://192.168.9.147:1880": generate
# ---- Viewing ---- # ---- Viewing ----
# Fetch and display current flows from Node-RED # 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 curl -s {{HOST}}/flows | python3 -m json.tool
# Open dashboard in browser # Open dashboard in browser
dashboard: 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 ---- # ---- Development ----
+114 -56
View File
@@ -1,6 +1,6 @@
[ [
{ {
"id": "br9dfef2b2", "id": "braff51339",
"type": "mqtt-broker", "type": "mqtt-broker",
"name": "mosquitto", "name": "mosquitto",
"broker": "mosquitto", "broker": "mosquitto",
@@ -22,7 +22,7 @@
"willPayload": "" "willPayload": ""
}, },
{ {
"id": "uibba9906e7", "id": "uibab06e3f4",
"type": "ui-base", "type": "ui-base",
"name": "Dashboard", "name": "Dashboard",
"path": "/dashboard", "path": "/dashboard",
@@ -36,7 +36,7 @@
"titleBarStyle": "default" "titleBarStyle": "default"
}, },
{ {
"id": "uitcdb20b5e", "id": "uit4a70230a",
"type": "ui-theme", "type": "ui-theme",
"name": "Default Theme", "name": "Default Theme",
"colors": { "colors": {
@@ -54,13 +54,13 @@
} }
}, },
{ {
"id": "uipb062fd21", "id": "uip2851213f",
"type": "ui-page", "type": "ui-page",
"name": "Home", "name": "Home",
"ui": "uibba9906e7", "ui": "uibab06e3f4",
"icon": "home", "icon": "home",
"path": "/home", "path": "/home",
"theme": "uitcdb20b5e", "theme": "uit4a70230a",
"layout": "flex", "layout": "flex",
"order": 0, "order": 0,
"className": "", "className": "",
@@ -68,10 +68,10 @@
"disabled": false "disabled": false
}, },
{ {
"id": "uig1ba3cd58", "id": "uig93b85217",
"type": "ui-group", "type": "ui-group",
"name": "Plugs", "name": "Plugs",
"page": "uipb062fd21", "page": "uip2851213f",
"width": "6", "width": "6",
"height": "1", "height": "1",
"order": 1, "order": 1,
@@ -80,9 +80,16 @@
"visible": true, "visible": true,
"disabled": false "disabled": false
}, },
{
"id": "fd235da06",
"type": "tab",
"label": "Zigbee Monitor",
"disabled": false,
"info": "Single button toggles all plugs",
"env": []
},
{ {
"id": "zigbee-state-squiggle", "id": "zigbee-state-squiggle",
"z": "f11689cf1",
"type": "mqtt in", "type": "mqtt in",
"wires": [ "wires": [
[ [
@@ -92,14 +99,14 @@
"name": "", "name": "",
"x": 170, "x": 170,
"y": 80, "y": 80,
"broker": "br9dfef2b2", "broker": "braff51339",
"topic": "zigbee2mqtt/Squiggle", "topic": "zigbee2mqtt/Squiggle",
"qos": "0", "qos": "0",
"datatype": "auto" "datatype": "auto",
"z": "fd235da06"
}, },
{ {
"id": "zigbee-state-sideboard", "id": "zigbee-state-sideboard",
"z": "f11689cf1",
"type": "mqtt in", "type": "mqtt in",
"wires": [ "wires": [
[ [
@@ -109,14 +116,14 @@
"name": "", "name": "",
"x": 170, "x": 170,
"y": 80, "y": 80,
"broker": "br9dfef2b2", "broker": "braff51339",
"topic": "zigbee2mqtt/Sideboard Lamp", "topic": "zigbee2mqtt/Sideboard Lamp",
"qos": "0", "qos": "0",
"datatype": "auto" "datatype": "auto",
"z": "fd235da06"
}, },
{ {
"id": "zigbee-state-bamboo", "id": "zigbee-state-bamboo",
"z": "f11689cf1",
"type": "mqtt in", "type": "mqtt in",
"wires": [ "wires": [
[ [
@@ -126,14 +133,31 @@
"name": "", "name": "",
"x": 170, "x": 170,
"y": 80, "y": 80,
"broker": "br9dfef2b2", "broker": "braff51339",
"topic": "zigbee2mqtt/Bamboo Lights", "topic": "zigbee2mqtt/Bamboo Lights",
"qos": "0", "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", "id": "button-in",
"z": "f11689cf1",
"type": "mqtt in", "type": "mqtt in",
"wires": [ "wires": [
[ [
@@ -143,23 +167,24 @@
"name": "", "name": "",
"x": 170, "x": 170,
"y": 220, "y": 220,
"broker": "br9dfef2b2", "broker": "braff51339",
"topic": "zigbee2mqtt/Door Button", "topic": "zigbee2mqtt/Door Button",
"qos": "0", "qos": "0",
"datatype": "auto" "datatype": "auto",
"z": "fd235da06"
}, },
{ {
"id": "btn-filter", "id": "btn-filter",
"z": "f11689cf1",
"type": "function", "type": "function",
"wires": [ "wires": [
[ [
"plug-out-squiggle", "plug-out-squiggle",
"plug-out-sideboard", "plug-out-sideboard",
"plug-out-bamboo" "plug-out-bamboo",
"plug-out-laser"
] ]
], ],
"name": "Single press only", "name": "Single press",
"x": 450, "x": 450,
"y": 220, "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", "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, "noerr": 0,
"initialize": "", "initialize": "",
"finalize": "", "finalize": "",
"libs": [] "libs": [],
"z": "fd235da06"
}, },
{ {
"id": "plug-out-squiggle", "id": "plug-out-squiggle",
"z": "f11689cf1",
"type": "mqtt out", "type": "mqtt out",
"wires": [], "wires": [],
"name": "", "name": "",
"x": 770, "x": 770,
"y": 220, "y": 360,
"broker": "br9dfef2b2", "broker": "braff51339",
"topic": "zigbee2mqtt/Squiggle/set", "topic": "zigbee2mqtt/Squiggle/set",
"qos": "2", "qos": "2",
"retain": false "retain": false,
"z": "fd235da06"
}, },
{ {
"id": "plug-out-sideboard", "id": "plug-out-sideboard",
"z": "f11689cf1",
"type": "mqtt out", "type": "mqtt out",
"wires": [], "wires": [],
"name": "", "name": "",
"x": 770, "x": 770,
"y": 360, "y": 360,
"broker": "br9dfef2b2", "broker": "braff51339",
"topic": "zigbee2mqtt/Sideboard Lamp/set", "topic": "zigbee2mqtt/Sideboard Lamp/set",
"qos": "2", "qos": "2",
"retain": false "retain": false,
"z": "fd235da06"
}, },
{ {
"id": "plug-out-bamboo", "id": "plug-out-bamboo",
"z": "f11689cf1",
"type": "mqtt out", "type": "mqtt out",
"wires": [], "wires": [],
"name": "", "name": "",
"x": 770, "x": 770,
"y": 360, "y": 360,
"broker": "br9dfef2b2", "broker": "braff51339",
"topic": "zigbee2mqtt/Bamboo Lights/set", "topic": "zigbee2mqtt/Bamboo Lights/set",
"qos": "2", "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", "id": "text-squiggle",
"z": "f11689cf1",
"type": "ui-text", "type": "ui-text",
"wires": [], "wires": [],
"name": "Squiggle", "name": "Squiggle",
"x": 770, "x": 770,
"y": 360, "y": 500,
"group": "uig1ba3cd58", "group": "uig93b85217",
"width": "6", "width": "6",
"height": "2", "height": "2",
"order": 1, "order": 1,
"label": "Squiggle", "label": "Squiggle",
"format": "{{msg.payload.state}}", "format": "{{msg.payload.state}}",
"layout": "row-spread", "layout": "row-spread",
"className": "" "className": "",
"z": "fd235da06"
}, },
{ {
"id": "text-sideboard", "id": "text-sideboard",
"z": "f11689cf1",
"type": "ui-text", "type": "ui-text",
"wires": [], "wires": [],
"name": "Sideboard Lamp", "name": "Sideboard Lamp",
"x": 770, "x": 770,
"y": 500, "y": 500,
"group": "uig1ba3cd58", "group": "uig93b85217",
"width": "6", "width": "6",
"height": "2", "height": "2",
"order": 1, "order": 2,
"label": "Sideboard Lamp", "label": "Sideboard Lamp",
"format": "{{msg.payload.state}}", "format": "{{msg.payload.state}}",
"layout": "row-spread", "layout": "row-spread",
"className": "" "className": "",
"z": "fd235da06"
}, },
{ {
"id": "text-bamboo", "id": "text-bamboo",
"z": "f11689cf1",
"type": "ui-text", "type": "ui-text",
"wires": [], "wires": [],
"name": "Bamboo Lights", "name": "Bamboo Lights",
"x": 770, "x": 770,
"y": 500, "y": 640,
"group": "uig1ba3cd58", "group": "uig93b85217",
"width": "6", "width": "6",
"height": "2", "height": "2",
"order": 1, "order": 3,
"label": "Bamboo Lights", "label": "Bamboo Lights",
"format": "{{msg.payload.state}}", "format": "{{msg.payload.state}}",
"layout": "row-spread", "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", "id": "permit-btn",
"z": "f11689cf1",
"type": "ui-button", "type": "ui-button",
"wires": [ "wires": [
[ [
@@ -270,27 +325,30 @@
], ],
"name": "Permit Join (60s)", "name": "Permit Join (60s)",
"x": 170, "x": 170,
"y": 500, "y": 640,
"group": "uig1ba3cd58", "group": "uig93b85217",
"payload": "{\"value\": true, \"time\": 60}", "payload": "{\"value\": true, \"time\": 60}",
"payloadType": "json", "payloadType": "json",
"topic": "zigbee2mqtt/bridge/request/permit_join", "topic": "zigbee2mqtt/bridge/request/permit_join",
"width": "3", "width": "3",
"height": "1", "height": "1",
"order": 1, "order": 5,
"className": "" "className": "",
"z": "fd235da06",
"icon": "",
"label": "Permit Join (60s)"
}, },
{ {
"id": "mqtt-out", "id": "mqtt-out",
"z": "f11689cf1",
"type": "mqtt out", "type": "mqtt out",
"wires": [], "wires": [],
"name": "", "name": "",
"x": 770, "x": 770,
"y": 640, "y": 780,
"broker": "br9dfef2b2", "broker": "braff51339",
"topic": "", "topic": "",
"qos": "2", "qos": "2",
"retain": false "retain": false,
"z": "fd235da06"
} }
] ]
+55 -3
View File
@@ -3,7 +3,6 @@ import future.keywords.if
import future.keywords.contains import future.keywords.contains
import future.keywords.in import future.keywords.in
# helpers
config_node(typ) if typ in {"mqtt-broker", "ui-base", "ui-page", "ui-group"} 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"} 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 count(node.wires) > 0
} }
# ---- DENY RULES ----
deny_missing_type contains msg if { deny_missing_type contains msg if {
some node in input some node in input
not node.type not node.type
@@ -175,3 +172,58 @@ deny_broker_port contains msg if {
} }
is_valid_port(p) if { p >= 1; p <= 65535 } 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])
}
+14 -1
View File
@@ -40,7 +40,7 @@ def make_broker_node(name, cfg):
def make_node(nd, col, row, broker_map, group_map): def make_node(nd, col, row, broker_map, group_map):
ntype = nd["type"] ntype = nd["type"]
nid = nd.get("id", new_id()) 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", ""))} "name": nd.get("label", nd.get("name", ""))}
node["x"] = COLUMNS[col] if col < len(COLUMNS) else col * 300 + 170 node["x"] = COLUMNS[col] if col < len(COLUMNS) else col * 300 + 170
node["y"] = row * ROW_HEIGHT + 80 node["y"] = row * ROW_HEIGHT + 80
@@ -145,9 +145,22 @@ def build_flow(doc, flow_def, node_positions):
}) })
group_map[grp["name"]] = gid 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"]): for i, nd in enumerate(flow_def["nodes"]):
col, row = node_positions.get(nd["id"], (0, i)) col, row = node_positions.get(nd["id"], (0, i))
node = make_node(nd, col, row, broker_map, group_map) 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) nodes.append(node)
wires = flow_def.get("wires", {}) wires = flow_def.get("wires", {})