@camstack/system 1.2.57 → 1.2.59

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 (69) hide show
  1. package/dist/addon-runner.js +1 -1
  2. package/dist/addon-runner.mjs +1 -1
  3. package/dist/addon-utils.js +1 -1
  4. package/dist/addon-utils.mjs +1 -1
  5. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +2 -2
  6. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +2 -2
  7. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +2 -2
  8. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +2 -2
  9. package/dist/builtins/alerts/alerts.addon.js +1 -1
  10. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  11. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +2 -2
  12. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +2 -2
  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 +2 -2
  16. package/dist/builtins/core-blocks/core-blocks.addon.mjs +2 -2
  17. package/dist/builtins/device-manager/device-manager.addon.js +2 -2
  18. package/dist/builtins/device-manager/device-manager.addon.mjs +2 -2
  19. package/dist/builtins/doorbell/binding-mirror.d.ts +65 -0
  20. package/dist/builtins/doorbell/virtual-doorbell.addon.d.ts +56 -11
  21. package/dist/builtins/doorbell/virtual-doorbell.addon.js +248 -26
  22. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +248 -26
  23. package/dist/builtins/hub-forwarder/index.js +1 -1
  24. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  25. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  26. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  27. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  28. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  29. package/dist/builtins/local-network/local-network.addon.js +1 -1
  30. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  31. package/dist/builtins/loki-logging/index.js +1 -1
  32. package/dist/builtins/loki-logging/index.mjs +1 -1
  33. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  34. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  35. package/dist/builtins/platform-probe/index.js +1 -1
  36. package/dist/builtins/platform-probe/index.mjs +1 -1
  37. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  38. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  39. package/dist/builtins/snapshot/index.js +2 -2
  40. package/dist/builtins/snapshot/index.mjs +2 -2
  41. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +2 -2
  42. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +2 -2
  43. package/dist/builtins/sqlite-storage/index.d.ts +3 -0
  44. package/dist/builtins/sqlite-storage/index.js +8 -0
  45. package/dist/builtins/sqlite-storage/index.mjs +2 -2
  46. package/dist/builtins/sqlite-storage/retired-settings-keys.d.ts +94 -0
  47. package/dist/builtins/sqlite-storage/sqlite-pragmas.d.ts +85 -0
  48. package/dist/builtins/sqlite-storage/sqlite-settings.addon.d.ts +1 -0
  49. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +445 -2
  50. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +438 -3
  51. package/dist/builtins/sqlite-storage/wal-maintenance.d.ts +113 -0
  52. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  53. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  54. package/dist/builtins/system-config/system-config.addon.js +1 -1
  55. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  56. package/dist/builtins/winston-logging/index.js +1 -1
  57. package/dist/builtins/winston-logging/index.mjs +1 -1
  58. package/dist/{dist-DhL_zGRT.mjs → dist-BqJJWCS8.mjs} +994 -34
  59. package/dist/{dist-BupxnZOi.js → dist-CV6XTApk.js} +1005 -33
  60. package/dist/index.js +190 -42
  61. package/dist/index.mjs +191 -43
  62. package/dist/kernel/addon-installer.d.ts +55 -7
  63. package/dist/kernel/addon-manifest.d.ts +36 -16
  64. package/dist/kernel/transport/frame-codec.d.ts +43 -2
  65. package/dist/{manifest-python-deps-Ck4-9K9m.mjs → manifest-python-deps-CX5tGRqf.mjs} +95 -10
  66. package/dist/{manifest-python-deps-BqE5j0-O.js → manifest-python-deps-d4acYXVV.js} +95 -10
  67. package/package.json +1 -1
  68. package/dist/{model-download-service-hf0ookyy.js → model-download-service-Cpc6SWzV.js} +1 -1
  69. package/dist/{model-download-service-Cp9f4dk6.mjs → model-download-service-D8B-4ktF.mjs} +1 -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-BupxnZOi.js");
