@blueharford/scrypted-spatial-awareness 0.3.0 → 0.4.1

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';
@@ -37339,9 +37810,98 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
37339
37810
  }
37340
37811
  // ==================== Settings Implementation ====================
37341
37812
  async getSettings() {
37342
- const settings = await this.storageSettings.getSettings();
37813
+ const baseSettings = await this.storageSettings.getSettings();
37814
+ // Build settings in desired order
37815
+ const settings = [];
37816
+ // Helper to find and add settings from baseSettings by group
37817
+ const addGroup = (group) => {
37818
+ baseSettings.filter(s => s.group === group).forEach(s => settings.push(s));
37819
+ };
37820
+ // ==================== 1. Getting Started ====================
37821
+ // Training Mode button that opens mobile-friendly training UI in modal
37822
+ 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);})()`;
37823
+ settings.push({
37824
+ key: 'trainingMode',
37825
+ title: 'Training Mode',
37826
+ type: 'html',
37827
+ value: `
37828
+ <style>
37829
+ .sa-training-container {
37830
+ padding: 16px;
37831
+ background: rgba(255,255,255,0.03);
37832
+ border-radius: 4px;
37833
+ border: 1px solid rgba(255,255,255,0.08);
37834
+ }
37835
+ .sa-training-title {
37836
+ color: #4fc3f7;
37837
+ font-size: 14px;
37838
+ font-weight: 500;
37839
+ margin-bottom: 8px;
37840
+ font-family: inherit;
37841
+ }
37842
+ .sa-training-desc {
37843
+ color: rgba(255,255,255,0.6);
37844
+ margin-bottom: 12px;
37845
+ font-size: 13px;
37846
+ line-height: 1.5;
37847
+ font-family: inherit;
37848
+ }
37849
+ .sa-training-btn {
37850
+ background: #4fc3f7;
37851
+ color: #000;
37852
+ border: none;
37853
+ padding: 10px 20px;
37854
+ border-radius: 4px;
37855
+ font-size: 14px;
37856
+ font-weight: 500;
37857
+ cursor: pointer;
37858
+ display: inline-flex;
37859
+ align-items: center;
37860
+ gap: 8px;
37861
+ transition: background 0.2s;
37862
+ font-family: inherit;
37863
+ }
37864
+ .sa-training-btn:hover {
37865
+ background: #81d4fa;
37866
+ }
37867
+ .sa-training-steps {
37868
+ color: rgba(255,255,255,0.5);
37869
+ font-size: 12px;
37870
+ margin-top: 12px;
37871
+ padding-top: 12px;
37872
+ border-top: 1px solid rgba(255,255,255,0.05);
37873
+ font-family: inherit;
37874
+ }
37875
+ .sa-training-steps ol {
37876
+ margin: 6px 0 0 16px;
37877
+ padding: 0;
37878
+ }
37879
+ .sa-training-steps li {
37880
+ margin-bottom: 2px;
37881
+ }
37882
+ </style>
37883
+ <div class="sa-training-container">
37884
+ <div class="sa-training-title">Guided Property Training</div>
37885
+ <p class="sa-training-desc">Walk your property while the system learns your camera layout, transit times, and landmarks automatically.</p>
37886
+ <button class="sa-training-btn" onclick="${trainingOnclickCode}">
37887
+ Start Training Mode
37888
+ </button>
37889
+ <div class="sa-training-steps">
37890
+ <strong>How it works:</strong>
37891
+ <ol>
37892
+ <li>Start training and walk to each camera</li>
37893
+ <li>System auto-detects you and records transit times</li>
37894
+ <li>Mark landmarks as you encounter them</li>
37895
+ <li>Apply results to generate your topology</li>
37896
+ </ol>
37897
+ </div>
37898
+ </div>
37899
+ `,
37900
+ group: 'Getting Started',
37901
+ });
37902
+ // ==================== 2. Topology ====================
37343
37903
  // 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);})()`;
37904
+ 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
37905
  settings.push({
37346
37906
  key: 'topologyEditor',
37347
37907
  title: 'Topology Editor',
@@ -37349,44 +37909,49 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
37349
37909
  value: `
37350
37910
  <style>
37351
37911
  .sa-open-btn {
37352
- background: linear-gradient(135deg, #e94560 0%, #0f3460 100%);
37353
- color: white;
37912
+ background: #4fc3f7;
37913
+ color: #000;
37354
37914
  border: none;
37355
- padding: 14px 28px;
37356
- border-radius: 8px;
37357
- font-size: 15px;
37358
- font-weight: 600;
37915
+ padding: 10px 20px;
37916
+ border-radius: 4px;
37917
+ font-size: 14px;
37918
+ font-weight: 500;
37359
37919
  cursor: pointer;
37360
37920
  display: inline-flex;
37361
37921
  align-items: center;
37362
- gap: 10px;
37363
- transition: transform 0.2s, box-shadow 0.2s;
37922
+ gap: 8px;
37923
+ transition: background 0.2s;
37924
+ font-family: inherit;
37364
37925
  }
37365
37926
  .sa-open-btn:hover {
37366
- transform: translateY(-2px);
37367
- box-shadow: 0 8px 20px rgba(233, 69, 96, 0.4);
37927
+ background: #81d4fa;
37368
37928
  }
37369
37929
  .sa-btn-container {
37370
- padding: 20px;
37371
- background: #16213e;
37372
- border-radius: 8px;
37930
+ padding: 16px;
37931
+ background: rgba(255,255,255,0.03);
37932
+ border-radius: 4px;
37373
37933
  text-align: center;
37934
+ border: 1px solid rgba(255,255,255,0.08);
37374
37935
  }
37375
37936
  .sa-btn-desc {
37376
- color: #888;
37377
- margin-bottom: 15px;
37378
- font-size: 14px;
37937
+ color: rgba(255,255,255,0.6);
37938
+ margin-bottom: 12px;
37939
+ font-size: 13px;
37940
+ font-family: inherit;
37379
37941
  }
37380
37942
  </style>
37381
37943
  <div class="sa-btn-container">
37382
37944
  <p class="sa-btn-desc">Configure camera positions, connections, and transit times</p>
37383
37945
  <button class="sa-open-btn" onclick="${onclickCode}">
37384
- <span>&#9881;</span> Open Topology Editor
37946
+ Open Topology Editor
37385
37947
  </button>
37386
37948
  </div>
37387
37949
  `,
37388
37950
  group: 'Topology',
37389
37951
  });
37952
+ // ==================== 3. Cameras ====================
37953
+ addGroup('Cameras');
37954
+ // ==================== 4. Status ====================
37390
37955
  // Add status display
37391
37956
  const activeCount = this.trackingState.getActiveCount();
37392
37957
  const topologyJson = this.storage.getItem('topology');
@@ -37430,6 +37995,12 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
37430
37995
  group: 'Status',
37431
37996
  });
37432
37997
  }
37998
+ // ==================== 5. Tracking ====================
37999
+ addGroup('Tracking');
38000
+ // ==================== 6. AI & Spatial Reasoning ====================
38001
+ addGroup('AI & Spatial Reasoning');
38002
+ // ==================== 7. Alerts ====================
38003
+ addGroup('Alerts');
37433
38004
  // Add alert rules configuration UI
37434
38005
  const alertRules = this.alertManager.getRules();
37435
38006
  const rulesHtml = this.generateAlertRulesHtml(alertRules);
@@ -37440,6 +38011,8 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
37440
38011
  value: rulesHtml,
37441
38012
  group: 'Alerts',
37442
38013
  });
38014
+ // ==================== 8. MQTT Integration ====================
38015
+ addGroup('MQTT Integration');
37443
38016
  return settings;
37444
38017
  }
