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

            
6
    node.adapterId = "z2m-zg-204zv";
7
    node.sourceTopic = "zigbee2mqtt/ZG-204ZV/#";
8
    node.subscriptionStarted = false;
9
    node.startTimer = null;
10
    node.legacyRoom = normalizeToken(config.mqttRoom);
11
    node.legacySensor = normalizeToken(config.mqttSensor);
12
    node.legacyBus = normalizeToken(config.mqttBus);
13
    node.publishCache = Object.create(null);
14
    node.retainedCache = Object.create(null);
15
    node.seenOptionalCapabilities = Object.create(null);
16
    node.detectedDevices = Object.create(null);
17
    node.stats = {
18
      processed_inputs: 0,
19
      devices_detected: 0,
20
      home_messages: 0,
21
      last_messages: 0,
22
      meta_messages: 0,
23
      home_availability_messages: 0,
24
      operational_messages: 0,
25
      invalid_messages: 0,
26
      invalid_topics: 0,
27
      invalid_payloads: 0,
28
      unmapped_messages: 0,
29
      adapter_exceptions: 0,
30
      errors: 0,
31
      dlq: 0
32
    };
33
    node.statsPublishEvery = 25;
34
    node.batteryLowThreshold = Number(config.batteryLowThreshold);
35
    if (!Number.isFinite(node.batteryLowThreshold)) node.batteryLowThreshold = 20;
36
    node.batteryType = normalizeToken(config.batteryType).toLowerCase() || "alkaline";
37

            
38
    var CAPABILITY_MAPPINGS = [
39
      {
40
        sourceSystem: "zigbee2mqtt",
41
        sourceTopicMatch: "zigbee2mqtt/ZG-204ZV/<site>/<location>/<device_id>",
42
        sourceFields: ["presence", "occupancy"],
43
        targetBus: "home",
44
        targetCapability: "motion",
45
        core: true,
46
        stream: "value",
47
        payloadProfile: "scalar",
48
        dataType: "boolean",
49
        historianMode: "event",
50
        historianEnabled: true,
51
        applies: function(payload) {
52
          return readNumber(payload, "fading_time") === 0;
53
        },
54
        read: function(payload) {
55
          return readBool(payload, this.sourceFields);
56
        }
57
      },
58
      {
59
        sourceSystem: "zigbee2mqtt",
60
        sourceTopicMatch: "zigbee2mqtt/ZG-204ZV/<site>/<location>/<device_id>",
61
        sourceFields: ["presence", "occupancy"],
62
        targetBus: "home",
63
        targetCapability: "presence",
64
        core: true,
65
        stream: "value",
66
        payloadProfile: "scalar",
67
        dataType: "boolean",
68
        historianMode: "state",
69
        historianEnabled: true,
70
        applies: function(payload) {
71
          var fadingTime = readNumber(payload, "fading_time");
72
          return fadingTime === undefined || fadingTime !== 0;
73
        },
74
        read: function(payload) {
75
          return readBool(payload, this.sourceFields);
76
        }
77
      },
78
      {
79
        sourceSystem: "zigbee2mqtt",
80
        sourceTopicMatch: "zigbee2mqtt/ZG-204ZV/<site>/<location>/<device_id>",
81
        sourceFields: ["temperature"],
82
        targetBus: "home",
83
        targetCapability: "temperature",
84
        core: true,
85
        stream: "value",
86
        payloadProfile: "scalar",
87
        dataType: "number",
88
        unit: "C",
89
        precision: 0.1,
90
        historianMode: "sample",
91
        historianEnabled: true,
92
        read: function(payload) {
93
          return readNumber(payload, this.sourceFields[0]);
94
        }
95
      },
96
      {
97
        sourceSystem: "zigbee2mqtt",
98
        sourceTopicMatch: "zigbee2mqtt/ZG-204ZV/<site>/<location>/<device_id>",
99
        sourceFields: ["humidity"],
100
        targetBus: "home",
101
        targetCapability: "humidity",
102
        core: true,
103
        stream: "value",
104
        payloadProfile: "scalar",
105
        dataType: "number",
106
        unit: "%",
107
        precision: 1,
108
        historianMode: "sample",
109
        historianEnabled: true,
110
        read: function(payload) {
111
          return readNumber(payload, this.sourceFields[0]);
112
        }
113
      },
114
      {
115
        sourceSystem: "zigbee2mqtt",
116
        sourceTopicMatch: "zigbee2mqtt/ZG-204ZV/<site>/<location>/<device_id>",
117
        sourceFields: ["illuminance"],
118
        targetBus: "home",
119
        targetCapability: "illuminance",
120
        core: true,
121
        stream: "value",
122
        payloadProfile: "scalar",
123
        dataType: "number",
124
        unit: "lx",
125
        precision: 1,
126
        historianMode: "sample",
127
        historianEnabled: true,
128
        read: function(payload) {
129
          return readNumber(payload, this.sourceFields[0]);
130
        }
131
      },
132
      {
133
        sourceSystem: "zigbee2mqtt",
134
        sourceTopicMatch: "zigbee2mqtt/ZG-204ZV/<site>/<location>/<device_id>",
135
        sourceFields: ["battery"],
136
        targetBus: "home",
137
        targetCapability: "battery",
138
        core: true,
139
        stream: "value",
140
        payloadProfile: "scalar",
141
        dataType: "number",
142
        unit: "%",
143
        precision: 1,
144
        historianMode: "sample",
145
        historianEnabled: true,
146
        read: function(payload) {
147
          var value = readNumber(payload, this.sourceFields[0]);
148
          if (value === undefined) return undefined;
149
          return translateBatteryLevel(value);
150
        }
151
      },
152
      {
153
        sourceSystem: "zigbee2mqtt",
154
        sourceTopicMatch: "zigbee2mqtt/ZG-204ZV/<site>/<location>/<device_id>",
155
        sourceFields: ["battery_low", "batteryLow", "battery"],
156
        targetBus: "home",
157
        targetCapability: "battery_low",
158
        core: true,
159
        stream: "value",
160
        payloadProfile: "scalar",
161
        dataType: "boolean",
162
        historianMode: "state",
163
        historianEnabled: true,
164
        read: function(payload) {
165
          var raw = readBool(payload, this.sourceFields.slice(0, 2));
166
          if (raw !== undefined) return raw;
167
          var battery = translateBatteryLevel(readNumber(payload, this.sourceFields[2]));
168
          if (battery === undefined) return undefined;
169
          return battery <= node.batteryLowThreshold;
170
        }
171
      },
172
      {
173
        sourceSystem: "zigbee2mqtt",
174
        sourceTopicMatch: "zigbee2mqtt/ZG-204ZV/<site>/<location>/<device_id>",
175
        sourceFields: ["tamper", "tampered", "tamper_alarm", "alarm_tamper"],
176
        targetBus: "home",
177
        targetCapability: "tamper",
178
        core: false,
179
        stream: "value",
180
        payloadProfile: "scalar",
181
        dataType: "boolean",
182
        historianMode: "state",
183
        historianEnabled: true,
184
        read: function(payload) {
185
          return readBool(payload, this.sourceFields);
186
        }
187
      }
188
    ];
