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,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 meta = { kind: 'debounce', name };
60
- const maxMeta = { kind: 'debounce maxWait', name };
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 };
@@ -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. */
@@ -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 and sun events per script with their next fire time. Use it for "why did it switch at …" and "what is still going to happen".',
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
 
@@ -8,10 +8,11 @@ const { getConfigPath } = require('../lib/storage');
8
8
  const DEFAULT_CONFIG_PATH = getConfigPath();
9
9
 
10
10
  /**
11
- * Config keys that are consumed only by the frontend (no daemon restart needed).
12
- * All other keys require a daemon restart when changed.
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