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.
- package/dist/web/assets/{index-hiXQxo8M.css → index-BWan5OPE.css} +1 -1
- package/dist/web/assets/index-CHsE05wI.js +273 -0
- package/dist/web/assets/{tsMode-eTXPyDTL.js → tsMode-CLQ49mna.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/src/config.js +102 -16
- package/src/index.js +93 -2
- package/src/web/server.js +31 -2
- package/dist/web/assets/index-Crt_IAIl.js +0 -273
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{m as O}from"./monaco-langs-DKJI_o9J.js";import{t as I}from"./index-
|
|
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
|
package/dist/web/index.html
CHANGED
|
@@ -172,10 +172,10 @@
|
|
|
172
172
|
}
|
|
173
173
|
})();
|
|
174
174
|
</script>
|
|
175
|
-
<script type="module" crossorigin src="/assets/index-
|
|
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-
|
|
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
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|