igloo
Docs / igloohome / BLE SDK / Best Practices

Best Practices

Patterns for building reliable apps with the Igloohome BLE SDK.


Architecture

┌──────────────┐
│   Your App   │
└──────┬───────┘
       │  IglooPlugin API (suspend / Flow)
┌──────▼───────┐
│  Igloohome   │──── REST API ────► Igloo Server
│     SDK      │
└──────┬───────┘
       │  BLE
┌──────▼───────┐
│  Smart Lock  │
└──────────────┘

The SDK orchestrates BLE and server operations together. For example, createPin() writes the PIN to lock hardware via BLE, registers it on the server, and rolls back if the server call fails — all in one call.


Singleton Pattern

Only one IglooPlugin instance should exist at a time. Multiple instances hold separate connection state and will cause undefined BLE behavior.

// Application-scoped singleton
object SdkProvider {
    lateinit var sdk: IglooPlugin
        private set

    fun init(context: Context) {
        sdk = IglooPlugin(context.applicationContext)
    }
}
// App-scoped singleton. IglooPlugin is @MainActor and holds no context/config —
// init() takes no parameters.
@MainActor
enum SdkProvider {
    static let sdk = IglooPlugin()
}

Token Management

The Igloohome SDK uses separate tokens for different operations:

Sync Tokens

The sync() and syncWithStatus() methods require three dedicated tokens:

Token Purpose
getDeviceToken Reading device state from the server.
storeLogsToken Uploading activity logs to the server.
updateDeviceToken Patching device info (battery, firmware version).
sdk.syncWithStatus(
    deviceId = deviceId,
    key = guestKey,
    getDeviceToken = tokens.getDeviceToken,
    storeLogsToken = tokens.storeLogsToken,
    updateDeviceToken = tokens.updateDeviceToken,
).collect { status ->
    // Handle each sync operation result
}
for await status in sdk.syncWithStatus(
    deviceId,
    key: guestKey,
    getDeviceToken: tokens.getDeviceToken,
    storeLogsToken: tokens.storeLogsToken,
    updateDeviceToken: tokens.updateDeviceToken
) {
    // Handle each sync operation result
}

Access Token

All other methods (createPin, addKeycard, addFingerprint, link, checkFirmwareUpdate, performDfu, etc.) accept a single accessToken: String parameter. This token is always required.

sdk.createPin(
    deviceId = deviceId,
    key = guestKey,
    pin = "123456",
    pinType = PinType.PERMANENT,
    name = "Front Door PIN",
    accessToken = accessToken,
)
try await sdk.createPin(
    deviceId: deviceId,
    key: guestKey,
    name: "Front Door PIN",
    pin: "123456",
    pinType: .permanent,
    startDate: nil,
    endDate: nil,
    accessToken: accessToken)

Connection Management

  • One operation at a time. BLE operations are serialized internally per device — don't fire multiple lock/unlock calls concurrently on the same device.
  • Disconnect when done. Call disconnect(deviceId) after your operation completes to free BLE resources.
  • The SDK connects automatically. There is no separate connect() method — lock(), sync(), pair() etc. all connect internally.
try {
    sdk.lock(deviceId, key, accessToken)
} finally {
    sdk.disconnect(deviceId)
}
// Swift has no try/finally — use `defer` instead, which runs when the
// enclosing scope exits whether `lock` throws or not.
defer { Task { try? await sdk.disconnect(deviceId) } }
try await sdk.lock(deviceId, key: key)

Coroutine Patterns

All BLE operations are suspend functions or return Flow. Use structured concurrency.

Suspend Functions

// In a ViewModel
viewModelScope.launch {
    try {
        sdk.lock(deviceId, key, accessToken)
        _uiState.value = UiState.Success
    } catch (e: IglooHomeException) {
        _uiState.value = UiState.Error(e.message)
    }
}

Flow Collection

// Scanning — cancel when no longer needed
private var scanJob: Job? = null

fun startScan() {
    scanJob = viewModelScope.launch {
        sdk.scanDevice().collect { result ->
            _devices.value += result
        }
    }
}

