@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.
@@ -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
+ }