@blueharford/scrypted-spatial-awareness 0.6.32 → 0.6.33

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/out/plugin.zip CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blueharford/scrypted-spatial-awareness",
3
- "version": "0.6.32",
3
+ "version": "0.6.33",
4
4
  "description": "Cross-camera object tracking for Scrypted NVR with spatial awareness",
5
5
  "author": "Joshua Seidel <blueharford>",
6
6
  "license": "Apache-2.0",
@@ -24,7 +24,8 @@
24
24
  "scrypted-debug": "node node_modules/@scrypted/sdk/bin/scrypted-debug.js",
25
25
  "scrypted-deploy": "node node_modules/@scrypted/sdk/bin/scrypted-deploy.js",
26
26
  "scrypted-readme": "node node_modules/@scrypted/sdk/bin/scrypted-readme.js",
27
- "scrypted-package-json": "node node_modules/@scrypted/sdk/bin/scrypted-package-json.js"
27
+ "scrypted-package-json": "node node_modules/@scrypted/sdk/bin/scrypted-package-json.js",
28
+ "test": "node -r ts-node/register/transpile-only ./tests/run-tests.ts"
28
29
  },
29
30
  "keywords": [
30
31
  "scrypted",
@@ -54,6 +55,7 @@
54
55
  "devDependencies": {
55
56
  "@scrypted/common": "^1.0.0",
56
57
  "@types/node": "^20.11.0",
58
+ "ts-node": "^10.9.2",
57
59
  "typescript": "^5.3.0"
58
60
  }
59
61
  }
@@ -3,7 +3,7 @@
3
3
  * Generates and dispatches alerts based on tracking events
4
4
  */
5
5
 
