@blueharford/scrypted-spatial-awareness 0.1.0
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/.vscode/settings.json +3 -0
- package/CLAUDE.md +168 -0
- package/README.md +152 -0
- package/dist/main.nodejs.js +3 -0
- package/dist/main.nodejs.js.LICENSE.txt +1 -0
- package/dist/main.nodejs.js.map +1 -0
- package/dist/plugin.zip +0 -0
- package/out/main.nodejs.js +37376 -0
- package/out/main.nodejs.js.map +1 -0
- package/out/plugin.zip +0 -0
- package/package.json +59 -0
- package/src/alerts/alert-manager.ts +347 -0
- package/src/core/object-correlator.ts +376 -0
- package/src/core/tracking-engine.ts +367 -0
- package/src/devices/global-tracker-sensor.ts +191 -0
- package/src/devices/tracking-zone.ts +245 -0
- package/src/integrations/mqtt-publisher.ts +320 -0
- package/src/main.ts +690 -0
- package/src/models/alert.ts +229 -0
- package/src/models/topology.ts +168 -0
- package/src/models/tracked-object.ts +226 -0
- package/src/state/tracking-state.ts +285 -0
- package/src/ui/editor.html +1051 -0
- package/src/utils/id-generator.ts +36 -0
- package/tsconfig.json +21 -0
package/src/main.ts
ADDED
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
import sdk, {
|
|
2
|
+
DeviceProvider,
|
|
3
|
+
DeviceCreator,
|
|
4
|
+
DeviceCreatorSettings,
|
|
5
|
+
Settings,
|
|
6
|
+
Setting,
|
|
7
|
+
SettingValue,
|
|
8
|
+
ScryptedDeviceBase,
|
|
9
|
+
ScryptedDeviceType,
|
|
10
|
+
ScryptedInterface,
|
|
11
|
+
ScryptedNativeId,
|
|
12
|
+
HttpRequestHandler,
|
|
13
|
+
HttpRequest,
|
|
14
|
+
HttpResponse,
|
|
15
|
+
Readme,
|
|
16
|
+
} from '@scrypted/sdk';
|
|
17
|
+
import { StorageSettings } from '@scrypted/sdk/storage-settings';
|
|
18
|
+
import { CameraTopology, createEmptyTopology } from './models/topology';
|
|
19
|
+
import { TrackedObject } from './models/tracked-object';
|
|
20
|
+
import { Alert, AlertRule, createDefaultRules } from './models/alert';
|
|
21
|
+
import { TrackingState } from './state/tracking-state';
|
|
22
|
+
import { TrackingEngine, TrackingEngineConfig } from './core/tracking-engine';
|
|
23
|
+
import { AlertManager } from './alerts/alert-manager';
|
|
24
|
+
import { GlobalTrackerSensor } from './devices/global-tracker-sensor';
|
|
25
|
+
import { TrackingZone } from './devices/tracking-zone';
|
|
26
|
+
import { MqttPublisher, MqttConfig } from './integrations/mqtt-publisher';
|
|
27
|
+
import * as fs from 'fs';
|
|
28
|
+
import * as path from 'path';
|
|
29
|
+
|
|
30
|
+
const { deviceManager, systemManager } = sdk;
|
|
31
|
+
|
|
32
|
+
const TRACKING_ZONE_PREFIX = 'tracking-zone:';
|
|
33
|
+
const GLOBAL_TRACKER_ID = 'global-tracker';
|
|
34
|
+
|
|
35
|
+
export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
36
|
+
implements DeviceProvider, DeviceCreator, Settings, HttpRequestHandler, Readme {
|
|
37
|
+
|
|
38
|
+
private trackingEngine: TrackingEngine | null = null;
|
|
39
|
+
private trackingState: TrackingState;
|
|
40
|
+
private alertManager: AlertManager;
|
|
41
|
+
private mqttPublisher: MqttPublisher | null = null;
|
|
42
|
+
private devices: Map<string, any> = new Map();
|
|
43
|
+
|
|
44
|
+
storageSettings = new StorageSettings(this, {
|
|
45
|
+
// Topology Configuration (stored as JSON)
|
|
46
|
+
topology: {
|
|
47
|
+
title: 'Camera Topology',
|
|
48
|
+
type: 'string',
|
|
49
|
+
description: 'JSON configuration of camera relationships',
|
|
50
|
+
hide: true,
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
// Floor plan image (stored as base64)
|
|
54
|
+
floorPlanImage: {
|
|
55
|
+
title: 'Floor Plan Image',
|
|
56
|
+
type: 'string',
|
|
57
|
+
hide: true,
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
// Correlation Settings
|
|
61
|
+
correlationWindow: {
|
|
62
|
+
title: 'Correlation Window (seconds)',
|
|
63
|
+
type: 'number',
|
|
64
|
+
defaultValue: 30,
|
|
65
|
+
description: 'Maximum time to wait for an object to appear on connected camera',
|
|
66
|
+
group: 'Tracking',
|
|
67
|
+
},
|
|
68
|
+
correlationThreshold: {
|
|
69
|
+
title: 'Correlation Confidence Threshold',
|
|
70
|
+
type: 'number',
|
|
71
|
+
defaultValue: 0.6,
|
|
72
|
+
description: 'Minimum confidence (0-1) for automatic object correlation',
|
|
73
|
+
group: 'Tracking',
|
|
74
|
+
},
|
|
75
|
+
lostTimeout: {
|
|
76
|
+
title: 'Lost Object Timeout (seconds)',
|
|
77
|
+
type: 'number',
|
|
78
|
+
defaultValue: 300,
|
|
79
|
+
description: 'Time before marking a tracked object as lost',
|
|
80
|
+
group: 'Tracking',
|
|
81
|
+
},
|
|
82
|
+
useVisualMatching: {
|
|
83
|
+
title: 'Use Visual Matching',
|
|
84
|
+
type: 'boolean',
|
|
85
|
+
defaultValue: true,
|
|
86
|
+
description: 'Use visual embeddings for object correlation (requires compatible detectors)',
|
|
87
|
+
group: 'Tracking',
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
// MQTT Settings
|
|
91
|
+
enableMqtt: {
|
|
92
|
+
title: 'Enable MQTT',
|
|
93
|
+
type: 'boolean',
|
|
94
|
+
defaultValue: false,
|
|
95
|
+
group: 'MQTT Integration',
|
|
96
|
+
},
|
|
97
|
+
mqttBroker: {
|
|
98
|
+
title: 'MQTT Broker URL',
|
|
99
|
+
type: 'string',
|
|
100
|
+
placeholder: 'mqtt://localhost:1883',
|
|
101
|
+
group: 'MQTT Integration',
|
|
102
|
+
},
|
|
103
|
+
mqttUsername: {
|
|
104
|
+
title: 'MQTT Username',
|
|
105
|
+
type: 'string',
|
|
106
|
+
group: 'MQTT Integration',
|
|
107
|
+
},
|
|
108
|
+
mqttPassword: {
|
|
109
|
+
title: 'MQTT Password',
|
|
110
|
+
type: 'password',
|
|
111
|
+
group: 'MQTT Integration',
|
|
112
|
+
},
|
|
113
|
+
mqttBaseTopic: {
|
|
114
|
+
title: 'MQTT Base Topic',
|
|
115
|
+
type: 'string',
|
|
116
|
+
defaultValue: 'scrypted/spatial-awareness',
|
|
117
|
+
group: 'MQTT Integration',
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
// Alert Settings
|
|
121
|
+
enableAlerts: {
|
|
122
|
+
title: 'Enable Alerts',
|
|
123
|
+
type: 'boolean',
|
|
124
|
+
defaultValue: true,
|
|
125
|
+
group: 'Alerts',
|
|
126
|
+
},
|
|
127
|
+
defaultNotifier: {
|
|
128
|
+
title: 'Default Notifier',
|
|
129
|
+
type: 'device',
|
|
130
|
+
deviceFilter: `interfaces.includes('${ScryptedInterface.Notifier}')`,
|
|
131
|
+
group: 'Alerts',
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
// Tracked Cameras
|
|
135
|
+
trackedCameras: {
|
|
136
|
+
title: 'Cameras to Track',
|
|
137
|
+
type: 'device',
|
|
138
|
+
multiple: true,
|
|
139
|
+
deviceFilter: `interfaces.includes('${ScryptedInterface.ObjectDetector}')`,
|
|
140
|
+
group: 'Cameras',
|
|
141
|
+
description: 'Select cameras with object detection to track',
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
// Alert Rules (stored as JSON)
|
|
145
|
+
alertRules: {
|
|
146
|
+
title: 'Alert Rules',
|
|
147
|
+
type: 'string',
|
|
148
|
+
hide: true,
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
constructor(nativeId?: ScryptedNativeId) {
|
|
153
|
+
super(nativeId);
|
|
154
|
+
|
|
155
|
+
this.trackingState = new TrackingState(this.storage, this.console);
|
|
156
|
+
this.alertManager = new AlertManager(this.console, this.storage);
|
|
157
|
+
|
|
158
|
+
// Initialize on next tick to allow Scrypted to fully load
|
|
159
|
+
process.nextTick(() => this.initialize());
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private async initialize(): Promise<void> {
|
|
163
|
+
this.console.log('Initializing Spatial Awareness Plugin');
|
|
164
|
+
|
|
165
|
+
// Discover the global tracker device
|
|
166
|
+
await deviceManager.onDeviceDiscovered({
|
|
167
|
+
nativeId: GLOBAL_TRACKER_ID,
|
|
168
|
+
name: 'Global Object Tracker',
|
|
169
|
+
type: ScryptedDeviceType.Sensor,
|
|
170
|
+
interfaces: [
|
|
171
|
+
ScryptedInterface.OccupancySensor,
|
|
172
|
+
ScryptedInterface.Settings,
|
|
173
|
+
ScryptedInterface.Readme,
|
|
174
|
+
],
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Load topology if it exists
|
|
178
|
+
const topologyJson = this.storage.getItem('topology');
|
|
179
|
+
if (topologyJson) {
|
|
180
|
+
try {
|
|
181
|
+
const topology = JSON.parse(topologyJson) as CameraTopology;
|
|
182
|
+
await this.startTrackingEngine(topology);
|
|
183
|
+
} catch (e) {
|
|
184
|
+
this.console.error('Failed to parse topology:', e);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Load alert rules
|
|
189
|
+
const rulesJson = this.storage.getItem('alertRules');
|
|
190
|
+
if (rulesJson) {
|
|
191
|
+
try {
|
|
192
|
+
const rules = JSON.parse(rulesJson) as AlertRule[];
|
|
193
|
+
this.alertManager.setRules(rules);
|
|
194
|
+
} catch (e) {
|
|
195
|
+
this.console.error('Failed to parse alert rules:', e);
|
|
196
|
+
this.alertManager.setRules(createDefaultRules());
|
|
197
|
+
}
|
|
198
|
+
} else {
|
|
199
|
+
this.alertManager.setRules(createDefaultRules());
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Initialize MQTT if enabled
|
|
203
|
+
if (this.storageSettings.values.enableMqtt) {
|
|
204
|
+
await this.initializeMqtt();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
this.console.log('Spatial Awareness Plugin initialized');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private async initializeMqtt(): Promise<void> {
|
|
211
|
+
const broker = this.storageSettings.values.mqttBroker as string;
|
|
212
|
+
if (!broker) {
|
|
213
|
+
this.console.warn('MQTT enabled but no broker URL configured');
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const config: MqttConfig = {
|
|
218
|
+
broker,
|
|
219
|
+
username: this.storageSettings.values.mqttUsername as string,
|
|
220
|
+
password: this.storageSettings.values.mqttPassword as string,
|
|
221
|
+
baseTopic: this.storageSettings.values.mqttBaseTopic as string || 'scrypted/spatial-awareness',
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
this.mqttPublisher = new MqttPublisher(config, this.console);
|
|
225
|
+
await this.mqttPublisher.connect();
|
|
226
|
+
|
|
227
|
+
// Subscribe to state changes
|
|
228
|
+
this.trackingState.onStateChange((objects) => {
|
|
229
|
+
this.mqttPublisher?.publishState(objects);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
this.console.log('MQTT publisher initialized');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private async startTrackingEngine(topology: CameraTopology): Promise<void> {
|
|
236
|
+
// Stop existing engine if running
|
|
237
|
+
if (this.trackingEngine) {
|
|
238
|
+
await this.trackingEngine.stopTracking();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const config: TrackingEngineConfig = {
|
|
242
|
+
correlationWindow: (this.storageSettings.values.correlationWindow as number || 30) * 1000,
|
|
243
|
+
correlationThreshold: this.storageSettings.values.correlationThreshold as number || 0.6,
|
|
244
|
+
lostTimeout: (this.storageSettings.values.lostTimeout as number || 300) * 1000,
|
|
245
|
+
useVisualMatching: this.storageSettings.values.useVisualMatching as boolean ?? true,
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
this.trackingEngine = new TrackingEngine(
|
|
249
|
+
topology,
|
|
250
|
+
this.trackingState,
|
|
251
|
+
this.alertManager,
|
|
252
|
+
config,
|
|
253
|
+
this.console
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
await this.trackingEngine.startTracking();
|
|
257
|
+
this.console.log('Tracking engine started');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ==================== DeviceProvider Implementation ====================
|
|
261
|
+
|
|
262
|
+
async getDevice(nativeId: string): Promise<any> {
|
|
263
|
+
let device = this.devices.get(nativeId);
|
|
264
|
+
|
|
265
|
+
if (!device) {
|
|
266
|
+
if (nativeId === GLOBAL_TRACKER_ID) {
|
|
267
|
+
device = new GlobalTrackerSensor(this, nativeId, this.trackingState);
|
|
268
|
+
} else if (nativeId.startsWith(TRACKING_ZONE_PREFIX)) {
|
|
269
|
+
device = new TrackingZone(this, nativeId, this.trackingState);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (device) {
|
|
273
|
+
this.devices.set(nativeId, device);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return device;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async releaseDevice(id: string, nativeId: string): Promise<void> {
|
|
281
|
+
this.devices.delete(nativeId);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ==================== DeviceCreator Implementation ====================
|
|
285
|
+
|
|
286
|
+
async getCreateDeviceSettings(): Promise<Setting[]> {
|
|
287
|
+
return [
|
|
288
|
+
{
|
|
289
|
+
key: 'name',
|
|
290
|
+
title: 'Zone Name',
|
|
291
|
+
description: 'Name for this tracking zone',
|
|
292
|
+
type: 'string',
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
key: 'type',
|
|
296
|
+
title: 'Zone Type',
|
|
297
|
+
type: 'string',
|
|
298
|
+
choices: ['entry', 'exit', 'dwell', 'restricted'],
|
|
299
|
+
value: 'entry',
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
key: 'cameras',
|
|
303
|
+
title: 'Cameras',
|
|
304
|
+
type: 'device',
|
|
305
|
+
multiple: true,
|
|
306
|
+
deviceFilter: `interfaces.includes('${ScryptedInterface.ObjectDetector}')`,
|
|
307
|
+
},
|
|
308
|
+
];
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async createDevice(settings: DeviceCreatorSettings): Promise<string> {
|
|
312
|
+
const nativeId = TRACKING_ZONE_PREFIX + Date.now().toString();
|
|
313
|
+
|
|
314
|
+
await deviceManager.onDeviceDiscovered({
|
|
315
|
+
nativeId,
|
|
316
|
+
name: (settings.name as string) || 'Tracking Zone',
|
|
317
|
+
type: ScryptedDeviceType.Sensor,
|
|
318
|
+
interfaces: [
|
|
319
|
+
ScryptedInterface.OccupancySensor,
|
|
320
|
+
ScryptedInterface.MotionSensor,
|
|
321
|
+
ScryptedInterface.Settings,
|
|
322
|
+
],
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// Store zone configuration
|
|
326
|
+
this.storage.setItem(`zone:${nativeId}`, JSON.stringify({
|
|
327
|
+
type: settings.type,
|
|
328
|
+
cameras: settings.cameras,
|
|
329
|
+
}));
|
|
330
|
+
|
|
331
|
+
return nativeId;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ==================== Settings Implementation ====================
|
|
335
|
+
|
|
336
|
+
async getSettings(): Promise<Setting[]> {
|
|
337
|
+
const settings = await this.storageSettings.getSettings();
|
|
338
|
+
|
|
339
|
+
// Add topology editor button
|
|
340
|
+
settings.push({
|
|
341
|
+
key: 'openTopologyEditor',
|
|
342
|
+
title: 'Open Topology Editor',
|
|
343
|
+
type: 'button',
|
|
344
|
+
description: 'Configure camera relationships and transit paths with visual floor plan',
|
|
345
|
+
group: 'Cameras',
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// Add status display
|
|
349
|
+
const activeCount = this.trackingState.getActiveCount();
|
|
350
|
+
settings.push({
|
|
351
|
+
key: 'status',
|
|
352
|
+
title: 'Tracking Status',
|
|
353
|
+
type: 'string',
|
|
354
|
+
readonly: true,
|
|
355
|
+
value: this.trackingEngine
|
|
356
|
+
? `Active: Tracking ${activeCount} object${activeCount !== 1 ? 's' : ''}`
|
|
357
|
+
: 'Not configured - add cameras and configure topology',
|
|
358
|
+
group: 'Status',
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// Add recent alerts summary
|
|
362
|
+
const recentAlerts = this.alertManager.getRecentAlerts(5);
|
|
363
|
+
if (recentAlerts.length > 0) {
|
|
364
|
+
settings.push({
|
|
365
|
+
key: 'recentAlerts',
|
|
366
|
+
title: 'Recent Alerts',
|
|
367
|
+
type: 'string',
|
|
368
|
+
readonly: true,
|
|
369
|
+
value: recentAlerts.map(a => `${a.type}: ${a.message}`).join('\n'),
|
|
370
|
+
group: 'Status',
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return settings;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async putSetting(key: string, value: SettingValue): Promise<void> {
|
|
378
|
+
if (key === 'openTopologyEditor') {
|
|
379
|
+
// The UI will handle opening the editor via HTTP
|
|
380
|
+
this.console.log('Topology editor requested - access via plugin HTTP endpoint');
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
await this.storageSettings.putSetting(key, value);
|
|
385
|
+
|
|
386
|
+
// Handle setting changes that require engine restart
|
|
387
|
+
if (
|
|
388
|
+
key === 'trackedCameras' ||
|
|
389
|
+
key === 'correlationWindow' ||
|
|
390
|
+
key === 'correlationThreshold' ||
|
|
391
|
+
key === 'lostTimeout' ||
|
|
392
|
+
key === 'useVisualMatching'
|
|
393
|
+
) {
|
|
394
|
+
const topologyJson = this.storage.getItem('topology');
|
|
395
|
+
if (topologyJson) {
|
|
396
|
+
try {
|
|
397
|
+
const topology = JSON.parse(topologyJson) as CameraTopology;
|
|
398
|
+
await this.startTrackingEngine(topology);
|
|
399
|
+
} catch (e) {
|
|
400
|
+
this.console.error('Failed to restart tracking engine:', e);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Handle MQTT setting changes
|
|
406
|
+
if (key === 'enableMqtt' || key === 'mqttBroker' || key === 'mqttUsername' ||
|
|
407
|
+
key === 'mqttPassword' || key === 'mqttBaseTopic') {
|
|
408
|
+
if (this.mqttPublisher) {
|
|
409
|
+
this.mqttPublisher.disconnect();
|
|
410
|
+
this.mqttPublisher = null;
|
|
411
|
+
}
|
|
412
|
+
if (this.storageSettings.values.enableMqtt) {
|
|
413
|
+
await this.initializeMqtt();
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ==================== HttpRequestHandler Implementation ====================
|
|
419
|
+
|
|
420
|
+
async onRequest(request: HttpRequest, response: HttpResponse): Promise<void> {
|
|
421
|
+
const url = new URL(request.url!, 'http://localhost');
|
|
422
|
+
const path = url.pathname;
|
|
423
|
+
|
|
424
|
+
try {
|
|
425
|
+
// API Routes
|
|
426
|
+
if (path.endsWith('/api/tracked-objects')) {
|
|
427
|
+
return this.handleTrackedObjectsRequest(request, response);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (path.match(/\/api\/journey\/[\w-]+$/)) {
|
|
431
|
+
const globalId = path.split('/').pop()!;
|
|
432
|
+
return this.handleJourneyRequest(globalId, response);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (path.endsWith('/api/topology')) {
|
|
436
|
+
return this.handleTopologyRequest(request, response);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (path.endsWith('/api/alerts')) {
|
|
440
|
+
return this.handleAlertsRequest(request, response);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (path.endsWith('/api/floor-plan')) {
|
|
444
|
+
return this.handleFloorPlanRequest(request, response);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// UI Routes
|
|
448
|
+
if (path.endsWith('/ui/editor') || path.endsWith('/ui/editor/')) {
|
|
449
|
+
return this.serveEditorUI(response);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (path.includes('/ui/')) {
|
|
453
|
+
return this.serveStaticFile(path, response);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Default: return info page
|
|
457
|
+
response.send(JSON.stringify({
|
|
458
|
+
name: 'Spatial Awareness Plugin',
|
|
459
|
+
version: '0.1.0',
|
|
460
|
+
endpoints: {
|
|
461
|
+
api: {
|
|
462
|
+
trackedObjects: '/api/tracked-objects',
|
|
463
|
+
journey: '/api/journey/{globalId}',
|
|
464
|
+
topology: '/api/topology',
|
|
465
|
+
alerts: '/api/alerts',
|
|
466
|
+
floorPlan: '/api/floor-plan',
|
|
467
|
+
},
|
|
468
|
+
ui: {
|
|
469
|
+
editor: '/ui/editor',
|
|
470
|
+
},
|
|
471
|
+
},
|
|
472
|
+
}), {
|
|
473
|
+
headers: { 'Content-Type': 'application/json' },
|
|
474
|
+
});
|
|
475
|
+
} catch (e) {
|
|
476
|
+
this.console.error('HTTP request error:', e);
|
|
477
|
+
response.send(JSON.stringify({ error: (e as Error).message }), {
|
|
478
|
+
code: 500,
|
|
479
|
+
headers: { 'Content-Type': 'application/json' },
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
private handleTrackedObjectsRequest(request: HttpRequest, response: HttpResponse): void {
|
|
485
|
+
const objects = this.trackingState.getAllObjects();
|
|
486
|
+
response.send(JSON.stringify(objects), {
|
|
487
|
+
headers: { 'Content-Type': 'application/json' },
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
private handleJourneyRequest(globalId: string, response: HttpResponse): void {
|
|
492
|
+
const tracked = this.trackingState.getObject(globalId);
|
|
493
|
+
if (tracked) {
|
|
494
|
+
response.send(JSON.stringify({
|
|
495
|
+
globalId: tracked.globalId,
|
|
496
|
+
className: tracked.className,
|
|
497
|
+
label: tracked.label,
|
|
498
|
+
journey: tracked.journey,
|
|
499
|
+
sightings: tracked.sightings.map(s => ({
|
|
500
|
+
cameraId: s.cameraId,
|
|
501
|
+
cameraName: s.cameraName,
|
|
502
|
+
timestamp: s.timestamp,
|
|
503
|
+
})),
|
|
504
|
+
firstSeen: tracked.firstSeen,
|
|
505
|
+
lastSeen: tracked.lastSeen,
|
|
506
|
+
state: tracked.state,
|
|
507
|
+
}), {
|
|
508
|
+
headers: { 'Content-Type': 'application/json' },
|
|
509
|
+
});
|
|
510
|
+
} else {
|
|
511
|
+
response.send(JSON.stringify({ error: 'Object not found' }), {
|
|
512
|
+
code: 404,
|
|
513
|
+
headers: { 'Content-Type': 'application/json' },
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
private handleTopologyRequest(request: HttpRequest, response: HttpResponse): void {
|
|
519
|
+
if (request.method === 'GET') {
|
|
520
|
+
const topologyJson = this.storage.getItem('topology');
|
|
521
|
+
const topology = topologyJson ? JSON.parse(topologyJson) : createEmptyTopology();
|
|
522
|
+
response.send(JSON.stringify(topology), {
|
|
523
|
+
headers: { 'Content-Type': 'application/json' },
|
|
524
|
+
});
|
|
525
|
+
} else if (request.method === 'PUT' || request.method === 'POST') {
|
|
526
|
+
try {
|
|
527
|
+
const topology = JSON.parse(request.body!) as CameraTopology;
|
|
528
|
+
this.storage.setItem('topology', JSON.stringify(topology));
|
|
529
|
+
this.startTrackingEngine(topology);
|
|
530
|
+
response.send(JSON.stringify({ success: true }), {
|
|
531
|
+
headers: { 'Content-Type': 'application/json' },
|
|
532
|
+
});
|
|
533
|
+
} catch (e) {
|
|
534
|
+
response.send(JSON.stringify({ error: 'Invalid topology JSON' }), {
|
|
535
|
+
code: 400,
|
|
536
|
+
headers: { 'Content-Type': 'application/json' },
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
private handleAlertsRequest(request: HttpRequest, response: HttpResponse): void {
|
|
543
|
+
const alerts = this.alertManager.getRecentAlerts();
|
|
544
|
+
response.send(JSON.stringify(alerts), {
|
|
545
|
+
headers: { 'Content-Type': 'application/json' },
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
private handleFloorPlanRequest(request: HttpRequest, response: HttpResponse): void {
|
|
550
|
+
if (request.method === 'GET') {
|
|
551
|
+
const imageData = this.storage.getItem('floorPlanImage');
|
|
552
|
+
if (imageData) {
|
|
553
|
+
response.send(JSON.stringify({ imageData }), {
|
|
554
|
+
headers: { 'Content-Type': 'application/json' },
|
|
555
|
+
});
|
|
556
|
+
} else {
|
|
557
|
+
response.send(JSON.stringify({ imageData: null }), {
|
|
558
|
+
headers: { 'Content-Type': 'application/json' },
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
} else if (request.method === 'POST') {
|
|
562
|
+
try {
|
|
563
|
+
const body = JSON.parse(request.body!);
|
|
564
|
+
this.storage.setItem('floorPlanImage', body.imageData);
|
|
565
|
+
response.send(JSON.stringify({ success: true }), {
|
|
566
|
+
headers: { 'Content-Type': 'application/json' },
|
|
567
|
+
});
|
|
568
|
+
} catch (e) {
|
|
569
|
+
response.send(JSON.stringify({ error: 'Invalid request body' }), {
|
|
570
|
+
code: 400,
|
|
571
|
+
headers: { 'Content-Type': 'application/json' },
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
private serveEditorUI(response: HttpResponse): void {
|
|
578
|
+
// Try to read the editor HTML from the bundled file
|
|
579
|
+
try {
|
|
580
|
+
const editorPath = path.join(__dirname, 'ui', 'editor.html');
|
|
581
|
+
const html = fs.readFileSync(editorPath, 'utf-8');
|
|
582
|
+
response.send(html, {
|
|
583
|
+
headers: { 'Content-Type': 'text/html' },
|
|
584
|
+
});
|
|
585
|
+
} catch (e) {
|
|
586
|
+
this.console.error('Failed to load editor UI:', e);
|
|
587
|
+
// Fallback to basic UI
|
|
588
|
+
const html = `
|
|
589
|
+
<!DOCTYPE html>
|
|
590
|
+
<html>
|
|
591
|
+
<head>
|
|
592
|
+
<title>Spatial Awareness - Topology Editor</title>
|
|
593
|
+
<meta charset="utf-8">
|
|
594
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
595
|
+
<style>
|
|
596
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 0; padding: 20px; background: #1a1a1a; color: #fff; }
|
|
597
|
+
h1 { margin-top: 0; }
|
|
598
|
+
.container { max-width: 1200px; margin: 0 auto; }
|
|
599
|
+
.btn { background: #0066cc; color: #fff; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; font-size: 14px; }
|
|
600
|
+
</style>
|
|
601
|
+
</head>
|
|
602
|
+
<body>
|
|
603
|
+
<div class="container">
|
|
604
|
+
<h1>Spatial Awareness - Topology Editor</h1>
|
|
605
|
+
<p>Error loading visual editor. Configure topology via REST API: <code>PUT /api/topology</code></p>
|
|
606
|
+
<h3>Current Topology</h3>
|
|
607
|
+
<pre id="topology-json" style="background: #2a2a2a; padding: 15px; border-radius: 4px; overflow: auto;"></pre>
|
|
608
|
+
</div>
|
|
609
|
+
<script>
|
|
610
|
+
fetch('api/topology').then(r => r.json()).then(data => {
|
|
611
|
+
document.getElementById('topology-json').textContent = JSON.stringify(data, null, 2);
|
|
612
|
+
});
|
|
613
|
+
</script>
|
|
614
|
+
</body>
|
|
615
|
+
</html>`;
|
|
616
|
+
response.send(html, {
|
|
617
|
+
headers: { 'Content-Type': 'text/html' },
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
private serveStaticFile(path: string, response: HttpResponse): void {
|
|
623
|
+
// Serve static files for the UI
|
|
624
|
+
response.send('Not found', { code: 404 });
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// ==================== Readme Implementation ====================
|
|
628
|
+
|
|
629
|
+
async getReadmeMarkdown(): Promise<string> {
|
|
630
|
+
return `
|
|
631
|
+
# Spatial Awareness Plugin
|
|
632
|
+
|
|
633
|
+
This plugin enables cross-camera object tracking across your entire NVR system.
|
|
634
|
+
|
|
635
|
+
## Features
|
|
636
|
+
|
|
637
|
+
- **Cross-Camera Tracking**: Correlate objects as they move between cameras
|
|
638
|
+
- **Journey History**: Complete path history for each tracked object
|
|
639
|
+
- **Entry/Exit Detection**: Know when objects enter or leave your property
|
|
640
|
+
- **Visual Floor Plan**: Configure camera topology with a visual editor
|
|
641
|
+
- **MQTT Integration**: Export tracking data to Home Assistant
|
|
642
|
+
- **REST API**: Query tracked objects and journeys programmatically
|
|
643
|
+
- **Smart Alerts**: Get notified about property entry/exit, unusual paths, and more
|
|
644
|
+
|
|
645
|
+
## Setup
|
|
646
|
+
|
|
647
|
+
1. **Add Cameras**: Select cameras with object detection in the plugin settings
|
|
648
|
+
2. **Configure Topology**: Define camera relationships and transit times
|
|
649
|
+
3. **Enable Integrations**: Optionally enable MQTT for Home Assistant
|
|
650
|
+
4. **Create Zones**: Add tracking zones for specific area monitoring
|
|
651
|
+
|
|
652
|
+
## API Endpoints
|
|
653
|
+
|
|
654
|
+
- \`GET /api/tracked-objects\` - List all tracked objects
|
|
655
|
+
- \`GET /api/journey/{id}\` - Get journey for specific object
|
|
656
|
+
- \`GET /api/topology\` - Get camera topology
|
|
657
|
+
- \`PUT /api/topology\` - Update camera topology
|
|
658
|
+
- \`GET /api/alerts\` - Get recent alerts
|
|
659
|
+
|
|
660
|
+
## Visual Editor
|
|
661
|
+
|
|
662
|
+
Access the visual topology editor at \`/ui/editor\` to configure camera relationships using a floor plan.
|
|
663
|
+
|
|
664
|
+
## Alert Types
|
|
665
|
+
|
|
666
|
+
- **Property Entry**: Object entered the property
|
|
667
|
+
- **Property Exit**: Object exited the property
|
|
668
|
+
- **Unusual Path**: Object took an unexpected route
|
|
669
|
+
- **Dwell Time**: Object lingered too long in an area
|
|
670
|
+
- **Restricted Zone**: Object entered a restricted area
|
|
671
|
+
`;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// ==================== Public Methods for Child Devices ====================
|
|
675
|
+
|
|
676
|
+
getTrackingState(): TrackingState {
|
|
677
|
+
return this.trackingState;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
getAlertManager(): AlertManager {
|
|
681
|
+
return this.alertManager;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
getTopology(): CameraTopology | null {
|
|
685
|
+
const topologyJson = this.storage.getItem('topology');
|
|
686
|
+
return topologyJson ? JSON.parse(topologyJson) : null;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
export default SpatialAwarenessPlugin;
|