smart-home-engine 2.0.1 → 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.
@@ -0,0 +1,416 @@
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
+
38
+ /**
39
+ * Bundle calls: run fn with the arguments of the last call once `wait` ms passed without a new
40
+ * call — at the latest `maxWait` ms after the first call of a bundle.
41
+ * @param {Function} fn
42
+ * @param {number} wait ms
43
+ * @param {object} [opts]
44
+ * @param {number} [opts.maxWait] ms; run at the latest this long after the first bundled call
45
+ * @param {boolean} [opts.leading=false] run on the first call of a bundle as well
46
+ * @param {boolean} [opts.trailing=true] run after the quiet period (with the last call's arguments)
47
+ * @param {boolean} [opts.flushOnUnload=false] run a pending call when the script is unloaded
48
+ * @param {string} [opts.name] shown in the timer list
49
+ * @returns {Function} wrapped(...args) with cancel(), flush(), pending()
50
+ */
51
+ function debounce(fn, wait, opts = {}) {
52
+ const api = 'she.debounce(fn, wait, [opts])';
53
+ assertFn(fn, api);
54
+ assertDelay(wait, api, 'wait');
55
+ const { leading = false, trailing = true, maxWait, flushOnUnload = false, _onSettle } = opts;
56
+ if (maxWait !== undefined) assertDelay(maxWait, api, 'opts.maxWait');
57
+ if (!leading && !trailing) throw new TypeError(`${api}: opts.leading and opts.trailing cannot both be false`);
58
+ const name = opts.name || fn.name || undefined;
59
+ const meta = { kind: 'debounce', name };
60
+ const maxMeta = { kind: 'debounce maxWait', name };
61
+
62
+ let timer = null;
63
+ let maxTimer = null;
64
+ let lastArgs = null;
65
+ let waiting = false; // a call waits to run
66
+ let result;
67
+
68
+ function settle() {
69
+ lastArgs = null;
70
+ waiting = false;
71
+ if (timer === null && maxTimer === null && typeof _onSettle === 'function') _onSettle();
72
+ }
73
+ function clearTimers() {
74
+ if (timer !== null) clearTimer(timer);
75
+ if (maxTimer !== null) clearTimer(maxTimer);
76
+ timer = maxTimer = null;
77
+ }
78
+ function invoke() {
79
+ const args = lastArgs;
80
+ lastArgs = null;
81
+ waiting = false;
82
+ result = fn(...args);
83
+ if (timer === null && maxTimer === null && typeof _onSettle === 'function') _onSettle();
84
+ return result;
85
+ }
86
+ function onWait() {
87
+ timer = null;
88
+ if (maxTimer !== null) {
89
+ clearTimer(maxTimer);
90
+ maxTimer = null;
91
+ }
92
+ if (trailing && waiting) invoke();
93
+ else settle();
94
+ }
95
+ function onMaxWait() {
96
+ maxTimer = null;
97
+ if (timer !== null) {
98
+ clearTimer(timer);
99
+ timer = null;
100
+ }
101
+ if (waiting) invoke();
102
+ else settle();
103
+ }
104
+ function wrapped(...args) {
105
+ const idle = timer === null;
106
+ lastArgs = args;
107
+ if (idle && maxWait !== undefined && maxTimer === null) maxTimer = setTimer(onMaxWait, maxWait, maxMeta);
108
+ if (timer !== null) clearTimer(timer);
109
+ timer = setTimer(onWait, wait, meta);
110
+ if (idle && leading) invoke();
111
+ else waiting = true;
112
+ return result;
113
+ }
114
+ wrapped.cancel = () => {
115
+ clearTimers();
116
+ settle();
117
+ };
118
+ wrapped.flush = () => {
119
+ clearTimers();
120
+ if (waiting) return invoke();
121
+ settle();
122
+ return result;
123
+ };
124
+ wrapped.pending = () => waiting;
125
+ if (flushOnUnload) onUnload(() => wrapped.flush());
126
+ return wrapped;
127
+ }
128
+
129
+ /**
130
+ * Rate-limit calls: run fn at most once per `wait` ms — on the first call of a window
131
+ * (leading) and, when more calls came in, once more with the last arguments when the window ends
132
+ * (trailing).
133
+ * @param {Function} fn
134
+ * @param {number} wait ms
135
+ * @param {object} [opts]
136
+ * @param {boolean} [opts.leading=true]
137
+ * @param {boolean} [opts.trailing=true]
138
+ * @param {string} [opts.name] shown in the timer list
139
+ * @returns {Function} wrapped(...args) with cancel(), flush(), pending()
140
+ */
141
+ function throttle(fn, wait, opts = {}) {
142
+ const api = 'she.throttle(fn, wait, [opts])';
143
+ assertFn(fn, api);
144
+ assertDelay(wait, api, 'wait');
145
+ const { leading = true, trailing = true, _onSettle } = opts;
146
+ if (!leading && !trailing) throw new TypeError(`${api}: opts.leading and opts.trailing cannot both be false`);
147
+ const meta = { kind: 'throttle', name: opts.name || fn.name || undefined };
148
+
149
+ let timer = null;
150
+ let lastArgs = null;
151
+ let waiting = false;
152
+ let result;
153
+
154
+ function settle() {
155
+ lastArgs = null;
156
+ waiting = false;
157
+ if (timer === null && typeof _onSettle === 'function') _onSettle();
158
+ }
159
+ function invoke() {
160
+ const args = lastArgs;
161
+ lastArgs = null;
162
+ waiting = false;
163
+ result = fn(...args);
164
+ if (timer === null && typeof _onSettle === 'function') _onSettle();
165
+ return result;
166
+ }
167
+ function onWindowEnd() {
168
+ timer = null;
169
+ if (trailing && waiting) {
170
+ // the trailing run opens a new window, so runs stay `wait` apart
171
+ timer = setTimer(onWindowEnd, wait, meta);
172
+ invoke();
173
+ } else settle();
174
+ }
175
+ function wrapped(...args) {
176
+ lastArgs = args;
177
+ if (timer === null) {
178
+ timer = setTimer(onWindowEnd, wait, meta);
179
+ if (leading) invoke();
180
+ else waiting = true;
181
+ } else waiting = true;
182
+ return result;
183
+ }
184
+ wrapped.cancel = () => {
185
+ if (timer !== null) clearTimer(timer);
186
+ timer = null;
187
+ settle();
188
+ };
189
+ wrapped.flush = () => {
190
+ if (timer !== null) clearTimer(timer);
191
+ timer = null;
192
+ if (waiting) return invoke();
193
+ settle();
194
+ return result;
195
+ };
196
+ wrapped.pending = () => waiting;
197
+ return wrapped;
198
+ }
199
+
200
+ /**
201
+ * A debounce or throttle with a separate state per key. The key is the first argument of the
202
+ * call unless opts.key(...args) says otherwise. Idle keys are forgotten, so a wildcard
203
+ * subscription does not accumulate state.
204
+ */
205
+ function keyed(factory, api) {
206
+ return function keyedHelper(fn, wait, opts = {}) {
207
+ assertFn(fn, api);
208
+ const { key: keyFn, flushOnUnload = false, name, ...rest } = opts;
209
+ if (keyFn !== undefined) assertFn(keyFn, api, 'opts.key');
210
+ const states = new Map();
211
+ const forKey = (key) => {
212
+ let state = states.get(key);
213
+ if (!state) {
214
+ state = factory(fn, wait, { ...rest, name: name ? `${name} ${key}` : String(key), _onSettle: () => states.delete(key) });
215
+ states.set(key, state);
216
+ }
217
+ return state;
218
+ };
219
+ function wrapped(...args) {
220
+ const key = keyFn ? keyFn(...args) : args[0];
221
+ return forKey(key)(...args);
222
+ }
223
+ // without a key: every key
224
+ wrapped.cancel = (key) => {
225
+ if (key !== undefined) states.get(key)?.cancel();
226
+ else for (const state of [...states.values()]) state.cancel();
227
+ };
228
+ wrapped.flush = (key) => {
229
+ if (key !== undefined) return states.get(key)?.flush();
230
+ for (const state of [...states.values()]) state.flush();
231
+ return undefined;
232
+ };
233
+ wrapped.pending = (key) => {
234
+ if (key !== undefined) return states.get(key)?.pending() ?? false;
235
+ for (const state of states.values()) if (state.pending()) return true;
236
+ return false;
237
+ };
238
+ wrapped.keys = () => [...states.keys()];
239
+ // validate eagerly, so a wrong wait fails at load time and not on the first call
240
+ factory(fn, wait, { ...rest, _onSettle: undefined }).cancel();
241
+ if (flushOnUnload) onUnload(() => wrapped.flush());
242
+ return wrapped;
243
+ };
244
+ }
245
+ debounce.keyed = keyed(debounce, 'she.debounce.keyed(fn, wait, [opts])');
246
+ throttle.keyed = keyed(throttle, 'she.throttle.keyed(fn, wait, [opts])');
247
+
248
+ /**
249
+ * A restartable one-shot timer. It is created idle: restart() arms it, another restart() starts
250
+ * the delay over, cancel() disarms it.
251
+ * @param {number} ms
252
+ * @param {Function} cb
253
+ * @param {object} [opts]
254
+ * @param {string} [opts.name] shown in the timer list
255
+ * @returns {{ restart: Function, start: Function, cancel: Function, pending: Function, remaining: Function }}
256
+ */
257
+ function timeout(ms, cb, opts = {}) {
258
+ const api = 'she.timeout(ms, cb, [opts])';
259
+ assertDelay(ms, api, 'ms');
260
+ assertFn(cb, api, 'cb');
261
+ const meta = { kind: 'timeout', name: opts.name || cb.name || undefined };
262
+ let id = null;
263
+ let due = 0;
264
+ const handle = {
265
+ /** (Re)arm the timer; a new delay replaces the one given at creation. */
266
+ restart(newMs) {
267
+ if (newMs !== undefined) {
268
+ assertDelay(newMs, 'timeout.restart([ms])', 'ms');
269
+ ms = newMs;
270
+ }
271
+ if (id !== null) clearTimer(id);
272
+ due = now() + ms;
273
+ id = setTimer(
274
+ () => {
275
+ id = null;
276
+ cb();
277
+ },
278
+ ms,
279
+ meta,
280
+ );
281
+ return handle;
282
+ },
283
+ cancel() {
284
+ if (id !== null) clearTimer(id);
285
+ id = null;
286
+ return handle;
287
+ },
288
+ pending: () => id !== null,
289
+ remaining: () => (id === null ? 0 : Math.max(0, due - now())),
290
+ };
291
+ handle.start = handle.restart;
292
+ return handle;
293
+ }
294
+
295
+ /**
296
+ * Call fn until it succeeds, waiting between the attempts. fn(attempt) may return a promise.
297
+ * The promise resolves with fn's result; when every attempt failed it calls onFail(err, tries)
298
+ * and resolves with its return value, or — without onFail — rejects with the last error. A
299
+ * script unload stops the attempts; the promise then never settles.
300
+ * @param {Function} fn
301
+ * @param {object} [opts]
302
+ * @param {number} [opts.tries=3] attempts in total
303
+ * @param {number} [opts.delay=1000] ms before the second attempt
304
+ * @param {number} [opts.backoff=1] the delay is multiplied by this after each failure (2 doubles it)
305
+ * @param {Function} [opts.onFail] (err, tries) after the last failure
306
+ * @param {string} [opts.name] shown in the timer list
307
+ * @returns {Promise}
308
+ */
309
+ function retry(fn, opts = {}) {
310
+ const api = 'she.retry(fn, [opts])';
311
+ assertFn(fn, api);
312
+ const { tries = 3, delay = 1000, backoff = 1, onFail } = opts;
313
+ 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)})`);
314
+ assertDelay(delay, api, 'opts.delay');
315
+ if (typeof backoff !== 'number' || !Number.isFinite(backoff) || backoff < 0) throw new TypeError(`${api}: opts.backoff must be a factor (got ${describeDelay(backoff)})`);
316
+ if (onFail !== undefined) assertFn(onFail, api, 'opts.onFail');
317
+ const name = opts.name || fn.name || undefined;
318
+
319
+ let aborted = false;
320
+ const off = onUnload(() => {
321
+ aborted = true;
322
+ });
323
+ return new Promise((resolve, reject) => {
324
+ const attempt = (n) => {
325
+ if (aborted) return;
326
+ let p;
327
+ try {
328
+ p = Promise.resolve(fn(n));
329
+ } catch (err) {
330
+ p = Promise.reject(err);
331
+ }
332
+ p.then(
333
+ (value) => {
334
+ off();
335
+ resolve(value);
336
+ },
337
+ (err) => {
338
+ if (aborted) return;
339
+ if (n >= tries) {
340
+ off();
341
+ if (onFail) {
342
+ try {
343
+ resolve(onFail(err, n));
344
+ } catch (e) {
345
+ reject(e);
346
+ }
347
+ } else reject(err);
348
+ return;
349
+ }
350
+ const wait = delay * Math.pow(backoff, n - 1);
351
+ setTimer(() => attempt(n + 1), wait, { kind: 'retry', name: `${name ? name + ' ' : ''}attempt ${n + 1}/${tries}` });
352
+ },
353
+ );
354
+ };
355
+ attempt(1);
356
+ });
357
+ }
358
+
359
+ return { debounce, throttle, timeout, retry };
360
+ }
361
+
362
+ /**
363
+ * The limiter of a subscription (sub options `debounce`, `maxWait`, `throttle`, `perTopic`, `name`;
364
+ * seconds). Returns deliver'(topic, val, obj, objPrev, msg): events are bundled, and the callback
365
+ * gets the last event's topic, val, obj and msg with the objPrev from before the first bundled
366
+ * event — so a comparison of obj and objPrev still tells whether the value really changed.
367
+ * @param {object} options the subscription options
368
+ * @param {Function} deliver (topic, val, obj, objPrev, msg) runs the script's callback
369
+ * @param {object} helpers from createTimingHelpers
370
+ * @param {string} label the default name for the timer list (the topic)
371
+ * @returns {Function|null} the limited deliver, or null when the options ask for none
372
+ */
373
+ function subscriptionLimiter(options, deliver, helpers, label) {
374
+ const api = 'she.mqtt.sub(topic, opts, cb)';
375
+ const seconds = (what) => {
376
+ const v = options[what];
377
+ if (v === undefined) return undefined;
378
+ if (!isValidDelay(v)) throw new TypeError(`${api}: opts.${what} must be a number of seconds (got ${describeDelay(v)})`);
379
+ return v * 1000;
380
+ };
381
+ const debounce = seconds('debounce');
382
+ const throttle = seconds('throttle');
383
+ const maxWait = seconds('maxWait');
384
+ if (debounce === undefined && throttle === undefined) {
385
+ if (maxWait !== undefined) throw new TypeError(`${api}: opts.maxWait needs opts.debounce`);
386
+ return null;
387
+ }
388
+ if (debounce !== undefined && throttle !== undefined) throw new TypeError(`${api}: opts.debounce and opts.throttle exclude each other`);
389
+ if (throttle !== undefined && maxWait !== undefined) throw new TypeError(`${api}: opts.maxWait needs opts.debounce, not opts.throttle`);
390
+ const perTopic = !!options.perTopic;
391
+ const name = options.name !== undefined ? String(options.name) : label;
392
+
393
+ const firstPrev = new Map(); // key → objPrev of the first bundled event
394
+ const keyOf = (topic) => (perTopic ? topic : '');
395
+ const run = (topic, val, obj, objPrev, msg) => {
396
+ const key = keyOf(topic);
397
+ const prev = firstPrev.has(key) ? firstPrev.get(key) : objPrev;
398
+ firstPrev.delete(key);
399
+ deliver(topic, val, obj, prev, msg);
400
+ };
401
+ let limited;
402
+ if (debounce !== undefined) {
403
+ const opts = { maxWait, name, flushOnUnload: !!options.flushOnUnload, leading: !!options.leading, trailing: options.trailing !== false };
404
+ limited = perTopic ? helpers.debounce.keyed(run, debounce, opts) : helpers.debounce(run, debounce, opts);
405
+ } else {
406
+ const opts = { name, leading: options.leading !== false, trailing: options.trailing !== false };
407
+ limited = perTopic ? helpers.throttle.keyed(run, throttle, opts) : helpers.throttle(run, throttle, opts);
408
+ }
409
+ return (topic, val, obj, objPrev, msg) => {
410
+ const key = keyOf(topic);
411
+ if (!firstPrev.has(key)) firstPrev.set(key, objPrev);
412
+ limited(topic, val, obj, objPrev, msg);
413
+ };
414
+ }
415
+
416
+ module.exports = { createTimingHelpers, subscriptionLimiter };
@@ -123,6 +123,15 @@ module.exports = function (she, ctx = {}) {
123
123
  * cleared by an initial timeout on startup.
124
124
  */
125
125
  timer: function Sandbox_mqtt_timer(src, ms, target) {
126
+ // Swapped arguments used to become a 1 ms timer and a variable named like the number —
127
+ // silently, apart from Node's anonymous TimeoutNaNWarning. Fail at load instead.
128
+ const signature = 'she.mqtt.timer(src, ms, target)';
129
+ if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) {
130
+ throw new TypeError(`${signature}: ms must be a number of milliseconds (got ${typeof ms === 'string' ? JSON.stringify(ms) : String(ms)})`);
131
+ }
132
+ if (typeof target !== 'string' && typeof target !== 'function') {
133
+ throw new TypeError(`${signature}: target must be a topic string or a callback (got ${typeof target})`);
134
+ }
126
135
  const key = target;
127
136
  she.mqttsub(src, { retain: false }, (topic, val) => {
128
137
  if (isTruthy(val)) {
@@ -1532,7 +1532,7 @@ function toolListTimers({ script = '' } = {}, ctx = {}) {
1532
1532
  for (const sc of ctx.introspect.scripts()) {
1533
1533
  if (q && !sc.label.toLowerCase().includes(q)) continue;
1534
1534
  const items = [
1535
- ...sc.timers.map((t) => (t.every ? `interval every ${ago(t.every)}, next ${inTime(t.due)}` : `timer ${inTime(t.due)}`)),
1535
+ ...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
1536
  ...sc.jobs.map((j) => `schedule job ${inTime(j.next)}`),
1537
1537
  ...sc.sunEvents.map((e) => `${e.pattern} ${inTime(e.next)}`),
1538
1538
  ];
@@ -1,8 +1,8 @@
1
1
  ## she sandbox API (the common part)
2
2
  Scripts start with `/* global she */` and `'use strict';`. `require()` works for installed npm packages.
3
3
  ```
