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:
+84
-16
@@ -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:
|
||||||
|
- page: Home
|
||||||
|
icon: home
|
||||||
|
path: /home
|
||||||
groups:
|
groups:
|
||||||
- name: Zigbee
|
- name: Plugs
|
||||||
width: 6
|
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
@@ -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
@@ -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"
|
||||||
|
|||||||
+97
-78
@@ -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,99 +26,134 @@ 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)
|
||||||
|
if src:
|
||||||
src.setdefault("wires", [])
|
src.setdefault("wires", [])
|
||||||
src["wires"].append(targets)
|
src["wires"].append(targets)
|
||||||
|
|
||||||
@@ -141,10 +161,9 @@ def build_flow(doc, flow_def, node_positions):
|
|||||||
|
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user