Enhance Zigbee to NVL integration with button mapping and configuration updates
- Updated documentation to clarify the mapping of Zigbee buttons to specific (room, light) pairs using `switchBindings`. - Improved the Zigbee to NVL function to support both single-device and multi-device payloads, enhancing flexibility in handling actions. - Revised the room configuration to include detailed switch bindings and fallback mechanisms for device identification, streamlining the integration process. This update improves the usability and functionality of the Zigbee integration within Node-RED, facilitating better control of lighting systems.
This commit is contained in:
@@ -116,25 +116,28 @@ const lightNum = parseInt((name.match(/_(\d+)$/) || [,'1'])[1], 10) || 1;
|
||||
|
||||
Then set `state.rooms[roomKey]['ha_l' + lightNum + '_on']` or `_off` as you do now. So one HA to NVL function can serve all entities if you pass the right `msg.topic` and use a consistent naming (e.g. `room_light_N`).
|
||||
|
||||
### 3.3 Zigbee → PLC: which room?
|
||||
### 3.3 Zigbee → PLC: which light does each button control?
|
||||
|
||||
- Each **zigbee2mqtt-in** is one physical switch.
|
||||
- **Zigbee to NVL** must know which **room** that switch belongs to.
|
||||
- Either: **one Zigbee to NVL per room** (each has `ROOM_NAME = 'kitchen'` etc.) and wire each switch to the right one, or **one Zigbee to NVL** that gets **device/entity** from `msg` and uses a **device → room** map (e.g. `friendly_name` or `device_id` → room key).
|
||||
Each **button** (1..6) is mapped to a specific **(room, light)** so e.g. Office switch button 1 → cmd_livingroom light 1, button 2 → cmd_out light 2. Config is in **room-config.js** → **`switchBindings`**:
|
||||
|
||||
Example map in Zigbee to NVL:
|
||||
- Key: Zigbee device **friendly_name**.
|
||||
- Value: object mapping **button index** (1..6) to **`{ room, light }`** or to an array **`[ { room, light }, ... ]`** (one button can control several lights).
|
||||
- The PLC receives `zigbee_sw<light>` set in the given **room** for each target.
|
||||
|
||||
**Example in `room-config.js`:**
|
||||
|
||||
```javascript
|
||||
const DEVICE_TO_ROOM = {
|
||||
'Living Room Door Switch': 'livingRoom',
|
||||
'Office Switch': 'livingRoom', // or 'office' if you add that room
|
||||
'Kitchen Switch': 'kitchen',
|
||||
// ...
|
||||
};
|
||||
const roomKey = DEVICE_TO_ROOM[msg.payload?.friendly_name || ''] || 'livingRoom';
|
||||
switchBindings: {
|
||||
'Office Switch': {
|
||||
1: { room: 'cmd_livingroom', light: 1 }, // button 1 → light 1 in cmd_livingroom
|
||||
2: { room: 'cmd_out', light: 2 }, // button 2 → light 2 in cmd_out
|
||||
// One button, multiple lights:
|
||||
// 3: [ { room: 'kitchen', light: 1 }, { room: 'hallway', light: 1 } ],
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
Then use `roomKey` instead of a fixed `ROOM_NAME` when writing to `state.rooms[roomKey]`.
|
||||
Add every **room** you use (e.g. `cmd_out`) to **`roomNames`** so NVL SEND includes it. If a device has no `switchBindings` entry, Zigbee to NVL falls back to **`deviceToRoom`** and uses the button index as the light number in that single room. Use **[clear-zigbee-edge.js](clear-zigbee-edge.js)** in the Clear zigbee edge node so it supports an array of `{ room, key }` (multi-target).
|
||||
|
||||
### 3.4 PLC → HA: NVL to HA Sync (LIGHT_ENTITY_MAP)
|
||||
|
||||
@@ -263,7 +266,7 @@ Use a **consistent pattern** so you can derive room + light in Node-RED and in Y
|
||||
| 2 | **HA** | Create all entities (YAML package or UI). Use a clear naming scheme (e.g. `room_light_N`). |
|
||||
| 3 | **Node-RED – NVL SEND** | Set **`roomNames`** in `state-to-nvl-send-payload.js` to the full list of room keys (same order as PLC). |
|
||||
| 4 | **Node-RED – HA to NVL** | Either duplicate the flow per room (Option A) or use one flow and derive **room + light** from `msg.topic` (Option B). Ensure every HA entity is mapped to a room key and light index. |
|
||||
| 5 | **Node-RED – Zigbee to NVL** | Map each Zigbee device (by name or id) to a **room key** so the correct `state.rooms[roomKey]` is updated. |
|
||||
| 5 | **Node-RED – Zigbee to NVL** | In **room-config.js** set **switchBindings** (device → button → `{ room, light }` or array) so each button controls the right room+light; add those rooms to **roomNames**. Use **clear-zigbee-edge.js** for the 80 ms clear. |
|
||||
| 6 | **Node-RED – NVL to HA Sync** | Fill **LIGHT_ENTITY_MAP** with one entry per light: `room` (payload key from nvl-receive), `light` (1..6), `entityId` (HA entity). |
|
||||
| 7 | **Action nodes** | Keep one Action node (or two for on/off) with **Block Input Overrides** off so the sync’s `payload.action` and `payload.target.entity_id` are used for all entities. |
|
||||
|
||||
|
||||
15
docs/integration/clear-zigbee-edge.js
Normal file
15
docs/integration/clear-zigbee-edge.js
Normal file
@@ -0,0 +1,15 @@
|
||||
// Paste into the "Clear zigbee edge" Function node (after 80 ms delay).
|
||||
// Clears zigbee_swN in nvlInState so the PLC sees one pulse. Supports one room or many (switchBindings).
|
||||
// msg.zigbeeClear can be: { room, key } or [ { room, key }, ... ]
|
||||
|
||||
if (msg.zigbeeClear) {
|
||||
const state = flow.get('nvlInState') || { rooms: {}, boiler: {} };
|
||||
const list = Array.isArray(msg.zigbeeClear) ? msg.zigbeeClear : [msg.zigbeeClear];
|
||||
for (const item of list) {
|
||||
const r = state.rooms[item.room];
|
||||
if (r && item.key) r[item.key] = false;
|
||||
}
|
||||
flow.set('nvlInState', state);
|
||||
}
|
||||
msg.payload = { buildAndSend: true };
|
||||
return msg;
|
||||
81
docs/integration/nodered-flow-check-report.md
Normal file
81
docs/integration/nodered-flow-check-report.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Node-RED flow check report (live vs repo)
|
||||
|
||||
Checked live flows on **10.20.30.12** (Feb 2026). Summary below.
|
||||
|
||||
---
|
||||
|
||||
## Updated (match repo) – Living Room tab
|
||||
|
||||
| Node name | Status | Note |
|
||||
|----------------------|----------|------|
|
||||
| HA to NVL | Updated | Uses `roomConfig`, `entityToRoom` |
|
||||
| Zigbee to NVL | Updated | Uses `switchBindings`, multi-device payload, `v.payload.action` |
|
||||
| Clear zigbee edge | Updated | Supports `Array.isArray(msg.zigbeeClear)` |
|
||||
| STATE TO NVL | Updated | Uses `global.get('roomConfig').roomNames` |
|
||||
| NVL to HA Sync | Updated | Uses `roomConfig.lightEntityMap` |
|
||||
|
||||
You have **duplicate** nodes in the same tab: some of these names exist twice (or three times), and **one copy is old**. The flow may be wired to the **old** instances.
|
||||
|
||||
---
|
||||
|
||||
## Not updated / needs action
|
||||
|
||||
### 1. Load room config – **empty**
|
||||
|
||||
- **Current:** The "Load room config" function node contains only `return msg;` (12 chars).
|
||||
- **Effect:** `global.get('roomConfig')` is never set from `room-config.js`, so other nodes may use fallbacks or wrong data.
|
||||
- **Fix:** Paste the full loader code into the "Load room config" function node. Code to paste is in **[node-red/room-config-loader.js](../../node-red/room-config-loader.js)** (see below).
|
||||
|
||||
### 2. Duplicate nodes in Living Room
|
||||
|
||||
Same tab contains both updated and old versions:
|
||||
|
||||
- **HA to NVL:** 1 updated, 1 old
|
||||
- **Zigbee to NVL:** 2 updated, 1 old
|
||||
- **Clear zigbee edge:** 1 updated, 1 old
|
||||
- **NVL to HA Sync:** 1 updated, 1 old
|
||||
- **NVL SEND:** 1 node, **old** (hardcoded `roomNames = ['cmd_livingroom']`)
|
||||
|
||||
If your flow is connected to the **old** nodes, behaviour will not match the repo. Ensure:
|
||||
|
||||
- Zigbee/HA → **updated** "Zigbee to NVL" / "HA to NVL" → **updated** "STATE TO NVL" (or replace "NVL SEND" with STATE TO NVL and wire that) → nvl-send.
|
||||
- Delay → **updated** "Clear zigbee edge" (the one that has `Array.isArray(msg.zigbeeClear)`).
|
||||
|
||||
### 3. Flow 7
|
||||
|
||||
All relevant function nodes there are **old** (fixed `livingRoom`, no roomConfig). Update them if you use that tab, or leave as-is if unused.
|
||||
|
||||
---
|
||||
|
||||
## Code to paste: Load room config
|
||||
|
||||
Paste this into the **Load room config** function node (and connect an Inject “once after 0.5 s” to it):
|
||||
|
||||
```javascript
|
||||
// Delete cached version so changes to the file are picked up on redeploy
|
||||
delete require.cache[require.resolve('/root/.node-red/room-config.js')];
|
||||
const ROOM_CONFIG = require('/root/.node-red/room-config.js');
|
||||
|
||||
global.set('roomConfig', ROOM_CONFIG);
|
||||
node.warn('[Load room config] loaded: ' + ROOM_CONFIG.roomNames.length + ' rooms, ' + ROOM_CONFIG.lightEntityMap.length + ' light mappings');
|
||||
return null;
|
||||
```
|
||||
|
||||
Full file: [node-red/room-config-loader.js](../../node-red/room-config-loader.js).
|
||||
|
||||
---
|
||||
|
||||
## room-config.js on server
|
||||
|
||||
`/root/.node-red/room-config.js` is present and contains `roomNames`, `switchBindings`, etc. Upload from repo when you change config:
|
||||
`scp node-red/room-config.js root@10.20.30.12:/root/.node-red/room-config.js`
|
||||
|
||||
---
|
||||
|
||||
## Checklist
|
||||
|
||||
1. [ ] Paste **Load room config** code (above) into the "Load room config" node and deploy.
|
||||
2. [ ] In Living Room, confirm wires go to the **updated** HA to NVL / Zigbee to NVL / Clear zigbee edge (not the old duplicates).
|
||||
3. [ ] Use **STATE TO NVL** (roomConfig) in the NVL path, or replace **NVL SEND** content with [state-to-nvl-send-payload.js](state-to-nvl-send-payload.js).
|
||||
4. [ ] Optionally remove or disable the **old** duplicate nodes in Living Room to avoid confusion.
|
||||
5. [ ] After changing `room-config.js`, upload it and redeploy (or restart Node-RED).
|
||||
@@ -103,9 +103,10 @@ If `isOn` is false when you expect ON, check that the trigger-state output is ex
|
||||
|
||||
## Zigbee to NVL (function code)
|
||||
|
||||
- **Room:** `livingRoom`.
|
||||
- **Action map:** `single→1, double→2, hold→3, release→4, triple→5, quad→6` → sets `zigbee_swN`.
|
||||
- **Edge behaviour:** Passes `msg.zigbeeClear = { room: 'livingRoom', key: 'zigbee_swN' }` so **Clear zigbee edge** (after 80 ms) clears that flag and calls **Build NVL_In** again; PLC sees one pulse.
|
||||
- Uses **room-config.js** `switchBindings` (and optional `deviceIdToName` / `switchBindingsByDeviceId`) so each button maps to (room, light). See [zigbee-to-nvl-function.js](zigbee-to-nvl-function.js).
|
||||
- **Payload formats supported:** (1) single device `{ action: "1_single", friendly_name: "Office Switch" }`; (2) multi-device object keyed by IEEE `{ "0xa4c...": { action, ... } }`; (3) plain action string `"1_single"` with device name in `msg.topic` (e.g. `zigbee2mqtt/Office Switch/action`).
|
||||
- **If your multi-device node never sends `action`:** the node may only output device list/state. Use an **MQTT In** node subscribed to `zigbee2mqtt/+/action` and feed that into Zigbee to NVL; then `msg.payload` will be the action string (e.g. `1_single`) and `msg.topic` will be e.g. `zigbee2mqtt/Office Switch/action` (the function parses the friendly name from the topic).
|
||||
- **Edge behaviour:** Passes `msg.zigbeeClear` (single object or array) so **Clear zigbee edge** (after 80 ms) clears the flags; use [clear-zigbee-edge.js](clear-zigbee-edge.js).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,35 +1,108 @@
|
||||
// Writes to state.rooms.<roomKey>. Room from Zigbee device via global roomConfig.deviceToRoom. See /root/.node-red/room-config.js.
|
||||
// Each button maps to (room, light) via global roomConfig.switchBindings. See /root/.node-red/room-config.js.
|
||||
// Supports single-device payload (action, friendly_name at top level) and multi-device payload (keyed by IEEE address).
|
||||
const actionToSwitch = { single: 1, double: 2, hold: 3, release: 4, triple: 5, quad: 6 };
|
||||
const config = global.get('roomConfig');
|
||||
const switchBindings = (config && config.switchBindings) ? config.switchBindings : {};
|
||||
const deviceToRoom = (config && config.deviceToRoom) ? config.deviceToRoom : { 'Office Switch': 'cmd_livingroom' };
|
||||
const friendlyName = (msg.payload && msg.payload.friendly_name) ? msg.payload.friendly_name : '';
|
||||
const ROOM_NAME = deviceToRoom[friendlyName] || 'cmd_livingroom';
|
||||
const payload = msg.payload || {};
|
||||
const actionRaw = (payload.action || payload.click || '').toLowerCase();
|
||||
// Support "1_single", "2_double" etc. (button_number + action) or plain "single", "double"
|
||||
const parts = actionRaw.split('_');
|
||||
let swIndex = null;
|
||||
if (parts.length >= 2) {
|
||||
const buttonNum = parseInt(parts[0], 10);
|
||||
if (buttonNum >= 1 && buttonNum <= 6) swIndex = buttonNum;
|
||||
}
|
||||
if (swIndex == null) swIndex = actionToSwitch[actionRaw];
|
||||
if (swIndex == null && parts.length >= 2) swIndex = actionToSwitch[parts.slice(1).join('_')];
|
||||
if (swIndex == null) swIndex = actionToSwitch[parts[parts.length - 1]];
|
||||
// Optional: map IEEE address to friendly name when multi-device node doesn't provide it. e.g. deviceIdToName: { '0xa4c138a5b9771b05': 'Office Switch' }
|
||||
const deviceIdToName = (config && config.deviceIdToName) ? config.deviceIdToName : {};
|
||||
|
||||
if (swIndex == null) {
|
||||
node.warn('[Zigbee to NVL] unknown action: ' + JSON.stringify(actionRaw) + ' payload=' + JSON.stringify(payload));
|
||||
const payload = msg.payload != null ? msg.payload : {};
|
||||
// Payload can be: single-device { action, friendly_name }, multi-device { "0x...": { ... } }, or plain action string "1_single"
|
||||
let friendlyName = (typeof payload === 'object' && payload.friendly_name != null) ? payload.friendly_name.toString() : '';
|
||||
let actionRaw = (typeof payload === 'string' ? payload : (payload.action || payload.click || msg.action || (payload && payload.action)) || '').toString().toLowerCase();
|
||||
|
||||
// Multi-device payload: prefer the device that has an action AND a configured binding (so we don't pick another device's stale action)
|
||||
if (!actionRaw && typeof payload === 'object' && !Array.isArray(payload)) {
|
||||
const keys = Object.keys(payload);
|
||||
let fallback = null;
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const key = keys[i];
|
||||
const v = payload[key];
|
||||
if (key && key.toLowerCase().indexOf('0x') === 0 && v && typeof v === 'object') {
|
||||
const inner = v.payload || v.message || v.state || v;
|
||||
const a = (inner.action || inner.click || v.action || v.click || '').toString().toLowerCase();
|
||||
if (!a) continue;
|
||||
const name = (v.friendly_name || v.name || (v.item && (v.item.friendly_name || v.item.name)) || deviceIdToName[key] || key).toString();
|
||||
if (switchBindings[name] || deviceToRoom[name]) {
|
||||
actionRaw = a;
|
||||
friendlyName = name;
|
||||
break;
|
||||
}
|
||||
if (!fallback) fallback = { actionRaw: a, friendlyName: name };
|
||||
}
|
||||
}
|
||||
if (!actionRaw && fallback) {
|
||||
actionRaw = fallback.actionRaw;
|
||||
friendlyName = fallback.friendlyName;
|
||||
}
|
||||
}
|
||||
// If still no action, try msg.data or msg.topic (some nodes put action in topic, e.g. "1_single")
|
||||
if (!actionRaw && msg.data && msg.data.action) actionRaw = msg.data.action.toString().toLowerCase();
|
||||
if (!actionRaw && typeof msg.topic === 'string' && /^\d_[a-z_]+$/.test(msg.topic.trim())) actionRaw = msg.topic.trim().toLowerCase();
|
||||
// If payload was the action string, get friendly_name from topic e.g. zigbee2mqtt/Office Switch/action
|
||||
if (actionRaw && !friendlyName && typeof msg.topic === 'string') {
|
||||
const parts = msg.topic.split('/');
|
||||
if (parts.length >= 2) friendlyName = parts[1].trim(); // zigbee2mqtt/Office Switch/action → Office Switch
|
||||
}
|
||||
|
||||
if (!actionRaw) {
|
||||
node.warn('[Zigbee to NVL] no action in payload. Keys: ' + (typeof payload === 'object' ? Object.keys(payload).join(', ') : 'n/a'));
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = actionRaw.split('_');
|
||||
let buttonIndex = null;
|
||||
if (parts.length >= 2) {
|
||||
const n = parseInt(parts[0], 10);
|
||||
if (n >= 1 && n <= 6) buttonIndex = n;
|
||||
}
|
||||
if (buttonIndex == null) buttonIndex = actionToSwitch[actionRaw];
|
||||
if (buttonIndex == null && parts.length >= 2) buttonIndex = actionToSwitch[parts.slice(1).join('_')];
|
||||
if (buttonIndex == null) buttonIndex = actionToSwitch[parts[parts.length - 1]];
|
||||
|
||||
if (buttonIndex == null) {
|
||||
node.warn('[Zigbee to NVL] unknown action: ' + JSON.stringify(actionRaw));
|
||||
return null;
|
||||
}
|
||||
|
||||
node.warn('[Zigbee to NVL] action=' + actionRaw + ' buttonIndex=' + buttonIndex + ' device=' + friendlyName);
|
||||
|
||||
// Prefer switchBindings by friendly name; fallback by device ID (IEEE) if we have switchBindingsByDeviceId
|
||||
const bindingByDeviceId = (config && config.switchBindingsByDeviceId) ? config.switchBindingsByDeviceId : null;
|
||||
let targets = [];
|
||||
let binding = switchBindings[friendlyName] && switchBindings[friendlyName][buttonIndex];
|
||||
if ((binding === undefined || binding === null) && bindingByDeviceId) {
|
||||
const deviceId = typeof payload === 'object' && Object.keys(payload).some(function (k) { return k.indexOf('0x') === 0; })
|
||||
? Object.keys(payload).find(function (k) { return k.indexOf('0x') === 0; })
|
||||
: null;
|
||||
if (deviceId) binding = bindingByDeviceId[deviceId] && bindingByDeviceId[deviceId][buttonIndex];
|
||||
}
|
||||
if (binding !== undefined && binding !== null) {
|
||||
targets = Array.isArray(binding) ? binding : [binding];
|
||||
targets = targets.filter(function (t) { return t && t.room && t.light >= 1 && t.light <= 6; });
|
||||
}
|
||||
if (targets.length === 0) {
|
||||
const room = deviceToRoom[friendlyName] || 'cmd_livingroom';
|
||||
targets = [{ room: room, light: buttonIndex }];
|
||||
node.warn('[Zigbee to NVL] no switchBindings for ' + friendlyName + ' btn' + buttonIndex + ', using fallback: ' + room + ' light ' + buttonIndex);
|
||||
}
|
||||
|
||||
if (!flow.get('nvlInState')) flow.set('nvlInState', { rooms: {}, boiler: {} });
|
||||
const state = flow.get('nvlInState');
|
||||
if (!state.rooms[ROOM_NAME]) state.rooms[ROOM_NAME] = {};
|
||||
state.rooms[ROOM_NAME]['zigbee_sw' + swIndex] = true;
|
||||
const clearList = [];
|
||||
|
||||
for (const t of targets) {
|
||||
const key = 'zigbee_sw' + t.light;
|
||||
if (!state.rooms[t.room]) state.rooms[t.room] = {};
|
||||
state.rooms[t.room][key] = true;
|
||||
clearList.push({ room: t.room, key: key });
|
||||
}
|
||||
|
||||
flow.set('nvlInState', state);
|
||||
|
||||
node.warn('[Zigbee to NVL] ' + ROOM_NAME + ' zigbee_sw' + swIndex + '=true (action=' + actionRaw + '), buildAndSend + zigbeeClear');
|
||||
node.warn('[Zigbee to NVL] ' + (friendlyName || 'device') + ' btn' + buttonIndex + ' → ' + targets.map(function (t) { return t.room + ' L' + t.light; }).join(', '));
|
||||
|
||||
msg.payload = { buildAndSend: true };
|
||||
msg.zigbeeClear = { room: ROOM_NAME, key: 'zigbee_sw' + swIndex };
|
||||
msg.zigbeeClear = clearList;
|
||||
return msg;
|
||||
|
||||
Reference in New Issue
Block a user