189

            
190
    function normalizeToken(value) {
191
      if (value === undefined || value === null) return "";
192
      return String(value).trim();
193
    }
194

            
195
    function transliterate(value) {
196
      var s = normalizeToken(value);
197
      if (!s) return "";
198
      if (typeof s.normalize === "function") {
199
        s = s.normalize("NFKD").replace(/[\u0300-\u036f]/g, "");
200
      }
201
      return s;
202
    }
203

            
204
    function toKebabCase(value, fallback) {
205
      var s = transliterate(value).toLowerCase().replace(/[^a-z0-9]+/g, "-");
206
      s = s.replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-");
207
      return s || fallback || "";
208
    }
209

            
210
    function clamp(n, min, max) {
211
      return Math.max(min, Math.min(max, n));
212
    }
213

            
214
    var ALKALINE_BATTERY_CURVE = [
215
      { pct: 100, voltage: 1.60 },
216
      { pct: 90, voltage: 1.55 },
217
      { pct: 80, voltage: 1.50 },
218
      { pct: 70, voltage: 1.46 },
219
      { pct: 60, voltage: 1.42 },
220
      { pct: 50, voltage: 1.36 },
221
      { pct: 40, voltage: 1.30 },
222
      { pct: 30, voltage: 1.25 },
223
      { pct: 20, voltage: 1.20 },
224
      { pct: 10, voltage: 1.10 },
225
      { pct: 0, voltage: 0.90 }
226
    ];
227

            
228
    var NIMH_BATTERY_CURVE = [
229
      { pct: 100, voltage: 1.40 },
230
      { pct: 95, voltage: 1.33 },
231
      { pct: 90, voltage: 1.28 },
232
      { pct: 85, voltage: 1.24 },
233
      { pct: 80, voltage: 1.20 },
234
      { pct: 75, voltage: 1.19 },
235
      { pct: 70, voltage: 1.18 },
236
      { pct: 60, voltage: 1.17 },
237
      { pct: 50, voltage: 1.16 },
238
      { pct: 40, voltage: 1.15 },
239
      { pct: 30, voltage: 1.13 },
240
      { pct: 20, voltage: 1.11 },
241
      { pct: 10, voltage: 1.07 },
242
      { pct: 0, voltage: 1.00 }
243
    ];
244

            
245
    function interpolateCurve(points, inputKey, outputKey, inputValue) {
246
      if (!Array.isArray(points) || points.length === 0) return undefined;
247
      if (inputValue >= points[0][inputKey]) return points[0][outputKey];
248

            
249
      for (var i = 1; i < points.length; i++) {
250
        var upper = points[i - 1];
251
        var lower = points[i];
252
        var upperInput = upper[inputKey];
253
        var lowerInput = lower[inputKey];
254

            
255
        if (inputValue >= lowerInput) {
256
          var range = upperInput - lowerInput;
257
          if (range <= 0) return lower[outputKey];
258
          var ratio = (inputValue - lowerInput) / range;
259
          return lower[outputKey] + ratio * (upper[outputKey] - lower[outputKey]);
260
        }
261
      }
262

            
263
      return points[points.length - 1][outputKey];
264
    }
