@blueharford/scrypted-spatial-awareness 0.6.32 → 0.6.34

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.
@@ -61,6 +61,8 @@ export interface TrackingEngineConfig {
61
61
  loiteringThreshold: number;
62
62
  /** Per-object alert cooldown (ms) */
63
63
  objectAlertCooldown: number;
64
+ /** Minimum detection score to consider */
65
+ minDetectionScore: number;
64
66
  /** Use LLM for enhanced descriptions */
65
67
  useLlmDescriptions: boolean;
66
68
  /** Specific LLM device IDs to use (if not set, auto-discovers all for load balancing) */
@@ -112,7 +114,9 @@ export class TrackingEngine {
112
114
  private spatialReasoning: SpatialReasoningEngine;
113
115
  private listeners: Map<string, EventListenerRegister> = new Map();
114
116
  private pendingTimers: Map<GlobalTrackingId, NodeJS.Timeout> = new Map();
117
+ private loiteringTimers: Map<GlobalTrackingId, NodeJS.Timeout> = new Map();
115
118
  private lostCheckInterval: NodeJS.Timeout | null = null;
119
+ private cleanupInterval: NodeJS.Timeout | null = null;
116
120
  /** Track last alert time per object to enforce cooldown */
117
121
  private objectLastAlertTime: Map<GlobalTrackingId, number> = new Map();
118
122
  /** Callback for topology changes (e.g., landmark suggestions) */
@@ -215,6 +219,10 @@ export class TrackingEngine {
215
219
  this.checkForLostObjects();
216
220
  }, 30000); // Check every 30 seconds
217
221
 
222
+ this.cleanupInterval = setInterval(() => {
223
+ this.state.cleanup();
224
+ }, 300000); // Cleanup every 5 minutes
225
+
218
226
  this.console.log(`Tracking engine started with ${this.listeners.size} cameras`);
219
227
  }
220
228
 
@@ -236,12 +244,22 @@ export class TrackingEngine {
236
244
  }
237
245
  this.pendingTimers.clear();
238
246
 
247
+ for (const timer of this.loiteringTimers.values()) {
248
+ clearTimeout(timer);
249
+ }
250
+ this.loiteringTimers.clear();
251
+
239
252
  // Stop lost check interval
240
253
  if (this.lostCheckInterval) {
241
254
  clearInterval(this.lostCheckInterval);
242
255
  this.lostCheckInterval = null;
243
256
  }
244
257
 
258
+ if (this.cleanupInterval) {
259
+ clearInterval(this.cleanupInterval);
260
+ this.cleanupInterval = null;
261
+ }
262
+
245
263
  this.console.log('Tracking engine stopped');
246
264
  }
247
265
 