4
- she.mqtt.sub(topic, [opts], cb) cb(topic, val, obj) opts: { change: true } fires on changes only; wildcards + and #
5
- she.mqtt.pub(topic, payload) publish (commands: no retain)
4
+ she.mqtt.sub(topic, [opts], cb) cb(topic, val, obj) opts: { change: true } fires on changes only; { debounce: 5, maxWait: 15 } (SECONDS) bundles events; wildcards + and #
5
+ she.mqtt.pub(topic, payload, [opts]) publish (commands: no retain); { changed: true } publishes only when the payload differs from the last one
6
6
  she.mqtt.get(topic) last known value
7
7
  she.mqtt.age(topic) seconds since the value last changed
8
8
  she.mqtt.timer(src, ms, target) publish 1 when src turns truthy, 0 after ms
@@ -10,7 +10,8 @@ she.mqtt.or(srcs[], target) / and publish 1 when any / all sources are truthy,
10
10
  she.schedule('0 7 * * *', cb) cron string, Date, or 'sunrise' / 'sunset' with { shift: seconds }
11
11
  she.http.fetch(url, [opts]) → Promise<{ body, code, headers }>
12
12
  she.debug / info / warn / error logging
13
- setTimeout / setInterval available
13
+ setTimeout / setInterval available; she.timeout(ms, cb) → handle.restart()/cancel() is a restartable timer (motion light)
14
+ she.debounce(fn, ms, [{ maxWait }]) / she.throttle(fn, ms) / she.retry(fn, { tries, delay, onFail }) MILLISECONDS; cleared on reload
14
15
  ```
15
16
  Variables: read `var/status/<name>`, write `she.mqtt.pub('var/set/<name>', value)`.
16
17
  For anything else ask with the tool she_api_doc or say that you are not sure.
@@ -8,23 +8,41 @@ she.mqtt.sub(topic, [opts], cb) Subscribe; topic may be a string or an ar
8
8
  opts.retain also fire for the retained value present on connect and script start
9
9
  opts.shift delay the callback by N seconds; opts.random adds up to N random seconds
10
10
  opts.condition fn(val) or a one-line JS string over val/obj/objPrev; the callback runs only when truthy
11
- cb(topic, val, obj, objPrev, msg) obj and objPrev are { val, ts, lc }
12
- she.mqtt.pub(topic, payload, [opts]) Publish; objects are JSON-stringified; opts { qos, retain }
11
+ opts.debounce SECONDS: bundle events, one callback once nothing came in for N s; opts.maxWait (seconds) runs it at the latest then
12
+ opts.throttle SECONDS: the first event at once, then at most one callback per N s (with the last event)
13
+ opts.perTopic with debounce/throttle on an array or wildcard: one bundle per topic; opts.name names it in the timer list
14
+ opts.flushOnUnload with debounce: run a pending callback once on unload/reload (default: dropped)
15
+ cb(topic, val, obj, objPrev, msg) obj and objPrev are { val, ts, lc }; debounced: the last event, objPrev from before the first bundled one
16
+ she.mqtt.pub(topic, payload, [opts]) Publish; objects are JSON-stringified; opts { qos, retain, changed }
17
+ opts.changed publish only when the payload differs from this script's last publish to the topic (before that: from the topic's state, set→status)
13
18
  she.mqtt.get(topic) Last known value (undefined when never seen); an empty payload removes a topic
14
19
  she.mqtt.getProp(topic, 'val'|'ts'|'lc') One property of the state; without a property the whole { val, ts, lc }
15
20
  she.mqtt.age(topic, ['message']) Seconds since the last change, or since the last message with 'message'; NaN when never seen
16
21
  she.mqtt.link(src, target, [fn]) Forward src changes to target, optionally transformed
17
22
  she.mqtt.or / and / max / min(srcs[], topicOrCb) Combine sources; publish 1/0 or the extreme value; re-evaluated on every change
18
- she.mqtt.timer(src, ms, topicOrCb) Publish 1 when src turns truthy, 0 after ms; restarted when src fires again
23
+ she.mqtt.timer(src, ms, topicOrCb) Publish 1 when src turns truthy, 0 after ms (a number — swapped arguments throw at load); restarted when src fires again
19
24
  topicOrCb: a topic string or cb(topic, val)
20
25
  ```