265

            
266
    function translateBatteryLevel(rawValue) {
267
      if (rawValue === undefined) return undefined;
268
      var raw = clamp(Math.round(Number(rawValue)), 0, 100);
269
      if (node.batteryType !== "nimh") return raw;
270

            
271
      // Reinterpret the reported percentage on an alkaline discharge curve,
272
      // then project the equivalent cell voltage onto a flatter NiMH curve.
273
      var estimatedVoltage = interpolateCurve(ALKALINE_BATTERY_CURVE, "pct", "voltage", raw);
274
      var nimhPct = interpolateCurve(NIMH_BATTERY_CURVE, "voltage", "pct", estimatedVoltage);
275
      return clamp(Math.round(nimhPct), 0, 100);
276
    }
277

            
278
    function topicTokens(topic) {
279
      if (typeof topic !== "string") return [];
280
      return topic.split("/").map(function(token) { return token.trim(); }).filter(function(token) { return !!token; });
281
    }
282

            
283
    function asBool(value) {
284
      if (typeof value === "boolean") return value;
285
      if (typeof value === "number") return value !== 0;
286
      if (typeof value === "string") {
287
        var v = value.trim().toLowerCase();
288
        if (v === "true" || v === "1" || v === "on" || v === "yes" || v === "online") return true;
289
        if (v === "false" || v === "0" || v === "off" || v === "no" || v === "offline") return false;
290
      }
291
      return null;
292
    }
293

            
294
    function pickFirst(obj, keys) {
295
      if (!obj || typeof obj !== "object") return undefined;
296
      for (var i = 0; i < keys.length; i++) {
297
        if (Object.prototype.hasOwnProperty.call(obj, keys[i])) return obj[keys[i]];
298
      }
299
      return undefined;
300
    }
301

            
302
    function pickPath(obj, path) {
303
      if (!obj || typeof obj !== "object") return undefined;
304
      var parts = path.split(".");
305
      var current = obj;
306
      for (var i = 0; i < parts.length; i++) {
307
        if (!current || typeof current !== "object" || !Object.prototype.hasOwnProperty.call(current, parts[i])) return undefined;
308
        current = current[parts[i]];
309
      }
310
      return current;
311
    }
312

            
313
    function pickFirstPath(obj, paths) {
314
      for (var i = 0; i < paths.length; i++) {
315
        var value = pickPath(obj, paths[i]);
316
        if (value !== undefined && value !== null && normalizeToken(value) !== "") return value;
317
      }
318
      return undefined;
319
    }
320

            
321
    function readBool(payload, fields) {
322
      var raw = asBool(pickFirst(payload, fields));
323
      return raw === null ? undefined : raw;
324
    }
325

            
326
    function readNumber(payload, field) {
327
      if (!payload || typeof payload !== "object") return undefined;
328
      var value = payload[field];
329
      if (typeof value !== "number" || !isFinite(value)) return undefined;
330
      return Number(value);
331
    }
332

            
333
    function toIsoTimestamp(value) {
334
      if (value === undefined || value === null) return "";
335
      if (value instanceof Date && !isNaN(value.getTime())) return value.toISOString();
336
      if (typeof value === "number" && isFinite(value)) {
337
        var ms = value < 100000000000 ? value * 1000 : value;
338
        var dateFromNumber = new Date(ms);
339
        return isNaN(dateFromNumber.getTime()) ? "" : dateFromNumber.toISOString();
340
      }
341
      if (typeof value === "string") {
342
        var trimmed = value.trim();
343
        if (!trimmed) return "";
344
        if (/^\d+$/.test(trimmed)) return toIsoTimestamp(Number(trimmed));
345
        var dateFromString = new Date(trimmed);
346
        return isNaN(dateFromString.getTime()) ? "" : dateFromString.toISOString();
347
      }
348
      return "";
349
    }
350

            
351
    function canonicalSourceTopic(topic) {
352
      var t = normalizeToken(topic);
353
      if (!t) return "";
354
      if (/\/availability$/i.test(t)) return t.replace(/\/availability$/i, "");
355
      return t;
356
    }
