@camstack/system 1.2.304 → 1.2.306

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 (72) hide show
  1. package/dist/addon-runner.js +1 -1
  2. package/dist/addon-runner.mjs +1 -1
  3. package/dist/addon-utils.d.ts +1 -1
  4. package/dist/addon-utils.js +3 -1
  5. package/dist/addon-utils.mjs +2 -2
  6. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  7. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  8. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  9. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  10. package/dist/builtins/alerts/alerts.addon.js +1 -1
  11. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  12. package/dist/builtins/autotrack/index.js +1 -1
  13. package/dist/builtins/autotrack/index.mjs +1 -1
  14. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
  15. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
  16. package/dist/builtins/camera-grid/index.js +1 -1
  17. package/dist/builtins/camera-grid/index.mjs +1 -1
  18. package/dist/builtins/console-logging/index.js +1 -1
  19. package/dist/builtins/console-logging/index.mjs +1 -1
  20. package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
  21. package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
  22. package/dist/builtins/device-manager/device-extension-linked-slot.d.ts +27 -0
  23. package/dist/builtins/device-manager/device-linked-devices.d.ts +55 -13
  24. package/dist/builtins/device-manager/device-manager.addon.js +324 -136
  25. package/dist/builtins/device-manager/device-manager.addon.mjs +324 -136
  26. package/dist/builtins/doorbell/binding-mirror.d.ts +37 -7
  27. package/dist/builtins/doorbell/doorbell-extension-slot.d.ts +66 -0
  28. package/dist/builtins/doorbell/virtual-doorbell.addon.d.ts +17 -36
  29. package/dist/builtins/doorbell/virtual-doorbell.addon.js +320 -124
  30. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +320 -124
  31. package/dist/builtins/hub-forwarder/index.js +1 -1
  32. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  33. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  34. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  35. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  36. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  37. package/dist/builtins/local-network/local-network.addon.js +1 -1
  38. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  39. package/dist/builtins/loki-logging/index.js +1 -1
  40. package/dist/builtins/loki-logging/index.mjs +1 -1
  41. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  42. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  43. package/dist/builtins/platform-probe/index.js +1 -1
  44. package/dist/builtins/platform-probe/index.mjs +1 -1
  45. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  46. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  47. package/dist/builtins/snapshot/index.js +1 -1
  48. package/dist/builtins/snapshot/index.mjs +1 -1
  49. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  50. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  51. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
  52. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
  53. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  54. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  55. package/dist/builtins/system-config/system-config.addon.js +1 -1
  56. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  57. package/dist/builtins/winston-logging/index.js +1 -1
  58. package/dist/builtins/winston-logging/index.mjs +1 -1
  59. package/dist/{child-cap-dispatch-C1c3rWaX.mjs → child-cap-dispatch-BCJjUtXV.mjs} +9 -1
  60. package/dist/{child-cap-dispatch-De5_HcvR.js → child-cap-dispatch-Be8e2cBP.js} +9 -1
  61. package/dist/{dist-Duqf80QM.mjs → dist-CLfJMMdf.mjs} +1056 -349
  62. package/dist/{dist-C571Y95Z.js → dist-vecbhDzu.js} +1079 -354
  63. package/dist/download/model-downloader.d.ts +15 -0
  64. package/dist/{event-loop-stall-monitor-GPrVvx14.mjs → event-loop-stall-monitor-EXjzIxDz.mjs} +25 -3
  65. package/dist/{event-loop-stall-monitor-CAsHdMwP.js → event-loop-stall-monitor-ey4mbofz.js} +36 -2
  66. package/dist/index.js +21 -3
  67. package/dist/index.mjs +21 -3
  68. package/dist/kernel/config-manager.d.ts +3 -1
  69. package/dist/{retired-settings-keys-0j8rNoa8.js → retired-settings-keys-Bm2p0Rot.js} +1 -1
  70. package/dist/{retired-settings-keys-CeO_dBHU.mjs → retired-settings-keys-C1ODcdnr.mjs} +1 -1
  71. package/package.json +7 -1
  72. package/dist/builtins/device-manager/device-store-sections.d.ts +0 -26
