@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,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>