demo app + SDK updates:\n- Add direct ANI transfer (prepare -> transfer)\n- Add firmware update flow (fetch latest w/ x-api-key, OTA transfer)\n- Extract x-api-key to NetworkConstants\n- Rename example app to demo; move folder and entry to DemoApp\n- Switch from SPM package dependency to local framework target\n- Enable automatic Info.plist generation for framework target\n- Add placeholder test to satisfy SwiftPM test target\n- Regenerate XcodeGen project (demo.xcodeproj)

This commit is contained in:
km2023
2026-04-09 18:41:56 +08:00
parent c33e7d3fa1
commit 7d9ff3081d
30 changed files with 2594 additions and 516 deletions

View File

@@ -0,0 +1,341 @@
import CoreBluetooth
import Foundation
/// CoreBluetooth async API
final class BleClient: NSObject, @unchecked Sendable {
private var centralManager: CBCentralManager!
private let queue = DispatchQueue(label: "com.duooomi.ble.client")
private(set) var connectedPeripheral: CBPeripheral?
private(set) var writeCharacteristic: CBCharacteristic?
private(set) var readCharacteristic: CBCharacteristic?
/// (scan )
private var discoveredPeripherals: [UUID: CBPeripheral] = [:]
// MARK: - Continuations
private var connectContinuation: CheckedContinuation<CBPeripheral, Error>?
private var disconnectContinuation: CheckedContinuation<Void, Error>?
// MARK: - Streams
private var scanContinuation: AsyncStream<DiscoveredDevice>.Continuation?
private var notificationContinuation: AsyncStream<Data>.Continuation?
// MARK: - Callbacks
var onConnectionStateChange: ((ConnectionState) -> Void)?
var onDisconnected: (() -> Void)?
// MARK: - Init
override init() {
super.init()
centralManager = CBCentralManager(delegate: self, queue: queue)
BleLog.i("BleClient initialized", "BLE")
}
// MARK: - Public API
///
var bluetoothState: CBManagerState {
centralManager.state
}
/// `.withoutResponse`
var maximumWriteLength: Int {
connectedPeripheral?.maximumWriteValueLength(for: .withoutResponse) ?? 20
}
/// Service
/// - Parameter serviceUUIDs: Service UUID 使 SDK
/// - Returns:
func scan(serviceUUIDs: [CBUUID] = [BleUUIDs.service]) -> AsyncStream<DiscoveredDevice> {
stopScan()
discoveredPeripherals.removeAll()
return AsyncStream { continuation in
self.scanContinuation = continuation
continuation.onTermination = { [weak self] _ in
self?.queue.async {
self?.centralManager.stopScan()
self?.scanContinuation = nil
}
}
self.queue.async {
guard self.centralManager.state == .poweredOn else { return }
BleLog.i("CBCentral startScan", "BLE")
self.centralManager.scanForPeripherals(
withServices: serviceUUIDs,
options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
)
}
}
}
///
func stopScan() {
queue.async { [weak self] in
self?.centralManager.stopScan()
BleLog.i("CBCentral stopScan", "BLE")
}
scanContinuation?.finish()
scanContinuation = nil
}
///
/// - Parameters:
/// - deviceId: UUID
/// - timeout: 30
/// - Returns: `CBPeripheral`
/// - Throws:
func connect(deviceId: String, timeout: TimeInterval = 30) async throws -> CBPeripheral {
guard centralManager.state == .poweredOn else {
throw DuooomiBleError.bluetoothNotPoweredOn(String(describing: centralManager.state))
}
guard let uuid = UUID(uuidString: deviceId),
let peripheral = discoveredPeripherals[uuid] else {
throw DuooomiBleError.connectionFailed("Device not found: \(deviceId)")
}
return try await withCheckedThrowingContinuation { continuation in
self.connectContinuation = continuation
self.queue.async {
BleLog.i("Connecting to peripheral: \(peripheral.identifier.uuidString)", "BLE")
self.centralManager.connect(peripheral, options: nil)
}
// Timeout
self.queue.asyncAfter(deadline: .now() + timeout) { [weak self] in
if let cont = self?.connectContinuation {
self?.connectContinuation = nil
self?.centralManager.cancelPeripheralConnection(peripheral)
BleLog.w("Connection timeout: \(peripheral.identifier.uuidString)", "BLE")
cont.resume(throwing: DuooomiBleError.connectionFailed("Connection timeout after \(Int(timeout))s"))
}
}
}
}
///
/// - Throws:
func disconnect() async throws {
guard let peripheral = connectedPeripheral else { return }
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
self.disconnectContinuation = continuation
self.queue.async {
BleLog.i("Cancel connection: \(peripheral.identifier.uuidString)", "BLE")
self.centralManager.cancelPeripheralConnection(peripheral)
}
// Timeout for disconnect
self.queue.asyncAfter(deadline: .now() + 5) { [weak self] in
if let cont = self?.disconnectContinuation {
self?.disconnectContinuation = nil
BleLog.w("Force-finish disconnect after timeout", "BLE")
self?.cleanup()
cont.resume()
}
}
}
}
/// GATT Write Without Response
/// - Parameter data:
/// - Throws:
func writeWithoutResponse(_ data: Data) throws {
guard let peripheral = connectedPeripheral,
let characteristic = writeCharacteristic else {
throw DuooomiBleError.notConnected
}
BleLog.d("Write w/o response, len=\(data.count)", "BLE")
peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
}
/// Service
/// - Returns:
func retrieveConnectedDevices() -> [DiscoveredDevice] {
let peripherals = centralManager.retrieveConnectedPeripherals(withServices: [BleUUIDs.service])
return peripherals.map { peripheral in
DiscoveredDevice(
id: peripheral.identifier.uuidString,
name: peripheral.name,
rssi: 0
)
}
}
///
/// - Returns:
func notifications() -> AsyncStream<Data> {
AsyncStream { continuation in
self.notificationContinuation = continuation
continuation.onTermination = { [weak self] _ in
self?.notificationContinuation = nil
}
BleLog.i("Notifications stream attached", "BLE")
}
}
// MARK: - Private
private func cleanup() {
if let readChar = readCharacteristic, let peripheral = connectedPeripheral {
peripheral.setNotifyValue(false, for: readChar)
}
connectedPeripheral = nil
writeCharacteristic = nil
readCharacteristic = nil
notificationContinuation?.finish()
notificationContinuation = nil
BleLog.i("Client state cleaned up", "BLE")
}
}
// MARK: - CBCentralManagerDelegate
extension BleClient: CBCentralManagerDelegate {
func centralManagerDidUpdateState(_ central: CBCentralManager) {
// If scanning was requested before poweredOn, start now
if central.state == .poweredOn, scanContinuation != nil {
BleLog.d("State poweredOn; resume scan", "BLE")
central.scanForPeripherals(
withServices: [BleUUIDs.service],
options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
)
}
BleLog.d("Central state=\(central.state.rawValue)", "BLE")
}
func centralManager(
_ central: CBCentralManager,
didDiscover peripheral: CBPeripheral,
advertisementData: [String: Any],
rssi RSSI: NSNumber
) {
discoveredPeripherals[peripheral.identifier] = peripheral
let device = DiscoveredDevice(
id: peripheral.identifier.uuidString,
name: peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String,
rssi: RSSI.intValue
)
BleLog.d("Did discover: id=\(device.id), rssi=\(device.rssi)", "BLE")
scanContinuation?.yield(device)
}
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
connectedPeripheral = peripheral
peripheral.delegate = self
BleLog.i("Did connect: \(peripheral.identifier.uuidString)", "BLE")
peripheral.discoverServices([BleUUIDs.service])
}
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
let cont = connectContinuation
connectContinuation = nil
BleLog.e("Fail to connect: \(peripheral.identifier.uuidString), error=\(error?.localizedDescription ?? "-")", "BLE")
cont?.resume(throwing: DuooomiBleError.connectionFailed(error?.localizedDescription ?? "Unknown error"))
}
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
// If disconnect happens mid-setup (after didConnect but before discoverCharacteristics
// completes), the connect continuation must be resolved or the caller hangs forever.
if let connectCont = connectContinuation {
connectContinuation = nil
connectCont.resume(throwing: DuooomiBleError.connectionFailed(
error?.localizedDescription ?? "Disconnected during setup"
))
}
cleanup()
if let cont = disconnectContinuation {
disconnectContinuation = nil
cont.resume()
}
BleLog.w("Did disconnect: \(peripheral.identifier.uuidString), error=\(error?.localizedDescription ?? "-")", "BLE")
onDisconnected?()
}
}
// MARK: - CBPeripheralDelegate
extension BleClient: CBPeripheralDelegate {
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
if let error = error {
let cont = connectContinuation
connectContinuation = nil
BleLog.e("Discover services error: \(error.localizedDescription)", "BLE")
cont?.resume(throwing: DuooomiBleError.connectionFailed(error.localizedDescription))
return
}
guard let service = peripheral.services?.first(where: { $0.uuid == BleUUIDs.service }) else {
let cont = connectContinuation
connectContinuation = nil
BleLog.e("Service not found", "BLE")
cont?.resume(throwing: DuooomiBleError.serviceNotFound)
return
}
BleLog.d("Service found: \(service.uuid.uuidString)", "BLE")
peripheral.discoverCharacteristics(
[BleUUIDs.writeCharacteristic, BleUUIDs.readCharacteristic],
for: service
)
}
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
if let error = error {
let cont = connectContinuation
connectContinuation = nil
BleLog.e("Discover characteristics error: \(error.localizedDescription)", "BLE")
cont?.resume(throwing: DuooomiBleError.connectionFailed(error.localizedDescription))
return
}
guard let characteristics = service.characteristics else {
let cont = connectContinuation
connectContinuation = nil
cont?.resume(throwing: DuooomiBleError.characteristicNotFound)
return
}
for char in characteristics {
if char.uuid == BleUUIDs.writeCharacteristic {
writeCharacteristic = char
} else if char.uuid == BleUUIDs.readCharacteristic {
readCharacteristic = char
peripheral.setNotifyValue(true, for: char)
}
}
guard writeCharacteristic != nil, readCharacteristic != nil else {
let cont = connectContinuation
connectContinuation = nil
BleLog.e("Required characteristics missing", "BLE")
cont?.resume(throwing: DuooomiBleError.characteristicNotFound)
return
}
// Connection fully ready
let cont = connectContinuation
connectContinuation = nil
BleLog.i("Characteristics ready; connection established", "BLE")
cont?.resume(returning: peripheral)
}
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
guard error == nil, characteristic.uuid == BleUUIDs.readCharacteristic,
let value = characteristic.value else { return }
BleLog.d("Notification received, len=\(value.count)", "BLE")
notificationContinuation?.yield(value)
}
}

