smart-home-engine 1.20.2 → 1.20.3

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{m as O}from"./monaco-langs-Dt8a9eeo.js";import{t as I}from"./index-C31bVgVn.js";/*!-----------------------------------------------------------------------------
1
+ import{m as O}from"./monaco-langs-Dt8a9eeo.js";import{t as I}from"./index-BbXC7eVn.js";/*!-----------------------------------------------------------------------------
2
2
  * Copyright (c) Microsoft Corporation. All rights reserved.
3
3
  * Version: 0.52.2(404545bded1df6ffa41ea0af4e8ddb219018c6c1)
4
4
  * Released under the MIT license
@@ -172,10 +172,10 @@
172
172
  }
173
173
  })();
174
174
  </script>
175
- <script type="module" crossorigin src="/assets/index-C31bVgVn.js"></script>
175
+ <script type="module" crossorigin src="/assets/index-BbXC7eVn.js"></script>
176
176
  <link rel="modulepreload" crossorigin href="/assets/monaco-langs-Dt8a9eeo.js">
177
177
  <link rel="stylesheet" crossorigin href="/assets/monaco-langs-DyX1CsEw.css">
178
- <link rel="stylesheet" crossorigin href="/assets/index-CjRDTePT.css">
178
+ <link rel="stylesheet" crossorigin href="/assets/index-B4M4MeEk.css">
179
179
  </head>
180
180
  <body>
181
181
  <div id="app"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smart-home-engine",
3
- "version": "1.20.2",
3
+ "version": "1.20.3",
4
4
  "description": "Node.js based script runner for use in MQTT based Smart Home environments",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
@@ -326,6 +326,11 @@ async function _commissionInner(options) {
326
326
  if (!addr) throw new Error('Commission succeeded but node has no peerAddress');
327
327
  const nodeId = _nodeIdStr(addr.nodeId);
328
328
  _subscribeNodeLifecycle(clientNode, nodeId);
329
+ // A freshly commissioned node is already online, so the lifecycle 'online'
330
+ // event (the other place this is wired) will not fire again — attach the
331
+ // attribute watchers now or the node stays without live updates until the
332
+ // next daemon restart.
333
+ _broadcastNodeAttributes(clientNode, nodeId);
329
334
  _broadcast?.({ type: 'matter:deviceList', devices: listPaired() });
330
335
  return nodeId;
331
336
  }
@@ -394,11 +399,77 @@ async function getAttribute(nodeId, endpointId, clusterName, attrName) {
394
399
  return clusterState[attrName];
395
400
  }
396
401
 
402
+ /**
403
+ * Write a single attribute value (a remote write for client nodes — the
404
+ * attribute must be writable per the device's Matter data model).
405
+ *
406
+ * @param {string} nodeId
407
+ * @param {number} endpointId
408
+ * @param {string} clusterName camelCase cluster name, e.g. "basicInformation"
409
+ * @param {string} attrName camelCase attribute name, e.g. "nodeLabel"
410
+ * @param {*} value
411
+ * @returns {Promise<void>}
412
+ */
413
+ async function setAttribute(nodeId, endpointId, clusterName, attrName, value) {
414
+ const node = _findClientNode(nodeId);
415
+ const endpoint = _resolveEndpoint(node, endpointId);
416
+ await endpoint.set({ [_clusterName(clusterName)]: { [attrName]: value } });
417
+ }
418
+
419
+ /**
420
+ * Rename a device by writing basicInformation.nodeLabel on the root endpoint —
421
+ * the Matter-standard writable user label (max 32 chars), persisted on the
422
+ * device itself. she prefers nodeLabel over productName wherever names are
423
+ * shown or matched, so the new name is immediately usable in scripts.
424
+ *
425
+ * @param {string} nodeId
426
+ * @param {string} name
427
+ * @returns {Promise<void>}
428
+ */
429
+ async function rename(nodeId, name) {
430
+ await setAttribute(nodeId, 0, 'basicInformation', 'nodeLabel', String(name));
431
+ _log?.info(`matter: renamed node ${nodeId} to "${name}"`);
432
+ _broadcast?.({ type: 'matter:deviceList', devices: listPaired() });
433
+ }
434
+
397
435
  // ── Commands ──────────────────────────────────────────────────────────────────
398
436
 
