smart-home-engine 0.14.0 → 0.16.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.
package/src/index.js CHANGED
@@ -21,7 +21,7 @@ const _pino = require('pino')(
21
21
  sync: true,
22
22
  }),
23
23
  );
24
- // Lazy import log-ws exports a no-op broadcastLog when the HTTP server is not started.
24
+ // Lazy import — log-ws exports a no-op broadcastLog when the HTTP server is not started.
25
25
  const { broadcastLog, broadcast } = require('./web/log-ws');
26
26
  const shedb = require('./web/shedb');
27
27
  const log = {
@@ -55,7 +55,7 @@ log.debug('loaded config: ', config);
55
55
  if (typeof config.port !== 'undefined') {
56
56
  // Validate: password mode requires a password hash
57
57
  if (config.auth === 'password' && !config.password) {
58
- log.error('auth is set to "password" but no password is configured. Set a password via the web UI Config Authentication section first.');
58
+ log.error('auth is set to "password" but no password is configured. Set a password via the web UI Config → Authentication section first.');
59
59
  process.exit(1);
60
60
  }
61
61
  require('./web/server')
@@ -79,7 +79,7 @@ const modules = {
79
79
  'fs': require('fs'),
80
80
  'path': require('path'),
81
81
  'vm': require('vm'),
82
- /* eslint-disable no-restricted-modules */
82
+ /* eslint-disable no-restricted-modules, n/no-deprecated-api */
83
83
  'domain': require('domain'),
84
84
  'mqtt': require('mqtt'),
85
85
  'node-schedule': require('node-schedule'),
@@ -92,25 +92,21 @@ const Module = require('module');
92
92
  const { STORAGE_ROOT } = require('./lib/storage');
93
93
  const _userRequire = Module.createRequire(modules.path.join(STORAGE_ROOT, '_anchor.js'));
94
94
 
95
- const domain = modules.domain;
96
- const vm = modules.vm;
97
- const fs = modules.fs;
98
- const path = modules.path;
95
+ const { domain, vm, fs, path, suncalc } = modules;
99
96
  const scheduler = modules['node-schedule'];
100
- const suncalc = modules.suncalc;
101
97
 
102
98
  const StateStore = require('./lib/state-store');
103
99
  const sandboxModules = [];
104
100
  const store = new StateStore();
105
101
  const scripts = {};
106
- const scriptOrigins = new Map(); // file 'builtin' | 'user'
102
+ const scriptOrigins = new Map(); // file → 'builtin' | 'user'
107
103
  const subscriptions = [];
108
104
  const mqttEventCallbacks = [];
109
105
  const varSubscriptions = []; // store-based var:: subscriptions { key, handler, _script }
110
106
 
111
107
  // Per-script resource tracking for hot-reload
112
- const scriptJobs = new Map(); // scriptFile node-schedule Job[]
113
- const scriptTimers = new Map(); // scriptFile Set<timer id>
108
+ const scriptJobs = new Map(); // scriptFile → node-schedule Job[]
109
+ const scriptTimers = new Map(); // scriptFile → Set<timer id>
114
110
 
115
111
  const _global = {};
116
112
 
@@ -200,7 +196,7 @@ function sunScheduleEvent(obj, shift) {
200
196
  }
201
197
  }
202
198
 
203
- // MQTT only connect when a broker URL is configured
199
+ // MQTT — only connect when a broker URL is configured
204
200
  let mqtt = null;
205
201
  let connected = false;
206
202
 
@@ -209,9 +205,10 @@ let connected = false;
209
205
  require('./web/mqtt-api').init(store, () => mqtt);
210
206
  require('./web/ai-api').init(store);
211
207
 
212
- // MQTT message rate counter reset on each stats poll
208
+ // MQTT message rate counter — reset on each stats poll
213
209
  let _mqttMsgCount = 0;
214
210
  let _mqttMsgTs = Date.now();
211
+ let _prevCpuUsage = process.cpuUsage();
215
212
 
216
213
  // Register runtime stats provider for GET /she/status
217
214
  require('./web/server').setStatsProvider(() => {
@@ -223,6 +220,14 @@ require('./web/server').setStatsProvider(() => {
223
220
  const mqttMsgPerSec = elapsed > 0 ? Math.round((_mqttMsgCount / elapsed) * 10) / 10 : 0;
224
221
  _mqttMsgCount = 0;
225
222
  _mqttMsgTs = now;
223
+ const cpuDelta = process.cpuUsage(_prevCpuUsage);
224
+ _prevCpuUsage = process.cpuUsage();
225
+ const cpuPercent = elapsed > 0 ? Math.round(((cpuDelta.user + cpuDelta.system) / 1000 / (elapsed * 1000)) * 1000) / 10 : 0;
226
+ const mem = process.memoryUsage();
227
+ const memMb = Math.round(mem.rss / 1048576);
228
+ const core = shedb.getCore();
229
+ const dbDocs = core ? Object.keys(core.docs).length : null;
230
+ const dbViews = core ? Object.keys(core.queries).length : null;
226
231
  let matterNodes = 0;
227
232
  let matterEndpoints = 0;
228
233
  if (config.matterStorage) {
@@ -231,9 +236,15 @@ require('./web/server').setStatsProvider(() => {
231
236
  const paired = mc.listPaired();
232
237
  matterNodes = paired.length;
233
238
  for (const { nodeId } of paired) {
234
- try { matterEndpoints += mc.getEndpoints(nodeId).length; } catch { /* offline */ }
239
+ try {
240
+ matterEndpoints += mc.getEndpoints(nodeId).length;
241
+ } catch {
242
+ /* offline */
243
+ }
235
244
  }
236
- } catch { /* controller not ready */ }
245
+ } catch {
246
+ /* controller not ready */
247
+ }
237
248
  }
238
249
  return {
239
250
  scripts: Object.keys(scripts).length,
@@ -242,11 +253,17 @@ require('./web/server').setStatsProvider(() => {
242
253
  matterEnabled: !!config.matterStorage,
243
254
  matterNodes,
244
255
  matterEndpoints,
256
+ dbEnabled: !!config.dbPath,
257
+ dbDocs,
258
+ dbViews,
259
+ handlers: subscriptions.length + varSubscriptions.length + mqttEventCallbacks.length,
260
+ memMb,
261
+ cpuPercent,
245
262
  };
246
263
  });
247
264
 
248
265
  if (!config.url) {
249
- log.warn('no MQTT broker URL configured set "url" in ' + path.join(require('os').homedir(), '.she', 'config.json'));
266
+ log.warn('no MQTT broker URL configured — set "url" in ' + path.join(require('os').homedir(), '.she', 'config.json'));
250
267
  }
251
268
 
252
269
  if (config.url) {
@@ -313,30 +330,39 @@ if (config.url) {
313
330
  });
314
331
  }
315
332
 
316
- // sheDB only init when --db-path is given
333
+ // sheDB — only init when --db-path is given
317
334
  if (config.dbPath) {
318
335
  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 });
336
+ shedb.init({
337
+ dbPath: dbPathResolved,
338
+ dbPublish: config.dbPublish || false,
339
+ dbRetain: config.dbRetain || false,
340
+ dbPrefix: config.dbPrefix || 'she/db/',
341
+ mqttName: config.name,
342
+ mqtt,
343
+ log,
344
+ broadcast,
345
+ });
320
346
  }
321
347
 
322
- // Redis write-through cache only init when config.redis.url is given
348
+ // Redis write-through cache — only init when config.redis.url is given
323
349
  if (config.redis && config.redis.url) {
324
350
  require('./lib/redis')
325
351
  .init({ url: config.redis.url, store, log })
326
352
  .catch((err) => log.error('redis init failed:', err.message));
327
353
  }
328
354
 
329
- // InfluxDB only init when --influx is set
355
+ // InfluxDB — only init when --influx is set
330
356
  if (config.influx) {
331
357
  require('./influx').init(config.influx);
332
358
  }
333
359
 
334
- // Elasticsearch only init when --elastic is set
360
+ // Elasticsearch — only init when --elastic is set
335
361
  if (config.elastic) {
336
362
  require('./elastic').init(config.elastic);
337
363
  }
338
364
 
339
- // Matter controller only init when --matter-storage is set
365
+ // Matter controller — only init when --matter-storage is set
340
366
  if (config.matterStorage) {
341
367
  const { ensureStorageDir } = require('./lib/storage');
342
368
  const matterController = require('./matter/controller');
@@ -352,10 +378,10 @@ if (config.matterStorage) {
352
378
  log.error('matter controller init failed:', err.message, err.stack);
353
379
  });
354
380
  } else {
355
- log.warn('matter controller disabled set matterStorage in config.json to enable');
381
+ log.warn('matter controller disabled — set matterStorage in config.json to enable');
356
382
  }
357
383
 
358
- // Start scripts immediately MQTT retained state will populate the store asynchronously
384
+ // Start scripts immediately — MQTT retained state will populate the store asynchronously
359
385
  start();
360
386
 
361
387
  function stateChange(topic, state, oldState, msg) {
@@ -428,7 +454,7 @@ function setVariable(name, val) {
428
454
  }
429
455
 
430
456
  const changed = newState.val !== oldState.val;
431
- store.setObject(storeKey, newState); // primary: fires 'change' she.on() callbacks
457
+ store.setObject(storeKey, newState); // primary: fires 'change' → she.on() callbacks
432
458
  store.setObject('mqtt::' + mqttTopic, newState); // compat: so mqttsub('var//name') still works
433
459
  stateChange(mqttTopic, newState, oldState, {}); // fires mqttsub callbacks
434
460
 
@@ -471,40 +497,32 @@ function runScript(script, name, origin) {
471
497
  * @method debug
472
498
  * @param {...*}
473
499
  */
474
- debug() {
475
- const args = Array.prototype.slice.call(arguments);
476
- args.unshift(logLabel);
477
- log.debug.apply(log, args);
500
+ debug(...args) {
501
+ log.debug(logLabel, ...args);
478
502
  },
479
503
  /**
480
504
  * Log an info message
481
505
  * @method info
482
506
  * @param {...*}
483
507
  */
484
- info() {
485
- const args = Array.prototype.slice.call(arguments);
486
- args.unshift(logLabel);
487
- log.info.apply(log, args);
508
+ info(...args) {
509
+ log.info(logLabel, ...args);
488
510
  },
489
511
  /**
490
512
  * Log a warning message
491
513
  * @method warn
492
514
  * @param {...*}
493
515
  */
494
- warn() {
495
- const args = Array.prototype.slice.call(arguments);
496
- args.unshift(logLabel);
497
- log.warn.apply(log, args);
516
+ warn(...args) {
517
+ log.warn(logLabel, ...args);
498
518
  },
499
519
  /**
500
520
  * Log an error message
501
521
  * @method error
502
522
  * @param {...*}
503
523
  */
504
- error() {
505
- const args = Array.prototype.slice.call(arguments);
506
- args.unshift(logLabel);
507
- log.error.apply(log, args);
524
+ error(...args) {
525
+ log.error(logLabel, ...args);
508
526
  },
509
527
 
510
528
  /**
@@ -519,30 +537,29 @@ function runScript(script, name, origin) {
519
537
  * @param {(string|function)} [options.condition] - conditional function or condition string
520
538
  * @param {subscribeCallback} callback
521
539
  */
522
- mqttsub: function Sandbox_mqttsub(topic, /* optional */ options, callback) {
540
+ mqttsub: function Sandbox_mqttsub(topic, ...rest) {
523
541
  if (typeof topic === 'undefined') {
524
542
  throw new TypeError('argument topic missing');
525
543
  }
526
544
 
527
- if (arguments.length === 2) {
528
- if (typeof arguments[1] !== 'function') {
545
+ let options, callback;
546
+ if (rest.length === 1) {
547
+ if (typeof rest[0] !== 'function') {
529
548
  throw new TypeError('callback is not a function');
530
549
  }
531
-
532
- callback = arguments[1];
550
+ [callback] = rest;
533
551
  options = {};
534
- } else if (arguments.length === 3) {
535
- if (typeof arguments[2] !== 'function') {
552
+ } else if (rest.length === 2) {
553
+ if (typeof rest[1] !== 'function') {
536
554
  throw new TypeError('callback is not a function');
537
555
  }
538
- options = arguments[1] || {};
556
+ [options, callback] = rest;
557
+ options = options || {};
539
558
 
540
559
  if (typeof options === 'string' || typeof options === 'function') {
541
560
  options = { condition: options };
542
561
  }
543
-
544
- callback = arguments[2];
545
- } else if (arguments.length > 3) {
562
+ } else if (rest.length > 2) {
546
563
  throw new Error('wrong number of arguments');
547
564
  }
548
565
 
@@ -589,22 +606,23 @@ function runScript(script, name, origin) {
589
606
  * @param {string|Date|Object|Array} pattern - Cron string, suncalc event name, Date, node-schedule literal, or an array of any mix.
590
607
  * @param {Object} [options]
591
608
  * @param {number} [options.random] - random delay in seconds
592
- * @param {number} [options.shift] - offset in seconds for solar events (-8640086400)
609
+ * @param {number} [options.shift] - offset in seconds for solar events (-86400…86400)
593
610
  * @param {function} callback - is called with no arguments
594
611
  */
595
- schedule: function Sandbox_schedule(pattern, /* optional */ options, callback) {
596
- if (arguments.length === 2) {
597
- if (typeof arguments[1] !== 'function') {
612
+ schedule: function Sandbox_schedule(pattern, ...rest) {
613
+ let options, callback;
614
+ if (rest.length === 1) {
615
+ if (typeof rest[0] !== 'function') {
598
616
  throw new TypeError('callback is not a function');
599
617
  }
600
- callback = arguments[1];
618
+ [callback] = rest;
601
619
  options = {};
602
- } else if (arguments.length === 3) {
603
- if (typeof arguments[2] !== 'function') {
620
+ } else if (rest.length === 2) {
621
+ if (typeof rest[1] !== 'function') {
604
622
  throw new TypeError('callback is not a function');
605
623
  }
606
- options = arguments[1] || {};
607
- callback = arguments[2];
624
+ [options, callback] = rest;
625
+ options = options || {};
608
626
  } else {
609
627
  throw new Error('wrong number of arguments');
610
628
  }
@@ -695,7 +713,7 @@ function runScript(script, name, origin) {
695
713
 
696
714
  const tmp = topic.split('/');
697
715
  if (tmp[0] === config.variablePrefix && !config.disableVariables) {
698
- // Variable delegate to setVariable (handles var:: store + MQTT publish)
716
+ // Variable — delegate to setVariable (handles var:: store + MQTT publish)
699
717
  const varName = tmp.slice(2).join('/');
700
718
  setVariable(varName, val);
701
719
  } else if (tmp[0] === config.variablePrefix && config.disableVariables) {
@@ -731,18 +749,18 @@ function runScript(script, name, origin) {
731
749
  * @example // returns the timestamp of a given topic
732
750
  * she.getProp('hm//Bewegungsmelder Keller/MOTION', 'ts');
733
751
  */
734
- getProp: function Sandbox_getProp(topic /* , optional property, optional nested property, ... */) {
752
+ getProp: function Sandbox_getProp(topic, ...props) {
735
753
  topic = topic.replace(/^([^/]+)\/\/(.+)$/, '$1/status/$2');
736
- if (arguments.length > 1) {
754
+ if (props.length > 0) {
737
755
  let tmp = store.getObject('mqtt::' + topic);
738
756
  if (typeof tmp === 'undefined') {
739
757
  return;
740
758
  }
741
- for (let i = 1; i < arguments.length; i++) {
742
- if (typeof tmp[arguments[i]] === 'undefined') {
759
+ for (const prop of props) {
760
+ if (typeof tmp[prop] === 'undefined') {
743
761
  return;
744
762
  }
745
- tmp = tmp[arguments[i]];
763
+ tmp = tmp[prop];
746
764
  }
747
765
  return tmp;
748
766
  }
@@ -835,7 +853,7 @@ function runScript(script, name, origin) {
835
853
  /** @internal Register a callback for MQTT connection lifecycle events. */
836
854
  _registerMqttEvent: function Sandbox_she_registerMqttEvent(event, callback) {
837
855
  if (event !== 'connect' && event !== 'disconnect') {
838
- throw new TypeError('she.mqtt.on: unknown event "' + event + '" use "connect" or "disconnect"');
856
+ throw new TypeError('she.mqtt.on: unknown event "' + event + '" — use "connect" or "disconnect"');
839
857
  }
840
858
  mqttEventCallbacks.push({ event, callback: scriptDomain.bind(callback), _script: name });
841
859
  },
@@ -873,12 +891,12 @@ function runScript(script, name, origin) {
873
891
  try {
874
892
  let result;
875
893
  if (md.match(/^\.\//) || md.match(/^\.\.\//)) {
876
- // Relative import resolve from the script's own directory
894
+ // Relative import — resolve from the script's own directory
877
895
  const tmp = './' + path.relative(__dirname, path.join(scriptDir, md));
878
896
  she.debug('require', tmp);
879
897
  result = require(tmp);
880
898
  } else {
881
- // Absolute import try ~/.she/node_modules/ first (user-installed),
899
+ // Absolute import — try ~/.she/node_modules/ first (user-installed),
882
900
  // then fall back to engine's own require (builtins + engine deps).
883
901
  try {
884
902
  result = _userRequire(md);
@@ -1147,17 +1165,17 @@ function loadDir(dir) {
1147
1165
  filePath = filePath.replace(/\\/g, '/');
1148
1166
  const basename = path.basename(filePath);
1149
1167
 
1150
- // .shelib marker changes warn only, manual restart required
1168
+ // .shelib marker changes — warn only, manual restart required
1151
1169
  if (basename === '.shelib') {
1152
1170
  if (event === 'add') {
1153
- log.warn(filePath, 'library marker added .js files in this directory will no longer load as scripts after daemon restart');
1171
+ log.warn(filePath, 'library marker added — .js files in this directory will no longer load as scripts after daemon restart');
1154
1172
  } else if (event === 'unlink') {
1155
- log.warn(filePath, 'library marker removed .js files in this directory will load as scripts after daemon restart');
1173
+ log.warn(filePath, 'library marker removed — .js files in this directory will load as scripts after daemon restart');
1156
1174
  }
1157
1175
  return;
1158
1176
  }
1159
1177
 
1160
- // Directory events handle gracefully (no process.exit)
1178
+ // Directory events — handle gracefully (no process.exit)
1161
1179
  if (event === 'addDir') return;
1162
1180
 
1163
1181
  if (event === 'unlinkDir') {
@@ -1174,7 +1192,7 @@ function loadDir(dir) {
1174
1192
 
1175
1193
  if (event === 'change' && filePath.endsWith('.js')) {
1176
1194
  if (isLibFile(filePath, dir)) {
1177
- log.warn(filePath, 'is a library file scripts that require() it will see the old version until they or the daemon are restarted');
1195
+ log.warn(filePath, 'is a library file — scripts that require() it will see the old version until they or the daemon are restarted');
1178
1196
  return;
1179
1197
  }
1180
1198
  log.info(filePath, 'change detected. hot-reloading.');
@@ -1182,7 +1200,7 @@ function loadDir(dir) {
1182
1200
  loadScript(filePath);
1183
1201
  } else if (event === 'add' && filePath.endsWith('.js')) {
1184
1202
  if (isLibFile(filePath, dir)) {
1185
- log.debug(filePath, 'is a library file not loading as script');
1203
+ log.debug(filePath, 'is a library file — not loading as script');
1186
1204
  return;
1187
1205
  }
1188
1206
  log.info(filePath, 'added. loading.');
@@ -22,6 +22,11 @@ let _broadcast = null;
22
22
  const _listeners = new Map();
23
23
  let _nextListenerId = 1;
24
24
 
25
+ // ── WS attribute broadcast ───────────────────────────────────────────────────
26
+ // Tracks nodeIds that already have global attribute broadcast listeners set up
27
+ // so we never attach them more than once per node object.
28
+ const _attrBroadcastNodes = new Set();
29
+
25
30
  // ── Helpers ──────────────────────────────────────────────────────────────────
26
31
 
27
32
  function _bigintNodeId(nodeId) {
@@ -65,6 +70,18 @@ function _resolveEndpoint(node, endpointIdOrName) {
65
70
  throw new Error(`Endpoint not found: ${endpointIdOrName}`);
66
71
  }
67
72
 
73
+ /** Recursively convert BigInt values to numbers/strings so the result is JSON-serialisable. */
74
+ function _jsonSafe(v) {
75
+ if (typeof v === 'bigint') return v <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(v) : v.toString();
76
+ if (Array.isArray(v)) return v.map(_jsonSafe);
77
+ if (v !== null && typeof v === 'object') {
78
+ const out = {};
79
+ for (const [k, val] of Object.entries(v)) out[k] = _jsonSafe(val);
80
+ return out;
81
+ }
82
+ return v;
83
+ }
84
+
68
85
  /** Resolve a cluster name (camelCase) from a cluster ID number or string. */
69
86
  function _clusterName(clusterId) {
70
87
  // matter.js cluster state keys are camelCase cluster names.
@@ -107,6 +124,15 @@ async function init(storagePath, log, broadcastFn) {
107
124
 
108
125
  await _server.start();
109
126
  _log.info('matter controller started, storage:', storagePath);
127
+
128
+ // Subscribe lifecycle events for already-paired nodes (persisted from previous session).
129
+ for (const node of _server.peers) {
130
+ const addr = node.peerAddress;
131
+ if (!addr) continue;
132
+ const nodeId = _nodeIdStr(addr.nodeId);
133
+ _subscribeNodeLifecycle(node, nodeId);
134
+ if (node.lifecycle?.isOnline) _broadcastNodeAttributes(node, nodeId);
135
+ }
110
136
  }
111
137
 
112
138
  async function close() {
@@ -136,7 +162,9 @@ function _getDeviceName(node) {
136
162
  if (bi?.nodeLabel) return bi.nodeLabel;
137
163
  if (bi?.productName) return bi.productName;
138
164
  }
139
- } catch { /* node may be offline */ }
165
+ } catch {
166
+ /* node may be offline */
167
+ }
140
168
  return null;
141
169
  }
142
170
 
@@ -156,7 +184,9 @@ function _getEndpointName(endpoint) {
156
184
  const bi = state.basicInformation;
157
185
  if (bi?.nodeLabel) return bi.nodeLabel;
158
186
  if (bi?.productName) return bi.productName;
159
- } catch { /* best-effort */ }
187
+ } catch {
188
+ /* best-effort */
189
+ }
160
190
  return null;
161
191
  }
162
192
 
@@ -176,7 +206,9 @@ function _getDeviceSubtitle(node) {
176
206
  if (bi?.productName && bi.productName !== _getDeviceName(node)) parts.push(bi.productName);
177
207
  return parts.length ? parts.join(' · ') : null;
178
208
  }
179
- } catch { /* offline */ }
209
+ } catch {
210
+ /* offline */
211
+ }
180
212
  return null;
181
213
  }
182
214
 
@@ -273,7 +305,18 @@ function getEndpoints(nodeId) {
273
305
  const node = _findClientNode(nodeId);
274
306
  const result = [];
275
307
  for (const endpoint of node.endpoints) {
276
- const clusters = endpoint.state ? Object.keys(endpoint.state) : [];
308
+ const clusters = [];
309
+ if (endpoint.state) {
310
+ for (const [clusterName, clusterState] of Object.entries(endpoint.state)) {
311
+ const attrs = {};
312
+ if (clusterState && typeof clusterState === 'object') {
313
+ for (const [k, v] of Object.entries(clusterState)) {
314
+ attrs[k] = _jsonSafe(v);
315
+ }
316
+ }
317
+ clusters.push({ name: clusterName, attrs });
318
+ }
319
+ }
277
320
  result.push({ endpointId: endpoint.number ?? 0, clusters, name: _getEndpointName(endpoint) });
278
321
  }
279
322
  return result;
@@ -315,23 +358,69 @@ async function sendCommand(nodeId, endpointId, clusterName, commandName, args) {
315
358
  // Use the target endpoint's own act() instead of navigating via agent.parts,
316
359
  // because Parts in @matter/node is a set-like iterable, not a Map (.get doesn't exist).
317
360
  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
- );
361
+ return endpoint.act(`she.matter.send(${nodeId}, ${endpointId}, ${clusterName}.${commandName})`, async (agent) => {
362
+ const clusterAgent = agent[_clusterName(clusterName)];
363
+ if (!clusterAgent) throw new Error(`Cluster "${clusterName}" not found on endpoint ${endpointId}`);
364
+ const cmd = clusterAgent[commandName];
365
+ 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.
367
+ // 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);
370
+ });
331
371
  }
332
372
 
333
373
  // ── Node lifecycle events (online / offline) ────────────────────────────────
334
374
 
375
+ /**
376
+ * Subscribe to all attribute $Changed events on every endpoint/cluster of a node
377
+ * and forward each change to the WS broadcast so the web UI can display a live
378
+ * event feed. A per-node guard prevents double-registration on reconnects.
379
+ *
380
+ * @param {object} node ClientNode
381
+ * @param {string} nodeId decimal string
382
+ */
383
+ function _broadcastNodeAttributes(node, nodeId) {
384
+ if (!_broadcast) return;
385
+ if (_attrBroadcastNodes.has(nodeId)) return;
386
+ _attrBroadcastNodes.add(nodeId);
387
+ let count = 0;
388
+ try {
389
+ for (const endpoint of node.endpoints) {
390
+ const endpointId = endpoint.number ?? 0;
391
+ if (!endpoint.state) continue;
392
+ // Iterate cluster names from state (enumerable), then access events via
393
+ // bracket notation — endpoint.events may be a Proxy that doesn't enumerate.
394
+ for (const clusterName of Object.keys(endpoint.state)) {
395
+ const clusterState = endpoint.state[clusterName];
396
+ if (!clusterState || typeof clusterState !== 'object') continue;
397
+ const clusterEvents = endpoint.events?.[clusterName];
398
+ if (!clusterEvents) continue;
399
+ for (const attrName of Object.keys(clusterState)) {
400
+ const changeEvent = clusterEvents[`${attrName}$Changed`];
401
+ if (!changeEvent || typeof changeEvent.on !== 'function') continue;
402
+ changeEvent.on((value) => {
403
+ _broadcast?.({
404
+ type: 'matter:attr',
405
+ nodeId,
406
+ endpointId,
407
+ clusterName,
408
+ attrName,
409
+ value: _jsonSafe(value),
410
+ ts: Date.now(),
411
+ });
412
+ });
413
+ count++;
414
+ }
415
+ }
416
+ }
417
+ _log?.info(`matter: watching ${count} attribute(s) on node ${nodeId}`);
418
+ } catch (err) {
419
+ _attrBroadcastNodes.delete(nodeId); // allow retry
420
+ _log?.warn(`matter: attribute broadcast setup failed for ${nodeId}: ${err.message}`);
421
+ }
422
+ }
423
+
335
424
  /**
336
425
  * Subscribe to online/offline lifecycle changes for a node and broadcast them.
337
426
  * @param {object} node ClientNode
@@ -342,6 +431,7 @@ function _subscribeNodeLifecycle(node, nodeId) {
342
431
  if (!lc) return;
343
432
  lc.online?.on?.(() => {
344
433
  _broadcast?.({ type: 'matter:deviceStatus', nodeId, online: true });
434
+ _broadcastNodeAttributes(node, nodeId);
345
435
  });
346
436
  lc.offline?.on?.(() => {
347
437
  _broadcast?.({ type: 'matter:deviceStatus', nodeId, online: false });