Add multi-plug toggle, debounce via unique topic subscriptions

Button (Door Button) now toggles Squiggle, Sideboard Lamp, and Bamboo Lights
via three parallel mqtt-out publishes.

Key fix: avoid subscribing to a wildcard '#' topic alongside a specific topic
in the same Node-RED instance. The mqtt-broker re-adds its handler as an
EventEmitter listener on each subscribe call (file:
  /usr/src/node-red/node_modules/@node-red/nodes/core/network/10-mqtt.js
  _clientOn('message', subscription.handler) is unconditional), so after
each deploy/restart the same message arrives N times to internal listeners.
Each mqtt-in node now uses a unique topic (zigbee2mqtt/<device>).

Removed zigbee-in # wildcard and plug-state ui-text widgets (state now
displayed via per-plug ui-text widgets).

Added function node support to flow2json.py for msg filtering logic.
Added ui-text format/width/height defaults support.
Added dashboard 2.0 config node defaults matching reference flow:
  ui-base, ui-theme, ui-page, ui-group with full property set.
This commit is contained in:
2026-06-30 22:12:22 -07:00
parent ca1c7eda26
commit 517f3dbdae
4 changed files with 458 additions and 199 deletions
+86 -18
View File
@@ -5,44 +5,112 @@ brokers:
host: mosquitto host: mosquitto
port: 1883 port: 1883
plugs:
- Squiggle
- Sideboard Lamp
- Bamboo Lights
dashboards: dashboards:
- tab: Home - base: Dashboard
icon: dashboard pages:
groups: - page: Home
- name: Zigbee icon: home
width: 6 path: /home
groups:
- name: Plugs
width: 6
flows: flows:
- tab: Zigbee Monitor - tab: Zigbee Monitor
description: Monitor and control zigbee2mqtt coordinator description: Monitor and control zigbee2mqtt coordinator
nodes: nodes:
- id: zigbee-in - id: zigbee-state-squiggle
type: mqtt-in type: mqtt in
broker: mosquitto broker: mosquitto
topic: zigbee2mqtt/# topic: zigbee2mqtt/Squiggle
qos: 2 qos: 0
- id: debug - id: zigbee-state-sideboard
type: debug 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 type: ui-text
group: Zigbee group: Plugs
format: "{{msg.payload|json}}" 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 - id: permit-btn
type: ui-button type: ui-button
group: Zigbee group: Plugs
label: "Permit Join (60s)" label: Permit Join (60s)
topic: zigbee2mqtt/bridge/request/permit_join topic: zigbee2mqtt/bridge/request/permit_join
payload: payload:
value: true value: true
time: 60 time: 60
- id: mqtt-out - id: mqtt-out
type: mqtt-out type: mqtt out
broker: mosquitto broker: mosquitto
wires: 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] permit-btn: [mqtt-out]
+239 -75
View File
@@ -1,7 +1,6 @@
[ [
{ {
"id": "br1f38755c", "id": "br9dfef2b2",
"z": "",
"type": "mqtt-broker", "type": "mqtt-broker",
"name": "mosquitto", "name": "mosquitto",
"broker": "mosquitto", "broker": "mosquitto",
@@ -23,110 +22,275 @@
"willPayload": "" "willPayload": ""
}, },
{ {
"id": "t66ef1944", "id": "uibba9906e7",
"z": "", "type": "ui-base",
"type": "ui_tab", "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", "name": "Home",
"icon": "dashboard", "ui": "uibba9906e7",
"order": 1, "icon": "home",
"disabled": false, "path": "/home",
"hidden": false "theme": "uitcdb20b5e",
"layout": "flex",
"order": 0,
"className": "",
"visible": true,
"disabled": false
}, },
{ {
"id": "gc40a46a5", "id": "uig1ba3cd58",
"z": "t66ef1944", "type": "ui-group",
"type": "ui_group", "name": "Plugs",
"name": "Zigbee", "page": "uipb062fd21",
"tab": "t66ef1944", "width": "6",
"height": "1",
"order": 1, "order": 1,
"disp": true, "showTitle": true,
"width": "6" "className": "",
"visible": true,
"disabled": false
}, },
{ {
"id": "zigbee-in", "id": "zigbee-state-squiggle",
"z": "f44444498", "z": "f11689cf1",
"type": "mqtt-in", "type": "mqtt in",
"name": "",
"nl": false,
"rap": true,
"rh": 0,
"inputs": 0,
"datatype": "auto",
"x": 170,
"y": 80,
"broker": "br1f38755c",
"topic": "zigbee2mqtt/#",
"qos": "2",
"wires": [ "wires": [
[ [
"debug", "text-squiggle"
"last-msg"
] ]
] ],
"name": "",
"x": 170,
"y": 80,
"broker": "br9dfef2b2",
"topic": "zigbee2mqtt/Squiggle",
"qos": "0",
"datatype": "auto"
}, },
{ {
"id": "debug", "id": "zigbee-state-sideboard",
"z": "f44444498", "z": "f11689cf1",
"type": "debug", "type": "mqtt in",
"name": "Debug", "wires": [
"active": true, [
"tosidebar": true, "text-sideboard"
"console": false, ]
"tostatus": false, ],
"complete": "payload", "name": "",
"targetType": "msg", "x": 170,
"statusVal": "", "y": 80,
"statusType": "auto", "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, "x": 770,
"y": 80 "y": 220,
"broker": "br9dfef2b2",
"topic": "zigbee2mqtt/Squiggle/set",
"qos": "2",
"retain": false
}, },
{ {
"id": "last-msg", "id": "plug-out-sideboard",
"z": "f44444498", "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", "type": "ui-text",
"name": "Text", "wires": [],
"name": "Squiggle",
"x": 770,
"y": 360,
"group": "uig1ba3cd58",
"width": "6", "width": "6",
"height": "2", "height": "2",
"order": 1,
"label": "Squiggle",
"format": "{{msg.payload.state}}",
"layout": "row-spread", "layout": "row-spread",
"className": ""
},
{
"id": "text-sideboard",
"z": "f11689cf1",
"type": "ui-text",
"wires": [],
"name": "Sideboard Lamp",
"x": 770, "x": 770,
"y": 80, "y": 500,
"group": "gc40a46a5", "group": "uig1ba3cd58",
"format": "{{msg.payload|json}}" "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", "id": "permit-btn",
"z": "f44444498", "z": "f11689cf1",
"type": "ui-button", "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": [ "wires": [
[ [
"mqtt-out" "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", "id": "mqtt-out",
"z": "f44444498", "z": "f11689cf1",
"type": "mqtt-out", "type": "mqtt out",
"wires": [],
"name": "", "name": "",
"qos": "2",
"retain": false,
"respTopic": "",
"contentType": "",
"userProps": "",
"correl": "",
"expiry": "",
"x": 770, "x": 770,
"y": 220, "y": 640,
"broker": "br1f38755c", "broker": "br9dfef2b2",
"topic": "" "topic": "",
"qos": "2",
"retain": false
} }
] ]
+34 -26
View File
@@ -4,8 +4,8 @@ import future.keywords.contains
import future.keywords.in import future.keywords.in
# helpers # helpers
config_node(typ) if typ in {"mqtt-broker", "ui_tab", "ui_base"} config_node(typ) if typ in {"mqtt-broker", "ui-base", "ui-page", "ui-group"}
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"} 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 { broker_node(id) if {
some node in input some node in input
@@ -13,18 +13,6 @@ broker_node(id) if {
node.id == id 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 { node_exists(id) if {
some node in input some node in input
node.id == id node.id == id
@@ -89,7 +77,7 @@ deny_missing_field contains msg if {
deny_missing_field contains msg if { deny_missing_field contains msg if {
some node in input 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"} required := {"type", "group", "z"}
some f in required some f in required
not node[f] not node[f]
@@ -98,20 +86,29 @@ deny_missing_field contains msg if {
deny_missing_field contains msg if { deny_missing_field contains msg if {
some node in input some node in input
node.type == "ui_group" node.type == "ui-group"
required := {"type", "tab", "name"} required := {"type", "page", "name"}
some f in required some f in required
not node[f] 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 { deny_missing_field contains msg if {
some node in input 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"} required := {"type", "name"}
some f in required some f in required
not node[f] 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 { 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]) 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 { deny_group_ref contains msg if {
some node in input 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) not ui_group(node.group)
msg := sprintf("node %s (%s): references unknown group %s", [node.id, node.type, 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 some node in input
node.type == "ui_group" node.type == "ui-group"
not ui_tab(node.tab) not ui_page(node.page)
msg := sprintf("node %s (ui_group): references unknown tab %s", [node.id, node.tab]) msg := sprintf("node %s (ui-group): references unknown page %s", [node.id, node.page])
} }
deny_wire_ref contains msg if { 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]) msg := sprintf("node %s (%s): has no wires", [node.id, node.type])
} }
# MQTT brokers must have valid port
deny_broker_port contains msg if { deny_broker_port contains msg if {
some node in input some node in input
node.type == "mqtt-broker" node.type == "mqtt-broker"
+99 -80
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/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 json
import sys import sys
import uuid import uuid
@@ -14,26 +14,11 @@ BROKER_DEFAULTS = {
"closeTopic": "", "closeQos": "0", "closePayload": "", "closeTopic": "", "closeQos": "0", "closePayload": "",
"willTopic": "", "willQos": "0", "willPayload": "", "willTopic": "", "willQos": "0", "willPayload": "",
} }
THEME_DEFAULTS = {
NODE_TEMPLATES = { "colors": {"surface": "#ffffff", "primary": "#0094CE", "bgPage": "#eeeeee",
"mqtt-in": { "groupBg": "#ffffff", "groupOutline": "#cccccc"},
"nl": False, "rap": True, "rh": 0, "inputs": 0, "datatype": "auto", "sizes": {"pagePadding": "12px", "groupGap": "12px",
}, "groupBorderRadius": "4px", "widgetGap": "12px"},
"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",
},
} }
@@ -41,110 +26,144 @@ def new_id(prefix="n"):
return prefix + uuid.uuid4().hex[:8] return prefix + uuid.uuid4().hex[:8]
def make_broker_node(name, cfg, flow_id): FLOW_ID = new_id("f")
node = {"id": new_id("br"), "z": "", "type": "mqtt-broker", "name": name}
def make_broker_node(name, cfg):
node = {"id": new_id("br"), "type": "mqtt-broker", "name": name}
node["broker"] = cfg["host"] node["broker"] = cfg["host"]
node["port"] = str(cfg.get("port", 1883)) node["port"] = str(cfg.get("port", 1883))
for k, v in BROKER_DEFAULTS.items(): node.update(BROKER_DEFAULTS)
node.setdefault(k, v)
return node 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"] ntype = nd["type"]
nid = nd["id"] if "id" in nd else new_id() nid = nd.get("id", new_id())
node = {"id": nid, "z": flow_id, "type": ntype, "name": nd.get("label", nd.get("name", ""))} node = {"id": nid, "z": FLOW_ID, "type": ntype, "wires": [],
node.update(NODE_TEMPLATES.get(ntype, {})) "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
if ntype in ("mqtt-in", "mqtt-out"): if ntype in ("mqtt in", "mqtt-in", "mqtt out", "mqtt-out"):
node["broker"] = broker_map[nd["broker"]] node["broker"] = broker_map[nd["broker"]]
if ntype in ("ui-text", "ui-button"): if ntype in ("mqtt in", "mqtt-in"):
node["group"] = group_map[nd["group"]]
if ntype == "mqtt-in":
node["topic"] = nd["topic"] node["topic"] = nd["topic"]
node["qos"] = str(nd.get("qos", 2)) 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["topic"] = nd.get("topic", "")
node["qos"] = str(nd.get("qos", 2)) 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["format"] = nd.get("format", "{{msg.payload}}")
node["name"] = nd.get("label", nd.get("name", "Text")) node["layout"] = nd.get("layout", "row-spread")
if ntype == "ui-button": node["className"] = ""
if node["type"] in ("ui-button",):
node["payload"] = json.dumps(nd.get("payload", "")) node["payload"] = json.dumps(nd.get("payload", ""))
node["payloadType"] = "json"
node["topic"] = nd.get("topic", "") 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": 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 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): def build_flow(doc, flow_def, node_positions):
nodes = [] nodes = []
flow_id = new_id("f")
# Collect broker references
broker_map = {} broker_map = {}
for nd in flow_def["nodes"]: 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"] bname = nd["broker"]
if bname not in broker_map: if bname not in broker_map:
bcfg = doc["brokers"][bname] bn = make_broker_node(bname, doc["brokers"][bname])
bn = make_broker_node(bname, bcfg, flow_id)
broker_map[bname] = bn["id"] broker_map[bname] = bn["id"]
nodes.append(bn) nodes.append(bn)
# Collect dashboard groups/tabs
group_map = {} group_map = {}
if "dashboards" in doc: for db in doc.get("dashboards", []):
for db in doc["dashboards"]: bid = new_id("uib")
tab_id, tn = make_tab_node(db["tab"], db.get("icon", "dashboard"), db.get("order", 1)) tid = new_id("uit")
nodes.append(tn) nodes.append({
for gi, grp in enumerate(db.get("groups", [])): "id": bid, "type": "ui-base", "name": db["base"],
gn = make_group_node(grp["name"], tab_id, gi + 1) "path": db.get("path", "/dashboard"),
group_map[grp["name"]] = gn["id"] "includeClientData": True,
nodes.append(gn) "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"]): 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, flow_id) node = make_node(nd, col, row, broker_map, group_map)
nodes.append(node) nodes.append(node)
# Wire connections
wires = flow_def.get("wires", {}) wires = flow_def.get("wires", {})
for src_id, targets in wires.items(): 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 = next((n for n in nodes if n["id"] == src_id), None)
src.setdefault("wires", []) if src:
src["wires"].append(targets) src.setdefault("wires", [])
src["wires"].append(targets)
return nodes return nodes
def auto_position(flow_def): 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 = {} 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"]): for i, nd in enumerate(flow_def["nodes"]):
col = col_order.get(nd["type"], 1) col = col_order.get(nd["type"], 1)
positions[nd["id"]] = (col, i // 3) positions[nd["id"]] = (col, i // 3)