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