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