@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.
- package/dist/builtins/doorbell/binding-mirror.d.ts +50 -0
- package/dist/builtins/doorbell/trigger-engine.d.ts +85 -8
- package/dist/builtins/doorbell/virtual-doorbell.addon.d.ts +53 -11
- package/dist/builtins/doorbell/virtual-doorbell.addon.js +242 -41
- package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +242 -41
- package/package.json +1 -1
|
@@ -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
|
|
61
|
-
*
|
|
62
|
-
*
|
|
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.
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
* the falling edge
|
|
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
|
-
|
|
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
|
|
31
|
-
*
|
|
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
|
|
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
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
/**
|
|
51
|
-
|
|
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
|
|
82
|
-
*
|
|
83
|
-
*
|
|
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.
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
* the falling edge
|
|
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
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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 (
|
|
204
|
+
if (assignment.sourceDeviceId !== sourceDeviceId) continue;
|
|
129
205
|
const last = this.lastFiredAt.get(assignment.cameraId);
|
|
130
|
-
if (last !== void 0 && now - last < this.debounceMs)
|
|
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
|
|
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
|
|
290
|
-
*
|
|
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({
|
|
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
|
|
322
|
-
|
|
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:
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
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
|
-
|
|
334
|
-
|
|
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
|
-
/**
|
|
371
|
-
|
|
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
|
-
|
|
536
|
+
entries = (await this.ctx.api.deviceManager.getBindings.query({ deviceId: cameraId })).entries;
|
|
374
537
|
} catch (err) {
|
|
375
|
-
this.ctx.logger.
|
|
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: {
|
|
540
|
+
meta: {
|
|
541
|
+
error: require_dist.errMsg(err),
|
|
542
|
+
bindingState: this.bindingMirror.get(cameraId)
|
|
543
|
+
}
|
|
378
544
|
});
|
|
379
|
-
return
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
77
|
-
*
|
|
78
|
-
*
|
|
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.
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
* the falling edge
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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 (
|
|
199
|
+
if (assignment.sourceDeviceId !== sourceDeviceId) continue;
|
|
124
200
|
const last = this.lastFiredAt.get(assignment.cameraId);
|
|
125
|
-
if (last !== void 0 && now - last < this.debounceMs)
|
|
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
|
|
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
|
|
285
|
-
*
|
|
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({
|
|
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
|
|
317
|
-
|
|
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:
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
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
|
-
|
|
329
|
-
|
|
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
|
-
/**
|
|
366
|
-
|
|
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
|
-
|
|
531
|
+
entries = (await this.ctx.api.deviceManager.getBindings.query({ deviceId: cameraId })).entries;
|
|
369
532
|
} catch (err) {
|
|
370
|
-
this.ctx.logger.
|
|
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: {
|
|
535
|
+
meta: {
|
|
536
|
+
error: errMsg(err),
|
|
537
|
+
bindingState: this.bindingMirror.get(cameraId)
|
|
538
|
+
}
|
|
373
539
|
});
|
|
374
|
-
return
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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);
|