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
package/src/lib/time.js
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Zone-aware time helpers for scripts: time windows, wall-clock instants, weekend and holidays,
|
|
4
|
+
* formatting, durations — `she.time`.
|
|
5
|
+
*
|
|
6
|
+
* Everything is computed from `Intl.DateTimeFormat` in the configured time zone, so a script
|
|
7
|
+
* gets the same answers whatever zone the process runs in, and the daylight-saving changes are
|
|
8
|
+
* handled once, here, instead of in every script:
|
|
9
|
+
*
|
|
10
|
+
* - A wall-clock time that does not exist (02:30 on the day the clocks go forward) resolves
|
|
11
|
+
* to the instant one hour later on the wall clock (03:30), like Temporal's `compatible`
|
|
12
|
+
* disambiguation. One that exists twice (02:30 on the day the clocks go back) resolves to
|
|
13
|
+
* its first occurrence (the one still in summer time).
|
|
14
|
+
* - A window compares instants, so the window 02:00–03:00 lasts two hours on the autumn day
|
|
15
|
+
* and is empty on the spring day.
|
|
16
|
+
* - Durations are differences of instants: a night that spans the autumn change is an hour
|
|
17
|
+
* longer than its wall-clock times suggest, and `duration` says so.
|
|
18
|
+
*
|
|
19
|
+
* The helpers take a timestamp in ms (what `she.now()`, `obj.ts` and `Date.now()` give), a
|
|
20
|
+
* `Date`, or an ISO string, and default to now. No dependency besides Intl.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const holidayTable = require('./holidays');
|
|
24
|
+
|
|
25
|
+
const DAY = 86400000;
|
|
26
|
+
const WEEKDAYS = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
|
|
27
|
+
|
|
28
|
+
const _formatters = new Map();
|
|
29
|
+
function formatter(locale, options) {
|
|
30
|
+
const key = locale + '|' + JSON.stringify(options);
|
|
31
|
+
let f = _formatters.get(key);
|
|
32
|
+
if (!f) {
|
|
33
|
+
f = new Intl.DateTimeFormat(locale, options);
|
|
34
|
+
if (_formatters.size > 500) _formatters.clear();
|
|
35
|
+
_formatters.set(key, f);
|
|
36
|
+
}
|
|
37
|
+
return f;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The IANA zone the process runs in. */
|
|
41
|
+
function systemZone() {
|
|
42
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isZone(name) {
|
|
46
|
+
if (typeof name !== 'string' || !name) return false;
|
|
47
|
+
try {
|
|
48
|
+
new Intl.DateTimeFormat('en-US', { timeZone: name });
|
|
49
|
+
return true;
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isLocale(tag) {
|
|
56
|
+
if (typeof tag !== 'string' || !tag) return false;
|
|
57
|
+
try {
|
|
58
|
+
return Intl.DateTimeFormat.supportedLocalesOf([tag]).length > 0;
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The wall-clock fields of an instant in a zone.
|
|
66
|
+
* @returns {{year:number, month:number, day:number, hour:number, minute:number, second:number, millisecond:number, weekday:number, offset:number}}
|
|
67
|
+
* month 1–12, weekday 0 (Sunday) – 6, offset in minutes east of UTC
|
|
68
|
+
*/
|
|
69
|
+
function wallParts(timeZone, ts) {
|
|
70
|
+
const f = formatter('en-US', {
|
|
71
|
+
timeZone,
|
|
72
|
+
hourCycle: 'h23',
|
|
73
|
+
year: 'numeric',
|
|
74
|
+
month: '2-digit',
|
|
75
|
+
day: '2-digit',
|
|
76
|
+
hour: '2-digit',
|
|
77
|
+
minute: '2-digit',
|
|
78
|
+
second: '2-digit',
|
|
79
|
+
weekday: 'short',
|
|
80
|
+
});
|
|
81
|
+
const whole = Math.floor(ts / 1000) * 1000;
|
|
82
|
+
const p = {};
|
|
83
|
+
for (const { type, value } of f.formatToParts(new Date(whole))) p[type] = value;
|
|
84
|
+
const year = Number(p.year);
|
|
85
|
+
const month = Number(p.month);
|
|
86
|
+
const day = Number(p.day);
|
|
87
|
+
const hour = Number(p.hour) % 24;
|
|
88
|
+
const minute = Number(p.minute);
|
|
89
|
+
const second = Number(p.second);
|
|
90
|
+
const offset = Math.round((Date.UTC(year, month - 1, day, hour, minute, second) - whole) / 60000);
|
|
91
|
+
return { year, month, day, hour, minute, second, millisecond: ts - whole, weekday: WEEKDAYS[p.weekday], offset };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function offsetAt(timeZone, ts) {
|
|
95
|
+
return wallParts(timeZone, ts).offset;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The instant of a wall-clock time in a zone. Fields overflow like `Date.UTC` (day 32, hour 24).
|
|
100
|
+
* A time in a gap resolves forward by the gap; an ambiguous time to its first occurrence.
|
|
101
|
+
*/
|
|
102
|
+
function zonedToInstant(timeZone, year, month, day, hour = 0, minute = 0, second = 0, millisecond = 0) {
|
|
103
|
+
const wall = Date.UTC(year, month - 1, day, hour, minute, second, millisecond);
|
|
104
|
+
const before = offsetAt(timeZone, wall - DAY);
|
|
105
|
+
const after = offsetAt(timeZone, wall + DAY);
|
|
106
|
+
let first = NaN;
|
|
107
|
+
for (const off of before === after ? [before] : [before, after]) {
|
|
108
|
+
const instant = wall - off * 60000;
|
|
109
|
+
if (offsetAt(timeZone, instant) === off && !(instant >= first)) first = instant;
|
|
110
|
+
}
|
|
111
|
+
return Number.isNaN(first) ? wall - before * 60000 : first;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const UNIT_MS = { ms: 1, s: 1000, m: 60000, h: 3600000 };
|
|
115
|
+
|
|
116
|
+
function describe(x) {
|
|
117
|
+
if (typeof x === 'string') return JSON.stringify(x);
|
|
118
|
+
if (x && typeof x === 'object') return Object.prototype.toString.call(x);
|
|
119
|
+
return String(x);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* @param {object} opts
|
|
124
|
+
* @param {string} [opts.timeZone] IANA zone; default the process zone
|
|
125
|
+
* @param {string} [opts.locale] BCP 47 tag for names and words; default 'de-DE'
|
|
126
|
+
* @param {string|null} [opts.holidays] region code of the holiday table, e.g. 'DE-BW'; null = not configured
|
|
127
|
+
* @param {Array<{date: string, name: string}>} [opts.holidaysExtra] parsed with holidays.parseExtra
|
|
128
|
+
* @param {Function} [opts.sunTimes] (noonMs) → the sun events of that day as Dates (suncalc.getTimes)
|
|
129
|
+
* @param {Function} [opts.now] → ms, default Date.now
|
|
130
|
+
* @param {Function} [opts.warn] (msg) one-off warnings
|
|
131
|
+
*/
|
|
132
|
+
function createTime(opts = {}) {
|
|
133
|
+
const timeZone = isZone(opts.timeZone) ? opts.timeZone : systemZone();
|
|
134
|
+
const locale = isLocale(opts.locale) ? opts.locale : 'de-DE';
|
|
135
|
+
const holidays = holidayTable.isRegion(opts.holidays) ? holidayTable.normalizeRegion(opts.holidays) : null;
|
|
136
|
+
const extra = Array.isArray(opts.holidaysExtra) ? opts.holidaysExtra : [];
|
|
137
|
+
const sunTimes = typeof opts.sunTimes === 'function' ? opts.sunTimes : null;
|
|
138
|
+
const now = typeof opts.now === 'function' ? opts.now : Date.now;
|
|
139
|
+
const warn = typeof opts.warn === 'function' ? opts.warn : () => {};
|
|
140
|
+
let warnedHolidays = false;
|
|
141
|
+
|
|
142
|
+
function toMs(x, api, what = 'ts') {
|
|
143
|
+
if (x === undefined || x === null) return now();
|
|
144
|
+
if (typeof x === 'number') return x;
|
|
145
|
+
if (x && typeof x === 'object' && typeof x.getTime === 'function') return x.getTime();
|
|
146
|
+
if (typeof x === 'string') {
|
|
147
|
+
const ms = Date.parse(x);
|
|
148
|
+
if (!Number.isNaN(ms)) return ms;
|
|
149
|
+
}
|
|
150
|
+
throw new TypeError(`${api}: ${what} must be a timestamp in ms, a Date or an ISO string (got ${describe(x)})`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const pad = (n, w = 2) => String(n).padStart(w, '0');
|
|
154
|
+
|
|
155
|
+
function offsetString(minutes) {
|
|
156
|
+
const sign = minutes < 0 ? '-' : '+';
|
|
157
|
+
const abs = Math.abs(minutes);
|
|
158
|
+
return `${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function parts(ts) {
|
|
162
|
+
const p = wallParts(timeZone, toMs(ts, 'she.time.parts([ts])'));
|
|
163
|
+
p.zone = timeZone;
|
|
164
|
+
return p;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function startOfDay(ts) {
|
|
168
|
+
const p = wallParts(timeZone, toMs(ts, 'she.time.startOfDay([ts])'));
|
|
169
|
+
return zonedToInstant(timeZone, p.year, p.month, p.day);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function endOfDay(ts) {
|
|
173
|
+
const p = wallParts(timeZone, toMs(ts, 'she.time.endOfDay([ts])'));
|
|
174
|
+
return zonedToInstant(timeZone, p.year, p.month, p.day + 1) - 1;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function addDays(ts, days) {
|
|
178
|
+
const api = 'she.time.addDays(ts, days)';
|
|
179
|
+
const p = wallParts(timeZone, toMs(ts, api));
|
|
180
|
+
if (typeof days !== 'number' || !Number.isInteger(days)) throw new TypeError(`${api}: days must be an integer (got ${describe(days)})`);
|
|
181
|
+
return zonedToInstant(timeZone, p.year, p.month, p.day + days, p.hour, p.minute, p.second, p.millisecond);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Resolve 'HH:mm[:ss]' or a sun event with offset ('sunset-30m') on the day of `dayTs`. */
|
|
185
|
+
function resolve(spec, dayTs, api) {
|
|
186
|
+
if (typeof spec === 'number') return spec;
|
|
187
|
+
if (spec && typeof spec === 'object' && typeof spec.getTime === 'function') return spec.getTime();
|
|
188
|
+
if (typeof spec !== 'string') throw new TypeError(`${api}: expected 'HH:mm' or a sun event like 'sunset-30m' (got ${describe(spec)})`);
|
|
189
|
+
const p = wallParts(timeZone, dayTs);
|
|
190
|
+
const clock = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/.exec(spec.trim());
|
|
191
|
+
if (clock) {
|
|
192
|
+
const [, h, mi, s] = clock.map(Number);
|
|
193
|
+
if (h > 24 || mi > 59 || s > 59 || (h === 24 && (mi || s))) throw new TypeError(`${api}: ${describe(spec)} is not a time of day`);
|
|
194
|
+
return zonedToInstant(timeZone, p.year, p.month, p.day, h, mi, s || 0);
|
|
195
|
+
}
|
|
196
|
+
const sun = /^([a-zA-Z]+)(?:\s*([+-])\s*(\d+(?:\.\d+)?)(ms|s|m|h)?)?$/.exec(spec.trim());
|
|
197
|
+
if (!sun) throw new TypeError(`${api}: expected 'HH:mm' or a sun event like 'sunset-30m' (got ${describe(spec)})`);
|
|
198
|
+
const [, name, sign, amount, unit] = sun;
|
|
199
|
+
if (!sunTimes) throw new TypeError(`${api}: sun events are not available here`);
|
|
200
|
+
const times = sunTimes(zonedToInstant(timeZone, p.year, p.month, p.day, 12));
|
|
201
|
+
if (!times || !Object.prototype.hasOwnProperty.call(times, name)) {
|
|
202
|
+
throw new TypeError(`${api}: unknown time or sun event ${describe(spec)} — expected 'HH:mm' or one of ${Object.keys(times || {}).join(', ')}`);
|
|
203
|
+
}
|
|
204
|
+
if (sign && !unit) throw new TypeError(`${api}: the offset of ${describe(spec)} needs a unit: ms, s, m or h (e.g. 'sunset-30m')`);
|
|
205
|
+
const base = times[name] instanceof Date ? times[name].getTime() : toMs(times[name], api);
|
|
206
|
+
return base + (sign ? (sign === '-' ? -1 : 1) * Number(amount) * UNIT_MS[unit] : 0);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function at(spec, ts) {
|
|
210
|
+
const api = 'she.time.at(time, [ts])';
|
|
211
|
+
return resolve(spec, toMs(ts, api), api);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function between(from, to, ts) {
|
|
215
|
+
const api = 'she.time.between(from, to, [ts])';
|
|
216
|
+
const t = toMs(ts, api);
|
|
217
|
+
const f = resolve(from, t, api);
|
|
218
|
+
const e = resolve(to, t, api);
|
|
219
|
+
if (Number.isNaN(f) || Number.isNaN(e)) return false;
|
|
220
|
+
return f <= e ? t >= f && t < e : t >= f || t < e;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function isWeekend(ts) {
|
|
224
|
+
const d = wallParts(timeZone, toMs(ts, 'she.time.isWeekend([ts])')).weekday;
|
|
225
|
+
return d === 0 || d === 6;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function holiday(ts) {
|
|
229
|
+
const p = wallParts(timeZone, toMs(ts, 'she.time.holiday([ts])'));
|
|
230
|
+
const md = `${pad(p.month)}-${pad(p.day)}`;
|
|
231
|
+
const ymd = `${p.year}-${md}`;
|
|
232
|
+
for (const x of extra) if (x.date === md || x.date === ymd) return x.name;
|
|
233
|
+
if (!holidays) {
|
|
234
|
+
if (!warnedHolidays && !extra.length) {
|
|
235
|
+
warnedHolidays = true;
|
|
236
|
+
warn('she.time: no holidays configured — set "holidays" (a region such as DE-BW) or "holidaysExtra" in the config; isHoliday() is always false');
|
|
237
|
+
}
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
return holidayTable.holidaysOfYear(holidays, p.year).get(md) || null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const isHoliday = (ts) => holiday(ts) !== null;
|
|
244
|
+
const isWorkday = (ts) => !isWeekend(ts) && !isHoliday(ts);
|
|
245
|
+
|
|
246
|
+
function names(kind, style, ts) {
|
|
247
|
+
return formatter(locale, { timeZone, [kind]: style }).format(new Date(ts));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const TOKEN = /\[([^\]]*)\]|YYYY|YY|MMMM|MMM|MM|M|DD|D|HH|H|mm|m|ss|s|SSS|ddd|dd|Z|./gs;
|
|
251
|
+
|
|
252
|
+
function format(ts, pattern = 'YYYY-MM-DD HH:mm:ss') {
|
|
253
|
+
const api = 'she.time.format(ts, pattern)';
|
|
254
|
+
const ms = toMs(ts, api);
|
|
255
|
+
if (typeof pattern !== 'string') throw new TypeError(`${api}: pattern must be a string (got ${describe(pattern)})`);
|
|
256
|
+
const p = wallParts(timeZone, ms);
|
|
257
|
+
return pattern.replace(TOKEN, (tok, literal) => {
|
|
258
|
+
if (literal !== undefined) return literal;
|
|
259
|
+
switch (tok) {
|
|
260
|
+
case 'YYYY':
|
|
261
|
+
return pad(p.year, 4);
|
|
262
|
+
case 'YY':
|
|
263
|
+
return pad(p.year % 100);
|
|
264
|
+
case 'MMMM':
|
|
265
|
+
return names('month', 'long', ms);
|
|
266
|
+
case 'MMM':
|
|
267
|
+
return names('month', 'short', ms);
|
|
268
|
+
case 'MM':
|
|
269
|
+
return pad(p.month);
|
|
270
|
+
case 'M':
|
|
271
|
+
return String(p.month);
|
|
272
|
+
case 'DD':
|
|
273
|
+
return pad(p.day);
|
|
274
|
+
case 'D':
|
|
275
|
+
return String(p.day);
|
|
276
|
+
case 'HH':
|
|
277
|
+
return pad(p.hour);
|
|
278
|
+
case 'H':
|
|
279
|
+
return String(p.hour);
|
|
280
|
+
case 'mm':
|
|
281
|
+
return pad(p.minute);
|
|
282
|
+
case 'm':
|
|
283
|
+
return String(p.minute);
|
|
284
|
+
case 'ss':
|
|
285
|
+
return pad(p.second);
|
|
286
|
+
case 's':
|
|
287
|
+
return String(p.second);
|
|
288
|
+
case 'SSS':
|
|
289
|
+
return pad(p.millisecond, 3);
|
|
290
|
+
case 'ddd':
|
|
291
|
+
return names('weekday', 'long', ms);
|
|
292
|
+
case 'dd':
|
|
293
|
+
return names('weekday', 'short', ms);
|
|
294
|
+
case 'Z':
|
|
295
|
+
return offsetString(p.offset);
|
|
296
|
+
default:
|
|
297
|
+
return tok;
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function iso(ts) {
|
|
303
|
+
const ms = toMs(ts, 'she.time.iso([ts])');
|
|
304
|
+
const p = wallParts(timeZone, ms);
|
|
305
|
+
return `${pad(p.year, 4)}-${pad(p.month)}-${pad(p.day)}T${pad(p.hour)}:${pad(p.minute)}:${pad(p.second)}${offsetString(p.offset)}`;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function duration(a, b) {
|
|
309
|
+
const api = 'she.time.duration(a, b)';
|
|
310
|
+
return toMs(b, api, 'b') - toMs(a, api, 'a');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const UNITS = [
|
|
314
|
+
['day', 'd', DAY],
|
|
315
|
+
['hour', 'h', 3600000],
|
|
316
|
+
['minute', 'min', 60000],
|
|
317
|
+
['second', 's', 1000],
|
|
318
|
+
];
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* '2 h 40 min' — the two largest non-zero units with the SI-style symbols d, h, min, s;
|
|
322
|
+
* `{ long: true }` spells them out in the locale ('2 Stunden 40 Minuten'); `{ parts: n }`
|
|
323
|
+
* shows up to n units.
|
|
324
|
+
*/
|
|
325
|
+
function humanize(ms, options = {}) {
|
|
326
|
+
const api = 'she.time.humanize(ms, [opts])';
|
|
327
|
+
if (typeof ms !== 'number' || Number.isNaN(ms)) throw new TypeError(`${api}: ms must be a number (got ${describe(ms)})`);
|
|
328
|
+
const { long = false, parts: maxParts = 2 } = options;
|
|
329
|
+
const word = (n, unit, symbol) => (long ? new Intl.NumberFormat(locale, { style: 'unit', unit, unitDisplay: 'long' }).format(n) : `${n} ${symbol}`);
|
|
330
|
+
const sign = ms < 0 ? '-' : '';
|
|
331
|
+
let rest = Math.abs(Math.round(ms));
|
|
332
|
+
if (rest < 1000) return sign + word(rest, 'millisecond', 'ms');
|
|
333
|
+
const out = [];
|
|
334
|
+
let used = 0; // units from the largest non-zero one on, zero ones included: 2 h 0 min 30 s is '2 h'
|
|
335
|
+
for (const [unit, symbol, size] of UNITS) {
|
|
336
|
+
const n = Math.floor(rest / size);
|
|
337
|
+
rest -= n * size;
|
|
338
|
+
if (n > 0 || used) {
|
|
339
|
+
used++;
|
|
340
|
+
if (n > 0) out.push(word(n, unit, symbol));
|
|
341
|
+
if (used >= maxParts) break;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return sign + out.join(' ');
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return Object.freeze({
|
|
348
|
+
zone: timeZone,
|
|
349
|
+
locale,
|
|
350
|
+
holidays,
|
|
351
|
+
now: () => now(),
|
|
352
|
+
parts,
|
|
353
|
+
at,
|
|
354
|
+
between,
|
|
355
|
+
startOfDay,
|
|
356
|
+
endOfDay,
|
|
357
|
+
addDays,
|
|
358
|
+
isWeekend,
|
|
359
|
+
isHoliday,
|
|
360
|
+
holiday,
|
|
361
|
+
isWorkday,
|
|
362
|
+
format,
|
|
363
|
+
iso,
|
|
364
|
+
duration,
|
|
365
|
+
humanize,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* The validated time settings of a daemon from its config, with one warning per problem.
|
|
371
|
+
* @param {object} config `timezone`, `locale`, `holidays`, `holidaysExtra`
|
|
372
|
+
* @param {Function} [warn] (msg)
|
|
373
|
+
* @returns {{ timeZone: string, locale: string, holidays: string|null, holidaysExtra: Array<{date: string, name: string}> }}
|
|
374
|
+
*/
|
|
375
|
+
function settingsFromConfig(config = {}, warn = () => {}) {
|
|
376
|
+
let timeZone = systemZone();
|
|
377
|
+
if (config.timezone) {
|
|
378
|
+
if (isZone(config.timezone)) timeZone = config.timezone;
|
|
379
|
+
else warn(`config: timezone "${config.timezone}" is not an IANA time zone — using ${timeZone}`);
|
|
380
|
+
}
|
|
381
|
+
let locale = 'de-DE';
|
|
382
|
+
if (config.locale) {
|
|
383
|
+
if (isLocale(config.locale)) locale = config.locale;
|
|
384
|
+
else warn(`config: locale "${config.locale}" is not a known locale — using ${locale}`);
|
|
385
|
+
}
|
|
386
|
+
let holidays = null;
|
|
387
|
+
if (config.holidays) {
|
|
388
|
+
if (holidayTable.isRegion(config.holidays)) holidays = holidayTable.normalizeRegion(config.holidays);
|
|
389
|
+
else
|
|
390
|
+
warn(
|
|
391
|
+
`config: holidays "${config.holidays}" is not a known region — known: ${holidayTable
|
|
392
|
+
.listRegions()
|
|
393
|
+
.map((r) => r.code)
|
|
394
|
+
.join(', ')}`,
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
const parsed = holidayTable.parseExtra(config.holidaysExtra);
|
|
398
|
+
for (const e of parsed.errors) warn(`config: ${e}`);
|
|
399
|
+
return { timeZone, locale, holidays, holidaysExtra: parsed.entries };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
module.exports = { createTime, settingsFromConfig, wallParts, zonedToInstant, offsetAt, systemZone, isZone, isLocale };
|
package/src/lib/timing.js
CHANGED
|
@@ -34,6 +34,9 @@ function createTimingHelpers(prim) {
|
|
|
34
34
|
const { setTimeout: setTimer, clearTimeout: clearTimer } = prim;
|
|
35
35
|
const now = prim.now || Date.now;
|
|
36
36
|
const onUnload = typeof prim.onUnload === 'function' ? prim.onUnload : () => () => {};
|
|
37
|
+
// the script line a helper was created on, captured once per helper and carried in its timer meta
|
|
38
|
+
// (the stack at re-arm time points into a callback, not to the registration)
|
|
39
|
+
const lineAt = typeof prim.line === 'function' ? prim.line : () => null;
|
|
37
40
|
|
|
38
41
|
/**
|
|
39
42
|
* Bundle calls: run fn with the arguments of the last call once `wait` ms passed without a new
|
|
@@ -56,8 +59,9 @@ function createTimingHelpers(prim) {
|
|
|
56
59
|
if (maxWait !== undefined) assertDelay(maxWait, api, 'opts.maxWait');
|
|
57
60
|
if (!leading && !trailing) throw new TypeError(`${api}: opts.leading and opts.trailing cannot both be false`);
|
|
58
61
|
const name = opts.name || fn.name || undefined;
|
|
59
|
-
const
|
|
60
|
-
const
|
|
62
|
+
const line = opts.line !== undefined ? opts.line : lineAt();
|
|
63
|
+
const meta = { kind: 'debounce', name, line };
|
|
64
|
+
const maxMeta = { kind: 'debounce maxWait', name, line };
|
|
61
65
|
|
|
62
66
|
let timer = null;
|
|
63
67
|
let maxTimer = null;
|
|
@@ -144,7 +148,7 @@ function createTimingHelpers(prim) {
|
|
|
144
148
|
assertDelay(wait, api, 'wait');
|
|
145
149
|
const { leading = true, trailing = true, _onSettle } = opts;
|
|
146
150
|
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 };
|
|
151
|
+
const meta = { kind: 'throttle', name: opts.name || fn.name || undefined, line: opts.line !== undefined ? opts.line : lineAt() };
|
|
148
152
|
|
|
149
153
|
let timer = null;
|
|
150
154
|
let lastArgs = null;
|
|
@@ -258,7 +262,7 @@ function createTimingHelpers(prim) {
|
|
|
258
262
|
const api = 'she.timeout(ms, cb, [opts])';
|
|
259
263
|
assertDelay(ms, api, 'ms');
|
|
260
264
|
assertFn(cb, api, 'cb');
|
|
261
|
-
const meta = { kind: 'timeout', name: opts.name || cb.name || undefined };
|
|
265
|
+
const meta = { kind: 'timeout', name: opts.name || cb.name || undefined, line: lineAt() };
|
|
262
266
|
let id = null;
|
|
263
267
|
let due = 0;
|
|
264
268
|
const handle = {
|
|
@@ -315,6 +319,7 @@ function createTimingHelpers(prim) {
|
|
|
315
319
|
if (typeof backoff !== 'number' || !Number.isFinite(backoff) || backoff < 0) throw new TypeError(`${api}: opts.backoff must be a factor (got ${describeDelay(backoff)})`);
|
|
316
320
|
if (onFail !== undefined) assertFn(onFail, api, 'opts.onFail');
|
|
317
321
|
const name = opts.name || fn.name || undefined;
|
|
322
|
+
const line = lineAt();
|
|
318
323
|
|
|
319
324
|
let aborted = false;
|
|
320
325
|
const off = onUnload(() => {
|
|
@@ -348,7 +353,7 @@ function createTimingHelpers(prim) {
|
|
|
348
353
|
return;
|
|
349
354
|
}
|
|
350
355
|
const wait = delay * Math.pow(backoff, n - 1);
|
|
351
|
-
setTimer(() => attempt(n + 1), wait, { kind: 'retry', name: `${name ? name + ' ' : ''}attempt ${n + 1}/${tries}
|
|
356
|
+
setTimer(() => attempt(n + 1), wait, { kind: 'retry', name: `${name ? name + ' ' : ''}attempt ${n + 1}/${tries}`, line });
|
|
352
357
|
},
|
|
353
358
|
);
|
|
354
359
|
};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Which version the update check offers — by channel, from the GitHub releases.
|
|
4
|
+
*
|
|
5
|
+
* `stable` offers the newest release that is not a pre-release; `beta` offers the newest of all
|
|
6
|
+
* releases, so a beta tester gets `3.0.0-beta.1` after `3.0.0-beta.0` and `3.0.0` once it is
|
|
7
|
+
* out, and nobody is ever offered a downgrade: switching back to `stable` on a beta shows an
|
|
8
|
+
* update only when a stable release is newer than what runs. The version is installed exactly
|
|
9
|
+
* (`smart-home-engine@<version>`), so npm's dist-tags play no part — but the version has to exist
|
|
10
|
+
* on npm: a GitHub release whose npm job failed is not offered. Best-effort: every failure yields
|
|
11
|
+
* "no update known" and the reason, nothing throws.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const semverCompare = require('semantic-compare');
|
|
15
|
+
|
|
16
|
+
const REPO = 'hobbyquaker/she';
|
|
17
|
+
const PACKAGE = 'smart-home-engine';
|
|
18
|
+
const GITHUB_RELEASES = `https://api.github.com/repos/${REPO}/releases?per_page=30`;
|
|
19
|
+
const NPM_REGISTRY = 'https://registry.npmjs.org';
|
|
20
|
+
const CHANNELS = ['stable', 'beta'];
|
|
21
|
+
|
|
22
|
+
let _fetch = (...args) => fetch(...args);
|
|
23
|
+
|
|
24
|
+
/** Test hook: replace the fetch implementation. */
|
|
25
|
+
function setFetch(fn) {
|
|
26
|
+
_fetch = fn;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 'stable' or 'beta'; anything else is 'stable'. */
|
|
30
|
+
function normalizeChannel(channel) {
|
|
31
|
+
return CHANNELS.includes(channel) ? channel : 'stable';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const VERSION_RX = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/;
|
|
35
|
+
|
|
36
|
+
/** The version of a release tag (`v3.0.0-beta.0` → `3.0.0-beta.0`), or null when it is not one. */
|
|
37
|
+
function versionOfTag(tag) {
|
|
38
|
+
if (typeof tag !== 'string') return null;
|
|
39
|
+
const v = tag.replace(/^v/, '');
|
|
40
|
+
return VERSION_RX.test(v) ? v : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isPrerelease(version) {
|
|
44
|
+
return typeof version === 'string' && version.includes('-');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function compare(a, b) {
|
|
48
|
+
try {
|
|
49
|
+
return semverCompare(a, b);
|
|
50
|
+
} catch {
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function newest(versions) {
|
|
56
|
+
return versions.reduce((best, v) => (best === null || compare(v, best) > 0 ? v : best), null);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The newest stable release and the newest pre-release of a GitHub release list.
|
|
61
|
+
* @param {Array<{tag_name: string, prerelease?: boolean, draft?: boolean}>} releases
|
|
62
|
+
* @returns {{ stable: string|null, prerelease: string|null }}
|
|
63
|
+
*/
|
|
64
|
+
function newestOf(releases) {
|
|
65
|
+
const stable = [];
|
|
66
|
+
const pre = [];
|
|
67
|
+
for (const r of Array.isArray(releases) ? releases : []) {
|
|
68
|
+
if (!r || r.draft) continue;
|
|
69
|
+
const v = versionOfTag(r.tag_name);
|
|
70
|
+
if (!v) continue;
|
|
71
|
+
(r.prerelease || isPrerelease(v) ? pre : stable).push(v);
|
|
72
|
+
}
|
|
73
|
+
return { stable: newest(stable), prerelease: newest(pre) };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The version a channel offers, given the newest releases. */
|
|
77
|
+
function offeredVersion(channel, { stable, prerelease }) {
|
|
78
|
+
if (normalizeChannel(channel) === 'stable') return stable;
|
|
79
|
+
if (!stable) return prerelease;
|
|
80
|
+
if (!prerelease) return stable;
|
|
81
|
+
return compare(stable, prerelease) < 0 ? prerelease : stable;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function fetchReleases() {
|
|
85
|
+
const res = await _fetch(GITHUB_RELEASES, {
|
|
86
|
+
headers: { 'Accept': 'application/vnd.github+json', 'User-Agent': `${PACKAGE} update check` },
|
|
87
|
+
signal: AbortSignal.timeout(10000),
|
|
88
|
+
});
|
|
89
|
+
if (!res.ok) throw new Error(`GitHub answered ${res.status}`);
|
|
90
|
+
return res.json();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function existsOnNpm(version) {
|
|
94
|
+
const res = await _fetch(`${NPM_REGISTRY}/${PACKAGE}/${encodeURIComponent(version)}`, { signal: AbortSignal.timeout(10000) });
|
|
95
|
+
if (!res.ok) return false;
|
|
96
|
+
const data = await res.json();
|
|
97
|
+
return data && data.version === version;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Check for an update on a channel.
|
|
102
|
+
* @param {object} opts
|
|
103
|
+
* @param {string} opts.installed the running version
|
|
104
|
+
* @param {string} [opts.channel] 'stable' (default) or 'beta'
|
|
105
|
+
* @param {boolean} [opts.docker] a Docker container cannot update itself
|
|
106
|
+
* @returns {Promise<{channel: string, installed: string, stable: string|null, prerelease: string|null,
|
|
107
|
+
* latest: string|null, latestIsPrerelease: boolean, selfUpdate: boolean, checkedAt: number, error: string|null}>}
|
|
108
|
+
* `latest` is the version to offer — newer than `installed`, on the channel, present on npm — or null
|
|
109
|
+
*/
|
|
110
|
+
async function checkUpdate(opts) {
|
|
111
|
+
const channel = normalizeChannel(opts.channel);
|
|
112
|
+
const installed = String(opts.installed || '');
|
|
113
|
+
const result = {
|
|
114
|
+
channel,
|
|
115
|
+
installed,
|
|
116
|
+
stable: null,
|
|
117
|
+
prerelease: null,
|
|
118
|
+
latest: null,
|
|
119
|
+
latestIsPrerelease: false,
|
|
120
|
+
selfUpdate: !opts.docker,
|
|
121
|
+
checkedAt: Date.now(),
|
|
122
|
+
error: null,
|
|
123
|
+
};
|
|
124
|
+
let releases;
|
|
125
|
+
try {
|
|
126
|
+
releases = await fetchReleases();
|
|
127
|
+
} catch (err) {
|
|
128
|
+
result.error = `release list not available: ${err.message}`;
|
|
129
|
+
return result;
|
|
130
|
+
}
|
|
131
|
+
const found = newestOf(releases);
|
|
132
|
+
result.stable = found.stable;
|
|
133
|
+
result.prerelease = found.prerelease;
|
|
134
|
+
const candidate = offeredVersion(channel, found);
|
|
135
|
+
if (!candidate || !(compare(installed, candidate) < 0)) return result;
|
|
136
|
+
let onNpm = false;
|
|
137
|
+
try {
|
|
138
|
+
onNpm = await existsOnNpm(candidate);
|
|
139
|
+
} catch (err) {
|
|
140
|
+
result.error = `npm registry not available: ${err.message}`;
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
if (!onNpm) {
|
|
144
|
+
result.error = `${candidate} is released on GitHub but not published on npm`;
|
|
145
|
+
return result;
|
|
146
|
+
}
|
|
147
|
+
result.latest = candidate;
|
|
148
|
+
result.latestIsPrerelease = isPrerelease(candidate);
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
module.exports = { CHANNELS, PACKAGE, checkUpdate, newestOf, offeredVersion, versionOfTag, isPrerelease, normalizeChannel, setFetch };
|
package/src/sandbox/stdlib.js
CHANGED
|
@@ -37,6 +37,11 @@ module.exports = function (she, ctx = {}) {
|
|
|
37
37
|
sub: (...args) => she.mqttsub(...args),
|
|
38
38
|
/** Publish an MQTT message. Same signature as she.mqttpub(). */
|
|
39
39
|
pub: (...args) => she.mqttpub(...args),
|
|
40
|
+
/**
|
|
41
|
+
* Publish a command and wait for the device's status to confirm it. Resolves with
|
|
42
|
+
* { val, ms, tries }; rejects with an Error carrying reason timeout | offline | superseded | unloaded.
|
|
43
|
+
*/
|
|
44
|
+
command: (...args) => she.mqttcommand(...args),
|
|
40
45
|
/** Get the last-known value for a topic. */
|
|
41
46
|
get: (topic) => she.getValue(topic),
|
|
42
47
|
/** Get a specific property from a topic's state object. */
|
package/src/web/ai-tools.js
CHANGED
|
@@ -493,7 +493,7 @@ const TOOL_DEFINITIONS = [
|
|
|
493
493
|
function: {
|
|
494
494
|
name: 'list_timers',
|
|
495
495
|
description:
|
|
496
|
-
'Pending one-shot timers, intervals, schedule jobs
|
|
496
|
+
'Pending one-shot timers, intervals, schedule jobs, sun events, timing helpers (debounce, throttle, timeout, retry) and commands waiting for confirmation (she.mqtt.command) per script with their next fire time. Use it for "why did it switch at …" and "what is still going to happen".',
|
|
497
497
|
parameters: {
|
|
498
498
|
type: 'object',
|
|
499
499
|
properties: { script: { type: 'string', description: 'Substring of a script path. Empty: every script with something pending.' } },
|
|
@@ -1514,14 +1514,18 @@ function toolGetHealth(ctx = {}) {
|
|
|
1514
1514
|
if (!ctx.introspect?.health) return NO_INTROSPECT;
|
|
1515
1515
|
const h = ctx.introspect.health() || {};
|
|
1516
1516
|
const st = ctx.introspect.stats ? ctx.introspect.stats() || {} : {};
|
|
1517
|
+
const cfg = ctx.introspect.config ? ctx.introspect.config() || {} : {};
|
|
1517
1518
|
const lines = [
|
|
1518
|
-
`started: ${h.started}, mqtt: ${h.mqttConfigured ? (h.mqttConnected ? 'connected' : 'DISCONNECTED') : 'not configured'}, scripts loaded: ${h.scripts}, safe mode: ${h.safeMode ? 'YES' : 'no'}`,
|
|
1519
|
+
`she ${cfg.version || 'unknown version'} — started: ${h.started}, mqtt: ${h.mqttConfigured ? (h.mqttConnected ? 'connected' : 'DISCONNECTED') : 'not configured'}, scripts loaded: ${h.scripts}, safe mode: ${h.safeMode ? 'YES' : 'no'}`,
|
|
1519
1520
|
];
|
|
1520
1521
|
if (st.topics !== undefined) lines.push(`topics: ${st.topics}, messages/s: ${st.mqttMsgPerSec}, handlers: ${st.handlers}`);
|
|
1521
1522
|
if (st.memMb !== undefined)
|
|
1522
1523
|
lines.push(`memory: ${st.memMb} MB, cpu: ${st.cpuPercent}%, event loop: ${st.eluPercent}% utilised, lag mean ${st.elMeanMs} ms max ${st.elMaxMs} ms`);
|
|
1523
1524
|
if (st.matterEnabled) lines.push(`matter: ${st.matterNodes} node(s), ${st.matterEndpoints} endpoint(s)`);
|
|
1524
1525
|
if (st.dbEnabled) lines.push(`sheDB: ${st.dbDocs} document(s), ${st.dbViews} view(s)`);
|
|
1526
|
+
if (st.pendingTimers !== undefined)
|
|
1527
|
+
lines.push(`pending timers: ${st.pendingTimers}, failed commands since start: ${st.commandsFailed ?? 0} (she.mqtt.command: timeout or offline)`);
|
|
1528
|
+
if (cfg.timezone) lines.push(`time: zone ${cfg.timezone}, locale ${cfg.locale}, holidays ${cfg.holidays || 'NOT CONFIGURED (she.time.isHoliday() is always false)'}`);
|
|
1525
1529
|
return lines.join('\n');
|
|
1526
1530
|
}
|
|
1527
1531
|
|
package/src/web/config-api.js
CHANGED
|
@@ -8,10 +8,11 @@ const { getConfigPath } = require('../lib/storage');
|
|
|
8
8
|
const DEFAULT_CONFIG_PATH = getConfigPath();
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* Config keys
|
|
12
|
-
*
|
|
11
|
+
* Config keys whose change needs no daemon restart: consumed only by the frontend, or re-read
|
|
12
|
+
* from the file by the daemon at every use (updateChannel — the update check). All other keys
|
|
13
|
+
* require a daemon restart when changed.
|
|
13
14
|
*/
|
|
14
|
-
const FRONTEND_ONLY_KEYS = new Set(['gitAutoCommit', 'gitAutoPush', 'pinnedPackages']);
|
|
15
|
+
const FRONTEND_ONLY_KEYS = new Set(['gitAutoCommit', 'gitAutoPush', 'pinnedPackages', 'updateChannel']);
|
|
15
16
|
|
|
16
17
|
const router = express.Router();
|
|
17
18
|
|