@blueharford/scrypted-spatial-awareness 0.1.10 → 0.1.14

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/out/plugin.zip CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blueharford/scrypted-spatial-awareness",
3
- "version": "0.1.10",
3
+ "version": "0.1.14",
4
4
  "description": "Cross-camera object tracking for Scrypted NVR with spatial awareness",
5
5
  "author": "Joshua Seidel <blueharford>",
6
6
  "license": "Apache-2.0",
@@ -3,7 +3,7 @@
3
3
  * Generates and dispatches alerts based on tracking events
4
4
  */
5
5
 
6
- import sdk, { Notifier } from '@scrypted/sdk';
6
+ import sdk, { Notifier, Camera, ScryptedInterface, MediaObject } from '@scrypted/sdk';
7
7
  import {
8
8
  Alert,
9
9
  AlertRule,
@@ -15,7 +15,7 @@ import {
15
15
  } from '../models/alert';
16
16
  import { TrackedObject, GlobalTrackingId } from '../models/tracked-object';
17
17
 
18
- const { systemManager } = sdk;
18
+ const { systemManager, mediaManager } = sdk;
19
19
 
20
20
  export class AlertManager {
21
21
  private rules: AlertRule[] = [];
@@ -109,6 +109,20 @@ export class AlertManager {
109
109
  ? rule.notifiers
110
110
  : this.getDefaultNotifiers();
111
111
 
112
+ // Try to get a thumbnail from the camera
113
+ let mediaObject: MediaObject | undefined;
114
+ const cameraId = alert.details.toCameraId || alert.details.cameraId;
115
+ if (cameraId) {
116
+ try {
117
+ const camera = systemManager.getDeviceById<Camera>(cameraId);
118
+ if (camera && camera.interfaces?.includes(ScryptedInterface.Camera)) {
119
+ mediaObject = await camera.takePicture();
120
+ }
121
+ } catch (e) {
122
+ this.console.warn(`Failed to get thumbnail from camera ${cameraId}:`, e);
123
+ }
124
+ }
125
+
112
126
  for (const notifierId of notifierIds) {
113
127
  try {
114
128
  const notifier = systemManager.getDeviceById<Notifier>(notifierId);
@@ -127,10 +141,11 @@ export class AlertManager {
127
141
  trackedObjectId: alert.trackedObjectId,
128
142
  timestamp: alert.timestamp,
129
143
  },
130
- }
144
+ },
145
+ mediaObject
131
146
  );
132
147
 
133
- this.console.log(`Notification sent to ${notifierId}`);
148
+ this.console.log(`Notification sent to ${notifierId}${mediaObject ? ' with thumbnail' : ''}`);
134
149
  } catch (e) {
135
150
  this.console.error(`Failed to send notification to ${notifierId}:`, e);
136
151
  }
@@ -142,17 +157,19 @@ export class AlertManager {
142
157
  */
143
158
  private getNotificationTitle(alert: Alert): string {
144
159
  const prefix = alert.severity === 'critical' ? '🚨 ' :
145
- alert.severity === 'warning' ? 'âš ī¸ ' : 'â„šī¸ ';
160
+ alert.severity === 'warning' ? 'âš ī¸ ' : '';
146
161
 
147
162
  switch (alert.type) {
148
163
  case 'property_entry':
149
- return `${prefix}Entry Detected`;
164
+ return `${prefix}đŸšļ Entry Detected`;
150
165
  case 'property_exit':
151
- return `${prefix}Exit Detected`;
166
+ return `${prefix}đŸšļ Exit Detected`;
167
+ case 'movement':
168
+ return `${prefix}đŸšļ Movement Detected`;
152
169
  case 'unusual_path':
153
170
  return `${prefix}Unusual Path`;
154
171
  case 'dwell_time':
155
- return `${prefix}Extended Presence`;
172
+ return `${prefix}âąī¸ Extended Presence`;
156
173
  case 'restricted_zone':
157
174
  return `${prefix}Restricted Zone Alert`;
158
175
  case 'lost_tracking':
@@ -192,6 +192,8 @@ export class TrackingEngine {
192
192
  // Check if this is a cross-camera transition
193
193
  const lastSighting = getLastSighting(tracked);
194
194
  if (lastSighting && lastSighting.cameraId !== sighting.cameraId) {
195
+ const transitDuration = sighting.timestamp - lastSighting.timestamp;
196
+
195
197
  // Add journey segment
196
198
  this.state.addJourney(tracked.globalId, {
197
199
  fromCameraId: lastSighting.cameraId,
@@ -200,7 +202,7 @@ export class TrackingEngine {
200
202
  toCameraName: sighting.cameraName,
201
203
  exitTime: lastSighting.timestamp,
202
204
  entryTime: sighting.timestamp,
203
- transitDuration: sighting.timestamp - lastSighting.timestamp,
205
+ transitDuration,
204
206
  correlationConfidence: correlation.confidence,
205
207
  });
206
208
 
@@ -209,6 +211,18 @@ export class TrackingEngine {
209
211
  `${lastSighting.cameraName} → ${sighting.cameraName} ` +
210
212
  `(confidence: ${(correlation.confidence * 100).toFixed(0)}%)`
211
213
  );
214
+
215
+ // Generate movement alert for cross-camera transition
216
+ await this.alertManager.checkAndAlert('movement', tracked, {
217
+ fromCameraId: lastSighting.cameraId,
218
+ fromCameraName: lastSighting.cameraName,
219
+ toCameraId: sighting.cameraId,
220
+ toCameraName: sighting.cameraName,
221
+ transitTime: transitDuration,
222
+ objectClass: sighting.detection.className,
223
+ objectLabel: sighting.detection.label,
224
+ detectionId: sighting.detectionId,
225
+ });
212
226
  }
213
227
 
214
228
  // Add sighting to tracked object
package/src/main.ts CHANGED
@@ -336,6 +336,8 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
336
336
  const settings = await this.storageSettings.getSettings();
337
337
 
338
338
  // Topology editor button that opens modal overlay (appended to body for proper z-index)
339
+ 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);})()`;
340
+
339
341
  settings.push({
340
342
  key: 'topologyEditor',
341
343
  title: 'Topology Editor',
@@ -374,47 +376,43 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
374
376
  </style>
375
377
  <div class="sa-btn-container">
376
378
  <p class="sa-btn-desc">Configure camera positions, connections, and transit times</p>
377
- <button class="sa-open-btn" onclick="window.openSATopologyEditor()">
379
+ <button class="sa-open-btn" onclick="${onclickCode}">
378
380
  <span>&#9881;</span> Open Topology Editor
379
381
  </button>
380
382
  </div>
381
- <script>
382
- window.openSATopologyEditor = function() {
383
- var existing = document.getElementById('sa-topology-modal');
384
- if (existing) existing.remove();
385
- var modal = document.createElement('div');
386
- modal.id = 'sa-topology-modal';
387
- modal.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;';
388
- var container = document.createElement('div');
389
- container.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);';
390
- var closeBtn = document.createElement('button');
391
- closeBtn.innerHTML = '&times;';
392
- closeBtn.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;';
393
- closeBtn.onclick = function() { modal.remove(); };
394
- var iframe = document.createElement('iframe');
395
- iframe.src = '/endpoint/@blueharford/scrypted-spatial-awareness/ui/editor';
396
- iframe.style.cssText = 'width:100%;height:100%;border:none;';
397
- container.appendChild(closeBtn);
398
- container.appendChild(iframe);
399
- modal.appendChild(container);
400
- modal.onclick = function(e) { if (e.target === modal) modal.remove(); };
401
- document.body.appendChild(modal);
402
- };
403
- </script>
404
383
  `,
405
384
  group: 'Topology',
406
385
  });
407
386
 
408
387
  // Add status display
409
388
  const activeCount = this.trackingState.getActiveCount();
389
+ const topologyJson = this.storage.getItem('topology');
390
+ let statusText = 'Not configured - add cameras and configure topology';
391
+
392
+ if (this.trackingEngine) {
393
+ statusText = `Active: Tracking ${activeCount} object${activeCount !== 1 ? 's' : ''}`;
394
+ } else if (topologyJson) {
395
+ try {
396
+ const topology = JSON.parse(topologyJson) as CameraTopology;
397
+ if (topology.cameras && topology.cameras.length > 0) {
398
+ // Topology exists but engine not running - try to start it
399
+ statusText = `Configured (${topology.cameras.length} cameras) - Starting...`;
400
+ // Restart the tracking engine asynchronously
401
+ this.startTrackingEngine(topology).catch(e => {
402
+ this.console.error('Failed to restart tracking engine:', e);
403
+ });
404
+ }
405
+ } catch (e) {
406
+ statusText = 'Error loading topology';
407
+ }
408
+ }
409
+
410
410
  settings.push({
411
411
  key: 'status',
412
412
  title: 'Tracking Status',
413
413
  type: 'string',
414
414
  readonly: true,
415
- value: this.trackingEngine
416
- ? `Active: Tracking ${activeCount} object${activeCount !== 1 ? 's' : ''}`
417
- : 'Not configured - add cameras and configure topology',
415
+ value: statusText,
418
416
  group: 'Status',
419
417
  });
420
418
 
@@ -431,9 +429,83 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
431
429
  });