6
+ const require_dist = require("../../dist-CV6XTApk.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
@@ -221,6 +221,65 @@ var DoorbellTriggerEngine = class {
221
221
  //#endregion
222
222
  //#region src/builtins/doorbell/binding-mirror.ts
223
223
  /**
224
+ * The virtual-doorbell wrapper's in-memory mirror of ONE durable fact per
225
+ * camera: is this wrapper still the active `doorbell` binding for it?
226
+ *
227
+ * WHY A MIRROR AND NOT A READ. A doorbell press is a one-shot event: there
228
+ * is no retry, no reconcile, nothing downstream that can notice it went
229
+ * missing. The wrapper used to re-derive the binding on the ring path via
230
+ * `deviceManager.getBindings` and treat ANY answer that omitted its own
231
+ * activation as "the operator unbound this". On 2026-08-04/05 camera 615
232
+ * rang zero times out of three real presses: the binding was persisted (a
233
+ * same-day backup taken before the presses proves it, and the same process
234
+ * later returned it correctly to a tRPC query), but the store answered
235
+ * transiently without it — `getBindings` reads the device-manager addon
236
+ * store, which is exactly the surface that can silently answer `{}` (D44,
237
+ * `addon-registry.service.ts`). Three presses, three drops, at `debug`.
238
+ *
239
+ * WHY CORROBORATION AND NOT A BETTER DISCRIMINATOR. There isn't one. A
240
+ * genuinely unbound pure-wrapper cap and a store answering from an empty
241
+ * snapshot produce the SAME `getBindings` response — no `doorbell` entry,
242
+ * with the camera's native entries (snapshot, motion, …) still present in
243
+ * both cases because those come from the capability registry, not the
244
+ * store. The response cannot be graded. What CAN be graded is repetition: a
245
+ * real unbind is permanent and every later read agrees, a store blip does
246
+ * not survive the next read. So a negative read ARMS the revocation and a
247
+ * second consecutive negative applies it.
248
+ *
249
+ * ERRING. `undefined` (never read) and `unbind-pending` both ring. Reaching
250
+ * the ring path at all means the camera has a configured trigger source,
251
+ * and a source can only be configured through this wrapper's own
252
+ * binding-gated settings surface — so a configured source is itself
253
+ * evidence the wrapper was bound. The cost of erring open is a spurious
254
+ * ring on a camera the operator unbound but left configured, for at most
255
+ * two reload cycles. The cost of erring closed is the press.
256
+ *
257
+ * ─────────────────────────────────────────────────────────────────────
258
+ * 2026-08-07 (D72). The corroboration above was DEFEATED, and the reason
259
+ * matters more than the fix. `refreshBindingMirror` matched the binding with
260
+ * `entry.providerAddonId === this.ctx.id`. The two addon-context factories do
261
+ * not spell `ctx.id` the same way: a forked addon gets `virtual-doorbell`, a
262
+ * hub-main BUILTIN gets `addon:virtual-doorbell`. Every entry on the wire
263
+ * carries the bare form. So for this builtin the comparison was false for
264
+ * every entry, forever — not a blip. Corroboration only defends against a
265
+ * TRANSIENT bad read; two systematically-wrong reads agree with each other,
266
+ * and the mirror latched `unbound` with no way back except an operator
267
+ * opening the settings page. A real press on camera 615 was dropped.
268
+ *
269
+ * Two things follow, and both are now load-bearing here:
270
+ *
271
+ * 1. The comparison goes through `isSameAddonId` ({@link entriesBindWrapper}),
272
+ * never a raw `===`.
273
+ * 2. The DERIVED view (`deviceManager.getBindings`) is no longer the sole
274
+ * input. Before a negative is believed — and again on the ring path
275
+ * before a press is destroyed — the DURABLE authority is consulted
276
+ * directly ({@link readDurableWrapperBinding} over the `device-manager`
277
+ * addon store's `deviceBindings` map). That read carries a discriminator
278
+ * the derived view genuinely cannot offer: a store answering from an
279
+ * empty snapshot (D44) has no `deviceBindings` key at all, while a
280
+ * genuine unbind leaves the key present and merely omits the row.
281
+ */
282
+ /**
224
283
  * Fold one SUCCESSFUL bindings read into the mirror. A read that failed must
225
284
  * never reach this function — an unanswerable store carries no information
226
285
  * and must leave the mirror exactly as it was.
@@ -237,6 +296,52 @@ function allowsRing(state) {
237
296
  function hasConfirmedBinding(state) {
238
297
  return state === "bound";
239
298
  }
299
+ /**
300
+ * Does this list of binding entries name `ownAddonId` as the ACTIVE WRAPPER
301
+ * of `capName`?
302
+ *
303
+ * `isSameAddonId` is the whole point: `ownAddonId` is an `AddonContext.id`,
304
+ * which is prefixed (`addon:…`) in hub-main and bare in a forked runner,
305
+ * while `providerAddonId` is always bare. A raw `===` here is the D72 bug.
306
+ */
307
+ function entriesBindWrapper(entries, capName, ownAddonId) {
308
+ return entries.some((entry) => entry.capName === capName && entry.kind === "wrapped" && require_dist.isSameAddonId(entry.providerAddonId, ownAddonId));
309
+ }
310
+ /**
311
+ * Render binding entries for a log line. A systematic wrong answer and a
312
+ * one-off blip produce the same VERDICT; only the entries themselves tell
313
+ * them apart, so a negative read never gets to report just its conclusion.
314
+ */
315
+ function summariseEntries(entries) {
316
+ return entries.map((entry) => `${entry.capName}:${entry.kind}:${entry.providerAddonId}`);
317
+ }
318
+ function isRecord$2(value) {
319
+ return typeof value === "object" && value !== null && !Array.isArray(value);
320
+ }
321
+ /**
322
+ * Read the DURABLE authority — the `device-manager` addon store's
323
+ * `deviceBindings` map — for "is `ownAddonId` the active wrapper of
324
+ * `capName` on `deviceId`".
325
+ *
326
+ * This is the input the derived `getBindings` view is not: it is the row the
327
+ * operator's toggle actually writes, with no registry resolution, no
328
+ * `defaultActive` auto-binding and no cross-node cap folding in between. It
329
+ * also grades its own trustworthiness, which `getBindings` cannot: the
330
+ * absence of the `deviceBindings` KEY is the signature of a store answering
331
+ * from an empty snapshot, whereas a genuine unbind leaves the key present.
332
+ */
333
+ function readDurableWrapperBinding(record, deviceId, capName, ownAddonId) {
334
+ if (!isRecord$2(record)) return "unreadable";
335
+ const deviceBindings = record["deviceBindings"];
336
+ if (!isRecord$2(deviceBindings)) return "unreadable";
337
+ const perDevice = deviceBindings[String(deviceId)];
338
+ if (!isRecord$2(perDevice)) return "unbound";
339
+ const activation = perDevice[capName];
340
+ if (!isRecord$2(activation)) return "unbound";
341
+ const wrapperAddonId = activation["wrapperAddonId"];
342
+ if (typeof wrapperAddonId !== "string") return "unbound";
343
+ return require_dist.isSameAddonId(wrapperAddonId, ownAddonId) ? "bound" : "unbound";
344
+ }
240
345
  //#endregion
241
346
  //#region src/builtins/doorbell/doorbell-settings.ts
242
347
  /**
@@ -330,6 +435,13 @@ function applyDoorbellSourcesPatch(current, patchValue) {
330
435
  }
331
436
  //#endregion
332
437
  //#region src/builtins/doorbell/virtual-doorbell.addon.ts
438
+ /**
439
+ * The addon whose store IS the durable authority on wrapper activations.
440
+ * `deviceBindings[<deviceId>][<capName>].wrapperAddonId` is the row the
441
+ * operator's bindings toggle writes; `deviceManager.getBindings` is a VIEW
442
+ * derived from it plus the capability registry.
443
+ */
444
+ var DEVICE_MANAGER_ADDON_ID = "device-manager";
333
445
  /** Structural narrowing helper for untrusted bus payloads. */
334
446
  function isRecord(value) {
335
447
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -469,19 +581,14 @@ var VirtualDoorbellAddon = class extends require_dist.BaseAddon {
469
581
  * byte-shaped like a native firmware ring so every downstream consumer
470
582
  * (UI toast, notifier rules, exporters) is agnostic to the origin.
471
583
  *
472
- * The binding gate here is a pure in-memory read. It performs NO I/O and
473
- * cannot fail: the whole point of the mirror is that the ring path can no
474
- * longer be talked out of ringing by a store that answered badly.
584
+ * The binding gate here is a pure in-memory read on the path that RINGS:
585
+ * an open mirror performs NO I/O and cannot fail. The mirror can only
586
+ * DESTROY a press through the one branch below, and that branch is not
587
+ * allowed to be the last word — see `ringsDespiteUnboundMirror`.
475
588
  */
476
589
  async fireDoorbell(cameraId, timestamp) {
477
590
  const binding = this.bindingMirror.get(cameraId);
478
- if (!allowsRing(binding)) {
479
- 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", {
480
- tags: { deviceId: cameraId },
481
- meta: { bindingState: binding }
482
- });
483
- return;
484
- }
591
+ if (!allowsRing(binding) && !await this.ringsDespiteUnboundMirror(cameraId, binding)) return;
485
592
  if (!hasConfirmedBinding(binding)) this.ctx.logger.warn("virtual-doorbell: ringing on an unconfirmed binding — the bindings store has not corroborated an unbind, and a one-shot press is never dropped on an unproven read", {
486
593
  tags: { deviceId: cameraId },
487
594
  meta: { bindingState: binding ?? "never-read" }
@@ -520,20 +627,28 @@ var VirtualDoorbellAddon = class extends require_dist.BaseAddon {
520
627
  });
521
628
  }
522
629
  /**
523
- * Fold one bindings read for `cameraId` into the mirror. Runs on the
524
- * assignment-reload cadence, never while firing.
630
+ * The addon id as the REGISTRY and the bindings store spell it.
525
631
  *
526
- * A read that THROWS carries no information and leaves the mirror exactly
527
- * as it was the previous verdict keeps standing. A read that succeeds
528
- * but omits our activation only ARMS a revocation; `nextBindingState`
529
- * requires a second consecutive negative before the camera goes silent,
530
- * because a genuine unbind and a store answering from an empty snapshot
531
- * (D44) are indistinguishable in a single response.
632
+ * `ctx.id` does NOT: hub-main prefixes it (`addon:virtual-doorbell`) while
633
+ * a forked runner does not, and this builtin runs in hub-main. Comparing
634
+ * the raw `ctx.id` to a `providerAddonId` was false for every entry for
635
+ * twelve hours on 2026-08-07 (D72).
532
636
  */
533
- async refreshBindingMirror(cameraId) {
534
- let entries;
637
+ get ownAddonId() {
638
+ return require_dist.bareAddonId(this.ctx.id);
639
+ }
640
+ /**
641
+ * One read of the DERIVED binding view. `null` when the read could not be
642
+ * answered at all — which carries no information and must leave the mirror
643
+ * untouched.
644
+ */
645
+ async readDerivedBinding(cameraId) {
535
646
  try {
536
- entries = (await this.ctx.api.deviceManager.getBindings.query({ deviceId: cameraId })).entries;
647
+ const bindings = await this.ctx.api.deviceManager.getBindings.query({ deviceId: cameraId });
648
+ return {
649
+ bound: entriesBindWrapper(bindings.entries, require_dist.doorbellCapability.name, this.ownAddonId),
650
+ entries: bindings.entries
651
+ };
537
652
  } catch (err) {
538
653
  this.ctx.logger.warn("virtual-doorbell: binding check failed — keeping the last known verdict, NOT treating this as an unbind", {
539
654
  tags: { deviceId: cameraId },
@@ -542,19 +657,126 @@ var VirtualDoorbellAddon = class extends require_dist.BaseAddon {
542
657
  bindingState: this.bindingMirror.get(cameraId)
543
658
  }
544
659
  });
660
+ return null;
661
+ }
662
+ }
663
+ /**
664
+ * One read of the DURABLE authority: the `device-manager` addon store's
665
+ * `deviceBindings` map, straight from the settings DB.
666
+ *
667
+ * This is deliberately NOT `getBindings`. The derived view folds the store
668
+ * together with the capability registry, `defaultActive` auto-binding and
669
+ * the cluster native-cap feeds — any of which can make it disagree with
670
+ * what the operator actually persisted, and one of which (an addon-id
671
+ * spelling mismatch) made it disagree *systematically* while looking
672
+ * exactly like a genuine unbind. The raw row cannot: it is what the
673
+ * bindings toggle wrote, and it grades its own trustworthiness (D44).
674
+ */
675
+ async readDurableAuthority(cameraId) {
676
+ try {
677
+ return readDurableWrapperBinding(await this.ctx.api.addonSettingsRaw.getGlobal.query({ addonId: DEVICE_MANAGER_ADDON_ID }), cameraId, require_dist.doorbellCapability.name, this.ownAddonId);
678
+ } catch (err) {
679
+ this.ctx.logger.warn("virtual-doorbell: the durable bindings store could not be read — it carries no information and revokes nothing", {
680
+ tags: { deviceId: cameraId },
681
+ meta: { error: require_dist.errMsg(err) }
682
+ });
683
+ return "unreadable";
684
+ }
685
+ }
686
+ /**
687
+ * Reconcile `cameraId`'s mirror against the authority. Runs on the
688
+ * assignment-reload cadence, never while firing — and it runs on EVERY
689
+ * cycle regardless of the current verdict, so a camera sitting in
690
+ * `unbound` returns to `bound` on its own the moment the authority says
691
+ * so. There is no latch here that an operator has to clear.
692
+ *
693
+ * A positive derived read is believed immediately. A NEGATIVE one never
694
+ * is: the durable authority is asked, and it outranks the derived view in
695
+ * the direction that destroys work. Only when the authority agrees does
696
+ * the negative get folded, and even then `nextBindingState` still wants a
697
+ * second consecutive one (D49).
698
+ */
699
+ async refreshBindingMirror(cameraId) {
700
+ const derived = await this.readDerivedBinding(cameraId);
701
+ if (derived === null) return;
702
+ if (derived.bound) {
703
+ this.bindingMirror.set(cameraId, nextBindingState(this.bindingMirror.get(cameraId), true));
704
+ return;
705
+ }
706
+ const authority = await this.readDurableAuthority(cameraId);
707
+ const entries = summariseEntries(derived.entries);
708
+ if (authority === "bound") {
709
+ this.ctx.logger.warn("virtual-doorbell: the derived binding list omits this wrapper but the durable bindings store still names this wrapper — trusting the store", {
710
+ tags: { deviceId: cameraId },
711
+ meta: {
712
+ ownAddonId: this.ownAddonId,
713
+ entries
714
+ }
715
+ });
716
+ this.bindingMirror.set(cameraId, nextBindingState(this.bindingMirror.get(cameraId), true));
545
717
  return;
546
718
  }
547
- const boundNow = entries.some((entry) => entry.capName === require_dist.doorbellCapability.name && entry.kind === "wrapped" && entry.providerAddonId === this.ctx.id);
548
719
  const previous = this.bindingMirror.get(cameraId);
549
- const next = nextBindingState(previous, boundNow);
720
+ const next = nextBindingState(previous, false);
550
721
  this.bindingMirror.set(cameraId, next);
551
- if (next !== previous && next !== "bound") this.ctx.logger.warn("virtual-doorbell: bindings read did not see this wrapper", {
722
+ this.ctx.logger.warn("virtual-doorbell: bindings read did not see this wrapper", {
552
723
  tags: { deviceId: cameraId },
553
724
  meta: {
554
725
  from: previous ?? "never-read",
555
- to: next
726
+ to: next,
727
+ ownAddonId: this.ownAddonId,
728
+ durableAuthority: authority,
729
+ entries
730
+ }
731
+ });
732
+ }
733
+ /**
734
+ * The mirror says `unbound` and a real press just arrived. Decide, with
735
+ * ONE read of the durable authority, whether to destroy it.
736
+ *
737
+ * D49 forbade I/O on the ring path because the read it would have made was
738
+ * the same fallible derived read that caused the problem. This read is
739
+ * different in both directions: it is the authority rather than a view of
740
+ * it, and it is paid ONLY on the branch that would otherwise drop a press —
741
+ * a ring is rare, one query is cheap, and a dropped ring has no retry, no
742
+ * reconcile and nothing downstream that notices.
743
+ *
744
+ * Returns `true` when the ring must proceed.
745
+ */
746
+ async ringsDespiteUnboundMirror(cameraId, binding) {
747
+ const authority = await this.readDurableAuthority(cameraId);
748
+ if (authority === "bound") {
749
+ this.bindingMirror.set(cameraId, "bound");
750
+ 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", {
751
+ tags: { deviceId: cameraId },
752
+ meta: {
753
+ bindingState: binding,
754
+ ownAddonId: this.ownAddonId
755
+ }
756
+ });
757
+ return true;
758
+ }
759
+ if (authority === "unreadable") {
760
+ 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", {
761
+ tags: { deviceId: cameraId },
762
+ meta: {
763
+ bindingState: binding,
764
+ ownAddonId: this.ownAddonId
765
+ }
766
+ });
767
+ return true;
768
+ }
769
+ const derived = await this.readDerivedBinding(cameraId);
770
+ 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", {
771
+ tags: { deviceId: cameraId },
772
+ meta: {
773
+ bindingState: binding,
774
+ ownAddonId: this.ownAddonId,
775
+ durableAuthority: authority,
776
+ entries: derived === null ? "unreadable" : summariseEntries(derived.entries)
556
777
  }
557
778
  });
779
+ return false;
558
780
  }
