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.
@@ -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 };
@@ -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)) {
@@ -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 and sun events per script with their next fire time. Use it for "why did it switch at …" and "what is still going to happen".',
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)}` : `timer ${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
  ];
@@ -8,10 +8,11 @@ const { getConfigPath } = require('../lib/storage');
8
8
  const DEFAULT_CONFIG_PATH = getConfigPath();
9
9
 
10
10
  /**
11
- * Config keys that are consumed only by the frontend (no daemon restart needed).
12
- * All other keys require a daemon restart when changed.
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