Pairing a Camera

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.

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
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

Steps 1–5 and 7 are Bluetooth; steps 6 and 8 are ordinary API calls 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. An empty string means the camera has no device id to report; that is not an error and pairing can continue.

5. Scan for Wi-Fi networks

ble.sendWiFiScanCommand()
ble.getWiFiList()

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.

getWiFiList() starts a 90-second guard timer. In IVSDK 3.0.1 the timer’s check can never fire (it tests a non-optional array against nil), so an empty scan produces no state change — run your own timeout on wifiList staying empty and offer a rescan.

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: "GMT-05:00")
    )
)

The response carries the sessionKey and the region to hand to the camera. For re-pairing a cellular camera, pass sessionType: .fourG(deviceId:) instead of the default .other.

7. Write the credentials

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

Take sessionKey and region from the pairing-session response rather than composing them — the region returned there is the one the camera must register against.

The write takes a few seconds and there is no success callback. On most cameras the SDK disconnects after the final write and the camera leaves Bluetooth to join the network, so cameraConnectionStatus becoming .notConnected at this point is expected, not an error. 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 let status = state.data else { return }
        if status.expired {
            showFailure("Pairing timed out.")
        } else if let pairing = status.pairing {
            // pairing.status reaching its terminal value means success;
            // pairing.reason carries the message when it fails.
        }
    }
    .store(in: &cancellables)

login reports the camera reaching the cloud; pairing reports it being attached to the space. Stop polling once expired is true or pairing reaches a terminal state, and surface pairing?.reason on failure — it is the most useful thing you can tell the user.

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. If the camera has no device id to report, bleCameraDID is set to "" 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 environment matching your InstaSDK configuration
region PairingSessionKeyModel.region from the same response

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.

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.


Table of contents