smart-home-engine 1.53.4 → 2.0.1

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.
@@ -1,4 +1,4 @@
1
- import{m as O}from"./monaco-langs-DKJI_o9J.js";import{t as I}from"./index-DPRtP55L.js";/*!-----------------------------------------------------------------------------
1
+ import{m as O}from"./monaco-langs-DKJI_o9J.js";import{t as I}from"./index-CqYTIaIG.js";/*!-----------------------------------------------------------------------------
2
2
  * Copyright (c) Microsoft Corporation. All rights reserved.
3
3
  * Version: 0.52.2(404545bded1df6ffa41ea0af4e8ddb219018c6c1)
4
4
  * Released under the MIT license
@@ -175,10 +175,10 @@
175
175
  }
176
176
  })();
177
177
  </script>
178
- <script type="module" crossorigin src="/assets/index-DPRtP55L.js"></script>
178
+ <script type="module" crossorigin src="/assets/index-CqYTIaIG.js"></script>
179
179
  <link rel="modulepreload" crossorigin href="/assets/monaco-langs-DKJI_o9J.js">
180
180
  <link rel="stylesheet" crossorigin href="/assets/monaco-langs-DyX1CsEw.css">
181
- <link rel="stylesheet" crossorigin href="/assets/index-Cp--IWao.css">
181
+ <link rel="stylesheet" crossorigin href="/assets/index-Bu5o4KXc.css">
182
182
  </head>
183
183
  <body>
184
184
  <div id="app"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smart-home-engine",
3
- "version": "1.53.4",
3
+ "version": "2.0.1",
4
4
  "description": "Node.js based script runner for use in MQTT based Smart Home environments",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
@@ -32,7 +32,9 @@
32
32
  "dependencies": {
33
33
  "@elastic/elasticsearch": "^9.4.2",
34
34
  "@influxdata/influxdb-client": "^1.35.0",
35
- "@matter/main": "^0.17.0",
35
+ "@matter/dcl-data": "^2026.10.18",
36
+ "@matter/main": "^0.17.9",
37
+ "@matter/thread-br-client": "^0.17.9",
36
38
  "bcryptjs": "^2.4.3",
37
39
  "chokidar": "^4.0.0",
38
40
  "express": "^5.2.1",
package/src/config.js CHANGED
@@ -29,6 +29,11 @@ const config = require('yargs')
29
29
  describe: 'enable Matter controller; pass a directory path or true to use <data-dir>/matter',
30
30
  type: 'string',
31
31
  })
32
+ .option('matter-dcl-offline', {
33
+ describe: 'never contact the CSA Distributed Compliance Ledger (vendor names from the bundled snapshot, product details from the cache only)',
34
+ type: 'boolean',
35
+ default: false,
36
+ })
32
37
  .option('port', {
33
38
  alias: 'p',
34
39
  describe: 'HTTP server port (0 = OS-assigned random port)',
@@ -127,10 +132,25 @@ const config = require('yargs')
127
132
  type: 'boolean',
128
133
  })
129
134
  .option('safe-mode-auto-detect', {
130
- describe: 'enter safe mode automatically after an unclean shutdown (default: true)',
135
+ describe: 'enter safe mode automatically when the daemon crashes repeatedly shortly after start (default: true)',
131
136
  default: true,
132
137
  type: 'boolean',
133
138
  })
139
+ .option('safe-mode-early-crash', {
140
+ describe: 'seconds after start within which an unclean end counts as an early crash (default: 300)',
141
+ default: 300,
142
+ type: 'number',
143
+ })
144
+ .option('safe-mode-crash-count', {
145
+ describe: 'how many early crashes in a row put the next start into safe mode (default: 3)',
146
+ default: 3,
147
+ type: 'number',
148
+ })
149
+ .option('safe-mode-crash-window', {
150
+ describe: 'seconds within which those crashes must have happened (default: 1800)',
151
+ default: 1800,
152
+ type: 'number',
153
+ })
134
154
  .option('script-timeout', {
135
155
  describe: "ms a script's synchronous top-level code may run before it is terminated, 0 = no limit (default: 5000)",
136
156
  default: 5000,
package/src/index.js CHANGED
@@ -102,23 +102,44 @@ const _sentinelFile = _path.join(require('./lib/storage').STORAGE_ROOT, '.she-ru
102
102
  * unrelated process after a reboot is the one case this misses, which is the harmless
103
103
  * direction to be wrong in.
104
104
  */
