@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/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.1.14",
3
+ "version": "0.1.16",
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",
@@ -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
- // Generate movement alert for cross-camera transition
216
- await this.alertManager.checkAndAlert('movement', tracked, {
217
- fromCameraId: lastSighting.cameraId,
218
- fromCameraName: lastSighting.cameraName,
219
- toCameraId: sighting.cameraId,
220
- toCameraName: sighting.cameraName,
221
- transitTime: transitDuration,
222
- objectClass: sighting.detection.className,
223
- objectLabel: sighting.detection.label,
224
- detectionId: sighting.detectionId,
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
- if (isEntryPoint) {
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
- defaultNotifier: {
127
- title: 'Default Notifier',
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) {