@blueharford/scrypted-spatial-awareness 0.1.15 → 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.
@@ -35006,6 +35006,575 @@ class ObjectCorrelator {
35006
35006
  exports.ObjectCorrelator = ObjectCorrelator;
35007
35007
 
35008
35008
 
35009
+ /***/ },
35010
+
35011
+ /***/ "./src/core/spatial-reasoning.ts"
35012
+ /*!***************************************!*\
35013
+ !*** ./src/core/spatial-reasoning.ts ***!
35014
+ \***************************************/
35015
+ (__unused_webpack_module, exports, __webpack_require__) {
35016
+
35017
+ "use strict";
35018
+
35019
+ /**
35020
+ * Spatial Reasoning Engine
35021
+ * Uses RAG (Retrieval Augmented Generation) to provide rich contextual understanding
35022
+ * of movement across the property topology
35023
+ */
35024
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
35025
+ if (k2 === undefined) k2 = k;
35026
+ var desc = Object.getOwnPropertyDescriptor(m, k);
35027
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
35028
+ desc = { enumerable: true, get: function() { return m[k]; } };
35029
+ }
35030
+ Object.defineProperty(o, k2, desc);
35031
+ }) : (function(o, m, k, k2) {
35032
+ if (k2 === undefined) k2 = k;
35033
+ o[k2] = m[k];
35034
+ }));
35035
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
35036
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
35037
+ }) : function(o, v) {
35038
+ o["default"] = v;
35039
+ });
35040
+ var __importStar = (this && this.__importStar) || (function () {
35041
+ var ownKeys = function(o) {
35042
+ ownKeys = Object.getOwnPropertyNames || function (o) {
35043
+ var ar = [];
35044
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
35045
+ return ar;
35046
+ };
35047
+ return ownKeys(o);
35048
+ };
35049
+ return function (mod) {
35050
+ if (mod && mod.__esModule) return mod;
35051
+ var result = {};
35052
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
35053
+ __setModuleDefault(result, mod);
35054
+ return result;
35055
+ };
35056
+ })();
35057
+ Object.defineProperty(exports, "__esModule", ({ value: true }));
35058
+ exports.SpatialReasoningEngine = void 0;
35059
+ const sdk_1 = __importStar(__webpack_require__(/*! @scrypted/sdk */ "./node_modules/@scrypted/sdk/dist/src/index.js"));
35060
+ const topology_1 = __webpack_require__(/*! ../models/topology */ "./src/models/topology.ts");
35061
+ const { systemManager } = sdk_1.default;
35062
+ class SpatialReasoningEngine {
35063
+ config;
35064
+ console;
35065
+ topology = null;
35066
+ llmDevice = null;
35067
+ contextChunks = [];
35068
+ topologyContextCache = null;
35069
+ contextCacheTime = 0;
35070
+ landmarkSuggestions = new Map();
35071
+ constructor(config, console) {
35072
+ this.config = config;
35073
+ this.console = console;
35074
+ }
35075
+ /** Update the topology and rebuild context */
35076
+ updateTopology(topology) {
35077
+ this.topology = topology;
35078
+ this.rebuildContextChunks();
35079
+ this.topologyContextCache = null;
35080
+ this.contextCacheTime = 0;
35081
+ }
35082
+ /** Build context chunks for RAG retrieval */
35083
+ rebuildContextChunks() {
35084
+ if (!this.topology)
35085
+ return;
35086
+ this.contextChunks = [];
35087
+ // Property context
35088
+ if (this.topology.property) {
35089
+ this.contextChunks.push({
35090
+ id: 'property',
35091
+ type: 'property',
35092
+ content: this.buildPropertyContext(),
35093
+ metadata: { ...this.topology.property },
35094
+ });
35095
+ }
35096
+ // Camera contexts
35097
+ for (const camera of this.topology.cameras) {
35098
+ this.contextChunks.push({
35099
+ id: `camera_${camera.deviceId}`,
35100
+ type: 'camera',
35101
+ content: this.buildCameraContext(camera),
35102
+ metadata: {
35103
+ deviceId: camera.deviceId,
35104
+ name: camera.name,
35105
+ isEntryPoint: camera.isEntryPoint,
35106
+ isExitPoint: camera.isExitPoint,
35107
+ },
35108
+ });
35109
+ }
35110
+ // Landmark contexts
35111
+ for (const landmark of this.topology.landmarks) {
35112
+ this.contextChunks.push({
35113
+ id: `landmark_${landmark.id}`,
35114
+ type: 'landmark',
35115
+ content: this.buildLandmarkContext(landmark),
35116
+ metadata: {
35117
+ id: landmark.id,
35118
+ name: landmark.name,
35119
+ type: landmark.type,
35120
+ isEntryPoint: landmark.isEntryPoint,
35121
+ isExitPoint: landmark.isExitPoint,
35122
+ },
35123
+ });
35124
+ }
35125
+ // Connection contexts
35126
+ for (const connection of this.topology.connections) {
35127
+ this.contextChunks.push({
35128
+ id: `connection_${connection.id}`,
35129
+ type: 'connection',
35130
+ content: this.buildConnectionContext(connection),
35131
+ metadata: {
35132
+ id: connection.id,
35133
+ fromCameraId: connection.fromCameraId,
35134
+ toCameraId: connection.toCameraId,
35135
+ },
35136
+ });
35137
+ }
35138
+ this.console.log(`Built ${this.contextChunks.length} context chunks for spatial reasoning`);
35139
+ }
35140
+ /** Build property context string */
35141
+ buildPropertyContext() {
35142
+ if (!this.topology?.property)
35143
+ return '';
35144
+ const p = this.topology.property;
35145
+ const parts = [];
35146
+ if (p.propertyType)
35147
+ parts.push(`Property type: ${p.propertyType}`);
35148
+ if (p.description)
35149
+ parts.push(p.description);
35150
+ if (p.frontFacing)
35151
+ parts.push(`Front faces ${p.frontFacing}`);
35152
+ if (p.features?.length)
35153
+ parts.push(`Features: ${p.features.join(', ')}`);
35154
+ return parts.join('. ');
35155
+ }
35156
+ /** Build camera context string */
35157
+ buildCameraContext(camera) {
35158
+ const parts = [`Camera: ${camera.name}`];
35159
+ if (camera.context?.mountLocation) {
35160
+ parts.push(`Mounted at: ${camera.context.mountLocation}`);
35161
+ }
35162
+ if (camera.context?.coverageDescription) {
35163
+ parts.push(`Coverage: ${camera.context.coverageDescription}`);
35164
+ }
35165
+ if (camera.context?.mountHeight) {
35166
+ parts.push(`Height: ${camera.context.mountHeight} feet`);
35167
+ }
35168
+ if (camera.isEntryPoint)
35169
+ parts.push('Watches property entry point');
35170
+ if (camera.isExitPoint)
35171
+ parts.push('Watches property exit point');
35172
+ // Visible landmarks
35173
+ if (this.topology && camera.context?.visibleLandmarks?.length) {
35174
+ const landmarkNames = camera.context.visibleLandmarks
35175
+ .map(id => (0, topology_1.findLandmark)(this.topology, id)?.name)
35176
+ .filter(Boolean);
35177
+ if (landmarkNames.length) {
35178
+ parts.push(`Can see: ${landmarkNames.join(', ')}`);
35179
+ }
35180
+ }
35181
+ return parts.join('. ');
35182
+ }
35183
+ /** Build landmark context string */
35184
+ buildLandmarkContext(landmark) {
35185
+ const parts = [`${landmark.name} (${landmark.type})`];
35186
+ if (landmark.description)
35187
+ parts.push(landmark.description);
35188
+ if (landmark.isEntryPoint)
35189
+ parts.push('Property entry point');
35190
+ if (landmark.isExitPoint)
35191
+ parts.push('Property exit point');
35192
+ // Adjacent landmarks
35193
+ if (this.topology && landmark.adjacentTo?.length) {
35194
+ const adjacentNames = landmark.adjacentTo
35195
+ .map(id => (0, topology_1.findLandmark)(this.topology, id)?.name)
35196
+ .filter(Boolean);
35197
+ if (adjacentNames.length) {
35198
+ parts.push(`Adjacent to: ${adjacentNames.join(', ')}`);
35199
+ }
35200
+ }
35201
+ return parts.join('. ');
35202
+ }
35203
+ /** Build connection context string */
35204
+ buildConnectionContext(connection) {
35205
+ if (!this.topology)
35206
+ return '';
35207
+ const fromCamera = (0, topology_1.findCamera)(this.topology, connection.fromCameraId);
35208
+ const toCamera = (0, topology_1.findCamera)(this.topology, connection.toCameraId);
35209
+ if (!fromCamera || !toCamera)
35210
+ return '';
35211
+ const parts = [
35212
+ `Path from ${fromCamera.name} to ${toCamera.name}`,
35213
+ ];
35214
+ if (connection.name)
35215
+ parts.push(`Called: ${connection.name}`);
35216
+ const transitSecs = Math.round(connection.transitTime.typical / 1000);
35217
+ parts.push(`Typical transit: ${transitSecs} seconds`);
35218
+ if (connection.bidirectional)
35219
+ parts.push('Bidirectional path');
35220
+ // Path landmarks
35221
+ if (connection.pathLandmarks?.length) {
35222
+ const landmarkNames = connection.pathLandmarks
35223
+ .map((id) => (0, topology_1.findLandmark)(this.topology, id)?.name)
35224
+ .filter(Boolean);
35225
+ if (landmarkNames.length) {
35226
+ parts.push(`Passes: ${landmarkNames.join(' → ')}`);
35227
+ }
35228
+ }
35229
+ return parts.join('. ');
35230
+ }
35231
+ /** Get cached or generate topology description */
35232
+ getTopologyContext() {
35233
+ const now = Date.now();
35234
+ if (this.topologyContextCache && (now - this.contextCacheTime) < this.config.contextCacheTtl) {
35235
+ return this.topologyContextCache;
35236
+ }
35237
+ if (!this.topology)
35238
+ return '';
35239
+ this.topologyContextCache = (0, topology_1.generateTopologyDescription)(this.topology);
35240
+ this.contextCacheTime = now;
35241
+ return this.topologyContextCache;
35242
+ }
35243
+ /** Retrieve relevant context chunks for a movement query */
35244
+ retrieveRelevantContext(fromCameraId, toCameraId) {
35245
+ const relevant = [];
35246
+ // Always include property context
35247
+ const propertyChunk = this.contextChunks.find(c => c.type === 'property');
35248
+ if (propertyChunk)
35249
+ relevant.push(propertyChunk);
35250
+ // Include both camera contexts
35251
+ const fromChunk = this.contextChunks.find(c => c.id === `camera_${fromCameraId}`);
35252
+ const toChunk = this.contextChunks.find(c => c.id === `camera_${toCameraId}`);
35253
+ if (fromChunk)
35254
+ relevant.push(fromChunk);
35255
+ if (toChunk)
35256
+ relevant.push(toChunk);
35257
+ // Include direct connection if exists
35258
+ const connectionChunk = this.contextChunks.find(c => c.type === 'connection' &&
35259
+ ((c.metadata.fromCameraId === fromCameraId && c.metadata.toCameraId === toCameraId) ||
35260
+ (c.metadata.fromCameraId === toCameraId && c.metadata.toCameraId === fromCameraId)));
35261
+ if (connectionChunk)
35262
+ relevant.push(connectionChunk);
35263
+ // Include visible landmarks from both cameras
35264
+ if (this.topology) {
35265
+ const fromLandmarks = (0, topology_1.getLandmarksVisibleFromCamera)(this.topology, fromCameraId);
35266
+ const toLandmarks = (0, topology_1.getLandmarksVisibleFromCamera)(this.topology, toCameraId);
35267
+ const allLandmarkIds = new Set([
35268
+ ...fromLandmarks.map(l => l.id),
35269
+ ...toLandmarks.map(l => l.id),
35270
+ ]);
35271
+ for (const landmarkId of allLandmarkIds) {
35272
+ const chunk = this.contextChunks.find(c => c.id === `landmark_${landmarkId}`);
35273
+ if (chunk)
35274
+ relevant.push(chunk);
35275
+ }
35276
+ }
35277
+ return relevant;
35278
+ }
35279
+ /** Find or initialize LLM device */
35280
+ async findLlmDevice() {
35281
+ if (this.llmDevice)
35282
+ return this.llmDevice;
35283
+ try {
35284
+ for (const id of Object.keys(systemManager.getSystemState())) {
35285
+ const device = systemManager.getDeviceById(id);
35286
+ if (device?.interfaces?.includes(sdk_1.ScryptedInterface.ObjectDetection)) {
35287
+ const name = device.name?.toLowerCase() || '';
35288
+ if (name.includes('llm') || name.includes('gpt') || name.includes('claude') ||
35289
+ name.includes('ollama') || name.includes('gemini')) {
35290
+ this.llmDevice = device;
35291
+ this.console.log(`Found LLM device: ${device.name}`);
35292
+ return this.llmDevice;
35293
+ }
35294
+ }
35295
+ }
35296
+ }
35297
+ catch (e) {
35298
+ this.console.warn('Error finding LLM device:', e);
35299
+ }
35300
+ return null;
35301
+ }
35302
+ /** Generate rich movement description using LLM */
35303
+ async generateMovementDescription(tracked, fromCameraId, toCameraId, transitTime, mediaObject) {
35304
+ if (!this.topology) {
35305
+ return {
35306
+ description: `${tracked.className} moving between cameras`,
35307
+ involvedLandmarks: [],
35308
+ confidence: 0.5,
35309
+ usedLlm: false,
35310
+ };
35311
+ }
35312
+ const fromCamera = (0, topology_1.findCamera)(this.topology, fromCameraId);
35313
+ const toCamera = (0, topology_1.findCamera)(this.topology, toCameraId);
35314
+ if (!fromCamera || !toCamera) {
35315
+ return {
35316
+ description: `${tracked.className} moving between cameras`,
35317
+ involvedLandmarks: [],
35318
+ confidence: 0.5,
35319
+ usedLlm: false,
35320
+ };
35321
+ }
35322
+ // Get involved landmarks
35323
+ const fromLandmarks = (0, topology_1.getLandmarksVisibleFromCamera)(this.topology, fromCameraId);
35324
+ const toLandmarks = (0, topology_1.getLandmarksVisibleFromCamera)(this.topology, toCameraId);
35325
+ const allLandmarks = [...new Set([...fromLandmarks, ...toLandmarks])];
35326
+ // Build basic description without LLM
35327
+ let basicDescription = this.buildBasicDescription(tracked, fromCamera, toCamera, transitTime, fromLandmarks, toLandmarks);
35328
+ // Try LLM for enhanced description
35329
+ if (this.config.enableLlm && mediaObject) {
35330
+ const llmDescription = await this.getLlmEnhancedDescription(tracked, fromCamera, toCamera, transitTime, fromLandmarks, toLandmarks, mediaObject);
35331
+ if (llmDescription) {
35332
+ return {
35333
+ description: llmDescription,
35334
+ involvedLandmarks: allLandmarks,
35335
+ pathDescription: this.buildPathDescription(fromCamera, toCamera),
35336
+ confidence: 0.9,
35337
+ usedLlm: true,
35338
+ };
35339
+ }
35340
+ }
35341
+ return {
35342
+ description: basicDescription,
35343
+ involvedLandmarks: allLandmarks,
35344
+ pathDescription: this.buildPathDescription(fromCamera, toCamera),
35345
+ confidence: 0.7,
35346
+ usedLlm: false,
35347
+ };
35348
+ }
35349
+ /** Build basic movement description without LLM */
35350
+ buildBasicDescription(tracked, fromCamera, toCamera, transitTime, fromLandmarks, toLandmarks) {
35351
+ const objectType = this.capitalizeFirst(tracked.className);
35352
+ const transitSecs = Math.round(transitTime / 1000);
35353
+ // Build origin description
35354
+ let origin = fromCamera.name;
35355
+ if (fromLandmarks.length > 0) {
35356
+ const nearLandmark = fromLandmarks[0];
35357
+ origin = `near ${nearLandmark.name}`;
35358
+ }
35359
+ else if (fromCamera.context?.coverageDescription) {
35360
+ origin = fromCamera.context.coverageDescription.split('.')[0];
35361
+ }
35362
+ // Build destination description
35363
+ let destination = toCamera.name;
35364
+ if (toLandmarks.length > 0) {
35365
+ const nearLandmark = toLandmarks[0];
35366
+ destination = `towards ${nearLandmark.name}`;
35367
+ }
35368
+ else if (toCamera.context?.coverageDescription) {
35369
+ destination = `towards ${toCamera.context.coverageDescription.split('.')[0]}`;
35370
+ }
35371
+ // Build transit string
35372
+ const transitStr = transitSecs > 0 ? ` (${transitSecs}s)` : '';
35373
+ return `${objectType} moving from ${origin} ${destination}${transitStr}`;
35374
+ }
35375
+ /** Build path description from connection */
35376
+ buildPathDescription(fromCamera, toCamera) {
35377
+ if (!this.topology)
35378
+ return undefined;
35379
+ const connection = (0, topology_1.findConnection)(this.topology, fromCamera.deviceId, toCamera.deviceId);
35380
+ if (!connection)
35381
+ return undefined;
35382
+ if (connection.pathLandmarks?.length) {
35383
+ const landmarkNames = connection.pathLandmarks
35384
+ .map(id => (0, topology_1.findLandmark)(this.topology, id)?.name)
35385
+ .filter(Boolean);
35386
+ if (landmarkNames.length) {
35387
+ return `Via ${landmarkNames.join(' → ')}`;
35388
+ }
35389
+ }
35390
+ return connection.name || undefined;
35391
+ }
35392
+ /** Get LLM-enhanced description */
35393
+ async getLlmEnhancedDescription(tracked, fromCamera, toCamera, transitTime, fromLandmarks, toLandmarks, mediaObject) {
35394
+ const llm = await this.findLlmDevice();
35395
+ if (!llm)
35396
+ return null;
35397
+ try {
35398
+ // Retrieve relevant context for RAG
35399
+ const relevantChunks = this.retrieveRelevantContext(fromCamera.deviceId, toCamera.deviceId);
35400
+ // Build RAG context
35401
+ const ragContext = relevantChunks.map(c => c.content).join('\n\n');
35402
+ // Build the prompt
35403
+ const prompt = this.buildLlmPrompt(tracked, fromCamera, toCamera, transitTime, fromLandmarks, toLandmarks, ragContext);
35404
+ // Call LLM
35405
+ const result = await llm.detectObjects(mediaObject, {
35406
+ settings: { prompt }
35407
+ });
35408
+ // Extract description from result
35409
+ if (result.detections?.[0]?.label) {
35410
+ return result.detections[0].label;
35411
+ }
35412
+ return null;
35413
+ }
35414
+ catch (e) {
35415
+ this.console.warn('LLM description generation failed:', e);
35416
+ return null;
35417
+ }
35418
+ }
35419
+ /** Build LLM prompt with RAG context */
35420
+ buildLlmPrompt(tracked, fromCamera, toCamera, transitTime, fromLandmarks, toLandmarks, ragContext) {
35421
+ const transitSecs = Math.round(transitTime / 1000);
35422
+ return `You are a security camera system describing movement on a property.
35423
+
35424
+ PROPERTY CONTEXT:
35425
+ ${ragContext}
35426
+
35427
+ CURRENT EVENT:
35428
+ - Object type: ${tracked.className}
35429
+ - Moving from: ${fromCamera.name}${fromLandmarks.length ? ` (near ${fromLandmarks.map(l => l.name).join(', ')})` : ''}
35430
+ - Moving to: ${toCamera.name}${toLandmarks.length ? ` (near ${toLandmarks.map(l => l.name).join(', ')})` : ''}
35431
+ - Transit time: ${transitSecs} seconds
35432
+
35433
+ INSTRUCTIONS:
35434
+ Generate a single, concise sentence describing this movement. Include:
35435
+ 1. Description of the ${tracked.className} (if person: gender, clothing; if vehicle: color, type)
35436
+ 2. Where they came from (using landmark names if available)
35437
+ 3. Where they're heading (using landmark names if available)
35438
+
35439
+ Examples of good descriptions:
35440
+ - "Man in blue jacket walking from the driveway towards the front door"
35441
+ - "Black SUV pulling into the driveway from the street"
35442
+ - "Woman with dog walking from the backyard towards the side gate"
35443
+ - "Delivery person approaching the front porch from the mailbox"
35444
+
35445
+ Generate ONLY the description, nothing else:`;
35446
+ }
35447
+ /** Suggest a new landmark based on AI analysis */
35448
+ async suggestLandmark(cameraId, mediaObject, objectClass, position) {
35449
+ if (!this.config.enableLandmarkLearning)
35450
+ return null;
35451
+ const llm = await this.findLlmDevice();
35452
+ if (!llm)
35453
+ return null;
35454
+ try {
35455
+ const prompt = `Analyze this security camera image. A ${objectClass} was detected.
35456
+
35457
+ Looking at the surroundings and environment, identify any notable landmarks or features visible that could help describe this location. Consider:
35458
+ - Structures (house, garage, shed, porch)
35459
+ - Features (mailbox, tree, pool, garden)
35460
+ - Access points (driveway, walkway, gate, door)
35461
+ - Boundaries (fence, wall, hedge)
35462
+
35463
+ If you can identify a clear landmark feature, respond with ONLY a JSON object:
35464
+ {"name": "Landmark Name", "type": "structure|feature|boundary|access|vehicle|neighbor|zone|street", "description": "Brief description"}
35465
+
35466
+ If no clear landmark is identifiable, respond with: {"name": null}`;
35467
+ const result = await llm.detectObjects(mediaObject, {
35468
+ settings: { prompt }
35469
+ });
35470
+ if (result.detections?.[0]?.label) {
35471
+ try {
35472
+ const parsed = JSON.parse(result.detections[0].label);
35473
+ if (parsed.name && parsed.type) {
35474
+ const suggestionId = `suggest_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
35475
+ const suggestion = {
35476
+ id: suggestionId,
35477
+ landmark: {
35478
+ id: `landmark_${Date.now()}`,
35479
+ name: parsed.name,
35480
+ type: parsed.type,
35481
+ position,
35482
+ description: parsed.description,
35483
+ aiSuggested: true,
35484
+ aiConfidence: 0.7,
35485
+ visibleFromCameras: [cameraId],
35486
+ },
35487
+ detectedByCameras: [cameraId],
35488
+ timestamp: Date.now(),
35489
+ detectionCount: 1,
35490
+ status: 'pending',
35491
+ };
35492
+ // Store suggestion
35493
+ const existingKey = this.findSimilarSuggestion(parsed.name, position);
35494
+ if (existingKey) {
35495
+ // Increment count for similar suggestion
35496
+ const existing = this.landmarkSuggestions.get(existingKey);
35497
+ existing.detectionCount++;
35498
+ existing.landmark.aiConfidence = Math.min(0.95, existing.landmark.aiConfidence + 0.05);
35499
+ if (!existing.detectedByCameras.includes(cameraId)) {
35500
+ existing.detectedByCameras.push(cameraId);
35501
+ }
35502
+ return existing;
35503
+ }
35504
+ else {
35505
+ this.landmarkSuggestions.set(suggestionId, suggestion);
35506
+ return suggestion;
35507
+ }
35508
+ }
35509
+ }
35510
+ catch (parseError) {
35511
+ // LLM didn't return valid JSON
35512
+ }
35513
+ }
35514
+ return null;
35515
+ }
35516
+ catch (e) {
35517
+ this.console.warn('Landmark suggestion failed:', e);
35518
+ return null;
35519
+ }
35520
+ }
35521
+ /** Find similar existing suggestion by name proximity and position */
35522
+ findSimilarSuggestion(name, position) {
35523
+ const nameLower = name.toLowerCase();
35524
+ const POSITION_THRESHOLD = 100; // pixels
35525
+ for (const [key, suggestion] of this.landmarkSuggestions) {
35526
+ if (suggestion.status !== 'pending')
35527
+ continue;
35528
+ const suggestionName = suggestion.landmark.name.toLowerCase();
35529
+ const distance = Math.sqrt(Math.pow(suggestion.landmark.position.x - position.x, 2) +
35530
+ Math.pow(suggestion.landmark.position.y - position.y, 2));
35531
+ // Similar name and nearby position
35532
+ if ((suggestionName.includes(nameLower) || nameLower.includes(suggestionName)) &&
35533
+ distance < POSITION_THRESHOLD) {
35534
+ return key;
35535
+ }
35536
+ }
35537
+ return null;
35538
+ }
35539
+ /** Get pending landmark suggestions above confidence threshold */
35540
+ getPendingSuggestions() {
35541
+ return Array.from(this.landmarkSuggestions.values())
35542
+ .filter(s => s.status === 'pending' &&
35543
+ s.landmark.aiConfidence >= this.config.landmarkConfidenceThreshold)
35544
+ .sort((a, b) => b.detectionCount - a.detectionCount);
35545
+ }
35546
+ /** Accept a landmark suggestion */
35547
+ acceptSuggestion(suggestionId) {
35548
+ const suggestion = this.landmarkSuggestions.get(suggestionId);
35549
+ if (!suggestion)
35550
+ return null;
35551
+ suggestion.status = 'accepted';
35552
+ const landmark = { ...suggestion.landmark };
35553
+ landmark.aiSuggested = false; // Mark as confirmed
35554
+ this.landmarkSuggestions.delete(suggestionId);
35555
+ return landmark;
35556
+ }
35557
+ /** Reject a landmark suggestion */
35558
+ rejectSuggestion(suggestionId) {
35559
+ const suggestion = this.landmarkSuggestions.get(suggestionId);
35560
+ if (!suggestion)
35561
+ return false;
35562
+ suggestion.status = 'rejected';
35563
+ this.landmarkSuggestions.delete(suggestionId);
35564
+ return true;
35565
+ }
35566
+ /** Utility to capitalize first letter */
35567
+ capitalizeFirst(str) {
35568
+ return str ? str.charAt(0).toUpperCase() + str.slice(1) : 'Object';
35569
+ }
35570
+ /** Get landmark templates for UI */
35571
+ getLandmarkTemplates() {
35572
+ return topology_1.LANDMARK_TEMPLATES;
35573
+ }
35574
+ }
35575
+ exports.SpatialReasoningEngine = SpatialReasoningEngine;
35576
+
35577
+
35009
35578
  /***/ },
35010
35579
 
35011
35580
  /***/ "./src/core/tracking-engine.ts"
@@ -35059,6 +35628,7 @@ const sdk_1 = __importStar(__webpack_require__(/*! @scrypted/sdk */ "./node_modu
35059
35628
  const topology_1 = __webpack_require__(/*! ../models/topology */ "./src/models/topology.ts");
35060
35629
  const tracked_object_1 = __webpack_require__(/*! ../models/tracked-object */ "./src/models/tracked-object.ts");
35061
35630
  const object_correlator_1 = __webpack_require__(/*! ./object-correlator */ "./src/core/object-correlator.ts");
35631
+ const spatial_reasoning_1 = __webpack_require__(/*! ./spatial-reasoning */ "./src/core/spatial-reasoning.ts");
35062
35632
  const { systemManager } = sdk_1.default;
35063
35633
  class TrackingEngine {
35064
35634
  topology;
@@ -35067,9 +35637,14 @@ class TrackingEngine {
35067
35637
  config;
35068
35638
  console;
35069
35639
  correlator;
35640
+ spatialReasoning;
35070
35641
  listeners = new Map();
35071
35642
  pendingTimers = new Map();
35072
35643
  lostCheckInterval = null;
35644
+ /** Track last alert time per object to enforce cooldown */
35645
+ objectLastAlertTime = new Map();
35646
+ /** Callback for topology changes (e.g., landmark suggestions) */
35647
+ onTopologyChange;
35073
35648
  constructor(topology, state, alertManager, config, console) {
35074
35649
  this.topology = topology;
35075
35650
  this.state = state;
@@ -35077,6 +35652,19 @@ class TrackingEngine {
35077
35652
  this.config = config;
35078
35653
  this.console = console;
35079
35654
  this.correlator = new object_correlator_1.ObjectCorrelator(topology, config);
35655
+ // Initialize spatial reasoning engine
35656
+ const spatialConfig = {
35657
+ enableLlm: config.useLlmDescriptions,
35658
+ enableLandmarkLearning: config.enableLandmarkLearning ?? true,
35659
+ landmarkConfidenceThreshold: config.landmarkConfidenceThreshold ?? 0.7,
35660
+ contextCacheTtl: 60000, // 1 minute cache
35661
+ };
35662
+ this.spatialReasoning = new spatial_reasoning_1.SpatialReasoningEngine(spatialConfig, console);
35663
+ this.spatialReasoning.updateTopology(topology);
35664
+ }
35665
+ /** Set callback for topology changes */
35666
+ setTopologyChangeCallback(callback) {
35667
+ this.onTopologyChange = callback;
35080
35668
  }
35081
35669
  /** Start listening to all cameras in topology */
35082
35670
  async startTracking() {
@@ -35172,6 +35760,61 @@ class TrackingEngine {
35172
35760
  await this.processSighting(sighting, camera.isEntryPoint, camera.isExitPoint);
35173
35761
  }
35174
35762
  }
35763
+ /** Check if object passes loitering threshold */
35764
+ passesLoiteringThreshold(tracked) {
35765
+ const visibleDuration = tracked.lastSeen - tracked.firstSeen;
35766
+ return visibleDuration >= this.config.loiteringThreshold;
35767
+ }
35768
+ /** Check if object is in alert cooldown */
35769
+ isInAlertCooldown(globalId) {
35770
+ const lastAlertTime = this.objectLastAlertTime.get(globalId);
35771
+ if (!lastAlertTime)
35772
+ return false;
35773
+ return (Date.now() - lastAlertTime) < this.config.objectAlertCooldown;
35774
+ }
35775
+ /** Record that we alerted for this object */
35776
+ recordAlertTime(globalId) {
35777
+ this.objectLastAlertTime.set(globalId, Date.now());
35778
+ }
35779
+ /** Get spatial reasoning result for movement (uses RAG + LLM) */
35780
+ async getSpatialDescription(tracked, fromCameraId, toCameraId, transitTime, currentCameraId) {
35781
+ try {
35782
+ // Get snapshot from camera for LLM analysis (if LLM is enabled)
35783
+ let mediaObject;
35784
+ if (this.config.useLlmDescriptions) {
35785
+ const camera = systemManager.getDeviceById(currentCameraId);
35786
+ if (camera?.interfaces?.includes(sdk_1.ScryptedInterface.Camera)) {
35787
+ mediaObject = await camera.takePicture();
35788
+ }
35789
+ }
35790
+ // Use spatial reasoning engine for rich context-aware description
35791
+ const result = await this.spatialReasoning.generateMovementDescription(tracked, fromCameraId, toCameraId, transitTime, mediaObject);
35792
+ // Optionally trigger landmark learning
35793
+ if (this.config.enableLandmarkLearning && mediaObject) {
35794
+ this.tryLearnLandmark(currentCameraId, mediaObject, tracked.className);
35795
+ }
35796
+ return result;
35797
+ }
35798
+ catch (e) {
35799
+ this.console.warn('Spatial reasoning failed:', e);
35800
+ return null;
35801
+ }
35802
+ }
35803
+ /** Try to learn new landmarks from detections (background task) */
35804
+ async tryLearnLandmark(cameraId, mediaObject, objectClass) {
35805
+ try {
35806
+ // Position is approximate - could be improved with object position from detection
35807
+ const position = { x: 50, y: 50 };
35808
+ const suggestion = await this.spatialReasoning.suggestLandmark(cameraId, mediaObject, objectClass, position);
35809
+ if (suggestion) {
35810
+ this.console.log(`AI suggested landmark: ${suggestion.landmark.name} ` +
35811
+ `(${suggestion.landmark.type}, confidence: ${suggestion.landmark.aiConfidence?.toFixed(2)})`);
35812
+ }
35813
+ }
35814
+ catch (e) {
35815
+ // Landmark learning is best-effort, don't log errors
35816
+ }
35817
+ }
35175
35818
  /** Process a single sighting */
35176
35819
  async processSighting(sighting, isEntryPoint, isExitPoint) {
35177
35820
  // Try to correlate with existing tracked objects
@@ -35197,17 +35840,27 @@ class TrackingEngine {
35197
35840
  this.console.log(`Object ${tracked.globalId.slice(0, 8)} transited: ` +
35198
35841
  `${lastSighting.cameraName} → ${sighting.cameraName} ` +
35199
35842
  `(confidence: ${(correlation.confidence * 100).toFixed(0)}%)`);
35200
- // Generate movement alert for cross-camera transition
35201
- await this.alertManager.checkAndAlert('movement', tracked, {
35202
- fromCameraId: lastSighting.cameraId,
35203
- fromCameraName: lastSighting.cameraName,
35204
- toCameraId: sighting.cameraId,
35205
- toCameraName: sighting.cameraName,
35206
- transitTime: transitDuration,
35207
- objectClass: sighting.detection.className,
35208
- objectLabel: sighting.detection.label,
35209
- detectionId: sighting.detectionId,
35210
- });
35843
+ // Check loitering threshold and per-object cooldown before alerting
35844
+ if (this.passesLoiteringThreshold(tracked) && !this.isInAlertCooldown(tracked.globalId)) {
35845
+ // Get spatial reasoning result with RAG context
35846
+ const spatialResult = await this.getSpatialDescription(tracked, lastSighting.cameraId, sighting.cameraId, transitDuration, sighting.cameraId);
35847
+ // Generate movement alert for cross-camera transition
35848
+ await this.alertManager.checkAndAlert('movement', tracked, {
35849
+ fromCameraId: lastSighting.cameraId,
35850
+ fromCameraName: lastSighting.cameraName,
35851
+ toCameraId: sighting.cameraId,
35852
+ toCameraName: sighting.cameraName,
35853
+ transitTime: transitDuration,
35854
+ objectClass: sighting.detection.className,
35855
+ objectLabel: spatialResult?.description || sighting.detection.label,
35856
+ detectionId: sighting.detectionId,
35857
+ // Include spatial context for enriched alerts
35858
+ pathDescription: spatialResult?.pathDescription,
35859
+ involvedLandmarks: spatialResult?.involvedLandmarks?.map(l => l.name),
35860
+ usedLlm: spatialResult?.usedLlm,
35861
+ });
35862
+ this.recordAlertTime(tracked.globalId);
35863
+ }
35211
35864
  }
35212
35865
  // Add sighting to tracked object
35213
35866
  this.state.addSighting(tracked.globalId, sighting);
@@ -35231,14 +35884,21 @@ class TrackingEngine {
35231
35884
  this.console.log(`New ${sighting.detection.className} detected on ${sighting.cameraName} ` +
35232
35885
  `(ID: ${globalId.slice(0, 8)})`);
35233
35886
  // Generate entry alert if this is an entry point
35234
- if (isEntryPoint) {
35887
+ // Entry alerts also respect loitering threshold and cooldown
35888
+ if (isEntryPoint && this.passesLoiteringThreshold(tracked) && !this.isInAlertCooldown(globalId)) {
35889
+ // Get spatial reasoning for entry event
35890
+ const spatialResult = await this.getSpatialDescription(tracked, 'outside', // Virtual "outside" location for entry
35891
+ sighting.cameraId, 0, sighting.cameraId);
35235
35892
  await this.alertManager.checkAndAlert('property_entry', tracked, {
35236
35893
  cameraId: sighting.cameraId,
35237
35894
  cameraName: sighting.cameraName,
35238
35895
  objectClass: sighting.detection.className,
35239
- objectLabel: sighting.detection.label,
35896
+ objectLabel: spatialResult?.description || sighting.detection.label,
35240
35897
  detectionId: sighting.detectionId,
35898
+ involvedLandmarks: spatialResult?.involvedLandmarks?.map(l => l.name),
35899
+ usedLlm: spatialResult?.usedLlm,
35241
35900
  });
35901
+ this.recordAlertTime(globalId);
35242
35902
  }
35243
35903
  }
35244
35904
  }
@@ -35319,6 +35979,39 @@ class TrackingEngine {
35319
35979
  updateTopology(topology) {
35320
35980
  this.topology = topology;
35321
35981
  this.correlator = new object_correlator_1.ObjectCorrelator(topology, this.config);
35982
+ this.spatialReasoning.updateTopology(topology);
35983
+ }
35984
+ /** Get pending landmark suggestions */
35985
+ getPendingLandmarkSuggestions() {
35986
+ return this.spatialReasoning.getPendingSuggestions();
35987
+ }
35988
+ /** Accept a landmark suggestion, adding it to topology */
35989
+ acceptLandmarkSuggestion(suggestionId) {
35990
+ const landmark = this.spatialReasoning.acceptSuggestion(suggestionId);
35991
+ if (landmark && this.topology) {
35992
+ // Add the accepted landmark to topology
35993
+ if (!this.topology.landmarks) {
35994
+ this.topology.landmarks = [];
35995
+ }
35996
+ this.topology.landmarks.push(landmark);
35997
+ // Notify about topology change
35998
+ if (this.onTopologyChange) {
35999
+ this.onTopologyChange(this.topology);
36000
+ }
36001
+ }
36002
+ return landmark;
36003
+ }
36004
+ /** Reject a landmark suggestion */
36005
+ rejectLandmarkSuggestion(suggestionId) {
36006
+ return this.spatialReasoning.rejectSuggestion(suggestionId);
36007
+ }
36008
+ /** Get landmark templates for UI */
36009
+ getLandmarkTemplates() {
36010
+ return this.spatialReasoning.getLandmarkTemplates();
36011
+ }
36012
+ /** Get the spatial reasoning engine for direct access */
36013
+ getSpatialReasoningEngine() {
36014
+ return this.spatialReasoning;
35322
36015
  }
35323
36016
  /** Get current topology */
35324
36017
  getTopology() {
@@ -36091,6 +36784,42 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
36091
36784
  description: 'Use visual embeddings for object correlation (requires compatible detectors)',
36092
36785
  group: 'Tracking',
36093
36786
  },
36787
+ loiteringThreshold: {
36788
+ title: 'Loitering Threshold (seconds)',
36789
+ type: 'number',
36790
+ defaultValue: 3,
36791
+ description: 'Object must be visible for this duration before triggering movement alerts',
36792
+ group: 'Tracking',
36793
+ },
36794
+ objectAlertCooldown: {
36795
+ title: 'Per-Object Alert Cooldown (seconds)',
36796
+ type: 'number',
36797
+ defaultValue: 30,
36798
+ description: 'Minimum time between alerts for the same tracked object',
36799
+ group: 'Tracking',
36800
+ },
36801
+ // LLM Integration
36802
+ useLlmDescriptions: {
36803
+ title: 'Use LLM for Rich Descriptions',
36804
+ type: 'boolean',
36805
+ defaultValue: true,
36806
+ description: 'Use LLM plugin (if installed) to generate descriptive alerts like "Man walking from garage towards front door"',
36807
+ group: 'AI & Spatial Reasoning',
36808
+ },
36809
+ enableLandmarkLearning: {
36810
+ title: 'Learn Landmarks from AI',
36811
+ type: 'boolean',
36812
+ defaultValue: true,
36813
+ description: 'Allow AI to suggest new landmarks based on detected objects and camera context',
36814
+ group: 'AI & Spatial Reasoning',
36815
+ },
36816
+ landmarkConfidenceThreshold: {
36817
+ title: 'Landmark Suggestion Confidence',
36818
+ type: 'number',
36819
+ defaultValue: 0.7,
36820
+ description: 'Minimum AI confidence (0-1) to suggest a landmark',
36821
+ group: 'AI & Spatial Reasoning',
36822
+ },
36094
36823
  // MQTT Settings
36095
36824
  enableMqtt: {
36096
36825
  title: 'Enable MQTT',
@@ -36233,8 +36962,18 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
36233
36962
  correlationThreshold: this.storageSettings.values.correlationThreshold || 0.6,
36234
36963
  lostTimeout: (this.storageSettings.values.lostTimeout || 300) * 1000,
36235
36964
  useVisualMatching: this.storageSettings.values.useVisualMatching ?? true,
36965
+ loiteringThreshold: (this.storageSettings.values.loiteringThreshold || 3) * 1000,
36966
+ objectAlertCooldown: (this.storageSettings.values.objectAlertCooldown || 30) * 1000,
36967
+ useLlmDescriptions: this.storageSettings.values.useLlmDescriptions ?? true,
36968
+ enableLandmarkLearning: this.storageSettings.values.enableLandmarkLearning ?? true,
36969
+ landmarkConfidenceThreshold: this.storageSettings.values.landmarkConfidenceThreshold ?? 0.7,
36236
36970
  };
36237
36971
  this.trackingEngine = new tracking_engine_1.TrackingEngine(topology, this.trackingState, this.alertManager, config, this.console);
36972
+ // Set up callback to save topology changes (e.g., from accepted landmark suggestions)
36973
+ this.trackingEngine.setTopologyChangeCallback((updatedTopology) => {
36974
+ this.storage.setItem('topology', JSON.stringify(updatedTopology));
36975
+ this.console.log('Topology auto-saved after change');
36976
+ });
36238
36977
  await this.trackingEngine.startTracking();
36239
36978
  this.console.log('Tracking engine started');
36240
36979
  }
@@ -36471,7 +37210,12 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
36471
37210
  key === 'correlationWindow' ||
36472
37211
  key === 'correlationThreshold' ||
36473
37212
  key === 'lostTimeout' ||
36474
- key === 'useVisualMatching') {
37213
+ key === 'useVisualMatching' ||
37214
+ key === 'loiteringThreshold' ||
37215
+ key === 'objectAlertCooldown' ||
37216
+ key === 'useLlmDescriptions' ||
37217
+ key === 'enableLandmarkLearning' ||
37218
+ key === 'landmarkConfidenceThreshold') {
36475
37219
  const topologyJson = this.storage.getItem('topology');
36476
37220
  if (topologyJson) {
36477
37221
  try {
@@ -36523,6 +37267,28 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
36523
37267
  if (path.endsWith('/api/floor-plan')) {
36524
37268
  return this.handleFloorPlanRequest(request, response);
36525
37269
  }
37270
+ if (path.endsWith('/api/landmarks')) {
37271
+ return this.handleLandmarksRequest(request, response);
37272
+ }
37273
+ if (path.match(/\/api\/landmarks\/[\w-]+$/)) {
37274
+ const landmarkId = path.split('/').pop();
37275
+ return this.handleLandmarkRequest(landmarkId, request, response);
37276
+ }
37277
+ if (path.endsWith('/api/landmark-suggestions')) {
37278
+ return this.handleLandmarkSuggestionsRequest(request, response);
37279
+ }
37280
+ if (path.match(/\/api\/landmark-suggestions\/[\w-]+\/(accept|reject)$/)) {
37281
+ const parts = path.split('/');
37282
+ const action = parts.pop();
37283
+ const suggestionId = parts.pop();
37284
+ return this.handleSuggestionActionRequest(suggestionId, action, response);
37285
+ }
37286
+ if (path.endsWith('/api/landmark-templates')) {
37287
+ return this.handleLandmarkTemplatesRequest(response);
37288
+ }
37289
+ if (path.endsWith('/api/infer-relationships')) {
37290
+ return this.handleInferRelationshipsRequest(response);
37291
+ }
36526
37292
  // UI Routes
36527
37293
  if (path.endsWith('/ui/editor') || path.endsWith('/ui/editor/')) {
36528
37294
  return this.serveEditorUI(response);
@@ -36704,6 +37470,202 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
36704
37470
  }
36705
37471
  }
36706
37472
  }
37473
+ handleLandmarksRequest(request, response) {
37474
+ const topology = this.getTopology();
37475
+ if (!topology) {
37476
+ response.send(JSON.stringify({ landmarks: [] }), {
37477
+ headers: { 'Content-Type': 'application/json' },
37478
+ });
37479
+ return;
37480
+ }
37481
+ if (request.method === 'GET') {
37482
+ response.send(JSON.stringify({
37483
+ landmarks: topology.landmarks || [],
37484
+ }), {
37485
+ headers: { 'Content-Type': 'application/json' },
37486
+ });
37487
+ }
37488
+ else if (request.method === 'POST') {
37489
+ try {
37490
+ const landmark = JSON.parse(request.body);
37491
+ if (!landmark.id) {
37492
+ landmark.id = `landmark_${Date.now()}`;
37493
+ }
37494
+ if (!topology.landmarks) {
37495
+ topology.landmarks = [];
37496
+ }
37497
+ topology.landmarks.push(landmark);
37498
+ this.storage.setItem('topology', JSON.stringify(topology));
37499
+ if (this.trackingEngine) {
37500
+ this.trackingEngine.updateTopology(topology);
37501
+ }
37502
+ response.send(JSON.stringify({ success: true, landmark }), {
37503
+ headers: { 'Content-Type': 'application/json' },
37504
+ });
37505
+ }
37506
+ catch (e) {
37507
+ response.send(JSON.stringify({ error: 'Invalid landmark data' }), {
37508
+ code: 400,
37509
+ headers: { 'Content-Type': 'application/json' },
37510
+ });
37511
+ }
37512
+ }
37513
+ }
37514
+ handleLandmarkRequest(landmarkId, request, response) {
37515
+ const topology = this.getTopology();
37516
+ if (!topology) {
37517
+ response.send(JSON.stringify({ error: 'No topology configured' }), {
37518
+ code: 404,
37519
+ headers: { 'Content-Type': 'application/json' },
37520
+ });
37521
+ return;
37522
+ }
37523
+ const landmarkIndex = topology.landmarks?.findIndex(l => l.id === landmarkId) ?? -1;
37524
+ if (request.method === 'GET') {
37525
+ const landmark = topology.landmarks?.[landmarkIndex];
37526
+ if (landmark) {
37527
+ response.send(JSON.stringify(landmark), {
37528
+ headers: { 'Content-Type': 'application/json' },
37529
+ });
37530
+ }
37531
+ else {
37532
+ response.send(JSON.stringify({ error: 'Landmark not found' }), {
37533
+ code: 404,
37534
+ headers: { 'Content-Type': 'application/json' },
37535
+ });
37536
+ }
37537
+ }
37538
+ else if (request.method === 'PUT') {
37539
+ try {
37540
+ const updates = JSON.parse(request.body);
37541
+ if (landmarkIndex >= 0) {
37542
+ topology.landmarks[landmarkIndex] = {
37543
+ ...topology.landmarks[landmarkIndex],
37544
+ ...updates,
37545
+ id: landmarkId, // Preserve ID
37546
+ };
37547
+ this.storage.setItem('topology', JSON.stringify(topology));
37548
+ if (this.trackingEngine) {
37549
+ this.trackingEngine.updateTopology(topology);
37550
+ }
37551
+ response.send(JSON.stringify({ success: true, landmark: topology.landmarks[landmarkIndex] }), {
37552
+ headers: { 'Content-Type': 'application/json' },
37553
+ });
37554
+ }
37555
+ else {
37556
+ response.send(JSON.stringify({ error: 'Landmark not found' }), {
37557
+ code: 404,
37558
+ headers: { 'Content-Type': 'application/json' },
37559
+ });
37560
+ }
37561
+ }
37562
+ catch (e) {
37563
+ response.send(JSON.stringify({ error: 'Invalid landmark data' }), {
37564
+ code: 400,
37565
+ headers: { 'Content-Type': 'application/json' },
37566
+ });
37567
+ }
37568
+ }
37569
+ else if (request.method === 'DELETE') {
37570
+ if (landmarkIndex >= 0) {
37571
+ topology.landmarks.splice(landmarkIndex, 1);
37572
+ this.storage.setItem('topology', JSON.stringify(topology));
37573
+ if (this.trackingEngine) {
37574
+ this.trackingEngine.updateTopology(topology);
37575
+ }
37576
+ response.send(JSON.stringify({ success: true }), {
37577
+ headers: { 'Content-Type': 'application/json' },
37578
+ });
37579
+ }
37580
+ else {
37581
+ response.send(JSON.stringify({ error: 'Landmark not found' }), {
37582
+ code: 404,
37583
+ headers: { 'Content-Type': 'application/json' },
37584
+ });
37585
+ }
37586
+ }
37587
+ }
37588
+ handleLandmarkSuggestionsRequest(request, response) {
37589
+ if (!this.trackingEngine) {
37590
+ response.send(JSON.stringify({ suggestions: [] }), {
37591
+ headers: { 'Content-Type': 'application/json' },
37592
+ });
37593
+ return;
37594
+ }
37595
+ const suggestions = this.trackingEngine.getPendingLandmarkSuggestions();
37596
+ response.send(JSON.stringify({
37597
+ suggestions,
37598
+ count: suggestions.length,
37599
+ }), {
37600
+ headers: { 'Content-Type': 'application/json' },
37601
+ });
37602
+ }
37603
+ handleSuggestionActionRequest(suggestionId, action, response) {
37604
+ if (!this.trackingEngine) {
37605
+ response.send(JSON.stringify({ error: 'Tracking engine not running' }), {
37606
+ code: 500,
37607
+ headers: { 'Content-Type': 'application/json' },
37608
+ });
37609
+ return;
37610
+ }
37611
+ if (action === 'accept') {
37612
+ const landmark = this.trackingEngine.acceptLandmarkSuggestion(suggestionId);
37613
+ if (landmark) {
37614
+ response.send(JSON.stringify({ success: true, landmark }), {
37615
+ headers: { 'Content-Type': 'application/json' },
37616
+ });
37617
+ }
37618
+ else {
37619
+ response.send(JSON.stringify({ error: 'Suggestion not found' }), {
37620
+ code: 404,
37621
+ headers: { 'Content-Type': 'application/json' },
37622
+ });
37623
+ }
37624
+ }
37625
+ else if (action === 'reject') {
37626
+ const success = this.trackingEngine.rejectLandmarkSuggestion(suggestionId);
37627
+ if (success) {
37628
+ response.send(JSON.stringify({ success: true }), {
37629
+ headers: { 'Content-Type': 'application/json' },
37630
+ });
37631
+ }
37632
+ else {
37633
+ response.send(JSON.stringify({ error: 'Suggestion not found' }), {
37634
+ code: 404,
37635
+ headers: { 'Content-Type': 'application/json' },
37636
+ });
37637
+ }
37638
+ }
37639
+ else {
37640
+ response.send(JSON.stringify({ error: 'Invalid action' }), {
37641
+ code: 400,
37642
+ headers: { 'Content-Type': 'application/json' },
37643
+ });
37644
+ }
37645
+ }
37646
+ handleLandmarkTemplatesRequest(response) {
37647
+ response.send(JSON.stringify({
37648
+ templates: topology_1.LANDMARK_TEMPLATES,
37649
+ }), {
37650
+ headers: { 'Content-Type': 'application/json' },
37651
+ });
37652
+ }
37653
+ handleInferRelationshipsRequest(response) {
37654
+ const topology = this.getTopology();
37655
+ if (!topology) {
37656
+ response.send(JSON.stringify({ relationships: [] }), {
37657
+ headers: { 'Content-Type': 'application/json' },
37658
+ });
37659
+ return;
37660
+ }
37661
+ const inferred = (0, topology_1.inferRelationships)(topology);
37662
+ response.send(JSON.stringify({
37663
+ relationships: inferred,
37664
+ count: inferred.length,
37665
+ }), {
37666
+ headers: { 'Content-Type': 'application/json' },
37667
+ });
37668
+ }
36707
37669
  serveEditorUI(response) {
36708
37670
  response.send(editor_html_1.EDITOR_HTML, {
36709
37671
  headers: { 'Content-Type': 'text/html' },
@@ -36882,9 +37844,22 @@ function generateAlertMessage(type, details) {
36882
37844
  case 'property_exit':
36883
37845
  return `${objectDesc} exited property via ${details.cameraName || 'unknown camera'}`;
36884
37846
  case 'movement':
37847
+ // If we have a rich description from LLM/RAG, use it
37848
+ if (details.objectLabel && details.usedLlm) {
37849
+ const transitSecs = details.transitTime ? Math.round(details.transitTime / 1000) : 0;
37850
+ const transitStr = transitSecs > 0 ? ` (${transitSecs}s)` : '';
37851
+ // Include path/landmark context if available
37852
+ const pathContext = details.pathDescription ? ` via ${details.pathDescription}` : '';
37853
+ return `${details.objectLabel}${pathContext}${transitStr}`;
37854
+ }
37855
+ // Fallback to basic message with landmark info
36885
37856
  const transitSecs = details.transitTime ? Math.round(details.transitTime / 1000) : 0;
36886
37857
  const transitStr = transitSecs > 0 ? ` (${transitSecs}s transit)` : '';
36887
- return `${objectDesc} moving from ${details.fromCameraName || 'unknown'} towards ${details.toCameraName || 'unknown'}${transitStr}`;
37858
+ let movementDesc = `${objectDesc} moving from ${details.fromCameraName || 'unknown'} towards ${details.toCameraName || 'unknown'}`;
37859
+ if (details.involvedLandmarks && details.involvedLandmarks.length > 0) {
37860
+ movementDesc += ` near ${details.involvedLandmarks.join(', ')}`;
37861
+ }
37862
+ return `${movementDesc}${transitStr}`;
36888
37863
  case 'unusual_path':
36889
37864
  return `${objectDesc} took unusual path: ${details.actualPath || 'unknown'}`;
36890
37865
  case 'dwell_time':
@@ -36932,21 +37907,44 @@ function createAlert(type, trackedObjectId, details, severity = 'info', ruleId)
36932
37907
 
36933
37908
  /**
36934
37909
  * Camera Topology Models
36935
- * Defines the spatial relationships between cameras in the NVR system
37910
+ * Defines the spatial relationships between cameras, landmarks, and zones
36936
37911
  */
36937
37912
  Object.defineProperty(exports, "__esModule", ({ value: true }));
37913
+ exports.LANDMARK_TEMPLATES = void 0;
36938
37914
  exports.createEmptyTopology = createEmptyTopology;
36939
37915
  exports.findCamera = findCamera;
37916
+ exports.findLandmark = findLandmark;
36940
37917
  exports.findConnectionsFrom = findConnectionsFrom;
36941
37918
  exports.findConnection = findConnection;
36942
37919
  exports.getEntryPoints = getEntryPoints;
36943
37920
  exports.getExitPoints = getExitPoints;
37921
+ exports.getLandmarksVisibleFromCamera = getLandmarksVisibleFromCamera;
37922
+ exports.getCamerasWithLandmarkVisibility = getCamerasWithLandmarkVisibility;
37923
+ exports.getAdjacentLandmarks = getAdjacentLandmarks;
37924
+ exports.calculateDistance = calculateDistance;
37925
+ exports.inferRelationships = inferRelationships;
37926
+ exports.generateTopologyDescription = generateTopologyDescription;
37927
+ exports.generateMovementContext = generateMovementContext;
37928
+ /** Common landmark templates for quick setup */
37929
+ exports.LANDMARK_TEMPLATES = [
37930
+ { type: 'structure', suggestions: ['House', 'Garage', 'Shed', 'Porch', 'Deck', 'Patio', 'Gazebo', 'Pool House'] },
37931
+ { type: 'feature', suggestions: ['Mailbox', 'Tree', 'Firepit', 'Pool', 'Hot Tub', 'Garden', 'Fountain', 'Flagpole'] },
37932
+ { type: 'boundary', suggestions: ['Front Fence', 'Back Fence', 'Side Fence', 'Hedge', 'Wall', 'Property Line'] },
37933
+ { type: 'access', suggestions: ['Driveway', 'Front Walkway', 'Back Walkway', 'Front Door', 'Back Door', 'Side Door', 'Gate', 'Stairs'] },
37934
+ { type: 'vehicle', suggestions: ['Car Parking', 'Boat', 'RV Pad', 'Motorcycle Spot'] },
37935
+ { type: 'neighbor', suggestions: ["Neighbor's House", "Neighbor's Driveway", "Neighbor's Yard"] },
37936
+ { type: 'zone', suggestions: ['Front Yard', 'Back Yard', 'Side Yard', 'Courtyard'] },
37937
+ { type: 'street', suggestions: ['Street', 'Sidewalk', 'Alley', 'Cul-de-sac'] },
37938
+ ];
37939
+ // ==================== Helper Functions ====================
36944
37940
  /** Creates an empty topology */
36945
37941
  function createEmptyTopology() {
36946
37942
  return {
36947
- version: '1.0',
37943
+ version: '2.0',
36948
37944
  cameras: [],
36949
37945
  connections: [],
37946
+ landmarks: [],
37947
+ relationships: [],
36950
37948
  globalZones: [],
36951
37949
  };
36952
37950
  }
@@ -36954,6 +37952,10 @@ function createEmptyTopology() {
36954
37952
  function findCamera(topology, deviceId) {
36955
37953
  return topology.cameras.find(c => c.deviceId === deviceId);
36956
37954
  }
37955
+ /** Finds a landmark by ID */
37956
+ function findLandmark(topology, landmarkId) {
37957
+ return topology.landmarks.find(l => l.id === landmarkId);
37958
+ }
36957
37959
  /** Finds connections from a camera */
36958
37960
  function findConnectionsFrom(topology, cameraId) {
36959
37961
  return topology.connections.filter(c => c.fromCameraId === cameraId ||
@@ -36961,8 +37963,8 @@ function findConnectionsFrom(topology, cameraId) {
36961
37963
  }
36962
37964
  /** Finds a connection between two cameras */
36963
37965
  function findConnection(topology, fromCameraId, toCameraId) {
36964
- return topology.connections.find(c => (c.fromCameraId === fromCameraId && c.toCameraId === toCameraId) ||
36965
- (c.bidirectional && c.fromCameraId === toCameraId && c.toCameraId === fromCameraId));
37966
+ return topology.connections.filter(c => (c.fromCameraId === fromCameraId && c.toCameraId === toCameraId) ||
37967
+ (c.bidirectional && c.fromCameraId === toCameraId && c.toCameraId === fromCameraId))[0];
36966
37968
  }
36967
37969
  /** Gets all entry point cameras */
36968
37970
  function getEntryPoints(topology) {
@@ -36972,6 +37974,171 @@ function getEntryPoints(topology) {
36972
37974
  function getExitPoints(topology) {
36973
37975
  return topology.cameras.filter(c => c.isExitPoint);
36974
37976
  }
37977
+ /** Gets landmarks visible from a camera */
37978
+ function getLandmarksVisibleFromCamera(topology, cameraId) {
37979
+ const camera = findCamera(topology, cameraId);
37980
+ if (!camera?.context?.visibleLandmarks)
37981
+ return [];
37982
+ return camera.context.visibleLandmarks
37983
+ .map(id => findLandmark(topology, id))
37984
+ .filter((l) => l !== undefined);
37985
+ }
37986
+ /** Gets cameras that can see a landmark */
37987
+ function getCamerasWithLandmarkVisibility(topology, landmarkId) {
37988
+ return topology.cameras.filter(c => c.context?.visibleLandmarks?.includes(landmarkId));
37989
+ }
37990
+ /** Gets adjacent landmarks */
37991
+ function getAdjacentLandmarks(topology, landmarkId) {
37992
+ const landmark = findLandmark(topology, landmarkId);
37993
+ if (!landmark?.adjacentTo)
37994
+ return [];
37995
+ return landmark.adjacentTo
37996
+ .map(id => findLandmark(topology, id))
37997
+ .filter((l) => l !== undefined);
37998
+ }
37999
+ /** Calculates distance between two floor plan positions */
38000
+ function calculateDistance(posA, posB) {
38001
+ const dx = posB.x - posA.x;
38002
+ const dy = posB.y - posA.y;
38003
+ return Math.sqrt(dx * dx + dy * dy);
38004
+ }
38005
+ /** Auto-infers relationships based on positions and proximity */
38006
+ function inferRelationships(topology, proximityThreshold = 50) {
38007
+ const relationships = [];
38008
+ const entities = [];
38009
+ // Collect all positioned entities
38010
+ for (const camera of topology.cameras) {
38011
+ if (camera.floorPlanPosition) {
38012
+ entities.push({ id: camera.deviceId, position: camera.floorPlanPosition, type: 'camera' });
38013
+ }
38014
+ }
38015
+ for (const landmark of topology.landmarks) {
38016
+ entities.push({ id: landmark.id, position: landmark.position, type: 'landmark' });
38017
+ }
38018
+ // Find adjacent entities based on proximity
38019
+ for (let i = 0; i < entities.length; i++) {
38020
+ for (let j = i + 1; j < entities.length; j++) {
38021
+ const distance = calculateDistance(entities[i].position, entities[j].position);
38022
+ if (distance <= proximityThreshold) {
38023
+ relationships.push({
38024
+ id: `auto_${entities[i].id}_${entities[j].id}`,
38025
+ type: distance <= proximityThreshold / 2 ? 'adjacent' : 'near',
38026
+ entityA: entities[i].id,
38027
+ entityB: entities[j].id,
38028
+ autoInferred: true,
38029
+ });
38030
+ }
38031
+ }
38032
+ }
38033
+ return relationships;
38034
+ }
38035
+ /** Generates a natural language description of the topology for LLM context */
38036
+ function generateTopologyDescription(topology) {
38037
+ const lines = [];
38038
+ // Property description
38039
+ if (topology.property?.description) {
38040
+ lines.push(`Property: ${topology.property.description}`);
38041
+ }
38042
+ if (topology.property?.frontFacing) {
38043
+ lines.push(`Front of property faces ${topology.property.frontFacing}.`);
38044
+ }
38045
+ // Landmarks
38046
+ if (topology.landmarks.length > 0) {
38047
+ lines.push('\nLandmarks on property:');
38048
+ for (const landmark of topology.landmarks) {
38049
+ let desc = `- ${landmark.name} (${landmark.type})`;
38050
+ if (landmark.description)
38051
+ desc += `: ${landmark.description}`;
38052
+ if (landmark.isEntryPoint)
38053
+ desc += ' [Entry point]';
38054
+ if (landmark.isExitPoint)
38055
+ desc += ' [Exit point]';
38056
+ lines.push(desc);
38057
+ }
38058
+ }
38059
+ // Cameras
38060
+ if (topology.cameras.length > 0) {
38061
+ lines.push('\nCamera coverage:');
38062
+ for (const camera of topology.cameras) {
38063
+ let desc = `- ${camera.name}`;
38064
+ if (camera.context?.mountLocation)
38065
+ desc += ` (mounted at ${camera.context.mountLocation})`;
38066
+ if (camera.context?.coverageDescription)
38067
+ desc += `: ${camera.context.coverageDescription}`;
38068
+ if (camera.isEntryPoint)
38069
+ desc += ' [Watches entry point]';
38070
+ if (camera.isExitPoint)
38071
+ desc += ' [Watches exit point]';
38072
+ // List visible landmarks
38073
+ if (camera.context?.visibleLandmarks && camera.context.visibleLandmarks.length > 0) {
38074
+ const landmarkNames = camera.context.visibleLandmarks
38075
+ .map(id => findLandmark(topology, id)?.name)
38076
+ .filter(Boolean);
38077
+ if (landmarkNames.length > 0) {
38078
+ desc += ` Can see: ${landmarkNames.join(', ')}`;
38079
+ }
38080
+ }
38081
+ lines.push(desc);
38082
+ }
38083
+ }
38084
+ // Connections/paths
38085
+ if (topology.connections.length > 0) {
38086
+ lines.push('\nMovement paths:');
38087
+ for (const conn of topology.connections) {
38088
+ const fromCam = findCamera(topology, conn.fromCameraId);
38089
+ const toCam = findCamera(topology, conn.toCameraId);
38090
+ if (fromCam && toCam) {
38091
+ let desc = `- ${fromCam.name} → ${toCam.name}`;
38092
+ if (conn.name)
38093
+ desc += ` (${conn.name})`;
38094
+ desc += ` [${conn.transitTime.min / 1000}-${conn.transitTime.max / 1000}s transit]`;
38095
+ if (conn.bidirectional)
38096
+ desc += ' [bidirectional]';
38097
+ lines.push(desc);
38098
+ }
38099
+ }
38100
+ }
38101
+ return lines.join('\n');
38102
+ }
38103
+ /** Generates context for a specific movement between cameras */
38104
+ function generateMovementContext(topology, fromCameraId, toCameraId, objectClass) {
38105
+ const fromCamera = findCamera(topology, fromCameraId);
38106
+ const toCamera = findCamera(topology, toCameraId);
38107
+ const connection = findConnection(topology, fromCameraId, toCameraId);
38108
+ if (!fromCamera || !toCamera) {
38109
+ return `${objectClass} moving between cameras`;
38110
+ }
38111
+ const lines = [];
38112
+ // Source context
38113
+ lines.push(`Origin: ${fromCamera.name}`);
38114
+ if (fromCamera.context?.coverageDescription) {
38115
+ lines.push(` Coverage: ${fromCamera.context.coverageDescription}`);
38116
+ }
38117
+ // Destination context
38118
+ lines.push(`Destination: ${toCamera.name}`);
38119
+ if (toCamera.context?.coverageDescription) {
38120
+ lines.push(` Coverage: ${toCamera.context.coverageDescription}`);
38121
+ }
38122
+ // Path context
38123
+ if (connection) {
38124
+ if (connection.name)
38125
+ lines.push(`Path: ${connection.name}`);
38126
+ if (connection.pathLandmarks && connection.pathLandmarks.length > 0) {
38127
+ const landmarkNames = connection.pathLandmarks
38128
+ .map(id => findLandmark(topology, id)?.name)
38129
+ .filter(Boolean);
38130
+ if (landmarkNames.length > 0) {
38131
+ lines.push(`Passing: ${landmarkNames.join(' → ')}`);
38132
+ }
38133
+ }
38134
+ }
38135
+ // Nearby landmarks at destination
38136
+ const destLandmarks = getLandmarksVisibleFromCamera(topology, toCameraId);
38137
+ if (destLandmarks.length > 0) {
38138
+ lines.push(`Near: ${destLandmarks.map(l => l.name).join(', ')}`);
38139
+ }
38140
+ return lines.join('\n');
38141
+ }
36975
38142
 
36976
38143
 
36977
38144
  /***/ },
@@ -37432,6 +38599,22 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
37432
38599
  <div class="connection-item" style="color: #666; text-align: center; cursor: default;">No connections configured</div>
37433
38600
  </div>
37434
38601
  </div>
38602
+ <div class="section">
38603
+ <div class="section-title">
38604
+ <span>Landmarks</span>
38605
+ <button class="btn btn-small" onclick="openAddLandmarkModal()">+ Add</button>
38606
+ </div>
38607
+ <div id="landmark-list">
38608
+ <div class="landmark-item" style="color: #666; text-align: center; cursor: default; padding: 8px;">No landmarks configured</div>
38609
+ </div>
38610
+ </div>
38611
+ <div class="section" id="suggestions-section" style="display: none;">
38612
+ <div class="section-title">
38613
+ <span>AI Suggestions</span>
38614
+ <button class="btn btn-small" onclick="loadSuggestions()">Refresh</button>
38615
+ </div>
38616
+ <div id="suggestions-list"></div>
38617
+ </div>
37435
38618
  </div>
37436
38619
  </div>
37437
38620
  <div class="editor">
@@ -37445,6 +38628,7 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
37445
38628
  <button class="btn" id="tool-wall" onclick="setTool('wall')">Draw Wall</button>
37446
38629
  <button class="btn" id="tool-room" onclick="setTool('room')">Draw Room</button>
37447
38630
  <button class="btn" id="tool-camera" onclick="setTool('camera')">Place Camera</button>
38631
+ <button class="btn" id="tool-landmark" onclick="setTool('landmark')">Place Landmark</button>
37448
38632
  <button class="btn" id="tool-connect" onclick="setTool('connect')">Connect</button>
37449
38633
  </div>
37450
38634
  <div class="toolbar-group">
@@ -37472,7 +38656,7 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
37472
38656
  <span id="status-text">Ready</span>
37473
38657
  </div>
37474
38658
  <div>
37475
- <span id="camera-count">0</span> cameras | <span id="connection-count">0</span> connections
38659
+ <span id="camera-count">0</span> cameras | <span id="connection-count">0</span> connections | <span id="landmark-count">0</span> landmarks
37476
38660
  </div>
37477
38661
  </div>
37478
38662
  </div>
@@ -37566,12 +38750,61 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
37566
38750
  </div>
37567
38751
  </div>
37568
38752
 
38753
+ <div class="modal-overlay" id="add-landmark-modal">
38754
+ <div class="modal">
38755
+ <h2>Add Landmark</h2>
38756
+ <div class="form-group">
38757
+ <label>Landmark Type</label>
38758
+ <select id="landmark-type-select" onchange="updateLandmarkSuggestions()">
38759
+ <option value="structure">Structure (House, Garage, Shed)</option>
38760
+ <option value="feature">Feature (Mailbox, Tree, Pool)</option>
38761
+ <option value="boundary">Boundary (Fence, Wall, Hedge)</option>
38762
+ <option value="access">Access (Driveway, Walkway, Gate)</option>
38763
+ <option value="vehicle">Vehicle (Parking, Boat, RV)</option>
38764
+ <option value="neighbor">Neighbor (House, Driveway)</option>
38765
+ <option value="zone">Zone (Front Yard, Back Yard)</option>
38766
+ <option value="street">Street (Street, Sidewalk, Alley)</option>
38767
+ </select>
38768
+ </div>
38769
+ <div class="form-group">
38770
+ <label>Quick Templates</label>
38771
+ <div id="landmark-templates" style="display: flex; flex-wrap: wrap; gap: 5px;"></div>
38772
+ </div>
38773
+ <div class="form-group">
38774
+ <label>Name</label>
38775
+ <input type="text" id="landmark-name-input" placeholder="e.g., Front Porch, Red Shed">
38776
+ </div>
38777
+ <div class="form-group">
38778
+ <label>Description (optional)</label>
38779
+ <input type="text" id="landmark-desc-input" placeholder="Brief description for AI context">
38780
+ </div>
38781
+ <div class="form-group">
38782
+ <label class="checkbox-group">
38783
+ <input type="checkbox" id="landmark-entry-checkbox">
38784
+ Entry Point (people can enter property here)
38785
+ </label>
38786
+ </div>
38787
+ <div class="form-group">
38788
+ <label class="checkbox-group">
38789
+ <input type="checkbox" id="landmark-exit-checkbox">
38790
+ Exit Point (people can exit property here)
38791
+ </label>
38792
+ </div>
38793
+ <div class="modal-actions">
38794
+ <button class="btn" onclick="closeModal('add-landmark-modal')">Cancel</button>
38795
+ <button class="btn btn-primary" onclick="addLandmark()">Add Landmark</button>
38796
+ </div>
38797
+ </div>
38798
+ </div>
38799
+
37569
38800
  <script>
37570
- let topology = { version: '1.0', cameras: [], connections: [], globalZones: [], floorPlan: null, drawings: [] };
38801
+ let topology = { version: '2.0', cameras: [], connections: [], globalZones: [], landmarks: [], relationships: [], floorPlan: null, drawings: [] };
37571
38802
  let selectedItem = null;
37572
38803
  let currentTool = 'select';
37573
38804
  let floorPlanImage = null;
37574
38805
  let availableCameras = [];
38806
+ let landmarkTemplates = [];
38807
+ let pendingSuggestions = [];
37575
38808
  let isDrawing = false;
37576
38809
  let drawStart = null;
37577
38810
  let currentDrawing = null;
@@ -37582,6 +38815,8 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
37582
38815
  async function init() {
37583
38816
  await loadTopology();
37584
38817
  await loadAvailableCameras();
38818
+ await loadLandmarkTemplates();
38819
+ await loadSuggestions();
37585
38820
  resizeCanvas();
37586
38821
  render();
37587
38822
  updateUI();
@@ -37617,6 +38852,192 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
37617
38852
  updateCameraSelects();
37618
38853
  }
37619
38854
 
38855
+ async function loadLandmarkTemplates() {
38856
+ try {
38857
+ const response = await fetch('../api/landmark-templates');
38858
+ if (response.ok) {
38859
+ const data = await response.json();
38860
+ landmarkTemplates = data.templates || [];
38861
+ }
38862
+ } catch (e) { console.error('Failed to load landmark templates:', e); }
38863
+ }
38864
+
38865
+ async function loadSuggestions() {
38866
+ try {
38867
+ const response = await fetch('../api/landmark-suggestions');
38868
+ if (response.ok) {
38869
+ const data = await response.json();
38870
+ pendingSuggestions = data.suggestions || [];
38871
+ updateSuggestionsUI();
38872
+ }
38873
+ } catch (e) { console.error('Failed to load suggestions:', e); }
38874
+ }
38875
+
38876
+ function updateSuggestionsUI() {
38877
+ const section = document.getElementById('suggestions-section');
38878
+ const list = document.getElementById('suggestions-list');
38879
+ if (pendingSuggestions.length === 0) {
38880
+ section.style.display = 'none';
38881
+ return;
38882
+ }
38883
+ section.style.display = 'block';
38884
+ list.innerHTML = pendingSuggestions.map(s =>
38885
+ '<div class="camera-item" style="display: flex; justify-content: space-between; align-items: center;">' +
38886
+ '<div><div class="camera-name">' + s.landmark.name + '</div>' +
38887
+ '<div class="camera-info">' + s.landmark.type + ' - ' + Math.round((s.landmark.aiConfidence || 0) * 100) + '% confidence</div></div>' +
38888
+ '<div style="display: flex; gap: 5px;">' +
38889
+ '<button class="btn btn-small btn-primary" onclick="acceptSuggestion(\\'' + s.id + '\\')">Accept</button>' +
38890
+ '<button class="btn btn-small" onclick="rejectSuggestion(\\'' + s.id + '\\')">Reject</button>' +
38891
+ '</div></div>'
38892
+ ).join('');
38893
+ }
38894
+
38895
+ async function acceptSuggestion(id) {
38896
+ try {
38897
+ const response = await fetch('../api/landmark-suggestions/' + id + '/accept', { method: 'POST' });
38898
+ if (response.ok) {
38899
+ const data = await response.json();
38900
+ if (data.landmark) {
38901
+ topology.landmarks.push(data.landmark);
38902
+ updateUI();
38903
+ render();
38904
+ }
38905
+ await loadSuggestions();
38906
+ setStatus('Landmark accepted', 'success');
38907
+ }
38908
+ } catch (e) { console.error('Failed to accept suggestion:', e); }
38909
+ }
38910
+
38911
+ async function rejectSuggestion(id) {
38912
+ try {
38913
+ await fetch('../api/landmark-suggestions/' + id + '/reject', { method: 'POST' });
38914
+ await loadSuggestions();
38915
+ setStatus('Suggestion rejected', 'success');
38916
+ } catch (e) { console.error('Failed to reject suggestion:', e); }
38917
+ }
38918
+
38919
+ function openAddLandmarkModal() {
38920
+ updateLandmarkSuggestions();
38921
+ document.getElementById('add-landmark-modal').classList.add('active');
38922
+ }
38923
+
38924
+ function updateLandmarkSuggestions() {
38925
+ const type = document.getElementById('landmark-type-select').value;
38926
+ const template = landmarkTemplates.find(t => t.type === type);
38927
+ const container = document.getElementById('landmark-templates');
38928
+ if (template) {
38929
+ container.innerHTML = template.suggestions.map(s =>
38930
+ '<button class="btn btn-small" onclick="setLandmarkName(\\'' + s + '\\')" style="margin: 2px;">' + s + '</button>'
38931
+ ).join('');
38932
+ } else {
38933
+ container.innerHTML = '<span style="color: #666; font-size: 12px;">No templates for this type</span>';
38934
+ }
38935
+ }
38936
+
38937
+ function setLandmarkName(name) {
38938
+ document.getElementById('landmark-name-input').value = name;
38939
+ }
38940
+
38941
+ function addLandmark() {
38942
+ const name = document.getElementById('landmark-name-input').value;
38943
+ if (!name) { alert('Please enter a landmark name'); return; }
38944
+ const type = document.getElementById('landmark-type-select').value;
38945
+ const description = document.getElementById('landmark-desc-input').value;
38946
+ const isEntry = document.getElementById('landmark-entry-checkbox').checked;
38947
+ const isExit = document.getElementById('landmark-exit-checkbox').checked;
38948
+ const pos = topology._pendingLandmarkPos || { x: canvas.width / 2 + Math.random() * 100 - 50, y: canvas.height / 2 + Math.random() * 100 - 50 };
38949
+ delete topology._pendingLandmarkPos;
38950
+ const landmark = {
38951
+ id: 'landmark_' + Date.now(),
38952
+ name,
38953
+ type,
38954
+ position: pos,
38955
+ description: description || undefined,
38956
+ isEntryPoint: isEntry,
38957
+ isExitPoint: isExit,
38958
+ visibleFromCameras: [],
38959
+ };
38960
+ if (!topology.landmarks) topology.landmarks = [];
38961
+ topology.landmarks.push(landmark);
38962
+ closeModal('add-landmark-modal');
38963
+ document.getElementById('landmark-name-input').value = '';
38964
+ document.getElementById('landmark-desc-input').value = '';
38965
+ document.getElementById('landmark-entry-checkbox').checked = false;
38966
+ document.getElementById('landmark-exit-checkbox').checked = false;
38967
+ updateUI();
38968
+ render();
38969
+ }
38970
+
38971
+ function selectLandmark(id) {
38972
+ selectedItem = { type: 'landmark', id };
38973
+ const landmark = topology.landmarks.find(l => l.id === id);
38974
+ showLandmarkProperties(landmark);
38975
+ updateUI();
38976
+ render();
38977
+ }
38978
+
38979
+ function showLandmarkProperties(landmark) {
38980
+ const panel = document.getElementById('properties-panel');
38981
+ const cameraOptions = topology.cameras.map(c =>
38982
+ '<label class="checkbox-group" style="margin-bottom: 5px;"><input type="checkbox" ' +
38983
+ ((landmark.visibleFromCameras || []).includes(c.deviceId) ? 'checked' : '') +
38984
+ ' onchange="toggleLandmarkCamera(\\'' + landmark.id + '\\', \\'' + c.deviceId + '\\', this.checked)">' +
38985
+ c.name + '</label>'
38986
+ ).join('');
38987
+ panel.innerHTML = '<h3>Landmark Properties</h3>' +
38988
+ '<div class="form-group"><label>Name</label><input type="text" value="' + landmark.name + '" onchange="updateLandmarkName(\\'' + landmark.id + '\\', this.value)"></div>' +
38989
+ '<div class="form-group"><label>Type</label><select onchange="updateLandmarkType(\\'' + landmark.id + '\\', this.value)">' +
38990
+ '<option value="structure"' + (landmark.type === 'structure' ? ' selected' : '') + '>Structure</option>' +
38991
+ '<option value="feature"' + (landmark.type === 'feature' ? ' selected' : '') + '>Feature</option>' +
38992
+ '<option value="boundary"' + (landmark.type === 'boundary' ? ' selected' : '') + '>Boundary</option>' +
38993
+ '<option value="access"' + (landmark.type === 'access' ? ' selected' : '') + '>Access</option>' +
38994
+ '<option value="vehicle"' + (landmark.type === 'vehicle' ? ' selected' : '') + '>Vehicle</option>' +
38995
+ '<option value="neighbor"' + (landmark.type === 'neighbor' ? ' selected' : '') + '>Neighbor</option>' +
38996
+ '<option value="zone"' + (landmark.type === 'zone' ? ' selected' : '') + '>Zone</option>' +
38997
+ '<option value="street"' + (landmark.type === 'street' ? ' selected' : '') + '>Street</option>' +
38998
+ '</select></div>' +
38999
+ '<div class="form-group"><label>Description</label><input type="text" value="' + (landmark.description || '') + '" onchange="updateLandmarkDesc(\\'' + landmark.id + '\\', this.value)"></div>' +
39000
+ '<div class="form-group"><label class="checkbox-group"><input type="checkbox" ' + (landmark.isEntryPoint ? 'checked' : '') + ' onchange="updateLandmarkEntry(\\'' + landmark.id + '\\', this.checked)">Entry Point</label></div>' +
39001
+ '<div class="form-group"><label class="checkbox-group"><input type="checkbox" ' + (landmark.isExitPoint ? 'checked' : '') + ' onchange="updateLandmarkExit(\\'' + landmark.id + '\\', this.checked)">Exit Point</label></div>' +
39002
+ '<div class="form-group"><label>Visible from Cameras</label>' + (cameraOptions || '<span style="color:#666;font-size:12px;">Add cameras first</span>') + '</div>' +
39003
+ '<div class="form-group"><button class="btn" style="width: 100%; background: #f44336;" onclick="deleteLandmark(\\'' + landmark.id + '\\')">Delete Landmark</button></div>';
39004
+ }
39005
+
39006
+ function updateLandmarkName(id, value) { const l = topology.landmarks.find(x => x.id === id); if (l) l.name = value; updateUI(); }
39007
+ function updateLandmarkType(id, value) { const l = topology.landmarks.find(x => x.id === id); if (l) l.type = value; render(); }
39008
+ function updateLandmarkDesc(id, value) { const l = topology.landmarks.find(x => x.id === id); if (l) l.description = value || undefined; }
39009
+ function updateLandmarkEntry(id, value) { const l = topology.landmarks.find(x => x.id === id); if (l) l.isEntryPoint = value; }
39010
+ function updateLandmarkExit(id, value) { const l = topology.landmarks.find(x => x.id === id); if (l) l.isExitPoint = value; }
39011
+ function toggleLandmarkCamera(landmarkId, cameraId, visible) {
39012
+ const l = topology.landmarks.find(x => x.id === landmarkId);
39013
+ if (!l) return;
39014
+ if (!l.visibleFromCameras) l.visibleFromCameras = [];
39015
+ if (visible && !l.visibleFromCameras.includes(cameraId)) {
39016
+ l.visibleFromCameras.push(cameraId);
39017
+ } else if (!visible) {
39018
+ l.visibleFromCameras = l.visibleFromCameras.filter(id => id !== cameraId);
39019
+ }
39020
+ // Also update camera's visibleLandmarks
39021
+ const camera = topology.cameras.find(c => c.deviceId === cameraId);
39022
+ if (camera) {
39023
+ if (!camera.context) camera.context = {};
39024
+ if (!camera.context.visibleLandmarks) camera.context.visibleLandmarks = [];
39025
+ if (visible && !camera.context.visibleLandmarks.includes(landmarkId)) {
39026
+ camera.context.visibleLandmarks.push(landmarkId);
39027
+ } else if (!visible) {
39028
+ camera.context.visibleLandmarks = camera.context.visibleLandmarks.filter(id => id !== landmarkId);
39029
+ }
39030
+ }
39031
+ }
39032
+ function deleteLandmark(id) {
39033
+ if (!confirm('Delete this landmark?')) return;
39034
+ topology.landmarks = topology.landmarks.filter(l => l.id !== id);
39035
+ selectedItem = null;
39036
+ document.getElementById('properties-panel').innerHTML = '<h3>Properties</h3><p style="color: #666;">Select an item to edit.</p>';
39037
+ updateUI();
39038
+ render();
39039
+ }
39040
+
37620
39041
  async function saveTopology() {
37621
39042
  try {
37622
39043
  setStatus('Saving...', 'warning');
@@ -37704,6 +39125,10 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
37704
39125
  ctx.strokeRect(currentDrawing.x, currentDrawing.y, currentDrawing.width, currentDrawing.height);
37705
39126
  }
37706
39127
  }
39128
+ // Draw landmarks first (below cameras and connections)
39129
+ for (const landmark of (topology.landmarks || [])) {
39130
+ if (landmark.position) { drawLandmark(landmark); }
39131
+ }
37707
39132
  for (const conn of topology.connections) {
37708
39133
  const fromCam = topology.cameras.find(c => c.deviceId === conn.fromCameraId);
37709
39134
  const toCam = topology.cameras.find(c => c.deviceId === conn.toCameraId);
@@ -37716,6 +39141,46 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
37716
39141
  }
37717
39142
  }
37718
39143
 
39144
+ function drawLandmark(landmark) {
39145
+ const pos = landmark.position;
39146
+ const isSelected = selectedItem?.type === 'landmark' && selectedItem?.id === landmark.id;
39147
+ // Color by type
39148
+ const colors = {
39149
+ structure: '#8b5cf6', // purple
39150
+ feature: '#10b981', // green
39151
+ boundary: '#f59e0b', // amber
39152
+ access: '#3b82f6', // blue
39153
+ vehicle: '#6366f1', // indigo
39154
+ neighbor: '#ec4899', // pink
39155
+ zone: '#14b8a6', // teal
39156
+ street: '#6b7280', // gray
39157
+ };
39158
+ const color = colors[landmark.type] || '#888';
39159
+ // Draw landmark marker
39160
+ ctx.beginPath();
39161
+ ctx.moveTo(pos.x, pos.y - 15);
39162
+ ctx.lineTo(pos.x + 12, pos.y + 8);
39163
+ ctx.lineTo(pos.x - 12, pos.y + 8);
39164
+ ctx.closePath();
39165
+ ctx.fillStyle = isSelected ? '#e94560' : color;
39166
+ ctx.fill();
39167
+ ctx.strokeStyle = '#fff';
39168
+ ctx.lineWidth = 2;
39169
+ ctx.stroke();
39170
+ // Entry/exit indicators
39171
+ if (landmark.isEntryPoint || landmark.isExitPoint) {
39172
+ ctx.beginPath();
39173
+ ctx.arc(pos.x, pos.y - 20, 5, 0, Math.PI * 2);
39174
+ ctx.fillStyle = landmark.isEntryPoint ? '#4caf50' : '#ff9800';
39175
+ ctx.fill();
39176
+ }
39177
+ // Label
39178
+ ctx.fillStyle = '#fff';
39179
+ ctx.font = '11px sans-serif';
39180
+ ctx.textAlign = 'center';
39181
+ ctx.fillText(landmark.name, pos.x, pos.y + 25);
39182
+ }
39183
+
37719
39184
  function drawCamera(camera) {
37720
39185
  const pos = camera.floorPlanPosition;
37721
39186
  const isSelected = selectedItem?.type === 'camera' && selectedItem?.id === camera.deviceId;
@@ -37882,8 +39347,17 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
37882
39347
  } else {
37883
39348
  connectionList.innerHTML = topology.connections.map(c => '<div class="connection-item ' + (selectedItem?.type === 'connection' && selectedItem?.id === c.id ? 'selected' : '') + '" onclick="selectConnection(\\'' + c.id + '\\')"><div class="camera-name">' + c.name + '</div><div class="camera-info">' + (c.transitTime.typical / 1000) + 's typical ' + (c.bidirectional ? '<->' : '->') + '</div></div>').join('');
37884
39349
  }
39350
+ // Landmark list
39351
+ const landmarkList = document.getElementById('landmark-list');
39352
+ const landmarks = topology.landmarks || [];
39353
+ if (landmarks.length === 0) {
39354
+ landmarkList.innerHTML = '<div class="landmark-item" style="color: #666; text-align: center; cursor: default; padding: 8px;">No landmarks configured</div>';
39355
+ } else {
39356
+ landmarkList.innerHTML = landmarks.map(l => '<div class="camera-item ' + (selectedItem?.type === 'landmark' && selectedItem?.id === l.id ? 'selected' : '') + '" onclick="selectLandmark(\\'' + l.id + '\\')"><div class="camera-name">' + l.name + '</div><div class="camera-info">' + l.type + (l.isEntryPoint ? ' | Entry' : '') + (l.isExitPoint ? ' | Exit' : '') + '</div></div>').join('');
39357
+ }
37885
39358
  document.getElementById('camera-count').textContent = topology.cameras.length;
37886
39359
  document.getElementById('connection-count').textContent = topology.connections.length;
39360
+ document.getElementById('landmark-count').textContent = landmarks.length;
37887
39361
  }
37888
39362
 
37889
39363
  function selectCamera(deviceId) {
@@ -37954,10 +39428,18 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
37954
39428
  const y = e.clientY - rect.top;
37955
39429
 
37956
39430
  if (currentTool === 'select') {
39431
+ // Check cameras first
37957
39432
  for (const camera of topology.cameras) {
37958
39433
  if (camera.floorPlanPosition) {
37959
39434
  const dist = Math.hypot(x - camera.floorPlanPosition.x, y - camera.floorPlanPosition.y);
37960
- if (dist < 25) { selectCamera(camera.deviceId); dragging = camera; return; }
39435
+ if (dist < 25) { selectCamera(camera.deviceId); dragging = { type: 'camera', item: camera }; return; }
39436
+ }
39437
+ }
39438
+ // Check landmarks
39439
+ for (const landmark of (topology.landmarks || [])) {
39440
+ if (landmark.position) {
39441
+ const dist = Math.hypot(x - landmark.position.x, y - landmark.position.y);
39442
+ if (dist < 20) { selectLandmark(landmark.id); dragging = { type: 'landmark', item: landmark }; return; }
37961
39443
  }
37962
39444
  }
37963
39445
  } else if (currentTool === 'wall') {
@@ -37970,8 +39452,10 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
37970
39452
  currentDrawing = { type: 'room', x: x, y: y, width: 0, height: 0 };
37971
39453
  } else if (currentTool === 'camera') {
37972
39454
  openAddCameraModal();
37973
- // Will position camera at click location after adding
37974
39455
  topology._pendingCameraPos = { x, y };
39456
+ } else if (currentTool === 'landmark') {
39457
+ openAddLandmarkModal();
39458
+ topology._pendingLandmarkPos = { x, y };
37975
39459
  }
37976
39460
  });
37977
39461
 
@@ -37981,8 +39465,13 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
37981
39465
  const y = e.clientY - rect.top;
37982
39466
 
37983
39467
  if (dragging) {
37984
- dragging.floorPlanPosition.x = x;
37985
- dragging.floorPlanPosition.y = y;
39468
+ if (dragging.type === 'camera') {
39469
+ dragging.item.floorPlanPosition.x = x;
39470
+ dragging.item.floorPlanPosition.y = y;
39471
+ } else if (dragging.type === 'landmark') {
39472
+ dragging.item.position.x = x;
39473
+ dragging.item.position.y = y;
39474
+ }
37986
39475
  render();
37987
39476
  } else if (isDrawing && currentDrawing) {
37988
39477
  if (currentDrawing.type === 'wall') {