@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/CHANGELOG.md +23 -0
- package/README.md +7 -2
- package/dist/main.nodejs.js +1 -1
- package/dist/main.nodejs.js.map +1 -1
- package/dist/plugin.zip +0 -0
- package/out/main.nodejs.js +607 -229
- package/out/main.nodejs.js.map +1 -1
- package/out/plugin.zip +0 -0
- package/package.json +4 -2
- package/src/alerts/alert-manager.ts +149 -21
- package/src/alerts/alert-utils.ts +32 -0
- package/src/core/topology-discovery.ts +181 -95
- package/src/core/tracking-engine.ts +150 -44
- package/src/main.ts +110 -19
- package/src/models/tracked-object.ts +1 -1
- package/src/state/tracking-state.ts +18 -5
- package/tests/run-tests.ts +50 -0
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
61
|
-
|
|
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
|
-
//
|
|
68
|
-
if (
|
|
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(
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
422
|
-
|
|
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 (
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
486
|
-
|
|
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
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
-
|
|
521
|
-
analysis.
|
|
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
|
-
|
|
525
|
-
|
|
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
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
this.
|
|
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
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
this.
|
|
537
|
-
|
|
538
|
-
|
|
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
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|
-
|
|
560
|
-
|
|
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);
|