fun stopScan() {
    scanJob?.cancel()
}

Flow with Lifecycle

// In a Fragment/Activity
lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        sdk.addFingerprint(deviceId, key, name, accessToken).collect { event ->
            // Handle scan events
        }
    }
}

Swift Concurrency Patterns

All BLE operations are async throws functions or return AsyncStream/AsyncThrowingStream. Use structured concurrency (Task, async let, task cancellation) in place of Kotlin's coroutine scopes.

Async Functions

// In an @Observable / ObservableObject view model
Task {
    do {
        try await sdk.lock(deviceId, key: key)
        uiState = .success
    } catch {
        uiState = .error(error.localizedDescription)
    }
}

Stream Collection

// Scanning — cancel when no longer needed
private var scanTask: Task<Void, Never>?

func startScan() {
    scanTask = Task {
        do {
            for try await result in sdk.scansLock() {
                devices.append(result)
            }
        } catch {
            // Handle scan error
        }
    }
}

func stopScan() {
    sdk.stopScan()
    scanTask?.cancel()
}

Stream with View Lifecycle

// In a SwiftUI View, tied to the view's lifetime
.task {
    for await event in await sdk.addFingerprint(
        deviceId: deviceId, key: key, name: name, accessToken: accessToken)
    {
        // Handle scan events
    }
}

Error Handling

Catch at the UI layer, not deep in the call stack.

viewModelScope.launch {
    try {
        sdk.pair(deviceId, name, accessToken)
        _uiState.value = UiState.Paired
    } catch (e: IglooHomeException) {
        _uiState.value = when (e) {
            is IglooHomeException.DevicePairedException ->
                UiState.Error("Already paired")
            is IglooHomeException.HasLinkedDeviceException ->
                UiState.Error("Unlink ${e.linkedDeviceIds.size} accessories first")
            is IglooHomeException.ConnectionException ->
                UiState.Retryable("unexpected bluetooth connection issue")
            is IglooHomeException.TimeoutException ->
                UiState.Retryable("Connection lost — try again")
            else ->
                UiState.Error(e.message ?: "Unknown error")
        }
    }
}
Task {
    do {
        try await sdk.pair(deviceId: deviceId, lockName: name, propertyIds: propertyIds, accessToken: accessToken)
        uiState = .paired
    } catch IgloohomeError.devicePaired {
        uiState = .error("Already paired")
    } catch IgloohomeError.hasLinkedDevice(let linkedDeviceIds) {
        uiState = .error("Unlink \(linkedDeviceIds.count) accessories first")
    } catch IgloohomeError.connection {
        uiState = .retryable("unexpected bluetooth connection issue")
    } catch IgloohomeError.timeout {
        uiState = .retryable("Connection lost — try again")
    } catch {
        uiState = .error(error.localizedDescription)
    }
}

Retry only transient errors: TimeoutException, ConnectionException, and ApiException with 5xx status (Android); IgloohomeError.timeout, .connection, and IgloohomeError.api with 5xx status (iOS). Terminal errors like DevicePairedException/IgloohomeError.devicePaired or LockStorageFullException/IgloohomeError.lockStorageFull require user action.


Sync with Granular Error Handling

Use syncWithStatus() to handle errors per sync operation instead of failing the entire sync on the first error.

sdk.syncWithStatus(
    deviceId = deviceId,
    key = guestKey,
    getDeviceToken = tokens.getDeviceToken,
    storeLogsToken = tokens.storeLogsToken,
    updateDeviceToken = tokens.updateDeviceToken,
).collect { status ->
    when (status.operation) {
        SyncResultStatus.Operation.SET_TIME ->
            if (!status.isSuccess) log("Set time failed: ${status.error?.message}")
        SyncResultStatus.Operation.GET_BATTERY_LEVEL ->
            if (status.isSuccess) updateBatteryUi()
        SyncResultStatus.Operation.SYNC_ACTIVITY_LOGS ->
            if (!status.isSuccess) log("Log sync failed: ${status.error?.message}")
    }
}
for await status in sdk.syncWithStatus(
    deviceId,
    key: guestKey,
    getDeviceToken: tokens.getDeviceToken,
    storeLogsToken: tokens.storeLogsToken,
    updateDeviceToken: tokens.updateDeviceToken
) {
    switch status.operation {
    case .SET_TIME:
        if !status.isSuccess { log("Set time failed: \(status.error?.localizedDescription ?? "")") }
    case .GET_BATTERY_LEVEL:
        if status.isSuccess { updateBatteryUi() }
    case .SYNC_ACTIVITY_LOGS:
        if !status.isSuccess { log("Log sync failed: \(status.error?.localizedDescription ?? "")") }
    }
}

