smart-home-engine 1.36.1 → 1.38.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-5np-MgKm.js +273 -0
- package/dist/web/assets/{index-hiXQxo8M.css → index-Ggp8nXi_.css} +1 -1
- package/dist/web/assets/{tsMode-eTXPyDTL.js → tsMode-BQh1DHEC.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/service/she-servicectl +32 -1
- package/src/config.js +102 -16
- package/src/index.js +93 -2
- package/src/lib/device-discovery.js +194 -0
- package/src/lib/services-host.js +1 -1
- package/src/web/server.js +31 -2
- package/src/web/services-api.js +128 -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-5np-MgKm.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-5np-MgKm.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-Ggp8nXi_.css">
|
|
179
179
|
</head>
|
|
180
180
|
<body>
|
|
181
181
|
<div id="app"></div>
|
package/package.json
CHANGED
package/service/she-servicectl
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
# she-servicectl env <adapter> <instance> read|write (write: file on stdin)
|
|
24
24
|
# she-servicectl broker-env read|write (write: file on stdin)
|
|
25
25
|
# she-servicectl schema <adapter>
|
|
26
|
+
# she-servicectl discover <adapter> [--timeout <s>] [--address <addr-or-cidr>]... scan for devices, JSON on stdout
|
|
26
27
|
# she-servicectl install <adapter> <instance> (options as KEY=VALUE lines on stdin)
|
|
27
28
|
# she-servicectl uninstall <adapter> <instance>
|
|
28
29
|
# she-servicectl npm <adapter> version|origin|install|update|uninstall [--purge] (uninstall: package, template unit; --purge also /etc and /var/lib)
|
|
@@ -41,7 +42,7 @@
|
|
|
41
42
|
|
|
42
43
|
set -eu
|
|
43
44
|
|
|
44
|
-
VERSION=
|
|
45
|
+
VERSION=11
|
|
45
46
|
BROKER_ENV=/etc/mqtt-interfaces/broker.env
|
|
46
47
|
UNIT_DIR=/etc/systemd/system
|
|
47
48
|
|
|
@@ -417,6 +418,35 @@ cmd_schema() {
|
|
|
417
418
|
exec "$cmd" --config-schema
|
|
418
419
|
}
|
|
419
420
|
|
|
421
|
+
# The adapter's own --discover (core 0.9+): it owns the hint, the protocols and the rate limiting.
|
|
422
|
+
# Runs on this host on purpose — broadcast, multicast, ARP and /dev only reach the network and the
|
|
423
|
+
# usb bus of the machine the scan runs on, which is this one, not necessarily she's.
|
|
424
|
+
cmd_discover() {
|
|
425
|
+
[ $# -ge 1 ] || die "usage: discover <adapter> [--timeout <s>] [--address <addr>]..."
|
|
426
|
+
need_adapter "$1"
|
|
427
|
+
cmd=$(adapter_cmd "$1") || die "adapter not installed: $1"
|
|
428
|
+
shift
|
|
429
|
+
set -- --discover --discover-json "$@"
|
|
430
|
+
args=""
|
|
431
|
+
while [ $# -gt 0 ]; do
|
|
432
|
+
case "$1" in
|
|
433
|
+
--discover|--discover-json) args="$args $1"; shift ;;
|
|
434
|
+
--timeout)
|
|
435
|
+
[ $# -ge 2 ] || die "--timeout needs a value"
|
|
436
|
+
case "$2" in ''|*[!0-9]*) die "invalid timeout: $2" ;; esac
|
|
437
|
+
args="$args --discover-timeout $2"; shift 2 ;;
|
|
438
|
+
--address)
|
|
439
|
+
[ $# -ge 2 ] || die "--address needs a value"
|
|
440
|
+
# an address or a cidr range, nothing that could become a second argument
|
|
441
|
+
case "$2" in *[!0-9A-Za-z.:/_-]*|'') die "invalid address: $2" ;; esac
|
|
442
|
+
args="$args --discover-address $2"; shift 2 ;;
|
|
443
|
+
*) die "unknown discover option: $1" ;;
|
|
444
|
+
esac
|
|
445
|
+
done
|
|
446
|
+
# shellcheck disable=SC2086 # args is built from validated tokens above
|
|
447
|
+
exec "$cmd" $args
|
|
448
|
+
}
|
|
449
|
+
|
|
420
450
|
cmd_install() {
|
|
421
451
|
[ $# -eq 2 ] || die "usage: install <adapter> <instance> (KEY=VALUE lines on stdin)"
|
|
422
452
|
need_adapter "$1"; need_instance "$2"
|
|
@@ -646,6 +676,7 @@ case "$c" in
|
|
|
646
676
|
env) cmd_env "$@" ;;
|
|
647
677
|
broker-env) cmd_broker_env "$@" ;;
|
|
648
678
|
schema) cmd_schema "$@" ;;
|
|
679
|
+
discover) cmd_discover "$@" ;;
|
|
649
680
|
install) cmd_install "$@" ;;
|
|
650
681
|
uninstall) cmd_uninstall "$@" ;;
|
|
651
682
|
npm) cmd_npm "$@" ;;
|
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 {
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Device discovery (roadmap I13): shaping what an adapter's `--discover --discover-json` printed
|
|
5
|
+
* into what the Add-instance UI needs.
|
|
6
|
+
*
|
|
7
|
+
* The adapter owns the scanning (mqtt-interfaces-core 0.9 `lib/discovery.js`: SSDP, mDNS/DNS-SD,
|
|
8
|
+
* UDP probes, port probes, subnet sweeps, ARP/OUI, USB serial ports). This module never scans —
|
|
9
|
+
* it validates the JSON that came back over the helper, decides what goes into the config, and
|
|
10
|
+
* proposes an instance name.
|
|
11
|
+
*
|
|
12
|
+
* Everything here treats its input as hostile: it was printed by a device on the network that
|
|
13
|
+
* answered a broadcast, and it ends up in a config file, a systemd unit name and an MQTT topic.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Fields of a core discovery entry we pass on, with the maximum length we keep of each. */
|
|
17
|
+
const STRING_FIELDS = {
|
|
18
|
+
address: 128,
|
|
19
|
+
fqdn: 253,
|
|
20
|
+
hostname: 63,
|
|
21
|
+
name: 120,
|
|
22
|
+
model: 120,
|
|
23
|
+
type: 120,
|
|
24
|
+
serial: 120,
|
|
25
|
+
version: 60,
|
|
26
|
+
id: 253, // serial: the /dev/serial/by-id name
|
|
27
|
+
device: 253, // serial: the /dev/tty* the by-id name points at
|
|
28
|
+
udn: 120,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Instance names are topic prefixes, systemd instances and file names all at once. */
|
|
32
|
+
const INSTANCE_RE = /^[A-Za-z0-9_.-]+$/;
|
|
33
|
+
const MAX_NAME = 32;
|
|
34
|
+
|
|
35
|
+
/** German first, then whatever else NFKD can strip an accent off. */
|
|
36
|
+
const TRANSLITERATE = [
|
|
37
|
+
[/ä/g, 'ae'],
|
|
38
|
+
[/ö/g, 'oe'],
|
|
39
|
+
[/ü/g, 'ue'],
|
|
40
|
+
[/Ä/g, 'Ae'],
|
|
41
|
+
[/Ö/g, 'Oe'],
|
|
42
|
+
[/Ü/g, 'Ue'],
|
|
43
|
+
[/ß/g, 'ss'],
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
function cleanString(value, max) {
|
|
47
|
+
if (typeof value !== 'string') return null;
|
|
48
|
+
// control characters would break the log line and the terminal of whoever cats the env file
|
|
49
|
+
// eslint-disable-next-line no-control-regex
|
|
50
|
+
const stripped = value.replace(/[\u0000-\u001f\u007f]/g, '').trim();
|
|
51
|
+
return stripped ? stripped.slice(0, max) : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Turn a device's own name into something usable as an instance name.
|
|
56
|
+
* "Küche Oben" → "kueche-oben". Returns null when nothing usable is left.
|
|
57
|
+
* @param {string} value the name the device answers with
|
|
58
|
+
* @returns {string|null}
|
|
59
|
+
*/
|
|
60
|
+
function slugName(value) {
|
|
61
|
+
const cleaned = cleanString(value, 200);
|
|
62
|
+
if (!cleaned) return null;
|
|
63
|
+
let out = cleaned;
|
|
64
|
+
for (const [re, replacement] of TRANSLITERATE) out = out.replace(re, replacement);
|
|
65
|
+
out = out
|
|
66
|
+
.normalize('NFKD')
|
|
67
|
+
.replace(/[\u0300-\u036f]/g, '') // the accents NFKD just split off
|
|
68
|
+
.toLowerCase()
|
|
69
|
+
.replace(/[^a-z0-9_.-]+/g, '-')
|
|
70
|
+
.replace(/-{2,}/g, '-')
|
|
71
|
+
.replace(/^[-._]+|[-._]+$/g, '')
|
|
72
|
+
.slice(0, MAX_NAME)
|
|
73
|
+
.replace(/[-._]+$/, ''); // the slice may have ended on a separator
|
|
74
|
+
// all digits would be a uuid or a serial rather than a name, and reads like an index
|
|
75
|
+
if (!out || !INSTANCE_RE.test(out) || /^[0-9]+$/.test(out)) return null;
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A name not taken yet, suffixed `-2`, `-3`, … Instance names are MQTT topic prefixes, so the
|
|
81
|
+
* set to avoid is every instance she knows of — any adapter, any host — not just this adapter's.
|
|
82
|
+
* @param {string} base
|
|
83
|
+
* @param {Set<string>} taken lower-cased names already in use
|
|
84
|
+
* @returns {string}
|
|
85
|
+
*/
|
|
86
|
+
function uniqueName(base, taken) {
|
|
87
|
+
if (!taken.has(base)) return base;
|
|
88
|
+
for (let n = 2; n < 100; n++) {
|
|
89
|
+
const stem = base.slice(0, MAX_NAME - String(n).length - 1).replace(/[-._]+$/, '');
|
|
90
|
+
const candidate = `${stem}-${n}`;
|
|
91
|
+
if (!taken.has(candidate)) return candidate;
|
|
92
|
+
}
|
|
93
|
+
return base;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The value that goes into the config: the qualified name when DNS knows the device, its address
|
|
98
|
+
* otherwise — the same choice `--<option> auto` makes in the core, and for the same reason, a
|
|
99
|
+
* name outlives a DHCP lease. The short hostname is deliberately not used: it only resolves
|
|
100
|
+
* through the search list of whoever asks. For a serial port the address *is* the by-id path.
|
|
101
|
+
* @param {object} entry
|
|
102
|
+
* @returns {string|null}
|
|
103
|
+
*/
|
|
104
|
+
function configValue(entry) {
|
|
105
|
+
const fqdn = cleanString(entry.fqdn, STRING_FIELDS.fqdn);
|
|
106
|
+
const address = cleanString(entry.address, STRING_FIELDS.address);
|
|
107
|
+
return fqdn || address;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Validate and shape the entries of `--discover-json`.
|
|
112
|
+
*
|
|
113
|
+
* @param {unknown} raw parsed JSON from the adapter
|
|
114
|
+
* @param {object} [options]
|
|
115
|
+
* @param {Set<string>|string[]} [options.taken] instance names already in use (any adapter, any host)
|
|
116
|
+
* @param {Map<string,string>|object} [options.usedBy] configured value → instance using it
|
|
117
|
+
* @param {string} [options.fallbackName] instance name to build on when a device has no name of its own
|
|
118
|
+
* @param {number} [options.max] maximum number of devices returned (default 50)
|
|
119
|
+
* @returns {Array<object>} devices, each `{value, suggestName, usedBy, sources, services, …}`
|
|
120
|
+
*/
|
|
121
|
+
function shapeDevices(raw, options = {}) {
|
|
122
|
+
if (!Array.isArray(raw)) return [];
|
|
123
|
+
const taken = new Set([...(options.taken ?? [])].map((n) => String(n).toLowerCase()));
|
|
124
|
+
const usedBy = options.usedBy instanceof Map ? options.usedBy : new Map(Object.entries(options.usedBy ?? {}));
|
|
125
|
+
const max = Number.isFinite(options.max) ? options.max : 50;
|
|
126
|
+
const out = [];
|
|
127
|
+
for (const entry of raw) {
|
|
128
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
|
|
129
|
+
const value = configValue(entry);
|
|
130
|
+
if (!value) continue; // an entry we cannot configure anything with is of no use here
|
|
131
|
+
const device = { value };
|
|
132
|
+
for (const [field, maxLen] of Object.entries(STRING_FIELDS)) {
|
|
133
|
+
const clean = cleanString(entry[field], maxLen);
|
|
134
|
+
if (clean) device[field] = clean;
|
|
135
|
+
}
|
|
136
|
+
device.sources = Array.isArray(entry.sources)
|
|
137
|
+
? entry.sources
|
|
138
|
+
.map((s) => cleanString(s, 20))
|
|
139
|
+
.filter(Boolean)
|
|
140
|
+
.slice(0, 8)
|
|
141
|
+
: [];
|
|
142
|
+
device.services = {};
|
|
143
|
+
if (entry.services && typeof entry.services === 'object' && !Array.isArray(entry.services)) {
|
|
144
|
+
for (const [label, open] of Object.entries(entry.services).slice(0, 24)) {
|
|
145
|
+
const clean = cleanString(label, 40);
|
|
146
|
+
if (clean) device.services[clean] = open === true;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
// The name the *user* gave the device (core convention: `name`), not what it is
|
|
150
|
+
// (`model`/`type`) — an instance name built from a model would repeat per device.
|
|
151
|
+
const slug = slugName(entry.name) ?? (options.fallbackName ? slugName(options.fallbackName) : null);
|
|
152
|
+
if (slug) {
|
|
153
|
+
const unique = uniqueName(slug, taken);
|
|
154
|
+
device.suggestName = unique;
|
|
155
|
+
taken.add(unique); // two speakers both called "Küche" must not both propose kueche
|
|
156
|
+
}
|
|
157
|
+
// An instance may have been configured with any of the identifiers this device answers
|
|
158
|
+
// to: the raw /dev/ttyACM0 rather than the by-id path, or the address before dns knew a
|
|
159
|
+
// name for it. Matching only `value` would offer a stick that is already in use.
|
|
160
|
+
device.usedBy = null;
|
|
161
|
+
for (const identifier of [value, device.address, device.fqdn, device.hostname, device.device, device.id]) {
|
|
162
|
+
if (identifier && usedBy.has(identifier)) {
|
|
163
|
+
device.usedBy = usedBy.get(identifier);
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
out.push(device);
|
|
168
|
+
if (out.length >= max) break;
|
|
169
|
+
}
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The discovery kinds a schema asks for, from the `x-discover` marker the core sets on the one
|
|
175
|
+
* property that takes what a scan finds (`ccu-address`, `serialport`, …).
|
|
176
|
+
*
|
|
177
|
+
* @param {object|null} schema a `--config-schema` document
|
|
178
|
+
* @returns {{key: string, envName: string, kinds: string[]}|null} null when not discovery-capable
|
|
179
|
+
*/
|
|
180
|
+
function discoverTarget(schema) {
|
|
181
|
+
const properties = schema && typeof schema === 'object' ? schema.properties : null;
|
|
182
|
+
if (!properties || typeof properties !== 'object') return null;
|
|
183
|
+
for (const [key, prop] of Object.entries(properties)) {
|
|
184
|
+
if (!prop || typeof prop !== 'object' || prop['x-discover'] === undefined) continue;
|
|
185
|
+
const raw = prop['x-discover'];
|
|
186
|
+
// 'network' | 'serial' | both; `true` from a future core is treated as a network scan
|
|
187
|
+
const kinds = (Array.isArray(raw) ? raw : [raw]).map((k) => (k === true ? 'network' : k)).filter((k) => k === 'network' || k === 'serial');
|
|
188
|
+
if (kinds.length === 0) continue;
|
|
189
|
+
return { key, envName: typeof prop['x-env'] === 'string' ? prop['x-env'] : null, kinds };
|
|
190
|
+
}
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
module.exports = { shapeDevices, slugName, uniqueName, configValue, discoverTarget, INSTANCE_RE, MAX_NAME };
|
package/src/lib/services-host.js
CHANGED
|
@@ -26,7 +26,7 @@ const { execFile, spawn } = require('child_process');
|
|
|
26
26
|
const sshDeploy = require('./ssh-deploy');
|
|
27
27
|
|
|
28
28
|
const HELPER = '/usr/local/bin/she-servicectl';
|
|
29
|
-
const HELPER_VERSION =
|
|
29
|
+
const HELPER_VERSION = 11; // must match VERSION in service/she-servicectl
|
|
30
30
|
/** Default identity for services.hosts[].ssh — one key for all managed hosts (I5). */
|
|
31
31
|
const DEFAULT_SERVICES_IDENTITY = '~/.she/ssh/services_id_ed25519';
|
|
32
32
|
/** The helper as shipped with this she version (copied to remote hosts by the deploy route). */
|
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 };
|