infra: runtime config fixes + declarative flow updates

Refs #3
This commit is contained in:
2026-08-27 03:15:07 +00:00
parent 14581d05dd
commit 229e311f68
7 changed files with 542 additions and 170 deletions
+3
View File
@@ -19,3 +19,6 @@ infra/nodered/.config.nodes.json
infra/nodered/.config.runtime.json
infra/nodered/.config.users.json
infra/nodered/flows_cred.json
# operator tool pin
mise.toml
+90 -37
View File
@@ -5,6 +5,16 @@ brokers:
host: mosquitto
port: 1883
# Plugs toggled on brief single press
single_plugs:
- Squiggle
- Sideboard Lamp
- Bamboo Lights
# Plugs toggled on press-hold-release (action: "release" after a "hold")
hold_plugs:
- DJ Booth
dashboards:
- base: Dashboard
pages:
@@ -17,35 +27,32 @@ dashboards:
flows:
- tab: Zigbee Monitor
description: Button dispatches single vs hold-release to different plug sets.
Plug plumbing (state-in, /set out, dashboard widget, per-channel mapper)
is generated by flow2json.py from the plugs lists below.
# Declarative plug set. channels:[] marks a multi-channel device, which gets
# a mapper that toggles each channel and a widget showing each state_N.
plugs:
- id: squiggle
name: Squiggle
- id: sideboard
name: Sideboard Lamp
- id: bamboo
name: Bamboo Lights
- id: djbooth
name: DJ Booth
- id: couch
name: Dual Outlet - Couch
channels: [1, 2]
# Device ids toggled on brief single press (driven by single_filter)
single_plugs: [squiggle, sideboard, bamboo, couch]
# Device ids toggled on press-hold-release (driven by hold_filter)
hold_plugs: [djbooth]
single_filter: single-filter
hold_filter: hold-filter
description: Button dispatches single vs hold-release to different plug sets
nodes:
- id: zigbee-state-squiggle
type: mqtt in
broker: mosquitto
topic: zigbee2mqtt/Squiggle
qos: 0
- id: zigbee-state-sideboard
type: mqtt in
broker: mosquitto
topic: zigbee2mqtt/Sideboard Lamp
qos: 0
- id: zigbee-state-bamboo
type: mqtt in
broker: mosquitto
topic: zigbee2mqtt/Bamboo Lights
qos: 0
- id: zigbee-state-djbooth
type: mqtt in
broker: mosquitto
topic: zigbee2mqtt/DJ Booth
qos: 0
- id: button-in
type: mqtt in
broker: mosquitto
@@ -56,10 +63,6 @@ flows:
type: function
name: Single click
code: |
const now = Date.now();
const last = context.get('lastTime') || 0;
if (now - last < 50) return null;
context.set('lastTime', now);
try {
const p = JSON.parse(msg.payload);
if (p.action === 'single') {
@@ -73,10 +76,6 @@ flows:
type: function
name: Hold release
code: |
const now = Date.now();
const last = context.get('lastTime') || 0;
if (now - last < 50) return null;
context.set('lastTime', now);
try {
const p = JSON.parse(msg.payload);
if (p.action === 'release') {
@@ -86,6 +85,54 @@ flows:
} 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: plug-out-djbooth
type: mqtt out
broker: mosquitto
topic: zigbee2mqtt/DJ Booth/set
- id: text-squiggle
type: ui-text
group: Plugs
label: Squiggle
format: "{{msg.payload.state}}"
order: 1
- id: text-sideboard
type: ui-text
group: Plugs
label: Sideboard Lamp
format: "{{msg.payload.state}}"
order: 2
- id: text-bamboo
type: ui-text
group: Plugs
label: Bamboo Lights
format: "{{msg.payload.state}}"
order: 3
- id: text-djbooth
type: ui-text
group: Plugs
label: DJ Booth
format: "{{msg.payload.state}}"
order: 4
- id: permit-btn
type: ui-button
group: Plugs
@@ -94,12 +141,18 @@ flows:
payload:
value: true
time: 60
order: 9
order: 5
- id: mqtt-out
type: mqtt out
broker: mosquitto
wires:
zigbee-state-squiggle: [text-squiggle]
zigbee-state-sideboard: [text-sideboard]
zigbee-state-bamboo: [text-bamboo]
zigbee-state-djbooth: [text-djbooth]
button-in: [single-filter, hold-filter]
single-filter: [plug-out-squiggle, plug-out-sideboard, plug-out-bamboo]
hold-filter: [plug-out-djbooth]
permit-btn: [mqtt-out]
Regular → Executable
View File
+1
View File
@@ -2,6 +2,7 @@ listener 1883
allow_anonymous true
acl_file /mosquitto/config/acl.conf
password_file /mosquitto/config/passwd
persistence true
persistence_location /mosquitto/data
+440
View File
@@ -0,0 +1,440 @@
[
{
"id": "brd05c91e3",
"type": "mqtt-broker",
"name": "mosquitto",
"broker": "mosquitto",
"port": "1883",
"clientid": "",
"autoConnect": true,
"usetls": false,
"protocolVersion": "4",
"keepalive": "60",
"cleansession": true,
"birthTopic": "",
"birthQos": "0",
"birthPayload": "",
"closeTopic": "",
"closeQos": "0",
"closePayload": "",
"willTopic": "",
"willQos": "0",
"willPayload": ""
},
{
"id": "uib31315d0b",
"type": "ui-base",
"name": "Dashboard",
"path": "/dashboard",
"includeClientData": true,
"acceptsClientConfig": [
"ui-notification",
"ui-control"
],
"showPathInSidebar": false,
"navigationStyle": "default",
"titleBarStyle": "default"
},
{
"id": "uit1f2d1d17",
"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": "uipc7a69acb",
"type": "ui-page",
"name": "Home",
"ui": "uib31315d0b",
"icon": "home",
"path": "/home",
"theme": "uit1f2d1d17",
"layout": "flex",
"order": 0,
"className": "",
"visible": true,
"disabled": false
},
{
"id": "uig32ecccd2",
"type": "ui-group",
"name": "Plugs",
"page": "uipc7a69acb",
"width": "6",
"height": "1",
"order": 1,
"showTitle": true,
"className": "",
"visible": true,
"disabled": false
},
{
"id": "f810d3244",
"type": "tab",
"label": "Zigbee Monitor",
"disabled": false,
"info": "Button dispatches single vs hold-release to different plug sets. Plug plumbing (state-in, /set out, dashboard widget, per-channel mapper) is generated by flow2json.py from the plugs lists below.",
"env": []
},
{
"id": "zigbee-state-squiggle",
"type": "mqtt in",
"wires": [
[
"text-squiggle"
]
],
"name": "",
"broker": "brd05c91e3",
"topic": "zigbee2mqtt/Squiggle",
"qos": "0",
"datatype": "auto",
"z": "f810d3244",
"x": 170,
"y": 80
},
{
"id": "text-squiggle",
"type": "ui-text",
"wires": [],
"name": "Squiggle",
"group": "uig32ecccd2",
"width": "6",
"height": "2",
"order": 1,
"label": "Squiggle",
"format": "{{msg.payload.state}}",
"layout": "row-spread",
"className": "",
"z": "f810d3244",
"x": 770,
"y": 150
},
{
"id": "plug-out-squiggle",
"type": "mqtt out",
"wires": [],
"name": "",
"broker": "brd05c91e3",
"topic": "zigbee2mqtt/Squiggle/set",
"qos": "2",
"retain": false,
"z": "f810d3244",
"x": 770,
"y": 220
},
{
"id": "zigbee-state-sideboard",
"type": "mqtt in",
"wires": [
[
"text-sideboard"
]
],
"name": "",
"broker": "brd05c91e3",
"topic": "zigbee2mqtt/Sideboard Lamp",
"qos": "0",
"datatype": "auto",
"z": "f810d3244",
"x": 170,
"y": 290
},
{
"id": "text-sideboard",
"type": "ui-text",
"wires": [],
"name": "Sideboard Lamp",
"group": "uig32ecccd2",
"width": "6",
"height": "2",
"order": 2,
"label": "Sideboard Lamp",
"format": "{{msg.payload.state}}",
"layout": "row-spread",
"className": "",
"z": "f810d3244",
"x": 770,
"y": 360
},
{
"id": "plug-out-sideboard",
"type": "mqtt out",
"wires": [],
"name": "",
"broker": "brd05c91e3",
"topic": "zigbee2mqtt/Sideboard Lamp/set",
"qos": "2",
"retain": false,
"z": "f810d3244",
"x": 770,
"y": 430
},
{
"id": "zigbee-state-bamboo",
"type": "mqtt in",
"wires": [
[
"text-bamboo"
]
],
"name": "",
"broker": "brd05c91e3",
"topic": "zigbee2mqtt/Bamboo Lights",
"qos": "0",
"datatype": "auto",
"z": "f810d3244",
"x": 170,
"y": 500
},
{
"id": "text-bamboo",
"type": "ui-text",
"wires": [],
"name": "Bamboo Lights",
"group": "uig32ecccd2",
"width": "6",
"height": "2",
"order": 3,
"label": "Bamboo Lights",
"format": "{{msg.payload.state}}",
"layout": "row-spread",
"className": "",
"z": "f810d3244",
"x": 770,
"y": 570
},
{
"id": "plug-out-bamboo",
"type": "mqtt out",
"wires": [],
"name": "",
"broker": "brd05c91e3",
"topic": "zigbee2mqtt/Bamboo Lights/set",
"qos": "2",
"retain": false,
"z": "f810d3244",
"x": 770,
"y": 640
},
{
"id": "zigbee-state-couch",
"type": "mqtt in",
"wires": [
[
"text-couch"
]
],
"name": "",
"broker": "brd05c91e3",
"topic": "zigbee2mqtt/Dual Outlet - Couch",
"qos": "0",
"datatype": "auto",
"z": "f810d3244",
"x": 170,
"y": 710
},
{
"id": "text-couch",
"type": "ui-text",
"wires": [],
"name": "Dual Outlet - Couch",
"group": "uig32ecccd2",
"width": "6",
"height": "2",
"order": 4,
"label": "Dual Outlet - Couch",
"format": "{{msg.payload.state_1}} / {{msg.payload.state_2}}",
"layout": "row-spread",
"className": "",
"z": "f810d3244",
"x": 770,
"y": 780
},
{
"id": "mapper-couch",
"type": "function",
"wires": [
[
"plug-out-couch"
]
],
"name": "Toggle each outlet",
"func": "msg.payload = {state_1: 'TOGGLE', state_2: 'TOGGLE'};\nreturn msg;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"z": "f810d3244",
"x": 450,
"y": 850
},
{
"id": "plug-out-couch",
"type": "mqtt out",
"wires": [],
"name": "",
"broker": "brd05c91e3",
"topic": "zigbee2mqtt/Dual Outlet - Couch/set",
"qos": "2",
"retain": false,
"z": "f810d3244",
"x": 770,
"y": 920
},
{
"id": "zigbee-state-djbooth",
"type": "mqtt in",
"wires": [
[
"text-djbooth"
]
],
"name": "",
"broker": "brd05c91e3",
"topic": "zigbee2mqtt/DJ Booth",
"qos": "0",
"datatype": "auto",
"z": "f810d3244",
"x": 170,
"y": 990
},
{
"id": "text-djbooth",
"type": "ui-text",
"wires": [],
"name": "DJ Booth",
"group": "uig32ecccd2",
"width": "6",
"height": "2",
"order": 5,
"label": "DJ Booth",
"format": "{{msg.payload.state}}",
"layout": "row-spread",
"className": "",
"z": "f810d3244",
"x": 770,
"y": 1060
},
{
"id": "plug-out-djbooth",
"type": "mqtt out",
"wires": [],
"name": "",
"broker": "brd05c91e3",
"topic": "zigbee2mqtt/DJ Booth/set",
"qos": "2",
"retain": false,
"z": "f810d3244",
"x": 770,
"y": 1130
},
{
"id": "button-in",
"type": "mqtt in",
"wires": [
[
"single-filter",
"hold-filter"
]
],
"name": "",
"x": 170,
"y": 80,
"broker": "brd05c91e3",
"topic": "zigbee2mqtt/Door Button",
"qos": "0",
"datatype": "auto",
"z": "f810d3244"
},
{
"id": "single-filter",
"type": "function",
"wires": [
[
"plug-out-squiggle",
"plug-out-sideboard",
"plug-out-bamboo",
"mapper-couch"
]
],
"name": "Single click",
"x": 450,
"y": 80,
"func": "const now = Date.now();\nconst last = context.get('lastTime') || 0;\nif (now - last < 50) return null;\ncontext.set('lastTime', now);\ntry {\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": [],
"z": "f810d3244"
},
{
"id": "hold-filter",
"type": "function",
"wires": [
[
"plug-out-djbooth"
]
],
"name": "Hold release",
"x": 450,
"y": 80,
"func": "const now = Date.now();\nconst last = context.get('lastTime') || 0;\nif (now - last < 50) return null;\ncontext.set('lastTime', now);\ntry {\n const p = JSON.parse(msg.payload);\n if (p.action === 'release') {\n msg.payload = { state: 'TOGGLE' };\n return msg;\n }\n} catch (e) {}\nreturn null;\n",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"z": "f810d3244"
},
{
"id": "permit-btn",
"type": "ui-button",
"wires": [
[
"mqtt-out"
]
],
"name": "Permit Join (60s)",
"x": 170,
"y": 220,
"group": "uig32ecccd2",
"payload": "{\"value\": true, \"time\": 60}",
"payloadType": "json",
"topic": "zigbee2mqtt/bridge/request/permit_join",
"width": "3",
"height": "1",
"order": 9,
"className": "",
"z": "f810d3244",
"icon": "",
"label": "Permit Join (60s)"
},
{
"id": "mqtt-out",
"type": "mqtt out",
"wires": [],
"name": "",
"x": 770,
"y": 220,
"broker": "brd05c91e3",
"topic": "",
"qos": "2",
"retain": false,
"z": "f810d3244"
}
]
+7 -8
View File
@@ -36,28 +36,27 @@ advanced:
- 25
- 93
- 235
frontend:
enabled: true
port: 8080
permit_join: false
groups:
"1":
'1':
friendly_name: all_plugs
devices:
- Squiggle
- Sideboard Lamp
- Bamboo Lights
devices:
"0xffffb40e0604fbd0":
'0xffffb40e0604fbd0':
friendly_name: Squiggle
"0xffffb40e06065536":
'0xffffb40e06065536':
friendly_name: Door Button
"0xffffb40e0603c9e4":
'0xffffb40e0603c9e4':
friendly_name: Sideboard Lamp
"0xffffb40e06050af6":
'0xffffb40e06050af6':
friendly_name: Bamboo Lights
"0xffffb40e0603ef11":
'0xffffb40e0603ef11':
friendly_name: DJ Booth
"0x4ce175523f710000":
'0x4ce175523f710000':
friendly_name: Dual Outlet - Couch
+1 -125
View File
@@ -37,117 +37,6 @@ def make_broker_node(name, cfg):
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())
@@ -265,17 +154,6 @@ def build_flow(doc, flow_def, node_positions):
"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)
@@ -285,9 +163,7 @@ def build_flow(doc, flow_def, node_positions):
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)
wires = flow_def.get("wires", {})
for src_id, targets in wires.items():
src = next((n for n in nodes if n["id"] == src_id), None)
if src: