@camstack/system 1.1.51 → 1.1.52

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.
Files changed (58) hide show
  1. package/dist/addon-runner.js +2 -2
  2. package/dist/addon-runner.mjs +2 -2
  3. package/dist/addon-utils.js +1 -1
  4. package/dist/addon-utils.mjs +1 -1
  5. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  6. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  7. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  8. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  9. package/dist/builtins/alerts/alerts.addon.js +1 -1
  10. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  11. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
  12. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
  13. package/dist/builtins/console-logging/index.js +1 -1
  14. package/dist/builtins/console-logging/index.mjs +1 -1
  15. package/dist/builtins/device-manager/device-linked-devices.d.ts +47 -0
  16. package/dist/builtins/device-manager/device-manager.addon.js +1840 -1712
  17. package/dist/builtins/device-manager/device-manager.addon.mjs +1840 -1712
  18. package/dist/builtins/doorbell/index.d.ts +2 -0
  19. package/dist/builtins/doorbell/index.js +8 -0
  20. package/dist/builtins/doorbell/index.mjs +2 -0
  21. package/dist/builtins/doorbell/trigger-engine.d.ts +69 -0
  22. package/dist/builtins/doorbell/virtual-doorbell.addon.d.ts +87 -0
  23. package/dist/builtins/doorbell/virtual-doorbell.addon.js +489 -0
  24. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +483 -0
  25. package/dist/builtins/hub-forwarder/index.js +1 -1
  26. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  27. package/dist/builtins/local-auth/local-auth.addon.js +2 -2
  28. package/dist/builtins/local-auth/local-auth.addon.mjs +2 -2
  29. package/dist/builtins/local-network/local-network.addon.js +1 -1
  30. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  31. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  32. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  33. package/dist/builtins/platform-probe/index.js +1 -1
  34. package/dist/builtins/platform-probe/index.mjs +1 -1
  35. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  36. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  37. package/dist/builtins/snapshot/index.js +1 -1
  38. package/dist/builtins/snapshot/index.mjs +1 -1
  39. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  40. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  41. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +1 -1
  42. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +1 -1
  43. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  44. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  45. package/dist/builtins/system-config/system-config.addon.js +4 -4
  46. package/dist/builtins/system-config/system-config.addon.mjs +4 -4
  47. package/dist/builtins/winston-logging/index.js +1 -1
  48. package/dist/builtins/winston-logging/index.mjs +1 -1
  49. package/dist/{dist-BWQX9yUj.mjs → dist-B_PSvr-m.mjs} +228 -12
  50. package/dist/{dist-C3uHEBtP.js → dist-BduBhv6N.js} +233 -11
  51. package/dist/http/file-data-plane.d.ts +8 -0
  52. package/dist/index.js +3 -3
  53. package/dist/index.mjs +3 -3
  54. package/dist/{manifest-python-deps-BA6KA9If.mjs → manifest-python-deps-CIyvuHAB.mjs} +1 -1
  55. package/dist/{manifest-python-deps-CVcJ9hDX.js → manifest-python-deps-CPAMH5gV.js} +1 -1
  56. package/dist/{model-download-service-C-IHWnXx.mjs → model-download-service-Cp9f4dk6.mjs} +1 -1
  57. package/dist/{model-download-service-D-Umz4-L.js → model-download-service-hf0ookyy.js} +1 -1
  58. package/package.json +13 -1
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-C3uHEBtP.js");
6
+ const require_dist = require("../../dist-BduBhv6N.js");
7
7
  let _camstack_types_node = require("@camstack/types/node");
8
8
  let zod = require("zod");
9
9
  let node_crypto = require("node:crypto");
@@ -1747,1832 +1747,1956 @@ var DeviceEventPropagator = class {
1747
1747
  }
1748
1748
  };
1749
1749
  //#endregion
1750
- //#region src/builtins/device-manager/device-link-cycle.ts
1751
- function nodeKey(deviceId, cap) {
1752
- return `${deviceId}:${cap}`;
1753
- }
1754
- /** The target's container stableId — its parent's (falling back to its own for
1755
- * a top-level target). Sibling FIELD sources resolve relative to this, the same
1756
- * rule `rebuildLinkDependents` / `resolveLinkedStatus` apply. */
1757
- function containerStableId(targetRow, rowById) {
1758
- return targetRow.parentDeviceId !== null ? rowById.get(targetRow.parentDeviceId)?.stableId ?? targetRow.stableId : targetRow.stableId;
1759
- }
1760
- /** Resolve ONE field/global/literal binding to its `(deviceId, cap)` node, or
1761
- * null when it is a literal or resolves to no known device. */
1762
- function resolveBindingNode(binding, targetRow, rowById, idByStableId) {
1763
- if (binding.kind === "literal") return null;
1764
- const wantedStableId = binding.kind === "global" ? binding.sourceStableId : `${containerStableId(targetRow, rowById)}-${binding.sourceKey}`;
1765
- const srcId = idByStableId.get(wantedStableId);
1766
- if (srcId === void 0) return null;
1767
- return nodeKey(srcId, binding.cap);
1768
- }
1750
+ //#region src/builtins/device-manager/device-queries.ts
1769
1751
  /**
1770
- * Resolve a link source to the set of `(deviceId, cap)` nodes it depends on.
1771
- * A literal source depends on nothing; a field/global source depends on exactly
1772
- * one node; an EXPRESSION source depends on one node per resolvable non-literal
1773
- * binding. Unresolvable (dangling) bindings contribute no edge — they cannot
1774
- * loop while dangling; a cycle that only becomes resolvable later is bounded by
1775
- * the resolve-time re-entrancy guard.
1752
+ * Device query + live-runtime + provider/adoption operations for the
1753
+ * device-manager addon the read/live half of the `IDeviceManagerProvider`
1754
+ * surface. Extracted verbatim from the `onInitialize` provider literal as free
1755
+ * functions taking a `ProviderContext` (DI). Input/return types are pinned to
1756
+ * the cap contract via `Parameters<IDeviceManagerProvider['x']>[0]` /
1757
+ * `ReturnType<…>` so the typed surface is identical to the inferred methods.
1758
+ *
1759
+ * Covers: persisted/live device queries (listPersistedByAddon, listAll,
1760
+ * getDevice, getChildren), live runtime methods (getStreamSources,
1761
+ * getConfigSchema, getSettingsSchema, updateConfig, enable, disable, remove,
1762
+ * removeByIntegration, getStreamProfileMap, setStreamProfileMap, probeStreams),
1763
+ * device-provider ops (discoverDevices, adoptDevice, getCreationSchema,
1764
+ * createDevice, testCreationField), and device-adoption ops
1765
+ * (adoptionListCandidates/Refresh/Adopt/Release/Resync, testField).
1766
+ *
1767
+ * The hub-live-then-persisted-fallback union, the cross-process device-ops
1768
+ * dispatch, the depth-first remove cascade, and the resetToSource resync purge
1769
+ * are byte-for-byte preserved. `remove`/`removeByIntegration`/`enable`/`disable`/
1770
+ * `probeStreams`/`getStreamProfileMap` self-call sibling cap methods via
1771
+ * `pctx.provider`. Pure refactor — no behavior change.
1776
1772
  */
1777
- function resolveSourceNodes(link, targetRow, rowById, idByStableId) {
1778
- const src = link.source;
1779
- if (src.kind === "expression") {
1780
- const nodes = [];
1781
- for (const binding of Object.values(src.bindings)) {
1782
- const node = resolveBindingNode(binding, targetRow, rowById, idByStableId);
1783
- if (node !== null) nodes.push(node);
1784
- }
1785
- return nodes;
1786
- }
1787
- const node = resolveBindingNode(src, targetRow, rowById, idByStableId);
1788
- return node !== null ? [node] : [];
1773
+ async function listPersistedByAddon(pctx, input) {
1774
+ const { addonId } = input;
1775
+ const [index, meta] = await Promise.all([pctx.metaStore.readIndex(), pctx.metaStore.readMeta()]);
1776
+ const stableIds = index[addonId] ?? [];
1777
+ const byStableId = /* @__PURE__ */ new Map();
1778
+ for (const m of Object.values(meta)) if (m.addonId === addonId) byStableId.set(m.stableId, m);
1779
+ return stableIds.map((stableId) => {
1780
+ const m = byStableId.get(stableId);
1781
+ return {
1782
+ id: m.id,
1783
+ stableId,
1784
+ type: m.type,
1785
+ name: m.name,
1786
+ location: m.location ?? null,
1787
+ disabled: m.disabled ?? false,
1788
+ parentDeviceId: m.parentDeviceId
1789
+ };
1790
+ });
1789
1791
  }
1790
- /**
1791
- * Detect whether replacing `editedDeviceId`'s links with `editedLinks` closes
1792
- * a dependency cycle. Returns the cycle as an ordered list of node keys
1793
- * (first node repeated at the end) or null when the set is safe.
1794
- */
1795
- function findDeviceLinkCycle(allMeta, editedDeviceId, editedLinks) {
1796
- const rows = Object.values(allMeta);
1797
- const rowById = /* @__PURE__ */ new Map();
1798
- const idByStableId = /* @__PURE__ */ new Map();
1799
- for (const r of rows) {
1800
- rowById.set(r.id, r);
1801
- if (!idByStableId.has(r.stableId)) idByStableId.set(r.stableId, r.id);
1802
- }
1803
- const dependsOn = /* @__PURE__ */ new Map();
1804
- const addEdges = (row, links) => {
1805
- for (const link of links) {
1806
- const srcNodes = resolveSourceNodes(link, row, rowById, idByStableId);
1807
- if (srcNodes.length === 0) continue;
1808
- const tNode = nodeKey(row.id, link.target.cap);
1809
- const list = dependsOn.get(tNode) ?? [];
1810
- for (const srcNode of srcNodes) list.push(srcNode);
1811
- dependsOn.set(tNode, list);
1792
+ async function listAll(pctx, input) {
1793
+ const { addonId } = input;
1794
+ const results = [];
1795
+ const seen = /* @__PURE__ */ new Set();
1796
+ const meta = await pctx.metaStore.readMeta();
1797
+ const metadataMap = await pctx.metaStore.readMetadataMap();
1798
+ if (pctx.registry) {
1799
+ const liveEntries = addonId ? pctx.registry.getAllForAddon(addonId).map((device) => ({
1800
+ addonId,
1801
+ device
1802
+ })) : pctx.registry.getAllWithAddonId();
1803
+ for (const { addonId: aid, device } of liveEntries) {
1804
+ const key = String(device.id);
1805
+ const metadata = metadataMap[key] ?? null;
1806
+ const metaRow = meta[key] ?? null;
1807
+ results.push(toDeviceInfo(aid, device, metadata, metaRow));
1808
+ seen.add(key);
1812
1809
  }
1813
- };
1814
- for (const r of rows) {
1815
- const links = r.id === editedDeviceId ? editedLinks : r.deviceLinks ?? [];
1816
- if (links.length > 0) addEdges(r, links);
1817
1810
  }
1818
- const roots = [...new Set(editedLinks.map((l) => nodeKey(editedDeviceId, l.target.cap)))];
1819
- for (const root of roots) {
1820
- const cycle = dfsFindCycle(root, dependsOn);
1821
- if (cycle) return cycle;
1811
+ const index = await pctx.metaStore.readIndex();
1812
+ const metaByAddonStable = /* @__PURE__ */ new Map();
1813
+ for (const m of Object.values(meta)) metaByAddonStable.set(`${m.addonId}${m.stableId}`, m);
1814
+ const targetAddons = addonId ? [addonId] : Object.keys(index);
1815
+ for (const aid of targetAddons) for (const stableId of index[aid] ?? []) {
1816
+ const m = metaByAddonStable.get(`${aid}${stableId}`);
1817
+ const key = String(m.id);
1818
+ if (seen.has(key)) continue;
1819
+ const persistedType = m.type;
1820
+ const persistedConfig = await pctx.settings.readDeviceStore(m.id);
1821
+ const metadata = metadataMap[key] ?? null;
1822
+ results.push({
1823
+ id: m.id,
1824
+ stableId,
1825
+ addonId: aid,
1826
+ type: persistedType,
1827
+ name: m?.name ?? stableId,
1828
+ location: m?.location ?? null,
1829
+ disabled: m?.disabled ?? false,
1830
+ parentDeviceId: m?.parentDeviceId ?? null,
1831
+ role: toDeviceRole(m?.role),
1832
+ online: pctx.host.resolveDeviceOnline(m.id, pctx.registry !== null),
1833
+ probed: pctx.host.resolveDeviceProbed(m.id),
1834
+ features: persistedFeatures(m?.features),
1835
+ isCamera: persistedType === require_dist.DeviceType.Camera,
1836
+ config: persistedConfig ?? {},
1837
+ metadata,
1838
+ ...m?.integrationId !== void 0 ? { integrationId: m.integrationId } : {},
1839
+ ...m?.linkDeviceId !== void 0 ? { linkDeviceId: m.linkDeviceId } : {},
1840
+ ...m?.primaryChildEntityId !== void 0 ? { primaryChildEntityId: m.primaryChildEntityId } : {},
1841
+ ...m?.childLayout !== void 0 ? { childLayout: m.childLayout } : {},
1842
+ ...m?.deviceLinks !== void 0 ? { deviceLinks: m.deviceLinks } : {},
1843
+ ...m?.display !== void 0 ? { display: m.display } : {},
1844
+ ...(() => {
1845
+ const si = buildSourceInfoFromConfig(persistedConfig ?? {}, stableId, aid);
1846
+ return si !== void 0 ? { sourceInfo: si } : {};
1847
+ })()
1848
+ });
1822
1849
  }
1823
- return null;
1850
+ return results;
1824
1851
  }
1825
- /** Iterative DFS from `root` over `dependsOn`; returns the first back-edge
1826
- * cycle path (closed first node repeated last) or null. */
1827
- function dfsFindCycle(root, dependsOn) {
1828
- const onPath = /* @__PURE__ */ new Set();
1829
- const done = /* @__PURE__ */ new Set();
1830
- const path = [];
1831
- const stack = [{
1832
- node: root,
1833
- nextChild: 0
1834
- }];
1835
- onPath.add(root);
1836
- path.push(root);
1837
- while (stack.length > 0) {
1838
- const frame = stack[stack.length - 1];
1839
- const children = dependsOn.get(frame.node) ?? [];
1840
- if (frame.nextChild < children.length) {
1841
- const child = children[frame.nextChild];
1842
- frame.nextChild += 1;
1843
- if (onPath.has(child)) {
1844
- const start = path.indexOf(child);
1845
- return [...path.slice(start), child];
1846
- }
1847
- if (done.has(child)) continue;
1848
- stack.push({
1849
- node: child,
1850
- nextChild: 0
1851
- });
1852
- onPath.add(child);
1853
- path.push(child);
1854
- continue;
1852
+ async function getDevice(pctx, input) {
1853
+ const { deviceId } = input;
1854
+ if (pctx.registry) {
1855
+ const found = resolveDeviceById(pctx.registry, deviceId);
1856
+ if (found) {
1857
+ const key = String(found.device.id);
1858
+ const [map, metaMap] = await Promise.all([pctx.metaStore.readMetadataMap(), pctx.metaStore.readMeta()]);
1859
+ const metadata = map[key] ?? null;
1860
+ const metaRow = metaMap[key] ?? null;
1861
+ return toDeviceInfo(found.addonId, found.device, metadata, metaRow);
1855
1862
  }
1856
- stack.pop();
1857
- onPath.delete(frame.node);
1858
- path.pop();
1859
- done.add(frame.node);
1860
1863
  }
1861
- return null;
1864
+ const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
1865
+ if (!persisted) return null;
1866
+ const { addonId: aid, stableId, meta: m } = persisted;
1867
+ const persistedConfig = await pctx.settings.readDeviceStore(m.id);
1868
+ const key = String(deviceId);
1869
+ const metadata = (await pctx.metaStore.readMetadataMap())[key] ?? null;
1870
+ const sourceInfoGetDevice = buildSourceInfoFromConfig(persistedConfig ?? {}, stableId, aid);
1871
+ return {
1872
+ id: deviceId,
1873
+ stableId,
1874
+ addonId: aid,
1875
+ type: m.type,
1876
+ name: m.name,
1877
+ location: m.location ?? null,
1878
+ disabled: m.disabled ?? false,
1879
+ parentDeviceId: m.parentDeviceId,
1880
+ role: toDeviceRole(m.role),
1881
+ online: pctx.host.resolveDeviceOnline(deviceId, true),
1882
+ probed: pctx.host.resolveDeviceProbed(deviceId),
1883
+ features: persistedFeatures(m.features),
1884
+ isCamera: false,
1885
+ config: persistedConfig ?? {},
1886
+ metadata,
1887
+ ...m.integrationId !== void 0 ? { integrationId: m.integrationId } : {},
1888
+ ...m.linkDeviceId !== void 0 ? { linkDeviceId: m.linkDeviceId } : {},
1889
+ ...m.primaryChildEntityId !== void 0 ? { primaryChildEntityId: m.primaryChildEntityId } : {},
1890
+ ...m.childLayout !== void 0 ? { childLayout: m.childLayout } : {},
1891
+ ...m.deviceLinks !== void 0 ? { deviceLinks: m.deviceLinks } : {},
1892
+ ...m.display !== void 0 ? { display: m.display } : {},
1893
+ ...sourceInfoGetDevice !== void 0 ? { sourceInfo: sourceInfoGetDevice } : {}
1894
+ };
1862
1895
  }
1863
- //#endregion
1864
- //#region src/builtins/device-manager/device-meta-actions.ts
1865
- /**
1866
- * Device meta-mutation + persistence actions for the device-manager addon —
1867
- * the `IDeviceManagerProvider` write/load surface. Extracted verbatim from the
1868
- * `onInitialize` provider literal as free functions taking a `ProviderContext`
1869
- * (DI). Each function's input/return types are pinned to the cap contract via
1870
- * `Parameters<IDeviceManagerProvider['x']>[0]` / `ReturnType<…>` so the typed
1871
- * surface is identical to the inferred object-literal methods.
1872
- *
1873
- * Covers: id allocation + registration (allocateDeviceId, registerDevice,
1874
- * removeDevice), config persistence (persistConfig, loadConfig), the meta
1875
- * surface load (loadMeta, loadRuntimeState), every meta setter (setName,
1876
- * setLocation, setType, setIntegrationId, setLinkDeviceId,
1877
- * setPrimaryChildEntityId, setChildLayout, setDeviceLinks, setRole, setDisplay,
1878
- * applyInitialMeta, setMetadata, setDisabled), and the location registry
1879
- * (listLocations, addLocation, removeLocation).
1880
- *
1881
- * EXACT ordering and side effects preserved — notably registerDevice's
1882
- * name-precedence reconcile + field-preservation spreads, the
1883
- * withMetaWriteLock-serialised read-modify-writes, the event emissions, and the
1884
- * link reverse-index rebuild gating. Pure refactor — no behavior change.
1885
- */
1886
- /**
1887
- * Stamp (or update) the owning integration id on a device's meta row + emit
1888
- * `DeviceMetaChanged`. The device-manager chokepoint shared by the
1889
- * `setIntegrationId` cap handler AND the create/adopt forwarders (queries)
1890
- * that stamp an integration's ownership onto every device it materializes.
1891
- * Idempotent. Takes its low-level deps directly (not the full `ProviderContext`)
1892
- * because `ProviderContext.stampIntegrationId` delegates HERE — passing the
1893
- * context would be a capture cycle.
1894
- */
1895
- async function stampIntegrationId(metaStore, settings, ctx, deviceId, integrationId) {
1896
- await metaStore.withMetaWriteLock(async () => {
1897
- const persisted = await metaStore.resolvePersistedById(deviceId);
1898
- if (!persisted) throw new Error(`[device-manager] stampIntegrationId: unknown device id=${deviceId}`);
1899
- const { meta: m } = persisted;
1900
- const key = String(deviceId);
1901
- const allMeta = await metaStore.readMeta();
1902
- await settings.writeAddonStore({ deviceMeta: {
1903
- ...allMeta,
1904
- [key]: {
1905
- ...m,
1906
- integrationId
1907
- }
1908
- } });
1909
- });
1910
- ctx.eventBus.emit({
1911
- id: (0, node_crypto.randomUUID)(),
1912
- timestamp: /* @__PURE__ */ new Date(),
1913
- source: {
1914
- type: "device",
1915
- id: deviceId
1916
- },
1917
- category: require_dist.EventCategory.DeviceMetaChanged,
1918
- data: {
1919
- deviceId,
1920
- field: "integrationId",
1921
- value: integrationId
1896
+ async function getChildren(pctx, input) {
1897
+ const { parentDeviceId } = input;
1898
+ let ownerAddonId = null;
1899
+ if (pctx.registry) {
1900
+ if (pctx.registry.getById(parentDeviceId)) ownerAddonId = pctx.registry.getAddonId(parentDeviceId);
1901
+ }
1902
+ if (!ownerAddonId) {
1903
+ const persisted = await pctx.metaStore.resolvePersistedById(parentDeviceId);
1904
+ if (!persisted) return [];
1905
+ ownerAddonId = persisted.addonId;
1906
+ }
1907
+ const results = [];
1908
+ const seen = /* @__PURE__ */ new Set();
1909
+ const [index, meta, metadataMap] = await Promise.all([
1910
+ pctx.metaStore.readIndex(),
1911
+ pctx.metaStore.readMeta(),
1912
+ pctx.metaStore.readMetadataMap()
1913
+ ]);
1914
+ if (pctx.registry) {
1915
+ const liveChildren = pctx.registry.getChildren(parentDeviceId);
1916
+ for (const device of liveChildren) {
1917
+ const key = String(device.id);
1918
+ const metadata = metadataMap[key] ?? null;
1919
+ const metaRow = meta[key] ?? null;
1920
+ results.push(toDeviceInfo(ownerAddonId, device, metadata, metaRow));
1921
+ seen.add(key);
1922
1922
  }
1923
- });
1924
- }
1925
- async function allocateDeviceId(pctx, input) {
1926
- const { addonId, stableId } = input;
1927
- return await pctx.metaStore.withMetaWriteLock(async () => {
1928
- const meta = await pctx.metaStore.readMeta();
1929
- const existing = Object.values(meta).find((m) => m.addonId === addonId && m.stableId === stableId);
1930
- if (existing) return { id: existing.id };
1931
- const id = await pctx.metaStore.allocateNextDeviceId();
1932
- await pctx.settings.writeAddonStore({ deviceMeta: {
1933
- ...meta,
1934
- [String(id)]: {
1935
- addonId,
1936
- stableId,
1937
- type: "generic",
1938
- name: stableId,
1939
- location: null,
1940
- disabled: false,
1941
- parentDeviceId: null,
1942
- id
1943
- }
1944
- } });
1945
- return { id };
1946
- });
1947
- }
1948
- async function registerDevice(pctx, input) {
1949
- const { addonId, stableId, id, type, name, parentDeviceId, features, config } = input;
1950
- const key = String(id);
1951
- const featuresArr = Array.isArray(features) ? [...features] : [];
1952
- const { isFirstRegistration, exportFingerprint, fingerprintChanged } = await pctx.metaStore.withMetaWriteLock(async () => {
1953
- const index = await pctx.metaStore.readIndex();
1954
- const existing = index[addonId] ?? [];
1955
- const wasInIndex = existing.includes(stableId);
1956
- if (!wasInIndex) await pctx.settings.writeAddonStore({ deviceIndex: {
1957
- ...index,
1958
- [addonId]: [...existing, stableId]
1959
- } });
1960
- const meta = await pctx.metaStore.readMeta();
1961
- const existingMeta = meta[key];
1962
- const wasUserNamed = existingMeta?.userNamed ?? (existingMeta !== void 0 && existingMeta.name !== stableId);
1963
- const resolvedName = wasUserNamed && existingMeta !== void 0 ? existingMeta.name : name;
1964
- const isFirst = !existingMeta || !wasInIndex;
1965
- const fingerprint = (0, _camstack_types_node.canonicalDeviceFingerprint)({
1966
- deviceId: id,
1967
- deviceType: type,
1968
- features: featuresArr
1923
+ }
1924
+ const ownerMetaByStableId = /* @__PURE__ */ new Map();
1925
+ for (const m of Object.values(meta)) if (m.addonId === ownerAddonId) ownerMetaByStableId.set(m.stableId, m);
1926
+ const persistedChildren = (index[ownerAddonId] ?? []).filter((sid) => ownerMetaByStableId.get(sid)?.parentDeviceId === parentDeviceId);
1927
+ for (const childStableId of persistedChildren) {
1928
+ const m = ownerMetaByStableId.get(childStableId);
1929
+ const key = String(m.id);
1930
+ if (seen.has(key)) continue;
1931
+ const persistedConfig = await pctx.settings.readDeviceStore(m.id);
1932
+ const metadata = metadataMap[key] ?? null;
1933
+ const sourceInfoChild = buildSourceInfoFromConfig(persistedConfig ?? {}, childStableId, ownerAddonId);
1934
+ results.push({
1935
+ id: m.id,
1936
+ stableId: childStableId,
1937
+ addonId: ownerAddonId,
1938
+ type: m.type,
1939
+ name: m.name,
1940
+ location: m.location ?? null,
1941
+ disabled: m.disabled ?? false,
1942
+ parentDeviceId,
1943
+ role: toDeviceRole(m.role),
1944
+ online: pctx.host.resolveDeviceOnline(m.id, pctx.registry !== null),
1945
+ probed: pctx.host.resolveDeviceProbed(m.id),
1946
+ features: persistedFeatures(m.features),
1947
+ isCamera: false,
1948
+ config: persistedConfig ?? {},
1949
+ metadata,
1950
+ ...m.integrationId !== void 0 ? { integrationId: m.integrationId } : {},
1951
+ ...m.linkDeviceId !== void 0 ? { linkDeviceId: m.linkDeviceId } : {},
1952
+ ...m.primaryChildEntityId !== void 0 ? { primaryChildEntityId: m.primaryChildEntityId } : {},
1953
+ ...m.childLayout !== void 0 ? { childLayout: m.childLayout } : {},
1954
+ ...m.deviceLinks !== void 0 ? { deviceLinks: m.deviceLinks } : {},
1955
+ ...m.display !== void 0 ? { display: m.display } : {},
1956
+ ...sourceInfoChild !== void 0 ? { sourceInfo: sourceInfoChild } : {}
1969
1957
  });
1970
- await pctx.settings.writeAddonStore({ deviceMeta: {
1971
- ...meta,
1972
- [key]: {
1973
- addonId,
1974
- stableId,
1975
- type,
1976
- name: resolvedName,
1977
- userNamed: wasUserNamed,
1978
- location: existingMeta?.location ?? null,
1979
- disabled: existingMeta?.disabled ?? false,
1980
- ...existingMeta?.integrationId !== void 0 ? { integrationId: existingMeta.integrationId } : {},
1981
- ...existingMeta?.linkDeviceId !== void 0 ? { linkDeviceId: existingMeta.linkDeviceId } : {},
1982
- ...existingMeta?.primaryChildEntityId !== void 0 ? { primaryChildEntityId: existingMeta.primaryChildEntityId } : {},
1983
- ...existingMeta?.childLayout !== void 0 ? { childLayout: existingMeta.childLayout } : {},
1984
- ...existingMeta?.deviceLinks !== void 0 ? { deviceLinks: existingMeta.deviceLinks } : {},
1985
- ...existingMeta?.role !== void 0 ? { role: existingMeta.role } : {},
1986
- ...existingMeta?.display !== void 0 ? { display: existingMeta.display } : {},
1987
- parentDeviceId,
1988
- id,
1989
- features: featuresArr,
1990
- exportFingerprint: fingerprint
1991
- }
1992
- } });
1993
- return {
1994
- isFirstRegistration: isFirst,
1995
- exportFingerprint: fingerprint,
1996
- fingerprintChanged: existingMeta?.exportFingerprint !== fingerprint
1997
- };
1998
- });
1999
- if (Object.keys(config).length > 0) await pctx.settings.writeDeviceStore(id, config);
2000
- pctx.metaStore.idToAddonId.set(id, addonId);
2001
- if (isFirstRegistration) pctx.host.ctx.eventBus.emit({
2002
- id: (0, node_crypto.randomUUID)(),
2003
- timestamp: /* @__PURE__ */ new Date(),
2004
- source: {
2005
- type: "device",
2006
- id
2007
- },
2008
- category: require_dist.EventCategory.DeviceRegistered,
2009
- data: {
2010
- deviceId: id,
2011
- name: name.length > 0 ? name : stableId,
2012
- providerId: addonId,
2013
- parentDeviceId: parentDeviceId ?? null
2014
- }
2015
- });
2016
- else pctx.host.ctx.eventBus.emit({
2017
- id: (0, node_crypto.randomUUID)(),
2018
- timestamp: /* @__PURE__ */ new Date(),
2019
- source: {
2020
- type: "device",
2021
- id
2022
- },
2023
- category: require_dist.EventCategory.DeviceMetaChanged,
2024
- data: {
2025
- deviceId: id,
2026
- name: name.length > 0 ? name : stableId,
2027
- providerId: addonId,
2028
- parentDeviceId: parentDeviceId ?? null,
2029
- features: featuresArr
2030
- }
2031
- });
2032
- if (isFirstRegistration || fingerprintChanged) pctx.host.ctx.eventBus.emit({
2033
- id: (0, node_crypto.randomUUID)(),
2034
- timestamp: /* @__PURE__ */ new Date(),
2035
- source: {
2036
- type: "device",
2037
- id
2038
- },
2039
- category: require_dist.EventCategory.DeviceProvisioned,
2040
- data: {
2041
- deviceId: id,
2042
- fingerprint: exportFingerprint,
2043
- generation: Date.now()
1958
+ }
1959
+ return results;
1960
+ }
1961
+ async function getStreamSources(pctx, input) {
1962
+ const { deviceId } = input;
1963
+ if (pctx.registry) {
1964
+ const found = resolveDeviceById(pctx.registry, deviceId);
1965
+ if (found) {
1966
+ if (!isCameraDevice(found.device)) return [];
1967
+ return (await found.device.getStreamSources()).map((s) => ({
1968
+ id: s.id,
1969
+ label: s.label,
1970
+ protocol: s.protocol,
1971
+ url: s.url,
1972
+ resolution: s.resolution,
1973
+ fps: s.fps,
1974
+ bitrate: s.bitrate,
1975
+ codec: s.codec,
1976
+ profileHint: s.profileHint
1977
+ }));
2044
1978
  }
2045
- });
2046
- if (pctx.host.devicesWithLinks.size > 0 && pctx.host.expectedSourceStableIds.has(stableId)) await pctx.metaStore.rebuildLinkDependents();
1979
+ }
1980
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] Device with id ${deviceId} not found`);
1981
+ return (await pctx.requireDeviceOps(deviceId).getStreamSources({ deviceId })).map((s) => ({ ...s }));
2047
1982
  }
2048
- async function removeDevice(pctx, input) {
1983
+ async function getConfigSchema(pctx, input) {
2049
1984
  const { deviceId } = input;
2050
- const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
2051
- if (!persisted) return;
2052
- const { addonId, stableId, meta: persistedMeta } = persisted;
2053
- const key = String(deviceId);
2054
- const deviceName = persistedMeta.name;
2055
- await pctx.metaStore.withMetaWriteLock(async () => {
2056
- const index = await pctx.metaStore.readIndex();
2057
- const remaining = (index[addonId] ?? []).filter((sid) => sid !== stableId);
2058
- const updatedIndex = remaining.length > 0 ? {
2059
- ...index,
2060
- [addonId]: remaining
2061
- } : (() => {
2062
- const { [addonId]: _removed, ...rest } = index;
2063
- return rest;
2064
- })();
2065
- await pctx.settings.writeAddonStore({ deviceIndex: updatedIndex });
2066
- const { [key]: _removedMeta, ...restMeta } = await pctx.metaStore.readMeta();
2067
- await pctx.settings.writeAddonStore({ deviceMeta: restMeta });
2068
- const map = await pctx.metaStore.readMetadataMap();
2069
- if (key in map) {
2070
- const { [key]: _removedMetadata, ...restMap } = map;
2071
- await pctx.settings.writeAddonStore({ deviceMetadata: restMap });
2072
- }
2073
- });
2074
- await pctx.settings.clearDeviceStore(deviceId);
2075
- await pctx.settings.clearDeviceRuntimeState(deviceId);
2076
- const bindingsStore = await readBindingsStore(pctx.bindingsDeps);
2077
- const bindingKey = String(deviceId);
2078
- if (bindingsStore.deviceBindings[bindingKey]) {
2079
- const { [bindingKey]: _removedBindings, ...restBindings } = bindingsStore.deviceBindings;
2080
- await writeBindingsStore(pctx.bindingsDeps, { deviceBindings: restBindings });
2081
- }
2082
- pctx.host.remoteNativeCaps.delete(deviceId);
2083
- pctx.host.capabilityRegistry?.unregisterAllNativeForDevice(deviceId);
2084
- pctx.metaStore.idToAddonId.delete(deviceId);
2085
- pctx.host.devicesWithLinks.delete(deviceId);
2086
- pctx.host.dropDeviceOverlays(deviceId);
2087
- await pctx.metaStore.rebuildLinkDependents();
2088
- pctx.host.ctx.logger.info("removed device", { tags: {
2089
- deviceId,
2090
- deviceName: deviceName.length > 0 ? deviceName : stableId
2091
- } });
2092
- pctx.host.ctx.eventBus.emit({
2093
- id: (0, node_crypto.randomUUID)(),
2094
- timestamp: /* @__PURE__ */ new Date(),
2095
- source: {
2096
- type: "device",
2097
- id: deviceId
2098
- },
2099
- category: require_dist.EventCategory.DeviceUnregistered,
2100
- data: {
2101
- deviceId,
2102
- providerId: addonId,
2103
- parentDeviceId: persistedMeta.parentDeviceId ?? null
2104
- }
2105
- });
2106
- }
2107
- async function persistConfig(pctx, input) {
2108
- const { deviceId, data } = input;
2109
- if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] persistConfig: unknown device id=${deviceId}`);
2110
- await pctx.settings.writeDeviceStore(deviceId, data);
2111
- }
2112
- async function loadConfig(pctx, input) {
2113
- const { deviceId } = input;
2114
- if (!await pctx.metaStore.resolvePersistedById(deviceId)) return {};
2115
- return pctx.settings.readDeviceStore(deviceId);
2116
- }
2117
- /**
2118
- * Load the operator-organisational meta surface for one device
2119
- * (`name` / `location` / `disabled` / `type` / `parentDeviceId`
2120
- * / `addonId` + `id` / `stableId`). Used by the kernel proxy's
2121
- * device-context factory to populate `ctx.deviceMeta` before
2122
- * the device class constructor runs. Returns `null` when no
2123
- * persisted row exists for the id.
2124
- *
2125
- * Reads default `location` to `null` and `disabled` to `false`
2126
- * for legacy rows that predate the field — production code
2127
- * relies on the IDevice type contract that both are present.
2128
- */
2129
- async function loadMeta(pctx, input) {
2130
- const { deviceId } = input;
2131
- const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
2132
- if (!persisted) return null;
2133
- const { addonId, stableId, meta: m } = persisted;
2134
- const key = String(deviceId);
2135
- const metadata = (await pctx.metaStore.readMetadataMap())[key] ?? null;
2136
- return {
2137
- id: m.id,
2138
- stableId,
2139
- addonId,
2140
- type: m.type,
2141
- name: m.name,
2142
- location: m.location ?? null,
2143
- disabled: m.disabled ?? false,
2144
- parentDeviceId: m.parentDeviceId,
2145
- metadata,
2146
- ...m.integrationId !== void 0 ? { integrationId: m.integrationId } : {},
2147
- ...m.linkDeviceId !== void 0 ? { linkDeviceId: m.linkDeviceId } : {},
2148
- ...m.role !== void 0 ? { role: m.role } : {}
2149
- };
2150
- }
2151
- /**
2152
- * Update the operator-edited display name. Writes the meta
2153
- * row, emits a `DeviceMetaChanged` event so live consumers
2154
- * (UI device list, alert center) see the rename without
2155
- * polling. The live `IDevice.name` mirror is updated by the
2156
- * kernel proxy on its side (`device-cap-proxy.ts`).
2157
- */
2158
- async function setName(pctx, input) {
2159
- const { deviceId, name } = input;
2160
- const cascaded = [];
2161
- await pctx.metaStore.withMetaWriteLock(async () => {
2162
- const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
2163
- if (!persisted) throw new Error(`[device-manager] setName: unknown device id=${deviceId}`);
2164
- const { meta: m } = persisted;
2165
- const key = String(deviceId);
2166
- const oldName = m.name;
2167
- const allMeta = await pctx.metaStore.readMeta();
2168
- const nextMeta = {
2169
- ...allMeta,
2170
- [key]: {
2171
- ...m,
2172
- name,
2173
- userNamed: true
2174
- }
2175
- };
2176
- if (oldName.length > 0 && oldName !== name) for (const [childKey, childMeta] of Object.entries(allMeta)) {
2177
- if (childKey === key) continue;
2178
- if (!(childMeta.parentDeviceId === deviceId || childMeta.linkDeviceId === deviceId)) continue;
2179
- if (childMeta.name !== oldName && !childMeta.name.startsWith(`${oldName} `)) continue;
2180
- const childName = childMeta.name === oldName ? name : `${name}${childMeta.name.slice(oldName.length)}`;
2181
- nextMeta[childKey] = {
2182
- ...childMeta,
2183
- name: childName
2184
- };
2185
- cascaded.push({
2186
- id: Number(childKey),
2187
- name: childName
2188
- });
2189
- }
2190
- await pctx.settings.writeAddonStore({ deviceMeta: nextMeta });
2191
- });
2192
- pctx.host.ctx.eventBus.emit({
2193
- id: (0, node_crypto.randomUUID)(),
2194
- timestamp: /* @__PURE__ */ new Date(),
2195
- source: {
2196
- type: "device",
2197
- id: deviceId
2198
- },
2199
- category: require_dist.EventCategory.DeviceMetaChanged,
2200
- data: {
2201
- deviceId,
2202
- field: "name",
2203
- value: name
2204
- }
2205
- });
2206
- for (const child of cascaded) pctx.host.ctx.eventBus.emit({
2207
- id: (0, node_crypto.randomUUID)(),
2208
- timestamp: /* @__PURE__ */ new Date(),
2209
- source: {
2210
- type: "device",
2211
- id: child.id
2212
- },
2213
- category: require_dist.EventCategory.DeviceMetaChanged,
2214
- data: {
2215
- deviceId: child.id,
2216
- field: "name",
2217
- value: child.name
2218
- }
2219
- });
2220
- }
2221
- /**
2222
- * Update the operator-organisational location label. `null`
2223
- * clears it. Mirrors the same persist-then-emit shape as
2224
- * `setName`; consumers subscribe to `DeviceMetaChanged` and
2225
- * filter on `field: 'location'`.
2226
- */
2227
- async function setLocation(pctx, input) {
2228
- const { deviceId, location } = input;
2229
- await pctx.metaStore.withMetaWriteLock(async () => {
2230
- const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
2231
- if (!persisted) throw new Error(`[device-manager] setLocation: unknown device id=${deviceId}`);
2232
- const { meta: m } = persisted;
2233
- const key = String(deviceId);
2234
- const allMeta = await pctx.metaStore.readMeta();
2235
- await pctx.settings.writeAddonStore({ deviceMeta: {
2236
- ...allMeta,
2237
- [key]: {
2238
- ...m,
2239
- location
2240
- }
2241
- } });
2242
- });
2243
- pctx.host.ctx.eventBus.emit({
2244
- id: (0, node_crypto.randomUUID)(),
2245
- timestamp: /* @__PURE__ */ new Date(),
2246
- source: {
2247
- type: "device",
2248
- id: deviceId
2249
- },
2250
- category: require_dist.EventCategory.DeviceMetaChanged,
2251
- data: {
2252
- deviceId,
2253
- field: "location",
2254
- value: location
2255
- }
2256
- });
2257
- }
2258
- /**
2259
- * Update the device type. Writes the meta row, emits a
2260
- * `DeviceMetaChanged` event. Used by the kernel to apply
2261
- * `initialMeta.type` before device construction so the device
2262
- * constructs with its real type instead of the `allocateDeviceId`
2263
- * placeholder `'generic'`.
2264
- */
2265
- async function setType(pctx, input) {
2266
- const { deviceId, type } = input;
2267
- await pctx.metaStore.withMetaWriteLock(async () => {
2268
- const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
2269
- if (!persisted) throw new Error(`[device-manager] setType: unknown device id=${deviceId}`);
2270
- const { meta: m } = persisted;
2271
- const key = String(deviceId);
2272
- const allMeta = await pctx.metaStore.readMeta();
2273
- await pctx.settings.writeAddonStore({ deviceMeta: {
2274
- ...allMeta,
2275
- [key]: {
2276
- ...m,
2277
- type
2278
- }
2279
- } });
2280
- });
2281
- pctx.host.ctx.eventBus.emit({
2282
- id: (0, node_crypto.randomUUID)(),
2283
- timestamp: /* @__PURE__ */ new Date(),
2284
- source: {
2285
- type: "device",
2286
- id: deviceId
2287
- },
2288
- category: require_dist.EventCategory.DeviceMetaChanged,
2289
- data: {
2290
- deviceId,
2291
- field: "type",
2292
- value: type
2293
- }
2294
- });
2295
- }
2296
- /**
2297
- * Stamp (or update) the owning integration id on the device's
2298
- * meta row. Called by the kernel's `create()` pre-seed path
2299
- * when `initialMeta.integrationId` is set (analogous to
2300
- * `setName` / `setType`). Idempotent.
2301
- */
2302
- async function setIntegrationId(pctx, input) {
2303
- await pctx.stampIntegrationId(input.deviceId, input.integrationId);
2304
- }
2305
- /**
2306
- * Stamp (or update) the linked device id on the device's meta
2307
- * row. Called by the kernel's `create()` pre-seed path when
2308
- * `initialMeta.linkDeviceId` is set (analogous to `setIntegrationId`).
2309
- * Idempotent. `null` explicitly clears a previous link.
2310
- */
2311
- async function setLinkDeviceId(pctx, input) {
2312
- const { deviceId, linkDeviceId } = input;
2313
- await pctx.metaStore.withMetaWriteLock(async () => {
2314
- const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
2315
- if (!persisted) throw new Error(`[device-manager] setLinkDeviceId: unknown device id=${deviceId}`);
2316
- const { meta: m } = persisted;
2317
- const key = String(deviceId);
2318
- const allMeta = await pctx.metaStore.readMeta();
2319
- await pctx.settings.writeAddonStore({ deviceMeta: {
2320
- ...allMeta,
2321
- [key]: {
2322
- ...m,
2323
- linkDeviceId
2324
- }
2325
- } });
2326
- });
2327
- pctx.host.ctx.eventBus.emit({
2328
- id: (0, node_crypto.randomUUID)(),
2329
- timestamp: /* @__PURE__ */ new Date(),
2330
- source: {
2331
- type: "device",
2332
- id: deviceId
2333
- },
2334
- category: require_dist.EventCategory.DeviceMetaChanged,
2335
- data: {
2336
- deviceId,
2337
- field: "linkDeviceId",
2338
- value: linkDeviceId
2339
- }
2340
- });
2341
- }
2342
- /**
2343
- * Set (or clear) the durable primary-child override on a CONTAINER
2344
- * device's meta row, keyed on the chosen child's re-sync/rename-stable
2345
- * `entityId`. Mirrors `setLinkDeviceId` but persists a string entityId
2346
- * (survives a re-sync that reallocates the child's numeric id) instead
2347
- * of the numeric child id. `null` clears the override → priority default.
2348
- */
2349
- async function setPrimaryChildEntityId(pctx, input) {
2350
- const { deviceId, primaryChildEntityId } = input;
2351
- await pctx.metaStore.withMetaWriteLock(async () => {
2352
- const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
2353
- if (!persisted) throw new Error(`[device-manager] setPrimaryChildEntityId: unknown device id=${deviceId}`);
2354
- const { meta: m } = persisted;
2355
- const key = String(deviceId);
2356
- const allMeta = await pctx.metaStore.readMeta();
2357
- await pctx.settings.writeAddonStore({ deviceMeta: {
2358
- ...allMeta,
2359
- [key]: {
2360
- ...m,
2361
- primaryChildEntityId
2362
- }
2363
- } });
2364
- });
2365
- pctx.host.ctx.eventBus.emit({
2366
- id: (0, node_crypto.randomUUID)(),
2367
- timestamp: /* @__PURE__ */ new Date(),
2368
- source: {
2369
- type: "device",
2370
- id: deviceId
2371
- },
2372
- category: require_dist.EventCategory.DeviceMetaChanged,
2373
- data: {
2374
- deviceId,
2375
- field: "primaryChildEntityId",
2376
- value: primaryChildEntityId
2377
- }
2378
- });
2379
- }
2380
- /**
2381
- * Set (or replace) the container-level `childLayout` on a device's
2382
- * meta row. Mirrors `setPrimaryChildEntityId` but persists the
2383
- * accordion-section layout array (parent-only). Persisted, projected,
2384
- * and preserved across re-register/restore.
2385
- */
2386
- async function setChildLayout(pctx, input) {
2387
- const { deviceId, childLayout } = input;
2388
- await pctx.metaStore.withMetaWriteLock(async () => {
2389
- const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
2390
- if (!persisted) throw new Error(`[device-manager] setChildLayout: unknown device id=${deviceId}`);
2391
- const { meta: m } = persisted;
2392
- const key = String(deviceId);
2393
- const allMeta = await pctx.metaStore.readMeta();
2394
- await pctx.settings.writeAddonStore({ deviceMeta: {
2395
- ...allMeta,
2396
- [key]: {
2397
- ...m,
2398
- childLayout
2399
- }
2400
- } });
2401
- });
2402
- pctx.host.ctx.eventBus.emit({
2403
- id: (0, node_crypto.randomUUID)(),
2404
- timestamp: /* @__PURE__ */ new Date(),
2405
- source: {
2406
- type: "device",
2407
- id: deviceId
2408
- },
2409
- category: require_dist.EventCategory.DeviceMetaChanged,
2410
- data: {
2411
- deviceId,
2412
- field: "childLayout",
2413
- value: childLayout
2414
- }
2415
- });
2416
- }
2417
- /**
2418
- * Set (or replace) the operator-authored cross-device field wirings on
2419
- * a device's meta row. Mirrors `setChildLayout` but persists the
2420
- * `deviceLinks` array. Persisted, projected, and preserved across
2421
- * re-register/restore. Also maintains the in-memory `devicesWithLinks`
2422
- * Set used as an O(1) gate by `resolveLinkedStatus`.
2423
- */
2424
- async function setDeviceLinks(pctx, input) {
2425
- const { deviceId, deviceLinks } = input;
2426
- const cycle = findDeviceLinkCycle(await pctx.metaStore.readMeta(), deviceId, deviceLinks);
2427
- if (cycle) throw new Error(`[device-manager] setDeviceLinks: cross-device link cycle: ${cycle.join(" → ")}`);
2428
- for (const link of deviceLinks) {
2429
- if (link.source.kind !== "expression") continue;
2430
- const err = require_dist.validateExpressionSource(link.source);
2431
- if (err) throw new Error(`[device-manager] setDeviceLinks: invalid expression on link '${link.id}': ${err}`);
2432
- }
2433
- await pctx.metaStore.withMetaWriteLock(async () => {
2434
- const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
2435
- if (!persisted) throw new Error(`[device-manager] setDeviceLinks: unknown device id=${deviceId}`);
2436
- const { meta: m } = persisted;
2437
- const key = String(deviceId);
2438
- const allMeta = await pctx.metaStore.readMeta();
2439
- await pctx.settings.writeAddonStore({ deviceMeta: {
2440
- ...allMeta,
2441
- [key]: {
2442
- ...m,
2443
- deviceLinks
2444
- }
2445
- } });
2446
- });
2447
- if (deviceLinks.length > 0) pctx.host.devicesWithLinks.add(deviceId);
2448
- else pctx.host.devicesWithLinks.delete(deviceId);
2449
- await pctx.metaStore.rebuildLinkDependents();
2450
- pctx.host.ctx.eventBus.emit({
2451
- id: (0, node_crypto.randomUUID)(),
2452
- timestamp: /* @__PURE__ */ new Date(),
2453
- source: {
2454
- type: "device",
2455
- id: deviceId
2456
- },
2457
- category: require_dist.EventCategory.DeviceMetaChanged,
2458
- data: {
2459
- deviceId,
2460
- field: "deviceLinks",
2461
- value: deviceLinks
2462
- }
2463
- });
2464
- }
2465
- /**
2466
- * Stamp (or update) the semantic role on the device's meta row.
2467
- * Called by the kernel's `create()` / `spawnAccessoryChild`
2468
- * pre-seed when `initialMeta.role` is set (analogous to
2469
- * `setIntegrationId`). Idempotent. `null` clears a previous role.
2470
- */
2471
- async function setRole(pctx, input) {
2472
- const { deviceId, role } = input;
2473
- await pctx.metaStore.withMetaWriteLock(async () => {
2474
- const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
2475
- if (!persisted) throw new Error(`[device-manager] setRole: unknown device id=${deviceId}`);
2476
- const { meta: m } = persisted;
2477
- const key = String(deviceId);
2478
- const allMeta = await pctx.metaStore.readMeta();
2479
- await pctx.settings.writeAddonStore({ deviceMeta: {
2480
- ...allMeta,
2481
- [key]: {
2482
- ...m,
2483
- role
2484
- }
2485
- } });
2486
- });
2487
- pctx.host.ctx.eventBus.emit({
2488
- id: (0, node_crypto.randomUUID)(),
2489
- timestamp: /* @__PURE__ */ new Date(),
2490
- source: {
2491
- type: "device",
2492
- id: deviceId
2493
- },
2494
- category: require_dist.EventCategory.DeviceMetaChanged,
2495
- data: {
2496
- deviceId,
2497
- field: "role",
2498
- value: role
2499
- }
2500
- });
2501
- }
2502
- /**
2503
- * Normalize an operator-authored display override at write time so the render
2504
- * path's `UNIT_TABLE` lookups always hit canonical spellings — maps `unit` and
2505
- * every `perCap[*].unit` through `normalizeUnit`. Pure: rebuilds new objects,
2506
- * never mutates the input. A unit `normalizeUnit` cannot canonicalize is left
2507
- * as-is (the render path refuses conversion for unknown spellings).
2508
- */
2509
- function normalizeDisplayOverride(display) {
2510
- return {
2511
- ...display,
2512
- ...display.unit !== void 0 ? { unit: require_dist.normalizeUnit(display.unit) ?? display.unit } : {},
2513
- ...display.perCap !== void 0 ? { perCap: Object.fromEntries(Object.entries(display.perCap).map(([cap, refine]) => [cap, refine.unit !== void 0 ? {
2514
- ...refine,
2515
- unit: require_dist.normalizeUnit(refine.unit) ?? refine.unit
2516
- } : refine])) } : {}
2517
- };
2518
- }
2519
- /**
2520
- * Set (or clear) the per-device display override on a device's meta row.
2521
- * Mirrors `setChildLayout` persistence; `null` REMOVES the `display` key
2522
- * entirely (immutable rest-destructure — matches the "absent ⇒ no override"
2523
- * projection contract, never persists `display: undefined`). Override units are
2524
- * normalized at write so the render path always looks up canonical spellings.
2525
- */
2526
- async function setDisplay(pctx, input) {
2527
- const { deviceId, display } = input;
2528
- const normalized = display === null ? null : normalizeDisplayOverride(display);
2529
- await pctx.metaStore.withMetaWriteLock(async () => {
2530
- const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
2531
- if (!persisted) throw new Error(`[device-manager] setDisplay: unknown device id=${deviceId}`);
2532
- const { meta: m } = persisted;
2533
- const key = String(deviceId);
2534
- const allMeta = await pctx.metaStore.readMeta();
2535
- const nextRow = normalized === null ? (({ display: _drop, ...rest }) => rest)(m) : {
2536
- ...m,
2537
- display: normalized
2538
- };
2539
- await pctx.settings.writeAddonStore({ deviceMeta: {
2540
- ...allMeta,
2541
- [key]: nextRow
2542
- } });
2543
- });
2544
- pctx.host.ctx.eventBus.emit({
2545
- id: (0, node_crypto.randomUUID)(),
2546
- timestamp: /* @__PURE__ */ new Date(),
2547
- source: {
2548
- type: "device",
2549
- id: deviceId
2550
- },
2551
- category: require_dist.EventCategory.DeviceMetaChanged,
2552
- data: {
2553
- deviceId,
2554
- field: "display",
2555
- value: normalized
2556
- }
2557
- });
2558
- }
2559
- /**
2560
- * Read the operator-authored per-role display defaults. Empty record when none
2561
- * set. Not per-device — a plain top-level-key read.
2562
- */
2563
- async function getRoleDisplayDefaults(pctx, _input) {
2564
- return { defaults: (await pctx.metaStore.readStore()).roleDisplayDefaults ?? {} };
2565
- }
2566
- /**
2567
- * Replace the per-role display defaults whole-record (full replace). Override
2568
- * units are normalized (`normalizeUnit`) at write so the render path always
2569
- * looks up canonical spellings. Single-writer top-level key — no lock, no RMW,
2570
- * no interaction with the `deviceMeta` write lock. Not per-device, so no event
2571
- * is emitted; the UI invalidates its own query on mutate.
2572
- */
2573
- async function setRoleDisplayDefaults(pctx, input) {
2574
- const normalized = Object.fromEntries(Object.entries(input.defaults).map(([role, def]) => [role, def.unit !== void 0 ? {
2575
- ...def,
2576
- unit: require_dist.normalizeUnit(def.unit) ?? def.unit
2577
- } : def]));
2578
- await pctx.settings.writeAddonStore({ roleDisplayDefaults: normalized });
2579
- }
2580
- /**
2581
- * Batched meta pre-seed. Applies every provided field to the
2582
- * device's meta row in ONE read-modify-write under a single
2583
- * `withMetaWriteLock` acquisition (one `deviceMeta` blob write),
2584
- * then emits one `DeviceMetaChanged` event per field that was
2585
- * supplied — preserving the exact semantics of the individual
2586
- * setters (`setName` / `setLocation` / `setType` /
2587
- * `setIntegrationId` / `setLinkDeviceId` / `setRole`). Omitted
2588
- * fields are left untouched; `null` clears `location` /
2589
- * `linkDeviceId` / `role`. Idempotent.
2590
- *
2591
- * Collapses the per-child meta pre-seed (up to 6 individual
2592
- * setter round-trips, each its own lock + write) into one — the
2593
- * dominant cost when the kernel's `spawnAccessoryChild` reconciles
2594
- * a many-entity container.
2595
- */
2596
- async function applyInitialMeta(pctx, input) {
2597
- const { deviceId, name, location, type, integrationId, linkDeviceId, role } = input;
2598
- await pctx.metaStore.withMetaWriteLock(async () => {
2599
- const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
2600
- if (!persisted) throw new Error(`[device-manager] applyInitialMeta: unknown device id=${deviceId}`);
2601
- const { meta: m } = persisted;
2602
- const key = String(deviceId);
2603
- const allMeta = await pctx.metaStore.readMeta();
2604
- const merged = {
2605
- ...m,
2606
- ...name !== void 0 ? { name } : {},
2607
- ...location !== void 0 ? { location } : {},
2608
- ...type !== void 0 ? { type } : {},
2609
- ...integrationId !== void 0 ? { integrationId } : {},
2610
- ...linkDeviceId !== void 0 ? { linkDeviceId } : {},
2611
- ...role !== void 0 ? { role } : {}
2612
- };
2613
- await pctx.settings.writeAddonStore({ deviceMeta: {
2614
- ...allMeta,
2615
- [key]: merged
2616
- } });
2617
- });
2618
- const emitMetaChanged = (field, value) => {
2619
- pctx.host.ctx.eventBus.emit({
2620
- id: (0, node_crypto.randomUUID)(),
2621
- timestamp: /* @__PURE__ */ new Date(),
2622
- source: {
2623
- type: "device",
2624
- id: deviceId
2625
- },
2626
- category: require_dist.EventCategory.DeviceMetaChanged,
2627
- data: {
2628
- deviceId,
2629
- field,
2630
- value
2631
- }
2632
- });
2633
- };
2634
- if (name !== void 0) emitMetaChanged("name", name);
2635
- if (location !== void 0) emitMetaChanged("location", location);
2636
- if (type !== void 0) emitMetaChanged("type", type);
2637
- if (integrationId !== void 0) emitMetaChanged("integrationId", integrationId);
2638
- if (linkDeviceId !== void 0) emitMetaChanged("linkDeviceId", linkDeviceId);
2639
- if (role !== void 0) emitMetaChanged("role", role);
2640
- }
2641
- /**
2642
- * Patch the device's hardware-identity metadata blob. Shallow
2643
- * merge — `null` removes a key, anything else overwrites.
2644
- * Drivers populate factual fields on first probe; operators
2645
- * augment via the Device Info tab. Idempotent: a no-op patch
2646
- * (every key already present with the same value) doesn't emit
2647
- * the meta-changed event.
2648
- */
2649
- async function setMetadata(pctx, input) {
2650
- const { deviceId, patch } = input;
2651
- const result = await pctx.metaStore.withMetaWriteLock(async () => {
2652
- if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] setMetadata: unknown device id=${deviceId}`);
2653
- const key = String(deviceId);
2654
- const map = await pctx.metaStore.readMetadataMap();
2655
- const next = { ...map[key] ?? {} };
2656
- let changed = false;
2657
- for (const [k, v] of Object.entries(patch)) if (v === null) {
2658
- if (k in next) {
2659
- delete next[k];
2660
- changed = true;
2661
- }
2662
- } else if (next[k] !== v) {
2663
- next[k] = v;
2664
- changed = true;
2665
- }
2666
- if (!changed) return { changed: false };
2667
- const hasFields = Object.keys(next).length > 0;
2668
- const updatedMap = { ...map };
2669
- if (hasFields) updatedMap[key] = next;
2670
- else delete updatedMap[key];
2671
- await pctx.settings.writeAddonStore({ deviceMetadata: updatedMap });
2672
- return {
2673
- changed: true,
2674
- finalMeta: hasFields ? next : null
2675
- };
2676
- });
2677
- if (!result.changed) return;
2678
- pctx.host.ctx.eventBus.emit({
2679
- id: (0, node_crypto.randomUUID)(),
2680
- timestamp: /* @__PURE__ */ new Date(),
2681
- source: {
2682
- type: "device",
2683
- id: deviceId
2684
- },
2685
- category: require_dist.EventCategory.DeviceMetaChanged,
2686
- data: {
2687
- deviceId,
2688
- field: "metadata",
2689
- value: result.finalMeta
1985
+ if (pctx.registry) {
1986
+ const found = resolveDeviceById(pctx.registry, deviceId);
1987
+ if (found) return found.device.config.entries().map((entry) => ({
1988
+ key: entry.key,
1989
+ value: entry.value,
1990
+ ...entry.description !== void 0 ? { description: entry.description } : {}
1991
+ }));
1992
+ }
1993
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] Device with id ${deviceId} not found`);
1994
+ return (await pctx.requireDeviceOps(deviceId).getConfigEntries({ deviceId })).map((e) => ({ ...e }));
1995
+ }
1996
+ async function getSettingsSchema(pctx, input) {
1997
+ const { deviceId } = input;
1998
+ if (pctx.registry) {
1999
+ const found = resolveDeviceById(pctx.registry, deviceId);
2000
+ if (found) return found.device.getSettingsUISchema();
2001
+ }
2002
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) return null;
2003
+ return await pctx.requireDeviceOps(deviceId).getSettingsSchema({ deviceId }) ?? null;
2004
+ }
2005
+ async function updateConfig(pctx, input) {
2006
+ const { deviceId } = input;
2007
+ if (pctx.registry) {
2008
+ const found = resolveDeviceById(pctx.registry, deviceId);
2009
+ if (found) {
2010
+ await found.device.config.setAll(input.values);
2011
+ return { success: true };
2690
2012
  }
2013
+ }
2014
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] Device with id ${deviceId} not found`);
2015
+ await pctx.requireDeviceOps(deviceId).setConfig({
2016
+ deviceId,
2017
+ values: input.values
2691
2018
  });
2019
+ return { success: true };
2692
2020
  }
2693
- /**
2694
- * Soft-disable the device. Persisted on the meta row;
2695
- * lifecycle gating is the driver's responsibility (BaseDevice
2696
- * exposes `this.disabled` for the driver to consult at the top
2697
- * of its lifecycle methods).
2698
- */
2699
- async function setDisabled(pctx, input) {
2700
- const { deviceId, disabled } = input;
2701
- await pctx.metaStore.withMetaWriteLock(async () => {
2702
- const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
2703
- if (!persisted) throw new Error(`[device-manager] setDisabled: unknown device id=${deviceId}`);
2704
- const { meta: m } = persisted;
2705
- const key = String(deviceId);
2706
- const allMeta = await pctx.metaStore.readMeta();
2707
- await pctx.settings.writeAddonStore({ deviceMeta: {
2708
- ...allMeta,
2709
- [key]: {
2710
- ...m,
2711
- disabled
2712
- }
2713
- } });
2021
+ async function enable(pctx, input) {
2022
+ await pctx.provider.setDisabled({
2023
+ deviceId: input.deviceId,
2024
+ disabled: false
2714
2025
  });
2715
- pctx.host.ctx.eventBus.emit({
2716
- id: (0, node_crypto.randomUUID)(),
2717
- timestamp: /* @__PURE__ */ new Date(),
2718
- source: {
2719
- type: "device",
2720
- id: deviceId
2721
- },
2722
- category: require_dist.EventCategory.DeviceMetaChanged,
2723
- data: {
2724
- deviceId,
2725
- field: "disabled",
2726
- value: disabled
2727
- }
2026
+ return { success: true };
2027
+ }
2028
+ async function disable(pctx, input) {
2029
+ await pctx.provider.setDisabled({
2030
+ deviceId: input.deviceId,
2031
+ disabled: true
2728
2032
  });
2033
+ return { success: true };
2729
2034
  }
2730
- async function loadRuntimeState(pctx, input) {
2035
+ async function remove(pctx, input) {
2731
2036
  const { deviceId } = input;
2732
- if (!await pctx.metaStore.resolvePersistedById(deviceId)) return {};
2733
- const data = await pctx.settings.readDeviceRuntimeState(deviceId);
2734
- pctx.host.seedMirror(deviceId, pctx.host.withResetSessionProbe(data));
2735
- return data;
2736
- }
2737
- /**
2738
- * Union of (1) operator-curated location registry and (2) labels
2739
- * currently in use on persisted devices. Case-insensitive
2740
- * dedupe (preserves the first-seen casing). Sorted
2741
- * case-insensitively for stable UI. Drives the Device Info
2742
- * location autocomplete.
2743
- */
2744
- async function listLocations(pctx) {
2745
- const store = await pctx.settings.readAddonStore();
2746
- const meta = store.deviceMeta ?? {};
2747
- const locations = store.locations ?? [];
2748
- const seen = /* @__PURE__ */ new Map();
2749
- const consider = (raw) => {
2750
- if (typeof raw !== "string") return;
2751
- const trimmed = raw.trim();
2752
- if (trimmed.length === 0) return;
2753
- const key = trimmed.toLowerCase();
2754
- if (!seen.has(key)) seen.set(key, trimmed);
2037
+ const removeOne = async (id) => {
2038
+ if (pctx.registry) {
2039
+ const live = resolveDeviceById(pctx.registry, id);
2040
+ if (live) {
2041
+ const deviceName = live.device.name;
2042
+ await live.device.removeDevice();
2043
+ pctx.registry.remove(id);
2044
+ await pctx.provider.removeDevice({ deviceId: id });
2045
+ pctx.host.ctx.logger.info("removed hub-local device", { tags: {
2046
+ deviceId: id,
2047
+ deviceName
2048
+ } });
2049
+ return;
2050
+ }
2051
+ }
2052
+ const persisted = await pctx.metaStore.resolvePersistedById(id);
2053
+ if (!persisted) return;
2054
+ const { meta: persistedMeta } = persisted;
2055
+ try {
2056
+ await pctx.requireDeviceOps(id).removeDevice({ deviceId: id });
2057
+ } catch (err) {
2058
+ pctx.host.ctx.logger.warn("remove via device-ops failed — clearing persistence anyway", {
2059
+ tags: {
2060
+ deviceId: id,
2061
+ deviceName: persistedMeta.name
2062
+ },
2063
+ meta: { error: require_dist.errMsg(err) }
2064
+ });
2065
+ }
2066
+ await pctx.provider.removeDevice({ deviceId: id });
2755
2067
  };
2756
- for (const label of locations) consider(label);
2757
- for (const m of Object.values(meta)) consider(m.location);
2758
- return [...seen.values()].toSorted((a, b) => a.localeCompare(b, void 0, { sensitivity: "base" }));
2759
- }
2760
- /**
2761
- * Add a label to the curated location registry. Idempotent:
2762
- * existing entries (case-insensitive match) are silently kept.
2763
- * Empty / whitespace-only inputs throw — operators must supply a
2764
- * meaningful label.
2765
- */
2766
- async function addLocation(pctx, input) {
2767
- const trimmed = input.name.trim();
2768
- if (trimmed.length === 0) throw new Error("[device-manager] addLocation: name must be non-empty");
2769
- const current = (await pctx.settings.readAddonStore()).locations ?? [];
2770
- if (current.some((l) => l.toLowerCase() === trimmed.toLowerCase())) return;
2771
- await pctx.settings.writeAddonStore({ locations: [...current, trimmed] });
2068
+ const removeCascade = async (id) => {
2069
+ for (const childId of await pctx.metaStore.directChildIds(id)) await removeCascade(childId);
2070
+ await removeOne(id);
2071
+ };
2072
+ await removeCascade(deviceId);
2073
+ return { success: true };
2772
2074
  }
2773
2075
  /**
2774
- * Remove a label from the curated registry. Match is
2775
- * case-insensitive. Devices that still reference this label keep
2776
- * their `meta.location` value (the registry is a suggestion
2777
- * list, not a foreign key) pass `cascade: true` to also clear
2778
- * `setLocation` on every device that referenced this exact
2779
- * label. Cascade only matches case-insensitively + trimmed, same
2780
- * as the registry equality check.
2076
+ * Cascade-delete every top-level device whose `integrationId`
2077
+ * matches. Enumerates a SNAPSHOT of the meta map so concurrent
2078
+ * removals don't clobber each other. Only top-level parents are
2079
+ * enumerated children cascade via the per-parent `removeCascade`
2080
+ * inside the delegated `remove` call. Idempotent: devices with no
2081
+ * `integrationId` never match.
2781
2082
  */
2782
- async function removeLocation(pctx, input) {
2783
- const trimmed = input.name.trim();
2784
- if (trimmed.length === 0) return;
2785
- const store = await pctx.settings.readAddonStore();
2786
- const current = store.locations ?? [];
2787
- const remaining = current.filter((l) => l.toLowerCase() !== trimmed.toLowerCase());
2788
- if (remaining.length !== current.length) await pctx.settings.writeAddonStore({ locations: remaining });
2789
- if (input.cascade !== true) return;
2790
- const meta = store.deviceMeta ?? {};
2791
- const updates = { ...meta };
2792
- const cleared = [];
2793
- for (const [key, m] of Object.entries(meta)) {
2794
- if (typeof m.location !== "string") continue;
2795
- if (m.location.trim().toLowerCase() !== trimmed.toLowerCase()) continue;
2796
- updates[key] = {
2797
- ...m,
2798
- location: null
2799
- };
2800
- cleared.push(m.id);
2801
- }
2802
- if (cleared.length === 0) return;
2803
- await pctx.settings.writeAddonStore({ deviceMeta: updates });
2804
- for (const deviceId of cleared) pctx.host.ctx.eventBus.emit({
2805
- id: (0, node_crypto.randomUUID)(),
2806
- timestamp: /* @__PURE__ */ new Date(),
2807
- source: {
2808
- type: "device",
2809
- id: deviceId
2810
- },
2811
- category: require_dist.EventCategory.DeviceMetaChanged,
2812
- data: {
2813
- deviceId,
2814
- field: "location",
2815
- value: null
2816
- }
2083
+ async function removeByIntegration(pctx, input) {
2084
+ const { integrationId } = input;
2085
+ const meta = await pctx.metaStore.readMeta();
2086
+ const parentKeys = Object.keys(meta).filter((key) => {
2087
+ const m = meta[key];
2088
+ return m !== void 0 && m.integrationId === integrationId && m.parentDeviceId === null;
2817
2089
  });
2090
+ let removed = 0;
2091
+ for (const _key of parentKeys) {
2092
+ const m = meta[_key];
2093
+ if (!m) continue;
2094
+ await pctx.provider.remove({ deviceId: m.id });
2095
+ removed++;
2096
+ }
2097
+ return { removed };
2818
2098
  }
2819
- //#endregion
2820
- //#region src/builtins/device-manager/device-link-overlay.ts
2821
- /** Build both lookup maps from resolved link entries. Pure — order-preserving. */
2822
- function buildLinkIndexes(entries) {
2823
- const targets = /* @__PURE__ */ new Map();
2824
- const dependents = /* @__PURE__ */ new Map();
2825
- for (const { targetDeviceId, link, sourceDeviceId, bindingSourceIds } of entries) {
2826
- const tKey = `${targetDeviceId}:${link.target.cap}`;
2827
- const tList = targets.get(tKey) ?? [];
2828
- tList.push({
2829
- link,
2830
- sourceDeviceId,
2831
- ...bindingSourceIds !== void 0 ? { bindingSourceIds } : {}
2832
- });
2833
- targets.set(tKey, tList);
2834
- const addDependent = (sourceId, sourceCap) => {
2835
- const sKey = `${sourceId}:${sourceCap}`;
2836
- const sList = dependents.get(sKey) ?? [];
2837
- sList.push({
2838
- targetDeviceId,
2839
- targetCap: link.target.cap
2099
+ async function getStreamProfileMap(pctx, input) {
2100
+ if (!pctx.registry) return {};
2101
+ const found = resolveDeviceById(pctx.registry, input.deviceId);
2102
+ if (!found) return {};
2103
+ const storedMap = found.device.config.entries().find((e) => e.key === "_profileMap")?.value;
2104
+ if (storedMap !== void 0 && typeof storedMap === "object" && storedMap !== null) return storedMap;
2105
+ if (!isCameraDevice(found.device)) return {};
2106
+ const sources = await found.device.getStreamSources();
2107
+ const profileMap = {};
2108
+ for (const s of sources) if (s.profileHint && s.id) profileMap[s.profileHint] = s.id;
2109
+ return profileMap;
2110
+ }
2111
+ async function setStreamProfileMap(pctx, input) {
2112
+ const { deviceId } = input;
2113
+ if (pctx.registry) {
2114
+ const found = resolveDeviceById(pctx.registry, deviceId);
2115
+ if (found) {
2116
+ await found.device.config.setAll({ _profileMap: input.profileMap });
2117
+ return { success: true };
2118
+ }
2119
+ }
2120
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] Device with id ${deviceId} not found`);
2121
+ await pctx.requireDeviceOps(deviceId).setConfig({
2122
+ deviceId,
2123
+ values: { _profileMap: input.profileMap }
2124
+ });
2125
+ return { success: true };
2126
+ }
2127
+ async function probeStreams(pctx, input) {
2128
+ const streamProbe = pctx.host.ctx.kernel.streamProbe;
2129
+ if (!streamProbe) return [];
2130
+ const sources = await pctx.provider.getStreamSources({ deviceId: input.deviceId });
2131
+ const results = [];
2132
+ for (const s of sources) {
2133
+ if (!s.url) continue;
2134
+ try {
2135
+ const metadata = await streamProbe.probe(s.url, { force: true });
2136
+ results.push({
2137
+ streamId: s.id,
2138
+ width: metadata.width,
2139
+ height: metadata.height,
2140
+ codec: metadata.codec,
2141
+ fps: metadata.fps,
2142
+ bitrateKbps: metadata.bitrateKbps
2840
2143
  });
2841
- dependents.set(sKey, sList);
2842
- };
2843
- if (link.source.kind === "expression") {
2844
- if (bindingSourceIds !== void 0) for (const [name, bindingDeviceId] of Object.entries(bindingSourceIds)) {
2845
- const binding = link.source.bindings[name];
2846
- if (binding === void 0 || binding.kind === "literal") continue;
2847
- addDependent(bindingDeviceId, binding.cap);
2848
- }
2849
- continue;
2144
+ } catch (err) {
2145
+ pctx.host.ctx.logger.debug("streamProbe.probe failed — returning placeholder", { meta: {
2146
+ deviceId: input.deviceId,
2147
+ streamId: s.id,
2148
+ error: err instanceof Error ? err.message : String(err)
2149
+ } });
2150
+ results.push({ streamId: s.id });
2850
2151
  }
2851
- if (link.source.kind !== "literal") addDependent(sourceDeviceId, link.source.cap);
2852
2152
  }
2853
- return {
2854
- targets,
2855
- dependents
2856
- };
2153
+ return results;
2857
2154
  }
2858
- //#endregion
2859
- //#region src/builtins/device-manager/device-meta-store.ts
2860
- /**
2861
- * Meta persistence layer for the device-manager addon.
2862
- *
2863
- * `DeviceMetaStore` encapsulates the read/modify/write helpers that previously
2864
- * lived as closures inside `onInitialize`: the `ctx.settings`-backed reads
2865
- * (`readStore` / `readIndex` / `readMeta` / `readMetadataMap`), the
2866
- * single-writer mutex (`withMetaWriteLock`), id allocation
2867
- * (`allocateNextDeviceId`), ownership/child resolution, link-source resolution,
2868
- * and the cross-device link reverse-index rebuild. Extracting it as a class
2869
- * (constructed once per `onInitialize`) keeps the EXACT semantics — the
2870
- * `withMetaWriteLock` promise-chain is per-instance state, identical to the
2871
- * former closure while letting the meta-action + query modules depend on a
2872
- * named, injectable surface instead of captured closures.
2873
- *
2874
- * Cross-device link state (the `linkTargets` / `linkDependents` /
2875
- * `expectedSourceStableIds` maps + the `devicesWithLinks` gate) lives on the
2876
- * addon; the store reaches it through the injected `LinkIndexHost` so the
2877
- * single owner of that state stays the addon instance.
2878
- */
2879
- var DeviceMetaStore = class {
2880
- settings;
2881
- registry;
2882
- linkHost;
2883
- /** Synchronous ownership cache, keyed by NUMERIC deviceId → owning addonId.
2884
- * The persisted meta store is authoritative but reads are async; hub-side
2885
- * callers (e.g. `CapabilityRegistry.getNativeProvider` fallback) need
2886
- * ownership without awaiting. Kept in sync with every register/remove and
2887
- * warmed from persistence on boot. */
2888
- idToAddonId = /* @__PURE__ */ new Map();
2889
- /** Serialises every read-modify-write of the deviceMeta / deviceIndex blob
2890
- * through one promise chain (see `withMetaWriteLock`). Per-instance state —
2891
- * identical to the former `onInitialize` closure variable. */
2892
- metaWriteChain = Promise.resolve();
2893
- constructor(settings, registry, linkHost) {
2894
- this.settings = settings;
2895
- this.registry = registry;
2896
- this.linkHost = linkHost;
2155
+ async function discoverDevices(pctx, input) {
2156
+ const dp = await pctx.host.requireDeviceProvider(input.addonId);
2157
+ if (!await dp.supportsDiscovery({})) throw new Error(`Addon "${input.addonId}" does not support device discovery`);
2158
+ return (await dp.discoverDevices({})).map((d) => ({
2159
+ stableId: d.stableId,
2160
+ type: d.type,
2161
+ suggestedName: d.suggestedName,
2162
+ prefilledConfig: d.prefilledConfig
2163
+ }));
2164
+ }
2165
+ async function adoptDevice(pctx, input) {
2166
+ const dp = await pctx.host.requireDeviceProvider(input.addonId);
2167
+ if (!await dp.supportsDiscovery({})) throw new Error(`Addon "${input.addonId}" does not support device adoption`);
2168
+ const summary = await dp.adoptDiscoveredDevice({ candidate: input.candidate });
2169
+ if (input.integrationId !== void 0) try {
2170
+ await pctx.stampIntegrationId(summary.id, input.integrationId);
2171
+ } catch (err) {
2172
+ pctx.host.ctx.logger.warn("adoptDevice: integrationId stamp failed (device adopted)", {
2173
+ tags: {
2174
+ deviceId: summary.id,
2175
+ integrationId: input.integrationId
2176
+ },
2177
+ meta: { error: require_dist.errMsg(err) }
2178
+ });
2897
2179
  }
2898
- readStore = async () => {
2899
- return await this.settings.readAddonStore();
2900
- };
2901
- readIndex = async () => {
2902
- return (await this.readStore()).deviceIndex ?? {};
2903
- };
2904
- readMeta = async () => {
2905
- return (await this.readStore()).deviceMeta ?? {};
2906
- };
2907
- /** Hardware-identity metadata map. Lives in a sibling key on the
2908
- * device-manager addon store so its writers (`setMetadata`) never
2909
- * collide with the lifecycle writers on `deviceMeta`
2910
- * (`registerDevice` / `setName` / `setLocation` / `setDisabled`).
2911
- * Single-writer per row eliminates the "writer X clobbers writer
2912
- * Y's field" bug class `setMetadata` is the only producer. */
2913
- readMetadataMap = async () => {
2914
- return (await this.readStore()).deviceMetadata ?? {};
2915
- };
2916
- withMetaWriteLock = async (fn) => {
2917
- const previous = this.metaWriteChain;
2918
- let release = () => {};
2919
- const next = new Promise((resolve) => {
2920
- release = resolve;
2180
+ return summary;
2181
+ }
2182
+ async function getCreationSchema(pctx, input) {
2183
+ const dp = await pctx.host.requireDeviceProvider(input.addonId);
2184
+ if (!await dp.supportsManualCreation({})) return null;
2185
+ return await dp.getChildCreationSchema({ type: input.type }) ?? null;
2186
+ }
2187
+ async function createDevice(pctx, input) {
2188
+ const dp = await pctx.host.requireDeviceProvider(input.addonId);
2189
+ if (!await dp.supportsManualCreation({})) throw new Error(`Addon "${input.addonId}" does not support manual device creation`);
2190
+ const summary = await dp.createDevice({
2191
+ type: input.type,
2192
+ config: input.config
2193
+ });
2194
+ if (input.integrationId !== void 0) try {
2195
+ await pctx.stampIntegrationId(summary.id, input.integrationId);
2196
+ } catch (err) {
2197
+ pctx.host.ctx.logger.warn("createDevice: integrationId stamp failed (device created)", {
2198
+ tags: {
2199
+ deviceId: summary.id,
2200
+ integrationId: input.integrationId
2201
+ },
2202
+ meta: { error: require_dist.errMsg(err) }
2921
2203
  });
2922
- this.metaWriteChain = next;
2923
- try {
2924
- await previous.catch(() => {});
2925
- return await fn();
2926
- } finally {
2927
- release();
2204
+ }
2205
+ return summary;
2206
+ }
2207
+ async function testCreationField(pctx, input) {
2208
+ return (await pctx.host.requireDeviceProvider(input.addonId)).testCreationField({
2209
+ type: input.type,
2210
+ key: input.key,
2211
+ value: input.value,
2212
+ ...input.formValues !== void 0 ? { formValues: input.formValues } : {}
2213
+ });
2214
+ }
2215
+ async function adoptionListCandidates(pctx, input) {
2216
+ const { addonId, ...rest } = input;
2217
+ return (await pctx.host.requireDeviceAdoptionProvider(addonId)).listCandidates(rest);
2218
+ }
2219
+ async function adoptionRefresh(pctx, input) {
2220
+ const { addonId, integrationId } = input;
2221
+ return (await pctx.host.requireDeviceAdoptionProvider(addonId)).refresh({ integrationId });
2222
+ }
2223
+ async function adoptionAdopt(pctx, input) {
2224
+ const { addonId, ...rest } = input;
2225
+ return (await pctx.host.requireDeviceAdoptionProvider(addonId)).adopt(rest);
2226
+ }
2227
+ async function adoptionRelease(pctx, input) {
2228
+ const { addonId, ...rest } = input;
2229
+ return (await pctx.host.requireDeviceAdoptionProvider(addonId)).release(rest);
2230
+ }
2231
+ async function adoptionResync(pctx, input) {
2232
+ const { camDeviceId, resetToSource } = input;
2233
+ let owningAddonId = pctx.registry?.getAddonId(camDeviceId) ?? null;
2234
+ if (!owningAddonId) owningAddonId = (await pctx.metaStore.resolvePersistedById(camDeviceId))?.addonId ?? null;
2235
+ if (!owningAddonId) throw new Error(`adoptionResync: device ${camDeviceId} not found`);
2236
+ let removedChildren = 0;
2237
+ if (resetToSource === true) {
2238
+ const childIds = await pctx.metaStore.directChildIds(camDeviceId);
2239
+ for (const childId of childIds) {
2240
+ await pctx.provider.remove({ deviceId: childId });
2241
+ removedChildren += 1;
2928
2242
  }
2243
+ pctx.host.ctx.logger.info("resetToSource purge before resync", { tags: {
2244
+ deviceId: camDeviceId,
2245
+ removedChildren
2246
+ } });
2247
+ }
2248
+ return {
2249
+ ...await (await pctx.host.requireDeviceAdoptionProvider(owningAddonId)).resync({
2250
+ camDeviceId,
2251
+ resetToSource
2252
+ }),
2253
+ removedChildren
2929
2254
  };
2930
- /**
2931
- * Resolve a numeric deviceId to the owning `(addonId, stableId)` pair.
2932
- * Scans persisted meta live IDevice lookup (hub registry) is handled
2933
- * separately per call site so callers can decide whether to route to
2934
- * an in-process driver or to the cross-process `device-ops` bridge.
2935
- * Returns null when no device with that id is known to the hub.
2936
- */
2937
- resolvePersistedById = async (deviceId) => {
2938
- const m = (await this.readMeta())[String(deviceId)];
2939
- if (!m) return null;
2940
- return {
2941
- addonId: m.addonId,
2942
- stableId: m.stableId,
2943
- meta: m
2944
- };
2945
- };
2946
- /** Direct children of a device: the union of the live registry's children
2947
- * and the persisted-meta scan (`parentDeviceId === parentId`), deduplicated
2948
- * and excluding self. Shared by the `remove` cascade and the `resetToSource`
2949
- * resync purge (#19). */
2950
- directChildIds = async (parentId) => {
2951
- const ids = /* @__PURE__ */ new Set();
2952
- if (this.registry) for (const c of this.registry.getChildren(parentId)) ids.add(c.id);
2953
- const meta = await this.readMeta();
2954
- for (const m of Object.values(meta)) if (m.parentDeviceId === parentId) ids.add(m.id);
2955
- ids.delete(parentId);
2956
- return [...ids];
2255
+ }
2256
+ async function testField(pctx, input) {
2257
+ const { deviceId } = input;
2258
+ let owningAddonId = null;
2259
+ if (pctx.registry) owningAddonId = pctx.registry.getAddonId(deviceId);
2260
+ if (!owningAddonId) owningAddonId = (await pctx.metaStore.resolvePersistedById(deviceId))?.addonId ?? null;
2261
+ if (!owningAddonId) throw new Error(`Device with id ${deviceId} not found`);
2262
+ const dp = await pctx.host.waitDeviceProvider(owningAddonId);
2263
+ if (!dp) return {
2264
+ status: "ok",
2265
+ labels: [],
2266
+ error: void 0
2957
2267
  };
2958
- /** Resolve a link's `sourceKey` to a live device id: the sibling accessory
2959
- * whose stableId is `${parentStableId}-${sourceKey}`. Null when absent.
2960
- * Pure over a pre-read meta map so a multi-link resolve reads the store once. */
2961
- resolveSourceDeviceId = (parentStableId, sourceKey, meta) => {
2962
- const wanted = `${parentStableId}-${sourceKey}`;
2963
- for (const m of Object.values(meta)) if (m.stableId === wanted) return m.id;
2964
- return null;
2268
+ if (typeof dp.testCreationField !== "function") return {
2269
+ status: "ok",
2270
+ labels: [],
2271
+ error: void 0
2965
2272
  };
2966
- /** Resolve a GLOBAL link source (P2e) to a live device id: the device whose
2967
- * FULL stableId equals `sourceStableId`, regardless of parent container.
2968
- * First match wins (stableIds are effectively unique cluster-wide — see the
2969
- * `DeviceLinkGlobalSource` docblock). Null when absent. Pure over a
2970
- * pre-read meta map so a multi-link resolve reads the store once. */
2971
- resolveGlobalSourceDeviceId = (sourceStableId, meta) => {
2972
- for (const m of Object.values(meta)) if (m.stableId === sourceStableId) return m.id;
2973
- return null;
2273
+ return dp.testCreationField({
2274
+ type: require_dist.DeviceType.Camera,
2275
+ key: input.key,
2276
+ value: input.value
2277
+ });
2278
+ }
2279
+ //#endregion
2280
+ //#region src/builtins/device-manager/device-linked-devices.ts
2281
+ /** Reserved device-config keys (cf. `_profileMap`). */
2282
+ var LINKED_MODE_CONFIG_KEY = "_linkedDevicesMode";
2283
+ var LINKED_IDS_CONFIG_KEY = "_linkedDeviceIds";
2284
+ /** Parse the two reserved keys out of a device-config blob. Tolerant:
2285
+ * ids may arrive as numbers or numeric strings (the multiselect field
2286
+ * stores string option values); anything else is dropped. */
2287
+ function parseLinkedDevicesConfig(blob) {
2288
+ const mode = blob["_linkedDevicesMode"] === "manual" ? "manual" : "auto";
2289
+ const rawIds = blob[LINKED_IDS_CONFIG_KEY];
2290
+ const manualIds = [];
2291
+ if (Array.isArray(rawIds)) for (const v of rawIds) {
2292
+ const n = typeof v === "number" ? v : typeof v === "string" ? Number(v) : NaN;
2293
+ if (Number.isInteger(n) && !manualIds.includes(n)) manualIds.push(n);
2294
+ }
2295
+ return {
2296
+ mode,
2297
+ manualIds
2974
2298
  };
2975
- allocateNextDeviceId = async () => {
2976
- const current = (await this.readStore()).nextDeviceId ?? 1;
2977
- await this.settings.writeAddonStore({ nextDeviceId: current + 1 });
2978
- return current;
2299
+ }
2300
+ /**
2301
+ * Pure resolution core: children (auto → same-location | manual → list),
2302
+ * minus self, deduped. Order: children first, then mode additions.
2303
+ */
2304
+ function resolveLinkedDeviceIds(params) {
2305
+ const { selfId, selfLocation, config, candidates } = params;
2306
+ const ids = /* @__PURE__ */ new Set();
2307
+ for (const c of candidates) if (c.parentDeviceId === selfId) ids.add(c.id);
2308
+ if (config.mode === "auto") {
2309
+ if (selfLocation !== null && selfLocation.length > 0) {
2310
+ for (const c of candidates) if (c.location === selfLocation) ids.add(c.id);
2311
+ }
2312
+ } else {
2313
+ const known = new Set(candidates.map((c) => c.id));
2314
+ for (const id of config.manualIds) if (known.has(id)) ids.add(id);
2315
+ }
2316
+ ids.delete(selfId);
2317
+ return [...ids];
2318
+ }
2319
+ /** Cap implementation of `getLinkedDevices`. */
2320
+ async function getLinkedDevices(pctx, input) {
2321
+ const { deviceId } = input;
2322
+ const [all, blob] = await Promise.all([listAll(pctx, {}), pctx.settings.readDeviceStore(deviceId)]);
2323
+ const config = parseLinkedDevicesConfig(blob);
2324
+ const byId = new Map(all.map((d) => [d.id, d]));
2325
+ const ids = resolveLinkedDeviceIds({
2326
+ selfId: deviceId,
2327
+ selfLocation: byId.get(deviceId)?.location ?? null,
2328
+ config,
2329
+ candidates: all
2330
+ });
2331
+ const devices = [];
2332
+ for (const id of ids) {
2333
+ const d = byId.get(id);
2334
+ if (!d) continue;
2335
+ devices.push({
2336
+ deviceId: d.id,
2337
+ name: d.name,
2338
+ location: d.location,
2339
+ features: d.features
2340
+ });
2341
+ }
2342
+ return {
2343
+ mode: config.mode,
2344
+ devices
2979
2345
  };
2980
- /** Rebuild the `linkTargets` / `linkDependents` reverse-index maps from the
2981
- * current persisted meta. Called at boot (once the `devicesWithLinks` seed
2982
- * has run) and whenever the link topology can change: `setDeviceLinks`,
2983
- * `registerDevice` (a new sibling source may resolve previously-dangling
2984
- * links), and `removeDevice` (its entries must be evicted). */
2985
- rebuildLinkDependents = async () => {
2986
- if (this.linkHost.devicesWithLinks.size === 0) {
2987
- this.linkHost.setLinkIndexes(/* @__PURE__ */ new Map(), /* @__PURE__ */ new Map(), /* @__PURE__ */ new Set());
2988
- return;
2346
+ }
2347
+ /**
2348
+ * "Linked devices" settings section for a CAMERA (null for every other
2349
+ * device type the association is camera-anchored). Contributed through
2350
+ * `collectSystemDeviceContributions`; saves route through the existing
2351
+ * `writerCapName: 'device-manager'` path into the device config blob.
2352
+ */
2353
+ async function buildLinkedDevicesContribution(pctx, deviceId) {
2354
+ const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
2355
+ if (!persisted || persisted.meta.type !== require_dist.DeviceType.Camera) return null;
2356
+ const [all, blob] = await Promise.all([listAll(pctx, {}), pctx.settings.readDeviceStore(deviceId)]);
2357
+ const config = parseLinkedDevicesConfig(blob);
2358
+ const options = all.filter((d) => d.id !== deviceId).map((d) => ({
2359
+ value: String(d.id),
2360
+ label: d.location !== null ? `${d.name} (${d.location})` : d.name
2361
+ }));
2362
+ return { sections: [{
2363
+ id: "linked-devices",
2364
+ title: "Linked devices",
2365
+ description: "Companion devices associated with this camera. Device-tree children are always linked; auto mode also links every device sharing the camera location, manual mode links the picked list instead.",
2366
+ order: 70,
2367
+ fields: [{
2368
+ type: "select",
2369
+ key: LINKED_MODE_CONFIG_KEY,
2370
+ label: "Link mode",
2371
+ description: "Auto: children + same-location devices. Manual: children + the list below.",
2372
+ options: [{
2373
+ value: "auto",
2374
+ label: "Auto (location-based)"
2375
+ }, {
2376
+ value: "manual",
2377
+ label: "Manual"
2378
+ }],
2379
+ value: config.mode
2380
+ }, {
2381
+ type: "multiselect",
2382
+ key: LINKED_IDS_CONFIG_KEY,
2383
+ label: "Manually linked devices",
2384
+ description: "Used in manual mode (children stay linked regardless).",
2385
+ options,
2386
+ value: config.manualIds.map(String)
2387
+ }]
2388
+ }] };
2389
+ }
2390
+ /**
2391
+ * Direct `applyDeviceSettingsPatch` normalization for the two reserved keys.
2392
+ * The admin-ui save path reaches the device config blob via the
2393
+ * `writerCapName: 'device-manager'` routing (never this method), but the cap
2394
+ * contract includes the method — keep it correct for direct callers.
2395
+ */
2396
+ async function applyLinkedDevicesPatch(pctx, deviceId, patch) {
2397
+ const next = { ...await pctx.settings.readDeviceStore(deviceId) };
2398
+ if ("_linkedDevicesMode" in patch) next[LINKED_MODE_CONFIG_KEY] = patch["_linkedDevicesMode"] === "manual" ? "manual" : "auto";
2399
+ if ("_linkedDeviceIds" in patch) next[LINKED_IDS_CONFIG_KEY] = [...parseLinkedDevicesConfig({ [LINKED_IDS_CONFIG_KEY]: patch[LINKED_IDS_CONFIG_KEY] }).manualIds];
2400
+ await pctx.settings.writeDeviceStore(deviceId, next);
2401
+ return { success: true };
2402
+ }
2403
+ //#endregion
2404
+ //#region src/builtins/device-manager/device-link-cycle.ts
2405
+ function nodeKey(deviceId, cap) {
2406
+ return `${deviceId}:${cap}`;
2407
+ }
2408
+ /** The target's container stableId — its parent's (falling back to its own for
2409
+ * a top-level target). Sibling FIELD sources resolve relative to this, the same
2410
+ * rule `rebuildLinkDependents` / `resolveLinkedStatus` apply. */
2411
+ function containerStableId(targetRow, rowById) {
2412
+ return targetRow.parentDeviceId !== null ? rowById.get(targetRow.parentDeviceId)?.stableId ?? targetRow.stableId : targetRow.stableId;
2413
+ }
2414
+ /** Resolve ONE field/global/literal binding to its `(deviceId, cap)` node, or
2415
+ * null when it is a literal or resolves to no known device. */
2416
+ function resolveBindingNode(binding, targetRow, rowById, idByStableId) {
2417
+ if (binding.kind === "literal") return null;
2418
+ const wantedStableId = binding.kind === "global" ? binding.sourceStableId : `${containerStableId(targetRow, rowById)}-${binding.sourceKey}`;
2419
+ const srcId = idByStableId.get(wantedStableId);
2420
+ if (srcId === void 0) return null;
2421
+ return nodeKey(srcId, binding.cap);
2422
+ }
2423
+ /**
2424
+ * Resolve a link source to the set of `(deviceId, cap)` nodes it depends on.
2425
+ * A literal source depends on nothing; a field/global source depends on exactly
2426
+ * one node; an EXPRESSION source depends on one node per resolvable non-literal
2427
+ * binding. Unresolvable (dangling) bindings contribute no edge — they cannot
2428
+ * loop while dangling; a cycle that only becomes resolvable later is bounded by
2429
+ * the resolve-time re-entrancy guard.
2430
+ */
2431
+ function resolveSourceNodes(link, targetRow, rowById, idByStableId) {
2432
+ const src = link.source;
2433
+ if (src.kind === "expression") {
2434
+ const nodes = [];
2435
+ for (const binding of Object.values(src.bindings)) {
2436
+ const node = resolveBindingNode(binding, targetRow, rowById, idByStableId);
2437
+ if (node !== null) nodes.push(node);
2989
2438
  }
2990
- const allMeta = await this.readMeta();
2991
- const stableIdById = /* @__PURE__ */ new Map();
2992
- const idByStableId = /* @__PURE__ */ new Map();
2993
- for (const m of Object.values(allMeta)) {
2994
- stableIdById.set(m.id, m.stableId);
2995
- idByStableId.set(m.stableId, m.id);
2439
+ return nodes;
2440
+ }
2441
+ const node = resolveBindingNode(src, targetRow, rowById, idByStableId);
2442
+ return node !== null ? [node] : [];
2443
+ }
2444
+ /**
2445
+ * Detect whether replacing `editedDeviceId`'s links with `editedLinks` closes
2446
+ * a dependency cycle. Returns the cycle as an ordered list of node keys
2447
+ * (first node repeated at the end) or null when the set is safe.
2448
+ */
2449
+ function findDeviceLinkCycle(allMeta, editedDeviceId, editedLinks) {
2450
+ const rows = Object.values(allMeta);
2451
+ const rowById = /* @__PURE__ */ new Map();
2452
+ const idByStableId = /* @__PURE__ */ new Map();
2453
+ for (const r of rows) {
2454
+ rowById.set(r.id, r);
2455
+ if (!idByStableId.has(r.stableId)) idByStableId.set(r.stableId, r.id);
2456
+ }
2457
+ const dependsOn = /* @__PURE__ */ new Map();
2458
+ const addEdges = (row, links) => {
2459
+ for (const link of links) {
2460
+ const srcNodes = resolveSourceNodes(link, row, rowById, idByStableId);
2461
+ if (srcNodes.length === 0) continue;
2462
+ const tNode = nodeKey(row.id, link.target.cap);
2463
+ const list = dependsOn.get(tNode) ?? [];
2464
+ for (const srcNode of srcNodes) list.push(srcNode);
2465
+ dependsOn.set(tNode, list);
2996
2466
  }
2997
- const entries = [];
2998
- const expectedSources = /* @__PURE__ */ new Set();
2999
- for (const targetId of this.linkHost.devicesWithLinks) {
3000
- const targetMeta = Object.values(allMeta).find((m) => m.id === targetId);
3001
- if (!targetMeta) continue;
3002
- const container = targetMeta.parentDeviceId !== null ? stableIdById.get(targetMeta.parentDeviceId) ?? stableIdById.get(targetId) : stableIdById.get(targetId);
3003
- if (container === void 0) continue;
3004
- for (const link of targetMeta.deviceLinks ?? []) {
3005
- const src = link.source;
3006
- if (src.kind === "expression") {
3007
- const bindingSourceIds = {};
3008
- for (const [name, b] of Object.entries(src.bindings)) {
3009
- if (b.kind === "literal") continue;
3010
- const wanted = b.kind === "global" ? b.sourceStableId : `${container}-${b.sourceKey}`;
3011
- expectedSources.add(wanted);
3012
- const bid = idByStableId.get(wanted);
3013
- if (bid !== void 0) bindingSourceIds[name] = bid;
3014
- }
3015
- entries.push({
3016
- targetDeviceId: targetId,
3017
- link,
3018
- sourceDeviceId: -1,
3019
- bindingSourceIds
3020
- });
3021
- continue;
3022
- }
3023
- if (src.kind === "literal") {
3024
- entries.push({
3025
- targetDeviceId: targetId,
3026
- link,
3027
- sourceDeviceId: -1
3028
- });
3029
- continue;
3030
- }
3031
- const wantedStableId = src.kind === "global" ? src.sourceStableId : `${container}-${src.sourceKey}`;
3032
- expectedSources.add(wantedStableId);
3033
- const srcId = idByStableId.get(wantedStableId);
3034
- if (srcId === void 0) continue;
3035
- entries.push({
3036
- targetDeviceId: targetId,
3037
- link,
3038
- sourceDeviceId: srcId
3039
- });
2467
+ };
2468
+ for (const r of rows) {
2469
+ const links = r.id === editedDeviceId ? editedLinks : r.deviceLinks ?? [];
2470
+ if (links.length > 0) addEdges(r, links);
2471
+ }
2472
+ const roots = [...new Set(editedLinks.map((l) => nodeKey(editedDeviceId, l.target.cap)))];
2473
+ for (const root of roots) {
2474
+ const cycle = dfsFindCycle(root, dependsOn);
2475
+ if (cycle) return cycle;
2476
+ }
2477
+ return null;
2478
+ }
2479
+ /** Iterative DFS from `root` over `dependsOn`; returns the first back-edge
2480
+ * cycle path (closed first node repeated last) or null. */
2481
+ function dfsFindCycle(root, dependsOn) {
2482
+ const onPath = /* @__PURE__ */ new Set();
2483
+ const done = /* @__PURE__ */ new Set();
2484
+ const path = [];
2485
+ const stack = [{
2486
+ node: root,
2487
+ nextChild: 0
2488
+ }];
2489
+ onPath.add(root);
2490
+ path.push(root);
2491
+ while (stack.length > 0) {
2492
+ const frame = stack[stack.length - 1];
2493
+ const children = dependsOn.get(frame.node) ?? [];
2494
+ if (frame.nextChild < children.length) {
2495
+ const child = children[frame.nextChild];
2496
+ frame.nextChild += 1;
2497
+ if (onPath.has(child)) {
2498
+ const start = path.indexOf(child);
2499
+ return [...path.slice(start), child];
3040
2500
  }
2501
+ if (done.has(child)) continue;
2502
+ stack.push({
2503
+ node: child,
2504
+ nextChild: 0
2505
+ });
2506
+ onPath.add(child);
2507
+ path.push(child);
2508
+ continue;
3041
2509
  }
3042
- const { targets, dependents } = buildLinkIndexes(entries);
3043
- this.linkHost.setLinkIndexes(targets, dependents, expectedSources);
3044
- };
3045
- };
2510
+ stack.pop();
2511
+ onPath.delete(frame.node);
2512
+ path.pop();
2513
+ done.add(frame.node);
2514
+ }
2515
+ return null;
2516
+ }
3046
2517
  //#endregion
3047
- //#region src/builtins/device-manager/device-queries.ts
2518
+ //#region src/builtins/device-manager/device-meta-actions.ts
3048
2519
  /**
3049
- * Device query + live-runtime + provider/adoption operations for the
3050
- * device-manager addon the read/live half of the `IDeviceManagerProvider`
3051
- * surface. Extracted verbatim from the `onInitialize` provider literal as free
3052
- * functions taking a `ProviderContext` (DI). Input/return types are pinned to
3053
- * the cap contract via `Parameters<IDeviceManagerProvider['x']>[0]` /
3054
- * `ReturnType<…>` so the typed surface is identical to the inferred methods.
2520
+ * Device meta-mutation + persistence actions for the device-manager addon —
2521
+ * the `IDeviceManagerProvider` write/load surface. Extracted verbatim from the
2522
+ * `onInitialize` provider literal as free functions taking a `ProviderContext`
2523
+ * (DI). Each function's input/return types are pinned to the cap contract via
2524
+ * `Parameters<IDeviceManagerProvider['x']>[0]` / `ReturnType<…>` so the typed
2525
+ * surface is identical to the inferred object-literal methods.
3055
2526
  *
3056
- * Covers: persisted/live device queries (listPersistedByAddon, listAll,
3057
- * getDevice, getChildren), live runtime methods (getStreamSources,
3058
- * getConfigSchema, getSettingsSchema, updateConfig, enable, disable, remove,
3059
- * removeByIntegration, getStreamProfileMap, setStreamProfileMap, probeStreams),
3060
- * device-provider ops (discoverDevices, adoptDevice, getCreationSchema,
3061
- * createDevice, testCreationField), and device-adoption ops
3062
- * (adoptionListCandidates/Refresh/Adopt/Release/Resync, testField).
2527
+ * Covers: id allocation + registration (allocateDeviceId, registerDevice,
2528
+ * removeDevice), config persistence (persistConfig, loadConfig), the meta
2529
+ * surface load (loadMeta, loadRuntimeState), every meta setter (setName,
2530
+ * setLocation, setType, setIntegrationId, setLinkDeviceId,
2531
+ * setPrimaryChildEntityId, setChildLayout, setDeviceLinks, setRole, setDisplay,
2532
+ * applyInitialMeta, setMetadata, setDisabled), and the location registry
2533
+ * (listLocations, addLocation, removeLocation).
3063
2534
  *
3064
- * The hub-live-then-persisted-fallback union, the cross-process device-ops
3065
- * dispatch, the depth-first remove cascade, and the resetToSource resync purge
3066
- * are byte-for-byte preserved. `remove`/`removeByIntegration`/`enable`/`disable`/
3067
- * `probeStreams`/`getStreamProfileMap` self-call sibling cap methods via
3068
- * `pctx.provider`. Pure refactor — no behavior change.
2535
+ * EXACT ordering and side effects preserved — notably registerDevice's
2536
+ * name-precedence reconcile + field-preservation spreads, the
2537
+ * withMetaWriteLock-serialised read-modify-writes, the event emissions, and the
2538
+ * link reverse-index rebuild gating. Pure refactor — no behavior change.
3069
2539
  */
3070
- async function listPersistedByAddon(pctx, input) {
3071
- const { addonId } = input;
3072
- const [index, meta] = await Promise.all([pctx.metaStore.readIndex(), pctx.metaStore.readMeta()]);
3073
- const stableIds = index[addonId] ?? [];
3074
- const byStableId = /* @__PURE__ */ new Map();
3075
- for (const m of Object.values(meta)) if (m.addonId === addonId) byStableId.set(m.stableId, m);
3076
- return stableIds.map((stableId) => {
3077
- const m = byStableId.get(stableId);
2540
+ /**
2541
+ * Stamp (or update) the owning integration id on a device's meta row + emit
2542
+ * `DeviceMetaChanged`. The device-manager chokepoint shared by the
2543
+ * `setIntegrationId` cap handler AND the create/adopt forwarders (queries)
2544
+ * that stamp an integration's ownership onto every device it materializes.
2545
+ * Idempotent. Takes its low-level deps directly (not the full `ProviderContext`)
2546
+ * because `ProviderContext.stampIntegrationId` delegates HERE — passing the
2547
+ * context would be a capture cycle.
2548
+ */
2549
+ async function stampIntegrationId(metaStore, settings, ctx, deviceId, integrationId) {
2550
+ await metaStore.withMetaWriteLock(async () => {
2551
+ const persisted = await metaStore.resolvePersistedById(deviceId);
2552
+ if (!persisted) throw new Error(`[device-manager] stampIntegrationId: unknown device id=${deviceId}`);
2553
+ const { meta: m } = persisted;
2554
+ const key = String(deviceId);
2555
+ const allMeta = await metaStore.readMeta();
2556
+ await settings.writeAddonStore({ deviceMeta: {
2557
+ ...allMeta,
2558
+ [key]: {
2559
+ ...m,
2560
+ integrationId
2561
+ }
2562
+ } });
2563
+ });
2564
+ ctx.eventBus.emit({
2565
+ id: (0, node_crypto.randomUUID)(),
2566
+ timestamp: /* @__PURE__ */ new Date(),
2567
+ source: {
2568
+ type: "device",
2569
+ id: deviceId
2570
+ },
2571
+ category: require_dist.EventCategory.DeviceMetaChanged,
2572
+ data: {
2573
+ deviceId,
2574
+ field: "integrationId",
2575
+ value: integrationId
2576
+ }
2577
+ });
2578
+ }
2579
+ async function allocateDeviceId(pctx, input) {
2580
+ const { addonId, stableId } = input;
2581
+ return await pctx.metaStore.withMetaWriteLock(async () => {
2582
+ const meta = await pctx.metaStore.readMeta();
2583
+ const existing = Object.values(meta).find((m) => m.addonId === addonId && m.stableId === stableId);
2584
+ if (existing) return { id: existing.id };
2585
+ const id = await pctx.metaStore.allocateNextDeviceId();
2586
+ await pctx.settings.writeAddonStore({ deviceMeta: {
2587
+ ...meta,
2588
+ [String(id)]: {
2589
+ addonId,
2590
+ stableId,
2591
+ type: "generic",
2592
+ name: stableId,
2593
+ location: null,
2594
+ disabled: false,
2595
+ parentDeviceId: null,
2596
+ id
2597
+ }
2598
+ } });
2599
+ return { id };
2600
+ });
2601
+ }
2602
+ async function registerDevice(pctx, input) {
2603
+ const { addonId, stableId, id, type, name, parentDeviceId, features, config } = input;
2604
+ const key = String(id);
2605
+ const featuresArr = Array.isArray(features) ? [...features] : [];
2606
+ const { isFirstRegistration, exportFingerprint, fingerprintChanged } = await pctx.metaStore.withMetaWriteLock(async () => {
2607
+ const index = await pctx.metaStore.readIndex();
2608
+ const existing = index[addonId] ?? [];
2609
+ const wasInIndex = existing.includes(stableId);
2610
+ if (!wasInIndex) await pctx.settings.writeAddonStore({ deviceIndex: {
2611
+ ...index,
2612
+ [addonId]: [...existing, stableId]
2613
+ } });
2614
+ const meta = await pctx.metaStore.readMeta();
2615
+ const existingMeta = meta[key];
2616
+ const wasUserNamed = existingMeta?.userNamed ?? (existingMeta !== void 0 && existingMeta.name !== stableId);
2617
+ const resolvedName = wasUserNamed && existingMeta !== void 0 ? existingMeta.name : name;
2618
+ const isFirst = !existingMeta || !wasInIndex;
2619
+ const fingerprint = (0, _camstack_types_node.canonicalDeviceFingerprint)({
2620
+ deviceId: id,
2621
+ deviceType: type,
2622
+ features: featuresArr
2623
+ });
2624
+ await pctx.settings.writeAddonStore({ deviceMeta: {
2625
+ ...meta,
2626
+ [key]: {
2627
+ addonId,
2628
+ stableId,
2629
+ type,
2630
+ name: resolvedName,
2631
+ userNamed: wasUserNamed,
2632
+ location: existingMeta?.location ?? null,
2633
+ disabled: existingMeta?.disabled ?? false,
2634
+ ...existingMeta?.integrationId !== void 0 ? { integrationId: existingMeta.integrationId } : {},
2635
+ ...existingMeta?.linkDeviceId !== void 0 ? { linkDeviceId: existingMeta.linkDeviceId } : {},
2636
+ ...existingMeta?.primaryChildEntityId !== void 0 ? { primaryChildEntityId: existingMeta.primaryChildEntityId } : {},
2637
+ ...existingMeta?.childLayout !== void 0 ? { childLayout: existingMeta.childLayout } : {},
2638
+ ...existingMeta?.deviceLinks !== void 0 ? { deviceLinks: existingMeta.deviceLinks } : {},
2639
+ ...existingMeta?.role !== void 0 ? { role: existingMeta.role } : {},
2640
+ ...existingMeta?.display !== void 0 ? { display: existingMeta.display } : {},
2641
+ parentDeviceId,
2642
+ id,
2643
+ features: featuresArr,
2644
+ exportFingerprint: fingerprint
2645
+ }
2646
+ } });
3078
2647
  return {
3079
- id: m.id,
3080
- stableId,
3081
- type: m.type,
3082
- name: m.name,
3083
- location: m.location ?? null,
3084
- disabled: m.disabled ?? false,
3085
- parentDeviceId: m.parentDeviceId
2648
+ isFirstRegistration: isFirst,
2649
+ exportFingerprint: fingerprint,
2650
+ fingerprintChanged: existingMeta?.exportFingerprint !== fingerprint
3086
2651
  };
3087
2652
  });
2653
+ if (Object.keys(config).length > 0) await pctx.settings.writeDeviceStore(id, config);
2654
+ pctx.metaStore.idToAddonId.set(id, addonId);
2655
+ if (isFirstRegistration) pctx.host.ctx.eventBus.emit({
2656
+ id: (0, node_crypto.randomUUID)(),
2657
+ timestamp: /* @__PURE__ */ new Date(),
2658
+ source: {
2659
+ type: "device",
2660
+ id
2661
+ },
2662
+ category: require_dist.EventCategory.DeviceRegistered,
2663
+ data: {
2664
+ deviceId: id,
2665
+ name: name.length > 0 ? name : stableId,
2666
+ providerId: addonId,
2667
+ parentDeviceId: parentDeviceId ?? null
2668
+ }
2669
+ });
2670
+ else pctx.host.ctx.eventBus.emit({
2671
+ id: (0, node_crypto.randomUUID)(),
2672
+ timestamp: /* @__PURE__ */ new Date(),
2673
+ source: {
2674
+ type: "device",
2675
+ id
2676
+ },
2677
+ category: require_dist.EventCategory.DeviceMetaChanged,
2678
+ data: {
2679
+ deviceId: id,
2680
+ name: name.length > 0 ? name : stableId,
2681
+ providerId: addonId,
2682
+ parentDeviceId: parentDeviceId ?? null,
2683
+ features: featuresArr
2684
+ }
2685
+ });
2686
+ if (isFirstRegistration || fingerprintChanged) pctx.host.ctx.eventBus.emit({
2687
+ id: (0, node_crypto.randomUUID)(),
2688
+ timestamp: /* @__PURE__ */ new Date(),
2689
+ source: {
2690
+ type: "device",
2691
+ id
2692
+ },
2693
+ category: require_dist.EventCategory.DeviceProvisioned,
2694
+ data: {
2695
+ deviceId: id,
2696
+ fingerprint: exportFingerprint,
2697
+ generation: Date.now()
2698
+ }
2699
+ });
2700
+ if (pctx.host.devicesWithLinks.size > 0 && pctx.host.expectedSourceStableIds.has(stableId)) await pctx.metaStore.rebuildLinkDependents();
3088
2701
  }
3089
- async function listAll(pctx, input) {
3090
- const { addonId } = input;
3091
- const results = [];
3092
- const seen = /* @__PURE__ */ new Set();
3093
- const meta = await pctx.metaStore.readMeta();
3094
- const metadataMap = await pctx.metaStore.readMetadataMap();
3095
- if (pctx.registry) {
3096
- const liveEntries = addonId ? pctx.registry.getAllForAddon(addonId).map((device) => ({
3097
- addonId,
3098
- device
3099
- })) : pctx.registry.getAllWithAddonId();
3100
- for (const { addonId: aid, device } of liveEntries) {
3101
- const key = String(device.id);
3102
- const metadata = metadataMap[key] ?? null;
3103
- const metaRow = meta[key] ?? null;
3104
- results.push(toDeviceInfo(aid, device, metadata, metaRow));
3105
- seen.add(key);
2702
+ async function removeDevice(pctx, input) {
2703
+ const { deviceId } = input;
2704
+ const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
2705
+ if (!persisted) return;
2706
+ const { addonId, stableId, meta: persistedMeta } = persisted;
2707
+ const key = String(deviceId);
2708
+ const deviceName = persistedMeta.name;
2709
+ await pctx.metaStore.withMetaWriteLock(async () => {
2710
+ const index = await pctx.metaStore.readIndex();
2711
+ const remaining = (index[addonId] ?? []).filter((sid) => sid !== stableId);
2712
+ const updatedIndex = remaining.length > 0 ? {
2713
+ ...index,
2714
+ [addonId]: remaining
2715
+ } : (() => {
2716
+ const { [addonId]: _removed, ...rest } = index;
2717
+ return rest;
2718
+ })();
2719
+ await pctx.settings.writeAddonStore({ deviceIndex: updatedIndex });
2720
+ const { [key]: _removedMeta, ...restMeta } = await pctx.metaStore.readMeta();
2721
+ await pctx.settings.writeAddonStore({ deviceMeta: restMeta });
2722
+ const map = await pctx.metaStore.readMetadataMap();
2723
+ if (key in map) {
2724
+ const { [key]: _removedMetadata, ...restMap } = map;
2725
+ await pctx.settings.writeAddonStore({ deviceMetadata: restMap });
3106
2726
  }
2727
+ });
2728
+ await pctx.settings.clearDeviceStore(deviceId);
2729
+ await pctx.settings.clearDeviceRuntimeState(deviceId);
2730
+ const bindingsStore = await readBindingsStore(pctx.bindingsDeps);
2731
+ const bindingKey = String(deviceId);
2732
+ if (bindingsStore.deviceBindings[bindingKey]) {
2733
+ const { [bindingKey]: _removedBindings, ...restBindings } = bindingsStore.deviceBindings;
2734
+ await writeBindingsStore(pctx.bindingsDeps, { deviceBindings: restBindings });
3107
2735
  }
3108
- const index = await pctx.metaStore.readIndex();
3109
- const metaByAddonStable = /* @__PURE__ */ new Map();
3110
- for (const m of Object.values(meta)) metaByAddonStable.set(`${m.addonId}${m.stableId}`, m);
3111
- const targetAddons = addonId ? [addonId] : Object.keys(index);
3112
- for (const aid of targetAddons) for (const stableId of index[aid] ?? []) {
3113
- const m = metaByAddonStable.get(`${aid}${stableId}`);
3114
- const key = String(m.id);
3115
- if (seen.has(key)) continue;
3116
- const persistedType = m.type;
3117
- const persistedConfig = await pctx.settings.readDeviceStore(m.id);
3118
- const metadata = metadataMap[key] ?? null;
3119
- results.push({
3120
- id: m.id,
3121
- stableId,
3122
- addonId: aid,
3123
- type: persistedType,
3124
- name: m?.name ?? stableId,
3125
- location: m?.location ?? null,
3126
- disabled: m?.disabled ?? false,
3127
- parentDeviceId: m?.parentDeviceId ?? null,
3128
- role: toDeviceRole(m?.role),
3129
- online: pctx.host.resolveDeviceOnline(m.id, pctx.registry !== null),
3130
- probed: pctx.host.resolveDeviceProbed(m.id),
3131
- features: persistedFeatures(m?.features),
3132
- isCamera: persistedType === require_dist.DeviceType.Camera,
3133
- config: persistedConfig ?? {},
3134
- metadata,
3135
- ...m?.integrationId !== void 0 ? { integrationId: m.integrationId } : {},
3136
- ...m?.linkDeviceId !== void 0 ? { linkDeviceId: m.linkDeviceId } : {},
3137
- ...m?.primaryChildEntityId !== void 0 ? { primaryChildEntityId: m.primaryChildEntityId } : {},
3138
- ...m?.childLayout !== void 0 ? { childLayout: m.childLayout } : {},
3139
- ...m?.deviceLinks !== void 0 ? { deviceLinks: m.deviceLinks } : {},
3140
- ...m?.display !== void 0 ? { display: m.display } : {},
3141
- ...(() => {
3142
- const si = buildSourceInfoFromConfig(persistedConfig ?? {}, stableId, aid);
3143
- return si !== void 0 ? { sourceInfo: si } : {};
3144
- })()
3145
- });
3146
- }
3147
- return results;
2736
+ pctx.host.remoteNativeCaps.delete(deviceId);
2737
+ pctx.host.capabilityRegistry?.unregisterAllNativeForDevice(deviceId);
2738
+ pctx.metaStore.idToAddonId.delete(deviceId);
2739
+ pctx.host.devicesWithLinks.delete(deviceId);
2740
+ pctx.host.dropDeviceOverlays(deviceId);
2741
+ await pctx.metaStore.rebuildLinkDependents();
2742
+ pctx.host.ctx.logger.info("removed device", { tags: {
2743
+ deviceId,
2744
+ deviceName: deviceName.length > 0 ? deviceName : stableId
2745
+ } });
2746
+ pctx.host.ctx.eventBus.emit({
2747
+ id: (0, node_crypto.randomUUID)(),
2748
+ timestamp: /* @__PURE__ */ new Date(),
2749
+ source: {
2750
+ type: "device",
2751
+ id: deviceId
2752
+ },
2753
+ category: require_dist.EventCategory.DeviceUnregistered,
2754
+ data: {
2755
+ deviceId,
2756
+ providerId: addonId,
2757
+ parentDeviceId: persistedMeta.parentDeviceId ?? null
2758
+ }
2759
+ });
3148
2760
  }
3149
- async function getDevice(pctx, input) {
2761
+ async function persistConfig(pctx, input) {
2762
+ const { deviceId, data } = input;
2763
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] persistConfig: unknown device id=${deviceId}`);
2764
+ await pctx.settings.writeDeviceStore(deviceId, data);
2765
+ }
2766
+ async function loadConfig(pctx, input) {
2767
+ const { deviceId } = input;
2768
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) return {};
2769
+ return pctx.settings.readDeviceStore(deviceId);
2770
+ }
2771
+ /**
2772
+ * Load the operator-organisational meta surface for one device
2773
+ * (`name` / `location` / `disabled` / `type` / `parentDeviceId`
2774
+ * / `addonId` + `id` / `stableId`). Used by the kernel proxy's
2775
+ * device-context factory to populate `ctx.deviceMeta` before
2776
+ * the device class constructor runs. Returns `null` when no
2777
+ * persisted row exists for the id.
2778
+ *
2779
+ * Reads default `location` to `null` and `disabled` to `false`
2780
+ * for legacy rows that predate the field — production code
2781
+ * relies on the IDevice type contract that both are present.
2782
+ */
2783
+ async function loadMeta(pctx, input) {
3150
2784
  const { deviceId } = input;
3151
- if (pctx.registry) {
3152
- const found = resolveDeviceById(pctx.registry, deviceId);
3153
- if (found) {
3154
- const key = String(found.device.id);
3155
- const [map, metaMap] = await Promise.all([pctx.metaStore.readMetadataMap(), pctx.metaStore.readMeta()]);
3156
- const metadata = map[key] ?? null;
3157
- const metaRow = metaMap[key] ?? null;
3158
- return toDeviceInfo(found.addonId, found.device, metadata, metaRow);
3159
- }
3160
- }
3161
2785
  const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
3162
2786
  if (!persisted) return null;
3163
- const { addonId: aid, stableId, meta: m } = persisted;
3164
- const persistedConfig = await pctx.settings.readDeviceStore(m.id);
2787
+ const { addonId, stableId, meta: m } = persisted;
3165
2788
  const key = String(deviceId);
3166
2789
  const metadata = (await pctx.metaStore.readMetadataMap())[key] ?? null;
3167
- const sourceInfoGetDevice = buildSourceInfoFromConfig(persistedConfig ?? {}, stableId, aid);
3168
2790
  return {
3169
- id: deviceId,
2791
+ id: m.id,
3170
2792
  stableId,
3171
- addonId: aid,
2793
+ addonId,
3172
2794
  type: m.type,
3173
2795
  name: m.name,
3174
2796
  location: m.location ?? null,
3175
2797
  disabled: m.disabled ?? false,
3176
2798
  parentDeviceId: m.parentDeviceId,
3177
- role: toDeviceRole(m.role),
3178
- online: pctx.host.resolveDeviceOnline(deviceId, true),
3179
- probed: pctx.host.resolveDeviceProbed(deviceId),
3180
- features: persistedFeatures(m.features),
3181
- isCamera: false,
3182
- config: persistedConfig ?? {},
3183
2799
  metadata,
3184
2800
  ...m.integrationId !== void 0 ? { integrationId: m.integrationId } : {},
3185
2801
  ...m.linkDeviceId !== void 0 ? { linkDeviceId: m.linkDeviceId } : {},
3186
- ...m.primaryChildEntityId !== void 0 ? { primaryChildEntityId: m.primaryChildEntityId } : {},
3187
- ...m.childLayout !== void 0 ? { childLayout: m.childLayout } : {},
3188
- ...m.deviceLinks !== void 0 ? { deviceLinks: m.deviceLinks } : {},
3189
- ...m.display !== void 0 ? { display: m.display } : {},
3190
- ...sourceInfoGetDevice !== void 0 ? { sourceInfo: sourceInfoGetDevice } : {}
2802
+ ...m.role !== void 0 ? { role: m.role } : {}
3191
2803
  };
3192
2804
  }
3193
- async function getChildren(pctx, input) {
3194
- const { parentDeviceId } = input;
3195
- let ownerAddonId = null;
3196
- if (pctx.registry) {
3197
- if (pctx.registry.getById(parentDeviceId)) ownerAddonId = pctx.registry.getAddonId(parentDeviceId);
3198
- }
3199
- if (!ownerAddonId) {
3200
- const persisted = await pctx.metaStore.resolvePersistedById(parentDeviceId);
3201
- if (!persisted) return [];
3202
- ownerAddonId = persisted.addonId;
3203
- }
3204
- const results = [];
3205
- const seen = /* @__PURE__ */ new Set();
3206
- const [index, meta, metadataMap] = await Promise.all([
3207
- pctx.metaStore.readIndex(),
3208
- pctx.metaStore.readMeta(),
3209
- pctx.metaStore.readMetadataMap()
3210
- ]);
3211
- if (pctx.registry) {
3212
- const liveChildren = pctx.registry.getChildren(parentDeviceId);
3213
- for (const device of liveChildren) {
3214
- const key = String(device.id);
3215
- const metadata = metadataMap[key] ?? null;
3216
- const metaRow = meta[key] ?? null;
3217
- results.push(toDeviceInfo(ownerAddonId, device, metadata, metaRow));
3218
- seen.add(key);
2805
+ /**
2806
+ * Update the operator-edited display name. Writes the meta
2807
+ * row, emits a `DeviceMetaChanged` event so live consumers
2808
+ * (UI device list, alert center) see the rename without
2809
+ * polling. The live `IDevice.name` mirror is updated by the
2810
+ * kernel proxy on its side (`device-cap-proxy.ts`).
2811
+ */
2812
+ async function setName(pctx, input) {
2813
+ const { deviceId, name } = input;
2814
+ const cascaded = [];
2815
+ await pctx.metaStore.withMetaWriteLock(async () => {
2816
+ const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
2817
+ if (!persisted) throw new Error(`[device-manager] setName: unknown device id=${deviceId}`);
2818
+ const { meta: m } = persisted;
2819
+ const key = String(deviceId);
2820
+ const oldName = m.name;
2821
+ const allMeta = await pctx.metaStore.readMeta();
2822
+ const nextMeta = {
2823
+ ...allMeta,
2824
+ [key]: {
2825
+ ...m,
2826
+ name,
2827
+ userNamed: true
2828
+ }
2829
+ };
2830
+ if (oldName.length > 0 && oldName !== name) for (const [childKey, childMeta] of Object.entries(allMeta)) {
2831
+ if (childKey === key) continue;
2832
+ if (!(childMeta.parentDeviceId === deviceId || childMeta.linkDeviceId === deviceId)) continue;
2833
+ if (childMeta.name !== oldName && !childMeta.name.startsWith(`${oldName} `)) continue;
2834
+ const childName = childMeta.name === oldName ? name : `${name}${childMeta.name.slice(oldName.length)}`;
2835
+ nextMeta[childKey] = {
2836
+ ...childMeta,
2837
+ name: childName
2838
+ };
2839
+ cascaded.push({
2840
+ id: Number(childKey),
2841
+ name: childName
2842
+ });
3219
2843
  }
3220
- }
3221
- const ownerMetaByStableId = /* @__PURE__ */ new Map();
3222
- for (const m of Object.values(meta)) if (m.addonId === ownerAddonId) ownerMetaByStableId.set(m.stableId, m);
3223
- const persistedChildren = (index[ownerAddonId] ?? []).filter((sid) => ownerMetaByStableId.get(sid)?.parentDeviceId === parentDeviceId);
3224
- for (const childStableId of persistedChildren) {
3225
- const m = ownerMetaByStableId.get(childStableId);
3226
- const key = String(m.id);
3227
- if (seen.has(key)) continue;
3228
- const persistedConfig = await pctx.settings.readDeviceStore(m.id);
3229
- const metadata = metadataMap[key] ?? null;
3230
- const sourceInfoChild = buildSourceInfoFromConfig(persistedConfig ?? {}, childStableId, ownerAddonId);
3231
- results.push({
3232
- id: m.id,
3233
- stableId: childStableId,
3234
- addonId: ownerAddonId,
3235
- type: m.type,
3236
- name: m.name,
3237
- location: m.location ?? null,
3238
- disabled: m.disabled ?? false,
3239
- parentDeviceId,
3240
- role: toDeviceRole(m.role),
3241
- online: pctx.host.resolveDeviceOnline(m.id, pctx.registry !== null),
3242
- probed: pctx.host.resolveDeviceProbed(m.id),
3243
- features: persistedFeatures(m.features),
3244
- isCamera: false,
3245
- config: persistedConfig ?? {},
3246
- metadata,
3247
- ...m.integrationId !== void 0 ? { integrationId: m.integrationId } : {},
3248
- ...m.linkDeviceId !== void 0 ? { linkDeviceId: m.linkDeviceId } : {},
3249
- ...m.primaryChildEntityId !== void 0 ? { primaryChildEntityId: m.primaryChildEntityId } : {},
3250
- ...m.childLayout !== void 0 ? { childLayout: m.childLayout } : {},
3251
- ...m.deviceLinks !== void 0 ? { deviceLinks: m.deviceLinks } : {},
3252
- ...m.display !== void 0 ? { display: m.display } : {},
3253
- ...sourceInfoChild !== void 0 ? { sourceInfo: sourceInfoChild } : {}
3254
- });
3255
- }
3256
- return results;
2844
+ await pctx.settings.writeAddonStore({ deviceMeta: nextMeta });
2845
+ });
2846
+ pctx.host.ctx.eventBus.emit({
2847
+ id: (0, node_crypto.randomUUID)(),
2848
+ timestamp: /* @__PURE__ */ new Date(),
2849
+ source: {
2850
+ type: "device",
2851
+ id: deviceId
2852
+ },
2853
+ category: require_dist.EventCategory.DeviceMetaChanged,
2854
+ data: {
2855
+ deviceId,
2856
+ field: "name",
2857
+ value: name
2858
+ }
2859
+ });
2860
+ for (const child of cascaded) pctx.host.ctx.eventBus.emit({
2861
+ id: (0, node_crypto.randomUUID)(),
2862
+ timestamp: /* @__PURE__ */ new Date(),
2863
+ source: {
2864
+ type: "device",
2865
+ id: child.id
2866
+ },
2867
+ category: require_dist.EventCategory.DeviceMetaChanged,
2868
+ data: {
2869
+ deviceId: child.id,
2870
+ field: "name",
2871
+ value: child.name
2872
+ }
2873
+ });
3257
2874
  }
3258
- async function getStreamSources(pctx, input) {
3259
- const { deviceId } = input;
3260
- if (pctx.registry) {
3261
- const found = resolveDeviceById(pctx.registry, deviceId);
3262
- if (found) {
3263
- if (!isCameraDevice(found.device)) return [];
3264
- return (await found.device.getStreamSources()).map((s) => ({
3265
- id: s.id,
3266
- label: s.label,
3267
- protocol: s.protocol,
3268
- url: s.url,
3269
- resolution: s.resolution,
3270
- fps: s.fps,
3271
- bitrate: s.bitrate,
3272
- codec: s.codec,
3273
- profileHint: s.profileHint
3274
- }));
2875
+ /**
2876
+ * Update the operator-organisational location label. `null`
2877
+ * clears it. Mirrors the same persist-then-emit shape as
2878
+ * `setName`; consumers subscribe to `DeviceMetaChanged` and
2879
+ * filter on `field: 'location'`.
2880
+ */
2881
+ async function setLocation(pctx, input) {
2882
+ const { deviceId, location } = input;
2883
+ await pctx.metaStore.withMetaWriteLock(async () => {
2884
+ const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
2885
+ if (!persisted) throw new Error(`[device-manager] setLocation: unknown device id=${deviceId}`);
2886
+ const { meta: m } = persisted;
2887
+ const key = String(deviceId);
2888
+ const allMeta = await pctx.metaStore.readMeta();
2889
+ await pctx.settings.writeAddonStore({ deviceMeta: {
2890
+ ...allMeta,
2891
+ [key]: {
2892
+ ...m,
2893
+ location
2894
+ }
2895
+ } });
2896
+ });
2897
+ pctx.host.ctx.eventBus.emit({
2898
+ id: (0, node_crypto.randomUUID)(),
2899
+ timestamp: /* @__PURE__ */ new Date(),
2900
+ source: {
2901
+ type: "device",
2902
+ id: deviceId
2903
+ },
2904
+ category: require_dist.EventCategory.DeviceMetaChanged,
2905
+ data: {
2906
+ deviceId,
2907
+ field: "location",
2908
+ value: location
3275
2909
  }
3276
- }
3277
- if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] Device with id ${deviceId} not found`);
3278
- return (await pctx.requireDeviceOps(deviceId).getStreamSources({ deviceId })).map((s) => ({ ...s }));
2910
+ });
3279
2911
  }
3280
- async function getConfigSchema(pctx, input) {
3281
- const { deviceId } = input;
3282
- if (pctx.registry) {
3283
- const found = resolveDeviceById(pctx.registry, deviceId);
3284
- if (found) return found.device.config.entries().map((entry) => ({
3285
- key: entry.key,
3286
- value: entry.value,
3287
- ...entry.description !== void 0 ? { description: entry.description } : {}
3288
- }));
3289
- }
3290
- if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] Device with id ${deviceId} not found`);
3291
- return (await pctx.requireDeviceOps(deviceId).getConfigEntries({ deviceId })).map((e) => ({ ...e }));
2912
+ /**
2913
+ * Update the device type. Writes the meta row, emits a
2914
+ * `DeviceMetaChanged` event. Used by the kernel to apply
2915
+ * `initialMeta.type` before device construction so the device
2916
+ * constructs with its real type instead of the `allocateDeviceId`
2917
+ * placeholder `'generic'`.
2918
+ */
2919
+ async function setType(pctx, input) {
2920
+ const { deviceId, type } = input;
2921
+ await pctx.metaStore.withMetaWriteLock(async () => {
2922
+ const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
2923
+ if (!persisted) throw new Error(`[device-manager] setType: unknown device id=${deviceId}`);
2924
+ const { meta: m } = persisted;
2925
+ const key = String(deviceId);
2926
+ const allMeta = await pctx.metaStore.readMeta();
2927
+ await pctx.settings.writeAddonStore({ deviceMeta: {
2928
+ ...allMeta,
2929
+ [key]: {
2930
+ ...m,
2931
+ type
2932
+ }
2933
+ } });
2934
+ });
2935
+ pctx.host.ctx.eventBus.emit({
2936
+ id: (0, node_crypto.randomUUID)(),
2937
+ timestamp: /* @__PURE__ */ new Date(),
2938
+ source: {
2939
+ type: "device",
2940
+ id: deviceId
2941
+ },
2942
+ category: require_dist.EventCategory.DeviceMetaChanged,
2943
+ data: {
2944
+ deviceId,
2945
+ field: "type",
2946
+ value: type
2947
+ }
2948
+ });
3292
2949
  }
3293
- async function getSettingsSchema(pctx, input) {
3294
- const { deviceId } = input;
3295
- if (pctx.registry) {
3296
- const found = resolveDeviceById(pctx.registry, deviceId);
3297
- if (found) return found.device.getSettingsUISchema();
3298
- }
3299
- if (!await pctx.metaStore.resolvePersistedById(deviceId)) return null;
3300
- return await pctx.requireDeviceOps(deviceId).getSettingsSchema({ deviceId }) ?? null;
2950
+ /**
2951
+ * Stamp (or update) the owning integration id on the device's
2952
+ * meta row. Called by the kernel's `create()` pre-seed path
2953
+ * when `initialMeta.integrationId` is set (analogous to
2954
+ * `setName` / `setType`). Idempotent.
2955
+ */
2956
+ async function setIntegrationId(pctx, input) {
2957
+ await pctx.stampIntegrationId(input.deviceId, input.integrationId);
3301
2958
  }
3302
- async function updateConfig(pctx, input) {
3303
- const { deviceId } = input;
3304
- if (pctx.registry) {
3305
- const found = resolveDeviceById(pctx.registry, deviceId);
3306
- if (found) {
3307
- await found.device.config.setAll(input.values);
3308
- return { success: true };
2959
+ /**
2960
+ * Stamp (or update) the linked device id on the device's meta
2961
+ * row. Called by the kernel's `create()` pre-seed path when
2962
+ * `initialMeta.linkDeviceId` is set (analogous to `setIntegrationId`).
2963
+ * Idempotent. `null` explicitly clears a previous link.
2964
+ */
2965
+ async function setLinkDeviceId(pctx, input) {
2966
+ const { deviceId, linkDeviceId } = input;
2967
+ await pctx.metaStore.withMetaWriteLock(async () => {
2968
+ const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
2969
+ if (!persisted) throw new Error(`[device-manager] setLinkDeviceId: unknown device id=${deviceId}`);
2970
+ const { meta: m } = persisted;
2971
+ const key = String(deviceId);
2972
+ const allMeta = await pctx.metaStore.readMeta();
2973
+ await pctx.settings.writeAddonStore({ deviceMeta: {
2974
+ ...allMeta,
2975
+ [key]: {
2976
+ ...m,
2977
+ linkDeviceId
2978
+ }
2979
+ } });
2980
+ });
2981
+ pctx.host.ctx.eventBus.emit({
2982
+ id: (0, node_crypto.randomUUID)(),
2983
+ timestamp: /* @__PURE__ */ new Date(),
2984
+ source: {
2985
+ type: "device",
2986
+ id: deviceId
2987
+ },
2988
+ category: require_dist.EventCategory.DeviceMetaChanged,
2989
+ data: {
2990
+ deviceId,
2991
+ field: "linkDeviceId",
2992
+ value: linkDeviceId
3309
2993
  }
3310
- }
3311
- if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] Device with id ${deviceId} not found`);
3312
- await pctx.requireDeviceOps(deviceId).setConfig({
3313
- deviceId,
3314
- values: input.values
3315
2994
  });
3316
- return { success: true };
3317
2995
  }
3318
- async function enable(pctx, input) {
3319
- await pctx.provider.setDisabled({
3320
- deviceId: input.deviceId,
3321
- disabled: false
2996
+ /**
2997
+ * Set (or clear) the durable primary-child override on a CONTAINER
2998
+ * device's meta row, keyed on the chosen child's re-sync/rename-stable
2999
+ * `entityId`. Mirrors `setLinkDeviceId` but persists a string entityId
3000
+ * (survives a re-sync that reallocates the child's numeric id) instead
3001
+ * of the numeric child id. `null` clears the override → priority default.
3002
+ */
3003
+ async function setPrimaryChildEntityId(pctx, input) {
3004
+ const { deviceId, primaryChildEntityId } = input;
3005
+ await pctx.metaStore.withMetaWriteLock(async () => {
3006
+ const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
3007
+ if (!persisted) throw new Error(`[device-manager] setPrimaryChildEntityId: unknown device id=${deviceId}`);
3008
+ const { meta: m } = persisted;
3009
+ const key = String(deviceId);
3010
+ const allMeta = await pctx.metaStore.readMeta();
3011
+ await pctx.settings.writeAddonStore({ deviceMeta: {
3012
+ ...allMeta,
3013
+ [key]: {
3014
+ ...m,
3015
+ primaryChildEntityId
3016
+ }
3017
+ } });
3018
+ });
3019
+ pctx.host.ctx.eventBus.emit({
3020
+ id: (0, node_crypto.randomUUID)(),
3021
+ timestamp: /* @__PURE__ */ new Date(),
3022
+ source: {
3023
+ type: "device",
3024
+ id: deviceId
3025
+ },
3026
+ category: require_dist.EventCategory.DeviceMetaChanged,
3027
+ data: {
3028
+ deviceId,
3029
+ field: "primaryChildEntityId",
3030
+ value: primaryChildEntityId
3031
+ }
3322
3032
  });
3323
- return { success: true };
3324
3033
  }
3325
- async function disable(pctx, input) {
3326
- await pctx.provider.setDisabled({
3327
- deviceId: input.deviceId,
3328
- disabled: true
3034
+ /**
3035
+ * Set (or replace) the container-level `childLayout` on a device's
3036
+ * meta row. Mirrors `setPrimaryChildEntityId` but persists the
3037
+ * accordion-section layout array (parent-only). Persisted, projected,
3038
+ * and preserved across re-register/restore.
3039
+ */
3040
+ async function setChildLayout(pctx, input) {
3041
+ const { deviceId, childLayout } = input;
3042
+ await pctx.metaStore.withMetaWriteLock(async () => {
3043
+ const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
3044
+ if (!persisted) throw new Error(`[device-manager] setChildLayout: unknown device id=${deviceId}`);
3045
+ const { meta: m } = persisted;
3046
+ const key = String(deviceId);
3047
+ const allMeta = await pctx.metaStore.readMeta();
3048
+ await pctx.settings.writeAddonStore({ deviceMeta: {
3049
+ ...allMeta,
3050
+ [key]: {
3051
+ ...m,
3052
+ childLayout
3053
+ }
3054
+ } });
3055
+ });
3056
+ pctx.host.ctx.eventBus.emit({
3057
+ id: (0, node_crypto.randomUUID)(),
3058
+ timestamp: /* @__PURE__ */ new Date(),
3059
+ source: {
3060
+ type: "device",
3061
+ id: deviceId
3062
+ },
3063
+ category: require_dist.EventCategory.DeviceMetaChanged,
3064
+ data: {
3065
+ deviceId,
3066
+ field: "childLayout",
3067
+ value: childLayout
3068
+ }
3069
+ });
3070
+ }
3071
+ /**
3072
+ * Set (or replace) the operator-authored cross-device field wirings on
3073
+ * a device's meta row. Mirrors `setChildLayout` but persists the
3074
+ * `deviceLinks` array. Persisted, projected, and preserved across
3075
+ * re-register/restore. Also maintains the in-memory `devicesWithLinks`
3076
+ * Set used as an O(1) gate by `resolveLinkedStatus`.
3077
+ */
3078
+ async function setDeviceLinks(pctx, input) {
3079
+ const { deviceId, deviceLinks } = input;
3080
+ const cycle = findDeviceLinkCycle(await pctx.metaStore.readMeta(), deviceId, deviceLinks);
3081
+ if (cycle) throw new Error(`[device-manager] setDeviceLinks: cross-device link cycle: ${cycle.join(" → ")}`);
3082
+ for (const link of deviceLinks) {
3083
+ if (link.source.kind !== "expression") continue;
3084
+ const err = require_dist.validateExpressionSource(link.source);
3085
+ if (err) throw new Error(`[device-manager] setDeviceLinks: invalid expression on link '${link.id}': ${err}`);
3086
+ }
3087
+ await pctx.metaStore.withMetaWriteLock(async () => {
3088
+ const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
3089
+ if (!persisted) throw new Error(`[device-manager] setDeviceLinks: unknown device id=${deviceId}`);
3090
+ const { meta: m } = persisted;
3091
+ const key = String(deviceId);
3092
+ const allMeta = await pctx.metaStore.readMeta();
3093
+ await pctx.settings.writeAddonStore({ deviceMeta: {
3094
+ ...allMeta,
3095
+ [key]: {
3096
+ ...m,
3097
+ deviceLinks
3098
+ }
3099
+ } });
3100
+ });
3101
+ if (deviceLinks.length > 0) pctx.host.devicesWithLinks.add(deviceId);
3102
+ else pctx.host.devicesWithLinks.delete(deviceId);
3103
+ await pctx.metaStore.rebuildLinkDependents();
3104
+ pctx.host.ctx.eventBus.emit({
3105
+ id: (0, node_crypto.randomUUID)(),
3106
+ timestamp: /* @__PURE__ */ new Date(),
3107
+ source: {
3108
+ type: "device",
3109
+ id: deviceId
3110
+ },
3111
+ category: require_dist.EventCategory.DeviceMetaChanged,
3112
+ data: {
3113
+ deviceId,
3114
+ field: "deviceLinks",
3115
+ value: deviceLinks
3116
+ }
3329
3117
  });
3330
- return { success: true };
3331
3118
  }
3332
- async function remove(pctx, input) {
3333
- const { deviceId } = input;
3334
- const removeOne = async (id) => {
3335
- if (pctx.registry) {
3336
- const live = resolveDeviceById(pctx.registry, id);
3337
- if (live) {
3338
- const deviceName = live.device.name;
3339
- await live.device.removeDevice();
3340
- pctx.registry.remove(id);
3341
- await pctx.provider.removeDevice({ deviceId: id });
3342
- pctx.host.ctx.logger.info("removed hub-local device", { tags: {
3343
- deviceId: id,
3344
- deviceName
3345
- } });
3346
- return;
3119
+ /**
3120
+ * Stamp (or update) the semantic role on the device's meta row.
3121
+ * Called by the kernel's `create()` / `spawnAccessoryChild`
3122
+ * pre-seed when `initialMeta.role` is set (analogous to
3123
+ * `setIntegrationId`). Idempotent. `null` clears a previous role.
3124
+ */
3125
+ async function setRole(pctx, input) {
3126
+ const { deviceId, role } = input;
3127
+ await pctx.metaStore.withMetaWriteLock(async () => {
3128
+ const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
3129
+ if (!persisted) throw new Error(`[device-manager] setRole: unknown device id=${deviceId}`);
3130
+ const { meta: m } = persisted;
3131
+ const key = String(deviceId);
3132
+ const allMeta = await pctx.metaStore.readMeta();
3133
+ await pctx.settings.writeAddonStore({ deviceMeta: {
3134
+ ...allMeta,
3135
+ [key]: {
3136
+ ...m,
3137
+ role
3347
3138
  }
3139
+ } });
3140
+ });
3141
+ pctx.host.ctx.eventBus.emit({
3142
+ id: (0, node_crypto.randomUUID)(),
3143
+ timestamp: /* @__PURE__ */ new Date(),
3144
+ source: {
3145
+ type: "device",
3146
+ id: deviceId
3147
+ },
3148
+ category: require_dist.EventCategory.DeviceMetaChanged,
3149
+ data: {
3150
+ deviceId,
3151
+ field: "role",
3152
+ value: role
3348
3153
  }
3349
- const persisted = await pctx.metaStore.resolvePersistedById(id);
3350
- if (!persisted) return;
3351
- const { meta: persistedMeta } = persisted;
3352
- try {
3353
- await pctx.requireDeviceOps(id).removeDevice({ deviceId: id });
3354
- } catch (err) {
3355
- pctx.host.ctx.logger.warn("remove via device-ops failed clearing persistence anyway", {
3356
- tags: {
3357
- deviceId: id,
3358
- deviceName: persistedMeta.name
3359
- },
3360
- meta: { error: require_dist.errMsg(err) }
3361
- });
3362
- }
3363
- await pctx.provider.removeDevice({ deviceId: id });
3364
- };
3365
- const removeCascade = async (id) => {
3366
- for (const childId of await pctx.metaStore.directChildIds(id)) await removeCascade(childId);
3367
- await removeOne(id);
3154
+ });
3155
+ }
3156
+ /**
3157
+ * Normalize an operator-authored display override at write time so the render
3158
+ * path's `UNIT_TABLE` lookups always hit canonical spellings — maps `unit` and
3159
+ * every `perCap[*].unit` through `normalizeUnit`. Pure: rebuilds new objects,
3160
+ * never mutates the input. A unit `normalizeUnit` cannot canonicalize is left
3161
+ * as-is (the render path refuses conversion for unknown spellings).
3162
+ */
3163
+ function normalizeDisplayOverride(display) {
3164
+ return {
3165
+ ...display,
3166
+ ...display.unit !== void 0 ? { unit: require_dist.normalizeUnit(display.unit) ?? display.unit } : {},
3167
+ ...display.perCap !== void 0 ? { perCap: Object.fromEntries(Object.entries(display.perCap).map(([cap, refine]) => [cap, refine.unit !== void 0 ? {
3168
+ ...refine,
3169
+ unit: require_dist.normalizeUnit(refine.unit) ?? refine.unit
3170
+ } : refine])) } : {}
3368
3171
  };
3369
- await removeCascade(deviceId);
3370
- return { success: true };
3371
3172
  }
3372
3173
  /**
3373
- * Cascade-delete every top-level device whose `integrationId`
3374
- * matches. Enumerates a SNAPSHOT of the meta map so concurrent
3375
- * removals don't clobber each other. Only top-level parents are
3376
- * enumerated children cascade via the per-parent `removeCascade`
3377
- * inside the delegated `remove` call. Idempotent: devices with no
3378
- * `integrationId` never match.
3174
+ * Set (or clear) the per-device display override on a device's meta row.
3175
+ * Mirrors `setChildLayout` persistence; `null` REMOVES the `display` key
3176
+ * entirely (immutable rest-destructure matches the "absent no override"
3177
+ * projection contract, never persists `display: undefined`). Override units are
3178
+ * normalized at write so the render path always looks up canonical spellings.
3379
3179
  */
3380
- async function removeByIntegration(pctx, input) {
3381
- const { integrationId } = input;
3382
- const meta = await pctx.metaStore.readMeta();
3383
- const parentKeys = Object.keys(meta).filter((key) => {
3384
- const m = meta[key];
3385
- return m !== void 0 && m.integrationId === integrationId && m.parentDeviceId === null;
3180
+ async function setDisplay(pctx, input) {
3181
+ const { deviceId, display } = input;
3182
+ const normalized = display === null ? null : normalizeDisplayOverride(display);
3183
+ await pctx.metaStore.withMetaWriteLock(async () => {
3184
+ const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
3185
+ if (!persisted) throw new Error(`[device-manager] setDisplay: unknown device id=${deviceId}`);
3186
+ const { meta: m } = persisted;
3187
+ const key = String(deviceId);
3188
+ const allMeta = await pctx.metaStore.readMeta();
3189
+ const nextRow = normalized === null ? (({ display: _drop, ...rest }) => rest)(m) : {
3190
+ ...m,
3191
+ display: normalized
3192
+ };
3193
+ await pctx.settings.writeAddonStore({ deviceMeta: {
3194
+ ...allMeta,
3195
+ [key]: nextRow
3196
+ } });
3386
3197
  });
3387
- let removed = 0;
3388
- for (const _key of parentKeys) {
3389
- const m = meta[_key];
3390
- if (!m) continue;
3391
- await pctx.provider.remove({ deviceId: m.id });
3392
- removed++;
3393
- }
3394
- return { removed };
3395
- }
3396
- async function getStreamProfileMap(pctx, input) {
3397
- if (!pctx.registry) return {};
3398
- const found = resolveDeviceById(pctx.registry, input.deviceId);
3399
- if (!found) return {};
3400
- const storedMap = found.device.config.entries().find((e) => e.key === "_profileMap")?.value;
3401
- if (storedMap !== void 0 && typeof storedMap === "object" && storedMap !== null) return storedMap;
3402
- if (!isCameraDevice(found.device)) return {};
3403
- const sources = await found.device.getStreamSources();
3404
- const profileMap = {};
3405
- for (const s of sources) if (s.profileHint && s.id) profileMap[s.profileHint] = s.id;
3406
- return profileMap;
3407
- }
3408
- async function setStreamProfileMap(pctx, input) {
3409
- const { deviceId } = input;
3410
- if (pctx.registry) {
3411
- const found = resolveDeviceById(pctx.registry, deviceId);
3412
- if (found) {
3413
- await found.device.config.setAll({ _profileMap: input.profileMap });
3414
- return { success: true };
3198
+ pctx.host.ctx.eventBus.emit({
3199
+ id: (0, node_crypto.randomUUID)(),
3200
+ timestamp: /* @__PURE__ */ new Date(),
3201
+ source: {
3202
+ type: "device",
3203
+ id: deviceId
3204
+ },
3205
+ category: require_dist.EventCategory.DeviceMetaChanged,
3206
+ data: {
3207
+ deviceId,
3208
+ field: "display",
3209
+ value: normalized
3415
3210
  }
3416
- }
3417
- if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] Device with id ${deviceId} not found`);
3418
- await pctx.requireDeviceOps(deviceId).setConfig({
3419
- deviceId,
3420
- values: { _profileMap: input.profileMap }
3421
3211
  });
3422
- return { success: true };
3423
- }
3424
- async function probeStreams(pctx, input) {
3425
- const streamProbe = pctx.host.ctx.kernel.streamProbe;
3426
- if (!streamProbe) return [];
3427
- const sources = await pctx.provider.getStreamSources({ deviceId: input.deviceId });
3428
- const results = [];
3429
- for (const s of sources) {
3430
- if (!s.url) continue;
3431
- try {
3432
- const metadata = await streamProbe.probe(s.url, { force: true });
3433
- results.push({
3434
- streamId: s.id,
3435
- width: metadata.width,
3436
- height: metadata.height,
3437
- codec: metadata.codec,
3438
- fps: metadata.fps,
3439
- bitrateKbps: metadata.bitrateKbps
3440
- });
3441
- } catch (err) {
3442
- pctx.host.ctx.logger.debug("streamProbe.probe failed — returning placeholder", { meta: {
3443
- deviceId: input.deviceId,
3444
- streamId: s.id,
3445
- error: err instanceof Error ? err.message : String(err)
3446
- } });
3447
- results.push({ streamId: s.id });
3448
- }
3449
- }
3450
- return results;
3451
- }
3452
- async function discoverDevices(pctx, input) {
3453
- const dp = await pctx.host.requireDeviceProvider(input.addonId);
3454
- if (!await dp.supportsDiscovery({})) throw new Error(`Addon "${input.addonId}" does not support device discovery`);
3455
- return (await dp.discoverDevices({})).map((d) => ({
3456
- stableId: d.stableId,
3457
- type: d.type,
3458
- suggestedName: d.suggestedName,
3459
- prefilledConfig: d.prefilledConfig
3460
- }));
3461
3212
  }
3462
- async function adoptDevice(pctx, input) {
3463
- const dp = await pctx.host.requireDeviceProvider(input.addonId);
3464
- if (!await dp.supportsDiscovery({})) throw new Error(`Addon "${input.addonId}" does not support device adoption`);
3465
- const summary = await dp.adoptDiscoveredDevice({ candidate: input.candidate });
3466
- if (input.integrationId !== void 0) try {
3467
- await pctx.stampIntegrationId(summary.id, input.integrationId);
3468
- } catch (err) {
3469
- pctx.host.ctx.logger.warn("adoptDevice: integrationId stamp failed (device adopted)", {
3470
- tags: {
3471
- deviceId: summary.id,
3472
- integrationId: input.integrationId
3473
- },
3474
- meta: { error: require_dist.errMsg(err) }
3475
- });
3476
- }
3477
- return summary;
3213
+ /**
3214
+ * Read the operator-authored per-role display defaults. Empty record when none
3215
+ * set. Not per-device a plain top-level-key read.
3216
+ */
3217
+ async function getRoleDisplayDefaults(pctx, _input) {
3218
+ return { defaults: (await pctx.metaStore.readStore()).roleDisplayDefaults ?? {} };
3478
3219
  }
3479
- async function getCreationSchema(pctx, input) {
3480
- const dp = await pctx.host.requireDeviceProvider(input.addonId);
3481
- if (!await dp.supportsManualCreation({})) return null;
3482
- return await dp.getChildCreationSchema({ type: input.type }) ?? null;
3220
+ /**
3221
+ * Replace the per-role display defaults whole-record (full replace). Override
3222
+ * units are normalized (`normalizeUnit`) at write so the render path always
3223
+ * looks up canonical spellings. Single-writer top-level key — no lock, no RMW,
3224
+ * no interaction with the `deviceMeta` write lock. Not per-device, so no event
3225
+ * is emitted; the UI invalidates its own query on mutate.
3226
+ */
3227
+ async function setRoleDisplayDefaults(pctx, input) {
3228
+ const normalized = Object.fromEntries(Object.entries(input.defaults).map(([role, def]) => [role, def.unit !== void 0 ? {
3229
+ ...def,
3230
+ unit: require_dist.normalizeUnit(def.unit) ?? def.unit
3231
+ } : def]));
3232
+ await pctx.settings.writeAddonStore({ roleDisplayDefaults: normalized });
3483
3233
  }
3484
- async function createDevice(pctx, input) {
3485
- const dp = await pctx.host.requireDeviceProvider(input.addonId);
3486
- if (!await dp.supportsManualCreation({})) throw new Error(`Addon "${input.addonId}" does not support manual device creation`);
3487
- const summary = await dp.createDevice({
3488
- type: input.type,
3489
- config: input.config
3234
+ /**
3235
+ * Batched meta pre-seed. Applies every provided field to the
3236
+ * device's meta row in ONE read-modify-write under a single
3237
+ * `withMetaWriteLock` acquisition (one `deviceMeta` blob write),
3238
+ * then emits one `DeviceMetaChanged` event per field that was
3239
+ * supplied — preserving the exact semantics of the individual
3240
+ * setters (`setName` / `setLocation` / `setType` /
3241
+ * `setIntegrationId` / `setLinkDeviceId` / `setRole`). Omitted
3242
+ * fields are left untouched; `null` clears `location` /
3243
+ * `linkDeviceId` / `role`. Idempotent.
3244
+ *
3245
+ * Collapses the per-child meta pre-seed (up to 6 individual
3246
+ * setter round-trips, each its own lock + write) into one — the
3247
+ * dominant cost when the kernel's `spawnAccessoryChild` reconciles
3248
+ * a many-entity container.
3249
+ */
3250
+ async function applyInitialMeta(pctx, input) {
3251
+ const { deviceId, name, location, type, integrationId, linkDeviceId, role } = input;
3252
+ await pctx.metaStore.withMetaWriteLock(async () => {
3253
+ const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
3254
+ if (!persisted) throw new Error(`[device-manager] applyInitialMeta: unknown device id=${deviceId}`);
3255
+ const { meta: m } = persisted;
3256
+ const key = String(deviceId);
3257
+ const allMeta = await pctx.metaStore.readMeta();
3258
+ const merged = {
3259
+ ...m,
3260
+ ...name !== void 0 ? { name } : {},
3261
+ ...location !== void 0 ? { location } : {},
3262
+ ...type !== void 0 ? { type } : {},
3263
+ ...integrationId !== void 0 ? { integrationId } : {},
3264
+ ...linkDeviceId !== void 0 ? { linkDeviceId } : {},
3265
+ ...role !== void 0 ? { role } : {}
3266
+ };
3267
+ await pctx.settings.writeAddonStore({ deviceMeta: {
3268
+ ...allMeta,
3269
+ [key]: merged
3270
+ } });
3490
3271
  });
3491
- if (input.integrationId !== void 0) try {
3492
- await pctx.stampIntegrationId(summary.id, input.integrationId);
3493
- } catch (err) {
3494
- pctx.host.ctx.logger.warn("createDevice: integrationId stamp failed (device created)", {
3495
- tags: {
3496
- deviceId: summary.id,
3497
- integrationId: input.integrationId
3272
+ const emitMetaChanged = (field, value) => {
3273
+ pctx.host.ctx.eventBus.emit({
3274
+ id: (0, node_crypto.randomUUID)(),
3275
+ timestamp: /* @__PURE__ */ new Date(),
3276
+ source: {
3277
+ type: "device",
3278
+ id: deviceId
3498
3279
  },
3499
- meta: { error: require_dist.errMsg(err) }
3280
+ category: require_dist.EventCategory.DeviceMetaChanged,
3281
+ data: {
3282
+ deviceId,
3283
+ field,
3284
+ value
3285
+ }
3500
3286
  });
3501
- }
3502
- return summary;
3287
+ };
3288
+ if (name !== void 0) emitMetaChanged("name", name);
3289
+ if (location !== void 0) emitMetaChanged("location", location);
3290
+ if (type !== void 0) emitMetaChanged("type", type);
3291
+ if (integrationId !== void 0) emitMetaChanged("integrationId", integrationId);
3292
+ if (linkDeviceId !== void 0) emitMetaChanged("linkDeviceId", linkDeviceId);
3293
+ if (role !== void 0) emitMetaChanged("role", role);
3503
3294
  }
3504
- async function testCreationField(pctx, input) {
3505
- return (await pctx.host.requireDeviceProvider(input.addonId)).testCreationField({
3506
- type: input.type,
3507
- key: input.key,
3508
- value: input.value,
3509
- ...input.formValues !== void 0 ? { formValues: input.formValues } : {}
3295
+ /**
3296
+ * Patch the device's hardware-identity metadata blob. Shallow
3297
+ * merge — `null` removes a key, anything else overwrites.
3298
+ * Drivers populate factual fields on first probe; operators
3299
+ * augment via the Device Info tab. Idempotent: a no-op patch
3300
+ * (every key already present with the same value) doesn't emit
3301
+ * the meta-changed event.
3302
+ */
3303
+ async function setMetadata(pctx, input) {
3304
+ const { deviceId, patch } = input;
3305
+ const result = await pctx.metaStore.withMetaWriteLock(async () => {
3306
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] setMetadata: unknown device id=${deviceId}`);
3307
+ const key = String(deviceId);
3308
+ const map = await pctx.metaStore.readMetadataMap();
3309
+ const next = { ...map[key] ?? {} };
3310
+ let changed = false;
3311
+ for (const [k, v] of Object.entries(patch)) if (v === null) {
3312
+ if (k in next) {
3313
+ delete next[k];
3314
+ changed = true;
3315
+ }
3316
+ } else if (next[k] !== v) {
3317
+ next[k] = v;
3318
+ changed = true;
3319
+ }
3320
+ if (!changed) return { changed: false };
3321
+ const hasFields = Object.keys(next).length > 0;
3322
+ const updatedMap = { ...map };
3323
+ if (hasFields) updatedMap[key] = next;
3324
+ else delete updatedMap[key];
3325
+ await pctx.settings.writeAddonStore({ deviceMetadata: updatedMap });
3326
+ return {
3327
+ changed: true,
3328
+ finalMeta: hasFields ? next : null
3329
+ };
3330
+ });
3331
+ if (!result.changed) return;
3332
+ pctx.host.ctx.eventBus.emit({
3333
+ id: (0, node_crypto.randomUUID)(),
3334
+ timestamp: /* @__PURE__ */ new Date(),
3335
+ source: {
3336
+ type: "device",
3337
+ id: deviceId
3338
+ },
3339
+ category: require_dist.EventCategory.DeviceMetaChanged,
3340
+ data: {
3341
+ deviceId,
3342
+ field: "metadata",
3343
+ value: result.finalMeta
3344
+ }
3510
3345
  });
3511
3346
  }
3512
- async function adoptionListCandidates(pctx, input) {
3513
- const { addonId, ...rest } = input;
3514
- return (await pctx.host.requireDeviceAdoptionProvider(addonId)).listCandidates(rest);
3347
+ /**
3348
+ * Soft-disable the device. Persisted on the meta row;
3349
+ * lifecycle gating is the driver's responsibility (BaseDevice
3350
+ * exposes `this.disabled` for the driver to consult at the top
3351
+ * of its lifecycle methods).
3352
+ */
3353
+ async function setDisabled(pctx, input) {
3354
+ const { deviceId, disabled } = input;
3355
+ await pctx.metaStore.withMetaWriteLock(async () => {
3356
+ const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
3357
+ if (!persisted) throw new Error(`[device-manager] setDisabled: unknown device id=${deviceId}`);
3358
+ const { meta: m } = persisted;
3359
+ const key = String(deviceId);
3360
+ const allMeta = await pctx.metaStore.readMeta();
3361
+ await pctx.settings.writeAddonStore({ deviceMeta: {
3362
+ ...allMeta,
3363
+ [key]: {
3364
+ ...m,
3365
+ disabled
3366
+ }
3367
+ } });
3368
+ });
3369
+ pctx.host.ctx.eventBus.emit({
3370
+ id: (0, node_crypto.randomUUID)(),
3371
+ timestamp: /* @__PURE__ */ new Date(),
3372
+ source: {
3373
+ type: "device",
3374
+ id: deviceId
3375
+ },
3376
+ category: require_dist.EventCategory.DeviceMetaChanged,
3377
+ data: {
3378
+ deviceId,
3379
+ field: "disabled",
3380
+ value: disabled
3381
+ }
3382
+ });
3515
3383
  }
3516
- async function adoptionRefresh(pctx, input) {
3517
- const { addonId, integrationId } = input;
3518
- return (await pctx.host.requireDeviceAdoptionProvider(addonId)).refresh({ integrationId });
3384
+ async function loadRuntimeState(pctx, input) {
3385
+ const { deviceId } = input;
3386
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) return {};
3387
+ const data = await pctx.settings.readDeviceRuntimeState(deviceId);
3388
+ pctx.host.seedMirror(deviceId, pctx.host.withResetSessionProbe(data));
3389
+ return data;
3519
3390
  }
3520
- async function adoptionAdopt(pctx, input) {
3521
- const { addonId, ...rest } = input;
3522
- return (await pctx.host.requireDeviceAdoptionProvider(addonId)).adopt(rest);
3391
+ /**
3392
+ * Union of (1) operator-curated location registry and (2) labels
3393
+ * currently in use on persisted devices. Case-insensitive
3394
+ * dedupe (preserves the first-seen casing). Sorted
3395
+ * case-insensitively for stable UI. Drives the Device Info
3396
+ * location autocomplete.
3397
+ */
3398
+ async function listLocations(pctx) {
3399
+ const store = await pctx.settings.readAddonStore();
3400
+ const meta = store.deviceMeta ?? {};
3401
+ const locations = store.locations ?? [];
3402
+ const seen = /* @__PURE__ */ new Map();
3403
+ const consider = (raw) => {
3404
+ if (typeof raw !== "string") return;
3405
+ const trimmed = raw.trim();
3406
+ if (trimmed.length === 0) return;
3407
+ const key = trimmed.toLowerCase();
3408
+ if (!seen.has(key)) seen.set(key, trimmed);
3409
+ };
3410
+ for (const label of locations) consider(label);
3411
+ for (const m of Object.values(meta)) consider(m.location);
3412
+ return [...seen.values()].toSorted((a, b) => a.localeCompare(b, void 0, { sensitivity: "base" }));
3523
3413
  }
3524
- async function adoptionRelease(pctx, input) {
3525
- const { addonId, ...rest } = input;
3526
- return (await pctx.host.requireDeviceAdoptionProvider(addonId)).release(rest);
3414
+ /**
3415
+ * Add a label to the curated location registry. Idempotent:
3416
+ * existing entries (case-insensitive match) are silently kept.
3417
+ * Empty / whitespace-only inputs throw — operators must supply a
3418
+ * meaningful label.
3419
+ */
3420
+ async function addLocation(pctx, input) {
3421
+ const trimmed = input.name.trim();
3422
+ if (trimmed.length === 0) throw new Error("[device-manager] addLocation: name must be non-empty");
3423
+ const current = (await pctx.settings.readAddonStore()).locations ?? [];
3424
+ if (current.some((l) => l.toLowerCase() === trimmed.toLowerCase())) return;
3425
+ await pctx.settings.writeAddonStore({ locations: [...current, trimmed] });
3527
3426
  }
3528
- async function adoptionResync(pctx, input) {
3529
- const { camDeviceId, resetToSource } = input;
3530
- let owningAddonId = pctx.registry?.getAddonId(camDeviceId) ?? null;
3531
- if (!owningAddonId) owningAddonId = (await pctx.metaStore.resolvePersistedById(camDeviceId))?.addonId ?? null;
3532
- if (!owningAddonId) throw new Error(`adoptionResync: device ${camDeviceId} not found`);
3533
- let removedChildren = 0;
3534
- if (resetToSource === true) {
3535
- const childIds = await pctx.metaStore.directChildIds(camDeviceId);
3536
- for (const childId of childIds) {
3537
- await pctx.provider.remove({ deviceId: childId });
3538
- removedChildren += 1;
3427
+ /**
3428
+ * Remove a label from the curated registry. Match is
3429
+ * case-insensitive. Devices that still reference this label keep
3430
+ * their `meta.location` value (the registry is a suggestion
3431
+ * list, not a foreign key) pass `cascade: true` to also clear
3432
+ * `setLocation` on every device that referenced this exact
3433
+ * label. Cascade only matches case-insensitively + trimmed, same
3434
+ * as the registry equality check.
3435
+ */
3436
+ async function removeLocation(pctx, input) {
3437
+ const trimmed = input.name.trim();
3438
+ if (trimmed.length === 0) return;
3439
+ const store = await pctx.settings.readAddonStore();
3440
+ const current = store.locations ?? [];
3441
+ const remaining = current.filter((l) => l.toLowerCase() !== trimmed.toLowerCase());
3442
+ if (remaining.length !== current.length) await pctx.settings.writeAddonStore({ locations: remaining });
3443
+ if (input.cascade !== true) return;
3444
+ const meta = store.deviceMeta ?? {};
3445
+ const updates = { ...meta };
3446
+ const cleared = [];
3447
+ for (const [key, m] of Object.entries(meta)) {
3448
+ if (typeof m.location !== "string") continue;
3449
+ if (m.location.trim().toLowerCase() !== trimmed.toLowerCase()) continue;
3450
+ updates[key] = {
3451
+ ...m,
3452
+ location: null
3453
+ };
3454
+ cleared.push(m.id);
3455
+ }
3456
+ if (cleared.length === 0) return;
3457
+ await pctx.settings.writeAddonStore({ deviceMeta: updates });
3458
+ for (const deviceId of cleared) pctx.host.ctx.eventBus.emit({
3459
+ id: (0, node_crypto.randomUUID)(),
3460
+ timestamp: /* @__PURE__ */ new Date(),
3461
+ source: {
3462
+ type: "device",
3463
+ id: deviceId
3464
+ },
3465
+ category: require_dist.EventCategory.DeviceMetaChanged,
3466
+ data: {
3467
+ deviceId,
3468
+ field: "location",
3469
+ value: null
3539
3470
  }
3540
- pctx.host.ctx.logger.info("resetToSource purge before resync", { tags: {
3541
- deviceId: camDeviceId,
3542
- removedChildren
3543
- } });
3471
+ });
3472
+ }
3473
+ //#endregion
3474
+ //#region src/builtins/device-manager/device-link-overlay.ts
3475
+ /** Build both lookup maps from resolved link entries. Pure — order-preserving. */
3476
+ function buildLinkIndexes(entries) {
3477
+ const targets = /* @__PURE__ */ new Map();
3478
+ const dependents = /* @__PURE__ */ new Map();
3479
+ for (const { targetDeviceId, link, sourceDeviceId, bindingSourceIds } of entries) {
3480
+ const tKey = `${targetDeviceId}:${link.target.cap}`;
3481
+ const tList = targets.get(tKey) ?? [];
3482
+ tList.push({
3483
+ link,
3484
+ sourceDeviceId,
3485
+ ...bindingSourceIds !== void 0 ? { bindingSourceIds } : {}
3486
+ });
3487
+ targets.set(tKey, tList);
3488
+ const addDependent = (sourceId, sourceCap) => {
3489
+ const sKey = `${sourceId}:${sourceCap}`;
3490
+ const sList = dependents.get(sKey) ?? [];
3491
+ sList.push({
3492
+ targetDeviceId,
3493
+ targetCap: link.target.cap
3494
+ });
3495
+ dependents.set(sKey, sList);
3496
+ };
3497
+ if (link.source.kind === "expression") {
3498
+ if (bindingSourceIds !== void 0) for (const [name, bindingDeviceId] of Object.entries(bindingSourceIds)) {
3499
+ const binding = link.source.bindings[name];
3500
+ if (binding === void 0 || binding.kind === "literal") continue;
3501
+ addDependent(bindingDeviceId, binding.cap);
3502
+ }
3503
+ continue;
3504
+ }
3505
+ if (link.source.kind !== "literal") addDependent(sourceDeviceId, link.source.cap);
3544
3506
  }
3545
3507
  return {
3546
- ...await (await pctx.host.requireDeviceAdoptionProvider(owningAddonId)).resync({
3547
- camDeviceId,
3548
- resetToSource
3549
- }),
3550
- removedChildren
3508
+ targets,
3509
+ dependents
3551
3510
  };
3552
3511
  }
3553
- async function testField(pctx, input) {
3554
- const { deviceId } = input;
3555
- let owningAddonId = null;
3556
- if (pctx.registry) owningAddonId = pctx.registry.getAddonId(deviceId);
3557
- if (!owningAddonId) owningAddonId = (await pctx.metaStore.resolvePersistedById(deviceId))?.addonId ?? null;
3558
- if (!owningAddonId) throw new Error(`Device with id ${deviceId} not found`);
3559
- const dp = await pctx.host.waitDeviceProvider(owningAddonId);
3560
- if (!dp) return {
3561
- status: "ok",
3562
- labels: [],
3563
- error: void 0
3512
+ //#endregion
3513
+ //#region src/builtins/device-manager/device-meta-store.ts
3514
+ /**
3515
+ * Meta persistence layer for the device-manager addon.
3516
+ *
3517
+ * `DeviceMetaStore` encapsulates the read/modify/write helpers that previously
3518
+ * lived as closures inside `onInitialize`: the `ctx.settings`-backed reads
3519
+ * (`readStore` / `readIndex` / `readMeta` / `readMetadataMap`), the
3520
+ * single-writer mutex (`withMetaWriteLock`), id allocation
3521
+ * (`allocateNextDeviceId`), ownership/child resolution, link-source resolution,
3522
+ * and the cross-device link reverse-index rebuild. Extracting it as a class
3523
+ * (constructed once per `onInitialize`) keeps the EXACT semantics — the
3524
+ * `withMetaWriteLock` promise-chain is per-instance state, identical to the
3525
+ * former closure — while letting the meta-action + query modules depend on a
3526
+ * named, injectable surface instead of captured closures.
3527
+ *
3528
+ * Cross-device link state (the `linkTargets` / `linkDependents` /
3529
+ * `expectedSourceStableIds` maps + the `devicesWithLinks` gate) lives on the
3530
+ * addon; the store reaches it through the injected `LinkIndexHost` so the
3531
+ * single owner of that state stays the addon instance.
3532
+ */
3533
+ var DeviceMetaStore = class {
3534
+ settings;
3535
+ registry;
3536
+ linkHost;
3537
+ /** Synchronous ownership cache, keyed by NUMERIC deviceId → owning addonId.
3538
+ * The persisted meta store is authoritative but reads are async; hub-side
3539
+ * callers (e.g. `CapabilityRegistry.getNativeProvider` fallback) need
3540
+ * ownership without awaiting. Kept in sync with every register/remove and
3541
+ * warmed from persistence on boot. */
3542
+ idToAddonId = /* @__PURE__ */ new Map();
3543
+ /** Serialises every read-modify-write of the deviceMeta / deviceIndex blob
3544
+ * through one promise chain (see `withMetaWriteLock`). Per-instance state —
3545
+ * identical to the former `onInitialize` closure variable. */
3546
+ metaWriteChain = Promise.resolve();
3547
+ constructor(settings, registry, linkHost) {
3548
+ this.settings = settings;
3549
+ this.registry = registry;
3550
+ this.linkHost = linkHost;
3551
+ }
3552
+ readStore = async () => {
3553
+ return await this.settings.readAddonStore();
3564
3554
  };
3565
- if (typeof dp.testCreationField !== "function") return {
3566
- status: "ok",
3567
- labels: [],
3568
- error: void 0
3555
+ readIndex = async () => {
3556
+ return (await this.readStore()).deviceIndex ?? {};
3569
3557
  };
3570
- return dp.testCreationField({
3571
- type: require_dist.DeviceType.Camera,
3572
- key: input.key,
3573
- value: input.value
3574
- });
3575
- }
3558
+ readMeta = async () => {
3559
+ return (await this.readStore()).deviceMeta ?? {};
3560
+ };
3561
+ /** Hardware-identity metadata map. Lives in a sibling key on the
3562
+ * device-manager addon store so its writers (`setMetadata`) never
3563
+ * collide with the lifecycle writers on `deviceMeta`
3564
+ * (`registerDevice` / `setName` / `setLocation` / `setDisabled`).
3565
+ * Single-writer per row eliminates the "writer X clobbers writer
3566
+ * Y's field" bug class — `setMetadata` is the only producer. */
3567
+ readMetadataMap = async () => {
3568
+ return (await this.readStore()).deviceMetadata ?? {};
3569
+ };
3570
+ withMetaWriteLock = async (fn) => {
3571
+ const previous = this.metaWriteChain;
3572
+ let release = () => {};
3573
+ const next = new Promise((resolve) => {
3574
+ release = resolve;
3575
+ });
3576
+ this.metaWriteChain = next;
3577
+ try {
3578
+ await previous.catch(() => {});
3579
+ return await fn();
3580
+ } finally {
3581
+ release();
3582
+ }
3583
+ };
3584
+ /**
3585
+ * Resolve a numeric deviceId to the owning `(addonId, stableId)` pair.
3586
+ * Scans persisted meta — live IDevice lookup (hub registry) is handled
3587
+ * separately per call site so callers can decide whether to route to
3588
+ * an in-process driver or to the cross-process `device-ops` bridge.
3589
+ * Returns null when no device with that id is known to the hub.
3590
+ */
3591
+ resolvePersistedById = async (deviceId) => {
3592
+ const m = (await this.readMeta())[String(deviceId)];
3593
+ if (!m) return null;
3594
+ return {
3595
+ addonId: m.addonId,
3596
+ stableId: m.stableId,
3597
+ meta: m
3598
+ };
3599
+ };
3600
+ /** Direct children of a device: the union of the live registry's children
3601
+ * and the persisted-meta scan (`parentDeviceId === parentId`), deduplicated
3602
+ * and excluding self. Shared by the `remove` cascade and the `resetToSource`
3603
+ * resync purge (#19). */
3604
+ directChildIds = async (parentId) => {
3605
+ const ids = /* @__PURE__ */ new Set();
3606
+ if (this.registry) for (const c of this.registry.getChildren(parentId)) ids.add(c.id);
3607
+ const meta = await this.readMeta();
3608
+ for (const m of Object.values(meta)) if (m.parentDeviceId === parentId) ids.add(m.id);
3609
+ ids.delete(parentId);
3610
+ return [...ids];
3611
+ };
3612
+ /** Resolve a link's `sourceKey` to a live device id: the sibling accessory
3613
+ * whose stableId is `${parentStableId}-${sourceKey}`. Null when absent.
3614
+ * Pure over a pre-read meta map so a multi-link resolve reads the store once. */
3615
+ resolveSourceDeviceId = (parentStableId, sourceKey, meta) => {
3616
+ const wanted = `${parentStableId}-${sourceKey}`;
3617
+ for (const m of Object.values(meta)) if (m.stableId === wanted) return m.id;
3618
+ return null;
3619
+ };
3620
+ /** Resolve a GLOBAL link source (P2e) to a live device id: the device whose
3621
+ * FULL stableId equals `sourceStableId`, regardless of parent container.
3622
+ * First match wins (stableIds are effectively unique cluster-wide — see the
3623
+ * `DeviceLinkGlobalSource` docblock). Null when absent. Pure over a
3624
+ * pre-read meta map so a multi-link resolve reads the store once. */
3625
+ resolveGlobalSourceDeviceId = (sourceStableId, meta) => {
3626
+ for (const m of Object.values(meta)) if (m.stableId === sourceStableId) return m.id;
3627
+ return null;
3628
+ };
3629
+ allocateNextDeviceId = async () => {
3630
+ const current = (await this.readStore()).nextDeviceId ?? 1;
3631
+ await this.settings.writeAddonStore({ nextDeviceId: current + 1 });
3632
+ return current;
3633
+ };
3634
+ /** Rebuild the `linkTargets` / `linkDependents` reverse-index maps from the
3635
+ * current persisted meta. Called at boot (once the `devicesWithLinks` seed
3636
+ * has run) and whenever the link topology can change: `setDeviceLinks`,
3637
+ * `registerDevice` (a new sibling source may resolve previously-dangling
3638
+ * links), and `removeDevice` (its entries must be evicted). */
3639
+ rebuildLinkDependents = async () => {
3640
+ if (this.linkHost.devicesWithLinks.size === 0) {
3641
+ this.linkHost.setLinkIndexes(/* @__PURE__ */ new Map(), /* @__PURE__ */ new Map(), /* @__PURE__ */ new Set());
3642
+ return;
3643
+ }
3644
+ const allMeta = await this.readMeta();
3645
+ const stableIdById = /* @__PURE__ */ new Map();
3646
+ const idByStableId = /* @__PURE__ */ new Map();
3647
+ for (const m of Object.values(allMeta)) {
3648
+ stableIdById.set(m.id, m.stableId);
3649
+ idByStableId.set(m.stableId, m.id);
3650
+ }
3651
+ const entries = [];
3652
+ const expectedSources = /* @__PURE__ */ new Set();
3653
+ for (const targetId of this.linkHost.devicesWithLinks) {
3654
+ const targetMeta = Object.values(allMeta).find((m) => m.id === targetId);
3655
+ if (!targetMeta) continue;
3656
+ const container = targetMeta.parentDeviceId !== null ? stableIdById.get(targetMeta.parentDeviceId) ?? stableIdById.get(targetId) : stableIdById.get(targetId);
3657
+ if (container === void 0) continue;
3658
+ for (const link of targetMeta.deviceLinks ?? []) {
3659
+ const src = link.source;
3660
+ if (src.kind === "expression") {
3661
+ const bindingSourceIds = {};
3662
+ for (const [name, b] of Object.entries(src.bindings)) {
3663
+ if (b.kind === "literal") continue;
3664
+ const wanted = b.kind === "global" ? b.sourceStableId : `${container}-${b.sourceKey}`;
3665
+ expectedSources.add(wanted);
3666
+ const bid = idByStableId.get(wanted);
3667
+ if (bid !== void 0) bindingSourceIds[name] = bid;
3668
+ }
3669
+ entries.push({
3670
+ targetDeviceId: targetId,
3671
+ link,
3672
+ sourceDeviceId: -1,
3673
+ bindingSourceIds
3674
+ });
3675
+ continue;
3676
+ }
3677
+ if (src.kind === "literal") {
3678
+ entries.push({
3679
+ targetDeviceId: targetId,
3680
+ link,
3681
+ sourceDeviceId: -1
3682
+ });
3683
+ continue;
3684
+ }
3685
+ const wantedStableId = src.kind === "global" ? src.sourceStableId : `${container}-${src.sourceKey}`;
3686
+ expectedSources.add(wantedStableId);
3687
+ const srcId = idByStableId.get(wantedStableId);
3688
+ if (srcId === void 0) continue;
3689
+ entries.push({
3690
+ targetDeviceId: targetId,
3691
+ link,
3692
+ sourceDeviceId: srcId
3693
+ });
3694
+ }
3695
+ }
3696
+ const { targets, dependents } = buildLinkIndexes(entries);
3697
+ this.linkHost.setLinkIndexes(targets, dependents, expectedSources);
3698
+ };
3699
+ };
3576
3700
  //#endregion
3577
3701
  //#region src/builtins/device-manager/device-link-resolver.ts
3578
3702
  /** Returns true when `x` is a non-null, non-array plain object. */
@@ -4543,6 +4667,10 @@ var DeviceManagerAddon = class extends require_dist.BaseAddon {
4543
4667
  listAll: (input) => listAll(pctx, input),
4544
4668
  getDevice: (input) => getDevice(pctx, input),
4545
4669
  getChildren: (input) => getChildren(pctx, input),
4670
+ getLinkedDevices: (input) => getLinkedDevices(pctx, input),
4671
+ getDeviceSettingsContribution: (input) => buildLinkedDevicesContribution(pctx, input.deviceId),
4672
+ getDeviceLiveContribution: async () => null,
4673
+ applyDeviceSettingsPatch: (input) => applyLinkedDevicesPatch(pctx, input.deviceId, input.patch),
4546
4674
  getStreamSources: (input) => getStreamSources(pctx, input),
4547
4675
  getConfigSchema: (input) => getConfigSchema(pctx, input),
4548
4676
  getSettingsSchema: (input) => getSettingsSchema(pctx, input),