@blueharford/scrypted-spatial-awareness 0.2.0 → 0.3.0

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/out/plugin.zip CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blueharford/scrypted-spatial-awareness",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Cross-camera object tracking for Scrypted NVR with spatial awareness",
5
5
  "author": "Joshua Seidel <blueharford>",
6
6
  "license": "Apache-2.0",
@@ -116,7 +116,7 @@ export class SpatialReasoningEngine {
116
116
  }
117
117
 
118
118
  // Landmark contexts
119
- for (const landmark of this.topology.landmarks) {
119
+ for (const landmark of this.topology.landmarks || []) {
120
120
  this.contextChunks.push({
121
121
  id: `landmark_${landmark.id}`,
122
122
  type: 'landmark',
@@ -14,7 +14,7 @@ import sdk, {
14
14
  Camera,
15
15
  MediaObject,
16
16
  } from '@scrypted/sdk';
17
- import { CameraTopology, findCamera, findConnection, findConnectionsFrom, Landmark } from '../models/topology';
17
+ import { CameraTopology, CameraConnection, findCamera, findConnection, findConnectionsFrom, Landmark } from '../models/topology';
18
18
  import {
19
19
  TrackedObject,
20
20
  ObjectSighting,
@@ -48,12 +48,43 @@ export interface TrackingEngineConfig {
48
48
  objectAlertCooldown: number;
49
49
  /** Use LLM for enhanced descriptions */
50
50
  useLlmDescriptions: boolean;
51
+ /** LLM rate limit interval (ms) - minimum time between LLM calls */
52
+ llmDebounceInterval?: number;
53
+ /** Whether to fall back to basic notifications when LLM is unavailable or slow */
54
+ llmFallbackEnabled?: boolean;
55
+ /** Timeout for LLM responses (ms) */
56
+ llmFallbackTimeout?: number;
57
+ /** Enable automatic transit time learning from observations */
58
+ enableTransitTimeLearning?: boolean;
59
+ /** Enable automatic camera connection suggestions */
60
+ enableConnectionSuggestions?: boolean;
51
61
  /** Enable landmark learning from AI */
52
62
  enableLandmarkLearning?: boolean;
53
63
  /** Minimum confidence for landmark suggestions */
54
64
  landmarkConfidenceThreshold?: number;
55
65
  }
56
66
 
67
+ /** Observed transit time for learning */
68
+ interface ObservedTransit {
69
+ fromCameraId: string;
70
+ toCameraId: string;
71
+ transitTime: number;
72
+ timestamp: number;
73
+ }
74
+
75
+ /** Suggested camera connection based on observed patterns */
76
+ export interface ConnectionSuggestion {
77
+ id: string;
78
+ fromCameraId: string;
79
+ fromCameraName: string;
80
+ toCameraId: string;
81
+ toCameraName: string;
82
+ observedTransits: ObservedTransit[];
83
+ suggestedTransitTime: { min: number; typical: number; max: number };
84
+ confidence: number;
85
+ timestamp: number;
86
+ }
87
+
57
88
  export class TrackingEngine {
58
89
  private topology: CameraTopology;
59
90
  private state: TrackingState;
@@ -70,6 +101,20 @@ export class TrackingEngine {
70
101
  /** Callback for topology changes (e.g., landmark suggestions) */
71
102
  private onTopologyChange?: (topology: CameraTopology) => void;
72
103
 
104
+ // ==================== LLM Debouncing ====================
105
+ /** Last time LLM was called */
106
+ private lastLlmCallTime: number = 0;
107
+ /** Queue of pending LLM requests (we only keep latest) */
108
+ private llmDebounceTimer: NodeJS.Timeout | null = null;
109
+
110
+ // ==================== Transit Time Learning ====================
111
+ /** Observed transit times for learning */
112
+ private observedTransits: Map<string, ObservedTransit[]> = new Map();
113
+ /** Connection suggestions based on observed patterns */
114
+ private connectionSuggestions: Map<string, ConnectionSuggestion> = new Map();
115
+ /** Minimum observations before suggesting a connection */
116
+ private readonly MIN_OBSERVATIONS_FOR_SUGGESTION = 3;
117
+
73
118
  constructor(
74
119
  topology: CameraTopology,
75
120
  state: TrackingState,
@@ -232,7 +277,20 @@ export class TrackingEngine {
232
277
  this.objectLastAlertTime.set(globalId, Date.now());
233
278
  }
234
279
 
235
- /** Get spatial reasoning result for movement (uses RAG + LLM) */
280
+ /** Check if LLM call is allowed (rate limiting) */
281
+ private isLlmCallAllowed(): boolean {
282
+ const debounceInterval = this.config.llmDebounceInterval || 0;
283
+ if (debounceInterval <= 0) return true;
284
+ const timeSinceLastCall = Date.now() - this.lastLlmCallTime;
285
+ return timeSinceLastCall >= debounceInterval;
286
+ }
287
+
288
+ /** Record that an LLM call was made */
289
+ private recordLlmCall(): void {
290
+ this.lastLlmCallTime = Date.now();
291
+ }
292
+
293
+ /** Get spatial reasoning result for movement (uses RAG + LLM) with debouncing and fallback */
236
294
  private async getSpatialDescription(
237
295
  tracked: TrackedObject,
238
296
  fromCameraId: string,
@@ -240,7 +298,16 @@ export class TrackingEngine {
240
298
  transitTime: number,
241
299
  currentCameraId: string
242
300
  ): Promise<SpatialReasoningResult | null> {
301
+ const fallbackEnabled = this.config.llmFallbackEnabled ?? true;
302
+ const fallbackTimeout = this.config.llmFallbackTimeout ?? 3000;
303
+
243
304
  try {
305
+ // Check rate limiting - if not allowed, return null to use basic description
306
+ if (!this.isLlmCallAllowed()) {
307
+ this.console.log('LLM rate-limited, using basic notification');
308
+ return null;
309
+ }
310
+
244
311
  // Get snapshot from camera for LLM analysis (if LLM is enabled)
245
312
  let mediaObject: MediaObject | undefined;
246
313
  if (this.config.useLlmDescriptions) {
@@ -250,16 +317,42 @@ export class TrackingEngine {
250
317
  }
251
318
  }
252
319
 
320
+ // Record that we're making an LLM call
321
+ this.recordLlmCall();
322
+
253
323
  // Use spatial reasoning engine for rich context-aware description
254
- const result = await this.spatialReasoning.generateMovementDescription(
255
- tracked,
256
- fromCameraId,
257
- toCameraId,
258
- transitTime,
259
- mediaObject
260
- );
324
+ // Apply timeout if fallback is enabled
325
+ let result: SpatialReasoningResult;
326
+ if (fallbackEnabled && mediaObject) {
327
+ const timeoutPromise = new Promise<SpatialReasoningResult | null>((_, reject) => {
328
+ setTimeout(() => reject(new Error('LLM timeout')), fallbackTimeout);
329
+ });
330
+
331
+ const descriptionPromise = this.spatialReasoning.generateMovementDescription(
332
+ tracked,
333
+ fromCameraId,
334
+ toCameraId,
335
+ transitTime,
336
+ mediaObject
337
+ );
338
+
339
+ try {
340
+ result = await Promise.race([descriptionPromise, timeoutPromise]) as SpatialReasoningResult;
341
+ } catch (timeoutError) {
342
+ this.console.log('LLM timed out, using basic notification');
343
+ return null;
344
+ }
345
+ } else {
346
+ result = await this.spatialReasoning.generateMovementDescription(
347
+ tracked,
348
+ fromCameraId,
349
+ toCameraId,
350
+ transitTime,
351
+ mediaObject
352
+ );
353
+ }
261
354
 
262
- // Optionally trigger landmark learning
355
+ // Optionally trigger landmark learning (background, non-blocking)
263
356
  if (this.config.enableLandmarkLearning && mediaObject) {
264
357
  this.tryLearnLandmark(currentCameraId, mediaObject, tracked.className);
265
358
  }
@@ -328,6 +421,9 @@ export class TrackingEngine {
328
421
  correlationConfidence: correlation.confidence,
329
422
  });
330
423
 
424
+ // Record for transit time learning
425
+ this.recordObservedTransit(lastSighting.cameraId, sighting.cameraId, transitDuration);
426
+
331
427
  this.console.log(
332
428
  `Object ${tracked.globalId.slice(0, 8)} transited: ` +
333
429
  `${lastSighting.cameraName} → ${sighting.cameraName} ` +
@@ -571,4 +667,274 @@ export class TrackingEngine {
571
667
  getTrackedObject(globalId: GlobalTrackingId): TrackedObject | undefined {
572
668
  return this.state.getObject(globalId);
573
669
  }
670
+
671
+ // ==================== Transit Time Learning ====================
672
+
673
+ /** Record an observed transit time for learning */
674
+ private recordObservedTransit(
675
+ fromCameraId: string,
676
+ toCameraId: string,
677
+ transitTime: number
678
+ ): void {
679
+ if (!this.config.enableTransitTimeLearning) return;
680
+
681
+ const key = `${fromCameraId}->${toCameraId}`;
682
+ const observation: ObservedTransit = {
683
+ fromCameraId,
684
+ toCameraId,
685
+ transitTime,
686
+ timestamp: Date.now(),
687
+ };
688
+
689
+ // Add to observations
690
+ if (!this.observedTransits.has(key)) {
691
+ this.observedTransits.set(key, []);
692
+ }
693
+ const observations = this.observedTransits.get(key)!;
694
+ observations.push(observation);
695
+
696
+ // Keep only last 100 observations per connection
697
+ if (observations.length > 100) {
698
+ observations.shift();
699
+ }
700
+
701
+ // Check if we should update existing connection
702
+ const existingConnection = findConnection(this.topology, fromCameraId, toCameraId);
703
+ if (existingConnection) {
704
+ this.maybeUpdateConnectionTransitTime(existingConnection, observations);
705
+ } else if (this.config.enableConnectionSuggestions) {
706
+ // No existing connection - suggest one
707
+ this.maybeCreateConnectionSuggestion(fromCameraId, toCameraId, observations);
708
+ }
709
+ }
710
+
711
+ /** Update an existing connection's transit time based on observations */
712
+ private maybeUpdateConnectionTransitTime(
713
+ connection: CameraConnection,
714
+ observations: ObservedTransit[]
715
+ ): void {
716
+ if (observations.length < 5) return; // Need minimum observations
717
+
718
+ const times = observations.map(o => o.transitTime).sort((a, b) => a - b);
719
+
720
+ // Calculate percentiles
721
+ const newMin = times[Math.floor(times.length * 0.1)];
722
+ const newTypical = times[Math.floor(times.length * 0.5)];
723
+ const newMax = times[Math.floor(times.length * 0.9)];
724
+
725
+ // Only update if significantly different (>20% change)
726
+ const currentTypical = connection.transitTime.typical;
727
+ const percentChange = Math.abs(newTypical - currentTypical) / currentTypical;
728
+
729
+ if (percentChange > 0.2 && observations.length >= 10) {
730
+ this.console.log(
731
+ `Updating transit time for ${connection.name}: ` +
732
+ `${Math.round(currentTypical / 1000)}s → ${Math.round(newTypical / 1000)}s (based on ${observations.length} observations)`
733
+ );
734
+
735
+ connection.transitTime = {
736
+ min: newMin,
737
+ typical: newTypical,
738
+ max: newMax,
739
+ };
740
+
741
+ // Notify about topology change
742
+ if (this.onTopologyChange) {
743
+ this.onTopologyChange(this.topology);
744
+ }
745
+ }
746
+ }
747
+
748
+ /** Create or update a connection suggestion based on observations */
749
+ private maybeCreateConnectionSuggestion(
750
+ fromCameraId: string,
751
+ toCameraId: string,
752
+ observations: ObservedTransit[]
753
+ ): void {
754
+ if (observations.length < this.MIN_OBSERVATIONS_FOR_SUGGESTION) return;
755
+
756
+ const fromCamera = findCamera(this.topology, fromCameraId);
757
+ const toCamera = findCamera(this.topology, toCameraId);
758
+ if (!fromCamera || !toCamera) return;
759
+
760
+ const key = `${fromCameraId}->${toCameraId}`;
761
+ const times = observations.map(o => o.transitTime).sort((a, b) => a - b);
762
+
763
+ // Calculate transit time suggestion
764
+ const suggestedMin = times[Math.floor(times.length * 0.1)] || times[0];
765
+ const suggestedTypical = times[Math.floor(times.length * 0.5)] || times[0];
766
+ const suggestedMax = times[Math.floor(times.length * 0.9)] || times[times.length - 1];
767
+
768
+ // Calculate confidence based on consistency and count
769
+ const avgTime = times.reduce((a, b) => a + b, 0) / times.length;
770
+ const variance = times.reduce((sum, t) => sum + Math.pow(t - avgTime, 2), 0) / times.length;
771
+ const stdDev = Math.sqrt(variance);
772
+ const coefficientOfVariation = stdDev / avgTime;
773
+
774
+ // Higher confidence with more observations and lower variance
775
+ const countFactor = Math.min(observations.length / 10, 1);
776
+ const consistencyFactor = Math.max(0, 1 - coefficientOfVariation);
777
+ const confidence = (countFactor * 0.6 + consistencyFactor * 0.4);
778
+
779
+ const suggestion: ConnectionSuggestion = {
780
+ id: `suggest_${key}`,
781
+ fromCameraId,
782
+ fromCameraName: fromCamera.name,
783
+ toCameraId,
784
+ toCameraName: toCamera.name,
785
+ observedTransits: observations.slice(-10), // Keep last 10
786
+ suggestedTransitTime: {
787
+ min: suggestedMin,
788
+ typical: suggestedTypical,
789
+ max: suggestedMax,
790
+ },
791
+ confidence,
792
+ timestamp: Date.now(),
793
+ };
794
+
795
+ this.connectionSuggestions.set(key, suggestion);
796
+
797
+ if (observations.length === this.MIN_OBSERVATIONS_FOR_SUGGESTION) {
798
+ this.console.log(
799
+ `New connection suggested: ${fromCamera.name} → ${toCamera.name} ` +
800
+ `(typical: ${Math.round(suggestedTypical / 1000)}s, confidence: ${Math.round(confidence * 100)}%)`
801
+ );
802
+ }
803
+ }
804
+
805
+ /** Get pending connection suggestions */
806
+ getConnectionSuggestions(): ConnectionSuggestion[] {
807
+ return Array.from(this.connectionSuggestions.values())
808
+ .filter(s => s.confidence >= 0.5) // Only suggest with reasonable confidence
809
+ .sort((a, b) => b.confidence - a.confidence);
810
+ }
811
+
812
+ /** Accept a connection suggestion, adding it to topology */
813
+ acceptConnectionSuggestion(suggestionId: string): CameraConnection | null {
814
+ const key = suggestionId.replace('suggest_', '');
815
+ const suggestion = this.connectionSuggestions.get(key);
816
+ if (!suggestion) return null;
817
+
818
+ const fromCamera = findCamera(this.topology, suggestion.fromCameraId);
819
+ const toCamera = findCamera(this.topology, suggestion.toCameraId);
820
+ if (!fromCamera || !toCamera) return null;
821
+
822
+ const connection: CameraConnection = {
823
+ id: `conn-${Date.now()}`,
824
+ fromCameraId: suggestion.fromCameraId,
825
+ toCameraId: suggestion.toCameraId,
826
+ name: `${fromCamera.name} to ${toCamera.name}`,
827
+ exitZone: [],
828
+ entryZone: [],
829
+ transitTime: suggestion.suggestedTransitTime,
830
+ bidirectional: true, // Default to bidirectional
831
+ };
832
+
833
+ this.topology.connections.push(connection);
834
+ this.connectionSuggestions.delete(key);
835
+
836
+ // Notify about topology change
837
+ if (this.onTopologyChange) {
838
+ this.onTopologyChange(this.topology);
839
+ }
840
+
841
+ this.console.log(`Connection accepted: ${connection.name}`);
842
+ return connection;
843
+ }
844
+
845
+ /** Reject a connection suggestion */
846
+ rejectConnectionSuggestion(suggestionId: string): boolean {
847
+ const key = suggestionId.replace('suggest_', '');
848
+ if (!this.connectionSuggestions.has(key)) return false;
849
+ this.connectionSuggestions.delete(key);
850
+ // Also clear observations so it doesn't get re-suggested immediately
851
+ this.observedTransits.delete(key);
852
+ return true;
853
+ }
854
+
855
+ // ==================== Live Tracking State ====================
856
+
857
+ /** Get current state of all tracked objects for live overlay */
858
+ getLiveTrackingState(): {
859
+ objects: Array<{
860
+ globalId: string;
861
+ className: string;
862
+ label?: string;
863
+ lastCameraId: string;
864
+ lastCameraName: string;
865
+ lastSeen: number;
866
+ state: string;
867
+ cameraPosition?: { x: number; y: number };
868
+ }>;
869
+ timestamp: number;
870
+ } {
871
+ const activeObjects = this.state.getActiveObjects();
872
+ const objects = activeObjects.map(tracked => {
873
+ const lastSighting = getLastSighting(tracked);
874
+ const camera = lastSighting ? findCamera(this.topology, lastSighting.cameraId) : null;
875
+
876
+ return {
877
+ globalId: tracked.globalId,
878
+ className: tracked.className,
879
+ label: tracked.label,
880
+ lastCameraId: lastSighting?.cameraId || '',
881
+ lastCameraName: lastSighting?.cameraName || '',
882
+ lastSeen: tracked.lastSeen,
883
+ state: tracked.state,
884
+ cameraPosition: camera?.floorPlanPosition,
885
+ };
886
+ });
887
+
888
+ return {
889
+ objects,
890
+ timestamp: Date.now(),
891
+ };
892
+ }
893
+
894
+ /** Get journey path for visualization */
895
+ getJourneyPath(globalId: GlobalTrackingId): {
896
+ segments: Array<{
897
+ fromCamera: { id: string; name: string; position?: { x: number; y: number } };
898
+ toCamera: { id: string; name: string; position?: { x: number; y: number } };
899
+ transitTime: number;
900
+ timestamp: number;
901
+ }>;
902
+ currentLocation?: { cameraId: string; cameraName: string; position?: { x: number; y: number } };
903
+ } | null {
904
+ const tracked = this.state.getObject(globalId);
905
+ if (!tracked) return null;
906
+
907
+ const segments = tracked.journey.map(j => {
908
+ const fromCamera = findCamera(this.topology, j.fromCameraId);
909
+ const toCamera = findCamera(this.topology, j.toCameraId);
910
+
911
+ return {
912
+ fromCamera: {
913
+ id: j.fromCameraId,
914
+ name: j.fromCameraName,
915
+ position: fromCamera?.floorPlanPosition,
916
+ },
917
+ toCamera: {
918
+ id: j.toCameraId,
919
+ name: j.toCameraName,
920
+ position: toCamera?.floorPlanPosition,
921
+ },
922
+ transitTime: j.transitDuration,
923
+ timestamp: j.entryTime,
924
+ };
925
+ });
926
+
927
+ const lastSighting = getLastSighting(tracked);
928
+ let currentLocation;
929
+ if (lastSighting) {
930
+ const camera = findCamera(this.topology, lastSighting.cameraId);
931
+ currentLocation = {
932
+ cameraId: lastSighting.cameraId,
933
+ cameraName: lastSighting.cameraName,
934
+ position: camera?.floorPlanPosition,
935
+ };
936
+ }
937
+
938
+ return { segments, currentLocation };
939
+ }
574
940
  }
package/src/main.ts CHANGED
@@ -115,6 +115,41 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
115
115
  description: 'Use LLM plugin (if installed) to generate descriptive alerts like "Man walking from garage towards front door"',
116
116
  group: 'AI & Spatial Reasoning',
117
117
  },
118
+ llmDebounceInterval: {
119
+ title: 'LLM Rate Limit (seconds)',
120
+ type: 'number',
121
+ defaultValue: 10,
122
+ description: 'Minimum time between LLM calls to prevent API overload (0 = no limit)',
123
+ group: 'AI & Spatial Reasoning',
124
+ },
125
+ llmFallbackEnabled: {
126
+ title: 'Fallback to Basic Notifications',
127
+ type: 'boolean',
128
+ defaultValue: true,
129
+ description: 'When LLM is rate-limited or slow, fall back to basic notifications immediately',
130
+ group: 'AI & Spatial Reasoning',
131
+ },
132
+ llmFallbackTimeout: {
133
+ title: 'LLM Timeout (seconds)',
134
+ type: 'number',
135
+ defaultValue: 3,
136
+ description: 'Maximum time to wait for LLM response before falling back to basic notification',
137
+ group: 'AI & Spatial Reasoning',
138
+ },
139
+ enableTransitTimeLearning: {
140
+ title: 'Learn Transit Times',
141
+ type: 'boolean',
142
+ defaultValue: true,
143
+ description: 'Automatically adjust connection transit times based on observed movement patterns',
144
+ group: 'AI & Spatial Reasoning',
145
+ },
146
+ enableConnectionSuggestions: {
147
+ title: 'Suggest Camera Connections',
148
+ type: 'boolean',
149
+ defaultValue: true,
150
+ description: 'Automatically suggest new camera connections based on observed movement patterns',
151
+ group: 'AI & Spatial Reasoning',
152
+ },
118
153
  enableLandmarkLearning: {
119
154
  title: 'Learn Landmarks from AI',
120
155
  type: 'boolean',
@@ -291,6 +326,11 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
291
326
  loiteringThreshold: (this.storageSettings.values.loiteringThreshold as number || 3) * 1000,
292
327
  objectAlertCooldown: (this.storageSettings.values.objectAlertCooldown as number || 30) * 1000,
293
328
  useLlmDescriptions: this.storageSettings.values.useLlmDescriptions as boolean ?? true,
329
+ llmDebounceInterval: (this.storageSettings.values.llmDebounceInterval as number || 10) * 1000,
330
+ llmFallbackEnabled: this.storageSettings.values.llmFallbackEnabled as boolean ?? true,
331
+ llmFallbackTimeout: (this.storageSettings.values.llmFallbackTimeout as number || 3) * 1000,
332
+ enableTransitTimeLearning: this.storageSettings.values.enableTransitTimeLearning as boolean ?? true,
333
+ enableConnectionSuggestions: this.storageSettings.values.enableConnectionSuggestions as boolean ?? true,
294
334
  enableLandmarkLearning: this.storageSettings.values.enableLandmarkLearning as boolean ?? true,
295
335
  landmarkConfidenceThreshold: this.storageSettings.values.landmarkConfidenceThreshold as number ?? 0.7,
296
336
  };
@@ -576,6 +616,11 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
576
616
  key === 'loiteringThreshold' ||
577
617
  key === 'objectAlertCooldown' ||
578
618
  key === 'useLlmDescriptions' ||
619
+ key === 'llmDebounceInterval' ||
620
+ key === 'llmFallbackEnabled' ||
621
+ key === 'llmFallbackTimeout' ||
622
+ key === 'enableTransitTimeLearning' ||
623
+ key === 'enableConnectionSuggestions' ||
579
624
  key === 'enableLandmarkLearning' ||
580
625
  key === 'landmarkConfidenceThreshold'
581
626
  ) {
@@ -668,6 +713,29 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
668
713
  return this.handleInferRelationshipsRequest(response);
669
714
  }
670
715
 
716
+ // Connection suggestions
717
+ if (path.endsWith('/api/connection-suggestions')) {
718
+ return this.handleConnectionSuggestionsRequest(request, response);
719
+ }
720
+
721
+ if (path.match(/\/api\/connection-suggestions\/[\w->]+\/(accept|reject)$/)) {
722
+ const parts = path.split('/');
723
+ const action = parts.pop()!;
724
+ const suggestionId = parts.pop()!;
725
+ return this.handleConnectionSuggestionActionRequest(suggestionId, action, response);
726
+ }
727
+
728
+ // Live tracking state
729
+ if (path.endsWith('/api/live-tracking')) {
730
+ return this.handleLiveTrackingRequest(response);
731
+ }
732
+
733
+ // Journey visualization
734
+ if (path.match(/\/api\/journey-path\/[\w-]+$/)) {
735
+ const globalId = path.split('/').pop()!;
736
+ return this.handleJourneyPathRequest(globalId, response);
737
+ }
738
+
671
739
  // UI Routes
672
740
  if (path.endsWith('/ui/editor') || path.endsWith('/ui/editor/')) {
673
741
  return this.serveEditorUI(response);
@@ -680,14 +748,18 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
680
748
  // Default: return info page
681
749
  response.send(JSON.stringify({
682
750
  name: 'Spatial Awareness Plugin',
683
- version: '0.1.0',
751
+ version: '0.3.0',
684
752
  endpoints: {
685
753
  api: {
686
754
  trackedObjects: '/api/tracked-objects',
687
755
  journey: '/api/journey/{globalId}',
756
+ journeyPath: '/api/journey-path/{globalId}',
688
757
  topology: '/api/topology',
689
758
  alerts: '/api/alerts',
690
759
  floorPlan: '/api/floor-plan',
760
+ liveTracking: '/api/live-tracking',
761
+ connectionSuggestions: '/api/connection-suggestions',
762
+ landmarkSuggestions: '/api/landmark-suggestions',
691
763
  },
692
764
  ui: {
693
765
  editor: '/ui/editor',
@@ -1054,6 +1126,108 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
1054
1126
  });
1055
1127
  }
1056
1128
 
1129
+ private handleConnectionSuggestionsRequest(request: HttpRequest, response: HttpResponse): void {
1130
+ if (!this.trackingEngine) {
1131
+ response.send(JSON.stringify({ suggestions: [] }), {
1132
+ headers: { 'Content-Type': 'application/json' },
1133
+ });
1134
+ return;
1135
+ }
1136
+
1137
+ const suggestions = this.trackingEngine.getConnectionSuggestions();
1138
+ response.send(JSON.stringify({
1139
+ suggestions,
1140
+ count: suggestions.length,
1141
+ }), {
1142
+ headers: { 'Content-Type': 'application/json' },
1143
+ });
1144
+ }
1145
+
1146
+ private handleConnectionSuggestionActionRequest(
1147
+ suggestionId: string,
1148
+ action: string,
1149
+ response: HttpResponse
1150
+ ): void {
1151
+ if (!this.trackingEngine) {
1152
+ response.send(JSON.stringify({ error: 'Tracking engine not running' }), {
1153
+ code: 500,
1154
+ headers: { 'Content-Type': 'application/json' },
1155
+ });
1156
+ return;
1157
+ }
1158
+
1159
+ if (action === 'accept') {
1160
+ const connection = this.trackingEngine.acceptConnectionSuggestion(suggestionId);
1161
+ if (connection) {
1162
+ // Save updated topology
1163
+ const topology = this.trackingEngine.getTopology();
1164
+ this.storage.setItem('topology', JSON.stringify(topology));
1165
+
1166
+ response.send(JSON.stringify({ success: true, connection }), {
1167
+ headers: { 'Content-Type': 'application/json' },
1168
+ });
1169
+ } else {
1170
+ response.send(JSON.stringify({ error: 'Suggestion not found' }), {
1171
+ code: 404,
1172
+ headers: { 'Content-Type': 'application/json' },
1173
+ });
1174
+ }
1175
+ } else if (action === 'reject') {
1176
+ const success = this.trackingEngine.rejectConnectionSuggestion(suggestionId);
1177
+ if (success) {
1178
+ response.send(JSON.stringify({ success: true }), {
1179
+ headers: { 'Content-Type': 'application/json' },
1180
+ });
1181
+ } else {
1182
+ response.send(JSON.stringify({ error: 'Suggestion not found' }), {
1183
+ code: 404,
1184
+ headers: { 'Content-Type': 'application/json' },
1185
+ });
1186
+ }
1187
+ } else {
1188
+ response.send(JSON.stringify({ error: 'Invalid action' }), {
1189
+ code: 400,
1190
+ headers: { 'Content-Type': 'application/json' },
1191
+ });
1192
+ }
1193
+ }
1194
+
1195
+ private handleLiveTrackingRequest(response: HttpResponse): void {
1196
+ if (!this.trackingEngine) {
1197
+ response.send(JSON.stringify({ objects: [], timestamp: Date.now() }), {
1198
+ headers: { 'Content-Type': 'application/json' },
1199
+ });
1200
+ return;
1201
+ }
1202
+
1203
+ const liveState = this.trackingEngine.getLiveTrackingState();
1204
+ response.send(JSON.stringify(liveState), {
1205
+ headers: { 'Content-Type': 'application/json' },
1206
+ });
1207
+ }
1208
+
1209
+ private handleJourneyPathRequest(globalId: string, response: HttpResponse): void {
1210
+ if (!this.trackingEngine) {
1211
+ response.send(JSON.stringify({ error: 'Tracking engine not running' }), {
1212
+ code: 500,
1213
+ headers: { 'Content-Type': 'application/json' },
1214
+ });
1215
+ return;
1216
+ }
1217
+
1218
+ const journeyPath = this.trackingEngine.getJourneyPath(globalId);
1219
+ if (journeyPath) {
1220
+ response.send(JSON.stringify(journeyPath), {
1221
+ headers: { 'Content-Type': 'application/json' },
1222
+ });
1223
+ } else {
1224
+ response.send(JSON.stringify({ error: 'Object not found' }), {
1225
+ code: 404,
1226
+ headers: { 'Content-Type': 'application/json' },
1227
+ });
1228
+ }
1229
+ }
1230
+
1057
1231
  private serveEditorUI(response: HttpResponse): void {
1058
1232
  response.send(EDITOR_HTML, {
1059
1233
  headers: { 'Content-Type': 'text/html' },