@camstack/system 1.2.330 → 1.2.331

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 (78) hide show
  1. package/dist/addon-runner.js +1 -1
  2. package/dist/addon-runner.mjs +1 -1
  3. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  4. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  5. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  6. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  7. package/dist/builtins/alerts/alerts.addon.js +1 -1
  8. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  9. package/dist/builtins/autotrack/index.js +3 -3
  10. package/dist/builtins/autotrack/index.mjs +3 -3
  11. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
  12. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
  13. package/dist/builtins/camera-grid/index.js +1 -1
  14. package/dist/builtins/camera-grid/index.mjs +1 -1
  15. package/dist/builtins/composer/composer.addon.js +27 -8
  16. package/dist/builtins/composer/composer.addon.mjs +21 -3
  17. package/dist/builtins/console-logging/index.js +1 -1
  18. package/dist/builtins/console-logging/index.mjs +1 -1
  19. package/dist/builtins/core-blocks/core-blocks.addon.js +2 -2
  20. package/dist/builtins/core-blocks/core-blocks.addon.mjs +2 -2
  21. package/dist/builtins/device-manager/device-bindings-store.d.ts +7 -0
  22. package/dist/builtins/device-manager/device-manager.addon.d.ts +3 -0
  23. package/dist/builtins/device-manager/device-manager.addon.js +130 -3
  24. package/dist/builtins/device-manager/device-manager.addon.mjs +130 -3
  25. package/dist/builtins/device-manager/stale-wrapper-activations.d.ts +26 -0
  26. package/dist/builtins/hub-forwarder/index.js +1 -1
  27. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  28. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  29. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  30. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  31. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  32. package/dist/builtins/local-network/local-network.addon.js +1 -1
  33. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  34. package/dist/builtins/loki-logging/index.js +1 -1
  35. package/dist/builtins/loki-logging/index.mjs +1 -1
  36. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  37. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  38. package/dist/builtins/platform-probe/index.js +1 -1
  39. package/dist/builtins/platform-probe/index.mjs +1 -1
  40. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  41. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  42. package/dist/builtins/shared/device-state-changed.d.ts +3 -4
  43. package/dist/builtins/snapshot/index.js +1 -1
  44. package/dist/builtins/snapshot/index.mjs +1 -1
  45. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  46. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  47. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
  48. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
  49. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  50. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  51. package/dist/builtins/system-config/system-config.addon.js +1 -1
  52. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  53. package/dist/builtins/winston-logging/index.js +1 -1
  54. package/dist/builtins/winston-logging/index.mjs +1 -1
  55. package/dist/{child-cap-dispatch-C8uts1P4.js → child-cap-dispatch-93MfsaZL.js} +1 -0
  56. package/dist/{child-cap-dispatch-C2VINHu1.mjs → child-cap-dispatch-kLIiU1CG.mjs} +1 -0
  57. package/dist/{composition-sources-2XxNEet3.js → composition-sources-BXZorHDO.js} +1 -1
  58. package/dist/{composition-sources-Wj2kWund.mjs → composition-sources-aEEer5gE.mjs} +1 -1
  59. package/dist/{dist-Dp043AI0.mjs → dist-CoG8lY6H.mjs} +222 -374
  60. package/dist/{dist-Bx4zxM9N.js → dist-DRUzy1SF.js} +221 -421
  61. package/dist/index.js +2 -2
  62. package/dist/index.mjs +2 -2
  63. package/dist/{retired-settings-keys-DR2WMe5d.js → retired-settings-keys-BY9H1I2b.js} +1 -1
  64. package/dist/{retired-settings-keys-Dxf9vELs.mjs → retired-settings-keys-BcJDpASY.mjs} +1 -1
  65. package/package.json +1 -16
  66. package/dist/builtins/doorbell/binding-mirror.d.ts +0 -110
  67. package/dist/builtins/doorbell/doorbell-composition-migration.d.ts +0 -47
  68. package/dist/builtins/doorbell/doorbell-extension-slot.d.ts +0 -67
  69. package/dist/builtins/doorbell/doorbell-settings.d.ts +0 -58
  70. package/dist/builtins/doorbell/index.d.ts +0 -2
  71. package/dist/builtins/doorbell/index.js +0 -8
  72. package/dist/builtins/doorbell/index.mjs +0 -2
  73. package/dist/builtins/doorbell/trigger-engine.d.ts +0 -92
  74. package/dist/builtins/doorbell/virtual-doorbell.addon.d.ts +0 -218
  75. package/dist/builtins/doorbell/virtual-doorbell.addon.js +0 -1413
  76. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +0 -1407
  77. package/dist/device-state-changed-Bk7cvTnz.js +0 -31
  78. package/dist/device-state-changed-DoBbkyEI.mjs +0 -20