559
781
  /**
560
782
  * Record a binding we observed FIRST-HAND. Both callers are cap methods
@@ -1,4 +1,4 @@
1
- import { Lt as EventCategory, O as doorbellCapability, _t as DeviceType, dt as BaseAddon, ut as errMsg, wt as createEvent } from "../../dist-DhL_zGRT.mjs";
1
+ import { B as isSameAddonId, Et as createEvent, ft as errMsg, k as doorbellCapability, pt as BaseAddon, x as bareAddonId, yt as DeviceType, zt as EventCategory } from "../../dist-BqJJWCS8.mjs";
2
2
  //#region src/builtins/doorbell/trigger-engine.ts
3
3
  /**
4
4
  * Pure trigger logic for the virtual-doorbell builtin — edge detection over
@@ -216,6 +216,65 @@ var DoorbellTriggerEngine = class {
216
216
  //#endregion
217
217
  //#region src/builtins/doorbell/binding-mirror.ts
218
218
  /**
219
+ * The virtual-doorbell wrapper's in-memory mirror of ONE durable fact per
220
+ * camera: is this wrapper still the active `doorbell` binding for it?
221
+ *
222
+ * WHY A MIRROR AND NOT A READ. A doorbell press is a one-shot event: there
223
+ * is no retry, no reconcile, nothing downstream that can notice it went
224
+ * missing. The wrapper used to re-derive the binding on the ring path via
225
+ * `deviceManager.getBindings` and treat ANY answer that omitted its own
226
+ * activation as "the operator unbound this". On 2026-08-04/05 camera 615
227
+ * rang zero times out of three real presses: the binding was persisted (a
228
+ * same-day backup taken before the presses proves it, and the same process
229
+ * later returned it correctly to a tRPC query), but the store answered
230
+ * transiently without it — `getBindings` reads the device-manager addon
231
+ * store, which is exactly the surface that can silently answer `{}` (D44,
232
+ * `addon-registry.service.ts`). Three presses, three drops, at `debug`.
233
+ *
234
+ * WHY CORROBORATION AND NOT A BETTER DISCRIMINATOR. There isn't one. A
235
+ * genuinely unbound pure-wrapper cap and a store answering from an empty
236
+ * snapshot produce the SAME `getBindings` response — no `doorbell` entry,
237
+ * with the camera's native entries (snapshot, motion, …) still present in
238
+ * both cases because those come from the capability registry, not the
239
+ * store. The response cannot be graded. What CAN be graded is repetition: a
240
+ * real unbind is permanent and every later read agrees, a store blip does
241
+ * not survive the next read. So a negative read ARMS the revocation and a
242
+ * second consecutive negative applies it.
243
+ *
244
+ * ERRING. `undefined` (never read) and `unbind-pending` both ring. Reaching
245
+ * the ring path at all means the camera has a configured trigger source,
246
+ * and a source can only be configured through this wrapper's own
247
+ * binding-gated settings surface — so a configured source is itself
248
+ * evidence the wrapper was bound. The cost of erring open is a spurious
249
+ * ring on a camera the operator unbound but left configured, for at most
250
+ * two reload cycles. The cost of erring closed is the press.
251
+ *
252
+ * ─────────────────────────────────────────────────────────────────────
253
+ * 2026-08-07 (D72). The corroboration above was DEFEATED, and the reason
254
+ * matters more than the fix. `refreshBindingMirror` matched the binding with
255
+ * `entry.providerAddonId === this.ctx.id`. The two addon-context factories do
256
+ * not spell `ctx.id` the same way: a forked addon gets `virtual-doorbell`, a
257
+ * hub-main BUILTIN gets `addon:virtual-doorbell`. Every entry on the wire
258
+ * carries the bare form. So for this builtin the comparison was false for
259
+ * every entry, forever — not a blip. Corroboration only defends against a
260
+ * TRANSIENT bad read; two systematically-wrong reads agree with each other,
261
+ * and the mirror latched `unbound` with no way back except an operator
262
+ * opening the settings page. A real press on camera 615 was dropped.
263
+ *
264
+ * Two things follow, and both are now load-bearing here:
265
+ *
266
+ * 1. The comparison goes through `isSameAddonId` ({@link entriesBindWrapper}),
267
+ * never a raw `===`.
268
+ * 2. The DERIVED view (`deviceManager.getBindings`) is no longer the sole
269
+ * input. Before a negative is believed — and again on the ring path
270
+ * before a press is destroyed — the DURABLE authority is consulted
271
+ * directly ({@link readDurableWrapperBinding} over the `device-manager`
272
+ * addon store's `deviceBindings` map). That read carries a discriminator
273
+ * the derived view genuinely cannot offer: a store answering from an
274
+ * empty snapshot (D44) has no `deviceBindings` key at all, while a
275
+ * genuine unbind leaves the key present and merely omits the row.
276
+ */
277
+ /**
219
278
  * Fold one SUCCESSFUL bindings read into the mirror. A read that failed must
220
279
  * never reach this function — an unanswerable store carries no information
221
280
  * and must leave the mirror exactly as it was.
@@ -232,6 +291,52 @@ function allowsRing(state) {
232
291
  function hasConfirmedBinding(state) {
233
292
  return state === "bound";
234
293
  }
294
+ /**
295
+ * Does this list of binding entries name `ownAddonId` as the ACTIVE WRAPPER
296
+ * of `capName`?
297
+ *
298
+ * `isSameAddonId` is the whole point: `ownAddonId` is an `AddonContext.id`,
299
+ * which is prefixed (`addon:…`) in hub-main and bare in a forked runner,
300
+ * while `providerAddonId` is always bare. A raw `===` here is the D72 bug.
301
+ */
302
+ function entriesBindWrapper(entries, capName, ownAddonId) {
303
+ return entries.some((entry) => entry.capName === capName && entry.kind === "wrapped" && isSameAddonId(entry.providerAddonId, ownAddonId));
304
+ }
305
+ /**
306
+ * Render binding entries for a log line. A systematic wrong answer and a
307
+ * one-off blip produce the same VERDICT; only the entries themselves tell
308
+ * them apart, so a negative read never gets to report just its conclusion.
309
+ */
310
+ function summariseEntries(entries) {
311
+ return entries.map((entry) => `${entry.capName}:${entry.kind}:${entry.providerAddonId}`);
312
+ }
313
+ function isRecord$2(value) {
314
+ return typeof value === "object" && value !== null && !Array.isArray(value);
315
+ }
316
+ /**
317
+ * Read the DURABLE authority — the `device-manager` addon store's
318
+ * `deviceBindings` map — for "is `ownAddonId` the active wrapper of
319
+ * `capName` on `deviceId`".
320
+ *
321
+ * This is the input the derived `getBindings` view is not: it is the row the
322
+ * operator's toggle actually writes, with no registry resolution, no
323
+ * `defaultActive` auto-binding and no cross-node cap folding in between. It
324
+ * also grades its own trustworthiness, which `getBindings` cannot: the
325
+ * absence of the `deviceBindings` KEY is the signature of a store answering
326
+ * from an empty snapshot, whereas a genuine unbind leaves the key present.
327
+ */
328
+ function readDurableWrapperBinding(record, deviceId, capName, ownAddonId) {
329
+ if (!isRecord$2(record)) return "unreadable";
330
+ const deviceBindings = record["deviceBindings"];
331
+ if (!isRecord$2(deviceBindings)) return "unreadable";
332
+ const perDevice = deviceBindings[String(deviceId)];
333
+ if (!isRecord$2(perDevice)) return "unbound";
334
+ const activation = perDevice[capName];
335
+ if (!isRecord$2(activation)) return "unbound";
336
+ const wrapperAddonId = activation["wrapperAddonId"];
337
+ if (typeof wrapperAddonId !== "string") return "unbound";
338
+ return isSameAddonId(wrapperAddonId, ownAddonId) ? "bound" : "unbound";
339
+ }
235
340
  //#endregion
