Files
nr-flow-validator/scripts/flow2json.py
T
david f95e8b8ecb 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.
2026-06-30 23:16:17 -07:00

197 lines
6.7 KiB
Python

#!/usr/bin/env python3
"""Convert declarative flow YAML to Node-RED flow JSON (Dashboard 2.0)."""
import json
import sys
import uuid
import yaml
COLUMNS = [170, 450, 770]
ROW_HEIGHT = 140
BROKER_DEFAULTS = {
"clientid": "", "autoConnect": True, "usetls": False,
"protocolVersion": "4", "keepalive": "60", "cleansession": True,
"birthTopic": "", "birthQos": "0", "birthPayload": "",
"closeTopic": "", "closeQos": "0", "closePayload": "",
"willTopic": "", "willQos": "0", "willPayload": "",
}
THEME_DEFAULTS = {
"colors": {"surface": "#ffffff", "primary": "#0094CE", "bgPage": "#eeeeee",
"groupBg": "#ffffff", "groupOutline": "#cccccc"},
"sizes": {"pagePadding": "12px", "groupGap": "12px",
"groupBorderRadius": "4px", "widgetGap": "12px"},
}
def new_id(prefix="n"):
return prefix + uuid.uuid4().hex[:8]
FLOW_ID = new_id("f")
def make_broker_node(name, cfg):
node = {"id": new_id("br"), "type": "mqtt-broker", "name": name}
node["broker"] = cfg["host"]
node["port"] = str(cfg.get("port", 1883))
node.update(BROKER_DEFAULTS)
return node
def make_node(nd, col, row, broker_map, group_map):
ntype = nd["type"]
nid = nd.get("id", new_id())
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
if ntype in ("mqtt in", "mqtt-in", "mqtt out", "mqtt-out"):
node["broker"] = broker_map[nd["broker"]]
if ntype in ("mqtt in", "mqtt-in"):
node["topic"] = nd["topic"]
node["qos"] = str(nd.get("qos", 2))
node["datatype"] = "auto"
if ntype in ("mqtt out", "mqtt-out"):
node["topic"] = nd.get("topic", "")
node["qos"] = str(nd.get("qos", 2))
node["retain"] = False
if ntype in ("ui-text", "ui-button"):
node["group"] = group_map[nd["group"]]
if node["type"] in ("ui-text",):
node["width"] = nd.get("width", "6")
node["height"] = nd.get("height", "2")
node["order"] = nd.get("order", 1)
node["label"] = nd.get("label", node.get("name", ""))
node["format"] = nd.get("format", "{{msg.payload}}")
node["layout"] = nd.get("layout", "row-spread")
node["className"] = ""
if node["type"] in ("ui-button",):
node["payload"] = json.dumps(nd.get("payload", ""))
node["payloadType"] = "json"
node["topic"] = nd.get("topic", "")
node["width"] = "3"
node["height"] = "1"
node["order"] = nd.get("order", 1)
node["className"] = ""
if ntype == "debug":
node["name"] = nd.get("label", "Debug")
node["active"] = True
node["tosidebar"] = True
node["complete"] = "payload"
if ntype == "function":
node["name"] = nd.get("label", nd.get("name", "Function"))
node["func"] = nd["code"]
node["outputs"] = nd.get("outputs", 1)
node["noerr"] = 0
node["initialize"] = ""
node["finalize"] = ""
node["libs"] = []
return node
def build_flow(doc, flow_def, node_positions):
nodes = []
broker_map = {}
for nd in flow_def["nodes"]:
if nd["type"] in ("mqtt in", "mqtt-in", "mqtt out", "mqtt-out") and "broker" in nd:
bname = nd["broker"]
if bname not in broker_map:
bn = make_broker_node(bname, doc["brokers"][bname])
broker_map[bname] = bn["id"]
nodes.append(bn)
group_map = {}
for db in doc.get("dashboards", []):
bid = new_id("uib")
tid = new_id("uit")
nodes.append({
"id": bid, "type": "ui-base", "name": db["base"],
"path": db.get("path", "/dashboard"),
"includeClientData": True,
"acceptsClientConfig": ["ui-notification", "ui-control"],
"showPathInSidebar": False,
"navigationStyle": "default",
"titleBarStyle": "default",
})
nodes.append({
"id": tid, "type": "ui-theme",
"name": db.get("theme", "Default Theme"),
"colors": db.get("themeColors", THEME_DEFAULTS["colors"]),
"sizes": db.get("themeSizes", THEME_DEFAULTS["sizes"]),
})
for pi, page in enumerate(db.get("pages", [])):
pid = new_id("uip")
nodes.append({
"id": pid, "type": "ui-page",
"name": page["page"], "ui": bid,
"icon": page.get("icon", "home"),
"path": page.get("path", "/home"),
"theme": page.get("theme", tid),
"layout": page.get("layout", "flex"),
"order": pi,
"className": "", "visible": True, "disabled": False,
})
for gi, grp in enumerate(page.get("groups", [])):
gid = new_id("uig")
nodes.append({
"id": gid, "type": "ui-group",
"name": grp["name"], "page": pid,
"width": str(grp.get("width", 6)),
"height": "1",
"order": gi + 1,
"showTitle": True,
"className": "", "visible": True, "disabled": False,
})
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", {})
for src_id, targets in wires.items():
src = next((n for n in nodes if n["id"] == src_id), None)
if src:
src.setdefault("wires", [])
src["wires"].append(targets)
return nodes
def auto_position(flow_def):
col_order = {"mqtt in": 0, "ui-button": 0, "inject": 0,
"debug": 2, "ui-text": 2, "mqtt out": 2}
positions = {}
for i, nd in enumerate(flow_def["nodes"]):
col = col_order.get(nd["type"], 1)
positions[nd["id"]] = (col, i // 3)
return positions
def main():
doc = yaml.safe_load(open(sys.argv[1]))
all_nodes = []
for flow_def in doc.get("flows", []):
positions = auto_position(flow_def)
all_nodes += build_flow(doc, flow_def, positions)
json.dump(all_nodes, sys.stdout, indent=2)
if __name__ == "__main__":
main()