feat: 重写 SDK 支持 iOS 12.2,移除 async/await 和 Combine

- 所有公开 API 改为 completion handler (Result<T, Error>)
- 状态通知改为 DuooomiBleSDKDelegate 协议
- 移除 Sendable、@MainActor、AsyncStream、CheckedContinuation
- 网络请求改为 URLSession.dataTask 回调
- BLE 通信改为 delegate + 闭包模式
- deployment target 降至 iOS 12.2

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
km2023
2026-04-16 16:59:39 +08:00
parent 79af43cdef
commit 1a7bbdc625
26 changed files with 3211 additions and 944 deletions

View File

@@ -1,198 +1,116 @@
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")
/// BLE
protocol BleClientDelegate: AnyObject {
func bleClient(_ client: BleClient, didDiscoverDevice device: DiscoveredDevice)
func bleClient(_ client: BleClient, didReceiveData data: Data)
}
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
final class BleClient: NSObject {
weak var delegate: BleClientDelegate?
var onConnectionStateChange: ((ConnectionState) -> Void)?
var onDisconnected: (() -> Void)?
// MARK: - Init
private let queue = DispatchQueue(label: "com.duooomi.ble.client", qos: .userInitiated)
private var centralManager: CBCentralManager!
private var connectedPeripheral: CBPeripheral?
private var writeCharacteristic: CBCharacteristic?
private var readCharacteristic: CBCharacteristic?
override init() {
super.init()
centralManager = CBCentralManager(delegate: self, queue: queue)
BleLog.i("BleClient initialized", "BLE")
}
// Connect/Disconnect callbacks
private var connectCompletion: ((Result<CBPeripheral, Error>) -> Void)?
private var disconnectCompletion: ((Result<Void, Error>) -> Void)?
private var connectTimeoutWork: DispatchWorkItem?
// MARK: - Public API
///
var bluetoothState: CBManagerState {
centralManager.state
}
/// `.withoutResponse`
var maximumWriteLength: Int {
connectedPeripheral?.maximumWriteValueLength(for: .withoutResponse) ?? 20
connectedPeripheral?.maximumWriteValueLength(for: .withoutResponse) ?? 182
}
/// 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]
)
}
}
override init() {
super.init()
centralManager = CBCentralManager(delegate: self, queue: queue)
}
///
func stopScan() {
queue.async { [weak self] in
self?.centralManager.stopScan()
BleLog.i("CBCentral stopScan", "BLE")
}
scanContinuation?.finish()
scanContinuation = nil
}
// MARK: - Scan
///
/// - Parameters:
/// - deviceId: UUID
/// - timeout: 30
/// - Returns: `CBPeripheral`
/// - Throws:
func connect(deviceId: String, timeout: TimeInterval = 30) async throws -> CBPeripheral {
func scan(serviceUUIDs: [CBUUID] = [BleUUIDs.service]) {
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"))
}
}
BleLog.w("Bluetooth not powered on", "BLE")
return
}
centralManager.scanForPeripherals(
withServices: serviceUUIDs,
options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
)
}
///
/// - 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()
}
}
}
func stopScan() {
centralManager.stopScan()
}
/// GATT Write Without Response
/// - Parameter data:
/// - Throws:
// MARK: - Connect
func connect(deviceId: String, timeout: TimeInterval = 30, completion: @escaping (Result<CBPeripheral, Error>) -> Void) {
guard centralManager.state == .poweredOn else {
completion(.failure(DuooomiBleError.bluetoothNotPoweredOn("Bluetooth not powered on")))
return
}
let peripherals = centralManager.retrievePeripherals(withIdentifiers: [UUID(uuidString: deviceId)!])
guard let peripheral = peripherals.first else {
completion(.failure(DuooomiBleError.connectionFailed("Device not found: \(deviceId)")))
return
}
self.connectCompletion = completion
peripheral.delegate = self
centralManager.connect(peripheral, options: nil)
// Timeout
let work = DispatchWorkItem { [weak self] in
guard let self = self, self.connectCompletion != nil else { return }
self.centralManager.cancelPeripheralConnection(peripheral)
let cb = self.connectCompletion
self.connectCompletion = nil
cb?(.failure(DuooomiBleError.connectionFailed("Connection timeout")))
}
connectTimeoutWork = work
queue.asyncAfter(deadline: .now() + timeout, execute: work)
}
// MARK: - Disconnect
func disconnect(completion: @escaping (Result<Void, Error>) -> Void) {
guard let peripheral = connectedPeripheral else {
completion(.success(()))
return
}
disconnectCompletion = completion
centralManager.cancelPeripheralConnection(peripheral)
}
// MARK: - Write
func writeWithoutResponse(_ data: Data) throws {
guard let peripheral = connectedPeripheral,
let characteristic = writeCharacteristic else {
guard let peripheral = connectedPeripheral, let char = writeCharacteristic else {
throw DuooomiBleError.notConnected
}
BleLog.d("Write w/o response, len=\(data.count)", "BLE")
peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
peripheral.writeValue(data, for: char, type: .withoutResponse)
}
/// Service
/// - Returns:
// MARK: - Retrieve Connected
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
)
centralManager.retrieveConnectedPeripherals(withServices: [BleUUIDs.service]).map {
DiscoveredDevice(id: $0.identifier.uuidString, name: $0.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
@@ -200,67 +118,51 @@ final class BleClient: NSObject, @unchecked Sendable {
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")
BleLog.i("Bluetooth state: \(central.state.rawValue)", "BLE")
}
func centralManager(
_ central: CBCentralManager,
didDiscover peripheral: CBPeripheral,
advertisementData: [String: Any],
rssi RSSI: NSNumber
) {
discoveredPeripherals[peripheral.identifier] = peripheral
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral,
advertisementData: [String: Any], rssi RSSI: NSNumber) {
let device = DiscoveredDevice(
id: peripheral.identifier.uuidString,
name: peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String,
name: peripheral.name,
rssi: RSSI.intValue
)
BleLog.d("Did discover: id=\(device.id), rssi=\(device.rssi)", "BLE")
scanContinuation?.yield(device)
delegate?.bleClient(self, didDiscoverDevice: device)
}
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
connectTimeoutWork?.cancel()
connectTimeoutWork = nil
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"))
connectTimeoutWork?.cancel()
connectTimeoutWork = nil
let cb = connectCompletion
connectCompletion = nil
cb?(.failure(error ?? DuooomiBleError.connectionFailed("Unknown")))
}
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"
))
connectedPeripheral = nil
writeCharacteristic = nil
readCharacteristic = nil
if let cb = disconnectCompletion {
disconnectCompletion = nil
if let error = error {
cb(.failure(error))
} else {
cb(.success(()))
}
}
cleanup()
if let cont = disconnectContinuation {
disconnectContinuation = nil
cont.resume()
DispatchQueue.main.async { [weak self] in
self?.onDisconnected?()
}
BleLog.w("Did disconnect: \(peripheral.identifier.uuidString), error=\(error?.localizedDescription ?? "-")", "BLE")
onDisconnected?()
}
}
@@ -270,22 +172,17 @@ 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))
let cb = connectCompletion
connectCompletion = nil
cb?(.failure(error))
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)
let cb = connectCompletion
connectCompletion = nil
cb?(.failure(DuooomiBleError.serviceNotFound))
return
}
BleLog.d("Service found: \(service.uuid.uuidString)", "BLE")
peripheral.discoverCharacteristics(
[BleUUIDs.writeCharacteristic, BleUUIDs.readCharacteristic],
for: service
@@ -294,17 +191,15 @@ extension BleClient: CBPeripheralDelegate {
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))
let cb = connectCompletion
connectCompletion = nil
cb?(.failure(error))
return
}
guard let characteristics = service.characteristics else {
let cont = connectContinuation
connectContinuation = nil
cont?.resume(throwing: DuooomiBleError.characteristicNotFound)
let cb = connectCompletion
connectCompletion = nil
cb?(.failure(DuooomiBleError.characteristicNotFound))
return
}
@@ -318,24 +213,19 @@ extension BleClient: CBPeripheralDelegate {
}
guard writeCharacteristic != nil, readCharacteristic != nil else {
let cont = connectContinuation
connectContinuation = nil
BleLog.e("Required characteristics missing", "BLE")
cont?.resume(throwing: DuooomiBleError.characteristicNotFound)
let cb = connectCompletion
connectCompletion = nil
cb?(.failure(DuooomiBleError.characteristicNotFound))
return
}
// Connection fully ready
let cont = connectContinuation
connectContinuation = nil
BleLog.i("Characteristics ready; connection established", "BLE")
cont?.resume(returning: peripheral)
let cb = connectCompletion
connectCompletion = nil
cb?(.success(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)
guard error == nil, let data = characteristic.value else { return }
delegate?.bleClient(self, didReceiveData: data)
}
}

