diff --git a/flows/zigbee-monitor.yaml b/flows/zigbee-monitor.yaml index 9d35b84..02549ae 100644 --- a/flows/zigbee-monitor.yaml +++ b/flows/zigbee-monitor.yaml @@ -5,44 +5,112 @@ brokers: host: mosquitto port: 1883 +plugs: + - Squiggle + - Sideboard Lamp + - Bamboo Lights + dashboards: - - tab: Home - icon: dashboard - groups: - - name: Zigbee - width: 6 + - base: Dashboard + pages: + - page: Home + icon: home + path: /home + groups: + - name: Plugs + width: 6 flows: - tab: Zigbee Monitor description: Monitor and control zigbee2mqtt coordinator nodes: - - id: zigbee-in - type: mqtt-in + - id: zigbee-state-squiggle + type: mqtt in broker: mosquitto - topic: zigbee2mqtt/# - qos: 2 + topic: zigbee2mqtt/Squiggle + qos: 0 - - id: debug - type: debug + - id: zigbee-state-sideboard + type: mqtt in + broker: mosquitto + topic: zigbee2mqtt/Sideboard Lamp + qos: 0 - - id: last-msg + - id: zigbee-state-bamboo + type: mqtt in + broker: mosquitto + topic: zigbee2mqtt/Bamboo Lights + qos: 0 + + - id: button-in + type: mqtt in + broker: mosquitto + topic: zigbee2mqtt/Door Button + qos: 0 + + - id: btn-filter + type: function + name: Single press only + code: | + try { + const p = JSON.parse(msg.payload); + if (p.action === 'single') { + msg.payload = { state: 'TOGGLE' }; + return msg; + } + } catch (e) {} + return null; + + - id: plug-out-squiggle + type: mqtt out + broker: mosquitto + topic: zigbee2mqtt/Squiggle/set + + - id: plug-out-sideboard + type: mqtt out + broker: mosquitto + topic: zigbee2mqtt/Sideboard Lamp/set + + - id: plug-out-bamboo + type: mqtt out + broker: mosquitto + topic: zigbee2mqtt/Bamboo Lights/set + + - id: text-squiggle type: ui-text - group: Zigbee - format: "{{msg.payload|json}}" + group: Plugs + label: Squiggle + format: "{{msg.payload.state}}" + + - id: text-sideboard + type: ui-text + group: Plugs + label: Sideboard Lamp + format: "{{msg.payload.state}}" + + - id: text-bamboo + type: ui-text + group: Plugs + label: Bamboo Lights + format: "{{msg.payload.state}}" - id: permit-btn type: ui-button - group: Zigbee - label: "Permit Join (60s)" + group: Plugs + label: Permit Join (60s) topic: zigbee2mqtt/bridge/request/permit_join payload: value: true time: 60 - id: mqtt-out - type: mqtt-out + type: mqtt out broker: mosquitto wires: - zigbee-in: [debug, last-msg] + zigbee-state-squiggle: [text-squiggle] + zigbee-state-sideboard: [text-sideboard] + zigbee-state-bamboo: [text-bamboo] + button-in: [btn-filter] + btn-filter: [plug-out-squiggle, plug-out-sideboard, plug-out-bamboo] permit-btn: [mqtt-out] diff --git a/output/zigbee-monitor.json b/output/zigbee-monitor.json index 42e8517..c280dcb 100644 --- a/output/zigbee-monitor.json +++ b/output/zigbee-monitor.json @@ -1,7 +1,6 @@ [ { - "id": "br1f38755c", - "z": "", + "id": "br9dfef2b2", "type": "mqtt-broker", "name": "mosquitto", "broker": "mosquitto", @@ -23,110 +22,275 @@ "willPayload": "" }, { - "id": "t66ef1944", - "z": "", - "type": "ui_tab", + "id": "uibba9906e7", + "type": "ui-base", + "name": "Dashboard", + "path": "/dashboard", + "includeClientData": true, + "acceptsClientConfig": [ + "ui-notification", + "ui-control" + ], + "showPathInSidebar": false, + "navigationStyle": "default", + "titleBarStyle": "default" + }, + { + "id": "uitcdb20b5e", + "type": "ui-theme", + "name": "Default Theme", + "colors": { + "surface": "#ffffff", + "primary": "#0094CE", + "bgPage": "#eeeeee", + "groupBg": "#ffffff", + "groupOutline": "#cccccc" + }, + "sizes": { + "pagePadding": "12px", + "groupGap": "12px", + "groupBorderRadius": "4px", + "widgetGap": "12px" + } + }, + { + "id": "uipb062fd21", + "type": "ui-page", "name": "Home", - "icon": "dashboard", - "order": 1, - "disabled": false, - "hidden": false + "ui": "uibba9906e7", + "icon": "home", + "path": "/home", + "theme": "uitcdb20b5e", + "layout": "flex", + "order": 0, + "className": "", + "visible": true, + "disabled": false }, { - "id": "gc40a46a5", - "z": "t66ef1944", - "type": "ui_group", - "name": "Zigbee", - "tab": "t66ef1944", + "id": "uig1ba3cd58", + "type": "ui-group", + "name": "Plugs", + "page": "uipb062fd21", + "width": "6", + "height": "1", "order": 1, - "disp": true, - "width": "6" + "showTitle": true, + "className": "", + "visible": true, + "disabled": false }, { - "id": "zigbee-in", - "z": "f44444498", - "type": "mqtt-in", - "name": "", - "nl": false, - "rap": true, - "rh": 0, - "inputs": 0, - "datatype": "auto", - "x": 170, - "y": 80, - "broker": "br1f38755c", - "topic": "zigbee2mqtt/#", - "qos": "2", + "id": "zigbee-state-squiggle", + "z": "f11689cf1", + "type": "mqtt in", "wires": [ [ - "debug", - "last-msg" + "text-squiggle" ] - ] + ], + "name": "", + "x": 170, + "y": 80, + "broker": "br9dfef2b2", + "topic": "zigbee2mqtt/Squiggle", + "qos": "0", + "datatype": "auto" }, { - "id": "debug", - "z": "f44444498", - "type": "debug", - "name": "Debug", - "active": true, - "tosidebar": true, - "console": false, - "tostatus": false, - "complete": "payload", - "targetType": "msg", - "statusVal": "", - "statusType": "auto", + "id": "zigbee-state-sideboard", + "z": "f11689cf1", + "type": "mqtt in", + "wires": [ + [ + "text-sideboard" + ] + ], + "name": "", + "x": 170, + "y": 80, + "broker": "br9dfef2b2", + "topic": "zigbee2mqtt/Sideboard Lamp", + "qos": "0", + "datatype": "auto" + }, + { + "id": "zigbee-state-bamboo", + "z": "f11689cf1", + "type": "mqtt in", + "wires": [ + [ + "text-bamboo" + ] + ], + "name": "", + "x": 170, + "y": 80, + "broker": "br9dfef2b2", + "topic": "zigbee2mqtt/Bamboo Lights", + "qos": "0", + "datatype": "auto" + }, + { + "id": "button-in", + "z": "f11689cf1", + "type": "mqtt in", + "wires": [ + [ + "btn-filter" + ] + ], + "name": "", + "x": 170, + "y": 220, + "broker": "br9dfef2b2", + "topic": "zigbee2mqtt/Door Button", + "qos": "0", + "datatype": "auto" + }, + { + "id": "btn-filter", + "z": "f11689cf1", + "type": "function", + "wires": [ + [ + "plug-out-squiggle", + "plug-out-sideboard", + "plug-out-bamboo" + ] + ], + "name": "Single press only", + "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", + "outputs": 1, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [] + }, + { + "id": "plug-out-squiggle", + "z": "f11689cf1", + "type": "mqtt out", + "wires": [], + "name": "", "x": 770, - "y": 80 + "y": 220, + "broker": "br9dfef2b2", + "topic": "zigbee2mqtt/Squiggle/set", + "qos": "2", + "retain": false }, { - "id": "last-msg", - "z": "f44444498", + "id": "plug-out-sideboard", + "z": "f11689cf1", + "type": "mqtt out", + "wires": [], + "name": "", + "x": 770, + "y": 360, + "broker": "br9dfef2b2", + "topic": "zigbee2mqtt/Sideboard Lamp/set", + "qos": "2", + "retain": false + }, + { + "id": "plug-out-bamboo", + "z": "f11689cf1", + "type": "mqtt out", + "wires": [], + "name": "", + "x": 770, + "y": 360, + "broker": "br9dfef2b2", + "topic": "zigbee2mqtt/Bamboo Lights/set", + "qos": "2", + "retain": false + }, + { + "id": "text-squiggle", + "z": "f11689cf1", "type": "ui-text", - "name": "Text", + "wires": [], + "name": "Squiggle", + "x": 770, + "y": 360, + "group": "uig1ba3cd58", "width": "6", "height": "2", + "order": 1, + "label": "Squiggle", + "format": "{{msg.payload.state}}", "layout": "row-spread", + "className": "" + }, + { + "id": "text-sideboard", + "z": "f11689cf1", + "type": "ui-text", + "wires": [], + "name": "Sideboard Lamp", "x": 770, - "y": 80, - "group": "gc40a46a5", - "format": "{{msg.payload|json}}" + "y": 500, + "group": "uig1ba3cd58", + "width": "6", + "height": "2", + "order": 1, + "label": "Sideboard Lamp", + "format": "{{msg.payload.state}}", + "layout": "row-spread", + "className": "" + }, + { + "id": "text-bamboo", + "z": "f11689cf1", + "type": "ui-text", + "wires": [], + "name": "Bamboo Lights", + "x": 770, + "y": 500, + "group": "uig1ba3cd58", + "width": "6", + "height": "2", + "order": 1, + "label": "Bamboo Lights", + "format": "{{msg.payload.state}}", + "layout": "row-spread", + "className": "" }, { "id": "permit-btn", - "z": "f44444498", + "z": "f11689cf1", "type": "ui-button", - "name": "Permit Join (60s)", - "width": "3", - "height": "1", - "payloadType": "json", - "x": 170, - "y": 220, - "group": "gc40a46a5", - "payload": "{\"value\": true, \"time\": 60}", - "topic": "zigbee2mqtt/bridge/request/permit_join", "wires": [ [ "mqtt-out" ] - ] + ], + "name": "Permit Join (60s)", + "x": 170, + "y": 500, + "group": "uig1ba3cd58", + "payload": "{\"value\": true, \"time\": 60}", + "payloadType": "json", + "topic": "zigbee2mqtt/bridge/request/permit_join", + "width": "3", + "height": "1", + "order": 1, + "className": "" }, { "id": "mqtt-out", - "z": "f44444498", - "type": "mqtt-out", + "z": "f11689cf1", + "type": "mqtt out", + "wires": [], "name": "", - "qos": "2", - "retain": false, - "respTopic": "", - "contentType": "", - "userProps": "", - "correl": "", - "expiry": "", "x": 770, - "y": 220, - "broker": "br1f38755c", - "topic": "" + "y": 640, + "broker": "br9dfef2b2", + "topic": "", + "qos": "2", + "retain": false } ] \ No newline at end of file diff --git a/policy/flow.rego b/policy/flow.rego index 9155fe0..a480c6b 100644 --- a/policy/flow.rego +++ b/policy/flow.rego @@ -4,8 +4,8 @@ import future.keywords.contains import future.keywords.in # helpers -config_node(typ) if typ in {"mqtt-broker", "ui_tab", "ui_base"} -welcome_isolated(typ) if typ in {"debug", "mqtt-broker", "ui_tab", "ui_group", "ui_text", "ui_chart", "ui_gauge", "ui_button", "ui_slider", "ui_switch", "inject", "comment", "mqtt in", "catch", "status", "link in"} +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"} broker_node(id) if { some node in input @@ -13,18 +13,6 @@ broker_node(id) if { node.id == id } -ui_group(id) if { - some node in input - node.type == "ui_group" - node.id == id -} - -ui_tab(id) if { - some node in input - node.type == "ui_tab" - node.id == id -} - node_exists(id) if { some node in input node.id == id @@ -89,7 +77,7 @@ deny_missing_field contains msg if { deny_missing_field contains msg if { some node in input - node.type in {"ui_text", "ui_button", "ui_chart", "ui_gauge", "ui_slider", "ui_switch"} + node.type in {"ui-text", "ui-button", "ui-chart", "ui-gauge", "ui-switch", "ui-slider", "ui-dropdown"} required := {"type", "group", "z"} some f in required not node[f] @@ -98,20 +86,29 @@ deny_missing_field contains msg if { deny_missing_field contains msg if { some node in input - node.type == "ui_group" - required := {"type", "tab", "name"} + node.type == "ui-group" + required := {"type", "page", "name"} some f in required not node[f] - msg := sprintf("node %s (ui_group): missing %s", [node.id, f]) + msg := sprintf("node %s (ui-group): missing %s", [node.id, f]) } deny_missing_field contains msg if { some node in input - node.type == "ui_tab" + node.type == "ui-page" + required := {"type", "name", "ui"} + some f in required + not node[f] + msg := sprintf("node %s (ui-page): missing %s", [node.id, f]) +} + +deny_missing_field contains msg if { + some node in input + node.type == "ui-base" required := {"type", "name"} some f in required not node[f] - msg := sprintf("node %s (ui_tab): missing %s", [node.id, f]) + msg := sprintf("node %s (ui-base): missing %s", [node.id, f]) } deny_broker_ref contains msg if { @@ -121,18 +118,30 @@ deny_broker_ref contains msg if { msg := sprintf("node %s (%s): references unknown broker %s", [node.id, node.type, node.broker]) } +ui_page(id) if { + some node in input + node.type == "ui-page" + node.id == id +} + +ui_group(id) if { + some node in input + node.type == "ui-group" + node.id == id +} + deny_group_ref contains msg if { some node in input - node.type in {"ui_text", "ui_button", "ui_chart", "ui_gauge", "ui_slider", "ui_switch"} + node.type in {"ui-text", "ui-button", "ui-chart", "ui-gauge", "ui-switch", "ui-slider", "ui-dropdown"} not ui_group(node.group) msg := sprintf("node %s (%s): references unknown group %s", [node.id, node.type, node.group]) } -deny_tab_ref contains msg if { +deny_page_ref contains msg if { some node in input - node.type == "ui_group" - not ui_tab(node.tab) - msg := sprintf("node %s (ui_group): references unknown tab %s", [node.id, node.tab]) + node.type == "ui-group" + not ui_page(node.page) + msg := sprintf("node %s (ui-group): references unknown page %s", [node.id, node.page]) } deny_wire_ref contains msg if { @@ -158,7 +167,6 @@ deny_isolated_node contains msg if { msg := sprintf("node %s (%s): has no wires", [node.id, node.type]) } -# MQTT brokers must have valid port deny_broker_port contains msg if { some node in input node.type == "mqtt-broker" diff --git a/scripts/flow2json.py b/scripts/flow2json.py index 3de62e7..411bbce 100644 --- a/scripts/flow2json.py +++ b/scripts/flow2json.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Convert declarative flow YAML to Node-RED flow JSON.""" +"""Convert declarative flow YAML to Node-RED flow JSON (Dashboard 2.0).""" import json import sys import uuid @@ -14,26 +14,11 @@ BROKER_DEFAULTS = { "closeTopic": "", "closeQos": "0", "closePayload": "", "willTopic": "", "willQos": "0", "willPayload": "", } - -NODE_TEMPLATES = { - "mqtt-in": { - "nl": False, "rap": True, "rh": 0, "inputs": 0, "datatype": "auto", - }, - "mqtt-out": { - "qos": "2", "retain": False, "respTopic": "", "contentType": "", - "userProps": "", "correl": "", "expiry": "", - }, - "debug": { - "active": True, "tosidebar": True, "console": False, - "tostatus": False, "complete": "payload", "targetType": "msg", - "statusVal": "", "statusType": "auto", - }, - "ui-text": { - "width": "6", "height": "2", "layout": "row-spread", - }, - "ui-button": { - "width": "3", "height": "1", "payloadType": "json", - }, +THEME_DEFAULTS = { + "colors": {"surface": "#ffffff", "primary": "#0094CE", "bgPage": "#eeeeee", + "groupBg": "#ffffff", "groupOutline": "#cccccc"}, + "sizes": {"pagePadding": "12px", "groupGap": "12px", + "groupBorderRadius": "4px", "widgetGap": "12px"}, } @@ -41,110 +26,144 @@ def new_id(prefix="n"): return prefix + uuid.uuid4().hex[:8] -def make_broker_node(name, cfg, flow_id): - node = {"id": new_id("br"), "z": "", "type": "mqtt-broker", "name": name} +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)) - for k, v in BROKER_DEFAULTS.items(): - node.setdefault(k, v) + node.update(BROKER_DEFAULTS) return node -def make_node(nd, col, row, broker_map, group_map, flow_id): +def make_node(nd, col, row, broker_map, group_map): ntype = nd["type"] - nid = nd["id"] if "id" in nd else new_id() - node = {"id": nid, "z": flow_id, "type": ntype, "name": nd.get("label", nd.get("name", ""))} - node.update(NODE_TEMPLATES.get(ntype, {})) + nid = nd.get("id", new_id()) + node = {"id": nid, "z": FLOW_ID, "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-out"): + if ntype in ("mqtt in", "mqtt-in", "mqtt out", "mqtt-out"): node["broker"] = broker_map[nd["broker"]] - if ntype in ("ui-text", "ui-button"): - node["group"] = group_map[nd["group"]] - if ntype == "mqtt-in": + if ntype in ("mqtt in", "mqtt-in"): node["topic"] = nd["topic"] node["qos"] = str(nd.get("qos", 2)) - if ntype == "mqtt-out": + node["datatype"] = "auto" + if ntype in ("mqtt out", "mqtt-out"): node["topic"] = nd.get("topic", "") node["qos"] = str(nd.get("qos", 2)) - if ntype == "ui-text": + 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["name"] = nd.get("label", nd.get("name", "Text")) - if ntype == "ui-button": + 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["name"] = nd.get("label", nd.get("name", "Button")) + node["width"] = "3" + node["height"] = "1" + node["order"] = nd.get("order", 1) + node["className"] = "" if ntype == "debug": - node["name"] = nd.get("label", nd.get("name", "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 make_group_node(name, tab_id, order): - return { - "id": new_id("g"), "z": tab_id, "type": "ui_group", - "name": name, "tab": tab_id, "order": order, - "disp": True, "width": "6", - } - - -def make_tab_node(name, icon, order): - tab_id = new_id("t") - return tab_id, { - "id": tab_id, "z": "", "type": "ui_tab", - "name": name, "icon": icon, "order": order, - "disabled": False, "hidden": False, - } - - def build_flow(doc, flow_def, node_positions): nodes = [] - flow_id = new_id("f") - # Collect broker references broker_map = {} for nd in flow_def["nodes"]: - if nd["type"] in ("mqtt-in", "mqtt-out") and "broker" in nd: + 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: - bcfg = doc["brokers"][bname] - bn = make_broker_node(bname, bcfg, flow_id) + bn = make_broker_node(bname, doc["brokers"][bname]) broker_map[bname] = bn["id"] nodes.append(bn) - # Collect dashboard groups/tabs group_map = {} - if "dashboards" in doc: - for db in doc["dashboards"]: - tab_id, tn = make_tab_node(db["tab"], db.get("icon", "dashboard"), db.get("order", 1)) - nodes.append(tn) - for gi, grp in enumerate(db.get("groups", [])): - gn = make_group_node(grp["name"], tab_id, gi + 1) - group_map[grp["name"]] = gn["id"] - nodes.append(gn) + 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 - # Create flow nodes with auto-positioning 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, flow_id) + node = make_node(nd, col, row, broker_map, group_map) nodes.append(node) - # Wire connections wires = flow_def.get("wires", {}) for src_id, targets in wires.items(): - src = next(n for n in nodes if n.get("id") == src_id or n.get("name", "").lower().replace(" ", "-") == src_id) - src.setdefault("wires", []) - src["wires"].append(targets) + 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): - """Simple auto-layout: put each node in a column determined by its role.""" + col_order = {"mqtt in": 0, "ui-button": 0, "inject": 0, + "debug": 2, "ui-text": 2, "mqtt out": 2} positions = {} - col_order = {"mqtt-in": 0, "ui-button": 0, "inject": 0, - "debug": 2, "ui-text": 2, "mqtt-out": 2, "ui-chart": 2, "ui-gauge": 2} for i, nd in enumerate(flow_def["nodes"]): col = col_order.get(nd["type"], 1) positions[nd["id"]] = (col, i // 3)