@blueharford/scrypted-spatial-awareness 0.4.6 → 0.4.8-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -34499,25 +34499,31 @@ class AlertManager {
34499
34499
  getNotificationTitle(alert) {
34500
34500
  const prefix = alert.severity === 'critical' ? '🚨 ' :
34501
34501
  alert.severity === 'warning' ? '⚠️ ' : '';
34502
+ // Use object class in title
34503
+ const objectType = alert.details.objectClass
34504
+ ? alert.details.objectClass.charAt(0).toUpperCase() + alert.details.objectClass.slice(1)
34505
+ : 'Object';
34502
34506
  switch (alert.type) {
34503
34507
  case 'property_entry':
34504
- return `${prefix}🚶 Entry Detected`;
34508
+ return `${prefix}${objectType} Arrived`;
34505
34509
  case 'property_exit':
34506
- return `${prefix}🚶 Exit Detected`;
34510
+ return `${prefix}${objectType} Left`;
34507
34511
  case 'movement':
34508
- return `${prefix}🚶 Movement Detected`;
34512
+ // Include destination in title
34513
+ const dest = alert.details.toCameraName || 'area';
34514
+ return `${prefix}${objectType} → ${dest}`;
34509
34515
  case 'unusual_path':
34510
- return `${prefix}Unusual Path`;
34516
+ return `${prefix}Unusual Route`;
34511
34517
  case 'dwell_time':
34512
- return `${prefix}⏱️ Extended Presence`;
34518
+ return `${prefix}${objectType} Lingering`;
34513
34519
  case 'restricted_zone':
34514
- return `${prefix}Restricted Zone Alert`;
34520
+ return `${prefix}Restricted Zone!`;
34515
34521
  case 'lost_tracking':
34516
- return `${prefix}Lost Tracking`;
34522
+ return `${prefix}${objectType} Lost`;
34517
34523
  case 'reappearance':
34518
- return `${prefix}Object Reappeared`;
34524
+ return `${prefix}${objectType} Reappeared`;
34519
34525
  default:
34520
- return `${prefix}Spatial Awareness Alert`;
34526
+ return `${prefix}Spatial Alert`;
34521
34527
  }
34522
34528
  }
34523
34529
  /**
@@ -34751,8 +34757,20 @@ class ObjectCorrelator {
34751
34757
  candidates.push(candidate);
34752
34758
  }
34753
34759
  }
34754
- if (candidates.length === 0)
34760
+ if (candidates.length === 0) {
34761
+ // No candidates above threshold - try to find best match with relaxed criteria
34762
+ // This helps when there's only one object of this class active
34763
+ const sameClassObjects = activeObjects.filter(o => o.className === sighting.detection.className);
34764
+ if (sameClassObjects.length === 1) {
34765
+ // Only one object of this class - likely the same one
34766
+ const candidate = await this.evaluateCandidate(sameClassObjects[0], sighting);
34767
+ // Accept with lower threshold if timing is reasonable
34768
+ if (candidate.confidence >= 0.3 && candidate.factors.timing > 0) {
34769
+ return candidate;
34770
+ }
34771
+ }
34755
34772
  return null;
34773
+ }
34756
34774
  // Sort by confidence (highest first)
34757
34775
  candidates.sort((a, b) => b.confidence - a.confidence);
34758
34776
  // Return best match
@@ -34815,14 +34833,32 @@ class ObjectCorrelator {
34815
34833
  if (lastSighting.cameraId === sighting.cameraId) {
34816
34834
  return 1.0;
34817
34835
  }
34836
+ // Calculate transit time first
34837
+ const transitTime = sighting.timestamp - lastSighting.timestamp;
34818
34838
  // Find connection between cameras
34819
34839
  const connection = (0, topology_1.findConnection)(this.topology, lastSighting.cameraId, sighting.cameraId);
34820
34840
  if (!connection) {
34821
- // No defined connection - low score but not zero
34822
- // (allows for uncharted paths)
34823
- return 0.2;
34841
+ // No defined connection - still allow correlation based on reasonable timing
34842
+ // Allow up to 5 minutes transit between any cameras (property could be large)
34843
+ const MAX_UNCHARTED_TRANSIT = 300000; // 5 minutes
34844
+ if (transitTime > 0 && transitTime < MAX_UNCHARTED_TRANSIT) {
34845
+ // Score based on how reasonable the timing is
34846
+ // Give higher base score for reasonable transits (encourages matching)
34847
+ if (transitTime < 60000) {
34848
+ // Under 1 minute - very likely same object
34849
+ return 0.9;
34850
+ }
34851
+ else if (transitTime < 120000) {
34852
+ // Under 2 minutes - probably same object
34853
+ return 0.7;
34854
+ }
34855
+ else {
34856
+ // 2-5 minutes - possible but less certain
34857
+ return Math.max(0.4, 0.7 - (transitTime - 120000) / 180000 * 0.3);
34858
+ }
34859
+ }
34860
+ return 0.3; // Even long transits get some credit
34824
34861
  }
34825
- const transitTime = sighting.timestamp - lastSighting.timestamp;
34826
34862
  const { min, typical, max } = connection.transitTime;
34827
34863
  // Way outside range
34828
34864
  if (transitTime < min * 0.5 || transitTime > max * 2) {
@@ -34878,7 +34914,8 @@ class ObjectCorrelator {
34878
34914
  // Find connection
34879
34915
  const connection = (0, topology_1.findConnection)(this.topology, lastSighting.cameraId, sighting.cameraId);
34880
34916
  if (!connection) {
34881
- return 0.3; // No connection defined
34917
+ // No connection defined - give neutral score (don't penalize)
34918
+ return 0.5;
34882
34919
  }
34883
34920
  let score = 0;
34884
34921
  // Check if last detection was in/near exit zone
@@ -35021,42 +35058,12 @@ exports.ObjectCorrelator = ObjectCorrelator;
35021
35058
  * Uses RAG (Retrieval Augmented Generation) to provide rich contextual understanding
35022
35059
  * of movement across the property topology
35023
35060
  */
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
- })();
35061
+ var __importDefault = (this && this.__importDefault) || function (mod) {
35062
+ return (mod && mod.__esModule) ? mod : { "default": mod };
35063
+ };
35057
35064
  Object.defineProperty(exports, "__esModule", ({ value: true }));