@@ -1,5 +1,5 @@
1
- import { $t as siteZoneKey, A as STREAM_PROFILE_META, Bn as EventCategory, Bt as parseStreamParamsFormPatch, G as buildStreamParamsConfigSchema, Jt as runtimeStatePolicyFor, Lt as normalizeUnit, Ot as locationIconKey, Sn as WELL_KNOWN_TAB_MAP, _n as DeviceFeature, d as DAY_NAMES, dn as errMsg, h as DeviceStatusSchema, jn as isDeviceConfigCap, mt as formatMinutes, nt as deviceStateCapability, o as CAMERA_SWITCH_ORDER, ot as enumerateItemArrayFields, pn as BaseAddon, rt as deviceStatusCapability, s as CAP_NAMES_WITH_STATUS, st as enumerateSchemaFields, t as ALL_CAPABILITY_DEFINITIONS, tt as deviceManagerCapability, ut as expandContainer, vn as DeviceRole, wt as legacyAutoLinkIds, x as MINUTES_PER_DAY, y as LocationIconIdSchema, yn as DeviceType, zn as sleep$1 } from "../../dist-Duqf80QM.mjs";
2
- import { n as purgeRetiredSettingsRows } from "../../retired-settings-keys-CeO_dBHU.mjs";
1
+ import { At as locationIconKey, Et as legacyAutoLinkIds, Hn as sleep$1, Ht as parseStreamParamsFormPatch, Pn as isDeviceConfigCap, Sn as DeviceType, Tn as WELL_KNOWN_TAB_MAP, Un as EventCategory, W as buildStreamParamsConfigSchema, X as createDeviceExtensionProvider, Xt as runtimeStatePolicyFor, at as deviceStatusCapability, bn as DeviceFeature, ct as enumerateItemArrayFields, d as DAY_NAMES, et as defineExtensionSlotProvider, ft as expandContainer, gn as BaseAddon, gt as formatMinutes, h as DeviceStatusSchema, it as deviceStateCapability, k as STREAM_PROFILE_META, lt as enumerateSchemaFields, mn as errMsg, nt as deviceExtensionCapability, o as CAMERA_SWITCH_ORDER, rt as deviceManagerCapability, s as CAP_NAMES_WITH_STATUS, t as ALL_CAPABILITY_DEFINITIONS, tn as siteZoneKey, x as MINUTES_PER_DAY, xn as DeviceRole, y as LocationIconIdSchema, zt as normalizeUnit } from "../../dist-CLfJMMdf.mjs";
2
+ import { n as purgeRetiredSettingsRows } from "../../retired-settings-keys-C1ODcdnr.mjs";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { canonicalDeviceFingerprint } from "@camstack/types/node";
5
5
  //#region src/builtins/device-manager/adoption-job-engine.ts
@@ -2715,6 +2715,16 @@ var LINKED_IDS_CONFIG_KEY = "_linkedDeviceIds";
2715
2715
  * explicit operator OPT-IN (2026-07-22 flip: the old absent⇒ALL default
2716
2716
  * auto-materialized sensor snapshots the operator never asked for). */
2717
2717
  var LINKED_TRACKED_IDS_CONFIG_KEY = "_linkedDeviceTrackedIds";
2718
+ /**
2719
+ * The three reserved keys, as one set: they stay with a camera's NUMBER on a
2720
+ * hardware swap, and a settings save that still carries them (a page opened
2721
+ * before D655 retired the section) is refused by name, never dropped.
2722
+ */
2723
+ var LINKED_DEVICES_RESERVED_KEYS = new Set([
2724
+ LINKED_MODE_CONFIG_KEY,
2725
+ LINKED_IDS_CONFIG_KEY,
2726
+ LINKED_TRACKED_IDS_CONFIG_KEY
2727
+ ]);
2718
2728
  /** Parse the two reserved keys out of a device-config blob. Tolerant:
2719
2729
  * ids may arrive as numbers or numeric strings (the multiselect field
2720
2730
  * stores string option values); anything else is dropped. */
