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