236
341
  //#region src/builtins/doorbell/doorbell-settings.ts
237
342
  /**
@@ -325,6 +430,13 @@ function applyDoorbellSourcesPatch(current, patchValue) {
325
430
  }
326
431
  //#endregion
327
432
  //#region src/builtins/doorbell/virtual-doorbell.addon.ts
433
+ /**
434
+ * The addon whose store IS the durable authority on wrapper activations.
435
+ * `deviceBindings[<deviceId>][<capName>].wrapperAddonId` is the row the
436
+ * operator's bindings toggle writes; `deviceManager.getBindings` is a VIEW
437
+ * derived from it plus the capability registry.
438
+ */
439
+ var DEVICE_MANAGER_ADDON_ID = "device-manager";
328
440
  /** Structural narrowing helper for untrusted bus payloads. */
329
441
  function isRecord(value) {
330
442
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -464,19 +576,14 @@ var VirtualDoorbellAddon = class extends BaseAddon {
464
576
  * byte-shaped like a native firmware ring so every downstream consumer
465
577
  * (UI toast, notifier rules, exporters) is agnostic to the origin.
466
578
  *
467
- * The binding gate here is a pure in-memory read. It performs NO I/O and
468
- * cannot fail: the whole point of the mirror is that the ring path can no
469
- * longer be talked out of ringing by a store that answered badly.
579
+ * The binding gate here is a pure in-memory read on the path that RINGS:
580
+ * an open mirror performs NO I/O and cannot fail. The mirror can only
581
+ * DESTROY a press through the one branch below, and that branch is not
582
+ * allowed to be the last word — see `ringsDespiteUnboundMirror`.
470
583
  */
471
584
  async fireDoorbell(cameraId, timestamp) {
472
585
  const binding = this.bindingMirror.get(cameraId);
473
- if (!allowsRing(binding)) {
474
- 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", {
475
- tags: { deviceId: cameraId },
476
- meta: { bindingState: binding }
477
- });
478
- return;
479
- }
586
+ if (!allowsRing(binding) && !await this.ringsDespiteUnboundMirror(cameraId, binding)) return;
480
587
  if (!hasConfirmedBinding(binding)) this.ctx.logger.warn("virtual-doorbell: ringing on an unconfirmed binding — the bindings store has not corroborated an unbind, and a one-shot press is never dropped on an unproven read", {
481
588
  tags: { deviceId: cameraId },
482
589
  meta: { bindingState: binding ?? "never-read" }
@@ -515,20 +622,28 @@ var VirtualDoorbellAddon = class extends BaseAddon {
515
622
  });
516
623
  }
517
624
  /**
518
- * Fold one bindings read for `cameraId` into the mirror. Runs on the
519
- * assignment-reload cadence, never while firing.
625
+ * The addon id as the REGISTRY and the bindings store spell it.
520
626
  *
521
- * A read that THROWS carries no information and leaves the mirror exactly
522
- * as it was the previous verdict keeps standing. A read that succeeds
523
- * but omits our activation only ARMS a revocation; `nextBindingState`
524
- * requires a second consecutive negative before the camera goes silent,
525
- * because a genuine unbind and a store answering from an empty snapshot
526
- * (D44) are indistinguishable in a single response.
627
+ * `ctx.id` does NOT: hub-main prefixes it (`addon:virtual-doorbell`) while
628
+ * a forked runner does not, and this builtin runs in hub-main. Comparing
629
+ * the raw `ctx.id` to a `providerAddonId` was false for every entry for
630
+ * twelve hours on 2026-08-07 (D72).
527
631
  */
528
- async refreshBindingMirror(cameraId) {
529
- let entries;
632
+ get ownAddonId() {
633
+ return bareAddonId(this.ctx.id);
634
+ }
635
+ /**
636
+ * One read of the DERIVED binding view. `null` when the read could not be
637
+ * answered at all — which carries no information and must leave the mirror
638
+ * untouched.
639
+ */
640
+ async readDerivedBinding(cameraId) {
530
641
  try {
531
- entries = (await this.ctx.api.deviceManager.getBindings.query({ deviceId: cameraId })).entries;
642
+ const bindings = await this.ctx.api.deviceManager.getBindings.query({ deviceId: cameraId });
643
+ return {
644
+ bound: entriesBindWrapper(bindings.entries, doorbellCapability.name, this.ownAddonId),
645
+ entries: bindings.entries
646
+ };
532
647
  } catch (err) {
533
648
  this.ctx.logger.warn("virtual-doorbell: binding check failed — keeping the last known verdict, NOT treating this as an unbind", {
534
649
  tags: { deviceId: cameraId },
@@ -537,19 +652,126 @@ var VirtualDoorbellAddon = class extends BaseAddon {
537
652
  bindingState: this.bindingMirror.get(cameraId)
538
653
  }
539
654
  });
655
+ return null;
656
+ }
657
+ }
658
+ /**
659
+ * One read of the DURABLE authority: the `device-manager` addon store's
660
+ * `deviceBindings` map, straight from the settings DB.
661
+ *
662
+ * This is deliberately NOT `getBindings`. The derived view folds the store
663
+ * together with the capability registry, `defaultActive` auto-binding and
664
+ * the cluster native-cap feeds — any of which can make it disagree with
665
+ * what the operator actually persisted, and one of which (an addon-id
666
+ * spelling mismatch) made it disagree *systematically* while looking
667
+ * exactly like a genuine unbind. The raw row cannot: it is what the
668
+ * bindings toggle wrote, and it grades its own trustworthiness (D44).
669
+ */
670
+ async readDurableAuthority(cameraId) {
671
+ try {
672
+ return readDurableWrapperBinding(await this.ctx.api.addonSettingsRaw.getGlobal.query({ addonId: DEVICE_MANAGER_ADDON_ID }), cameraId, doorbellCapability.name, this.ownAddonId);
673
+ } catch (err) {
674
+ this.ctx.logger.warn("virtual-doorbell: the durable bindings store could not be read — it carries no information and revokes nothing", {
675
+ tags: { deviceId: cameraId },
676
+ meta: { error: errMsg(err) }
677
+ });
678
+ return "unreadable";
679
+ }
680
+ }
681
+ /**
682
+ * Reconcile `cameraId`'s mirror against the authority. Runs on the
683
+ * assignment-reload cadence, never while firing — and it runs on EVERY
684
+ * cycle regardless of the current verdict, so a camera sitting in
685
+ * `unbound` returns to `bound` on its own the moment the authority says
686
+ * so. There is no latch here that an operator has to clear.
687
+ *
688
+ * A positive derived read is believed immediately. A NEGATIVE one never
689
+ * is: the durable authority is asked, and it outranks the derived view in
690
+ * the direction that destroys work. Only when the authority agrees does
691
+ * the negative get folded, and even then `nextBindingState` still wants a
692
+ * second consecutive one (D49).
693
+ */
694
+ async refreshBindingMirror(cameraId) {
695
+ const derived = await this.readDerivedBinding(cameraId);
696
+ if (derived === null) return;
697
+ if (derived.bound) {
698
+ this.bindingMirror.set(cameraId, nextBindingState(this.bindingMirror.get(cameraId), true));
699
+ return;
700
+ }
701
+ const authority = await this.readDurableAuthority(cameraId);
702
+ const entries = summariseEntries(derived.entries);
703
+ if (authority === "bound") {
704
+ this.ctx.logger.warn("virtual-doorbell: the derived binding list omits this wrapper but the durable bindings store still names this wrapper — trusting the store", {
705
+ tags: { deviceId: cameraId },
706
+ meta: {
707
+ ownAddonId: this.ownAddonId,
708
+ entries
709
+ }
710
+ });
711
+ this.bindingMirror.set(cameraId, nextBindingState(this.bindingMirror.get(cameraId), true));
540
712
  return;
541
713
  }
542
- const boundNow = entries.some((entry) => entry.capName === doorbellCapability.name && entry.kind === "wrapped" && entry.providerAddonId === this.ctx.id);
543
714
  const previous = this.bindingMirror.get(cameraId);
544
- const next = nextBindingState(previous, boundNow);
715
+ const next = nextBindingState(previous, false);
545
716
  this.bindingMirror.set(cameraId, next);
546
- if (next !== previous && next !== "bound") this.ctx.logger.warn("virtual-doorbell: bindings read did not see this wrapper", {
717
+ this.ctx.logger.warn("virtual-doorbell: bindings read did not see this wrapper", {
547
718
  tags: { deviceId: cameraId },
548
719
  meta: {
549
720
  from: previous ?? "never-read",
550
- to: next
721
+ to: next,
722
+ ownAddonId: this.ownAddonId,
723
+ durableAuthority: authority,
724
+ entries
725
+ }
726
+ });
727
+ }
728
+ /**
729
+ * The mirror says `unbound` and a real press just arrived. Decide, with
730
+ * ONE read of the durable authority, whether to destroy it.
731
+ *
732
+ * D49 forbade I/O on the ring path because the read it would have made was
733
+ * the same fallible derived read that caused the problem. This read is
734
+ * different in both directions: it is the authority rather than a view of
735
+ * it, and it is paid ONLY on the branch that would otherwise drop a press —
736
+ * a ring is rare, one query is cheap, and a dropped ring has no retry, no
737
+ * reconcile and nothing downstream that notices.
738
+ *
739
+ * Returns `true` when the ring must proceed.
740
+ */
741
+ async ringsDespiteUnboundMirror(cameraId, binding) {
742
+ const authority = await this.readDurableAuthority(cameraId);
743
+ if (authority === "bound") {
744
+ this.bindingMirror.set(cameraId, "bound");
745
+ 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", {
746
+ tags: { deviceId: cameraId },
747
+ meta: {
748
+ bindingState: binding,
749
+ ownAddonId: this.ownAddonId
750
+ }
751
+ });
752
+ return true;
753
+ }
754
+ if (authority === "unreadable") {
755
+ 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", {
756
+ tags: { deviceId: cameraId },
757
+ meta: {
758
+ bindingState: binding,
759
+ ownAddonId: this.ownAddonId
760
+ }
761
+ });
762
+ return true;
763
+ }
764
+ const derived = await this.readDerivedBinding(cameraId);
765
+ 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", {
766
+ tags: { deviceId: cameraId },
767
+ meta: {
768
+ bindingState: binding,
769
+ ownAddonId: this.ownAddonId,
770
+ durableAuthority: authority,
771
+ entries: derived === null ? "unreadable" : summariseEntries(derived.entries)
551
772
  }
552
773
  });
774
+ return false;
553
775
  }
554
776
  /**
555
777
  * Record a binding we observed FIRST-HAND. Both callers are cap methods