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