@camstack/system 1.2.55 → 1.2.56

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.
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The virtual-doorbell wrapper's in-memory mirror of ONE durable fact per
3
+ * camera: is this wrapper still the active `doorbell` binding for it?
4
+ *
5
+ * WHY A MIRROR AND NOT A READ. A doorbell press is a one-shot event: there
6
+ * is no retry, no reconcile, nothing downstream that can notice it went
7
+ * missing. The wrapper used to re-derive the binding on the ring path via
8
+ * `deviceManager.getBindings` and treat ANY answer that omitted its own
9
+ * activation as "the operator unbound this". On 2026-08-04/05 camera 615
10
+ * rang zero times out of three real presses: the binding was persisted (a
11
+ * same-day backup taken before the presses proves it, and the same process
12
+ * later returned it correctly to a tRPC query), but the store answered
13
+ * transiently without it — `getBindings` reads the device-manager addon
14
+ * store, which is exactly the surface that can silently answer `{}` (D44,
15
+ * `addon-registry.service.ts`). Three presses, three drops, at `debug`.
16
+ *
17
+ * WHY CORROBORATION AND NOT A BETTER DISCRIMINATOR. There isn't one. A
18
+ * genuinely unbound pure-wrapper cap and a store answering from an empty
19
+ * snapshot produce the SAME `getBindings` response — no `doorbell` entry,
20
+ * with the camera's native entries (snapshot, motion, …) still present in
21
+ * both cases because those come from the capability registry, not the
22
+ * store. The response cannot be graded. What CAN be graded is repetition: a
23
+ * real unbind is permanent and every later read agrees, a store blip does
24
+ * not survive the next read. So a negative read ARMS the revocation and a
25
+ * second consecutive negative applies it.
26
+ *
27
+ * ERRING. `undefined` (never read) and `unbind-pending` both ring. Reaching
28
+ * the ring path at all means the camera has a configured trigger source,
29
+ * and a source can only be configured through this wrapper's own
30
+ * binding-gated settings surface — so a configured source is itself
31
+ * evidence the wrapper was bound. The cost of erring open is a spurious
32
+ * ring on a camera the operator unbound but left configured, for at most
33
+ * two reload cycles. The cost of erring closed is the press.
34
+ */
35
+ /**
36
+ * `bound` — a read saw the wrapper's activation.
37
+ * `unbind-pending` — one read did not; NOT yet believed, still rings.
38
+ * `unbound` — two consecutive reads did not; believed, silences the camera.
39
+ */
40
+ export type DoorbellBindingState = 'bound' | 'unbind-pending' | 'unbound';
41
+ /**
42
+ * Fold one SUCCESSFUL bindings read into the mirror. A read that failed must
43
+ * never reach this function — an unanswerable store carries no information
44
+ * and must leave the mirror exactly as it was.
45
+ */
46
+ export declare function nextBindingState(previous: DoorbellBindingState | undefined, boundNow: boolean): DoorbellBindingState;
47
+ /** May a ring proceed? Only a corroborated unbind stops one. */
48
+ export declare function allowsRing(state: DoorbellBindingState | undefined): boolean;
49
+ /** True only when a read has positively confirmed the binding. */
50
+ export declare function hasConfirmedBinding(state: DoorbellBindingState | undefined): boolean;
@@ -19,6 +19,19 @@
19
19
  * reconnect is not a doorbell press.
20
20
  */
21
21
  export declare const SOURCE_CAP_ACTIVE_FIELD: Readonly<Record<string, string>>;
22
+ /**
23
+ * The same caps → the slice field carrying the ms-epoch timestamp of the
24
+ * last transition. Every source cap MUST appear here (guarded by a spec):
25
+ * without a transition timestamp the engine cannot tell a genuine press
26
+ * from a boot-time hydration when the FIRST slice it ever sees is already
27
+ * active, and errs towards silence — swallowing the press.
28
+ *
29
+ * These timestamps are UPSTREAM ones, not ingest ones: the Home Assistant
30
+ * provider derives them from `state.last_changed`, so they survive our own
31
+ * restarts and correctly read as "hours ago" for a state that has been
32
+ * active for hours. `motion` names its rise timestamp `lastDetectedAt`.
33
+ */
34
+ export declare const SOURCE_CAP_CHANGED_AT_FIELD: Readonly<Record<string, string>>;
22
35
  /** Cap names whose presence in a device's bindings qualify it as a source. */
23
36
  export declare const SOURCE_CAPS: readonly string[];
24
37
  /**
@@ -41,6 +54,45 @@ export declare const SOURCE_CAPS: readonly string[];
41
54
  export declare const SOURCE_DEVICE_TYPES: readonly string[];
42
55
  /** True when a cap is a recognised binary/switch doorbell source. */
43
56
  export declare function isSourceCap(capName: string): boolean;
57
+ /**
58
+ * Why a slice produced no ring. Every value names a DROPPED evaluation, so
59
+ * the addon can log the ones that may have cost a real press instead of
60
+ * returning an empty array that reads as "nothing happened".
61
+ */
62
+ export type DoorbellIgnoreReason =
63
+ /** Not a recognised binary/switch source cap. */
64
+ 'unknown-cap'
65
+ /** Recognised cap, but its active field is missing or not a boolean. */
66
+ | 'non-boolean-value'
67
+ /** First sighting, INACTIVE — nothing can have been lost. */
68
+ | 'baseline-seeded-inactive'
69
+ /** First sighting, ALREADY ACTIVE, and we could not prove it is a fresh
70
+ * transition. A genuine press MAY have been swallowed here. */
71
+ | 'baseline-seeded-stale-active'
72
+ /** Re-emission carrying the same binary value. */
73
+ | 'no-change'
74
+ /** The falling edge — switch-off / contact-close never rings. */
75
+ | 'falling-edge'
76
+ /** A real rise, but no camera is wired to this source. */
77
+ | 'no-assignment';
78
+ /**
79
+ * The outcome of feeding one slice. `fired` is what rings; `debounced` and
80
+ * `ignored` exist so that no suppression is invisible.
81
+ */
82
+ export interface DoorbellTriggerResult {
83
+ /** Cameras to ring now. */
84
+ readonly fired: readonly number[];
85
+ /** Cameras that WOULD have rung but are inside their debounce window. */
86
+ readonly debounced: readonly number[];
87
+ /** Set when the slice carried no ring at all; null once a rise was matched. */
88
+ readonly ignored: DoorbellIgnoreReason | null;
89
+ }
90
+ /**
91
+ * How recent a source's own transition timestamp must be for a FIRST
92
+ * sighting that is already active to count as a genuine press rather than a
93
+ * hydration of long-standing state.
94
+ */
95
+ export declare const DEFAULT_FIRST_SIGHTING_FRESHNESS_MS = 30000;
44
96
  /**
45
97
  * One camera's virtual-doorbell wiring: a source device that rings it. A
46
98
  * camera with several sources produces several assignments (one per source).
@@ -57,9 +109,25 @@ export declare const DEFAULT_DEBOUNCE_MS = 2000;
57
109
  *
58
110
  * Tracks the last observed binary value per (sourceDevice, cap) so a
59
111
  * `DeviceStateChanged` re-emission (the slice's `lastChangedAt` moves while
60
- * the binary value does not) never double-fires, and the FIRST observation
61
- * after boot only seeds the baseline — a state hydration at startup must not
62
- * ring anybody's doorbell.
112
+ * the binary value does not) never double-fires.
113
+ *
114
+ * FIRST SIGHTINGS. Seeding the baseline and never firing was wrong for the
115
+ * one case that matters: a momentary source (a button wired to a Home
116
+ * Assistant switch) is NOT re-pushed in its resting `off` state after a hub
117
+ * restart, so after every restart — several a day — the first slice the
118
+ * engine ever sees for it is the PRESS ITSELF, already active. That press
119
+ * was eaten whole; a real one on 2026-08-05 left no trace anywhere.
120
+ *
121
+ * The discriminator is the source's own transition timestamp. A first
122
+ * sighting that is already active rings only when its transition can be
123
+ * proved to have happened AFTER this engine started watching AND within
124
+ * `firstSightingFreshnessMs`. Both halves earn their keep: freshness alone
125
+ * would ring on a boot hydration of a state that flipped seconds before the
126
+ * restart, and "after we started" alone would ring, on a long-lived
127
+ * process, for a source adopted today whose state flipped yesterday. An
128
+ * undateable first sighting (no timestamp, or a zero sentinel) does NOT
129
+ * ring — the ambiguous case errs towards silence, but reports
130
+ * `baseline-seeded-stale-active` so the caller can say so out loud.
63
131
  */
