@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.
@@ -34385,13 +34385,23 @@ var __importStar = (this && this.__importStar) || (function () {
34385
34385
  })();
34386
34386
  Object.defineProperty(exports, "__esModule", ({ value: true }));
34387
34387
  exports.AlertManager = void 0;
34388
- const sdk_1 = __importStar(__webpack_require__(/*! @scrypted/sdk */ "./node_modules/@scrypted/sdk/dist/src/index.js"));
34389
34388
  const alert_1 = __webpack_require__(/*! ../models/alert */ "./src/models/alert.ts");
34390
- const { systemManager, mediaManager } = sdk_1.default;
34389
+ const alert_utils_1 = __webpack_require__(/*! ./alert-utils */ "./src/alerts/alert-utils.ts");
34390
+ let sdkModule = null;
34391
+ const getSdk = async () => {
34392
+ if (!sdkModule) {
34393
+ sdkModule = await Promise.resolve().then(() => __importStar(__webpack_require__(/*! @scrypted/sdk */ "./node_modules/@scrypted/sdk/dist/src/index.js")));
34394
+ }
34395
+ return sdkModule;
34396
+ };
34391
34397
  class AlertManager {
34392
34398
  rules = [];
34393
34399
  recentAlerts = [];
34394
34400
  cooldowns = new Map();
34401
+ activeAlerts = new Map();
34402
+ activeAlertTtlMs = 10 * 60 * 1000;
34403
+ notifyOnUpdates = false;
34404
+ updateNotificationCooldownMs = 60000;
34395
34405
  console;
34396
34406
  storage;
34397
34407
  maxAlerts = 100;
@@ -34403,7 +34413,7 @@ class AlertManager {
34403
34413
  /**
34404
34414
  * Check if an alert should be generated and send it
34405
34415
  */
34406
- async checkAndAlert(type, tracked, details) {
34416
+ async checkAndAlert(type, tracked, details, mediaObjectOverride) {
34407
34417
  // Find matching rule
34408
34418
  const rule = this.rules.find(r => r.type === type && r.enabled);
34409
34419
  if (!rule)
@@ -34420,16 +34430,22 @@ class AlertManager {
34420
34430
  return null;
34421
34431
  }
34422
34432
  }
34423
- // Check cooldown
34433
+ // Check conditions
34434
+ if (!this.evaluateConditions(rule.conditions, tracked)) {
34435
+ return null;
34436
+ }
34437
+ // Update existing movement alert if active (prevents alert spam)
34438
+ if (type === 'movement') {
34439
+ const updated = await this.updateActiveAlert(type, rule.id, tracked, details);
34440
+ if (updated)
34441
+ return updated;
34442
+ }
34443
+ // Check cooldown (only for new alerts)
34424
34444
  const cooldownKey = `${rule.id}:${tracked.globalId}`;
34425
34445
  const lastAlert = this.cooldowns.get(cooldownKey) || 0;
34426
34446
  if (rule.cooldown > 0 && Date.now() - lastAlert < rule.cooldown) {
34427
34447
  return null;
34428
34448
  }
34429
- // Check conditions
34430
- if (!this.evaluateConditions(rule.conditions, tracked)) {
34431
- return null;
34432
- }
34433
34449
  // Create alert
34434
34450
  // Note: details.objectLabel may contain LLM-generated description - preserve it if provided
34435
34451
  const fullDetails = {
@@ -34443,17 +34459,43 @@ class AlertManager {
34443
34459
  if (this.recentAlerts.length > this.maxAlerts) {
34444
34460
  this.recentAlerts.pop();
34445
34461
  }
34462
+ if (type === 'movement') {
34463
+ const key = (0, alert_utils_1.getActiveAlertKey)(type, rule.id, tracked.globalId);
34464
+ this.activeAlerts.set(key, { alert, lastUpdate: Date.now(), lastNotified: alert.timestamp });
34465
+ }
34446
34466
  // Update cooldown
34447
34467
  this.cooldowns.set(cooldownKey, Date.now());
34448
34468
  // Send notifications
34449
- await this.sendNotifications(alert, rule);
34469
+ await this.sendNotifications(alert, rule, mediaObjectOverride);
34450
34470
  this.console.log(`Alert generated: [${alert.severity}] ${alert.message}`);
34451
34471
  return alert;
34452
34472
  }
34473
+ async updateMovementAlert(tracked, details) {
34474
+ const rule = this.rules.find(r => r.type === 'movement' && r.enabled);
34475
+ if (!rule)
34476
+ return null;
34477
+ if (rule.objectClasses && rule.objectClasses.length > 0) {
34478
+ if (!rule.objectClasses.includes(tracked.className)) {
34479
+ return null;
34480
+ }
34481
+ }
34482
+ if (rule.cameraIds && rule.cameraIds.length > 0 && details.cameraId) {
34483
+ if (!rule.cameraIds.includes(details.cameraId)) {
34484
+ return null;
34485
+ }
34486
+ }
34487
+ if (!this.evaluateConditions(rule.conditions, tracked)) {
34488
+ return null;
34489
+ }
34490
+ return this.updateActiveAlert('movement', rule.id, tracked, details);
34491
+ }
34453
34492
  /**
34454
34493
  * Send notifications for an alert
34455
34494
  */
34456
- async sendNotifications(alert, rule) {
34495
+ async sendNotifications(alert, rule, mediaObjectOverride) {
34496
+ const sdkModule = await getSdk();
34497
+ const { ScryptedInterface } = sdkModule;
34498
+ const { systemManager } = sdkModule.default;
34457
34499
  const notifierIds = rule.notifiers.length > 0
34458
34500
  ? rule.notifiers
34459
34501
  : this.getDefaultNotifiers();
@@ -34464,17 +34506,19 @@ class AlertManager {
34464
34506
  return;
34465
34507
  }
34466
34508
  // Try to get a thumbnail from the camera
34467
- let mediaObject;
34468
- const cameraId = alert.details.toCameraId || alert.details.cameraId;
34469
- if (cameraId) {
34470
- try {
34471
- const camera = systemManager.getDeviceById(cameraId);
34472
- if (camera && camera.interfaces?.includes(sdk_1.ScryptedInterface.Camera)) {
34473
- mediaObject = await camera.takePicture();
34509
+ let mediaObject = mediaObjectOverride;
34510
+ if (!mediaObject) {
34511
+ const cameraId = alert.details.toCameraId || alert.details.cameraId;
34512
+ if (cameraId) {
34513
+ try {
34514
+ const camera = systemManager.getDeviceById(cameraId);
34515
+ if (camera && camera.interfaces?.includes(ScryptedInterface.Camera)) {
34516
+ mediaObject = await camera.takePicture();
34517
+ }
34518
+ }
34519
+ catch (e) {
34520
+ this.console.warn(`Failed to get thumbnail from camera ${cameraId}:`, e);
34474
34521
  }
34475
- }
34476
- catch (e) {
34477
- this.console.warn(`Failed to get thumbnail from camera ${cameraId}:`, e);
34478
34522
  }
34479
34523
  }
34480
34524
  for (const notifierId of notifierIds) {
@@ -34500,6 +34544,57 @@ class AlertManager {
34500
34544
  }
34501
34545
  }
34502
34546
  }
34547
+ clearActiveAlertsForObject(globalId) {
34548
+ for (const [key, entry] of this.activeAlerts.entries()) {
34549
+ if (entry.alert.trackedObjectId === globalId) {
34550
+ this.activeAlerts.delete(key);
34551
+ }
34552
+ }
34553
+ }
34554
+ setUpdateNotificationOptions(enabled, cooldownMs) {
34555
+ this.notifyOnUpdates = enabled;
34556
+ this.updateNotificationCooldownMs = Math.max(0, cooldownMs);
34557
+ }
34558
+ async updateActiveAlert(type, ruleId, tracked, details) {
34559
+ const key = (0, alert_utils_1.getActiveAlertKey)(type, ruleId, tracked.globalId);
34560
+ const existing = this.activeAlerts.get(key);
34561
+ if (!existing)
34562
+ return null;
34563
+ const now = Date.now();
34564
+ if (now - existing.lastUpdate > this.activeAlertTtlMs) {
34565
+ this.activeAlerts.delete(key);
34566
+ return null;
34567
+ }
34568
+ const updatedDetails = {
34569
+ ...existing.alert.details,
34570
+ ...details,
34571
+ objectClass: tracked.className,
34572
+ objectLabel: details.objectLabel || tracked.label,
34573
+ };
34574
+ const shouldUpdate = (0, alert_utils_1.hasMeaningfulAlertChange)(existing.alert.details, updatedDetails);
34575
+ if (!shouldUpdate)
34576
+ return existing.alert;
34577
+ existing.alert.details = updatedDetails;
34578
+ existing.alert.message = (0, alert_1.generateAlertMessage)(type, updatedDetails);
34579
+ existing.alert.timestamp = now;
34580
+ existing.lastUpdate = now;
34581
+ const idx = this.recentAlerts.findIndex(a => a.id === existing.alert.id);
34582
+ if (idx >= 0) {
34583
+ this.recentAlerts.splice(idx, 1);
34584
+ }
34585
+ this.recentAlerts.unshift(existing.alert);
34586
+ if (this.recentAlerts.length > this.maxAlerts) {
34587
+ this.recentAlerts.pop();
34588
+ }
34589
+ if (this.notifyOnUpdates) {
34590
+ const rule = this.rules.find(r => r.id === ruleId);
34591
+ if (rule && (0, alert_utils_1.shouldSendUpdateNotification)(this.notifyOnUpdates, existing.lastNotified, now, this.updateNotificationCooldownMs)) {
34592
+ existing.lastNotified = now;
34593
+ await this.sendNotifications(existing.alert, rule);
34594
+ }
34595
+ }
34596
+ return existing.alert;
34597
+ }
34503
34598
  /**
34504
34599
  * Get notification title based on alert type
34505
34600
  * For movement alerts with LLM descriptions, use the smart description as title
@@ -34750,6 +34845,40 @@ class AlertManager {
34750
34845
  exports.AlertManager = AlertManager;
34751
34846
 
34752
34847
 
34848
+ /***/ },
34849
+
34850
+ /***/ "./src/alerts/alert-utils.ts"
34851
+ /*!***********************************!*\
34852
+ !*** ./src/alerts/alert-utils.ts ***!
34853
+ \***********************************/
34854
+ (__unused_webpack_module, exports) {
34855
+
34856
+ "use strict";
34857
+
34858
+ Object.defineProperty(exports, "__esModule", ({ value: true }));
34859
+ exports.hasMeaningfulAlertChange = hasMeaningfulAlertChange;
34860
+ exports.getActiveAlertKey = getActiveAlertKey;
34861
+ exports.shouldSendUpdateNotification = shouldSendUpdateNotification;
34862
+ function hasMeaningfulAlertChange(prev, next) {
34863
+ return (prev.fromCameraId !== next.fromCameraId ||
34864
+ prev.toCameraId !== next.toCameraId ||
34865
+ prev.cameraId !== next.cameraId ||
34866
+ prev.objectLabel !== next.objectLabel ||
34867
+ prev.pathDescription !== next.pathDescription ||
34868
+ JSON.stringify(prev.involvedLandmarks || []) !== JSON.stringify(next.involvedLandmarks || []));
34869
+ }
34870
+ function getActiveAlertKey(type, ruleId, trackedId) {
34871
+ return `${type}:${ruleId}:${trackedId}`;
34872
+ }
34873
+ function shouldSendUpdateNotification(enabled, lastNotified, now, cooldownMs) {
34874
+ if (!enabled)
34875
+ return false;
34876
+ if (cooldownMs <= 0)
34877
+ return true;
34878
+ return now - lastNotified >= cooldownMs;
34879
+ }
34880
+
34881
+
34753
34882
  /***/ },
34754
34883
 
34755
34884
  /***/ "./src/core/object-correlator.ts"
@@ -37324,7 +37453,9 @@ class TrackingEngine {
37324
37453
  spatialReasoning;
37325
37454
  listeners = new Map();
37326
37455
  pendingTimers = new Map();
37456
+ loiteringTimers = new Map();
37327
37457
  lostCheckInterval = null;
37458
+ cleanupInterval = null;
37328
37459
  /** Track last alert time per object to enforce cooldown */
37329
37460
  objectLastAlertTime = new Map();
37330
37461
  /** Callback for topology changes (e.g., landmark suggestions) */
@@ -37407,6 +37538,9 @@ class TrackingEngine {
37407
37538
  this.lostCheckInterval = setInterval(() => {
37408
37539
  this.checkForLostObjects();
37409
37540
  }, 30000); // Check every 30 seconds
37541
+ this.cleanupInterval = setInterval(() => {
37542
+ this.state.cleanup();
37543
+ }, 300000); // Cleanup every 5 minutes
37410
37544
  this.console.log(`Tracking engine started with ${this.listeners.size} cameras`);
37411
37545
  }
37412
37546
  /** Stop all camera listeners */
@@ -37426,11 +37560,19 @@ class TrackingEngine {
37426
37560
  clearTimeout(timer);
37427
37561
  }
37428
37562
  this.pendingTimers.clear();
37563
+ for (const timer of this.loiteringTimers.values()) {
37564
+ clearTimeout(timer);
37565
+ }
37566
+ this.loiteringTimers.clear();
37429
37567
  // Stop lost check interval
37430
37568
  if (this.lostCheckInterval) {
37431
37569
  clearInterval(this.lostCheckInterval);
37432
37570
  this.lostCheckInterval = null;
37433
37571
  }
37572
+ if (this.cleanupInterval) {
37573
+ clearInterval(this.cleanupInterval);
37574
+ this.cleanupInterval = null;
37575
+ }
37434
37576
  this.console.log('Tracking engine stopped');
37435
37577
  }
37436
37578
  /** Handle detection event from a camera */
@@ -37445,7 +37587,7 @@ class TrackingEngine {
37445
37587
  const timestamp = detected.timestamp || Date.now();
37446
37588
  for (const detection of detected.detections) {
37447
37589
  // Skip low-confidence detections
37448
- if (detection.score < 0.5)
37590
+ if (detection.score < this.config.minDetectionScore)
37449
37591
  continue;
37450
37592
  // If in training mode, record trainer detections
37451
37593
  if (this.isTrainingActive() && detection.className === 'person') {
@@ -37523,6 +37665,9 @@ class TrackingEngine {
37523
37665
  const fallbackEnabled = this.config.llmFallbackEnabled ?? true;
37524
37666
  const fallbackTimeout = this.config.llmFallbackTimeout ?? 3000;
37525
37667
  try {
37668
+ if (!this.config.useLlmDescriptions) {
37669
+ return this.spatialReasoning.generateMovementDescription(tracked, fromCameraId, toCameraId, transitTime);
37670
+ }
37526
37671
  // Check rate limiting - if not allowed, return null to use basic description
37527
37672
  if (!this.tryLlmCall()) {
37528
37673
  this.console.log('[Movement] LLM rate-limited, using basic notification');
@@ -37530,11 +37675,9 @@ class TrackingEngine {
37530
37675
  }
37531
37676
  // Get snapshot from camera for LLM analysis (if LLM is enabled)
37532
37677
  let mediaObject;
37533
- if (this.config.useLlmDescriptions) {
37534
- const camera = systemManager.getDeviceById(currentCameraId);
37535
- if (camera?.interfaces?.includes(sdk_1.ScryptedInterface.Camera)) {
37536
- mediaObject = await camera.takePicture();
37537
- }
37678
+ const camera = systemManager.getDeviceById(currentCameraId);
37679
+ if (camera?.interfaces?.includes(sdk_1.ScryptedInterface.Camera)) {
37680
+ mediaObject = await camera.takePicture();
37538
37681
  }
37539
37682
  // Use spatial reasoning engine for rich context-aware description
37540
37683
  // Apply timeout if fallback is enabled
@@ -37591,6 +37734,8 @@ class TrackingEngine {
37591
37734
  // Check if this is a cross-camera transition
37592
37735
  const lastSighting = (0, tracked_object_1.getLastSighting)(tracked);
37593
37736
  if (lastSighting && lastSighting.cameraId !== sighting.cameraId) {
37737
+ // Cancel any pending loitering alert if object already transitioned
37738
+ this.clearLoiteringTimer(tracked.globalId);
37594
37739
  const transitDuration = sighting.timestamp - lastSighting.timestamp;
37595
37740
  // Update cached snapshot from new camera (object is now visible here)
37596
37741
  if (this.config.useLlmDescriptions) {
@@ -37615,25 +37760,44 @@ class TrackingEngine {
37615
37760
  `${lastSighting.cameraName} → ${sighting.cameraName} ` +
37616
37761
  `(confidence: ${(correlation.confidence * 100).toFixed(0)}%)`);
37617
37762
  // Check loitering threshold and per-object cooldown before alerting
37618
- if (this.passesLoiteringThreshold(tracked) && !this.isInAlertCooldown(tracked.globalId)) {
37619
- // Get spatial reasoning result with RAG context
37620
- const spatialResult = await this.getSpatialDescription(tracked, lastSighting.cameraId, sighting.cameraId, transitDuration, sighting.cameraId);
37621
- // Generate movement alert for cross-camera transition
37622
- await this.alertManager.checkAndAlert('movement', tracked, {
37623
- fromCameraId: lastSighting.cameraId,
37624
- fromCameraName: lastSighting.cameraName,
37625
- toCameraId: sighting.cameraId,
37626
- toCameraName: sighting.cameraName,
37627
- transitTime: transitDuration,
37628
- objectClass: sighting.detection.className,
37629
- objectLabel: spatialResult?.description || sighting.detection.label,
37630
- detectionId: sighting.detectionId,
37631
- // Include spatial context for enriched alerts
37632
- pathDescription: spatialResult?.pathDescription,
37633
- involvedLandmarks: spatialResult?.involvedLandmarks?.map(l => l.name),
37634
- usedLlm: spatialResult?.usedLlm,
37635
- });
37636
- this.recordAlertTime(tracked.globalId);
37763
+ if (this.passesLoiteringThreshold(tracked)) {
37764
+ if (this.isInAlertCooldown(tracked.globalId)) {
37765
+ const spatialResult = await this.spatialReasoning.generateMovementDescription(tracked, lastSighting.cameraId, sighting.cameraId, transitDuration);
37766
+ await this.alertManager.updateMovementAlert(tracked, {
37767
+ fromCameraId: lastSighting.cameraId,
37768
+ fromCameraName: lastSighting.cameraName,
37769
+ toCameraId: sighting.cameraId,
37770
+ toCameraName: sighting.cameraName,
37771
+ transitTime: transitDuration,
37772
+ objectClass: sighting.detection.className,
37773
+ objectLabel: spatialResult.description || sighting.detection.label,
37774
+ detectionId: sighting.detectionId,
37775
+ pathDescription: spatialResult.pathDescription,
37776
+ involvedLandmarks: spatialResult.involvedLandmarks?.map(l => l.name),
37777
+ usedLlm: spatialResult.usedLlm,
37778
+ });
37779
+ }
37780
+ else {
37781
+ // Get spatial reasoning result with RAG context
37782
+ const spatialResult = await this.getSpatialDescription(tracked, lastSighting.cameraId, sighting.cameraId, transitDuration, sighting.cameraId);
37783
+ // Generate movement alert for cross-camera transition
37784
+ const mediaObject = this.snapshotCache.get(tracked.globalId);
37785
+ await this.alertManager.checkAndAlert('movement', tracked, {
37786
+ fromCameraId: lastSighting.cameraId,
37787
+ fromCameraName: lastSighting.cameraName,
37788
+ toCameraId: sighting.cameraId,
37789
+ toCameraName: sighting.cameraName,
37790
+ transitTime: transitDuration,
37791
+ objectClass: sighting.detection.className,
37792
+ objectLabel: spatialResult?.description || sighting.detection.label,
37793
+ detectionId: sighting.detectionId,
37794
+ // Include spatial context for enriched alerts
37795
+ pathDescription: spatialResult?.pathDescription,
37796
+ involvedLandmarks: spatialResult?.involvedLandmarks?.map(l => l.name),
37797
+ usedLlm: spatialResult?.usedLlm,
37798
+ }, mediaObject);
37799
+ this.recordAlertTime(tracked.globalId);
37800
+ }
37637
37801
  }
37638
37802
  }
37639
37803
  // Add sighting to tracked object
@@ -37672,58 +37836,91 @@ class TrackingEngine {
37672
37836
  });
37673
37837
  }
37674
37838
  // Check after loitering threshold if object is still being tracked
37675
- setTimeout(async () => {
37676
- const tracked = this.state.getObject(globalId);
37677
- if (!tracked || tracked.state !== 'active')
37678
- return;
37679
- // Check if we've already alerted for this object
37680
- if (this.isInAlertCooldown(globalId))
37681
- return;
37682
- // Use prefetched LLM result if available (started when snapshot was captured)
37683
- let spatialResult;
37684
- const pendingDescription = this.pendingDescriptions.get(globalId);
37685
- if (pendingDescription) {
37686
- this.console.log(`[Entry Alert] Using prefetched LLM result for ${globalId.slice(0, 8)}`);
37687
- try {
37688
- spatialResult = await pendingDescription;
37689
- this.console.log(`[Entry Alert] Prefetch result: "${spatialResult.description.substring(0, 60)}...", usedLlm=${spatialResult.usedLlm}`);
37839
+ const existing = this.loiteringTimers.get(globalId);
37840
+ if (existing) {
37841
+ clearTimeout(existing);
37842
+ this.loiteringTimers.delete(globalId);
37843
+ }
37844
+ const timer = setTimeout(async () => {
37845
+ try {
37846
+ const tracked = this.state.getObject(globalId);
37847
+ if (!tracked || tracked.state !== 'active')
37848
+ return;
37849
+ const lastSighting = (0, tracked_object_1.getLastSighting)(tracked);
37850
+ if (!lastSighting || lastSighting.cameraId !== sighting.cameraId) {
37851
+ return;
37690
37852
  }
37691
- catch (e) {
37692
- this.console.warn(`[Entry Alert] Prefetch failed, using basic description: ${e}`);
37693
- // Don't make another LLM call - use basic description (no mediaObject = no LLM)
37694
- spatialResult = await this.spatialReasoning.generateEntryDescription(tracked, sighting.cameraId);
37853
+ const maxStaleMs = Math.max(10000, this.config.loiteringThreshold * 2);
37854
+ if (Date.now() - lastSighting.timestamp > maxStaleMs) {
37855
+ return;
37695
37856
  }
37696
- this.pendingDescriptions.delete(globalId);
37697
- }
37698
- else {
37699
- // No prefetch available - only call LLM if rate limit allows
37700
- if (this.tryLlmCall()) {
37701
- this.console.log(`[Entry Alert] No prefetch, generating with LLM`);
37702
- const mediaObject = this.snapshotCache.get(globalId);
37703
- spatialResult = await this.spatialReasoning.generateEntryDescription(tracked, sighting.cameraId, mediaObject);
37704
- this.console.log(`[Entry Alert] Got description: "${spatialResult.description.substring(0, 60)}...", usedLlm=${spatialResult.usedLlm}`);
37857
+ // Check if we've already alerted for this object
37858
+ if (this.isInAlertCooldown(globalId)) {
37859
+ const spatialResult = await this.spatialReasoning.generateEntryDescription(tracked, sighting.cameraId);
37860
+ await this.alertManager.updateMovementAlert(tracked, {
37861
+ cameraId: sighting.cameraId,
37862
+ cameraName: sighting.cameraName,
37863
+ toCameraId: sighting.cameraId,
37864
+ toCameraName: sighting.cameraName,
37865
+ objectClass: sighting.detection.className,
37866
+ objectLabel: spatialResult.description,
37867
+ detectionId: sighting.detectionId,
37868
+ involvedLandmarks: spatialResult.involvedLandmarks?.map(l => l.name),
37869
+ usedLlm: spatialResult.usedLlm,
37870
+ });
37871
+ return;
37872
+ }
37873
+ // Use prefetched LLM result if available (started when snapshot was captured)
37874
+ let spatialResult;
37875
+ const pendingDescription = this.pendingDescriptions.get(globalId);
37876
+ if (pendingDescription) {
37877
+ this.console.log(`[Entry Alert] Using prefetched LLM result for ${globalId.slice(0, 8)}`);
37878
+ try {
37879
+ spatialResult = await pendingDescription;
37880
+ this.console.log(`[Entry Alert] Prefetch result: "${spatialResult.description.substring(0, 60)}...", usedLlm=${spatialResult.usedLlm}`);
37881
+ }
37882
+ catch (e) {
37883
+ this.console.warn(`[Entry Alert] Prefetch failed, using basic description: ${e}`);
37884
+ // Don't make another LLM call - use basic description (no mediaObject = no LLM)
37885
+ spatialResult = await this.spatialReasoning.generateEntryDescription(tracked, sighting.cameraId);
37886
+ }
37887
+ this.pendingDescriptions.delete(globalId);
37705
37888
  }
37706
37889
  else {
37707
- // Rate limited - use basic description (no LLM)
37708
- this.console.log(`[Entry Alert] Rate limited, using basic description`);
37709
- spatialResult = await this.spatialReasoning.generateEntryDescription(tracked, sighting.cameraId);
37710
- }
37711
- }
37712
- // Always use movement alert type for smart notifications with LLM descriptions
37713
- // The property_entry/property_exit types are legacy and disabled by default
37714
- await this.alertManager.checkAndAlert('movement', tracked, {
37715
- cameraId: sighting.cameraId,
37716
- cameraName: sighting.cameraName,
37717
- toCameraId: sighting.cameraId,
37718
- toCameraName: sighting.cameraName,
37719
- objectClass: sighting.detection.className,
37720
- objectLabel: spatialResult.description, // Smart LLM-generated description
37721
- detectionId: sighting.detectionId,
37722
- involvedLandmarks: spatialResult.involvedLandmarks?.map(l => l.name),
37723
- usedLlm: spatialResult.usedLlm,
37724
- });
37725
- this.recordAlertTime(globalId);
37890
+ // No prefetch available - only call LLM if rate limit allows
37891
+ if (this.tryLlmCall()) {
37892
+ this.console.log(`[Entry Alert] No prefetch, generating with LLM`);
37893
+ const mediaObject = this.snapshotCache.get(globalId);
37894
+ spatialResult = await this.spatialReasoning.generateEntryDescription(tracked, sighting.cameraId, mediaObject);
37895
+ this.console.log(`[Entry Alert] Got description: "${spatialResult.description.substring(0, 60)}...", usedLlm=${spatialResult.usedLlm}`);
37896
+ }
37897
+ else {
37898
+ // Rate limited - use basic description (no LLM)
37899
+ this.console.log(`[Entry Alert] Rate limited, using basic description`);
37900
+ spatialResult = await this.spatialReasoning.generateEntryDescription(tracked, sighting.cameraId);
37901
+ }
37902
+ }
37903
+ // Always use movement alert type for smart notifications with LLM descriptions
37904
+ // The property_entry/property_exit types are legacy and disabled by default
37905
+ const mediaObject = this.snapshotCache.get(globalId);
37906
+ await this.alertManager.checkAndAlert('movement', tracked, {
37907
+ cameraId: sighting.cameraId,
37908
+ cameraName: sighting.cameraName,
37909
+ toCameraId: sighting.cameraId,
37910
+ toCameraName: sighting.cameraName,
37911
+ objectClass: sighting.detection.className,
37912
+ objectLabel: spatialResult.description, // Smart LLM-generated description
37913
+ detectionId: sighting.detectionId,
37914
+ involvedLandmarks: spatialResult.involvedLandmarks?.map(l => l.name),
37915
+ usedLlm: spatialResult.usedLlm,
37916
+ }, mediaObject);
37917
+ this.recordAlertTime(globalId);
37918
+ }
37919
+ finally {
37920
+ this.loiteringTimers.delete(globalId);
37921
+ }
37726
37922
  }, this.config.loiteringThreshold);
37923
+ this.loiteringTimers.set(globalId, timer);
37727
37924
  }
37728
37925
  /** Capture and cache a snapshot for a tracked object, and start LLM analysis immediately */
37729
37926
  async captureAndCacheSnapshot(globalId, cameraId, eventType = 'entry') {
@@ -37798,6 +37995,8 @@ class TrackingEngine {
37798
37995
  handlePotentialExit(tracked, sighting) {
37799
37996
  // Mark as pending and set timer
37800
37997
  this.state.markPending(tracked.globalId);
37998
+ // Cancel any pending loitering alert
37999
+ this.clearLoiteringTimer(tracked.globalId);
37801
38000
  // Capture a fresh snapshot now while object is still visible (before they leave)
37802
38001
  // Also starts LLM analysis immediately in parallel
37803
38002
  if (this.config.useLlmDescriptions) {
@@ -37841,6 +38040,7 @@ class TrackingEngine {
37841
38040
  }
37842
38041
  }
37843
38042
  // Use movement alert for exit too - smart notifications with LLM descriptions
38043
+ const mediaObject = this.snapshotCache.get(tracked.globalId);
37844
38044
  await this.alertManager.checkAndAlert('movement', current, {
37845
38045
  cameraId: sighting.cameraId,
37846
38046
  cameraName: sighting.cameraName,
@@ -37850,7 +38050,8 @@ class TrackingEngine {
37850
38050
  objectLabel: spatialResult.description,
37851
38051
  involvedLandmarks: spatialResult.involvedLandmarks?.map(l => l.name),
37852
38052
  usedLlm: spatialResult.usedLlm,
37853
- });
38053
+ }, mediaObject);
38054
+ this.alertManager.clearActiveAlertsForObject(tracked.globalId);
37854
38055
  // Clean up cached snapshot and pending descriptions after exit alert
37855
38056
  this.snapshotCache.delete(tracked.globalId);
37856
38057
  this.pendingDescriptions.delete(tracked.globalId);
@@ -37867,6 +38068,7 @@ class TrackingEngine {
37867
38068
  const timeSinceSeen = now - tracked.lastSeen;
37868
38069
  if (timeSinceSeen > this.config.lostTimeout) {
37869
38070
  this.state.markLost(tracked.globalId);
38071
+ this.clearLoiteringTimer(tracked.globalId);
37870
38072
  this.console.log(`Object ${tracked.globalId.slice(0, 8)} marked as lost ` +
37871
38073
  `(not seen for ${Math.round(timeSinceSeen / 1000)}s)`);
37872
38074
  // Clean up cached snapshot and pending descriptions
@@ -37876,9 +38078,18 @@ class TrackingEngine {
37876
38078
  objectClass: tracked.className,
37877
38079
  objectLabel: tracked.label,
37878
38080
  });
38081
+ this.alertManager.clearActiveAlertsForObject(tracked.globalId);
37879
38082
  }
37880
38083
  }
37881
38084
  }
38085
+ /** Clear a pending loitering timer if present */
38086
+ clearLoiteringTimer(globalId) {
38087
+ const timer = this.loiteringTimers.get(globalId);
38088
+ if (timer) {
38089
+ clearTimeout(timer);
38090
+ this.loiteringTimers.delete(globalId);
38091
+ }
38092
+ }
37882
38093
  /** Update topology configuration */
37883
38094
  updateTopology(topology) {
37884
38095
  this.topology = topology;
@@ -39363,6 +39574,13 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
39363
39574
  description: 'Object must be visible for this duration before triggering movement alerts',
39364
39575
  group: 'Tracking',
39365
39576
  },
39577
+ minDetectionScore: {
39578
+ title: 'Minimum Detection Confidence',
39579
+ type: 'number',
39580
+ defaultValue: 0.5,
39581
+ description: 'Minimum detection score (0-1) to consider for tracking',
39582
+ group: 'Tracking',
39583
+ },
39366
39584
  objectAlertCooldown: {
39367
39585
  title: 'Per-Object Alert Cooldown (seconds)',
39368
39586
  type: 'number',
@@ -39370,6 +39588,20 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
39370
39588
  description: 'Minimum time between alerts for the same tracked object',
39371
39589
  group: 'Tracking',
39372
39590
  },
39591
+ notifyOnAlertUpdates: {
39592
+ title: 'Notify on Alert Updates',
39593
+ type: 'boolean',
39594
+ defaultValue: false,
39595
+ description: 'Send notifications when an existing alert is updated (camera transitions, context changes)',
39596
+ group: 'Tracking',
39597
+ },
39598
+ alertUpdateCooldown: {
39599
+ title: 'Alert Update Cooldown (seconds)',
39600
+ type: 'number',
39601
+ defaultValue: 60,
39602
+ description: 'Minimum time between update notifications for the same tracked object (0 = no limit)',
39603
+ group: 'Tracking',
39604
+ },
39373
39605
  // LLM Integration
39374
39606
  useLlmDescriptions: {
39375
39607
  title: 'Use LLM for Rich Descriptions',
@@ -39562,6 +39794,7 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
39562
39794
  if (this.storageSettings.values.enableMqtt) {
39563
39795
  await this.initializeMqtt();
39564
39796
  }
39797
+ this.applyAlertUpdateSettings();
39565
39798
  this.console.log('Spatial Awareness Plugin initialized');
39566
39799
  }
39567
39800
  async initializeMqtt() {
@@ -39590,23 +39823,25 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
39590
39823
  await this.trackingEngine.stopTracking();
39591
39824
  }
39592
39825
  const config = {
39593
- correlationWindow: (this.storageSettings.values.correlationWindow || 30) * 1000,
39594
- correlationThreshold: this.storageSettings.values.correlationThreshold || 0.35,
39595
- lostTimeout: (this.storageSettings.values.lostTimeout || 300) * 1000,
39826
+ correlationWindow: this.getNumberSetting(this.storageSettings.values.correlationWindow, 30) * 1000,
39827
+ correlationThreshold: this.getNumberSetting(this.storageSettings.values.correlationThreshold, 0.35),
39828
+ lostTimeout: this.getNumberSetting(this.storageSettings.values.lostTimeout, 300) * 1000,
39596
39829
  useVisualMatching: this.storageSettings.values.useVisualMatching ?? true,
39597
- loiteringThreshold: (this.storageSettings.values.loiteringThreshold || 3) * 1000,
39598
- objectAlertCooldown: (this.storageSettings.values.objectAlertCooldown || 30) * 1000,
39830
+ loiteringThreshold: this.getNumberSetting(this.storageSettings.values.loiteringThreshold, 3) * 1000,
39831
+ minDetectionScore: this.getNumberSetting(this.storageSettings.values.minDetectionScore, 0.5),
39832
+ objectAlertCooldown: this.getNumberSetting(this.storageSettings.values.objectAlertCooldown, 30) * 1000,
39599
39833
  useLlmDescriptions: this.storageSettings.values.useLlmDescriptions ?? true,
39600
39834
  llmDeviceIds: this.parseLlmProviders(),
39601
- llmDebounceInterval: (this.storageSettings.values.llmDebounceInterval || 30) * 1000,
39835
+ llmDebounceInterval: this.getNumberSetting(this.storageSettings.values.llmDebounceInterval, 5) * 1000,
39602
39836
  llmFallbackEnabled: this.storageSettings.values.llmFallbackEnabled ?? true,
39603
- llmFallbackTimeout: (this.storageSettings.values.llmFallbackTimeout || 3) * 1000,
39837
+ llmFallbackTimeout: this.getNumberSetting(this.storageSettings.values.llmFallbackTimeout, 3) * 1000,
39604
39838
  enableTransitTimeLearning: this.storageSettings.values.enableTransitTimeLearning ?? true,
39605
39839
  enableConnectionSuggestions: this.storageSettings.values.enableConnectionSuggestions ?? true,
39606
39840
  enableLandmarkLearning: this.storageSettings.values.enableLandmarkLearning ?? true,
39607
- landmarkConfidenceThreshold: this.storageSettings.values.landmarkConfidenceThreshold ?? 0.7,
39841
+ landmarkConfidenceThreshold: this.getNumberSetting(this.storageSettings.values.landmarkConfidenceThreshold, 0.7),
39608
39842
  };
39609
39843
  this.trackingEngine = new tracking_engine_1.TrackingEngine(topology, this.trackingState, this.alertManager, config, this.console);
39844
+ this.applyAlertUpdateSettings();
39610
39845
  // Set up callback to save topology changes (e.g., from accepted landmark suggestions)
39611
39846
  this.trackingEngine.setTopologyChangeCallback((updatedTopology) => {
39612
39847
  this.storage.setItem('topology', JSON.stringify(updatedTopology));
@@ -39619,10 +39854,10 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
39619
39854
  }
39620
39855
  async initializeDiscoveryEngine(topology) {
39621
39856
  const discoveryConfig = {
39622
- discoveryIntervalHours: this.storageSettings.values.discoveryIntervalHours ?? 0,
39623
- autoAcceptThreshold: this.storageSettings.values.autoAcceptThreshold ?? 0.85,
39624
- minLandmarkConfidence: this.storageSettings.values.minLandmarkConfidence ?? 0.6,
39625
- minConnectionConfidence: this.storageSettings.values.minConnectionConfidence ?? 0.5,
39857
+ discoveryIntervalHours: this.getNumberSetting(this.storageSettings.values.discoveryIntervalHours, 0),
39858
+ autoAcceptThreshold: this.getNumberSetting(this.storageSettings.values.autoAcceptThreshold, 0.85),
39859
+ minLandmarkConfidence: this.getNumberSetting(this.storageSettings.values.minLandmarkConfidence, 0.6),
39860
+ minConnectionConfidence: this.getNumberSetting(this.storageSettings.values.minConnectionConfidence, 0.5),
39626
39861
  };
39627
39862
  if (this.discoveryEngine) {
39628
39863
  // Update existing engine
@@ -39667,6 +39902,12 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
39667
39902
  }
39668
39903
  return undefined;
39669
39904
  }
39905
+ getNumberSetting(value, fallback) {
39906
+ if (value === undefined || value === null)
39907
+ return fallback;
39908
+ const num = typeof value === 'string' ? Number(value) : value;
39909
+ return Number.isFinite(num) ? num : fallback;
39910
+ }
39670
39911
  // ==================== DeviceProvider Implementation ====================
39671
39912
  async getDevice(nativeId) {
39672
39913
  let device = this.devices.get(nativeId);
@@ -39959,6 +40200,9 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
39959
40200
  }
39960
40201
  }
39961
40202
  }
40203
+ if (key === 'notifyOnAlertUpdates' || key === 'alertUpdateCooldown') {
40204
+ this.applyAlertUpdateSettings();
40205
+ }
39962
40206
  // Handle MQTT setting changes
39963
40207
  if (key === 'enableMqtt' || key === 'mqttBroker' || key === 'mqttUsername' ||
39964
40208
  key === 'mqttPassword' || key === 'mqttBaseTopic') {
@@ -39971,6 +40215,11 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
39971
40215
  }
39972
40216
  }
39973
40217
  }
40218
+ applyAlertUpdateSettings() {
40219
+ const enabled = this.storageSettings.values.notifyOnAlertUpdates ?? false;
40220
+ const cooldownSeconds = this.getNumberSetting(this.storageSettings.values.alertUpdateCooldown, 60);
40221
+ this.alertManager.setUpdateNotificationOptions(enabled, cooldownSeconds * 1000);
40222
+ }
39974
40223
  // ==================== HttpRequestHandler Implementation ====================
39975
40224
  async onRequest(request, response) {
39976
40225
  const url = new URL(request.url, 'http://localhost');
@@ -42034,6 +42283,8 @@ class TrackingState {
42034
42283
  changeCallbacks = [];
42035
42284
  storage;
42036
42285
  console;
42286
+ persistTimer = null;
42287
+ persistDebounceMs = 2000;
42037
42288
  constructor(storage, console) {
42038
42289
  this.storage = storage;
42039
42290
  this.console = console;
@@ -42063,7 +42314,7 @@ class TrackingState {
42063
42314
  }
42064
42315
  this.objects.set(object.globalId, object);
42065
42316
  this.notifyChange();
42066
- this.persistState();
42317
+ this.schedulePersist();
42067
42318
  }
42068
42319
  /** Add a new sighting to an existing tracked object */
42069
42320
  addSighting(globalId, sighting) {
@@ -42077,7 +42328,7 @@ class TrackingState {
42077
42328
  }
42078
42329
  this.objectsByCamera.get(sighting.cameraId).add(globalId);
42079
42330
  this.notifyChange();
42080
- this.persistState();
42331
+ this.schedulePersist();
42081
42332
  return true;
42082
42333
  }
42083
42334
  /** Add a journey segment (cross-camera transition) */
@@ -42093,7 +42344,7 @@ class TrackingState {
42093
42344
  }
42094
42345
  this.objectsByCamera.get(segment.toCameraId).add(globalId);
42095
42346
  this.notifyChange();
42096
- this.persistState();
42347
+ this.schedulePersist();
42097
42348
  return true;
42098
42349
  }
42099
42350
  /** Get object by global ID */
@@ -42138,7 +42389,7 @@ class TrackingState {
42138
42389
  set.delete(globalId);
42139
42390
  }
42140
42391
  this.notifyChange();
42141
- this.persistState();
42392
+ this.schedulePersist();
42142
42393
  }
42143
42394
  }
42144
42395
  /** Mark object as lost (not seen for too long) */
@@ -42152,7 +42403,7 @@ class TrackingState {
42152
42403
  set.delete(globalId);
42153
42404
  }
42154
42405
  this.notifyChange();
42155
- this.persistState();
42406
+ this.schedulePersist();
42156
42407
  }
42157
42408
  }
42158
42409
  /** Update object to pending state (waiting for correlation) */
@@ -42208,6 +42459,15 @@ class TrackingState {
42208
42459
  this.console.error('Failed to persist tracking state:', e);
42209
42460
  }
42210
42461
  }
42462
+ schedulePersist() {
42463
+ if (this.persistTimer) {
42464
+ clearTimeout(this.persistTimer);
42465
+ }
42466
+ this.persistTimer = setTimeout(() => {
42467
+ this.persistTimer = null;
42468
+ this.persistState();
42469
+ }, this.persistDebounceMs);
42470
+ }
42211
42471
  loadPersistedState() {
42212
42472
  try {
42213
42473
  const json = this.storage.getItem('tracked-objects');