smart-home-engine 2.0.0 → 2.1.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-Bvl2uECO.css → index-Bu5o4KXc.css} +1 -1
- package/dist/web/assets/index-DtW7-hC9.js +330 -0
- package/dist/web/assets/{tsMode-BJVHqGQB.js → tsMode-DmHeR7H2.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/src/index.js +194 -71
- package/src/lib/publish-changed.js +45 -0
- package/src/lib/script-timers.js +48 -7
- package/src/lib/timing.js +416 -0
- package/src/matter/thread-mesh.js +62 -23
- package/src/sandbox/stdlib.js +9 -0
- package/src/web/ai-tools.js +1 -1
- package/src/web/prompts/api-ref-compact.md +4 -3
- package/src/web/prompts/api-ref.md +22 -4
- package/dist/web/assets/index-DLP7FxoS.js +0 -305
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{m as O}from"./monaco-langs-DKJI_o9J.js";import{t as I}from"./index-
|
|
1
|
+
import{m as O}from"./monaco-langs-DKJI_o9J.js";import{t as I}from"./index-DtW7-hC9.js";/*!-----------------------------------------------------------------------------
|
|
2
2
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
3
3
|
* Version: 0.52.2(404545bded1df6ffa41ea0af4e8ddb219018c6c1)
|
|
4
4
|
* Released under the MIT license
|
package/dist/web/index.html
CHANGED
|
@@ -175,10 +175,10 @@
|
|
|
175
175
|
}
|
|
176
176
|
})();
|
|
177
177
|
</script>
|
|
178
|
-
<script type="module" crossorigin src="/assets/index-
|
|
178
|
+
<script type="module" crossorigin src="/assets/index-DtW7-hC9.js"></script>
|
|
179
179
|
<link rel="modulepreload" crossorigin href="/assets/monaco-langs-DKJI_o9J.js">
|
|
180
180
|
<link rel="stylesheet" crossorigin href="/assets/monaco-langs-DyX1CsEw.css">
|
|
181
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
181
|
+
<link rel="stylesheet" crossorigin href="/assets/index-Bu5o4KXc.css">
|
|
182
182
|
</head>
|
|
183
183
|
<body>
|
|
184
184
|
<div id="app"></div>
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -268,7 +268,9 @@ const { domain, vm, fs, path, suncalc } = modules;
|
|
|
268
268
|
const scheduler = modules['node-schedule'];
|
|
269
269
|
|
|
270
270
|
const StateStore = require('./lib/state-store');
|
|
271
|
-
const { trackTimeout } = require('./lib/script-timers');
|
|
271
|
+
const { trackTimeout, isValidDelay, describeDelay } = require('./lib/script-timers');
|
|
272
|
+
const timing = require('./lib/timing');
|
|
273
|
+
const publishChanged = require('./lib/publish-changed');
|
|
272
274
|
const sandboxModules = [];
|
|
273
275
|
const store = new StateStore();
|
|
274
276
|
if (typeof config.port !== 'undefined') require('./web/broker-api').setStore(store);
|
|
@@ -282,9 +284,20 @@ const varSubscriptions = []; // store-based var:: subscriptions { key, handler,
|
|
|
282
284
|
const scriptJobs = new Map(); // scriptFile → node-schedule Job[]
|
|
283
285
|
const scriptPublishes = new Map(); // scriptFile → Set<topic> a script published to (roadmap I19; capped)
|
|
284
286
|
const scriptTimers = new Map(); // scriptFile → Set<timer id>
|
|
287
|
+
const scriptUnloadHooks = new Map(); // scriptFile → Set<fn> run first when the script is unloaded
|
|
285
288
|
|
|
286
289
|
const _global = {};
|
|
287
290
|
|
|
291
|
+
// One store listener serves every she.on('var::…') of every script. Before, each call
|
|
292
|
+
// added a listener of its own, so Node warned about MaxListeners from the eleventh on; now a
|
|
293
|
+
// registration is an entry in varSubscriptions, which unloadScript removes.
|
|
294
|
+
store.on('change', (changedKey, val, obj, prevObj) => {
|
|
295
|
+
if (!changedKey.startsWith('var::')) return;
|
|
296
|
+
for (const sub of varSubscriptions.slice()) {
|
|
297
|
+
if (sub.key === changedKey) _dispatch(sub._script, () => sub.callback(val, obj, prevObj));
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
|
|
288
301
|
// ── Event-loop heartbeat ─────────────────────────────────────────────────────
|
|
289
302
|
let _blockingScript = null;
|
|
290
303
|
let _blockingScriptTs = 0; // timestamp when the last _dispatch() call finished
|
|
@@ -620,7 +633,7 @@ const introspect = {
|
|
|
620
633
|
publishes: [...(scriptPublishes.get(file) || [])],
|
|
621
634
|
jobs: (scriptJobs.get(file) || []).filter(Boolean).map((job) => ({ next: nextOf(job) })),
|
|
622
635
|
sunEvents: sunEvents.filter((e) => e._script === file).map((e) => ({ pattern: e.pattern, next: nextOf(e._job) })),
|
|
623
|
-
timers: [...(scriptTimers.get(file) || [])].map((t) => ({ due: t.due || null, every: t.every || null })),
|
|
636
|
+
timers: [...(scriptTimers.get(file) || [])].map((t) => ({ due: t.due || null, every: t.every || null, kind: t.kind || null, name: t.name || null })),
|
|
624
637
|
})),
|
|
625
638
|
stats: statsSnapshot,
|
|
626
639
|
health: healthSnapshot,
|
|
@@ -935,17 +948,25 @@ function stateChange(topic, state, oldState, msg) {
|
|
|
935
948
|
|
|
936
949
|
delay = Math.floor(delay);
|
|
937
950
|
|
|
938
|
-
setTimeout
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
951
|
+
// The delay timer is tracked like a script's own setTimeout, so a reload of
|
|
952
|
+
// the script between the message and the shifted callback cancels the callback instead
|
|
953
|
+
// 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
|
+
);
|
|
949
970
|
}
|
|
950
971
|
});
|
|
951
972
|
}
|
|
@@ -1004,6 +1025,14 @@ function runScript(script, name, _origin) {
|
|
|
1004
1025
|
if (!scriptTimers.has(name)) scriptTimers.set(name, new Set());
|
|
1005
1026
|
const _myJobs = scriptJobs.get(name);
|
|
1006
1027
|
const _myTimers = scriptTimers.get(name);
|
|
1028
|
+
const _myHooks = new Set();
|
|
1029
|
+
scriptUnloadHooks.set(name, _myHooks);
|
|
1030
|
+
const _myLastPayload = new Map(); // topic → last payload, for pub({ changed: true })
|
|
1031
|
+
let _unloaded = false;
|
|
1032
|
+
let _subCount = 0;
|
|
1033
|
+
_myHooks.add(() => {
|
|
1034
|
+
_unloaded = true;
|
|
1035
|
+
});
|
|
1007
1036
|
|
|
1008
1037
|
log.debug(logLabel, 'creating domain');
|
|
1009
1038
|
const scriptDomain = domain.create();
|
|
@@ -1014,6 +1043,41 @@ function runScript(script, name, _origin) {
|
|
|
1014
1043
|
return a !== null && typeof a === 'object' ? JSON.stringify(a) : a;
|
|
1015
1044
|
}
|
|
1016
1045
|
|
|
1046
|
+
/**
|
|
1047
|
+
* The script's one-shot timer: the delay is validated and a bad one is logged with the script's
|
|
1048
|
+
* name, the callback runs in the script's domain, the id is tracked for the unload
|
|
1049
|
+
* and carries kind and name for the timer list. Every timer API of the sandbox goes through here.
|
|
1050
|
+
*/
|
|
1051
|
+
function _timeout(fn, delay, meta = {}, api = 'setTimeout') {
|
|
1052
|
+
if (_unloaded) {
|
|
1053
|
+
log.debug(logLabel, `${api} after unload ignored`);
|
|
1054
|
+
return null;
|
|
1055
|
+
}
|
|
1056
|
+
return trackTimeout(
|
|
1057
|
+
_myTimers,
|
|
1058
|
+
scriptDomain.bind(() => _dispatch(name, fn)),
|
|
1059
|
+
delay,
|
|
1060
|
+
{
|
|
1061
|
+
warn: (msg) => log.warn(logLabel, msg),
|
|
1062
|
+
api,
|
|
1063
|
+
kind: meta.kind,
|
|
1064
|
+
name: meta.name,
|
|
1065
|
+
},
|
|
1066
|
+
);
|
|
1067
|
+
}
|
|
1068
|
+
function _clearTimeout(id) {
|
|
1069
|
+
_myTimers.delete(id);
|
|
1070
|
+
clearTimeout(id);
|
|
1071
|
+
}
|
|
1072
|
+
const _timing = timing.createTimingHelpers({
|
|
1073
|
+
setTimeout: (fn, ms, meta) => _timeout(fn, ms, meta),
|
|
1074
|
+
clearTimeout: _clearTimeout,
|
|
1075
|
+
onUnload: (fn) => {
|
|
1076
|
+
_myHooks.add(fn);
|
|
1077
|
+
return () => _myHooks.delete(fn);
|
|
1078
|
+
},
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1017
1081
|
const she = {
|
|
1018
1082
|
global: _global,
|
|
1019
1083
|
|
|
@@ -1087,36 +1151,52 @@ function runScript(script, name, _origin) {
|
|
|
1087
1151
|
} else if (rest.length > 2) {
|
|
1088
1152
|
throw new Error('wrong number of arguments');
|
|
1089
1153
|
}
|
|
1154
|
+
options = options || {};
|
|
1090
1155
|
|
|
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
|
-
}
|
|
1156
|
+
let topics;
|
|
1157
|
+
if (typeof topic === 'string') topics = [topic];
|
|
1158
|
+
else if (typeof topic === 'object' && topic.length > 0) topics = Array.prototype.slice.call(topic);
|
|
1159
|
+
else return;
|
|
1099
1160
|
|
|
1100
|
-
|
|
1101
|
-
|
|
1161
|
+
if (typeof options.condition === 'string') {
|
|
1162
|
+
if (options.condition.indexOf('\n') !== -1) {
|
|
1163
|
+
throw new Error('options.condition string must be one-line javascript');
|
|
1102
1164
|
}
|
|
1165
|
+
/* eslint-disable no-new-func */
|
|
1166
|
+
options.condition = new Function('topic', 'val', 'obj', 'objPrev', 'msg', 'return ' + options.condition + ';');
|
|
1167
|
+
}
|
|
1103
1168
|
|
|
1104
|
-
|
|
1169
|
+
if (typeof options.condition === 'function') {
|
|
1170
|
+
options.condition = scriptDomain.bind(options.condition);
|
|
1171
|
+
}
|
|
1105
1172
|
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1173
|
+
const boundCallback = typeof callback === 'function' && scriptDomain.bind(callback);
|
|
1174
|
+
// what runs the callback for one event; with opts.debounce or opts.throttle the events of
|
|
1175
|
+
// 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];
|
|
1178
|
+
const limited = timing.subscriptionLimiter(options, deliver, _timing, label);
|
|
1179
|
+
if (limited) deliver = limited;
|
|
1180
|
+
|
|
1181
|
+
for (const tp of topics) {
|
|
1182
|
+
subscriptions.push({ topic: tp, options, callback: boundCallback, deliver, _script: name });
|
|
1183
|
+
_subCount++;
|
|
1184
|
+
if (_subCount === 1000) {
|
|
1185
|
+
log.warn(
|
|
1186
|
+
logLabel,
|
|
1187
|
+
'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',
|
|
1188
|
+
);
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
if (options.retain && boundCallback) {
|
|
1192
|
+
if (store.has('mqtt::' + tp)) {
|
|
1193
|
+
deliver(tp, store.get('mqtt::' + tp), store.getObject('mqtt::' + tp));
|
|
1194
|
+
} else if (tp.includes('+') || tp.endsWith('#')) {
|
|
1195
|
+
for (const [t, obj] of store.mqttEntries()) {
|
|
1196
|
+
if (mqttWildcards(t, tp)) deliver(t, obj.val, obj);
|
|
1112
1197
|
}
|
|
1113
1198
|
}
|
|
1114
1199
|
}
|
|
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
1200
|
}
|
|
1121
1201
|
},
|
|
1122
1202
|
|
|
@@ -1179,17 +1259,30 @@ function runScript(script, name, _origin) {
|
|
|
1179
1259
|
return;
|
|
1180
1260
|
}
|
|
1181
1261
|
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1262
|
+
const run = options.random
|
|
1263
|
+
? () => {
|
|
1264
|
+
// Track the random-delay timer so it is cancelled on unload
|
|
1265
|
+
// 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');
|
|
1267
|
+
}
|
|
1268
|
+
: () => _dispatch(name, scriptDomain.bind(callback));
|
|
1269
|
+
let job = null;
|
|
1270
|
+
job = scheduler.scheduleJob(pattern, () => {
|
|
1271
|
+
// A one-shot job (a Date) is done after this run: forget it, so that a script
|
|
1272
|
+
// scheduling a Date per event does not keep every fired job.
|
|
1273
|
+
if (job && job.nextInvocation() === null) {
|
|
1274
|
+
const i = _myJobs.indexOf(job);
|
|
1275
|
+
if (i !== -1) _myJobs.splice(i, 1);
|
|
1276
|
+
}
|
|
1277
|
+
run();
|
|
1278
|
+
});
|
|
1279
|
+
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
|
+
log.warn(logLabel, `she.schedule: nothing scheduled for ${shown} — not a valid pattern, or a date in the past`);
|
|
1283
|
+
return;
|
|
1192
1284
|
}
|
|
1285
|
+
_myJobs.push(job);
|
|
1193
1286
|
},
|
|
1194
1287
|
|
|
1195
1288
|
/**
|
|
@@ -1200,6 +1293,8 @@ function runScript(script, name, _origin) {
|
|
|
1200
1293
|
* @param {Object} [options] - the options to publish with
|
|
1201
1294
|
* @param {number} [options.qos=0] - QoS Level
|
|
1202
1295
|
* @param {boolean} [options.retain=false] - retain flag
|
|
1296
|
+
* @param {boolean} [options.changed=false] - publish only when the payload differs from the last one this
|
|
1297
|
+
* script published to the topic (before the first one: from the topic's current value in the state store)
|
|
1203
1298
|
*/
|
|
1204
1299
|
mqttpub: function Sandbox_mqttpub(topic, payload, options) {
|
|
1205
1300
|
if (typeof topic === 'object' && topic.length > 0) {
|
|
@@ -1218,7 +1313,13 @@ function runScript(script, name, _origin) {
|
|
|
1218
1313
|
} else {
|
|
1219
1314
|
payload = String(payload);
|
|
1220
1315
|
}
|
|
1316
|
+
let changed = false;
|
|
1317
|
+
if (options && typeof options === 'object' && 'changed' in options) {
|
|
1318
|
+
({ changed, ...options } = options);
|
|
1319
|
+
}
|
|
1320
|
+
if (changed && publishChanged.isRepeat(_myLastPayload, topic, payload, (t) => store.getObject('mqtt::' + t))) return;
|
|
1221
1321
|
if (!mqtt || !connected) return; // silently drop when MQTT is not connected
|
|
1322
|
+
publishChanged.remember(_myLastPayload, topic, payload);
|
|
1222
1323
|
mqtt.publish(topic, payload, options);
|
|
1223
1324
|
},
|
|
1224
1325
|
|
|
@@ -1308,11 +1409,8 @@ function runScript(script, name, _origin) {
|
|
|
1308
1409
|
} else if (key.startsWith('var::')) {
|
|
1309
1410
|
const varStoreKey = 'var::' + key.slice(5);
|
|
1310
1411
|
const boundCb = scriptDomain.bind(callback);
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
};
|
|
1314
|
-
store.on('change', varHandler);
|
|
1315
|
-
varSubscriptions.push({ key: varStoreKey, handler: varHandler, _script: name });
|
|
1412
|
+
// served by the one store listener at module level
|
|
1413
|
+
varSubscriptions.push({ key: varStoreKey, callback: boundCb, _script: name });
|
|
1316
1414
|
// Fire immediately if var already has a value (retain semantics)
|
|
1317
1415
|
const currentVarObj = store.getObject(varStoreKey);
|
|
1318
1416
|
if (currentVarObj !== undefined) boundCb(currentVarObj.val, currentVarObj, undefined);
|
|
@@ -1332,22 +1430,30 @@ function runScript(script, name, _origin) {
|
|
|
1332
1430
|
she.log = she.info;
|
|
1333
1431
|
|
|
1334
1432
|
const Sandbox = {
|
|
1335
|
-
setTimeout
|
|
1433
|
+
// setTimeout(fn) without a delay is Node's "next tick"; a delay that is given but is not a
|
|
1434
|
+
// number is a mistake and logged with the script's name
|
|
1435
|
+
setTimeout: (fn, ...rest) => {
|
|
1436
|
+
const [delay, ...args] = rest;
|
|
1336
1437
|
const wrapped = args.length ? () => fn(...args) : fn;
|
|
1337
|
-
return
|
|
1438
|
+
return _timeout(wrapped, rest.length ? delay : 0);
|
|
1338
1439
|
},
|
|
1339
|
-
setInterval: (fn,
|
|
1440
|
+
setInterval: (fn, ...rest) => {
|
|
1441
|
+
let [delay, ...args] = rest;
|
|
1442
|
+
if (!isValidDelay(delay)) {
|
|
1443
|
+
log.warn(logLabel, `setInterval: delay is not a number of milliseconds (got ${describeDelay(delay)}), using 0`);
|
|
1444
|
+
delay = 0;
|
|
1445
|
+
}
|
|
1340
1446
|
const wrapped = args.length ? () => fn(...args) : fn;
|
|
1341
|
-
const id = setInterval(
|
|
1447
|
+
const id = setInterval(
|
|
1448
|
+
scriptDomain.bind(() => _dispatch(name, wrapped)),
|
|
1449
|
+
delay,
|
|
1450
|
+
);
|
|
1342
1451
|
id.every = delay;
|
|
1343
1452
|
id.due = Date.now() + delay;
|
|
1344
1453
|
_myTimers.add(id);
|
|
1345
1454
|
return id;
|
|
1346
1455
|
},
|
|
1347
|
-
clearTimeout:
|
|
1348
|
-
_myTimers.delete(id);
|
|
1349
|
-
clearTimeout(id);
|
|
1350
|
-
},
|
|
1456
|
+
clearTimeout: _clearTimeout,
|
|
1351
1457
|
clearInterval: (id) => {
|
|
1352
1458
|
_myTimers.delete(id);
|
|
1353
1459
|
clearInterval(id);
|
|
@@ -1356,7 +1462,11 @@ function runScript(script, name, _origin) {
|
|
|
1356
1462
|
Buffer,
|
|
1357
1463
|
|
|
1358
1464
|
require(md) {
|
|
1359
|
-
|
|
1465
|
+
// A relative path is cached by Node under its resolved file; caching it here under the bare
|
|
1466
|
+
// specifier made two scripts requiring './util' from different directories share the first
|
|
1467
|
+
// one's module. The cache here is for the daemon's own modules and packages.
|
|
1468
|
+
const relative = /^\.\.?\//.test(md);
|
|
1469
|
+
if (!relative && modules[md]) {
|
|
1360
1470
|
return modules[md];
|
|
1361
1471
|
}
|
|
1362
1472
|
// Failures throw (Cannot find module, syntax errors in the required
|
|
@@ -1384,7 +1494,7 @@ function runScript(script, name, _origin) {
|
|
|
1384
1494
|
}
|
|
1385
1495
|
}
|
|
1386
1496
|
}
|
|
1387
|
-
modules[md] = result;
|
|
1497
|
+
if (!relative) modules[md] = result;
|
|
1388
1498
|
return result;
|
|
1389
1499
|
},
|
|
1390
1500
|
|
|
@@ -1415,16 +1525,16 @@ function runScript(script, name, _origin) {
|
|
|
1415
1525
|
},
|
|
1416
1526
|
has: (p) => secrets.has(p),
|
|
1417
1527
|
});
|
|
1418
|
-
// she.setTimeout / she.clearTimeout — tracked
|
|
1419
|
-
//
|
|
1420
|
-
she.setTimeout =
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
she.
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1528
|
+
// she.setTimeout / she.clearTimeout — the sandbox's tracked timers under the she object: the
|
|
1529
|
+
// documented aliases of the globals, and what stdlib and the sandbox modules use.
|
|
1530
|
+
she.setTimeout = Sandbox.setTimeout;
|
|
1531
|
+
she.clearTimeout = Sandbox.clearTimeout;
|
|
1532
|
+
// Timing helpers: their timers are the script's, so a reload clears them and
|
|
1533
|
+
// the timer list shows them with kind and name.
|
|
1534
|
+
she.debounce = _timing.debounce;
|
|
1535
|
+
she.throttle = _timing.throttle;
|
|
1536
|
+
she.timeout = _timing.timeout;
|
|
1537
|
+
she.retry = _timing.retry;
|
|
1428
1538
|
sandboxModules.forEach((md) => {
|
|
1429
1539
|
md(she, { scriptDomain, scriptName, scriptFile: name });
|
|
1430
1540
|
});
|
|
@@ -1523,6 +1633,20 @@ function unloadScript(file) {
|
|
|
1523
1633
|
let removedCallbacks = 0;
|
|
1524
1634
|
let removedTimers = 0;
|
|
1525
1635
|
|
|
1636
|
+
// The script's unload hooks run first: the sandbox marks itself unloaded, a debounce
|
|
1637
|
+
// with flushOnUnload runs its pending call, a retry stops. Then the timers below are cleared.
|
|
1638
|
+
const hooks = scriptUnloadHooks.get(file);
|
|
1639
|
+
if (hooks) {
|
|
1640
|
+
scriptUnloadHooks.delete(file);
|
|
1641
|
+
for (const hook of hooks) {
|
|
1642
|
+
try {
|
|
1643
|
+
_dispatch(file, hook);
|
|
1644
|
+
} catch (err) {
|
|
1645
|
+
log.error(unloadLabel, 'error while unloading: ' + (err && err.stack ? err.stack : err));
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1526
1650
|
// Remove MQTT subscriptions belonging to this script
|
|
1527
1651
|
for (let i = subscriptions.length - 1; i >= 0; i--) {
|
|
1528
1652
|
if (subscriptions[i]._script === file) {
|
|
@@ -1572,7 +1696,6 @@ function unloadScript(file) {
|
|
|
1572
1696
|
// Remove store-based var:: subscriptions belonging to this script
|
|
1573
1697
|
for (let i = varSubscriptions.length - 1; i >= 0; i--) {
|
|
1574
1698
|
if (varSubscriptions[i]._script === file) {
|
|
1575
|
-
store.removeListener('change', varSubscriptions[i].handler);
|
|
1576
1699
|
varSubscriptions.splice(i, 1);
|
|
1577
1700
|
removedCallbacks++;
|
|
1578
1701
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* `she.mqtt.pub(topic, payload, { changed: true })`: publish only when the payload
|
|
4
|
+
* differs from what this script last sent to the topic. Before the first publish of a run, the
|
|
5
|
+
* comparison falls back to the topic's current value in the state store — for a command topic
|
|
6
|
+
* (`<prefix>/set/<name>`) to its `status` sibling, which is where a variable's or an adapter's
|
|
7
|
+
* state lives. The last payloads are kept per script, so a hot reload starts over with the store.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const parsePayload = require('./parse-payload');
|
|
11
|
+
|
|
12
|
+
const CAP = 200; // topics remembered per script, like the publish record
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {Map<string,string>} last topic → the payload this script last published
|
|
16
|
+
* @param {string} topic
|
|
17
|
+
* @param {string} payload the stringified payload
|
|
18
|
+
* @param {Function} lookup (topic) → the state object { val, ts, lc } or undefined
|
|
19
|
+
* @returns {boolean} true when the publish would repeat the last one
|
|
20
|
+
*/
|
|
21
|
+
function isRepeat(last, topic, payload, lookup) {
|
|
22
|
+
if (last.has(topic)) return last.get(topic) === payload;
|
|
23
|
+
let state = lookup(topic);
|
|
24
|
+
if (state === undefined) {
|
|
25
|
+
const parts = topic.split('/');
|
|
26
|
+
if (parts.length > 2 && parts[1] === 'set') {
|
|
27
|
+
parts[1] = 'status';
|
|
28
|
+
state = lookup(parts.join('/'));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (state === undefined || state === null) return false;
|
|
32
|
+
return JSON.stringify(parsePayload(payload).val) === JSON.stringify(state.val);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Remember what was published; at most CAP topics per script.
|
|
37
|
+
* @param {Map<string,string>} last
|
|
38
|
+
* @param {string} topic
|
|
39
|
+
* @param {string} payload
|
|
40
|
+
*/
|
|
41
|
+
function remember(last, topic, payload) {
|
|
42
|
+
if (last.size < CAP || last.has(topic)) last.set(topic, payload);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = { isRepeat, remember, CAP };
|
package/src/lib/script-timers.js
CHANGED
|
@@ -7,21 +7,62 @@
|
|
|
7
7
|
* every timeout that ran normally stayed in the set for the life of the daemon — with its callback
|
|
8
8
|
* and everything the callback captured. A script that starts a "switch off after N minutes" timer
|
|
9
9
|
* per event leaked hundreds of them an hour, and the heap ran full after about twelve days.
|
|
10
|
-
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A delay a script passed to a timer API: a finite number of milliseconds, not negative.
|
|
14
|
+
* Node coerces anything else to NaN, warns once per process without naming the caller and fires
|
|
15
|
+
* after 1 ms — a swapped argument order or an undefined config field became a silent 1 ms timer.
|
|
16
|
+
* @param {*} delay
|
|
17
|
+
* @returns {boolean}
|
|
18
|
+
*/
|
|
19
|
+
function isValidDelay(delay) {
|
|
20
|
+
return typeof delay === 'number' && Number.isFinite(delay) && delay >= 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* How an invalid delay is described in the warning: `undefined`, `NaN`, `"5m"`, `an object`.
|
|
25
|
+
* @param {*} delay
|
|
26
|
+
* @returns {string}
|
|
27
|
+
*/
|
|
28
|
+
function describeDelay(delay) {
|
|
29
|
+
if (typeof delay === 'string') return JSON.stringify(delay);
|
|
30
|
+
if (typeof delay === 'function') return 'a function';
|
|
31
|
+
if (delay !== null && typeof delay === 'object') return Array.isArray(delay) ? 'an array' : 'an object';
|
|
32
|
+
return String(delay);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
11
36
|
* @param {Set} timers the script's timer set
|
|
12
37
|
* @param {Function} fn called when the timer fires
|
|
13
|
-
* @param {number} delay ms
|
|
14
|
-
* @param {Function} [
|
|
15
|
-
* @
|
|
38
|
+
* @param {number} delay ms; an invalid delay is reported through `opts.warn` and runs as 0
|
|
39
|
+
* @param {object|Function} [opts] options, or (legacy) the setTimeout function to use
|
|
40
|
+
* @param {Function} [opts.setTimeoutFn] for tests
|
|
41
|
+
* @param {Function} [opts.warn] called with a message when the delay is not a number (the caller
|
|
42
|
+
* adds the script's label); without it an invalid delay is silently 0
|
|
43
|
+
* @param {string} [opts.api] the API name for the warning, default `setTimeout`
|
|
44
|
+
* @param {string} [opts.kind] shown in the timer list: `debounce`, `throttle`, `timeout`, `retry`
|
|
45
|
+
* @param {string} [opts.name] shown in the timer list next to the kind
|
|
46
|
+
* @returns the timer id (what clearTimeout takes); carries `due`, and `kind`/`name` when given
|
|
16
47
|
*/
|
|
17
|
-
function trackTimeout(timers, fn, delay,
|
|
48
|
+
function trackTimeout(timers, fn, delay, opts = {}) {
|
|
49
|
+
if (typeof opts === 'function') opts = { setTimeoutFn: opts };
|
|
50
|
+
const setTimeoutFn = opts.setTimeoutFn || setTimeout;
|
|
51
|
+
if (!isValidDelay(delay)) {
|
|
52
|
+
if (typeof opts.warn === 'function') opts.warn(`${opts.api || 'setTimeout'}: delay is not a number of milliseconds (got ${describeDelay(delay)}), using 0`);
|
|
53
|
+
delay = 0;
|
|
54
|
+
}
|
|
18
55
|
const id = setTimeoutFn(() => {
|
|
19
56
|
timers.delete(id);
|
|
20
57
|
fn();
|
|
21
58
|
}, delay);
|
|
22
|
-
if (id && typeof id === 'object')
|
|
59
|
+
if (id && typeof id === 'object') {
|
|
60
|
+
id.due = Date.now() + delay; // when it fires (roadmap I28: list_timers)
|
|
61
|
+
if (opts.kind) id.kind = opts.kind;
|
|
62
|
+
if (opts.name) id.name = opts.name;
|
|
63
|
+
}
|
|
23
64
|
timers.add(id);
|
|
24
65
|
return id;
|
|
25
66
|
}
|
|
26
67
|
|
|
27
|
-
module.exports = { trackTimeout };
|
|
68
|
+
module.exports = { trackTimeout, isValidDelay, describeDelay };
|