iOS SBeacon Framework Beacon Scanning - scopetech/Mobile-Public GitHub Wiki

Beacon Scanning

iOS SBeacon Framework v1.0.8

This guide covers discovering unpaired SBeacon devices using BLE scanning.


Overview

Beacon scanning allows you to discover nearby unpaired SBeacon devices. Discovered beacons appear in SBeaconManager.shared.unpairedSBeacons array and can be paired to your account.


Prerequisites


Starting a Scan

Basic Scan

import BLEFramework

// Start scanning for SBeacon devices
SBeaconManager.shared.startScan()

// Scanning continues until you call stopScan()

Timed Scan (Recommended)

For battery efficiency, scan for a limited duration:

func scanForBeacons(duration: TimeInterval = 10.0) {
    SBeaconManager.shared.startScan()

    DispatchQueue.main.asyncAfter(deadline: .now() + duration) {
        SBeaconManager.shared.stopScan()
    }
}

Short Scan (10 seconds)

The framework provides a convenience method for 10-second scans:

// Scans for 10 seconds then automatically stops
SBeaconManager.shared.scanForBeacons()

Stopping a Scan

// Stop scanning to save battery
SBeaconManager.shared.stopScan()

Important: Always stop scanning when:

  • You've found the beacon you're looking for
  • User navigates away from the scanning screen
  • App enters background
  • Battery optimization is important

Accessing Discovered Beacons

UnpairedSBeacon Model

Unpaired beacons discovered during scanning:

public class UnpairedSBeacon {
    public var minor: NSNumber      // iBeacon minor ID
    public var major: NSNumber      // iBeacon major ID
    public var macAddressEnding: String  // Last 4 bytes of MAC (e.g., "a3b5c7d9")
    public var peripheral: CBPeripheral?  // BLE peripheral reference
    public var lastTimeUpdated: Date      // Last time beacon was seen
    public var uuid: UUID                 // SBeacon UUID (E2C56DB5-DFFB...)
    public var range: NSNumber?           // RSSI signal strength
}

Reading Unpaired Beacons

// Get all discovered unpaired beacons
let unpairedBeacons = SBeaconManager.shared.unpairedSBeacons

for beacon in unpairedBeacons {
    print("Found beacon: ****\(beacon.macAddressEnding)")
    print("  Major: \(beacon.major)")
    print("  Minor: \(beacon.minor)")
    print("  RSSI: \(beacon.range ?? -100)")
}

Filtering Beacons

Filter by Major/Minor

func findBeacon(major: Int, minor: Int) -> UnpairedSBeacon? {
    return SBeaconManager.shared.unpairedSBeacons.first { beacon in
        beacon.major.intValue == major && beacon.minor.intValue == minor
    }
}

Filter by Signal Strength (RSSI)

// Get beacons with strong signal (closer devices)
let nearbyBeacons = SBeaconManager.shared.unpairedSBeacons.filter { beacon in
    guard let rssi = beacon.range?.intValue else { return false }
    return rssi > -70  // Strong signal threshold
}

Filter by Recency

// Get beacons seen in last 10 seconds
let recentBeacons = SBeaconManager.shared.unpairedSBeacons.filter { beacon in
    let timeSinceUpdate = Date().timeIntervalSince(beacon.lastTimeUpdated)
    return timeSinceUpdate < 10.0
}

SwiftUI Integration

Complete Scanning View

import SwiftUI
import BLEFramework

struct BeaconScanView: View {
    @StateObject private var viewModel = BeaconScanViewModel()

    var body: some View {
        List {
            Section("Unpaired Beacons") {
                ForEach(viewModel.unpairedBeacons, id: \.macAddressEnding) { beacon in
                    BeaconRow(beacon: beacon)
                        .onTapGesture {
                            viewModel.pairBeacon(beacon)
                        }
                }

                if viewModel.unpairedBeacons.isEmpty {
                    Text("No beacons found")
                        .foregroundColor(.secondary)
                }
            }
        }
        .navigationTitle("Scan for Beacons")
        .refreshable {
            await viewModel.refresh()
        }
        .onAppear {
            viewModel.startScanning()
        }
        .onDisappear {
            viewModel.stopScanning()
        }
    }
}

