@blueharford/scrypted-spatial-awareness 0.1.14 → 0.1.16
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/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 +140 -16
- package/out/main.nodejs.js.map +1 -1
- package/out/plugin.zip +0 -0
- package/package.json +1 -1
- package/src/alerts/alert-manager.ts +18 -0
- package/src/core/tracking-engine.ts +122 -13
- package/src/main.ts +34 -3
package/out/plugin.zip
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -186,6 +186,24 @@ export class AlertManager {
|
|
|
186
186
|
*/
|
|
187
187
|
private getDefaultNotifiers(): string[] {
|
|
188
188
|
try {
|
|
189
|
+
// Try new multiple notifiers setting first
|
|
190
|
+
const notifiers = this.storage.getItem('defaultNotifiers');
|
|
191
|
+
if (notifiers) {
|
|
192
|
+
// Could be JSON array or comma-separated string
|
|
193
|
+
try {
|
|
194
|
+
const parsed = JSON.parse(notifiers);
|
|
195
|
+
if (Array.isArray(parsed)) {
|
|
196
|
+
return parsed;
|
|
197
|
+
}
|
|
198
|
+
} catch {
|
|
199
|
+
// Not JSON, might be comma-separated or single value
|
|
200
|
+
if (notifiers.includes(',')) {
|
|
201
|
+
return notifiers.split(',').map(s => s.trim()).filter(Boolean);
|
|
202
|
+
}
|
|
203
|
+
return [notifiers];
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// Fallback to old single notifier setting
|
|
189
207
|
const defaultNotifier = this.storage.getItem('defaultNotifier');
|
|
190
208
|
return defaultNotifier ? [defaultNotifier] : [];
|
|
191
209
|
} catch {
|
|
@@ -10,6 +10,9 @@ import sdk, {
|
|
|
10
10
|
ObjectDetectionResult,
|
|
11
11
|
ScryptedInterface,
|
|
12
12
|
EventListenerRegister,
|
|
13
|
+
ObjectDetection,
|
|
14
|
+
Camera,
|
|
15
|
+
MediaObject,
|
|
13
16
|
} from '@scrypted/sdk';
|
|
14
17
|
import { CameraTopology, findCamera, findConnection, findConnectionsFrom } from '../models/topology';
|
|
15
18
|
import {
|
|
@@ -34,6 +37,12 @@ export interface TrackingEngineConfig {
|
|
|
34
37
|
lostTimeout: number;
|
|
35
38
|
/** Enable visual embedding matching */
|
|
36
39
|
useVisualMatching: boolean;
|
|
40
|
+
/** Loitering threshold - object must be visible this long before alerting (ms) */
|
|
41
|
+
loiteringThreshold: number;
|
|
42
|
+
/** Per-object alert cooldown (ms) */
|
|
43
|
+
objectAlertCooldown: number;
|
|
44
|
+
/** Use LLM for enhanced descriptions */
|
|
45
|
+
useLlmDescriptions: boolean;
|
|
37
46
|
}
|
|
38
47
|
|
|
39
48
|
export class TrackingEngine {
|
|
@@ -46,6 +55,10 @@ export class TrackingEngine {
|
|
|
46
55
|
private listeners: Map<string, EventListenerRegister> = new Map();
|
|
47
56
|
private pendingTimers: Map<GlobalTrackingId, NodeJS.Timeout> = new Map();
|
|
48
57
|
private lostCheckInterval: NodeJS.Timeout | null = null;
|
|
58
|
+
/** Track last alert time per object to enforce cooldown */
|
|
59
|
+
private objectLastAlertTime: Map<GlobalTrackingId, number> = new Map();
|
|
60
|
+
/** Cache for LLM device reference */
|
|
61
|
+
private llmDevice: ObjectDetection | null = null;
|
|
49
62
|
|
|
50
63
|
constructor(
|
|
51
64
|
topology: CameraTopology,
|
|
@@ -176,6 +189,79 @@ export class TrackingEngine {
|
|
|
176
189
|
}
|
|
177
190
|
}
|
|
178
191
|
|
|
192
|
+
/** Check if object passes loitering threshold */
|
|
193
|
+
private passesLoiteringThreshold(tracked: TrackedObject): boolean {
|
|
194
|
+
const visibleDuration = tracked.lastSeen - tracked.firstSeen;
|
|
195
|
+
return visibleDuration >= this.config.loiteringThreshold;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Check if object is in alert cooldown */
|
|
199
|
+
private isInAlertCooldown(globalId: GlobalTrackingId): boolean {
|
|
200
|
+
const lastAlertTime = this.objectLastAlertTime.get(globalId);
|
|
201
|
+
if (!lastAlertTime) return false;
|
|
202
|
+
return (Date.now() - lastAlertTime) < this.config.objectAlertCooldown;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Record that we alerted for this object */
|
|
206
|
+
private recordAlertTime(globalId: GlobalTrackingId): void {
|
|
207
|
+
this.objectLastAlertTime.set(globalId, Date.now());
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Try to get LLM-enhanced description for movement */
|
|
211
|
+
private async getLlmDescription(
|
|
212
|
+
tracked: TrackedObject,
|
|
213
|
+
fromCamera: string,
|
|
214
|
+
toCamera: string,
|
|
215
|
+
cameraId: string
|
|
216
|
+
): Promise<string | null> {
|
|
217
|
+
if (!this.config.useLlmDescriptions) return null;
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
// Find LLM plugin device if not cached
|
|
221
|
+
if (!this.llmDevice) {
|
|
222
|
+
for (const id of Object.keys(systemManager.getSystemState())) {
|
|
223
|
+
const device = systemManager.getDeviceById(id);
|
|
224
|
+
if (device?.interfaces?.includes(ScryptedInterface.ObjectDetection) &&
|
|
225
|
+
device.name?.toLowerCase().includes('llm')) {
|
|
226
|
+
this.llmDevice = device as unknown as ObjectDetection;
|
|
227
|
+
this.console.log(`Found LLM device: ${device.name}`);
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (!this.llmDevice) return null;
|
|
234
|
+
|
|
235
|
+
// Get snapshot from camera for LLM analysis
|
|
236
|
+
const camera = systemManager.getDeviceById<Camera>(cameraId);
|
|
237
|
+
if (!camera?.interfaces?.includes(ScryptedInterface.Camera)) return null;
|
|
238
|
+
|
|
239
|
+
const picture = await camera.takePicture();
|
|
240
|
+
if (!picture) return null;
|
|
241
|
+
|
|
242
|
+
// Ask LLM to describe the movement
|
|
243
|
+
const prompt = `Describe this ${tracked.className} in one short sentence. ` +
|
|
244
|
+
`They are moving from the ${fromCamera} area towards the ${toCamera}. ` +
|
|
245
|
+
`Include details like: gender (man/woman), clothing color, vehicle color/type if applicable. ` +
|
|
246
|
+
`Example: "Man in blue jacket walking from garage towards front door" or ` +
|
|
247
|
+
`"Black SUV driving from driveway towards street"`;
|
|
248
|
+
|
|
249
|
+
const result = await this.llmDevice.detectObjects(picture, {
|
|
250
|
+
settings: { prompt }
|
|
251
|
+
} as any);
|
|
252
|
+
|
|
253
|
+
// Extract description from LLM response
|
|
254
|
+
if (result.detections?.[0]?.label) {
|
|
255
|
+
return result.detections[0].label;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return null;
|
|
259
|
+
} catch (e) {
|
|
260
|
+
this.console.warn('LLM description failed:', e);
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
179
265
|
/** Process a single sighting */
|
|
180
266
|
private async processSighting(
|
|
181
267
|
sighting: ObjectSighting,
|
|
@@ -212,17 +298,30 @@ export class TrackingEngine {
|
|
|
212
298
|
`(confidence: ${(correlation.confidence * 100).toFixed(0)}%)`
|
|
213
299
|
);
|
|
214
300
|
|
|
215
|
-
//
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
301
|
+
// Check loitering threshold and per-object cooldown before alerting
|
|
302
|
+
if (this.passesLoiteringThreshold(tracked) && !this.isInAlertCooldown(tracked.globalId)) {
|
|
303
|
+
// Try to get LLM-enhanced description
|
|
304
|
+
const llmDescription = await this.getLlmDescription(
|
|
305
|
+
tracked,
|
|
306
|
+
lastSighting.cameraName,
|
|
307
|
+
sighting.cameraName,
|
|
308
|
+
sighting.cameraId
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
// Generate movement alert for cross-camera transition
|
|
312
|
+
await this.alertManager.checkAndAlert('movement', tracked, {
|
|
313
|
+
fromCameraId: lastSighting.cameraId,
|
|
314
|
+
fromCameraName: lastSighting.cameraName,
|
|
315
|
+
toCameraId: sighting.cameraId,
|
|
316
|
+
toCameraName: sighting.cameraName,
|
|
317
|
+
transitTime: transitDuration,
|
|
318
|
+
objectClass: sighting.detection.className,
|
|
319
|
+
objectLabel: llmDescription || sighting.detection.label,
|
|
320
|
+
detectionId: sighting.detectionId,
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
this.recordAlertTime(tracked.globalId);
|
|
324
|
+
}
|
|
226
325
|
}
|
|
227
326
|
|
|
228
327
|
// Add sighting to tracked object
|
|
@@ -253,14 +352,24 @@ export class TrackingEngine {
|
|
|
253
352
|
);
|
|
254
353
|
|
|
255
354
|
// Generate entry alert if this is an entry point
|
|
256
|
-
|
|
355
|
+
// Entry alerts also respect loitering threshold and cooldown
|
|
356
|
+
if (isEntryPoint && this.passesLoiteringThreshold(tracked) && !this.isInAlertCooldown(globalId)) {
|
|
357
|
+
const llmDescription = await this.getLlmDescription(
|
|
358
|
+
tracked,
|
|
359
|
+
'outside',
|
|
360
|
+
sighting.cameraName,
|
|
361
|
+
sighting.cameraId
|
|
362
|
+
);
|
|
363
|
+
|
|
257
364
|
await this.alertManager.checkAndAlert('property_entry', tracked, {
|
|
258
365
|
cameraId: sighting.cameraId,
|
|
259
366
|
cameraName: sighting.cameraName,
|
|
260
367
|
objectClass: sighting.detection.className,
|
|
261
|
-
objectLabel: sighting.detection.label,
|
|
368
|
+
objectLabel: llmDescription || sighting.detection.label,
|
|
262
369
|
detectionId: sighting.detectionId,
|
|
263
370
|
});
|
|
371
|
+
|
|
372
|
+
this.recordAlertTime(globalId);
|
|
264
373
|
}
|
|
265
374
|
}
|
|
266
375
|
}
|
package/src/main.ts
CHANGED
|
@@ -85,6 +85,29 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
85
85
|
description: 'Use visual embeddings for object correlation (requires compatible detectors)',
|
|
86
86
|
group: 'Tracking',
|
|
87
87
|
},
|
|
88
|
+
loiteringThreshold: {
|
|
89
|
+
title: 'Loitering Threshold (seconds)',
|
|
90
|
+
type: 'number',
|
|
91
|
+
defaultValue: 3,
|
|
92
|
+
description: 'Object must be visible for this duration before triggering movement alerts',
|
|
93
|
+
group: 'Tracking',
|
|
94
|
+
},
|
|
95
|
+
objectAlertCooldown: {
|
|
96
|
+
title: 'Per-Object Alert Cooldown (seconds)',
|
|
97
|
+
type: 'number',
|
|
98
|
+
defaultValue: 30,
|
|
99
|
+
description: 'Minimum time between alerts for the same tracked object',
|
|
100
|
+
group: 'Tracking',
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
// LLM Integration
|
|
104
|
+
useLlmDescriptions: {
|
|
105
|
+
title: 'Use LLM for Rich Descriptions',
|
|
106
|
+
type: 'boolean',
|
|
107
|
+
defaultValue: true,
|
|
108
|
+
description: 'Use LLM plugin (if installed) to generate descriptive alerts like "Man walking from garage towards front door"',
|
|
109
|
+
group: 'Tracking',
|
|
110
|
+
},
|
|
88
111
|
|
|
89
112
|
// MQTT Settings
|
|
90
113
|
enableMqtt: {
|
|
@@ -123,10 +146,12 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
123
146
|
defaultValue: true,
|
|
124
147
|
group: 'Alerts',
|
|
125
148
|
},
|
|
126
|
-
|
|
127
|
-
title: '
|
|
149
|
+
defaultNotifiers: {
|
|
150
|
+
title: 'Notifiers',
|
|
128
151
|
type: 'device',
|
|
152
|
+
multiple: true,
|
|
129
153
|
deviceFilter: `interfaces.includes('${ScryptedInterface.Notifier}')`,
|
|
154
|
+
description: 'Select one or more notifiers to receive alerts',
|
|
130
155
|
group: 'Alerts',
|
|
131
156
|
},
|
|
132
157
|
|
|
@@ -242,6 +267,9 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
242
267
|
correlationThreshold: this.storageSettings.values.correlationThreshold as number || 0.6,
|
|
243
268
|
lostTimeout: (this.storageSettings.values.lostTimeout as number || 300) * 1000,
|
|
244
269
|
useVisualMatching: this.storageSettings.values.useVisualMatching as boolean ?? true,
|
|
270
|
+
loiteringThreshold: (this.storageSettings.values.loiteringThreshold as number || 3) * 1000,
|
|
271
|
+
objectAlertCooldown: (this.storageSettings.values.objectAlertCooldown as number || 30) * 1000,
|
|
272
|
+
useLlmDescriptions: this.storageSettings.values.useLlmDescriptions as boolean ?? true,
|
|
245
273
|
};
|
|
246
274
|
|
|
247
275
|
this.trackingEngine = new TrackingEngine(
|
|
@@ -515,7 +543,10 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
515
543
|
key === 'correlationWindow' ||
|
|
516
544
|
key === 'correlationThreshold' ||
|
|
517
545
|
key === 'lostTimeout' ||
|
|
518
|
-
key === 'useVisualMatching'
|
|
546
|
+
key === 'useVisualMatching' ||
|
|
547
|
+
key === 'loiteringThreshold' ||
|
|
548
|
+
key === 'objectAlertCooldown' ||
|
|
549
|
+
key === 'useLlmDescriptions'
|
|
519
550
|
) {
|
|
520
551
|
const topologyJson = this.storage.getItem('topology');
|
|
521
552
|
if (topologyJson) {
|