@@ -264,7 +282,7 @@ export class TrackingEngine {
264
282
 
265
283
  for (const detection of detected.detections) {
266
284
  // Skip low-confidence detections
267
- if (detection.score < 0.5) continue;
285
+ if (detection.score < this.config.minDetectionScore) continue;
268
286
 
269
287
  // If in training mode, record trainer detections
270
288
  if (this.isTrainingActive() && detection.className === 'person') {
@@ -358,6 +376,15 @@ export class TrackingEngine {
358
376
  const fallbackTimeout = this.config.llmFallbackTimeout ?? 3000;
359
377
 
360
378
  try {
379
+ if (!this.config.useLlmDescriptions) {
380
+ return this.spatialReasoning.generateMovementDescription(
381
+ tracked,
382
+ fromCameraId,
383
+ toCameraId,
384
+ transitTime
385
+ );
386
+ }
387
+
361
388
  // Check rate limiting - if not allowed, return null to use basic description
362
389
  if (!this.tryLlmCall()) {
363
390
  this.console.log('[Movement] LLM rate-limited, using basic notification');
@@ -366,11 +393,9 @@ export class TrackingEngine {
366
393
 
367
394
  // Get snapshot from camera for LLM analysis (if LLM is enabled)
368
395
  let mediaObject: MediaObject | undefined;
369
- if (this.config.useLlmDescriptions) {
370
- const camera = systemManager.getDeviceById<Camera>(currentCameraId);
371
- if (camera?.interfaces?.includes(ScryptedInterface.Camera)) {
372
- mediaObject = await camera.takePicture();
373
- }
396
+ const camera = systemManager.getDeviceById<Camera>(currentCameraId);
397
+ if (camera?.interfaces?.includes(ScryptedInterface.Camera)) {
398
+ mediaObject = await camera.takePicture();
374
399
  }
375
400
 
376
401
  // Use spatial reasoning engine for rich context-aware description
@@ -460,6 +485,8 @@ export class TrackingEngine {
460
485
  // Check if this is a cross-camera transition
461
486
  const lastSighting = getLastSighting(tracked);
462
487
  if (lastSighting && lastSighting.cameraId !== sighting.cameraId) {
488
+ // Cancel any pending loitering alert if object already transitioned
489
+ this.clearLoiteringTimer(tracked.globalId);
463
490
  const transitDuration = sighting.timestamp - lastSighting.timestamp;
464
491
 
465
492
  // Update cached snapshot from new camera (object is now visible here)
@@ -491,33 +518,57 @@ export class TrackingEngine {
491
518
  );
492
519
 
493
520
  // Check loitering threshold and per-object cooldown before alerting
494
- if (this.passesLoiteringThreshold(tracked) && !this.isInAlertCooldown(tracked.globalId)) {
495
- // Get spatial reasoning result with RAG context
496
- const spatialResult = await this.getSpatialDescription(
497
- tracked,
498
- lastSighting.cameraId,
499
- sighting.cameraId,
500
- transitDuration,
501
- sighting.cameraId
502
- );
503
-
504
- // Generate movement alert for cross-camera transition
505
- await this.alertManager.checkAndAlert('movement', tracked, {
506
- fromCameraId: lastSighting.cameraId,
507
- fromCameraName: lastSighting.cameraName,
508
- toCameraId: sighting.cameraId,
509
- toCameraName: sighting.cameraName,
510
- transitTime: transitDuration,
511
- objectClass: sighting.detection.className,
512
- objectLabel: spatialResult?.description || sighting.detection.label,
513
- detectionId: sighting.detectionId,
514
- // Include spatial context for enriched alerts
515
- pathDescription: spatialResult?.pathDescription,
516
- involvedLandmarks: spatialResult?.involvedLandmarks?.map(l => l.name),
517
- usedLlm: spatialResult?.usedLlm,
518
- });
519
-
520
- this.recordAlertTime(tracked.globalId);
521
+ if (this.passesLoiteringThreshold(tracked)) {
522
+ if (this.isInAlertCooldown(tracked.globalId)) {
523
+ const spatialResult = await this.spatialReasoning.generateMovementDescription(
524
+ tracked,
525
+ lastSighting.cameraId,
526
+ sighting.cameraId,
527
+ transitDuration
528
+ );
529
+
530
+ await this.alertManager.updateMovementAlert(tracked, {
531
+ fromCameraId: lastSighting.cameraId,
532
+ fromCameraName: lastSighting.cameraName,
533
+ toCameraId: sighting.cameraId,
534
+ toCameraName: sighting.cameraName,
535
+ transitTime: transitDuration,
536
+ objectClass: sighting.detection.className,
537
+ objectLabel: spatialResult.description || sighting.detection.label,
538
+ detectionId: sighting.detectionId,
539
+ pathDescription: spatialResult.pathDescription,
540
+ involvedLandmarks: spatialResult.involvedLandmarks?.map(l => l.name),
541
+ usedLlm: spatialResult.usedLlm,
542
+ });
543
+ } else {
544
+ // Get spatial reasoning result with RAG context
545
+ const spatialResult = await this.getSpatialDescription(
546
+ tracked,
547
+ lastSighting.cameraId,
548
+ sighting.cameraId,
549
+ transitDuration,
550
+ sighting.cameraId
551
+ );
552
+
553
+ // Generate movement alert for cross-camera transition
554
+ const mediaObject = this.snapshotCache.get(tracked.globalId);
555
+ await this.alertManager.checkAndAlert('movement', tracked, {
556
+ fromCameraId: lastSighting.cameraId,
557
+ fromCameraName: lastSighting.cameraName,
558
+ toCameraId: sighting.cameraId,
559
+ toCameraName: sighting.cameraName,
560
+ transitTime: transitDuration,
561
+ objectClass: sighting.detection.className,
562
+ objectLabel: spatialResult?.description || sighting.detection.label,
563
+ detectionId: sighting.detectionId,
564
+ // Include spatial context for enriched alerts
565
+ pathDescription: spatialResult?.pathDescription,
566
+ involvedLandmarks: spatialResult?.involvedLandmarks?.map(l => l.name),
567
+ usedLlm: spatialResult?.usedLlm,
568
+ }, mediaObject);
569
+
570
+ this.recordAlertTime(tracked.globalId);
571
+ }
521
572
  }
522
573
  }
523
574
 
@@ -569,18 +620,49 @@ export class TrackingEngine {
569
620
  }
570
621
 
571
622
  // Check after loitering threshold if object is still being tracked
572
- setTimeout(async () => {
573
- const tracked = this.state.getObject(globalId);
574
- if (!tracked || tracked.state !== 'active') return;
623
+ const existing = this.loiteringTimers.get(globalId);
624
+ if (existing) {
625
+ clearTimeout(existing);
626
+ this.loiteringTimers.delete(globalId);
627
+ }
628
+
629
+ const timer = setTimeout(async () => {
630
+ try {
631
+ const tracked = this.state.getObject(globalId);
632
+ if (!tracked || tracked.state !== 'active') return;
575
633
 
576
- // Check if we've already alerted for this object
577
- if (this.isInAlertCooldown(globalId)) return;
634
+ const lastSighting = getLastSighting(tracked);
635
+ if (!lastSighting || lastSighting.cameraId !== sighting.cameraId) {
636
+ return;
637
+ }
638
+
639
+ const maxStaleMs = Math.max(10000, this.config.loiteringThreshold * 2);
640
+ if (Date.now() - lastSighting.timestamp > maxStaleMs) {
641
+ return;
642
+ }
643
+
644
+ // Check if we've already alerted for this object
645
+ if (this.isInAlertCooldown(globalId)) {
646
+ const spatialResult = await this.spatialReasoning.generateEntryDescription(tracked, sighting.cameraId);
647
+ await this.alertManager.updateMovementAlert(tracked, {
648
+ cameraId: sighting.cameraId,
649
+ cameraName: sighting.cameraName,
650
+ toCameraId: sighting.cameraId,
651
+ toCameraName: sighting.cameraName,
652
+ objectClass: sighting.detection.className,
653
+ objectLabel: spatialResult.description,
654
+ detectionId: sighting.detectionId,
655
+ involvedLandmarks: spatialResult.involvedLandmarks?.map(l => l.name),
656
+ usedLlm: spatialResult.usedLlm,
657
+ });
658
+ return;
659
+ }
578
660
 
579
661
  // Use prefetched LLM result if available (started when snapshot was captured)
580
662
  let spatialResult: SpatialReasoningResult;
581
663
  const pendingDescription = this.pendingDescriptions.get(globalId);
582
664
 
583
- if (pendingDescription) {
665
+ if (pendingDescription) {
584
666
  this.console.log(`[Entry Alert] Using prefetched LLM result for ${globalId.slice(0, 8)}`);
585
667
  try {
586
668
  spatialResult = await pendingDescription;
@@ -591,7 +673,7 @@ export class TrackingEngine {
591
673
  spatialResult = await this.spatialReasoning.generateEntryDescription(tracked, sighting.cameraId);
592
674
  }
593
675
  this.pendingDescriptions.delete(globalId);
594
- } else {
676
+ } else {
595
677
  // No prefetch available - only call LLM if rate limit allows
596
678
  if (this.tryLlmCall()) {
597
679
  this.console.log(`[Entry Alert] No prefetch, generating with LLM`);
@@ -607,7 +689,8 @@ export class TrackingEngine {
607
689
 
608
690
  // Always use movement alert type for smart notifications with LLM descriptions
609
691
  // The property_entry/property_exit types are legacy and disabled by default
610
- await this.alertManager.checkAndAlert('movement', tracked, {
692
+ const mediaObject = this.snapshotCache.get(globalId);
693
+ await this.alertManager.checkAndAlert('movement', tracked, {
611
694
  cameraId: sighting.cameraId,
612
695
  cameraName: sighting.cameraName,
613
696
  toCameraId: sighting.cameraId,
@@ -617,10 +700,15 @@ export class TrackingEngine {
617
700
  detectionId: sighting.detectionId,
618
701
  involvedLandmarks: spatialResult.involvedLandmarks?.map(l => l.name),
619
702
  usedLlm: spatialResult.usedLlm,
620
- });
703
+ }, mediaObject);
621
704
 
622
- this.recordAlertTime(globalId);
705
+ this.recordAlertTime(globalId);
706
+ } finally {
707
+ this.loiteringTimers.delete(globalId);
708
+ }
623
709
  }, this.config.loiteringThreshold);
710
+
711
+ this.loiteringTimers.set(globalId, timer);
624
712
  }
625
713
 
626
714
  /** Capture and cache a snapshot for a tracked object, and start LLM analysis immediately */
@@ -712,6 +800,9 @@ export class TrackingEngine {
712
800
  // Mark as pending and set timer
713
801
  this.state.markPending(tracked.globalId);
714
802
 
803
+ // Cancel any pending loitering alert
804
+ this.clearLoiteringTimer(tracked.globalId);
805
+
715
806
  // Capture a fresh snapshot now while object is still visible (before they leave)
716
807
  // Also starts LLM analysis immediately in parallel
717
808
  if (this.config.useLlmDescriptions) {
@@ -756,6 +847,7 @@ export class TrackingEngine {
756
847
  }
757
848
 
758
849
  // Use movement alert for exit too - smart notifications with LLM descriptions
850
+ const mediaObject = this.snapshotCache.get(tracked.globalId);
759
851
  await this.alertManager.checkAndAlert('movement', current, {
760
852
  cameraId: sighting.cameraId,
761
853
  cameraName: sighting.cameraName,
@@ -765,7 +857,9 @@ export class TrackingEngine {
765
857
  objectLabel: spatialResult.description,
766
858
  involvedLandmarks: spatialResult.involvedLandmarks?.map(l => l.name),
767
859
  usedLlm: spatialResult.usedLlm,
768
- });
860
+ }, mediaObject);
861
+
862
+ this.alertManager.clearActiveAlertsForObject(tracked.globalId);
769
863
 
770
864
  // Clean up cached snapshot and pending descriptions after exit alert
771
865
  this.snapshotCache.delete(tracked.globalId);
@@ -787,6 +881,7 @@ export class TrackingEngine {
787
881
 
788
882
  if (timeSinceSeen > this.config.lostTimeout) {
789
883
  this.state.markLost(tracked.globalId);
884
+ this.clearLoiteringTimer(tracked.globalId);
790
885
  this.console.log(
791
886
  `Object ${tracked.globalId.slice(0, 8)} marked as lost ` +
792
887
  `(not seen for ${Math.round(timeSinceSeen / 1000)}s)`
@@ -800,10 +895,21 @@ export class TrackingEngine {
800
895
  objectClass: tracked.className,
801
896
  objectLabel: tracked.label,
802
897
  });
898
+
899
+ this.alertManager.clearActiveAlertsForObject(tracked.globalId);
803
900
  }
804
901
  }
805
902
  }
806
903
 
904
+ /** Clear a pending loitering timer if present */
905
+ private clearLoiteringTimer(globalId: GlobalTrackingId): void {
906
+ const timer = this.loiteringTimers.get(globalId);
907
+ if (timer) {
908
+ clearTimeout(timer);
909
+ this.loiteringTimers.delete(globalId);
910
+ }
911
+ }
912
+
807
913
  /** Update topology configuration */
808
914
  updateTopology(topology: CameraTopology): void {
809
915
  this.topology = topology;
package/src/main.ts CHANGED
@@ -6,6 +6,7 @@ import sdk, {
6
6
  Setting,
7
7
  SettingValue,
8
8
  ScryptedDeviceBase,
9
+ ScryptedDevice,
9
10
  ScryptedDeviceType,
10
11
  ScryptedInterface,
11
12
  ScryptedNativeId,
@@ -111,6 +112,13 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
111
112
  description: 'Object must be visible for this duration before triggering movement alerts',
112
113
  group: 'Tracking',
113
114
  },
115
+ minDetectionScore: {
116
+ title: 'Minimum Detection Confidence',
117
+ type: 'number',
118
+ defaultValue: 0.5,
119
+ description: 'Minimum detection score (0-1) to consider for tracking',
120
+ group: 'Tracking',
121
+ },
114
122
  objectAlertCooldown: {
115
123
  title: 'Per-Object Alert Cooldown (seconds)',
116
124
  type: 'number',
@@ -118,6 +126,20 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
118
126
  description: 'Minimum time between alerts for the same tracked object',
119
127
  group: 'Tracking',
120
128
  },
129
+ notifyOnAlertUpdates: {
130
+ title: 'Notify on Alert Updates',
131
+ type: 'boolean',
132
+ defaultValue: false,
133
+ description: 'Send notifications when an existing alert is updated (camera transitions, context changes)',
134
+ group: 'Tracking',
135
+ },
136
+ alertUpdateCooldown: {
137
+ title: 'Alert Update Cooldown (seconds)',
138
+ type: 'number',
139
+ defaultValue: 60,
140
+ description: 'Minimum time between update notifications for the same tracked object (0 = no limit)',
141
+ group: 'Tracking',
142
+ },
121
143
 
122
144
  // LLM Integration
123
145
  useLlmDescriptions: {
@@ -321,6 +343,8 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
321
343
  await this.initializeMqtt();
322
344
  }
323
345
 
346
+ this.applyAlertUpdateSettings();
347
+
324
348
  this.console.log('Spatial Awareness Plugin initialized');
325
349
  }
326
350
 
@@ -356,21 +380,22 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
356
380
  }
357
381
 
358
382
  const config: TrackingEngineConfig = {
359
- correlationWindow: (this.storageSettings.values.correlationWindow as number || 30) * 1000,
360
- correlationThreshold: this.storageSettings.values.correlationThreshold as number || 0.35,
361
- lostTimeout: (this.storageSettings.values.lostTimeout as number || 300) * 1000,
383
+ correlationWindow: this.getNumberSetting(this.storageSettings.values.correlationWindow, 30) * 1000,
384
+ correlationThreshold: this.getNumberSetting(this.storageSettings.values.correlationThreshold, 0.35),
385
+ lostTimeout: this.getNumberSetting(this.storageSettings.values.lostTimeout, 300) * 1000,
362
386
  useVisualMatching: this.storageSettings.values.useVisualMatching as boolean ?? true,
363
- loiteringThreshold: (this.storageSettings.values.loiteringThreshold as number || 3) * 1000,
364
- objectAlertCooldown: (this.storageSettings.values.objectAlertCooldown as number || 30) * 1000,
387
+ loiteringThreshold: this.getNumberSetting(this.storageSettings.values.loiteringThreshold, 3) * 1000,
388
+ minDetectionScore: this.getNumberSetting(this.storageSettings.values.minDetectionScore, 0.5),
389
+ objectAlertCooldown: this.getNumberSetting(this.storageSettings.values.objectAlertCooldown, 30) * 1000,
365
390
  useLlmDescriptions: this.storageSettings.values.useLlmDescriptions as boolean ?? true,
366
391
  llmDeviceIds: this.parseLlmProviders(),
367
- llmDebounceInterval: (this.storageSettings.values.llmDebounceInterval as number || 30) * 1000,
392
+ llmDebounceInterval: this.getNumberSetting(this.storageSettings.values.llmDebounceInterval, 5) * 1000,
368
393
  llmFallbackEnabled: this.storageSettings.values.llmFallbackEnabled as boolean ?? true,
369
- llmFallbackTimeout: (this.storageSettings.values.llmFallbackTimeout as number || 3) * 1000,
394
+ llmFallbackTimeout: this.getNumberSetting(this.storageSettings.values.llmFallbackTimeout, 3) * 1000,
370
395
  enableTransitTimeLearning: this.storageSettings.values.enableTransitTimeLearning as boolean ?? true,
371
396
  enableConnectionSuggestions: this.storageSettings.values.enableConnectionSuggestions as boolean ?? true,
372
397
  enableLandmarkLearning: this.storageSettings.values.enableLandmarkLearning as boolean ?? true,
373
- landmarkConfidenceThreshold: this.storageSettings.values.landmarkConfidenceThreshold as number ?? 0.7,
398
+ landmarkConfidenceThreshold: this.getNumberSetting(this.storageSettings.values.landmarkConfidenceThreshold, 0.7),
374
399
  };
375
400
 
376
401
  this.trackingEngine = new TrackingEngine(
@@ -381,6 +406,8 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
381
406
  this.console
382
407
  );
383
408
 
409
+ this.applyAlertUpdateSettings();
410
+
384
411
  // Set up callback to save topology changes (e.g., from accepted landmark suggestions)
385
412
  this.trackingEngine.setTopologyChangeCallback((updatedTopology) => {
386
413
  this.storage.setItem('topology', JSON.stringify(updatedTopology));
@@ -396,10 +423,10 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
396
423
 
397
424
  private async initializeDiscoveryEngine(topology: CameraTopology): Promise<void> {
398
425
  const discoveryConfig: DiscoveryConfig = {
399
- discoveryIntervalHours: this.storageSettings.values.discoveryIntervalHours as number ?? 0,
400
- autoAcceptThreshold: this.storageSettings.values.autoAcceptThreshold as number ?? 0.85,
401
- minLandmarkConfidence: this.storageSettings.values.minLandmarkConfidence as number ?? 0.6,
402
- minConnectionConfidence: this.storageSettings.values.minConnectionConfidence as number ?? 0.5,
426
+ discoveryIntervalHours: this.getNumberSetting(this.storageSettings.values.discoveryIntervalHours, 0),
427
+ autoAcceptThreshold: this.getNumberSetting(this.storageSettings.values.autoAcceptThreshold, 0.85),
428
+ minLandmarkConfidence: this.getNumberSetting(this.storageSettings.values.minLandmarkConfidence, 0.6),
429
+ minConnectionConfidence: this.getNumberSetting(this.storageSettings.values.minConnectionConfidence, 0.5),
403
430
  };
404
431
 
405
432
  if (this.discoveryEngine) {
@@ -448,6 +475,12 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
448
475
  return undefined;
449
476
  }
450
477
 
478
+ private getNumberSetting(value: unknown, fallback: number): number {
479
+ if (value === undefined || value === null) return fallback;
480
+ const num = typeof value === 'string' ? Number(value) : (value as number);
481
+ return Number.isFinite(num) ? num : fallback;
482
+ }
483
+
451
484
  // ==================== DeviceProvider Implementation ====================
452
485
 
453
486
  async getDevice(nativeId: string): Promise<any> {
@@ -732,6 +765,20 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
732
765
  // ==================== 8. Auto-Topology Discovery ====================
733
766
  addGroup('Auto-Topology Discovery');
734
767
 
768
+ if (this.discoveryEngine) {
769
+ const excluded = this.discoveryEngine.getExcludedVisionLlmNames();
770
+ if (excluded.length > 0) {
771
+ settings.push({
772
+ key: 'excludedVisionLlms',
773
+ title: 'Excluded LLMs (No Vision)',
774
+ type: 'string',
775
+ readonly: true,
776
+ value: excluded.join(', '),
777
+ group: 'Auto-Topology Discovery',
778
+ });
779
+ }
780
+ }
781
+
735
782
  // ==================== 9. MQTT Integration ====================
736
783
  addGroup('MQTT Integration');
737
784
 
@@ -749,6 +796,7 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
749
796
  key === 'lostTimeout' ||
750
797
  key === 'useVisualMatching' ||
751
798
  key === 'loiteringThreshold' ||
799
+ key === 'minDetectionScore' ||
752
800
  key === 'objectAlertCooldown' ||
753
801
  key === 'useLlmDescriptions' ||
754
802
  key === 'llmDebounceInterval' ||
@@ -771,6 +819,10 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
771
819
  }
772
820
  }
773
821
 
822
+ if (key === 'notifyOnAlertUpdates' || key === 'alertUpdateCooldown') {
823
+ this.applyAlertUpdateSettings();
824
+ }
825
+
774
826
  // Handle MQTT setting changes
775
827
  if (key === 'enableMqtt' || key === 'mqttBroker' || key === 'mqttUsername' ||
776
828
  key === 'mqttPassword' || key === 'mqttBaseTopic') {
@@ -784,6 +836,12 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
784
836
  }
785
837
  }
786
838
 
839
+ private applyAlertUpdateSettings(): void {
840
+ const enabled = this.storageSettings.values.notifyOnAlertUpdates as boolean ?? false;
841
+ const cooldownSeconds = this.getNumberSetting(this.storageSettings.values.alertUpdateCooldown, 60);
842
+ this.alertManager.setUpdateNotificationOptions(enabled, cooldownSeconds * 1000);
843
+ }
844
+
787
845
  // ==================== HttpRequestHandler Implementation ====================
788
846
 
789
847
  async onRequest(request: HttpRequest, response: HttpResponse): Promise<void> {
@@ -874,7 +932,7 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
874
932
 
875
933
  // Training Mode endpoints
876
934
  if (path.endsWith('/api/training/start')) {
877
- return this.handleTrainingStartRequest(request, response);
935
+ return await this.handleTrainingStartRequest(request, response);
878
936
  }
879
937
  if (path.endsWith('/api/training/pause')) {
880
938
  return this.handleTrainingPauseRequest(response);
@@ -1468,13 +1526,25 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
1468
1526
 
1469
1527
  // ==================== Training Mode Handlers ====================
1470
1528
 
1471
- private handleTrainingStartRequest(request: HttpRequest, response: HttpResponse): void {
1529
+ private async handleTrainingStartRequest(request: HttpRequest, response: HttpResponse): Promise<void> {
1472
1530
  if (!this.trackingEngine) {
1473
- response.send(JSON.stringify({ error: 'Tracking engine not running. Configure topology first.' }), {
1474
- code: 500,
1475
- headers: { 'Content-Type': 'application/json' },
1476
- });
1477
- return;
1531
+ const topologyJson = this.storage.getItem('topology');
1532
+ const topology = topologyJson ? JSON.parse(topologyJson) as CameraTopology : createEmptyTopology();
1533
+
1534
+ if (!topology.cameras?.length) {
1535
+ const cameras = this.buildTopologyCamerasFromSettings();
1536
+ if (cameras.length === 0) {
1537
+ response.send(JSON.stringify({ error: 'No cameras configured. Select tracked cameras first.' }), {
1538
+ code: 400,
1539
+ headers: { 'Content-Type': 'application/json' },
1540
+ });
1541
+ return;
1542
+ }
1543
+ topology.cameras = cameras;
1544
+ this.storage.setItem('topology', JSON.stringify(topology));
1545
+ }
1546
+
1547
+ await this.startTrackingEngine(topology);
1478
1548
  }
1479
1549
 
1480
1550
  try {
@@ -2282,6 +2352,27 @@ Access the visual topology editor at \`/ui/editor\` to configure camera relation
2282
2352
  const topologyJson = this.storage.getItem('topology');
2283
2353
  return topologyJson ? JSON.parse(topologyJson) : null;
2284
2354
  }
2355
+
2356
+ private buildTopologyCamerasFromSettings(): CameraTopology['cameras'] {
2357
+ const value = this.storageSettings.values.trackedCameras;
2358
+ const cameraIds = Array.isArray(value)
2359
+ ? value.filter(Boolean)
2360
+ : typeof value === 'string' && value.length
2361
+ ? [value]
2362
+ : [];
2363
+
2364
+ return cameraIds.map((deviceId: string) => {
2365
+ const device = systemManager.getDeviceById<ScryptedDevice>(deviceId);
2366
+ return {
2367
+ deviceId,
2368
+ nativeId: device?.nativeId || deviceId,
2369
+ name: device?.name || deviceId,
2370
+ isEntryPoint: false,
2371
+ isExitPoint: false,
2372
+ trackClasses: [],
2373
+ };
2374
+ });
2375
+ }
2285
2376
  }
2286
2377
 
2287
2378
  export default SpatialAwarenessPlugin;
@@ -3,7 +3,7 @@
3
3
  * Defines objects being tracked across multiple cameras
4
4
  */
5
5
 
6
- import { ObjectDetectionResult } from '@scrypted/sdk';
6
+ import type { ObjectDetectionResult } from '@scrypted/sdk';
7
7
 
8
8
  /** Unique identifier for a globally tracked object */
9
9
  export type GlobalTrackingId = string;
@@ -21,6 +21,8 @@ export class TrackingState {
21
21
  private changeCallbacks: StateChangeCallback[] = [];
22
22
  private storage: Storage;
23
23
  private console: Console;
24
+ private persistTimer: NodeJS.Timeout | null = null;
25
+ private readonly persistDebounceMs: number = 2000;
24
26
 
25
27
  constructor(storage: Storage, console: Console) {
26
28
  this.storage = storage;
@@ -60,7 +62,7 @@ export class TrackingState {
60
62
 
61
63
  this.objects.set(object.globalId, object);
62
64
  this.notifyChange();
63
- this.persistState();
65
+ this.schedulePersist();
64
66
  }
65
67
 
66
68
  /** Add a new sighting to an existing tracked object */
@@ -77,7 +79,7 @@ export class TrackingState {
77
79
  this.objectsByCamera.get(sighting.cameraId)!.add(globalId);
78
80
 
79
81
  this.notifyChange();
80
- this.persistState();
82
+ this.schedulePersist();
81
83
  return true;
82
84
  }
83
85
 
@@ -96,7 +98,7 @@ export class TrackingState {
96
98
  this.objectsByCamera.get(segment.toCameraId)!.add(globalId);
97
99
 
98
100
  this.notifyChange();
99
- this.persistState();
101
+ this.schedulePersist();
100
102
  return true;
101
103
  }
102
104
 
@@ -150,7 +152,7 @@ export class TrackingState {
150
152
  }
151
153
 
152
154
  this.notifyChange();
153
- this.persistState();
155
+ this.schedulePersist();
154
156
  }
155
157
  }
156
158
 
@@ -167,7 +169,7 @@ export class TrackingState {
167
169
  }
168
170
 
169
171
  this.notifyChange();
170
- this.persistState();
172
+ this.schedulePersist();
171
173
  }
172
174
  }
173
175
 
@@ -229,6 +231,17 @@ export class TrackingState {
229
231
  }
230
232
  }
231
233
 
234
+ private schedulePersist(): void {
235
+ if (this.persistTimer) {
236
+ clearTimeout(this.persistTimer);
237
+ }
238
+
239
+ this.persistTimer = setTimeout(() => {
240
+ this.persistTimer = null;
241
+ this.persistState();
242
+ }, this.persistDebounceMs);
243
+ }
244
+
232
245
  private loadPersistedState(): void {
233
246
  try {
234
247
  const json = this.storage.getItem('tracked-objects');