35058
35065
  exports.SpatialReasoningEngine = void 0;
35059
- const sdk_1 = __importStar(__webpack_require__(/*! @scrypted/sdk */ "./node_modules/@scrypted/sdk/dist/src/index.js"));
35066
+ const sdk_1 = __importDefault(__webpack_require__(/*! @scrypted/sdk */ "./node_modules/@scrypted/sdk/dist/src/index.js"));
35060
35067
  const topology_1 = __webpack_require__(/*! ../models/topology */ "./src/models/topology.ts");
35061
35068
  const { systemManager } = sdk_1.default;
35062
35069
  class SpatialReasoningEngine {
@@ -35276,29 +35283,185 @@ class SpatialReasoningEngine {
35276
35283
  }
35277
35284
  return relevant;
35278
35285
  }
35279
- /** Find or initialize LLM device */
35286
+ llmSearched = false;
35287
+ llmProvider = null;
35288
+ /** Find or initialize LLM device - looks for ChatCompletion interface from @scrypted/llm plugin */
35280
35289
  async findLlmDevice() {
35281
35290
  if (this.llmDevice)
35282
35291
  return this.llmDevice;
35292
+ if (this.llmSearched)
35293
+ return null; // Already searched and found nothing
35294
+ this.llmSearched = true;
35283
35295
  try {
35296
+ // Look for devices with ChatCompletion interface (the correct interface for @scrypted/llm)
35284
35297
  for (const id of Object.keys(systemManager.getSystemState())) {
35285
35298
  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;
35299
+ if (!device)
35300
+ continue;
35301
+ // Check if this device has ChatCompletion interface
35302
+ // The @scrypted/llm plugin exposes ChatCompletion, not ObjectDetection
35303
+ if (device.interfaces?.includes('ChatCompletion')) {
35304
+ const deviceName = device.name?.toLowerCase() || '';
35305
+ const pluginId = device.pluginId?.toLowerCase() || '';
35306
+ // Identify the provider type for logging
35307
+ let providerType = 'Unknown';
35308
+ if (pluginId.includes('@scrypted/llm') || pluginId.includes('llm')) {
35309
+ providerType = 'Scrypted LLM';
35293
35310
  }
35311
+ if (deviceName.includes('openai') || deviceName.includes('gpt')) {
35312
+ providerType = 'OpenAI';
35313
+ }
35314
+ else if (deviceName.includes('anthropic') || deviceName.includes('claude')) {
35315
+ providerType = 'Anthropic';
35316
+ }
35317
+ else if (deviceName.includes('ollama')) {
35318
+ providerType = 'Ollama';
35319
+ }
35320
+ else if (deviceName.includes('gemini') || deviceName.includes('google')) {
35321
+ providerType = 'Google';
35322
+ }
35323
+ else if (deviceName.includes('llama')) {
35324
+ providerType = 'llama.cpp';
35325
+ }
35326
+ this.llmDevice = device;
35327
+ this.llmProvider = `${providerType} (${device.name})`;
35328
+ this.console.log(`[LLM] Connected to ${providerType}: ${device.name}`);
35329
+ this.console.log(`[LLM] Plugin: ${pluginId || 'N/A'}`);
35330
+ this.console.log(`[LLM] Interfaces: ${device.interfaces?.join(', ')}`);
35331
+ return this.llmDevice;
35294
35332
  }
35295
35333
  }
35334
+ // If we get here, no LLM plugin found
35335
+ this.console.warn('[LLM] No ChatCompletion device found. Install @scrypted/llm for enhanced descriptions.');
35336
+ this.console.warn('[LLM] Falling back to rule-based descriptions using topology data.');
35296
35337
  }
35297
35338
  catch (e) {
35298
- this.console.warn('Error finding LLM device:', e);
35339
+ this.console.error('[LLM] Error searching for LLM device:', e);
35299
35340
  }
35300
35341
  return null;
35301
35342
  }
