@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
@@ -3,8 +3,8 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-BK4iP6ab.js");
7
- const require_retired_settings_keys = require("../../retired-settings-keys-DwWMph6F.js");
6
+ const require_dist = require("../../dist-D3ch99WD.js");
7
+ const require_retired_settings_keys = require("../../retired-settings-keys-DG_LEPqC.js");
8
8
  let node_crypto = require("node:crypto");
9
9
  let _camstack_types_node = require("@camstack/types/node");
10
10
  //#region src/builtins/device-manager/adoption-job-engine.ts
@@ -2720,6 +2720,16 @@ var LINKED_IDS_CONFIG_KEY = "_linkedDeviceIds";
2720
2720
  * explicit operator OPT-IN (2026-07-22 flip: the old absent⇒ALL default
2721
2721
  * auto-materialized sensor snapshots the operator never asked for). */
2722
2722
  var LINKED_TRACKED_IDS_CONFIG_KEY = "_linkedDeviceTrackedIds";
2723
+ /**
2724
+ * The three reserved keys, as one set: they stay with a camera's NUMBER on a
2725
+ * hardware swap, and a settings save that still carries them (a page opened
2726
+ * before D655 retired the section) is refused by name, never dropped.
2727
+ */
2728
+ var LINKED_DEVICES_RESERVED_KEYS = new Set([
2729
+ LINKED_MODE_CONFIG_KEY,
2730
+ LINKED_IDS_CONFIG_KEY,
2731
+ LINKED_TRACKED_IDS_CONFIG_KEY
2732
+ ]);
2723
2733
  /** Parse the two reserved keys out of a device-config blob. Tolerant:
2724
2734
  * ids may arrive as numbers or numeric strings (the multiselect field
2725
2735
  * stores string option values); anything else is dropped. */
