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
|
@@ -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 };
|
|
@@ -46,6 +46,39 @@ function hex64(v) {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
/** LQI 3 → good, 2 → fair, 1 → poor, 0 → unknown (the OHF dashboard's colours). */
|
|
49
|
+
/**
|
|
50
|
+
* A Thread partition ID as 8 hex digits: devices report it as a number (Network Diagnostics), border
|
|
51
|
+
* routers as hex (their mDNS TXT record) — the same partition must compare equal.
|
|
52
|
+
*/
|
|
53
|
+
function partitionHex(v) {
|
|
54
|
+
if (typeof v === 'number' || typeof v === 'bigint' || /^\d+$/.test(String(v))) {
|
|
55
|
+
return Number(v).toString(16).toUpperCase().padStart(8, '0');
|
|
56
|
+
}
|
|
57
|
+
return String(v).toUpperCase().padStart(8, '0');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** an IPv6 address from 16 bytes (a Uint8Array or `{ $bytes }`) in the usual short form */
|
|
61
|
+
function _ipv6(v) {
|
|
62
|
+
const buf = v?.$bytes ? Buffer.from(v.$bytes, 'hex') : ArrayBuffer.isView(v) ? Buffer.from(v.buffer, v.byteOffset, v.byteLength) : null;
|
|
63
|
+
if (!buf || buf.length !== 16) return null;
|
|
64
|
+
const groups = [];
|
|
65
|
+
for (let i = 0; i < 16; i += 2) groups.push(buf.readUInt16BE(i).toString(16));
|
|
66
|
+
// the longest run of two or more zero groups becomes "::"
|
|
67
|
+
let best = { at: -1, len: 0 };
|
|
68
|
+
for (let i = 0; i < 8; ) {
|
|
69
|
+
if (groups[i] !== '0') {
|
|
70
|
+
i++;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
let j = i;
|
|
74
|
+
while (j < 8 && groups[j] === '0') j++;
|
|
75
|
+
if (j - i > best.len && j - i > 1) best = { at: i, len: j - i };
|
|
76
|
+
i = j;
|
|
77
|
+
}
|
|
78
|
+
if (best.at < 0) return groups.join(':');
|
|
79
|
+
return `${groups.slice(0, best.at).join(':')}::${groups.slice(best.at + best.len).join(':')}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
49
82
|
function lqiQuality(lqi) {
|
|
50
83
|
if (lqi >= 3) return 'good';
|
|
51
84
|
if (lqi === 2) return 'fair';
|
|
@@ -68,7 +101,7 @@ function rssiQuality(rssi) {
|
|
|
68
101
|
* @param {object[]} bindings M13's binding list
|
|
69
102
|
*/
|
|
70
103
|
function buildGraph(nodes, borderRouters = [], bindings = [], meshcop = []) {
|
|
71
|
-
const out = { nodes: [], links: [], networks: [], meshcop: { nodes: 0, networks: [] } };
|
|
104
|
+
const out = { nodes: [], links: [], networks: [], hidden: { wifi: 0, other: 0 }, meshcop: { nodes: 0, networks: [] } };
|
|
72
105
|
const byExt = new Map();
|
|
73
106
|
const byRloc = new Map();
|
|
74
107
|
const byNodeId = new Map();
|
|
@@ -87,6 +120,8 @@ function buildGraph(nodes, borderRouters = [], bindings = [], meshcop = []) {
|
|
|
87
120
|
channel: n.thread.channel ?? null,
|
|
88
121
|
partitionId: n.thread.partitionId ?? null,
|
|
89
122
|
networkName: n.thread.networkName ?? null,
|
|
123
|
+
extPanId: n.thread.extPanId ?? null,
|
|
124
|
+
ipv6: n.thread.ipv6 ?? [],
|
|
90
125
|
neighbours: (n.thread.neighborTable ?? []).length,
|
|
91
126
|
routes: (n.thread.routeTable ?? []).length,
|
|
92
127
|
readAt: n.thread.readAt ?? null,
|
|
@@ -95,23 +130,10 @@ function buildGraph(nodes, borderRouters = [], bindings = [], meshcop = []) {
|
|
|
95
130
|
byNodeId.set(n.nodeId, g);
|
|
96
131
|
if (g.extAddress) byExt.set(g.extAddress, g);
|
|
97
132
|
if (g.rloc16 !== null) byRloc.set(g.rloc16, g);
|
|
98
|
-
} else if (n.wifi) {
|
|
99
|
-
const g = {
|
|
100
|
-
id: `node:${n.nodeId}`,
|
|
101
|
-
kind: 'wifi',
|
|
102
|
-
nodeId: n.nodeId,
|
|
103
|
-
name: n.name ?? `Node ${n.nodeId}`,
|
|
104
|
-
online: !!n.online,
|
|
105
|
-
rssi: n.wifi.rssi ?? null,
|
|
106
|
-
bssid: n.wifi.bssid ?? null,
|
|
107
|
-
quality: rssiQuality(n.wifi.rssi),
|
|
108
|
-
};
|
|
109
|
-
out.nodes.push(g);
|
|
110
|
-
byNodeId.set(n.nodeId, g);
|
|
111
133
|
} else {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
134
|
+
// Wi-Fi, Ethernet and devices without diagnostics (offline, bridges) are not part of a Thread
|
|
135
|
+
// mesh: counted for the caption, not drawn
|
|
136
|
+
out.hidden[n.wifi ? 'wifi' : 'other']++;
|
|
115
137
|
}
|
|
116
138
|
}
|
|
117
139
|
// border routers: a graph node each, joined to a fabric node when the ext address matches
|
|
@@ -133,6 +155,9 @@ function buildGraph(nodes, borderRouters = [], bindings = [], meshcop = []) {
|
|
|
133
155
|
role: br.state?.role ?? null,
|
|
134
156
|
online: true,
|
|
135
157
|
rloc16: br.rest?.node?.rloc16 ?? null,
|
|
158
|
+
partitionId: br.partitionId ?? null,
|
|
159
|
+
addresses: br.addresses ?? [],
|
|
160
|
+
hostname: br.hostname ?? null,
|
|
136
161
|
};
|
|
137
162
|
out.nodes.push(g);
|
|
138
163
|
if (ext) byExt.set(ext, g);
|
|
@@ -158,6 +183,8 @@ function buildGraph(nodes, borderRouters = [], bindings = [], meshcop = []) {
|
|
|
158
183
|
extAddress: ext,
|
|
159
184
|
rloc16: nb.rloc16 ?? null,
|
|
160
185
|
online: true,
|
|
186
|
+
networkName: from.networkName ?? null,
|
|
187
|
+
extPanId: from.extPanId ?? null,
|
|
161
188
|
explanation:
|
|
162
189
|
"A neighbour of a device in she's fabric that is not paired with she: a device of another controller, or a border router that announces a different address.",
|
|
163
190
|
};
|
|
@@ -263,14 +290,24 @@ function buildGraph(nodes, borderRouters = [], bindings = [], meshcop = []) {
|
|
|
263
290
|
label: `${b.sourceEndpointName ?? 'ep ' + b.sourceEndpointId} → ${b.targetEndpointName ?? 'ep ' + b.endpoint}`,
|
|
264
291
|
});
|
|
265
292
|
}
|
|
266
|
-
// networks
|
|
293
|
+
// the Thread networks: every drawn node belongs to one (by extended PAN ID, else by name), so the graph
|
|
294
|
+
// can group them — border routers of the same network included, whether in she's fabric or not
|
|
267
295
|
const nets = new Map();
|
|
268
296
|
for (const g of out.nodes) {
|
|
269
|
-
if (g.
|
|
270
|
-
|
|
271
|
-
|
|
297
|
+
if (!g.extPanId && g.networkName) {
|
|
298
|
+
const byName = out.nodes.find((x) => x.extPanId && x.networkName === g.networkName);
|
|
299
|
+
if (byName) g.extPanId = byName.extPanId;
|
|
300
|
+
}
|
|
301
|
+
const key = g.extPanId || (g.networkName ? `name:${g.networkName}` : null);
|
|
302
|
+
if (!key) continue;
|
|
303
|
+
g.network = key;
|
|
304
|
+
const n = nets.get(key) ?? { key, extPanId: g.extPanId ?? null, networkName: g.networkName ?? null, partitions: new Set(), channel: null, nodes: 0, borderRouters: 0 };
|
|
305
|
+
if (!n.networkName && g.networkName) n.networkName = g.networkName;
|
|
306
|
+
if (g.partitionId !== null && g.partitionId !== undefined) n.partitions.add(partitionHex(g.partitionId));
|
|
307
|
+
if (n.channel === null && g.channel !== null && g.channel !== undefined) n.channel = g.channel;
|
|
272
308
|
n.nodes++;
|
|
273
|
-
|
|
309
|
+
if (g.kind === 'border-router' || g.borderRouter) n.borderRouters++;
|
|
310
|
+
nets.set(key, n);
|
|
274
311
|
}
|
|
275
312
|
out.networks = [...nets.values()].map((n) => ({ ...n, partitions: [...n.partitions] }));
|
|
276
313
|
return out;
|
|
@@ -297,6 +334,8 @@ function _nodeData(node, nodeId) {
|
|
|
297
334
|
partitionId: tnd.partitionId === undefined || tnd.partitionId === null ? null : Number(tnd.partitionId),
|
|
298
335
|
leaderRouterId: tnd.leaderRouterId ?? null,
|
|
299
336
|
networkName: tnd.networkName ?? null,
|
|
337
|
+
extPanId: hex64(tnd.extendedPanId),
|
|
338
|
+
ipv6: (threadIf?.iPv6Addresses ?? []).map(_ipv6).filter(Boolean),
|
|
300
339
|
neighborTable: fresh?.neighborTable ?? (Array.isArray(tnd.neighborTable) ? tnd.neighborTable : []),
|
|
301
340
|
routeTable: fresh?.routeTable ?? (Array.isArray(tnd.routeTable) ? tnd.routeTable : []),
|
|
302
341
|
readAt: fresh?.readAt ?? null,
|
|
@@ -493,4 +532,4 @@ function stop() {
|
|
|
493
532
|
_lastRefresh = null;
|
|
494
533
|
}
|
|
495
534
|
|
|
496
|
-
module.exports = { buildGraph, getMesh, refresh, meshcopQuery, start, stop, hex64, lqiQuality, rssiQuality, ROLES, _tables, _meshcop, _meshcopNode };
|
|
535
|
+
module.exports = { _ipv6, partitionHex, buildGraph, getMesh, refresh, meshcopQuery, start, stop, hex64, lqiQuality, rssiQuality, ROLES, _tables, _meshcop, _meshcopNode };
|
package/src/sandbox/stdlib.js
CHANGED
|
@@ -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)) {
|
package/src/web/ai-tools.js
CHANGED
|
@@ -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)}` :
|
|
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)
|
|
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
|
-
|
|
12
|
-
|
|
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)
|