@blueharford/scrypted-spatial-awareness 0.6.32 → 0.6.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/README.md +7 -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 +607 -229
- package/out/main.nodejs.js.map +1 -1
- package/out/plugin.zip +0 -0
- package/package.json +4 -2
- package/src/alerts/alert-manager.ts +149 -21
- package/src/alerts/alert-utils.ts +32 -0
- package/src/core/topology-discovery.ts +181 -95
- package/src/core/tracking-engine.ts +150 -44
- package/src/main.ts +110 -19
- 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"
|
|
@@ -36468,6 +36597,12 @@ class TopologyDiscoveryEngine {
|
|
|
36468
36597
|
getStatus() {
|
|
36469
36598
|
return { ...this.status };
|
|
36470
36599
|
}
|
|
36600
|
+
/** Get list of LLMs excluded for lack of vision support */
|
|
36601
|
+
getExcludedVisionLlmNames() {
|
|
36602
|
+
return this.llmDevices
|
|
36603
|
+
.filter(l => !l.visionCapable)
|
|
36604
|
+
.map(l => l.name || l.id);
|
|
36605
|
+
}
|
|
36471
36606
|
/** Get pending suggestions */
|
|
36472
36607
|
getPendingSuggestions() {
|
|
36473
36608
|
return Array.from(this.suggestions.values())
|
|
@@ -36519,6 +36654,7 @@ class TopologyDiscoveryEngine {
|
|
|
36519
36654
|
providerType,
|
|
36520
36655
|
lastUsed: 0,
|
|
36521
36656
|
errorCount: 0,
|
|
36657
|
+
visionCapable: true,
|
|
36522
36658
|
});
|
|
36523
36659
|
this.console.log(`[Discovery] Found LLM: ${device.name}`);
|
|
36524
36660
|
}
|
|
@@ -36565,6 +36701,42 @@ class TopologyDiscoveryEngine {
|
|
|
36565
36701
|
this.console.log(`[Discovery] Selected LLM: ${selected.name}`);
|
|
36566
36702
|
return selected.device;
|
|
36567
36703
|
}
|
|
36704
|
+
/** Select an LLM device, excluding any IDs if provided */
|
|
36705
|
+
async selectLlmDevice(excludeIds) {
|
|
36706
|
+
await this.findAllLlmDevices();
|
|
36707
|
+
if (this.llmDevices.length === 0)
|
|
36708
|
+
return null;
|
|
36709
|
+
let bestIndex = -1;
|
|
36710
|
+
let bestScore = Infinity;
|
|
36711
|
+
for (let i = 0; i < this.llmDevices.length; i++) {
|
|
36712
|
+
const llm = this.llmDevices[i];
|
|
36713
|
+
if (excludeIds.has(llm.id))
|
|
36714
|
+
continue;
|
|
36715
|
+
if (!llm.visionCapable)
|
|
36716
|
+
continue;
|
|
36717
|
+
const score = llm.lastUsed + (llm.errorCount * 60000);
|
|
36718
|
+
if (score < bestScore) {
|
|
36719
|
+
bestScore = score;
|
|
36720
|
+
bestIndex = i;
|
|
36721
|
+
}
|
|
36722
|
+
}
|
|
36723
|
+
if (bestIndex === -1)
|
|
36724
|
+
return null;
|
|
36725
|
+
const selected = this.llmDevices[bestIndex];
|
|
36726
|
+
this.llmDevice = selected.device;
|
|
36727
|
+
this.llmProviderType = selected.providerType;
|
|
36728
|
+
selected.lastUsed = Date.now();
|
|
36729
|
+
this.console.log(`[Discovery] Selected LLM: ${selected.name}`);
|
|
36730
|
+
return selected.device;
|
|
36731
|
+
}
|
|
36732
|
+
isRetryableLlmError(error) {
|
|
36733
|
+
const errorStr = String(error).toLowerCase();
|
|
36734
|
+
return (errorStr.includes('404') ||
|
|
36735
|
+
errorStr.includes('not found') ||
|
|
36736
|
+
errorStr.includes('no such model') ||
|
|
36737
|
+
errorStr.includes('model not found') ||
|
|
36738
|
+
errorStr.includes('endpoint'));
|
|
36739
|
+
}
|
|
36568
36740
|
/** Mark an LLM as having an error */
|
|
36569
36741
|
markLlmError(device) {
|
|
36570
36742
|
const llm = this.llmDevices.find(l => l.device === device);
|
|
@@ -36614,45 +36786,51 @@ class TopologyDiscoveryEngine {
|
|
|
36614
36786
|
potentialOverlaps: [],
|
|
36615
36787
|
isValid: false,
|
|
36616
36788
|
};
|
|
36617
|
-
const llm = await this.findLlmDevice();
|
|
36618
|
-
if (!llm?.getChatCompletion) {
|
|
36619
|
-
analysis.error = 'No LLM device available';
|
|
36620
|
-
return analysis;
|
|
36621
|
-
}
|
|
36622
36789
|
const imageData = await this.getCameraSnapshot(cameraId);
|
|
36623
36790
|
if (!imageData) {
|
|
36624
36791
|
analysis.error = 'Failed to capture camera snapshot';
|
|
36625
36792
|
return analysis;
|
|
36626
36793
|
}
|
|
36627
|
-
|
|
36628
|
-
|
|
36629
|
-
const formatsToTry = [];
|
|
36630
|
-
// Start with detected format
|
|
36631
|
-
formatsToTry.push(this.llmProviderType);
|
|
36632
|
-
// Add fallbacks based on detected provider
|
|
36633
|
-
if (this.llmProviderType === 'openai') {
|
|
36634
|
-
formatsToTry.push('scrypted', 'anthropic');
|
|
36635
|
-
}
|
|
36636
|
-
else if (this.llmProviderType === 'anthropic') {
|
|
36637
|
-
formatsToTry.push('scrypted', 'openai');
|
|
36638
|
-
}
|
|
36639
|
-
else if (this.llmProviderType === 'scrypted') {
|
|
36640
|
-
formatsToTry.push('anthropic', 'openai');
|
|
36641
|
-
}
|
|
36642
|
-
else {
|
|
36643
|
-
// Unknown - try all formats
|
|
36644
|
-
formatsToTry.push('scrypted', 'anthropic', 'openai');
|
|
36645
|
-
}
|
|
36794
|
+
await this.findAllLlmDevices();
|
|
36795
|
+
const excludeIds = new Set();
|
|
36646
36796
|
let lastError = null;
|
|
36647
|
-
|
|
36648
|
-
|
|
36649
|
-
|
|
36650
|
-
|
|
36651
|
-
|
|
36652
|
-
|
|
36653
|
-
|
|
36654
|
-
|
|
36655
|
-
|
|
36797
|
+
const maxAttempts = Math.max(1, this.llmDevices.length || 1);
|
|
36798
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
36799
|
+
const llm = await this.selectLlmDevice(excludeIds);
|
|
36800
|
+
if (!llm?.getChatCompletion) {
|
|
36801
|
+
analysis.error = 'No LLM device available';
|
|
36802
|
+
return analysis;
|
|
36803
|
+
}
|
|
36804
|
+
let allFormatsVisionError = false;
|
|
36805
|
+
// Try with detected provider format first, then fallback to alternates
|
|
36806
|
+
// The order matters: try the most likely formats first
|
|
36807
|
+
const formatsToTry = [];
|
|
36808
|
+
// Start with detected format
|
|
36809
|
+
formatsToTry.push(this.llmProviderType);
|
|
36810
|
+
// Add fallbacks based on detected provider
|
|
36811
|
+
if (this.llmProviderType === 'openai') {
|
|
36812
|
+
formatsToTry.push('scrypted', 'anthropic');
|
|
36813
|
+
}
|
|
36814
|
+
else if (this.llmProviderType === 'anthropic') {
|
|
36815
|
+
formatsToTry.push('scrypted', 'openai');
|
|
36816
|
+
}
|
|
36817
|
+
else if (this.llmProviderType === 'scrypted') {
|
|
36818
|
+
formatsToTry.push('anthropic', 'openai');
|
|
36819
|
+
}
|
|
36820
|
+
else {
|
|
36821
|
+
// Unknown - try all formats
|
|
36822
|
+
formatsToTry.push('scrypted', 'anthropic', 'openai');
|
|
36823
|
+
}
|
|
36824
|
+
let visionFormatFailures = 0;
|
|
36825
|
+
for (const formatType of formatsToTry) {
|
|
36826
|
+
try {
|
|
36827
|
+
this.console.log(`[Discovery] Trying ${formatType} image format for ${cameraName}...`);
|
|
36828
|
+
// Build prompt with camera context (height)
|
|
36829
|
+
const cameraNode = this.topology ? (0, topology_1.findCamera)(this.topology, cameraId) : null;
|
|
36830
|
+
const mountHeight = cameraNode?.context?.mountHeight || 8;
|
|
36831
|
+
const cameraRange = cameraNode?.fov?.range || 80;
|
|
36832
|
+
// Add camera-specific context to the prompt
|
|
36833
|
+
const contextPrefix = `CAMERA INFORMATION:
|
|
36656
36834
|
- Camera Name: ${cameraName}
|
|
36657
36835
|
- Mount Height: ${mountHeight} feet above ground
|
|
36658
36836
|
- Approximate viewing range: ${cameraRange} feet
|
|
@@ -36660,96 +36838,122 @@ class TopologyDiscoveryEngine {
|
|
|
36660
36838
|
Use the mount height to help estimate distances - objects at ground level will appear at different angles depending on distance from a camera mounted at ${mountHeight} feet.
|
|
36661
36839
|
|
|
36662
36840
|
`;
|
|
36663
|
-
|
|
36664
|
-
|
|
36665
|
-
|
|
36666
|
-
|
|
36667
|
-
|
|
36668
|
-
|
|
36669
|
-
|
|
36670
|
-
|
|
36671
|
-
|
|
36672
|
-
|
|
36673
|
-
|
|
36674
|
-
|
|
36675
|
-
|
|
36676
|
-
|
|
36677
|
-
|
|
36678
|
-
|
|
36679
|
-
|
|
36680
|
-
|
|
36681
|
-
|
|
36682
|
-
|
|
36683
|
-
|
|
36684
|
-
|
|
36685
|
-
|
|
36686
|
-
|
|
36687
|
-
|
|
36688
|
-
|
|
36689
|
-
|
|
36690
|
-
|
|
36691
|
-
|
|
36692
|
-
|
|
36693
|
-
|
|
36694
|
-
|
|
36695
|
-
|
|
36696
|
-
|
|
36697
|
-
|
|
36698
|
-
|
|
36699
|
-
|
|
36700
|
-
|
|
36701
|
-
|
|
36702
|
-
|
|
36703
|
-
|
|
36704
|
-
|
|
36705
|
-
|
|
36706
|
-
|
|
36707
|
-
|
|
36708
|
-
|
|
36709
|
-
|
|
36710
|
-
|
|
36711
|
-
|
|
36712
|
-
|
|
36713
|
-
|
|
36714
|
-
|
|
36715
|
-
|
|
36716
|
-
|
|
36717
|
-
|
|
36841
|
+
// Build multimodal message with provider-specific image format
|
|
36842
|
+
const result = await llm.getChatCompletion({
|
|
36843
|
+
messages: [
|
|
36844
|
+
{
|
|
36845
|
+
role: 'user',
|
|
36846
|
+
content: [
|
|
36847
|
+
{ type: 'text', text: contextPrefix + SCENE_ANALYSIS_PROMPT },
|
|
36848
|
+
(0, spatial_reasoning_1.buildImageContent)(imageData, formatType),
|
|
36849
|
+
],
|
|
36850
|
+
},
|
|
36851
|
+
],
|
|
36852
|
+
max_tokens: 4000, // Increased for detailed scene analysis
|
|
36853
|
+
temperature: 0.3,
|
|
36854
|
+
});
|
|
36855
|
+
const content = result?.choices?.[0]?.message?.content;
|
|
36856
|
+
if (content && typeof content === 'string') {
|
|
36857
|
+
try {
|
|
36858
|
+
// Extract JSON from response (handle markdown code blocks)
|
|
36859
|
+
let jsonStr = content.trim();
|
|
36860
|
+
if (jsonStr.startsWith('```')) {
|
|
36861
|
+
jsonStr = jsonStr.replace(/```json?\n?/g, '').replace(/```$/g, '').trim();
|
|
36862
|
+
}
|
|
36863
|
+
// Try to recover truncated JSON
|
|
36864
|
+
const parsed = this.parseJsonWithRecovery(jsonStr, cameraName);
|
|
36865
|
+
// Map parsed data to our types
|
|
36866
|
+
if (Array.isArray(parsed.landmarks)) {
|
|
36867
|
+
analysis.landmarks = parsed.landmarks.map((l) => ({
|
|
36868
|
+
name: l.name || 'Unknown',
|
|
36869
|
+
type: this.mapLandmarkType(l.type),
|
|
36870
|
+
confidence: typeof l.confidence === 'number' ? l.confidence : 0.7,
|
|
36871
|
+
distance: this.mapDistance(l.distance),
|
|
36872
|
+
description: l.description || '',
|
|
36873
|
+
boundingBox: l.boundingBox,
|
|
36874
|
+
}));
|
|
36875
|
+
}
|
|
36876
|
+
if (Array.isArray(parsed.zones)) {
|
|
36877
|
+
analysis.zones = parsed.zones.map((z) => ({
|
|
36878
|
+
name: z.name || 'Unknown',
|
|
36879
|
+
type: this.mapZoneType(z.type),
|
|
36880
|
+
coverage: typeof z.coverage === 'number' ? z.coverage : 0.5,
|
|
36881
|
+
description: z.description || '',
|
|
36882
|
+
boundingBox: z.boundingBox,
|
|
36883
|
+
distance: this.mapDistance(z.distance), // Parse distance for zones too
|
|
36884
|
+
}));
|
|
36885
|
+
}
|
|
36886
|
+
if (parsed.edges && typeof parsed.edges === 'object') {
|
|
36887
|
+
analysis.edges = {
|
|
36888
|
+
top: parsed.edges.top || '',
|
|
36889
|
+
left: parsed.edges.left || '',
|
|
36890
|
+
right: parsed.edges.right || '',
|
|
36891
|
+
bottom: parsed.edges.bottom || '',
|
|
36892
|
+
};
|
|
36893
|
+
}
|
|
36894
|
+
if (parsed.orientation) {
|
|
36895
|
+
analysis.orientation = this.mapOrientation(parsed.orientation);
|
|
36896
|
+
}
|
|
36897
|
+
analysis.isValid = true;
|
|
36898
|
+
this.console.log(`[Discovery] Analyzed ${cameraName}: ${analysis.landmarks.length} landmarks, ${analysis.zones.length} zones (using ${formatType} format)`);
|
|
36899
|
+
// Update the preferred format for future requests
|
|
36900
|
+
if (formatType !== this.llmProviderType) {
|
|
36901
|
+
this.console.log(`[Discovery] Switching to ${formatType} format for future requests`);
|
|
36902
|
+
this.llmProviderType = formatType;
|
|
36903
|
+
}
|
|
36904
|
+
// Success - exit the retry loop
|
|
36905
|
+
return analysis;
|
|
36718
36906
|
}
|
|
36719
|
-
|
|
36720
|
-
|
|
36721
|
-
|
|
36722
|
-
|
|
36723
|
-
this.console.log(`[Discovery] Switching to ${formatType} format for future requests`);
|
|
36724
|
-
this.llmProviderType = formatType;
|
|
36907
|
+
catch (parseError) {
|
|
36908
|
+
this.console.warn(`[Discovery] Failed to parse LLM response for ${cameraName}:`, parseError);
|
|
36909
|
+
analysis.error = 'Failed to parse LLM response';
|
|
36910
|
+
return analysis;
|
|
36725
36911
|
}
|
|
36726
|
-
// Success - exit the retry loop
|
|
36727
|
-
return analysis;
|
|
36728
36912
|
}
|
|
36729
|
-
|
|
36730
|
-
|
|
36731
|
-
|
|
36732
|
-
|
|
36913
|
+
}
|
|
36914
|
+
catch (e) {
|
|
36915
|
+
lastError = e;
|
|
36916
|
+
// Check if this is a vision/multimodal format error
|
|
36917
|
+
if ((0, spatial_reasoning_1.isVisionFormatError)(e)) {
|
|
36918
|
+
this.console.warn(`[Discovery] ${formatType} format failed, trying fallback...`);
|
|
36919
|
+
visionFormatFailures++;
|
|
36920
|
+
continue; // Try next format
|
|
36921
|
+
}
|
|
36922
|
+
// Retry with a different LLM if error indicates bad endpoint/model
|
|
36923
|
+
if (this.isRetryableLlmError(e)) {
|
|
36924
|
+
this.console.warn(`[Discovery] LLM error for ${cameraName}, trying another provider...`);
|
|
36925
|
+
this.markLlmError(llm);
|
|
36926
|
+
const llmEntry = this.llmDevices.find(d => d.device === llm);
|
|
36927
|
+
if (llmEntry) {
|
|
36928
|
+
excludeIds.add(llmEntry.id);
|
|
36929
|
+
}
|
|
36930
|
+
break;
|
|
36733
36931
|
}
|
|
36932
|
+
// Not a format error - don't retry
|
|
36933
|
+
this.console.warn(`[Discovery] Scene analysis failed for ${cameraName}:`, e);
|
|
36934
|
+
break;
|
|
36734
36935
|
}
|
|
36735
36936
|
}
|
|
36736
|
-
|
|
36737
|
-
|
|
36738
|
-
|
|
36739
|
-
if (
|
|
36740
|
-
|
|
36741
|
-
|
|
36742
|
-
|
|
36743
|
-
|
|
36744
|
-
this.console.warn(`[Discovery] Scene analysis failed for ${cameraName}:`, e);
|
|
36745
|
-
break;
|
|
36937
|
+
allFormatsVisionError = visionFormatFailures > 0 && visionFormatFailures === formatsToTry.length;
|
|
36938
|
+
if (allFormatsVisionError) {
|
|
36939
|
+
const llmEntry = this.llmDevices.find(d => d.device === llm);
|
|
36940
|
+
if (llmEntry) {
|
|
36941
|
+
llmEntry.visionCapable = false;
|
|
36942
|
+
excludeIds.add(llmEntry.id);
|
|
36943
|
+
this.console.warn(`[Discovery] ${llmEntry.name} does not support vision. Excluding from discovery.`);
|
|
36944
|
+
}
|
|
36746
36945
|
}
|
|
36747
36946
|
}
|
|
36748
36947
|
// All formats failed
|
|
36749
36948
|
if (lastError) {
|
|
36750
36949
|
// Track error for load balancing
|
|
36751
|
-
|
|
36752
|
-
|
|
36950
|
+
// Note: llm may be null here if no device was available
|
|
36951
|
+
if (lastError && !this.isRetryableLlmError(lastError)) {
|
|
36952
|
+
// Best-effort error accounting for the most recent device
|
|
36953
|
+
const lastDevice = this.llmDevice;
|
|
36954
|
+
if (lastDevice) {
|
|
36955
|
+
this.markLlmError(lastDevice);
|
|
36956
|
+
}
|
|
36753
36957
|
}
|
|
36754
36958
|
const errorStr = String(lastError);
|
|
36755
36959
|
if ((0, spatial_reasoning_1.isVisionFormatError)(lastError)) {
|
|
@@ -37324,7 +37528,9 @@ class TrackingEngine {
|
|
|
37324
37528
|
spatialReasoning;
|
|
37325
37529
|
listeners = new Map();
|
|
37326
37530
|
pendingTimers = new Map();
|
|
37531
|
+
loiteringTimers = new Map();
|
|
37327
37532
|
lostCheckInterval = null;
|
|
37533
|
+
cleanupInterval = null;
|
|
37328
37534
|
/** Track last alert time per object to enforce cooldown */
|
|
37329
37535
|
objectLastAlertTime = new Map();
|
|
37330
37536
|
/** Callback for topology changes (e.g., landmark suggestions) */
|
|
@@ -37407,6 +37613,9 @@ class TrackingEngine {
|
|
|
37407
37613
|
this.lostCheckInterval = setInterval(() => {
|
|
37408
37614
|
this.checkForLostObjects();
|
|
37409
37615
|
}, 30000); // Check every 30 seconds
|
|
37616
|
+
this.cleanupInterval = setInterval(() => {
|
|
37617
|
+
this.state.cleanup();
|
|
37618
|
+
}, 300000); // Cleanup every 5 minutes
|
|
37410
37619
|
this.console.log(`Tracking engine started with ${this.listeners.size} cameras`);
|
|
37411
37620
|
}
|
|
37412
37621
|
/** Stop all camera listeners */
|
|
@@ -37426,11 +37635,19 @@ class TrackingEngine {
|
|
|
37426
37635
|
clearTimeout(timer);
|
|
37427
37636
|
}
|
|
37428
37637
|
this.pendingTimers.clear();
|
|
37638
|
+
for (const timer of this.loiteringTimers.values()) {
|
|
37639
|
+
clearTimeout(timer);
|
|
37640
|
+
}
|
|
37641
|
+
this.loiteringTimers.clear();
|
|
37429
37642
|
// Stop lost check interval
|
|
37430
37643
|
if (this.lostCheckInterval) {
|
|
37431
37644
|
clearInterval(this.lostCheckInterval);
|
|
37432
37645
|
this.lostCheckInterval = null;
|
|
37433
37646
|
}
|
|
37647
|
+
if (this.cleanupInterval) {
|
|
37648
|
+
clearInterval(this.cleanupInterval);
|
|
37649
|
+
this.cleanupInterval = null;
|
|
37650
|
+
}
|
|
37434
37651
|
this.console.log('Tracking engine stopped');
|
|
37435
37652
|
}
|
|
37436
37653
|
/** Handle detection event from a camera */
|
|
@@ -37445,7 +37662,7 @@ class TrackingEngine {
|
|
|
37445
37662
|
const timestamp = detected.timestamp || Date.now();
|
|
37446
37663
|
for (const detection of detected.detections) {
|
|
37447
37664
|
// Skip low-confidence detections
|
|
37448
|
-
if (detection.score <
|
|
37665
|
+
if (detection.score < this.config.minDetectionScore)
|
|
37449
37666
|
continue;
|
|
37450
37667
|
// If in training mode, record trainer detections
|
|
37451
37668
|
if (this.isTrainingActive() && detection.className === 'person') {
|
|
@@ -37523,6 +37740,9 @@ class TrackingEngine {
|
|
|
37523
37740
|
const fallbackEnabled = this.config.llmFallbackEnabled ?? true;
|
|
37524
37741
|
const fallbackTimeout = this.config.llmFallbackTimeout ?? 3000;
|
|
37525
37742
|
try {
|
|
37743
|
+
if (!this.config.useLlmDescriptions) {
|
|
37744
|
+
return this.spatialReasoning.generateMovementDescription(tracked, fromCameraId, toCameraId, transitTime);
|
|
37745
|
+
}
|
|
37526
37746
|
// Check rate limiting - if not allowed, return null to use basic description
|
|
37527
37747
|
if (!this.tryLlmCall()) {
|
|
37528
37748
|
this.console.log('[Movement] LLM rate-limited, using basic notification');
|
|
@@ -37530,11 +37750,9 @@ class TrackingEngine {
|
|
|
37530
37750
|
}
|
|
37531
37751
|
// Get snapshot from camera for LLM analysis (if LLM is enabled)
|
|
37532
37752
|
let mediaObject;
|
|
37533
|
-
|
|
37534
|
-
|
|
37535
|
-
|
|
37536
|
-
mediaObject = await camera.takePicture();
|
|
37537
|
-
}
|
|
37753
|
+
const camera = systemManager.getDeviceById(currentCameraId);
|
|
37754
|
+
if (camera?.interfaces?.includes(sdk_1.ScryptedInterface.Camera)) {
|
|
37755
|
+
mediaObject = await camera.takePicture();
|
|
37538
37756
|
}
|
|
37539
37757
|
// Use spatial reasoning engine for rich context-aware description
|
|
37540
37758
|
// Apply timeout if fallback is enabled
|
|
@@ -37591,6 +37809,8 @@ class TrackingEngine {
|
|
|
37591
37809
|
// Check if this is a cross-camera transition
|
|
37592
37810
|
const lastSighting = (0, tracked_object_1.getLastSighting)(tracked);
|
|
37593
37811
|
if (lastSighting && lastSighting.cameraId !== sighting.cameraId) {
|
|
37812
|
+
// Cancel any pending loitering alert if object already transitioned
|
|
37813
|
+
this.clearLoiteringTimer(tracked.globalId);
|
|
37594
37814
|
const transitDuration = sighting.timestamp - lastSighting.timestamp;
|
|
37595
37815
|
// Update cached snapshot from new camera (object is now visible here)
|
|
37596
37816
|
if (this.config.useLlmDescriptions) {
|
|
@@ -37615,25 +37835,44 @@ class TrackingEngine {
|
|
|
37615
37835
|
`${lastSighting.cameraName} → ${sighting.cameraName} ` +
|
|
37616
37836
|
`(confidence: ${(correlation.confidence * 100).toFixed(0)}%)`);
|
|
37617
37837
|
// Check loitering threshold and per-object cooldown before alerting
|
|
37618
|
-
if (this.passesLoiteringThreshold(tracked)
|
|
37619
|
-
|
|
37620
|
-
|
|
37621
|
-
|
|
37622
|
-
|
|
37623
|
-
|
|
37624
|
-
|
|
37625
|
-
|
|
37626
|
-
|
|
37627
|
-
|
|
37628
|
-
|
|
37629
|
-
|
|
37630
|
-
|
|
37631
|
-
|
|
37632
|
-
|
|
37633
|
-
|
|
37634
|
-
|
|
37635
|
-
|
|
37636
|
-
|
|
37838
|
+
if (this.passesLoiteringThreshold(tracked)) {
|
|
37839
|
+
if (this.isInAlertCooldown(tracked.globalId)) {
|
|
37840
|
+
const spatialResult = await this.spatialReasoning.generateMovementDescription(tracked, lastSighting.cameraId, sighting.cameraId, transitDuration);
|
|
37841
|
+
await this.alertManager.updateMovementAlert(tracked, {
|
|
37842
|
+
fromCameraId: lastSighting.cameraId,
|
|
37843
|
+
fromCameraName: lastSighting.cameraName,
|
|
37844
|
+
toCameraId: sighting.cameraId,
|
|
37845
|
+
toCameraName: sighting.cameraName,
|
|
37846
|
+
transitTime: transitDuration,
|
|
37847
|
+
objectClass: sighting.detection.className,
|
|
37848
|
+
objectLabel: spatialResult.description || sighting.detection.label,
|
|
37849
|
+
detectionId: sighting.detectionId,
|
|
37850
|
+
pathDescription: spatialResult.pathDescription,
|
|
37851
|
+
involvedLandmarks: spatialResult.involvedLandmarks?.map(l => l.name),
|
|
37852
|
+
usedLlm: spatialResult.usedLlm,
|
|
37853
|
+
});
|
|
37854
|
+
}
|
|
37855
|
+
else {
|
|
37856
|
+
// Get spatial reasoning result with RAG context
|
|
37857
|
+
const spatialResult = await this.getSpatialDescription(tracked, lastSighting.cameraId, sighting.cameraId, transitDuration, sighting.cameraId);
|
|
37858
|
+
// Generate movement alert for cross-camera transition
|
|
37859
|
+
const mediaObject = this.snapshotCache.get(tracked.globalId);
|
|
37860
|
+
await this.alertManager.checkAndAlert('movement', tracked, {
|
|
37861
|
+
fromCameraId: lastSighting.cameraId,
|
|
37862
|
+
fromCameraName: lastSighting.cameraName,
|
|
37863
|
+
toCameraId: sighting.cameraId,
|
|
37864
|
+
toCameraName: sighting.cameraName,
|
|
37865
|
+
transitTime: transitDuration,
|
|
37866
|
+
objectClass: sighting.detection.className,
|
|
37867
|
+
objectLabel: spatialResult?.description || sighting.detection.label,
|
|
37868
|
+
detectionId: sighting.detectionId,
|
|
37869
|
+
// Include spatial context for enriched alerts
|
|
37870
|
+
pathDescription: spatialResult?.pathDescription,
|
|
37871
|
+
involvedLandmarks: spatialResult?.involvedLandmarks?.map(l => l.name),
|
|
37872
|
+
usedLlm: spatialResult?.usedLlm,
|
|
37873
|
+
}, mediaObject);
|
|
37874
|
+
this.recordAlertTime(tracked.globalId);
|
|
37875
|
+
}
|
|
37637
37876
|
}
|
|
37638
37877
|
}
|
|
37639
37878
|
// Add sighting to tracked object
|
|
@@ -37672,58 +37911,91 @@ class TrackingEngine {
|
|
|
37672
37911
|
});
|
|
37673
37912
|
}
|
|
37674
37913
|
// Check after loitering threshold if object is still being tracked
|
|
37675
|
-
|
|
37676
|
-
|
|
37677
|
-
|
|
37678
|
-
|
|
37679
|
-
|
|
37680
|
-
|
|
37681
|
-
|
|
37682
|
-
|
|
37683
|
-
|
|
37684
|
-
|
|
37685
|
-
|
|
37686
|
-
|
|
37687
|
-
|
|
37688
|
-
spatialResult = await pendingDescription;
|
|
37689
|
-
this.console.log(`[Entry Alert] Prefetch result: "${spatialResult.description.substring(0, 60)}...", usedLlm=${spatialResult.usedLlm}`);
|
|
37914
|
+
const existing = this.loiteringTimers.get(globalId);
|
|
37915
|
+
if (existing) {
|
|
37916
|
+
clearTimeout(existing);
|
|
37917
|
+
this.loiteringTimers.delete(globalId);
|
|
37918
|
+
}
|
|
37919
|
+
const timer = setTimeout(async () => {
|
|
37920
|
+
try {
|
|
37921
|
+
const tracked = this.state.getObject(globalId);
|
|
37922
|
+
if (!tracked || tracked.state !== 'active')
|
|
37923
|
+
return;
|
|
37924
|
+
const lastSighting = (0, tracked_object_1.getLastSighting)(tracked);
|
|
37925
|
+
if (!lastSighting || lastSighting.cameraId !== sighting.cameraId) {
|
|
37926
|
+
return;
|
|
37690
37927
|
}
|
|
37691
|
-
|
|
37692
|
-
|
|
37693
|
-
|
|
37694
|
-
spatialResult = await this.spatialReasoning.generateEntryDescription(tracked, sighting.cameraId);
|
|
37928
|
+
const maxStaleMs = Math.max(10000, this.config.loiteringThreshold * 2);
|
|
37929
|
+
if (Date.now() - lastSighting.timestamp > maxStaleMs) {
|
|
37930
|
+
return;
|
|
37695
37931
|
}
|
|
37696
|
-
this
|
|
37697
|
-
|
|
37698
|
-
|
|
37699
|
-
|
|
37700
|
-
|
|
37701
|
-
|
|
37702
|
-
|
|
37703
|
-
|
|
37704
|
-
|
|
37932
|
+
// Check if we've already alerted for this object
|
|
37933
|
+
if (this.isInAlertCooldown(globalId)) {
|
|
37934
|
+
const spatialResult = await this.spatialReasoning.generateEntryDescription(tracked, sighting.cameraId);
|
|
37935
|
+
await this.alertManager.updateMovementAlert(tracked, {
|
|
37936
|
+
cameraId: sighting.cameraId,
|
|
37937
|
+
cameraName: sighting.cameraName,
|
|
37938
|
+
toCameraId: sighting.cameraId,
|
|
37939
|
+
toCameraName: sighting.cameraName,
|
|
37940
|
+
objectClass: sighting.detection.className,
|
|
37941
|
+
objectLabel: spatialResult.description,
|
|
37942
|
+
detectionId: sighting.detectionId,
|
|
37943
|
+
involvedLandmarks: spatialResult.involvedLandmarks?.map(l => l.name),
|
|
37944
|
+
usedLlm: spatialResult.usedLlm,
|
|
37945
|
+
});
|
|
37946
|
+
return;
|
|
37947
|
+
}
|
|
37948
|
+
// Use prefetched LLM result if available (started when snapshot was captured)
|
|
37949
|
+
let spatialResult;
|
|
37950
|
+
const pendingDescription = this.pendingDescriptions.get(globalId);
|
|
37951
|
+
if (pendingDescription) {
|
|
37952
|
+
this.console.log(`[Entry Alert] Using prefetched LLM result for ${globalId.slice(0, 8)}`);
|
|
37953
|
+
try {
|
|
37954
|
+
spatialResult = await pendingDescription;
|
|
37955
|
+
this.console.log(`[Entry Alert] Prefetch result: "${spatialResult.description.substring(0, 60)}...", usedLlm=${spatialResult.usedLlm}`);
|
|
37956
|
+
}
|
|
37957
|
+
catch (e) {
|
|
37958
|
+
this.console.warn(`[Entry Alert] Prefetch failed, using basic description: ${e}`);
|
|
37959
|
+
// Don't make another LLM call - use basic description (no mediaObject = no LLM)
|
|
37960
|
+
spatialResult = await this.spatialReasoning.generateEntryDescription(tracked, sighting.cameraId);
|
|
37961
|
+
}
|
|
37962
|
+
this.pendingDescriptions.delete(globalId);
|
|
37705
37963
|
}
|
|
37706
37964
|
else {
|
|
37707
|
-
//
|
|
37708
|
-
this.
|
|
37709
|
-
|
|
37710
|
-
|
|
37711
|
-
|
|
37712
|
-
|
|
37713
|
-
|
|
37714
|
-
|
|
37715
|
-
|
|
37716
|
-
|
|
37717
|
-
|
|
37718
|
-
|
|
37719
|
-
|
|
37720
|
-
|
|
37721
|
-
|
|
37722
|
-
|
|
37723
|
-
|
|
37724
|
-
|
|
37725
|
-
|
|
37965
|
+
// No prefetch available - only call LLM if rate limit allows
|
|
37966
|
+
if (this.tryLlmCall()) {
|
|
37967
|
+
this.console.log(`[Entry Alert] No prefetch, generating with LLM`);
|
|
37968
|
+
const mediaObject = this.snapshotCache.get(globalId);
|
|
37969
|
+
spatialResult = await this.spatialReasoning.generateEntryDescription(tracked, sighting.cameraId, mediaObject);
|
|
37970
|
+
this.console.log(`[Entry Alert] Got description: "${spatialResult.description.substring(0, 60)}...", usedLlm=${spatialResult.usedLlm}`);
|
|
37971
|
+
}
|
|
37972
|
+
else {
|
|
37973
|
+
// Rate limited - use basic description (no LLM)
|
|
37974
|
+
this.console.log(`[Entry Alert] Rate limited, using basic description`);
|
|
37975
|
+
spatialResult = await this.spatialReasoning.generateEntryDescription(tracked, sighting.cameraId);
|
|
37976
|
+
}
|
|
37977
|
+
}
|
|
37978
|
+
// Always use movement alert type for smart notifications with LLM descriptions
|
|
37979
|
+
// The property_entry/property_exit types are legacy and disabled by default
|
|
37980
|
+
const mediaObject = this.snapshotCache.get(globalId);
|
|
37981
|
+
await this.alertManager.checkAndAlert('movement', tracked, {
|
|
37982
|
+
cameraId: sighting.cameraId,
|
|
37983
|
+
cameraName: sighting.cameraName,
|
|
37984
|
+
toCameraId: sighting.cameraId,
|
|
37985
|
+
toCameraName: sighting.cameraName,
|
|
37986
|
+
objectClass: sighting.detection.className,
|
|
37987
|
+
objectLabel: spatialResult.description, // Smart LLM-generated description
|
|
37988
|
+
detectionId: sighting.detectionId,
|
|
37989
|
+
involvedLandmarks: spatialResult.involvedLandmarks?.map(l => l.name),
|
|
37990
|
+
usedLlm: spatialResult.usedLlm,
|
|
37991
|
+
}, mediaObject);
|
|
37992
|
+
this.recordAlertTime(globalId);
|
|
37993
|
+
}
|
|
37994
|
+
finally {
|
|
37995
|
+
this.loiteringTimers.delete(globalId);
|
|
37996
|
+
}
|
|
37726
37997
|
}, this.config.loiteringThreshold);
|
|
37998
|
+
this.loiteringTimers.set(globalId, timer);
|
|
37727
37999
|
}
|
|
37728
38000
|
/** Capture and cache a snapshot for a tracked object, and start LLM analysis immediately */
|
|
37729
38001
|
async captureAndCacheSnapshot(globalId, cameraId, eventType = 'entry') {
|
|
@@ -37798,6 +38070,8 @@ class TrackingEngine {
|
|
|
37798
38070
|
handlePotentialExit(tracked, sighting) {
|
|
37799
38071
|
// Mark as pending and set timer
|
|
37800
38072
|
this.state.markPending(tracked.globalId);
|
|
38073
|
+
// Cancel any pending loitering alert
|
|
38074
|
+
this.clearLoiteringTimer(tracked.globalId);
|
|
37801
38075
|
// Capture a fresh snapshot now while object is still visible (before they leave)
|
|
37802
38076
|
// Also starts LLM analysis immediately in parallel
|
|
37803
38077
|
if (this.config.useLlmDescriptions) {
|
|
@@ -37841,6 +38115,7 @@ class TrackingEngine {
|
|
|
37841
38115
|
}
|
|
37842
38116
|
}
|
|
37843
38117
|
// Use movement alert for exit too - smart notifications with LLM descriptions
|
|
38118
|
+
const mediaObject = this.snapshotCache.get(tracked.globalId);
|
|
37844
38119
|
await this.alertManager.checkAndAlert('movement', current, {
|
|
37845
38120
|
cameraId: sighting.cameraId,
|
|
37846
38121
|
cameraName: sighting.cameraName,
|
|
@@ -37850,7 +38125,8 @@ class TrackingEngine {
|
|
|
37850
38125
|
objectLabel: spatialResult.description,
|
|
37851
38126
|
involvedLandmarks: spatialResult.involvedLandmarks?.map(l => l.name),
|
|
37852
38127
|
usedLlm: spatialResult.usedLlm,
|
|
37853
|
-
});
|
|
38128
|
+
}, mediaObject);
|
|
38129
|
+
this.alertManager.clearActiveAlertsForObject(tracked.globalId);
|
|
37854
38130
|
// Clean up cached snapshot and pending descriptions after exit alert
|
|
37855
38131
|
this.snapshotCache.delete(tracked.globalId);
|
|
37856
38132
|
this.pendingDescriptions.delete(tracked.globalId);
|
|
@@ -37867,6 +38143,7 @@ class TrackingEngine {
|
|
|
37867
38143
|
const timeSinceSeen = now - tracked.lastSeen;
|
|
37868
38144
|
if (timeSinceSeen > this.config.lostTimeout) {
|
|
37869
38145
|
this.state.markLost(tracked.globalId);
|
|
38146
|
+
this.clearLoiteringTimer(tracked.globalId);
|
|
37870
38147
|
this.console.log(`Object ${tracked.globalId.slice(0, 8)} marked as lost ` +
|
|
37871
38148
|
`(not seen for ${Math.round(timeSinceSeen / 1000)}s)`);
|
|
37872
38149
|
// Clean up cached snapshot and pending descriptions
|
|
@@ -37876,9 +38153,18 @@ class TrackingEngine {
|
|
|
37876
38153
|
objectClass: tracked.className,
|
|
37877
38154
|
objectLabel: tracked.label,
|
|
37878
38155
|
});
|
|
38156
|
+
this.alertManager.clearActiveAlertsForObject(tracked.globalId);
|
|
37879
38157
|
}
|
|
37880
38158
|
}
|
|
37881
38159
|
}
|
|
38160
|
+
/** Clear a pending loitering timer if present */
|
|
38161
|
+
clearLoiteringTimer(globalId) {
|
|
38162
|
+
const timer = this.loiteringTimers.get(globalId);
|
|
38163
|
+
if (timer) {
|
|
38164
|
+
clearTimeout(timer);
|
|
38165
|
+
this.loiteringTimers.delete(globalId);
|
|
38166
|
+
}
|
|
38167
|
+
}
|
|
37882
38168
|
/** Update topology configuration */
|
|
37883
38169
|
updateTopology(topology) {
|
|
37884
38170
|
this.topology = topology;
|
|
@@ -39363,6 +39649,13 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
39363
39649
|
description: 'Object must be visible for this duration before triggering movement alerts',
|
|
39364
39650
|
group: 'Tracking',
|
|
39365
39651
|
},
|
|
39652
|
+
minDetectionScore: {
|
|
39653
|
+
title: 'Minimum Detection Confidence',
|
|
39654
|
+
type: 'number',
|
|
39655
|
+
defaultValue: 0.5,
|
|
39656
|
+
description: 'Minimum detection score (0-1) to consider for tracking',
|
|
39657
|
+
group: 'Tracking',
|
|
39658
|
+
},
|
|
39366
39659
|
objectAlertCooldown: {
|
|
39367
39660
|
title: 'Per-Object Alert Cooldown (seconds)',
|
|
39368
39661
|
type: 'number',
|
|
@@ -39370,6 +39663,20 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
39370
39663
|
description: 'Minimum time between alerts for the same tracked object',
|
|
39371
39664
|
group: 'Tracking',
|
|
39372
39665
|
},
|
|
39666
|
+
notifyOnAlertUpdates: {
|
|
39667
|
+
title: 'Notify on Alert Updates',
|
|
39668
|
+
type: 'boolean',
|
|
39669
|
+
defaultValue: false,
|
|
39670
|
+
description: 'Send notifications when an existing alert is updated (camera transitions, context changes)',
|
|
39671
|
+
group: 'Tracking',
|
|
39672
|
+
},
|
|
39673
|
+
alertUpdateCooldown: {
|
|
39674
|
+
title: 'Alert Update Cooldown (seconds)',
|
|
39675
|
+
type: 'number',
|
|
39676
|
+
defaultValue: 60,
|
|
39677
|
+
description: 'Minimum time between update notifications for the same tracked object (0 = no limit)',
|
|
39678
|
+
group: 'Tracking',
|
|
39679
|
+
},
|
|
39373
39680
|
// LLM Integration
|
|
39374
39681
|
useLlmDescriptions: {
|
|
39375
39682
|
title: 'Use LLM for Rich Descriptions',
|
|
@@ -39562,6 +39869,7 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
39562
39869
|
if (this.storageSettings.values.enableMqtt) {
|
|
39563
39870
|
await this.initializeMqtt();
|
|
39564
39871
|
}
|
|
39872
|
+
this.applyAlertUpdateSettings();
|
|
39565
39873
|
this.console.log('Spatial Awareness Plugin initialized');
|
|
39566
39874
|
}
|
|
39567
39875
|
async initializeMqtt() {
|
|
@@ -39590,23 +39898,25 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
39590
39898
|
await this.trackingEngine.stopTracking();
|
|
39591
39899
|
}
|
|
39592
39900
|
const config = {
|
|
39593
|
-
correlationWindow: (this.storageSettings.values.correlationWindow
|
|
39594
|
-
correlationThreshold: this.storageSettings.values.correlationThreshold
|
|
39595
|
-
lostTimeout: (this.storageSettings.values.lostTimeout
|
|
39901
|
+
correlationWindow: this.getNumberSetting(this.storageSettings.values.correlationWindow, 30) * 1000,
|
|
39902
|
+
correlationThreshold: this.getNumberSetting(this.storageSettings.values.correlationThreshold, 0.35),
|
|
39903
|
+
lostTimeout: this.getNumberSetting(this.storageSettings.values.lostTimeout, 300) * 1000,
|
|
39596
39904
|
useVisualMatching: this.storageSettings.values.useVisualMatching ?? true,
|
|
39597
|
-
loiteringThreshold: (this.storageSettings.values.loiteringThreshold
|
|
39598
|
-
|
|
39905
|
+
loiteringThreshold: this.getNumberSetting(this.storageSettings.values.loiteringThreshold, 3) * 1000,
|
|
39906
|
+
minDetectionScore: this.getNumberSetting(this.storageSettings.values.minDetectionScore, 0.5),
|
|
39907
|
+
objectAlertCooldown: this.getNumberSetting(this.storageSettings.values.objectAlertCooldown, 30) * 1000,
|
|
39599
39908
|
useLlmDescriptions: this.storageSettings.values.useLlmDescriptions ?? true,
|
|
39600
39909
|
llmDeviceIds: this.parseLlmProviders(),
|
|
39601
|
-
llmDebounceInterval: (this.storageSettings.values.llmDebounceInterval
|
|
39910
|
+
llmDebounceInterval: this.getNumberSetting(this.storageSettings.values.llmDebounceInterval, 5) * 1000,
|
|
39602
39911
|
llmFallbackEnabled: this.storageSettings.values.llmFallbackEnabled ?? true,
|
|
39603
|
-
llmFallbackTimeout: (this.storageSettings.values.llmFallbackTimeout
|
|
39912
|
+
llmFallbackTimeout: this.getNumberSetting(this.storageSettings.values.llmFallbackTimeout, 3) * 1000,
|
|
39604
39913
|
enableTransitTimeLearning: this.storageSettings.values.enableTransitTimeLearning ?? true,
|
|
39605
39914
|
enableConnectionSuggestions: this.storageSettings.values.enableConnectionSuggestions ?? true,
|
|
39606
39915
|
enableLandmarkLearning: this.storageSettings.values.enableLandmarkLearning ?? true,
|
|
39607
|
-
landmarkConfidenceThreshold: this.storageSettings.values.landmarkConfidenceThreshold
|
|
39916
|
+
landmarkConfidenceThreshold: this.getNumberSetting(this.storageSettings.values.landmarkConfidenceThreshold, 0.7),
|
|
39608
39917
|
};
|
|
39609
39918
|
this.trackingEngine = new tracking_engine_1.TrackingEngine(topology, this.trackingState, this.alertManager, config, this.console);
|
|
39919
|
+
this.applyAlertUpdateSettings();
|
|
39610
39920
|
// Set up callback to save topology changes (e.g., from accepted landmark suggestions)
|
|
39611
39921
|
this.trackingEngine.setTopologyChangeCallback((updatedTopology) => {
|
|
39612
39922
|
this.storage.setItem('topology', JSON.stringify(updatedTopology));
|
|
@@ -39619,10 +39929,10 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
39619
39929
|
}
|
|
39620
39930
|
async initializeDiscoveryEngine(topology) {
|
|
39621
39931
|
const discoveryConfig = {
|
|
39622
|
-
discoveryIntervalHours: this.storageSettings.values.discoveryIntervalHours
|
|
39623
|
-
autoAcceptThreshold: this.storageSettings.values.autoAcceptThreshold
|
|
39624
|
-
minLandmarkConfidence: this.storageSettings.values.minLandmarkConfidence
|
|
39625
|
-
minConnectionConfidence: this.storageSettings.values.minConnectionConfidence
|
|
39932
|
+
discoveryIntervalHours: this.getNumberSetting(this.storageSettings.values.discoveryIntervalHours, 0),
|
|
39933
|
+
autoAcceptThreshold: this.getNumberSetting(this.storageSettings.values.autoAcceptThreshold, 0.85),
|
|
39934
|
+
minLandmarkConfidence: this.getNumberSetting(this.storageSettings.values.minLandmarkConfidence, 0.6),
|
|
39935
|
+
minConnectionConfidence: this.getNumberSetting(this.storageSettings.values.minConnectionConfidence, 0.5),
|
|
39626
39936
|
};
|
|
39627
39937
|
if (this.discoveryEngine) {
|
|
39628
39938
|
// Update existing engine
|
|
@@ -39667,6 +39977,12 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
39667
39977
|
}
|
|
39668
39978
|
return undefined;
|
|
39669
39979
|
}
|
|
39980
|
+
getNumberSetting(value, fallback) {
|
|
39981
|
+
if (value === undefined || value === null)
|
|
39982
|
+
return fallback;
|
|
39983
|
+
const num = typeof value === 'string' ? Number(value) : value;
|
|
39984
|
+
return Number.isFinite(num) ? num : fallback;
|
|
39985
|
+
}
|
|
39670
39986
|
// ==================== DeviceProvider Implementation ====================
|
|
39671
39987
|
async getDevice(nativeId) {
|
|
39672
39988
|
let device = this.devices.get(nativeId);
|
|
@@ -39925,6 +40241,19 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
39925
40241
|
addGroup('AI & Spatial Reasoning');
|
|
39926
40242
|
// ==================== 8. Auto-Topology Discovery ====================
|
|
39927
40243
|
addGroup('Auto-Topology Discovery');
|
|
40244
|
+
if (this.discoveryEngine) {
|
|
40245
|
+
const excluded = this.discoveryEngine.getExcludedVisionLlmNames();
|
|
40246
|
+
if (excluded.length > 0) {
|
|
40247
|
+
settings.push({
|
|
40248
|
+
key: 'excludedVisionLlms',
|
|
40249
|
+
title: 'Excluded LLMs (No Vision)',
|
|
40250
|
+
type: 'string',
|
|
40251
|
+
readonly: true,
|
|
40252
|
+
value: excluded.join(', '),
|
|
40253
|
+
group: 'Auto-Topology Discovery',
|
|
40254
|
+
});
|
|
40255
|
+
}
|
|
40256
|
+
}
|
|
39928
40257
|
// ==================== 9. MQTT Integration ====================
|
|
39929
40258
|
addGroup('MQTT Integration');
|
|
39930
40259
|
return settings;
|
|
@@ -39938,6 +40267,7 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
39938
40267
|
key === 'lostTimeout' ||
|
|
39939
40268
|
key === 'useVisualMatching' ||
|
|
39940
40269
|
key === 'loiteringThreshold' ||
|
|
40270
|
+
key === 'minDetectionScore' ||
|
|
39941
40271
|
key === 'objectAlertCooldown' ||
|
|
39942
40272
|
key === 'useLlmDescriptions' ||
|
|
39943
40273
|
key === 'llmDebounceInterval' ||
|
|
@@ -39959,6 +40289,9 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
39959
40289
|
}
|
|
39960
40290
|
}
|
|
39961
40291
|
}
|
|
40292
|
+
if (key === 'notifyOnAlertUpdates' || key === 'alertUpdateCooldown') {
|
|
40293
|
+
this.applyAlertUpdateSettings();
|
|
40294
|
+
}
|
|
39962
40295
|
// Handle MQTT setting changes
|
|
39963
40296
|
if (key === 'enableMqtt' || key === 'mqttBroker' || key === 'mqttUsername' ||
|
|
39964
40297
|
key === 'mqttPassword' || key === 'mqttBaseTopic') {
|
|
@@ -39971,6 +40304,11 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
39971
40304
|
}
|
|
39972
40305
|
}
|
|
39973
40306
|
}
|
|
40307
|
+
applyAlertUpdateSettings() {
|
|
40308
|
+
const enabled = this.storageSettings.values.notifyOnAlertUpdates ?? false;
|
|
40309
|
+
const cooldownSeconds = this.getNumberSetting(this.storageSettings.values.alertUpdateCooldown, 60);
|
|
40310
|
+
this.alertManager.setUpdateNotificationOptions(enabled, cooldownSeconds * 1000);
|
|
40311
|
+
}
|
|
39974
40312
|
// ==================== HttpRequestHandler Implementation ====================
|
|
39975
40313
|
async onRequest(request, response) {
|
|
39976
40314
|
const url = new URL(request.url, 'http://localhost');
|
|
@@ -40042,7 +40380,7 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
40042
40380
|
}
|
|
40043
40381
|
// Training Mode endpoints
|
|
40044
40382
|
if (path.endsWith('/api/training/start')) {
|
|
40045
|
-
return this.handleTrainingStartRequest(request, response);
|
|
40383
|
+
return await this.handleTrainingStartRequest(request, response);
|
|
40046
40384
|
}
|
|
40047
40385
|
if (path.endsWith('/api/training/pause')) {
|
|
40048
40386
|
return this.handleTrainingPauseRequest(response);
|
|
@@ -40612,13 +40950,23 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
40612
40950
|
}
|
|
40613
40951
|
}
|
|
40614
40952
|
// ==================== Training Mode Handlers ====================
|
|
40615
|
-
handleTrainingStartRequest(request, response) {
|
|
40953
|
+
async handleTrainingStartRequest(request, response) {
|
|
40616
40954
|
if (!this.trackingEngine) {
|
|
40617
|
-
|
|
40618
|
-
|
|
40619
|
-
|
|
40620
|
-
|
|
40621
|
-
|
|
40955
|
+
const topologyJson = this.storage.getItem('topology');
|
|
40956
|
+
const topology = topologyJson ? JSON.parse(topologyJson) : (0, topology_1.createEmptyTopology)();
|
|
40957
|
+
if (!topology.cameras?.length) {
|
|
40958
|
+
const cameras = this.buildTopologyCamerasFromSettings();
|
|
40959
|
+
if (cameras.length === 0) {
|
|
40960
|
+
response.send(JSON.stringify({ error: 'No cameras configured. Select tracked cameras first.' }), {
|
|
40961
|
+
code: 400,
|
|
40962
|
+
headers: { 'Content-Type': 'application/json' },
|
|
40963
|
+
});
|
|
40964
|
+
return;
|
|
40965
|
+
}
|
|
40966
|
+
topology.cameras = cameras;
|
|
40967
|
+
this.storage.setItem('topology', JSON.stringify(topology));
|
|
40968
|
+
}
|
|
40969
|
+
await this.startTrackingEngine(topology);
|
|
40622
40970
|
}
|
|
40623
40971
|
try {
|
|
40624
40972
|
let config;
|
|
@@ -41327,6 +41675,25 @@ Access the visual topology editor at \`/ui/editor\` to configure camera relation
|
|
|
41327
41675
|
const topologyJson = this.storage.getItem('topology');
|
|
41328
41676
|
return topologyJson ? JSON.parse(topologyJson) : null;
|
|
41329
41677
|
}
|
|
41678
|
+
buildTopologyCamerasFromSettings() {
|
|
41679
|
+
const value = this.storageSettings.values.trackedCameras;
|
|
41680
|
+
const cameraIds = Array.isArray(value)
|
|
41681
|
+
? value.filter(Boolean)
|
|
41682
|
+
: typeof value === 'string' && value.length
|
|
41683
|
+
? [value]
|
|
41684
|
+
: [];
|
|
41685
|
+
return cameraIds.map((deviceId) => {
|
|
41686
|
+
const device = systemManager.getDeviceById(deviceId);
|
|
41687
|
+
return {
|
|
41688
|
+
deviceId,
|
|
41689
|
+
nativeId: device?.nativeId || deviceId,
|
|
41690
|
+
name: device?.name || deviceId,
|
|
41691
|
+
isEntryPoint: false,
|
|
41692
|
+
isExitPoint: false,
|
|
41693
|
+
trackClasses: [],
|
|
41694
|
+
};
|
|
41695
|
+
});
|
|
41696
|
+
}
|
|
41330
41697
|
}
|
|
41331
41698
|
exports.SpatialAwarenessPlugin = SpatialAwarenessPlugin;
|
|
41332
41699
|
exports["default"] = SpatialAwarenessPlugin;
|
|
@@ -42034,6 +42401,8 @@ class TrackingState {
|
|
|
42034
42401
|
changeCallbacks = [];
|
|
42035
42402
|
storage;
|
|
42036
42403
|
console;
|
|
42404
|
+
persistTimer = null;
|
|
42405
|
+
persistDebounceMs = 2000;
|
|
42037
42406
|
constructor(storage, console) {
|
|
42038
42407
|
this.storage = storage;
|
|
42039
42408
|
this.console = console;
|
|
@@ -42063,7 +42432,7 @@ class TrackingState {
|
|
|
42063
42432
|
}
|
|
42064
42433
|
this.objects.set(object.globalId, object);
|
|
42065
42434
|
this.notifyChange();
|
|
42066
|
-
this.
|
|
42435
|
+
this.schedulePersist();
|
|
42067
42436
|
}
|
|
42068
42437
|
/** Add a new sighting to an existing tracked object */
|
|
42069
42438
|
addSighting(globalId, sighting) {
|
|
@@ -42077,7 +42446,7 @@ class TrackingState {
|
|
|
42077
42446
|
}
|
|
42078
42447
|
this.objectsByCamera.get(sighting.cameraId).add(globalId);
|
|
42079
42448
|
this.notifyChange();
|
|
42080
|
-
this.
|
|
42449
|
+
this.schedulePersist();
|
|
42081
42450
|
return true;
|
|
42082
42451
|
}
|
|
42083
42452
|
/** Add a journey segment (cross-camera transition) */
|
|
@@ -42093,7 +42462,7 @@ class TrackingState {
|
|
|
42093
42462
|
}
|
|
42094
42463
|
this.objectsByCamera.get(segment.toCameraId).add(globalId);
|
|
42095
42464
|
this.notifyChange();
|
|
42096
|
-
this.
|
|
42465
|
+
this.schedulePersist();
|
|
42097
42466
|
return true;
|
|
42098
42467
|
}
|
|
42099
42468
|
/** Get object by global ID */
|
|
@@ -42138,7 +42507,7 @@ class TrackingState {
|
|
|
42138
42507
|
set.delete(globalId);
|
|
42139
42508
|
}
|
|
42140
42509
|
this.notifyChange();
|
|
42141
|
-
this.
|
|
42510
|
+
this.schedulePersist();
|
|
42142
42511
|
}
|
|
42143
42512
|
}
|
|
42144
42513
|
/** Mark object as lost (not seen for too long) */
|
|
@@ -42152,7 +42521,7 @@ class TrackingState {
|
|
|
42152
42521
|
set.delete(globalId);
|
|
42153
42522
|
}
|
|
42154
42523
|
this.notifyChange();
|
|
42155
|
-
this.
|
|
42524
|
+
this.schedulePersist();
|
|
42156
42525
|
}
|
|
42157
42526
|
}
|
|
42158
42527
|
/** Update object to pending state (waiting for correlation) */
|
|
@@ -42208,6 +42577,15 @@ class TrackingState {
|
|
|
42208
42577
|
this.console.error('Failed to persist tracking state:', e);
|
|
42209
42578
|
}
|
|
42210
42579
|
}
|
|
42580
|
+
schedulePersist() {
|
|
42581
|
+
if (this.persistTimer) {
|
|
42582
|
+
clearTimeout(this.persistTimer);
|
|
42583
|
+
}
|
|
42584
|
+
this.persistTimer = setTimeout(() => {
|
|
42585
|
+
this.persistTimer = null;
|
|
42586
|
+
this.persistState();
|
|
42587
|
+
}, this.persistDebounceMs);
|
|
42588
|
+
}
|
|
42211
42589
|
loadPersistedState() {
|
|
42212
42590
|
try {
|
|
42213
42591
|
const json = this.storage.getItem('tracked-objects');
|