smart-home-engine 2.1.0 → 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) => {
@@ -270,7 +271,16 @@ const scheduler = modules['node-schedule'];
270
271
  const StateStore = require('./lib/state-store');
271
272
  const { trackTimeout, isValidDelay, describeDelay } = require('./lib/script-timers');
272
273
  const timing = require('./lib/timing');
274
+ const timeLib = require('./lib/time');
273
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;
274
284
  const sandboxModules = [];
275
285
  const store = new StateStore();
276
286
  if (typeof config.port !== 'undefined') require('./web/broker-api').setStore(store);
@@ -294,10 +304,31 @@ const _global = {};
294
304
  store.on('change', (changedKey, val, obj, prevObj) => {
295
305
  if (!changedKey.startsWith('var::')) return;
296
306
  for (const sub of varSubscriptions.slice()) {
297
- if (sub.key === changedKey) _dispatch(sub._script, () => sub.callback(val, obj, prevObj));
307
+ if (sub.key === changedKey) _dispatch(sub._script, () => sub.callback(val, obj, prevObj), sub._rt);
298
308
  }
299
309
  });
300
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
+
301
332
  // ── Event-loop heartbeat ─────────────────────────────────────────────────────
302
333
  let _blockingScript = null;
303
334
  let _blockingScriptTs = 0; // timestamp when the last _dispatch() call finished
@@ -310,18 +341,26 @@ let _blockingScriptTs = 0; // timestamp when the last _dispatch() call finished
310
341
  * as a last resort but cannot name the script; label-prefixed errors also show
311
342
  * up in the script's log panel in the Scripts tab.
312
343
  */
313
- function _dispatch(label, fn) {
344
+ function _dispatch(label, fn, handler) {
314
345
  _blockingScript = label;
346
+ const t0 = handler ? performance.now() : 0;
315
347
  let result;
348
+ let failed = false;
316
349
  try {
317
350
  result = fn();
351
+ } catch (err) {
352
+ failed = true;
353
+ throw err;
318
354
  } finally {
319
355
  // Record end-time synchronously — no setImmediate, which would race with the
320
356
  // heartbeat timer when blocking occurs inside a poll-phase (I/O) callback.
321
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);
322
360
  }
323
361
  if (result && typeof result.then === 'function' && typeof result.catch === 'function') {
324
362
  result.catch((err) => {
363
+ if (handler) handler.stats.fail();
325
364
  const msg = err instanceof Error ? (err.stack ?? err.message) : String(err);
326
365
  log.error(makeLabel(label) + ' async callback error: ' + msg);
327
366
  });
@@ -350,6 +389,14 @@ const SUNCALC_EVENTS = new Set([
350
389
  const sunEvents = [];
351
390
  let sunTimes = [{}, /* today */ {}, /* tomorrow */ {}];
352
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
+
353
400
  function calculateSunTimes() {
354
401
  const now = new Date();
355
402
  const today = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 12, 0, 0, 0, 0);
@@ -405,10 +452,10 @@ function sunScheduleEvent(obj, shift) {
405
452
  // Event is less than 1s in the future or already in the past
406
453
  // (options.random may have shifted us further to the past)
407
454
  // call the callback immediately!
408
- _dispatch(obj._script, obj.domain.bind(obj.callback));
455
+ _dispatch(obj._script, obj.domain.bind(obj.callback), obj._rt);
409
456
  } else {
410
457
  // Schedule the event and track the job so it can be cancelled on script unload
411
- 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));
412
459
  }
413
460
  }
414
461
  }
@@ -530,11 +577,21 @@ new PerformanceObserver((list) => {
530
577
  }
531
578
  }).observe({ type: 'gc', buffered: false });
532
579
 