View File

@@ -1,6 +1,6 @@
import Foundation
public enum ConnectionState: String, Sendable {
public enum ConnectionState: String {
case idle
case scanning
case connecting
@@ -9,7 +9,7 @@ public enum ConnectionState: String, Sendable {
case disconnected
}
public struct DiscoveredDevice: Identifiable, Sendable, Equatable {
public struct DiscoveredDevice: Identifiable, Equatable {
public let id: String
public let name: String?
public let rssi: Int
@@ -21,7 +21,7 @@ public struct DiscoveredDevice: Identifiable, Sendable, Equatable {
}
}
public enum DuooomiBleError: LocalizedError, Sendable {
public enum DuooomiBleError: LocalizedError {
case bluetoothNotPoweredOn(String)
case notConnected
case timeout(command: CommandType)

View File

@@ -1,7 +1,7 @@
import Foundation
/// SDK
public struct DuooomiBleConfig: Sendable {
public struct DuooomiBleConfig {
/// API
public let apiHost: URL
/// API key

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,34 @@
import Foundation
/// SDK Combine @Published
/// 线
public protocol DuooomiBleSDKDelegate: AnyObject {
///
func sdk(_ sdk: DuooomiBleSDK, didChangeState state: ConnectionState)
///
func sdk(_ sdk: DuooomiBleSDK, didUpdateDevices devices: [DiscoveredDevice])
///
func sdk(_ sdk: DuooomiBleSDK, didConnectDevice device: DiscoveredDevice?)
///
func sdk(_ sdk: DuooomiBleSDK, didUpdateDeviceInfo info: DeviceInfo?)
///
func sdk(_ sdk: DuooomiBleSDK, didUpdateVersion version: String)
///
func sdk(_ sdk: DuooomiBleSDK, didChangeActivation isActivated: Bool)
/// (0-100)
func sdk(_ sdk: DuooomiBleSDK, didUpdateProgress progress: Int)
///
func sdk(_ sdk: DuooomiBleSDK, didEncounterError error: String?)
}
/// delegate
public extension DuooomiBleSDKDelegate {
func sdk(_ sdk: DuooomiBleSDK, didChangeState state: ConnectionState) {}
func sdk(_ sdk: DuooomiBleSDK, didUpdateDevices devices: [DiscoveredDevice]) {}
func sdk(_ sdk: DuooomiBleSDK, didConnectDevice device: DiscoveredDevice?) {}
func sdk(_ sdk: DuooomiBleSDK, didUpdateDeviceInfo info: DeviceInfo?) {}
func sdk(_ sdk: DuooomiBleSDK, didUpdateVersion version: String) {}
func sdk(_ sdk: DuooomiBleSDK, didChangeActivation isActivated: Bool) {}
func sdk(_ sdk: DuooomiBleSDK, didUpdateProgress progress: Int) {}
func sdk(_ sdk: DuooomiBleSDK, didEncounterError error: String?) {}
}

View File

@@ -1,6 +1,6 @@
import Foundation
public struct BindingResponse: Codable, Sendable, Equatable {
public struct BindingResponse: Codable, Equatable {
public let type: Int
/// SN (14 )
public let sn: String

View File

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

View File

@@ -1,6 +1,6 @@
import Foundation
public struct DeviceInfo: Sendable, Equatable {
public struct DeviceInfo: Equatable {
public let allspace: UInt64
public let freespace: UInt64
public let name: String

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import Foundation
public struct VersionInfo: Sendable, Equatable {
public struct VersionInfo: Equatable {
public let version: String
/// Int ( 7) String ( "0x07")
public let type: String

View File

@@ -21,7 +21,7 @@ public enum FrameConstants {
public static let screenSize = 360
}
public enum CommandType: UInt8, Sendable {
public enum CommandType: UInt8 {
case otaPackage = 0x02
case transferBootAnimation = 0x03
case transferAniVideo = 0x05

View File

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

View File

@@ -10,7 +10,7 @@ final class AniConverter {
private let config: DuooomiBleConfig
private var endpoint: URL {
config.apiHost.appendingPathComponent("api/auth/loomart/file/convert-to-ani")
URL(string: "\(config.apiHost)/api/auth/loomart/file/convert-to-ani")!
}
private let session: URLSession = {
@@ -26,15 +26,23 @@ final class AniConverter {
}
/// ANI ani CDN URL
func convert(fileUrl: String) async throws -> String {
do {
return try await performConvert(fileUrl: fileUrl)
} catch let error as NSError where error.code == NSURLErrorNetworkConnectionLost {
return try await performConvert(fileUrl: fileUrl)
func convert(fileUrl: String, completion: @escaping (Result<String, Error>) -> Void) {
performConvert(fileUrl: fileUrl) { result in
switch result {
case .success:
completion(result)
case .failure(let error):
// Retry once on connection lost
if (error as NSError).code == NSURLErrorNetworkConnectionLost {
self.performConvert(fileUrl: fileUrl, completion: completion)
} else {
completion(.failure(error))
}
}
}
}
private func performConvert(fileUrl: String) async throws -> String {
private func performConvert(fileUrl: String, completion: @escaping (Result<String, Error>) -> Void) {
var request = URLRequest(url: endpoint)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "content-type")
@@ -47,32 +55,49 @@ final class AniConverter {
"height": config.aniHeight,
"fps": config.aniFps,
]
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await session.data(for: request)
if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
throw AniConverterError(message: "HTTP \(http.statusCode)")
do {
request.httpBody = try JSONSerialization.data(withJSONObject: body)
} catch {
completion(.failure(error))
return
}
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw AniConverterError(message: "Invalid JSON response")
}
session.dataTask(with: request) { data, response, error in
if let error = error {
completion(.failure(error))
return
}
guard (json["success"] as? Bool) == true,
let outer = json["data"] as? [String: Any] else {
throw AniConverterError(message: "ANI API reported failure")
}
if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
completion(.failure(AniConverterError(message: "HTTP \(http.statusCode)")))
return
}
if (outer["status"] as? Bool) != true {
let msg = (outer["msg"] as? String) ?? "ANI conversion failed"
throw AniConverterError(message: msg)
}
guard let data = data,
let json = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else {
completion(.failure(AniConverterError(message: "Invalid JSON response")))
return
}
guard let aniUrl = outer["data"] as? String, !aniUrl.isEmpty else {
throw AniConverterError(message: "Missing ani url in response")
}
guard (json["success"] as? Bool) == true,
let outer = json["data"] as? [String: Any] else {
completion(.failure(AniConverterError(message: "ANI API reported failure")))
return
}
return aniUrl
if (outer["status"] as? Bool) != true {
let msg = (outer["msg"] as? String) ?? "ANI conversion failed"
completion(.failure(AniConverterError(message: msg)))
return
}
guard let aniUrl = outer["data"] as? String, !aniUrl.isEmpty else {
completion(.failure(AniConverterError(message: "Missing ani url in response")))
return
}
completion(.success(aniUrl))
}.resume()
}
}

View File

@@ -1,8 +1,16 @@
import Foundation
///
protocol BleProtocolServiceDelegate: AnyObject {
func protocolService(_ service: BleProtocolService, didReceiveMessage commandType: UInt8, data: Data)
}
///
final class BleProtocolService: @unchecked Sendable {
final class BleProtocolService: BleClientDelegate {
weak var delegate: BleProtocolServiceDelegate?
private let client: BleClient
private var isListening = false
///
private struct FragmentSession {
@@ -11,63 +19,36 @@ final class BleProtocolService: @unchecked Sendable {
}
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
}
isListening = true
client.delegate = self
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)
}
}
BleLog.i("Start listening for notifications", "Protocol")
}
///
func stopListening() {
listenerTask?.cancel()
listenerTask = nil
messageContinuation?.finish()
messageContinuation = nil
isListening = false
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 {
onProgress: ((Double) -> Void)? = nil,
completion: @escaping (Result<Void, Error>) -> Void
) {
let maxWriteLen = client.maximumWriteLength
let maxDataSize = min(
maxWriteLen - FrameConstants.headerSize - FrameConstants.footerSize,
@@ -84,27 +65,84 @@ final class BleProtocolService: @unchecked Sendable {
guard !frames.isEmpty else {
BleLog.e("Payload too large; frames empty", "Protocol")
throw DuooomiBleError.transferFailed("Payload too large: exceeds 65535-page limit")
completion(.failure(DuooomiBleError.transferFailed("Payload too large: exceeds 65535-page limit")))
return
}
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)
func sendFrame(at index: Int) {
guard index < total else {
onProgress?(1.0)
BleLog.i("Frames sent: total=\(total)", "Protocol")
completion(.success(()))
return
}
do {
try self.client.writeWithoutResponse(frames[index])
} catch {
completion(.failure(error))
return
}
onProgress?(Double(index + 1) / Double(total))
if index + 1 < total {
// Inter-frame delay 35ms
DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.035) {
sendFrame(at: index + 1)
}
} else {
onProgress?(1.0)
BleLog.i("Frames sent: total=\(total)", "Protocol")
completion(.success(()))
}
}
BleLog.i("Frames sent: total=\(total)", "Protocol")
sendFrame(at: 0)
}
/// JSON
/// - Parameters:
/// - type:
/// - payload:
/// - Throws: /
func sendJSON<T: Encodable>(type: CommandType, payload: T) async throws {
/// JSON
func sendJSON<T: Encodable>(type: CommandType, payload: T, completion: @escaping (Result<Void, Error>) -> Void) {
do {
let data = try JSONEncoder().encode(payload)
send(type: type.rawValue, data: data, completion: completion)
} catch {
completion(.failure(error))
}
}
/// JSON fire-and-forget
func sendJSON<T: Encodable>(type: CommandType, payload: T) throws {
let jsonData = try JSONEncoder().encode(payload)
try await send(type: type.rawValue, data: jsonData)
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.rawValue,
data: jsonData,
head: .appToDevice,
maxDataSize: safeMaxDataSize
)
for frame in frames {
try client.writeWithoutResponse(frame)
}
}
// MARK: - BleClientDelegate
func bleClient(_ client: BleClient, didDiscoverDevice device: DiscoveredDevice) {
// Not handled here scan results forwarded by SDK
}
func bleClient(_ client: BleClient, didReceiveData data: Data) {
guard isListening else { return }
handleRawData(data)
}
// MARK: - Receive & Reassemble
@@ -119,15 +157,12 @@ final class BleProtocolService: @unchecked Sendable {
handleFragment(frame)
} else {
BleLog.d("Single frame received: type=\(frame.type), len=\(frame.data.count)", "Protocol")
messageContinuation?.yield((commandType: frame.type, data: frame.data))
delegate?.protocolService(self, didReceiveMessage: 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)"
let key = "\(frame.type)"
if fragments[key] == nil {
fragments[key] = FragmentSession(total: Int(frame.subpageTotal), frames: [:])
@@ -153,6 +188,6 @@ final class BleProtocolService: @unchecked Sendable {
fragments.removeValue(forKey: key)
BleLog.i("Fragment reassembled: key=\(key), size=\(combined.count)", "Protocol")
messageContinuation?.yield((commandType: frame.type, data: combined))
delegate?.protocolService(self, didReceiveMessage: frame.type, data: combined)
}
}

View File

@@ -1,6 +1,6 @@
import Foundation
/// JSON
/// JSON fire-and-forget
final class DeviceInfoService {
private let protocolService: BleProtocolService
@@ -10,61 +10,43 @@ final class DeviceInfoService {
// MARK: - Commands
///
/// - Throws:
func getDeviceInfo() async throws {
try await protocolService.sendJSON(
func getDeviceInfo() throws {
try protocolService.sendJSON(
type: .getDeviceInfo,
payload: CommandPayload(type: CommandType.getDeviceInfo.rawValue)
)
}
///
/// - Throws:
func getDeviceVersion() async throws {
try await protocolService.sendJSON(
func getDeviceVersion() throws {
try protocolService.sendJSON(
type: .getDeviceVersion,
payload: CommandPayload(type: CommandType.getDeviceVersion.rawValue)
)
}
///
/// - Parameter userId:
/// - Throws:
func bindDevice(userId: String) async throws {
try await protocolService.sendJSON(
func bindDevice(userId: String) throws {
try 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(
func unbindDevice(userId: String) throws {
try 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(
func deleteFile(key: String) throws {
try 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(
func prepareTransfer(key: String, size: Int) throws {
try protocolService.sendJSON(
type: .prepareTransfer,
payload: PrepareTransferPayload(
type: CommandType.prepareTransfer.rawValue,

View File

@@ -9,40 +9,60 @@ final class FileTransferService {
}
///
/// - Parameters:
/// - fileUri: file:// URL https:// URL
/// - commandType:
/// - onProgress: 0.0 ~ 1.0
func transferFile(
fileUri: String,
commandType: CommandType,
onProgress: ((Double) -> Void)? = nil
) async throws {
onProgress: ((Double) -> Void)? = nil,
completion: @escaping (Result<Void, Error>) -> Void
) {
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
)
loadFileData(from: fileUri) { [weak self] result in
switch result {
case .failure(let error):
completion(.failure(error))
case .success(let data):
BleLog.d("File loaded: size=\(data.count) bytes", "Transfer")
self?.protocolService.send(
type: commandType.rawValue,
data: data,
onProgress: onProgress,
completion: completion
)
}
}
}
private func loadFileData(from uri: String) async throws -> Data {
private func loadFileData(from uri: String, completion: @escaping (Result<Data, Error>) -> Void) {
guard let url = URL(string: uri) else {
throw DuooomiBleError.transferFailed("Invalid URL: \(uri)")
completion(.failure(DuooomiBleError.transferFailed("Invalid URL: \(uri)")))
return
}
if url.isFileURL {
return try Data(contentsOf: url)
} else {
let (data, response) = try await URLSession.shared.data(from: url)
do {
let data = try Data(contentsOf: url)
completion(.success(data))
} catch {
completion(.failure(DuooomiBleError.transferFailed("Failed to read local file: \(error.localizedDescription)")))
}
return
}
URLSession.shared.dataTask(with: url) { data, response, error in
if let error = error {
completion(.failure(DuooomiBleError.transferFailed("Download failed: \(error.localizedDescription)")))
return
}
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
throw DuooomiBleError.transferFailed("Failed to download file: \(uri)")
completion(.failure(DuooomiBleError.transferFailed("Failed to download file: \(uri)")))
return
}
return data
}
guard let data = data, !data.isEmpty else {
completion(.failure(DuooomiBleError.transferFailed("Empty download response")))
return
}
completion(.success(data))
}.resume()
}
}

View File

@@ -1,7 +1,7 @@
import Foundation
///
public struct FirmwareInfo: Codable, Equatable, Sendable {
public struct FirmwareInfo: Codable, Equatable {
public let version: String
public let fileUrl: String
public let description: String?
@@ -25,20 +25,30 @@ final class FirmwareService {
self.config = config
}
func fetchLatest(identifier: String? = nil, status: String? = nil) async throws -> FirmwareInfo? {
func fetchLatest(
identifier: String? = nil,
status: String? = nil,
completion: @escaping (Result<FirmwareInfo?, Error>) -> Void
) {
let id = (identifier ?? config.firmwareIdentifier).trimmingCharacters(in: .whitespacesAndNewlines)
let st = status ?? config.firmwareStatus
guard !id.isEmpty else {
throw DuooomiBleError.transferFailed("Invalid firmware identifier")
completion(.failure(DuooomiBleError.transferFailed("Invalid firmware identifier")))
return
}
var comps = URLComponents(url: config.apiHost.appendingPathComponent(latestPath), resolvingAgainstBaseURL: false)!
let urlString = "\(config.apiHost)/\(latestPath)"
guard var comps = URLComponents(string: urlString) else {
completion(.failure(DuooomiBleError.transferFailed("Invalid firmware URL")))
return
}
comps.queryItems = [
URLQueryItem(name: "identifier", value: id),
URLQueryItem(name: "status", value: st)
]
guard let url = comps.url else {
throw DuooomiBleError.transferFailed("Invalid firmware URL")
completion(.failure(DuooomiBleError.transferFailed("Invalid firmware URL")))
return
}
var req = URLRequest(url: url)
@@ -46,15 +56,30 @@ final class FirmwareService {
req.setValue(config.apiKey, forHTTPHeaderField: "x-api-key")
req.setValue("application/json", forHTTPHeaderField: "accept")
let (data, resp) = try await URLSession.shared.data(for: req)
guard let http = resp as? HTTPURLResponse else {
throw DuooomiBleError.transferFailed("Invalid response")
}
guard (200...299).contains(http.statusCode) else {
throw DuooomiBleError.transferFailed("HTTP \(http.statusCode)")
}
URLSession.shared.dataTask(with: req) { data, resp, error in
if let error = error {
completion(.failure(error))
return
}
guard let http = resp as? HTTPURLResponse else {
completion(.failure(DuooomiBleError.transferFailed("Invalid response")))
return
}
guard (200...299).contains(http.statusCode) else {
completion(.failure(DuooomiBleError.transferFailed("HTTP \(http.statusCode)")))
return
}
guard let data = data else {
completion(.failure(DuooomiBleError.transferFailed("Empty response")))
return
}
let decoded = try JSONDecoder().decode(FirmwareResponse.self, from: data)
return decoded.data
do {
let decoded = try JSONDecoder().decode(FirmwareResponse.self, from: data)
completion(.success(decoded.data))
} catch {
completion(.failure(error))
}
}.resume()
}
}