@@ -2769,14 +2779,24 @@ function resolveLinkedDeviceIds(params) {
2769
2779
  * are ignored.
2770
2780
  */
2771
2781
  function resolveTrackedEventDeviceIds(config, linkedIds) {
2782
+ const picked = new Set(parseTrackedOptIn(config));
2783
+ return linkedIds.filter((id) => picked.has(id));
2784
+ }
2785
+ /**
2786
+ * The persisted tracked-event opt-in AS STORED — not intersected with the
2787
+ * linked set, which is the resolver's job. Absent / non-array ⇒ `[]` (NONE,
2788
+ * the same answer the resolver gives). Ids may be numbers or numeric strings;
2789
+ * anything else is dropped; deduped, in stored order.
2790
+ */
2791
+ function parseTrackedOptIn(config) {
2772
2792
  const raw = config[LINKED_TRACKED_IDS_CONFIG_KEY];
2773
2793
  if (!Array.isArray(raw)) return [];
2774
- const picked = /* @__PURE__ */ new Set();
2794
+ const out = [];
2775
2795
  for (const v of raw) {
2776
2796
  const n = typeof v === "number" ? v : typeof v === "string" ? Number(v) : NaN;
2777
- if (Number.isInteger(n)) picked.add(n);
2797
+ if (Number.isInteger(n) && !out.includes(n)) out.push(n);
2778
2798
  }
2779
- return linkedIds.filter((id) => picked.has(id));
2799
+ return out;
2780
2800
  }
2781
2801
  /**
2782
2802
  * The fleet listing every function here needs, and the ONLY projection any of
@@ -2869,76 +2889,16 @@ async function getLinkedDevicesBatch(pctx, input) {
2869
2889
  });
2870
2890
  }
2871
2891
  /**
2872
- * "Linked devices" settings section for a CAMERA (null for every other
2873
- * device type — the association is camera-anchored). Contributed through
2874
- * `collectSystemDeviceContributions`; saves route through the existing
2875
- * `writerCapName: 'device-manager'` path into the device config blob.
2876
- */
2877
- async function buildLinkedDevicesContribution(pctx, deviceId) {
2878
- const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
2879
- if (!persisted || persisted.meta.type !== require_dist.DeviceType.Camera) return null;
2880
- const [all, blob] = await Promise.all([listAll(pctx, LINK_CANDIDATE_PROJECTION), pctx.settings.readDeviceStore(deviceId)]);
2881
- const config = parseLinkedDevicesConfig(blob);
2882
- const self = all.find((d) => d.id === deviceId);
2883
- const linkedIds = resolveLinkedDeviceIds({
2884
- selfId: deviceId,
2885
- config,
2886
- candidates: all
2887
- });
2888
- const byId = new Map(all.map((d) => [d.id, d]));
2889
- const trackedOptions = linkedIds.flatMap((id) => {
2890
- const d = byId.get(id);
2891
- if (!d) return [];
2892
- return [{
2893
- value: String(d.id),
2894
- label: d.location !== null ? `${d.name} (${d.location})` : d.name
2895
- }];
2896
- });
2897
- const trackedValue = resolveTrackedEventDeviceIds(blob, linkedIds).map(String);
2898
- return { sections: [{
2899
- id: "linked-devices",
2900
- title: "Linked devices",
2901
- 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).",
2902
- order: 70,
2903
- fields: [{
2904
- type: "device-multiselect",
2905
- key: LINKED_IDS_CONFIG_KEY,
2906
- label: "Linked devices",
2907
- description: "Pick the companions of this camera. Same-location devices are suggested; device-tree children stay linked regardless.",
2908
- deviceFilter: {
2909
- excludeDeviceId: deviceId,
2910
- excludeTypes: [require_dist.DeviceType.Container]
2911
- },
2912
- suggestLocation: self?.location ?? null,
2913
- value: config.manualIds.map(String)
2914
- }, {
2915
- type: "multiselect",
2916
- key: LINKED_TRACKED_IDS_CONFIG_KEY,
2917
- label: "Devices that produce tracked events",
2918
- description: "Linked devices whose state changes materialize a synthetic tracked event on this camera. Default: none (opt-in).",
2919
- options: trackedOptions,
2920
- value: trackedValue
2921
- }]
2922
- }] };
2923
- }
2924
- /**
2925
- * Direct `applyDeviceSettingsPatch` normalization for the two reserved keys.
2926
- * The admin-ui save path reaches the device config blob via the
2927
- * `writerCapName: 'device-manager'` routing (never this method), but the cap
2928
- * contract includes the method — keep it correct for direct callers.
2892
+ * THE write path for the linked-devices keys — the `linked-devices` slot's
2893
+ * `set` lands here (D648). Normalizes the pick list and the tracked-event
2894
+ * opt-in; an EXPLICIT empty opt-in is stored as `[]`, never deleted, because
2895
+ * an absent key and an empty one are read alike today but only `[]` says the
2896
+ * operator chose "none".
2929
2897
  */
2930
2898
  async function applyLinkedDevicesPatch(pctx, deviceId, patch) {
2931
2899
  const next = { ...await pctx.settings.readDeviceStore(deviceId) };
2932
2900
  if ("_linkedDeviceIds" in patch) next[LINKED_IDS_CONFIG_KEY] = [...parseLinkedDevicesConfig({ [LINKED_IDS_CONFIG_KEY]: patch[LINKED_IDS_CONFIG_KEY] }).manualIds];
2933
- if ("_linkedDeviceTrackedIds" in patch) {
2934
- const raw = patch[LINKED_TRACKED_IDS_CONFIG_KEY];
2935
- const ids = [];
2936
- if (Array.isArray(raw)) for (const v of raw) {
2937
- const n = typeof v === "number" ? v : typeof v === "string" ? Number(v) : NaN;
2938
- if (Number.isInteger(n) && !ids.includes(n)) ids.push(n);
2939
- }
2940
- next[LINKED_TRACKED_IDS_CONFIG_KEY] = ids;
2941
- }
2901
+ if ("_linkedDeviceTrackedIds" in patch) next[LINKED_TRACKED_IDS_CONFIG_KEY] = [...parseTrackedOptIn(patch)];
2942
2902
  await pctx.settings.writeDeviceStore(deviceId, next);
2943
2903
  return { success: true };
2944
2904
  }
