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
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 });
|