Pairing over Bluetooth

A camera fresh out of the box has no network. BLEManager bridges that gap: it finds the camera advertising over Bluetooth Low Energy, connects to it, asks it which Wi-Fi networks it can see, and writes the credentials plus a pairing session key so the camera can join the network and register itself with InstaVision.

public class BLEManager: NSObject, ObservableObject

BLEManager is an ObservableObject. Its methods are commands that return Void, and every result — the camera list, the Wi-Fi list, the connection state — arrives through @Published properties. There are no completion handlers.

Bluetooth is not the only way in. When the camera has no Bluetooth or the user declines the permission, the camera never appears in the scan, or the connection drops, fall back to Pairing with a QR Code — the same session key and the same confirmation step, without the radio. Every dead end below should offer it.

Contents

  1. Before you start
  2. The flow
  3. Walkthrough
  4. Published properties
  5. Methods
  6. Types
  7. Common problems

Before you start

Add a Bluetooth usage description to Info.plist, or the app is terminated the first time the central manager is created:

<key>NSBluetoothAlwaysUsageDescription</key>
<string>Used to set up your camera over Bluetooth.</string>

The camera must be in pairing mode. Only InstaVision cameras are surfaced — every other BLE peripheral nearby is filtered out.


The flow

1. initCBCentralManager()     → prompts for Bluetooth permission
2. startScan()                → cameraList fills as cameras are found
3. connectToCamera(camera:)   → cameraConnectionStatus becomes .connected
4. fetchCameraDid()           → bleCameraDIDFetched becomes true
   deviceModelInfo(…)         → the model behind that device id, and what it supports
5. sendWiFiScanCommand()      → wifiList fills with what the camera can see
   getWiFiList()              → starts the 90 s result guard timer
6. createPairingSessionKey    → a session key from SpaceService
7. updateWifiInfo(wifiInfo:)  → writes credentials; the camera leaves BLE
8. getPairingSessionStatus    → poll until the camera appears in the cloud
   device(spaceId:deviceId:)  → poll until pairingStatus is ready to use

Steps 1–5 and 7 are Bluetooth. The rest are ordinary API calls: the model lookup on Factory.deviceService, and the session key and its status on Factory.spaceService — see Pairing Session Keys.


Walkthrough

1. Ask for permission and scan

let ble = BLEManager.instance
ble.initCBCentralManager()   // triggers the system permission prompt
ble.startScan()

Call initCBCentralManager() when the user reaches the pairing screen, not at launch — that is when the permission prompt appears, and it makes far more sense to the user there.

startScan() resets everything first, so it is safe to call again to restart a search. If nothing is found before the scan times out, noCameraFound becomes true and scanning stops.

2. Let the user choose a camera

Cameras appear in cameraList, sorted by signal strength then name — the nearest camera is first, which is usually the one the user is standing next to.

struct CameraPickerView: View {
    @ObservedObject var ble = BLEManager.instance

    var body: some View {
        List(ble.cameraList, id: \.identifier) { camera in
            Button {
                ble.connectToCamera(camera: camera)
            } label: {
                HStack {
                    Text(camera.name)
                    Spacer()
                    Text(String(describing: camera.signal))
                        .foregroundStyle(.secondary)
                }
            }
        }
        .overlay {
            if ble.noCameraFound {
                VStack(spacing: 8) {
                    Image(systemName: "antenna.radiowaves.left.and.right.slash")
                    Text("No cameras found").font(.headline)
                    Text("Make sure the camera is powered on and in pairing mode.")
                        .font(.subheadline).foregroundStyle(.secondary)
                }
            }
        }
    }
}

camera.name is already display-ready — the SDK turns the advertised name into Camera A1B2.

3. Wait for the connection

cameraConnectionStatus becomes .connected once the camera is ready to accept commands.

ble.$cameraConnectionStatus
    .receive(on: DispatchQueue.main)
    .sink { status in
        switch status {
        case .connected:    proceedToWiFiStep()
        case .notConnected: showRetry()
        case .none:         break
        }
    }
    .store(in: &cancellables)

.none is the initial state, before any attempt — do not treat it as a failure.

4. Read the camera’s device id

ble.fetchCameraDid()

ble.$bleCameraDIDFetched
    .filter { $0 }
    .receive(on: DispatchQueue.main)
    .sink { _ in
        let did = ble.bleCameraDID   // may be "" if the camera exposes no DID
    }
    .store(in: &cancellables)