64
132
  export declare class DoorbellTriggerEngine {
65
133
  private assignments;
@@ -69,9 +137,13 @@ export declare class DoorbellTriggerEngine {
69
137
  private readonly lastFiredAt;
70
138
  private readonly debounceMs;
71
139
  private readonly now;
140
+ private readonly firstSightingFreshnessMs;
141
+ /** When this engine started watching — the floor for a first-sighting rise. */
142
+ private readonly startedAt;
72
143
  constructor(options?: {
73
144
  readonly debounceMs?: number;
74
145
  readonly now?: () => number;
146
+ readonly firstSightingFreshnessMs?: number;
75
147
  });
76
148
  /** Replace the full assignment set (rebuilt whenever settings change). */
77
149
  setAssignments(assignments: readonly DoorbellAssignment[]): void;
@@ -79,11 +151,16 @@ export declare class DoorbellTriggerEngine {
79
151
  /** True when any assignment references `deviceId` as its source. */
80
152
  hasSource(deviceId: number): boolean;
81
153
  /**
82
- * Feed one `DeviceStateChanged` slice. Returns the camera ids whose
83
- * doorbell should ring (already debounced). Only a false→true rise on a
84
- * recognised source cap fires; baseline-only sightings, re-emissions, and
85
- * the falling edge return an empty array.
154
+ * Feed one `DeviceStateChanged` slice. Reports which cameras should ring,
155
+ * which were suppressed by the debounce window, and when nothing was
156
+ * matched at all WHY. Only a rise to active on a recognised source cap
157
+ * can ring; re-emissions and the falling edge never do.
158
+ */
159
+ onStateSlice(deviceId: number, capName: string, slice: Record<string, unknown>): DoorbellTriggerResult;
160
+ /**
161
+ * Can this slice PROVE its active state is a transition we would have
162
+ * witnessed had we been listening — i.e. recent, and after we started?
86
163
  */
87
- onStateSlice(deviceId: number, capName: string, slice: Record<string, unknown>): number[];
164
+ private roseAfterWeStartedWatching;
88
165
  private matchAndDebounce;
89
166
  }
@@ -2,6 +2,13 @@ import { ProviderRegistration, BaseAddon } from '@camstack/types';
2
2
  interface VirtualDoorbellAddonConfig {
3
3
  /** Per-camera debounce window (ms) against sensor chatter. */
4
4
  readonly debounceMs: number;
5
+ /**
6
+ * Reload the camera→source assignment map (and the binding mirror) at
7
+ * most this often — lazily, on trigger traffic. Covers devices/settings
8
+ * appearing after boot without a settings save, and is the cadence on
9
+ * which an unbind gets corroborated.
10
+ */
11
+ readonly assignmentsTtlMs: number;
5
12
  }
6
13
  /**
7
14
  * VirtualDoorbellAddon — WRAPPER over the `doorbell` capability.
@@ -27,28 +34,63 @@ interface VirtualDoorbellAddonConfig {
27
34
  * bumps the camera's `doorbell` runtime-state counters via the
28
35
  * standard `deviceState.setCapSlice` plumbing.
29
36
  *
30
- * Fires ONLY for cameras where the wrapper is BOUND (checked against
31
- * `deviceManager.getBindings`) and at least one source device is configured.
37
+ * Fires ONLY for cameras with at least one configured source device that
38
+ * this wrapper is BOUND to. The binding is NOT re-derived on the ring path:
39
+ * it is mirrored in memory (`binding-mirror.ts`) and refreshed on the
40
+ * assignment-reload cadence, because a fallible read must never be able to
41
+ * destroy a one-shot press.
32
42
  */
33
43
  export declare class VirtualDoorbellAddon extends BaseAddon<VirtualDoorbellAddonConfig> {
34
- private readonly engine;
44
+ private engine;
35
45
  /** Monotonic guard: assignments are reloaded lazily on trigger traffic. */
36
46
  private assignmentsLoadedAt;
37
47
  private assignmentsLoading;
48
+ /**
49
+ * Per-camera mirror of "is this wrapper still the active doorbell
50
+ * binding". Written ONLY off the ring path; read on it. See
51
+ * `binding-mirror.ts` for why a single negative read is not believed.
52
+ */
53
+ private readonly bindingMirror;
38
54
  constructor();
39
55
  protected onInitialize(): Promise<ProviderRegistration[]>;
40
56
  private handleStateChanged;
41
57
  /**
42
- * Ring the camera's virtual doorbell: verify the wrapper is BOUND to
43
- * the camera, bump the `doorbell` runtime-state counters through the
44
- * canonical `deviceState.setCapSlice` write, and emit the typed
45
- * `DoorbellOnPressed` event with the CAMERA as source — byte-shaped
46
- * like a native firmware ring so every downstream consumer (UI
47
- * toast, notifier rules, exporters) is agnostic to the origin.
58
+ * Say out loud every case where a rise did NOT become a ring. Silence
59
+ * reads as "never happened": three real presses on camera 615 produced
60
+ * zero operator-visible lines because the drop logged at `debug`.
61
+ */
62
+ private logSuppressions;
63
+ /**
64
+ * Ring the camera's virtual doorbell: bump the `doorbell` runtime-state
65
+ * counters through the canonical `deviceState.setCapSlice` write, and
66
+ * emit the typed `DoorbellOnPressed` event with the CAMERA as source —
67
+ * byte-shaped like a native firmware ring so every downstream consumer
68
+ * (UI toast, notifier rules, exporters) is agnostic to the origin.
69
+ *
70
+ * The binding gate here is a pure in-memory read. It performs NO I/O and
71
+ * cannot fail: the whole point of the mirror is that the ring path can no
72
+ * longer be talked out of ringing by a store that answered badly.
48
73
  */
49
74
  private fireDoorbell;
50
- /** True when THIS wrapper is the active `doorbell` binding for the camera. */
51
- private isWrapperBound;
75
+ /**
76
+ * Fold one bindings read for `cameraId` into the mirror. Runs on the
77
+ * assignment-reload cadence, never while firing.
78
+ *
79
+ * A read that THROWS carries no information and leaves the mirror exactly
80
+ * as it was — the previous verdict keeps standing. A read that succeeds
81
+ * but omits our activation only ARMS a revocation; `nextBindingState`
82
+ * requires a second consecutive negative before the camera goes silent,
83
+ * because a genuine unbind and a store answering from an empty snapshot
84
+ * (D44) are indistinguishable in a single response.
85
+ */
86
+ private refreshBindingMirror;
87
+ /**
88
+ * Record a binding we observed FIRST-HAND. Both callers are cap methods
89
+ * the router only reaches by resolving this wrapper for this device —
90
+ * which it can only do through the binding — so a call is direct evidence
91
+ * and outranks anything the store says.
92
+ */
93
+ private confirmBindingFromCapCall;
52
94
  /**
53
95
  * Lazily (re)build the camera→source assignment set from every
54
96
  * camera's device store. TTL-bounded so devices/settings appearing
@@ -37,6 +37,30 @@ var SOURCE_CAP_ACTIVE_FIELD = {
37
37
  vibration: "detected",
38
38
  tamper: "tampered"
39
39
  };
40
+ /**
41
+ * The same caps → the slice field carrying the ms-epoch timestamp of the
42
+ * last transition. Every source cap MUST appear here (guarded by a spec):
43
+ * without a transition timestamp the engine cannot tell a genuine press
44
+ * from a boot-time hydration when the FIRST slice it ever sees is already
45
+ * active, and errs towards silence — swallowing the press.
46
+ *
47
+ * These timestamps are UPSTREAM ones, not ingest ones: the Home Assistant
48
+ * provider derives them from `state.last_changed`, so they survive our own
49
+ * restarts and correctly read as "hours ago" for a state that has been
50
+ * active for hours. `motion` names its rise timestamp `lastDetectedAt`.
51
+ */
52
+ var SOURCE_CAP_CHANGED_AT_FIELD = {
53
+ contact: "lastChangedAt",
54
+ binary: "lastChangedAt",
55
+ switch: "lastChangedAt",
56
+ motion: "lastDetectedAt",
57
+ flood: "lastChangedAt",
58
+ gas: "lastChangedAt",
59
+ smoke: "lastChangedAt",
60
+ "carbon-monoxide": "lastChangedAt",
61
+ vibration: "lastChangedAt",
62
+ tamper: "lastChangedAt"
63
+ };
40
64
  /** Cap names whose presence in a device's bindings qualify it as a source. */
41
65
  var SOURCE_CAPS = Object.keys(SOURCE_CAP_ACTIVE_FIELD);
42
66
  /**
@@ -73,14 +97,46 @@ function sliceActiveValue(capName, slice) {
73
97
  const raw = slice[field];
74
98
  return typeof raw === "boolean" ? raw : null;
75
99
  }
100
+ /** Ms-epoch transition timestamp a source cap's slice carries, or null when
101
+ * it is absent, non-numeric or the zero "never observed" sentinel. */
102
+ function sliceChangedAt(capName, slice) {
103
+ const field = SOURCE_CAP_CHANGED_AT_FIELD[capName];
104
+ if (field === void 0) return null;
105
+ const raw = slice[field];
106
+ if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0) return null;
107
+ return raw;
108
+ }
109
+ function ignoredResult(reason) {
110
+ return {
111
+ fired: [],
112
+ debounced: [],
113
+ ignored: reason
114
+ };
115
+ }
76
116
  /**
77
117
  * Stateful (but side-effect-free towards the outside) trigger engine.
78
118
  *
79
119
  * Tracks the last observed binary value per (sourceDevice, cap) so a
80
120
  * `DeviceStateChanged` re-emission (the slice's `lastChangedAt` moves while
81
- * the binary value does not) never double-fires, and the FIRST observation
82
- * after boot only seeds the baseline — a state hydration at startup must not
83
- * ring anybody's doorbell.
121
+ * the binary value does not) never double-fires.
122
+ *
123
+ * FIRST SIGHTINGS. Seeding the baseline and never firing was wrong for the
124
+ * one case that matters: a momentary source (a button wired to a Home
125
+ * Assistant switch) is NOT re-pushed in its resting `off` state after a hub
126
+ * restart, so after every restart — several a day — the first slice the
127
+ * engine ever sees for it is the PRESS ITSELF, already active. That press
128
+ * was eaten whole; a real one on 2026-08-05 left no trace anywhere.
129
+ *
130
+ * The discriminator is the source's own transition timestamp. A first
131
+ * sighting that is already active rings only when its transition can be
132
+ * proved to have happened AFTER this engine started watching AND within
133
+ * `firstSightingFreshnessMs`. Both halves earn their keep: freshness alone
134
+ * would ring on a boot hydration of a state that flipped seconds before the
135
+ * restart, and "after we started" alone would ring, on a long-lived
136
+ * process, for a source adopted today whose state flipped yesterday. An
137
+ * undateable first sighting (no timestamp, or a zero sentinel) does NOT
138
+ * ring — the ambiguous case errs towards silence, but reports
139
+ * `baseline-seeded-stale-active` so the caller can say so out loud.
84
140
  */
85
141
  var DoorbellTriggerEngine = class {
86
142
  assignments = [];
@@ -90,9 +146,14 @@ var DoorbellTriggerEngine = class {
90
146
  lastFiredAt = /* @__PURE__ */ new Map();
91
147
  debounceMs;
92
148
  now;
149
+ firstSightingFreshnessMs;
150
+ /** When this engine started watching — the floor for a first-sighting rise. */
151
+ startedAt;
93
152
  constructor(options) {
94
153
  this.debounceMs = options?.debounceMs ?? 2e3;
95
154
  this.now = options?.now ?? Date.now;
155
+ this.firstSightingFreshnessMs = options?.firstSightingFreshnessMs ?? 3e4;
156
+ this.startedAt = this.now();
96
157
  }
97
158
  /** Replace the full assignment set (rebuilt whenever settings change). */
98
159
  setAssignments(assignments) {
@@ -106,35 +167,77 @@ var DoorbellTriggerEngine = class {
106
167
  return this.assignments.some((a) => a.sourceDeviceId === deviceId);
107
168
  }
108
169
  /**
109
- * Feed one `DeviceStateChanged` slice. Returns the camera ids whose
110
- * doorbell should ring (already debounced). Only a false→true rise on a
111
- * recognised source cap fires; baseline-only sightings, re-emissions, and
112
- * the falling edge return an empty array.
170
+ * Feed one `DeviceStateChanged` slice. Reports which cameras should ring,
171
+ * which were suppressed by the debounce window, and when nothing was
172
+ * matched at all WHY. Only a rise to active on a recognised source cap
173
+ * can ring; re-emissions and the falling edge never do.
113
174
  */
114
175
  onStateSlice(deviceId, capName, slice) {
115
176
  const value = sliceActiveValue(capName, slice);
116
- if (value === null) return [];
177
+ if (value === null) return ignoredResult(isSourceCap(capName) ? "non-boolean-value" : "unknown-cap");
117
178
  const key = `${deviceId}:${capName}`;
118
179
  const prior = this.lastValues.get(key);
119
180
  this.lastValues.set(key, value);
120
- if (prior === void 0 || prior === value) return [];
121
- if (!value) return [];
122
- return this.matchAndDebounce((a) => a.sourceDeviceId === deviceId);
181
+ if (prior === void 0) {
182
+ if (!value) return ignoredResult("baseline-seeded-inactive");
183
+ if (!this.roseAfterWeStartedWatching(capName, slice)) return ignoredResult("baseline-seeded-stale-active");
184
+ return this.matchAndDebounce(deviceId);
185
+ }
186
+ if (prior === value) return ignoredResult("no-change");
187
+ if (!value) return ignoredResult("falling-edge");
188
+ return this.matchAndDebounce(deviceId);
123
189
  }
124
- matchAndDebounce(predicate) {
190
+ /**
191
+ * Can this slice PROVE its active state is a transition we would have
192
+ * witnessed had we been listening — i.e. recent, and after we started?
193
+ */
194
+ roseAfterWeStartedWatching(capName, slice) {
195
+ const changedAt = sliceChangedAt(capName, slice);
196
+ if (changedAt === null) return false;
197
+ return changedAt >= Math.max(this.startedAt, this.now() - this.firstSightingFreshnessMs);
198
+ }
199
+ matchAndDebounce(sourceDeviceId) {
125
200
  const now = this.now();
126
201
  const fired = [];
202
+ const debounced = [];
127
203
  for (const assignment of this.assignments) {
128
- if (!predicate(assignment)) continue;
204
+ if (assignment.sourceDeviceId !== sourceDeviceId) continue;
129
205
  const last = this.lastFiredAt.get(assignment.cameraId);
130
- if (last !== void 0 && now - last < this.debounceMs) continue;
206
+ if (last !== void 0 && now - last < this.debounceMs) {
207
+ debounced.push(assignment.cameraId);
208
+ continue;
209
+ }
131
210
  this.lastFiredAt.set(assignment.cameraId, now);
132
211
  fired.push(assignment.cameraId);
133
212
  }
134
- return fired;
213
+ if (fired.length === 0 && debounced.length === 0) return ignoredResult("no-assignment");
214
+ return {
215
+ fired,
216
+ debounced,
217
+ ignored: null
218
+ };
135
219
  }
136
220
  };
137
221
  //#endregion
222
+ //#region src/builtins/doorbell/binding-mirror.ts
223
+ /**
224
+ * Fold one SUCCESSFUL bindings read into the mirror. A read that failed must
225
+ * never reach this function — an unanswerable store carries no information
226
+ * and must leave the mirror exactly as it was.
227
+ */
228
+ function nextBindingState(previous, boundNow) {
229
+ if (boundNow) return "bound";
230
+ return previous === "unbind-pending" || previous === "unbound" ? "unbound" : "unbind-pending";
231
+ }
232
+ /** May a ring proceed? Only a corroborated unbind stops one. */
233
+ function allowsRing(state) {
234
+ return state !== "unbound";
235
+ }
236
+ /** True only when a read has positively confirmed the binding. */
237
+ function hasConfirmedBinding(state) {
238
+ return state === "bound";
239
+ }
240
+ //#endregion
138
241
  //#region src/builtins/doorbell/doorbell-settings.ts
139
242
  /**
140
243
  * Pure per-camera settings parsing/normalization for the virtual-doorbell
@@ -227,10 +330,6 @@ function applyDoorbellSourcesPatch(current, patchValue) {
227
330
  }
228
331
  //#endregion
229
332
  //#region src/builtins/doorbell/virtual-doorbell.addon.ts
230
- /** Reload the camera→source assignment map at most this often (lazily,
231
- * on trigger traffic). Covers devices/settings appearing after boot
232
- * without a settings save. */
233
- var ASSIGNMENTS_TTL_MS = 6e4;
234
333
  /** Structural narrowing helper for untrusted bus payloads. */
235
334
  function isRecord(value) {
236
335
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -286,19 +385,32 @@ function parseDoorbellSlice(slice) {
286
385
  * bumps the camera's `doorbell` runtime-state counters via the
287
386
  * standard `deviceState.setCapSlice` plumbing.
288
387
  *
289
- * Fires ONLY for cameras where the wrapper is BOUND (checked against
290
- * `deviceManager.getBindings`) and at least one source device is configured.
388
+ * Fires ONLY for cameras with at least one configured source device that
389
+ * this wrapper is BOUND to. The binding is NOT re-derived on the ring path:
390
+ * it is mirrored in memory (`binding-mirror.ts`) and refreshed on the
391
+ * assignment-reload cadence, because a fallible read must never be able to
392
+ * destroy a one-shot press.
291
393
  */
292
394
  var VirtualDoorbellAddon = class extends require_dist.BaseAddon {
293
395
  engine;
294
396
  /** Monotonic guard: assignments are reloaded lazily on trigger traffic. */
295
397
  assignmentsLoadedAt = 0;
296
398
  assignmentsLoading = null;
399
+ /**
400
+ * Per-camera mirror of "is this wrapper still the active doorbell
401
+ * binding". Written ONLY off the ring path; read on it. See
402
+ * `binding-mirror.ts` for why a single negative read is not believed.
403
+ */
404
+ bindingMirror = /* @__PURE__ */ new Map();
297
405
  constructor() {
298
- super({ debounceMs: 2e3 });
406
+ super({
407
+ debounceMs: 2e3,
408
+ assignmentsTtlMs: 6e4
409
+ });
299
410
  this.engine = new DoorbellTriggerEngine({ debounceMs: 2e3 });
300
411
  }
301
412
  async onInitialize() {
413
+ this.engine = new DoorbellTriggerEngine({ debounceMs: this.config.debounceMs });
302
414
  this.ctx.logger.info("Virtual doorbell wrapper initialized");
303
415
  this.subscribe({ category: require_dist.EventCategory.DeviceStateChanged }, (event) => {
304
416
  const data = parseDeviceStateChanged(event.data);
@@ -318,22 +430,62 @@ var VirtualDoorbellAddon = class extends require_dist.BaseAddon {
318
430
  async handleStateChanged(data) {
319
431
  if (!isSourceCap(data.capName)) return;
320
432
  await this.ensureAssignments();
321
- const cameraIds = this.engine.onStateSlice(data.deviceId, data.capName, data.slice);
322
- for (const cameraId of cameraIds) await this.fireDoorbell(cameraId, Date.now());
433
+ const result = this.engine.onStateSlice(data.deviceId, data.capName, data.slice);
434
+ this.logSuppressions(data, result);
435
+ for (const cameraId of result.fired) await this.fireDoorbell(cameraId, Date.now());
436
+ }
437
+ /**
438
+ * Say out loud every case where a rise did NOT become a ring. Silence
439
+ * reads as "never happened": three real presses on camera 615 produced
440
+ * zero operator-visible lines because the drop logged at `debug`.
441
+ */
442
+ logSuppressions(data, result) {
443
+ for (const cameraId of result.debounced) this.ctx.logger.info("virtual-doorbell: rise suppressed by the debounce window", {
444
+ tags: { deviceId: cameraId },
445
+ meta: {
446
+ sourceDeviceId: data.deviceId,
447
+ capName: data.capName
448
+ }
449
+ });
450
+ if (result.ignored === "baseline-seeded-stale-active") {
451
+ 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";
452
+ const options = {
453
+ tags: { deviceId: data.deviceId },
454
+ meta: { capName: data.capName }
455
+ };
456
+ if (this.engine.hasSource(data.deviceId)) this.ctx.logger.warn(message, options);
457
+ else this.ctx.logger.debug(message, options);
458
+ return;
459
+ }
460
+ if (result.ignored === "no-assignment") this.ctx.logger.debug("virtual-doorbell: rise on a source no camera is wired to", {
461
+ tags: { deviceId: data.deviceId },
462
+ meta: { capName: data.capName }
463
+ });
323
464
  }
324
465
  /**
325
- * Ring the camera's virtual doorbell: verify the wrapper is BOUND to
326
- * the camera, bump the `doorbell` runtime-state counters through the
327
- * canonical `deviceState.setCapSlice` write, and emit the typed
328
- * `DoorbellOnPressed` event with the CAMERA as source byte-shaped
329
- * like a native firmware ring so every downstream consumer (UI
330
- * toast, notifier rules, exporters) is agnostic to the origin.
466
+ * Ring the camera's virtual doorbell: bump the `doorbell` runtime-state
467
+ * counters through the canonical `deviceState.setCapSlice` write, and
468
+ * emit the typed `DoorbellOnPressed` event with the CAMERA as source —
469
+ * byte-shaped like a native firmware ring so every downstream consumer
470
+ * (UI toast, notifier rules, exporters) is agnostic to the origin.
471
+ *
472
+ * The binding gate here is a pure in-memory read. It performs NO I/O and
473
+ * cannot fail: the whole point of the mirror is that the ring path can no
474
+ * longer be talked out of ringing by a store that answered badly.
331
475
  */
332
476
  async fireDoorbell(cameraId, timestamp) {
333
- if (!await this.isWrapperBound(cameraId)) {
334
- this.ctx.logger.debug("virtual-doorbell: trigger matched but wrapper not bound — skipping", { tags: { deviceId: cameraId } });
477
+ const binding = this.bindingMirror.get(cameraId);
478
+ if (!allowsRing(binding)) {
479
+ this.ctx.logger.warn("virtual-doorbell: ring DROPPED — the wrapper is not bound to this camera, but a trigger source is still configured for it", {
480
+ tags: { deviceId: cameraId },
481
+ meta: { bindingState: binding }
482
+ });
335
483
  return;
336
484
  }
485
+ if (!hasConfirmedBinding(binding)) this.ctx.logger.warn("virtual-doorbell: ringing on an unconfirmed binding — the bindings store has not corroborated an unbind, and a one-shot press is never dropped on an unproven read", {
486
+ tags: { deviceId: cameraId },
487
+ meta: { bindingState: binding ?? "never-read" }
488
+ });
337
489
  try {
338
490
  const next = {
339
491
  lastPressedAt: timestamp,
@@ -367,17 +519,51 @@ var VirtualDoorbellAddon = class extends require_dist.BaseAddon {
367
519
  meta: { timestamp }
368
520
  });
369
521
  }
370
- /** True when THIS wrapper is the active `doorbell` binding for the camera. */
371
- async isWrapperBound(cameraId) {
522
+ /**
523
+ * Fold one bindings read for `cameraId` into the mirror. Runs on the
524
+ * assignment-reload cadence, never while firing.
525
+ *
526
+ * A read that THROWS carries no information and leaves the mirror exactly
527
+ * as it was — the previous verdict keeps standing. A read that succeeds
528
+ * but omits our activation only ARMS a revocation; `nextBindingState`
529
+ * requires a second consecutive negative before the camera goes silent,
530
+ * because a genuine unbind and a store answering from an empty snapshot
531
+ * (D44) are indistinguishable in a single response.
532
+ */
533
+ async refreshBindingMirror(cameraId) {
534
+ let entries;
372
535
  try {
373
- return (await this.ctx.api.deviceManager.getBindings.query({ deviceId: cameraId })).entries.some((entry) => entry.capName === require_dist.doorbellCapability.name && entry.kind === "wrapped" && entry.providerAddonId === this.ctx.id);
536
+ entries = (await this.ctx.api.deviceManager.getBindings.query({ deviceId: cameraId })).entries;
374
537
  } catch (err) {
375
- this.ctx.logger.debug("virtual-doorbell: getBindings failed — treating as unbound", {
538
+ this.ctx.logger.warn("virtual-doorbell: binding check failed — keeping the last known verdict, NOT treating this as an unbind", {
376
539
  tags: { deviceId: cameraId },
377
- meta: { error: require_dist.errMsg(err) }
540
+ meta: {
541
+ error: require_dist.errMsg(err),
542
+ bindingState: this.bindingMirror.get(cameraId)
543
+ }
378
544
  });
379
- return false;
545
+ return;
380
546
  }
547
+ const boundNow = entries.some((entry) => entry.capName === require_dist.doorbellCapability.name && entry.kind === "wrapped" && entry.providerAddonId === this.ctx.id);
548
+ const previous = this.bindingMirror.get(cameraId);
549
+ const next = nextBindingState(previous, boundNow);
550
+ this.bindingMirror.set(cameraId, next);
551
+ if (next !== previous && next !== "bound") this.ctx.logger.warn("virtual-doorbell: bindings read did not see this wrapper", {
552
+ tags: { deviceId: cameraId },
553
+ meta: {
554
+ from: previous ?? "never-read",
555
+ to: next
556
+ }
557
+ });
558
+ }
559
+ /**
560
+ * Record a binding we observed FIRST-HAND. Both callers are cap methods
561
+ * the router only reaches by resolving this wrapper for this device —
562
+ * which it can only do through the binding — so a call is direct evidence
563
+ * and outranks anything the store says.
564
+ */
565
+ confirmBindingFromCapCall(deviceId) {
566
+ this.bindingMirror.set(deviceId, nextBindingState(this.bindingMirror.get(deviceId), true));
381
567
  }
382
568
  /**
383
569
  * Lazily (re)build the camera→source assignment set from every
@@ -386,7 +572,8 @@ var VirtualDoorbellAddon = class extends require_dist.BaseAddon {
386
572
  * eagerly by `applyDeviceSettingsPatch`.
387
573
  */
388
574
  async ensureAssignments() {
389
- if (Date.now() - this.assignmentsLoadedAt < ASSIGNMENTS_TTL_MS) return;
575
+ const now = Date.now();
576
+ if (this.assignmentsLoadedAt > 0 && now - this.assignmentsLoadedAt < this.config.assignmentsTtlMs) return;
390
577
  if (this.assignmentsLoading) return this.assignmentsLoading;
391
578
  this.assignmentsLoading = this.loadAssignments().then(() => {
392
579
  this.assignmentsLoadedAt = Date.now();
@@ -398,17 +585,29 @@ var VirtualDoorbellAddon = class extends require_dist.BaseAddon {
398
585
  return this.assignmentsLoading;
399
586
  }
400
587
  async loadAssignments() {
588
+ if (!this.ctx.settings) {
589
+ this.ctx.logger.warn("virtual-doorbell: no settings store — every camera reads ZERO trigger sources, no doorbell can ring");
590
+ return;
591
+ }
401
592
  const cameras = (await this.ctx.api.deviceManager.listAll.query({})).filter((d) => d.type === require_dist.DeviceType.Camera);
402
593
  const assignments = [];
594
+ const configuredCameras = [];
403
595
  for (const camera of cameras) {
404
596
  const sources = await this.readDeviceSources(camera.id);
597
+ if (sources.length > 0) configuredCameras.push(camera.id);
405
598
  for (const source of sources) assignments.push({
406
599
  cameraId: camera.id,
407
600
  sourceDeviceId: source.deviceId
408
601
  });
409
602
  }
603
+ for (const cameraId of configuredCameras) await this.refreshBindingMirror(cameraId);
604
+ const hadAssignments = this.engine.getAssignments().length > 0;
410
605
  this.engine.setAssignments(assignments);
411
- this.ctx.logger.debug("virtual-doorbell: assignments loaded", { meta: { count: assignments.length } });
606
+ 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");
607
+ this.ctx.logger.debug("virtual-doorbell: assignments loaded", { meta: {
608
+ count: assignments.length,
609
+ configuredCameras: configuredCameras.length
610
+ } });
412
611
  }
413
612
  invalidateAssignments() {
414
613
  this.assignmentsLoadedAt = 0;
@@ -425,7 +624,7 @@ var VirtualDoorbellAddon = class extends require_dist.BaseAddon {
425
624
  capName: require_dist.doorbellCapability.name
426
625
  }));
427
626
  } catch (err) {
428
- this.ctx.logger.debug("virtual-doorbell: getCapSlice failed during getStatus", {
627
+ this.ctx.logger.warn("virtual-doorbell: getCapSlice failed during getStatus", {
429
628
  tags: { deviceId },
430
629
  meta: { error: require_dist.errMsg(err) }
431
630
  });
@@ -477,6 +676,7 @@ var VirtualDoorbellAddon = class extends require_dist.BaseAddon {
477
676
  async buildDeviceSettingsContribution(deviceId) {
478
677
  const device = await this.lookupDevice(deviceId);
479
678
  if (device && device.type !== require_dist.DeviceType.Camera) return null;
679
+ this.confirmBindingFromCapCall(deviceId);
480
680
  const sources = await this.readDeviceSources(deviceId);
481
681
  return { sections: [{
482
682
  id: "virtual-doorbell",
@@ -499,6 +699,7 @@ var VirtualDoorbellAddon = class extends require_dist.BaseAddon {
499
699
  }
500
700
  async saveDeviceSettingsPatch(deviceId, patch) {
501
701
  if (!this.ctx.settings) throw new Error("[virtual-doorbell] settings store unavailable — cannot persist per-device settings");
702
+ this.confirmBindingFromCapCall(deviceId);
502
703
  if ("doorbellSources" in patch) {
503
704
  const next = applyDoorbellSourcesPatch(await this.ctx.settings.readDeviceStore(deviceId), patch[KEY_SOURCES]);
504
705
  await this.ctx.settings.writeDeviceStore(deviceId, next);
@@ -32,6 +32,30 @@ var SOURCE_CAP_ACTIVE_FIELD = {
32
32
  vibration: "detected",
33
33
  tamper: "tampered"
34
34
  };
35
+ /**
36
+ * The same caps → the slice field carrying the ms-epoch timestamp of the
37
+ * last transition. Every source cap MUST appear here (guarded by a spec):
38
+ * without a transition timestamp the engine cannot tell a genuine press
39
+ * from a boot-time hydration when the FIRST slice it ever sees is already
40
+ * active, and errs towards silence — swallowing the press.
41
+ *
42
+ * These timestamps are UPSTREAM ones, not ingest ones: the Home Assistant
43
+ * provider derives them from `state.last_changed`, so they survive our own
44
+ * restarts and correctly read as "hours ago" for a state that has been
45
+ * active for hours. `motion` names its rise timestamp `lastDetectedAt`.
46
+ */
47
+ var SOURCE_CAP_CHANGED_AT_FIELD = {
48
+ contact: "lastChangedAt",
49
+ binary: "lastChangedAt",
50
+ switch: "lastChangedAt",
51
+ motion: "lastDetectedAt",
52
+ flood: "lastChangedAt",
53
+ gas: "lastChangedAt",
54
+ smoke: "lastChangedAt",
55
+ "carbon-monoxide": "lastChangedAt",
56
+ vibration: "lastChangedAt",
57
+ tamper: "lastChangedAt"
58
+ };
35
59
  /** Cap names whose presence in a device's bindings qualify it as a source. */
36
60
  var SOURCE_CAPS = Object.keys(SOURCE_CAP_ACTIVE_FIELD);
37
61
  /**
@@ -68,14 +92,46 @@ function sliceActiveValue(capName, slice) {
68
92
  const raw = slice[field];
69
93
  return typeof raw === "boolean" ? raw : null;
70
94
  }
95
+ /** Ms-epoch transition timestamp a source cap's slice carries, or null when
96
+ * it is absent, non-numeric or the zero "never observed" sentinel. */
97
+ function sliceChangedAt(capName, slice) {
98
+ const field = SOURCE_CAP_CHANGED_AT_FIELD[capName];
99
+ if (field === void 0) return null;
100
+ const raw = slice[field];
101
+ if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0) return null;
102
+ return raw;
103
+ }
104
+ function ignoredResult(reason) {
105
+ return {
106
+ fired: [],
107
+ debounced: [],
108
+ ignored: reason
109
+ };
110
+ }
71
111
  /**
72
112
  * Stateful (but side-effect-free towards the outside) trigger engine.
73
113
  *
74
114
  * Tracks the last observed binary value per (sourceDevice, cap) so a
75
115
  * `DeviceStateChanged` re-emission (the slice's `lastChangedAt` moves while
76
- * the binary value does not) never double-fires, and the FIRST observation
77
- * after boot only seeds the baseline — a state hydration at startup must not
78
- * ring anybody's doorbell.
116
+ * the binary value does not) never double-fires.
117
+ *
118
+ * FIRST SIGHTINGS. Seeding the baseline and never firing was wrong for the
119
+ * one case that matters: a momentary source (a button wired to a Home
120
+ * Assistant switch) is NOT re-pushed in its resting `off` state after a hub
121
+ * restart, so after every restart — several a day — the first slice the
122
+ * engine ever sees for it is the PRESS ITSELF, already active. That press
123
+ * was eaten whole; a real one on 2026-08-05 left no trace anywhere.
124
+ *
125
+ * The discriminator is the source's own transition timestamp. A first
126
+ * sighting that is already active rings only when its transition can be
127
+ * proved to have happened AFTER this engine started watching AND within
128
+ * `firstSightingFreshnessMs`. Both halves earn their keep: freshness alone
129
+ * would ring on a boot hydration of a state that flipped seconds before the
130
+ * restart, and "after we started" alone would ring, on a long-lived
131
+ * process, for a source adopted today whose state flipped yesterday. An
132
+ * undateable first sighting (no timestamp, or a zero sentinel) does NOT
133
+ * ring — the ambiguous case errs towards silence, but reports
134
+ * `baseline-seeded-stale-active` so the caller can say so out loud.
79
135
  */
80
136
  var DoorbellTriggerEngine = class {
81
137
  assignments = [];
@@ -85,9 +141,14 @@ var DoorbellTriggerEngine = class {
85
141
  lastFiredAt = /* @__PURE__ */ new Map();
86
142
  debounceMs;
87
143
  now;
144
+ firstSightingFreshnessMs;
145
+ /** When this engine started watching — the floor for a first-sighting rise. */
146
+ startedAt;
88
147
  constructor(options) {
89
148
  this.debounceMs = options?.debounceMs ?? 2e3;
90
149
  this.now = options?.now ?? Date.now;
150
+ this.firstSightingFreshnessMs = options?.firstSightingFreshnessMs ?? 3e4;
151
+ this.startedAt = this.now();
91
152
  }
92
153
  /** Replace the full assignment set (rebuilt whenever settings change). */
93
154
  setAssignments(assignments) {
@@ -101,35 +162,77 @@ var DoorbellTriggerEngine = class {
101
162
  return this.assignments.some((a) => a.sourceDeviceId === deviceId);
102
163
  }
103
164
  /**
104
- * Feed one `DeviceStateChanged` slice. Returns the camera ids whose
105
- * doorbell should ring (already debounced). Only a false→true rise on a
106
- * recognised source cap fires; baseline-only sightings, re-emissions, and
107
- * the falling edge return an empty array.
165
+ * Feed one `DeviceStateChanged` slice. Reports which cameras should ring,
166
+ * which were suppressed by the debounce window, and when nothing was
167
+ * matched at all WHY. Only a rise to active on a recognised source cap
168
+ * can ring; re-emissions and the falling edge never do.
108
169
  */
109
170
  onStateSlice(deviceId, capName, slice) {
110
171
  const value = sliceActiveValue(capName, slice);
111
- if (value === null) return [];
172
+ if (value === null) return ignoredResult(isSourceCap(capName) ? "non-boolean-value" : "unknown-cap");
112
173
  const key = `${deviceId}:${capName}`;
113
174
  const prior = this.lastValues.get(key);
114
175
  this.lastValues.set(key, value);
115
- if (prior === void 0 || prior === value) return [];
116
- if (!value) return [];
117
- return this.matchAndDebounce((a) => a.sourceDeviceId === deviceId);
176
+ if (prior === void 0) {
177
+ if (!value) return ignoredResult("baseline-seeded-inactive");
178
+ if (!this.roseAfterWeStartedWatching(capName, slice)) return ignoredResult("baseline-seeded-stale-active");
179
+ return this.matchAndDebounce(deviceId);
180
+ }
181
+ if (prior === value) return ignoredResult("no-change");
182
+ if (!value) return ignoredResult("falling-edge");
183
+ return this.matchAndDebounce(deviceId);
118
184
  }
119
- matchAndDebounce(predicate) {
185
+ /**
186
+ * Can this slice PROVE its active state is a transition we would have
187
+ * witnessed had we been listening — i.e. recent, and after we started?
188
+ */
189
+ roseAfterWeStartedWatching(capName, slice) {
190
+ const changedAt = sliceChangedAt(capName, slice);
191
+ if (changedAt === null) return false;
192
+ return changedAt >= Math.max(this.startedAt, this.now() - this.firstSightingFreshnessMs);
193
+ }
194
+ matchAndDebounce(sourceDeviceId) {
120
195
  const now = this.now();
121
196
  const fired = [];
197
+ const debounced = [];
122
198
  for (const assignment of this.assignments) {
123
- if (!predicate(assignment)) continue;
199
+ if (assignment.sourceDeviceId !== sourceDeviceId) continue;
124
200
  const last = this.lastFiredAt.get(assignment.cameraId);
125
- if (last !== void 0 && now - last < this.debounceMs) continue;
201
+ if (last !== void 0 && now - last < this.debounceMs) {
202
+ debounced.push(assignment.cameraId);
203
+ continue;
204
+ }
126
205
  this.lastFiredAt.set(assignment.cameraId, now);
127
206
  fired.push(assignment.cameraId);
128
207
  }
129
- return fired;
208
+ if (fired.length === 0 && debounced.length === 0) return ignoredResult("no-assignment");
209
+ return {
210
+ fired,
211
+ debounced,
212
+ ignored: null
213
+ };
130
214
  }
131
215
  };
132
216
  //#endregion
217
+ //#region src/builtins/doorbell/binding-mirror.ts
218
+ /**
219
+ * Fold one SUCCESSFUL bindings read into the mirror. A read that failed must
220
+ * never reach this function — an unanswerable store carries no information
221
+ * and must leave the mirror exactly as it was.
222
+ */
223
+ function nextBindingState(previous, boundNow) {
224
+ if (boundNow) return "bound";
225
+ return previous === "unbind-pending" || previous === "unbound" ? "unbound" : "unbind-pending";
226
+ }
227
+ /** May a ring proceed? Only a corroborated unbind stops one. */
228
+ function allowsRing(state) {
229
+ return state !== "unbound";
230
+ }
231
+ /** True only when a read has positively confirmed the binding. */
232
+ function hasConfirmedBinding(state) {
233
+ return state === "bound";
234
+ }
235
+ //#endregion
133
236
  //#region src/builtins/doorbell/doorbell-settings.ts
134
237
  /**
135
238
  * Pure per-camera settings parsing/normalization for the virtual-doorbell
@@ -222,10 +325,6 @@ function applyDoorbellSourcesPatch(current, patchValue) {
222
325
  }
223
326
  //#endregion
224
327
  //#region src/builtins/doorbell/virtual-doorbell.addon.ts
225
- /** Reload the camera→source assignment map at most this often (lazily,
226
- * on trigger traffic). Covers devices/settings appearing after boot
227
- * without a settings save. */
228
- var ASSIGNMENTS_TTL_MS = 6e4;
229
328
  /** Structural narrowing helper for untrusted bus payloads. */
230
329
  function isRecord(value) {
231
330
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -281,19 +380,32 @@ function parseDoorbellSlice(slice) {
281
380
  * bumps the camera's `doorbell` runtime-state counters via the
282
381
  * standard `deviceState.setCapSlice` plumbing.
283
382
  *
284
- * Fires ONLY for cameras where the wrapper is BOUND (checked against
285
- * `deviceManager.getBindings`) and at least one source device is configured.
383
+ * Fires ONLY for cameras with at least one configured source device that
384
+ * this wrapper is BOUND to. The binding is NOT re-derived on the ring path:
385
+ * it is mirrored in memory (`binding-mirror.ts`) and refreshed on the
386
+ * assignment-reload cadence, because a fallible read must never be able to
387
+ * destroy a one-shot press.
286
388
  */
287
389
  var VirtualDoorbellAddon = class extends BaseAddon {
288
390
  engine;
289
391
  /** Monotonic guard: assignments are reloaded lazily on trigger traffic. */
290
392
  assignmentsLoadedAt = 0;
291
393
  assignmentsLoading = null;
394
+ /**
395
+ * Per-camera mirror of "is this wrapper still the active doorbell
396
+ * binding". Written ONLY off the ring path; read on it. See
397
+ * `binding-mirror.ts` for why a single negative read is not believed.
398
+ */
399
+ bindingMirror = /* @__PURE__ */ new Map();
292
400
  constructor() {
293
- super({ debounceMs: 2e3 });
401
+ super({
402
+ debounceMs: 2e3,
403
+ assignmentsTtlMs: 6e4
404
+ });
294
405
  this.engine = new DoorbellTriggerEngine({ debounceMs: 2e3 });
295
406
  }
296
407
  async onInitialize() {
408
+ this.engine = new DoorbellTriggerEngine({ debounceMs: this.config.debounceMs });
297
409
  this.ctx.logger.info("Virtual doorbell wrapper initialized");
298
410
  this.subscribe({ category: EventCategory.DeviceStateChanged }, (event) => {
299
411
  const data = parseDeviceStateChanged(event.data);
@@ -313,22 +425,62 @@ var VirtualDoorbellAddon = class extends BaseAddon {
313
425
  async handleStateChanged(data) {
314
426
  if (!isSourceCap(data.capName)) return;
315
427
  await this.ensureAssignments();
316
- const cameraIds = this.engine.onStateSlice(data.deviceId, data.capName, data.slice);
317
- for (const cameraId of cameraIds) await this.fireDoorbell(cameraId, Date.now());
428
+ const result = this.engine.onStateSlice(data.deviceId, data.capName, data.slice);
429
+ this.logSuppressions(data, result);
430
+ for (const cameraId of result.fired) await this.fireDoorbell(cameraId, Date.now());
431
+ }
432
+ /**
433
+ * Say out loud every case where a rise did NOT become a ring. Silence
434
+ * reads as "never happened": three real presses on camera 615 produced
435
+ * zero operator-visible lines because the drop logged at `debug`.
436
+ */
437
+ logSuppressions(data, result) {
438
+ for (const cameraId of result.debounced) this.ctx.logger.info("virtual-doorbell: rise suppressed by the debounce window", {
439
+ tags: { deviceId: cameraId },
440
+ meta: {
441
+ sourceDeviceId: data.deviceId,
442
+ capName: data.capName
443
+ }
444
+ });
445
+ if (result.ignored === "baseline-seeded-stale-active") {
446
+ 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";
447
+ const options = {
448
+ tags: { deviceId: data.deviceId },
449
+ meta: { capName: data.capName }
450
+ };
451
+ if (this.engine.hasSource(data.deviceId)) this.ctx.logger.warn(message, options);
452
+ else this.ctx.logger.debug(message, options);
453
+ return;
454
+ }
455
+ if (result.ignored === "no-assignment") this.ctx.logger.debug("virtual-doorbell: rise on a source no camera is wired to", {
456
+ tags: { deviceId: data.deviceId },
457
+ meta: { capName: data.capName }
458
+ });
318
459
  }
319
460
  /**
320
- * Ring the camera's virtual doorbell: verify the wrapper is BOUND to
321
- * the camera, bump the `doorbell` runtime-state counters through the
322
- * canonical `deviceState.setCapSlice` write, and emit the typed
323
- * `DoorbellOnPressed` event with the CAMERA as source byte-shaped
324
- * like a native firmware ring so every downstream consumer (UI
325
- * toast, notifier rules, exporters) is agnostic to the origin.
461
+ * Ring the camera's virtual doorbell: bump the `doorbell` runtime-state
462
+ * counters through the canonical `deviceState.setCapSlice` write, and
463
+ * emit the typed `DoorbellOnPressed` event with the CAMERA as source —
464
+ * byte-shaped like a native firmware ring so every downstream consumer
465
+ * (UI toast, notifier rules, exporters) is agnostic to the origin.
466
+ *
467
+ * The binding gate here is a pure in-memory read. It performs NO I/O and
468
+ * cannot fail: the whole point of the mirror is that the ring path can no
469
+ * longer be talked out of ringing by a store that answered badly.
326
470
  */
327
471
  async fireDoorbell(cameraId, timestamp) {
328
- if (!await this.isWrapperBound(cameraId)) {
329
- this.ctx.logger.debug("virtual-doorbell: trigger matched but wrapper not bound — skipping", { tags: { deviceId: cameraId } });
472
+ const binding = this.bindingMirror.get(cameraId);
473
+ if (!allowsRing(binding)) {
474
+ this.ctx.logger.warn("virtual-doorbell: ring DROPPED — the wrapper is not bound to this camera, but a trigger source is still configured for it", {
475
+ tags: { deviceId: cameraId },
476
+ meta: { bindingState: binding }
477
+ });
330
478
  return;
331
479
  }
480
+ if (!hasConfirmedBinding(binding)) this.ctx.logger.warn("virtual-doorbell: ringing on an unconfirmed binding — the bindings store has not corroborated an unbind, and a one-shot press is never dropped on an unproven read", {
481
+ tags: { deviceId: cameraId },
482
+ meta: { bindingState: binding ?? "never-read" }
483
+ });
332
484
  try {
333
485
  const next = {
334
486
  lastPressedAt: timestamp,
@@ -362,17 +514,51 @@ var VirtualDoorbellAddon = class extends BaseAddon {
362
514
  meta: { timestamp }
363
515
  });
364
516
  }
365
- /** True when THIS wrapper is the active `doorbell` binding for the camera. */
366
- async isWrapperBound(cameraId) {
517
+ /**
518
+ * Fold one bindings read for `cameraId` into the mirror. Runs on the
519
+ * assignment-reload cadence, never while firing.
520
+ *
521
+ * A read that THROWS carries no information and leaves the mirror exactly
522
+ * as it was — the previous verdict keeps standing. A read that succeeds
523
+ * but omits our activation only ARMS a revocation; `nextBindingState`
524
+ * requires a second consecutive negative before the camera goes silent,
525
+ * because a genuine unbind and a store answering from an empty snapshot
526
+ * (D44) are indistinguishable in a single response.
527
+ */
528
+ async refreshBindingMirror(cameraId) {
529
+ let entries;
367
530
  try {
368
- return (await this.ctx.api.deviceManager.getBindings.query({ deviceId: cameraId })).entries.some((entry) => entry.capName === doorbellCapability.name && entry.kind === "wrapped" && entry.providerAddonId === this.ctx.id);
531
+ entries = (await this.ctx.api.deviceManager.getBindings.query({ deviceId: cameraId })).entries;
369
532
  } catch (err) {
370
- this.ctx.logger.debug("virtual-doorbell: getBindings failed — treating as unbound", {
533
+ this.ctx.logger.warn("virtual-doorbell: binding check failed — keeping the last known verdict, NOT treating this as an unbind", {
371
534
  tags: { deviceId: cameraId },
372
- meta: { error: errMsg(err) }
535
+ meta: {
536
+ error: errMsg(err),
537
+ bindingState: this.bindingMirror.get(cameraId)
538
+ }
373
539
  });
374
- return false;
540
+ return;
375
541
  }
542
+ const boundNow = entries.some((entry) => entry.capName === doorbellCapability.name && entry.kind === "wrapped" && entry.providerAddonId === this.ctx.id);
543
+ const previous = this.bindingMirror.get(cameraId);
544
+ const next = nextBindingState(previous, boundNow);
545
+ this.bindingMirror.set(cameraId, next);
546
+ if (next !== previous && next !== "bound") this.ctx.logger.warn("virtual-doorbell: bindings read did not see this wrapper", {
547
+ tags: { deviceId: cameraId },
548
+ meta: {
549
+ from: previous ?? "never-read",
550
+ to: next
551
+ }
552
+ });
553
+ }
554
+ /**
555
+ * Record a binding we observed FIRST-HAND. Both callers are cap methods
556
+ * the router only reaches by resolving this wrapper for this device —
557
+ * which it can only do through the binding — so a call is direct evidence
558
+ * and outranks anything the store says.
559
+ */
560
+ confirmBindingFromCapCall(deviceId) {
561
+ this.bindingMirror.set(deviceId, nextBindingState(this.bindingMirror.get(deviceId), true));
376
562
  }
377
563
  /**
378
564
  * Lazily (re)build the camera→source assignment set from every
@@ -381,7 +567,8 @@ var VirtualDoorbellAddon = class extends BaseAddon {
381
567
  * eagerly by `applyDeviceSettingsPatch`.
382
568
  */
383
569
  async ensureAssignments() {
384
- if (Date.now() - this.assignmentsLoadedAt < ASSIGNMENTS_TTL_MS) return;
570
+ const now = Date.now();
571
+ if (this.assignmentsLoadedAt > 0 && now - this.assignmentsLoadedAt < this.config.assignmentsTtlMs) return;
385
572
  if (this.assignmentsLoading) return this.assignmentsLoading;
386
573
  this.assignmentsLoading = this.loadAssignments().then(() => {
387
574
  this.assignmentsLoadedAt = Date.now();
@@ -393,17 +580,29 @@ var VirtualDoorbellAddon = class extends BaseAddon {
393
580
  return this.assignmentsLoading;
394
581
  }
395
582
  async loadAssignments() {
583
+ if (!this.ctx.settings) {
584
+ this.ctx.logger.warn("virtual-doorbell: no settings store — every camera reads ZERO trigger sources, no doorbell can ring");
585
+ return;
586
+ }
396
587
  const cameras = (await this.ctx.api.deviceManager.listAll.query({})).filter((d) => d.type === DeviceType.Camera);
397
588
  const assignments = [];
589
+ const configuredCameras = [];
398
590
  for (const camera of cameras) {
399
591
  const sources = await this.readDeviceSources(camera.id);
592
+ if (sources.length > 0) configuredCameras.push(camera.id);
400
593
  for (const source of sources) assignments.push({
401
594
  cameraId: camera.id,
402
595
  sourceDeviceId: source.deviceId
403
596
  });
404
597
  }
598
+ for (const cameraId of configuredCameras) await this.refreshBindingMirror(cameraId);
599
+ const hadAssignments = this.engine.getAssignments().length > 0;
405
600
  this.engine.setAssignments(assignments);
406
- this.ctx.logger.debug("virtual-doorbell: assignments loaded", { meta: { count: assignments.length } });
601
+ 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");
602
+ this.ctx.logger.debug("virtual-doorbell: assignments loaded", { meta: {
603
+ count: assignments.length,
604
+ configuredCameras: configuredCameras.length
605
+ } });
407
606
  }
408
607
  invalidateAssignments() {
409
608
  this.assignmentsLoadedAt = 0;
@@ -420,7 +619,7 @@ var VirtualDoorbellAddon = class extends BaseAddon {
420
619
  capName: doorbellCapability.name
421
620
  }));
422
621
  } catch (err) {
423
- this.ctx.logger.debug("virtual-doorbell: getCapSlice failed during getStatus", {
622
+ this.ctx.logger.warn("virtual-doorbell: getCapSlice failed during getStatus", {
424
623
  tags: { deviceId },
425
624
  meta: { error: errMsg(err) }
426
625
  });
@@ -472,6 +671,7 @@ var VirtualDoorbellAddon = class extends BaseAddon {
472
671
  async buildDeviceSettingsContribution(deviceId) {
473
672
  const device = await this.lookupDevice(deviceId);
474
673
  if (device && device.type !== DeviceType.Camera) return null;
674
+ this.confirmBindingFromCapCall(deviceId);
475
675
  const sources = await this.readDeviceSources(deviceId);
476
676
  return { sections: [{
477
677
  id: "virtual-doorbell",
@@ -494,6 +694,7 @@ var VirtualDoorbellAddon = class extends BaseAddon {
494
694
  }
495
695
  async saveDeviceSettingsPatch(deviceId, patch) {
496
696
  if (!this.ctx.settings) throw new Error("[virtual-doorbell] settings store unavailable — cannot persist per-device settings");
697
+ this.confirmBindingFromCapCall(deviceId);
497
698
  if ("doorbellSources" in patch) {
498
699
  const next = applyDoorbellSourcesPatch(await this.ctx.settings.readDeviceStore(deviceId), patch[KEY_SOURCES]);
499
700
  await this.ctx.settings.writeDeviceStore(deviceId, next);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/system",
3
- "version": "1.2.55",
3
+ "version": "1.2.56",
4
4
  "description": "Core addon for CamStack — builtins, pipeline, process management, auth, logging, events",
5
5
  "keywords": [
6
6
  "camstack",