smart-home-engine 0.0.1 → 0.10.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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +76 -0
  3. package/dist/web/assets/codicon-DCmgc-ay.ttf +0 -0
  4. package/dist/web/assets/index-Bdf2J0nm.js +140 -0
  5. package/dist/web/assets/index-DkhtWYJx.css +1 -0
  6. package/dist/web/assets/monaco-langs-DZ6hB11b.js +1423 -0
  7. package/dist/web/assets/monaco-langs-DyX1CsEw.css +1 -0
  8. package/dist/web/assets/tsMode-THvwQw-l.js +16 -0
  9. package/dist/web/index.html +164 -0
  10. package/dist/web/monacoeditorwork/editor.worker.bundle.js +13519 -0
  11. package/dist/web/monacoeditorwork/ts.worker.bundle.js +256353 -0
  12. package/package.json +84 -10
  13. package/src/config.js +53 -0
  14. package/src/elastic.js +19 -0
  15. package/src/index.js +1184 -0
  16. package/src/influx.js +25 -0
  17. package/src/lib/mqtt-wildcards.js +34 -0
  18. package/src/lib/parse-payload.js +29 -0
  19. package/src/lib/redis.js +74 -0
  20. package/src/lib/shedb-core.js +447 -0
  21. package/src/lib/shedb-worker.js +126 -0
  22. package/src/lib/state-store.js +97 -0
  23. package/src/lib/storage.js +74 -0
  24. package/src/matter/controller.js +307 -0
  25. package/src/sandbox/api.js +57 -0
  26. package/src/sandbox/elastic-sandbox.js +88 -0
  27. package/src/sandbox/influx-sandbox.js +107 -0
  28. package/src/sandbox/matter-sandbox.js +92 -0
  29. package/src/sandbox/shedb-sandbox.js +89 -0
  30. package/src/sandbox/stdlib.js +132 -0
  31. package/src/scripts/hello.js +3 -0
  32. package/src/web/ai-api.js +443 -0
  33. package/src/web/config-api.js +34 -0
  34. package/src/web/deps-api.js +138 -0
  35. package/src/web/git-api.js +188 -0
  36. package/src/web/log-ws.js +71 -0
  37. package/src/web/matter-api.js +102 -0
  38. package/src/web/mqtt-api.js +65 -0
  39. package/src/web/scripts-api.js +192 -0
  40. package/src/web/server.js +130 -0
  41. package/src/web/shedb-api.js +140 -0
  42. package/src/web/shedb.js +168 -0
  43. package/index.js +0 -0
