Newer Older
570 lines | 19.071kb
Bogdan Timofte authored 2 weeks ago
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
};