@blueharford/scrypted-spatial-awareness 0.3.0 → 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;
@@ -35657,6 +35658,13 @@ class TrackingEngine {
35657
35658
  connectionSuggestions = new Map();
35658
35659
  /** Minimum observations before suggesting a connection */
35659
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;
35660
35668
  constructor(topology, state, alertManager, config, console) {
35661
35669
  this.topology = topology;
35662
35670
  this.state = state;
@@ -35750,6 +35758,10 @@ class TrackingEngine {
35750
35758
  // Skip low-confidence detections
35751
35759
  if (detection.score < 0.5)
35752
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
+ }
35753
35765
  // Skip classes we're not tracking on this camera
35754
35766
  if (camera.trackClasses.length > 0 &&
35755
35767
  !camera.trackClasses.includes(detection.className)) {
@@ -36282,6 +36294,464 @@ class TrackingEngine {
36282
36294
  }
36283
36295
  return { segments, currentLocation };
36284
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
+ }
36285
36755
  }
36286
36756
  exports.TrackingEngine = TrackingEngine;
36287
36757
 
@@ -36989,6 +37459,7 @@ const global_tracker_sensor_1 = __webpack_require__(/*! ./devices/global-tracker
36989
37459
  const tracking_zone_1 = __webpack_require__(/*! ./devices/tracking-zone */ "./src/devices/tracking-zone.ts");
36990
37460
  const mqtt_publisher_1 = __webpack_require__(/*! ./integrations/mqtt-publisher */ "./src/integrations/mqtt-publisher.ts");
36991
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");
36992
37463
  const { deviceManager, systemManager } = sdk_1.default;
36993
37464
  const TRACKING_ZONE_PREFIX = 'tracking-zone:';
36994
37465
  const GLOBAL_TRACKER_ID = 'global-tracker';
@@ -37340,8 +37811,89 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
37340
37811
  // ==================== Settings Implementation ====================
37341
37812
  async getSettings() {
37342
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
+ });
37343
37895
  // Topology editor button that opens modal overlay (appended to body for proper z-index)
37344
- 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);})()`;
37345
37897
  settings.push({
37346
37898
  key: 'topologyEditor',
37347
37899
  title: 'Topology Editor',
@@ -37349,39 +37901,41 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
37349
37901
  value: `
37350
37902
  <style>
37351
37903
  .sa-open-btn {
37352
- background: linear-gradient(135deg, #e94560 0%, #0f3460 100%);
37353
- color: white;
37904
+ background: #4fc3f7;
37905
+ color: #000;
37354
37906
  border: none;
37355
- padding: 14px 28px;
37356
- border-radius: 8px;
37357
- font-size: 15px;
37358
- font-weight: 600;
37907
+ padding: 10px 20px;
37908
+ border-radius: 4px;
37909
+ font-size: 14px;
37910
+ font-weight: 500;
37359
37911
  cursor: pointer;
37360
37912
  display: inline-flex;
37361
37913
  align-items: center;
37362
- gap: 10px;
37363
- transition: transform 0.2s, box-shadow 0.2s;
37914
+ gap: 8px;
37915
+ transition: background 0.2s;
37916
+ font-family: inherit;
37364
37917
  }
37365
37918
  .sa-open-btn:hover {
37366
- transform: translateY(-2px);
37367
- box-shadow: 0 8px 20px rgba(233, 69, 96, 0.4);
37919
+ background: #81d4fa;
37368
37920
  }
37369
37921
  .sa-btn-container {
37370
- padding: 20px;
37371
- background: #16213e;
37372
- border-radius: 8px;
37922
+ padding: 16px;
37923
+ background: rgba(255,255,255,0.03);
37924
+ border-radius: 4px;
37373
37925
  text-align: center;
37926
+ border: 1px solid rgba(255,255,255,0.08);
37374
37927
  }
37375
37928
  .sa-btn-desc {
37376
- color: #888;
37377
- margin-bottom: 15px;
37378
- font-size: 14px;
37929
+ color: rgba(255,255,255,0.6);
37930
+ margin-bottom: 12px;
37931
+ font-size: 13px;
37932
+ font-family: inherit;
37379
37933
  }
37380
37934
  </style>
37381
37935
  <div class="sa-btn-container">
37382
37936
  <p class="sa-btn-desc">Configure camera positions, connections, and transit times</p>
37383
37937
  <button class="sa-open-btn" onclick="${onclickCode}">
37384
- <span>&#9881;</span> Open Topology Editor
37938
+ Open Topology Editor
37385
37939
  </button>
37386
37940
  </div>
37387
37941
  `,
@@ -37610,17 +38164,42 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
37610
38164
  const globalId = path.split('/').pop();
37611
38165
  return this.handleJourneyPathRequest(globalId, response);
37612
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
+ }
37613
38189
  // UI Routes
37614
38190
  if (path.endsWith('/ui/editor') || path.endsWith('/ui/editor/')) {
37615
38191
  return this.serveEditorUI(response);
37616
38192
  }
38193
+ if (path.endsWith('/ui/training') || path.endsWith('/ui/training/')) {
38194
+ return this.serveTrainingUI(response);
38195
+ }
37617
38196
  if (path.includes('/ui/')) {
37618
38197
  return this.serveStaticFile(path, response);
37619
38198
  }
37620
38199
  // Default: return info page
37621
38200
  response.send(JSON.stringify({
37622
38201
  name: 'Spatial Awareness Plugin',
37623
- version: '0.3.0',
38202
+ version: '0.4.0',
37624
38203
  endpoints: {
37625
38204
  api: {
37626
38205
  trackedObjects: '/api/tracked-objects',
@@ -37632,9 +38211,19 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
37632
38211
  liveTracking: '/api/live-tracking',
37633
38212
  connectionSuggestions: '/api/connection-suggestions',
37634
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
+ },
37635
38223
  },
37636
38224
  ui: {
37637
38225
  editor: '/ui/editor',
38226
+ training: '/ui/training',
37638
38227
  },
37639
38228
  },
37640
38229
  }), {
@@ -38085,11 +38674,175 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
38085
38674
  });
38086
38675
  }
38087
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
+ }
38088
38836
  serveEditorUI(response) {
38089
38837
  response.send(editor_html_1.EDITOR_HTML, {
38090
38838
  headers: { 'Content-Type': 'text/html' },
38091
38839
  });
38092
38840
  }
38841
+ serveTrainingUI(response) {
38842
+ response.send(training_html_1.TRAINING_HTML, {
38843
+ headers: { 'Content-Type': 'text/html' },
38844
+ });
38845
+ }
38093
38846
  serveStaticFile(path, response) {
38094
38847
  // Serve static files for the UI
38095
38848
  response.send('Not found', { code: 404 });
@@ -38668,6 +39421,83 @@ function getJourneySummary(tracked) {
38668
39421
  }
38669
39422
 
38670
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
+
38671
39501
  /***/ },
38672
39502
 
38673
39503
  /***/ "./src/state/tracking-state.ts"
@@ -40200,6 +41030,1026 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
40200
41030
  </html>`;
40201
41031
 
40202
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
+
40203
42053
  /***/ },
40204
42054
 
40205
42055
  /***/ "assert"