@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/CHANGELOG.md +18 -0
- package/README.md +5 -2
- package/dist/main.nodejs.js +1 -1
- package/dist/main.nodejs.js.map +1 -1
- package/dist/plugin.zip +0 -0
- package/out/main.nodejs.js +381 -113
- package/out/main.nodejs.js.map +1 -1
- package/out/plugin.zip +0 -0
- package/package.json +4 -2
- package/session-manager-plugin.pkg +0 -0
- package/src/alerts/alert-manager.ts +149 -21
- package/src/alerts/alert-utils.ts +32 -0
- package/src/core/tracking-engine.ts +161 -46
- package/src/main.ts +55 -13
- package/src/models/tracked-object.ts +1 -1
- package/src/state/tracking-state.ts +18 -5
- package/tests/run-tests.ts +50 -0
package/out/main.nodejs.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
34469
|
-
|
|
34470
|
-
|
|
34471
|
-
|
|
34472
|
-
|
|
34473
|
-
|
|
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 <
|
|
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') {
|
|
@@ -37502,19 +37644,30 @@ class TrackingEngine {
|
|
|
37502
37644
|
this.lastLlmCallTime = Date.now();
|
|
37503
37645
|
}
|
|
37504
37646
|
/** Check and record LLM call - returns false if rate limited */
|
|
37505
|
-
tryLlmCall() {
|
|
37647
|
+
tryLlmCall(silent = false) {
|
|
37506
37648
|
if (!this.isLlmCallAllowed()) {
|
|
37507
|
-
|
|
37649
|
+
// Only log once per rate limit window, not every call
|
|
37650
|
+
if (!silent && !this.rateLimitLogged) {
|
|
37651
|
+
const remaining = Math.ceil((this.config.llmDebounceInterval || 30000) - (Date.now() - this.lastLlmCallTime)) / 1000;
|
|
37652
|
+
this.console.log(`[LLM] Rate limited, ${remaining.toFixed(0)}s until next call allowed`);
|
|
37653
|
+
this.rateLimitLogged = true;
|
|
37654
|
+
}
|
|
37508
37655
|
return false;
|
|
37509
37656
|
}
|
|
37657
|
+
this.rateLimitLogged = false;
|
|
37510
37658
|
this.recordLlmCall();
|
|
37511
37659
|
return true;
|
|
37512
37660
|
}
|
|
37661
|
+
/** Track if we've already logged rate limit message */
|
|
37662
|
+
rateLimitLogged = false;
|
|
37513
37663
|
/** Get spatial reasoning result for movement (uses RAG + LLM) with debouncing and fallback */
|
|
37514
37664
|
async getSpatialDescription(tracked, fromCameraId, toCameraId, transitTime, currentCameraId) {
|
|
37515
37665
|
const fallbackEnabled = this.config.llmFallbackEnabled ?? true;
|
|
37516
37666
|
const fallbackTimeout = this.config.llmFallbackTimeout ?? 3000;
|
|
37517
37667
|
try {
|
|
37668
|
+
if (!this.config.useLlmDescriptions) {
|
|
37669
|
+
return this.spatialReasoning.generateMovementDescription(tracked, fromCameraId, toCameraId, transitTime);
|
|
37670
|
+
}
|
|
37518
37671
|
// Check rate limiting - if not allowed, return null to use basic description
|
|
37519
37672
|
if (!this.tryLlmCall()) {
|
|
37520
37673
|
this.console.log('[Movement] LLM rate-limited, using basic notification');
|
|
@@ -37522,11 +37675,9 @@ class TrackingEngine {
|
|
|
37522
37675
|
}
|
|
37523
37676
|
// Get snapshot from camera for LLM analysis (if LLM is enabled)
|
|
37524
37677
|
let mediaObject;
|
|
37525
|
-
|
|
37526
|
-
|
|
37527
|
-
|
|
37528
|
-
mediaObject = await camera.takePicture();
|
|
37529
|
-
}
|
|
37678
|
+
const camera = systemManager.getDeviceById(currentCameraId);
|
|
37679
|
+
if (camera?.interfaces?.includes(sdk_1.ScryptedInterface.Camera)) {
|
|
37680
|
+
mediaObject = await camera.takePicture();
|
|
37530
37681
|
}
|
|
37531
37682
|
// Use spatial reasoning engine for rich context-aware description
|
|
37532
37683
|
// Apply timeout if fallback is enabled
|
|
@@ -37583,6 +37734,8 @@ class TrackingEngine {
|
|
|
37583
37734
|
// Check if this is a cross-camera transition
|
|
37584
37735
|
const lastSighting = (0, tracked_object_1.getLastSighting)(tracked);
|
|
37585
37736
|
if (lastSighting && lastSighting.cameraId !== sighting.cameraId) {
|
|
37737
|
+
// Cancel any pending loitering alert if object already transitioned
|
|
37738
|
+
this.clearLoiteringTimer(tracked.globalId);
|
|
37586
37739
|
const transitDuration = sighting.timestamp - lastSighting.timestamp;
|
|
37587
37740
|
// Update cached snapshot from new camera (object is now visible here)
|
|
37588
37741
|
if (this.config.useLlmDescriptions) {
|
|
@@ -37607,25 +37760,44 @@ class TrackingEngine {
|
|
|
37607
37760
|
`${lastSighting.cameraName} → ${sighting.cameraName} ` +
|
|
37608
37761
|
`(confidence: ${(correlation.confidence * 100).toFixed(0)}%)`);
|
|
37609
37762
|
// Check loitering threshold and per-object cooldown before alerting
|
|
37610
|
-
if (this.passesLoiteringThreshold(tracked)
|
|
37611
|
-
|
|
37612
|
-
|
|
37613
|
-
|
|
37614
|
-
|
|
37615
|
-
|
|
37616
|
-
|
|
37617
|
-
|
|
37618
|
-
|
|
37619
|
-
|
|
37620
|
-
|
|
37621
|
-
|
|
37622
|
-
|
|
37623
|
-
|
|
37624
|
-
|
|
37625
|
-
|
|
37626
|
-
|
|
37627
|
-
|
|
37628
|
-
|
|
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
|
+
}
|
|
37629
37801
|
}
|
|
37630
37802
|
}
|
|
37631
37803
|
// Add sighting to tracked object
|
|
@@ -37664,58 +37836,91 @@ class TrackingEngine {
|
|
|
37664
37836
|
});
|
|
37665
37837
|
}
|
|
37666
37838
|
// Check after loitering threshold if object is still being tracked
|
|
37667
|
-
|
|
37668
|
-
|
|
37669
|
-
|
|
37670
|
-
|
|
37671
|
-
|
|
37672
|
-
|
|
37673
|
-
|
|
37674
|
-
|
|
37675
|
-
|
|
37676
|
-
|
|
37677
|
-
|
|
37678
|
-
|
|
37679
|
-
|
|
37680
|
-
spatialResult = await pendingDescription;
|
|
37681
|
-
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;
|
|
37682
37852
|
}
|
|
37683
|
-
|
|
37684
|
-
|
|
37685
|
-
|
|
37686
|
-
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;
|
|
37687
37856
|
}
|
|
37688
|
-
this
|
|
37689
|
-
|
|
37690
|
-
|
|
37691
|
-
|
|
37692
|
-
|
|
37693
|
-
|
|
37694
|
-
|
|
37695
|
-
|
|
37696
|
-
|
|
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);
|
|
37697
37888
|
}
|
|
37698
37889
|
else {
|
|
37699
|
-
//
|
|
37700
|
-
this.
|
|
37701
|
-
|
|
37702
|
-
|
|
37703
|
-
|
|
37704
|
-
|
|
37705
|
-
|
|
37706
|
-
|
|
37707
|
-
|
|
37708
|
-
|
|
37709
|
-
|
|
37710
|
-
|
|
37711
|
-
|
|
37712
|
-
|
|
37713
|
-
|
|
37714
|
-
|
|
37715
|
-
|
|
37716
|
-
|
|
37717
|
-
|
|
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
|
+
}
|
|
37718
37922
|
}, this.config.loiteringThreshold);
|
|
37923
|
+
this.loiteringTimers.set(globalId, timer);
|
|
37719
37924
|
}
|
|
37720
37925
|
/** Capture and cache a snapshot for a tracked object, and start LLM analysis immediately */
|
|
37721
37926
|
async captureAndCacheSnapshot(globalId, cameraId, eventType = 'entry') {
|
|
@@ -37790,6 +37995,8 @@ class TrackingEngine {
|
|
|
37790
37995
|
handlePotentialExit(tracked, sighting) {
|
|
37791
37996
|
// Mark as pending and set timer
|
|
37792
37997
|
this.state.markPending(tracked.globalId);
|
|
37998
|
+
// Cancel any pending loitering alert
|
|
37999
|
+
this.clearLoiteringTimer(tracked.globalId);
|
|
37793
38000
|
// Capture a fresh snapshot now while object is still visible (before they leave)
|
|
37794
38001
|
// Also starts LLM analysis immediately in parallel
|
|
37795
38002
|
if (this.config.useLlmDescriptions) {
|
|
@@ -37833,6 +38040,7 @@ class TrackingEngine {
|
|
|
37833
38040
|
}
|
|
37834
38041
|
}
|
|
37835
38042
|
// Use movement alert for exit too - smart notifications with LLM descriptions
|
|
38043
|
+
const mediaObject = this.snapshotCache.get(tracked.globalId);
|
|
37836
38044
|
await this.alertManager.checkAndAlert('movement', current, {
|
|
37837
38045
|
cameraId: sighting.cameraId,
|
|
37838
38046
|
cameraName: sighting.cameraName,
|
|
@@ -37842,7 +38050,8 @@ class TrackingEngine {
|
|
|
37842
38050
|
objectLabel: spatialResult.description,
|
|
37843
38051
|
involvedLandmarks: spatialResult.involvedLandmarks?.map(l => l.name),
|
|
37844
38052
|
usedLlm: spatialResult.usedLlm,
|
|
37845
|
-
});
|
|
38053
|
+
}, mediaObject);
|
|
38054
|
+
this.alertManager.clearActiveAlertsForObject(tracked.globalId);
|
|
37846
38055
|
// Clean up cached snapshot and pending descriptions after exit alert
|
|
37847
38056
|
this.snapshotCache.delete(tracked.globalId);
|
|
37848
38057
|
this.pendingDescriptions.delete(tracked.globalId);
|
|
@@ -37859,6 +38068,7 @@ class TrackingEngine {
|
|
|
37859
38068
|
const timeSinceSeen = now - tracked.lastSeen;
|
|
37860
38069
|
if (timeSinceSeen > this.config.lostTimeout) {
|
|
37861
38070
|
this.state.markLost(tracked.globalId);
|
|
38071
|
+
this.clearLoiteringTimer(tracked.globalId);
|
|
37862
38072
|
this.console.log(`Object ${tracked.globalId.slice(0, 8)} marked as lost ` +
|
|
37863
38073
|
`(not seen for ${Math.round(timeSinceSeen / 1000)}s)`);
|
|
37864
38074
|
// Clean up cached snapshot and pending descriptions
|
|
@@ -37868,9 +38078,18 @@ class TrackingEngine {
|
|
|
37868
38078
|
objectClass: tracked.className,
|
|
37869
38079
|
objectLabel: tracked.label,
|
|
37870
38080
|
});
|
|
38081
|
+
this.alertManager.clearActiveAlertsForObject(tracked.globalId);
|
|
37871
38082
|
}
|
|
37872
38083
|
}
|
|
37873
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
|
+
}
|
|
37874
38093
|
/** Update topology configuration */
|
|
37875
38094
|
updateTopology(topology) {
|
|
37876
38095
|
this.topology = topology;
|
|
@@ -39355,6 +39574,13 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
39355
39574
|
description: 'Object must be visible for this duration before triggering movement alerts',
|
|
39356
39575
|
group: 'Tracking',
|
|
39357
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
|
+
},
|
|
39358
39584
|
objectAlertCooldown: {
|
|
39359
39585
|
title: 'Per-Object Alert Cooldown (seconds)',
|
|
39360
39586
|
type: 'number',
|
|
@@ -39362,6 +39588,20 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
39362
39588
|
description: 'Minimum time between alerts for the same tracked object',
|
|
39363
39589
|
group: 'Tracking',
|
|
39364
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
|
+
},
|
|
39365
39605
|
// LLM Integration
|
|
39366
39606
|
useLlmDescriptions: {
|
|
39367
39607
|
title: 'Use LLM for Rich Descriptions',
|
|
@@ -39373,7 +39613,7 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
39373
39613
|
llmDebounceInterval: {
|
|
39374
39614
|
title: 'LLM Rate Limit (seconds)',
|
|
39375
39615
|
type: 'number',
|
|
39376
|
-
defaultValue:
|
|
39616
|
+
defaultValue: 5,
|
|
39377
39617
|
description: 'Minimum time between LLM calls to prevent API rate limiting. Increase if you get rate limit errors. (0 = no limit)',
|
|
39378
39618
|
group: 'AI & Spatial Reasoning',
|
|
39379
39619
|
},
|
|
@@ -39554,6 +39794,7 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
39554
39794
|
if (this.storageSettings.values.enableMqtt) {
|
|
39555
39795
|
await this.initializeMqtt();
|
|
39556
39796
|
}
|
|
39797
|
+
this.applyAlertUpdateSettings();
|
|
39557
39798
|
this.console.log('Spatial Awareness Plugin initialized');
|
|
39558
39799
|
}
|
|
39559
39800
|
async initializeMqtt() {
|
|
@@ -39582,23 +39823,25 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
39582
39823
|
await this.trackingEngine.stopTracking();
|
|
39583
39824
|
}
|
|
39584
39825
|
const config = {
|
|
39585
|
-
correlationWindow: (this.storageSettings.values.correlationWindow
|
|
39586
|
-
correlationThreshold: this.storageSettings.values.correlationThreshold
|
|
39587
|
-
lostTimeout: (this.storageSettings.values.lostTimeout
|
|
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,
|
|
39588
39829
|
useVisualMatching: this.storageSettings.values.useVisualMatching ?? true,
|
|
39589
|
-
loiteringThreshold: (this.storageSettings.values.loiteringThreshold
|
|
39590
|
-
|
|
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,
|
|
39591
39833
|
useLlmDescriptions: this.storageSettings.values.useLlmDescriptions ?? true,
|
|
39592
39834
|
llmDeviceIds: this.parseLlmProviders(),
|
|
39593
|
-
llmDebounceInterval: (this.storageSettings.values.llmDebounceInterval
|
|
39835
|
+
llmDebounceInterval: this.getNumberSetting(this.storageSettings.values.llmDebounceInterval, 5) * 1000,
|
|
39594
39836
|
llmFallbackEnabled: this.storageSettings.values.llmFallbackEnabled ?? true,
|
|
39595
|
-
llmFallbackTimeout: (this.storageSettings.values.llmFallbackTimeout
|
|
39837
|
+
llmFallbackTimeout: this.getNumberSetting(this.storageSettings.values.llmFallbackTimeout, 3) * 1000,
|
|
39596
39838
|
enableTransitTimeLearning: this.storageSettings.values.enableTransitTimeLearning ?? true,
|
|
39597
39839
|
enableConnectionSuggestions: this.storageSettings.values.enableConnectionSuggestions ?? true,
|
|
39598
39840
|
enableLandmarkLearning: this.storageSettings.values.enableLandmarkLearning ?? true,
|
|
39599
|
-
landmarkConfidenceThreshold: this.storageSettings.values.landmarkConfidenceThreshold
|
|
39841
|
+
landmarkConfidenceThreshold: this.getNumberSetting(this.storageSettings.values.landmarkConfidenceThreshold, 0.7),
|
|
39600
39842
|
};
|
|
39601
39843
|
this.trackingEngine = new tracking_engine_1.TrackingEngine(topology, this.trackingState, this.alertManager, config, this.console);
|
|
39844
|
+
this.applyAlertUpdateSettings();
|
|
39602
39845
|
// Set up callback to save topology changes (e.g., from accepted landmark suggestions)
|
|
39603
39846
|
this.trackingEngine.setTopologyChangeCallback((updatedTopology) => {
|
|
39604
39847
|
this.storage.setItem('topology', JSON.stringify(updatedTopology));
|
|
@@ -39611,10 +39854,10 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
39611
39854
|
}
|
|
39612
39855
|
async initializeDiscoveryEngine(topology) {
|
|
39613
39856
|
const discoveryConfig = {
|
|
39614
|
-
discoveryIntervalHours: this.storageSettings.values.discoveryIntervalHours
|
|
39615
|
-
autoAcceptThreshold: this.storageSettings.values.autoAcceptThreshold
|
|
39616
|
-
minLandmarkConfidence: this.storageSettings.values.minLandmarkConfidence
|
|
39617
|
-
minConnectionConfidence: this.storageSettings.values.minConnectionConfidence
|
|
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),
|
|
39618
39861
|
};
|
|
39619
39862
|
if (this.discoveryEngine) {
|
|
39620
39863
|
// Update existing engine
|
|
@@ -39659,6 +39902,12 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
39659
39902
|
}
|
|
39660
39903
|
return undefined;
|
|
39661
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
|
+
}
|
|
39662
39911
|
// ==================== DeviceProvider Implementation ====================
|
|
39663
39912
|
async getDevice(nativeId) {
|
|
39664
39913
|
let device = this.devices.get(nativeId);
|
|
@@ -39951,6 +40200,9 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
39951
40200
|
}
|
|
39952
40201
|
}
|
|
39953
40202
|
}
|
|
40203
|
+
if (key === 'notifyOnAlertUpdates' || key === 'alertUpdateCooldown') {
|
|
40204
|
+
this.applyAlertUpdateSettings();
|
|
40205
|
+
}
|
|
39954
40206
|
// Handle MQTT setting changes
|
|
39955
40207
|
if (key === 'enableMqtt' || key === 'mqttBroker' || key === 'mqttUsername' ||
|
|
39956
40208
|
key === 'mqttPassword' || key === 'mqttBaseTopic') {
|
|
@@ -39963,6 +40215,11 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
39963
40215
|
}
|
|
39964
40216
|
}
|
|
39965
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
|
+
}
|
|
39966
40223
|
// ==================== HttpRequestHandler Implementation ====================
|
|
39967
40224
|
async onRequest(request, response) {
|
|
39968
40225
|
const url = new URL(request.url, 'http://localhost');
|
|
@@ -42026,6 +42283,8 @@ class TrackingState {
|
|
|
42026
42283
|
changeCallbacks = [];
|
|
42027
42284
|
storage;
|
|
42028
42285
|
console;
|
|
42286
|
+
persistTimer = null;
|
|
42287
|
+
persistDebounceMs = 2000;
|
|
42029
42288
|
constructor(storage, console) {
|
|
42030
42289
|
this.storage = storage;
|
|
42031
42290
|
this.console = console;
|
|
@@ -42055,7 +42314,7 @@ class TrackingState {
|
|
|
42055
42314
|
}
|
|
42056
42315
|
this.objects.set(object.globalId, object);
|
|
42057
42316
|
this.notifyChange();
|
|
42058
|
-
this.
|
|
42317
|
+
this.schedulePersist();
|
|
42059
42318
|
}
|
|
42060
42319
|
/** Add a new sighting to an existing tracked object */
|
|
42061
42320
|
addSighting(globalId, sighting) {
|
|
@@ -42069,7 +42328,7 @@ class TrackingState {
|
|
|
42069
42328
|
}
|
|
42070
42329
|
this.objectsByCamera.get(sighting.cameraId).add(globalId);
|
|
42071
42330
|
this.notifyChange();
|
|
42072
|
-
this.
|
|
42331
|
+
this.schedulePersist();
|
|
42073
42332
|
return true;
|
|
42074
42333
|
}
|
|
42075
42334
|
/** Add a journey segment (cross-camera transition) */
|
|
@@ -42085,7 +42344,7 @@ class TrackingState {
|
|
|
42085
42344
|
}
|
|
42086
42345
|
this.objectsByCamera.get(segment.toCameraId).add(globalId);
|
|
42087
42346
|
this.notifyChange();
|
|
42088
|
-
this.
|
|
42347
|
+
this.schedulePersist();
|
|
42089
42348
|
return true;
|
|
42090
42349
|
}
|
|
42091
42350
|
/** Get object by global ID */
|
|
@@ -42130,7 +42389,7 @@ class TrackingState {
|
|
|
42130
42389
|
set.delete(globalId);
|
|
42131
42390
|
}
|
|
42132
42391
|
this.notifyChange();
|
|
42133
|
-
this.
|
|
42392
|
+
this.schedulePersist();
|
|
42134
42393
|
}
|
|
42135
42394
|
}
|
|
42136
42395
|
/** Mark object as lost (not seen for too long) */
|
|
@@ -42144,7 +42403,7 @@ class TrackingState {
|
|
|
42144
42403
|
set.delete(globalId);
|
|
42145
42404
|
}
|
|
42146
42405
|
this.notifyChange();
|
|
42147
|
-
this.
|
|
42406
|
+
this.schedulePersist();
|
|
42148
42407
|
}
|
|
42149
42408
|
}
|
|
42150
42409
|
/** Update object to pending state (waiting for correlation) */
|
|
@@ -42200,6 +42459,15 @@ class TrackingState {
|
|
|
42200
42459
|
this.console.error('Failed to persist tracking state:', e);
|
|
42201
42460
|
}
|
|
42202
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
|
+
}
|
|
42203
42471
|
loadPersistedState() {
|
|
42204
42472
|
try {
|
|
42205
42473
|
const json = this.storage.getItem('tracked-objects');
|