@camera.ui/sdk 0.0.2 → 0.0.4

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 (76) hide show
  1. package/LICENSE.md +1 -1
  2. package/README.md +3 -3
  3. package/dist/camera/events.js +2 -0
  4. package/dist/camera/index.js +3 -1
  5. package/dist/external.js +7 -0
  6. package/dist/index.d.ts +7132 -4248
  7. package/dist/index.js +3 -11
  8. package/dist/internal/contract-validators.js +21 -0
  9. package/dist/internal/index.d.ts +915 -0
  10. package/dist/internal/index.js +9 -0
  11. package/dist/internal/sensor-triggers.js +2 -0
  12. package/dist/internal/shared-utils.js +86 -0
  13. package/dist/internal/streaming-internal.js +1 -0
  14. package/dist/manager/index.js +1 -1
  15. package/dist/observable/index.js +419 -0
  16. package/dist/plugin/api.js +21 -0
  17. package/dist/plugin/contract.js +101 -114
  18. package/dist/plugin/helper.js +277 -0
  19. package/dist/plugin/index.js +4 -1
  20. package/dist/plugin/interfaces.js +51 -1
  21. package/dist/plugin/notifier.js +23 -0
  22. package/dist/plugin/oauth.js +1 -0
  23. package/dist/sensor/audio.js +103 -81
  24. package/dist/sensor/base.js +350 -318
  25. package/dist/sensor/battery.js +73 -59
  26. package/dist/sensor/classifier.js +117 -0
  27. package/dist/sensor/clip.js +30 -0
  28. package/dist/sensor/contact.js +37 -18
  29. package/dist/sensor/detection.js +4 -0
  30. package/dist/sensor/doorbell.js +52 -38
  31. package/dist/sensor/face.js +71 -86
  32. package/dist/sensor/garage.js +121 -0
  33. package/dist/sensor/humidity.js +52 -0
  34. package/dist/sensor/index.js +17 -11
  35. package/dist/sensor/leak.js +52 -0
  36. package/dist/sensor/licensePlate.js +70 -79
  37. package/dist/sensor/light.js +82 -38
  38. package/dist/sensor/lock.js +99 -0
  39. package/dist/sensor/motion.js +85 -70
  40. package/dist/sensor/object.js +73 -94
  41. package/dist/sensor/occupancy.js +52 -0
  42. package/dist/sensor/ptz.js +114 -100
  43. package/dist/sensor/securitySystem.js +98 -0
  44. package/dist/sensor/siren.js +75 -43
  45. package/dist/sensor/smoke.js +52 -0
  46. package/dist/sensor/spec.js +1 -0
  47. package/dist/sensor/switch.js +72 -0
  48. package/dist/sensor/temperature.js +52 -0
  49. package/dist/storage/index.js +1 -2
  50. package/dist/types.js +1 -0
  51. package/docs/.vitepress/config.ts +77 -0
  52. package/docs/.vitepress/theme/index.ts +5 -0
  53. package/docs/.vitepress/theme/style.css +117 -0
  54. package/docs/index.md +16 -0
  55. package/docs/logo.png +0 -0
  56. package/docs/public/apple-touch-icon.png +0 -0
  57. package/docs/public/favicon-16.ico +0 -0
  58. package/docs/public/favicon.ico +0 -0
  59. package/docs/public/logo.svg +1 -0
  60. package/examples/README.md +7 -0
  61. package/examples/getting-started.md +535 -0
  62. package/package.json +36 -23
  63. package/scripts/build-example-docs.mjs +62 -0
  64. package/tsconfig.node.json +3 -2
  65. package/typedoc.json +42 -0
  66. package/dist/sensor/guards.js +0 -133
  67. package/dist/sensor/types.js +0 -46
  68. package/dist/service/base.js +0 -96
  69. package/dist/service/index.js +0 -3
  70. /package/dist/camera/{types.js → enums.js} +0 -0
  71. /package/dist/{manager/types.js → camera/frames.js} +0 -0
  72. /package/dist/{plugin/types.js → internal/camera-config-internal.js} +0 -0
  73. /package/dist/{service/services.js → internal/camera-enums.js} +0 -0
  74. /package/dist/{service/types.js → internal/camera-wire.js} +0 -0
  75. /package/dist/{storage/schema.js → internal/manager-rpc.js} +0 -0
  76. /package/dist/{storage/storages.js → internal/sensor-rpc.js} +0 -0
