@blueharford/scrypted-spatial-awareness 0.6.32 → 0.6.34

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.
package/out/plugin.zip CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blueharford/scrypted-spatial-awareness",
3
- "version": "0.6.32",
3
+ "version": "0.6.34",
4
4
  "description": "Cross-camera object tracking for Scrypted NVR with spatial awareness",
5
5
  "author": "Joshua Seidel <blueharford>",
6
6
  "license": "Apache-2.0",
@@ -24,7 +24,8 @@
24
24
  "scrypted-debug": "node node_modules/@scrypted/sdk/bin/scrypted-debug.js",
25
25
  "scrypted-deploy": "node node_modules/@scrypted/sdk/bin/scrypted-deploy.js",
26
26
  "scrypted-readme": "node node_modules/@scrypted/sdk/bin/scrypted-readme.js",
27
- "scrypted-package-json": "node node_modules/@scrypted/sdk/bin/scrypted-package-json.js"
27
+ "scrypted-package-json": "node node_modules/@scrypted/sdk/bin/scrypted-package-json.js",
28
+ "test": "node -r ts-node/register/transpile-only ./tests/run-tests.ts"
28
29
  },
29
30
  "keywords": [
30
31
  "scrypted",
@@ -54,6 +55,7 @@
54
55
  "devDependencies": {
55
56
  "@scrypted/common": "^1.0.0",
56
57
  "@types/node": "^20.11.0",
58
+ "ts-node": "^10.9.2",
57
59
  "typescript": "^5.3.0"
58
60
  }
59
61
  }
@@ -3,7 +3,7 @@
3
3
  * Generates and dispatches alerts based on tracking events
4
4
  */
5
5
 