struct BeaconRow: View {
    let beacon: UnpairedSBeacon

    var body: some View {
        HStack {
            VStack(alignment: .leading) {
                Text("****\(beacon.macAddressEnding)")
                    .font(.headline)

                Text("Major: \(beacon.major), Minor: \(beacon.minor)")
                    .font(.caption)
                    .foregroundColor(.secondary)
            }

            Spacer()

            // Signal strength indicator
            Image(systemName: signalStrengthIcon)
                .foregroundColor(signalStrengthColor)
        }
    }

    private var signalStrengthIcon: String {
        guard let rssi = beacon.range?.intValue else {
            return "antenna.radiowaves.left.and.right.slash"
        }

        if rssi > -60 { return "antenna.radiowaves.left.and.right" }
        if rssi > -75 { return "antenna.radiowaves.left.and.right.slash" }
        return "wifi.slash"
    }

    private var signalStrengthColor: Color {
        guard let rssi = beacon.range?.intValue else { return .gray }

        if rssi > -60 { return .green }
        if rssi > -75 { return .orange }
        return .red
    }
}

ViewModel with Periodic Refresh

import Foundation
import Combine
import BLEFramework

class BeaconScanViewModel: ObservableObject {
    @Published var unpairedBeacons: [UnpairedSBeacon] = []
    @Published var isScanning = false

    private var refreshTimer: Timer?
    private let refreshInterval: TimeInterval = 1.0

    deinit {
        stopScanning()
    }

    func startScanning() {
        SBeaconManager.shared.startScan()
        isScanning = true

        // Periodically refresh the list to show updated RSSI values
        refreshTimer = Timer.scheduledTimer(withTimeInterval: refreshInterval, repeats: true) { [weak self] _ in
            self?.refreshBeacons()
        }

        // Save battery by allowing timer tolerance
        refreshTimer?.tolerance = refreshInterval / 2
    }

    func stopScanning() {
        SBeaconManager.shared.stopScan()
        refreshTimer?.invalidate()
        refreshTimer = nil
        isScanning = false
    }

    func refresh() async {
        // For pull-to-refresh
        startScanning()
        try? await Task.sleep(nanoseconds: 5_000_000_000)  // 5 seconds
        refreshBeacons()
    }

    func pairBeacon(_ beacon: UnpairedSBeacon) {
        // Pairing logic (see Beacon Pairing guide)
    }

    private func refreshBeacons() {
        // Copy array to trigger @Published update
        unpairedBeacons = SBeaconManager.shared.unpairedSBeacons
    }
}

UIKit Integration

Complete Scanning ViewController

import UIKit
import BLEFramework

class BeaconScanViewController: UITableViewController {

    private var unpairedBeacons: [UnpairedSBeacon] = []
    private var refreshTimer: Timer?

    override func viewDidLoad() {
        super.viewDidLoad()

        title = "Scan for Beacons"
        setupRefreshControl()
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        startScanning()
    }

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        stopScanning()
    }

    // MARK: - Scanning

    private func startScanning() {
        SBeaconManager.shared.startScan()

        // Refresh list every second
        refreshTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
            self?.refreshBeacons()
        }
    }

    private func stopScanning() {
        SBeaconManager.shared.stopScan()
        refreshTimer?.invalidate()
        refreshTimer = nil
    }

    private func refreshBeacons() {
        unpairedBeacons = SBeaconManager.shared.unpairedSBeacons
        tableView.reloadData()
    }

    private func setupRefreshControl() {
        refreshControl = UIRefreshControl()
        refreshControl?.addTarget(self, action: #selector(handleRefresh), for: .valueChanged)
    }

    @objc private func handleRefresh() {
        refreshBeacons()

        DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
            self?.refreshControl?.endRefreshing()
        }
    }

    // MARK: - Table View

    override func numberOfSections(in tableView: UITableView) -> Int {
        return 1
    }

    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return unpairedBeacons.count
    }

    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "BeaconCell") ?? UITableViewCell(style: .subtitle, reuseIdentifier: "BeaconCell")

        let beacon = unpairedBeacons[indexPath.row]

        cell.textLabel?.text = "****\(beacon.macAddressEnding)"
        cell.detailTextLabel?.text = "Major: \(beacon.major), Minor: \(beacon.minor)"

        // Show signal strength
        if let rssi = beacon.range?.intValue {
            cell.accessoryView = signalStrengthView(for: rssi)
        }

        return cell
    }

    override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)

        let beacon = unpairedBeacons[indexPath.row]
        pairBeacon(beacon)
    }

    private func signalStrengthView(for rssi: Int) -> UIView {
        let label = UILabel()
        label.font = .systemFont(ofSize: 12)

        if rssi > -60 {
            label.text = "Strong"
            label.textColor = .systemGreen
        } else if rssi > -75 {
            label.text = "Medium"
            label.textColor = .systemOrange
        } else {
            label.text = "Weak"
            label.textColor = .systemRed
        }

        label.sizeToFit()
        return label
    }

    private func pairBeacon(_ beacon: UnpairedSBeacon) {
        // Pairing logic (see Beacon Pairing guide)
    }
}