@@ -1,408 +1,467 @@
1
+ import { Subject } from '../observable/index.js';
2
+ import { isEqual } from '../internal/shared-utils.js';
1
3
  /**
2
- * Abstract Sensor Base Class
4
+ * Type of sensor. Each maps to a smart-home concept (HomeKit service).
5
+ * Plugins create sensors of these types and attach them to cameras.
6
+ */
7
+ export var SensorType;
8
+ (function (SensorType) {
9
+ // Detection Sensors — analyze frames and report detections
10
+ /** Video-based motion detection */
11
+ SensorType["Motion"] = "motion";
12
+ /** Object detection (person, vehicle, animal, etc.) */
13
+ SensorType["Object"] = "object";
14
+ /** Audio event detection (glass break, scream, etc.) */
15
+ SensorType["Audio"] = "audio";
16
+ /** Face detection and recognition */
17
+ SensorType["Face"] = "face";
18
+ /** License plate detection and OCR */
19
+ SensorType["LicensePlate"] = "licensePlate";
20
+ /** General-purpose image classifier */
21
+ SensorType["Classifier"] = "classifier";
22
+ /** CLIP embedding generation for semantic search */
23
+ SensorType["Clip"] = "clip";
24
+ // Sensors — read-only state/environment sensors
25
+ /** Contact/open-close sensor (door, window) */
26
+ SensorType["Contact"] = "contact";
27
+ /** Temperature sensor (°C) */
28
+ SensorType["Temperature"] = "temperature";
29
+ /** Humidity sensor (0–100%) */
30
+ SensorType["Humidity"] = "humidity";
31
+ /** Occupancy/presence sensor */
32
+ SensorType["Occupancy"] = "occupancy";
33
+ /** Smoke detector */
34
+ SensorType["Smoke"] = "smoke";
35
+ /** Water leak detector */
36
+ SensorType["Leak"] = "leak";
37
+ // Controls — writable sensors the user can toggle from UI
38
+ /** Light on/off and brightness control */
39
+ SensorType["Light"] = "light";
40
+ /** Siren on/off and volume control */
41
+ SensorType["Siren"] = "siren";
42
+ /** Generic on/off switch */
43
+ SensorType["Switch"] = "switch";
44
+ /** Lock/unlock control */
45
+ SensorType["Lock"] = "lock";
46
+ /** Pan-tilt-zoom camera control */
47
+ SensorType["PTZ"] = "ptz";
48
+ /** Security system arm/disarm control */
49
+ SensorType["SecuritySystem"] = "securitySystem";
50
+ /** Garage door opener */
51
+ SensorType["Garage"] = "garage";
52
+ // Triggers
53
+ /** Doorbell ring trigger */
54
+ SensorType["Doorbell"] = "doorbell";
55
+ // Info
56
+ /** Battery level and charging state */
57
+ SensorType["Battery"] = "battery";
58
+ })(SensorType || (SensorType = {}));
59
+ /**
60
+ * Categorizes a sensor's role in the system.
61
+ * Determines how the backend treats the sensor (read-only vs. controllable).
62
+ */
63
+ export var SensorCategory;
64
+ (function (SensorCategory) {
65
+ /** Read-only detection sensor (motion, object, audio, etc.) */
66
+ SensorCategory["Sensor"] = "sensor";
67
+ /** Controllable sensor with set methods (light, siren, PTZ, etc.) */
68
+ SensorCategory["Control"] = "control";
69
+ /** Event trigger (doorbell ring) */
70
+ SensorCategory["Trigger"] = "trigger";
71
+ /** Informational read-only state (battery level) */
72
+ SensorCategory["Info"] = "info";
73
+ })(SensorCategory || (SensorCategory = {}));
74
+ /**
75
+ * Abstract base class for all sensors. Plugins extend this (or use specialized
76
+ * subclasses like `MotionSensor`, `LightControl`, etc.) to implement sensor logic.
3
77
  *
4
- * Properties are intercepted via Proxy and automatically synchronized via RPC.
5
- * Plugin developers can simply write `sensor.detected = true` and the update
6
- * is automatically propagated.
78
+ * Properties are managed through a reactive proxy setting a property via `this.props`
79
+ * automatically notifies the backend and local listeners if the value changed.
7
80
  *
8
- * @template TProperties - The properties interface for this sensor
9
- * @template TStorage - The storage values interface for this sensor
10
- * @template TCapability - The capability enum type for this sensor (default: string)
81
+ * @template TProperties - Sensor-specific property interface (e.g., MotionSensorProperties)
82
+ * @template TStorage - Persistent storage schema for per-sensor config
83
+ * @template TCapability - Capability enum type (e.g., PTZCapability)
11
84
  */
