@camstack/system 1.1.53 → 1.1.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/addon-runner.js +2 -2
  2. package/dist/addon-runner.mjs +2 -2
  3. package/dist/addon-utils.d.ts +1 -0
  4. package/dist/addon-utils.js +2 -0
  5. package/dist/addon-utils.mjs +2 -2
  6. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  7. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  8. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  9. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  10. package/dist/builtins/alerts/alerts.addon.js +1 -1
  11. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  12. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
  13. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
  14. package/dist/builtins/console-logging/index.js +1 -1
  15. package/dist/builtins/console-logging/index.mjs +1 -1
  16. package/dist/builtins/device-manager/device-manager.addon.js +46 -37
  17. package/dist/builtins/device-manager/device-manager.addon.mjs +46 -37
  18. package/dist/builtins/device-manager/device-store-sections.d.ts +26 -0
  19. package/dist/builtins/doorbell/doorbell-settings.d.ts +58 -0
  20. package/dist/builtins/doorbell/trigger-engine.d.ts +32 -30
  21. package/dist/builtins/doorbell/virtual-doorbell.addon.d.ts +39 -22
  22. package/dist/builtins/doorbell/virtual-doorbell.addon.js +207 -191
  23. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +207 -191
  24. package/dist/builtins/hub-forwarder/index.js +1 -1
  25. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  26. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  27. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  28. package/dist/builtins/local-network/local-network.addon.js +1 -1
  29. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  30. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  31. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  32. package/dist/builtins/platform-probe/index.js +1 -1
  33. package/dist/builtins/platform-probe/index.mjs +1 -1
  34. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  35. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  36. package/dist/builtins/snapshot/index.js +1 -1
  37. package/dist/builtins/snapshot/index.mjs +1 -1
  38. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  39. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  40. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +1 -1
  41. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +1 -1
  42. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  43. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  44. package/dist/builtins/system-config/system-config.addon.js +1 -1
  45. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  46. package/dist/builtins/winston-logging/index.js +1 -1
  47. package/dist/builtins/winston-logging/index.mjs +1 -1
  48. package/dist/{dist-BNro5CRu.js → dist-B5C4dVvO.js} +201 -7
  49. package/dist/{dist-D7_69kDt.mjs → dist-DssImRC5.mjs} +201 -7
  50. package/dist/index.js +2 -2
  51. package/dist/index.mjs +2 -2
  52. package/dist/{manifest-python-deps-EM2H-jjS.mjs → manifest-python-deps-CWom-eqp.mjs} +1 -1
  53. package/dist/{manifest-python-deps-DszE7bYn.js → manifest-python-deps-kkvku9yU.js} +1 -1
  54. package/package.json +2 -2
@@ -1,41 +1,59 @@
1
- import { C as doorbellCapability, _t as createEvent, it as BaseAddon, kt as EventCategory, rt as errMsg, ut as DeviceType } from "../../dist-D7_69kDt.mjs";
1
+ import { C as doorbellCapability, _t as createEvent, it as BaseAddon, kt as EventCategory, rt as errMsg, ut as DeviceType } from "../../dist-DssImRC5.mjs";
2
2
  //#region src/builtins/doorbell/trigger-engine.ts
3
- var DOORBELL_TRIGGERS = [
4
- "contact-open",
5
- "contact-close",
6
- "switch-on",
7
- "event"
8
- ];
9
- /** Type guard for {@link DoorbellTrigger}. */
10
- function isDoorbellTrigger(value) {
11
- return typeof value === "string" && DOORBELL_TRIGGERS.includes(value);
12
- }
13
- /** Cap slices the engine understands as binary trigger sources. */
14
- var CONTACT_CAP = "contact";
15
- var SWITCH_CAP = "switch";
16
3
  /**
17
- * Map an observed boolean transition on a source cap to the trigger it
18
- * satisfies. `null` when the transition has no configured meaning
19
- * (e.g. switch turning OFF).
4
+ * Pure trigger logic for the virtual-doorbell builtin edge detection over
5
+ * runtime-state slices with per-camera debounce. Extracted from the addon
6
+ * class so the behaviour is unit-testable without instantiating the addon or
7
+ * mocking the event bus.
8
+ *
9
+ * REACTION MODEL (simplified — operator picks a source, the system reacts
10
+ * with a sensible default): a source device rings a camera's doorbell when a
11
+ * known binary / switch cap on it RISES to its active state (false→true) — a
12
+ * contact opening, a switch turning on, an alarm sensor firing. There is no
13
+ * per-source trigger configuration today; the shape stays extensible for a
14
+ * future custom-trigger override (see `doorbell-settings.ts`).
20
15
  */
