@camstack/system 1.2.304 → 1.2.305

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 (71) 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 +8 -0
  23. package/dist/builtins/device-manager/device-linked-devices.d.ts +34 -0
  24. package/dist/builtins/device-manager/device-manager.addon.js +188 -22
  25. package/dist/builtins/device-manager/device-manager.addon.mjs +188 -22
  26. package/dist/builtins/doorbell/binding-mirror.d.ts +24 -0
  27. package/dist/builtins/doorbell/doorbell-extension-slot.d.ts +66 -0
  28. package/dist/builtins/doorbell/virtual-doorbell.addon.d.ts +14 -0
  29. package/dist/builtins/doorbell/virtual-doorbell.addon.js +307 -38
  30. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +307 -38
  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-C571Y95Z.js → dist-BK4iP6ab.js} +1069 -310
  62. package/dist/{dist-Duqf80QM.mjs → dist-Df1NhKxm.mjs} +1046 -311
  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-DwWMph6F.js} +1 -1
  70. package/dist/{retired-settings-keys-CeO_dBHU.mjs → retired-settings-keys-dC4ckbXJ.mjs} +1 -1
  71. package/package.json +7 -1
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-C571Y95Z.js");
6
+ const require_dist = require("../../dist-BK4iP6ab.js");
7
7
  //#region src/builtins/doorbell/trigger-engine.ts
8
8
  /**
9
9
  * Pure trigger logic for the virtual-doorbell builtin — edge detection over
@@ -210,7 +210,7 @@ function nextBindingState(previous, boundNow) {
210
210
  }
211
211
  /** May a ring proceed? Only a corroborated unbind stops one. */
212
212
  function allowsRing(state) {
213
- return state !== "unbound";
213
+ return !needsDurableRead(state);
214
214
  }
215
215
  /** True only when a read has positively confirmed the binding. */
