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