357

            
358
    function inferFromTopic(topic) {
359
      var tokens = topicTokens(topic);
360
      var result = {
361
        deviceType: "",
362
        site: "",
363
        location: "",
364
        deviceId: "",
365
        friendlyName: "",
366
        isAvailabilityTopic: false
367
      };
368

            
369
      if (tokens.length >= 2 && tokens[0].toLowerCase() === "zigbee2mqtt") {
370
        result.deviceType = tokens[1];
371

            
372
        if (tokens.length >= 6 && tokens[5].toLowerCase() === "availability") {
373
          result.site = tokens[2];
374
          result.location = tokens[3];
375
          result.deviceId = tokens[4];
376
          result.friendlyName = tokens.slice(1, 5).join("/");
377
          result.isAvailabilityTopic = true;
378
          return result;
379
        }
380

            
381
        if (tokens.length >= 5 && tokens[4].toLowerCase() !== "availability") {
382
          result.site = tokens[2];
383
          result.location = tokens[3];
384
          result.deviceId = tokens[4];
385
          result.friendlyName = tokens.slice(1, 5).join("/");
386
          return result;
387
        }
388

            
389
        if (tokens.length >= 5 && tokens[4].toLowerCase() === "availability") {
390
          result.location = tokens[2];
391
          result.deviceId = tokens[3];
392
          result.friendlyName = tokens.slice(1, 4).join("/");
393
          result.isAvailabilityTopic = true;
394
          return result;
395
        }
396

            
397
        if (tokens.length >= 4) {
398
          result.location = tokens[2];
399
          result.deviceId = tokens[3];
400
          result.friendlyName = tokens.slice(1, 4).join("/");
401
          return result;
402
        }
403

            
404
        result.friendlyName = tokens.slice(1).join("/");
405
        result.deviceId = tokens[1];
406
        result.isAvailabilityTopic = tokens.length >= 3 && tokens[tokens.length - 1].toLowerCase() === "availability";
407
        return result;
408
      }
409

            
410
      if (tokens.length >= 5 && tokens[1].toLowerCase() === "home") {
411
        result.site = tokens[0];
412
        result.location = tokens[2];
413
        result.deviceId = tokens[4];
414
        result.isAvailabilityTopic = tokens.length >= 6 && tokens[5].toLowerCase() === "availability";
415
        return result;
416
      }
417

            
418
      if (tokens.length >= 4) {
419
        result.site = tokens[0];
420
        result.location = tokens[2];
421
        result.deviceId = tokens[3];
422
      } else if (tokens.length >= 2) {
423
        result.location = tokens[tokens.length - 2];
424
        result.deviceId = tokens[tokens.length - 1];
425
      } else if (tokens.length === 1) {
426
        result.deviceId = tokens[0];
427
      }
428

            
429
      return result;
430
    }
431

            
432
    function resolveIdentity(msg, payload) {
433
      var safeMsg = (msg && typeof msg === "object") ? msg : {};
434
      var inferred = inferFromTopic(safeMsg.topic);
435
      var siteRaw = inferred.site
436
        || normalizeToken(pickFirst(payload, ["site", "homeSite"]))
437
        || normalizeToken(pickFirst(safeMsg, ["site", "homeSite"]))
438
        || "";
439

            
440
      var locationRaw = inferred.location
441
        || normalizeToken(pickFirst(payload, ["location", "room", "homeLocation", "mqttRoom"]))
442
        || normalizeToken(pickFirst(safeMsg, ["location", "room", "homeLocation", "mqttRoom"]))
443
        || node.legacyRoom
444
        || "";
445

            
446
      var deviceRaw = inferred.deviceId
447
        || normalizeToken(pickFirst(payload, ["deviceId", "device_id", "sensor", "mqttSensor", "friendly_name", "friendlyName", "name", "device"]))
448
        || normalizeToken(pickFirst(safeMsg, ["deviceId", "device_id", "sensor", "mqttSensor"]))
449
        || node.legacySensor
450
        || inferred.friendlyName
451
        || inferred.deviceType;
452

            
453
      var displayName = normalizeToken(pickFirst(payload, ["display_name", "displayName", "friendly_name", "friendlyName", "name"]))
454
        || normalizeToken(pickFirst(safeMsg, ["display_name", "displayName", "friendly_name", "friendlyName", "name"]))
455
        || inferred.friendlyName
456
        || deviceRaw
457
        || "ZG-204ZV";
458

            
459
      var sourceRef = normalizeToken(pickFirstPath(payload, ["source_ref", "sourceRef", "ieee_address", "ieeeAddr", "device.ieee_address", "device.ieeeAddr"]))
460
        || normalizeToken(pickFirstPath(safeMsg, ["source_ref", "sourceRef", "ieee_address", "ieeeAddr", "device.ieee_address", "device.ieeeAddr"]))
461
        || canonicalSourceTopic(safeMsg.topic)
462
        || inferred.friendlyName
463
        || inferred.deviceType
464
        || deviceRaw
465
        || "z2m-zg-204zv";
466

            
467
      return {
468
        site: toKebabCase(siteRaw, "unknown"),
469
        location: toKebabCase(locationRaw, "unknown"),
470
        deviceId: toKebabCase(deviceRaw, toKebabCase(inferred.deviceType, "zg-204zv")),
471
        displayName: displayName,
472
        sourceRef: sourceRef,
473
        sourceTopic: canonicalSourceTopic(safeMsg.topic),
474
        isAvailabilityTopic: inferred.isAvailabilityTopic
475
      };
476
    }
477

            
478
    function noteDevice(identity) {
479
      if (!identity) return;
480
      var key = identity.site + "/" + identity.location + "/" + identity.deviceId;
481
      if (node.detectedDevices[key]) return;
482
      node.detectedDevices[key] = true;
483
      node.stats.devices_detected += 1;
484
    }
