smart-home-engine 2.1.0 → 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 };
@@ -57,7 +57,8 @@ function trackTimeout(timers, fn, delay, opts = {}) {
57
57
  fn();
58
58
  }, delay);
59
59
  if (id && typeof id === 'object') {
60
- id.due = Date.now() + delay; // when it fires (roadmap I28: list_timers)
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)
61
62
  if (opts.kind) id.kind = opts.kind;
62
63
  if (opts.name) id.name = opts.name;
63
64
  }