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,350 @@
1
+ 'use strict';
2
+ /**
3
+ * The daemon's runtime model: what every script has registered — subscriptions, variable listeners,
4
+ * schedules, sun events, timers (grouped by kind, name and the line that created them) and pending
5
+ * commands — with per-handler statistics, the timers pending under each handler, and a ring buffer
6
+ * of the daemon's gauges for the last hour.
7
+ *
8
+ * The Runtime page reads the whole model about once a second over the WebSocket while it is open;
9
+ * the script editor's Runtime panel reads one script's part of it. Neither changes the model: the
10
+ * actions (cancel a timer, run it now, run a schedule once) go through the closures the
11
+ * registration site stored on the entry, so the model never reaches into the sandbox itself.
12
+ *
13
+ * Statistics are kept since the script's load and as a sliding five-minute window of thirty 10 s
14
+ * buckets, so a handler firing a hundred times a second costs thirty small objects, not a list of
15
+ * every fire.
16
+ */
17
+
18
+ const WINDOW_MS = 5 * 60 * 1000;
19
+ const BUCKET_MS = 10_000;
20
+ const BUCKETS = WINDOW_MS / BUCKET_MS;
21
+
22
+ /** Fires, errors and time of one handler: totals since load and the last five minutes. */
23
+ class HandlerStats {
24
+ constructor() {
25
+ this.fires = 0;
26
+ this.errors = 0;
27
+ this.totalMs = 0;
28
+ this.maxMs = 0;
29
+ this.lastFire = null;
30
+ this.lastMs = null;
31
+ this.lastError = null;
32
+ this._buckets = [];
33
+ for (let i = 0; i < BUCKETS; i++) this._buckets.push({ start: 0, fires: 0, errors: 0, ms: 0 });
34
+ }
35
+
36
+ _bucket(now) {
37
+ const start = Math.floor(now / BUCKET_MS) * BUCKET_MS;
38
+ const b = this._buckets[(start / BUCKET_MS) % BUCKETS];
39
+ if (b.start !== start) {
40
+ b.start = start;
41
+ b.fires = 0;
42
+ b.errors = 0;
43
+ b.ms = 0;
44
+ }
45
+ return b;
46
+ }
47
+
48
+ /**
49
+ * One callback run.
50
+ * @param {number} ms how long it took
51
+ * @param {boolean} [failed] it threw or rejected
52
+ * @param {number} [now]
53
+ */
54
+ record(ms, failed = false, now = Date.now()) {
55
+ this.fires++;
56
+ this.totalMs += ms;
57
+ if (ms > this.maxMs) this.maxMs = ms;
58
+ this.lastFire = now;
59
+ this.lastMs = ms;
60
+ const b = this._bucket(now);
61
+ b.fires++;
62
+ b.ms += ms;
63
+ if (failed) {
64
+ this.errors++;
65
+ this.lastError = now;
66
+ b.errors++;
67
+ }
68
+ }
69
+
70
+ /** An error that showed up after the run was recorded (a rejected promise). */
71
+ fail(now = Date.now()) {
72
+ this.errors++;
73
+ this.lastError = now;
74
+ this._bucket(now).errors++;
75
+ }
76
+
77
+ /** The last five minutes. */
78
+ recent(now = Date.now()) {
79
+ const since = now - WINDOW_MS;
80
+ const r = { fires: 0, errors: 0, totalMs: 0 };
81
+ for (const b of this._buckets) {
82
+ if (b.start <= since || b.start > now) continue;
83
+ r.fires += b.fires;
84
+ r.errors += b.errors;
85
+ r.totalMs += b.ms;
86
+ }
87
+ return r;
88
+ }
89
+
90
+ toJSON(now = Date.now()) {
91
+ return {
92
+ fires: this.fires,
93
+ errors: this.errors,
94
+ totalMs: Math.round(this.totalMs * 100) / 100,
95
+ maxMs: Math.round(this.maxMs * 100) / 100,
96
+ lastFire: this.lastFire,
97
+ lastMs: this.lastMs === null ? null : Math.round(this.lastMs * 100) / 100,
98
+ lastError: this.lastError,
99
+ recent: (() => {
100
+ const r = this.recent(now);
101
+ r.totalMs = Math.round(r.totalMs * 100) / 100;
102
+ return r;
103
+ })(),
104
+ };
105
+ }
106
+ }
107
+
108
+ /** The daemon's gauges, one sample per interval, for the last hour. */
109
+ class GaugeHistory {
110
+ /**
111
+ * @param {object} [opts]
112
+ * @param {number} [opts.intervalMs=10000] how often a sample is kept
113
+ * @param {number} [opts.size=360] samples kept (360 × 10 s = one hour)
114
+ */
115
+ constructor(opts = {}) {
116
+ this.intervalMs = opts.intervalMs || 10_000;
117
+ this.size = opts.size || 360;
118
+ this._samples = [];
119
+ this._lastKept = null;
120
+ }
121
+
122
+ /**
123
+ * Offer a sample; it is kept when the interval since the last kept one has passed.
124
+ * @param {object} sample must carry `ts`
125
+ * @returns {boolean} kept
126
+ */
127
+ push(sample) {
128
+ if (this._lastKept !== null && sample.ts - this._lastKept < this.intervalMs) return false;
129
+ this._lastKept = sample.ts;
130
+ this._samples.push(sample);
131
+ if (this._samples.length > this.size) this._samples.shift();
132
+ return true;
133
+ }
134
+
135
+ list() {
136
+ return this._samples.slice();
137
+ }
138
+ }
139
+
140
+ class NotFoundError extends Error {
141
+ constructor(id) {
142
+ super(`unknown runtime id ${id} — the script may have been reloaded`);
143
+ this.code = 'ENOENT';
144
+ }
145
+ }
146
+
147
+ /**
148
+ * The registry of handlers and their pending timers.
149
+ */
150
+ class Runtime {
151
+ constructor() {
152
+ this._nextId = 1;
153
+ /** @type {Map<string, object>} handler id → handler */
154
+ this.handlers = new Map();
155
+ /** @type {Map<string, object>} pending id → pending (timer, command) */
156
+ this.pending = new Map();
157
+ /** @type {Map<string, Set<object>>} script file → its handlers */
158
+ this.byScript = new Map();
159
+ /** @type {Map<string, object>} `${script}\0${group key}` → timer group handler */
160
+ this._groups = new Map();
161
+ this.gauges = new GaugeHistory();
162
+ this.latestGauges = null;
163
+ this.commandsFailed = 0;
164
+ }
165
+
166
+ /**
167
+ * Register a handler.
168
+ * @param {string} script the script file
169
+ * @param {object} info
170
+ * @param {string} info.kind `sub`, `var`, `schedule`, `sun`, or a timer kind
171
+ * @param {string} [info.name] what the UI shows: the topic, the pattern, the timer's name
172
+ * @param {string} [info.detail] a second line: the options, the other topics
173
+ * @param {number|null} [info.line] the script line that registered it
174
+ * @param {Function} [info.run] runs the handler once now (a schedule, an interval)
175
+ * @param {Function} [info.cancel] removes the handler (an interval)
176
+ * @param {Function} [info.next] → ms timestamp of the next run, or null
177
+ * @returns {object} the handler
178
+ */
179
+ handler(script, info) {
180
+ const h = {
181
+ id: 'h' + this._nextId++,
182
+ script,
183
+ kind: info.kind,
184
+ name: info.name ?? null,
185
+ detail: info.detail ?? null,
186
+ line: info.line ?? null,
187
+ stats: new HandlerStats(),
188
+ pending: new Map(),
189
+ run: info.run || null,
190
+ cancel: info.cancel || null,
191
+ next: info.next || null,
192
+ groupKey: info.groupKey || null,
193
+ };
194
+ this.handlers.set(h.id, h);
195
+ let set = this.byScript.get(script);
196
+ if (!set) {
197
+ set = new Set();
198
+ this.byScript.set(script, set);
199
+ }
200
+ set.add(h);
201
+ return h;
202
+ }
203
+
204
+ /**
205
+ * The group handler for timers of one kind, name and line in a script — found or created. Its
206
+ * statistics accumulate over every timer that fired under it; the timers pending right now are
207
+ * its `pending`.
208
+ * @param {string} script
209
+ * @param {{kind: string, name?: string, line?: number|null}} meta
210
+ */
211
+ timerGroup(script, meta) {
212
+ const key = `${meta.kind}\0${meta.name ?? ''}\0${meta.line ?? ''}`;
213
+ const mapKey = `${script}\0${key}`;
214
+ let h = this._groups.get(mapKey);
215
+ if (!h) {
216
+ h = this.handler(script, { kind: meta.kind, name: meta.name, line: meta.line, groupKey: key });
217
+ this._groups.set(mapKey, h);
218
+ }
219
+ return h;
220
+ }
221
+
222
+ /**
223
+ * A timer (or command) pending under a handler.
224
+ * @param {object} handler
225
+ * @param {object} info
226
+ * @param {number} info.due ms timestamp when it fires
227
+ * @param {number} [info.start] ms timestamp when it was armed (default now)
228
+ * @param {number} [info.every] an interval's period
229
+ * @param {Function} [info.fire] fires it now (removes it, runs the callback)
230
+ * @param {Function} [info.cancel] removes it without running
231
+ * @param {object} [info.extra] shown as is (a command's topic, payload, tries)
232
+ * @returns {object} the pending entry
233
+ */
234
+ addPending(handler, info) {
235
+ const p = {
236
+ id: 't' + this._nextId++,
237
+ handler,
238
+ due: info.due,
239
+ start: info.start ?? Date.now(),
240
+ every: info.every ?? null,
241
+ fire: info.fire || null,
242
+ cancel: info.cancel || null,
243
+ extra: info.extra || null,
244
+ };
245
+ handler.pending.set(p.id, p);
246
+ this.pending.set(p.id, p);
247
+ return p;
248
+ }
249
+
250
+ /** A pending entry fired or was cleared. */
251
+ removePending(p) {
252
+ if (!p) return;
253
+ p.handler.pending.delete(p.id);
254
+ this.pending.delete(p.id);
255
+ }
256
+
257
+ /** A handler is gone (the subscription removed, the interval cleared). */
258
+ removeHandler(h) {
259
+ if (!h || !this.handlers.has(h.id)) return;
260
+ for (const p of h.pending.values()) this.pending.delete(p.id);
261
+ h.pending.clear();
262
+ this.handlers.delete(h.id);
263
+ this.byScript.get(h.script)?.delete(h);
264
+ if (h.groupKey) this._groups.delete(`${h.script}\0${h.groupKey}`);
265
+ }
266
+
267
+ /** Everything a script registered — on unload. */
268
+ removeScript(script) {
269
+ const set = this.byScript.get(script);
270
+ if (!set) return;
271
+ for (const h of [...set]) this.removeHandler(h);
272
+ this.byScript.delete(script);
273
+ }
274
+
275
+ /**
276
+ * An action from the editor: `cancel` or `run` on a pending timer, `run` (once) or `cancel` on a
277
+ * handler that offers it.
278
+ * @param {string} id
279
+ * @param {'cancel'|'run'} action
280
+ * @returns {{script: string, kind: string, name: string|null, what: 'timer'|'handler'}}
281
+ */
282
+ action(id, action) {
283
+ if (action !== 'cancel' && action !== 'run') throw new TypeError(`unknown action ${action}`);
284
+ const p = this.pending.get(id);
285
+ if (p) {
286
+ const fn = action === 'run' ? p.fire : p.cancel;
287
+ if (!fn) throw new TypeError(`${action} is not possible for a pending ${p.handler.kind}`);
288
+ fn();
289
+ return { script: p.handler.script, kind: p.handler.kind, name: p.handler.name, what: 'timer' };
290
+ }
291
+ const h = this.handlers.get(id);
292
+ if (h) {
293
+ const fn = action === 'run' ? h.run : h.cancel;
294
+ if (!fn) throw new TypeError(`${action} is not possible for a ${h.kind}`);
295
+ fn();
296
+ return { script: h.script, kind: h.kind, name: h.name, what: 'handler' };
297
+ }
298
+ throw new NotFoundError(id);
299
+ }
300
+
301
+ /**
302
+ * The model as JSON.
303
+ * @param {object} [opts]
304
+ * @param {string} [opts.script] only this script file
305
+ * @param {Function} [opts.label] script file → label shown in the UI
306
+ * @param {Function} [opts.origin] script file → `builtin` | `user`
307
+ * @param {number} [opts.now]
308
+ */
309
+ snapshot(opts = {}) {
310
+ const now = opts.now ?? Date.now();
311
+ const label = opts.label || ((f) => f);
312
+ const origin = opts.origin || (() => 'user');
313
+ const scripts = [];
314
+ for (const [file, set] of this.byScript) {
315
+ if (opts.script !== undefined && file !== opts.script) continue;
316
+ const handlers = [];
317
+ for (const h of set) {
318
+ let next = null;
319
+ if (h.next) {
320
+ try {
321
+ next = h.next();
322
+ } catch {
323
+ next = null;
324
+ }
325
+ }
326
+ const pending = [];
327
+ for (const p of h.pending.values()) {
328
+ pending.push({ id: p.id, due: p.due, start: p.start, every: p.every, ...(p.extra || {}) });
329
+ }
330
+ pending.sort((a, b) => a.due - b.due);
331
+ handlers.push({
332
+ id: h.id,
333
+ kind: h.kind,
334
+ name: h.name,
335
+ detail: h.detail,
336
+ line: h.line,
337
+ next,
338
+ actions: { run: !!h.run, cancel: !!h.cancel },
339
+ ...h.stats.toJSON(now),
340
+ pending,
341
+ });
342
+ }
343
+ scripts.push({ file, label: label(file), origin: origin(file), handlers });
344
+ }
345
+ scripts.sort((a, b) => a.label.localeCompare(b.label));
346
+ return { ts: now, gauges: this.latestGauges, commandsFailed: this.commandsFailed, scripts };
347
+ }
348
+ }
349
+
350
+ module.exports = { Runtime, HandlerStats, GaugeHistory, NotFoundError, WINDOW_MS, BUCKET_MS };
@@ -7,21 +7,63 @@
7
7
  * every timeout that ran normally stayed in the set for the life of the daemon — with its callback
