@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.
package/src/main.ts CHANGED
@@ -32,6 +32,8 @@ import { GlobalTrackerSensor } from './devices/global-tracker-sensor';
32
32
  import { TrackingZone } from './devices/tracking-zone';
33
33
  import { MqttPublisher, MqttConfig } from './integrations/mqtt-publisher';
34
34
  import { EDITOR_HTML } from './ui/editor-html';
35
+ import { TRAINING_HTML } from './ui/training-html';
36
+ import { TrainingConfig, TrainingLandmark } from './models/training';
35
37
 
36
38
  const { deviceManager, systemManager } = sdk;
37
39
 
@@ -430,10 +432,103 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
430
432
  // ==================== Settings Implementation ====================
431
433
 
432
434
  async getSettings(): Promise<Setting[]> {
433
- const settings = await this.storageSettings.getSettings();
435
+ const baseSettings = await this.storageSettings.getSettings();
434
436
 
437
+ // Build settings in desired order
438
+ const settings: Setting[] = [];
439
+
440
+ // Helper to find and add settings from baseSettings by group
441
+ const addGroup = (group: string) => {
442
+ baseSettings.filter(s => s.group === group).forEach(s => settings.push(s));
443
+ };
444
+
445
+ // ==================== 1. Getting Started ====================
446
+ // Training Mode button that opens mobile-friendly training UI in modal
447
+ 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);})()`;
448
+
449
+ settings.push({
450
+ key: 'trainingMode',
451
+ title: 'Training Mode',
452
+ type: 'html' as any,
453
+ value: `
454
+ <style>
455
+ .sa-training-container {
456
+ padding: 16px;
457
+ background: rgba(255,255,255,0.03);
458
+ border-radius: 4px;
459
+ border: 1px solid rgba(255,255,255,0.08);
460
+ }
461
+ .sa-training-title {
462
+ color: #4fc3f7;
463
+ font-size: 14px;
464
+ font-weight: 500;
465
+ margin-bottom: 8px;
466
+ font-family: inherit;
467
+ }
468
+ .sa-training-desc {
469
+ color: rgba(255,255,255,0.6);
470
+ margin-bottom: 12px;
471
+ font-size: 13px;
472
+ line-height: 1.5;
473
+ font-family: inherit;
474
+ }
475
+ .sa-training-btn {
476
+ background: #4fc3f7;
477
+ color: #000;
478
+ border: none;
479
+ padding: 10px 20px;
480
+ border-radius: 4px;
481
+ font-size: 14px;
482
+ font-weight: 500;
483
+ cursor: pointer;
484
+ display: inline-flex;
485
+ align-items: center;
486
+ gap: 8px;
487
+ transition: background 0.2s;
488
+ font-family: inherit;
489
+ }
490
+ .sa-training-btn:hover {
491
+ background: #81d4fa;
492
+ }
493
+ .sa-training-steps {
494
+ color: rgba(255,255,255,0.5);
495
+ font-size: 12px;
496
+ margin-top: 12px;
497
+ padding-top: 12px;
498
+ border-top: 1px solid rgba(255,255,255,0.05);
499
+ font-family: inherit;
500
+ }
501
+ .sa-training-steps ol {
502
+ margin: 6px 0 0 16px;
503
+ padding: 0;
504
+ }
505
+ .sa-training-steps li {
506
+ margin-bottom: 2px;
507
+ }
508
+ </style>
509
+ <div class="sa-training-container">
510
+ <div class="sa-training-title">Guided Property Training</div>
511
+ <p class="sa-training-desc">Walk your property while the system learns your camera layout, transit times, and landmarks automatically.</p>
512
+ <button class="sa-training-btn" onclick="${trainingOnclickCode}">
513
+ Start Training Mode
514
+ </button>
515
+ <div class="sa-training-steps">
516
+ <strong>How it works:</strong>
517
+ <ol>
518
+ <li>Start training and walk to each camera</li>
519
+ <li>System auto-detects you and records transit times</li>
520
+ <li>Mark landmarks as you encounter them</li>
521
+ <li>Apply results to generate your topology</li>
522
+ </ol>
523
+ </div>
524
+ </div>
525
+ `,
526
+ group: 'Getting Started',
527
+ });
528
+
529
+ // ==================== 2. Topology ====================
435
530
  // Topology editor button that opens modal overlay (appended to body for proper z-index)
436
- 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);})()`;
531
+ 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);})()`;
437
532
 
438
533
  settings.push({
439
534
  key: 'topologyEditor',
@@ -442,45 +537,51 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
442
537
  value: `
