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,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)
}
}