8
8
  * and everything the callback captured. A script that starts a "switch off after N minutes" timer
9
9
  * per event leaked hundreds of them an hour, and the heap ran full after about twelve days.
10
- *
10
+ */
11
+
12
+ /**
13
+ * A delay a script passed to a timer API: a finite number of milliseconds, not negative.
14
+ * Node coerces anything else to NaN, warns once per process without naming the caller and fires
15
+ * after 1 ms — a swapped argument order or an undefined config field became a silent 1 ms timer.
16
+ * @param {*} delay
17
+ * @returns {boolean}
18
+ */
19
+ function isValidDelay(delay) {
20
+ return typeof delay === 'number' && Number.isFinite(delay) && delay >= 0;
21
+ }
22
+
23
+ /**
24
+ * How an invalid delay is described in the warning: `undefined`, `NaN`, `"5m"`, `an object`.
25
+ * @param {*} delay
26
+ * @returns {string}
27
+ */
28
+ function describeDelay(delay) {
29
+ if (typeof delay === 'string') return JSON.stringify(delay);
30
+ if (typeof delay === 'function') return 'a function';
31
+ if (delay !== null && typeof delay === 'object') return Array.isArray(delay) ? 'an array' : 'an object';
32
+ return String(delay);
33
+ }
34
+
35
+ /**
11
36
  * @param {Set} timers the script's timer set
12
37
  * @param {Function} fn called when the timer fires
13
- * @param {number} delay ms
14
- * @param {Function} [setTimeoutFn] for tests
15
- * @returns the timer id (what clearTimeout takes)
38
+ * @param {number} delay ms; an invalid delay is reported through `opts.warn` and runs as 0
39
+ * @param {object|Function} [opts] options, or (legacy) the setTimeout function to use
40
+ * @param {Function} [opts.setTimeoutFn] for tests
41
+ * @param {Function} [opts.warn] called with a message when the delay is not a number (the caller
42
+ * adds the script's label); without it an invalid delay is silently 0
43
+ * @param {string} [opts.api] the API name for the warning, default `setTimeout`
44
+ * @param {string} [opts.kind] shown in the timer list: `debounce`, `throttle`, `timeout`, `retry`
45
+ * @param {string} [opts.name] shown in the timer list next to the kind
46
+ * @returns the timer id (what clearTimeout takes); carries `due`, and `kind`/`name` when given
16
47
  */