37445
38018
  generateAlertRulesHtml(rules) {
@@ -37610,17 +38183,42 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
37610
38183
  const globalId = path.split('/').pop();
37611
38184
  return this.handleJourneyPathRequest(globalId, response);
37612
38185
  }
38186
+ // Training Mode endpoints
38187
+ if (path.endsWith('/api/training/start')) {
38188
+ return this.handleTrainingStartRequest(request, response);
38189
+ }
38190
+ if (path.endsWith('/api/training/pause')) {
38191
+ return this.handleTrainingPauseRequest(response);
38192
+ }
38193
+ if (path.endsWith('/api/training/resume')) {
38194
+ return this.handleTrainingResumeRequest(response);
38195
+ }
38196
+ if (path.endsWith('/api/training/end')) {
38197
+ return this.handleTrainingEndRequest(response);
38198
+ }
38199
+ if (path.endsWith('/api/training/status')) {
38200
+ return this.handleTrainingStatusRequest(response);
38201
+ }
38202
+ if (path.endsWith('/api/training/landmark')) {
38203
+ return this.handleTrainingLandmarkRequest(request, response);
38204
+ }
38205
+ if (path.endsWith('/api/training/apply')) {
38206
+ return this.handleTrainingApplyRequest(response);
38207
+ }
37613
38208
  // UI Routes
37614
38209
  if (path.endsWith('/ui/editor') || path.endsWith('/ui/editor/')) {
37615
38210
  return this.serveEditorUI(response);
37616
38211
  }
38212
+ if (path.endsWith('/ui/training') || path.endsWith('/ui/training/')) {
38213
+ return this.serveTrainingUI(response);
38214
+ }
37617
38215
  if (path.includes('/ui/')) {
37618
38216
  return this.serveStaticFile(path, response);
37619
38217
  }
37620
38218
  // Default: return info page
37621
38219
  response.send(JSON.stringify({
37622
38220
  name: 'Spatial Awareness Plugin',
37623
- version: '0.3.0',
38221
+ version: '0.4.0',
37624
38222
  endpoints: {
37625
38223
  api: {
37626
38224
  trackedObjects: '/api/tracked-objects',
@@ -37632,9 +38230,19 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
37632
38230
  liveTracking: '/api/live-tracking',
37633
38231
  connectionSuggestions: '/api/connection-suggestions',
37634
38232
  landmarkSuggestions: '/api/landmark-suggestions',
38233
+ training: {
38234
+ start: '/api/training/start',
38235
+ pause: '/api/training/pause',
38236
+ resume: '/api/training/resume',
38237
+ end: '/api/training/end',
38238
+ status: '/api/training/status',
38239
+ landmark: '/api/training/landmark',
38240
+ apply: '/api/training/apply',
38241
+ },
37635
38242
  },
37636
38243
  ui: {
37637
38244
  editor: '/ui/editor',
38245
+ training: '/ui/training',
37638
38246
  },
37639
38247
  },
37640
38248
  }), {
@@ -38085,11 +38693,175 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
38085
38693
  });
38086
38694
  }
38087
38695
  }
38696
+ // ==================== Training Mode Handlers ====================
38697
+ handleTrainingStartRequest(request, response) {
38698
+ if (!this.trackingEngine) {
38699
+ response.send(JSON.stringify({ error: 'Tracking engine not running. Configure topology first.' }), {
38700
+ code: 500,
38701
+ headers: { 'Content-Type': 'application/json' },
38702
+ });
38703
+ return;
38704
+ }
38705
+ try {
38706
+ let config;
38707
+ let trainerName;
38708
+ if (request.body) {
38709
+ const body = JSON.parse(request.body);
38710
+ trainerName = body.trainerName;
38711
+ config = body.config;
38712
+ }
38713
+ const session = this.trackingEngine.startTrainingSession(trainerName, config);
38714
+ response.send(JSON.stringify(session), {
38715
+ headers: { 'Content-Type': 'application/json' },
38716
+ });
38717
+ }
38718
+ catch (e) {
38719
+ response.send(JSON.stringify({ error: e.message }), {
38720
+ code: 500,
38721
+ headers: { 'Content-Type': 'application/json' },
38722
+ });
38723
+ }
38724
+ }
38725
+ handleTrainingPauseRequest(response) {
38726
+ if (!this.trackingEngine) {
38727
+ response.send(JSON.stringify({ error: 'Tracking engine not running' }), {
38728
+ code: 500,
38729
+ headers: { 'Content-Type': 'application/json' },
38730
+ });
38731
+ return;
38732
+ }
38733
+ const success = this.trackingEngine.pauseTrainingSession();
38734
+ if (success) {
38735
+ response.send(JSON.stringify({ success: true }), {
38736
+ headers: { 'Content-Type': 'application/json' },
38737
+ });
38738
+ }
38739
+ else {
38740
+ response.send(JSON.stringify({ error: 'No active training session to pause' }), {
38741
+ code: 400,
38742
+ headers: { 'Content-Type': 'application/json' },
38743
+ });
38744
+ }
38745
+ }
38746
+ handleTrainingResumeRequest(response) {
38747
+ if (!this.trackingEngine) {
38748
+ response.send(JSON.stringify({ error: 'Tracking engine not running' }), {
38749
+ code: 500,
38750
+ headers: { 'Content-Type': 'application/json' },
38751
+ });
38752
+ return;
38753
+ }
38754
+ const success = this.trackingEngine.resumeTrainingSession();
38755
+ if (success) {
38756
+ response.send(JSON.stringify({ success: true }), {
38757
+ headers: { 'Content-Type': 'application/json' },
38758
+ });
38759
+ }
38760
+ else {
38761
+ response.send(JSON.stringify({ error: 'No paused training session to resume' }), {
38762
+ code: 400,
38763
+ headers: { 'Content-Type': 'application/json' },
38764
+ });
38765
+ }
38766
+ }
38767
+ handleTrainingEndRequest(response) {
38768
+ if (!this.trackingEngine) {
38769
+ response.send(JSON.stringify({ error: 'Tracking engine not running' }), {
38770
+ code: 500,
38771
+ headers: { 'Content-Type': 'application/json' },
38772
+ });
38773
+ return;
38774
+ }
38775
+ const session = this.trackingEngine.endTrainingSession();
38776
+ if (session) {
38777
+ response.send(JSON.stringify(session), {
38778
+ headers: { 'Content-Type': 'application/json' },
38779
+ });
38780
+ }
38781
+ else {
38782
+ response.send(JSON.stringify({ error: 'No training session to end' }), {
38783
+ code: 400,
38784
+ headers: { 'Content-Type': 'application/json' },
38785
+ });
38786
+ }
38787
+ }
38788
+ handleTrainingStatusRequest(response) {
38789
+ if (!this.trackingEngine) {
38790
+ response.send(JSON.stringify({ state: 'idle', stats: null }), {
38791
+ headers: { 'Content-Type': 'application/json' },
38792
+ });
38793
+ return;
38794
+ }
38795
+ const status = this.trackingEngine.getTrainingStatus();
38796
+ if (status) {
38797
+ response.send(JSON.stringify(status), {
38798
+ headers: { 'Content-Type': 'application/json' },
38799
+ });
38800
+ }
38801
+ else {
38802
+ response.send(JSON.stringify({ state: 'idle', stats: null }), {
38803
+ headers: { 'Content-Type': 'application/json' },
38804
+ });
38805
+ }
38806
+ }
38807
+ handleTrainingLandmarkRequest(request, response) {
38808
+ if (!this.trackingEngine) {
38809
+ response.send(JSON.stringify({ error: 'Tracking engine not running' }), {
38810
+ code: 500,
38811
+ headers: { 'Content-Type': 'application/json' },
38812
+ });
38813
+ return;
38814
+ }
38815
+ try {
38816
+ const body = JSON.parse(request.body);
38817
+ const landmark = this.trackingEngine.markTrainingLandmark(body);
38818
+ if (landmark) {
38819
+ response.send(JSON.stringify({ success: true, landmark }), {
38820
+ headers: { 'Content-Type': 'application/json' },
38821
+ });
38822
+ }
38823
+ else {
38824
+ response.send(JSON.stringify({ error: 'No active training session' }), {
38825
+ code: 400,
38826
+ headers: { 'Content-Type': 'application/json' },
38827
+ });
38828
+ }
38829
+ }
38830
+ catch (e) {
38831
+ response.send(JSON.stringify({ error: 'Invalid request body' }), {
38832
+ code: 400,
38833
+ headers: { 'Content-Type': 'application/json' },
38834
+ });
38835
+ }
38836
+ }
38837
+ handleTrainingApplyRequest(response) {
38838
+ if (!this.trackingEngine) {
38839
+ response.send(JSON.stringify({ error: 'Tracking engine not running' }), {
38840
+ code: 500,
38841
+ headers: { 'Content-Type': 'application/json' },
38842
+ });
38843
+ return;
38844
+ }
38845
+ const result = this.trackingEngine.applyTrainingToTopology();
38846
+ if (result.success) {
38847
+ // Save the updated topology
38848
+ const topology = this.trackingEngine.getTopology();
38849
+ this.storage.setItem('topology', JSON.stringify(topology));
38850
+ }
38851
+ response.send(JSON.stringify(result), {
38852
+ headers: { 'Content-Type': 'application/json' },
38853
+ });
38854
+ }
38088
38855
  serveEditorUI(response) {
38089
38856
  response.send(editor_html_1.EDITOR_HTML, {
38090
38857
  headers: { 'Content-Type': 'text/html' },
38091
38858
  });
38092
38859
  }
