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