@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.
@@ -35108,7 +35108,7 @@ class SpatialReasoningEngine {
35108
35108
  });
35109
35109
  }
35110
35110
  // Landmark contexts
35111
- for (const landmark of this.topology.landmarks) {
35111
+ for (const landmark of this.topology.landmarks || []) {
35112
35112
  this.contextChunks.push({
35113
35113
  id: `landmark_${landmark.id}`,
35114
35114
  type: 'landmark',
@@ -35645,6 +35645,18 @@ class TrackingEngine {
35645
35645
  objectLastAlertTime = new Map();
35646
35646
  /** Callback for topology changes (e.g., landmark suggestions) */
35647
35647
  onTopologyChange;
35648
+ // ==================== LLM Debouncing ====================
35649
+ /** Last time LLM was called */
35650
+ lastLlmCallTime = 0;
35651
+ /** Queue of pending LLM requests (we only keep latest) */
35652
+ llmDebounceTimer = null;
35653
+ // ==================== Transit Time Learning ====================
35654
+ /** Observed transit times for learning */
35655
+ observedTransits = new Map();
35656
+ /** Connection suggestions based on observed patterns */
35657
+ connectionSuggestions = new Map();
35658
+ /** Minimum observations before suggesting a connection */
35659
+ MIN_OBSERVATIONS_FOR_SUGGESTION = 3;
35648
35660
  constructor(topology, state, alertManager, config, console) {
35649
35661
  this.topology = topology;
35650
35662
  this.state = state;
@@ -35776,9 +35788,28 @@ class TrackingEngine {
35776
35788
  recordAlertTime(globalId) {
35777
35789
  this.objectLastAlertTime.set(globalId, Date.now());
35778
35790
  }
35779
- /** Get spatial reasoning result for movement (uses RAG + LLM) */
35791
+ /** Check if LLM call is allowed (rate limiting) */
35792
+ isLlmCallAllowed() {
35793
+ const debounceInterval = this.config.llmDebounceInterval || 0;
35794
+ if (debounceInterval <= 0)
35795
+ return true;
35796
+ const timeSinceLastCall = Date.now() - this.lastLlmCallTime;
35797
+ return timeSinceLastCall >= debounceInterval;
35798
+ }
35799
+ /** Record that an LLM call was made */
35800
+ recordLlmCall() {
35801
+ this.lastLlmCallTime = Date.now();
35802
+ }
35803
+ /** Get spatial reasoning result for movement (uses RAG + LLM) with debouncing and fallback */
35780
35804
  async getSpatialDescription(tracked, fromCameraId, toCameraId, transitTime, currentCameraId) {
35805
+ const fallbackEnabled = this.config.llmFallbackEnabled ?? true;
35806
+ const fallbackTimeout = this.config.llmFallbackTimeout ?? 3000;
35781
35807
  try {
35808
+ // Check rate limiting - if not allowed, return null to use basic description
35809
+ if (!this.isLlmCallAllowed()) {
35810
+ this.console.log('LLM rate-limited, using basic notification');
35811
+ return null;
35812
+ }
35782
35813
  // Get snapshot from camera for LLM analysis (if LLM is enabled)
35783
35814
  let mediaObject;
35784
35815
  if (this.config.useLlmDescriptions) {
@@ -35787,9 +35818,28 @@ class TrackingEngine {
35787
35818
  mediaObject = await camera.takePicture();
35788
35819
  }
35789
35820
  }
35821
+ // Record that we're making an LLM call
35822
+ this.recordLlmCall();
35790
35823
  // Use spatial reasoning engine for rich context-aware description
35791
- const result = await this.spatialReasoning.generateMovementDescription(tracked, fromCameraId, toCameraId, transitTime, mediaObject);
35792
- // Optionally trigger landmark learning
35824
+ // Apply timeout if fallback is enabled
35825
+ let result;
35826
+ if (fallbackEnabled && mediaObject) {
35827
+ const timeoutPromise = new Promise((_, reject) => {
35828
+ setTimeout(() => reject(new Error('LLM timeout')), fallbackTimeout);
35829
+ });
35830
+ const descriptionPromise = this.spatialReasoning.generateMovementDescription(tracked, fromCameraId, toCameraId, transitTime, mediaObject);
35831
+ try {
35832
+ result = await Promise.race([descriptionPromise, timeoutPromise]);
35833
+ }
35834
+ catch (timeoutError) {
35835
+ this.console.log('LLM timed out, using basic notification');
35836
+ return null;
35837
+ }
35838
+ }
35839
+ else {
35840
+ result = await this.spatialReasoning.generateMovementDescription(tracked, fromCameraId, toCameraId, transitTime, mediaObject);
35841
+ }
35842
+ // Optionally trigger landmark learning (background, non-blocking)
35793
35843
  if (this.config.enableLandmarkLearning && mediaObject) {
35794
35844
  this.tryLearnLandmark(currentCameraId, mediaObject, tracked.className);
35795
35845
  }
@@ -35837,6 +35887,8 @@ class TrackingEngine {
35837
35887
  transitDuration,
35838
35888
  correlationConfidence: correlation.confidence,
35839
35889
  });
35890
+ // Record for transit time learning
35891
+ this.recordObservedTransit(lastSighting.cameraId, sighting.cameraId, transitDuration);
35840
35892
  this.console.log(`Object ${tracked.globalId.slice(0, 8)} transited: ` +
35841
35893
  `${lastSighting.cameraName} → ${sighting.cameraName} ` +
35842
35894
  `(confidence: ${(correlation.confidence * 100).toFixed(0)}%)`);
@@ -36025,6 +36077,211 @@ class TrackingEngine {
36025
36077
  getTrackedObject(globalId) {
36026
36078
  return this.state.getObject(globalId);
36027
36079
  }
36080
+ // ==================== Transit Time Learning ====================
36081
+ /** Record an observed transit time for learning */
36082
+ recordObservedTransit(fromCameraId, toCameraId, transitTime) {
36083
+ if (!this.config.enableTransitTimeLearning)
36084
+ return;
36085
+ const key = `${fromCameraId}->${toCameraId}`;
36086
+ const observation = {
36087
+ fromCameraId,
36088
+ toCameraId,
36089
+ transitTime,
36090
+ timestamp: Date.now(),
36091
+ };
36092
+ // Add to observations
36093
+ if (!this.observedTransits.has(key)) {
36094
+ this.observedTransits.set(key, []);
36095
+ }
36096
+ const observations = this.observedTransits.get(key);
36097
+ observations.push(observation);
36098
+ // Keep only last 100 observations per connection
36099
+ if (observations.length > 100) {
36100
+ observations.shift();
36101
+ }
36102
+ // Check if we should update existing connection
36103
+ const existingConnection = (0, topology_1.findConnection)(this.topology, fromCameraId, toCameraId);
36104
+ if (existingConnection) {
36105
+ this.maybeUpdateConnectionTransitTime(existingConnection, observations);
36106
+ }
36107
+ else if (this.config.enableConnectionSuggestions) {
36108
+ // No existing connection - suggest one
36109
+ this.maybeCreateConnectionSuggestion(fromCameraId, toCameraId, observations);
36110
+ }
36111
+ }
36112
+ /** Update an existing connection's transit time based on observations */
36113
+ maybeUpdateConnectionTransitTime(connection, observations) {
36114
+ if (observations.length < 5)
36115
+ return; // Need minimum observations
36116
+ const times = observations.map(o => o.transitTime).sort((a, b) => a - b);
36117
+ // Calculate percentiles
36118
+ const newMin = times[Math.floor(times.length * 0.1)];
36119
+ const newTypical = times[Math.floor(times.length * 0.5)];
36120
+ const newMax = times[Math.floor(times.length * 0.9)];
36121
+ // Only update if significantly different (>20% change)
36122
+ const currentTypical = connection.transitTime.typical;
36123
+ const percentChange = Math.abs(newTypical - currentTypical) / currentTypical;
36124
+ if (percentChange > 0.2 && observations.length >= 10) {
36125
+ this.console.log(`Updating transit time for ${connection.name}: ` +
36126
+ `${Math.round(currentTypical / 1000)}s → ${Math.round(newTypical / 1000)}s (based on ${observations.length} observations)`);
36127
+ connection.transitTime = {
36128
+ min: newMin,
36129
+ typical: newTypical,
36130
+ max: newMax,
36131
+ };
36132
+ // Notify about topology change
36133
+ if (this.onTopologyChange) {
36134
+ this.onTopologyChange(this.topology);
36135
+ }
36136
+ }
36137
+ }
36138
+ /** Create or update a connection suggestion based on observations */
36139
+ maybeCreateConnectionSuggestion(fromCameraId, toCameraId, observations) {
36140
+ if (observations.length < this.MIN_OBSERVATIONS_FOR_SUGGESTION)
36141
+ return;
36142
+ const fromCamera = (0, topology_1.findCamera)(this.topology, fromCameraId);
36143
+ const toCamera = (0, topology_1.findCamera)(this.topology, toCameraId);
36144
+ if (!fromCamera || !toCamera)
36145
+ return;
36146
+ const key = `${fromCameraId}->${toCameraId}`;
36147
+ const times = observations.map(o => o.transitTime).sort((a, b) => a - b);
36148
+ // Calculate transit time suggestion
36149
+ const suggestedMin = times[Math.floor(times.length * 0.1)] || times[0];
36150
+ const suggestedTypical = times[Math.floor(times.length * 0.5)] || times[0];
36151
+ const suggestedMax = times[Math.floor(times.length * 0.9)] || times[times.length - 1];
36152
+ // Calculate confidence based on consistency and count
36153
+ const avgTime = times.reduce((a, b) => a + b, 0) / times.length;
36154
+ const variance = times.reduce((sum, t) => sum + Math.pow(t - avgTime, 2), 0) / times.length;
36155
+ const stdDev = Math.sqrt(variance);
36156
+ const coefficientOfVariation = stdDev / avgTime;
36157
+ // Higher confidence with more observations and lower variance
36158
+ const countFactor = Math.min(observations.length / 10, 1);
36159
+ const consistencyFactor = Math.max(0, 1 - coefficientOfVariation);
36160
+ const confidence = (countFactor * 0.6 + consistencyFactor * 0.4);
36161
+ const suggestion = {
36162
+ id: `suggest_${key}`,
36163
+ fromCameraId,
36164
+ fromCameraName: fromCamera.name,
36165
+ toCameraId,
36166
+ toCameraName: toCamera.name,
36167
+ observedTransits: observations.slice(-10), // Keep last 10
36168
+ suggestedTransitTime: {
36169
+ min: suggestedMin,
36170
+ typical: suggestedTypical,
36171
+ max: suggestedMax,
36172
+ },
36173
+ confidence,
36174
+ timestamp: Date.now(),
36175
+ };
36176
+ this.connectionSuggestions.set(key, suggestion);
36177
+ if (observations.length === this.MIN_OBSERVATIONS_FOR_SUGGESTION) {
36178
+ this.console.log(`New connection suggested: ${fromCamera.name} → ${toCamera.name} ` +
36179
+ `(typical: ${Math.round(suggestedTypical / 1000)}s, confidence: ${Math.round(confidence * 100)}%)`);
36180
+ }
36181
+ }
36182
+ /** Get pending connection suggestions */
36183
+ getConnectionSuggestions() {
36184
+ return Array.from(this.connectionSuggestions.values())
36185
+ .filter(s => s.confidence >= 0.5) // Only suggest with reasonable confidence
36186
+ .sort((a, b) => b.confidence - a.confidence);
36187
+ }
36188
+ /** Accept a connection suggestion, adding it to topology */
36189
+ acceptConnectionSuggestion(suggestionId) {
36190
+ const key = suggestionId.replace('suggest_', '');
36191
+ const suggestion = this.connectionSuggestions.get(key);
36192
+ if (!suggestion)
36193
+ return null;
36194
+ const fromCamera = (0, topology_1.findCamera)(this.topology, suggestion.fromCameraId);
36195
+ const toCamera = (0, topology_1.findCamera)(this.topology, suggestion.toCameraId);
36196
+ if (!fromCamera || !toCamera)
36197
+ return null;
36198
+ const connection = {
36199
+ id: `conn-${Date.now()}`,
36200
+ fromCameraId: suggestion.fromCameraId,
36201
+ toCameraId: suggestion.toCameraId,
36202
+ name: `${fromCamera.name} to ${toCamera.name}`,
36203
+ exitZone: [],
36204
+ entryZone: [],
36205
+ transitTime: suggestion.suggestedTransitTime,
36206
+ bidirectional: true, // Default to bidirectional
36207
+ };
36208
+ this.topology.connections.push(connection);
36209
+ this.connectionSuggestions.delete(key);
36210
+ // Notify about topology change
36211
+ if (this.onTopologyChange) {
36212
+ this.onTopologyChange(this.topology);
36213
+ }
36214
+ this.console.log(`Connection accepted: ${connection.name}`);
36215
+ return connection;
36216
+ }
36217
+ /** Reject a connection suggestion */
36218
+ rejectConnectionSuggestion(suggestionId) {
36219
+ const key = suggestionId.replace('suggest_', '');
36220
+ if (!this.connectionSuggestions.has(key))
36221
+ return false;
36222
+ this.connectionSuggestions.delete(key);
36223
+ // Also clear observations so it doesn't get re-suggested immediately
36224
+ this.observedTransits.delete(key);
36225
+ return true;
36226
+ }
36227
+ // ==================== Live Tracking State ====================
36228
+ /** Get current state of all tracked objects for live overlay */
36229
+ getLiveTrackingState() {
36230
+ const activeObjects = this.state.getActiveObjects();
36231
+ const objects = activeObjects.map(tracked => {
36232
+ const lastSighting = (0, tracked_object_1.getLastSighting)(tracked);
36233
+ const camera = lastSighting ? (0, topology_1.findCamera)(this.topology, lastSighting.cameraId) : null;
36234
+ return {
36235
+ globalId: tracked.globalId,
36236
+ className: tracked.className,
36237
+ label: tracked.label,
36238
+ lastCameraId: lastSighting?.cameraId || '',
36239
+ lastCameraName: lastSighting?.cameraName || '',
36240
+ lastSeen: tracked.lastSeen,
36241
+ state: tracked.state,
36242
+ cameraPosition: camera?.floorPlanPosition,
36243
+ };
36244
+ });
36245
+ return {
36246
+ objects,
36247
+ timestamp: Date.now(),
36248
+ };
36249
+ }
36250
+ /** Get journey path for visualization */
36251
+ getJourneyPath(globalId) {
36252
+ const tracked = this.state.getObject(globalId);
36253
+ if (!tracked)
36254
+ return null;
36255
+ const segments = tracked.journey.map(j => {
36256
+ const fromCamera = (0, topology_1.findCamera)(this.topology, j.fromCameraId);
36257
+ const toCamera = (0, topology_1.findCamera)(this.topology, j.toCameraId);
36258
+ return {
36259
+ fromCamera: {
36260
+ id: j.fromCameraId,
36261
+ name: j.fromCameraName,
36262
+ position: fromCamera?.floorPlanPosition,
36263
+ },
36264
+ toCamera: {
36265
+ id: j.toCameraId,
36266
+ name: j.toCameraName,
36267
+ position: toCamera?.floorPlanPosition,
36268
+ },
36269
+ transitTime: j.transitDuration,
36270
+ timestamp: j.entryTime,
36271
+ };
36272
+ });
36273
+ const lastSighting = (0, tracked_object_1.getLastSighting)(tracked);
36274
+ let currentLocation;
36275
+ if (lastSighting) {
36276
+ const camera = (0, topology_1.findCamera)(this.topology, lastSighting.cameraId);
36277
+ currentLocation = {
36278
+ cameraId: lastSighting.cameraId,
36279
+ cameraName: lastSighting.cameraName,
36280
+ position: camera?.floorPlanPosition,
36281
+ };
36282
+ }
36283
+ return { segments, currentLocation };
36284
+ }
36028
36285
  }
36029
36286
  exports.TrackingEngine = TrackingEngine;
36030
36287
 
@@ -36806,6 +37063,41 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
36806
37063
  description: 'Use LLM plugin (if installed) to generate descriptive alerts like "Man walking from garage towards front door"',
36807
37064
  group: 'AI & Spatial Reasoning',
36808
37065
  },
37066
+ llmDebounceInterval: {
37067
+ title: 'LLM Rate Limit (seconds)',
37068
+ type: 'number',
37069
+ defaultValue: 10,
37070
+ description: 'Minimum time between LLM calls to prevent API overload (0 = no limit)',
37071
+ group: 'AI & Spatial Reasoning',
37072
+ },
37073
+ llmFallbackEnabled: {
37074
+ title: 'Fallback to Basic Notifications',
37075
+ type: 'boolean',
37076
+ defaultValue: true,
37077
+ description: 'When LLM is rate-limited or slow, fall back to basic notifications immediately',
37078
+ group: 'AI & Spatial Reasoning',
37079
+ },
37080
+ llmFallbackTimeout: {
37081
+ title: 'LLM Timeout (seconds)',
37082
+ type: 'number',
37083
+ defaultValue: 3,
37084
+ description: 'Maximum time to wait for LLM response before falling back to basic notification',
37085
+ group: 'AI & Spatial Reasoning',
37086
+ },
37087
+ enableTransitTimeLearning: {
37088
+ title: 'Learn Transit Times',
37089
+ type: 'boolean',
37090
+ defaultValue: true,
37091
+ description: 'Automatically adjust connection transit times based on observed movement patterns',
37092
+ group: 'AI & Spatial Reasoning',
37093
+ },
37094
+ enableConnectionSuggestions: {
37095
+ title: 'Suggest Camera Connections',
37096
+ type: 'boolean',
37097
+ defaultValue: true,
37098
+ description: 'Automatically suggest new camera connections based on observed movement patterns',
37099
+ group: 'AI & Spatial Reasoning',
37100
+ },
36809
37101
  enableLandmarkLearning: {
36810
37102
  title: 'Learn Landmarks from AI',
36811
37103
  type: 'boolean',
@@ -36965,6 +37257,11 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
36965
37257
  loiteringThreshold: (this.storageSettings.values.loiteringThreshold || 3) * 1000,
36966
37258
  objectAlertCooldown: (this.storageSettings.values.objectAlertCooldown || 30) * 1000,
36967
37259
  useLlmDescriptions: this.storageSettings.values.useLlmDescriptions ?? true,
37260
+ llmDebounceInterval: (this.storageSettings.values.llmDebounceInterval || 10) * 1000,
37261
+ llmFallbackEnabled: this.storageSettings.values.llmFallbackEnabled ?? true,
37262
+ llmFallbackTimeout: (this.storageSettings.values.llmFallbackTimeout || 3) * 1000,
37263
+ enableTransitTimeLearning: this.storageSettings.values.enableTransitTimeLearning ?? true,
37264
+ enableConnectionSuggestions: this.storageSettings.values.enableConnectionSuggestions ?? true,
36968
37265
  enableLandmarkLearning: this.storageSettings.values.enableLandmarkLearning ?? true,
36969
37266
  landmarkConfidenceThreshold: this.storageSettings.values.landmarkConfidenceThreshold ?? 0.7,
36970
37267
  };
@@ -37214,6 +37511,11 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
37214
37511
  key === 'loiteringThreshold' ||
37215
37512
  key === 'objectAlertCooldown' ||
37216
37513
  key === 'useLlmDescriptions' ||
37514
+ key === 'llmDebounceInterval' ||
37515
+ key === 'llmFallbackEnabled' ||
37516
+ key === 'llmFallbackTimeout' ||
37517
+ key === 'enableTransitTimeLearning' ||
37518
+ key === 'enableConnectionSuggestions' ||
37217
37519
  key === 'enableLandmarkLearning' ||
37218
37520
  key === 'landmarkConfidenceThreshold') {
37219
37521
  const topologyJson = this.storage.getItem('topology');
@@ -37289,6 +37591,25 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
37289
37591
  if (path.endsWith('/api/infer-relationships')) {
37290
37592
  return this.handleInferRelationshipsRequest(response);
37291
37593
  }
37594
+ // Connection suggestions
37595
+ if (path.endsWith('/api/connection-suggestions')) {
37596
+ return this.handleConnectionSuggestionsRequest(request, response);
37597
+ }
37598
+ if (path.match(/\/api\/connection-suggestions\/[\w->]+\/(accept|reject)$/)) {
37599
+ const parts = path.split('/');
37600
+ const action = parts.pop();
37601
+ const suggestionId = parts.pop();
37602
+ return this.handleConnectionSuggestionActionRequest(suggestionId, action, response);
37603
+ }
37604
+ // Live tracking state
37605
+ if (path.endsWith('/api/live-tracking')) {
37606
+ return this.handleLiveTrackingRequest(response);
37607
+ }
37608
+ // Journey visualization
37609
+ if (path.match(/\/api\/journey-path\/[\w-]+$/)) {
37610
+ const globalId = path.split('/').pop();
37611
+ return this.handleJourneyPathRequest(globalId, response);
37612
+ }
37292
37613
  // UI Routes
37293
37614
  if (path.endsWith('/ui/editor') || path.endsWith('/ui/editor/')) {
37294
37615
  return this.serveEditorUI(response);
@@ -37299,14 +37620,18 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
37299
37620
  // Default: return info page
37300
37621
  response.send(JSON.stringify({
37301
37622
  name: 'Spatial Awareness Plugin',
37302
- version: '0.1.0',
37623
+ version: '0.3.0',
37303
37624
  endpoints: {
37304
37625
  api: {
37305
37626
  trackedObjects: '/api/tracked-objects',
37306
37627
  journey: '/api/journey/{globalId}',
37628
+ journeyPath: '/api/journey-path/{globalId}',
37307
37629
  topology: '/api/topology',
37308
37630
  alerts: '/api/alerts',
37309
37631
  floorPlan: '/api/floor-plan',
37632
+ liveTracking: '/api/live-tracking',
37633
+ connectionSuggestions: '/api/connection-suggestions',
37634
+ landmarkSuggestions: '/api/landmark-suggestions',
37310
37635
  },
37311
37636
  ui: {
37312
37637
  editor: '/ui/editor',
@@ -37666,6 +37991,100 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
37666
37991
  headers: { 'Content-Type': 'application/json' },
37667
37992
  });
37668
37993
  }
37994
+ handleConnectionSuggestionsRequest(request, response) {
37995
+ if (!this.trackingEngine) {
37996
+ response.send(JSON.stringify({ suggestions: [] }), {
37997
+ headers: { 'Content-Type': 'application/json' },
37998
+ });
37999
+ return;
38000
+ }
38001
+ const suggestions = this.trackingEngine.getConnectionSuggestions();
38002
+ response.send(JSON.stringify({
38003
+ suggestions,
38004
+ count: suggestions.length,
38005
+ }), {
38006
+ headers: { 'Content-Type': 'application/json' },
38007
+ });
38008
+ }
38009
+ handleConnectionSuggestionActionRequest(suggestionId, action, response) {
38010
+ if (!this.trackingEngine) {
38011
+ response.send(JSON.stringify({ error: 'Tracking engine not running' }), {
38012
+ code: 500,
38013
+ headers: { 'Content-Type': 'application/json' },
38014
+ });
38015
+ return;
38016
+ }
38017
+ if (action === 'accept') {
38018
+ const connection = this.trackingEngine.acceptConnectionSuggestion(suggestionId);
38019
+ if (connection) {
38020
+ // Save updated topology
38021
+ const topology = this.trackingEngine.getTopology();
38022
+ this.storage.setItem('topology', JSON.stringify(topology));
38023
+ response.send(JSON.stringify({ success: true, connection }), {
38024
+ headers: { 'Content-Type': 'application/json' },
38025
+ });
38026
+ }
38027
+ else {
38028
+ response.send(JSON.stringify({ error: 'Suggestion not found' }), {
38029
+ code: 404,
38030
+ headers: { 'Content-Type': 'application/json' },
38031
+ });
38032
+ }
38033
+ }
38034
+ else if (action === 'reject') {
38035
+ const success = this.trackingEngine.rejectConnectionSuggestion(suggestionId);
38036
+ if (success) {
38037
+ response.send(JSON.stringify({ success: true }), {
38038
+ headers: { 'Content-Type': 'application/json' },
38039
+ });
38040
+ }
38041
+ else {
38042
+ response.send(JSON.stringify({ error: 'Suggestion not found' }), {
38043
+ code: 404,
38044
+ headers: { 'Content-Type': 'application/json' },
38045
+ });
38046
+ }
38047
+ }
38048
+ else {
38049
+ response.send(JSON.stringify({ error: 'Invalid action' }), {
38050
+ code: 400,
38051
+ headers: { 'Content-Type': 'application/json' },
38052
+ });
38053
+ }
38054
+ }
38055
+ handleLiveTrackingRequest(response) {
38056
+ if (!this.trackingEngine) {
38057
+ response.send(JSON.stringify({ objects: [], timestamp: Date.now() }), {
38058
+ headers: { 'Content-Type': 'application/json' },
38059
+ });
38060
+ return;
38061
+ }
38062
+ const liveState = this.trackingEngine.getLiveTrackingState();
38063
+ response.send(JSON.stringify(liveState), {
38064
+ headers: { 'Content-Type': 'application/json' },
38065
+ });
38066
+ }
38067
+ handleJourneyPathRequest(globalId, response) {
38068
+ if (!this.trackingEngine) {
38069
+ response.send(JSON.stringify({ error: 'Tracking engine not running' }), {
38070
+ code: 500,
38071
+ headers: { 'Content-Type': 'application/json' },
38072
+ });
38073
+ return;
38074
+ }
38075
+ const journeyPath = this.trackingEngine.getJourneyPath(globalId);
38076
+ if (journeyPath) {
38077
+ response.send(JSON.stringify(journeyPath), {
38078
+ headers: { 'Content-Type': 'application/json' },
38079
+ });
38080
+ }
38081
+ else {
38082
+ response.send(JSON.stringify({ error: 'Object not found' }), {
38083
+ code: 404,
38084
+ headers: { 'Content-Type': 'application/json' },
38085
+ });
38086
+ }
38087
+ }
37669
38088
  serveEditorUI(response) {
37670
38089
  response.send(editor_html_1.EDITOR_HTML, {
37671
38090
  headers: { 'Content-Type': 'text/html' },
@@ -37954,7 +38373,7 @@ function findCamera(topology, deviceId) {
37954
38373
  }
37955
38374
  /** Finds a landmark by ID */
37956
38375
  function findLandmark(topology, landmarkId) {
37957
- return topology.landmarks.find(l => l.id === landmarkId);
38376
+ return topology.landmarks?.find(l => l.id === landmarkId);
37958
38377
  }
37959
38378
  /** Finds connections from a camera */
37960
38379
  function findConnectionsFrom(topology, cameraId) {
@@ -38012,7 +38431,7 @@ function inferRelationships(topology, proximityThreshold = 50) {
38012
38431
  entities.push({ id: camera.deviceId, position: camera.floorPlanPosition, type: 'camera' });
38013
38432
  }
38014
38433
  }
38015
- for (const landmark of topology.landmarks) {
38434
+ for (const landmark of topology.landmarks || []) {
38016
38435
  entities.push({ id: landmark.id, position: landmark.position, type: 'landmark' });
38017
38436
  }
38018
38437
  // Find adjacent entities based on proximity
@@ -38043,9 +38462,9 @@ function generateTopologyDescription(topology) {
38043
38462
  lines.push(`Front of property faces ${topology.property.frontFacing}.`);
38044
38463
  }
38045
38464
  // Landmarks
38046
- if (topology.landmarks.length > 0) {
38465
+ if (topology.landmarks?.length > 0) {
38047
38466
  lines.push('\nLandmarks on property:');
38048
- for (const landmark of topology.landmarks) {
38467
+ for (const landmark of topology.landmarks || []) {
38049
38468
  let desc = `- ${landmark.name} (${landmark.type})`;
38050
38469
  if (landmark.description)
38051
38470
  desc += `: ${landmark.description}`;
@@ -38615,6 +39034,23 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
38615
39034
  </div>
38616
39035
  <div id="suggestions-list"></div>
38617
39036
  </div>
39037
+ <div class="section" id="connection-suggestions-section" style="display: none;">
39038
+ <div class="section-title">
39039
+ <span>Connection Suggestions</span>
39040
+ <button class="btn btn-small" onclick="loadConnectionSuggestions()">Refresh</button>
39041
+ </div>
39042
+ <div id="connection-suggestions-list"></div>
39043
+ </div>
39044
+ <div class="section" id="live-tracking-section">
39045
+ <div class="section-title">
39046
+ <span>Live Tracking</span>
39047
+ <label class="checkbox-group" style="font-size: 11px; font-weight: normal; text-transform: none;">
39048
+ <input type="checkbox" id="live-tracking-toggle" onchange="toggleLiveTracking(this.checked)">
39049
+ Enable
39050
+ </label>
39051
+ </div>
39052
+ <div id="live-tracking-list" style="max-height: 150px; overflow-y: auto;"></div>
39053
+ </div>
38618
39054
  </div>
38619
39055
  </div>
38620
39056
  <div class="editor">
@@ -38805,6 +39241,12 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
38805
39241
  let availableCameras = [];
38806
39242
  let landmarkTemplates = [];
38807
39243
  let pendingSuggestions = [];
39244
+ let connectionSuggestions = [];
39245
+ let liveTrackingData = { objects: [], timestamp: 0 };
39246
+ let liveTrackingEnabled = false;
39247
+ let liveTrackingInterval = null;
39248
+ let selectedJourneyId = null;
39249
+ let journeyPath = null;
38808
39250
  let isDrawing = false;
38809
39251
  let drawStart = null;
38810
39252
  let currentDrawing = null;
@@ -38817,6 +39259,7 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
38817
39259
  await loadAvailableCameras();
38818
39260
  await loadLandmarkTemplates();
38819
39261
  await loadSuggestions();
39262
+ await loadConnectionSuggestions();
38820
39263
  resizeCanvas();
38821
39264
  render();
38822
39265
  updateUI();
@@ -38916,6 +39359,132 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
38916
39359
  } catch (e) { console.error('Failed to reject suggestion:', e); }
38917
39360
  }
38918
39361
 
39362
+ // ==================== Connection Suggestions ====================
39363
+ async function loadConnectionSuggestions() {
39364
+ try {
39365
+ const response = await fetch('../api/connection-suggestions');
39366
+ if (response.ok) {
39367
+ const data = await response.json();
39368
+ connectionSuggestions = data.suggestions || [];
39369
+ updateConnectionSuggestionsUI();
39370
+ }
39371
+ } catch (e) { console.error('Failed to load connection suggestions:', e); }
39372
+ }
39373
+
39374
+ function updateConnectionSuggestionsUI() {
39375
+ const section = document.getElementById('connection-suggestions-section');
39376
+ const list = document.getElementById('connection-suggestions-list');
39377
+ if (connectionSuggestions.length === 0) {
39378
+ section.style.display = 'none';
39379
+ return;
39380
+ }
39381
+ section.style.display = 'block';
39382
+ list.innerHTML = connectionSuggestions.map(s =>
39383
+ '<div class="camera-item" style="display: flex; justify-content: space-between; align-items: center;">' +
39384
+ '<div><div class="camera-name">' + s.fromCameraName + ' → ' + s.toCameraName + '</div>' +
39385
+ '<div class="camera-info">' + Math.round(s.suggestedTransitTime.typical / 1000) + 's typical, ' +
39386
+ Math.round(s.confidence * 100) + '% confidence</div></div>' +
39387
+ '<div style="display: flex; gap: 5px;">' +
39388
+ '<button class="btn btn-small btn-primary" onclick="acceptConnectionSuggestion(\\'' + s.id + '\\')">Accept</button>' +
39389
+ '<button class="btn btn-small" onclick="rejectConnectionSuggestion(\\'' + s.id + '\\')">Reject</button>' +
39390
+ '</div></div>'
39391
+ ).join('');
39392
+ }
39393
+
39394
+ async function acceptConnectionSuggestion(id) {
39395
+ try {
39396
+ const response = await fetch('../api/connection-suggestions/' + encodeURIComponent(id) + '/accept', { method: 'POST' });
39397
+ if (response.ok) {
39398
+ const data = await response.json();
39399
+ if (data.connection) {
39400
+ topology.connections.push(data.connection);
39401
+ updateUI();
39402
+ render();
39403
+ }
39404
+ await loadConnectionSuggestions();
39405
+ setStatus('Connection accepted', 'success');
39406
+ }
39407
+ } catch (e) { console.error('Failed to accept connection suggestion:', e); }
39408
+ }
39409
+
39410
+ async function rejectConnectionSuggestion(id) {
39411
+ try {
39412
+ await fetch('../api/connection-suggestions/' + encodeURIComponent(id) + '/reject', { method: 'POST' });
39413
+ await loadConnectionSuggestions();
39414
+ setStatus('Connection suggestion rejected', 'success');
39415
+ } catch (e) { console.error('Failed to reject connection suggestion:', e); }
39416
+ }
39417
+
39418
+ // ==================== Live Tracking ====================
39419
+ function toggleLiveTracking(enabled) {
39420
+ liveTrackingEnabled = enabled;
39421
+ if (enabled) {
39422
+ loadLiveTracking();
39423
+ liveTrackingInterval = setInterval(loadLiveTracking, 2000); // Poll every 2 seconds
39424
+ } else {
39425
+ if (liveTrackingInterval) {
39426
+ clearInterval(liveTrackingInterval);
39427
+ liveTrackingInterval = null;
39428
+ }
39429
+ liveTrackingData = { objects: [], timestamp: 0 };
39430
+ selectedJourneyId = null;
39431
+ journeyPath = null;
39432
+ updateLiveTrackingUI();
39433
+ render();
39434
+ }
39435
+ }
39436
+
39437
+ async function loadLiveTracking() {
39438
+ try {
39439
+ const response = await fetch('../api/live-tracking');
39440
+ if (response.ok) {
39441
+ liveTrackingData = await response.json();
39442
+ updateLiveTrackingUI();
39443
+ render();
39444
+ }
39445
+ } catch (e) { console.error('Failed to load live tracking:', e); }
39446
+ }
39447
+
39448
+ function updateLiveTrackingUI() {
39449
+ const list = document.getElementById('live-tracking-list');
39450
+ if (liveTrackingData.objects.length === 0) {
39451
+ list.innerHTML = '<div style="color: #666; font-size: 12px; text-align: center; padding: 10px;">No active objects</div>';
39452
+ return;
39453
+ }
39454
+ list.innerHTML = liveTrackingData.objects.map(obj => {
39455
+ const isSelected = selectedJourneyId === obj.globalId;
39456
+ const ageSeconds = Math.round((Date.now() - obj.lastSeen) / 1000);
39457
+ const ageStr = ageSeconds < 60 ? ageSeconds + 's ago' : Math.round(ageSeconds / 60) + 'm ago';
39458
+ return '<div class="camera-item' + (isSelected ? ' selected' : '') + '" ' +
39459
+ 'onclick="selectTrackedObject(\\'' + obj.globalId + '\\')" ' +
39460
+ 'style="padding: 8px; cursor: pointer;">' +
39461
+ '<div class="camera-name" style="font-size: 12px;">' +
39462
+ (obj.className.charAt(0).toUpperCase() + obj.className.slice(1)) +
39463
+ (obj.label ? ' (' + obj.label + ')' : '') + '</div>' +
39464
+ '<div class="camera-info">' + obj.lastCameraName + ' • ' + ageStr + '</div>' +
39465
+ '</div>';
39466
+ }).join('');
39467
+ }
39468
+
39469
+ async function selectTrackedObject(globalId) {
39470
+ if (selectedJourneyId === globalId) {
39471
+ // Deselect
39472
+ selectedJourneyId = null;
39473
+ journeyPath = null;
39474
+ } else {
39475
+ selectedJourneyId = globalId;
39476
+ // Load journey path
39477
+ try {
39478
+ const response = await fetch('../api/journey-path/' + globalId);
39479
+ if (response.ok) {
39480
+ journeyPath = await response.json();
39481
+ }
39482
+ } catch (e) { console.error('Failed to load journey path:', e); }
39483
+ }
39484
+ updateLiveTrackingUI();
39485
+ render();
39486
+ }
39487
+
38919
39488
  function openAddLandmarkModal() {
38920
39489
  updateLandmarkSuggestions();
38921
39490
  document.getElementById('add-landmark-modal').classList.add('active');
@@ -39139,6 +39708,112 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
39139
39708
  for (const camera of topology.cameras) {
39140
39709
  if (camera.floorPlanPosition) { drawCamera(camera); }
39141
39710
  }
39711
+
39712
+ // Draw journey path if selected
39713
+ if (journeyPath && journeyPath.segments.length > 0) {
39714
+ drawJourneyPath();
39715
+ }
39716
+
39717
+ // Draw live tracking objects
39718
+ if (liveTrackingEnabled && liveTrackingData.objects.length > 0) {
39719
+ drawLiveTrackingObjects();
39720
+ }
39721
+ }
39722
+
39723
+ function drawJourneyPath() {
39724
+ if (!journeyPath) return;
39725
+
39726
+ ctx.strokeStyle = '#ff6b6b';
39727
+ ctx.lineWidth = 3;
39728
+ ctx.setLineDash([8, 4]);
39729
+
39730
+ // Draw path segments
39731
+ for (const segment of journeyPath.segments) {
39732
+ if (segment.fromCamera.position && segment.toCamera.position) {
39733
+ ctx.beginPath();
39734
+ ctx.moveTo(segment.fromCamera.position.x, segment.fromCamera.position.y);
39735
+ ctx.lineTo(segment.toCamera.position.x, segment.toCamera.position.y);
39736
+ ctx.stroke();
39737
+
39738
+ // Draw timestamp indicator
39739
+ const midX = (segment.fromCamera.position.x + segment.toCamera.position.x) / 2;
39740
+ const midY = (segment.fromCamera.position.y + segment.toCamera.position.y) / 2;
39741
+ ctx.fillStyle = 'rgba(255, 107, 107, 0.9)';
39742
+ ctx.beginPath();
39743
+ ctx.arc(midX, midY, 4, 0, Math.PI * 2);
39744
+ ctx.fill();
39745
+ }
39746
+ }
39747
+
39748
+ ctx.setLineDash([]);
39749
+
39750
+ // Draw current location indicator
39751
+ if (journeyPath.currentLocation?.position) {
39752
+ const pos = journeyPath.currentLocation.position;
39753
+ // Pulsing dot effect
39754
+ const pulse = (Date.now() % 1000) / 1000;
39755
+ const radius = 10 + pulse * 5;
39756
+ const alpha = 1 - pulse * 0.5;
39757
+
39758
+ ctx.beginPath();
39759
+ ctx.arc(pos.x, pos.y, radius, 0, Math.PI * 2);
39760
+ ctx.fillStyle = 'rgba(255, 107, 107, ' + alpha + ')';
39761
+ ctx.fill();
39762
+
39763
+ ctx.beginPath();
39764
+ ctx.arc(pos.x, pos.y, 6, 0, Math.PI * 2);
39765
+ ctx.fillStyle = '#ff6b6b';
39766
+ ctx.fill();
39767
+ ctx.strokeStyle = '#fff';
39768
+ ctx.lineWidth = 2;
39769
+ ctx.stroke();
39770
+ }
39771
+ }
39772
+
39773
+ function drawLiveTrackingObjects() {
39774
+ const objectColors = {
39775
+ person: '#4caf50',
39776
+ car: '#2196f3',
39777
+ animal: '#ff9800',
39778
+ default: '#9c27b0'
39779
+ };
39780
+
39781
+ for (const obj of liveTrackingData.objects) {
39782
+ if (!obj.cameraPosition) continue;
39783
+
39784
+ // Skip if this is the selected journey object (drawn separately with path)
39785
+ if (obj.globalId === selectedJourneyId) continue;
39786
+
39787
+ const pos = obj.cameraPosition;
39788
+ const color = objectColors[obj.className] || objectColors.default;
39789
+ const ageSeconds = (Date.now() - obj.lastSeen) / 1000;
39790
+
39791
+ // Fade old objects
39792
+ const alpha = Math.max(0.3, 1 - ageSeconds / 60);
39793
+
39794
+ // Draw object indicator
39795
+ ctx.beginPath();
39796
+ ctx.arc(pos.x, pos.y, 12, 0, Math.PI * 2);
39797
+ ctx.fillStyle = color.replace(')', ', ' + alpha + ')').replace('rgb', 'rgba');
39798
+ ctx.fill();
39799
+ ctx.strokeStyle = 'rgba(255, 255, 255, ' + alpha + ')';
39800
+ ctx.lineWidth = 2;
39801
+ ctx.stroke();
39802
+
39803
+ // Draw class icon
39804
+ ctx.fillStyle = 'rgba(255, 255, 255, ' + alpha + ')';
39805
+ ctx.font = 'bold 10px sans-serif';
39806
+ ctx.textAlign = 'center';
39807
+ ctx.textBaseline = 'middle';
39808
+ const icon = obj.className === 'person' ? 'P' : obj.className === 'car' ? 'C' : obj.className === 'animal' ? 'A' : '?';
39809
+ ctx.fillText(icon, pos.x, pos.y);
39810
+
39811
+ // Draw label below
39812
+ if (obj.label) {
39813
+ ctx.font = '9px sans-serif';
39814
+ ctx.fillText(obj.label.slice(0, 10), pos.x, pos.y + 20);
39815
+ }
39816
+ }
39142
39817
  }
39143
39818
 
39144
39819
  function drawLandmark(landmark) {