bleCameraDID is not itself published — observe bleCameraDIDFetched and read the id when it becomes true. A device id is exactly 27 characters; anything shorter is not stored, so an empty string means the camera reported nothing usable.

That id is what identifies the model, so look it up before going any further:

Factory.deviceService.deviceModelInfo(spaceId: spaceId, deviceId: ble.bleCameraDID)
    .receive(on: DispatchQueue.main)
    .sink { result in
        guard case let .success(info) = result else { return }
        let properties = info.modelDetails?.properties
        let is4G  = properties?.fourGProps?.enabled ?? false
        let bands = properties?.wifiBand ?? []
    }
    .store(in: &cancellables)

This is the branch point of the whole flow. A camera with fourGProps.enabled goes through SIM activation instead of a Wi-Fi scan; models that need a guided physical setup have their own flow; and wifiBand is what lets you warn the user before they choose a network the camera cannot join. Only an ordinary Wi-Fi camera continues to step 5.

Without a 27-character id there is no model to look up. Treat that as a failed connection rather than carrying on — offer a retry and the QR code route.

5. Scan for Wi-Fi networks

ble.sendWiFiScanCommand()
ble.getWiFiList()

Send the scan command as soon as the model lookup succeeds, and call getWiFiList() when the network-picker screen appears — the results arrive while the user is still moving between screens, so the list is already filling by the time they see it. Skip both for a 4G camera or a model with its own setup flow.

Results stream in and append to wifiList over a few seconds, de-duplicated by SSID — so render the list as it grows rather than waiting for a completion.

ble.$wifiList
    .receive(on: DispatchQueue.main)
    .sink { networks in self.networks = networks }
    .store(in: &cancellables)

These are the networks the camera can see, not the phone. A network with full bars on the phone may be weak where the camera is mounted, and WifiNetwork.rssi is the honest number — show it, so the user can pick a network that will actually hold up.

6. Create a pairing session key

Before writing credentials, ask the backend for a session key. It is what lets the camera register itself against the right space.

Factory.spaceService.createPairingSessionKey(
    spaceId: spaceId,
    request: PairingSessionKeyRequest(
        timezoneSettings: TimezoneSettings(id: "America/New_York",
                                           tzFormat: "EST+5EDT,M3.2.0/2,M11.1.0/2")
    )
)

The response carries the sessionKey — the one value in the next step you cannot derive yourself. For re-pairing a cellular camera, pass sessionType: .fourG(deviceId:) instead of the default .other.

Create the key immediately before writing the credentials, not earlier in the flow. It expires, and the clock starts here.

7. Write the credentials

ble.updateWifiInfo(wifiInfo: WiFiDetailModel(
    ssid: selectedNetwork.ssid,
    password: enteredPassword,
    sessionKey: session.sessionKey,
    env: "1",      // production
    region: "1"    // US
))

env and region are numeric codes, not the Environment and ServerRegion values you passed to InstaSDK.configure. Map them from the same configuration your app was built with:

Configured with Value to send
environment: .production env = "1"
environment: .staging env = "2"
environment: .dev env = "3"
region: .us region = "1"
region: .apac region = "3"

Get these wrong and the camera registers against a backend the user does not exist on. Nothing reports the mismatch — pairing simply never completes.

The write takes a few seconds and there is no success callback. The SDK writes the five values in order and then disconnects itself, without moving cameraConnectionStatus — so a .notConnected arriving at this point is a link that dropped on its own, not the orderly hand-off. Treat it as a Bluetooth failure and offer the QR code route, but keep polling in step 8 regardless: the camera may already have everything it needs, in which case pairing completes and the warning can be dismissed.

Cameras that use the chunked write path do not auto-disconnect — call disconnect() yourself when leaving the screen regardless.

8. Confirm the camera reached the cloud

Poll the pairing session with the same key:

Timer.publish(every: 3, on: .main, in: .common)
    .autoconnect()
    .flatMap { _ in
        Factory.spaceService.getPairingSessionStatus(spaceId: spaceId, sessionKey: sessionKey)
    }
    .receive(on: DispatchQueue.main)
    .sink { state in
        guard case let .success(status) = state else { return }

        if status.expired {
            showFailure("Pairing timed out.")
            return
        }
        if let login = status.login, login.status == "Failure" {
            showFailure(login.reason)
            return
        }
        if let pairing = status.pairing, pairing.status == "Failure" {
            showFailure(pairing.reason)
            return
        }
        if let step = DeviceAuthStatus(rawValue: status.status),
           step != .initialized, !status.deviceId.isEmpty {
            // The camera is in the cloud. Continue with status.deviceId.
        }
    }
    .store(in: &cancellables)

