smart-home-engine 1.20.1 → 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-DxR31sxM.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-DxR31sxM.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.1",
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": {
package/src/index.js CHANGED
@@ -498,6 +498,28 @@ if (config.url) {
498
498
 
499
499
  if (shedb.handleMqttMessage(topic, payload)) return;
500
500
 
501
+ // An empty payload deletes the topic from the state store (mqtt-smarthome
502
+ // convention; also how a retained message is cleared per MQTT spec). The
503
+ // retain flag cannot be used to detect this: MQTT 3.1.1 brokers clear it
504
+ // when forwarding to established subscriptions, so a retained-clear
505
+ // arrives here as a plain empty message. Previously an empty payload was
506
+ // stored as { val: NaN }, so the MQTT tab kept showing cleared topics.
507
+ // Subscription callbacks are still dispatched (empty publishes are also
508
+ // used as plain triggers) with val '' — only the stored state is dropped.
509
+ if (payload.length === 0) {
510
+ const emptyOld = store.getObject('mqtt::' + topic) || {};
511
+ if (store.delete('mqtt::' + topic)) {
512
+ log.debug('empty payload, removed topic from state store:', topic);
513
+ }
514
+ const delArr = topic.split('/');
515
+ if (delArr[0] === config.variablePrefix && delArr[1] === 'status') {
516
+ store.delete('var::' + delArr.slice(2).join('/'));
517
+ }
518
+ const ts = new Date().getTime();
519
+ stateChange(topic, { val: '', ts, lc: ts }, emptyOld, msg);
520
+ return;
521
+ }
522
+
501
523
  const state = require('./lib/parse-payload')(payload);
502
524
 
503
525
  const topicArr = topic.split('/');
package/src/lib/redis.js CHANGED
@@ -63,6 +63,13 @@ async function init({ url, store, log }) {
63
63
  });
64
64
  });
65
65
 
66
+ // Write-through: StateStore deletions (e.g. cleared retained messages) → hdel
67
+ store.on('delete', (key) => {
68
+ client.hdel('she:state', key).catch((err) => {
69
+ log.error('redis: hdel failed:', err.message);
70
+ });
71
+ });
72
+
66
73
  log.info('redis: connected', url);
67
74
  }
68
75
 
@@ -10,6 +10,7 @@
10
10
  *
11
11
  * Events:
12
12
  * 'change' (key, val, obj, prevObj)
13
+ * 'delete' (key, prevObj)
13
14
  */
14
15
 
15
16
  const { EventEmitter } = require('events');
@@ -47,6 +48,21 @@ class StateStore extends EventEmitter {
47
48
  this.emit('change', key, obj.val, obj, prev);
48
49
  }
49
50
 
51
+ /**
52
+ * Remove a key from the store (e.g. when a retained MQTT message is
53
+ * deleted via an empty retained publish). Emits 'delete' (key, prevObj)
54
+ * when the key existed; a no-op otherwise.
55
+ * @param {string} key
56
+ * @returns {boolean} true when the key existed and was removed
57
+ */
58
+ delete(key) {
59
+ const prev = this._map.get(key);
60
+ if (prev === undefined) return false;
61
+ this._map.delete(key);
62
+ this.emit('delete', key, prev);
63
+ return true;
64
+ }
65
+
50
66
  /**
51
67
  * @param {string} key
52
68
  * @returns {*} the val of the stored state, or undefined
@@ -9,7 +9,11 @@
9
9
  * All nodeIds are exposed as decimal strings (BigInt serialization boundary).
10
10
  */
11
11
 
12
- const { Environment, ServerNode, ControllerBehavior } = require('@matter/main');
12
+ const { Environment, ServerNode, ControllerBehavior, Logger, LogLevel, LogFormat } = require('@matter/main');
13
+
14
+ // Upper bound for a commissioning attempt — matter.js has internal retries that
15
+ // can keep an HTTP request (and the pairing wizard) hanging for a very long time.
16
+ const COMMISSION_TIMEOUT_MS = 120000;
13
17
 
14
18
  /** @type {import('@matter/main').ServerNode | null} */
15
19
  let _server = null;
