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.
- package/dist/web/assets/index-BZWF5ORr.js +394 -0
- package/dist/web/assets/{index-Bu5o4KXc.css → index-CdLKmsX4.css} +1 -1
- package/dist/web/assets/{tsMode-DmHeR7H2.js → tsMode-BquNtbO5.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/src/config.js +21 -1
- package/src/index.js +370 -47
- package/src/lib/call-site.js +51 -0
- package/src/lib/command-topics.js +162 -0
- package/src/lib/command.js +188 -0
- package/src/lib/holidays.js +188 -0
- package/src/lib/runtime.js +350 -0
- package/src/lib/script-timers.js +2 -1
- package/src/lib/time.js +402 -0
- package/src/lib/timing.js +10 -5
- package/src/lib/update-channel.js +152 -0
- package/src/sandbox/stdlib.js +5 -0
- package/src/web/ai-tools.js +6 -2
- package/src/web/config-api.js +4 -3
- package/src/web/log-ws.js +49 -2
- package/src/web/prompts/api-ref-compact.md +2 -0
- package/src/web/prompts/api-ref.md +23 -0
- package/src/web/runtime-api.js +66 -0
- package/src/web/server.js +51 -19
- package/dist/web/assets/index-DtW7-hC9.js +0 -330
|
@@ -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 };
|
package/src/lib/script-timers.js
CHANGED
|
@@ -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.
|
|
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
|
}
|