432
430
  }
433
431
 
432
+ // Add alert rules configuration UI
433
+ const alertRules = this.alertManager.getRules();
434
+ const rulesHtml = this.generateAlertRulesHtml(alertRules);
435
+ settings.push({
436
+ key: 'alertRulesEditor',
437
+ title: 'Alert Rules',
438
+ type: 'html' as any,
439
+ value: rulesHtml,
440
+ group: 'Alerts',
441
+ });
442
+
434
443
  return settings;
435
444
  }
436
445
 
446
+ private generateAlertRulesHtml(rules: any[]): string {
447
+ const ruleRows = rules.map(rule => `
448
+ <tr data-rule-id="${rule.id}">
449
+ <td style="padding:8px;border-bottom:1px solid #333;">
450
+ <input type="checkbox" ${rule.enabled ? 'checked' : ''}
451
+ onchange="(function(el){var rules=JSON.parse(localStorage.getItem('sa-temp-rules')||'[]');var r=rules.find(x=>x.id==='${rule.id}');if(r)r.enabled=el.checked;localStorage.setItem('sa-temp-rules',JSON.stringify(rules));})(this)" />
452
+ </td>
453
+ <td style="padding:8px;border-bottom:1px solid #333;color:#fff;">${rule.name}</td>
454
+ <td style="padding:8px;border-bottom:1px solid #333;color:#888;">${rule.type}</td>
455
+ <td style="padding:8px;border-bottom:1px solid #333;">
456
+ <span style="padding:2px 8px;border-radius:4px;font-size:12px;background:${
457
+ rule.severity === 'critical' ? '#e94560' :
458
+ rule.severity === 'warning' ? '#f39c12' : '#3498db'
459
+ };color:white;">${rule.severity}</span>
460
+ </td>
461
+ <td style="padding:8px;border-bottom:1px solid #333;color:#888;">${Math.round(rule.cooldown / 1000)}s</td>
462
+ </tr>
463
+ `).join('');
464
+
465
+ const initCode = `localStorage.setItem('sa-temp-rules',JSON.stringify(${JSON.stringify(rules)}))`;
466
+ const saveCode = `(function(){var rules=JSON.parse(localStorage.getItem('sa-temp-rules')||'[]');fetch('/endpoint/@blueharford/scrypted-spatial-awareness/api/alert-rules',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(rules)}).then(r=>r.json()).then(d=>{if(d.success)alert('Alert rules saved!');else alert('Error: '+d.error);}).catch(e=>alert('Error: '+e));})()`;
467
+
468
+ return `
469
+ <style>
470
+ .sa-rules-table { width:100%; border-collapse:collapse; margin-top:10px; }
471
+ .sa-rules-table th { text-align:left; padding:10px 8px; border-bottom:2px solid #e94560; color:#e94560; font-size:13px; }
472
+ .sa-save-rules-btn {
473
+ background: linear-gradient(135deg, #27ae60 0%, #2ecc71 100%);
474
+ color: white;
475
+ border: none;
476
+ padding: 10px 20px;
477
+ border-radius: 6px;
478
+ font-size: 14px;
479
+ font-weight: 600;
480
+ cursor: pointer;
481
+ margin-top: 15px;
482
+ }
483
+ .sa-save-rules-btn:hover { opacity: 0.9; }
484
+ .sa-rules-container { background:#16213e; border-radius:8px; padding:15px; }
485
+ .sa-rules-desc { color:#888; font-size:13px; margin-bottom:10px; }
486
+ </style>
487
+ <div class="sa-rules-container">
488
+ <p class="sa-rules-desc">Enable or disable alert types. Movement alerts notify you when someone moves between cameras.</p>
489
+ <table class="sa-rules-table">
490
+ <thead>
491
+ <tr>
492
+ <th style="width:40px;">On</th>
493
+ <th>Alert Type</th>
494
+ <th>Event</th>
495
+ <th>Severity</th>
496
+ <th>Cooldown</th>
497
+ </tr>
498
+ </thead>
499
+ <tbody>
500
+ ${ruleRows}
501
+ </tbody>
502
+ </table>
503
+ <button class="sa-save-rules-btn" onclick="${saveCode}">Save Alert Rules</button>
504
+ <script>(function(){${initCode}})();</script>
505
+ </div>
506
+ `;
507
+ }
508
+
437
509
  async putSetting(key: string, value: SettingValue): Promise<void> {
438
510
  await this.storageSettings.putSetting(key, value);
439
511
 
@@ -487,13 +559,17 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
487
559
  }