Three things end the poll, and all three need handling:

  • expired — the session ran out before the camera got there.
  • login.status or pairing.status equal to "Failure" — the camera arrived and was rejected. login reports it reaching the cloud and pairing reports it being attached to the space, so which of the two failed tells you how far it got. reason carries the message, and it is the most useful thing you can tell the user.
  • status past .initialized and a non-empty deviceId — success. Both matter: the status can advance a moment before the device id is filled in, so a camera with an empty deviceId is not ready yet, whatever the status says. Keep polling.

Poll every three seconds and budget around three minutes. A camera still has to join the network, reach the backend and register itself, so one to two minutes is normal — show progress rather than a bare spinner.

Once you have the device id, switch to polling the device itself until it is ready to use:

Factory.deviceService.device(spaceId: spaceId, deviceId: status.deviceId)
    .receive(on: DispatchQueue.main)
    .sink { result in
        guard case let .success(device) = result else { return }
        if DeviceAuthStatus(rawValue: device.pairingStatus)?.isReadyForAuth == true {
            finishPairing(device)
        }
    }
    .store(in: &cancellables)

isReadyForAuth is true for .paired and .activated, and that is the point at which the camera is genuinely usable. Anything earlier means keep polling.

Clean up

func onLeavingPairingScreen() {
    BLEManager.instance.disconnect()
}

Always disconnect when the user leaves so the radio is released. (startScan() also drops any existing connection before scanning.)


Published properties

Access everything through the shared instance:

static public let instance: BLEManager
Property Type Meaning Published
cBManagerState CBManagerState? Bluetooth state. .poweredOn is the only state in which scanning works.
authorizationStatus AuthorizationStatus Whether the user has granted Bluetooth permission.
cameraList [BLECamera] Discovered cameras, sorted by signal strength then name.
noCameraFound Bool true when a scan ends with nothing found.
cameraConnectionStatus CameraConnectionStatus Connection state with the selected camera.
bleCameraDIDFetched Bool true once the device id read has completed.
wifiList [WifiNetwork] Wi-Fi networks the camera can see. Accumulates as results arrive.
bleCameraDID String The connected camera’s device id.

bleCameraDID is a plain var, not @Published. Observe bleCameraDIDFetched and read bleCameraDID when it flips to true.

Observing

// SwiftUI
@ObservedObject var ble = BLEManager.instance

// Combine
BLEManager.instance.$cameraConnectionStatus
    .receive(on: DispatchQueue.main)
    .sink { status in /* … */ }
    .store(in: &cancellables)

Methods

initCBCentralManager()

public func initCBCentralManager()

Creates the Bluetooth central and begins delivering state updates. This is the call that triggers the system permission prompt.

startScan()

public func startScan()

Begins discovery. Resets everything first — clears cameraList and wifiList, drops any existing connection, and sets cameraConnectionStatus to .none — so it is safe to call again to restart a search.

If the central has not been created yet, startScan() creates it. If Bluetooth is not .poweredOn, scanning starts automatically once it becomes ready. If nothing is found before the scan times out, noCameraFound becomes true and scanning stops.

stopScan()

public func stopScan()

Stops discovery. The SDK calls this itself once a camera is fully connected, so you rarely need it except when the user leaves the screen.

connectToCamera(camera:)

public func connectToCamera(camera: BLECamera?)

Connects to a camera from cameraList, matched by its identifier. Passing the already-connected camera is a no-op beyond stopping the scan. On success cameraConnectionStatus becomes .connected; if the camera does not become ready in time it becomes .notConnected.

disconnect()

public func disconnect()

Stops scanning and drops the connection. Call it when the user abandons pairing, and in onDisappear. The SDK also calls it once Wi-Fi credentials have been written.

fetchCameraDid()

public func fetchCameraDid()

Reads the camera’s device identifier. Call it after cameraConnectionStatus becomes .connected, then wait for bleCameraDIDFetched. A device id is exactly 27 characters, and anything shorter is discarded — bleCameraDID is left as "" and bleCameraDIDFetched still becomes true, so check for an empty string rather than assuming a value.

