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/README.md +14 -10
- package/dist/web/assets/index-Cqfuxa_i.js +220 -0
- package/dist/web/assets/index-DcqBg4oJ.css +1 -0
- package/dist/web/assets/{monaco-langs-DZ6hB11b.js → monaco-langs-Decdf6BV.js} +1 -1
- package/dist/web/assets/{tsMode-BcZhguVQ.js → tsMode-B7q_C6Fy.js} +1 -1
- package/dist/web/index.html +3 -3
- package/package.json +1 -1
- package/src/index.js +94 -76
- package/src/matter/controller.js +107 -17
- package/src/web/ai-api.js +65 -26
- package/src/web/ai-context.js +15 -38
- package/src/web/ai-tools.js +160 -13
- package/src/web/deps-api.js +32 -1
- package/src/web/matter-api.js +2 -2
- package/src/web/prompts/scripts-base.md +7 -0
- package/src/web/prompts/she-api-ref.md +17 -0
- package/src/web/scripts-api.js +0 -1
- package/src/web/server.js +3 -1
- package/src/web/shedb-api.js +1 -1
- package/src/web/shedb.js +2 -6
- package/dist/web/assets/index-BcOZhXqD.css +0 -1
- package/dist/web/assets/index-oMmhHXuR.js +0 -220
package/src/index.js
CHANGED
|
@@ -21,7 +21,7 @@ const _pino = require('pino')(
|
|
|
21
21
|
sync: true,
|
|
22
22
|
}),
|
|
23
23
|
);
|
|
24
|
-
// Lazy import
|
|
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
|
|
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
|
|
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
|
|
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
|
|
113
|
-
const scriptTimers = new Map(); // scriptFile
|
|
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
|
|
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
|
|
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 {
|
|
239
|
+
try {
|
|
240
|
+
matterEndpoints += mc.getEndpoints(nodeId).length;
|
|
241
|
+
} catch {
|
|
242
|
+
/* offline */
|
|
243
|
+
}
|
|
235
244
|
}
|
|
236
|
-
} catch {
|
|
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
|
|
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
|
|
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({
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
381
|
+
log.warn('matter controller disabled — set matterStorage in config.json to enable');
|
|
356
382
|
}
|
|
357
383
|
|
|
358
|
-
// Start scripts immediately
|
|
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'
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
528
|
-
|
|
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 (
|
|
535
|
-
if (typeof
|
|
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
|
|
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 (-86400
|
|
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,
|
|
596
|
-
|
|
597
|
-
|
|
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 =
|
|
618
|
+
[callback] = rest;
|
|
601
619
|
options = {};
|
|
602
|
-
} else if (
|
|
603
|
-
if (typeof
|
|
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
|
|
607
|
-
|
|
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
|
|
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
|
|
752
|
+
getProp: function Sandbox_getProp(topic, ...props) {
|
|
735
753
|
topic = topic.replace(/^([^/]+)\/\/(.+)$/, '$1/status/$2');
|
|
736
|
-
if (
|
|
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 (
|
|
742
|
-
if (typeof tmp[
|
|
759
|
+
for (const prop of props) {
|
|
760
|
+
if (typeof tmp[prop] === 'undefined') {
|
|
743
761
|
return;
|
|
744
762
|
}
|
|
745
|
-
tmp = tmp[
|
|
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 + '"
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1203
|
+
log.debug(filePath, 'is a library file — not loading as script');
|
|
1186
1204
|
return;
|
|
1187
1205
|
}
|
|
1188
1206
|
log.info(filePath, 'added. loading.');
|
package/src/matter/controller.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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 });
|