21
- function edgeToTrigger(capName, value) {
22
- if (capName === CONTACT_CAP) return value ? "contact-open" : "contact-close";
23
- if (capName === SWITCH_CAP) return value ? "switch-on" : null;
24
- return null;
16
+ /**
17
+ * Known binary / switch source caps the boolean slice field whose
18
+ * false→true rise rings the doorbell. Every entry is "ring on active".
19
+ * Sensors whose "active" reading is not a plain boolean (presence's string
20
+ * state, connectivity's connected flag) are deliberately excluded — a
21
+ * reconnect is not a doorbell press.
22
+ */
23
+ var SOURCE_CAP_ACTIVE_FIELD = {
24
+ contact: "entryOpen",
25
+ binary: "on",
26
+ switch: "on",
27
+ motion: "detected",
28
+ flood: "flooded",
29
+ gas: "detected",
30
+ smoke: "detected",
31
+ "carbon-monoxide": "detected",
32
+ vibration: "detected",
33
+ tamper: "tampered"
34
+ };
35
+ /** Cap names whose presence in a device's bindings qualify it as a source. */
36
+ var SOURCE_CAPS = Object.keys(SOURCE_CAP_ACTIVE_FIELD);
37
+ /** True when a cap is a recognised binary/switch doorbell source. */
38
+ function isSourceCap(capName) {
39
+ return Object.prototype.hasOwnProperty.call(SOURCE_CAP_ACTIVE_FIELD, capName);
25
40
  }
26
- /** Extract the binary state carried by a contact / switch slice. */
27
- function sliceBinaryValue(capName, slice) {
28
- const raw = capName === CONTACT_CAP ? slice["entryOpen"] : slice["on"];
41
+ /** Extract the "active" boolean a source cap's slice carries, or null when
42
+ * the cap is unknown or the field is missing / non-boolean. */
43
+ function sliceActiveValue(capName, slice) {
44
+ const field = SOURCE_CAP_ACTIVE_FIELD[capName];
45
+ if (field === void 0) return null;
46
+ const raw = slice[field];
29
47
  return typeof raw === "boolean" ? raw : null;
30
48
  }
31
49
  /**
32
50
  * Stateful (but side-effect-free towards the outside) trigger engine.
33
51
  *
34
52
  * Tracks the last observed binary value per (sourceDevice, cap) so a
35
- * `DeviceStateChanged` re-emission (the slice's `lastChangedAt` moves
36
- * while the binary value does not) never double-fires, and the FIRST
37
- * observation after boot only seeds the baseline — a state hydration
38
- * at startup must not ring anybody's doorbell.
53
+ * `DeviceStateChanged` re-emission (the slice's `lastChangedAt` moves while
54
+ * the binary value does not) never double-fires, and the FIRST observation
55
+ * after boot only seeds the baseline — a state hydration at startup must not
56
+ * ring anybody's doorbell.
39
57
  */
40
58
  var DoorbellTriggerEngine = class {
41
59
  assignments = [];
@@ -62,30 +80,19 @@ var DoorbellTriggerEngine = class {
62
80
  }
63
81
  /**
64
82
  * Feed one `DeviceStateChanged` slice. Returns the camera ids whose
65
- * doorbell should ring (already debounced). Baseline-only sightings
66
- * and non-transitions return an empty array.
83
+ * doorbell should ring (already debounced). Only a false→true rise on a
84
+ * recognised source cap fires; baseline-only sightings, re-emissions, and
85
+ * the falling edge return an empty array.
67
86
  */
68
87
  onStateSlice(deviceId, capName, slice) {
69
- if (capName !== CONTACT_CAP && capName !== SWITCH_CAP) return [];
70
- const value = sliceBinaryValue(capName, slice);
88
+ const value = sliceActiveValue(capName, slice);
71
89
  if (value === null) return [];
72
90
  const key = `${deviceId}:${capName}`;
73
91
  const prior = this.lastValues.get(key);
74
92
  this.lastValues.set(key, value);
75
93
  if (prior === void 0 || prior === value) return [];
76
- const trigger = edgeToTrigger(capName, value);
77
- if (!trigger) return [];
78
- return this.matchAndDebounce((a) => a.sourceDeviceId === deviceId && a.trigger === trigger);
79
- }
80
- /**
81
- * Feed one event-emitter `onEvent` firing. Returns the camera ids to
82
- * ring — assignments with `trigger: 'event'`, a matching source and a
83
- * verbatim `eventType` match. An assignment with no configured
84
- * eventType never matches (misconfiguration must not ring on every
85
- * event the device emits).
86
- */
87
- onEmitterEvent(deviceId, eventType) {
88
- return this.matchAndDebounce((a) => a.trigger === "event" && a.sourceDeviceId === deviceId && a.eventType !== null && a.eventType.length > 0 && a.eventType === eventType);
94
+ if (!value) return [];
95
+ return this.matchAndDebounce((a) => a.sourceDeviceId === deviceId);
89
96
  }
90
97
  matchAndDebounce(predicate) {
91
98
  const now = this.now();
@@ -101,37 +108,102 @@ var DoorbellTriggerEngine = class {
101
108
  }
102
109
  };
103
110
  //#endregion
104
- //#region src/builtins/doorbell/virtual-doorbell.addon.ts
105
- /** Device-store keys (namespaced to avoid collisions in merged stores). */
106
- var KEY_SOURCE = "doorbellSourceDeviceId";
107
- var KEY_TRIGGER = "doorbellTrigger";
108
- var KEY_EVENT_TYPE = "doorbellEventType";
109
- var DEFAULT_TRIGGER = "contact-open";
111
+ //#region src/builtins/doorbell/doorbell-settings.ts
112
+ /**
113
+ * Pure per-camera settings parsing/normalization for the virtual-doorbell
114
+ * builtin. Kept separate from the addon class (and the runtime trigger
115
+ * engine) so the persisted-shape handling — including tolerant migration of
116
+ * the legacy single-source blob — is unit-testable in isolation.
117
+ *
118
+ * PERSISTED SHAPE (extensible by design):
119
+ * doorbellSources: Array<{ deviceId: number }>
120
+ *
121
+ * The source is an OBJECT rather than a bare id so future per-source options
122
+ * (a custom trigger override, an event-type match, a per-source debounce)
123
+ * can be added without another migration. Today only `deviceId` is stored;
124
+ * the reaction is DERIVED at runtime from the source device's cap (see
125
+ * `trigger-engine.ts`).
126
+ */
127
+ /** Device-store key holding the source list. */
128
+ var KEY_SOURCES = "doorbellSources";
129
+ /**
130
+ * Legacy device-store keys from the pre-simplification shape (single source
131
+ * device + explicit trigger + event type). Retained ONLY for tolerant
132
+ * migration on read and cleanup on write — never written afresh.
133
+ */
134
+ var LEGACY_KEY_SOURCE = "doorbellSourceDeviceId";
135
+ var LEGACY_KEY_TRIGGER = "doorbellTrigger";
136
+ var LEGACY_KEY_EVENT_TYPE = "doorbellEventType";
137
+ function isRecord$1(value) {
138
+ return typeof value === "object" && value !== null && !Array.isArray(value);
139
+ }
140
+ /** Coerce a persisted / form-supplied id (number or numeric string) to a
141
+ * positive integer device id, or null when it is not a usable id. */
142
+ function coerceDeviceId(value) {
143
+ if (typeof value === "number" && Number.isInteger(value) && value > 0) return value;
144
+ if (typeof value === "string" && value !== "none" && value.trim().length > 0) {
145
+ const parsed = Number(value);
146
+ if (Number.isInteger(parsed) && parsed > 0) return parsed;
147
+ }
148
+ return null;
149
+ }
150
+ /** Fold a raw list of ids / source records into a deduped source array. */
151
+ function collectSources(items) {
152
+ const out = [];
153
+ const seen = /* @__PURE__ */ new Set();
154
+ for (const item of items) {
155
+ const id = isRecord$1(item) ? coerceDeviceId(item["deviceId"]) : coerceDeviceId(item);
156
+ if (id !== null && !seen.has(id)) {
157
+ seen.add(id);
158
+ out.push({ deviceId: id });
159
+ }
160
+ }
161
+ return out;
162
+ }
110
163
  /**
111
- * Caps whose presence in a device's bindings marks it as a usable
112
- * doorbell SOURCE anything with a binary-ish surface. Deliberately
113
- * not gated on device linking: which device rings which camera is an
114
- * operator decision.
164
+ * Parse the persisted doorbell sources for one camera. Tolerant of BOTH:
165
+ * - the new `doorbellSources: [{deviceId}]` shape (ids may arrive as
166
+ * numbers, numeric strings, or `{deviceId}` records); and
167
+ * - the legacy single `doorbellSourceDeviceId` scalar, mapped onto one
168
+ * source (the old trigger/eventType are dropped — the reaction is now
169
+ * derived at runtime).
170
+ *
171
+ * Never throws on malformed data — unknown entries are skipped.
172
+ */
173
+ function parseDoorbellSources(blob) {
174
+ const raw = blob[KEY_SOURCES];
175
+ const fromNew = Array.isArray(raw) ? collectSources(raw) : [];
176
+ if (fromNew.length > 0) return fromNew;
177
+ return collectSources([blob[LEGACY_KEY_SOURCE]]);
178
+ }
179
+ /**
180
+ * Normalize a settings-patch value for the `doorbellSources` multiselect
181
+ * (a `string[]` of device ids from the form, or an already-shaped
182
+ * `{deviceId}[]` / `number[]`) into the persisted extensible shape.
183
+ */
184
+ function normalizeDoorbellSources(value) {
185
+ if (!Array.isArray(value)) return [];
186
+ return collectSources(value);
187
+ }
188
+ /**
189
+ * Produce the next device-store blob after applying a doorbell-sources
190
+ * patch: writes the normalized new shape and strips every legacy key so a
191
+ * migrated camera never carries stale single-source fields.
115
192
  */
116
- var SOURCE_CAPS = [
117
- "contact",
118
- "flood",
119
- "gas",
120
- "carbon-monoxide",
121
- "enum-sensor",
122
- "event-emitter",
123
- "switch"
124
- ];
193
+ function applyDoorbellSourcesPatch(current, patchValue) {
194
+ const next = { ...current };
195
+ delete next[LEGACY_KEY_SOURCE];
196
+ delete next[LEGACY_KEY_TRIGGER];
197
+ delete next[LEGACY_KEY_EVENT_TYPE];
198
+ next[KEY_SOURCES] = normalizeDoorbellSources(patchValue);
199
+ return next;
200
+ }
201
+ //#endregion
202
+ //#region src/builtins/doorbell/virtual-doorbell.addon.ts
125
203
  /** Reload the camera→source assignment map at most this often (lazily,
126
204
  * on trigger traffic). Covers devices/settings appearing after boot
127
205
  * without a settings save. */
128
206
  var ASSIGNMENTS_TTL_MS = 6e4;
129
- var TRIGGER_LABELS = {
130
- "contact-open": "Contact opens",
131
- "contact-close": "Contact closes",
132
- "switch-on": "Switch turns on",
133
- event: "Device event"
134
- };
135
207
  /** Structural narrowing helper for untrusted bus payloads. */
136
208
  function isRecord(value) {
137
209
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -166,26 +238,29 @@ function parseDoorbellSlice(slice) {
166
238
  /**
167
239
  * VirtualDoorbellAddon — WRAPPER over the `doorbell` capability.
168
240
  *
169
- * Turns any binary-ish device (contact sensor, switch, event emitter,
170
- * flood/gas/CO sensor) into a doorbell for a camera:
241
+ * Turns any binary sensor or switch (contact, motion, flood/gas/CO/smoke,
242
+ * vibration, tamper, generic binary, or a switch) into a doorbell for a
243
+ * camera:
171
244
  *
172
245
  * 1. The operator binds the wrapper to a camera in the bindings UI
173
246
  * (`defaultActive: false` — never auto-bound).
174
- * 2. Per-camera settings pick the SOURCE device + trigger
175
- * (`contact-open` / `contact-close` / `switch-on` / `event`
176
- * with an event-type match).
247
+ * 2. In the camera's GENERAL settings (next to "Linked devices") the
248
+ * operator picks one or more SOURCE devices from a searchable list —
249
+ * that is the ONLY choice. The reaction is derived automatically per
250
+ * device type: a binary sensor rings when it goes active, a switch
251
+ * rings when it turns on (see `trigger-engine.ts`). The persisted
252
+ * shape (`doorbellSources: [{deviceId}]`) stays extensible for a
253
+ * future custom-trigger override.
177
254
  * 3. At runtime the addon watches `EventCategory.DeviceStateChanged`
178
- * (contact/switch slice transitions — EDGE detected per trigger)
179
- * and `EventCategory.EventEmitted` (event-emitter firings). On a
180
- * matching, debounced trigger it emits
181
- * `EventCategory.DoorbellOnPressed` with the CAMERA as source
182
- * (`{deviceId: cameraId, timestamp}` the same shape Reolink's
183
- * native `emitDoorbellPressed` produces) and bumps the camera's
184
- * `doorbell` runtime-state counters via the standard
185
- * `deviceState.setCapSlice` plumbing.
255
+ * (binary/switch slice transitions — false→true edge detected). On a
256
+ * matching, debounced rise it emits `EventCategory.DoorbellOnPressed`
257
+ * with the CAMERA as source (`{deviceId: cameraId, timestamp}` the
258
+ * same shape Reolink's native `emitDoorbellPressed` produces) and
259
+ * bumps the camera's `doorbell` runtime-state counters via the
260
+ * standard `deviceState.setCapSlice` plumbing.
186
261
  *
187
262
  * Fires ONLY for cameras where the wrapper is BOUND (checked against
188
- * `deviceManager.getBindings`) and a source device is configured.
263
+ * `deviceManager.getBindings`) and at least one source device is configured.
189
264
  */
190
265
  var VirtualDoorbellAddon = class extends BaseAddon {
191
266
  engine;
@@ -203,9 +278,6 @@ var VirtualDoorbellAddon = class extends BaseAddon {
203
278
  if (!data) return;
204
279
  this.handleStateChanged(data);
205
280
  });
206
- this.subscribe({ category: EventCategory.EventEmitted }, (event) => {
207
- this.handleEmitterEvent(event.data.deviceId, event.data.eventType);
208
- });
209
281
  return [{
210
282
  capability: doorbellCapability,
211
283
  provider: {
@@ -217,16 +289,11 @@ var VirtualDoorbellAddon = class extends BaseAddon {
217
289
  }];
218
290
  }
219
291
  async handleStateChanged(data) {
220
- if (data.capName !== "contact" && data.capName !== "switch") return;
292
+ if (!isSourceCap(data.capName)) return;
221
293
  await this.ensureAssignments();
222
294
  const cameraIds = this.engine.onStateSlice(data.deviceId, data.capName, data.slice);
223
295
  for (const cameraId of cameraIds) await this.fireDoorbell(cameraId, Date.now());
224
296
  }
225
- async handleEmitterEvent(sourceDeviceId, eventType) {
226
- await this.ensureAssignments();
227
- const cameraIds = this.engine.onEmitterEvent(sourceDeviceId, eventType);
228
- for (const cameraId of cameraIds) await this.fireDoorbell(cameraId, Date.now());
229
- }
230
297
  /**
231
298
  * Ring the camera's virtual doorbell: verify the wrapper is BOUND to
232
299
  * the camera, bump the `doorbell` runtime-state counters through the
@@ -307,13 +374,10 @@ var VirtualDoorbellAddon = class extends BaseAddon {
307
374
  const cameras = (await this.ctx.api.deviceManager.listAll.query({})).filter((d) => d.type === DeviceType.Camera);
308
375
  const assignments = [];
309
376
  for (const camera of cameras) {
310
- const settings = await this.readDeviceSettings(camera.id);
311
- if (settings.sourceDeviceId === null) continue;
312
- assignments.push({
377
+ const sources = await this.readDeviceSources(camera.id);
378
+ for (const source of sources) assignments.push({
313
379
  cameraId: camera.id,
314
- sourceDeviceId: settings.sourceDeviceId,
315
- trigger: settings.trigger,
316
- eventType: settings.eventType.length > 0 ? settings.eventType : null
380
+ sourceDeviceId: source.deviceId
317
381
  });
318
382
  }
319
383
  this.engine.setAssignments(assignments);
@@ -341,21 +405,13 @@ var VirtualDoorbellAddon = class extends BaseAddon {
341
405
  return null;
342
406
  }
343
407
  }
344
- async readDeviceSettings(deviceId) {
345
- if (!this.ctx.settings) return {
346
- sourceDeviceId: null,
347
- trigger: DEFAULT_TRIGGER,
348
- eventType: ""
349
- };
350
- const raw = await this.ctx.settings.readDeviceStore(deviceId);
351
- const sourceRaw = raw[KEY_SOURCE];
352
- const triggerRaw = raw[KEY_TRIGGER];
353
- const eventTypeRaw = raw[KEY_EVENT_TYPE];
354
- return {
355
- sourceDeviceId: typeof sourceRaw === "number" && Number.isFinite(sourceRaw) && sourceRaw > 0 ? sourceRaw : null,
356
- trigger: isDoorbellTrigger(triggerRaw) ? triggerRaw : DEFAULT_TRIGGER,
357
- eventType: typeof eventTypeRaw === "string" ? eventTypeRaw : ""
358
- };
408
+ /**
409
+ * The camera's configured doorbell sources, parsed tolerantly (new
410
+ * `doorbellSources` shape + legacy single-source migration).
411
+ */
412
+ async readDeviceSources(deviceId) {
413
+ if (!this.ctx.settings) return [];
414
+ return parseDoorbellSources(await this.ctx.settings.readDeviceStore(deviceId));
359
415
  }
360
416
  /**
361
417
  * Single-trip, non-throwing device lookup (mirrors snapshot's
@@ -376,108 +432,68 @@ var VirtualDoorbellAddon = class extends BaseAddon {
376
432
  }
377
433
  }
378
434
  /**
379
- * Settings section for the device-detail page. Camera-only — the
380
- * doorbell cap also applies to Button accessories (native providers),
381
- * where a virtual-source picker is meaningless.
435
+ * Settings section for the device-detail page — contributed into the
436
+ * GENERAL settings surface (no `tab`, so it lands in the same tab as
437
+ * "Linked devices", ordered just after it). Camera-only: the doorbell
438
+ * cap also applies to Button accessories (native providers) where a
439
+ * virtual-source picker is meaningless.
440
+ *
441
+ * A SINGLE `multiselect` field lists every eligible binary/switch
442
+ * device; at ≥8 options the admin-ui form-builder auto-swaps it for the
443
+ * searchable multi-select (same idiom as "Linked devices"). The reaction
444
+ * is derived automatically per device type — no trigger/eventType
445
+ * controls.
382
446
  */
383
447
  async buildDeviceSettingsContribution(deviceId) {
384
448
  const device = await this.lookupDevice(deviceId);
385
449
  if (device && device.type !== DeviceType.Camera) return null;
386
- const current = await this.readDeviceSettings(deviceId);
450
+ const sources = await this.readDeviceSources(deviceId);
387
451
  return { sections: [{
388
452
  id: "virtual-doorbell",
389
- title: "Virtual Doorbell",
390
- tab: "doorbell",
391
- order: 70,
392
- fields: [
393
- {
394
- type: "select",
395
- key: KEY_SOURCE,
396
- label: "Source device",
397
- description: "Device whose state change rings this camera’s doorbell (contact, switch, sensor, event emitter).",
398
- options: await this.listSourceDeviceOptions(),
399
- required: true,
400
- value: current.sourceDeviceId === null ? "none" : String(current.sourceDeviceId)
401
- },
402
- {
403
- type: "select",
404
- key: KEY_TRIGGER,
405
- label: "Trigger",
406
- description: "Which transition of the source device counts as a ring.",
407
- options: DOORBELL_TRIGGERS.map((t) => ({
408
- value: t,
409
- label: TRIGGER_LABELS[t]
410
- })),
411
- required: true,
412
- value: current.trigger
413
- },
414
- {
415
- type: "text",
416
- key: KEY_EVENT_TYPE,
417
- label: "Event type",
418
- description: "Only used when Trigger is 'Device event' — matched verbatim against the source device's emitted event type (e.g. press_short).",
419
- value: current.eventType
420
- }
421
- ]
453
+ title: "Virtual doorbell",
454
+ description: "Ring this camera’s doorbell when a chosen device becomes active — a contact opening, a switch turning on, or a sensor firing. Pick any binary sensor or switch below; the reaction is automatic per device type.",
455
+ order: 71,
456
+ fields: [{
457
+ type: "multiselect",
458
+ key: KEY_SOURCES,
459
+ label: "Trigger devices",
460
+ description: "Any of these becoming active rings this camera. Binary sensors ring when they go active; switches ring when they turn on.",
461
+ options: await this.listSourceDeviceOptions(),
462
+ value: sources.map((s) => String(s.deviceId))
463
+ }]
422
464
  }] };
423
465
  }
424
466
  /**
425
- * Cluster-wide picker options: every device whose BINDINGS expose one
426
- * of the binary-ish source caps. Binding-sourced (not feature-flag
427
- * sourced) because cap names are exactly what the runtime consumes.
467
+ * Cluster-wide picker options: every device whose BINDINGS expose one of
468
+ * the recognised binary/switch source caps. Binding-sourced (not
469
+ * feature-flag sourced) because cap names are exactly what the runtime
470
+ * consumes. Labelled `Name (Location)` (id carried in the option value)
471
+ * so the searchable multi-select matches on name, location, or id — the
472
+ * same shape "Linked devices" uses.
428
473
  */
429
474
  async listSourceDeviceOptions() {
430
- const none = {
431
- value: "none",
432
- label: "None"
433
- };
434
475
  try {
435
476
  const [devices, allBindings] = await Promise.all([this.ctx.api.deviceManager.listAll.query({}), this.ctx.api.deviceManager.getAllBindings.query({})]);
436
477
  const eligibleIds = /* @__PURE__ */ new Set();
437
478
  for (const deviceBindings of allBindings) if (deviceBindings.entries.some((e) => SOURCE_CAPS.includes(e.capName))) eligibleIds.add(deviceBindings.deviceId);
438
- return [none, ...devices.filter((d) => eligibleIds.has(d.id)).map((d) => ({
479
+ return devices.filter((d) => eligibleIds.has(d.id)).map((d) => ({
439
480
  value: String(d.id),
440
- label: `${d.name} (#${d.id})`
441
- })).toSorted((a, b) => a.label.localeCompare(b.label))];
481
+ label: typeof d.location === "string" && d.location.length > 0 ? `${d.name} (${d.location})` : d.name
482
+ })).toSorted((a, b) => a.label.localeCompare(b.label));
442
483
  } catch (err) {
443
484
  this.ctx.logger.warn("virtual-doorbell: source-device enumeration failed", { meta: { error: errMsg(err) } });
444
- return [none];
485
+ return [];
445
486
  }
446
487
  }
447
488
  async saveDeviceSettingsPatch(deviceId, patch) {
448
489
  if (!this.ctx.settings) throw new Error("[virtual-doorbell] settings store unavailable — cannot persist per-device settings");
449
- const next = { ...await this.ctx.settings.readDeviceStore(deviceId) };
450
- if (KEY_SOURCE in patch) {
451
- const parsed = parseSourceDeviceId(patch[KEY_SOURCE]);
452
- if (parsed === null) delete next[KEY_SOURCE];
453
- else next[KEY_SOURCE] = parsed;
490
+ if ("doorbellSources" in patch) {
491
+ const next = applyDoorbellSourcesPatch(await this.ctx.settings.readDeviceStore(deviceId), patch[KEY_SOURCES]);
492
+ await this.ctx.settings.writeDeviceStore(deviceId, next);
493
+ this.invalidateAssignments();
454
494
  }
455
- if (KEY_TRIGGER in patch) {
456
- const v = patch[KEY_TRIGGER];
457
- if (isDoorbellTrigger(v)) next[KEY_TRIGGER] = v;
458
- else delete next[KEY_TRIGGER];
459
- }
460
- if (KEY_EVENT_TYPE in patch) {
461
- const v = patch[KEY_EVENT_TYPE];
462
- next[KEY_EVENT_TYPE] = typeof v === "string" ? v.trim() : "";
463
- }
464
- await this.ctx.settings.writeDeviceStore(deviceId, next);
465
- this.invalidateAssignments();
466
495
  return { success: true };
467
496
  }
468
497
  };
469
- /**
470
- * Coerce the select value ('none' | numeric string | number) back to a
471
- * device id. The select field surfaces string values; a stored value
472
- * round-trips as a number.
473
- */
474
- function parseSourceDeviceId(value) {
475
- if (typeof value === "number" && Number.isFinite(value) && value > 0) return value;
476
- if (typeof value === "string" && value !== "none" && value.trim().length > 0) {
477
- const parsed = Number(value);
478
- if (Number.isFinite(parsed) && parsed > 0) return parsed;
479
- }
480
- return null;
481
- }
482
498
  //#endregion
483
499
  export { VirtualDoorbellAddon, VirtualDoorbellAddon as default };
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-BNro5CRu.js");
6
+ const require_dist = require("../../dist-B5C4dVvO.js");
7
7
  const require_formatter = require("../../formatter-DqAKDlvN.js");
8
8
  //#region src/builtins/hub-forwarder/hub-forwarder-destination.ts
9
9
  var DEFAULT_OUTBOUND_BUFFER_SIZE = 500;
@@ -1,4 +1,4 @@
1
- import { L as logDestinationCapability, it as BaseAddon } from "../../dist-D7_69kDt.mjs";
1
+ import { L as logDestinationCapability, it as BaseAddon } from "../../dist-DssImRC5.mjs";
2
2
  import { t as formatLogLine } from "../../formatter-B7qW8bPJ.mjs";
3
3
  //#region src/builtins/hub-forwarder/hub-forwarder-destination.ts
4
4
  var DEFAULT_OUTBOUND_BUFFER_SIZE = 500;
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-BNro5CRu.js");
6
+ const require_dist = require("../../dist-B5C4dVvO.js");
7
7
  let node_crypto = require("node:crypto");
8
8
  node_crypto = require_chunk.__toESM(node_crypto);
9
9
  let crypto$1 = require("crypto");
@@ -1,4 +1,4 @@
1
- import { _ as authProviderCapability, d as UserRecordSchema, it as BaseAddon, l as ScopedTokenSchema, r as ApiKeyRecordSchema, tt as userManagementCapability } from "../../dist-D7_69kDt.mjs";
1
+ import { _ as authProviderCapability, d as UserRecordSchema, it as BaseAddon, l as ScopedTokenSchema, r as ApiKeyRecordSchema, tt as userManagementCapability } from "../../dist-DssImRC5.mjs";
2
2
  import { createRequire } from "node:module";
3
3
  import * as crypto$1 from "node:crypto";
4
4
  import { randomUUID } from "node:crypto";
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_chunk = require("../../chunk-Cek0wNdY.js");
3
- const require_dist = require("../../dist-BNro5CRu.js");
3
+ const require_dist = require("../../dist-B5C4dVvO.js");
4
4
  let node_os = require("node:os");
5
5
  node_os = require_chunk.__toESM(node_os);
6
6
  //#region src/builtins/local-network/local-network.addon.ts
@@ -1,4 +1,4 @@
1
- import { I as localNetworkCapability, it as BaseAddon, kt as EventCategory } from "../../dist-D7_69kDt.mjs";
1
+ import { I as localNetworkCapability, it as BaseAddon, kt as EventCategory } from "../../dist-DssImRC5.mjs";
2
2
  import * as os from "node:os";
3
3
  //#region src/builtins/local-network/local-network.addon.ts
4
4
  /**
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-BNro5CRu.js");
6
+ const require_dist = require("../../dist-B5C4dVvO.js");
7
7
  let node_fs = require("node:fs");
8
8
  node_fs = require_chunk.__toESM(node_fs);
9
9
  let node_child_process = require("node:child_process");
@@ -1,4 +1,4 @@
1
- import { _t as createEvent, it as BaseAddon, kt as EventCategory, z as metricsProviderCapability } from "../../dist-D7_69kDt.mjs";
1
+ import { _t as createEvent, it as BaseAddon, kt as EventCategory, z as metricsProviderCapability } from "../../dist-DssImRC5.mjs";
2
2
  import * as fs from "node:fs";
3
3
  import { execFile, execFileSync } from "node:child_process";
4
4
  import { promisify } from "node:util";
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-BNro5CRu.js");
6
+ const require_dist = require("../../dist-B5C4dVvO.js");
7
7
  let node_fs = require("node:fs");
8
8
  node_fs = require_chunk.__toESM(node_fs);
9
9
  let node_child_process = require("node:child_process");
@@ -1,4 +1,4 @@
1
- import { W as platformProbeCapability, it as BaseAddon, kt as EventCategory, q as scoreRuntimes, rt as errMsg, yt as emitReadiness } from "../../dist-D7_69kDt.mjs";
1
+ import { W as platformProbeCapability, it as BaseAddon, kt as EventCategory, q as scoreRuntimes, rt as errMsg, yt as emitReadiness } from "../../dist-DssImRC5.mjs";
2
2
  import * as fs from "node:fs";
3
3
  import { execFile } from "node:child_process";
4
4
  import { promisify } from "node:util";
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-BNro5CRu.js");
6
+ const require_dist = require("../../dist-B5C4dVvO.js");
7
7
  //#region src/builtins/remote-access-orchestrator/enabled-providers-reconcile.ts
8
8
  /**
9
9
  * Reconcile the durable `enabledProviders` set against authoritative
@@ -1,4 +1,4 @@
1
- import { it as BaseAddon, kt as EventCategory } from "../../dist-D7_69kDt.mjs";
1
+ import { it as BaseAddon, kt as EventCategory } from "../../dist-DssImRC5.mjs";
2
2
  //#region src/builtins/remote-access-orchestrator/enabled-providers-reconcile.ts
3
3
  /**
4
4
  * Reconcile the durable `enabledProviders` set against authoritative
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-BNro5CRu.js");
6
+ const require_dist = require("../../dist-B5C4dVvO.js");
7
7
  let node_child_process = require("node:child_process");
8
8
  //#region src/builtins/snapshot/snapshot-coalescing.ts
9
9
  /**
@@ -1,4 +1,4 @@
1
- import { $ as streamQualityLabel, B as nodePin, X as snapshotCapability, ct as DeviceFeature, it as BaseAddon, rt as errMsg, ut as DeviceType } from "../../dist-D7_69kDt.mjs";
1
+ import { $ as streamQualityLabel, B as nodePin, X as snapshotCapability, ct as DeviceFeature, it as BaseAddon, rt as errMsg, ut as DeviceType } from "../../dist-DssImRC5.mjs";
2
2
  import { execFile } from "node:child_process";
3
3
  //#region src/builtins/snapshot/snapshot-coalescing.ts
4
4
  /**