Pairing with a QR Code
Bluetooth is not always usable. The phone may have Bluetooth switched off, the user may have declined the permission, the camera may not advertise, or the model may have no Bluetooth radio at all. QR pairing is the fallback that needs none of it: the app renders the Wi-Fi credentials and the pairing session key as a QR code on screen, and the camera reads it with its own lens.
Two different QR codes appear in this flow, and it is worth keeping them apart:
| QR code | Who reads it | What it carries |
|---|---|---|
| Printed on the camera or its box | The phone | The camera’s device id (DID) |
| Rendered by your app on screen | The camera | Wi-Fi credentials, session key, region, environment |
The first identifies the camera so you can look its model up. The second is the actual hand-off.
Contents
When to use it
Offer QR pairing wherever the Bluetooth flow can dead-end:
- Bluetooth is powered off, or
authorizationStatusis.notAllowed. noCameraFoundbecametrue— the camera never appeared in the scan.cameraConnectionStatusbecame.notConnected— the connection failed or dropped.- The Wi-Fi scan came back empty.
Each of those is a screen in its own right, and each should carry a “connect the camera via QR code” action alongside the retry.
Not every model supports it. Look at CameraModelProperties from deviceModelInfo before offering either transport — bluetooth says whether the model can be paired over BLE, and models that need a guided physical setup (a wired doorbell, for instance) may support neither.
QR pairing is not the same thing as a cellular camera’s SIM QR code. That one is scanned to read the SIM number for
bootstrapSim, and a 4G camera still has to go through SIM activation before either pairing transport is used.
The flow
1. Scan the camera's printed QR → a 27 character device id
2. deviceModelInfo(spaceId:deviceId:) → model, capabilities, 4G flag
3. Collect the Wi-Fi credentials → prefilled from the phone's own network
4. createPairingSessionKey → session key from SpaceService
5. Render the payload as a QR code → the camera reads it off the screen
6. getPairingSessionStatus → poll until the camera appears in the cloud
Only step 1 is device-specific work in your app; steps 2, 4 and 6 are ordinary API calls, and step 5 is one call to QRCodeGenerator. There is no Bluetooth anywhere in this flow, so none of BLEManager is involved.
Walkthrough
1. Read the camera’s device id
Scan the QR code printed on the camera with AVCaptureMetadataOutput. The SDK does not provide a scanner — this is ordinary AVFoundation work, and it needs a camera usage description:
<key>NSCameraUsageDescription</key>
<string>Used to scan the QR code on your camera.</string>
A valid device id is 27 characters with no whitespace. Validate before acting on a scan, or an unrelated QR code in the frame will be sent to the backend as a device id:
func isValidDID(_ scanned: String) -> Bool {
let trimmed = scanned.trimmingCharacters(in: .whitespacesAndNewlines)
return !trimmed.contains(" ") && trimmed.count == 27
}
2. Look the model up
The device id on its own does not tell you how to pair. Fetch the model information with it:
Factory.deviceService.deviceModelInfo(spaceId: spaceId, deviceId: scannedDid)
.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. A 4G camera goes through SIM activation first; everything else continues to the Wi-Fi step. wifiBand tells you which bands the model can join, which is what lets you warn the user before they pick a network the camera cannot reach.
A failure here usually means the scanned code is not an InstaVision device id — surface it as a scan failure and let the user try again, rather than as a network error.
3. Collect the Wi-Fi credentials
In the Bluetooth flow the user picks from the list the camera reported. There is no such list here, so the network has to be entered by hand — but you can do most of the typing for them. The phone is almost always already on the network the camera should join, so prefill the SSID from NEHotspotNetwork.fetchCurrent:
NEHotspotNetwork.fetchCurrent { network in
DispatchQueue.main.async {
if let ssid = network?.ssid { self.ssid = ssid }
}
}
fetchCurrent needs location authorization and the Hotspot entitlement; without them it returns nil, so treat the prefill as a convenience and always leave the field editable. Stop overwriting the field once the user has typed in it.
Two warnings earn their place on this screen:
- If
wifiBanddoes not include5 GHz, say so, and check the entered SSID for5gbefore continuing. A 5 GHz-only network is the single most common reason pairing silently fails. - Wi-Fi passwords are case-sensitive and there is no way to verify one before the camera tries it.
4. Create the pairing session key
Exactly as in the Bluetooth flow — see Pairing Session Keys:
Factory.spaceService.createPairingSessionKey(
spaceId: spaceId,
request: PairingSessionKeyRequest(
timezoneSettings: TimezoneSettings(id: "America/New_York",
tzFormat: "EST+5EDT,M3.2.0/2,M11.1.0/2")
)
)
Create it immediately before showing the QR code, not earlier in the flow. The session expires, and the clock starts here rather than when the camera finally reads the screen. Use device time zone in PairingSessionKeyRequest
5. Show the QR code
The payload is a single newline-separated string. Build it, render it, and put it on screen:
var qrPayload: String {
[ssid, password, session.sessionKey, regionCode, environmentCode]
.joined(separator: "\n")
}
if let image = QRCodeGenerator.generateQRCode(from: qrPayload) {
Image(uiImage: image)
.resizable()
.scaledToFit()
}
The camera is reading a screen with its own lens, from a few inches away, so presentation is functional rather than cosmetic:
.onAppear {
previousBrightness = UIScreen.main.brightness
UIScreen.main.brightness = 1.0
UIApplication.shared.isIdleTimerDisabled = true
}
.onDisappear {
UIScreen.main.brightness = previousBrightness
UIApplication.shared.isIdleTimerDisabled = false
}
Full brightness, no screen dimming, no auto-lock, and as large a code as the layout allows. Restore both settings when the screen goes away. Do not draw anything over the code — no overlays, no loading spinner on top of it — and keep it on screen for the whole of step 6.
The camera chirps when it has read the code successfully. That audible confirmation is the only feedback the user gets at this point, so tell them to listen for it, and give them a way out if they hear nothing.
6. Confirm the camera reached the cloud
Rendering the code proves nothing — the camera may never have read it. Poll the pairing session with the same key, every three seconds, for as long as the screen is up:
Timer.publish(every: 3, on: .main, in: .common)
.autoconnect()
.flatMap { _ in
Factory.spaceService.getPairingSessionStatus(spaceId: spaceId, sessionKey: session.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)
Budget around three minutes. A camera that has read the code still has to join the network, reach the backend and register, and one to two minutes is normal — show progress rather than a bare spinner, and stop polling when the screen is dismissed.
Once deviceId is non-empty, switch to polling the device itself with device(spaceId:deviceId:) until its pairingStatus reaches .paired or .activated. That is the point at which the camera is usable.
The QR payload
Five values, in this order, separated by newlines:
<ssid>
<password>
<sessionKey>
<region>
<env>
| Line | Value | Source |
|---|---|---|
| 1 | ssid | The network the camera should join |
| 2 | password | Entered by the user; empty line for an open network |
| 3 | sessionKey | PairingSessionKeyModel.sessionKey from createPairingSessionKey |
| 4 | region | The numeric code for your data residency region |
| 5 | env | The numeric code for your deployment environment |
region and env are numeric codes, not the ServerRegion and Environment values you passed to InstaSDK.configure. Map them from the same configuration:
| 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" |
These are the same five values WiFiDetailModel carries over Bluetooth, in a different order — region comes before env here. The order is the camera’s contract; do not reorder the lines.
The password travels in the payload as plain text, and the payload is on screen at full brightness. Do not screenshot it, do not log it, and dismiss the screen as soon as pairing resolves.
QRCodeGenerator
public class QRCodeGenerator {
public static func generateQRCode(from string: String, size: CGFloat = 1024) -> UIImage?
public static func generateQRCode(from dict: [String: String]) -> UIImage?
}
Renders a string as a QR code image. The size is the pixel size of the generated bitmap and defaults to 1024, which is large enough for any phone screen — the image is drawn without interpolation so the modules stay square and crisp when it is scaled to fit.
Both methods return nil if the payload cannot be encoded. Handle that rather than force-unwrapping; an unexpectedly long SSID or password is the realistic cause.
The dictionary overload serializes to JSON first. The pairing payload is not JSON, so use the string method for it.
Common problems
The camera never chirps. It has not read the code. Almost always brightness, distance or glare — raise the brightness to 1.0, hold the phone six to ten inches from the lens, and keep the screen square to it. A camera still booting cannot read anything either; give it a moment after power-on.
The camera chirps but nothing appears in the cloud. It read the code and could not use it. A wrong Wi-Fi password and a 5 GHz-only network account for nearly all of these, and the camera has no way to tell you which — pairing?.reason from the session status is the only signal you get.
The session expires while the code is on screen. The key was created too early. Create it immediately before rendering, and start a fresh session — a new key means a new payload and a newly rendered code.
Pairing works on staging but not in production. Check env and region. They are numeric codes and a mismatch sends the camera to a backend your user does not exist on, which surfaces only as a silent pairing failure.
The scan reads the wrong code. The camera, its box and the SIM card can all carry QR codes. Validate the 27-character device id before using a scan result, and reject anything else quietly so the user can keep scanning.