533
- // Register runtime stats provider for GET /she/status
534
- const statsSnapshot = () => {
580
+ function countTopics() {
535
581
  let topics = 0;
536
582
  // eslint-disable-next-line no-unused-vars
537
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() {
538
595
  const now = Date.now();
539
596
  const elapsed = (now - _mqttMsgTs) / 1000;
540
597
  const mqttMsgPerSec = elapsed > 0 ? Math.round((_mqttMsgCount / elapsed) * 10) / 10 : 0;
@@ -545,11 +602,35 @@ const statsSnapshot = () => {
545
602
  const cpuPercent = elapsed > 0 ? Math.round(((cpuDelta.user + cpuDelta.system) / 1000 / (elapsed * 1000)) * 1000) / 10 : 0;
546
603
  const eluDelta = performance.eventLoopUtilization(_prevElu);
547
604
  _prevElu = performance.eventLoopUtilization();
548
- const elMeanMs = Math.round(_elHisto.mean / 1e6);
549
- 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;
550
608
  _elHisto.reset();
551
609
  const mem = process.memoryUsage();
552
- 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();
553
634
  const core = shedb.getCore();
554
635
  const dbDocs = core ? Object.keys(core.docs).length : null;
555
636
  const dbViews = core ? Object.keys(core.queries).length : null;
@@ -584,16 +665,79 @@ const statsSnapshot = () => {
584
665
  dbViews,
585
666
  handlers: subscriptions.length + varSubscriptions.length + mqttEventCallbacks.length,
586
667
  memMb,
668
+ heapMb,
587
669
  cpuPercent,
588
- eluPercent: Math.round(eluDelta.utilization * 100),
670
+ eluPercent,
589
671
  elMeanMs,
590
672
  elMaxMs,
673
+ pendingTimers: runtime.pending.size,
674
+ commandsFailed: runtime.commandsFailed,
591
675
  safeMode: SAFE_MODE,
592
676
  safeModeReason: SAFE_MODE_REASON,
593
677
  };
594
678
  };
595
679
  require('./web/server').setStatsProvider(statsSnapshot);
596
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
+
597
741
  // Health probe for GET /she/health (roadmap A1) — cheap, no counting, no allocation.
598
742
  const healthSnapshot = () => ({
599
743
  started: _started,
@@ -622,7 +766,14 @@ const introspect = {
622
766
  if (!mqtt || !connected) return reject(new Error('MQTT is not connected'));
623
767
  mqtt.publish(String(topic), typeof payload === 'object' ? JSON.stringify(payload) : String(payload), opts || {}, (err) => (err ? reject(err) : resolve()));
624
768
  }),
625
- 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
+ }),
626
777
  scripts: () =>
627
778
  Object.keys(scripts).map((file) => ({
628
779
  file,
@@ -633,7 +784,13 @@ const introspect = {
633
784
  publishes: [...(scriptPublishes.get(file) || [])],
634
785
  jobs: (scriptJobs.get(file) || []).filter(Boolean).map((job) => ({ next: nextOf(job) })),
635
786
  sunEvents: sunEvents.filter((e) => e._script === file).map((e) => ({ pattern: e.pattern, next: nextOf(e._job) })),
636
- timers: [...(scriptTimers.get(file) || [])].map((t) => ({ due: t.due || null, every: t.every || null, kind: t.kind || null, name: t.name || 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
+ })),
637
794
  })),
638
795
  stats: statsSnapshot,
639
796
  health: healthSnapshot,
@@ -948,25 +1105,50 @@ function stateChange(topic, state, oldState, msg) {
948
1105
 
949
1106
  delay = Math.floor(delay);
950
1107
 
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();
951
1118
  // The delay timer is tracked like a script's own setTimeout, so a reload of
952
1119
  // the script between the message and the shifted callback cancels the callback instead
953
1120
  // of running the unloaded script's code.
954
- trackTimeout(
955
- scriptTimers.get(subs._script) || new Set(),
956
- () => {
957
- /**
958
- * @callback subscribeCallback
959
- * @param {string} topic - the topic that triggered this callback
960
- * @param {mixed} val - the val property of the new state
961
- * @param {object} obj - new state - the whole state object (e.g. {"val": true, "ts": 12346345, "lc": 12346345} )
962
- * @param {object} objPrev - previous state - the whole state object
963
- * @param {object} msg - the mqtt message as received from MQTT.js
964
- */
965
- subs.deliver(topic, state.val, state, oldState, msg);
966
- },
967
- delay,
968
- delay > 0 ? { kind: 'shift', name: subs.topic } : {},
969
- );
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
+ }
970
1152
  }
971
1153
  });
972
1154
  }
@@ -1016,7 +1198,7 @@ function createScript(source, name) {
1016
1198
  }
1017
1199
  }
1018
1200
 