sendWiFiScanCommand()

public func sendWiFiScanCommand()

Asks the camera to scan for Wi-Fi networks. Clears wifiList first. Results arrive asynchronously and append to wifiList — expect it to fill over a few seconds rather than appearing at once.

getWiFiList()

public func getWiFiList()

Starts a 90-second guard timer for the scan. Call it after sendWiFiScanCommand(). Intended to set cameraConnectionStatus to .notConnected when nothing arrives, but in 3.0.1 the check cannot fire — see step 5.

updateWifiInfo(wifiInfo:)

public func updateWifiInfo(wifiInfo: WiFiDetailModel)

Writes the Wi-Fi credentials and pairing details to the camera. This is the final BLE step. It takes a few seconds and reports no completion. On the standard characteristic layout the SDK disconnects after the final write; on the chunked layout it does not, so call disconnect() yourself. Confirm the outcome by polling getPairingSessionStatus with the same session key.


Types

CameraConnectionStatus

public enum CameraConnectionStatus {
    case connected
    case notConnected
    case none
}

.none is the initial state, before any attempt. .notConnected means an attempt failed, timed out, or the camera disconnected — treat the two differently in your UI.

AuthorizationStatus

public enum BLEManager.AuthorizationStatus {
    case allowed
    case notAllowed
    case none
}

.none means not yet determined; .notAllowed covers both denied and restricted. On .notAllowed, direct the user to Settings — the prompt is not shown twice.

authorizationStatus also becomes .notAllowed when Bluetooth is powered off during a scan, so check cBManagerState before telling the user permission was denied.

BLECamera

public struct BLECamera: Equatable {
    public var name: String
    public var identifier: UUID
    public var rssi: Int
    public var cameraDid: String?
    public var signal: Signal          // derived from rssi; init(name:identifier:rssi:cameraDid:)

    public enum Signal: Int {
        case poor = 0
        case weak
        case good
        case excellent
    }
}

name is display-ready: the SDK shortens the advertised name to Camera plus four characters, so the user sees Camera A1B2. signal is derived from rssi and is what cameraList is sorted by, descending — so the nearest camera is first.

Use identifier as the list id; it is stable for the duration of the session.

WifiNetwork

public struct WifiNetwork: Codable, Equatable {
    public var ssid: String
    public var securityProtocol: Int
    public var rssi: Int
}

One network as seen by the camera. securityProtocol is the protocol code the camera reported; a value indicating an open network means no password is required.

WiFiDetailModel

public struct WiFiDetailModel: Codable {
    public var ssid: String
    public var password: String
    public var sessionKey: String
    public var env: String
    public var region: String
}

Everything the camera needs to join the network and register itself.

Field Source
ssid The selected WifiNetwork.ssid
password Entered by the user (empty for an open network)
sessionKey PairingSessionKeyModel.sessionKey from createPairingSessionKey
env The numeric code for the environment your app is configured for — 1, 2 or 3
region The numeric code for your data residency region — 1 for .us, 3 for .apac

Common problems

No cameras found. Check cBManagerState before blaming permission. If it is .poweredOff the message is “turn Bluetooth on”; only authorizationStatus == .notAllowed with Bluetooth powered on means permission was denied — and in that case the prompt will not reappear, so direct the user to Settings.

Everything fails and the user is stuck. Every dead end here — Bluetooth off, permission denied, no camera found, connection dropped, empty Wi-Fi list — should offer pairing with a QR code as the way out. It needs no Bluetooth at all.

The device id read back is empty or short. Only a 27-character id is kept, and without one there is no model to look up. Reconnect and read it again, or fall back to the QR flow, where the id is scanned off the camera’s own label instead.

Cameras found on the first try but not the second. Bluetooth may still be settling after the previous connection. Call disconnect() when leaving the screen, then startScan() again.

The Wi-Fi list stays empty. The camera may not have finished booting. Wait for cameraConnectionStatus == .connected before calling sendWiFiScanCommand(), and offer a rescan.

Credentials written but the camera never appears. Almost always a wrong Wi-Fi password or a 5 GHz-only network on a camera that supports 2.4 GHz. The camera cannot report this back over BLE — it has already disconnected — so the pairing session’s pairing?.reason is your only signal.

Passwords in memory. WiFiDetailModel.password is a plain String. Do not log it, do not persist it outside the Keychain, and clear the entry field once pairing completes.