@@ -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. |
|
@@ -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` |
|
@@ -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.* |
|
@@ -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) |
|
@@ -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 |
+``` |
|
@@ -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 |
+} |
|
@@ -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><site>/home/<location>/<capability>/<device_id>/<stream></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/<site>/<location>/<device_id></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> |
|
@@ -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 |
+}; |
|
@@ -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 |
+} |
|
@@ -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 -> Contact Sensor</code>, |
|
| 52 |
+ <code>battery + battery_low -> Battery</code>, |
|
| 53 |
+ and in <code>dual-contact</code> mode <code>tamper -> 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> |
|
@@ -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 |
+}; |
|
@@ -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 |  | |
|
| 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 |
+ |
|
@@ -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 |
+} |
|
@@ -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><site>/home/<location>/<capability>/<device_id>/<stream></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/<site>/<location>/<device_id></code> with a JSON payload. |
|
| 38 |
+ </p> |
|
| 39 |
+ <p> |
|
| 40 |
+ Availability topic is also supported: |
|
| 41 |
+ <code>zigbee2mqtt/ZG-204ZV/<site>/<location>/<device_id>/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><site>/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 -> motion/value</code>, |
|
| 106 |
+ <code>presence/occupancy + fading_time>0 -> presence/value</code>, |
|
| 107 |
+ <code>temperature -> temperature/value</code>, |
|
| 108 |
+ <code>humidity -> humidity/value</code>, |
|
| 109 |
+ <code>illuminance -> illuminance/value</code>, |
|
| 110 |
+ <code>battery -> battery/value</code>, |
|
| 111 |
+ <code>battery_low -> battery_low/value</code>, |
|
| 112 |
+ <code>tamper -> 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> |
|
@@ -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 |
+}; |
|
@@ -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 |
+} |
|
@@ -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 <site>/home/<location>/+/<accessory>/last</code> and |
|
| 38 |
+ <code>subscribe <site>/home/<location>/+/<accessory>/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><site>/home/<location>/<capability>/<accessory>/value</code> and |
|
| 48 |
+ <code><site>/home/<location>/<capability>/<accessory>/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 -> Motion + Occupancy</code>, |
|
| 58 |
+ <code>temperature -> Temperature</code>, |
|
| 59 |
+ <code>humidity -> Humidity</code>, |
|
| 60 |
+ <code>illuminance -> Light Level</code>, |
|
| 61 |
+ <code>battery + battery_low -> 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> |
|
@@ -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 |
+}; |
|
@@ -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 |
+} |
|
@@ -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><site>/energy/<entity_type>/<entity_id>/<metric>/<stream></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/<site>/<location>/<device_id></code> or |
|
| 42 |
+ <code>zigbee2mqtt/S60ZBTPF/<site>/<location>/<device_id></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> |
|
@@ -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 |
+}; |
|
@@ -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 |
+} |
|
@@ -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><site>/home/<location>/power/<device_id>/...</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/<site>/<location>/<device_id></code> or |
|
| 34 |
+ <code>zigbee2mqtt/S60ZBTPF/<site>/<location>/<device_id></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><site>/home/<location>/power/<device_id>/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> |
|
@@ -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 |
+}; |
|
@@ -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 |
+} |
|
@@ -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><site>/home/<location>/power/<accessory>/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><site>/home/<location>/power/<accessory>/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> |
|
@@ -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 |
+}; |
|
@@ -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 |
+} |
|
@@ -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><site>/home/<location>/<capability>/<device_id>/<stream></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/<site>/<location>/<device_id></code> with a JSON payload. |
|
| 41 |
+ </p> |
|
| 42 |
+ <p> |
|
| 43 |
+ Availability topic is also supported: |
|
| 44 |
+ <code>zigbee2mqtt/PA-44Z/<site>/<location>/<device_id>/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 -> smoke/value</code>, |
|
| 72 |
+ <code>battery -> battery/value</code>, |
|
| 73 |
+ <code>battery_low -> battery_low/value</code>, |
|
| 74 |
+ <code>device_fault -> device_fault/value</code>, |
|
| 75 |
+ <code>silence -> silence/value</code>, |
|
| 76 |
+ <code>test -> test/value</code>, |
|
| 77 |
+ <code>smoke_concentration -> 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> |
|
@@ -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 |
+}; |
|
@@ -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 |
+} |
|
@@ -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 <site>/home/<location>/+/<accessory>/last</code>, |
|
| 34 |
+ <code>subscribe <site>/home/<location>/+/<accessory>/value</code>, and |
|
| 35 |
+ <code>subscribe <site>/home/<location>/+/<accessory>/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 -> Smoke Sensor</code>, |
|
| 44 |
+ <code>battery + battery_low -> Battery</code>, |
|
| 45 |
+ <code>device_fault -> 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> |
|
@@ -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 |
+}; |
|
@@ -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 |  | |
|
| 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 |
+ |
|
@@ -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 |
+} |
|
@@ -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><site>/home/<location>/<capability>/<device_id>/<stream></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/<site>/<location>/<device_id></code> with a JSON payload. |
|
| 34 |
+ </p> |
|
| 35 |
+ <p> |
|
| 36 |
+ Availability topic is also supported: |
|
| 37 |
+ <code>zigbee2mqtt/SNZB-05P/<site>/<location>/<device_id>/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><site>/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 -> water_leak/value</code>, |
|
| 93 |
+ <code>battery -> battery/value</code>, |
|
| 94 |
+ <code>battery_low -> battery_low/value</code>, |
|
| 95 |
+ <code>tamper -> 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> |
|
@@ -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 |
+}; |
|
@@ -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 |
+} |
|
@@ -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 <site>/home/<location>/+/<accessory>/last</code>, |
|
| 34 |
+ <code>subscribe <site>/home/<location>/+/<accessory>/value</code>, and |
|
| 35 |
+ <code>subscribe <site>/home/<location>/+/<accessory>/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><site>/home/<location>/<capability>/<accessory>/value</code>, |
|
| 45 |
+ <code><site>/home/<location>/<capability>/<accessory>/last</code>, and |
|
| 46 |
+ <code><site>/home/<location>/<capability>/<accessory>/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 -> Leak Sensor</code>, |
|
| 57 |
+ <code>battery + battery_low -> 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> |
|
@@ -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 |
+}; |
|
@@ -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" |
|
@@ -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> |
|
@@ -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 |
+}; |
|
@@ -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 |
+} |
|
@@ -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 |
+] |
|
@@ -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 |
+} |
|
@@ -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> |
|
@@ -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 |
+}; |
|