@blueharford/scrypted-spatial-awareness 0.1.0

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.
@@ -0,0 +1,367 @@
1
+ /**
2
+ * Tracking Engine
3
+ * Central orchestrator for cross-camera object tracking
4
+ */
5
+
6
+ import sdk, {
7
+ ScryptedDevice,
8
+ ObjectDetector,
9
+ ObjectsDetected,
10
+ ObjectDetectionResult,
11
+ ScryptedInterface,
12
+ EventListenerRegister,
13
+ } from '@scrypted/sdk';
14
+ import { CameraTopology, findCamera, findConnection, findConnectionsFrom } from '../models/topology';
15
+ import {
16
+ TrackedObject,
17
+ ObjectSighting,
18
+ GlobalTrackingId,
19
+ CorrelationCandidate,
20
+ getLastSighting,
21
+ } from '../models/tracked-object';
22
+ import { TrackingState } from '../state/tracking-state';
23
+ import { AlertManager } from '../alerts/alert-manager';
24
+ import { ObjectCorrelator } from './object-correlator';
25
+
26
+ const { systemManager } = sdk;
27
+
28
+ export interface TrackingEngineConfig {
29
+ /** Maximum time to wait for correlation (ms) */
30
+ correlationWindow: number;
31
+ /** Minimum confidence for automatic correlation */
32
+ correlationThreshold: number;
33
+ /** Time before marking object as 'lost' */
34
+ lostTimeout: number;
35
+ /** Enable visual embedding matching */
36
+ useVisualMatching: boolean;
37
+ }
38
+
39
+ export class TrackingEngine {
40
+ private topology: CameraTopology;
41
+ private state: TrackingState;
42
+ private alertManager: AlertManager;
43
+ private config: TrackingEngineConfig;
44
+ private console: Console;
45
+ private correlator: ObjectCorrelator;
46
+ private listeners: Map<string, EventListenerRegister> = new Map();
47
+ private pendingTimers: Map<GlobalTrackingId, NodeJS.Timeout> = new Map();
48
+ private lostCheckInterval: NodeJS.Timeout | null = null;
49
+
50
+ constructor(
51
+ topology: CameraTopology,
52
+ state: TrackingState,
53
+ alertManager: AlertManager,
54
+ config: TrackingEngineConfig,
55
+ console: Console
56
+ ) {
57
+ this.topology = topology;
58
+ this.state = state;
59
+ this.alertManager = alertManager;
60
+ this.config = config;
61
+ this.console = console;
62
+ this.correlator = new ObjectCorrelator(topology, config);
63
+ }
64
+
65
+ /** Start listening to all cameras in topology */
66
+ async startTracking(): Promise<void> {
67
+ this.console.log('Starting tracking engine...');
68
+
69
+ // Stop any existing listeners
70
+ await this.stopTracking();
71
+
72
+ // Subscribe to each camera's object detection events
73
+ for (const camera of this.topology.cameras) {
74
+ try {
75
+ const device = systemManager.getDeviceById<ObjectDetector>(camera.deviceId);
76
+ if (!device) {
77
+ this.console.warn(`Camera not found: ${camera.deviceId} (${camera.name})`);
78
+ continue;
79
+ }
80
+
81
+ // Check if device has ObjectDetector interface
82
+ if (!device.interfaces?.includes(ScryptedInterface.ObjectDetector)) {
83
+ this.console.warn(`Camera ${camera.name} does not support object detection`);
84
+ continue;
85
+ }
86
+
87
+ const listener = device.listen(ScryptedInterface.ObjectDetector, (source, details, data) => {
88
+ this.handleDetection(camera.deviceId, camera.name, data as ObjectsDetected);
89
+ });
90
+
91
+ this.listeners.set(camera.deviceId, listener);
92
+ this.console.log(`Listening to camera: ${camera.name}`);
93
+ } catch (e) {
94
+ this.console.error(`Failed to subscribe to camera ${camera.name}:`, e);
95
+ }
96
+ }
97
+
98
+ // Start periodic check for lost objects
99
+ this.lostCheckInterval = setInterval(() => {
100
+ this.checkForLostObjects();
101
+ }, 30000); // Check every 30 seconds
102
+
103
+ this.console.log(`Tracking engine started with ${this.listeners.size} cameras`);
104
+ }
105
+
106
+ /** Stop all camera listeners */
107
+ async stopTracking(): Promise<void> {
108
+ // Remove all event listeners
109
+ for (const [cameraId, listener] of this.listeners.entries()) {
110
+ try {
111
+ listener.removeListener();
112
+ } catch (e) {
113
+ this.console.error(`Failed to remove listener for ${cameraId}:`, e);
114
+ }
115
+ }
116
+ this.listeners.clear();
117
+
118
+ // Clear pending timers
119
+ for (const timer of this.pendingTimers.values()) {
120
+ clearTimeout(timer);
121
+ }
122
+ this.pendingTimers.clear();
123
+
124
+ // Stop lost check interval
125
+ if (this.lostCheckInterval) {
126
+ clearInterval(this.lostCheckInterval);
127
+ this.lostCheckInterval = null;
128
+ }
129
+
130
+ this.console.log('Tracking engine stopped');
131
+ }
132
+
133
+ /** Handle detection event from a camera */
134
+ private async handleDetection(
135
+ cameraId: string,
136
+ cameraName: string,
137
+ detected: ObjectsDetected
138
+ ): Promise<void> {
139
+ if (!detected.detections || detected.detections.length === 0) {
140
+ return;
141
+ }
142
+
143
+ const camera = findCamera(this.topology, cameraId);
144
+ if (!camera) {
145
+ return;
146
+ }
147
+
148
+ const timestamp = detected.timestamp || Date.now();
149
+
150
+ for (const detection of detected.detections) {
151
+ // Skip low-confidence detections
152
+ if (detection.score < 0.5) continue;
153
+
154
+ // Skip classes we're not tracking on this camera
155
+ if (camera.trackClasses.length > 0 &&
156
+ !camera.trackClasses.includes(detection.className)) {
157
+ continue;
158
+ }
159
+
160
+ // Create sighting object
161
+ const sighting: ObjectSighting = {
162
+ detection,
163
+ cameraId,
164
+ cameraName,
165
+ timestamp,
166
+ confidence: detection.score,
167
+ embedding: detection.embedding,
168
+ detectionId: detected.detectionId,
169
+ position: detection.boundingBox ? {
170
+ x: (detection.boundingBox[0] + detection.boundingBox[2] / 2) / 100,
171
+ y: (detection.boundingBox[1] + detection.boundingBox[3] / 2) / 100,
172
+ } : undefined,
173
+ };
174
+
175
+ await this.processSighting(sighting, camera.isEntryPoint, camera.isExitPoint);
176
+ }
177
+ }
178
+
179
+ /** Process a single sighting */
180
+ private async processSighting(
181
+ sighting: ObjectSighting,
182
+ isEntryPoint: boolean,
183
+ isExitPoint: boolean
184
+ ): Promise<void> {
185
+ // Try to correlate with existing tracked objects
186
+ const correlation = await this.correlateDetection(sighting);
187
+
188
+ if (correlation) {
189
+ // Matched to existing object
190
+ const tracked = correlation.trackedObject;
191
+
192
+ // Check if this is a cross-camera transition
193
+ const lastSighting = getLastSighting(tracked);
194
+ if (lastSighting && lastSighting.cameraId !== sighting.cameraId) {
195
+ // Add journey segment
196
+ this.state.addJourney(tracked.globalId, {
197
+ fromCameraId: lastSighting.cameraId,
198
+ fromCameraName: lastSighting.cameraName,
199
+ toCameraId: sighting.cameraId,
200
+ toCameraName: sighting.cameraName,
201
+ exitTime: lastSighting.timestamp,
202
+ entryTime: sighting.timestamp,
203
+ transitDuration: sighting.timestamp - lastSighting.timestamp,
204
+ correlationConfidence: correlation.confidence,
205
+ });
206
+
207
+ this.console.log(
208
+ `Object ${tracked.globalId.slice(0, 8)} transited: ` +
209
+ `${lastSighting.cameraName} → ${sighting.cameraName} ` +
210
+ `(confidence: ${(correlation.confidence * 100).toFixed(0)}%)`
211
+ );
212
+ }
213
+
214
+ // Add sighting to tracked object
215
+ this.state.addSighting(tracked.globalId, sighting);
216
+
217
+ // Cancel any pending lost timer
218
+ const pendingTimer = this.pendingTimers.get(tracked.globalId);
219
+ if (pendingTimer) {
220
+ clearTimeout(pendingTimer);
221
+ this.pendingTimers.delete(tracked.globalId);
222
+ }
223
+
224
+ // Reactivate if was pending
225
+ this.state.reactivate(tracked.globalId);
226
+
227
+ // Check for exit
228
+ if (isExitPoint && this.isLeavingFrame(sighting)) {
229
+ this.handlePotentialExit(tracked, sighting);
230
+ }
231
+ } else {
232
+ // New object - create tracking entry
233
+ const globalId = this.state.generateId();
234
+ const tracked = this.state.createObject(globalId, sighting, isEntryPoint);
235
+
236
+ this.console.log(
237
+ `New ${sighting.detection.className} detected on ${sighting.cameraName} ` +
238
+ `(ID: ${globalId.slice(0, 8)})`
239
+ );
240
+
241
+ // Generate entry alert if this is an entry point
242
+ if (isEntryPoint) {
243
+ await this.alertManager.checkAndAlert('property_entry', tracked, {
244
+ cameraId: sighting.cameraId,
245
+ cameraName: sighting.cameraName,
246
+ objectClass: sighting.detection.className,
247
+ objectLabel: sighting.detection.label,
248
+ detectionId: sighting.detectionId,
249
+ });
250
+ }
251
+ }
252
+ }
253
+
254
+ /** Attempt to correlate a sighting with existing tracked objects */
255
+ private async correlateDetection(
256
+ sighting: ObjectSighting
257
+ ): Promise<CorrelationCandidate | null> {
258
+ const activeObjects = this.state.getActiveObjects();
259
+ if (activeObjects.length === 0) return null;
260
+
261
+ // First, check for same-camera tracking (using detection ID)
262
+ for (const tracked of activeObjects) {
263
+ const lastSighting = getLastSighting(tracked);
264
+ if (lastSighting &&
265
+ lastSighting.cameraId === sighting.cameraId &&
266
+ lastSighting.detection.id === sighting.detection.id) {
267
+ // Same object on same camera (continuing track)
268
+ return {
269
+ trackedObject: tracked,
270
+ newSighting: sighting,
271
+ confidence: 1.0,
272
+ factors: { timing: 1, visual: 1, spatial: 1, class: 1 },
273
+ };
274
+ }
275
+ }
276
+
277
+ // Check for cross-camera correlation
278
+ const candidate = await this.correlator.findBestMatch(sighting, activeObjects);
279
+ return candidate;
280
+ }
281
+
282
+ /** Check if a detection is leaving the camera frame */
283
+ private isLeavingFrame(sighting: ObjectSighting): boolean {
284
+ if (!sighting.position) return false;
285
+
286
+ // Consider leaving if near edge of frame
287
+ const edgeThreshold = 0.1; // 10% from edge
288
+ return (
289
+ sighting.position.x < edgeThreshold ||
290
+ sighting.position.x > (1 - edgeThreshold) ||
291
+ sighting.position.y < edgeThreshold ||
292
+ sighting.position.y > (1 - edgeThreshold)
293
+ );
294
+ }
295
+
296
+ /** Handle potential exit from property */
297
+ private handlePotentialExit(tracked: TrackedObject, sighting: ObjectSighting): void {
298
+ // Mark as pending and set timer
299
+ this.state.markPending(tracked.globalId);
300
+
301
+ // Wait for correlation window before marking as exited
302
+ const timer = setTimeout(async () => {
303
+ const current = this.state.getObject(tracked.globalId);
304
+ if (current && current.state === 'pending') {
305
+ this.state.markExited(tracked.globalId, sighting.cameraId, sighting.cameraName);
306
+
307
+ this.console.log(
308
+ `Object ${tracked.globalId.slice(0, 8)} exited via ${sighting.cameraName}`
309
+ );
310
+
311
+ await this.alertManager.checkAndAlert('property_exit', current, {
312
+ cameraId: sighting.cameraId,
313
+ cameraName: sighting.cameraName,
314
+ objectClass: current.className,
315
+ objectLabel: current.label,
316
+ });
317
+ }
318
+ this.pendingTimers.delete(tracked.globalId);
319
+ }, this.config.correlationWindow);
320
+
321
+ this.pendingTimers.set(tracked.globalId, timer);
322
+ }
323
+
324
+ /** Check for objects that haven't been seen recently */
325
+ private checkForLostObjects(): void {
326
+ const now = Date.now();
327
+ const activeObjects = this.state.getActiveObjects();
328
+
329
+ for (const tracked of activeObjects) {
330
+ const timeSinceSeen = now - tracked.lastSeen;
331
+
332
+ if (timeSinceSeen > this.config.lostTimeout) {
333
+ this.state.markLost(tracked.globalId);
334
+ this.console.log(
335
+ `Object ${tracked.globalId.slice(0, 8)} marked as lost ` +
336
+ `(not seen for ${Math.round(timeSinceSeen / 1000)}s)`
337
+ );
338
+
339
+ this.alertManager.checkAndAlert('lost_tracking', tracked, {
340
+ objectClass: tracked.className,
341
+ objectLabel: tracked.label,
342
+ });
343
+ }
344
+ }
345
+ }
346
+
347
+ /** Update topology configuration */
348
+ updateTopology(topology: CameraTopology): void {
349
+ this.topology = topology;
350
+ this.correlator = new ObjectCorrelator(topology, this.config);
351
+ }
352
+
353
+ /** Get current topology */
354
+ getTopology(): CameraTopology {
355
+ return this.topology;
356
+ }
357
+
358
+ /** Get all currently tracked objects */
359
+ getTrackedObjects(): TrackedObject[] {
360
+ return this.state.getAllObjects();
361
+ }
362
+
363
+ /** Get tracked object by ID */
364
+ getTrackedObject(globalId: GlobalTrackingId): TrackedObject | undefined {
365
+ return this.state.getObject(globalId);
366
+ }
367
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Global Tracker Sensor
3
+ * Main hub device that provides property-wide occupancy tracking
4
+ */
5
+
6
+ import {
7
+ OccupancySensor,
8
+ ScryptedDeviceBase,
9
+ Settings,
10
+ Setting,
11
+ Readme,
12
+ ScryptedNativeId,
13
+ } from '@scrypted/sdk';
14
+ import { TrackingState } from '../state/tracking-state';
15
+ import { TrackedObject, getJourneySummary, calculateDwellTime } from '../models/tracked-object';
16
+
17
+ export class GlobalTrackerSensor extends ScryptedDeviceBase
18
+ implements OccupancySensor, Settings, Readme {
19
+
20
+ private trackingState: TrackingState;
21
+ private plugin: any;
22
+
23
+ constructor(plugin: any, nativeId: ScryptedNativeId, trackingState: TrackingState) {
24
+ super(nativeId);
25
+ this.plugin = plugin;
26
+ this.trackingState = trackingState;
27
+
28
+ // Update occupancy when tracking state changes
29
+ trackingState.onStateChange(() => this.updateOccupancy());
30
+
31
+ // Initial update
32
+ this.updateOccupancy();
33
+ }
34
+
35
+ /**
36
+ * Update the occupied state based on active tracked objects
37
+ */
38
+ private updateOccupancy(): void {
39
+ const activeCount = this.trackingState.getActiveCount();
40
+ this.occupied = activeCount > 0;
41
+ }
42
+
43
+ // ==================== Settings Implementation ====================
44
+
45
+ async getSettings(): Promise<Setting[]> {
46
+ const active = this.trackingState.getActiveObjects();
47
+ const all = this.trackingState.getAllObjects();
48
+ const settings: Setting[] = [];
49
+
50
+ // Summary stats
51
+ settings.push({
52
+ key: 'activeCount',
53
+ title: 'Currently Active',
54
+ type: 'string',
55
+ readonly: true,
56
+ value: `${active.length} object${active.length !== 1 ? 's' : ''}`,
57
+ group: 'Status',
58
+ });
59
+
60
+ settings.push({
61
+ key: 'totalTracked',
62
+ title: 'Total Tracked (24h)',
63
+ type: 'string',
64
+ readonly: true,
65
+ value: `${all.length} object${all.length !== 1 ? 's' : ''}`,
66
+ group: 'Status',
67
+ });
68
+
69
+ // Active objects list
70
+ if (active.length > 0) {
71
+ settings.push({
72
+ key: 'activeObjectsHeader',
73
+ title: 'Active Objects',
74
+ type: 'html',
75
+ value: '<h4 style="margin: 0;">Currently Tracked</h4>',
76
+ group: 'Active Objects',
77
+ });
78
+
79
+ for (const obj of active.slice(0, 10)) {
80
+ const dwellTime = calculateDwellTime(obj);
81
+ const dwellMinutes = Math.round(dwellTime / 60000);
82
+
83
+ settings.push({
84
+ key: `active-${obj.globalId}`,
85
+ title: `${obj.className}${obj.label ? ` (${obj.label})` : ''}`,
86
+ type: 'string',
87
+ readonly: true,
88
+ value: `Cameras: ${obj.activeOnCameras.join(', ')} | Time: ${dwellMinutes}m`,
89
+ group: 'Active Objects',
90
+ });
91
+ }
92
+
93
+ if (active.length > 10) {
94
+ settings.push({
95
+ key: 'moreActive',
96
+ title: 'More',
97
+ type: 'string',
98
+ readonly: true,
99
+ value: `...and ${active.length - 10} more`,
100
+ group: 'Active Objects',
101
+ });
102
+ }
103
+ }
104
+
105
+ // Recent exits
106
+ const recentExits = all
107
+ .filter(o => o.state === 'exited')
108
+ .sort((a, b) => b.lastSeen - a.lastSeen)
109
+ .slice(0, 5);
110
+
111
+ if (recentExits.length > 0) {
112
+ settings.push({
113
+ key: 'recentExitsHeader',
114
+ title: 'Recent Exits',
115
+ type: 'html',
116
+ value: '<h4 style="margin: 0;">Recently Exited</h4>',
117
+ group: 'Recent Activity',
118
+ });
119
+
120
+ for (const obj of recentExits) {
121
+ const exitTime = new Date(obj.lastSeen).toLocaleTimeString();
122
+ const journey = getJourneySummary(obj);
123
+
124
+ settings.push({
125
+ key: `exit-${obj.globalId}`,
126
+ title: `${obj.className} at ${exitTime}`,
127
+ type: 'string',
128
+ readonly: true,
129
+ value: journey || 'No journey recorded',
130
+ group: 'Recent Activity',
131
+ });
132
+ }
133
+ }
134
+
135
+ return settings;
136
+ }
137
+
138
+ async putSetting(key: string, value: any): Promise<void> {
139
+ // No editable settings
140
+ }
141
+
142
+ // ==================== Readme Implementation ====================
143
+
144
+ async getReadmeMarkdown(): Promise<string> {
145
+ const active = this.trackingState.getActiveObjects();
146
+ const all = this.trackingState.getAllObjects();
147
+
148
+ // Generate stats
149
+ const personCount = active.filter(o => o.className === 'person').length;
150
+ const vehicleCount = active.filter(o => ['car', 'vehicle', 'truck'].includes(o.className)).length;
151
+ const animalCount = active.filter(o => o.className === 'animal').length;
152
+
153
+ let activeBreakdown = '';
154
+ if (personCount > 0) activeBreakdown += `- People: ${personCount}\n`;
155
+ if (vehicleCount > 0) activeBreakdown += `- Vehicles: ${vehicleCount}\n`;
156
+ if (animalCount > 0) activeBreakdown += `- Animals: ${animalCount}\n`;
157
+
158
+ return `
159
+ # Global Object Tracker
160
+
161
+ This sensor tracks the presence of objects across all connected cameras in your property.
162
+
163
+ ## Current Status
164
+
165
+ **Occupied**: ${this.occupied ? 'Yes' : 'No'}
166
+
167
+ **Active Objects**: ${active.length}
168
+ ${activeBreakdown || '- None currently'}
169
+
170
+ **Total Tracked (24h)**: ${all.length}
171
+
172
+ ## How It Works
173
+
174
+ The Global Tracker combines detection events from all configured cameras to maintain a unified view of objects on your property. When an object moves from one camera's view to another, the system correlates these sightings to track the object's journey.
175
+
176
+ ## States
177
+
178
+ - **Active**: Object is currently visible on camera
179
+ - **Pending**: Object left camera view, waiting for correlation
180
+ - **Exited**: Object left the property
181
+ - **Lost**: Object disappeared without exiting (timeout)
182
+
183
+ ## Integration
184
+
185
+ This sensor can be used in automations:
186
+ - Trigger actions when property becomes occupied/unoccupied
187
+ - Create presence-based automations
188
+ - Monitor overall property activity
189
+ `;
190
+ }
191
+ }