View File

@@ -0,0 +1,69 @@
import Foundation
public enum ConnectionState: String, Sendable {
case idle
case scanning
case connecting
case connected
case disconnecting
case disconnected
}
public struct DiscoveredDevice: Identifiable, Sendable, Equatable {
public let id: String
public let name: String?
public let rssi: Int
public init(id: String, name: String?, rssi: Int) {
self.id = id
self.name = name
self.rssi = rssi
}
}
public enum DuooomiBleError: LocalizedError, Sendable {
case bluetoothNotPoweredOn(String)
case notConnected
case timeout(command: CommandType)
case connectionFailed(String)
case serviceNotFound
case characteristicNotFound
case bindingFailed(String)
case unbindFailed
case deleteFileFailed(status: Int)
case prepareTransferFailed(status: String)
case transferFailed(String)
case invalidFrame
case permissionDenied
public var errorDescription: String? {
switch self {
case .bluetoothNotPoweredOn(let state):
return "Bluetooth is not powered on. State: \(state)"
case .notConnected:
return "No device connected"
case .timeout(let command):
return "Operation timeout: \(command)"
case .connectionFailed(let reason):
return "Connection failed: \(reason)"
case .serviceNotFound:
return "BLE service not found"
case .characteristicNotFound:
return "BLE characteristic not found"
case .bindingFailed(let reason):
return "Binding failed: \(reason)"
case .unbindFailed:
return "Unbind failed"
case .deleteFileFailed(let status):
return "Delete file failed with status: \(status)"
case .prepareTransferFailed(let status):
return "Prepare transfer failed: \(status)"
case .transferFailed(let reason):
return "Transfer failed: \(reason)"
case .invalidFrame:
return "Invalid protocol frame"
case .permissionDenied:
return "Bluetooth permission denied"
}
}
}

