@blueharford/scrypted-spatial-awareness 0.1.16 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Camera Topology Models
3
- * Defines the spatial relationships between cameras in the NVR system
3
+ * Defines the spatial relationships between cameras, landmarks, and zones
4
4
  */
5
5
 
6
6
  /** A point in 2D space (normalized 0-100 or pixel coordinates) */
@@ -15,14 +15,106 @@ export interface FloorPlanPosition {
15
15
  y: number;
16
16
  }
17
17
 
18
+ // ==================== Landmark Types ====================
19
+
20
+ /** Types of landmarks in the topology */
21
+ export type LandmarkType =
22
+ | 'structure' // House, shed, garage, porch
23
+ | 'feature' // Mailbox, tree, firepit, deck, pool
24
+ | 'boundary' // Fence, wall, hedge, property line
25
+ | 'access' // Driveway, walkway, gate, door, stairs
26
+ | 'vehicle' // Parked car location, boat, RV
27
+ | 'neighbor' // Neighbor's house, neighbor's driveway
28
+ | 'zone' // Front yard, back yard, side yard
29
+ | 'street'; // Street, sidewalk, alley
30
+
31
+ /** Common landmark templates for quick setup */
32
+ export const LANDMARK_TEMPLATES: { type: LandmarkType; suggestions: string[] }[] = [
33
+ { type: 'structure', suggestions: ['House', 'Garage', 'Shed', 'Porch', 'Deck', 'Patio', 'Gazebo', 'Pool House'] },
34
+ { type: 'feature', suggestions: ['Mailbox', 'Tree', 'Firepit', 'Pool', 'Hot Tub', 'Garden', 'Fountain', 'Flagpole'] },
35
+ { type: 'boundary', suggestions: ['Front Fence', 'Back Fence', 'Side Fence', 'Hedge', 'Wall', 'Property Line'] },
36
+ { type: 'access', suggestions: ['Driveway', 'Front Walkway', 'Back Walkway', 'Front Door', 'Back Door', 'Side Door', 'Gate', 'Stairs'] },
37
+ { type: 'vehicle', suggestions: ['Car Parking', 'Boat', 'RV Pad', 'Motorcycle Spot'] },
38
+ { type: 'neighbor', suggestions: ["Neighbor's House", "Neighbor's Driveway", "Neighbor's Yard"] },
39
+ { type: 'zone', suggestions: ['Front Yard', 'Back Yard', 'Side Yard', 'Courtyard'] },
40
+ { type: 'street', suggestions: ['Street', 'Sidewalk', 'Alley', 'Cul-de-sac'] },
41
+ ];
42
+
43
+ /** A landmark/static object in the topology */
44
+ export interface Landmark {
45
+ /** Unique identifier */
46
+ id: string;
47
+ /** Display name (e.g., "Mailbox", "Red Shed") */
48
+ name: string;
49
+ /** Type of landmark */
50
+ type: LandmarkType;
51
+ /** Position on floor plan */
52
+ position: FloorPlanPosition;
53
+ /** Optional polygon outline on floor plan */
54
+ outline?: ClipPath;
55
+ /** Human-readable description for LLM context */
56
+ description?: string;
57
+ /** Can someone enter property through/near this landmark? */
58
+ isEntryPoint?: boolean;
59
+ /** Can someone exit property through/near this landmark? */
60
+ isExitPoint?: boolean;
61
+ /** IDs of adjacent landmarks (for path calculation) */
62
+ adjacentTo?: string[];
63
+ /** IDs of cameras that can see this landmark */
64
+ visibleFromCameras?: string[];
65
+ /** Whether this was suggested by AI (pending user confirmation) */
66
+ aiSuggested?: boolean;
67
+ /** Confidence score if AI suggested (0-1) */
68
+ aiConfidence?: number;
69
+ }
70
+
71
+ // ==================== Camera FOV Types ====================
72
+
73
+ /** Camera field of view - simple configuration */
74
+ export interface CameraFOVSimple {
75
+ mode: 'simple';
76
+ /** FOV angle in degrees (e.g., 90, 120) */
77
+ angle: number;
78
+ /** Direction the camera faces in degrees (0 = up/north on floor plan, 90 = right/east) */
79
+ direction: number;
80
+ /** How far the camera can see (in floor plan units) */
81
+ range: number;
82
+ }
83
+
84
+ /** Camera field of view - polygon configuration */
85
+ export interface CameraFOVPolygon {
86
+ mode: 'polygon';
87
+ /** Polygon defining exact coverage area on floor plan */
88
+ polygon: ClipPath;
89
+ }
90
+
18
91
  /** Camera field of view configuration */
