@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/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 };