View File

@@ -0,0 +1,484 @@
import Combine
import CoreBluetooth
import Foundation
/// Duooomi BLE SDK - iOS SDK
///
///
///
@MainActor
public final class DuooomiBleSDK: ObservableObject {
// MARK: - Observable State
@Published public private(set) var btState: ConnectionState = .idle
@Published public private(set) var connectedDevice: DiscoveredDevice? = nil
@Published public private(set) var deviceInfo: DeviceInfo? = nil
@Published public private(set) var version: String = ""
@Published public private(set) var isActivated: Bool = false
@Published public private(set) var transferProgress: Int = 0
@Published public private(set) var error: String? = nil
@Published public private(set) var discoveredDevices: [DiscoveredDevice] = []
// MARK: - Internal Services
private let bleClient: BleClient
private let protocolService: BleProtocolService
private let deviceInfoService: DeviceInfoService
private let fileTransferService: FileTransferService
// MARK: - Request-Response
private var pendingContinuations: [String: CheckedContinuation<Data, Error>] = [:]
private var messageListenerTask: Task<Void, Never>?
// MARK: - Scan Batching
private var scanTask: Task<Void, Never>?
private var pendingDevices: [DiscoveredDevice] = []
private var allDiscoveredDevices: [DiscoveredDevice] = []
private var flushWorkItem: DispatchWorkItem?
// MARK: - Init
public init() {
bleClient = BleClient()
protocolService = BleProtocolService(client: bleClient)
deviceInfoService = DeviceInfoService(protocolService: protocolService)
fileTransferService = FileTransferService(protocolService: protocolService)
BleLog.i("SDK initialized", "SDK")
setupDisconnectHandler()
}
private func setupDisconnectHandler() {
bleClient.onDisconnected = { [weak self] in
Task { @MainActor [weak self] in
guard let self else { return }
BleLog.w("Peripheral disconnected; resetting state", "SDK")
self.connectedDevice = nil
self.deviceInfo = nil
self.version = ""
self.isActivated = false
self.btState = .disconnected
self.protocolService.stopListening()
self.cancelAllPending(error: DuooomiBleError.notConnected)
}
}
}
// MARK: - Scanning
///
/// - Note: 使 500ms UI `stopScan()`
public func scan() {
stopScan()
btState = .scanning
discoveredDevices = []
allDiscoveredDevices = []
pendingDevices = []
BleLog.i("Start scanning...", "Scan")
let stream = bleClient.scan()
scanTask = Task { [weak self] in
for await device in stream {
guard let self, !Task.isCancelled else { break }
BleLog.d("Discovered: id=\(device.id), name=\(device.name ?? "-"), rssi=\(device.rssi)", "Scan")
await self.queueDevice(device)
}
}
}
///
/// - Note: `btState` `.connected`
public func stopScan() {
scanTask?.cancel()
scanTask = nil
bleClient.stopScan()
flushDevices()
if connectedDevice != nil {
btState = .connected
} else if btState == .scanning {
btState = .idle
}
BleLog.i("Stop scanning (state=\(btState.rawValue))", "Scan")
}
// MARK: - Connection
///
/// - Parameter deviceId: `DiscoveredDevice.id`CBPeripheral UUID
/// - Returns:
/// - Throws: `DuooomiBleError.connectionFailed`
public func connect(deviceId: String) async throws -> DiscoveredDevice {
stopScan()
btState = .connecting
BleLog.i("Connecting to \(deviceId)...", "Connect")
do {
let peripheral = try await bleClient.connect(deviceId: deviceId)
// Start protocol listener
protocolService.startListening()
startMessageListener()
let device = DiscoveredDevice(
id: peripheral.identifier.uuidString,
name: peripheral.name,
rssi: 0
)
connectedDevice = device
btState = .connected
BleLog.i("Connected: \(device.id)", "Connect")
return device
} catch {
btState = .idle
self.error = error.localizedDescription
BleLog.e("Connect failed: \(error.localizedDescription)", "Connect")
throw error
}
}
///
/// - Throws:
public func disconnect() async throws {
btState = .disconnecting
BleLog.i("Disconnect requested", "Connect")
protocolService.stopListening()
messageListenerTask?.cancel()
messageListenerTask = nil
cancelAllPending(error: DuooomiBleError.notConnected)
// Capture the underlying disconnect error so we can clean up state regardless,
// but still surface it to the caller. UI consumers may also see it via `error`.
var disconnectError: Error?
do {
try await bleClient.disconnect()
} catch {
disconnectError = error
self.error = error.localizedDescription
BleLog.e("Disconnect error: \(error.localizedDescription)", "Connect")
}
connectedDevice = nil
deviceInfo = nil
version = ""
isActivated = false
btState = .disconnected
BleLog.i("Disconnected", "Connect")
if let disconnectError {
throw disconnectError
}
}
/// SDK service SDK
/// SDK Service
/// - Returns:
public func getConnectedDevices() -> [DiscoveredDevice] {
let list = bleClient.retrieveConnectedDevices()
BleLog.d("System-connected devices: \(list.count)", "Connect")
return list
}
// MARK: - Device Commands
///
/// - Returns:
/// - Throws:
public func getDeviceInfo() async throws -> DeviceInfo {
try ensureConnected()
BleLog.d("Sending getDeviceInfo", "Command")
let data = try await sendAndWait(commandType: .getDeviceInfo) {
try await self.deviceInfoService.getDeviceInfo()
}
let info = try decodeResponse(DeviceInfo.self, from: data)
BleLog.i("DeviceInfo received: name=\(info.name), brand=\(info.brand)", "Command")
self.deviceInfo = info
return info
}
///
/// - Returns:
/// - Throws:
public func getVersion() async throws -> VersionInfo {
try ensureConnected()
BleLog.d("Sending getDeviceVersion", "Command")
let data = try await sendAndWait(commandType: .getDeviceVersion) {
try await self.deviceInfoService.getDeviceVersion()
}
let info = try decodeResponse(VersionInfo.self, from: data)
self.version = info.version
BleLog.i("Version received: \(info.version) (type=\(info.type))", "Command")
return info
}
///
/// - Parameter userId:
/// - Returns: SN
/// - Throws:
public func bind(userId: String) async throws -> BindingResponse {
try ensureConnected()
BleLog.d("Sending bind (userId=\(userId))", "Command")
let data = try await sendAndWait(commandType: .bindDevice) {
try await self.deviceInfoService.bindDevice(userId: userId)
}
let resp = try decodeResponse(BindingResponse.self, from: data)
isActivated = resp.success == 1
if resp.success != 1 {
BleLog.w("Bind failed: device already bound", "Command")
throw DuooomiBleError.bindingFailed("Device already bound to another user")
}
BleLog.i("Bind success: sn=\(resp.sn)", "Command")
return resp
}
///
/// - Parameter userId:
/// - Returns:
/// - Throws:
public func unbind(userId: String) async throws -> UnbindResponse {
try ensureConnected()
BleLog.d("Sending unbind (userId=\(userId))", "Command")
let data = try await sendAndWait(commandType: .unbindDevice) {
try await self.deviceInfoService.unbindDevice(userId: userId)
}
let resp = try decodeResponse(UnbindResponse.self, from: data)
if resp.success == 1 {
isActivated = false
BleLog.i("Unbind success", "Command")
} else {
BleLog.w("Unbind failed", "Command")
throw DuooomiBleError.unbindFailed
}
return resp
}
/// key
/// - Parameter key: key URL
/// - Returns:
/// - Throws:
public func deleteFile(key: String) async throws -> DeleteFileResponse {
try ensureConnected()
BleLog.d("Sending deleteFile (key=\(key))", "Command")
let data = try await sendAndWait(commandType: .deleteFile) {
try await self.deviceInfoService.deleteFile(key: key)
}
let resp = try decodeResponse(DeleteFileResponse.self, from: data)
if resp.success != 0 {
BleLog.w("Delete failed with status=\(resp.success)", "Command")
throw DuooomiBleError.deleteFileFailed(status: resp.success)
}
BleLog.i("Delete success (key=\(key))", "Command")
return resp
}
///
/// - Parameters:
/// - key: key使 URL
/// - size:
/// - Returns: `status == "ready"`
/// - Throws:
public func prepareTransfer(key: String, size: Int) async throws -> PrepareTransferResponse {
try ensureConnected()
let opId = "\(CommandType.prepareTransfer.rawValue)_\(key)"
BleLog.d("Sending prepareTransfer (key=\(key), size=\(size))", "Transfer")
let data = try await sendAndWait(opId: opId, command: .prepareTransfer) {
try await self.deviceInfoService.prepareTransfer(key: key, size: size)
}
let resp = try decodeResponse(PrepareTransferResponse.self, from: data)
if resp.status != "ready" {
BleLog.w("PrepareTransfer not ready: status=\(resp.status)", "Transfer")
throw DuooomiBleError.prepareTransferFailed(status: resp.status)
}
BleLog.i("PrepareTransfer ready (key=\(key))", "Transfer")
return resp
}
// MARK: - File Transfer
///
/// - Parameters:
/// - fileUri: `file://` `https://` URL
/// - commandType: `.transferAniVideo``.otaPackage`
/// - Throws:
public func transferFile(
fileUri: String,
commandType: CommandType = .transferAniVideo
) async throws {
try ensureConnected()
transferProgress = 0
BleLog.i("Transfer start: uri=\(fileUri), cmd=\(commandType)", "Transfer")
try await fileTransferService.transferFile(
fileUri: fileUri,
commandType: commandType
) { [weak self] progress in
Task { @MainActor [weak self] in
self?.transferProgress = Int(progress * 100)
if let p = self?.transferProgress, p % 10 == 0 {
BleLog.d("Progress: \(p)%", "Transfer")
}
}
}
transferProgress = 100
BleLog.i("Transfer completed", "Transfer")
}
// MARK: - Request-Response Pattern
private func sendAndWait(
commandType: CommandType,
timeout: TimeInterval = 10,
send: @escaping () async throws -> Void
) async throws -> Data {
try await sendAndWait(
opId: "\(commandType.rawValue)",
command: commandType,
timeout: timeout,
send: send
)
}
private func sendAndWait(
opId: String,
command: CommandType,
timeout: TimeInterval = 10,
send: @escaping () async throws -> Void
) async throws -> Data {
BleLog.d("Register opId=\(opId), cmd=\(command)", "RPC")
// Register continuation BEFORE sending so a fast device response can't race past us.
// Use a defer block to guarantee opId removal regardless of timeout/send/cancel outcome.
do {
return try await withThrowingTaskGroup(of: Data.self) { group in
group.addTask { @MainActor [self] in
try await withCheckedThrowingContinuation { continuation in
self.pendingContinuations[opId] = continuation
BleLog.d("Continuation stored for opId=\(opId)", "RPC")
}
}
group.addTask {
// Send first; if send fails, propagate immediately instead of waiting timeout.
try await send()
BleLog.d("Command sent for opId=\(opId)", "RPC")
try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
throw DuooomiBleError.timeout(command: command)
}
defer { group.cancelAll() }
guard let result = try await group.next() else {
throw DuooomiBleError.timeout(command: command)
}
BleLog.d("Result received for opId=\(opId)", "RPC")
return result
}
} catch {
// Whatever the failure (timeout, cancellation, send error), make sure no stale
// continuation is left in the dictionary so a late device response cannot resume
// a continuation that has already been released.
await MainActor.run {
if let stale = self.pendingContinuations.removeValue(forKey: opId) {
stale.resume(throwing: error)
}
}
BleLog.e("RPC failed for opId=\(opId): \(error.localizedDescription)", "RPC")
throw error
}
}
// MARK: - Message Listener
private func startMessageListener() {
messageListenerTask?.cancel()
messageListenerTask = Task { [weak self] in
guard let self else { return }
BleLog.i("Message listener started", "RPC")
for await (commandType, data) in self.protocolService.incomingMessages {
guard !Task.isCancelled else { break }
BleLog.d("Incoming message: type=\(commandType), size=\(data.count)", "RPC")
await self.handleIncomingMessage(commandType: commandType, data: data)
}
}
}
@MainActor
private func handleIncomingMessage(commandType: UInt8, data: Data) {
let opId = "\(commandType)"
// Check for prepareTransfer with key-based opId
if commandType == CommandType.prepareTransfer.rawValue {
if let resp = try? decodeResponse(PrepareTransferResponse.self, from: data) {
let keyedOpId = "\(commandType)_\(resp.key)"
if let continuation = pendingContinuations.removeValue(forKey: keyedOpId) {
BleLog.d("Resuming keyed opId=\(keyedOpId)", "RPC")
continuation.resume(returning: data)
return
}
}
}
if let continuation = pendingContinuations.removeValue(forKey: opId) {
BleLog.d("Resuming opId=\(opId)", "RPC")
continuation.resume(returning: data)
} else {
BleLog.w("No pending continuation for opId=\(opId)", "RPC")
}
}
// MARK: - Helpers
private func ensureConnected() throws {
guard connectedDevice != nil else {
BleLog.w("Operation requires connection", "SDK")
throw DuooomiBleError.notConnected
}
}
private func decodeResponse<T: Decodable>(_ type: T.Type, from data: Data) throws -> T {
// Device responses may contain null bytes
let cleaned = data.filter { $0 != 0 }
do {
return try JSONDecoder().decode(type, from: cleaned)
} catch {
let raw = String(data: cleaned, encoding: .utf8) ?? "<non-utf8, \(cleaned.count) bytes>"
BleLog.e("JSON decode \(T.self) failed: \(error.localizedDescription)\nRaw: \(raw)", "Decode")
throw error
}
}
private func cancelAllPending(error: Error) {
let continuations = pendingContinuations
pendingContinuations.removeAll()
for (_, continuation) in continuations {
continuation.resume(throwing: error)
}
}
// MARK: - Scan Batching (500ms throttle)
private func queueDevice(_ device: DiscoveredDevice) {
guard !allDiscoveredDevices.contains(where: { $0.id == device.id }) else { return }
allDiscoveredDevices.append(device)
pendingDevices.append(device)
flushWorkItem?.cancel()
let workItem = DispatchWorkItem { [weak self] in
Task { @MainActor [weak self] in
self?.flushDevices()
}
}
flushWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: workItem)
}
private func flushDevices() {
guard !pendingDevices.isEmpty else { return }
pendingDevices = []
discoveredDevices = allDiscoveredDevices
BleLog.d("Discovered devices flushed: total=\(discoveredDevices.count)", "Scan")
}
}

