smart-home-engine 0.12.0 → 0.14.0
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/README.md +1 -1
- package/dist/web/assets/index-BcOZhXqD.css +1 -0
- package/dist/web/assets/index-oMmhHXuR.js +220 -0
- package/dist/web/assets/{tsMode-BU_qnlmu.js → tsMode-BcZhguVQ.js} +1 -1
- package/dist/web/index.html +5 -4
- package/package.json +85 -87
- package/src/config.js +4 -0
- package/src/index.js +50 -3
- package/src/matter/controller.js +161 -23
- package/src/sandbox/matter-sandbox.js +20 -15
- package/src/sandbox/stdlib.js +17 -0
- package/src/web/ai-api.js +137 -164
- package/src/web/ai-context.js +139 -0
- package/src/web/ai-tools.js +210 -0
- package/src/web/log-ws.js +16 -1
- package/src/web/matter-api.js +7 -2
- package/src/web/prompts/db-doc.md +17 -0
- package/src/web/prompts/db-view.md +30 -0
- package/src/web/prompts/scripts-base.md +18 -0
- package/src/web/prompts/she-api-ref.md +73 -0
- package/src/web/scripts-api.js +4 -8
- package/src/web/server.js +8 -1
- package/dist/web/assets/index-DZTaIKZS.css +0 -1
- package/dist/web/assets/index-G6QfHETZ.js +0 -212
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{gU as O}from"./monaco-langs-DZ6hB11b.js";import{t as I}from"./index-
|
|
1
|
+
import{gU as O}from"./monaco-langs-DZ6hB11b.js";import{t as I}from"./index-oMmhHXuR.js";/*!-----------------------------------------------------------------------------
|
|
2
2
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
3
3
|
* Version: 0.52.2(404545bded1df6ffa41ea0af4e8ddb219018c6c1)
|
|
4
4
|
* Released under the MIT license
|
package/dist/web/index.html
CHANGED
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
--bg-input: #3c3c3c;
|
|
39
39
|
--border: #3c3c3c;
|
|
40
40
|
--border-sub: #333333;
|
|
41
|
+
--indent-line: #505050;
|
|
41
42
|
--fg: #cccccc;
|
|
42
43
|
--fg-muted: #858585;
|
|
43
44
|
--fg-dim: #555555;
|
|
@@ -72,8 +73,8 @@
|
|
|
72
73
|
--bg-input: #ffffff;
|
|
73
74
|
--border: #cecece;
|
|
74
75
|
--border-sub: #e0e0e0;
|
|
76
|
+
--indent-line: #c8c8c8;
|
|
75
77
|
--fg: #3b3b3b;
|
|
76
|
-
--fg-muted: #6e6e6e;
|
|
77
78
|
--fg-dim: #aaaaaa;
|
|
78
79
|
--fg-text: #2b2b2b;
|
|
79
80
|
--fg-brand: #0070c1;
|
|
@@ -106,8 +107,8 @@
|
|
|
106
107
|
--bg-input: #ffffff;
|
|
107
108
|
--border: #cecece;
|
|
108
109
|
--border-sub: #e0e0e0;
|
|
110
|
+
--indent-line: #c8c8c8;
|
|
109
111
|
--fg: #3b3b3b;
|
|
110
|
-
--fg-muted: #6e6e6e;
|
|
111
112
|
--fg-dim: #aaaaaa;
|
|
112
113
|
--fg-text: #2b2b2b;
|
|
113
114
|
--fg-brand: #0070c1;
|
|
@@ -153,10 +154,10 @@
|
|
|
153
154
|
}
|
|
154
155
|
})();
|
|
155
156
|
</script>
|
|
156
|
-
<script type="module" crossorigin src="/assets/index-
|
|
157
|
+
<script type="module" crossorigin src="/assets/index-oMmhHXuR.js"></script>
|
|
157
158
|
<link rel="modulepreload" crossorigin href="/assets/monaco-langs-DZ6hB11b.js">
|
|
158
159
|
<link rel="stylesheet" crossorigin href="/assets/monaco-langs-DyX1CsEw.css">
|
|
159
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
160
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BcOZhXqD.css">
|
|
160
161
|
</head>
|
|
161
162
|
<body>
|
|
162
163
|
<div id="app"></div>
|
package/package.json
CHANGED
|
@@ -1,89 +1,87 @@
|
|
|
1
1
|
{
|
|
2
|
-
"name":
|
|
3
|
-
"version":
|
|
4
|
-
"description":
|
|
5
|
-
"main":
|
|
6
|
-
"scripts":
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
"bin":
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
"files":
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
"author":
|
|
27
|
-
"license":
|
|
28
|
-
"dependencies":
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
"engines":
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
"devDependencies":
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
"directories":
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
"repository":
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
"keywords":
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
"bugs":
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
"homepage":
|
|
2
|
+
"name": "smart-home-engine",
|
|
3
|
+
"version": "0.14.0",
|
|
4
|
+
"description": "Node.js based script runner for use in MQTT based Smart Home environments",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "cross-env NODE_OPTIONS=\"--no-warnings\" jest --testPathPattern=test/unit --forceExit",
|
|
8
|
+
"test:integration": "cross-env NODE_OPTIONS=\"--no-warnings\" jest --testPathPattern=test/integration --forceExit",
|
|
9
|
+
"test:all": "cross-env NODE_OPTIONS=\"--no-warnings\" jest --forceExit",
|
|
10
|
+
"test:coverage": "cross-env NODE_OPTIONS=\"--no-warnings\" jest --coverage --forceExit",
|
|
11
|
+
"test:verbose": "cross-env NODE_OPTIONS=\"--no-warnings\" jest --verbose --forceExit",
|
|
12
|
+
"lint": "eslint --cache --cache-location .cache/.eslintcache .",
|
|
13
|
+
"lint:fix": "eslint --cache --cache-location .cache/.eslintcache --fix .",
|
|
14
|
+
"format": "prettier --cache --cache-location .cache/.prettiercache --write .",
|
|
15
|
+
"format:check": "prettier --cache --cache-location .cache/.prettiercache --check .",
|
|
16
|
+
"build:web": "cd web && npm install && npm run build"
|
|
17
|
+
},
|
|
18
|
+
"bin": {
|
|
19
|
+
"she": "src/index.js"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"src/",
|
|
23
|
+
"sandbox/",
|
|
24
|
+
"dist/web/"
|
|
25
|
+
],
|
|
26
|
+
"author": "Sebastian 'hobbyquaker' Raff <hobbyquaker@gmail.com>",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"bcryptjs": "^2.4.3",
|
|
30
|
+
"@elastic/elasticsearch": "^9.4.2",
|
|
31
|
+
"@influxdata/influxdb-client": "^1.35.0",
|
|
32
|
+
"@matter/main": "^0.17.0",
|
|
33
|
+
"chokidar": "^4.0.0",
|
|
34
|
+
"express": "^5.2.1",
|
|
35
|
+
"ioredis": "^5.11.0",
|
|
36
|
+
"mqtt": "^5.0.0",
|
|
37
|
+
"node-schedule": "^2.0.0",
|
|
38
|
+
"pino": "^9.0.0",
|
|
39
|
+
"pino-pretty": "^13.0.0",
|
|
40
|
+
"suncalc": "^1.9.0",
|
|
41
|
+
"ws": "^8.21.0",
|
|
42
|
+
"yargs": "^17.0.0"
|
|
43
|
+
},
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=20.0.0"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@eslint/js": "^9.0.0",
|
|
49
|
+
"@sinonjs/fake-timers": "^11.0.0",
|
|
50
|
+
"@types/jest": "^29.5.0",
|
|
51
|
+
"@types/node": "^22.0.0",
|
|
52
|
+
"aedes": "^0.50.0",
|
|
53
|
+
"cross-env": "^7.0.3",
|
|
54
|
+
"eslint": "^9.0.0",
|
|
55
|
+
"eslint-config-prettier": "^10.1.8",
|
|
56
|
+
"eslint-plugin-n": "^17.0.0",
|
|
57
|
+
"eslint-plugin-prettier": "^5.0.0",
|
|
58
|
+
"globals": "^16.0.0",
|
|
59
|
+
"jest": "^29.7.0",
|
|
60
|
+
"prettier": "^3.0.0"
|
|
61
|
+
},
|
|
62
|
+
"directories": {
|
|
63
|
+
"doc": "doc"
|
|
64
|
+
},
|
|
65
|
+
"repository": {
|
|
66
|
+
"type": "git",
|
|
67
|
+
"url": "https://github.com/hobbyquaker/she"
|
|
68
|
+
},
|
|
69
|
+
"keywords": [
|
|
70
|
+
"MQTT",
|
|
71
|
+
"javascript",
|
|
72
|
+
"node.js",
|
|
73
|
+
"npm",
|
|
74
|
+
"sandbox",
|
|
75
|
+
"vm",
|
|
76
|
+
"Smart",
|
|
77
|
+
"Home",
|
|
78
|
+
"Internet",
|
|
79
|
+
"of",
|
|
80
|
+
"Things",
|
|
81
|
+
"IoT"
|
|
82
|
+
],
|
|
83
|
+
"bugs": {
|
|
84
|
+
"url": "https://github.com/hobbyquaker/she/issues"
|
|
85
|
+
},
|
|
86
|
+
"homepage": "https://github.com/hobbyquaker/she"
|
|
87
87
|
}
|
|
88
|
-
|
|
89
|
-
|
package/src/config.js
CHANGED
|
@@ -19,6 +19,10 @@ const config = require('yargs')
|
|
|
19
19
|
default: path.join(os.homedir(), '.she', 'db'),
|
|
20
20
|
type: 'string',
|
|
21
21
|
})
|
|
22
|
+
.option('matter-storage', {
|
|
23
|
+
describe: 'enable Matter controller; pass a directory path or true to use ~/.she/matter',
|
|
24
|
+
type: 'string',
|
|
25
|
+
})
|
|
22
26
|
.option('port', {
|
|
23
27
|
alias: 'p',
|
|
24
28
|
describe: 'HTTP server port (0 = OS-assigned random port)',
|
package/src/index.js
CHANGED
|
@@ -209,6 +209,42 @@ let connected = false;
|
|
|
209
209
|
require('./web/mqtt-api').init(store, () => mqtt);
|
|
210
210
|
require('./web/ai-api').init(store);
|
|
211
211
|
|
|
212
|
+
// MQTT message rate counter — reset on each stats poll
|
|
213
|
+
let _mqttMsgCount = 0;
|
|
214
|
+
let _mqttMsgTs = Date.now();
|
|
215
|
+
|
|
216
|
+
// Register runtime stats provider for GET /she/status
|
|
217
|
+
require('./web/server').setStatsProvider(() => {
|
|
218
|
+
let topics = 0;
|
|
219
|
+
// eslint-disable-next-line no-unused-vars
|
|
220
|
+
for (const _ of store.mqttEntries()) topics++;
|
|
221
|
+
const now = Date.now();
|
|
222
|
+
const elapsed = (now - _mqttMsgTs) / 1000;
|
|
223
|
+
const mqttMsgPerSec = elapsed > 0 ? Math.round((_mqttMsgCount / elapsed) * 10) / 10 : 0;
|
|
224
|
+
_mqttMsgCount = 0;
|
|
225
|
+
_mqttMsgTs = now;
|
|
226
|
+
let matterNodes = 0;
|
|
227
|
+
let matterEndpoints = 0;
|
|
228
|
+
if (config.matterStorage) {
|
|
229
|
+
try {
|
|
230
|
+
const mc = require('./matter/controller');
|
|
231
|
+
const paired = mc.listPaired();
|
|
232
|
+
matterNodes = paired.length;
|
|
233
|
+
for (const { nodeId } of paired) {
|
|
234
|
+
try { matterEndpoints += mc.getEndpoints(nodeId).length; } catch { /* offline */ }
|
|
235
|
+
}
|
|
236
|
+
} catch { /* controller not ready */ }
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
scripts: Object.keys(scripts).length,
|
|
240
|
+
topics,
|
|
241
|
+
mqttMsgPerSec,
|
|
242
|
+
matterEnabled: !!config.matterStorage,
|
|
243
|
+
matterNodes,
|
|
244
|
+
matterEndpoints,
|
|
245
|
+
};
|
|
246
|
+
});
|
|
247
|
+
|
|
212
248
|
if (!config.url) {
|
|
213
249
|
log.warn('no MQTT broker URL configured — set "url" in ' + path.join(require('os').homedir(), '.she', 'config.json'));
|
|
214
250
|
}
|
|
@@ -238,6 +274,7 @@ if (config.url) {
|
|
|
238
274
|
});
|
|
239
275
|
|
|
240
276
|
mqtt.on('message', (topic, payload, msg) => {
|
|
277
|
+
_mqttMsgCount++;
|
|
241
278
|
if (shedb.handleMqttMessage(topic, payload)) return;
|
|
242
279
|
|
|
243
280
|
const state = require('./lib/parse-payload')(payload);
|
|
@@ -278,7 +315,8 @@ if (config.url) {
|
|
|
278
315
|
|
|
279
316
|
// sheDB — only init when --db-path is given
|
|
280
317
|
if (config.dbPath) {
|
|
281
|
-
|
|
318
|
+
const dbPathResolved = config.dbPath.replace(/^~(?=[/\\]|$)/, require('os').homedir());
|
|
319
|
+
shedb.init({ dbPath: dbPathResolved, dbPublish: config.dbPublish || false, dbRetain: config.dbRetain || false, dbPrefix: config.dbPrefix || 'she/db/', mqttName: config.name, mqtt, log, broadcast });
|
|
282
320
|
}
|
|
283
321
|
|
|
284
322
|
// Redis write-through cache — only init when config.redis.url is given
|
|
@@ -302,10 +340,19 @@ if (config.elastic) {
|
|
|
302
340
|
if (config.matterStorage) {
|
|
303
341
|
const { ensureStorageDir } = require('./lib/storage');
|
|
304
342
|
const matterController = require('./matter/controller');
|
|
305
|
-
|
|
343
|
+
let matterStoragePath;
|
|
344
|
+
if (typeof config.matterStorage === 'string') {
|
|
345
|
+
matterStoragePath = config.matterStorage.replace(/^~(?=[/\\]|$)/, require('os').homedir());
|
|
346
|
+
fs.mkdirSync(matterStoragePath, { recursive: true });
|
|
347
|
+
} else {
|
|
348
|
+
matterStoragePath = ensureStorageDir('matter');
|
|
349
|
+
}
|
|
350
|
+
log.info('matter controller starting, storage:', matterStoragePath);
|
|
306
351
|
matterController.init(matterStoragePath, log, broadcast).catch((err) => {
|
|
307
|
-
log.error('matter controller init failed:', err.message);
|
|
352
|
+
log.error('matter controller init failed:', err.message, err.stack);
|
|
308
353
|
});
|
|
354
|
+
} else {
|
|
355
|
+
log.warn('matter controller disabled — set matterStorage in config.json to enable');
|
|
309
356
|
}
|
|
310
357
|
|
|
311
358
|
// Start scripts immediately — MQTT retained state will populate the store asynchronously
|
package/src/matter/controller.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* All nodeIds are exposed as decimal strings (BigInt serialization boundary).
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
const { Environment, ServerNode } = require('@matter/main');
|
|
12
|
+
const { Environment, ServerNode, ControllerBehavior } = require('@matter/main');
|
|
13
13
|
|
|
14
14
|
/** @type {import('@matter/main').ServerNode | null} */
|
|
15
15
|
let _server = null;
|
|
@@ -32,13 +32,37 @@ function _nodeIdStr(nodeId) {
|
|
|
32
32
|
return nodeId.toString();
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
function _findClientNode(
|
|
35
|
+
function _findClientNode(nodeIdOrName) {
|
|
36
|
+
nodeIdOrName = String(nodeIdOrName);
|
|
36
37
|
if (!_server) throw new Error('Matter controller not started');
|
|
38
|
+
// Try exact numeric nodeId match first
|
|
37
39
|
for (const node of _server.peers) {
|
|
38
40
|
const addr = node.peerAddress;
|
|
39
|
-
if (addr && _nodeIdStr(addr.nodeId) ===
|
|
41
|
+
if (addr && _nodeIdStr(addr.nodeId) === nodeIdOrName) return node;
|
|
40
42
|
}
|
|
41
|
-
|
|
43
|
+
// Fall back to name match (basicInformation.nodeLabel / productName)
|
|
44
|
+
for (const node of _server.peers) {
|
|
45
|
+
if (!node.peerAddress) continue;
|
|
46
|
+
if (_getDeviceName(node) === nodeIdOrName) return node;
|
|
47
|
+
}
|
|
48
|
+
throw new Error(`Matter node not found: ${nodeIdOrName}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Resolve an endpoint by numeric id or by name.
|
|
53
|
+
* @param {object} node ClientNode
|
|
54
|
+
* @param {number|string} endpointIdOrName
|
|
55
|
+
* @returns endpoint object
|
|
56
|
+
*/
|
|
57
|
+
function _resolveEndpoint(node, endpointIdOrName) {
|
|
58
|
+
const asNum = Number(endpointIdOrName);
|
|
59
|
+
if (Number.isFinite(asNum)) return node.endpoints.for(asNum);
|
|
60
|
+
// Name-based lookup
|
|
61
|
+
const name = String(endpointIdOrName);
|
|
62
|
+
for (const ep of node.endpoints) {
|
|
63
|
+
if (_getEndpointName(ep) === name) return ep;
|
|
64
|
+
}
|
|
65
|
+
throw new Error(`Endpoint not found: ${endpointIdOrName}`);
|
|
42
66
|
}
|
|
43
67
|
|
|
44
68
|
/** Resolve a cluster name (camelCase) from a cluster ID number or string. */
|
|
@@ -69,7 +93,13 @@ async function init(storagePath, log, broadcastFn) {
|
|
|
69
93
|
|
|
70
94
|
// Disable matter.js built-in CLI arg and env-var parsing so it doesn't
|
|
71
95
|
// interfere with the daemon's own yargs config.
|
|
72
|
-
|
|
96
|
+
// Wrapped in try/catch because the internal variable layout changed in some
|
|
97
|
+
// @matter/main versions and throws when 'environment' is not a map segment.
|
|
98
|
+
try {
|
|
99
|
+
Environment.default.vars.set('environment.disableInteraction', true);
|
|
100
|
+
} catch {
|
|
101
|
+
// Non-fatal: the daemon has no interactive terminal anyway.
|
|
102
|
+
}
|
|
73
103
|
|
|
74
104
|
_server = await ServerNode.create({
|
|
75
105
|
id: 'she-matter-controller',
|
|
@@ -92,9 +122,67 @@ async function close() {
|
|
|
92
122
|
|
|
93
123
|
// ── Device management ─────────────────────────────────────────────────────────
|
|
94
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Extract a human-readable name for a node from its root endpoint's basicInformation cluster.
|
|
127
|
+
* Returns null when the cluster is unavailable or the node is offline.
|
|
128
|
+
* @param {object} node ClientNode
|
|
129
|
+
* @returns {string|null}
|
|
130
|
+
*/
|
|
131
|
+
function _getDeviceName(node) {
|
|
132
|
+
try {
|
|
133
|
+
for (const ep of node.endpoints) {
|
|
134
|
+
if ((ep.number ?? 0) !== 0) continue;
|
|
135
|
+
const bi = ep.state?.basicInformation;
|
|
136
|
+
if (bi?.nodeLabel) return bi.nodeLabel;
|
|
137
|
+
if (bi?.productName) return bi.productName;
|
|
138
|
+
}
|
|
139
|
+
} catch { /* node may be offline */ }
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Extract a human-readable name for a single endpoint.
|
|
145
|
+
* Prefers bridgedDeviceBasicInformation.nodeLabel for bridged devices,
|
|
146
|
+
* falls back to basicInformation for the root endpoint.
|
|
147
|
+
* @param {object} endpoint
|
|
148
|
+
* @returns {string|null}
|
|
149
|
+
*/
|
|
150
|
+
function _getEndpointName(endpoint) {
|
|
151
|
+
try {
|
|
152
|
+
const state = endpoint.state;
|
|
153
|
+
if (!state) return null;
|
|
154
|
+
const bridgedBi = state.bridgedDeviceBasicInformation;
|
|
155
|
+
if (bridgedBi?.nodeLabel) return bridgedBi.nodeLabel;
|
|
156
|
+
const bi = state.basicInformation;
|
|
157
|
+
if (bi?.nodeLabel) return bi.nodeLabel;
|
|
158
|
+
if (bi?.productName) return bi.productName;
|
|
159
|
+
} catch { /* best-effort */ }
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Extract vendor + product subtitle for the root endpoint (endpoint 0).
|
|
165
|
+
* Returns null when unavailable.
|
|
166
|
+
* @param {object} node
|
|
167
|
+
* @returns {string|null}
|
|
168
|
+
*/
|
|
169
|
+
function _getDeviceSubtitle(node) {
|
|
170
|
+
try {
|
|
171
|
+
for (const ep of node.endpoints) {
|
|
172
|
+
if ((ep.number ?? 0) !== 0) continue;
|
|
173
|
+
const bi = ep.state?.basicInformation;
|
|
174
|
+
const parts = [];
|
|
175
|
+
if (bi?.vendorName) parts.push(bi.vendorName);
|
|
176
|
+
if (bi?.productName && bi.productName !== _getDeviceName(node)) parts.push(bi.productName);
|
|
177
|
+
return parts.length ? parts.join(' · ') : null;
|
|
178
|
+
}
|
|
179
|
+
} catch { /* offline */ }
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
95
183
|
/**
|
|
96
184
|
* List all paired nodes.
|
|
97
|
-
* @returns {{ nodeId: string, online: boolean }[]}
|
|
185
|
+
* @returns {{ nodeId: string, online: boolean, name: string|null }[]}
|
|
98
186
|
*/
|
|
99
187
|
function listPaired() {
|
|
100
188
|
if (!_server) return [];
|
|
@@ -105,6 +193,7 @@ function listPaired() {
|
|
|
105
193
|
result.push({
|
|
106
194
|
nodeId: _nodeIdStr(addr.nodeId),
|
|
107
195
|
online: node.lifecycle?.isOnline ?? false,
|
|
196
|
+
name: _getDeviceName(node),
|
|
108
197
|
});
|
|
109
198
|
}
|
|
110
199
|
return result;
|
|
@@ -113,12 +202,41 @@ function listPaired() {
|
|
|
113
202
|
/**
|
|
114
203
|
* Commission a new device.
|
|
115
204
|
*
|
|
116
|
-
* @param {{ passcode: number, discriminator?: number } | { pairingCode: string }} options
|
|
205
|
+
* @param {{ passcode: number, discriminator?: number, discoveryAddress?: string } | { pairingCode: string, discoveryAddress?: string }} options
|
|
206
|
+
* discoveryAddress: optional "ip" or "ip:port" to bypass mDNS discovery and connect directly.
|
|
117
207
|
* @returns {Promise<string>} nodeId of the newly commissioned device
|
|
118
208
|
*/
|
|
119
209
|
async function commission(options) {
|
|
120
210
|
if (!_server) throw new Error('Matter controller not started');
|
|
121
|
-
const
|
|
211
|
+
const { discoveryAddress, ...commissionOpts } = options;
|
|
212
|
+
let clientNode;
|
|
213
|
+
if (discoveryAddress) {
|
|
214
|
+
const colonIdx = discoveryAddress.lastIndexOf(':');
|
|
215
|
+
let ip, port;
|
|
216
|
+
if (colonIdx > 0 && !discoveryAddress.startsWith('[') && colonIdx !== discoveryAddress.indexOf(':')) {
|
|
217
|
+
// IPv6 without brackets — treat whole string as IP, use default port
|
|
218
|
+
ip = discoveryAddress;
|
|
219
|
+
port = 5540;
|
|
220
|
+
} else if (colonIdx > 0) {
|
|
221
|
+
const maybePort = parseInt(discoveryAddress.slice(colonIdx + 1), 10);
|
|
222
|
+
if (Number.isFinite(maybePort)) {
|
|
223
|
+
ip = discoveryAddress.slice(0, colonIdx).replace(/^\[|\]$/g, '');
|
|
224
|
+
port = maybePort;
|
|
225
|
+
} else {
|
|
226
|
+
ip = discoveryAddress;
|
|
227
|
+
port = 5540;
|
|
228
|
+
}
|
|
229
|
+
} else {
|
|
230
|
+
ip = discoveryAddress;
|
|
231
|
+
port = 5540;
|
|
232
|
+
}
|
|
233
|
+
_log.info(`matter: using direct discovery address ${ip}:${port} (bypassing mDNS)`);
|
|
234
|
+
_server.behaviors.require(ControllerBehavior);
|
|
235
|
+
clientNode = await _server.peers.forDescriptor({ addresses: [{ type: 'udp', ip, port }] });
|
|
236
|
+
await clientNode.commission(commissionOpts);
|
|
237
|
+
} else {
|
|
238
|
+
clientNode = await _server.peers.commission(commissionOpts);
|
|
239
|
+
}
|
|
122
240
|
const addr = clientNode.peerAddress;
|
|
123
241
|
if (!addr) throw new Error('Commission succeeded but node has no peerAddress');
|
|
124
242
|
const nodeId = _nodeIdStr(addr.nodeId);
|
|
@@ -149,14 +267,14 @@ async function unpair(nodeId) {
|
|
|
149
267
|
* Each endpoint entry carries the list of available cluster names.
|
|
150
268
|
*
|
|
151
269
|
* @param {string} nodeId
|
|
152
|
-
* @returns {{ endpointId: number, clusters: string[] }[]}
|
|
270
|
+
* @returns {{ endpointId: number, clusters: string[], name: string|null }[]}
|
|
153
271
|
*/
|
|
154
272
|
function getEndpoints(nodeId) {
|
|
155
273
|
const node = _findClientNode(nodeId);
|
|
156
274
|
const result = [];
|
|
157
275
|
for (const endpoint of node.endpoints) {
|
|
158
276
|
const clusters = endpoint.state ? Object.keys(endpoint.state) : [];
|
|
159
|
-
result.push({ endpointId: endpoint.number ?? 0, clusters });
|
|
277
|
+
result.push({ endpointId: endpoint.number ?? 0, clusters, name: _getEndpointName(endpoint) });
|
|
160
278
|
}
|
|
161
279
|
return result;
|
|
162
280
|
}
|
|
@@ -174,7 +292,7 @@ function getEndpoints(nodeId) {
|
|
|
174
292
|
*/
|
|
175
293
|
async function getAttribute(nodeId, endpointId, clusterName, attrName) {
|
|
176
294
|
const node = _findClientNode(nodeId);
|
|
177
|
-
const endpoint = node
|
|
295
|
+
const endpoint = _resolveEndpoint(node, endpointId);
|
|
178
296
|
const clusterState = endpoint.state?.[_clusterName(clusterName)];
|
|
179
297
|
if (!clusterState) throw new Error(`Cluster "${clusterName}" not found on endpoint ${endpointId}`);
|
|
180
298
|
return clusterState[attrName];
|
|
@@ -194,17 +312,22 @@ async function getAttribute(nodeId, endpointId, clusterName, attrName) {
|
|
|
194
312
|
*/
|
|
195
313
|
async function sendCommand(nodeId, endpointId, clusterName, commandName, args) {
|
|
196
314
|
const node = _findClientNode(nodeId);
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
315
|
+
// Use the target endpoint's own act() instead of navigating via agent.parts,
|
|
316
|
+
// because Parts in @matter/node is a set-like iterable, not a Map (.get doesn't exist).
|
|
317
|
+
const endpoint = _resolveEndpoint(node, endpointId);
|
|
318
|
+
return endpoint.act(
|
|
319
|
+
`she.matter.send(${nodeId}, ${endpointId}, ${clusterName}.${commandName})`,
|
|
320
|
+
async (agent) => {
|
|
321
|
+
const clusterAgent = agent[_clusterName(clusterName)];
|
|
322
|
+
if (!clusterAgent) throw new Error(`Cluster "${clusterName}" not found on endpoint ${endpointId}`);
|
|
323
|
+
const cmd = clusterAgent[commandName];
|
|
324
|
+
if (typeof cmd !== 'function') throw new Error(`Command "${commandName}" not found in cluster "${clusterName}"`);
|
|
325
|
+
// Only pass args when the caller actually provided non-empty args.
|
|
326
|
+
// Void commands (e.g. onOff.off) fail TLV validation if passed an empty object.
|
|
327
|
+
const hasArgs = args !== undefined && args !== null && Object.keys(args).length > 0;
|
|
328
|
+
return hasArgs ? cmd.call(clusterAgent, args) : cmd.call(clusterAgent);
|
|
329
|
+
}
|
|
330
|
+
);
|
|
208
331
|
}
|
|
209
332
|
|
|
210
333
|
// ── Node lifecycle events (online / offline) ────────────────────────────────
|
|
@@ -241,7 +364,7 @@ function _subscribeNodeLifecycle(node, nodeId) {
|
|
|
241
364
|
*/
|
|
242
365
|
function subscribeAttribute(scriptFile, nodeId, endpointId, clusterName, attrName, callback) {
|
|
243
366
|
const node = _findClientNode(nodeId);
|
|
244
|
-
const endpoint = node
|
|
367
|
+
const endpoint = _resolveEndpoint(node, endpointId);
|
|
245
368
|
const events = endpoint.events?.[_clusterName(clusterName)];
|
|
246
369
|
if (!events) throw new Error(`Cluster "${clusterName}" not found on endpoint ${endpointId}`);
|
|
247
370
|
const changeEvent = events[`${attrName}$Changed`];
|
|
@@ -292,6 +415,20 @@ function cleanup(scriptFile) {
|
|
|
292
415
|
_listeners.delete(scriptFile);
|
|
293
416
|
}
|
|
294
417
|
|
|
418
|
+
/**
|
|
419
|
+
* Get vendor · product subtitle for a node by nodeId string.
|
|
420
|
+
* @param {string} nodeId
|
|
421
|
+
* @returns {string|null}
|
|
422
|
+
*/
|
|
423
|
+
function getDeviceSubtitle(nodeId) {
|
|
424
|
+
try {
|
|
425
|
+
const node = _findClientNode(nodeId);
|
|
426
|
+
return _getDeviceSubtitle(node);
|
|
427
|
+
} catch {
|
|
428
|
+
return null;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
295
432
|
module.exports = {
|
|
296
433
|
init,
|
|
297
434
|
close,
|
|
@@ -299,6 +436,7 @@ module.exports = {
|
|
|
299
436
|
commission,
|
|
300
437
|
unpair,
|
|
301
438
|
getEndpoints,
|
|
439
|
+
getDeviceSubtitle,
|
|
302
440
|
getAttribute,
|
|
303
441
|
sendCommand,
|
|
304
442
|
subscribeAttribute,
|