485

            
486
    function validateInboundTopic(topic) {
487
      var rawTopic = normalizeToken(topic);
488
      if (!rawTopic) {
489
        return {
490
          valid: false,
491
          reason: "Topic must be a non-empty string"
492
        };
493
      }
494

            
495
      var tokens = rawTopic.split("/").map(function(token) {
496
        return token.trim();
497
      });
498
      if (tokens.length < 5) {
499
        return {
500
          valid: false,
501
          reason: "Topic must match zigbee2mqtt/ZG-204ZV/<site>/<location>/<device_id>[/availability]"
502
        };
503
      }
504
      if (tokens[0].toLowerCase() !== "zigbee2mqtt" || tokens[1].toLowerCase() !== "zg-204zv") {
505
        return {
506
          valid: false,
507
          reason: "Topic must start with zigbee2mqtt/ZG-204ZV"
508
        };
509
      }
510
      if (!tokens[2] || !tokens[3] || !tokens[4]) {
511
        return {
512
          valid: false,
513
          reason: "Topic must contain non-empty site, location and device_id segments"
514
        };
515
      }
516
      if (tokens.length === 5) {
517
        return {
518
          valid: true,
519
          isAvailabilityTopic: false
520
        };
521
      }
522
      if (tokens.length === 6 && tokens[5].toLowerCase() === "availability") {
523
        return {
524
          valid: true,
525
          isAvailabilityTopic: true
526
        };
527
      }
528
      return {
529
        valid: false,
530
        reason: "Topic must not contain extra segments beyond an optional /availability suffix"
531
      };
532
    }
533

            
534
    function translatedMessageCount() {
535
      return node.stats.home_messages + node.stats.last_messages + node.stats.meta_messages + node.stats.home_availability_messages;
536
    }
537

            
538
    function updateNodeStatus(fill, shape, suffix) {
539
      var parts = [
540
        "dev " + node.stats.devices_detected,
541
        "in " + node.stats.processed_inputs,
542
        "tr " + translatedMessageCount()
543
      ];
544
      if (node.stats.operational_messages > 0) parts.push("op " + node.stats.operational_messages);
545
      if (node.stats.errors > 0) parts.push("err " + node.stats.errors);
546
      if (node.stats.invalid_topics > 0) parts.push("topic " + node.stats.invalid_topics);
547
      if (node.stats.invalid_messages > 0) parts.push("msg " + node.stats.invalid_messages);
548
      if (node.stats.invalid_payloads > 0) parts.push("payload " + node.stats.invalid_payloads);
549
      if (node.stats.unmapped_messages > 0) parts.push("unmapped " + node.stats.unmapped_messages);
550
      if (node.stats.adapter_exceptions > 0) parts.push("exc " + node.stats.adapter_exceptions);
551
      if (node.stats.dlq > 0) parts.push("dlq " + node.stats.dlq);
552
      if (suffix) parts.push(suffix);
553
      node.status({
554
        fill: fill,
555
        shape: shape,
556
        text: parts.join(" | ")
557
      });
558
    }
559

            
560
    function buildHomeTopic(identity, mapping, stream) {
561
      return identity.site + "/" + mapping.targetBus + "/" + identity.location + "/" + mapping.targetCapability + "/" + identity.deviceId + "/" + stream;
562
    }
563

            
564
    function buildSysTopic(site, stream) {
565
      return site + "/sys/adapter/" + node.adapterId + "/" + stream;
566
    }
567

            
568
    function makePublishMsg(topic, payload, retain) {
569
      return {
570
        topic: topic,
571
        payload: payload,
572
        qos: 1,
573
        retain: !!retain
574
      };
575
    }
576

            
577
    function makeSubscribeMsg(topic) {
578
      return {
579
        action: "subscribe",
580
        topic: [{
581
          topic: topic,
582
          qos: 2,
583
          rh: 0,
584
          rap: true
585
        }]
586
      };
587
    }
588

            
589
    function signature(value) {
590
      return JSON.stringify(value);
591
    }
592

            
593
    function shouldPublishLiveValue(cacheKey, payload) {
594
      var sig = signature(payload);
595
      if (node.publishCache[cacheKey] === sig) return false;
596
      node.publishCache[cacheKey] = sig;
597
      return true;
598
    }
599

            
600
    function shouldPublishRetained(cacheKey, payload) {
601
      var sig = signature(payload);
602
      if (node.retainedCache[cacheKey] === sig) return false;
603
      node.retainedCache[cacheKey] = sig;
604
      return true;
605
    }
606

            
607
    function humanizeCapability(capability) {
608
      return capability.replace(/_/g, " ").replace(/\b[a-z]/g, function(ch) { return ch.toUpperCase(); });
609
    }
610

            
611
    function resolveObservation(msg, payloadObject) {
612
      var safeMsg = (msg && typeof msg === "object") ? msg : {};
613
      var observedAtRaw = pickFirstPath(payloadObject, [
614
        "observed_at",
615
        "observedAt",
616
        "timestamp",
617
        "time",
618
        "ts",
619
        "last_seen",
620
        "lastSeen",
621
        "device.last_seen",
622
        "device.lastSeen"
623
      ]) || pickFirstPath(safeMsg, [
624
        "observed_at",
625
        "observedAt",
626
        "timestamp",
627
        "time",
628
        "ts"
629
      ]);
630
      var observedAt = toIsoTimestamp(observedAtRaw);
631
      if (observedAt) {
632
        return {
633
          observedAt: observedAt,
634
          quality: "good"
635
        };
636
      }
637
      return {
638
        observedAt: new Date().toISOString(),
639
        quality: "estimated"
640
      };
641
    }