View File

@@ -0,0 +1,11 @@
import Foundation
public struct BindingResponse: Codable, Sendable, Equatable {
public let type: Int
/// SN (14 )
public let sn: String
/// 1 = , 0 =
public let success: Int
/// key
public let contents: [String]
}

View File

@@ -0,0 +1,7 @@
import Foundation
public struct DeleteFileResponse: Codable, Sendable, Equatable {
public let type: Int
/// 0 = , 1 = , 2 =
public let success: Int
}

View File

@@ -0,0 +1,56 @@
import Foundation
public struct DeviceInfo: Sendable, Equatable {
public let allspace: UInt64
public let freespace: UInt64
public let name: String
public let size: String
public let brand: String
public let powerlevel: Int
}
extension DeviceInfo: Codable {
enum CodingKeys: String, CodingKey {
case allspace, freespace, name, size, brand, powerlevel
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
// All string fields: fallback to "" if missing
name = (try? container.decode(String.self, forKey: .name)) ?? ""
size = (try? container.decode(String.self, forKey: .size)) ?? ""
brand = (try? container.decode(String.self, forKey: .brand)) ?? ""
// powerlevel: Int, may come as Int or String
if let num = try? container.decode(Int.self, forKey: .powerlevel) {
powerlevel = num
} else if let str = try? container.decode(String.self, forKey: .powerlevel),
let num = Int(str) {
powerlevel = num
} else {
powerlevel = 0
}
// allspace/freespace: may come as number, string, or be missing
allspace = Self.decodeFlexibleUInt64(container: container, key: .allspace)
freespace = Self.decodeFlexibleUInt64(container: container, key: .freespace)
}
private static func decodeFlexibleUInt64(
container: KeyedDecodingContainer<CodingKeys>,
key: CodingKeys
) -> UInt64 {
if let value = try? container.decode(UInt64.self, forKey: key) {
return value
}
if let value = try? container.decode(Int.self, forKey: key) {
return UInt64(max(0, value))
}
if let str = try? container.decode(String.self, forKey: key),
let value = UInt64(str) {
return value
}
return 0
}
}