105
+ const _crashRecord = require('./lib/crash-record');
106
+ const _crashFile = _path.join(require('./lib/storage').STORAGE_ROOT, '.she-crashes.json');
107
+ let _deadRun = null; // the stale sentinel's { pid, startMs }
105
108
  function _sentinelState() {
106
- let pid;
109
+ let parsed;
107
110
  try {
108
- pid = parseInt(_fs.readFileSync(_sentinelFile, 'utf8').trim(), 10);
111
+ parsed = _crashRecord.parseSentinel(_fs.readFileSync(_sentinelFile, 'utf8'));
109
112
  } catch {
110
113
  return null; // no marker
111
114
  }
112
- if (!pid) return 'stale';
115
+ if (!parsed.pid) {
116
+ _deadRun = parsed;
117
+ return 'stale';
118
+ }
113
119
  try {
114
- process.kill(pid, 0);
120
+ process.kill(parsed.pid, 0);
115
121
  return 'live';
116
122
  } catch (e) {
117
- return e.code === 'EPERM' ? 'live' : 'stale'; // EPERM: alive, just not ours
123
+ if (e.code === 'EPERM') return 'live'; // alive, just not ours
124
+ _deadRun = parsed;
125
+ return 'stale';
118
126
  }
119
127
  }
120
128
  const _sentinelBefore = _sentinelState();
121
- const SAFE_MODE = config.safeMode === true || (config.safeModeAutoDetect !== false && _sentinelBefore === 'stale');
129
+ // A stale sentinel alone is no longer a reason for safe mode (roadmap A10): only a crash that
130
+ // repeats and happens early — the signature of a script that kills the daemon at load — is.
131
+ // A single crash after days of uptime restarts normally, so the house keeps its automation.
132
+ let _crashEval = null;
133
+ if (_sentinelBefore === 'stale') {
134
+ _crashEval = _crashRecord.evaluate(_deadRun, _crashRecord.readCrashes(_crashFile), {
135
+ earlyCrashSec: config.safeModeEarlyCrash,
136
+ crashCount: config.safeModeCrashCount,
137
+ windowSec: config.safeModeCrashWindow,
138
+ });
139
+ _crashRecord.writeCrashes(_crashFile, _crashEval.crashes);
140
+ }
141
+ const SAFE_MODE = config.safeMode === true || (config.safeModeAutoDetect !== false && _sentinelBefore === 'stale' && !!_crashEval?.safeMode);
142
+ const SAFE_MODE_REASON = !SAFE_MODE ? null : config.safeMode === true ? '--safe-mode' : _crashEval.reason;
122
143
  let _sentinelWritten = false;
