@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-bridge.js
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* matter-bridge.js (AirNexus + robust refresh)
|
|
3
|
+
*
|
|
4
|
+
* Key:
|
|
5
|
+
* - Broadcast bridgeReset after server create and after server start (fixes stale endpoints after pairing reset)
|
|
6
|
+
* - Broadcast bridgeReset when commissioning becomes true (commissioned transition)
|
|
7
|
+
* - Poll commissioning state because it can change after start()
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { Endpoint, Environment, ServerNode, Logger, VendorId, StorageService } = require("@matter/main");
|
|
11
|
+
const { AggregatorEndpoint } = require("@matter/main/endpoints");
|
|
12
|
+
const { NetworkCommissioning } = require("@matter/main/clusters");
|
|
13
|
+
const { NetworkCommissioningServer } = require("@matter/main/behaviors");
|
|
14
|
+
const os = require("os");
|
|
15
|
+
const path = require("path");
|
|
16
|
+
const fs = require("fs");
|
|
17
|
+
const QRCode = require("qrcode");
|
|
18
|
+
const { getMatterDeviceId, getMatterUniqueId } = require("./utils");
|
|
19
|
+
|
|
20
|
+
// ============================================================================
|
|
21
|
+
// UTILITY
|
|
22
|
+
// ============================================================================
|
|
23
|
+
|
|
24
|
+
function generatePasscode() {
|
|
25
|
+
let passcode = Math.floor(Math.random() * 99999997) + 1;
|
|
26
|
+
const invalidCodes = new Set([
|
|
27
|
+
11111111, 22222222, 33333333, 44444444, 55555555,
|
|
28
|
+
66666666, 77777777, 88888888, 12345678, 87654321
|
|
29
|
+
]);
|
|
30
|
+
if (invalidCodes.has(passcode)) passcode += 1;
|
|
31
|
+
return +passcode.toString().padStart(8, "0");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function generateDiscriminator() {
|
|
35
|
+
return Math.floor(Math.random() * 4095);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function mkdirp(p) {
|
|
39
|
+
try { fs.mkdirSync(p, { recursive: true }); } catch (e) {}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ============================================================================
|
|
43
|
+
// BRIDGE CONFIG
|
|
44
|
+
// ============================================================================
|
|
45
|
+
|
|
46
|
+
class BridgeConfig {
|
|
47
|
+
constructor(config) {
|
|
48
|
+
this.name = config.name || "AirNexus Matter Bridge";
|
|
49
|
+
this.vendorId = +config.vendorId || 65521;
|
|
50
|
+
this.productId = +config.productId || 32768;
|
|
51
|
+
this.vendorName = config.vendorName || "AirNexus";
|
|
52
|
+
this.productName = config.productName || "AirNexus Dynamic Matter Bridge";
|
|
53
|
+
|
|
54
|
+
this.networkInterface = config.networkInterface || "";
|
|
55
|
+
this.storageLocation = config.storageLocation || "";
|
|
56
|
+
this.port = +config.port || 5540;
|
|
57
|
+
this.logLevel = config.logLevel || "WARN";
|
|
58
|
+
|
|
59
|
+
this.passcode = (config.passcode != null && String(config.passcode).trim() !== "")
|
|
60
|
+
? +config.passcode
|
|
61
|
+
: generatePasscode();
|
|
62
|
+
|
|
63
|
+
this.discriminator = (config.discriminator != null && String(config.discriminator).trim() !== "")
|
|
64
|
+
? +config.discriminator
|
|
65
|
+
: generateDiscriminator();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
static setLogLevel(level) {
|
|
69
|
+
const logLevels = { FATAL: 5, ERROR: 4, WARN: 3, INFO: 1, DEBUG: 0 };
|
|
70
|
+
if (level in logLevels) Logger.defaultLogLevel = logLevels[level];
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ============================================================================
|
|
75
|
+
// DEVICE MANAGER
|
|
76
|
+
// ============================================================================
|
|
77
|
+
|
|
78
|
+
class DeviceManager {
|
|
79
|
+
constructor(node) {
|
|
80
|
+
this.node = node;
|
|
81
|
+
this.registered = [];
|
|
82
|
+
this.pendingUsers = [];
|
|
83
|
+
this.failedDevices = new Set();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
registerChild(child) {
|
|
87
|
+
this.node.log(`Registering device ${child.id}`);
|
|
88
|
+
|
|
89
|
+
if (this.registered.find(c => c.id === child.id)) {
|
|
90
|
+
this.node.warn(`Device ${child.id} already registered, skipping`);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const idx = this.pendingUsers.indexOf(child.id);
|
|
95
|
+
if (idx > -1) this.pendingUsers.splice(idx, 1);
|
|
96
|
+
|
|
97
|
+
this.registered.push(child);
|
|
98
|
+
this.addToAggregator(child);
|
|
99
|
+
|
|
100
|
+
if (this.pendingUsers.length === 0 && this.node.serverReady) {
|
|
101
|
+
this.node.serverManager.start();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
addToAggregator(child) {
|
|
106
|
+
const deviceId = getMatterDeviceId(child.id);
|
|
107
|
+
if (!this.node.aggregator) {
|
|
108
|
+
this.node.error("Aggregator not ready");
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const existing = this.node.aggregator.parts.get(deviceId);
|
|
113
|
+
if (existing) {
|
|
114
|
+
existing.nodeRed = child;
|
|
115
|
+
child.device = existing;
|
|
116
|
+
child.deviceAddedSuccessfully = true;
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
this.node.aggregator.add(child.device);
|
|
122
|
+
child.deviceAddedSuccessfully = true;
|
|
123
|
+
this.node.log(`Added device ${deviceId} to aggregator`);
|
|
124
|
+
} catch (e) {
|
|
125
|
+
this.handleDeviceError(child, e);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
handleDeviceError(child, error) {
|
|
130
|
+
this.node.error(`Failed to add device ${child.id}: ${error.message}`);
|
|
131
|
+
child.error?.(`Device initialization failed: ${error.message}`);
|
|
132
|
+
child.status?.({ fill: "red", shape: "ring", text: "init failed" });
|
|
133
|
+
child.deviceAddedSuccessfully = false;
|
|
134
|
+
this.failedDevices.add(child.id);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
notifyDevicesReady() {
|
|
138
|
+
setTimeout(() => {
|
|
139
|
+
this.registered.forEach(child => {
|
|
140
|
+
if (this.failedDevices.has(child.id) || child.deviceInitFailed) return;
|
|
141
|
+
try {
|
|
142
|
+
if (child.device?.state) {
|
|
143
|
+
void Object.keys(child.device.state);
|
|
144
|
+
child.emit("serverReady");
|
|
145
|
+
} else {
|
|
146
|
+
child.status?.({ fill: "red", shape: "ring", text: "init failed" });
|
|
147
|
+
}
|
|
148
|
+
} catch (e) {
|
|
149
|
+
child.status?.({ fill: "red", shape: "ring", text: "validation failed" });
|
|
150
|
+
this.failedDevices.add(child.id);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
}, 800);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ? Used by pairing reset + server recreate/start + commissioning
|
|
157
|
+
broadcastBridgeReset(meta = {}) {
|
|
158
|
+
setTimeout(() => {
|
|
159
|
+
this.registered.forEach(child => {
|
|
160
|
+
if (this.failedDevices.has(child.id) || child.deviceInitFailed || child._closed) return;
|
|
161
|
+
try { child.emit("bridgeReset", meta); } catch (e) {}
|
|
162
|
+
});
|
|
163
|
+
}, 500);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ============================================================================
|
|
168
|
+
// SERVER MANAGER
|
|
169
|
+
// ============================================================================
|
|
170
|
+
|
|
171
|
+
class ServerManager {
|
|
172
|
+
constructor(node, RED) {
|
|
173
|
+
this.node = node;
|
|
174
|
+
this.RED = RED;
|
|
175
|
+
this._createInProgress = false;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
_getDefaultStorageLocation() {
|
|
179
|
+
const userDir = (this.RED?.settings?.userDir) ? this.RED.settings.userDir : process.cwd();
|
|
180
|
+
return path.join(userDir, "matter-storage", String(this.node.id));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
_ensureStorageLocation() {
|
|
184
|
+
const cfg = this.node.bridgeConfig;
|
|
185
|
+
if (!cfg.storageLocation) cfg.storageLocation = this._getDefaultStorageLocation();
|
|
186
|
+
mkdirp(cfg.storageLocation);
|
|
187
|
+
return cfg.storageLocation;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async create() {
|
|
191
|
+
if (this._createInProgress) return;
|
|
192
|
+
this._createInProgress = true;
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
if (this.node.matterServer) {
|
|
196
|
+
try { await this.node.matterServer.close(); } catch (e) {}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
this.node.serverReady = false;
|
|
200
|
+
this.node.matterServer = null;
|
|
201
|
+
this.node.aggregator = null;
|
|
202
|
+
|
|
203
|
+
this.configureEnvironment();
|
|
204
|
+
const serverConfig = this.createServerConfig();
|
|
205
|
+
|
|
206
|
+
const matterServer = await ServerNode.create(
|
|
207
|
+
ServerNode.RootEndpoint.with(NetworkCommissioningServer.with("EthernetNetworkInterface")),
|
|
208
|
+
serverConfig
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
this.node.matterServer = matterServer;
|
|
212
|
+
this.node.aggregator = new Endpoint(AggregatorEndpoint, { id: "aggregator" });
|
|
213
|
+
this.node.matterServer.add(this.node.aggregator);
|
|
214
|
+
|
|
215
|
+
this.node.serverReady = true;
|
|
216
|
+
this.node.log("Bridge created");
|
|
217
|
+
|
|
218
|
+
// ? IMPORTANT: after recreate, force device nodes to rebuild endpoints
|
|
219
|
+
this.node.deviceManager.broadcastBridgeReset({ reason: "serverCreated" });
|
|
220
|
+
|
|
221
|
+
if (this.node.deviceManager.pendingUsers.length === 0) {
|
|
222
|
+
await this.start();
|
|
223
|
+
}
|
|
224
|
+
} catch (err) {
|
|
225
|
+
this.node.error("Failed to create Matter server: " + err.message);
|
|
226
|
+
} finally {
|
|
227
|
+
this._createInProgress = false;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
configureEnvironment() {
|
|
232
|
+
const cfg = this.node.bridgeConfig;
|
|
233
|
+
|
|
234
|
+
if (cfg.networkInterface) {
|
|
235
|
+
Environment.default.vars.set("mdns.networkInterface", cfg.networkInterface);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const storageLoc = this._ensureStorageLocation();
|
|
239
|
+
const storageService = Environment.default.get(StorageService);
|
|
240
|
+
storageService.location = storageLoc;
|
|
241
|
+
Environment.default.set(StorageService, storageService);
|
|
242
|
+
this.node.log(`Using storage: ${storageService.location}`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
createServerConfig() {
|
|
246
|
+
const cfg = this.node.bridgeConfig;
|
|
247
|
+
const networkId = new Uint8Array(32);
|
|
248
|
+
const deviceId = getMatterDeviceId(this.node.id);
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
id: this.node.id,
|
|
252
|
+
network: { port: cfg.port },
|
|
253
|
+
commissioning: { passcode: cfg.passcode, discriminator: cfg.discriminator },
|
|
254
|
+
productDescription: { name: cfg.name, deviceType: AggregatorEndpoint.deviceType },
|
|
255
|
+
basicInformation: {
|
|
256
|
+
vendorName: cfg.vendorName,
|
|
257
|
+
vendorId: VendorId(cfg.vendorId),
|
|
258
|
+
nodeLabel: cfg.name,
|
|
259
|
+
productName: cfg.productName,
|
|
260
|
+
productLabel: cfg.name,
|
|
261
|
+
productId: cfg.productId,
|
|
262
|
+
serialNumber: deviceId,
|
|
263
|
+
uniqueId: getMatterUniqueId(this.node.id),
|
|
264
|
+
hardwareVersion: 1,
|
|
265
|
+
softwareVersion: 1
|
|
266
|
+
},
|
|
267
|
+
networkCommissioning: {
|
|
268
|
+
maxNetworks: 1,
|
|
269
|
+
interfaceEnabled: true,
|
|
270
|
+
lastConnectErrorValue: 0,
|
|
271
|
+
lastNetworkId: networkId,
|
|
272
|
+
lastNetworkingStatus: NetworkCommissioning.NetworkCommissioningStatus.Success,
|
|
273
|
+
networks: [{ networkId, connected: true }]
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async start() {
|
|
279
|
+
if (!this.node.serverReady || !this.node.matterServer) return;
|
|
280
|
+
|
|
281
|
+
if (this.node.matterServer.lifecycle?.isOnline) {
|
|
282
|
+
this.updateNodeStatus();
|
|
283
|
+
this.node.deviceManager.notifyDevicesReady();
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
this.node.log("Starting Matter server...");
|
|
288
|
+
try {
|
|
289
|
+
await this.node.matterServer.start();
|
|
290
|
+
this.node.log("Matter server started");
|
|
291
|
+
|
|
292
|
+
this.updateNodeStatus();
|
|
293
|
+
|
|
294
|
+
// ? IMPORTANT: after start, force device nodes to rebuild endpoints (helps Alexa)
|
|
295
|
+
this.node.deviceManager.broadcastBridgeReset({ reason: "serverStarted" });
|
|
296
|
+
|
|
297
|
+
this.node.deviceManager.notifyDevicesReady();
|
|
298
|
+
} catch (err) {
|
|
299
|
+
this.node.error("Failed to start server: " + err.message);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ? Broadcast refresh when we newly observe commissioned=true
|
|
304
|
+
updateNodeStatus() {
|
|
305
|
+
if (!this.node.matterServer) return;
|
|
306
|
+
|
|
307
|
+
const commissioned = !!this.node.matterServer.lifecycle?.isCommissioned;
|
|
308
|
+
|
|
309
|
+
if (commissioned && this.node._lastCommissioned !== true) {
|
|
310
|
+
this.node.log("Commissioned -> auto-refreshing bridged devices");
|
|
311
|
+
this.node.deviceManager.broadcastBridgeReset({ reason: "commissioned" });
|
|
312
|
+
}
|
|
313
|
+
this.node._lastCommissioned = commissioned;
|
|
314
|
+
|
|
315
|
+
if (!commissioned) {
|
|
316
|
+
const pairingData = this.node.matterServer.state.commissioning.pairingCodes;
|
|
317
|
+
this.node.status({ fill: "blue", shape: "dot", text: `QR: ${pairingData.manualPairingCode}` });
|
|
318
|
+
this.node.qrCode = pairingData.qrPairingCode;
|
|
319
|
+
this.node.manualCode = pairingData.manualPairingCode;
|
|
320
|
+
} else {
|
|
321
|
+
this.node.status({ fill: "green", shape: "dot", text: "commissioned" });
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ============================================================================
|
|
327
|
+
// ERROR HANDLER
|
|
328
|
+
// ============================================================================
|
|
329
|
+
|
|
330
|
+
class ErrorHandler {
|
|
331
|
+
constructor(node) {
|
|
332
|
+
this.node = node;
|
|
333
|
+
this.handler = this.handleUnhandledRejection.bind(this);
|
|
334
|
+
process.on("unhandledRejection", this.handler);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
handleUnhandledRejection(reason) {
|
|
338
|
+
if (!reason?.message?.includes("Behaviors have errors")) return;
|
|
339
|
+
this.node.error(`Matter.js initialization error: ${reason.message}`);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
cleanup() {
|
|
343
|
+
process.removeListener("unhandledRejection", this.handler);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ============================================================================
|
|
348
|
+
// HTTP API HANDLER
|
|
349
|
+
// ============================================================================
|
|
350
|
+
|
|
351
|
+
class HttpApiHandler {
|
|
352
|
+
static setupEndpoints(RED) {
|
|
353
|
+
RED.httpAdmin.get("/_matterbridge/qrcode/:id",
|
|
354
|
+
RED.auth.needsPermission("admin.write"),
|
|
355
|
+
this.getQrCode.bind(this, RED)
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
RED.httpAdmin.get("/_matterbridge/commissioning/:id",
|
|
359
|
+
RED.auth.needsPermission("admin.write"),
|
|
360
|
+
this.getCommissioningInfo.bind(this, RED)
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
RED.httpAdmin.get("/_matterbridge/interfaces",
|
|
364
|
+
RED.auth.needsPermission("admin.write"),
|
|
365
|
+
this.getNetworkInterfaces.bind(this)
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
static async getQrCode(RED, req, res) {
|
|
370
|
+
const targetNode = RED.nodes.getNode(req.params.id);
|
|
371
|
+
if (!targetNode) return res.json({ error: "Bridge not found" });
|
|
372
|
+
|
|
373
|
+
try {
|
|
374
|
+
const data = await this.getQrCodeData(targetNode);
|
|
375
|
+
res.json(data);
|
|
376
|
+
} catch (err) {
|
|
377
|
+
res.json({ error: err.message });
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
static async getCommissioningInfo(RED, req, res) {
|
|
382
|
+
const targetNode = RED.nodes.getNode(req.params.id);
|
|
383
|
+
if (!targetNode?.matterServer) return res.sendStatus(404);
|
|
384
|
+
|
|
385
|
+
if (!targetNode.matterServer.lifecycle.isCommissioned) {
|
|
386
|
+
const pairingData = targetNode.matterServer.state.commissioning.pairingCodes;
|
|
387
|
+
res.json({
|
|
388
|
+
state: "ready",
|
|
389
|
+
qrPairingCode: pairingData.qrPairingCode,
|
|
390
|
+
manualPairingCode: pairingData.manualPairingCode
|
|
391
|
+
});
|
|
392
|
+
} else {
|
|
393
|
+
res.json({ state: "commissioned" });
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
static getNetworkInterfaces(req, res) {
|
|
398
|
+
const interfaces = os.networkInterfaces();
|
|
399
|
+
const output = [];
|
|
400
|
+
for (const iface in interfaces) {
|
|
401
|
+
for (const addr of interfaces[iface]) {
|
|
402
|
+
if (!addr.internal && addr.family === "IPv6") {
|
|
403
|
+
output.push(iface);
|
|
404
|
+
break;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
res.json([...new Set(output)]);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
static async getQrCodeData(node) {
|
|
412
|
+
if (node.qrCode) {
|
|
413
|
+
return { qrCode: node.qrCode, manualCode: node.manualCode, commissioned: node.matterServer?.lifecycle?.isCommissioned };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (node.matterServer && !node.matterServer.lifecycle.isCommissioned) {
|
|
417
|
+
const pairingData = node.matterServer.state.commissioning.pairingCodes;
|
|
418
|
+
return { qrCode: pairingData.qrPairingCode, manualCode: pairingData.manualPairingCode, commissioned: false };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return { commissioned: true };
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ============================================================================
|
|
426
|
+
// MAIN NODE
|
|
427
|
+
// ============================================================================
|
|
428
|
+
|
|
429
|
+
module.exports = function (RED) {
|
|
430
|
+
function MatterDynamicBridge(config) {
|
|
431
|
+
RED.nodes.createNode(this, config);
|
|
432
|
+
const node = this;
|
|
433
|
+
|
|
434
|
+
node.bridgeConfig = new BridgeConfig(config);
|
|
435
|
+
BridgeConfig.setLogLevel(node.bridgeConfig.logLevel);
|
|
436
|
+
|
|
437
|
+
node.deviceManager = new DeviceManager(node);
|
|
438
|
+
node.serverManager = new ServerManager(node, RED);
|
|
439
|
+
node.errorHandler = new ErrorHandler(node);
|
|
440
|
+
|
|
441
|
+
node.serverReady = false;
|
|
442
|
+
node.users = node.deviceManager.pendingUsers;
|
|
443
|
+
node.registered = node.deviceManager.registered;
|
|
444
|
+
node.registerChild = node.deviceManager.registerChild.bind(node.deviceManager);
|
|
445
|
+
|
|
446
|
+
node._lastCommissioned = null;
|
|
447
|
+
|
|
448
|
+
node.serverManager.create();
|
|
449
|
+
|
|
450
|
+
// poll commissioning state
|
|
451
|
+
node._commissionPoll = setInterval(() => {
|
|
452
|
+
try {
|
|
453
|
+
if (node.matterServer) node.serverManager.updateNodeStatus();
|
|
454
|
+
} catch (e) {}
|
|
455
|
+
}, 2000);
|
|
456
|
+
|
|
457
|
+
this.on("close", async function (removed, done) {
|
|
458
|
+
node.errorHandler.cleanup();
|
|
459
|
+
|
|
460
|
+
if (node._commissionPoll) {
|
|
461
|
+
clearInterval(node._commissionPoll);
|
|
462
|
+
node._commissionPoll = null;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (node.matterServer) {
|
|
466
|
+
try { await node.matterServer.close(); } catch (e) {}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
done();
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
RED.nodes.registerType("matter-dynamic-bridge", MatterDynamicBridge);
|
|
474
|
+
HttpApiHandler.setupEndpoints(RED);
|
|
475
|
+
};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
RED.nodes.registerType('matter-device', {
|
|
3
|
+
category: 'Matter',
|
|
4
|
+
color: '#ffffff',
|
|
5
|
+
defaults: {
|
|
6
|
+
name: {value: ""},
|
|
7
|
+
bridge: {type: "matter-dynamic-bridge", required: true},
|
|
8
|
+
autoConfirm: {value: false},
|
|
9
|
+
deviceConfig: {value: "{\n \"deviceType\": \"OnOffLightDevice\"\n}", validate: function(v) {
|
|
10
|
+
try {
|
|
11
|
+
JSON.parse(v);
|
|
12
|
+
return true;
|
|
13
|
+
} catch(e) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}}
|
|
17
|
+
},
|
|
18
|
+
inputs: 1,
|
|
19
|
+
outputs: 3,
|
|
20
|
+
outputLabels: ["events", "commands", "debug"],
|
|
21
|
+
icon: "icons/matter-device-icon.svg",
|
|
22
|
+
label: function() {
|
|
23
|
+
return this.name || "Matter Device";
|
|
24
|
+
},
|
|
25
|
+
oneditprepare: function() {
|
|
26
|
+
// Create JSON editor
|
|
27
|
+
this.configEditor = RED.editor.createEditor({
|
|
28
|
+
id: 'node-input-deviceConfig-editor',
|
|
29
|
+
mode: 'ace/mode/json',
|
|
30
|
+
value: this.deviceConfig
|
|
31
|
+
});
|
|
32
|
+
},
|
|
33
|
+
oneditsave: function() {
|
|
34
|
+
this.deviceConfig = this.configEditor.getValue();
|
|
35
|
+
this.configEditor.destroy();
|
|
36
|
+
delete this.configEditor;
|
|
37
|
+
},
|
|
38
|
+
oneditcancel: function() {
|
|
39
|
+
this.configEditor.destroy();
|
|
40
|
+
delete this.configEditor;
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
<script type="text/x-red" data-template-name="matter-device">
|
|
46
|
+
<div class="form-row">
|
|
47
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
48
|
+
<input type="text" id="node-input-name" placeholder="Name">
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
<div class="form-row">
|
|
52
|
+
<label for="node-input-bridge"><i class="fa fa-server"></i> Bridge</label>
|
|
53
|
+
<input type="text" id="node-input-bridge">
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<div class="form-row">
|
|
57
|
+
<label for="node-input-autoConfirm"><i class="fa fa-check-circle"></i> Auto Confirm</label>
|
|
58
|
+
<input type="checkbox" id="node-input-autoConfirm" style="display: inline-block; width: auto; vertical-align: top;">
|
|
59
|
+
<span style="margin-left: 10px; color: #999;">Automatically confirm command execution</span>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<div class="form-row">
|
|
63
|
+
<label for="node-input-deviceConfig"><i class="fa fa-code"></i> Config</label>
|
|
64
|
+
<div style="height: 250px; min-height:150px;" class="node-text-editor" id="node-input-deviceConfig-editor"></div>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<div class="form-tips">
|
|
68
|
+
Output 1 = events � Output 2 = commands � Output 3 = debug/diag
|
|
69
|
+
</div>
|
|
70
|
+
</script>
|
|
71
|
+
|
|
72
|
+
<script type="text/x-red" data-help-name="matter-device">
|
|
73
|
+
<p>Matter device node that can be configured via JSON.</p>
|
|
74
|
+
|
|
75
|
+
<h3>Configuration</h3>
|
|
76
|
+
<dl class="message-properties">
|
|
77
|
+
<dt>deviceType <span class="property-type">string</span></dt>
|
|
78
|
+
<dd>The Matter device type (e.g., "OnOffLightDevice", "ThermostatDevice")</dd>
|
|
79
|
+
</dl>
|
|
80
|
+
|
|
81
|
+
<h3>Inputs</h3>
|
|
82
|
+
<p>The payload must match Matter.js cluster structure exactly:</p>
|
|
83
|
+
<dl class="message-properties">
|
|
84
|
+
<dt>payload <span class="property-type">object</span></dt>
|
|
85
|
+
<dd>Device state in Matter format. Examples:
|
|
86
|
+
<ul>
|
|
87
|
+
<li>OnOff Light: <code>{onOff: {onOff: true}}</code></li>
|
|
88
|
+
<li>Temperature: <code>{temperatureMeasurement: {measuredValue: 2000}}</code> (20�C * 100)</li>
|
|
89
|
+
<li>Thermostat: <code>{thermostat: {occupiedHeatingSetpoint: 2100}}</code></li>
|
|
90
|
+
</ul>
|
|
91
|
+
</dd>
|
|
92
|
+
|
|
93
|
+
<dt>topic <span class="property-type">string</span></dt>
|
|
94
|
+
<dd>Set to "state" to query current device state</dd>
|
|
95
|
+
</dl>
|
|
96
|
+
|
|
97
|
+
<h3>Outputs</h3>
|
|
98
|
+
|
|
99
|
+
<p><b>Output 1: State changes (events)</b></p>
|
|
100
|
+
<dl class="message-properties">
|
|
101
|
+
<dt>payload <span class="property-type">object</span></dt>
|
|
102
|
+
<dd>State changes in same format as input</dd>
|
|
103
|
+
<dt>eventSource <span class="property-type">object</span></dt>
|
|
104
|
+
<dd>Source of the state change (local or network)</dd>
|
|
105
|
+
</dl>
|
|
106
|
+
|
|
107
|
+
<p><b>Output 2: Commands</b></p>
|
|
108
|
+
<dl class="message-properties">
|
|
109
|
+
<dt>payload.command <span class="property-type">string</span></dt>
|
|
110
|
+
<dd>Command name (e.g., "on", "off", "toggle")</dd>
|
|
111
|
+
<dt>payload.cluster <span class="property-type">string</span></dt>
|
|
112
|
+
<dd>Cluster name (e.g., "OnOff")</dd>
|
|
113
|
+
<dt>payload.data <span class="property-type">object</span></dt>
|
|
114
|
+
<dd>Command parameters</dd>
|
|
115
|
+
</dl>
|
|
116
|
+
|
|
117
|
+
<p><b>Output 3: Debug / Diagnostics</b></p>
|
|
118
|
+
<dl class="message-properties">
|
|
119
|
+
<dt>payload.type <span class="property-type">string</span></dt>
|
|
120
|
+
<dd>Diagnostic message type (e.g., <code>subscribe_start</code>, <code>event</code>, <code>write_attempt</code>, <code>write_error</code>, <code>bridgeReset</code>)</dd>
|
|
121
|
+
<dt>payload <span class="property-type">object</span></dt>
|
|
122
|
+
<dd>Extra details depending on the type</dd>
|
|
123
|
+
</dl>
|
|
124
|
+
|
|
125
|
+
<h3>Auto Confirm</h3>
|
|
126
|
+
<p>When enabled, commands are automatically confirmed, preventing timeout issues with HomeKit/Alexa.
|
|
127
|
+
When disabled, you must manually send the state back to the input after executing the command on the real device.</p>
|
|
128
|
+
|
|
129
|
+
<h3>Examples</h3>
|
|
130
|
+
<p><b>Turn on a light:</b><br>
|
|
131
|
+
<code>msg.payload = {onOff: {onOff: true}}</code></p>
|
|
132
|
+
|
|
133
|
+
<p><b>Set temperature sensor:</b><br>
|
|
134
|
+
<code>msg.payload = {temperatureMeasurement: {measuredValue: 2150}}</code> // 21.5�C</p>
|
|
135
|
+
|
|
136
|
+
<p><b>Query device state:</b><br>
|
|
137
|
+
<code>msg.topic = "state"</code></p>
|
|
138
|
+
</script>
|