@@ -1,1413 +0,0 @@
1
- Object.defineProperties(exports, {
2
- __esModule: { value: true },
3
- [Symbol.toStringTag]: { value: "Module" }
4
- });
5
- require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-Bx4zxM9N.js");
7
- const require_device_state_changed = require("../../device-state-changed-Bk7cvTnz.js");
8
- let zod = require("zod");
9
- //#region src/builtins/doorbell/trigger-engine.ts
10
- /**
11
- * Pure trigger logic for the virtual-doorbell builtin — edge detection over
12
- * runtime-state slices with per-camera debounce. Extracted from the addon
13
- * class so the behaviour is unit-testable without instantiating the addon or
14
- * mocking the event bus.
15
- *
16
- * REACTION MODEL (simplified — operator picks a source, the system reacts
17
- * with a sensible default): a source device rings a camera's doorbell when a
18
- * known binary / switch cap on it RISES to its active state (false→true) — a
19
- * contact opening, a switch turning on, an alarm sensor firing. There is no
20
- * per-source trigger configuration today; the shape stays extensible for a
21
- * future custom-trigger override (see `doorbell-settings.ts`).
22
- *
23
- * ## Dove vive il fronte, dal 2026-08-27
24
- *
25
- * `SOURCE_CAP_ACTIVE_FIELD`, `SOURCE_CAP_CHANGED_AT_FIELD`, i due predicati di
26
- * slice e il predicato di fronte sono stati SPOSTATI in
27
- * `@camstack/types` (`catalogs/sensor-active-state.ts`), perché il recorder ne
28
- * ha bisogno per il trigger `RecordingTriggers.sensorDeviceIds`. Una seconda
29
- * copia sarebbe divergita alla prima cap aggiunta, e il sintomo — "il sensore
30
- * fa suonare il campanello ma non registra" — è precisamente D62.
31
- *
32
- * Questo file ri-esporta i nomi pubblici (i suoi consumatori non cambiano
33
- * import) e resta l'unico proprietario di ciò che è del CAMPANELLO e di
34
- * nessun altro: le assegnazioni camera↔sorgente, il debounce per camera e il
35
- * vocabolario `DoorbellIgnoreReason` — che ha un valore in più
36
- * (`no-assignment`) che il valutatore puro non può conoscere.
37
- */
38
- function ignoredResult(reason) {
39
- return {
40
- fired: [],
41
- debounced: [],
42
- ignored: reason
43
- };
44
- }
45
- /**
46
- * Stateful (but side-effect-free towards the outside) trigger engine.
47
- *
48
- * Tracks the last observed binary value per (sourceDevice, cap) so a
49
- * `DeviceStateChanged` re-emission (the slice's `lastChangedAt` moves while
50
- * the binary value does not) never double-fires.
51
- *
52
- * FIRST SIGHTINGS. Seeding the baseline and never firing was wrong for the
53
- * one case that matters: a momentary source (a button wired to a Home
54
- * Assistant switch) is NOT re-pushed in its resting `off` state after a hub
55
- * restart, so after every restart — several a day — the first slice the
56
- * engine ever sees for it is the PRESS ITSELF, already active. That press
57
- * was eaten whole; a real one on 2026-08-05 left no trace anywhere.
58
- *
59
- * The discriminator is the source's own transition timestamp. A first
60
- * sighting that is already active rings only when its transition can be
61
- * proved to have happened AFTER this engine started watching AND within
62
- * `firstSightingFreshnessMs`. Both halves earn their keep: freshness alone
63
- * would ring on a boot hydration of a state that flipped seconds before the
64
- * restart, and "after we started" alone would ring, on a long-lived
65
- * process, for a source adopted today whose state flipped yesterday. An
66
- * undateable first sighting (no timestamp, or a zero sentinel) does NOT
67
- * ring — the ambiguous case errs towards silence, but reports
68
- * `baseline-seeded-stale-active` so the caller can say so out loud.
69
- */
70
- var DoorbellTriggerEngine = class {
71
- assignments = [];
72
- /** Last observed binary value keyed by `${deviceId}:${capName}`. */
73
- lastValues = /* @__PURE__ */ new Map();
74
- /** Last fire timestamp per camera — debounce bookkeeping. */
75
- lastFiredAt = /* @__PURE__ */ new Map();
76
- debounceMs;
77
- now;
78
- firstSightingFreshnessMs;
79
- /** When this engine started watching — the floor for a first-sighting rise. */
80
- startedAt;
81
- constructor(options) {
82
- this.debounceMs = options?.debounceMs ?? 2e3;
83
- this.now = options?.now ?? Date.now;
84
- this.firstSightingFreshnessMs = options?.firstSightingFreshnessMs ?? 3e4;
85
- this.startedAt = this.now();
86
- }
87
- /** Replace the full assignment set (rebuilt whenever settings change). */
88
- setAssignments(assignments) {
89
- this.assignments = [...assignments];
90
- }
91
- getAssignments() {
92
- return this.assignments;
93
- }
94
- /** True when any assignment references `deviceId` as its source. */
95
- hasSource(deviceId) {
96
- return this.assignments.some((a) => a.sourceDeviceId === deviceId);
97
- }
98
- /**
99
- * Feed one `DeviceStateChanged` slice. Reports which cameras should ring,
100
- * which were suppressed by the debounce window, and — when nothing was
101
- * matched at all — WHY. Only a rise to active on a recognised source cap
102
- * can ring; re-emissions and the falling edge never do.
103
- */
104
- onStateSlice(deviceId, capName, slice) {
105
- const key = `${deviceId}:${capName}`;
106
- const verdict = require_dist.evaluateSensorEdge({
107
- capName,
108
- slice,
109
- prior: this.lastValues.get(key),
110
- startedAtMs: this.startedAt,
111
- nowMs: this.now(),
112
- firstSightingFreshnessMs: this.firstSightingFreshnessMs
113
- });
114
- if (verdict.edge === "none") {
115
- if (verdict.value !== null) this.lastValues.set(key, verdict.value);
116
- return ignoredResult(verdict.reason);
117
- }
118
- this.lastValues.set(key, true);
119
- return this.matchAndDebounce(deviceId);
120
- }
121
- matchAndDebounce(sourceDeviceId) {
122
- const now = this.now();
123
- const fired = [];
124
- const debounced = [];
125
- for (const assignment of this.assignments) {
126
- if (assignment.sourceDeviceId !== sourceDeviceId) continue;
127
- const last = this.lastFiredAt.get(assignment.cameraId);
128
- if (last !== void 0 && now - last < this.debounceMs) {
129
- debounced.push(assignment.cameraId);
130
- continue;
131
- }
132
- this.lastFiredAt.set(assignment.cameraId, now);
133
- fired.push(assignment.cameraId);
134
- }
135
- if (fired.length === 0 && debounced.length === 0) return ignoredResult("no-assignment");
136
- return {
137
- fired,
138
- debounced,
139
- ignored: null
140
- };
141
- }
142
- };
143
- //#endregion
144
- //#region src/builtins/doorbell/binding-mirror.ts
145
- /**
146
- * The virtual-doorbell wrapper's in-memory mirror of ONE durable fact per
147
- * camera: is this wrapper still the active `doorbell` binding for it?
148
- *
149
- * WHY A MIRROR AND NOT A READ. A doorbell press is a one-shot event: there
150
- * is no retry, no reconcile, nothing downstream that can notice it went
151
- * missing. The wrapper used to re-derive the binding on the ring path via
152
- * `deviceManager.getBindings` and treat ANY answer that omitted its own
153
- * activation as "the operator unbound this". On 2026-08-04/05 camera 615
154
- * rang zero times out of three real presses: the binding was persisted (a
155
- * same-day backup taken before the presses proves it, and the same process
156
- * later returned it correctly to a tRPC query), but the store answered
157
- * transiently without it — `getBindings` reads the device-manager addon
158
- * store, which is exactly the surface that can silently answer `{}` (D44,
159
- * `addon-registry.service.ts`). Three presses, three drops, at `debug`.
160
- *
161
- * WHY CORROBORATION AND NOT A BETTER DISCRIMINATOR. There isn't one. A
162
- * genuinely unbound pure-wrapper cap and a store answering from an empty
163
- * snapshot produce the SAME `getBindings` response — no `doorbell` entry,
164
- * with the camera's native entries (snapshot, motion, …) still present in
165
- * both cases because those come from the capability registry, not the
166
- * store. The response cannot be graded. What CAN be graded is repetition: a
167
- * real unbind is permanent and every later read agrees, a store blip does
168
- * not survive the next read. So a negative read ARMS the revocation and a
169
- * second consecutive negative applies it.
170
- *
171
- * ERRING. `undefined` (never read) and `unbind-pending` both ring. The cost
172
- * of erring open is a spurious ring on a camera whose wrapper is not bound
173
- * but which has trigger sources, for at most two reload cycles. The cost of
174
- * erring closed is the press.
175
- *
176
- * D655: a configured source is NO LONGER evidence of the binding. D49 leaned
177
- * on "a source can only be configured through this wrapper's own
178
- * binding-gated settings surface"; that surface is retired, and the
179
- * `doorbell-source` extension slot that replaced it is offered on every
180
- * camera, bound or not (D648). The erring-open window above is therefore
181
- * reachable by configuring sources on a never-bound camera, not only by
182
- * unbinding a configured one — still bounded by two reload cycles, and the
183
- * slot's card says `inactive` once the reads agree.
184
- *
185
- * ─────────────────────────────────────────────────────────────────────
186
- * 2026-08-07 (D72). The corroboration above was DEFEATED, and the reason
187
- * matters more than the fix. `refreshBindingMirror` matched the binding with
188
- * `entry.providerAddonId === this.ctx.id`. The two addon-context factories do
189
- * not spell `ctx.id` the same way: a forked addon gets `virtual-doorbell`, a
190
- * hub-main BUILTIN gets `addon:virtual-doorbell`. Every entry on the wire
191
- * carries the bare form. So for this builtin the comparison was false for
192
- * every entry, forever — not a blip. Corroboration only defends against a
193
- * TRANSIENT bad read; two systematically-wrong reads agree with each other,
194
- * and the mirror latched `unbound` with no way back except an operator
195
- * opening the settings page. A real press on camera 615 was dropped.
196
- *
197
- * Two things follow, and both are now load-bearing here:
198
- *
199
- * 1. The comparison goes through `isSameAddonId` ({@link entriesBindWrapper}),
200
- * never a raw `===`.
201
- * 2. The DERIVED view (`deviceManager.getBindings`) is no longer the sole
202
- * input. Before a negative is believed — and again on the ring path
203
- * before a press is destroyed — the DURABLE authority is consulted
204
- * directly ({@link durableVerdictOf} over
205
- * `deviceManager.getWrapperActivation`). That read carries a discriminator
206
- * the derived view genuinely cannot offer: it is three-way, so a store
207
- * that could not answer (`failed`) is never mistaken for a store that
208
- * answered "nothing here" (`missing`).
209
- *
210
- * 2026-09-29 (D675). Until then the durable read was
211
- * `addonSettingsRaw.getGlobal({ addonId: 'device-manager' })`, whose backend
212
- * (`ConfigManager.getAddonConfig`) has had no store on the hub since D231
213
- * isolated the engine: it answered `{}` on every call, so every durable read
214
- * was `unreadable`, an operator's explicit "off" (camera 615,
215
- * `wrapperAddonId: null`) was never honoured, and a press rang anyway.
216
- */
217
- /**
218
- * Fold one SUCCESSFUL bindings read into the mirror. A read that failed must
219
- * never reach this function — an unanswerable store carries no information
220
- * and must leave the mirror exactly as it was.
221
- */
222
- function nextBindingState(previous, boundNow) {
223
- if (boundNow) return "bound";
224
- return previous === "unbind-pending" || previous === "unbound" ? "unbound" : "unbind-pending";
225
- }
226
- /**
227
- * May a ring proceed on the mirror STATE alone? Only a corroborated unbind
228
- * stops one. {@link needsDurableRead} adds the served-elsewhere case on top.
229
- */
230
- function allowsRing(state) {
231
- return state !== "unbound";
232
- }
233
- /** True only when a read has positively confirmed the binding. */
234
- function hasConfirmedBinding(state) {
235
- return state === "bound";
236
- }
237
- /**
238
- * Does this list of binding entries name `ownAddonId` as the ACTIVE WRAPPER
239
- * of `capName`?
240
- *
241
- * `isSameAddonId` is the whole point: `ownAddonId` is an `AddonContext.id`,
242
- * which is prefixed (`addon:…`) in hub-main and bare in a forked runner,
243
- * while `providerAddonId` is always bare. A raw `===` here is the D72 bug.
244
- */
245
- function entriesBindWrapper(entries, capName, ownAddonId) {
246
- return entries.some((entry) => entry.capName === capName && entry.kind === "wrapped" && require_dist.isSameAddonId(entry.providerAddonId, ownAddonId));
247
- }
248
- /**
249
- * Render binding entries for a log line. A systematic wrong answer and a
250
- * one-off blip produce the same VERDICT; only the entries themselves tell
251
- * them apart, so a negative read never gets to report just its conclusion.
252
- */
253
- function summariseEntries(entries) {
254
- return entries.map((entry) => `${entry.capName}:${entry.kind}:${entry.providerAddonId}`);
255
- }
256
- /**
257
- * The durable verdict for `ownAddonId`, from device-manager's STORED
258
- * activation of this cap on this device (`deviceManager.getWrapperActivation`).
259
- *
260
- * - `value` naming this wrapper → `bound`.
261
- * - `value` naming another wrapper, or `null` — the operator's explicit "off"
262
- * marker (D62) — → `unbound`.
263
- * - `missing` → `unbound`: the store ANSWERED and holds no activation, and the
264
- * doorbell cap is `defaultActive: false`, so a wrapper nobody activated is
265
- * not bound.
266
- * - `failed` → `unreadable`: it carries no information and destroys nothing.
267
- */
268
- function durableVerdictOf(read, ownAddonId) {
269
- switch (read.kind) {
270
- case "failed": return "unreadable";
271
- case "missing": return "unbound";
272
- case "value": return read.wrapperAddonId !== null && require_dist.isSameAddonId(read.wrapperAddonId, ownAddonId) ? "bound" : "unbound";
273
- }
274
- }
275
- /**
276
- * Who, other than this wrapper, the derived view says serves `capName` —
277
- * `composer` on a camera whose doorbell was migrated to a composition (D671).
278
- * Non-empty means a press this wrapper does not ring is still somebody's ring.
279
- */
280
- function otherProvidersOf(entries, capName, ownAddonId) {
281
- return entries.filter((entry) => entry.capName === capName && !require_dist.isSameAddonId(entry.providerAddonId, ownAddonId)).map((entry) => entry.providerAddonId);
282
- }
283
- /**
284
- * Does this camera need the durable authority before a verdict?
285
- *
286
- * An `unbound` mirror does (D72). So does any mirror short of `bound` on a
287
- * camera whose doorbell another provider serves — the D671 cutover, where
288
- * erring open means ringing ON TOP of the composer: after every restart the
289
- * mirror starts `undefined` and spends a reload cycle at `unbind-pending`, and
290
- * each press in that window was a duplicate ring. Two independent reads
291
- * agreeing — the derived view naming another provider, the store naming no
292
- * wrapper — is the D49 "second read agrees"; an unreadable store still rings.
293
- */
294
- function needsDurableRead(mirror) {
295
- if (!allowsRing(mirror.state)) return true;
296
- return mirror.servedBy.length > 0 && mirror.state !== "bound";
297
- }
298
- /**
299
- * THE decision — the ring gate (`fireDoorbell` / `ringsAfterDurableRead`)
300
- * and the Extensions card's activation both call it, so what the card says
301
- * and what a press does cannot disagree. `durable` is `null` when it was not
302
- * read, which {@link needsDurableRead} allows only where it says so.
303
- * Pure: it reads nothing and writes nothing.
304
- */
305
- function ringVerdict(mirror, durable) {
306
- const state = mirror.state;
307
- if (state === "bound") return "rings";
308
- if (mirror.servedBy.length > 0) {
309
- if (durable === "unbound") return "silent";
310
- if (durable === "bound") return "rings";
311
- }
312
- if (state === "unbind-pending") return "rings-pending";
313
- if (state === void 0) return "rings-unconfirmed";
314
- if (durable === "bound") return "rings";
315
- if (durable === "unbound") return "silent";
316
- return "unknown";
317
- }
318
- /** Whether a press proceeds under this verdict. */
319
- function verdictRings(verdict) {
320
- return verdict !== "silent";
321
- }
322
- //#endregion
323
- //#region src/builtins/doorbell/doorbell-settings.ts
324
- /**
325
- * Pure per-camera settings parsing/normalization for the virtual-doorbell
326
- * builtin. Kept separate from the addon class (and the runtime trigger
327
- * engine) so the persisted-shape handling — including tolerant migration of
328
- * the legacy single-source blob — is unit-testable in isolation.
329
- *
330
- * PERSISTED SHAPE (extensible by design):
331
- * doorbellSources: Array<{ deviceId: number }>
332
- *
333
- * The source is an OBJECT rather than a bare id so future per-source options
334
- * (a custom trigger override, an event-type match, a per-source debounce)
335
- * can be added without another migration. Today only `deviceId` is stored;
336
- * the reaction is DERIVED at runtime from the source device's cap (see
337
- * `trigger-engine.ts`).
338
- */
339
- /** Device-store key holding the source list. */
340
- var KEY_SOURCES = "doorbellSources";
341
- /**
342
- * Legacy device-store keys from the pre-simplification shape (single source
343
- * device + explicit trigger + event type). Retained ONLY for tolerant
344
- * migration on read and cleanup on write — never written afresh.
345
- */
346
- var LEGACY_KEY_SOURCE = "doorbellSourceDeviceId";
347
- var LEGACY_KEY_TRIGGER = "doorbellTrigger";
348
- var LEGACY_KEY_EVENT_TYPE = "doorbellEventType";
349
- function isRecord$1(value) {
350
- return typeof value === "object" && value !== null && !Array.isArray(value);
351
- }
352
- /** Coerce a persisted / form-supplied id (number or numeric string) to a
353
- * positive integer device id, or null when it is not a usable id. */
354
- function coerceDeviceId(value) {
355
- if (typeof value === "number" && Number.isInteger(value) && value > 0) return value;
356
- if (typeof value === "string" && value !== "none" && value.trim().length > 0) {
357
- const parsed = Number(value);
358
- if (Number.isInteger(parsed) && parsed > 0) return parsed;
359
- }
360
- return null;
361
- }
362
- /** Fold a raw list of ids / source records into a deduped source array. */
363
- function collectSources(items) {
364
- const out = [];
365
- const seen = /* @__PURE__ */ new Set();
366
- for (const item of items) {
367
- const id = isRecord$1(item) ? coerceDeviceId(item["deviceId"]) : coerceDeviceId(item);
368
- if (id !== null && !seen.has(id)) {
369
- seen.add(id);
370
- out.push({ deviceId: id });
371
- }
372
- }
373
- return out;
374
- }
375
- /**
376
- * Parse the persisted doorbell sources for one camera. Tolerant of BOTH:
377
- * - the new `doorbellSources: [{deviceId}]` shape (ids may arrive as
378
- * numbers, numeric strings, or `{deviceId}` records); and
379
- * - the legacy single `doorbellSourceDeviceId` scalar, mapped onto one
380
- * source (the old trigger/eventType are dropped — the reaction is now
381
- * derived at runtime).
382
- *
383
- * Never throws on malformed data — unknown entries are skipped.
384
- */
385
- function parseDoorbellSources(blob) {
386
- const raw = blob[KEY_SOURCES];
387
- const fromNew = Array.isArray(raw) ? collectSources(raw) : [];
388
- if (fromNew.length > 0) return fromNew;
389
- return collectSources([blob[LEGACY_KEY_SOURCE]]);
390
- }
391
- /**
392
- * Normalize a settings-patch value for the `doorbellSources` multiselect
393
- * (a `string[]` of device ids from the form, or an already-shaped
394
- * `{deviceId}[]` / `number[]`) into the persisted extensible shape.
395
- */
396
- function normalizeDoorbellSources(value) {
397
- if (!Array.isArray(value)) return [];
398
- return collectSources(value);
399
- }
400
- /**
401
- * Produce the next device-store blob after applying a doorbell-sources
402
- * patch: writes the normalized new shape and strips every legacy key so a
403
- * migrated camera never carries stale single-source fields.
404
- */
405
- function applyDoorbellSourcesPatch(current, patchValue) {
406
- const next = { ...current };
407
- delete next[LEGACY_KEY_SOURCE];
408
- delete next[LEGACY_KEY_TRIGGER];
409
- delete next[LEGACY_KEY_EVENT_TYPE];
410
- next[KEY_SOURCES] = normalizeDoorbellSources(patchValue);
411
- return next;
412
- }
413
- //#endregion
414
- //#region src/builtins/doorbell/doorbell-extension-slot.ts
415
- /**
416
- * The virtual doorbell's side of `device-extension` (D648): the
417
- * `doorbell-source` slot, served over the wrapper's `doorbellSources` store
418
- * — since D655 the only editor of a camera's trigger devices.
419
- *
420
- * - `list` reads the store through `parseDoorbellSources` — the parser the
421
- * trigger engine's assignments are built from — so the slot's value is
422
- * exactly what rings. It also REPORTS whether the wrapper is bound to the
423
- * camera (`activation`), named as `setWrapperActive`'s: sources on an
424
- * unbound camera ring nothing, and the card must say so rather than look
425
- * configured. Nothing here writes it.
426
- * - The activation costs NOTHING on the common path. A camera with no
427
- * sources has no gate worth reporting and makes no read. A camera with
428
- * sources is answered from the addon's D49 binding mirror — the in-memory
429
- * verdict the ring path itself uses — through `ringVerdict`, the ring
430
- * gate's own decision function. The card is READ-ONLY: it never refreshes
431
- * or writes the mirror, and reads the durable authority only for an
432
- * `unbound` mirror, exactly where the ring gate reads it.
433
- * `getBindings` was measured at up to 5.24 s on the live hub (2026-08-30),
434
- * and the dispatcher already paid one for this page.
435
- * - The picked sources are labelled with ONE batched `listAll({deviceIds})`,
436
- * never a `getDevice` per id.
437
- * - `set` goes through the addon's `persistDoorbellSources`, the SAME
438
- * normalise-and-store path the wrapper has, which also invalidates
439
- * the assignment cache so the binding mirror is re-read off the ring path
440
- * (D49). A `setSlot` call is NOT evidence that the wrapper is bound (the
441
- * contract reaches this provider through `device-extension`'s own
442
- * binding), so it never confirms the mirror. A NEW pick must be a device
443
- * that carries a source cap (`SOURCE_CAPS`, the D636 ids-only read) — which
444
- * also refuses a container, a device with no source cap and an id no
445
- * device has — and never the camera itself.
446
- * - `candidates` are those same source-capable devices, devices in the
447
- * camera's location suggested.
448
- */
449
- /** The routing prefix of this owner's `slotRef`s. */
450
- var VIRTUAL_DOORBELL_EXTENSION_SOURCE = "virtual-doorbell";
451
- /** The store the slot reads and writes, named (D62). */
452
- var DOORBELL_SOURCES_AUTHORITY = `virtual-doorbell:${KEY_SOURCES}`;
453
- /** The authority that decides whether the wrapper serves a camera at all. */
454
- var WRAPPER_ACTIVATION_AUTHORITY = "device-manager:setWrapperActive";
455
- 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.";
456
- var PENDING_DETAIL = "One bindings read did not see the wrapper; a press still rings until a second read agrees.";
457
- /** How the card SAYS a verdict. The verdict itself is `ringVerdict`'s, never the card's. */
458
- function activationFromVerdict(verdict) {
459
- const authority = WRAPPER_ACTIVATION_AUTHORITY;
460
- switch (verdict) {
461
- case "rings": return {
462
- state: "active",
463
- authority
464
- };
465
- case "rings-pending": return {
466
- state: "active",
467
- authority,
468
- detail: PENDING_DETAIL
469
- };
470
- case "rings-unconfirmed": return {
471
- state: "active",
472
- authority,
473
- detail: "No bindings read has confirmed the wrapper yet; a press rings meanwhile."
474
- };
475
- case "unknown": return {
476
- state: "unknown",
477
- authority,
478
- detail: "Could not verify: the last reads did not see the wrapper and the bindings store cannot answer. A press still rings."
479
- };
480
- case "silent": return {
481
- state: "inactive",
482
- authority,
483
- detail: INACTIVE_DETAIL
484
- };
485
- }
486
- }
487
- /**
488
- * Concurrent card views of the same `unbound` camera share ONE durable read,
489
- * per camera, for as long as it is in flight — a page with several viewers
490
- * (or a refetch storm) must not multiply the store round trip. Only the
491
- * CARD's reads are shared: the ring gate calls the addon's
492
- * `readDurableAuthority` on its own path and never waits behind a card.
493
- */
494
- function shareDurableReads(read) {
495
- const inFlight = /* @__PURE__ */ new Map();
496
- return (deviceId) => {
497
- const pending = inFlight.get(deviceId);
498
- if (pending !== void 0) return pending;
499
- const started = read(deviceId).finally(() => {
500
- inFlight.delete(deviceId);
501
- });
502
- inFlight.set(deviceId, started);
503
- return started;
504
- };
505
- }
506
- async function activationOf(deps, readDurable, deviceId) {
507
- const mirrored = deps.readBindingMirror(deviceId);
508
- return activationFromVerdict(ringVerdict(mirrored, needsDurableRead(mirrored) ? await readDurable(deviceId) : null));
509
- }
510
- function sourceOf(deviceId, byId) {
511
- const device = byId.get(deviceId);
512
- if (device === void 0) return {
513
- deviceId,
514
- name: null,
515
- health: "missing",
516
- detail: "No device has this id any more — it rings nothing until removed."
517
- };
518
- return {
519
- deviceId,
520
- name: device.name,
521
- health: "ok"
522
- };
523
- }
524
- async function candidatesFor(deps, deviceId) {
525
- const [ids, fleet] = await Promise.all([deps.sourceCapableDeviceIds(), deps.listFleet()]);
526
- const eligible = new Set(ids);
527
- const byId = new Map(fleet.map((d) => [d.id, d]));
528
- const location = byId.get(deviceId)?.location ?? null;
529
- const out = [];
530
- for (const device of fleet) {
531
- if (device.id === deviceId || !eligible.has(device.id)) continue;
532
- const suggested = location !== null && device.location === location;
533
- const parent = device.parentDeviceId === null ? void 0 : byId.get(device.parentDeviceId);
534
- out.push({
535
- deviceId: device.id,
536
- name: device.name,
537
- type: device.type,
538
- location: device.location,
539
- parentName: parent?.name ?? null,
540
- suggested,
541
- reason: suggested ? "same location" : null
542
- });
543
- }
544
- return out.toSorted((a, b) => Number(b.suggested) - Number(a.suggested) || a.name.localeCompare(b.name));
545
- }
546
- /** Why each NEW pick is refused, or [] when every one is a legitimate source. */
547
- async function refusedPicks(deps, deviceId, picked) {
548
- const persisted = new Set((await deps.readSources(deviceId)).map((s) => s.deviceId));
549
- const added = picked.filter((id) => !persisted.has(id));
550
- if (added.length === 0) return [];
551
- const eligible = new Set(await deps.sourceCapableDeviceIds());
552
- const refused = [];
553
- for (const id of added) if (id === deviceId) refused.push(`${id} is the camera itself`);
554
- else if (!eligible.has(id)) refused.push(`${id} carries no doorbell source capability (a binary sensor or switch)`);
555
- return refused;
556
- }
557
- /** Build the `device-extension` provider the virtual doorbell registers. */
558
- function createDoorbellExtensionProvider(deps) {
559
- const readDurable = shareDurableReads(deps.readDurableBinding);
560
- return require_dist.createDeviceExtensionProvider({
561
- source: VIRTUAL_DOORBELL_EXTENSION_SOURCE,
562
- label: "Virtual doorbell",
563
- slots: [require_dist.defineExtensionSlotProvider("doorbell-source", {
564
- list: async (deviceId) => {
565
- const ids = (await deps.readSources(deviceId)).map((s) => s.deviceId);
566
- if (ids.length === 0) return {
567
- authority: DOORBELL_SOURCES_AUTHORITY,
568
- activation: null,
569
- value: {
570
- slot: "doorbell-source",
571
- deviceIds: []
572
- },
573
- sources: []
574
- };
575
- const [devices, activation] = await Promise.all([deps.listDevices(ids), activationOf(deps, readDurable, deviceId)]);
576
- const byId = new Map(devices.map((d) => [d.id, d]));
577
- return {
578
- authority: DOORBELL_SOURCES_AUTHORITY,
579
- activation,
580
- value: {
581
- slot: "doorbell-source",
582
- deviceIds: ids
583
- },
584
- sources: ids.map((id) => sourceOf(id, byId))
585
- };
586
- },
587
- set: async (deviceId, value) => {
588
- const refused = await refusedPicks(deps, deviceId, value.deviceIds);
589
- if (refused.length > 0) {
590
- deps.logger.warn("virtual-doorbell: extension write refused — nothing persisted", {
591
- tags: { deviceId },
592
- meta: { refused }
593
- });
594
- throw new Error(`doorbell-source on ${deviceId}: refused — ${refused.join("; ")}`);
595
- }
596
- await deps.persistSources(deviceId, value.deviceIds);
597
- return {
598
- applied: true,
599
- reload: "none"
600
- };
601
- },
602
- candidates: (deviceId) => candidatesFor(deps, deviceId)
603
- })],
604
- hostTypeOf: async (deviceId) => (await deps.getDevice(deviceId))?.type ?? null,
605
- logger: deps.logger
606
- });
607
- }
608
- //#endregion
609
- //#region src/builtins/doorbell/doorbell-composition-migration.ts
610
- var DOORBELL_COMPOSITION_MIGRATION_STATE_KEY = "doorbell-source-composition-migration-v1";
611
- var DOORBELL_COMPOSITION_MIGRATION_CALLER = {
612
- userId: "system:doorbell-source-migration",
613
- isAdmin: true
614
- };
615
- function isRecord(value) {
616
- return typeof value === "object" && value !== null && !Array.isArray(value);
617
- }
618
- function stableValue(value) {
619
- if (Array.isArray(value)) return value.map(stableValue);
620
- if (!isRecord(value)) return value;
621
- return Object.fromEntries(Object.keys(value).toSorted().map((key) => [key, stableValue(value[key])]));
622
- }
623
- function sameJson(a, b) {
624
- return JSON.stringify(stableValue(a)) === JSON.stringify(stableValue(b));
625
- }
626
- function withoutEnabled(feature) {
627
- const { enabled: _enabled, ...rest } = feature;
628
- return rest;
629
- }
630
- function migrationSources(camera, configured, devicesById, bindingsById) {
631
- const sources = [];
632
- for (const configuredSource of configured) {
633
- const device = devicesById.get(configuredSource.deviceId);
634
- const entries = bindingsById.get(configuredSource.deviceId);
635
- if (device === void 0 || entries === void 0) throw new Error(`source device ${configuredSource.deviceId} could not be resolved and read`);
636
- const found = require_dist.doorbellFeatureSources({
637
- addonId: device.addonId,
638
- stableId: device.stableId
639
- }, entries.map((entry) => entry.capName));
640
- if (found.length === 0) throw new Error(`source device ${configuredSource.deviceId} has no supported source capability`);
641
- sources.push(...found);
642
- }
643
- if (sources.length === 0) throw new Error(`camera ${camera.id} has no resolvable doorbell sources`);
644
- return sources;
645
- }
646
- function wrapperIsActive(entries) {
647
- return entries.some((entry) => entry.capName === "doorbell" && entry.kind === "wrapped" && entry.providerAddonId === "virtual-doorbell");
648
- }
649
- function composerIsBound(entries) {
650
- return entries.some((entry) => entry.capName === "doorbell" && (entry.providerAddonId === "composer" || entry.nativeAddonId === "composer"));
651
- }
652
- var DoorbellCompositionMigration = class {
653
- deps;
654
- rerunRequested = false;
655
- inFlight = null;
656
- stopped = false;
657
- constructor(deps) {
658
- this.deps = deps;
659
- }
660
- request(reason) {
661
- if (this.stopped) return;
662
- this.rerunRequested = true;
663
- if (this.inFlight !== null) return;
664
- this.inFlight = this.runUntilQuiet(reason).finally(() => {
665
- this.inFlight = null;
666
- if (this.rerunRequested && !this.stopped) this.request("coalesced lifecycle event");
667
- });
668
- }
669
- async waitForIdle() {
670
- while (this.inFlight !== null || this.rerunRequested) {
671
- const current = this.inFlight;
672
- if (current !== null) await current;
673
- else await Promise.resolve();
674
- }
675
- }
676
- shutdown() {
677
- this.stopped = true;
678
- this.rerunRequested = false;
679
- }
680
- async runUntilQuiet(reason) {
681
- while (this.rerunRequested && !this.stopped) {
682
- this.rerunRequested = false;
683
- try {
684
- await this.runAttempt(reason);
685
- } catch (err) {
686
- this.deps.logger.warn("doorbell-source migration: attempt failed", { meta: {
687
- reason,
688
- error: err instanceof Error ? err.message : String(err)
689
- } });
690
- }
691
- }
692
- }
693
- async runAttempt(reason) {
694
- if (await this.deps.isComplete()) return;
695
- const cameras = await this.deps.listCameras();
696
- const configuredByCamera = /* @__PURE__ */ new Map();
697
- const failedReads = /* @__PURE__ */ new Set();
698
- for (const camera of cameras) try {
699
- const sources = await this.deps.readSources(camera.id);
700
- if (sources.length > 0) configuredByCamera.set(camera.id, sources);
701
- } catch (err) {
702
- failedReads.add(camera.id);
703
- this.warnCamera(camera.id, `could not read source settings (${errorText(err)})`);
704
- }
705
- if (failedReads.size > 0) return;
706
- if (configuredByCamera.size === 0) {
707
- await this.deps.markComplete();
708
- return;
709
- }
710
- const camerasById = new Map(cameras.map((camera) => [camera.id, camera]));
711
- const sourceIds = [...new Set([...configuredByCamera.values()].flatMap((sources) => sources.map((s) => s.deviceId)))].toSorted((a, b) => a - b);
712
- const sourceDevices = await this.deps.listSourceDevices(sourceIds);
713
- const devicesById = new Map(sourceDevices.map((device) => [device.id, device]));
714
- const bindingIds = [...new Set([...configuredByCamera.keys(), ...sourceIds])].toSorted((a, b) => a - b);
715
- const bindingRows = await this.deps.getBindingsBatch(bindingIds);
716
- const bindingsById = new Map(bindingRows.map((row) => [row.deviceId, row.entries]));
717
- const coreBlocks = this.deps.coreBlocks();
718
- if (coreBlocks === null) {
719
- for (const cameraId of configuredByCamera.keys()) this.warnCamera(cameraId, "core-blocks provider is not ready");
720
- return;
721
- }
722
- let allSettled = true;
723
- for (const [cameraId, configured] of configuredByCamera) {
724
- const camera = camerasById.get(cameraId);
725
- const entries = bindingsById.get(cameraId);
726
- if (camera === void 0 || entries === void 0) {
727
- allSettled = false;
728
- this.warnCamera(cameraId, "camera or binding row could not be read");
729
- continue;
730
- }
731
- try {
732
- const sources = migrationSources(camera, configured, devicesById, bindingsById);
733
- const cameraRef = {
734
- addonId: camera.addonId,
735
- stableId: camera.stableId
736
- };
737
- const read = await coreBlocks.getCustomization({ ...cameraRef });
738
- if (read.state !== "loaded") throw new Error(`customizations are not loaded (${read.reason})`);
739
- const view = read.view;
740
- if (view !== null && !view.block.enabled) throw new Error(`customization block ${view.block.id} is disabled`);
741
- const existingComposition = view?.block.source?.kind === "composition" ? view.block.source.composition : null;
742
- if (view !== null && existingComposition === null) throw new Error(`customization block ${view.block.id} has no readable composition`);
743
- const existingDoorbell = existingComposition?.features.find((feature) => feature.cap === "doorbell");
744
- const wrapperActive = wrapperIsActive(entries);
745
- const composerBound = composerIsBound(entries);
746
- const crashRecovery = !wrapperActive && composerBound && existingDoorbell?.kind === "fields" && existingDoorbell.enabled === true;
747
- const enabled = wrapperActive || crashRecovery;
748
- const canonical = require_dist.buildDoorbellCompositionFeature({
749
- camera: cameraRef,
750
- sources,
751
- enabled
752
- });
753
- let needsSave = view === null;
754
- let composition;
755
- if (existingComposition === null) composition = {
756
- target: {
757
- kind: "existing",
758
- device: cameraRef
759
- },
760
- features: [canonical]
761
- };
762
- else if (existingDoorbell === void 0) {
763
- composition = {
764
- ...existingComposition,
765
- features: [...existingComposition.features, canonical]
766
- };
767
- needsSave = true;
768
- } else if (existingDoorbell.kind === "fields" && sameJson(withoutEnabled(existingDoorbell), withoutEnabled(canonical))) {
769
- composition = {
770
- ...existingComposition,
771
- features: existingComposition.features.map((feature) => feature.cap === "doorbell" ? canonical : feature)
772
- };
773
- needsSave ||= existingDoorbell.enabled !== canonical.enabled;
774
- } else throw new Error("an existing doorbell feature is not owned by this migration");
775
- if (needsSave) if (view === null) await coreBlocks.createComposition({
776
- block: {
777
- enabled: true,
778
- composition
779
- },
780
- caller: DOORBELL_COMPOSITION_MIGRATION_CALLER
781
- });
782
- else await coreBlocks.updateComposition({
783
- blockId: view.block.id,
784
- expectedUpdatedAt: view.block.updatedAt,
785
- block: { composition },
786
- caller: DOORBELL_COMPOSITION_MIGRATION_CALLER
787
- });
788
- if (!enabled) continue;
789
- if (!composerBound) {
790
- allSettled = false;
791
- continue;
792
- }
793
- if (wrapperActive) {
794
- await this.deps.setWrapperActive({
795
- deviceId: camera.id,
796
- capName: "doorbell",
797
- wrapperAddonId: "virtual-doorbell",
798
- active: false
799
- });
800
- this.deps.logger.info("doorbell-source migration: composer owns doorbell; wrapper deactivated", {
801
- tags: { deviceId: camera.id },
802
- meta: { reason }
803
- });
804
- }
805
- } catch (err) {
806
- allSettled = false;
807
- this.warnCamera(cameraId, errorText(err));
808
- }
809
- }
810
- if (allSettled) await this.deps.markComplete();
811
- }
812
- warnCamera(cameraId, reason) {
813
- this.deps.logger.warn("doorbell-source migration: camera not migrated", {
814
- tags: { deviceId: cameraId },
815
- meta: { reason }
816
- });
817
- }
818
- };
819
- function errorText(err) {
820
- return err instanceof Error ? err.message : String(err);
821
- }
822
- //#endregion
823
- //#region src/builtins/doorbell/virtual-doorbell.addon.ts
824
- /** Narrow a raw `doorbell` runtime-state slice to counters (defaults on absence). */
825
- function parseDoorbellSlice(slice) {
826
- const lastPressedAtRaw = slice?.["lastPressedAt"];
827
- const countRaw = slice?.["pressCountSinceStart"];
828
- return {
829
- lastPressedAt: typeof lastPressedAtRaw === "number" ? lastPressedAtRaw : null,
830
- pressCountSinceStart: typeof countRaw === "number" && Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0
831
- };
832
- }
833
- /**
834
- * VirtualDoorbellAddon — WRAPPER over the `doorbell` capability.
835
- *
836
- * Turns any binary sensor or switch (contact, motion, flood/gas/CO/smoke,
837
- * vibration, tamper, generic binary, or a switch) into a doorbell for a
838
- * camera:
839
- *
840
- * 1. The operator binds the wrapper to a camera in the bindings UI
841
- * (`defaultActive: false` — never auto-bound).
842
- * 2. In the camera's Extensions section (the `doorbell-source` slot,
843
- * D648/D655) the operator picks one or more SOURCE devices — that is
844
- * the ONLY choice. The reaction is derived automatically per
845
- * device type: a binary sensor rings when it goes active, a switch
846
- * rings when it turns on (see `trigger-engine.ts`). The persisted
847
- * shape (`doorbellSources: [{deviceId}]`) stays extensible for a
848
- * future custom-trigger override.
849
- * 3. At runtime the addon watches `EventCategory.DeviceStateChanged`
850
- * (binary/switch slice transitions — false→true edge detected). On a
851
- * matching, debounced rise it emits `EventCategory.DoorbellOnPressed`
852
- * with the CAMERA as source (`{deviceId: cameraId, timestamp}` — the
853
- * same shape Reolink's native `emitDoorbellPressed` produces) and
854
- * bumps the camera's `doorbell` runtime-state counters via the
855
- * standard `deviceState.setCapSlice` plumbing.
856
- *
857
- * Fires ONLY for cameras with at least one configured source device that
858
- * this wrapper is BOUND to. The binding is NOT re-derived on the ring path:
859
- * it is mirrored in memory (`binding-mirror.ts`) and refreshed on the
860
- * assignment-reload cadence, because a fallible read must never be able to
861
- * destroy a one-shot press.
862
- */
863
- var VirtualDoorbellAddon = class extends require_dist.BaseAddon {
864
- engine;
865
- compositionMigration = null;
866
- providerRegisteredUnsubscribe = null;
867
- /** Monotonic guard: assignments are reloaded lazily on trigger traffic. */
868
- assignmentsLoadedAt = 0;
869
- assignmentsLoading = null;
870
- /**
871
- * Per-camera mirror of "is this wrapper still the active doorbell
872
- * binding". Written ONLY off the ring path; read on it. See
873
- * `binding-mirror.ts` for why a single negative read is not believed.
874
- */
875
- bindingMirror = /* @__PURE__ */ new Map();
876
- /**
877
- * Per camera, the last NEGATIVE refresh this addon reported — its state,
878
- * the authority's verdict and who else serves the doorbell. A refresh runs
879
- * every reload cycle (about once a minute); the same answer repeated is not
880
- * news, so it is reported once per change and forgotten on a positive read.
881
- * Camera 615 logged the same WARN 607 times in 14 h after D671.
882
- */
883
- reportedNegative = /* @__PURE__ */ new Map();
884
- /** Cameras whose last durable-authority read THREW — reported once per outage. */
885
- durableReadFailing = /* @__PURE__ */ new Set();
886
- /**
887
- * Per camera, the OTHER providers of `doorbell` the last successful derived
888
- * read named (the composer after D671). Written off the ring path with the
889
- * mirror; read on it, to decide whether erring open would double a ring.
890
- */
891
- servedElsewhere = /* @__PURE__ */ new Map();
892
- /** Cameras whose store-over-derived disagreement has been reported, until a derived read agrees. */
893
- reportedStoreOverride = /* @__PURE__ */ new Set();
894
- constructor() {
895
- super({
896
- debounceMs: 2e3,
897
- assignmentsTtlMs: 6e4
898
- });
899
- this.engine = new DoorbellTriggerEngine({ debounceMs: 2e3 });
900
- }
901
- async onInitialize() {
902
- this.engine = new DoorbellTriggerEngine({ debounceMs: this.config.debounceMs });
903
- this.ctx.logger.info("Virtual doorbell wrapper initialized");
904
- this.subscribe({ category: require_dist.EventCategory.DeviceStateChanged }, (event) => {
905
- const data = require_device_state_changed.parseDeviceStateChanged(event.data);
906
- if (!data) return;
907
- this.handleStateChanged(data);
908
- });
909
- const migrationState = this.state(DOORBELL_COMPOSITION_MIGRATION_STATE_KEY, zod.z.object({ complete: zod.z.boolean() }), { complete: false });
910
- this.compositionMigration = new DoorbellCompositionMigration({
911
- logger: this.ctx.logger.child("doorbell-source-migration"),
912
- listCameras: async () => this.ctx.api.deviceManager.listAll.query({
913
- isCamera: true,
914
- projection: "slim"
915
- }),
916
- listSourceDevices: async (deviceIds) => this.ctx.api.deviceManager.listAll.query({
917
- deviceIds: [...deviceIds],
918
- projection: "slim"
919
- }),
920
- readSources: (cameraId) => this.readDeviceSources(cameraId),
921
- getBindingsBatch: async (deviceIds) => this.ctx.api.deviceManager.getBindingsBatch.query({ deviceIds: [...deviceIds] }),
922
- coreBlocks: () => this.ctx.capabilities?.get("core-blocks") ?? null,
923
- setWrapperActive: (input) => this.ctx.api.deviceManager.setWrapperActive.mutate(input),
924
- isComplete: async () => (await migrationState.get()).complete,
925
- markComplete: async () => migrationState.set({ complete: true })
926
- });
927
- this.providerRegisteredUnsubscribe = this.ctx.eventBus.subscribe({ category: "capability:provider-registered" }, (event) => {
928
- if (event.data["capability"] !== "core-blocks") return;
929
- this.compositionMigration?.request("core-blocks registered");
930
- });
931
- this.subscribe({ category: require_dist.EventCategory.DeviceBindingsChanged }, () => {
932
- this.compositionMigration?.request("device bindings changed");
933
- });
934
- this.compositionMigration.request("addon initialized");
935
- return [{
936
- capability: require_dist.doorbellCapability,
937
- provider: { getStatus: (input) => this.getStatus(input.deviceId) }
938
- }, {
939
- capability: require_dist.deviceExtensionCapability,
940
- provider: this.buildExtensionProvider()
941
- }];
942
- }
943
- async onShutdown() {
944
- this.providerRegisteredUnsubscribe?.();
945
- this.providerRegisteredUnsubscribe = null;
946
- this.compositionMigration?.shutdown();
947
- this.compositionMigration = null;
948
- }
949
- /**
950
- * The `device-extension` provider: the only editor of a camera's trigger
951
- * devices since D655 (see `doorbell-extension-slot.ts`).
952
- */
953
- buildExtensionProvider() {
954
- const toSlotDevice = (d) => ({
955
- id: d.id,
956
- name: d.name,
957
- type: d.type,
958
- location: d.location,
959
- parentDeviceId: d.parentDeviceId
960
- });
961
- return createDoorbellExtensionProvider({
962
- ownAddonId: this.ownAddonId,
963
- logger: this.ctx.logger,
964
- readSources: (deviceId) => this.readDeviceSources(deviceId),
965
- persistSources: (deviceId, deviceIds) => this.persistDoorbellSources(deviceId, deviceIds),
966
- getDevice: async (deviceId) => {
967
- const found = await this.ctx.api.deviceManager.getDevice.query({ deviceId });
968
- return found ? toSlotDevice(found) : null;
969
- },
970
- listDevices: async (deviceIds) => (await this.ctx.api.deviceManager.listAll.query({
971
- projection: "slim",
972
- deviceIds: [...deviceIds]
973
- })).map(toSlotDevice),
974
- readBindingMirror: (deviceId) => this.mirrorRead(deviceId),
975
- readDurableBinding: (deviceId) => this.readDurableAuthority(deviceId),
976
- sourceCapableDeviceIds: () => this.ctx.api.deviceManager.listDeviceIdsByCaps.query({ capNames: [...require_dist.SOURCE_CAPS] }),
977
- listFleet: async () => (await this.ctx.api.deviceManager.listAll.query({ projection: "slim" })).map(toSlotDevice)
978
- });
979
- }
980
- async handleStateChanged(data) {
981
- if (!require_dist.isSourceCap(data.capName)) return;
982
- await this.ensureAssignments();
983
- const result = this.engine.onStateSlice(data.deviceId, data.capName, data.slice);
984
- this.logSuppressions(data, result);
985
- for (const cameraId of result.fired) await this.fireDoorbell(cameraId, Date.now(), data.deviceId, data.capName);
986
- }
987
- /**
988
- * Say out loud every case where a rise did NOT become a ring. Silence
989
- * reads as "never happened": three real presses on camera 615 produced
990
- * zero operator-visible lines because the drop logged at `debug`.
991
- */
992
- logSuppressions(data, result) {
993
- for (const cameraId of result.debounced) this.ctx.logger.info("virtual-doorbell: rise suppressed by the debounce window", {
994
- tags: { deviceId: cameraId },
995
- meta: {
996
- sourceDeviceId: data.deviceId,
997
- capName: data.capName
998
- }
999
- });
1000
- if (result.ignored === "baseline-seeded-stale-active") {
1001
- const message = "virtual-doorbell: first slice seen for this source is already ACTIVE but its transition could not be dated — seeded as baseline, a press may have been swallowed";
1002
- const options = {
1003
- tags: { deviceId: data.deviceId },
1004
- meta: { capName: data.capName }
1005
- };
1006
- if (this.engine.hasSource(data.deviceId)) this.ctx.logger.warn(message, options);
1007
- else this.ctx.logger.debug(message, options);
1008
- return;
1009
- }
1010
- if (result.ignored === "no-assignment") this.ctx.logger.debug("virtual-doorbell: rise on a source no camera is wired to", {
1011
- tags: { deviceId: data.deviceId },
1012
- meta: { capName: data.capName }
1013
- });
1014
- }
1015
- /**
1016
- * Ring the camera's virtual doorbell: bump the `doorbell` runtime-state
1017
- * counters through the canonical `deviceState.setCapSlice` write, and
1018
- * emit the typed `DoorbellOnPressed` event with the CAMERA as source —
1019
- * byte-shaped like a native firmware ring so every downstream consumer
1020
- * (UI toast, notifier rules, exporters) is agnostic to the origin.
1021
- *
1022
- * The binding gate here is a pure in-memory read on the path that RINGS:
1023
- * a `bound` mirror, or an open one on a camera nobody else serves, performs
1024
- * NO I/O and cannot fail. Every other case asks the durable authority once
1025
- * before deciding — see `needsDurableRead` and `ringsAfterDurableRead`.
1026
- *
1027
- * `sourceDeviceId`/`capName` are the source that produced this rise —
1028
- * carried through purely so the terminal `pressed` line can name it. Every
1029
- * suppression branch beside it already reports its source
1030
- * (`logSuppressions`); the successful path was the one place that did not,
1031
- * which meant answering "what rang it" required hand-correlating a
1032
- * timestamp against provider logs (proven live 2026-08-25, camera 615).
1033
- */
1034
- async fireDoorbell(cameraId, timestamp, sourceDeviceId, capName) {
1035
- const mirror = this.mirrorRead(cameraId);
1036
- const binding = mirror.state;
1037
- if (needsDurableRead(mirror) && !await this.ringsAfterDurableRead(cameraId, mirror)) return;
1038
- 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", {
1039
- tags: { deviceId: cameraId },
1040
- meta: { bindingState: binding ?? "never-read" }
1041
- });
1042
- try {
1043
- const next = {
1044
- lastPressedAt: timestamp,
1045
- pressCountSinceStart: parseDoorbellSlice(await this.ctx.api.deviceState.getCapSlice.query({
1046
- deviceId: cameraId,
1047
- capName: require_dist.doorbellCapability.name
1048
- })).pressCountSinceStart + 1
1049
- };
1050
- await this.ctx.api.deviceState.setCapSlice.mutate({
1051
- deviceId: cameraId,
1052
- capName: require_dist.doorbellCapability.name,
1053
- slice: next
1054
- });
1055
- } catch (err) {
1056
- this.ctx.logger.warn("virtual-doorbell: runtime-state update failed", {
1057
- tags: { deviceId: cameraId },
1058
- meta: { error: require_dist.errMsg(err) }
1059
- });
1060
- }
1061
- this.ctx.eventBus.emit(require_dist.createEvent(require_dist.EventCategory.DoorbellOnPressed, {
1062
- type: "device",
1063
- id: cameraId,
1064
- deviceId: cameraId,
1065
- addonId: this.ctx.id
1066
- }, {
1067
- deviceId: cameraId,
1068
- timestamp
1069
- }));
1070
- this.ctx.logger.info("virtual-doorbell: pressed", {
1071
- tags: { deviceId: cameraId },
1072
- meta: {
1073
- timestamp,
1074
- sourceDeviceId,
1075
- capName
1076
- }
1077
- });
1078
- }
1079
- /**
1080
- * The addon id as the REGISTRY and the bindings store spell it.
1081
- *
1082
- * `ctx.id` does NOT: hub-main prefixes it (`addon:virtual-doorbell`) while
1083
- * a forked runner does not, and this builtin runs in hub-main. Comparing
1084
- * the raw `ctx.id` to a `providerAddonId` was false for every entry for
1085
- * twelve hours on 2026-08-07 (D72).
1086
- */
1087
- get ownAddonId() {
1088
- return require_dist.bareAddonId(this.ctx.id);
1089
- }
1090
- /**
1091
- * One read of the DERIVED binding view. `null` when the read could not be
1092
- * answered at all — which carries no information and must leave the mirror
1093
- * untouched.
1094
- */
1095
- async readDerivedBinding(cameraId) {
1096
- try {
1097
- const bindings = await this.ctx.api.deviceManager.getBindings.query({ deviceId: cameraId });
1098
- return {
1099
- bound: entriesBindWrapper(bindings.entries, require_dist.doorbellCapability.name, this.ownAddonId),
1100
- entries: bindings.entries
1101
- };
1102
- } catch (err) {
1103
- this.ctx.logger.warn("virtual-doorbell: binding check failed — keeping the last known verdict, NOT treating this as an unbind", {
1104
- tags: { deviceId: cameraId },
1105
- meta: {
1106
- error: require_dist.errMsg(err),
1107
- bindingState: this.bindingMirror.get(cameraId)
1108
- }
1109
- });
1110
- return null;
1111
- }
1112
- }
1113
- /**
1114
- * One read of the DURABLE authority: the wrapper activation device-manager
1115
- * STORED for this camera (`deviceManager.getWrapperActivation`, D675) — the
1116
- * row the operator's bindings toggle writes. `deviceManager.getBindings` is
1117
- * a VIEW derived from it plus the capability registry.
1118
- *
1119
- * This is deliberately NOT `getBindings`. The derived view folds the store
1120
- * together with the capability registry, `defaultActive` auto-binding and
1121
- * the cluster native-cap feeds — any of which can make it disagree with
1122
- * what the operator actually persisted, and one of which (an addon-id
1123
- * spelling mismatch) made it disagree *systematically* while looking
1124
- * exactly like a genuine unbind. The raw row cannot: it is what the
1125
- * bindings toggle wrote, and it grades its own trustworthiness (D44).
1126
- *
1127
- * A read that throws is reported when the outage STARTS and when it ends —
1128
- * never per read: this runs on every reload cycle.
1129
- */
1130
- async readDurableAuthority(cameraId) {
1131
- let read;
1132
- try {
1133
- read = await this.ctx.api.deviceManager.getWrapperActivation.query({
1134
- deviceId: cameraId,
1135
- capName: require_dist.doorbellCapability.name
1136
- });
1137
- } catch (err) {
1138
- read = {
1139
- kind: "failed",
1140
- error: require_dist.errMsg(err)
1141
- };
1142
- }
1143
- if (read.kind === "failed") {
1144
- if (!this.durableReadFailing.has(cameraId)) {
1145
- this.durableReadFailing.add(cameraId);
1146
- this.ctx.logger.warn("virtual-doorbell: the durable bindings store could not be read — it carries no information and revokes nothing (reported once until it answers again)", {
1147
- tags: { deviceId: cameraId },
1148
- meta: { error: read.error }
1149
- });
1150
- }
1151
- return "unreadable";
1152
- }
1153
- if (this.durableReadFailing.delete(cameraId)) this.ctx.logger.info("virtual-doorbell: the durable bindings store answers again", { tags: { deviceId: cameraId } });
1154
- return durableVerdictOf(read, this.ownAddonId);
1155
- }
1156
- /**
1157
- * Reconcile `cameraId`'s mirror against the authority. Runs on the
1158
- * assignment-reload cadence, never while firing — and it runs on EVERY
1159
- * cycle regardless of the current verdict, so a camera sitting in
1160
- * `unbound` returns to `bound` on its own the moment the authority says
1161
- * so. There is no latch here that an operator has to clear.
1162
- *
1163
- * A positive derived read is believed immediately. A NEGATIVE one never
1164
- * is: the durable authority is asked, and it outranks the derived view in
1165
- * the direction that destroys work. Only when the authority agrees does
1166
- * the negative get folded, and even then `nextBindingState` still wants a
1167
- * second consecutive one (D49).
1168
- */
1169
- async refreshBindingMirror(cameraId) {
1170
- const derived = await this.readDerivedBinding(cameraId);
1171
- if (derived === null) return;
1172
- const servedBy = otherProvidersOf(derived.entries, require_dist.doorbellCapability.name, this.ownAddonId);
1173
- this.servedElsewhere.set(cameraId, servedBy);
1174
- if (derived.bound) {
1175
- this.bindingMirror.set(cameraId, nextBindingState(this.bindingMirror.get(cameraId), true));
1176
- this.reportedNegative.delete(cameraId);
1177
- this.reportedStoreOverride.delete(cameraId);
1178
- return;
1179
- }
1180
- const authority = await this.readDurableAuthority(cameraId);
1181
- const entries = summariseEntries(derived.entries);
1182
- if (authority === "bound") {
1183
- this.reportedNegative.delete(cameraId);
1184
- if (!this.reportedStoreOverride.has(cameraId)) {
1185
- this.reportedStoreOverride.add(cameraId);
1186
- 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 (reported once until they agree)", {
1187
- tags: { deviceId: cameraId },
1188
- meta: {
1189
- ownAddonId: this.ownAddonId,
1190
- entries
1191
- }
1192
- });
1193
- }
1194
- this.bindingMirror.set(cameraId, nextBindingState(this.bindingMirror.get(cameraId), true));
1195
- return;
1196
- }
1197
- this.reportedStoreOverride.delete(cameraId);
1198
- const previous = this.bindingMirror.get(cameraId);
1199
- const next = nextBindingState(previous, false);
1200
- this.bindingMirror.set(cameraId, next);
1201
- this.reportNegativeRefresh(cameraId, {
1202
- from: previous ?? "never-read",
1203
- to: next,
1204
- authority,
1205
- servedBy,
1206
- entries
1207
- });
1208
- }
1209
- /** The ring gate's in-memory input for one camera — no I/O. */
1210
- mirrorRead(cameraId) {
1211
- return {
1212
- state: this.bindingMirror.get(cameraId),
1213
- servedBy: this.servedElsewhere.get(cameraId) ?? []
1214
- };
1215
- }
1216
- /**
1217
- * Report a negative refresh ONCE per change of what it says (D391: a
1218
- * repeated identical line is noise that buries the next real one).
1219
- *
1220
- * The level follows what the answer MEANS:
1221
- * - the durable store could not grade it (`unreadable`) — WARN: the
1222
- * camera's ring now depends on erring open, and nobody can say whether
1223
- * that is right;
1224
- * - the store CONFIRMS the wrapper is not bound — an operator's unbind or
1225
- * the D671 cutover to a composition. That is a state, not an anomaly:
1226
- * INFO when it is believed (`unbound`), DEBUG for the first,
1227
- * not-yet-believed read.
1228
- */
1229
- reportNegativeRefresh(cameraId, report) {
1230
- const key = `${report.to}|${report.authority}|${report.servedBy.join(",")}`;
1231
- if (this.reportedNegative.get(cameraId) === key) return;
1232
- this.reportedNegative.set(cameraId, key);
1233
- const options = {
1234
- tags: { deviceId: cameraId },
1235
- meta: {
1236
- from: report.from,
1237
- to: report.to,
1238
- ownAddonId: this.ownAddonId,
1239
- durableAuthority: report.authority,
1240
- doorbellServedBy: report.servedBy,
1241
- entries: report.entries
1242
- }
1243
- };
1244
- if (report.authority === "unreadable") {
1245
- this.ctx.logger.warn("virtual-doorbell: bindings read did not see this wrapper and the durable store cannot confirm it — a press still rings", options);
1246
- return;
1247
- }
1248
- const message = report.servedBy.length > 0 ? `virtual-doorbell: wrapper deactivated on this camera — its doorbell is served by ${report.servedBy.join(", ")}` : "virtual-doorbell: wrapper deactivated on this camera — the bindings store confirms it";
1249
- if (report.to === "unbound") this.ctx.logger.info(message, options);
1250
- else this.ctx.logger.debug(message, options);
1251
- }
1252
- /**
1253
- * A real press arrived on a camera the in-memory gate cannot clear on its
1254
- * own: the mirror is `unbound`, or it is short of `bound` while another
1255
- * provider serves the doorbell. Decide, with ONE read of the durable
1256
- * authority, whether this wrapper rings.
1257
- *
1258
- * D49 forbade I/O on the ring path because the read it would have made was
1259
- * the same fallible derived read that caused the problem. This read is
1260
- * different in both directions: it is the authority rather than a view of
1261
- * it, and it is paid ONLY where erring open could be wrong — a dropped ring
1262
- * (an `unbound` mirror) or a DOUBLED one (another provider rings this
1263
- * camera). A ring is rare and one query is cheap. An unreadable store still
1264
- * rings: it carries no information.
1265
- *
1266
- * Returns `true` when the ring must proceed.
1267
- */
1268
- async ringsAfterDurableRead(cameraId, mirror) {
1269
- const binding = mirror.state;
1270
- const authority = await this.readDurableAuthority(cameraId);
1271
- const verdict = ringVerdict(mirror, authority);
1272
- switch (verdict) {
1273
- case "rings":
1274
- this.bindingMirror.set(cameraId, "bound");
1275
- this.ctx.logger.warn("virtual-doorbell: ring RESCUED — the mirror did not confirm this wrapper but the durable bindings store still names it; the mirror has been repaired", {
1276
- tags: { deviceId: cameraId },
1277
- meta: {
1278
- bindingState: binding ?? "never-read",
1279
- doorbellServedBy: mirror.servedBy,
1280
- ownAddonId: this.ownAddonId
1281
- }
1282
- });
1283
- return verdictRings(verdict);
1284
- case "unknown":
1285
- 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", {
1286
- tags: { deviceId: cameraId },
1287
- meta: {
1288
- bindingState: binding,
1289
- ownAddonId: this.ownAddonId
1290
- }
1291
- });
1292
- return verdictRings(verdict);
1293
- case "silent": {
1294
- const derived = await this.readDerivedBinding(cameraId);
1295
- const servedBy = derived === null ? mirror.servedBy : otherProvidersOf(derived.entries, require_dist.doorbellCapability.name, this.ownAddonId);
1296
- const options = {
1297
- tags: { deviceId: cameraId },
1298
- meta: {
1299
- bindingState: binding,
1300
- ownAddonId: this.ownAddonId,
1301
- durableAuthority: authority,
1302
- doorbellServedBy: servedBy,
1303
- entries: derived === null ? "unreadable" : summariseEntries(derived.entries)
1304
- }
1305
- };
1306
- if (servedBy.length > 0) this.ctx.logger.info(`virtual-doorbell: press left to ${servedBy.join(", ")} — this wrapper is deactivated on this camera`, options);
1307
- else 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", options);
1308
- return verdictRings(verdict);
1309
- }
1310
- case "rings-pending":
1311
- case "rings-unconfirmed":
1312
- this.ctx.logger.warn(`virtual-doorbell: ringing although ${mirror.servedBy.join(", ")} serves this doorbell — the durable bindings store could not confirm this wrapper was deactivated`, {
1313
- tags: { deviceId: cameraId },
1314
- meta: {
1315
- bindingState: binding ?? "never-read",
1316
- durableAuthority: authority,
1317
- ownAddonId: this.ownAddonId
1318
- }
1319
- });
1320
- return verdictRings(verdict);
1321
- }
1322
- }
1323
- /**
1324
- * Lazily (re)build the camera→source assignment set from every
1325
- * camera's device store. TTL-bounded so devices/settings appearing
1326
- * after boot are picked up without a save; invalidated eagerly by
1327
- * `persistDoorbellSources`.
1328
- */
1329
- async ensureAssignments() {
1330
- const now = Date.now();
1331
- if (this.assignmentsLoadedAt > 0 && now - this.assignmentsLoadedAt < this.config.assignmentsTtlMs) return;
1332
- if (this.assignmentsLoading) return this.assignmentsLoading;
1333
- this.assignmentsLoading = this.loadAssignments().then(() => {
1334
- this.assignmentsLoadedAt = Date.now();
1335
- }).catch((err) => {
1336
- this.ctx.logger.warn("virtual-doorbell: assignment reload failed", { meta: { error: require_dist.errMsg(err) } });
1337
- }).finally(() => {
1338
- this.assignmentsLoading = null;
1339
- });
1340
- return this.assignmentsLoading;
1341
- }
1342
- async loadAssignments() {
1343
- if (!this.ctx.settings) {
1344
- this.ctx.logger.warn("virtual-doorbell: no settings store — every camera reads ZERO trigger sources, no doorbell can ring");
1345
- return;
1346
- }
1347
- const cameras = (await this.ctx.api.deviceManager.listAll.query({})).filter((d) => d.type === require_dist.DeviceType.Camera);
1348
- const assignments = [];
1349
- const configuredCameras = [];
1350
- for (const camera of cameras) {
1351
- const sources = await this.readDeviceSources(camera.id);
1352
- if (sources.length > 0) configuredCameras.push(camera.id);
1353
- for (const source of sources) assignments.push({
1354
- cameraId: camera.id,
1355
- sourceDeviceId: source.deviceId
1356
- });
1357
- }
1358
- for (const cameraId of configuredCameras) await this.refreshBindingMirror(cameraId);
1359
- const hadAssignments = this.engine.getAssignments().length > 0;
1360
- this.engine.setAssignments(assignments);
1361
- if (hadAssignments && assignments.length === 0) this.ctx.logger.warn("virtual-doorbell: assignment reload went from configured to EMPTY — every camera just lost its trigger sources");
1362
- this.ctx.logger.debug("virtual-doorbell: assignments loaded", { meta: {
1363
- count: assignments.length,
1364
- configuredCameras: configuredCameras.length
1365
- } });
1366
- }
1367
- invalidateAssignments() {
1368
- this.assignmentsLoadedAt = 0;
1369
- }
1370
- /**
1371
- * Status from the kernel-managed `doorbell` runtime-state slice —
1372
- * the wrapper keeps NO parallel counter fields (the slice is the
1373
- * single source of truth, persisted across restarts).
1374
- */
1375
- async getStatus(deviceId) {
1376
- try {
1377
- return parseDoorbellSlice(await this.ctx.api.deviceState.getCapSlice.query({
1378
- deviceId,
1379
- capName: require_dist.doorbellCapability.name
1380
- }));
1381
- } catch (err) {
1382
- this.ctx.logger.warn("virtual-doorbell: getCapSlice failed during getStatus", {
1383
- tags: { deviceId },
1384
- meta: { error: require_dist.errMsg(err) }
1385
- });
1386
- return null;
1387
- }
1388
- }
1389
- /**
1390
- * The camera's configured doorbell sources, parsed tolerantly (new
1391
- * `doorbellSources` shape + legacy single-source migration).
1392
- */
1393
- async readDeviceSources(deviceId) {
1394
- if (!this.ctx.settings) return [];
1395
- return parseDoorbellSources(await this.ctx.settings.readDeviceStore(deviceId));
1396
- }
1397
- /**
1398
- * THE write path for a camera's trigger sources — the `doorbell-source`
1399
- * extension slot lands here (D648; its only caller since D655): normalise
1400
- * to `doorbellSources: [{deviceId}]`, strip the legacy keys, and invalidate
1401
- * the assignment cache so the next rise (and the binding mirror's next
1402
- * refresh, off the ring path) sees the new wiring.
1403
- */
1404
- async persistDoorbellSources(deviceId, value) {
1405
- if (!this.ctx.settings) throw new Error("[virtual-doorbell] settings store unavailable — cannot persist per-device settings");
1406
- const current = await this.ctx.settings.readDeviceStore(deviceId);
1407
- await this.ctx.settings.writeDeviceStore(deviceId, applyDoorbellSourcesPatch(current, value));
1408
- this.invalidateAssignments();
1409
- }
1410
- };
1411
- //#endregion
1412
- exports.VirtualDoorbellAddon = VirtualDoorbellAddon;
1413
- exports.default = VirtualDoorbellAddon;