123
144
  function _clearSentinel() {
124
145
  if (!_sentinelWritten) return;
@@ -131,7 +152,7 @@ function _clearSentinel() {
131
152
  }
132
153
  if (_sentinelBefore !== 'live') {
133
154
  try {
134
- _fs.writeFileSync(_sentinelFile, String(process.pid));
155
+ _fs.writeFileSync(_sentinelFile, _crashRecord.formatSentinel(process.pid, Date.now()));
135
156
  _sentinelWritten = true;
136
157
  } catch (e) {
137
158
  // not fatal — auto-detect simply never triggers when the file cannot be written
@@ -185,8 +206,19 @@ if (SAFE_MODE) {
185
206
  if (config.safeMode === true) log.warn('SAFE MODE (--safe-mode): user scripts are not loaded. Restart without the flag to run them.');
186
207
  else
187
208
  log.warn(
188
- `SAFE MODE: ${_sentinelFile} was left behind, so the previous run was killed rather than stopped — a script may be blocking the event loop. User scripts are not loaded; fix the script and restart. Set "safeModeAutoDetect": false to disable this detection.`,
209
+ `SAFE MODE: ${_crashEval.reason} — ${_sentinelFile} was left behind each time, so the daemon was killed rather than stopped shortly after start; a script may be blocking the event loop or crashing the daemon at load. User scripts are not loaded; fix the script and restart. Set "safeModeAutoDetect": false to disable this detection, or raise safeModeCrashCount.`,
189
210
  );
211
+ } else if (_sentinelBefore === 'stale' && _crashEval) {
212
+ log.warn(`${_crashEval.message} (${_sentinelFile} was left behind${config.safeModeAutoDetect === false ? '; safe-mode detection is off' : ''})`);
213
+ }
214
+ // a run that stays up past the early-crash threshold is healthy: the crash record starts over
215
+ if (_sentinelBefore !== 'live' && !SAFE_MODE) {
216
+ setTimeout(
217
+ () => {
218
+ _crashRecord.writeCrashes(_crashFile, []);
219
+ },
220
+ (config.safeModeEarlyCrash || 300) * 1000,
221
+ ).unref();
190
222
  }
191
223
  log.debug('loaded config: ', config);
192
224
 
@@ -386,6 +418,7 @@ const _SENTINEL_TIMEOUT_MS = config.sentinelTimeout; // ms to wait for sentinel
386
418
  // she.matter.* at top level don't fail with "Matter controller not started".
387
419
  let _mqttGate = false;
388
420
  let _matterGate = !config.matterStorage; // pre-open when matter is disabled
421
+ let _matterStoragePath = null;
389
422
 
390
423
  function _maybeStart() {
391
424
  if (_started || !_mqttGate || !_matterGate) {
@@ -396,6 +429,41 @@ function _maybeStart() {
396
429
  }
397
430
  _started = true;
398
431
  start();
432
+ // the Matter ↔ MQTT bridge needs both: the controller and the retained state (M19)
433
+ if (config.matterStorage) {
434
+ try {
435
+ require('./matter/mqtt-bridge').start();
436
+ } catch (err) {
437
+ log.warn('matter bridge start failed:', err.message);
438
+ }
439
+ // Device health (M21): batteries, last seen, offline history — published under <name>/status/matter/health
440
+ try {
441
+ require('./matter/device-health').start({
442
+ publish: (topic, payload, opts) => {
443
+ if (mqtt && connected) mqtt.publish(topic, payload, opts || {});
444
+ },
445
+ name: config.name,
446
+ log,
447
+ broadcast,
448
+ storageDir: _matterStoragePath,
449
+ });
450
+ } catch (err) {
451
+ log.warn('matter device health start failed:', err.message);
452
+ }
453
+ // Thread health alerts (M26): evaluated once a minute, published under <name>/status/thread/health
454
+ try {
455
+ require('./matter/thread-health').start({
456
+ publish: (topic, payload, opts) => {
457
+ if (mqtt && connected) mqtt.publish(topic, payload, opts || {});
458
+ },
459
+ name: config.name,
460
+ log,
461
+ broadcast,
462
+ });
463
+ } catch (err) {
464
+ log.warn('matter thread health start failed:', err.message);
465
+ }
466
+ }
399
467
  }
400
468
 
401
469
  function matterGateOpen() {
@@ -508,6 +576,7 @@ const statsSnapshot = () => {
508
576
  elMeanMs,
509
577
  elMaxMs,
510
578
  safeMode: SAFE_MODE,
579
+ safeModeReason: SAFE_MODE_REASON,
511
580
  };
512
581
  };
513
582
  require('./web/server').setStatsProvider(statsSnapshot);
@@ -519,6 +588,7 @@ const healthSnapshot = () => ({
519
588
  mqttConnected: connected,
520
589
  scripts: Object.keys(scripts).length,
521
590
  safeMode: SAFE_MODE,
591
+ safeModeReason: SAFE_MODE_REASON,
522
592
  });
523
593
  require('./web/server').setHealthProvider(healthSnapshot);
524
594
 
@@ -652,6 +722,15 @@ if (config.url) {
652
722
 
653
723
  if (shedb.handleMqttMessage(topic, payload)) return;
654
724
 
725
+ // matter/set/… and matter/get/… go to the Matter bridge (M19); the message is still stored below
726
+ if (config.matterStorage && _started) {
727
+ try {
728
+ require('./matter/mqtt-bridge').onMessage(topic, payload);
729
+ } catch (err) {
730
+ log.warn('matter bridge:', err.message);
731
+ }
732
+ }
733
+
655
734
  // An empty payload deletes the topic from the state store (mqtt-smarthome
656
735
  // convention; also how a retained message is cleared per MQTT spec). The
657
736
  // retain flag cannot be used to detect this: MQTT 3.1.1 brokers clear it
@@ -774,6 +853,18 @@ if (config.matterStorage) {
774
853
  matterStoragePath = ensureStorageDir('matter');
775
854
  }
776
855
  log.info('matter controller starting, storage:', matterStoragePath);
856
+ _matterStoragePath = matterStoragePath;
857
+ require('./matter/mqtt-bridge').init({
858
+ publish: (topic, payload, opts) => {
859
+ if (!mqtt) throw new Error('MQTT is not connected');
860
+ mqtt.publish(topic, payload, opts || {});
861
+ },
862
+ isConnected: () => !!mqtt && connected,
863
+ store,
864
+ log,
865
+ storageDir: matterStoragePath,
866
+ broadcast,
867
+ });
777
868
  // Scripts wait for the controller (see _maybeStart) — but never forever:
778
869
  // a hanging start must not block the whole script engine.
779
870
  const _matterStartTimeout = setTimeout(() => {
@@ -781,7 +872,7 @@ if (config.matterStorage) {
781
872
  matterGateOpen();
782
873
  }, 30000);
783
874
  matterController
784
- .init(matterStoragePath, log, broadcast)
875
+ .init(matterStoragePath, log, broadcast, { dclOffline: config.matterDclOffline === true })
785
876
  .catch((err) => {
786
877
  log.error('matter controller init failed:', err.message, err.stack);
787
878
  })
@@ -1796,6 +1887,13 @@ async function gracefulShutdown(signal) {
1796
1887
  _clearSentinel();
1797
1888
  log.info(`got ${signal}. exiting.`);
1798
1889
  if (config.matterStorage) {
1890
+ try {
1891
+ require('./matter/mqtt-bridge').stop();
1892
+ require('./matter/thread-health').stop();
1893
+ require('./matter/device-health').stop();
1894
+ } catch {
1895
+ // ignore
1896
+ }
1799
1897
  try {
1800
1898
  await require('./matter/controller').close();
1801
1899
  } catch {
@@ -0,0 +1,103 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Crash record for the safe-mode decision (roadmap A10). Safe mode — starting without any user
5
+ * script — is the right answer to a script that kills the daemon at or shortly after load, where
6
+ * every restart would fail the same way; it is the wrong answer to a crash after days of normal
7
+ * operation, where systemd restarts the daemon and the house then runs without automation until
8
+ * someone notices. So: a crash on its own is not a reason. Safe mode only when the crash repeats
9
+ * and happens early.
10
+ *
11
+ * The sentinel `.she-running` carries `<pid> <startMs>`; a stale one at start tells when the dead
12
+ * run began. The crash record `.she-crashes.json` next to it keeps the last few unclean runs. Pure
13
+ * functions here; the daemon reads and writes the files.
14
+ */
15
+
16
+ const fs = require('fs');
17
+
18
+ const DEFAULTS = Object.freeze({ earlyCrashSec: 300, crashCount: 3, windowSec: 1800 });
19
+ const KEEP = 10;
20
+
21
+ /** `<pid> <startMs>` (or the pre-A10 `<pid>` alone) → { pid, startMs }; null when unreadable. */
22
+ function parseSentinel(text) {
23
+ const parts = String(text ?? '')
24
+ .trim()
25
+ .split(/\s+/);
26
+ const pid = parseInt(parts[0], 10);
27
+ if (!Number.isFinite(pid) || pid <= 0) return { pid: null, startMs: null };
28
+ const startMs = parts.length > 1 ? parseInt(parts[1], 10) : NaN;
29
+ return { pid, startMs: Number.isFinite(startMs) && startMs > 0 ? startMs : null };
30
+ }
31
+
32
+ function formatSentinel(pid, startMs) {
33
+ return `${pid} ${startMs}\n`;
34
+ }
35
+
36
+ function readCrashes(file) {
37
+ try {
38
+ const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
39
+ const list = Array.isArray(raw) ? raw : Array.isArray(raw?.crashes) ? raw.crashes : [];
40
+ return list.filter((c) => c && typeof c === 'object' && Number.isFinite(c.end));
41
+ } catch {
42
+ return [];
43
+ }
44
+ }
45
+
46
+ function writeCrashes(file, crashes) {
47
+ try {
48
+ if (!crashes.length) {
49
+ fs.rmSync(file, { force: true });
50
+ return;
51
+ }
52
+ fs.writeFileSync(file, JSON.stringify({ v: 1, crashes: crashes.slice(-KEEP) }) + '\n');
53
+ } catch {
54
+ /* a record that cannot be written costs at most one safe-mode decision */
55
+ }
56
+ }
57
+
58
+ function fmtUptime(ms) {
59
+ if (ms === null) return 'unknown uptime';
60
+ const s = Math.round(ms / 1000);
61
+ if (s < 120) return `${s} s`;
62
+ const m = Math.round(s / 60);
63
+ if (m < 120) return `${m} min`;
64
+ const h = Math.round(m / 60);
65
+ if (h < 48) return `${h} h`;
66
+ return `${Math.round(h / 24)} d`;
67
+ }
68
+
69
+ /**
70
+ * The decision at a start that found a stale sentinel.
71
+ *
72
+ * @param {{ startMs: number|null }} dead the dead run (from its sentinel; startMs null for a pre-A10 sentinel)
73
+ * @param {{ start: number|null, end: number, early: boolean }[]} crashes the record so far
74
+ * @param {{ now?: number, earlyCrashSec?: number, crashCount?: number, windowSec?: number }} [opts]
75
+ * @returns {{ safeMode: boolean, reason: string|null, crashes: object[], uptimeMs: number|null, early: boolean, message: string }}
76
+ */
77
+ function evaluate(dead, crashes, opts = {}) {
78
+ const now = opts.now ?? Date.now();
79
+ const earlyMs = (opts.earlyCrashSec ?? DEFAULTS.earlyCrashSec) * 1000;
80
+ const count = Math.max(1, opts.crashCount ?? DEFAULTS.crashCount);
81
+ const windowMs = (opts.windowSec ?? DEFAULTS.windowSec) * 1000;
82
+ const uptimeMs = dead?.startMs ? Math.max(0, now - dead.startMs) : null;
83
+ // a dead run whose start is unknown (a sentinel from before this record) counts as early: that is
84
+ // the case the detection was built for, and it happens once at most
85
+ const early = uptimeMs === null || uptimeMs < earlyMs;
86
+ const list = [...crashes, { start: dead?.startMs ?? null, end: now, early }].slice(-KEEP);
87
+ const recent = list.slice(-count);
88
+ const safeMode = early && recent.length >= count && recent.every((c) => c.early && now - c.end <= windowMs);
89
+ let reason = null;
90
+ let message;
91
+ if (safeMode) {
92
+ reason = `${count} crashes within ${fmtUptime(earlyMs)} of start (last ${fmtUptime(now - recent[0].end)})`;
93
+ message = `the previous run was killed ${uptimeMs === null ? '' : 'after ' + fmtUptime(uptimeMs) + ' '}— the ${count}. early crash in a row`;
94
+ } else if (early) {
95
+ const n = list.filter((c, i) => i >= list.length - count && c.early && now - c.end <= windowMs).length;
96
+ message = `the previous run was killed ${uptimeMs === null ? '(uptime unknown) ' : 'after ' + fmtUptime(uptimeMs) + ' '}— early crash ${n} of ${count} within ${fmtUptime(windowMs)}; starting normally`;
97
+ } else {
98
+ message = `the previous run was killed after ${fmtUptime(uptimeMs)} of uptime — not an early crash; starting normally`;
99
+ }
100
+ return { safeMode, reason, crashes: list, uptimeMs, early, message };
101
+ }
102
+
103
+ module.exports = { DEFAULTS, KEEP, parseSentinel, formatSentinel, readCrashes, writeCrashes, evaluate, fmtUptime };
@@ -317,4 +317,31 @@ function cli(argv, io = { stdout: process.stdout, stderr: process.stderr, stdin:
317
317
  }
318
318
  }
319
319
 
320
- module.exports = { init, load, status, list, get, has, set, mark, remove, redact, cli, validName, NAME_RE, MAX_VALUE, MIN_REDACT };
320
+ /**
321
+ * The encryption of the secrets store for other files that must not sit in plaintext in the data
322
+ * directory (the Matter group keys, the Thread datasets): same key, same format, their own file.
323
+ */
324
+ function readEncryptedFile(file) {
325
+ let raw;
326
+ try {
327
+ raw = fs.readFileSync(file, 'utf8');
328
+ } catch (err) {
329
+ if (err.code === 'ENOENT') return null;
330
+ throw err;
331
+ }
332
+ const key = loadKey();
333
+ if (!key) throw new Error(`no key to read ${file}: set SHE_SECRETS_KEY or restore ${_keyFile}`);
334
+ const obj = JSON.parse(raw);
335
+ if (!obj || obj.alg !== ALG) throw new Error(`${file}: unknown file format`);
336
+ return JSON.parse(decrypt(key, obj));
337
+ }
338
+
339
+ function writeEncryptedFile(file, data) {
340
+ const key = loadKey({ create: true });
341
+ fs.mkdirSync(path.dirname(file), { recursive: true });
342
+ const tmp = file + '.tmp';
343
+ fs.writeFileSync(tmp, JSON.stringify(encrypt(key, JSON.stringify(data))) + '\n', { mode: 0o600 });
344
+ fs.renameSync(tmp, file);
345
+ }
346
+
347
+ module.exports = { init, load, status, list, get, has, set, mark, remove, redact, cli, validName, NAME_RE, MAX_VALUE, MIN_REDACT, readEncryptedFile, writeEncryptedFile };