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/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-20i8zomj.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 +532 -86
- 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/publish-changed.js +45 -0
- package/src/lib/runtime.js +350 -0
- package/src/lib/script-timers.js +49 -7
- package/src/lib/time.js +402 -0
- package/src/lib/timing.js +421 -0
- package/src/lib/update-channel.js +152 -0
- package/src/sandbox/stdlib.js +14 -0
- package/src/web/ai-tools.js +7 -3
- package/src/web/config-api.js +4 -3
- package/src/web/log-ws.js +49 -2
- package/src/web/prompts/api-ref-compact.md +6 -3
- package/src/web/prompts/api-ref.md +45 -4
- package/src/web/runtime-api.js +66 -0
- package/src/web/server.js +51 -19
- package/dist/web/assets/index-CqYTIaIG.js +0 -306
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
|
-
|
|
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
|
-
|
|
536
|
-
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;
|
|
537
608
|
_elHisto.reset();
|
|
538
609
|
const mem = process.memoryUsage();
|
|
539
|
-
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();
|
|
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
|
|
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: () => ({
|
|
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) => ({
|
|
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
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
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
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
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
|
-
|
|
1101
|
-
|
|
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
|
-
|
|
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 &&
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
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
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
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
|
|
1312
|
-
|
|
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
|
|
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
|
|
1727
|
+
return _timeout(wrapped, rest.length ? delay : 0);
|
|
1338
1728
|
},
|
|
1339
|
-
setInterval: (fn,
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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
|
|
1419
|
-
//
|
|
1420
|
-
she.setTimeout =
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
she.
|
|
1425
|
-
|
|
1426
|
-
|
|
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
|
}
|