package/src/index.js ADDED
@@ -0,0 +1,1184 @@
1
+ #!/usr/bin/env node
2
+ /* eslint-disable func-names */
3
+ /* eslint-disable func-name-matching */
4
+ /* eslint-disable camelcase */
5
+
6
+ /* eslint prefer-rest-params: "warn" */
7
+ /* eslint prefer-destructuring: "warn" */
8
+
9
+ /* eslint n/no-deprecated-api: "warn" */
10
+
11
+ // Ensure ~/.she/ exists before anything else runs
12
+ require('./lib/storage').ensureRoot();
13
+
14
+ const PinoPretty = require('pino-pretty');
15
+ const _pino = require('pino')(
16
+ { level: 'debug' },
17
+ PinoPretty({
18
+ colorize: false,
19
+ translateTime: 'SYS:yyyy-mm-dd HH:MM:ss.l',
20
+ ignore: 'pid,hostname',
21
+ sync: true,
22
+ }),
23
+ );
24
+ // Lazy import — log-ws exports a no-op broadcastLog when the HTTP server is not started.
25
+ const { broadcastLog, broadcast } = require('./web/log-ws');
26
+ const shedb = require('./web/shedb');
27
+ const log = {
28
+ debug: (...args) => {
29
+ _pino.debug(args.join(' '));
30
+ broadcastLog({ level: 'debug', msg: args.join(' '), ts: Date.now() });
31
+ },
32
+ info: (...args) => {
33
+ _pino.info(args.join(' '));
34
+ broadcastLog({ level: 'info', msg: args.join(' '), ts: Date.now() });
35
+ },
36
+ warn: (...args) => {
37
+ _pino.warn(args.join(' '));
38
+ broadcastLog({ level: 'warn', msg: args.join(' '), ts: Date.now() });
39
+ },
40
+ error: (...args) => {
41
+ _pino.error(args.join(' '));
42
+ broadcastLog({ level: 'error', msg: args.join(' '), ts: Date.now() });
43
+ },
44
+ setLevel: (level) => {
45
+ _pino.level = level;
46
+ },
47
+ };
48
+ const config = require('./config.js');
49
+ const pkg = require('../package.json');
50
+
51
+ log.setLevel(['debug', 'info', 'warn', 'error'].indexOf(config.verbosity) === -1 ? 'info' : config.verbosity);
52
+ log.info('she ' + pkg.version + ' starting');
53
+ log.debug('loaded config: ', config);
54
+
55
+ if (typeof config.port !== 'undefined') {
56
+ require('./web/server')
57
+ .startServer(config.port, {
58
+ apiKey: config.apiKey,
59
+ configPath: config.config,
60
+ scriptDir: config.dir || null,
61
+ })
62
+ .then((actualPort) => log.info('http server listening on :' + actualPort))
63
+ .catch((err) => {
64
+ log.error('http server start failed:', err.message);
65
+ process.exit(1);
66
+ });
67
+ }
68
+
69
+ const chokidar = require('chokidar');
70
+ const modules = {
71
+ 'fs': require('fs'),
72
+ 'path': require('path'),
73
+ 'vm': require('vm'),
74
+ /* eslint-disable no-restricted-modules */
75
+ 'domain': require('domain'),
76
+ 'mqtt': require('mqtt'),
77
+ 'node-schedule': require('node-schedule'),
78
+ 'suncalc': require('suncalc'),
79
+ };
80
+
81
+ // Require function anchored at ~/.she/ so user-installed npm packages
82
+ // in ~/.she/node_modules/ are resolved by sandbox scripts.
83
+ const Module = require('module');
84
+ const { STORAGE_ROOT } = require('./lib/storage');
85
+ const _userRequire = Module.createRequire(modules.path.join(STORAGE_ROOT, '_anchor.js'));
86
+
87
+ const domain = modules.domain;
88
+ const vm = modules.vm;
89
+ const fs = modules.fs;
90
+ const path = modules.path;
91
+ const scheduler = modules['node-schedule'];
92
+ const suncalc = modules.suncalc;
93
+
94
+ const StateStore = require('./lib/state-store');
95
+ const sandboxModules = [];
96
+ const store = new StateStore();
97
+ const scripts = {};
98
+ const scriptOrigins = new Map(); // file → 'builtin' | 'user'
99
+ const subscriptions = [];
100
+ const mqttEventCallbacks = [];
101
+ const varSubscriptions = []; // store-based var:: subscriptions { key, handler, _script }
102
+
103
+ // Per-script resource tracking for hot-reload
104
+ const scriptJobs = new Map(); // scriptFile → node-schedule Job[]
105
+ const scriptTimers = new Map(); // scriptFile → Set<timer id>
106
+
107
+ const _global = {};
108
+
109
+ // Sun scheduling
110
+
111
+ const SUNCALC_EVENTS = new Set([
112
+ 'sunrise',
113
+ 'sunriseEnd',
114
+ 'goldenHourEnd',
115
+ 'solarNoon',
116
+ 'goldenHour',
117
+ 'sunsetStart',
118
+ 'sunset',
119
+ 'dusk',
120
+ 'nauticalDusk',
121
+ 'night',
122
+ 'nadir',
123
+ 'nightEnd',
124
+ 'nauticalDawn',
125
+ 'dawn',
126
+ ]);
127
+
128
+ const sunEvents = [];
129
+ let sunTimes = [{}, /* today */ {}, /* tomorrow */ {}];
130
+
131
+ function calculateSunTimes() {
132
+ const now = new Date();
133
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 12, 0, 0, 0, 0);
134
+ const yesterday = new Date(today.getTime() - 86400000); // (24 * 60 * 60 * 1000));
135
+ const tomorrow = new Date(today.getTime() + 86400000); // (24 * 60 * 60 * 1000));
136
+ sunTimes = [
137
+ suncalc.getTimes(yesterday, config.latitude, config.longitude),
138
+ suncalc.getTimes(today, config.latitude, config.longitude),
139
+ suncalc.getTimes(tomorrow, config.latitude, config.longitude),
140
+ ];
141
+ }
142
+
143
+ calculateSunTimes();
144
+
145
+ scheduler.scheduleJob('0 0 * * *', () => {
146
+ // Re-calculate every day
147
+ calculateSunTimes();
148
+ // Schedule events for this day
149
+ sunEvents.forEach((event) => {
150
+ sunScheduleEvent(event);
151
+ });
152
+ log.info('re-scheduled', sunEvents.length, 'sun events');
153
+ });
154
+
155
+ function sunScheduleEvent(obj, shift) {
156
+ // Shift = -1 -> yesterday
157
+ // shift = 0 -> today
158
+ // shift = 1 -> tomorrow
159
+ let event = sunTimes[1 + (shift || 0)][obj.pattern];
160
+ const now = new Date();
161
+
162
+ if (event.toString() !== 'Invalid Date') {
163
+ // Event will occur today
164
+
165
+ if (obj.options.shift) {
166
+ event = new Date(event.getTime() + (parseFloat(obj.options.shift) || 0) * 1000);
167
+ }
168
+
169
+ if (event.getDate() !== now.getDate() && typeof shift === 'undefined') {
170
+ // Event shifted to previous or next day
171
+ sunScheduleEvent(obj, event < now ? 1 : -1);
172
+ return;
173
+ }
174
+
175
+ if (now.getTime() - event.getTime() < 1000) {
176
+ // Event is less than 1s in the past or occurs later this day
177
+
178
+ if (obj.options.random) {
179
+ event = new Date(event.getTime() + Math.floor((parseFloat(obj.options.random) || 0) * Math.random()) * 1000);
180
+ }
181
+
182
+ if (event.getTime() - now.getTime() < 1000) {
183
+ // Event is less than 1s in the future or already in the past
184
+ // (options.random may have shifted us further to the past)
185
+ // call the callback immediately!
186
+ obj.domain.bind(obj.callback)();
187
+ } else {
188
+ // Schedule the event!
189
+ scheduler.scheduleJob(event, obj.domain.bind(obj.callback));
190
+ }
191
+ }
192
+ }
193
+ }
194
+
195
+ // MQTT — only connect when a broker URL is configured
196
+ let mqtt = null;
197
+ let connected = false;
198
+
199
+ // Wire up the MQTT API: pass the state store and a getter for the live MQTT client.
200
+ // The getter always returns the current value of `mqtt` (null until connected).
201
+ require('./web/mqtt-api').init(store, () => mqtt);
202
+ require('./web/ai-api').init(store);
203
+
204
+ if (!config.url) {
205
+ log.warn('no MQTT broker URL configured — set "url" in ' + path.join(require('os').homedir(), '.she', 'config.json'));
206
+ }
207
+
208
+ if (config.url) {
209
+ mqtt = modules.mqtt.connect(config.url, { will: { topic: config.name + '/connected', payload: '0', retain: true } });
210
+ mqtt.publish(config.name + '/connected', '2', { retain: true });
211
+
212
+ mqtt.on('connect', () => {
213
+ connected = true;
214
+ log.info('mqtt connected ' + config.url);
215
+ log.debug('mqtt subscribe #');
216
+ mqtt.subscribe('#');
217
+ mqttEventCallbacks.filter((c) => c.event === 'connect').forEach((c) => c.callback());
218
+ });
219
+
220
+ mqtt.on('close', () => {
221
+ if (connected) {
222
+ connected = false;
223
+ log.info('mqtt closed ' + config.url);
224
+ mqttEventCallbacks.filter((c) => c.event === 'disconnect').forEach((c) => c.callback());
225
+ }
226
+ });
227
+
228
+ mqtt.on('error', () => {
229
+ log.error('mqtt error ' + config.url);
230
+ });
231
+
232
+ mqtt.on('message', (topic, payload, msg) => {
233
+ if (shedb.handleMqttMessage(topic, payload)) return;
234
+
235
+ const state = require('./lib/parse-payload')(payload);
236
+
237
+ const topicArr = topic.split('/');
238
+ let oldState;
239
+
240
+ if (topicArr[0] === config.variablePrefix && topicArr[1] === 'set' && !config.disableVariables) {
241
+ topicArr[1] = 'status';
242
+ topic = topicArr.join('/');
243
+ const varName = topicArr.slice(2).join('/');
244
+ oldState = store.getObject('mqtt::' + topic) || {};
245
+ const ts = new Date().getTime();
246
+
247
+ state.ts = ts;
248
+
249
+ state.lc = state.val === oldState.val ? oldState.lc : ts;
250
+ store.setObject('mqtt::' + topic, state);
251
+ store.setObject('var::' + varName, state);
252
+ mqtt.publish(topic, JSON.stringify(state), { retain: true });
253
+ } else {
254
+ if (!state) {
255
+ log.error('invalid state', topic, payload);
256
+ process.exit();
257
+ }
258
+ if (!state.ts) {
259
+ state.ts = new Date().getTime();
260
+ }
261
+ oldState = store.getObject('mqtt::' + topic) || {};
262
+ if (oldState.val !== state.val) {
263
+ state.lc = state.ts;
264
+ }
265
+ store.setObject('mqtt::' + topic, state);
266
+ stateChange(topic, state, oldState, msg);
267
+ }
268
+ });
269
+ }
270
+
271
+ // sheDB — only init when --db-path is given
272
+ if (config.dbPath) {
273
+ shedb.init({ dbPath: config.dbPath, dbPublish: config.dbPublish || false, dbRetain: config.dbRetain || false, dbPrefix: config.dbPrefix || 'she/db/', mqttName: config.name, mqtt, log, broadcast });
274
+ }
275
+
276
+ // Redis write-through cache — only init when config.redis.url is given
277
+ if (config.redis && config.redis.url) {
278
+ require('./lib/redis')
279
+ .init({ url: config.redis.url, store, log })
280
+ .catch((err) => log.error('redis init failed:', err.message));
281
+ }
282
+
283
+ // InfluxDB — only init when --influx is set
284
+ if (config.influx) {
285
+ require('./influx').init(config.influx);
286
+ }
287
+
288
+ // Elasticsearch — only init when --elastic is set
289
+ if (config.elastic) {
290
+ require('./elastic').init(config.elastic);
291
+ }
292
+
293
+ // Matter controller — only init when --matter-storage is set
294
+ if (config.matterStorage) {
295
+ const { ensureStorageDir } = require('./lib/storage');
296
+ const matterController = require('./matter/controller');
297
+ const matterStoragePath = typeof config.matterStorage === 'string' ? config.matterStorage : ensureStorageDir('matter');
298
+ matterController.init(matterStoragePath, log, broadcast).catch((err) => {
299
+ log.error('matter controller init failed:', err.message);
300
+ });
301
+ }
302
+
303
+ // Start scripts immediately — MQTT retained state will populate the store asynchronously
304
+ start();
305
+
306
+ function stateChange(topic, state, oldState, msg) {
307
+ subscriptions.forEach((subs) => {
308
+ const options = subs.options || {};
309
+ let delay;
310
+
311
+ const match = mqttWildcards(topic, subs.topic);
312
+
313
+ if (match && typeof options.condition === 'function') {
314
+ if (!options.condition(topic.replace(/^([^/]+)\/status\/(.+)/, '$1//$2'), state.val, state, oldState, msg)) {
315
+ return;
316
+ }
317
+ }
318
+
319
+ if (match && typeof subs.callback === 'function') {
320
+ if (msg.retain && !options.retain) {
321
+ return;
322
+ }
323
+ if (options.change && state.val === oldState.val) {
324
+ return;
325
+ }
326
+
327
+ delay = 0;
328
+ if (options.shift) {
329
+ delay += (parseFloat(options.shift) || 0) * 1000;
330
+ }
331
+ if (options.random) {
332
+ delay += (parseFloat(options.random) || 0) * Math.random() * 1000;
333
+ }
334
+
335
+ delay = Math.floor(delay);
336
+
337
+ setTimeout(() => {
338
+ /**
339
+ * @callback subscribeCallback
340
+ * @param {string} topic - the topic that triggered this callback. +/status/# will be replaced by +//#
341
+ * @param {mixed} val - the val property of the new state
342
+ * @param {object} obj - new state - the whole state object (e.g. {"val": true, "ts": 12346345, "lc": 12346345} )
343
+ * @param {object} objPrev - previous state - the whole state object
344
+ * @param {object} msg - the mqtt message as received from MQTT.js
345
+ */
346
+ subs.callback(topic.replace(/^([^/]+)\/status\/(.+)/, '$1//$2'), state.val, state, oldState, msg);
347
+ }, delay);
348
+ }
349
+ });
350
+ }
351
+
352
+ const mqttWildcards = require('./lib/mqtt-wildcards');
353
+
354
+ /**
355
+ * Write a variable to the var:: store namespace, sync mqtt:: for backwards
356
+ * compat, fire mqttsub callbacks, and (mqtt backend) publish retained.
357
+ * @param {string} name - bare variable name (e.g. 'testvar1')
358
+ * @param {*} val
359
+ */
360
+ function setVariable(name, val) {
361
+ const storeKey = 'var::' + name;
362
+ const mqttTopic = config.variablePrefix + '/status/' + name;
363
+ const oldState = store.getObject(storeKey) || {};
364
+ const ts = new Date().getTime();
365
+
366
+ let newState;
367
+ if (typeof val === 'object' && val !== null && 'val' in val) {
368
+ newState = { val: val.val, ts: val.ts || ts };
369
+ newState.lc = newState.val !== oldState.val ? newState.ts : oldState.lc || newState.ts;
370
+ } else {
371
+ newState = { val, ts };
372
+ newState.lc = val !== oldState.val ? ts : oldState.lc || ts;
373
+ }
374
+
375
+ const changed = newState.val !== oldState.val;
376
+ store.setObject(storeKey, newState); // primary: fires 'change' → she.on() callbacks
377
+ store.setObject('mqtt::' + mqttTopic, newState); // compat: so mqttsub('var//name') still works
378
+ stateChange(mqttTopic, newState, oldState, {}); // fires mqttsub callbacks
379
+
380
+ const backend = (config.variables && config.variables.backend) || 'mqtt';
381
+ if (backend === 'mqtt' && mqtt && connected && changed) {
382
+ mqtt.publish(mqttTopic, JSON.stringify(newState), { retain: true });
383
+ }
384
+ }
385
+
386
+ function createScript(source, name) {
387
+ log.debug(name, 'compiling');
388
+ try {
389
+ return new vm.Script(source, { filename: name });
390
+ } catch (err) {
391
+ log.error(name, err.name + ':', err.message);
392
+ return false;
393
+ }
394
+ }
395
+
396
+ function runScript(script, name, origin) {
397
+ const scriptDir = path.dirname(path.resolve(name));
398
+ const logLabel = (origin || 'user') + '::' + path.basename(name) + ':';
399
+
400
+ // Initialise per-script resource tracking
401
+ if (!scriptJobs.has(name)) scriptJobs.set(name, []);
402
+ if (!scriptTimers.has(name)) scriptTimers.set(name, new Set());
403
+ const _myJobs = scriptJobs.get(name);
404
+ const _myTimers = scriptTimers.get(name);
405
+
406
+ log.debug(logLabel, 'creating domain');
407
+ const scriptDomain = domain.create();
408
+
409
+ log.debug(logLabel, 'creating sandbox');
410
+
411
+ const she = {
412
+ global: _global,
413
+
414
+ /**
415
+ * Log a debug message
416
+ * @method debug
417
+ * @param {...*}
418
+ */
419
+ debug() {
420
+ const args = Array.prototype.slice.call(arguments);
421
+ args.unshift(logLabel);
422
+ log.debug.apply(log, args);
423
+ },
424
+ /**
425
+ * Log an info message
426
+ * @method info
427
+ * @param {...*}
428
+ */
429
+ info() {
430
+ const args = Array.prototype.slice.call(arguments);
431
+ args.unshift(logLabel);
432
+ log.info.apply(log, args);
433
+ },
434
+ /**
435
+ * Log a warning message
436
+ * @method warn
437
+ * @param {...*}
438
+ */
439
+ warn() {
440
+ const args = Array.prototype.slice.call(arguments);
441
+ args.unshift(logLabel);
442
+ log.warn.apply(log, args);
443
+ },
444
+ /**
445
+ * Log an error message
446
+ * @method error
447
+ * @param {...*}
448
+ */
449
+ error() {
450
+ const args = Array.prototype.slice.call(arguments);
451
+ args.unshift(logLabel);
452
+ log.error.apply(log, args);
453
+ },
454
+
455
+ /**
456
+ * Subscribe to MQTT topic(s)
457
+ * @method mqttsub
458
+ * @param {(string|string[])} topic - topic or array of topics to subscribe
459
+ * @param {Object|string|function} [options] - Options object or as shorthand to options.condition a function or string
460
+ * @param {number} [options.shift] - delay execution in seconds. Has to be positive
461
+ * @param {number} [options.random] - random delay execution in seconds. Has to be positive
462
+ * @param {boolean} [options.change] - if set to true callback is only called if val changed
463
+ * @param {boolean} [options.retain] - if set to true callback is also called on retained messages
464
+ * @param {(string|function)} [options.condition] - conditional function or condition string
465
+ * @param {subscribeCallback} callback
466
+ */
467
+ mqttsub: function Sandbox_mqttsub(topic, /* optional */ options, callback) {
468
+ if (typeof topic === 'undefined') {
469
+ throw new TypeError('argument topic missing');
470
+ }
471
+
472
+ if (arguments.length === 2) {
473
+ if (typeof arguments[1] !== 'function') {
474
+ throw new TypeError('callback is not a function');
475
+ }
476
+
477
+ callback = arguments[1];
478
+ options = {};
479
+ } else if (arguments.length === 3) {
480
+ if (typeof arguments[2] !== 'function') {
481
+ throw new TypeError('callback is not a function');
482
+ }
483
+ options = arguments[1] || {};
484
+
485
+ if (typeof options === 'string' || typeof options === 'function') {
486
+ options = { condition: options };
487
+ }
488
+
489
+ callback = arguments[2];
490
+ } else if (arguments.length > 3) {
491
+ throw new Error('wrong number of arguments');
492
+ }
493
+
494
+ if (typeof topic === 'string') {
495
+ topic = topic.replace(/^([^/]+)\/\//, '$1/status/');
496
+
497
+ if (typeof options.condition === 'string') {
498
+ if (options.condition.indexOf('\n') !== -1) {
499
+ throw new Error('options.condition string must be one-line javascript');
500
+ }
501
+ /* eslint-disable no-new-func */
502
+ options.condition = new Function('topic', 'val', 'obj', 'objPrev', 'msg', 'return ' + options.condition + ';');
503
+ }
504
+
505
+ if (typeof options.condition === 'function') {
506
+ options.condition = scriptDomain.bind(options.condition);
507
+ }
508
+
509
+ subscriptions.push({ topic, options, callback: typeof callback === 'function' && scriptDomain.bind(callback), _script: name });
510
+
511
+ if (options.retain && store.has('mqtt::' + topic) && typeof callback === 'function') {
512
+ callback(topic.replace(/^([^/]+)\/status\/(.+)/, '$1//$2'), store.get('mqtt::' + topic), store.getObject('mqtt::' + topic));
513
+ } else if (options.retain && (/\/\+\//.test(topic) || /\+$/.test(topic) || /\+/.test(topic) || topic.endsWith('#')) && typeof callback === 'function') {
514
+ for (const [t, obj] of store.mqttEntries()) {
515
+ if (mqttWildcards(t, topic)) {
516
+ callback(t.replace(/^([^/]+)\/status\/(.+)/, '$1//$2'), obj.val, obj);
517
+ }
518
+ }
519
+ }
520
+ } else if (typeof topic === 'object' && topic.length > 0) {
521
+ topic = Array.prototype.slice.call(topic);
522
+ topic.forEach((tp) => {
523
+ she.mqttsub(tp, options, callback);
524
+ });
525
+ }
526
+ },
527
+
528
+ /**
529
+ * Schedule recurring and one-shot callbacks, including solar events.
530
+ * Pass a suncalc event name (e.g. 'sunrise', 'sunset') as pattern to schedule
531
+ * relative to a solar event; cron strings, Date objects, and node-schedule
532
+ * literals are also accepted.
533
+ * @method schedule
534
+ * @param {string|Date|Object|Array} pattern - Cron string, suncalc event name, Date, node-schedule literal, or an array of any mix.
535
+ * @param {Object} [options]
536
+ * @param {number} [options.random] - random delay in seconds
537
+ * @param {number} [options.shift] - offset in seconds for solar events (-86400…86400)
538
+ * @param {function} callback - is called with no arguments
539
+ */
540
+ schedule: function Sandbox_schedule(pattern, /* optional */ options, callback) {
541
+ if (arguments.length === 2) {
542
+ if (typeof arguments[1] !== 'function') {
543
+ throw new TypeError('callback is not a function');
544
+ }
545
+ callback = arguments[1];
546
+ options = {};
547
+ } else if (arguments.length === 3) {
548
+ if (typeof arguments[2] !== 'function') {
549
+ throw new TypeError('callback is not a function');
550
+ }
551
+ options = arguments[1] || {};
552
+ callback = arguments[2];
553
+ } else {
554
+ throw new Error('wrong number of arguments');
555
+ }
556
+
557
+ if (typeof pattern === 'object' && pattern.length > 0) {
558
+ pattern = Array.prototype.slice.call(pattern);
559
+ pattern.forEach((pt) => {
560
+ she.schedule(pt, options, callback);
561
+ });
562
+ return;
563
+ }
564
+
565
+ // A string with no spaces is treated as a suncalc event name.
566
+ if (typeof pattern === 'string' && !pattern.includes(' ')) {
567
+ if (!SUNCALC_EVENTS.has(pattern)) {
568
+ throw new TypeError('unknown suncalc event ' + pattern);
569
+ }
570
+ if (typeof options.shift !== 'undefined' && (options.shift < -86400 || options.shift > 86400)) {
571
+ throw new Error('options.shift out of range');
572
+ }
573
+ const obj = {
574
+ pattern,
575
+ options,
576
+ callback,
577
+ context: she,
578
+ domain: scriptDomain,
579
+ _script: name,
580
+ };
581
+ sunEvents.push(obj);
582
+ sunScheduleEvent(obj);
583
+ return;
584
+ }
585
+
586
+ if (options.random) {
587
+ _myJobs.push(
588
+ scheduler.scheduleJob(pattern, () => {
589
+ setTimeout(scriptDomain.bind(callback), (parseFloat(options.random) || 0) * 1000 * Math.random());
590
+ }),
591
+ );
592
+ } else {
593
+ _myJobs.push(scheduler.scheduleJob(pattern, scriptDomain.bind(callback)));
594
+ }
595
+ },
596
+
597
+ /**
598
+ * Publish a MQTT message
599
+ * @method mqttpub
600
+ * @param {(string|string[])} topic - topic or array of topics to publish to
601
+ * @param {(string|Object)} payload - the payload string. If an object is given it will be JSON.stringified
602
+ * @param {Object} [options] - the options to publish with
603
+ * @param {number} [options.qos=0] - QoS Level
604
+ * @param {boolean} [options.retain=false] - retain flag
605
+ */
606
+ mqttpub: function Sandbox_mqttpub(topic, payload, options) {
607
+ if (typeof topic === 'object' && topic.length > 0) {
608
+ topic = Array.prototype.slice.call(topic);
609
+ topic.forEach((tp) => {
610
+ she.mqttpub(tp, payload, options);
611
+ });
612
+ return;
613
+ }
614
+
615
+ topic = topic.replace(/^([^/]+)\/\/(.+)$/, '$1/set/$2');
616
+
617
+ if (typeof payload === 'object') {
618
+ payload = JSON.stringify(payload);
619
+ } else {
620
+ payload = String(payload);
621
+ }
622
+ if (!mqtt || !connected) return; // silently drop when MQTT is not connected
623
+ mqtt.publish(topic, payload, options);
624
+ },
625
+
626
+ /**
627
+ * Set a value on one or more topics
628
+ * @method setValue
629
+ * @param {(string|string[])} topic - topic or array of topics to set value on
630
+ * @param {mixed} val
631
+ */
632
+ setValue: function Sandbox_setValue(topic, val) {
633
+ if (typeof topic === 'object' && topic.length > 0) {
634
+ topic = Array.prototype.slice.call(topic);
635
+ topic.forEach((tp) => {
636
+ she.setValue(tp, val);
637
+ });
638
+ return;
639
+ }
640
+
641
+ const tmp = topic.split('/');
642
+ if (tmp[0] === config.variablePrefix && !config.disableVariables) {
643
+ // Variable — delegate to setVariable (handles var:: store + MQTT publish)
644
+ const varName = tmp.slice(2).join('/');
645
+ setVariable(varName, val);
646
+ } else if (tmp[0] === config.variablePrefix && config.disableVariables) {
647
+ tmp[1] = 'status';
648
+ topic = tmp.join('/');
649
+ if (!store.has('mqtt::' + topic) || store.get('mqtt::' + topic) !== val) {
650
+ tmp[1] = 'set';
651
+ topic = tmp.join('/');
652
+ she.mqttpub(topic, val, { retain: false });
653
+ }
654
+ } else {
655
+ topic = topic.replace(/^([^/]+)\/\/(.+)$/, '$1/set/$2');
656
+ she.mqttpub(topic, val, { retain: false });
657
+ }
658
+ },
659
+
660
+ /**
661
+ * @method getValue
662
+ * @param {string} topic
663
+ * @returns {mixed} the topics value
664
+ */
665
+ getValue: function Sandbox_getValue(topic) {
666
+ topic = topic.replace(/^([^/]+)\/\/(.+)$/, '$1/status/$2');
667
+ return store.get('mqtt::' + topic);
668
+ },
669
+
670
+ /**
671
+ * Get a specific property of a topic
672
+ * @method getProp
673
+ * @param {string} topic
674
+ * @param {...string} [property] - the property to retrieve. May be repeated for nested properties. If omitted the whole topic object is returned.
675
+ * @returns {mixed} the topics properties value
676
+ * @example // returns the timestamp of a given topic
677
+ * she.getProp('hm//Bewegungsmelder Keller/MOTION', 'ts');
678
+ */
679
+ getProp: function Sandbox_getProp(topic /* , optional property, optional nested property, ... */) {
680
+ topic = topic.replace(/^([^/]+)\/\/(.+)$/, '$1/status/$2');
681
+ if (arguments.length > 1) {
682
+ let tmp = store.getObject('mqtt::' + topic);
683
+ if (typeof tmp === 'undefined') {
684
+ return;
685
+ }
686
+ for (let i = 1; i < arguments.length; i++) {
687
+ if (typeof tmp[arguments[i]] === 'undefined') {
688
+ return;
689
+ }
690
+ tmp = tmp[arguments[i]];
691
+ }
692
+ return tmp;
693
+ }
694
+ return store.getObject('mqtt::' + topic);
695
+ },
696
+
697
+ /**
698
+ * Universal read by namespaced key.
699
+ * @method get
700
+ * @param {string} key Namespaced key, e.g. 'mqtt::home/sensor/temp' or 'var::myVar'
701
+ * @returns {*} current value, or undefined
702
+ */
703
+ get: function Sandbox_she_get(key) {
704
+ if (key.startsWith('var::')) {
705
+ return store.get('var::' + key.slice(5));
706
+ }
707
+ return store.get(key);
708
+ },
709
+
710
+ /**
711
+ * Universal read (full state object) by namespaced key.
712
+ * @method getObject
713
+ * @param {string} key
714
+ * @returns {{ val:*, ts:number, lc:number } | undefined}
715
+ */
716
+ getObject: function Sandbox_she_getObject(key) {
717
+ if (key.startsWith('var::')) {
718
+ return store.getObject('var::' + key.slice(5));
719
+ }
720
+ return store.getObject(key);
721
+ },
722
+
723
+ /**
724
+ * Universal subscribe by namespaced key.
725
+ * Callback receives (val, obj, prevObj).
726
+ * @method on
727
+ * @param {string} key Namespaced key: 'mqtt::topic', 'var::name', 'matter::nodeId/ep/Cluster/attr'
728
+ * @param {function} callback
729
+ */
730
+ on: function Sandbox_she_on(key, callback) {
731
+ if (typeof key !== 'string') throw new TypeError('she.on: key must be a string');
732
+ if (typeof callback !== 'function') throw new TypeError('she.on: callback must be a function');
733
+
734
+ if (key.startsWith('mqtt::')) {
735
+ she.mqttsub(key.slice(6), { retain: true }, (_topic, val, obj, prevObj) => {
736
+ callback(val, obj, prevObj);
737
+ });
738
+ } else if (key.startsWith('var::')) {
739
+ const varStoreKey = 'var::' + key.slice(5);
740
+ const boundCb = scriptDomain.bind(callback);
741
+ const varHandler = (changedKey, val, obj, prevObj) => {
742
+ if (changedKey === varStoreKey) boundCb(val, obj, prevObj);
743
+ };
744
+ store.on('change', varHandler);
745
+ varSubscriptions.push({ key: varStoreKey, handler: varHandler, _script: name });
746
+ // Fire immediately if var already has a value (retain semantics)
747
+ const currentVarObj = store.getObject(varStoreKey);
748
+ if (currentVarObj !== undefined) boundCb(currentVarObj.val, currentVarObj, undefined);
749
+ } else if (key.startsWith('matter::')) {
750
+ const parts = key.slice(8).split('/');
751
+ if (parts.length !== 4) throw new TypeError('she.on: invalid matter key (expected matter::nodeId/ep/Cluster/attr)');
752
+ const [nodeId, endpointId, clusterName, attrName] = parts;
753
+ if (!she.matter) throw new Error('she.on: Matter not configured');
754
+ she.matter.sub(Number(nodeId), Number(endpointId), clusterName, attrName, callback);
755
+ } else {
756
+ throw new TypeError('she.on: unknown namespace in key: ' + key);
757
+ }
758
+ },
759
+
760
+ /**
761
+ * Universal write by namespaced key.
762
+ * @method set
763
+ * @param {string} key Namespaced key: 'mqtt::topic', 'var::name'
764
+ * @param {*} val
765
+ */
766
+ set: function Sandbox_she_set(key, val) {
767
+ if (typeof key !== 'string') throw new TypeError('she.set: key must be a string');
768
+
769
+ if (key.startsWith('mqtt::')) {
770
+ she.mqttpub(key.slice(6), val);
771
+ } else if (key.startsWith('var::')) {
772
+ setVariable(key.slice(5), val);
773
+ } else if (key.startsWith('matter::')) {
774
+ throw new Error('she.set: matter:: write not yet implemented');
775
+ } else {
776
+ throw new TypeError('she.set: unknown namespace in key: ' + key);
777
+ }
778
+ },
779
+
780
+ /** @internal Register a callback for MQTT connection lifecycle events. */
781
+ _registerMqttEvent: function Sandbox_she_registerMqttEvent(event, callback) {
782
+ if (event !== 'connect' && event !== 'disconnect') {
783
+ throw new TypeError('she.mqtt.on: unknown event "' + event + '" — use "connect" or "disconnect"');
784
+ }
785
+ mqttEventCallbacks.push({ event, callback: scriptDomain.bind(callback), _script: name });
786
+ },
787
+ };
788
+
789
+ // she.log is an alias for she.info
790
+ she.log = she.info;
791
+
792
+ const Sandbox = {
793
+ setTimeout: (fn, delay, ...args) => {
794
+ const id = setTimeout(fn, delay, ...args);
795
+ _myTimers.add(id);
796
+ return id;
797
+ },
798
+ setInterval: (fn, delay, ...args) => {
799
+ const id = setInterval(fn, delay, ...args);
800
+ _myTimers.add(id);
801
+ return id;
802
+ },
803
+ clearTimeout: (id) => {
804
+ _myTimers.delete(id);
805
+ clearTimeout(id);
806
+ },
807
+ clearInterval: (id) => {
808
+ _myTimers.delete(id);
809
+ clearInterval(id);
810
+ },
811
+
812
+ Buffer,
813
+
814
+ require(md) {
815
+ if (modules[md]) {
816
+ return modules[md];
817
+ }
818
+ try {
819
+ let result;
820
+ if (md.match(/^\.\//) || md.match(/^\.\.\//)) {
821
+ // Relative import — resolve from the script's own directory
822
+ const tmp = './' + path.relative(__dirname, path.join(scriptDir, md));
823
+ she.debug('require', tmp);
824
+ result = require(tmp);
825
+ } else {
826
+ // Absolute import — try ~/.she/node_modules/ first (user-installed),
827
+ // then fall back to engine's own require (builtins + engine deps).
828
+ try {
829
+ result = _userRequire(md);
830
+ she.debug('require (user)', md);
831
+ } catch {
832
+ const localMod = path.join(scriptDir, 'node_modules', md, 'package.json');
833
+ if (fs.existsSync(localMod)) {
834
+ result = require(path.join(scriptDir, 'node_modules', md));
835
+ she.debug('require (local)', md);
836
+ } else {
837
+ result = require(md);
838
+ she.debug('require', md);
839
+ }
840
+ }
841
+ }
842
+ modules[md] = result;
843
+ return result;
844
+ } catch (err) {
845
+ const lines = err.stack.split('\n');
846
+ const stack = [];
847
+ lines.forEach((line) => {
848
+ if (!line.match(/module\.js:/)) {
849
+ stack.push(line);
850
+ }
851
+ });
852
+ log.error(name + ': ' + stack);
853
+ }
854
+ },
855
+
856
+ console: {
857
+ log: (...args) => she.info(...args),
858
+ error: (...args) => she.error(...args),
859
+ },
860
+
861
+ she,
862
+ };
863
+
864
+ const scriptName = path.basename(name, path.extname(name));
865
+ sandboxModules.forEach((md) => {
866
+ md(she, { scriptDomain, scriptName });
867
+ });
868
+
869
+ log.debug(name, 'contextifying sandbox');
870
+ const context = vm.createContext(Sandbox);
871
+
872
+ scriptDomain.on('error', (e) => {
873
+ if (!e.stack) {
874
+ log.error(logLabel, 'unknown exception');
875
+ return;
876
+ }
877
+ const lines = e.stack.split('\n');
878
+ const stack = [];
879
+ for (let i = 0; i < lines.length; i++) {
880
+ if (lines[i].match(/at ContextifyScript\.Script\.runInContext|at Script\.runInContext/)) {
881
+ break;
882
+ }
883
+ stack.push(lines[i]);
884
+ }
885
+
886
+ log.error(logLabel + ' ' + e.name + ': ' + e.message + '\n' + stack.join('\n'));
887
+ });
888
+
889
+ scriptDomain.run(() => {
890
+ log.debug(logLabel, 'running');
891
+ script.runInContext(context);
892
+ });
893
+ }
894
+
895
+ function loadScript(file, origin) {
896
+ origin = origin || 'user';
897
+ file = file.replace(/\\/g, '/');
898
+ const loadLabel = (origin || 'user') + '::' + path.basename(file) + ':';
899
+ if (scripts[file]) {
900
+ log.error(loadLabel, 'already loaded?!');
901
+ return;
902
+ }
903
+
904
+ log.info(loadLabel, 'loading');
905
+ fs.readFile(file, (err, src) => {
906
+ if (err && err.code === 'ENOENT') {
907
+ log.error(loadLabel, 'not found');
908
+ } else if (err) {
909
+ log.error(loadLabel, err);
910
+ } else {
911
+ if (file.match(/\.js$/)) {
912
+ // Javascript
913
+ scripts[file] = createScript(src, file);
914
+ }
915
+ if (scripts[file]) {
916
+ scriptOrigins.set(file, origin);
917
+ runScript(scripts[file], file, origin);
918
+ }
919
+ }
920
+ });
921
+ }
922
+
923
+ function unloadScript(file) {
924
+ file = file.replace(/\\/g, '/');
925
+ const origin = scriptOrigins.get(file) || 'user';
926
+ const unloadLabel = (origin || 'user') + '::' + path.basename(file) + ':';
927
+ log.info(unloadLabel, 'unloading');
928
+ scriptOrigins.delete(file);
929
+
930
+ // Remove MQTT subscriptions belonging to this script
931
+ for (let i = subscriptions.length - 1; i >= 0; i--) {
932
+ if (subscriptions[i]._script === file) subscriptions.splice(i, 1);
933
+ }
934
+
935
+ // Remove MQTT event callbacks (connect/disconnect) belonging to this script
936
+ for (let i = mqttEventCallbacks.length - 1; i >= 0; i--) {
937
+ if (mqttEventCallbacks[i]._script === file) mqttEventCallbacks.splice(i, 1);
938
+ }
939
+
940
+ // Cancel all node-schedule jobs for this script
941
+ const jobs = scriptJobs.get(file);
942
+ if (jobs) {
943
+ jobs.forEach((job) => job && job.cancel());
944
+ scriptJobs.delete(file);
945
+ }
946
+
947
+ // Remove sun events belonging to this script
948
+ for (let i = sunEvents.length - 1; i >= 0; i--) {
949
+ if (sunEvents[i]._script === file) sunEvents.splice(i, 1);
950
+ }
951
+
952
+ // Clear all tracked timers for this script
953
+ const timers = scriptTimers.get(file);
954
+ if (timers) {
955
+ timers.forEach((id) => clearTimeout(id));
956
+ scriptTimers.delete(file);
957
+ }
958
+
959
+ // Remove store-based var:: subscriptions belonging to this script
960
+ for (let i = varSubscriptions.length - 1; i >= 0; i--) {
961
+ if (varSubscriptions[i]._script === file) {
962
+ store.removeListener('change', varSubscriptions[i].handler);
963
+ varSubscriptions.splice(i, 1);
964
+ }
965
+ }
966
+
967
+ // Remove shedb listeners belonging to this script
968
+ const shedbSandbox = require('./sandbox/shedb-sandbox');
969
+ shedbSandbox.cleanup(file);
970
+
971
+ // Remove matter subscriptions belonging to this script
972
+ if (config.matterStorage) {
973
+ const matterSandbox = require('./sandbox/matter-sandbox');
974
+ matterSandbox.cleanup(file);
975
+ }
976
+
977
+ // Remove from scripts map so it can be re-loaded
978
+ delete scripts[file];
979
+ }
980
+
981
+ function loadBuiltinsDir(callback) {
982
+ const dir = path.join(__dirname, 'scripts');
983
+ fs.readdir(dir, (err, data) => {
984
+ if (err) {
985
+ if (err.code !== 'ENOENT') {
986
+ log.error('readdir builtin scripts', dir, err);
987
+ }
988
+ callback();
989
+ return;
990
+ }
991
+ data.sort().forEach((file) => {
992
+ if (file.match(/\.js$/)) {
993
+ loadScript(path.join(dir, file), 'builtin');
994
+ }
995
+ });
996
+ callback();
997
+ });
998
+ }
999
+
1000
+ function loadSandbox(callback) {
1001
+ const dir = path.join(__dirname, 'sandbox');
1002
+ fs.readdir(dir, (err, data) => {
1003
+ if (err) {
1004
+ if (err.errno === 34) {
1005
+ log.error('directory ' + path.resolve(dir) + ' not found');
1006
+ } else {
1007
+ log.error('readdir', dir, err);
1008
+ }
1009
+ } else {
1010
+ data.sort().forEach((file) => {
1011
+ if (file.match(/\.js$/)) {
1012
+ sandboxModules.push(require(path.join(dir, file)));
1013
+ }
1014
+ });
1015
+
1016
+ if (!config.disableWatch) {
1017
+ const sandboxWatcher = chokidar.watch(dir, {
1018
+ ignored: (p, stats) => stats?.isFile() && !p.endsWith('.js'),
1019
+ persistent: true,
1020
+ ignoreInitial: true,
1021
+ usePolling: true,
1022
+ });
1023
+ sandboxWatcher.on('ready', () => log.debug('watch', dir, 'initialized'));
1024
+ sandboxWatcher.on('all', (event, filePath) => {
1025
+ sandboxWatcher.close();
1026
+ log.info(filePath, 'sandbox change detected. exiting.');
1027
+ process.exit(0);
1028
+ });
1029
+ }
1030
+
1031
+ callback();
1032
+ }
1033
+ });
1034
+ }
1035
+
1036
+ /**
1037
+ * Returns true if any ancestor directory of absFile (between it and scriptRoot)
1038
+ * contains a .shelib marker file.
1039
+ */
1040
+ function isLibFile(absFile, scriptRoot) {
1041
+ const root = path.resolve(scriptRoot);
1042
+ let dir = path.dirname(path.resolve(absFile));
1043
+ while (dir.length >= root.length && dir.startsWith(root)) {
1044
+ if (dir !== root && fs.existsSync(path.join(dir, '.shelib'))) return true;
1045
+ const parent = path.dirname(dir);
1046
+ if (parent === dir) break;
1047
+ dir = parent;
1048
+ }
1049
+ return false;
1050
+ }
1051
+
1052
+ /**
1053
+ * Recursively load all user .js scripts from a directory tree.
1054
+ * Files inside directories that contain a .shelib marker are skipped.
1055
+ */
1056
+ function loadDirRecursive(dir, scriptRoot) {
1057
+ let entries;
1058
+ try {
1059
+ entries = fs.readdirSync(dir, { withFileTypes: true });
1060
+ } catch (err) {
1061
+ log.error('readdir', dir, err);
1062
+ return;
1063
+ }
1064
+ entries
1065
+ .sort((a, b) => a.name.localeCompare(b.name))
1066
+ .forEach((entry) => {
1067
+ const abs = path.join(dir, entry.name);
1068
+ if (entry.isDirectory()) {
1069
+ loadDirRecursive(abs, scriptRoot);
1070
+ } else if (entry.name.endsWith('.js') && !isLibFile(abs, scriptRoot)) {
1071
+ loadScript(abs.replace(/\\/g, '/'));
1072
+ }
1073
+ });
1074
+ }
1075
+
1076
+ function loadDir(dir) {
1077
+ const scriptRoot = path.resolve(dir);
1078
+ loadDirRecursive(scriptRoot, scriptRoot);
1079
+
1080
+ if (!config.disableWatch) {
1081
+ const dirWatcher = chokidar.watch(dir, {
1082
+ ignored: (p, stats) => {
1083
+ const name = path.basename(p);
1084
+ return stats?.isFile() && !name.endsWith('.js') && name !== '.shelib';
1085
+ },
1086
+ persistent: true,
1087
+ ignoreInitial: true,
1088
+ usePolling: true,
1089
+ });
1090
+ dirWatcher.on('ready', () => log.info('watch', dir, 'initialized'));
1091
+ dirWatcher.on('all', (event, filePath) => {
1092
+ filePath = filePath.replace(/\\/g, '/');
1093
+ const basename = path.basename(filePath);
1094
+
1095
+ // .shelib marker changes — warn only, manual restart required
1096
+ if (basename === '.shelib') {
1097
+ if (event === 'add') {
1098
+ log.warn(filePath, 'library marker added — .js files in this directory will no longer load as scripts after daemon restart');
1099
+ } else if (event === 'unlink') {
1100
+ log.warn(filePath, 'library marker removed — .js files in this directory will load as scripts after daemon restart');
1101
+ }
1102
+ return;
1103
+ }
1104
+
1105
+ // Directory events — handle gracefully (no process.exit)
1106
+ if (event === 'addDir') return;
1107
+
1108
+ if (event === 'unlinkDir') {
1109
+ const absDir = path.resolve(filePath);
1110
+ Object.keys(scripts).forEach((scriptFile) => {
1111
+ const absScript = path.resolve(scriptFile);
1112
+ if (absScript.startsWith(absDir + path.sep) || absScript === absDir) {
1113
+ log.info(scriptFile, 'directory removed. unloading.');
1114
+ unloadScript(scriptFile);
1115
+ }
1116
+ });
1117
+ return;
1118
+ }
1119
+
1120
+ if (event === 'change' && filePath.endsWith('.js')) {
1121
+ if (isLibFile(filePath, dir)) {
1122
+ log.warn(filePath, 'is a library file — scripts that require() it will see the old version until they or the daemon are restarted');
1123
+ return;
1124
+ }
1125
+ log.info(filePath, 'change detected. hot-reloading.');
1126
+ unloadScript(filePath);
1127
+ loadScript(filePath);
1128
+ } else if (event === 'add' && filePath.endsWith('.js')) {
1129
+ if (isLibFile(filePath, dir)) {
1130
+ log.debug(filePath, 'is a library file — not loading as script');
1131
+ return;
1132
+ }
1133
+ log.info(filePath, 'added. loading.');
1134
+ loadScript(filePath);
1135
+ } else if (event === 'unlink' && filePath.endsWith('.js')) {
1136
+ if (scripts[filePath]) {
1137
+ log.info(filePath, 'removed. unloading.');
1138
+ unloadScript(filePath);
1139
+ }
1140
+ }
1141
+ });
1142
+ }
1143
+ }
1144
+
1145
+ function start() {
1146
+ if (config.file) {
1147
+ if (typeof config.file === 'string') {
1148
+ loadScript(config.file);
1149
+ } else {
1150
+ config.file.forEach((file) => {
1151
+ loadScript(file);
1152
+ });
1153
+ }
1154
+ }
1155
+
1156
+ loadSandbox(() => {
1157
+ loadBuiltinsDir(() => {
1158
+ if (config.dir) {
1159
+ if (typeof config.dir === 'string') {
1160
+ loadDir(config.dir);
1161
+ } else {
1162
+ config.dir.forEach((dir) => {
1163
+ loadDir(dir);
1164
+ });
1165
+ }
1166
+ }
1167
+ });
1168
+ });
1169
+ }
1170
+
1171
+ async function gracefulShutdown(signal) {
1172
+ log.info(`got ${signal}. exiting.`);
1173
+ if (config.matterStorage) {
1174
+ try {
1175
+ await require('./matter/controller').close();
1176
+ } catch {
1177
+ // ignore
1178
+ }
1179
+ }
1180
+ process.exit(0);
1181
+ }
1182
+
1183
+ process.on('SIGINT', () => gracefulShutdown('SIGINT'));
1184
+ process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));