21
26
 
27
+ ### Timing helpers (MILLISECONDS, like setTimeout; cleared on unload/reload; pending runs listed by list_timers with kind and name)
28
+ ```
29
+ she.timeout(ms, cb, [{ name }]) A restartable timer, created idle → handle.restart([ms]) arms or starts over, cancel(), pending(), remaining()
30
+ the motion-light pattern: off.restart() on every motion, off.cancel() when the state changes
31
+ she.debounce(fn, wait, [opts]) Bundle calls: fn runs with the LAST call's args after `wait` ms of quiet; opts { maxWait, leading=false, trailing=true, flushOnUnload, name }
32
+ → wrapped(...args) with cancel(), flush(), pending(); maxWait keeps a never-resting source from starving fn
33
+ she.throttle(fn, wait, [opts]) Rate-limit: first call at once, then at most once per `wait` ms; opts { leading=true, trailing=true, name }; trailing:false = fire, then block
34
+ she.debounce.keyed / she.throttle.keyed(fn, wait, [opts]) One state per key (the first argument, or opts.key(...args)); cancel(key)/flush(key)/pending(key), without key: all
35
+ she.retry(fn, [opts]) → Promise; fn(attempt) may be async; opts { tries=3, delay=1000, backoff=1, onFail(err, tries), name }
36
+ resolves with fn's result; after the last failure: onFail's return value, or rejects without onFail; an unload stops it
37
+ she.setTimeout / she.clearTimeout The sandbox's setTimeout/clearTimeout under she (official aliases); a non-number delay is logged with the script name
38
+ ```
39
+
22
40
  ### Scheduling
23
41
  ```
24
42
  she.schedule(pattern, [opts], cb) pattern: cron string | Date | 'sunrise' 'sunset' 'dawn' 'dusk' 'nauticalDawn' 'nauticalDusk' 'solarNoon' 'night'
25
43
  opts.shift seconds offset (-1800 = 30 min before); opts.random up to N random seconds later
26
44
  she.now() Milliseconds since the epoch
27
- setTimeout / setInterval Available; cleared automatically when the script is unloaded
45
+ setTimeout / setInterval Available; cleared automatically when the script is unloaded; a bad delay is logged with the script name
28
46
  ```
29
47
 
30
48
  ### sheDB (documents, ids like topics: devices/lamp1)