216
216
  function hasConfirmedBinding(state) {
@@ -262,6 +262,29 @@ function readDurableWrapperBinding(record, deviceId, capName, ownAddonId) {
262
262
  if (typeof wrapperAddonId !== "string") return "unbound";
263
263
  return require_dist.isSameAddonId(wrapperAddonId, ownAddonId) ? "bound" : "unbound";
264
264
  }
265
+ /** Does this mirror state need the durable authority before a verdict? Only `unbound` does. */
266
+ function needsDurableRead(state) {
267
+ return state === "unbound";
268
+ }
269
+ /**
270
+ * THE decision — the ring gate (`fireDoorbell` / `ringsDespiteUnboundMirror`)
271
+ * and the Extensions card's activation both call it, so what the card says
272
+ * and what a press does cannot disagree. `durable` is `null` when it was not
273
+ * read, which {@link needsDurableRead} allows for every state but `unbound`.
274
+ * Pure: it reads nothing and writes nothing.
275
+ */
276
+ function ringVerdict(state, durable) {
277
+ if (state === "bound") return "rings";
278
+ if (state === "unbind-pending") return "rings-pending";
279
+ if (state === void 0) return "rings-unconfirmed";
280
+ if (durable === "bound") return "rings";
281
+ if (durable === "unbound") return "silent";
282
+ return "unknown";
283
+ }
284
+ /** Whether a press proceeds under this verdict. */
285
+ function verdictRings(verdict) {
286
+ return verdict !== "silent";
287
+ }
265
288
  //#endregion
266
289
  //#region src/builtins/doorbell/doorbell-settings.ts
267
290
  /**
@@ -354,6 +377,203 @@ function applyDoorbellSourcesPatch(current, patchValue) {
354
377
  return next;
355
378
  }
356
379
  //#endregion
380
+ //#region src/builtins/doorbell/doorbell-extension-slot.ts
381
+ /**
382
+ * The virtual doorbell's side of `device-extension` (D648): the
383
+ * `doorbell-source` slot, served over the `doorbellSources` store the
384
+ * "Virtual doorbell" settings section already writes.
385
+ *
386
+ * - `list` reads the store through `parseDoorbellSources` — the parser the
387
+ * trigger engine's assignments are built from — so the slot's value is
388
+ * exactly what rings. It also REPORTS whether the wrapper is bound to the
389
+ * camera (`activation`), named as `setWrapperActive`'s: sources on an
390
+ * unbound camera ring nothing, and the card must say so rather than look
391
+ * configured. Nothing here writes it.
392
+ * - The activation costs NOTHING on the common path. A camera with no
393
+ * sources has no gate worth reporting and makes no read. A camera with
394
+ * sources is answered from the addon's D49 binding mirror — the in-memory
395
+ * verdict the ring path itself uses — through `ringVerdict`, the ring
396
+ * gate's own decision function. The card is READ-ONLY: it never refreshes
397
+ * or writes the mirror, and reads the durable authority only for an
398
+ * `unbound` mirror, exactly where the ring gate reads it.
399
+ * `getBindings` was measured at up to 5.24 s on the live hub (2026-08-30),
400
+ * and the dispatcher already paid one for this page.
401
+ * - The picked sources are labelled with ONE batched `listAll({deviceIds})`,
402
+ * never a `getDevice` per id.
403
+ * - `set` goes through the addon's `persistDoorbellSources`, the SAME
404
+ * normalise-and-store path the settings save uses, which also invalidates
405
+ * the assignment cache so the binding mirror is re-read off the ring path
406
+ * (D49). A `setSlot` call is NOT evidence that the wrapper is bound (the
407
+ * contract reaches this provider through `device-extension`'s own
408
+ * binding), so it never confirms the mirror. A NEW pick must be a device
409
+ * that carries a source cap (`SOURCE_CAPS`, the D636 ids-only read) — which
410
+ * also refuses a container, a device with no source cap and an id no
411
+ * device has — and never the camera itself.
412
+ * - `candidates` are those same source-capable devices, devices in the
413
+ * camera's location suggested.
414
+ */
415
+ /** The routing prefix of this owner's `slotRef`s. */
416
+ var VIRTUAL_DOORBELL_EXTENSION_SOURCE = "virtual-doorbell";
417
+ /** The store the slot reads and writes, named (D62). */
418
+ var DOORBELL_SOURCES_AUTHORITY = `virtual-doorbell:${KEY_SOURCES}`;
419
+ /** The authority that decides whether the wrapper serves a camera at all. */
420
+ var WRAPPER_ACTIVATION_AUTHORITY = "device-manager:setWrapperActive";
421
+ var INACTIVE_DETAIL = "The virtual doorbell is not bound to this camera: these devices ring nothing until it is activated in the camera’s bindings.";
422
+ var PENDING_DETAIL = "One bindings read did not see the wrapper; a press still rings until a second read agrees.";
423
+ /** How the card SAYS a verdict. The verdict itself is `ringVerdict`'s, never the card's. */
424
+ function activationFromVerdict(verdict) {
425
+ const authority = WRAPPER_ACTIVATION_AUTHORITY;
426
+ switch (verdict) {
427
+ case "rings": return {
428
+ state: "active",
429
+ authority
430
+ };
431
+ case "rings-pending": return {
432
+ state: "active",
433
+ authority,
434
+ detail: PENDING_DETAIL
435
+ };
436
+ case "rings-unconfirmed": return {
437
+ state: "active",
438
+ authority,
439
+ detail: "No bindings read has confirmed the wrapper yet; a press rings meanwhile."
440
+ };
441
+ case "unknown": return {
442
+ state: "unknown",
443
+ authority,
444
+ detail: "Could not verify: the last reads did not see the wrapper and the bindings store cannot answer. A press still rings."
445
+ };
446
+ case "silent": return {
447
+ state: "inactive",
448
+ authority,
449
+ detail: INACTIVE_DETAIL
450
+ };
451
+ }
452
+ }
453
+ /**
454
+ * Concurrent card views of the same `unbound` camera share ONE durable read,
455
+ * per camera, for as long as it is in flight — a page with several viewers
456
+ * (or a refetch storm) must not multiply the store round trip. Only the
457
+ * CARD's reads are shared: the ring gate calls the addon's
458
+ * `readDurableAuthority` on its own path and never waits behind a card.
459
+ */
460
+ function shareDurableReads(read) {
461
+ const inFlight = /* @__PURE__ */ new Map();
462
+ return (deviceId) => {
463
+ const pending = inFlight.get(deviceId);
464
+ if (pending !== void 0) return pending;
465
+ const started = read(deviceId).finally(() => {
466
+ inFlight.delete(deviceId);
467
+ });
468
+ inFlight.set(deviceId, started);
469
+ return started;
470
+ };
471
+ }
472
+ async function activationOf(deps, readDurable, deviceId) {
473
+ const mirrored = deps.readBindingMirror(deviceId);
474
+ return activationFromVerdict(ringVerdict(mirrored, needsDurableRead(mirrored) ? await readDurable(deviceId) : null));
475
+ }
476
+ function sourceOf(deviceId, byId) {
477
+ const device = byId.get(deviceId);
478
+ if (device === void 0) return {
479
+ deviceId,
480
+ name: null,
481
+ health: "missing",
482
+ detail: "No device has this id any more — it rings nothing until removed."
483
+ };
484
+ return {
485
+ deviceId,
486
+ name: device.name,
487
+ health: "ok"
488
+ };
489
+ }
490
+ async function candidatesFor(deps, deviceId) {
491
+ const [ids, fleet] = await Promise.all([deps.sourceCapableDeviceIds(), deps.listFleet()]);
492
+ const eligible = new Set(ids);
493
+ const byId = new Map(fleet.map((d) => [d.id, d]));
494
+ const location = byId.get(deviceId)?.location ?? null;
495
+ const out = [];
496
+ for (const device of fleet) {
497
+ if (device.id === deviceId || !eligible.has(device.id)) continue;
498
+ const suggested = location !== null && device.location === location;
499
+ const parent = device.parentDeviceId === null ? void 0 : byId.get(device.parentDeviceId);
500
+ out.push({
501
+ deviceId: device.id,
502
+ name: device.name,
503
+ type: device.type,
504
+ location: device.location,
505
+ parentName: parent?.name ?? null,
506
+ suggested,
507
+ reason: suggested ? "same location" : null
508
+ });
509
+ }
510
+ return out.toSorted((a, b) => Number(b.suggested) - Number(a.suggested) || a.name.localeCompare(b.name));
511
+ }
512
+ /** Why each NEW pick is refused, or [] when every one is a legitimate source. */
513
+ async function refusedPicks(deps, deviceId, picked) {
514
+ const persisted = new Set((await deps.readSources(deviceId)).map((s) => s.deviceId));
515
+ const added = picked.filter((id) => !persisted.has(id));
516
+ if (added.length === 0) return [];
517
+ const eligible = new Set(await deps.sourceCapableDeviceIds());
518
+ const refused = [];
519
+ for (const id of added) if (id === deviceId) refused.push(`${id} is the camera itself`);
520
+ else if (!eligible.has(id)) refused.push(`${id} carries no doorbell source capability (a binary sensor or switch)`);
521
+ return refused;
522
+ }
523
+ /** Build the `device-extension` provider the virtual doorbell registers. */
524
+ function createDoorbellExtensionProvider(deps) {
525
+ const readDurable = shareDurableReads(deps.readDurableBinding);
526
+ return require_dist.createDeviceExtensionProvider({
527
+ source: VIRTUAL_DOORBELL_EXTENSION_SOURCE,
528
+ label: "Virtual doorbell",
529
+ slots: [require_dist.defineExtensionSlotProvider("doorbell-source", {
530
+ list: async (deviceId) => {
531
+ const ids = (await deps.readSources(deviceId)).map((s) => s.deviceId);
532
+ if (ids.length === 0) return {
533
+ authority: DOORBELL_SOURCES_AUTHORITY,
534
+ enabled: null,
535
+ activation: null,
536
+ value: {
537
+ slot: "doorbell-source",
538
+ deviceIds: []
539
+ },
540
+ sources: []
541
+ };
542
+ const [devices, activation] = await Promise.all([deps.listDevices(ids), activationOf(deps, readDurable, deviceId)]);
543
+ const byId = new Map(devices.map((d) => [d.id, d]));
544
+ return {
545
+ authority: DOORBELL_SOURCES_AUTHORITY,
546
+ enabled: null,
547
+ activation,
548
+ value: {
549
+ slot: "doorbell-source",
550
+ deviceIds: ids
551
+ },
552
+ sources: ids.map((id) => sourceOf(id, byId))
553
+ };
554
+ },
555
+ set: async (deviceId, value) => {
556
+ const refused = await refusedPicks(deps, deviceId, value.deviceIds);
557
+ if (refused.length > 0) {
558
+ deps.logger.warn("virtual-doorbell: extension write refused — nothing persisted", {
559
+ tags: { deviceId },
560
+ meta: { refused }
561
+ });
562
+ throw new Error(`doorbell-source on ${deviceId}: refused — ${refused.join("; ")}`);
563
+ }
564
+ await deps.persistSources(deviceId, value.deviceIds);
565
+ return {
566
+ applied: true,
567
+ reload: "none"
568
+ };
569
+ },
570
+ candidates: (deviceId) => candidatesFor(deps, deviceId)
571
+ })],
572
+ hostTypeOf: async (deviceId) => (await deps.getDevice(deviceId))?.type ?? null,
573
+ logger: deps.logger
574
+ });
575
+ }
576
+ //#endregion
357
577
  //#region src/builtins/doorbell/virtual-doorbell.addon.ts
358
578
  /**
359
579
  * The addon whose store IS the durable authority on wrapper activations.
@@ -457,8 +677,42 @@ var VirtualDoorbellAddon = class extends require_dist.BaseAddon {
457
677
  getDeviceLiveContribution: async () => null,
458
678
  applyDeviceSettingsPatch: (input) => this.saveDeviceSettingsPatch(input.deviceId, input.patch)
459
679
  }
680
+ }, {
681
+ capability: require_dist.deviceExtensionCapability,
682
+ provider: this.buildExtensionProvider()
460
683
  }];
461
684
  }
685
+ /**
686
+ * The `device-extension` provider: the same store and the same save path as
687
+ * the settings section below (see `doorbell-extension-slot.ts`).
688
+ */
689
+ buildExtensionProvider() {
690
+ const toSlotDevice = (d) => ({
691
+ id: d.id,
692
+ name: d.name,
693
+ type: d.type,
694
+ location: d.location,
695
+ parentDeviceId: d.parentDeviceId
696
+ });
697
+ return createDoorbellExtensionProvider({
698
+ ownAddonId: this.ownAddonId,
699
+ logger: this.ctx.logger,
700
+ readSources: (deviceId) => this.readDeviceSources(deviceId),
701
+ persistSources: (deviceId, deviceIds) => this.persistDoorbellSources(deviceId, deviceIds),
702
+ getDevice: async (deviceId) => {
703
+ const found = await this.ctx.api.deviceManager.getDevice.query({ deviceId });
704
+ return found ? toSlotDevice(found) : null;
705
+ },
706
+ listDevices: async (deviceIds) => (await this.ctx.api.deviceManager.listAll.query({
707
+ projection: "slim",
708
+ deviceIds: [...deviceIds]
709
+ })).map(toSlotDevice),
710
+ readBindingMirror: (deviceId) => this.bindingMirror.get(deviceId),
711
+ readDurableBinding: (deviceId) => this.readDurableAuthority(deviceId),
712
+ sourceCapableDeviceIds: () => this.ctx.api.deviceManager.listDeviceIdsByCaps.query({ capNames: [...require_dist.SOURCE_CAPS] }),
713
+ listFleet: async () => (await this.ctx.api.deviceManager.listAll.query({ projection: "slim" })).map(toSlotDevice)
714
+ });
715
+ }
462
716
  async handleStateChanged(data) {
463
717
  if (!require_dist.isSourceCap(data.capName)) return;
464
718
  await this.ensureAssignments();
@@ -676,38 +930,43 @@ var VirtualDoorbellAddon = class extends require_dist.BaseAddon {
676
930
  */
677
931
  async ringsDespiteUnboundMirror(cameraId, binding) {
678
932
  const authority = await this.readDurableAuthority(cameraId);
679
- if (authority === "bound") {
680
- this.bindingMirror.set(cameraId, "bound");
681
- this.ctx.logger.warn("virtual-doorbell: ring RESCUED — the mirror said unbound but the durable bindings store still names this wrapper; the mirror has been repaired", {
682
- tags: { deviceId: cameraId },
683
- meta: {
684
- bindingState: binding,
685
- ownAddonId: this.ownAddonId
686
- }
687
- });
688
- return true;
689
- }
690
- if (authority === "unreadable") {
691
- this.ctx.logger.warn("virtual-doorbell: ringing on an unbound mirror — the durable bindings store could not answer, and an unanswerable store never destroys a one-shot press", {
692
- tags: { deviceId: cameraId },
693
- meta: {
694
- bindingState: binding,
695
- ownAddonId: this.ownAddonId
696
- }
697
- });
698
- return true;
699
- }
700
- const derived = await this.readDerivedBinding(cameraId);
701
- this.ctx.logger.warn("virtual-doorbell: ring DROPPED — the wrapper is not bound to this camera, but a trigger source is still configured for it", {
702
- tags: { deviceId: cameraId },
703
- meta: {
704
- bindingState: binding,
705
- ownAddonId: this.ownAddonId,
706
- durableAuthority: authority,
707
- entries: derived === null ? "unreadable" : summariseEntries(derived.entries)
933
+ const verdict = ringVerdict(binding, authority);
934
+ switch (verdict) {
935
+ case "rings":
936
+ this.bindingMirror.set(cameraId, "bound");
937
+ this.ctx.logger.warn("virtual-doorbell: ring RESCUED — the mirror said unbound but the durable bindings store still names this wrapper; the mirror has been repaired", {
938
+ tags: { deviceId: cameraId },
939
+ meta: {
940
+ bindingState: binding,
941
+ ownAddonId: this.ownAddonId
942
+ }
943
+ });
944
+ return verdictRings(verdict);
945
+ case "unknown":
946
+ this.ctx.logger.warn("virtual-doorbell: ringing on an unbound mirror — the durable bindings store could not answer, and an unanswerable store never destroys a one-shot press", {
947
+ tags: { deviceId: cameraId },
948
+ meta: {
949
+ bindingState: binding,
950
+ ownAddonId: this.ownAddonId
951
+ }
952
+ });
953
+ return verdictRings(verdict);
954
+ case "silent": {
955
+ const derived = await this.readDerivedBinding(cameraId);
956
+ this.ctx.logger.warn("virtual-doorbell: ring DROPPED — the wrapper is not bound to this camera, but a trigger source is still configured for it", {
957
+ tags: { deviceId: cameraId },
958
+ meta: {
959
+ bindingState: binding,
960
+ ownAddonId: this.ownAddonId,
961
+ durableAuthority: authority,
962
+ entries: derived === null ? "unreadable" : summariseEntries(derived.entries)
963
+ }
964
+ });
965
+ return verdictRings(verdict);
708
966
  }
709
- });
710
- return false;
967
+ case "rings-pending":
968
+ case "rings-unconfirmed": return verdictRings(verdict);
969
+ }
711
970
  }
712
971
  /**
713
972
  * Record a binding we observed FIRST-HAND. Both callers are cap methods
@@ -853,13 +1112,23 @@ var VirtualDoorbellAddon = class extends require_dist.BaseAddon {
853
1112
  async saveDeviceSettingsPatch(deviceId, patch) {
854
1113
  if (!this.ctx.settings) throw new Error("[virtual-doorbell] settings store unavailable — cannot persist per-device settings");
855
1114
  this.confirmBindingFromCapCall(deviceId);
856
- if ("doorbellSources" in patch) {
857
- const next = applyDoorbellSourcesPatch(await this.ctx.settings.readDeviceStore(deviceId), patch[KEY_SOURCES]);
858
- await this.ctx.settings.writeDeviceStore(deviceId, next);
859
- this.invalidateAssignments();
860
- }
1115
+ if ("doorbellSources" in patch) await this.persistDoorbellSources(deviceId, patch[KEY_SOURCES]);
861
1116
  return { success: true };
862
1117
  }
1118
+ /**
1119
+ * THE write path for a camera's trigger sources — the settings save above
1120
+ * and the `doorbell-source` extension slot both land here (D648), so the
1121
+ * two surfaces cannot store different shapes: normalise to
1122
+ * `doorbellSources: [{deviceId}]`, strip the legacy keys, and invalidate the
1123
+ * assignment cache so the next rise (and the binding mirror's next
1124
+ * refresh, off the ring path) sees the new wiring.
1125
+ */
1126
+ async persistDoorbellSources(deviceId, value) {
1127
+ if (!this.ctx.settings) throw new Error("[virtual-doorbell] settings store unavailable — cannot persist per-device settings");
1128
+ const current = await this.ctx.settings.readDeviceStore(deviceId);
1129
+ await this.ctx.settings.writeDeviceStore(deviceId, applyDoorbellSourcesPatch(current, value));
1130
+ this.invalidateAssignments();
1131
+ }
863
1132
  };
864
1133
  //#endregion
865
1134
  exports.VirtualDoorbellAddon = VirtualDoorbellAddon;