642

            
643
    function buildMetaPayload(identity, mapping) {
644
      var payload = {
645
        schema_ref: "mqbus.home.v1",
646
        payload_profile: mapping.payloadProfile,
647
        stream_payload_profiles: {
648
          value: "scalar",
649
          last: "envelope"
650
        },
651
        data_type: mapping.dataType,
652
        adapter_id: node.adapterId,
653
        source: mapping.sourceSystem,
654
        source_ref: identity.sourceRef,
655
        source_topic: identity.sourceTopic,
656
        display_name: identity.displayName + " " + humanizeCapability(mapping.targetCapability),
657
        tags: [mapping.sourceSystem, "zg-204zv", mapping.targetBus],
658
        historian: {
659
          enabled: !!mapping.historianEnabled,
660
          mode: mapping.historianMode
661
        }
662
      };
663

            
664
      if (mapping.unit) payload.unit = mapping.unit;
665
      if (mapping.precision !== undefined) payload.precision = mapping.precision;
666

            
667
      return payload;
668
    }
669

            
670
    function buildLastPayload(value, observation) {
671
      var payload = {
672
        value: value,
673
        observed_at: observation.observedAt
674
      };
675
      if (observation.quality && observation.quality !== "good") payload.quality = observation.quality;
676
      return payload;
677
    }
678

            
679
    function noteMessage(kind) {
680
      if (!Object.prototype.hasOwnProperty.call(node.stats, kind)) return;
681
      node.stats[kind] += 1;
682
    }
683

            
684
    function noteErrorKind(code) {
685
      if (code === "invalid_message") {
686
        noteMessage("invalid_messages");
687
      } else if (code === "invalid_topic") {
688
        noteMessage("invalid_topics");
689
      } else if (code === "payload_not_object" || code === "invalid_availability_payload") {
690
        noteMessage("invalid_payloads");
691
      } else if (code === "no_mapped_fields") {
692
        noteMessage("unmapped_messages");
693
      } else if (code === "adapter_exception") {
694
        noteMessage("adapter_exceptions");
695
      }
696
    }
697

            
698
    function summarizeForLog(value) {
699
      if (value === undefined) return "undefined";
700
      if (value === null) return "null";
701
      if (typeof value === "string") {
702
        return value.length > 180 ? value.slice(0, 177) + "..." : value;
703
      }
704
      try {
705
        var serialized = JSON.stringify(value);
706
        if (serialized.length > 180) return serialized.slice(0, 177) + "...";
707
        return serialized;
708
      } catch (err) {
709
        return String(value);
710
      }
711
    }
712

            
713
    function logIssue(level, code, reason, msg, rawPayload) {
714
      var parts = [
715
        "[" + node.adapterId + "]",
716
        code + ":",
717
        reason
718
      ];
719
      var sourceTopic = msg && typeof msg === "object" ? normalizeToken(msg.topic) : "";
720
      if (sourceTopic) parts.push("topic=" + sourceTopic);
721
      if (rawPayload !== undefined) parts.push("payload=" + summarizeForLog(rawPayload));
722

            
723
      var text = parts.join(" ");
724
      if (level === "error") {
725
        node.error(text, msg);
726
        return;
727
      }
728
      node.warn(text);
729
    }
730

            
731
    function enqueueHomeMeta(messages, identity, mapping) {
732
      var topic = buildHomeTopic(identity, mapping, "meta");
733
      var payload = buildMetaPayload(identity, mapping);
734
      if (!shouldPublishRetained("meta:" + topic, payload)) return;
735
      messages.push(makePublishMsg(topic, payload, true));
736
      noteMessage("meta_messages");
737
    }
738

            
739
    function enqueueHomeAvailability(messages, identity, mapping, online) {
740
      var topic = buildHomeTopic(identity, mapping, "availability");
741
      var payload = online ? "online" : "offline";
742
      if (!shouldPublishRetained("availability:" + topic, payload)) return;
743
      messages.push(makePublishMsg(topic, payload, true));
744
      noteMessage("home_availability_messages");
745
    }
746

            
747
    function enqueueHomeLast(messages, identity, mapping, value, observation) {
748
      var topic = buildHomeTopic(identity, mapping, "last");
749
      var payload = buildLastPayload(value, observation);
750
      if (!shouldPublishRetained("last:" + topic, payload)) return;
751
      messages.push(makePublishMsg(topic, payload, true));
752
      noteMessage("last_messages");
753
    }
754

            
755
    function enqueueHomeValue(messages, identity, mapping, value) {
756
      var topic = buildHomeTopic(identity, mapping, "value");
757
      if (!shouldPublishLiveValue("value:" + topic, value)) return;
758
      messages.push(makePublishMsg(topic, value, false));
759
      noteMessage("home_messages");
760
    }
761

            
762
    function enqueueAdapterAvailability(messages, site, online) {
763
      var topic = buildSysTopic(site, "availability");
764
      var payload = online ? "online" : "offline";
765
      if (!shouldPublishRetained("sys:availability:" + topic, payload)) return;
766
      messages.push(makePublishMsg(topic, payload, true));
767
      noteMessage("operational_messages");
768
    }
