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/dist/web/assets/index-BZWF5ORr.js +394 -0
- package/dist/web/assets/{index-Bu5o4KXc.css → index-CdLKmsX4.css} +1 -1
- package/dist/web/assets/{tsMode-DmHeR7H2.js → tsMode-BquNtbO5.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/src/config.js +21 -1
- package/src/index.js +370 -47
- package/src/lib/call-site.js +51 -0
- package/src/lib/command-topics.js +162 -0
- package/src/lib/command.js +188 -0
- package/src/lib/holidays.js +188 -0
- package/src/lib/runtime.js +350 -0
- package/src/lib/script-timers.js +2 -1
- package/src/lib/time.js +402 -0
- package/src/lib/timing.js +10 -5
- package/src/lib/update-channel.js +152 -0
- package/src/sandbox/stdlib.js +5 -0
- package/src/web/ai-tools.js +6 -2
- package/src/web/config-api.js +4 -3
- package/src/web/log-ws.js +49 -2
- package/src/web/prompts/api-ref-compact.md +2 -0
- package/src/web/prompts/api-ref.md +23 -0
- package/src/web/runtime-api.js +66 -0
- package/src/web/server.js +51 -19
- package/dist/web/assets/index-DtW7-hC9.js +0 -330
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
|
-
|
|
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
|
-
|
|
549
|
-
const
|
|
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
|
|
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
|
|
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: () => ({
|
|
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) => ({
|
|
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
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
:
|
|
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
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
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.
|
|
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--) {
|