@openmdm/plugin-geofence 0.2.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/LICENSE +21 -0
- package/dist/index.d.ts +199 -0
- package/dist/index.js +519 -0
- package/dist/index.js.map +1 -0
- package/package.json +54 -0
- package/src/index.ts +911 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,911 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenMDM Geofencing Plugin
|
|
3
|
+
*
|
|
4
|
+
* Provides location-based policy enforcement and monitoring.
|
|
5
|
+
* Supports circular and polygon geofence zones with enter/exit actions.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* import { createMDM } from '@openmdm/core';
|
|
10
|
+
* import { geofencePlugin } from '@openmdm/plugin-geofence';
|
|
11
|
+
*
|
|
12
|
+
* const mdm = createMDM({
|
|
13
|
+
* database: drizzleAdapter(db),
|
|
14
|
+
* plugins: [
|
|
15
|
+
* geofencePlugin({
|
|
16
|
+
* onEnter: async (device, zone) => {
|
|
17
|
+
* console.log(`Device ${device.id} entered ${zone.name}`);
|
|
18
|
+
* },
|
|
19
|
+
* onExit: async (device, zone) => {
|
|
20
|
+
* console.log(`Device ${device.id} left ${zone.name}`);
|
|
21
|
+
* },
|
|
22
|
+
* }),
|
|
23
|
+
* ],
|
|
24
|
+
* });
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type {
|
|
29
|
+
MDMPlugin,
|
|
30
|
+
MDMInstance,
|
|
31
|
+
Device,
|
|
32
|
+
DeviceLocation,
|
|
33
|
+
Heartbeat,
|
|
34
|
+
PluginRoute,
|
|
35
|
+
} from '@openmdm/core';
|
|
36
|
+
|
|
37
|
+
// ============================================
|
|
38
|
+
// Geofence Types
|
|
39
|
+
// ============================================
|
|
40
|
+
|
|
41
|
+
export interface GeofencePluginOptions {
|
|
42
|
+
/**
|
|
43
|
+
* Callback when device enters a geofence zone
|
|
44
|
+
*/
|
|
45
|
+
onEnter?: (device: Device, zone: GeofenceZone) => Promise<void>;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Callback when device exits a geofence zone
|
|
49
|
+
*/
|
|
50
|
+
onExit?: (device: Device, zone: GeofenceZone) => Promise<void>;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Callback when device is inside a zone during heartbeat
|
|
54
|
+
*/
|
|
55
|
+
onInside?: (device: Device, zone: GeofenceZone) => Promise<void>;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Default dwell time (ms) before triggering enter event (default: 0)
|
|
59
|
+
*/
|
|
60
|
+
defaultDwellTime?: number;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Enable location history tracking (default: false)
|
|
64
|
+
*/
|
|
65
|
+
trackHistory?: boolean;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Maximum history entries per device (default: 1000)
|
|
69
|
+
*/
|
|
70
|
+
maxHistoryEntries?: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface GeofenceZone {
|
|
74
|
+
id: string;
|
|
75
|
+
name: string;
|
|
76
|
+
description?: string;
|
|
77
|
+
type: 'circle' | 'polygon';
|
|
78
|
+
enabled: boolean;
|
|
79
|
+
|
|
80
|
+
// Circle zone
|
|
81
|
+
center?: {
|
|
82
|
+
latitude: number;
|
|
83
|
+
longitude: number;
|
|
84
|
+
};
|
|
85
|
+
radius?: number; // meters
|
|
86
|
+
|
|
87
|
+
// Polygon zone
|
|
88
|
+
vertices?: Array<{
|
|
89
|
+
latitude: number;
|
|
90
|
+
longitude: number;
|
|
91
|
+
}>;
|
|
92
|
+
|
|
93
|
+
// Actions
|
|
94
|
+
onEnter?: GeofenceAction;
|
|
95
|
+
onExit?: GeofenceAction;
|
|
96
|
+
|
|
97
|
+
// Policy override when inside zone
|
|
98
|
+
policyOverride?: string;
|
|
99
|
+
|
|
100
|
+
// Scheduling
|
|
101
|
+
schedule?: GeofenceSchedule;
|
|
102
|
+
|
|
103
|
+
// Metadata
|
|
104
|
+
metadata?: Record<string, unknown>;
|
|
105
|
+
createdAt: Date;
|
|
106
|
+
updatedAt: Date;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface GeofenceAction {
|
|
110
|
+
/** Action type */
|
|
111
|
+
type: 'notify' | 'command' | 'policy' | 'webhook' | 'none';
|
|
112
|
+
|
|
113
|
+
/** Notification message */
|
|
114
|
+
notification?: {
|
|
115
|
+
title: string;
|
|
116
|
+
body: string;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/** Command to execute */
|
|
120
|
+
command?: {
|
|
121
|
+
type: string;
|
|
122
|
+
payload?: Record<string, unknown>;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** Policy to apply */
|
|
126
|
+
policyId?: string;
|
|
127
|
+
|
|
128
|
+
/** Webhook to call */
|
|
129
|
+
webhook?: {
|
|
130
|
+
url: string;
|
|
131
|
+
method?: 'GET' | 'POST';
|
|
132
|
+
headers?: Record<string, string>;
|
|
133
|
+
body?: Record<string, unknown>;
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface GeofenceSchedule {
|
|
138
|
+
/** Days of week (0=Sunday, 6=Saturday) */
|
|
139
|
+
daysOfWeek?: number[];
|
|
140
|
+
|
|
141
|
+
/** Start time (HH:mm) */
|
|
142
|
+
startTime?: string;
|
|
143
|
+
|
|
144
|
+
/** End time (HH:mm) */
|
|
145
|
+
endTime?: string;
|
|
146
|
+
|
|
147
|
+
/** Timezone (default: UTC) */
|
|
148
|
+
timezone?: string;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface DeviceZoneState {
|
|
152
|
+
deviceId: string;
|
|
153
|
+
zoneId: string;
|
|
154
|
+
inside: boolean;
|
|
155
|
+
enteredAt?: Date;
|
|
156
|
+
exitedAt?: Date;
|
|
157
|
+
dwellTime?: number;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface LocationHistoryEntry {
|
|
161
|
+
deviceId: string;
|
|
162
|
+
location: DeviceLocation;
|
|
163
|
+
zones: string[]; // Zone IDs device was inside
|
|
164
|
+
timestamp: Date;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface CreateGeofenceZoneInput {
|
|
168
|
+
name: string;
|
|
169
|
+
description?: string;
|
|
170
|
+
type: 'circle' | 'polygon';
|
|
171
|
+
enabled?: boolean;
|
|
172
|
+
center?: { latitude: number; longitude: number };
|
|
173
|
+
radius?: number;
|
|
174
|
+
vertices?: Array<{ latitude: number; longitude: number }>;
|
|
175
|
+
onEnter?: GeofenceAction;
|
|
176
|
+
onExit?: GeofenceAction;
|
|
177
|
+
policyOverride?: string;
|
|
178
|
+
schedule?: GeofenceSchedule;
|
|
179
|
+
metadata?: Record<string, unknown>;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface UpdateGeofenceZoneInput {
|
|
183
|
+
name?: string;
|
|
184
|
+
description?: string;
|
|
185
|
+
enabled?: boolean;
|
|
186
|
+
center?: { latitude: number; longitude: number };
|
|
187
|
+
radius?: number;
|
|
188
|
+
vertices?: Array<{ latitude: number; longitude: number }>;
|
|
189
|
+
onEnter?: GeofenceAction;
|
|
190
|
+
onExit?: GeofenceAction;
|
|
191
|
+
policyOverride?: string;
|
|
192
|
+
schedule?: GeofenceSchedule;
|
|
193
|
+
metadata?: Record<string, unknown>;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ============================================
|
|
197
|
+
// Geo Utilities
|
|
198
|
+
// ============================================
|
|
199
|
+
|
|
200
|
+
const EARTH_RADIUS_METERS = 6371000;
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Calculate distance between two points using Haversine formula
|
|
204
|
+
*/
|
|
205
|
+
function haversineDistance(
|
|
206
|
+
lat1: number,
|
|
207
|
+
lon1: number,
|
|
208
|
+
lat2: number,
|
|
209
|
+
lon2: number
|
|
210
|
+
): number {
|
|
211
|
+
const toRad = (deg: number) => (deg * Math.PI) / 180;
|
|
212
|
+
|
|
213
|
+
const dLat = toRad(lat2 - lat1);
|
|
214
|
+
const dLon = toRad(lon2 - lon1);
|
|
215
|
+
|
|
216
|
+
const a =
|
|
217
|
+
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
|
218
|
+
Math.cos(toRad(lat1)) *
|
|
219
|
+
Math.cos(toRad(lat2)) *
|
|
220
|
+
Math.sin(dLon / 2) *
|
|
221
|
+
Math.sin(dLon / 2);
|
|
222
|
+
|
|
223
|
+
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
224
|
+
|
|
225
|
+
return EARTH_RADIUS_METERS * c;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Check if point is inside circular zone
|
|
230
|
+
*/
|
|
231
|
+
function isInsideCircle(
|
|
232
|
+
point: { latitude: number; longitude: number },
|
|
233
|
+
center: { latitude: number; longitude: number },
|
|
234
|
+
radiusMeters: number
|
|
235
|
+
): boolean {
|
|
236
|
+
const distance = haversineDistance(
|
|
237
|
+
point.latitude,
|
|
238
|
+
point.longitude,
|
|
239
|
+
center.latitude,
|
|
240
|
+
center.longitude
|
|
241
|
+
);
|
|
242
|
+
return distance <= radiusMeters;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Check if point is inside polygon using ray casting algorithm
|
|
247
|
+
*/
|
|
248
|
+
function isInsidePolygon(
|
|
249
|
+
point: { latitude: number; longitude: number },
|
|
250
|
+
vertices: Array<{ latitude: number; longitude: number }>
|
|
251
|
+
): boolean {
|
|
252
|
+
if (vertices.length < 3) return false;
|
|
253
|
+
|
|
254
|
+
let inside = false;
|
|
255
|
+
const n = vertices.length;
|
|
256
|
+
|
|
257
|
+
for (let i = 0, j = n - 1; i < n; j = i++) {
|
|
258
|
+
const xi = vertices[i].longitude;
|
|
259
|
+
const yi = vertices[i].latitude;
|
|
260
|
+
const xj = vertices[j].longitude;
|
|
261
|
+
const yj = vertices[j].latitude;
|
|
262
|
+
|
|
263
|
+
const intersect =
|
|
264
|
+
yi > point.latitude !== yj > point.latitude &&
|
|
265
|
+
point.longitude < ((xj - xi) * (point.latitude - yi)) / (yj - yi) + xi;
|
|
266
|
+
|
|
267
|
+
if (intersect) inside = !inside;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return inside;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Check if zone is active according to schedule
|
|
275
|
+
*/
|
|
276
|
+
function isZoneScheduleActive(schedule: GeofenceSchedule | undefined): boolean {
|
|
277
|
+
if (!schedule) return true;
|
|
278
|
+
|
|
279
|
+
const now = new Date();
|
|
280
|
+
const tz = schedule.timezone || 'UTC';
|
|
281
|
+
|
|
282
|
+
// Get current time in zone's timezone
|
|
283
|
+
const formatter = new Intl.DateTimeFormat('en-US', {
|
|
284
|
+
timeZone: tz,
|
|
285
|
+
hour: '2-digit',
|
|
286
|
+
minute: '2-digit',
|
|
287
|
+
hour12: false,
|
|
288
|
+
weekday: 'short',
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
const parts = formatter.formatToParts(now);
|
|
292
|
+
const currentHour = parseInt(parts.find((p) => p.type === 'hour')?.value || '0');
|
|
293
|
+
const currentMinute = parseInt(parts.find((p) => p.type === 'minute')?.value || '0');
|
|
294
|
+
const weekdayStr = parts.find((p) => p.type === 'weekday')?.value || '';
|
|
295
|
+
|
|
296
|
+
const weekdayMap: Record<string, number> = {
|
|
297
|
+
Sun: 0,
|
|
298
|
+
Mon: 1,
|
|
299
|
+
Tue: 2,
|
|
300
|
+
Wed: 3,
|
|
301
|
+
Thu: 4,
|
|
302
|
+
Fri: 5,
|
|
303
|
+
Sat: 6,
|
|
304
|
+
};
|
|
305
|
+
const currentDay = weekdayMap[weekdayStr] ?? now.getDay();
|
|
306
|
+
|
|
307
|
+
// Check day of week
|
|
308
|
+
if (schedule.daysOfWeek && !schedule.daysOfWeek.includes(currentDay)) {
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Check time window
|
|
313
|
+
if (schedule.startTime && schedule.endTime) {
|
|
314
|
+
const [startHour, startMin] = schedule.startTime.split(':').map(Number);
|
|
315
|
+
const [endHour, endMin] = schedule.endTime.split(':').map(Number);
|
|
316
|
+
|
|
317
|
+
const currentMins = currentHour * 60 + currentMinute;
|
|
318
|
+
const startMins = startHour * 60 + startMin;
|
|
319
|
+
const endMins = endHour * 60 + endMin;
|
|
320
|
+
|
|
321
|
+
if (startMins <= endMins) {
|
|
322
|
+
// Normal case: e.g., 09:00-17:00
|
|
323
|
+
if (currentMins < startMins || currentMins > endMins) {
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
} else {
|
|
327
|
+
// Overnight case: e.g., 22:00-06:00
|
|
328
|
+
if (currentMins < startMins && currentMins > endMins) {
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return true;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ============================================
|
|
338
|
+
// Geofence Plugin Implementation
|
|
339
|
+
// ============================================
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Create geofencing plugin
|
|
343
|
+
*/
|
|
344
|
+
export function geofencePlugin(options: GeofencePluginOptions = {}): MDMPlugin {
|
|
345
|
+
const {
|
|
346
|
+
onEnter,
|
|
347
|
+
onExit,
|
|
348
|
+
onInside,
|
|
349
|
+
defaultDwellTime = 0,
|
|
350
|
+
trackHistory = false,
|
|
351
|
+
maxHistoryEntries = 1000,
|
|
352
|
+
} = options;
|
|
353
|
+
|
|
354
|
+
let mdm: MDMInstance;
|
|
355
|
+
|
|
356
|
+
// In-memory storage (should be moved to DB adapter in production)
|
|
357
|
+
const zones = new Map<string, GeofenceZone>();
|
|
358
|
+
const deviceZoneStates = new Map<string, Map<string, DeviceZoneState>>();
|
|
359
|
+
const locationHistory = new Map<string, LocationHistoryEntry[]>();
|
|
360
|
+
|
|
361
|
+
let zoneIdCounter = 1;
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Generate zone ID
|
|
365
|
+
*/
|
|
366
|
+
function generateZoneId(): string {
|
|
367
|
+
return `zone_${Date.now()}_${zoneIdCounter++}`;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Check if location is inside zone
|
|
372
|
+
*/
|
|
373
|
+
function isInsideZone(
|
|
374
|
+
location: DeviceLocation,
|
|
375
|
+
zone: GeofenceZone
|
|
376
|
+
): boolean {
|
|
377
|
+
if (!zone.enabled) return false;
|
|
378
|
+
if (!isZoneScheduleActive(zone.schedule)) return false;
|
|
379
|
+
|
|
380
|
+
if (zone.type === 'circle' && zone.center && zone.radius) {
|
|
381
|
+
return isInsideCircle(location, zone.center, zone.radius);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (zone.type === 'polygon' && zone.vertices) {
|
|
385
|
+
return isInsidePolygon(location, zone.vertices);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return false;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Process location update for a device
|
|
393
|
+
*/
|
|
394
|
+
async function processLocation(
|
|
395
|
+
device: Device,
|
|
396
|
+
location: DeviceLocation
|
|
397
|
+
): Promise<void> {
|
|
398
|
+
if (!deviceZoneStates.has(device.id)) {
|
|
399
|
+
deviceZoneStates.set(device.id, new Map());
|
|
400
|
+
}
|
|
401
|
+
const deviceStates = deviceZoneStates.get(device.id)!;
|
|
402
|
+
|
|
403
|
+
const currentZones: string[] = [];
|
|
404
|
+
|
|
405
|
+
for (const [zoneId, zone] of zones) {
|
|
406
|
+
const wasInside = deviceStates.get(zoneId)?.inside ?? false;
|
|
407
|
+
const isInside = isInsideZone(location, zone);
|
|
408
|
+
|
|
409
|
+
if (isInside) {
|
|
410
|
+
currentZones.push(zoneId);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (isInside && !wasInside) {
|
|
414
|
+
// Device entered zone
|
|
415
|
+
const enteredAt = new Date();
|
|
416
|
+
deviceStates.set(zoneId, {
|
|
417
|
+
deviceId: device.id,
|
|
418
|
+
zoneId,
|
|
419
|
+
inside: true,
|
|
420
|
+
enteredAt,
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// Check dwell time
|
|
424
|
+
const dwellTime = (zone.metadata?.dwellTime as number) ?? defaultDwellTime;
|
|
425
|
+
|
|
426
|
+
if (dwellTime > 0) {
|
|
427
|
+
setTimeout(async () => {
|
|
428
|
+
const state = deviceStates.get(zoneId);
|
|
429
|
+
if (state?.inside && state.enteredAt === enteredAt) {
|
|
430
|
+
await triggerEnter(device, zone);
|
|
431
|
+
}
|
|
432
|
+
}, dwellTime);
|
|
433
|
+
} else {
|
|
434
|
+
await triggerEnter(device, zone);
|
|
435
|
+
}
|
|
436
|
+
} else if (!isInside && wasInside) {
|
|
437
|
+
// Device exited zone
|
|
438
|
+
const state = deviceStates.get(zoneId);
|
|
439
|
+
deviceStates.set(zoneId, {
|
|
440
|
+
deviceId: device.id,
|
|
441
|
+
zoneId,
|
|
442
|
+
inside: false,
|
|
443
|
+
enteredAt: state?.enteredAt,
|
|
444
|
+
exitedAt: new Date(),
|
|
445
|
+
dwellTime: state?.enteredAt
|
|
446
|
+
? Date.now() - state.enteredAt.getTime()
|
|
447
|
+
: undefined,
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
await triggerExit(device, zone);
|
|
451
|
+
} else if (isInside) {
|
|
452
|
+
// Device still inside
|
|
453
|
+
await onInside?.(device, zone);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Track history
|
|
458
|
+
if (trackHistory) {
|
|
459
|
+
if (!locationHistory.has(device.id)) {
|
|
460
|
+
locationHistory.set(device.id, []);
|
|
461
|
+
}
|
|
462
|
+
const history = locationHistory.get(device.id)!;
|
|
463
|
+
|
|
464
|
+
history.push({
|
|
465
|
+
deviceId: device.id,
|
|
466
|
+
location,
|
|
467
|
+
zones: currentZones,
|
|
468
|
+
timestamp: new Date(),
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
// Trim history
|
|
472
|
+
if (history.length > maxHistoryEntries) {
|
|
473
|
+
history.splice(0, history.length - maxHistoryEntries);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Trigger enter event
|
|
480
|
+
*/
|
|
481
|
+
async function triggerEnter(device: Device, zone: GeofenceZone): Promise<void> {
|
|
482
|
+
console.log(`[OpenMDM Geofence] Device ${device.id} entered zone ${zone.name}`);
|
|
483
|
+
|
|
484
|
+
await onEnter?.(device, zone);
|
|
485
|
+
|
|
486
|
+
// Emit event
|
|
487
|
+
await mdm.emit('custom', {
|
|
488
|
+
type: 'geofence.enter',
|
|
489
|
+
deviceId: device.id,
|
|
490
|
+
zoneId: zone.id,
|
|
491
|
+
zoneName: zone.name,
|
|
492
|
+
timestamp: new Date().toISOString(),
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
// Execute enter action
|
|
496
|
+
if (zone.onEnter) {
|
|
497
|
+
await executeAction(device, zone.onEnter, zone, 'enter');
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Apply policy override
|
|
501
|
+
if (zone.policyOverride) {
|
|
502
|
+
await mdm.devices.assignPolicy(device.id, zone.policyOverride);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Trigger exit event
|
|
508
|
+
*/
|
|
509
|
+
async function triggerExit(device: Device, zone: GeofenceZone): Promise<void> {
|
|
510
|
+
console.log(`[OpenMDM Geofence] Device ${device.id} exited zone ${zone.name}`);
|
|
511
|
+
|
|
512
|
+
await onExit?.(device, zone);
|
|
513
|
+
|
|
514
|
+
// Emit event
|
|
515
|
+
const state = deviceZoneStates.get(device.id)?.get(zone.id);
|
|
516
|
+
await mdm.emit('custom', {
|
|
517
|
+
type: 'geofence.exit',
|
|
518
|
+
deviceId: device.id,
|
|
519
|
+
zoneId: zone.id,
|
|
520
|
+
zoneName: zone.name,
|
|
521
|
+
dwellTime: state?.dwellTime,
|
|
522
|
+
timestamp: new Date().toISOString(),
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
// Execute exit action
|
|
526
|
+
if (zone.onExit) {
|
|
527
|
+
await executeAction(device, zone.onExit, zone, 'exit');
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Revert policy override (restore original policy)
|
|
531
|
+
if (zone.policyOverride && device.policyId !== zone.policyOverride) {
|
|
532
|
+
// Only if not still in another zone with same override
|
|
533
|
+
const stillInOverrideZone = Array.from(zones.values()).some(
|
|
534
|
+
(z) =>
|
|
535
|
+
z.id !== zone.id &&
|
|
536
|
+
z.policyOverride === zone.policyOverride &&
|
|
537
|
+
deviceZoneStates.get(device.id)?.get(z.id)?.inside
|
|
538
|
+
);
|
|
539
|
+
|
|
540
|
+
if (!stillInOverrideZone) {
|
|
541
|
+
// Revert to previous policy (would need to track original policy)
|
|
542
|
+
console.log(
|
|
543
|
+
`[OpenMDM Geofence] Policy override ended for device ${device.id}`
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Execute geofence action
|
|
551
|
+
*/
|
|
552
|
+
async function executeAction(
|
|
553
|
+
device: Device,
|
|
554
|
+
action: GeofenceAction,
|
|
555
|
+
zone: GeofenceZone,
|
|
556
|
+
trigger: 'enter' | 'exit'
|
|
557
|
+
): Promise<void> {
|
|
558
|
+
switch (action.type) {
|
|
559
|
+
case 'notify':
|
|
560
|
+
if (action.notification) {
|
|
561
|
+
await mdm.commands.send({
|
|
562
|
+
deviceId: device.id,
|
|
563
|
+
type: 'sendNotification',
|
|
564
|
+
payload: {
|
|
565
|
+
title: action.notification.title,
|
|
566
|
+
body: action.notification.body,
|
|
567
|
+
},
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
break;
|
|
571
|
+
|
|
572
|
+
case 'command':
|
|
573
|
+
if (action.command) {
|
|
574
|
+
await mdm.commands.send({
|
|
575
|
+
deviceId: device.id,
|
|
576
|
+
type: action.command.type as any,
|
|
577
|
+
payload: action.command.payload,
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
break;
|
|
581
|
+
|
|
582
|
+
case 'policy':
|
|
583
|
+
if (action.policyId) {
|
|
584
|
+
await mdm.devices.assignPolicy(device.id, action.policyId);
|
|
585
|
+
}
|
|
586
|
+
break;
|
|
587
|
+
|
|
588
|
+
case 'webhook':
|
|
589
|
+
if (action.webhook) {
|
|
590
|
+
try {
|
|
591
|
+
await fetch(action.webhook.url, {
|
|
592
|
+
method: action.webhook.method || 'POST',
|
|
593
|
+
headers: {
|
|
594
|
+
'Content-Type': 'application/json',
|
|
595
|
+
...action.webhook.headers,
|
|
596
|
+
},
|
|
597
|
+
body: JSON.stringify({
|
|
598
|
+
event: `geofence.${trigger}`,
|
|
599
|
+
device: {
|
|
600
|
+
id: device.id,
|
|
601
|
+
enrollmentId: device.enrollmentId,
|
|
602
|
+
},
|
|
603
|
+
zone: {
|
|
604
|
+
id: zone.id,
|
|
605
|
+
name: zone.name,
|
|
606
|
+
},
|
|
607
|
+
timestamp: new Date().toISOString(),
|
|
608
|
+
...action.webhook.body,
|
|
609
|
+
}),
|
|
610
|
+
});
|
|
611
|
+
} catch (error) {
|
|
612
|
+
console.error(
|
|
613
|
+
`[OpenMDM Geofence] Webhook failed for zone ${zone.id}:`,
|
|
614
|
+
error
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
break;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Define plugin routes
|
|
623
|
+
const routes: PluginRoute[] = [
|
|
624
|
+
// List all zones
|
|
625
|
+
{
|
|
626
|
+
method: 'GET',
|
|
627
|
+
path: '/geofence/zones',
|
|
628
|
+
auth: true,
|
|
629
|
+
admin: true,
|
|
630
|
+
handler: async (context: any) => {
|
|
631
|
+
const zoneList = Array.from(zones.values()).map((zone) => ({
|
|
632
|
+
...zone,
|
|
633
|
+
createdAt: zone.createdAt.toISOString(),
|
|
634
|
+
updatedAt: zone.updatedAt.toISOString(),
|
|
635
|
+
}));
|
|
636
|
+
return context.json({ zones: zoneList });
|
|
637
|
+
},
|
|
638
|
+
},
|
|
639
|
+
|
|
640
|
+
// Get zone by ID
|
|
641
|
+
{
|
|
642
|
+
method: 'GET',
|
|
643
|
+
path: '/geofence/zones/:zoneId',
|
|
644
|
+
auth: true,
|
|
645
|
+
admin: true,
|
|
646
|
+
handler: async (context: any) => {
|
|
647
|
+
const { zoneId } = context.req.param();
|
|
648
|
+
const zone = zones.get(zoneId);
|
|
649
|
+
|
|
650
|
+
if (!zone) {
|
|
651
|
+
return context.json({ error: 'Zone not found' }, 404);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
return context.json({
|
|
655
|
+
...zone,
|
|
656
|
+
createdAt: zone.createdAt.toISOString(),
|
|
657
|
+
updatedAt: zone.updatedAt.toISOString(),
|
|
658
|
+
});
|
|
659
|
+
},
|
|
660
|
+
},
|
|
661
|
+
|
|
662
|
+
// Create zone
|
|
663
|
+
{
|
|
664
|
+
method: 'POST',
|
|
665
|
+
path: '/geofence/zones',
|
|
666
|
+
auth: true,
|
|
667
|
+
admin: true,
|
|
668
|
+
handler: async (context: any) => {
|
|
669
|
+
const body = (await context.req.json()) as CreateGeofenceZoneInput;
|
|
670
|
+
|
|
671
|
+
// Validate
|
|
672
|
+
if (body.type === 'circle') {
|
|
673
|
+
if (!body.center || !body.radius) {
|
|
674
|
+
return context.json(
|
|
675
|
+
{ error: 'Circle zone requires center and radius' },
|
|
676
|
+
400
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
} else if (body.type === 'polygon') {
|
|
680
|
+
if (!body.vertices || body.vertices.length < 3) {
|
|
681
|
+
return context.json(
|
|
682
|
+
{ error: 'Polygon zone requires at least 3 vertices' },
|
|
683
|
+
400
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const zone: GeofenceZone = {
|
|
689
|
+
id: generateZoneId(),
|
|
690
|
+
name: body.name,
|
|
691
|
+
description: body.description,
|
|
692
|
+
type: body.type,
|
|
693
|
+
enabled: body.enabled ?? true,
|
|
694
|
+
center: body.center,
|
|
695
|
+
radius: body.radius,
|
|
696
|
+
vertices: body.vertices,
|
|
697
|
+
onEnter: body.onEnter,
|
|
698
|
+
onExit: body.onExit,
|
|
699
|
+
policyOverride: body.policyOverride,
|
|
700
|
+
schedule: body.schedule,
|
|
701
|
+
metadata: body.metadata,
|
|
702
|
+
createdAt: new Date(),
|
|
703
|
+
updatedAt: new Date(),
|
|
704
|
+
};
|
|
705
|
+
|
|
706
|
+
zones.set(zone.id, zone);
|
|
707
|
+
|
|
708
|
+
return context.json(
|
|
709
|
+
{
|
|
710
|
+
...zone,
|
|
711
|
+
createdAt: zone.createdAt.toISOString(),
|
|
712
|
+
updatedAt: zone.updatedAt.toISOString(),
|
|
713
|
+
},
|
|
714
|
+
201
|
|
715
|
+
);
|
|
716
|
+
},
|
|
717
|
+
},
|
|
718
|
+
|
|
719
|
+
// Update zone
|
|
720
|
+
{
|
|
721
|
+
method: 'PUT',
|
|
722
|
+
path: '/geofence/zones/:zoneId',
|
|
723
|
+
auth: true,
|
|
724
|
+
admin: true,
|
|
725
|
+
handler: async (context: any) => {
|
|
726
|
+
const { zoneId } = context.req.param();
|
|
727
|
+
const body = (await context.req.json()) as UpdateGeofenceZoneInput;
|
|
728
|
+
|
|
729
|
+
const existing = zones.get(zoneId);
|
|
730
|
+
if (!existing) {
|
|
731
|
+
return context.json({ error: 'Zone not found' }, 404);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const updated: GeofenceZone = {
|
|
735
|
+
...existing,
|
|
736
|
+
...body,
|
|
737
|
+
id: zoneId,
|
|
738
|
+
type: existing.type, // Type cannot be changed
|
|
739
|
+
createdAt: existing.createdAt,
|
|
740
|
+
updatedAt: new Date(),
|
|
741
|
+
};
|
|
742
|
+
|
|
743
|
+
zones.set(zoneId, updated);
|
|
744
|
+
|
|
745
|
+
return context.json({
|
|
746
|
+
...updated,
|
|
747
|
+
createdAt: updated.createdAt.toISOString(),
|
|
748
|
+
updatedAt: updated.updatedAt.toISOString(),
|
|
749
|
+
});
|
|
750
|
+
},
|
|
751
|
+
},
|
|
752
|
+
|
|
753
|
+
// Delete zone
|
|
754
|
+
{
|
|
755
|
+
method: 'DELETE',
|
|
756
|
+
path: '/geofence/zones/:zoneId',
|
|
757
|
+
auth: true,
|
|
758
|
+
admin: true,
|
|
759
|
+
handler: async (context: any) => {
|
|
760
|
+
const { zoneId } = context.req.param();
|
|
761
|
+
|
|
762
|
+
if (!zones.has(zoneId)) {
|
|
763
|
+
return context.json({ error: 'Zone not found' }, 404);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
zones.delete(zoneId);
|
|
767
|
+
|
|
768
|
+
// Clean up device states for this zone
|
|
769
|
+
for (const states of deviceZoneStates.values()) {
|
|
770
|
+
states.delete(zoneId);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
return context.json({ success: true });
|
|
774
|
+
},
|
|
775
|
+
},
|
|
776
|
+
|
|
777
|
+
// Get devices in zone
|
|
778
|
+
{
|
|
779
|
+
method: 'GET',
|
|
780
|
+
path: '/geofence/zones/:zoneId/devices',
|
|
781
|
+
auth: true,
|
|
782
|
+
admin: true,
|
|
783
|
+
handler: async (context: any) => {
|
|
784
|
+
const { zoneId } = context.req.param();
|
|
785
|
+
|
|
786
|
+
if (!zones.has(zoneId)) {
|
|
787
|
+
return context.json({ error: 'Zone not found' }, 404);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const devicesInZone: string[] = [];
|
|
791
|
+
for (const [deviceId, states] of deviceZoneStates) {
|
|
792
|
+
if (states.get(zoneId)?.inside) {
|
|
793
|
+
devicesInZone.push(deviceId);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
return context.json({ deviceIds: devicesInZone });
|
|
798
|
+
},
|
|
799
|
+
},
|
|
800
|
+
|
|
801
|
+
// Get device zone status
|
|
802
|
+
{
|
|
803
|
+
method: 'GET',
|
|
804
|
+
path: '/geofence/devices/:deviceId/zones',
|
|
805
|
+
auth: true,
|
|
806
|
+
admin: true,
|
|
807
|
+
handler: async (context: any) => {
|
|
808
|
+
const { deviceId } = context.req.param();
|
|
809
|
+
const states = deviceZoneStates.get(deviceId);
|
|
810
|
+
|
|
811
|
+
if (!states) {
|
|
812
|
+
return context.json({ zones: [] });
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
const zoneStates = Array.from(states.values()).map((state) => ({
|
|
816
|
+
...state,
|
|
817
|
+
enteredAt: state.enteredAt?.toISOString(),
|
|
818
|
+
exitedAt: state.exitedAt?.toISOString(),
|
|
819
|
+
zoneName: zones.get(state.zoneId)?.name,
|
|
820
|
+
}));
|
|
821
|
+
|
|
822
|
+
return context.json({ zones: zoneStates });
|
|
823
|
+
},
|
|
824
|
+
},
|
|
825
|
+
|
|
826
|
+
// Get device location history
|
|
827
|
+
{
|
|
828
|
+
method: 'GET',
|
|
829
|
+
path: '/geofence/devices/:deviceId/history',
|
|
830
|
+
auth: true,
|
|
831
|
+
admin: true,
|
|
832
|
+
handler: async (context: any) => {
|
|
833
|
+
const { deviceId } = context.req.param();
|
|
834
|
+
const history = locationHistory.get(deviceId) || [];
|
|
835
|
+
|
|
836
|
+
return context.json({
|
|
837
|
+
history: history.map((entry) => ({
|
|
838
|
+
...entry,
|
|
839
|
+
timestamp: entry.timestamp.toISOString(),
|
|
840
|
+
})),
|
|
841
|
+
});
|
|
842
|
+
},
|
|
843
|
+
},
|
|
844
|
+
|
|
845
|
+
// Check if point is in any zone
|
|
846
|
+
{
|
|
847
|
+
method: 'POST',
|
|
848
|
+
path: '/geofence/check',
|
|
849
|
+
auth: true,
|
|
850
|
+
handler: async (context: any) => {
|
|
851
|
+
const body = await context.req.json();
|
|
852
|
+
const { latitude, longitude } = body;
|
|
853
|
+
|
|
854
|
+
const matchingZones: string[] = [];
|
|
855
|
+
const location = { latitude, longitude, timestamp: new Date() };
|
|
856
|
+
|
|
857
|
+
for (const [zoneId, zone] of zones) {
|
|
858
|
+
if (isInsideZone(location, zone)) {
|
|
859
|
+
matchingZones.push(zoneId);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
return context.json({
|
|
864
|
+
inside: matchingZones.length > 0,
|
|
865
|
+
zones: matchingZones.map((id) => ({
|
|
866
|
+
id,
|
|
867
|
+
name: zones.get(id)?.name,
|
|
868
|
+
})),
|
|
869
|
+
});
|
|
870
|
+
},
|
|
871
|
+
},
|
|
872
|
+
];
|
|
873
|
+
|
|
874
|
+
return {
|
|
875
|
+
name: 'geofence',
|
|
876
|
+
version: '1.0.0',
|
|
877
|
+
|
|
878
|
+
async onInit(instance: MDMInstance): Promise<void> {
|
|
879
|
+
mdm = instance;
|
|
880
|
+
console.log('[OpenMDM Geofence] Plugin initialized');
|
|
881
|
+
},
|
|
882
|
+
|
|
883
|
+
async onDestroy(): Promise<void> {
|
|
884
|
+
zones.clear();
|
|
885
|
+
deviceZoneStates.clear();
|
|
886
|
+
locationHistory.clear();
|
|
887
|
+
console.log('[OpenMDM Geofence] Plugin destroyed');
|
|
888
|
+
},
|
|
889
|
+
|
|
890
|
+
routes,
|
|
891
|
+
|
|
892
|
+
async onHeartbeat(device: Device, heartbeat: Heartbeat): Promise<void> {
|
|
893
|
+
if (heartbeat.location) {
|
|
894
|
+
await processLocation(device, heartbeat.location);
|
|
895
|
+
}
|
|
896
|
+
},
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// ============================================
|
|
901
|
+
// Exports
|
|
902
|
+
// ============================================
|
|
903
|
+
|
|
904
|
+
export {
|
|
905
|
+
haversineDistance,
|
|
906
|
+
isInsideCircle,
|
|
907
|
+
isInsidePolygon,
|
|
908
|
+
isZoneScheduleActive,
|
|
909
|
+
};
|
|
910
|
+
|
|
911
|
+
export type { MDMPlugin };
|