@@ -2764,14 +2774,24 @@ function resolveLinkedDeviceIds(params) {
2764
2774
  * are ignored.
2765
2775
  */
2766
2776
  function resolveTrackedEventDeviceIds(config, linkedIds) {
2777
+ const picked = new Set(parseTrackedOptIn(config));
2778
+ return linkedIds.filter((id) => picked.has(id));
2779
+ }
2780
+ /**
2781
+ * The persisted tracked-event opt-in AS STORED — not intersected with the
2782
+ * linked set, which is the resolver's job. Absent / non-array ⇒ `[]` (NONE,
2783
+ * the same answer the resolver gives). Ids may be numbers or numeric strings;
2784
+ * anything else is dropped; deduped, in stored order.
2785
+ */
2786
+ function parseTrackedOptIn(config) {
2767
2787
  const raw = config[LINKED_TRACKED_IDS_CONFIG_KEY];
2768
2788
  if (!Array.isArray(raw)) return [];
2769
- const picked = /* @__PURE__ */ new Set();
2789
+ const out = [];
2770
2790
  for (const v of raw) {
2771
2791
  const n = typeof v === "number" ? v : typeof v === "string" ? Number(v) : NaN;
2772
- if (Number.isInteger(n)) picked.add(n);
2792
+ if (Number.isInteger(n) && !out.includes(n)) out.push(n);
2773
2793
  }
2774
- return linkedIds.filter((id) => picked.has(id));
2794
+ return out;
2775
2795
  }
2776
2796
  /**
2777
2797
  * The fleet listing every function here needs, and the ONLY projection any of
@@ -2864,115 +2884,56 @@ async function getLinkedDevicesBatch(pctx, input) {
2864
2884
  });
2865
2885
  }
2866
2886
  /**
2867
- * "Linked devices" settings section for a CAMERA (null for every other
2868
- * device type — the association is camera-anchored). Contributed through
2869
- * `collectSystemDeviceContributions`; saves route through the existing
2870
- * `writerCapName: 'device-manager'` path into the device config blob.
2871
- */
2872
- async function buildLinkedDevicesContribution(pctx, deviceId) {
2873
- const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
2874
- if (!persisted || persisted.meta.type !== DeviceType.Camera) return null;
2875
- const [all, blob] = await Promise.all([listAll(pctx, LINK_CANDIDATE_PROJECTION), pctx.settings.readDeviceStore(deviceId)]);
2876
- const config = parseLinkedDevicesConfig(blob);
2877
- const self = all.find((d) => d.id === deviceId);
2878
- const linkedIds = resolveLinkedDeviceIds({
2879
- selfId: deviceId,
2880
- config,
2881
- candidates: all
2882
- });
2883
- const byId = new Map(all.map((d) => [d.id, d]));
2884
- const trackedOptions = linkedIds.flatMap((id) => {
2885
- const d = byId.get(id);
2886
- if (!d) return [];
2887
- return [{
2888
- value: String(d.id),
2889
- label: d.location !== null ? `${d.name} (${d.location})` : d.name
2890
- }];
2891
- });
2892
- const trackedValue = resolveTrackedEventDeviceIds(blob, linkedIds).map(String);
2893
- return { sections: [{
2894
- id: "linked-devices",
2895
- title: "Linked devices",
2896
- description: "Companion devices associated with this camera. Device-tree children are always linked; everything else is the list you pick below. Devices in the same location are suggested first, never linked on their own (D356).",
2897
- order: 70,
2898
- fields: [{
2899
- type: "device-multiselect",
2900
- key: LINKED_IDS_CONFIG_KEY,
2901
- label: "Linked devices",
2902
- description: "Pick the companions of this camera. Same-location devices are suggested; device-tree children stay linked regardless.",
2903
- deviceFilter: {
2904
- excludeDeviceId: deviceId,
2905
- excludeTypes: [DeviceType.Container]
2906
- },
2907
- suggestLocation: self?.location ?? null,
2908
- value: config.manualIds.map(String)
2909
- }, {
2910
- type: "multiselect",
2911
- key: LINKED_TRACKED_IDS_CONFIG_KEY,
2912
- label: "Devices that produce tracked events",
2913
- description: "Linked devices whose state changes materialize a synthetic tracked event on this camera. Default: none (opt-in).",
2914
- options: trackedOptions,
2915
- value: trackedValue
2916
- }]
2917
- }] };
2918
- }
2919
- /**
2920
- * Direct `applyDeviceSettingsPatch` normalization for the two reserved keys.
2921
- * The admin-ui save path reaches the device config blob via the
2922
- * `writerCapName: 'device-manager'` routing (never this method), but the cap
2923
- * contract includes the method — keep it correct for direct callers.
2887
+ * THE write path for the linked-devices keys — the `linked-devices` slot's
2888
+ * `set` lands here (D648). Normalizes the pick list and the tracked-event
2889
+ * opt-in; an EXPLICIT empty opt-in is stored as `[]`, never deleted, because
2890
+ * an absent key and an empty one are read alike today but only `[]` says the
2891
+ * operator chose "none".
2924
2892
  */
2925
2893
  async function applyLinkedDevicesPatch(pctx, deviceId, patch) {
2926
2894
  const next = { ...await pctx.settings.readDeviceStore(deviceId) };
2927
2895
  if ("_linkedDeviceIds" in patch) next[LINKED_IDS_CONFIG_KEY] = [...parseLinkedDevicesConfig({ [LINKED_IDS_CONFIG_KEY]: patch[LINKED_IDS_CONFIG_KEY] }).manualIds];
2928
- if ("_linkedDeviceTrackedIds" in patch) {
2929
- const raw = patch[LINKED_TRACKED_IDS_CONFIG_KEY];
2930
- const ids = [];
2931
- if (Array.isArray(raw)) for (const v of raw) {
2932
- const n = typeof v === "number" ? v : typeof v === "string" ? Number(v) : NaN;
2933
- if (Number.isInteger(n) && !ids.includes(n)) ids.push(n);
2934
- }
2935
- next[LINKED_TRACKED_IDS_CONFIG_KEY] = ids;
2936
- }
2896
+ if ("_linkedDeviceTrackedIds" in patch) next[LINKED_TRACKED_IDS_CONFIG_KEY] = [...parseTrackedOptIn(patch)];
2937
2897
  await pctx.settings.writeDeviceStore(deviceId, next);
2938
2898
  return { success: true };
2939
2899
  }
2940
- //#endregion
2941
- //#region src/builtins/device-manager/device-store-sections.ts
2942
- /** The device-manager's device-store-backed sections. Add an entry to extend. */
2943
- var DEVICE_STORE_SECTIONS = [{
2944
- keys: [
2945
- LINKED_MODE_CONFIG_KEY,
2946
- LINKED_IDS_CONFIG_KEY,
2947
- LINKED_TRACKED_IDS_CONFIG_KEY
2948
- ],
2949
- apply: (pctx, deviceId, patch) => applyLinkedDevicesPatch(pctx, deviceId, patch)
2950
- }];
2951
- /**
2952
- * Split a `writerCapName: 'device-manager'` patch into device-store slices
2953
- * (one per matching registered section) and the leftover driver patch.
2954
- * Generic — consults {@link DEVICE_STORE_SECTIONS}, no hardcoded key list.
2955
- */
2956
- function splitDeviceStoreKeys(patch) {
2957
- const claimed = /* @__PURE__ */ new Set();
2958
- const storeGroups = [];
2959
- for (const section of DEVICE_STORE_SECTIONS) {
2960
- const slice = {};
2961
- for (const key of section.keys) if (key in patch) {
2962
- slice[key] = patch[key];
2963
- claimed.add(key);
2900
+ /**
2901
+ * Which NEW picks a linked-devices write must refuse: the camera itself, a
2902
+ * container (never a pick — its children are, D356 and the file header), and
2903
+ * an id no device has.
2904
+ *
2905
+ * Only ids that are not ALREADY persisted are judged. A list written before
2906
+ * this rule existed may hold a container or a device that has since been
2907
+ * deleted; the editor shows those as unavailable / missing, and keeping them
2908
+ * is not a new link. Removing them is the operator's call, never this
2909
+ * function's (a missing source is shown, never silently dropped).
2910
+ *
2911
+ * Since D655 every write of the list goes through the slot, the Locations
2912
+ * page's room rebuild included, so this is the only gate a pick passes.
2913
+ */
2914
+ function refusedLinkedPicks(params) {
2915
+ const kept = new Set(params.persisted);
2916
+ const out = [];
2917
+ for (const deviceId of params.picked) {
2918
+ if (kept.has(deviceId)) continue;
2919
+ if (deviceId === params.selfId) {
2920
+ out.push({
2921
+ deviceId,
2922
+ reason: "self"
2923
+ });
2924
+ continue;
2964
2925
  }
2965
- if (Object.keys(slice).length > 0) storeGroups.push({
2966
- section,
2967
- patch: slice
2926
+ const device = params.fleet.get(deviceId);
2927
+ if (device === void 0) out.push({
2928
+ deviceId,
2929
+ reason: "unknown"
2930
+ });
2931
+ else if (device.type === DeviceType.Container) out.push({
2932
+ deviceId,
2933
+ reason: "container"
2968
2934
  });
2969
2935
  }
2970
- const driverPatch = {};
2971
- for (const [key, value] of Object.entries(patch)) if (!claimed.has(key)) driverPatch[key] = value;
2972
- return {
2973
- storeGroups,
2974
- driverPatch
2975
- };
2936
+ return out;
2976
2937
  }
2977
2938
  //#endregion
2978
2939
  //#region src/builtins/device-manager/device-aggregation.ts
@@ -3639,12 +3600,15 @@ async function updateDeviceFieldsBatch(deps, input) {
3639
3600
  * registry lookup). Used by `updateDeviceFieldsBatch`. */
3640
3601
  async function applyGroupPatch(deps, deviceId, group) {
3641
3602
  if (group.writerCapName === "device-manager") {
3642
- const { storeGroups, driverPatch } = splitDeviceStoreKeys(group.patch);
3643
- if (storeGroups.length > 0) {
3644
- const settings = deps.ctx.settings;
3645
- if (!settings) throw new Error("[device-manager] device-store save requires the settings surface");
3646
- for (const { section, patch } of storeGroups) await section.apply({ settings }, deviceId, patch);
3603
+ const retired = Object.keys(group.patch).filter((key) => LINKED_DEVICES_RESERVED_KEYS.has(key));
3604
+ if (retired.length > 0) {
3605
+ deps.ctx.logger.warn("device-manager: a save of the retired linked-devices section refused", {
3606
+ tags: { deviceId },
3607
+ meta: { keys: retired }
3608
+ });
3609
+ throw new Error(`[device-manager] ${retired.join(", ")} can no longer be saved from the settings section (D655) — reload the page and use the Extensions section`);
3647
3610
  }
3611
+ const driverPatch = group.patch;
3648
3612
  if (Object.keys(driverPatch).length === 0) return;
3649
3613
  const hubRegistry = deps.ctx.kernel?.deviceRegistry;
3650
3614
  const found = hubRegistry ? resolveDeviceById(hubRegistry, deviceId) : null;
@@ -3926,6 +3890,226 @@ var DeviceEventPropagator = class {
3926
3890
  return chain;
3927
3891
  }
3928
3892
  };
3893
+ //#endregion
3894
+ //#region src/builtins/device-manager/device-extension-linked-slot.ts
3895
+ /** The routing prefix of this owner's `slotRef`s. */
3896
+ var DEVICE_MANAGER_EXTENSION_SOURCE = "device-manager";
3897
+ /** The store the slot reads and writes, named (D62). */
3898
+ var LINKED_DEVICES_AUTHORITY = `device-manager:${LINKED_IDS_CONFIG_KEY}`;
3899
+ /**
3900
+ * The picked ids' ledger fields, from ONE indexed read of the device manager's
3901
+ * own store (`rows.getMany`) — never the whole fleet to label a handful of ids.
3902
+ * An id no device has is absent from the map.
3903
+ */
3904
+ function pickedFrom(rows) {
3905
+ const out = /* @__PURE__ */ new Map();
3906
+ for (const [id, row] of rows) out.set(id, {
3907
+ name: row.meta.name,
3908
+ type: row.meta.type
3909
+ });
3910
+ return out;
3911
+ }
3912
+ /** What a camera's picked id is, said once for the editor. */
3913
+ function sourceOf(deviceId, byId) {
3914
+ const device = byId.get(deviceId);
3915
+ if (device === void 0) return {
3916
+ deviceId,
3917
+ name: null,
3918
+ health: "missing",
3919
+ detail: "No device has this id any more — it links nothing until removed."
3920
+ };
3921
+ if (device.type === DeviceType.Container) return {
3922
+ deviceId,
3923
+ name: device.name,
3924
+ health: "unavailable",
3925
+ detail: "A container is never a pick (D356): its children are linked in its place. Pick the children instead."
3926
+ };
3927
+ return {
3928
+ deviceId,
3929
+ name: device.name,
3930
+ health: "ok"
3931
+ };
3932
+ }
3933
+ /**
3934
+ * Which ids of a written opt-in must be refused: a NEW opt-in for a device the
3935
+ * camera does not link. An id already stored is never refused — whatever was
3936
+ * read can be written back unchanged (a stale opt-in is removed by leaving it
3937
+ * out, never by being unable to save). The written list REPLACES the stored
3938
+ * one: since D655 the editor shows every stored opt-in, so nothing it sends
3939
+ * back is written blind.
3940
+ */
3941
+ function refusedTrackedOptIns(params) {
3942
+ const allowed = new Set([
3943
+ ...params.picked,
3944
+ ...params.implied,
3945
+ ...params.stored
3946
+ ]);
3947
+ return params.tracked.filter((id) => !allowed.has(id));
3948
+ }
3949
+ /** A ledger row as the pure link resolver reads it, or null for an unknown type. */
3950
+ function toLinkCandidate(row) {
3951
+ const type = Object.values(DeviceType).find((t) => t === row.meta.type);
3952
+ if (type === void 0) return null;
3953
+ return {
3954
+ id: row.meta.id,
3955
+ parentDeviceId: row.meta.parentDeviceId,
3956
+ location: row.meta.location,
3957
+ type,
3958
+ stableId: row.meta.stableId,
3959
+ ...row.meta.primaryChildEntityId !== void 0 ? { primaryChildEntityId: row.meta.primaryChildEntityId } : {},
3960
+ ...row.meta.role !== void 0 ? { role: row.meta.role } : {},
3961
+ name: row.meta.name
3962
+ };
3963
+ }
3964
+ /** How deep a container nesting is walked — a cycle in the ledger must end. */
3965
+ var MAX_CONTAINER_DEPTH = 8;
3966
+ /**
3967
+ * The devices the camera links BY STRUCTURE, labelled: what the resolver adds
3968
+ * beyond the picks. Computed by the resolver itself (`resolveLinkedDeviceIds`)
3969
+ * over the only rows it can reach — the picks, the camera's children, and the
3970
+ * descendants of every container among them — read level by level with ONE
3971
+ * indexed `listByParentMany` each, never the fleet.
3972
+ */
3973
+ async function impliedLinks(pctx, deviceId, manualIds, pickedRows) {
3974
+ const rows = new Map(pickedRows);
3975
+ let parents = [deviceId, ...[...pickedRows.values()].filter((r) => r.meta.type === DeviceType.Container).map((r) => r.meta.id)];
3976
+ const walked = /* @__PURE__ */ new Set();
3977
+ for (let depth = 0; depth < MAX_CONTAINER_DEPTH && parents.length > 0; depth++) {
3978
+ for (const id of parents) walked.add(id);
3979
+ const byParent = await pctx.metaStore.rows.listByParentMany(parents);
3980
+ const next = [];
3981
+ for (const children of byParent.values()) for (const child of children) {
3982
+ rows.set(child.meta.id, child);
3983
+ if (child.meta.type === DeviceType.Container && !walked.has(child.meta.id)) next.push(child.meta.id);
3984
+ }
3985
+ parents = next;
3986
+ }
3987
+ const candidates = [...rows.values()].flatMap((r) => {
3988
+ const c = toLinkCandidate(r);
3989
+ return c === null ? [] : [c];
3990
+ });
3991
+ const picks = new Set(manualIds);
3992
+ return resolveLinkedDeviceIds({
3993
+ selfId: deviceId,
3994
+ config: {
3995
+ mode: "manual",
3996
+ manualIds
3997
+ },
3998
+ candidates
3999
+ }).filter((id) => !picks.has(id)).map((id) => ({
4000
+ deviceId: id,
4001
+ name: rows.get(id)?.meta.name ?? null,
4002
+ health: "ok"
4003
+ }));
4004
+ }
4005
+ function describeRefusal(deviceId, reason) {
4006
+ if (reason === "self") return `${deviceId} is the camera itself`;
4007
+ if (reason === "container") return `${deviceId} is a container — pick its children`;
4008
+ return `${deviceId} is not a device`;
4009
+ }
4010
+ async function fleetOf(pctx) {
4011
+ const all = await listAll(pctx, LINK_CANDIDATE_PROJECTION);
4012
+ return new Map(all.map((d) => [d.id, d]));
4013
+ }
4014
+ function candidatesFor(deviceId, byId) {
4015
+ const location = byId.get(deviceId)?.location ?? null;
4016
+ const out = [];
4017
+ for (const device of byId.values()) {
4018
+ if (device.id === deviceId) continue;
4019
+ if (device.parentDeviceId === deviceId) continue;
4020
+ if (device.type === DeviceType.Container) continue;
4021
+ const suggested = location !== null && device.location === location;
4022
+ const parent = device.parentDeviceId === null ? void 0 : byId.get(device.parentDeviceId);
4023
+ out.push({
4024
+ deviceId: device.id,
4025
+ name: device.name,
4026
+ type: device.type,
4027
+ location: device.location,
4028
+ parentName: parent?.name ?? null,
4029
+ suggested,
4030
+ reason: suggested ? "same location" : null
4031
+ });
4032
+ }
4033
+ return out.toSorted((a, b) => Number(b.suggested) - Number(a.suggested) || a.name.localeCompare(b.name));
4034
+ }
4035
+ /** Build the `device-extension` provider the device manager registers. */
4036
+ function createDeviceManagerExtensionProvider(pctx) {
4037
+ return createDeviceExtensionProvider({
4038
+ source: DEVICE_MANAGER_EXTENSION_SOURCE,
4039
+ label: "Device manager",
4040
+ slots: [defineExtensionSlotProvider("linked-devices", {
4041
+ list: async (deviceId) => {
4042
+ const blob = await pctx.settings.readDeviceStore(deviceId);
4043
+ const { manualIds } = parseLinkedDevicesConfig(blob);
4044
+ const pickedRows = await pctx.metaStore.rows.getMany(manualIds);
4045
+ const byId = pickedFrom(pickedRows);
4046
+ const implied = await impliedLinks(pctx, deviceId, manualIds, pickedRows);
4047
+ return {
4048
+ authority: LINKED_DEVICES_AUTHORITY,
4049
+ enabled: null,
4050
+ activation: null,
4051
+ value: {
4052
+ slot: "linked-devices",
4053
+ deviceIds: [...manualIds],
4054
+ trackedDeviceIds: [...parseTrackedOptIn(blob)]
4055
+ },
4056
+ sources: manualIds.map((id) => sourceOf(id, byId)),
4057
+ ...implied.length > 0 ? { impliedSources: implied } : {}
4058
+ };
4059
+ },
4060
+ set: async (deviceId, value) => {
4061
+ const [pickedRows, blob] = await Promise.all([pctx.metaStore.rows.getMany(value.deviceIds), pctx.settings.readDeviceStore(deviceId)]);
4062
+ const byId = pickedFrom(pickedRows);
4063
+ const refused = refusedLinkedPicks({
4064
+ selfId: deviceId,
4065
+ picked: value.deviceIds,
4066
+ persisted: parseLinkedDevicesConfig(blob).manualIds,
4067
+ fleet: byId
4068
+ });
4069
+ if (refused.length > 0) {
4070
+ pctx.host.ctx.logger.warn("linked-devices: write refused — nothing persisted", {
4071
+ tags: { deviceId },
4072
+ meta: { refused }
4073
+ });
4074
+ throw new Error(`linked-devices on ${deviceId}: refused — ` + refused.map((r) => describeRefusal(r.deviceId, r.reason)).join("; "));
4075
+ }
4076
+ const tracked = value.trackedDeviceIds;
4077
+ if (tracked === void 0) {
4078
+ await applyLinkedDevicesPatch(pctx, deviceId, { [LINKED_IDS_CONFIG_KEY]: value.deviceIds });
4079
+ return {
4080
+ applied: true,
4081
+ reload: "none"
4082
+ };
4083
+ }
4084
+ const implied = await impliedLinks(pctx, deviceId, value.deviceIds, pickedRows);
4085
+ const notLinked = refusedTrackedOptIns({
4086
+ tracked,
4087
+ picked: value.deviceIds,
4088
+ implied: implied.map((s) => s.deviceId),
4089
+ stored: parseTrackedOptIn(blob)
4090
+ });
4091
+ if (notLinked.length > 0) {
4092
+ pctx.host.ctx.logger.warn("linked-devices: write refused — nothing persisted", {
4093
+ tags: { deviceId },
4094
+ meta: { trackedNotLinked: notLinked }
4095
+ });
4096
+ throw new Error(`linked-devices on ${deviceId}: refused — tracked events can only be opted in for a linked device, not ${notLinked.join(", ")}`);
4097
+ }
4098
+ await applyLinkedDevicesPatch(pctx, deviceId, {
4099
+ [LINKED_IDS_CONFIG_KEY]: value.deviceIds,
4100
+ [LINKED_TRACKED_IDS_CONFIG_KEY]: [...new Set(tracked)]
4101
+ });
4102
+ return {
4103
+ applied: true,
4104
+ reload: "none"
4105
+ };
4106
+ },
4107
+ candidates: async (deviceId) => candidatesFor(deviceId, await fleetOf(pctx))
4108
+ })],
4109
+ hostTypeOf: async (deviceId) => (await pctx.metaStore.resolvePersistedById(deviceId))?.meta.type ?? null,
4110
+ logger: pctx.host.ctx.logger
4111
+ });
4112
+ }
3929
4113
  /** A refusal that names a switch the camera does not offer, rather than one it
3930
4114
  * could not be asked about. The cap documents rejecting the former; anything
3931
4115
  * else is treated as the camera being out of reach, which is the safe reading:
@@ -4096,9 +4280,9 @@ async function reloadMigratedDevices(pctx, targets) {
4096
4280
  *
4097
4281
  * - The DRIVER config keys (host, port, credentials, `deviceCache`, every
4098
4282
  * schema field) SWAP — they describe the box.
4099
- * - The device-manager-owned section keys ({@link DEVICE_STORE_SECTIONS}:
4100
- * linked-devices mode/ids) STAY with the number — a sensor is linked to
4101
- * "the driveway camera", not to a serial number.
4283
+ * - The device-manager-owned linked-devices keys (mode, ids, tracked-event
4284
+ * opt-in — {@link ROLE_SCOPED_CONFIG_KEYS}) STAY with the number — a sensor
4285
+ * is linked to "the driveway camera", not to a serial number.
4102
4286
  * - `_profileMap` is DROPPED from both. It is the legacy spelling of the
4103
4287
  * stream-profile assignment (the live authority is the stream broker's own
4104
4288
  * map — see `StreamsTab.tsx`, which stopped writing it), and either copy
@@ -4116,7 +4300,7 @@ async function reloadMigratedDevices(pctx, targets) {
4116
4300
  /** Legacy stream-profile assignment key (cf. `device-queries.ts`). */
4117
4301
  var PROFILE_MAP_CONFIG_KEY = "_profileMap";
4118
4302
  /** Config keys that belong to the camera's ROLE and stay with the number. */
4119
- var ROLE_SCOPED_CONFIG_KEYS = new Set(DEVICE_STORE_SECTIONS.flatMap((section) => [...section.keys]));
4303
+ var ROLE_SCOPED_CONFIG_KEYS = LINKED_DEVICES_RESERVED_KEYS;
4120
4304
  function splitConfig(config) {
4121
4305
  const hardware = {};
4122
4306
  const role = {};
@@ -7903,9 +8087,6 @@ var DeviceManagerAddon = class extends BaseAddon {
7903
8087
  getChildrenBatch: (input) => getChildrenBatch(pctx, input),
7904
8088
  getLinkedDevices: (input) => getLinkedDevices(pctx, input),
7905
8089
  getLinkedDevicesBatch: (input) => getLinkedDevicesBatch(pctx, input),
7906
- getDeviceSettingsContribution: (input) => buildLinkedDevicesContribution(pctx, input.deviceId),
7907
- getDeviceLiveContribution: async () => null,
7908
- applyDeviceSettingsPatch: (input) => applyLinkedDevicesPatch(pctx, input.deviceId, input.patch),
7909
8090
  getStreamSources: (input) => getStreamSources(pctx, input),
7910
8091
  getConfigSchema: (input) => getConfigSchema(pctx, input),
7911
8092
  getSettingsSchema: (input) => getSettingsSchema(pctx, input),
@@ -8044,28 +8225,35 @@ var DeviceManagerAddon = class extends BaseAddon {
8044
8225
  this.propagator.start();
8045
8226
  this.ctx.logger.info("device-event-propagator started");
8046
8227
  }
8047
- return [{
8048
- capability: deviceManagerCapability,
8049
- provider
8050
- }, {
8051
- capability: deviceStateCapability,
8052
- provider: {
8053
- getSnapshot: async (input) => {
8054
- return stateMirror.snapshotForDevice(input.deviceId);
8055
- },
8056
- getCapSlice: async (input) => {
8057
- return stateMirror.capSlice(input.deviceId, input.capName);
8058
- },
8059
- getAllSnapshots: async () => {
8060
- return stateMirror.allSnapshots();
8061
- },
8062
- setCapSlice: async (input) => {
8063
- const { deviceId, capName, slice } = input;
8064
- if (!await resolvePersistedById(deviceId)) throw new Error(`[device-manager] setCapSlice: unknown device id=${deviceId}`);
8065
- if (stateMirror.applySingleCapUpdate(deviceId, capName, slice)) stateMirror.scheduleRuntimeStateDiskWrite(deviceId, settings, capName);
8228
+ return [
8229
+ {
8230
+ capability: deviceManagerCapability,
8231
+ provider
8232
+ },
8233
+ {
8234
+ capability: deviceStateCapability,
8235
+ provider: {
8236
+ getSnapshot: async (input) => {
8237
+ return stateMirror.snapshotForDevice(input.deviceId);
8238
+ },
8239
+ getCapSlice: async (input) => {
8240
+ return stateMirror.capSlice(input.deviceId, input.capName);
8241
+ },
8242
+ getAllSnapshots: async () => {
8243
+ return stateMirror.allSnapshots();
8244
+ },
8245
+ setCapSlice: async (input) => {
8246
+ const { deviceId, capName, slice } = input;
8247
+ if (!await resolvePersistedById(deviceId)) throw new Error(`[device-manager] setCapSlice: unknown device id=${deviceId}`);
8248
+ if (stateMirror.applySingleCapUpdate(deviceId, capName, slice)) stateMirror.scheduleRuntimeStateDiskWrite(deviceId, settings, capName);
8249
+ }
8066
8250
  }
8251
+ },
8252
+ {
8253
+ capability: deviceExtensionCapability,
8254
+ provider: createDeviceManagerExtensionProvider(pctx)
8067
8255
  }
8068
- }];
8256
+ ];
8069
8257
  }
8070
8258
  async onShutdown() {
8071
8259
  this.propagator?.stop();
@@ -24,13 +24,19 @@
24
24
  * not survive the next read. So a negative read ARMS the revocation and a
25
25
  * second consecutive negative applies it.
26
26
  *
27
- * ERRING. `undefined` (never read) and `unbind-pending` both ring. Reaching
28
- * the ring path at all means the camera has a configured trigger source,
29
- * and a source can only be configured through this wrapper's own
30
- * binding-gated settings surface — so a configured source is itself
31
- * evidence the wrapper was bound. The cost of erring open is a spurious
32
- * ring on a camera the operator unbound but left configured, for at most
33
- * two reload cycles. The cost of erring closed is the press.
27
+ * ERRING. `undefined` (never read) and `unbind-pending` both ring. The cost
28
+ * of erring open is a spurious ring on a camera whose wrapper is not bound
29
+ * but which has trigger sources, for at most two reload cycles. The cost of
30
+ * erring closed is the press.
31
+ *
32
+ * D655: a configured source is NO LONGER evidence of the binding. D49 leaned
33
+ * on "a source can only be configured through this wrapper's own
34
+ * binding-gated settings surface"; that surface is retired, and the
35
+ * `doorbell-source` extension slot that replaced it is offered on every
36
+ * camera, bound or not (D648). The erring-open window above is therefore
37
+ * reachable by configuring sources on a never-bound camera, not only by
38
+ * unbinding a configured one — still bounded by two reload cycles, and the
39
+ * slot's card says `inactive` once the reads agree.
34
40
  *
35
41
  * ─────────────────────────────────────────────────────────────────────
36
42
  * 2026-08-07 (D72). The corroboration above was DEFEATED, and the reason
@@ -113,3 +119,27 @@ export type DurableBindingVerdict = 'bound' | 'unbound' | 'unreadable';
113
119
  * from an empty snapshot, whereas a genuine unbind leaves the key present.
114
120
  */
115
121
  export declare function readDurableWrapperBinding(record: unknown, deviceId: number, capName: string, ownAddonId: string): DurableBindingVerdict;
122
+ /**
123
+ * What a press on this camera would do right now.
124
+ *
125
+ * `rings` — the binding is confirmed (mirror `bound`, or the durable store
126
+ * rescued an `unbound` mirror). `rings-pending` — one negative read, not yet
127
+ * believed. `rings-unconfirmed` — no read has happened yet; the ring path errs
128
+ * open. `unknown` — the mirror says unbound but the durable store could not
129
+ * be read: a press RINGS (an unanswerable store never destroys a one-shot
130
+ * press), and nobody can say whether that is right. `silent` — the durable
131
+ * store agrees with an unbound mirror: the only verdict that drops a press.
132
+ */
133
+ export type RingVerdict = 'rings' | 'rings-pending' | 'rings-unconfirmed' | 'unknown' | 'silent';
134
+ /** Does this mirror state need the durable authority before a verdict? Only `unbound` does. */
135
+ export declare function needsDurableRead(state: DoorbellBindingState | undefined): boolean;
136
+ /**
137
+ * THE decision — the ring gate (`fireDoorbell` / `ringsDespiteUnboundMirror`)
138
+ * and the Extensions card's activation both call it, so what the card says
139
+ * and what a press does cannot disagree. `durable` is `null` when it was not
140
+ * read, which {@link needsDurableRead} allows for every state but `unbound`.
141
+ * Pure: it reads nothing and writes nothing.
142
+ */
143
+ export declare function ringVerdict(state: DoorbellBindingState | undefined, durable: DurableBindingVerdict | null): RingVerdict;
144
+ /** Whether a press proceeds under this verdict. */
145
+ export declare function verdictRings(verdict: RingVerdict): boolean;