diff --git a/.gitignore b/.gitignore index 4a709c0..a2fb292 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/flows/zigbee-monitor.yaml b/flows/zigbee-monitor.yaml index a315136..82c3c8f 100644 --- a/flows/zigbee-monitor.yaml +++ b/flows/zigbee-monitor.yaml @@ -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] diff --git a/infra/mosquitto/config/acl.conf b/infra/mosquitto/config/acl.conf old mode 100644 new mode 100755 diff --git a/infra/mosquitto/config/mosquitto.conf b/infra/mosquitto/config/mosquitto.conf index dea7748..b9217f9 100644 --- a/infra/mosquitto/config/mosquitto.conf +++ b/infra/mosquitto/config/mosquitto.conf @@ -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 diff --git a/infra/nodered/flows.json b/infra/nodered/flows.json new file mode 100644 index 0000000..597fbc2 --- /dev/null +++ b/infra/nodered/flows.json @@ -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" + } +] \ No newline at end of file diff --git a/infra/zigbee2mqtt/configuration.yaml b/infra/zigbee2mqtt/configuration.yaml index 5c98ce9..0959524 100644 --- a/infra/zigbee2mqtt/configuration.yaml +++ b/infra/zigbee2mqtt/configuration.yaml @@ -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 diff --git a/scripts/flow2json.py b/scripts/flow2json.py index 0c21685..cc87808 100644 --- a/scripts/flow2json.py +++ b/scripts/flow2json.py @@ -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: