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.
@@ -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-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
@@ -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-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-hiXQxo8M.css">
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smart-home-engine",
3
- "version": "1.36.1",
3
+ "version": "1.38.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": {
@@ -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=10
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
- .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 {
@@ -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 };
@@ -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 = 10; // must match VERSION in service/she-servicectl
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
- 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 };