@blueharford/scrypted-spatial-awareness 0.2.1 → 0.4.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.
@@ -35627,6 +35627,7 @@ exports.TrackingEngine = void 0;
35627
35627
  const sdk_1 = __importStar(__webpack_require__(/*! @scrypted/sdk */ "./node_modules/@scrypted/sdk/dist/src/index.js"));
35628
35628
  const topology_1 = __webpack_require__(/*! ../models/topology */ "./src/models/topology.ts");
35629
35629
  const tracked_object_1 = __webpack_require__(/*! ../models/tracked-object */ "./src/models/tracked-object.ts");
35630
+ const training_1 = __webpack_require__(/*! ../models/training */ "./src/models/training.ts");
35630
35631
  const object_correlator_1 = __webpack_require__(/*! ./object-correlator */ "./src/core/object-correlator.ts");
35631
35632
  const spatial_reasoning_1 = __webpack_require__(/*! ./spatial-reasoning */ "./src/core/spatial-reasoning.ts");
35632
35633
  const { systemManager } = sdk_1.default;
@@ -35645,6 +35646,25 @@ class TrackingEngine {
35645
35646
  objectLastAlertTime = new Map();
35646
35647
  /** Callback for topology changes (e.g., landmark suggestions) */
35647
35648
  onTopologyChange;
35649
+ // ==================== LLM Debouncing ====================
35650
+ /** Last time LLM was called */
35651
+ lastLlmCallTime = 0;
35652
+ /** Queue of pending LLM requests (we only keep latest) */
35653
+ llmDebounceTimer = null;
35654
+ // ==================== Transit Time Learning ====================
35655
+ /** Observed transit times for learning */
35656
+ observedTransits = new Map();
35657
+ /** Connection suggestions based on observed patterns */
35658
+ connectionSuggestions = new Map();
35659
+ /** Minimum observations before suggesting a connection */
35660
+ MIN_OBSERVATIONS_FOR_SUGGESTION = 3;
35661
+ // ==================== Training Mode ====================
35662
+ /** Current training session (null if not training) */
35663
+ trainingSession = null;
35664
+ /** Training configuration */
35665
+ trainingConfig = training_1.DEFAULT_TRAINING_CONFIG;
35666
+ /** Callback for training status updates */
35667
+ onTrainingStatusUpdate;
35648
35668
  constructor(topology, state, alertManager, config, console) {
35649
35669
  this.topology = topology;
35650
35670
  this.state = state;
@@ -35738,6 +35758,10 @@ class TrackingEngine {
35738
35758
  // Skip low-confidence detections
35739
35759
  if (detection.score < 0.5)
35740
35760
  continue;
35761
+ // If in training mode, record trainer detections
35762
+ if (this.isTrainingActive() && detection.className === 'person') {
35763
+ this.recordTrainerDetection(cameraId, detection, detection.score);
35764
+ }
35741
35765
  // Skip classes we're not tracking on this camera
35742
35766
  if (camera.trackClasses.length > 0 &&
35743
35767
  !camera.trackClasses.includes(detection.className)) {
@@ -35776,9 +35800,28 @@ class TrackingEngine {
35776
35800
  recordAlertTime(globalId) {
35777
35801
  this.objectLastAlertTime.set(globalId, Date.now());
35778
35802
  }
35779
- /** Get spatial reasoning result for movement (uses RAG + LLM) */
35803
+ /** Check if LLM call is allowed (rate limiting) */
35804
+ isLlmCallAllowed() {
35805
+ const debounceInterval = this.config.llmDebounceInterval || 0;
35806
+ if (debounceInterval <= 0)
35807
+ return true;
35808
+ const timeSinceLastCall = Date.now() - this.lastLlmCallTime;
35809
+ return timeSinceLastCall >= debounceInterval;
35810
+ }
35811
+ /** Record that an LLM call was made */
35812
+ recordLlmCall() {
35813
+ this.lastLlmCallTime = Date.now();
35814
+ }
35815
+ /** Get spatial reasoning result for movement (uses RAG + LLM) with debouncing and fallback */
35780
35816
  async getSpatialDescription(tracked, fromCameraId, toCameraId, transitTime, currentCameraId) {
35817
+ const fallbackEnabled = this.config.llmFallbackEnabled ?? true;
35818
+ const fallbackTimeout = this.config.llmFallbackTimeout ?? 3000;
35781
35819
  try {
35820
+ // Check rate limiting - if not allowed, return null to use basic description
35821
+ if (!this.isLlmCallAllowed()) {
35822
+ this.console.log('LLM rate-limited, using basic notification');
35823
+ return null;
35824
+ }
35782
35825
  // Get snapshot from camera for LLM analysis (if LLM is enabled)
35783
35826
  let mediaObject;
35784
35827
  if (this.config.useLlmDescriptions) {
@@ -35787,9 +35830,28 @@ class TrackingEngine {
35787
35830
  mediaObject = await camera.takePicture();
35788
35831
  }
35789
35832
  }
35833
+ // Record that we're making an LLM call
35834
+ this.recordLlmCall();
35790
35835
  // 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
35836
+ // Apply timeout if fallback is enabled
35837
+ let result;
35838
+ if (fallbackEnabled && mediaObject) {
35839
+ const timeoutPromise = new Promise((_, reject) => {
35840
+ setTimeout(() => reject(new Error('LLM timeout')), fallbackTimeout);
35841
+ });
35842
+ const descriptionPromise = this.spatialReasoning.generateMovementDescription(tracked, fromCameraId, toCameraId, transitTime, mediaObject);
35843
+ try {
35844
+ result = await Promise.race([descriptionPromise, timeoutPromise]);
35845
+ }
35846
+ catch (timeoutError) {
35847
+ this.console.log('LLM timed out, using basic notification');
35848
+ return null;
35849
+ }
35850
+ }
35851
+ else {
35852
+ result = await this.spatialReasoning.generateMovementDescription(tracked, fromCameraId, toCameraId, transitTime, mediaObject);
35853
+ }
35854
+ // Optionally trigger landmark learning (background, non-blocking)
35793
35855
  if (this.config.enableLandmarkLearning && mediaObject) {
35794
35856
  this.tryLearnLandmark(currentCameraId, mediaObject, tracked.className);
35795
35857
  }
@@ -35837,6 +35899,8 @@ class TrackingEngine {
35837
35899
  transitDuration,
35838
35900
  correlationConfidence: correlation.confidence,
35839
35901
  });
35902
+ // Record for transit time learning
35903
+ this.recordObservedTransit(lastSighting.cameraId, sighting.cameraId, transitDuration);
35840
35904
  this.console.log(`Object ${tracked.globalId.slice(0, 8)} transited: ` +
35841
35905
  `${lastSighting.cameraName} → ${sighting.cameraName} ` +
35842
35906
  `(confidence: ${(correlation.confidence * 100).toFixed(0)}%)`);
@@ -36025,6 +36089,669 @@ class TrackingEngine {
36025
36089
  getTrackedObject(globalId) {
36026
36090
  return this.state.getObject(globalId);
36027
36091
  }
36092
+ // ==================== Transit Time Learning ====================
36093
+ /** Record an observed transit time for learning */
36094
+ recordObservedTransit(fromCameraId, toCameraId, transitTime) {
36095
+ if (!this.config.enableTransitTimeLearning)
36096
+ return;
36097
+ const key = `${fromCameraId}->${toCameraId}`;
36098
+ const observation = {
36099
+ fromCameraId,
36100
+ toCameraId,
36101
+ transitTime,
36102
+ timestamp: Date.now(),
36103
+ };
36104
+ // Add to observations
36105
+ if (!this.observedTransits.has(key)) {
36106
+ this.observedTransits.set(key, []);
36107
+ }
36108
+ const observations = this.observedTransits.get(key);
36109
+ observations.push(observation);
36110
+ // Keep only last 100 observations per connection
36111
+ if (observations.length > 100) {
36112
+ observations.shift();
36113
+ }
36114
+ // Check if we should update existing connection
36115
+ const existingConnection = (0, topology_1.findConnection)(this.topology, fromCameraId, toCameraId);
36116
+ if (existingConnection) {
36117
+ this.maybeUpdateConnectionTransitTime(existingConnection, observations);
36118
+ }
36119
+ else if (this.config.enableConnectionSuggestions) {
36120
+ // No existing connection - suggest one
36121
+ this.maybeCreateConnectionSuggestion(fromCameraId, toCameraId, observations);
36122
+ }
36123
+ }
36124
+ /** Update an existing connection's transit time based on observations */
36125
+ maybeUpdateConnectionTransitTime(connection, observations) {
36126
+ if (observations.length < 5)
36127
+ return; // Need minimum observations
36128
+ const times = observations.map(o => o.transitTime).sort((a, b) => a - b);
36129
+ // Calculate percentiles
36130
+ const newMin = times[Math.floor(times.length * 0.1)];
36131
+ const newTypical = times[Math.floor(times.length * 0.5)];
36132
+ const newMax = times[Math.floor(times.length * 0.9)];
36133
+ // Only update if significantly different (>20% change)
36134
+ const currentTypical = connection.transitTime.typical;
36135
+ const percentChange = Math.abs(newTypical - currentTypical) / currentTypical;
36136
+ if (percentChange > 0.2 && observations.length >= 10) {
36137
+ this.console.log(`Updating transit time for ${connection.name}: ` +
36138
+ `${Math.round(currentTypical / 1000)}s → ${Math.round(newTypical / 1000)}s (based on ${observations.length} observations)`);
36139
+ connection.transitTime = {
36140
+ min: newMin,
36141
+ typical: newTypical,
36142
+ max: newMax,
36143
+ };
36144
+ // Notify about topology change
36145
+ if (this.onTopologyChange) {
36146
+ this.onTopologyChange(this.topology);
36147
+ }
36148
+ }
36149
+ }
36150
+ /** Create or update a connection suggestion based on observations */
36151
+ maybeCreateConnectionSuggestion(fromCameraId, toCameraId, observations) {
36152
+ if (observations.length < this.MIN_OBSERVATIONS_FOR_SUGGESTION)
36153
+ return;
36154
+ const fromCamera = (0, topology_1.findCamera)(this.topology, fromCameraId);
36155
+ const toCamera = (0, topology_1.findCamera)(this.topology, toCameraId);
36156
+ if (!fromCamera || !toCamera)
36157
+ return;
36158
+ const key = `${fromCameraId}->${toCameraId}`;
36159
+ const times = observations.map(o => o.transitTime).sort((a, b) => a - b);
36160
+ // Calculate transit time suggestion
36161
+ const suggestedMin = times[Math.floor(times.length * 0.1)] || times[0];
36162
+ const suggestedTypical = times[Math.floor(times.length * 0.5)] || times[0];
36163
+ const suggestedMax = times[Math.floor(times.length * 0.9)] || times[times.length - 1];
36164
+ // Calculate confidence based on consistency and count
36165
+ const avgTime = times.reduce((a, b) => a + b, 0) / times.length;
36166
+ const variance = times.reduce((sum, t) => sum + Math.pow(t - avgTime, 2), 0) / times.length;
36167
+ const stdDev = Math.sqrt(variance);
36168
+ const coefficientOfVariation = stdDev / avgTime;
36169
+ // Higher confidence with more observations and lower variance
36170
+ const countFactor = Math.min(observations.length / 10, 1);
36171
+ const consistencyFactor = Math.max(0, 1 - coefficientOfVariation);
36172
+ const confidence = (countFactor * 0.6 + consistencyFactor * 0.4);
36173
+ const suggestion = {
36174
+ id: `suggest_${key}`,
36175
+ fromCameraId,
36176
+ fromCameraName: fromCamera.name,
36177
+ toCameraId,
36178
+ toCameraName: toCamera.name,
36179
+ observedTransits: observations.slice(-10), // Keep last 10
36180
+ suggestedTransitTime: {
36181
+ min: suggestedMin,
36182
+ typical: suggestedTypical,
36183
+ max: suggestedMax,
36184
+ },
36185
+ confidence,
36186
+ timestamp: Date.now(),
36187
+ };
36188
+ this.connectionSuggestions.set(key, suggestion);
36189
+ if (observations.length === this.MIN_OBSERVATIONS_FOR_SUGGESTION) {
36190
+ this.console.log(`New connection suggested: ${fromCamera.name} → ${toCamera.name} ` +
36191
+ `(typical: ${Math.round(suggestedTypical / 1000)}s, confidence: ${Math.round(confidence * 100)}%)`);
36192
+ }
36193
+ }
36194
+ /** Get pending connection suggestions */
36195
+ getConnectionSuggestions() {
36196
+ return Array.from(this.connectionSuggestions.values())
36197
+ .filter(s => s.confidence >= 0.5) // Only suggest with reasonable confidence
36198
+ .sort((a, b) => b.confidence - a.confidence);
36199
+ }
36200
+ /** Accept a connection suggestion, adding it to topology */
36201
+ acceptConnectionSuggestion(suggestionId) {
36202
+ const key = suggestionId.replace('suggest_', '');
36203
+ const suggestion = this.connectionSuggestions.get(key);
36204
+ if (!suggestion)
36205
+ return null;
36206
+ const fromCamera = (0, topology_1.findCamera)(this.topology, suggestion.fromCameraId);
36207
+ const toCamera = (0, topology_1.findCamera)(this.topology, suggestion.toCameraId);
36208
+ if (!fromCamera || !toCamera)
36209
+ return null;
36210
+ const connection = {
36211
+ id: `conn-${Date.now()}`,
36212
+ fromCameraId: suggestion.fromCameraId,
36213
+ toCameraId: suggestion.toCameraId,
36214
+ name: `${fromCamera.name} to ${toCamera.name}`,
36215
+ exitZone: [],
36216
+ entryZone: [],
36217
+ transitTime: suggestion.suggestedTransitTime,
36218
+ bidirectional: true, // Default to bidirectional
36219
+ };
36220
+ this.topology.connections.push(connection);
36221
+ this.connectionSuggestions.delete(key);
36222
+ // Notify about topology change
36223
+ if (this.onTopologyChange) {
36224
+ this.onTopologyChange(this.topology);
36225
+ }
36226
+ this.console.log(`Connection accepted: ${connection.name}`);
36227
+ return connection;
36228
+ }
36229
+ /** Reject a connection suggestion */
36230
+ rejectConnectionSuggestion(suggestionId) {
36231
+ const key = suggestionId.replace('suggest_', '');
36232
+ if (!this.connectionSuggestions.has(key))
36233
+ return false;
36234
+ this.connectionSuggestions.delete(key);
36235
+ // Also clear observations so it doesn't get re-suggested immediately
36236
+ this.observedTransits.delete(key);
36237
+ return true;
36238
+ }
36239
+ // ==================== Live Tracking State ====================
36240
+ /** Get current state of all tracked objects for live overlay */
36241
+ getLiveTrackingState() {
36242
+ const activeObjects = this.state.getActiveObjects();
36243
+ const objects = activeObjects.map(tracked => {
36244
+ const lastSighting = (0, tracked_object_1.getLastSighting)(tracked);
36245
+ const camera = lastSighting ? (0, topology_1.findCamera)(this.topology, lastSighting.cameraId) : null;
36246
+ return {
36247
+ globalId: tracked.globalId,
36248
+ className: tracked.className,
36249
+ label: tracked.label,
36250
+ lastCameraId: lastSighting?.cameraId || '',
36251
+ lastCameraName: lastSighting?.cameraName || '',
36252
+ lastSeen: tracked.lastSeen,
36253
+ state: tracked.state,
36254
+ cameraPosition: camera?.floorPlanPosition,
36255
+ };
36256
+ });
36257
+ return {
36258
+ objects,
36259
+ timestamp: Date.now(),
36260
+ };
36261
+ }
36262
+ /** Get journey path for visualization */
36263
+ getJourneyPath(globalId) {
36264
+ const tracked = this.state.getObject(globalId);
36265
+ if (!tracked)
36266
+ return null;
36267
+ const segments = tracked.journey.map(j => {
36268
+ const fromCamera = (0, topology_1.findCamera)(this.topology, j.fromCameraId);
36269
+ const toCamera = (0, topology_1.findCamera)(this.topology, j.toCameraId);
36270
+ return {
36271
+ fromCamera: {
36272
+ id: j.fromCameraId,
36273
+ name: j.fromCameraName,
36274
+ position: fromCamera?.floorPlanPosition,
36275
+ },
36276
+ toCamera: {
36277
+ id: j.toCameraId,
36278
+ name: j.toCameraName,
36279
+ position: toCamera?.floorPlanPosition,
36280
+ },
36281
+ transitTime: j.transitDuration,
36282
+ timestamp: j.entryTime,
36283
+ };
36284
+ });
36285
+ const lastSighting = (0, tracked_object_1.getLastSighting)(tracked);
36286
+ let currentLocation;
36287
+ if (lastSighting) {
36288
+ const camera = (0, topology_1.findCamera)(this.topology, lastSighting.cameraId);
36289
+ currentLocation = {
36290
+ cameraId: lastSighting.cameraId,
36291
+ cameraName: lastSighting.cameraName,
36292
+ position: camera?.floorPlanPosition,
36293
+ };
36294
+ }
36295
+ return { segments, currentLocation };
36296
+ }
36297
+ // ==================== Training Mode Methods ====================
36298
+ /** Set callback for training status updates */
36299
+ setTrainingStatusCallback(callback) {
36300
+ this.onTrainingStatusUpdate = callback;
36301
+ }
36302
+ /** Get current training session (if any) */
36303
+ getTrainingSession() {
36304
+ return this.trainingSession;
36305
+ }
36306
+ /** Check if training mode is active */
36307
+ isTrainingActive() {
36308
+ return this.trainingSession !== null && this.trainingSession.state === 'active';
36309
+ }
36310
+ /** Start a new training session */
36311
+ startTrainingSession(trainerName, config) {
36312
+ // End any existing session
36313
+ if (this.trainingSession && this.trainingSession.state === 'active') {
36314
+ this.endTrainingSession();
36315
+ }
36316
+ // Apply custom config
36317
+ if (config) {
36318
+ this.trainingConfig = { ...training_1.DEFAULT_TRAINING_CONFIG, ...config };
36319
+ }
36320
+ // Create new session
36321
+ this.trainingSession = (0, training_1.createTrainingSession)(trainerName);
36322
+ this.trainingSession.state = 'active';
36323
+ this.console.log(`Training session started: ${this.trainingSession.id}`);
36324
+ this.emitTrainingStatus();
36325
+ return this.trainingSession;
36326
+ }
36327
+ /** Pause the current training session */
36328
+ pauseTrainingSession() {
36329
+ if (!this.trainingSession || this.trainingSession.state !== 'active') {
36330
+ return false;
36331
+ }
36332
+ this.trainingSession.state = 'paused';
36333
+ this.trainingSession.updatedAt = Date.now();
36334
+ this.console.log('Training session paused');
36335
+ this.emitTrainingStatus();
36336
+ return true;
36337
+ }
36338
+ /** Resume a paused training session */
36339
+ resumeTrainingSession() {
36340
+ if (!this.trainingSession || this.trainingSession.state !== 'paused') {
36341
+ return false;
36342
+ }
36343
+ this.trainingSession.state = 'active';
36344
+ this.trainingSession.updatedAt = Date.now();
36345
+ this.console.log('Training session resumed');
36346
+ this.emitTrainingStatus();
36347
+ return true;
36348
+ }
36349
+ /** End the current training session */
36350
+ endTrainingSession() {
36351
+ if (!this.trainingSession) {
36352
+ return null;
36353
+ }
36354
+ this.trainingSession.state = 'completed';
36355
+ this.trainingSession.completedAt = Date.now();
36356
+ this.trainingSession.updatedAt = Date.now();
36357
+ this.trainingSession.stats = (0, training_1.calculateTrainingStats)(this.trainingSession, this.topology.cameras.length);
36358
+ this.console.log(`Training session completed: ${this.trainingSession.stats.camerasVisited} cameras, ` +
36359
+ `${this.trainingSession.stats.transitsRecorded} transits, ` +
36360
+ `${this.trainingSession.stats.landmarksMarked} landmarks`);
36361
+ const session = this.trainingSession;
36362
+ this.emitTrainingStatus();
36363
+ return session;
36364
+ }
36365
+ /** Record that trainer was detected on a camera */
36366
+ recordTrainerDetection(cameraId, detection, detectionConfidence) {
36367
+ if (!this.trainingSession || this.trainingSession.state !== 'active') {
36368
+ return;
36369
+ }
36370
+ // Only process person detections during training
36371
+ if (detection.className !== 'person') {
36372
+ return;
36373
+ }
36374
+ // Check confidence threshold
36375
+ if (detectionConfidence < this.trainingConfig.minDetectionConfidence) {
36376
+ return;
36377
+ }
36378
+ const camera = (0, topology_1.findCamera)(this.topology, cameraId);
36379
+ const cameraName = camera?.name || cameraId;
36380
+ const now = Date.now();
36381
+ // Check if this is a new camera or same camera
36382
+ if (this.trainingSession.currentCameraId === cameraId) {
36383
+ // Update existing visit
36384
+ const currentVisit = this.trainingSession.visits.find(v => v.cameraId === cameraId && v.departedAt === null);
36385
+ if (currentVisit) {
36386
+ currentVisit.detectionConfidence = Math.max(currentVisit.detectionConfidence, detectionConfidence);
36387
+ if (detection.boundingBox) {
36388
+ currentVisit.boundingBox = detection.boundingBox;
36389
+ }
36390
+ }
36391
+ }
36392
+ else {
36393
+ // This is a new camera - check for transition
36394
+ if (this.trainingSession.currentCameraId && this.trainingSession.transitStartTime) {
36395
+ // Complete the transit
36396
+ const transitDuration = now - this.trainingSession.transitStartTime;
36397
+ const fromCameraId = this.trainingSession.previousCameraId || this.trainingSession.currentCameraId;
36398
+ const fromCamera = (0, topology_1.findCamera)(this.topology, fromCameraId);
36399
+ // Mark departure from previous camera
36400
+ const prevVisit = this.trainingSession.visits.find(v => v.cameraId === fromCameraId && v.departedAt === null);
36401
+ if (prevVisit) {
36402
+ prevVisit.departedAt = this.trainingSession.transitStartTime;
36403
+ }
36404
+ // Check for overlap (both cameras detecting at same time)
36405
+ const hasOverlap = this.checkTrainingOverlap(fromCameraId, cameraId, now);
36406
+ // Record the transit
36407
+ const transit = {
36408
+ id: `transit-${now}`,
36409
+ fromCameraId,
36410
+ toCameraId: cameraId,
36411
+ startTime: this.trainingSession.transitStartTime,
36412
+ endTime: now,
36413
+ transitSeconds: Math.round(transitDuration / 1000),
36414
+ hasOverlap,
36415
+ };
36416
+ this.trainingSession.transits.push(transit);
36417
+ this.console.log(`Training transit: ${fromCamera?.name || fromCameraId} → ${cameraName} ` +
36418
+ `(${transit.transitSeconds}s${hasOverlap ? ', overlap detected' : ''})`);
36419
+ // If overlap detected, record it
36420
+ if (hasOverlap && this.trainingConfig.autoDetectOverlaps) {
36421
+ this.recordTrainingOverlap(fromCameraId, cameraId);
36422
+ }
36423
+ }
36424
+ // Record new camera visit
36425
+ const visit = {
36426
+ cameraId,
36427
+ cameraName,
36428
+ arrivedAt: now,
36429
+ departedAt: null,
36430
+ trainerEmbedding: detection.embedding,
36431
+ detectionConfidence,
36432
+ boundingBox: detection.boundingBox,
36433
+ floorPlanPosition: camera?.floorPlanPosition,
36434
+ };
36435
+ this.trainingSession.visits.push(visit);
36436
+ // Update session state
36437
+ this.trainingSession.previousCameraId = this.trainingSession.currentCameraId;
36438
+ this.trainingSession.currentCameraId = cameraId;
36439
+ this.trainingSession.transitStartTime = now;
36440
+ // Store trainer embedding if not already captured
36441
+ if (!this.trainingSession.trainerEmbedding && detection.embedding) {
36442
+ this.trainingSession.trainerEmbedding = detection.embedding;
36443
+ }
36444
+ }
36445
+ this.trainingSession.updatedAt = now;
36446
+ this.trainingSession.stats = (0, training_1.calculateTrainingStats)(this.trainingSession, this.topology.cameras.length);
36447
+ this.emitTrainingStatus();
36448
+ }
36449
+ /** Check if there's overlap between two cameras during training */
36450
+ checkTrainingOverlap(fromCameraId, toCameraId, now) {
36451
+ // Check if both cameras have recent visits overlapping in time
36452
+ const fromVisit = this.trainingSession?.visits.find(v => v.cameraId === fromCameraId &&
36453
+ (v.departedAt === null || v.departedAt > now - 5000) // Within 5 seconds
36454
+ );
36455
+ const toVisit = this.trainingSession?.visits.find(v => v.cameraId === toCameraId &&
36456
+ v.arrivedAt <= now &&
36457
+ v.arrivedAt >= now - 5000 // Arrived within last 5 seconds
36458
+ );
36459
+ return !!(fromVisit && toVisit);
36460
+ }
36461
+ /** Record a camera overlap detected during training */
36462
+ recordTrainingOverlap(camera1Id, camera2Id) {
36463
+ if (!this.trainingSession)
36464
+ return;
36465
+ // Check if we already have this overlap
36466
+ const existingOverlap = this.trainingSession.overlaps.find(o => (o.camera1Id === camera1Id && o.camera2Id === camera2Id) ||
36467
+ (o.camera1Id === camera2Id && o.camera2Id === camera1Id));
36468
+ if (existingOverlap)
36469
+ return;
36470
+ const camera1 = (0, topology_1.findCamera)(this.topology, camera1Id);
36471
+ const camera2 = (0, topology_1.findCamera)(this.topology, camera2Id);
36472
+ // Calculate approximate position (midpoint of both camera positions)
36473
+ let position = { x: 50, y: 50 };
36474
+ if (camera1?.floorPlanPosition && camera2?.floorPlanPosition) {
36475
+ position = {
36476
+ x: (camera1.floorPlanPosition.x + camera2.floorPlanPosition.x) / 2,
36477
+ y: (camera1.floorPlanPosition.y + camera2.floorPlanPosition.y) / 2,
36478
+ };
36479
+ }
36480
+ const overlap = {
36481
+ id: `overlap-${Date.now()}`,
36482
+ camera1Id,
36483
+ camera2Id,
36484
+ position,
36485
+ radius: 30, // Default radius
36486
+ markedAt: Date.now(),
36487
+ };
36488
+ this.trainingSession.overlaps.push(overlap);
36489
+ this.console.log(`Camera overlap detected: ${camera1?.name} ↔ ${camera2?.name}`);
36490
+ }
36491
+ /** Manually mark a landmark during training */
36492
+ markTrainingLandmark(landmark) {
36493
+ if (!this.trainingSession)
36494
+ return null;
36495
+ const newLandmark = {
36496
+ ...landmark,
36497
+ id: `landmark-${Date.now()}`,
36498
+ markedAt: Date.now(),
36499
+ };
36500
+ this.trainingSession.landmarks.push(newLandmark);
36501
+ this.trainingSession.updatedAt = Date.now();
36502
+ this.trainingSession.stats = (0, training_1.calculateTrainingStats)(this.trainingSession, this.topology.cameras.length);
36503
+ this.console.log(`Landmark marked: ${newLandmark.name} (${newLandmark.type})`);
36504
+ this.emitTrainingStatus();
36505
+ return newLandmark;
36506
+ }
36507
+ /** Manually mark a structure during training */
36508
+ markTrainingStructure(structure) {
36509
+ if (!this.trainingSession)
36510
+ return null;
36511
+ const newStructure = {
36512
+ ...structure,
36513
+ id: `structure-${Date.now()}`,
36514
+ markedAt: Date.now(),
36515
+ };
36516
+ this.trainingSession.structures.push(newStructure);
36517
+ this.trainingSession.updatedAt = Date.now();
36518
+ this.trainingSession.stats = (0, training_1.calculateTrainingStats)(this.trainingSession, this.topology.cameras.length);
36519
+ this.console.log(`Structure marked: ${newStructure.name} (${newStructure.type})`);
36520
+ this.emitTrainingStatus();
36521
+ return newStructure;
36522
+ }
36523
+ /** Confirm camera position on floor plan during training */
36524
+ confirmCameraPosition(cameraId, position) {
36525
+ if (!this.trainingSession)
36526
+ return false;
36527
+ // Update in current session
36528
+ const visit = this.trainingSession.visits.find(v => v.cameraId === cameraId);
36529
+ if (visit) {
36530
+ visit.floorPlanPosition = position;
36531
+ }
36532
+ // Update in topology
36533
+ const camera = (0, topology_1.findCamera)(this.topology, cameraId);
36534
+ if (camera) {
36535
+ camera.floorPlanPosition = position;
36536
+ if (this.onTopologyChange) {
36537
+ this.onTopologyChange(this.topology);
36538
+ }
36539
+ }
36540
+ this.trainingSession.updatedAt = Date.now();
36541
+ this.emitTrainingStatus();
36542
+ return true;
36543
+ }
36544
+ /** Get training status for UI updates */
36545
+ getTrainingStatus() {
36546
+ if (!this.trainingSession)
36547
+ return null;
36548
+ const currentCamera = this.trainingSession.currentCameraId
36549
+ ? (0, topology_1.findCamera)(this.topology, this.trainingSession.currentCameraId)
36550
+ : null;
36551
+ const previousCamera = this.trainingSession.previousCameraId
36552
+ ? (0, topology_1.findCamera)(this.topology, this.trainingSession.previousCameraId)
36553
+ : null;
36554
+ // Generate suggestions for next actions
36555
+ const suggestions = [];
36556
+ const visitedCameras = new Set(this.trainingSession.visits.map(v => v.cameraId));
36557
+ const unvisitedCameras = this.topology.cameras.filter(c => !visitedCameras.has(c.deviceId));
36558
+ if (unvisitedCameras.length > 0) {
36559
+ // Suggest nearest unvisited camera based on connections
36560
+ const currentConnections = currentCamera
36561
+ ? (0, topology_1.findConnectionsFrom)(this.topology, currentCamera.deviceId)
36562
+ : [];
36563
+ const connectedUnvisited = currentConnections
36564
+ .map(c => c.toCameraId)
36565
+ .filter(id => !visitedCameras.has(id));
36566
+ if (connectedUnvisited.length > 0) {
36567
+ const nextCam = (0, topology_1.findCamera)(this.topology, connectedUnvisited[0]);
36568
+ if (nextCam) {
36569
+ suggestions.push(`Walk to ${nextCam.name}`);
36570
+ }
36571
+ }
36572
+ else {
36573
+ suggestions.push(`${unvisitedCameras.length} cameras not yet visited`);
36574
+ }
36575
+ }
36576
+ if (this.trainingSession.visits.length >= 2 && this.trainingSession.landmarks.length === 0) {
36577
+ suggestions.push('Consider marking some landmarks');
36578
+ }
36579
+ if (visitedCameras.size >= this.topology.cameras.length) {
36580
+ suggestions.push('All cameras visited! You can end training.');
36581
+ }
36582
+ const status = {
36583
+ sessionId: this.trainingSession.id,
36584
+ state: this.trainingSession.state,
36585
+ currentCamera: currentCamera ? {
36586
+ id: currentCamera.deviceId,
36587
+ name: currentCamera.name,
36588
+ detectedAt: this.trainingSession.visits.find(v => v.cameraId === currentCamera.deviceId && !v.departedAt)?.arrivedAt || Date.now(),
36589
+ confidence: this.trainingSession.visits.find(v => v.cameraId === currentCamera.deviceId && !v.departedAt)?.detectionConfidence || 0,
36590
+ } : undefined,
36591
+ activeTransit: this.trainingSession.transitStartTime && previousCamera ? {
36592
+ fromCameraId: previousCamera.deviceId,
36593
+ fromCameraName: previousCamera.name,
36594
+ startTime: this.trainingSession.transitStartTime,
36595
+ elapsedSeconds: Math.round((Date.now() - this.trainingSession.transitStartTime) / 1000),
36596
+ } : undefined,
36597
+ stats: this.trainingSession.stats,
36598
+ suggestions,
36599
+ };
36600
+ return status;
36601
+ }
36602
+ /** Emit training status update to callback */
36603
+ emitTrainingStatus() {
36604
+ if (this.onTrainingStatusUpdate) {
36605
+ const status = this.getTrainingStatus();
36606
+ if (status) {
36607
+ this.onTrainingStatusUpdate(status);
36608
+ }
36609
+ }
36610
+ }
36611
+ /** Apply training results to topology */
36612
+ applyTrainingToTopology() {
36613
+ const result = {
36614
+ camerasAdded: 0,
36615
+ connectionsCreated: 0,
36616
+ connectionsUpdated: 0,
36617
+ landmarksAdded: 0,
36618
+ zonesCreated: 0,
36619
+ warnings: [],
36620
+ success: false,
36621
+ };
36622
+ if (!this.trainingSession) {
36623
+ result.warnings.push('No training session to apply');
36624
+ return result;
36625
+ }
36626
+ try {
36627
+ // 1. Update camera positions from training visits
36628
+ for (const visit of this.trainingSession.visits) {
36629
+ const camera = (0, topology_1.findCamera)(this.topology, visit.cameraId);
36630
+ if (camera && visit.floorPlanPosition) {
36631
+ if (!camera.floorPlanPosition) {
36632
+ camera.floorPlanPosition = visit.floorPlanPosition;
36633
+ result.camerasAdded++;
36634
+ }
36635
+ }
36636
+ }
36637
+ // 2. Create or update connections from training transits
36638
+ for (const transit of this.trainingSession.transits) {
36639
+ const existingConnection = (0, topology_1.findConnection)(this.topology, transit.fromCameraId, transit.toCameraId);
36640
+ if (existingConnection) {
36641
+ // Update existing connection with observed transit time
36642
+ const transitMs = transit.transitSeconds * 1000;
36643
+ existingConnection.transitTime = {
36644
+ min: Math.min(existingConnection.transitTime.min, transitMs * 0.7),
36645
+ typical: transitMs,
36646
+ max: Math.max(existingConnection.transitTime.max, transitMs * 1.3),
36647
+ };
36648
+ result.connectionsUpdated++;
36649
+ }
36650
+ else {
36651
+ // Create new connection
36652
+ const fromCamera = (0, topology_1.findCamera)(this.topology, transit.fromCameraId);
36653
+ const toCamera = (0, topology_1.findCamera)(this.topology, transit.toCameraId);
36654
+ if (fromCamera && toCamera) {
36655
+ const transitMs = transit.transitSeconds * 1000;
36656
+ const newConnection = {
36657
+ id: `conn-training-${Date.now()}-${result.connectionsCreated}`,
36658
+ fromCameraId: transit.fromCameraId,
36659
+ toCameraId: transit.toCameraId,
36660
+ name: `${fromCamera.name} to ${toCamera.name}`,
36661
+ exitZone: [], // Will be refined in topology editor
36662
+ entryZone: [], // Will be refined in topology editor
36663
+ transitTime: {
36664
+ min: transitMs * 0.7,
36665
+ typical: transitMs,
36666
+ max: transitMs * 1.3,
36667
+ },
36668
+ bidirectional: true,
36669
+ };
36670
+ this.topology.connections.push(newConnection);
36671
+ result.connectionsCreated++;
36672
+ }
36673
+ }
36674
+ }
36675
+ // 3. Add landmarks from training
36676
+ for (const trainLandmark of this.trainingSession.landmarks) {
36677
+ // Map training landmark type to topology landmark type
36678
+ const typeMapping = {
36679
+ mailbox: 'feature',
36680
+ garage: 'structure',
36681
+ shed: 'structure',
36682
+ tree: 'feature',
36683
+ gate: 'access',
36684
+ door: 'access',
36685
+ driveway: 'access',
36686
+ pathway: 'access',
36687
+ garden: 'feature',
36688
+ pool: 'feature',
36689
+ deck: 'structure',
36690
+ patio: 'structure',
36691
+ other: 'feature',
36692
+ };
36693
+ // Convert training landmark to topology landmark
36694
+ const landmark = {
36695
+ id: trainLandmark.id,
36696
+ name: trainLandmark.name,
36697
+ type: typeMapping[trainLandmark.type] || 'feature',
36698
+ position: trainLandmark.position,
36699
+ visibleFromCameras: trainLandmark.visibleFromCameras.length > 0
36700
+ ? trainLandmark.visibleFromCameras
36701
+ : undefined,
36702
+ description: trainLandmark.description,
36703
+ };
36704
+ if (!this.topology.landmarks) {
36705
+ this.topology.landmarks = [];
36706
+ }
36707
+ this.topology.landmarks.push(landmark);
36708
+ result.landmarksAdded++;
36709
+ }
36710
+ // 4. Create zones from overlaps
36711
+ for (const overlap of this.trainingSession.overlaps) {
36712
+ const camera1 = (0, topology_1.findCamera)(this.topology, overlap.camera1Id);
36713
+ const camera2 = (0, topology_1.findCamera)(this.topology, overlap.camera2Id);
36714
+ if (camera1 && camera2) {
36715
+ // Create global zone for overlap area
36716
+ const zoneName = `${camera1.name}/${camera2.name} Overlap`;
36717
+ const existingZone = this.topology.globalZones?.find(z => z.name === zoneName);
36718
+ if (!existingZone) {
36719
+ if (!this.topology.globalZones) {
36720
+ this.topology.globalZones = [];
36721
+ }
36722
+ // Create camera zone mappings (placeholder zones to be refined in editor)
36723
+ const cameraZones = [
36724
+ { cameraId: overlap.camera1Id, zone: [] },
36725
+ { cameraId: overlap.camera2Id, zone: [] },
36726
+ ];
36727
+ this.topology.globalZones.push({
36728
+ id: `zone-overlap-${overlap.id}`,
36729
+ name: zoneName,
36730
+ type: 'dwell', // Overlap zones are good for tracking dwell time
36731
+ cameraZones,
36732
+ });
36733
+ result.zonesCreated++;
36734
+ }
36735
+ }
36736
+ }
36737
+ // Notify about topology change
36738
+ if (this.onTopologyChange) {
36739
+ this.onTopologyChange(this.topology);
36740
+ }
36741
+ result.success = true;
36742
+ this.console.log(`Training applied: ${result.connectionsCreated} connections created, ` +
36743
+ `${result.connectionsUpdated} updated, ${result.landmarksAdded} landmarks added`);
36744
+ }
36745
+ catch (e) {
36746
+ result.warnings.push(`Error applying training: ${e}`);
36747
+ }
36748
+ return result;
36749
+ }
36750
+ /** Clear the current training session without applying */
36751
+ clearTrainingSession() {
36752
+ this.trainingSession = null;
36753
+ this.emitTrainingStatus();
36754
+ }
36028
36755
  }
36029
36756
  exports.TrackingEngine = TrackingEngine;
36030
36757
 
@@ -36732,6 +37459,7 @@ const global_tracker_sensor_1 = __webpack_require__(/*! ./devices/global-tracker
36732
37459
  const tracking_zone_1 = __webpack_require__(/*! ./devices/tracking-zone */ "./src/devices/tracking-zone.ts");
36733
37460
  const mqtt_publisher_1 = __webpack_require__(/*! ./integrations/mqtt-publisher */ "./src/integrations/mqtt-publisher.ts");
36734
37461
  const editor_html_1 = __webpack_require__(/*! ./ui/editor-html */ "./src/ui/editor-html.ts");
37462
+ const training_html_1 = __webpack_require__(/*! ./ui/training-html */ "./src/ui/training-html.ts");
36735
37463
  const { deviceManager, systemManager } = sdk_1.default;
36736
37464
  const TRACKING_ZONE_PREFIX = 'tracking-zone:';
36737
37465
  const GLOBAL_TRACKER_ID = 'global-tracker';
@@ -36806,6 +37534,41 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
36806
37534
  description: 'Use LLM plugin (if installed) to generate descriptive alerts like "Man walking from garage towards front door"',
36807
37535
  group: 'AI & Spatial Reasoning',
36808
37536
  },
37537
+ llmDebounceInterval: {
37538
+ title: 'LLM Rate Limit (seconds)',
37539
+ type: 'number',
37540
+ defaultValue: 10,
37541
+ description: 'Minimum time between LLM calls to prevent API overload (0 = no limit)',
37542
+ group: 'AI & Spatial Reasoning',
37543
+ },
37544
+ llmFallbackEnabled: {
37545
+ title: 'Fallback to Basic Notifications',
37546
+ type: 'boolean',
37547
+ defaultValue: true,
37548
+ description: 'When LLM is rate-limited or slow, fall back to basic notifications immediately',
37549
+ group: 'AI & Spatial Reasoning',
37550
+ },
37551
+ llmFallbackTimeout: {
37552
+ title: 'LLM Timeout (seconds)',
37553
+ type: 'number',
37554
+ defaultValue: 3,
37555
+ description: 'Maximum time to wait for LLM response before falling back to basic notification',
37556
+ group: 'AI & Spatial Reasoning',
37557
+ },
37558
+ enableTransitTimeLearning: {
37559
+ title: 'Learn Transit Times',
37560
+ type: 'boolean',
37561
+ defaultValue: true,
37562
+ description: 'Automatically adjust connection transit times based on observed movement patterns',
37563
+ group: 'AI & Spatial Reasoning',
37564
+ },
37565
+ enableConnectionSuggestions: {
37566
+ title: 'Suggest Camera Connections',
37567
+ type: 'boolean',
37568
+ defaultValue: true,
37569
+ description: 'Automatically suggest new camera connections based on observed movement patterns',
37570
+ group: 'AI & Spatial Reasoning',
37571
+ },
36809
37572
  enableLandmarkLearning: {
36810
37573
  title: 'Learn Landmarks from AI',
36811
37574
  type: 'boolean',
@@ -36965,6 +37728,11 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
36965
37728
  loiteringThreshold: (this.storageSettings.values.loiteringThreshold || 3) * 1000,
36966
37729
  objectAlertCooldown: (this.storageSettings.values.objectAlertCooldown || 30) * 1000,
36967
37730
  useLlmDescriptions: this.storageSettings.values.useLlmDescriptions ?? true,
37731
+ llmDebounceInterval: (this.storageSettings.values.llmDebounceInterval || 10) * 1000,
37732
+ llmFallbackEnabled: this.storageSettings.values.llmFallbackEnabled ?? true,
37733
+ llmFallbackTimeout: (this.storageSettings.values.llmFallbackTimeout || 3) * 1000,
37734
+ enableTransitTimeLearning: this.storageSettings.values.enableTransitTimeLearning ?? true,
37735
+ enableConnectionSuggestions: this.storageSettings.values.enableConnectionSuggestions ?? true,
36968
37736
  enableLandmarkLearning: this.storageSettings.values.enableLandmarkLearning ?? true,
36969
37737
  landmarkConfidenceThreshold: this.storageSettings.values.landmarkConfidenceThreshold ?? 0.7,
36970
37738
  };
@@ -37043,8 +37811,89 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
37043
37811
  // ==================== Settings Implementation ====================
37044
37812
  async getSettings() {
37045
37813
  const settings = await this.storageSettings.getSettings();
37814
+ // Training Mode button that opens mobile-friendly training UI in modal
37815
+ const trainingOnclickCode = `(function(){var e=document.getElementById('sa-training-modal');if(e)e.remove();var m=document.createElement('div');m.id='sa-training-modal';m.style.cssText='position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.85);z-index:2147483647;display:flex;align-items:center;justify-content:center;';var c=document.createElement('div');c.style.cssText='width:min(420px,95vw);height:92vh;max-height:900px;background:#121212;border-radius:8px;overflow:hidden;position:relative;box-shadow:0 8px 32px rgba(0,0,0,0.5);border:1px solid rgba(255,255,255,0.1);';var b=document.createElement('button');b.innerHTML='×';b.style.cssText='position:absolute;top:8px;right:8px;z-index:2147483647;background:rgba(255,255,255,0.1);color:white;border:none;width:32px;height:32px;border-radius:4px;font-size:18px;cursor:pointer;line-height:1;';b.onclick=function(){m.remove();};var f=document.createElement('iframe');f.src='/endpoint/@blueharford/scrypted-spatial-awareness/ui/training';f.style.cssText='width:100%;height:100%;border:none;';c.appendChild(b);c.appendChild(f);m.appendChild(c);m.onclick=function(ev){if(ev.target===m)m.remove();};document.body.appendChild(m);})()`;
37816
+ settings.push({
37817
+ key: 'trainingMode',
37818
+ title: 'Training Mode',
37819
+ type: 'html',
37820
+ value: `
37821
+ <style>
37822
+ .sa-training-container {
37823
+ padding: 16px;
37824
+ background: rgba(255,255,255,0.03);
37825
+ border-radius: 4px;
37826
+ border: 1px solid rgba(255,255,255,0.08);
37827
+ }
37828
+ .sa-training-title {
37829
+ color: #4fc3f7;
37830
+ font-size: 14px;
37831
+ font-weight: 500;
37832
+ margin-bottom: 8px;
37833
+ font-family: inherit;
37834
+ }
37835
+ .sa-training-desc {
37836
+ color: rgba(255,255,255,0.6);
37837
+ margin-bottom: 12px;
37838
+ font-size: 13px;
37839
+ line-height: 1.5;
37840
+ font-family: inherit;
37841
+ }
37842
+ .sa-training-btn {
37843
+ background: #4fc3f7;
37844
+ color: #000;
37845
+ border: none;
37846
+ padding: 10px 20px;
37847
+ border-radius: 4px;
37848
+ font-size: 14px;
37849
+ font-weight: 500;
37850
+ cursor: pointer;
37851
+ display: inline-flex;
37852
+ align-items: center;
37853
+ gap: 8px;
37854
+ transition: background 0.2s;
37855
+ font-family: inherit;
37856
+ }
37857
+ .sa-training-btn:hover {
37858
+ background: #81d4fa;
37859
+ }
37860
+ .sa-training-steps {
37861
+ color: rgba(255,255,255,0.5);
37862
+ font-size: 12px;
37863
+ margin-top: 12px;
37864
+ padding-top: 12px;
37865
+ border-top: 1px solid rgba(255,255,255,0.05);
37866
+ font-family: inherit;
37867
+ }
37868
+ .sa-training-steps ol {
37869
+ margin: 6px 0 0 16px;
37870
+ padding: 0;
37871
+ }
37872
+ .sa-training-steps li {
37873
+ margin-bottom: 2px;
37874
+ }
37875
+ </style>
37876
+ <div class="sa-training-container">
37877
+ <div class="sa-training-title">Guided Property Training</div>
37878
+ <p class="sa-training-desc">Walk your property while the system learns your camera layout, transit times, and landmarks automatically.</p>
37879
+ <button class="sa-training-btn" onclick="${trainingOnclickCode}">
37880
+ Start Training Mode
37881
+ </button>
37882
+ <div class="sa-training-steps">
37883
+ <strong>How it works:</strong>
37884
+ <ol>
37885
+ <li>Start training and walk to each camera</li>
37886
+ <li>System auto-detects you and records transit times</li>
37887
+ <li>Mark landmarks as you encounter them</li>
37888
+ <li>Apply results to generate your topology</li>
37889
+ </ol>
37890
+ </div>
37891
+ </div>
37892
+ `,
37893
+ group: 'Getting Started',
37894
+ });
37046
37895
  // Topology editor button that opens modal overlay (appended to body for proper z-index)
37047
- const onclickCode = `(function(){var e=document.getElementById('sa-topology-modal');if(e)e.remove();var m=document.createElement('div');m.id='sa-topology-modal';m.style.cssText='position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.9);z-index:2147483647;display:flex;align-items:center;justify-content:center;';var c=document.createElement('div');c.style.cssText='width:95vw;height:92vh;max-width:1800px;background:#1a1a2e;border-radius:12px;overflow:hidden;position:relative;box-shadow:0 25px 50px rgba(0,0,0,0.5);';var b=document.createElement('button');b.innerHTML='×';b.style.cssText='position:absolute;top:15px;right:15px;z-index:2147483647;background:#e94560;color:white;border:none;width:40px;height:40px;border-radius:50%;font-size:24px;cursor:pointer;';b.onclick=function(){m.remove();};var f=document.createElement('iframe');f.src='/endpoint/@blueharford/scrypted-spatial-awareness/ui/editor';f.style.cssText='width:100%;height:100%;border:none;';c.appendChild(b);c.appendChild(f);m.appendChild(c);m.onclick=function(ev){if(ev.target===m)m.remove();};document.body.appendChild(m);})()`;
37896
+ const onclickCode = `(function(){var e=document.getElementById('sa-topology-modal');if(e)e.remove();var m=document.createElement('div');m.id='sa-topology-modal';m.style.cssText='position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.85);z-index:2147483647;display:flex;align-items:center;justify-content:center;';var c=document.createElement('div');c.style.cssText='width:95vw;height:92vh;max-width:1800px;background:#121212;border-radius:8px;overflow:hidden;position:relative;box-shadow:0 8px 32px rgba(0,0,0,0.5);border:1px solid rgba(255,255,255,0.1);';var b=document.createElement('button');b.innerHTML='×';b.style.cssText='position:absolute;top:8px;right:8px;z-index:2147483647;background:rgba(255,255,255,0.1);color:white;border:none;width:32px;height:32px;border-radius:4px;font-size:18px;cursor:pointer;line-height:1;';b.onclick=function(){m.remove();};var f=document.createElement('iframe');f.src='/endpoint/@blueharford/scrypted-spatial-awareness/ui/editor';f.style.cssText='width:100%;height:100%;border:none;';c.appendChild(b);c.appendChild(f);m.appendChild(c);m.onclick=function(ev){if(ev.target===m)m.remove();};document.body.appendChild(m);})()`;
37048
37897
  settings.push({
37049
37898
  key: 'topologyEditor',
37050
37899
  title: 'Topology Editor',
@@ -37052,39 +37901,41 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
37052
37901
  value: `
37053
37902
  <style>
37054
37903
  .sa-open-btn {
37055
- background: linear-gradient(135deg, #e94560 0%, #0f3460 100%);
37056
- color: white;
37904
+ background: #4fc3f7;
37905
+ color: #000;
37057
37906
  border: none;
37058
- padding: 14px 28px;
37059
- border-radius: 8px;
37060
- font-size: 15px;
37061
- font-weight: 600;
37907
+ padding: 10px 20px;
37908
+ border-radius: 4px;
37909
+ font-size: 14px;
37910
+ font-weight: 500;
37062
37911
  cursor: pointer;
37063
37912
  display: inline-flex;
37064
37913
  align-items: center;
37065
- gap: 10px;
37066
- transition: transform 0.2s, box-shadow 0.2s;
37914
+ gap: 8px;
37915
+ transition: background 0.2s;
37916
+ font-family: inherit;
37067
37917
  }
37068
37918
  .sa-open-btn:hover {
37069
- transform: translateY(-2px);
37070
- box-shadow: 0 8px 20px rgba(233, 69, 96, 0.4);
37919
+ background: #81d4fa;
37071
37920
  }
37072
37921
  .sa-btn-container {
37073
- padding: 20px;
37074
- background: #16213e;
37075
- border-radius: 8px;
37922
+ padding: 16px;
37923
+ background: rgba(255,255,255,0.03);
37924
+ border-radius: 4px;
37076
37925
  text-align: center;
37926
+ border: 1px solid rgba(255,255,255,0.08);
37077
37927
  }
37078
37928
  .sa-btn-desc {
37079
- color: #888;
37080
- margin-bottom: 15px;
37081
- font-size: 14px;
37929
+ color: rgba(255,255,255,0.6);
37930
+ margin-bottom: 12px;
37931
+ font-size: 13px;
37932
+ font-family: inherit;
37082
37933
  }
37083
37934
  </style>
37084
37935
  <div class="sa-btn-container">
37085
37936
  <p class="sa-btn-desc">Configure camera positions, connections, and transit times</p>
37086
37937
  <button class="sa-open-btn" onclick="${onclickCode}">
37087
- <span>&#9881;</span> Open Topology Editor
37938
+ Open Topology Editor
37088
37939
  </button>
37089
37940
  </div>
37090
37941
  `,
@@ -37214,6 +38065,11 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
37214
38065
  key === 'loiteringThreshold' ||
37215
38066
  key === 'objectAlertCooldown' ||
37216
38067
  key === 'useLlmDescriptions' ||
38068
+ key === 'llmDebounceInterval' ||
38069
+ key === 'llmFallbackEnabled' ||
38070
+ key === 'llmFallbackTimeout' ||
38071
+ key === 'enableTransitTimeLearning' ||
38072
+ key === 'enableConnectionSuggestions' ||
37217
38073
  key === 'enableLandmarkLearning' ||
37218
38074
  key === 'landmarkConfidenceThreshold') {
37219
38075
  const topologyJson = this.storage.getItem('topology');
@@ -37289,27 +38145,85 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
37289
38145
  if (path.endsWith('/api/infer-relationships')) {
37290
38146
  return this.handleInferRelationshipsRequest(response);
37291
38147
  }
38148
+ // Connection suggestions
38149
+ if (path.endsWith('/api/connection-suggestions')) {
38150
+ return this.handleConnectionSuggestionsRequest(request, response);
38151
+ }
38152
+ if (path.match(/\/api\/connection-suggestions\/[\w->]+\/(accept|reject)$/)) {
38153
+ const parts = path.split('/');
38154
+ const action = parts.pop();
38155
+ const suggestionId = parts.pop();
38156
+ return this.handleConnectionSuggestionActionRequest(suggestionId, action, response);
38157
+ }
38158
+ // Live tracking state
38159
+ if (path.endsWith('/api/live-tracking')) {
38160
+ return this.handleLiveTrackingRequest(response);
38161
+ }
38162
+ // Journey visualization
38163
+ if (path.match(/\/api\/journey-path\/[\w-]+$/)) {
38164
+ const globalId = path.split('/').pop();
38165
+ return this.handleJourneyPathRequest(globalId, response);
38166
+ }
38167
+ // Training Mode endpoints
38168
+ if (path.endsWith('/api/training/start')) {
38169
+ return this.handleTrainingStartRequest(request, response);
38170
+ }
38171
+ if (path.endsWith('/api/training/pause')) {
38172
+ return this.handleTrainingPauseRequest(response);
38173
+ }
38174
+ if (path.endsWith('/api/training/resume')) {
38175
+ return this.handleTrainingResumeRequest(response);
38176
+ }
38177
+ if (path.endsWith('/api/training/end')) {
38178
+ return this.handleTrainingEndRequest(response);
38179
+ }
38180
+ if (path.endsWith('/api/training/status')) {
38181
+ return this.handleTrainingStatusRequest(response);
38182
+ }
38183
+ if (path.endsWith('/api/training/landmark')) {
38184
+ return this.handleTrainingLandmarkRequest(request, response);
38185
+ }
38186
+ if (path.endsWith('/api/training/apply')) {
38187
+ return this.handleTrainingApplyRequest(response);
38188
+ }
37292
38189
  // UI Routes
37293
38190
  if (path.endsWith('/ui/editor') || path.endsWith('/ui/editor/')) {
37294
38191
  return this.serveEditorUI(response);
37295
38192
  }
38193
+ if (path.endsWith('/ui/training') || path.endsWith('/ui/training/')) {
38194
+ return this.serveTrainingUI(response);
38195
+ }
37296
38196
  if (path.includes('/ui/')) {
37297
38197
  return this.serveStaticFile(path, response);
37298
38198
  }
37299
38199
  // Default: return info page
37300
38200
  response.send(JSON.stringify({
37301
38201
  name: 'Spatial Awareness Plugin',
37302
- version: '0.1.0',
38202
+ version: '0.4.0',
37303
38203
  endpoints: {
37304
38204
  api: {
37305
38205
  trackedObjects: '/api/tracked-objects',
37306
38206
  journey: '/api/journey/{globalId}',
38207
+ journeyPath: '/api/journey-path/{globalId}',
37307
38208
  topology: '/api/topology',
37308
38209
  alerts: '/api/alerts',
37309
38210
  floorPlan: '/api/floor-plan',
38211
+ liveTracking: '/api/live-tracking',
38212
+ connectionSuggestions: '/api/connection-suggestions',
38213
+ landmarkSuggestions: '/api/landmark-suggestions',
38214
+ training: {
38215
+ start: '/api/training/start',
38216
+ pause: '/api/training/pause',
38217
+ resume: '/api/training/resume',
38218
+ end: '/api/training/end',
38219
+ status: '/api/training/status',
38220
+ landmark: '/api/training/landmark',
38221
+ apply: '/api/training/apply',
38222
+ },
37310
38223
  },
37311
38224
  ui: {
37312
38225
  editor: '/ui/editor',
38226
+ training: '/ui/training',
37313
38227
  },
37314
38228
  },
37315
38229
  }), {
@@ -37666,32 +38580,290 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
37666
38580
  headers: { 'Content-Type': 'application/json' },
37667
38581
  });
37668
38582
  }
37669
- serveEditorUI(response) {
37670
- response.send(editor_html_1.EDITOR_HTML, {
37671
- headers: { 'Content-Type': 'text/html' },
38583
+ handleConnectionSuggestionsRequest(request, response) {
38584
+ if (!this.trackingEngine) {
38585
+ response.send(JSON.stringify({ suggestions: [] }), {
38586
+ headers: { 'Content-Type': 'application/json' },
38587
+ });
38588
+ return;
38589
+ }
38590
+ const suggestions = this.trackingEngine.getConnectionSuggestions();
38591
+ response.send(JSON.stringify({
38592
+ suggestions,
38593
+ count: suggestions.length,
38594
+ }), {
38595
+ headers: { 'Content-Type': 'application/json' },
37672
38596
  });
37673
38597
  }
37674
- serveStaticFile(path, response) {
37675
- // Serve static files for the UI
37676
- response.send('Not found', { code: 404 });
37677
- }
37678
- // ==================== Readme Implementation ====================
37679
- async getReadmeMarkdown() {
37680
- return `
37681
- # Spatial Awareness Plugin
37682
-
37683
- This plugin enables cross-camera object tracking across your entire NVR system.
37684
-
37685
- ## Features
37686
-
37687
- - **Cross-Camera Tracking**: Correlate objects as they move between cameras
37688
- - **Journey History**: Complete path history for each tracked object
37689
- - **Entry/Exit Detection**: Know when objects enter or leave your property
37690
- - **Visual Floor Plan**: Configure camera topology with a visual editor
37691
- - **MQTT Integration**: Export tracking data to Home Assistant
37692
- - **REST API**: Query tracked objects and journeys programmatically
37693
- - **Smart Alerts**: Get notified about property entry/exit, unusual paths, and more
37694
-
38598
+ handleConnectionSuggestionActionRequest(suggestionId, action, response) {
38599
+ if (!this.trackingEngine) {
38600
+ response.send(JSON.stringify({ error: 'Tracking engine not running' }), {
38601
+ code: 500,
38602
+ headers: { 'Content-Type': 'application/json' },
38603
+ });
38604
+ return;
38605
+ }
38606
+ if (action === 'accept') {
38607
+ const connection = this.trackingEngine.acceptConnectionSuggestion(suggestionId);
38608
+ if (connection) {
38609
+ // Save updated topology
38610
+ const topology = this.trackingEngine.getTopology();
38611
+ this.storage.setItem('topology', JSON.stringify(topology));
38612
+ response.send(JSON.stringify({ success: true, connection }), {
38613
+ headers: { 'Content-Type': 'application/json' },
38614
+ });
38615
+ }
38616
+ else {
38617
+ response.send(JSON.stringify({ error: 'Suggestion not found' }), {
38618
+ code: 404,
38619
+ headers: { 'Content-Type': 'application/json' },
38620
+ });
38621
+ }
38622
+ }
38623
+ else if (action === 'reject') {
38624
+ const success = this.trackingEngine.rejectConnectionSuggestion(suggestionId);
38625
+ if (success) {
38626
+ response.send(JSON.stringify({ success: true }), {
38627
+ headers: { 'Content-Type': 'application/json' },
38628
+ });
38629
+ }
38630
+ else {
38631
+ response.send(JSON.stringify({ error: 'Suggestion not found' }), {
38632
+ code: 404,
38633
+ headers: { 'Content-Type': 'application/json' },
38634
+ });
38635
+ }
38636
+ }
38637
+ else {
38638
+ response.send(JSON.stringify({ error: 'Invalid action' }), {
38639
+ code: 400,
38640
+ headers: { 'Content-Type': 'application/json' },
38641
+ });
38642
+ }
38643
+ }
38644
+ handleLiveTrackingRequest(response) {
38645
+ if (!this.trackingEngine) {
38646
+ response.send(JSON.stringify({ objects: [], timestamp: Date.now() }), {
38647
+ headers: { 'Content-Type': 'application/json' },
38648
+ });
38649
+ return;
38650
+ }
38651
+ const liveState = this.trackingEngine.getLiveTrackingState();
38652
+ response.send(JSON.stringify(liveState), {
38653
+ headers: { 'Content-Type': 'application/json' },
38654
+ });
38655
+ }
38656
+ handleJourneyPathRequest(globalId, response) {
38657
+ if (!this.trackingEngine) {
38658
+ response.send(JSON.stringify({ error: 'Tracking engine not running' }), {
38659
+ code: 500,
38660
+ headers: { 'Content-Type': 'application/json' },
38661
+ });
38662
+ return;
38663
+ }
38664
+ const journeyPath = this.trackingEngine.getJourneyPath(globalId);
38665
+ if (journeyPath) {
38666
+ response.send(JSON.stringify(journeyPath), {
38667
+ headers: { 'Content-Type': 'application/json' },
38668
+ });
38669
+ }
38670
+ else {
38671
+ response.send(JSON.stringify({ error: 'Object not found' }), {
38672
+ code: 404,
38673
+ headers: { 'Content-Type': 'application/json' },
38674
+ });
38675
+ }
38676
+ }
38677
+ // ==================== Training Mode Handlers ====================
38678
+ handleTrainingStartRequest(request, response) {
38679
+ if (!this.trackingEngine) {
38680
+ response.send(JSON.stringify({ error: 'Tracking engine not running. Configure topology first.' }), {
38681
+ code: 500,
38682
+ headers: { 'Content-Type': 'application/json' },
38683
+ });
38684
+ return;
38685
+ }
38686
+ try {
38687
+ let config;
38688
+ let trainerName;
38689
+ if (request.body) {
38690
+ const body = JSON.parse(request.body);
38691
+ trainerName = body.trainerName;
38692
+ config = body.config;
38693
+ }
38694
+ const session = this.trackingEngine.startTrainingSession(trainerName, config);
38695
+ response.send(JSON.stringify(session), {
38696
+ headers: { 'Content-Type': 'application/json' },
38697
+ });
38698
+ }
38699
+ catch (e) {
38700
+ response.send(JSON.stringify({ error: e.message }), {
38701
+ code: 500,
38702
+ headers: { 'Content-Type': 'application/json' },
38703
+ });
38704
+ }
38705
+ }
38706
+ handleTrainingPauseRequest(response) {
38707
+ if (!this.trackingEngine) {
38708
+ response.send(JSON.stringify({ error: 'Tracking engine not running' }), {
38709
+ code: 500,
38710
+ headers: { 'Content-Type': 'application/json' },
38711
+ });
38712
+ return;
38713
+ }
38714
+ const success = this.trackingEngine.pauseTrainingSession();
38715
+ if (success) {
38716
+ response.send(JSON.stringify({ success: true }), {
38717
+ headers: { 'Content-Type': 'application/json' },
38718
+ });
38719
+ }
38720
+ else {
38721
+ response.send(JSON.stringify({ error: 'No active training session to pause' }), {
38722
+ code: 400,
38723
+ headers: { 'Content-Type': 'application/json' },
38724
+ });
38725
+ }
38726
+ }
38727
+ handleTrainingResumeRequest(response) {
38728
+ if (!this.trackingEngine) {
38729
+ response.send(JSON.stringify({ error: 'Tracking engine not running' }), {
38730
+ code: 500,
38731
+ headers: { 'Content-Type': 'application/json' },
38732
+ });
38733
+ return;
38734
+ }
38735
+ const success = this.trackingEngine.resumeTrainingSession();
38736
+ if (success) {
38737
+ response.send(JSON.stringify({ success: true }), {
38738
+ headers: { 'Content-Type': 'application/json' },
38739
+ });
38740
+ }
38741
+ else {
38742
+ response.send(JSON.stringify({ error: 'No paused training session to resume' }), {
38743
+ code: 400,
38744
+ headers: { 'Content-Type': 'application/json' },
38745
+ });
38746
+ }
38747
+ }
38748
+ handleTrainingEndRequest(response) {
38749
+ if (!this.trackingEngine) {
38750
+ response.send(JSON.stringify({ error: 'Tracking engine not running' }), {
38751
+ code: 500,
38752
+ headers: { 'Content-Type': 'application/json' },
38753
+ });
38754
+ return;
38755
+ }
38756
+ const session = this.trackingEngine.endTrainingSession();
38757
+ if (session) {
38758
+ response.send(JSON.stringify(session), {
38759
+ headers: { 'Content-Type': 'application/json' },
38760
+ });
38761
+ }
38762
+ else {
38763
+ response.send(JSON.stringify({ error: 'No training session to end' }), {
38764
+ code: 400,
38765
+ headers: { 'Content-Type': 'application/json' },
38766
+ });
38767
+ }
38768
+ }
38769
+ handleTrainingStatusRequest(response) {
38770
+ if (!this.trackingEngine) {
38771
+ response.send(JSON.stringify({ state: 'idle', stats: null }), {
38772
+ headers: { 'Content-Type': 'application/json' },
38773
+ });
38774
+ return;
38775
+ }
38776
+ const status = this.trackingEngine.getTrainingStatus();
38777
+ if (status) {
38778
+ response.send(JSON.stringify(status), {
38779
+ headers: { 'Content-Type': 'application/json' },
38780
+ });
38781
+ }
38782
+ else {
38783
+ response.send(JSON.stringify({ state: 'idle', stats: null }), {
38784
+ headers: { 'Content-Type': 'application/json' },
38785
+ });
38786
+ }
38787
+ }
38788
+ handleTrainingLandmarkRequest(request, response) {
38789
+ if (!this.trackingEngine) {
38790
+ response.send(JSON.stringify({ error: 'Tracking engine not running' }), {
38791
+ code: 500,
38792
+ headers: { 'Content-Type': 'application/json' },
38793
+ });
38794
+ return;
38795
+ }
38796
+ try {
38797
+ const body = JSON.parse(request.body);
38798
+ const landmark = this.trackingEngine.markTrainingLandmark(body);
38799
+ if (landmark) {
38800
+ response.send(JSON.stringify({ success: true, landmark }), {
38801
+ headers: { 'Content-Type': 'application/json' },
38802
+ });
38803
+ }
38804
+ else {
38805
+ response.send(JSON.stringify({ error: 'No active training session' }), {
38806
+ code: 400,
38807
+ headers: { 'Content-Type': 'application/json' },
38808
+ });
38809
+ }
38810
+ }
38811
+ catch (e) {
38812
+ response.send(JSON.stringify({ error: 'Invalid request body' }), {
38813
+ code: 400,
38814
+ headers: { 'Content-Type': 'application/json' },
38815
+ });
38816
+ }
38817
+ }
38818
+ handleTrainingApplyRequest(response) {
38819
+ if (!this.trackingEngine) {
38820
+ response.send(JSON.stringify({ error: 'Tracking engine not running' }), {
38821
+ code: 500,
38822
+ headers: { 'Content-Type': 'application/json' },
38823
+ });
38824
+ return;
38825
+ }
38826
+ const result = this.trackingEngine.applyTrainingToTopology();
38827
+ if (result.success) {
38828
+ // Save the updated topology
38829
+ const topology = this.trackingEngine.getTopology();
38830
+ this.storage.setItem('topology', JSON.stringify(topology));
38831
+ }
38832
+ response.send(JSON.stringify(result), {
38833
+ headers: { 'Content-Type': 'application/json' },
38834
+ });
38835
+ }
38836
+ serveEditorUI(response) {
38837
+ response.send(editor_html_1.EDITOR_HTML, {
38838
+ headers: { 'Content-Type': 'text/html' },
38839
+ });
38840
+ }
38841
+ serveTrainingUI(response) {
38842
+ response.send(training_html_1.TRAINING_HTML, {
38843
+ headers: { 'Content-Type': 'text/html' },
38844
+ });
38845
+ }
38846
+ serveStaticFile(path, response) {
38847
+ // Serve static files for the UI
38848
+ response.send('Not found', { code: 404 });
38849
+ }
38850
+ // ==================== Readme Implementation ====================
38851
+ async getReadmeMarkdown() {
38852
+ return `
38853
+ # Spatial Awareness Plugin
38854
+
38855
+ This plugin enables cross-camera object tracking across your entire NVR system.
38856
+
38857
+ ## Features
38858
+
38859
+ - **Cross-Camera Tracking**: Correlate objects as they move between cameras
38860
+ - **Journey History**: Complete path history for each tracked object
38861
+ - **Entry/Exit Detection**: Know when objects enter or leave your property
38862
+ - **Visual Floor Plan**: Configure camera topology with a visual editor
38863
+ - **MQTT Integration**: Export tracking data to Home Assistant
38864
+ - **REST API**: Query tracked objects and journeys programmatically
38865
+ - **Smart Alerts**: Get notified about property entry/exit, unusual paths, and more
38866
+
37695
38867
  ## Setup
37696
38868
 
37697
38869
  1. **Add Cameras**: Select cameras with object detection in the plugin settings
@@ -38249,6 +39421,83 @@ function getJourneySummary(tracked) {
38249
39421
  }
38250
39422
 
38251
39423
 
39424
+ /***/ },
39425
+
39426
+ /***/ "./src/models/training.ts"
39427
+ /*!********************************!*\
39428
+ !*** ./src/models/training.ts ***!
39429
+ \********************************/
39430
+ (__unused_webpack_module, exports) {
39431
+
39432
+ "use strict";
39433
+
39434
+ /**
39435
+ * Training Mode Types
39436
+ *
39437
+ * These types support the guided training system where a user physically
39438
+ * walks around their property to train the system on camera positions,
39439
+ * transit times, overlaps, landmarks, and structures.
39440
+ */
39441
+ Object.defineProperty(exports, "__esModule", ({ value: true }));
39442
+ exports.DEFAULT_TRAINING_CONFIG = void 0;
39443
+ exports.createTrainingSession = createTrainingSession;
39444
+ exports.calculateTrainingStats = calculateTrainingStats;
39445
+ /** Default training configuration */
39446
+ exports.DEFAULT_TRAINING_CONFIG = {
39447
+ minDetectionConfidence: 0.7,
39448
+ maxTransitWait: 120, // 2 minutes
39449
+ autoDetectOverlaps: true,
39450
+ autoSuggestLandmarks: true,
39451
+ minOverlapDuration: 2, // 2 seconds
39452
+ };
39453
+ /** Create a new empty training session */
39454
+ function createTrainingSession(trainerName) {
39455
+ const now = Date.now();
39456
+ return {
39457
+ id: `training-${now}-${Math.random().toString(36).substr(2, 9)}`,
39458
+ state: 'idle',
39459
+ startedAt: now,
39460
+ updatedAt: now,
39461
+ trainerName,
39462
+ visits: [],
39463
+ transits: [],
39464
+ landmarks: [],
39465
+ overlaps: [],
39466
+ structures: [],
39467
+ stats: {
39468
+ totalDuration: 0,
39469
+ camerasVisited: 0,
39470
+ transitsRecorded: 0,
39471
+ landmarksMarked: 0,
39472
+ overlapsDetected: 0,
39473
+ structuresMarked: 0,
39474
+ averageTransitTime: 0,
39475
+ coveragePercentage: 0,
39476
+ },
39477
+ };
39478
+ }
39479
+ /** Calculate session statistics */
39480
+ function calculateTrainingStats(session, totalCameras) {
39481
+ const uniqueCameras = new Set(session.visits.map(v => v.cameraId));
39482
+ const transitTimes = session.transits.map(t => t.transitSeconds);
39483
+ const avgTransit = transitTimes.length > 0
39484
+ ? transitTimes.reduce((a, b) => a + b, 0) / transitTimes.length
39485
+ : 0;
39486
+ return {
39487
+ totalDuration: (session.completedAt || Date.now()) - session.startedAt,
39488
+ camerasVisited: uniqueCameras.size,
39489
+ transitsRecorded: session.transits.length,
39490
+ landmarksMarked: session.landmarks.length,
39491
+ overlapsDetected: session.overlaps.length,
39492
+ structuresMarked: session.structures.length,
39493
+ averageTransitTime: Math.round(avgTransit),
39494
+ coveragePercentage: totalCameras > 0
39495
+ ? Math.round((uniqueCameras.size / totalCameras) * 100)
39496
+ : 0,
39497
+ };
39498
+ }
39499
+
39500
+
38252
39501
  /***/ },
38253
39502
 
38254
39503
  /***/ "./src/state/tracking-state.ts"
@@ -38615,6 +39864,23 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
38615
39864
  </div>
38616
39865
  <div id="suggestions-list"></div>
38617
39866
  </div>
39867
+ <div class="section" id="connection-suggestions-section" style="display: none;">
39868
+ <div class="section-title">
39869
+ <span>Connection Suggestions</span>
39870
+ <button class="btn btn-small" onclick="loadConnectionSuggestions()">Refresh</button>
39871
+ </div>
39872
+ <div id="connection-suggestions-list"></div>
39873
+ </div>
39874
+ <div class="section" id="live-tracking-section">
39875
+ <div class="section-title">
39876
+ <span>Live Tracking</span>
39877
+ <label class="checkbox-group" style="font-size: 11px; font-weight: normal; text-transform: none;">
39878
+ <input type="checkbox" id="live-tracking-toggle" onchange="toggleLiveTracking(this.checked)">
39879
+ Enable
39880
+ </label>
39881
+ </div>
39882
+ <div id="live-tracking-list" style="max-height: 150px; overflow-y: auto;"></div>
39883
+ </div>
38618
39884
  </div>
38619
39885
  </div>
38620
39886
  <div class="editor">
@@ -38805,6 +40071,12 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
38805
40071
  let availableCameras = [];
38806
40072
  let landmarkTemplates = [];
38807
40073
  let pendingSuggestions = [];
40074
+ let connectionSuggestions = [];
40075
+ let liveTrackingData = { objects: [], timestamp: 0 };
40076
+ let liveTrackingEnabled = false;
40077
+ let liveTrackingInterval = null;
40078
+ let selectedJourneyId = null;
40079
+ let journeyPath = null;
38808
40080
  let isDrawing = false;
38809
40081
  let drawStart = null;
38810
40082
  let currentDrawing = null;
@@ -38817,6 +40089,7 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
38817
40089
  await loadAvailableCameras();
38818
40090
  await loadLandmarkTemplates();
38819
40091
  await loadSuggestions();
40092
+ await loadConnectionSuggestions();
38820
40093
  resizeCanvas();
38821
40094
  render();
38822
40095
  updateUI();
@@ -38916,22 +40189,148 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
38916
40189
  } catch (e) { console.error('Failed to reject suggestion:', e); }
38917
40190
  }
38918
40191
 
38919
- function openAddLandmarkModal() {
38920
- updateLandmarkSuggestions();
38921
- document.getElementById('add-landmark-modal').classList.add('active');
40192
+ // ==================== Connection Suggestions ====================
40193
+ async function loadConnectionSuggestions() {
40194
+ try {
40195
+ const response = await fetch('../api/connection-suggestions');
40196
+ if (response.ok) {
40197
+ const data = await response.json();
40198
+ connectionSuggestions = data.suggestions || [];
40199
+ updateConnectionSuggestionsUI();
40200
+ }
40201
+ } catch (e) { console.error('Failed to load connection suggestions:', e); }
38922
40202
  }
38923
40203
 
38924
- function updateLandmarkSuggestions() {
38925
- const type = document.getElementById('landmark-type-select').value;
38926
- const template = landmarkTemplates.find(t => t.type === type);
38927
- const container = document.getElementById('landmark-templates');
38928
- if (template) {
38929
- container.innerHTML = template.suggestions.map(s =>
38930
- '<button class="btn btn-small" onclick="setLandmarkName(\\'' + s + '\\')" style="margin: 2px;">' + s + '</button>'
38931
- ).join('');
38932
- } else {
38933
- container.innerHTML = '<span style="color: #666; font-size: 12px;">No templates for this type</span>';
38934
- }
40204
+ function updateConnectionSuggestionsUI() {
40205
+ const section = document.getElementById('connection-suggestions-section');
40206
+ const list = document.getElementById('connection-suggestions-list');
40207
+ if (connectionSuggestions.length === 0) {
40208
+ section.style.display = 'none';
40209
+ return;
40210
+ }
40211
+ section.style.display = 'block';
40212
+ list.innerHTML = connectionSuggestions.map(s =>
40213
+ '<div class="camera-item" style="display: flex; justify-content: space-between; align-items: center;">' +
40214
+ '<div><div class="camera-name">' + s.fromCameraName + ' → ' + s.toCameraName + '</div>' +
40215
+ '<div class="camera-info">' + Math.round(s.suggestedTransitTime.typical / 1000) + 's typical, ' +
40216
+ Math.round(s.confidence * 100) + '% confidence</div></div>' +
40217
+ '<div style="display: flex; gap: 5px;">' +
40218
+ '<button class="btn btn-small btn-primary" onclick="acceptConnectionSuggestion(\\'' + s.id + '\\')">Accept</button>' +
40219
+ '<button class="btn btn-small" onclick="rejectConnectionSuggestion(\\'' + s.id + '\\')">Reject</button>' +
40220
+ '</div></div>'
40221
+ ).join('');
40222
+ }
40223
+
40224
+ async function acceptConnectionSuggestion(id) {
40225
+ try {
40226
+ const response = await fetch('../api/connection-suggestions/' + encodeURIComponent(id) + '/accept', { method: 'POST' });
40227
+ if (response.ok) {
40228
+ const data = await response.json();
40229
+ if (data.connection) {
40230
+ topology.connections.push(data.connection);
40231
+ updateUI();
40232
+ render();
40233
+ }
40234
+ await loadConnectionSuggestions();
40235
+ setStatus('Connection accepted', 'success');
40236
+ }
40237
+ } catch (e) { console.error('Failed to accept connection suggestion:', e); }
40238
+ }
40239
+
40240
+ async function rejectConnectionSuggestion(id) {
40241
+ try {
40242
+ await fetch('../api/connection-suggestions/' + encodeURIComponent(id) + '/reject', { method: 'POST' });
40243
+ await loadConnectionSuggestions();
40244
+ setStatus('Connection suggestion rejected', 'success');
40245
+ } catch (e) { console.error('Failed to reject connection suggestion:', e); }
40246
+ }
40247
+
40248
+ // ==================== Live Tracking ====================
40249
+ function toggleLiveTracking(enabled) {
40250
+ liveTrackingEnabled = enabled;
40251
+ if (enabled) {
40252
+ loadLiveTracking();
40253
+ liveTrackingInterval = setInterval(loadLiveTracking, 2000); // Poll every 2 seconds
40254
+ } else {
40255
+ if (liveTrackingInterval) {
40256
+ clearInterval(liveTrackingInterval);
40257
+ liveTrackingInterval = null;
40258
+ }
40259
+ liveTrackingData = { objects: [], timestamp: 0 };
40260
+ selectedJourneyId = null;
40261
+ journeyPath = null;
40262
+ updateLiveTrackingUI();
40263
+ render();
40264
+ }
40265
+ }
40266
+
40267
+ async function loadLiveTracking() {
40268
+ try {
40269
+ const response = await fetch('../api/live-tracking');
40270
+ if (response.ok) {
40271
+ liveTrackingData = await response.json();
40272
+ updateLiveTrackingUI();
40273
+ render();
40274
+ }
40275
+ } catch (e) { console.error('Failed to load live tracking:', e); }
40276
+ }
40277
+
40278
+ function updateLiveTrackingUI() {
40279
+ const list = document.getElementById('live-tracking-list');
40280
+ if (liveTrackingData.objects.length === 0) {
40281
+ list.innerHTML = '<div style="color: #666; font-size: 12px; text-align: center; padding: 10px;">No active objects</div>';
40282
+ return;
40283
+ }
40284
+ list.innerHTML = liveTrackingData.objects.map(obj => {
40285
+ const isSelected = selectedJourneyId === obj.globalId;
40286
+ const ageSeconds = Math.round((Date.now() - obj.lastSeen) / 1000);
40287
+ const ageStr = ageSeconds < 60 ? ageSeconds + 's ago' : Math.round(ageSeconds / 60) + 'm ago';
40288
+ return '<div class="camera-item' + (isSelected ? ' selected' : '') + '" ' +
40289
+ 'onclick="selectTrackedObject(\\'' + obj.globalId + '\\')" ' +
40290
+ 'style="padding: 8px; cursor: pointer;">' +
40291
+ '<div class="camera-name" style="font-size: 12px;">' +
40292
+ (obj.className.charAt(0).toUpperCase() + obj.className.slice(1)) +
40293
+ (obj.label ? ' (' + obj.label + ')' : '') + '</div>' +
40294
+ '<div class="camera-info">' + obj.lastCameraName + ' • ' + ageStr + '</div>' +
40295
+ '</div>';
40296
+ }).join('');
40297
+ }
40298
+
40299
+ async function selectTrackedObject(globalId) {
40300
+ if (selectedJourneyId === globalId) {
40301
+ // Deselect
40302
+ selectedJourneyId = null;
40303
+ journeyPath = null;
40304
+ } else {
40305
+ selectedJourneyId = globalId;
40306
+ // Load journey path
40307
+ try {
40308
+ const response = await fetch('../api/journey-path/' + globalId);
40309
+ if (response.ok) {
40310
+ journeyPath = await response.json();
40311
+ }
40312
+ } catch (e) { console.error('Failed to load journey path:', e); }
40313
+ }
40314
+ updateLiveTrackingUI();
40315
+ render();
40316
+ }
40317
+
40318
+ function openAddLandmarkModal() {
40319
+ updateLandmarkSuggestions();
40320
+ document.getElementById('add-landmark-modal').classList.add('active');
40321
+ }
40322
+
40323
+ function updateLandmarkSuggestions() {
40324
+ const type = document.getElementById('landmark-type-select').value;
40325
+ const template = landmarkTemplates.find(t => t.type === type);
40326
+ const container = document.getElementById('landmark-templates');
40327
+ if (template) {
40328
+ container.innerHTML = template.suggestions.map(s =>
40329
+ '<button class="btn btn-small" onclick="setLandmarkName(\\'' + s + '\\')" style="margin: 2px;">' + s + '</button>'
40330
+ ).join('');
40331
+ } else {
40332
+ container.innerHTML = '<span style="color: #666; font-size: 12px;">No templates for this type</span>';
40333
+ }
38935
40334
  }
38936
40335
 
38937
40336
  function setLandmarkName(name) {
@@ -39139,6 +40538,112 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
39139
40538
  for (const camera of topology.cameras) {
39140
40539
  if (camera.floorPlanPosition) { drawCamera(camera); }
39141
40540
  }
40541
+
40542
+ // Draw journey path if selected
40543
+ if (journeyPath && journeyPath.segments.length > 0) {
40544
+ drawJourneyPath();
40545
+ }
40546
+
40547
+ // Draw live tracking objects
40548
+ if (liveTrackingEnabled && liveTrackingData.objects.length > 0) {
40549
+ drawLiveTrackingObjects();
40550
+ }
40551
+ }
40552
+
40553
+ function drawJourneyPath() {
40554
+ if (!journeyPath) return;
40555
+
40556
+ ctx.strokeStyle = '#ff6b6b';
40557
+ ctx.lineWidth = 3;
40558
+ ctx.setLineDash([8, 4]);
40559
+
40560
+ // Draw path segments
40561
+ for (const segment of journeyPath.segments) {
40562
+ if (segment.fromCamera.position && segment.toCamera.position) {
40563
+ ctx.beginPath();
40564
+ ctx.moveTo(segment.fromCamera.position.x, segment.fromCamera.position.y);
40565
+ ctx.lineTo(segment.toCamera.position.x, segment.toCamera.position.y);
40566
+ ctx.stroke();
40567
+
40568
+ // Draw timestamp indicator
40569
+ const midX = (segment.fromCamera.position.x + segment.toCamera.position.x) / 2;
40570
+ const midY = (segment.fromCamera.position.y + segment.toCamera.position.y) / 2;
40571
+ ctx.fillStyle = 'rgba(255, 107, 107, 0.9)';
40572
+ ctx.beginPath();
40573
+ ctx.arc(midX, midY, 4, 0, Math.PI * 2);
40574
+ ctx.fill();
40575
+ }
40576
+ }
40577
+
40578
+ ctx.setLineDash([]);
40579
+
40580
+ // Draw current location indicator
40581
+ if (journeyPath.currentLocation?.position) {
40582
+ const pos = journeyPath.currentLocation.position;
40583
+ // Pulsing dot effect
40584
+ const pulse = (Date.now() % 1000) / 1000;
40585
+ const radius = 10 + pulse * 5;
40586
+ const alpha = 1 - pulse * 0.5;
40587
+
40588
+ ctx.beginPath();
40589
+ ctx.arc(pos.x, pos.y, radius, 0, Math.PI * 2);
40590
+ ctx.fillStyle = 'rgba(255, 107, 107, ' + alpha + ')';
40591
+ ctx.fill();
40592
+
40593
+ ctx.beginPath();
40594
+ ctx.arc(pos.x, pos.y, 6, 0, Math.PI * 2);
40595
+ ctx.fillStyle = '#ff6b6b';
40596
+ ctx.fill();
40597
+ ctx.strokeStyle = '#fff';
40598
+ ctx.lineWidth = 2;
40599
+ ctx.stroke();
40600
+ }
40601
+ }
40602
+
40603
+ function drawLiveTrackingObjects() {
40604
+ const objectColors = {
40605
+ person: '#4caf50',
40606
+ car: '#2196f3',
40607
+ animal: '#ff9800',
40608
+ default: '#9c27b0'
40609
+ };
40610
+
40611
+ for (const obj of liveTrackingData.objects) {
40612
+ if (!obj.cameraPosition) continue;
40613
+
40614
+ // Skip if this is the selected journey object (drawn separately with path)
40615
+ if (obj.globalId === selectedJourneyId) continue;
40616
+
40617
+ const pos = obj.cameraPosition;
40618
+ const color = objectColors[obj.className] || objectColors.default;
40619
+ const ageSeconds = (Date.now() - obj.lastSeen) / 1000;
40620
+
40621
+ // Fade old objects
40622
+ const alpha = Math.max(0.3, 1 - ageSeconds / 60);
40623
+
40624
+ // Draw object indicator
40625
+ ctx.beginPath();
40626
+ ctx.arc(pos.x, pos.y, 12, 0, Math.PI * 2);
40627
+ ctx.fillStyle = color.replace(')', ', ' + alpha + ')').replace('rgb', 'rgba');
40628
+ ctx.fill();
40629
+ ctx.strokeStyle = 'rgba(255, 255, 255, ' + alpha + ')';
40630
+ ctx.lineWidth = 2;
40631
+ ctx.stroke();
40632
+
40633
+ // Draw class icon
40634
+ ctx.fillStyle = 'rgba(255, 255, 255, ' + alpha + ')';
40635
+ ctx.font = 'bold 10px sans-serif';
40636
+ ctx.textAlign = 'center';
40637
+ ctx.textBaseline = 'middle';
40638
+ const icon = obj.className === 'person' ? 'P' : obj.className === 'car' ? 'C' : obj.className === 'animal' ? 'A' : '?';
40639
+ ctx.fillText(icon, pos.x, pos.y);
40640
+
40641
+ // Draw label below
40642
+ if (obj.label) {
40643
+ ctx.font = '9px sans-serif';
40644
+ ctx.fillText(obj.label.slice(0, 10), pos.x, pos.y + 20);
40645
+ }
40646
+ }
39142
40647
  }
39143
40648
 
39144
40649
  function drawLandmark(landmark) {
@@ -39525,6 +41030,1026 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
39525
41030
  </html>`;
39526
41031
 
39527
41032
 
41033
+ /***/ },
41034
+
41035
+ /***/ "./src/ui/training-html.ts"
41036
+ /*!*********************************!*\
41037
+ !*** ./src/ui/training-html.ts ***!
41038
+ \*********************************/
41039
+ (__unused_webpack_module, exports) {
41040
+
41041
+ "use strict";
41042
+
41043
+ /**
41044
+ * Training Mode UI - Mobile-optimized walkthrough interface
41045
+ * Designed for phone use while walking around property
41046
+ */
41047
+ Object.defineProperty(exports, "__esModule", ({ value: true }));
41048
+ exports.TRAINING_HTML = void 0;
41049
+ exports.TRAINING_HTML = `<!DOCTYPE html>
41050
+ <html lang="en">
41051
+ <head>
41052
+ <meta charset="UTF-8">
41053
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
41054
+ <meta name="apple-mobile-web-app-capable" content="yes">
41055
+ <meta name="mobile-web-app-capable" content="yes">
41056
+ <title>Spatial Awareness - Training Mode</title>
41057
+ <style>
41058
+ * { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
41059
+ html, body { height: 100%; overflow: hidden; }
41060
+ body {
41061
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
41062
+ background: #121212;
41063
+ color: rgba(255,255,255,0.87);
41064
+ min-height: 100vh;
41065
+ display: flex;
41066
+ flex-direction: column;
41067
+ }
41068
+
41069
+ /* Header */
41070
+ .header {
41071
+ background: rgba(255,255,255,0.03);
41072
+ padding: 12px 16px;
41073
+ display: flex;
41074
+ justify-content: space-between;
41075
+ align-items: center;
41076
+ border-bottom: 1px solid rgba(255,255,255,0.08);
41077
+ }
41078
+ .header h1 { font-size: 16px; font-weight: 500; }
41079
+ .header-status {
41080
+ display: flex;
41081
+ align-items: center;
41082
+ gap: 8px;
41083
+ font-size: 13px;
41084
+ }
41085
+ .status-badge {
41086
+ padding: 4px 10px;
41087
+ border-radius: 4px;
41088
+ font-size: 11px;
41089
+ font-weight: 500;
41090
+ text-transform: uppercase;
41091
+ }
41092
+ .status-badge.idle { background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.6); }
41093
+ .status-badge.active { background: #4fc3f7; color: #000; animation: pulse 2s infinite; }
41094
+ .status-badge.paused { background: #ffb74d; color: #000; }
41095
+ .status-badge.completed { background: #81c784; color: #000; }
41096
+ @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.7; } }
41097
+
41098
+ /* Main content area */
41099
+ .main-content {
41100
+ flex: 1;
41101
+ display: flex;
41102
+ flex-direction: column;
41103
+ padding: 12px;
41104
+ overflow-y: auto;
41105
+ gap: 12px;
41106
+ }
41107
+
41108
+ /* Camera detection card */
41109
+ .detection-card {
41110
+ background: rgba(255,255,255,0.03);
41111
+ border-radius: 8px;
41112
+ padding: 16px;
41113
+ border: 1px solid rgba(255,255,255,0.08);
41114
+ transition: all 0.3s;
41115
+ }
41116
+ .detection-card.detecting {
41117
+ border-color: #4fc3f7;
41118
+ background: rgba(79, 195, 247, 0.1);
41119
+ }
41120
+ .detection-card.in-transit {
41121
+ border-color: #ffb74d;
41122
+ background: rgba(255, 183, 77, 0.1);
41123
+ }
41124
+
41125
+ .detection-icon {
41126
+ width: 64px;
41127
+ height: 64px;
41128
+ border-radius: 8px;
41129
+ background: rgba(255,255,255,0.05);
41130
+ display: flex;
41131
+ align-items: center;
41132
+ justify-content: center;
41133
+ margin: 0 auto 12px;
41134
+ font-size: 28px;
41135
+ }
41136
+ .detection-card.detecting .detection-icon {
41137
+ background: #4fc3f7;
41138
+ animation: detectPulse 1.5s infinite;
41139
+ }
41140
+ @keyframes detectPulse {
41141
+ 0%, 100% { transform: scale(1); }
41142
+ 50% { transform: scale(1.03); }
41143
+ }
41144
+
41145
+ .detection-title {
41146
+ font-size: 18px;
41147
+ font-weight: 500;
41148
+ text-align: center;
41149
+ margin-bottom: 4px;
41150
+ }
41151
+ .detection-subtitle {
41152
+ font-size: 13px;
41153
+ color: rgba(255,255,255,0.5);
41154
+ text-align: center;
41155
+ }
41156
+ .detection-confidence {
41157
+ margin-top: 8px;
41158
+ text-align: center;
41159
+ font-size: 12px;
41160
+ color: rgba(255,255,255,0.4);
41161
+ }
41162
+
41163
+ /* Transit timer */
41164
+ .transit-timer {
41165
+ display: flex;
41166
+ align-items: center;
41167
+ justify-content: center;
41168
+ gap: 8px;
41169
+ margin-top: 12px;
41170
+ padding: 10px;
41171
+ background: rgba(0,0,0,0.2);
41172
+ border-radius: 6px;
41173
+ }
41174
+ .transit-timer-icon { font-size: 18px; }
41175
+ .transit-timer-text { font-size: 16px; font-weight: 500; }
41176
+ .transit-timer-from { font-size: 12px; color: rgba(255,255,255,0.5); }
41177
+
41178
+ /* Stats grid */
41179
+ .stats-grid {
41180
+ display: grid;
41181
+ grid-template-columns: repeat(3, 1fr);
41182
+ gap: 8px;
41183
+ }
41184
+ .stat-item {
41185
+ background: rgba(255,255,255,0.03);
41186
+ border-radius: 6px;
41187
+ padding: 12px 8px;
41188
+ text-align: center;
41189
+ border: 1px solid rgba(255,255,255,0.05);
41190
+ }
41191
+ .stat-value {
41192
+ font-size: 24px;
41193
+ font-weight: 500;
41194
+ color: #4fc3f7;
41195
+ }
41196
+ .stat-label {
41197
+ font-size: 10px;
41198
+ color: rgba(255,255,255,0.4);
41199
+ text-transform: uppercase;
41200
+ margin-top: 2px;
41201
+ }
41202
+
41203
+ /* Progress bar */
41204
+ .progress-section {
41205
+ background: rgba(255,255,255,0.03);
41206
+ border-radius: 6px;
41207
+ padding: 12px;
41208
+ border: 1px solid rgba(255,255,255,0.05);
41209
+ }
41210
+ .progress-header {
41211
+ display: flex;
41212
+ justify-content: space-between;
41213
+ margin-bottom: 8px;
41214
+ font-size: 13px;
41215
+ }
41216
+ .progress-bar {
41217
+ height: 6px;
41218
+ background: rgba(255,255,255,0.1);
41219
+ border-radius: 3px;
41220
+ overflow: hidden;
41221
+ }
41222
+ .progress-fill {
41223
+ height: 100%;
41224
+ background: #4fc3f7;
41225
+ border-radius: 3px;
41226
+ transition: width 0.5s;
41227
+ }
41228
+
41229
+ /* Suggestions */
41230
+ .suggestions-section {
41231
+ background: rgba(79, 195, 247, 0.08);
41232
+ border: 1px solid rgba(79, 195, 247, 0.2);
41233
+ border-radius: 6px;
41234
+ padding: 12px;
41235
+ }
41236
+ .suggestions-title {
41237
+ font-size: 11px;
41238
+ text-transform: uppercase;
41239
+ color: #4fc3f7;
41240
+ margin-bottom: 6px;
41241
+ font-weight: 500;
41242
+ }
41243
+ .suggestion-item {
41244
+ font-size: 13px;
41245
+ padding: 6px 0;
41246
+ border-bottom: 1px solid rgba(255,255,255,0.05);
41247
+ color: rgba(255,255,255,0.7);
41248
+ }
41249
+ .suggestion-item:last-child { border-bottom: none; }
41250
+ .suggestion-item::before {
41251
+ content: "→ ";
41252
+ color: #4fc3f7;
41253
+ }
41254
+
41255
+ /* Action buttons */
41256
+ .action-buttons {
41257
+ display: flex;
41258
+ gap: 8px;
41259
+ padding: 8px 0;
41260
+ }
41261
+ .btn {
41262
+ flex: 1;
41263
+ padding: 14px 16px;
41264
+ border: none;
41265
+ border-radius: 6px;
41266
+ font-size: 14px;
41267
+ font-weight: 500;
41268
+ cursor: pointer;
41269
+ transition: all 0.2s;
41270
+ display: flex;
41271
+ align-items: center;
41272
+ justify-content: center;
41273
+ gap: 6px;
41274
+ }
41275
+ .btn:active { transform: scale(0.98); }
41276
+ .btn-primary { background: #4fc3f7; color: #000; }
41277
+ .btn-secondary { background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.87); }
41278
+ .btn-danger { background: #ef5350; color: #fff; }
41279
+ .btn-warning { background: #ffb74d; color: #000; }
41280
+ .btn:disabled { opacity: 0.4; cursor: not-allowed; }
41281
+
41282
+ /* Full-width button */
41283
+ .btn-full { flex: none; width: 100%; }
41284
+
41285
+ /* Mark landmark/structure panel */
41286
+ .mark-panel {
41287
+ background: rgba(255,255,255,0.03);
41288
+ border-radius: 6px;
41289
+ padding: 16px;
41290
+ border: 1px solid rgba(255,255,255,0.05);
41291
+ }
41292
+ .mark-panel-title {
41293
+ font-size: 14px;
41294
+ font-weight: 500;
41295
+ margin-bottom: 12px;
41296
+ }
41297
+ .mark-type-grid {
41298
+ display: grid;
41299
+ grid-template-columns: repeat(4, 1fr);
41300
+ gap: 6px;
41301
+ margin-bottom: 12px;
41302
+ }
41303
+ .mark-type-btn {
41304
+ padding: 10px 6px;
41305
+ border: 1px solid rgba(255,255,255,0.1);
41306
+ border-radius: 6px;
41307
+ background: transparent;
41308
+ color: rgba(255,255,255,0.7);
41309
+ font-size: 10px;
41310
+ text-align: center;
41311
+ cursor: pointer;
41312
+ transition: all 0.2s;
41313
+ }
41314
+ .mark-type-btn.selected {
41315
+ border-color: #4fc3f7;
41316
+ background: rgba(79, 195, 247, 0.15);
41317
+ color: #fff;
41318
+ }
41319
+ .mark-type-btn .icon { font-size: 18px; margin-bottom: 2px; display: block; }
41320
+
41321
+ /* Input field */
41322
+ .input-group { margin-bottom: 12px; }
41323
+ .input-group label {
41324
+ display: block;
41325
+ font-size: 12px;
41326
+ color: rgba(255,255,255,0.5);
41327
+ margin-bottom: 4px;
41328
+ }
41329
+ .input-group input {
41330
+ width: 100%;
41331
+ padding: 12px;
41332
+ border: 1px solid rgba(255,255,255,0.1);
41333
+ border-radius: 6px;
41334
+ background: rgba(0,0,0,0.2);
41335
+ color: #fff;
41336
+ font-size: 14px;
41337
+ }
41338
+ .input-group input:focus {
41339
+ outline: none;
41340
+ border-color: #4fc3f7;
41341
+ }
41342
+
41343
+ /* History list */
41344
+ .history-section {
41345
+ background: rgba(255,255,255,0.03);
41346
+ border-radius: 6px;
41347
+ padding: 12px;
41348
+ max-height: 180px;
41349
+ overflow-y: auto;
41350
+ border: 1px solid rgba(255,255,255,0.05);
41351
+ }
41352
+ .history-title {
41353
+ font-size: 13px;
41354
+ font-weight: 500;
41355
+ margin-bottom: 8px;
41356
+ display: flex;
41357
+ justify-content: space-between;
41358
+ }
41359
+ .history-item {
41360
+ padding: 8px;
41361
+ border-bottom: 1px solid rgba(255,255,255,0.05);
41362
+ font-size: 13px;
41363
+ }
41364
+ .history-item:last-child { border-bottom: none; }
41365
+ .history-item-time {
41366
+ font-size: 10px;
41367
+ color: rgba(255,255,255,0.4);
41368
+ }
41369
+ .history-item-camera { color: #4fc3f7; font-weight: 500; }
41370
+ .history-item-transit { color: #ffb74d; }
41371
+
41372
+ /* Bottom action bar */
41373
+ .bottom-bar {
41374
+ background: rgba(255,255,255,0.03);
41375
+ padding: 12px 16px;
41376
+ padding-bottom: max(12px, env(safe-area-inset-bottom));
41377
+ border-top: 1px solid rgba(255,255,255,0.08);
41378
+ }
41379
+
41380
+ /* Tabs */
41381
+ .tabs {
41382
+ display: flex;
41383
+ background: rgba(0,0,0,0.2);
41384
+ border-radius: 6px;
41385
+ padding: 3px;
41386
+ margin-bottom: 12px;
41387
+ }
41388
+ .tab {
41389
+ flex: 1;
41390
+ padding: 10px;
41391
+ border: none;
41392
+ background: transparent;
41393
+ color: rgba(255,255,255,0.5);
41394
+ font-size: 13px;
41395
+ font-weight: 500;
41396
+ cursor: pointer;
41397
+ border-radius: 4px;
41398
+ transition: all 0.2s;
41399
+ }
41400
+ .tab.active {
41401
+ background: rgba(255,255,255,0.08);
41402
+ color: rgba(255,255,255,0.87);
41403
+ }
41404
+
41405
+ /* Tab content */
41406
+ .tab-content { display: none; }
41407
+ .tab-content.active { display: block; }
41408
+
41409
+ /* Apply results modal */
41410
+ .modal-overlay {
41411
+ display: none;
41412
+ position: fixed;
41413
+ top: 0;
41414
+ left: 0;
41415
+ right: 0;
41416
+ bottom: 0;
41417
+ background: rgba(0,0,0,0.85);
41418
+ z-index: 100;
41419
+ align-items: center;
41420
+ justify-content: center;
41421
+ padding: 16px;
41422
+ }
41423
+ .modal-overlay.active { display: flex; }
41424
+ .modal {
41425
+ background: #1e1e1e;
41426
+ border-radius: 8px;
41427
+ padding: 20px;
41428
+ max-width: 360px;
41429
+ width: 100%;
41430
+ border: 1px solid rgba(255,255,255,0.1);
41431
+ }
41432
+ .modal h2 { font-size: 18px; margin-bottom: 12px; font-weight: 500; }
41433
+ .modal-result-item {
41434
+ display: flex;
41435
+ justify-content: space-between;
41436
+ padding: 8px 0;
41437
+ border-bottom: 1px solid rgba(255,255,255,0.08);
41438
+ font-size: 13px;
41439
+ }
41440
+ .modal-result-value { color: #4fc3f7; font-weight: 500; }
41441
+ .modal-buttons { display: flex; gap: 8px; margin-top: 16px; }
41442
+
41443
+ /* Idle state */
41444
+ .idle-content {
41445
+ flex: 1;
41446
+ display: flex;
41447
+ flex-direction: column;
41448
+ align-items: center;
41449
+ justify-content: center;
41450
+ text-align: center;
41451
+ padding: 32px 16px;
41452
+ }
41453
+ .idle-icon {
41454
+ font-size: 56px;
41455
+ margin-bottom: 16px;
41456
+ opacity: 0.7;
41457
+ }
41458
+ .idle-title {
41459
+ font-size: 20px;
41460
+ font-weight: 500;
41461
+ margin-bottom: 8px;
41462
+ }
41463
+ .idle-desc {
41464
+ font-size: 14px;
41465
+ color: rgba(255,255,255,0.5);
41466
+ max-width: 280px;
41467
+ line-height: 1.5;
41468
+ }
41469
+ .idle-instructions {
41470
+ margin-top: 24px;
41471
+ text-align: left;
41472
+ background: rgba(255,255,255,0.03);
41473
+ border-radius: 6px;
41474
+ padding: 16px;
41475
+ border: 1px solid rgba(255,255,255,0.05);
41476
+ }
41477
+ .idle-instructions h3 {
41478
+ font-size: 12px;
41479
+ margin-bottom: 10px;
41480
+ color: #4fc3f7;
41481
+ font-weight: 500;
41482
+ }
41483
+ .idle-instructions ol {
41484
+ padding-left: 18px;
41485
+ font-size: 13px;
41486
+ line-height: 1.8;
41487
+ color: rgba(255,255,255,0.6);
41488
+ }
41489
+ </style>
41490
+ </head>
41491
+ <body>
41492
+ <div class="header">
41493
+ <h1>Training Mode</h1>
41494
+ <div class="header-status">
41495
+ <span class="status-badge" id="status-badge">Idle</span>
41496
+ </div>
41497
+ </div>
41498
+
41499
+ <!-- Idle State -->
41500
+ <div class="main-content" id="idle-content">
41501
+ <div class="idle-content">
41502
+ <div class="idle-icon">🚶</div>
41503
+ <div class="idle-title">Train Your System</div>
41504
+ <div class="idle-desc">Walk around your property to teach the system about camera positions, transit times, and landmarks.</div>
41505
+
41506
+ <div class="idle-instructions">
41507
+ <h3>How it works:</h3>
41508
+ <ol>
41509
+ <li>Tap <strong>Start Training</strong> below</li>
41510
+ <li>Walk to each camera on your property</li>
41511
+ <li>The system detects you automatically</li>
41512
+ <li>Mark landmarks as you encounter them</li>
41513
+ <li>End training when you're done</li>
41514
+ </ol>
41515
+ </div>
41516
+ </div>
41517
+
41518
+ <div class="bottom-bar">
41519
+ <button class="btn btn-primary btn-full" onclick="startTraining()">
41520
+ ▶ Start Training
41521
+ </button>
41522
+ </div>
41523
+ </div>
41524
+
41525
+ <!-- Active Training State -->
41526
+ <div class="main-content" id="active-content" style="display: none;">
41527
+ <!-- Detection Card -->
41528
+ <div class="detection-card" id="detection-card">
41529
+ <div class="detection-icon" id="detection-icon">👤</div>
41530
+ <div class="detection-title" id="detection-title">Waiting for detection...</div>
41531
+ <div class="detection-subtitle" id="detection-subtitle">Walk to any camera to begin</div>
41532
+ <div class="detection-confidence" id="detection-confidence"></div>
41533
+
41534
+ <div class="transit-timer" id="transit-timer" style="display: none;">
41535
+ <span class="transit-timer-icon">⏱</span>
41536
+ <span class="transit-timer-text" id="transit-time">0s</span>
41537
+ <span class="transit-timer-from" id="transit-from"></span>
41538
+ </div>
41539
+ </div>
41540
+
41541
+ <!-- Tabs -->
41542
+ <div class="tabs">
41543
+ <button class="tab active" onclick="switchTab('status')">Status</button>
41544
+ <button class="tab" onclick="switchTab('mark')">Mark</button>
41545
+ <button class="tab" onclick="switchTab('history')">History</button>
41546
+ </div>
41547
+
41548
+ <!-- Status Tab -->
41549
+ <div class="tab-content active" id="tab-status">
41550
+ <!-- Stats -->
41551
+ <div class="stats-grid">
41552
+ <div class="stat-item">
41553
+ <div class="stat-value" id="stat-cameras">0</div>
41554
+ <div class="stat-label">Cameras</div>
41555
+ </div>
41556
+ <div class="stat-item">
41557
+ <div class="stat-value" id="stat-transits">0</div>
41558
+ <div class="stat-label">Transits</div>
41559
+ </div>
41560
+ <div class="stat-item">
41561
+ <div class="stat-value" id="stat-landmarks">0</div>
41562
+ <div class="stat-label">Landmarks</div>
41563
+ </div>
41564
+ </div>
41565
+
41566
+ <!-- Progress -->
41567
+ <div class="progress-section" style="margin-top: 15px;">
41568
+ <div class="progress-header">
41569
+ <span>Coverage</span>
41570
+ <span id="progress-percent">0%</span>
41571
+ </div>
41572
+ <div class="progress-bar">
41573
+ <div class="progress-fill" id="progress-fill" style="width: 0%"></div>
41574
+ </div>
41575
+ </div>
41576
+
41577
+ <!-- Suggestions -->
41578
+ <div class="suggestions-section" style="margin-top: 15px;" id="suggestions-section">
41579
+ <div class="suggestions-title">Suggestions</div>
41580
+ <div id="suggestions-list">
41581
+ <div class="suggestion-item">Start walking to a camera</div>
41582
+ </div>
41583
+ </div>
41584
+ </div>
41585
+
41586
+ <!-- Mark Tab -->
41587
+ <div class="tab-content" id="tab-mark">
41588
+ <div class="mark-panel">
41589
+ <div class="mark-panel-title">Mark a Landmark</div>
41590
+ <div class="mark-type-grid" id="landmark-type-grid">
41591
+ <button class="mark-type-btn selected" data-type="mailbox" onclick="selectLandmarkType('mailbox')">
41592
+ <span class="icon">📬</span>
41593
+ Mailbox
41594
+ </button>
41595
+ <button class="mark-type-btn" data-type="garage" onclick="selectLandmarkType('garage')">
41596
+ <span class="icon">🏠</span>
41597
+ Garage
41598
+ </button>
41599
+ <button class="mark-type-btn" data-type="shed" onclick="selectLandmarkType('shed')">
41600
+ <span class="icon">🏚</span>
41601
+ Shed
41602
+ </button>
41603
+ <button class="mark-type-btn" data-type="tree" onclick="selectLandmarkType('tree')">
41604
+ <span class="icon">🌳</span>
41605
+ Tree
41606
+ </button>
41607
+ <button class="mark-type-btn" data-type="gate" onclick="selectLandmarkType('gate')">
41608
+ <span class="icon">🚪</span>
41609
+ Gate
41610
+ </button>
41611
+ <button class="mark-type-btn" data-type="driveway" onclick="selectLandmarkType('driveway')">
41612
+ <span class="icon">🛣</span>
41613
+ Driveway
41614
+ </button>
41615
+ <button class="mark-type-btn" data-type="pool" onclick="selectLandmarkType('pool')">
41616
+ <span class="icon">🏊</span>
41617
+ Pool
41618
+ </button>
41619
+ <button class="mark-type-btn" data-type="other" onclick="selectLandmarkType('other')">
41620
+ <span class="icon">📍</span>
41621
+ Other
41622
+ </button>
41623
+ </div>
41624
+
41625
+ <div class="input-group">
41626
+ <label>Landmark Name</label>
41627
+ <input type="text" id="landmark-name" placeholder="e.g., Front Mailbox">
41628
+ </div>
41629
+
41630
+ <button class="btn btn-primary btn-full" onclick="markLandmark()">
41631
+ 📍 Mark Landmark Here
41632
+ </button>
41633
+ </div>
41634
+ </div>
41635
+
41636
+ <!-- History Tab -->
41637
+ <div class="tab-content" id="tab-history">
41638
+ <div class="history-section">
41639
+ <div class="history-title">
41640
+ <span>Recent Activity</span>
41641
+ <span id="history-count">0 events</span>
41642
+ </div>
41643
+ <div id="history-list">
41644
+ <div class="history-item" style="color: rgba(255,255,255,0.4); text-align: center;">
41645
+ No activity yet
41646
+ </div>
41647
+ </div>
41648
+ </div>
41649
+ </div>
41650
+
41651
+ <!-- Bottom Actions -->
41652
+ <div class="bottom-bar">
41653
+ <div class="action-buttons">
41654
+ <button class="btn btn-warning" id="pause-btn" onclick="togglePause()">
41655
+ ⏸ Pause
41656
+ </button>
41657
+ <button class="btn btn-danger" onclick="endTraining()">
41658
+ ⏹ End
41659
+ </button>
41660
+ </div>
41661
+ </div>
41662
+ </div>
41663
+
41664
+ <!-- Completed State -->
41665
+ <div class="main-content" id="completed-content" style="display: none;">
41666
+ <div class="idle-content">
41667
+ <div class="idle-icon">✅</div>
41668
+ <div class="idle-title">Training Complete!</div>
41669
+ <div class="idle-desc">Review the results and apply them to your topology.</div>
41670
+ </div>
41671
+
41672
+ <!-- Final Stats -->
41673
+ <div class="stats-grid">
41674
+ <div class="stat-item">
41675
+ <div class="stat-value" id="final-cameras">0</div>
41676
+ <div class="stat-label">Cameras</div>
41677
+ </div>
41678
+ <div class="stat-item">
41679
+ <div class="stat-value" id="final-transits">0</div>
41680
+ <div class="stat-label">Transits</div>
41681
+ </div>
41682
+ <div class="stat-item">
41683
+ <div class="stat-value" id="final-landmarks">0</div>
41684
+ <div class="stat-label">Landmarks</div>
41685
+ </div>
41686
+ </div>
41687
+
41688
+ <div class="stats-grid" style="margin-top: 10px;">
41689
+ <div class="stat-item">
41690
+ <div class="stat-value" id="final-overlaps">0</div>
41691
+ <div class="stat-label">Overlaps</div>
41692
+ </div>
41693
+ <div class="stat-item">
41694
+ <div class="stat-value" id="final-avg-transit">0s</div>
41695
+ <div class="stat-label">Avg Transit</div>
41696
+ </div>
41697
+ <div class="stat-item">
41698
+ <div class="stat-value" id="final-coverage">0%</div>
41699
+ <div class="stat-label">Coverage</div>
41700
+ </div>
41701
+ </div>
41702
+
41703
+ <div class="bottom-bar" style="margin-top: auto;">
41704
+ <div class="action-buttons">
41705
+ <button class="btn btn-secondary" onclick="resetTraining()">
41706
+ ↻ Start Over
41707
+ </button>
41708
+ <button class="btn btn-primary" onclick="applyTraining()">
41709
+ ✓ Apply Results
41710
+ </button>
41711
+ </div>
41712
+ </div>
41713
+ </div>
41714
+
41715
+ <!-- Apply Results Modal -->
41716
+ <div class="modal-overlay" id="results-modal">
41717
+ <div class="modal">
41718
+ <h2>Training Applied!</h2>
41719
+ <div id="results-content">
41720
+ <div class="modal-result-item">
41721
+ <span>Connections Created</span>
41722
+ <span class="modal-result-value" id="result-connections">0</span>
41723
+ </div>
41724
+ <div class="modal-result-item">
41725
+ <span>Connections Updated</span>
41726
+ <span class="modal-result-value" id="result-updated">0</span>
41727
+ </div>
41728
+ <div class="modal-result-item">
41729
+ <span>Landmarks Added</span>
41730
+ <span class="modal-result-value" id="result-landmarks">0</span>
41731
+ </div>
41732
+ <div class="modal-result-item">
41733
+ <span>Zones Created</span>
41734
+ <span class="modal-result-value" id="result-zones">0</span>
41735
+ </div>
41736
+ </div>
41737
+ <div class="modal-buttons">
41738
+ <button class="btn btn-secondary" style="flex: 1;" onclick="closeResultsModal()">Close</button>
41739
+ <button class="btn btn-primary" style="flex: 1;" onclick="openEditor()">Open Editor</button>
41740
+ </div>
41741
+ </div>
41742
+ </div>
41743
+
41744
+ <script>
41745
+ let trainingState = 'idle'; // idle, active, paused, completed
41746
+ let session = null;
41747
+ let pollInterval = null;
41748
+ let transitInterval = null;
41749
+ let selectedLandmarkType = 'mailbox';
41750
+ let historyItems = [];
41751
+
41752
+ // Initialize
41753
+ async function init() {
41754
+ // Check if there's an existing session
41755
+ const status = await fetchTrainingStatus();
41756
+ if (status && (status.state === 'active' || status.state === 'paused')) {
41757
+ session = status;
41758
+ trainingState = status.state;
41759
+ updateUI();
41760
+ startPolling();
41761
+ }
41762
+ }
41763
+
41764
+ // API calls
41765
+ async function fetchTrainingStatus() {
41766
+ try {
41767
+ const response = await fetch('../api/training/status');
41768
+ if (response.ok) {
41769
+ return await response.json();
41770
+ }
41771
+ } catch (e) { console.error('Failed to fetch status:', e); }
41772
+ return null;
41773
+ }
41774
+
41775
+ async function startTraining() {
41776
+ try {
41777
+ const response = await fetch('../api/training/start', { method: 'POST' });
41778
+ if (response.ok) {
41779
+ session = await response.json();
41780
+ trainingState = 'active';
41781
+ updateUI();
41782
+ startPolling();
41783
+ addHistoryItem('Training started', 'start');
41784
+ }
41785
+ } catch (e) {
41786
+ console.error('Failed to start training:', e);
41787
+ alert('Failed to start training. Please try again.');
41788
+ }
41789
+ }
41790
+
41791
+ async function togglePause() {
41792
+ const endpoint = trainingState === 'active' ? 'pause' : 'resume';
41793
+ try {
41794
+ const response = await fetch('../api/training/' + endpoint, { method: 'POST' });
41795
+ if (response.ok) {
41796
+ trainingState = trainingState === 'active' ? 'paused' : 'active';
41797
+ updateUI();
41798
+ addHistoryItem('Training ' + (trainingState === 'paused' ? 'paused' : 'resumed'), 'control');
41799
+ }
41800
+ } catch (e) { console.error('Failed to toggle pause:', e); }
41801
+ }
41802
+
41803
+ async function endTraining() {
41804
+ if (!confirm('End training session?')) return;
41805
+ try {
41806
+ const response = await fetch('../api/training/end', { method: 'POST' });
41807
+ if (response.ok) {
41808
+ session = await response.json();
41809
+ trainingState = 'completed';
41810
+ stopPolling();
41811
+ updateUI();
41812
+ }
41813
+ } catch (e) { console.error('Failed to end training:', e); }
41814
+ }
41815
+
41816
+ async function applyTraining() {
41817
+ try {
41818
+ const response = await fetch('../api/training/apply', { method: 'POST' });
41819
+ if (response.ok) {
41820
+ const result = await response.json();
41821
+ document.getElementById('result-connections').textContent = result.connectionsCreated;
41822
+ document.getElementById('result-updated').textContent = result.connectionsUpdated;
41823
+ document.getElementById('result-landmarks').textContent = result.landmarksAdded;
41824
+ document.getElementById('result-zones').textContent = result.zonesCreated;
41825
+ document.getElementById('results-modal').classList.add('active');
41826
+ }
41827
+ } catch (e) {
41828
+ console.error('Failed to apply training:', e);
41829
+ alert('Failed to apply training results.');
41830
+ }
41831
+ }
41832
+
41833
+ function closeResultsModal() {
41834
+ document.getElementById('results-modal').classList.remove('active');
41835
+ }
41836
+
41837
+ function openEditor() {
41838
+ window.location.href = '../ui/editor';
41839
+ }
41840
+
41841
+ function resetTraining() {
41842
+ trainingState = 'idle';
41843
+ session = null;
41844
+ historyItems = [];
41845
+ updateUI();
41846
+ }
41847
+
41848
+ async function markLandmark() {
41849
+ const name = document.getElementById('landmark-name').value.trim();
41850
+ if (!name) {
41851
+ alert('Please enter a landmark name');
41852
+ return;
41853
+ }
41854
+
41855
+ const currentCameraId = session?.currentCamera?.id;
41856
+ const visibleFromCameras = currentCameraId ? [currentCameraId] : [];
41857
+
41858
+ try {
41859
+ const response = await fetch('../api/training/landmark', {
41860
+ method: 'POST',
41861
+ headers: { 'Content-Type': 'application/json' },
41862
+ body: JSON.stringify({
41863
+ name,
41864
+ type: selectedLandmarkType,
41865
+ visibleFromCameras,
41866
+ position: { x: 50, y: 50 }, // Will be refined when applied
41867
+ })
41868
+ });
41869
+ if (response.ok) {
41870
+ document.getElementById('landmark-name').value = '';
41871
+ addHistoryItem('Marked: ' + name + ' (' + selectedLandmarkType + ')', 'landmark');
41872
+ // Refresh status
41873
+ const status = await fetchTrainingStatus();
41874
+ if (status) {
41875
+ session = status;
41876
+ updateStatsUI();
41877
+ }
41878
+ }
41879
+ } catch (e) { console.error('Failed to mark landmark:', e); }
41880
+ }
41881
+
41882
+ function selectLandmarkType(type) {
41883
+ selectedLandmarkType = type;
41884
+ document.querySelectorAll('.mark-type-btn').forEach(btn => {
41885
+ btn.classList.toggle('selected', btn.dataset.type === type);
41886
+ });
41887
+ }
41888
+
41889
+ // Polling
41890
+ function startPolling() {
41891
+ if (pollInterval) clearInterval(pollInterval);
41892
+ pollInterval = setInterval(async () => {
41893
+ const status = await fetchTrainingStatus();
41894
+ if (status) {
41895
+ session = status;
41896
+ updateDetectionUI();
41897
+ updateStatsUI();
41898
+ updateSuggestionsUI();
41899
+ }
41900
+ }, 1000);
41901
+ }
41902
+
41903
+ function stopPolling() {
41904
+ if (pollInterval) {
41905
+ clearInterval(pollInterval);
41906
+ pollInterval = null;
41907
+ }
41908
+ if (transitInterval) {
41909
+ clearInterval(transitInterval);
41910
+ transitInterval = null;
41911
+ }
41912
+ }
41913
+
41914
+ // UI Updates
41915
+ function updateUI() {
41916
+ // Show/hide content sections
41917
+ document.getElementById('idle-content').style.display = trainingState === 'idle' ? 'flex' : 'none';
41918
+ document.getElementById('active-content').style.display = (trainingState === 'active' || trainingState === 'paused') ? 'flex' : 'none';
41919
+ document.getElementById('completed-content').style.display = trainingState === 'completed' ? 'flex' : 'none';
41920
+
41921
+ // Update status badge
41922
+ const badge = document.getElementById('status-badge');
41923
+ badge.textContent = trainingState.charAt(0).toUpperCase() + trainingState.slice(1);
41924
+ badge.className = 'status-badge ' + trainingState;
41925
+
41926
+ // Update pause button
41927
+ const pauseBtn = document.getElementById('pause-btn');
41928
+ if (pauseBtn) {
41929
+ pauseBtn.innerHTML = trainingState === 'paused' ? '▶ Resume' : '⏸ Pause';
41930
+ }
41931
+
41932
+ // Update completed stats
41933
+ if (trainingState === 'completed' && session) {
41934
+ document.getElementById('final-cameras').textContent = session.stats?.camerasVisited || 0;
41935
+ document.getElementById('final-transits').textContent = session.stats?.transitsRecorded || 0;
41936
+ document.getElementById('final-landmarks').textContent = session.stats?.landmarksMarked || 0;
41937
+ document.getElementById('final-overlaps').textContent = session.stats?.overlapsDetected || 0;
41938
+ document.getElementById('final-avg-transit').textContent = (session.stats?.averageTransitTime || 0) + 's';
41939
+ document.getElementById('final-coverage').textContent = (session.stats?.coveragePercentage || 0) + '%';
41940
+ }
41941
+ }
41942
+
41943
+ function updateDetectionUI() {
41944
+ if (!session) return;
41945
+
41946
+ const card = document.getElementById('detection-card');
41947
+ const icon = document.getElementById('detection-icon');
41948
+ const title = document.getElementById('detection-title');
41949
+ const subtitle = document.getElementById('detection-subtitle');
41950
+ const confidence = document.getElementById('detection-confidence');
41951
+ const transitTimer = document.getElementById('transit-timer');
41952
+
41953
+ if (session.currentCamera) {
41954
+ // Detected on a camera
41955
+ card.className = 'detection-card detecting';
41956
+ icon.textContent = '📷';
41957
+ title.textContent = session.currentCamera.name;
41958
+ subtitle.textContent = 'You are visible on this camera';
41959
+ confidence.textContent = 'Confidence: ' + Math.round(session.currentCamera.confidence * 100) + '%';
41960
+ transitTimer.style.display = 'none';
41961
+
41962
+ // Check for new camera detection to add to history
41963
+ const lastHistoryCamera = historyItems.find(h => h.type === 'camera');
41964
+ if (!lastHistoryCamera || lastHistoryCamera.cameraId !== session.currentCamera.id) {
41965
+ addHistoryItem('Detected on: ' + session.currentCamera.name, 'camera', session.currentCamera.id);
41966
+ }
41967
+ } else if (session.activeTransit) {
41968
+ // In transit
41969
+ card.className = 'detection-card in-transit';
41970
+ icon.textContent = '🚶';
41971
+ title.textContent = 'In Transit';
41972
+ subtitle.textContent = 'Walking to next camera...';
41973
+ confidence.textContent = '';
41974
+ transitTimer.style.display = 'flex';
41975
+ document.getElementById('transit-from').textContent = 'from ' + session.activeTransit.fromCameraName;
41976
+
41977
+ // Start transit timer if not already running
41978
+ if (!transitInterval) {
41979
+ transitInterval = setInterval(() => {
41980
+ if (session?.activeTransit) {
41981
+ document.getElementById('transit-time').textContent = session.activeTransit.elapsedSeconds + 's';
41982
+ }
41983
+ }, 1000);
41984
+ }
41985
+ } else {
41986
+ // Waiting
41987
+ card.className = 'detection-card';
41988
+ icon.textContent = '👤';
41989
+ title.textContent = 'Waiting for detection...';
41990
+ subtitle.textContent = 'Walk to any camera to begin';
41991
+ confidence.textContent = '';
41992
+ transitTimer.style.display = 'none';
41993
+
41994
+ if (transitInterval) {
41995
+ clearInterval(transitInterval);
41996
+ transitInterval = null;
41997
+ }
41998
+ }
41999
+ }
42000
+
42001
+ function updateStatsUI() {
42002
+ if (!session?.stats) return;
42003
+
42004
+ document.getElementById('stat-cameras').textContent = session.stats.camerasVisited;
42005
+ document.getElementById('stat-transits').textContent = session.stats.transitsRecorded;
42006
+ document.getElementById('stat-landmarks').textContent = session.stats.landmarksMarked;
42007
+ document.getElementById('progress-percent').textContent = session.stats.coveragePercentage + '%';
42008
+ document.getElementById('progress-fill').style.width = session.stats.coveragePercentage + '%';
42009
+ }
42010
+
42011
+ function updateSuggestionsUI() {
42012
+ if (!session?.suggestions || session.suggestions.length === 0) return;
42013
+
42014
+ const list = document.getElementById('suggestions-list');
42015
+ list.innerHTML = session.suggestions.map(s =>
42016
+ '<div class="suggestion-item">' + s + '</div>'
42017
+ ).join('');
42018
+ }
42019
+
42020
+ function addHistoryItem(text, type, cameraId) {
42021
+ const time = new Date().toLocaleTimeString();
42022
+ historyItems.unshift({ text, type, time, cameraId });
42023
+ if (historyItems.length > 50) historyItems.pop();
42024
+
42025
+ const list = document.getElementById('history-list');
42026
+ document.getElementById('history-count').textContent = historyItems.length + ' events';
42027
+
42028
+ list.innerHTML = historyItems.map(item => {
42029
+ let className = '';
42030
+ if (item.type === 'camera') className = 'history-item-camera';
42031
+ if (item.type === 'transit') className = 'history-item-transit';
42032
+ return '<div class="history-item"><span class="' + className + '">' + item.text + '</span>' +
42033
+ '<div class="history-item-time">' + item.time + '</div></div>';
42034
+ }).join('');
42035
+ }
42036
+
42037
+ function switchTab(tabName) {
42038
+ document.querySelectorAll('.tab').forEach(tab => {
42039
+ tab.classList.toggle('active', tab.textContent.toLowerCase() === tabName);
42040
+ });
42041
+ document.querySelectorAll('.tab-content').forEach(content => {
42042
+ content.classList.toggle('active', content.id === 'tab-' + tabName);
42043
+ });
42044
+ }
42045
+
42046
+ // Initialize on load
42047
+ init();
42048
+ </script>
42049
+ </body>
42050
+ </html>`;
42051
+
42052
+
39528
42053
  /***/ },
39529
42054
 
39530
42055
  /***/ "assert"