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.
- package/dist/web/assets/index-5np-MgKm.js +273 -0
- package/dist/web/assets/{index-BWan5OPE.css → index-Ggp8nXi_.css} +1 -1
- package/dist/web/assets/{tsMode-CLQ49mna.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/lib/device-discovery.js +194 -0
- package/src/lib/services-host.js +1 -1
- package/src/web/services-api.js +128 -2
- package/dist/web/assets/index-CHsE05wI.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 "$@" ;;
|
|
@@ -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/services-api.js
CHANGED
|
@@ -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 });
|