443
538
  <style>
444
539
  .sa-open-btn {
445
- background: linear-gradient(135deg, #e94560 0%, #0f3460 100%);
446
- color: white;
540
+ background: #4fc3f7;
541
+ color: #000;
447
542
  border: none;
448
- padding: 14px 28px;
449
- border-radius: 8px;
450
- font-size: 15px;
451
- font-weight: 600;
543
+ padding: 10px 20px;
544
+ border-radius: 4px;
545
+ font-size: 14px;
546
+ font-weight: 500;
452
547
  cursor: pointer;
453
548
  display: inline-flex;
454
549
  align-items: center;
455
- gap: 10px;
456
- transition: transform 0.2s, box-shadow 0.2s;
550
+ gap: 8px;
551
+ transition: background 0.2s;
552
+ font-family: inherit;
457
553
  }
458
554
  .sa-open-btn:hover {
459
- transform: translateY(-2px);
460
- box-shadow: 0 8px 20px rgba(233, 69, 96, 0.4);
555
+ background: #81d4fa;
461
556
  }
462
557
  .sa-btn-container {
463
- padding: 20px;
464
- background: #16213e;
465
- border-radius: 8px;
558
+ padding: 16px;
559
+ background: rgba(255,255,255,0.03);
560
+ border-radius: 4px;
466
561
  text-align: center;
562
+ border: 1px solid rgba(255,255,255,0.08);
467
563
  }
468
564
  .sa-btn-desc {
469
- color: #888;
470
- margin-bottom: 15px;
471
- font-size: 14px;
565
+ color: rgba(255,255,255,0.6);
566
+ margin-bottom: 12px;
567
+ font-size: 13px;
568
+ font-family: inherit;
472
569
  }
473
570
  </style>
474
571
  <div class="sa-btn-container">
475
572
  <p class="sa-btn-desc">Configure camera positions, connections, and transit times</p>
476
573
  <button class="sa-open-btn" onclick="${onclickCode}">
477
- <span>&#9881;</span> Open Topology Editor
574
+ Open Topology Editor
478
575
  </button>
479
576
  </div>
480
577
  `,
481
578
  group: 'Topology',
482
579
  });
483
580
 
581
+ // ==================== 3. Cameras ====================
582
+ addGroup('Cameras');
583
+
584
+ // ==================== 4. Status ====================
484
585
  // Add status display
485
586
  const activeCount = this.trackingState.getActiveCount();
486
587
  const topologyJson = this.storage.getItem('topology');
@@ -526,6 +627,15 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
526
627
  });
527
628
  }
528
629
 
630
+ // ==================== 5. Tracking ====================
631
+ addGroup('Tracking');
632
+
633
+ // ==================== 6. AI & Spatial Reasoning ====================
634
+ addGroup('AI & Spatial Reasoning');
635
+
636
+ // ==================== 7. Alerts ====================
637
+ addGroup('Alerts');
638
+
529
639
  // Add alert rules configuration UI
530
640
  const alertRules = this.alertManager.getRules();
531
641
  const rulesHtml = this.generateAlertRulesHtml(alertRules);
@@ -537,6 +647,9 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
537
647
  group: 'Alerts',
538
648
  });
539
649
 
650
+ // ==================== 8. MQTT Integration ====================
651
+ addGroup('MQTT Integration');
652
+
540
653
  return settings;
541
654
  }
542
655
 
@@ -736,11 +849,38 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
736
849
  return this.handleJourneyPathRequest(globalId, response);
737
850
  }
738
851
 
852
+ // Training Mode endpoints
853
+ if (path.endsWith('/api/training/start')) {
854
+ return this.handleTrainingStartRequest(request, response);
855
+ }
856
+ if (path.endsWith('/api/training/pause')) {
857
+ return this.handleTrainingPauseRequest(response);
858
+ }
859
+ if (path.endsWith('/api/training/resume')) {
860
+ return this.handleTrainingResumeRequest(response);
861
+ }
862
+ if (path.endsWith('/api/training/end')) {
863
+ return this.handleTrainingEndRequest(response);
864
+ }
865
+ if (path.endsWith('/api/training/status')) {
866
+ return this.handleTrainingStatusRequest(response);
867
+ }
868
+ if (path.endsWith('/api/training/landmark')) {
869
+ return this.handleTrainingLandmarkRequest(request, response);
870
+ }
871
+ if (path.endsWith('/api/training/apply')) {
872
+ return this.handleTrainingApplyRequest(response);
873
+ }
874
+
739
875
  // UI Routes
740
876
  if (path.endsWith('/ui/editor') || path.endsWith('/ui/editor/')) {
741
877
  return this.serveEditorUI(response);
742
878
  }
743
879
 
880
+ if (path.endsWith('/ui/training') || path.endsWith('/ui/training/')) {
881
+ return this.serveTrainingUI(response);
882
+ }
883
+
744
884
  if (path.includes('/ui/')) {
745
885
  return this.serveStaticFile(path, response);
746
886
  }
@@ -748,7 +888,7 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
748
888
  // Default: return info page
749
889
  response.send(JSON.stringify({
750
890
  name: 'Spatial Awareness Plugin',
751
- version: '0.3.0',
891
+ version: '0.4.0',
752
892
  endpoints: {
753
893
  api: {
754
894
  trackedObjects: '/api/tracked-objects',
@@ -760,9 +900,19 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
760
900
  liveTracking: '/api/live-tracking',
761
901
  connectionSuggestions: '/api/connection-suggestions',
762
902
  landmarkSuggestions: '/api/landmark-suggestions',
903
+ training: {
904
+ start: '/api/training/start',
905
+ pause: '/api/training/pause',
906
+ resume: '/api/training/resume',
907
+ end: '/api/training/end',
908
+ status: '/api/training/status',
909
+ landmark: '/api/training/landmark',
910
+ apply: '/api/training/apply',
911
+ },
763
912
  },
764
913
  ui: {
765
914
  editor: '/ui/editor',
915
+ training: '/ui/training',
766
916
  },
767
917
  },
768
918
  }), {
@@ -1228,12 +1378,187 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
1228
1378
  }
1229
1379
  }
1230
1380
 
1381
+ // ==================== Training Mode Handlers ====================
1382
+
1383
+ private handleTrainingStartRequest(request: HttpRequest, response: HttpResponse): void {
1384
+ if (!this.trackingEngine) {
1385
+ response.send(JSON.stringify({ error: 'Tracking engine not running. Configure topology first.' }), {
1386
+ code: 500,
1387
+ headers: { 'Content-Type': 'application/json' },
1388
+ });
1389
+ return;
1390
+ }
1391
+
1392
+ try {
1393
+ let config: Partial<TrainingConfig> | undefined;
1394
+ let trainerName: string | undefined;
1395
+
1396
+ if (request.body) {
1397
+ const body = JSON.parse(request.body);
1398
+ trainerName = body.trainerName;
1399
+ config = body.config;
1400
+ }
1401
+
1402
+ const session = this.trackingEngine.startTrainingSession(trainerName, config);
1403
+ response.send(JSON.stringify(session), {
1404
+ headers: { 'Content-Type': 'application/json' },
1405
+ });
1406
+ } catch (e) {
1407
+ response.send(JSON.stringify({ error: (e as Error).message }), {
1408
+ code: 500,
1409
+ headers: { 'Content-Type': 'application/json' },
1410
+ });
1411
+ }
1412
+ }
1413
+
1414
+ private handleTrainingPauseRequest(response: HttpResponse): void {
1415
+ if (!this.trackingEngine) {
1416
+ response.send(JSON.stringify({ error: 'Tracking engine not running' }), {
1417
+ code: 500,
1418
+ headers: { 'Content-Type': 'application/json' },
1419
+ });
1420
+ return;
1421
+ }
1422
+
1423
+ const success = this.trackingEngine.pauseTrainingSession();
1424
+ if (success) {
1425
+ response.send(JSON.stringify({ success: true }), {
1426
+ headers: { 'Content-Type': 'application/json' },
1427
+ });
1428
+ } else {
1429
+ response.send(JSON.stringify({ error: 'No active training session to pause' }), {
1430
+ code: 400,
1431
+ headers: { 'Content-Type': 'application/json' },
1432
+ });
1433
+ }
1434
+ }
1435
+
1436
+ private handleTrainingResumeRequest(response: HttpResponse): void {
1437
+ if (!this.trackingEngine) {
1438
+ response.send(JSON.stringify({ error: 'Tracking engine not running' }), {
1439
+ code: 500,
1440
+ headers: { 'Content-Type': 'application/json' },
1441
+ });
1442
+ return;
1443
+ }
1444
+
1445
+ const success = this.trackingEngine.resumeTrainingSession();
1446
+ if (success) {
1447
+ response.send(JSON.stringify({ success: true }), {
1448
+ headers: { 'Content-Type': 'application/json' },
1449
+ });
1450
+ } else {
1451
+ response.send(JSON.stringify({ error: 'No paused training session to resume' }), {
1452
+ code: 400,
1453
+ headers: { 'Content-Type': 'application/json' },
1454
+ });
1455
+ }
1456
+ }
1457
+
1458
+ private handleTrainingEndRequest(response: HttpResponse): void {
1459
+ if (!this.trackingEngine) {
1460
+ response.send(JSON.stringify({ error: 'Tracking engine not running' }), {
1461
+ code: 500,
1462
+ headers: { 'Content-Type': 'application/json' },
1463
+ });
1464
+ return;
1465
+ }
1466
+
1467
+ const session = this.trackingEngine.endTrainingSession();
1468
+ if (session) {
1469
+ response.send(JSON.stringify(session), {
1470
+ headers: { 'Content-Type': 'application/json' },
1471
+ });
1472
+ } else {
1473
+ response.send(JSON.stringify({ error: 'No training session to end' }), {
1474
+ code: 400,
1475
+ headers: { 'Content-Type': 'application/json' },
1476
+ });
1477
+ }
1478
+ }
1479
+
1480
+ private handleTrainingStatusRequest(response: HttpResponse): void {
1481
+ if (!this.trackingEngine) {
1482
+ response.send(JSON.stringify({ state: 'idle', stats: null }), {
1483
+ headers: { 'Content-Type': 'application/json' },
1484
+ });
1485
+ return;
1486
+ }
1487
+
1488
+ const status = this.trackingEngine.getTrainingStatus();
1489
+ if (status) {
1490
+ response.send(JSON.stringify(status), {
1491
+ headers: { 'Content-Type': 'application/json' },
1492
+ });
1493
+ } else {
1494
+ response.send(JSON.stringify({ state: 'idle', stats: null }), {
1495
+ headers: { 'Content-Type': 'application/json' },
1496
+ });
1497
+ }
1498
+ }
1499
+
1500
+ private handleTrainingLandmarkRequest(request: HttpRequest, response: HttpResponse): void {
1501
+ if (!this.trackingEngine) {
1502
+ response.send(JSON.stringify({ error: 'Tracking engine not running' }), {
1503
+ code: 500,
1504
+ headers: { 'Content-Type': 'application/json' },
1505
+ });
1506
+ return;
1507
+ }
1508
+
1509
+ try {
1510
+ const body = JSON.parse(request.body!) as Omit<TrainingLandmark, 'id' | 'markedAt'>;
1511
+ const landmark = this.trackingEngine.markTrainingLandmark(body);
1512
+ if (landmark) {
1513
+ response.send(JSON.stringify({ success: true, landmark }), {
1514
+ headers: { 'Content-Type': 'application/json' },
1515
+ });
1516
+ } else {
1517
+ response.send(JSON.stringify({ error: 'No active training session' }), {
1518
+ code: 400,
1519
+ headers: { 'Content-Type': 'application/json' },
1520
+ });
1521
+ }
1522
+ } catch (e) {
1523
+ response.send(JSON.stringify({ error: 'Invalid request body' }), {
1524
+ code: 400,
1525
+ headers: { 'Content-Type': 'application/json' },
1526
+ });
1527
+ }
1528
+ }
1529
+
1530
+ private handleTrainingApplyRequest(response: HttpResponse): void {
1531
+ if (!this.trackingEngine) {
1532
+ response.send(JSON.stringify({ error: 'Tracking engine not running' }), {
1533
+ code: 500,
1534
+ headers: { 'Content-Type': 'application/json' },
1535
+ });
1536
+ return;
1537
+ }
1538
+
1539
+ const result = this.trackingEngine.applyTrainingToTopology();
1540
+ if (result.success) {
1541
+ // Save the updated topology
1542
+ const topology = this.trackingEngine.getTopology();
1543
+ this.storage.setItem('topology', JSON.stringify(topology));
1544
+ }
1545
+ response.send(JSON.stringify(result), {
1546
+ headers: { 'Content-Type': 'application/json' },
1547
+ });
1548
+ }
1549
+
1231
1550
  private serveEditorUI(response: HttpResponse): void {
1232
1551
  response.send(EDITOR_HTML, {
1233
1552
  headers: { 'Content-Type': 'text/html' },
1234
1553
  });
1235
1554
  }
1236
1555
 
1556
+ private serveTrainingUI(response: HttpResponse): void {
1557
+ response.send(TRAINING_HTML, {
1558
+ headers: { 'Content-Type': 'text/html' },
1559
+ });
1560
+ }
1561
+
1237
1562
  private serveStaticFile(path: string, response: HttpResponse): void {
1238
1563
  // Serve static files for the UI
1239
1564
  response.send('Not found', { code: 404 });