From b9c53c1a239d61285cd6aadddeb0eb1d959054e3 Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Sat, 15 Aug 2026 02:55:29 -0700 Subject: [PATCH] generator: auto-expand declarative plug lists with per-channel mapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add build_plugs() to flow2json.py: a flow can declare a plugs: table ({id, name, channels?}) plus single_plugs/hold_plugs lists and the generator emits all plumbing automatically — state-in node, /set out node, dashboard widget, and (for multi-channel devices) a mapper function that toggles each channel (state_1/state_2) instead of the ignored shared {state:TOGGLE} payload. Rewrite zigbee-monitor.yaml to the declarative form; Dual Outlet - Couch is multi-channel (channels: [1,2]). Adding a plug is one line, no manual node/wire duplication. Single press toggles 5 devices (couch via mapper verified E2E ON/OFF), hold-release still DJ Booth only. Refs: david/nr-flow-validator#2 --- flows/zigbee-monitor.yaml | 147 ++++++++------------------------------ scripts/flow2json.py | 126 +++++++++++++++++++++++++++++++- 2 files changed, 154 insertions(+), 119 deletions(-) diff --git a/flows/zigbee-monitor.yaml b/flows/zigbee-monitor.yaml index 043677d..a315136 100644 --- a/flows/zigbee-monitor.yaml +++ b/flows/zigbee-monitor.yaml @@ -5,17 +5,6 @@ brokers: host: mosquitto port: 1883 -# Plugs toggled on brief single press -single_plugs: - - Squiggle - - Sideboard Lamp - - Bamboo Lights - - Dual Outlet - Couch - -# Plugs toggled on press-hold-release (action: "release" after a "hold") -hold_plugs: - - DJ Booth - dashboards: - base: Dashboard pages: @@ -28,38 +17,35 @@ dashboards: flows: - tab: Zigbee Monitor - description: Button dispatches single vs hold-release to different plug sets + 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 + 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: zigbee-state-couch - type: mqtt in - broker: mosquitto - topic: zigbee2mqtt/Dual Outlet - Couch - qos: 0 - - id: button-in type: mqtt in broker: mosquitto @@ -100,73 +86,6 @@ 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: couch-mapper - type: function - name: Toggle both outlets - code: | - msg.payload = { state_1: 'TOGGLE', state_2: 'TOGGLE' }; - return msg; - - - id: plug-out-couch - type: mqtt out - broker: mosquitto - topic: zigbee2mqtt/Dual Outlet - Couch/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: text-couch - type: ui-text - group: Plugs - label: Dual Outlet - Couch - format: "{{msg.payload.state_1}} / {{msg.payload.state_2}}" - order: 6 - - id: permit-btn type: ui-button group: Plugs @@ -175,20 +94,12 @@ flows: payload: value: true time: 60 - order: 5 + order: 9 - 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] - zigbee-state-couch: [text-couch] button-in: [single-filter, hold-filter] - single-filter: [plug-out-squiggle, plug-out-sideboard, plug-out-bamboo, couch-mapper] - couch-mapper: [plug-out-couch] - hold-filter: [plug-out-djbooth] permit-btn: [mqtt-out] diff --git a/scripts/flow2json.py b/scripts/flow2json.py index cc87808..0c21685 100644 --- a/scripts/flow2json.py +++ b/scripts/flow2json.py @@ -37,6 +37,117 @@ 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()) @@ -154,6 +265,17 @@ 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) @@ -163,7 +285,9 @@ def build_flow(doc, flow_def, node_positions): node["label"] = nd.get("label", nd.get("name", "")) nodes.append(node) - wires = flow_def.get("wires", {}) + 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: