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