@camstack/system 1.2.305 → 1.2.307

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 (60) hide show
  1. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  2. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  3. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  4. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  5. package/dist/builtins/alerts/alerts.addon.js +1 -1
  6. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  7. package/dist/builtins/autotrack/index.js +1 -1
  8. package/dist/builtins/autotrack/index.mjs +1 -1
  9. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
  10. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
  11. package/dist/builtins/camera-grid/index.js +1 -1
  12. package/dist/builtins/camera-grid/index.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/core-blocks/core-blocks.addon.js +1 -1
  16. package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
  17. package/dist/builtins/device-manager/device-extension-linked-slot.d.ts +19 -0
  18. package/dist/builtins/device-manager/device-linked-devices.d.ts +26 -18
  19. package/dist/builtins/device-manager/device-manager.addon.js +157 -135
  20. package/dist/builtins/device-manager/device-manager.addon.mjs +157 -135
  21. package/dist/builtins/doorbell/binding-mirror.d.ts +13 -7
  22. package/dist/builtins/doorbell/virtual-doorbell.addon.d.ts +12 -45
  23. package/dist/builtins/doorbell/virtual-doorbell.addon.js +29 -102
  24. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +29 -102
  25. package/dist/builtins/hub-forwarder/index.js +1 -1
  26. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  27. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  28. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  29. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  30. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  31. package/dist/builtins/local-network/local-network.addon.js +1 -1
  32. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  33. package/dist/builtins/loki-logging/index.js +1 -1
  34. package/dist/builtins/loki-logging/index.mjs +1 -1
  35. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  36. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  37. package/dist/builtins/platform-probe/index.js +1 -1
  38. package/dist/builtins/platform-probe/index.mjs +1 -1
  39. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  40. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  41. package/dist/builtins/snapshot/index.js +1 -1
  42. package/dist/builtins/snapshot/index.mjs +1 -1
  43. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  44. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  45. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
  46. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
  47. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  48. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  49. package/dist/builtins/system-config/system-config.addon.js +1 -1
  50. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  51. package/dist/builtins/winston-logging/index.js +1 -1
  52. package/dist/builtins/winston-logging/index.mjs +1 -1
  53. package/dist/{dist-BK4iP6ab.js → dist-D3ch99WD.js} +66 -73
  54. package/dist/{dist-Df1NhKxm.mjs → dist-DdfJa8Zn.mjs} +67 -68
  55. package/dist/index.js +1 -1
  56. package/dist/index.mjs +1 -1
  57. package/dist/{retired-settings-keys-dC4ckbXJ.mjs → retired-settings-keys-BJNhhXap.mjs} +1 -1
  58. package/dist/{retired-settings-keys-DwWMph6F.js → retired-settings-keys-DG_LEPqC.js} +1 -1
  59. package/package.json +1 -1
  60. package/dist/builtins/device-manager/device-store-sections.d.ts +0 -26
@@ -1,5 +1,5 @@
1
- import { A as STREAM_PROFILE_META, Bt as normalizeUnit, Cn as DeviceType, Dt as legacyAutoLinkIds, En as WELL_KNOWN_TAB_MAP, Fn as isDeviceConfigCap, G as buildStreamParamsConfigSchema, Sn as DeviceRole, Un as sleep$1, Ut as parseStreamParamsFormPatch, Wn as EventCategory, Z as createDeviceExtensionProvider, Zt as runtimeStatePolicyFor, _n as BaseAddon, _t as formatMinutes, at as deviceStateCapability, d as DAY_NAMES, h as DeviceStatusSchema, hn as errMsg, it as deviceManagerCapability, jt as locationIconKey, lt as enumerateItemArrayFields, nn as siteZoneKey, o as CAMERA_SWITCH_ORDER, ot as deviceStatusCapability, pt as expandContainer, rt as deviceExtensionCapability, s as CAP_NAMES_WITH_STATUS, t as ALL_CAPABILITY_DEFINITIONS, tt as defineExtensionSlotProvider, ut as enumerateSchemaFields, x as MINUTES_PER_DAY, xn as DeviceFeature, y as LocationIconIdSchema } from "../../dist-Df1NhKxm.mjs";
2
- import { n as purgeRetiredSettingsRows } from "../../retired-settings-keys-dC4ckbXJ.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-DdfJa8Zn.mjs";
2
+ import { n as purgeRetiredSettingsRows } from "../../retired-settings-keys-BJNhhXap.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,76 +2884,16 @@ 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
  }
@@ -2948,11 +2908,8 @@ async function applyLinkedDevicesPatch(pctx, deviceId, patch) {
2948
2908
  * is not a new link. Removing them is the operator's call, never this
2949
2909
  * function's (a missing source is shown, never silently dropped).
2950
2910
  *
2951
- * Deliberately NOT applied to the settings-section save
2952
- * (`applyLinkedDevicesPatch` via `DEVICE_STORE_SECTIONS`): that surface has
2953
- * never refused, its picker never offers a container, and the Locations page
2954
- * writes the list through it. Slice 1 changes no behaviour on the old path;
2955
- * both paths persist through `applyLinkedDevicesPatch`.
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.
2956
2913
  */
2957
2914
  function refusedLinkedPicks(params) {
2958
2915
  const kept = new Set(params.persisted);
@@ -2979,43 +2936,6 @@ function refusedLinkedPicks(params) {
2979
2936
  return out;
2980
2937
  }
2981
2938
  //#endregion
2982
- //#region src/builtins/device-manager/device-store-sections.ts
2983
- /** The device-manager's device-store-backed sections. Add an entry to extend. */
2984
- var DEVICE_STORE_SECTIONS = [{
2985
- keys: [
2986
- LINKED_MODE_CONFIG_KEY,
2987
- LINKED_IDS_CONFIG_KEY,
2988
- LINKED_TRACKED_IDS_CONFIG_KEY
2989
- ],
2990
- apply: (pctx, deviceId, patch) => applyLinkedDevicesPatch(pctx, deviceId, patch)
2991
- }];
2992
- /**
2993
- * Split a `writerCapName: 'device-manager'` patch into device-store slices
2994
- * (one per matching registered section) and the leftover driver patch.
2995
- * Generic — consults {@link DEVICE_STORE_SECTIONS}, no hardcoded key list.
2996
- */
2997
- function splitDeviceStoreKeys(patch) {
2998
- const claimed = /* @__PURE__ */ new Set();
2999
- const storeGroups = [];
3000
- for (const section of DEVICE_STORE_SECTIONS) {
3001
- const slice = {};
3002
- for (const key of section.keys) if (key in patch) {
3003
- slice[key] = patch[key];
3004
- claimed.add(key);
3005
- }
3006
- if (Object.keys(slice).length > 0) storeGroups.push({
3007
- section,
3008
- patch: slice
3009
- });
3010
- }
3011
- const driverPatch = {};
3012
- for (const [key, value] of Object.entries(patch)) if (!claimed.has(key)) driverPatch[key] = value;
3013
- return {
3014
- storeGroups,
3015
- driverPatch
3016
- };
3017
- }
3018
- //#endregion
3019
2939
  //#region src/builtins/device-manager/device-aggregation.ts
3020
2940
  /**
3021
2941
  * One process-wide cache behind every device-details fan-out.
@@ -3680,12 +3600,15 @@ async function updateDeviceFieldsBatch(deps, input) {
3680
3600
  * registry lookup). Used by `updateDeviceFieldsBatch`. */
3681
3601
  async function applyGroupPatch(deps, deviceId, group) {
3682
3602
  if (group.writerCapName === "device-manager") {
3683
- const { storeGroups, driverPatch } = splitDeviceStoreKeys(group.patch);
3684
- if (storeGroups.length > 0) {
3685
- const settings = deps.ctx.settings;
3686
- if (!settings) throw new Error("[device-manager] device-store save requires the settings surface");
3687
- 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`);
3688
3610
  }
3611
+ const driverPatch = group.patch;
3689
3612
  if (Object.keys(driverPatch).length === 0) return;
3690
3613
  const hubRegistry = deps.ctx.kernel?.deviceRegistry;
3691
3614
  const found = hubRegistry ? resolveDeviceById(hubRegistry, deviceId) : null;
@@ -3974,12 +3897,11 @@ var DEVICE_MANAGER_EXTENSION_SOURCE = "device-manager";
3974
3897
  /** The store the slot reads and writes, named (D62). */
3975
3898
  var LINKED_DEVICES_AUTHORITY = `device-manager:${LINKED_IDS_CONFIG_KEY}`;
3976
3899
  /**
3977
- * The picked ids' ledger rows, in ONE indexed read of the device manager's own
3978
- * store (`rows.getMany`) — never the whole fleet to label a handful of ids.
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.
3979
3902
  * An id no device has is absent from the map.
3980
3903
  */
3981
- async function pickedDevices(pctx, ids) {
3982
- const rows = await pctx.metaStore.rows.getMany(ids);
3904
+ function pickedFrom(rows) {
3983
3905
  const out = /* @__PURE__ */ new Map();
3984
3906
  for (const [id, row] of rows) out.set(id, {
3985
3907
  name: row.meta.name,
@@ -4008,6 +3930,78 @@ function sourceOf(deviceId, byId) {
4008
3930
  health: "ok"
4009
3931
  };
4010
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
+ }
4011
4005
  function describeRefusal(deviceId, reason) {
4012
4006
  if (reason === "self") return `${deviceId} is the camera itself`;
4013
4007
  if (reason === "container") return `${deviceId} is a container — pick its children`;
@@ -4045,21 +4039,27 @@ function createDeviceManagerExtensionProvider(pctx) {
4045
4039
  label: "Device manager",
4046
4040
  slots: [defineExtensionSlotProvider("linked-devices", {
4047
4041
  list: async (deviceId) => {
4048
- const { manualIds } = parseLinkedDevicesConfig(await pctx.settings.readDeviceStore(deviceId));
4049
- const byId = await pickedDevices(pctx, manualIds);
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);
4050
4047
  return {
4051
4048
  authority: LINKED_DEVICES_AUTHORITY,
4052
4049
  enabled: null,
4053
4050
  activation: null,
4054
4051
  value: {
4055
4052
  slot: "linked-devices",
4056
- deviceIds: [...manualIds]
4053
+ deviceIds: [...manualIds],
4054
+ trackedDeviceIds: [...parseTrackedOptIn(blob)]
4057
4055
  },
4058
- sources: manualIds.map((id) => sourceOf(id, byId))
4056
+ sources: manualIds.map((id) => sourceOf(id, byId)),
4057
+ ...implied.length > 0 ? { impliedSources: implied } : {}
4059
4058
  };
4060
4059
  },
4061
4060
  set: async (deviceId, value) => {
4062
- const [byId, blob] = await Promise.all([pickedDevices(pctx, value.deviceIds), pctx.settings.readDeviceStore(deviceId)]);
4061
+ const [pickedRows, blob] = await Promise.all([pctx.metaStore.rows.getMany(value.deviceIds), pctx.settings.readDeviceStore(deviceId)]);
4062
+ const byId = pickedFrom(pickedRows);
4063
4063
  const refused = refusedLinkedPicks({
4064
4064
  selfId: deviceId,
4065
4065
  picked: value.deviceIds,
@@ -4073,7 +4073,32 @@ function createDeviceManagerExtensionProvider(pctx) {
4073
4073
  });
4074
4074
  throw new Error(`linked-devices on ${deviceId}: refused — ` + refused.map((r) => describeRefusal(r.deviceId, r.reason)).join("; "));
4075
4075
  }
4076
- await applyLinkedDevicesPatch(pctx, deviceId, { [LINKED_IDS_CONFIG_KEY]: value.deviceIds });
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
+ });
4077
4102
  return {
4078
4103
  applied: true,
4079
4104
  reload: "none"
@@ -4255,9 +4280,9 @@ async function reloadMigratedDevices(pctx, targets) {
4255
4280
  *
4256
4281
  * - The DRIVER config keys (host, port, credentials, `deviceCache`, every
4257
4282
  * schema field) SWAP — they describe the box.
4258
- * - The device-manager-owned section keys ({@link DEVICE_STORE_SECTIONS}:
4259
- * linked-devices mode/ids) STAY with the number — a sensor is linked to
4260
- * "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.
4261
4286
  * - `_profileMap` is DROPPED from both. It is the legacy spelling of the
4262
4287
  * stream-profile assignment (the live authority is the stream broker's own
4263
4288
  * map — see `StreamsTab.tsx`, which stopped writing it), and either copy
@@ -4275,7 +4300,7 @@ async function reloadMigratedDevices(pctx, targets) {
4275
4300
  /** Legacy stream-profile assignment key (cf. `device-queries.ts`). */
4276
4301
  var PROFILE_MAP_CONFIG_KEY = "_profileMap";
4277
4302
  /** Config keys that belong to the camera's ROLE and stay with the number. */
4278
- var ROLE_SCOPED_CONFIG_KEYS = new Set(DEVICE_STORE_SECTIONS.flatMap((section) => [...section.keys]));
4303
+ var ROLE_SCOPED_CONFIG_KEYS = LINKED_DEVICES_RESERVED_KEYS;
4279
4304
  function splitConfig(config) {
4280
4305
  const hardware = {};
4281
4306
  const role = {};
@@ -8062,9 +8087,6 @@ var DeviceManagerAddon = class extends BaseAddon {
8062
8087
  getChildrenBatch: (input) => getChildrenBatch(pctx, input),
8063
8088
  getLinkedDevices: (input) => getLinkedDevices(pctx, input),
8064
8089
  getLinkedDevicesBatch: (input) => getLinkedDevicesBatch(pctx, input),
8065
- getDeviceSettingsContribution: (input) => buildLinkedDevicesContribution(pctx, input.deviceId),
8066
- getDeviceLiveContribution: async () => null,
8067
- applyDeviceSettingsPatch: (input) => applyLinkedDevicesPatch(pctx, input.deviceId, input.patch),
8068
8090
  getStreamSources: (input) => getStreamSources(pctx, input),
8069
8091
  getConfigSchema: (input) => getConfigSchema(pctx, input),
8070
8092
  getSettingsSchema: (input) => getSettingsSchema(pctx, input),
@@ -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
@@ -5,7 +5,7 @@ interface VirtualDoorbellAddonConfig {
5
5
  /**
6
6
  * Reload the camera→source assignment map (and the binding mirror) at
7
7
  * most this often — lazily, on trigger traffic. Covers devices/settings
8
- * appearing after boot without a settings save, and is the cadence on
8
+ * appearing after boot without a save, and is the cadence on
9
9
  * which an unbind gets corroborated.
10
10
  */
11
11
  readonly assignmentsTtlMs: number;
@@ -19,9 +19,9 @@ interface VirtualDoorbellAddonConfig {
19
19
  *
20
20
  * 1. The operator binds the wrapper to a camera in the bindings UI
21
21
  * (`defaultActive: false` — never auto-bound).
22
- * 2. In the camera's GENERAL settings (next to "Linked devices") the
23
- * operator picks one or more SOURCE devices from a searchable list —
24
- * that is the ONLY choice. The reaction is derived automatically per
22
+ * 2. In the camera's Extensions section (the `doorbell-source` slot,
23
+ * D648/D655) the operator picks one or more SOURCE devices — that is
24
+ * the ONLY choice. The reaction is derived automatically per
25
25
  * device type: a binary sensor rings when it goes active, a switch
26
26
  * rings when it turns on (see `trigger-engine.ts`). The persisted
27
27
  * shape (`doorbellSources: [{deviceId}]`) stays extensible for a
@@ -54,8 +54,8 @@ export declare class VirtualDoorbellAddon extends BaseAddon<VirtualDoorbellAddon
54
54
  constructor();
55
55
  protected onInitialize(): Promise<ProviderRegistration[]>;
56
56
  /**
57
- * The `device-extension` provider: the same store and the same save path as
58
- * the settings section below (see `doorbell-extension-slot.ts`).
57
+ * The `device-extension` provider: the only editor of a camera's trigger
58
+ * devices since D655 (see `doorbell-extension-slot.ts`).
59
59
  */
60
60
  private buildExtensionProvider;
61
61
  private handleStateChanged;
@@ -141,18 +141,11 @@ export declare class VirtualDoorbellAddon extends BaseAddon<VirtualDoorbellAddon
141
141
  * Returns `true` when the ring must proceed.
142
142
  */
143
143
  private ringsDespiteUnboundMirror;
144
- /**
145
- * Record a binding we observed FIRST-HAND. Both callers are cap methods
146
- * the router only reaches by resolving this wrapper for this device —
147
- * which it can only do through the binding — so a call is direct evidence
148
- * and outranks anything the store says.
149
- */
150
- private confirmBindingFromCapCall;
151
144
  /**
152
145
  * Lazily (re)build the camera→source assignment set from every
153
146
  * camera's device store. TTL-bounded so devices/settings appearing
154
- * after boot are picked up without a settings save; invalidated
155
- * eagerly by `applyDeviceSettingsPatch`.
147
+ * after boot are picked up without a save; invalidated eagerly by
148
+ * `persistDoorbellSources`.
156
149
  */
157
150
  private ensureAssignments;
158
151
  private loadAssignments;
@@ -169,36 +162,10 @@ export declare class VirtualDoorbellAddon extends BaseAddon<VirtualDoorbellAddon
169
162
  */
170
163
  private readDeviceSources;
171
164
  /**
172
- * Single-trip, non-throwing device lookup (mirrors snapshot's
173
- * `lookupDeviceMeta`). Null on any failure — callers fall back to
174
- * rendering the section (better a spurious section than a thrown
175
- * aggregator entry).
176
- */
177
- private lookupDevice;
178
- /**
179
- * Settings section for the device-detail page — contributed into the
180
- * GENERAL settings surface (no `tab`, so it lands in the same tab as
181
- * "Linked devices", ordered just after it). Camera-only: the doorbell
182
- * cap also applies to Button accessories (native providers) where a
183
- * virtual-source picker is meaningless.
184
- *
185
- * A SINGLE `device-multiselect` field carries only a declarative
186
- * `deviceFilter` — the admin-ui renders the SHARED searchable device
187
- * picker (same idiom as "Linked devices") whose options come from
188
- * `deviceManager.listAll` filtered CLIENT-SIDE. The addon NO LONGER
189
- * computes the option list server-side: the old path enumerated
190
- * `deviceManager.getAllBindings`, which returns an empty set on the live
191
- * cluster and left the picker blank. The reaction is still derived
192
- * automatically per device type — no trigger/eventType controls.
193
- */
194
- private buildDeviceSettingsContribution;
195
- private saveDeviceSettingsPatch;
196
- /**
197
- * THE write path for a camera's trigger sources — the settings save above
198
- * and the `doorbell-source` extension slot both land here (D648), so the
199
- * two surfaces cannot store different shapes: normalise to
200
- * `doorbellSources: [{deviceId}]`, strip the legacy keys, and invalidate the
201
- * assignment cache so the next rise (and the binding mirror's next
165
+ * THE write path for a camera's trigger sources — the `doorbell-source`
166
+ * extension slot lands here (D648; its only caller since D655): normalise
167
+ * to `doorbellSources: [{deviceId}]`, strip the legacy keys, and invalidate
168
+ * the assignment cache so the next rise (and the binding mirror's next
202
169
  * refresh, off the ring path) sees the new wiring.
203
170
  */
204
171
  private persistDoorbellSources;