#!/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()