@airnexus/node-red-contrib-matter-airnexus 0.2.4-airnexus.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,880 @@
1
+ /**
2
+ * matter-device.js (OPTION 2 + PERSIST ENABLE/NAME + rich status)
3
+ *
4
+ * Output 1: events/state (attribute $Changed + thermostat diffs on interactionEnd)
5
+ * Output 2: commands (real Matter cluster commands) + optional pseudo-commands for attribute writes
6
+ * Output 3: debug/diagnostics
7
+ *
8
+ * OPTION 2:
9
+ * - Device is NOT registered to bridge/aggregator until enabled.
10
+ * - Once enabled, it registers and appears in Alexa.
11
+ * - Disabling sets reachable=false and suppresses outputs (does not unpublish).
12
+ *
13
+ * PERSISTENCE:
14
+ * - Persists enabled + name to node context so it survives Node-RED redeploy/restart.
15
+ * - On boot, if persisted enabled=true, auto-registers and becomes reachable.
16
+ *
17
+ * Status:
18
+ * - Disabled: grey ring "disabled (not registered)" or "disabled"
19
+ * - Enabled: green dot "Name | mode:X | H:xx.x C:xx.x | T:xx.x | last:<source>"
20
+ */
21
+
22
+ const { Endpoint } = require("@matter/main");
23
+ const matterBehaviors = require("@matter/main/behaviors");
24
+ const { getMatterDeviceId, getMatterUniqueId } = require("./utils");
25
+
26
+ // ============================================================================
27
+ // BEHAVIOR PATCHING (Output 2 commands + Output 3 debug)
28
+ // ============================================================================
29
+
30
+ let behaviorsPatched = false;
31
+
32
+ function patchBehaviors() {
33
+ if (behaviorsPatched) return;
34
+
35
+ Object.values(matterBehaviors).forEach((BehaviorClass) => {
36
+ if (!BehaviorClass?.cluster?.commands || typeof BehaviorClass !== "function") return;
37
+
38
+ Object.entries(BehaviorClass.cluster.commands).forEach(([cmd, def]) => {
39
+ const originalMethod = BehaviorClass.prototype[cmd];
40
+
41
+ BehaviorClass.prototype[cmd] = async function (request) {
42
+ const node = this.endpoint?.nodeRed;
43
+ if (node && !node._closed) {
44
+ const clusterName = BehaviorClass.cluster?.name || BehaviorClass.name || "unknown";
45
+ const payload = { command: cmd, cluster: clusterName, data: request };
46
+
47
+ try {
48
+ if (!node._enabled) {
49
+ node._dbg({ type: "command_rx_ignored_disabled", payload });
50
+ } else {
51
+ node.send([null, { payload }, null]);
52
+ node._dbg({ type: "command_rx", payload });
53
+ }
54
+ } catch (e) {}
55
+ }
56
+
57
+ if (originalMethod && !originalMethod.toString().includes("unimplemented")) {
58
+ return await originalMethod.call(this, request);
59
+ }
60
+
61
+ if (!def.responseSchema || def.responseSchema.name === "TlvNoResponse") return;
62
+ return { status: 0 };
63
+ };
64
+ });
65
+ });
66
+
67
+ behaviorsPatched = true;
68
+ }
69
+
70
+ patchBehaviors();
71
+
72
+ // Load devices AFTER patching
73
+ const matterDevices = require("@matter/main/devices");
74
+ const { BridgedDeviceBasicInformationServer, IdentifyServer } = matterBehaviors;
75
+
76
+ // ============================================================================
77
+ // HELPERS
78
+ // ============================================================================
79
+
80
+ function isObject(x) {
81
+ return x && typeof x === "object" && !Array.isArray(x);
82
+ }
83
+
84
+ function c01_to_c(v) {
85
+ if (typeof v !== "number" || !Number.isFinite(v)) return null;
86
+ return Math.round(v) / 100;
87
+ }
88
+
89
+ const SYSTEM_MODE_LABEL = {
90
+ 0: "off",
91
+ 1: "auto",
92
+ 3: "cool",
93
+ 4: "heat",
94
+ 5: "emHeat",
95
+ 6: "preCool",
96
+ 7: "fan",
97
+ 8: "dry",
98
+ 9: "sleep"
99
+ };
100
+
101
+ function formatThermoStatus(t) {
102
+ if (!t) return "";
103
+ const mode = (t.systemMode != null)
104
+ ? `${t.systemMode}${SYSTEM_MODE_LABEL[t.systemMode] ? ":" + SYSTEM_MODE_LABEL[t.systemMode] : ""}`
105
+ : "-";
106
+ const h = c01_to_c(t.occupiedHeatingSetpoint);
107
+ const c = c01_to_c(t.occupiedCoolingSetpoint);
108
+ const lt = c01_to_c(t.localTemperature);
109
+
110
+ const parts = [];
111
+ parts.push(`mode:${mode}`);
112
+ if (h != null) parts.push(`H:${h.toFixed(1)}`);
113
+ if (c != null) parts.push(`C:${c.toFixed(1)}`);
114
+ if (lt != null) parts.push(`T:${lt.toFixed(1)}`);
115
+
116
+ return parts.join(" ");
117
+ }
118
+
119
+ function cloneState(obj) {
120
+ if (!isObject(obj)) return obj;
121
+ try { return JSON.parse(JSON.stringify(obj)); } catch (e) { return { ...obj }; }
122
+ }
123
+
124
+ function diffObjects(prev, curr) {
125
+ if (!isObject(curr)) return null;
126
+ const out = {};
127
+ let changed = false;
128
+
129
+ const prevObj = isObject(prev) ? prev : {};
130
+
131
+ for (const k of Object.keys(curr)) {
132
+ const a = curr[k];
133
+ const b = prevObj[k];
134
+
135
+ const same = (typeof a === "object")
136
+ ? JSON.stringify(a) === JSON.stringify(b)
137
+ : a === b;
138
+
139
+ if (!same) { out[k] = a; changed = true; }
140
+ }
141
+
142
+ for (const k of Object.keys(prevObj)) {
143
+ if (!(k in curr)) { out[k] = null; changed = true; }
144
+ }
145
+
146
+ return changed ? out : null;
147
+ }
148
+
149
+ // ============================================================================
150
+ // CUSTOM BEHAVIORS
151
+ // ============================================================================
152
+
153
+ class DynamicIdentifyServer extends IdentifyServer {
154
+ async triggerEffect(identifier, variant) {}
155
+ }
156
+
157
+ // ============================================================================
158
+ // DEVICE FACTORY
159
+ // ============================================================================
160
+
161
+ class DeviceFactory {
162
+ static createBehaviorsWithFeatures(behaviors, behaviorFeatures = {}) {
163
+ return behaviors
164
+ .map((behavior) => {
165
+ if (typeof behavior === "string") {
166
+ const BehaviorClass = matterBehaviors[behavior];
167
+ if (!BehaviorClass) return null;
168
+
169
+ const clusterName = behavior.replace("Server", "");
170
+ if (behaviorFeatures[clusterName]) {
171
+ const clusters = require("@matter/main/clusters");
172
+ const cluster = clusters[clusterName];
173
+ const features = behaviorFeatures[clusterName]
174
+ .map((fname) => cluster?.Feature?.[fname])
175
+ .filter(Boolean);
176
+
177
+ if (features.length > 0) return BehaviorClass.with(...features);
178
+ }
179
+ return BehaviorClass;
180
+ }
181
+ return behavior;
182
+ })
183
+ .filter(Boolean);
184
+ }
185
+
186
+ static getMandatoryBehaviors(DeviceClass, behaviorFeatures = {}) {
187
+ const behaviors = [];
188
+
189
+ if (DeviceClass.requirements?.server?.mandatory) {
190
+ Object.entries(DeviceClass.requirements.server.mandatory).forEach(([key, BehaviorClass]) => {
191
+ if (!BehaviorClass) return;
192
+
193
+ const behaviorName = key.replace("Server", "");
194
+ if (behaviorFeatures[behaviorName]) {
195
+ const clusters = require("@matter/main/clusters");
196
+ const cluster = clusters[behaviorName];
197
+ const features = behaviorFeatures[behaviorName]
198
+ .map((fname) => cluster?.Feature?.[fname])
199
+ .filter(Boolean);
200
+
201
+ if (features.length > 0) behaviors.push(BehaviorClass.with(...features));
202
+ else behaviors.push(BehaviorClass);
203
+ } else {
204
+ behaviors.push(BehaviorClass);
205
+ }
206
+ });
207
+ }
208
+
209
+ return behaviors;
210
+ }
211
+
212
+ static createEndpoint(node, DeviceClass, behaviors, deviceConfig) {
213
+ const deviceId = getMatterDeviceId(node.id);
214
+
215
+ // Start reachable=false because we are hidden until enabled
216
+ const endpointConfig = {
217
+ id: deviceId,
218
+ bridgedDeviceBasicInformation: {
219
+ nodeLabel: node.name,
220
+ productName: node.name,
221
+ productLabel: node.name,
222
+ serialNumber: deviceId,
223
+ uniqueId: getMatterUniqueId(node.id),
224
+ reachable: false
225
+ }
226
+ };
227
+
228
+ if (deviceConfig.initialState) Object.assign(endpointConfig, deviceConfig.initialState);
229
+
230
+ const endpoint = new Endpoint(DeviceClass.with(...behaviors), endpointConfig);
231
+ endpoint.nodeRed = node;
232
+ return endpoint;
233
+ }
234
+
235
+ static createDevice(node, deviceConfig) {
236
+ const DeviceClass = matterDevices[deviceConfig.deviceType];
237
+ if (!DeviceClass) throw new Error(`Device type '${deviceConfig.deviceType}' not found`);
238
+
239
+ const behaviors = this.getMandatoryBehaviors(DeviceClass, deviceConfig.behaviorFeatures);
240
+ behaviors.push(BridgedDeviceBasicInformationServer, DynamicIdentifyServer);
241
+
242
+ if (deviceConfig.additionalBehaviors) {
243
+ const additionalBehaviors = this.createBehaviorsWithFeatures(
244
+ deviceConfig.additionalBehaviors,
245
+ deviceConfig.behaviorFeatures
246
+ );
247
+ behaviors.push(...additionalBehaviors);
248
+ }
249
+
250
+ return this.createEndpoint(node, DeviceClass, behaviors, deviceConfig);
251
+ }
252
+ }
253
+
254
+ // ============================================================================
255
+ // EVENT MANAGER
256
+ // ============================================================================
257
+
258
+ class EventManager {
259
+ constructor(node) {
260
+ this.node = node;
261
+ this.eventsSubscribed = false;
262
+ this.eventHandlers = {};
263
+ this.lastClusterState = {};
264
+ }
265
+
266
+ subscribe() {
267
+ if (this.eventsSubscribed) return;
268
+ if (!this.node.device?.events) throw new Error("Device events not available");
269
+ if (!this.node.device?.state) throw new Error("Device state not available");
270
+
271
+ void Object.keys(this.node.device.state);
272
+
273
+ this.eventsSubscribed = true;
274
+
275
+ const clusters = Object.keys(this.node.device.events);
276
+ this.node._dbg({ type: "subscribe_start", clusters });
277
+
278
+ clusters.forEach((clusterName) => this.subscribeCluster(clusterName));
279
+ this.node._dbg({ type: "subscribe_done" });
280
+ }
281
+
282
+ subscribeCluster(clusterName) {
283
+ const clusterEvents = this.node.device.events[clusterName];
284
+ if (!clusterEvents) return;
285
+
286
+ Object.keys(clusterEvents)
287
+ .filter((eventName) => eventName.endsWith("$Changed"))
288
+ .forEach((eventName) => {
289
+ const ev = clusterEvents[eventName];
290
+ if (!ev || typeof ev.on !== "function") return;
291
+
292
+ const attributeName = eventName.slice(0, -"$Changed".length);
293
+ const handlerKey = `${clusterName}.${eventName}`;
294
+ if (this.eventHandlers[handlerKey]) return;
295
+
296
+ this.eventHandlers[handlerKey] = (value) => {
297
+ const prev = this.lastClusterState[clusterName] || {};
298
+ this.lastClusterState[clusterName] = { ...prev, [attributeName]: value };
299
+
300
+ if (!this.node._enabled) {
301
+ this.node._dbg({ type: "event_ignored_disabled", clusterName, attributeName, value });
302
+ return;
303
+ }
304
+
305
+ const msg = { payload: { [clusterName]: { [attributeName]: value } } };
306
+ this.node.send([msg, null, null]);
307
+ this.node._dbg({ type: "event_changed", clusterName, attributeName, value });
308
+
309
+ if (clusterName === "thermostat") {
310
+ this.node._mergeThermostat({ [attributeName]: value }, "changed");
311
+ }
312
+ };
313
+
314
+ try {
315
+ ev.on(this.eventHandlers[handlerKey]);
316
+ this.node._dbg({ type: "subscribed", clusterName, eventName });
317
+ } catch (e) {
318
+ this.node._dbg({ type: "subscribe_error", clusterName, eventName, error: String(e?.message || e) });
319
+ }
320
+ });
321
+
322
+ if (this.node.snapshotOnInteractionEnd && clusterName === "thermostat" && clusterEvents.interactionEnd?.on) {
323
+ const handlerKey = `${clusterName}.interactionEnd`;
324
+ if (this.eventHandlers[handlerKey]) return;
325
+
326
+ this.eventHandlers[handlerKey] = () => {
327
+ const curr = this.node.device?.state?.[clusterName];
328
+ if (!isObject(curr)) return;
329
+
330
+ const prev = this.lastClusterState[clusterName] || {};
331
+ const diff = diffObjects(prev, curr);
332
+
333
+ this.lastClusterState[clusterName] = cloneState(curr);
334
+
335
+ if (!diff) {
336
+ this.node._dbg({ type: "interactionEnd_nochange" });
337
+ return;
338
+ }
339
+
340
+ if (!this.node._enabled) {
341
+ this.node._dbg({ type: "snapshot_ignored_disabled", diff });
342
+ return;
343
+ }
344
+
345
+ this.node.send([{ payload: { thermostat: diff } }, null, null]);
346
+ this.node._dbg({ type: "snapshot_diff", keys: Object.keys(diff) });
347
+
348
+ this.node._mergeThermostat(diff, "interactionEnd");
349
+
350
+ if (this.node.emitPseudoCommands) {
351
+ this.node.send([null, { payload: { command: "attributeWrite", cluster: "thermostat", data: diff } }, null]);
352
+ this.node._dbg({ type: "pseudo_command_emitted" });
353
+ }
354
+ };
355
+
356
+ try {
357
+ clusterEvents.interactionEnd.on(this.eventHandlers[handlerKey]);
358
+ this.node._dbg({ type: "subscribed", clusterName: "thermostat", eventName: "interactionEnd" });
359
+ } catch (e) {
360
+ this.node._dbg({ type: "subscribe_error", clusterName: "thermostat", eventName: "interactionEnd", error: String(e?.message || e) });
361
+ }
362
+ }
363
+ }
364
+
365
+ async cleanup() {
366
+ if (!this.node.device?.events) return;
367
+
368
+ for (const [handlerKey, handler] of Object.entries(this.eventHandlers)) {
369
+ const [clusterName, eventName] = handlerKey.split(".");
370
+ const ev = this.node.device.events?.[clusterName]?.[eventName];
371
+ if (ev?.off) {
372
+ try { await ev.off(handler); } catch (e) {}
373
+ }
374
+ }
375
+
376
+ this.eventsSubscribed = false;
377
+ this.eventHandlers = {};
378
+ this.lastClusterState = {};
379
+ }
380
+ }
381
+
382
+ // ============================================================================
383
+ // MAIN NODE
384
+ // ============================================================================
385
+
386
+ module.exports = function (RED) {
387
+ function MatterDevice(config) {
388
+ RED.nodes.createNode(this, config);
389
+ const node = this;
390
+
391
+ const nodeCtx = node.context();
392
+
393
+ node.bridge = RED.nodes.getNode(config.bridge);
394
+ node.name = config.name;
395
+ node.type = "matter-device";
396
+
397
+ node.emitPseudoCommands = /^true$/i.test(String(config.emitPseudoCommands || "false"));
398
+ node.snapshotOnInteractionEnd = (config.snapshotOnInteractionEnd == null)
399
+ ? true
400
+ : /^true$/i.test(String(config.snapshotOnInteractionEnd));
401
+
402
+ node._closed = false;
403
+
404
+ // Persisted state
405
+ const persisted = nodeCtx.get("persist") || {};
406
+ node._enabled = persisted.enabled === true; // persists
407
+ node._pendingName = (typeof persisted.name === "string" && persisted.name.trim() !== "")
408
+ ? persisted.name.trim()
409
+ : null;
410
+
411
+ // Option 2: must re-register after deploy (node instance is new)
412
+ node._registeredToBridge = false;
413
+
414
+ node._isThermostat = false;
415
+ node._lastThermo = {
416
+ systemMode: null,
417
+ occupiedHeatingSetpoint: null,
418
+ occupiedCoolingSetpoint: null,
419
+ localTemperature: null
420
+ };
421
+ node._lastThermoSource = "none";
422
+
423
+ // Debug helper -> Output 3
424
+ node._dbg = (payload) => {
425
+ if (node._closed) return;
426
+ try { node.send([null, null, { payload }]); } catch (e) {}
427
+ };
428
+
429
+ function savePersist() {
430
+ nodeCtx.set("persist", {
431
+ enabled: !!node._enabled,
432
+ name: node._pendingName || ""
433
+ });
434
+ }
435
+
436
+ // Status helper
437
+ node._updateStatus = () => {
438
+ if (node._closed) return;
439
+
440
+ if (!node._registeredToBridge) {
441
+ node.status({ fill: "grey", shape: "ring", text: node._enabled ? "enabling..." : "disabled (not registered)" });
442
+ return;
443
+ }
444
+
445
+ if (!node._enabled) {
446
+ node.status({ fill: "grey", shape: "ring", text: "disabled" });
447
+ return;
448
+ }
449
+
450
+ const displayName = (node._pendingName && node._pendingName.trim())
451
+ ? node._pendingName.trim()
452
+ : (node.name || "device");
453
+
454
+ let text = displayName;
455
+
456
+ if (node._isThermostat) {
457
+ const thermo = formatThermoStatus(node._lastThermo);
458
+ if (thermo) text += ` | ${thermo}`;
459
+ text += ` | last:${node._lastThermoSource}`;
460
+ }
461
+
462
+ node.status({ fill: "green", shape: "dot", text });
463
+ };
464
+
465
+ node._mergeThermostat = (partial, source) => {
466
+ if (!partial || !isObject(partial)) return;
467
+ for (const k of Object.keys(partial)) {
468
+ if (k in node._lastThermo) node._lastThermo[k] = partial[k];
469
+ }
470
+ node._lastThermoSource = source || "update";
471
+ node._updateStatus();
472
+ };
473
+
474
+ // Init promise
475
+ node.initPromise = new Promise((resolve, reject) => {
476
+ node.resolveInit = resolve;
477
+ node.rejectInit = (err) => { try { reject(err); } catch (e) {} };
478
+ });
479
+ node._initResolved = false;
480
+
481
+ // Parse deviceConfig JSON
482
+ let deviceConfig;
483
+ try {
484
+ deviceConfig = typeof config.deviceConfig === "string" ? JSON.parse(config.deviceConfig) : config.deviceConfig;
485
+ } catch (e) {
486
+ node.error("Invalid device configuration JSON: " + e.message);
487
+ node._dbg({ type: "config_error", error: e.message });
488
+ return;
489
+ }
490
+
491
+ node._isThermostat = String(deviceConfig?.deviceType || "").toLowerCase().includes("thermostat");
492
+ if (config.snapshotOnInteractionEnd == null) node.snapshotOnInteractionEnd = node._isThermostat;
493
+
494
+ // Create endpoint immediately (but may not register until enabled)
495
+ try {
496
+ // Apply persisted name into node.name before creating endpoint so it becomes the default label
497
+ if (node._pendingName) node.name = node._pendingName;
498
+
499
+ node.device = DeviceFactory.createDevice(node, deviceConfig);
500
+ } catch (e) {
501
+ node.error("Failed to create device endpoint: " + e.message);
502
+ node._dbg({ type: "create_error", error: e.message });
503
+ return;
504
+ }
505
+
506
+ const eventManager = new EventManager(node);
507
+
508
+ // pending write retry ONLY after real write attempt
509
+ let pendingWrite = null;
510
+ let pendingTimer = null;
511
+ let writeAttempts = 0;
512
+
513
+ const WRITE_RETRY_DELAY_MS = Number(config.writeRetryDelayMs || 750);
514
+ const MAX_WRITE_RETRIES = Number(config.maxWriteRetries || 40);
515
+
516
+ function stageNamePreRegister(newName) {
517
+ const n = String(newName || "").trim();
518
+ if (!n) return;
519
+
520
+ node._pendingName = n;
521
+ node.name = n;
522
+ savePersist();
523
+
524
+ try {
525
+ const bdi = node.device?.state?.bridgedDeviceBasicInformation;
526
+ if (bdi && typeof bdi === "object") {
527
+ bdi.nodeLabel = n;
528
+ bdi.productName = n;
529
+ bdi.productLabel = n;
530
+ }
531
+ } catch (e) {}
532
+
533
+ node._dbg({ type: "rename_staged_pre_register", name: n });
534
+ node._updateStatus();
535
+ }
536
+
537
+ async function applyNamePostRegister(newName) {
538
+ const n = String(newName || "").trim();
539
+ if (!n) return;
540
+
541
+ node._pendingName = n;
542
+ node.name = n;
543
+ savePersist();
544
+
545
+ try {
546
+ if (node.device?.set) {
547
+ await node.device.set({
548
+ bridgedDeviceBasicInformation: {
549
+ nodeLabel: n,
550
+ productName: n,
551
+ productLabel: n
552
+ }
553
+ });
554
+ }
555
+ node._dbg({ type: "rename_ok", name: n });
556
+ } catch (e) {
557
+ node._dbg({ type: "rename_error", error: String(e?.message || e) });
558
+ } finally {
559
+ node._updateStatus();
560
+ }
561
+ }
562
+
563
+ async function applyReachable(reachable) {
564
+ try {
565
+ if (node.device?.set) {
566
+ await node.device.set({ bridgedDeviceBasicInformation: { reachable: !!reachable } });
567
+ }
568
+ node._dbg({ type: "reachable_set", reachable: !!reachable });
569
+ } catch (e) {
570
+ node._dbg({ type: "reachable_error", error: String(e?.message || e) });
571
+ }
572
+ }
573
+
574
+ async function registerToBridgeIfNeeded() {
575
+ if (node._registeredToBridge) return true;
576
+ if (!node.bridge || !node.bridge.serverReady) return false;
577
+ if (typeof node.bridge.registerChild !== "function") return false;
578
+
579
+ try {
580
+ node.bridge.registerChild(node);
581
+ node._registeredToBridge = true;
582
+ node._dbg({ type: "registered_to_bridge" });
583
+
584
+ // Apply staged name again for safety
585
+ if (node._pendingName) {
586
+ await applyNamePostRegister(node._pendingName);
587
+ }
588
+
589
+ return true;
590
+ } catch (e) {
591
+ node._dbg({ type: "registered_to_bridge_error", error: String(e?.message || e) });
592
+ return false;
593
+ }
594
+ }
595
+
596
+ function scheduleWriteRetry() {
597
+ if (pendingTimer) return;
598
+ pendingTimer = setTimeout(async () => {
599
+ pendingTimer = null;
600
+ if (node._closed || !pendingWrite || !node.device) return;
601
+
602
+ writeAttempts += 1;
603
+ node._dbg({ type: "write_retry", attempt: writeAttempts, payload: pendingWrite });
604
+
605
+ try {
606
+ await node.device.set(pendingWrite);
607
+ node._dbg({ type: "write_retry_ok", attempt: writeAttempts });
608
+ pendingWrite = null;
609
+ writeAttempts = 0;
610
+ node._updateStatus();
611
+ } catch (err) {
612
+ const msgText = String(err?.message || err);
613
+ node._dbg({ type: "write_retry_err", attempt: writeAttempts, error: msgText });
614
+
615
+ if (isClosed(msgText)) {
616
+ node.emit("bridgeReset", { reason: "closedOnWriteRetry", error: msgText });
617
+ pendingWrite = null;
618
+ writeAttempts = 0;
619
+ return;
620
+ }
621
+
622
+ if (isNotInitialized(msgText)) {
623
+ if (writeAttempts < MAX_WRITE_RETRIES) {
624
+ scheduleWriteRetry();
625
+ return;
626
+ }
627
+ node._dbg({ type: "write_retry_giveup", attempts: writeAttempts, dropped: pendingWrite, lastError: msgText });
628
+ pendingWrite = null;
629
+ writeAttempts = 0;
630
+ node._updateStatus();
631
+ return;
632
+ }
633
+
634
+ pendingWrite = null;
635
+ writeAttempts = 0;
636
+ }
637
+ }, WRITE_RETRY_DELAY_MS);
638
+ }
639
+
640
+ async function initAndSubscribe(source) {
641
+ if (node._closed) return;
642
+ if (!node._registeredToBridge) return;
643
+
644
+ try {
645
+ if (!node.device?.state || !node.device?.events) throw new Error("Device structure incomplete");
646
+ void Object.keys(node.device.state);
647
+ eventManager.subscribe();
648
+
649
+ node._dbg({ type: "init_ok", source });
650
+
651
+ if (!node._initResolved) {
652
+ node._initResolved = true;
653
+ try { node.resolveInit(); } catch (e) {}
654
+ }
655
+
656
+ node._updateStatus();
657
+ } catch (e) {
658
+ node._dbg({ type: "init_err", source, error: String(e?.message || e) });
659
+ }
660
+ }
661
+
662
+ // --------------------------------------------------------------------
663
+ // Auto-enable on deploy if persisted enabled=true
664
+ // --------------------------------------------------------------------
665
+ async function bootAutoRegisterLoop() {
666
+ if (node._closed) return;
667
+ if (!node._enabled) {
668
+ node._updateStatus();
669
+ return;
670
+ }
671
+
672
+ // Stage persisted name before register so discovery uses it
673
+ if (node._pendingName) stageNamePreRegister(node._pendingName);
674
+
675
+ const ok = await registerToBridgeIfNeeded();
676
+ if (!ok) {
677
+ // Bridge not ready yet; retry
678
+ node._updateStatus();
679
+ setTimeout(bootAutoRegisterLoop, 500);
680
+ return;
681
+ }
682
+
683
+ // Make reachable true
684
+ await applyReachable(true);
685
+
686
+ // Subscribe
687
+ await initAndSubscribe("boot-persisted-enable");
688
+
689
+ node._dbg({ type: "boot_persisted_enabled_ok" });
690
+ node._updateStatus();
691
+ }
692
+
693
+ // Starting status now (based on persisted enabled flag)
694
+ node._updateStatus();
695
+
696
+ // Kick auto registration if persisted enabled
697
+ setTimeout(bootAutoRegisterLoop, 500);
698
+
699
+ // ====================================================================
700
+ // INPUT HANDLER
701
+ // ====================================================================
702
+
703
+ this.on("input", async function (msg) {
704
+ const topic = String(msg.topic || "").trim().toLowerCase();
705
+
706
+ // Enable
707
+ if (topic === "enable") {
708
+ if (typeof msg.payload === "string") stageNamePreRegister(msg.payload);
709
+ if (isObject(msg.payload) && typeof msg.payload.name === "string") stageNamePreRegister(msg.payload.name);
710
+
711
+ node._enabled = true;
712
+ savePersist();
713
+
714
+ const ok = await registerToBridgeIfNeeded();
715
+ await applyReachable(true);
716
+ if (ok) await initAndSubscribe("enable");
717
+
718
+ node._dbg({ type: "enabled" });
719
+ node._updateStatus();
720
+ return;
721
+ }
722
+
723
+ // Disable
724
+ if (topic === "disable") {
725
+ node._enabled = false;
726
+ savePersist();
727
+ if (node._registeredToBridge) await applyReachable(false);
728
+ node._dbg({ type: "disabled" });
729
+ node._updateStatus();
730
+ return;
731
+ }
732
+
733
+ // Name
734
+ if (topic === "name") {
735
+ if (!node._registeredToBridge) stageNamePreRegister(msg.payload);
736
+ else await applyNamePostRegister(msg.payload);
737
+ node._updateStatus();
738
+ return;
739
+ }
740
+
741
+ // Config
742
+ if (topic === "config" && isObject(msg.payload)) {
743
+ const enabled = (msg.payload.enabled != null) ? !!msg.payload.enabled : null;
744
+ const name = (typeof msg.payload.name === "string") ? msg.payload.name : null;
745
+
746
+ if (name) {
747
+ if (!node._registeredToBridge) stageNamePreRegister(name);
748
+ else await applyNamePostRegister(name);
749
+ }
750
+
751
+ if (enabled === true) {
752
+ node._enabled = true;
753
+ savePersist();
754
+ const ok = await registerToBridgeIfNeeded();
755
+ await applyReachable(true);
756
+ if (ok) await initAndSubscribe("config-enable");
757
+ node._dbg({ type: "enabled" });
758
+ node._updateStatus();
759
+ } else if (enabled === false) {
760
+ node._enabled = false;
761
+ savePersist();
762
+ if (node._registeredToBridge) await applyReachable(false);
763
+ node._dbg({ type: "disabled" });
764
+ node._updateStatus();
765
+ }
766
+ return;
767
+ }
768
+
769
+ // State query
770
+ if (topic === "state") {
771
+ try {
772
+ if (!node._registeredToBridge) {
773
+ node.send([{ payload: { note: "not registered yet", enabled: node._enabled, name: node._pendingName || node.name } }, null, null]);
774
+ node._dbg({ type: "state_not_registered" });
775
+ return;
776
+ }
777
+
778
+ await node.initPromise;
779
+
780
+ const out = {};
781
+ if (node.device?.state) Object.keys(node.device.state).forEach((c) => (out[c] = { ...node.device.state[c] }));
782
+ node.send([{ payload: out }, null, null]);
783
+ node._dbg({ type: "state_dump", clusters: Object.keys(out) });
784
+
785
+ if (node._isThermostat && out.thermostat) node._mergeThermostat(out.thermostat, "state");
786
+ } catch (e) {
787
+ node._dbg({ type: "state_error", error: String(e?.message || e) });
788
+ }
789
+ return;
790
+ }
791
+
792
+ // Ignore normal writes unless enabled+registered
793
+ if (!node._registeredToBridge) {
794
+ node._dbg({ type: "write_ignored_not_registered", topic });
795
+ return;
796
+ }
797
+ if (!node._enabled) {
798
+ node._dbg({ type: "write_ignored_disabled", topic });
799
+ return;
800
+ }
801
+
802
+ // Regular writes
803
+ try {
804
+ await node.initPromise;
805
+ if (!node.device) return;
806
+
807
+ if (isObject(msg.payload)) {
808
+ node._dbg({ type: "write_attempt", payload: msg.payload });
809
+
810
+ try {
811
+ await node.device.set(msg.payload);
812
+
813
+ if (node._isThermostat && isObject(msg.payload.thermostat)) {
814
+ node._mergeThermostat(msg.payload.thermostat, "write");
815
+ }
816
+ } catch (err) {
817
+ const msgText = String(err?.message || err);
818
+ node._dbg({ type: "write_error", error: msgText });
819
+
820
+ if (isNotInitialized(msgText)) {
821
+ pendingWrite = msg.payload;
822
+ writeAttempts = 0;
823
+ node.status({ fill: "yellow", shape: "ring", text: "waiting init" });
824
+ scheduleWriteRetry();
825
+ return;
826
+ }
827
+
828
+ if (isClosed(msgText)) {
829
+ node.emit("bridgeReset", { reason: "closedOnSet", error: msgText });
830
+ return;
831
+ }
832
+ }
833
+ }
834
+ } catch (e) {
835
+ node._dbg({ type: "init_wait_error", error: String(e?.message || e) });
836
+ } finally {
837
+ node._updateStatus();
838
+ }
839
+ });
840
+
841
+ // Bridge reset (re-subscribe only)
842
+ this.on("bridgeReset", async function (meta) {
843
+ if (node._closed) return;
844
+ node._dbg({ type: "bridgeReset", meta });
845
+ if (!node._registeredToBridge) return;
846
+
847
+ try {
848
+ node.bridge = RED.nodes.getNode(config.bridge);
849
+ try { await eventManager.cleanup(); } catch (e) {}
850
+ await initAndSubscribe("bridgeReset");
851
+ } catch (e) {
852
+ node._dbg({ type: "bridgeReset_error", error: String(e?.message || e) });
853
+ }
854
+ });
855
+
856
+ // Bridge reference check
857
+ if (!node.bridge) {
858
+ node.status({ fill: "red", shape: "ring", text: "no bridge" });
859
+ node._dbg({ type: "no_bridge_config" });
860
+ return;
861
+ }
862
+
863
+ node._dbg({ type: "boot", persistedEnabled: node._enabled, persistedName: node._pendingName });
864
+
865
+ // CLEANUP
866
+ this.on("close", async function (removed, done) {
867
+ node._closed = true;
868
+ if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null; }
869
+ pendingWrite = null;
870
+
871
+ try { await eventManager.cleanup(); } catch (e) {}
872
+ try { if (node.device?.nodeRed) node.device.nodeRed = null; } catch (e) {}
873
+
874
+ node.device = null;
875
+ done();
876
+ });
877
+ }
878
+
879
+ RED.nodes.registerType("matter-device", MatterDevice);
880
+ };