@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
@@ -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-vecbhDzu.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
@@ -166,13 +166,19 @@ var DoorbellTriggerEngine = class {
166
166
  * not survive the next read. So a negative read ARMS the revocation and a
167
167
  * second consecutive negative applies it.
168
168
  *
169
- * ERRING. `undefined` (never read) and `unbind-pending` both ring. Reaching
170
- * the ring path at all means the camera has a configured trigger source,
171
- * and a source can only be configured through this wrapper's own
172
- * binding-gated settings surface — so a configured source is itself
173
- * evidence the wrapper was bound. The cost of erring open is a spurious
174
- * ring on a camera the operator unbound but left configured, for at most
175
- * two reload cycles. The cost of erring closed is the press.
169
+ * ERRING. `undefined` (never read) and `unbind-pending` both ring. The cost
170
+ * of erring open is a spurious ring on a camera whose wrapper is not bound
171
+ * but which has trigger sources, for at most two reload cycles. The cost of
172
+ * erring closed is the press.
173
+ *
174
+ * D655: a configured source is NO LONGER evidence of the binding. D49 leaned
175
+ * on "a source can only be configured through this wrapper's own
176
+ * binding-gated settings surface"; that surface is retired, and the
177
+ * `doorbell-source` extension slot that replaced it is offered on every
178
+ * camera, bound or not (D648). The erring-open window above is therefore
179
+ * reachable by configuring sources on a never-bound camera, not only by
180
+ * unbinding a configured one — still bounded by two reload cycles, and the
181
+ * slot's card says `inactive` once the reads agree.
176
182
  *
177
183
  * ─────────────────────────────────────────────────────────────────────
178
184
  * 2026-08-07 (D72). The corroboration above was DEFEATED, and the reason
@@ -210,7 +216,7 @@ function nextBindingState(previous, boundNow) {
210
216
  }
211
217
  /** May a ring proceed? Only a corroborated unbind stops one. */
212
218
  function allowsRing(state) {
213
- return state !== "unbound";
219
+ return !needsDurableRead(state);
214
220
  }
215
221
  /** True only when a read has positively confirmed the binding. */
216
222
  function hasConfirmedBinding(state) {
@@ -262,6 +268,29 @@ function readDurableWrapperBinding(record, deviceId, capName, ownAddonId) {
262
268
  if (typeof wrapperAddonId !== "string") return "unbound";
263
269
  return require_dist.isSameAddonId(wrapperAddonId, ownAddonId) ? "bound" : "unbound";
264
270
  }
271
+ /** Does this mirror state need the durable authority before a verdict? Only `unbound` does. */
272
+ function needsDurableRead(state) {
273
+ return state === "unbound";
274
+ }
275
+ /**
276
+ * THE decision — the ring gate (`fireDoorbell` / `ringsDespiteUnboundMirror`)
277
+ * and the Extensions card's activation both call it, so what the card says
278
+ * and what a press does cannot disagree. `durable` is `null` when it was not
279
+ * read, which {@link needsDurableRead} allows for every state but `unbound`.
280
+ * Pure: it reads nothing and writes nothing.
281
+ */
282
+ function ringVerdict(state, durable) {
283
+ if (state === "bound") return "rings";
284
+ if (state === "unbind-pending") return "rings-pending";
285
+ if (state === void 0) return "rings-unconfirmed";
286
+ if (durable === "bound") return "rings";
287
+ if (durable === "unbound") return "silent";
288
+ return "unknown";
289
+ }
290
+ /** Whether a press proceeds under this verdict. */
291
+ function verdictRings(verdict) {
292
+ return verdict !== "silent";
293
+ }
265
294
  //#endregion
266
295
  //#region src/builtins/doorbell/doorbell-settings.ts
267
296
  /**
@@ -354,6 +383,203 @@ function applyDoorbellSourcesPatch(current, patchValue) {
354
383
  return next;
355
384
  }
356
385
  //#endregion
386
+ //#region src/builtins/doorbell/doorbell-extension-slot.ts
387
+ /**
388
+ * The virtual doorbell's side of `device-extension` (D648): the
389
+ * `doorbell-source` slot, served over the wrapper's `doorbellSources` store
390
+ * — since D655 the only editor of a camera's trigger devices.
391
+ *
392
+ * - `list` reads the store through `parseDoorbellSources` — the parser the
393
+ * trigger engine's assignments are built from — so the slot's value is
394
+ * exactly what rings. It also REPORTS whether the wrapper is bound to the
395
+ * camera (`activation`), named as `setWrapperActive`'s: sources on an
396
+ * unbound camera ring nothing, and the card must say so rather than look
397
+ * configured. Nothing here writes it.
398
+ * - The activation costs NOTHING on the common path. A camera with no
399
+ * sources has no gate worth reporting and makes no read. A camera with
400
+ * sources is answered from the addon's D49 binding mirror — the in-memory
401
+ * verdict the ring path itself uses — through `ringVerdict`, the ring
402
+ * gate's own decision function. The card is READ-ONLY: it never refreshes
403
+ * or writes the mirror, and reads the durable authority only for an
404
+ * `unbound` mirror, exactly where the ring gate reads it.
405
+ * `getBindings` was measured at up to 5.24 s on the live hub (2026-08-30),
406
+ * and the dispatcher already paid one for this page.
407
+ * - The picked sources are labelled with ONE batched `listAll({deviceIds})`,
408
+ * never a `getDevice` per id.
409
+ * - `set` goes through the addon's `persistDoorbellSources`, the SAME
410
+ * normalise-and-store path the wrapper has, which also invalidates
411
+ * the assignment cache so the binding mirror is re-read off the ring path
412
+ * (D49). A `setSlot` call is NOT evidence that the wrapper is bound (the
413
+ * contract reaches this provider through `device-extension`'s own
414
+ * binding), so it never confirms the mirror. A NEW pick must be a device
415
+ * that carries a source cap (`SOURCE_CAPS`, the D636 ids-only read) — which
416
+ * also refuses a container, a device with no source cap and an id no
417
+ * device has — and never the camera itself.
418
+ * - `candidates` are those same source-capable devices, devices in the
419
+ * camera's location suggested.
420
+ */
421
+ /** The routing prefix of this owner's `slotRef`s. */
422
+ var VIRTUAL_DOORBELL_EXTENSION_SOURCE = "virtual-doorbell";
423
+ /** The store the slot reads and writes, named (D62). */
424
+ var DOORBELL_SOURCES_AUTHORITY = `virtual-doorbell:${KEY_SOURCES}`;
425
+ /** The authority that decides whether the wrapper serves a camera at all. */
426
+ var WRAPPER_ACTIVATION_AUTHORITY = "device-manager:setWrapperActive";
427
+ 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.";
428
+ var PENDING_DETAIL = "One bindings read did not see the wrapper; a press still rings until a second read agrees.";
429
+ /** How the card SAYS a verdict. The verdict itself is `ringVerdict`'s, never the card's. */
430
+ function activationFromVerdict(verdict) {
431
+ const authority = WRAPPER_ACTIVATION_AUTHORITY;
432
+ switch (verdict) {
433
+ case "rings": return {
434
+ state: "active",
435
+ authority
436
+ };
437
+ case "rings-pending": return {
438
+ state: "active",
439
+ authority,
440
+ detail: PENDING_DETAIL
441
+ };
442
+ case "rings-unconfirmed": return {
443
+ state: "active",
444
+ authority,
445
+ detail: "No bindings read has confirmed the wrapper yet; a press rings meanwhile."
446
+ };
447
+ case "unknown": return {
448
+ state: "unknown",
449
+ authority,
450
+ detail: "Could not verify: the last reads did not see the wrapper and the bindings store cannot answer. A press still rings."
451
+ };
452
+ case "silent": return {
453
+ state: "inactive",
454
+ authority,
455
+ detail: INACTIVE_DETAIL
456
+ };
457
+ }
458
+ }
459
+ /**
460
+ * Concurrent card views of the same `unbound` camera share ONE durable read,
461
+ * per camera, for as long as it is in flight — a page with several viewers
462
+ * (or a refetch storm) must not multiply the store round trip. Only the
463
+ * CARD's reads are shared: the ring gate calls the addon's
464
+ * `readDurableAuthority` on its own path and never waits behind a card.
465
+ */
466
+ function shareDurableReads(read) {
467
+ const inFlight = /* @__PURE__ */ new Map();
468
+ return (deviceId) => {
469
+ const pending = inFlight.get(deviceId);
470
+ if (pending !== void 0) return pending;
471
+ const started = read(deviceId).finally(() => {
472
+ inFlight.delete(deviceId);
473
+ });
474
+ inFlight.set(deviceId, started);
475
+ return started;
476
+ };
477
+ }
478
+ async function activationOf(deps, readDurable, deviceId) {
479
+ const mirrored = deps.readBindingMirror(deviceId);
480
+ return activationFromVerdict(ringVerdict(mirrored, needsDurableRead(mirrored) ? await readDurable(deviceId) : null));
481
+ }
482
+ function sourceOf(deviceId, byId) {
483
+ const device = byId.get(deviceId);
484
+ if (device === void 0) return {
485
+ deviceId,
486
+ name: null,
487
+ health: "missing",
488
+ detail: "No device has this id any more — it rings nothing until removed."
489
+ };
490
+ return {
491
+ deviceId,
492
+ name: device.name,
493
+ health: "ok"
494
+ };
495
+ }
496
+ async function candidatesFor(deps, deviceId) {
497
+ const [ids, fleet] = await Promise.all([deps.sourceCapableDeviceIds(), deps.listFleet()]);
498
+ const eligible = new Set(ids);
499
+ const byId = new Map(fleet.map((d) => [d.id, d]));
500
+ const location = byId.get(deviceId)?.location ?? null;
501
+ const out = [];
502
+ for (const device of fleet) {
503
+ if (device.id === deviceId || !eligible.has(device.id)) continue;
504
+ const suggested = location !== null && device.location === location;
505
+ const parent = device.parentDeviceId === null ? void 0 : byId.get(device.parentDeviceId);
506
+ out.push({
507
+ deviceId: device.id,
508
+ name: device.name,
509
+ type: device.type,
510
+ location: device.location,
511
+ parentName: parent?.name ?? null,
512
+ suggested,
513
+ reason: suggested ? "same location" : null
514
+ });
515
+ }
516
+ return out.toSorted((a, b) => Number(b.suggested) - Number(a.suggested) || a.name.localeCompare(b.name));
517
+ }
518
+ /** Why each NEW pick is refused, or [] when every one is a legitimate source. */
519
+ async function refusedPicks(deps, deviceId, picked) {
520
+ const persisted = new Set((await deps.readSources(deviceId)).map((s) => s.deviceId));
521
+ const added = picked.filter((id) => !persisted.has(id));
522
+ if (added.length === 0) return [];
523
+ const eligible = new Set(await deps.sourceCapableDeviceIds());
524
+ const refused = [];
525
+ for (const id of added) if (id === deviceId) refused.push(`${id} is the camera itself`);
526
+ else if (!eligible.has(id)) refused.push(`${id} carries no doorbell source capability (a binary sensor or switch)`);
527
+ return refused;
528
+ }
529
+ /** Build the `device-extension` provider the virtual doorbell registers. */
530
+ function createDoorbellExtensionProvider(deps) {
531
+ const readDurable = shareDurableReads(deps.readDurableBinding);
532
+ return require_dist.createDeviceExtensionProvider({
533
+ source: VIRTUAL_DOORBELL_EXTENSION_SOURCE,
534
+ label: "Virtual doorbell",
535
+ slots: [require_dist.defineExtensionSlotProvider("doorbell-source", {
536
+ list: async (deviceId) => {
537
+ const ids = (await deps.readSources(deviceId)).map((s) => s.deviceId);
538
+ if (ids.length === 0) return {
539
+ authority: DOORBELL_SOURCES_AUTHORITY,
540
+ enabled: null,
541
+ activation: null,
542
+ value: {
543
+ slot: "doorbell-source",
544
+ deviceIds: []
545
+ },
546
+ sources: []
547
+ };
548
+ const [devices, activation] = await Promise.all([deps.listDevices(ids), activationOf(deps, readDurable, deviceId)]);
549
+ const byId = new Map(devices.map((d) => [d.id, d]));
550
+ return {
551
+ authority: DOORBELL_SOURCES_AUTHORITY,
552
+ enabled: null,
553
+ activation,
554
+ value: {
555
+ slot: "doorbell-source",
556
+ deviceIds: ids
557
+ },
558
+ sources: ids.map((id) => sourceOf(id, byId))
559
+ };
560
+ },
561
+ set: async (deviceId, value) => {
562
+ const refused = await refusedPicks(deps, deviceId, value.deviceIds);
563
+ if (refused.length > 0) {
564
+ deps.logger.warn("virtual-doorbell: extension write refused — nothing persisted", {
565
+ tags: { deviceId },
566
+ meta: { refused }
567
+ });
568
+ throw new Error(`doorbell-source on ${deviceId}: refused — ${refused.join("; ")}`);
569
+ }
570
+ await deps.persistSources(deviceId, value.deviceIds);
571
+ return {
572
+ applied: true,
573
+ reload: "none"
574
+ };
575
+ },
576
+ candidates: (deviceId) => candidatesFor(deps, deviceId)
577
+ })],
578
+ hostTypeOf: async (deviceId) => (await deps.getDevice(deviceId))?.type ?? null,
579
+ logger: deps.logger
580
+ });
581
+ }
582
+ //#endregion
357
583
  //#region src/builtins/doorbell/virtual-doorbell.addon.ts
358
584
  /**
359
585
  * The addon whose store IS the durable authority on wrapper activations.
@@ -402,9 +628,9 @@ function parseDoorbellSlice(slice) {
402
628
  *
403
629
  * 1. The operator binds the wrapper to a camera in the bindings UI
404
630
  * (`defaultActive: false` — never auto-bound).
405
- * 2. In the camera's GENERAL settings (next to "Linked devices") the
406
- * operator picks one or more SOURCE devices from a searchable list —
407
- * that is the ONLY choice. The reaction is derived automatically per
631
+ * 2. In the camera's Extensions section (the `doorbell-source` slot,
632
+ * D648/D655) the operator picks one or more SOURCE devices — that is
633
+ * the ONLY choice. The reaction is derived automatically per
408
634
  * device type: a binary sensor rings when it goes active, a switch
409
635
  * rings when it turns on (see `trigger-engine.ts`). The persisted
410
636
  * shape (`doorbellSources: [{deviceId}]`) stays extensible for a
@@ -451,14 +677,43 @@ var VirtualDoorbellAddon = class extends require_dist.BaseAddon {
451
677
  });
452
678
  return [{
453
679
  capability: require_dist.doorbellCapability,
454
- provider: {
455
- getStatus: (input) => this.getStatus(input.deviceId),
456
- getDeviceSettingsContribution: (input) => this.buildDeviceSettingsContribution(input.deviceId),
457
- getDeviceLiveContribution: async () => null,
458
- applyDeviceSettingsPatch: (input) => this.saveDeviceSettingsPatch(input.deviceId, input.patch)
459
- }
680
+ provider: { getStatus: (input) => this.getStatus(input.deviceId) }
681
+ }, {
682
+ capability: require_dist.deviceExtensionCapability,
683
+ provider: this.buildExtensionProvider()
460
684
  }];
461
685
  }
686
+ /**
687
+ * The `device-extension` provider: the only editor of a camera's trigger
688
+ * devices since D655 (see `doorbell-extension-slot.ts`).
689
+ */
690
+ buildExtensionProvider() {
691
+ const toSlotDevice = (d) => ({
692
+ id: d.id,
693
+ name: d.name,
694
+ type: d.type,
695
+ location: d.location,
696
+ parentDeviceId: d.parentDeviceId
697
+ });
698
+ return createDoorbellExtensionProvider({
699
+ ownAddonId: this.ownAddonId,
700
+ logger: this.ctx.logger,
701
+ readSources: (deviceId) => this.readDeviceSources(deviceId),
702
+ persistSources: (deviceId, deviceIds) => this.persistDoorbellSources(deviceId, deviceIds),
703
+ getDevice: async (deviceId) => {
704
+ const found = await this.ctx.api.deviceManager.getDevice.query({ deviceId });
705
+ return found ? toSlotDevice(found) : null;
706
+ },
707
+ listDevices: async (deviceIds) => (await this.ctx.api.deviceManager.listAll.query({
708
+ projection: "slim",
709
+ deviceIds: [...deviceIds]
710
+ })).map(toSlotDevice),
711
+ readBindingMirror: (deviceId) => this.bindingMirror.get(deviceId),
712
+ readDurableBinding: (deviceId) => this.readDurableAuthority(deviceId),
713
+ sourceCapableDeviceIds: () => this.ctx.api.deviceManager.listDeviceIdsByCaps.query({ capNames: [...require_dist.SOURCE_CAPS] }),
714
+ listFleet: async () => (await this.ctx.api.deviceManager.listAll.query({ projection: "slim" })).map(toSlotDevice)
715
+ });
716
+ }
462
717
  async handleStateChanged(data) {
463
718
  if (!require_dist.isSourceCap(data.capName)) return;
464
719
  await this.ensureAssignments();
@@ -676,53 +931,49 @@ var VirtualDoorbellAddon = class extends require_dist.BaseAddon {
676
931
  */
677
932
  async ringsDespiteUnboundMirror(cameraId, binding) {
678
933
  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)
934
+ const verdict = ringVerdict(binding, authority);
935
+ switch (verdict) {
936
+ case "rings":
937
+ this.bindingMirror.set(cameraId, "bound");
938
+ 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", {
939
+ tags: { deviceId: cameraId },
940
+ meta: {
941
+ bindingState: binding,
942
+ ownAddonId: this.ownAddonId
943
+ }
944
+ });
945
+ return verdictRings(verdict);
946
+ case "unknown":
947
+ 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", {
948
+ tags: { deviceId: cameraId },
949
+ meta: {
950
+ bindingState: binding,
951
+ ownAddonId: this.ownAddonId
952
+ }
953
+ });
954
+ return verdictRings(verdict);
955
+ case "silent": {
956
+ const derived = await this.readDerivedBinding(cameraId);
957
+ 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", {
958
+ tags: { deviceId: cameraId },
959
+ meta: {
960
+ bindingState: binding,
961
+ ownAddonId: this.ownAddonId,
962
+ durableAuthority: authority,
963
+ entries: derived === null ? "unreadable" : summariseEntries(derived.entries)
964
+ }
965
+ });
966
+ return verdictRings(verdict);
708
967
  }
709
- });
710
- return false;
711
- }
712
- /**
713
- * Record a binding we observed FIRST-HAND. Both callers are cap methods
714
- * the router only reaches by resolving this wrapper for this device —
715
- * which it can only do through the binding — so a call is direct evidence
716
- * and outranks anything the store says.
717
- */
718
- confirmBindingFromCapCall(deviceId) {
719
- this.bindingMirror.set(deviceId, nextBindingState(this.bindingMirror.get(deviceId), true));
968
+ case "rings-pending":
969
+ case "rings-unconfirmed": return verdictRings(verdict);
970
+ }
720
971
  }
721
972
  /**
722
973
  * Lazily (re)build the camera→source assignment set from every
723
974
  * camera's device store. TTL-bounded so devices/settings appearing
724
- * after boot are picked up without a settings save; invalidated
725
- * eagerly by `applyDeviceSettingsPatch`.
975
+ * after boot are picked up without a save; invalidated eagerly by
976
+ * `persistDoorbellSources`.
726
977
  */
727
978
  async ensureAssignments() {
728
979
  const now = Date.now();
@@ -793,72 +1044,17 @@ var VirtualDoorbellAddon = class extends require_dist.BaseAddon {
793
1044
  return parseDoorbellSources(await this.ctx.settings.readDeviceStore(deviceId));
794
1045
  }
795
1046
  /**
796
- * Single-trip, non-throwing device lookup (mirrors snapshot's
797
- * `lookupDeviceMeta`). Null on any failure — callers fall back to
798
- * rendering the section (better a spurious section than a thrown
799
- * aggregator entry).
1047
+ * THE write path for a camera's trigger sources — the `doorbell-source`
1048
+ * extension slot lands here (D648; its only caller since D655): normalise
1049
+ * to `doorbellSources: [{deviceId}]`, strip the legacy keys, and invalidate
1050
+ * the assignment cache so the next rise (and the binding mirror's next
1051
+ * refresh, off the ring path) sees the new wiring.
800
1052
  */
801
- async lookupDevice(deviceId) {
802
- try {
803
- const found = await this.ctx.api.deviceManager.getDevice.query({ deviceId });
804
- return found ? { type: found.type } : null;
805
- } catch (err) {
806
- this.ctx.logger.debug("virtual-doorbell: getDevice failed during contribution", {
807
- tags: { deviceId },
808
- meta: { error: require_dist.errMsg(err) }
809
- });
810
- return null;
811
- }
812
- }
813
- /**
814
- * Settings section for the device-detail page — contributed into the
815
- * GENERAL settings surface (no `tab`, so it lands in the same tab as
816
- * "Linked devices", ordered just after it). Camera-only: the doorbell
817
- * cap also applies to Button accessories (native providers) where a
818
- * virtual-source picker is meaningless.
819
- *
820
- * A SINGLE `device-multiselect` field carries only a declarative
821
- * `deviceFilter` — the admin-ui renders the SHARED searchable device
822
- * picker (same idiom as "Linked devices") whose options come from
823
- * `deviceManager.listAll` filtered CLIENT-SIDE. The addon NO LONGER
824
- * computes the option list server-side: the old path enumerated
825
- * `deviceManager.getAllBindings`, which returns an empty set on the live
826
- * cluster and left the picker blank. The reaction is still derived
827
- * automatically per device type — no trigger/eventType controls.
828
- */
829
- async buildDeviceSettingsContribution(deviceId) {
830
- const device = await this.lookupDevice(deviceId);
831
- if (device && device.type !== require_dist.DeviceType.Camera) return null;
832
- this.confirmBindingFromCapCall(deviceId);
833
- const sources = await this.readDeviceSources(deviceId);
834
- return { sections: [{
835
- id: "virtual-doorbell",
836
- title: "Virtual doorbell",
837
- description: "Ring this camera’s doorbell when a chosen device becomes active — a contact opening, a switch turning on, or a sensor firing. Pick any binary sensor or switch below; the reaction is automatic per device type.",
838
- order: 71,
839
- fields: [{
840
- type: "device-multiselect",
841
- key: KEY_SOURCES,
842
- label: "Trigger devices",
843
- description: "Any of these becoming active rings this camera. Binary sensors ring when they go active; switches ring when they turn on.",
844
- deviceFilter: {
845
- caps: require_dist.SOURCE_CAPS,
846
- types: require_dist.SOURCE_DEVICE_TYPES,
847
- excludeDeviceId: deviceId
848
- },
849
- value: sources.map((s) => String(s.deviceId))
850
- }]
851
- }] };
852
- }
853
- async saveDeviceSettingsPatch(deviceId, patch) {
1053
+ async persistDoorbellSources(deviceId, value) {
854
1054
  if (!this.ctx.settings) throw new Error("[virtual-doorbell] settings store unavailable — cannot persist per-device settings");
855
- 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
- }
861
- return { success: true };
1055
+ const current = await this.ctx.settings.readDeviceStore(deviceId);
1056
+ await this.ctx.settings.writeDeviceStore(deviceId, applyDoorbellSourcesPatch(current, value));
1057
+ this.invalidateAssignments();
862
1058
  }
863
1059
  };
864
1060
  //#endregion