View File

@@ -0,0 +1,8 @@
import Foundation
public struct PrepareTransferResponse: Codable, Sendable, Equatable {
public let type: Int
public let key: String
/// "ready" | "no_space" | "duplicated"
public let status: String
}

View File

@@ -0,0 +1,6 @@
import Foundation
public struct UnbindResponse: Codable, Sendable, Equatable {
/// 1 = , 0 =
public let success: Int
}

View File

@@ -0,0 +1,27 @@
import Foundation
public struct VersionInfo: Sendable, Equatable {
public let version: String
/// Int ( 7) String ( "0x07")
public let type: String
}
extension VersionInfo: Codable {
enum CodingKeys: String, CodingKey {
case version, type
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
version = try container.decode(String.self, forKey: .version)
// type may come as Int or String from different firmware versions
if let str = try? container.decode(String.self, forKey: .type) {
type = str
} else if let num = try? container.decode(Int.self, forKey: .type) {
type = "\(num)"
} else {
type = ""
}
}
}

View File

@@ -0,0 +1,35 @@
import CoreBluetooth
public enum BleUUIDs {
public static let service = CBUUID(string: "000002c4-0000-1000-8000-00805f9b34fb")
public static let writeCharacteristic = CBUUID(string: "000002c5-0000-1000-8000-00805f9b34fb")
public static let readCharacteristic = CBUUID(string: "000002c6-0000-1000-8000-00805f9b34fb")
public static let broadcastCharacteristic = CBUUID(string: "000002c1-0000-1000-8000-00805f9b34fb")
}
public enum FrameHead: UInt8 {
case appToDevice = 0xC7
case deviceToApp = 0xB0
}
public enum FrameConstants {
public static let maxDataSize = 496
public static let headerSize = 8
public static let footerSize = 1
/// Inter-frame delay in nanoseconds (35ms)
public static let frameIntervalNanos: UInt64 = 35_000_000
public static let screenSize = 360
}
public enum CommandType: UInt8, Sendable {
case otaPackage = 0x02
case transferBootAnimation = 0x03
case transferAniVideo = 0x05
case transferJpegImage = 0x06
case getDeviceVersion = 0x07
case getDeviceInfo = 0x0D
case bindDevice = 0x0F
case unbindDevice = 0x12
case deleteFile = 0x13
case prepareTransfer = 0x14
}

