smart-home-engine 1.20.2 → 1.20.4

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-xX-8bkDA.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-xX-8bkDA.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.4",
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,115 @@ 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
+ * Uses the interaction-level write rather than Endpoint.set(): set() requires
407
+ * a synthesized client behavior, which matter.js does not create for every
408
+ * cluster (e.g. basicInformation on the root endpoint reports
409
+ * "Behavior ... is not present on this endpoint" although the cluster and its
410
+ * state are there). The interaction write goes over the same wire path that
411
+ * commands use and needs no behavior instance.
412
+ *
413
+ * @param {string} nodeId
414
+ * @param {number} endpointId
415
+ * @param {string} clusterName camelCase cluster name, e.g. "basicInformation"
416
+ * @param {string} attrName camelCase attribute name, e.g. "nodeLabel"
417
+ * @param {*} value
418
+ * @returns {Promise<void>}
419
+ */
420
+ async function setAttribute(nodeId, endpointId, clusterName, attrName, value) {
421
+ const node = _findClientNode(nodeId);
422
+ const { Write, WriteResult } = require('@matter/protocol');
423
+ const clusters = require('@matter/main/clusters');
424
+ const clusterDef = clusters[clusterName.charAt(0).toUpperCase() + clusterName.slice(1)]?.Complete;
425
+ if (!clusterDef) throw new Error(`Unknown cluster "${clusterName}"`);
426
+ if (!clusterDef.attributes?.[attrName]) throw new Error(`Unknown attribute "${clusterName}.${attrName}"`);
427
+ const request = Write(Write.Attribute({ endpoint: Number(endpointId), cluster: clusterDef, attributes: attrName, value }));
428
+ const result = await node.interaction.write(request);
429
+ WriteResult.assertSuccess(result);
430
+ }
431
+
432
+ /**
433
+ * Rename a device by writing basicInformation.nodeLabel on the root endpoint —
434
+ * the Matter-standard writable user label (max 32 chars), persisted on the
435
+ * device itself. she prefers nodeLabel over productName wherever names are
436
+ * shown or matched, so the new name is immediately usable in scripts.
437
+ *
438
+ * @param {string} nodeId
439
+ * @param {string} name
440
+ * @returns {Promise<void>}
441
+ */
442
+ async function rename(nodeId, name) {
443
+ await setAttribute(nodeId, 0, 'basicInformation', 'nodeLabel', String(name));
444
+ _log?.info(`matter: renamed node ${nodeId} to "${name}"`);
445
+ _broadcast?.({ type: 'matter:deviceList', devices: listPaired() });
446
+ }
447
+
397
448
  // ── Commands ──────────────────────────────────────────────────────────────────
398
449
 
