expo-beacon 0.3.2 → 0.4.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/README.md +253 -14
- package/android/src/main/java/expo/modules/beacon/BeaconEventReceiver.kt +42 -18
- package/android/src/main/java/expo/modules/beacon/BeaconForegroundService.kt +63 -3
- package/android/src/main/java/expo/modules/beacon/ExpoBeaconModule.kt +172 -12
- package/build/ExpoBeacon.types.d.ts +45 -1
- package/build/ExpoBeacon.types.d.ts.map +1 -1
- package/build/ExpoBeacon.types.js.map +1 -1
- package/build/ExpoBeaconModule.d.ts +75 -1
- package/build/ExpoBeaconModule.d.ts.map +1 -1
- package/build/ExpoBeaconModule.js +1 -9
- package/build/ExpoBeaconModule.js.map +1 -1
- package/build/ExpoBeaconModule.web.d.ts +5 -1
- package/build/ExpoBeaconModule.web.d.ts.map +1 -1
- package/build/ExpoBeaconModule.web.js +4 -0
- package/build/ExpoBeaconModule.web.js.map +1 -1
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js.map +1 -1
- package/ios/ExpoBeaconModule.swift +408 -39
- package/package.json +1 -1
- package/src/ExpoBeacon.types.ts +50 -1
- package/src/ExpoBeaconModule.ts +32 -11
- package/src/ExpoBeaconModule.web.ts +12 -0
- package/src/index.ts +5 -0
|
@@ -4,6 +4,7 @@ import CoreBluetooth
|
|
|
4
4
|
import UserNotifications
|
|
5
5
|
|
|
6
6
|
private let PAIRED_BEACONS_KEY = "expo.beacon.paired"
|
|
7
|
+
private let PAIRED_EDDYSTONES_KEY = "expo.beacon.paired_eddystones"
|
|
7
8
|
private let IS_MONITORING_KEY = "expo.beacon.is_monitoring"
|
|
8
9
|
private let MAX_DISTANCE_KEY = "expo.beacon.max_distance"
|
|
9
10
|
private let NOTIFICATION_CONFIG_KEY = "expo.beacon.notification_config"
|
|
@@ -57,13 +58,28 @@ public class ExpoBeaconModule: Module {
|
|
|
57
58
|
private var wildcardScannedBeacons: [[String: Any]] = []
|
|
58
59
|
private var wildcardScanTimer: DispatchWorkItem?
|
|
59
60
|
|
|
61
|
+
// Eddystone (CoreBluetooth) scan state
|
|
62
|
+
fileprivate var eddystoneScanPromise: Promise?
|
|
63
|
+
private var eddystoneScannedBeacons: [[String: Any]] = []
|
|
64
|
+
private var eddystoneScanTimer: DispatchWorkItem?
|
|
65
|
+
|
|
66
|
+
// Eddystone monitoring state
|
|
67
|
+
private var eddystoneMonitoringActive = false
|
|
68
|
+
private var eddystoneMonitoringTimer: Timer?
|
|
69
|
+
private var eddystoneLatestSeen: [String: Date] = [:]
|
|
70
|
+
private var eddystoneEnteredRegions: Set<String> = []
|
|
71
|
+
private var eddystoneMissCounters: [String: Int] = [:]
|
|
72
|
+
private var eddystoneEnterCounters: [String: Int] = [:]
|
|
73
|
+
private var eddystoneExitCounters: [String: Int] = [:]
|
|
74
|
+
private var eddystoneLastDistanceEmit: [String: Date] = [:]
|
|
75
|
+
|
|
60
76
|
// Permission callback
|
|
61
77
|
private var permissionCompletion: ((Bool) -> Void)?
|
|
62
78
|
|
|
63
79
|
public func definition() -> ModuleDefinition {
|
|
64
80
|
Name("ExpoBeacon")
|
|
65
81
|
|
|
66
|
-
Events("onBeaconEnter", "onBeaconExit", "onBeaconDistance", "onBeaconFound")
|
|
82
|
+
Events("onBeaconEnter", "onBeaconExit", "onBeaconDistance", "onBeaconFound", "onEddystoneFound", "onEddystoneEnter", "onEddystoneExit", "onEddystoneDistance")
|
|
67
83
|
|
|
68
84
|
// MARK: - Scan
|
|
69
85
|
|
|
@@ -75,21 +91,38 @@ public class ExpoBeaconModule: Module {
|
|
|
75
91
|
|
|
76
92
|
self.scanPromise = promise
|
|
77
93
|
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
return
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// UUID-targeted scan — use CoreLocation ranging
|
|
94
|
+
// Build UUID list — iOS cannot do wildcard iBeacon scans via CoreBluetooth
|
|
95
|
+
// (Apple strips iBeacon data from BLE advertisements). When no UUIDs are
|
|
96
|
+
// provided, fall back to the unique UUIDs of paired beacons.
|
|
85
97
|
var parsedUUIDs: [UUID] = []
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
98
|
+
if uuids.isEmpty {
|
|
99
|
+
let paired = self.loadPairedBeaconsRaw()
|
|
100
|
+
var seen = Set<String>()
|
|
101
|
+
for b in paired {
|
|
102
|
+
guard let uuidStr = b["uuid"] as? String,
|
|
103
|
+
let uuid = UUID(uuidString: uuidStr) else { continue }
|
|
104
|
+
let key = uuid.uuidString.uppercased()
|
|
105
|
+
if !seen.contains(key) {
|
|
106
|
+
seen.insert(key)
|
|
107
|
+
parsedUUIDs.append(uuid)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if parsedUUIDs.isEmpty {
|
|
111
|
+
promise.reject("WILDCARD_NOT_SUPPORTED",
|
|
112
|
+
"iOS does not support wildcard iBeacon scanning. " +
|
|
113
|
+
"Provide at least one proximity UUID, or pair beacons first.")
|
|
89
114
|
self.scanPromise = nil
|
|
90
115
|
return
|
|
91
116
|
}
|
|
92
|
-
|
|
117
|
+
} else {
|
|
118
|
+
for uuidStr in uuids {
|
|
119
|
+
guard let uuid = UUID(uuidString: uuidStr) else {
|
|
120
|
+
promise.reject("INVALID_UUID", "Invalid UUID: \(uuidStr)")
|
|
121
|
+
self.scanPromise = nil
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
parsedUUIDs.append(uuid)
|
|
125
|
+
}
|
|
93
126
|
}
|
|
94
127
|
|
|
95
128
|
self.scannedBeacons = []
|
|
@@ -149,6 +182,29 @@ public class ExpoBeaconModule: Module {
|
|
|
149
182
|
return self.loadPairedBeaconsRaw()
|
|
150
183
|
}
|
|
151
184
|
|
|
185
|
+
// MARK: - Eddystone Pair
|
|
186
|
+
|
|
187
|
+
Function("pairEddystone") { (identifier: String, namespace: String, instance: String) -> Void in
|
|
188
|
+
var eddystones = self.loadPairedEddystonesRaw()
|
|
189
|
+
eddystones.removeAll { ($0["identifier"] as? String) == identifier }
|
|
190
|
+
eddystones.append([
|
|
191
|
+
"identifier": identifier,
|
|
192
|
+
"namespace": namespace,
|
|
193
|
+
"instance": instance
|
|
194
|
+
])
|
|
195
|
+
UserDefaults.standard.set(eddystones, forKey: PAIRED_EDDYSTONES_KEY)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
Function("unpairEddystone") { (identifier: String) in
|
|
199
|
+
var eddystones = self.loadPairedEddystonesRaw()
|
|
200
|
+
eddystones.removeAll { ($0["identifier"] as? String) == identifier }
|
|
201
|
+
UserDefaults.standard.set(eddystones, forKey: PAIRED_EDDYSTONES_KEY)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
Function("getPairedEddystones") { () -> [[String: Any]] in
|
|
205
|
+
return self.loadPairedEddystonesRaw()
|
|
206
|
+
}
|
|
207
|
+
|
|
152
208
|
// MARK: - Notification Config
|
|
153
209
|
|
|
154
210
|
Function("setNotificationConfig") { (config: [String: Any]) in
|
|
@@ -207,7 +263,16 @@ public class ExpoBeaconModule: Module {
|
|
|
207
263
|
Function("startContinuousScan") { () -> Void in
|
|
208
264
|
guard !self.continuousScanActive else { return }
|
|
209
265
|
self.continuousScanActive = true
|
|
210
|
-
|
|
266
|
+
// Ranging requires location authorization — request it before starting.
|
|
267
|
+
self.requestLocationPermission { granted in
|
|
268
|
+
guard granted, self.continuousScanActive else {
|
|
269
|
+
self.continuousScanActive = false
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
self.startContinuousScanRanging()
|
|
273
|
+
// Also start BLE scanning for Eddystone beacons
|
|
274
|
+
self.ensureBleScanRunning()
|
|
275
|
+
}
|
|
211
276
|
}
|
|
212
277
|
|
|
213
278
|
Function("stopContinuousScan") { () -> Void in
|
|
@@ -216,6 +281,19 @@ public class ExpoBeaconModule: Module {
|
|
|
216
281
|
self.locationManager.stopRangingBeacons(satisfying: constraint)
|
|
217
282
|
}
|
|
218
283
|
self.continuousScanOnlyConstraints.removeAll()
|
|
284
|
+
self.stopBleScanIfUnneeded()
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// MARK: - Eddystone Scan
|
|
288
|
+
|
|
289
|
+
AsyncFunction("scanForEddystonesAsync") { (scanDurationMs: Int, promise: Promise) in
|
|
290
|
+
guard self.eddystoneScanPromise == nil else {
|
|
291
|
+
promise.reject("SCAN_IN_PROGRESS", "An Eddystone scan is already in progress")
|
|
292
|
+
return
|
|
293
|
+
}
|
|
294
|
+
self.eddystoneScanPromise = promise
|
|
295
|
+
self.eddystoneScannedBeacons = []
|
|
296
|
+
self.startEddystoneScan(durationMs: scanDurationMs)
|
|
219
297
|
}
|
|
220
298
|
}
|
|
221
299
|
|
|
@@ -298,6 +376,12 @@ public class ExpoBeaconModule: Module {
|
|
|
298
376
|
distanceRangingConstraints[identifier] = constraint
|
|
299
377
|
locationManager.startRangingBeacons(satisfying: constraint)
|
|
300
378
|
}
|
|
379
|
+
|
|
380
|
+
// Start Eddystone-UID monitoring if any paired Eddystones exist
|
|
381
|
+
let eddystones = loadPairedEddystonesRaw()
|
|
382
|
+
if !eddystones.isEmpty {
|
|
383
|
+
startEddystoneMonitoring()
|
|
384
|
+
}
|
|
301
385
|
}
|
|
302
386
|
|
|
303
387
|
private func stopRegionMonitoring() {
|
|
@@ -314,28 +398,27 @@ public class ExpoBeaconModule: Module {
|
|
|
314
398
|
missCounters.removeAll()
|
|
315
399
|
enterCounters.removeAll()
|
|
316
400
|
exitCounters.removeAll()
|
|
401
|
+
|
|
402
|
+
stopEddystoneMonitoring()
|
|
317
403
|
}
|
|
318
404
|
|
|
319
|
-
// Start ranging for
|
|
405
|
+
// Start UUID-only ranging for each unique paired-beacon UUID.
|
|
406
|
+
// UUID-only constraints discover ALL beacons advertising that UUID,
|
|
407
|
+
// not just the specific major/minor that was paired.
|
|
320
408
|
private func startContinuousScanRanging() {
|
|
321
409
|
let beacons = loadPairedBeaconsRaw()
|
|
410
|
+
var seenUUIDs = Set<String>()
|
|
322
411
|
for b in beacons {
|
|
323
412
|
guard
|
|
324
|
-
let identifier = b["identifier"] as? String,
|
|
325
413
|
let uuidString = b["uuid"] as? String,
|
|
326
|
-
let uuid = UUID(uuidString: uuidString)
|
|
327
|
-
let major = b["major"] as? Int,
|
|
328
|
-
let minor = b["minor"] as? Int
|
|
414
|
+
let uuid = UUID(uuidString: uuidString)
|
|
329
415
|
else { continue }
|
|
330
416
|
|
|
331
|
-
|
|
332
|
-
|
|
417
|
+
let key = uuid.uuidString.uppercased()
|
|
418
|
+
guard !seenUUIDs.contains(key) else { continue }
|
|
419
|
+
seenUUIDs.insert(key)
|
|
333
420
|
|
|
334
|
-
let constraint = CLBeaconIdentityConstraint(
|
|
335
|
-
uuid: uuid,
|
|
336
|
-
major: CLBeaconMajorValue(major),
|
|
337
|
-
minor: CLBeaconMinorValue(minor)
|
|
338
|
-
)
|
|
421
|
+
let constraint = CLBeaconIdentityConstraint(uuid: uuid)
|
|
339
422
|
continuousScanOnlyConstraints.append(constraint)
|
|
340
423
|
locationManager.startRangingBeacons(satisfying: constraint)
|
|
341
424
|
}
|
|
@@ -433,7 +516,7 @@ public class ExpoBeaconModule: Module {
|
|
|
433
516
|
private func stopWildcardScanAndResolve() {
|
|
434
517
|
wildcardScanTimer?.cancel()
|
|
435
518
|
wildcardScanTimer = nil
|
|
436
|
-
|
|
519
|
+
stopBleScanIfUnneeded()
|
|
437
520
|
|
|
438
521
|
// Deduplicate by uuid:major:minor, keeping the last (freshest) reading
|
|
439
522
|
var seen = Set<String>()
|
|
@@ -450,6 +533,227 @@ public class ExpoBeaconModule: Module {
|
|
|
450
533
|
wildcardScannedBeacons = []
|
|
451
534
|
}
|
|
452
535
|
|
|
536
|
+
// MARK: - Eddystone Scan
|
|
537
|
+
|
|
538
|
+
private func startEddystoneScan(durationMs: Int) {
|
|
539
|
+
ensureBleScanRunning()
|
|
540
|
+
|
|
541
|
+
let timer = DispatchWorkItem { [weak self] in
|
|
542
|
+
self?.stopEddystoneScanAndResolve()
|
|
543
|
+
}
|
|
544
|
+
eddystoneScanTimer = timer
|
|
545
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(durationMs), execute: timer)
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
private func stopEddystoneScanAndResolve() {
|
|
549
|
+
eddystoneScanTimer?.cancel()
|
|
550
|
+
eddystoneScanTimer = nil
|
|
551
|
+
stopBleScanIfUnneeded()
|
|
552
|
+
|
|
553
|
+
// Deduplicate: by namespace:instance for UID, by url for URL
|
|
554
|
+
var seen = Set<String>()
|
|
555
|
+
var deduped: [[String: Any]] = []
|
|
556
|
+
for beacon in eddystoneScannedBeacons.reversed() {
|
|
557
|
+
let key: String
|
|
558
|
+
if let ns = beacon["namespace"] as? String, let inst = beacon["instance"] as? String {
|
|
559
|
+
key = "uid:\(ns):\(inst)"
|
|
560
|
+
} else if let url = beacon["url"] as? String {
|
|
561
|
+
key = "url:\(url)"
|
|
562
|
+
} else {
|
|
563
|
+
continue
|
|
564
|
+
}
|
|
565
|
+
guard !seen.contains(key) else { continue }
|
|
566
|
+
seen.insert(key)
|
|
567
|
+
deduped.append(beacon)
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
eddystoneScanPromise?.resolve(deduped)
|
|
571
|
+
eddystoneScanPromise = nil
|
|
572
|
+
eddystoneScannedBeacons = []
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
fileprivate func handleEddystoneDiscovery(advertisementData: [String: Any], rssi: NSNumber) {
|
|
576
|
+
let eddystoneServiceUUID = CBUUID(string: "FEAA")
|
|
577
|
+
guard let serviceData = advertisementData[CBAdvertisementDataServiceDataKey] as? [CBUUID: Data],
|
|
578
|
+
let data = serviceData[eddystoneServiceUUID],
|
|
579
|
+
data.count >= 2 else { return }
|
|
580
|
+
|
|
581
|
+
let frameType = data[0]
|
|
582
|
+
let rssiValue = rssi.intValue
|
|
583
|
+
var result: [String: Any]?
|
|
584
|
+
|
|
585
|
+
switch frameType {
|
|
586
|
+
case 0x00: // Eddystone-UID
|
|
587
|
+
guard data.count >= 18 else { return }
|
|
588
|
+
let txPower = Int(Int8(bitPattern: data[1]))
|
|
589
|
+
let namespace = data[2..<12].map { String(format: "%02x", $0) }.joined()
|
|
590
|
+
let instance = data[12..<18].map { String(format: "%02x", $0) }.joined()
|
|
591
|
+
let distance = Self.calculateDistance(rssi: rssiValue, txPower: txPower)
|
|
592
|
+
result = [
|
|
593
|
+
"frameType": "uid",
|
|
594
|
+
"namespace": namespace,
|
|
595
|
+
"instance": instance,
|
|
596
|
+
"rssi": rssiValue,
|
|
597
|
+
"distance": distance,
|
|
598
|
+
"txPower": txPower
|
|
599
|
+
]
|
|
600
|
+
|
|
601
|
+
case 0x10: // Eddystone-URL
|
|
602
|
+
guard data.count >= 3 else { return }
|
|
603
|
+
let txPower = Int(Int8(bitPattern: data[1]))
|
|
604
|
+
let url = Self.decodeEddystoneURL(data: data)
|
|
605
|
+
let distance = Self.calculateDistance(rssi: rssiValue, txPower: txPower)
|
|
606
|
+
result = [
|
|
607
|
+
"frameType": "url",
|
|
608
|
+
"url": url,
|
|
609
|
+
"rssi": rssiValue,
|
|
610
|
+
"distance": distance,
|
|
611
|
+
"txPower": txPower
|
|
612
|
+
]
|
|
613
|
+
|
|
614
|
+
default:
|
|
615
|
+
break
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
guard let beacon = result else { return }
|
|
619
|
+
|
|
620
|
+
if eddystoneScanPromise != nil {
|
|
621
|
+
eddystoneScannedBeacons.append(beacon)
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if continuousScanActive {
|
|
625
|
+
sendEvent("onEddystoneFound", beacon)
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Eddystone monitoring: match UID frames against paired list
|
|
629
|
+
if eddystoneMonitoringActive, let ns = beacon["namespace"] as? String,
|
|
630
|
+
let inst = beacon["instance"] as? String,
|
|
631
|
+
let distance = beacon["distance"] as? Double {
|
|
632
|
+
let pairedEddystones = loadPairedEddystonesRaw()
|
|
633
|
+
for paired in pairedEddystones {
|
|
634
|
+
guard let identifier = paired["identifier"] as? String,
|
|
635
|
+
let pns = paired["namespace"] as? String,
|
|
636
|
+
let pinst = paired["instance"] as? String,
|
|
637
|
+
pns == ns && pinst == inst else { continue }
|
|
638
|
+
|
|
639
|
+
eddystoneLatestSeen[identifier] = Date()
|
|
640
|
+
eddystoneMissCounters[identifier] = 0
|
|
641
|
+
|
|
642
|
+
// Throttle distance events to ~1/sec
|
|
643
|
+
let now = Date()
|
|
644
|
+
if let lastEmit = eddystoneLastDistanceEmit[identifier],
|
|
645
|
+
now.timeIntervalSince(lastEmit) < 1.0 {
|
|
646
|
+
break
|
|
647
|
+
}
|
|
648
|
+
eddystoneLastDistanceEmit[identifier] = now
|
|
649
|
+
|
|
650
|
+
let distParams: [String: Any] = [
|
|
651
|
+
"identifier": identifier,
|
|
652
|
+
"namespace": ns,
|
|
653
|
+
"instance": inst,
|
|
654
|
+
"distance": distance
|
|
655
|
+
]
|
|
656
|
+
sendEvent("onEddystoneDistance", distParams)
|
|
657
|
+
|
|
658
|
+
// Distance-driven enter/exit with hysteresis
|
|
659
|
+
if let maxDist = UserDefaults.standard.object(forKey: MAX_DISTANCE_KEY) as? Double {
|
|
660
|
+
if distance <= maxDist {
|
|
661
|
+
eddystoneExitCounters[identifier] = 0
|
|
662
|
+
eddystoneEnterCounters[identifier] = (eddystoneEnterCounters[identifier] ?? 0) + 1
|
|
663
|
+
|
|
664
|
+
if !eddystoneEnteredRegions.contains(identifier) && (eddystoneEnterCounters[identifier] ?? 0) >= HYSTERESIS_COUNT {
|
|
665
|
+
eddystoneEnteredRegions.insert(identifier)
|
|
666
|
+
eddystoneEnterCounters[identifier] = 0
|
|
667
|
+
let params: [String: Any] = [
|
|
668
|
+
"identifier": identifier,
|
|
669
|
+
"namespace": ns,
|
|
670
|
+
"instance": inst,
|
|
671
|
+
"event": "enter",
|
|
672
|
+
"distance": distance
|
|
673
|
+
]
|
|
674
|
+
sendEvent("onEddystoneEnter", params)
|
|
675
|
+
postBeaconNotification(identifier: identifier, eventType: "enter")
|
|
676
|
+
}
|
|
677
|
+
} else {
|
|
678
|
+
eddystoneEnterCounters[identifier] = 0
|
|
679
|
+
eddystoneExitCounters[identifier] = (eddystoneExitCounters[identifier] ?? 0) + 1
|
|
680
|
+
|
|
681
|
+
if eddystoneEnteredRegions.contains(identifier) && (eddystoneExitCounters[identifier] ?? 0) >= HYSTERESIS_COUNT {
|
|
682
|
+
eddystoneEnteredRegions.remove(identifier)
|
|
683
|
+
eddystoneExitCounters[identifier] = 0
|
|
684
|
+
let params: [String: Any] = [
|
|
685
|
+
"identifier": identifier,
|
|
686
|
+
"namespace": ns,
|
|
687
|
+
"instance": inst,
|
|
688
|
+
"event": "exit",
|
|
689
|
+
"distance": distance
|
|
690
|
+
]
|
|
691
|
+
sendEvent("onEddystoneExit", params)
|
|
692
|
+
postBeaconNotification(identifier: identifier, eventType: "exit")
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
} else {
|
|
696
|
+
// No maxDistance — enter on first consistent readings
|
|
697
|
+
eddystoneEnterCounters[identifier] = (eddystoneEnterCounters[identifier] ?? 0) + 1
|
|
698
|
+
|
|
699
|
+
if !eddystoneEnteredRegions.contains(identifier) && (eddystoneEnterCounters[identifier] ?? 0) >= HYSTERESIS_COUNT {
|
|
700
|
+
eddystoneEnteredRegions.insert(identifier)
|
|
701
|
+
eddystoneEnterCounters[identifier] = 0
|
|
702
|
+
let params: [String: Any] = [
|
|
703
|
+
"identifier": identifier,
|
|
704
|
+
"namespace": ns,
|
|
705
|
+
"instance": inst,
|
|
706
|
+
"event": "enter",
|
|
707
|
+
"distance": distance
|
|
708
|
+
]
|
|
709
|
+
sendEvent("onEddystoneEnter", params)
|
|
710
|
+
postBeaconNotification(identifier: identifier, eventType: "enter")
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
break
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
fileprivate func ensureBleScanRunning() {
|
|
719
|
+
if centralManager == nil {
|
|
720
|
+
centralManager = CBCentralManager(delegate: bluetoothDelegate, queue: .main)
|
|
721
|
+
} else if centralManager?.state == .poweredOn {
|
|
722
|
+
centralManager?.scanForPeripherals(
|
|
723
|
+
withServices: nil,
|
|
724
|
+
options: [CBCentralManagerScanOptionAllowDuplicatesKey: true]
|
|
725
|
+
)
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
private func stopBleScanIfUnneeded() {
|
|
730
|
+
guard wildcardScanTimer == nil && eddystoneScanTimer == nil && !continuousScanActive && !eddystoneMonitoringActive else { return }
|
|
731
|
+
centralManager?.stopScan()
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
private static func decodeEddystoneURL(data: Data) -> String {
|
|
735
|
+
guard data.count >= 3 else { return "" }
|
|
736
|
+
let schemes = ["http://www.", "https://www.", "http://", "https://"]
|
|
737
|
+
let suffixes: [UInt8: String] = [
|
|
738
|
+
0x00: ".com/", 0x01: ".org/", 0x02: ".edu/", 0x03: ".net/",
|
|
739
|
+
0x04: ".info/", 0x05: ".biz/", 0x06: ".gov/",
|
|
740
|
+
0x07: ".com", 0x08: ".org", 0x09: ".edu", 0x0A: ".net",
|
|
741
|
+
0x0B: ".info", 0x0C: ".biz", 0x0D: ".gov"
|
|
742
|
+
]
|
|
743
|
+
let schemeIndex = Int(data[2])
|
|
744
|
+
guard schemeIndex < schemes.count else { return "" }
|
|
745
|
+
var url = schemes[schemeIndex]
|
|
746
|
+
for i in 3..<data.count {
|
|
747
|
+
let byte = data[i]
|
|
748
|
+
if let suffix = suffixes[byte] {
|
|
749
|
+
url += suffix
|
|
750
|
+
} else if byte >= 0x20 && byte <= 0x7E {
|
|
751
|
+
url += String(UnicodeScalar(byte))
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
return url
|
|
755
|
+
}
|
|
756
|
+
|
|
453
757
|
/// Log-distance path loss model: distance = 10 ^ ((txPower - rssi) / (10 * n)), n = 2.0
|
|
454
758
|
private static func calculateDistance(rssi: Int, txPower: Int) -> Double {
|
|
455
759
|
guard rssi != 0 else { return -1 }
|
|
@@ -461,6 +765,75 @@ public class ExpoBeaconModule: Module {
|
|
|
461
765
|
return UserDefaults.standard.array(forKey: PAIRED_BEACONS_KEY) as? [[String: Any]] ?? []
|
|
462
766
|
}
|
|
463
767
|
|
|
768
|
+
private func loadPairedEddystonesRaw() -> [[String: Any]] {
|
|
769
|
+
return UserDefaults.standard.array(forKey: PAIRED_EDDYSTONES_KEY) as? [[String: Any]] ?? []
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// MARK: - Eddystone Monitoring
|
|
773
|
+
|
|
774
|
+
private func startEddystoneMonitoring() {
|
|
775
|
+
eddystoneMonitoringActive = true
|
|
776
|
+
ensureBleScanRunning()
|
|
777
|
+
|
|
778
|
+
// Timer to detect exit (beacon disappears from BLE advertisements)
|
|
779
|
+
eddystoneMonitoringTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in
|
|
780
|
+
self?.eddystoneMonitoringTick()
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
private func stopEddystoneMonitoring() {
|
|
785
|
+
eddystoneMonitoringActive = false
|
|
786
|
+
eddystoneMonitoringTimer?.invalidate()
|
|
787
|
+
eddystoneMonitoringTimer = nil
|
|
788
|
+
eddystoneLatestSeen.removeAll()
|
|
789
|
+
eddystoneEnteredRegions.removeAll()
|
|
790
|
+
eddystoneMissCounters.removeAll()
|
|
791
|
+
eddystoneEnterCounters.removeAll()
|
|
792
|
+
eddystoneExitCounters.removeAll()
|
|
793
|
+
eddystoneLastDistanceEmit.removeAll()
|
|
794
|
+
stopBleScanIfUnneeded()
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
private func eddystoneMonitoringTick() {
|
|
798
|
+
let now = Date()
|
|
799
|
+
let pairedEddystones = loadPairedEddystonesRaw()
|
|
800
|
+
|
|
801
|
+
for paired in pairedEddystones {
|
|
802
|
+
guard let identifier = paired["identifier"] as? String else { continue }
|
|
803
|
+
|
|
804
|
+
if let lastSeen = eddystoneLatestSeen[identifier], now.timeIntervalSince(lastSeen) < 3.0 {
|
|
805
|
+
// Recently seen — miss counter reset already done in handleEddystoneDiscovery
|
|
806
|
+
continue
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// Not seen recently — increment miss counter
|
|
810
|
+
guard eddystoneEnteredRegions.contains(identifier) else { continue }
|
|
811
|
+
|
|
812
|
+
let count = (eddystoneMissCounters[identifier] ?? 0) + 1
|
|
813
|
+
eddystoneMissCounters[identifier] = count
|
|
814
|
+
|
|
815
|
+
if count >= EXIT_MISS_THRESHOLD {
|
|
816
|
+
eddystoneEnteredRegions.remove(identifier)
|
|
817
|
+
eddystoneMissCounters[identifier] = 0
|
|
818
|
+
eddystoneEnterCounters[identifier] = 0
|
|
819
|
+
eddystoneExitCounters[identifier] = 0
|
|
820
|
+
eddystoneLatestSeen.removeValue(forKey: identifier)
|
|
821
|
+
|
|
822
|
+
let ns = paired["namespace"] as? String ?? ""
|
|
823
|
+
let inst = paired["instance"] as? String ?? ""
|
|
824
|
+
let params: [String: Any] = [
|
|
825
|
+
"identifier": identifier,
|
|
826
|
+
"namespace": ns,
|
|
827
|
+
"instance": inst,
|
|
828
|
+
"event": "exit",
|
|
829
|
+
"distance": -1
|
|
830
|
+
]
|
|
831
|
+
sendEvent("onEddystoneExit", params)
|
|
832
|
+
postBeaconNotification(identifier: identifier, eventType: "exit")
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
464
837
|
private func postBeaconNotification(identifier: String, eventType: String) {
|
|
465
838
|
let cfg = loadNotificationConfig()
|
|
466
839
|
let eventsCfg = cfg["beaconEvents"] as? [String: Any]
|
|
@@ -588,18 +961,9 @@ public class ExpoBeaconModule: Module {
|
|
|
588
961
|
}
|
|
589
962
|
}
|
|
590
963
|
|
|
591
|
-
//
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
"uuid": beacon.uuid.uuidString.uppercased(),
|
|
595
|
-
"major": beacon.major.intValue,
|
|
596
|
-
"minor": beacon.minor.intValue,
|
|
597
|
-
"rssi": beacon.rssi,
|
|
598
|
-
"distance": beacon.accuracy,
|
|
599
|
-
"txPower": 0
|
|
600
|
-
]
|
|
601
|
-
sendEvent("onBeaconFound", foundParams)
|
|
602
|
-
}
|
|
964
|
+
// Note: onBeaconFound for continuous scan is emitted by the
|
|
965
|
+
// UUID-only constraints in check 3 below, not here, to avoid
|
|
966
|
+
// duplicate events when both monitoring and continuous scan are active.
|
|
603
967
|
} else {
|
|
604
968
|
// No valid beacon reading — beacon may have disappeared
|
|
605
969
|
let count = (missCounters[identifier] ?? 0) + 1
|
|
@@ -753,13 +1117,17 @@ private class BluetoothDelegate: NSObject, CBCentralManagerDelegate {
|
|
|
753
1117
|
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
|
754
1118
|
switch central.state {
|
|
755
1119
|
case .poweredOn:
|
|
756
|
-
module?.
|
|
1120
|
+
module?.ensureBleScanRunning()
|
|
757
1121
|
case .unauthorized:
|
|
758
1122
|
module?.scanPromise?.reject("BLUETOOTH_UNAUTHORIZED", "Bluetooth permission denied")
|
|
759
1123
|
module?.scanPromise = nil
|
|
1124
|
+
module?.eddystoneScanPromise?.reject("BLUETOOTH_UNAUTHORIZED", "Bluetooth permission denied")
|
|
1125
|
+
module?.eddystoneScanPromise = nil
|
|
760
1126
|
case .poweredOff:
|
|
761
1127
|
module?.scanPromise?.reject("BLUETOOTH_OFF", "Bluetooth is powered off")
|
|
762
1128
|
module?.scanPromise = nil
|
|
1129
|
+
module?.eddystoneScanPromise?.reject("BLUETOOTH_OFF", "Bluetooth is powered off")
|
|
1130
|
+
module?.eddystoneScanPromise = nil
|
|
763
1131
|
default:
|
|
764
1132
|
break
|
|
765
1133
|
}
|
|
@@ -770,6 +1138,7 @@ private class BluetoothDelegate: NSObject, CBCentralManagerDelegate {
|
|
|
770
1138
|
advertisementData: [String: Any],
|
|
771
1139
|
rssi RSSI: NSNumber) {
|
|
772
1140
|
module?.handleWildcardDiscovery(advertisementData: advertisementData, rssi: RSSI)
|
|
1141
|
+
module?.handleEddystoneDiscovery(advertisementData: advertisementData, rssi: RSSI)
|
|
773
1142
|
}
|
|
774
1143
|
}
|
|
775
1144
|
|
package/package.json
CHANGED
package/src/ExpoBeacon.types.ts
CHANGED
|
@@ -99,11 +99,60 @@ export type MonitoringOptions = {
|
|
|
99
99
|
notifications?: NotificationConfig;
|
|
100
100
|
};
|
|
101
101
|
|
|
102
|
+
/** Eddystone frame type. */
|
|
103
|
+
export type EddystoneFrameType = "uid" | "url";
|
|
104
|
+
|
|
105
|
+
/** Raw Eddystone beacon discovered during a scan. */
|
|
106
|
+
export type EddystoneScanResult = {
|
|
107
|
+
frameType: EddystoneFrameType;
|
|
108
|
+
/** 10-byte namespace ID as hex string (20 chars). Present for UID frames. */
|
|
109
|
+
namespace?: string;
|
|
110
|
+
/** 6-byte instance ID as hex string (12 chars). Present for UID frames. */
|
|
111
|
+
instance?: string;
|
|
112
|
+
/** Decoded URL. Present for URL frames. */
|
|
113
|
+
url?: string;
|
|
114
|
+
rssi: number;
|
|
115
|
+
distance: number;
|
|
116
|
+
txPower: number;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/** An Eddystone-UID beacon that has been paired/registered for monitoring. */
|
|
120
|
+
export type PairedEddystone = {
|
|
121
|
+
identifier: string;
|
|
122
|
+
/** 10-byte namespace ID as hex string (20 chars). */
|
|
123
|
+
namespace: string;
|
|
124
|
+
/** 6-byte instance ID as hex string (12 chars). */
|
|
125
|
+
instance: string;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/** Payload for Eddystone enter/exit region events. */
|
|
129
|
+
export type EddystoneRegionEvent = {
|
|
130
|
+
identifier: string;
|
|
131
|
+
namespace: string;
|
|
132
|
+
instance: string;
|
|
133
|
+
event: "enter" | "exit";
|
|
134
|
+
/** Measured distance in metres at the time of the event (–1 if unavailable). */
|
|
135
|
+
distance: number;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/** Payload for periodic Eddystone distance update events during monitoring. */
|
|
139
|
+
export type EddystoneDistanceEvent = {
|
|
140
|
+
identifier: string;
|
|
141
|
+
namespace: string;
|
|
142
|
+
instance: string;
|
|
143
|
+
distance: number;
|
|
144
|
+
};
|
|
145
|
+
|
|
102
146
|
/** Module event map. */
|
|
103
147
|
export type ExpoBeaconModuleEvents = {
|
|
104
148
|
onBeaconEnter: (params: BeaconRegionEvent) => void;
|
|
105
149
|
onBeaconExit: (params: BeaconRegionEvent) => void;
|
|
106
150
|
onBeaconDistance: (params: BeaconDistanceEvent) => void;
|
|
107
|
-
/** Fired continuously during a live scan as each
|
|
151
|
+
/** Fired continuously during a live scan as each iBeacon is detected. */
|
|
108
152
|
onBeaconFound: (params: BeaconScanResult) => void;
|
|
153
|
+
/** Fired continuously during a live scan as each Eddystone beacon is detected. */
|
|
154
|
+
onEddystoneFound: (params: EddystoneScanResult) => void;
|
|
155
|
+
onEddystoneEnter: (params: EddystoneRegionEvent) => void;
|
|
156
|
+
onEddystoneExit: (params: EddystoneRegionEvent) => void;
|
|
157
|
+
onEddystoneDistance: (params: EddystoneDistanceEvent) => void;
|
|
109
158
|
};
|