@blueharford/scrypted-spatial-awareness 0.4.7 → 0.5.0-beta

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.
@@ -516,31 +516,61 @@ export class TrackingEngine {
516
516
  `(ID: ${globalId.slice(0, 8)})`
517
517
  );
518
518
 
519
- // Generate entry alert if this is an entry point
520
- // Entry alerts also respect loitering threshold and cooldown
521
- if (isEntryPoint && this.passesLoiteringThreshold(tracked) && !this.isInAlertCooldown(globalId)) {
522
- // Get spatial reasoning for entry event
523
- const spatialResult = await this.getSpatialDescription(
524
- tracked,
525
- 'outside', // Virtual "outside" location for entry
526
- sighting.cameraId,
527
- 0,
528
- sighting.cameraId
529
- );
519
+ // Schedule loitering check - alert after object passes loitering threshold
520
+ // This ensures we don't miss alerts for brief appearances while still filtering noise
521
+ this.scheduleLoiteringAlert(globalId, sighting, isEntryPoint);
522
+ }
523
+ }
524
+
525
+ /** Schedule an alert after loitering threshold passes */
526
+ private scheduleLoiteringAlert(
527
+ globalId: GlobalTrackingId,
528
+ sighting: ObjectSighting,
529
+ isEntryPoint: boolean
530
+ ): void {
531
+ // Check after loitering threshold if object is still being tracked
532
+ setTimeout(async () => {
533
+ const tracked = this.state.getObject(globalId);
534
+ if (!tracked || tracked.state !== 'active') return;
535
+
536
+ // Check if we've already alerted for this object
537
+ if (this.isInAlertCooldown(globalId)) return;
538
+
539
+ // Generate spatial description
540
+ const spatialResult = this.spatialReasoning.generateEntryDescription(
541
+ tracked,
542
+ sighting.cameraId
543
+ );
530
544
 
545
+ if (isEntryPoint) {
546
+ // Entry point - generate property entry alert
531
547
  await this.alertManager.checkAndAlert('property_entry', tracked, {
532
548
  cameraId: sighting.cameraId,
533
549
  cameraName: sighting.cameraName,
534
550
  objectClass: sighting.detection.className,
535
- objectLabel: spatialResult?.description || sighting.detection.label,
551
+ objectLabel: spatialResult.description,
536
552
  detectionId: sighting.detectionId,
537
- involvedLandmarks: spatialResult?.involvedLandmarks?.map(l => l.name),
538
- usedLlm: spatialResult?.usedLlm,
553
+ involvedLandmarks: spatialResult.involvedLandmarks?.map(l => l.name),
554
+ usedLlm: spatialResult.usedLlm,
555
+ });
556
+ } else {
557
+ // Non-entry point - still alert about activity using movement alert type
558
+ // This notifies about any activity around the property using topology context
559
+ await this.alertManager.checkAndAlert('movement', tracked, {
560
+ cameraId: sighting.cameraId,
561
+ cameraName: sighting.cameraName,
562
+ toCameraId: sighting.cameraId,
563
+ toCameraName: sighting.cameraName,
564
+ objectClass: sighting.detection.className,
565
+ objectLabel: spatialResult.description, // Use spatial reasoning description (topology-based)
566
+ detectionId: sighting.detectionId,
567
+ involvedLandmarks: spatialResult.involvedLandmarks?.map(l => l.name),
568
+ usedLlm: spatialResult.usedLlm,
539
569
  });
540
-
541
- this.recordAlertTime(globalId);
542
570
  }
543
- }
571
+
572
+ this.recordAlertTime(globalId);
573
+ }, this.config.loiteringThreshold);
544
574
  }
545
575
 
546
576
  /** Attempt to correlate a sighting with existing tracked objects */