View File

@@ -0,0 +1,20 @@
import Foundation
/// BLE
/// : [head:1][type:1][subpageTotal:2][curPage:2][dataLen:2][data:N][checksum:1]
public struct ProtocolFrame: Sendable {
/// (0xC7 = APPDevice, 0xB0 = DeviceAPP)
public let head: UInt8
/// CommandType
public let type: UInt8
/// (0 = )
public let subpageTotal: UInt16
/// ( total-1 0)
public let curPage: UInt16
///
public let dataLen: UInt16
///
public let data: Data
///
public let checksum: UInt8
}

View File

@@ -0,0 +1,165 @@
import Foundation
public enum ProtocolManager {
// MARK: - Checksum
/// 8
/// checksum = (~sum + 1) & 0xFF
public static func calculateChecksum(_ data: Data) -> UInt8 {
var sum: UInt32 = 0
for byte in data {
sum += UInt32(byte)
}
return UInt8((~sum &+ 1) & 0xFF)
}
///
/// - Parameters:
/// - data:
/// - expected:
/// - Returns:
public static func verifyChecksum(_ data: Data, expected: UInt8) -> Bool {
calculateChecksum(data) == expected
}
// MARK: - Frame Creation
///
/// - Throws: payload UInt16 > 65535 invalidFrame
public static func createFrames(
type: UInt8,
data: Data,
head: FrameHead = .appToDevice,
maxDataSize: Int = FrameConstants.maxDataSize
) -> [Data] {
// subpageTotal/curPage are encoded as UInt16, so the absolute ceiling is 65535 pages.
// Returning [] keeps the call non-throwing for backwards compatibility; callers (the
// protocol service / file transfer) detect the empty array as a payload-too-large signal.
let maxChunk = max(1, maxDataSize)
let totalPages = (data.count + maxChunk - 1) / maxChunk
if totalPages > Int(UInt16.max) {
return []
}
if data.count <= maxDataSize {
return [createSingleFrame(type: type, data: data, head: head)]
} else {
return createFragmentedFrames(type: type, data: data, head: head, maxDataSize: maxChunk)
}
}
private static func createSingleFrame(type: UInt8, data: Data, head: FrameHead) -> Data {
let totalSize = FrameConstants.headerSize + data.count + FrameConstants.footerSize
var buffer = Data(capacity: totalSize)
// Header: head(1) + type(1) + subpageTotal(2) + curPage(2) + dataLen(2)
buffer.append(head.rawValue)
buffer.append(type)
buffer.append(0) // subpageTotal high
buffer.append(0) // subpageTotal low
buffer.append(0) // curPage high
buffer.append(0) // curPage low
buffer.append(UInt8((data.count >> 8) & 0xFF)) // dataLen high
buffer.append(UInt8(data.count & 0xFF)) // dataLen low
// Data
buffer.append(data)
// Checksum (calculated on everything before checksum)
let checksum = calculateChecksum(buffer)
buffer.append(checksum)
return buffer
}
private static func createFragmentedFrames(
type: UInt8,
data: Data,
head: FrameHead,
maxDataSize: Int
) -> [Data] {
let totalPages = (data.count + maxDataSize - 1) / maxDataSize
var frames: [Data] = []
frames.reserveCapacity(totalPages)
for i in 0..<totalPages {
let start = i * maxDataSize
let end = min(start + maxDataSize, data.count)
let chunk = data[start..<end]
var buffer = Data(capacity: FrameConstants.headerSize + chunk.count + FrameConstants.footerSize)
buffer.append(head.rawValue)
buffer.append(type)
// subpageTotal (big-endian UInt16)
buffer.append(UInt8((totalPages >> 8) & 0xFF))
buffer.append(UInt8(totalPages & 0xFF))
// curPage: (total-1) 0
let curPageVal = totalPages - 1 - i
buffer.append(UInt8((curPageVal >> 8) & 0xFF))
buffer.append(UInt8(curPageVal & 0xFF))
// dataLen
buffer.append(UInt8((chunk.count >> 8) & 0xFF))
buffer.append(UInt8(chunk.count & 0xFF))
// data
buffer.append(contentsOf: chunk)
// checksum
let checksum = calculateChecksum(buffer)
buffer.append(checksum)
frames.append(buffer)
}
return frames
}
// MARK: - Frame Parsing
///
/// - Parameter data:
/// - Returns: `ProtocolFrame` `nil`
public static func parseFrame(_ data: Data) -> ProtocolFrame? {
let minSize = FrameConstants.headerSize + FrameConstants.footerSize
guard data.count >= minSize else { return nil }
let head = data[data.startIndex]
guard head == FrameHead.deviceToApp.rawValue || head == FrameHead.appToDevice.rawValue else {
return nil
}
let type = data[data.startIndex + 1]
let subpageTotal = UInt16(data[data.startIndex + 2]) << 8 | UInt16(data[data.startIndex + 3])
let curPage = UInt16(data[data.startIndex + 4]) << 8 | UInt16(data[data.startIndex + 5])
let dataLen = UInt16(data[data.startIndex + 6]) << 8 | UInt16(data[data.startIndex + 7])
let expectedSize = FrameConstants.headerSize + Int(dataLen) + FrameConstants.footerSize
guard data.count >= expectedSize else { return nil }
let dataStart = data.startIndex + FrameConstants.headerSize
let dataEnd = dataStart + Int(dataLen)
let frameData = data[dataStart..<dataEnd]
let checksum = data[dataEnd]
// Verify checksum on everything before checksum byte
let dataToCheck = data[data.startIndex..<dataEnd]
guard verifyChecksum(dataToCheck, expected: checksum) else {
return nil
}
return ProtocolFrame(
head: head,
type: type,
subpageTotal: subpageTotal,
curPage: curPage,
dataLen: dataLen,
data: Data(frameData),
checksum: checksum
)
}
}

