@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
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tracking Engine
|
|
3
|
+
* Central orchestrator for cross-camera object tracking
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import sdk, {
|
|
7
|
+
ScryptedDevice,
|
|
8
|
+
ObjectDetector,
|
|
9
|
+
ObjectsDetected,
|
|
10
|
+
ObjectDetectionResult,
|
|
11
|
+
ScryptedInterface,
|
|
12
|
+
EventListenerRegister,
|
|
13
|
+
} from '@scrypted/sdk';
|
|
14
|
+
import { CameraTopology, findCamera, findConnection, findConnectionsFrom } from '../models/topology';
|
|
15
|
+
import {
|
|
16
|
+
TrackedObject,
|
|
17
|
+
ObjectSighting,
|
|
18
|
+
GlobalTrackingId,
|
|
19
|
+
CorrelationCandidate,
|
|
20
|
+
getLastSighting,
|
|
21
|
+
} from '../models/tracked-object';
|
|
22
|
+
import { TrackingState } from '../state/tracking-state';
|
|
23
|
+
import { AlertManager } from '../alerts/alert-manager';
|
|
24
|
+
import { ObjectCorrelator } from './object-correlator';
|
|
25
|
+
|
|
26
|
+
const { systemManager } = sdk;
|
|
27
|
+
|
|
28
|
+
export interface TrackingEngineConfig {
|
|
29
|
+
/** Maximum time to wait for correlation (ms) */
|
|
30
|
+
correlationWindow: number;
|
|
31
|
+
/** Minimum confidence for automatic correlation */
|
|
32
|
+
correlationThreshold: number;
|
|
33
|
+
/** Time before marking object as 'lost' */
|
|
34
|
+
lostTimeout: number;
|
|
35
|
+
/** Enable visual embedding matching */
|
|
36
|
+
useVisualMatching: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class TrackingEngine {
|
|
40
|
+
private topology: CameraTopology;
|
|
41
|
+
private state: TrackingState;
|
|
42
|
+
private alertManager: AlertManager;
|
|
43
|
+
private config: TrackingEngineConfig;
|
|
44
|
+
private console: Console;
|
|
45
|
+
private correlator: ObjectCorrelator;
|
|
46
|
+
private listeners: Map<string, EventListenerRegister> = new Map();
|
|
47
|
+
private pendingTimers: Map<GlobalTrackingId, NodeJS.Timeout> = new Map();
|
|
48
|
+
private lostCheckInterval: NodeJS.Timeout | null = null;
|
|
49
|
+
|
|
50
|
+
constructor(
|
|
51
|
+
topology: CameraTopology,
|
|
52
|
+
state: TrackingState,
|
|
53
|
+
alertManager: AlertManager,
|
|
54
|
+
config: TrackingEngineConfig,
|
|
55
|
+
console: Console
|
|
56
|
+
) {
|
|
57
|
+
this.topology = topology;
|
|
58
|
+
this.state = state;
|
|
59
|
+
this.alertManager = alertManager;
|
|
60
|
+
this.config = config;
|
|
61
|
+
this.console = console;
|
|
62
|
+
this.correlator = new ObjectCorrelator(topology, config);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Start listening to all cameras in topology */
|
|
66
|
+
async startTracking(): Promise<void> {
|
|
67
|
+
this.console.log('Starting tracking engine...');
|
|
68
|
+
|
|
69
|
+
// Stop any existing listeners
|
|
70
|
+
await this.stopTracking();
|
|
71
|
+
|
|
72
|
+
// Subscribe to each camera's object detection events
|
|
73
|
+
for (const camera of this.topology.cameras) {
|
|
74
|
+
try {
|
|
75
|
+
const device = systemManager.getDeviceById<ObjectDetector>(camera.deviceId);
|
|
76
|
+
if (!device) {
|
|
77
|
+
this.console.warn(`Camera not found: ${camera.deviceId} (${camera.name})`);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Check if device has ObjectDetector interface
|
|
82
|
+
if (!device.interfaces?.includes(ScryptedInterface.ObjectDetector)) {
|
|
83
|
+
this.console.warn(`Camera ${camera.name} does not support object detection`);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const listener = device.listen(ScryptedInterface.ObjectDetector, (source, details, data) => {
|
|
88
|
+
this.handleDetection(camera.deviceId, camera.name, data as ObjectsDetected);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
this.listeners.set(camera.deviceId, listener);
|
|
92
|
+
this.console.log(`Listening to camera: ${camera.name}`);
|
|
93
|
+
} catch (e) {
|
|
94
|
+
this.console.error(`Failed to subscribe to camera ${camera.name}:`, e);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Start periodic check for lost objects
|
|
99
|
+
this.lostCheckInterval = setInterval(() => {
|
|
100
|
+
this.checkForLostObjects();
|
|
101
|
+
}, 30000); // Check every 30 seconds
|
|
102
|
+
|
|
103
|
+
this.console.log(`Tracking engine started with ${this.listeners.size} cameras`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Stop all camera listeners */
|
|
107
|
+
async stopTracking(): Promise<void> {
|
|
108
|
+
// Remove all event listeners
|
|
109
|
+
for (const [cameraId, listener] of this.listeners.entries()) {
|
|
110
|
+
try {
|
|
111
|
+
listener.removeListener();
|
|
112
|
+
} catch (e) {
|
|
113
|
+
this.console.error(`Failed to remove listener for ${cameraId}:`, e);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
this.listeners.clear();
|
|
117
|
+
|
|
118
|
+
// Clear pending timers
|
|
119
|
+
for (const timer of this.pendingTimers.values()) {
|
|
120
|
+
clearTimeout(timer);
|
|
121
|
+
}
|
|
122
|
+
this.pendingTimers.clear();
|
|
123
|
+
|
|
124
|
+
// Stop lost check interval
|
|
125
|
+
if (this.lostCheckInterval) {
|
|
126
|
+
clearInterval(this.lostCheckInterval);
|
|
127
|
+
this.lostCheckInterval = null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
this.console.log('Tracking engine stopped');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Handle detection event from a camera */
|
|
134
|
+
private async handleDetection(
|
|
135
|
+
cameraId: string,
|
|
136
|
+
cameraName: string,
|
|
137
|
+
detected: ObjectsDetected
|
|
138
|
+
): Promise<void> {
|
|
139
|
+
if (!detected.detections || detected.detections.length === 0) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const camera = findCamera(this.topology, cameraId);
|
|
144
|
+
if (!camera) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const timestamp = detected.timestamp || Date.now();
|
|
149
|
+
|
|
150
|
+
for (const detection of detected.detections) {
|
|
151
|
+
// Skip low-confidence detections
|
|
152
|
+
if (detection.score < 0.5) continue;
|
|
153
|
+
|
|
154
|
+
// Skip classes we're not tracking on this camera
|
|
155
|
+
if (camera.trackClasses.length > 0 &&
|
|
156
|
+
!camera.trackClasses.includes(detection.className)) {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Create sighting object
|
|
161
|
+
const sighting: ObjectSighting = {
|
|
162
|
+
detection,
|
|
163
|
+
cameraId,
|
|
164
|
+
cameraName,
|
|
165
|
+
timestamp,
|
|
166
|
+
confidence: detection.score,
|
|
167
|
+
embedding: detection.embedding,
|
|
168
|
+
detectionId: detected.detectionId,
|
|
169
|
+
position: detection.boundingBox ? {
|
|
170
|
+
x: (detection.boundingBox[0] + detection.boundingBox[2] / 2) / 100,
|
|
171
|
+
y: (detection.boundingBox[1] + detection.boundingBox[3] / 2) / 100,
|
|
172
|
+
} : undefined,
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
await this.processSighting(sighting, camera.isEntryPoint, camera.isExitPoint);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Process a single sighting */
|
|
180
|
+
private async processSighting(
|
|
181
|
+
sighting: ObjectSighting,
|
|
182
|
+
isEntryPoint: boolean,
|
|
183
|
+
isExitPoint: boolean
|
|
184
|
+
): Promise<void> {
|
|
185
|
+
// Try to correlate with existing tracked objects
|
|
186
|
+
const correlation = await this.correlateDetection(sighting);
|
|
187
|
+
|
|
188
|
+
if (correlation) {
|
|
189
|
+
// Matched to existing object
|
|
190
|
+
const tracked = correlation.trackedObject;
|
|
191
|
+
|
|
192
|
+
// Check if this is a cross-camera transition
|
|
193
|
+
const lastSighting = getLastSighting(tracked);
|
|
194
|
+
if (lastSighting && lastSighting.cameraId !== sighting.cameraId) {
|
|
195
|
+
// Add journey segment
|
|
196
|
+
this.state.addJourney(tracked.globalId, {
|
|
197
|
+
fromCameraId: lastSighting.cameraId,
|
|
198
|
+
fromCameraName: lastSighting.cameraName,
|
|
199
|
+
toCameraId: sighting.cameraId,
|
|
200
|
+
toCameraName: sighting.cameraName,
|
|
201
|
+
exitTime: lastSighting.timestamp,
|
|
202
|
+
entryTime: sighting.timestamp,
|
|
203
|
+
transitDuration: sighting.timestamp - lastSighting.timestamp,
|
|
204
|
+
correlationConfidence: correlation.confidence,
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
this.console.log(
|
|
208
|
+
`Object ${tracked.globalId.slice(0, 8)} transited: ` +
|
|
209
|
+
`${lastSighting.cameraName} → ${sighting.cameraName} ` +
|
|
210
|
+
`(confidence: ${(correlation.confidence * 100).toFixed(0)}%)`
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Add sighting to tracked object
|
|
215
|
+
this.state.addSighting(tracked.globalId, sighting);
|
|
216
|
+
|
|
217
|
+
// Cancel any pending lost timer
|
|
218
|
+
const pendingTimer = this.pendingTimers.get(tracked.globalId);
|
|
219
|
+
if (pendingTimer) {
|
|
220
|
+
clearTimeout(pendingTimer);
|
|
221
|
+
this.pendingTimers.delete(tracked.globalId);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Reactivate if was pending
|
|
225
|
+
this.state.reactivate(tracked.globalId);
|
|
226
|
+
|
|
227
|
+
// Check for exit
|
|
228
|
+
if (isExitPoint && this.isLeavingFrame(sighting)) {
|
|
229
|
+
this.handlePotentialExit(tracked, sighting);
|
|
230
|
+
}
|
|
231
|
+
} else {
|
|
232
|
+
// New object - create tracking entry
|
|
233
|
+
const globalId = this.state.generateId();
|
|
234
|
+
const tracked = this.state.createObject(globalId, sighting, isEntryPoint);
|
|
235
|
+
|
|
236
|
+
this.console.log(
|
|
237
|
+
`New ${sighting.detection.className} detected on ${sighting.cameraName} ` +
|
|
238
|
+
`(ID: ${globalId.slice(0, 8)})`
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
// Generate entry alert if this is an entry point
|
|
242
|
+
if (isEntryPoint) {
|
|
243
|
+
await this.alertManager.checkAndAlert('property_entry', tracked, {
|
|
244
|
+
cameraId: sighting.cameraId,
|
|
245
|
+
cameraName: sighting.cameraName,
|
|
246
|
+
objectClass: sighting.detection.className,
|
|
247
|
+
objectLabel: sighting.detection.label,
|
|
248
|
+
detectionId: sighting.detectionId,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Attempt to correlate a sighting with existing tracked objects */
|
|
255
|
+
private async correlateDetection(
|
|
256
|
+
sighting: ObjectSighting
|
|
257
|
+
): Promise<CorrelationCandidate | null> {
|
|
258
|
+
const activeObjects = this.state.getActiveObjects();
|
|
259
|
+
if (activeObjects.length === 0) return null;
|
|
260
|
+
|
|
261
|
+
// First, check for same-camera tracking (using detection ID)
|
|
262
|
+
for (const tracked of activeObjects) {
|
|
263
|
+
const lastSighting = getLastSighting(tracked);
|
|
264
|
+
if (lastSighting &&
|
|
265
|
+
lastSighting.cameraId === sighting.cameraId &&
|
|
266
|
+
lastSighting.detection.id === sighting.detection.id) {
|
|
267
|
+
// Same object on same camera (continuing track)
|
|
268
|
+
return {
|
|
269
|
+
trackedObject: tracked,
|
|
270
|
+
newSighting: sighting,
|
|
271
|
+
confidence: 1.0,
|
|
272
|
+
factors: { timing: 1, visual: 1, spatial: 1, class: 1 },
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Check for cross-camera correlation
|
|
278
|
+
const candidate = await this.correlator.findBestMatch(sighting, activeObjects);
|
|
279
|
+
return candidate;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Check if a detection is leaving the camera frame */
|
|
283
|
+
private isLeavingFrame(sighting: ObjectSighting): boolean {
|
|
284
|
+
if (!sighting.position) return false;
|
|
285
|
+
|
|
286
|
+
// Consider leaving if near edge of frame
|
|
287
|
+
const edgeThreshold = 0.1; // 10% from edge
|
|
288
|
+
return (
|
|
289
|
+
sighting.position.x < edgeThreshold ||
|
|
290
|
+
sighting.position.x > (1 - edgeThreshold) ||
|
|
291
|
+
sighting.position.y < edgeThreshold ||
|
|
292
|
+
sighting.position.y > (1 - edgeThreshold)
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Handle potential exit from property */
|
|
297
|
+
private handlePotentialExit(tracked: TrackedObject, sighting: ObjectSighting): void {
|
|
298
|
+
// Mark as pending and set timer
|
|
299
|
+
this.state.markPending(tracked.globalId);
|
|
300
|
+
|
|
301
|
+
// Wait for correlation window before marking as exited
|
|
302
|
+
const timer = setTimeout(async () => {
|
|
303
|
+
const current = this.state.getObject(tracked.globalId);
|
|
304
|
+
if (current && current.state === 'pending') {
|
|
305
|
+
this.state.markExited(tracked.globalId, sighting.cameraId, sighting.cameraName);
|
|
306
|
+
|
|
307
|
+
this.console.log(
|
|
308
|
+
`Object ${tracked.globalId.slice(0, 8)} exited via ${sighting.cameraName}`
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
await this.alertManager.checkAndAlert('property_exit', current, {
|
|
312
|
+
cameraId: sighting.cameraId,
|
|
313
|
+
cameraName: sighting.cameraName,
|
|
314
|
+
objectClass: current.className,
|
|
315
|
+
objectLabel: current.label,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
this.pendingTimers.delete(tracked.globalId);
|
|
319
|
+
}, this.config.correlationWindow);
|
|
320
|
+
|
|
321
|
+
this.pendingTimers.set(tracked.globalId, timer);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Check for objects that haven't been seen recently */
|
|
325
|
+
private checkForLostObjects(): void {
|
|
326
|
+
const now = Date.now();
|
|
327
|
+
const activeObjects = this.state.getActiveObjects();
|
|
328
|
+
|
|
329
|
+
for (const tracked of activeObjects) {
|
|
330
|
+
const timeSinceSeen = now - tracked.lastSeen;
|
|
331
|
+
|
|
332
|
+
if (timeSinceSeen > this.config.lostTimeout) {
|
|
333
|
+
this.state.markLost(tracked.globalId);
|
|
334
|
+
this.console.log(
|
|
335
|
+
`Object ${tracked.globalId.slice(0, 8)} marked as lost ` +
|
|
336
|
+
`(not seen for ${Math.round(timeSinceSeen / 1000)}s)`
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
this.alertManager.checkAndAlert('lost_tracking', tracked, {
|
|
340
|
+
objectClass: tracked.className,
|
|
341
|
+
objectLabel: tracked.label,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Update topology configuration */
|
|
348
|
+
updateTopology(topology: CameraTopology): void {
|
|
349
|
+
this.topology = topology;
|
|
350
|
+
this.correlator = new ObjectCorrelator(topology, this.config);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Get current topology */
|
|
354
|
+
getTopology(): CameraTopology {
|
|
355
|
+
return this.topology;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Get all currently tracked objects */
|
|
359
|
+
getTrackedObjects(): TrackedObject[] {
|
|
360
|
+
return this.state.getAllObjects();
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Get tracked object by ID */
|
|
364
|
+
getTrackedObject(globalId: GlobalTrackingId): TrackedObject | undefined {
|
|
365
|
+
return this.state.getObject(globalId);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global Tracker Sensor
|
|
3
|
+
* Main hub device that provides property-wide occupancy tracking
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
OccupancySensor,
|
|
8
|
+
ScryptedDeviceBase,
|
|
9
|
+
Settings,
|
|
10
|
+
Setting,
|
|
11
|
+
Readme,
|
|
12
|
+
ScryptedNativeId,
|
|
13
|
+
} from '@scrypted/sdk';
|
|
14
|
+
import { TrackingState } from '../state/tracking-state';
|
|
15
|
+
import { TrackedObject, getJourneySummary, calculateDwellTime } from '../models/tracked-object';
|
|
16
|
+
|
|
17
|
+
export class GlobalTrackerSensor extends ScryptedDeviceBase
|
|
18
|
+
implements OccupancySensor, Settings, Readme {
|
|
19
|
+
|
|
20
|
+
private trackingState: TrackingState;
|
|
21
|
+
private plugin: any;
|
|
22
|
+
|
|
23
|
+
constructor(plugin: any, nativeId: ScryptedNativeId, trackingState: TrackingState) {
|
|
24
|
+
super(nativeId);
|
|
25
|
+
this.plugin = plugin;
|
|
26
|
+
this.trackingState = trackingState;
|
|
27
|
+
|
|
28
|
+
// Update occupancy when tracking state changes
|
|
29
|
+
trackingState.onStateChange(() => this.updateOccupancy());
|
|
30
|
+
|
|
31
|
+
// Initial update
|
|
32
|
+
this.updateOccupancy();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Update the occupied state based on active tracked objects
|
|
37
|
+
*/
|
|
38
|
+
private updateOccupancy(): void {
|
|
39
|
+
const activeCount = this.trackingState.getActiveCount();
|
|
40
|
+
this.occupied = activeCount > 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ==================== Settings Implementation ====================
|
|
44
|
+
|
|
45
|
+
async getSettings(): Promise<Setting[]> {
|
|
46
|
+
const active = this.trackingState.getActiveObjects();
|
|
47
|
+
const all = this.trackingState.getAllObjects();
|
|
48
|
+
const settings: Setting[] = [];
|
|
49
|
+
|
|
50
|
+
// Summary stats
|
|
51
|
+
settings.push({
|
|
52
|
+
key: 'activeCount',
|
|
53
|
+
title: 'Currently Active',
|
|
54
|
+
type: 'string',
|
|
55
|
+
readonly: true,
|
|
56
|
+
value: `${active.length} object${active.length !== 1 ? 's' : ''}`,
|
|
57
|
+
group: 'Status',
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
settings.push({
|
|
61
|
+
key: 'totalTracked',
|
|
62
|
+
title: 'Total Tracked (24h)',
|
|
63
|
+
type: 'string',
|
|
64
|
+
readonly: true,
|
|
65
|
+
value: `${all.length} object${all.length !== 1 ? 's' : ''}`,
|
|
66
|
+
group: 'Status',
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Active objects list
|
|
70
|
+
if (active.length > 0) {
|
|
71
|
+
settings.push({
|
|
72
|
+
key: 'activeObjectsHeader',
|
|
73
|
+
title: 'Active Objects',
|
|
74
|
+
type: 'html',
|
|
75
|
+
value: '<h4 style="margin: 0;">Currently Tracked</h4>',
|
|
76
|
+
group: 'Active Objects',
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
for (const obj of active.slice(0, 10)) {
|
|
80
|
+
const dwellTime = calculateDwellTime(obj);
|
|
81
|
+
const dwellMinutes = Math.round(dwellTime / 60000);
|
|
82
|
+
|
|
83
|
+
settings.push({
|
|
84
|
+
key: `active-${obj.globalId}`,
|
|
85
|
+
title: `${obj.className}${obj.label ? ` (${obj.label})` : ''}`,
|
|
86
|
+
type: 'string',
|
|
87
|
+
readonly: true,
|
|
88
|
+
value: `Cameras: ${obj.activeOnCameras.join(', ')} | Time: ${dwellMinutes}m`,
|
|
89
|
+
group: 'Active Objects',
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (active.length > 10) {
|
|
94
|
+
settings.push({
|
|
95
|
+
key: 'moreActive',
|
|
96
|
+
title: 'More',
|
|
97
|
+
type: 'string',
|
|
98
|
+
readonly: true,
|
|
99
|
+
value: `...and ${active.length - 10} more`,
|
|
100
|
+
group: 'Active Objects',
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Recent exits
|
|
106
|
+
const recentExits = all
|
|
107
|
+
.filter(o => o.state === 'exited')
|
|
108
|
+
.sort((a, b) => b.lastSeen - a.lastSeen)
|
|
109
|
+
.slice(0, 5);
|
|
110
|
+
|
|
111
|
+
if (recentExits.length > 0) {
|
|
112
|
+
settings.push({
|
|
113
|
+
key: 'recentExitsHeader',
|
|
114
|
+
title: 'Recent Exits',
|
|
115
|
+
type: 'html',
|
|
116
|
+
value: '<h4 style="margin: 0;">Recently Exited</h4>',
|
|
117
|
+
group: 'Recent Activity',
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
for (const obj of recentExits) {
|
|
121
|
+
const exitTime = new Date(obj.lastSeen).toLocaleTimeString();
|
|
122
|
+
const journey = getJourneySummary(obj);
|
|
123
|
+
|
|
124
|
+
settings.push({
|
|
125
|
+
key: `exit-${obj.globalId}`,
|
|
126
|
+
title: `${obj.className} at ${exitTime}`,
|
|
127
|
+
type: 'string',
|
|
128
|
+
readonly: true,
|
|
129
|
+
value: journey || 'No journey recorded',
|
|
130
|
+
group: 'Recent Activity',
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return settings;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async putSetting(key: string, value: any): Promise<void> {
|
|
139
|
+
// No editable settings
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ==================== Readme Implementation ====================
|
|
143
|
+
|
|
144
|
+
async getReadmeMarkdown(): Promise<string> {
|
|
145
|
+
const active = this.trackingState.getActiveObjects();
|
|
146
|
+
const all = this.trackingState.getAllObjects();
|
|
147
|
+
|
|
148
|
+
// Generate stats
|
|
149
|
+
const personCount = active.filter(o => o.className === 'person').length;
|
|
150
|
+
const vehicleCount = active.filter(o => ['car', 'vehicle', 'truck'].includes(o.className)).length;
|
|
151
|
+
const animalCount = active.filter(o => o.className === 'animal').length;
|
|
152
|
+
|
|
153
|
+
let activeBreakdown = '';
|
|
154
|
+
if (personCount > 0) activeBreakdown += `- People: ${personCount}\n`;
|
|
155
|
+
if (vehicleCount > 0) activeBreakdown += `- Vehicles: ${vehicleCount}\n`;
|
|
156
|
+
if (animalCount > 0) activeBreakdown += `- Animals: ${animalCount}\n`;
|
|
157
|
+
|
|
158
|
+
return `
|
|
159
|
+
# Global Object Tracker
|
|
160
|
+
|
|
161
|
+
This sensor tracks the presence of objects across all connected cameras in your property.
|
|
162
|
+
|
|
163
|
+
## Current Status
|
|
164
|
+
|
|
165
|
+
**Occupied**: ${this.occupied ? 'Yes' : 'No'}
|
|
166
|
+
|
|
167
|
+
**Active Objects**: ${active.length}
|
|
168
|
+
${activeBreakdown || '- None currently'}
|
|
169
|
+
|
|
170
|
+
**Total Tracked (24h)**: ${all.length}
|
|
171
|
+
|
|
172
|
+
## How It Works
|
|
173
|
+
|
|
174
|
+
The Global Tracker combines detection events from all configured cameras to maintain a unified view of objects on your property. When an object moves from one camera's view to another, the system correlates these sightings to track the object's journey.
|
|
175
|
+
|
|
176
|
+
## States
|
|
177
|
+
|
|
178
|
+
- **Active**: Object is currently visible on camera
|
|
179
|
+
- **Pending**: Object left camera view, waiting for correlation
|
|
180
|
+
- **Exited**: Object left the property
|
|
181
|
+
- **Lost**: Object disappeared without exiting (timeout)
|
|
182
|
+
|
|
183
|
+
## Integration
|
|
184
|
+
|
|
185
|
+
This sensor can be used in automations:
|
|
186
|
+
- Trigger actions when property becomes occupied/unoccupied
|
|
187
|
+
- Create presence-based automations
|
|
188
|
+
- Monitor overall property activity
|
|
189
|
+
`;
|
|
190
|
+
}
|
|
191
|
+
}
|