@blueharford/scrypted-spatial-awareness 0.6.31 → 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.31",
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
  }
Binary file
@@ -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') {
@@ -328,15 +346,24 @@ export class TrackingEngine {
328
346
  }
329
347
 
330
348
  /** Check and record LLM call - returns false if rate limited */
331
- private tryLlmCall(): boolean {
349
+ private tryLlmCall(silent: boolean = false): boolean {
332
350
  if (!this.isLlmCallAllowed()) {
333
- this.console.log('[LLM] Rate limited, skipping LLM call');
351
+ // Only log once per rate limit window, not every call
352
+ if (!silent && !this.rateLimitLogged) {
353
+ const remaining = Math.ceil((this.config.llmDebounceInterval || 30000) - (Date.now() - this.lastLlmCallTime)) / 1000;
354
+ this.console.log(`[LLM] Rate limited, ${remaining.toFixed(0)}s until next call allowed`);
355
+ this.rateLimitLogged = true;
356
+ }
334
357
  return false;
335
358
  }
359
+ this.rateLimitLogged = false;
336
360
  this.recordLlmCall();
337
361
  return true;
338
362
  }
339
363
 
364
+ /** Track if we've already logged rate limit message */
365
+ private rateLimitLogged: boolean = false;
366
+
340
367
  /** Get spatial reasoning result for movement (uses RAG + LLM) with debouncing and fallback */
341
368
  private async getSpatialDescription(
342
369
  tracked: TrackedObject,
@@ -349,6 +376,15 @@ export class TrackingEngine {
349
376
  const fallbackTimeout = this.config.llmFallbackTimeout ?? 3000;
350
377
 
351
378
  try {
379
+ if (!this.config.useLlmDescriptions) {
380
+ return this.spatialReasoning.generateMovementDescription(
381
+ tracked,
382
+ fromCameraId,
383
+ toCameraId,
384
+ transitTime
385
+ );
386
+ }
387
+
352
388
  // Check rate limiting - if not allowed, return null to use basic description
353
389
  if (!this.tryLlmCall()) {
354
390
  this.console.log('[Movement] LLM rate-limited, using basic notification');
@@ -357,11 +393,9 @@ export class TrackingEngine {
357
393
 
358
394
  // Get snapshot from camera for LLM analysis (if LLM is enabled)
359
395
  let mediaObject: MediaObject | undefined;
360
- if (this.config.useLlmDescriptions) {
361
- const camera = systemManager.getDeviceById<Camera>(currentCameraId);
362
- if (camera?.interfaces?.includes(ScryptedInterface.Camera)) {
363
- mediaObject = await camera.takePicture();
364
- }
396
+ const camera = systemManager.getDeviceById<Camera>(currentCameraId);
397
+ if (camera?.interfaces?.includes(ScryptedInterface.Camera)) {
398
+ mediaObject = await camera.takePicture();
365
399
  }
366
400
 
367
401
  // Use spatial reasoning engine for rich context-aware description
@@ -451,6 +485,8 @@ export class TrackingEngine {
451
485
  // Check if this is a cross-camera transition
452
486
  const lastSighting = getLastSighting(tracked);
453
487
  if (lastSighting && lastSighting.cameraId !== sighting.cameraId) {
488
+ // Cancel any pending loitering alert if object already transitioned
489
+ this.clearLoiteringTimer(tracked.globalId);
454
490
  const transitDuration = sighting.timestamp - lastSighting.timestamp;
455
491
 
456
492
  // Update cached snapshot from new camera (object is now visible here)
@@ -482,33 +518,57 @@ export class TrackingEngine {
482
518
  );
483
519
 
484
520
  // Check loitering threshold and per-object cooldown before alerting
485
- if (this.passesLoiteringThreshold(tracked) && !this.isInAlertCooldown(tracked.globalId)) {
486
- // Get spatial reasoning result with RAG context
487
- const spatialResult = await this.getSpatialDescription(
488
- tracked,
489
- lastSighting.cameraId,
490
- sighting.cameraId,
491
- transitDuration,
492
- sighting.cameraId
493
- );
494
-
495
- // Generate movement alert for cross-camera transition
496
- await this.alertManager.checkAndAlert('movement', tracked, {
497
- fromCameraId: lastSighting.cameraId,
498
- fromCameraName: lastSighting.cameraName,
499
- toCameraId: sighting.cameraId,
500
- toCameraName: sighting.cameraName,
501
- transitTime: transitDuration,
502
- objectClass: sighting.detection.className,
503
- objectLabel: spatialResult?.description || sighting.detection.label,
504
- detectionId: sighting.detectionId,
505
- // Include spatial context for enriched alerts
506
- pathDescription: spatialResult?.pathDescription,
507
- involvedLandmarks: spatialResult?.involvedLandmarks?.map(l => l.name),
508
- usedLlm: spatialResult?.usedLlm,
509
- });
510
-
511
- 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
+ }
512
572
  }
513
573
  }
514
574
 
@@ -560,18 +620,49 @@ export class TrackingEngine {
560
620
  }
561
621
 
562
622
  // Check after loitering threshold if object is still being tracked
563
- setTimeout(async () => {
564
- const tracked = this.state.getObject(globalId);
565
- 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
+ }
566
628
 
567
- // Check if we've already alerted for this object
568
- if (this.isInAlertCooldown(globalId)) return;
629
+ const timer = setTimeout(async () => {
630
+ try {
631
+ const tracked = this.state.getObject(globalId);
632
+ if (!tracked || tracked.state !== 'active') return;
633
+
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
+ }
569
660
 
570
661
  // Use prefetched LLM result if available (started when snapshot was captured)
571
662
  let spatialResult: SpatialReasoningResult;
572
663
  const pendingDescription = this.pendingDescriptions.get(globalId);
573
664
 
574
- if (pendingDescription) {
665
+ if (pendingDescription) {
575
666
  this.console.log(`[Entry Alert] Using prefetched LLM result for ${globalId.slice(0, 8)}`);
576
667
  try {
577
668
  spatialResult = await pendingDescription;
@@ -582,7 +673,7 @@ export class TrackingEngine {
582
673
  spatialResult = await this.spatialReasoning.generateEntryDescription(tracked, sighting.cameraId);
583
674
  }
584
675
  this.pendingDescriptions.delete(globalId);
585
- } else {
676
+ } else {
586
677
  // No prefetch available - only call LLM if rate limit allows
587
678
  if (this.tryLlmCall()) {
588
679
  this.console.log(`[Entry Alert] No prefetch, generating with LLM`);
@@ -598,7 +689,8 @@ export class TrackingEngine {
598
689
 
599
690
  // Always use movement alert type for smart notifications with LLM descriptions
600
691
  // The property_entry/property_exit types are legacy and disabled by default
601
- await this.alertManager.checkAndAlert('movement', tracked, {
692
+ const mediaObject = this.snapshotCache.get(globalId);
693
+ await this.alertManager.checkAndAlert('movement', tracked, {
602
694
  cameraId: sighting.cameraId,
603
695
  cameraName: sighting.cameraName,
604
696
  toCameraId: sighting.cameraId,
@@ -608,10 +700,15 @@ export class TrackingEngine {
608
700
  detectionId: sighting.detectionId,
609
701
  involvedLandmarks: spatialResult.involvedLandmarks?.map(l => l.name),
610
702
  usedLlm: spatialResult.usedLlm,
611
- });
703
+ }, mediaObject);
612
704
 
613
- this.recordAlertTime(globalId);
705
+ this.recordAlertTime(globalId);
706
+ } finally {
707
+ this.loiteringTimers.delete(globalId);
708
+ }
614
709
  }, this.config.loiteringThreshold);
710
+
711
+ this.loiteringTimers.set(globalId, timer);
615
712
  }
616
713
 
617
714
  /** Capture and cache a snapshot for a tracked object, and start LLM analysis immediately */
@@ -703,6 +800,9 @@ export class TrackingEngine {
703
800
  // Mark as pending and set timer
704
801
  this.state.markPending(tracked.globalId);
705
802
 
803
+ // Cancel any pending loitering alert
804
+ this.clearLoiteringTimer(tracked.globalId);
805
+
706
806
  // Capture a fresh snapshot now while object is still visible (before they leave)
707
807
  // Also starts LLM analysis immediately in parallel
708
808
  if (this.config.useLlmDescriptions) {
@@ -747,6 +847,7 @@ export class TrackingEngine {
747
847
  }
748
848
 
749
849
  // Use movement alert for exit too - smart notifications with LLM descriptions
850
+ const mediaObject = this.snapshotCache.get(tracked.globalId);
750
851
  await this.alertManager.checkAndAlert('movement', current, {
751
852
  cameraId: sighting.cameraId,
752
853
  cameraName: sighting.cameraName,
@@ -756,7 +857,9 @@ export class TrackingEngine {
756
857
  objectLabel: spatialResult.description,
757
858
  involvedLandmarks: spatialResult.involvedLandmarks?.map(l => l.name),
758
859
  usedLlm: spatialResult.usedLlm,
759
- });
860
+ }, mediaObject);
861
+
862
+ this.alertManager.clearActiveAlertsForObject(tracked.globalId);
760
863
 
761
864
  // Clean up cached snapshot and pending descriptions after exit alert
762
865
  this.snapshotCache.delete(tracked.globalId);
@@ -778,6 +881,7 @@ export class TrackingEngine {
778
881
 
779
882
  if (timeSinceSeen > this.config.lostTimeout) {
780
883
  this.state.markLost(tracked.globalId);
884
+ this.clearLoiteringTimer(tracked.globalId);
781
885
  this.console.log(
782
886
  `Object ${tracked.globalId.slice(0, 8)} marked as lost ` +
783
887
  `(not seen for ${Math.round(timeSinceSeen / 1000)}s)`
@@ -791,10 +895,21 @@ export class TrackingEngine {
791
895
  objectClass: tracked.className,
792
896
  objectLabel: tracked.label,
793
897
  });
898
+
899
+ this.alertManager.clearActiveAlertsForObject(tracked.globalId);
794
900
  }
795
901
  }
796
902
  }
797
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
+
798
913
  /** Update topology configuration */
799
914
  updateTopology(topology: CameraTopology): void {
800
915
  this.topology = topology;