6
- import sdk, { Notifier, Camera, ScryptedInterface, MediaObject } from '@scrypted/sdk';
6
+ import type { Notifier, Camera, MediaObject } from '@scrypted/sdk';
7
7
  import {
8
8
  Alert,
9
9
  AlertRule,
@@ -12,15 +12,31 @@ import {
12
12
  AlertCondition,
13
13
  createAlert,
14
14
  createDefaultRules,
15
+ generateAlertMessage,
15
16
  } from '../models/alert';
16
17
  import { TrackedObject, GlobalTrackingId } from '../models/tracked-object';
17
-
18
- const { systemManager, mediaManager } = sdk;
18
+ import {
19
+ getActiveAlertKey,
20
+ hasMeaningfulAlertChange,
21
+ shouldSendUpdateNotification,
22
+ } from './alert-utils';
23
+
24
+ let sdkModule: typeof import('@scrypted/sdk') | null = null;
25
+ const getSdk = async () => {
26
+ if (!sdkModule) {
27
+ sdkModule = await import('@scrypted/sdk');
28
+ }
29
+ return sdkModule;
30
+ };
19
31
 
20
32
  export class AlertManager {
21
33
  private rules: AlertRule[] = [];
22
34
  private recentAlerts: Alert[] = [];
23
35
  private cooldowns: Map<string, number> = new Map();
36
+ private activeAlerts: Map<string, { alert: Alert; lastUpdate: number; lastNotified: number }> = new Map();
37
+ private readonly activeAlertTtlMs: number = 10 * 60 * 1000;
38
+ private notifyOnUpdates: boolean = false;
39
+ private updateNotificationCooldownMs: number = 60000;
24
40
  private console: Console;
25
41
  private storage: Storage;
26
42
  private maxAlerts: number = 100;
@@ -37,7 +53,8 @@ export class AlertManager {
37
53
  async checkAndAlert(
38
54
  type: AlertType,
39
55
  tracked: TrackedObject,
40
- details: Partial<AlertDetails>
56
+ details: Partial<AlertDetails>,
57
+ mediaObjectOverride?: MediaObject
41
58
  ): Promise<Alert | null> {
42
59
  // Find matching rule
43
60
  const rule = this.rules.find(r => r.type === type && r.enabled);
@@ -57,15 +74,21 @@ export class AlertManager {
57
74
  }
58
75
  }
59
76
 
60
- // Check cooldown
61
- const cooldownKey = `${rule.id}:${tracked.globalId}`;
62
- const lastAlert = this.cooldowns.get(cooldownKey) || 0;
63
- if (rule.cooldown > 0 && Date.now() - lastAlert < rule.cooldown) {
77
+ // Check conditions
78
+ if (!this.evaluateConditions(rule.conditions, tracked)) {
64
79
  return null;
65
80
  }
66
81
 
67
- // Check conditions
68
- if (!this.evaluateConditions(rule.conditions, tracked)) {
82
+ // Update existing movement alert if active (prevents alert spam)
83
+ if (type === 'movement') {
84
+ const updated = await this.updateActiveAlert(type, rule.id, tracked, details);
85
+ if (updated) return updated;
86
+ }
87
+
88
+ // Check cooldown (only for new alerts)
89
+ const cooldownKey = `${rule.id}:${tracked.globalId}`;
90
+ const lastAlert = this.cooldowns.get(cooldownKey) || 0;
91
+ if (rule.cooldown > 0 && Date.now() - lastAlert < rule.cooldown) {
69
92
  return null;
70
93
  }
71
94
 
@@ -91,21 +114,59 @@ export class AlertManager {
91
114
  this.recentAlerts.pop();
92
115
  }
93
116
 
117
+ if (type === 'movement') {
118
+ const key = getActiveAlertKey(type, rule.id, tracked.globalId);
119
+ this.activeAlerts.set(key, { alert, lastUpdate: Date.now(), lastNotified: alert.timestamp });
120
+ }
121
+
94
122
  // Update cooldown
95
123
  this.cooldowns.set(cooldownKey, Date.now());
96
124
 
97
125
  // Send notifications
98
- await this.sendNotifications(alert, rule);
126
+ await this.sendNotifications(alert, rule, mediaObjectOverride);
99
127
 
100
128
  this.console.log(`Alert generated: [${alert.severity}] ${alert.message}`);
101
129
 
102
130
  return alert;
103
131
  }
104
132
 
133
+ async updateMovementAlert(
134
+ tracked: TrackedObject,
135
+ details: Partial<AlertDetails>
136
+ ): Promise<Alert | null> {
137
+ const rule = this.rules.find(r => r.type === 'movement' && r.enabled);
138
+ if (!rule) return null;
139
+
140
+ if (rule.objectClasses && rule.objectClasses.length > 0) {
141
+ if (!rule.objectClasses.includes(tracked.className)) {
142
+ return null;
143
+ }
144
+ }
145
+
146
+ if (rule.cameraIds && rule.cameraIds.length > 0 && details.cameraId) {
147
+ if (!rule.cameraIds.includes(details.cameraId)) {
148
+ return null;
149
+ }
150
+ }
151
+
152
+ if (!this.evaluateConditions(rule.conditions, tracked)) {
153
+ return null;
154
+ }
155
+
156
+ return this.updateActiveAlert('movement', rule.id, tracked, details);
157
+ }
158
+
105
159
  /**
106
160
  * Send notifications for an alert
107
161
  */
108
- private async sendNotifications(alert: Alert, rule: AlertRule): Promise<void> {
162
+ private async sendNotifications(
163
+ alert: Alert,
164
+ rule: AlertRule,
165
+ mediaObjectOverride?: MediaObject
166
+ ): Promise<void> {
167
+ const sdkModule = await getSdk();
168
+ const { ScryptedInterface } = sdkModule;
169
+ const { systemManager } = sdkModule.default;
109
170
  const notifierIds = rule.notifiers.length > 0
110
171
  ? rule.notifiers
111
172
  : this.getDefaultNotifiers();
@@ -119,16 +180,18 @@ export class AlertManager {
119
180
  }
120
181
 
121
182
  // Try to get a thumbnail from the camera
122
- let mediaObject: MediaObject | undefined;
123
- const cameraId = alert.details.toCameraId || alert.details.cameraId;
124
- if (cameraId) {
125
- try {
126
- const camera = systemManager.getDeviceById<Camera>(cameraId);
127
- if (camera && camera.interfaces?.includes(ScryptedInterface.Camera)) {
128
- mediaObject = await camera.takePicture();
183
+ let mediaObject: MediaObject | undefined = mediaObjectOverride;
184
+ if (!mediaObject) {
185
+ const cameraId = alert.details.toCameraId || alert.details.cameraId;
186
+ if (cameraId) {
187
+ try {
188
+ const camera = systemManager.getDeviceById<Camera>(cameraId);
189
+ if (camera && camera.interfaces?.includes(ScryptedInterface.Camera)) {
190
+ mediaObject = await camera.takePicture();
191
+ }
192
+ } catch (e) {
193
+ this.console.warn(`Failed to get thumbnail from camera ${cameraId}:`, e);
129
194
  }
130
- } catch (e) {
131
- this.console.warn(`Failed to get thumbnail from camera ${cameraId}:`, e);
132
195
  }
133
196
  }
134
197
 
@@ -161,6 +224,71 @@ export class AlertManager {
161
224
  }
162
225
  }
163
226
 
227
+ clearActiveAlertsForObject(globalId: GlobalTrackingId): void {
228
+ for (const [key, entry] of this.activeAlerts.entries()) {
229
+ if (entry.alert.trackedObjectId === globalId) {
230
+ this.activeAlerts.delete(key);
231
+ }
232
+ }
233
+ }
234
+
235
+ setUpdateNotificationOptions(enabled: boolean, cooldownMs: number): void {
236
+ this.notifyOnUpdates = enabled;
237
+ this.updateNotificationCooldownMs = Math.max(0, cooldownMs);
238
+ }
239
+
240
+ private async updateActiveAlert(
241
+ type: AlertType,
242
+ ruleId: string,
243
+ tracked: TrackedObject,
244
+ details: Partial<AlertDetails>
245
+ ): Promise<Alert | null> {
246
+ const key = getActiveAlertKey(type, ruleId, tracked.globalId);
247
+ const existing = this.activeAlerts.get(key);
248
+ if (!existing) return null;
249
+
250
+ const now = Date.now();
251
+ if (now - existing.lastUpdate > this.activeAlertTtlMs) {
252
+ this.activeAlerts.delete(key);
253
+ return null;
254
+ }
255
+
256
+ const updatedDetails: AlertDetails = {
257
+ ...existing.alert.details,
258
+ ...details,
259
+ objectClass: tracked.className,
260
+ objectLabel: details.objectLabel || tracked.label,
261
+ };
262
+
263
+ const shouldUpdate = hasMeaningfulAlertChange(existing.alert.details, updatedDetails);
264
+ if (!shouldUpdate) return existing.alert;
265
+
266
+ existing.alert.details = updatedDetails;
267
+ existing.alert.message = generateAlertMessage(type, updatedDetails);
268
+ existing.alert.timestamp = now;
269
+ existing.lastUpdate = now;
270
+
271
+ const idx = this.recentAlerts.findIndex(a => a.id === existing.alert.id);
272
+ if (idx >= 0) {
273
+ this.recentAlerts.splice(idx, 1);
274
+ }
275
+ this.recentAlerts.unshift(existing.alert);
276
+ if (this.recentAlerts.length > this.maxAlerts) {
277
+ this.recentAlerts.pop();
278
+ }
279
+
280
+ if (this.notifyOnUpdates) {
281
+ const rule = this.rules.find(r => r.id === ruleId);
282
+ if (rule && shouldSendUpdateNotification(this.notifyOnUpdates, existing.lastNotified, now, this.updateNotificationCooldownMs)) {
283
+ existing.lastNotified = now;
284
+ await this.sendNotifications(existing.alert, rule);
285
+ }
286
+ }
287
+
288
+ return existing.alert;
289
+ }
290
+
291
+
164
292
  /**
165
293
  * Get notification title based on alert type
166
294
  * For movement alerts with LLM descriptions, use the smart description as title
@@ -0,0 +1,32 @@
1
+ import { AlertDetails, AlertType } from '../models/alert';
2
+ import { GlobalTrackingId } from '../models/tracked-object';
3
+
4
+ export function hasMeaningfulAlertChange(prev: AlertDetails, next: AlertDetails): boolean {
5
+ return (
6
+ prev.fromCameraId !== next.fromCameraId ||
7
+ prev.toCameraId !== next.toCameraId ||
8
+ prev.cameraId !== next.cameraId ||
9
+ prev.objectLabel !== next.objectLabel ||
10
+ prev.pathDescription !== next.pathDescription ||
11
+ JSON.stringify(prev.involvedLandmarks || []) !== JSON.stringify(next.involvedLandmarks || [])
12
+ );
13
+ }
14
+
15
+ export function getActiveAlertKey(
16
+ type: AlertType,
17
+ ruleId: string,
18
+ trackedId: GlobalTrackingId
19
+ ): string {
20
+ return `${type}:${ruleId}:${trackedId}`;
21
+ }
22
+
23
+ export function shouldSendUpdateNotification(
24
+ enabled: boolean,
25
+ lastNotified: number,
26
+ now: number,
27
+ cooldownMs: number
28
+ ): boolean {
29
+ if (!enabled) return false;
30
+ if (cooldownMs <= 0) return true;
31
+ return now - lastNotified >= cooldownMs;
32
+ }
@@ -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;