@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,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tracking Zone Device
|
|
3
|
+
* Virtual sensor for monitoring specific areas across cameras
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
MotionSensor,
|
|
8
|
+
OccupancySensor,
|
|
9
|
+
ScryptedDeviceBase,
|
|
10
|
+
Settings,
|
|
11
|
+
Setting,
|
|
12
|
+
SettingValue,
|
|
13
|
+
ScryptedNativeId,
|
|
14
|
+
ScryptedInterface,
|
|
15
|
+
} from '@scrypted/sdk';
|
|
16
|
+
import { StorageSettings } from '@scrypted/sdk/storage-settings';
|
|
17
|
+
import { TrackingState } from '../state/tracking-state';
|
|
18
|
+
import { TrackedObject } from '../models/tracked-object';
|
|
19
|
+
import { GlobalZoneType } from '../models/topology';
|
|
20
|
+
|
|
21
|
+
export interface TrackingZoneConfig {
|
|
22
|
+
type: GlobalZoneType;
|
|
23
|
+
cameras: string[];
|
|
24
|
+
dwellThreshold?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class TrackingZone extends ScryptedDeviceBase
|
|
28
|
+
implements MotionSensor, OccupancySensor, Settings {
|
|
29
|
+
|
|
30
|
+
private trackingState: TrackingState;
|
|
31
|
+
private plugin: any;
|
|
32
|
+
private config: TrackingZoneConfig = {
|
|
33
|
+
type: 'entry',
|
|
34
|
+
cameras: [],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
storageSettings = new StorageSettings(this, {
|
|
38
|
+
zoneType: {
|
|
39
|
+
title: 'Zone Type',
|
|
40
|
+
type: 'string',
|
|
41
|
+
choices: ['entry', 'exit', 'dwell', 'restricted'],
|
|
42
|
+
defaultValue: 'entry',
|
|
43
|
+
description: 'Type of zone for alerting purposes',
|
|
44
|
+
},
|
|
45
|
+
cameras: {
|
|
46
|
+
title: 'Cameras',
|
|
47
|
+
type: 'device',
|
|
48
|
+
multiple: true,
|
|
49
|
+
deviceFilter: `interfaces.includes('${ScryptedInterface.ObjectDetector}')`,
|
|
50
|
+
description: 'Cameras that make up this zone',
|
|
51
|
+
},
|
|
52
|
+
dwellThreshold: {
|
|
53
|
+
title: 'Dwell Time Threshold (seconds)',
|
|
54
|
+
type: 'number',
|
|
55
|
+
defaultValue: 60,
|
|
56
|
+
description: 'For dwell zones: alert if object stays longer than this',
|
|
57
|
+
},
|
|
58
|
+
trackClasses: {
|
|
59
|
+
title: 'Track Object Types',
|
|
60
|
+
type: 'string',
|
|
61
|
+
multiple: true,
|
|
62
|
+
choices: ['person', 'car', 'vehicle', 'animal', 'package'],
|
|
63
|
+
description: 'Object types to monitor in this zone (empty = all)',
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
constructor(
|
|
68
|
+
plugin: any,
|
|
69
|
+
nativeId: ScryptedNativeId,
|
|
70
|
+
trackingState: TrackingState
|
|
71
|
+
) {
|
|
72
|
+
super(nativeId);
|
|
73
|
+
this.plugin = plugin;
|
|
74
|
+
this.trackingState = trackingState;
|
|
75
|
+
|
|
76
|
+
// Load config
|
|
77
|
+
this.loadConfig();
|
|
78
|
+
|
|
79
|
+
// Listen for state changes
|
|
80
|
+
trackingState.onStateChange(() => this.evaluateZone());
|
|
81
|
+
|
|
82
|
+
// Initial evaluation
|
|
83
|
+
this.evaluateZone();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Load configuration from storage
|
|
88
|
+
*/
|
|
89
|
+
private loadConfig(): void {
|
|
90
|
+
try {
|
|
91
|
+
const configJson = this.storage.getItem('zoneConfig');
|
|
92
|
+
if (configJson) {
|
|
93
|
+
this.config = JSON.parse(configJson);
|
|
94
|
+
}
|
|
95
|
+
} catch (e) {
|
|
96
|
+
this.console.error('Failed to load zone config:', e);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Save configuration to storage
|
|
102
|
+
*/
|
|
103
|
+
private saveConfig(): void {
|
|
104
|
+
try {
|
|
105
|
+
this.storage.setItem('zoneConfig', JSON.stringify(this.config));
|
|
106
|
+
} catch (e) {
|
|
107
|
+
this.console.error('Failed to save zone config:', e);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Configure the zone programmatically
|
|
113
|
+
*/
|
|
114
|
+
configure(config: Partial<TrackingZoneConfig>): void {
|
|
115
|
+
this.config = { ...this.config, ...config };
|
|
116
|
+
this.saveConfig();
|
|
117
|
+
this.evaluateZone();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Evaluate the zone state based on current tracking data
|
|
122
|
+
*/
|
|
123
|
+
private evaluateZone(): void {
|
|
124
|
+
const cameras = this.getCameraIds();
|
|
125
|
+
if (cameras.length === 0) {
|
|
126
|
+
this.occupied = false;
|
|
127
|
+
this.motionDetected = false;
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const trackClasses = this.storageSettings.values.trackClasses as string[] || [];
|
|
132
|
+
let hasObject = false;
|
|
133
|
+
let hasMovement = false;
|
|
134
|
+
const now = Date.now();
|
|
135
|
+
|
|
136
|
+
for (const cameraId of cameras) {
|
|
137
|
+
const objects = this.trackingState.getObjectsOnCamera(cameraId);
|
|
138
|
+
|
|
139
|
+
for (const obj of objects) {
|
|
140
|
+
// Filter by class if specified
|
|
141
|
+
if (trackClasses.length > 0 && !trackClasses.includes(obj.className)) {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
hasObject = true;
|
|
146
|
+
|
|
147
|
+
// Check for recent movement
|
|
148
|
+
const recentSightings = obj.sightings.filter(
|
|
149
|
+
s => s.cameraId === cameraId && now - s.timestamp < 5000
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
if (recentSightings.some(s => s.detection.movement?.moving)) {
|
|
153
|
+
hasMovement = true;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
this.occupied = hasObject;
|
|
159
|
+
this.motionDetected = hasMovement;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Get camera IDs from settings
|
|
164
|
+
*/
|
|
165
|
+
private getCameraIds(): string[] {
|
|
166
|
+
const cameras = this.storageSettings.values.cameras;
|
|
167
|
+
if (Array.isArray(cameras)) {
|
|
168
|
+
return cameras as string[];
|
|
169
|
+
}
|
|
170
|
+
if (cameras) {
|
|
171
|
+
return [cameras as string];
|
|
172
|
+
}
|
|
173
|
+
return this.config.cameras || [];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Get objects currently in this zone
|
|
178
|
+
*/
|
|
179
|
+
getObjectsInZone(): TrackedObject[] {
|
|
180
|
+
const cameras = this.getCameraIds();
|
|
181
|
+
const trackClasses = this.storageSettings.values.trackClasses as string[] || [];
|
|
182
|
+
const objects: TrackedObject[] = [];
|
|
183
|
+
const seen = new Set<string>();
|
|
184
|
+
|
|
185
|
+
for (const cameraId of cameras) {
|
|
186
|
+
for (const obj of this.trackingState.getObjectsOnCamera(cameraId)) {
|
|
187
|
+
if (seen.has(obj.globalId)) continue;
|
|
188
|
+
seen.add(obj.globalId);
|
|
189
|
+
|
|
190
|
+
if (trackClasses.length === 0 || trackClasses.includes(obj.className)) {
|
|
191
|
+
objects.push(obj);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return objects;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ==================== Settings Implementation ====================
|
|
200
|
+
|
|
201
|
+
async getSettings(): Promise<Setting[]> {
|
|
202
|
+
const settings = await this.storageSettings.getSettings();
|
|
203
|
+
|
|
204
|
+
// Add current status
|
|
205
|
+
const objectsInZone = this.getObjectsInZone();
|
|
206
|
+
|
|
207
|
+
settings.push({
|
|
208
|
+
key: 'currentStatus',
|
|
209
|
+
title: 'Current Status',
|
|
210
|
+
type: 'string',
|
|
211
|
+
readonly: true,
|
|
212
|
+
value: this.occupied
|
|
213
|
+
? `Occupied: ${objectsInZone.length} object${objectsInZone.length !== 1 ? 's' : ''}`
|
|
214
|
+
: 'Empty',
|
|
215
|
+
group: 'Status',
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
if (objectsInZone.length > 0) {
|
|
219
|
+
settings.push({
|
|
220
|
+
key: 'objectsList',
|
|
221
|
+
title: 'Objects in Zone',
|
|
222
|
+
type: 'string',
|
|
223
|
+
readonly: true,
|
|
224
|
+
value: objectsInZone
|
|
225
|
+
.map(o => `${o.className}${o.label ? ` (${o.label})` : ''}`)
|
|
226
|
+
.join(', '),
|
|
227
|
+
group: 'Status',
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return settings;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async putSetting(key: string, value: SettingValue): Promise<void> {
|
|
235
|
+
await this.storageSettings.putSetting(key, value);
|
|
236
|
+
|
|
237
|
+
// Update config based on settings
|
|
238
|
+
this.config.type = this.storageSettings.values.zoneType as GlobalZoneType || 'entry';
|
|
239
|
+
this.config.cameras = this.getCameraIds();
|
|
240
|
+
this.config.dwellThreshold = (this.storageSettings.values.dwellThreshold as number || 60) * 1000;
|
|
241
|
+
|
|
242
|
+
this.saveConfig();
|
|
243
|
+
this.evaluateZone();
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MQTT Publisher
|
|
3
|
+
* Publishes tracking state and alerts to MQTT for Home Assistant integration
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import mqtt, { MqttClient, IClientOptions } from 'mqtt';
|
|
7
|
+
import { TrackedObject } from '../models/tracked-object';
|
|
8
|
+
import { Alert } from '../models/alert';
|
|
9
|
+
|
|
10
|
+
export interface MqttConfig {
|
|
11
|
+
broker: string;
|
|
12
|
+
username?: string;
|
|
13
|
+
password?: string;
|
|
14
|
+
baseTopic: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class MqttPublisher {
|
|
18
|
+
private client: MqttClient | null = null;
|
|
19
|
+
private config: MqttConfig;
|
|
20
|
+
private console: Console;
|
|
21
|
+
private connected: boolean = false;
|
|
22
|
+
private reconnectTimer: NodeJS.Timeout | null = null;
|
|
23
|
+
|
|
24
|
+
constructor(config: MqttConfig, console: Console) {
|
|
25
|
+
this.config = config;
|
|
26
|
+
this.console = console;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Connect to MQTT broker
|
|
31
|
+
*/
|
|
32
|
+
async connect(): Promise<void> {
|
|
33
|
+
if (this.client) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const options: IClientOptions = {
|
|
38
|
+
clientId: `scrypted-spatial-awareness-${Date.now()}`,
|
|
39
|
+
clean: true,
|
|
40
|
+
connectTimeout: 10000,
|
|
41
|
+
reconnectPeriod: 5000,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
if (this.config.username) {
|
|
45
|
+
options.username = this.config.username;
|
|
46
|
+
options.password = this.config.password;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
this.client = mqtt.connect(this.config.broker, options);
|
|
51
|
+
|
|
52
|
+
this.client.on('connect', () => {
|
|
53
|
+
this.connected = true;
|
|
54
|
+
this.console.log(`MQTT connected to ${this.config.broker}`);
|
|
55
|
+
this.publishDiscovery();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
this.client.on('error', (error) => {
|
|
59
|
+
this.console.error('MQTT error:', error);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
this.client.on('close', () => {
|
|
63
|
+
this.connected = false;
|
|
64
|
+
this.console.log('MQTT connection closed');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
this.client.on('offline', () => {
|
|
68
|
+
this.connected = false;
|
|
69
|
+
this.console.log('MQTT offline');
|
|
70
|
+
});
|
|
71
|
+
} catch (e) {
|
|
72
|
+
this.console.error('Failed to connect to MQTT:', e);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Disconnect from MQTT broker
|
|
78
|
+
*/
|
|
79
|
+
disconnect(): void {
|
|
80
|
+
if (this.reconnectTimer) {
|
|
81
|
+
clearTimeout(this.reconnectTimer);
|
|
82
|
+
this.reconnectTimer = null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (this.client) {
|
|
86
|
+
this.client.end();
|
|
87
|
+
this.client = null;
|
|
88
|
+
this.connected = false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Publish Home Assistant MQTT discovery messages
|
|
94
|
+
*/
|
|
95
|
+
private publishDiscovery(): void {
|
|
96
|
+
if (!this.client || !this.connected) return;
|
|
97
|
+
|
|
98
|
+
const baseTopic = this.config.baseTopic;
|
|
99
|
+
|
|
100
|
+
// Occupancy sensor for property
|
|
101
|
+
const occupancyConfig = {
|
|
102
|
+
name: 'Property Occupancy',
|
|
103
|
+
unique_id: 'spatial_awareness_occupancy',
|
|
104
|
+
state_topic: `${baseTopic}/occupancy/state`,
|
|
105
|
+
device_class: 'occupancy',
|
|
106
|
+
payload_on: 'ON',
|
|
107
|
+
payload_off: 'OFF',
|
|
108
|
+
device: {
|
|
109
|
+
identifiers: ['spatial_awareness'],
|
|
110
|
+
name: 'Spatial Awareness',
|
|
111
|
+
model: 'Cross-Camera Tracker',
|
|
112
|
+
manufacturer: 'Scrypted',
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
this.client.publish(
|
|
117
|
+
`homeassistant/binary_sensor/spatial_awareness_occupancy/config`,
|
|
118
|
+
JSON.stringify(occupancyConfig),
|
|
119
|
+
{ retain: true }
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
// Active count sensor
|
|
123
|
+
const countConfig = {
|
|
124
|
+
name: 'Active Tracked Objects',
|
|
125
|
+
unique_id: 'spatial_awareness_count',
|
|
126
|
+
state_topic: `${baseTopic}/count/state`,
|
|
127
|
+
icon: 'mdi:account-multiple',
|
|
128
|
+
device: {
|
|
129
|
+
identifiers: ['spatial_awareness'],
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
this.client.publish(
|
|
134
|
+
`homeassistant/sensor/spatial_awareness_count/config`,
|
|
135
|
+
JSON.stringify(countConfig),
|
|
136
|
+
{ retain: true }
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
// Person count sensor
|
|
140
|
+
const personCountConfig = {
|
|
141
|
+
name: 'People on Property',
|
|
142
|
+
unique_id: 'spatial_awareness_person_count',
|
|
143
|
+
state_topic: `${baseTopic}/person_count/state`,
|
|
144
|
+
icon: 'mdi:account-group',
|
|
145
|
+
device: {
|
|
146
|
+
identifiers: ['spatial_awareness'],
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
this.client.publish(
|
|
151
|
+
`homeassistant/sensor/spatial_awareness_person_count/config`,
|
|
152
|
+
JSON.stringify(personCountConfig),
|
|
153
|
+
{ retain: true }
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
// Vehicle count sensor
|
|
157
|
+
const vehicleCountConfig = {
|
|
158
|
+
name: 'Vehicles on Property',
|
|
159
|
+
unique_id: 'spatial_awareness_vehicle_count',
|
|
160
|
+
state_topic: `${baseTopic}/vehicle_count/state`,
|
|
161
|
+
icon: 'mdi:car',
|
|
162
|
+
device: {
|
|
163
|
+
identifiers: ['spatial_awareness'],
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
this.client.publish(
|
|
168
|
+
`homeassistant/sensor/spatial_awareness_vehicle_count/config`,
|
|
169
|
+
JSON.stringify(vehicleCountConfig),
|
|
170
|
+
{ retain: true }
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
this.console.log('MQTT discovery published');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Publish current tracking state
|
|
178
|
+
*/
|
|
179
|
+
publishState(objects: TrackedObject[]): void {
|
|
180
|
+
if (!this.client || !this.connected) return;
|
|
181
|
+
|
|
182
|
+
const baseTopic = this.config.baseTopic;
|
|
183
|
+
const activeObjects = objects.filter(o => o.state === 'active' || o.state === 'pending');
|
|
184
|
+
|
|
185
|
+
// Occupancy
|
|
186
|
+
const occupied = activeObjects.length > 0;
|
|
187
|
+
this.client.publish(`${baseTopic}/occupancy/state`, occupied ? 'ON' : 'OFF', { retain: true });
|
|
188
|
+
|
|
189
|
+
// Counts
|
|
190
|
+
this.client.publish(`${baseTopic}/count/state`, String(activeObjects.length), { retain: true });
|
|
191
|
+
|
|
192
|
+
const personCount = activeObjects.filter(o => o.className === 'person').length;
|
|
193
|
+
this.client.publish(`${baseTopic}/person_count/state`, String(personCount), { retain: true });
|
|
194
|
+
|
|
195
|
+
const vehicleCount = activeObjects.filter(o =>
|
|
196
|
+
['car', 'vehicle', 'truck'].includes(o.className)
|
|
197
|
+
).length;
|
|
198
|
+
this.client.publish(`${baseTopic}/vehicle_count/state`, String(vehicleCount), { retain: true });
|
|
199
|
+
|
|
200
|
+
// Full state JSON
|
|
201
|
+
const statePayload = {
|
|
202
|
+
timestamp: Date.now(),
|
|
203
|
+
occupied,
|
|
204
|
+
activeCount: activeObjects.length,
|
|
205
|
+
personCount,
|
|
206
|
+
vehicleCount,
|
|
207
|
+
objects: activeObjects.map(o => ({
|
|
208
|
+
id: o.globalId,
|
|
209
|
+
class: o.className,
|
|
210
|
+
label: o.label,
|
|
211
|
+
cameras: o.activeOnCameras,
|
|
212
|
+
firstSeen: o.firstSeen,
|
|
213
|
+
lastSeen: o.lastSeen,
|
|
214
|
+
state: o.state,
|
|
215
|
+
})),
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
this.client.publish(`${baseTopic}/state`, JSON.stringify(statePayload), { retain: true });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Publish an alert
|
|
223
|
+
*/
|
|
224
|
+
publishAlert(alert: Alert): void {
|
|
225
|
+
if (!this.client || !this.connected) return;
|
|
226
|
+
|
|
227
|
+
const baseTopic = this.config.baseTopic;
|
|
228
|
+
|
|
229
|
+
const alertPayload = {
|
|
230
|
+
id: alert.id,
|
|
231
|
+
type: alert.type,
|
|
232
|
+
severity: alert.severity,
|
|
233
|
+
message: alert.message,
|
|
234
|
+
timestamp: alert.timestamp,
|
|
235
|
+
objectId: alert.trackedObjectId,
|
|
236
|
+
details: alert.details,
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
// Publish to alerts topic
|
|
240
|
+
this.client.publish(`${baseTopic}/alerts`, JSON.stringify(alertPayload));
|
|
241
|
+
|
|
242
|
+
// Also publish to type-specific topic
|
|
243
|
+
this.client.publish(`${baseTopic}/alerts/${alert.type}`, JSON.stringify(alertPayload));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Publish object entry event
|
|
248
|
+
*/
|
|
249
|
+
publishEntry(object: TrackedObject, cameraName: string): void {
|
|
250
|
+
if (!this.client || !this.connected) return;
|
|
251
|
+
|
|
252
|
+
const baseTopic = this.config.baseTopic;
|
|
253
|
+
|
|
254
|
+
const payload = {
|
|
255
|
+
event: 'entry',
|
|
256
|
+
timestamp: Date.now(),
|
|
257
|
+
object: {
|
|
258
|
+
id: object.globalId,
|
|
259
|
+
class: object.className,
|
|
260
|
+
label: object.label,
|
|
261
|
+
},
|
|
262
|
+
camera: cameraName,
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
this.client.publish(`${baseTopic}/events/entry`, JSON.stringify(payload));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Publish object exit event
|
|
270
|
+
*/
|
|
271
|
+
publishExit(object: TrackedObject, cameraName: string): void {
|
|
272
|
+
if (!this.client || !this.connected) return;
|
|
273
|
+
|
|
274
|
+
const baseTopic = this.config.baseTopic;
|
|
275
|
+
|
|
276
|
+
const payload = {
|
|
277
|
+
event: 'exit',
|
|
278
|
+
timestamp: Date.now(),
|
|
279
|
+
object: {
|
|
280
|
+
id: object.globalId,
|
|
281
|
+
class: object.className,
|
|
282
|
+
label: object.label,
|
|
283
|
+
dwellTime: object.lastSeen - object.firstSeen,
|
|
284
|
+
},
|
|
285
|
+
camera: cameraName,
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
this.client.publish(`${baseTopic}/events/exit`, JSON.stringify(payload));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Publish camera transition event
|
|
293
|
+
*/
|
|
294
|
+
publishTransition(object: TrackedObject, fromCamera: string, toCamera: string): void {
|
|
295
|
+
if (!this.client || !this.connected) return;
|
|
296
|
+
|
|
297
|
+
const baseTopic = this.config.baseTopic;
|
|
298
|
+
|
|
299
|
+
const payload = {
|
|
300
|
+
event: 'transition',
|
|
301
|
+
timestamp: Date.now(),
|
|
302
|
+
object: {
|
|
303
|
+
id: object.globalId,
|
|
304
|
+
class: object.className,
|
|
305
|
+
label: object.label,
|
|
306
|
+
},
|
|
307
|
+
from: fromCamera,
|
|
308
|
+
to: toCamera,
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
this.client.publish(`${baseTopic}/events/transition`, JSON.stringify(payload));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Check if connected
|
|
316
|
+
*/
|
|
317
|
+
isConnected(): boolean {
|
|
318
|
+
return this.connected;
|
|
319
|
+
}
|
|
320
|
+
}
|