@blueharford/scrypted-spatial-awareness 0.4.7 → 0.5.0-beta

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.
@@ -0,0 +1,641 @@
1
+ /**
2
+ * Topology Discovery Engine
3
+ * Uses vision LLM to analyze camera snapshots and discover topology elements
4
+ */
5
+
6
+ import sdk, {
7
+ ScryptedInterface,
8
+ Camera,
9
+ MediaObject,
10
+ ScryptedDevice,
11
+ } from '@scrypted/sdk';
12
+ import {
13
+ DiscoveryConfig,
14
+ DEFAULT_DISCOVERY_CONFIG,
15
+ SceneAnalysis,
16
+ DiscoveredLandmark,
17
+ DiscoveredZone,
18
+ EdgeAnalysis,
19
+ TopologyCorrelation,
20
+ SharedLandmark,
21
+ SuggestedConnection,
22
+ DiscoverySuggestion,
23
+ DiscoveryStatus,
24
+ DEFAULT_DISCOVERY_STATUS,
25
+ RATE_LIMIT_WARNING_THRESHOLD,
26
+ } from '../models/discovery';
27
+ import {
28
+ CameraTopology,
29
+ CameraNode,
30
+ Landmark,
31
+ findCamera,
32
+ } from '../models/topology';
33
+ import { mediaObjectToBase64 } from './spatial-reasoning';
34
+
35
+ const { systemManager } = sdk;
36
+
37
+ /** Interface for ChatCompletion devices */
38
+ interface ChatCompletionDevice extends ScryptedDevice {
39
+ getChatCompletion?(params: any): Promise<any>;
40
+ }
41
+
42
+ /** Scene analysis prompt for single camera */
43
+ const SCENE_ANALYSIS_PROMPT = `Analyze this security camera image and identify what you see.
44
+
45
+ 1. LANDMARKS - Identify fixed features visible:
46
+ - Structures (house, garage, shed, porch, deck)
47
+ - Features (mailbox, tree, pool, garden, fountain)
48
+ - Access points (door, gate, driveway entrance, walkway)
49
+ - Boundaries (fence, wall, hedge)
50
+
51
+ 2. ZONES - Identify area types visible:
52
+ - What type of area is this? (front yard, backyard, driveway, street, patio, walkway)
53
+ - Estimate what percentage of the frame each zone covers (0.0 to 1.0)
54
+
55
+ 3. EDGES - What's visible at the frame edges:
56
+ - Top edge: (sky, roof, trees, etc.)
57
+ - Left edge: (fence, neighbor, street, etc.)
58
+ - Right edge: (fence, garage, etc.)
59
+ - Bottom edge: (ground, driveway, grass, etc.)
60
+
61
+ 4. ORIENTATION - Estimate camera facing direction based on shadows, sun position, or landmarks
62
+
63
+ Respond with ONLY valid JSON in this exact format:
64
+ {
65
+ "landmarks": [
66
+ {"name": "Front Door", "type": "access", "confidence": 0.9, "description": "White front door with black frame"}
67
+ ],
68
+ "zones": [
69
+ {"name": "Front Yard", "type": "yard", "coverage": 0.4, "description": "Grass lawn area"}
70
+ ],
71
+ "edges": {"top": "sky with clouds", "left": "fence and trees", "right": "garage wall", "bottom": "concrete walkway"},
72
+ "orientation": "north"
73
+ }`;
74
+
75
+ /** Multi-camera correlation prompt */
76
+ const CORRELATION_PROMPT = `I have scene analyses from multiple security cameras at the same property. Help me correlate them to understand the property layout.
77
+
78
+ CAMERA SCENES:
79
+ {scenes}
80
+
81
+ Identify:
82
+ 1. Shared landmarks - Features that appear in multiple camera views
83
+ 2. Camera connections - How someone could move between camera views and estimated walking time
84
+ 3. Overall layout - Describe the property layout based on what you see
85
+
86
+ Respond with ONLY valid JSON:
87
+ {
88
+ "sharedLandmarks": [
89
+ {"name": "Driveway", "type": "access", "seenByCameras": ["camera1", "camera2"], "confidence": 0.8, "description": "Concrete driveway"}
90
+ ],
91
+ "connections": [
92
+ {"from": "camera1", "to": "camera2", "transitSeconds": 10, "via": "driveway", "confidence": 0.7, "bidirectional": true}
93
+ ],
94
+ "layoutDescription": "Single-story house with front yard facing street, driveway on the left side, backyard accessible through side gate"
95
+ }`;
96
+
97
+ export class TopologyDiscoveryEngine {
98
+ private config: DiscoveryConfig;
99
+ private console: Console;
100
+ private topology: CameraTopology | null = null;
101
+ private llmDevice: ChatCompletionDevice | null = null;
102
+ private llmSearched: boolean = false;
103
+
104
+ // Scene analysis cache (camera ID -> analysis)
105
+ private sceneCache: Map<string, SceneAnalysis> = new Map();
106
+
107
+ // Pending suggestions for user review
108
+ private suggestions: Map<string, DiscoverySuggestion> = new Map();
109
+
110
+ // Discovery status
111
+ private status: DiscoveryStatus = { ...DEFAULT_DISCOVERY_STATUS };
112
+
113
+ // Periodic discovery timer
114
+ private discoveryTimer: NodeJS.Timeout | null = null;
115
+
116
+ constructor(config: Partial<DiscoveryConfig>, console: Console) {
117
+ this.config = { ...DEFAULT_DISCOVERY_CONFIG, ...config };
118
+ this.console = console;
119
+ }
120
+
121
+ /** Update configuration */
122
+ updateConfig(config: Partial<DiscoveryConfig>): void {
123
+ this.config = { ...this.config, ...config };
124
+
125
+ // Restart periodic discovery if config changed
126
+ if (this.status.isRunning) {
127
+ this.stopPeriodicDiscovery();
128
+ if (this.config.discoveryIntervalHours > 0) {
129
+ this.startPeriodicDiscovery();
130
+ }
131
+ }
132
+ }
133
+
134
+ /** Update topology reference */
135
+ updateTopology(topology: CameraTopology): void {
136
+ this.topology = topology;
137
+ }
138
+
139
+ /** Get current status */
140
+ getStatus(): DiscoveryStatus {
141
+ return { ...this.status };
142
+ }
143
+
144
+ /** Get pending suggestions */
145
+ getPendingSuggestions(): DiscoverySuggestion[] {
146
+ return Array.from(this.suggestions.values())
147
+ .filter(s => s.status === 'pending')
148
+ .sort((a, b) => b.confidence - a.confidence);
149
+ }
150
+
151
+ /** Get cached scene analysis for a camera */
152
+ getSceneAnalysis(cameraId: string): SceneAnalysis | null {
153
+ return this.sceneCache.get(cameraId) || null;
154
+ }
155
+
156
+ /** Check if rate limit warning should be shown */
157
+ shouldShowRateLimitWarning(): boolean {
158
+ return this.config.discoveryIntervalHours > 0 &&
159
+ this.config.discoveryIntervalHours < RATE_LIMIT_WARNING_THRESHOLD;
160
+ }
161
+
162
+ /** Check if discovery is enabled */
163
+ isEnabled(): boolean {
164
+ return this.config.discoveryIntervalHours > 0;
165
+ }
166
+
167
+ /** Find LLM device with ChatCompletion interface */
168
+ private async findLlmDevice(): Promise<ChatCompletionDevice | null> {
169
+ if (this.llmDevice) return this.llmDevice;
170
+ if (this.llmSearched) return null;
171
+
172
+ this.llmSearched = true;
173
+
174
+ try {
175
+ for (const id of Object.keys(systemManager.getSystemState())) {
176
+ const device = systemManager.getDeviceById(id);
177
+ if (!device) continue;
178
+
179
+ if (device.interfaces?.includes('ChatCompletion')) {
180
+ this.llmDevice = device as unknown as ChatCompletionDevice;
181
+ this.console.log(`[Discovery] Connected to LLM: ${device.name}`);
182
+ return this.llmDevice;
183
+ }
184
+ }
185
+
186
+ this.console.warn('[Discovery] No ChatCompletion device found. Vision-based discovery unavailable.');
187
+ } catch (e) {
188
+ this.console.error('[Discovery] Error finding LLM device:', e);
189
+ }
190
+
191
+ return null;
192
+ }
193
+
194
+ /** Get camera snapshot as base64 */
195
+ private async getCameraSnapshot(cameraId: string): Promise<string | null> {
196
+ try {
197
+ const camera = systemManager.getDeviceById<Camera>(cameraId);
198
+ if (!camera?.interfaces?.includes(ScryptedInterface.Camera)) {
199
+ return null;
200
+ }
201
+
202
+ const mediaObject = await camera.takePicture();
203
+ return mediaObjectToBase64(mediaObject);
204
+ } catch (e) {
205
+ this.console.warn(`[Discovery] Failed to get snapshot from camera ${cameraId}:`, e);
206
+ return null;
207
+ }
208
+ }
209
+
210
+ /** Analyze a single camera's scene */
211
+ async analyzeScene(cameraId: string): Promise<SceneAnalysis> {
212
+ const camera = this.topology ? findCamera(this.topology, cameraId) : null;
213
+ const cameraName = camera?.name || cameraId;
214
+
215
+ const analysis: SceneAnalysis = {
216
+ cameraId,
217
+ cameraName,
218
+ timestamp: Date.now(),
219
+ landmarks: [],
220
+ zones: [],
221
+ edges: { top: '', left: '', right: '', bottom: '' },
222
+ orientation: 'unknown',
223
+ potentialOverlaps: [],
224
+ isValid: false,
225
+ };
226
+
227
+ const llm = await this.findLlmDevice();
228
+ if (!llm?.getChatCompletion) {
229
+ analysis.error = 'No LLM device available';
230
+ return analysis;
231
+ }
232
+
233
+ const imageBase64 = await this.getCameraSnapshot(cameraId);
234
+ if (!imageBase64) {
235
+ analysis.error = 'Failed to capture camera snapshot';
236
+ return analysis;
237
+ }
238
+
239
+ try {
240
+ // Build multimodal message
241
+ const result = await llm.getChatCompletion({
242
+ messages: [
243
+ {
244
+ role: 'user',
245
+ content: [
246
+ { type: 'text', text: SCENE_ANALYSIS_PROMPT },
247
+ { type: 'image_url', image_url: { url: imageBase64 } },
248
+ ],
249
+ },
250
+ ],
251
+ max_tokens: 500,
252
+ temperature: 0.3,
253
+ });
254
+
255
+ const content = result?.choices?.[0]?.message?.content;
256
+ if (content && typeof content === 'string') {
257
+ try {
258
+ // Extract JSON from response (handle markdown code blocks)
259
+ let jsonStr = content.trim();
260
+ if (jsonStr.startsWith('```')) {
261
+ jsonStr = jsonStr.replace(/```json?\n?/g, '').replace(/```$/g, '').trim();
262
+ }
263
+
264
+ const parsed = JSON.parse(jsonStr);
265
+
266
+ // Map parsed data to our types
267
+ if (Array.isArray(parsed.landmarks)) {
268
+ analysis.landmarks = parsed.landmarks.map((l: any) => ({
269
+ name: l.name || 'Unknown',
270
+ type: this.mapLandmarkType(l.type),
271
+ confidence: typeof l.confidence === 'number' ? l.confidence : 0.7,
272
+ description: l.description || '',
273
+ boundingBox: l.boundingBox,
274
+ }));
275
+ }
276
+
277
+ if (Array.isArray(parsed.zones)) {
278
+ analysis.zones = parsed.zones.map((z: any) => ({
279
+ name: z.name || 'Unknown',
280
+ type: this.mapZoneType(z.type),
281
+ coverage: typeof z.coverage === 'number' ? z.coverage : 0.5,
282
+ description: z.description || '',
283
+ boundingBox: z.boundingBox,
284
+ }));
285
+ }
286
+
287
+ if (parsed.edges && typeof parsed.edges === 'object') {
288
+ analysis.edges = {
289
+ top: parsed.edges.top || '',
290
+ left: parsed.edges.left || '',
291
+ right: parsed.edges.right || '',
292
+ bottom: parsed.edges.bottom || '',
293
+ };
294
+ }
295
+
296
+ if (parsed.orientation) {
297
+ analysis.orientation = this.mapOrientation(parsed.orientation);
298
+ }
299
+
300
+ analysis.isValid = true;
301
+ this.console.log(`[Discovery] Analyzed ${cameraName}: ${analysis.landmarks.length} landmarks, ${analysis.zones.length} zones`);
302
+ } catch (parseError) {
303
+ this.console.warn(`[Discovery] Failed to parse LLM response for ${cameraName}:`, parseError);
304
+ analysis.error = 'Failed to parse LLM response';
305
+ }
306
+ }
307
+ } catch (e) {
308
+ this.console.warn(`[Discovery] Scene analysis failed for ${cameraName}:`, e);
309
+ analysis.error = `Analysis failed: ${e}`;
310
+ }
311
+
312
+ // Cache the analysis
313
+ this.sceneCache.set(cameraId, analysis);
314
+
315
+ return analysis;
316
+ }
317
+
318
+ /** Map LLM landmark type to our type */
319
+ private mapLandmarkType(type: string): import('../models/topology').LandmarkType {
320
+ const typeMap: Record<string, import('../models/topology').LandmarkType> = {
321
+ structure: 'structure',
322
+ feature: 'feature',
323
+ boundary: 'boundary',
324
+ access: 'access',
325
+ vehicle: 'vehicle',
326
+ neighbor: 'neighbor',
327
+ zone: 'zone',
328
+ street: 'street',
329
+ };
330
+ return typeMap[type?.toLowerCase()] || 'feature';
331
+ }
332
+
333
+ /** Map LLM zone type to our type */
334
+ private mapZoneType(type: string): import('../models/discovery').DiscoveredZoneType {
335
+ const typeMap: Record<string, import('../models/discovery').DiscoveredZoneType> = {
336
+ yard: 'yard',
337
+ driveway: 'driveway',
338
+ street: 'street',
339
+ patio: 'patio',
340
+ deck: 'patio',
341
+ walkway: 'walkway',
342
+ parking: 'parking',
343
+ garden: 'garden',
344
+ pool: 'pool',
345
+ };
346
+ return typeMap[type?.toLowerCase()] || 'unknown';
347
+ }
348
+
349
+ /** Map LLM orientation to our type */
350
+ private mapOrientation(orientation: string): SceneAnalysis['orientation'] {
351
+ const dir = orientation?.toLowerCase();
352
+ if (dir?.includes('north') && dir?.includes('east')) return 'northeast';
353
+ if (dir?.includes('north') && dir?.includes('west')) return 'northwest';
354
+ if (dir?.includes('south') && dir?.includes('east')) return 'southeast';
355
+ if (dir?.includes('south') && dir?.includes('west')) return 'southwest';
356
+ if (dir?.includes('north')) return 'north';
357
+ if (dir?.includes('south')) return 'south';
358
+ if (dir?.includes('east')) return 'east';
359
+ if (dir?.includes('west')) return 'west';
360
+ return 'unknown';
361
+ }
362
+
363
+ /** Analyze all cameras and correlate findings */
364
+ async runFullDiscovery(): Promise<TopologyCorrelation | null> {
365
+ if (!this.topology?.cameras?.length) {
366
+ this.console.warn('[Discovery] No cameras in topology');
367
+ return null;
368
+ }
369
+
370
+ this.status.isScanning = true;
371
+ this.status.lastError = undefined;
372
+
373
+ try {
374
+ this.console.log(`[Discovery] Starting full discovery scan of ${this.topology.cameras.length} cameras`);
375
+
376
+ // Analyze each camera
377
+ const analyses: SceneAnalysis[] = [];
378
+ for (const camera of this.topology.cameras) {
379
+ const analysis = await this.analyzeScene(camera.deviceId);
380
+ if (analysis.isValid) {
381
+ analyses.push(analysis);
382
+ }
383
+ // Rate limit - wait 2 seconds between cameras
384
+ await new Promise(resolve => setTimeout(resolve, 2000));
385
+ }
386
+
387
+ this.status.camerasAnalyzed = analyses.length;
388
+ this.console.log(`[Discovery] Analyzed ${analyses.length} cameras successfully`);
389
+
390
+ // Correlate if we have multiple cameras
391
+ let correlation: TopologyCorrelation | null = null;
392
+ if (analyses.length >= 2) {
393
+ correlation = await this.correlateScenes(analyses);
394
+ if (correlation) {
395
+ this.generateSuggestionsFromCorrelation(correlation);
396
+ }
397
+ } else {
398
+ // Single camera - generate suggestions from its analysis
399
+ this.generateSuggestionsFromAnalysis(analyses[0]);
400
+ }
401
+
402
+ this.status.lastScanTime = Date.now();
403
+ this.status.pendingSuggestions = this.getPendingSuggestions().length;
404
+
405
+ return correlation;
406
+ } catch (e) {
407
+ this.console.error('[Discovery] Full discovery failed:', e);
408
+ this.status.lastError = `Discovery failed: ${e}`;
409
+ return null;
410
+ } finally {
411
+ this.status.isScanning = false;
412
+ }
413
+ }
414
+
415
+ /** Correlate scenes from multiple cameras */
416
+ private async correlateScenes(analyses: SceneAnalysis[]): Promise<TopologyCorrelation | null> {
417
+ const llm = await this.findLlmDevice();
418
+ if (!llm?.getChatCompletion) {
419
+ return null;
420
+ }
421
+
422
+ try {
423
+ // Build scenes description for prompt
424
+ const scenesText = analyses.map(a => {
425
+ const landmarkList = a.landmarks.map(l => `${l.name} (${l.type})`).join(', ');
426
+ const zoneList = a.zones.map(z => `${z.name} (${z.type}, ${Math.round(z.coverage * 100)}%)`).join(', ');
427
+ return `Camera "${a.cameraName}" (${a.cameraId}):
428
+ - Landmarks: ${landmarkList || 'None identified'}
429
+ - Zones: ${zoneList || 'None identified'}
430
+ - Edges: Top=${a.edges.top}, Left=${a.edges.left}, Right=${a.edges.right}, Bottom=${a.edges.bottom}
431
+ - Orientation: ${a.orientation}`;
432
+ }).join('\n\n');
433
+
434
+ const prompt = CORRELATION_PROMPT.replace('{scenes}', scenesText);
435
+
436
+ const result = await llm.getChatCompletion({
437
+ messages: [{ role: 'user', content: prompt }],
438
+ max_tokens: 800,
439
+ temperature: 0.4,
440
+ });
441
+
442
+ const content = result?.choices?.[0]?.message?.content;
443
+ if (content && typeof content === 'string') {
444
+ try {
445
+ let jsonStr = content.trim();
446
+ if (jsonStr.startsWith('```')) {
447
+ jsonStr = jsonStr.replace(/```json?\n?/g, '').replace(/```$/g, '').trim();
448
+ }
449
+
450
+ const parsed = JSON.parse(jsonStr);
451
+
452
+ const correlation: TopologyCorrelation = {
453
+ sharedLandmarks: [],
454
+ suggestedConnections: [],
455
+ layoutDescription: parsed.layoutDescription || '',
456
+ timestamp: Date.now(),
457
+ };
458
+
459
+ if (Array.isArray(parsed.sharedLandmarks)) {
460
+ correlation.sharedLandmarks = parsed.sharedLandmarks.map((l: any) => ({
461
+ name: l.name || 'Unknown',
462
+ type: this.mapLandmarkType(l.type),
463
+ seenByCameras: Array.isArray(l.seenByCameras) ? l.seenByCameras : [],
464
+ confidence: typeof l.confidence === 'number' ? l.confidence : 0.7,
465
+ description: l.description,
466
+ }));
467
+ }
468
+
469
+ if (Array.isArray(parsed.connections)) {
470
+ correlation.suggestedConnections = parsed.connections.map((c: any) => ({
471
+ fromCameraId: c.from || c.fromCameraId || '',
472
+ toCameraId: c.to || c.toCameraId || '',
473
+ transitSeconds: typeof c.transitSeconds === 'number' ? c.transitSeconds : 15,
474
+ via: c.via || '',
475
+ confidence: typeof c.confidence === 'number' ? c.confidence : 0.6,
476
+ bidirectional: c.bidirectional !== false,
477
+ }));
478
+ }
479
+
480
+ this.console.log(`[Discovery] Correlation found ${correlation.sharedLandmarks.length} shared landmarks, ${correlation.suggestedConnections.length} connections`);
481
+
482
+ return correlation;
483
+ } catch (parseError) {
484
+ this.console.warn('[Discovery] Failed to parse correlation response:', parseError);
485
+ }
486
+ }
487
+ } catch (e) {
488
+ this.console.warn('[Discovery] Correlation failed:', e);
489
+ }
490
+
491
+ return null;
492
+ }
493
+
494
+ /** Generate suggestions from a single camera analysis */
495
+ private generateSuggestionsFromAnalysis(analysis: SceneAnalysis): void {
496
+ if (!analysis.isValid) return;
497
+
498
+ // Generate landmark suggestions
499
+ for (const landmark of analysis.landmarks) {
500
+ if (landmark.confidence >= this.config.minLandmarkConfidence) {
501
+ const suggestion: DiscoverySuggestion = {
502
+ id: `landmark_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
503
+ type: 'landmark',
504
+ timestamp: Date.now(),
505
+ sourceCameras: [analysis.cameraId],
506
+ confidence: landmark.confidence,
507
+ status: 'pending',
508
+ landmark: {
509
+ name: landmark.name,
510
+ type: landmark.type,
511
+ description: landmark.description,
512
+ visibleFromCameras: [analysis.cameraId],
513
+ },
514
+ };
515
+ this.suggestions.set(suggestion.id, suggestion);
516
+ }
517
+ }
518
+
519
+ // Generate zone suggestions
520
+ for (const zone of analysis.zones) {
521
+ if (zone.coverage >= 0.2) {
522
+ const suggestion: DiscoverySuggestion = {
523
+ id: `zone_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
524
+ type: 'zone',
525
+ timestamp: Date.now(),
526
+ sourceCameras: [analysis.cameraId],
527
+ confidence: 0.7,
528
+ status: 'pending',
529
+ zone: zone,
530
+ };
531
+ this.suggestions.set(suggestion.id, suggestion);
532
+ }
533
+ }
534
+ }
535
+
536
+ /** Generate suggestions from multi-camera correlation */
537
+ private generateSuggestionsFromCorrelation(correlation: TopologyCorrelation): void {
538
+ // Generate landmark suggestions from shared landmarks
539
+ for (const shared of correlation.sharedLandmarks) {
540
+ const suggestion: DiscoverySuggestion = {
541
+ id: `shared_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
542
+ type: 'landmark',
543
+ timestamp: Date.now(),
544
+ sourceCameras: shared.seenByCameras,
545
+ confidence: shared.confidence,
546
+ status: 'pending',
547
+ landmark: {
548
+ name: shared.name,
549
+ type: shared.type,
550
+ description: shared.description,
551
+ visibleFromCameras: shared.seenByCameras,
552
+ },
553
+ };
554
+ this.suggestions.set(suggestion.id, suggestion);
555
+ }
556
+
557
+ // Generate connection suggestions
558
+ for (const conn of correlation.suggestedConnections) {
559
+ const suggestion: DiscoverySuggestion = {
560
+ id: `conn_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
561
+ type: 'connection',
562
+ timestamp: Date.now(),
563
+ sourceCameras: [conn.fromCameraId, conn.toCameraId],
564
+ confidence: conn.confidence,
565
+ status: 'pending',
566
+ connection: conn,
567
+ };
568
+ this.suggestions.set(suggestion.id, suggestion);
569
+ }
570
+ }
571
+
572
+ /** Accept a suggestion */
573
+ acceptSuggestion(suggestionId: string): DiscoverySuggestion | null {
574
+ const suggestion = this.suggestions.get(suggestionId);
575
+ if (!suggestion || suggestion.status !== 'pending') return null;
576
+
577
+ suggestion.status = 'accepted';
578
+ this.status.pendingSuggestions = this.getPendingSuggestions().length;
579
+
580
+ return suggestion;
581
+ }
582
+
583
+ /** Reject a suggestion */
584
+ rejectSuggestion(suggestionId: string): boolean {
585
+ const suggestion = this.suggestions.get(suggestionId);
586
+ if (!suggestion || suggestion.status !== 'pending') return false;
587
+
588
+ suggestion.status = 'rejected';
589
+ this.status.pendingSuggestions = this.getPendingSuggestions().length;
590
+
591
+ return true;
592
+ }
593
+
594
+ /** Start periodic discovery */
595
+ startPeriodicDiscovery(): void {
596
+ if (this.discoveryTimer) {
597
+ clearInterval(this.discoveryTimer);
598
+ }
599
+
600
+ if (this.config.discoveryIntervalHours <= 0) {
601
+ this.console.log('[Discovery] Periodic discovery disabled (interval = 0)');
602
+ return;
603
+ }
604
+
605
+ this.status.isRunning = true;
606
+ const intervalMs = this.config.discoveryIntervalHours * 60 * 60 * 1000;
607
+
608
+ this.console.log(`[Discovery] Starting periodic discovery every ${this.config.discoveryIntervalHours} hours`);
609
+
610
+ // Schedule next scan
611
+ this.status.nextScanTime = Date.now() + intervalMs;
612
+
613
+ this.discoveryTimer = setInterval(async () => {
614
+ if (!this.status.isScanning) {
615
+ await this.runFullDiscovery();
616
+ this.status.nextScanTime = Date.now() + intervalMs;
617
+ }
618
+ }, intervalMs);
619
+ }
620
+
621
+ /** Stop periodic discovery */
622
+ stopPeriodicDiscovery(): void {
623
+ if (this.discoveryTimer) {
624
+ clearInterval(this.discoveryTimer);
625
+ this.discoveryTimer = null;
626
+ }
627
+
628
+ this.status.isRunning = false;
629
+ this.status.nextScanTime = null;
630
+
631
+ this.console.log('[Discovery] Stopped periodic discovery');
632
+ }
633
+
634
+ /** Clear all cached data and suggestions */
635
+ clearCache(): void {
636
+ this.sceneCache.clear();
637
+ this.suggestions.clear();
638
+ this.status.pendingSuggestions = 0;
639
+ this.status.camerasAnalyzed = 0;
640
+ }
641
+ }