smart-home-engine 1.36.1 → 1.37.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.
@@ -1,4 +1,4 @@
1
- import{m as O}from"./monaco-langs-DKJI_o9J.js";import{t as I}from"./index-Crt_IAIl.js";/*!-----------------------------------------------------------------------------
1
+ import{m as O}from"./monaco-langs-DKJI_o9J.js";import{t as I}from"./index-CHsE05wI.js";/*!-----------------------------------------------------------------------------
2
2
  * Copyright (c) Microsoft Corporation. All rights reserved.
3
3
  * Version: 0.52.2(404545bded1df6ffa41ea0af4e8ddb219018c6c1)
4
4
  * Released under the MIT license
@@ -172,10 +172,10 @@
172
172
  }
173
173
  })();
174
174
  </script>
175
- <script type="module" crossorigin src="/assets/index-Crt_IAIl.js"></script>
175
+ <script type="module" crossorigin src="/assets/index-CHsE05wI.js"></script>
176
176
  <link rel="modulepreload" crossorigin href="/assets/monaco-langs-DKJI_o9J.js">
177
177
  <link rel="stylesheet" crossorigin href="/assets/monaco-langs-DyX1CsEw.css">
178
- <link rel="stylesheet" crossorigin href="/assets/index-hiXQxo8M.css">
178
+ <link rel="stylesheet" crossorigin href="/assets/index-BWan5OPE.css">
179
179
  </head>
180
180
  <body>
181
181
  <div id="app"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smart-home-engine",
3
- "version": "1.36.1",
3
+ "version": "1.37.0",
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": {
package/src/config.js CHANGED
@@ -44,22 +44,97 @@ const config = require('yargs')
44
44
  })
45
45
  .default('config', path.join(DATA_ROOT, 'config', 'config.json'))
46
46
  .hide('config')
47
- // Sensible defaults for values not present in config.json
48
- .default({
49
- latitude: 48.7408,
50
- longitude: 9.1778,
51
- name: 'she',
52
- variablePrefix: 'var',
53
- verbosity: 'info',
54
- disableVariables: false,
55
- disableWatch: false,
56
- dbRetain: false,
57
- dbPrefix: 'she/db/',
58
- auth: 'none',
59
- proxyHeader: 'X-Remote-User',
60
- bindAddress: '0.0.0.0',
61
- timezone: '',
62
- mqttVersion: '',
47
+ // Sensible defaults for values not present in config.json. Declared as typed options
48
+ // rather than bare defaults: without a type, a value arriving from the environment
49
+ // stays a string, and `SHE_DISABLE_WATCH=false` would be truthy.
50
+ .option('url', {
51
+ describe: 'MQTT broker URL, e.g. mqtt://localhost — MQTT is disabled when omitted',
52
+ type: 'string',
53
+ })
54
+ .option('name', {
55
+ describe: 'instance name — MQTT client id and prefix of the connected topic',
56
+ default: 'she',
57
+ type: 'string',
58
+ })
59
+ .option('verbosity', {
60
+ describe: 'log level: error, warn, info, debug',
61
+ default: 'info',
62
+ type: 'string',
63
+ })
64
+ .option('latitude', {
65
+ describe: 'latitude for solar-event scheduling',
66
+ default: 48.7408,
67
+ type: 'number',
68
+ })
69
+ .option('longitude', {
70
+ describe: 'longitude for solar-event scheduling',
71
+ default: 9.1778,
72
+ type: 'number',
73
+ })
74
+ .option('timezone', {
75
+ describe: 'IANA timezone for schedules (default: the system timezone)',
76
+ default: '',
77
+ type: 'string',
78
+ })
79
+ .option('variable-prefix', {
80
+ describe: 'topic prefix for the variable system',
81
+ default: 'var',
82
+ type: 'string',
83
+ })
84
+ .option('disable-variables', {
85
+ describe: 'disable the variable feedback loop — set on all but one instance sharing a broker',
86
+ default: false,
87
+ type: 'boolean',
88
+ })
89
+ .option('disable-watch', {
90
+ describe: 'disable file watching: scripts are not reloaded when they change',
91
+ default: false,
92
+ type: 'boolean',
93
+ })
94
+ .option('db-retain', {
95
+ describe: 'publish sheDB document changes as retained MQTT messages',
96
+ default: false,
97
+ type: 'boolean',
98
+ })
99
+ .option('db-prefix', {
100
+ describe: 'topic prefix for sheDB publications',
101
+ default: 'she/db/',
102
+ type: 'string',
103
+ })
104
+ .option('auth', {
105
+ describe: 'authentication mode: none, password, proxy',
106
+ default: 'none',
107
+ type: 'string',
108
+ })
109
+ .option('proxy-header', {
110
+ describe: 'HTTP header carrying the authenticated user in proxy mode',
111
+ default: 'X-Remote-User',
112
+ type: 'string',
113
+ })
114
+ .option('bind-address', {
115
+ describe: 'interface the HTTP server binds to — 127.0.0.1 when behind a reverse proxy',
116
+ default: '0.0.0.0',
117
+ type: 'string',
118
+ })
119
+ .option('mqtt-version', {
120
+ describe: 'MQTT protocol version to force (3, 4 or 5; default: negotiated)',
121
+ default: '',
122
+ type: 'string',
123
+ })
124
+ .option('safe-mode', {
125
+ describe: 'start without loading any user script (recovery mode — see safeModeAutoDetect)',
126
+ default: false,
127
+ type: 'boolean',
128
+ })
129
+ .option('safe-mode-auto-detect', {
130
+ describe: 'enter safe mode automatically after an unclean shutdown (default: true)',
131
+ default: true,
132
+ type: 'boolean',
133
+ })
134
+ .option('script-timeout', {
135
+ describe: "ms a script's synchronous top-level code may run before it is terminated, 0 = no limit (default: 5000)",
136
+ default: 5000,
137
+ type: 'number',
63
138
  })
64
139
  .option('sentinel-timeout', {
65
140
  describe: 'ms to wait for the retained-state sentinel after connecting to MQTT (default: 5000)',
@@ -70,8 +145,19 @@ const config = require('yargs')
70
145
  describe: 'create system user and install systemd service, then exit (run as root)',
71
146
  type: 'boolean',
72
147
  })
148
+ // Every option is also settable as SHE_<OPTION> (SCREAMING_SNAKE_CASE), e.g. SHE_PORT,
149
+ // SHE_BIND_ADDRESS, SHE_SAFE_MODE_AUTO_DETECT. Precedence: CLI > env > config file >
150
+ // default. The trailing underscore in the prefix matters — yargs matches the prefix as
151
+ // a plain string, so 'SHE' alone would also swallow SHELL (SHE + LL → config.ll).
152
+ .env('SHE_')
73
153
  .version()
74
154
  .help('help')
75
155
  .parseSync();
76
156
 
157
+ // SHE_SECRETS_KEY is not an option: lib/secrets.js reads it straight from the environment.
158
+ // The prefix match picks it up anyway, and the config object is debug-logged at startup —
159
+ // so drop the master key here rather than print it.
160
+ delete config.secretsKey;
161
+ delete config['secrets-key'];
162
+
77
163
  module.exports = config;
package/src/index.js CHANGED
@@ -121,6 +121,62 @@ const log = {
121
121
  }
122
122
  const pkg = require('../package.json');
123
123
 
124
+ // ---------------------------------------------------------------------------
125
+ // Safe mode (roadmap S4): start the daemon without executing any user script.
126
+ // Entered explicitly with --safe-mode, or automatically when the previous run
127
+ // did not shut down cleanly — a script blocking the event loop forever leaves
128
+ // the sentinel behind because neither the exit handler nor SIGTERM ever runs.
129
+ // ---------------------------------------------------------------------------
130
+ const _sentinelFile = _path.join(require('./lib/storage').STORAGE_ROOT, '.she-running');
131
+
132
+ /**
133
+ * Who left the marker behind: `null` nobody (clean start), `'live'` a second instance
134
+ * sharing this data directory (documented setup — see cli.md "Multiple instances"),
135
+ * `'stale'` a run that was killed. Only 'stale' means safe mode; a PID reused by an
136
+ * unrelated process after a reboot is the one case this misses, which is the harmless
137
+ * direction to be wrong in.
138
+ */
139
+ function _sentinelState() {
140
+ let pid;
141
+ try {
142
+ pid = parseInt(_fs.readFileSync(_sentinelFile, 'utf8').trim(), 10);
143
+ } catch {
144
+ return null; // no marker
145
+ }
146
+ if (!pid) return 'stale';
147
+ try {
148
+ process.kill(pid, 0);
149
+ return 'live';
150
+ } catch (e) {
151
+ return e.code === 'EPERM' ? 'live' : 'stale'; // EPERM: alive, just not ours
152
+ }
153
+ }
154
+ const _sentinelBefore = _sentinelState();
155
+ const SAFE_MODE = config.safeMode === true || (config.safeModeAutoDetect !== false && _sentinelBefore === 'stale');
156
+ let _sentinelWritten = false;
157
+ function _clearSentinel() {
158
+ if (!_sentinelWritten) return;
159
+ _sentinelWritten = false;
160
+ try {
161
+ _fs.unlinkSync(_sentinelFile);
162
+ } catch {
163
+ /* already gone */
164
+ }
165
+ }
166
+ if (_sentinelBefore !== 'live') {
167
+ try {
168
+ _fs.writeFileSync(_sentinelFile, String(process.pid));
169
+ _sentinelWritten = true;
170
+ } catch (e) {
171
+ // not fatal — auto-detect simply never triggers when the file cannot be written
172
+ console.error('could not write ' + _sentinelFile + ': ' + e.message);
173
+ }
174
+ }
175
+ // Runs on exit(0) from the restart endpoint and on a normal end of the event loop.
176
+ process.on('exit', _clearSentinel);
177
+ // Wall-clock budget for a script's synchronous top-level code (0 = no limit).
178
+ const SCRIPT_TIMEOUT_MS = typeof config.scriptTimeout === 'number' && config.scriptTimeout >= 0 ? config.scriptTimeout : 5000;
179
+
124
180
  /**
125
181
  * Build a short log label for a script file.
126
182
  * Uses the path relative to the configured script dir(s) when possible,
@@ -147,6 +203,14 @@ process.on('unhandledRejection', (reason) => {
147
203
  });
148
204
 
149
205
  log.info('she ' + pkg.version + ' starting');
206
+ if (_sentinelBefore === 'live') log.info(`another she instance already uses ${require('./lib/storage').STORAGE_ROOT} — leaving its ${_path.basename(_sentinelFile)} marker alone`);
207
+ if (SAFE_MODE) {
208
+ if (config.safeMode === true) log.warn('SAFE MODE (--safe-mode): user scripts are not loaded. Restart without the flag to run them.');
209
+ else
210
+ log.warn(
211
+ `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.`,
212
+ );
213
+ }
150
214
  log.debug('loaded config: ', config);
151
215
 
152
216
  if (typeof config.port !== 'undefined') {
@@ -465,14 +529,26 @@ require('./web/server').setStatsProvider(() => {
465
529
  eluPercent: Math.round(eluDelta.utilization * 100),
466
530
  elMeanMs,
467
531
  elMaxMs,
532
+ safeMode: SAFE_MODE,
468
533
  };
469
534
  });
470
535
 
536
+ // Health probe for GET /she/health (roadmap A1) — cheap, no counting, no allocation.
537
+ require('./web/server').setHealthProvider(() => ({
538
+ started: _started,
539
+ mqttConfigured: !!config.url,
540
+ mqttConnected: connected,
541
+ scripts: Object.keys(scripts).length,
542
+ safeMode: SAFE_MODE,
543
+ }));
544
+
471
545
  // Push current script:running state so the UI green dots survive a browser reload.
472
546
  setWelcomeProvider(() => Object.keys(scripts).map((f) => ({ type: 'script:running', path: makeLabel(f).slice(0, -1), running: true })));
473
547
 
474
548
  if (!config.url) {
475
- log.warn('no MQTT broker URL configured — set "url" in ' + path.join(require('os').homedir(), '.she', 'config.json'));
549
+ // config.config is the file actually in use — hardcoding ~/.she misleads anyone
550
+ // running with --data-dir / SHE_DATA_DIR or --config.
551
+ log.warn('no MQTT broker URL configured — set "url" in ' + config.config + ' (or pass --url / SHE_URL)');
476
552
  }
477
553
 
478
554
  if (config.url) {
@@ -1255,8 +1331,16 @@ function runScript(script, name, _origin) {
1255
1331
  scriptDomain.run(() => {
1256
1332
  log.debug(logLabel, 'running');
1257
1333
  try {
1258
- script.runInContext(context);
1334
+ // The timeout only covers synchronous top-level code — a callback registered
1335
+ // here can still block the loop later (that is what the heartbeat reports).
1336
+ script.runInContext(context, SCRIPT_TIMEOUT_MS > 0 ? { timeout: SCRIPT_TIMEOUT_MS } : undefined);
1259
1337
  } catch (e) {
1338
+ if (e && e.code === 'ERR_SCRIPT_EXECUTION_TIMEOUT') {
1339
+ // Killing the script mid-run leaves whatever it registered before the
1340
+ // timeout in place; loading continues with the remaining scripts.
1341
+ log.error(logLabel, `top-level code did not finish within ${SCRIPT_TIMEOUT_MS} ms and was terminated ("scriptTimeout", 0 disables this)`);
1342
+ return;
1343
+ }
1260
1344
  // Contain top-level script errors: without this, the exception
1261
1345
  // unwinds through the caller (e.g. the watcher batch loading many
1262
1346
  // scripts in one tick) and aborts loading of subsequent scripts.
@@ -1621,6 +1705,10 @@ function loadDir(dir) {
1621
1705
  }
1622
1706
  }
1623
1707
  function start() {
1708
+ if (SAFE_MODE) {
1709
+ log.warn('safe mode: no script is loaded and the script directory is not watched. Edit or delete the offending script in the web UI, then restart the daemon.');
1710
+ return;
1711
+ }
1624
1712
  if (config.heartbeat?.enabled) {
1625
1713
  const _hbInterval = typeof config.heartbeat.interval === 'number' ? config.heartbeat.interval : 50;
1626
1714
  const _hbThreshold = typeof config.heartbeat.threshold === 'number' ? config.heartbeat.threshold : 300;
@@ -1669,6 +1757,9 @@ function start() {
1669
1757
  }
1670
1758
 
1671
1759
  async function gracefulShutdown(signal) {
1760
+ // First thing, synchronously: the async cleanup below may outlive systemd's
1761
+ // TimeoutStopSec, and a SIGKILL after that must not look like a crash.
1762
+ _clearSentinel();
1672
1763
  log.info(`got ${signal}. exiting.`);
1673
1764
  if (config.matterStorage) {
1674
1765
  try {
package/src/web/server.js CHANGED
@@ -32,7 +32,10 @@ app.use('/she/auth', authRouter);
32
32
  // /api/* is intentionally excluded — user scripts control their own auth.
33
33
  // The remote-host bootstrap script and its callback are fetched by curl on the target host: no
34
34
  // session, but useless without the one-time token they carry (services-api validates it).
35
- const OPEN_SHE_PATHS = new Set(['/she/auth/mode', '/she/auth/login', '/she/auth/logout', '/she/services/setup.sh', '/she/services/setup/done']);
35
+ // /she/health is public on purpose: Docker HEALTHCHECK, nginx upstream probes and monitoring
36
+ // agents cannot present a session. It answers with a status only — the version is added for
37
+ // authenticated callers, the rest of the payload is counters that give nothing away.
38
+ const OPEN_SHE_PATHS = new Set(['/she/auth/mode', '/she/auth/login', '/she/auth/logout', '/she/services/setup.sh', '/she/services/setup/done', '/she/health']);
36
39
  app.use('/she', (req, res, next) => {
37
40
  if (OPEN_SHE_PATHS.has(req.originalUrl.split('?')[0])) return next();
38
41
  authMiddleware(req, res, next);
@@ -124,6 +127,32 @@ let _getStats = null;
124
127
  function setStatsProvider(fn) {
125
128
  _getStats = fn;
126
129
  }
130
+
131
+ // Health check (roadmap A1) — 200 while the daemon can do its job, 503 otherwise.
132
+ // Not healthy: still starting up (waiting for retained MQTT state or the Matter
133
+ // controller), or a broker is configured but not connected. Safe mode stays 200:
134
+ // it is a deliberate state the user has to reach the web UI to get out of, so a
135
+ // probe that takes the daemon out of an upstream pool would be counterproductive.
136
+ let _getHealth = null;
137
+ function setHealthProvider(fn) {
138
+ _getHealth = fn;
139
+ }
140
+ app.get('/she/health', (req, res) => {
141
+ const h = _getHealth ? _getHealth() : null;
142
+ const mqtt = !h ? 'unknown' : h.mqttConfigured ? (h.mqttConnected ? 'connected' : 'disconnected') : 'disabled';
143
+ const ok = !!h && h.started && mqtt !== 'disconnected';
144
+ const body = {
145
+ status: ok ? 'ok' : 'degraded',
146
+ uptime: Math.round((Date.now() - SERVER_START_TIME) / 1000),
147
+ started: !!h?.started,
148
+ mqtt,
149
+ scripts: h?.scripts ?? 0,
150
+ };
151
+ if (h?.safeMode) body.safeMode = true;
152
+ if (checkAuth(req)) body.version = pkg.version;
153
+ res.set('Cache-Control', 'no-store');
154
+ res.status(ok ? 200 : 503).json(body);
155
+ });
127
156
  const _isDocker = require('fs').existsSync('/.dockerenv');
128
157
  app.get('/she/status', (req, res) => {
129
158
  const s = _getStats ? _getStats() : { scripts: 0, topics: 0 };
@@ -290,4 +319,4 @@ function stopServer() {
290
319
  });
291
320
  }
292
321
 
293
- module.exports = { app, registerRoute, unregisterRoutesByScript, setStatsProvider, startServer, stopServer };
322
+ module.exports = { app, registerRoute, unregisterRoutesByScript, setStatsProvider, setHealthProvider, startServer, stopServer };