@camstack/system 1.2.330 → 1.2.332

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