Scan Behavior Details

Device Discovery Lifecycle

  1. Scan Start → Framework subscribes to BLE scanner
  2. Device Discovered → Added to unpairedSBeacons array
  3. RSSI Updates → range property updated every ~1 second
  4. Device Timeout → Beacon removed if not seen for 20 seconds
  5. Scan Stop → Framework unsubscribes from BLE scanner

RSSI (Signal Strength) Values

RSSI Range Distance Signal Quality
-50 to 0 < 1 meter Excellent
-60 to -51 1-3 meters Good
-75 to -61 3-10 meters Fair
-90 to -76 10-30 meters Poor
< -90 > 30 meters Very Poor

SBeacon UUID

All SBeacon devices use the same UUID:

E2C56DB5-DFFB-48D2-B060-D0F5A71096E0

Devices are uniquely identified by their major and minor values.


Battery Optimization

Best Practices

1. Stop Scanning When Not Needed

override func viewWillDisappear(_ animated: Bool) {
    super.viewWillDisappear(animated)
    SBeaconManager.shared.stopScan()
}

2. Use Timed Scans

// Scan for 10 seconds, then stop
SBeaconManager.shared.scanForBeacons()  // Auto-stops after 10s

3. Limit Refresh Rate

// Don't refresh UI more than once per second
refreshTimer?.tolerance = 0.5  // Allow system to optimize

4. Scan Only When Necessary

  • Don't scan continuously in background
  • Pause scanning during other intensive operations
  • Consider user-initiated scanning (pull-to-refresh)

Troubleshooting

No Beacons Discovered

Problem: unpairedSBeacons array is empty

Solutions:

  • Ensure Bluetooth is enabled on device
  • Check Bluetooth permission granted (should prompt automatically)
  • Verify SBeacon device is powered on and advertising
  • Move closer to beacon (< 10 meters)
  • Check beacon battery level

Beacons Disappear from List

Problem: Beacons appear then vanish

Cause: Beacons not seen for 20 seconds are automatically removed

Solutions:

  • Ensure beacon is actively advertising
  • Check beacon battery isn't depleted
  • Reduce distance to beacon
  • Check for RF interference

RSSI Values Not Updating

Problem: range property stays constant

Solutions:

  • Ensure you're refreshing the UI periodically
  • Check scanning is actually running (scanRequestCounter > 0)
  • Verify beacon is in range

Duplicate Beacons

Problem: Same beacon appears multiple times

Cause: Framework should handle this automatically, but check:

Solutions:

  • Framework uses major/minor for uniqueness
  • Check you're not creating multiple managers
  • Ensure proper singleton usage

Best Practices

  1. Start/Stop Pattern: Always pair startScan() with stopScan()
  2. Lifecycle Management: Stop scanning in viewWillDisappear / .onDisappear
  3. Weak References: Use [weak self] in timers and closures
  4. Battery Awareness: Limit continuous scanning duration
  5. User Feedback: Show scanning indicator in UI
  6. Empty States: Handle case when no beacons found
  7. Signal Strength: Display RSSI to help users locate beacon
  8. Timeout Handling: Beacons disappear after 20s automatically

Related Pages

⚠️ **GitHub.com Fallback** ⚠️