769

            
770
    function enqueueAdapterStats(messages, site, force) {
771
      if (!force && node.stats.processed_inputs !== 1 && (node.stats.processed_inputs % node.statsPublishEvery) !== 0) return;
772
      var topic = buildSysTopic(site, "stats");
773
      var payload = {
774
        processed_inputs: node.stats.processed_inputs,
775
        devices_detected: node.stats.devices_detected,
776
        translated_messages: translatedMessageCount(),
777
        home_messages: node.stats.home_messages,
778
        last_messages: node.stats.last_messages,
779
        meta_messages: node.stats.meta_messages,
780
        home_availability_messages: node.stats.home_availability_messages,
781
        operational_messages: node.stats.operational_messages,
782
        invalid_messages: node.stats.invalid_messages,
783
        invalid_topics: node.stats.invalid_topics,
784
        invalid_payloads: node.stats.invalid_payloads,
785
        unmapped_messages: node.stats.unmapped_messages,
786
        adapter_exceptions: node.stats.adapter_exceptions,
787
        errors: node.stats.errors,
788
        dlq: node.stats.dlq
789
      };
790
      messages.push(makePublishMsg(topic, payload, true));
791
      noteMessage("operational_messages");
792
    }
793

            
794
    function enqueueError(messages, site, code, reason, sourceTopic) {
795
      var payload = {
796
        code: code,
797
        reason: reason,
798
        source_topic: normalizeToken(sourceTopic),
799
        adapter_id: node.adapterId
800
      };
801
      messages.push(makePublishMsg(buildSysTopic(site, "error"), payload, false));
802
      noteErrorKind(code);
803
      noteMessage("errors");
804
      noteMessage("operational_messages");
805
    }
806

            
807
    function enqueueDlq(messages, site, code, sourceTopic, rawPayload) {
808
      var payload = {
809
        code: code,
810
        source_topic: normalizeToken(sourceTopic),
811
        payload: rawPayload
812
      };
813
      messages.push(makePublishMsg(buildSysTopic(site, "dlq"), payload, false));
814
      noteMessage("dlq");
815
      noteMessage("operational_messages");
816
    }
817

            
818
    function activeMappings(payloadObject) {
819
      var result = [];
820
      for (var i = 0; i < CAPABILITY_MAPPINGS.length; i++) {
821
        var mapping = CAPABILITY_MAPPINGS[i];
822
        if (typeof mapping.applies === "function" && !mapping.applies(payloadObject)) continue;
823
        var value = payloadObject ? mapping.read(payloadObject) : undefined;
824
        var seen = mapping.core || node.seenOptionalCapabilities[mapping.targetCapability];
825
        if (value !== undefined) node.seenOptionalCapabilities[mapping.targetCapability] = true;
826
        if (mapping.core || seen || value !== undefined) {
827
          result.push({
828
            mapping: mapping,
829
            hasValue: value !== undefined,
830
            value: value
831
          });
832
        }
833
      }
834
      return result;
835
    }
836

            
837
    function resolveOnlineState(payloadObject, availabilityValue) {
838
      if (availabilityValue !== null && availabilityValue !== undefined) return !!availabilityValue;
839
      if (!payloadObject) return true;
840
      var active = true;
841
      if (typeof payloadObject.availability === "string") active = payloadObject.availability.trim().toLowerCase() !== "offline";
842
      if (typeof payloadObject.online === "boolean") active = payloadObject.online;
843
      return active;
844
    }
845

            
846
    function flush(send, done, controlMessages, publishMessages, error) {
847
      send([
848
        publishMessages.length ? publishMessages : null,
849
        controlMessages.length ? controlMessages : null
850
      ]);
851
      if (done) done(error);
852
    }
853

            
854
    function startSubscriptions() {
855
      if (node.subscriptionStarted) return;
856
      node.subscriptionStarted = true;
857
      node.send([null, makeSubscribeMsg(node.sourceTopic)]);
858
      updateNodeStatus("grey", "dot", "subscribed");
859
    }
