Showing 48 changed files with 9351 additions and 0 deletions
+233 -0
.doc/battery-level-conversion-nimh-vs-alkaline.md
@@ -0,0 +1,233 @@
1
+# Conversie nivel baterie intre `NiMH` si `alkaline`
2
+
3
+## Scop
4
+
5
+In acest repository exista o conversie configurabila pentru nivelul bateriei raportat de Zigbee2MQTT, folosita atunci cand dispozitivul are baterii reincarcabile `NiMH` in loc de baterii `alkaline`.
6
+
7
+Motivatia este simpla: multe dispozitive raporteaza procentul bateriei pe o curba calibrata mai degraba pentru baterii alcaline, iar celulele `NiMH` au o curba de descarcare mai plata. Din acest motiv, procentul brut poate parea prea mic pentru baterii `NiMH`, mai ales in zona de mijloc a descarcarii.
8
+
9
+Comportamentul este:
10
+
11
+- daca `batteryType` este `alkaline`, valoarea ramane neschimbata
12
+- daca `batteryType` este `nimh`, valoarea este remapata printr-o conversie bazata pe curbe aproximative `alkaline -> tensiune -> NiMH`
13
+- valoarea convertita este folosita atat pentru publicarea capabilitatii `battery`, cat si pentru derivarea lui `battery_low` atunci cand dispozitivul nu furnizeaza explicit acest camp
14
+
15
+## Configurare
16
+
17
+Conversia este controlata din UI-ul nodurilor Node-RED prin campul `Battery type`, cu valoarea implicita `alkaline`.
18
+
19
+Referinte UI:
20
+
21
+- [zg-204zv/homebus-adapter/z2m-zg-204zv-homebus.html](/Users/bogdan/Documents/Workspaces/Home/NodeRed/myNodes/zg-204zv/homebus-adapter/z2m-zg-204zv-homebus.html#L11)
22
+- [PA-44Z/homebus-adapter/z2m-pa-44z-homebus.html](/Users/bogdan/Documents/Workspaces/Home/NodeRed/myNodes/PA-44Z/homebus-adapter/z2m-pa-44z-homebus.html#L11)
23
+
24
+Valorile disponibile sunt:
25
+
26
+- `alkaline`
27
+- `nimh`
28
+
29
+Pragul pentru `battery_low` este separat si configurabil prin `batteryLowThreshold`, implicit `20`.
30
+
31
+## Implementare generala
32
+
33
+Fluxul logic este acelasi in ambele adaptoare:
34
+
35
+1. se citeste `payload.battery`
36
+2. valoarea este clamp-uita in intervalul `0..100`
37
+3. daca tipul bateriei este `nimh`, procentul este convertit mai intai intr-o tensiune echivalenta pe o curba `alkaline`
38
+4. tensiunea echivalenta este proiectata pe o curba `NiMH`
39
+5. valoarea rezultata este publicata pe capabilitatea `battery`
40
+6. `battery_low` se calculeaza din valoarea convertita daca dispozitivul nu trimite deja un camp `battery_low`
41
+
42
+## Proiecte care folosesc functia
43
+
44
+### 1. `zg-204zv/homebus-adapter`
45
+
46
+Implementarea este in:
47
+
48
+- [zg-204zv/homebus-adapter/z2m-zg-204zv-homebus.js](/Users/bogdan/Documents/Workspaces/Home/NodeRed/myNodes/zg-204zv/homebus-adapter/z2m-zg-204zv-homebus.js#L214)
49
+
50
+Configurarea tipului de baterie:
51
+
52
+- [zg-204zv/homebus-adapter/z2m-zg-204zv-homebus.js](/Users/bogdan/Documents/Workspaces/Home/NodeRed/myNodes/zg-204zv/homebus-adapter/z2m-zg-204zv-homebus.js#L34)
53
+
54
+Unde este folosita functia:
55
+
56
+- pentru `battery`: [zg-204zv/homebus-adapter/z2m-zg-204zv-homebus.js](/Users/bogdan/Documents/Workspaces/Home/NodeRed/myNodes/zg-204zv/homebus-adapter/z2m-zg-204zv-homebus.js#L146)
57
+- pentru `battery_low`: [zg-204zv/homebus-adapter/z2m-zg-204zv-homebus.js](/Users/bogdan/Documents/Workspaces/Home/NodeRed/myNodes/zg-204zv/homebus-adapter/z2m-zg-204zv-homebus.js#L164)
58
+
59
+Functia:
60
+
61
+```js
62
+var ALKALINE_BATTERY_CURVE = [
63
+  { pct: 100, voltage: 1.60 },
64
+  { pct: 90, voltage: 1.55 },
65
+  { pct: 80, voltage: 1.50 },
66
+  { pct: 70, voltage: 1.46 },
67
+  { pct: 60, voltage: 1.42 },
68
+  { pct: 50, voltage: 1.36 },
69
+  { pct: 40, voltage: 1.30 },
70
+  { pct: 30, voltage: 1.25 },
71
+  { pct: 20, voltage: 1.20 },
72
+  { pct: 10, voltage: 1.10 },
73
+  { pct: 0, voltage: 0.90 }
74
+];
75
+
76
+var NIMH_BATTERY_CURVE = [
77
+  { pct: 100, voltage: 1.40 },
78
+  { pct: 95, voltage: 1.33 },
79
+  { pct: 90, voltage: 1.28 },
80
+  { pct: 85, voltage: 1.24 },
81
+  { pct: 80, voltage: 1.20 },
82
+  { pct: 75, voltage: 1.19 },
83
+  { pct: 70, voltage: 1.18 },
84
+  { pct: 60, voltage: 1.17 },
85
+  { pct: 50, voltage: 1.16 },
86
+  { pct: 40, voltage: 1.15 },
87
+  { pct: 30, voltage: 1.13 },
88
+  { pct: 20, voltage: 1.11 },
89
+  { pct: 10, voltage: 1.07 },
90
+  { pct: 0, voltage: 1.00 }
91
+];
92
+
93
+function translateBatteryLevel(rawValue) {
94
+  if (rawValue === undefined) return undefined;
95
+  var raw = clamp(Math.round(Number(rawValue)), 0, 100);
96
+  if (node.batteryType !== "nimh") return raw;
97
+
98
+  var estimatedVoltage = interpolateCurve(ALKALINE_BATTERY_CURVE, "pct", "voltage", raw);
99
+  var nimhPct = interpolateCurve(NIMH_BATTERY_CURVE, "voltage", "pct", estimatedVoltage);
100
+  return clamp(Math.round(nimhPct), 0, 100);
101
+}
102
+```
103
+
104
+Observatii:
105
+
106
+- remaparea nu mai este o euristica pe praguri, ci o interpolare liniara intre doua curbe aproximative
107
+- procentul `alkaline` este reinterpretat ca tensiune echivalenta pe celula
108
+- tensiunea rezultata este apoi tradusa in procent `NiMH`
109
+- valoarea de referinta `1.20V` pe celula este tratata ca o zona inca sanatoasa pentru `NiMH`, nu ca baterie aproape goala
110
+
111
+Exemple:
112
+
113
+| Brut | Convertit `nimh` |
114
+| --- | --- |
115
+| 5 | 0 |
116
+| 10 | 18 |
117
+| 20 | 80 |
118
+| 25 | 83 |
119
+| 30 | 86 |
120
+| 40 | 92 |
121
+| 50 | 97 |
122
+| 60 | 100 |
123
+| 80 | 100 |
124
+| 100 | 100 |
125
+
126
+Impact functional:
127
+
128
+- `battery/value` publica procentul convertit
129
+- `battery_low/value` este calculat din procentul convertit daca nu exista deja `battery_low` in payload
130
+- la pragul implicit `20`, un `raw=5` devine `25`, deci nu va fi marcat imediat ca `battery_low`
131
+
132
+### 2. `PA-44Z/homebus-adapter`
133
+
134
+Implementarea este in:
135
+
136
+- [PA-44Z/homebus-adapter/z2m-pa-44z-homebus.js](/Users/bogdan/Documents/Workspaces/Home/NodeRed/myNodes/PA-44Z/homebus-adapter/z2m-pa-44z-homebus.js#L84)
137
+
138
+Configurarea tipului de baterie:
139
+
140
+- [PA-44Z/homebus-adapter/z2m-pa-44z-homebus.js](/Users/bogdan/Documents/Workspaces/Home/NodeRed/myNodes/PA-44Z/homebus-adapter/z2m-pa-44z-homebus.js#L9)
141
+
142
+Unde este folosita functia:
143
+
144
+- pentru `battery`: [PA-44Z/homebus-adapter/z2m-pa-44z-homebus.js](/Users/bogdan/Documents/Workspaces/Home/NodeRed/myNodes/PA-44Z/homebus-adapter/z2m-pa-44z-homebus.js#L215)
145
+- pentru derivarea `battery_low`: [PA-44Z/homebus-adapter/z2m-pa-44z-homebus.js](/Users/bogdan/Documents/Workspaces/Home/NodeRed/myNodes/PA-44Z/homebus-adapter/z2m-pa-44z-homebus.js#L217)
146
+
147
+Functia:
148
+
149
+```js
150
+var ALKALINE_BATTERY_CURVE = [
151
+  { pct: 100, voltage: 1.60 },
152
+  { pct: 90, voltage: 1.55 },
153
+  { pct: 80, voltage: 1.50 },
154
+  { pct: 70, voltage: 1.46 },
155
+  { pct: 60, voltage: 1.42 },
156
+  { pct: 50, voltage: 1.36 },
157
+  { pct: 40, voltage: 1.30 },
158
+  { pct: 30, voltage: 1.25 },
159
+  { pct: 20, voltage: 1.20 },
160
+  { pct: 10, voltage: 1.10 },
161
+  { pct: 0, voltage: 0.90 }
162
+];
163
+
164
+var NIMH_BATTERY_CURVE = [
165
+  { pct: 100, voltage: 1.40 },
166
+  { pct: 95, voltage: 1.33 },
167
+  { pct: 90, voltage: 1.28 },
168
+  { pct: 85, voltage: 1.24 },
169
+  { pct: 80, voltage: 1.20 },
170
+  { pct: 75, voltage: 1.19 },
171
+  { pct: 70, voltage: 1.18 },
172
+  { pct: 60, voltage: 1.17 },
173
+  { pct: 50, voltage: 1.16 },
174
+  { pct: 40, voltage: 1.15 },
175
+  { pct: 30, voltage: 1.13 },
176
+  { pct: 20, voltage: 1.11 },
177
+  { pct: 10, voltage: 1.07 },
178
+  { pct: 0, voltage: 1.00 }
179
+];
180
+
181
+function translateBatteryLevel(rawValue) {
182
+  if (rawValue === null || rawValue === undefined) return null;
183
+  var raw = clamp(Math.round(Number(rawValue)), 0, 100);
184
+  if (node.batteryType !== "nimh") return raw;
185
+
186
+  var estimatedVoltage = interpolateCurve(ALKALINE_BATTERY_CURVE, "pct", "voltage", raw);
187
+  var nimhPct = interpolateCurve(NIMH_BATTERY_CURVE, "voltage", "pct", estimatedVoltage);
188
+  return clamp(Math.round(nimhPct), 0, 100);
189
+}
190
+```
191
+
192
+Observatii:
193
+
194
+- foloseste aceeasi conversie bazata pe curbe ca adaptorul `ZG-204ZV`
195
+- rezultatul este continuu si mai aproape de comportamentul electric asteptat pentru `NiMH`
196
+
197
+Exemple:
198
+
199
+| Brut | Convertit `nimh` |
200
+| --- | --- |
201
+| 5 | 0 |
202
+| 10 | 18 |
203
+| 15 | 40 |
204
+| 20 | 80 |
205
+| 30 | 86 |
206
+| 50 | 97 |
207
+| 55 | 98 |
208
+| 95 | 100 |
209
+
210
+Impact functional:
211
+
212
+- `battery` publicat pe HomeBus este valoarea convertita
213
+- daca `payload.battery_low` lipseste, acesta este derivat din valoarea convertita si comparat cu `batteryLowThreshold`
214
+
215
+## Diferente intre cele doua implementari
216
+
217
+In varianta actuala, ambele adaptoare folosesc aceeasi abordare bazata pe curbe, pentru a evita discrepantele dintre proiecte.
218
+
219
+## Referinte suplimentare in documentatia UI
220
+
221
+Explicatiile din help-ul nodurilor confirma intentia conversiei:
222
+
223
+- [zg-204zv/homebus-adapter/z2m-zg-204zv-homebus.html](/Users/bogdan/Documents/Workspaces/Home/NodeRed/myNodes/zg-204zv/homebus-adapter/z2m-zg-204zv-homebus.html#L64)
224
+- [PA-44Z/homebus-adapter/z2m-pa-44z-homebus.html](/Users/bogdan/Documents/Workspaces/Home/NodeRed/myNodes/PA-44Z/homebus-adapter/z2m-pa-44z-homebus.html#L60)
225
+
226
+## Concluzie
227
+
228
+In starea actuala a repository-ului, functia de conversie intre `NiMH` si `alkaline` este folosita in doua proiecte:
229
+
230
+- `zg-204zv/homebus-adapter`
231
+- `PA-44Z/homebus-adapter`
232
+
233
+Ambele aplica conversia doar cand `batteryType` este setat la `nimh`, iar pentru `alkaline` publica procentul brut primit de la Zigbee2MQTT.
+358 -0
.doc/homekit-adapter-development-guide.md
@@ -0,0 +1,358 @@
1
+# HomeKit Adapter Development Guide
2
+
3
+This guide documents the conventions and lessons learned while building and debugging the custom HomeKit adapters in this repository and their interaction with `node-red-contrib-homekit-bridged`.
4
+
5
+## Scope
6
+
7
+Applies to the custom Node-RED adapters in this repository that:
8
+
9
+- consume retained/live telemetry from MQTT/HomeBus-like topics
10
+- translate that state into HomeKit payloads
11
+- feed `homekit-service` nodes from `node-red-contrib-homekit-bridged`
12
+
13
+Current adapters in scope:
14
+
15
+- `adapters/water-leak-sensor/homekit-adapter`
16
+- `adapters/presence-sensor/homekit-adapter`
17
+
18
+## Core model
19
+
20
+The adapter is responsible for:
21
+
22
+- subscribing to retained bootstrap data and live updates
23
+- keeping a local sensor state model
24
+- mapping that model to HomeKit service payloads
25
+- deciding when bootstrap is complete
26
+- emitting a full state snapshot once the HomeKit side is ready enough to consume it
27
+
28
+`node-red-contrib-homekit-bridged` is responsible for:
29
+
30
+- creating the HomeKit bridge and services
31
+- exposing HAP characteristics
32
+- publishing the bridge/accessories to HomeKit
33
+
34
+Important practical detail:
35
+
36
+- `node-red-contrib-homekit-bridged` delays bridge publication by `5s`
37
+- in practice, usable HomeKit readiness was observed around `7-10s` after startup
38
+
39
+This means early messages can be accepted by Node-RED but still be ineffective for HomeKit initialization.
40
+
41
+## Established conventions
42
+
43
+### 1. Bootstrap window
44
+
45
+Adapters must use a bounded bootstrap window.
46
+
47
+Current convention:
48
+
49
+- `bootstrapDeadlineMs = 10000`
50
+
51
+Reason:
52
+
53
+- retained messages may be missing
54
+- waiting forever leaves HomeKit on defaults forever
55
+- `10s` is long enough for flow initialization and bridge publication, while still reasonable for startup
56
+
57
+### 2. Hold-until-timeout bootstrap
58
+
59
+Current established behavior:
60
+
61
+- subscribe to `.../last` and live topics at startup
62
+- collect retained and live values into local state
63
+- do not immediately finalize bootstrap when all values arrive early
64
+- keep bootstrap open until the `10s` bootstrap deadline
65
+- at deadline, emit one full snapshot and unsubscribe from `.../last`
66
+
67
+Reason:
68
+
69
+- messages emitted before the bridge is fully usable may not initialize HomeKit correctly
70
+- without downstream feedback, repeated retries are arbitrary load
71
+- a single deterministic final bootstrap snapshot is easier to reason about
72
+
73
+### 3. Snapshot over delta at bootstrap
74
+
75
+At bootstrap end, always emit a full snapshot of the known service state.
76
+
77
+Do not rely on:
78
+
79
+- incremental updates only
80
+- the first retained message being enough
81
+- HomeKit reconstructing full state from partial early updates
82
+
83
+### 4. Unknown values are not invented
84
+
85
+At bootstrap timeout:
86
+
87
+- emit all known values
88
+- leave unknown services/characteristics as `null` output for that cycle
89
+
90
+Do not fabricate values just to fill a payload.
91
+
92
+### 5. Cache bypass for bootstrap snapshot
93
+
94
+Adapters may deduplicate repeated payloads during normal operation, but the final bootstrap snapshot must bypass local deduplication.
95
+
96
+Reason:
97
+
98
+- the same values may have been seen during early startup
99
+- HomeKit still needs one authoritative post-bootstrap snapshot
100
+
101
+## Topic and subscription conventions
102
+
103
+### `water-leak-sensor`
104
+
105
+Current supported model:
106
+
107
+- `SNZB-05P`
108
+
109
+Subscribe on startup to:
110
+
111
+- `.../last`
112
+- `.../value`
113
+- `.../availability`
114
+
115
+Bootstrap is based on:
116
+
117
+- `water_leak`
118
+- `battery` or `battery_low`
119
+
120
+### `presence-sensor`
121
+
122
+Current supported model:
123
+
124
+- `ZG-204ZV`
125
+
126
+Subscribe on startup to:
127
+
128
+- `.../last`
129
+- `.../value`
130
+
131
+Bootstrap is based on:
132
+
133
+- `motion`
134
+- `temperature`
135
+- `humidity`
136
+- `illuminance`
137
+- `battery` or `battery_low`
138
+
139
+### Unsubscribe rule
140
+
141
+Keep `.../last` subscribed during bootstrap.
142
+
143
+Unsubscribe from `.../last` only after the final bootstrap snapshot was emitted.
144
+
145
+## Payload conventions
146
+
147
+### General
148
+
149
+Adapters should maintain a local normalized state and derive HomeKit payloads from that state, not directly forward upstream messages.
150
+
151
+Use explicit conversions:
152
+
153
+- booleans via tolerant parsing
154
+- numbers via tolerant parsing
155
+- clamp to HAP-valid ranges
156
+
157
+### Battery payload
158
+
159
+Current convention:
160
+
161
+```json
162
+{"StatusLowBattery":0,"BatteryLevel":100,"ChargingState":2}
163
+```
164
+
165
+Notes:
166
+
167
+- `BatteryLevel` is `uint8`, integer `0..100`
168
+- `ChargingState = 2` means `NOT_CHARGEABLE`
169
+- do not send floats for battery level
170
+
171
+### Motion/occupancy status flags
172
+
173
+For services that include status flags, publish explicit values for:
174
+
175
+- `StatusActive`
176
+- `StatusFault`
177
+- `StatusLowBattery`
178
+- `StatusTampered`
179
+
180
+These should be derived from local state, not omitted.
181
+
182
+## Optional characteristics and `homekit-service` configuration
183
+
184
+This is the main practical integration rule with `node-red-contrib-homekit-bridged`.
185
+
186
+Some HomeKit characteristics are optional in HAP, but the adapter still publishes them.
187
+If they are not materialized on the `homekit-service` node, HomeKit may:
188
+
189
+- ignore them during startup
190
+- keep default values
191
+- only reflect them after a later update
192
+
193
+### Required `characteristicProperties`
194
+
195
+#### `water-leak-sensor`
196
+
197
+Battery service:
198
+
199
+```json
200
+{"BatteryLevel":{},"ChargingState":{}}
201
+```
202
+
203
+#### `presence-sensor`
204
+
205
+Battery service:
206
+
207
+```json
208
+{"BatteryLevel":{},"ChargingState":{}}
209
+```
210
+
211
+Motion service:
212
+
213
+```json
214
+{"StatusActive":{},"StatusFault":{},"StatusLowBattery":{},"StatusTampered":{}}
215
+```
216
+
217
+Occupancy service:
218
+
219
+```json
220
+{"StatusActive":{},"StatusFault":{},"StatusLowBattery":{},"StatusTampered":{}}
221
+```
222
+
223
+### Why this is needed
224
+
225
+Observed behavior during development:
226
+
227
+- optional characteristics can be ignored silently during startup
228
+- HomeKit may expose default values like `BatteryLevel = 0` and `ChargingState = Not Charging`
229
+- the values become correct only after a later update, if at all
230
+
231
+The `characteristicProperties` JSON above forces the relevant optional characteristics to be materialized on the corresponding `homekit-service`.
232
+
233
+### What not to do
234
+
235
+Do not use boolean values like:
236
+
237
+```json
238
+{"BatteryLevel":true,"ChargingState":true}
239
+```
240
+
241
+even if they happen to work as a side effect.
242
+
243
+Use empty objects:
244
+
245
+```json
246
+{"BatteryLevel":{},"ChargingState":{}}
247
+```
248
+
249
+This is clearer and aligns with the intended structure of `characteristicProperties`.
250
+
251
+## Delay and timing rules
252
+
253
+### Adapter startup delay
254
+
255
+Current adapters use:
256
+
257
+- `startSubscriptions` after `250ms`
258
+
259
+This is acceptable for subscription startup, but it is not enough to assume HomeKit readiness.
260
+
261
+### Bridge publication delay
262
+
263
+Observed in `node-red-contrib-homekit-bridged`:
264
+
265
+- bridge publication is delayed by `5000ms`
266
+
267
+Implication:
268
+
269
+- messages earlier than roughly `5s` after startup should not be trusted as bootstrap-finalizing messages for HomeKit behavior
270
+
271
+### Practical readiness window
272
+
273
+Observed in practice:
274
+
275
+- effective readiness was around `7-10s`
276
+
277
+Current convention therefore remains:
278
+
279
+- bootstrap deadline at `10s`
280
+- emit final bootstrap snapshot at that deadline
281
+
282
+## Design rules for new adapters
283
+
284
+When writing a new HomeKit adapter:
285
+
286
+1. Build a local state model first.
287
+2. Normalize all incoming data before mapping to HomeKit.
288
+3. Define which capabilities are mandatory for bootstrap completion.
289
+4. Subscribe to retained and live data separately if needed.
290
+5. Use a bounded bootstrap deadline.
291
+6. Emit one full bootstrap snapshot at the end of the bootstrap window.
292
+7. Unsubscribe from retained/bootstrap topics only after the final snapshot.
293
+8. Document every optional HomeKit characteristic that must be enabled on downstream `homekit-service` nodes.
294
+9. Keep payloads deterministic and range-safe.
295
+10. Prefer explicit service-specific builders like `buildBatteryMsg`, `buildMotionMsg`, etc.
296
+
297
+## Testing workflow
298
+
299
+Recommended order:
300
+
301
+1. Validate local code changes.
302
+2. Deploy to `testing` first.
303
+3. Restart `node-red.service` on `192.168.2.104`.
304
+4. Observe startup behavior for at least `10s`.
305
+5. Verify:
306
+   - bootstrap snapshot timing
307
+   - optional characteristic visibility in HomeKit
308
+   - retained topic unsubscribe behavior
309
+   - no silent fallback to default characteristic values
310
+6. Only then promote to `production`.
311
+
312
+## Deployment notes
313
+
314
+Known hosts:
315
+
316
+- `testing`: `node-red@192.168.2.104`
317
+- `production`: `node-red@192.168.2.101`
318
+- `legacy`: `pi@192.168.2.133`
319
+
320
+Important service name difference observed:
321
+
322
+- on `testing`, restart `node-red.service`
323
+- do not assume the unit is named `nodered.service`
324
+
325
+## Documentation rules for adapters
326
+
327
+Each HomeKit adapter help page should document:
328
+
329
+- startup subscription behavior
330
+- bootstrap rule
331
+- output payload shapes
332
+- required optional characteristics on linked `homekit-service` nodes
333
+- exact `characteristicProperties` JSON as copy-pasteable code blocks
334
+
335
+This is not optional. The downstream Node-RED flow configuration is part of the adapter contract.
336
+
337
+## Known pitfalls
338
+
339
+- Early retained messages can arrive before HomeKit is effectively ready.
340
+- Optional HAP characteristics can be ignored silently at startup.
341
+- Default HAP values can look like valid values while actually meaning "not initialized yet".
342
+- JSON key order is not the root cause for characteristic loss.
343
+- `waitForSetupMsg` is for deferred node configuration, not for runtime bootstrap value materialization.
344
+
345
+## Current repository defaults
346
+
347
+Unless a strong reason appears, keep these defaults for new HomeKit adapters:
348
+
349
+- startup subscription delay: `250ms`
350
+- bootstrap deadline: `10000ms`
351
+- final bootstrap strategy: hold-until-timeout, then emit one full snapshot
352
+- battery payload:
353
+
354
+```json
355
+{"StatusLowBattery":0,"BatteryLevel":100,"ChargingState":2}
356
+```
357
+
358
+- explicit documentation of required `characteristicProperties`
+7 -0
.gitignore
@@ -0,0 +1,7 @@
1
+.DS_Store
2
+node_modules/
3
+npm-debug.log*
4
+yarn-debug.log*
5
+yarn-error.log*
6
+.env
7
+.env.*
+78 -0
README.md
@@ -0,0 +1,78 @@
1
+# Node-RED Custom Nodes
2
+
3
+## Repository layout
4
+
5
+Adaptoarele sunt grupate in folderul `adapters/`, separat de celelalte noduri custom din workspace.
6
+
7
+Nodurile care nu sunt adaptoare raman la radacina, de exemplu:
8
+
9
+- `msg-status`
10
+- `presence-detector`
11
+
12
+In interiorul `adapters/`, adaptoarele sunt organizate pe tip de device, nu pe model.
13
+
14
+Structura standard pentru un device type este:
15
+
16
+- `adapters/<device-type>/homebus-adapter`
17
+- `adapters/<device-type>/homekit-adapter`
18
+- `adapters/<device-type>/energy-adapter` atunci cand exista telemetrie energetica dedicata
19
+- `adapters/<device-type>/models` pentru documentatie sau note per model
20
+
21
+Exemple curente:
22
+
23
+- `adapters/smart-socket/...`
24
+- `adapters/contact-sensor/...`
25
+- `adapters/water-leak-sensor/...`
26
+- `adapters/smoke-detector/...`
27
+- `adapters/presence-sensor/...`
28
+
29
+Scopul acestei structuri este sa permitem mai multe modele Zigbee sub acelasi tip de device, fara sa mai multiplicam layout-ul proiectului la nivel de folder radacina.
30
+
31
+## Deploy targets
32
+
33
+SSH target-urile folosite pentru deploy din acest workspace sunt:
34
+
35
+- `testing`: `node-red@192.168.2.104`
36
+- `production`: `node-red@192.168.2.101`
37
+- `legacy`: `pi@192.168.2.133`
38
+
39
+Verificate prin SSH la data de `2026-03-11`.
40
+
41
+## Service control
42
+
43
+Pe `testing` și `production`, user-ul `node-red` poate controla direct serviciul Node-RED, fără `sudo`:
44
+
45
+```bash
46
+systemctl restart node-red
47
+systemctl is-active node-red
48
+```
49
+
50
+Numele unității de serviciu este:
51
+
52
+```bash
53
+node-red.service
54
+```
55
+
56
+## Notes
57
+
58
+- `deploy.sh` folosește implicit target-ul de `testing`.
59
+- `deploy.sh` accepta cai relative de forma `adapters/device-type/adapter`, de exemplu `adapters/smart-socket/homebus-adapter`.
60
+- Pentru hosturile `testing` și `production`, user-ul SSH folosit efectiv este `node-red`.
61
+- După deploy pe `testing` și `production`, restart-ul standard este `systemctl restart node-red`.
62
+- Hostul `legacy` rămâne accesat cu user-ul `pi`.
63
+- `legacy` este în curs de dezafectare.
64
+- Nu se face deploy pe `legacy` decât în situații speciale.
65
+
66
+## Current verification
67
+
68
+Verificat la data de `2026-03-11`:
69
+
70
+- `testing` `192.168.2.104`: restart-ul `node-red` funcționează direct ca user `node-red`
71
+- `production` `192.168.2.101`: restart-ul `node-red` funcționează direct ca user `node-red`
72
+- `production` rulează `node-red-contrib-z2m-zg-204zv-homebus@0.0.20`
73
+
74
+## Additional documentation
75
+
76
+Documentația detaliată pentru adaptoarele HomeKit și integrarea cu `node-red-contrib-homekit-bridged` este în:
77
+
78
+- [.doc/homekit-adapter-development-guide.md](/Users/bogdan/Documents/Workspaces/Home/NodeRed/myNodes/.doc/homekit-adapter-development-guide.md)
+22 -0
adapters/README.md
@@ -0,0 +1,22 @@
1
+# Adapters
2
+
3
+Acest folder contine toate adaptoarele Node-RED organizate pe tip de device.
4
+
5
+Structura standard este:
6
+
7
+- `<device-type>/homebus-adapter`
8
+- `<device-type>/homekit-adapter`
9
+- `<device-type>/energy-adapter`
10
+- `<device-type>/models`
11
+
12
+Exemple:
13
+
14
+- `smart-socket/homebus-adapter`
15
+- `smoke-detector/homekit-adapter`
16
+- `presence-sensor/models`
17
+
18
+Pentru deploy din radacina workspace-ului:
19
+
20
+```bash
21
+./deploy.sh adapters/smart-socket/homebus-adapter
22
+```
+21 -0
adapters/contact-sensor/homebus-adapter/package.json
@@ -0,0 +1,21 @@
1
+{
2
+  "name": "node-red-contrib-z2m-snzb-04p-homebus",
3
+  "version": "0.0.1",
4
+  "description": "Node-RED node that adapts Zigbee2MQTT SONOFF SNZB-04P messages to canonical HomeBus and adapter operational topics",
5
+  "main": "z2m-snzb-04p-homebus.js",
6
+  "keywords": [
7
+    "node-red",
8
+    "zigbee2mqtt",
9
+    "homebus",
10
+    "contact",
11
+    "battery",
12
+    "voltage"
13
+  ],
14
+  "author": "",
15
+  "license": "MIT",
16
+  "node-red": {
17
+    "nodes": {
18
+      "z2m-snzb-04p-homebus": "z2m-snzb-04p-homebus.js"
19
+    }
20
+  }
21
+}
+66 -0
adapters/contact-sensor/homebus-adapter/z2m-snzb-04p-homebus.html
@@ -0,0 +1,66 @@
1
+<script type="text/x-red" data-template-name="z2m-snzb-04p-homebus">
2
+  <div class="form-row">
3
+    <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
4
+    <input type="text" id="node-input-name" placeholder="Name">
5
+  </div>
6
+  <div class="form-row">
7
+    <label for="node-input-site"><i class="fa fa-globe"></i> Site</label>
8
+    <input type="text" id="node-input-site" placeholder="unknown">
9
+  </div>
10
+  <div class="form-row">
11
+    <label for="node-input-batteryLowThreshold">Battery low threshold (%)</label>
12
+    <input type="number" id="node-input-batteryLowThreshold" min="0" max="100" placeholder="20">
13
+  </div>
14
+</script>
15
+
16
+<script type="text/x-red" data-help-name="z2m-snzb-04p-homebus">
17
+  <p>
18
+    Translates Zigbee2MQTT messages for SONOFF <code>SNZB-04P</code> contact sensors into canonical HomeBus topics.
19
+  </p>
20
+  <p>
21
+    Canonical topic shape:
22
+    <code>&lt;site&gt;/home/&lt;location&gt;/&lt;capability&gt;/&lt;device_id&gt;/&lt;stream&gt;</code>
23
+  </p>
24
+  <p>
25
+    Example outputs:
26
+    <code>vad/home/entry/contact/front-door/value</code>,
27
+    <code>vad/home/entry/battery/front-door/last</code>,
28
+    <code>vad/home/entry/contact/front-door/meta</code>
29
+  </p>
30
+  <p>
31
+    Expected Zigbee2MQTT telemetry topic:
32
+    <code>zigbee2mqtt/SNZB-04P/&lt;site&gt;/&lt;location&gt;/&lt;device_id&gt;</code>.
33
+  </p>
34
+  <p>
35
+    Used fields:
36
+    <code>contact</code>, <code>battery</code>, <code>battery_low</code>, <code>voltage</code>,
37
+    <code>tamper</code>, <code>availability</code>, <code>online</code>.
38
+  </p>
39
+  <ol>
40
+    <li>MQTT-ready publish messages, emitted as an array.</li>
41
+    <li><code>mqtt in</code> control messages for the raw Zigbee2MQTT subscription.</li>
42
+  </ol>
43
+</script>
44
+
45
+<script>
46
+  RED.nodes.registerType("z2m-snzb-04p-homebus", {
47
+    category: "myNodes",
48
+    color: "#d9ecfb",
49
+    defaults: {
50
+      name: { value: "" },
51
+      site: { value: "unknown" },
52
+      batteryLowThreshold: { value: 20, validate: RED.validators.number() },
53
+      mqttSite: { value: "" },
54
+      mqttBus: { value: "" },
55
+      mqttRoom: { value: "" },
56
+      mqttSensor: { value: "" }
57
+    },
58
+    inputs: 1,
59
+    outputs: 2,
60
+    icon: "font-awesome/fa-sitemap",
61
+    label: function() {
62
+      return this.name || "z2m-snzb-04p-homebus";
63
+    },
64
+    paletteLabel: "snzb-04p homebus"
65
+  });
66
+</script>
+535 -0
adapters/contact-sensor/homebus-adapter/z2m-snzb-04p-homebus.js
@@ -0,0 +1,535 @@
1
+module.exports = function(RED) {
2
+  function Z2MSNZB04PHomeBusNode(config) {
3
+    RED.nodes.createNode(this, config);
4
+    var node = this;
5
+
6
+    node.adapterId = "z2m-snzb-04p";
7
+    node.sourceTopic = "zigbee2mqtt/SNZB-04P/#";
8
+    node.subscriptionStarted = false;
9
+    node.startTimer = null;
10
+    node.site = normalizeToken(config.site || config.mqttSite);
11
+    node.legacyRoom = normalizeToken(config.mqttRoom);
12
+    node.legacySensor = normalizeToken(config.mqttSensor);
13
+    node.publishCache = Object.create(null);
14
+    node.retainedCache = Object.create(null);
15
+    node.seenOptionalCapabilities = Object.create(null);
16
+    node.detectedDevices = Object.create(null);
17
+    node.stats = {
18
+      processed_inputs: 0,
19
+      devices_detected: 0,
20
+      home_messages: 0,
21
+      last_messages: 0,
22
+      meta_messages: 0,
23
+      home_availability_messages: 0,
24
+      operational_messages: 0,
25
+      invalid_messages: 0,
26
+      invalid_topics: 0,
27
+      invalid_payloads: 0,
28
+      unmapped_messages: 0,
29
+      adapter_exceptions: 0,
30
+      errors: 0,
31
+      dlq: 0
32
+    };
33
+    node.statsPublishEvery = 25;
34
+    node.batteryLowThreshold = Number(config.batteryLowThreshold);
35
+    if (!Number.isFinite(node.batteryLowThreshold)) node.batteryLowThreshold = 20;
36
+
37
+    var CAPABILITY_MAPPINGS = [
38
+      {
39
+        sourceSystem: "zigbee2mqtt",
40
+        sourceTopicMatch: "zigbee2mqtt/SNZB-04P/<site>/<location>/<device_id>",
41
+        sourceFields: ["contact"],
42
+        targetBus: "home",
43
+        targetCapability: "contact",
44
+        core: true,
45
+        stream: "value",
46
+        payloadProfile: "scalar",
47
+        dataType: "boolean",
48
+        historianMode: "state",
49
+        historianEnabled: true,
50
+        read: function(payload) {
51
+          return readBool(payload, this.sourceFields);
52
+        }
53
+      },
54
+      {
55
+        sourceSystem: "zigbee2mqtt",
56
+        sourceTopicMatch: "zigbee2mqtt/SNZB-04P/<site>/<location>/<device_id>",
57
+        sourceFields: ["battery"],
58
+        targetBus: "home",
59
+        targetCapability: "battery",
60
+        core: true,
61
+        stream: "value",
62
+        payloadProfile: "scalar",
63
+        dataType: "number",
64
+        unit: "%",
65
+        precision: 1,
66
+        historianMode: "sample",
67
+        historianEnabled: true,
68
+        read: function(payload) {
69
+          var value = readNumber(payload, this.sourceFields[0]);
70
+          if (value === undefined) return undefined;
71
+          return clamp(Math.round(value), 0, 100);
72
+        }
73
+      },
74
+      {
75
+        sourceSystem: "zigbee2mqtt",
76
+        sourceTopicMatch: "zigbee2mqtt/SNZB-04P/<site>/<location>/<device_id>",
77
+        sourceFields: ["battery_low", "batteryLow", "battery"],
78
+        targetBus: "home",
79
+        targetCapability: "battery_low",
80
+        core: true,
81
+        stream: "value",
82
+        payloadProfile: "scalar",
83
+        dataType: "boolean",
84
+        historianMode: "state",
85
+        historianEnabled: true,
86
+        read: function(payload) {
87
+          var raw = readBool(payload, this.sourceFields.slice(0, 2));
88
+          if (raw !== undefined) return raw;
89
+          var battery = readNumber(payload, this.sourceFields[2]);
90
+          if (battery === undefined) return undefined;
91
+          return battery <= node.batteryLowThreshold;
92
+        }
93
+      },
94
+      {
95
+        sourceSystem: "zigbee2mqtt",
96
+        sourceTopicMatch: "zigbee2mqtt/SNZB-04P/<site>/<location>/<device_id>",
97
+        sourceFields: ["voltage"],
98
+        targetBus: "home",
99
+        targetCapability: "voltage",
100
+        core: false,
101
+        stream: "value",
102
+        payloadProfile: "scalar",
103
+        dataType: "number",
104
+        unit: "mV",
105
+        precision: 1,
106
+        historianMode: "sample",
107
+        historianEnabled: true,
108
+        read: function(payload) {
109
+          return readNumber(payload, this.sourceFields[0]);
110
+        }
111
+      },
112
+      {
113
+        sourceSystem: "zigbee2mqtt",
114
+        sourceTopicMatch: "zigbee2mqtt/SNZB-04P/<site>/<location>/<device_id>",
115
+        sourceFields: ["tamper", "tampered", "tamper_alarm", "alarm_tamper"],
116
+        targetBus: "home",
117
+        targetCapability: "tamper",
118
+        core: false,
119
+        stream: "value",
120
+        payloadProfile: "scalar",
121
+        dataType: "boolean",
122
+        historianMode: "state",
123
+        historianEnabled: true,
124
+        read: function(payload) {
125
+          return readBool(payload, this.sourceFields);
126
+        }
127
+      }
128
+    ];
129
+
130
+    function normalizeToken(value) {
131
+      if (value === undefined || value === null) return "";
132
+      return String(value).trim();
133
+    }
134
+
135
+    function transliterate(value) {
136
+      var s = normalizeToken(value);
137
+      if (!s) return "";
138
+      if (typeof s.normalize === "function") {
139
+        s = s.normalize("NFKD").replace(/[\u0300-\u036f]/g, "");
140
+      }
141
+      return s;
142
+    }
143
+
144
+    function toKebabCase(value, fallback) {
145
+      var s = transliterate(value).toLowerCase().replace(/[^a-z0-9]+/g, "-");
146
+      s = s.replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-");
147
+      return s || fallback || "";
148
+    }
149
+
150
+    function clamp(n, min, max) {
151
+      return Math.max(min, Math.min(max, n));
152
+    }
153
+
154
+    function topicTokens(topic) {
155
+      if (typeof topic !== "string") return [];
156
+      return topic.split("/").map(function(token) { return token.trim(); }).filter(function(token) { return !!token; });
157
+    }
158
+
159
+    function asBool(value) {
160
+      if (typeof value === "boolean") return value;
161
+      if (typeof value === "number") return value !== 0;
162
+      if (typeof value === "string") {
163
+        var v = value.trim().toLowerCase();
164
+        if (v === "true" || v === "1" || v === "on" || v === "yes" || v === "online") return true;
165
+        if (v === "false" || v === "0" || v === "off" || v === "no" || v === "offline") return false;
166
+      }
167
+      return null;
168
+    }
169
+
170
+    function pickFirst(obj, keys) {
171
+      if (!obj || typeof obj !== "object") return undefined;
172
+      for (var i = 0; i < keys.length; i++) {
173
+        if (Object.prototype.hasOwnProperty.call(obj, keys[i])) return obj[keys[i]];
174
+      }
175
+      return undefined;
176
+    }
177
+
178
+    function pickPath(obj, path) {
179
+      if (!obj || typeof obj !== "object") return undefined;
180
+      var parts = path.split(".");
181
+      var current = obj;
182
+      for (var i = 0; i < parts.length; i++) {
183
+        if (!current || typeof current !== "object" || !Object.prototype.hasOwnProperty.call(current, parts[i])) return undefined;
184
+        current = current[parts[i]];
185
+      }
186
+      return current;
187
+    }
188
+
189
+    function pickFirstPath(obj, paths) {
190
+      for (var i = 0; i < paths.length; i++) {
191
+        var value = pickPath(obj, paths[i]);
192
+        if (value !== undefined && value !== null && normalizeToken(value) !== "") return value;
193
+      }
194
+      return undefined;
195
+    }
196
+
197
+    function readBool(payload, fields) {
198
+      var raw = asBool(pickFirst(payload, fields));
199
+      return raw === null ? undefined : raw;
200
+    }
201
+
202
+    function readNumber(payload, field) {
203
+      if (!payload || typeof payload !== "object") return undefined;
204
+      var value = payload[field];
205
+      if (typeof value !== "number" || !isFinite(value)) return undefined;
206
+      return Number(value);
207
+    }
208
+
209
+    function toIsoTimestamp(value) {
210
+      if (value === undefined || value === null) return "";
211
+      if (value instanceof Date && !isNaN(value.getTime())) return value.toISOString();
212
+      if (typeof value === "number" && isFinite(value)) {
213
+        var ms = value < 100000000000 ? value * 1000 : value;
214
+        var dateFromNumber = new Date(ms);
215
+        return isNaN(dateFromNumber.getTime()) ? "" : dateFromNumber.toISOString();
216
+      }
217
+      if (typeof value === "string") {
218
+        var trimmed = value.trim();
219
+        if (!trimmed) return "";
220
+        if (/^\d+$/.test(trimmed)) return toIsoTimestamp(Number(trimmed));
221
+        var dateFromString = new Date(trimmed);
222
+        return isNaN(dateFromString.getTime()) ? "" : dateFromString.toISOString();
223
+      }
224
+      return "";
225
+    }
226
+
227
+    function canonicalSourceTopic(topic) {
228
+      var t = normalizeToken(topic);
229
+      if (!t) return "";
230
+      if (/\/availability$/i.test(t)) return t.replace(/\/availability$/i, "");
231
+      return t;
232
+    }
233
+
234
+    function inferFromTopic(topic) {
235
+      var tokens = topicTokens(topic);
236
+      var result = {
237
+        deviceType: "",
238
+        site: "",
239
+        location: "",
240
+        deviceId: "",
241
+        friendlyName: "",
242
+        isAvailabilityTopic: false
243
+      };
244
+
245
+      if (tokens.length >= 2 && tokens[0].toLowerCase() === "zigbee2mqtt") {
246
+        result.deviceType = tokens[1];
247
+
248
+        if (tokens.length >= 6 && tokens[5].toLowerCase() === "availability") {
249
+          result.site = tokens[2];
250
+          result.location = tokens[3];
251
+          result.deviceId = tokens[4];
252
+          result.friendlyName = tokens.slice(1, 5).join("/");
253
+          result.isAvailabilityTopic = true;
254
+          return result;
255
+        }
256
+
257
+        if (tokens.length >= 5 && tokens[4].toLowerCase() !== "availability") {
258
+          result.site = tokens[2];
259
+          result.location = tokens[3];
260
+          result.deviceId = tokens[4];
261
+          result.friendlyName = tokens.slice(1, 5).join("/");
262
+          return result;
263
+        }
264
+      }
265
+
266
+      return result;
267
+    }
268
+
269
+    function resolveIdentity(msg, payload) {
270
+      var safeMsg = (msg && typeof msg === "object") ? msg : {};
271
+      var inferred = inferFromTopic(safeMsg.topic);
272
+      var siteRaw = inferred.site
273
+        || normalizeToken(pickFirst(payload, ["site", "homeSite"]))
274
+        || normalizeToken(pickFirst(safeMsg, ["site", "homeSite"]))
275
+        || node.site
276
+        || "";
277
+
278
+      var locationRaw = inferred.location
279
+        || normalizeToken(pickFirst(payload, ["location", "room", "homeLocation", "mqttRoom"]))
280
+        || normalizeToken(pickFirst(safeMsg, ["location", "room", "homeLocation", "mqttRoom"]))
281
+        || node.legacyRoom
282
+        || "";
283
+
284
+      var deviceRaw = inferred.deviceId
285
+        || normalizeToken(pickFirst(payload, ["deviceId", "device_id", "sensor", "mqttSensor", "friendly_name", "friendlyName", "name", "device"]))
286
+        || normalizeToken(pickFirst(safeMsg, ["deviceId", "device_id", "sensor", "mqttSensor"]))
287
+        || node.legacySensor
288
+        || inferred.friendlyName
289
+        || inferred.deviceType;
290
+
291
+      var displayName = normalizeToken(pickFirst(payload, ["display_name", "displayName", "friendly_name", "friendlyName", "name"]))
292
+        || normalizeToken(pickFirst(safeMsg, ["display_name", "displayName", "friendly_name", "friendlyName", "name"]))
293
+        || inferred.friendlyName
294
+        || deviceRaw
295
+        || "SNZB-04P";
296
+
297
+      var sourceRef = normalizeToken(pickFirstPath(payload, ["source_ref", "sourceRef", "ieee_address", "ieeeAddr", "device.ieee_address", "device.ieeeAddr"]))
298
+        || normalizeToken(pickFirstPath(safeMsg, ["source_ref", "sourceRef", "ieee_address", "ieeeAddr", "device.ieee_address", "device.ieeeAddr"]))
299
+        || canonicalSourceTopic(safeMsg.topic)
300
+        || inferred.friendlyName
301
+        || inferred.deviceType
302
+        || deviceRaw
303
+        || "z2m-snzb-04p";
304
+
305
+      return {
306
+        site: toKebabCase(siteRaw, "unknown"),
307
+        location: toKebabCase(locationRaw, "unknown"),
308
+        deviceId: toKebabCase(deviceRaw, "snzb-04p"),
309
+        displayName: displayName,
310
+        sourceRef: sourceRef,
311
+        sourceTopic: canonicalSourceTopic(safeMsg.topic),
312
+        isAvailabilityTopic: inferred.isAvailabilityTopic
313
+      };
314
+    }
315
+
316
+    function noteDevice(identity) {
317
+      if (!identity) return;
318
+      var key = identity.site + "/" + identity.location + "/" + identity.deviceId;
319
+      if (node.detectedDevices[key]) return;
320
+      node.detectedDevices[key] = true;
321
+      node.stats.devices_detected += 1;
322
+    }
323
+
324
+    function validateInboundTopic(topic) {
325
+      var rawTopic = normalizeToken(topic);
326
+      if (!rawTopic) return { valid: false, reason: "Topic must be a non-empty string" };
327
+      var tokens = rawTopic.split("/").map(function(token) { return token.trim(); });
328
+      if (tokens.length < 5) {
329
+        return { valid: false, reason: "Topic must match zigbee2mqtt/SNZB-04P/<site>/<location>/<device_id>[/availability]" };
330
+      }
331
+      if (tokens[0].toLowerCase() !== "zigbee2mqtt" || tokens[1].toLowerCase() !== "snzb-04p") {
332
+        return { valid: false, reason: "Topic must start with zigbee2mqtt/SNZB-04P" };
333
+      }
334
+      if (!tokens[2] || !tokens[3] || !tokens[4]) {
335
+        return { valid: false, reason: "Topic must contain non-empty site, location and device_id segments" };
336
+      }
337
+      if (tokens.length === 5) return { valid: true, isAvailabilityTopic: false };
338
+      if (tokens.length === 6 && tokens[5].toLowerCase() === "availability") {
339
+        return { valid: true, isAvailabilityTopic: true };
340
+      }
341
+      return { valid: false, reason: "Topic must not contain extra segments beyond an optional /availability suffix" };
342
+    }
343
+
344
+    function translatedMessageCount() {
345
+      return node.stats.home_messages + node.stats.last_messages + node.stats.meta_messages + node.stats.home_availability_messages;
346
+    }
347
+
348
+    function updateNodeStatus(fill, shape, suffix) {
349
+      var parts = [
350
+        "dev " + node.stats.devices_detected,
351
+        "in " + node.stats.processed_inputs,
352
+        "tr " + translatedMessageCount()
353
+      ];
354
+      if (node.stats.operational_messages > 0) parts.push("op " + node.stats.operational_messages);
355
+      if (node.stats.errors > 0) parts.push("err " + node.stats.errors);
356
+      if (suffix) parts.push(suffix);
357
+      node.status({ fill: fill, shape: shape, text: parts.join(" | ") });
358
+    }
359
+
360
+    function buildHomeTopic(identity, mapping, stream) {
361
+      return identity.site + "/" + mapping.targetBus + "/" + identity.location + "/" + mapping.targetCapability + "/" + identity.deviceId + "/" + stream;
362
+    }
363
+
364
+    function buildSysTopic(site, stream) {
365
+      return site + "/sys/adapter/" + node.adapterId + "/" + stream;
366
+    }
367
+
368
+    function makePublishMsg(topic, payload, retain) {
369
+      return { topic: topic, payload: payload, qos: 1, retain: !!retain };
370
+    }
371
+
372
+    function makeSubscribeMsg(topic) {
373
+      return {
374
+        action: "subscribe",
375
+        topic: [{
376
+          topic: topic,
377
+          qos: 2,
378
+          rh: 0,
379
+          rap: true
380
+        }]
381
+      };
382
+    }
383
+
384
+    function signature(value) {
385
+      return JSON.stringify(value);
386
+    }
387
+
388
+    function shouldPublish(cacheKey, payload, retain) {
389
+      var sig = signature(payload) + "::" + (retain ? "r" : "n");
390
+      if (node.publishCache[cacheKey] === sig) return false;
391
+      node.publishCache[cacheKey] = sig;
392
+      return true;
393
+    }
394
+
395
+    function shouldPublishRetained(cacheKey, payload) {
396
+      var sig = signature(payload);
397
+      if (node.retainedCache[cacheKey] === sig) return false;
398
+      node.retainedCache[cacheKey] = sig;
399
+      return true;
400
+    }
401
+
402
+    function buildLastEnvelope(value, observedAt, quality) {
403
+      return {
404
+        value: value,
405
+        observed_at: observedAt || new Date().toISOString(),
406
+        quality: quality || "source"
407
+      };
408
+    }
409
+
410
+    function emitCapability(out, identity, mapping, value, observedAt, quality) {
411
+      var valueTopic = buildHomeTopic(identity, mapping, "value");
412
+      var lastTopic = buildHomeTopic(identity, mapping, "last");
413
+      var valueKey = valueTopic + "::value";
414
+      var lastPayload = buildLastEnvelope(value, observedAt, quality);
415
+      if (shouldPublish(valueKey, value, false)) {
416
+        out.push(makePublishMsg(valueTopic, value, false));
417
+        node.stats.home_messages += 1;
418
+      }
419
+      if (shouldPublishRetained(lastTopic, lastPayload)) {
420
+        out.push(makePublishMsg(lastTopic, lastPayload, true));
421
+        node.stats.last_messages += 1;
422
+      }
423
+      node.seenOptionalCapabilities[mapping.targetCapability] = true;
424
+    }
425
+
426
+    function emitMeta(out, identity, sourcePayload) {
427
+      var topic = identity.site + "/home/" + identity.location + "/meta/" + identity.deviceId + "/last";
428
+      var payload = {
429
+        adapter_id: node.adapterId,
430
+        device_type: "SNZB-04P",
431
+        display_name: identity.displayName,
432
+        source_ref: identity.sourceRef,
433
+        source_topic: identity.sourceTopic
434
+      };
435
+      if (sourcePayload && sourcePayload.update) payload.update = sourcePayload.update;
436
+      if (shouldPublishRetained(topic, payload)) {
437
+        out.push(makePublishMsg(topic, payload, true));
438
+        node.stats.meta_messages += 1;
439
+      }
440
+    }
441
+
442
+    function emitAvailability(out, identity, online) {
443
+      var topic = identity.site + "/home/" + identity.location + "/availability/" + identity.deviceId + "/last";
444
+      var payload = online ? "online" : "offline";
445
+      if (shouldPublishRetained(topic, payload)) {
446
+        out.push(makePublishMsg(topic, payload, true));
447
+        node.stats.home_availability_messages += 1;
448
+      }
449
+    }
450
+
451
+    function emitOperational(out, site, stream, payload, retain) {
452
+      out.push(makePublishMsg(buildSysTopic(site || "unknown", stream), payload, retain));
453
+      node.stats.operational_messages += 1;
454
+    }
455
+
456
+    function processTelemetry(msg) {
457
+      var out = [];
458
+      var payload = (msg && typeof msg.payload === "object" && msg.payload && !Array.isArray(msg.payload)) ? msg.payload : null;
459
+      if (!payload) {
460
+        node.stats.invalid_payloads += 1;
461
+        emitOperational(out, "unknown", "dlq", { reason: "invalid-payload", topic: msg && msg.topic }, false);
462
+        return out;
463
+      }
464
+
465
+      var validity = validateInboundTopic(msg.topic);
466
+      if (!validity.valid) {
467
+        node.stats.invalid_topics += 1;
468
+        emitOperational(out, "unknown", "dlq", { reason: validity.reason, topic: msg.topic }, false);
469
+        return out;
470
+      }
471
+
472
+      var identity = resolveIdentity(msg, payload);
473
+      noteDevice(identity);
474
+      emitMeta(out, identity, payload);
475
+
476
+      var observedAt = toIsoTimestamp(payload.last_seen) || new Date().toISOString();
477
+      var quality = toIsoTimestamp(payload.last_seen) ? "source" : "estimated";
478
+
479
+      for (var i = 0; i < CAPABILITY_MAPPINGS.length; i++) {
480
+        var mapping = CAPABILITY_MAPPINGS[i];
481
+        try {
482
+          var value = mapping.read(payload);
483
+          if (value === undefined) continue;
484
+          emitCapability(out, identity, mapping, value, observedAt, quality);
485
+        } catch (err) {
486
+          node.stats.adapter_exceptions += 1;
487
+          emitOperational(out, identity.site, "error", { capability: mapping.targetCapability, error: err.message }, false);
488
+        }
489
+      }
490
+
491
+      if (validity.isAvailabilityTopic || payload.availability !== undefined || payload.online !== undefined) {
492
+        var online = asBool(payload.online);
493
+        if (online === null) online = asBool(payload.availability);
494
+        if (online !== null) emitAvailability(out, identity, online);
495
+      }
496
+
497
+      return out;
498
+    }
499
+
500
+    function startSubscriptions() {
501
+      if (node.subscriptionStarted) return;
502
+      node.subscriptionStarted = true;
503
+      node.send([null, makeSubscribeMsg(node.sourceTopic)]);
504
+      updateNodeStatus("yellow", "ring", "subscribed");
505
+    }
506
+
507
+    node.on("input", function(msg, send, done) {
508
+      send = send || function() { node.send.apply(node, arguments); };
509
+      try {
510
+        node.stats.processed_inputs += 1;
511
+        var outputs = processTelemetry(msg);
512
+        send([outputs, null]);
513
+        updateNodeStatus(node.stats.errors ? "red" : "green", "dot");
514
+        if (done) done();
515
+      } catch (err) {
516
+        node.stats.errors += 1;
517
+        updateNodeStatus("red", "ring", err.message);
518
+        if (done) done(err);
519
+        else node.error(err, msg);
520
+      }
521
+    });
522
+
523
+    node.on("close", function() {
524
+      if (node.startTimer) {
525
+        clearTimeout(node.startTimer);
526
+        node.startTimer = null;
527
+      }
528
+    });
529
+
530
+    node.startTimer = setTimeout(startSubscriptions, 250);
531
+    updateNodeStatus("grey", "ring", "starting");
532
+  }
533
+
534
+  RED.nodes.registerType("z2m-snzb-04p-homebus", Z2MSNZB04PHomeBusNode);
535
+};
+19 -0
adapters/contact-sensor/homekit-adapter/package.json
@@ -0,0 +1,19 @@
1
+{
2
+  "name": "node-red-contrib-z2m-snzb-04p",
3
+  "version": "0.0.1",
4
+  "description": "Node-RED node that adapts HomeBus SONOFF SNZB-04P contact telemetry to HomeKit services",
5
+  "main": "z2m-snzb-04p.js",
6
+  "keywords": [
7
+    "node-red",
8
+    "homekit",
9
+    "contact",
10
+    "battery"
11
+  ],
12
+  "author": "",
13
+  "license": "MIT",
14
+  "node-red": {
15
+    "nodes": {
16
+      "snzb-04p-homekit-adapter": "z2m-snzb-04p.js"
17
+    }
18
+  }
19
+}
+101 -0
adapters/contact-sensor/homekit-adapter/z2m-snzb-04p.html
@@ -0,0 +1,101 @@
1
+<script type="text/x-red" data-template-name="snzb-04p-homekit-adapter">
2
+  <div class="form-row">
3
+    <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
4
+    <input type="text" id="node-input-name" placeholder="Name">
5
+  </div>
6
+  <div class="form-row">
7
+    <label for="node-input-site"><i class="fa fa-globe"></i> Site</label>
8
+    <input type="text" id="node-input-site" placeholder="vad">
9
+  </div>
10
+  <div class="form-row">
11
+    <label for="node-input-location"><i class="fa fa-home"></i> Location</label>
12
+    <input type="text" id="node-input-location" placeholder="entry">
13
+  </div>
14
+  <div class="form-row">
15
+    <label for="node-input-accessory"><i class="fa fa-dot-circle-o"></i> Accessory</label>
16
+    <input type="text" id="node-input-accessory" placeholder="front-door">
17
+  </div>
18
+  <div class="form-row">
19
+    <label for="node-input-sensorUsage">Usage</label>
20
+    <select id="node-input-sensorUsage">
21
+      <option value="door-window">Door / Window sensor</option>
22
+      <option value="contact">Generic contact sensor</option>
23
+      <option value="dual-contact">Dual contact: magnetic + mechanical</option>
24
+    </select>
25
+  </div>
26
+  <div class="form-row">
27
+    <label for="node-input-batteryLowThreshold">Battery low threshold (%)</label>
28
+    <input type="number" id="node-input-batteryLowThreshold" min="0" max="100" placeholder="20">
29
+  </div>
30
+</script>
31
+
32
+<script type="text/x-red" data-help-name="snzb-04p-homekit-adapter">
33
+  <p>
34
+    Consumes HomeBus telemetry for a single <code>SNZB-04P</code> endpoint and projects it to HomeKit services.
35
+  </p>
36
+  <p>
37
+    This adapter supports three usage modes. In all cases the HomeKit service type remains <code>ContactSensor</code>;
38
+    the usage setting documents the intended role of the device in the flow and controls whether a secondary contact output is emitted.
39
+  </p>
40
+  <p>
41
+    <code>Door / Window sensor</code>: use the magnetic contact as the primary contact sensor for doors and windows.
42
+  </p>
43
+  <p>
44
+    <code>Generic contact sensor</code>: use the same primary contact output where the endpoint is not modeled as a door or window in the flow.
45
+  </p>
46
+  <p>
47
+    <code>Dual contact: magnetic + mechanical</code>: expose the magnetic contact on output 1 and the mechanical/tamper contact on output 2.
48
+  </p>
49
+  <p>
50
+    Mappings:
51
+    <code>contact -&gt; Contact Sensor</code>,
52
+    <code>battery + battery_low -&gt; Battery</code>,
53
+    and in <code>dual-contact</code> mode <code>tamper -&gt; secondary Contact Sensor</code>.
54
+    Optional <code>tamper</code> updates are folded into HomeKit status flags when available.
55
+  </p>
56
+  <ol>
57
+    <li>Contact magnetic: <code>{ ContactSensorState, StatusActive, StatusFault, StatusLowBattery, StatusTampered }</code></li>
58
+    <li>Contact mecanic din <code>tamper</code> in modul <code>dual-contact</code>: <code>{ ContactSensorState, StatusActive, StatusFault, StatusLowBattery, StatusTampered }</code></li>
59
+    <li>Battery Service: <code>{ StatusLowBattery, BatteryLevel, ChargingState }</code></li>
60
+    <li><code>mqtt in</code> dynamic control messages: <code>{ action, topic }</code></li>
61
+  </ol>
62
+  <h3>HomeKit Service Setup</h3>
63
+  <p><code>Battery</code></p>
64
+  <pre><code>{"BatteryLevel":{},"ChargingState":{}}</code></pre>
65
+  <p><code>Contact</code></p>
66
+  <pre><code>{"StatusActive":{},"StatusFault":{},"StatusLowBattery":{},"StatusTampered":{}}</code></pre>
67
+  <p><code>Secondary contact</code> in <code>dual-contact</code> mode</p>
68
+  <pre><code>{"StatusActive":{},"StatusFault":{},"StatusLowBattery":{},"StatusTampered":{}}</code></pre>
69
+</script>
70
+
71
+<script>
72
+  function requiredText(v) {
73
+    return !!(v && String(v).trim());
74
+  }
75
+
76
+  RED.nodes.registerType("snzb-04p-homekit-adapter", {
77
+    category: "myNodes",
78
+    color: "#d7f0d1",
79
+    defaults: {
80
+      name: { value: "" },
81
+      outputTopic: { value: "" },
82
+      mqttBus: { value: "" },
83
+      site: { value: "", validate: requiredText },
84
+      location: { value: "", validate: requiredText },
85
+      accessory: { value: "", validate: requiredText },
86
+      sensorUsage: { value: "door-window" },
87
+      mqttSite: { value: "" },
88
+      mqttRoom: { value: "" },
89
+      mqttSensor: { value: "" },
90
+      batteryLowThreshold: { value: 20, validate: RED.validators.number() },
91
+      publishCacheWindowSec: { value: "" }
92
+    },
93
+    inputs: 1,
94
+    outputs: 4,
95
+    icon: "font-awesome/fa-exchange",
96
+    label: function() {
97
+      return this.name || "snzb-04p-homekit-adapter";
98
+    },
99
+    paletteLabel: "snzb-04p homekit"
100
+  });
101
+</script>
+411 -0
adapters/contact-sensor/homekit-adapter/z2m-snzb-04p.js
@@ -0,0 +1,411 @@
1
+module.exports = function(RED) {
2
+  function Z2MSNZB04PNode(config) {
3
+    RED.nodes.createNode(this, config);
4
+    var node = this;
5
+
6
+    node.site = normalizeToken(config.site || config.mqttSite || "");
7
+    node.location = normalizeToken(config.location || config.mqttRoom || "");
8
+    node.accessory = normalizeToken(config.accessory || config.mqttSensor || "");
9
+    node.sensorUsage = normalizeToken(config.sensorUsage || "door-window");
10
+    node.batteryLowThreshold = parseNumber(config.batteryLowThreshold, 20, 0);
11
+    node.bootstrapDeadlineMs = 10000;
12
+    node.hkCache = Object.create(null);
13
+    node.startTimer = null;
14
+    node.bootstrapTimer = null;
15
+    node.lastMsgContext = null;
16
+
17
+    node.stats = {
18
+      controls: 0,
19
+      last_inputs: 0,
20
+      value_inputs: 0,
21
+      availability_inputs: 0,
22
+      hk_updates: 0,
23
+      errors: 0
24
+    };
25
+
26
+    node.subscriptionState = {
27
+      started: false,
28
+      lastSubscribed: false,
29
+      valueSubscribed: false,
30
+      availabilitySubscribed: false
31
+    };
32
+
33
+    node.bootstrapState = {
34
+      finalized: false,
35
+      contact: false,
36
+      battery: false,
37
+      secondaryContact: false
38
+    };
39
+
40
+    node.sensorState = {
41
+      active: false,
42
+      site: node.site || "",
43
+      location: node.location || "",
44
+      deviceId: node.accessory || "",
45
+      contactKnown: false,
46
+      contact: true,
47
+      batteryKnown: false,
48
+      battery: null,
49
+      batteryLowKnown: false,
50
+      batteryLow: false,
51
+      tamperedKnown: false,
52
+      tampered: false
53
+    };
54
+
55
+    function parseNumber(value, fallback, min) {
56
+      var n = Number(value);
57
+      if (!Number.isFinite(n)) return fallback;
58
+      if (typeof min === "number" && n < min) return fallback;
59
+      return n;
60
+    }
61
+
62
+    function normalizeToken(value) {
63
+      if (value === undefined || value === null) return "";
64
+      return String(value).trim();
65
+    }
66
+
67
+    function asBool(value) {
68
+      if (typeof value === "boolean") return value;
69
+      if (typeof value === "number") return value !== 0;
70
+      if (typeof value === "string") {
71
+        var v = value.trim().toLowerCase();
72
+        if (v === "true" || v === "1" || v === "on" || v === "yes" || v === "online") return true;
73
+        if (v === "false" || v === "0" || v === "off" || v === "no" || v === "offline") return false;
74
+      }
75
+      return null;
76
+    }
77
+
78
+    function asNumber(value) {
79
+      if (typeof value === "number" && isFinite(value)) return value;
80
+      if (typeof value === "string") {
81
+        var trimmed = value.trim();
82
+        if (!trimmed) return null;
83
+        var parsed = Number(trimmed);
84
+        if (isFinite(parsed)) return parsed;
85
+      }
86
+      return null;
87
+    }
88
+
89
+    function clamp(n, min, max) {
90
+      return Math.max(min, Math.min(max, n));
91
+    }
92
+
93
+    function signature(value) {
94
+      return JSON.stringify(value);
95
+    }
96
+
97
+    function shouldPublish(cacheKey, payload) {
98
+      var sig = signature(payload);
99
+      if (node.hkCache[cacheKey] === sig) return false;
100
+      node.hkCache[cacheKey] = sig;
101
+      return true;
102
+    }
103
+
104
+    function cloneBaseMsg(msg) {
105
+      if (!msg || typeof msg !== "object") return {};
106
+      var out = {};
107
+      if (typeof msg.topic === "string") out.topic = msg.topic;
108
+      if (msg._msgid) out._msgid = msg._msgid;
109
+      return out;
110
+    }
111
+
112
+    function clearBootstrapTimer() {
113
+      if (!node.bootstrapTimer) return;
114
+      clearTimeout(node.bootstrapTimer);
115
+      node.bootstrapTimer = null;
116
+    }
117
+
118
+    function makeHomeKitMsg(baseMsg, payload) {
119
+      var out = RED.util.cloneMessage(baseMsg || {});
120
+      out.payload = payload;
121
+      return out;
122
+    }
123
+
124
+    function buildSubscriptionTopic(stream) {
125
+      return [node.site, "home", node.location, "+", node.accessory, stream].join("/");
126
+    }
127
+
128
+    function buildSubscribeMsgs() {
129
+      return [
130
+        { action: "subscribe", topic: buildSubscriptionTopic("last"), qos: 2, rh: 0, rap: true },
131
+        { action: "subscribe", topic: buildSubscriptionTopic("value"), qos: 2, rh: 0, rap: true },
132
+        { action: "subscribe", topic: buildSubscriptionTopic("availability"), qos: 2, rh: 0, rap: true }
133
+      ];
134
+    }
135
+
136
+    function buildUnsubscribeLastMsg(reason) {
137
+      return { action: "unsubscribe", topic: buildSubscriptionTopic("last"), reason: reason };
138
+    }
139
+
140
+    function statusText(prefix) {
141
+      var state = node.subscriptionState.lastSubscribed ? "cold" : (node.subscriptionState.started ? "live" : "idle");
142
+      var device = node.sensorState.deviceId || node.accessory || "?";
143
+      var usage = node.sensorUsage === "contact"
144
+        ? "contact"
145
+        : (node.sensorUsage === "dual-contact" ? "dual-contact" : "door/window");
146
+      return [prefix || state, usage, device, "l:" + node.stats.last_inputs, "v:" + node.stats.value_inputs, "a:" + node.stats.availability_inputs, "hk:" + node.stats.hk_updates].join(" ");
147
+    }
148
+
149
+    function setNodeStatus(prefix, fill, shape) {
150
+      node.status({
151
+        fill: fill || (node.stats.errors ? "red" : (node.subscriptionState.lastSubscribed ? "yellow" : (node.sensorState.active ? "green" : "yellow"))),
152
+        shape: shape || "dot",
153
+        text: statusText(prefix)
154
+      });
155
+    }
156
+
157
+    function noteError(text, msg) {
158
+      node.stats.errors += 1;
159
+      node.warn(text);
160
+      node.status({ fill: "red", shape: "ring", text: text });
161
+      if (msg) node.debug(msg);
162
+    }
163
+
164
+    function buildStatusFields() {
165
+      return {
166
+        StatusActive: !!node.sensorState.active,
167
+        StatusFault: node.sensorState.active ? 0 : 1,
168
+        StatusLowBattery: node.sensorState.batteryLow ? 1 : 0,
169
+        StatusTampered: node.sensorState.tampered ? 1 : 0
170
+      };
171
+    }
172
+
173
+    function buildContactMsg(baseMsg) {
174
+      if (!node.sensorState.contactKnown) return null;
175
+      var payload = buildStatusFields();
176
+      payload.ContactSensorState = node.sensorState.contact ? 0 : 1;
177
+      if (!shouldPublish("hk:contact", payload)) return null;
178
+      node.stats.hk_updates += 1;
179
+      return makeHomeKitMsg(baseMsg, payload);
180
+    }
181
+
182
+    function buildSecondaryContactMsg(baseMsg) {
183
+      if (node.sensorUsage !== "dual-contact") return null;
184
+      if (!node.sensorState.tamperedKnown) return null;
185
+      var payload = buildStatusFields();
186
+      payload.ContactSensorState = node.sensorState.tampered ? 0 : 1;
187
+      if (!shouldPublish("hk:secondary-contact", payload)) return null;
188
+      node.stats.hk_updates += 1;
189
+      return makeHomeKitMsg(baseMsg, payload);
190
+    }
191
+
192
+    function buildBatteryMsg(baseMsg) {
193
+      if (!node.sensorState.batteryKnown && !node.sensorState.batteryLowKnown) return null;
194
+      var batteryLevel = node.sensorState.batteryKnown
195
+        ? clamp(Math.round(Number(node.sensorState.battery)), 0, 100)
196
+        : (node.sensorState.batteryLow ? 1 : 100);
197
+      var payload = {
198
+        StatusLowBattery: node.sensorState.batteryLow ? 1 : 0,
199
+        BatteryLevel: batteryLevel,
200
+        ChargingState: 2
201
+      };
202
+      if (!shouldPublish("hk:battery", payload)) return null;
203
+      node.stats.hk_updates += 1;
204
+      return makeHomeKitMsg(baseMsg, payload);
205
+    }
206
+
207
+    function clearSnapshotCache() {
208
+      delete node.hkCache["hk:contact"];
209
+      delete node.hkCache["hk:battery"];
210
+      delete node.hkCache["hk:secondary-contact"];
211
+    }
212
+
213
+    function buildBootstrapOutputs(baseMsg) {
214
+      clearSnapshotCache();
215
+      return [buildContactMsg(baseMsg), buildSecondaryContactMsg(baseMsg), buildBatteryMsg(baseMsg)];
216
+    }
217
+
218
+    function unsubscribeLast(reason) {
219
+      if (!node.subscriptionState.lastSubscribed) return null;
220
+      node.subscriptionState.lastSubscribed = false;
221
+      node.stats.controls += 1;
222
+      return buildUnsubscribeLastMsg(reason);
223
+    }
224
+
225
+    function markBootstrapSatisfied(capability) {
226
+      if (capability === "contact" && node.sensorState.contactKnown) {
227
+        node.bootstrapState.contact = true;
228
+      } else if ((capability === "battery" || capability === "battery_low") && (node.sensorState.batteryKnown || node.sensorState.batteryLowKnown)) {
229
+        node.bootstrapState.battery = true;
230
+      } else if (capability === "tamper" && node.sensorUsage === "dual-contact" && node.sensorState.tamperedKnown) {
231
+        node.bootstrapState.secondaryContact = true;
232
+      }
233
+    }
234
+
235
+    function bootstrapReady() {
236
+      if (!node.bootstrapState.contact || !node.bootstrapState.battery) return false;
237
+      if (node.sensorUsage === "dual-contact") return node.bootstrapState.secondaryContact;
238
+      return true;
239
+    }
240
+
241
+    function finalizeBootstrap(reason, send) {
242
+      if (node.bootstrapState.finalized) return false;
243
+      if (!node.subscriptionState.lastSubscribed) return false;
244
+      node.bootstrapState.finalized = true;
245
+      clearBootstrapTimer();
246
+      send = send || function(msgs) { node.send(msgs); };
247
+      var outputs = buildBootstrapOutputs(cloneBaseMsg(node.lastMsgContext));
248
+      var controlMsg = unsubscribeLast(reason);
249
+      send([outputs[0], outputs[1], outputs[2], controlMsg]);
250
+      setNodeStatus("live");
251
+      return true;
252
+    }
253
+
254
+    function parseTopic(topic) {
255
+      if (typeof topic !== "string") return null;
256
+      var tokens = topic.split("/").map(function(token) { return token.trim(); }).filter(function(token) { return !!token; });
257
+      if (tokens.length !== 6) return null;
258
+      if (tokens[1] !== "home") return null;
259
+      if (tokens[5] !== "value" && tokens[5] !== "last" && tokens[5] !== "availability") return { ignored: true };
260
+      if ((node.site && tokens[0] !== node.site) || (node.location && tokens[2] !== node.location) || (node.accessory && tokens[4] !== node.accessory)) return { ignored: true };
261
+      return { site: tokens[0], location: tokens[2], capability: tokens[3], deviceId: tokens[4], stream: tokens[5] };
262
+    }
263
+
264
+    function extractValue(stream, payload) {
265
+      if (stream === "last" && payload && typeof payload === "object" && !Array.isArray(payload) && Object.prototype.hasOwnProperty.call(payload, "value")) {
266
+        return payload.value;
267
+      }
268
+      return payload;
269
+    }
270
+
271
+    function updateBatteryLowFromThreshold() {
272
+      if (!node.sensorState.batteryKnown || node.sensorState.batteryLowKnown) return;
273
+      node.sensorState.batteryLow = Number(node.sensorState.battery) <= node.batteryLowThreshold;
274
+    }
275
+
276
+    function processAvailability(baseMsg, value) {
277
+      var active = asBool(value);
278
+      if (active === null) return [null, null, null];
279
+      node.sensorState.active = active;
280
+      node.lastMsgContext = cloneBaseMsg(baseMsg);
281
+      return [buildContactMsg(baseMsg), buildSecondaryContactMsg(baseMsg), buildBatteryMsg(baseMsg)];
282
+    }
283
+
284
+    function processCapability(baseMsg, parsed, value) {
285
+      var contactMsg = null;
286
+      var secondaryContactMsg = null;
287
+      var batteryMsg = null;
288
+
289
+      node.sensorState.active = true;
290
+      node.sensorState.site = parsed.site;
291
+      node.sensorState.location = parsed.location;
292
+      node.sensorState.deviceId = parsed.deviceId;
293
+      node.lastMsgContext = cloneBaseMsg(baseMsg);
294
+
295
+      if (parsed.capability === "contact") {
296
+        var contact = asBool(value);
297
+        if (contact === null) return [null, null, null];
298
+        node.sensorState.contactKnown = true;
299
+        node.sensorState.contact = contact;
300
+        contactMsg = buildContactMsg(baseMsg);
301
+      } else if (parsed.capability === "battery") {
302
+        var battery = asNumber(value);
303
+        if (battery === null) return [null, null, null];
304
+        node.sensorState.batteryKnown = true;
305
+        node.sensorState.battery = clamp(Math.round(battery), 0, 100);
306
+        updateBatteryLowFromThreshold();
307
+        batteryMsg = buildBatteryMsg(baseMsg);
308
+        contactMsg = buildContactMsg(baseMsg);
309
+        secondaryContactMsg = buildSecondaryContactMsg(baseMsg);
310
+      } else if (parsed.capability === "battery_low") {
311
+        var batteryLow = asBool(value);
312
+        if (batteryLow === null) return [null, null, null];
313
+        node.sensorState.batteryLowKnown = true;
314
+        node.sensorState.batteryLow = batteryLow;
315
+        batteryMsg = buildBatteryMsg(baseMsg);
316
+        contactMsg = buildContactMsg(baseMsg);
317
+        secondaryContactMsg = buildSecondaryContactMsg(baseMsg);
318
+      } else if (parsed.capability === "tamper") {
319
+        var tampered = asBool(value);
320
+        if (tampered === null) return [null, null, null];
321
+        node.sensorState.tamperedKnown = true;
322
+        node.sensorState.tampered = tampered;
323
+        contactMsg = buildContactMsg(baseMsg);
324
+        batteryMsg = buildBatteryMsg(baseMsg);
325
+        secondaryContactMsg = buildSecondaryContactMsg(baseMsg);
326
+      } else {
327
+        return [null, null, null];
328
+      }
329
+
330
+      return [contactMsg, secondaryContactMsg, batteryMsg];
331
+    }
332
+
333
+    function startSubscriptions() {
334
+      if (node.subscriptionState.started) return;
335
+      if (!node.site || !node.location || !node.accessory) {
336
+        noteError("missing site, location or accessory");
337
+        return;
338
+      }
339
+      node.subscriptionState.started = true;
340
+      node.subscriptionState.lastSubscribed = true;
341
+      node.subscriptionState.valueSubscribed = true;
342
+      node.subscriptionState.availabilitySubscribed = true;
343
+      clearBootstrapTimer();
344
+      node.bootstrapTimer = setTimeout(function() {
345
+        finalizeBootstrap("bootstrap-timeout");
346
+      }, node.bootstrapDeadlineMs);
347
+      node.stats.controls += 1;
348
+      node.send([null, null, null, buildSubscribeMsgs()]);
349
+      setNodeStatus("cold");
350
+    }
351
+
352
+    node.on("input", function(msg, send, done) {
353
+      send = send || function() { node.send.apply(node, arguments); };
354
+      try {
355
+        var parsed = parseTopic(msg && msg.topic);
356
+        if (!parsed) {
357
+          noteError("invalid topic");
358
+          if (done) done();
359
+          return;
360
+        }
361
+        if (parsed.ignored) {
362
+          if (done) done();
363
+          return;
364
+        }
365
+
366
+        var value = extractValue(parsed.stream, msg.payload);
367
+        var outputs;
368
+        if (parsed.stream === "last") {
369
+          node.stats.last_inputs += 1;
370
+          outputs = processCapability(msg, parsed, value);
371
+          markBootstrapSatisfied(parsed.capability);
372
+        } else if (parsed.stream === "value") {
373
+          node.stats.value_inputs += 1;
374
+          outputs = processCapability(msg, parsed, value);
375
+          markBootstrapSatisfied(parsed.capability);
376
+        } else {
377
+          node.stats.availability_inputs += 1;
378
+          outputs = processAvailability(msg, value);
379
+        }
380
+
381
+        if (node.subscriptionState.lastSubscribed && bootstrapReady()) {
382
+          finalizeBootstrap("bootstrap-complete", send);
383
+          if (done) done();
384
+          return;
385
+        }
386
+
387
+        send([outputs[0], outputs[1], outputs[2], null]);
388
+        setNodeStatus();
389
+        if (done) done();
390
+      } catch (err) {
391
+        node.stats.errors += 1;
392
+        node.status({ fill: "red", shape: "ring", text: "error: " + err.message });
393
+        if (done) done(err);
394
+        else node.error(err, msg);
395
+      }
396
+    });
397
+
398
+    node.on("close", function() {
399
+      clearBootstrapTimer();
400
+      if (node.startTimer) {
401
+        clearTimeout(node.startTimer);
402
+        node.startTimer = null;
403
+      }
404
+    });
405
+
406
+    node.startTimer = setTimeout(startSubscriptions, 250);
407
+    node.status({ fill: "grey", shape: "ring", text: "starting" });
408
+  }
409
+
410
+  RED.nodes.registerType("snzb-04p-homekit-adapter", Z2MSNZB04PNode);
411
+};
+65 -0
adapters/contact-sensor/models/SNZB-04P.md
@@ -0,0 +1,65 @@
1
+---
2
+title: "SONOFF SNZB-04 control via MQTT"
3
+description: "Integrate your SONOFF SNZB-04 via Zigbee2MQTT with whatever smart home infrastructure you are using without the vendor's bridge or gateway."
4
+addedAt: 2020-07-10T21:02:48Z
5
+pageClass: device-page
6
+---
7
+
8
+<!-- !!!! -->
9
+<!-- ATTENTION: This file is auto-generated through docgen! -->
10
+<!-- You can only edit the "Notes"-Section between the two comment lines "Notes BEGIN" and "Notes END". -->
11
+<!-- Do not use h1 or h2 heading within "## Notes"-Section. -->
12
+<!-- !!!! -->
13
+
14
+# SONOFF SNZB-04
15
+
16
+|     |     |
17
+|-----|-----|
18
+| Model | SNZB-04  |
19
+| Vendor  | [SONOFF](/supported-devices/#v=SONOFF)  |
20
+| Description | Contact sensor |
21
+| Exposes | battery, voltage, contact, battery_low |
22
+| Picture | ![SONOFF SNZB-04](https://www.zigbee2mqtt.io/images/devices/SNZB-04.png) |
23
+| White-label | eWeLink RHK06 |
24
+
25
+
26
+<!-- Notes BEGIN: You can edit here. Add "## Notes" headline if not already present. -->
27
+## Notes
28
+
29
+
30
+### Pairing
31
+Long press reset button for 5s until the LED indicator flashes three times, which means the device has entered pairing mode
32
+<!-- Notes END: Do not edit below this line -->
33
+
34
+
35
+
36
+
37
+## Exposes
38
+
39
+### Battery (numeric)
40
+Remaining battery in %.
41
+Value can be found in the published state on the `battery` property.
42
+To read (`/get`) the value publish a message to topic `zigbee2mqtt/FRIENDLY_NAME/get` with payload `{"battery": ""}`.
43
+It's not possible to write (`/set`) this value.
44
+The minimal value is `0` and the maximum value is `100`.
45
+The unit of this value is `%`.
46
+
47
+### Voltage (numeric)
48
+Reported battery voltage in millivolts.
49
+Value can be found in the published state on the `voltage` property.
50
+To read (`/get`) the value publish a message to topic `zigbee2mqtt/FRIENDLY_NAME/get` with payload `{"voltage": ""}`.
51
+It's not possible to write (`/set`) this value.
52
+The unit of this value is `mV`.
53
+
54
+### Contact (binary)
55
+Indicates whether the device is opened or closed.
56
+Value can be found in the published state on the `contact` property.
57
+It's not possible to read (`/get`) or write (`/set`) this value.
58
+If value equals `false` contact is ON, if `true` OFF.
59
+
60
+### Battery low (binary)
61
+Indicates whether the battery of the device is almost empty.
62
+Value can be found in the published state on the `battery_low` property.
63
+It's not possible to read (`/get`) or write (`/set`) this value.
64
+If value equals `true` battery low is ON, if `false` OFF.
65
+
+23 -0
adapters/presence-sensor/homebus-adapter/package.json
@@ -0,0 +1,23 @@
1
+{
2
+  "name": "node-red-contrib-z2m-zg-204zv-homebus",
3
+  "version": "0.0.20",
4
+  "description": "Node-RED node that adapts Zigbee2MQTT HOBEIAN ZG-204ZV messages to canonical HomeBus and adapter operational topics",
5
+  "main": "z2m-zg-204zv-homebus.js",
6
+  "keywords": [
7
+    "node-red",
8
+    "zigbee2mqtt",
9
+    "homebus",
10
+    "presence",
11
+    "temperature",
12
+    "humidity",
13
+    "illuminance",
14
+    "battery"
15
+  ],
16
+  "author": "",
17
+  "license": "MIT",
18
+  "node-red": {
19
+    "nodes": {
20
+      "z2m-zg-204zv-homebus": "z2m-zg-204zv-homebus.js"
21
+    }
22
+  }
23
+}
+158 -0
adapters/presence-sensor/homebus-adapter/z2m-zg-204zv-homebus.html
@@ -0,0 +1,158 @@
1
+<script type="text/x-red" data-template-name="z2m-zg-204zv-homebus">
2
+  <div class="form-row">
3
+    <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
4
+    <input type="text" id="node-input-name" placeholder="Name">
5
+  </div>
6
+  <div class="form-row">
7
+    <label for="node-input-batteryLowThreshold">Battery low threshold (%)</label>
8
+    <input type="number" id="node-input-batteryLowThreshold" min="0" max="100" placeholder="20">
9
+  </div>
10
+  <div class="form-row">
11
+    <label for="node-input-batteryType">Battery type</label>
12
+    <select id="node-input-batteryType">
13
+      <option value="alkaline">Alkaline</option>
14
+      <option value="nimh">Rechargeable NiMH</option>
15
+    </select>
16
+  </div>
17
+</script>
18
+
19
+<script type="text/x-red" data-help-name="z2m-zg-204zv-homebus">
20
+  <p>
21
+    Translates Zigbee2MQTT messages for HOBEIAN <code>ZG-204ZV</code> into canonical HomeBus topics.
22
+  </p>
23
+  <p>
24
+    Canonical topic shape:
25
+    <code>&lt;site&gt;/home/&lt;location&gt;/&lt;capability&gt;/&lt;device_id&gt;/&lt;stream&gt;</code>
26
+  </p>
27
+  <p>
28
+    Example outputs:
29
+    <code>vad/home/living-room/motion/radar-south/value</code>,
30
+    <code>vad/home/living-room/temperature/radar-south/value</code>,
31
+    <code>vad/home/living-room/temperature/radar-south/last</code>,
32
+    <code>vad/home/living-room/motion/radar-south/meta</code>
33
+  </p>
34
+  <h3>Input</h3>
35
+  <p>
36
+    Expected Zigbee2MQTT telemetry topic:
37
+    <code>zigbee2mqtt/ZG-204ZV/&lt;site&gt;/&lt;location&gt;/&lt;device_id&gt;</code> with a JSON payload.
38
+  </p>
39
+  <p>
40
+    Availability topic is also supported:
41
+    <code>zigbee2mqtt/ZG-204ZV/&lt;site&gt;/&lt;location&gt;/&lt;device_id&gt;/availability</code> with payload <code>online</code> or <code>offline</code>.
42
+  </p>
43
+  <p>
44
+    Typical subscription for this adapter:
45
+    <code>zigbee2mqtt/ZG-204ZV/#</code>
46
+  </p>
47
+  <p>
48
+    Output 2 controls a dynamic <code>mqtt in</code> node on the raw Zigbee2MQTT broker. On startup, the adapter emits
49
+    <code>{ action: "subscribe", topic: "zigbee2mqtt/ZG-204ZV/#" }</code>.
50
+  </p>
51
+  <p>
52
+    This node is intended to fan out traffic for multiple <code>ZG-204ZV</code> devices, not to be instantiated per device.
53
+  </p>
54
+  <p>
55
+    The node status shows adapter statistics such as detected devices, processed inputs, translated publications,
56
+    operational messages, and errors. Invalid topics, invalid messages, invalid payloads, and unmapped payloads are counted separately in the status text.
57
+  </p>
58
+  <p>
59
+    Used fields: <code>presence</code>, <code>temperature</code>, <code>humidity</code>,
60
+    <code>fading_time</code>, <code>illuminance</code>, <code>battery</code>, <code>battery_low</code>,
61
+    <code>tamper</code>, <code>availability</code>, <code>online</code>.
62
+  </p>
63
+  <p>
64
+    Battery translation can be configured for the installed cells. Default is <code>Alkaline</code>.
65
+    When <code>Rechargeable NiMH</code> is selected, the adapter applies a curve-based remap to the incoming
66
+    Zigbee battery percentage before publishing <code>battery</code> and deriving <code>battery_low</code>.
67
+  </p>
68
+  <p>
69
+    The NiMH remap approximates an alkaline percentage to cell voltage and then projects that voltage onto a flatter
70
+    NiMH discharge curve. If the device firmware starts reporting a better native percentage later, switch the adapter back to
71
+    <code>Alkaline</code> to disable the translation.
72
+  </p>
73
+  <h3>Output</h3>
74
+  <ol>
75
+    <li>MQTT-ready publish messages, emitted as an array of messages on the semantic/output path.</li>
76
+    <li><code>mqtt in</code> control messages for the raw Zigbee2MQTT subscription.</li>
77
+  </ol>
78
+  <p>
79
+    Semantic bus messages are published in minimal form only:
80
+    <code>msg.topic</code>, <code>msg.payload</code>, <code>msg.qos</code>, and <code>msg.retain</code>.
81
+    Adapter internals such as vendor payload snapshots are not forwarded on the HomeBus output.
82
+  </p>
83
+  <p>
84
+    Live telemetry is unified on <code>value</code>. The adapter publishes lightweight hot-path <code>value</code> updates and deduplicates them on change.
85
+  </p>
86
+  <p>
87
+    Retained <code>last</code> carries the timestamped latest known sample for bootstrap and freshness evaluation.
88
+  </p>
89
+  <p>
90
+    The <code>last</code> payload uses a small JSON envelope with <code>value</code> and <code>observed_at</code>. When the source does not provide a timestamp, the adapter uses ingestion time and marks <code>quality=estimated</code>.
91
+  </p>
92
+  <p>
93
+    The node publishes retained <code>meta</code> and <code>availability</code> topics plus live
94
+    <code>value</code> topics for the capabilities supported by this sensor, together with retained <code>last</code> for the latest timestamped sample.
95
+  </p>
96
+  <p>
97
+    Operational topics are emitted on the same output under:
98
+    <code>&lt;site&gt;/sys/adapter/z2m-zg-204zv/{availability,stats,error,dlq}</code>.
99
+  </p>
100
+  <p>
101
+    The adapter identifier is fixed to <code>z2m-zg-204zv</code>. Device identity and <code>source_ref</code> are derived per message from the inbound topic or payload.
102
+  </p>
103
+  <p>
104
+    Mapping:
105
+    <code>presence/occupancy + fading_time=0 -&gt; motion/value</code>,
106
+    <code>presence/occupancy + fading_time&gt;0 -&gt; presence/value</code>,
107
+    <code>temperature -&gt; temperature/value</code>,
108
+    <code>humidity -&gt; humidity/value</code>,
109
+    <code>illuminance -&gt; illuminance/value</code>,
110
+    <code>battery -&gt; battery/value</code>,
111
+    <code>battery_low -&gt; battery_low/value</code>,
112
+    <code>tamper -&gt; tamper/value</code>.
113
+  </p>
114
+  <p>
115
+    For this mmWave device, raw Zigbee <code>presence</code> is converted to HomeBus <code>motion</code> when <code>fading_time</code> is <code>0</code>. Device-level <code>presence</code> is published only when the sensor is intentionally configured to hold presence with non-zero <code>fading_time</code>.
116
+  </p>
117
+  <p>
118
+    Identity is resolved with priority:
119
+    incoming topic, then message/payload fields, then fallback defaults
120
+    <code>unknown/unknown/device_type</code>.
121
+  </p>
122
+  <p>
123
+    With the recommended topic structure, <code>site</code>, <code>location</code>, <code>device_id</code>,
124
+    and usually <code>source_ref</code> are inferred directly from the Zigbee2MQTT topic path.
125
+  </p>
126
+  <p>
127
+    If this node is created from an older flow JSON, legacy fields
128
+    <code>mqttBus</code>, <code>mqttRoom</code>, and <code>mqttSensor</code> are still accepted as fallbacks.
129
+  </p>
130
+  <p>
131
+    Malformed or unmappable inputs are routed to adapter operational topics instead of being embedded into semantic bus payloads.
132
+  </p>
133
+  <p>
134
+    Input validation is strict for the source topic. Non-conforming topics or payloads are also logged with <code>node.warn</code>/<code>node.error</code> in Node-RED so they are visible in flow messages in addition to <code>sys/adapter/z2m-zg-204zv/{error,dlq}</code>.
135
+  </p>
136
+</script>
137
+
138
+<script>
139
+  RED.nodes.registerType("z2m-zg-204zv-homebus", {
140
+    category: "myNodes",
141
+    color: "#d9ecfb",
142
+    defaults: {
143
+      name: { value: "" },
144
+      batteryLowThreshold: { value: 20, validate: RED.validators.number() },
145
+      batteryType: { value: "alkaline" },
146
+      mqttBus: { value: "" },
147
+      mqttRoom: { value: "" },
148
+      mqttSensor: { value: "" }
149
+    },
150
+    inputs: 1,
151
+    outputs: 2,
152
+    icon: "font-awesome/fa-sitemap",
153
+    label: function() {
154
+      return this.name || "z2m-zg-204zv-homebus";
155
+    },
156
+    paletteLabel: "zg-204zv homebus"
157
+  });
158
+</script>
+987 -0
adapters/presence-sensor/homebus-adapter/z2m-zg-204zv-homebus.js
@@ -0,0 +1,987 @@
1
+module.exports = function(RED) {
2
+  function Z2MZG204ZVHomeBusNode(config) {
3
+    RED.nodes.createNode(this, config);
4
+    var node = this;
5
+
6
+    node.adapterId = "z2m-zg-204zv";
7
+    node.sourceTopic = "zigbee2mqtt/ZG-204ZV/#";
8
+    node.subscriptionStarted = false;
9
+    node.startTimer = null;
10
+    node.legacyRoom = normalizeToken(config.mqttRoom);
11
+    node.legacySensor = normalizeToken(config.mqttSensor);
12
+    node.legacyBus = normalizeToken(config.mqttBus);
13
+    node.publishCache = Object.create(null);
14
+    node.retainedCache = Object.create(null);
15
+    node.seenOptionalCapabilities = Object.create(null);
16
+    node.detectedDevices = Object.create(null);
17
+    node.stats = {
18
+      processed_inputs: 0,
19
+      devices_detected: 0,
20
+      home_messages: 0,
21
+      last_messages: 0,
22
+      meta_messages: 0,
23
+      home_availability_messages: 0,
24
+      operational_messages: 0,
25
+      invalid_messages: 0,
26
+      invalid_topics: 0,
27
+      invalid_payloads: 0,
28
+      unmapped_messages: 0,
29
+      adapter_exceptions: 0,
30
+      errors: 0,
31
+      dlq: 0
32
+    };
33
+    node.statsPublishEvery = 25;
34
+    node.batteryLowThreshold = Number(config.batteryLowThreshold);
35
+    if (!Number.isFinite(node.batteryLowThreshold)) node.batteryLowThreshold = 20;
36
+    node.batteryType = normalizeToken(config.batteryType).toLowerCase() || "alkaline";
37
+
38
+    var CAPABILITY_MAPPINGS = [
39
+      {
40
+        sourceSystem: "zigbee2mqtt",
41
+        sourceTopicMatch: "zigbee2mqtt/ZG-204ZV/<site>/<location>/<device_id>",
42
+        sourceFields: ["presence", "occupancy"],
43
+        targetBus: "home",
44
+        targetCapability: "motion",
45
+        core: true,
46
+        stream: "value",
47
+        payloadProfile: "scalar",
48
+        dataType: "boolean",
49
+        historianMode: "event",
50
+        historianEnabled: true,
51
+        applies: function(payload) {
52
+          return readNumber(payload, "fading_time") === 0;
53
+        },
54
+        read: function(payload) {
55
+          return readBool(payload, this.sourceFields);
56
+        }
57
+      },
58
+      {
59
+        sourceSystem: "zigbee2mqtt",
60
+        sourceTopicMatch: "zigbee2mqtt/ZG-204ZV/<site>/<location>/<device_id>",
61
+        sourceFields: ["presence", "occupancy"],
62
+        targetBus: "home",
63
+        targetCapability: "presence",
64
+        core: true,
65
+        stream: "value",
66
+        payloadProfile: "scalar",
67
+        dataType: "boolean",
68
+        historianMode: "state",
69
+        historianEnabled: true,
70
+        applies: function(payload) {
71
+          var fadingTime = readNumber(payload, "fading_time");
72
+          return fadingTime === undefined || fadingTime !== 0;
73
+        },
74
+        read: function(payload) {
75
+          return readBool(payload, this.sourceFields);
76
+        }
77
+      },
78
+      {
79
+        sourceSystem: "zigbee2mqtt",
80
+        sourceTopicMatch: "zigbee2mqtt/ZG-204ZV/<site>/<location>/<device_id>",
81
+        sourceFields: ["temperature"],
82
+        targetBus: "home",
83
+        targetCapability: "temperature",
84
+        core: true,
85
+        stream: "value",
86
+        payloadProfile: "scalar",
87
+        dataType: "number",
88
+        unit: "C",
89
+        precision: 0.1,
90
+        historianMode: "sample",
91
+        historianEnabled: true,
92
+        read: function(payload) {
93
+          return readNumber(payload, this.sourceFields[0]);
94
+        }
95
+      },
96
+      {
97
+        sourceSystem: "zigbee2mqtt",
98
+        sourceTopicMatch: "zigbee2mqtt/ZG-204ZV/<site>/<location>/<device_id>",
99
+        sourceFields: ["humidity"],
100
+        targetBus: "home",
101
+        targetCapability: "humidity",
102
+        core: true,
103
+        stream: "value",
104
+        payloadProfile: "scalar",
105
+        dataType: "number",
106
+        unit: "%",
107
+        precision: 1,
108
+        historianMode: "sample",
109
+        historianEnabled: true,
110
+        read: function(payload) {
111
+          return readNumber(payload, this.sourceFields[0]);
112
+        }
113
+      },
114
+      {
115
+        sourceSystem: "zigbee2mqtt",
116
+        sourceTopicMatch: "zigbee2mqtt/ZG-204ZV/<site>/<location>/<device_id>",
117
+        sourceFields: ["illuminance"],
118
+        targetBus: "home",
119
+        targetCapability: "illuminance",
120
+        core: true,
121
+        stream: "value",
122
+        payloadProfile: "scalar",
123
+        dataType: "number",
124
+        unit: "lx",
125
+        precision: 1,
126
+        historianMode: "sample",
127
+        historianEnabled: true,
128
+        read: function(payload) {
129
+          return readNumber(payload, this.sourceFields[0]);
130
+        }
131
+      },
132
+      {
133
+        sourceSystem: "zigbee2mqtt",
134
+        sourceTopicMatch: "zigbee2mqtt/ZG-204ZV/<site>/<location>/<device_id>",
135
+        sourceFields: ["battery"],
136
+        targetBus: "home",
137
+        targetCapability: "battery",
138
+        core: true,
139
+        stream: "value",
140
+        payloadProfile: "scalar",
141
+        dataType: "number",
142
+        unit: "%",
143
+        precision: 1,
144
+        historianMode: "sample",
145
+        historianEnabled: true,
146
+        read: function(payload) {
147
+          var value = readNumber(payload, this.sourceFields[0]);
148
+          if (value === undefined) return undefined;
149
+          return translateBatteryLevel(value);
150
+        }
151
+      },
152
+      {
153
+        sourceSystem: "zigbee2mqtt",
154
+        sourceTopicMatch: "zigbee2mqtt/ZG-204ZV/<site>/<location>/<device_id>",
155
+        sourceFields: ["battery_low", "batteryLow", "battery"],
156
+        targetBus: "home",
157
+        targetCapability: "battery_low",
158
+        core: true,
159
+        stream: "value",
160
+        payloadProfile: "scalar",
161
+        dataType: "boolean",
162
+        historianMode: "state",
163
+        historianEnabled: true,
164
+        read: function(payload) {
165
+          var raw = readBool(payload, this.sourceFields.slice(0, 2));
166
+          if (raw !== undefined) return raw;
167
+          var battery = translateBatteryLevel(readNumber(payload, this.sourceFields[2]));
168
+          if (battery === undefined) return undefined;
169
+          return battery <= node.batteryLowThreshold;
170
+        }
171
+      },
172
+      {
173
+        sourceSystem: "zigbee2mqtt",
174
+        sourceTopicMatch: "zigbee2mqtt/ZG-204ZV/<site>/<location>/<device_id>",
175
+        sourceFields: ["tamper", "tampered", "tamper_alarm", "alarm_tamper"],
176
+        targetBus: "home",
177
+        targetCapability: "tamper",
178
+        core: false,
179
+        stream: "value",
180
+        payloadProfile: "scalar",
181
+        dataType: "boolean",
182
+        historianMode: "state",
183
+        historianEnabled: true,
184
+        read: function(payload) {
185
+          return readBool(payload, this.sourceFields);
186
+        }
187
+      }
188
+    ];
189
+
190
+    function normalizeToken(value) {
191
+      if (value === undefined || value === null) return "";
192
+      return String(value).trim();
193
+    }
194
+
195
+    function transliterate(value) {
196
+      var s = normalizeToken(value);
197
+      if (!s) return "";
198
+      if (typeof s.normalize === "function") {
199
+        s = s.normalize("NFKD").replace(/[\u0300-\u036f]/g, "");
200
+      }
201
+      return s;
202
+    }
203
+
204
+    function toKebabCase(value, fallback) {
205
+      var s = transliterate(value).toLowerCase().replace(/[^a-z0-9]+/g, "-");
206
+      s = s.replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-");
207
+      return s || fallback || "";
208
+    }
209
+
210
+    function clamp(n, min, max) {
211
+      return Math.max(min, Math.min(max, n));
212
+    }
213
+
214
+    var ALKALINE_BATTERY_CURVE = [
215
+      { pct: 100, voltage: 1.60 },
216
+      { pct: 90, voltage: 1.55 },
217
+      { pct: 80, voltage: 1.50 },
218
+      { pct: 70, voltage: 1.46 },
219
+      { pct: 60, voltage: 1.42 },
220
+      { pct: 50, voltage: 1.36 },
221
+      { pct: 40, voltage: 1.30 },
222
+      { pct: 30, voltage: 1.25 },
223
+      { pct: 20, voltage: 1.20 },
224
+      { pct: 10, voltage: 1.10 },
225
+      { pct: 0, voltage: 0.90 }
226
+    ];
227
+
228
+    var NIMH_BATTERY_CURVE = [
229
+      { pct: 100, voltage: 1.40 },
230
+      { pct: 95, voltage: 1.33 },
231
+      { pct: 90, voltage: 1.28 },
232
+      { pct: 85, voltage: 1.24 },
233
+      { pct: 80, voltage: 1.20 },
234
+      { pct: 75, voltage: 1.19 },
235
+      { pct: 70, voltage: 1.18 },
236
+      { pct: 60, voltage: 1.17 },
237
+      { pct: 50, voltage: 1.16 },
238
+      { pct: 40, voltage: 1.15 },
239
+      { pct: 30, voltage: 1.13 },
240
+      { pct: 20, voltage: 1.11 },
241
+      { pct: 10, voltage: 1.07 },
242
+      { pct: 0, voltage: 1.00 }
243
+    ];
244
+
245
+    function interpolateCurve(points, inputKey, outputKey, inputValue) {
246
+      if (!Array.isArray(points) || points.length === 0) return undefined;
247
+      if (inputValue >= points[0][inputKey]) return points[0][outputKey];
248
+
249
+      for (var i = 1; i < points.length; i++) {
250
+        var upper = points[i - 1];
251
+        var lower = points[i];
252
+        var upperInput = upper[inputKey];
253
+        var lowerInput = lower[inputKey];
254
+
255
+        if (inputValue >= lowerInput) {
256
+          var range = upperInput - lowerInput;
257
+          if (range <= 0) return lower[outputKey];
258
+          var ratio = (inputValue - lowerInput) / range;
259
+          return lower[outputKey] + ratio * (upper[outputKey] - lower[outputKey]);
260
+        }
261
+      }
262
+
263
+      return points[points.length - 1][outputKey];
264
+    }
265
+
266
+    function translateBatteryLevel(rawValue) {
267
+      if (rawValue === undefined) return undefined;
268
+      var raw = clamp(Math.round(Number(rawValue)), 0, 100);
269
+      if (node.batteryType !== "nimh") return raw;
270
+
271
+      // Reinterpret the reported percentage on an alkaline discharge curve,
272
+      // then project the equivalent cell voltage onto a flatter NiMH curve.
273
+      var estimatedVoltage = interpolateCurve(ALKALINE_BATTERY_CURVE, "pct", "voltage", raw);
274
+      var nimhPct = interpolateCurve(NIMH_BATTERY_CURVE, "voltage", "pct", estimatedVoltage);
275
+      return clamp(Math.round(nimhPct), 0, 100);
276
+    }
277
+
278
+    function topicTokens(topic) {
279
+      if (typeof topic !== "string") return [];
280
+      return topic.split("/").map(function(token) { return token.trim(); }).filter(function(token) { return !!token; });
281
+    }
282
+
283
+    function asBool(value) {
284
+      if (typeof value === "boolean") return value;
285
+      if (typeof value === "number") return value !== 0;
286
+      if (typeof value === "string") {
287
+        var v = value.trim().toLowerCase();
288
+        if (v === "true" || v === "1" || v === "on" || v === "yes" || v === "online") return true;
289
+        if (v === "false" || v === "0" || v === "off" || v === "no" || v === "offline") return false;
290
+      }
291
+      return null;
292
+    }
293
+
294
+    function pickFirst(obj, keys) {
295
+      if (!obj || typeof obj !== "object") return undefined;
296
+      for (var i = 0; i < keys.length; i++) {
297
+        if (Object.prototype.hasOwnProperty.call(obj, keys[i])) return obj[keys[i]];
298
+      }
299
+      return undefined;
300
+    }
301
+
302
+    function pickPath(obj, path) {
303
+      if (!obj || typeof obj !== "object") return undefined;
304
+      var parts = path.split(".");
305
+      var current = obj;
306
+      for (var i = 0; i < parts.length; i++) {
307
+        if (!current || typeof current !== "object" || !Object.prototype.hasOwnProperty.call(current, parts[i])) return undefined;
308
+        current = current[parts[i]];
309
+      }
310
+      return current;
311
+    }
312
+
313
+    function pickFirstPath(obj, paths) {
314
+      for (var i = 0; i < paths.length; i++) {
315
+        var value = pickPath(obj, paths[i]);
316
+        if (value !== undefined && value !== null && normalizeToken(value) !== "") return value;
317
+      }
318
+      return undefined;
319
+    }
320
+
321
+    function readBool(payload, fields) {
322
+      var raw = asBool(pickFirst(payload, fields));
323
+      return raw === null ? undefined : raw;
324
+    }
325
+
326
+    function readNumber(payload, field) {
327
+      if (!payload || typeof payload !== "object") return undefined;
328
+      var value = payload[field];
329
+      if (typeof value !== "number" || !isFinite(value)) return undefined;
330
+      return Number(value);
331
+    }
332
+
333
+    function toIsoTimestamp(value) {
334
+      if (value === undefined || value === null) return "";
335
+      if (value instanceof Date && !isNaN(value.getTime())) return value.toISOString();
336
+      if (typeof value === "number" && isFinite(value)) {
337
+        var ms = value < 100000000000 ? value * 1000 : value;
338
+        var dateFromNumber = new Date(ms);
339
+        return isNaN(dateFromNumber.getTime()) ? "" : dateFromNumber.toISOString();
340
+      }
341
+      if (typeof value === "string") {
342
+        var trimmed = value.trim();
343
+        if (!trimmed) return "";
344
+        if (/^\d+$/.test(trimmed)) return toIsoTimestamp(Number(trimmed));
345
+        var dateFromString = new Date(trimmed);
346
+        return isNaN(dateFromString.getTime()) ? "" : dateFromString.toISOString();
347
+      }
348
+      return "";
349
+    }
350
+
351
+    function canonicalSourceTopic(topic) {
352
+      var t = normalizeToken(topic);
353
+      if (!t) return "";
354
+      if (/\/availability$/i.test(t)) return t.replace(/\/availability$/i, "");
355
+      return t;
356
+    }
357
+
358
+    function inferFromTopic(topic) {
359
+      var tokens = topicTokens(topic);
360
+      var result = {
361
+        deviceType: "",
362
+        site: "",
363
+        location: "",
364
+        deviceId: "",
365
+        friendlyName: "",
366
+        isAvailabilityTopic: false
367
+      };
368
+
369
+      if (tokens.length >= 2 && tokens[0].toLowerCase() === "zigbee2mqtt") {
370
+        result.deviceType = tokens[1];
371
+
372
+        if (tokens.length >= 6 && tokens[5].toLowerCase() === "availability") {
373
+          result.site = tokens[2];
374
+          result.location = tokens[3];
375
+          result.deviceId = tokens[4];
376
+          result.friendlyName = tokens.slice(1, 5).join("/");
377
+          result.isAvailabilityTopic = true;
378
+          return result;
379
+        }
380
+
381
+        if (tokens.length >= 5 && tokens[4].toLowerCase() !== "availability") {
382
+          result.site = tokens[2];
383
+          result.location = tokens[3];
384
+          result.deviceId = tokens[4];
385
+          result.friendlyName = tokens.slice(1, 5).join("/");
386
+          return result;
387
+        }
388
+
389
+        if (tokens.length >= 5 && tokens[4].toLowerCase() === "availability") {
390
+          result.location = tokens[2];
391
+          result.deviceId = tokens[3];
392
+          result.friendlyName = tokens.slice(1, 4).join("/");
393
+          result.isAvailabilityTopic = true;
394
+          return result;
395
+        }
396
+
397
+        if (tokens.length >= 4) {
398
+          result.location = tokens[2];
399
+          result.deviceId = tokens[3];
400
+          result.friendlyName = tokens.slice(1, 4).join("/");
401
+          return result;
402
+        }
403
+
404
+        result.friendlyName = tokens.slice(1).join("/");
405
+        result.deviceId = tokens[1];
406
+        result.isAvailabilityTopic = tokens.length >= 3 && tokens[tokens.length - 1].toLowerCase() === "availability";
407
+        return result;
408
+      }
409
+
410
+      if (tokens.length >= 5 && tokens[1].toLowerCase() === "home") {
411
+        result.site = tokens[0];
412
+        result.location = tokens[2];
413
+        result.deviceId = tokens[4];
414
+        result.isAvailabilityTopic = tokens.length >= 6 && tokens[5].toLowerCase() === "availability";
415
+        return result;
416
+      }
417
+
418
+      if (tokens.length >= 4) {
419
+        result.site = tokens[0];
420
+        result.location = tokens[2];
421
+        result.deviceId = tokens[3];
422
+      } else if (tokens.length >= 2) {
423
+        result.location = tokens[tokens.length - 2];
424
+        result.deviceId = tokens[tokens.length - 1];
425
+      } else if (tokens.length === 1) {
426
+        result.deviceId = tokens[0];
427
+      }
428
+
429
+      return result;
430
+    }
431
+
432
+    function resolveIdentity(msg, payload) {
433
+      var safeMsg = (msg && typeof msg === "object") ? msg : {};
434
+      var inferred = inferFromTopic(safeMsg.topic);
435
+      var siteRaw = inferred.site
436
+        || normalizeToken(pickFirst(payload, ["site", "homeSite"]))
437
+        || normalizeToken(pickFirst(safeMsg, ["site", "homeSite"]))
438
+        || "";
439
+
440
+      var locationRaw = inferred.location
441
+        || normalizeToken(pickFirst(payload, ["location", "room", "homeLocation", "mqttRoom"]))
442
+        || normalizeToken(pickFirst(safeMsg, ["location", "room", "homeLocation", "mqttRoom"]))
443
+        || node.legacyRoom
444
+        || "";
445
+
446
+      var deviceRaw = inferred.deviceId
447
+        || normalizeToken(pickFirst(payload, ["deviceId", "device_id", "sensor", "mqttSensor", "friendly_name", "friendlyName", "name", "device"]))
448
+        || normalizeToken(pickFirst(safeMsg, ["deviceId", "device_id", "sensor", "mqttSensor"]))
449
+        || node.legacySensor
450
+        || inferred.friendlyName
451
+        || inferred.deviceType;
452
+
453
+      var displayName = normalizeToken(pickFirst(payload, ["display_name", "displayName", "friendly_name", "friendlyName", "name"]))
454
+        || normalizeToken(pickFirst(safeMsg, ["display_name", "displayName", "friendly_name", "friendlyName", "name"]))
455
+        || inferred.friendlyName
456
+        || deviceRaw
457
+        || "ZG-204ZV";
458
+
459
+      var sourceRef = normalizeToken(pickFirstPath(payload, ["source_ref", "sourceRef", "ieee_address", "ieeeAddr", "device.ieee_address", "device.ieeeAddr"]))
460
+        || normalizeToken(pickFirstPath(safeMsg, ["source_ref", "sourceRef", "ieee_address", "ieeeAddr", "device.ieee_address", "device.ieeeAddr"]))
461
+        || canonicalSourceTopic(safeMsg.topic)
462
+        || inferred.friendlyName
463
+        || inferred.deviceType
464
+        || deviceRaw
465
+        || "z2m-zg-204zv";
466
+
467
+      return {
468
+        site: toKebabCase(siteRaw, "unknown"),
469
+        location: toKebabCase(locationRaw, "unknown"),
470
+        deviceId: toKebabCase(deviceRaw, toKebabCase(inferred.deviceType, "zg-204zv")),
471
+        displayName: displayName,
472
+        sourceRef: sourceRef,
473
+        sourceTopic: canonicalSourceTopic(safeMsg.topic),
474
+        isAvailabilityTopic: inferred.isAvailabilityTopic
475
+      };
476
+    }
477
+
478
+    function noteDevice(identity) {
479
+      if (!identity) return;
480
+      var key = identity.site + "/" + identity.location + "/" + identity.deviceId;
481
+      if (node.detectedDevices[key]) return;
482
+      node.detectedDevices[key] = true;
483
+      node.stats.devices_detected += 1;
484
+    }
485
+
486
+    function validateInboundTopic(topic) {
487
+      var rawTopic = normalizeToken(topic);
488
+      if (!rawTopic) {
489
+        return {
490
+          valid: false,
491
+          reason: "Topic must be a non-empty string"
492
+        };
493
+      }
494
+
495
+      var tokens = rawTopic.split("/").map(function(token) {
496
+        return token.trim();
497
+      });
498
+      if (tokens.length < 5) {
499
+        return {
500
+          valid: false,
501
+          reason: "Topic must match zigbee2mqtt/ZG-204ZV/<site>/<location>/<device_id>[/availability]"
502
+        };
503
+      }
504
+      if (tokens[0].toLowerCase() !== "zigbee2mqtt" || tokens[1].toLowerCase() !== "zg-204zv") {
505
+        return {
506
+          valid: false,
507
+          reason: "Topic must start with zigbee2mqtt/ZG-204ZV"
508
+        };
509
+      }
510
+      if (!tokens[2] || !tokens[3] || !tokens[4]) {
511
+        return {
512
+          valid: false,
513
+          reason: "Topic must contain non-empty site, location and device_id segments"
514
+        };
515
+      }
516
+      if (tokens.length === 5) {
517
+        return {
518
+          valid: true,
519
+          isAvailabilityTopic: false
520
+        };
521
+      }
522
+      if (tokens.length === 6 && tokens[5].toLowerCase() === "availability") {
523
+        return {
524
+          valid: true,
525
+          isAvailabilityTopic: true
526
+        };
527
+      }
528
+      return {
529
+        valid: false,
530
+        reason: "Topic must not contain extra segments beyond an optional /availability suffix"
531
+      };
532
+    }
533
+
534
+    function translatedMessageCount() {
535
+      return node.stats.home_messages + node.stats.last_messages + node.stats.meta_messages + node.stats.home_availability_messages;
536
+    }
537
+
538
+    function updateNodeStatus(fill, shape, suffix) {
539
+      var parts = [
540
+        "dev " + node.stats.devices_detected,
541
+        "in " + node.stats.processed_inputs,
542
+        "tr " + translatedMessageCount()
543
+      ];
544
+      if (node.stats.operational_messages > 0) parts.push("op " + node.stats.operational_messages);
545
+      if (node.stats.errors > 0) parts.push("err " + node.stats.errors);
546
+      if (node.stats.invalid_topics > 0) parts.push("topic " + node.stats.invalid_topics);
547
+      if (node.stats.invalid_messages > 0) parts.push("msg " + node.stats.invalid_messages);
548
+      if (node.stats.invalid_payloads > 0) parts.push("payload " + node.stats.invalid_payloads);
549
+      if (node.stats.unmapped_messages > 0) parts.push("unmapped " + node.stats.unmapped_messages);
550
+      if (node.stats.adapter_exceptions > 0) parts.push("exc " + node.stats.adapter_exceptions);
551
+      if (node.stats.dlq > 0) parts.push("dlq " + node.stats.dlq);
552
+      if (suffix) parts.push(suffix);
553
+      node.status({
554
+        fill: fill,
555
+        shape: shape,
556
+        text: parts.join(" | ")
557
+      });
558
+    }
559
+
560
+    function buildHomeTopic(identity, mapping, stream) {
561
+      return identity.site + "/" + mapping.targetBus + "/" + identity.location + "/" + mapping.targetCapability + "/" + identity.deviceId + "/" + stream;
562
+    }
563
+
564
+    function buildSysTopic(site, stream) {
565
+      return site + "/sys/adapter/" + node.adapterId + "/" + stream;
566
+    }
567
+
568
+    function makePublishMsg(topic, payload, retain) {
569
+      return {
570
+        topic: topic,
571
+        payload: payload,
572
+        qos: 1,
573
+        retain: !!retain
574
+      };
575
+    }
576
+
577
+    function makeSubscribeMsg(topic) {
578
+      return {
579
+        action: "subscribe",
580
+        topic: [{
581
+          topic: topic,
582
+          qos: 2,
583
+          rh: 0,
584
+          rap: true
585
+        }]
586
+      };
587
+    }
588
+
589
+    function signature(value) {
590
+      return JSON.stringify(value);
591
+    }
592
+
593
+    function shouldPublishLiveValue(cacheKey, payload) {
594
+      var sig = signature(payload);
595
+      if (node.publishCache[cacheKey] === sig) return false;
596
+      node.publishCache[cacheKey] = sig;
597
+      return true;
598
+    }
599
+
600
+    function shouldPublishRetained(cacheKey, payload) {
601
+      var sig = signature(payload);
602
+      if (node.retainedCache[cacheKey] === sig) return false;
603
+      node.retainedCache[cacheKey] = sig;
604
+      return true;
605
+    }
606
+
607
+    function humanizeCapability(capability) {
608
+      return capability.replace(/_/g, " ").replace(/\b[a-z]/g, function(ch) { return ch.toUpperCase(); });
609
+    }
610
+
611
+    function resolveObservation(msg, payloadObject) {
612
+      var safeMsg = (msg && typeof msg === "object") ? msg : {};
613
+      var observedAtRaw = pickFirstPath(payloadObject, [
614
+        "observed_at",
615
+        "observedAt",
616
+        "timestamp",
617
+        "time",
618
+        "ts",
619
+        "last_seen",
620
+        "lastSeen",
621
+        "device.last_seen",
622
+        "device.lastSeen"
623
+      ]) || pickFirstPath(safeMsg, [
624
+        "observed_at",
625
+        "observedAt",
626
+        "timestamp",
627
+        "time",
628
+        "ts"
629
+      ]);
630
+      var observedAt = toIsoTimestamp(observedAtRaw);
631
+      if (observedAt) {
632
+        return {
633
+          observedAt: observedAt,
634
+          quality: "good"
635
+        };
636
+      }
637
+      return {
638
+        observedAt: new Date().toISOString(),
639
+        quality: "estimated"
640
+      };
641
+    }
642
+
643
+    function buildMetaPayload(identity, mapping) {
644
+      var payload = {
645
+        schema_ref: "mqbus.home.v1",
646
+        payload_profile: mapping.payloadProfile,
647
+        stream_payload_profiles: {
648
+          value: "scalar",
649
+          last: "envelope"
650
+        },
651
+        data_type: mapping.dataType,
652
+        adapter_id: node.adapterId,
653
+        source: mapping.sourceSystem,
654
+        source_ref: identity.sourceRef,
655
+        source_topic: identity.sourceTopic,
656
+        display_name: identity.displayName + " " + humanizeCapability(mapping.targetCapability),
657
+        tags: [mapping.sourceSystem, "zg-204zv", mapping.targetBus],
658
+        historian: {
659
+          enabled: !!mapping.historianEnabled,
660
+          mode: mapping.historianMode
661
+        }
662
+      };
663
+
664
+      if (mapping.unit) payload.unit = mapping.unit;
665
+      if (mapping.precision !== undefined) payload.precision = mapping.precision;
666
+
667
+      return payload;
668
+    }
669
+
670
+    function buildLastPayload(value, observation) {
671
+      var payload = {
672
+        value: value,
673
+        observed_at: observation.observedAt
674
+      };
675
+      if (observation.quality && observation.quality !== "good") payload.quality = observation.quality;
676
+      return payload;
677
+    }
678
+
679
+    function noteMessage(kind) {
680
+      if (!Object.prototype.hasOwnProperty.call(node.stats, kind)) return;
681
+      node.stats[kind] += 1;
682
+    }
683
+
684
+    function noteErrorKind(code) {
685
+      if (code === "invalid_message") {
686
+        noteMessage("invalid_messages");
687
+      } else if (code === "invalid_topic") {
688
+        noteMessage("invalid_topics");
689
+      } else if (code === "payload_not_object" || code === "invalid_availability_payload") {
690
+        noteMessage("invalid_payloads");
691
+      } else if (code === "no_mapped_fields") {
692
+        noteMessage("unmapped_messages");
693
+      } else if (code === "adapter_exception") {
694
+        noteMessage("adapter_exceptions");
695
+      }
696
+    }
697
+
698
+    function summarizeForLog(value) {
699
+      if (value === undefined) return "undefined";
700
+      if (value === null) return "null";
701
+      if (typeof value === "string") {
702
+        return value.length > 180 ? value.slice(0, 177) + "..." : value;
703
+      }
704
+      try {
705
+        var serialized = JSON.stringify(value);
706
+        if (serialized.length > 180) return serialized.slice(0, 177) + "...";
707
+        return serialized;
708
+      } catch (err) {
709
+        return String(value);
710
+      }
711
+    }
712
+
713
+    function logIssue(level, code, reason, msg, rawPayload) {
714
+      var parts = [
715
+        "[" + node.adapterId + "]",
716
+        code + ":",
717
+        reason
718
+      ];
719
+      var sourceTopic = msg && typeof msg === "object" ? normalizeToken(msg.topic) : "";
720
+      if (sourceTopic) parts.push("topic=" + sourceTopic);
721
+      if (rawPayload !== undefined) parts.push("payload=" + summarizeForLog(rawPayload));
722
+
723
+      var text = parts.join(" ");
724
+      if (level === "error") {
725
+        node.error(text, msg);
726
+        return;
727
+      }
728
+      node.warn(text);
729
+    }
730
+
731
+    function enqueueHomeMeta(messages, identity, mapping) {
732
+      var topic = buildHomeTopic(identity, mapping, "meta");
733
+      var payload = buildMetaPayload(identity, mapping);
734
+      if (!shouldPublishRetained("meta:" + topic, payload)) return;
735
+      messages.push(makePublishMsg(topic, payload, true));
736
+      noteMessage("meta_messages");
737
+    }
738
+
739
+    function enqueueHomeAvailability(messages, identity, mapping, online) {
740
+      var topic = buildHomeTopic(identity, mapping, "availability");
741
+      var payload = online ? "online" : "offline";
742
+      if (!shouldPublishRetained("availability:" + topic, payload)) return;
743
+      messages.push(makePublishMsg(topic, payload, true));
744
+      noteMessage("home_availability_messages");
745
+    }
746
+
747
+    function enqueueHomeLast(messages, identity, mapping, value, observation) {
748
+      var topic = buildHomeTopic(identity, mapping, "last");
749
+      var payload = buildLastPayload(value, observation);
750
+      if (!shouldPublishRetained("last:" + topic, payload)) return;
751
+      messages.push(makePublishMsg(topic, payload, true));
752
+      noteMessage("last_messages");
753
+    }
754
+
755
+    function enqueueHomeValue(messages, identity, mapping, value) {
756
+      var topic = buildHomeTopic(identity, mapping, "value");
757
+      if (!shouldPublishLiveValue("value:" + topic, value)) return;
758
+      messages.push(makePublishMsg(topic, value, false));
759
+      noteMessage("home_messages");
760
+    }
761
+
762
+    function enqueueAdapterAvailability(messages, site, online) {
763
+      var topic = buildSysTopic(site, "availability");
764
+      var payload = online ? "online" : "offline";
765
+      if (!shouldPublishRetained("sys:availability:" + topic, payload)) return;
766
+      messages.push(makePublishMsg(topic, payload, true));
767
+      noteMessage("operational_messages");
768
+    }
769
+
770
+    function enqueueAdapterStats(messages, site, force) {
771
+      if (!force && node.stats.processed_inputs !== 1 && (node.stats.processed_inputs % node.statsPublishEvery) !== 0) return;
772
+      var topic = buildSysTopic(site, "stats");
773
+      var payload = {
774
+        processed_inputs: node.stats.processed_inputs,
775
+        devices_detected: node.stats.devices_detected,
776
+        translated_messages: translatedMessageCount(),
777
+        home_messages: node.stats.home_messages,
778
+        last_messages: node.stats.last_messages,
779
+        meta_messages: node.stats.meta_messages,
780
+        home_availability_messages: node.stats.home_availability_messages,
781
+        operational_messages: node.stats.operational_messages,
782
+        invalid_messages: node.stats.invalid_messages,
783
+        invalid_topics: node.stats.invalid_topics,
784
+        invalid_payloads: node.stats.invalid_payloads,
785
+        unmapped_messages: node.stats.unmapped_messages,
786
+        adapter_exceptions: node.stats.adapter_exceptions,
787
+        errors: node.stats.errors,
788
+        dlq: node.stats.dlq
789
+      };
790
+      messages.push(makePublishMsg(topic, payload, true));
791
+      noteMessage("operational_messages");
792
+    }
793
+
794
+    function enqueueError(messages, site, code, reason, sourceTopic) {
795
+      var payload = {
796
+        code: code,
797
+        reason: reason,
798
+        source_topic: normalizeToken(sourceTopic),
799
+        adapter_id: node.adapterId
800
+      };
801
+      messages.push(makePublishMsg(buildSysTopic(site, "error"), payload, false));
802
+      noteErrorKind(code);
803
+      noteMessage("errors");
804
+      noteMessage("operational_messages");
805
+    }
806
+
807
+    function enqueueDlq(messages, site, code, sourceTopic, rawPayload) {
808
+      var payload = {
809
+        code: code,
810
+        source_topic: normalizeToken(sourceTopic),
811
+        payload: rawPayload
812
+      };
813
+      messages.push(makePublishMsg(buildSysTopic(site, "dlq"), payload, false));
814
+      noteMessage("dlq");
815
+      noteMessage("operational_messages");
816
+    }
817
+
818
+    function activeMappings(payloadObject) {
819
+      var result = [];
820
+      for (var i = 0; i < CAPABILITY_MAPPINGS.length; i++) {
821
+        var mapping = CAPABILITY_MAPPINGS[i];
822
+        if (typeof mapping.applies === "function" && !mapping.applies(payloadObject)) continue;
823
+        var value = payloadObject ? mapping.read(payloadObject) : undefined;
824
+        var seen = mapping.core || node.seenOptionalCapabilities[mapping.targetCapability];
825
+        if (value !== undefined) node.seenOptionalCapabilities[mapping.targetCapability] = true;
826
+        if (mapping.core || seen || value !== undefined) {
827
+          result.push({
828
+            mapping: mapping,
829
+            hasValue: value !== undefined,
830
+            value: value
831
+          });
832
+        }
833
+      }
834
+      return result;
835
+    }
836
+
837
+    function resolveOnlineState(payloadObject, availabilityValue) {
838
+      if (availabilityValue !== null && availabilityValue !== undefined) return !!availabilityValue;
839
+      if (!payloadObject) return true;
840
+      var active = true;
841
+      if (typeof payloadObject.availability === "string") active = payloadObject.availability.trim().toLowerCase() !== "offline";
842
+      if (typeof payloadObject.online === "boolean") active = payloadObject.online;
843
+      return active;
844
+    }
845
+
846
+    function flush(send, done, controlMessages, publishMessages, error) {
847
+      send([
848
+        publishMessages.length ? publishMessages : null,
849
+        controlMessages.length ? controlMessages : null
850
+      ]);
851
+      if (done) done(error);
852
+    }
853
+
854
+    function startSubscriptions() {
855
+      if (node.subscriptionStarted) return;
856
+      node.subscriptionStarted = true;
857
+      node.send([null, makeSubscribeMsg(node.sourceTopic)]);
858
+      updateNodeStatus("grey", "dot", "subscribed");
859
+    }
860
+
861
+    node.on("input", function(msg, send, done) {
862
+      send = send || function() { node.send.apply(node, arguments); };
863
+      node.stats.processed_inputs += 1;
864
+
865
+      try {
866
+        var controlMessages = [];
867
+        if (!msg || typeof msg !== "object") {
868
+          var invalidMessages = [];
869
+          var invalidSite = resolveIdentity({}, null).site;
870
+          enqueueAdapterAvailability(invalidMessages, invalidSite, true);
871
+          enqueueError(invalidMessages, invalidSite, "invalid_message", "Input must be an object", "");
872
+          enqueueDlq(invalidMessages, invalidSite, "invalid_message", "", null);
873
+          enqueueAdapterStats(invalidMessages, invalidSite, true);
874
+          logIssue("warn", "invalid_message", "Input must be an object", null, msg);
875
+          updateNodeStatus("yellow", "ring", "bad msg");
876
+          flush(send, done, controlMessages, invalidMessages);
877
+          return;
878
+        }
879
+
880
+        var payload = msg.payload;
881
+        var payloadObject = payload && typeof payload === "object" && !Array.isArray(payload) ? payload : null;
882
+        var identity = resolveIdentity(msg, payloadObject);
883
+        var observation = resolveObservation(msg, payloadObject);
884
+        var messages = [];
885
+        var topicValidation = validateInboundTopic(msg.topic);
886
+        var availabilityValue = null;
887
+
888
+        enqueueAdapterAvailability(messages, identity.site, true);
889
+
890
+        if (!topicValidation.valid) {
891
+          enqueueError(messages, identity.site, "invalid_topic", topicValidation.reason, msg.topic);
892
+          enqueueDlq(messages, identity.site, "invalid_topic", msg.topic, payload);
893
+          enqueueAdapterStats(messages, identity.site, true);
894
+          logIssue("warn", "invalid_topic", topicValidation.reason, msg, payload);
895
+          updateNodeStatus("yellow", "ring", "bad topic");
896
+          flush(send, done, controlMessages, messages);
897
+          return;
898
+        }
899
+
900
+        identity.isAvailabilityTopic = topicValidation.isAvailabilityTopic;
901
+        noteDevice(identity);
902
+
903
+        var isAvailabilityTopic = topicValidation.isAvailabilityTopic;
904
+
905
+        if (isAvailabilityTopic) {
906
+          availabilityValue = asBool(payload);
907
+          if (availabilityValue === null) {
908
+            enqueueError(messages, identity.site, "invalid_availability_payload", "Availability payload must be online/offline or boolean", msg.topic);
909
+            enqueueDlq(messages, identity.site, "invalid_availability_payload", msg.topic, payload);
910
+            enqueueAdapterStats(messages, identity.site, true);
911
+            logIssue("warn", "invalid_availability_payload", "Availability payload must be online/offline or boolean", msg, payload);
912
+            updateNodeStatus("yellow", "ring", "invalid availability");
913
+            flush(send, done, controlMessages, messages);
914
+            return;
915
+          }
916
+        } else if (!payloadObject) {
917
+          enqueueError(messages, identity.site, "payload_not_object", "Telemetry payload must be an object", msg.topic);
918
+          enqueueDlq(messages, identity.site, "payload_not_object", msg.topic, payload);
919
+          enqueueAdapterStats(messages, identity.site, true);
920
+          logIssue("warn", "payload_not_object", "Telemetry payload must be an object", msg, payload);
921
+          updateNodeStatus("yellow", "ring", "payload not object");
922
+          flush(send, done, controlMessages, messages);
923
+          return;
924
+        }
925
+
926
+        var online = resolveOnlineState(payloadObject, availabilityValue);
927
+        var mappings = activeMappings(payloadObject);
928
+        var hasMappedValue = false;
929
+        for (var i = 0; i < mappings.length; i++) {
930
+          if (mappings[i].hasValue) {
931
+            hasMappedValue = true;
932
+            break;
933
+          }
934
+        }
935
+        var hasAvailabilityField = isAvailabilityTopic || (payloadObject && (typeof payloadObject.availability === "string" || typeof payloadObject.online === "boolean"));
936
+
937
+        if (!hasMappedValue && !hasAvailabilityField) {
938
+          enqueueError(messages, identity.site, "no_mapped_fields", "Payload did not contain any supported ZG-204ZV fields", msg.topic);
939
+          enqueueDlq(messages, identity.site, "no_mapped_fields", msg.topic, payloadObject);
940
+          logIssue("warn", "no_mapped_fields", "Payload did not contain any supported ZG-204ZV fields", msg, payloadObject);
941
+        }
942
+
943
+        for (var i = 0; i < mappings.length; i++) {
944
+          enqueueHomeMeta(messages, identity, mappings[i].mapping);
945
+          enqueueHomeAvailability(messages, identity, mappings[i].mapping, online);
946
+          if (mappings[i].hasValue) {
947
+            enqueueHomeLast(messages, identity, mappings[i].mapping, mappings[i].value, observation);
948
+            enqueueHomeValue(messages, identity, mappings[i].mapping, mappings[i].value);
949
+          }
950
+        }
951
+
952
+        enqueueAdapterStats(messages, identity.site, false);
953
+
954
+        if (!hasMappedValue && !hasAvailabilityField) {
955
+          updateNodeStatus("yellow", "ring", "unmapped");
956
+        } else {
957
+          updateNodeStatus(online ? "green" : "yellow", online ? "dot" : "ring", online ? null : "offline");
958
+        }
959
+
960
+        flush(send, done, controlMessages, messages);
961
+      } catch (err) {
962
+        var errorPayload = msg && msg.payload && typeof msg.payload === "object" && !Array.isArray(msg.payload) ? msg.payload : null;
963
+        var errorIdentity = resolveIdentity(msg, errorPayload);
964
+        noteDevice(errorIdentity);
965
+        var errorMessages = [];
966
+        enqueueAdapterAvailability(errorMessages, errorIdentity.site, true);
967
+        enqueueError(errorMessages, errorIdentity.site, "adapter_exception", err.message, msg && msg.topic);
968
+        enqueueAdapterStats(errorMessages, errorIdentity.site, true);
969
+        logIssue("error", "adapter_exception", err.message, msg, msg && msg.payload);
970
+        updateNodeStatus("red", "ring", "error");
971
+        flush(send, done, [], errorMessages, err);
972
+      }
973
+    });
974
+
975
+    node.on("close", function() {
976
+      if (node.startTimer) {
977
+        clearTimeout(node.startTimer);
978
+        node.startTimer = null;
979
+      }
980
+    });
981
+
982
+    node.startTimer = setTimeout(startSubscriptions, 250);
983
+    updateNodeStatus("grey", "ring", "waiting");
984
+  }
985
+
986
+  RED.nodes.registerType("z2m-zg-204zv-homebus", Z2MZG204ZVHomeBusNode);
987
+};
+23 -0
adapters/presence-sensor/homekit-adapter/package.json
@@ -0,0 +1,23 @@
1
+{
2
+  "name": "node-red-contrib-z2m-zg-204zv",
3
+  "version": "0.0.10",
4
+  "description": "Node-RED node that consumes HomeBus ZG-204ZV streams and projects them to HomeKit using dynamic MQTT subscriptions",
5
+  "main": "z2m-zg-204zv.js",
6
+  "keywords": [
7
+    "node-red",
8
+    "zigbee2mqtt",
9
+    "presence",
10
+    "homekit",
11
+    "temperature",
12
+    "humidity",
13
+    "illuminance",
14
+    "battery"
15
+  ],
16
+  "author": "",
17
+  "license": "MIT",
18
+  "node-red": {
19
+    "nodes": {
20
+      "zg-204zv-homekit-adapter": "z2m-zg-204zv.js"
21
+    }
22
+  }
23
+}
+134 -0
adapters/presence-sensor/homekit-adapter/z2m-zg-204zv.html
@@ -0,0 +1,134 @@
1
+<script type="text/x-red" data-template-name="zg-204zv-homekit-adapter">
2
+  <div class="form-row">
3
+    <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
4
+    <input type="text" id="node-input-name" placeholder="Name">
5
+  </div>
6
+  <div class="form-row">
7
+    <label for="node-input-site"><i class="fa fa-globe"></i> Site</label>
8
+    <input type="text" id="node-input-site" placeholder="vad">
9
+  </div>
10
+  <div class="form-row">
11
+    <label for="node-input-location"><i class="fa fa-home"></i> Location</label>
12
+    <input type="text" id="node-input-location" placeholder="balcon">
13
+  </div>
14
+  <div class="form-row">
15
+    <label for="node-input-accessory"><i class="fa fa-dot-circle-o"></i> Accessory</label>
16
+    <input type="text" id="node-input-accessory" placeholder="south">
17
+  </div>
18
+  <div class="form-row">
19
+    <label for="node-input-batteryLowThreshold">Battery low threshold (%)</label>
20
+    <input type="number" id="node-input-batteryLowThreshold" min="0" max="100" placeholder="20">
21
+  </div>
22
+  <div class="form-row">
23
+    <label for="node-input-occupancyFadingTimeSec">Occupancy fading time (s)</label>
24
+    <input type="number" id="node-input-occupancyFadingTimeSec" min="0" step="1" placeholder="300">
25
+  </div>
26
+</script>
27
+
28
+<script type="text/x-red" data-help-name="zg-204zv-homekit-adapter">
29
+  <p>
30
+    Consumes HomeBus telemetry for a single <code>ZG-204ZV</code> endpoint and projects it to HomeKit services.
31
+  </p>
32
+  <p>
33
+    Output 7 is not a publish stream anymore. It controls a Node-RED <code>mqtt in</code> node with dynamic subscriptions.
34
+  </p>
35
+  <p>
36
+    On start, the node emits:
37
+    <code>subscribe &lt;site&gt;/home/&lt;location&gt;/+/&lt;accessory&gt;/last</code> and
38
+    <code>subscribe &lt;site&gt;/home/&lt;location&gt;/+/&lt;accessory&gt;/value</code>.
39
+  </p>
40
+  <p>
41
+    The node keeps <code>.../last</code> subscribed until the cold-start bootstrap is complete for all required values.
42
+    A value is considered satisfied when it arrived either from <code>last</code> or from live <code>value</code>.
43
+    Only then does the node emit <code>unsubscribe .../last</code>.
44
+  </p>
45
+  <p>
46
+    Supported input topic shape:
47
+    <code>&lt;site&gt;/home/&lt;location&gt;/&lt;capability&gt;/&lt;accessory&gt;/value</code> and
48
+    <code>&lt;site&gt;/home/&lt;location&gt;/&lt;capability&gt;/&lt;accessory&gt;/last</code>.
49
+  </p>
50
+  <p>
51
+    The node accepts scalar <code>value</code> payloads and timestamped <code>last</code> envelopes of the form
52
+    <code>{ value, observed_at, quality }</code>. For <code>last</code>, only <code>payload.value</code> is used.
53
+  </p>
54
+  <h3>Capabilities</h3>
55
+  <p>
56
+    Mappings:
57
+    <code>motion -&gt; Motion + Occupancy</code>,
58
+    <code>temperature -&gt; Temperature</code>,
59
+    <code>humidity -&gt; Humidity</code>,
60
+    <code>illuminance -&gt; Light Level</code>,
61
+    <code>battery + battery_low -&gt; Battery</code>.
62
+  </p>
63
+  <p>
64
+    Occupancy is synthesized locally from <code>motion</code> and the configured <code>Occupancy fading time</code>.
65
+    A <code>motion=true</code> sample sets occupancy, while <code>motion=false</code> only clears the Motion service immediately;
66
+    Occupancy clears when the local timer expires.
67
+  </p>
68
+  <p>
69
+    Subscription topics are generated from the configured <code>site</code>, <code>location</code>, and
70
+    <code>accessory</code>. The bus segment remains fixed to <code>home</code>.
71
+  </p>
72
+  <p>
73
+    For compatibility with existing flows, legacy accessory ids such as <code>radar-south</code> are normalized to
74
+    <code>south</code>.
75
+  </p>
76
+  <h3>Outputs</h3>
77
+  <ol>
78
+    <li>Motion Sensor: <code>{ MotionDetected, StatusActive, StatusFault, StatusLowBattery, StatusTampered }</code></li>
79
+    <li>Occupancy Sensor: <code>{ OccupancyDetected, StatusActive, StatusFault, StatusLowBattery, StatusTampered }</code></li>
80
+    <li>Temperature Sensor: <code>{ CurrentTemperature }</code></li>
81
+    <li>Humidity Sensor: <code>{ CurrentRelativeHumidity }</code></li>
82
+    <li>Light Sensor: <code>{ CurrentAmbientLightLevel }</code></li>
83
+    <li>Battery Service: <code>{ StatusLowBattery, BatteryLevel, ChargingState }</code></li>
84
+    <li><code>mqtt in</code> dynamic control messages: <code>{ action, topic }</code></li>
85
+  </ol>
86
+  <h3>HomeKit Service Setup</h3>
87
+  <p>
88
+    The linked <code>homekit-service</code> nodes should explicitly materialize the optional characteristics used by this adapter.
89
+  </p>
90
+  <p>
91
+    Recommended <code>characteristicProperties</code> values:
92
+  </p>
93
+  <p><code>Battery</code></p>
94
+  <pre><code>{"BatteryLevel":{},"ChargingState":{}}</code></pre>
95
+  <p><code>Motion</code></p>
96
+  <pre><code>{"StatusActive":{},"StatusFault":{},"StatusLowBattery":{},"StatusTampered":{}}</code></pre>
97
+  <p><code>Occupancy</code></p>
98
+  <pre><code>{"StatusActive":{},"StatusFault":{},"StatusLowBattery":{},"StatusTampered":{}}</code></pre>
99
+  <p>
100
+    Temperature, Humidity, and Light Level currently publish only their main reading, so no extra optional characteristics are required for those services.
101
+  </p>
102
+</script>
103
+
104
+<script>
105
+  function requiredText(v) {
106
+    return !!(v && String(v).trim());
107
+  }
108
+
109
+  RED.nodes.registerType("zg-204zv-homekit-adapter", {
110
+    category: "myNodes",
111
+    color: "#d7f0d1",
112
+    defaults: {
113
+      name: { value: "" },
114
+      outputTopic: { value: "" },
115
+      mqttBus: { value: "" },
116
+      site: { value: "", validate: requiredText },
117
+      location: { value: "", validate: requiredText },
118
+      accessory: { value: "", validate: requiredText },
119
+      mqttSite: { value: "" },
120
+      mqttRoom: { value: "" },
121
+      mqttSensor: { value: "" },
122
+      batteryLowThreshold: { value: 20, validate: RED.validators.number() },
123
+      occupancyFadingTimeSec: { value: 300, validate: RED.validators.number() },
124
+      publishCacheWindowSec: { value: "" }
125
+    },
126
+    inputs: 1,
127
+    outputs: 7,
128
+    icon: "font-awesome/fa-eye",
129
+    label: function() {
130
+      return this.name || "zg-204zv-homekit-adapter";
131
+    },
132
+    paletteLabel: "zg-204zv-homekit-adapter"
133
+  });
134
+</script>
+570 -0
adapters/presence-sensor/homekit-adapter/z2m-zg-204zv.js
@@ -0,0 +1,570 @@
1
+module.exports = function(RED) {
2
+  function Z2MZG204ZVNode(config) {
3
+    RED.nodes.createNode(this, config);
4
+    var node = this;
5
+
6
+    node.site = normalizeToken(config.site || config.mqttSite || "");
7
+    node.location = normalizeToken(config.location || config.mqttRoom || "");
8
+    node.accessory = normalizeLegacyDeviceId(normalizeToken(config.accessory || config.mqttSensor || ""));
9
+    node.batteryLowThreshold = parseNumber(config.batteryLowThreshold, 20, 0);
10
+    node.occupancyFadingTimeSec = parseNumber(config.occupancyFadingTimeSec, 300, 0);
11
+    node.bootstrapDeadlineMs = 10000;
12
+    node.hkCache = Object.create(null);
13
+    node.occupancyTimer = null;
14
+    node.startTimer = null;
15
+    node.bootstrapTimer = null;
16
+    node.lastMsgContext = null;
17
+
18
+    node.stats = {
19
+      controls: 0,
20
+      last_inputs: 0,
21
+      value_inputs: 0,
22
+      hk_updates: 0,
23
+      errors: 0
24
+    };
25
+
26
+    node.subscriptionState = {
27
+      started: false,
28
+      lastSubscribed: false,
29
+      valueSubscribed: false
30
+    };
31
+
32
+    node.bootstrapState = {
33
+      finalized: false,
34
+      motion: false,
35
+      temperature: false,
36
+      humidity: false,
37
+      illuminance: false,
38
+      battery: false
39
+    };
40
+
41
+    node.sensorState = {
42
+      active: false,
43
+      site: node.site || "",
44
+      location: node.location || "",
45
+      deviceId: node.accessory || "",
46
+      motionKnown: false,
47
+      motion: false,
48
+      occupancyKnown: false,
49
+      occupancy: false,
50
+      temperatureKnown: false,
51
+      temperature: null,
52
+      humidityKnown: false,
53
+      humidity: null,
54
+      illuminanceKnown: false,
55
+      illuminance: null,
56
+      batteryKnown: false,
57
+      battery: null,
58
+      batteryLowKnown: false,
59
+      batteryLow: false,
60
+      tamperedKnown: false,
61
+      tampered: false
62
+    };
63
+
64
+    function parseNumber(value, fallback, min) {
65
+      var n = Number(value);
66
+      if (!Number.isFinite(n)) return fallback;
67
+      if (typeof min === "number" && n < min) return fallback;
68
+      return n;
69
+    }
70
+
71
+    function asBool(value) {
72
+      if (typeof value === "boolean") return value;
73
+      if (typeof value === "number") return value !== 0;
74
+      if (typeof value === "string") {
75
+        var v = value.trim().toLowerCase();
76
+        if (v === "true" || v === "1" || v === "on" || v === "yes" || v === "online") return true;
77
+        if (v === "false" || v === "0" || v === "off" || v === "no" || v === "offline") return false;
78
+      }
79
+      return null;
80
+    }
81
+
82
+    function asNumber(value) {
83
+      if (typeof value === "number" && isFinite(value)) return value;
84
+      if (typeof value === "string") {
85
+        var trimmed = value.trim();
86
+        if (!trimmed) return null;
87
+        var parsed = Number(trimmed);
88
+        if (isFinite(parsed)) return parsed;
89
+      }
90
+      return null;
91
+    }
92
+
93
+    function clamp(n, min, max) {
94
+      return Math.max(min, Math.min(max, n));
95
+    }
96
+
97
+    function normalizeToken(value) {
98
+      if (value === undefined || value === null) return "";
99
+      return String(value).trim();
100
+    }
101
+
102
+    function normalizeLegacyDeviceId(value) {
103
+      if (!value) return "";
104
+      if (/^radar-/i.test(value)) return value.replace(/^radar-/i, "");
105
+      return value;
106
+    }
107
+
108
+    function signature(value) {
109
+      return JSON.stringify(value);
110
+    }
111
+
112
+    function shouldPublish(cacheKey, payload) {
113
+      var sig = signature(payload);
114
+      if (node.hkCache[cacheKey] === sig) return false;
115
+      node.hkCache[cacheKey] = sig;
116
+      return true;
117
+    }
118
+
119
+    function cloneBaseMsg(msg) {
120
+      if (!msg || typeof msg !== "object") return {};
121
+      var out = {};
122
+      if (typeof msg.topic === "string") out.topic = msg.topic;
123
+      if (msg._msgid) out._msgid = msg._msgid;
124
+      return out;
125
+    }
126
+
127
+    function buildSubscriptionTopic(stream) {
128
+      return [
129
+        node.site,
130
+        "home",
131
+        node.location,
132
+        "+",
133
+        node.accessory,
134
+        stream
135
+      ].join("/");
136
+    }
137
+
138
+    function buildSubscribeMsgs() {
139
+      return [
140
+        {
141
+          action: "subscribe",
142
+          topic: buildSubscriptionTopic("last"),
143
+          qos: 2,
144
+          rh: 0,
145
+          rap: true
146
+        },
147
+        {
148
+          action: "subscribe",
149
+          topic: buildSubscriptionTopic("value"),
150
+          qos: 2,
151
+          rh: 0,
152
+          rap: true
153
+        }
154
+      ];
155
+    }
156
+
157
+    function buildUnsubscribeLastMsg(reason) {
158
+      return {
159
+        action: "unsubscribe",
160
+        topic: buildSubscriptionTopic("last"),
161
+        reason: reason
162
+      };
163
+    }
164
+
165
+    function statusText(prefix) {
166
+      var state = node.subscriptionState.lastSubscribed ? "cold" : (node.subscriptionState.started ? "live" : "idle");
167
+      var device = node.sensorState.deviceId || node.accessory || "?";
168
+      return [
169
+        prefix || state,
170
+        device,
171
+        "l:" + node.stats.last_inputs,
172
+        "v:" + node.stats.value_inputs,
173
+        "hk:" + node.stats.hk_updates
174
+      ].join(" ");
175
+    }
176
+
177
+    function setNodeStatus(prefix, fill, shape) {
178
+      node.status({
179
+        fill: fill || (node.stats.errors ? "red" : (node.subscriptionState.lastSubscribed ? "yellow" : "green")),
180
+        shape: shape || "dot",
181
+        text: statusText(prefix)
182
+      });
183
+    }
184
+
185
+    function noteError(text, msg) {
186
+      node.stats.errors += 1;
187
+      node.warn(text);
188
+      node.status({ fill: "red", shape: "ring", text: text });
189
+      if (msg) node.debug(msg);
190
+    }
191
+
192
+    function clearOccupancyTimer() {
193
+      if (node.occupancyTimer) {
194
+        clearTimeout(node.occupancyTimer);
195
+        node.occupancyTimer = null;
196
+      }
197
+    }
198
+
199
+    function clearBootstrapTimer() {
200
+      if (!node.bootstrapTimer) return;
201
+      clearTimeout(node.bootstrapTimer);
202
+      node.bootstrapTimer = null;
203
+    }
204
+
205
+    function makeHomeKitMsg(baseMsg, payload) {
206
+      var out = RED.util.cloneMessage(baseMsg || {});
207
+      out.payload = payload;
208
+      return out;
209
+    }
210
+
211
+    function buildStatusFields() {
212
+      return {
213
+        StatusActive: !!node.sensorState.active,
214
+        StatusFault: node.sensorState.active ? 0 : 1,
215
+        StatusLowBattery: node.sensorState.batteryLow ? 1 : 0,
216
+        StatusTampered: node.sensorState.tampered ? 1 : 0
217
+      };
218
+    }
219
+
220
+    function buildMotionMsg(baseMsg) {
221
+      if (!node.sensorState.motionKnown) return null;
222
+      var payload = buildStatusFields();
223
+      payload.MotionDetected = node.sensorState.motion ? 1 : 0;
224
+      if (!shouldPublish("hk:motion", payload)) return null;
225
+      node.stats.hk_updates += 1;
226
+      return makeHomeKitMsg(baseMsg, payload);
227
+    }
228
+
229
+    function buildOccupancyMsg(baseMsg) {
230
+      if (!node.sensorState.occupancyKnown) return null;
231
+      var payload = buildStatusFields();
232
+      payload.OccupancyDetected = node.sensorState.occupancy ? 1 : 0;
233
+      if (!shouldPublish("hk:occupancy", payload)) return null;
234
+      node.stats.hk_updates += 1;
235
+      return makeHomeKitMsg(baseMsg, payload);
236
+    }
237
+
238
+    function buildTemperatureMsg(baseMsg) {
239
+      if (!node.sensorState.temperatureKnown) return null;
240
+      var payload = {
241
+        CurrentTemperature: clamp(Number(node.sensorState.temperature), -100, 100)
242
+      };
243
+      if (!shouldPublish("hk:temperature", payload)) return null;
244
+      node.stats.hk_updates += 1;
245
+      return makeHomeKitMsg(baseMsg, payload);
246
+    }
247
+
248
+    function buildHumidityMsg(baseMsg) {
249
+      if (!node.sensorState.humidityKnown) return null;
250
+      var payload = {
251
+        CurrentRelativeHumidity: clamp(Number(node.sensorState.humidity), 0, 100)
252
+      };
253
+      if (!shouldPublish("hk:humidity", payload)) return null;
254
+      node.stats.hk_updates += 1;
255
+      return makeHomeKitMsg(baseMsg, payload);
256
+    }
257
+
258
+    function buildLightMsg(baseMsg) {
259
+      if (!node.sensorState.illuminanceKnown) return null;
260
+      var lux = Number(node.sensorState.illuminance);
261
+      var payload = {
262
+        CurrentAmbientLightLevel: lux <= 0 ? 0.0001 : clamp(lux, 0.0001, 100000)
263
+      };
264
+      if (!shouldPublish("hk:light", payload)) return null;
265
+      node.stats.hk_updates += 1;
266
+      return makeHomeKitMsg(baseMsg, payload);
267
+    }
268
+
269
+    function buildBatteryMsg(baseMsg) {
270
+      if (!node.sensorState.batteryKnown && !node.sensorState.batteryLowKnown) return null;
271
+      var batteryLevel = node.sensorState.batteryKnown
272
+        ? clamp(Math.round(Number(node.sensorState.battery)), 0, 100)
273
+        : (node.sensorState.batteryLow ? 1 : 100);
274
+      var payload = {
275
+        StatusLowBattery: node.sensorState.batteryLow ? 1 : 0,
276
+        BatteryLevel: batteryLevel,
277
+        ChargingState: 2
278
+      };
279
+      if (!shouldPublish("hk:battery", payload)) return null;
280
+      node.stats.hk_updates += 1;
281
+      return makeHomeKitMsg(baseMsg, payload);
282
+    }
283
+
284
+    function clearSnapshotCache() {
285
+      delete node.hkCache["hk:motion"];
286
+      delete node.hkCache["hk:occupancy"];
287
+      delete node.hkCache["hk:temperature"];
288
+      delete node.hkCache["hk:humidity"];
289
+      delete node.hkCache["hk:light"];
290
+      delete node.hkCache["hk:battery"];
291
+    }
292
+
293
+    function buildBootstrapOutputs(baseMsg) {
294
+      clearSnapshotCache();
295
+      return [
296
+        buildMotionMsg(baseMsg),
297
+        buildOccupancyMsg(baseMsg),
298
+        buildTemperatureMsg(baseMsg),
299
+        buildHumidityMsg(baseMsg),
300
+        buildLightMsg(baseMsg),
301
+        buildBatteryMsg(baseMsg)
302
+      ];
303
+    }
304
+
305
+    function emitTimerOccupancyClear() {
306
+      node.occupancyTimer = null;
307
+      if (!node.sensorState.occupancyKnown || !node.sensorState.occupancy) return;
308
+      node.sensorState.occupancy = false;
309
+      var baseMsg = cloneBaseMsg(node.lastMsgContext);
310
+      var occupancyMsg = buildOccupancyMsg(baseMsg);
311
+      if (occupancyMsg) {
312
+        node.send([null, occupancyMsg, null, null, null, null, null]);
313
+      }
314
+      setNodeStatus();
315
+    }
316
+
317
+    function applyMotionSample(rawDetected) {
318
+      var detected = !!rawDetected;
319
+      node.sensorState.motionKnown = true;
320
+      node.sensorState.motion = detected;
321
+
322
+      if (node.occupancyFadingTimeSec <= 0) {
323
+        clearOccupancyTimer();
324
+        node.sensorState.occupancyKnown = true;
325
+        node.sensorState.occupancy = detected;
326
+        return;
327
+      }
328
+
329
+      node.sensorState.occupancyKnown = true;
330
+      if (detected) {
331
+        node.sensorState.occupancy = true;
332
+        clearOccupancyTimer();
333
+        node.occupancyTimer = setTimeout(emitTimerOccupancyClear, Math.round(node.occupancyFadingTimeSec * 1000));
334
+      } else if (!node.sensorState.occupancy) {
335
+        clearOccupancyTimer();
336
+      }
337
+    }
338
+
339
+    function unsubscribeLast(reason, send) {
340
+      if (!node.subscriptionState.lastSubscribed) return null;
341
+      node.subscriptionState.lastSubscribed = false;
342
+      node.stats.controls += 1;
343
+      var controlMsg = buildUnsubscribeLastMsg(reason);
344
+      if (typeof send === "function") {
345
+        send([null, null, null, null, null, null, controlMsg]);
346
+      }
347
+      return controlMsg;
348
+    }
349
+
350
+    function markBootstrapSatisfied(capability) {
351
+      if (capability === "motion" && node.sensorState.motionKnown) {
352
+        node.bootstrapState.motion = true;
353
+      } else if (capability === "temperature" && node.sensorState.temperatureKnown) {
354
+        node.bootstrapState.temperature = true;
355
+      } else if (capability === "humidity" && node.sensorState.humidityKnown) {
356
+        node.bootstrapState.humidity = true;
357
+      } else if (capability === "illuminance" && node.sensorState.illuminanceKnown) {
358
+        node.bootstrapState.illuminance = true;
359
+      } else if ((capability === "battery" || capability === "battery_low") && (node.sensorState.batteryKnown || node.sensorState.batteryLowKnown)) {
360
+        node.bootstrapState.battery = true;
361
+      }
362
+    }
363
+
364
+    function isBootstrapComplete() {
365
+      return node.bootstrapState.motion
366
+        && node.bootstrapState.temperature
367
+        && node.bootstrapState.humidity
368
+        && node.bootstrapState.illuminance
369
+        && node.bootstrapState.battery;
370
+    }
371
+
372
+    function finalizeBootstrap(reason, send) {
373
+      if (node.bootstrapState.finalized) return false;
374
+      if (!node.subscriptionState.lastSubscribed) return false;
375
+      node.bootstrapState.finalized = true;
376
+      clearBootstrapTimer();
377
+      send = send || function(msgs) { node.send(msgs); };
378
+      var outputs = buildBootstrapOutputs(cloneBaseMsg(node.lastMsgContext));
379
+      var controlMsg = unsubscribeLast(reason);
380
+      send([
381
+        outputs[0],
382
+        outputs[1],
383
+        outputs[2],
384
+        outputs[3],
385
+        outputs[4],
386
+        outputs[5],
387
+        controlMsg
388
+      ]);
389
+      setNodeStatus("live");
390
+      return true;
391
+    }
392
+
393
+    function parseTopic(topic) {
394
+      if (typeof topic !== "string") return null;
395
+      var tokens = topic.split("/").map(function(token) {
396
+        return token.trim();
397
+      }).filter(function(token) {
398
+        return !!token;
399
+      });
400
+      if (tokens.length !== 6) return null;
401
+      if (tokens[1] !== "home") return null;
402
+      if (tokens[5] !== "value" && tokens[5] !== "last") return null;
403
+      if (node.site && tokens[0] !== node.site) return null;
404
+      if (node.location && tokens[2] !== node.location) return null;
405
+      if (node.accessory && tokens[4] !== node.accessory) return null;
406
+      return {
407
+        site: tokens[0],
408
+        location: tokens[2],
409
+        capability: tokens[3],
410
+        deviceId: tokens[4],
411
+        stream: tokens[5]
412
+      };
413
+    }
414
+
415
+    function extractValue(stream, payload) {
416
+      if (payload && typeof payload === "object" && !Array.isArray(payload) && Object.prototype.hasOwnProperty.call(payload, "value")) {
417
+        return payload.value;
418
+      }
419
+      return payload;
420
+    }
421
+
422
+    function updateBatteryLowFromThreshold() {
423
+      if (!node.sensorState.batteryKnown || node.sensorState.batteryLowKnown) return;
424
+      node.sensorState.batteryLow = Number(node.sensorState.battery) <= node.batteryLowThreshold;
425
+    }
426
+
427
+    function processCapability(baseMsg, parsed, value) {
428
+      var motionMsg = null;
429
+      var occupancyMsg = null;
430
+      var temperatureMsg = null;
431
+      var humidityMsg = null;
432
+      var lightMsg = null;
433
+      var batteryMsg = null;
434
+
435
+      node.sensorState.active = true;
436
+      node.sensorState.site = parsed.site;
437
+      node.sensorState.location = parsed.location;
438
+      node.sensorState.deviceId = parsed.deviceId;
439
+      node.lastMsgContext = cloneBaseMsg(baseMsg);
440
+
441
+      if (parsed.capability === "motion") {
442
+        var detected = asBool(value);
443
+        if (detected === null) return [null, null, null, null, null, null];
444
+        applyMotionSample(detected);
445
+        motionMsg = buildMotionMsg(baseMsg);
446
+        occupancyMsg = buildOccupancyMsg(baseMsg);
447
+      } else if (parsed.capability === "presence") {
448
+        return [null, null, null, null, null, null];
449
+      } else if (parsed.capability === "temperature") {
450
+        var temperature = asNumber(value);
451
+        if (temperature === null) return [null, null, null, null, null, null];
452
+        node.sensorState.temperatureKnown = true;
453
+        node.sensorState.temperature = temperature;
454
+        temperatureMsg = buildTemperatureMsg(baseMsg);
455
+      } else if (parsed.capability === "humidity") {
456
+        var humidity = asNumber(value);
457
+        if (humidity === null) return [null, null, null, null, null, null];
458
+        node.sensorState.humidityKnown = true;
459
+        node.sensorState.humidity = humidity;
460
+        humidityMsg = buildHumidityMsg(baseMsg);
461
+      } else if (parsed.capability === "illuminance") {
462
+        var illuminance = asNumber(value);
463
+        if (illuminance === null) return [null, null, null, null, null, null];
464
+        node.sensorState.illuminanceKnown = true;
465
+        node.sensorState.illuminance = illuminance;
466
+        lightMsg = buildLightMsg(baseMsg);
467
+      } else if (parsed.capability === "battery") {
468
+        var battery = asNumber(value);
469
+        if (battery === null) return [null, null, null, null, null, null];
470
+        node.sensorState.batteryKnown = true;
471
+        node.sensorState.battery = clamp(Math.round(battery), 0, 100);
472
+        updateBatteryLowFromThreshold();
473
+        batteryMsg = buildBatteryMsg(baseMsg);
474
+        motionMsg = buildMotionMsg(baseMsg);
475
+        occupancyMsg = buildOccupancyMsg(baseMsg);
476
+      } else if (parsed.capability === "battery_low") {
477
+        var batteryLow = asBool(value);
478
+        if (batteryLow === null) return [null, null, null, null, null, null];
479
+        node.sensorState.batteryLowKnown = true;
480
+        node.sensorState.batteryLow = batteryLow;
481
+        batteryMsg = buildBatteryMsg(baseMsg);
482
+        motionMsg = buildMotionMsg(baseMsg);
483
+        occupancyMsg = buildOccupancyMsg(baseMsg);
484
+      } else if (parsed.capability === "tamper") {
485
+        var tampered = asBool(value);
486
+        if (tampered === null) return [null, null, null, null, null, null];
487
+        node.sensorState.tamperedKnown = true;
488
+        node.sensorState.tampered = tampered;
489
+        motionMsg = buildMotionMsg(baseMsg);
490
+        occupancyMsg = buildOccupancyMsg(baseMsg);
491
+        batteryMsg = buildBatteryMsg(baseMsg);
492
+      } else {
493
+        return [null, null, null, null, null, null];
494
+      }
495
+
496
+      return [motionMsg, occupancyMsg, temperatureMsg, humidityMsg, lightMsg, batteryMsg];
497
+    }
498
+
499
+    function startSubscriptions() {
500
+      if (node.subscriptionState.started) return;
501
+      if (!node.site || !node.location || !node.accessory) {
502
+        noteError("missing site, location or accessory");
503
+        return;
504
+      }
505
+      node.subscriptionState.started = true;
506
+      node.subscriptionState.lastSubscribed = true;
507
+      node.subscriptionState.valueSubscribed = true;
508
+      clearBootstrapTimer();
509
+      node.bootstrapTimer = setTimeout(function() {
510
+        finalizeBootstrap("bootstrap-timeout");
511
+      }, node.bootstrapDeadlineMs);
512
+      node.stats.controls += 1;
513
+      node.send([null, null, null, null, null, null, buildSubscribeMsgs()]);
514
+      setNodeStatus("cold");
515
+    }
516
+
517
+    node.on("input", function(msg, send, done) {
518
+      send = send || function() { node.send.apply(node, arguments); };
519
+
520
+      try {
521
+        var parsed = parseTopic(msg && msg.topic);
522
+        if (!parsed) {
523
+          noteError("invalid topic");
524
+          if (done) done();
525
+          return;
526
+        }
527
+
528
+        var value = extractValue(parsed.stream, msg.payload);
529
+        var controlMsg = null;
530
+
531
+        if (parsed.stream === "last") node.stats.last_inputs += 1;
532
+        else node.stats.value_inputs += 1;
533
+
534
+        var outputs = processCapability(msg, parsed, value);
535
+        markBootstrapSatisfied(parsed.capability);
536
+        send([
537
+          outputs[0],
538
+          outputs[1],
539
+          outputs[2],
540
+          outputs[3],
541
+          outputs[4],
542
+          outputs[5],
543
+          controlMsg
544
+        ]);
545
+
546
+        setNodeStatus();
547
+        if (done) done();
548
+      } catch (err) {
549
+        node.stats.errors += 1;
550
+        node.status({ fill: "red", shape: "ring", text: "error: " + err.message });
551
+        if (done) done(err);
552
+        else node.error(err, msg);
553
+      }
554
+    });
555
+
556
+    node.on("close", function() {
557
+      clearBootstrapTimer();
558
+      clearOccupancyTimer();
559
+      if (node.startTimer) {
560
+        clearTimeout(node.startTimer);
561
+        node.startTimer = null;
562
+      }
563
+    });
564
+
565
+    node.startTimer = setTimeout(startSubscriptions, 250);
566
+    node.status({ fill: "grey", shape: "ring", text: "starting" });
567
+  }
568
+
569
+  RED.nodes.registerType("zg-204zv-homekit-adapter", Z2MZG204ZVNode);
570
+};
+21 -0
adapters/smart-socket/energy-adapter/package.json
@@ -0,0 +1,21 @@
1
+{
2
+  "name": "node-red-contrib-z2m-smart-socket-energy",
3
+  "version": "0.0.1",
4
+  "description": "Node-RED node that projects Zigbee2MQTT smart socket electrical telemetry to the canonical Energy Bus",
5
+  "main": "z2m-smart-socket-energy.js",
6
+  "keywords": [
7
+    "node-red",
8
+    "zigbee2mqtt",
9
+    "energy",
10
+    "smart-socket",
11
+    "smart-plug",
12
+    "power-monitoring"
13
+  ],
14
+  "author": "",
15
+  "license": "MIT",
16
+  "node-red": {
17
+    "nodes": {
18
+      "z2m-smart-socket-energy": "z2m-smart-socket-energy.js"
19
+    }
20
+  }
21
+}
+73 -0
adapters/smart-socket/energy-adapter/z2m-smart-socket-energy.html
@@ -0,0 +1,73 @@
1
+<script type="text/x-red" data-template-name="z2m-smart-socket-energy">
2
+  <div class="form-row">
3
+    <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
4
+    <input type="text" id="node-input-name" placeholder="Name">
5
+  </div>
6
+  <div class="form-row">
7
+    <label for="node-input-site"><i class="fa fa-globe"></i> Site</label>
8
+    <input type="text" id="node-input-site" placeholder="vad">
9
+  </div>
10
+  <div class="form-row">
11
+    <label for="node-input-entityType">Entity type</label>
12
+    <select id="node-input-entityType">
13
+      <option value="load">Load</option>
14
+      <option value="source">Source</option>
15
+      <option value="storage">Storage</option>
16
+      <option value="grid">Grid</option>
17
+      <option value="transfer">Transfer</option>
18
+    </select>
19
+  </div>
20
+</script>
21
+
22
+<script type="text/x-red" data-help-name="z2m-smart-socket-energy">
23
+  <p>
24
+    Projects <code>A1Z</code> and <code>S60ZBTPF</code> smart socket measurements onto the canonical Energy Bus.
25
+  </p>
26
+  <p>
27
+    Canonical topic shape:
28
+    <code>&lt;site&gt;/energy/&lt;entity_type&gt;/&lt;entity_id&gt;/&lt;metric&gt;/&lt;stream&gt;</code>
29
+  </p>
30
+  <p>
31
+    Standardized metrics:
32
+    <code>active_power</code>,
33
+    <code>energy_total</code>,
34
+    <code>voltage</code>,
35
+    <code>current</code>,
36
+    plus <code>energy_today</code>, <code>energy_yesterday</code>, and <code>energy_month</code> for <code>S60ZBTPF</code>.
37
+  </p>
38
+  <h3>Inputs</h3>
39
+  <p>
40
+    Raw Zigbee2MQTT telemetry:
41
+    <code>zigbee2mqtt/A1Z/&lt;site&gt;/&lt;location&gt;/&lt;device_id&gt;</code> or
42
+    <code>zigbee2mqtt/S60ZBTPF/&lt;site&gt;/&lt;location&gt;/&lt;device_id&gt;</code>,
43
+    plus optional sibling <code>/availability</code>.
44
+  </p>
45
+  <h3>Outputs</h3>
46
+  <ol>
47
+    <li>MQTT-ready Energy Bus and <code>sys</code> publish messages.</li>
48
+    <li>Dynamic <code>mqtt in</code> control messages that subscribe to both supported raw Zigbee2MQTT model trees.</li>
49
+  </ol>
50
+</script>
51
+
52
+<script>
53
+  RED.nodes.registerType("z2m-smart-socket-energy", {
54
+    category: "myNodes",
55
+    color: "#fce3cf",
56
+    defaults: {
57
+      name: { value: "" },
58
+      site: { value: "unknown" },
59
+      entityType: { value: "load" },
60
+      mqttSite: { value: "" },
61
+      mqttBus: { value: "" },
62
+      mqttRoom: { value: "" },
63
+      mqttSensor: { value: "" }
64
+    },
65
+    inputs: 1,
66
+    outputs: 2,
67
+    icon: "font-awesome/fa-bolt",
68
+    label: function() {
69
+      return this.name || "z2m-smart-socket-energy";
70
+    },
71
+    paletteLabel: "smart socket energy"
72
+  });
73
+</script>
+666 -0
adapters/smart-socket/energy-adapter/z2m-smart-socket-energy.js
@@ -0,0 +1,666 @@
1
+module.exports = function(RED) {
2
+  function Z2MSmartSocketEnergyNode(config) {
3
+    RED.nodes.createNode(this, config);
4
+    var node = this;
5
+
6
+    node.adapterId = "z2m-smart-socket-energy";
7
+    node.site = normalizeToken(config.site || config.mqttSite || "");
8
+    node.entityType = normalizeToken(config.entityType || "load").toLowerCase() || "load";
9
+    node.sourceModels = ["A1Z", "S60ZBTPF"];
10
+    node.sourceTopics = node.sourceModels.map(function(model) { return "zigbee2mqtt/" + model + "/#"; });
11
+    node.subscriptionStarted = false;
12
+    node.startTimer = null;
13
+    node.publishCache = Object.create(null);
14
+    node.retainedCache = Object.create(null);
15
+    node.statsPublishEvery = 25;
16
+
17
+    node.stats = {
18
+      processed_inputs: 0,
19
+      raw_inputs: 0,
20
+      energy_messages: 0,
21
+      last_messages: 0,
22
+      meta_messages: 0,
23
+      energy_availability_messages: 0,
24
+      operational_messages: 0,
25
+      invalid_messages: 0,
26
+      invalid_topics: 0,
27
+      invalid_payloads: 0,
28
+      unmapped_messages: 0,
29
+      errors: 0,
30
+      dlq: 0
31
+    };
32
+
33
+    var ENERGY_MAPPINGS = [
34
+      {
35
+        targetMetric: "active_power",
36
+        core: true,
37
+        payloadProfile: "scalar",
38
+        dataType: "number",
39
+        unit: "W",
40
+        precision: 0.1,
41
+        historianMode: "sample",
42
+        historianEnabled: true,
43
+        read: function(payload) {
44
+          var value = asNumber(payload.power);
45
+          return value === null ? undefined : value;
46
+        }
47
+      },
48
+      {
49
+        targetMetric: "energy_total",
50
+        core: true,
51
+        payloadProfile: "scalar",
52
+        dataType: "number",
53
+        unit: "kWh",
54
+        precision: 0.001,
55
+        historianMode: "sample",
56
+        historianEnabled: true,
57
+        read: function(payload) {
58
+          var value = asNumber(payload.energy);
59
+          return value === null ? undefined : value;
60
+        }
61
+      },
62
+      {
63
+        targetMetric: "voltage",
64
+        core: true,
65
+        payloadProfile: "scalar",
66
+        dataType: "number",
67
+        unit: "V",
68
+        precision: 0.1,
69
+        historianMode: "sample",
70
+        historianEnabled: true,
71
+        read: function(payload) {
72
+          var value = asNumber(payload.voltage);
73
+          return value === null ? undefined : value;
74
+        }
75
+      },
76
+      {
77
+        targetMetric: "current",
78
+        core: true,
79
+        payloadProfile: "scalar",
80
+        dataType: "number",
81
+        unit: "A",
82
+        precision: 0.001,
83
+        historianMode: "sample",
84
+        historianEnabled: true,
85
+        read: function(payload) {
86
+          var value = asNumber(payload.current);
87
+          return value === null ? undefined : value;
88
+        }
89
+      },
90
+      {
91
+        models: ["S60ZBTPF"],
92
+        targetMetric: "energy_today",
93
+        core: false,
94
+        payloadProfile: "scalar",
95
+        dataType: "number",
96
+        unit: "kWh",
97
+        precision: 0.001,
98
+        historianMode: "sample",
99
+        historianEnabled: true,
100
+        read: function(payload) {
101
+          var value = asNumber(payload.energy_today);
102
+          return value === null ? undefined : value;
103
+        }
104
+      },
105
+      {
106
+        models: ["S60ZBTPF"],
107
+        targetMetric: "energy_yesterday",
108
+        core: false,
109
+        payloadProfile: "scalar",
110
+        dataType: "number",
111
+        unit: "kWh",
112
+        precision: 0.001,
113
+        historianMode: "sample",
114
+        historianEnabled: true,
115
+        read: function(payload) {
116
+          var value = asNumber(payload.energy_yesterday);
117
+          return value === null ? undefined : value;
118
+        }
119
+      },
120
+      {
121
+        models: ["S60ZBTPF"],
122
+        targetMetric: "energy_month",
123
+        core: false,
124
+        payloadProfile: "scalar",
125
+        dataType: "number",
126
+        unit: "kWh",
127
+        precision: 0.001,
128
+        historianMode: "sample",
129
+        historianEnabled: true,
130
+        read: function(payload) {
131
+          var value = asNumber(payload.energy_month);
132
+          return value === null ? undefined : value;
133
+        }
134
+      }
135
+    ];
136
+
137
+    function normalizeToken(value) {
138
+      if (value === undefined || value === null) return "";
139
+      return String(value).trim();
140
+    }
141
+
142
+    function transliterate(value) {
143
+      var s = normalizeToken(value);
144
+      if (!s) return "";
145
+      if (typeof s.normalize === "function") {
146
+        s = s.normalize("NFKD").replace(/[\u0300-\u036f]/g, "");
147
+      }
148
+      return s;
149
+    }
150
+
151
+    function toKebabCase(value, fallback) {
152
+      var s = transliterate(value).toLowerCase().replace(/[^a-z0-9]+/g, "-");
153
+      s = s.replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-");
154
+      return s || fallback || "";
155
+    }
156
+
157
+    function humanizeCapability(capability) {
158
+      return String(capability || "").replace(/_/g, " ").replace(/\b[a-z]/g, function(ch) { return ch.toUpperCase(); });
159
+    }
160
+
161
+    function asBool(value) {
162
+      if (typeof value === "boolean") return value;
163
+      if (typeof value === "number") return value !== 0;
164
+      if (typeof value === "string") {
165
+        var v = value.trim().toLowerCase();
166
+        if (v === "true" || v === "1" || v === "on" || v === "yes" || v === "online") return true;
167
+        if (v === "false" || v === "0" || v === "off" || v === "no" || v === "offline") return false;
168
+      }
169
+      return null;
170
+    }
171
+
172
+    function asNumber(value) {
173
+      if (typeof value === "number" && isFinite(value)) return value;
174
+      if (typeof value === "string") {
175
+        var trimmed = value.trim();
176
+        if (!trimmed) return null;
177
+        var parsed = Number(trimmed);
178
+        if (isFinite(parsed)) return parsed;
179
+      }
180
+      return null;
181
+    }
182
+
183
+    function toIsoTimestamp(value) {
184
+      if (value === undefined || value === null || value === "") return "";
185
+      var parsed = new Date(value);
186
+      if (isNaN(parsed.getTime())) return "";
187
+      return parsed.toISOString();
188
+    }
189
+
190
+    function resolveObservation(msg, payloadObject) {
191
+      var observedAt = "";
192
+      if (payloadObject && typeof payloadObject === "object") {
193
+        observedAt = toIsoTimestamp(payloadObject.observed_at)
194
+          || toIsoTimestamp(payloadObject.observedAt)
195
+          || toIsoTimestamp(payloadObject.timestamp)
196
+          || toIsoTimestamp(payloadObject.time)
197
+          || toIsoTimestamp(payloadObject.ts)
198
+          || toIsoTimestamp(payloadObject.last_seen);
199
+      }
200
+
201
+      if (!observedAt && msg && typeof msg === "object") {
202
+        observedAt = toIsoTimestamp(msg.observed_at)
203
+          || toIsoTimestamp(msg.observedAt)
204
+          || toIsoTimestamp(msg.timestamp)
205
+          || toIsoTimestamp(msg.time)
206
+          || toIsoTimestamp(msg.ts);
207
+      }
208
+
209
+      if (observedAt) {
210
+        return {
211
+          observedAt: observedAt,
212
+          quality: "good"
213
+        };
214
+      }
215
+
216
+      return {
217
+        observedAt: new Date().toISOString(),
218
+        quality: "estimated"
219
+      };
220
+    }
221
+
222
+    function supportsModel(mapping, model) {
223
+      if (!mapping.models || !mapping.models.length) return true;
224
+      return mapping.models.indexOf(model) >= 0;
225
+    }
226
+
227
+    function translatedMessageCount() {
228
+      return node.stats.energy_messages
229
+        + node.stats.last_messages
230
+        + node.stats.meta_messages
231
+        + node.stats.energy_availability_messages;
232
+    }
233
+
234
+    function makePublishMsg(topic, payload, retain) {
235
+      return {
236
+        topic: topic,
237
+        payload: payload,
238
+        qos: 1,
239
+        retain: !!retain
240
+      };
241
+    }
242
+
243
+    function makeSubscribeMsg(topic) {
244
+      return {
245
+        action: "subscribe",
246
+        topic: topic,
247
+        qos: 2,
248
+        rh: 0,
249
+        rap: true
250
+      };
251
+    }
252
+
253
+    function buildEnergyTopic(identity, mapping, stream) {
254
+      return identity.site + "/energy/" + identity.entityType + "/" + identity.entityId + "/" + mapping.targetMetric + "/" + stream;
255
+    }
256
+
257
+    function buildSysTopic(site, stream) {
258
+      return site + "/sys/adapter/" + node.adapterId + "/" + stream;
259
+    }
260
+
261
+    function signature(value) {
262
+      return JSON.stringify(value);
263
+    }
264
+
265
+    function shouldPublishLive(cacheKey, payload) {
266
+      var sig = signature(payload);
267
+      if (node.publishCache[cacheKey] === sig) return false;
268
+      node.publishCache[cacheKey] = sig;
269
+      return true;
270
+    }
271
+
272
+    function shouldPublishRetained(cacheKey, payload) {
273
+      var sig = signature(payload);
274
+      if (node.retainedCache[cacheKey] === sig) return false;
275
+      node.retainedCache[cacheKey] = sig;
276
+      return true;
277
+    }
278
+
279
+    function buildLastPayload(value, observation) {
280
+      var payload = {
281
+        value: value,
282
+        observed_at: observation.observedAt
283
+      };
284
+      if (observation.quality && observation.quality !== "good") payload.quality = observation.quality;
285
+      return payload;
286
+    }
287
+
288
+    function buildMetaPayload(identity, mapping) {
289
+      var payload = {
290
+        schema_ref: "mqbus.energy.v1",
291
+        payload_profile: mapping.payloadProfile || "scalar",
292
+        stream_payload_profiles: { value: "scalar", last: "envelope" },
293
+        data_type: mapping.dataType,
294
+        adapter_id: node.adapterId,
295
+        source: "zigbee2mqtt",
296
+        source_ref: identity.sourceRef,
297
+        source_topic: identity.sourceTopic,
298
+        display_name: identity.displayName + " " + humanizeCapability(mapping.targetMetric),
299
+        tags: ["zigbee2mqtt", "smart-socket", identity.model.toLowerCase(), "energy", identity.entityType],
300
+        historian: {
301
+          enabled: !!mapping.historianEnabled,
302
+          mode: mapping.historianMode
303
+        }
304
+      };
305
+
306
+      if (mapping.unit) payload.unit = mapping.unit;
307
+      if (mapping.precision !== undefined) payload.precision = mapping.precision;
308
+
309
+      return payload;
310
+    }
311
+
312
+    function enqueueEnergyMeta(messages, identity, mapping) {
313
+      var topic = buildEnergyTopic(identity, mapping, "meta");
314
+      var payload = buildMetaPayload(identity, mapping);
315
+      if (!shouldPublishRetained("meta:" + topic, payload)) return;
316
+      messages.push(makePublishMsg(topic, payload, true));
317
+      noteMessage("meta_messages");
318
+    }
319
+
320
+    function enqueueEnergyAvailability(messages, identity, mapping, online) {
321
+      var topic = buildEnergyTopic(identity, mapping, "availability");
322
+      var payload = online ? "online" : "offline";
323
+      if (!shouldPublishRetained("availability:" + topic, payload)) return;
324
+      messages.push(makePublishMsg(topic, payload, true));
325
+      noteMessage("energy_availability_messages");
326
+    }
327
+
328
+    function enqueueEnergyLast(messages, identity, mapping, value, observation) {
329
+      var topic = buildEnergyTopic(identity, mapping, "last");
330
+      var payload = buildLastPayload(value, observation);
331
+      if (!shouldPublishRetained("last:" + topic, payload)) return;
332
+      messages.push(makePublishMsg(topic, payload, true));
333
+      noteMessage("last_messages");
334
+    }
335
+
336
+    function enqueueEnergyValue(messages, identity, mapping, value) {
337
+      var topic = buildEnergyTopic(identity, mapping, "value");
338
+      if (!shouldPublishLive("value:" + topic, value)) return;
339
+      messages.push(makePublishMsg(topic, value, false));
340
+      noteMessage("energy_messages");
341
+    }
342
+
343
+    function enqueueAdapterAvailability(messages, site, online) {
344
+      var topic = buildSysTopic(site, "availability");
345
+      var payload = online ? "online" : "offline";
346
+      if (!shouldPublishRetained("sys:availability:" + topic, payload)) return;
347
+      messages.push(makePublishMsg(topic, payload, true));
348
+      noteMessage("operational_messages");
349
+    }
350
+
351
+    function enqueueAdapterStats(messages, site, force) {
352
+      if (!force && node.stats.processed_inputs !== 1 && (node.stats.processed_inputs % node.statsPublishEvery) !== 0) return;
353
+      var topic = buildSysTopic(site, "stats");
354
+      var payload = {
355
+        processed_inputs: node.stats.processed_inputs,
356
+        raw_inputs: node.stats.raw_inputs,
357
+        translated_messages: translatedMessageCount(),
358
+        energy_messages: node.stats.energy_messages,
359
+        last_messages: node.stats.last_messages,
360
+        meta_messages: node.stats.meta_messages,
361
+        energy_availability_messages: node.stats.energy_availability_messages,
362
+        operational_messages: node.stats.operational_messages,
363
+        invalid_messages: node.stats.invalid_messages,
364
+        invalid_topics: node.stats.invalid_topics,
365
+        invalid_payloads: node.stats.invalid_payloads,
366
+        unmapped_messages: node.stats.unmapped_messages,
367
+        errors: node.stats.errors,
368
+        dlq: node.stats.dlq
369
+      };
370
+      messages.push(makePublishMsg(topic, payload, true));
371
+      noteMessage("operational_messages");
372
+    }
373
+
374
+    function enqueueError(messages, site, code, reason, sourceTopic) {
375
+      var payload = {
376
+        code: code,
377
+        reason: reason,
378
+        source_topic: normalizeToken(sourceTopic),
379
+        adapter_id: node.adapterId
380
+      };
381
+      messages.push(makePublishMsg(buildSysTopic(site, "error"), payload, false));
382
+      if (code === "invalid_message") noteMessage("invalid_messages");
383
+      else if (code === "invalid_topic") noteMessage("invalid_topics");
384
+      else if (code === "payload_not_object" || code === "invalid_availability_payload") noteMessage("invalid_payloads");
385
+      else if (code === "no_mapped_fields") noteMessage("unmapped_messages");
386
+      noteMessage("errors");
387
+      noteMessage("operational_messages");
388
+    }
389
+
390
+    function enqueueDlq(messages, site, code, sourceTopic, rawPayload) {
391
+      var payload = {
392
+        code: code,
393
+        source_topic: normalizeToken(sourceTopic),
394
+        payload: rawPayload
395
+      };
396
+      messages.push(makePublishMsg(buildSysTopic(site, "dlq"), payload, false));
397
+      noteMessage("dlq");
398
+      noteMessage("operational_messages");
399
+    }
400
+
401
+    function noteMessage(kind) {
402
+      if (Object.prototype.hasOwnProperty.call(node.stats, kind)) {
403
+        node.stats[kind] += 1;
404
+      }
405
+    }
406
+
407
+    function summarizeForLog(value) {
408
+      if (value === undefined) return "undefined";
409
+      if (value === null) return "null";
410
+      if (typeof value === "string") return value.length > 180 ? value.slice(0, 177) + "..." : value;
411
+      try {
412
+        var serialized = JSON.stringify(value);
413
+        return serialized.length > 180 ? serialized.slice(0, 177) + "..." : serialized;
414
+      } catch (err) {
415
+        return String(value);
416
+      }
417
+    }
418
+
419
+    function logIssue(level, code, reason, msg, rawPayload) {
420
+      var parts = ["[" + node.adapterId + "]", code + ":", reason];
421
+      if (msg && typeof msg === "object" && normalizeToken(msg.topic)) {
422
+        parts.push("topic=" + normalizeToken(msg.topic));
423
+      }
424
+      if (rawPayload !== undefined) {
425
+        parts.push("payload=" + summarizeForLog(rawPayload));
426
+      }
427
+      var text = parts.join(" ");
428
+      if (level === "error") {
429
+        node.error(text, msg);
430
+      } else {
431
+        node.warn(text);
432
+      }
433
+    }
434
+
435
+    function parseRawTopic(topic) {
436
+      if (typeof topic !== "string") return null;
437
+      var tokens = topic.split("/").map(function(token) { return token.trim(); }).filter(function(token) { return !!token; });
438
+      if (tokens.length !== 5 && tokens.length !== 6) return null;
439
+      if (tokens[0].toLowerCase() !== "zigbee2mqtt") return null;
440
+      if (node.sourceModels.indexOf(tokens[1]) < 0) return null;
441
+      if (tokens.length === 6 && tokens[5].toLowerCase() !== "availability") return null;
442
+      return {
443
+        model: tokens[1],
444
+        site: toKebabCase(tokens[2], node.site || "unknown"),
445
+        location: toKebabCase(tokens[3], "unknown"),
446
+        deviceId: toKebabCase(tokens[4], tokens[1].toLowerCase()),
447
+        sourceTopic: tokens.slice(0, 5).join("/"),
448
+        availabilityTopic: tokens.length === 6
449
+      };
450
+    }
451
+
452
+    function buildIdentity(parsed) {
453
+      var entityId = toKebabCase((parsed.location || "unknown") + "-" + (parsed.deviceId || parsed.model.toLowerCase()), parsed.deviceId || parsed.model.toLowerCase());
454
+      return {
455
+        model: parsed.model,
456
+        site: parsed.site || node.site || "unknown",
457
+        entityType: node.entityType || "load",
458
+        entityId: entityId,
459
+        sourceRef: parsed.sourceTopic,
460
+        sourceTopic: parsed.sourceTopic,
461
+        displayName: [parsed.location || "unknown", parsed.deviceId || parsed.model.toLowerCase()].join(" ")
462
+      };
463
+    }
464
+
465
+    function activeMappings(model, payloadObject) {
466
+      var result = [];
467
+      for (var i = 0; i < ENERGY_MAPPINGS.length; i++) {
468
+        var mapping = ENERGY_MAPPINGS[i];
469
+        if (!supportsModel(mapping, model)) continue;
470
+        var value = payloadObject ? mapping.read(payloadObject, model) : undefined;
471
+        result.push({
472
+          mapping: mapping,
473
+          hasValue: value !== undefined,
474
+          value: value
475
+        });
476
+      }
477
+      return result;
478
+    }
479
+
480
+    function resolveOnlineState(payloadObject, availabilityValue) {
481
+      if (availabilityValue !== null && availabilityValue !== undefined) return !!availabilityValue;
482
+      if (!payloadObject) return true;
483
+      if (typeof payloadObject.availability === "string") {
484
+        return payloadObject.availability.trim().toLowerCase() !== "offline";
485
+      }
486
+      if (typeof payloadObject.online === "boolean") {
487
+        return payloadObject.online;
488
+      }
489
+      return true;
490
+    }
491
+
492
+    function housekeepingOnly(payloadObject) {
493
+      if (!payloadObject || typeof payloadObject !== "object") return false;
494
+      var keys = Object.keys(payloadObject);
495
+      if (!keys.length) return true;
496
+      for (var i = 0; i < keys.length; i++) {
497
+        if (["last_seen", "linkquality", "update", "availability", "online", "state", "power_on_behavior", "power_outage_memory", "indicator_mode", "child_lock", "countdown", "switch_type_button", "outlet_control_protect", "overload_protection"].indexOf(keys[i]) < 0) return false;
498
+      }
499
+      return !Object.prototype.hasOwnProperty.call(payloadObject, "power")
500
+        && !Object.prototype.hasOwnProperty.call(payloadObject, "energy")
501
+        && !Object.prototype.hasOwnProperty.call(payloadObject, "voltage")
502
+        && !Object.prototype.hasOwnProperty.call(payloadObject, "current")
503
+        && !Object.prototype.hasOwnProperty.call(payloadObject, "energy_today")
504
+        && !Object.prototype.hasOwnProperty.call(payloadObject, "energy_yesterday")
505
+        && !Object.prototype.hasOwnProperty.call(payloadObject, "energy_month");
506
+    }
507
+
508
+    function statusText(prefix) {
509
+      return [
510
+        prefix || (node.subscriptionStarted ? "live" : "idle"),
511
+        "in:" + node.stats.processed_inputs,
512
+        "energy:" + node.stats.energy_messages,
513
+        "err:" + node.stats.errors
514
+      ].join(" ");
515
+    }
516
+
517
+    function updateNodeStatus(fill, shape, prefix) {
518
+      node.status({
519
+        fill: fill || (node.stats.errors ? "red" : "green"),
520
+        shape: shape || "dot",
521
+        text: statusText(prefix)
522
+      });
523
+    }
524
+
525
+    function flush(send, done, publishMessages, controlMessages, error) {
526
+      send([
527
+        publishMessages.length ? publishMessages : null,
528
+        controlMessages.length ? controlMessages : null
529
+      ]);
530
+      if (done) done(error);
531
+    }
532
+
533
+    function startSubscriptions() {
534
+      if (node.subscriptionStarted) return;
535
+      node.subscriptionStarted = true;
536
+      var controls = node.sourceTopics.map(function(topic) { return makeSubscribeMsg(topic); });
537
+      node.send([null, controls]);
538
+      updateNodeStatus("yellow", "dot", "subscribed");
539
+    }
540
+
541
+    node.on("input", function(msg, send, done) {
542
+      send = send || function() { node.send.apply(node, arguments); };
543
+      node.stats.processed_inputs += 1;
544
+
545
+      try {
546
+        if (!msg || typeof msg !== "object") {
547
+          var invalidMessages = [];
548
+          var invalidSite = node.site || "unknown";
549
+          enqueueAdapterAvailability(invalidMessages, invalidSite, true);
550
+          enqueueError(invalidMessages, invalidSite, "invalid_message", "Input must be an object", "");
551
+          enqueueDlq(invalidMessages, invalidSite, "invalid_message", "", null);
552
+          enqueueAdapterStats(invalidMessages, invalidSite, true);
553
+          logIssue("warn", "invalid_message", "Input must be an object", null, msg);
554
+          updateNodeStatus("yellow", "ring", "bad msg");
555
+          flush(send, done, invalidMessages, []);
556
+          return;
557
+        }
558
+
559
+        var publishMessages = [];
560
+        var controlMessages = [];
561
+        var parsed = parseRawTopic(msg.topic);
562
+
563
+        if (!parsed) {
564
+          var unknownSite = node.site || "unknown";
565
+          enqueueAdapterAvailability(publishMessages, unknownSite, true);
566
+          enqueueError(publishMessages, unknownSite, "invalid_topic", "Unsupported topic for smart socket energy adapter", msg.topic);
567
+          enqueueDlq(publishMessages, unknownSite, "invalid_topic", msg.topic, msg.payload);
568
+          enqueueAdapterStats(publishMessages, unknownSite, true);
569
+          logIssue("warn", "invalid_topic", "Unsupported topic for smart socket energy adapter", msg, msg.payload);
570
+          updateNodeStatus("yellow", "ring", "bad topic");
571
+          flush(send, done, publishMessages, controlMessages);
572
+          return;
573
+        }
574
+
575
+        node.stats.raw_inputs += 1;
576
+        var identity = buildIdentity(parsed);
577
+        enqueueAdapterAvailability(publishMessages, identity.site, true);
578
+
579
+        if (parsed.availabilityTopic) {
580
+          var availabilityValue = asBool(msg.payload);
581
+          if (availabilityValue === null) {
582
+            enqueueError(publishMessages, identity.site, "invalid_availability_payload", "Availability payload must be online/offline or boolean", msg.topic);
583
+            enqueueDlq(publishMessages, identity.site, "invalid_availability_payload", msg.topic, msg.payload);
584
+            enqueueAdapterStats(publishMessages, identity.site, true);
585
+            logIssue("warn", "invalid_availability_payload", "Availability payload must be online/offline or boolean", msg, msg.payload);
586
+            updateNodeStatus("yellow", "ring", "bad availability");
587
+            flush(send, done, publishMessages, controlMessages);
588
+            return;
589
+          }
590
+
591
+          var availabilityMappings = activeMappings(identity.model, null);
592
+          for (var i = 0; i < availabilityMappings.length; i++) {
593
+            enqueueEnergyMeta(publishMessages, identity, availabilityMappings[i].mapping);
594
+            enqueueEnergyAvailability(publishMessages, identity, availabilityMappings[i].mapping, availabilityValue);
595
+          }
596
+
597
+          enqueueAdapterStats(publishMessages, identity.site, false);
598
+          updateNodeStatus(availabilityValue ? "green" : "yellow", availabilityValue ? "dot" : "ring", availabilityValue ? "live" : "offline");
599
+          flush(send, done, publishMessages, controlMessages);
600
+          return;
601
+        }
602
+
603
+        var payloadObject = msg.payload && typeof msg.payload === "object" && !Array.isArray(msg.payload) ? msg.payload : null;
604
+        if (!payloadObject) {
605
+          enqueueError(publishMessages, identity.site, "payload_not_object", "Telemetry payload must be an object", msg.topic);
606
+          enqueueDlq(publishMessages, identity.site, "payload_not_object", msg.topic, msg.payload);
607
+          enqueueAdapterStats(publishMessages, identity.site, true);
608
+          logIssue("warn", "payload_not_object", "Telemetry payload must be an object", msg, msg.payload);
609
+          updateNodeStatus("yellow", "ring", "payload");
610
+          flush(send, done, publishMessages, controlMessages);
611
+          return;
612
+        }
613
+
614
+        var observation = resolveObservation(msg, payloadObject);
615
+        var online = resolveOnlineState(payloadObject, null);
616
+        var mappings = activeMappings(identity.model, payloadObject);
617
+        var hasMappedValue = false;
618
+
619
+        for (var j = 0; j < mappings.length; j++) {
620
+          enqueueEnergyMeta(publishMessages, identity, mappings[j].mapping);
621
+          enqueueEnergyAvailability(publishMessages, identity, mappings[j].mapping, online);
622
+          if (mappings[j].hasValue) {
623
+            hasMappedValue = true;
624
+            enqueueEnergyLast(publishMessages, identity, mappings[j].mapping, mappings[j].value, observation);
625
+            enqueueEnergyValue(publishMessages, identity, mappings[j].mapping, mappings[j].value);
626
+          }
627
+        }
628
+
629
+        if (!hasMappedValue && !housekeepingOnly(payloadObject)) {
630
+          enqueueError(publishMessages, identity.site, "no_mapped_fields", "Payload did not contain any supported smart socket Energy Bus fields", msg.topic);
631
+          enqueueDlq(publishMessages, identity.site, "no_mapped_fields", msg.topic, payloadObject);
632
+          logIssue("warn", "no_mapped_fields", "Payload did not contain any supported smart socket Energy Bus fields", msg, payloadObject);
633
+        }
634
+
635
+        enqueueAdapterStats(publishMessages, identity.site, false);
636
+        if (!hasMappedValue && !housekeepingOnly(payloadObject)) {
637
+          updateNodeStatus("yellow", "ring", "unmapped");
638
+        } else {
639
+          updateNodeStatus(online ? "green" : "yellow", online ? "dot" : "ring", online ? "live" : "offline");
640
+        }
641
+        flush(send, done, publishMessages, controlMessages);
642
+      } catch (err) {
643
+        var site = node.site || "unknown";
644
+        var errorMessages = [];
645
+        enqueueAdapterAvailability(errorMessages, site, true);
646
+        enqueueError(errorMessages, site, "adapter_exception", err.message, msg && msg.topic);
647
+        enqueueAdapterStats(errorMessages, site, true);
648
+        logIssue("error", "adapter_exception", err.message, msg, msg && msg.payload);
649
+        updateNodeStatus("red", "ring", "error");
650
+        flush(send, done, errorMessages, [], err);
651
+      }
652
+    });
653
+
654
+    node.on("close", function() {
655
+      if (node.startTimer) {
656
+        clearTimeout(node.startTimer);
657
+        node.startTimer = null;
658
+      }
659
+    });
660
+
661
+    node.startTimer = setTimeout(startSubscriptions, 250);
662
+    updateNodeStatus("grey", "ring", "waiting");
663
+  }
664
+
665
+  RED.nodes.registerType("z2m-smart-socket-energy", Z2MSmartSocketEnergyNode);
666
+};
+22 -0
adapters/smart-socket/homebus-adapter/package.json
@@ -0,0 +1,22 @@
1
+{
2
+  "name": "node-red-contrib-z2m-smart-socket-homebus",
3
+  "version": "0.0.1",
4
+  "description": "Node-RED node that adapts Zigbee2MQTT smart socket messages to canonical HomeBus control topics and translates HomeBus power commands back to Zigbee2MQTT",
5
+  "main": "z2m-smart-socket-homebus.js",
6
+  "keywords": [
7
+    "node-red",
8
+    "zigbee2mqtt",
9
+    "homebus",
10
+    "smart-socket",
11
+    "smart-plug",
12
+    "power",
13
+    "outlet"
14
+  ],
15
+  "author": "",
16
+  "license": "MIT",
17
+  "node-red": {
18
+    "nodes": {
19
+      "z2m-smart-socket-homebus": "z2m-smart-socket-homebus.js"
20
+    }
21
+  }
22
+}
+72 -0
adapters/smart-socket/homebus-adapter/z2m-smart-socket-homebus.html
@@ -0,0 +1,72 @@
1
+<script type="text/x-red" data-template-name="z2m-smart-socket-homebus">
2
+  <div class="form-row">
3
+    <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
4
+    <input type="text" id="node-input-name" placeholder="Name">
5
+  </div>
6
+  <div class="form-row">
7
+    <label for="node-input-site"><i class="fa fa-globe"></i> Site</label>
8
+    <input type="text" id="node-input-site" placeholder="vad">
9
+  </div>
10
+</script>
11
+
12
+<script type="text/x-red" data-help-name="z2m-smart-socket-homebus">
13
+  <p>
14
+    Translates Zigbee2MQTT smart socket telemetry for <code>A1Z</code> and <code>S60ZBTPF</code> into canonical HomeBus control topics.
15
+  </p>
16
+  <p>
17
+    The adapter standardizes both models to the same HomeBus capability set, with <code>state</code> mapped to
18
+    <code>&lt;site&gt;/home/&lt;location&gt;/power/&lt;device_id&gt;/...</code>.
19
+  </p>
20
+  <p>
21
+    Supported canonical projections:
22
+    <code>power</code>,
23
+    <code>power_restore_mode</code>,
24
+    <code>countdown</code>,
25
+    <code>lock</code>,
26
+    <code>indicator_mode</code>,
27
+    <code>button_mode</code>,
28
+    <code>protection_enabled</code>.
29
+  </p>
30
+  <h3>Inputs</h3>
31
+  <p>
32
+    Raw Zigbee2MQTT telemetry:
33
+    <code>zigbee2mqtt/A1Z/&lt;site&gt;/&lt;location&gt;/&lt;device_id&gt;</code> or
34
+    <code>zigbee2mqtt/S60ZBTPF/&lt;site&gt;/&lt;location&gt;/&lt;device_id&gt;</code>
35
+  </p>
36
+  <p>
37
+    Raw availability is also supported on the sibling <code>/availability</code> topic.
38
+  </p>
39
+  <p>
40
+    Canonical HomeBus power commands are accepted on:
41
+    <code>&lt;site&gt;/home/&lt;location&gt;/power/&lt;device_id&gt;/set</code>
42
+    with payload <code>true</code>, <code>false</code>, <code>on</code>, <code>off</code>, or <code>toggle</code>.
43
+  </p>
44
+  <h3>Outputs</h3>
45
+  <ol>
46
+    <li>MQTT-ready HomeBus and <code>sys</code> publish messages.</li>
47
+    <li>MQTT-ready Zigbee2MQTT raw command publishes, for example <code>zigbee2mqtt/A1Z/vad/kitchen/coffee/set</code>.</li>
48
+    <li>Dynamic <code>mqtt in</code> control messages that subscribe to <code>zigbee2mqtt/A1Z/#</code> and <code>zigbee2mqtt/S60ZBTPF/#</code>.</li>
49
+  </ol>
50
+</script>
51
+
52
+<script>
53
+  RED.nodes.registerType("z2m-smart-socket-homebus", {
54
+    category: "myNodes",
55
+    color: "#d9ecfb",
56
+    defaults: {
57
+      name: { value: "" },
58
+      site: { value: "unknown" },
59
+      mqttSite: { value: "" },
60
+      mqttBus: { value: "" },
61
+      mqttRoom: { value: "" },
62
+      mqttSensor: { value: "" }
63
+    },
64
+    inputs: 1,
65
+    outputs: 3,
66
+    icon: "font-awesome/fa-plug",
67
+    label: function() {
68
+      return this.name || "z2m-smart-socket-homebus";
69
+    },
70
+    paletteLabel: "smart socket homebus"
71
+  });
72
+</script>
+816 -0
adapters/smart-socket/homebus-adapter/z2m-smart-socket-homebus.js
@@ -0,0 +1,816 @@
1
+module.exports = function(RED) {
2
+  function Z2MSmartSocketHomeBusNode(config) {
3
+    RED.nodes.createNode(this, config);
4
+    var node = this;
5
+
6
+    node.adapterId = "z2m-smart-socket-homebus";
7
+    node.site = normalizeToken(config.site || config.mqttSite || "");
8
+    node.sourceModels = ["A1Z", "S60ZBTPF"];
9
+    node.sourceTopics = node.sourceModels.map(function(model) { return "zigbee2mqtt/" + model + "/#"; });
10
+    node.subscriptionStarted = false;
11
+    node.startTimer = null;
12
+    node.publishCache = Object.create(null);
13
+    node.retainedCache = Object.create(null);
14
+    node.deviceRegistry = Object.create(null);
15
+    node.statsPublishEvery = 25;
16
+
17
+    node.stats = {
18
+      processed_inputs: 0,
19
+      raw_inputs: 0,
20
+      command_inputs: 0,
21
+      devices_detected: 0,
22
+      home_messages: 0,
23
+      last_messages: 0,
24
+      meta_messages: 0,
25
+      home_availability_messages: 0,
26
+      raw_command_messages: 0,
27
+      operational_messages: 0,
28
+      invalid_messages: 0,
29
+      invalid_topics: 0,
30
+      invalid_payloads: 0,
31
+      unmapped_messages: 0,
32
+      unknown_devices: 0,
33
+      errors: 0,
34
+      dlq: 0
35
+    };
36
+
37
+    var HOME_MAPPINGS = [
38
+      {
39
+        targetCapability: "power",
40
+        core: true,
41
+        writable: true,
42
+        payloadProfile: "scalar",
43
+        dataType: "boolean",
44
+        historianMode: "state",
45
+        historianEnabled: true,
46
+        read: function(payload) {
47
+          var value = asSwitchState(payload.state);
48
+          return value === null ? undefined : value;
49
+        }
50
+      },
51
+      {
52
+        targetCapability: "power_restore_mode",
53
+        core: true,
54
+        payloadProfile: "scalar",
55
+        dataType: "string",
56
+        historianMode: "state",
57
+        historianEnabled: true,
58
+        read: function(payload, model) {
59
+          var raw = null;
60
+          if (model === "A1Z") raw = asLowerEnum(payload.power_outage_memory);
61
+          else if (model === "S60ZBTPF") raw = asLowerEnum(payload.power_on_behavior);
62
+          if (!raw) return undefined;
63
+          if (raw === "restore") return "previous";
64
+          return raw;
65
+        }
66
+      },
67
+      {
68
+        models: ["A1Z"],
69
+        targetCapability: "countdown",
70
+        core: false,
71
+        payloadProfile: "scalar",
72
+        dataType: "number",
73
+        unit: "s",
74
+        precision: 1,
75
+        historianMode: "sample",
76
+        historianEnabled: true,
77
+        read: function(payload) {
78
+          var value = asNumber(payload.countdown);
79
+          return value === null ? undefined : value;
80
+        }
81
+      },
82
+      {
83
+        models: ["A1Z"],
84
+        targetCapability: "lock",
85
+        core: false,
86
+        payloadProfile: "scalar",
87
+        dataType: "boolean",
88
+        historianMode: "state",
89
+        historianEnabled: true,
90
+        read: function(payload) {
91
+          var value = asLockState(payload.child_lock);
92
+          return value === null ? undefined : value;
93
+        }
94
+      },
95
+      {
96
+        models: ["A1Z"],
97
+        targetCapability: "indicator_mode",
98
+        core: false,
99
+        payloadProfile: "scalar",
100
+        dataType: "string",
101
+        historianMode: "state",
102
+        historianEnabled: true,
103
+        read: function(payload) {
104
+          var value = asLowerEnum(payload.indicator_mode);
105
+          return value || undefined;
106
+        }
107
+      },
108
+      {
109
+        models: ["A1Z"],
110
+        targetCapability: "button_mode",
111
+        core: false,
112
+        payloadProfile: "scalar",
113
+        dataType: "string",
114
+        historianMode: "state",
115
+        historianEnabled: true,
116
+        read: function(payload) {
117
+          var value = asLowerEnum(payload.switch_type_button);
118
+          return value || undefined;
119
+        }
120
+      },
121
+      {
122
+        models: ["S60ZBTPF"],
123
+        targetCapability: "protection_enabled",
124
+        core: false,
125
+        payloadProfile: "scalar",
126
+        dataType: "boolean",
127
+        historianMode: "state",
128
+        historianEnabled: true,
129
+        read: function(payload) {
130
+          var value = asBool(payload.outlet_control_protect);
131
+          return value === null ? undefined : value;
132
+        }
133
+      }
134
+    ];
135
+
136
+    function normalizeToken(value) {
137
+      if (value === undefined || value === null) return "";
138
+      return String(value).trim();
139
+    }
140
+
141
+    function transliterate(value) {
142
+      var s = normalizeToken(value);
143
+      if (!s) return "";
144
+      if (typeof s.normalize === "function") {
145
+        s = s.normalize("NFKD").replace(/[\u0300-\u036f]/g, "");
146
+      }
147
+      return s;
148
+    }
149
+
150
+    function toKebabCase(value, fallback) {
151
+      var s = transliterate(value).toLowerCase().replace(/[^a-z0-9]+/g, "-");
152
+      s = s.replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-");
153
+      return s || fallback || "";
154
+    }
155
+
156
+    function humanizeCapability(capability) {
157
+      return String(capability || "").replace(/_/g, " ").replace(/\b[a-z]/g, function(ch) { return ch.toUpperCase(); });
158
+    }
159
+
160
+    function asBool(value) {
161
+      if (typeof value === "boolean") return value;
162
+      if (typeof value === "number") return value !== 0;
163
+      if (typeof value === "string") {
164
+        var v = value.trim().toLowerCase();
165
+        if (v === "true" || v === "1" || v === "on" || v === "yes" || v === "online" || v === "lock" || v === "locked") return true;
166
+        if (v === "false" || v === "0" || v === "off" || v === "no" || v === "offline" || v === "unlock" || v === "unlocked") return false;
167
+      }
168
+      return null;
169
+    }
170
+
171
+    function asNumber(value) {
172
+      if (typeof value === "number" && isFinite(value)) return value;
173
+      if (typeof value === "string") {
174
+        var trimmed = value.trim();
175
+        if (!trimmed) return null;
176
+        var parsed = Number(trimmed);
177
+        if (isFinite(parsed)) return parsed;
178
+      }
179
+      return null;
180
+    }
181
+
182
+    function asLowerEnum(value) {
183
+      var s = normalizeToken(value).toLowerCase();
184
+      return s || null;
185
+    }
186
+
187
+    function asSwitchState(value) {
188
+      if (typeof value === "boolean") return value;
189
+      if (typeof value === "number") return value !== 0;
190
+      if (typeof value === "string") {
191
+        var v = value.trim().toLowerCase();
192
+        if (v === "on" || v === "true" || v === "1") return true;
193
+        if (v === "off" || v === "false" || v === "0") return false;
194
+      }
195
+      return null;
196
+    }
197
+
198
+    function asLockState(value) {
199
+      if (typeof value === "string") {
200
+        var v = value.trim().toLowerCase();
201
+        if (v === "lock" || v === "locked") return true;
202
+        if (v === "unlock" || v === "unlocked") return false;
203
+      }
204
+      return asBool(value);
205
+    }
206
+
207
+    function toIsoTimestamp(value) {
208
+      if (value === undefined || value === null || value === "") return "";
209
+      var parsed = new Date(value);
210
+      if (isNaN(parsed.getTime())) return "";
211
+      return parsed.toISOString();
212
+    }
213
+
214
+    function resolveObservation(msg, payloadObject) {
215
+      var observedAt = "";
216
+      if (payloadObject && typeof payloadObject === "object") {
217
+        observedAt = toIsoTimestamp(payloadObject.observed_at)
218
+          || toIsoTimestamp(payloadObject.observedAt)
219
+          || toIsoTimestamp(payloadObject.timestamp)
220
+          || toIsoTimestamp(payloadObject.time)
221
+          || toIsoTimestamp(payloadObject.ts)
222
+          || toIsoTimestamp(payloadObject.last_seen);
223
+      }
224
+
225
+      if (!observedAt && msg && typeof msg === "object") {
226
+        observedAt = toIsoTimestamp(msg.observed_at)
227
+          || toIsoTimestamp(msg.observedAt)
228
+          || toIsoTimestamp(msg.timestamp)
229
+          || toIsoTimestamp(msg.time)
230
+          || toIsoTimestamp(msg.ts);
231
+      }
232
+
233
+      if (observedAt) {
234
+        return {
235
+          observedAt: observedAt,
236
+          quality: "good"
237
+        };
238
+      }
239
+
240
+      return {
241
+        observedAt: new Date().toISOString(),
242
+        quality: "estimated"
243
+      };
244
+    }
245
+
246
+    function supportsModel(mapping, model) {
247
+      if (!mapping.models || !mapping.models.length) return true;
248
+      return mapping.models.indexOf(model) >= 0;
249
+    }
250
+
251
+    function registryKey(site, location, deviceId) {
252
+      return [site || "", location || "", deviceId || ""].join("|");
253
+    }
254
+
255
+    function translatedMessageCount() {
256
+      return node.stats.home_messages
257
+        + node.stats.last_messages
258
+        + node.stats.meta_messages
259
+        + node.stats.home_availability_messages
260
+        + node.stats.raw_command_messages;
261
+    }
262
+
263
+    function makePublishMsg(topic, payload, retain) {
264
+      return {
265
+        topic: topic,
266
+        payload: payload,
267
+        qos: 1,
268
+        retain: !!retain
269
+      };
270
+    }
271
+
272
+    function makeSubscribeMsg(topic) {
273
+      return {
274
+        action: "subscribe",
275
+        topic: topic,
276
+        qos: 2,
277
+        rh: 0,
278
+        rap: true
279
+      };
280
+    }
281
+
282
+    function buildHomeTopic(identity, mapping, stream) {
283
+      return identity.site + "/home/" + identity.location + "/" + mapping.targetCapability + "/" + identity.deviceId + "/" + stream;
284
+    }
285
+
286
+    function buildSysTopic(site, stream) {
287
+      return site + "/sys/adapter/" + node.adapterId + "/" + stream;
288
+    }
289
+
290
+    function signature(value) {
291
+      return JSON.stringify(value);
292
+    }
293
+
294
+    function shouldPublishLive(cacheKey, payload) {
295
+      var sig = signature(payload);
296
+      if (node.publishCache[cacheKey] === sig) return false;
297
+      node.publishCache[cacheKey] = sig;
298
+      return true;
299
+    }
300
+
301
+    function shouldPublishRetained(cacheKey, payload) {
302
+      var sig = signature(payload);
303
+      if (node.retainedCache[cacheKey] === sig) return false;
304
+      node.retainedCache[cacheKey] = sig;
305
+      return true;
306
+    }
307
+
308
+    function buildLastPayload(value, observation) {
309
+      var payload = {
310
+        value: value,
311
+        observed_at: observation.observedAt
312
+      };
313
+      if (observation.quality && observation.quality !== "good") payload.quality = observation.quality;
314
+      return payload;
315
+    }
316
+
317
+    function buildMetaPayload(identity, mapping) {
318
+      var payload = {
319
+        schema_ref: "mqbus.home.v1",
320
+        payload_profile: mapping.payloadProfile || "scalar",
321
+        stream_payload_profiles: mapping.writable
322
+          ? { value: "scalar", last: "envelope", set: "scalar" }
323
+          : { value: "scalar", last: "envelope" },
324
+        data_type: mapping.dataType,
325
+        adapter_id: node.adapterId,
326
+        source: "zigbee2mqtt",
327
+        source_ref: identity.sourceRef,
328
+        source_topic: identity.sourceTopic,
329
+        display_name: identity.displayName + " " + humanizeCapability(mapping.targetCapability),
330
+        tags: ["zigbee2mqtt", "smart-socket", identity.model.toLowerCase(), "home"],
331
+        historian: {
332
+          enabled: !!mapping.historianEnabled,
333
+          mode: mapping.historianMode
334
+        }
335
+      };
336
+
337
+      if (mapping.unit) payload.unit = mapping.unit;
338
+      if (mapping.precision !== undefined) payload.precision = mapping.precision;
339
+      if (mapping.writable) {
340
+        payload.writable = true;
341
+        payload.set_topic = buildHomeTopic(identity, mapping, "set");
342
+      }
343
+
344
+      return payload;
345
+    }
346
+
347
+    function enqueueHomeMeta(messages, identity, mapping) {
348
+      var topic = buildHomeTopic(identity, mapping, "meta");
349
+      var payload = buildMetaPayload(identity, mapping);
350
+      if (!shouldPublishRetained("meta:" + topic, payload)) return;
351
+      messages.push(makePublishMsg(topic, payload, true));
352
+      noteMessage("meta_messages");
353
+    }
354
+
355
+    function enqueueHomeAvailability(messages, identity, mapping, online) {
356
+      var topic = buildHomeTopic(identity, mapping, "availability");
357
+      var payload = online ? "online" : "offline";
358
+      if (!shouldPublishRetained("availability:" + topic, payload)) return;
359
+      messages.push(makePublishMsg(topic, payload, true));
360
+      noteMessage("home_availability_messages");
361
+    }
362
+
363
+    function enqueueHomeLast(messages, identity, mapping, value, observation) {
364
+      var topic = buildHomeTopic(identity, mapping, "last");
365
+      var payload = buildLastPayload(value, observation);
366
+      if (!shouldPublishRetained("last:" + topic, payload)) return;
367
+      messages.push(makePublishMsg(topic, payload, true));
368
+      noteMessage("last_messages");
369
+    }
370
+
371
+    function enqueueHomeValue(messages, identity, mapping, value) {
372
+      var topic = buildHomeTopic(identity, mapping, "value");
373
+      if (!shouldPublishLive("value:" + topic, value)) return;
374
+      messages.push(makePublishMsg(topic, value, false));
375
+      noteMessage("home_messages");
376
+    }
377
+
378
+    function enqueueAdapterAvailability(messages, site, online) {
379
+      var topic = buildSysTopic(site, "availability");
380
+      var payload = online ? "online" : "offline";
381
+      if (!shouldPublishRetained("sys:availability:" + topic, payload)) return;
382
+      messages.push(makePublishMsg(topic, payload, true));
383
+      noteMessage("operational_messages");
384
+    }
385
+
386
+    function enqueueAdapterStats(messages, site, force) {
387
+      if (!force && node.stats.processed_inputs !== 1 && (node.stats.processed_inputs % node.statsPublishEvery) !== 0) return;
388
+      var topic = buildSysTopic(site, "stats");
389
+      var payload = {
390
+        processed_inputs: node.stats.processed_inputs,
391
+        raw_inputs: node.stats.raw_inputs,
392
+        command_inputs: node.stats.command_inputs,
393
+        devices_detected: node.stats.devices_detected,
394
+        translated_messages: translatedMessageCount(),
395
+        home_messages: node.stats.home_messages,
396
+        last_messages: node.stats.last_messages,
397
+        meta_messages: node.stats.meta_messages,
398
+        home_availability_messages: node.stats.home_availability_messages,
399
+        raw_command_messages: node.stats.raw_command_messages,
400
+        operational_messages: node.stats.operational_messages,
401
+        invalid_messages: node.stats.invalid_messages,
402
+        invalid_topics: node.stats.invalid_topics,
403
+        invalid_payloads: node.stats.invalid_payloads,
404
+        unmapped_messages: node.stats.unmapped_messages,
405
+        unknown_devices: node.stats.unknown_devices,
406
+        errors: node.stats.errors,
407
+        dlq: node.stats.dlq
408
+      };
409
+      messages.push(makePublishMsg(topic, payload, true));
410
+      noteMessage("operational_messages");
411
+    }
412
+
413
+    function enqueueError(messages, site, code, reason, sourceTopic) {
414
+      var payload = {
415
+        code: code,
416
+        reason: reason,
417
+        source_topic: normalizeToken(sourceTopic),
418
+        adapter_id: node.adapterId
419
+      };
420
+      messages.push(makePublishMsg(buildSysTopic(site, "error"), payload, false));
421
+      if (code === "invalid_message") noteMessage("invalid_messages");
422
+      else if (code === "invalid_topic" || code === "unsupported_set") noteMessage("invalid_topics");
423
+      else if (code === "payload_not_object" || code === "invalid_availability_payload" || code === "invalid_command_payload") noteMessage("invalid_payloads");
424
+      else if (code === "no_mapped_fields") noteMessage("unmapped_messages");
425
+      else if (code === "unknown_device") noteMessage("unknown_devices");
426
+      noteMessage("errors");
427
+      noteMessage("operational_messages");
428
+    }
429
+
430
+    function enqueueDlq(messages, site, code, sourceTopic, rawPayload) {
431
+      var payload = {
432
+        code: code,
433
+        source_topic: normalizeToken(sourceTopic),
434
+        payload: rawPayload
435
+      };
436
+      messages.push(makePublishMsg(buildSysTopic(site, "dlq"), payload, false));
437
+      noteMessage("dlq");
438
+      noteMessage("operational_messages");
439
+    }
440
+
441
+    function noteMessage(kind) {
442
+      if (Object.prototype.hasOwnProperty.call(node.stats, kind)) {
443
+        node.stats[kind] += 1;
444
+      }
445
+    }
446
+
447
+    function summarizeForLog(value) {
448
+      if (value === undefined) return "undefined";
449
+      if (value === null) return "null";
450
+      if (typeof value === "string") return value.length > 180 ? value.slice(0, 177) + "..." : value;
451
+      try {
452
+        var serialized = JSON.stringify(value);
453
+        return serialized.length > 180 ? serialized.slice(0, 177) + "..." : serialized;
454
+      } catch (err) {
455
+        return String(value);
456
+      }
457
+    }
458
+
459
+    function logIssue(level, code, reason, msg, rawPayload) {
460
+      var parts = ["[" + node.adapterId + "]", code + ":", reason];
461
+      if (msg && typeof msg === "object" && normalizeToken(msg.topic)) {
462
+        parts.push("topic=" + normalizeToken(msg.topic));
463
+      }
464
+      if (rawPayload !== undefined) {
465
+        parts.push("payload=" + summarizeForLog(rawPayload));
466
+      }
467
+      var text = parts.join(" ");
468
+      if (level === "error") {
469
+        node.error(text, msg);
470
+      } else {
471
+        node.warn(text);
472
+      }
473
+    }
474
+
475
+    function parseRawTopic(topic) {
476
+      if (typeof topic !== "string") return null;
477
+      var tokens = topic.split("/").map(function(token) { return token.trim(); }).filter(function(token) { return !!token; });
478
+      if (tokens.length !== 5 && tokens.length !== 6) return null;
479
+      if (tokens[0].toLowerCase() !== "zigbee2mqtt") return null;
480
+      if (node.sourceModels.indexOf(tokens[1]) < 0) return null;
481
+      if (tokens.length === 6 && tokens[5].toLowerCase() !== "availability") return null;
482
+      return {
483
+        model: tokens[1],
484
+        site: toKebabCase(tokens[2], node.site || "unknown"),
485
+        location: toKebabCase(tokens[3], "unknown"),
486
+        deviceId: toKebabCase(tokens[4], tokens[1].toLowerCase()),
487
+        sourceTopic: tokens.slice(0, 5).join("/"),
488
+        availabilityTopic: tokens.length === 6
489
+      };
490
+    }
491
+
492
+    function parseSetTopic(topic) {
493
+      if (typeof topic !== "string") return null;
494
+      var tokens = topic.split("/").map(function(token) { return token.trim(); }).filter(function(token) { return !!token; });
495
+      if (tokens.length !== 6) return null;
496
+      if (tokens[1] !== "home") return null;
497
+      if (tokens[3] !== "power" || tokens[5] !== "set") return null;
498
+      return {
499
+        site: toKebabCase(tokens[0], node.site || "unknown"),
500
+        location: toKebabCase(tokens[2], "unknown"),
501
+        capability: tokens[3],
502
+        deviceId: toKebabCase(tokens[4], "socket")
503
+      };
504
+    }
505
+
506
+    function buildIdentity(parsed) {
507
+      return {
508
+        model: parsed.model,
509
+        site: parsed.site || node.site || "unknown",
510
+        location: parsed.location || "unknown",
511
+        deviceId: parsed.deviceId || parsed.model.toLowerCase(),
512
+        sourceRef: parsed.sourceTopic,
513
+        sourceTopic: parsed.sourceTopic,
514
+        displayName: [parsed.location || "unknown", parsed.deviceId || parsed.model.toLowerCase()].join(" ")
515
+      };
516
+    }
517
+
518
+    function noteDevice(identity) {
519
+      var key = registryKey(identity.site, identity.location, identity.deviceId);
520
+      if (!Object.prototype.hasOwnProperty.call(node.deviceRegistry, key)) {
521
+        node.stats.devices_detected += 1;
522
+      }
523
+      node.deviceRegistry[key] = {
524
+        site: identity.site,
525
+        location: identity.location,
526
+        deviceId: identity.deviceId,
527
+        model: identity.model,
528
+        sourceTopic: identity.sourceTopic,
529
+        sourceRef: identity.sourceRef
530
+      };
531
+    }
532
+
533
+    function lookupDevice(parsedSet) {
534
+      return node.deviceRegistry[registryKey(parsedSet.site, parsedSet.location, parsedSet.deviceId)] || null;
535
+    }
536
+
537
+    function activeMappings(model, payloadObject) {
538
+      var result = [];
539
+      for (var i = 0; i < HOME_MAPPINGS.length; i++) {
540
+        var mapping = HOME_MAPPINGS[i];
541
+        if (!supportsModel(mapping, model)) continue;
542
+        var value = payloadObject ? mapping.read(payloadObject, model) : undefined;
543
+        result.push({
544
+          mapping: mapping,
545
+          hasValue: value !== undefined,
546
+          value: value
547
+        });
548
+      }
549
+      return result;
550
+    }
551
+
552
+    function resolveOnlineState(payloadObject, availabilityValue) {
553
+      if (availabilityValue !== null && availabilityValue !== undefined) return !!availabilityValue;
554
+      if (!payloadObject) return true;
555
+      if (typeof payloadObject.availability === "string") {
556
+        return payloadObject.availability.trim().toLowerCase() !== "offline";
557
+      }
558
+      if (typeof payloadObject.online === "boolean") {
559
+        return payloadObject.online;
560
+      }
561
+      return true;
562
+    }
563
+
564
+    function housekeepingOnly(payloadObject) {
565
+      if (!payloadObject || typeof payloadObject !== "object") return false;
566
+      var keys = Object.keys(payloadObject);
567
+      if (!keys.length) return true;
568
+      for (var i = 0; i < keys.length; i++) {
569
+        if (["last_seen", "linkquality", "update", "availability", "online"].indexOf(keys[i]) < 0) return false;
570
+      }
571
+      return true;
572
+    }
573
+
574
+    function parsePowerCommand(payload) {
575
+      var value = payload;
576
+      var sourceTopicHint = "";
577
+      if (value && typeof value === "object" && !Array.isArray(value)) {
578
+        if (typeof value.source_topic === "string" && value.source_topic.trim()) sourceTopicHint = value.source_topic.trim();
579
+        else if (typeof value.source_ref === "string" && value.source_ref.trim()) sourceTopicHint = value.source_ref.trim();
580
+        if (Object.prototype.hasOwnProperty.call(value, "value")) value = value.value;
581
+        else if (Object.prototype.hasOwnProperty.call(value, "power")) value = value.power;
582
+        else if (Object.prototype.hasOwnProperty.call(value, "state")) value = value.state;
583
+      }
584
+
585
+      if (typeof value === "string" && value.trim().toLowerCase() === "toggle") {
586
+        return {
587
+          kind: "toggle",
588
+          sourceTopicHint: sourceTopicHint
589
+        };
590
+      }
591
+
592
+      var boolValue = asSwitchState(value);
593
+      if (boolValue === null) return null;
594
+      return {
595
+        kind: "state",
596
+        value: boolValue,
597
+        sourceTopicHint: sourceTopicHint
598
+      };
599
+    }
600
+
601
+    function statusText(prefix) {
602
+      return [
603
+        prefix || (node.subscriptionStarted ? "live" : "idle"),
604
+        "in:" + node.stats.processed_inputs,
605
+        "home:" + node.stats.home_messages,
606
+        "set:" + node.stats.raw_command_messages,
607
+        "err:" + node.stats.errors
608
+      ].join(" ");
609
+    }
610
+
611
+    function updateNodeStatus(fill, shape, prefix) {
612
+      node.status({
613
+        fill: fill || (node.stats.errors ? "red" : "green"),
614
+        shape: shape || "dot",
615
+        text: statusText(prefix)
616
+      });
617
+    }
618
+
619
+    function flush(send, done, publishMessages, rawCommandMessages, controlMessages, error) {
620
+      send([
621
+        publishMessages.length ? publishMessages : null,
622
+        rawCommandMessages.length ? rawCommandMessages : null,
623
+        controlMessages.length ? controlMessages : null
624
+      ]);
625
+      if (done) done(error);
626
+    }
627
+
628
+    function startSubscriptions() {
629
+      if (node.subscriptionStarted) return;
630
+      node.subscriptionStarted = true;
631
+      var controls = node.sourceTopics.map(function(topic) { return makeSubscribeMsg(topic); });
632
+      node.send([null, null, controls]);
633
+      updateNodeStatus("yellow", "dot", "subscribed");
634
+    }
635
+
636
+    node.on("input", function(msg, send, done) {
637
+      send = send || function() { node.send.apply(node, arguments); };
638
+      node.stats.processed_inputs += 1;
639
+
640
+      try {
641
+        if (!msg || typeof msg !== "object") {
642
+          var invalidMessages = [];
643
+          var invalidSite = node.site || "unknown";
644
+          enqueueAdapterAvailability(invalidMessages, invalidSite, true);
645
+          enqueueError(invalidMessages, invalidSite, "invalid_message", "Input must be an object", "");
646
+          enqueueDlq(invalidMessages, invalidSite, "invalid_message", "", null);
647
+          enqueueAdapterStats(invalidMessages, invalidSite, true);
648
+          logIssue("warn", "invalid_message", "Input must be an object", null, msg);
649
+          updateNodeStatus("yellow", "ring", "bad msg");
650
+          flush(send, done, invalidMessages, [], []);
651
+          return;
652
+        }
653
+
654
+        var publishMessages = [];
655
+        var rawCommandMessages = [];
656
+        var controlMessages = [];
657
+        var setTopic = parseSetTopic(msg.topic);
658
+
659
+        if (setTopic) {
660
+          node.stats.command_inputs += 1;
661
+          enqueueAdapterAvailability(publishMessages, setTopic.site, true);
662
+
663
+          var command = parsePowerCommand(msg.payload);
664
+          if (!command) {
665
+            enqueueError(publishMessages, setTopic.site, "invalid_command_payload", "Power set payload must be boolean, on/off, or toggle", msg.topic);
666
+            enqueueDlq(publishMessages, setTopic.site, "invalid_command_payload", msg.topic, msg.payload);
667
+            enqueueAdapterStats(publishMessages, setTopic.site, true);
668
+            logIssue("warn", "invalid_command_payload", "Power set payload must be boolean, on/off, or toggle", msg, msg.payload);
669
+            updateNodeStatus("yellow", "ring", "bad set");
670
+            flush(send, done, publishMessages, rawCommandMessages, controlMessages);
671
+            return;
672
+          }
673
+
674
+          var registryEntry = lookupDevice(setTopic);
675
+          if (!registryEntry && command.sourceTopicHint) {
676
+            var hinted = parseRawTopic(command.sourceTopicHint);
677
+            if (hinted) {
678
+              registryEntry = {
679
+                site: hinted.site,
680
+                location: hinted.location,
681
+                deviceId: hinted.deviceId,
682
+                model: hinted.model,
683
+                sourceTopic: hinted.sourceTopic,
684
+                sourceRef: hinted.sourceTopic
685
+              };
686
+            }
687
+          }
688
+
689
+          if (!registryEntry) {
690
+            enqueueError(publishMessages, setTopic.site, "unknown_device", "No raw Zigbee2MQTT topic is known yet for this canonical power endpoint", msg.topic);
691
+            enqueueDlq(publishMessages, setTopic.site, "unknown_device", msg.topic, msg.payload);
692
+            enqueueAdapterStats(publishMessages, setTopic.site, true);
693
+            logIssue("warn", "unknown_device", "No raw Zigbee2MQTT topic is known yet for this canonical power endpoint", msg, msg.payload);
694
+            updateNodeStatus("yellow", "ring", "unknown device");
695
+            flush(send, done, publishMessages, rawCommandMessages, controlMessages);
696
+            return;
697
+          }
698
+
699
+          rawCommandMessages.push(makePublishMsg(
700
+            registryEntry.sourceTopic + "/set",
701
+            command.kind === "toggle" ? { state: "TOGGLE" } : { state: command.value ? "ON" : "OFF" },
702
+            false
703
+          ));
704
+          noteMessage("raw_command_messages");
705
+          enqueueAdapterStats(publishMessages, setTopic.site, false);
706
+          updateNodeStatus("green", "dot", "set->z2m");
707
+          flush(send, done, publishMessages, rawCommandMessages, controlMessages);
708
+          return;
709
+        }
710
+
711
+        var parsed = parseRawTopic(msg.topic);
712
+        if (!parsed) {
713
+          var unknownSite = node.site || "unknown";
714
+          enqueueAdapterAvailability(publishMessages, unknownSite, true);
715
+          enqueueError(publishMessages, unknownSite, "invalid_topic", "Unsupported topic for smart socket homebus adapter", msg.topic);
716
+          enqueueDlq(publishMessages, unknownSite, "invalid_topic", msg.topic, msg.payload);
717
+          enqueueAdapterStats(publishMessages, unknownSite, true);
718
+          logIssue("warn", "invalid_topic", "Unsupported topic for smart socket homebus adapter", msg, msg.payload);
719
+          updateNodeStatus("yellow", "ring", "bad topic");
720
+          flush(send, done, publishMessages, rawCommandMessages, controlMessages);
721
+          return;
722
+        }
723
+
724
+        node.stats.raw_inputs += 1;
725
+        var identity = buildIdentity(parsed);
726
+        noteDevice(identity);
727
+        enqueueAdapterAvailability(publishMessages, identity.site, true);
728
+
729
+        if (parsed.availabilityTopic) {
730
+          var availabilityValue = asBool(msg.payload);
731
+          if (availabilityValue === null) {
732
+            enqueueError(publishMessages, identity.site, "invalid_availability_payload", "Availability payload must be online/offline or boolean", msg.topic);
733
+            enqueueDlq(publishMessages, identity.site, "invalid_availability_payload", msg.topic, msg.payload);
734
+            enqueueAdapterStats(publishMessages, identity.site, true);
735
+            logIssue("warn", "invalid_availability_payload", "Availability payload must be online/offline or boolean", msg, msg.payload);
736
+            updateNodeStatus("yellow", "ring", "bad availability");
737
+            flush(send, done, publishMessages, rawCommandMessages, controlMessages);
738
+            return;
739
+          }
740
+
741
+          var availabilityMappings = activeMappings(identity.model, null);
742
+          for (var i = 0; i < availabilityMappings.length; i++) {
743
+            enqueueHomeMeta(publishMessages, identity, availabilityMappings[i].mapping);
744
+            enqueueHomeAvailability(publishMessages, identity, availabilityMappings[i].mapping, availabilityValue);
745
+          }
746
+
747
+          enqueueAdapterStats(publishMessages, identity.site, false);
748
+          updateNodeStatus(availabilityValue ? "green" : "yellow", availabilityValue ? "dot" : "ring", availabilityValue ? "live" : "offline");
749
+          flush(send, done, publishMessages, rawCommandMessages, controlMessages);
750
+          return;
751
+        }
752
+
753
+        var payloadObject = msg.payload && typeof msg.payload === "object" && !Array.isArray(msg.payload) ? msg.payload : null;
754
+        if (!payloadObject) {
755
+          enqueueError(publishMessages, identity.site, "payload_not_object", "Telemetry payload must be an object", msg.topic);
756
+          enqueueDlq(publishMessages, identity.site, "payload_not_object", msg.topic, msg.payload);
757
+          enqueueAdapterStats(publishMessages, identity.site, true);
758
+          logIssue("warn", "payload_not_object", "Telemetry payload must be an object", msg, msg.payload);
759
+          updateNodeStatus("yellow", "ring", "payload");
760
+          flush(send, done, publishMessages, rawCommandMessages, controlMessages);
761
+          return;
762
+        }
763
+
764
+        var observation = resolveObservation(msg, payloadObject);
765
+        var online = resolveOnlineState(payloadObject, null);
766
+        var mappings = activeMappings(identity.model, payloadObject);
767
+        var hasMappedValue = false;
768
+
769
+        for (var j = 0; j < mappings.length; j++) {
770
+          enqueueHomeMeta(publishMessages, identity, mappings[j].mapping);
771
+          enqueueHomeAvailability(publishMessages, identity, mappings[j].mapping, online);
772
+          if (mappings[j].hasValue) {
773
+            hasMappedValue = true;
774
+            enqueueHomeLast(publishMessages, identity, mappings[j].mapping, mappings[j].value, observation);
775
+            enqueueHomeValue(publishMessages, identity, mappings[j].mapping, mappings[j].value);
776
+          }
777
+        }
778
+
779
+        if (!hasMappedValue && !housekeepingOnly(payloadObject)) {
780
+          enqueueError(publishMessages, identity.site, "no_mapped_fields", "Payload did not contain any supported smart socket HomeBus fields", msg.topic);
781
+          enqueueDlq(publishMessages, identity.site, "no_mapped_fields", msg.topic, payloadObject);
782
+          logIssue("warn", "no_mapped_fields", "Payload did not contain any supported smart socket HomeBus fields", msg, payloadObject);
783
+        }
784
+
785
+        enqueueAdapterStats(publishMessages, identity.site, false);
786
+        if (!hasMappedValue && !housekeepingOnly(payloadObject)) {
787
+          updateNodeStatus("yellow", "ring", "unmapped");
788
+        } else {
789
+          updateNodeStatus(online ? "green" : "yellow", online ? "dot" : "ring", online ? "live" : "offline");
790
+        }
791
+        flush(send, done, publishMessages, rawCommandMessages, controlMessages);
792
+      } catch (err) {
793
+        var site = node.site || "unknown";
794
+        var errorMessages = [];
795
+        enqueueAdapterAvailability(errorMessages, site, true);
796
+        enqueueError(errorMessages, site, "adapter_exception", err.message, msg && msg.topic);
797
+        enqueueAdapterStats(errorMessages, site, true);
798
+        logIssue("error", "adapter_exception", err.message, msg, msg && msg.payload);
799
+        updateNodeStatus("red", "ring", "error");
800
+        flush(send, done, errorMessages, [], [], err);
801
+      }
802
+    });
803
+
804
+    node.on("close", function() {
805
+      if (node.startTimer) {
806
+        clearTimeout(node.startTimer);
807
+        node.startTimer = null;
808
+      }
809
+    });
810
+
811
+    node.startTimer = setTimeout(startSubscriptions, 250);
812
+    updateNodeStatus("grey", "ring", "waiting");
813
+  }
814
+
815
+  RED.nodes.registerType("z2m-smart-socket-homebus", Z2MSmartSocketHomeBusNode);
816
+};
+21 -0
adapters/smart-socket/homekit-adapter/package.json
@@ -0,0 +1,21 @@
1
+{
2
+  "name": "node-red-contrib-smart-socket-homekit-adapter",
3
+  "version": "0.0.1",
4
+  "description": "Node-RED node that consumes canonical HomeBus smart socket power streams and projects them to a HomeKit Outlet while publishing canonical power commands back to HomeBus",
5
+  "main": "smart-socket-homekit.js",
6
+  "keywords": [
7
+    "node-red",
8
+    "homekit",
9
+    "homebus",
10
+    "smart-socket",
11
+    "outlet",
12
+    "power"
13
+  ],
14
+  "author": "",
15
+  "license": "MIT",
16
+  "node-red": {
17
+    "nodes": {
18
+      "smart-socket-homekit-adapter": "smart-socket-homekit.js"
19
+    }
20
+  }
21
+}
+76 -0
adapters/smart-socket/homekit-adapter/smart-socket-homekit.html
@@ -0,0 +1,76 @@
1
+<script type="text/x-red" data-template-name="smart-socket-homekit-adapter">
2
+  <div class="form-row">
3
+    <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
4
+    <input type="text" id="node-input-name" placeholder="Name">
5
+  </div>
6
+  <div class="form-row">
7
+    <label for="node-input-site"><i class="fa fa-globe"></i> Site</label>
8
+    <input type="text" id="node-input-site" placeholder="vad">
9
+  </div>
10
+  <div class="form-row">
11
+    <label for="node-input-location"><i class="fa fa-home"></i> Location</label>
12
+    <input type="text" id="node-input-location" placeholder="living-room">
13
+  </div>
14
+  <div class="form-row">
15
+    <label for="node-input-accessory"><i class="fa fa-plug"></i> Accessory</label>
16
+    <input type="text" id="node-input-accessory" placeholder="tv">
17
+  </div>
18
+</script>
19
+
20
+<script type="text/x-red" data-help-name="smart-socket-homekit-adapter">
21
+  <p>
22
+    Consumes canonical HomeBus power telemetry for a single smart socket endpoint and projects it to a HomeKit <code>Outlet</code> service.
23
+  </p>
24
+  <p>
25
+    The node subscribes to:
26
+    <code>&lt;site&gt;/home/&lt;location&gt;/power/&lt;accessory&gt;/last</code>,
27
+    <code>.../meta</code>,
28
+    <code>.../value</code>, and
29
+    <code>.../availability</code>.
30
+  </p>
31
+  <p>
32
+    It also accepts HomeKit control messages from the paired <code>homekit-service</code> node and republishes them as canonical HomeBus power commands on:
33
+    <code>&lt;site&gt;/home/&lt;location&gt;/power/&lt;accessory&gt;/set</code>
34
+  </p>
35
+  <p>
36
+    Wire the first output of the <code>homekit-service</code> <code>Outlet</code> node back into this adapter input if you want HomeKit toggles to be translated into HomeBus commands.
37
+  </p>
38
+  <h3>Outputs</h3>
39
+  <ol>
40
+    <li>HomeKit Outlet payloads, for example <code>{ On: 1, OutletInUse: 1 }</code>.</li>
41
+    <li>MQTT-ready canonical HomeBus set publishes.</li>
42
+    <li>Dynamic <code>mqtt in</code> control messages for semantic MQTT bootstrap/live subscriptions.</li>
43
+  </ol>
44
+  <h3>HomeKit Service Setup</h3>
45
+  <p><code>Outlet</code></p>
46
+  <pre><code>{"OutletInUse":{}}</code></pre>
47
+</script>
48
+
49
+<script>
50
+  function requiredText(v) {
51
+    return !!(v && String(v).trim());
52
+  }
53
+
54
+  RED.nodes.registerType("smart-socket-homekit-adapter", {
55
+    category: "myNodes",
56
+    color: "#d7f0d1",
57
+    defaults: {
58
+      name: { value: "" },
59
+      outputTopic: { value: "" },
60
+      mqttBus: { value: "" },
61
+      site: { value: "", validate: requiredText },
62
+      location: { value: "", validate: requiredText },
63
+      accessory: { value: "", validate: requiredText },
64
+      mqttSite: { value: "" },
65
+      mqttRoom: { value: "" },
66
+      mqttSensor: { value: "" }
67
+    },
68
+    inputs: 1,
69
+    outputs: 3,
70
+    icon: "font-awesome/fa-plug",
71
+    label: function() {
72
+      return this.name || "smart-socket-homekit-adapter";
73
+    },
74
+    paletteLabel: "smart socket homekit"
75
+  });
76
+</script>
+368 -0
adapters/smart-socket/homekit-adapter/smart-socket-homekit.js
@@ -0,0 +1,368 @@
1
+module.exports = function(RED) {
2
+  function SmartSocketHomeKitNode(config) {
3
+    RED.nodes.createNode(this, config);
4
+    var node = this;
5
+
6
+    node.site = normalizeToken(config.site || config.mqttSite || "");
7
+    node.location = normalizeToken(config.location || config.mqttRoom || "");
8
+    node.accessory = normalizeToken(config.accessory || config.mqttSensor || "");
9
+    node.bootstrapDeadlineMs = 10000;
10
+    node.hkCache = Object.create(null);
11
+    node.startTimer = null;
12
+    node.bootstrapTimer = null;
13
+    node.lastMsgContext = null;
14
+
15
+    node.stats = {
16
+      controls: 0,
17
+      last_inputs: 0,
18
+      meta_inputs: 0,
19
+      value_inputs: 0,
20
+      availability_inputs: 0,
21
+      command_outputs: 0,
22
+      hk_updates: 0,
23
+      errors: 0
24
+    };
25
+
26
+    node.subscriptionState = {
27
+      started: false,
28
+      lastSubscribed: false,
29
+      metaSubscribed: false,
30
+      valueSubscribed: false,
31
+      availabilitySubscribed: false
32
+    };
33
+
34
+    node.bootstrapState = {
35
+      finalized: false,
36
+      power: false
37
+    };
38
+
39
+    node.outletState = {
40
+      active: false,
41
+      site: node.site || "",
42
+      location: node.location || "",
43
+      deviceId: node.accessory || "",
44
+      sourceTopic: "",
45
+      sourceRef: "",
46
+      powerKnown: false,
47
+      power: false
48
+    };
49
+
50
+    function normalizeToken(value) {
51
+      if (value === undefined || value === null) return "";
52
+      return String(value).trim();
53
+    }
54
+
55
+    function asBool(value) {
56
+      if (typeof value === "boolean") return value;
57
+      if (typeof value === "number") return value !== 0;
58
+      if (typeof value === "string") {
59
+        var v = value.trim().toLowerCase();
60
+        if (v === "true" || v === "1" || v === "on" || v === "yes" || v === "online") return true;
61
+        if (v === "false" || v === "0" || v === "off" || v === "no" || v === "offline") return false;
62
+      }
63
+      return null;
64
+    }
65
+
66
+    function signature(value) {
67
+      return JSON.stringify(value);
68
+    }
69
+
70
+    function shouldPublish(cacheKey, payload) {
71
+      var sig = signature(payload);
72
+      if (node.hkCache[cacheKey] === sig) return false;
73
+      node.hkCache[cacheKey] = sig;
74
+      return true;
75
+    }
76
+
77
+    function cloneBaseMsg(msg) {
78
+      if (!msg || typeof msg !== "object") return {};
79
+      var out = {};
80
+      if (typeof msg.topic === "string") out.topic = msg.topic;
81
+      if (msg._msgid) out._msgid = msg._msgid;
82
+      return out;
83
+    }
84
+
85
+    function clearBootstrapTimer() {
86
+      if (!node.bootstrapTimer) return;
87
+      clearTimeout(node.bootstrapTimer);
88
+      node.bootstrapTimer = null;
89
+    }
90
+
91
+    function makeHomeKitMsg(baseMsg, payload) {
92
+      var out = RED.util.cloneMessage(baseMsg || {});
93
+      out.payload = payload;
94
+      return out;
95
+    }
96
+
97
+    function makeSetMsg(baseMsg, payload) {
98
+      var out = RED.util.cloneMessage(baseMsg || {});
99
+      out.topic = buildSetTopic();
100
+      if (node.outletState.sourceTopic || node.outletState.sourceRef) {
101
+        out.payload = {
102
+          value: payload
103
+        };
104
+        if (node.outletState.sourceTopic) out.payload.source_topic = node.outletState.sourceTopic;
105
+        if (node.outletState.sourceRef) out.payload.source_ref = node.outletState.sourceRef;
106
+      } else {
107
+        out.payload = payload;
108
+      }
109
+      out.qos = 1;
110
+      out.retain = false;
111
+      return out;
112
+    }
113
+
114
+    function buildSubscriptionTopic(stream) {
115
+      return [node.site, "home", node.location, "power", node.accessory, stream].join("/");
116
+    }
117
+
118
+    function buildSetTopic() {
119
+      return [node.site, "home", node.location, "power", node.accessory, "set"].join("/");
120
+    }
121
+
122
+    function buildSubscribeMsgs() {
123
+      return [
124
+        { action: "subscribe", topic: buildSubscriptionTopic("last"), qos: 2, rh: 0, rap: true },
125
+        { action: "subscribe", topic: buildSubscriptionTopic("meta"), qos: 2, rh: 0, rap: true },
126
+        { action: "subscribe", topic: buildSubscriptionTopic("value"), qos: 2, rh: 0, rap: true },
127
+        { action: "subscribe", topic: buildSubscriptionTopic("availability"), qos: 2, rh: 0, rap: true }
128
+      ];
129
+    }
130
+
131
+    function buildUnsubscribeLastMsg(reason) {
132
+      return {
133
+        action: "unsubscribe",
134
+        topic: buildSubscriptionTopic("last"),
135
+        reason: reason
136
+      };
137
+    }
138
+
139
+    function statusText(prefix) {
140
+      var state = node.subscriptionState.lastSubscribed ? "cold" : (node.subscriptionState.started ? "live" : "idle");
141
+      var device = node.outletState.deviceId || node.accessory || "?";
142
+      return [
143
+        prefix || state,
144
+        device,
145
+        "l:" + node.stats.last_inputs,
146
+        "m:" + node.stats.meta_inputs,
147
+        "v:" + node.stats.value_inputs,
148
+        "a:" + node.stats.availability_inputs,
149
+        "set:" + node.stats.command_outputs,
150
+        "hk:" + node.stats.hk_updates
151
+      ].join(" ");
152
+    }
153
+
154
+    function setNodeStatus(prefix, fill, shape) {
155
+      node.status({
156
+        fill: fill || (node.stats.errors ? "red" : (node.subscriptionState.lastSubscribed ? "yellow" : (node.outletState.active ? "green" : "yellow"))),
157
+        shape: shape || "dot",
158
+        text: statusText(prefix)
159
+      });
160
+    }
161
+
162
+    function noteError(text, msg) {
163
+      node.stats.errors += 1;
164
+      node.warn(text);
165
+      node.status({ fill: "red", shape: "ring", text: text });
166
+      if (msg) node.debug(msg);
167
+    }
168
+
169
+    function buildOutletMsg(baseMsg) {
170
+      if (!node.outletState.powerKnown) return null;
171
+      var payload = {
172
+        On: node.outletState.power ? 1 : 0,
173
+        OutletInUse: (node.outletState.active && node.outletState.power) ? 1 : 0
174
+      };
175
+      if (!shouldPublish("hk:outlet", payload)) return null;
176
+      node.stats.hk_updates += 1;
177
+      return makeHomeKitMsg(baseMsg, payload);
178
+    }
179
+
180
+    function clearSnapshotCache() {
181
+      delete node.hkCache["hk:outlet"];
182
+    }
183
+
184
+    function buildBootstrapOutputs(baseMsg) {
185
+      clearSnapshotCache();
186
+      return [buildOutletMsg(baseMsg)];
187
+    }
188
+
189
+    function unsubscribeLast(reason) {
190
+      if (!node.subscriptionState.lastSubscribed) return null;
191
+      node.subscriptionState.lastSubscribed = false;
192
+      node.stats.controls += 1;
193
+      return buildUnsubscribeLastMsg(reason);
194
+    }
195
+
196
+    function parseTopic(topic) {
197
+      if (typeof topic !== "string") return null;
198
+      var tokens = topic.split("/").map(function(token) { return token.trim(); }).filter(function(token) { return !!token; });
199
+      if (tokens.length !== 6) return null;
200
+      if (tokens[1] !== "home") return null;
201
+      if (tokens[3] !== "power") return { ignored: true };
202
+      if (tokens[5] !== "value" && tokens[5] !== "last" && tokens[5] !== "meta" && tokens[5] !== "availability") return { ignored: true };
203
+      if ((node.site && tokens[0] !== node.site) || (node.location && tokens[2] !== node.location) || (node.accessory && tokens[4] !== node.accessory)) return { ignored: true };
204
+      return {
205
+        site: tokens[0],
206
+        location: tokens[2],
207
+        capability: tokens[3],
208
+        deviceId: tokens[4],
209
+        stream: tokens[5]
210
+      };
211
+    }
212
+
213
+    function extractValue(stream, payload) {
214
+      if (stream === "last" && payload && typeof payload === "object" && !Array.isArray(payload) && Object.prototype.hasOwnProperty.call(payload, "value")) {
215
+        return payload.value;
216
+      }
217
+      return payload;
218
+    }
219
+
220
+    function processAvailability(baseMsg, value) {
221
+      var active = asBool(value);
222
+      if (active === null) return null;
223
+      node.outletState.active = active;
224
+      node.lastMsgContext = cloneBaseMsg(baseMsg);
225
+      return buildOutletMsg(baseMsg);
226
+    }
227
+
228
+    function processMeta(baseMsg, parsed, value) {
229
+      if (!value || typeof value !== "object" || Array.isArray(value)) return null;
230
+      node.outletState.site = parsed.site;
231
+      node.outletState.location = parsed.location;
232
+      node.outletState.deviceId = parsed.deviceId;
233
+      if (typeof value.source_topic === "string" && value.source_topic.trim()) {
234
+        node.outletState.sourceTopic = value.source_topic.trim();
235
+      }
236
+      if (typeof value.source_ref === "string" && value.source_ref.trim()) {
237
+        node.outletState.sourceRef = value.source_ref.trim();
238
+      }
239
+      node.lastMsgContext = cloneBaseMsg(baseMsg);
240
+      return null;
241
+    }
242
+
243
+    function processPower(baseMsg, parsed, value) {
244
+      var power = asBool(value);
245
+      if (power === null) return null;
246
+      node.outletState.active = true;
247
+      node.outletState.site = parsed.site;
248
+      node.outletState.location = parsed.location;
249
+      node.outletState.deviceId = parsed.deviceId;
250
+      node.outletState.powerKnown = true;
251
+      node.outletState.power = power;
252
+      node.lastMsgContext = cloneBaseMsg(baseMsg);
253
+      return buildOutletMsg(baseMsg);
254
+    }
255
+
256
+    function finalizeBootstrap(reason, send) {
257
+      if (node.bootstrapState.finalized) return false;
258
+      if (!node.subscriptionState.lastSubscribed) return false;
259
+      node.bootstrapState.finalized = true;
260
+      clearBootstrapTimer();
261
+      send = send || function(msgs) { node.send(msgs); };
262
+      var outputs = buildBootstrapOutputs(cloneBaseMsg(node.lastMsgContext));
263
+      var controlMsg = unsubscribeLast(reason);
264
+      send([outputs[0], null, controlMsg]);
265
+      setNodeStatus("live");
266
+      return true;
267
+    }
268
+
269
+    function startSubscriptions() {
270
+      if (node.subscriptionState.started) return;
271
+      if (!node.site || !node.location || !node.accessory) {
272
+        noteError("missing site, location or accessory");
273
+        return;
274
+      }
275
+      node.subscriptionState.started = true;
276
+      node.subscriptionState.lastSubscribed = true;
277
+      node.subscriptionState.metaSubscribed = true;
278
+      node.subscriptionState.valueSubscribed = true;
279
+      node.subscriptionState.availabilitySubscribed = true;
280
+      clearBootstrapTimer();
281
+      node.bootstrapTimer = setTimeout(function() {
282
+        finalizeBootstrap("bootstrap-timeout");
283
+      }, node.bootstrapDeadlineMs);
284
+      node.stats.controls += 1;
285
+      node.send([null, null, buildSubscribeMsgs()]);
286
+      setNodeStatus("cold");
287
+    }
288
+
289
+    function translateHomeKitCommand(msg) {
290
+      var payload = msg && msg.payload;
291
+      if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
292
+      if (!Object.prototype.hasOwnProperty.call(payload, "On")) return null;
293
+      var desired = asBool(payload.On);
294
+      if (desired === null) return null;
295
+      node.stats.command_outputs += 1;
296
+      return makeSetMsg(cloneBaseMsg(msg), desired);
297
+    }
298
+
299
+    node.on("input", function(msg, send, done) {
300
+      send = send || function() { node.send.apply(node, arguments); };
301
+      try {
302
+        var commandMsg = translateHomeKitCommand(msg);
303
+        if (commandMsg) {
304
+          send([null, commandMsg, null]);
305
+          setNodeStatus("set");
306
+          if (done) done();
307
+          return;
308
+        }
309
+
310
+        var parsed = parseTopic(msg && msg.topic);
311
+        if (!parsed) {
312
+          noteError("invalid topic", msg);
313
+          if (done) done();
314
+          return;
315
+        }
316
+        if (parsed.ignored) {
317
+          if (done) done();
318
+          return;
319
+        }
320
+
321
+        var value = extractValue(parsed.stream, msg.payload);
322
+        var output = null;
323
+
324
+        if (parsed.stream === "last") {
325
+          node.stats.last_inputs += 1;
326
+          output = processPower(msg, parsed, value);
327
+          node.bootstrapState.power = node.outletState.powerKnown;
328
+        } else if (parsed.stream === "meta") {
329
+          node.stats.meta_inputs += 1;
330
+          output = processMeta(msg, parsed, value);
331
+        } else if (parsed.stream === "value") {
332
+          node.stats.value_inputs += 1;
333
+          output = processPower(msg, parsed, value);
334
+          node.bootstrapState.power = node.outletState.powerKnown;
335
+        } else {
336
+          node.stats.availability_inputs += 1;
337
+          output = processAvailability(msg, value);
338
+        }
339
+
340
+        if (node.subscriptionState.lastSubscribed) {
341
+          setNodeStatus("cold");
342
+        } else {
343
+          send([output, null, null]);
344
+          setNodeStatus();
345
+        }
346
+
347
+        if (done) done();
348
+      } catch (err) {
349
+        noteError(err.message, msg);
350
+        if (done) done(err);
351
+        else node.error(err, msg);
352
+      }
353
+    });
354
+
355
+    node.on("close", function() {
356
+      clearBootstrapTimer();
357
+      if (node.startTimer) {
358
+        clearTimeout(node.startTimer);
359
+        node.startTimer = null;
360
+      }
361
+    });
362
+
363
+    node.startTimer = setTimeout(startSubscriptions, 250);
364
+    node.status({ fill: "grey", shape: "ring", text: "starting" });
365
+  }
366
+
367
+  RED.nodes.registerType("smart-socket-homekit-adapter", SmartSocketHomeKitNode);
368
+};
+21 -0
adapters/smoke-detector/homebus-adapter/package.json
@@ -0,0 +1,21 @@
1
+{
2
+  "name": "node-red-contrib-z2m-pa-44z-homebus",
3
+  "version": "0.0.1",
4
+  "description": "Node-RED node that adapts Zigbee2MQTT Tuya PA-44Z smoke detector messages to canonical HomeBus and adapter operational topics",
5
+  "main": "z2m-pa-44z-homebus.js",
6
+  "keywords": [
7
+    "node-red",
8
+    "zigbee2mqtt",
9
+    "homebus",
10
+    "smoke",
11
+    "battery",
12
+    "fault"
13
+  ],
14
+  "author": "",
15
+  "license": "MIT",
16
+  "node-red": {
17
+    "nodes": {
18
+      "z2m-pa-44z-homebus": "z2m-pa-44z-homebus.js"
19
+    }
20
+  }
21
+}
+103 -0
adapters/smoke-detector/homebus-adapter/z2m-pa-44z-homebus.html
@@ -0,0 +1,103 @@
1
+<script type="text/x-red" data-template-name="z2m-pa-44z-homebus">
2
+  <div class="form-row">
3
+    <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
4
+    <input type="text" id="node-input-name" placeholder="Name">
5
+  </div>
6
+  <div class="form-row">
7
+    <label for="node-input-site"><i class="fa fa-globe"></i> Site</label>
8
+    <input type="text" id="node-input-site" placeholder="vad">
9
+  </div>
10
+  <div class="form-row">
11
+    <label for="node-input-batteryType">Battery type</label>
12
+    <select id="node-input-batteryType">
13
+      <option value="alkaline">Alkaline (Recommended)</option>
14
+      <option value="nimh">Rechargeable NiMH</option>
15
+    </select>
16
+  </div>
17
+  <div class="form-row">
18
+    <label for="node-input-batteryLowThreshold">Battery low threshold (%)</label>
19
+    <input type="number" id="node-input-batteryLowThreshold" min="0" max="100" placeholder="20">
20
+  </div>
21
+</script>
22
+
23
+<script type="text/x-red" data-help-name="z2m-pa-44z-homebus">
24
+  <p>
25
+    Translates Zigbee2MQTT messages for Tuya <code>PA-44Z</code> smoke detectors into canonical HomeBus topics.
26
+  </p>
27
+  <p>
28
+    Canonical topic shape:
29
+    <code>&lt;site&gt;/home/&lt;location&gt;/&lt;capability&gt;/&lt;device_id&gt;/&lt;stream&gt;</code>
30
+  </p>
31
+  <p>
32
+    Example outputs:
33
+    <code>vad/home/kitchen/smoke/smoke-sensor/value</code>,
34
+    <code>vad/home/kitchen/battery/smoke-sensor/last</code>,
35
+    <code>vad/home/kitchen/device_fault/smoke-sensor/meta</code>
36
+  </p>
37
+  <h3>Input</h3>
38
+  <p>
39
+    Expected Zigbee2MQTT telemetry topic:
40
+    <code>zigbee2mqtt/PA-44Z/&lt;site&gt;/&lt;location&gt;/&lt;device_id&gt;</code> with a JSON payload.
41
+  </p>
42
+  <p>
43
+    Availability topic is also supported:
44
+    <code>zigbee2mqtt/PA-44Z/&lt;site&gt;/&lt;location&gt;/&lt;device_id&gt;/availability</code> with payload <code>online</code> or <code>offline</code>.
45
+  </p>
46
+  <p>
47
+    Typical subscription for this adapter:
48
+    <code>zigbee2mqtt/PA-44Z/#</code>
49
+  </p>
50
+  <p>
51
+    Output 2 controls a dynamic <code>mqtt in</code> node on the raw Zigbee2MQTT broker. On startup, the adapter emits
52
+    <code>{ action: "subscribe", topic: "zigbee2mqtt/PA-44Z/#" }</code>.
53
+  </p>
54
+  <p>
55
+    Used fields:
56
+    <code>smoke</code>, <code>battery</code>, <code>device_fault</code>, <code>silence</code>,
57
+    <code>test</code>, <code>smoke_concentration</code>, <code>availability</code>.
58
+  </p>
59
+  <p>
60
+    The adapter translates the reported battery for AAA rechargeable cells when <code>Battery type</code> is set to
61
+    <code>Rechargeable NiMH</code>. The remap approximates an alkaline percentage to voltage and then maps that voltage
62
+    onto a flatter NiMH discharge curve. Default behavior assumes alkaline cells.
63
+  </p>
64
+  <h3>Output</h3>
65
+  <ol>
66
+    <li>MQTT-ready publish messages, emitted as an array of messages on the semantic/output path.</li>
67
+    <li><code>mqtt in</code> control messages for the raw Zigbee2MQTT subscription.</li>
68
+  </ol>
69
+  <p>
70
+    Mapping:
71
+    <code>smoke -&gt; smoke/value</code>,
72
+    <code>battery -&gt; battery/value</code>,
73
+    <code>battery_low -&gt; battery_low/value</code>,
74
+    <code>device_fault -&gt; device_fault/value</code>,
75
+    <code>silence -&gt; silence/value</code>,
76
+    <code>test -&gt; test/value</code>,
77
+    <code>smoke_concentration -&gt; smoke_concentration/value</code>.
78
+  </p>
79
+</script>
80
+
81
+<script>
82
+  RED.nodes.registerType("z2m-pa-44z-homebus", {
83
+    category: "myNodes",
84
+    color: "#d9ecfb",
85
+    defaults: {
86
+      name: { value: "" },
87
+      site: { value: "unknown" },
88
+      batteryType: { value: "alkaline" },
89
+      batteryLowThreshold: { value: 20, validate: RED.validators.number() },
90
+      mqttSite: { value: "" },
91
+      mqttBus: { value: "" },
92
+      mqttRoom: { value: "" },
93
+      mqttSensor: { value: "" }
94
+    },
95
+    inputs: 1,
96
+    outputs: 2,
97
+    icon: "font-awesome/fa-sitemap",
98
+    label: function() {
99
+      return this.name || "z2m-pa-44z-homebus";
100
+    },
101
+    paletteLabel: "pa-44z homebus"
102
+  });
103
+</script>
+340 -0
adapters/smoke-detector/homebus-adapter/z2m-pa-44z-homebus.js
@@ -0,0 +1,340 @@
1
+module.exports = function(RED) {
2
+  function PA44ZHomeBusNode(config) {
3
+    RED.nodes.createNode(this, config);
4
+    var node = this;
5
+
6
+    node.adapterId = "z2m-pa-44z";
7
+    node.sourceTopic = "zigbee2mqtt/PA-44Z/#";
8
+    node.site = normalizeToken(config.site || config.mqttSite || "unknown");
9
+    node.batteryType = normalizeToken(config.batteryType || "alkaline").toLowerCase() || "alkaline";
10
+    node.batteryLowThreshold = parseNumber(config.batteryLowThreshold, 20, 0);
11
+    node.publishCache = Object.create(null);
12
+    node.startTimer = null;
13
+    node.subscriptionStarted = false;
14
+
15
+    node.stats = {
16
+      processed: 0,
17
+      published: 0,
18
+      invalid: 0,
19
+      errors: 0
20
+    };
21
+
22
+    function normalizeToken(value) {
23
+      if (value === undefined || value === null) return "";
24
+      return String(value).trim();
25
+    }
26
+
27
+    function parseNumber(value, fallback, min) {
28
+      var n = Number(value);
29
+      if (!Number.isFinite(n)) return fallback;
30
+      if (typeof min === "number" && n < min) return fallback;
31
+      return n;
32
+    }
33
+
34
+    function transliterate(value) {
35
+      var s = normalizeToken(value);
36
+      if (!s) return "";
37
+      if (typeof s.normalize === "function") {
38
+        s = s.normalize("NFKD").replace(/[\u0300-\u036f]/g, "");
39
+      }
40
+      return s;
41
+    }
42
+
43
+    function toKebabCase(value, fallback) {
44
+      var s = transliterate(value).toLowerCase().replace(/[^a-z0-9]+/g, "-");
45
+      s = s.replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-");
46
+      return s || fallback || "";
47
+    }
48
+
49
+    function clamp(n, min, max) {
50
+      return Math.max(min, Math.min(max, n));
51
+    }
52
+
53
+    var ALKALINE_BATTERY_CURVE = [
54
+      { pct: 100, voltage: 1.60 },
55
+      { pct: 90, voltage: 1.55 },
56
+      { pct: 80, voltage: 1.50 },
57
+      { pct: 70, voltage: 1.46 },
58
+      { pct: 60, voltage: 1.42 },
59
+      { pct: 50, voltage: 1.36 },
60
+      { pct: 40, voltage: 1.30 },
61
+      { pct: 30, voltage: 1.25 },
62
+      { pct: 20, voltage: 1.20 },
63
+      { pct: 10, voltage: 1.10 },
64
+      { pct: 0, voltage: 0.90 }
65
+    ];
66
+
67
+    var NIMH_BATTERY_CURVE = [
68
+      { pct: 100, voltage: 1.40 },
69
+      { pct: 95, voltage: 1.33 },
70
+      { pct: 90, voltage: 1.28 },
71
+      { pct: 85, voltage: 1.24 },
72
+      { pct: 80, voltage: 1.20 },
73
+      { pct: 75, voltage: 1.19 },
74
+      { pct: 70, voltage: 1.18 },
75
+      { pct: 60, voltage: 1.17 },
76
+      { pct: 50, voltage: 1.16 },
77
+      { pct: 40, voltage: 1.15 },
78
+      { pct: 30, voltage: 1.13 },
79
+      { pct: 20, voltage: 1.11 },
80
+      { pct: 10, voltage: 1.07 },
81
+      { pct: 0, voltage: 1.00 }
82
+    ];
83
+
84
+    function interpolateCurve(points, inputKey, outputKey, inputValue) {
85
+      if (!Array.isArray(points) || points.length === 0) return null;
86
+      if (inputValue >= points[0][inputKey]) return points[0][outputKey];
87
+
88
+      for (var i = 1; i < points.length; i++) {
89
+        var upper = points[i - 1];
90
+        var lower = points[i];
91
+        var upperInput = upper[inputKey];
92
+        var lowerInput = lower[inputKey];
93
+
94
+        if (inputValue >= lowerInput) {
95
+          var range = upperInput - lowerInput;
96
+          if (range <= 0) return lower[outputKey];
97
+          var ratio = (inputValue - lowerInput) / range;
98
+          return lower[outputKey] + ratio * (upper[outputKey] - lower[outputKey]);
99
+        }
100
+      }
101
+
102
+      return points[points.length - 1][outputKey];
103
+    }
104
+
105
+    function asBool(value) {
106
+      if (typeof value === "boolean") return value;
107
+      if (typeof value === "number") return value !== 0;
108
+      if (typeof value === "string") {
109
+        var v = value.trim().toLowerCase();
110
+        if (v === "true" || v === "1" || v === "on" || v === "yes" || v === "online") return true;
111
+        if (v === "false" || v === "0" || v === "off" || v === "no" || v === "offline") return false;
112
+      }
113
+      return null;
114
+    }
115
+
116
+    function asNumber(value) {
117
+      if (typeof value === "number" && isFinite(value)) return value;
118
+      if (typeof value === "string") {
119
+        var trimmed = value.trim();
120
+        if (!trimmed) return null;
121
+        var parsed = Number(trimmed);
122
+        if (isFinite(parsed)) return parsed;
123
+      }
124
+      return null;
125
+    }
126
+
127
+    function toIsoTimestamp(value) {
128
+      if (value === undefined || value === null) return "";
129
+      if (typeof value === "string" && value.trim()) {
130
+        var fromString = new Date(value);
131
+        if (!isNaN(fromString.getTime())) return fromString.toISOString();
132
+      }
133
+      return new Date().toISOString();
134
+    }
135
+
136
+    function translateBatteryLevel(rawValue) {
137
+      if (rawValue === null || rawValue === undefined) return null;
138
+      var raw = clamp(Math.round(Number(rawValue)), 0, 100);
139
+      if (node.batteryType !== "nimh") return raw;
140
+
141
+      // Reinterpret the reported percentage on an alkaline discharge curve,
142
+      // then project the equivalent cell voltage onto a flatter NiMH curve.
143
+      var estimatedVoltage = interpolateCurve(ALKALINE_BATTERY_CURVE, "pct", "voltage", raw);
144
+      var nimhPct = interpolateCurve(NIMH_BATTERY_CURVE, "voltage", "pct", estimatedVoltage);
145
+      return clamp(Math.round(nimhPct), 0, 100);
146
+    }
147
+
148
+    function signature(value) {
149
+      return JSON.stringify(value);
150
+    }
151
+
152
+    function shouldPublish(cacheKey, payload) {
153
+      var sig = signature(payload);
154
+      if (node.publishCache[cacheKey] === sig) return false;
155
+      node.publishCache[cacheKey] = sig;
156
+      return true;
157
+    }
158
+
159
+    function makePublish(topic, payload, retain) {
160
+      return {
161
+        topic: topic,
162
+        payload: payload,
163
+        qos: 2,
164
+        retain: !!retain
165
+      };
166
+    }
167
+
168
+    function setStatus(prefix, fill) {
169
+      node.status({
170
+        fill: fill || (node.stats.errors ? "red" : "green"),
171
+        shape: "dot",
172
+        text: [prefix || "live", "in:" + node.stats.processed, "out:" + node.stats.published, "err:" + node.stats.errors].join(" ")
173
+      });
174
+    }
175
+
176
+    function buildTopic(site, location, capability, deviceId, stream) {
177
+      return [site, "home", location, capability, deviceId, stream].join("/");
178
+    }
179
+
180
+    function pushCapability(messages, identity, capability, value, observedAt, meta) {
181
+      if (value === null || value === undefined) return;
182
+
183
+      var valueTopic = buildTopic(identity.site, identity.location, capability, identity.deviceId, "value");
184
+      var lastTopic = buildTopic(identity.site, identity.location, capability, identity.deviceId, "last");
185
+      var metaTopic = buildTopic(identity.site, identity.location, capability, identity.deviceId, "meta");
186
+
187
+      if (shouldPublish(valueTopic, value)) {
188
+        messages.push(makePublish(valueTopic, value, false));
189
+        node.stats.published += 1;
190
+      }
191
+
192
+      var lastPayload = {
193
+        value: value,
194
+        observed_at: observedAt,
195
+        quality: "reported"
196
+      };
197
+      if (shouldPublish(lastTopic, lastPayload)) {
198
+        messages.push(makePublish(lastTopic, lastPayload, true));
199
+        node.stats.published += 1;
200
+      }
201
+
202
+      if (meta && shouldPublish(metaTopic, meta)) {
203
+        messages.push(makePublish(metaTopic, meta, true));
204
+        node.stats.published += 1;
205
+      }
206
+    }
207
+
208
+    function publishAvailability(messages, identity, active, observedAt) {
209
+      var availabilityTopic = buildTopic(identity.site, identity.location, "adapter", identity.deviceId, "availability");
210
+      var availabilityPayload = active;
211
+      if (shouldPublish(availabilityTopic, availabilityPayload)) {
212
+        messages.push(makePublish(availabilityTopic, availabilityPayload, true));
213
+        node.stats.published += 1;
214
+      }
215
+
216
+      var lastTopic = buildTopic(identity.site, identity.location, "adapter", identity.deviceId, "last");
217
+      var lastPayload = { value: active, observed_at: observedAt, quality: "reported" };
218
+      if (shouldPublish(lastTopic, lastPayload)) {
219
+        messages.push(makePublish(lastTopic, lastPayload, true));
220
+        node.stats.published += 1;
221
+      }
222
+    }
223
+
224
+    function parseTopic(topic) {
225
+      if (typeof topic !== "string") return null;
226
+      var tokens = topic.split("/").map(function(token) { return token.trim(); }).filter(function(token) { return !!token; });
227
+      if (tokens.length !== 5 && tokens.length !== 6) return null;
228
+      if (tokens[0].toLowerCase() !== "zigbee2mqtt" || tokens[1].toLowerCase() !== "pa-44z") return null;
229
+      if (tokens.length === 6 && tokens[5].toLowerCase() !== "availability") return null;
230
+      return {
231
+        site: toKebabCase(tokens[2], node.site || "unknown"),
232
+        location: toKebabCase(tokens[3], "unknown"),
233
+        deviceId: toKebabCase(tokens[4], "pa-44z"),
234
+        availabilityTopic: tokens.length === 6
235
+      };
236
+    }
237
+
238
+    function buildIdentity(parsed) {
239
+      return {
240
+        site: parsed.site || node.site || "unknown",
241
+        location: parsed.location || "unknown",
242
+        deviceId: parsed.deviceId || "pa-44z"
243
+      };
244
+    }
245
+
246
+    function buildMeta(identity, capability, unit, core) {
247
+      var out = {
248
+        adapter_id: node.adapterId,
249
+        device_type: "PA-44Z",
250
+        capability: capability,
251
+        core: !!core,
252
+        source_ref: ["zigbee2mqtt", "PA-44Z", identity.site, identity.location, identity.deviceId].join("/")
253
+      };
254
+      if (unit) out.unit = unit;
255
+      return out;
256
+    }
257
+
258
+    function processTelemetry(identity, payload, observedAt) {
259
+      var messages = [];
260
+      var smoke = asBool(payload.smoke);
261
+      var battery = translateBatteryLevel(asNumber(payload.battery));
262
+      var batteryLowRaw = asBool(payload.battery_low);
263
+      var batteryLow = batteryLowRaw === null ? (battery !== null && battery <= node.batteryLowThreshold) : batteryLowRaw;
264
+      var deviceFault = asBool(payload.device_fault);
265
+      var silence = asBool(payload.silence);
266
+      var test = asBool(payload.test);
267
+      var concentration = asNumber(payload.smoke_concentration);
268
+
269
+      publishAvailability(messages, identity, true, observedAt);
270
+      pushCapability(messages, identity, "smoke", smoke, observedAt, buildMeta(identity, "smoke", "", true));
271
+      pushCapability(messages, identity, "battery", battery, observedAt, buildMeta(identity, "battery", "%", true));
272
+      pushCapability(messages, identity, "battery_low", batteryLow, observedAt, buildMeta(identity, "battery_low", "", true));
273
+      pushCapability(messages, identity, "device_fault", deviceFault, observedAt, buildMeta(identity, "device_fault", "", true));
274
+      pushCapability(messages, identity, "silence", silence, observedAt, buildMeta(identity, "silence", "", false));
275
+      pushCapability(messages, identity, "test", test, observedAt, buildMeta(identity, "test", "", false));
276
+      pushCapability(messages, identity, "smoke_concentration", concentration, observedAt, buildMeta(identity, "smoke_concentration", "ppm", false));
277
+
278
+      return messages;
279
+    }
280
+
281
+    function startSubscriptions() {
282
+      if (node.subscriptionStarted) return;
283
+      node.subscriptionStarted = true;
284
+      node.send([null, { action: "subscribe", topic: node.sourceTopic, qos: 2, rh: 0, rap: true }]);
285
+      setStatus("subscribed", "yellow");
286
+    }
287
+
288
+    node.on("input", function(msg, send, done) {
289
+      send = send || function() { node.send.apply(node, arguments); };
290
+      try {
291
+        node.stats.processed += 1;
292
+        var parsed = parseTopic(msg && msg.topic);
293
+        if (!parsed) {
294
+          node.stats.invalid += 1;
295
+          setStatus("invalid", "red");
296
+          if (done) done();
297
+          return;
298
+        }
299
+
300
+        var identity = buildIdentity(parsed);
301
+        var observedAt = toIsoTimestamp(msg && msg.payload && msg.payload.last_seen);
302
+        var messages = [];
303
+
304
+        if (parsed.availabilityTopic) {
305
+          var active = asBool(msg.payload);
306
+          if (active === null) active = false;
307
+          publishAvailability(messages, identity, active, observedAt);
308
+        } else if (msg.payload && typeof msg.payload === "object" && !Array.isArray(msg.payload)) {
309
+          messages = processTelemetry(identity, msg.payload, observedAt);
310
+        } else {
311
+          node.stats.invalid += 1;
312
+          setStatus("invalid", "red");
313
+          if (done) done();
314
+          return;
315
+        }
316
+
317
+        if (messages.length) send([messages, null]);
318
+        setStatus();
319
+        if (done) done();
320
+      } catch (err) {
321
+        node.stats.errors += 1;
322
+        node.status({ fill: "red", shape: "ring", text: "error: " + err.message });
323
+        if (done) done(err);
324
+        else node.error(err, msg);
325
+      }
326
+    });
327
+
328
+    node.on("close", function() {
329
+      if (node.startTimer) {
330
+        clearTimeout(node.startTimer);
331
+        node.startTimer = null;
332
+      }
333
+    });
334
+
335
+    node.startTimer = setTimeout(startSubscriptions, 250);
336
+    node.status({ fill: "grey", shape: "ring", text: "starting" });
337
+  }
338
+
339
+  RED.nodes.registerType("z2m-pa-44z-homebus", PA44ZHomeBusNode);
340
+};
+21 -0
adapters/smoke-detector/homekit-adapter/package.json
@@ -0,0 +1,21 @@
1
+{
2
+  "name": "node-red-contrib-z2m-pa-44z",
3
+  "version": "0.0.1",
4
+  "description": "Node-RED node that consumes HomeBus Tuya PA-44Z smoke detector streams and projects them to HomeKit using dynamic MQTT subscriptions",
5
+  "main": "z2m-pa-44z.js",
6
+  "keywords": [
7
+    "node-red",
8
+    "zigbee2mqtt",
9
+    "homebus",
10
+    "smoke",
11
+    "homekit",
12
+    "battery"
13
+  ],
14
+  "author": "",
15
+  "license": "MIT",
16
+  "node-red": {
17
+    "nodes": {
18
+      "pa-44z-homekit-adapter": "z2m-pa-44z.js"
19
+    }
20
+  }
21
+}
+92 -0
adapters/smoke-detector/homekit-adapter/z2m-pa-44z.html
@@ -0,0 +1,92 @@
1
+<script type="text/x-red" data-template-name="pa-44z-homekit-adapter">
2
+  <div class="form-row">
3
+    <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
4
+    <input type="text" id="node-input-name" placeholder="Name">
5
+  </div>
6
+  <div class="form-row">
7
+    <label for="node-input-site"><i class="fa fa-globe"></i> Site</label>
8
+    <input type="text" id="node-input-site" placeholder="vad">
9
+  </div>
10
+  <div class="form-row">
11
+    <label for="node-input-location"><i class="fa fa-home"></i> Location</label>
12
+    <input type="text" id="node-input-location" placeholder="kitchen">
13
+  </div>
14
+  <div class="form-row">
15
+    <label for="node-input-accessory"><i class="fa fa-dot-circle-o"></i> Accessory</label>
16
+    <input type="text" id="node-input-accessory" placeholder="smoke-sensor">
17
+  </div>
18
+  <div class="form-row">
19
+    <label for="node-input-batteryLowThreshold">Battery low threshold (%)</label>
20
+    <input type="number" id="node-input-batteryLowThreshold" min="0" max="100" placeholder="20">
21
+  </div>
22
+</script>
23
+
24
+<script type="text/x-red" data-help-name="pa-44z-homekit-adapter">
25
+  <p>
26
+    Consumes HomeBus telemetry for a single <code>PA-44Z</code> smoke detector endpoint and projects it to HomeKit services.
27
+  </p>
28
+  <p>
29
+    Output 3 controls a Node-RED <code>mqtt in</code> node with dynamic subscriptions.
30
+  </p>
31
+  <p>
32
+    On start, the node emits:
33
+    <code>subscribe &lt;site&gt;/home/&lt;location&gt;/+/&lt;accessory&gt;/last</code>,
34
+    <code>subscribe &lt;site&gt;/home/&lt;location&gt;/+/&lt;accessory&gt;/value</code>, and
35
+    <code>subscribe &lt;site&gt;/home/&lt;location&gt;/+/&lt;accessory&gt;/availability</code>.
36
+  </p>
37
+  <p>
38
+    The node keeps <code>.../last</code> subscribed until bootstrap is held open for <code>10s</code>, then emits a complete snapshot and unsubscribes from <code>.../last</code>.
39
+  </p>
40
+  <h3>Capabilities</h3>
41
+  <p>
42
+    Mappings:
43
+    <code>smoke -&gt; Smoke Sensor</code>,
44
+    <code>battery + battery_low -&gt; Battery</code>,
45
+    <code>device_fault -&gt; StatusFault</code>.
46
+  </p>
47
+  <p>
48
+    <code>silence</code>, <code>test</code>, and <code>smoke_concentration</code> are carried on HomeBus but are not exposed to HomeKit by this adapter.
49
+  </p>
50
+  <h3>Outputs</h3>
51
+  <ol>
52
+    <li>Smoke Sensor: <code>{ SmokeDetected, StatusActive, StatusFault, StatusLowBattery }</code></li>
53
+    <li>Battery Service: <code>{ StatusLowBattery, BatteryLevel, ChargingState }</code></li>
54
+    <li><code>mqtt in</code> dynamic control messages: <code>{ action, topic }</code></li>
55
+  </ol>
56
+  <h3>HomeKit Service Setup</h3>
57
+  <p><code>Smoke Sensor</code></p>
58
+  <pre><code>{"StatusActive":{},"StatusFault":{},"StatusLowBattery":{}}</code></pre>
59
+  <p><code>Battery</code></p>
60
+  <pre><code>{"BatteryLevel":{},"ChargingState":{}}</code></pre>
61
+</script>
62
+
63
+<script>
64
+  function requiredText(v) {
65
+    return !!(v && String(v).trim());
66
+  }
67
+
68
+  RED.nodes.registerType("pa-44z-homekit-adapter", {
69
+    category: "myNodes",
70
+    color: "#d7f0d1",
71
+    defaults: {
72
+      name: { value: "" },
73
+      outputTopic: { value: "" },
74
+      mqttBus: { value: "" },
75
+      site: { value: "", validate: requiredText },
76
+      location: { value: "", validate: requiredText },
77
+      accessory: { value: "", validate: requiredText },
78
+      mqttSite: { value: "" },
79
+      mqttRoom: { value: "" },
80
+      mqttSensor: { value: "" },
81
+      batteryLowThreshold: { value: 20, validate: RED.validators.number() },
82
+      publishCacheWindowSec: { value: "" }
83
+    },
84
+    inputs: 1,
85
+    outputs: 3,
86
+    icon: "font-awesome/fa-fire-extinguisher",
87
+    label: function() {
88
+      return this.name || "pa-44z-homekit-adapter";
89
+    },
90
+    paletteLabel: "pa-44z homekit"
91
+  });
92
+</script>
+382 -0
adapters/smoke-detector/homekit-adapter/z2m-pa-44z.js
@@ -0,0 +1,382 @@
1
+module.exports = function(RED) {
2
+  function PA44ZHomeKitNode(config) {
3
+    RED.nodes.createNode(this, config);
4
+    var node = this;
5
+
6
+    node.site = normalizeToken(config.site || config.mqttSite || "");
7
+    node.location = normalizeToken(config.location || config.mqttRoom || "");
8
+    node.accessory = normalizeToken(config.accessory || config.mqttSensor || "");
9
+    node.batteryLowThreshold = parseNumber(config.batteryLowThreshold, 20, 0);
10
+    node.bootstrapDeadlineMs = 10000;
11
+    node.hkCache = Object.create(null);
12
+    node.startTimer = null;
13
+    node.bootstrapTimer = null;
14
+    node.lastMsgContext = null;
15
+
16
+    node.stats = {
17
+      controls: 0,
18
+      last_inputs: 0,
19
+      value_inputs: 0,
20
+      availability_inputs: 0,
21
+      hk_updates: 0,
22
+      errors: 0
23
+    };
24
+
25
+    node.subscriptionState = {
26
+      started: false,
27
+      lastSubscribed: false,
28
+      valueSubscribed: false,
29
+      availabilitySubscribed: false
30
+    };
31
+
32
+    node.bootstrapState = {
33
+      finalized: false,
34
+      smoke: false,
35
+      battery: false
36
+    };
37
+
38
+    node.sensorState = {
39
+      active: false,
40
+      site: node.site || "",
41
+      location: node.location || "",
42
+      deviceId: node.accessory || "",
43
+      smokeKnown: false,
44
+      smoke: false,
45
+      batteryKnown: false,
46
+      battery: null,
47
+      batteryLowKnown: false,
48
+      batteryLow: false,
49
+      deviceFaultKnown: false,
50
+      deviceFault: false
51
+    };
52
+
53
+    function normalizeToken(value) {
54
+      if (value === undefined || value === null) return "";
55
+      return String(value).trim();
56
+    }
57
+
58
+    function parseNumber(value, fallback, min) {
59
+      var n = Number(value);
60
+      if (!Number.isFinite(n)) return fallback;
61
+      if (typeof min === "number" && n < min) return fallback;
62
+      return n;
63
+    }
64
+
65
+    function asBool(value) {
66
+      if (typeof value === "boolean") return value;
67
+      if (typeof value === "number") return value !== 0;
68
+      if (typeof value === "string") {
69
+        var v = value.trim().toLowerCase();
70
+        if (v === "true" || v === "1" || v === "on" || v === "yes" || v === "online") return true;
71
+        if (v === "false" || v === "0" || v === "off" || v === "no" || v === "offline") return false;
72
+      }
73
+      return null;
74
+    }
75
+
76
+    function asNumber(value) {
77
+      if (typeof value === "number" && isFinite(value)) return value;
78
+      if (typeof value === "string") {
79
+        var trimmed = value.trim();
80
+        if (!trimmed) return null;
81
+        var parsed = Number(trimmed);
82
+        if (isFinite(parsed)) return parsed;
83
+      }
84
+      return null;
85
+    }
86
+
87
+    function clamp(n, min, max) {
88
+      return Math.max(min, Math.min(max, n));
89
+    }
90
+
91
+    function signature(value) {
92
+      return JSON.stringify(value);
93
+    }
94
+
95
+    function shouldPublish(cacheKey, payload) {
96
+      var sig = signature(payload);
97
+      if (node.hkCache[cacheKey] === sig) return false;
98
+      node.hkCache[cacheKey] = sig;
99
+      return true;
100
+    }
101
+
102
+    function cloneBaseMsg(msg) {
103
+      if (!msg || typeof msg !== "object") return {};
104
+      var out = {};
105
+      if (typeof msg.topic === "string") out.topic = msg.topic;
106
+      if (msg._msgid) out._msgid = msg._msgid;
107
+      return out;
108
+    }
109
+
110
+    function clearBootstrapTimer() {
111
+      if (!node.bootstrapTimer) return;
112
+      clearTimeout(node.bootstrapTimer);
113
+      node.bootstrapTimer = null;
114
+    }
115
+
116
+    function makeHomeKitMsg(baseMsg, payload) {
117
+      var out = RED.util.cloneMessage(baseMsg || {});
118
+      out.payload = payload;
119
+      return out;
120
+    }
121
+
122
+    function buildSubscriptionTopic(stream) {
123
+      return [node.site, "home", node.location, "+", node.accessory, stream].join("/");
124
+    }
125
+
126
+    function buildSubscribeMsgs() {
127
+      return [
128
+        { action: "subscribe", topic: buildSubscriptionTopic("last"), qos: 2, rh: 0, rap: true },
129
+        { action: "subscribe", topic: buildSubscriptionTopic("value"), qos: 2, rh: 0, rap: true },
130
+        { action: "subscribe", topic: buildSubscriptionTopic("availability"), qos: 2, rh: 0, rap: true }
131
+      ];
132
+    }
133
+
134
+    function buildUnsubscribeLastMsg(reason) {
135
+      return { action: "unsubscribe", topic: buildSubscriptionTopic("last"), reason: reason };
136
+    }
137
+
138
+    function statusText(prefix) {
139
+      var state = node.subscriptionState.lastSubscribed ? "cold" : (node.subscriptionState.started ? "live" : "idle");
140
+      var device = node.sensorState.deviceId || node.accessory || "?";
141
+      return [prefix || state, device, "l:" + node.stats.last_inputs, "v:" + node.stats.value_inputs, "a:" + node.stats.availability_inputs, "hk:" + node.stats.hk_updates].join(" ");
142
+    }
143
+
144
+    function setNodeStatus(prefix, fill, shape) {
145
+      node.status({
146
+        fill: fill || (node.stats.errors ? "red" : (node.subscriptionState.lastSubscribed ? "yellow" : (node.sensorState.active ? "green" : "yellow"))),
147
+        shape: shape || "dot",
148
+        text: statusText(prefix)
149
+      });
150
+    }
151
+
152
+    function noteError(text, msg) {
153
+      node.stats.errors += 1;
154
+      node.warn(text);
155
+      node.status({ fill: "red", shape: "ring", text: text });
156
+      if (msg) node.debug(msg);
157
+    }
158
+
159
+    function buildStatusFields() {
160
+      return {
161
+        StatusActive: !!node.sensorState.active,
162
+        StatusFault: node.sensorState.deviceFaultKnown
163
+          ? (node.sensorState.deviceFault ? 1 : 0)
164
+          : (node.sensorState.active ? 0 : 1),
165
+        StatusLowBattery: node.sensorState.batteryLow ? 1 : 0
166
+      };
167
+    }
168
+
169
+    function buildSmokeMsg(baseMsg) {
170
+      if (!node.sensorState.smokeKnown) return null;
171
+      var payload = buildStatusFields();
172
+      payload.SmokeDetected = node.sensorState.smoke ? 1 : 0;
173
+      if (!shouldPublish("hk:smoke", payload)) return null;
174
+      node.stats.hk_updates += 1;
175
+      return makeHomeKitMsg(baseMsg, payload);
176
+    }
177
+
178
+    function buildBatteryMsg(baseMsg) {
179
+      if (!node.sensorState.batteryKnown && !node.sensorState.batteryLowKnown) return null;
180
+      var batteryLevel = node.sensorState.batteryKnown
181
+        ? clamp(Math.round(Number(node.sensorState.battery)), 0, 100)
182
+        : (node.sensorState.batteryLow ? 1 : 100);
183
+      var payload = {
184
+        StatusLowBattery: node.sensorState.batteryLow ? 1 : 0,
185
+        BatteryLevel: batteryLevel,
186
+        ChargingState: 2
187
+      };
188
+      if (!shouldPublish("hk:battery", payload)) return null;
189
+      node.stats.hk_updates += 1;
190
+      return makeHomeKitMsg(baseMsg, payload);
191
+    }
192
+
193
+    function clearSnapshotCache() {
194
+      delete node.hkCache["hk:smoke"];
195
+      delete node.hkCache["hk:battery"];
196
+    }
197
+
198
+    function buildBootstrapOutputs(baseMsg) {
199
+      clearSnapshotCache();
200
+      return [buildSmokeMsg(baseMsg), buildBatteryMsg(baseMsg)];
201
+    }
202
+
203
+    function unsubscribeLast(reason) {
204
+      if (!node.subscriptionState.lastSubscribed) return null;
205
+      node.subscriptionState.lastSubscribed = false;
206
+      node.stats.controls += 1;
207
+      return buildUnsubscribeLastMsg(reason);
208
+    }
209
+
210
+    function markBootstrapSatisfied(capability) {
211
+      if (capability === "smoke" && node.sensorState.smokeKnown) {
212
+        node.bootstrapState.smoke = true;
213
+      } else if ((capability === "battery" || capability === "battery_low") && (node.sensorState.batteryKnown || node.sensorState.batteryLowKnown)) {
214
+        node.bootstrapState.battery = true;
215
+      }
216
+    }
217
+
218
+    function parseTopic(topic) {
219
+      if (typeof topic !== "string") return null;
220
+      var tokens = topic.split("/").map(function(token) { return token.trim(); }).filter(function(token) { return !!token; });
221
+      if (tokens.length !== 6) return null;
222
+      if (tokens[1] !== "home") return null;
223
+      if (tokens[5] !== "value" && tokens[5] !== "last" && tokens[5] !== "availability") return { ignored: true };
224
+      if ((node.site && tokens[0] !== node.site) || (node.location && tokens[2] !== node.location) || (node.accessory && tokens[4] !== node.accessory)) return { ignored: true };
225
+      return { site: tokens[0], location: tokens[2], capability: tokens[3], deviceId: tokens[4], stream: tokens[5] };
226
+    }
227
+
228
+    function extractValue(stream, payload) {
229
+      if (stream === "last" && payload && typeof payload === "object" && !Array.isArray(payload) && Object.prototype.hasOwnProperty.call(payload, "value")) {
230
+        return payload.value;
231
+      }
232
+      return payload;
233
+    }
234
+
235
+    function updateBatteryLowFromThreshold() {
236
+      if (!node.sensorState.batteryKnown || node.sensorState.batteryLowKnown) return;
237
+      node.sensorState.batteryLow = Number(node.sensorState.battery) <= node.batteryLowThreshold;
238
+    }
239
+
240
+    function processAvailability(baseMsg, value) {
241
+      var active = asBool(value);
242
+      if (active === null) return [null, null];
243
+      node.sensorState.active = active;
244
+      node.lastMsgContext = cloneBaseMsg(baseMsg);
245
+      return [buildSmokeMsg(baseMsg), buildBatteryMsg(baseMsg)];
246
+    }
247
+
248
+    function processCapability(baseMsg, parsed, value) {
249
+      var smokeMsg = null;
250
+      var batteryMsg = null;
251
+
252
+      node.sensorState.active = true;
253
+      node.sensorState.site = parsed.site;
254
+      node.sensorState.location = parsed.location;
255
+      node.sensorState.deviceId = parsed.deviceId;
256
+      node.lastMsgContext = cloneBaseMsg(baseMsg);
257
+
258
+      if (parsed.capability === "smoke") {
259
+        var smoke = asBool(value);
260
+        if (smoke === null) return [null, null];
261
+        node.sensorState.smokeKnown = true;
262
+        node.sensorState.smoke = smoke;
263
+        smokeMsg = buildSmokeMsg(baseMsg);
264
+      } else if (parsed.capability === "battery") {
265
+        var battery = asNumber(value);
266
+        if (battery === null) return [null, null];
267
+        node.sensorState.batteryKnown = true;
268
+        node.sensorState.battery = clamp(Math.round(battery), 0, 100);
269
+        updateBatteryLowFromThreshold();
270
+        smokeMsg = buildSmokeMsg(baseMsg);
271
+        batteryMsg = buildBatteryMsg(baseMsg);
272
+      } else if (parsed.capability === "battery_low") {
273
+        var batteryLow = asBool(value);
274
+        if (batteryLow === null) return [null, null];
275
+        node.sensorState.batteryLowKnown = true;
276
+        node.sensorState.batteryLow = batteryLow;
277
+        smokeMsg = buildSmokeMsg(baseMsg);
278
+        batteryMsg = buildBatteryMsg(baseMsg);
279
+      } else if (parsed.capability === "device_fault") {
280
+        var deviceFault = asBool(value);
281
+        if (deviceFault === null) return [null, null];
282
+        node.sensorState.deviceFaultKnown = true;
283
+        node.sensorState.deviceFault = deviceFault;
284
+        smokeMsg = buildSmokeMsg(baseMsg);
285
+      } else {
286
+        return [null, null];
287
+      }
288
+
289
+      return [smokeMsg, batteryMsg];
290
+    }
291
+
292
+    function finalizeBootstrap(reason, send) {
293
+      if (node.bootstrapState.finalized) return false;
294
+      if (!node.subscriptionState.lastSubscribed) return false;
295
+      node.bootstrapState.finalized = true;
296
+      clearBootstrapTimer();
297
+      send = send || function(msgs) { node.send(msgs); };
298
+      var outputs = buildBootstrapOutputs(cloneBaseMsg(node.lastMsgContext));
299
+      var controlMsg = unsubscribeLast(reason);
300
+      send([outputs[0], outputs[1], controlMsg]);
301
+      setNodeStatus("live");
302
+      return true;
303
+    }
304
+
305
+    function startSubscriptions() {
306
+      if (node.subscriptionState.started) return;
307
+      if (!node.site || !node.location || !node.accessory) {
308
+        noteError("missing site, location or accessory");
309
+        return;
310
+      }
311
+      node.subscriptionState.started = true;
312
+      node.subscriptionState.lastSubscribed = true;
313
+      node.subscriptionState.valueSubscribed = true;
314
+      node.subscriptionState.availabilitySubscribed = true;
315
+      clearBootstrapTimer();
316
+      node.bootstrapTimer = setTimeout(function() {
317
+        finalizeBootstrap("bootstrap-timeout");
318
+      }, node.bootstrapDeadlineMs);
319
+      node.stats.controls += 1;
320
+      node.send([null, null, buildSubscribeMsgs()]);
321
+      setNodeStatus("cold");
322
+    }
323
+
324
+    node.on("input", function(msg, send, done) {
325
+      send = send || function() { node.send.apply(node, arguments); };
326
+      try {
327
+        var parsed = parseTopic(msg && msg.topic);
328
+        if (!parsed) {
329
+          noteError("invalid topic");
330
+          if (done) done();
331
+          return;
332
+        }
333
+        if (parsed.ignored) {
334
+          if (done) done();
335
+          return;
336
+        }
337
+
338
+        var value = extractValue(parsed.stream, msg.payload);
339
+        var outputs;
340
+        if (parsed.stream === "last") {
341
+          node.stats.last_inputs += 1;
342
+          outputs = processCapability(msg, parsed, value);
343
+          markBootstrapSatisfied(parsed.capability);
344
+        } else if (parsed.stream === "value") {
345
+          node.stats.value_inputs += 1;
346
+          outputs = processCapability(msg, parsed, value);
347
+          markBootstrapSatisfied(parsed.capability);
348
+        } else {
349
+          node.stats.availability_inputs += 1;
350
+          outputs = processAvailability(msg, value);
351
+        }
352
+
353
+        if (node.subscriptionState.lastSubscribed) {
354
+          setNodeStatus("cold");
355
+        } else {
356
+          send([outputs[0], outputs[1], null]);
357
+          setNodeStatus();
358
+        }
359
+
360
+        if (done) done();
361
+      } catch (err) {
362
+        node.stats.errors += 1;
363
+        node.status({ fill: "red", shape: "ring", text: "error: " + err.message });
364
+        if (done) done(err);
365
+        else node.error(err, msg);
366
+      }
367
+    });
368
+
369
+    node.on("close", function() {
370
+      clearBootstrapTimer();
371
+      if (node.startTimer) {
372
+        clearTimeout(node.startTimer);
373
+        node.startTimer = null;
374
+      }
375
+    });
376
+
377
+    node.startTimer = setTimeout(startSubscriptions, 250);
378
+    node.status({ fill: "grey", shape: "ring", text: "starting" });
379
+  }
380
+
381
+  RED.nodes.registerType("pa-44z-homekit-adapter", PA44ZHomeKitNode);
382
+};
+74 -0
adapters/smoke-detector/models/PA-44Z.md
@@ -0,0 +1,74 @@
1
+---
2
+title: "Tuya PA-44Z control via MQTT"
3
+description: "Integrate your Tuya PA-44Z via Zigbee2MQTT with whatever smart home infrastructure you are using without the vendor's bridge or gateway."
4
+addedAt: 2023-06-01T08:16:21
5
+pageClass: device-page
6
+---
7
+
8
+<!-- !!!! -->
9
+<!-- ATTENTION: This file is auto-generated through docgen! -->
10
+<!-- You can only edit the "Notes"-Section between the two comment lines "Notes BEGIN" and "Notes END". -->
11
+<!-- Do not use h1 or h2 heading within "## Notes"-Section. -->
12
+<!-- !!!! -->
13
+
14
+# Tuya PA-44Z
15
+
16
+|     |     |
17
+|-----|-----|
18
+| Model | PA-44Z  |
19
+| Vendor  | [Tuya](/supported-devices/#v=Tuya)  |
20
+| Description | Photoelectric smoke detector |
21
+| Exposes | smoke, battery, silence, test, smoke_concentration, device_fault |
22
+| Picture | ![Tuya PA-44Z](https://www.zigbee2mqtt.io/images/devices/PA-44Z.png) |
23
+
24
+
25
+<!-- Notes BEGIN: You can edit here. Add "## Notes" headline if not already present. -->
26
+## Notes
27
+
28
+### Pairing
29
+Press test button for 10 seconds.
30
+<!-- Notes END: Do not edit below this line -->
31
+
32
+
33
+
34
+
35
+## Exposes
36
+
37
+### Smoke (binary)
38
+Indicates whether the device detected smoke.
39
+Value can be found in the published state on the `smoke` property.
40
+It's not possible to read (`/get`) or write (`/set`) this value.
41
+If value equals `true` smoke is ON, if `false` OFF.
42
+
43
+### Battery (numeric)
44
+Remaining battery in %, can take up to 24 hours before reported.
45
+Value can be found in the published state on the `battery` property.
46
+It's not possible to read (`/get`) or write (`/set`) this value.
47
+The minimal value is `0` and the maximum value is `100`.
48
+The unit of this value is `%`.
49
+
50
+### Silence (binary)
51
+Silence the alarm.
52
+Value can be found in the published state on the `silence` property.
53
+It's not possible to read (`/get`) this value.
54
+To write (`/set`) a value publish a message to topic `zigbee2mqtt/FRIENDLY_NAME/set` with payload `{"silence": NEW_VALUE}`.
55
+If value equals `true` silence is ON, if `false` OFF.
56
+
57
+### Test (binary)
58
+Indicates whether the device is being tested.
59
+Value can be found in the published state on the `test` property.
60
+It's not possible to read (`/get`) or write (`/set`) this value.
61
+If value equals `true` test is ON, if `false` OFF.
62
+
63
+### Smoke concentration (numeric)
64
+Parts per million of smoke detected.
65
+Value can be found in the published state on the `smoke_concentration` property.
66
+It's not possible to read (`/get`) or write (`/set`) this value.
67
+The unit of this value is `ppm`.
68
+
69
+### Device fault (binary)
70
+Indicates a fault with the device.
71
+Value can be found in the published state on the `device_fault` property.
72
+It's not possible to read (`/get`) or write (`/set`) this value.
73
+If value equals `true` device fault is ON, if `false` OFF.
74
+
+21 -0
adapters/water-leak-sensor/homebus-adapter/package.json
@@ -0,0 +1,21 @@
1
+{
2
+  "name": "node-red-contrib-z2m-snzb-05p-homebus",
3
+  "version": "0.0.1",
4
+  "description": "Node-RED node that adapts Zigbee2MQTT SONOFF SNZB-05P messages to canonical HomeBus and adapter operational topics",
5
+  "main": "z2m-snzb-05p-homebus.js",
6
+  "keywords": [
7
+    "node-red",
8
+    "zigbee2mqtt",
9
+    "homebus",
10
+    "water",
11
+    "leak",
12
+    "battery"
13
+  ],
14
+  "author": "",
15
+  "license": "MIT",
16
+  "node-red": {
17
+    "nodes": {
18
+      "z2m-snzb-05p-homebus": "z2m-snzb-05p-homebus.js"
19
+    }
20
+  }
21
+}
+139 -0
adapters/water-leak-sensor/homebus-adapter/z2m-snzb-05p-homebus.html
@@ -0,0 +1,139 @@
1
+<script type="text/x-red" data-template-name="z2m-snzb-05p-homebus">
2
+  <div class="form-row">
3
+    <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
4
+    <input type="text" id="node-input-name" placeholder="Name">
5
+  </div>
6
+  <div class="form-row">
7
+    <label for="node-input-site"><i class="fa fa-globe"></i> Site</label>
8
+    <input type="text" id="node-input-site" placeholder="unknown">
9
+  </div>
10
+  <div class="form-row">
11
+    <label for="node-input-batteryLowThreshold">Battery low threshold (%)</label>
12
+    <input type="number" id="node-input-batteryLowThreshold" min="0" max="100" placeholder="20">
13
+  </div>
14
+</script>
15
+
16
+<script type="text/x-red" data-help-name="z2m-snzb-05p-homebus">
17
+  <p>
18
+    Translates Zigbee2MQTT messages for SONOFF <code>SNZB-05P</code> water leak sensors into canonical HomeBus topics.
19
+  </p>
20
+  <p>
21
+    Canonical topic shape:
22
+    <code>&lt;site&gt;/home/&lt;location&gt;/&lt;capability&gt;/&lt;device_id&gt;/&lt;stream&gt;</code>
23
+  </p>
24
+  <p>
25
+    Example outputs:
26
+    <code>vad/home/bathroom/water_leak/sink-sensor/value</code>,
27
+    <code>vad/home/bathroom/battery/sink-sensor/last</code>,
28
+    <code>vad/home/bathroom/water_leak/sink-sensor/meta</code>
29
+  </p>
30
+  <h3>Input</h3>
31
+  <p>
32
+    Expected Zigbee2MQTT telemetry topic:
33
+    <code>zigbee2mqtt/SNZB-05P/&lt;site&gt;/&lt;location&gt;/&lt;device_id&gt;</code> with a JSON payload.
34
+  </p>
35
+  <p>
36
+    Availability topic is also supported:
37
+    <code>zigbee2mqtt/SNZB-05P/&lt;site&gt;/&lt;location&gt;/&lt;device_id&gt;/availability</code> with payload <code>online</code> or <code>offline</code>.
38
+  </p>
39
+  <p>
40
+    Typical subscription for this adapter:
41
+    <code>zigbee2mqtt/SNZB-05P/#</code>
42
+  </p>
43
+  <p>
44
+    Output 2 controls a dynamic <code>mqtt in</code> node on the raw Zigbee2MQTT broker. On startup, the adapter emits
45
+    <code>{ action: "subscribe", topic: "zigbee2mqtt/SNZB-05P/#" }</code>.
46
+  </p>
47
+  <p>
48
+    This node is intended to fan out traffic for multiple <code>SNZB-05P</code> devices, not to be instantiated per device.
49
+  </p>
50
+  <p>
51
+    The node status shows adapter statistics such as detected devices, processed inputs, translated publications,
52
+    operational messages, and errors. Invalid topics, invalid messages, invalid payloads, and unmapped payloads are counted separately in the status text.
53
+  </p>
54
+  <p>
55
+    Used fields:
56
+    <code>water_leak</code>, <code>battery</code>, <code>battery_low</code>, <code>availability</code>, <code>online</code>.
57
+    Optional tamper-like fields are also supported when present:
58
+    <code>tamper</code>, <code>tampered</code>, <code>tamper_alarm</code>, <code>alarm_tamper</code>.
59
+  </p>
60
+  <h3>Output</h3>
61
+  <ol>
62
+    <li>MQTT-ready publish messages, emitted as an array of messages on the semantic/output path.</li>
63
+    <li><code>mqtt in</code> control messages for the raw Zigbee2MQTT subscription.</li>
64
+  </ol>
65
+  <p>
66
+    Semantic bus messages are published in minimal form only:
67
+    <code>msg.topic</code>, <code>msg.payload</code>, <code>msg.qos</code>, and <code>msg.retain</code>.
68
+    Adapter internals such as vendor payload snapshots are not forwarded on the HomeBus output.
69
+  </p>
70
+  <p>
71
+    Live telemetry is unified on <code>value</code>. The adapter publishes lightweight hot-path <code>value</code> updates and deduplicates them on change.
72
+  </p>
73
+  <p>
74
+    Retained <code>last</code> carries the timestamped latest known sample for bootstrap and freshness evaluation.
75
+  </p>
76
+  <p>
77
+    The <code>last</code> payload uses a small JSON envelope with <code>value</code> and <code>observed_at</code>. When the source does not provide a timestamp, the adapter uses ingestion time and marks <code>quality=estimated</code>.
78
+  </p>
79
+  <p>
80
+    The node publishes retained <code>meta</code> and <code>availability</code> topics plus live
81
+    <code>value</code> topics for the capabilities supported by this sensor, together with retained <code>last</code> for the latest timestamped sample.
82
+  </p>
83
+  <p>
84
+    Operational topics are emitted on the same output under:
85
+    <code>&lt;site&gt;/sys/adapter/z2m-snzb-05p/{availability,stats,error,dlq}</code>.
86
+  </p>
87
+  <p>
88
+    The adapter identifier is fixed to <code>z2m-snzb-05p</code>. Device identity and <code>source_ref</code> are derived per message from the inbound topic or payload.
89
+  </p>
90
+  <p>
91
+    Mapping:
92
+    <code>water_leak -&gt; water_leak/value</code>,
93
+    <code>battery -&gt; battery/value</code>,
94
+    <code>battery_low -&gt; battery_low/value</code>,
95
+    <code>tamper -&gt; tamper/value</code> when available.
96
+  </p>
97
+  <p>
98
+    Identity is resolved with priority:
99
+    incoming topic, then message/payload fields, then fallback defaults
100
+    <code>unknown/unknown/snzb-05p</code>.
101
+  </p>
102
+  <p>
103
+    With the recommended topic structure, <code>site</code>, <code>location</code>, <code>device_id</code>,
104
+    and usually <code>source_ref</code> are inferred directly from the Zigbee2MQTT topic path.
105
+  </p>
106
+  <p>
107
+    If this node is created from an older flow JSON, legacy fields
108
+    <code>mqttBus</code>, <code>mqttRoom</code>, and <code>mqttSensor</code> are still accepted as fallbacks.
109
+  </p>
110
+  <p>
111
+    Malformed or unmappable inputs are routed to adapter operational topics instead of being embedded into semantic bus payloads.
112
+  </p>
113
+  <p>
114
+    Input validation is strict for the source topic. Non-conforming topics or payloads are also logged with <code>node.warn</code>/<code>node.error</code> in Node-RED so they are visible in flow messages in addition to <code>sys/adapter/z2m-snzb-05p/{error,dlq}</code>.
115
+  </p>
116
+</script>
117
+
118
+<script>
119
+  RED.nodes.registerType("z2m-snzb-05p-homebus", {
120
+    category: "myNodes",
121
+    color: "#d9ecfb",
122
+    defaults: {
123
+      name: { value: "" },
124
+      site: { value: "unknown" },
125
+      batteryLowThreshold: { value: 20, validate: RED.validators.number() },
126
+      mqttSite: { value: "" },
127
+      mqttBus: { value: "" },
128
+      mqttRoom: { value: "" },
129
+      mqttSensor: { value: "" }
130
+    },
131
+    inputs: 1,
132
+    outputs: 2,
133
+    icon: "font-awesome/fa-sitemap",
134
+    label: function() {
135
+      return this.name || "z2m-snzb-05p-homebus";
136
+    },
137
+    paletteLabel: "snzb-05p homebus"
138
+  });
139
+</script>
+829 -0
adapters/water-leak-sensor/homebus-adapter/z2m-snzb-05p-homebus.js
@@ -0,0 +1,829 @@
1
+module.exports = function(RED) {
2
+  function Z2MSNZB05PHomeBusNode(config) {
3
+    RED.nodes.createNode(this, config);
4
+    var node = this;
5
+
6
+    node.adapterId = "z2m-snzb-05p";
7
+    node.sourceTopic = "zigbee2mqtt/SNZB-05P/#";
8
+    node.subscriptionStarted = false;
9
+    node.startTimer = null;
10
+    node.site = normalizeToken(config.site || config.mqttSite);
11
+    node.legacyRoom = normalizeToken(config.mqttRoom);
12
+    node.legacySensor = normalizeToken(config.mqttSensor);
13
+    node.publishCache = Object.create(null);
14
+    node.retainedCache = Object.create(null);
15
+    node.seenOptionalCapabilities = Object.create(null);
16
+    node.detectedDevices = Object.create(null);
17
+    node.stats = {
18
+      processed_inputs: 0,
19
+      devices_detected: 0,
20
+      home_messages: 0,
21
+      last_messages: 0,
22
+      meta_messages: 0,
23
+      home_availability_messages: 0,
24
+      operational_messages: 0,
25
+      invalid_messages: 0,
26
+      invalid_topics: 0,
27
+      invalid_payloads: 0,
28
+      unmapped_messages: 0,
29
+      adapter_exceptions: 0,
30
+      errors: 0,
31
+      dlq: 0
32
+    };
33
+    node.statsPublishEvery = 25;
34
+    node.batteryLowThreshold = Number(config.batteryLowThreshold);
35
+    if (!Number.isFinite(node.batteryLowThreshold)) node.batteryLowThreshold = 20;
36
+
37
+    var CAPABILITY_MAPPINGS = [
38
+      {
39
+        sourceSystem: "zigbee2mqtt",
40
+        sourceTopicMatch: "zigbee2mqtt/SNZB-05P/<site>/<location>/<device_id>",
41
+        sourceFields: ["water_leak", "waterLeak", "leak"],
42
+        targetBus: "home",
43
+        targetCapability: "water_leak",
44
+        core: true,
45
+        stream: "value",
46
+        payloadProfile: "scalar",
47
+        dataType: "boolean",
48
+        historianMode: "state",
49
+        historianEnabled: true,
50
+        read: function(payload) {
51
+          return readBool(payload, this.sourceFields);
52
+        }
53
+      },
54
+      {
55
+        sourceSystem: "zigbee2mqtt",
56
+        sourceTopicMatch: "zigbee2mqtt/SNZB-05P/<site>/<location>/<device_id>",
57
+        sourceFields: ["battery"],
58
+        targetBus: "home",
59
+        targetCapability: "battery",
60
+        core: true,
61
+        stream: "value",
62
+        payloadProfile: "scalar",
63
+        dataType: "number",
64
+        unit: "%",
65
+        precision: 1,
66
+        historianMode: "sample",
67
+        historianEnabled: true,
68
+        read: function(payload) {
69
+          var value = readNumber(payload, this.sourceFields[0]);
70
+          if (value === undefined) return undefined;
71
+          return clamp(Math.round(value), 0, 100);
72
+        }
73
+      },
74
+      {
75
+        sourceSystem: "zigbee2mqtt",
76
+        sourceTopicMatch: "zigbee2mqtt/SNZB-05P/<site>/<location>/<device_id>",
77
+        sourceFields: ["battery_low", "batteryLow", "battery"],
78
+        targetBus: "home",
79
+        targetCapability: "battery_low",
80
+        core: true,
81
+        stream: "value",
82
+        payloadProfile: "scalar",
83
+        dataType: "boolean",
84
+        historianMode: "state",
85
+        historianEnabled: true,
86
+        read: function(payload) {
87
+          var raw = readBool(payload, this.sourceFields.slice(0, 2));
88
+          if (raw !== undefined) return raw;
89
+          var battery = readNumber(payload, this.sourceFields[2]);
90
+          if (battery === undefined) return undefined;
91
+          return battery <= node.batteryLowThreshold;
92
+        }
93
+      },
94
+      {
95
+        sourceSystem: "zigbee2mqtt",
96
+        sourceTopicMatch: "zigbee2mqtt/SNZB-05P/<site>/<location>/<device_id>",
97
+        sourceFields: ["tamper", "tampered", "tamper_alarm", "alarm_tamper"],
98
+        targetBus: "home",
99
+        targetCapability: "tamper",
100
+        core: false,
101
+        stream: "value",
102
+        payloadProfile: "scalar",
103
+        dataType: "boolean",
104
+        historianMode: "state",
105
+        historianEnabled: true,
106
+        read: function(payload) {
107
+          return readBool(payload, this.sourceFields);
108
+        }
109
+      }
110
+    ];
111
+
112
+    function normalizeToken(value) {
113
+      if (value === undefined || value === null) return "";
114
+      return String(value).trim();
115
+    }
116
+
117
+    function transliterate(value) {
118
+      var s = normalizeToken(value);
119
+      if (!s) return "";
120
+      if (typeof s.normalize === "function") {
121
+        s = s.normalize("NFKD").replace(/[\u0300-\u036f]/g, "");
122
+      }
123
+      return s;
124
+    }
125
+
126
+    function toKebabCase(value, fallback) {
127
+      var s = transliterate(value).toLowerCase().replace(/[^a-z0-9]+/g, "-");
128
+      s = s.replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-");
129
+      return s || fallback || "";
130
+    }
131
+
132
+    function clamp(n, min, max) {
133
+      return Math.max(min, Math.min(max, n));
134
+    }
135
+
136
+    function topicTokens(topic) {
137
+      if (typeof topic !== "string") return [];
138
+      return topic.split("/").map(function(token) { return token.trim(); }).filter(function(token) { return !!token; });
139
+    }
140
+
141
+    function asBool(value) {
142
+      if (typeof value === "boolean") return value;
143
+      if (typeof value === "number") return value !== 0;
144
+      if (typeof value === "string") {
145
+        var v = value.trim().toLowerCase();
146
+        if (v === "true" || v === "1" || v === "on" || v === "yes" || v === "online") return true;
147
+        if (v === "false" || v === "0" || v === "off" || v === "no" || v === "offline") return false;
148
+      }
149
+      return null;
150
+    }
151
+
152
+    function pickFirst(obj, keys) {
153
+      if (!obj || typeof obj !== "object") return undefined;
154
+      for (var i = 0; i < keys.length; i++) {
155
+        if (Object.prototype.hasOwnProperty.call(obj, keys[i])) return obj[keys[i]];
156
+      }
157
+      return undefined;
158
+    }
159
+
160
+    function pickPath(obj, path) {
161
+      if (!obj || typeof obj !== "object") return undefined;
162
+      var parts = path.split(".");
163
+      var current = obj;
164
+      for (var i = 0; i < parts.length; i++) {
165
+        if (!current || typeof current !== "object" || !Object.prototype.hasOwnProperty.call(current, parts[i])) return undefined;
166
+        current = current[parts[i]];
167
+      }
168
+      return current;
169
+    }
170
+
171
+    function pickFirstPath(obj, paths) {
172
+      for (var i = 0; i < paths.length; i++) {
173
+        var value = pickPath(obj, paths[i]);
174
+        if (value !== undefined && value !== null && normalizeToken(value) !== "") return value;
175
+      }
176
+      return undefined;
177
+    }
178
+
179
+    function readBool(payload, fields) {
180
+      var raw = asBool(pickFirst(payload, fields));
181
+      return raw === null ? undefined : raw;
182
+    }
183
+
184
+    function readNumber(payload, field) {
185
+      if (!payload || typeof payload !== "object") return undefined;
186
+      var value = payload[field];
187
+      if (typeof value !== "number" || !isFinite(value)) return undefined;
188
+      return Number(value);
189
+    }
190
+
191
+    function toIsoTimestamp(value) {
192
+      if (value === undefined || value === null) return "";
193
+      if (value instanceof Date && !isNaN(value.getTime())) return value.toISOString();
194
+      if (typeof value === "number" && isFinite(value)) {
195
+        var ms = value < 100000000000 ? value * 1000 : value;
196
+        var dateFromNumber = new Date(ms);
197
+        return isNaN(dateFromNumber.getTime()) ? "" : dateFromNumber.toISOString();
198
+      }
199
+      if (typeof value === "string") {
200
+        var trimmed = value.trim();
201
+        if (!trimmed) return "";
202
+        if (/^\d+$/.test(trimmed)) return toIsoTimestamp(Number(trimmed));
203
+        var dateFromString = new Date(trimmed);
204
+        return isNaN(dateFromString.getTime()) ? "" : dateFromString.toISOString();
205
+      }
206
+      return "";
207
+    }
208
+
209
+    function canonicalSourceTopic(topic) {
210
+      var t = normalizeToken(topic);
211
+      if (!t) return "";
212
+      if (/\/availability$/i.test(t)) return t.replace(/\/availability$/i, "");
213
+      return t;
214
+    }
215
+
216
+    function inferFromTopic(topic) {
217
+      var tokens = topicTokens(topic);
218
+      var result = {
219
+        deviceType: "",
220
+        site: "",
221
+        location: "",
222
+        deviceId: "",
223
+        friendlyName: "",
224
+        isAvailabilityTopic: false
225
+      };
226
+
227
+      if (tokens.length >= 2 && tokens[0].toLowerCase() === "zigbee2mqtt") {
228
+        result.deviceType = tokens[1];
229
+
230
+        if (tokens.length >= 6 && tokens[5].toLowerCase() === "availability") {
231
+          result.site = tokens[2];
232
+          result.location = tokens[3];
233
+          result.deviceId = tokens[4];
234
+          result.friendlyName = tokens.slice(1, 5).join("/");
235
+          result.isAvailabilityTopic = true;
236
+          return result;
237
+        }
238
+
239
+        if (tokens.length >= 5 && tokens[4].toLowerCase() !== "availability") {
240
+          result.site = tokens[2];
241
+          result.location = tokens[3];
242
+          result.deviceId = tokens[4];
243
+          result.friendlyName = tokens.slice(1, 5).join("/");
244
+          return result;
245
+        }
246
+
247
+        result.friendlyName = tokens.slice(1).join("/");
248
+        result.deviceId = tokens[1];
249
+        result.isAvailabilityTopic = tokens.length >= 3 && tokens[tokens.length - 1].toLowerCase() === "availability";
250
+        return result;
251
+      }
252
+
253
+      if (tokens.length >= 5 && tokens[1].toLowerCase() === "home") {
254
+        result.site = tokens[0];
255
+        result.location = tokens[2];
256
+        result.deviceId = tokens[4];
257
+        result.isAvailabilityTopic = tokens.length >= 6 && tokens[5].toLowerCase() === "availability";
258
+        return result;
259
+      }
260
+
261
+      if (tokens.length >= 4) {
262
+        result.site = tokens[0];
263
+        result.location = tokens[2];
264
+        result.deviceId = tokens[3];
265
+      } else if (tokens.length >= 2) {
266
+        result.location = tokens[tokens.length - 2];
267
+        result.deviceId = tokens[tokens.length - 1];
268
+      } else if (tokens.length === 1) {
269
+        result.deviceId = tokens[0];
270
+      }
271
+
272
+      return result;
273
+    }
274
+
275
+    function resolveIdentity(msg, payload) {
276
+      var safeMsg = (msg && typeof msg === "object") ? msg : {};
277
+      var inferred = inferFromTopic(safeMsg.topic);
278
+      var siteRaw = inferred.site
279
+        || normalizeToken(pickFirst(payload, ["site", "homeSite"]))
280
+        || normalizeToken(pickFirst(safeMsg, ["site", "homeSite"]))
281
+        || node.site
282
+        || "";
283
+
284
+      var locationRaw = inferred.location
285
+        || normalizeToken(pickFirst(payload, ["location", "room", "homeLocation", "mqttRoom"]))
286
+        || normalizeToken(pickFirst(safeMsg, ["location", "room", "homeLocation", "mqttRoom"]))
287
+        || node.legacyRoom
288
+        || "";
289
+
290
+      var deviceRaw = inferred.deviceId
291
+        || normalizeToken(pickFirst(payload, ["deviceId", "device_id", "sensor", "mqttSensor", "friendly_name", "friendlyName", "name", "device"]))
292
+        || normalizeToken(pickFirst(safeMsg, ["deviceId", "device_id", "sensor", "mqttSensor"]))
293
+        || node.legacySensor
294
+        || inferred.friendlyName
295
+        || inferred.deviceType;
296
+
297
+      var displayName = normalizeToken(pickFirst(payload, ["display_name", "displayName", "friendly_name", "friendlyName", "name"]))
298
+        || normalizeToken(pickFirst(safeMsg, ["display_name", "displayName", "friendly_name", "friendlyName", "name"]))
299
+        || inferred.friendlyName
300
+        || deviceRaw
301
+        || "SNZB-05P";
302
+
303
+      var sourceRef = normalizeToken(pickFirstPath(payload, ["source_ref", "sourceRef", "ieee_address", "ieeeAddr", "device.ieee_address", "device.ieeeAddr"]))
304
+        || normalizeToken(pickFirstPath(safeMsg, ["source_ref", "sourceRef", "ieee_address", "ieeeAddr", "device.ieee_address", "device.ieeeAddr"]))
305
+        || canonicalSourceTopic(safeMsg.topic)
306
+        || inferred.friendlyName
307
+        || inferred.deviceType
308
+        || deviceRaw
309
+        || "z2m-snzb-05p";
310
+
311
+      return {
312
+        site: toKebabCase(siteRaw, "unknown"),
313
+        location: toKebabCase(locationRaw, "unknown"),
314
+        deviceId: toKebabCase(deviceRaw, toKebabCase(inferred.deviceType, "snzb-05p")),
315
+        displayName: displayName,
316
+        sourceRef: sourceRef,
317
+        sourceTopic: canonicalSourceTopic(safeMsg.topic),
318
+        isAvailabilityTopic: inferred.isAvailabilityTopic
319
+      };
320
+    }
321
+
322
+    function noteDevice(identity) {
323
+      if (!identity) return;
324
+      var key = identity.site + "/" + identity.location + "/" + identity.deviceId;
325
+      if (node.detectedDevices[key]) return;
326
+      node.detectedDevices[key] = true;
327
+      node.stats.devices_detected += 1;
328
+    }
329
+
330
+    function validateInboundTopic(topic) {
331
+      var rawTopic = normalizeToken(topic);
332
+      if (!rawTopic) {
333
+        return {
334
+          valid: false,
335
+          reason: "Topic must be a non-empty string"
336
+        };
337
+      }
338
+
339
+      var tokens = rawTopic.split("/").map(function(token) {
340
+        return token.trim();
341
+      });
342
+      if (tokens.length < 5) {
343
+        return {
344
+          valid: false,
345
+          reason: "Topic must match zigbee2mqtt/SNZB-05P/<site>/<location>/<device_id>[/availability]"
346
+        };
347
+      }
348
+      if (tokens[0].toLowerCase() !== "zigbee2mqtt" || tokens[1].toLowerCase() !== "snzb-05p") {
349
+        return {
350
+          valid: false,
351
+          reason: "Topic must start with zigbee2mqtt/SNZB-05P"
352
+        };
353
+      }
354
+      if (!tokens[2] || !tokens[3] || !tokens[4]) {
355
+        return {
356
+          valid: false,
357
+          reason: "Topic must contain non-empty site, location and device_id segments"
358
+        };
359
+      }
360
+      if (tokens.length === 5) {
361
+        return {
362
+          valid: true,
363
+          isAvailabilityTopic: false
364
+        };
365
+      }
366
+      if (tokens.length === 6 && tokens[5].toLowerCase() === "availability") {
367
+        return {
368
+          valid: true,
369
+          isAvailabilityTopic: true
370
+        };
371
+      }
372
+      return {
373
+        valid: false,
374
+        reason: "Topic must not contain extra segments beyond an optional /availability suffix"
375
+      };
376
+    }
377
+
378
+    function translatedMessageCount() {
379
+      return node.stats.home_messages + node.stats.last_messages + node.stats.meta_messages + node.stats.home_availability_messages;
380
+    }
381
+
382
+    function updateNodeStatus(fill, shape, suffix) {
383
+      var parts = [
384
+        "dev " + node.stats.devices_detected,
385
+        "in " + node.stats.processed_inputs,
386
+        "tr " + translatedMessageCount()
387
+      ];
388
+      if (node.stats.operational_messages > 0) parts.push("op " + node.stats.operational_messages);
389
+      if (node.stats.errors > 0) parts.push("err " + node.stats.errors);
390
+      if (node.stats.invalid_topics > 0) parts.push("topic " + node.stats.invalid_topics);
391
+      if (node.stats.invalid_messages > 0) parts.push("msg " + node.stats.invalid_messages);
392
+      if (node.stats.invalid_payloads > 0) parts.push("payload " + node.stats.invalid_payloads);
393
+      if (node.stats.unmapped_messages > 0) parts.push("unmapped " + node.stats.unmapped_messages);
394
+      if (node.stats.adapter_exceptions > 0) parts.push("exc " + node.stats.adapter_exceptions);
395
+      if (node.stats.dlq > 0) parts.push("dlq " + node.stats.dlq);
396
+      if (suffix) parts.push(suffix);
397
+      node.status({
398
+        fill: fill,
399
+        shape: shape,
400
+        text: parts.join(" | ")
401
+      });
402
+    }
403
+
404
+    function buildHomeTopic(identity, mapping, stream) {
405
+      return identity.site + "/" + mapping.targetBus + "/" + identity.location + "/" + mapping.targetCapability + "/" + identity.deviceId + "/" + stream;
406
+    }
407
+
408
+    function buildSysTopic(site, stream) {
409
+      return site + "/sys/adapter/" + node.adapterId + "/" + stream;
410
+    }
411
+
412
+    function makePublishMsg(topic, payload, retain) {
413
+      return {
414
+        topic: topic,
415
+        payload: payload,
416
+        qos: 1,
417
+        retain: !!retain
418
+      };
419
+    }
420
+
421
+    function makeSubscribeMsg(topic) {
422
+      return {
423
+        action: "subscribe",
424
+        topic: [{
425
+          topic: topic,
426
+          qos: 2,
427
+          rh: 0,
428
+          rap: true
429
+        }]
430
+      };
431
+    }
432
+
433
+    function signature(value) {
434
+      return JSON.stringify(value);
435
+    }
436
+
437
+    function shouldPublishLiveValue(cacheKey, payload) {
438
+      var sig = signature(payload);
439
+      if (node.publishCache[cacheKey] === sig) return false;
440
+      node.publishCache[cacheKey] = sig;
441
+      return true;
442
+    }
443
+
444
+    function shouldPublishRetained(cacheKey, payload) {
445
+      var sig = signature(payload);
446
+      if (node.retainedCache[cacheKey] === sig) return false;
447
+      node.retainedCache[cacheKey] = sig;
448
+      return true;
449
+    }
450
+
451
+    function humanizeCapability(capability) {
452
+      return capability.replace(/_/g, " ").replace(/\b[a-z]/g, function(ch) { return ch.toUpperCase(); });
453
+    }
454
+
455
+    function resolveObservation(msg, payloadObject) {
456
+      var safeMsg = (msg && typeof msg === "object") ? msg : {};
457
+      var observedAtRaw = pickFirstPath(payloadObject, [
458
+        "observed_at",
459
+        "observedAt",
460
+        "timestamp",
461
+        "time",
462
+        "ts",
463
+        "last_seen",
464
+        "lastSeen",
465
+        "device.last_seen",
466
+        "device.lastSeen"
467
+      ]) || pickFirstPath(safeMsg, [
468
+        "observed_at",
469
+        "observedAt",
470
+        "timestamp",
471
+        "time",
472
+        "ts"
473
+      ]);
474
+      var observedAt = toIsoTimestamp(observedAtRaw);
475
+      if (observedAt) {
476
+        return {
477
+          observedAt: observedAt,
478
+          quality: "good"
479
+        };
480
+      }
481
+      return {
482
+        observedAt: new Date().toISOString(),
483
+        quality: "estimated"
484
+      };
485
+    }
486
+
487
+    function buildMetaPayload(identity, mapping) {
488
+      var payload = {
489
+        schema_ref: "mqbus.home.v1",
490
+        payload_profile: mapping.payloadProfile,
491
+        stream_payload_profiles: {
492
+          value: "scalar",
493
+          last: "envelope"
494
+        },
495
+        data_type: mapping.dataType,
496
+        adapter_id: node.adapterId,
497
+        source: mapping.sourceSystem,
498
+        source_ref: identity.sourceRef,
499
+        source_topic: identity.sourceTopic,
500
+        display_name: identity.displayName + " " + humanizeCapability(mapping.targetCapability),
501
+        tags: [mapping.sourceSystem, "snzb-05p", mapping.targetBus],
502
+        historian: {
503
+          enabled: !!mapping.historianEnabled,
504
+          mode: mapping.historianMode
505
+        }
506
+      };
507
+
508
+      if (mapping.unit) payload.unit = mapping.unit;
509
+      if (mapping.precision !== undefined) payload.precision = mapping.precision;
510
+
511
+      return payload;
512
+    }
513
+
514
+    function buildLastPayload(value, observation) {
515
+      var payload = {
516
+        value: value,
517
+        observed_at: observation.observedAt
518
+      };
519
+      if (observation.quality && observation.quality !== "good") payload.quality = observation.quality;
520
+      return payload;
521
+    }
522
+
523
+    function noteMessage(kind) {
524
+      if (!Object.prototype.hasOwnProperty.call(node.stats, kind)) return;
525
+      node.stats[kind] += 1;
526
+    }
527
+
528
+    function noteErrorKind(code) {
529
+      if (code === "invalid_message") {
530
+        noteMessage("invalid_messages");
531
+      } else if (code === "invalid_topic") {
532
+        noteMessage("invalid_topics");
533
+      } else if (code === "payload_not_object" || code === "invalid_availability_payload") {
534
+        noteMessage("invalid_payloads");
535
+      } else if (code === "no_mapped_fields") {
536
+        noteMessage("unmapped_messages");
537
+      } else if (code === "adapter_exception") {
538
+        noteMessage("adapter_exceptions");
539
+      }
540
+    }
541
+
542
+    function summarizeForLog(value) {
543
+      if (value === undefined) return "undefined";
544
+      if (value === null) return "null";
545
+      if (typeof value === "string") {
546
+        return value.length > 180 ? value.slice(0, 177) + "..." : value;
547
+      }
548
+      try {
549
+        var serialized = JSON.stringify(value);
550
+        if (serialized.length > 180) return serialized.slice(0, 177) + "...";
551
+        return serialized;
552
+      } catch (err) {
553
+        return String(value);
554
+      }
555
+    }
556
+
557
+    function logIssue(level, code, reason, msg, rawPayload) {
558
+      var parts = [
559
+        "[" + node.adapterId + "]",
560
+        code + ":",
561
+        reason
562
+      ];
563
+      var sourceTopic = msg && typeof msg === "object" ? normalizeToken(msg.topic) : "";
564
+      if (sourceTopic) parts.push("topic=" + sourceTopic);
565
+      if (rawPayload !== undefined) parts.push("payload=" + summarizeForLog(rawPayload));
566
+
567
+      var text = parts.join(" ");
568
+      if (level === "error") {
569
+        node.error(text, msg);
570
+        return;
571
+      }
572
+      node.warn(text);
573
+    }
574
+
575
+    function enqueueHomeMeta(messages, identity, mapping) {
576
+      var topic = buildHomeTopic(identity, mapping, "meta");
577
+      var payload = buildMetaPayload(identity, mapping);
578
+      if (!shouldPublishRetained("meta:" + topic, payload)) return;
579
+      messages.push(makePublishMsg(topic, payload, true));
580
+      noteMessage("meta_messages");
581
+    }
582
+
583
+    function enqueueHomeAvailability(messages, identity, mapping, online) {
584
+      var topic = buildHomeTopic(identity, mapping, "availability");
585
+      var payload = online ? "online" : "offline";
586
+      if (!shouldPublishRetained("availability:" + topic, payload)) return;
587
+      messages.push(makePublishMsg(topic, payload, true));
588
+      noteMessage("home_availability_messages");
589
+    }
590
+
591
+    function enqueueHomeLast(messages, identity, mapping, value, observation) {
592
+      var topic = buildHomeTopic(identity, mapping, "last");
593
+      var payload = buildLastPayload(value, observation);
594
+      if (!shouldPublishRetained("last:" + topic, payload)) return;
595
+      messages.push(makePublishMsg(topic, payload, true));
596
+      noteMessage("last_messages");
597
+    }
598
+
599
+    function enqueueHomeValue(messages, identity, mapping, value) {
600
+      var topic = buildHomeTopic(identity, mapping, "value");
601
+      if (!shouldPublishLiveValue("value:" + topic, value)) return;
602
+      messages.push(makePublishMsg(topic, value, false));
603
+      noteMessage("home_messages");
604
+    }
605
+
606
+    function enqueueAdapterAvailability(messages, site, online) {
607
+      var topic = buildSysTopic(site, "availability");
608
+      var payload = online ? "online" : "offline";
609
+      if (!shouldPublishRetained("sys:availability:" + topic, payload)) return;
610
+      messages.push(makePublishMsg(topic, payload, true));
611
+      noteMessage("operational_messages");
612
+    }
613
+
614
+    function enqueueAdapterStats(messages, site, force) {
615
+      if (!force && node.stats.processed_inputs !== 1 && (node.stats.processed_inputs % node.statsPublishEvery) !== 0) return;
616
+      var topic = buildSysTopic(site, "stats");
617
+      var payload = {
618
+        processed_inputs: node.stats.processed_inputs,
619
+        devices_detected: node.stats.devices_detected,
620
+        translated_messages: translatedMessageCount(),
621
+        home_messages: node.stats.home_messages,
622
+        last_messages: node.stats.last_messages,
623
+        meta_messages: node.stats.meta_messages,
624
+        home_availability_messages: node.stats.home_availability_messages,
625
+        operational_messages: node.stats.operational_messages,
626
+        invalid_messages: node.stats.invalid_messages,
627
+        invalid_topics: node.stats.invalid_topics,
628
+        invalid_payloads: node.stats.invalid_payloads,
629
+        unmapped_messages: node.stats.unmapped_messages,
630
+        adapter_exceptions: node.stats.adapter_exceptions,
631
+        errors: node.stats.errors,
632
+        dlq: node.stats.dlq
633
+      };
634
+      messages.push(makePublishMsg(topic, payload, true));
635
+      noteMessage("operational_messages");
636
+    }
637
+
638
+    function enqueueError(messages, site, code, reason, sourceTopic) {
639
+      var payload = {
640
+        code: code,
641
+        reason: reason,
642
+        source_topic: normalizeToken(sourceTopic),
643
+        adapter_id: node.adapterId
644
+      };
645
+      messages.push(makePublishMsg(buildSysTopic(site, "error"), payload, false));
646
+      noteErrorKind(code);
647
+      noteMessage("errors");
648
+      noteMessage("operational_messages");
649
+    }
650
+
651
+    function enqueueDlq(messages, site, code, sourceTopic, rawPayload) {
652
+      var payload = {
653
+        code: code,
654
+        source_topic: normalizeToken(sourceTopic),
655
+        payload: rawPayload
656
+      };
657
+      messages.push(makePublishMsg(buildSysTopic(site, "dlq"), payload, false));
658
+      noteMessage("dlq");
659
+      noteMessage("operational_messages");
660
+    }
661
+
662
+    function activeMappings(payloadObject) {
663
+      var result = [];
664
+      for (var i = 0; i < CAPABILITY_MAPPINGS.length; i++) {
665
+        var mapping = CAPABILITY_MAPPINGS[i];
666
+        if (typeof mapping.applies === "function" && !mapping.applies(payloadObject)) continue;
667
+        var value = payloadObject ? mapping.read(payloadObject) : undefined;
668
+        var seen = mapping.core || node.seenOptionalCapabilities[mapping.targetCapability];
669
+        if (value !== undefined) node.seenOptionalCapabilities[mapping.targetCapability] = true;
670
+        if (mapping.core || seen || value !== undefined) {
671
+          result.push({
672
+            mapping: mapping,
673
+            hasValue: value !== undefined,
674
+            value: value
675
+          });
676
+        }
677
+      }
678
+      return result;
679
+    }
680
+
681
+    function resolveOnlineState(payloadObject, availabilityValue) {
682
+      if (availabilityValue !== null && availabilityValue !== undefined) return !!availabilityValue;
683
+      if (!payloadObject) return true;
684
+      var active = true;
685
+      if (typeof payloadObject.availability === "string") active = payloadObject.availability.trim().toLowerCase() !== "offline";
686
+      if (typeof payloadObject.online === "boolean") active = payloadObject.online;
687
+      return active;
688
+    }
689
+
690
+    function flush(send, done, controlMessages, publishMessages, error) {
691
+      send([
692
+        publishMessages.length ? publishMessages : null,
693
+        controlMessages.length ? controlMessages : null
694
+      ]);
695
+      if (done) done(error);
696
+    }
697
+
698
+    function startSubscriptions() {
699
+      if (node.subscriptionStarted) return;
700
+      node.subscriptionStarted = true;
701
+      node.send([null, makeSubscribeMsg(node.sourceTopic)]);
702
+      updateNodeStatus("grey", "dot", "subscribed");
703
+    }
704
+
705
+    node.on("input", function(msg, send, done) {
706
+      send = send || function() { node.send.apply(node, arguments); };
707
+      node.stats.processed_inputs += 1;
708
+
709
+      try {
710
+        var controlMessages = [];
711
+        if (!msg || typeof msg !== "object") {
712
+          var invalidMessages = [];
713
+          var invalidSite = resolveIdentity({}, null).site;
714
+          enqueueAdapterAvailability(invalidMessages, invalidSite, true);
715
+          enqueueError(invalidMessages, invalidSite, "invalid_message", "Input must be an object", "");
716
+          enqueueDlq(invalidMessages, invalidSite, "invalid_message", "", null);
717
+          enqueueAdapterStats(invalidMessages, invalidSite, true);
718
+          logIssue("warn", "invalid_message", "Input must be an object", null, msg);
719
+          updateNodeStatus("yellow", "ring", "bad msg");
720
+          flush(send, done, controlMessages, invalidMessages);
721
+          return;
722
+        }
723
+
724
+        var payload = msg.payload;
725
+        var payloadObject = payload && typeof payload === "object" && !Array.isArray(payload) ? payload : null;
726
+        var identity = resolveIdentity(msg, payloadObject);
727
+        var observation = resolveObservation(msg, payloadObject);
728
+        var messages = [];
729
+        var topicValidation = validateInboundTopic(msg.topic);
730
+        var availabilityValue = null;
731
+
732
+        enqueueAdapterAvailability(messages, identity.site, true);
733
+
734
+        if (!topicValidation.valid) {
735
+          enqueueError(messages, identity.site, "invalid_topic", topicValidation.reason, msg.topic);
736
+          enqueueDlq(messages, identity.site, "invalid_topic", msg.topic, payload);
737
+          enqueueAdapterStats(messages, identity.site, true);
738
+          logIssue("warn", "invalid_topic", topicValidation.reason, msg, payload);
739
+          updateNodeStatus("yellow", "ring", "bad topic");
740
+          flush(send, done, controlMessages, messages);
741
+          return;
742
+        }
743
+
744
+        identity.isAvailabilityTopic = topicValidation.isAvailabilityTopic;
745
+        noteDevice(identity);
746
+
747
+        if (topicValidation.isAvailabilityTopic) {
748
+          availabilityValue = asBool(payload);
749
+          if (availabilityValue === null) {
750
+            enqueueError(messages, identity.site, "invalid_availability_payload", "Availability payload must be online/offline or boolean", msg.topic);
751
+            enqueueDlq(messages, identity.site, "invalid_availability_payload", msg.topic, payload);
752
+            enqueueAdapterStats(messages, identity.site, true);
753
+            logIssue("warn", "invalid_availability_payload", "Availability payload must be online/offline or boolean", msg, payload);
754
+            updateNodeStatus("yellow", "ring", "invalid availability");
755
+            flush(send, done, controlMessages, messages);
756
+            return;
757
+          }
758
+        } else if (!payloadObject) {
759
+          enqueueError(messages, identity.site, "payload_not_object", "Telemetry payload must be an object", msg.topic);
760
+          enqueueDlq(messages, identity.site, "payload_not_object", msg.topic, payload);
761
+          enqueueAdapterStats(messages, identity.site, true);
762
+          logIssue("warn", "payload_not_object", "Telemetry payload must be an object", msg, payload);
763
+          updateNodeStatus("yellow", "ring", "payload not object");
764
+          flush(send, done, controlMessages, messages);
765
+          return;
766
+        }
767
+
768
+        var online = resolveOnlineState(payloadObject, availabilityValue);
769
+        var mappings = activeMappings(payloadObject);
770
+        var hasMappedValue = false;
771
+        for (var i = 0; i < mappings.length; i++) {
772
+          if (mappings[i].hasValue) {
773
+            hasMappedValue = true;
774
+            break;
775
+          }
776
+        }
777
+        var hasAvailabilityField = topicValidation.isAvailabilityTopic || (payloadObject && (typeof payloadObject.availability === "string" || typeof payloadObject.online === "boolean"));
778
+
779
+        if (!hasMappedValue && !hasAvailabilityField) {
780
+          enqueueError(messages, identity.site, "no_mapped_fields", "Payload did not contain any supported SNZB-05P fields", msg.topic);
781
+          enqueueDlq(messages, identity.site, "no_mapped_fields", msg.topic, payloadObject);
782
+          logIssue("warn", "no_mapped_fields", "Payload did not contain any supported SNZB-05P fields", msg, payloadObject);
783
+        }
784
+
785
+        for (var j = 0; j < mappings.length; j++) {
786
+          enqueueHomeMeta(messages, identity, mappings[j].mapping);
787
+          enqueueHomeAvailability(messages, identity, mappings[j].mapping, online);
788
+          if (mappings[j].hasValue) {
789
+            enqueueHomeLast(messages, identity, mappings[j].mapping, mappings[j].value, observation);
790
+            enqueueHomeValue(messages, identity, mappings[j].mapping, mappings[j].value);
791
+          }
792
+        }
793
+
794
+        enqueueAdapterStats(messages, identity.site, false);
795
+
796
+        if (!hasMappedValue && !hasAvailabilityField) {
797
+          updateNodeStatus("yellow", "ring", "unmapped");
798
+        } else {
799
+          updateNodeStatus(online ? "green" : "yellow", online ? "dot" : "ring", online ? null : "offline");
800
+        }
801
+
802
+        flush(send, done, controlMessages, messages);
803
+      } catch (err) {
804
+        var errorPayload = msg && msg.payload && typeof msg.payload === "object" && !Array.isArray(msg.payload) ? msg.payload : null;
805
+        var errorIdentity = resolveIdentity(msg, errorPayload);
806
+        noteDevice(errorIdentity);
807
+        var errorMessages = [];
808
+        enqueueAdapterAvailability(errorMessages, errorIdentity.site, true);
809
+        enqueueError(errorMessages, errorIdentity.site, "adapter_exception", err.message, msg && msg.topic);
810
+        enqueueAdapterStats(errorMessages, errorIdentity.site, true);
811
+        logIssue("error", "adapter_exception", err.message, msg, msg && msg.payload);
812
+        updateNodeStatus("red", "ring", "error");
813
+        flush(send, done, [], errorMessages, err);
814
+      }
815
+    });
816
+
817
+    node.on("close", function() {
818
+      if (node.startTimer) {
819
+        clearTimeout(node.startTimer);
820
+        node.startTimer = null;
821
+      }
822
+    });
823
+
824
+    node.startTimer = setTimeout(startSubscriptions, 250);
825
+    updateNodeStatus("grey", "ring", "waiting");
826
+  }
827
+
828
+  RED.nodes.registerType("z2m-snzb-05p-homebus", Z2MSNZB05PHomeBusNode);
829
+};
+22 -0
adapters/water-leak-sensor/homekit-adapter/package.json
@@ -0,0 +1,22 @@
1
+{
2
+  "name": "node-red-contrib-z2m-snzb-05p",
3
+  "version": "0.0.1",
4
+  "description": "Node-RED node that consumes HomeBus SONOFF SNZB-05P streams and projects them to HomeKit using dynamic MQTT subscriptions",
5
+  "main": "z2m-snzb-05p.js",
6
+  "keywords": [
7
+    "node-red",
8
+    "zigbee2mqtt",
9
+    "homebus",
10
+    "water",
11
+    "leak",
12
+    "homekit",
13
+    "battery"
14
+  ],
15
+  "author": "",
16
+  "license": "MIT",
17
+  "node-red": {
18
+    "nodes": {
19
+      "snzb-05p-homekit-adapter": "z2m-snzb-05p.js"
20
+    }
21
+  }
22
+}
+110 -0
adapters/water-leak-sensor/homekit-adapter/z2m-snzb-05p.html
@@ -0,0 +1,110 @@
1
+<script type="text/x-red" data-template-name="snzb-05p-homekit-adapter">
2
+  <div class="form-row">
3
+    <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
4
+    <input type="text" id="node-input-name" placeholder="Name">
5
+  </div>
6
+  <div class="form-row">
7
+    <label for="node-input-site"><i class="fa fa-globe"></i> Site</label>
8
+    <input type="text" id="node-input-site" placeholder="vad">
9
+  </div>
10
+  <div class="form-row">
11
+    <label for="node-input-location"><i class="fa fa-home"></i> Location</label>
12
+    <input type="text" id="node-input-location" placeholder="bathroom">
13
+  </div>
14
+  <div class="form-row">
15
+    <label for="node-input-accessory"><i class="fa fa-dot-circle-o"></i> Accessory</label>
16
+    <input type="text" id="node-input-accessory" placeholder="sink-sensor">
17
+  </div>
18
+  <div class="form-row">
19
+    <label for="node-input-batteryLowThreshold">Battery low threshold (%)</label>
20
+    <input type="number" id="node-input-batteryLowThreshold" min="0" max="100" placeholder="20">
21
+  </div>
22
+</script>
23
+
24
+<script type="text/x-red" data-help-name="snzb-05p-homekit-adapter">
25
+  <p>
26
+    Consumes HomeBus telemetry for a single <code>SNZB-05P</code> endpoint and projects it to HomeKit services.
27
+  </p>
28
+  <p>
29
+    Output 3 is not a publish stream anymore. It controls a Node-RED <code>mqtt in</code> node with dynamic subscriptions.
30
+  </p>
31
+  <p>
32
+    On start, the node emits:
33
+    <code>subscribe &lt;site&gt;/home/&lt;location&gt;/+/&lt;accessory&gt;/last</code>,
34
+    <code>subscribe &lt;site&gt;/home/&lt;location&gt;/+/&lt;accessory&gt;/value</code>, and
35
+    <code>subscribe &lt;site&gt;/home/&lt;location&gt;/+/&lt;accessory&gt;/availability</code>.
36
+  </p>
37
+  <p>
38
+    The node keeps <code>.../last</code> subscribed until the cold-start bootstrap is complete for all required values.
39
+    A value is considered satisfied when it arrived either from <code>last</code> or from live <code>value</code>.
40
+    Only then does the node emit <code>unsubscribe .../last</code>.
41
+  </p>
42
+  <p>
43
+    Supported input topic shape:
44
+    <code>&lt;site&gt;/home/&lt;location&gt;/&lt;capability&gt;/&lt;accessory&gt;/value</code>,
45
+    <code>&lt;site&gt;/home/&lt;location&gt;/&lt;capability&gt;/&lt;accessory&gt;/last</code>, and
46
+    <code>&lt;site&gt;/home/&lt;location&gt;/&lt;capability&gt;/&lt;accessory&gt;/availability</code>.
47
+  </p>
48
+  <p>
49
+    The node accepts scalar <code>value</code> payloads, string/boolean <code>availability</code> payloads,
50
+    and timestamped <code>last</code> envelopes of the form
51
+    <code>{ value, observed_at, quality }</code>. For <code>last</code>, only <code>payload.value</code> is used.
52
+  </p>
53
+  <h3>Capabilities</h3>
54
+  <p>
55
+    Mappings:
56
+    <code>water_leak -&gt; Leak Sensor</code>,
57
+    <code>battery + battery_low -&gt; Battery</code>.
58
+    Optional <code>tamper</code> updates are folded into HomeKit status flags when available.
59
+  </p>
60
+  <p>
61
+    Subscription topics are generated from the configured <code>site</code>, <code>location</code>, and
62
+    <code>accessory</code>. The bus segment remains fixed to <code>home</code>.
63
+  </p>
64
+  <h3>Outputs</h3>
65
+  <ol>
66
+    <li>Leak Sensor: <code>{ LeakDetected, StatusActive, StatusFault, StatusLowBattery, StatusTampered }</code></li>
67
+    <li>Battery Service: <code>{ StatusLowBattery, BatteryLevel, ChargingState }</code></li>
68
+    <li><code>mqtt in</code> dynamic control messages: <code>{ action, topic }</code></li>
69
+  </ol>
70
+  <h3>HomeKit Service Setup</h3>
71
+  <p>
72
+    The linked <code>homekit-service</code> nodes should explicitly materialize the optional characteristics used by this adapter.
73
+    Set the Battery service node <code>characteristicProperties</code> to:
74
+  </p>
75
+  <pre><code>{"BatteryLevel":{},"ChargingState":{}}</code></pre>
76
+  <p>
77
+    Without these optional characteristics, HomeKit may keep default values until a later update or ignore the fields during startup.
78
+  </p>
79
+</script>
80
+
81
+<script>
82
+  function requiredText(v) {
83
+    return !!(v && String(v).trim());
84
+  }
85
+
86
+  RED.nodes.registerType("snzb-05p-homekit-adapter", {
87
+    category: "myNodes",
88
+    color: "#d7f0d1",
89
+    defaults: {
90
+      name: { value: "" },
91
+      outputTopic: { value: "" },
92
+      mqttBus: { value: "" },
93
+      site: { value: "", validate: requiredText },
94
+      location: { value: "", validate: requiredText },
95
+      accessory: { value: "", validate: requiredText },
96
+      mqttSite: { value: "" },
97
+      mqttRoom: { value: "" },
98
+      mqttSensor: { value: "" },
99
+      batteryLowThreshold: { value: 20, validate: RED.validators.number() },
100
+      publishCacheWindowSec: { value: "" }
101
+    },
102
+    inputs: 1,
103
+    outputs: 3,
104
+    icon: "font-awesome/fa-tint",
105
+    label: function() {
106
+      return this.name || "snzb-05p-homekit-adapter";
107
+    },
108
+    paletteLabel: "snzb-05p homekit"
109
+  });
110
+</script>
+453 -0
adapters/water-leak-sensor/homekit-adapter/z2m-snzb-05p.js
@@ -0,0 +1,453 @@
1
+module.exports = function(RED) {
2
+  function Z2MSNZB05PNode(config) {
3
+    RED.nodes.createNode(this, config);
4
+    var node = this;
5
+
6
+    node.site = normalizeToken(config.site || config.mqttSite || "");
7
+    node.location = normalizeToken(config.location || config.mqttRoom || "");
8
+    node.accessory = normalizeLegacyDeviceId(normalizeToken(config.accessory || config.mqttSensor || ""));
9
+    node.batteryLowThreshold = parseNumber(config.batteryLowThreshold, 20, 0);
10
+    node.bootstrapDeadlineMs = 10000;
11
+    node.hkCache = Object.create(null);
12
+    node.startTimer = null;
13
+    node.bootstrapTimer = null;
14
+    node.lastMsgContext = null;
15
+
16
+    node.stats = {
17
+      controls: 0,
18
+      last_inputs: 0,
19
+      value_inputs: 0,
20
+      availability_inputs: 0,
21
+      hk_updates: 0,
22
+      errors: 0
23
+    };
24
+
25
+    node.subscriptionState = {
26
+      started: false,
27
+      lastSubscribed: false,
28
+      valueSubscribed: false,
29
+      availabilitySubscribed: false
30
+    };
31
+
32
+    node.bootstrapState = {
33
+      finalized: false,
34
+      waterLeak: false,
35
+      battery: false
36
+    };
37
+
38
+    node.sensorState = {
39
+      active: false,
40
+      site: node.site || "",
41
+      location: node.location || "",
42
+      deviceId: node.accessory || "",
43
+      leakKnown: false,
44
+      leak: false,
45
+      batteryKnown: false,
46
+      battery: null,
47
+      batteryLowKnown: false,
48
+      batteryLow: false,
49
+      tamperedKnown: false,
50
+      tampered: false
51
+    };
52
+
53
+    function parseNumber(value, fallback, min) {
54
+      var n = Number(value);
55
+      if (!Number.isFinite(n)) return fallback;
56
+      if (typeof min === "number" && n < min) return fallback;
57
+      return n;
58
+    }
59
+
60
+    function asBool(value) {
61
+      if (typeof value === "boolean") return value;
62
+      if (typeof value === "number") return value !== 0;
63
+      if (typeof value === "string") {
64
+        var v = value.trim().toLowerCase();
65
+        if (v === "true" || v === "1" || v === "on" || v === "yes" || v === "online") return true;
66
+        if (v === "false" || v === "0" || v === "off" || v === "no" || v === "offline") return false;
67
+      }
68
+      return null;
69
+    }
70
+
71
+    function asNumber(value) {
72
+      if (typeof value === "number" && isFinite(value)) return value;
73
+      if (typeof value === "string") {
74
+        var trimmed = value.trim();
75
+        if (!trimmed) return null;
76
+        var parsed = Number(trimmed);
77
+        if (isFinite(parsed)) return parsed;
78
+      }
79
+      return null;
80
+    }
81
+
82
+    function clamp(n, min, max) {
83
+      return Math.max(min, Math.min(max, n));
84
+    }
85
+
86
+    function normalizeToken(value) {
87
+      if (value === undefined || value === null) return "";
88
+      return String(value).trim();
89
+    }
90
+
91
+    function normalizeLegacyDeviceId(value) {
92
+      return value;
93
+    }
94
+
95
+    function signature(value) {
96
+      return JSON.stringify(value);
97
+    }
98
+
99
+    function shouldPublish(cacheKey, payload) {
100
+      var sig = signature(payload);
101
+      if (node.hkCache[cacheKey] === sig) return false;
102
+      node.hkCache[cacheKey] = sig;
103
+      return true;
104
+    }
105
+
106
+    function cloneBaseMsg(msg) {
107
+      if (!msg || typeof msg !== "object") return {};
108
+      var out = {};
109
+      if (typeof msg.topic === "string") out.topic = msg.topic;
110
+      if (msg._msgid) out._msgid = msg._msgid;
111
+      return out;
112
+    }
113
+
114
+    function buildSubscriptionTopic(stream) {
115
+      return [
116
+        node.site,
117
+        "home",
118
+        node.location,
119
+        "+",
120
+        node.accessory,
121
+        stream
122
+      ].join("/");
123
+    }
124
+
125
+    function buildSubscribeMsgs() {
126
+      return [
127
+        {
128
+          action: "subscribe",
129
+          topic: buildSubscriptionTopic("last"),
130
+          qos: 2,
131
+          rh: 0,
132
+          rap: true
133
+        },
134
+        {
135
+          action: "subscribe",
136
+          topic: buildSubscriptionTopic("value"),
137
+          qos: 2,
138
+          rh: 0,
139
+          rap: true
140
+        },
141
+        {
142
+          action: "subscribe",
143
+          topic: buildSubscriptionTopic("availability"),
144
+          qos: 2,
145
+          rh: 0,
146
+          rap: true
147
+        }
148
+      ];
149
+    }
150
+
151
+    function buildUnsubscribeLastMsg(reason) {
152
+      return {
153
+        action: "unsubscribe",
154
+        topic: buildSubscriptionTopic("last"),
155
+        reason: reason
156
+      };
157
+    }
158
+
159
+    function statusText(prefix) {
160
+      var state = node.subscriptionState.lastSubscribed ? "cold" : (node.subscriptionState.started ? "live" : "idle");
161
+      var device = node.sensorState.deviceId || node.accessory || "?";
162
+      return [
163
+        prefix || state,
164
+        device,
165
+        "l:" + node.stats.last_inputs,
166
+        "v:" + node.stats.value_inputs,
167
+        "a:" + node.stats.availability_inputs,
168
+        "hk:" + node.stats.hk_updates
169
+      ].join(" ");
170
+    }
171
+
172
+    function setNodeStatus(prefix, fill, shape) {
173
+      node.status({
174
+        fill: fill || (node.stats.errors ? "red" : (node.subscriptionState.lastSubscribed ? "yellow" : (node.sensorState.active ? "green" : "yellow"))),
175
+        shape: shape || "dot",
176
+        text: statusText(prefix)
177
+      });
178
+    }
179
+
180
+    function noteError(text, msg) {
181
+      node.stats.errors += 1;
182
+      node.warn(text);
183
+      node.status({ fill: "red", shape: "ring", text: text });
184
+      if (msg) node.debug(msg);
185
+    }
186
+
187
+    function makeHomeKitMsg(baseMsg, payload) {
188
+      var out = RED.util.cloneMessage(baseMsg || {});
189
+      out.payload = payload;
190
+      return out;
191
+    }
192
+
193
+    function clearBootstrapTimer() {
194
+      if (!node.bootstrapTimer) return;
195
+      clearTimeout(node.bootstrapTimer);
196
+      node.bootstrapTimer = null;
197
+    }
198
+
199
+    function buildStatusFields() {
200
+      return {
201
+        StatusActive: !!node.sensorState.active,
202
+        StatusFault: node.sensorState.active ? 0 : 1,
203
+        StatusLowBattery: node.sensorState.batteryLow ? 1 : 0,
204
+        StatusTampered: node.sensorState.tampered ? 1 : 0
205
+      };
206
+    }
207
+
208
+    function buildLeakMsg(baseMsg) {
209
+      if (!node.sensorState.leakKnown) return null;
210
+      var payload = buildStatusFields();
211
+      payload.LeakDetected = node.sensorState.leak ? 1 : 0;
212
+      if (!shouldPublish("hk:leak", payload)) return null;
213
+      node.stats.hk_updates += 1;
214
+      return makeHomeKitMsg(baseMsg, payload);
215
+    }
216
+
217
+    function buildBatteryMsg(baseMsg) {
218
+      if (!node.sensorState.batteryKnown && !node.sensorState.batteryLowKnown) return null;
219
+      var batteryLevel = node.sensorState.batteryKnown
220
+        ? clamp(Math.round(Number(node.sensorState.battery)), 0, 100)
221
+        : (node.sensorState.batteryLow ? 1 : 100);
222
+      var payload = {
223
+        ChargingState: 2,
224
+        BatteryLevel: batteryLevel,
225
+        StatusLowBattery: node.sensorState.batteryLow ? 1 : 0
226
+      };
227
+      if (!shouldPublish("hk:battery", payload)) return null;
228
+      node.stats.hk_updates += 1;
229
+      return makeHomeKitMsg(baseMsg, payload);
230
+    }
231
+
232
+    function clearSnapshotCache() {
233
+      delete node.hkCache["hk:leak"];
234
+      delete node.hkCache["hk:battery"];
235
+    }
236
+
237
+    function buildBootstrapOutputs(baseMsg) {
238
+      clearSnapshotCache();
239
+      return [
240
+        buildLeakMsg(baseMsg),
241
+        buildBatteryMsg(baseMsg)
242
+      ];
243
+    }
244
+
245
+    function unsubscribeLast(reason, send) {
246
+      if (!node.subscriptionState.lastSubscribed) return null;
247
+      node.subscriptionState.lastSubscribed = false;
248
+      node.stats.controls += 1;
249
+      var controlMsg = buildUnsubscribeLastMsg(reason);
250
+      if (typeof send === "function") {
251
+        send([null, null, controlMsg]);
252
+      }
253
+      return controlMsg;
254
+    }
255
+
256
+    function markBootstrapSatisfied(capability) {
257
+      if (capability === "water_leak" && node.sensorState.leakKnown) {
258
+        node.bootstrapState.waterLeak = true;
259
+      } else if ((capability === "battery" || capability === "battery_low") && (node.sensorState.batteryKnown || node.sensorState.batteryLowKnown)) {
260
+        node.bootstrapState.battery = true;
261
+      }
262
+    }
263
+
264
+    function isBootstrapComplete() {
265
+      return node.bootstrapState.waterLeak && node.bootstrapState.battery;
266
+    }
267
+
268
+    function finalizeBootstrap(reason, send) {
269
+      if (node.bootstrapState.finalized) return false;
270
+      if (!node.subscriptionState.lastSubscribed) return false;
271
+      node.bootstrapState.finalized = true;
272
+      clearBootstrapTimer();
273
+      send = send || function(msgs) { node.send(msgs); };
274
+      var outputs = buildBootstrapOutputs(cloneBaseMsg(node.lastMsgContext));
275
+      var controlMsg = unsubscribeLast(reason);
276
+      send([outputs[0], outputs[1], controlMsg]);
277
+      setNodeStatus("live");
278
+      return true;
279
+    }
280
+
281
+    function parseTopic(topic) {
282
+      if (typeof topic !== "string") return null;
283
+      var tokens = topic.split("/").map(function(token) {
284
+        return token.trim();
285
+      }).filter(function(token) {
286
+        return !!token;
287
+      });
288
+      if (tokens.length !== 6) return null;
289
+      if (tokens[1] !== "home") return null;
290
+      if (tokens[5] !== "value" && tokens[5] !== "last" && tokens[5] !== "availability") {
291
+        return { ignored: true };
292
+      }
293
+      if ((node.site && tokens[0] !== node.site) || (node.location && tokens[2] !== node.location) || (node.accessory && tokens[4] !== node.accessory)) {
294
+        return { ignored: true };
295
+      }
296
+      return {
297
+        site: tokens[0],
298
+        location: tokens[2],
299
+        capability: tokens[3],
300
+        deviceId: tokens[4],
301
+        stream: tokens[5]
302
+      };
303
+    }
304
+
305
+    function extractValue(stream, payload) {
306
+      if (stream === "last" && payload && typeof payload === "object" && !Array.isArray(payload) && Object.prototype.hasOwnProperty.call(payload, "value")) {
307
+        return payload.value;
308
+      }
309
+      return payload;
310
+    }
311
+
312
+    function updateBatteryLowFromThreshold() {
313
+      if (!node.sensorState.batteryKnown || node.sensorState.batteryLowKnown) return;
314
+      node.sensorState.batteryLow = Number(node.sensorState.battery) <= node.batteryLowThreshold;
315
+    }
316
+
317
+    function processAvailability(baseMsg, value) {
318
+      var active = asBool(value);
319
+      if (active === null) return [null, null];
320
+      node.sensorState.active = active;
321
+      node.lastMsgContext = cloneBaseMsg(baseMsg);
322
+      return [
323
+        buildLeakMsg(baseMsg),
324
+        buildBatteryMsg(baseMsg)
325
+      ];
326
+    }
327
+
328
+    function processCapability(baseMsg, parsed, value) {
329
+      var leakMsg = null;
330
+      var batteryMsg = null;
331
+
332
+      node.sensorState.active = true;
333
+      node.sensorState.site = parsed.site;
334
+      node.sensorState.location = parsed.location;
335
+      node.sensorState.deviceId = parsed.deviceId;
336
+      node.lastMsgContext = cloneBaseMsg(baseMsg);
337
+
338
+      if (parsed.capability === "water_leak") {
339
+        var leak = asBool(value);
340
+        if (leak === null) return [null, null];
341
+        node.sensorState.leakKnown = true;
342
+        node.sensorState.leak = leak;
343
+        leakMsg = buildLeakMsg(baseMsg);
344
+      } else if (parsed.capability === "battery") {
345
+        var battery = asNumber(value);
346
+        if (battery === null) return [null, null];
347
+        node.sensorState.batteryKnown = true;
348
+        node.sensorState.battery = clamp(Math.round(battery), 0, 100);
349
+        updateBatteryLowFromThreshold();
350
+        batteryMsg = buildBatteryMsg(baseMsg);
351
+        leakMsg = buildLeakMsg(baseMsg);
352
+      } else if (parsed.capability === "battery_low") {
353
+        var batteryLow = asBool(value);
354
+        if (batteryLow === null) return [null, null];
355
+        node.sensorState.batteryLowKnown = true;
356
+        node.sensorState.batteryLow = batteryLow;
357
+        batteryMsg = buildBatteryMsg(baseMsg);
358
+        leakMsg = buildLeakMsg(baseMsg);
359
+      } else if (parsed.capability === "tamper") {
360
+        var tampered = asBool(value);
361
+        if (tampered === null) return [null, null];
362
+        node.sensorState.tamperedKnown = true;
363
+        node.sensorState.tampered = tampered;
364
+        leakMsg = buildLeakMsg(baseMsg);
365
+        batteryMsg = buildBatteryMsg(baseMsg);
366
+      } else {
367
+        return [null, null];
368
+      }
369
+
370
+      return [leakMsg, batteryMsg];
371
+    }
372
+
373
+    function startSubscriptions() {
374
+      if (node.subscriptionState.started) return;
375
+      if (!node.site || !node.location || !node.accessory) {
376
+        noteError("missing site, location or accessory");
377
+        return;
378
+      }
379
+      node.subscriptionState.started = true;
380
+      node.subscriptionState.lastSubscribed = true;
381
+      node.subscriptionState.valueSubscribed = true;
382
+      node.subscriptionState.availabilitySubscribed = true;
383
+      clearBootstrapTimer();
384
+      node.bootstrapTimer = setTimeout(function() {
385
+        finalizeBootstrap("bootstrap-timeout");
386
+      }, node.bootstrapDeadlineMs);
387
+      node.stats.controls += 1;
388
+      node.send([null, null, buildSubscribeMsgs()]);
389
+      setNodeStatus("cold");
390
+    }
391
+
392
+    node.on("input", function(msg, send, done) {
393
+      send = send || function() { node.send.apply(node, arguments); };
394
+
395
+      try {
396
+        var parsed = parseTopic(msg && msg.topic);
397
+        if (!parsed) {
398
+          noteError("invalid topic");
399
+          if (done) done();
400
+          return;
401
+        }
402
+        if (parsed.ignored) {
403
+          if (done) done();
404
+          return;
405
+        }
406
+
407
+        var value = extractValue(parsed.stream, msg.payload);
408
+        var controlMsg = null;
409
+        var outputs;
410
+
411
+        if (parsed.stream === "last") {
412
+          node.stats.last_inputs += 1;
413
+          outputs = processCapability(msg, parsed, value);
414
+          markBootstrapSatisfied(parsed.capability);
415
+        } else if (parsed.stream === "value") {
416
+          node.stats.value_inputs += 1;
417
+          outputs = processCapability(msg, parsed, value);
418
+          markBootstrapSatisfied(parsed.capability);
419
+        } else {
420
+          node.stats.availability_inputs += 1;
421
+          outputs = processAvailability(msg, value);
422
+        }
423
+
424
+        send([
425
+          outputs[0],
426
+          outputs[1],
427
+          controlMsg
428
+        ]);
429
+
430
+        setNodeStatus();
431
+        if (done) done();
432
+      } catch (err) {
433
+        node.stats.errors += 1;
434
+        node.status({ fill: "red", shape: "ring", text: "error: " + err.message });
435
+        if (done) done(err);
436
+        else node.error(err, msg);
437
+      }
438
+    });
439
+
440
+    node.on("close", function() {
441
+      clearBootstrapTimer();
442
+      if (node.startTimer) {
443
+        clearTimeout(node.startTimer);
444
+        node.startTimer = null;
445
+      }
446
+    });
447
+
448
+    node.startTimer = setTimeout(startSubscriptions, 250);
449
+    node.status({ fill: "grey", shape: "ring", text: "starting" });
450
+  }
451
+
452
+  RED.nodes.registerType("snzb-05p-homekit-adapter", Z2MSNZB05PNode);
453
+};
+158 -0
deploy.sh
@@ -0,0 +1,158 @@
1
+#!/usr/bin/env bash
2
+set -euo pipefail
3
+
4
+# Deploy a Node-RED node folder to a remote host and restart Node-RED
5
+# Usage: ./deploy.sh <node-folder-path> [remote_user@host|local]
6
+# Example: ./deploy.sh adapters/smart-socket/homebus-adapter node-red@192.168.2.104
7
+#
8
+# Known deploy targets:
9
+# - testing:    node-red@192.168.2.104
10
+# - production: node-red@192.168.2.101
11
+# - legacy:     pi@192.168.2.133
12
+
13
+REMOTE_DEFAULT="node-red@192.168.2.104"
14
+REMOTE_NODERED_DIR="~/.node-red"
15
+RSYNC_OPTS=( -az --delete --exclude=node_modules --exclude=.git )
16
+
17
+# Parse arguments and flags.
18
+# Usage: ./deploy.sh <node-folder-path> [remote_user@host|local] [--restart]
19
+RESTART=false
20
+POS=()
21
+
22
+while [[ ${#@} -gt 0 ]]; do
23
+  case "$1" in
24
+    -h|--help)
25
+      cat <<EOF
26
+Usage: $0 <node-folder-path> [remote_user@host|local] [--restart]
27
+
28
+This will sync the folder at <node-folder-path> to the remote staging dir,
29
+run npm install for that node inside ~/.node-red, and optionally restart Node-RED
30
+when the --restart flag is provided.
31
+
32
+Examples:
33
+  $0 presence-detector                     # deploy without restarting
34
+  $0 adapters/smart-socket/homebus-adapter --restart
35
+  $0 adapters/smart-socket/homebus-adapter pi@host
36
+  $0 adapters/contact-sensor/homekit-adapter local
37
+  $0 adapters/water-leak-sensor/homebus-adapter --restart pi@host
38
+
39
+Notes:
40
+- SSH access must be available to the remote host (use keys for passwordless login).
41
+- The remote user must have sudo rights to run: sudo systemctl restart nodered (only required with --restart)
42
+- Known targets:
43
+  testing    -> node-red@192.168.2.104
44
+  production -> node-red@192.168.2.101
45
+  legacy     -> pi@192.168.2.133
46
+EOF
47
+      exit 0
48
+      ;;
49
+    --restart)
50
+      RESTART=true
51
+      shift
52
+      ;;
53
+    --*)
54
+      echo "Unknown option: $1" >&2
55
+      exit 3
56
+      ;;
57
+    *)
58
+      POS+=("$1")
59
+      shift
60
+      ;;
61
+  esac
62
+done
63
+
64
+# Validate positional args
65
+if [[ ${#POS[@]} -lt 1 ]]; then
66
+  echo "Error: missing <node-folder-path>" >&2
67
+  echo "Run '$0 --help' for usage." >&2
68
+  exit 1
69
+fi
70
+
71
+NODE_PATH="${POS[0]}"
72
+REMOTE_TARGET="${POS[1]:-$REMOTE_DEFAULT}"
73
+LOCAL_DEPLOY=false
74
+if [[ "$REMOTE_TARGET" == "local" ]]; then
75
+  LOCAL_DEPLOY=true
76
+fi
77
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
78
+
79
+# Resolve local path
80
+if [[ -d "$NODE_PATH" ]]; then
81
+  LOCAL_PATH="$NODE_PATH"
82
+elif [[ -d "$PWD/$NODE_PATH" ]]; then
83
+  LOCAL_PATH="$PWD/$NODE_PATH"
84
+elif [[ -d "$SCRIPT_DIR/$NODE_PATH" ]]; then
85
+  LOCAL_PATH="$SCRIPT_DIR/$NODE_PATH"
86
+else
87
+  LOCAL_PATH="$PWD/$NODE_PATH"
88
+fi
89
+if [[ ! -d "$LOCAL_PATH" ]]; then
90
+  echo "Error: local folder '$LOCAL_PATH' does not exist." >&2
91
+  echo "Checked: '$NODE_PATH', '$PWD/$NODE_PATH', '$SCRIPT_DIR/$NODE_PATH'" >&2
92
+  exit 2
93
+fi
94
+
95
+# Remote staging directory (copy here first, then npm install from it on the Pi)
96
+# NOTE: staged path mirrors the relative adapter path, e.g. "~/incomming/adapters/smart-socket/homebus-adapter"
97
+REMOTE_STAGING_DIR="~/incomming"
98
+
99
+DEST_DIR="${REMOTE_STAGING_DIR%/}/$NODE_PATH"
100
+
101
+if [[ "$LOCAL_DEPLOY" == "true" ]]; then
102
+  LOCAL_STAGING_DIR="$HOME/incomming"
103
+  LOCAL_DEST_DIR="${LOCAL_STAGING_DIR%/}/$NODE_PATH"
104
+  LOCAL_NODERED_DIR="$HOME/.node-red"
105
+
106
+  echo "Deploying node '$NODE_PATH' locally to ${LOCAL_DEST_DIR} (staging), then installing into ${LOCAL_NODERED_DIR}."
107
+
108
+  mkdir -p "${LOCAL_STAGING_DIR%/}"
109
+  mkdir -p "$(dirname "${LOCAL_DEST_DIR}")"
110
+  rm -rf "${LOCAL_DEST_DIR}"
111
+  mkdir -p "${LOCAL_DEST_DIR}"
112
+  if command -v rsync >/dev/null 2>&1; then
113
+    rsync "${RSYNC_OPTS[@]}" "$LOCAL_PATH/" "${LOCAL_DEST_DIR}/"
114
+  else
115
+    # Fallback when rsync is not available: copy with tar while excluding large/generated dirs.
116
+    tar --exclude='.git' --exclude='node_modules' -C "$LOCAL_PATH" -cf - . | tar -C "${LOCAL_DEST_DIR}" -xf -
117
+  fi
118
+  ( cd "${LOCAL_NODERED_DIR%/}" && npm install --no-audit --no-fund "${LOCAL_STAGING_DIR%/}/$NODE_PATH" || true )
119
+
120
+  if [[ "$RESTART" == "true" ]]; then
121
+    echo "Restarting local Node-RED service (sudo may ask for a password)..."
122
+    sudo systemctl restart nodered
123
+  else
124
+    echo "Skipping Node-RED restart (use --restart to enable)."
125
+  fi
126
+else
127
+  echo "Deploying node '$NODE_PATH' to ${REMOTE_TARGET}:${DEST_DIR} (staging), then installing into ${REMOTE_NODERED_DIR}. Use --restart to restart Node-RED after install."
128
+  if ! command -v rsync >/dev/null 2>&1; then
129
+    echo "Error: rsync is required for remote deploy mode but is not installed locally." >&2
130
+    echo "Use '$0 $NODE_PATH local' for local deploy, or install rsync." >&2
131
+    exit 4
132
+  fi
133
+
134
+  # Ensure staging dir exists on remote
135
+  ssh "$REMOTE_TARGET" "mkdir -p ${REMOTE_STAGING_DIR%/}"
136
+  ssh "$REMOTE_TARGET" "mkdir -p ${DEST_DIR%/*}"
137
+
138
+  # Sync node files to staging dir (omit node_modules and .git). Fallback to tar when remote rsync is missing.
139
+  if ssh "$REMOTE_TARGET" "command -v rsync >/dev/null 2>&1"; then
140
+    rsync "${RSYNC_OPTS[@]}" -e ssh "$LOCAL_PATH/" "$REMOTE_TARGET:$DEST_DIR/"
141
+  else
142
+    ssh "$REMOTE_TARGET" "rm -rf ${DEST_DIR} && mkdir -p ${DEST_DIR}"
143
+    tar --exclude='.git' --exclude='node_modules' -C "$LOCAL_PATH" -cf - . | ssh "$REMOTE_TARGET" "tar -C ${DEST_DIR} -xf -"
144
+  fi
145
+
146
+  # Run npm install on the remote (from within ~/.node-red using staged path)
147
+  ssh "$REMOTE_TARGET" "set -euo pipefail; cd ${REMOTE_NODERED_DIR%/} && npm install --no-audit --no-fund ${REMOTE_STAGING_DIR%/}/$NODE_PATH || true"
148
+
149
+  # Conditionally restart Node-RED (only if --restart was passed)
150
+  if [[ "$RESTART" == "true" ]]; then
151
+    echo "Restarting Node-RED on ${REMOTE_TARGET} (sudo may ask for a password)..."
152
+    ssh "$REMOTE_TARGET" "sudo systemctl restart nodered"
153
+  else
154
+    echo "Skipping Node-RED restart (use --restart to enable)."
155
+  fi
156
+fi
157
+
158
+echo "✅ Deploy finished: $NODE_PATH -> $REMOTE_TARGET"
+105 -0
msg-status/msg-status.html
@@ -0,0 +1,105 @@
1
+<script type="text/x-red" data-template-name="msg-status">
2
+  <div class="form-row">
3
+    <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
4
+    <input type="text" id="node-input-name" placeholder="Name">
5
+  </div>
6
+  <div class="form-row">
7
+    <label for="node-input-msgPath"><i class="fa fa-code"></i> Message path</label>
8
+    <input type="text" id="node-input-msgPath" placeholder="msg.payload.state">
9
+  </div>
10
+  <div class="form-row">
11
+    <label for="node-input-prefix">Prefix (optional)</label>
12
+    <input type="text" id="node-input-prefix" placeholder="State:">
13
+  </div>
14
+  <div class="form-row">
15
+    <label for="node-input-suffix">Suffix (optional)</label>
16
+    <input type="text" id="node-input-suffix" placeholder="(live)">
17
+  </div>
18
+  <div class="form-row">
19
+    <label for="node-input-showTime">Show time</label>
20
+    <input type="checkbox" id="node-input-showTime" style="width:auto; vertical-align:middle;">
21
+  </div>
22
+  <div class="form-row">
23
+    <label for="node-input-fill">Status color</label>
24
+    <select id="node-input-fill">
25
+      <option value="blue">Blue</option>
26
+      <option value="green">Green</option>
27
+      <option value="red">Red</option>
28
+      <option value="yellow">Yellow</option>
29
+      <option value="grey">Grey</option>
30
+    </select>
31
+  </div>
32
+  <div class="form-row">
33
+    <label for="node-input-shape">Status shape</label>
34
+    <select id="node-input-shape">
35
+      <option value="dot">Dot</option>
36
+      <option value="ring">Ring</option>
37
+    </select>
38
+  </div>
39
+</script>
40
+
41
+<script type="text/x-red" data-help-name="msg-status">
42
+  <p>
43
+    Displays a configurable part of the incoming message in the node status.
44
+  </p>
45
+  <h3>Configuration</h3>
46
+  <dl class="message-properties">
47
+    <dt>Message path
48
+      <span class="property-type">string</span>
49
+    </dt>
50
+    <dd>
51
+      Dot-notation path to the value to display (e.g., <code>msg.payload.state</code>, <code>msg.topic</code>, <code>msg.payload</code>).
52
+      Supports nested paths like <code>msg.data.nested.value</code>.
53
+    </dd>
54
+    <dt>Prefix <span class="property-type">string (optional)</span></dt>
55
+    <dd>Text to prepend to the status value (e.g., "State: ").</dd>
56
+    <dt>Suffix <span class="property-type">string (optional)</span></dt>
57
+    <dd>Text to append to the status value (e.g., " (live)").</dd>
58
+    <dt>Show time <span class="property-type">boolean</span></dt>
59
+    <dd>When enabled, prefixes the status text with the local time of the last received message in <code>HH:MM:SS</code> format.</dd>
60
+    <dt>Status color
61
+      <span class="property-type">string</span>
62
+    </dt>
63
+    <dd>Color of the status indicator: blue, green, red, yellow, or grey.</dd>
64
+    <dt>Status shape
65
+      <span class="property-type">string</span>
66
+    </dt>
67
+    <dd>Shape of the status indicator: dot or ring.</dd>
68
+  </dl>
69
+
70
+  <h3>Example</h3>
71
+  <p>
72
+    If a message with <code>msg.payload.state = "active"</code> arrives, and you configure:
73
+  </p>
74
+  <ul>
75
+    <li>Message path: <code>msg.payload.state</code></li>
76
+    <li>Prefix: <code>State:</code></li>
77
+    <li>Color: green</li>
78
+  </ul>
79
+  <p>
80
+    The node status will display: <code>12:34:56 State: active</code> with a green dot.
81
+  </p>
82
+</script>
83
+
84
+<script>
85
+  RED.nodes.registerType("msg-status", {
86
+    category: "function",
87
+    color: "#87ceeb",
88
+    defaults: {
89
+      name: {value: ""},
90
+      msgPath: {value: "", required: true},
91
+      prefix: {value: ""},
92
+      suffix: {value: ""},
93
+      showTime: {value: true},
94
+      fill: {value: "blue"},
95
+      shape: {value: "dot"}
96
+    },
97
+    inputs: 1,
98
+    outputs: 1,
99
+    icon: "font-awesome/fa-info-circle",
100
+    label: function() {
101
+      return this.name || (this.msgPath ? "status: " + this.msgPath : "msg-status");
102
+    },
103
+    paletteLabel: "msg status"
104
+  });
105
+</script>
+60 -0
msg-status/msg-status.js
@@ -0,0 +1,60 @@
1
+module.exports = function(RED) {
2
+  function MsgStatusNode(config) {
3
+    RED.nodes.createNode(this, config);
4
+    var node = this;
5
+
6
+    // Configuration
7
+    node.msgPath = (config.msgPath || "").trim();
8
+    node.prefix = (config.prefix || "").trim();
9
+    node.suffix = (config.suffix || "").trim();
10
+    node.shape = config.shape || "dot";
11
+    node.fill = config.fill || "blue";
12
+    node.showTime = config.showTime !== false;
13
+
14
+    if (!node.msgPath) {
15
+      node.status({fill: "red", shape: "ring", text: "error: no path configured"});
16
+      return;
17
+    }
18
+
19
+    function formatTime(date) {
20
+      function pad(value) {
21
+        return String(value).padStart(2, "0");
22
+      }
23
+      return [
24
+        pad(date.getHours()),
25
+        pad(date.getMinutes()),
26
+        pad(date.getSeconds())
27
+      ].join(":");
28
+    }
29
+
30
+    node.on("input", function(msg) {
31
+      try {
32
+        // Helper: get nested value from object using dot notation
33
+        var value = RED.util.getMessageProperty(msg, node.msgPath);
34
+
35
+        // Format status text
36
+        var statusText = node.showTime ? formatTime(new Date()) + " " : "";
37
+        if (node.prefix) statusText += node.prefix + " ";
38
+        statusText += (value !== null && value !== undefined) ? String(value) : "null";
39
+        if (node.suffix) statusText += " " + node.suffix;
40
+
41
+        node.status({
42
+          fill: node.fill,
43
+          shape: node.shape,
44
+          text: statusText
45
+        });
46
+
47
+      } catch (e) {
48
+        node.status({fill: "red", shape: "ring", text: "error: " + e.message});
49
+      }
50
+
51
+      // Pass through the original message
52
+      node.send(msg);
53
+    });
54
+
55
+    // On first deploy or config change, show initial status
56
+    node.status({fill: node.fill, shape: node.shape, text: "waiting..."});
57
+  }
58
+
59
+  RED.nodes.registerType("msg-status", MsgStatusNode);
60
+};
+22 -0
msg-status/package.json
@@ -0,0 +1,22 @@
1
+{
2
+  "name": "node-red-contrib-msg-status",
3
+  "version": "1.1.0",
4
+  "description": "Node-RED node that displays a configurable part of the message in the node status",
5
+  "main": "msg-status.js",
6
+  "scripts": {
7
+    "test": "echo \"Error: no test specified\" && exit 1"
8
+  },
9
+  "keywords": [
10
+    "node-red",
11
+    "status",
12
+    "message",
13
+    "display"
14
+  ],
15
+  "author": "",
16
+  "license": "MIT",
17
+  "node-red": {
18
+    "nodes": {
19
+      "msg-status": "msg-status.js"
20
+    }
21
+  }
22
+}
+153 -0
presence-detector/example_flow.json
@@ -0,0 +1,153 @@
1
+[
2
+  {
3
+    "id": "presence-example",
4
+    "type": "tab",
5
+    "label": "Presence Example",
6
+    "disabled": false,
7
+    "info": ""
8
+  },
9
+  {
10
+    "id": "inject-motion-true",
11
+    "type": "inject",
12
+    "z": "presence-example",
13
+    "name": "motion true",
14
+    "topic": "",
15
+    "payload": "{\"motionDetected\":true}",
16
+    "payloadType": "json",
17
+    "repeat": "",
18
+    "crontab": "",
19
+    "once": false,
20
+    "x": 120,
21
+    "y": 80,
22
+    "wires": [["presence-node"]]
23
+  },
24
+  {
25
+    "id": "inject-motion-false",
26
+    "type": "inject",
27
+    "z": "presence-example",
28
+    "name": "motion false",
29
+    "topic": "",
30
+    "payload": "{\"motionDetected\":false}",
31
+    "payloadType": "json",
32
+    "x": 120,
33
+    "y": 140,
34
+    "wires": [["presence-node"]]
35
+  },
36
+  {
37
+    "id": "inject-door-open",
38
+    "type": "inject",
39
+    "z": "presence-example",
40
+    "name": "door open",
41
+    "topic": "",
42
+    "payload": "{\"doorOpen\":true}",
43
+    "payloadType": "json",
44
+    "x": 130,
45
+    "y": 200,
46
+    "wires": [["presence-node"]]
47
+  },
48
+  {
49
+    "id": "inject-door-closed",
50
+    "type": "inject",
51
+    "z": "presence-example",
52
+    "name": "door closed",
53
+    "topic": "",
54
+    "payload": "{\"doorOpen\":false}",
55
+    "payloadType": "json",
56
+    "x": 130,
57
+    "y": 260,
58
+    "wires": [["presence-node"]]
59
+  },
60
+  
61
+  {
62
+    "id": "inject-combined",
63
+    "type": "inject",
64
+    "z": "presence-example",
65
+    "name": "combined motion=true door=false",
66
+    "topic": "",
67
+    "payload": "{\"motionDetected\":true,\"doorOpen\":false}",
68
+    "payloadType": "json",
69
+    "x": 120,
70
+    "y": 320,
71
+    "wires": [["presence-node"]]
72
+  },
73
+  {
74
+    "id": "inject-combined-2",
75
+    "type": "inject",
76
+    "z": "presence-example",
77
+    "name": "combined motion=false door=true",
78
+    "topic": "",
79
+    "payload": "{\"motionDetected\":false,\"doorOpen\":true}",
80
+    "payloadType": "json",
81
+    "x": 120,
82
+    "y": 360,
83
+    "wires": [["presence-node"]]
84
+  },
85
+  {
86
+    "id": "inject-presence-true",
87
+    "type": "inject",
88
+    "z": "presence-example",
89
+    "name": "presence true (override)",
90
+    "topic": "",
91
+    "payload": "{\"presenceDetected\":true}",
92
+    "payloadType": "json",
93
+    "x": 120,
94
+    "y": 420,
95
+    "wires": [["presence-node"]]
96
+  },
97
+  {
98
+    "id": "inject-presence-false",
99
+    "type": "inject",
100
+    "z": "presence-example",
101
+    "name": "presence false (override)",
102
+    "topic": "",
103
+    "payload": "{\"presenceDetected\":false}",
104
+    "payloadType": "json",
105
+    "x": 120,
106
+    "y": 460,
107
+    "wires": [["presence-node"]]
108
+  },
109
+  {
110
+    "id": "presence-node",
111
+    "type": "presence-detector",
112
+    "z": "presence-example",
113
+    "name": "room presence",
114
+    "motionTimeout": "30000",
115
+    "delayBetween": "3000",
116
+    "payload1Text": "{\"cmd\":\"left_timeout\"}",
117
+    "payload2Text": "{\"cmd\":\"absent\"}",
118
+    "treatMissingDoorAsOpen": true,
119
+    "resetOnMotion": true,
120
+    "resetOnDoorOpen": true,
121
+    "x": 380,
122
+    "y": 160,
123
+    "wires": [["debug-presence"],["debug-control"]]
124
+  },
125
+  {
126
+    "id": "debug-presence",
127
+    "type": "debug",
128
+    "z": "presence-example",
129
+    "name": "presence_out",
130
+    "active": true,
131
+    "tosidebar": true,
132
+    "console": false,
133
+    "tostatus": false,
134
+    "complete": "payload",
135
+    "x": 600,
136
+    "y": 140,
137
+    "wires": []
138
+  },
139
+  {
140
+    "id": "debug-control",
141
+    "type": "debug",
142
+    "z": "presence-example",
143
+    "name": "control_out",
144
+    "active": true,
145
+    "tosidebar": true,
146
+    "console": false,
147
+    "tostatus": false,
148
+    "complete": "payload",
149
+    "x": 600,
150
+    "y": 200,
151
+    "wires": []
152
+  }
153
+]
+20 -0
presence-detector/package.json
@@ -0,0 +1,20 @@
1
+{
2
+  "name": "node-red-node-presence-detector",
3
+  "version": "0.0.1",
4
+  "description": "Node-RED node that detects presence using motion and door sensors",
5
+  "main": "presence-detector.js",
6
+  "keywords": [
7
+    "node-red",
8
+    "presence",
9
+    "sensor",
10
+    "motion",
11
+    "door"
12
+  ],
13
+  "author": "",
14
+  "license": "MIT",
15
+  "node-red": {
16
+    "nodes": {
17
+      "presence-detector": "presence-detector.js"
18
+    }
19
+  }
20
+}
+72 -0
presence-detector/presence-detector.html
@@ -0,0 +1,72 @@
1
+<script type="text/x-red" data-template-name="presence-detector">
2
+  <div class="form-row">
3
+    <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
4
+    <input type="text" id="node-input-name" placeholder="Name">
5
+  </div>
6
+  <div class="form-row">
7
+    <label for="node-input-motionTimeout">Motion timeout (ms)</label>
8
+    <input type="number" id="node-input-motionTimeout" placeholder="30000">
9
+  </div>
10
+  <div class="form-row">
11
+    <label for="node-input-delayBetween">Delay between timeout payloads (ms)</label>
12
+    <input type="number" id="node-input-delayBetween" placeholder="3000">
13
+  </div>
14
+  <div class="form-row">
15
+    <label for="node-input-payload1Text">Payload #1 (sent on timeout first)</label>
16
+    <textarea id="node-input-payload1Text" rows="3" placeholder='{"cmd":"leave"}'></textarea>
17
+  </div>
18
+  <div class="form-row">
19
+    <label for="node-input-payload2Text">Payload #2 (sent on timeout after delay, and also on presence)</label>
20
+    <textarea id="node-input-payload2Text" rows="3" placeholder='{"cmd":"clear"}'></textarea>
21
+  </div>
22
+  <div class="form-row">
23
+    <label for="node-input-treatMissingDoorAsOpen">Treat missing door as open</label>
24
+    <input type="checkbox" id="node-input-treatMissingDoorAsOpen" checked>
25
+  </div>
26
+  <div class="form-row">
27
+    <label for="node-input-resetOnMotion">Reset timer on motion</label>
28
+    <input type="checkbox" id="node-input-resetOnMotion" checked>
29
+  </div>
30
+  <div class="form-row">
31
+    <label for="node-input-resetOnDoorOpen">Reset timer on door open</label>
32
+    <input type="checkbox" id="node-input-resetOnDoorOpen" checked>
33
+  </div>
34
+</script>
35
+
36
+<script type="text/x-red" data-help-name="presence-detector">
37
+  <p>
38
+    Determines presence from motion and door sensor inputs.
39
+  </p>
40
+  <p>
41
+    Outputs:
42
+    <ol>
43
+      <li>Presence message: <code>msg.payload.presenceDetected = true|false</code> (sent on every presence change)</li>
44
+      <li>Control payloads: on timeout sends payload #1 then after delay payload #2 (if configured); sends payload #2 immediately when presence is detected (if configured)</li>
45
+    </ol>
46
+  </p>
47
+  <p>Supported message shapes: send an object in <code>msg.payload</code> with fields like <code>motionDetected</code>, <code>doorOpen</code>, or <code>presenceDetected</code>. Example: <code>{"motionDetected":true,"doorOpen":false}</code>.</p>
48
+  <p>When a timeout is counting down, the node status shows the remaining time in <code>HH:MM:SS</code> format. Status updates occur every 10s while more than 10s remain, and every 1s when 10s or less remain.</p>
49
+</script>
50
+
51
+<script>
52
+  RED.nodes.registerType('presence-detector', {
53
+    category: 'function',
54
+    color: '#a6bbcf',
55
+    defaults: {
56
+      name: {value: ""},
57
+      motionTimeout: {value: 30000},
58
+      delayBetween: {value: 3000},
59
+      payload1Text: {value: ""},
60
+      payload2Text: {value: ""},
61
+      treatMissingDoorAsOpen: {value: true},
62
+      resetOnMotion: {value: true},
63
+      resetOnDoorOpen: {value: true}
64
+    },
65
+    inputs: 1,
66
+    outputs: 2,
67
+    icon: "font-awesome/fa-users",
68
+    label: function() {
69
+      return this.name||"presence-detector";
70
+    }
71
+  });
72
+</script>
+208 -0
presence-detector/presence-detector.js
@@ -0,0 +1,208 @@
1
+module.exports = function(RED) {
2
+  function PresenceDetectorNode(config) {
3
+    RED.nodes.createNode(this, config);
4
+    var node = this;
5
+
6
+    // Configuration
7
+    node.motionTimeout = Number(config.motionTimeout) || 30000; // ms
8
+    node.delayBetween = Number(config.delayBetween) || 3000; // ms between payload #1 and #2
9
+    node.payload1Text = (config.payload1Text || "").trim();
10
+    node.payload2Text = (config.payload2Text || "").trim();
11
+    node.treatMissingDoorAsOpen = config.treatMissingDoorAsOpen === "true" || config.treatMissingDoorAsOpen === true;
12
+    node.resetOnMotion = config.resetOnMotion === "true" || config.resetOnMotion === true;
13
+    node.resetOnDoorOpen = config.resetOnDoorOpen === "true" || config.resetOnDoorOpen === true;
14
+
15
+    // Internal state
16
+    node.presence = false;
17
+    node.doorOpen = node.treatMissingDoorAsOpen; // default initial
18
+    node.lastMotion = false;
19
+    node.timer = null;
20
+    node.timerType = null; // "door" or "motion"
21
+
22
+    function parsePayloadText(text) {
23
+      if (!text) return null;
24
+      try {
25
+        return JSON.parse(text);
26
+      } catch (e) {
27
+        return text;
28
+      }
29
+    }
30
+
31
+    node.payload1 = parsePayloadText(node.payload1Text);
32
+    node.payload2 = parsePayloadText(node.payload2Text);
33
+
34
+    // Helper: format milliseconds to HH:MM:SS
35
+    function formatMs(ms) {
36
+      ms = Math.max(0, Math.floor(ms/1000));
37
+      var hrs = Math.floor(ms / 3600);
38
+      var mins = Math.floor((ms % 3600) / 60);
39
+      var secs = ms % 60;
40
+      return [hrs, mins, secs].map(function(n){ return String(n).padStart(2, '0'); }).join(':');
41
+    }
42
+
43
+    function sendPresence(p) {
44
+      if (node.presence === p) return; // only send on change
45
+      node.presence = p;
46
+      node.status({fill: p ? "green" : "grey", shape: "dot", text: p ? "present" : "absent"});
47
+      var out = {payload: {presenceDetected: p}};
48
+      node.send([out, null]);
49
+    }
50
+
51
+    function sendControl(payload) {
52
+      node.send([null, {payload: payload}]);
53
+    }
54
+
55
+    function clearTimer() {
56
+      if (node.timer) {
57
+        clearTimeout(node.timer);
58
+        node.timer = null;
59
+      }
60
+      if (node.updateInterval) {
61
+        clearInterval(node.updateInterval);
62
+        node.updateInterval = null;
63
+      }
64
+      node.timerType = null;
65
+      node.timerEnd = null;
66
+      node._lastStatusUpdate = null;
67
+      node.status({fill: node.presence ? "green" : "grey", shape: "dot", text: node.presence ? "present" : "idle"});
68
+    }
69
+
70
+    function startTimer(type, timeoutMs) {
71
+      clearTimer();
72
+      node.timerType = type;
73
+      node.timerEnd = Date.now() + timeoutMs;
74
+      node._lastStatusUpdate = 0;
75
+
76
+      function statusUpdater() {
77
+        var now = Date.now();
78
+        var remaining = Math.max(0, node.timerEnd - now);
79
+        // update every 10s when remaining > 10s, otherwise every second
80
+        var shouldUpdate = false;
81
+        if (remaining <= 10000) {
82
+          shouldUpdate = true;
83
+        } else {
84
+          if (!node._lastStatusUpdate || (now - node._lastStatusUpdate) >= 10000) shouldUpdate = true;
85
+        }
86
+        if (shouldUpdate) {
87
+          node._lastStatusUpdate = now;
88
+          node.status({fill: "yellow", shape: "ring", text: `waiting (${type}) ${formatMs(remaining)}`});
89
+        }
90
+      }
91
+
92
+      // initial status
93
+      statusUpdater();
94
+      node.updateInterval = setInterval(statusUpdater, 1000);
95
+
96
+      node.timer = setTimeout(function() {
97
+        // clear interval and timers
98
+        if (node.updateInterval) { clearInterval(node.updateInterval); node.updateInterval = null; }
99
+        node.timer = null;
100
+        node.timerType = null;
101
+        node.timerEnd = null;
102
+        node._lastStatusUpdate = null;
103
+
104
+        // timeout fires -> absence
105
+        sendPresence(false);
106
+        // control payload sequence
107
+        if (node.payload1 !== null) sendControl(node.payload1);
108
+        if (node.payload2 !== null) {
109
+          setTimeout(function() {
110
+            sendControl(node.payload2);
111
+          }, node.delayBetween);
112
+        }
113
+      }, timeoutMs);
114
+    }
115
+
116
+    function handleMotion(val) {
117
+      var motion = (val === true || val === 1 || String(val) === "1" || String(val).toLowerCase() === "true");
118
+      node.lastMotion = motion;
119
+      if (motion) {
120
+        // someone detected
121
+        sendPresence(true);
122
+        if (node.payload2 !== null) sendControl(node.payload2);
123
+        if (node.resetOnMotion) clearTimer();
124
+      } else {
125
+        // no motion -> only start timer if door is open (or no door sensor)
126
+        if (node.doorOpen) {
127
+          // If the door is open and motion stops, start motion timeout
128
+          startTimer("motion", node.motionTimeout);
129
+        } else {
130
+          // door closed — keep presence as-is until door opens
131
+          node.status({fill: node.presence ? "green" : "grey", shape: "dot", text: node.presence ? "present (door closed)" : "idle"});
132
+        }
133
+      }
134
+    }
135
+
136
+    function handleDoor(val) {
137
+      var open = null;
138
+      if (typeof val === 'string') {
139
+        var v = val.toLowerCase();
140
+        if (v === 'open' || v === '1' || v === 'true') open = true;
141
+        if (v === 'closed' || v === '0' || v === 'false') open = false;
142
+      } else if (typeof val === 'number') {
143
+        open = (val === 1);
144
+      } else if (typeof val === 'boolean') {
145
+        open = val;
146
+      } else if (val && typeof val === 'object') {
147
+        if ('ContactSensorState' in val) {
148
+          open = (val.ContactSensorState === 1);
149
+        } else if ('doorOpen' in val) {
150
+          open = !!val.doorOpen;
151
+        }
152
+      }
153
+      if (open === null) return; // unknown message
154
+
155
+      node.doorOpen = open;
156
+      if (open) {
157
+        // door opened -> start deducing occupancy
158
+        if (node.resetOnDoorOpen) clearTimer();
159
+        if (node.lastMotion) {
160
+          // motion present now -> confirm presence
161
+          sendPresence(true);
162
+          if (node.payload2 !== null) sendControl(node.payload2);
163
+        } else {
164
+          // no motion now -> start motion timeout to decide absence
165
+          startTimer("motion", node.motionTimeout);
166
+        }
167
+      } else {
168
+        // door closed -> stop any running timeout and keep current presence until door opens
169
+        clearTimer();
170
+        // presence remains as-is
171
+      }
172
+    }
173
+
174
+    node.on('input', function(msg) {
175
+      try {
176
+        // Only accept explicit object payloads with fields: motionDetected, doorOpen, presenceDetected
177
+        if (!msg.payload || typeof msg.payload !== 'object') return;
178
+
179
+        var p = msg.payload;
180
+
181
+        // Explicit presence override
182
+        if ('presenceDetected' in p) {
183
+          var pres = !!p.presenceDetected;
184
+          sendPresence(pres);
185
+          if (pres && node.payload2 !== null) sendControl(node.payload2);
186
+          return;
187
+        }
188
+
189
+        var didSomething = false;
190
+        if ('motionDetected' in p) { handleMotion(p.motionDetected); didSomething = true; }
191
+        if ('motionDtected' in p) { handleMotion(p.motionDtected); didSomething = true; }
192
+        if ('doorOpen' in p) { handleDoor(p.doorOpen); didSomething = true; }
193
+        if ('ContactSensorState' in p) { handleDoor(p); didSomething = true; }
194
+        // ignore other payloads
195
+      } catch (err) {
196
+        node.error(err.message);
197
+      }
198
+    });
199
+
200
+    node.on('close', function() {
201
+      clearTimer();
202
+    });
203
+
204
+    // initialize status
205
+    node.status({fill: node.presence ? "green" : "grey", shape: "dot", text: node.presence ? "present" : "idle"});
206
+  }
207
+  RED.nodes.registerType("presence-detector", PresenceDetectorNode);
208
+};