450
+ /** Lowercase the first character (PascalCase model names → camelCase). */
451
+ function _camelize(s) {
452
+ return s.charAt(0).toLowerCase() + s.slice(1);
453
+ }
454
+
455
+ /**
456
+ * Best-effort: list the mandatory field names (camelCase) of a command's
457
+ * request schema. Resolution mirrors matter.js's own command lookup:
458
+ * 1. the behavior's cluster definition (unwrapping a `.cluster`-wrapped
459
+ * specifier like @matter/protocol Specifier.clusterFor does), commands as
460
+ * a TLV Command (requestSchema/fieldDefinitions) or a model-based command
461
+ * ({ schema: CommandModel });
462
+ * 2. fallback: the behavior's ClusterModel schema — request-direction
463
+ * CommandModel children matched by camelized name (the shape generated
464
+ * for discovered client clusters).
465
+ * Returns [] when unresolvable.
466
+ * @param {object} behavior the cluster behavior (agent[clusterName])
467
+ * @param {string} commandName
468
+ * @returns {string[]}
469
+ */
470
+ function _mandatoryCommandFields(behavior, commandName) {
471
+ try {
472
+ let cluster = behavior?.cluster;
473
+ if (cluster && typeof cluster === 'object' && 'cluster' in cluster) cluster = cluster.cluster;
474
+ let cmd = cluster?.commands?.[commandName];
475
+ if (!cmd) {
476
+ // Discovered client clusters: command models live on the ClusterModel schema
477
+ const schemaModel = behavior?.schema ?? behavior?.constructor?.schema;
478
+ if (schemaModel?.children) {
479
+ for (const c of schemaModel.children) {
480
+ if (c.tag === 'command' && c.name && _camelize(c.name) === commandName && c.direction !== 'response') {
481
+ cmd = { schema: c };
482
+ break;
483
+ }
484
+ }
485
+ }
486
+ }
487
+ const schema = cmd?.schema ?? cmd?.requestSchema;
488
+ if (schema?.fieldDefinitions) {
489
+ return Object.entries(schema.fieldDefinitions)
490
+ .filter(([, def]) => !def?.optional)
491
+ .map(([name]) => name);
492
+ }
493
+ if (schema?.children) {
494
+ return [...schema.children].filter((c) => c.effectiveConformance?.isMandatory !== false).map((c) => _camelize(c.name));
495
+ }
496
+ } catch {
497
+ /* best-effort — fall through */
498
+ }
499
+ return [];
500
+ }
501
+
399
502
  /**
400
503
  * Invoke a cluster command.
401
504
  *
505
+ * Mandatory boilerplate fields that scripts rarely care about are filled with
506
+ * neutral defaults when omitted: optionsMask/optionsOverride → {} (bitmap 0,
507
+ * "obey the device's options") and transitionTime → 0 (instant). So
508
+ * she.matter.send('x', 1, 'levelControl', 'moveToLevel', { level: 128 }) works
509
+ * without spelling out the full Matter command schema.
510
+ *
402
511
  * @param {string} nodeId
403
512
  * @param {number} endpointId
404
513
  * @param {string} clusterName camelCase cluster name, e.g. "onOff"
@@ -416,10 +525,27 @@ async function sendCommand(nodeId, endpointId, clusterName, commandName, args) {
416
525
  if (!clusterAgent) throw new Error(`Cluster "${clusterName}" not found on endpoint ${endpointId}`);
417
526
  const cmd = clusterAgent[commandName];
418
527
  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.
528
+ // Fill neutral defaults for mandatory boilerplate fields the caller omitted.
529
+ const merged = args !== undefined && args !== null && typeof args === 'object' ? { ...args } : {};
530
+ let mandatoryFields = _mandatoryCommandFields(clusterAgent, commandName);
531
+ if (mandatoryFields.length === 0) {
532
+ _log?.debug(`matter: could not resolve request fields for ${clusterName}.${commandName}`);
533
+ // Last resort: every LevelControl/ColorControl command carries the two
534
+ // options bitmaps (and the TLV encoder ignores unknown fields), so
535
+ // injecting them is safe even for the few commands without them.
536
+ if (_clusterName(clusterName) === 'levelControl' || _clusterName(clusterName) === 'colorControl') {
537
+ mandatoryFields = ['optionsMask', 'optionsOverride'];
538
+ }
539
+ }
540
+ for (const field of mandatoryFields) {
541
+ if (merged[field] !== undefined) continue;
542
+ if (field === 'optionsMask' || field === 'optionsOverride') merged[field] = {};
543
+ else if (field === 'transitionTime') merged[field] = 0;
544
+ }
545
+ // Only pass args when there is actually a payload.
420
546
  // 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);
547
+ const hasArgs = Object.keys(merged).length > 0;
548
+ return hasArgs ? cmd.call(clusterAgent, merged) : cmd.call(clusterAgent);
423
549
  });
424
550
  }
425
551
 
@@ -467,7 +593,14 @@ function _broadcastNodeAttributes(node, nodeId) {
467
593
  }
468
594
  }
469
595
  }
470
- _log?.info(`matter: watching ${count} attribute(s) on node ${nodeId}`);
596
+ if (count === 0) {
597
+ // Endpoint structure not populated yet (e.g. right after commissioning)
598
+ // — drop the guard so the next lifecycle 'online' event retries.
599
+ _attrBroadcastNodes.delete(nodeId);
600
+ _log?.debug(`matter: no watchable attributes on node ${nodeId} yet, will retry on next online event`);
601
+ } else {
602
+ _log?.info(`matter: watching ${count} attribute(s) on node ${nodeId}`);
603
+ }
471
604
  } catch (err) {
472
605
  _attrBroadcastNodes.delete(nodeId); // allow retry
473
606
  _log?.warn(`matter: attribute broadcast setup failed for ${nodeId}: ${err.message}`);
@@ -586,6 +719,8 @@ module.exports = {
586
719
  getEndpoints,
587
720
  getDeviceSubtitle,
588
721
  getAttribute,
722
+ setAttribute,
723
+ rename,
589
724
  sendCommand,
590
725
  subscribeAttribute,
591
726
  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);