@@ -2953,11 +2913,8 @@ async function applyLinkedDevicesPatch(pctx, deviceId, patch) {
2953
2913
  * is not a new link. Removing them is the operator's call, never this
2954
2914
  * function's (a missing source is shown, never silently dropped).
2955
2915
  *
2956
- * Deliberately NOT applied to the settings-section save
2957
- * (`applyLinkedDevicesPatch` via `DEVICE_STORE_SECTIONS`): that surface has
2958
- * never refused, its picker never offers a container, and the Locations page
2959
- * writes the list through it. Slice 1 changes no behaviour on the old path;
2960
- * both paths persist through `applyLinkedDevicesPatch`.
2916
+ * Since D655 every write of the list goes through the slot, the Locations
2917
+ * page's room rebuild included, so this is the only gate a pick passes.
2961
2918
  */
2962
2919
  function refusedLinkedPicks(params) {
2963
2920
  const kept = new Set(params.persisted);
@@ -2984,43 +2941,6 @@ function refusedLinkedPicks(params) {
2984
2941
  return out;
2985
2942
  }
2986
2943
  //#endregion
2987
- //#region src/builtins/device-manager/device-store-sections.ts
2988
- /** The device-manager's device-store-backed sections. Add an entry to extend. */
2989
- var DEVICE_STORE_SECTIONS = [{
2990
- keys: [
2991
- LINKED_MODE_CONFIG_KEY,
2992
- LINKED_IDS_CONFIG_KEY,
2993
- LINKED_TRACKED_IDS_CONFIG_KEY
2994
- ],
2995
- apply: (pctx, deviceId, patch) => applyLinkedDevicesPatch(pctx, deviceId, patch)
2996
- }];
2997
- /**
2998
- * Split a `writerCapName: 'device-manager'` patch into device-store slices
2999
- * (one per matching registered section) and the leftover driver patch.
3000
- * Generic — consults {@link DEVICE_STORE_SECTIONS}, no hardcoded key list.
3001
- */
3002
- function splitDeviceStoreKeys(patch) {
3003
- const claimed = /* @__PURE__ */ new Set();
3004
- const storeGroups = [];
3005
- for (const section of DEVICE_STORE_SECTIONS) {
3006
- const slice = {};
3007
- for (const key of section.keys) if (key in patch) {
3008
- slice[key] = patch[key];
3009
- claimed.add(key);
3010
- }
3011
- if (Object.keys(slice).length > 0) storeGroups.push({
3012
- section,
3013
- patch: slice
3014
- });
3015
- }
3016
- const driverPatch = {};
3017
- for (const [key, value] of Object.entries(patch)) if (!claimed.has(key)) driverPatch[key] = value;
3018
- return {
3019
- storeGroups,
3020
- driverPatch
3021
- };
3022
- }
3023
- //#endregion
3024
2944
  //#region src/builtins/device-manager/device-aggregation.ts
3025
2945
  /**
3026
2946
  * One process-wide cache behind every device-details fan-out.
@@ -3685,12 +3605,15 @@ async function updateDeviceFieldsBatch(deps, input) {
3685
3605
  * registry lookup). Used by `updateDeviceFieldsBatch`. */
3686
3606
  async function applyGroupPatch(deps, deviceId, group) {
3687
3607
  if (group.writerCapName === "device-manager") {
3688
- const { storeGroups, driverPatch } = splitDeviceStoreKeys(group.patch);
3689
- if (storeGroups.length > 0) {
3690
- const settings = deps.ctx.settings;
3691
- if (!settings) throw new Error("[device-manager] device-store save requires the settings surface");
3692
- for (const { section, patch } of storeGroups) await section.apply({ settings }, deviceId, patch);
3608
+ const retired = Object.keys(group.patch).filter((key) => LINKED_DEVICES_RESERVED_KEYS.has(key));
3609
+ if (retired.length > 0) {
3610
+ deps.ctx.logger.warn("device-manager: a save of the retired linked-devices section refused", {
3611
+ tags: { deviceId },
3612
+ meta: { keys: retired }
3613
+ });
3614
+ 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`);
3693
3615
  }
3616
+ const driverPatch = group.patch;
3694
3617
  if (Object.keys(driverPatch).length === 0) return;
3695
3618
  const hubRegistry = deps.ctx.kernel?.deviceRegistry;
3696
3619
  const found = hubRegistry ? resolveDeviceById(hubRegistry, deviceId) : null;
@@ -3979,12 +3902,11 @@ var DEVICE_MANAGER_EXTENSION_SOURCE = "device-manager";
3979
3902
  /** The store the slot reads and writes, named (D62). */
3980
3903
  var LINKED_DEVICES_AUTHORITY = `device-manager:${LINKED_IDS_CONFIG_KEY}`;
3981
3904
  /**
3982
- * The picked ids' ledger rows, in ONE indexed read of the device manager's own
3983
- * store (`rows.getMany`) — never the whole fleet to label a handful of ids.
3905
+ * The picked ids' ledger fields, from ONE indexed read of the device manager's
3906
+ * own store (`rows.getMany`) — never the whole fleet to label a handful of ids.
3984
3907
  * An id no device has is absent from the map.
3985
3908
  */
3986
- async function pickedDevices(pctx, ids) {
3987
- const rows = await pctx.metaStore.rows.getMany(ids);
3909
+ function pickedFrom(rows) {
3988
3910
  const out = /* @__PURE__ */ new Map();
3989
3911
  for (const [id, row] of rows) out.set(id, {
3990
3912
  name: row.meta.name,
@@ -4013,6 +3935,78 @@ function sourceOf(deviceId, byId) {
4013
3935
  health: "ok"
4014
3936
  };
4015
3937
  }
3938
+ /**
3939
+ * Which ids of a written opt-in must be refused: a NEW opt-in for a device the
3940
+ * camera does not link. An id already stored is never refused — whatever was
3941
+ * read can be written back unchanged (a stale opt-in is removed by leaving it
3942
+ * out, never by being unable to save). The written list REPLACES the stored
3943
+ * one: since D655 the editor shows every stored opt-in, so nothing it sends
3944
+ * back is written blind.
3945
+ */
3946
+ function refusedTrackedOptIns(params) {
3947
+ const allowed = new Set([
3948
+ ...params.picked,
3949
+ ...params.implied,
3950
+ ...params.stored
3951
+ ]);
3952
+ return params.tracked.filter((id) => !allowed.has(id));
3953
+ }
3954
+ /** A ledger row as the pure link resolver reads it, or null for an unknown type. */
3955
+ function toLinkCandidate(row) {
3956
+ const type = Object.values(require_dist.DeviceType).find((t) => t === row.meta.type);
3957
+ if (type === void 0) return null;
3958
+ return {
3959
+ id: row.meta.id,
3960
+ parentDeviceId: row.meta.parentDeviceId,
3961
+ location: row.meta.location,
3962
+ type,
3963
+ stableId: row.meta.stableId,
3964
+ ...row.meta.primaryChildEntityId !== void 0 ? { primaryChildEntityId: row.meta.primaryChildEntityId } : {},
3965
+ ...row.meta.role !== void 0 ? { role: row.meta.role } : {},
3966
+ name: row.meta.name
3967
+ };
3968
+ }
3969
+ /** How deep a container nesting is walked — a cycle in the ledger must end. */
3970
+ var MAX_CONTAINER_DEPTH = 8;
3971
+ /**
3972
+ * The devices the camera links BY STRUCTURE, labelled: what the resolver adds
3973
+ * beyond the picks. Computed by the resolver itself (`resolveLinkedDeviceIds`)
3974
+ * over the only rows it can reach — the picks, the camera's children, and the
3975
+ * descendants of every container among them — read level by level with ONE
3976
+ * indexed `listByParentMany` each, never the fleet.
3977
+ */
3978
+ async function impliedLinks(pctx, deviceId, manualIds, pickedRows) {
3979
+ const rows = new Map(pickedRows);
3980
+ let parents = [deviceId, ...[...pickedRows.values()].filter((r) => r.meta.type === require_dist.DeviceType.Container).map((r) => r.meta.id)];
3981
+ const walked = /* @__PURE__ */ new Set();
3982
+ for (let depth = 0; depth < MAX_CONTAINER_DEPTH && parents.length > 0; depth++) {
3983
+ for (const id of parents) walked.add(id);
3984
+ const byParent = await pctx.metaStore.rows.listByParentMany(parents);
3985
+ const next = [];
3986
+ for (const children of byParent.values()) for (const child of children) {
3987
+ rows.set(child.meta.id, child);
3988
+ if (child.meta.type === require_dist.DeviceType.Container && !walked.has(child.meta.id)) next.push(child.meta.id);
3989
+ }
3990
+ parents = next;
3991
+ }
3992
+ const candidates = [...rows.values()].flatMap((r) => {
3993
+ const c = toLinkCandidate(r);
3994
+ return c === null ? [] : [c];
3995
+ });
3996
+ const picks = new Set(manualIds);
3997
+ return resolveLinkedDeviceIds({
3998
+ selfId: deviceId,
3999
+ config: {
4000
+ mode: "manual",
4001
+ manualIds
4002
+ },
4003
+ candidates
4004
+ }).filter((id) => !picks.has(id)).map((id) => ({
4005
+ deviceId: id,
4006
+ name: rows.get(id)?.meta.name ?? null,
4007
+ health: "ok"
4008
+ }));
4009
+ }
4016
4010
  function describeRefusal(deviceId, reason) {
4017
4011
  if (reason === "self") return `${deviceId} is the camera itself`;
4018
4012
  if (reason === "container") return `${deviceId} is a container — pick its children`;
@@ -4050,21 +4044,27 @@ function createDeviceManagerExtensionProvider(pctx) {
4050
4044
  label: "Device manager",
4051
4045
  slots: [require_dist.defineExtensionSlotProvider("linked-devices", {
4052
4046
  list: async (deviceId) => {
4053
- const { manualIds } = parseLinkedDevicesConfig(await pctx.settings.readDeviceStore(deviceId));
4054
- const byId = await pickedDevices(pctx, manualIds);
4047
+ const blob = await pctx.settings.readDeviceStore(deviceId);
4048
+ const { manualIds } = parseLinkedDevicesConfig(blob);
4049
+ const pickedRows = await pctx.metaStore.rows.getMany(manualIds);
4050
+ const byId = pickedFrom(pickedRows);
4051
+ const implied = await impliedLinks(pctx, deviceId, manualIds, pickedRows);
4055
4052
  return {
4056
4053
  authority: LINKED_DEVICES_AUTHORITY,
4057
4054
  enabled: null,
4058
4055
  activation: null,
4059
4056
  value: {
4060
4057
  slot: "linked-devices",
4061
- deviceIds: [...manualIds]
4058
+ deviceIds: [...manualIds],
4059
+ trackedDeviceIds: [...parseTrackedOptIn(blob)]
4062
4060
  },
4063
- sources: manualIds.map((id) => sourceOf(id, byId))
4061
+ sources: manualIds.map((id) => sourceOf(id, byId)),
4062
+ ...implied.length > 0 ? { impliedSources: implied } : {}
4064
4063
  };
4065
4064
  },
4066
4065
  set: async (deviceId, value) => {
4067
- const [byId, blob] = await Promise.all([pickedDevices(pctx, value.deviceIds), pctx.settings.readDeviceStore(deviceId)]);
4066
+ const [pickedRows, blob] = await Promise.all([pctx.metaStore.rows.getMany(value.deviceIds), pctx.settings.readDeviceStore(deviceId)]);
4067
+ const byId = pickedFrom(pickedRows);
4068
4068
  const refused = refusedLinkedPicks({
4069
4069
  selfId: deviceId,
4070
4070
  picked: value.deviceIds,
@@ -4078,7 +4078,32 @@ function createDeviceManagerExtensionProvider(pctx) {
4078
4078
  });
4079
4079
  throw new Error(`linked-devices on ${deviceId}: refused — ` + refused.map((r) => describeRefusal(r.deviceId, r.reason)).join("; "));
4080
4080
  }
4081
- await applyLinkedDevicesPatch(pctx, deviceId, { [LINKED_IDS_CONFIG_KEY]: value.deviceIds });
4081
+ const tracked = value.trackedDeviceIds;
4082
+ if (tracked === void 0) {
4083
+ await applyLinkedDevicesPatch(pctx, deviceId, { [LINKED_IDS_CONFIG_KEY]: value.deviceIds });
4084
+ return {
4085
+ applied: true,
4086
+ reload: "none"
4087
+ };
4088
+ }
4089
+ const implied = await impliedLinks(pctx, deviceId, value.deviceIds, pickedRows);
4090
+ const notLinked = refusedTrackedOptIns({
4091
+ tracked,
4092
+ picked: value.deviceIds,
4093
+ implied: implied.map((s) => s.deviceId),
4094
+ stored: parseTrackedOptIn(blob)
4095
+ });
4096
+ if (notLinked.length > 0) {
4097
+ pctx.host.ctx.logger.warn("linked-devices: write refused — nothing persisted", {
4098
+ tags: { deviceId },
4099
+ meta: { trackedNotLinked: notLinked }
4100
+ });
4101
+ throw new Error(`linked-devices on ${deviceId}: refused — tracked events can only be opted in for a linked device, not ${notLinked.join(", ")}`);
4102
+ }
4103
+ await applyLinkedDevicesPatch(pctx, deviceId, {
4104
+ [LINKED_IDS_CONFIG_KEY]: value.deviceIds,
4105
+ [LINKED_TRACKED_IDS_CONFIG_KEY]: [...new Set(tracked)]
4106
+ });
4082
4107
  return {
4083
4108
  applied: true,
4084
4109
  reload: "none"
@@ -4260,9 +4285,9 @@ async function reloadMigratedDevices(pctx, targets) {
4260
4285
  *
4261
4286
  * - The DRIVER config keys (host, port, credentials, `deviceCache`, every
4262
4287
  * schema field) SWAP — they describe the box.
4263
- * - The device-manager-owned section keys ({@link DEVICE_STORE_SECTIONS}:
4264
- * linked-devices mode/ids) STAY with the number — a sensor is linked to
4265
- * "the driveway camera", not to a serial number.
4288
+ * - The device-manager-owned linked-devices keys (mode, ids, tracked-event
4289
+ * opt-in — {@link ROLE_SCOPED_CONFIG_KEYS}) STAY with the number — a sensor
4290
+ * is linked to "the driveway camera", not to a serial number.
4266
4291
  * - `_profileMap` is DROPPED from both. It is the legacy spelling of the
4267
4292
  * stream-profile assignment (the live authority is the stream broker's own
4268
4293
  * map — see `StreamsTab.tsx`, which stopped writing it), and either copy
@@ -4280,7 +4305,7 @@ async function reloadMigratedDevices(pctx, targets) {
4280
4305
  /** Legacy stream-profile assignment key (cf. `device-queries.ts`). */
4281
4306
  var PROFILE_MAP_CONFIG_KEY = "_profileMap";
4282
4307
  /** Config keys that belong to the camera's ROLE and stay with the number. */
4283
- var ROLE_SCOPED_CONFIG_KEYS = new Set(DEVICE_STORE_SECTIONS.flatMap((section) => [...section.keys]));
4308
+ var ROLE_SCOPED_CONFIG_KEYS = LINKED_DEVICES_RESERVED_KEYS;
4284
4309
  function splitConfig(config) {
4285
4310
  const hardware = {};
4286
4311
  const role = {};
@@ -8067,9 +8092,6 @@ var DeviceManagerAddon = class extends require_dist.BaseAddon {
8067
8092
  getChildrenBatch: (input) => getChildrenBatch(pctx, input),
8068
8093
  getLinkedDevices: (input) => getLinkedDevices(pctx, input),
8069
8094
  getLinkedDevicesBatch: (input) => getLinkedDevicesBatch(pctx, input),
8070
- getDeviceSettingsContribution: (input) => buildLinkedDevicesContribution(pctx, input.deviceId),
8071
- getDeviceLiveContribution: async () => null,
8072
- applyDeviceSettingsPatch: (input) => applyLinkedDevicesPatch(pctx, input.deviceId, input.patch),
8073
8095
  getStreamSources: (input) => getStreamSources(pctx, input),
8074
8096
  getConfigSchema: (input) => getConfigSchema(pctx, input),
8075
8097
  getSettingsSchema: (input) => getSettingsSchema(pctx, input),