860

            
861
    node.on("input", function(msg, send, done) {
862
      send = send || function() { node.send.apply(node, arguments); };
863
      node.stats.processed_inputs += 1;
864

            
865
      try {
866
        var controlMessages = [];
867
        if (!msg || typeof msg !== "object") {
868
          var invalidMessages = [];
869
          var invalidSite = resolveIdentity({}, null).site;
870
          enqueueAdapterAvailability(invalidMessages, invalidSite, true);
871
          enqueueError(invalidMessages, invalidSite, "invalid_message", "Input must be an object", "");
872
          enqueueDlq(invalidMessages, invalidSite, "invalid_message", "", null);
873
          enqueueAdapterStats(invalidMessages, invalidSite, true);
874
          logIssue("warn", "invalid_message", "Input must be an object", null, msg);
875
          updateNodeStatus("yellow", "ring", "bad msg");
876
          flush(send, done, controlMessages, invalidMessages);
877
          return;
878
        }
879

            
880
        var payload = msg.payload;
881
        var payloadObject = payload && typeof payload === "object" && !Array.isArray(payload) ? payload : null;
882
        var identity = resolveIdentity(msg, payloadObject);
883
        var observation = resolveObservation(msg, payloadObject);
884
        var messages = [];
885
        var topicValidation = validateInboundTopic(msg.topic);
886
        var availabilityValue = null;
887

            
888
        enqueueAdapterAvailability(messages, identity.site, true);
889

            
890
        if (!topicValidation.valid) {
891
          enqueueError(messages, identity.site, "invalid_topic", topicValidation.reason, msg.topic);
892
          enqueueDlq(messages, identity.site, "invalid_topic", msg.topic, payload);
893
          enqueueAdapterStats(messages, identity.site, true);
894
          logIssue("warn", "invalid_topic", topicValidation.reason, msg, payload);
895
          updateNodeStatus("yellow", "ring", "bad topic");
896
          flush(send, done, controlMessages, messages);
897
          return;
898
        }
899

            
900
        identity.isAvailabilityTopic = topicValidation.isAvailabilityTopic;
901
        noteDevice(identity);
902

            
903
        var isAvailabilityTopic = topicValidation.isAvailabilityTopic;
904

            
905
        if (isAvailabilityTopic) {
906
          availabilityValue = asBool(payload);
907
          if (availabilityValue === null) {
908
            enqueueError(messages, identity.site, "invalid_availability_payload", "Availability payload must be online/offline or boolean", msg.topic);
909
            enqueueDlq(messages, identity.site, "invalid_availability_payload", msg.topic, payload);
910
            enqueueAdapterStats(messages, identity.site, true);
911
            logIssue("warn", "invalid_availability_payload", "Availability payload must be online/offline or boolean", msg, payload);
912
            updateNodeStatus("yellow", "ring", "invalid availability");
913
            flush(send, done, controlMessages, messages);
914
            return;
915
          }
916
        } else if (!payloadObject) {
917
          enqueueError(messages, identity.site, "payload_not_object", "Telemetry payload must be an object", msg.topic);
918
          enqueueDlq(messages, identity.site, "payload_not_object", msg.topic, payload);
919
          enqueueAdapterStats(messages, identity.site, true);
920
          logIssue("warn", "payload_not_object", "Telemetry payload must be an object", msg, payload);
921
          updateNodeStatus("yellow", "ring", "payload not object");
922
          flush(send, done, controlMessages, messages);
923
          return;
924
        }
925

            
926
        var online = resolveOnlineState(payloadObject, availabilityValue);
927
        var mappings = activeMappings(payloadObject);
928
        var hasMappedValue = false;
929
        for (var i = 0; i < mappings.length; i++) {
930
          if (mappings[i].hasValue) {
931
            hasMappedValue = true;
932
            break;
933
          }
934
        }
935
        var hasAvailabilityField = isAvailabilityTopic || (payloadObject && (typeof payloadObject.availability === "string" || typeof payloadObject.online === "boolean"));
936

            
937
        if (!hasMappedValue && !hasAvailabilityField) {
938
          enqueueError(messages, identity.site, "no_mapped_fields", "Payload did not contain any supported ZG-204ZV fields", msg.topic);
939
          enqueueDlq(messages, identity.site, "no_mapped_fields", msg.topic, payloadObject);
940
          logIssue("warn", "no_mapped_fields", "Payload did not contain any supported ZG-204ZV fields", msg, payloadObject);
941
        }
942

            
943
        for (var i = 0; i < mappings.length; i++) {
944
          enqueueHomeMeta(messages, identity, mappings[i].mapping);
945
          enqueueHomeAvailability(messages, identity, mappings[i].mapping, online);
946
          if (mappings[i].hasValue) {
947
            enqueueHomeLast(messages, identity, mappings[i].mapping, mappings[i].value, observation);
948
            enqueueHomeValue(messages, identity, mappings[i].mapping, mappings[i].value);
949
          }
950
        }
951

            
952
        enqueueAdapterStats(messages, identity.site, false);
953

            
954
        if (!hasMappedValue && !hasAvailabilityField) {
955
          updateNodeStatus("yellow", "ring", "unmapped");
956
        } else {
957
          updateNodeStatus(online ? "green" : "yellow", online ? "dot" : "ring", online ? null : "offline");
958
        }
959

            
960
        flush(send, done, controlMessages, messages);
961
      } catch (err) {
962
        var errorPayload = msg && msg.payload && typeof msg.payload === "object" && !Array.isArray(msg.payload) ? msg.payload : null;
963
        var errorIdentity = resolveIdentity(msg, errorPayload);
964
        noteDevice(errorIdentity);
965
        var errorMessages = [];
966
        enqueueAdapterAvailability(errorMessages, errorIdentity.site, true);
967
        enqueueError(errorMessages, errorIdentity.site, "adapter_exception", err.message, msg && msg.topic);
968
        enqueueAdapterStats(errorMessages, errorIdentity.site, true);
969
        logIssue("error", "adapter_exception", err.message, msg, msg && msg.payload);
970
        updateNodeStatus("red", "ring", "error");
971
        flush(send, done, [], errorMessages, err);
972
      }
973
    });
974

            
975
    node.on("close", function() {
976
      if (node.startTimer) {
977
        clearTimeout(node.startTimer);
978
        node.startTimer = null;
979
      }
980
    });
981

            
982
    node.startTimer = setTimeout(startSubscriptions, 250);
983
    updateNodeStatus("grey", "ring", "waiting");
984
  }
985

            
986
  RED.nodes.registerType("z2m-zg-204zv-homebus", Z2MZG204ZVHomeBusNode);
987
};