1019
- function runScript(script, name, _origin) {
1201
+ function runScript(script, name, _origin, source) {
1020
1202
  const scriptDir = path.dirname(path.resolve(name));
1021
1203
  const logLabel = makeLabel(name);
1022
1204
 
@@ -1053,9 +1235,20 @@ function runScript(script, name, _origin) {
1053
1235
  log.debug(logLabel, `${api} after unload ignored`);
1054
1236
  return null;
1055
1237
  }
1056
- return trackTimeout(
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(
1057
1247
  _myTimers,
1058
- scriptDomain.bind(() => _dispatch(name, fn)),
1248
+ () => {
1249
+ runtime.removePending(pending);
1250
+ run();
1251
+ },
1059
1252
  delay,
1060
1253
  {
1061
1254
  warn: (msg) => log.warn(logLabel, msg),
@@ -1064,10 +1257,29 @@ function runScript(script, name, _origin) {
1064
1257
  name: meta.name,
1065
1258
  },
1066
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;
1067
1278
  }
1068
1279
  function _clearTimeout(id) {
1069
1280
  _myTimers.delete(id);
1070
1281
  clearTimeout(id);
1282
+ if (id && id.rt) runtime.removePending(id.rt);
1071
1283
  }
1072
1284
  const _timing = timing.createTimingHelpers({
1073
1285
  setTimeout: (fn, ms, meta) => _timeout(fn, ms, meta),
@@ -1076,6 +1288,35 @@ function runScript(script, name, _origin) {
1076
1288
  _myHooks.add(fn);
1077
1289
  return () => _myHooks.delete(fn);
1078
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),
1079
1320
  });
1080
1321
 
1081
1322
  const she = {
@@ -1171,15 +1412,22 @@ function runScript(script, name, _origin) {
1171
1412
  }
1172
1413
 
1173
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) });
1174
1423
  // what runs the callback for one event; with opts.debounce or opts.throttle the events of
1175
1424
  // this call are bundled — across the topics of an array, per topic with opts.perTopic
1176
- let deliver = (t, val, obj, objPrev, msg) => _dispatch(name, () => boundCallback(t, val, obj, objPrev, msg));
1177
- const label = topics.length > 1 ? `${topics[0]} +${topics.length - 1}` : topics[0];
1425
+ let deliver = (t, val, obj, objPrev, msg) => _dispatch(name, () => boundCallback(t, val, obj, objPrev, msg), handler);
1178
1426
  const limited = timing.subscriptionLimiter(options, deliver, _timing, label);
1179
1427
  if (limited) deliver = limited;
1180
1428
 
1181
1429
  for (const tp of topics) {
1182
- subscriptions.push({ topic: tp, options, callback: boundCallback, deliver, _script: name });
1430
+ subscriptions.push({ topic: tp, options, callback: boundCallback, deliver, _script: name, _rt: handler });
1183
1431
  _subCount++;
1184
1432
  if (_subCount === 1000) {
1185
1433
  log.warn(
@@ -1246,6 +1494,7 @@ function runScript(script, name, _origin) {
1246
1494
  if (typeof options.shift !== 'undefined' && (options.shift < -86400 || options.shift > 86400)) {
1247
1495
  throw new Error('options.shift out of range');
1248
1496
  }
1497
+ const sunDetail = [options.shift ? `shift ${options.shift}s` : null, options.random ? `random ${options.random}s` : null].filter(Boolean).join(', ') || null;
1249
1498
  const obj = {
1250
1499
  pattern,
1251
1500
  options,
@@ -1253,19 +1502,37 @@ function runScript(script, name, _origin) {
1253
1502
  context: she,
1254
1503
  domain: scriptDomain,
1255
1504
  _script: name,
1505
+ _rt: null,
1256
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
+ });
1257
1515
  sunEvents.push(obj);
1258
1516
  sunScheduleEvent(obj);
1259
1517
  return;
1260
1518
  }
1261
1519
 
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);
1262
1524
  const run = options.random
1263
1525
  ? () => {
1264
1526
  // Track the random-delay timer so it is cancelled on unload
1265
1527
  // if the job fires in the same tick as the script is reloaded.
1266
- _timeout(scriptDomain.bind(callback), (parseFloat(options.random) || 0) * 1000 * Math.random(), { kind: 'schedule random' }, 'she.schedule');
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
+ );
1267
1534
  }
1268
- : () => _dispatch(name, scriptDomain.bind(callback));
1535
+ : runNow;
1269
1536
  let job = null;
1270
1537
  job = scheduler.scheduleJob(pattern, () => {
1271
1538
  // A one-shot job (a Date) is done after this run: forget it, so that a script
@@ -1273,16 +1540,23 @@ function runScript(script, name, _origin) {
1273
1540
  if (job && job.nextInvocation() === null) {
1274
1541
  const i = _myJobs.indexOf(job);
1275
1542
  if (i !== -1) _myJobs.splice(i, 1);
1543
+ runtime.removeHandler(handler);
1276
1544
  }
1277
1545
  run();
1278
1546
  });
1279
1547
  if (!job) {
1280
- // a Date from the sandbox is of the context's realm, so no instanceof
1281
- const shown = pattern && typeof pattern.toISOString === 'function' ? pattern.toISOString() : JSON.stringify(pattern);
1282
1548
  log.warn(logLabel, `she.schedule: nothing scheduled for ${shown} — not a valid pattern, or a date in the past`);
1283
1549
  return;
1284
1550
  }
1285
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
+ });
1286
1560
  },
1287
1561
 
1288
1562
  /**
@@ -1307,6 +1581,7 @@ function runScript(script, name, _origin) {
1307
1581
 
1308
1582
  topic = topic.replace(/^([^/]+)\/\/(.+)$/, '$1/set/$2');
1309
1583
  recordPublish(name, topic);
1584
+ lintPublish(topic, 'she.mqtt.pub');
1310
1585
 
1311
1586
  if (typeof payload === 'object') {
1312
1587
  payload = JSON.stringify(payload);
@@ -1323,6 +1598,19 @@ function runScript(script, name, _origin) {
1323
1598
  mqtt.publish(topic, payload, options);
1324
1599
  },
1325
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
+
1326
1614
  /**
1327
1615
  * Set a value on one or more topics
1328
1616
  * @method setValue
@@ -1409,11 +1697,12 @@ function runScript(script, name, _origin) {
1409
1697
  } else if (key.startsWith('var::')) {
1410
1698
  const varStoreKey = 'var::' + key.slice(5);
1411
1699
  const boundCb = scriptDomain.bind(callback);
1700
+ const handler = runtime.handler(name, { kind: 'var', name: key, line: lineOf(name) });
1412
1701
  // served by the one store listener at module level
1413
- varSubscriptions.push({ key: varStoreKey, callback: boundCb, _script: name });
1702
+ varSubscriptions.push({ key: varStoreKey, callback: boundCb, _script: name, _rt: handler });
1414
1703
  // Fire immediately if var already has a value (retain semantics)
1415
1704
  const currentVarObj = store.getObject(varStoreKey);
1416
- if (currentVarObj !== undefined) boundCb(currentVarObj.val, currentVarObj, undefined);
1705
+ if (currentVarObj !== undefined) _dispatch(name, () => boundCb(currentVarObj.val, currentVarObj, undefined), handler);
1417
1706
  } else if (key.startsWith('matter::')) {
1418
1707
  const parts = key.slice(8).split('/');
1419
1708
  if (parts.length !== 4) throw new TypeError('she.on: invalid matter key (expected matter::nodeId/ep/Cluster/attr)');
@@ -1444,19 +1733,36 @@ function runScript(script, name, _origin) {
1444
1733
  delay = 0;
1445
1734
  }
1446
1735
  const wrapped = args.length ? () => fn(...args) : fn;
1447
- const id = setInterval(
1448
- scriptDomain.bind(() => _dispatch(name, wrapped)),
1449
- delay,
1450
- );
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);
1451
1747
  id.every = delay;
1452
- id.due = Date.now() + delay;
1748
+ id.start = Date.now();
1749
+ id.due = id.start + delay;
1453
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;
1454
1759
  return id;
1455
1760
  },
1456
1761
  clearTimeout: _clearTimeout,
1457
1762
  clearInterval: (id) => {
1458
1763
  _myTimers.delete(id);
1459
1764
  clearInterval(id);
1765
+ if (id && id.rt) runtime.removeHandler(id.rt);
1460
1766
  },
1461
1767
 
1462
1768
  Buffer,
@@ -1511,6 +1817,9 @@ function runScript(script, name, _origin) {
1511
1817
  she.config = Object.freeze({
1512
1818
  latitude: config.latitude,
1513
1819
  longitude: config.longitude,
1820
+ timezone: timeSettings.timeZone,
1821
+ locale: timeSettings.locale,
1822
+ holidays: timeSettings.holidays,
1514
1823
  });
1515
1824
  // she.secrets (roadmap A5): read-only access to the secrets store; unknown paths warn once per script
1516
1825
  const _secretWarned = new Set();
@@ -1535,10 +1844,23 @@ function runScript(script, name, _origin) {
1535
1844
  she.throttle = _timing.throttle;
1536
1845
  she.timeout = _timing.timeout;
1537
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
+ });
1538
1854
  sandboxModules.forEach((md) => {
1539
1855
  md(she, { scriptDomain, scriptName, scriptFile: name });
1540
1856
  });
1541
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
+
1542
1864
  log.debug(logLabel, 'contextifying sandbox');
1543
1865
  const context = vm.createContext(Sandbox);
1544
1866
 
@@ -1616,7 +1938,7 @@ function loadScript(file, origin) {
1616
1938
  }
1617
1939
  if (scripts[file]) {
1618
1940
  scriptOrigins.set(file, origin);
1619
- runScript(scripts[file], file, origin);
1941
+ runScript(scripts[file], file, origin, src.toString());
1620
1942
  broadcast({ type: 'script:running', path: makeLabel(file).slice(0, -1), running: true });
1621
1943
  }
1622
1944
  }
@@ -1692,6 +2014,7 @@ function unloadScript(file) {
1692
2014
  scriptTimers.delete(file);
1693
2015
  }
1694
2016
  scriptPublishes.delete(file);
2017
+ runtime.removeScript(file);
1695
2018
 
1696
2019
  // Remove store-based var:: subscriptions belonging to this script
1697
2020
  for (let i = varSubscriptions.length - 1; i >= 0; i--) {