Each operation emits a SyncResultStatus with its own success/failure state, so a failure in one step (e.g. log upload) does not prevent the others from completing. On iOS, prefer syncWithStatus over the deprecated sync() for this reason too.


First-Time Device Setup

  1. scanDevice() (Android) / scansLock() (iOS) — find the lock
  2. pair() — register on account
  3. calibrate() — tune motor direction
  4. sync() (Android) / syncWithStatus() (iOS, sync() is deprecated) — set clock and read battery
  5. setWifiConfig() — if bridge, configure WiFi

Regular Operations

  1. lock() / unlock() — control the lock
  2. sync() (Android) / syncWithStatus() (iOS) — periodically sync time and logs

Access Management

  1. createPin() / addKeycard() / addFingerprint() — add access
  2. deletePin() / deleteKeycard() / deleteFingerprint() — remove access

Firmware Update

  1. checkFirmwareUpdate() — check for updates
  2. performDfu() — apply update (keep screen on)
  3. sync() — verify device state after update

Device Removal

  1. unlink() — remove all accessories first
  2. unpair() — remove the lock

Troubleshooting

Problem Cause Solution
BluetoothException on every call Bluetooth disabled or permissions not granted Check BluetoothAdapter.isEnabled and request runtime permissions
ConnectionException frequently Device out of BLE range Move within 2–3 meters of the lock
TimeoutException on first call Lock in deep sleep Retry — first connection wakes the lock
DevicePairedException during pair Lock already registered Check server if you own it; factory reset if transferring
HasLinkedDeviceException during unpair Accessories still linked Call unlink() for each accessory first
DuplicatePinException Same PIN exists on lock Choose a different PIN code
LockStorageFullException Lock PIN/card slots exhausted Delete existing access before adding new ones
BatteryLowException during DFU Battery below threshold Charge or replace batteries before DFU
Multiple IglooPlugin instances Creating SDK in Activity/Fragment Use application-scoped singleton
BLE operations fail silently RxJava undeliverable exceptions SDK handles these internally — upgrade if on old version

The same table for iOS:

Problem Cause Solution
IgloohomeError.bluetoothIsTurnedOff on every call Bluetooth disabled, or NSBluetoothAlwaysUsageDescription missing from Info.plist Enable Bluetooth; verify the Info.plist keys are present (iOS kills the app on first scan without them)
IgloohomeError.lockNotFound / .connection frequently Device out of BLE range Move within 2–3 meters of the lock
IgloohomeError.timeout on first call Lock in deep sleep Retry — first connection wakes the lock
IgloohomeError.devicePaired during pair Lock already registered Check server if you own it; factory reset if transferring
IgloohomeError.hasLinkedDevice during unpair Accessories still linked Call unlink() for each accessory first
IgloohomeError.duplicatePin Same PIN exists on lock Choose a different PIN code
IgloohomeError.lockStorageFull Lock PIN/card slots exhausted Delete existing access before adding new ones
IgloohomeError.batteryLow during DFU Battery below threshold Charge or replace batteries before DFU
Testing on Simulator does nothing Bluetooth is unavailable in the iOS Simulator Test on a physical device
Two CreateKeycardAccessResponse / CalibrationLockingDirection types found Both IgloohomeSDK and IglooSDKCore declare a type of that name (different raw values for CalibrationLockingDirection) Import only IgloohomeSDK for app code where possible; be explicit about which module's type you mean if you also import IglooSDKCore