17
- function trackTimeout(timers, fn, delay, setTimeoutFn = setTimeout) {
48
+ function trackTimeout(timers, fn, delay, opts = {}) {
49
+ if (typeof opts === 'function') opts = { setTimeoutFn: opts };
50
+ const setTimeoutFn = opts.setTimeoutFn || setTimeout;
51
+ if (!isValidDelay(delay)) {
52
+ if (typeof opts.warn === 'function') opts.warn(`${opts.api || 'setTimeout'}: delay is not a number of milliseconds (got ${describeDelay(delay)}), using 0`);
53
+ delay = 0;
54
+ }
18
55
  const id = setTimeoutFn(() => {
19
56
  timers.delete(id);
20
57
  fn();
21
58
  }, delay);
22
- if (id && typeof id === 'object') id.due = Date.now() + delay; // when it fires (roadmap I28: list_timers)
59
+ if (id && typeof id === 'object') {
60
+ id.start = Date.now(); // when it was armed — the progress bar of the timer list runs from here
61
+ id.due = id.start + delay; // when it fires (roadmap I28: list_timers)
62
+ if (opts.kind) id.kind = opts.kind;
63
+ if (opts.name) id.name = opts.name;
64
+ }
23
65
  timers.add(id);
24
66
  return id;
25
67
  }
26
68
 
27
- module.exports = { trackTimeout };
69
+ module.exports = { trackTimeout, isValidDelay, describeDelay };