488
560
 
489
561
  if (path.endsWith('/api/topology')) {
490
- return this.handleTopologyRequest(request, response);
562
+ return await this.handleTopologyRequest(request, response);
491
563
  }
492
564
 
493
565
  if (path.endsWith('/api/alerts')) {
494
566
  return this.handleAlertsRequest(request, response);
495
567
  }
496
568
 
569
+ if (path.endsWith('/api/alert-rules')) {
570
+ return this.handleAlertRulesRequest(request, response);
571
+ }
572
+
497
573
  if (path.endsWith('/api/cameras')) {
498
574
  return this.handleCamerasRequest(response);
499
575
  }
@@ -573,7 +649,7 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
573
649
  }
574
650
  }
575
651
 
576
- private handleTopologyRequest(request: HttpRequest, response: HttpResponse): void {
652
+ private async handleTopologyRequest(request: HttpRequest, response: HttpResponse): Promise<void> {
577
653
  if (request.method === 'GET') {
578
654
  const topologyJson = this.storage.getItem('topology');
579
655
  const topology = topologyJson ? JSON.parse(topologyJson) : createEmptyTopology();
@@ -584,7 +660,7 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
584
660
  try {
585
661
  const topology = JSON.parse(request.body!) as CameraTopology;
586
662
  this.storage.setItem('topology', JSON.stringify(topology));
587
- this.startTrackingEngine(topology);
663
+ await this.startTrackingEngine(topology);
588
664
  response.send(JSON.stringify({ success: true }), {
589
665
  headers: { 'Content-Type': 'application/json' },
590
666
  });
@@ -604,6 +680,28 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
604
680
  });
605
681
  }
606
682
 
683
+ private handleAlertRulesRequest(request: HttpRequest, response: HttpResponse): void {
684
+ if (request.method === 'GET') {
685
+ const rules = this.alertManager.getRules();
686
+ response.send(JSON.stringify(rules), {
687
+ headers: { 'Content-Type': 'application/json' },
688
+ });
689
+ } else if (request.method === 'PUT' || request.method === 'POST') {
690
+ try {
691
+ const rules = JSON.parse(request.body!);
692
+ this.alertManager.setRules(rules);
693
+ response.send(JSON.stringify({ success: true }), {
694
+ headers: { 'Content-Type': 'application/json' },
695
+ });
696
+ } catch (e) {
697
+ response.send(JSON.stringify({ error: 'Invalid rules JSON' }), {
698
+ code: 400,
699
+ headers: { 'Content-Type': 'application/json' },
700
+ });
701
+ }
702
+ }
703
+ }
704
+
607
705
  private handleCamerasRequest(response: HttpResponse): void {
608
706
  try {
609
707
  // Get all devices with ObjectDetector interface
@@ -12,6 +12,7 @@ export type AlertSeverity = 'info' | 'warning' | 'critical';
12
12
  export type AlertType =
13
13
  | 'property_entry'
14
14
  | 'property_exit'
15
+ | 'movement'
15
16
  | 'unusual_path'
16
17
  | 'dwell_time'
17
18
  | 'restricted_zone'
@@ -51,10 +52,20 @@ export interface AlertDetails {
51
52
  cameraId?: string;
52
53
  /** Camera display name */
53
54
  cameraName?: string;
55
+ /** Source camera for movement alerts */
56
+ fromCameraId?: string;
57
+ /** Source camera name for movement alerts */
58
+ fromCameraName?: string;
59
+ /** Destination camera for movement alerts */
60
+ toCameraId?: string;
61
+ /** Destination camera name for movement alerts */
62
+ toCameraName?: string;
54
63
  /** Zone name (for zone-related alerts) */
55
64
  zoneName?: string;
56
65
  /** Dwell time in milliseconds (for dwell alerts) */
57
66
  dwellTime?: number;
67
+ /** Transit time in milliseconds (for movement alerts) */
68
+ transitTime?: number;
58
69
  /** Expected path (for unusual path alerts) */
59
70
  expectedPath?: string;
60
71
  /** Actual path taken */
@@ -116,7 +127,7 @@ export function createDefaultRules(): AlertRule[] {
116
127
  conditions: [],
117
128
  severity: 'info',
118
129
  notifiers: [],
119
- cooldown: 300000, // 5 minutes
130
+ cooldown: 60000, // 1 minute
120
131
  },
121
132
  {
122
133
  id: 'property-exit',
@@ -126,7 +137,17 @@ export function createDefaultRules(): AlertRule[] {
126
137
  conditions: [],
127
138
  severity: 'info',
128
139
  notifiers: [],
129
- cooldown: 300000,
140
+ cooldown: 60000,
141
+ },
142
+ {
143
+ id: 'movement',
144
+ name: 'Movement Between Cameras',
145
+ enabled: true,
146
+ type: 'movement',
147
+ conditions: [],
148
+ severity: 'info',
149
+ notifiers: [],
150
+ cooldown: 10000, // 10 seconds - we want frequent movement updates
130
151
  },
131
152
  {
132
153
  id: 'unusual-path',
@@ -178,15 +199,21 @@ export function generateAlertMessage(
178
199
  type: AlertType,
179
200
  details: AlertDetails
180
201
  ): string {
202
+ // Capitalize the object class for display (person -> Person, car -> Car, dog -> Dog)
203
+ const capitalize = (s: string) => s ? s.charAt(0).toUpperCase() + s.slice(1) : 'Object';
181
204
  const objectDesc = details.objectLabel
182
- ? `${details.objectClass} (${details.objectLabel})`
183
- : details.objectClass || 'Object';
205
+ ? `${capitalize(details.objectClass || '')} (${details.objectLabel})`
206
+ : capitalize(details.objectClass || '');
184
207
 
185
208
  switch (type) {
186
209
  case 'property_entry':
187
210
  return `${objectDesc} entered property via ${details.cameraName || 'unknown camera'}`;
188
211
  case 'property_exit':
189
212
  return `${objectDesc} exited property via ${details.cameraName || 'unknown camera'}`;
213
+ case 'movement':
214
+ const transitSecs = details.transitTime ? Math.round(details.transitTime / 1000) : 0;
215
+ const transitStr = transitSecs > 0 ? ` (${transitSecs}s transit)` : '';
216
+ return `${objectDesc} moving from ${details.fromCameraName || 'unknown'} towards ${details.toCameraName || 'unknown'}${transitStr}`;
190
217
  case 'unusual_path':
191
218
  return `${objectDesc} took unusual path: ${details.actualPath || 'unknown'}`;
192
219
  case 'dwell_time':