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