35343
+ /** Get the current LLM provider name */
35344
+ getLlmProvider() {
35345
+ return this.llmProvider;
35346
+ }
35347
+ /** Check if LLM is available */
35348
+ isLlmAvailable() {
35349
+ return this.llmDevice !== null;
35350
+ }
35351
+ /** Generate entry description when object enters property */
35352
+ generateEntryDescription(tracked, cameraId) {
35353
+ if (!this.topology) {
35354
+ return {
35355
+ description: `${this.capitalizeFirst(tracked.className)} entered property`,
35356
+ involvedLandmarks: [],
35357
+ confidence: 0.5,
35358
+ usedLlm: false,
35359
+ };
35360
+ }
35361
+ const camera = (0, topology_1.findCamera)(this.topology, cameraId);
35362
+ if (!camera) {
35363
+ return {
35364
+ description: `${this.capitalizeFirst(tracked.className)} entered property`,
35365
+ involvedLandmarks: [],
35366
+ confidence: 0.5,
35367
+ usedLlm: false,
35368
+ };
35369
+ }
35370
+ const landmarks = (0, topology_1.getLandmarksVisibleFromCamera)(this.topology, cameraId);
35371
+ const objectType = this.capitalizeFirst(tracked.className);
35372
+ // Build entry description using topology context
35373
+ const location = this.describeLocation(camera, landmarks, 'to');
35374
+ // Check if we can determine where they came from (e.g., street, neighbor)
35375
+ const entryLandmark = landmarks.find(l => l.isEntryPoint);
35376
+ const streetLandmark = landmarks.find(l => l.type === 'street');
35377
+ const neighborLandmark = landmarks.find(l => l.type === 'neighbor');
35378
+ let source = '';
35379
+ if (streetLandmark) {
35380
+ source = ` from ${streetLandmark.name}`;
35381
+ }
35382
+ else if (neighborLandmark) {
35383
+ source = ` from ${neighborLandmark.name}`;
35384
+ }
35385
+ return {
35386
+ description: `${objectType} arrived at ${location}${source}`,
35387
+ involvedLandmarks: landmarks,
35388
+ confidence: 0.8,
35389
+ usedLlm: false,
35390
+ };
35391
+ }
35392
+ /** Generate exit description when object leaves property */
35393
+ generateExitDescription(tracked, cameraId) {
35394
+ if (!this.topology) {
35395
+ return {
35396
+ description: `${this.capitalizeFirst(tracked.className)} left property`,
35397
+ involvedLandmarks: [],
35398
+ confidence: 0.5,
35399
+ usedLlm: false,
35400
+ };
35401
+ }
35402
+ const camera = (0, topology_1.findCamera)(this.topology, cameraId);
35403
+ if (!camera) {
35404
+ return {
35405
+ description: `${this.capitalizeFirst(tracked.className)} left property`,
35406
+ involvedLandmarks: [],
35407
+ confidence: 0.5,
35408
+ usedLlm: false,
35409
+ };
35410
+ }
35411
+ const landmarks = (0, topology_1.getLandmarksVisibleFromCamera)(this.topology, cameraId);
35412
+ const objectType = this.capitalizeFirst(tracked.className);
35413
+ // Build exit description
35414
+ const location = this.describeLocation(camera, landmarks, 'from');
35415
+ // Check for exit point landmarks
35416
+ const exitLandmark = landmarks.find(l => l.isExitPoint);
35417
+ const streetLandmark = landmarks.find(l => l.type === 'street');
35418
+ let destination = '';
35419
+ if (streetLandmark) {
35420
+ destination = ` towards ${streetLandmark.name}`;
35421
+ }
35422
+ else if (exitLandmark) {
35423
+ destination = ` via ${exitLandmark.name}`;
35424
+ }
35425
+ // Include time on property if available
35426
+ const dwellTime = Math.round((tracked.lastSeen - tracked.firstSeen) / 1000);
35427
+ let timeContext = '';
35428
+ if (dwellTime > 60) {
35429
+ timeContext = ` after ${Math.round(dwellTime / 60)}m on property`;
35430
+ }
35431
+ else if (dwellTime > 10) {
35432
+ timeContext = ` after ${dwellTime}s`;
35433
+ }
35434
+ // Summarize journey if they visited multiple cameras (use landmarks from topology)
35435
+ let journeyContext = '';
35436
+ if (tracked.journey.length > 0 && this.topology) {
35437
+ const visitedLandmarks = [];
35438
+ // Get landmarks from entry camera
35439
+ if (tracked.entryCamera) {
35440
+ const entryLandmarks = (0, topology_1.getLandmarksVisibleFromCamera)(this.topology, tracked.entryCamera);
35441
+ const entryLandmark = entryLandmarks.find(l => l.isEntryPoint || l.type === 'access') || entryLandmarks[0];
35442
+ if (entryLandmark) {
35443
+ visitedLandmarks.push(entryLandmark.name);
35444
+ }
35445
+ }
35446
+ // Get landmarks from journey segments
35447
+ for (const segment of tracked.journey) {
35448
+ const segmentLandmarks = (0, topology_1.getLandmarksVisibleFromCamera)(this.topology, segment.toCameraId);
35449
+ const segmentLandmark = segmentLandmarks.find(l => !visitedLandmarks.includes(l.name) && (l.type === 'access' || l.type === 'zone' || l.type === 'structure'));
35450
+ if (segmentLandmark && !visitedLandmarks.includes(segmentLandmark.name)) {
35451
+ visitedLandmarks.push(segmentLandmark.name);
35452
+ }
35453
+ }
35454
+ if (visitedLandmarks.length > 1) {
35455
+ journeyContext = ` — visited ${visitedLandmarks.join(' → ')}`;
35456
+ }
35457
+ }
35458
+ return {
35459
+ description: `${objectType} left ${location}${destination}${timeContext}${journeyContext}`,
35460
+ involvedLandmarks: landmarks,
35461
+ confidence: 0.8,
35462
+ usedLlm: false,
35463
+ };
35464
+ }
35302
35465
  /** Generate rich movement description using LLM */
