@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.
- package/ARCHITECTURE.md +327 -0
- package/LICENSE +21 -0
- package/README.md +274 -0
- package/icons/matter-device-icon.svg +3 -0
- package/matter-bridge.html +263 -0
- package/matter-bridge.js +475 -0
- package/matter-device.html +138 -0
- package/matter-device.js +880 -0
- package/matter-pairing.html +54 -0
- package/matter-pairing.js +275 -0
- package/package.json +41 -0
- package/utils.js +30 -0
package/matter-device.js
ADDED
|
@@ -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
|
+
};
|