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