smart-home-engine 2.0.1 → 3.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js CHANGED
@@ -71,7 +71,7 @@ const _pino = require('pino')(
71
71
  }),
72
72
  );
73
73
  // Lazy import — log-ws exports a no-op broadcastLog when the HTTP server is not started.
74
- const { broadcastLog, broadcast, setWelcomeProvider } = require('./web/log-ws');
74
+ const { broadcastLog, broadcast, setWelcomeProvider, onClientMessage, onClientClose, sendWs } = require('./web/log-ws');
75
75
  const shedb = require('./web/shedb');
76
76
  // One level for every sink (roadmap B-12): a line below it is dropped before any work on it.
77
77
  const log = require('./lib/logger').createLogger({
@@ -237,6 +237,7 @@ if (typeof config.port !== 'undefined') {
237
237
  bindAddress: config.bindAddress,
238
238
  configPath: config.config,
239
239
  scriptDir: config.dir || null,
240
+ updateChannel: config.updateChannel,
240
241
  })
241
242
  .then((actualPort) => log.info('http server listening on :' + actualPort))
242
243
  .catch((err) => {
@@ -268,7 +269,18 @@ const { domain, vm, fs, path, suncalc } = modules;
268
269
  const scheduler = modules['node-schedule'];
269
270
 
270
271
  const StateStore = require('./lib/state-store');
271
- const { trackTimeout } = require('./lib/script-timers');
272
+ const { trackTimeout, isValidDelay, describeDelay } = require('./lib/script-timers');
273
+ const timing = require('./lib/timing');
274
+ const timeLib = require('./lib/time');
275
+ const publishChanged = require('./lib/publish-changed');
276
+ const { Runtime } = require('./lib/runtime');
277
+ const { callerLine } = require('./lib/call-site');
278
+ const { createCommander } = require('./lib/command');
279
+ const { statusTopicWarning, scanPublishTopics } = require('./lib/command-topics');
280
+ // What every script has registered, with statistics — the Runtime page and the editor's panel
281
+ const runtime = new Runtime();
282
+ /** the script line a registration came from, or null */
283
+ const lineOf = (file) => callerLine(file)?.line ?? null;
272
284
  const sandboxModules = [];
273
285
  const store = new StateStore();
274
286
  if (typeof config.port !== 'undefined') require('./web/broker-api').setStore(store);
@@ -282,9 +294,41 @@ const varSubscriptions = []; // store-based var:: subscriptions { key, handler,
282
294
  const scriptJobs = new Map(); // scriptFile → node-schedule Job[]
283
295
  const scriptPublishes = new Map(); // scriptFile → Set<topic> a script published to (roadmap I19; capped)
284
296
  const scriptTimers = new Map(); // scriptFile → Set<timer id>
297
+ const scriptUnloadHooks = new Map(); // scriptFile → Set<fn> run first when the script is unloaded
285
298
 
286
299
  const _global = {};
287
300
 
301
+ // One store listener serves every she.on('var::…') of every script. Before, each call
302
+ // added a listener of its own, so Node warned about MaxListeners from the eleventh on; now a
303
+ // registration is an entry in varSubscriptions, which unloadScript removes.
304
+ store.on('change', (changedKey, val, obj, prevObj) => {
305
+ if (!changedKey.startsWith('var::')) return;
306
+ for (const sub of varSubscriptions.slice()) {
307
+ if (sub.key === changedKey) _dispatch(sub._script, () => sub.callback(val, obj, prevObj), sub._rt);
308
+ }
309
+ });
310
+
311
+ // she.mqtt.command waits for the device's status: one store listener serves every pending command
312
+ const commandWatchers = new Map(); // topic → Set<cb(val)>
313
+ store.on('change', (changedKey, val) => {
314
+ if (commandWatchers.size === 0 || !changedKey.startsWith('mqtt::')) return;
315
+ const set = commandWatchers.get(changedKey.slice(6));
316
+ if (!set) return;
317
+ for (const cb of [...set]) cb(val);
318
+ });
319
+ function watchTopic(topic, cb) {
320
+ let set = commandWatchers.get(topic);
321
+ if (!set) {
322
+ set = new Set();
323
+ commandWatchers.set(topic, set);
324
+ }
325
+ set.add(cb);
326
+ return () => {
327
+ set.delete(cb);
328
+ if (set.size === 0) commandWatchers.delete(topic);
329
+ };
330
+ }
331
+
288
332
  // ── Event-loop heartbeat ─────────────────────────────────────────────────────
289
333
  let _blockingScript = null;
290
334
  let _blockingScriptTs = 0; // timestamp when the last _dispatch() call finished
@@ -297,18 +341,26 @@ let _blockingScriptTs = 0; // timestamp when the last _dispatch() call finished
297
341
  * as a last resort but cannot name the script; label-prefixed errors also show
298
342
  * up in the script's log panel in the Scripts tab.
299
343
  */
300
- function _dispatch(label, fn) {
344
+ function _dispatch(label, fn, handler) {
301
345
  _blockingScript = label;
346
+ const t0 = handler ? performance.now() : 0;
302
347
  let result;
348
+ let failed = false;
303
349
  try {
304
350
  result = fn();
351
+ } catch (err) {
352
+ failed = true;
353
+ throw err;
305
354
  } finally {
306
355
  // Record end-time synchronously — no setImmediate, which would race with the
307
356
  // heartbeat timer when blocking occurs inside a poll-phase (I/O) callback.
308
357
  _blockingScriptTs = Date.now();
358
+ // the handler's statistics: fires, time, errors — since load and over the last five minutes
359
+ if (handler) handler.stats.record(performance.now() - t0, failed, _blockingScriptTs);
309
360
  }
310
361
  if (result && typeof result.then === 'function' && typeof result.catch === 'function') {
311
362
  result.catch((err) => {
363
+ if (handler) handler.stats.fail();
312
364
  const msg = err instanceof Error ? (err.stack ?? err.message) : String(err);
313
365
  log.error(makeLabel(label) + ' async callback error: ' + msg);
314
366
  });
@@ -337,6 +389,14 @@ const SUNCALC_EVENTS = new Set([
337
389
  const sunEvents = [];
338
390
  let sunTimes = [{}, /* today */ {}, /* tomorrow */ {}];
339
391
 
392
+ // she.time: the zone, locale and holiday table every script's helpers use — validated once,
393
+ // a bad value is logged once here and replaced by the default.
394
+ const timeSettings = timeLib.settingsFromConfig(config, (msg) => log.warn(msg));
395
+ log.info(
396
+ `she.time: zone ${timeSettings.timeZone}, locale ${timeSettings.locale}, holidays ${timeSettings.holidays || 'not configured'}` +
397
+ (timeSettings.holidaysExtra.length ? ` + ${timeSettings.holidaysExtra.length} extra` : ''),
398
+ );
399
+
340
400
  function calculateSunTimes() {
341
401
  const now = new Date();
342
402
  const today = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 12, 0, 0, 0, 0);
@@ -392,10 +452,10 @@ function sunScheduleEvent(obj, shift) {
392
452
  // Event is less than 1s in the future or already in the past
393
453
  // (options.random may have shifted us further to the past)
394
454
  // call the callback immediately!
395
- _dispatch(obj._script, obj.domain.bind(obj.callback));
455
+ _dispatch(obj._script, obj.domain.bind(obj.callback), obj._rt);
396
456
  } else {
397
457
  // Schedule the event and track the job so it can be cancelled on script unload
398
- obj._job = scheduler.scheduleJob(event, () => _dispatch(obj._script, obj.domain.bind(obj.callback)));
458
+ obj._job = scheduler.scheduleJob(event, () => _dispatch(obj._script, obj.domain.bind(obj.callback), obj._rt));
399
459
  }
400
460
  }
401
461
  }
@@ -517,11 +577,21 @@ new PerformanceObserver((list) => {
517
577
  }
518
578
  }).observe({ type: 'gc', buffered: false });
519
579
 
520
- // Register runtime stats provider for GET /she/status
521
- const statsSnapshot = () => {
580
+ function countTopics() {
522
581
  let topics = 0;
523
582
  // eslint-disable-next-line no-unused-vars
524
583
  for (const _ of store.mqttEntries()) topics++;
584
+ return topics;
585
+ }
586
+
587
+ /**
588
+ * The daemon's gauges: rates since the previous sample (message rate, CPU, event-loop utilisation
589
+ * and lag), memory, counts. One sampler owns the interval counters — every reader (the status
590
+ * endpoint, the Runtime page, the hour's history) sees the same sample instead of resetting the
591
+ * counters under each other. Sampled every 10 s, every second while a Runtime page is open; the
592
+ * history keeps one sample per 10 s for an hour.
593
+ */
594
+ function sampleGauges() {
525
595
  const now = Date.now();
526
596
  const elapsed = (now - _mqttMsgTs) / 1000;
527
597
  const mqttMsgPerSec = elapsed > 0 ? Math.round((_mqttMsgCount / elapsed) * 10) / 10 : 0;
@@ -532,11 +602,35 @@ const statsSnapshot = () => {
532
602
  const cpuPercent = elapsed > 0 ? Math.round(((cpuDelta.user + cpuDelta.system) / 1000 / (elapsed * 1000)) * 1000) / 10 : 0;
533
603
  const eluDelta = performance.eventLoopUtilization(_prevElu);
534
604
  _prevElu = performance.eventLoopUtilization();
535
- const elMeanMs = Math.round(_elHisto.mean / 1e6);
536
- const elMaxMs = Math.round(_elHisto.max / 1e6);
605
+ // the histogram is NaN before the first measurement of an interval — that is 0 lag, not null
606
+ const elMeanMs = Math.round(_elHisto.mean / 1e6) || 0;
607
+ const elMaxMs = Math.round(_elHisto.max / 1e6) || 0;
537
608
  _elHisto.reset();
538
609
  const mem = process.memoryUsage();
539
- const memMb = Math.round(mem.rss / 1048576);
610
+ const sample = {
611
+ ts: now,
612
+ mqttMsgPerSec,
613
+ cpuPercent,
614
+ eluPercent: Math.round(eluDelta.utilization * 100),
615
+ elMeanMs,
616
+ elMaxMs,
617
+ memMb: Math.round(mem.rss / 1048576),
618
+ heapMb: Math.round(mem.heapUsed / 1048576),
619
+ topics: countTopics(),
620
+ handlers: runtime.handlers.size,
621
+ pendingTimers: runtime.pending.size,
622
+ };
623
+ runtime.latestGauges = sample;
624
+ runtime.gauges.push(sample);
625
+ return sample;
626
+ }
627
+ sampleGauges();
628
+
629
+ // Register runtime stats provider for GET /she/status
630
+ const statsSnapshot = () => {
631
+ const g = runtime.latestGauges || sampleGauges();
632
+ const { mqttMsgPerSec, cpuPercent, eluPercent, elMeanMs, elMaxMs, memMb, heapMb } = g;
633
+ const topics = countTopics();
540
634
  const core = shedb.getCore();
541
635
  const dbDocs = core ? Object.keys(core.docs).length : null;
542
636
  const dbViews = core ? Object.keys(core.queries).length : null;
@@ -571,16 +665,79 @@ const statsSnapshot = () => {
571
665
  dbViews,
572
666
  handlers: subscriptions.length + varSubscriptions.length + mqttEventCallbacks.length,
573
667
  memMb,
668
+ heapMb,
574
669
  cpuPercent,
575
- eluPercent: Math.round(eluDelta.utilization * 100),
670
+ eluPercent,
576
671
  elMeanMs,
577
672
  elMaxMs,
673
+ pendingTimers: runtime.pending.size,
674
+ commandsFailed: runtime.commandsFailed,
578
675
  safeMode: SAFE_MODE,
579
676
  safeModeReason: SAFE_MODE_REASON,
580
677
  };
581
678
  };
582
679
  require('./web/server').setStatsProvider(statsSnapshot);
583
680
 
681
+ // The Runtime page and the editor's Runtime panel: a snapshot about once a second over the
682
+ // WebSocket, only to the clients that asked for it (optionally one script's part); the gauges are
683
+ // sampled every second while someone watches, every 10 s otherwise.
684
+ const _runtimeSubs = new Map(); // ws → script file | null (everything)
685
+ const scriptLabel = (f) => makeLabel(f).slice(0, -1);
686
+ const scriptOrigin = (f) => scriptOrigins.get(f) || 'user';
687
+ const fileOfLabel = (label) => Object.keys(scripts).find((f) => scriptLabel(f) === label);
688
+ function runtimeSnapshot(file) {
689
+ return runtime.snapshot({ script: file, label: scriptLabel, origin: scriptOrigin });
690
+ }
691
+ function pushRuntime() {
692
+ if (_runtimeSubs.size === 0) return;
693
+ let full = null;
694
+ for (const [ws, file] of _runtimeSubs) {
695
+ let snap;
696
+ if (file === null) snap = full || (full = runtimeSnapshot());
697
+ else snap = runtimeSnapshot(file);
698
+ sendWs(ws, { type: 'runtime', ...snap });
699
+ }
700
+ }
701
+ let _sampleTimer = null;
702
+ function scheduleSample() {
703
+ if (_sampleTimer) clearTimeout(_sampleTimer);
704
+ _sampleTimer = setTimeout(
705
+ () => {
706
+ sampleGauges();
707
+ pushRuntime();
708
+ scheduleSample();
709
+ },
710
+ _runtimeSubs.size > 0 ? 1000 : 10_000,
711
+ );
712
+ _sampleTimer.unref();
713
+ }
714
+ scheduleSample();
715
+ onClientMessage('runtime:subscribe', (ws, msg) => {
716
+ let file = null;
717
+ if (typeof msg.script === 'string' && msg.script) {
718
+ file = fileOfLabel(msg.script);
719
+ // not loaded (a library, a disabled or broken script): subscribe anyway and show it empty — the
720
+ // panel re-subscribes when the script's running state changes
721
+ if (!file) file = '\0unknown:' + msg.script;
722
+ }
723
+ const first = _runtimeSubs.size === 0;
724
+ _runtimeSubs.set(ws, file);
725
+ sendWs(ws, { type: 'runtime', ...runtimeSnapshot(file) });
726
+ if (first) scheduleSample(); // from the 10 s to the 1 s cadence at once
727
+ });
728
+ onClientMessage('runtime:unsubscribe', (ws) => _runtimeSubs.delete(ws));
729
+ onClientClose((ws) => _runtimeSubs.delete(ws));
730
+ require('./web/runtime-api').init({
731
+ runtime,
732
+ snapshot: (label) => {
733
+ if (!label) return runtimeSnapshot();
734
+ const file = fileOfLabel(label);
735
+ return runtimeSnapshot(file || '\0unknown:' + label);
736
+ },
737
+ history: () => ({ intervalMs: runtime.gauges.intervalMs, samples: runtime.gauges.list() }),
738
+ log: (file, msg) => log.info(makeLabel(file), msg),
739
+ });
740
+
584
741
  // Health probe for GET /she/health (roadmap A1) — cheap, no counting, no allocation.
585
742
  const healthSnapshot = () => ({
586
743
  started: _started,
@@ -609,7 +766,14 @@ const introspect = {
609
766
  if (!mqtt || !connected) return reject(new Error('MQTT is not connected'));
610
767
  mqtt.publish(String(topic), typeof payload === 'object' ? JSON.stringify(payload) : String(payload), opts || {}, (err) => (err ? reject(err) : resolve()));
611
768
  }),
612
- config: () => ({ name: config.name, variablePrefix: config.variablePrefix || 'var', version: pkg.version }),
769
+ config: () => ({
770
+ name: config.name,
771
+ variablePrefix: config.variablePrefix || 'var',
772
+ version: pkg.version,
773
+ timezone: timeSettings.timeZone,
774
+ locale: timeSettings.locale,
775
+ holidays: timeSettings.holidays,
776
+ }),
613
777
  scripts: () =>
614
778
  Object.keys(scripts).map((file) => ({
615
779
  file,
@@ -620,7 +784,13 @@ const introspect = {
620
784
  publishes: [...(scriptPublishes.get(file) || [])],
621
785
  jobs: (scriptJobs.get(file) || []).filter(Boolean).map((job) => ({ next: nextOf(job) })),
622
786
  sunEvents: sunEvents.filter((e) => e._script === file).map((e) => ({ pattern: e.pattern, next: nextOf(e._job) })),
623
- timers: [...(scriptTimers.get(file) || [])].map((t) => ({ due: t.due || null, every: t.every || null })),
787
+ timers: [...(scriptTimers.get(file) || [])].map((t) => ({
788
+ due: t.due || null,
789
+ start: t.start || null,
790
+ every: t.every || null,
791
+ kind: t.kind || null,
792
+ name: t.name || null,
793
+ })),
624
794
  })),
625
795
  stats: statsSnapshot,
626
796
  health: healthSnapshot,
@@ -935,17 +1105,50 @@ function stateChange(topic, state, oldState, msg) {
935
1105
 
936
1106
  delay = Math.floor(delay);
937
1107
 
938
- setTimeout(() => {
939
- /**
940
- * @callback subscribeCallback
941
- * @param {string} topic - the topic that triggered this callback
942
- * @param {mixed} val - the val property of the new state
943
- * @param {object} obj - new state - the whole state object (e.g. {"val": true, "ts": 12346345, "lc": 12346345} )
944
- * @param {object} objPrev - previous state - the whole state object
945
- * @param {object} msg - the mqtt message as received from MQTT.js
946
- */
947
- _dispatch(subs._script, () => subs.callback(topic, state.val, state, oldState, msg));
948
- }, delay);
1108
+ /**
1109
+ * @callback subscribeCallback
1110
+ * @param {string} topic - the topic that triggered this callback
1111
+ * @param {mixed} val - the val property of the new state
1112
+ * @param {object} obj - new state - the whole state object (e.g. {"val": true, "ts": 12346345, "lc": 12346345} )
1113
+ * @param {object} objPrev - previous state - the whole state object
1114
+ * @param {object} msg - the mqtt message as received from MQTT.js
1115
+ */
1116
+ const run = () => subs.deliver(topic, state.val, state, oldState, msg);
1117
+ const timers = scriptTimers.get(subs._script) || new Set();
1118
+ // The delay timer is tracked like a script's own setTimeout, so a reload of
1119
+ // the script between the message and the shifted callback cancels the callback instead
1120
+ // of running the unloaded script's code.
1121
+ if (delay === 0) {
1122
+ trackTimeout(timers, run, 0);
1123
+ } else {
1124
+ // a shifted delivery is a pending timer of the script: listed under the
1125
+ // subscription's `shift` group, cancellable from the editor
1126
+ const group = runtime.timerGroup(subs._script, { kind: 'shift', name: subs.topic, line: subs._rt?.line ?? null });
1127
+ let pending = null;
1128
+ const id = trackTimeout(
1129
+ timers,
1130
+ () => {
1131
+ runtime.removePending(pending);
1132
+ run();
1133
+ },
1134
+ delay,
1135
+ { kind: 'shift', name: subs.topic },
1136
+ );
1137
+ const clear = () => {
1138
+ timers.delete(id);
1139
+ clearTimeout(id);
1140
+ runtime.removePending(pending);
1141
+ };
1142
+ pending = runtime.addPending(group, {
1143
+ due: id.due,
1144
+ start: id.start,
1145
+ fire: () => {
1146
+ clear();
1147
+ run();
1148
+ },
1149
+ cancel: clear,
1150
+ });
1151
+ }
949
1152
  }
950
1153
  });
951
1154
  }
@@ -995,7 +1198,7 @@ function createScript(source, name) {
995
1198
  }
996
1199
  }
997
1200
 
998
- function runScript(script, name, _origin) {
1201
+ function runScript(script, name, _origin, source) {
999
1202
  const scriptDir = path.dirname(path.resolve(name));
1000
1203
  const logLabel = makeLabel(name);
1001
1204
 
@@ -1004,6 +1207,14 @@ function runScript(script, name, _origin) {
1004
1207
  if (!scriptTimers.has(name)) scriptTimers.set(name, new Set());
1005
1208
  const _myJobs = scriptJobs.get(name);
1006
1209
  const _myTimers = scriptTimers.get(name);
1210
+ const _myHooks = new Set();
1211
+ scriptUnloadHooks.set(name, _myHooks);
1212
+ const _myLastPayload = new Map(); // topic → last payload, for pub({ changed: true })
1213
+ let _unloaded = false;
1214
+ let _subCount = 0;
1215
+ _myHooks.add(() => {
1216
+ _unloaded = true;
1217
+ });
1007
1218
 
1008
1219
  log.debug(logLabel, 'creating domain');
1009
1220
  const scriptDomain = domain.create();
@@ -1014,6 +1225,100 @@ function runScript(script, name, _origin) {
1014
1225
  return a !== null && typeof a === 'object' ? JSON.stringify(a) : a;
1015
1226
  }
1016
1227
 
1228
+ /**
1229
+ * The script's one-shot timer: the delay is validated and a bad one is logged with the script's
1230
+ * name, the callback runs in the script's domain, the id is tracked for the unload
1231
+ * and carries kind and name for the timer list. Every timer API of the sandbox goes through here.
1232
+ */
1233
+ function _timeout(fn, delay, meta = {}, api = 'setTimeout') {
1234
+ if (_unloaded) {
1235
+ log.debug(logLabel, `${api} after unload ignored`);
1236
+ return null;
1237
+ }
1238
+ // The runtime model groups timers by kind, name and the line that created them: a plain
1239
+ // setTimeout by its call site, a timing helper by the line the helper was created on (the
1240
+ // helper passes it in its meta — the stack at re-arm time points into a callback).
1241
+ const kind = meta.kind || 'timer';
1242
+ const line = meta.line !== undefined ? meta.line : kind === 'timer' ? lineOf(name) : null;
1243
+ const group = runtime.timerGroup(name, { kind, name: meta.name, line });
1244
+ const run = scriptDomain.bind(() => _dispatch(name, fn, group));
1245
+ let pending = null;
1246
+ const id = trackTimeout(
1247
+ _myTimers,
1248
+ () => {
1249
+ runtime.removePending(pending);
1250
+ run();
1251
+ },
1252
+ delay,
1253
+ {
1254
+ warn: (msg) => log.warn(logLabel, msg),
1255
+ api,
1256
+ kind: meta.kind,
1257
+ name: meta.name,
1258
+ },
1259
+ );
1260
+ pending = runtime.addPending(group, {
1261
+ due: id.due,
1262
+ start: id.start,
1263
+ fire:
1264
+ meta.actions && meta.actions.run === false
1265
+ ? null
1266
+ : () => {
1267
+ _clearTimeout(id);
1268
+ run();
1269
+ },
1270
+ cancel: () => {
1271
+ _clearTimeout(id);
1272
+ if (typeof meta.onCancel === 'function') meta.onCancel();
1273
+ },
1274
+ extra: meta.extra,
1275
+ });
1276
+ id.rt = pending;
1277
+ return id;
1278
+ }
1279
+ function _clearTimeout(id) {
1280
+ _myTimers.delete(id);
1281
+ clearTimeout(id);
1282
+ if (id && id.rt) runtime.removePending(id.rt);
1283
+ }
1284
+ const _timing = timing.createTimingHelpers({
1285
+ setTimeout: (fn, ms, meta) => _timeout(fn, ms, meta),
1286
+ clearTimeout: _clearTimeout,
1287
+ onUnload: (fn) => {
1288
+ _myHooks.add(fn);
1289
+ return () => _myHooks.delete(fn);
1290
+ },
1291
+ line: () => lineOf(name),
1292
+ });
1293
+ const _lintWarned = new Set(); // topics already warned about (load-time scan and runtime)
1294
+ function lintPublish(topic, api, line) {
1295
+ if (_lintWarned.has(topic)) return;
1296
+ const why = statusTopicWarning(topic, (t) => store.has('mqtt::' + t));
1297
+ if (!why) return;
1298
+ _lintWarned.add(topic);
1299
+ log.warn(logLabel, `${api}${line ? ` (line ${line})` : ''}: ${why}`);
1300
+ }
1301
+ const _command = createCommander({
1302
+ publish: (topic, payload) => {
1303
+ recordPublish(name, topic);
1304
+ if (mqtt && connected) mqtt.publish(topic, payload, {});
1305
+ },
1306
+ get: (topic) => store.get('mqtt::' + topic),
1307
+ has: (topic) => store.has('mqtt::' + topic),
1308
+ watch: watchTopic,
1309
+ setTimeout: (fn, ms, meta) => _timeout(fn, ms, meta, 'she.mqtt.command'),
1310
+ clearTimeout: _clearTimeout,
1311
+ onUnload: (fn) => {
1312
+ _myHooks.add(fn);
1313
+ return () => _myHooks.delete(fn);
1314
+ },
1315
+ warn: (msg) => log.warn(logLabel, msg),
1316
+ onFail: () => {
1317
+ runtime.commandsFailed++;
1318
+ },
1319
+ line: () => lineOf(name),
1320
+ });
1321
+
1017
1322
  const she = {
1018
1323
  global: _global,
1019
1324
 
@@ -1087,36 +1392,59 @@ function runScript(script, name, _origin) {
1087
1392
  } else if (rest.length > 2) {
1088
1393
  throw new Error('wrong number of arguments');
1089
1394
  }
1395
+ options = options || {};
1090
1396
 
1091
- if (typeof topic === 'string') {
1092
- if (typeof options.condition === 'string') {
1093
- if (options.condition.indexOf('\n') !== -1) {
1094
- throw new Error('options.condition string must be one-line javascript');
1095
- }
1096
- /* eslint-disable no-new-func */
1097
- options.condition = new Function('topic', 'val', 'obj', 'objPrev', 'msg', 'return ' + options.condition + ';');
1098
- }
1397
+ let topics;
1398
+ if (typeof topic === 'string') topics = [topic];
1399
+ else if (typeof topic === 'object' && topic.length > 0) topics = Array.prototype.slice.call(topic);
1400
+ else return;
1099
1401
 
1100
- if (typeof options.condition === 'function') {
1101
- options.condition = scriptDomain.bind(options.condition);
1402
+ if (typeof options.condition === 'string') {
1403
+ if (options.condition.indexOf('\n') !== -1) {
1404
+ throw new Error('options.condition string must be one-line javascript');
1102
1405
  }
1406
+ /* eslint-disable no-new-func */
1407
+ options.condition = new Function('topic', 'val', 'obj', 'objPrev', 'msg', 'return ' + options.condition + ';');
1408
+ }
1409
+
1410
+ if (typeof options.condition === 'function') {
1411
+ options.condition = scriptDomain.bind(options.condition);
1412
+ }
1103
1413
 
1104
- subscriptions.push({ topic, options, callback: typeof callback === 'function' && scriptDomain.bind(callback), _script: name });
1414
+ const boundCallback = typeof callback === 'function' && scriptDomain.bind(callback);
1415
+ const label = topics.length > 1 ? `${topics[0]} +${topics.length - 1}` : topics[0];
1416
+ // one handler in the runtime model per call — the topics of an array share it, like
1417
+ // they share the limiter below
1418
+ const optNames = ['change', 'retain', 'shift', 'random', 'condition', 'debounce', 'throttle', 'maxWait', 'perTopic'].filter(
1419
+ (k) => options[k] !== undefined && options[k] !== false,
1420
+ );
1421
+ const detail = [topics.length > 1 ? topics.join(', ') : null, optNames.length ? optNames.join(', ') : null].filter(Boolean).join(' · ') || null;
1422
+ const handler = runtime.handler(name, { kind: 'sub', name: label, detail, line: lineOf(name) });
1423
+ // what runs the callback for one event; with opts.debounce or opts.throttle the events of
1424
+ // this call are bundled — across the topics of an array, per topic with opts.perTopic
1425
+ let deliver = (t, val, obj, objPrev, msg) => _dispatch(name, () => boundCallback(t, val, obj, objPrev, msg), handler);
1426
+ const limited = timing.subscriptionLimiter(options, deliver, _timing, label);
1427
+ if (limited) deliver = limited;
1428
+
1429
+ for (const tp of topics) {
1430
+ subscriptions.push({ topic: tp, options, callback: boundCallback, deliver, _script: name, _rt: handler });
1431
+ _subCount++;
1432
+ if (_subCount === 1000) {
1433
+ log.warn(
1434
+ logLabel,
1435
+ 'has registered 1000 subscriptions — she.mqtt.sub is meant to be called when the script loads, not inside a callback: every call adds a subscription that stays until the script is unloaded',
1436
+ );
1437
+ }
1105
1438
 
1106
- if (options.retain && store.has('mqtt::' + topic) && typeof callback === 'function') {
1107
- _dispatch(name, () => callback(topic, store.get('mqtt::' + topic), store.getObject('mqtt::' + topic)));
1108
- } else if (options.retain && (/\/\+\//.test(topic) || /\+$/.test(topic) || /\+/.test(topic) || topic.endsWith('#')) && typeof callback === 'function') {
1109
- for (const [t, obj] of store.mqttEntries()) {
1110
- if (mqttWildcards(t, topic)) {
1111
- _dispatch(name, () => callback(t, obj.val, obj));
1439
+ if (options.retain && boundCallback) {
1440
+ if (store.has('mqtt::' + tp)) {
1441
+ deliver(tp, store.get('mqtt::' + tp), store.getObject('mqtt::' + tp));
1442
+ } else if (tp.includes('+') || tp.endsWith('#')) {
1443
+ for (const [t, obj] of store.mqttEntries()) {
1444
+ if (mqttWildcards(t, tp)) deliver(t, obj.val, obj);
1112
1445
  }
1113
1446
  }
1114
1447
  }
1115
- } else if (typeof topic === 'object' && topic.length > 0) {
1116
- topic = Array.prototype.slice.call(topic);
1117
- topic.forEach((tp) => {
1118
- she.mqttsub(tp, options, callback);
1119
- });
1120
1448
  }
1121
1449
  },
1122
1450
 
@@ -1166,6 +1494,7 @@ function runScript(script, name, _origin) {
1166
1494
  if (typeof options.shift !== 'undefined' && (options.shift < -86400 || options.shift > 86400)) {
1167
1495
  throw new Error('options.shift out of range');
1168
1496
  }
1497
+ const sunDetail = [options.shift ? `shift ${options.shift}s` : null, options.random ? `random ${options.random}s` : null].filter(Boolean).join(', ') || null;
1169
1498
  const obj = {
1170
1499
  pattern,
1171
1500
  options,
@@ -1173,23 +1502,61 @@ function runScript(script, name, _origin) {
1173
1502
  context: she,
1174
1503
  domain: scriptDomain,
1175
1504
  _script: name,
1505
+ _rt: null,
1176
1506
  };
1507
+ obj._rt = runtime.handler(name, {
1508
+ kind: 'sun',
1509
+ name: pattern,
1510
+ detail: sunDetail,
1511
+ line: lineOf(name),
1512
+ next: () => nextOf(obj._job),
1513
+ run: () => _dispatch(name, scriptDomain.bind(callback), obj._rt),
1514
+ });
1177
1515
  sunEvents.push(obj);
1178
1516
  sunScheduleEvent(obj);
1179
1517
  return;
1180
1518
  }
1181
1519
 
1182
- if (options.random) {
1183
- _myJobs.push(
1184
- scheduler.scheduleJob(pattern, () => {
1185
- // Track the random-delay timer so it is cancelled on unload
1186
- // if the job fires in the same tick as the script is reloaded.
1187
- trackTimeout(_myTimers, () => _dispatch(name, scriptDomain.bind(callback)), (parseFloat(options.random) || 0) * 1000 * Math.random());
1188
- }),
1189
- );
1190
- } else {
1191
- _myJobs.push(scheduler.scheduleJob(pattern, () => _dispatch(name, scriptDomain.bind(callback))));
1520
+ // a Date from the sandbox is of the context's realm, so no instanceof
1521
+ const shown = pattern && typeof pattern.toISOString === 'function' ? pattern.toISOString() : typeof pattern === 'string' ? pattern : JSON.stringify(pattern);
1522
+ let handler = null;
1523
+ const runNow = () => _dispatch(name, scriptDomain.bind(callback), handler);
1524
+ const run = options.random
1525
+ ? () => {
1526
+ // Track the random-delay timer so it is cancelled on unload
1527
+ // if the job fires in the same tick as the script is reloaded.
1528
+ _timeout(
1529
+ () => runNow(),
1530
+ (parseFloat(options.random) || 0) * 1000 * Math.random(),
1531
+ { kind: 'schedule random', name: shown, line: handler?.line ?? null },
1532
+ 'she.schedule',
1533
+ );
1534
+ }
1535
+ : runNow;
1536
+ let job = null;
1537
+ job = scheduler.scheduleJob(pattern, () => {
1538
+ // A one-shot job (a Date) is done after this run: forget it, so that a script
1539
+ // scheduling a Date per event does not keep every fired job.
1540
+ if (job && job.nextInvocation() === null) {
1541
+ const i = _myJobs.indexOf(job);
1542
+ if (i !== -1) _myJobs.splice(i, 1);
1543
+ runtime.removeHandler(handler);
1544
+ }
1545
+ run();
1546
+ });
1547
+ if (!job) {
1548
+ log.warn(logLabel, `she.schedule: nothing scheduled for ${shown} — not a valid pattern, or a date in the past`);
1549
+ return;
1192
1550
  }
1551
+ _myJobs.push(job);
1552
+ handler = runtime.handler(name, {
1553
+ kind: 'schedule',
1554
+ name: shown,
1555
+ detail: options.random ? `random ${options.random}s` : null,
1556
+ line: lineOf(name),
1557
+ next: () => nextOf(job),
1558
+ run: runNow,
1559
+ });
1193
1560
  },
1194
1561
 
1195
1562
  /**
@@ -1200,6 +1567,8 @@ function runScript(script, name, _origin) {
1200
1567
  * @param {Object} [options] - the options to publish with
1201
1568
  * @param {number} [options.qos=0] - QoS Level
1202
1569
  * @param {boolean} [options.retain=false] - retain flag
1570
+ * @param {boolean} [options.changed=false] - publish only when the payload differs from the last one this
1571
+ * script published to the topic (before the first one: from the topic's current value in the state store)
1203
1572
  */
1204
1573
  mqttpub: function Sandbox_mqttpub(topic, payload, options) {
1205
1574
  if (typeof topic === 'object' && topic.length > 0) {
@@ -1212,16 +1581,36 @@ function runScript(script, name, _origin) {
1212
1581
 
1213
1582
  topic = topic.replace(/^([^/]+)\/\/(.+)$/, '$1/set/$2');
1214
1583
  recordPublish(name, topic);
1584
+ lintPublish(topic, 'she.mqtt.pub');
1215
1585
 
1216
1586
  if (typeof payload === 'object') {
1217
1587
  payload = JSON.stringify(payload);
1218
1588
  } else {
1219
1589
  payload = String(payload);
1220
1590
  }
1591
+ let changed = false;
1592
+ if (options && typeof options === 'object' && 'changed' in options) {
1593
+ ({ changed, ...options } = options);
1594
+ }
1595
+ if (changed && publishChanged.isRepeat(_myLastPayload, topic, payload, (t) => store.getObject('mqtt::' + t))) return;
1221
1596
  if (!mqtt || !connected) return; // silently drop when MQTT is not connected
1597
+ publishChanged.remember(_myLastPayload, topic, payload);
1222
1598
  mqtt.publish(topic, payload, options);
1223
1599
  },
1224
1600
 
1601
+ /**
1602
+ * Publish a command and wait for the device's status to confirm it. she.mqtt.command.
1603
+ * @method mqttcommand
1604
+ * @param {string} topic
1605
+ * @param {*} payload
1606
+ * @param {Object} [options] confirm, path, expect, tolerance, timeout (s), retries, quiet
1607
+ * @returns {Promise<{val, ms, tries, already?}>}
1608
+ */
1609
+ mqttcommand: function Sandbox_mqttcommand(topic, payload, options) {
1610
+ if (typeof topic === 'string') lintPublish(topic.replace(/^([^/]+)\/\/(.+)$/, '$1/set/$2'), 'she.mqtt.command');
1611
+ return _command(topic, payload, options);
1612
+ },
1613
+
1225
1614
  /**
1226
1615
  * Set a value on one or more topics
1227
1616
  * @method setValue
@@ -1308,14 +1697,12 @@ function runScript(script, name, _origin) {
1308
1697
  } else if (key.startsWith('var::')) {
1309
1698
  const varStoreKey = 'var::' + key.slice(5);
1310
1699
  const boundCb = scriptDomain.bind(callback);
1311
- const varHandler = (changedKey, val, obj, prevObj) => {
1312
- if (changedKey === varStoreKey) _dispatch(name, () => boundCb(val, obj, prevObj));
1313
- };
1314
- store.on('change', varHandler);
1315
- varSubscriptions.push({ key: varStoreKey, handler: varHandler, _script: name });
1700
+ const handler = runtime.handler(name, { kind: 'var', name: key, line: lineOf(name) });
1701
+ // served by the one store listener at module level
1702
+ varSubscriptions.push({ key: varStoreKey, callback: boundCb, _script: name, _rt: handler });
1316
1703
  // Fire immediately if var already has a value (retain semantics)
1317
1704
  const currentVarObj = store.getObject(varStoreKey);
1318
- if (currentVarObj !== undefined) boundCb(currentVarObj.val, currentVarObj, undefined);
1705
+ if (currentVarObj !== undefined) _dispatch(name, () => boundCb(currentVarObj.val, currentVarObj, undefined), handler);
1319
1706
  } else if (key.startsWith('matter::')) {
1320
1707
  const parts = key.slice(8).split('/');
1321
1708
  if (parts.length !== 4) throw new TypeError('she.on: invalid matter key (expected matter::nodeId/ep/Cluster/attr)');
@@ -1332,31 +1719,60 @@ function runScript(script, name, _origin) {
1332
1719
  she.log = she.info;
1333
1720
 
1334
1721
  const Sandbox = {
1335
- setTimeout: (fn, delay, ...args) => {
1722
+ // setTimeout(fn) without a delay is Node's "next tick"; a delay that is given but is not a
1723
+ // number is a mistake and logged with the script's name
1724
+ setTimeout: (fn, ...rest) => {
1725
+ const [delay, ...args] = rest;
1336
1726
  const wrapped = args.length ? () => fn(...args) : fn;
1337
- return trackTimeout(_myTimers, () => _dispatch(name, wrapped), delay);
1727
+ return _timeout(wrapped, rest.length ? delay : 0);
1338
1728
  },
1339
- setInterval: (fn, delay, ...args) => {
1729
+ setInterval: (fn, ...rest) => {
1730
+ let [delay, ...args] = rest;
1731
+ if (!isValidDelay(delay)) {
1732
+ log.warn(logLabel, `setInterval: delay is not a number of milliseconds (got ${describeDelay(delay)}), using 0`);
1733
+ delay = 0;
1734
+ }
1340
1735
  const wrapped = args.length ? () => fn(...args) : fn;
1341
- const id = setInterval(() => _dispatch(name, wrapped), delay);
1736
+ let handler = null;
1737
+ let pending = null;
1738
+ const tick = scriptDomain.bind(() => _dispatch(name, wrapped, handler));
1739
+ const id = setInterval(() => {
1740
+ // the pending entry is the next run: the progress bar starts over with every tick
1741
+ if (pending) {
1742
+ pending.start = Date.now();
1743
+ pending.due = pending.start + delay;
1744
+ }
1745
+ tick();
1746
+ }, delay);
1342
1747
  id.every = delay;
1343
- id.due = Date.now() + delay;
1748
+ id.start = Date.now();
1749
+ id.due = id.start + delay;
1344
1750
  _myTimers.add(id);
1751
+ const clear = () => {
1752
+ _myTimers.delete(id);
1753
+ clearInterval(id);
1754
+ runtime.removeHandler(handler);
1755
+ };
1756
+ handler = runtime.handler(name, { kind: 'interval', name: `every ${delay} ms`, line: lineOf(name), run: tick, cancel: clear });
1757
+ pending = runtime.addPending(handler, { due: id.due, start: id.start, every: delay });
1758
+ id.rt = handler;
1345
1759
  return id;
1346
1760
  },
1347
- clearTimeout: (id) => {
1348
- _myTimers.delete(id);
1349
- clearTimeout(id);
1350
- },
1761
+ clearTimeout: _clearTimeout,
1351
1762
  clearInterval: (id) => {
1352
1763
  _myTimers.delete(id);
1353
1764
  clearInterval(id);
1765
+ if (id && id.rt) runtime.removeHandler(id.rt);
1354
1766
  },
1355
1767
 
1356
1768
  Buffer,
1357
1769
 
1358
1770
  require(md) {
1359
- if (modules[md]) {
1771
+ // A relative path is cached by Node under its resolved file; caching it here under the bare
1772
+ // specifier made two scripts requiring './util' from different directories share the first
1773
+ // one's module. The cache here is for the daemon's own modules and packages.
1774
+ const relative = /^\.\.?\//.test(md);
1775
+ if (!relative && modules[md]) {
1360
1776
  return modules[md];
1361
1777
  }
1362
1778
  // Failures throw (Cannot find module, syntax errors in the required
@@ -1384,7 +1800,7 @@ function runScript(script, name, _origin) {
1384
1800
  }
1385
1801
  }
1386
1802
  }
1387
- modules[md] = result;
1803
+ if (!relative) modules[md] = result;
1388
1804
  return result;
1389
1805
  },
1390
1806
 
@@ -1401,6 +1817,9 @@ function runScript(script, name, _origin) {
1401
1817
  she.config = Object.freeze({
1402
1818
  latitude: config.latitude,
1403
1819
  longitude: config.longitude,
1820
+ timezone: timeSettings.timeZone,
1821
+ locale: timeSettings.locale,
1822
+ holidays: timeSettings.holidays,
1404
1823
  });
1405
1824
  // she.secrets (roadmap A5): read-only access to the secrets store; unknown paths warn once per script
1406
1825
  const _secretWarned = new Set();
@@ -1415,20 +1834,33 @@ function runScript(script, name, _origin) {
1415
1834
  },
1416
1835
  has: (p) => secrets.has(p),
1417
1836
  });
1418
- // she.setTimeout / she.clearTimeout — tracked versions for use by stdlib and
1419
- // sandbox modules that don't have direct access to the Sandbox context.
1420
- she.setTimeout = (fn, delay, ...args) => {
1421
- const wrapped = args.length ? () => fn(...args) : fn;
1422
- return trackTimeout(_myTimers, () => _dispatch(name, wrapped), delay);
1423
- };
1424
- she.clearTimeout = (id) => {
1425
- _myTimers.delete(id);
1426
- clearTimeout(id);
1427
- };
1837
+ // she.setTimeout / she.clearTimeout — the sandbox's tracked timers under the she object: the
1838
+ // documented aliases of the globals, and what stdlib and the sandbox modules use.
1839
+ she.setTimeout = Sandbox.setTimeout;
1840
+ she.clearTimeout = Sandbox.clearTimeout;
1841
+ // Timing helpers: their timers are the script's, so a reload clears them and
1842
+ // the timer list shows them with kind and name.
1843
+ she.debounce = _timing.debounce;
1844
+ she.throttle = _timing.throttle;
1845
+ she.timeout = _timing.timeout;
1846
+ she.retry = _timing.retry;
1847
+ // she.time: windows, wall-clock instants, holidays and formatting in the configured zone;
1848
+ // sun events come from suncalc with the coordinates she.schedule uses.
1849
+ she.time = timeLib.createTime({
1850
+ ...timeSettings,
1851
+ sunTimes: (noonMs) => suncalc.getTimes(new Date(noonMs), config.latitude, config.longitude),
1852
+ warn: (msg) => log.warn(logLabel, msg),
1853
+ });
1428
1854
  sandboxModules.forEach((md) => {
1429
1855
  md(she, { scriptDomain, scriptName, scriptFile: name });
1430
1856
  });
1431
1857
 
1858
+ // Lint before the script runs: a literal topic that an adapter writes as status is a command
1859
+ // that will never arrive (<inst>/status/… instead of <inst>/set/…)
1860
+ if (source !== undefined) {
1861
+ for (const hit of scanPublishTopics(source)) lintPublish(hit.topic, hit.api, hit.line);
1862
+ }
1863
+
1432
1864
  log.debug(logLabel, 'contextifying sandbox');
1433
1865
  const context = vm.createContext(Sandbox);
1434
1866
 
@@ -1506,7 +1938,7 @@ function loadScript(file, origin) {
1506
1938
  }
1507
1939
  if (scripts[file]) {
1508
1940
  scriptOrigins.set(file, origin);
1509
- runScript(scripts[file], file, origin);
1941
+ runScript(scripts[file], file, origin, src.toString());
1510
1942
  broadcast({ type: 'script:running', path: makeLabel(file).slice(0, -1), running: true });
1511
1943
  }
1512
1944
  }
@@ -1523,6 +1955,20 @@ function unloadScript(file) {
1523
1955
  let removedCallbacks = 0;
1524
1956
  let removedTimers = 0;
1525
1957
 
1958
+ // The script's unload hooks run first: the sandbox marks itself unloaded, a debounce
1959
+ // with flushOnUnload runs its pending call, a retry stops. Then the timers below are cleared.
1960
+ const hooks = scriptUnloadHooks.get(file);
1961
+ if (hooks) {
1962
+ scriptUnloadHooks.delete(file);
1963
+ for (const hook of hooks) {
1964
+ try {
1965
+ _dispatch(file, hook);
1966
+ } catch (err) {
1967
+ log.error(unloadLabel, 'error while unloading: ' + (err && err.stack ? err.stack : err));
1968
+ }
1969
+ }
1970
+ }
1971
+
1526
1972
  // Remove MQTT subscriptions belonging to this script
1527
1973
  for (let i = subscriptions.length - 1; i >= 0; i--) {
1528
1974
  if (subscriptions[i]._script === file) {
@@ -1568,11 +2014,11 @@ function unloadScript(file) {
1568
2014
  scriptTimers.delete(file);
1569
2015
  }
1570
2016
  scriptPublishes.delete(file);
2017
+ runtime.removeScript(file);
1571
2018
 
1572
2019
  // Remove store-based var:: subscriptions belonging to this script
1573
2020
  for (let i = varSubscriptions.length - 1; i >= 0; i--) {
1574
2021
  if (varSubscriptions[i]._script === file) {
1575
- store.removeListener('change', varSubscriptions[i].handler);
1576
2022
  varSubscriptions.splice(i, 1);
1577
2023
  removedCallbacks++;
1578
2024
  }