38860
+ serveTrainingUI(response) {
38861
+ response.send(training_html_1.TRAINING_HTML, {
38862
+ headers: { 'Content-Type': 'text/html' },
38863
+ });
38864
+ }
38093
38865
  serveStaticFile(path, response) {
38094
38866
  // Serve static files for the UI
38095
38867
  response.send('Not found', { code: 404 });
@@ -38668,6 +39440,83 @@ function getJourneySummary(tracked) {
38668
39440
  }
38669
39441
 
38670
39442
 
39443
+ /***/ },
39444
+
39445
+ /***/ "./src/models/training.ts"
39446
+ /*!********************************!*\
39447
+ !*** ./src/models/training.ts ***!
39448
+ \********************************/
39449
+ (__unused_webpack_module, exports) {
39450
+
39451
+ "use strict";
39452
+
39453
+ /**
39454
+ * Training Mode Types
39455
+ *
39456
+ * These types support the guided training system where a user physically
39457
+ * walks around their property to train the system on camera positions,
39458
+ * transit times, overlaps, landmarks, and structures.
39459
+ */
39460
+ Object.defineProperty(exports, "__esModule", ({ value: true }));
39461
+ exports.DEFAULT_TRAINING_CONFIG = void 0;
39462
+ exports.createTrainingSession = createTrainingSession;
39463
+ exports.calculateTrainingStats = calculateTrainingStats;
39464
+ /** Default training configuration */
39465
+ exports.DEFAULT_TRAINING_CONFIG = {
39466
+ minDetectionConfidence: 0.7,
39467
+ maxTransitWait: 120, // 2 minutes
39468
+ autoDetectOverlaps: true,
39469
+ autoSuggestLandmarks: true,
39470
+ minOverlapDuration: 2, // 2 seconds
39471
+ };
39472
+ /** Create a new empty training session */
39473
+ function createTrainingSession(trainerName) {
39474
+ const now = Date.now();
39475
+ return {
39476
+ id: `training-${now}-${Math.random().toString(36).substr(2, 9)}`,
39477
+ state: 'idle',
39478
+ startedAt: now,
39479
+ updatedAt: now,
39480
+ trainerName,
39481
+ visits: [],
39482
+ transits: [],
39483
+ landmarks: [],
39484
+ overlaps: [],
39485
+ structures: [],
39486
+ stats: {
39487
+ totalDuration: 0,
39488
+ camerasVisited: 0,
39489
+ transitsRecorded: 0,
39490
+ landmarksMarked: 0,
39491
+ overlapsDetected: 0,
39492
+ structuresMarked: 0,
39493
+ averageTransitTime: 0,
39494
+ coveragePercentage: 0,
39495
+ },
39496
+ };
39497
+ }
39498
+ /** Calculate session statistics */
39499
+ function calculateTrainingStats(session, totalCameras) {
39500
+ const uniqueCameras = new Set(session.visits.map(v => v.cameraId));
39501
+ const transitTimes = session.transits.map(t => t.transitSeconds);
39502
+ const avgTransit = transitTimes.length > 0
39503
+ ? transitTimes.reduce((a, b) => a + b, 0) / transitTimes.length
39504
+ : 0;
39505
+ return {
39506
+ totalDuration: (session.completedAt || Date.now()) - session.startedAt,
39507
+ camerasVisited: uniqueCameras.size,
39508
+ transitsRecorded: session.transits.length,
39509
+ landmarksMarked: session.landmarks.length,
39510
+ overlapsDetected: session.overlaps.length,
39511
+ structuresMarked: session.structures.length,
39512
+ averageTransitTime: Math.round(avgTransit),
39513
+ coveragePercentage: totalCameras > 0
39514
+ ? Math.round((uniqueCameras.size / totalCameras) * 100)
39515
+ : 0,
39516
+ };
39517
+ }
39518
+
39519
+
38671
39520
  /***/ },
38672
39521
 
38673
39522
  /***/ "./src/state/tracking-state.ts"
