smart-home-engine 0.13.0 → 0.15.18

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.
@@ -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,
@@ -15,6 +15,11 @@
15
15
  * she.matter.send(nodeId, endpointId, clusterName, command, args?)
16
16
  * → Promise<result>
17
17
  *
18
+ * nodeId — decimal NodeId string (e.g. '4') OR device name (e.g. 'Matterbridge')
19
+ * endpointId — numeric endpoint id (e.g. 43) OR endpoint name (e.g. 'Licht Werkstatt')
20
+ * Name matching uses basicInformation.nodeLabel / productName for devices and
21
+ * bridgedDeviceBasicInformation.nodeLabel / basicInformation.nodeLabel for endpoints.
22
+ *
18
23
  * All subscriptions registered by a script are automatically cancelled on
19
24
  * hot-reload (cleanup() is called from unloadScript() in index.js).
20
25
  */
@@ -26,11 +31,11 @@ module.exports = function (she, { scriptDomain, scriptName }) {
26
31
  /**
27
32
  * Subscribe to an attribute change on a paired Matter device.
28
33
  *
29
- * @param {string} nodeId Decimal NodeId string
30
- * @param {number} endpointId
31
- * @param {string} clusterName camelCase cluster name, e.g. "onOff"
32
- * @param {string} attrName camelCase attribute name, e.g. "onOff"
33
- * @param {Function} callback (value, oldValue) => void
34
+ * @param {string|number} nodeId Decimal NodeId string/number OR device name
35
+ * @param {number|string} endpointId Numeric endpoint id OR endpoint name
36
+ * @param {string} clusterName camelCase cluster name, e.g. "onOff"
37
+ * @param {string} attrName camelCase attribute name, e.g. "onOff"
38
+ * @param {Function} callback (value, oldValue) => void
34
39
  * @returns {number} listenerId
35
40
  */
36
41
  sub(nodeId, endpointId, clusterName, attrName, callback) {
@@ -53,10 +58,10 @@ module.exports = function (she, { scriptDomain, scriptName }) {
53
58
  /**
54
59
  * Read a single attribute value from a paired Matter device.
55
60
  *
56
- * @param {string} nodeId
57
- * @param {number} endpointId
58
- * @param {string} clusterName camelCase cluster name
59
- * @param {string} attrName camelCase attribute name
61
+ * @param {string|number} nodeId Decimal NodeId string/number OR device name
62
+ * @param {number|string} endpointId Numeric endpoint id OR endpoint name
63
+ * @param {string} clusterName camelCase cluster name
64
+ * @param {string} attrName camelCase attribute name
60
65
  * @returns {Promise<unknown>}
61
66
  */
62
67
  get(nodeId, endpointId, clusterName, attrName) {
@@ -66,15 +71,15 @@ module.exports = function (she, { scriptDomain, scriptName }) {
66
71
  /**
67
72
  * Invoke a cluster command on a paired Matter device.
68
73
  *
69
- * @param {string} nodeId
70
- * @param {number} endpointId
71
- * @param {string} clusterName camelCase cluster name
72
- * @param {string} command camelCase command name
73
- * @param {object} [args={}]
74
+ * @param {string|number} nodeId Decimal NodeId string/number OR device name
75
+ * @param {number|string} endpointId Numeric endpoint id OR endpoint name
76
+ * @param {string} clusterName camelCase cluster name
77
+ * @param {string} command camelCase command name
78
+ * @param {object} [args] Command arguments (omit for void commands)
74
79
  * @returns {Promise<unknown>}
75
80
  */
76
81
  send(nodeId, endpointId, clusterName, command, args) {
77
- return controller.sendCommand(nodeId, endpointId, clusterName, command, args ?? {});
82
+ return controller.sendCommand(nodeId, endpointId, clusterName, command, args);
78
83
  },
79
84
  };
80
85
  };
@@ -129,4 +129,21 @@ module.exports = function (she) {
129
129
  /** Register a callback for MQTT connection lifecycle events ('connect' or 'disconnect'). */
130
130
  on: (event, cb) => she._registerMqttEvent(event, cb),
131
131
  };
132
+
133
+ /**
134
+ * Fetch a URL and return a Promise that resolves to the response body.
135
+ * Resolves to parsed JSON when the Content-Type is application/json, plain text otherwise.
136
+ * Rejects on non-2xx responses.
137
+ * @method fetch
138
+ * @param {string} url
139
+ * @param {RequestInit} [options]
140
+ * @returns {Promise<string|object>}
141
+ */
142
+ she.fetch = function Sandbox_fetch(url, options) {
143
+ return fetch(url, options).then((r) => {
144
+ if (!r.ok) throw new Error(`HTTP ${r.status} ${r.statusText}`);
145
+ const ct = r.headers.get('content-type') || '';
146
+ return ct.includes('json') ? r.json() : r.text();
147
+ });
148
+ };
132
149
  };