437
+ /**
438
+ * Best-effort: list the mandatory field names (camelCase) of a command's
439
+ * request schema. Handles both shapes matter.js uses: a TLV ObjectSchema
440
+ * (fieldDefinitions, camelCase keys) and a CommandModel (children with
441
+ * PascalCase names and conformance flags). Returns [] when unresolvable.
442
+ * @param {object} cluster the behavior's cluster definition
443
+ * @param {string} commandName
444
+ * @returns {string[]}
445
+ */
446
+ function _mandatoryCommandFields(cluster, commandName) {
447
+ try {
448
+ const cmd = cluster?.commands?.[commandName];
449
+ const schema = cmd?.schema ?? cmd?.requestSchema;
450
+ if (schema?.fieldDefinitions) {
451
+ return Object.entries(schema.fieldDefinitions)
452
+ .filter(([, def]) => !def?.optional)
453
+ .map(([name]) => name);
454
+ }
455
+ if (schema?.children) {
456
+ return [...schema.children].filter((c) => c.effectiveConformance?.isMandatory !== false).map((c) => c.name.charAt(0).toLowerCase() + c.name.slice(1));
457
+ }
458
+ } catch {
459
+ /* best-effort — fall through */
460
+ }
461
+ return [];
462
+ }
463
+
399
464
  /**
400
465
  * Invoke a cluster command.
401
466
  *
467
+ * Mandatory boilerplate fields that scripts rarely care about are filled with
468
+ * neutral defaults when omitted: optionsMask/optionsOverride → {} (bitmap 0,
469
+ * "obey the device's options") and transitionTime → 0 (instant). So
470
+ * she.matter.send('x', 1, 'levelControl', 'moveToLevel', { level: 128 }) works
471
+ * without spelling out the full Matter command schema.
472
+ *
402
473
  * @param {string} nodeId
403
474
  * @param {number} endpointId
404
475
  * @param {string} clusterName camelCase cluster name, e.g. "onOff"
@@ -416,10 +487,17 @@ async function sendCommand(nodeId, endpointId, clusterName, commandName, args) {
416
487
  if (!clusterAgent) throw new Error(`Cluster "${clusterName}" not found on endpoint ${endpointId}`);
417
488
  const cmd = clusterAgent[commandName];
418
489
  if (typeof cmd !== 'function') throw new Error(`Command "${commandName}" not found in cluster "${clusterName}"`);
419
- // Only pass args when the caller actually provided non-empty args.
490
+ // Fill neutral defaults for mandatory boilerplate fields the caller omitted.
491
+ const merged = args !== undefined && args !== null && typeof args === 'object' ? { ...args } : {};
492
+ for (const field of _mandatoryCommandFields(clusterAgent.cluster, commandName)) {
493
+ if (merged[field] !== undefined) continue;
494
+ if (field === 'optionsMask' || field === 'optionsOverride') merged[field] = {};
495
+ else if (field === 'transitionTime') merged[field] = 0;
496
+ }
497
+ // Only pass args when there is actually a payload.
420
498
  // Void commands (e.g. onOff.off) fail TLV validation if passed an empty object.
421
- const hasArgs = args !== undefined && args !== null && Object.keys(args).length > 0;
422
- return hasArgs ? cmd.call(clusterAgent, args) : cmd.call(clusterAgent);
499
+ const hasArgs = Object.keys(merged).length > 0;
500
+ return hasArgs ? cmd.call(clusterAgent, merged) : cmd.call(clusterAgent);
423
501
  });
424
502
  }
425
503
 
@@ -467,7 +545,14 @@ function _broadcastNodeAttributes(node, nodeId) {
467
545
  }
468
546
  }
469
547
  }
470
- _log?.info(`matter: watching ${count} attribute(s) on node ${nodeId}`);
548
+ if (count === 0) {
549
+ // Endpoint structure not populated yet (e.g. right after commissioning)
550
+ // — drop the guard so the next lifecycle 'online' event retries.
551
+ _attrBroadcastNodes.delete(nodeId);
552
+ _log?.debug(`matter: no watchable attributes on node ${nodeId} yet, will retry on next online event`);
553
+ } else {
554
+ _log?.info(`matter: watching ${count} attribute(s) on node ${nodeId}`);
555
+ }
471
556
  } catch (err) {
472
557
  _attrBroadcastNodes.delete(nodeId); // allow retry
473
558
  _log?.warn(`matter: attribute broadcast setup failed for ${nodeId}: ${err.message}`);
@@ -586,6 +671,8 @@ module.exports = {
586
671
  getEndpoints,
587
672
  getDeviceSubtitle,
588
673
  getAttribute,
674
+ setAttribute,
675
+ rename,
589
676
  sendCommand,
590
677
  subscribeAttribute,
591
678
  unsubscribe,
@@ -58,6 +58,25 @@ router.post('/commission', async (req, res) => {
58
58
  }
59
59
  });
60
60
 
61
+ // POST /she/matter/devices/:nodeId/rename — { name } → writes basicInformation.nodeLabel
62
+ router.post('/devices/:nodeId/rename', async (req, res) => {
63
+ if (!isReady()) return notReady(res);
64
+ const name = req.body?.name;
65
+ if (typeof name !== 'string' || !name.trim()) {
66
+ return res.status(400).json({ error: 'body must contain a non-empty name' });
67
+ }
68
+ if (name.trim().length > 32) {
69
+ return res.status(400).json({ error: 'name must be at most 32 characters (Matter nodeLabel limit)' });
70
+ }
71
+ try {
72
+ await getController().rename(req.params.nodeId, name.trim());
73
+ res.json({ ok: true });
74
+ } catch (err) {
75
+ if (err.message.includes('not found')) return res.status(404).json({ error: err.message });
76
+ res.status(500).json({ error: err.message });
77
+ }
78
+ });
79
+
61
80
  // GET /she/matter/devices/:nodeId — node detail
62
81
  router.get('/devices/:nodeId', (req, res) => {
63
82
  if (!isReady()) return notReady(res);