@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.
@@ -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,13 +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;
35073
35644
  /** Track last alert time per object to enforce cooldown */
35074
35645
  objectLastAlertTime = new Map();
35075
- /** Cache for LLM device reference */
35076
- llmDevice = null;
35646
+ /** Callback for topology changes (e.g., landmark suggestions) */
35647
+ onTopologyChange;
35077
35648
  constructor(topology, state, alertManager, config, console) {
35078
35649
  this.topology = topology;
35079
35650
  this.state = state;
@@ -35081,6 +35652,19 @@ class TrackingEngine {
35081
35652
  this.config = config;
35082
35653
  this.console = console;
35083
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;
35084
35668
  }
35085
35669
  /** Start listening to all cameras in topology */
35086
35670
  async startTracking() {
@@ -35192,52 +35776,45 @@ class TrackingEngine {
35192
35776
  recordAlertTime(globalId) {
35193
35777
  this.objectLastAlertTime.set(globalId, Date.now());
35194
35778
  }
35195
- /** Try to get LLM-enhanced description for movement */
35196
- async getLlmDescription(tracked, fromCamera, toCamera, cameraId) {
35197
- if (!this.config.useLlmDescriptions)
35198
- return null;
35779
+ /** Get spatial reasoning result for movement (uses RAG + LLM) */
35780
+ async getSpatialDescription(tracked, fromCameraId, toCameraId, transitTime, currentCameraId) {
35199
35781
  try {
35200
- // Find LLM plugin device if not cached
35201
- if (!this.llmDevice) {
35202
- for (const id of Object.keys(systemManager.getSystemState())) {
35203
- const device = systemManager.getDeviceById(id);
35204
- if (device?.interfaces?.includes(sdk_1.ScryptedInterface.ObjectDetection) &&
35205
- device.name?.toLowerCase().includes('llm')) {
35206
- this.llmDevice = device;
35207
- this.console.log(`Found LLM device: ${device.name}`);
35208
- break;
35209
- }
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();
35210
35788
  }
35211
35789
  }
35212
- if (!this.llmDevice)
35213
- return null;
35214
- // Get snapshot from camera for LLM analysis
35215
- const camera = systemManager.getDeviceById(cameraId);
35216
- if (!camera?.interfaces?.includes(sdk_1.ScryptedInterface.Camera))
35217
- return null;
35218
- const picture = await camera.takePicture();
35219
- if (!picture)
35220
- return null;
35221
- // Ask LLM to describe the movement
35222
- const prompt = `Describe this ${tracked.className} in one short sentence. ` +
35223
- `They are moving from the ${fromCamera} area towards the ${toCamera}. ` +
35224
- `Include details like: gender (man/woman), clothing color, vehicle color/type if applicable. ` +
35225
- `Example: "Man in blue jacket walking from garage towards front door" or ` +
35226
- `"Black SUV driving from driveway towards street"`;
35227
- const result = await this.llmDevice.detectObjects(picture, {
35228
- settings: { prompt }
35229
- });
35230
- // Extract description from LLM response
35231
- if (result.detections?.[0]?.label) {
35232
- return result.detections[0].label;
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);
35233
35795
  }
35234
- return null;
35796
+ return result;
35235
35797
  }
35236
35798
  catch (e) {
35237
- this.console.warn('LLM description failed:', e);
35799
+ this.console.warn('Spatial reasoning failed:', e);
35238
35800
  return null;
35239
35801
  }
35240
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
+ }
35241
35818
  /** Process a single sighting */
35242
35819
  async processSighting(sighting, isEntryPoint, isExitPoint) {
35243
35820
  // Try to correlate with existing tracked objects
@@ -35265,8 +35842,8 @@ class TrackingEngine {
35265
35842
  `(confidence: ${(correlation.confidence * 100).toFixed(0)}%)`);
35266
35843
  // Check loitering threshold and per-object cooldown before alerting
35267
35844
  if (this.passesLoiteringThreshold(tracked) && !this.isInAlertCooldown(tracked.globalId)) {
35268
- // Try to get LLM-enhanced description
35269
- const llmDescription = await this.getLlmDescription(tracked, lastSighting.cameraName, sighting.cameraName, sighting.cameraId);
35845
+ // Get spatial reasoning result with RAG context
35846
+ const spatialResult = await this.getSpatialDescription(tracked, lastSighting.cameraId, sighting.cameraId, transitDuration, sighting.cameraId);
35270
35847
  // Generate movement alert for cross-camera transition
35271
35848
  await this.alertManager.checkAndAlert('movement', tracked, {
35272
35849
  fromCameraId: lastSighting.cameraId,
@@ -35275,8 +35852,12 @@ class TrackingEngine {
35275
35852
  toCameraName: sighting.cameraName,
35276
35853
  transitTime: transitDuration,
35277
35854
  objectClass: sighting.detection.className,
35278
- objectLabel: llmDescription || sighting.detection.label,
35855
+ objectLabel: spatialResult?.description || sighting.detection.label,
35279
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,
35280
35861
  });
35281
35862
  this.recordAlertTime(tracked.globalId);
35282
35863
  }
@@ -35305,13 +35886,17 @@ class TrackingEngine {
35305
35886
  // Generate entry alert if this is an entry point
35306
35887
  // Entry alerts also respect loitering threshold and cooldown
35307
35888
  if (isEntryPoint && this.passesLoiteringThreshold(tracked) && !this.isInAlertCooldown(globalId)) {
35308
- const llmDescription = await this.getLlmDescription(tracked, 'outside', sighting.cameraName, sighting.cameraId);
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);
35309
35892
  await this.alertManager.checkAndAlert('property_entry', tracked, {
35310
35893
  cameraId: sighting.cameraId,
35311
35894
  cameraName: sighting.cameraName,
35312
35895
  objectClass: sighting.detection.className,
35313
- objectLabel: llmDescription || sighting.detection.label,
35896
+ objectLabel: spatialResult?.description || sighting.detection.label,
35314
35897
  detectionId: sighting.detectionId,
35898
+ involvedLandmarks: spatialResult?.involvedLandmarks?.map(l => l.name),
35899
+ usedLlm: spatialResult?.usedLlm,
35315
35900
  });
35316
35901
  this.recordAlertTime(globalId);
35317
35902
  }
@@ -35394,6 +35979,39 @@ class TrackingEngine {
35394
35979
  updateTopology(topology) {
35395
35980
  this.topology = topology;
35396
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;
35397
36015
  }
35398
36016
  /** Get current topology */
35399
36017
  getTopology() {
@@ -36186,7 +36804,21 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
36186
36804
  type: 'boolean',
36187
36805
  defaultValue: true,
36188
36806
  description: 'Use LLM plugin (if installed) to generate descriptive alerts like "Man walking from garage towards front door"',
36189
- group: 'Tracking',
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',
36190
36822
  },
36191
36823
  // MQTT Settings
36192
36824
  enableMqtt: {
@@ -36333,8 +36965,15 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
36333
36965
  loiteringThreshold: (this.storageSettings.values.loiteringThreshold || 3) * 1000,
36334
36966
  objectAlertCooldown: (this.storageSettings.values.objectAlertCooldown || 30) * 1000,
36335
36967
  useLlmDescriptions: this.storageSettings.values.useLlmDescriptions ?? true,
36968
+ enableLandmarkLearning: this.storageSettings.values.enableLandmarkLearning ?? true,
36969
+ landmarkConfidenceThreshold: this.storageSettings.values.landmarkConfidenceThreshold ?? 0.7,
36336
36970
  };
36337
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
+ });
36338
36977
  await this.trackingEngine.startTracking();
36339
36978
  this.console.log('Tracking engine started');
36340
36979
  }
@@ -36574,7 +37213,9 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
36574
37213
  key === 'useVisualMatching' ||
36575
37214
  key === 'loiteringThreshold' ||
36576
37215
  key === 'objectAlertCooldown' ||
36577
- key === 'useLlmDescriptions') {
37216
+ key === 'useLlmDescriptions' ||
37217
+ key === 'enableLandmarkLearning' ||
37218
+ key === 'landmarkConfidenceThreshold') {
36578
37219
  const topologyJson = this.storage.getItem('topology');
36579
37220
  if (topologyJson) {
36580
37221
  try {
@@ -36626,6 +37267,28 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
36626
37267
  if (path.endsWith('/api/floor-plan')) {
36627
37268
  return this.handleFloorPlanRequest(request, response);
36628
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
+ }
36629
37292
  // UI Routes
36630
37293
  if (path.endsWith('/ui/editor') || path.endsWith('/ui/editor/')) {
36631
37294
  return this.serveEditorUI(response);
@@ -36807,6 +37470,202 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
36807
37470
  }
36808
37471
  }
36809
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
+ }
36810
37669
  serveEditorUI(response) {
36811
37670
  response.send(editor_html_1.EDITOR_HTML, {
36812
37671
  headers: { 'Content-Type': 'text/html' },
@@ -36985,9 +37844,22 @@ function generateAlertMessage(type, details) {
36985
37844
  case 'property_exit':
36986
37845
  return `${objectDesc} exited property via ${details.cameraName || 'unknown camera'}`;
36987
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
36988
37856
  const transitSecs = details.transitTime ? Math.round(details.transitTime / 1000) : 0;
36989
37857
  const transitStr = transitSecs > 0 ? ` (${transitSecs}s transit)` : '';
36990
- 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}`;
36991
37863
  case 'unusual_path':
36992
37864
  return `${objectDesc} took unusual path: ${details.actualPath || 'unknown'}`;
36993
37865
  case 'dwell_time':
@@ -37035,21 +37907,44 @@ function createAlert(type, trackedObjectId, details, severity = 'info', ruleId)
37035
37907
 
37036
37908
  /**
37037
37909
  * Camera Topology Models
37038
- * Defines the spatial relationships between cameras in the NVR system
37910
+ * Defines the spatial relationships between cameras, landmarks, and zones
37039
37911
  */
37040
37912
  Object.defineProperty(exports, "__esModule", ({ value: true }));
37913
+ exports.LANDMARK_TEMPLATES = void 0;
37041
37914
  exports.createEmptyTopology = createEmptyTopology;
37042
37915
  exports.findCamera = findCamera;
37916
+ exports.findLandmark = findLandmark;
37043
37917
  exports.findConnectionsFrom = findConnectionsFrom;
37044
37918
  exports.findConnection = findConnection;
37045
37919
  exports.getEntryPoints = getEntryPoints;
37046
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 ====================
37047
37940
  /** Creates an empty topology */
37048
37941
  function createEmptyTopology() {
37049
37942
  return {
37050
- version: '1.0',
37943
+ version: '2.0',
37051
37944
  cameras: [],
37052
37945
  connections: [],
37946
+ landmarks: [],
37947
+ relationships: [],
37053
37948
  globalZones: [],
37054
37949
  };
37055
37950
  }
@@ -37057,6 +37952,10 @@ function createEmptyTopology() {
37057
37952
  function findCamera(topology, deviceId) {
37058
37953
  return topology.cameras.find(c => c.deviceId === deviceId);
37059
37954
  }
37955
+ /** Finds a landmark by ID */
37956
+ function findLandmark(topology, landmarkId) {
37957
+ return topology.landmarks.find(l => l.id === landmarkId);
37958
+ }
37060
37959
  /** Finds connections from a camera */
37061
37960
  function findConnectionsFrom(topology, cameraId) {
37062
37961
  return topology.connections.filter(c => c.fromCameraId === cameraId ||
@@ -37064,8 +37963,8 @@ function findConnectionsFrom(topology, cameraId) {
37064
37963
  }
37065
37964
  /** Finds a connection between two cameras */
37066
37965
  function findConnection(topology, fromCameraId, toCameraId) {
37067
- return topology.connections.find(c => (c.fromCameraId === fromCameraId && c.toCameraId === toCameraId) ||
37068
- (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];
37069
37968
  }
37070
37969
  /** Gets all entry point cameras */
37071
37970
  function getEntryPoints(topology) {
@@ -37075,6 +37974,171 @@ function getEntryPoints(topology) {
37075
37974
  function getExitPoints(topology) {
37076
37975
  return topology.cameras.filter(c => c.isExitPoint);
37077
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
+ }
37078
38142
 
37079
38143
 
37080
38144
  /***/ },
@@ -37535,6 +38599,22 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
37535
38599
  <div class="connection-item" style="color: #666; text-align: center; cursor: default;">No connections configured</div>
37536
38600
  </div>
37537
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>
37538
38618
  </div>
37539
38619
  </div>
37540
38620
  <div class="editor">
@@ -37548,6 +38628,7 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
37548
38628
  <button class="btn" id="tool-wall" onclick="setTool('wall')">Draw Wall</button>
37549
38629
  <button class="btn" id="tool-room" onclick="setTool('room')">Draw Room</button>
37550
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>
37551
38632
  <button class="btn" id="tool-connect" onclick="setTool('connect')">Connect</button>
37552
38633
  </div>
37553
38634
  <div class="toolbar-group">
@@ -37575,7 +38656,7 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
37575
38656
  <span id="status-text">Ready</span>
37576
38657
  </div>
37577
38658
  <div>
37578
- <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
37579
38660
  </div>
37580
38661
  </div>
37581
38662
  </div>
@@ -37669,12 +38750,61 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
37669
38750
  </div>
37670
38751
  </div>
37671
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
+
37672
38800
  <script>
37673
- 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: [] };
37674
38802
  let selectedItem = null;
37675
38803
  let currentTool = 'select';
37676
38804
  let floorPlanImage = null;
37677
38805
  let availableCameras = [];
38806
+ let landmarkTemplates = [];
38807
+ let pendingSuggestions = [];
37678
38808
  let isDrawing = false;
37679
38809
  let drawStart = null;
37680
38810
  let currentDrawing = null;
@@ -37685,6 +38815,8 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
37685
38815
  async function init() {
37686
38816
  await loadTopology();
37687
38817
  await loadAvailableCameras();
38818
+ await loadLandmarkTemplates();
38819
+ await loadSuggestions();
37688
38820
  resizeCanvas();
37689
38821
  render();
37690
38822
  updateUI();
@@ -37720,6 +38852,192 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
37720
38852
  updateCameraSelects();
37721
38853
  }
37722
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
+
37723
39041
  async function saveTopology() {
37724
39042
  try {
37725
39043
  setStatus('Saving...', 'warning');
@@ -37807,6 +39125,10 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
37807
39125
  ctx.strokeRect(currentDrawing.x, currentDrawing.y, currentDrawing.width, currentDrawing.height);
37808
39126
  }
37809
39127
  }
39128
+ // Draw landmarks first (below cameras and connections)
39129
+ for (const landmark of (topology.landmarks || [])) {
39130
+ if (landmark.position) { drawLandmark(landmark); }
39131
+ }
37810
39132
  for (const conn of topology.connections) {
37811
39133
  const fromCam = topology.cameras.find(c => c.deviceId === conn.fromCameraId);
37812
39134
  const toCam = topology.cameras.find(c => c.deviceId === conn.toCameraId);
@@ -37819,6 +39141,46 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
37819
39141
  }
37820
39142
  }
37821
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
+
37822
39184
  function drawCamera(camera) {
37823
39185
  const pos = camera.floorPlanPosition;
37824
39186
  const isSelected = selectedItem?.type === 'camera' && selectedItem?.id === camera.deviceId;
@@ -37985,8 +39347,17 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
37985
39347
  } else {
37986
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('');
37987
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
+ }
37988
39358
  document.getElementById('camera-count').textContent = topology.cameras.length;
37989
39359
  document.getElementById('connection-count').textContent = topology.connections.length;
39360
+ document.getElementById('landmark-count').textContent = landmarks.length;
37990
39361
  }
37991
39362
 
37992
39363
  function selectCamera(deviceId) {
@@ -38057,10 +39428,18 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
38057
39428
  const y = e.clientY - rect.top;
38058
39429
 
38059
39430
  if (currentTool === 'select') {
39431
+ // Check cameras first
38060
39432
  for (const camera of topology.cameras) {
38061
39433
  if (camera.floorPlanPosition) {
38062
39434
  const dist = Math.hypot(x - camera.floorPlanPosition.x, y - camera.floorPlanPosition.y);
38063
- 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; }
38064
39443
  }
38065
39444
  }
38066
39445
  } else if (currentTool === 'wall') {
@@ -38073,8 +39452,10 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
38073
39452
  currentDrawing = { type: 'room', x: x, y: y, width: 0, height: 0 };
38074
39453
  } else if (currentTool === 'camera') {
38075
39454
  openAddCameraModal();
38076
- // Will position camera at click location after adding
38077
39455
  topology._pendingCameraPos = { x, y };
39456
+ } else if (currentTool === 'landmark') {
39457
+ openAddLandmarkModal();
39458
+ topology._pendingLandmarkPos = { x, y };
38078
39459
  }
38079
39460
  });
38080
39461
 
@@ -38084,8 +39465,13 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
38084
39465
  const y = e.clientY - rect.top;
38085
39466
 
38086
39467
  if (dragging) {
38087
- dragging.floorPlanPosition.x = x;
38088
- 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
+ }
38089
39475
  render();
38090
39476
  } else if (isDrawing && currentDrawing) {
38091
39477
  if (currentDrawing.type === 'wall') {