b9c53c1a23
Add build_plugs() to flow2json.py: a flow can declare a plugs: table
({id, name, channels?}) plus single_plugs/hold_plugs lists and the
generator emits all plumbing automatically — state-in node, /set out node,
dashboard widget, and (for multi-channel devices) a mapper function that
toggles each channel (state_1/state_2) instead of the ignored shared
{state:TOGGLE} payload.
Rewrite zigbee-monitor.yaml to the declarative form; Dual Outlet - Couch
is multi-channel (channels: [1,2]). Adding a plug is one line, no manual
node/wire duplication. Single press toggles 5 devices (couch via mapper
verified E2E ON/OFF), hold-release still DJ Booth only.
Refs: david/nr-flow-validator#2
321 lines
11 KiB
Python
321 lines
11 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 build_plugs(flow_def, doc, broker_map, group_map):
|
|
"""Expand a declarative `plugs:` list into state-in/out/widget nodes and wiring.
|
|
|
|
Each plug entry: {id, name, channels?}.
|
|
- single channel: widget shows {{msg.payload.state}}
|
|
- multi-channel (channels: [1, 2]): a mapper function is emitted before the
|
|
plug-out so the device toggles per-channel, and the widget shows each state_N.
|
|
|
|
single_plugs / hold_plugs are lists of plug ids driven by the function nodes
|
|
named single_filter / hold_filter respectively.
|
|
"""
|
|
if "plugs" not in flow_def:
|
|
return [], {}
|
|
|
|
_flow_order["text-order-counter"] = 0
|
|
|
|
plug_cfg = {p["id"]: p for p in flow_def["plugs"]}
|
|
|
|
def state_keys(p):
|
|
return [int(k) for k in p.get("channels", [])] if p.get("channels") else []
|
|
|
|
nodes = []
|
|
wires = {}
|
|
|
|
def wire(src, target):
|
|
wires.setdefault(src, []).append(target)
|
|
|
|
def channel_mapper_id(pid):
|
|
return f"mapper-{pid}"
|
|
|
|
for key in ("single_plugs", "hold_plugs"):
|
|
filter_id = flow_def.get("single_filter" if key == "single_plugs" else "hold_filter")
|
|
if not filter_id:
|
|
raise ValueError(f"flow missing {'single_filter' if key == 'single_plugs' else 'hold_filter'} for {key}")
|
|
for pid in flow_def.get(key, []):
|
|
p = plug_cfg[pid]
|
|
name = p["name"]
|
|
keys = state_keys(p)
|
|
|
|
sid = f"zigbee-state-{pid}"
|
|
oid = f"plug-out-{pid}"
|
|
tid = f"text-{pid}"
|
|
|
|
nodes.append({
|
|
"id": sid, "type": "mqtt in", "wires": [], "name": "",
|
|
"broker": broker_map[_broker(flow_def, doc)],
|
|
"topic": f"zigbee2mqtt/{name}", "qos": "0", "datatype": "auto",
|
|
})
|
|
|
|
fmt = "{{msg.payload.state}}"
|
|
if keys:
|
|
parts = [f"{{{{msg.payload.state_{k}}}}}" for k in keys]
|
|
fmt = " / ".join(parts)
|
|
nodes.append({
|
|
"id": tid, "type": "ui-text", "wires": [], "name": name,
|
|
"group": group_map[_group(flow_def)],
|
|
"width": "6", "height": "2", "order": _order(flow_def),
|
|
"label": name, "format": fmt, "layout": "row-spread", "className": "",
|
|
})
|
|
|
|
target = oid
|
|
if keys:
|
|
mid = channel_mapper_id(pid)
|
|
code = ", ".join(f"state_{k}: 'TOGGLE'" for k in keys)
|
|
nodes.append({
|
|
"id": mid, "type": "function", "wires": [], "name": "Toggle each outlet",
|
|
"func": f"msg.payload = {{{code}}};\nreturn msg;",
|
|
"outputs": 1, "noerr": 0, "initialize": "", "finalize": "", "libs": [],
|
|
})
|
|
wire(filter_id, mid)
|
|
wire(mid, oid)
|
|
else:
|
|
wire(filter_id, target)
|
|
|
|
nodes.append({
|
|
"id": oid, "type": "mqtt out", "wires": [], "name": "",
|
|
"broker": broker_map[_broker(flow_def, doc)],
|
|
"topic": f"zigbee2mqtt/{name}/set", "qos": "2", "retain": False,
|
|
})
|
|
|
|
wire(sid, tid)
|
|
|
|
return nodes, wires
|
|
|
|
|
|
def _broker(flow_def, doc):
|
|
# broker used by generated plug nodes: first broker referenced by any node, else first defined
|
|
for nd in flow_def.get("nodes", []):
|
|
if nd.get("broker"):
|
|
return nd["broker"]
|
|
brokers = list(doc.get("brokers", {}))
|
|
if not brokers:
|
|
raise ValueError("no broker defined")
|
|
return brokers[0]
|
|
|
|
|
|
def _group(flow_def):
|
|
for nd in flow_def.get("nodes", []):
|
|
if nd.get("group"):
|
|
return nd["group"]
|
|
raise ValueError("no dashboard group defined")
|
|
|
|
|
|
_flow_order = {"text-order-counter": 0}
|
|
|
|
|
|
def _order(flow_def):
|
|
_flow_order["text-order-counter"] += 1
|
|
return _flow_order["text-order-counter"]
|
|
|
|
|
|
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": [],
|
|
})
|
|
|
|
plug_nodes, plug_wires = build_plugs(flow_def, doc, broker_map, group_map)
|
|
renum = 0
|
|
for pn in plug_nodes:
|
|
pn["z"] = FLOW_ID
|
|
col = {"mqtt in": 0, "function": 1, "mqtt out": 2, "ui-text": 2}.get(
|
|
pn.get("type"), 0)
|
|
pn["x"] = COLUMNS[col]
|
|
pn["y"] = 80 + (renum * ROW_HEIGHT // 2)
|
|
renum += 1
|
|
nodes.append(pn)
|
|
|
|
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 = dict(flow_def.get("wires", {}))
|
|
for k, v in plug_wires.items():
|
|
wires.setdefault(k, []).extend(v)
|
|
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()
|