smart-home-engine 1.37.0 → 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-CHsE05wI.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-CHsE05wI.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-BWan5OPE.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.37.0",
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 "$@" ;;
@@ -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). */
@@ -52,6 +52,7 @@ const fs = require('fs');
52
52
  const crypto = require('crypto');
53
53
  const dns = require('dns');
54
54
  const { analyzeServices, wipeTopics, LOG_LEVELS } = require('../lib/services-inventory');
55
+ const { shapeDevices, discoverTarget } = require('../lib/device-discovery');
55
56
  const npmRegistry = require('../lib/npm-registry');
56
57
  const catalog = require('../lib/services-catalog');
57
58
  const os = require('os');
@@ -602,9 +603,10 @@ async function listHosts(req) {
602
603
  );
603
604
  }
604
605
 
605
- // every mutating host route changes what the listing would show
606
+ // every mutating host route changes what the listing would show — a scan does not, and
607
+ // re-listing every host (ssh round trips included) after each one would be pure waste
606
608
  router.use((req, res, next) => {
607
- if (req.method !== 'GET') {
609
+ if (req.method !== 'GET' && !req.path.endsWith('/discover')) {
608
610
  res.on('finish', () => {
609
611
  if (req.path.startsWith('/hosts/')) invalidateHosts();
610
612
  });
@@ -803,6 +805,130 @@ router.get('/hosts/:host/adapters/:adapter/schema', async (req, res) => {
803
805
  }
804
806
  });
805
807
 
808
+ // POST /she/services/hosts/:host/adapters/:adapter/discover { timeout?, address? }
809
+ /**
810
+ * Scan for devices with the adapter's own `--discover` on the target host (roadmap I13).
811
+ *
812
+ * The scan has to run *there*: broadcast, multicast, ARP and /dev/serial only reach the network
813
+ * and the usb bus of the machine running the scan, and that is the adapter's host, not
814
+ * necessarily she's. The adapter owns the protocols; she shapes the answer and adds what only it
815
+ * knows — which devices an instance already uses, and a free instance name.
816
+ */
817
+ router.post('/hosts/:host/adapters/:adapter/discover', async (req, res) => {
818
+ const entry = resolve(req, res, { adapter: true });
819
+ if (!entry) return;
820
+ const adapter = req.params.adapter;
821
+ const body = req.body || {};
822
+ const args = ['discover', adapter];
823
+ if (body.timeout !== undefined) {
824
+ const timeout = Number(body.timeout);
825
+ if (!Number.isInteger(timeout) || timeout < 1 || timeout > 120) return res.status(400).json({ error: 'timeout must be 1-120 seconds' });
826
+ args.push('--timeout', String(timeout));
827
+ }
828
+ const addresses = body.address === undefined ? [] : Array.isArray(body.address) ? body.address : [body.address];
829
+ if (addresses.length > 8) return res.status(400).json({ error: 'at most 8 addresses' });
830
+ for (const address of addresses) {
831
+ // an ipv4 address, a hostname or a cidr range — the helper validates again
832
+ if (typeof address !== 'string' || !/^[A-Za-z0-9.:_-]+(\/[0-9]{1,3})?$/.test(address) || address.length > 64) {
833
+ return res.status(400).json({ error: 'invalid address: ' + String(address).slice(0, 40) });
834
+ }
835
+ args.push('--address', address);
836
+ }
837
+ let schema;
838
+ try {
839
+ // not swallowed: an unreachable host or a missing helper must say so, rather than come
840
+ // out as "this adapter cannot be discovered", which sends the user after the wrong thing
841
+ schema = await loadSchema(entry.driver, adapter);
842
+ } catch (err) {
843
+ return hostError(res, err);
844
+ }
845
+ const target = discoverTarget(schema);
846
+ if (!target) return res.status(400).json({ error: adapter + ' does not support discovery', code: 'NO_DISCOVERY' });
847
+ try {
848
+ // one listing serves both the free-name check and the "already configured" markers
849
+ let listing = null;
850
+ try {
851
+ listing = parseList((await entry.driver.exec(['list'], { timeout: 30000 })).stdout);
852
+ } catch {
853
+ /* the scan is still worth running without it */
854
+ }
855
+ // the adapter listens per method for the timeout, and a --discover-address sweep of a /24
856
+ // adds a minute of tcp connects on top: give the helper room rather than killing a scan
857
+ const seconds = body.timeout === undefined ? 5 : Number(body.timeout);
858
+ const { stdout } = await entry.driver.exec(args, { timeout: (seconds + 20) * 1000 + addresses.length * 60000 });
859
+ let raw;
860
+ try {
861
+ raw = JSON.parse(stdout);
862
+ } catch {
863
+ throw new HostError('EXEC_FAILED', adapter + ' did not print discovery JSON');
864
+ }
865
+ res.json({
866
+ devices: shapeDevices(raw, {
867
+ taken: await knownInstanceNames(listing),
868
+ usedBy: await configuredValues(entry, adapter, target.envName, listing),
869
+ fallbackName: typeof schema?.properties?.name?.default === 'string' ? schema.properties.name.default : null,
870
+ }),
871
+ property: target.key,
872
+ envName: target.envName,
873
+ kinds: target.kinds,
874
+ });
875
+ } catch (err) {
876
+ hostError(res, err);
877
+ }
878
+ });
879
+
880
+ /**
881
+ * Every instance name she knows of: the retained MQTT inventory (every instance that ever
882
+ * connected, whatever host it runs on), the cached listing of all hosts when it is warm, and the
883
+ * listing of the host being installed on. An instance name is an MQTT topic prefix, so a name
884
+ * taken by another adapter on another host is taken all the same.
885
+ * @param {{instances?: Array<{instance: string}>}|null} listing the target host's listing
886
+ * @returns {Promise<Set<string>>}
887
+ */
888
+ async function knownInstanceNames(listing) {
889
+ const names = new Set();
890
+ const add = (name) => {
891
+ if (typeof name === 'string' && name) names.add(name.toLowerCase());
892
+ };
893
+ if (_store) {
894
+ for (const inst of analyzeServices(_store.mqttEntries()).instances) add(inst.instance);
895
+ }
896
+ if (_hostsCache) {
897
+ try {
898
+ for (const host of await _hostsCache.promise) {
899
+ for (const unit of host.instances ?? []) add(unit.instance);
900
+ }
901
+ } catch {
902
+ /* a host that cannot be listed contributes no names */
903
+ }
904
+ }
905
+ for (const unit of listing?.instances ?? []) add(unit.instance);
906
+ return names;
907
+ }
908
+
909
+ /**
910
+ * What the adapter's instances on this host are already pointed at: `<value> → <instance>`, read
911
+ * from their env files. Best effort — an unreadable env file only means one missing marker.
912
+ * @returns {Promise<Map<string,string>>}
913
+ */
914
+ async function configuredValues(entry, adapter, envName, listing) {
915
+ const used = new Map();
916
+ if (!envName || !listing) return used;
917
+ const instances = (listing.instances ?? []).filter((i) => i.adapter === adapter);
918
+ await Promise.all(
919
+ instances.map(async (unit) => {
920
+ try {
921
+ const { stdout } = await entry.driver.exec(['env', adapter, unit.instance, 'read'], { timeout: 15000 });
922
+ const value = parseEnvFile(stdout)[envName];
923
+ if (typeof value === 'string' && value.trim()) used.set(value.trim(), unit.instance);
924
+ } catch {
925
+ /* not readable: this instance simply does not mark a device */
926
+ }
927
+ }),
928
+ );
929
+ return used;
930
+ }
931
+
806
932
  // POST /she/services/hosts/:host/adapters/:adapter/install { instance, env }
807
933
  router.post('/hosts/:host/adapters/:adapter/install', async (req, res) => {
808
934
  const entry = resolve(req, res, { adapter: true });