@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
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
cat > /home/airnexus/.node-red/node-red-contrib-matter-airnexus/matter-pairing.html <<'HTML'
|
|
2
|
+
<script type="text/javascript">
|
|
3
|
+
RED.nodes.registerType('matter-pairing', {
|
|
4
|
+
category: 'Matter',
|
|
5
|
+
color: '#7bbcff',
|
|
6
|
+
defaults: {
|
|
7
|
+
name: { value: "" },
|
|
8
|
+
bridge: { value: "", type: "matter-dynamic-bridge", required: true },
|
|
9
|
+
timeoutMins: { value: 15, validate: RED.validators.number() },
|
|
10
|
+
includeSvg: { value: true }
|
|
11
|
+
},
|
|
12
|
+
inputs: 1,
|
|
13
|
+
outputs: 1,
|
|
14
|
+
icon: "font-awesome/fa-qrcode",
|
|
15
|
+
label: function() { return this.name || "matter-pairing"; }
|
|
16
|
+
});
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<script type="text/html" data-template-name="matter-pairing">
|
|
20
|
+
<div class="form-row">
|
|
21
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
22
|
+
<input type="text" id="node-input-name" placeholder="matter-pairing">
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<div class="form-row">
|
|
26
|
+
<label for="node-input-bridge"><i class="fa fa-sitemap"></i> Bridge</label>
|
|
27
|
+
<input type="text" id="node-input-bridge">
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<div class="form-row">
|
|
31
|
+
<label for="node-input-timeoutMins"><i class="fa fa-clock-o"></i> Pairing timeout (mins)</label>
|
|
32
|
+
<input type="number" id="node-input-timeoutMins" min="0" step="1" style="width:120px;">
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<div class="form-row">
|
|
36
|
+
<label for="node-input-includeSvg"><i class="fa fa-qrcode"></i> Include QR SVG</label>
|
|
37
|
+
<input type="checkbox" id="node-input-includeSvg" style="width:auto;">
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<div class="form-tips">
|
|
41
|
+
<b>Commands</b><br/>
|
|
42
|
+
<code>get</code> ? output current pairing info<br/>
|
|
43
|
+
<code>pair</code> ? enable pairing mode (best-effort)<br/>
|
|
44
|
+
<code>reset</code>/<code>unpair</code>/<code>delete</code> ? factory reset bridge commissioning + new codes<br/>
|
|
45
|
+
<code>disable</code> ? best-effort stop advertising (offline)<br/><br/>
|
|
46
|
+
Optional payload: <code>{timeoutMins:15, includeSvg:true, forceReset:true}</code>
|
|
47
|
+
</div>
|
|
48
|
+
</script>
|
|
49
|
+
|
|
50
|
+
<script type="text/html" data-help-name="matter-pairing">
|
|
51
|
+
<p>Outputs Matter commissioning info (manual pairing code + QR pairing string) and can reset commissioning to re-enter pairing mode.</p>
|
|
52
|
+
<p><b>Input commands</b>: <code>get</code>, <code>pair</code>, <code>reset</code>/<code>unpair</code>/<code>delete</code>, <code>disable</code></p>
|
|
53
|
+
</script>
|
|
54
|
+
HTML
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* matter-pairing.js (robust)
|
|
3
|
+
*
|
|
4
|
+
* Key improvement:
|
|
5
|
+
* ? After factory reset / recreate, broadcast bridgeReset so device nodes rebuild endpoints.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
module.exports = function (RED) {
|
|
9
|
+
const fs = require("fs");
|
|
10
|
+
const path = require("path");
|
|
11
|
+
let QRCode = null;
|
|
12
|
+
try { QRCode = require("qrcode"); } catch (e) {}
|
|
13
|
+
|
|
14
|
+
const INVALID_PASSCODES = new Set([
|
|
15
|
+
11111111, 22222222, 33333333, 44444444, 55555555,
|
|
16
|
+
66666666, 77777777, 88888888, 12345678, 87654321
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
function nowMs() { return Date.now(); }
|
|
20
|
+
|
|
21
|
+
function generatePasscode() {
|
|
22
|
+
let passcode = Math.floor(Math.random() * 99999997) + 1;
|
|
23
|
+
if (INVALID_PASSCODES.has(passcode)) passcode += 1;
|
|
24
|
+
return +passcode.toString().padStart(8, "0");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function generateDiscriminator() {
|
|
28
|
+
return Math.floor(Math.random() * 4095);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isObject(x) { return x && typeof x === "object" && !Array.isArray(x); }
|
|
32
|
+
|
|
33
|
+
function parseAction(msg) {
|
|
34
|
+
const t = (msg.topic || "").toString().trim().toLowerCase();
|
|
35
|
+
if (t) return t;
|
|
36
|
+
|
|
37
|
+
const p = msg.payload;
|
|
38
|
+
if (typeof p === "string") return p.trim().toLowerCase();
|
|
39
|
+
if (isObject(p)) return String(p.action || p.command || "").trim().toLowerCase() || "get";
|
|
40
|
+
return "get";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function getTimeoutMins(node, msg) {
|
|
44
|
+
const p = msg.payload;
|
|
45
|
+
const v = isObject(p) && p.timeoutMins != null ? Number(p.timeoutMins) : Number(node.timeoutMins);
|
|
46
|
+
if (!Number.isFinite(v) || v < 0) return 15;
|
|
47
|
+
return v;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getIncludeSvg(node, msg) {
|
|
51
|
+
const p = msg.payload;
|
|
52
|
+
if (isObject(p) && p.includeSvg != null) return !!p.includeSvg;
|
|
53
|
+
return !!node.includeSvg;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function ensureBridgeStorageLocation(bridge) {
|
|
57
|
+
if (bridge?.bridgeConfig?.storageLocation) return bridge.bridgeConfig.storageLocation;
|
|
58
|
+
const userDir = (RED.settings && RED.settings.userDir) ? RED.settings.userDir : process.cwd();
|
|
59
|
+
const loc = path.join(userDir, "matter-storage", String(bridge.id));
|
|
60
|
+
bridge.bridgeConfig = bridge.bridgeConfig || {};
|
|
61
|
+
bridge.bridgeConfig.storageLocation = loc;
|
|
62
|
+
return loc;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function rmrf(p) { try { fs.rmSync(p, { recursive: true, force: true }); } catch (e) {} }
|
|
66
|
+
function mkdirp(p) { try { fs.mkdirSync(p, { recursive: true }); } catch (e) {} }
|
|
67
|
+
|
|
68
|
+
async function bestEffortBringOffline(bridge) {
|
|
69
|
+
const ms = bridge?.matterServer;
|
|
70
|
+
if (!ms) return;
|
|
71
|
+
try { if (ms.lifecycle && typeof ms.lifecycle.bringOffline === "function") { await ms.lifecycle.bringOffline(); return; } } catch (e) {}
|
|
72
|
+
try { if (typeof ms.stop === "function") { await ms.stop(); return; } } catch (e) {}
|
|
73
|
+
try { if (typeof ms.close === "function") { await ms.close(); } } catch (e) {}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function recreateServer(bridge) {
|
|
77
|
+
if (!bridge?.serverManager || typeof bridge.serverManager.create !== "function") {
|
|
78
|
+
throw new Error("Bridge does not expose serverManager.create()");
|
|
79
|
+
}
|
|
80
|
+
await bridge.serverManager.create();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function startServerIfPossible(bridge) {
|
|
84
|
+
if (bridge?.serverManager && typeof bridge.serverManager.start === "function") {
|
|
85
|
+
await bridge.serverManager.start();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function getPairingInfo(bridge, includeSvg) {
|
|
90
|
+
if (!bridge?.matterServer) return { state: "not_ready", commissioned: false };
|
|
91
|
+
|
|
92
|
+
const commissioned = !!bridge.matterServer.lifecycle?.isCommissioned;
|
|
93
|
+
if (commissioned) return { state: "commissioned", commissioned: true };
|
|
94
|
+
|
|
95
|
+
const pairingCodes = bridge.matterServer.state?.commissioning?.pairingCodes;
|
|
96
|
+
if (!pairingCodes) return { state: "no_pairing_codes", commissioned: false };
|
|
97
|
+
|
|
98
|
+
const out = {
|
|
99
|
+
state: "ready",
|
|
100
|
+
commissioned: false,
|
|
101
|
+
qrPairingCode: pairingCodes.qrPairingCode,
|
|
102
|
+
manualPairingCode: pairingCodes.manualPairingCode
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
if (includeSvg && QRCode) {
|
|
106
|
+
try {
|
|
107
|
+
out.qrSvg = await QRCode.toString(pairingCodes.qrPairingCode, { type: "svg", width: 200, margin: 1 });
|
|
108
|
+
} catch (e) {}
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function factoryResetBridge(bridge) {
|
|
114
|
+
await bestEffortBringOffline(bridge);
|
|
115
|
+
|
|
116
|
+
const storageLoc = ensureBridgeStorageLocation(bridge);
|
|
117
|
+
rmrf(storageLoc);
|
|
118
|
+
mkdirp(storageLoc);
|
|
119
|
+
|
|
120
|
+
if (bridge.bridgeConfig) {
|
|
121
|
+
bridge.bridgeConfig.passcode = generatePasscode();
|
|
122
|
+
bridge.bridgeConfig.discriminator = generateDiscriminator();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
bridge.serverReady = false;
|
|
126
|
+
bridge.aggregator = null;
|
|
127
|
+
bridge.matterServer = null;
|
|
128
|
+
|
|
129
|
+
await recreateServer(bridge);
|
|
130
|
+
await startServerIfPossible(bridge);
|
|
131
|
+
|
|
132
|
+
// ? IMPORTANT: after reset, force devices to rebuild endpoints
|
|
133
|
+
if (bridge.deviceManager?.broadcastBridgeReset) {
|
|
134
|
+
bridge.deviceManager.broadcastBridgeReset({ reason: "factoryReset" });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return storageLoc;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function MatterPairingNode(config) {
|
|
141
|
+
RED.nodes.createNode(this, config);
|
|
142
|
+
const node = this;
|
|
143
|
+
|
|
144
|
+
node.name = config.name;
|
|
145
|
+
node.bridge = RED.nodes.getNode(config.bridge);
|
|
146
|
+
|
|
147
|
+
node.timeoutMins = Number(config.timeoutMins || 15);
|
|
148
|
+
node.includeSvg = config.includeSvg === true || /^true$/i.test(String(config.includeSvg));
|
|
149
|
+
|
|
150
|
+
node._pairingEnabled = false;
|
|
151
|
+
node._pairingUntil = 0;
|
|
152
|
+
let pairingTimer = null;
|
|
153
|
+
|
|
154
|
+
function setStatus(text, fill = "blue", shape = "dot") {
|
|
155
|
+
node.status({ fill, shape, text });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function clearTimer() {
|
|
159
|
+
if (pairingTimer) { clearTimeout(pairingTimer); pairingTimer = null; }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function outputInfo(extra = {}, includeSvgOverride = null) {
|
|
163
|
+
const bridge = node.bridge;
|
|
164
|
+
const includeSvg = includeSvgOverride == null ? !!node.includeSvg : !!includeSvgOverride;
|
|
165
|
+
const info = await getPairingInfo(bridge, includeSvg);
|
|
166
|
+
|
|
167
|
+
node.send({
|
|
168
|
+
payload: {
|
|
169
|
+
bridgeId: bridge?.id,
|
|
170
|
+
pairingEnabled: node._pairingEnabled,
|
|
171
|
+
pairingUntil: node._pairingUntil ? new Date(node._pairingUntil).toISOString() : null,
|
|
172
|
+
...info,
|
|
173
|
+
...extra
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
if (info.state === "commissioned") setStatus("commissioned", "green");
|
|
178
|
+
else if (info.state === "ready") setStatus(`pairing: ${info.manualPairingCode}`, "blue");
|
|
179
|
+
else if (info.state === "not_ready") setStatus("bridge not ready", "yellow");
|
|
180
|
+
else setStatus(info.state, "yellow");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function enablePairingFor(timeoutMins) {
|
|
184
|
+
clearTimer();
|
|
185
|
+
node._pairingEnabled = true;
|
|
186
|
+
|
|
187
|
+
if (timeoutMins > 0) {
|
|
188
|
+
node._pairingUntil = nowMs() + timeoutMins * 60 * 1000;
|
|
189
|
+
pairingTimer = setTimeout(async () => {
|
|
190
|
+
node._pairingEnabled = false;
|
|
191
|
+
node._pairingUntil = 0;
|
|
192
|
+
try { await bestEffortBringOffline(node.bridge); } catch (e) {}
|
|
193
|
+
setStatus("pairing disabled", "grey");
|
|
194
|
+
}, timeoutMins * 60 * 1000);
|
|
195
|
+
} else {
|
|
196
|
+
node._pairingUntil = 0;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
this.on("input", async function (msg) {
|
|
201
|
+
const action = parseAction(msg);
|
|
202
|
+
const bridge = node.bridge;
|
|
203
|
+
|
|
204
|
+
if (!bridge) {
|
|
205
|
+
setStatus("no bridge", "red", "ring");
|
|
206
|
+
node.send({ payload: { error: "Bridge not configured" } });
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const timeoutMins = getTimeoutMins(node, msg);
|
|
211
|
+
const includeSvg = getIncludeSvg(node, msg);
|
|
212
|
+
const p = msg.payload;
|
|
213
|
+
const forceReset = isObject(p) ? !!p.forceReset : false;
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
if (action === "get") {
|
|
217
|
+
await outputInfo({}, includeSvg);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (action === "pair" || action === "pairing" || action === "enable") {
|
|
222
|
+
if (bridge?.matterServer?.lifecycle?.isCommissioned && !forceReset) {
|
|
223
|
+
await outputInfo({ warning: "Bridge is commissioned. Use {forceReset:true} or 'reset' to re-enter pairing." }, includeSvg);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (bridge?.matterServer?.lifecycle?.isCommissioned && forceReset) {
|
|
228
|
+
const storageLoc = await factoryResetBridge(bridge);
|
|
229
|
+
await enablePairingFor(timeoutMins);
|
|
230
|
+
await outputInfo({ reset: true, storageLocation: storageLoc }, includeSvg);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
await startServerIfPossible(bridge);
|
|
235
|
+
await enablePairingFor(timeoutMins);
|
|
236
|
+
await outputInfo({}, includeSvg);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (action === "reset" || action === "unpair" || action === "delete") {
|
|
241
|
+
const storageLoc = await factoryResetBridge(bridge);
|
|
242
|
+
await enablePairingFor(timeoutMins);
|
|
243
|
+
await outputInfo({ reset: true, storageLocation: storageLoc }, includeSvg);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (action === "disable") {
|
|
248
|
+
clearTimer();
|
|
249
|
+
node._pairingEnabled = false;
|
|
250
|
+
node._pairingUntil = 0;
|
|
251
|
+
await bestEffortBringOffline(bridge);
|
|
252
|
+
setStatus("pairing disabled", "grey");
|
|
253
|
+
await outputInfo({}, includeSvg);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
await outputInfo({ warning: `Unknown action '${action}'. Use get/pair/reset/disable.` }, includeSvg);
|
|
258
|
+
} catch (err) {
|
|
259
|
+
setStatus("error", "red", "ring");
|
|
260
|
+
node.send({ payload: { error: err.message } });
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
this.on("close", function (removed, done) {
|
|
265
|
+
clearTimer();
|
|
266
|
+
node._pairingEnabled = false;
|
|
267
|
+
node._pairingUntil = 0;
|
|
268
|
+
done();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
setStatus("ready", "green");
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
RED.nodes.registerType("matter-pairing", MatterPairingNode);
|
|
275
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@airnexus/node-red-contrib-matter-airnexus",
|
|
3
|
+
"version": "0.2.4-airnexus.1",
|
|
4
|
+
"description": "AirNexus Matter bridge + dynamic devices + pairing management for Node-RED",
|
|
5
|
+
"main": "matter-bridge.js",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"node-red",
|
|
8
|
+
"node-red-contrib",
|
|
9
|
+
"airnexus",
|
|
10
|
+
"matter",
|
|
11
|
+
"matter-js",
|
|
12
|
+
"iot",
|
|
13
|
+
"smart-home",
|
|
14
|
+
"homekit",
|
|
15
|
+
"google-home",
|
|
16
|
+
"alexa",
|
|
17
|
+
"dynamic",
|
|
18
|
+
"bridge",
|
|
19
|
+
"pairing",
|
|
20
|
+
"commissioning"
|
|
21
|
+
],
|
|
22
|
+
"author": "AirNexus",
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@matter/main": "^0.13.0",
|
|
26
|
+
"qrcode": "^1.5.4"
|
|
27
|
+
},
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=18.0.0"
|
|
30
|
+
},
|
|
31
|
+
"node-red": {
|
|
32
|
+
"version": ">=3.0.0",
|
|
33
|
+
"nodes": {
|
|
34
|
+
"matter-dynamic-bridge": "matter-bridge.js",
|
|
35
|
+
"matter-device": "matter-device.js",
|
|
36
|
+
"matter-pairing": "matter-pairing.js"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
package/utils.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// UTILITY FUNCTIONS
|
|
3
|
+
// ============================================================================
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Get Matter-compatible device ID from Node-RED node ID
|
|
7
|
+
* Removes hyphens from the node ID to create a valid Matter device identifier
|
|
8
|
+
*
|
|
9
|
+
* @param {string} nodeId - Node-RED node ID (e.g., "a1b2-c3d4-e5f6")
|
|
10
|
+
* @returns {string} Matter device ID (e.g., "a1b2c3d4e5f6")
|
|
11
|
+
*/
|
|
12
|
+
function getMatterDeviceId(nodeId) {
|
|
13
|
+
return nodeId.replace(/-/g, '');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Get Matter-compatible unique ID from Node-RED node ID
|
|
18
|
+
* Creates a reversed version of the device ID for uniqueness
|
|
19
|
+
*
|
|
20
|
+
* @param {string} nodeId - Node-RED node ID (e.g., "a1b2-c3d4-e5f6")
|
|
21
|
+
* @returns {string} Matter unique ID (reversed device ID, e.g., "6f5e4d3c2b1a")
|
|
22
|
+
*/
|
|
23
|
+
function getMatterUniqueId(nodeId) {
|
|
24
|
+
return getMatterDeviceId(nodeId).split("").reverse().join("");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = {
|
|
28
|
+
getMatterDeviceId,
|
|
29
|
+
getMatterUniqueId
|
|
30
|
+
};
|