35303
35466
  async generateMovementDescription(tracked, fromCameraId, toCameraId, transitTime, mediaObject) {
35304
35467
  if (!this.topology) {
@@ -35350,27 +35513,78 @@ class SpatialReasoningEngine {
35350
35513
  buildBasicDescription(tracked, fromCamera, toCamera, transitTime, fromLandmarks, toLandmarks) {
35351
35514
  const objectType = this.capitalizeFirst(tracked.className);
35352
35515
  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}`;
35516
+ // Get connection for path context
35517
+ const connection = this.topology ? (0, topology_1.findConnection)(this.topology, fromCamera.deviceId, toCamera.deviceId) : null;
35518
+ // Build origin description using landmarks, camera context, or camera name
35519
+ let origin = this.describeLocation(fromCamera, fromLandmarks, 'from');
35520
+ // Build destination description
35521
+ let destination = this.describeLocation(toCamera, toLandmarks, 'to');
35522
+ // Check if we have a named path/connection
35523
+ let pathContext = '';
35524
+ if (connection?.name) {
35525
+ pathContext = ` via ${connection.name}`;
35526
+ }
35527
+ else if (connection?.pathLandmarks?.length && this.topology) {
35528
+ const pathNames = connection.pathLandmarks
35529
+ .map(id => (0, topology_1.findLandmark)(this.topology, id)?.name)
35530
+ .filter(Boolean);
35531
+ if (pathNames.length > 0) {
35532
+ pathContext = ` past ${pathNames.join(' and ')}`;
35533
+ }
35534
+ }
35535
+ // Include journey context if this is not the first camera
35536
+ let journeyContext = '';
35537
+ if (tracked.journey.length > 0) {
35538
+ const totalTime = Math.round((Date.now() - tracked.firstSeen) / 1000);
35539
+ if (totalTime > 60) {
35540
+ journeyContext = ` (${Math.round(totalTime / 60)}m on property)`;
35541
+ }
35358
35542
  }
35359
- else if (fromCamera.context?.coverageDescription) {
35360
- origin = fromCamera.context.coverageDescription.split('.')[0];
35543
+ // Determine movement verb based on transit time and object type
35544
+ const verb = this.getMovementVerb(tracked.className, transitSecs);
35545
+ return `${objectType} ${verb} ${origin} heading ${destination}${pathContext}${journeyContext}`;
35546
+ }
35547
+ /** Describe a location using landmarks, camera context, or camera name */
35548
+ describeLocation(camera, landmarks, direction) {
35549
+ // Priority 1: Use entry/exit landmarks
35550
+ const entryExitLandmark = landmarks.find(l => (direction === 'from' && l.isExitPoint) || (direction === 'to' && l.isEntryPoint));
35551
+ if (entryExitLandmark) {
35552
+ return direction === 'from' ? `the ${entryExitLandmark.name}` : `the ${entryExitLandmark.name}`;
35361
35553
  }
35362
- // Build destination description
35363
- let destination = toCamera.name;
35364
- if (toLandmarks.length > 0) {
35365
- const nearLandmark = toLandmarks[0];
35366
- destination = `towards ${nearLandmark.name}`;
35554
+ // Priority 2: Use access landmarks (driveway, walkway, etc.)
35555
+ const accessLandmark = landmarks.find(l => l.type === 'access');
35556
+ if (accessLandmark) {
35557
+ return `the ${accessLandmark.name}`;
35367
35558
  }
35368
- else if (toCamera.context?.coverageDescription) {
35369
- destination = `towards ${toCamera.context.coverageDescription.split('.')[0]}`;
35559
+ // Priority 3: Use zone landmarks (front yard, back yard)
35560
+ const zoneLandmark = landmarks.find(l => l.type === 'zone');
35561
+ if (zoneLandmark) {
35562
+ return `the ${zoneLandmark.name}`;
35370
35563
  }
35371
- // Build transit string
35372
- const transitStr = transitSecs > 0 ? ` (${transitSecs}s)` : '';
35373
- return `${objectType} moving from ${origin} ${destination}${transitStr}`;
35564
+ // Priority 4: Use any landmark
35565
+ if (landmarks.length > 0) {
35566
+ return `near ${landmarks[0].name}`;
35567
+ }
35568
+ // Priority 5: Use camera coverage description
35569
+ if (camera.context?.coverageDescription) {
35570
+ const desc = camera.context.coverageDescription.split('.')[0].toLowerCase();
35571
+ return `the ${desc}`;
35572
+ }
35573
+ // Fallback: Generic description (no camera name inference - use topology for context)
35574
+ return direction === 'from' ? 'property' : 'property';
35575
+ }
35576
+ /** Get appropriate movement verb based on context */
35577
+ getMovementVerb(className, transitSecs) {
35578
+ if (className === 'car' || className === 'vehicle' || className === 'truck') {
35579
+ return transitSecs < 10 ? 'driving from' : 'moved from';
35580
+ }
35581
+ if (transitSecs < 5) {
35582
+ return 'walking from';
35583
+ }
35584
+ if (transitSecs < 30) {
35585
+ return 'moved from';
35586
+ }
35587
+ return 'traveled from';
35374
35588
  }
35375
35589
  /** Build path description from connection */
35376
35590
  buildPathDescription(fromCamera, toCamera) {
@@ -35389,10 +35603,10 @@ class SpatialReasoningEngine {
35389
35603
  }
35390
35604
  return connection.name || undefined;
35391
35605
  }
35392
- /** Get LLM-enhanced description */
35606
+ /** Get LLM-enhanced description using ChatCompletion interface */
35393
35607
  async getLlmEnhancedDescription(tracked, fromCamera, toCamera, transitTime, fromLandmarks, toLandmarks, mediaObject) {
35394
35608
  const llm = await this.findLlmDevice();
35395
- if (!llm)
35609
+ if (!llm || !llm.getChatCompletion)
35396
35610
  return null;
35397
35611
  try {
35398
35612
  // Retrieve relevant context for RAG
@@ -35401,13 +35615,21 @@ class SpatialReasoningEngine {
35401
35615
  const ragContext = relevantChunks.map(c => c.content).join('\n\n');
35402
35616
  // Build the prompt
35403
35617
  const prompt = this.buildLlmPrompt(tracked, fromCamera, toCamera, transitTime, fromLandmarks, toLandmarks, ragContext);
35404
- // Call LLM
35405
- const result = await llm.detectObjects(mediaObject, {
35406
- settings: { prompt }
35618
+ // Call LLM using ChatCompletion interface
35619
+ const result = await llm.getChatCompletion({
35620
+ messages: [
35621
+ {
35622
+ role: 'user',
35623
+ content: prompt,
35624
+ },
35625
+ ],
35626
+ max_tokens: 150,
35627
+ temperature: 0.7,
35407
35628
  });
35408
- // Extract description from result
35409
- if (result.detections?.[0]?.label) {
35410
- return result.detections[0].label;
35629
+ // Extract description from ChatCompletion result
35630
+ const content = result?.choices?.[0]?.message?.content;
35631
+ if (content && typeof content === 'string') {
35632
+ return content.trim();
35411
35633
  }
35412
35634
  return null;
35413
35635
  }
@@ -35444,12 +35666,12 @@ Examples of good descriptions:
35444
35666
 
35445
35667
  Generate ONLY the description, nothing else:`;
35446
35668
  }
35447
- /** Suggest a new landmark based on AI analysis */
35669
+ /** Suggest a new landmark based on AI analysis using ChatCompletion */
35448
35670
  async suggestLandmark(cameraId, mediaObject, objectClass, position) {
35449
35671
  if (!this.config.enableLandmarkLearning)
35450
35672
  return null;
35451
35673
  const llm = await this.findLlmDevice();
35452
- if (!llm)
35674
+ if (!llm || !llm.getChatCompletion)
35453
35675
  return null;
35454
35676
  try {
35455
35677
  const prompt = `Analyze this security camera image. A ${objectClass} was detected.
@@ -35464,12 +35686,21 @@ If you can identify a clear landmark feature, respond with ONLY a JSON object:
35464
35686
  {"name": "Landmark Name", "type": "structure|feature|boundary|access|vehicle|neighbor|zone|street", "description": "Brief description"}
35465
35687
 
35466
35688
  If no clear landmark is identifiable, respond with: {"name": null}`;
35467
- const result = await llm.detectObjects(mediaObject, {
35468
- settings: { prompt }
35689
+ // Call LLM using ChatCompletion interface
35690
+ const result = await llm.getChatCompletion({
35691
+ messages: [
35692
+ {
35693
+ role: 'user',
35694
+ content: prompt,
35695
+ },
35696
+ ],
35697
+ max_tokens: 100,
35698
+ temperature: 0.3,
35469
35699
  });
35470
- if (result.detections?.[0]?.label) {
35700
+ const content = result?.choices?.[0]?.message?.content;
35701
+ if (content && typeof content === 'string') {
35471
35702
  try {
35472
- const parsed = JSON.parse(result.detections[0].label);
35703
+ const parsed = JSON.parse(content.trim());
35473
35704
  if (parsed.name && parsed.type) {
35474
35705
  const suggestionId = `suggest_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
35475
35706
  const suggestion = {
@@ -35947,24 +36178,52 @@ class TrackingEngine {
35947
36178
  const tracked = this.state.createObject(globalId, sighting, isEntryPoint);
35948
36179
  this.console.log(`New ${sighting.detection.className} detected on ${sighting.cameraName} ` +
35949
36180
  `(ID: ${globalId.slice(0, 8)})`);
35950
- // Generate entry alert if this is an entry point
35951
- // Entry alerts also respect loitering threshold and cooldown
35952
- if (isEntryPoint && this.passesLoiteringThreshold(tracked) && !this.isInAlertCooldown(globalId)) {
35953
- // Get spatial reasoning for entry event
35954
- const spatialResult = await this.getSpatialDescription(tracked, 'outside', // Virtual "outside" location for entry
35955
- sighting.cameraId, 0, sighting.cameraId);
36181
+ // Schedule loitering check - alert after object passes loitering threshold
36182
+ // This ensures we don't miss alerts for brief appearances while still filtering noise
36183
+ this.scheduleLoiteringAlert(globalId, sighting, isEntryPoint);
36184
+ }
36185
+ }
36186
+ /** Schedule an alert after loitering threshold passes */
36187
+ scheduleLoiteringAlert(globalId, sighting, isEntryPoint) {
36188
+ // Check after loitering threshold if object is still being tracked
36189
+ setTimeout(async () => {
36190
+ const tracked = this.state.getObject(globalId);
36191
+ if (!tracked || tracked.state !== 'active')
36192
+ return;
36193
+ // Check if we've already alerted for this object
36194
+ if (this.isInAlertCooldown(globalId))
36195
+ return;
36196
+ // Generate spatial description
36197
+ const spatialResult = this.spatialReasoning.generateEntryDescription(tracked, sighting.cameraId);
36198
+ if (isEntryPoint) {
36199
+ // Entry point - generate property entry alert
35956
36200
  await this.alertManager.checkAndAlert('property_entry', tracked, {
35957
36201
  cameraId: sighting.cameraId,
35958
36202
  cameraName: sighting.cameraName,
35959
36203
  objectClass: sighting.detection.className,
35960
- objectLabel: spatialResult?.description || sighting.detection.label,
36204
+ objectLabel: spatialResult.description,
35961
36205
  detectionId: sighting.detectionId,
35962
- involvedLandmarks: spatialResult?.involvedLandmarks?.map(l => l.name),
35963
- usedLlm: spatialResult?.usedLlm,
36206
+ involvedLandmarks: spatialResult.involvedLandmarks?.map(l => l.name),
36207
+ usedLlm: spatialResult.usedLlm,
35964
36208
  });
35965
- this.recordAlertTime(globalId);
35966
36209
  }
35967
- }
36210
+ else {
36211
+ // Non-entry point - still alert about activity using movement alert type
36212
+ // This notifies about any activity around the property using topology context
36213
+ await this.alertManager.checkAndAlert('movement', tracked, {
36214
+ cameraId: sighting.cameraId,
36215
+ cameraName: sighting.cameraName,
36216
+ toCameraId: sighting.cameraId,
36217
+ toCameraName: sighting.cameraName,
36218
+ objectClass: sighting.detection.className,
36219
+ objectLabel: spatialResult.description, // Use spatial reasoning description (topology-based)
36220
+ detectionId: sighting.detectionId,
36221
+ involvedLandmarks: spatialResult.involvedLandmarks?.map(l => l.name),
36222
+ usedLlm: spatialResult.usedLlm,
36223
+ });
36224
+ }
36225
+ this.recordAlertTime(globalId);
36226
+ }, this.config.loiteringThreshold);
35968
36227
  }
35969
36228
  /** Attempt to correlate a sighting with existing tracked objects */
35970
36229
  async correlateDetection(sighting) {
@@ -36010,12 +36269,16 @@ class TrackingEngine {
36010
36269
  const current = this.state.getObject(tracked.globalId);
36011
36270
  if (current && current.state === 'pending') {
36012
36271
  this.state.markExited(tracked.globalId, sighting.cameraId, sighting.cameraName);
36013
- this.console.log(`Object ${tracked.globalId.slice(0, 8)} exited via ${sighting.cameraName}`);
36272
+ // Generate rich exit description using topology context
36273
+ const spatialResult = this.spatialReasoning.generateExitDescription(current, sighting.cameraId);
36274
+ this.console.log(`Object ${tracked.globalId.slice(0, 8)} exited: ${spatialResult.description}`);
36014
36275
  await this.alertManager.checkAndAlert('property_exit', current, {
36015
36276
  cameraId: sighting.cameraId,
36016
36277
  cameraName: sighting.cameraName,
36017
36278
  objectClass: current.className,
36018
- objectLabel: current.label,
36279
+ objectLabel: spatialResult.description,
36280
+ involvedLandmarks: spatialResult.involvedLandmarks?.map(l => l.name),
36281
+ usedLlm: spatialResult.usedLlm,
36019
36282
  });
36020
36283
  }
36021
36284
  this.pendingTimers.delete(tracked.globalId);
@@ -37450,6 +37713,8 @@ Object.defineProperty(exports, "__esModule", ({ value: true }));
37450
37713
  exports.SpatialAwarenessPlugin = void 0;
37451
37714
  const sdk_1 = __importStar(__webpack_require__(/*! @scrypted/sdk */ "./node_modules/@scrypted/sdk/dist/src/index.js"));
37452
37715
  const storage_settings_1 = __webpack_require__(/*! @scrypted/sdk/storage-settings */ "./node_modules/@scrypted/sdk/dist/src/storage-settings.js");
37716
+ const fs = __importStar(__webpack_require__(/*! fs */ "fs"));
37717
+ const path = __importStar(__webpack_require__(/*! path */ "path"));
37453
37718
  const topology_1 = __webpack_require__(/*! ./models/topology */ "./src/models/topology.ts");
37454
37719
  const alert_1 = __webpack_require__(/*! ./models/alert */ "./src/models/alert.ts");
37455
37720
  const tracking_state_1 = __webpack_require__(/*! ./state/tracking-state */ "./src/state/tracking-state.ts");
@@ -37460,7 +37725,7 @@ const tracking_zone_1 = __webpack_require__(/*! ./devices/tracking-zone */ "./sr
37460
37725
  const mqtt_publisher_1 = __webpack_require__(/*! ./integrations/mqtt-publisher */ "./src/integrations/mqtt-publisher.ts");
37461
37726
  const editor_html_1 = __webpack_require__(/*! ./ui/editor-html */ "./src/ui/editor-html.ts");
37462
37727
  const training_html_1 = __webpack_require__(/*! ./ui/training-html */ "./src/ui/training-html.ts");
37463
- const { deviceManager, systemManager } = sdk_1.default;
37728
+ const { deviceManager, systemManager, mediaManager } = sdk_1.default;
37464
37729
  const TRACKING_ZONE_PREFIX = 'tracking-zone:';
37465
37730
  const GLOBAL_TRACKER_ID = 'global-tracker';
37466
37731
  class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
@@ -37494,8 +37759,8 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
37494
37759
  correlationThreshold: {
37495
37760
  title: 'Correlation Confidence Threshold',
37496
37761
  type: 'number',
37497
- defaultValue: 0.6,
37498
- description: 'Minimum confidence (0-1) for automatic object correlation',
37762
+ defaultValue: 0.35,
37763
+ description: 'Minimum confidence (0-1) for automatic object correlation. Lower values allow more matches.',
37499
37764
  group: 'Tracking',
37500
37765
  },
37501
37766
  lostTimeout: {
@@ -37722,7 +37987,7 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
37722
37987
  }
37723
37988
  const config = {
37724
37989
  correlationWindow: (this.storageSettings.values.correlationWindow || 30) * 1000,
37725
- correlationThreshold: this.storageSettings.values.correlationThreshold || 0.6,
37990
+ correlationThreshold: this.storageSettings.values.correlationThreshold || 0.35,
37726
37991
  lostTimeout: (this.storageSettings.values.lostTimeout || 300) * 1000,
37727
37992
  useVisualMatching: this.storageSettings.values.useVisualMatching ?? true,
37728
37993
  loiteringThreshold: (this.storageSettings.values.loiteringThreshold || 3) * 1000,
@@ -38373,15 +38638,37 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
38373
38638
  });
38374
38639
  }
38375
38640
  }
38376
- handleFloorPlanRequest(request, response) {
38641
+ async getFloorPlanPath() {
38642
+ // Use mediaManager.getFilesPath() for proper persistent storage
38643
+ const filesPath = await mediaManager.getFilesPath();
38644
+ this.console.log('Files path from mediaManager:', filesPath);
38645
+ // Ensure directory exists
38646
+ if (!fs.existsSync(filesPath)) {
38647
+ fs.mkdirSync(filesPath, { recursive: true });
38648
+ }
38649
+ return path.join(filesPath, 'floorplan.jpg');
38650
+ }
38651
+ async handleFloorPlanRequest(request, response) {
38377
38652
  if (request.method === 'GET') {
38378
- const imageData = this.storage.getItem('floorPlanImage');
38379
- if (imageData) {
38380
- response.send(JSON.stringify({ imageData }), {
38381
- headers: { 'Content-Type': 'application/json' },
38382
- });
38653
+ try {
38654
+ const floorPlanPath = await this.getFloorPlanPath();
38655
+ this.console.log('Loading floor plan from:', floorPlanPath, 'exists:', fs.existsSync(floorPlanPath));
38656
+ if (fs.existsSync(floorPlanPath)) {
38657
+ const imageBuffer = fs.readFileSync(floorPlanPath);
38658
+ const imageData = 'data:image/jpeg;base64,' + imageBuffer.toString('base64');
38659
+ this.console.log('Floor plan loaded, size:', imageBuffer.length);
38660
+ response.send(JSON.stringify({ imageData }), {
38661
+ headers: { 'Content-Type': 'application/json' },
38662
+ });
38663
+ }
38664
+ else {
38665
+ response.send(JSON.stringify({ imageData: null }), {
38666
+ headers: { 'Content-Type': 'application/json' },
38667
+ });
38668
+ }
38383
38669
  }
38384
- else {
38670
+ catch (e) {
38671
+ this.console.error('Failed to read floor plan:', e);
38385
38672
  response.send(JSON.stringify({ imageData: null }), {
38386
38673
  headers: { 'Content-Type': 'application/json' },
38387
38674
  });
@@ -38390,14 +38677,21 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
38390
38677
  else if (request.method === 'POST') {
38391
38678
  try {
38392
38679
  const body = JSON.parse(request.body);
38393
- this.storage.setItem('floorPlanImage', body.imageData);
38680
+ const imageData = body.imageData;
38681
+ // Extract base64 data (remove data:image/xxx;base64, prefix)
38682
+ const base64Data = imageData.replace(/^data:image\/\w+;base64,/, '');
38683
+ const imageBuffer = Buffer.from(base64Data, 'base64');
38684
+ const floorPlanPath = await this.getFloorPlanPath();
38685
+ fs.writeFileSync(floorPlanPath, imageBuffer);
38686
+ this.console.log('Floor plan saved to:', floorPlanPath, 'size:', imageBuffer.length);
38394
38687
  response.send(JSON.stringify({ success: true }), {
38395
38688
  headers: { 'Content-Type': 'application/json' },
38396
38689
  });
38397
38690
  }
38398
38691
  catch (e) {
38399
- response.send(JSON.stringify({ error: 'Invalid request body' }), {
38400
- code: 400,
38692
+ this.console.error('Failed to save floor plan:', e);
38693
+ response.send(JSON.stringify({ error: 'Failed to save floor plan' }), {
38694
+ code: 500,
38401
38695
  headers: { 'Content-Type': 'application/json' },
38402
38696
  });
38403
38697
  }
@@ -39031,26 +39325,53 @@ function generateAlertMessage(type, details) {
39031
39325
  : capitalize(details.objectClass || '');
39032
39326
  switch (type) {
39033
39327
  case 'property_entry':
39034
- return `${objectDesc} entered property via ${details.cameraName || 'unknown camera'}`;
39328
+ // Use the rich description from spatial reasoning if available
39329
+ if (details.objectLabel && details.objectLabel !== details.objectClass) {
39330
+ return details.objectLabel;
39331
+ }
39332
+ // Fallback to basic description
39333
+ if (details.involvedLandmarks && details.involvedLandmarks.length > 0) {
39334
+ return `${objectDesc} entered property near ${details.involvedLandmarks[0]}`;
39335
+ }
39336
+ return `${objectDesc} entered property at ${details.cameraName || 'entrance'}`;
39035
39337
  case 'property_exit':
39036
- return `${objectDesc} exited property via ${details.cameraName || 'unknown camera'}`;
39338
+ // Use the rich description from spatial reasoning if available
39339
+ if (details.objectLabel && details.objectLabel !== details.objectClass) {
39340
+ return details.objectLabel;
39341
+ }
39342
+ // Fallback to basic description
39343
+ if (details.involvedLandmarks && details.involvedLandmarks.length > 0) {
39344
+ return `${objectDesc} left property via ${details.involvedLandmarks[0]}`;
39345
+ }
39346
+ return `${objectDesc} left property`;
39037
39347
  case 'movement':
39038
- // If we have a rich description from LLM/RAG, use it
39039
- if (details.objectLabel && details.usedLlm) {
39348
+ // If objectLabel contains a full description, use it directly
39349
+ if (details.objectLabel && details.objectLabel !== details.objectClass) {
39350
+ // Check if this is a cross-camera movement or initial detection
39351
+ if (details.fromCameraId && details.fromCameraId !== details.toCameraId && details.transitTime) {
39352
+ const transitSecs = Math.round(details.transitTime / 1000);
39353
+ const transitStr = transitSecs > 0 ? ` (${transitSecs}s)` : '';
39354
+ const pathContext = details.pathDescription ? ` via ${details.pathDescription}` : '';
39355
+ return `${details.objectLabel}${pathContext}${transitStr}`;
39356
+ }
39357
+ // Initial detection - use the label directly
39358
+ return details.objectLabel;
39359
+ }
39360
+ // Cross-camera movement with basic info
39361
+ if (details.fromCameraId && details.fromCameraId !== details.toCameraId) {
39040
39362
  const transitSecs = details.transitTime ? Math.round(details.transitTime / 1000) : 0;
39041
- const transitStr = transitSecs > 0 ? ` (${transitSecs}s)` : '';
39042
- // Include path/landmark context if available
39043
- const pathContext = details.pathDescription ? ` via ${details.pathDescription}` : '';
39044
- return `${details.objectLabel}${pathContext}${transitStr}`;
39045
- }
39046
- // Fallback to basic message with landmark info
39047
- const transitSecs = details.transitTime ? Math.round(details.transitTime / 1000) : 0;
39048
- const transitStr = transitSecs > 0 ? ` (${transitSecs}s transit)` : '';
39049
- let movementDesc = `${objectDesc} moving from ${details.fromCameraName || 'unknown'} towards ${details.toCameraName || 'unknown'}`;
39363
+ const transitStr = transitSecs > 0 ? ` (${transitSecs}s transit)` : '';
39364
+ let movementDesc = `${objectDesc} moving from ${details.fromCameraName || 'unknown'} towards ${details.toCameraName || 'unknown'}`;
39365
+ if (details.involvedLandmarks && details.involvedLandmarks.length > 0) {
39366
+ movementDesc += ` near ${details.involvedLandmarks.join(', ')}`;
39367
+ }
39368
+ return `${movementDesc}${transitStr}`;
39369
+ }
39370
+ // Initial detection without full label
39050
39371
  if (details.involvedLandmarks && details.involvedLandmarks.length > 0) {
39051
- movementDesc += ` near ${details.involvedLandmarks.join(', ')}`;
39372
+ return `${objectDesc} detected near ${details.involvedLandmarks[0]}`;
39052
39373
  }
39053
- return `${movementDesc}${transitStr}`;
39374
+ return `${objectDesc} detected at ${details.cameraName || 'camera'}`;
39054
39375
  case 'unusual_path':
39055
39376
  return `${objectDesc} took unusual path: ${details.actualPath || 'unknown'}`;
39056
39377
  case 'dwell_time':
@@ -40120,10 +40441,27 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
40120
40441
  if (response.ok) {
40121
40442
  topology = await response.json();
40122
40443
  if (!topology.drawings) topology.drawings = [];
40444
+ // Load floor plan from separate storage (handles legacy imageData in topology too)
40123
40445
  if (topology.floorPlan?.imageData) {
40446
+ // Legacy: imageData was stored in topology
40124
40447
  await loadFloorPlanImage(topology.floorPlan.imageData);
40125
40448
  } else if (topology.floorPlan?.type === 'blank') {
40126
40449
  blankCanvasMode = true;
40450
+ } else {
40451
+ // Always try to load from floor-plan endpoint (handles uploaded and missing cases)
40452
+ try {
40453
+ const fpResponse = await fetch('../api/floor-plan');
40454
+ if (fpResponse.ok) {
40455
+ const fpData = await fpResponse.json();
40456
+ if (fpData.imageData) {
40457
+ await loadFloorPlanImage(fpData.imageData);
40458
+ // Update topology reference if not set
40459
+ if (!topology.floorPlan || topology.floorPlan.type !== 'uploaded') {
40460
+ topology.floorPlan = { type: 'uploaded', width: floorPlanImage.width, height: floorPlanImage.height };
40461
+ }
40462
+ }
40463
+ }
40464
+ } catch (err) { console.error('Failed to load floor plan:', err); }
40127
40465
  }
40128
40466
  }
40129
40467
  } catch (e) { console.error('Failed to load topology:', e); }
@@ -40747,16 +41085,84 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
40747
41085
 
40748
41086
  function uploadFloorPlan() { document.getElementById('upload-modal').classList.add('active'); }
40749
41087
 
41088
+ // Compress and resize image to avoid 413 errors (Scrypted has ~50KB limit)
41089
+ function compressImage(img, maxSize = 800, quality = 0.5) {
41090
+ return new Promise((resolve) => {
41091
+ const canvas = document.createElement('canvas');
41092
+ let width = img.width;
41093
+ let height = img.height;
41094
+
41095
+ // Always resize to fit within maxSize
41096
+ if (width > maxSize || height > maxSize) {
41097
+ if (width > height) {
41098
+ height = Math.round(height * maxSize / width);
41099
+ width = maxSize;
41100
+ } else {
41101
+ width = Math.round(width * maxSize / height);
41102
+ height = maxSize;
41103
+ }
41104
+ }
41105
+
41106
+ canvas.width = width;
41107
+ canvas.height = height;
41108
+ const ctx = canvas.getContext('2d');
41109
+ ctx.drawImage(img, 0, 0, width, height);
41110
+
41111
+ // Convert to JPEG with aggressive compression
41112
+ let compressed = canvas.toDataURL('image/jpeg', quality);
41113
+ console.log('Compressed image from', img.width, 'x', img.height, 'to', width, 'x', height, 'size:', Math.round(compressed.length / 1024), 'KB');
41114
+
41115
+ // If still too large, compress more
41116
+ let q = quality;
41117
+ while (compressed.length > 50000 && q > 0.1) {
41118
+ q -= 0.1;
41119
+ compressed = canvas.toDataURL('image/jpeg', q);
41120
+ console.log('Re-compressed at quality', q.toFixed(1), 'size:', Math.round(compressed.length / 1024), 'KB');
41121
+ }
41122
+
41123
+ resolve(compressed);
41124
+ });
41125
+ }
41126
+
40750
41127
  async function handleFloorPlanUpload(event) {
40751
41128
  const file = event.target.files[0];
40752
41129
  if (!file) return;
40753
41130
  const reader = new FileReader();
40754
41131
  reader.onload = async (e) => {
40755
- const imageData = e.target.result;
40756
- await loadFloorPlanImage(imageData);
40757
- topology.floorPlan = { imageData, width: floorPlanImage.width, height: floorPlanImage.height };
40758
- closeModal('upload-modal');
40759
- render();
41132
+ const originalData = e.target.result;
41133
+
41134
+ // Load image to get dimensions
41135
+ const img = new Image();
41136
+ img.onload = async () => {
41137
+ // Compress image to reduce size
41138
+ const imageData = await compressImage(img);
41139
+ await loadFloorPlanImage(imageData);
41140
+
41141
+ // Store floor plan separately via API
41142
+ try {
41143
+ setStatus('Uploading floor plan...', 'warning');
41144
+ const response = await fetch('../api/floor-plan', {
41145
+ method: 'POST',
41146
+ headers: { 'Content-Type': 'application/json' },
41147
+ body: JSON.stringify({ imageData })
41148
+ });
41149
+ if (response.ok) {
41150
+ setStatus('Floor plan saved', 'success');
41151
+ } else {
41152
+ setStatus('Failed to save floor plan: ' + response.status, 'error');
41153
+ console.error('Floor plan upload failed:', response.status, response.statusText);
41154
+ }
41155
+ } catch (err) {
41156
+ console.error('Failed to save floor plan:', err);
41157
+ setStatus('Failed to save floor plan', 'error');
41158
+ }
41159
+
41160
+ // Store reference in topology (without the large imageData)
41161
+ topology.floorPlan = { type: 'uploaded', width: floorPlanImage.width, height: floorPlanImage.height };
41162
+ closeModal('upload-modal');
41163
+ render();
41164
+ };
41165
+ img.src = originalData;
40760
41166
  };
40761
41167
  reader.readAsDataURL(file);
40762
41168
  }
@@ -40916,7 +41322,7 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
40916
41322
  function updateConnectionName(id, value) { const conn = topology.connections.find(c => c.id === id); if (conn) conn.name = value; updateUI(); }
40917
41323
  function updateTransitTime(id, field, value) { const conn = topology.connections.find(c => c.id === id); if (conn) conn.transitTime[field] = parseInt(value) * 1000; }
40918
41324
  function updateConnectionBidi(id, value) { const conn = topology.connections.find(c => c.id === id); if (conn) conn.bidirectional = value; render(); }
40919
- function deleteCamera(id) { if (!confirm('Delete this camera?')) return; topology.cameras = topology.cameras.filter(c => c.deviceId !== id); topology.connections = topology.connections.filter(c => c.fromCameraId !== id && c.toCameraId !== id); selectedItem = null; document.getElementById('properties-panel').innerHTML = '<h3>Properties</h3><p style="color: #666;">Select a camera or connection.</p>'; updateUI(); render(); }
41325
+ function deleteCamera(id) { if (!confirm('Delete this camera?')) return; topology.cameras = topology.cameras.filter(c => c.deviceId !== id); topology.connections = topology.connections.filter(c => c.fromCameraId !== id && c.toCameraId !== id); selectedItem = null; document.getElementById('properties-panel').innerHTML = '<h3>Properties</h3><p style="color: #666;">Select a camera or connection.</p>'; updateCameraSelects(); updateUI(); render(); }
40920
41326
  function deleteConnection(id) { if (!confirm('Delete this connection?')) return; topology.connections = topology.connections.filter(c => c.id !== id); selectedItem = null; document.getElementById('properties-panel').innerHTML = '<h3>Properties</h3><p style="color: #666;">Select a camera or connection.</p>'; updateUI(); render(); }
40921
41327
  function setTool(tool) {
40922
41328
  currentTool = tool;
@@ -42192,6 +42598,17 @@ module.exports = require("os");
42192
42598
 
42193
42599
  /***/ },
42194
42600
 
42601
+ /***/ "path"
42602
+ /*!***********************!*\
42603
+ !*** external "path" ***!
42604
+ \***********************/
42605
+ (module) {
42606
+
42607
+ "use strict";
42608
+ module.exports = require("path");
42609
+
42610
+ /***/ },
42611
+
42195
42612
  /***/ "stream"
42196
42613
  /*!*************************!*\
42197
42614
  !*** external "stream" ***!