Newer Older
340 lines | 12.62kb
Bogdan Timofte authored 2 weeks ago
1
module.exports = function(RED) {
2
  function PA44ZHomeBusNode(config) {
3
    RED.nodes.createNode(this, config);
4
    var node = this;
5

            
6
    node.adapterId = "z2m-pa-44z";
7
    node.sourceTopic = "zigbee2mqtt/PA-44Z/#";
8
    node.site = normalizeToken(config.site || config.mqttSite || "unknown");
9
    node.batteryType = normalizeToken(config.batteryType || "alkaline").toLowerCase() || "alkaline";
10
    node.batteryLowThreshold = parseNumber(config.batteryLowThreshold, 20, 0);
11
    node.publishCache = Object.create(null);
12
    node.startTimer = null;
13
    node.subscriptionStarted = false;
14

            
15
    node.stats = {
16
      processed: 0,
17
      published: 0,
18
      invalid: 0,
19
      errors: 0
20
    };
21

            
22
    function normalizeToken(value) {
23
      if (value === undefined || value === null) return "";
24
      return String(value).trim();
25
    }
26

            
27
    function parseNumber(value, fallback, min) {
28
      var n = Number(value);
29
      if (!Number.isFinite(n)) return fallback;
30
      if (typeof min === "number" && n < min) return fallback;
31
      return n;
32
    }
33

            
34
    function transliterate(value) {
35
      var s = normalizeToken(value);
36
      if (!s) return "";
37
      if (typeof s.normalize === "function") {
38
        s = s.normalize("NFKD").replace(/[\u0300-\u036f]/g, "");
39
      }
40
      return s;
41
    }
42

            
43
    function toKebabCase(value, fallback) {
44
      var s = transliterate(value).toLowerCase().replace(/[^a-z0-9]+/g, "-");
45
      s = s.replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-");
46
      return s || fallback || "";
47
    }
48

            
49
    function clamp(n, min, max) {
50
      return Math.max(min, Math.min(max, n));
51
    }
52

            
53
    var ALKALINE_BATTERY_CURVE = [
54
      { pct: 100, voltage: 1.60 },
55
      { pct: 90, voltage: 1.55 },
56
      { pct: 80, voltage: 1.50 },
57
      { pct: 70, voltage: 1.46 },
58
      { pct: 60, voltage: 1.42 },
59
      { pct: 50, voltage: 1.36 },
60
      { pct: 40, voltage: 1.30 },
61
      { pct: 30, voltage: 1.25 },
62
      { pct: 20, voltage: 1.20 },
63
      { pct: 10, voltage: 1.10 },
64
      { pct: 0, voltage: 0.90 }
65
    ];
66

            
67
    var NIMH_BATTERY_CURVE = [
68
      { pct: 100, voltage: 1.40 },
69
      { pct: 95, voltage: 1.33 },
70
      { pct: 90, voltage: 1.28 },
71
      { pct: 85, voltage: 1.24 },
72
      { pct: 80, voltage: 1.20 },
73
      { pct: 75, voltage: 1.19 },
74
      { pct: 70, voltage: 1.18 },
75
      { pct: 60, voltage: 1.17 },
76
      { pct: 50, voltage: 1.16 },
77
      { pct: 40, voltage: 1.15 },
78
      { pct: 30, voltage: 1.13 },
79
      { pct: 20, voltage: 1.11 },
80
      { pct: 10, voltage: 1.07 },
81
      { pct: 0, voltage: 1.00 }
82
    ];
83

            
84
    function interpolateCurve(points, inputKey, outputKey, inputValue) {
85
      if (!Array.isArray(points) || points.length === 0) return null;
86
      if (inputValue >= points[0][inputKey]) return points[0][outputKey];
87

            
88
      for (var i = 1; i < points.length; i++) {
89
        var upper = points[i - 1];
90
        var lower = points[i];
91
        var upperInput = upper[inputKey];
92
        var lowerInput = lower[inputKey];
93

            
94
        if (inputValue >= lowerInput) {
95
          var range = upperInput - lowerInput;
96
          if (range <= 0) return lower[outputKey];
97
          var ratio = (inputValue - lowerInput) / range;
98
          return lower[outputKey] + ratio * (upper[outputKey] - lower[outputKey]);
99
        }
100
      }
101

            
102
      return points[points.length - 1][outputKey];
103
    }
104

            
105
    function asBool(value) {
106
      if (typeof value === "boolean") return value;
107
      if (typeof value === "number") return value !== 0;
108
      if (typeof value === "string") {
109
        var v = value.trim().toLowerCase();
110
        if (v === "true" || v === "1" || v === "on" || v === "yes" || v === "online") return true;
111
        if (v === "false" || v === "0" || v === "off" || v === "no" || v === "offline") return false;
112
      }
113
      return null;
114
    }
115

            
116
    function asNumber(value) {
117
      if (typeof value === "number" && isFinite(value)) return value;
118
      if (typeof value === "string") {
119
        var trimmed = value.trim();
120
        if (!trimmed) return null;
121
        var parsed = Number(trimmed);
122
        if (isFinite(parsed)) return parsed;
123
      }
124
      return null;
125
    }
126

            
127
    function toIsoTimestamp(value) {
128
      if (value === undefined || value === null) return "";
129
      if (typeof value === "string" && value.trim()) {
130
        var fromString = new Date(value);
131
        if (!isNaN(fromString.getTime())) return fromString.toISOString();
132
      }
133
      return new Date().toISOString();
134
    }
135

            
136
    function translateBatteryLevel(rawValue) {
137
      if (rawValue === null || rawValue === undefined) return null;
138
      var raw = clamp(Math.round(Number(rawValue)), 0, 100);
139
      if (node.batteryType !== "nimh") return raw;
140

            
141
      // Reinterpret the reported percentage on an alkaline discharge curve,
142
      // then project the equivalent cell voltage onto a flatter NiMH curve.
143
      var estimatedVoltage = interpolateCurve(ALKALINE_BATTERY_CURVE, "pct", "voltage", raw);
144
      var nimhPct = interpolateCurve(NIMH_BATTERY_CURVE, "voltage", "pct", estimatedVoltage);
145
      return clamp(Math.round(nimhPct), 0, 100);
146
    }
147

            
148
    function signature(value) {
149
      return JSON.stringify(value);
150
    }
151

            
152
    function shouldPublish(cacheKey, payload) {
153
      var sig = signature(payload);
154
      if (node.publishCache[cacheKey] === sig) return false;
155
      node.publishCache[cacheKey] = sig;
156
      return true;
157
    }
158

            
159
    function makePublish(topic, payload, retain) {
160
      return {
161
        topic: topic,
162
        payload: payload,
163
        qos: 2,
164
        retain: !!retain
165
      };
166
    }
167

            
168
    function setStatus(prefix, fill) {
169
      node.status({
170
        fill: fill || (node.stats.errors ? "red" : "green"),
171
        shape: "dot",
172
        text: [prefix || "live", "in:" + node.stats.processed, "out:" + node.stats.published, "err:" + node.stats.errors].join(" ")
173
      });
174
    }
175

            
176
    function buildTopic(site, location, capability, deviceId, stream) {
177
      return [site, "home", location, capability, deviceId, stream].join("/");
178
    }
179

            
180
    function pushCapability(messages, identity, capability, value, observedAt, meta) {
181
      if (value === null || value === undefined) return;
182

            
183
      var valueTopic = buildTopic(identity.site, identity.location, capability, identity.deviceId, "value");
184
      var lastTopic = buildTopic(identity.site, identity.location, capability, identity.deviceId, "last");
185
      var metaTopic = buildTopic(identity.site, identity.location, capability, identity.deviceId, "meta");
186

            
187
      if (shouldPublish(valueTopic, value)) {
188
        messages.push(makePublish(valueTopic, value, false));
189
        node.stats.published += 1;
190
      }
191

            
192
      var lastPayload = {
193
        value: value,
194
        observed_at: observedAt,
195
        quality: "reported"
196
      };
197
      if (shouldPublish(lastTopic, lastPayload)) {
198
        messages.push(makePublish(lastTopic, lastPayload, true));
199
        node.stats.published += 1;
200
      }
201

            
202
      if (meta && shouldPublish(metaTopic, meta)) {
203
        messages.push(makePublish(metaTopic, meta, true));
204
        node.stats.published += 1;
205
      }
206
    }
207

            
208
    function publishAvailability(messages, identity, active, observedAt) {
209
      var availabilityTopic = buildTopic(identity.site, identity.location, "adapter", identity.deviceId, "availability");
210
      var availabilityPayload = active;
211
      if (shouldPublish(availabilityTopic, availabilityPayload)) {
212
        messages.push(makePublish(availabilityTopic, availabilityPayload, true));
213
        node.stats.published += 1;
214
      }
215

            
216
      var lastTopic = buildTopic(identity.site, identity.location, "adapter", identity.deviceId, "last");
217
      var lastPayload = { value: active, observed_at: observedAt, quality: "reported" };
218
      if (shouldPublish(lastTopic, lastPayload)) {
219
        messages.push(makePublish(lastTopic, lastPayload, true));
220
        node.stats.published += 1;
221
      }
222
    }
223

            
224
    function parseTopic(topic) {
225
      if (typeof topic !== "string") return null;
226
      var tokens = topic.split("/").map(function(token) { return token.trim(); }).filter(function(token) { return !!token; });
227
      if (tokens.length !== 5 && tokens.length !== 6) return null;
228
      if (tokens[0].toLowerCase() !== "zigbee2mqtt" || tokens[1].toLowerCase() !== "pa-44z") return null;
229
      if (tokens.length === 6 && tokens[5].toLowerCase() !== "availability") return null;
230
      return {
231
        site: toKebabCase(tokens[2], node.site || "unknown"),
232
        location: toKebabCase(tokens[3], "unknown"),
233
        deviceId: toKebabCase(tokens[4], "pa-44z"),
234
        availabilityTopic: tokens.length === 6
235
      };
236
    }
237

            
238
    function buildIdentity(parsed) {
239
      return {
240
        site: parsed.site || node.site || "unknown",
241
        location: parsed.location || "unknown",
242
        deviceId: parsed.deviceId || "pa-44z"
243
      };
244
    }
245

            
246
    function buildMeta(identity, capability, unit, core) {
247
      var out = {
248
        adapter_id: node.adapterId,
249
        device_type: "PA-44Z",
250
        capability: capability,
251
        core: !!core,
252
        source_ref: ["zigbee2mqtt", "PA-44Z", identity.site, identity.location, identity.deviceId].join("/")
253
      };
254
      if (unit) out.unit = unit;
255
      return out;
256
    }
257

            
258
    function processTelemetry(identity, payload, observedAt) {
259
      var messages = [];
260
      var smoke = asBool(payload.smoke);
261
      var battery = translateBatteryLevel(asNumber(payload.battery));
262
      var batteryLowRaw = asBool(payload.battery_low);
263
      var batteryLow = batteryLowRaw === null ? (battery !== null && battery <= node.batteryLowThreshold) : batteryLowRaw;
264
      var deviceFault = asBool(payload.device_fault);
265
      var silence = asBool(payload.silence);
266
      var test = asBool(payload.test);
267
      var concentration = asNumber(payload.smoke_concentration);
268

            
269
      publishAvailability(messages, identity, true, observedAt);
270
      pushCapability(messages, identity, "smoke", smoke, observedAt, buildMeta(identity, "smoke", "", true));
271
      pushCapability(messages, identity, "battery", battery, observedAt, buildMeta(identity, "battery", "%", true));
272
      pushCapability(messages, identity, "battery_low", batteryLow, observedAt, buildMeta(identity, "battery_low", "", true));
273
      pushCapability(messages, identity, "device_fault", deviceFault, observedAt, buildMeta(identity, "device_fault", "", true));
274
      pushCapability(messages, identity, "silence", silence, observedAt, buildMeta(identity, "silence", "", false));
275
      pushCapability(messages, identity, "test", test, observedAt, buildMeta(identity, "test", "", false));
276
      pushCapability(messages, identity, "smoke_concentration", concentration, observedAt, buildMeta(identity, "smoke_concentration", "ppm", false));
277

            
278
      return messages;
279
    }
280

            
281
    function startSubscriptions() {
282
      if (node.subscriptionStarted) return;
283
      node.subscriptionStarted = true;
284
      node.send([null, { action: "subscribe", topic: node.sourceTopic, qos: 2, rh: 0, rap: true }]);
285
      setStatus("subscribed", "yellow");
286
    }
287

            
288
    node.on("input", function(msg, send, done) {
289
      send = send || function() { node.send.apply(node, arguments); };
290
      try {
291
        node.stats.processed += 1;
292
        var parsed = parseTopic(msg && msg.topic);
293
        if (!parsed) {
294
          node.stats.invalid += 1;
295
          setStatus("invalid", "red");
296
          if (done) done();
297
          return;
298
        }
299

            
300
        var identity = buildIdentity(parsed);
301
        var observedAt = toIsoTimestamp(msg && msg.payload && msg.payload.last_seen);
302
        var messages = [];
303

            
304
        if (parsed.availabilityTopic) {
305
          var active = asBool(msg.payload);
306
          if (active === null) active = false;
307
          publishAvailability(messages, identity, active, observedAt);
308
        } else if (msg.payload && typeof msg.payload === "object" && !Array.isArray(msg.payload)) {
309
          messages = processTelemetry(identity, msg.payload, observedAt);
310
        } else {
311
          node.stats.invalid += 1;
312
          setStatus("invalid", "red");
313
          if (done) done();
314
          return;
315
        }
316

            
317
        if (messages.length) send([messages, null]);
318
        setStatus();
319
        if (done) done();
320
      } catch (err) {
321
        node.stats.errors += 1;
322
        node.status({ fill: "red", shape: "ring", text: "error: " + err.message });
323
        if (done) done(err);
324
        else node.error(err, msg);
325
      }
326
    });
327

            
328
    node.on("close", function() {
329
      if (node.startTimer) {
330
        clearTimeout(node.startTimer);
331
        node.startTimer = null;
332
      }
333
    });
334

            
335
    node.startTimer = setTimeout(startSubscriptions, 250);
336
    node.status({ fill: "grey", shape: "ring", text: "starting" });
337
  }
338

            
339
  RED.nodes.registerType("z2m-pa-44z-homebus", PA44ZHomeBusNode);
340
};