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