6
- import sdk, { Notifier, Camera, ScryptedInterface, MediaObject } from '@scrypted/sdk';
6
+ import type { Notifier, Camera, MediaObject } from '@scrypted/sdk';
7
7
  import {
8
8
  Alert,
9
9
  AlertRule,
@@ -12,15 +12,31 @@ import {
12
12
  AlertCondition,
13
13
  createAlert,
14
14
  createDefaultRules,
15
+ generateAlertMessage,
15
16
  } from '../models/alert';
16
17
  import { TrackedObject, GlobalTrackingId } from '../models/tracked-object';
17
-
18
- const { systemManager, mediaManager } = sdk;
18
+ import {
19
+ getActiveAlertKey,
20
+ hasMeaningfulAlertChange,
21
+ shouldSendUpdateNotification,
22
+ } from './alert-utils';
23
+
24
+ let sdkModule: typeof import('@scrypted/sdk') | null = null;
25
+ const getSdk = async () => {
26
+ if (!sdkModule) {
27
+ sdkModule = await import('@scrypted/sdk');
28
+ }
29
+ return sdkModule;
30
+ };
19
31
 
20
32
  export class AlertManager {
21
33
  private rules: AlertRule[] = [];
22
34
  private recentAlerts: Alert[] = [];
23
35
  private cooldowns: Map<string, number> = new Map();
36
+ private activeAlerts: Map<string, { alert: Alert; lastUpdate: number; lastNotified: number }> = new Map();
37
+ private readonly activeAlertTtlMs: number = 10 * 60 * 1000;
38
+ private notifyOnUpdates: boolean = false;
39
+ private updateNotificationCooldownMs: number = 60000;
24
40
  private console: Console;
25
41
  private storage: Storage;
26
42
  private maxAlerts: number = 100;
@@ -37,7 +53,8 @@ export class AlertManager {
37
53
  async checkAndAlert(
38
54
  type: AlertType,
39
55
  tracked: TrackedObject,
40
- details: Partial<AlertDetails>
56
+ details: Partial<AlertDetails>,
57
+ mediaObjectOverride?: MediaObject
41
58
  ): Promise<Alert | null> {
42
59
  // Find matching rule
43
60
  const rule = this.rules.find(r => r.type === type && r.enabled);
@@ -57,15 +74,21 @@ export class AlertManager {
57
74
  }
58
75
  }
59
76
 
60
- // Check cooldown
61
- const cooldownKey = `${rule.id}:${tracked.globalId}`;
62
- const lastAlert = this.cooldowns.get(cooldownKey) || 0;
63
- if (rule.cooldown > 0 && Date.now() - lastAlert < rule.cooldown) {
77
+ // Check conditions
78
+ if (!this.evaluateConditions(rule.conditions, tracked)) {
64
79
  return null;
65
80
  }
66
81
 
67
- // Check conditions
68
- if (!this.evaluateConditions(rule.conditions, tracked)) {
82
+ // Update existing movement alert if active (prevents alert spam)
83
+ if (type === 'movement') {
84
+ const updated = await this.updateActiveAlert(type, rule.id, tracked, details);
85
+ if (updated) return updated;
86
+ }
87
+
88
+ // Check cooldown (only for new alerts)
89
+ const cooldownKey = `${rule.id}:${tracked.globalId}`;
90
+ const lastAlert = this.cooldowns.get(cooldownKey) || 0;
91
+ if (rule.cooldown > 0 && Date.now() - lastAlert < rule.cooldown) {
69
92
  return null;
70
93
  }
71
94
 
@@ -91,21 +114,59 @@ export class AlertManager {
91
114
  this.recentAlerts.pop();
92
115
  }
93
116
 
117
+ if (type === 'movement') {
118
+ const key = getActiveAlertKey(type, rule.id, tracked.globalId);
119
+ this.activeAlerts.set(key, { alert, lastUpdate: Date.now(), lastNotified: alert.timestamp });
120
+ }
121
+
94
122
  // Update cooldown
95
123
  this.cooldowns.set(cooldownKey, Date.now());
96
124
 
97
125
  // Send notifications
98
- await this.sendNotifications(alert, rule);
126
+ await this.sendNotifications(alert, rule, mediaObjectOverride);
99
127
 
100
128
  this.console.log(`Alert generated: [${alert.severity}] ${alert.message}`);
101
129
 
102
130
  return alert;
103
131
  }
104
132
 
133
+ async updateMovementAlert(
134
+ tracked: TrackedObject,
135
+ details: Partial<AlertDetails>
136
+ ): Promise<Alert | null> {
137
+ const rule = this.rules.find(r => r.type === 'movement' && r.enabled);
138
+ if (!rule) return null;
139
+
140
+ if (rule.objectClasses && rule.objectClasses.length > 0) {
141
+ if (!rule.objectClasses.includes(tracked.className)) {
142
+ return null;
143
+ }
144
+ }
145
+
146
+ if (rule.cameraIds && rule.cameraIds.length > 0 && details.cameraId) {
147
+ if (!rule.cameraIds.includes(details.cameraId)) {
148
+ return null;
149
+ }
150
+ }
151
+
152
+ if (!this.evaluateConditions(rule.conditions, tracked)) {
153
+ return null;
154
+ }
155
+
156
+ return this.updateActiveAlert('movement', rule.id, tracked, details);
157
+ }
158
+
105
159
  /**
106
160
  * Send notifications for an alert
107
161
  */
108
- private async sendNotifications(alert: Alert, rule: AlertRule): Promise<void> {
162
+ private async sendNotifications(
163
+ alert: Alert,
164
+ rule: AlertRule,
165
+ mediaObjectOverride?: MediaObject
166
+ ): Promise<void> {
167
+ const sdkModule = await getSdk();
168
+ const { ScryptedInterface } = sdkModule;
169
+ const { systemManager } = sdkModule.default;
109
170
  const notifierIds = rule.notifiers.length > 0
110
171
  ? rule.notifiers
111
172
  : this.getDefaultNotifiers();
@@ -119,16 +180,18 @@ export class AlertManager {
119
180
  }
120
181
 
121
182
  // Try to get a thumbnail from the camera
122
- let mediaObject: MediaObject | undefined;
123
- const cameraId = alert.details.toCameraId || alert.details.cameraId;
124
- if (cameraId) {
125
- try {
126
- const camera = systemManager.getDeviceById<Camera>(cameraId);
127
- if (camera && camera.interfaces?.includes(ScryptedInterface.Camera)) {
128
- mediaObject = await camera.takePicture();
183
+ let mediaObject: MediaObject | undefined = mediaObjectOverride;
184
+ if (!mediaObject) {
185
+ const cameraId = alert.details.toCameraId || alert.details.cameraId;
186
+ if (cameraId) {
187
+ try {
188
+ const camera = systemManager.getDeviceById<Camera>(cameraId);
189
+ if (camera && camera.interfaces?.includes(ScryptedInterface.Camera)) {
190
+ mediaObject = await camera.takePicture();
191
+ }
192
+ } catch (e) {
193
+ this.console.warn(`Failed to get thumbnail from camera ${cameraId}:`, e);
129
194
  }
130
- } catch (e) {
131
- this.console.warn(`Failed to get thumbnail from camera ${cameraId}:`, e);
132
195
  }
133
196
  }
134
197
 
@@ -161,6 +224,71 @@ export class AlertManager {
161
224
  }
162
225
  }
163
226
 
227
+ clearActiveAlertsForObject(globalId: GlobalTrackingId): void {
228
+ for (const [key, entry] of this.activeAlerts.entries()) {
229
+ if (entry.alert.trackedObjectId === globalId) {
230
+ this.activeAlerts.delete(key);
231
+ }
232
+ }
233
+ }
234
+
235
+ setUpdateNotificationOptions(enabled: boolean, cooldownMs: number): void {
236
+ this.notifyOnUpdates = enabled;
237
+ this.updateNotificationCooldownMs = Math.max(0, cooldownMs);
238
+ }
239
+
240
+ private async updateActiveAlert(
241
+ type: AlertType,
242
+ ruleId: string,
243
+ tracked: TrackedObject,
244
+ details: Partial<AlertDetails>
245
+ ): Promise<Alert | null> {
246
+ const key = getActiveAlertKey(type, ruleId, tracked.globalId);
247
+ const existing = this.activeAlerts.get(key);
248
+ if (!existing) return null;
249
+
250
+ const now = Date.now();
251
+ if (now - existing.lastUpdate > this.activeAlertTtlMs) {
252
+ this.activeAlerts.delete(key);
253
+ return null;
254
+ }
255
+
256
+ const updatedDetails: AlertDetails = {
257
+ ...existing.alert.details,
258
+ ...details,
259
+ objectClass: tracked.className,
260
+ objectLabel: details.objectLabel || tracked.label,
261
+ };
262
+
263
+ const shouldUpdate = hasMeaningfulAlertChange(existing.alert.details, updatedDetails);
264
+ if (!shouldUpdate) return existing.alert;
265
+
266
+ existing.alert.details = updatedDetails;
267
+ existing.alert.message = generateAlertMessage(type, updatedDetails);
268
+ existing.alert.timestamp = now;
269
+ existing.lastUpdate = now;
270
+
271
+ const idx = this.recentAlerts.findIndex(a => a.id === existing.alert.id);
272
+ if (idx >= 0) {
273
+ this.recentAlerts.splice(idx, 1);
274
+ }
275
+ this.recentAlerts.unshift(existing.alert);
276
+ if (this.recentAlerts.length > this.maxAlerts) {
277
+ this.recentAlerts.pop();
278
+ }
279
+
280
+ if (this.notifyOnUpdates) {
281
+ const rule = this.rules.find(r => r.id === ruleId);
282
+ if (rule && shouldSendUpdateNotification(this.notifyOnUpdates, existing.lastNotified, now, this.updateNotificationCooldownMs)) {
283
+ existing.lastNotified = now;
284
+ await this.sendNotifications(existing.alert, rule);
285
+ }
286
+ }
287
+
288
+ return existing.alert;
289
+ }
290
+
291
+
164
292
  /**
165
293
  * Get notification title based on alert type
166
294
  * For movement alerts with LLM descriptions, use the smart description as title
@@ -0,0 +1,32 @@
1
+ import { AlertDetails, AlertType } from '../models/alert';
2
+ import { GlobalTrackingId } from '../models/tracked-object';
3
+
4
+ export function hasMeaningfulAlertChange(prev: AlertDetails, next: AlertDetails): boolean {
5
+ return (
6
+ prev.fromCameraId !== next.fromCameraId ||
7
+ prev.toCameraId !== next.toCameraId ||
8
+ prev.cameraId !== next.cameraId ||
9
+ prev.objectLabel !== next.objectLabel ||
10
+ prev.pathDescription !== next.pathDescription ||
11
+ JSON.stringify(prev.involvedLandmarks || []) !== JSON.stringify(next.involvedLandmarks || [])
12
+ );
13
+ }
14
+
15
+ export function getActiveAlertKey(
16
+ type: AlertType,
17
+ ruleId: string,
18
+ trackedId: GlobalTrackingId
19
+ ): string {
20
+ return `${type}:${ruleId}:${trackedId}`;
21
+ }
22
+
23
+ export function shouldSendUpdateNotification(
24
+ enabled: boolean,
25
+ lastNotified: number,
26
+ now: number,
27
+ cooldownMs: number
28
+ ): boolean {
29
+ if (!enabled) return false;
30
+ if (cooldownMs <= 0) return true;
31
+ return now - lastNotified >= cooldownMs;
32
+ }
@@ -231,6 +231,13 @@ export class TopologyDiscoveryEngine {
231
231
  return { ...this.status };
232
232
  }
233
233
 
234
+ /** Get list of LLMs excluded for lack of vision support */
235
+ getExcludedVisionLlmNames(): string[] {
236
+ return this.llmDevices
237
+ .filter(l => !l.visionCapable)
238
+ .map(l => l.name || l.id);
239
+ }
240
+
234
241
  /** Get pending suggestions */
235
242
  getPendingSuggestions(): DiscoverySuggestion[] {
236
243
  return Array.from(this.suggestions.values())
@@ -262,6 +269,7 @@ export class TopologyDiscoveryEngine {
262
269
  providerType: LlmProvider;
263
270
  lastUsed: number;
264
271
  errorCount: number;
272
+ visionCapable: boolean;
265
273
  }> = [];
266
274
 
267
275
  /** Find ALL LLM devices for load balancing */
@@ -294,6 +302,7 @@ export class TopologyDiscoveryEngine {
294
302
  providerType,
295
303
  lastUsed: 0,
296
304
  errorCount: 0,
305
+ visionCapable: true,
297
306
  });
298
307
 
299
308
  this.console.log(`[Discovery] Found LLM: ${device.name}`);
@@ -348,6 +357,48 @@ export class TopologyDiscoveryEngine {
348
357
  return selected.device;
349
358
  }
350
359
 
360
+ /** Select an LLM device, excluding any IDs if provided */
361
+ private async selectLlmDevice(excludeIds: Set<string>): Promise<ChatCompletionDevice | null> {
362
+ await this.findAllLlmDevices();
363
+
364
+ if (this.llmDevices.length === 0) return null;
365
+
366
+ let bestIndex = -1;
367
+ let bestScore = Infinity;
368
+
369
+ for (let i = 0; i < this.llmDevices.length; i++) {
370
+ const llm = this.llmDevices[i];
371
+ if (excludeIds.has(llm.id)) continue;
372
+ if (!llm.visionCapable) continue;
373
+ const score = llm.lastUsed + (llm.errorCount * 60000);
374
+ if (score < bestScore) {
375
+ bestScore = score;
376
+ bestIndex = i;
377
+ }
378
+ }
379
+
380
+ if (bestIndex === -1) return null;
381
+
382
+ const selected = this.llmDevices[bestIndex];
383
+ this.llmDevice = selected.device;
384
+ this.llmProviderType = selected.providerType;
385
+ selected.lastUsed = Date.now();
386
+
387
+ this.console.log(`[Discovery] Selected LLM: ${selected.name}`);
388
+ return selected.device;
389
+ }
390
+
391
+ private isRetryableLlmError(error: any): boolean {
392
+ const errorStr = String(error).toLowerCase();
393
+ return (
394
+ errorStr.includes('404') ||
395
+ errorStr.includes('not found') ||
396
+ errorStr.includes('no such model') ||
397
+ errorStr.includes('model not found') ||
398
+ errorStr.includes('endpoint')
399
+ );
400
+ }
401
+
351
402
  /** Mark an LLM as having an error */
352
403
  private markLlmError(device: ChatCompletionDevice): void {
353
404
  const llm = this.llmDevices.find(l => l.device === device);
@@ -406,42 +457,49 @@ export class TopologyDiscoveryEngine {
406
457
  isValid: false,
407
458
  };
408
459
 
409
- const llm = await this.findLlmDevice();
410
- if (!llm?.getChatCompletion) {
411
- analysis.error = 'No LLM device available';
412
- return analysis;
413
- }
414
-
415
460
  const imageData = await this.getCameraSnapshot(cameraId);
416
461
  if (!imageData) {
417
462
  analysis.error = 'Failed to capture camera snapshot';
418
463
  return analysis;
419
464
  }
420
465
 
421
- // Try with detected provider format first, then fallback to alternates
422
- // The order matters: try the most likely formats first
423
- const formatsToTry: LlmProvider[] = [];
424
-
425
- // Start with detected format
426
- formatsToTry.push(this.llmProviderType);
427
-
428
- // Add fallbacks based on detected provider
429
- if (this.llmProviderType === 'openai') {
430
- formatsToTry.push('scrypted', 'anthropic');
431
- } else if (this.llmProviderType === 'anthropic') {
432
- formatsToTry.push('scrypted', 'openai');
433
- } else if (this.llmProviderType === 'scrypted') {
434
- formatsToTry.push('anthropic', 'openai');
435
- } else {
436
- // Unknown - try all formats
437
- formatsToTry.push('scrypted', 'anthropic', 'openai');
438
- }
439
-
466
+ await this.findAllLlmDevices();
467
+ const excludeIds = new Set<string>();
440
468
  let lastError: any = null;
469
+ const maxAttempts = Math.max(1, this.llmDevices.length || 1);
441
470
 
442
- for (const formatType of formatsToTry) {
443
- try {
444
- this.console.log(`[Discovery] Trying ${formatType} image format for ${cameraName}...`);
471
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
472
+ const llm = await this.selectLlmDevice(excludeIds);
473
+ if (!llm?.getChatCompletion) {
474
+ analysis.error = 'No LLM device available';
475
+ return analysis;
476
+ }
477
+
478
+ let allFormatsVisionError = false;
479
+
480
+ // Try with detected provider format first, then fallback to alternates
481
+ // The order matters: try the most likely formats first
482
+ const formatsToTry: LlmProvider[] = [];
483
+
484
+ // Start with detected format
485
+ formatsToTry.push(this.llmProviderType);
486
+
487
+ // Add fallbacks based on detected provider
488
+ if (this.llmProviderType === 'openai') {
489
+ formatsToTry.push('scrypted', 'anthropic');
490
+ } else if (this.llmProviderType === 'anthropic') {
491
+ formatsToTry.push('scrypted', 'openai');
492
+ } else if (this.llmProviderType === 'scrypted') {
493
+ formatsToTry.push('anthropic', 'openai');
494
+ } else {
495
+ // Unknown - try all formats
496
+ formatsToTry.push('scrypted', 'anthropic', 'openai');
497
+ }
498
+
499
+ let visionFormatFailures = 0;
500
+ for (const formatType of formatsToTry) {
501
+ try {
502
+ this.console.log(`[Discovery] Trying ${formatType} image format for ${cameraName}...`);
445
503
 
446
504
  // Build prompt with camera context (height)
447
505
  const cameraNode = this.topology ? findCamera(this.topology, cameraId) : null;
@@ -459,7 +517,7 @@ Use the mount height to help estimate distances - objects at ground level will a
459
517
  `;
460
518
 
461
519
  // Build multimodal message with provider-specific image format
462
- const result = await llm.getChatCompletion({
520
+ const result = await llm.getChatCompletion({
463
521
  messages: [
464
522
  {
465
523
  role: 'user',
@@ -473,91 +531,119 @@ Use the mount height to help estimate distances - objects at ground level will a
473
531
  temperature: 0.3,
474
532
  });
475
533
 
476
- const content = result?.choices?.[0]?.message?.content;
477
- if (content && typeof content === 'string') {
478
- try {
479
- // Extract JSON from response (handle markdown code blocks)
480
- let jsonStr = content.trim();
481
- if (jsonStr.startsWith('```')) {
482
- jsonStr = jsonStr.replace(/```json?\n?/g, '').replace(/```$/g, '').trim();
483
- }
534
+ const content = result?.choices?.[0]?.message?.content;
535
+ if (content && typeof content === 'string') {
536
+ try {
537
+ // Extract JSON from response (handle markdown code blocks)
538
+ let jsonStr = content.trim();
539
+ if (jsonStr.startsWith('```')) {
540
+ jsonStr = jsonStr.replace(/```json?\n?/g, '').replace(/```$/g, '').trim();
541
+ }
484
542
 
485
- // Try to recover truncated JSON
486
- const parsed = this.parseJsonWithRecovery(jsonStr, cameraName);
543
+ // Try to recover truncated JSON
544
+ const parsed = this.parseJsonWithRecovery(jsonStr, cameraName);
545
+
546
+ // Map parsed data to our types
547
+ if (Array.isArray(parsed.landmarks)) {
548
+ analysis.landmarks = parsed.landmarks.map((l: any) => ({
549
+ name: l.name || 'Unknown',
550
+ type: this.mapLandmarkType(l.type),
551
+ confidence: typeof l.confidence === 'number' ? l.confidence : 0.7,
552
+ distance: this.mapDistance(l.distance),
553
+ description: l.description || '',
554
+ boundingBox: l.boundingBox,
555
+ }));
556
+ }
487
557
 
488
- // Map parsed data to our types
489
- if (Array.isArray(parsed.landmarks)) {
490
- analysis.landmarks = parsed.landmarks.map((l: any) => ({
491
- name: l.name || 'Unknown',
492
- type: this.mapLandmarkType(l.type),
493
- confidence: typeof l.confidence === 'number' ? l.confidence : 0.7,
494
- distance: this.mapDistance(l.distance),
495
- description: l.description || '',
496
- boundingBox: l.boundingBox,
497
- }));
498
- }
558
+ if (Array.isArray(parsed.zones)) {
559
+ analysis.zones = parsed.zones.map((z: any) => ({
560
+ name: z.name || 'Unknown',
561
+ type: this.mapZoneType(z.type),
562
+ coverage: typeof z.coverage === 'number' ? z.coverage : 0.5,
563
+ description: z.description || '',
564
+ boundingBox: z.boundingBox,
565
+ distance: this.mapDistance(z.distance), // Parse distance for zones too
566
+ } as DiscoveredZone & { distance?: DistanceEstimate }));
567
+ }
499
568
 
500
- if (Array.isArray(parsed.zones)) {
501
- analysis.zones = parsed.zones.map((z: any) => ({
502
- name: z.name || 'Unknown',
503
- type: this.mapZoneType(z.type),
504
- coverage: typeof z.coverage === 'number' ? z.coverage : 0.5,
505
- description: z.description || '',
506
- boundingBox: z.boundingBox,
507
- distance: this.mapDistance(z.distance), // Parse distance for zones too
508
- } as DiscoveredZone & { distance?: DistanceEstimate }));
509
- }
569
+ if (parsed.edges && typeof parsed.edges === 'object') {
570
+ analysis.edges = {
571
+ top: parsed.edges.top || '',
572
+ left: parsed.edges.left || '',
573
+ right: parsed.edges.right || '',
574
+ bottom: parsed.edges.bottom || '',
575
+ };
576
+ }
510
577
 
511
- if (parsed.edges && typeof parsed.edges === 'object') {
512
- analysis.edges = {
513
- top: parsed.edges.top || '',
514
- left: parsed.edges.left || '',
515
- right: parsed.edges.right || '',
516
- bottom: parsed.edges.bottom || '',
517
- };
518
- }
578
+ if (parsed.orientation) {
579
+ analysis.orientation = this.mapOrientation(parsed.orientation);
580
+ }
519
581
 
520
- if (parsed.orientation) {
521
- analysis.orientation = this.mapOrientation(parsed.orientation);
522
- }
582
+ analysis.isValid = true;
583
+ this.console.log(`[Discovery] Analyzed ${cameraName}: ${analysis.landmarks.length} landmarks, ${analysis.zones.length} zones (using ${formatType} format)`);
523
584
 
524
- analysis.isValid = true;
525
- this.console.log(`[Discovery] Analyzed ${cameraName}: ${analysis.landmarks.length} landmarks, ${analysis.zones.length} zones (using ${formatType} format)`);
585
+ // Update the preferred format for future requests
586
+ if (formatType !== this.llmProviderType) {
587
+ this.console.log(`[Discovery] Switching to ${formatType} format for future requests`);
588
+ this.llmProviderType = formatType;
589
+ }
526
590
 
527
- // Update the preferred format for future requests
528
- if (formatType !== this.llmProviderType) {
529
- this.console.log(`[Discovery] Switching to ${formatType} format for future requests`);
530
- this.llmProviderType = formatType;
591
+ // Success - exit the retry loop
592
+ return analysis;
593
+ } catch (parseError) {
594
+ this.console.warn(`[Discovery] Failed to parse LLM response for ${cameraName}:`, parseError);
595
+ analysis.error = 'Failed to parse LLM response';
596
+ return analysis;
531
597
  }
598
+ }
599
+ } catch (e) {
600
+ lastError = e;
601
+
602
+ // Check if this is a vision/multimodal format error
603
+ if (isVisionFormatError(e)) {
604
+ this.console.warn(`[Discovery] ${formatType} format failed, trying fallback...`);
605
+ visionFormatFailures++;
606
+ continue; // Try next format
607
+ }
532
608
 
533
- // Success - exit the retry loop
534
- return analysis;
535
- } catch (parseError) {
536
- this.console.warn(`[Discovery] Failed to parse LLM response for ${cameraName}:`, parseError);
537
- analysis.error = 'Failed to parse LLM response';
538
- return analysis;
609
+ // Retry with a different LLM if error indicates bad endpoint/model
610
+ if (this.isRetryableLlmError(e)) {
611
+ this.console.warn(`[Discovery] LLM error for ${cameraName}, trying another provider...`);
612
+ this.markLlmError(llm);
613
+ const llmEntry = this.llmDevices.find(d => d.device === llm);
614
+ if (llmEntry) {
615
+ excludeIds.add(llmEntry.id);
616
+ }
617
+ break;
539
618
  }
540
- }
541
- } catch (e) {
542
- lastError = e;
543
619
 
544
- // Check if this is a vision/multimodal format error
545
- if (isVisionFormatError(e)) {
546
- this.console.warn(`[Discovery] ${formatType} format failed, trying fallback...`);
547
- continue; // Try next format
620
+ // Not a format error - don't retry
621
+ this.console.warn(`[Discovery] Scene analysis failed for ${cameraName}:`, e);
622
+ break;
548
623
  }
624
+ }
549
625
 
550
- // Not a format error - don't retry
551
- this.console.warn(`[Discovery] Scene analysis failed for ${cameraName}:`, e);
552
- break;
626
+ allFormatsVisionError = visionFormatFailures > 0 && visionFormatFailures === formatsToTry.length;
627
+ if (allFormatsVisionError) {
628
+ const llmEntry = this.llmDevices.find(d => d.device === llm);
629
+ if (llmEntry) {
630
+ llmEntry.visionCapable = false;
631
+ excludeIds.add(llmEntry.id);
632
+ this.console.warn(`[Discovery] ${llmEntry.name} does not support vision. Excluding from discovery.`);
633
+ }
553
634
  }
554
635
  }
555
636
 
556
637
  // All formats failed
557
638
  if (lastError) {
558
639
  // Track error for load balancing
559
- if (llm) {
560
- this.markLlmError(llm);
640
+ // Note: llm may be null here if no device was available
641
+ if (lastError && !this.isRetryableLlmError(lastError)) {
642
+ // Best-effort error accounting for the most recent device
643
+ const lastDevice = this.llmDevice;
644
+ if (lastDevice) {
645
+ this.markLlmError(lastDevice);
646
+ }
561
647
  }
562
648
 
563
649
  const errorStr = String(lastError);