@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.
package/src/main.ts ADDED
@@ -0,0 +1,690 @@
1
+ import sdk, {
2
+ DeviceProvider,
3
+ DeviceCreator,
4
+ DeviceCreatorSettings,
5
+ Settings,
6
+ Setting,
7
+ SettingValue,
8
+ ScryptedDeviceBase,
9
+ ScryptedDeviceType,
10
+ ScryptedInterface,
11
+ ScryptedNativeId,
12
+ HttpRequestHandler,
13
+ HttpRequest,
14
+ HttpResponse,
15
+ Readme,
16
+ } from '@scrypted/sdk';
17
+ import { StorageSettings } from '@scrypted/sdk/storage-settings';
18
+ import { CameraTopology, createEmptyTopology } from './models/topology';
19
+ import { TrackedObject } from './models/tracked-object';
20
+ import { Alert, AlertRule, createDefaultRules } from './models/alert';
21
+ import { TrackingState } from './state/tracking-state';
22
+ import { TrackingEngine, TrackingEngineConfig } from './core/tracking-engine';
23
+ import { AlertManager } from './alerts/alert-manager';
24
+ import { GlobalTrackerSensor } from './devices/global-tracker-sensor';
25
+ import { TrackingZone } from './devices/tracking-zone';
26
+ import { MqttPublisher, MqttConfig } from './integrations/mqtt-publisher';
27
+ import * as fs from 'fs';
28
+ import * as path from 'path';
29
+
30
+ const { deviceManager, systemManager } = sdk;
31
+
32
+ const TRACKING_ZONE_PREFIX = 'tracking-zone:';
33
+ const GLOBAL_TRACKER_ID = 'global-tracker';
34
+
35
+ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
36
+ implements DeviceProvider, DeviceCreator, Settings, HttpRequestHandler, Readme {
37
+
38
+ private trackingEngine: TrackingEngine | null = null;
39
+ private trackingState: TrackingState;
40
+ private alertManager: AlertManager;
41
+ private mqttPublisher: MqttPublisher | null = null;
42
+ private devices: Map<string, any> = new Map();
43
+
44
+ storageSettings = new StorageSettings(this, {
45
+ // Topology Configuration (stored as JSON)
46
+ topology: {
47
+ title: 'Camera Topology',
48
+ type: 'string',
49
+ description: 'JSON configuration of camera relationships',
50
+ hide: true,
51
+ },
52
+
53
+ // Floor plan image (stored as base64)
54
+ floorPlanImage: {
55
+ title: 'Floor Plan Image',
56
+ type: 'string',
57
+ hide: true,
58
+ },
59
+
60
+ // Correlation Settings
61
+ correlationWindow: {
62
+ title: 'Correlation Window (seconds)',
63
+ type: 'number',
64
+ defaultValue: 30,
65
+ description: 'Maximum time to wait for an object to appear on connected camera',
66
+ group: 'Tracking',
67
+ },
68
+ correlationThreshold: {
69
+ title: 'Correlation Confidence Threshold',
70
+ type: 'number',
71
+ defaultValue: 0.6,
72
+ description: 'Minimum confidence (0-1) for automatic object correlation',
73
+ group: 'Tracking',
74
+ },
75
+ lostTimeout: {
76
+ title: 'Lost Object Timeout (seconds)',
77
+ type: 'number',
78
+ defaultValue: 300,
79
+ description: 'Time before marking a tracked object as lost',
80
+ group: 'Tracking',
81
+ },
82
+ useVisualMatching: {
83
+ title: 'Use Visual Matching',
84
+ type: 'boolean',
85
+ defaultValue: true,
86
+ description: 'Use visual embeddings for object correlation (requires compatible detectors)',
87
+ group: 'Tracking',
88
+ },
89
+
90
+ // MQTT Settings
91
+ enableMqtt: {
92
+ title: 'Enable MQTT',
93
+ type: 'boolean',
94
+ defaultValue: false,
95
+ group: 'MQTT Integration',
96
+ },
97
+ mqttBroker: {
98
+ title: 'MQTT Broker URL',
99
+ type: 'string',
100
+ placeholder: 'mqtt://localhost:1883',
101
+ group: 'MQTT Integration',
102
+ },
103
+ mqttUsername: {
104
+ title: 'MQTT Username',
105
+ type: 'string',
106
+ group: 'MQTT Integration',
107
+ },
108
+ mqttPassword: {
109
+ title: 'MQTT Password',
110
+ type: 'password',
111
+ group: 'MQTT Integration',
112
+ },
113
+ mqttBaseTopic: {
114
+ title: 'MQTT Base Topic',
115
+ type: 'string',
116
+ defaultValue: 'scrypted/spatial-awareness',
117
+ group: 'MQTT Integration',
118
+ },
119
+
120
+ // Alert Settings
121
+ enableAlerts: {
122
+ title: 'Enable Alerts',
123
+ type: 'boolean',
124
+ defaultValue: true,
125
+ group: 'Alerts',
126
+ },
127
+ defaultNotifier: {
128
+ title: 'Default Notifier',
129
+ type: 'device',
130
+ deviceFilter: `interfaces.includes('${ScryptedInterface.Notifier}')`,
131
+ group: 'Alerts',
132
+ },
133
+
134
+ // Tracked Cameras
135
+ trackedCameras: {
136
+ title: 'Cameras to Track',
137
+ type: 'device',
138
+ multiple: true,
139
+ deviceFilter: `interfaces.includes('${ScryptedInterface.ObjectDetector}')`,
140
+ group: 'Cameras',
141
+ description: 'Select cameras with object detection to track',
142
+ },
143
+
144
+ // Alert Rules (stored as JSON)
145
+ alertRules: {
146
+ title: 'Alert Rules',
147
+ type: 'string',
148
+ hide: true,
149
+ },
150
+ });
151
+
152
+ constructor(nativeId?: ScryptedNativeId) {
153
+ super(nativeId);
154
+
155
+ this.trackingState = new TrackingState(this.storage, this.console);
156
+ this.alertManager = new AlertManager(this.console, this.storage);
157
+
158
+ // Initialize on next tick to allow Scrypted to fully load
159
+ process.nextTick(() => this.initialize());
160
+ }
161
+
162
+ private async initialize(): Promise<void> {
163
+ this.console.log('Initializing Spatial Awareness Plugin');
164
+
165
+ // Discover the global tracker device
166
+ await deviceManager.onDeviceDiscovered({
167
+ nativeId: GLOBAL_TRACKER_ID,
168
+ name: 'Global Object Tracker',
169
+ type: ScryptedDeviceType.Sensor,
170
+ interfaces: [
171
+ ScryptedInterface.OccupancySensor,
172
+ ScryptedInterface.Settings,
173
+ ScryptedInterface.Readme,
174
+ ],
175
+ });
176
+
177
+ // Load topology if it exists
178
+ const topologyJson = this.storage.getItem('topology');
179
+ if (topologyJson) {
180
+ try {
181
+ const topology = JSON.parse(topologyJson) as CameraTopology;
182
+ await this.startTrackingEngine(topology);
183
+ } catch (e) {
184
+ this.console.error('Failed to parse topology:', e);
185
+ }
186
+ }
187
+
188
+ // Load alert rules
189
+ const rulesJson = this.storage.getItem('alertRules');
190
+ if (rulesJson) {
191
+ try {
192
+ const rules = JSON.parse(rulesJson) as AlertRule[];
193
+ this.alertManager.setRules(rules);
194
+ } catch (e) {
195
+ this.console.error('Failed to parse alert rules:', e);
196
+ this.alertManager.setRules(createDefaultRules());
197
+ }
198
+ } else {
199
+ this.alertManager.setRules(createDefaultRules());
200
+ }
201
+
202
+ // Initialize MQTT if enabled
203
+ if (this.storageSettings.values.enableMqtt) {
204
+ await this.initializeMqtt();
205
+ }
206
+
207
+ this.console.log('Spatial Awareness Plugin initialized');
208
+ }
209
+
210
+ private async initializeMqtt(): Promise<void> {
211
+ const broker = this.storageSettings.values.mqttBroker as string;
212
+ if (!broker) {
213
+ this.console.warn('MQTT enabled but no broker URL configured');
214
+ return;
215
+ }
216
+
217
+ const config: MqttConfig = {
218
+ broker,
219
+ username: this.storageSettings.values.mqttUsername as string,
220
+ password: this.storageSettings.values.mqttPassword as string,
221
+ baseTopic: this.storageSettings.values.mqttBaseTopic as string || 'scrypted/spatial-awareness',
222
+ };
223
+
224
+ this.mqttPublisher = new MqttPublisher(config, this.console);
225
+ await this.mqttPublisher.connect();
226
+
227
+ // Subscribe to state changes
228
+ this.trackingState.onStateChange((objects) => {
229
+ this.mqttPublisher?.publishState(objects);
230
+ });
231
+
232
+ this.console.log('MQTT publisher initialized');
233
+ }
234
+
235
+ private async startTrackingEngine(topology: CameraTopology): Promise<void> {
236
+ // Stop existing engine if running
237
+ if (this.trackingEngine) {
238
+ await this.trackingEngine.stopTracking();
239
+ }
240
+
241
+ const config: TrackingEngineConfig = {
242
+ correlationWindow: (this.storageSettings.values.correlationWindow as number || 30) * 1000,
243
+ correlationThreshold: this.storageSettings.values.correlationThreshold as number || 0.6,
244
+ lostTimeout: (this.storageSettings.values.lostTimeout as number || 300) * 1000,
245
+ useVisualMatching: this.storageSettings.values.useVisualMatching as boolean ?? true,
246
+ };
247
+
248
+ this.trackingEngine = new TrackingEngine(
249
+ topology,
250
+ this.trackingState,
251
+ this.alertManager,
252
+ config,
253
+ this.console
254
+ );
255
+
256
+ await this.trackingEngine.startTracking();
257
+ this.console.log('Tracking engine started');
258
+ }
259
+
260
+ // ==================== DeviceProvider Implementation ====================
261
+
262
+ async getDevice(nativeId: string): Promise<any> {
263
+ let device = this.devices.get(nativeId);
264
+
265
+ if (!device) {
266
+ if (nativeId === GLOBAL_TRACKER_ID) {
267
+ device = new GlobalTrackerSensor(this, nativeId, this.trackingState);
268
+ } else if (nativeId.startsWith(TRACKING_ZONE_PREFIX)) {
269
+ device = new TrackingZone(this, nativeId, this.trackingState);
270
+ }
271
+
272
+ if (device) {
273
+ this.devices.set(nativeId, device);
274
+ }
275
+ }
276
+
277
+ return device;
278
+ }
279
+
280
+ async releaseDevice(id: string, nativeId: string): Promise<void> {
281
+ this.devices.delete(nativeId);
282
+ }
283
+
284
+ // ==================== DeviceCreator Implementation ====================
285
+
286
+ async getCreateDeviceSettings(): Promise<Setting[]> {
287
+ return [
288
+ {
289
+ key: 'name',
290
+ title: 'Zone Name',
291
+ description: 'Name for this tracking zone',
292
+ type: 'string',
293
+ },
294
+ {
295
+ key: 'type',
296
+ title: 'Zone Type',
297
+ type: 'string',
298
+ choices: ['entry', 'exit', 'dwell', 'restricted'],
299
+ value: 'entry',
300
+ },
301
+ {
302
+ key: 'cameras',
303
+ title: 'Cameras',
304
+ type: 'device',
305
+ multiple: true,
306
+ deviceFilter: `interfaces.includes('${ScryptedInterface.ObjectDetector}')`,
307
+ },
308
+ ];
309
+ }
310
+
311
+ async createDevice(settings: DeviceCreatorSettings): Promise<string> {
312
+ const nativeId = TRACKING_ZONE_PREFIX + Date.now().toString();
313
+
314
+ await deviceManager.onDeviceDiscovered({
315
+ nativeId,
316
+ name: (settings.name as string) || 'Tracking Zone',
317
+ type: ScryptedDeviceType.Sensor,
318
+ interfaces: [
319
+ ScryptedInterface.OccupancySensor,
320
+ ScryptedInterface.MotionSensor,
321
+ ScryptedInterface.Settings,
322
+ ],
323
+ });
324
+
325
+ // Store zone configuration
326
+ this.storage.setItem(`zone:${nativeId}`, JSON.stringify({
327
+ type: settings.type,
328
+ cameras: settings.cameras,
329
+ }));
330
+
331
+ return nativeId;
332
+ }
333
+
334
+ // ==================== Settings Implementation ====================
335
+
336
+ async getSettings(): Promise<Setting[]> {
337
+ const settings = await this.storageSettings.getSettings();
338
+
339
+ // Add topology editor button
340
+ settings.push({
341
+ key: 'openTopologyEditor',
342
+ title: 'Open Topology Editor',
343
+ type: 'button',
344
+ description: 'Configure camera relationships and transit paths with visual floor plan',
345
+ group: 'Cameras',
346
+ });
347
+
348
+ // Add status display
349
+ const activeCount = this.trackingState.getActiveCount();
350
+ settings.push({
351
+ key: 'status',
352
+ title: 'Tracking Status',
353
+ type: 'string',
354
+ readonly: true,
355
+ value: this.trackingEngine
356
+ ? `Active: Tracking ${activeCount} object${activeCount !== 1 ? 's' : ''}`
357
+ : 'Not configured - add cameras and configure topology',
358
+ group: 'Status',
359
+ });
360
+
361
+ // Add recent alerts summary
362
+ const recentAlerts = this.alertManager.getRecentAlerts(5);
363
+ if (recentAlerts.length > 0) {
364
+ settings.push({
365
+ key: 'recentAlerts',
366
+ title: 'Recent Alerts',
367
+ type: 'string',
368
+ readonly: true,
369
+ value: recentAlerts.map(a => `${a.type}: ${a.message}`).join('\n'),
370
+ group: 'Status',
371
+ });
372
+ }
373
+
374
+ return settings;
375
+ }
376
+
377
+ async putSetting(key: string, value: SettingValue): Promise<void> {
378
+ if (key === 'openTopologyEditor') {
379
+ // The UI will handle opening the editor via HTTP
380
+ this.console.log('Topology editor requested - access via plugin HTTP endpoint');
381
+ return;
382
+ }
383
+
384
+ await this.storageSettings.putSetting(key, value);
385
+
386
+ // Handle setting changes that require engine restart
387
+ if (
388
+ key === 'trackedCameras' ||
389
+ key === 'correlationWindow' ||
390
+ key === 'correlationThreshold' ||
391
+ key === 'lostTimeout' ||
392
+ key === 'useVisualMatching'
393
+ ) {
394
+ const topologyJson = this.storage.getItem('topology');
395
+ if (topologyJson) {
396
+ try {
397
+ const topology = JSON.parse(topologyJson) as CameraTopology;
398
+ await this.startTrackingEngine(topology);
399
+ } catch (e) {
400
+ this.console.error('Failed to restart tracking engine:', e);
401
+ }
402
+ }
403
+ }
404
+
405
+ // Handle MQTT setting changes
406
+ if (key === 'enableMqtt' || key === 'mqttBroker' || key === 'mqttUsername' ||
407
+ key === 'mqttPassword' || key === 'mqttBaseTopic') {
408
+ if (this.mqttPublisher) {
409
+ this.mqttPublisher.disconnect();
410
+ this.mqttPublisher = null;
411
+ }
412
+ if (this.storageSettings.values.enableMqtt) {
413
+ await this.initializeMqtt();
414
+ }
415
+ }
416
+ }
417
+
418
+ // ==================== HttpRequestHandler Implementation ====================
419
+
420
+ async onRequest(request: HttpRequest, response: HttpResponse): Promise<void> {
421
+ const url = new URL(request.url!, 'http://localhost');
422
+ const path = url.pathname;
423
+
424
+ try {
425
+ // API Routes
426
+ if (path.endsWith('/api/tracked-objects')) {
427
+ return this.handleTrackedObjectsRequest(request, response);
428
+ }
429
+
430
+ if (path.match(/\/api\/journey\/[\w-]+$/)) {
431
+ const globalId = path.split('/').pop()!;
432
+ return this.handleJourneyRequest(globalId, response);
433
+ }
434
+
435
+ if (path.endsWith('/api/topology')) {
436
+ return this.handleTopologyRequest(request, response);
437
+ }
438
+
439
+ if (path.endsWith('/api/alerts')) {
440
+ return this.handleAlertsRequest(request, response);
441
+ }
442
+
443
+ if (path.endsWith('/api/floor-plan')) {
444
+ return this.handleFloorPlanRequest(request, response);
445
+ }
446
+
447
+ // UI Routes
448
+ if (path.endsWith('/ui/editor') || path.endsWith('/ui/editor/')) {
449
+ return this.serveEditorUI(response);
450
+ }
451
+
452
+ if (path.includes('/ui/')) {
453
+ return this.serveStaticFile(path, response);
454
+ }
455
+
456
+ // Default: return info page
457
+ response.send(JSON.stringify({
458
+ name: 'Spatial Awareness Plugin',
459
+ version: '0.1.0',
460
+ endpoints: {
461
+ api: {
462
+ trackedObjects: '/api/tracked-objects',
463
+ journey: '/api/journey/{globalId}',
464
+ topology: '/api/topology',
465
+ alerts: '/api/alerts',
466
+ floorPlan: '/api/floor-plan',
467
+ },
468
+ ui: {
469
+ editor: '/ui/editor',
470
+ },
471
+ },
472
+ }), {
473
+ headers: { 'Content-Type': 'application/json' },
474
+ });
475
+ } catch (e) {
476
+ this.console.error('HTTP request error:', e);
477
+ response.send(JSON.stringify({ error: (e as Error).message }), {
478
+ code: 500,
479
+ headers: { 'Content-Type': 'application/json' },
480
+ });
481
+ }
482
+ }
483
+
484
+ private handleTrackedObjectsRequest(request: HttpRequest, response: HttpResponse): void {
485
+ const objects = this.trackingState.getAllObjects();
486
+ response.send(JSON.stringify(objects), {
487
+ headers: { 'Content-Type': 'application/json' },
488
+ });
489
+ }
490
+
491
+ private handleJourneyRequest(globalId: string, response: HttpResponse): void {
492
+ const tracked = this.trackingState.getObject(globalId);
493
+ if (tracked) {
494
+ response.send(JSON.stringify({
495
+ globalId: tracked.globalId,
496
+ className: tracked.className,
497
+ label: tracked.label,
498
+ journey: tracked.journey,
499
+ sightings: tracked.sightings.map(s => ({
500
+ cameraId: s.cameraId,
501
+ cameraName: s.cameraName,
502
+ timestamp: s.timestamp,
503
+ })),
504
+ firstSeen: tracked.firstSeen,
505
+ lastSeen: tracked.lastSeen,
506
+ state: tracked.state,
507
+ }), {
508
+ headers: { 'Content-Type': 'application/json' },
509
+ });
510
+ } else {
511
+ response.send(JSON.stringify({ error: 'Object not found' }), {
512
+ code: 404,
513
+ headers: { 'Content-Type': 'application/json' },
514
+ });
515
+ }
516
+ }
517
+
518
+ private handleTopologyRequest(request: HttpRequest, response: HttpResponse): void {
519
+ if (request.method === 'GET') {
520
+ const topologyJson = this.storage.getItem('topology');
521
+ const topology = topologyJson ? JSON.parse(topologyJson) : createEmptyTopology();
522
+ response.send(JSON.stringify(topology), {
523
+ headers: { 'Content-Type': 'application/json' },
524
+ });
525
+ } else if (request.method === 'PUT' || request.method === 'POST') {
526
+ try {
527
+ const topology = JSON.parse(request.body!) as CameraTopology;
528
+ this.storage.setItem('topology', JSON.stringify(topology));
529
+ this.startTrackingEngine(topology);
530
+ response.send(JSON.stringify({ success: true }), {
531
+ headers: { 'Content-Type': 'application/json' },
532
+ });
533
+ } catch (e) {
534
+ response.send(JSON.stringify({ error: 'Invalid topology JSON' }), {
535
+ code: 400,
536
+ headers: { 'Content-Type': 'application/json' },
537
+ });
538
+ }
539
+ }
540
+ }
541
+
542
+ private handleAlertsRequest(request: HttpRequest, response: HttpResponse): void {
543
+ const alerts = this.alertManager.getRecentAlerts();
544
+ response.send(JSON.stringify(alerts), {
545
+ headers: { 'Content-Type': 'application/json' },
546
+ });
547
+ }
548
+
549
+ private handleFloorPlanRequest(request: HttpRequest, response: HttpResponse): void {
550
+ if (request.method === 'GET') {
551
+ const imageData = this.storage.getItem('floorPlanImage');
552
+ if (imageData) {
553
+ response.send(JSON.stringify({ imageData }), {
554
+ headers: { 'Content-Type': 'application/json' },
555
+ });
556
+ } else {
557
+ response.send(JSON.stringify({ imageData: null }), {
558
+ headers: { 'Content-Type': 'application/json' },
559
+ });
560
+ }
561
+ } else if (request.method === 'POST') {
562
+ try {
563
+ const body = JSON.parse(request.body!);
564
+ this.storage.setItem('floorPlanImage', body.imageData);
565
+ response.send(JSON.stringify({ success: true }), {
566
+ headers: { 'Content-Type': 'application/json' },
567
+ });
568
+ } catch (e) {
569
+ response.send(JSON.stringify({ error: 'Invalid request body' }), {
570
+ code: 400,
571
+ headers: { 'Content-Type': 'application/json' },
572
+ });
573
+ }
574
+ }
575
+ }
576
+
577
+ private serveEditorUI(response: HttpResponse): void {
578
+ // Try to read the editor HTML from the bundled file
579
+ try {
580
+ const editorPath = path.join(__dirname, 'ui', 'editor.html');
581
+ const html = fs.readFileSync(editorPath, 'utf-8');
582
+ response.send(html, {
583
+ headers: { 'Content-Type': 'text/html' },
584
+ });
585
+ } catch (e) {
586
+ this.console.error('Failed to load editor UI:', e);
587
+ // Fallback to basic UI
588
+ const html = `
589
+ <!DOCTYPE html>
590
+ <html>
591
+ <head>
592
+ <title>Spatial Awareness - Topology Editor</title>
593
+ <meta charset="utf-8">
594
+ <meta name="viewport" content="width=device-width, initial-scale=1">
595
+ <style>
596
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 0; padding: 20px; background: #1a1a1a; color: #fff; }
597
+ h1 { margin-top: 0; }
598
+ .container { max-width: 1200px; margin: 0 auto; }
599
+ .btn { background: #0066cc; color: #fff; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; font-size: 14px; }
600
+ </style>
601
+ </head>
602
+ <body>
603
+ <div class="container">
604
+ <h1>Spatial Awareness - Topology Editor</h1>
605
+ <p>Error loading visual editor. Configure topology via REST API: <code>PUT /api/topology</code></p>
606
+ <h3>Current Topology</h3>
607
+ <pre id="topology-json" style="background: #2a2a2a; padding: 15px; border-radius: 4px; overflow: auto;"></pre>
608
+ </div>
609
+ <script>
610
+ fetch('api/topology').then(r => r.json()).then(data => {
611
+ document.getElementById('topology-json').textContent = JSON.stringify(data, null, 2);
612
+ });
613
+ </script>
614
+ </body>
615
+ </html>`;
616
+ response.send(html, {
617
+ headers: { 'Content-Type': 'text/html' },
618
+ });
619
+ }
620
+ }
621
+
622
+ private serveStaticFile(path: string, response: HttpResponse): void {
623
+ // Serve static files for the UI
624
+ response.send('Not found', { code: 404 });
625
+ }
626
+
627
+ // ==================== Readme Implementation ====================
628
+
629
+ async getReadmeMarkdown(): Promise<string> {
630
+ return `
631
+ # Spatial Awareness Plugin
632
+
633
+ This plugin enables cross-camera object tracking across your entire NVR system.
634
+
635
+ ## Features
636
+
637
+ - **Cross-Camera Tracking**: Correlate objects as they move between cameras
638
+ - **Journey History**: Complete path history for each tracked object
639
+ - **Entry/Exit Detection**: Know when objects enter or leave your property
640
+ - **Visual Floor Plan**: Configure camera topology with a visual editor
641
+ - **MQTT Integration**: Export tracking data to Home Assistant
642
+ - **REST API**: Query tracked objects and journeys programmatically
643
+ - **Smart Alerts**: Get notified about property entry/exit, unusual paths, and more
644
+
645
+ ## Setup
646
+
647
+ 1. **Add Cameras**: Select cameras with object detection in the plugin settings
648
+ 2. **Configure Topology**: Define camera relationships and transit times
649
+ 3. **Enable Integrations**: Optionally enable MQTT for Home Assistant
650
+ 4. **Create Zones**: Add tracking zones for specific area monitoring
651
+
652
+ ## API Endpoints
653
+
654
+ - \`GET /api/tracked-objects\` - List all tracked objects
655
+ - \`GET /api/journey/{id}\` - Get journey for specific object
656
+ - \`GET /api/topology\` - Get camera topology
657
+ - \`PUT /api/topology\` - Update camera topology
658
+ - \`GET /api/alerts\` - Get recent alerts
659
+
660
+ ## Visual Editor
661
+
662
+ Access the visual topology editor at \`/ui/editor\` to configure camera relationships using a floor plan.
663
+
664
+ ## Alert Types
665
+
666
+ - **Property Entry**: Object entered the property
667
+ - **Property Exit**: Object exited the property
668
+ - **Unusual Path**: Object took an unexpected route
669
+ - **Dwell Time**: Object lingered too long in an area
670
+ - **Restricted Zone**: Object entered a restricted area
671
+ `;
672
+ }
673
+
674
+ // ==================== Public Methods for Child Devices ====================
675
+
676
+ getTrackingState(): TrackingState {
677
+ return this.trackingState;
678
+ }
679
+
680
+ getAlertManager(): AlertManager {
681
+ return this.alertManager;
682
+ }
683
+
684
+ getTopology(): CameraTopology | null {
685
+ const topologyJson = this.storage.getItem('topology');
686
+ return topologyJson ? JSON.parse(topologyJson) : null;
687
+ }
688
+ }
689
+
690
+ export default SpatialAwarenessPlugin;