19
- export interface CameraFOV {
20
- /** FOV angle in degrees */
92
+ export type CameraFOV = CameraFOVSimple | CameraFOVPolygon;
93
+
94
+ /** Legacy FOV format for backward compatibility */
95
+ export interface CameraFOVLegacy {
21
96
  angle: number;
22
- /** Direction the camera faces in degrees from north (0 = north, 90 = east) */
23
97
  direction: number;
24
98
  }
25
99
 
100
+ // ==================== Camera Context ====================
101
+
102
+ /** Rich context description for a camera */
103
+ export interface CameraContext {
104
+ /** Where the camera is mounted (e.g., "Under front porch awning", "On garage wall") */
105
+ mountLocation?: string;
106
+ /** What the camera is pointing at / what it can see */
107
+ description?: string;
108
+ /** Height of camera mount in feet (helps with perspective understanding) */
109
+ mountHeight?: number;
110
+ /** IDs of landmarks visible from this camera */
111
+ visibleLandmarks?: string[];
112
+ /** Natural language description of camera coverage for LLM */
113
+ coverageDescription?: string;
114
+ }
115
+
116
+ // ==================== Transit Configuration ====================
117
+
26
118
  /** Transit time configuration between cameras */
27
119
  export interface TransitTime {
28
120
  /** Minimum expected transit time in milliseconds */
@@ -33,6 +125,8 @@ export interface TransitTime {
33
125
  max: number;
34
126
  }
35
127
 
128
+ // ==================== Camera Node ====================
129
+
36
130
  /** Represents a camera in the topology */
37
131
  export interface CameraNode {
38
132
  /** Scrypted device ID */
@@ -44,15 +138,19 @@ export interface CameraNode {
44
138
  /** Position on floor plan (optional) */
45
139
  floorPlanPosition?: FloorPlanPosition;
46
140
  /** Camera field of view configuration (optional) */
47
- fov?: CameraFOV;
141
+ fov?: CameraFOV | CameraFOVLegacy;
48
142
  /** Is this an entry point to the property */
49
143
  isEntryPoint: boolean;
50
144
  /** Is this an exit point from the property */
51
145
  isExitPoint: boolean;
52
146
  /** Detection classes to track on this camera */
53
147
  trackClasses: string[];
148
+ /** Rich context description */
149
+ context?: CameraContext;
54
150
  }
55
151
 
152
+ // ==================== Connections ====================
153
+
56
154
  /** Represents a connection between two cameras */
57
155
  export interface CameraConnection {
58
156
  /** Unique identifier for this connection */
@@ -71,8 +169,12 @@ export interface CameraConnection {
71
169
  bidirectional: boolean;
72
170
  /** Human-readable path name (e.g., "Driveway to Front Door") */
73
171
  name: string;
172
+ /** Landmarks along this path (for rich descriptions) */
173
+ pathLandmarks?: string[];
74
174
  }
75
175
 
176
+ // ==================== Zones ====================
177
+
76
178
  /** Zone type for alerting purposes */
77
179
  export type GlobalZoneType = 'entry' | 'exit' | 'dwell' | 'restricted';
78
180
 
@@ -96,6 +198,55 @@ export interface CameraZoneMapping {
96
198
  zone: ClipPath;
97
199
  }
98
200
 
201
+ // ==================== Spatial Relationships ====================
202
+
203
+ /** Types of spatial relationships between entities */
204
+ export type RelationshipType =
205
+ | 'adjacent' // Next to each other
206
+ | 'leads_to' // Path from A leads to B
207
+ | 'visible_from' // A is visible from B
208
+ | 'part_of' // A is part of B (e.g., front door is part of house)
209
+ | 'contains' // A contains B
210
+ | 'near' // Close proximity
211
+ | 'across_from' // Opposite sides
212
+ | 'between'; // A is between B and C
213
+
214
+ /** A spatial relationship between two entities */
215
+ export interface SpatialRelationship {
216
+ /** Unique identifier */
217
+ id: string;
218
+ /** Type of relationship */
219
+ type: RelationshipType;
220
+ /** First entity (camera ID or landmark ID) */
221
+ entityA: string;
222
+ /** Second entity (camera ID or landmark ID) */
223
+ entityB: string;
224
+ /** Optional third entity for 'between' relationships */
225
+ entityC?: string;
226
+ /** Optional description */
227
+ description?: string;
228
+ /** Whether this was auto-inferred */
229
+ autoInferred?: boolean;
230
+ }
231
+
232
+ // ==================== Property Configuration ====================
233
+
234
+ /** Overall property description for context */
235
+ export interface PropertyConfig {
236
+ /** Type of property */
237
+ propertyType?: 'single_family' | 'townhouse' | 'apartment' | 'condo' | 'commercial' | 'other';
238
+ /** Description of the property for LLM context */
239
+ description?: string;
240
+ /** Address (optional, for context) */
241
+ address?: string;
242
+ /** Lot features */
243
+ features?: string[];
244
+ /** Cardinal direction the front of the property faces */
245
+ frontFacing?: 'north' | 'south' | 'east' | 'west' | 'northeast' | 'northwest' | 'southeast' | 'southwest';
246
+ }
247
+
248
+ // ==================== Floor Plan ====================
249
+
99
250
  /** Floor plan image configuration */
100
251
  export interface FloorPlanConfig {
101
252
  /** Base64 encoded image data or URL */
@@ -104,30 +255,64 @@ export interface FloorPlanConfig {
104
255
  width: number;
105
256
  /** Image height in pixels */
106
257
  height: number;
107
- /** Scale factor (pixels per real-world unit) */
258
+ /** Scale factor (pixels per real-world foot, for distance calculations) */
108
259
  scale?: number;
260
+ /** Rotation of the floor plan (0 = north is up) */
261
+ rotation?: number;
109
262
  }
110
263
 
264
+ // ==================== AI Suggestions ====================
265
+
266
+ /** An AI-suggested landmark pending user confirmation */
267
+ export interface LandmarkSuggestion {
268
+ /** Unique ID for this suggestion */
269
+ id: string;
270
+ /** Suggested landmark */
271
+ landmark: Landmark;
272
+ /** Which camera(s) detected this */
273
+ detectedByCameras: string[];
274
+ /** When this was suggested */
275
+ timestamp: number;
276
+ /** Detection count (how many times AI identified this) */
277
+ detectionCount: number;
278
+ /** Status */
279
+ status: 'pending' | 'accepted' | 'rejected';
280
+ }
281
+
282
+ // ==================== Complete Topology ====================
283
+
111
284
  /** Complete camera topology configuration */
112
285
  export interface CameraTopology {
113
286
  /** Version for migration support */
114
287
  version: string;
288
+ /** Property-level configuration */
289
+ property?: PropertyConfig;
115
290
  /** All cameras in the system */
116
291
  cameras: CameraNode[];
117
292
  /** Connections between cameras */
118
293
  connections: CameraConnection[];
294
+ /** Static landmarks/objects */
295
+ landmarks: Landmark[];
296
+ /** Spatial relationships (auto-inferred + manual) */
297
+ relationships: SpatialRelationship[];
119
298
  /** Named zones spanning multiple cameras */
120
299
  globalZones: GlobalZone[];
121
300
  /** Floor plan configuration (optional) */
122
301
  floorPlan?: FloorPlanConfig;
302
+ /** Pending AI landmark suggestions */
303
+ pendingSuggestions?: LandmarkSuggestion[];
123
304
  }
124
305
 
306
+ // ==================== Helper Functions ====================
307
+
125
308
  /** Creates an empty topology */
126
309
  export function createEmptyTopology(): CameraTopology {
127
310
  return {
128
- version: '1.0',
311
+ version: '2.0',
129
312
  cameras: [],
130
313
  connections: [],
314
+ landmarks: [],
315
+ relationships: [],
131
316
  globalZones: [],
132
317
  };
133
318
  }
@@ -137,6 +322,11 @@ export function findCamera(topology: CameraTopology, deviceId: string): CameraNo
137
322
  return topology.cameras.find(c => c.deviceId === deviceId);
138
323
  }
139
324
 
325
+ /** Finds a landmark by ID */
326
+ export function findLandmark(topology: CameraTopology, landmarkId: string): Landmark | undefined {
327
+ return topology.landmarks.find(l => l.id === landmarkId);
328
+ }
329
+
140
330
  /** Finds connections from a camera */
141
331
  export function findConnectionsFrom(topology: CameraTopology, cameraId: string): CameraConnection[] {
142
332
  return topology.connections.filter(c =>
@@ -151,10 +341,10 @@ export function findConnection(
151
341
  fromCameraId: string,
152
342
  toCameraId: string
153
343
  ): CameraConnection | undefined {
154
- return topology.connections.find(c =>
344
+ return topology.connections.filter(c =>
155
345
  (c.fromCameraId === fromCameraId && c.toCameraId === toCameraId) ||
156
346
  (c.bidirectional && c.fromCameraId === toCameraId && c.toCameraId === fromCameraId)
157
- );
347
+ )[0];
158
348
  }
159
349
 
160
350
  /** Gets all entry point cameras */
@@ -166,3 +356,186 @@ export function getEntryPoints(topology: CameraTopology): CameraNode[] {
166
356
  export function getExitPoints(topology: CameraTopology): CameraNode[] {
167
357
  return topology.cameras.filter(c => c.isExitPoint);
168
358
  }
359
+
360
+ /** Gets landmarks visible from a camera */
361
+ export function getLandmarksVisibleFromCamera(topology: CameraTopology, cameraId: string): Landmark[] {
362
+ const camera = findCamera(topology, cameraId);
363
+ if (!camera?.context?.visibleLandmarks) return [];
364
+ return camera.context.visibleLandmarks
365
+ .map(id => findLandmark(topology, id))
366
+ .filter((l): l is Landmark => l !== undefined);
367
+ }
368
+
369
+ /** Gets cameras that can see a landmark */
370
+ export function getCamerasWithLandmarkVisibility(topology: CameraTopology, landmarkId: string): CameraNode[] {
371
+ return topology.cameras.filter(c =>
372
+ c.context?.visibleLandmarks?.includes(landmarkId)
373
+ );
374
+ }
375
+
376
+ /** Gets adjacent landmarks */
377
+ export function getAdjacentLandmarks(topology: CameraTopology, landmarkId: string): Landmark[] {
378
+ const landmark = findLandmark(topology, landmarkId);
379
+ if (!landmark?.adjacentTo) return [];
380
+ return landmark.adjacentTo
381
+ .map(id => findLandmark(topology, id))
382
+ .filter((l): l is Landmark => l !== undefined);
383
+ }
384
+
385
+ /** Calculates distance between two floor plan positions */
386
+ export function calculateDistance(posA: FloorPlanPosition, posB: FloorPlanPosition): number {
387
+ const dx = posB.x - posA.x;
388
+ const dy = posB.y - posA.y;
389
+ return Math.sqrt(dx * dx + dy * dy);
390
+ }
391
+
392
+ /** Auto-infers relationships based on positions and proximity */
393
+ export function inferRelationships(topology: CameraTopology, proximityThreshold: number = 50): SpatialRelationship[] {
394
+ const relationships: SpatialRelationship[] = [];
395
+ const entities: { id: string; position: FloorPlanPosition; type: 'camera' | 'landmark' }[] = [];
396
+
397
+ // Collect all positioned entities
398
+ for (const camera of topology.cameras) {
399
+ if (camera.floorPlanPosition) {
400
+ entities.push({ id: camera.deviceId, position: camera.floorPlanPosition, type: 'camera' });
401
+ }
402
+ }
403
+ for (const landmark of topology.landmarks) {
404
+ entities.push({ id: landmark.id, position: landmark.position, type: 'landmark' });
405
+ }
406
+
407
+ // Find adjacent entities based on proximity
408
+ for (let i = 0; i < entities.length; i++) {
409
+ for (let j = i + 1; j < entities.length; j++) {
410
+ const distance = calculateDistance(entities[i].position, entities[j].position);
411
+ if (distance <= proximityThreshold) {
412
+ relationships.push({
413
+ id: `auto_${entities[i].id}_${entities[j].id}`,
414
+ type: distance <= proximityThreshold / 2 ? 'adjacent' : 'near',
415
+ entityA: entities[i].id,
416
+ entityB: entities[j].id,
417
+ autoInferred: true,
418
+ });
419
+ }
420
+ }
421
+ }
422
+
423
+ return relationships;
424
+ }
425
+
426
+ /** Generates a natural language description of the topology for LLM context */
427
+ export function generateTopologyDescription(topology: CameraTopology): string {
428
+ const lines: string[] = [];
429
+
430
+ // Property description
431
+ if (topology.property?.description) {
432
+ lines.push(`Property: ${topology.property.description}`);
433
+ }
434
+ if (topology.property?.frontFacing) {
435
+ lines.push(`Front of property faces ${topology.property.frontFacing}.`);
436
+ }
437
+
438
+ // Landmarks
439
+ if (topology.landmarks.length > 0) {
440
+ lines.push('\nLandmarks on property:');
441
+ for (const landmark of topology.landmarks) {
442
+ let desc = `- ${landmark.name} (${landmark.type})`;
443
+ if (landmark.description) desc += `: ${landmark.description}`;
444
+ if (landmark.isEntryPoint) desc += ' [Entry point]';
445
+ if (landmark.isExitPoint) desc += ' [Exit point]';
446
+ lines.push(desc);
447
+ }
448
+ }
449
+
450
+ // Cameras
451
+ if (topology.cameras.length > 0) {
452
+ lines.push('\nCamera coverage:');
453
+ for (const camera of topology.cameras) {
454
+ let desc = `- ${camera.name}`;
455
+ if (camera.context?.mountLocation) desc += ` (mounted at ${camera.context.mountLocation})`;
456
+ if (camera.context?.coverageDescription) desc += `: ${camera.context.coverageDescription}`;
457
+ if (camera.isEntryPoint) desc += ' [Watches entry point]';
458
+ if (camera.isExitPoint) desc += ' [Watches exit point]';
459
+
460
+ // List visible landmarks
461
+ if (camera.context?.visibleLandmarks && camera.context.visibleLandmarks.length > 0) {
462
+ const landmarkNames = camera.context.visibleLandmarks
463
+ .map(id => findLandmark(topology, id)?.name)
464
+ .filter(Boolean);
465
+ if (landmarkNames.length > 0) {
466
+ desc += ` Can see: ${landmarkNames.join(', ')}`;
467
+ }
468
+ }
469
+ lines.push(desc);
470
+ }
471
+ }
472
+
473
+ // Connections/paths
474
+ if (topology.connections.length > 0) {
475
+ lines.push('\nMovement paths:');
476
+ for (const conn of topology.connections) {
477
+ const fromCam = findCamera(topology, conn.fromCameraId);
478
+ const toCam = findCamera(topology, conn.toCameraId);
479
+ if (fromCam && toCam) {
480
+ let desc = `- ${fromCam.name} → ${toCam.name}`;
481
+ if (conn.name) desc += ` (${conn.name})`;
482
+ desc += ` [${conn.transitTime.min / 1000}-${conn.transitTime.max / 1000}s transit]`;
483
+ if (conn.bidirectional) desc += ' [bidirectional]';
484
+ lines.push(desc);
485
+ }
486
+ }
487
+ }
488
+
489
+ return lines.join('\n');
490
+ }
491
+
492
+ /** Generates context for a specific movement between cameras */
493
+ export function generateMovementContext(
494
+ topology: CameraTopology,
495
+ fromCameraId: string,
496
+ toCameraId: string,
497
+ objectClass: string
498
+ ): string {
499
+ const fromCamera = findCamera(topology, fromCameraId);
500
+ const toCamera = findCamera(topology, toCameraId);
501
+ const connection = findConnection(topology, fromCameraId, toCameraId);
502
+
503
+ if (!fromCamera || !toCamera) {
504
+ return `${objectClass} moving between cameras`;
505
+ }
506
+
507
+ const lines: string[] = [];
508
+
509
+ // Source context
510
+ lines.push(`Origin: ${fromCamera.name}`);
511
+ if (fromCamera.context?.coverageDescription) {
512
+ lines.push(` Coverage: ${fromCamera.context.coverageDescription}`);
513
+ }
514
+
515
+ // Destination context
516
+ lines.push(`Destination: ${toCamera.name}`);
517
+ if (toCamera.context?.coverageDescription) {
518
+ lines.push(` Coverage: ${toCamera.context.coverageDescription}`);
519
+ }
520
+
521
+ // Path context
522
+ if (connection) {
523
+ if (connection.name) lines.push(`Path: ${connection.name}`);
524
+ if (connection.pathLandmarks && connection.pathLandmarks.length > 0) {
525
+ const landmarkNames = connection.pathLandmarks
526
+ .map(id => findLandmark(topology, id)?.name)
527
+ .filter(Boolean);
528
+ if (landmarkNames.length > 0) {
529
+ lines.push(`Passing: ${landmarkNames.join(' → ')}`);
530
+ }
531
+ }
532
+ }
533
+
534
+ // Nearby landmarks at destination
535
+ const destLandmarks = getLandmarksVisibleFromCamera(topology, toCameraId);
536
+ if (destLandmarks.length > 0) {
537
+ lines.push(`Near: ${destLandmarks.map(l => l.name).join(', ')}`);
538
+ }
539
+
540
+ return lines.join('\n');
541
+ }