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