@@ -596,15 +626,23 @@ export class TrackingEngine {
596
626
  if (current && current.state === 'pending') {
597
627
  this.state.markExited(tracked.globalId, sighting.cameraId, sighting.cameraName);
598
628
 
629
+ // Generate rich exit description using topology context
630
+ const spatialResult = this.spatialReasoning.generateExitDescription(
631
+ current,
632
+ sighting.cameraId
633
+ );
634
+
599
635
  this.console.log(
600
- `Object ${tracked.globalId.slice(0, 8)} exited via ${sighting.cameraName}`
636
+ `Object ${tracked.globalId.slice(0, 8)} exited: ${spatialResult.description}`
601
637
  );
602
638
 
603
639
  await this.alertManager.checkAndAlert('property_exit', current, {
604
640
  cameraId: sighting.cameraId,
605
641
  cameraName: sighting.cameraName,
606
642
  objectClass: current.className,
607
- objectLabel: current.label,
643
+ objectLabel: spatialResult.description,
644
+ involvedLandmarks: spatialResult.involvedLandmarks?.map(l => l.name),
645
+ usedLlm: spatialResult.usedLlm,
608
646
  });
609
647
  }
610
648
  this.pendingTimers.delete(tracked.globalId);
package/src/main.ts CHANGED
@@ -36,6 +36,8 @@ import { MqttPublisher, MqttConfig } from './integrations/mqtt-publisher';
36
36
  import { EDITOR_HTML } from './ui/editor-html';
37
37
  import { TRAINING_HTML } from './ui/training-html';
38
38
  import { TrainingConfig, TrainingLandmark } from './models/training';
39
+ import { TopologyDiscoveryEngine } from './core/topology-discovery';
40
+ import { DiscoveryConfig, DiscoverySuggestion } from './models/discovery';
39
41
 
40
42
  const { deviceManager, systemManager, mediaManager } = sdk;
41
43
 
@@ -49,6 +51,7 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
49
51
  private trackingState: TrackingState;
50
52
  private alertManager: AlertManager;
51
53
  private mqttPublisher: MqttPublisher | null = null;
54
+ private discoveryEngine: TopologyDiscoveryEngine | null = null;
52
55
  private devices: Map<string, any> = new Map();
53
56
 
54
57
  storageSettings = new StorageSettings(this, {
@@ -169,6 +172,36 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
169
172
  group: 'AI & Spatial Reasoning',
170
173
  },
171
174
 
175
+ // Auto-Topology Discovery Settings
176
+ discoveryIntervalHours: {
177
+ title: 'Auto-Discovery Interval (hours)',
178
+ type: 'number',
179
+ defaultValue: 0,
180
+ description: 'Automatically scan cameras to discover landmarks and connections. Set to 0 to disable. Uses vision LLM to analyze camera views.',
181
+ group: 'Auto-Topology Discovery',
182
+ },
183
+ minLandmarkConfidence: {
184
+ title: 'Min Landmark Confidence',
185
+ type: 'number',
186
+ defaultValue: 0.6,
187
+ description: 'Minimum confidence (0-1) for discovered landmarks',
188
+ group: 'Auto-Topology Discovery',
189
+ },
190
+ minConnectionConfidence: {
191
+ title: 'Min Connection Confidence',
192
+ type: 'number',
193
+ defaultValue: 0.5,
194
+ description: 'Minimum confidence (0-1) for suggested camera connections',
195
+ group: 'Auto-Topology Discovery',
196
+ },
197
+ autoAcceptThreshold: {
198
+ title: 'Auto-Accept Threshold',
199
+ type: 'number',
200
+ defaultValue: 0.85,
201
+ description: 'Suggestions above this confidence are automatically accepted (0-1)',
202
+ group: 'Auto-Topology Discovery',
203
+ },
204
+
172
205
  // MQTT Settings
173
206
  enableMqtt: {
174
207
  title: 'Enable MQTT',
@@ -355,6 +388,33 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
355
388
 
356
389
  await this.trackingEngine.startTracking();
357
390
  this.console.log('Tracking engine started');
391
+
392
+ // Initialize or update discovery engine
393
+ await this.initializeDiscoveryEngine(topology);
394
+ }
395
+
396
+ private async initializeDiscoveryEngine(topology: CameraTopology): Promise<void> {
397
+ const discoveryConfig: DiscoveryConfig = {
398
+ discoveryIntervalHours: this.storageSettings.values.discoveryIntervalHours as number ?? 0,
399
+ autoAcceptThreshold: this.storageSettings.values.autoAcceptThreshold as number ?? 0.85,
400
+ minLandmarkConfidence: this.storageSettings.values.minLandmarkConfidence as number ?? 0.6,
401
+ minConnectionConfidence: this.storageSettings.values.minConnectionConfidence as number ?? 0.5,
402
+ };
403
+
404
+ if (this.discoveryEngine) {
405
+ // Update existing engine
406
+ this.discoveryEngine.updateConfig(discoveryConfig);
407
+ this.discoveryEngine.updateTopology(topology);
408
+ } else {
409
+ // Create new engine
410
+ this.discoveryEngine = new TopologyDiscoveryEngine(discoveryConfig, this.console);
411
+ this.discoveryEngine.updateTopology(topology);
412
+
413
+ // Start periodic discovery if enabled
414
+ if (discoveryConfig.discoveryIntervalHours > 0) {
415
+ this.discoveryEngine.startPeriodicDiscovery();
416
+ }
417
+ }
358
418
  }
359
419
 
360
420
  // ==================== DeviceProvider Implementation ====================
@@ -874,6 +934,27 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
874
934
  return this.handleTrainingApplyRequest(response);
875
935
  }
876
936
 
937
+ // Discovery endpoints
938
+ if (path.endsWith('/api/discovery/scan')) {
939
+ return this.handleDiscoveryScanRequest(response);
940
+ }
941
+ if (path.endsWith('/api/discovery/status')) {
942
+ return this.handleDiscoveryStatusRequest(response);
943
+ }
944
+ if (path.endsWith('/api/discovery/suggestions')) {
945
+ return this.handleDiscoverySuggestionsRequest(response);
946
+ }
947
+ if (path.match(/\/api\/discovery\/suggestions\/[\w-]+\/(accept|reject)$/)) {
948
+ const parts = path.split('/');
949
+ const action = parts.pop()!;
950
+ const suggestionId = parts.pop()!;
951
+ return this.handleDiscoverySuggestionActionRequest(suggestionId, action, response);
952
+ }
953
+ if (path.match(/\/api\/discovery\/camera\/[\w-]+$/)) {
954
+ const cameraId = path.split('/').pop()!;
955
+ return this.handleDiscoveryCameraAnalysisRequest(cameraId, response);
956
+ }
957
+
877
958
  // UI Routes
878
959
  if (path.endsWith('/ui/editor') || path.endsWith('/ui/editor/')) {
879
960
  return this.serveEditorUI(response);
@@ -890,7 +971,7 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
890
971
  // Default: return info page
891
972
  response.send(JSON.stringify({
892
973
  name: 'Spatial Awareness Plugin',
893
- version: '0.4.0',
974
+ version: '0.5.0-beta',
894
975
  endpoints: {
895
976
  api: {
896
977
  trackedObjects: '/api/tracked-objects',
@@ -911,6 +992,12 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
911
992
  landmark: '/api/training/landmark',
912
993
  apply: '/api/training/apply',
913
994
  },
995
+ discovery: {
996
+ scan: '/api/discovery/scan',
997
+ status: '/api/discovery/status',
998
+ suggestions: '/api/discovery/suggestions',
999
+ camera: '/api/discovery/camera/{cameraId}',
1000
+ },
914
1001
  },
915
1002
  ui: {
916
1003
  editor: '/ui/editor',
@@ -1581,6 +1668,207 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
1581
1668
  });
1582
1669
  }
1583
1670
 
1671
+ // ==================== Discovery Handlers ====================
1672
+
1673
+ private async handleDiscoveryScanRequest(response: HttpResponse): Promise<void> {
1674
+ if (!this.discoveryEngine) {
1675
+ response.send(JSON.stringify({ error: 'Discovery engine not initialized. Configure topology first.' }), {
1676
+ code: 500,
1677
+ headers: { 'Content-Type': 'application/json' },
1678
+ });
1679
+ return;
1680
+ }
1681
+
1682
+ try {
1683
+ this.console.log('[Discovery] Manual scan triggered via API');
1684
+ const correlation = await this.discoveryEngine.runFullDiscovery();
1685
+ const status = this.discoveryEngine.getStatus();
1686
+ const suggestions = this.discoveryEngine.getPendingSuggestions();
1687
+
1688
+ response.send(JSON.stringify({
1689
+ success: true,
1690
+ status,
1691
+ correlation,
1692
+ suggestions,
1693
+ }), {
1694
+ headers: { 'Content-Type': 'application/json' },
1695
+ });
1696
+ } catch (e) {
1697
+ this.console.error('[Discovery] Scan failed:', e);
1698
+ response.send(JSON.stringify({ error: `Scan failed: ${(e as Error).message}` }), {
1699
+ code: 500,
1700
+ headers: { 'Content-Type': 'application/json' },
1701
+ });
1702
+ }
1703
+ }
1704
+
1705
+ private handleDiscoveryStatusRequest(response: HttpResponse): void {
1706
+ if (!this.discoveryEngine) {
1707
+ response.send(JSON.stringify({
1708
+ isRunning: false,
1709
+ isScanning: false,
1710
+ lastScanTime: null,
1711
+ nextScanTime: null,
1712
+ camerasAnalyzed: 0,
1713
+ pendingSuggestions: 0,
1714
+ }), {
1715
+ headers: { 'Content-Type': 'application/json' },
1716
+ });
1717
+ return;
1718
+ }
1719
+
1720
+ const status = this.discoveryEngine.getStatus();
1721
+ response.send(JSON.stringify(status), {
1722
+ headers: { 'Content-Type': 'application/json' },
1723
+ });
1724
+ }
1725
+
1726
+ private handleDiscoverySuggestionsRequest(response: HttpResponse): void {
1727
+ if (!this.discoveryEngine) {
1728
+ response.send(JSON.stringify({ suggestions: [] }), {
1729
+ headers: { 'Content-Type': 'application/json' },
1730
+ });
1731
+ return;
1732
+ }
1733
+
1734
+ const suggestions = this.discoveryEngine.getPendingSuggestions();
1735
+ response.send(JSON.stringify({ suggestions }), {
1736
+ headers: { 'Content-Type': 'application/json' },
1737
+ });
1738
+ }
1739
+
1740
+ private handleDiscoverySuggestionActionRequest(
1741
+ suggestionId: string,
1742
+ action: string,
1743
+ response: HttpResponse
1744
+ ): void {
1745
+ if (!this.discoveryEngine) {
1746
+ response.send(JSON.stringify({ error: 'Discovery engine not initialized' }), {
1747
+ code: 500,
1748
+ headers: { 'Content-Type': 'application/json' },
1749
+ });
1750
+ return;
1751
+ }
1752
+
1753
+ if (action === 'accept') {
1754
+ const suggestion = this.discoveryEngine.acceptSuggestion(suggestionId);
1755
+ if (suggestion) {
1756
+ // Apply accepted suggestion to topology
1757
+ this.applyDiscoverySuggestion(suggestion);
1758
+ response.send(JSON.stringify({ success: true, suggestion }), {
1759
+ headers: { 'Content-Type': 'application/json' },
1760
+ });
1761
+ } else {
1762
+ response.send(JSON.stringify({ error: 'Suggestion not found' }), {
1763
+ code: 404,
1764
+ headers: { 'Content-Type': 'application/json' },
1765
+ });
1766
+ }
1767
+ } else if (action === 'reject') {
1768
+ const success = this.discoveryEngine.rejectSuggestion(suggestionId);
1769
+ if (success) {
1770
+ response.send(JSON.stringify({ success: true }), {
1771
+ headers: { 'Content-Type': 'application/json' },
1772
+ });
1773
+ } else {
1774
+ response.send(JSON.stringify({ error: 'Suggestion not found' }), {
1775
+ code: 404,
1776
+ headers: { 'Content-Type': 'application/json' },
1777
+ });
1778
+ }
1779
+ } else {
1780
+ response.send(JSON.stringify({ error: 'Invalid action' }), {
1781
+ code: 400,
1782
+ headers: { 'Content-Type': 'application/json' },
1783
+ });
1784
+ }
1785
+ }
1786
+
1787
+ private async handleDiscoveryCameraAnalysisRequest(
1788
+ cameraId: string,
1789
+ response: HttpResponse
1790
+ ): Promise<void> {
1791
+ if (!this.discoveryEngine) {
1792
+ response.send(JSON.stringify({ error: 'Discovery engine not initialized' }), {
1793
+ code: 500,
1794
+ headers: { 'Content-Type': 'application/json' },
1795
+ });
1796
+ return;
1797
+ }
1798
+
1799
+ try {
1800
+ const analysis = await this.discoveryEngine.analyzeScene(cameraId);
1801
+ response.send(JSON.stringify(analysis), {
1802
+ headers: { 'Content-Type': 'application/json' },
1803
+ });
1804
+ } catch (e) {
1805
+ response.send(JSON.stringify({ error: `Analysis failed: ${(e as Error).message}` }), {
1806
+ code: 500,
1807
+ headers: { 'Content-Type': 'application/json' },
1808
+ });
1809
+ }
1810
+ }
1811
+
1812
+ private applyDiscoverySuggestion(suggestion: DiscoverySuggestion): void {
1813
+ if (!this.trackingEngine) return;
1814
+
1815
+ const topology = this.trackingEngine.getTopology();
1816
+ let updated = false;
1817
+
1818
+ if (suggestion.type === 'landmark' && suggestion.landmark) {
1819
+ // Add new landmark to topology
1820
+ const landmark: Landmark = {
1821
+ id: `landmark_${Date.now()}`,
1822
+ name: suggestion.landmark.name!,
1823
+ type: suggestion.landmark.type!,
1824
+ position: suggestion.landmark.position || { x: 0, y: 0 },
1825
+ description: suggestion.landmark.description,
1826
+ visibleFromCameras: suggestion.landmark.visibleFromCameras,
1827
+ aiSuggested: true,
1828
+ aiConfidence: suggestion.confidence,
1829
+ };
1830
+
1831
+ if (!topology.landmarks) {
1832
+ topology.landmarks = [];
1833
+ }
1834
+ topology.landmarks.push(landmark);
1835
+ updated = true;
1836
+
1837
+ this.console.log(`[Discovery] Added landmark: ${landmark.name}`);
1838
+ }
1839
+
1840
+ if (suggestion.type === 'connection' && suggestion.connection) {
1841
+ // Add new connection to topology
1842
+ const conn = suggestion.connection;
1843
+ const newConnection = {
1844
+ id: `conn_${Date.now()}`,
1845
+ fromCameraId: conn.fromCameraId,
1846
+ toCameraId: conn.toCameraId,
1847
+ bidirectional: conn.bidirectional,
1848
+ // Default exit/entry zones covering full frame
1849
+ exitZone: [[0, 0], [100, 0], [100, 100], [0, 100]] as [number, number][],
1850
+ entryZone: [[0, 0], [100, 0], [100, 100], [0, 100]] as [number, number][],
1851
+ transitTime: {
1852
+ typical: conn.transitSeconds * 1000,
1853
+ min: Math.max(1000, conn.transitSeconds * 500),
1854
+ max: conn.transitSeconds * 2000,
1855
+ },
1856
+ name: conn.via ? `Via ${conn.via}` : undefined,
1857
+ };
1858
+
1859
+ topology.connections.push(newConnection);
1860
+ updated = true;
1861
+
1862
+ this.console.log(`[Discovery] Added connection: ${conn.fromCameraId} -> ${conn.toCameraId}`);
1863
+ }
1864
+
1865
+ if (updated) {
1866
+ // Save updated topology
1867
+ this.storage.setItem('topology', JSON.stringify(topology));
1868
+ this.trackingEngine.updateTopology(topology);
1869
+ }
1870
+ }
1871
+
1584
1872
  private serveEditorUI(response: HttpResponse): void {
1585
1873
  response.send(EDITOR_HTML, {
1586
1874
  headers: { 'Content-Type': 'text/html' },
@@ -214,26 +214,53 @@ export function generateAlertMessage(
214
214
 
215
215
  switch (type) {
216
216
  case 'property_entry':
217
- return `${objectDesc} arrived at ${details.cameraName || 'property'}`;
217
+ // Use the rich description from spatial reasoning if available
218
+ if (details.objectLabel && details.objectLabel !== details.objectClass) {
219
+ return details.objectLabel;
220
+ }
221
+ // Fallback to basic description
222
+ if (details.involvedLandmarks && details.involvedLandmarks.length > 0) {
223
+ return `${objectDesc} entered property near ${details.involvedLandmarks[0]}`;
224
+ }
225
+ return `${objectDesc} entered property at ${details.cameraName || 'entrance'}`;
218
226
  case 'property_exit':
219
- return `${objectDesc} left from ${details.cameraName || 'property'}`;
227
+ // Use the rich description from spatial reasoning if available
228
+ if (details.objectLabel && details.objectLabel !== details.objectClass) {
229
+ return details.objectLabel;
230
+ }
231
+ // Fallback to basic description
232
+ if (details.involvedLandmarks && details.involvedLandmarks.length > 0) {
233
+ return `${objectDesc} left property via ${details.involvedLandmarks[0]}`;
234
+ }
235
+ return `${objectDesc} left property`;
220
236
  case 'movement':
221
- // If we have a rich description from LLM/RAG, use it
222
- if (details.objectLabel && details.usedLlm) {
237
+ // If objectLabel contains a full description, use it directly
238
+ if (details.objectLabel && details.objectLabel !== details.objectClass) {
239
+ // Check if this is a cross-camera movement or initial detection
240
+ if (details.fromCameraId && details.fromCameraId !== details.toCameraId && details.transitTime) {
241
+ const transitSecs = Math.round(details.transitTime / 1000);
242
+ const transitStr = transitSecs > 0 ? ` (${transitSecs}s)` : '';
243
+ const pathContext = details.pathDescription ? ` via ${details.pathDescription}` : '';
244
+ return `${details.objectLabel}${pathContext}${transitStr}`;
245
+ }
246
+ // Initial detection - use the label directly
247
+ return details.objectLabel;
248
+ }
249
+ // Cross-camera movement with basic info
250
+ if (details.fromCameraId && details.fromCameraId !== details.toCameraId) {
223
251
  const transitSecs = details.transitTime ? Math.round(details.transitTime / 1000) : 0;
224
- const transitStr = transitSecs > 0 ? ` (${transitSecs}s)` : '';
225
- // Include path/landmark context if available
226
- const pathContext = details.pathDescription ? ` via ${details.pathDescription}` : '';
227
- return `${details.objectLabel}${pathContext}${transitStr}`;
252
+ const transitStr = transitSecs > 0 ? ` (${transitSecs}s transit)` : '';
253
+ let movementDesc = `${objectDesc} moving from ${details.fromCameraName || 'unknown'} towards ${details.toCameraName || 'unknown'}`;
254
+ if (details.involvedLandmarks && details.involvedLandmarks.length > 0) {
255
+ movementDesc += ` near ${details.involvedLandmarks.join(', ')}`;
256
+ }
257
+ return `${movementDesc}${transitStr}`;
228
258
  }
229
- // Fallback to basic message with landmark info
230
- const transitSecs = details.transitTime ? Math.round(details.transitTime / 1000) : 0;
231
- const transitStr = transitSecs > 0 ? ` (${transitSecs}s transit)` : '';
232
- let movementDesc = `${objectDesc} moving from ${details.fromCameraName || 'unknown'} towards ${details.toCameraName || 'unknown'}`;
259
+ // Initial detection without full label
233
260
  if (details.involvedLandmarks && details.involvedLandmarks.length > 0) {
234
- movementDesc += ` near ${details.involvedLandmarks.join(', ')}`;
261
+ return `${objectDesc} detected near ${details.involvedLandmarks[0]}`;
235
262
  }
236
- return `${movementDesc}${transitStr}`;
263
+ return `${objectDesc} detected at ${details.cameraName || 'camera'}`;
237
264
  case 'unusual_path':
238
265
  return `${objectDesc} took unusual path: ${details.actualPath || 'unknown'}`;
239
266
  case 'dwell_time':