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
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Timing helpers for scripts: debounce, throttle, a restartable timeout, retry with
|
|
4
|
+
* backoff, and the debounce/throttle of a subscription.
|
|
5
|
+
*
|
|
6
|
+
* The helpers do not touch Node's timers. They take the script's tracked timer primitives, so every
|
|
7
|
+
* pending run is cleared when the script is unloaded or hot-reloaded and shows up in the timer list
|
|
8
|
+
* with its kind and name. Errors thrown by the wrapped function propagate to the caller — the
|
|
9
|
+
* sandbox runs the timer callbacks inside the script's domain, which logs them with the script name.
|
|
10
|
+
*
|
|
11
|
+
* Units: the function helpers take milliseconds like setTimeout; the subscription options take
|
|
12
|
+
* seconds like `shift` and `random`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const { isValidDelay, describeDelay } = require('./script-timers');
|
|
16
|
+
|
|
17
|
+
function assertFn(fn, api, what = 'fn') {
|
|
18
|
+
if (typeof fn !== 'function') throw new TypeError(`${api}: ${what} must be a function (got ${describeDelay(fn)})`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function assertDelay(ms, api, what) {
|
|
22
|
+
if (!isValidDelay(ms)) throw new TypeError(`${api}: ${what} must be a number of milliseconds (got ${describeDelay(ms)})`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {object} prim the script's primitives
|
|
27
|
+
* @param {Function} prim.setTimeout (fn, ms, meta) → id; meta is { kind, name } for the timer list
|
|
28
|
+
* @param {Function} prim.clearTimeout (id)
|
|
29
|
+
* @param {Function} [prim.now] → ms, default Date.now
|
|
30
|
+
* @param {Function} [prim.onUnload] (fn) → off(); fn runs when the script is unloaded
|
|
31
|
+
* @returns {{ debounce: Function, throttle: Function, timeout: Function, retry: Function }}
|
|
32
|
+
*/
|
|
33
|
+
function createTimingHelpers(prim) {
|
|
34
|
+
const { setTimeout: setTimer, clearTimeout: clearTimer } = prim;
|
|
35
|
+
const now = prim.now || Date.now;
|
|
36
|
+
const onUnload = typeof prim.onUnload === 'function' ? prim.onUnload : () => () => {};
|
|
37
|
+
// the script line a helper was created on, captured once per helper and carried in its timer meta
|
|
38
|
+
// (the stack at re-arm time points into a callback, not to the registration)
|
|
39
|
+
const lineAt = typeof prim.line === 'function' ? prim.line : () => null;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Bundle calls: run fn with the arguments of the last call once `wait` ms passed without a new
|
|
43
|
+
* call — at the latest `maxWait` ms after the first call of a bundle.
|
|
44
|
+
* @param {Function} fn
|
|
45
|
+
* @param {number} wait ms
|
|
46
|
+
* @param {object} [opts]
|
|
47
|
+
* @param {number} [opts.maxWait] ms; run at the latest this long after the first bundled call
|
|
48
|
+
* @param {boolean} [opts.leading=false] run on the first call of a bundle as well
|
|
49
|
+
* @param {boolean} [opts.trailing=true] run after the quiet period (with the last call's arguments)
|
|
50
|
+
* @param {boolean} [opts.flushOnUnload=false] run a pending call when the script is unloaded
|
|
51
|
+
* @param {string} [opts.name] shown in the timer list
|
|
52
|
+
* @returns {Function} wrapped(...args) with cancel(), flush(), pending()
|
|
53
|
+
*/
|
|
54
|
+
function debounce(fn, wait, opts = {}) {
|
|
55
|
+
const api = 'she.debounce(fn, wait, [opts])';
|
|
56
|
+
assertFn(fn, api);
|
|
57
|
+
assertDelay(wait, api, 'wait');
|
|
58
|
+
const { leading = false, trailing = true, maxWait, flushOnUnload = false, _onSettle } = opts;
|
|
59
|
+
if (maxWait !== undefined) assertDelay(maxWait, api, 'opts.maxWait');
|
|
60
|
+
if (!leading && !trailing) throw new TypeError(`${api}: opts.leading and opts.trailing cannot both be false`);
|
|
61
|
+
const name = opts.name || fn.name || undefined;
|
|
62
|
+
const line = opts.line !== undefined ? opts.line : lineAt();
|
|
63
|
+
const meta = { kind: 'debounce', name, line };
|
|
64
|
+
const maxMeta = { kind: 'debounce maxWait', name, line };
|
|
65
|
+
|
|
66
|
+
let timer = null;
|
|
67
|
+
let maxTimer = null;
|
|
68
|
+
let lastArgs = null;
|
|
69
|
+
let waiting = false; // a call waits to run
|
|
70
|
+
let result;
|
|
71
|
+
|
|
72
|
+
function settle() {
|
|
73
|
+
lastArgs = null;
|
|
74
|
+
waiting = false;
|
|
75
|
+
if (timer === null && maxTimer === null && typeof _onSettle === 'function') _onSettle();
|
|
76
|
+
}
|
|
77
|
+
function clearTimers() {
|
|
78
|
+
if (timer !== null) clearTimer(timer);
|
|
79
|
+
if (maxTimer !== null) clearTimer(maxTimer);
|
|
80
|
+
timer = maxTimer = null;
|
|
81
|
+
}
|
|
82
|
+
function invoke() {
|
|
83
|
+
const args = lastArgs;
|
|
84
|
+
lastArgs = null;
|
|
85
|
+
waiting = false;
|
|
86
|
+
result = fn(...args);
|
|
87
|
+
if (timer === null && maxTimer === null && typeof _onSettle === 'function') _onSettle();
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
function onWait() {
|
|
91
|
+
timer = null;
|
|
92
|
+
if (maxTimer !== null) {
|
|
93
|
+
clearTimer(maxTimer);
|
|
94
|
+
maxTimer = null;
|
|
95
|
+
}
|
|
96
|
+
if (trailing && waiting) invoke();
|
|
97
|
+
else settle();
|
|
98
|
+
}
|
|
99
|
+
function onMaxWait() {
|
|
100
|
+
maxTimer = null;
|
|
101
|
+
if (timer !== null) {
|
|
102
|
+
clearTimer(timer);
|
|
103
|
+
timer = null;
|
|
104
|
+
}
|
|
105
|
+
if (waiting) invoke();
|
|
106
|
+
else settle();
|
|
107
|
+
}
|
|
108
|
+
function wrapped(...args) {
|
|
109
|
+
const idle = timer === null;
|
|
110
|
+
lastArgs = args;
|
|
111
|
+
if (idle && maxWait !== undefined && maxTimer === null) maxTimer = setTimer(onMaxWait, maxWait, maxMeta);
|
|
112
|
+
if (timer !== null) clearTimer(timer);
|
|
113
|
+
timer = setTimer(onWait, wait, meta);
|
|
114
|
+
if (idle && leading) invoke();
|
|
115
|
+
else waiting = true;
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
wrapped.cancel = () => {
|
|
119
|
+
clearTimers();
|
|
120
|
+
settle();
|
|
121
|
+
};
|
|
122
|
+
wrapped.flush = () => {
|
|
123
|
+
clearTimers();
|
|
124
|
+
if (waiting) return invoke();
|
|
125
|
+
settle();
|
|
126
|
+
return result;
|
|
127
|
+
};
|
|
128
|
+
wrapped.pending = () => waiting;
|
|
129
|
+
if (flushOnUnload) onUnload(() => wrapped.flush());
|
|
130
|
+
return wrapped;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Rate-limit calls: run fn at most once per `wait` ms — on the first call of a window
|
|
135
|
+
* (leading) and, when more calls came in, once more with the last arguments when the window ends
|
|
136
|
+
* (trailing).
|
|
137
|
+
* @param {Function} fn
|
|
138
|
+
* @param {number} wait ms
|
|
139
|
+
* @param {object} [opts]
|
|
140
|
+
* @param {boolean} [opts.leading=true]
|
|
141
|
+
* @param {boolean} [opts.trailing=true]
|
|
142
|
+
* @param {string} [opts.name] shown in the timer list
|
|
143
|
+
* @returns {Function} wrapped(...args) with cancel(), flush(), pending()
|
|
144
|
+
*/
|
|
145
|
+
function throttle(fn, wait, opts = {}) {
|
|
146
|
+
const api = 'she.throttle(fn, wait, [opts])';
|
|
147
|
+
assertFn(fn, api);
|
|
148
|
+
assertDelay(wait, api, 'wait');
|
|
149
|
+
const { leading = true, trailing = true, _onSettle } = opts;
|
|
150
|
+
if (!leading && !trailing) throw new TypeError(`${api}: opts.leading and opts.trailing cannot both be false`);
|
|
151
|
+
const meta = { kind: 'throttle', name: opts.name || fn.name || undefined, line: opts.line !== undefined ? opts.line : lineAt() };
|
|
152
|
+
|
|
153
|
+
let timer = null;
|
|
154
|
+
let lastArgs = null;
|
|
155
|
+
let waiting = false;
|
|
156
|
+
let result;
|
|
157
|
+
|
|
158
|
+
function settle() {
|
|
159
|
+
lastArgs = null;
|
|
160
|
+
waiting = false;
|
|
161
|
+
if (timer === null && typeof _onSettle === 'function') _onSettle();
|
|
162
|
+
}
|
|
163
|
+
function invoke() {
|
|
164
|
+
const args = lastArgs;
|
|
165
|
+
lastArgs = null;
|
|
166
|
+
waiting = false;
|
|
167
|
+
result = fn(...args);
|
|
168
|
+
if (timer === null && typeof _onSettle === 'function') _onSettle();
|
|
169
|
+
return result;
|
|
170
|
+
}
|
|
171
|
+
function onWindowEnd() {
|
|
172
|
+
timer = null;
|
|
173
|
+
if (trailing && waiting) {
|
|
174
|
+
// the trailing run opens a new window, so runs stay `wait` apart
|
|
175
|
+
timer = setTimer(onWindowEnd, wait, meta);
|
|
176
|
+
invoke();
|
|
177
|
+
} else settle();
|
|
178
|
+
}
|
|
179
|
+
function wrapped(...args) {
|
|
180
|
+
lastArgs = args;
|
|
181
|
+
if (timer === null) {
|
|
182
|
+
timer = setTimer(onWindowEnd, wait, meta);
|
|
183
|
+
if (leading) invoke();
|
|
184
|
+
else waiting = true;
|
|
185
|
+
} else waiting = true;
|
|
186
|
+
return result;
|
|
187
|
+
}
|
|
188
|
+
wrapped.cancel = () => {
|
|
189
|
+
if (timer !== null) clearTimer(timer);
|
|
190
|
+
timer = null;
|
|
191
|
+
settle();
|
|
192
|
+
};
|
|
193
|
+
wrapped.flush = () => {
|
|
194
|
+
if (timer !== null) clearTimer(timer);
|
|
195
|
+
timer = null;
|
|
196
|
+
if (waiting) return invoke();
|
|
197
|
+
settle();
|
|
198
|
+
return result;
|
|
199
|
+
};
|
|
200
|
+
wrapped.pending = () => waiting;
|
|
201
|
+
return wrapped;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* A debounce or throttle with a separate state per key. The key is the first argument of the
|
|
206
|
+
* call unless opts.key(...args) says otherwise. Idle keys are forgotten, so a wildcard
|
|
207
|
+
* subscription does not accumulate state.
|
|
208
|
+
*/
|
|
209
|
+
function keyed(factory, api) {
|
|
210
|
+
return function keyedHelper(fn, wait, opts = {}) {
|
|
211
|
+
assertFn(fn, api);
|
|
212
|
+
const { key: keyFn, flushOnUnload = false, name, ...rest } = opts;
|
|
213
|
+
if (keyFn !== undefined) assertFn(keyFn, api, 'opts.key');
|
|
214
|
+
const states = new Map();
|
|
215
|
+
const forKey = (key) => {
|
|
216
|
+
let state = states.get(key);
|
|
217
|
+
if (!state) {
|
|
218
|
+
state = factory(fn, wait, { ...rest, name: name ? `${name} ${key}` : String(key), _onSettle: () => states.delete(key) });
|
|
219
|
+
states.set(key, state);
|
|
220
|
+
}
|
|
221
|
+
return state;
|
|
222
|
+
};
|
|
223
|
+
function wrapped(...args) {
|
|
224
|
+
const key = keyFn ? keyFn(...args) : args[0];
|
|
225
|
+
return forKey(key)(...args);
|
|
226
|
+
}
|
|
227
|
+
// without a key: every key
|
|
228
|
+
wrapped.cancel = (key) => {
|
|
229
|
+
if (key !== undefined) states.get(key)?.cancel();
|
|
230
|
+
else for (const state of [...states.values()]) state.cancel();
|
|
231
|
+
};
|
|
232
|
+
wrapped.flush = (key) => {
|
|
233
|
+
if (key !== undefined) return states.get(key)?.flush();
|
|
234
|
+
for (const state of [...states.values()]) state.flush();
|
|
235
|
+
return undefined;
|
|
236
|
+
};
|
|
237
|
+
wrapped.pending = (key) => {
|
|
238
|
+
if (key !== undefined) return states.get(key)?.pending() ?? false;
|
|
239
|
+
for (const state of states.values()) if (state.pending()) return true;
|
|
240
|
+
return false;
|
|
241
|
+
};
|
|
242
|
+
wrapped.keys = () => [...states.keys()];
|
|
243
|
+
// validate eagerly, so a wrong wait fails at load time and not on the first call
|
|
244
|
+
factory(fn, wait, { ...rest, _onSettle: undefined }).cancel();
|
|
245
|
+
if (flushOnUnload) onUnload(() => wrapped.flush());
|
|
246
|
+
return wrapped;
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
debounce.keyed = keyed(debounce, 'she.debounce.keyed(fn, wait, [opts])');
|
|
250
|
+
throttle.keyed = keyed(throttle, 'she.throttle.keyed(fn, wait, [opts])');
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* A restartable one-shot timer. It is created idle: restart() arms it, another restart() starts
|
|
254
|
+
* the delay over, cancel() disarms it.
|
|
255
|
+
* @param {number} ms
|
|
256
|
+
* @param {Function} cb
|
|
257
|
+
* @param {object} [opts]
|
|
258
|
+
* @param {string} [opts.name] shown in the timer list
|
|
259
|
+
* @returns {{ restart: Function, start: Function, cancel: Function, pending: Function, remaining: Function }}
|
|
260
|
+
*/
|
|
261
|
+
function timeout(ms, cb, opts = {}) {
|
|
262
|
+
const api = 'she.timeout(ms, cb, [opts])';
|
|
263
|
+
assertDelay(ms, api, 'ms');
|
|
264
|
+
assertFn(cb, api, 'cb');
|
|
265
|
+
const meta = { kind: 'timeout', name: opts.name || cb.name || undefined, line: lineAt() };
|
|
266
|
+
let id = null;
|
|
267
|
+
let due = 0;
|
|
268
|
+
const handle = {
|
|
269
|
+
/** (Re)arm the timer; a new delay replaces the one given at creation. */
|
|
270
|
+
restart(newMs) {
|
|
271
|
+
if (newMs !== undefined) {
|
|
272
|
+
assertDelay(newMs, 'timeout.restart([ms])', 'ms');
|
|
273
|
+
ms = newMs;
|
|
274
|
+
}
|
|
275
|
+
if (id !== null) clearTimer(id);
|
|
276
|
+
due = now() + ms;
|
|
277
|
+
id = setTimer(
|
|
278
|
+
() => {
|
|
279
|
+
id = null;
|
|
280
|
+
cb();
|
|
281
|
+
},
|
|
282
|
+
ms,
|
|
283
|
+
meta,
|
|
284
|
+
);
|
|
285
|
+
return handle;
|
|
286
|
+
},
|
|
287
|
+
cancel() {
|
|
288
|
+
if (id !== null) clearTimer(id);
|
|
289
|
+
id = null;
|
|
290
|
+
return handle;
|
|
291
|
+
},
|
|
292
|
+
pending: () => id !== null,
|
|
293
|
+
remaining: () => (id === null ? 0 : Math.max(0, due - now())),
|
|
294
|
+
};
|
|
295
|
+
handle.start = handle.restart;
|
|
296
|
+
return handle;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Call fn until it succeeds, waiting between the attempts. fn(attempt) may return a promise.
|
|
301
|
+
* The promise resolves with fn's result; when every attempt failed it calls onFail(err, tries)
|
|
302
|
+
* and resolves with its return value, or — without onFail — rejects with the last error. A
|
|
303
|
+
* script unload stops the attempts; the promise then never settles.
|
|
304
|
+
* @param {Function} fn
|
|
305
|
+
* @param {object} [opts]
|
|
306
|
+
* @param {number} [opts.tries=3] attempts in total
|
|
307
|
+
* @param {number} [opts.delay=1000] ms before the second attempt
|
|
308
|
+
* @param {number} [opts.backoff=1] the delay is multiplied by this after each failure (2 doubles it)
|
|
309
|
+
* @param {Function} [opts.onFail] (err, tries) after the last failure
|
|
310
|
+
* @param {string} [opts.name] shown in the timer list
|
|
311
|
+
* @returns {Promise}
|
|
312
|
+
*/
|
|
313
|
+
function retry(fn, opts = {}) {
|
|
314
|
+
const api = 'she.retry(fn, [opts])';
|
|
315
|
+
assertFn(fn, api);
|
|
316
|
+
const { tries = 3, delay = 1000, backoff = 1, onFail } = opts;
|
|
317
|
+
if (!Number.isInteger(tries) || tries < 1) throw new TypeError(`${api}: opts.tries must be a whole number of attempts, at least 1 (got ${describeDelay(tries)})`);
|
|
318
|
+
assertDelay(delay, api, 'opts.delay');
|
|
319
|
+
if (typeof backoff !== 'number' || !Number.isFinite(backoff) || backoff < 0) throw new TypeError(`${api}: opts.backoff must be a factor (got ${describeDelay(backoff)})`);
|
|
320
|
+
if (onFail !== undefined) assertFn(onFail, api, 'opts.onFail');
|
|
321
|
+
const name = opts.name || fn.name || undefined;
|
|
322
|
+
const line = lineAt();
|
|
323
|
+
|
|
324
|
+
let aborted = false;
|
|
325
|
+
const off = onUnload(() => {
|
|
326
|
+
aborted = true;
|
|
327
|
+
});
|
|
328
|
+
return new Promise((resolve, reject) => {
|
|
329
|
+
const attempt = (n) => {
|
|
330
|
+
if (aborted) return;
|
|
331
|
+
let p;
|
|
332
|
+
try {
|
|
333
|
+
p = Promise.resolve(fn(n));
|
|
334
|
+
} catch (err) {
|
|
335
|
+
p = Promise.reject(err);
|
|
336
|
+
}
|
|
337
|
+
p.then(
|
|
338
|
+
(value) => {
|
|
339
|
+
off();
|
|
340
|
+
resolve(value);
|
|
341
|
+
},
|
|
342
|
+
(err) => {
|
|
343
|
+
if (aborted) return;
|
|
344
|
+
if (n >= tries) {
|
|
345
|
+
off();
|
|
346
|
+
if (onFail) {
|
|
347
|
+
try {
|
|
348
|
+
resolve(onFail(err, n));
|
|
349
|
+
} catch (e) {
|
|
350
|
+
reject(e);
|
|
351
|
+
}
|
|
352
|
+
} else reject(err);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const wait = delay * Math.pow(backoff, n - 1);
|
|
356
|
+
setTimer(() => attempt(n + 1), wait, { kind: 'retry', name: `${name ? name + ' ' : ''}attempt ${n + 1}/${tries}`, line });
|
|
357
|
+
},
|
|
358
|
+
);
|
|
359
|
+
};
|
|
360
|
+
attempt(1);
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return { debounce, throttle, timeout, retry };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* The limiter of a subscription (sub options `debounce`, `maxWait`, `throttle`, `perTopic`, `name`;
|
|
369
|
+
* seconds). Returns deliver'(topic, val, obj, objPrev, msg): events are bundled, and the callback
|
|
370
|
+
* gets the last event's topic, val, obj and msg with the objPrev from before the first bundled
|
|
371
|
+
* event — so a comparison of obj and objPrev still tells whether the value really changed.
|
|
372
|
+
* @param {object} options the subscription options
|
|
373
|
+
* @param {Function} deliver (topic, val, obj, objPrev, msg) runs the script's callback
|
|
374
|
+
* @param {object} helpers from createTimingHelpers
|
|
375
|
+
* @param {string} label the default name for the timer list (the topic)
|
|
376
|
+
* @returns {Function|null} the limited deliver, or null when the options ask for none
|
|
377
|
+
*/
|
|
378
|
+
function subscriptionLimiter(options, deliver, helpers, label) {
|
|
379
|
+
const api = 'she.mqtt.sub(topic, opts, cb)';
|
|
380
|
+
const seconds = (what) => {
|
|
381
|
+
const v = options[what];
|
|
382
|
+
if (v === undefined) return undefined;
|
|
383
|
+
if (!isValidDelay(v)) throw new TypeError(`${api}: opts.${what} must be a number of seconds (got ${describeDelay(v)})`);
|
|
384
|
+
return v * 1000;
|
|
385
|
+
};
|
|
386
|
+
const debounce = seconds('debounce');
|
|
387
|
+
const throttle = seconds('throttle');
|
|
388
|
+
const maxWait = seconds('maxWait');
|
|
389
|
+
if (debounce === undefined && throttle === undefined) {
|
|
390
|
+
if (maxWait !== undefined) throw new TypeError(`${api}: opts.maxWait needs opts.debounce`);
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
if (debounce !== undefined && throttle !== undefined) throw new TypeError(`${api}: opts.debounce and opts.throttle exclude each other`);
|
|
394
|
+
if (throttle !== undefined && maxWait !== undefined) throw new TypeError(`${api}: opts.maxWait needs opts.debounce, not opts.throttle`);
|
|
395
|
+
const perTopic = !!options.perTopic;
|
|
396
|
+
const name = options.name !== undefined ? String(options.name) : label;
|
|
397
|
+
|
|
398
|
+
const firstPrev = new Map(); // key → objPrev of the first bundled event
|
|
399
|
+
const keyOf = (topic) => (perTopic ? topic : '');
|
|
400
|
+
const run = (topic, val, obj, objPrev, msg) => {
|
|
401
|
+
const key = keyOf(topic);
|
|
402
|
+
const prev = firstPrev.has(key) ? firstPrev.get(key) : objPrev;
|
|
403
|
+
firstPrev.delete(key);
|
|
404
|
+
deliver(topic, val, obj, prev, msg);
|
|
405
|
+
};
|
|
406
|
+
let limited;
|
|
407
|
+
if (debounce !== undefined) {
|
|
408
|
+
const opts = { maxWait, name, flushOnUnload: !!options.flushOnUnload, leading: !!options.leading, trailing: options.trailing !== false };
|
|
409
|
+
limited = perTopic ? helpers.debounce.keyed(run, debounce, opts) : helpers.debounce(run, debounce, opts);
|
|
410
|
+
} else {
|
|
411
|
+
const opts = { name, leading: options.leading !== false, trailing: options.trailing !== false };
|
|
412
|
+
limited = perTopic ? helpers.throttle.keyed(run, throttle, opts) : helpers.throttle(run, throttle, opts);
|
|
413
|
+
}
|
|
414
|
+
return (topic, val, obj, objPrev, msg) => {
|
|
415
|
+
const key = keyOf(topic);
|
|
416
|
+
if (!firstPrev.has(key)) firstPrev.set(key, objPrev);
|
|
417
|
+
limited(topic, val, obj, objPrev, msg);
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
module.exports = { createTimingHelpers, subscriptionLimiter };
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Which version the update check offers — by channel, from the GitHub releases.
|
|
4
|
+
*
|
|
5
|
+
* `stable` offers the newest release that is not a pre-release; `beta` offers the newest of all
|
|
6
|
+
* releases, so a beta tester gets `3.0.0-beta.1` after `3.0.0-beta.0` and `3.0.0` once it is
|
|
7
|
+
* out, and nobody is ever offered a downgrade: switching back to `stable` on a beta shows an
|
|
8
|
+
* update only when a stable release is newer than what runs. The version is installed exactly
|
|
9
|
+
* (`smart-home-engine@<version>`), so npm's dist-tags play no part — but the version has to exist
|
|
10
|
+
* on npm: a GitHub release whose npm job failed is not offered. Best-effort: every failure yields
|
|
11
|
+
* "no update known" and the reason, nothing throws.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const semverCompare = require('semantic-compare');
|
|
15
|
+
|
|
16
|
+
const REPO = 'hobbyquaker/she';
|
|
17
|
+
const PACKAGE = 'smart-home-engine';
|
|
18
|
+
const GITHUB_RELEASES = `https://api.github.com/repos/${REPO}/releases?per_page=30`;
|
|
19
|
+
const NPM_REGISTRY = 'https://registry.npmjs.org';
|
|
20
|
+
const CHANNELS = ['stable', 'beta'];
|
|
21
|
+
|
|
22
|
+
let _fetch = (...args) => fetch(...args);
|
|
23
|
+
|
|
24
|
+
/** Test hook: replace the fetch implementation. */
|
|
25
|
+
function setFetch(fn) {
|
|
26
|
+
_fetch = fn;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 'stable' or 'beta'; anything else is 'stable'. */
|
|
30
|
+
function normalizeChannel(channel) {
|
|
31
|
+
return CHANNELS.includes(channel) ? channel : 'stable';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const VERSION_RX = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/;
|
|
35
|
+
|
|
36
|
+
/** The version of a release tag (`v3.0.0-beta.0` → `3.0.0-beta.0`), or null when it is not one. */
|
|
37
|
+
function versionOfTag(tag) {
|
|
38
|
+
if (typeof tag !== 'string') return null;
|
|
39
|
+
const v = tag.replace(/^v/, '');
|
|
40
|
+
return VERSION_RX.test(v) ? v : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isPrerelease(version) {
|
|
44
|
+
return typeof version === 'string' && version.includes('-');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function compare(a, b) {
|
|
48
|
+
try {
|
|
49
|
+
return semverCompare(a, b);
|
|
50
|
+
} catch {
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function newest(versions) {
|
|
56
|
+
return versions.reduce((best, v) => (best === null || compare(v, best) > 0 ? v : best), null);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The newest stable release and the newest pre-release of a GitHub release list.
|
|
61
|
+
* @param {Array<{tag_name: string, prerelease?: boolean, draft?: boolean}>} releases
|
|
62
|
+
* @returns {{ stable: string|null, prerelease: string|null }}
|
|
63
|
+
*/
|
|
64
|
+
function newestOf(releases) {
|
|
65
|
+
const stable = [];
|
|
66
|
+
const pre = [];
|
|
67
|
+
for (const r of Array.isArray(releases) ? releases : []) {
|
|
68
|
+
if (!r || r.draft) continue;
|
|
69
|
+
const v = versionOfTag(r.tag_name);
|
|
70
|
+
if (!v) continue;
|
|
71
|
+
(r.prerelease || isPrerelease(v) ? pre : stable).push(v);
|
|
72
|
+
}
|
|
73
|
+
return { stable: newest(stable), prerelease: newest(pre) };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The version a channel offers, given the newest releases. */
|
|
77
|
+
function offeredVersion(channel, { stable, prerelease }) {
|
|
78
|
+
if (normalizeChannel(channel) === 'stable') return stable;
|
|
79
|
+
if (!stable) return prerelease;
|
|
80
|
+
if (!prerelease) return stable;
|
|
81
|
+
return compare(stable, prerelease) < 0 ? prerelease : stable;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function fetchReleases() {
|
|
85
|
+
const res = await _fetch(GITHUB_RELEASES, {
|
|
86
|
+
headers: { 'Accept': 'application/vnd.github+json', 'User-Agent': `${PACKAGE} update check` },
|
|
87
|
+
signal: AbortSignal.timeout(10000),
|
|
88
|
+
});
|
|
89
|
+
if (!res.ok) throw new Error(`GitHub answered ${res.status}`);
|
|
90
|
+
return res.json();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function existsOnNpm(version) {
|
|
94
|
+
const res = await _fetch(`${NPM_REGISTRY}/${PACKAGE}/${encodeURIComponent(version)}`, { signal: AbortSignal.timeout(10000) });
|
|
95
|
+
if (!res.ok) return false;
|
|
96
|
+
const data = await res.json();
|
|
97
|
+
return data && data.version === version;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Check for an update on a channel.
|
|
102
|
+
* @param {object} opts
|
|
103
|
+
* @param {string} opts.installed the running version
|
|
104
|
+
* @param {string} [opts.channel] 'stable' (default) or 'beta'
|
|
105
|
+
* @param {boolean} [opts.docker] a Docker container cannot update itself
|
|
106
|
+
* @returns {Promise<{channel: string, installed: string, stable: string|null, prerelease: string|null,
|
|
107
|
+
* latest: string|null, latestIsPrerelease: boolean, selfUpdate: boolean, checkedAt: number, error: string|null}>}
|
|
108
|
+
* `latest` is the version to offer — newer than `installed`, on the channel, present on npm — or null
|
|
109
|
+
*/
|
|
110
|
+
async function checkUpdate(opts) {
|
|
111
|
+
const channel = normalizeChannel(opts.channel);
|
|
112
|
+
const installed = String(opts.installed || '');
|
|
113
|
+
const result = {
|
|
114
|
+
channel,
|
|
115
|
+
installed,
|
|
116
|
+
stable: null,
|
|
117
|
+
prerelease: null,
|
|
118
|
+
latest: null,
|
|
119
|
+
latestIsPrerelease: false,
|
|
120
|
+
selfUpdate: !opts.docker,
|
|
121
|
+
checkedAt: Date.now(),
|
|
122
|
+
error: null,
|
|
123
|
+
};
|
|
124
|
+
let releases;
|
|
125
|
+
try {
|
|
126
|
+
releases = await fetchReleases();
|
|
127
|
+
} catch (err) {
|
|
128
|
+
result.error = `release list not available: ${err.message}`;
|
|
129
|
+
return result;
|
|
130
|
+
}
|
|
131
|
+
const found = newestOf(releases);
|
|
132
|
+
result.stable = found.stable;
|
|
133
|
+
result.prerelease = found.prerelease;
|
|
134
|
+
const candidate = offeredVersion(channel, found);
|
|
135
|
+
if (!candidate || !(compare(installed, candidate) < 0)) return result;
|
|
136
|
+
let onNpm = false;
|
|
137
|
+
try {
|
|
138
|
+
onNpm = await existsOnNpm(candidate);
|
|
139
|
+
} catch (err) {
|
|
140
|
+
result.error = `npm registry not available: ${err.message}`;
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
if (!onNpm) {
|
|
144
|
+
result.error = `${candidate} is released on GitHub but not published on npm`;
|
|
145
|
+
return result;
|
|
146
|
+
}
|
|
147
|
+
result.latest = candidate;
|
|
148
|
+
result.latestIsPrerelease = isPrerelease(candidate);
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
module.exports = { CHANNELS, PACKAGE, checkUpdate, newestOf, offeredVersion, versionOfTag, isPrerelease, normalizeChannel, setFetch };
|
package/src/sandbox/stdlib.js
CHANGED
|
@@ -37,6 +37,11 @@ module.exports = function (she, ctx = {}) {
|
|
|
37
37
|
sub: (...args) => she.mqttsub(...args),
|
|
38
38
|
/** Publish an MQTT message. Same signature as she.mqttpub(). */
|
|
39
39
|
pub: (...args) => she.mqttpub(...args),
|
|
40
|
+
/**
|
|
41
|
+
* Publish a command and wait for the device's status to confirm it. Resolves with
|
|
42
|
+
* { val, ms, tries }; rejects with an Error carrying reason timeout | offline | superseded | unloaded.
|
|
43
|
+
*/
|
|
44
|
+
command: (...args) => she.mqttcommand(...args),
|
|
40
45
|
/** Get the last-known value for a topic. */
|
|
41
46
|
get: (topic) => she.getValue(topic),
|
|
42
47
|
/** Get a specific property from a topic's state object. */
|
|
@@ -123,6 +128,15 @@ module.exports = function (she, ctx = {}) {
|
|
|
123
128
|
* cleared by an initial timeout on startup.
|
|
124
129
|
*/
|
|
125
130
|
timer: function Sandbox_mqtt_timer(src, ms, target) {
|
|
131
|
+
// Swapped arguments used to become a 1 ms timer and a variable named like the number —
|
|
132
|
+
// silently, apart from Node's anonymous TimeoutNaNWarning. Fail at load instead.
|
|
133
|
+
const signature = 'she.mqtt.timer(src, ms, target)';
|
|
134
|
+
if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) {
|
|
135
|
+
throw new TypeError(`${signature}: ms must be a number of milliseconds (got ${typeof ms === 'string' ? JSON.stringify(ms) : String(ms)})`);
|
|
136
|
+
}
|
|
137
|
+
if (typeof target !== 'string' && typeof target !== 'function') {
|
|
138
|
+
throw new TypeError(`${signature}: target must be a topic string or a callback (got ${typeof target})`);
|
|
139
|
+
}
|
|
126
140
|
const key = target;
|
|
127
141
|
she.mqttsub(src, { retain: false }, (topic, val) => {
|
|
128
142
|
if (isTruthy(val)) {
|
package/src/web/ai-tools.js
CHANGED
|
@@ -493,7 +493,7 @@ const TOOL_DEFINITIONS = [
|
|
|
493
493
|
function: {
|
|
494
494
|
name: 'list_timers',
|
|
495
495
|
description:
|
|
496
|
-
'Pending one-shot timers, intervals, schedule jobs
|
|
496
|
+
'Pending one-shot timers, intervals, schedule jobs, sun events, timing helpers (debounce, throttle, timeout, retry) and commands waiting for confirmation (she.mqtt.command) per script with their next fire time. Use it for "why did it switch at …" and "what is still going to happen".',
|
|
497
497
|
parameters: {
|
|
498
498
|
type: 'object',
|
|
499
499
|
properties: { script: { type: 'string', description: 'Substring of a script path. Empty: every script with something pending.' } },
|
|
@@ -1514,14 +1514,18 @@ function toolGetHealth(ctx = {}) {
|
|
|
1514
1514
|
if (!ctx.introspect?.health) return NO_INTROSPECT;
|
|
1515
1515
|
const h = ctx.introspect.health() || {};
|
|
1516
1516
|
const st = ctx.introspect.stats ? ctx.introspect.stats() || {} : {};
|
|
1517
|
+
const cfg = ctx.introspect.config ? ctx.introspect.config() || {} : {};
|
|
1517
1518
|
const lines = [
|
|
1518
|
-
`started: ${h.started}, mqtt: ${h.mqttConfigured ? (h.mqttConnected ? 'connected' : 'DISCONNECTED') : 'not configured'}, scripts loaded: ${h.scripts}, safe mode: ${h.safeMode ? 'YES' : 'no'}`,
|
|
1519
|
+
`she ${cfg.version || 'unknown version'} — started: ${h.started}, mqtt: ${h.mqttConfigured ? (h.mqttConnected ? 'connected' : 'DISCONNECTED') : 'not configured'}, scripts loaded: ${h.scripts}, safe mode: ${h.safeMode ? 'YES' : 'no'}`,
|
|
1519
1520
|
];
|
|
1520
1521
|
if (st.topics !== undefined) lines.push(`topics: ${st.topics}, messages/s: ${st.mqttMsgPerSec}, handlers: ${st.handlers}`);
|
|
1521
1522
|
if (st.memMb !== undefined)
|
|
1522
1523
|
lines.push(`memory: ${st.memMb} MB, cpu: ${st.cpuPercent}%, event loop: ${st.eluPercent}% utilised, lag mean ${st.elMeanMs} ms max ${st.elMaxMs} ms`);
|
|
1523
1524
|
if (st.matterEnabled) lines.push(`matter: ${st.matterNodes} node(s), ${st.matterEndpoints} endpoint(s)`);
|
|
1524
1525
|
if (st.dbEnabled) lines.push(`sheDB: ${st.dbDocs} document(s), ${st.dbViews} view(s)`);
|
|
1526
|
+
if (st.pendingTimers !== undefined)
|
|
1527
|
+
lines.push(`pending timers: ${st.pendingTimers}, failed commands since start: ${st.commandsFailed ?? 0} (she.mqtt.command: timeout or offline)`);
|
|
1528
|
+
if (cfg.timezone) lines.push(`time: zone ${cfg.timezone}, locale ${cfg.locale}, holidays ${cfg.holidays || 'NOT CONFIGURED (she.time.isHoliday() is always false)'}`);
|
|
1525
1529
|
return lines.join('\n');
|
|
1526
1530
|
}
|
|
1527
1531
|
|
|
@@ -1532,7 +1536,7 @@ function toolListTimers({ script = '' } = {}, ctx = {}) {
|
|
|
1532
1536
|
for (const sc of ctx.introspect.scripts()) {
|
|
1533
1537
|
if (q && !sc.label.toLowerCase().includes(q)) continue;
|
|
1534
1538
|
const items = [
|
|
1535
|
-
...sc.timers.map((t) => (t.every ? `interval every ${ago(t.every)}, next ${inTime(t.due)}` :
|
|
1539
|
+
...sc.timers.map((t) => (t.every ? `interval every ${ago(t.every)}, next ${inTime(t.due)}` : `${t.kind || 'timer'}${t.name ? ` "${t.name}"` : ''} ${inTime(t.due)}`)),
|
|
1536
1540
|
...sc.jobs.map((j) => `schedule job ${inTime(j.next)}`),
|
|
1537
1541
|
...sc.sunEvents.map((e) => `${e.pattern} ${inTime(e.next)}`),
|
|
1538
1542
|
];
|
package/src/web/config-api.js
CHANGED
|
@@ -8,10 +8,11 @@ const { getConfigPath } = require('../lib/storage');
|
|
|
8
8
|
const DEFAULT_CONFIG_PATH = getConfigPath();
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* Config keys
|
|
12
|
-
*
|
|
11
|
+
* Config keys whose change needs no daemon restart: consumed only by the frontend, or re-read
|
|
12
|
+
* from the file by the daemon at every use (updateChannel — the update check). All other keys
|
|
13
|
+
* require a daemon restart when changed.
|
|
13
14
|
*/
|
|
14
|
-
const FRONTEND_ONLY_KEYS = new Set(['gitAutoCommit', 'gitAutoPush', 'pinnedPackages']);
|
|
15
|
+
const FRONTEND_ONLY_KEYS = new Set(['gitAutoCommit', 'gitAutoPush', 'pinnedPackages', 'updateChannel']);
|
|
15
16
|
|
|
16
17
|
const router = express.Router();
|
|
17
18
|
|