12
85
  export class Sensor {
13
- /** Unique sensor ID */
86
+ name;
14
87
  id;
15
- /** Camera ID this sensor belongs to (set by addSensor) */
16
88
  _cameraId;
17
- /** Plugin ID that provides this sensor (set by addSensor) */
18
89
  _pluginId;
19
- /** Whether this sensor is online/available */
20
- _online = true;
21
- /** Internal properties storage */
22
90
  _propertiesStore;
23
- /** Proxied properties for intercepting writes */
24
- _propertiesProxy;
25
- /** Update function for RPC propagation */
26
- _updateFn;
27
- /** Property change listeners */
28
91
  _listeners = new Set();
29
- /** Simple property change listeners (for SensorLike interface) */
30
- _simpleListeners = new Set();
31
- /** Capability change listeners (for SensorLike interface) */
32
- _capabilityListeners = new Set();
33
- /** Storage instance for sensor-type-specific configuration (set by system) */
92
+ #propertyChangedSubject = new Subject();
93
+ onPropertyChanged = this.#propertyChangedSubject.asObservable();
94
+ #capabilitiesChangedSubject = new Subject();
95
+ onCapabilitiesChanged = this.#capabilitiesChangedSubject.asObservable();
96
+ /** Per-sensor persistent storage (available after sensor is added to a camera) */
34
97
  _storage;
35
- /** Sensor capabilities */
36
98
  _capabilities = [];
37
- /** Capability update function for RPC propagation */
38
99
  _capabilitiesUpdateFn;
39
- /** Whether this sensor is assigned to be the active provider for its type */
40
100
  _isAssigned = false;
41
- /** Assignment change listeners */
42
- _assignmentListeners = new Set();
43
- /**
44
- * Get schemas for sensor-type-specific configuration
45
- *
46
- * @returns An array of JsonSchema objects for configuration
47
- */
48
- schema;
49
- /**
50
- * Display name for UI, HomeKit, logs etc.
51
- * Initially equals name, can be changed by user via UI.
52
- * Persisted in sensor storage under '_displayName'.
53
- */
54
- displayName = '';
55
- /**
56
- * Whether this sensor requires video frames for detection.
57
- * - true: Frame-based detection (e.g., rust-motion, ML object detection)
58
- * - false: External detection (e.g., Ring, ONVIF events, SMTP)
59
- *
60
- * Override in subclass to specify behavior.
61
- * DetectorSensor subclasses set this to true.
101
+ #assignmentChangedSubject = new Subject();
102
+ onAssignmentChanged = this.#assignmentChangedSubject.asObservable();
103
+ _updateFn;
104
+ _displayName = '';
105
+ /** Override to provide a JSON schema for per-sensor storage settings UI */
106
+ get storageSchema() {
107
+ return [];
108
+ }
109
+ /*
110
+ * @internal
62
111
  */
63
112
  _requiresFrames;
64
- /**
65
- * Create a new sensor instance.
66
- *
67
- * Note: The id is a random UUID used for RPC namespaces.
68
- * The storage key is based on the stable 'name' property.
69
- */
70
- constructor() {
113
+ constructor(name) {
71
114
  this.id = crypto.randomUUID();
115
+ this.name = name;
72
116
  // Initialize empty storage
73
117
  this._propertiesStore = {};
74
- // Create Proxy for property interception
75
- this._propertiesProxy = new Proxy(this._propertiesStore, {
76
- set: (target, prop, value) => {
77
- const key = prop;
78
- const previousValue = target[key];
79
- // Only update if value changed
80
- if (previousValue !== value) {
81
- target[key] = value;
82
- // Fire-and-forget RPC update
83
- this._updateFn?.(prop, value);
84
- // Notify local listeners (prop is always a valid property enum value)
85
- this._notifyListeners(prop, value, previousValue);
86
- }
87
- return true;
88
- },
89
- get: (target, prop) => target[prop],
90
- });
91
- }
92
- /**
93
- * Initialize sensor with update function (called by backend)
94
- *
95
- * @param updateFn - The property update function
96
- *
97
- * @internal
98
- */
99
- _init(updateFn) {
100
- this._updateFn = updateFn;
101
118
  }
102
- /**
103
- * Set property value from external source (e.g., RPC)
104
- * This bypasses the proxy to avoid re-broadcasting
105
- *
106
- * @param property - The property name
107
- *
108
- * @param value - The new property value
109
- *
110
- * @internal
111
- */
112
- _setPropertyInternal(property, value) {
113
- const previousValue = this._propertiesStore[property];
114
- if (previousValue !== value) {
115
- this._propertiesStore[property] = value;
116
- // property is always a valid property enum value (K extends keyof TProperties)
117
- this._notifyListeners(property, value, previousValue);
118
- }
119
+ get displayName() {
120
+ return this._displayName || this.name;
119
121
  }
120
122
  /**
121
- * Get all current property values
123
+ * Set the display name (the only mutable identifier on a sensor).
122
124
  *
123
- * @internal
125
+ * @param value - Human-readable label shown in the UI.
124
126
  *
125
- * @returns The current properties
126
- */
127
- _getProperties() {
128
- return { ...this._propertiesStore };
129
- }
130
- /**
131
- * Access to proxied properties for derived classes
132
- */
133
- get props() {
134
- return this._propertiesProxy;
135
- }
136
- /**
137
- * Read-only access to raw properties (no proxy)
138
- */
139
- get rawProps() {
140
- return this._propertiesStore;
141
- }
142
- /**
143
- * Whether this sensor is online/available
127
+ * @example
128
+ * ```ts
129
+ * sensor.setDisplayName('Front Door Motion');
130
+ * ```
144
131
  */
145
- get online() {
146
- return this._online;
132
+ setDisplayName(value) {
133
+ this._displayName = value;
147
134
  }
148
- /**
149
- * Set online state
150
- *
151
- * @param online - The new online state
152
- *
153
- * @internal
154
- */
155
- _setOnline(online) {
156
- this._online = online;
135
+ /** Whether this sensor has been assigned to a camera in the backend */
136
+ get isAssigned() {
137
+ return this._isAssigned;
157
138
  }
158
- /**
159
- * Camera ID this sensor belongs to
160
- * Set automatically by CameraDevice.addSensor()
161
- */
139
+ /** Camera ID this sensor belongs to. Throws if sensor not yet added to a camera. */
162
140
  get cameraId() {
163
141
  if (!this._cameraId) {
164
142
  throw new Error('Sensor not attached to camera. Call camera.addSensor() first.');
165
143
  }
166
144
  return this._cameraId;
167
145
  }
168
- /**
169
- * Set camera ID (called by CameraDeviceProxy.addSensor)
170
- *
171
- * @param cameraId - The camera ID
172
- *
173
- * @internal
174
- */
175
- _setCameraId(cameraId) {
176
- this._cameraId = cameraId;
177
- }
178
- /**
179
- * Plugin ID that provides this sensor
180
- * Set automatically by CameraDevice.addSensor()
181
- */
182
146
  get pluginId() {
183
147
  return this._pluginId;
184
148
  }
185
- /**
186
- * Set plugin ID (called by CameraDeviceProxy.addSensor)
187
- *
188
- * @param pluginId - The plugin ID
189
- *
190
- * @internal
191
- */
192
- _setPluginId(pluginId) {
193
- this._pluginId = pluginId;
194
- }
195
- /**
196
- * Storage for sensor-type-specific configuration
197
- * Available after sensor is added via CameraDevice.addSensor()
198
- * Only available if schemas returns non-empty array
199
- */
149
+ /** Per-sensor persistent storage. Throws if sensor not yet added to a camera. */
200
150
  get storage() {
151
+ if (!this._storage) {
152
+ throw new Error('Storage not initialized - sensor not added to camera yet');
153
+ }
201
154
  return this._storage;
202
155
  }
203
- /**
204
- * Set storage instance (called by CameraDeviceProxy after addSensor)
205
- *
206
- * @param storage - The storage instance
207
- *
208
- * @internal
209
- */
210
- _setStorage(storage) {
211
- this._storage = storage;
212
- }
213
- /**
214
- * Get sensor capabilities
215
- * Returns a type-safe array of capabilities for this sensor type.
216
- */
156
+ /** Optional feature flags advertised by this sensor (e.g., PTZ pan/tilt/zoom) */
217
157
  get capabilities() {
218
158
  return this._capabilities;
219
159
  }
220
- /**
221
- * Set sensor capabilities
222
- * Updates the capabilities and broadcasts to consumers via RPC.
223
- * Capabilities are automatically deduplicated.
224
- *
225
- * @param value - The new capabilities array
226
- */
160
+ /** Set capabilities and notify the backend. Automatically deduplicates. */
227
161
  set capabilities(value) {
228
162
  // Deduplicate capabilities
229
163
  this._capabilities = [...new Set(value)];
230
164
  // Broadcast to SensorController (for RPC propagation)
231
165
  this._capabilitiesUpdateFn?.(this._capabilities);
232
166
  // Notify local listeners
233
- this._notifyCapabilityListeners();
167
+ this.#capabilitiesChangedSubject.next(this._capabilities);
168
+ }
169
+ /**
170
+ * Read-only access to the internal property store. Subclasses use this to
171
+ * read current state when implementing semantic methods (e.g., `if (this.blocked) return`).
172
+ */
173
+ get props() {
174
+ return this._propertiesStore;
175
+ }
176
+ getValue(property) {
177
+ return this._propertiesStore[property];
234
178
  }
235
179
  /**
236
- * Check if sensor has a specific capability
180
+ * Get a read-only snapshot of all property values.
237
181
  *
238
- * @param capability - The capability to check
182
+ * @returns Frozen view of every property currently held by the sensor.
239
183
  *
240
- * @returns True if the sensor has the capability
184
+ * @example
185
+ * ```ts
186
+ * const snapshot = sensor.getValues();
187
+ * console.log(snapshot);
188
+ * ```
241
189
  */
242
- hasCapability(capability) {
243
- return this._capabilities.includes(capability);
190
+ getValues() {
191
+ return { ...this._propertiesStore };
244
192
  }
245
193
  /**
246
- * Initialize capabilities update function (called by backend)
194
+ * Iterates over the partial, writes changed properties to the store, fires a **single
195
+ * batched** RPC update with the delta, and notifies local listeners per-property.
196
+ *
197
+ * Used by the semantic helper methods on each sensor type (`setOn`, `setLow`,
198
+ * `reportDetections`, etc.) — **not for plugin authors**. Plugin code should
199
+ * call the semantic helpers, not write state directly.
247
200
  *
248
- * @param updateFn - The capability update function
201
+ * One `_writeState` call → one `_updateFn` invocation. The receiver sees an
202
+ * atomic state transition for this sensor.
203
+ *
204
+ * @param partial - Partial property delta to apply to the sensor's state store.
249
205
  *
250
206
  * @internal
251
207
  */
252
- _initCapabilities(updateFn) {
253
- this._capabilitiesUpdateFn = updateFn;
208
+ _writeState(partial) {
209
+ const delta = {};
210
+ const changes = [];
211
+ for (const key of Object.keys(partial)) {
212
+ const value = partial[key];
213
+ if (value === undefined)
214
+ continue;
215
+ const previousValue = this._propertiesStore[key];
216
+ // Only update if value changed (deep compare for objects/arrays)
217
+ if (isEqual(previousValue, value, true))
218
+ continue;
219
+ this._propertiesStore[key] = value;
220
+ delta[key] = value;
221
+ changes.push({ property: key, value, previousValue });
222
+ }
223
+ if (Object.keys(delta).length === 0)
224
+ return;
225
+ // Fire-and-forget batched RPC update — one callback for the whole delta
226
+ this._updateFn?.(delta);
227
+ // Notify local listeners per-property (existing observable contract)
228
+ for (const change of changes) {
229
+ this._notifyListeners(change.property, change.value, change.previousValue);
230
+ }
254
231
  }
255
- // ========== Assignment Status ==========
256
232
  /**
257
- * Whether this sensor is assigned to be the active provider
258
- * for its sensor type on this camera.
233
+ * Helper for `reportDetections(detected, detections?)` flows.
234
+ *
235
+ * - If `detected === false` → returns `[]` (clear).
236
+ * - If `detected === true` and `detections` has items → returns them as-is.
237
+ * - If `detected === true` and `detections` is missing/empty → returns a single
238
+ * synthesized full-frame detection with the given `fallbackLabel` and any
239
+ * `fallbackExtra` fields (used for type-specific properties like `attribute`,
240
+ * `plateText`, etc.).
241
+ *
242
+ * Generic over `T extends Detection` so each sensor's `reportDetections` can
243
+ * use its specific Detection subtype.
244
+ *
245
+ * @param detected - Whether the caller is reporting an active detection.
246
+ *
247
+ * @param detections - Caller-provided detections (may be empty/undefined).
248
+ *
249
+ * @param fallbackLabel - Label used when synthesizing a fallback detection.
259
250
  *
260
- * A sensor is "assigned" when the user has selected this plugin
261
- * to provide this sensor type for the camera. Unassigned sensors
262
- * are still registered but their events are filtered out.
251
+ * @param fallbackExtra - Additional fields merged into the synthesized fallback.
263
252
  *
264
- * Use this to conditionally initialize resources:
265
- * - Only start ONVIF event subscriptions if assigned
266
- * - Only connect to external services if needed
253
+ * @returns Normalized detection list ready to write into the sensor's state.
254
+ *
255
+ * @internal
267
256
  */
268
- get isAssigned() {
269
- return this._isAssigned;
257
+ _normalizeReportedDetections(detected, detections, fallbackLabel, fallbackExtra) {
258
+ if (!detected)
259
+ return [];
260
+ if (detections && detections.length > 0)
261
+ return detections;
262
+ return [
263
+ {
264
+ label: fallbackLabel,
265
+ confidence: 1,
266
+ box: { x: 0, y: 0, width: 1, height: 1 },
267
+ ...(fallbackExtra ?? {}),
268
+ },
269
+ ];
270
+ }
271
+ hasCapability(capability) {
272
+ return this._capabilities.includes(capability);
273
+ }
274
+ /**
275
+ * Serialize this sensor to a JSON-safe object for RPC transport.
276
+ *
277
+ * @returns The wire representation used to mirror the sensor across processes.
278
+ *
279
+ * @internal
280
+ */
281
+ toJSON() {
282
+ return {
283
+ id: this.id,
284
+ type: this.type,
285
+ name: this.name,
286
+ displayName: this.displayName,
287
+ category: this.category,
288
+ cameraId: this.cameraId,
289
+ pluginId: this.pluginId,
290
+ properties: this._getProperties(),
291
+ capabilities: this._capabilities,
292
+ requiresFrames: this._requiresFrames,
293
+ };
270
294
  }
271
295
  /**
272
- * Subscribe to assignment changes.
296
+ * Lifecycle hook: the sensor just became assigned to a camera. Override to
297
+ * start background work that should only run while this sensor is live —
298
+ * polling loops, event subscriptions, timers, external connections.
299
+ *
300
+ * Called AFTER `cameraId`, `storage`, and RPC channels are wired up, so the
301
+ * override can safely access `this.cameraId`, `this.storage`, and publish
302
+ * properties via the semantic helper methods.
273
303
  *
274
- * Called when the user changes which plugin provides this sensor type.
275
- * Use this to react dynamically:
276
- * - Start/stop event loops
277
- * - Connect/disconnect from external services
304
+ * Errors thrown here are caught and logged they will NOT break assignment
305
+ * bookkeeping. If your work can fail, handle it inside the override.
278
306
  *
279
- * @param callback - Called with true when assigned, false when unassigned
307
+ * Paired 1:1 with `onDeassigned` for every `onAssigned` call there is
308
+ * exactly one matching `onDeassigned` later (on deassignment or cleanup).
280
309
  *
281
- * @returns A function to unsubscribe
310
+ * Default: no-op. Most sensors don't need lifecycle hooks.
282
311
  *
283
312
  * @example
284
- * ```typescript
285
- * sensor.onAssignmentChanged((assigned) => {
286
- * if (assigned) {
287
- * this.startEventLoop();
288
- * } else {
289
- * this.stopEventLoop();
290
- * }
291
- * });
313
+ * ```ts
314
+ * protected override async onAssigned(): Promise<void> {
315
+ * this._timer = setInterval(() => this.poll(), 5_000);
316
+ * }
292
317
  * ```
293
318
  */
294
- onAssignmentChanged(callback) {
295
- this._assignmentListeners.add(callback);
296
- return () => this._assignmentListeners.delete(callback);
297
- }
319
+ onAssigned() { }
298
320
  /**
299
- * Set assignment status (called by system)
321
+ * Lifecycle hook: the sensor is being deassigned. Override to tear down
322
+ * whatever was started in `onAssigned` — clear timers, close subscriptions,
323
+ * release external resources.
324
+ *
325
+ * Always called exactly once for each prior `onAssigned`. Also called from
326
+ * `_cleanup` if the sensor is being removed while still assigned, so you
327
+ * can rely on this as the single teardown point.
300
328
  *
301
- * @param assigned - Whether this sensor is now assigned
329
+ * Default: no-op.
330
+ *
331
+ * @example
332
+ * ```ts
333
+ * protected override onDeassigned(): void {
334
+ * if (this._timer) clearInterval(this._timer);
335
+ * }
336
+ * ```
337
+ */
338
+ onDeassigned() { }
339
+ /**
340
+ * @param updateFn - Receiver invoked with each batched property delta.
302
341
  *
303
342
  * @internal
304
343
  */
305
- _setAssigned(assigned) {
306
- if (this._isAssigned !== assigned) {
307
- this._isAssigned = assigned;
308
- for (const listener of this._assignmentListeners) {
309
- try {
310
- listener(assigned);
311
- }
312
- catch {
313
- // Ignore listener errors
314
- }
315
- }
316
- }
344
+ _init(updateFn) {
345
+ this._updateFn = updateFn;
317
346
  }
318
- // ========== SensorLike Interface Methods ==========
319
347
  /**
320
- * Get a property value by name (SensorLike interface)
348
+ * @param property - Property key to update on the internal store.
321
349
  *
322
- * @param property - The property name
350
+ * @param value - New value to assign to the property.
323
351
  *
324
- * @returns The property value or undefined if not found
352
+ * @internal
325
353
  */
326
- getPropertyValue(property) {
327
- return this._propertiesStore[property];
354
+ _setPropertyInternal(property, value) {
355
+ const previousValue = this._propertiesStore[property];
356
+ // Deep compare for objects/arrays
357
+ if (!isEqual(previousValue, value)) {
358
+ this._propertiesStore[property] = value;
359
+ // property is always a valid property enum value (K extends keyof TProperties)
360
+ this._notifyListeners(property, value, previousValue);
361
+ }
328
362
  }
329
363
  /**
330
- * Get all property values (SensorLike interface)
364
+ * @returns A shallow copy of the sensor's current property store.
331
365
  *
332
- * @returns All property values as a record
366
+ * @internal
333
367
  */
334
- getAllPropertyValues() {
368
+ _getProperties() {
335
369
  return { ...this._propertiesStore };
336
370
  }
337
371
  /**
338
- * Set a property value (SensorLike interface)
339
- * For Control sensors, this triggers the property setter through the proxy.
340
- *
341
- * @param property - The property name
372
+ * @param cameraId - ID of the camera the sensor has been attached to.
342
373
  *
343
- * @param value - The new property value
374
+ * @internal
344
375
  */
345
- async setPropertyValue(property, value) {
346
- this._propertiesProxy[property] = value;
376
+ _setCameraId(cameraId) {
377
+ this._cameraId = cameraId;
347
378
  }
348
379
  /**
349
- * Add a simple property change listener (SensorLike interface)
350
- * This is for consumers who just want property/value pairs.
380
+ * @param pluginId - Plugin ID owning this sensor.
351
381
  *
352
- * @param callback - The callback function to invoke on property changes
382
+ * @internal
383
+ */
384
+ _setPluginId(pluginId) {
385
+ this._pluginId = pluginId;
386
+ }
387
+ /**
388
+ * @param storage - Per-sensor persistent storage handle.
353
389
  *
354
- * @returns A function to remove the listener
390
+ * @internal
355
391
  */
356
- onPropertyChanged(callback) {
357
- this._simpleListeners.add(callback);
358
- return () => this._simpleListeners.delete(callback);
392
+ _setStorage(storage) {
393
+ this._storage = storage;
359
394
  }
360
395
  /**
361
- * Add a detailed property change listener
362
- * This includes full event details like previous value, timestamps, etc.
396
+ * @param updateFn - Receiver invoked with the deduplicated capability list when capabilities change.
363
397
  *
364
- * @param callback - The callback function to invoke on detailed property changes
398
+ * @internal
399
+ */
400
+ _initCapabilities(updateFn) {
401
+ this._capabilitiesUpdateFn = updateFn;
402
+ }
403
+ /**
404
+ * @param assigned - True when the sensor is assigned to a camera, false when deassigned.
365
405
  *
366
- * @returns A function to remove the listener
406
+ * @internal
367
407
  */
368
- onPropertyChangedDetailed(callback) {
369
- this._listeners.add(callback);
370
- return () => this._listeners.delete(callback);
408
+ _setAssigned(assigned) {
409
+ if (this._isAssigned === assigned)
410
+ return;
411
+ this._isAssigned = assigned;
412
+ this.#assignmentChangedSubject.next(assigned);
413
+ // Fire-and-forget the lifecycle hook. Plugin authors who need error
414
+ // handling can wrap their onAssigned/onDeassigned body in try/catch.
415
+ try {
416
+ const result = assigned ? this.onAssigned() : this.onDeassigned();
417
+ if (result && typeof result.catch === 'function') {
418
+ result.catch(() => {
419
+ // swallow — lifecycle errors must not break assignment bookkeeping
420
+ });
421
+ }
422
+ }
423
+ catch {
424
+ // swallow synchronous errors for the same reason
425
+ }
371
426
  }
372
427
  /**
373
- * Add a capability change listener (SensorLike interface)
374
- * Called when the sensor's capabilities are updated at runtime.
428
+ * @param property - Property name pushed by the backend.
375
429
  *
376
- * @param callback - The callback function to invoke on capability changes
430
+ * @param value - New value for the property.
377
431
  *
378
- * @returns A function to remove the listener
432
+ * @internal
379
433
  */
380
- onCapabilitiesChanged(callback) {
381
- this._capabilityListeners.add(callback);
382
- return () => this._capabilityListeners.delete(callback);
434
+ _onBackendPropertyChanged(property, value) {
435
+ // Update internal state (bypasses proxy to avoid re-broadcast)
436
+ this._setPropertyInternal(property, value);
383
437
  }
384
438
  /**
385
- * Notify all capability listeners of a capability change
439
+ * @internal
386
440
  */
387
- _notifyCapabilityListeners() {
388
- for (const listener of this._capabilityListeners) {
441
+ _cleanup() {
442
+ // Trigger onDeassigned if we're still assigned — guarantees the hook is
443
+ // paired 1:1 even when the sensor is force-removed without going through
444
+ // a proper deassignment path.
445
+ if (this._isAssigned) {
446
+ this._isAssigned = false;
389
447
  try {
390
- listener(this._capabilities);
448
+ const result = this.onDeassigned();
449
+ if (result && typeof result.catch === 'function') {
450
+ result.catch(() => { });
451
+ }
391
452
  }
392
453
  catch {
393
- // Ignore listener errors
454
+ // swallow
394
455
  }
395
456
  }
457
+ this._updateFn = undefined;
458
+ this._capabilitiesUpdateFn = undefined;
459
+ this._storage = undefined;
460
+ this._listeners.clear();
461
+ this.#propertyChangedSubject.complete();
462
+ this.#capabilitiesChangedSubject.complete();
463
+ this.#assignmentChangedSubject.complete();
396
464
  }
397
- /**
398
- * Notify all listeners of a property change
399
- *
400
- * @param property - The property name (must be a valid SensorPropertyType)
401
- *
402
- * @param value - The new property value
403
- *
404
- * @param previousValue - The previous property value
405
- */
406
465
  _notifyListeners(property, value, previousValue) {
407
466
  // Skip notification if sensor not yet attached to camera
408
467
  // (e.g., during constructor initialization)
@@ -427,34 +486,7 @@ export class Sensor {
427
486
  // Ignore listener errors
428
487
  }
429
488
  }
430
- // Notify simple listeners
431
- for (const listener of this._simpleListeners) {
432
- try {
433
- listener(property, value);
434
- }
435
- catch {
436
- // Ignore listener errors
437
- }
438
- }
439
- }
440
- /**
441
- * Serialize sensor for transport
442
- *
443
- * @returns The serialized sensor representation
444
- */
445
- toJSON() {
446
- return {
447
- id: this.id,
448
- type: this.type,
449
- name: this.name,
450
- displayName: this.displayName || this.name,
451
- category: this.category,
452
- cameraId: this.cameraId,
453
- pluginId: this.pluginId,
454
- online: this._online,
455
- properties: this._getProperties(),
456
- capabilities: this._capabilities,
457
- requiresFrames: this._requiresFrames,
458
- };
489
+ // Notify via Observable subject
490
+ this.#propertyChangedSubject.next({ property, value, timestamp: event.timestamp });
459
491
  }
460
492
  }