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.
@@ -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
- // 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
94
+ // Build UUID listiOS 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
- for uuidStr in uuids {
87
- guard let uuid = UUID(uuidString: uuidStr) else {
88
- promise.reject("INVALID_UUID", "Invalid UUID: \(uuidStr)")
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
- parsedUUIDs.append(uuid)
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
- self.startContinuousScanRanging()
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 paired beacons not already covered by distance ranging
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
- // Reuse the existing distance-ranging stream if monitoring is active
332
- if distanceRangingConstraints[identifier] != nil { continue }
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
- centralManager?.stopScan()
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
- // Also emit onBeaconFound if continuous scan is active for this beacon
592
- if continuousScanActive {
593
- let foundParams: [String: Any] = [
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?.beginWildcardScanning()
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-beacon",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "Expo module for scanning, pairing, and monitoring iBeacons on Android and iOS",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
@@ -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 beacon is detected. */
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
  };