iOS BLE Connection SDK
SDK Version: 1.0.0
Product:XTalkBleSdk
XTalkBleClient is a @MainActor ObservableObject. Create it, read its state, and call its public methods on the main actor.
1. Create and retain the client
The client must be strongly owned by a page model or longer-lived session. Do not create it temporarily in a button callback. The default AT encoding is .xor; the authenticator changes it while probing.
Lifecycle example
import Combine
import Foundation
import XTalkBleSdk
@MainActor
final class DeviceModel: ObservableObject {
let ble = XTalkBleClient()
private var eventTask: Task<Void, Never>?
func start() {
let stream = ble.events
eventTask = Task { @MainActor [weak self] in
for await event in stream {
guard !Task.isCancelled else { break }
self?.handle(event)
}
}
ble.startScan()
}
func connect(_ result: XTalkScanResult) {
ble.stopScan()
ble.connect(id: result.id)
}
func waitUntilAtReady() async throws {
let deadline = ContinuousClock.now.advanced(by: .seconds(10))
while !ble.atReady {
try Task.checkCancellation()
guard ContinuousClock.now < deadline else { throw XTalkBleError.timeout }
try await Task.sleep(for: .milliseconds(50))
}
}
func stop() {
eventTask?.cancel()
eventTask = nil
ble.teardown()
}
private func handle(_ event: XTalkBleEvent) {
switch event {
case .connectionStateChanged(let state):
print("BLE state: \(state)")
case .atLine(let line):
print("AT: \(line)")
case .sendFinished:
print("RF send finished")
case .diPayload(let data, let meta):
print("RX \(data.count) bytes, RSSI: \(meta.rssi.map(String.init) ?? "-")")
case .crcError(_, let line):
print("CRC error: \(line)")
case .passthroughRaw(let data):
print("Raw passthrough: \(data.count) bytes")
}
}
}
.connected only means that the link is connected. atReady == true means that the passthrough write characteristic and notification subscription are ready. teardown() is terminal; create a new client to start another full lifecycle.
2. Scan and select a device
After calling startScan(), observe scanResults or $scanResults. Each result's id is the identifier used for this connection. address can be absent; display displayName and rssi in the UI. Call connect(id:) after selection and do not persist an identifier from an old scan session indefinitely.
func selectStrongestDevice() {
guard let selected = ble.scanResults.max(by: { $0.rssi < $1.rssi }) else { return }
ble.stopScan()
ble.connect(id: selected.id)
}
Scan failures are published through connectionState == .failed(...) and events rather than thrown. Customer UI should provide a retry action.
3. Readiness gates
| State | Allowed operation |
|---|---|
.scanning | Show results, stop scanning |
.connecting | Show progress, let the user cancel |
.connected and atReady == false | Wait for services, characteristics, and notifications |
atReady == true | AT, RF, authentication |
deviceControlReady == true | Battery, BLE firmware, and hardware version queries |
.failed(error) | Recover from the error; do not continue the current flow |
AT, RF, and device information
let lines = try await ble.runAtCommand(
"AT+VER?",
timeoutMs: 2_000,
log: true,
responseMode: .automatic
)
try await ble.sendRfPayload(payload, timeoutMs: 5_000)
async let battery = ble.fetchBatteryLevel()
async let firmware = ble.fetchBleFirmwareVersion()
async let hardware = ble.fetchHardwareVersion()
let deviceInfo = await (battery, firmware, hardware)
runAtCommand returns response lines after transaction framing. The business layer parses the payload for its command and must never treat an arbitrary OK line as authentication success. .automatic suits normal commands; .collectUntilTimeout collects a complete response window.
let values = try await ble.withExclusiveAtSession { session in
let frequency = try await session.runAtCommand("AT+FREQ?")
let version = try await session.runAtCommand("AT+VER?")
return (frequency, version)
}
withExclusiveAtSession prevents another AT operation from being inserted into the group. Await commands sequentially; do not create competing child tasks on the same channel.
sendAtCommandNoWait and sendRfPayloadNoWait start a write but do not return a complete business response. Consume later data through events or observers. A second no-wait operation can throw .busy until the isolation window finishes. Prefer async operations when the business needs explicit success or failure.
4. Choose one event consumption style
- Swift concurrency: iterate over
events, suitable for session models. - Single callback: assign
onEvent, suitable for callback-based architectures. - Fine-grained observers: subscribe only to AT lines, send-finish lines, or raw passthrough bytes.
Observer methods return UUID tokens. Always call the matching remove method. All three mechanisms can receive the same underlying event, so do not register the same business handler multiple times.
Cleanup
Use disconnect() for a temporary disconnect. When the screen or business lifecycle ends permanently, cancel the event task, remove observers, and call teardown(). Do not consume the same business event repeatedly through events, onEvent, and multiple observers.
After disconnect(), the client can call startScan() again. After teardown(), pending transactions are cancelled and the event stream is closed; create a new XTalkBleClient.