smart-home-engine 0.13.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.
@@ -1,4 +1,4 @@
1
- import{gU as O}from"./monaco-langs-DZ6hB11b.js";import{t as I}from"./index-UyOLwDd5.js";/*!-----------------------------------------------------------------------------
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
@@ -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-UyOLwDd5.js"></script>
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-CxNH_rV4.css">
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": "smart-home-engine",
3
- "version": "0.13.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 \u0026\u0026 npm install \u0026\u0026 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 \u0027hobbyquaker\u0027 Raff \u003chobbyquaker@gmail.com\u003e",
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": "\u003e=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"
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
- shedb.init({ dbPath: config.dbPath, dbPublish: config.dbPublish || false, dbRetain: config.dbRetain || false, dbPrefix: config.dbPrefix || 'she/db/', mqttName: config.name, mqtt, log, broadcast });
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
- const matterStoragePath = typeof config.matterStorage === 'string' ? config.matterStorage : ensureStorageDir('matter');
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
@@ -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(nodeIdStr) {
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) === nodeIdStr) return node;
41
+ if (addr && _nodeIdStr(addr.nodeId) === nodeIdOrName) return node;
40
42
  }
41
- throw new Error(`Matter node not found: ${nodeIdStr}`);
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
- Environment.default.vars.set('environment.disableInteraction', true);
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 clientNode = await _server.peers.commission(options);
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.endpoints.for(endpointId);
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
- return node.act(`she.matter.send(${nodeId}, ${endpointId}, ${clusterName}.${commandName})`, async (agent) => {
198
- const rootParts = agent.parts;
199
- // Navigate to the target endpoint
200
- const ep = rootParts ? rootParts.get(endpointId) : null;
201
- if (!ep) throw new Error(`Endpoint ${endpointId} not found on node ${nodeId}`);
202
- const clusterAgent = ep[_clusterName(clusterName)];
203
- if (!clusterAgent) throw new Error(`Cluster "${clusterName}" not found`);
204
- const cmd = clusterAgent[commandName];
205
- if (typeof cmd !== 'function') throw new Error(`Command "${commandName}" not found in cluster "${clusterName}"`);
206
- return cmd.call(clusterAgent, args ?? {});
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.endpoints.for(endpointId);
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,