View File

@@ -0,0 +1,158 @@
import Foundation
///
final class BleProtocolService: @unchecked Sendable {
private let client: BleClient
///
private struct FragmentSession {
let total: Int
var frames: [Int: Data] // curPage -> data
}
private var fragments: [String: FragmentSession] = [:]
private var listenerTask: Task<Void, Never>?
private var messageContinuation: AsyncStream<(commandType: UInt8, data: Data)>.Continuation?
private(set) var incomingMessages: AsyncStream<(commandType: UInt8, data: Data)>!
init(client: BleClient) {
self.client = client
incomingMessages = AsyncStream { continuation in
self.messageContinuation = continuation
}
BleLog.i("Protocol service created", "Protocol")
}
// MARK: - Start/Stop Listening
///
/// - Note:
func startListening() {
// Recreate the stream in case it was previously finished
incomingMessages = AsyncStream { continuation in
self.messageContinuation = continuation
}
fragments.removeAll()
listenerTask = Task { [weak self] in
guard let self else { return }
BleLog.i("Start listening for notifications", "Protocol")
for await data in self.client.notifications() {
self.handleRawData(data)
}
}
}
///
func stopListening() {
listenerTask?.cancel()
listenerTask = nil
messageContinuation?.finish()
messageContinuation = nil
fragments.removeAll()
BleLog.i("Stop listening; fragments cleared", "Protocol")
}
// MARK: - Send
///
/// - Parameters:
/// - type:
/// - data:
/// - onProgress: 0.0~1.0
/// - Throws:
func send(
type: UInt8,
data: Data,
onProgress: ((Double) -> Void)? = nil
) async throws {
let maxWriteLen = client.maximumWriteLength
let maxDataSize = min(
maxWriteLen - FrameConstants.headerSize - FrameConstants.footerSize,
FrameConstants.maxDataSize
)
let safeMaxDataSize = max(1, maxDataSize)
let frames = ProtocolManager.createFrames(
type: type,
data: data,
head: .appToDevice,
maxDataSize: safeMaxDataSize
)
guard !frames.isEmpty else {
BleLog.e("Payload too large; frames empty", "Protocol")
throw DuooomiBleError.transferFailed("Payload too large: exceeds 65535-page limit")
}
let total = frames.count
BleLog.d("Sending frames: total=\(total), maxChunk=\(safeMaxDataSize)", "Protocol")
for (index, frame) in frames.enumerated() {
try client.writeWithoutResponse(frame)
try await Task.sleep(nanoseconds: FrameConstants.frameIntervalNanos)
onProgress?(Double(index + 1) / Double(total))
}
BleLog.i("Frames sent: total=\(total)", "Protocol")
}
/// JSON
/// - Parameters:
/// - type:
/// - payload:
/// - Throws: /
func sendJSON<T: Encodable>(type: CommandType, payload: T) async throws {
let jsonData = try JSONEncoder().encode(payload)
try await send(type: type.rawValue, data: jsonData)
}
// MARK: - Receive & Reassemble
private func handleRawData(_ data: Data) {
guard let frame = ProtocolManager.parseFrame(data) else {
BleLog.w("Drop invalid frame (len=\(data.count))", "Protocol")
return
}
if frame.subpageTotal > 0 {
handleFragment(frame)
} else {
BleLog.d("Single frame received: type=\(frame.type), len=\(frame.data.count)", "Protocol")
messageContinuation?.yield((commandType: frame.type, data: frame.data))
}
}
private func handleFragment(_ frame: ProtocolFrame) {
// Match TS reference behavior: scope reassembly per peripheral so concurrent transfers
// (or stale fragments from a previous device after reconnect) cannot pollute each other.
let deviceKey = client.connectedPeripheral?.identifier.uuidString ?? "unknown"
let key = "\(deviceKey)_\(frame.type)"
if fragments[key] == nil {
fragments[key] = FragmentSession(total: Int(frame.subpageTotal), frames: [:])
BleLog.d("New fragment session: key=\(key), total=\(frame.subpageTotal)", "Protocol")
}
guard var session = fragments[key] else { return }
guard frame.curPage < session.total else { return }
session.frames[Int(frame.curPage)] = frame.data
fragments[key] = session
// Check if all pages received
guard session.frames.count == session.total else { return }
// Reassemble: from high page to low page
var combined = Data()
for i in stride(from: session.total - 1, through: 0, by: -1) {
if let part = session.frames[i] {
combined.append(part)
}
}
fragments.removeValue(forKey: key)
BleLog.i("Fragment reassembled: key=\(key), size=\(combined.count)", "Protocol")
messageContinuation?.yield((commandType: frame.type, data: combined))
}
}