@@ -105,6 +109,17 @@ async function init(storagePath, log, broadcastFn) {
105
109
  if (_server) throw new Error('Matter controller already started');
106
110
  _log = log;
107
111
 
112
+ // Route matter.js's own diagnostics into the daemon logger so commissioning
113
+ // problems (discovery, PASE failures, retries) are visible in the Logs tab —
114
+ // by default matter.js writes straight to stdout, bypassing she's logging.
115
+ Logger.format = LogFormat.PLAIN;
116
+ Logger.level = LogLevel.INFO;
117
+ Logger.destinations.default.write = (text, message) => {
118
+ if (message.level >= LogLevel.ERROR) _log.error('matter.js:', text);
119
+ else if (message.level === LogLevel.WARN) _log.warn('matter.js:', text);
120
+ else _log.debug('matter.js:', text);
121
+ };
122
+
108
123
  // Configure storage before anything else touches StorageService
109
124
  Environment.default.vars.set('storage.path', storagePath);
110
125
 
@@ -240,6 +255,44 @@ function listPaired() {
240
255
  */
241
256
  async function commission(options) {
242
257
  if (!_server) throw new Error('Matter controller not started');
258
+
259
+ // Accept QR-code payloads ("MT:...") — matter.js's pairingCode option only
260
+ // decodes 11/21-digit manual pairing codes, so decode QR payloads here.
261
+ if (typeof options.pairingCode === 'string' && options.pairingCode.trim().toUpperCase().startsWith('MT:')) {
262
+ const { QrPairingCodeCodec } = require('@matter/types');
263
+ const [decoded] = QrPairingCodeCodec.decode(options.pairingCode.trim());
264
+ const { pairingCode: _pc, ...rest } = options;
265
+ options = { ...rest, passcode: decoded.passcode, discriminator: decoded.discriminator };
266
+ _log.debug('matter: decoded QR pairing code, discriminator', decoded.discriminator);
267
+ }
268
+
269
+ const label = options.pairingCode !== undefined ? 'pairing code' : `passcode${options.discriminator !== undefined ? ' + discriminator ' + options.discriminator : ''}`;
270
+ _log.info(`matter: commissioning start (${label}${options.discoveryAddress ? ', direct address ' + options.discoveryAddress : ''})`);
271
+ let timer;
272
+ try {
273
+ const nodeId = await Promise.race([
274
+ _commissionInner(options),
275
+ new Promise((_resolve, reject) => {
276
+ timer = setTimeout(
277
+ () =>
278
+ reject(
279
+ new Error(`commissioning timed out after ${COMMISSION_TIMEOUT_MS / 1000} s — check that the pairing window is still open and the device is reachable`),
280
+ ),
281
+ COMMISSION_TIMEOUT_MS,
282
+ );
283
+ }),
284
+ ]);
285
+ _log.info('matter: commissioning succeeded, nodeId', nodeId);
286
+ return nodeId;
287
+ } catch (err) {
288
+ _log.error('matter: commissioning failed:', err.message);
289
+ throw err;
290
+ } finally {
291
+ clearTimeout(timer);
292
+ }
293
+ }
294
+
295
+ async function _commissionInner(options) {
243
296
  const { discoveryAddress, ...commissionOpts } = options;
244
297
  let clientNode;
245
298
  if (discoveryAddress) {
@@ -273,6 +326,11 @@ async function commission(options) {
273
326
  if (!addr) throw new Error('Commission succeeded but node has no peerAddress');
274
327
  const nodeId = _nodeIdStr(addr.nodeId);
275
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);
276
334
  _broadcast?.({ type: 'matter:deviceList', devices: listPaired() });
277
335
  return nodeId;
278
336
  }
@@ -341,11 +399,77 @@ async function getAttribute(nodeId, endpointId, clusterName, attrName) {
341
399
  return clusterState[attrName];
342
400
  }
343
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
+
344
435
  // ── Commands ──────────────────────────────────────────────────────────────────
345
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
+
346
464
  /**
347
465
  * Invoke a cluster command.
348
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
+ *
349
473
  * @param {string} nodeId
350
474
  * @param {number} endpointId
351
475
  * @param {string} clusterName camelCase cluster name, e.g. "onOff"
@@ -363,10 +487,17 @@ async function sendCommand(nodeId, endpointId, clusterName, commandName, args) {
363
487
  if (!clusterAgent) throw new Error(`Cluster "${clusterName}" not found on endpoint ${endpointId}`);
364
488
  const cmd = clusterAgent[commandName];
365
489
  if (typeof cmd !== 'function') throw new Error(`Command "${commandName}" not found in cluster "${clusterName}"`);
366
- // 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.
367
498
  // Void commands (e.g. onOff.off) fail TLV validation if passed an empty object.
368
- const hasArgs = args !== undefined && args !== null && Object.keys(args).length > 0;
369
- 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);
370
501
  });
371
502
  }
372
503
 
@@ -414,7 +545,14 @@ function _broadcastNodeAttributes(node, nodeId) {
414
545
  }
415
546
  }
416
547
  }
417
- _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
+ }
418
556
  } catch (err) {
419
557
  _attrBroadcastNodes.delete(nodeId); // allow retry
420
558
  _log?.warn(`matter: attribute broadcast setup failed for ${nodeId}: ${err.message}`);
@@ -533,6 +671,8 @@ module.exports = {
533
671
  getEndpoints,
534
672
  getDeviceSubtitle,
535
673
  getAttribute,
674
+ setAttribute,
675
+ rename,
536
676
  sendCommand,
537
677
  subscribeAttribute,
538
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);
@@ -12,7 +12,8 @@
12
12
  * (or null when no broker is configured).
13
13
  *
14
14
  * State changes for mqtt:: keys are automatically broadcast to WebSocket clients
15
- * as { type: 'mqtt', topic, val, ts }.
15
+ * as { type: 'mqtt', topic, val, ts }; deletions (retained message cleared via
16
+ * an empty retained publish) as { type: 'mqtt', topic, deleted: true, ts }.
16
17
  */
17
18
 
18
19
  const express = require('express');
@@ -43,6 +44,12 @@ function init(store, getMqttClient) {
43
44
  broadcastBrokerLog(topic.slice('$SYS/broker/log/'.length), String(obj.val), obj.ts);
44
45
  }
45
46
  });
47
+
48
+ // Forward deletions (retained message cleared) so clients drop the topic
49
+ store.on('delete', (key) => {
50
+ if (!key.startsWith('mqtt::')) return;
51
+ broadcast({ type: 'mqtt', topic: key.slice(6), deleted: true, ts: Date.now() });
52
+ });
46
53
  }
47
54
 
48
55
  // GET /she/mqtt/state — sorted snapshot of all retained MQTT topic values