@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
|
@@ -61,6 +61,8 @@ export interface TrackingEngineConfig {
|
|
|
61
61
|
loiteringThreshold: number;
|
|
62
62
|
/** Per-object alert cooldown (ms) */
|
|
63
63
|
objectAlertCooldown: number;
|
|
64
|
+
/** Minimum detection score to consider */
|
|
65
|
+
minDetectionScore: number;
|
|
64
66
|
/** Use LLM for enhanced descriptions */
|
|
65
67
|
useLlmDescriptions: boolean;
|
|
66
68
|
/** Specific LLM device IDs to use (if not set, auto-discovers all for load balancing) */
|
|
@@ -112,7 +114,9 @@ export class TrackingEngine {
|
|
|
112
114
|
private spatialReasoning: SpatialReasoningEngine;
|
|
113
115
|
private listeners: Map<string, EventListenerRegister> = new Map();
|
|
114
116
|
private pendingTimers: Map<GlobalTrackingId, NodeJS.Timeout> = new Map();
|
|
117
|
+
private loiteringTimers: Map<GlobalTrackingId, NodeJS.Timeout> = new Map();
|
|
115
118
|
private lostCheckInterval: NodeJS.Timeout | null = null;
|
|
119
|
+
private cleanupInterval: NodeJS.Timeout | null = null;
|
|
116
120
|
/** Track last alert time per object to enforce cooldown */
|
|
117
121
|
private objectLastAlertTime: Map<GlobalTrackingId, number> = new Map();
|
|
118
122
|
/** Callback for topology changes (e.g., landmark suggestions) */
|
|
@@ -215,6 +219,10 @@ export class TrackingEngine {
|
|
|
215
219
|
this.checkForLostObjects();
|
|
216
220
|
}, 30000); // Check every 30 seconds
|
|
217
221
|
|
|
222
|
+
this.cleanupInterval = setInterval(() => {
|
|
223
|
+
this.state.cleanup();
|
|
224
|
+
}, 300000); // Cleanup every 5 minutes
|
|
225
|
+
|
|
218
226
|
this.console.log(`Tracking engine started with ${this.listeners.size} cameras`);
|
|
219
227
|
}
|
|
220
228
|
|
|
@@ -236,12 +244,22 @@ export class TrackingEngine {
|
|
|
236
244
|
}
|
|
237
245
|
this.pendingTimers.clear();
|
|
238
246
|
|
|
247
|
+
for (const timer of this.loiteringTimers.values()) {
|
|
248
|
+
clearTimeout(timer);
|
|
249
|
+
}
|
|
250
|
+
this.loiteringTimers.clear();
|
|
251
|
+
|
|
239
252
|
// Stop lost check interval
|
|
240
253
|
if (this.lostCheckInterval) {
|
|
241
254
|
clearInterval(this.lostCheckInterval);
|
|
242
255
|
this.lostCheckInterval = null;
|
|
243
256
|
}
|
|
244
257
|
|
|
258
|
+
if (this.cleanupInterval) {
|
|
259
|
+
clearInterval(this.cleanupInterval);
|
|
260
|
+
this.cleanupInterval = null;
|
|
261
|
+
}
|
|
262
|
+
|
|
245
263
|
this.console.log('Tracking engine stopped');
|
|
246
264
|
}
|
|
247
265
|
|
|
@@ -264,7 +282,7 @@ export class TrackingEngine {
|
|
|
264
282
|
|
|
265
283
|
for (const detection of detected.detections) {
|
|
266
284
|
// Skip low-confidence detections
|
|
267
|
-
if (detection.score <
|
|
285
|
+
if (detection.score < this.config.minDetectionScore) continue;
|
|
268
286
|
|
|
269
287
|
// If in training mode, record trainer detections
|
|
270
288
|
if (this.isTrainingActive() && detection.className === 'person') {
|
|
@@ -358,6 +376,15 @@ export class TrackingEngine {
|
|
|
358
376
|
const fallbackTimeout = this.config.llmFallbackTimeout ?? 3000;
|
|
359
377
|
|
|
360
378
|
try {
|
|
379
|
+
if (!this.config.useLlmDescriptions) {
|
|
380
|
+
return this.spatialReasoning.generateMovementDescription(
|
|
381
|
+
tracked,
|
|
382
|
+
fromCameraId,
|
|
383
|
+
toCameraId,
|
|
384
|
+
transitTime
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
361
388
|
// Check rate limiting - if not allowed, return null to use basic description
|
|
362
389
|
if (!this.tryLlmCall()) {
|
|
363
390
|
this.console.log('[Movement] LLM rate-limited, using basic notification');
|
|
@@ -366,11 +393,9 @@ export class TrackingEngine {
|
|
|
366
393
|
|
|
367
394
|
// Get snapshot from camera for LLM analysis (if LLM is enabled)
|
|
368
395
|
let mediaObject: MediaObject | undefined;
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
mediaObject = await camera.takePicture();
|
|
373
|
-
}
|
|
396
|
+
const camera = systemManager.getDeviceById<Camera>(currentCameraId);
|
|
397
|
+
if (camera?.interfaces?.includes(ScryptedInterface.Camera)) {
|
|
398
|
+
mediaObject = await camera.takePicture();
|
|
374
399
|
}
|
|
375
400
|
|
|
376
401
|
// Use spatial reasoning engine for rich context-aware description
|
|
@@ -460,6 +485,8 @@ export class TrackingEngine {
|
|
|
460
485
|
// Check if this is a cross-camera transition
|
|
461
486
|
const lastSighting = getLastSighting(tracked);
|
|
462
487
|
if (lastSighting && lastSighting.cameraId !== sighting.cameraId) {
|
|
488
|
+
// Cancel any pending loitering alert if object already transitioned
|
|
489
|
+
this.clearLoiteringTimer(tracked.globalId);
|
|
463
490
|
const transitDuration = sighting.timestamp - lastSighting.timestamp;
|
|
464
491
|
|
|
465
492
|
// Update cached snapshot from new camera (object is now visible here)
|
|
@@ -491,33 +518,57 @@ export class TrackingEngine {
|
|
|
491
518
|
);
|
|
492
519
|
|
|
493
520
|
// Check loitering threshold and per-object cooldown before alerting
|
|
494
|
-
if (this.passesLoiteringThreshold(tracked)
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
+
if (this.passesLoiteringThreshold(tracked)) {
|
|
522
|
+
if (this.isInAlertCooldown(tracked.globalId)) {
|
|
523
|
+
const spatialResult = await this.spatialReasoning.generateMovementDescription(
|
|
524
|
+
tracked,
|
|
525
|
+
lastSighting.cameraId,
|
|
526
|
+
sighting.cameraId,
|
|
527
|
+
transitDuration
|
|
528
|
+
);
|
|
529
|
+
|
|
530
|
+
await this.alertManager.updateMovementAlert(tracked, {
|
|
531
|
+
fromCameraId: lastSighting.cameraId,
|
|
532
|
+
fromCameraName: lastSighting.cameraName,
|
|
533
|
+
toCameraId: sighting.cameraId,
|
|
534
|
+
toCameraName: sighting.cameraName,
|
|
535
|
+
transitTime: transitDuration,
|
|
536
|
+
objectClass: sighting.detection.className,
|
|
537
|
+
objectLabel: spatialResult.description || sighting.detection.label,
|
|
538
|
+
detectionId: sighting.detectionId,
|
|
539
|
+
pathDescription: spatialResult.pathDescription,
|
|
540
|
+
involvedLandmarks: spatialResult.involvedLandmarks?.map(l => l.name),
|
|
541
|
+
usedLlm: spatialResult.usedLlm,
|
|
542
|
+
});
|
|
543
|
+
} else {
|
|
544
|
+
// Get spatial reasoning result with RAG context
|
|
545
|
+
const spatialResult = await this.getSpatialDescription(
|
|
546
|
+
tracked,
|
|
547
|
+
lastSighting.cameraId,
|
|
548
|
+
sighting.cameraId,
|
|
549
|
+
transitDuration,
|
|
550
|
+
sighting.cameraId
|
|
551
|
+
);
|
|
552
|
+
|
|
553
|
+
// Generate movement alert for cross-camera transition
|
|
554
|
+
const mediaObject = this.snapshotCache.get(tracked.globalId);
|
|
555
|
+
await this.alertManager.checkAndAlert('movement', tracked, {
|
|
556
|
+
fromCameraId: lastSighting.cameraId,
|
|
557
|
+
fromCameraName: lastSighting.cameraName,
|
|
558
|
+
toCameraId: sighting.cameraId,
|
|
559
|
+
toCameraName: sighting.cameraName,
|
|
560
|
+
transitTime: transitDuration,
|
|
561
|
+
objectClass: sighting.detection.className,
|
|
562
|
+
objectLabel: spatialResult?.description || sighting.detection.label,
|
|
563
|
+
detectionId: sighting.detectionId,
|
|
564
|
+
// Include spatial context for enriched alerts
|
|
565
|
+
pathDescription: spatialResult?.pathDescription,
|
|
566
|
+
involvedLandmarks: spatialResult?.involvedLandmarks?.map(l => l.name),
|
|
567
|
+
usedLlm: spatialResult?.usedLlm,
|
|
568
|
+
}, mediaObject);
|
|
569
|
+
|
|
570
|
+
this.recordAlertTime(tracked.globalId);
|
|
571
|
+
}
|
|
521
572
|
}
|
|
522
573
|
}
|
|
523
574
|
|
|
@@ -569,18 +620,49 @@ export class TrackingEngine {
|
|
|
569
620
|
}
|
|
570
621
|
|
|
571
622
|
// Check after loitering threshold if object is still being tracked
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
623
|
+
const existing = this.loiteringTimers.get(globalId);
|
|
624
|
+
if (existing) {
|
|
625
|
+
clearTimeout(existing);
|
|
626
|
+
this.loiteringTimers.delete(globalId);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const timer = setTimeout(async () => {
|
|
630
|
+
try {
|
|
631
|
+
const tracked = this.state.getObject(globalId);
|
|
632
|
+
if (!tracked || tracked.state !== 'active') return;
|
|
575
633
|
|
|
576
|
-
|
|
577
|
-
|
|
634
|
+
const lastSighting = getLastSighting(tracked);
|
|
635
|
+
if (!lastSighting || lastSighting.cameraId !== sighting.cameraId) {
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const maxStaleMs = Math.max(10000, this.config.loiteringThreshold * 2);
|
|
640
|
+
if (Date.now() - lastSighting.timestamp > maxStaleMs) {
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Check if we've already alerted for this object
|
|
645
|
+
if (this.isInAlertCooldown(globalId)) {
|
|
646
|
+
const spatialResult = await this.spatialReasoning.generateEntryDescription(tracked, sighting.cameraId);
|
|
647
|
+
await this.alertManager.updateMovementAlert(tracked, {
|
|
648
|
+
cameraId: sighting.cameraId,
|
|
649
|
+
cameraName: sighting.cameraName,
|
|
650
|
+
toCameraId: sighting.cameraId,
|
|
651
|
+
toCameraName: sighting.cameraName,
|
|
652
|
+
objectClass: sighting.detection.className,
|
|
653
|
+
objectLabel: spatialResult.description,
|
|
654
|
+
detectionId: sighting.detectionId,
|
|
655
|
+
involvedLandmarks: spatialResult.involvedLandmarks?.map(l => l.name),
|
|
656
|
+
usedLlm: spatialResult.usedLlm,
|
|
657
|
+
});
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
578
660
|
|
|
579
661
|
// Use prefetched LLM result if available (started when snapshot was captured)
|
|
580
662
|
let spatialResult: SpatialReasoningResult;
|
|
581
663
|
const pendingDescription = this.pendingDescriptions.get(globalId);
|
|
582
664
|
|
|
583
|
-
|
|
665
|
+
if (pendingDescription) {
|
|
584
666
|
this.console.log(`[Entry Alert] Using prefetched LLM result for ${globalId.slice(0, 8)}`);
|
|
585
667
|
try {
|
|
586
668
|
spatialResult = await pendingDescription;
|
|
@@ -591,7 +673,7 @@ export class TrackingEngine {
|
|
|
591
673
|
spatialResult = await this.spatialReasoning.generateEntryDescription(tracked, sighting.cameraId);
|
|
592
674
|
}
|
|
593
675
|
this.pendingDescriptions.delete(globalId);
|
|
594
|
-
|
|
676
|
+
} else {
|
|
595
677
|
// No prefetch available - only call LLM if rate limit allows
|
|
596
678
|
if (this.tryLlmCall()) {
|
|
597
679
|
this.console.log(`[Entry Alert] No prefetch, generating with LLM`);
|
|
@@ -607,7 +689,8 @@ export class TrackingEngine {
|
|
|
607
689
|
|
|
608
690
|
// Always use movement alert type for smart notifications with LLM descriptions
|
|
609
691
|
// The property_entry/property_exit types are legacy and disabled by default
|
|
610
|
-
|
|
692
|
+
const mediaObject = this.snapshotCache.get(globalId);
|
|
693
|
+
await this.alertManager.checkAndAlert('movement', tracked, {
|
|
611
694
|
cameraId: sighting.cameraId,
|
|
612
695
|
cameraName: sighting.cameraName,
|
|
613
696
|
toCameraId: sighting.cameraId,
|
|
@@ -617,10 +700,15 @@ export class TrackingEngine {
|
|
|
617
700
|
detectionId: sighting.detectionId,
|
|
618
701
|
involvedLandmarks: spatialResult.involvedLandmarks?.map(l => l.name),
|
|
619
702
|
usedLlm: spatialResult.usedLlm,
|
|
620
|
-
|
|
703
|
+
}, mediaObject);
|
|
621
704
|
|
|
622
|
-
|
|
705
|
+
this.recordAlertTime(globalId);
|
|
706
|
+
} finally {
|
|
707
|
+
this.loiteringTimers.delete(globalId);
|
|
708
|
+
}
|
|
623
709
|
}, this.config.loiteringThreshold);
|
|
710
|
+
|
|
711
|
+
this.loiteringTimers.set(globalId, timer);
|
|
624
712
|
}
|
|
625
713
|
|
|
626
714
|
/** Capture and cache a snapshot for a tracked object, and start LLM analysis immediately */
|
|
@@ -712,6 +800,9 @@ export class TrackingEngine {
|
|
|
712
800
|
// Mark as pending and set timer
|
|
713
801
|
this.state.markPending(tracked.globalId);
|
|
714
802
|
|
|
803
|
+
// Cancel any pending loitering alert
|
|
804
|
+
this.clearLoiteringTimer(tracked.globalId);
|
|
805
|
+
|
|
715
806
|
// Capture a fresh snapshot now while object is still visible (before they leave)
|
|
716
807
|
// Also starts LLM analysis immediately in parallel
|
|
717
808
|
if (this.config.useLlmDescriptions) {
|
|
@@ -756,6 +847,7 @@ export class TrackingEngine {
|
|
|
756
847
|
}
|
|
757
848
|
|
|
758
849
|
// Use movement alert for exit too - smart notifications with LLM descriptions
|
|
850
|
+
const mediaObject = this.snapshotCache.get(tracked.globalId);
|
|
759
851
|
await this.alertManager.checkAndAlert('movement', current, {
|
|
760
852
|
cameraId: sighting.cameraId,
|
|
761
853
|
cameraName: sighting.cameraName,
|
|
@@ -765,7 +857,9 @@ export class TrackingEngine {
|
|
|
765
857
|
objectLabel: spatialResult.description,
|
|
766
858
|
involvedLandmarks: spatialResult.involvedLandmarks?.map(l => l.name),
|
|
767
859
|
usedLlm: spatialResult.usedLlm,
|
|
768
|
-
});
|
|
860
|
+
}, mediaObject);
|
|
861
|
+
|
|
862
|
+
this.alertManager.clearActiveAlertsForObject(tracked.globalId);
|
|
769
863
|
|
|
770
864
|
// Clean up cached snapshot and pending descriptions after exit alert
|
|
771
865
|
this.snapshotCache.delete(tracked.globalId);
|
|
@@ -787,6 +881,7 @@ export class TrackingEngine {
|
|
|
787
881
|
|
|
788
882
|
if (timeSinceSeen > this.config.lostTimeout) {
|
|
789
883
|
this.state.markLost(tracked.globalId);
|
|
884
|
+
this.clearLoiteringTimer(tracked.globalId);
|
|
790
885
|
this.console.log(
|
|
791
886
|
`Object ${tracked.globalId.slice(0, 8)} marked as lost ` +
|
|
792
887
|
`(not seen for ${Math.round(timeSinceSeen / 1000)}s)`
|
|
@@ -800,10 +895,21 @@ export class TrackingEngine {
|
|
|
800
895
|
objectClass: tracked.className,
|
|
801
896
|
objectLabel: tracked.label,
|
|
802
897
|
});
|
|
898
|
+
|
|
899
|
+
this.alertManager.clearActiveAlertsForObject(tracked.globalId);
|
|
803
900
|
}
|
|
804
901
|
}
|
|
805
902
|
}
|
|
806
903
|
|
|
904
|
+
/** Clear a pending loitering timer if present */
|
|
905
|
+
private clearLoiteringTimer(globalId: GlobalTrackingId): void {
|
|
906
|
+
const timer = this.loiteringTimers.get(globalId);
|
|
907
|
+
if (timer) {
|
|
908
|
+
clearTimeout(timer);
|
|
909
|
+
this.loiteringTimers.delete(globalId);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
807
913
|
/** Update topology configuration */
|
|
808
914
|
updateTopology(topology: CameraTopology): void {
|
|
809
915
|
this.topology = topology;
|
package/src/main.ts
CHANGED
|
@@ -6,6 +6,7 @@ import sdk, {
|
|
|
6
6
|
Setting,
|
|
7
7
|
SettingValue,
|
|
8
8
|
ScryptedDeviceBase,
|
|
9
|
+
ScryptedDevice,
|
|
9
10
|
ScryptedDeviceType,
|
|
10
11
|
ScryptedInterface,
|
|
11
12
|
ScryptedNativeId,
|
|
@@ -111,6 +112,13 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
111
112
|
description: 'Object must be visible for this duration before triggering movement alerts',
|
|
112
113
|
group: 'Tracking',
|
|
113
114
|
},
|
|
115
|
+
minDetectionScore: {
|
|
116
|
+
title: 'Minimum Detection Confidence',
|
|
117
|
+
type: 'number',
|
|
118
|
+
defaultValue: 0.5,
|
|
119
|
+
description: 'Minimum detection score (0-1) to consider for tracking',
|
|
120
|
+
group: 'Tracking',
|
|
121
|
+
},
|
|
114
122
|
objectAlertCooldown: {
|
|
115
123
|
title: 'Per-Object Alert Cooldown (seconds)',
|
|
116
124
|
type: 'number',
|
|
@@ -118,6 +126,20 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
118
126
|
description: 'Minimum time between alerts for the same tracked object',
|
|
119
127
|
group: 'Tracking',
|
|
120
128
|
},
|
|
129
|
+
notifyOnAlertUpdates: {
|
|
130
|
+
title: 'Notify on Alert Updates',
|
|
131
|
+
type: 'boolean',
|
|
132
|
+
defaultValue: false,
|
|
133
|
+
description: 'Send notifications when an existing alert is updated (camera transitions, context changes)',
|
|
134
|
+
group: 'Tracking',
|
|
135
|
+
},
|
|
136
|
+
alertUpdateCooldown: {
|
|
137
|
+
title: 'Alert Update Cooldown (seconds)',
|
|
138
|
+
type: 'number',
|
|
139
|
+
defaultValue: 60,
|
|
140
|
+
description: 'Minimum time between update notifications for the same tracked object (0 = no limit)',
|
|
141
|
+
group: 'Tracking',
|
|
142
|
+
},
|
|
121
143
|
|
|
122
144
|
// LLM Integration
|
|
123
145
|
useLlmDescriptions: {
|
|
@@ -321,6 +343,8 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
321
343
|
await this.initializeMqtt();
|
|
322
344
|
}
|
|
323
345
|
|
|
346
|
+
this.applyAlertUpdateSettings();
|
|
347
|
+
|
|
324
348
|
this.console.log('Spatial Awareness Plugin initialized');
|
|
325
349
|
}
|
|
326
350
|
|
|
@@ -356,21 +380,22 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
356
380
|
}
|
|
357
381
|
|
|
358
382
|
const config: TrackingEngineConfig = {
|
|
359
|
-
correlationWindow: (this.storageSettings.values.correlationWindow
|
|
360
|
-
correlationThreshold: this.storageSettings.values.correlationThreshold
|
|
361
|
-
lostTimeout: (this.storageSettings.values.lostTimeout
|
|
383
|
+
correlationWindow: this.getNumberSetting(this.storageSettings.values.correlationWindow, 30) * 1000,
|
|
384
|
+
correlationThreshold: this.getNumberSetting(this.storageSettings.values.correlationThreshold, 0.35),
|
|
385
|
+
lostTimeout: this.getNumberSetting(this.storageSettings.values.lostTimeout, 300) * 1000,
|
|
362
386
|
useVisualMatching: this.storageSettings.values.useVisualMatching as boolean ?? true,
|
|
363
|
-
loiteringThreshold: (this.storageSettings.values.loiteringThreshold
|
|
364
|
-
|
|
387
|
+
loiteringThreshold: this.getNumberSetting(this.storageSettings.values.loiteringThreshold, 3) * 1000,
|
|
388
|
+
minDetectionScore: this.getNumberSetting(this.storageSettings.values.minDetectionScore, 0.5),
|
|
389
|
+
objectAlertCooldown: this.getNumberSetting(this.storageSettings.values.objectAlertCooldown, 30) * 1000,
|
|
365
390
|
useLlmDescriptions: this.storageSettings.values.useLlmDescriptions as boolean ?? true,
|
|
366
391
|
llmDeviceIds: this.parseLlmProviders(),
|
|
367
|
-
llmDebounceInterval: (this.storageSettings.values.llmDebounceInterval
|
|
392
|
+
llmDebounceInterval: this.getNumberSetting(this.storageSettings.values.llmDebounceInterval, 5) * 1000,
|
|
368
393
|
llmFallbackEnabled: this.storageSettings.values.llmFallbackEnabled as boolean ?? true,
|
|
369
|
-
llmFallbackTimeout: (this.storageSettings.values.llmFallbackTimeout
|
|
394
|
+
llmFallbackTimeout: this.getNumberSetting(this.storageSettings.values.llmFallbackTimeout, 3) * 1000,
|
|
370
395
|
enableTransitTimeLearning: this.storageSettings.values.enableTransitTimeLearning as boolean ?? true,
|
|
371
396
|
enableConnectionSuggestions: this.storageSettings.values.enableConnectionSuggestions as boolean ?? true,
|
|
372
397
|
enableLandmarkLearning: this.storageSettings.values.enableLandmarkLearning as boolean ?? true,
|
|
373
|
-
landmarkConfidenceThreshold: this.storageSettings.values.landmarkConfidenceThreshold
|
|
398
|
+
landmarkConfidenceThreshold: this.getNumberSetting(this.storageSettings.values.landmarkConfidenceThreshold, 0.7),
|
|
374
399
|
};
|
|
375
400
|
|
|
376
401
|
this.trackingEngine = new TrackingEngine(
|
|
@@ -381,6 +406,8 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
381
406
|
this.console
|
|
382
407
|
);
|
|
383
408
|
|
|
409
|
+
this.applyAlertUpdateSettings();
|
|
410
|
+
|
|
384
411
|
// Set up callback to save topology changes (e.g., from accepted landmark suggestions)
|
|
385
412
|
this.trackingEngine.setTopologyChangeCallback((updatedTopology) => {
|
|
386
413
|
this.storage.setItem('topology', JSON.stringify(updatedTopology));
|
|
@@ -396,10 +423,10 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
396
423
|
|
|
397
424
|
private async initializeDiscoveryEngine(topology: CameraTopology): Promise<void> {
|
|
398
425
|
const discoveryConfig: DiscoveryConfig = {
|
|
399
|
-
discoveryIntervalHours: this.storageSettings.values.discoveryIntervalHours
|
|
400
|
-
autoAcceptThreshold: this.storageSettings.values.autoAcceptThreshold
|
|
401
|
-
minLandmarkConfidence: this.storageSettings.values.minLandmarkConfidence
|
|
402
|
-
minConnectionConfidence: this.storageSettings.values.minConnectionConfidence
|
|
426
|
+
discoveryIntervalHours: this.getNumberSetting(this.storageSettings.values.discoveryIntervalHours, 0),
|
|
427
|
+
autoAcceptThreshold: this.getNumberSetting(this.storageSettings.values.autoAcceptThreshold, 0.85),
|
|
428
|
+
minLandmarkConfidence: this.getNumberSetting(this.storageSettings.values.minLandmarkConfidence, 0.6),
|
|
429
|
+
minConnectionConfidence: this.getNumberSetting(this.storageSettings.values.minConnectionConfidence, 0.5),
|
|
403
430
|
};
|
|
404
431
|
|
|
405
432
|
if (this.discoveryEngine) {
|
|
@@ -448,6 +475,12 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
448
475
|
return undefined;
|
|
449
476
|
}
|
|
450
477
|
|
|
478
|
+
private getNumberSetting(value: unknown, fallback: number): number {
|
|
479
|
+
if (value === undefined || value === null) return fallback;
|
|
480
|
+
const num = typeof value === 'string' ? Number(value) : (value as number);
|
|
481
|
+
return Number.isFinite(num) ? num : fallback;
|
|
482
|
+
}
|
|
483
|
+
|
|
451
484
|
// ==================== DeviceProvider Implementation ====================
|
|
452
485
|
|
|
453
486
|
async getDevice(nativeId: string): Promise<any> {
|
|
@@ -732,6 +765,20 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
732
765
|
// ==================== 8. Auto-Topology Discovery ====================
|
|
733
766
|
addGroup('Auto-Topology Discovery');
|
|
734
767
|
|
|
768
|
+
if (this.discoveryEngine) {
|
|
769
|
+
const excluded = this.discoveryEngine.getExcludedVisionLlmNames();
|
|
770
|
+
if (excluded.length > 0) {
|
|
771
|
+
settings.push({
|
|
772
|
+
key: 'excludedVisionLlms',
|
|
773
|
+
title: 'Excluded LLMs (No Vision)',
|
|
774
|
+
type: 'string',
|
|
775
|
+
readonly: true,
|
|
776
|
+
value: excluded.join(', '),
|
|
777
|
+
group: 'Auto-Topology Discovery',
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
735
782
|
// ==================== 9. MQTT Integration ====================
|
|
736
783
|
addGroup('MQTT Integration');
|
|
737
784
|
|
|
@@ -749,6 +796,7 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
749
796
|
key === 'lostTimeout' ||
|
|
750
797
|
key === 'useVisualMatching' ||
|
|
751
798
|
key === 'loiteringThreshold' ||
|
|
799
|
+
key === 'minDetectionScore' ||
|
|
752
800
|
key === 'objectAlertCooldown' ||
|
|
753
801
|
key === 'useLlmDescriptions' ||
|
|
754
802
|
key === 'llmDebounceInterval' ||
|
|
@@ -771,6 +819,10 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
771
819
|
}
|
|
772
820
|
}
|
|
773
821
|
|
|
822
|
+
if (key === 'notifyOnAlertUpdates' || key === 'alertUpdateCooldown') {
|
|
823
|
+
this.applyAlertUpdateSettings();
|
|
824
|
+
}
|
|
825
|
+
|
|
774
826
|
// Handle MQTT setting changes
|
|
775
827
|
if (key === 'enableMqtt' || key === 'mqttBroker' || key === 'mqttUsername' ||
|
|
776
828
|
key === 'mqttPassword' || key === 'mqttBaseTopic') {
|
|
@@ -784,6 +836,12 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
784
836
|
}
|
|
785
837
|
}
|
|
786
838
|
|
|
839
|
+
private applyAlertUpdateSettings(): void {
|
|
840
|
+
const enabled = this.storageSettings.values.notifyOnAlertUpdates as boolean ?? false;
|
|
841
|
+
const cooldownSeconds = this.getNumberSetting(this.storageSettings.values.alertUpdateCooldown, 60);
|
|
842
|
+
this.alertManager.setUpdateNotificationOptions(enabled, cooldownSeconds * 1000);
|
|
843
|
+
}
|
|
844
|
+
|
|
787
845
|
// ==================== HttpRequestHandler Implementation ====================
|
|
788
846
|
|
|
789
847
|
async onRequest(request: HttpRequest, response: HttpResponse): Promise<void> {
|
|
@@ -874,7 +932,7 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
874
932
|
|
|
875
933
|
// Training Mode endpoints
|
|
876
934
|
if (path.endsWith('/api/training/start')) {
|
|
877
|
-
return this.handleTrainingStartRequest(request, response);
|
|
935
|
+
return await this.handleTrainingStartRequest(request, response);
|
|
878
936
|
}
|
|
879
937
|
if (path.endsWith('/api/training/pause')) {
|
|
880
938
|
return this.handleTrainingPauseRequest(response);
|
|
@@ -1468,13 +1526,25 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
1468
1526
|
|
|
1469
1527
|
// ==================== Training Mode Handlers ====================
|
|
1470
1528
|
|
|
1471
|
-
private handleTrainingStartRequest(request: HttpRequest, response: HttpResponse): void {
|
|
1529
|
+
private async handleTrainingStartRequest(request: HttpRequest, response: HttpResponse): Promise<void> {
|
|
1472
1530
|
if (!this.trackingEngine) {
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1531
|
+
const topologyJson = this.storage.getItem('topology');
|
|
1532
|
+
const topology = topologyJson ? JSON.parse(topologyJson) as CameraTopology : createEmptyTopology();
|
|
1533
|
+
|
|
1534
|
+
if (!topology.cameras?.length) {
|
|
1535
|
+
const cameras = this.buildTopologyCamerasFromSettings();
|
|
1536
|
+
if (cameras.length === 0) {
|
|
1537
|
+
response.send(JSON.stringify({ error: 'No cameras configured. Select tracked cameras first.' }), {
|
|
1538
|
+
code: 400,
|
|
1539
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1540
|
+
});
|
|
1541
|
+
return;
|
|
1542
|
+
}
|
|
1543
|
+
topology.cameras = cameras;
|
|
1544
|
+
this.storage.setItem('topology', JSON.stringify(topology));
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
await this.startTrackingEngine(topology);
|
|
1478
1548
|
}
|
|
1479
1549
|
|
|
1480
1550
|
try {
|
|
@@ -2282,6 +2352,27 @@ Access the visual topology editor at \`/ui/editor\` to configure camera relation
|
|
|
2282
2352
|
const topologyJson = this.storage.getItem('topology');
|
|
2283
2353
|
return topologyJson ? JSON.parse(topologyJson) : null;
|
|
2284
2354
|
}
|
|
2355
|
+
|
|
2356
|
+
private buildTopologyCamerasFromSettings(): CameraTopology['cameras'] {
|
|
2357
|
+
const value = this.storageSettings.values.trackedCameras;
|
|
2358
|
+
const cameraIds = Array.isArray(value)
|
|
2359
|
+
? value.filter(Boolean)
|
|
2360
|
+
: typeof value === 'string' && value.length
|
|
2361
|
+
? [value]
|
|
2362
|
+
: [];
|
|
2363
|
+
|
|
2364
|
+
return cameraIds.map((deviceId: string) => {
|
|
2365
|
+
const device = systemManager.getDeviceById<ScryptedDevice>(deviceId);
|
|
2366
|
+
return {
|
|
2367
|
+
deviceId,
|
|
2368
|
+
nativeId: device?.nativeId || deviceId,
|
|
2369
|
+
name: device?.name || deviceId,
|
|
2370
|
+
isEntryPoint: false,
|
|
2371
|
+
isExitPoint: false,
|
|
2372
|
+
trackClasses: [],
|
|
2373
|
+
};
|
|
2374
|
+
});
|
|
2375
|
+
}
|
|
2285
2376
|
}
|
|
2286
2377
|
|
|
2287
2378
|
export default SpatialAwarenessPlugin;
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Defines objects being tracked across multiple cameras
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { ObjectDetectionResult } from '@scrypted/sdk';
|
|
6
|
+
import type { ObjectDetectionResult } from '@scrypted/sdk';
|
|
7
7
|
|
|
8
8
|
/** Unique identifier for a globally tracked object */
|
|
9
9
|
export type GlobalTrackingId = string;
|
|
@@ -21,6 +21,8 @@ export class TrackingState {
|
|
|
21
21
|
private changeCallbacks: StateChangeCallback[] = [];
|
|
22
22
|
private storage: Storage;
|
|
23
23
|
private console: Console;
|
|
24
|
+
private persistTimer: NodeJS.Timeout | null = null;
|
|
25
|
+
private readonly persistDebounceMs: number = 2000;
|
|
24
26
|
|
|
25
27
|
constructor(storage: Storage, console: Console) {
|
|
26
28
|
this.storage = storage;
|
|
@@ -60,7 +62,7 @@ export class TrackingState {
|
|
|
60
62
|
|
|
61
63
|
this.objects.set(object.globalId, object);
|
|
62
64
|
this.notifyChange();
|
|
63
|
-
this.
|
|
65
|
+
this.schedulePersist();
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
/** Add a new sighting to an existing tracked object */
|
|
@@ -77,7 +79,7 @@ export class TrackingState {
|
|
|
77
79
|
this.objectsByCamera.get(sighting.cameraId)!.add(globalId);
|
|
78
80
|
|
|
79
81
|
this.notifyChange();
|
|
80
|
-
this.
|
|
82
|
+
this.schedulePersist();
|
|
81
83
|
return true;
|
|
82
84
|
}
|
|
83
85
|
|
|
@@ -96,7 +98,7 @@ export class TrackingState {
|
|
|
96
98
|
this.objectsByCamera.get(segment.toCameraId)!.add(globalId);
|
|
97
99
|
|
|
98
100
|
this.notifyChange();
|
|
99
|
-
this.
|
|
101
|
+
this.schedulePersist();
|
|
100
102
|
return true;
|
|
101
103
|
}
|
|
102
104
|
|
|
@@ -150,7 +152,7 @@ export class TrackingState {
|
|
|
150
152
|
}
|
|
151
153
|
|
|
152
154
|
this.notifyChange();
|
|
153
|
-
this.
|
|
155
|
+
this.schedulePersist();
|
|
154
156
|
}
|
|
155
157
|
}
|
|
156
158
|
|
|
@@ -167,7 +169,7 @@ export class TrackingState {
|
|
|
167
169
|
}
|
|
168
170
|
|
|
169
171
|
this.notifyChange();
|
|
170
|
-
this.
|
|
172
|
+
this.schedulePersist();
|
|
171
173
|
}
|
|
172
174
|
}
|
|
173
175
|
|
|
@@ -229,6 +231,17 @@ export class TrackingState {
|
|
|
229
231
|
}
|
|
230
232
|
}
|
|
231
233
|
|
|
234
|
+
private schedulePersist(): void {
|
|
235
|
+
if (this.persistTimer) {
|
|
236
|
+
clearTimeout(this.persistTimer);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
this.persistTimer = setTimeout(() => {
|
|
240
|
+
this.persistTimer = null;
|
|
241
|
+
this.persistState();
|
|
242
|
+
}, this.persistDebounceMs);
|
|
243
|
+
}
|
|
244
|
+
|
|
232
245
|
private loadPersistedState(): void {
|
|
233
246
|
try {
|
|
234
247
|
const json = this.storage.getItem('tracked-objects');
|