View File

@@ -0,0 +1,98 @@
import Foundation
/// JSON
final class DeviceInfoService {
private let protocolService: BleProtocolService
init(protocolService: BleProtocolService) {
self.protocolService = protocolService
}
// MARK: - Commands
///
/// - Throws:
func getDeviceInfo() async throws {
try await protocolService.sendJSON(
type: .getDeviceInfo,
payload: CommandPayload(type: CommandType.getDeviceInfo.rawValue)
)
}
///
/// - Throws:
func getDeviceVersion() async throws {
try await protocolService.sendJSON(
type: .getDeviceVersion,
payload: CommandPayload(type: CommandType.getDeviceVersion.rawValue)
)
}
///
/// - Parameter userId:
/// - Throws:
func bindDevice(userId: String) async throws {
try await protocolService.sendJSON(
type: .bindDevice,
payload: BindPayload(type: CommandType.bindDevice.rawValue, userId: userId)
)
}
///
/// - Parameter userId:
/// - Throws:
func unbindDevice(userId: String) async throws {
try await protocolService.sendJSON(
type: .unbindDevice,
payload: BindPayload(type: CommandType.unbindDevice.rawValue, userId: userId)
)
}
///
/// - Parameter key: key URL
/// - Throws:
func deleteFile(key: String) async throws {
try await protocolService.sendJSON(
type: .deleteFile,
payload: FileKeyPayload(type: CommandType.deleteFile.rawValue, key: key)
)
}
///
/// - Parameters:
/// - key: key
/// - size:
/// - Throws:
func prepareTransfer(key: String, size: Int) async throws {
try await protocolService.sendJSON(
type: .prepareTransfer,
payload: PrepareTransferPayload(
type: CommandType.prepareTransfer.rawValue,
key: key,
size: size
)
)
}
}
// MARK: - Command Payloads
private struct CommandPayload: Encodable {
let type: UInt8
}
private struct BindPayload: Encodable {
let type: UInt8
let userId: String
}
private struct FileKeyPayload: Encodable {
let type: UInt8
let key: String
}
private struct PrepareTransferPayload: Encodable {
let type: UInt8
let key: String
let size: Int
}

View File

@@ -0,0 +1,48 @@
import Foundation
///
final class FileTransferService {
private let protocolService: BleProtocolService
init(protocolService: BleProtocolService) {
self.protocolService = protocolService
}
///
/// - Parameters:
/// - fileUri: file:// URL https:// URL
/// - commandType:
/// - onProgress: 0.0 ~ 1.0
func transferFile(
fileUri: String,
commandType: CommandType,
onProgress: ((Double) -> Void)? = nil
) async throws {
BleLog.i("Load file: \(fileUri)", "Transfer")
let data = try await loadFileData(from: fileUri)
BleLog.d("File loaded: size=\(data.count) bytes", "Transfer")
try await protocolService.send(
type: commandType.rawValue,
data: data,
onProgress: onProgress
)
}
private func loadFileData(from uri: String) async throws -> Data {
guard let url = URL(string: uri) else {
throw DuooomiBleError.transferFailed("Invalid URL: \(uri)")
}
if url.isFileURL {
return try Data(contentsOf: url)
} else {
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
throw DuooomiBleError.transferFailed("Failed to download file: \(uri)")
}
return data
}
}
}

View File

@@ -0,0 +1,28 @@
import Foundation
enum BleLog {
static func d(_ message: @autoclosure () -> String, _ category: String = "General") {
#if DEBUG
print("[DuooomiBleSDK][\(category)] \(message())")
#endif
}
static func i(_ message: @autoclosure () -> String, _ category: String = "General") {
#if DEBUG
print(" [DuooomiBleSDK][\(category)] \(message())")
#endif
}
static func w(_ message: @autoclosure () -> String, _ category: String = "General") {
#if DEBUG
print("⚠️ [DuooomiBleSDK][\(category)] \(message())")
#endif
}
static func e(_ message: @autoclosure () -> String, _ category: String = "General") {
#if DEBUG
print("❌ [DuooomiBleSDK][\(category)] \(message())")
#endif
}
}