expo-beacon 0.2.0 → 0.3.1
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 +35 -20
- package/android/src/main/java/expo/modules/beacon/BeaconForegroundService.kt +101 -28
- package/android/src/main/java/expo/modules/beacon/ExpoBeaconModule.kt +53 -8
- package/build/ExpoBeacon.types.d.ts +0 -10
- package/build/ExpoBeacon.types.d.ts.map +1 -1
- package/build/ExpoBeacon.types.js.map +1 -1
- package/build/ExpoBeaconModule.d.ts.map +1 -1
- package/build/ExpoBeaconModule.js.map +1 -1
- package/build/ExpoBeaconModule.web.d.ts +1 -1
- package/build/ExpoBeaconModule.web.d.ts.map +1 -1
- package/build/ExpoBeaconModule.web.js +1 -1
- 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/ExpoBeacon.podspec +1 -1
- package/ios/ExpoBeaconModule.swift +364 -75
- package/package.json +1 -1
- package/src/ExpoBeacon.types.ts +0 -11
- package/src/ExpoBeaconModule.ts +11 -2
- package/src/ExpoBeaconModule.web.ts +4 -2
- package/src/index.ts +0 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import ExpoModulesCore
|
|
2
2
|
import CoreLocation
|
|
3
|
+
import CoreBluetooth
|
|
3
4
|
import UserNotifications
|
|
4
5
|
|
|
5
6
|
private let PAIRED_BEACONS_KEY = "expo.beacon.paired"
|
|
@@ -7,21 +8,30 @@ private let IS_MONITORING_KEY = "expo.beacon.is_monitoring"
|
|
|
7
8
|
private let MAX_DISTANCE_KEY = "expo.beacon.max_distance"
|
|
8
9
|
private let NOTIFICATION_CONFIG_KEY = "expo.beacon.notification_config"
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
/// Number of consecutive ranging misses before emitting a distance-based exit event.
|
|
12
|
+
private let EXIT_MISS_THRESHOLD = 3
|
|
13
|
+
/// Number of consecutive readings required to confirm a distance-based enter or exit transition.
|
|
14
|
+
private let HYSTERESIS_COUNT = 3
|
|
15
|
+
|
|
16
|
+
public class ExpoBeaconModule: Module {
|
|
17
|
+
|
|
18
|
+
private lazy var locationDelegate = LocationDelegate(module: self)
|
|
11
19
|
|
|
12
20
|
private lazy var locationManager: CLLocationManager = {
|
|
13
21
|
let manager = CLLocationManager()
|
|
14
|
-
manager.delegate =
|
|
15
|
-
|
|
22
|
+
manager.delegate = locationDelegate
|
|
23
|
+
let backgroundModes = Bundle.main.object(forInfoDictionaryKey: "UIBackgroundModes") as? [String] ?? []
|
|
24
|
+
if backgroundModes.contains("location") {
|
|
25
|
+
manager.allowsBackgroundLocationUpdates = true
|
|
26
|
+
}
|
|
16
27
|
manager.pausesLocationUpdatesAutomatically = false
|
|
17
28
|
return manager
|
|
18
29
|
}()
|
|
19
30
|
|
|
20
31
|
// One-shot scan state
|
|
21
|
-
|
|
32
|
+
fileprivate var scanPromise: Promise?
|
|
22
33
|
private var scannedBeacons: [CLBeacon] = []
|
|
23
|
-
private var
|
|
24
|
-
private var scanRegion: CLBeaconRegion?
|
|
34
|
+
private var scanConstraints: [CLBeaconIdentityConstraint] = []
|
|
25
35
|
|
|
26
36
|
// Monitored regions
|
|
27
37
|
private var monitoredRegions: [CLBeaconRegion] = []
|
|
@@ -30,29 +40,60 @@ public class ExpoBeaconModule: NSObject, Module, CLLocationManagerDelegate {
|
|
|
30
40
|
private var distanceRangingConstraints: [String: CLBeaconIdentityConstraint] = [:]
|
|
31
41
|
// Identifiers currently in "entered" state (used for distance-driven enter/exit)
|
|
32
42
|
private var enteredRegions: Set<String> = []
|
|
43
|
+
// Consecutive miss counter per identifier (for distance-based exit when beacon disappears)
|
|
44
|
+
private var missCounters: [String: Int] = [:]
|
|
45
|
+
// Hysteresis counters: consecutive readings inside/outside threshold per identifier
|
|
46
|
+
private var enterCounters: [String: Int] = [:]
|
|
47
|
+
private var exitCounters: [String: Int] = [:]
|
|
33
48
|
|
|
34
49
|
// Continuous scan state
|
|
35
50
|
private var continuousScanActive = false
|
|
36
51
|
// Constraints started exclusively for continuous scan (not shared with distance ranging)
|
|
37
52
|
private var continuousScanOnlyConstraints: [CLBeaconIdentityConstraint] = []
|
|
38
53
|
|
|
54
|
+
// Wildcard (CoreBluetooth) scan state
|
|
55
|
+
private lazy var bluetoothDelegate = BluetoothDelegate(module: self)
|
|
56
|
+
private var centralManager: CBCentralManager?
|
|
57
|
+
private var wildcardScannedBeacons: [[String: Any]] = []
|
|
58
|
+
private var wildcardScanTimer: DispatchWorkItem?
|
|
59
|
+
|
|
39
60
|
// Permission callback
|
|
40
61
|
private var permissionCompletion: ((Bool) -> Void)?
|
|
41
62
|
|
|
42
63
|
public func definition() -> ModuleDefinition {
|
|
43
64
|
Name("ExpoBeacon")
|
|
44
65
|
|
|
45
|
-
Events("onBeaconEnter", "onBeaconExit", "
|
|
66
|
+
Events("onBeaconEnter", "onBeaconExit", "onBeaconDistance", "onBeaconFound")
|
|
46
67
|
|
|
47
68
|
// MARK: - Scan
|
|
48
69
|
|
|
49
|
-
AsyncFunction("scanForBeaconsAsync") { (scanDurationMs: Int, promise: Promise) in
|
|
70
|
+
AsyncFunction("scanForBeaconsAsync") { (uuids: [String], scanDurationMs: Int, promise: Promise) in
|
|
50
71
|
guard self.scanPromise == nil else {
|
|
51
72
|
promise.reject("SCAN_IN_PROGRESS", "A scan is already in progress")
|
|
52
73
|
return
|
|
53
74
|
}
|
|
75
|
+
|
|
54
76
|
self.scanPromise = promise
|
|
77
|
+
|
|
78
|
+
// Wildcard scan (empty UUIDs) — use CoreBluetooth raw BLE scanning
|
|
79
|
+
if uuids.isEmpty {
|
|
80
|
+
self.startWildcardScan(durationMs: scanDurationMs)
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// UUID-targeted scan — use CoreLocation ranging
|
|
85
|
+
var parsedUUIDs: [UUID] = []
|
|
86
|
+
for uuidStr in uuids {
|
|
87
|
+
guard let uuid = UUID(uuidString: uuidStr) else {
|
|
88
|
+
promise.reject("INVALID_UUID", "Invalid UUID: \(uuidStr)")
|
|
89
|
+
self.scanPromise = nil
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
parsedUUIDs.append(uuid)
|
|
93
|
+
}
|
|
94
|
+
|
|
55
95
|
self.scannedBeacons = []
|
|
96
|
+
self.scanConstraints = []
|
|
56
97
|
|
|
57
98
|
self.requestLocationPermission { granted in
|
|
58
99
|
guard granted else {
|
|
@@ -61,12 +102,12 @@ public class ExpoBeaconModule: NSObject, Module, CLLocationManagerDelegate {
|
|
|
61
102
|
return
|
|
62
103
|
}
|
|
63
104
|
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
105
|
+
// Range for each requested UUID simultaneously
|
|
106
|
+
for uuid in parsedUUIDs {
|
|
107
|
+
let constraint = CLBeaconIdentityConstraint(uuid: uuid)
|
|
108
|
+
self.scanConstraints.append(constraint)
|
|
109
|
+
self.locationManager.startRangingBeacons(satisfying: constraint)
|
|
110
|
+
}
|
|
70
111
|
|
|
71
112
|
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(scanDurationMs)) {
|
|
72
113
|
self.stopScanAndResolve()
|
|
@@ -76,7 +117,17 @@ public class ExpoBeaconModule: NSObject, Module, CLLocationManagerDelegate {
|
|
|
76
117
|
|
|
77
118
|
// MARK: - Pair
|
|
78
119
|
|
|
79
|
-
Function("pairBeacon") { (identifier: String, uuid: String, major: Int, minor: Int) in
|
|
120
|
+
Function("pairBeacon") { (identifier: String, uuid: String, major: Int, minor: Int) -> Void in
|
|
121
|
+
guard UUID(uuidString: uuid) != nil else {
|
|
122
|
+
throw Exception(name: "INVALID_UUID", description: "Invalid UUID format: \(uuid)")
|
|
123
|
+
}
|
|
124
|
+
guard (0...65535).contains(major) else {
|
|
125
|
+
throw Exception(name: "INVALID_MAJOR", description: "Major must be 0–65535, got \(major)")
|
|
126
|
+
}
|
|
127
|
+
guard (0...65535).contains(minor) else {
|
|
128
|
+
throw Exception(name: "INVALID_MINOR", description: "Minor must be 0–65535, got \(minor)")
|
|
129
|
+
}
|
|
130
|
+
|
|
80
131
|
var beacons = self.loadPairedBeaconsRaw()
|
|
81
132
|
beacons.removeAll { ($0["identifier"] as? String) == identifier }
|
|
82
133
|
beacons.append([
|
|
@@ -109,11 +160,11 @@ public class ExpoBeaconModule: NSObject, Module, CLLocationManagerDelegate {
|
|
|
109
160
|
|
|
110
161
|
// MARK: - Monitoring
|
|
111
162
|
|
|
112
|
-
AsyncFunction("startMonitoring") { (options: Any
|
|
163
|
+
AsyncFunction("startMonitoring") { (options: Either<Double, [String: Any]>?, promise: Promise) in
|
|
113
164
|
var maxDistance: Double? = nil
|
|
114
|
-
if let dist = options
|
|
165
|
+
if let dist: Double = options?.get() {
|
|
115
166
|
maxDistance = dist
|
|
116
|
-
} else if let map
|
|
167
|
+
} else if let map: [String: Any] = options?.get() {
|
|
117
168
|
maxDistance = map["maxDistance"] as? Double
|
|
118
169
|
if let notifications = map["notifications"] as? [String: Any],
|
|
119
170
|
let data = try? JSONSerialization.data(withJSONObject: notifications),
|
|
@@ -127,7 +178,7 @@ public class ExpoBeaconModule: NSObject, Module, CLLocationManagerDelegate {
|
|
|
127
178
|
UserDefaults.standard.removeObject(forKey: MAX_DISTANCE_KEY)
|
|
128
179
|
}
|
|
129
180
|
UserDefaults.standard.set(true, forKey: IS_MONITORING_KEY)
|
|
130
|
-
self.requestLocationPermission { granted in
|
|
181
|
+
self.requestLocationPermission(requireAlways: true) { granted in
|
|
131
182
|
guard granted else {
|
|
132
183
|
promise.reject("PERMISSION_DENIED", "Always location permission required for background monitoring")
|
|
133
184
|
return
|
|
@@ -170,17 +221,41 @@ public class ExpoBeaconModule: NSObject, Module, CLLocationManagerDelegate {
|
|
|
170
221
|
|
|
171
222
|
// MARK: - Private Helpers
|
|
172
223
|
|
|
173
|
-
private func requestLocationPermission(completion: @escaping (Bool) -> Void) {
|
|
224
|
+
private func requestLocationPermission(requireAlways: Bool = false, completion: @escaping (Bool) -> Void) {
|
|
174
225
|
let status = locationManager.authorizationStatus
|
|
175
226
|
switch status {
|
|
176
227
|
case .authorizedAlways:
|
|
177
228
|
completion(true)
|
|
229
|
+
case .authorizedWhenInUse:
|
|
230
|
+
if requireAlways {
|
|
231
|
+
// Already have whenInUse — request upgrade to always
|
|
232
|
+
self.permissionCompletion = { granted in
|
|
233
|
+
// After the upgrade prompt, only .authorizedAlways counts
|
|
234
|
+
let nowStatus = self.locationManager.authorizationStatus
|
|
235
|
+
completion(nowStatus == .authorizedAlways)
|
|
236
|
+
}
|
|
237
|
+
locationManager.requestAlwaysAuthorization()
|
|
238
|
+
} else {
|
|
239
|
+
completion(true)
|
|
240
|
+
}
|
|
178
241
|
case .notDetermined:
|
|
179
|
-
|
|
180
|
-
|
|
242
|
+
// Two-step flow: first request whenInUse, then upgrade to always
|
|
243
|
+
self.permissionCompletion = { _ in
|
|
244
|
+
let nowStatus = self.locationManager.authorizationStatus
|
|
245
|
+
if requireAlways && nowStatus == .authorizedWhenInUse {
|
|
246
|
+
// Got provisional whenInUse — request upgrade to always
|
|
247
|
+
self.permissionCompletion = { _ in
|
|
248
|
+
let finalStatus = self.locationManager.authorizationStatus
|
|
249
|
+
completion(finalStatus == .authorizedAlways)
|
|
250
|
+
}
|
|
251
|
+
self.locationManager.requestAlwaysAuthorization()
|
|
252
|
+
} else {
|
|
253
|
+
completion(nowStatus == .authorizedAlways || nowStatus == .authorizedWhenInUse)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
locationManager.requestWhenInUseAuthorization()
|
|
181
257
|
default:
|
|
182
|
-
|
|
183
|
-
completion(status == .authorizedWhenInUse)
|
|
258
|
+
completion(false)
|
|
184
259
|
}
|
|
185
260
|
}
|
|
186
261
|
|
|
@@ -236,6 +311,9 @@ public class ExpoBeaconModule: NSObject, Module, CLLocationManagerDelegate {
|
|
|
236
311
|
}
|
|
237
312
|
distanceRangingConstraints.removeAll()
|
|
238
313
|
enteredRegions.removeAll()
|
|
314
|
+
missCounters.removeAll()
|
|
315
|
+
enterCounters.removeAll()
|
|
316
|
+
exitCounters.removeAll()
|
|
239
317
|
}
|
|
240
318
|
|
|
241
319
|
// Start ranging for paired beacons not already covered by distance ranging
|
|
@@ -264,11 +342,10 @@ public class ExpoBeaconModule: NSObject, Module, CLLocationManagerDelegate {
|
|
|
264
342
|
}
|
|
265
343
|
|
|
266
344
|
private func stopScanAndResolve() {
|
|
267
|
-
|
|
345
|
+
for constraint in scanConstraints {
|
|
268
346
|
locationManager.stopRangingBeacons(satisfying: constraint)
|
|
269
|
-
scanConstraint = nil
|
|
270
|
-
scanRegion = nil
|
|
271
347
|
}
|
|
348
|
+
scanConstraints.removeAll()
|
|
272
349
|
|
|
273
350
|
var seen = Set<String>()
|
|
274
351
|
let results: [[String: Any]] = scannedBeacons.compactMap { beacon in
|
|
@@ -290,6 +367,96 @@ public class ExpoBeaconModule: NSObject, Module, CLLocationManagerDelegate {
|
|
|
290
367
|
scannedBeacons = []
|
|
291
368
|
}
|
|
292
369
|
|
|
370
|
+
// MARK: - Wildcard (CoreBluetooth) scan
|
|
371
|
+
|
|
372
|
+
private func startWildcardScan(durationMs: Int) {
|
|
373
|
+
wildcardScannedBeacons = []
|
|
374
|
+
|
|
375
|
+
if centralManager == nil {
|
|
376
|
+
// Creating CBCentralManager triggers a Bluetooth power-on check.
|
|
377
|
+
// Once .poweredOn, the delegate calls beginWildcardScanning().
|
|
378
|
+
centralManager = CBCentralManager(delegate: bluetoothDelegate, queue: .main)
|
|
379
|
+
} else if centralManager?.state == .poweredOn {
|
|
380
|
+
beginWildcardScanning()
|
|
381
|
+
}
|
|
382
|
+
// If state is not yet .poweredOn the delegate will call beginWildcardScanning()
|
|
383
|
+
// when centralManagerDidUpdateState fires.
|
|
384
|
+
|
|
385
|
+
let timer = DispatchWorkItem { [weak self] in
|
|
386
|
+
self?.stopWildcardScanAndResolve()
|
|
387
|
+
}
|
|
388
|
+
wildcardScanTimer = timer
|
|
389
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(durationMs), execute: timer)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
fileprivate func beginWildcardScanning() {
|
|
393
|
+
guard scanPromise != nil, wildcardScanTimer != nil else { return }
|
|
394
|
+
centralManager?.scanForPeripherals(
|
|
395
|
+
withServices: nil,
|
|
396
|
+
options: [CBCentralManagerScanOptionAllowDuplicatesKey: true]
|
|
397
|
+
)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
fileprivate func handleWildcardDiscovery(advertisementData: [String: Any], rssi: NSNumber) {
|
|
401
|
+
guard let mfgData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data,
|
|
402
|
+
mfgData.count >= 25 else { return }
|
|
403
|
+
|
|
404
|
+
// iBeacon format: Apple company ID (0x4C 0x00) + type 0x02 + length 0x15
|
|
405
|
+
guard mfgData[0] == 0x4C, mfgData[1] == 0x00,
|
|
406
|
+
mfgData[2] == 0x02, mfgData[3] == 0x15 else { return }
|
|
407
|
+
|
|
408
|
+
// Parse UUID (bytes 4–19)
|
|
409
|
+
let uuidBytes = [UInt8](mfgData[4..<20])
|
|
410
|
+
let uuid = NSUUID(uuidBytes: uuidBytes) as UUID
|
|
411
|
+
|
|
412
|
+
// Parse major (bytes 20–21, big-endian) and minor (bytes 22–23, big-endian)
|
|
413
|
+
let major = Int(UInt16(mfgData[20]) << 8 | UInt16(mfgData[21]))
|
|
414
|
+
let minor = Int(UInt16(mfgData[22]) << 8 | UInt16(mfgData[23]))
|
|
415
|
+
|
|
416
|
+
// TX power (byte 24, signed)
|
|
417
|
+
let txPower = Int(Int8(bitPattern: mfgData[24]))
|
|
418
|
+
|
|
419
|
+
let rssiValue = rssi.intValue
|
|
420
|
+
let distance = Self.calculateDistance(rssi: rssiValue, txPower: txPower)
|
|
421
|
+
|
|
422
|
+
let result: [String: Any] = [
|
|
423
|
+
"uuid": uuid.uuidString.uppercased(),
|
|
424
|
+
"major": major,
|
|
425
|
+
"minor": minor,
|
|
426
|
+
"rssi": rssiValue,
|
|
427
|
+
"distance": distance,
|
|
428
|
+
"txPower": txPower
|
|
429
|
+
]
|
|
430
|
+
wildcardScannedBeacons.append(result)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
private func stopWildcardScanAndResolve() {
|
|
434
|
+
wildcardScanTimer?.cancel()
|
|
435
|
+
wildcardScanTimer = nil
|
|
436
|
+
centralManager?.stopScan()
|
|
437
|
+
|
|
438
|
+
// Deduplicate by uuid:major:minor, keeping the last (freshest) reading
|
|
439
|
+
var seen = Set<String>()
|
|
440
|
+
var deduped: [[String: Any]] = []
|
|
441
|
+
for beacon in wildcardScannedBeacons.reversed() {
|
|
442
|
+
let key = "\(beacon["uuid"] ?? ""):\(beacon["major"] ?? ""):\(beacon["minor"] ?? "")"
|
|
443
|
+
guard !seen.contains(key) else { continue }
|
|
444
|
+
seen.insert(key)
|
|
445
|
+
deduped.append(beacon)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
scanPromise?.resolve(deduped)
|
|
449
|
+
scanPromise = nil
|
|
450
|
+
wildcardScannedBeacons = []
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/// Log-distance path loss model: distance = 10 ^ ((txPower - rssi) / (10 * n)), n = 2.0
|
|
454
|
+
private static func calculateDistance(rssi: Int, txPower: Int) -> Double {
|
|
455
|
+
guard rssi != 0 else { return -1 }
|
|
456
|
+
let ratio = Double(txPower - rssi) / 20.0
|
|
457
|
+
return pow(10.0, ratio)
|
|
458
|
+
}
|
|
459
|
+
|
|
293
460
|
private func loadPairedBeaconsRaw() -> [[String: Any]] {
|
|
294
461
|
return UserDefaults.standard.array(forKey: PAIRED_BEACONS_KEY) as? [[String: Any]] ?? []
|
|
295
462
|
}
|
|
@@ -342,79 +509,119 @@ public class ExpoBeaconModule: NSObject, Module, CLLocationManagerDelegate {
|
|
|
342
509
|
return a.uuid == b.uuid && a.major == b.major && a.minor == b.minor
|
|
343
510
|
}
|
|
344
511
|
|
|
345
|
-
// MARK: - CLLocationManagerDelegate
|
|
512
|
+
// MARK: - CLLocationManagerDelegate handlers (called by LocationDelegate)
|
|
346
513
|
|
|
347
|
-
|
|
514
|
+
fileprivate func handleDidChangeAuthorization(_ status: CLAuthorizationStatus) {
|
|
348
515
|
let granted = (status == .authorizedAlways || status == .authorizedWhenInUse)
|
|
349
516
|
permissionCompletion?(granted)
|
|
350
517
|
permissionCompletion = nil
|
|
351
518
|
}
|
|
352
519
|
|
|
353
|
-
|
|
520
|
+
fileprivate func handleDidRange(_ beacons: [CLBeacon], satisfying constraint: CLBeaconIdentityConstraint) {
|
|
354
521
|
// 1. One-shot scan mode
|
|
355
|
-
if
|
|
522
|
+
if scanConstraints.contains(where: { constraintMatches($0, constraint) }) {
|
|
356
523
|
scannedBeacons.append(contentsOf: beacons)
|
|
357
524
|
return
|
|
358
525
|
}
|
|
359
526
|
|
|
360
527
|
// 2. Distance-ranging for monitored beacons
|
|
361
528
|
if let (identifier, _) = distanceRangingConstraints.first(where: { constraintMatches($0.value, constraint) }) {
|
|
362
|
-
|
|
363
|
-
return
|
|
364
|
-
}
|
|
529
|
+
let validBeacon = beacons.first(where: { $0.accuracy >= 0 })
|
|
365
530
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
"uuid": beacon.uuid.uuidString.uppercased(),
|
|
370
|
-
"major": beacon.major.intValue,
|
|
371
|
-
"minor": beacon.minor.intValue,
|
|
372
|
-
"distance": beacon.accuracy
|
|
373
|
-
]
|
|
374
|
-
sendEvent("onBeaconDistance", distParams)
|
|
375
|
-
print("[ExpoBeacon] DIST: \(identifier) → \(String(format: "%.2f", beacon.accuracy))m")
|
|
531
|
+
if let beacon = validBeacon {
|
|
532
|
+
// Got a valid reading — reset miss counter
|
|
533
|
+
missCounters[identifier] = 0
|
|
376
534
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
535
|
+
// Emit distance event every ranging cycle (~1 s)
|
|
536
|
+
let distParams: [String: Any] = [
|
|
537
|
+
"identifier": identifier,
|
|
538
|
+
"uuid": beacon.uuid.uuidString.uppercased(),
|
|
539
|
+
"major": beacon.major.intValue,
|
|
540
|
+
"minor": beacon.minor.intValue,
|
|
541
|
+
"distance": beacon.accuracy
|
|
542
|
+
]
|
|
543
|
+
sendEvent("onBeaconDistance", distParams)
|
|
544
|
+
|
|
545
|
+
// Distance-driven enter/exit synthesis with hysteresis
|
|
546
|
+
if let maxDist = UserDefaults.standard.object(forKey: MAX_DISTANCE_KEY) as? Double {
|
|
547
|
+
if beacon.accuracy <= maxDist {
|
|
548
|
+
// Reading is inside threshold
|
|
549
|
+
exitCounters[identifier] = 0
|
|
550
|
+
enterCounters[identifier] = (enterCounters[identifier] ?? 0) + 1
|
|
551
|
+
|
|
552
|
+
if !enteredRegions.contains(identifier) && (enterCounters[identifier] ?? 0) >= HYSTERESIS_COUNT {
|
|
553
|
+
enteredRegions.insert(identifier)
|
|
554
|
+
enterCounters[identifier] = 0
|
|
555
|
+
let params: [String: Any] = [
|
|
556
|
+
"identifier": identifier,
|
|
557
|
+
"uuid": beacon.uuid.uuidString.uppercased(),
|
|
558
|
+
"major": beacon.major.intValue,
|
|
559
|
+
"minor": beacon.minor.intValue,
|
|
560
|
+
"event": "enter",
|
|
561
|
+
"distance": beacon.accuracy
|
|
562
|
+
]
|
|
563
|
+
sendEvent("onBeaconEnter", params)
|
|
564
|
+
postBeaconNotification(identifier: identifier, eventType: "enter")
|
|
565
|
+
}
|
|
566
|
+
} else {
|
|
567
|
+
// Reading is outside threshold
|
|
568
|
+
enterCounters[identifier] = 0
|
|
569
|
+
exitCounters[identifier] = (exitCounters[identifier] ?? 0) + 1
|
|
570
|
+
|
|
571
|
+
if enteredRegions.contains(identifier) && (exitCounters[identifier] ?? 0) >= HYSTERESIS_COUNT {
|
|
572
|
+
enteredRegions.remove(identifier)
|
|
573
|
+
exitCounters[identifier] = 0
|
|
574
|
+
let params: [String: Any] = [
|
|
575
|
+
"identifier": identifier,
|
|
576
|
+
"uuid": beacon.uuid.uuidString.uppercased(),
|
|
577
|
+
"major": beacon.major.intValue,
|
|
578
|
+
"minor": beacon.minor.intValue,
|
|
579
|
+
"event": "exit",
|
|
580
|
+
"distance": beacon.accuracy
|
|
581
|
+
]
|
|
582
|
+
sendEvent("onBeaconExit", params)
|
|
583
|
+
postBeaconNotification(identifier: identifier, eventType: "exit")
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Also emit onBeaconFound if continuous scan is active for this beacon
|
|
589
|
+
if continuousScanActive {
|
|
590
|
+
let foundParams: [String: Any] = [
|
|
383
591
|
"uuid": beacon.uuid.uuidString.uppercased(),
|
|
384
592
|
"major": beacon.major.intValue,
|
|
385
593
|
"minor": beacon.minor.intValue,
|
|
386
|
-
"
|
|
387
|
-
"distance": beacon.accuracy
|
|
594
|
+
"rssi": beacon.rssi,
|
|
595
|
+
"distance": beacon.accuracy,
|
|
596
|
+
"txPower": 0
|
|
388
597
|
]
|
|
389
|
-
sendEvent("
|
|
390
|
-
|
|
391
|
-
|
|
598
|
+
sendEvent("onBeaconFound", foundParams)
|
|
599
|
+
}
|
|
600
|
+
} else {
|
|
601
|
+
// No valid beacon reading — beacon may have disappeared
|
|
602
|
+
let count = (missCounters[identifier] ?? 0) + 1
|
|
603
|
+
missCounters[identifier] = count
|
|
604
|
+
|
|
605
|
+
if enteredRegions.contains(identifier) && count >= EXIT_MISS_THRESHOLD {
|
|
392
606
|
enteredRegions.remove(identifier)
|
|
607
|
+
missCounters[identifier] = 0
|
|
608
|
+
enterCounters[identifier] = 0
|
|
609
|
+
exitCounters[identifier] = 0
|
|
610
|
+
|
|
611
|
+
// Look up region info for the exit event payload
|
|
612
|
+
let region = monitoredRegions.first { $0.identifier == identifier }
|
|
393
613
|
let params: [String: Any] = [
|
|
394
614
|
"identifier": identifier,
|
|
395
|
-
"uuid":
|
|
396
|
-
"major":
|
|
397
|
-
"minor":
|
|
615
|
+
"uuid": region?.uuid.uuidString.uppercased() ?? "",
|
|
616
|
+
"major": region?.major?.intValue ?? 0,
|
|
617
|
+
"minor": region?.minor?.intValue ?? 0,
|
|
398
618
|
"event": "exit",
|
|
399
|
-
"distance":
|
|
619
|
+
"distance": -1
|
|
400
620
|
]
|
|
401
621
|
sendEvent("onBeaconExit", params)
|
|
402
622
|
postBeaconNotification(identifier: identifier, eventType: "exit")
|
|
403
623
|
}
|
|
404
624
|
}
|
|
405
|
-
|
|
406
|
-
// Also emit onBeaconFound if continuous scan is active for this beacon
|
|
407
|
-
if continuousScanActive {
|
|
408
|
-
let foundParams: [String: Any] = [
|
|
409
|
-
"uuid": beacon.uuid.uuidString.uppercased(),
|
|
410
|
-
"major": beacon.major.intValue,
|
|
411
|
-
"minor": beacon.minor.intValue,
|
|
412
|
-
"rssi": beacon.rssi,
|
|
413
|
-
"distance": beacon.accuracy,
|
|
414
|
-
"txPower": 0
|
|
415
|
-
]
|
|
416
|
-
sendEvent("onBeaconFound", foundParams)
|
|
417
|
-
}
|
|
418
625
|
return
|
|
419
626
|
}
|
|
420
627
|
|
|
@@ -435,7 +642,7 @@ public class ExpoBeaconModule: NSObject, Module, CLLocationManagerDelegate {
|
|
|
435
642
|
}
|
|
436
643
|
}
|
|
437
644
|
|
|
438
|
-
|
|
645
|
+
fileprivate func handleDidEnterRegion(_ region: CLRegion) {
|
|
439
646
|
guard let beaconRegion = region as? CLBeaconRegion else { return }
|
|
440
647
|
let identifier = beaconRegion.identifier
|
|
441
648
|
|
|
@@ -454,7 +661,7 @@ public class ExpoBeaconModule: NSObject, Module, CLLocationManagerDelegate {
|
|
|
454
661
|
postBeaconNotification(identifier: identifier, eventType: "enter")
|
|
455
662
|
}
|
|
456
663
|
|
|
457
|
-
|
|
664
|
+
fileprivate func handleDidExitRegion(_ region: CLRegion) {
|
|
458
665
|
guard let beaconRegion = region as? CLBeaconRegion else { return }
|
|
459
666
|
let identifier = beaconRegion.identifier
|
|
460
667
|
|
|
@@ -476,8 +683,90 @@ public class ExpoBeaconModule: NSObject, Module, CLLocationManagerDelegate {
|
|
|
476
683
|
postBeaconNotification(identifier: identifier, eventType: "exit")
|
|
477
684
|
}
|
|
478
685
|
|
|
479
|
-
|
|
686
|
+
fileprivate func handleMonitoringDidFail(for region: CLRegion?, withError error: Error) {
|
|
480
687
|
print("[ExpoBeacon] Monitoring failed for region \(region?.identifier ?? "unknown"): \(error.localizedDescription)")
|
|
481
688
|
}
|
|
689
|
+
|
|
690
|
+
fileprivate func handleDidFailRanging(for constraint: CLBeaconIdentityConstraint, error: Error) {
|
|
691
|
+
print("[ExpoBeacon] Ranging failed for constraint \(constraint.uuid): \(error.localizedDescription)")
|
|
692
|
+
|
|
693
|
+
// If a one-shot scan is active and this constraint belongs to it, reject the promise
|
|
694
|
+
if scanPromise != nil && scanConstraints.contains(where: { constraintMatches($0, constraint) }) {
|
|
695
|
+
// Stop all scan constraints
|
|
696
|
+
for sc in scanConstraints {
|
|
697
|
+
locationManager.stopRangingBeacons(satisfying: sc)
|
|
698
|
+
}
|
|
699
|
+
scanConstraints.removeAll()
|
|
700
|
+
scannedBeacons.removeAll()
|
|
701
|
+
scanPromise?.reject("RANGING_FAILED", "Beacon ranging failed: \(error.localizedDescription)")
|
|
702
|
+
scanPromise = nil
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// MARK: - CLLocationManagerDelegate
|
|
708
|
+
|
|
709
|
+
private class LocationDelegate: NSObject, CLLocationManagerDelegate {
|
|
710
|
+
private weak var module: ExpoBeaconModule?
|
|
711
|
+
|
|
712
|
+
init(module: ExpoBeaconModule) {
|
|
713
|
+
self.module = module
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
|
|
717
|
+
module?.handleDidChangeAuthorization(status)
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
func locationManager(_ manager: CLLocationManager, didRange beacons: [CLBeacon], satisfying constraint: CLBeaconIdentityConstraint) {
|
|
721
|
+
module?.handleDidRange(beacons, satisfying: constraint)
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
func locationManager(_ manager: CLLocationManager, didEnterRegion region: CLRegion) {
|
|
725
|
+
module?.handleDidEnterRegion(region)
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
func locationManager(_ manager: CLLocationManager, didExitRegion region: CLRegion) {
|
|
729
|
+
module?.handleDidExitRegion(region)
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
func locationManager(_ manager: CLLocationManager, monitoringDidFailFor region: CLRegion?, withError error: Error) {
|
|
733
|
+
module?.handleMonitoringDidFail(for: region, withError: error)
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
func locationManager(_ manager: CLLocationManager, didFailRangingFor beaconConstraint: CLBeaconIdentityConstraint, error: Error) {
|
|
737
|
+
module?.handleDidFailRanging(for: beaconConstraint, error: error)
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// MARK: - CBCentralManagerDelegate (wildcard iBeacon scanning)
|
|
742
|
+
|
|
743
|
+
private class BluetoothDelegate: NSObject, CBCentralManagerDelegate {
|
|
744
|
+
private weak var module: ExpoBeaconModule?
|
|
745
|
+
|
|
746
|
+
init(module: ExpoBeaconModule) {
|
|
747
|
+
self.module = module
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
|
751
|
+
switch central.state {
|
|
752
|
+
case .poweredOn:
|
|
753
|
+
module?.beginWildcardScanning()
|
|
754
|
+
case .unauthorized:
|
|
755
|
+
module?.scanPromise?.reject("BLUETOOTH_UNAUTHORIZED", "Bluetooth permission denied")
|
|
756
|
+
module?.scanPromise = nil
|
|
757
|
+
case .poweredOff:
|
|
758
|
+
module?.scanPromise?.reject("BLUETOOTH_OFF", "Bluetooth is powered off")
|
|
759
|
+
module?.scanPromise = nil
|
|
760
|
+
default:
|
|
761
|
+
break
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
func centralManager(_ central: CBCentralManager,
|
|
766
|
+
didDiscover peripheral: CBPeripheral,
|
|
767
|
+
advertisementData: [String: Any],
|
|
768
|
+
rssi RSSI: NSNumber) {
|
|
769
|
+
module?.handleWildcardDiscovery(advertisementData: advertisementData, rssi: RSSI)
|
|
770
|
+
}
|
|
482
771
|
}
|
|
483
772
|
|
package/package.json
CHANGED
package/src/ExpoBeacon.types.ts
CHANGED
|
@@ -27,16 +27,6 @@ export type BeaconRegionEvent = {
|
|
|
27
27
|
distance: number;
|
|
28
28
|
};
|
|
29
29
|
|
|
30
|
-
/** Payload for ranging events (beacon proximity update). */
|
|
31
|
-
export type BeaconRangingEvent = {
|
|
32
|
-
identifier: string;
|
|
33
|
-
uuid: string;
|
|
34
|
-
major: number;
|
|
35
|
-
minor: number;
|
|
36
|
-
rssi: number;
|
|
37
|
-
distance: number;
|
|
38
|
-
};
|
|
39
|
-
|
|
40
30
|
/** Payload for periodic distance update events during monitoring. */
|
|
41
31
|
export type BeaconDistanceEvent = {
|
|
42
32
|
identifier: string;
|
|
@@ -113,7 +103,6 @@ export type MonitoringOptions = {
|
|
|
113
103
|
export type ExpoBeaconModuleEvents = {
|
|
114
104
|
onBeaconEnter: (params: BeaconRegionEvent) => void;
|
|
115
105
|
onBeaconExit: (params: BeaconRegionEvent) => void;
|
|
116
|
-
onBeaconRanging: (params: BeaconRangingEvent) => void;
|
|
117
106
|
onBeaconDistance: (params: BeaconDistanceEvent) => void;
|
|
118
107
|
/** Fired continuously during a live scan as each beacon is detected. */
|
|
119
108
|
onBeaconFound: (params: BeaconScanResult) => void;
|
package/src/ExpoBeaconModule.ts
CHANGED
|
@@ -10,10 +10,19 @@ import {
|
|
|
10
10
|
|
|
11
11
|
declare class ExpoBeaconModule extends NativeModule<ExpoBeaconModuleEvents> {
|
|
12
12
|
/**
|
|
13
|
-
* Start a one-shot
|
|
13
|
+
* Start a one-shot iBeacon scan. Resolves with discovered beacons after scanDuration ms.
|
|
14
|
+
*
|
|
15
|
+
* Pass one or more UUIDs to scan for specific beacons (uses CoreLocation on iOS).
|
|
16
|
+
* Pass an empty array or omit to perform a wildcard scan that discovers all nearby
|
|
17
|
+
* iBeacons (uses CoreBluetooth on iOS — foreground only).
|
|
18
|
+
*
|
|
19
|
+
* @param uuids Proximity UUIDs to filter by. Empty/omitted = wildcard scan.
|
|
14
20
|
* @param scanDuration Duration in ms (default 5000)
|
|
15
21
|
*/
|
|
16
|
-
scanForBeaconsAsync(
|
|
22
|
+
scanForBeaconsAsync(
|
|
23
|
+
uuids?: string[],
|
|
24
|
+
scanDuration?: number,
|
|
25
|
+
): Promise<BeaconScanResult[]>;
|
|
17
26
|
|
|
18
27
|
/**
|
|
19
28
|
* Register a beacon for persistent region monitoring.
|