@@ -40200,6 +41049,1026 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
40200
41049
  </html>`;
40201
41050
 
40202
41051
 
41052
+ /***/ },
41053
+
41054
+ /***/ "./src/ui/training-html.ts"
41055
+ /*!*********************************!*\
41056
+ !*** ./src/ui/training-html.ts ***!
41057
+ \*********************************/
41058
+ (__unused_webpack_module, exports) {
41059
+
41060
+ "use strict";
41061
+
41062
+ /**
41063
+ * Training Mode UI - Mobile-optimized walkthrough interface
41064
+ * Designed for phone use while walking around property
41065
+ */
41066
+ Object.defineProperty(exports, "__esModule", ({ value: true }));
41067
+ exports.TRAINING_HTML = void 0;
41068
+ exports.TRAINING_HTML = `<!DOCTYPE html>
41069
+ <html lang="en">
41070
+ <head>
41071
+ <meta charset="UTF-8">
41072
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
41073
+ <meta name="apple-mobile-web-app-capable" content="yes">
41074
+ <meta name="mobile-web-app-capable" content="yes">
41075
+ <title>Spatial Awareness - Training Mode</title>
41076
+ <style>
41077
+ * { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
41078
+ html, body { height: 100%; overflow: hidden; }
41079
+ body {
41080
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
41081
+ background: #121212;
41082
+ color: rgba(255,255,255,0.87);
41083
+ min-height: 100vh;
41084
+ display: flex;
41085
+ flex-direction: column;
41086
+ }
41087
+
41088
+ /* Header */
41089
+ .header {
41090
+ background: rgba(255,255,255,0.03);
41091
+ padding: 12px 16px;
41092
+ display: flex;
41093
+ justify-content: space-between;
41094
+ align-items: center;
41095
+ border-bottom: 1px solid rgba(255,255,255,0.08);
41096
+ }
41097
+ .header h1 { font-size: 16px; font-weight: 500; }
41098
+ .header-status {
41099
+ display: flex;
41100
+ align-items: center;
41101
+ gap: 8px;
41102
+ font-size: 13px;
41103
+ }
41104
+ .status-badge {
41105
+ padding: 4px 10px;
41106
+ border-radius: 4px;
41107
+ font-size: 11px;
41108
+ font-weight: 500;
41109
+ text-transform: uppercase;
41110
+ }
41111
+ .status-badge.idle { background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.6); }
41112
+ .status-badge.active { background: #4fc3f7; color: #000; animation: pulse 2s infinite; }
41113
+ .status-badge.paused { background: #ffb74d; color: #000; }
41114
+ .status-badge.completed { background: #81c784; color: #000; }
41115
+ @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.7; } }
41116
+
41117
+ /* Main content area */
41118
+ .main-content {
41119
+ flex: 1;
41120
+ display: flex;
41121
+ flex-direction: column;
41122
+ padding: 12px;
41123
+ overflow-y: auto;
41124
+ gap: 12px;
41125
+ }
41126
+
41127
+ /* Camera detection card */
41128
+ .detection-card {
41129
+ background: rgba(255,255,255,0.03);
41130
+ border-radius: 8px;
41131
+ padding: 16px;
41132
+ border: 1px solid rgba(255,255,255,0.08);
41133
+ transition: all 0.3s;
41134
+ }
41135
+ .detection-card.detecting {
41136
+ border-color: #4fc3f7;
41137
+ background: rgba(79, 195, 247, 0.1);
41138
+ }
41139
+ .detection-card.in-transit {
41140
+ border-color: #ffb74d;
41141
+ background: rgba(255, 183, 77, 0.1);
41142
+ }
41143
+
41144
+ .detection-icon {
41145
+ width: 64px;
41146
+ height: 64px;
41147
+ border-radius: 8px;
41148
+ background: rgba(255,255,255,0.05);
41149
+ display: flex;
41150
+ align-items: center;
41151
+ justify-content: center;
41152
+ margin: 0 auto 12px;
41153
+ font-size: 28px;
41154
+ }
41155
+ .detection-card.detecting .detection-icon {
41156
+ background: #4fc3f7;
41157
+ animation: detectPulse 1.5s infinite;
41158
+ }
41159
+ @keyframes detectPulse {
41160
+ 0%, 100% { transform: scale(1); }
41161
+ 50% { transform: scale(1.03); }
41162
+ }
41163
+
41164
+ .detection-title {
41165
+ font-size: 18px;
41166
+ font-weight: 500;
41167
+ text-align: center;
41168
+ margin-bottom: 4px;
41169
+ }
41170
+ .detection-subtitle {
41171
+ font-size: 13px;
41172
+ color: rgba(255,255,255,0.5);
41173
+ text-align: center;
41174
+ }
41175
+ .detection-confidence {
41176
+ margin-top: 8px;
41177
+ text-align: center;
41178
+ font-size: 12px;
41179
+ color: rgba(255,255,255,0.4);
41180
+ }
41181
+
41182
+ /* Transit timer */
41183
+ .transit-timer {
41184
+ display: flex;
41185
+ align-items: center;
41186
+ justify-content: center;
41187
+ gap: 8px;
41188
+ margin-top: 12px;
41189
+ padding: 10px;
41190
+ background: rgba(0,0,0,0.2);
41191
+ border-radius: 6px;
41192
+ }
41193
+ .transit-timer-icon { font-size: 18px; }
41194
+ .transit-timer-text { font-size: 16px; font-weight: 500; }
41195
+ .transit-timer-from { font-size: 12px; color: rgba(255,255,255,0.5); }
41196
+
41197
+ /* Stats grid */
41198
+ .stats-grid {
41199
+ display: grid;
41200
+ grid-template-columns: repeat(3, 1fr);
41201
+ gap: 8px;
41202
+ }
41203
+ .stat-item {
41204
+ background: rgba(255,255,255,0.03);
41205
+ border-radius: 6px;
41206
+ padding: 12px 8px;
41207
+ text-align: center;
41208
+ border: 1px solid rgba(255,255,255,0.05);
41209
+ }
41210
+ .stat-value {
41211
+ font-size: 24px;
41212
+ font-weight: 500;
41213
+ color: #4fc3f7;
41214
+ }
41215
+ .stat-label {
41216
+ font-size: 10px;
41217
+ color: rgba(255,255,255,0.4);
41218
+ text-transform: uppercase;
41219
+ margin-top: 2px;
41220
+ }
41221
+
41222
+ /* Progress bar */
41223
+ .progress-section {
41224
+ background: rgba(255,255,255,0.03);
41225
+ border-radius: 6px;
41226
+ padding: 12px;
41227
+ border: 1px solid rgba(255,255,255,0.05);
41228
+ }
41229
+ .progress-header {
41230
+ display: flex;
41231
+ justify-content: space-between;
41232
+ margin-bottom: 8px;
41233
+ font-size: 13px;
41234
+ }
41235
+ .progress-bar {
41236
+ height: 6px;
41237
+ background: rgba(255,255,255,0.1);
41238
+ border-radius: 3px;
41239
+ overflow: hidden;
41240
+ }
41241
+ .progress-fill {
41242
+ height: 100%;
41243
+ background: #4fc3f7;
41244
+ border-radius: 3px;
41245
+ transition: width 0.5s;
41246
+ }
41247
+
41248
+ /* Suggestions */
41249
+ .suggestions-section {
41250
+ background: rgba(79, 195, 247, 0.08);
41251
+ border: 1px solid rgba(79, 195, 247, 0.2);
41252
+ border-radius: 6px;
41253
+ padding: 12px;
41254
+ }
41255
+ .suggestions-title {
41256
+ font-size: 11px;
41257
+ text-transform: uppercase;
41258
+ color: #4fc3f7;
41259
+ margin-bottom: 6px;
41260
+ font-weight: 500;
41261
+ }
41262
+ .suggestion-item {
41263
+ font-size: 13px;
41264
+ padding: 6px 0;
41265
+ border-bottom: 1px solid rgba(255,255,255,0.05);
41266
+ color: rgba(255,255,255,0.7);
41267
+ }
41268
+ .suggestion-item:last-child { border-bottom: none; }
41269
+ .suggestion-item::before {
41270
+ content: "→ ";
41271
+ color: #4fc3f7;
41272
+ }
41273
+
41274
+ /* Action buttons */
41275
+ .action-buttons {
41276
+ display: flex;
41277
+ gap: 8px;
41278
+ padding: 8px 0;
41279
+ }
41280
+ .btn {
41281
+ flex: 1;
41282
+ padding: 14px 16px;
41283
+ border: none;
41284
+ border-radius: 6px;
41285
+ font-size: 14px;
41286
+ font-weight: 500;
41287
+ cursor: pointer;
41288
+ transition: all 0.2s;
41289
+ display: flex;
41290
+ align-items: center;
41291
+ justify-content: center;
41292
+ gap: 6px;
41293
+ }
41294
+ .btn:active { transform: scale(0.98); }
41295
+ .btn-primary { background: #4fc3f7; color: #000; }
41296
+ .btn-secondary { background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.87); }
41297
+ .btn-danger { background: #ef5350; color: #fff; }
41298
+ .btn-warning { background: #ffb74d; color: #000; }
41299
+ .btn:disabled { opacity: 0.4; cursor: not-allowed; }
41300
+
41301
+ /* Full-width button */
41302
+ .btn-full { flex: none; width: 100%; }
41303
+
41304
+ /* Mark landmark/structure panel */
41305
+ .mark-panel {
41306
+ background: rgba(255,255,255,0.03);
41307
+ border-radius: 6px;
41308
+ padding: 16px;
41309
+ border: 1px solid rgba(255,255,255,0.05);
41310
+ }
41311
+ .mark-panel-title {
41312
+ font-size: 14px;
41313
+ font-weight: 500;
41314
+ margin-bottom: 12px;
41315
+ }
41316
+ .mark-type-grid {
41317
+ display: grid;
41318
+ grid-template-columns: repeat(4, 1fr);
41319
+ gap: 6px;
41320
+ margin-bottom: 12px;
41321
+ }
41322
+ .mark-type-btn {
41323
+ padding: 10px 6px;
41324
+ border: 1px solid rgba(255,255,255,0.1);
41325
+ border-radius: 6px;
41326
+ background: transparent;
41327
+ color: rgba(255,255,255,0.7);
41328
+ font-size: 10px;
41329
+ text-align: center;
41330
+ cursor: pointer;
41331
+ transition: all 0.2s;
41332
+ }
41333
+ .mark-type-btn.selected {
41334
+ border-color: #4fc3f7;
41335
+ background: rgba(79, 195, 247, 0.15);
41336
+ color: #fff;
41337
+ }
41338
+ .mark-type-btn .icon { font-size: 18px; margin-bottom: 2px; display: block; }
41339
+
41340
+ /* Input field */
41341
+ .input-group { margin-bottom: 12px; }
41342
+ .input-group label {
41343
+ display: block;
41344
+ font-size: 12px;
41345
+ color: rgba(255,255,255,0.5);
41346
+ margin-bottom: 4px;
41347
+ }
41348
+ .input-group input {
41349
+ width: 100%;
41350
+ padding: 12px;
41351
+ border: 1px solid rgba(255,255,255,0.1);
41352
+ border-radius: 6px;
41353
+ background: rgba(0,0,0,0.2);
41354
+ color: #fff;
41355
+ font-size: 14px;
41356
+ }
41357
+ .input-group input:focus {
41358
+ outline: none;
41359
+ border-color: #4fc3f7;
41360
+ }
41361
+
41362
+ /* History list */
41363
+ .history-section {
41364
+ background: rgba(255,255,255,0.03);
41365
+ border-radius: 6px;
41366
+ padding: 12px;
41367
+ max-height: 180px;
41368
+ overflow-y: auto;
41369
+ border: 1px solid rgba(255,255,255,0.05);
41370
+ }
41371
+ .history-title {
41372
+ font-size: 13px;
41373
+ font-weight: 500;
41374
+ margin-bottom: 8px;
41375
+ display: flex;
41376
+ justify-content: space-between;
41377
+ }
41378
+ .history-item {
41379
+ padding: 8px;
41380
+ border-bottom: 1px solid rgba(255,255,255,0.05);
41381
+ font-size: 13px;
41382
+ }
41383
+ .history-item:last-child { border-bottom: none; }
41384
+ .history-item-time {
41385
+ font-size: 10px;
41386
+ color: rgba(255,255,255,0.4);
41387
+ }
41388
+ .history-item-camera { color: #4fc3f7; font-weight: 500; }
41389
+ .history-item-transit { color: #ffb74d; }
41390
+
41391
+ /* Bottom action bar */
41392
+ .bottom-bar {
41393
+ background: rgba(255,255,255,0.03);
41394
+ padding: 12px 16px;
41395
+ padding-bottom: max(12px, env(safe-area-inset-bottom));
41396
+ border-top: 1px solid rgba(255,255,255,0.08);
41397
+ }
41398
+
41399
+ /* Tabs */
41400
+ .tabs {
41401
+ display: flex;
41402
+ background: rgba(0,0,0,0.2);
41403
+ border-radius: 6px;
41404
+ padding: 3px;
41405
+ margin-bottom: 12px;
41406
+ }
41407
+ .tab {
41408
+ flex: 1;
41409
+ padding: 10px;
41410
+ border: none;
41411
+ background: transparent;
41412
+ color: rgba(255,255,255,0.5);
41413
+ font-size: 13px;
41414
+ font-weight: 500;
41415
+ cursor: pointer;
41416
+ border-radius: 4px;
41417
+ transition: all 0.2s;
41418
+ }
41419
+ .tab.active {
41420
+ background: rgba(255,255,255,0.08);
41421
+ color: rgba(255,255,255,0.87);
41422
+ }
41423
+
41424
+ /* Tab content */
41425
+ .tab-content { display: none; }
41426
+ .tab-content.active { display: block; }
41427
+
41428
+ /* Apply results modal */
41429
+ .modal-overlay {
41430
+ display: none;
41431
+ position: fixed;
41432
+ top: 0;
41433
+ left: 0;
41434
+ right: 0;
41435
+ bottom: 0;
41436
+ background: rgba(0,0,0,0.85);
41437
+ z-index: 100;
41438
+ align-items: center;
41439
+ justify-content: center;
41440
+ padding: 16px;
41441
+ }
41442
+ .modal-overlay.active { display: flex; }
41443
+ .modal {
41444
+ background: #1e1e1e;
41445
+ border-radius: 8px;
41446
+ padding: 20px;
41447
+ max-width: 360px;
41448
+ width: 100%;
41449
+ border: 1px solid rgba(255,255,255,0.1);
41450
+ }
41451
+ .modal h2 { font-size: 18px; margin-bottom: 12px; font-weight: 500; }
41452
+ .modal-result-item {
41453
+ display: flex;
41454
+ justify-content: space-between;
41455
+ padding: 8px 0;
41456
+ border-bottom: 1px solid rgba(255,255,255,0.08);
41457
+ font-size: 13px;
41458
+ }
41459
+ .modal-result-value { color: #4fc3f7; font-weight: 500; }
41460
+ .modal-buttons { display: flex; gap: 8px; margin-top: 16px; }
41461
+
41462
+ /* Idle state */
41463
+ .idle-content {
41464
+ flex: 1;
41465
+ display: flex;
41466
+ flex-direction: column;
41467
+ align-items: center;
41468
+ justify-content: center;
41469
+ text-align: center;
41470
+ padding: 32px 16px;
41471
+ }
41472
+ .idle-icon {
41473
+ font-size: 56px;
41474
+ margin-bottom: 16px;
41475
+ opacity: 0.7;
41476
+ }
41477
+ .idle-title {
41478
+ font-size: 20px;
41479
+ font-weight: 500;
41480
+ margin-bottom: 8px;
41481
+ }
41482
+ .idle-desc {
41483
+ font-size: 14px;
41484
+ color: rgba(255,255,255,0.5);
41485
+ max-width: 280px;
41486
+ line-height: 1.5;
41487
+ }
41488
+ .idle-instructions {
41489
+ margin-top: 24px;
41490
+ text-align: left;
41491
+ background: rgba(255,255,255,0.03);
41492
+ border-radius: 6px;
41493
+ padding: 16px;
41494
+ border: 1px solid rgba(255,255,255,0.05);
41495
+ }
41496
+ .idle-instructions h3 {
41497
+ font-size: 12px;
41498
+ margin-bottom: 10px;
41499
+ color: #4fc3f7;
41500
+ font-weight: 500;
41501
+ }
41502
+ .idle-instructions ol {
41503
+ padding-left: 18px;
41504
+ font-size: 13px;
41505
+ line-height: 1.8;
41506
+ color: rgba(255,255,255,0.6);
41507
+ }
41508
+ </style>
41509
+ </head>
41510
+ <body>
41511
+ <div class="header">
41512
+ <h1>Training Mode</h1>
41513
+ <div class="header-status">
41514
+ <span class="status-badge" id="status-badge">Idle</span>
41515
+ </div>
41516
+ </div>
41517
+
41518
+ <!-- Idle State -->
41519
+ <div class="main-content" id="idle-content">
41520
+ <div class="idle-content">
41521
+ <div class="idle-icon">🚶</div>
41522
+ <div class="idle-title">Train Your System</div>
41523
+ <div class="idle-desc">Walk around your property to teach the system about camera positions, transit times, and landmarks.</div>
41524
+
41525
+ <div class="idle-instructions">
41526
+ <h3>How it works:</h3>
41527
+ <ol>
41528
+ <li>Tap <strong>Start Training</strong> below</li>
41529
+ <li>Walk to each camera on your property</li>
41530
+ <li>The system detects you automatically</li>
41531
+ <li>Mark landmarks as you encounter them</li>
41532
+ <li>End training when you're done</li>
41533
+ </ol>
41534
+ </div>
41535
+ </div>
41536
+
41537
+ <div class="bottom-bar">
41538
+ <button class="btn btn-primary btn-full" onclick="startTraining()">
41539
+ ▶ Start Training
41540
+ </button>
41541
+ </div>
41542
+ </div>
41543
+
41544
+ <!-- Active Training State -->
41545
+ <div class="main-content" id="active-content" style="display: none;">
41546
+ <!-- Detection Card -->
41547
+ <div class="detection-card" id="detection-card">
41548
+ <div class="detection-icon" id="detection-icon">👤</div>
41549
+ <div class="detection-title" id="detection-title">Waiting for detection...</div>
41550
+ <div class="detection-subtitle" id="detection-subtitle">Walk to any camera to begin</div>
41551
+ <div class="detection-confidence" id="detection-confidence"></div>
41552
+
41553
+ <div class="transit-timer" id="transit-timer" style="display: none;">
41554
+ <span class="transit-timer-icon">⏱</span>
41555
+ <span class="transit-timer-text" id="transit-time">0s</span>
41556
+ <span class="transit-timer-from" id="transit-from"></span>
41557
+ </div>
41558
+ </div>
41559
+
41560
+ <!-- Tabs -->
41561
+ <div class="tabs">
41562
+ <button class="tab active" onclick="switchTab('status')">Status</button>
41563
+ <button class="tab" onclick="switchTab('mark')">Mark</button>
41564
+ <button class="tab" onclick="switchTab('history')">History</button>
41565
+ </div>
41566
+
41567
+ <!-- Status Tab -->
41568
+ <div class="tab-content active" id="tab-status">
41569
+ <!-- Stats -->
41570
+ <div class="stats-grid">
41571
+ <div class="stat-item">
41572
+ <div class="stat-value" id="stat-cameras">0</div>
41573
+ <div class="stat-label">Cameras</div>
41574
+ </div>
41575
+ <div class="stat-item">
41576
+ <div class="stat-value" id="stat-transits">0</div>
41577
+ <div class="stat-label">Transits</div>
41578
+ </div>
41579
+ <div class="stat-item">
41580
+ <div class="stat-value" id="stat-landmarks">0</div>
41581
+ <div class="stat-label">Landmarks</div>
41582
+ </div>
41583
+ </div>
41584
+
41585
+ <!-- Progress -->
41586
+ <div class="progress-section" style="margin-top: 15px;">
41587
+ <div class="progress-header">
41588
+ <span>Coverage</span>
41589
+ <span id="progress-percent">0%</span>
41590
+ </div>
41591
+ <div class="progress-bar">
41592
+ <div class="progress-fill" id="progress-fill" style="width: 0%"></div>
41593
+ </div>
41594
+ </div>
41595
+
41596
+ <!-- Suggestions -->
41597
+ <div class="suggestions-section" style="margin-top: 15px;" id="suggestions-section">
41598
+ <div class="suggestions-title">Suggestions</div>
41599
+ <div id="suggestions-list">
41600
+ <div class="suggestion-item">Start walking to a camera</div>
41601
+ </div>
41602
+ </div>
41603
+ </div>
41604
+
41605
+ <!-- Mark Tab -->
41606
+ <div class="tab-content" id="tab-mark">
41607
+ <div class="mark-panel">
41608
+ <div class="mark-panel-title">Mark a Landmark</div>
41609
+ <div class="mark-type-grid" id="landmark-type-grid">
41610
+ <button class="mark-type-btn selected" data-type="mailbox" onclick="selectLandmarkType('mailbox')">
41611
+ <span class="icon">📬</span>
41612
+ Mailbox
41613
+ </button>
41614
+ <button class="mark-type-btn" data-type="garage" onclick="selectLandmarkType('garage')">
41615
+ <span class="icon">🏠</span>
41616
+ Garage
41617
+ </button>
41618
+ <button class="mark-type-btn" data-type="shed" onclick="selectLandmarkType('shed')">
41619
+ <span class="icon">🏚</span>
41620
+ Shed
41621
+ </button>
41622
+ <button class="mark-type-btn" data-type="tree" onclick="selectLandmarkType('tree')">
41623
+ <span class="icon">🌳</span>
41624
+ Tree
41625
+ </button>
41626
+ <button class="mark-type-btn" data-type="gate" onclick="selectLandmarkType('gate')">
41627
+ <span class="icon">🚪</span>
41628
+ Gate
41629
+ </button>
41630
+ <button class="mark-type-btn" data-type="driveway" onclick="selectLandmarkType('driveway')">
41631
+ <span class="icon">🛣</span>
41632
+ Driveway
41633
+ </button>
41634
+ <button class="mark-type-btn" data-type="pool" onclick="selectLandmarkType('pool')">
41635
+ <span class="icon">🏊</span>
41636
+ Pool
41637
+ </button>
41638
+ <button class="mark-type-btn" data-type="other" onclick="selectLandmarkType('other')">
41639
+ <span class="icon">📍</span>
41640
+ Other
41641
+ </button>
41642
+ </div>
41643
+
41644
+ <div class="input-group">
41645
+ <label>Landmark Name</label>
41646
+ <input type="text" id="landmark-name" placeholder="e.g., Front Mailbox">
41647
+ </div>
41648
+
41649
+ <button class="btn btn-primary btn-full" onclick="markLandmark()">
41650
+ 📍 Mark Landmark Here
41651
+ </button>
41652
+ </div>
41653
+ </div>
41654
+
41655
+ <!-- History Tab -->
41656
+ <div class="tab-content" id="tab-history">
41657
+ <div class="history-section">
41658
+ <div class="history-title">
41659
+ <span>Recent Activity</span>
41660
+ <span id="history-count">0 events</span>
41661
+ </div>
41662
+ <div id="history-list">
41663
+ <div class="history-item" style="color: rgba(255,255,255,0.4); text-align: center;">
41664
+ No activity yet
41665
+ </div>
41666
+ </div>
41667
+ </div>
41668
+ </div>
41669
+
41670
+ <!-- Bottom Actions -->
41671
+ <div class="bottom-bar">
41672
+ <div class="action-buttons">
41673
+ <button class="btn btn-warning" id="pause-btn" onclick="togglePause()">
41674
+ ⏸ Pause
41675
+ </button>
41676
+ <button class="btn btn-danger" onclick="endTraining()">
41677
+ ⏹ End
41678
+ </button>
41679
+ </div>
41680
+ </div>
41681
+ </div>
41682
+
41683
+ <!-- Completed State -->
41684
+ <div class="main-content" id="completed-content" style="display: none;">
41685
+ <div class="idle-content">
41686
+ <div class="idle-icon">✅</div>
41687
+ <div class="idle-title">Training Complete!</div>
41688
+ <div class="idle-desc">Review the results and apply them to your topology.</div>
41689
+ </div>
41690
+
41691
+ <!-- Final Stats -->
41692
+ <div class="stats-grid">
41693
+ <div class="stat-item">
41694
+ <div class="stat-value" id="final-cameras">0</div>
41695
+ <div class="stat-label">Cameras</div>
41696
+ </div>
41697
+ <div class="stat-item">
41698
+ <div class="stat-value" id="final-transits">0</div>
41699
+ <div class="stat-label">Transits</div>
41700
+ </div>
41701
+ <div class="stat-item">
41702
+ <div class="stat-value" id="final-landmarks">0</div>
41703
+ <div class="stat-label">Landmarks</div>
41704
+ </div>
41705
+ </div>
41706
+
41707
+ <div class="stats-grid" style="margin-top: 10px;">
41708
+ <div class="stat-item">
41709
+ <div class="stat-value" id="final-overlaps">0</div>
41710
+ <div class="stat-label">Overlaps</div>
41711
+ </div>
41712
+ <div class="stat-item">
41713
+ <div class="stat-value" id="final-avg-transit">0s</div>
41714
+ <div class="stat-label">Avg Transit</div>
41715
+ </div>
41716
+ <div class="stat-item">
41717
+ <div class="stat-value" id="final-coverage">0%</div>
41718
+ <div class="stat-label">Coverage</div>
41719
+ </div>
41720
+ </div>
41721
+
41722
+ <div class="bottom-bar" style="margin-top: auto;">
41723
+ <div class="action-buttons">
41724
+ <button class="btn btn-secondary" onclick="resetTraining()">
41725
+ ↻ Start Over
41726
+ </button>
41727
+ <button class="btn btn-primary" onclick="applyTraining()">
41728
+ ✓ Apply Results
41729
+ </button>
41730
+ </div>
41731
+ </div>
41732
+ </div>
41733
+
41734
+ <!-- Apply Results Modal -->
41735
+ <div class="modal-overlay" id="results-modal">
41736
+ <div class="modal">
41737
+ <h2>Training Applied!</h2>
41738
+ <div id="results-content">
41739
+ <div class="modal-result-item">
41740
+ <span>Connections Created</span>
41741
+ <span class="modal-result-value" id="result-connections">0</span>
41742
+ </div>
41743
+ <div class="modal-result-item">
41744
+ <span>Connections Updated</span>
41745
+ <span class="modal-result-value" id="result-updated">0</span>
41746
+ </div>
41747
+ <div class="modal-result-item">
41748
+ <span>Landmarks Added</span>
41749
+ <span class="modal-result-value" id="result-landmarks">0</span>
41750
+ </div>
41751
+ <div class="modal-result-item">
41752
+ <span>Zones Created</span>
41753
+ <span class="modal-result-value" id="result-zones">0</span>
41754
+ </div>
41755
+ </div>
41756
+ <div class="modal-buttons">
41757
+ <button class="btn btn-secondary" style="flex: 1;" onclick="closeResultsModal()">Close</button>
41758
+ <button class="btn btn-primary" style="flex: 1;" onclick="openEditor()">Open Editor</button>
41759
+ </div>
41760
+ </div>
41761
+ </div>
41762
+
41763
+ <script>
41764
+ let trainingState = 'idle'; // idle, active, paused, completed
41765
+ let session = null;
41766
+ let pollInterval = null;
41767
+ let transitInterval = null;
41768
+ let selectedLandmarkType = 'mailbox';
41769
+ let historyItems = [];
41770
+
41771
+ // Initialize
41772
+ async function init() {
41773
+ // Check if there's an existing session
41774
+ const status = await fetchTrainingStatus();
41775
+ if (status && (status.state === 'active' || status.state === 'paused')) {
41776
+ session = status;
41777
+ trainingState = status.state;
41778
+ updateUI();
41779
+ startPolling();
41780
+ }
41781
+ }
41782
+
41783
+ // API calls
41784
+ async function fetchTrainingStatus() {
41785
+ try {
41786
+ const response = await fetch('../api/training/status');
41787
+ if (response.ok) {
41788
+ return await response.json();
41789
+ }
41790
+ } catch (e) { console.error('Failed to fetch status:', e); }
41791
+ return null;
41792
+ }
41793
+
41794
+ async function startTraining() {
41795
+ try {
41796
+ const response = await fetch('../api/training/start', { method: 'POST' });
41797
+ if (response.ok) {
41798
+ session = await response.json();
41799
+ trainingState = 'active';
41800
+ updateUI();
41801
+ startPolling();
41802
+ addHistoryItem('Training started', 'start');
41803
+ }
41804
+ } catch (e) {
41805
+ console.error('Failed to start training:', e);
41806
+ alert('Failed to start training. Please try again.');
41807
+ }
41808
+ }
41809
+
41810
+ async function togglePause() {
41811
+ const endpoint = trainingState === 'active' ? 'pause' : 'resume';
41812
+ try {
41813
+ const response = await fetch('../api/training/' + endpoint, { method: 'POST' });
41814
+ if (response.ok) {
41815
+ trainingState = trainingState === 'active' ? 'paused' : 'active';
41816
+ updateUI();
41817
+ addHistoryItem('Training ' + (trainingState === 'paused' ? 'paused' : 'resumed'), 'control');
41818
+ }
41819
+ } catch (e) { console.error('Failed to toggle pause:', e); }
41820
+ }
41821
+
41822
+ async function endTraining() {
41823
+ if (!confirm('End training session?')) return;
41824
+ try {
41825
+ const response = await fetch('../api/training/end', { method: 'POST' });
41826
+ if (response.ok) {
41827
+ session = await response.json();
41828
+ trainingState = 'completed';
41829
+ stopPolling();
41830
+ updateUI();
41831
+ }
41832
+ } catch (e) { console.error('Failed to end training:', e); }
41833
+ }
41834
+
41835
+ async function applyTraining() {
41836
+ try {
41837
+ const response = await fetch('../api/training/apply', { method: 'POST' });
41838
+ if (response.ok) {
41839
+ const result = await response.json();
41840
+ document.getElementById('result-connections').textContent = result.connectionsCreated;
41841
+ document.getElementById('result-updated').textContent = result.connectionsUpdated;
41842
+ document.getElementById('result-landmarks').textContent = result.landmarksAdded;
41843
+ document.getElementById('result-zones').textContent = result.zonesCreated;
41844
+ document.getElementById('results-modal').classList.add('active');
41845
+ }
41846
+ } catch (e) {
41847
+ console.error('Failed to apply training:', e);
41848
+ alert('Failed to apply training results.');
41849
+ }
41850
+ }
41851
+
41852
+ function closeResultsModal() {
41853
+ document.getElementById('results-modal').classList.remove('active');
41854
+ }
41855
+
41856
+ function openEditor() {
41857
+ window.location.href = '../ui/editor';
41858
+ }
41859
+
41860
+ function resetTraining() {
41861
+ trainingState = 'idle';
41862
+ session = null;
41863
+ historyItems = [];
41864
+ updateUI();
41865
+ }
41866
+
41867
+ async function markLandmark() {
41868
+ const name = document.getElementById('landmark-name').value.trim();
41869
+ if (!name) {
41870
+ alert('Please enter a landmark name');
41871
+ return;
41872
+ }
41873
+
41874
+ const currentCameraId = session?.currentCamera?.id;
41875
+ const visibleFromCameras = currentCameraId ? [currentCameraId] : [];
41876
+
41877
+ try {
41878
+ const response = await fetch('../api/training/landmark', {
41879
+ method: 'POST',
41880
+ headers: { 'Content-Type': 'application/json' },
41881
+ body: JSON.stringify({
41882
+ name,
41883
+ type: selectedLandmarkType,
41884
+ visibleFromCameras,
41885
+ position: { x: 50, y: 50 }, // Will be refined when applied
41886
+ })
41887
+ });
41888
+ if (response.ok) {
41889
+ document.getElementById('landmark-name').value = '';
41890
+ addHistoryItem('Marked: ' + name + ' (' + selectedLandmarkType + ')', 'landmark');
41891
+ // Refresh status
41892
+ const status = await fetchTrainingStatus();
41893
+ if (status) {
41894
+ session = status;
41895
+ updateStatsUI();
41896
+ }
41897
+ }
41898
+ } catch (e) { console.error('Failed to mark landmark:', e); }
41899
+ }
41900
+
41901
+ function selectLandmarkType(type) {
41902
+ selectedLandmarkType = type;
41903
+ document.querySelectorAll('.mark-type-btn').forEach(btn => {
41904
+ btn.classList.toggle('selected', btn.dataset.type === type);
41905
+ });
41906
+ }
41907
+
41908
+ // Polling
41909
+ function startPolling() {
41910
+ if (pollInterval) clearInterval(pollInterval);
41911
+ pollInterval = setInterval(async () => {
41912
+ const status = await fetchTrainingStatus();
41913
+ if (status) {
41914
+ session = status;
41915
+ updateDetectionUI();
41916
+ updateStatsUI();
41917
+ updateSuggestionsUI();
41918
+ }
41919
+ }, 1000);
41920
+ }
41921
+
41922
+ function stopPolling() {
41923
+ if (pollInterval) {
41924
+ clearInterval(pollInterval);
41925
+ pollInterval = null;
41926
+ }
41927
+ if (transitInterval) {
41928
+ clearInterval(transitInterval);
41929
+ transitInterval = null;
41930
+ }
41931
+ }
41932
+
41933
+ // UI Updates
41934
+ function updateUI() {
41935
+ // Show/hide content sections
41936
+ document.getElementById('idle-content').style.display = trainingState === 'idle' ? 'flex' : 'none';
41937
+ document.getElementById('active-content').style.display = (trainingState === 'active' || trainingState === 'paused') ? 'flex' : 'none';
41938
+ document.getElementById('completed-content').style.display = trainingState === 'completed' ? 'flex' : 'none';
41939
+
41940
+ // Update status badge
41941
+ const badge = document.getElementById('status-badge');
41942
+ badge.textContent = trainingState.charAt(0).toUpperCase() + trainingState.slice(1);
41943
+ badge.className = 'status-badge ' + trainingState;
41944
+
41945
+ // Update pause button
41946
+ const pauseBtn = document.getElementById('pause-btn');
41947
+ if (pauseBtn) {
41948
+ pauseBtn.innerHTML = trainingState === 'paused' ? '▶ Resume' : '⏸ Pause';
41949
+ }
41950
+
41951
+ // Update completed stats
41952
+ if (trainingState === 'completed' && session) {
41953
+ document.getElementById('final-cameras').textContent = session.stats?.camerasVisited || 0;
41954
+ document.getElementById('final-transits').textContent = session.stats?.transitsRecorded || 0;
41955
+ document.getElementById('final-landmarks').textContent = session.stats?.landmarksMarked || 0;
41956
+ document.getElementById('final-overlaps').textContent = session.stats?.overlapsDetected || 0;
41957
+ document.getElementById('final-avg-transit').textContent = (session.stats?.averageTransitTime || 0) + 's';
41958
+ document.getElementById('final-coverage').textContent = (session.stats?.coveragePercentage || 0) + '%';
41959
+ }
41960
+ }
41961
+
41962
+ function updateDetectionUI() {
41963
+ if (!session) return;
41964
+
41965
+ const card = document.getElementById('detection-card');
41966
+ const icon = document.getElementById('detection-icon');
41967
+ const title = document.getElementById('detection-title');
41968
+ const subtitle = document.getElementById('detection-subtitle');
41969
+ const confidence = document.getElementById('detection-confidence');
41970
+ const transitTimer = document.getElementById('transit-timer');
41971
+
41972
+ if (session.currentCamera) {
41973
+ // Detected on a camera
41974
+ card.className = 'detection-card detecting';
41975
+ icon.textContent = '📷';
41976
+ title.textContent = session.currentCamera.name;
41977
+ subtitle.textContent = 'You are visible on this camera';
41978
+ confidence.textContent = 'Confidence: ' + Math.round(session.currentCamera.confidence * 100) + '%';
41979
+ transitTimer.style.display = 'none';
41980
+
41981
+ // Check for new camera detection to add to history
41982
+ const lastHistoryCamera = historyItems.find(h => h.type === 'camera');
41983
+ if (!lastHistoryCamera || lastHistoryCamera.cameraId !== session.currentCamera.id) {
41984
+ addHistoryItem('Detected on: ' + session.currentCamera.name, 'camera', session.currentCamera.id);
41985
+ }
41986
+ } else if (session.activeTransit) {
41987
+ // In transit
41988
+ card.className = 'detection-card in-transit';
41989
+ icon.textContent = '🚶';
41990
+ title.textContent = 'In Transit';
41991
+ subtitle.textContent = 'Walking to next camera...';
41992
+ confidence.textContent = '';
41993
+ transitTimer.style.display = 'flex';
41994
+ document.getElementById('transit-from').textContent = 'from ' + session.activeTransit.fromCameraName;
41995
+
41996
+ // Start transit timer if not already running
41997
+ if (!transitInterval) {
41998
+ transitInterval = setInterval(() => {
41999
+ if (session?.activeTransit) {
42000
+ document.getElementById('transit-time').textContent = session.activeTransit.elapsedSeconds + 's';
42001
+ }
42002
+ }, 1000);
42003
+ }
42004
+ } else {
42005
+ // Waiting
42006
+ card.className = 'detection-card';
42007
+ icon.textContent = '👤';
42008
+ title.textContent = 'Waiting for detection...';
42009
+ subtitle.textContent = 'Walk to any camera to begin';
42010
+ confidence.textContent = '';
42011
+ transitTimer.style.display = 'none';
42012
+
42013
+ if (transitInterval) {
42014
+ clearInterval(transitInterval);
42015
+ transitInterval = null;
42016
+ }
42017
+ }
42018
+ }
42019
+
42020
+ function updateStatsUI() {
42021
+ if (!session?.stats) return;
42022
+
42023
+ document.getElementById('stat-cameras').textContent = session.stats.camerasVisited;
42024
+ document.getElementById('stat-transits').textContent = session.stats.transitsRecorded;
42025
+ document.getElementById('stat-landmarks').textContent = session.stats.landmarksMarked;
42026
+ document.getElementById('progress-percent').textContent = session.stats.coveragePercentage + '%';
42027
+ document.getElementById('progress-fill').style.width = session.stats.coveragePercentage + '%';
42028
+ }
42029
+
42030
+ function updateSuggestionsUI() {
42031
+ if (!session?.suggestions || session.suggestions.length === 0) return;
42032
+
42033
+ const list = document.getElementById('suggestions-list');
42034
+ list.innerHTML = session.suggestions.map(s =>
42035
+ '<div class="suggestion-item">' + s + '</div>'
42036
+ ).join('');
42037
+ }
42038
+
42039
+ function addHistoryItem(text, type, cameraId) {
42040
+ const time = new Date().toLocaleTimeString();
42041
+ historyItems.unshift({ text, type, time, cameraId });
42042
+ if (historyItems.length > 50) historyItems.pop();
42043
+
42044
+ const list = document.getElementById('history-list');
42045
+ document.getElementById('history-count').textContent = historyItems.length + ' events';
42046
+
42047
+ list.innerHTML = historyItems.map(item => {
42048
+ let className = '';
42049
+ if (item.type === 'camera') className = 'history-item-camera';
42050
+ if (item.type === 'transit') className = 'history-item-transit';
42051
+ return '<div class="history-item"><span class="' + className + '">' + item.text + '</span>' +
42052
+ '<div class="history-item-time">' + item.time + '</div></div>';
42053
+ }).join('');
42054
+ }
42055
+
42056
+ function switchTab(tabName) {
42057
+ document.querySelectorAll('.tab').forEach(tab => {
42058
+ tab.classList.toggle('active', tab.textContent.toLowerCase() === tabName);
42059
+ });
42060
+ document.querySelectorAll('.tab-content').forEach(content => {
42061
+ content.classList.toggle('active', content.id === 'tab-' + tabName);
42062
+ });
42063
+ }
42064
+
42065
+ // Initialize on load
42066
+ init();
42067
+ </script>
42068
+ </body>
42069
+ </html>`;
42070
+
42071
+
40203
42072
  /***/ },
40204
42073
 
40205
42074
  /***/ "assert"