diff --git a/.gitignore b/.gitignore index 11e08c0..40ba64a 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,6 @@ fastlane/test_output # Code Injection iOSInjectionProject/ + +# Demo 本地秘钥(apiKey / owner),不进 git;首次 clone 复制 DemoSecrets.template.swift 填值 +demo/Sources/DemoSecrets.swift diff --git a/README.md b/README.md index 3bee6c3..b5a2716 100644 --- a/README.md +++ b/README.md @@ -21,24 +21,59 @@ dependencies: [ ] ``` +## Demo 初始化(仓库内 demo 目录) + +`apiKey` / `owner` / `scanNamePrefix` 不进 git,由本地 `demo/Sources/DemoSecrets.swift` 提供: + +```bash +cp demo/Sources/DemoSecrets.template.swift demo/Sources/DemoSecrets.swift +# 然后编辑 DemoSecrets.swift 填真实 apiKey +pod install +open demo.xcworkspace +``` + +`DemoSecrets.swift` 已加入 `.gitignore`,本地修改不会被 commit。 + ## 初始化 ```swift let sdk = DuooomiBleSDK(config: .init( - apiKey: "your-api-key" // 必填 - // apiHost: URL(...)!, // 默认 https://api.duooomi.com - // cdnHost: "https://cdn.bowong.cc/", // 默认 - // firmwareIdentifier: "duomi", // 默认 - // firmwareStatus: "DRAFT" // 默认 - // aniWidth: 360, // ANI 转换宽度,默认 360 - // aniHeight: 360, // ANI 转换高度,默认 360 - // aniFps: "24", // ANI 转换帧率,默认 24 + apiKey: "your-api-key", // 必填,Loomart x-api-key + owner: "your-owner", // 必填,所有 RPC 必带 x-owner header + scanNamePrefix: "蓝牙广播名前缀" // 必填,大小写不敏感 + // apiHost: URL(...)!, // 默认 https://api.duooomi.com + // cdnHost: "https://cdn.bowong.cc/", // 默认 + // aniWidth: 360, aniHeight: 360, aniFps: "24", // ANI 转换默认参数 )) -// 设置代理接收状态变化 sdk.delegate = self ``` +## SDK 能力范围 + +- **内置**:BLE 协议(扫描/连接/硬绑/硬解/播放模式/文件传输/OTA 烧录)+ 软绑(POST `/api/auth/loomart/shipment-sn/device/bind`)+ 软解绑 + 升级对账上报 + 服务端拉最新固件 + 北京时间获取 +- **不内置**:UI、retry 策略;SDK 只提供原子方法和状态属性,由集成方组合 + +### 绑定与固件升级流程 + +绑定按 BLE 链路 / 云端登记拆两步: + +1. **`bind(userId:)`** — 硬绑(BLE 0x0F)+ 同步 `getDeviceVersion`。完成后 `sn` / `version` / `isActivated` 就绪。 +2. **`fetchLatestFirmware(sn:currentVersion:userId:)`** — 内部串: + 1. 对账上报本地 pending `UpgradeRecord`(容错失败) + 2. 软绑 `shipment-sn`(失败 → 自动硬解绑回滚 + 重置 `isActivated`/`sn`,整个 fetch 失败) + 3. 拉服务端最新固件 → 写入 `firmwareUpgrade` + +升级烧录走 `upgradeFirmware(sn:fileUrl:firmwareId:fromVersion:targetVersion:)`,烧录完自动落 `UpgradeRecord`。 + +解绑只一步:`unbind(userId:)` 内部硬解 + 软解(软解失败仅 warn 不阻断)。 + +### API 调用风格 + +所有公开方法**显式传入参**,不依赖 SDK 内部状态自动取参。调用方从 SDK 只读属性(`sdk.sn` / `sdk.version` / `sdk.firmwareUpgrade?.targetFirmware?.*` 等)组装参数后传入。 + +解绑只一步:`unbind(userId:)` 内部硬解 + 软解(软解失败仅 warn 不阻断)。 + ## 代理(Delegate) 通过 `DuooomiBleSDKDelegate` 接收状态变化,所有方法在主线程调用,均有默认空实现: @@ -97,19 +132,22 @@ sdk.getVersion { result in } } +// 硬绑 + getVersion;不含软绑/OTA 检查 sdk.bind(userId: "user-id") { result in if case .success(let resp) = result { print("SN: \(resp.sn), Files: \(resp.contents.count)") + // 此时 sdk.sn / sdk.version / sdk.isActivated 已就绪 } } +// 硬解 + 软解(一步完成) sdk.unbind(userId: "user-id") { result in /* ... */ } sdk.deleteFile(key: "file-key") { result in /* ... */ } ``` #### 播放模式(Play Mode) -> ⚠️ **前置条件 — 必须先绑定。** `setPlayMode` 复用 `BIND_DEVICE` (0x0F) 命令通道,固件设计上要求设备先和该 `userId` 完成 `bind`(即 SDK `isActivated == true`)才能切播放模式。SDK 在客户端做了守卫:未绑定时直接 fail,不会发命令。请在 UI 上禁用切换按钮,直到 `didChangeActivation(true)` 回调到达。 +> ⚠️ **前置条件 — 必须先绑定。** `setPlayMode` 复用 `BIND_DEVICE` (0x0F) 命令通道,固件设计上要求设备先和该 `userId` 完成 `bind`(即 SDK `isActivated == true`)才能切播放模式。SDK 在客户端做了守卫:未绑定时直接 fail,不会发命令。调用方在 `isActivated == true` 之前不应调用此方法。 ```swift // 0 = 单播(播完一次停)/ 1 = 循环播放 @@ -151,24 +189,113 @@ sdk.transferMedia(fileUrl: "https://example.com/video.mp4") { result in ### 固件升级 +完整流程:**扫描 → 连接 → bind → fetchLatestFirmware(含对账上报)→ upgradeFirmware(落 record)→ 重连后再次 fetchLatestFirmware 触发对账**。 + ```swift -// 1. 查询最新固件 -sdk.fetchLatestFirmware { result in - guard case .success(let info?) = result else { return } - - // 2. 检查是否有新版本 - let needUpdate = sdk.hasNewerFirmware( - deviceVersion: sdk.version, - serverVersion: info.version - ) - - // 3. OTA 升级 - if needUpdate { - sdk.upgradeFirmware(fileUrl: info.fileUrl) { result in /* ... */ } +// ─── 0. 接收升级记录变化(用来观察对账上报结果) ─── +extension MyController: DuooomiBleSDKDelegate { + func sdk(_ sdk: DuooomiBleSDK, didUpdateUpgradeRecords records: [UpgradeRecord]) { + for r in records { + switch (r.reported, r.outcome) { + case (false, _): print("待上报 sn=\(r.sn) fw=\(r.firmwareId)") + case (true, .success): print("✅ 正确上报 \(r.fromVersion)→\(r.targetVersion)") + case (true, .versionMismatch): print("❌ 错误上报 versionMismatch") + case (true, nil): print("过期放弃 (>30 天)") + } + } } } +sdk.delegate = self + +// ─── 1. 扫描 + 连接 ─── +sdk.scan() +// 在 didUpdateDevices 拿到列表后选一台 connect +sdk.connect(deviceId: device.id) { result in + guard case .success = result else { return } + + // ─── 2. 硬绑(BLE 0x0F + 同步 getVersion)─── + sdk.bind(userId: "user-123") { result in + guard case .success(let resp) = result else { return } + // 此时 sdk.sn = resp.sn / sdk.version 已就绪 / sdk.isActivated = true + + // ─── 3. 拉最新固件(内部串:对账上报 → 软绑 → checkUpgrade)─── + sdk.fetchLatestFirmware( + sn: sdk.sn, + currentVersion: sdk.version, + userId: "user-123" + ) { result in + switch result { + case .failure(let err): + // 软绑失败已自动硬解回滚,可提示重试 + print("拉取失败:\(err.localizedDescription)") + return + case .success(let info): + // info 同步写入 sdk.firmwareUpgrade + guard let target = info.targetFirmware, + let fileUrl = target.fileUrl else { + print("无可升级固件") + return + } + + // ─── 4. 强制升级判定(可选)─── + if target.forceUpgrade == true { + // 弹不可关闭弹窗 + } + + // ─── 5. 烧录(完成后自动落 UpgradeRecord) ─── + sdk.upgradeFirmware( + sn: sdk.sn, + fileUrl: fileUrl, + firmwareId: target.id, + fromVersion: sdk.version, + targetVersion: target.version + ) { result in + switch result { + case .success: + // OTA 包发完,设备开始烧录并重启。 + // UpgradeRecord 已落本地(reported=false),等设备重启 + 重连。 + print("烧录命令完成,等待设备重启") + case .failure(let err): + print("烧录失败:\(err.localizedDescription)") + } + } + } + } + } +} + +// ─── 6. 设备重启 + 重连后,再次走完整流程 ─── +// 重新 scan/connect/bind 完成后,调一次 fetchLatestFirmware: +// 内部 tryReportPendingUpgrade 会拿当前 version 和上次 record.targetVersion 对账, +// 命中 → report-success,不命中 → report-failure(VERSION_MISMATCH ...)。 +// 结果通过 didUpdateUpgradeRecords 主动通知(参见步骤 0)。 ``` +### 升级对账上报机制 + +- **何时落记录**:`upgradeFirmware(...)` 烧录命令发完后,SDK 自动 upsert 一条 `UpgradeRecord` 到 `UserDefaults`(复合主键 `(sn, firmwareId)`),初始 `reported=false`。 +- **何时触发对账**:每次 `fetchLatestFirmware(...)` 进入时,第一步就跑 `tryReportPendingUpgrade`。 +- **判定规则**: + - `currentVersion == record.targetVersion` → `POST report-success` → `outcome=.success` + - `currentVersion != record.targetVersion` → `POST report-failure(VERSION_MISMATCH expected=... actual=...)` → `outcome=.versionMismatch` + - `now - triggeredAt > 30 天` → 标记 `outcome=nil` 不再尝试,保留为审计轨迹 + - HTTP 失败 → 保持 `reported=false`,下次 fetchLatestFirmware 再试 +- **响应回调**:`DuooomiBleSDKDelegate.didUpdateUpgradeRecords` 在每次 record 状态变化时主线程触发,完整 records 数组。 +- **手动触发**(仅调试用): + + ```swift + sdk.tryReportPendingUpgrade(sn: sdk.sn, currentVersion: sdk.version, userId: "user-123") + ``` + +### UpgradeRecord 状态判定 + +| `reported` | `outcome` | 含义 | +|---|---|---| +| `false` | `nil` | 待上报(HTTP 失败/未跑),下次 fetchLatestFirmware 重试 | +| `true` | `.success` | ✅ 正确上报:版本命中,业务对账成功 | +| `true` | `.versionMismatch` | ❌ 错误上报:设备版本与目标不符,烧录可能失败 | +| `true` | `nil` | 过期放弃:30 天 TTL 触发,保留作审计 | + ### 可读取状态 | 属性 | 类型 | 说明 | @@ -185,15 +312,32 @@ sdk.fetchLatestFirmware { result in ### 数据类型 ```swift -// FirmwareInfo — fetchLatestFirmware 返回 -public struct FirmwareInfo { - let version: String // 版本号 - let fileUrl: String // 固件下载地址 - let description: String? // 描述 - let fileSize: String? // 文件大小(字节字符串) - let fileMd5: String? // MD5 校验 - let identifier: String? // 设备标识 - let status: String? // DRAFT / PUBLISHED +// FirmwareUpgradeCheckResult — fetchLatestFirmware 返回,同步写入 sdk.firmwareUpgrade +public struct FirmwareUpgradeCheckResult { + let upgradeAvailable: Bool + let reason: FirmwareUpgradeCheckReason // UPGRADE_AVAILABLE / ALREADY_LATEST / DEVICE_NOT_FOUND ... + let currentSystemVersion: String? + let targetFirmware: TargetFirmware? +} + +public struct TargetFirmware { + let id: String // firmwareId(对账时使用) + let version: String // 目标版本 + let fileUrl: String? // 固件下载地址 + let fileSize: String? // 字节数(字符串) + let fileMd5: String? +} + +// UpgradeRecord — 本地持久化的 OTA 烧录记录(用 UserDefaults,集成方一般不用直接读) +public struct UpgradeRecord { + let sn: String + let firmwareId: String + let fromVersion: String + let targetVersion: String + let triggeredAt: Int64 // 毫秒时间戳 + let reported: Bool + let reportedAt: Int64? + let outcome: UpgradeOutcome? // .success / .versionMismatch / nil(TTL 过期) } // PlayMode — 播放模式 diff --git a/Sources/DuooomiBleSDK/Core/BleClient.swift b/Sources/DuooomiBleSDK/Core/BleClient.swift index de5dffc..b5b8ef2 100644 --- a/Sources/DuooomiBleSDK/Core/BleClient.swift +++ b/Sources/DuooomiBleSDK/Core/BleClient.swift @@ -23,6 +23,12 @@ final class BleClient: NSObject { private var connectCompletion: ((Result) -> Void)? private var disconnectCompletion: ((Result) -> Void)? private var connectTimeoutWork: DispatchWorkItem? + /// 待枚举 characteristic 的 service 计数(每个 service 完成 didDiscoverCharacteristicsFor 时递减)。 + private var servicesPendingCharDiscover: Int = 0 + /// 待枚举 included services 的 service 计数(递归发现 secondary service)。 + private var servicesPendingIncludedDiscover: Int = 0 + /// 已发起 characteristic 发现的 service uuidString 去重集合(避免同一 service 被重复发现)。 + private var charDiscoverDispatched: Set = [] var bluetoothState: CBManagerState { centralManager.state @@ -69,6 +75,9 @@ final class BleClient: NSObject { } self.connectCompletion = completion + self.servicesPendingCharDiscover = 0 + self.servicesPendingIncludedDiscover = 0 + self.charDiscoverDispatched.removeAll() peripheral.delegate = self centralManager.connect(peripheral, options: nil) @@ -180,56 +189,127 @@ extension BleClient: CBPeripheralDelegate { return } let services = peripheral.services ?? [] - BleLog.d("Discovered \(services.count) service(s): \(services.map { $0.uuid.uuidString })", "BLE") - guard let service = services.first(where: { $0.uuid == BleUUIDs.service }) else { + BleLog.d("Discovered \(services.count) primary service(s): \(services.map { $0.uuid.uuidString })", "BLE") + + // CoreBluetooth `discoverServices(nil)` 只发现 primary service, + // 固件可能把目标 service 挂成 included service(react-native-ble-plx 默认会递归发现)。 + // 这里对每个 primary service 同时发起: + // 1. 发现 included services(递归) + // 2. 发现自己的 characteristics + for s in services { + scheduleServiceFullDiscovery(peripheral: peripheral, service: s) + } + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverIncludedServicesFor service: CBService, error: Error?) { + servicesPendingIncludedDiscover = max(0, servicesPendingIncludedDiscover - 1) + if let error = error { + BleLog.w("Included service discover error for \(service.uuid.uuidString): \(error.localizedDescription)", "BLE") + } else { + let included = service.includedServices ?? [] + if !included.isEmpty { + BleLog.d(" [Service \(service.uuid.uuidString)] includes \(included.count) sub-service(s): \(included.map { $0.uuid.uuidString })", "BLE") + } + for sub in included { + scheduleServiceFullDiscovery(peripheral: peripheral, service: sub) + } + } + evaluateConnectCompletionIfReady() + } + + /// 对一个 service 同时发起 included services + characteristics 发现,去重避免循环。 + private func scheduleServiceFullDiscovery(peripheral: CBPeripheral, service: CBService) { + let key = service.uuid.uuidString.lowercased() + if charDiscoverDispatched.contains(key) { return } + charDiscoverDispatched.insert(key) + + servicesPendingIncludedDiscover += 1 + peripheral.discoverIncludedServices(nil, for: service) + + servicesPendingCharDiscover += 1 + peripheral.discoverCharacteristics(nil, for: service) + } + + /// 所有 included service 与 characteristic 发现都结束后,判定连接成败。 + private func evaluateConnectCompletionIfReady() { + guard servicesPendingIncludedDiscover == 0, servicesPendingCharDiscover == 0 else { return } + guard connectCompletion != nil, let peripheral = connectedPeripheral else { return } + + if writeCharacteristic == nil || readCharacteristic == nil { + BleLog.e("Required characteristics not found. SDK service=\(BleUUIDs.service.uuidString), write=\(BleUUIDs.writeCharacteristic.uuidString), read=\(BleUUIDs.readCharacteristic.uuidString). 检查上方 [Service ...] Char 列表,把固件实际使用的 UUID 反馈给 SDK。", "BLE") let cb = connectCompletion connectCompletion = nil cb?(.failure(DuooomiBleError.serviceNotFound)) return } - peripheral.discoverCharacteristics( - [BleUUIDs.writeCharacteristic, BleUUIDs.readCharacteristic], - for: service - ) - } - - func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { - if let error = error { - let cb = connectCompletion - connectCompletion = nil - cb?(.failure(error)) - return - } - guard let characteristics = service.characteristics else { - let cb = connectCompletion - connectCompletion = nil - cb?(.failure(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 cb = connectCompletion - connectCompletion = nil - cb?(.failure(DuooomiBleError.characteristicNotFound)) - return - } let cb = connectCompletion connectCompletion = nil cb?(.success(peripheral)) } + func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { + servicesPendingCharDiscover = max(0, servicesPendingCharDiscover - 1) + + if let error = error { + BleLog.w("Char discover error for service \(service.uuid.uuidString): \(error.localizedDescription)", "BLE") + } else { + // 诊断:打印此 service 下所有 characteristic 的属性(write/notify/read…) + for c in service.characteristics ?? [] { + let props = Self.describeProps(c.properties) + BleLog.d(" [Service \(service.uuid.uuidString)] Char \(c.uuid.uuidString) [\(props)]", "BLE") + } + + // 命中目标 service 的 characteristic 才登记 + if Self.uuidEqual(service.uuid, BleUUIDs.service) { + for char in service.characteristics ?? [] { + if Self.uuidEqual(char.uuid, BleUUIDs.writeCharacteristic) { + writeCharacteristic = char + } else if Self.uuidEqual(char.uuid, BleUUIDs.readCharacteristic) { + readCharacteristic = char + peripheral.setNotifyValue(true, for: char) + } + } + } + } + + evaluateConnectCompletionIfReady() + } + + private static func describeProps(_ p: CBCharacteristicProperties) -> String { + var bits: [String] = [] + if p.contains(.read) { bits.append("Read") } + if p.contains(.write) { bits.append("Write") } + if p.contains(.writeWithoutResponse) { bits.append("WriteNoResp") } + if p.contains(.notify) { bits.append("Notify") } + if p.contains(.indicate) { bits.append("Indicate") } + return bits.joined(separator: ",") + } + func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { guard error == nil, let data = characteristic.value else { return } delegate?.bleClient(self, didReceiveData: data) } + + // MARK: - UUID 归一化比较 + + /// CoreBluetooth 在 service.uuid 上对蓝牙标准 base UUID 范围内的 UUID 会返回 16-bit 短形式 + /// (如 `"02C4"`),与 `CBUUID(string: "000002c4-0000-1000-8000-00805f9b34fb")` 直接 `==` + /// 比较会因 `data` 长度不同而不相等。把短形式展开成完整 128-bit 字符串再比较。 + static func uuidEqual(_ a: CBUUID, _ b: CBUUID) -> Bool { + if a == b { return true } + return Self.canonicalUUIDString(a) == Self.canonicalUUIDString(b) + } + + private static func canonicalUUIDString(_ uuid: CBUUID) -> String { + let s = uuid.uuidString.lowercased() + switch s.count { + case 4: + return "0000\(s)-0000-1000-8000-00805f9b34fb" + case 8: + return "\(s)-0000-1000-8000-00805f9b34fb" + default: + return s + } + } } diff --git a/Sources/DuooomiBleSDK/Core/BleTypes.swift b/Sources/DuooomiBleSDK/Core/BleTypes.swift index f469141..c582de3 100644 --- a/Sources/DuooomiBleSDK/Core/BleTypes.swift +++ b/Sources/DuooomiBleSDK/Core/BleTypes.swift @@ -29,6 +29,8 @@ public enum DuooomiBleError: LocalizedError { case serviceNotFound case characteristicNotFound case bindingFailed(String) + case alreadyBoundByOtherUser + case softBindFailed(String) case unbindFailed case deleteFileFailed(status: Int) case prepareTransferFailed(status: String) @@ -36,6 +38,7 @@ public enum DuooomiBleError: LocalizedError { case invalidFrame case permissionDenied case unsupportedFormat(String) + case firmwareUpgradeUnavailable(String) public var errorDescription: String? { switch self { @@ -53,6 +56,10 @@ public enum DuooomiBleError: LocalizedError { return "BLE characteristic not found" case .bindingFailed(let reason): return "Binding failed: \(reason)" + case .alreadyBoundByOtherUser: + return "该设备已被其他用户绑定" + case .softBindFailed(let reason): + return reason case .unbindFailed: return "Unbind failed" case .deleteFileFailed(let status): @@ -67,6 +74,8 @@ public enum DuooomiBleError: LocalizedError { return "Bluetooth permission denied" case .unsupportedFormat(let ext): return "Unsupported file format: \(ext). Supported: jpeg, png, gif, mp4, webm, webp" + case .firmwareUpgradeUnavailable(let reason): + return "Firmware upgrade unavailable: \(reason)" } } } diff --git a/Sources/DuooomiBleSDK/DuooomiBleConfig.swift b/Sources/DuooomiBleSDK/DuooomiBleConfig.swift index 4f45811..0406499 100644 --- a/Sources/DuooomiBleSDK/DuooomiBleConfig.swift +++ b/Sources/DuooomiBleSDK/DuooomiBleConfig.swift @@ -4,38 +4,38 @@ import Foundation public struct DuooomiBleConfig { /// API 服务地址 public let apiHost: URL - /// API 鉴权 key + /// API 鉴权 key(Loomart x-api-key) public let apiKey: String + /// 多租户 owner 标识,所有 RPC 必带 `x-owner` header + public let owner: String /// CDN 资源地址(末尾带 `/`) public let cdnHost: String - /// 固件查询标识,如 "duomi" - public let firmwareIdentifier: String - /// 固件查询状态,如 "DRAFT" / "PUBLISHED" - public let firmwareStatus: String /// ANI 转换宽度(像素) public let aniWidth: Int /// ANI 转换高度(像素) public let aniHeight: Int /// ANI 转换帧率 public let aniFps: String + /// 扫描时按设备蓝牙广播名前缀过滤(大小写不敏感)。**必填**,由集成方约定固件广播名前缀。 + public let scanNamePrefix: String public init( apiHost: URL = URL(string: "https://api.duooomi.com")!, apiKey: String, + owner: String, cdnHost: String = "https://cdn.bowong.cc/", - firmwareIdentifier: String = "duomi", - firmwareStatus: String = "DRAFT", aniWidth: Int = 360, aniHeight: Int = 360, - aniFps: String = "24" + aniFps: String = "24", + scanNamePrefix: String ) { self.apiHost = apiHost self.apiKey = apiKey + self.owner = owner self.cdnHost = cdnHost.hasSuffix("/") ? cdnHost : cdnHost + "/" - self.firmwareIdentifier = firmwareIdentifier - self.firmwareStatus = firmwareStatus self.aniWidth = aniWidth self.aniHeight = aniHeight self.aniFps = aniFps + self.scanNamePrefix = scanNamePrefix } } diff --git a/Sources/DuooomiBleSDK/DuooomiBleSDK+Binding.swift b/Sources/DuooomiBleSDK/DuooomiBleSDK+Binding.swift new file mode 100644 index 0000000..427ff6f --- /dev/null +++ b/Sources/DuooomiBleSDK/DuooomiBleSDK+Binding.swift @@ -0,0 +1,109 @@ +import Foundation + +// MARK: - Binding (BLE 协议层硬绑/硬解 + 解绑端的软解;OTA 检查/对账/软绑搬到 +Firmware) + +extension DuooomiBleSDK { + + /// 硬绑:BLE 协议层把当前用户绑定到设备(命令 0x0F),完成后同步拉一次版本号。 + /// + /// **作用域**:只做 BLE 通道的绑定 + `getDeviceVersion`。 + /// 软绑 / 升级对账上报 / 拉最新固件 → 全部移到 `fetchLatestFirmware(sn:currentVersion:userId:)` 内部串。 + /// + /// 完成后 SDK 状态变化: + /// - `sdk.sn` 写入设备序列号 + /// - `sdk.version` 写入当前固件版本(getVersion 失败时保持空,但 bind 不算失败) + /// - `sdk.isActivated = true`,delegate `didChangeActivation(true)` 回调 + /// + /// - Parameters: + /// - userId: 当前用户 ID(写入 BLE BIND payload 的 `bindUserId` 字段,不依赖登录态 token) + /// - completion: 回调 `Result` + /// - 成功:`BindingResponse(success: 1, sn, contents)` + /// - 失败:`.notConnected` / `.alreadyBoundByOtherUser`(success≠1) / `.timeout(.bindDevice)` + /// - Note: `bind` / `unbind` / `setPlayMode` 共享 `0x0F` / `0x12` opId 通道,**不能并发**。 + public func bind(userId: String, completion: @escaping (Result) -> Void) { + guard ensureConnected(completion: completion) else { return } + BleLog.d("Sending bind (userId=\(userId))", "Command") + + sendAsyncAndWait(commandType: .bindDevice, completion: { [weak self] result in + guard let self = self else { return } + switch result { + case .failure(let error): + completion(.failure(error)) + case .success(let data): + do { + let resp = try self.decodeResponse(BindingResponse.self, from: data) + self.isActivated = resp.success == 1 + if resp.success != 1 { + BleLog.w("Hard bind failed: success=0", "Command") + completion(.failure(DuooomiBleError.alreadyBoundByOtherUser)) + return + } + self.sn = resp.sn + BleLog.i("Hard bind success: sn=\(resp.sn)", "Command") + // 同步拉版本,让后续 fetchLatestFirmware 的对账上报有 currentVersion 可用 + self.getVersion { _ in + completion(.success(resp)) + } + } catch { + completion(.failure(error)) + } + } + }, send: { done in + self.deviceInfoService.bindDevice(userId: userId, completion: done) + }) + } + + /// 解绑:硬解绑(BLE 0x12)→ 软解绑(shipment-sn HTTP)。SDK 内部完成全套。 + /// + /// 软解失败仅打 warn 不阻断硬解结果回传——对端云记录如果残留,由后续 bind 自然修正。 + /// + /// 完成后 SDK 状态变化: + /// - `sdk.sn` 清空 + /// - `sdk.isActivated = false`,delegate `didChangeActivation(false)` 回调 + /// + /// - Parameters: + /// - userId: 当前用户 ID(写入 BLE UNBIND payload + 软解请求体的 `bindUserId`) + /// - completion: 回调 `Result` + /// - 成功:`UnbindResponse(success: 1)`(软解失败也算成功) + /// - 失败:`.notConnected` / `.unbindFailed`(BLE 层 success≠1) / `.timeout(.unbindDevice)` + /// - Note: `bind` / `unbind` / `setPlayMode` 共享 `0x0F` / `0x12` opId 通道,**不能并发**。 + public func unbind(userId: String, completion: @escaping (Result) -> Void) { + guard ensureConnected(completion: completion) else { return } + let cachedSn = self.sn + BleLog.d("Sending unbind (userId=\(userId))", "Command") + + sendAsyncAndWait(commandType: .unbindDevice, completion: { [weak self] result in + guard let self = self else { return } + switch result { + case .failure(let error): + completion(.failure(error)) + case .success(let data): + do { + let resp = try self.decodeResponse(UnbindResponse.self, from: data) + if resp.success == 1 { + self.isActivated = false + self.sn = "" + BleLog.i("Hard unbind success", "Command") + if !cachedSn.isEmpty { + self.shipmentService.unbind(sn: cachedSn, bindUserId: userId) { softResult in + if case .failure(let err) = softResult { + BleLog.w("Soft unbind failed (ignored): \(err.localizedDescription)", "Command") + } + completion(.success(resp)) + } + } else { + completion(.success(resp)) + } + } else { + BleLog.w("Unbind failed", "Command") + completion(.failure(DuooomiBleError.unbindFailed)) + } + } catch { + completion(.failure(error)) + } + } + }, send: { done in + self.deviceInfoService.unbindDevice(userId: userId, completion: done) + }) + } +} diff --git a/Sources/DuooomiBleSDK/DuooomiBleSDK+Commands.swift b/Sources/DuooomiBleSDK/DuooomiBleSDK+Commands.swift new file mode 100644 index 0000000..7f4b9b2 --- /dev/null +++ b/Sources/DuooomiBleSDK/DuooomiBleSDK+Commands.swift @@ -0,0 +1,200 @@ +import Foundation + +// MARK: - Device Commands (无业务编排,纯协议层 RPC) + +extension DuooomiBleSDK { + + /// 读取设备信息(品牌、屏幕尺寸、电量、存储、播放模式等)。 + /// + /// 成功后同步写入 `sdk.deviceInfo` 并通过 delegate `didUpdateDeviceInfo` 回调。 + /// + /// - Parameter completion: 回调 `Result` + /// - 成功:`DeviceInfo`(含 `name` / `brand` / `size` / `powerlevel` / `allspace` / `freespace` / `loop?`) + /// - 失败:`.notConnected` / `.timeout(.getDeviceInfo)` / JSON 解码错误 + public func getDeviceInfo(completion: @escaping (Result) -> Void) { + guard ensureConnected(completion: completion) else { return } + BleLog.d("Sending getDeviceInfo", "Command") + + sendAndWait(commandType: .getDeviceInfo, completion: { [weak self] result in + guard let self = self else { return } + switch result { + case .failure(let error): + completion(.failure(error)) + case .success(let data): + do { + let info = try self.decodeResponse(DeviceInfo.self, from: data) + BleLog.i("DeviceInfo received: name=\(info.name), brand=\(info.brand)", "Command") + self.deviceInfo = info + completion(.success(info)) + } catch { + completion(.failure(error)) + } + } + }, send: { + try self.deviceInfoService.getDeviceInfo() + }) + } + + /// 读取设备固件版本。 + /// + /// 成功后同步写入 `sdk.version` 并通过 delegate `didUpdateVersion` 回调。 + /// `bind()` 内部已经在硬绑成功后自动调用一次,集成方一般不用单独调。 + /// + /// - Parameter completion: 回调 `Result` + /// - 成功:`VersionInfo`(含 `version` / `type`) + /// - 失败:`.notConnected` / `.timeout(.getDeviceVersion)` / JSON 解码错误 + public func getVersion(completion: @escaping (Result) -> Void) { + guard ensureConnected(completion: completion) else { return } + BleLog.d("Sending getDeviceVersion", "Command") + + sendAndWait(commandType: .getDeviceVersion, completion: { [weak self] result in + guard let self = self else { return } + switch result { + case .failure(let error): + completion(.failure(error)) + case .success(let data): + do { + let info = try self.decodeResponse(VersionInfo.self, from: data) + self.version = info.version + BleLog.i("Version received: \(info.version) (type=\(info.type))", "Command") + completion(.success(info)) + } catch { + completion(.failure(error)) + } + } + }, send: { + try self.deviceInfoService.getDeviceVersion() + }) + } + + /// 切换播放模式(单播 / 循环)。 + /// + /// 复用 `BIND_DEVICE` (0x0F) 命令通道;响应类型为 `BindingResponse`。 + /// 成功后自动刷新一次 `deviceInfo`(含最新 `loop` 字段),与 expo 端行为对齐。 + /// + /// - Parameters: + /// - mode: `.single`(0)=单播一次停 / `.loop`(1)=循环播放 + /// - userId: 当前用户 ID(必须和已绑定的 userId 一致) + /// - completion: 回调 `Result` + /// - 成功:`BindingResponse`(`success=1` / `sn` / `contents[]`) + /// - 失败:`.notConnected` / `.bindingFailed("Device not bound; call bind(userId:) first")` + /// / `.bindingFailed("Set play mode failed")` / `.timeout(.bindDevice)` + /// - Important: 必须先 `bind(userId:)` 完成(`isActivated == true`),否则设备会返回 `success=0`, + /// SDK 在客户端先做守卫,直接返回失败而不发命令。 + /// - Note: `bind` / `unbind` / `setPlayMode` 共享 `0x0F` / `0x12` opId 通道,**不能并发**。 + public func setPlayMode( + _ mode: PlayMode, + userId: String, + completion: @escaping (Result) -> Void + ) { + guard ensureConnected(completion: completion) else { return } + guard isActivated else { + BleLog.w("setPlayMode called before bind (isActivated=false)", "Command") + DispatchQueue.main.async { + completion(.failure(DuooomiBleError.bindingFailed("Device not bound; call bind(userId:) first"))) + } + return + } + BleLog.d("Sending setPlayMode (mode=\(mode), userId=\(userId))", "Command") + + sendAsyncAndWait(commandType: .bindDevice, completion: { [weak self] result in + guard let self = self else { return } + switch result { + case .failure(let error): + completion(.failure(error)) + case .success(let data): + do { + let resp = try self.decodeResponse(BindingResponse.self, from: data) + if resp.success != 1 { + BleLog.w("setPlayMode failed: device rejected", "Command") + completion(.failure(DuooomiBleError.bindingFailed("Set play mode failed"))) + return + } + BleLog.i("setPlayMode success: mode=\(mode)", "Command") + self.getDeviceInfo { _ in + completion(.success(resp)) + } + } catch { + completion(.failure(error)) + } + } + }, send: { done in + self.deviceInfoService.setPlayMode(userId: userId, loop: mode, completion: done) + }) + } + + /// 删除设备上的文件。 + /// + /// - Parameters: + /// - key: 文件 key(绑定后从 `BindingResponse.contents` 取得) + /// - completion: 回调 `Result` + /// - 成功:`DeleteFileResponse(success: 0)`(设备约定 0 表示成功) + /// - 失败:`.notConnected` / `.deleteFileFailed(status:)`(status 非 0) + /// / `.timeout(.deleteFile)` / JSON 解码错误 + public func deleteFile(key: String, completion: @escaping (Result) -> Void) { + guard ensureConnected(completion: completion) else { return } + BleLog.d("Sending deleteFile (key=\(key))", "Command") + + sendAndWait(commandType: .deleteFile, completion: { [weak self] result in + guard let self = self else { return } + switch result { + case .failure(let error): + completion(.failure(error)) + case .success(let data): + do { + let resp = try self.decodeResponse(DeleteFileResponse.self, from: data) + if resp.success != 0 { + BleLog.w("Delete failed with status=\(resp.success)", "Command") + completion(.failure(DuooomiBleError.deleteFileFailed(status: resp.success))) + } else { + BleLog.i("Delete success (key=\(key))", "Command") + completion(.success(resp)) + } + } catch { + completion(.failure(error)) + } + } + }, send: { + try self.deviceInfoService.deleteFile(key: key) + }) + } + + /// 传输前的预协商:让设备校验存储空间、key 是否冲突等。 + /// + /// 一般集成方不直接调用,`transferMedia(fileUrl:)` 内部已串好。 + /// + /// - Parameters: + /// - key: 文件 key(一般用原始 URL) + /// - size: 待写入文件字节数 + /// - completion: 回调 `Result` + /// - 成功:`PrepareTransferResponse(status: "ready", key)` + /// - 失败:`.notConnected` / `.prepareTransferFailed(status:)` / `.timeout(.prepareTransfer)` + public func prepareTransfer(key: String, size: Int, completion: @escaping (Result) -> Void) { + guard ensureConnected(completion: completion) else { return } + let opId = "\(CommandType.prepareTransfer.rawValue)_\(key)" + BleLog.d("Sending prepareTransfer (key=\(key), size=\(size))", "Transfer") + + sendAndWait(opId: opId, commandType: .prepareTransfer, completion: { [weak self] result in + guard let self = self else { return } + switch result { + case .failure(let error): + completion(.failure(error)) + case .success(let data): + do { + let resp = try self.decodeResponse(PrepareTransferResponse.self, from: data) + if resp.status != "ready" { + BleLog.w("PrepareTransfer not ready: status=\(resp.status)", "Transfer") + completion(.failure(DuooomiBleError.prepareTransferFailed(status: resp.status))) + } else { + BleLog.i("PrepareTransfer ready (key=\(key))", "Transfer") + completion(.success(resp)) + } + } catch { + completion(.failure(error)) + } + } + }, send: { + try self.deviceInfoService.prepareTransfer(key: key, size: size) + }) + } +} diff --git a/Sources/DuooomiBleSDK/DuooomiBleSDK+Connection.swift b/Sources/DuooomiBleSDK/DuooomiBleSDK+Connection.swift new file mode 100644 index 0000000..4759163 --- /dev/null +++ b/Sources/DuooomiBleSDK/DuooomiBleSDK+Connection.swift @@ -0,0 +1,90 @@ +import Foundation + +// MARK: - Connection + +extension DuooomiBleSDK { + + /// 连接到指定设备。 + /// + /// 内部会停止扫描、启动协议监听。成功后 `sdk.btState = .connected`,`sdk.connectedDevice` 写入; + /// delegate `didConnectDevice` 回调到达。后续可调用 `getVersion` / `getDeviceInfo` / `bind` 等。 + /// + /// - Parameters: + /// - deviceId: 设备 UUID(从 `discoveredDevices.id` 取,对应 `CBPeripheral.identifier.uuidString`) + /// - completion: 回调 `Result` + /// - 成功:连接上的 `DiscoveredDevice` + /// - 失败:`.connectionFailed(...)` / `.serviceNotFound` / `.characteristicNotFound` + /// / `.bluetoothNotPoweredOn(...)` / `.permissionDenied` + public func connect(deviceId: String, completion: @escaping (Result) -> Void) { + stopScan() + btState = .connecting + BleLog.i("Connecting to \(deviceId)...", "Connect") + + bleClient.connect(deviceId: deviceId) { [weak self] result in + guard let self = self else { return } + switch result { + case .success(let peripheral): + self.protocolService.startListening() + + let device = DiscoveredDevice( + id: peripheral.identifier.uuidString, + name: peripheral.name, + rssi: 0 + ) + self.connectedDevice = device + self.btState = .connected + BleLog.i("Connected: \(device.id)", "Connect") + completion(.success(device)) + + case .failure(let error): + self.btState = .idle + self.error = error.localizedDescription + BleLog.e("Connect failed: \(error.localizedDescription)", "Connect") + completion(.failure(error)) + } + } + } + + /// 断开当前 BLE 连接。 + /// + /// 同步清空 `sdk.connectedDevice` / `deviceInfo` / `version` / `isActivated`,停止协议监听, + /// 取消所有 pending RPC 回调(这些回调会收到 `.notConnected` 失败)。 + /// + /// - Parameter completion: 回调 `Result` + /// - 成功:已断开 + /// - 失败:CoreBluetooth 底层错误(极少见) + public func disconnect(completion: @escaping (Result) -> Void) { + btState = .disconnecting + BleLog.i("Disconnect requested", "Connect") + protocolService.stopListening() + cancelAllPending(error: DuooomiBleError.notConnected) + + bleClient.disconnect { [weak self] result in + guard let self = self else { return } + if case .failure(let error) = result { + self.error = error.localizedDescription + BleLog.e("Disconnect error: \(error.localizedDescription)", "Connect") + } + + self.connectedDevice = nil + self.deviceInfo = nil + self.version = "" + self.isActivated = false + self.btState = .disconnected + BleLog.i("Disconnected", "Connect") + completion(result) + } + } + + /// 返回 iOS 系统当前已连接的、和本 SDK 服务 UUID 匹配的设备列表。 + /// + /// 用于断网后重启 App、设备仍在系统蓝牙连接列表里的快速恢复场景。 + /// 不需要先 `scan()`,对每个返回的 `DiscoveredDevice.id` 直接 `connect(deviceId:)` 即可。 + /// + /// - Returns: 系统已连接的 `DiscoveredDevice` 数组(可能为空) + public func getConnectedDevices() -> [DiscoveredDevice] { + let list = bleClient.retrieveConnectedDevices() + BleLog.d("System-connected devices: \(list.count)", "Connect") + return list + } +} diff --git a/Sources/DuooomiBleSDK/DuooomiBleSDK+Firmware.swift b/Sources/DuooomiBleSDK/DuooomiBleSDK+Firmware.swift new file mode 100644 index 0000000..0a71c62 --- /dev/null +++ b/Sources/DuooomiBleSDK/DuooomiBleSDK+Firmware.swift @@ -0,0 +1,189 @@ +import Foundation + +// MARK: - Firmware OTA (检查 / 烧录 / 对账上报) +// +// 所有公开方法显式入参,不依赖 SDK 内部状态自动取参。 +// 集成方负责从 `sdk.sn` / `sdk.version` / `sdk.firmwareUpgrade` 等只读属性组装参数。 + +extension DuooomiBleSDK { + + /// 拉取服务端最新固件信息。**内部串三步业务编排**(顺序固定): + /// + /// 1. **对账上报**:把本地 pending `UpgradeRecord` 逐条报给服务端(容错失败,不阻断) + /// 2. **软绑**:POST `/api/auth/loomart/shipment-sn/device/bind`,登记云端绑定关系 + /// - 软绑失败 → 自动硬解绑(BLE 0x12)回滚 + 重置 `isActivated`/`sn`,整个 fetch 失败 + /// 3. **拉服务端最新固件**:写入 `self.firmwareUpgrade`,通过 completion 回传 + /// + /// - Parameters: + /// - sn: 设备序列号(bind 成功后从 `sdk.sn` 取) + /// - currentVersion: 设备当前固件版本(从 `sdk.version` 取) + /// - userId: 当前用户 ID + public func fetchLatestFirmware( + sn: String, + currentVersion: String, + userId: String, + completion: @escaping (Result) -> Void + ) { + guard !sn.isEmpty else { + DispatchQueue.main.async { + completion(.failure(DuooomiBleError.firmwareUpgradeUnavailable("sn is empty (bind first)"))) + } + return + } + + // Step 1: 对账上报历史 OTA 记录(容错失败,sn 级 in-flight 互斥) + self.tryReportPendingUpgrade(sn: sn, currentVersion: currentVersion, userId: userId) + + // Step 2: 软绑(shipment-sn HTTP) + // 失败 → 自动硬解绑(BLE 0x12)回滚 + 重置 isActivated/sn,保持云端 ↔ 本地状态一致; + // 整个 fetchLatestFirmware 失败回传,调用方收到错误后可重新走 bind 流程。 + BleLog.d("Soft bind start: sn=\(sn)", "Firmware") + shipmentService.bind(sn: sn, bindUserId: userId) { [weak self] result in + guard let self = self else { return } + if case .failure(let err) = result { + BleLog.e("Soft bind failed during fetchLatestFirmware: \(err.localizedDescription); rolling back hard unbind", "Firmware") + // 硬解绑回滚(fire-and-forget;BLE ack 不影响这次 fetch 错误回传) + self.deviceInfoService.unbindDevice(userId: userId) { rollbackResult in + if case .failure(let rbErr) = rollbackResult { + BleLog.w("Hard unbind rollback failed (ignored): \(rbErr.localizedDescription)", "Firmware") + } else { + BleLog.i("Hard unbind rollback sent", "Firmware") + } + } + self.isActivated = false + self.sn = "" + DispatchQueue.main.async { completion(.failure(err)) } + return + } + BleLog.i("Soft bind success: sn=\(sn)", "Firmware") + + // Step 3: 服务端 checkUpgrade + self.firmwareUpgradeService.checkUpgrade(sn: sn, currentVersion: currentVersion) { result in + switch result { + case .success(let r): + DispatchQueue.main.async { + self.firmwareUpgrade = r + completion(.success(r)) + } + case .failure(let err): + BleLog.w("checkUpgrade failed: \(err.localizedDescription)", "Firmware") + DispatchQueue.main.async { completion(.failure(err)) } + } + } + } + } + + /// OTA 固件烧录。完成后自动落 `UpgradeRecord` 到本地(复合主键 `(sn, firmwareId)`), + /// 等待下次 `fetchLatestFirmware` 触发 `tryReportPendingUpgrade` 对账上报。 + /// + /// - Parameters: + /// - sn: 设备序列号(用于落 UpgradeRecord 的主键) + /// - fileUrl: 固件包下载 URL(一般从 `sdk.firmwareUpgrade?.targetFirmware?.fileUrl` 取) + /// - firmwareId: 目标固件 ID(一般从 `sdk.firmwareUpgrade?.targetFirmware?.id` 取) + /// - fromVersion: 升级前版本(一般从 `sdk.version` 取) + /// - targetVersion: 升级目标版本(一般从 `sdk.firmwareUpgrade?.targetFirmware?.version` 取) + /// - completion: 回调 `Result` + /// - 成功:OTA 包已发完,设备会重启完成升级;UpgradeRecord 已落本地 + /// - 失败:`.notConnected` / `.transferFailed(...)` + public func upgradeFirmware( + sn: String, + fileUrl: String, + firmwareId: String, + fromVersion: String, + targetVersion: String, + completion: @escaping (Result) -> Void + ) { + guard connectedDevice != nil else { + completion(.failure(DuooomiBleError.notConnected)) + return + } + BleLog.i("OTA upgrade start: sn=\(sn) \(fromVersion)→\(targetVersion) (fw=\(firmwareId))", "Firmware") + transferFile(fileUri: fileUrl, commandType: .otaPackage) { [weak self] result in + if case .success = result { + BleLog.i("OTA upgrade completed", "Firmware") + if let self = self, + !sn.isEmpty, + !firmwareId.isEmpty, + !fromVersion.isEmpty, + !targetVersion.isEmpty { + let record = UpgradeRecord( + sn: sn, + firmwareId: firmwareId, + fromVersion: fromVersion, + targetVersion: targetVersion, + triggeredAt: UpgradeRecordStore.nowMillis() + ) + self.upgradeRecordStore.upsert(record) + BleLog.i("UpgradeRecord upserted: sn=\(sn), fw=\(firmwareId), \(fromVersion)→\(targetVersion)", "Firmware") + } + } + completion(result) + } + } + + /// 升级对账上报:把本地 pending `UpgradeRecord` 逐条报给服务端。 + /// + /// `fetchLatestFirmware` 内部第一步会调,集成方一般无需直接调用。**fire-and-forget**—— + /// 没有 completion 回调,结果通过 `sdk.upgradeRecords` 数组的 `reported` / `outcome` 字段反映。 + /// + /// 内部逻辑(对齐 expo `bleStore.tryReportPendingUpgrade`): + /// - 30 天 TTL:超期记录 → `markReported(outcome: nil)`,保留为审计轨迹 + /// - 设备实际版本 == 目标版本 → `report-success` → `outcome: .success` + /// - 不命中 → `report-failure(VERSION_MISMATCH ...)` → `outcome: .versionMismatch` + /// - HTTP 失败 → 保持 `reported=false`,下次再重试 + /// - sn 级 in-flight 互斥锁防止并发重复上报 + /// + /// - Parameters: + /// - sn: 设备序列号 + /// - currentVersion: 设备当前固件版本(与 record.targetVersion 比对) + /// - userId: 当前用户 ID(写入 report 请求体) + public func tryReportPendingUpgrade(sn: String, currentVersion: String, userId: String) { + guard !sn.isEmpty, !currentVersion.isEmpty else { return } + guard upgradeRecordStore.tryAcquire(sn: sn) else { return } + + let pending = upgradeRecordStore.pending(sn: sn) + let now = UpgradeRecordStore.nowMillis() + + let group = DispatchGroup() + for r in pending { + if now - r.triggeredAt > UpgradeRecordStore.ttlMillis { + upgradeRecordStore.markReported(sn: r.sn, firmwareId: r.firmwareId, outcome: nil) + continue + } + group.enter() + if currentVersion == r.targetVersion { + firmwareUpgradeService.reportUpgradeSuccess( + sn: r.sn, + firmwareId: r.firmwareId, + fromVersion: r.fromVersion, + bindUserId: userId + ) { [weak self] result in + if case .success = result { + self?.upgradeRecordStore.markReported(sn: r.sn, firmwareId: r.firmwareId, outcome: .success) + } else if case .failure(let err) = result { + BleLog.w("Report success failed for fw \(r.firmwareId): \(err.localizedDescription); will retry", "Firmware") + } + group.leave() + } + } else { + let reason = "VERSION_MISMATCH expected=\(r.targetVersion) actual=\(currentVersion) sn=\(r.sn) userId=\(userId)" + firmwareUpgradeService.reportUpgradeFailure( + sn: r.sn, + firmwareId: r.firmwareId, + fromVersion: r.fromVersion, + failureReason: reason + ) { [weak self] result in + if case .success = result { + self?.upgradeRecordStore.markReported(sn: r.sn, firmwareId: r.firmwareId, outcome: .versionMismatch) + } else if case .failure(let err) = result { + BleLog.w("Report failure failed for fw \(r.firmwareId): \(err.localizedDescription); will retry", "Firmware") + } + group.leave() + } + } + } + group.notify(queue: .global()) { [weak self] in + self?.upgradeRecordStore.release(sn: sn) + } + } +} diff --git a/Sources/DuooomiBleSDK/DuooomiBleSDK+Transfer.swift b/Sources/DuooomiBleSDK/DuooomiBleSDK+Transfer.swift new file mode 100644 index 0000000..041fceb --- /dev/null +++ b/Sources/DuooomiBleSDK/DuooomiBleSDK+Transfer.swift @@ -0,0 +1,220 @@ +import Foundation + +// MARK: - File Transfer (含 ANI 转换 / mime 校验 / 远程 size 探测) + +extension DuooomiBleSDK { + + static let supportedMediaExtensions: Set = [ + "jpeg", "jpg", "png", "gif", "mp4", "webm", "webp" + ] + + static let supportedMimeTypes: Set = [ + "image/jpeg", "image/png", "image/gif", "image/webp", + "video/mp4", "video/webm" + ] + + /// 把已就绪的文件 URI 直接传输给设备(**底层入口**,不做格式校验/转码/prepareTransfer)。 + /// + /// 一般集成方应使用 `transferMedia(fileUrl:)`,由它自动串好"格式校验 → ANI 转换 → prepareTransfer → transferFile"。 + /// + /// - Parameters: + /// - fileUri: 已就绪文件 URL(HTTPS 远程或 file:// 本地) + /// - commandType: 默认 `.transferAniVideo`;OTA 烧录用 `.otaPackage` + /// - completion: 回调 `Result`。成功时进度先到 100%。 + /// - 失败:`.notConnected` / `.transferFailed(...)` / 各种 IO 错误 + /// - Note: 进度通过 delegate `didUpdateProgress(0...100)` 回调,每 10% 一条 BleLog。 + public func transferFile( + fileUri: String, + commandType: CommandType = .transferAniVideo, + completion: @escaping (Result) -> Void + ) { + guard connectedDevice != nil else { + completion(.failure(DuooomiBleError.notConnected)) + return + } + transferProgress = 0 + BleLog.i("Transfer start: uri=\(fileUri), cmd=\(commandType)", "Transfer") + + fileTransferService.transferFile( + fileUri: fileUri, + commandType: commandType, + onProgress: { [weak self] progress in + DispatchQueue.main.async { + let p = Int(progress * 100) + self?.transferProgress = p + if p % 10 == 0 { + BleLog.d("Progress: \(p)%", "Transfer") + } + } + }, + completion: { [weak self] result in + if case .success = result { + self?.transferProgress = 100 + BleLog.i("Transfer completed", "Transfer") + } + completion(result) + } + ) + } + + /// 一步完成媒体传输:格式校验 → ANI 转换(图片/视频)→ prepareTransfer → transferFile。 + /// + /// 支持的媒体格式:JPEG / PNG / GIF / MP4 / WebM / WebP。先按 URL 扩展名匹配,无扩展名时 + /// 通过 HEAD 请求看 `Content-Type`。`.ani` 后缀直传,跳过转换。 + /// + /// - Parameters: + /// - fileUrl: HTTPS 媒体 URL(任何支持格式);`.ani` 后缀直传 + /// - completion: 回调 `Result` + /// - 失败:`.notConnected` / `.unsupportedFormat(...)` / `.transferFailed("ANI conversion failed: ...")` + /// / `.prepareTransferFailed(status:)` / `.transferFailed("Cannot determine file size: ...")` + /// - Note: 进度通过 delegate `didUpdateProgress(0...100)` 回调。 + public func transferMedia(fileUrl: String, completion: @escaping (Result) -> Void) { + guard connectedDevice != nil else { + completion(.failure(DuooomiBleError.notConnected)) + return + } + let url = fileUrl.trimmingCharacters(in: .whitespacesAndNewlines) + guard !url.isEmpty else { + completion(.failure(DuooomiBleError.transferFailed("Empty file URL"))) + return + } + + let isAni = url.lowercased().hasSuffix(".ani") + + if isAni { + BleLog.i("Direct ANI transfer: \(url)", "Transfer") + transferMediaStep2(aniUrl: url, originalUrl: url, completion: completion) + } else { + validateMediaFormat(url: url) { [weak self] result in + switch result { + case .failure(let error): + completion(.failure(error)) + case .success: + BleLog.i("Converting to ANI: \(url)", "Transfer") + self?.aniConverter.convert(fileUrl: url) { [weak self] result in + switch result { + case .failure(let error): + completion(.failure(DuooomiBleError.transferFailed("ANI conversion failed: \(error.localizedDescription)"))) + case .success(let aniUrl): + BleLog.i("ANI ready: \(aniUrl)", "Transfer") + self?.transferMediaStep2(aniUrl: aniUrl, originalUrl: url, completion: completion) + } + } + } + } + } + } + + private func transferMediaStep2(aniUrl: String, originalUrl: String, completion: @escaping (Result) -> Void) { + getRemoteFileSize(url: aniUrl) { [weak self] result in + switch result { + case .failure(let error): + completion(.failure(error)) + case .success(let aniSize): + BleLog.d("ANI size: \(aniSize) bytes", "Transfer") + self?.prepareTransfer(key: originalUrl, size: aniSize) { [weak self] result in + switch result { + case .failure(let error): + completion(.failure(error)) + case .success: + self?.transferFile(fileUri: aniUrl, commandType: .transferAniVideo, completion: completion) + } + } + } + } + } + + private func extractExtension(from urlString: String) -> String? { + guard let comps = URLComponents(string: urlString) else { return nil } + let path = comps.path + let ext = (path.lowercased() as NSString).pathExtension + guard !ext.isEmpty else { return nil } + return ext + } + + private func validateMediaFormat(url: String, completion: @escaping (Result) -> Void) { + if let ext = extractExtension(from: url) { + if Self.supportedMediaExtensions.contains(ext) { + completion(.success(())) + } else { + completion(.failure(DuooomiBleError.unsupportedFormat(ext))) + } + return + } + + guard let requestUrl = URL(string: url) else { + completion(.failure(DuooomiBleError.transferFailed("Invalid URL: \(url)"))) + return + } + var request = URLRequest(url: requestUrl) + request.httpMethod = "HEAD" + URLSession.shared.dataTask(with: request) { _, response, error in + if let error = error { + completion(.failure(DuooomiBleError.transferFailed("Format check failed: \(error.localizedDescription)"))) + return + } + guard let mime = (response as? HTTPURLResponse)?.allHeaderFields["Content-Type"] as? String else { + completion(.failure(DuooomiBleError.unsupportedFormat("unknown"))) + return + } + let baseMime = mime.components(separatedBy: ";").first?.trimmingCharacters(in: .whitespaces).lowercased() ?? "" + if Self.supportedMimeTypes.contains(baseMime) { + completion(.success(())) + } else { + completion(.failure(DuooomiBleError.unsupportedFormat(baseMime))) + } + }.resume() + } + + private func getRemoteFileSize(url: String, completion: @escaping (Result) -> Void) { + guard let fileUrl = URL(string: url) else { + completion(.failure(DuooomiBleError.transferFailed("Invalid URL: \(url)"))) + return + } + + var request = URLRequest(url: fileUrl) + request.httpMethod = "HEAD" + + URLSession.shared.dataTask(with: request) { [weak self] _, response, error in + if let error = error { + completion(.failure(error)) + return + } + + if let http = response as? HTTPURLResponse { + if let lengthStr = http.allHeaderFields["Content-Length"] as? String, + let length = Int(lengthStr), length > 0 { + completion(.success(length)) + return + } + if http.expectedContentLength > 0 { + completion(.success(Int(http.expectedContentLength))) + return + } + } + + self?.getFileSizeViaRange(fileUrl: fileUrl, completion: completion) + }.resume() + } + + private func getFileSizeViaRange(fileUrl: URL, completion: @escaping (Result) -> Void) { + var rangeReq = URLRequest(url: fileUrl) + rangeReq.httpMethod = "GET" + rangeReq.setValue("bytes=0-0", forHTTPHeaderField: "Range") + + URLSession.shared.dataTask(with: rangeReq) { _, response, error in + if let error = error { + completion(.failure(error)) + return + } + if let http = response as? HTTPURLResponse, + let rangeHeader = http.allHeaderFields["Content-Range"] as? String, + let totalStr = rangeHeader.split(separator: "/").last, + let total = Int(totalStr), total > 0 { + completion(.success(total)) + return + } + completion(.failure(DuooomiBleError.transferFailed("Cannot determine file size: \(fileUrl.absoluteString)"))) + }.resume() + } +} diff --git a/Sources/DuooomiBleSDK/DuooomiBleSDK.swift b/Sources/DuooomiBleSDK/DuooomiBleSDK.swift index ca27c39..dd64e7b 100644 --- a/Sources/DuooomiBleSDK/DuooomiBleSDK.swift +++ b/Sources/DuooomiBleSDK/DuooomiBleSDK.swift @@ -4,51 +4,65 @@ import Foundation /// Duooomi BLE SDK - 纯原生 iOS 蓝牙 SDK /// /// 提供设备扫描、连接、命令交互、文件传输等原子蓝牙操作。 -/// 不做业务编排,调用方自行串联多步流程。 +/// 业务编排(双层绑定 / OTA 检查 / 升级对账上报)由 SDK 主类内部完成,集成方仅与公开 API 交互。 +/// +/// 文件分层(按职责拆分;类即门面,对外 API 形态不变): +/// - `DuooomiBleSDK.swift` 状态属性 / init / 扫描 / RPC helpers / delegate 实现 +/// - `DuooomiBleSDK+Connection.swift` 连接 / 断开 +/// - `DuooomiBleSDK+Commands.swift` 纯协议层 RPC(getDeviceInfo / getVersion / setPlayMode / deleteFile / prepareTransfer) +/// - `DuooomiBleSDK+Binding.swift` 双层绑定编排(硬绑 → getVersion → OTA 检查/对账 → 软绑,失败回滚) +/// - `DuooomiBleSDK+Firmware.swift` OTA 检查 / 安装 / 升级对账上报 +/// - `DuooomiBleSDK+Transfer.swift` 文件传输(mime 校验 / ANI 转换 / 远程 size 探测) +/// +/// 注:`scan()` / `stopScan()` 留在主类不放 extension:name 与 `Combine.Publisher.scan(_:_:)` 冲突, +/// 当 SDK 实例被 `@Published` 包装时 extension 上的 `scan` 会被 Publisher.scan 抢占名字解析。 public final class DuooomiBleSDK: NSObject { // MARK: - Observable State public weak var delegate: DuooomiBleSDKDelegate? - public private(set) var btState: ConnectionState = .idle { + public internal(set) var btState: ConnectionState = .idle { didSet { guard btState != oldValue else { return } notifyMain { self.delegate?.sdk(self, didChangeState: self.btState) } } } - public private(set) var connectedDevice: DiscoveredDevice? = nil { + public internal(set) var connectedDevice: DiscoveredDevice? = nil { didSet { notifyMain { self.delegate?.sdk(self, didConnectDevice: self.connectedDevice) } } } - public private(set) var deviceInfo: DeviceInfo? = nil { + public internal(set) var deviceInfo: DeviceInfo? = nil { didSet { notifyMain { self.delegate?.sdk(self, didUpdateDeviceInfo: self.deviceInfo) } } } - public private(set) var version: String = "" { + public internal(set) var version: String = "" { didSet { notifyMain { self.delegate?.sdk(self, didUpdateVersion: self.version) } } } - public private(set) var isActivated: Bool = false { + public internal(set) var isActivated: Bool = false { didSet { guard isActivated != oldValue else { return } notifyMain { self.delegate?.sdk(self, didChangeActivation: self.isActivated) } } } - public private(set) var transferProgress: Int = 0 { + /// 当前已绑定设备的 SN(绑定成功后写入,解绑后清空) + public internal(set) var sn: String = "" + + public internal(set) var transferProgress: Int = 0 { didSet { guard transferProgress != oldValue else { return } notifyMain { self.delegate?.sdk(self, didUpdateProgress: self.transferProgress) } } } - public private(set) var error: String? = nil { + public internal(set) var error: String? = nil { didSet { notifyMain { self.delegate?.sdk(self, didEncounterError: self.error) } } } - public private(set) var discoveredDevices: [DiscoveredDevice] = [] { + public internal(set) var discoveredDevices: [DiscoveredDevice] = [] { didSet { notifyMain { self.delegate?.sdk(self, didUpdateDevices: self.discoveredDevices) } } } @@ -56,27 +70,36 @@ public final class DuooomiBleSDK: NSObject { public let config: DuooomiBleConfig - // MARK: - Internal Services + // MARK: - Internal Services (extension 文件按职责调用) - private let bleClient: BleClient - private let protocolService: BleProtocolService - private let deviceInfoService: DeviceInfoService - private let fileTransferService: FileTransferService - private let aniConverter: AniConverter - private let firmwareService: FirmwareService + let bleClient: BleClient + let protocolService: BleProtocolService + let deviceInfoService: DeviceInfoService + let fileTransferService: FileTransferService + let aniConverter: AniConverter + let timeProvider: BeijingTimeProvider + let shipmentService: ShipmentSnDeviceService + let firmwareUpgradeService: FirmwareUpgradeService + let upgradeRecordStore: UpgradeRecordStore - // MARK: - Request-Response + /// 服务端最新一次 `fetchLatestFirmware` 结果 + public internal(set) var firmwareUpgrade: FirmwareUpgradeCheckResult? = nil - private var pendingCallbacks: [String: (Result) -> Void] = [:] - private var pendingTimeouts: [String: DispatchWorkItem] = [:] - private let callbackQueue = DispatchQueue(label: "com.duooomi.ble.callback") + /// 当前 OTA 升级记录快照(按 triggeredAt 倒序展示用;持久化于 UserDefaults) + public var upgradeRecords: [UpgradeRecord] { upgradeRecordStore.all } + + // MARK: - Request-Response (extension 通过 sendAndWait / sendAsyncAndWait 共享通道) + + var pendingCallbacks: [String: (Result) -> Void] = [:] + var pendingTimeouts: [String: DispatchWorkItem] = [:] + let callbackQueue = DispatchQueue(label: "com.duooomi.ble.callback") // MARK: - Scan Batching - private var scanDelegate: ScanBleClientDelegate? - private var allDiscoveredDevices: [DiscoveredDevice] = [] - private var pendingDevices: [DiscoveredDevice] = [] - private var flushWorkItem: DispatchWorkItem? + var scanDelegate: ScanBleClientDelegate? + var allDiscoveredDevices: [DiscoveredDevice] = [] + var pendingDevices: [DiscoveredDevice] = [] + var flushWorkItem: DispatchWorkItem? // MARK: - Init @@ -84,19 +107,67 @@ public final class DuooomiBleSDK: NSObject { self.config = config bleClient = BleClient() protocolService = BleProtocolService(client: bleClient) - deviceInfoService = DeviceInfoService(protocolService: protocolService) + timeProvider = BeijingTimeProvider() + deviceInfoService = DeviceInfoService(protocolService: protocolService, timeProvider: timeProvider) fileTransferService = FileTransferService(protocolService: protocolService) aniConverter = AniConverter(config: config) - firmwareService = FirmwareService(config: config) + shipmentService = ShipmentSnDeviceService(config: config) + firmwareUpgradeService = FirmwareUpgradeService(config: config) + upgradeRecordStore = UpgradeRecordStore() super.init() protocolService.delegate = self - BleLog.i("SDK initialized (apiHost=\(config.apiHost), firmware=\(config.firmwareIdentifier)/\(config.firmwareStatus))", "SDK") + BleLog.i("SDK initialized (apiHost=\(config.apiHost), owner=\(config.owner))", "SDK") setupDisconnectHandler() + + // UpgradeRecord 变化(含对账上报结果)→ delegate 通知 + upgradeRecordStore.onChange = { [weak self] records in + guard let self = self else { return } + self.delegate?.sdk(self, didUpdateUpgradeRecords: records) + } } - private func setupDisconnectHandler() { + // MARK: - Scanning + + /// 开始扫描 BLE 设备。结果 throttle 500ms 后通过 delegate `didUpdateDevices` 回调, + /// 数据同步写入 `sdk.discoveredDevices`。`btState` 切到 `.scanning`。 + /// + /// 已经在扫描则会先 stopScan 再重启。已连接设备时 stopScan 后 btState 回 `.connected`,否则回 `.idle`。 + /// 扫描结果按 `config.scanNamePrefix`(如 "Duooomi-")做名字前缀过滤(必填)。 + public func scan() { + stopScan() + btState = .scanning + discoveredDevices = [] + allDiscoveredDevices = [] + pendingDevices = [] + + BleLog.i("Start scanning...", "Scan") + let proxy = ScanBleClientDelegate(sdk: self, original: protocolService) + scanDelegate = proxy + bleClient.delegate = proxy + bleClient.scan() + } + + /// 停止 BLE 扫描,flush 待回调的设备列表,恢复 `bleClient.delegate` 给协议服务。 + /// 已连接设备时 `btState` 回 `.connected`,否则回 `.idle`。 + public func stopScan() { + bleClient.stopScan() + flushDevices() + scanDelegate = nil + bleClient.delegate = protocolService + + if connectedDevice != nil { + btState = .connected + } else if btState == .scanning { + btState = .idle + } + BleLog.i("Stop scanning (state=\(btState.rawValue))", "Scan") + } + + // MARK: - Disconnect + + func setupDisconnectHandler() { bleClient.onDisconnected = { [weak self] in guard let self = self else { return } BleLog.w("Peripheral disconnected; resetting state", "SDK") @@ -110,565 +181,9 @@ public final class DuooomiBleSDK: NSObject { } } - // MARK: - Scanning - - /// 开始扫描设备。 - public func scan() { - stopScan() - btState = .scanning - discoveredDevices = [] - allDiscoveredDevices = [] - pendingDevices = [] - - BleLog.i("Start scanning...", "Scan") - // Create scan proxy to forward discovered devices to SDK while - // keeping protocol service as the data receiver - let proxy = ScanBleClientDelegate(sdk: self, original: protocolService) - scanDelegate = proxy - bleClient.delegate = proxy - bleClient.scan() - } - - /// 停止扫描设备。 - public func stopScan() { - bleClient.stopScan() - flushDevices() - // Restore protocol service as delegate - scanDelegate = nil - bleClient.delegate = protocolService - - if connectedDevice != nil { - btState = .connected - } else if btState == .scanning { - btState = .idle - } - BleLog.i("Stop scanning (state=\(btState.rawValue))", "Scan") - } - - // MARK: - Connection - - /// 连接到指定设备。 - public func connect(deviceId: String, completion: @escaping (Result) -> Void) { - stopScan() - btState = .connecting - BleLog.i("Connecting to \(deviceId)...", "Connect") - - bleClient.connect(deviceId: deviceId) { [weak self] result in - guard let self = self else { return } - switch result { - case .success(let peripheral): - self.protocolService.startListening() - - let device = DiscoveredDevice( - id: peripheral.identifier.uuidString, - name: peripheral.name, - rssi: 0 - ) - self.connectedDevice = device - self.btState = .connected - BleLog.i("Connected: \(device.id)", "Connect") - completion(.success(device)) - - case .failure(let error): - self.btState = .idle - self.error = error.localizedDescription - BleLog.e("Connect failed: \(error.localizedDescription)", "Connect") - completion(.failure(error)) - } - } - } - - /// 断开当前连接。 - public func disconnect(completion: @escaping (Result) -> Void) { - btState = .disconnecting - BleLog.i("Disconnect requested", "Connect") - protocolService.stopListening() - cancelAllPending(error: DuooomiBleError.notConnected) - - bleClient.disconnect { [weak self] result in - guard let self = self else { return } - if case .failure(let error) = result { - self.error = error.localizedDescription - BleLog.e("Disconnect error: \(error.localizedDescription)", "Connect") - } - - self.connectedDevice = nil - self.deviceInfo = nil - self.version = "" - self.isActivated = false - self.btState = .disconnected - BleLog.i("Disconnected", "Connect") - completion(result) - } - } - - /// 返回系统已连接的设备 - public func getConnectedDevices() -> [DiscoveredDevice] { - let list = bleClient.retrieveConnectedDevices() - BleLog.d("System-connected devices: \(list.count)", "Connect") - return list - } - - // MARK: - Device Commands - - /// 读取设备信息 - public func getDeviceInfo(completion: @escaping (Result) -> Void) { - guard ensureConnected(completion: completion) else { return } - BleLog.d("Sending getDeviceInfo", "Command") - - sendAndWait(commandType: .getDeviceInfo, completion: { [weak self] result in - guard let self = self else { return } - switch result { - case .failure(let error): - completion(.failure(error)) - case .success(let data): - do { - let info = try self.decodeResponse(DeviceInfo.self, from: data) - BleLog.i("DeviceInfo received: name=\(info.name), brand=\(info.brand)", "Command") - self.deviceInfo = info - completion(.success(info)) - } catch { - completion(.failure(error)) - } - } - }, send: { - try self.deviceInfoService.getDeviceInfo() - }) - } - - /// 读取设备固件版本 - public func getVersion(completion: @escaping (Result) -> Void) { - guard ensureConnected(completion: completion) else { return } - BleLog.d("Sending getDeviceVersion", "Command") - - sendAndWait(commandType: .getDeviceVersion, completion: { [weak self] result in - guard let self = self else { return } - switch result { - case .failure(let error): - completion(.failure(error)) - case .success(let data): - do { - let info = try self.decodeResponse(VersionInfo.self, from: data) - self.version = info.version - BleLog.i("Version received: \(info.version) (type=\(info.type))", "Command") - completion(.success(info)) - } catch { - completion(.failure(error)) - } - } - }, send: { - try self.deviceInfoService.getDeviceVersion() - }) - } - - /// 绑定设备到用户 - public func bind(userId: String, completion: @escaping (Result) -> Void) { - guard ensureConnected(completion: completion) else { return } - BleLog.d("Sending bind (userId=\(userId))", "Command") - - sendAndWait(commandType: .bindDevice, completion: { [weak self] result in - guard let self = self else { return } - switch result { - case .failure(let error): - completion(.failure(error)) - case .success(let data): - do { - let resp = try self.decodeResponse(BindingResponse.self, from: data) - self.isActivated = resp.success == 1 - if resp.success != 1 { - BleLog.w("Bind failed: device already bound", "Command") - completion(.failure(DuooomiBleError.bindingFailed("Device already bound to another user"))) - } else { - BleLog.i("Bind success: sn=\(resp.sn)", "Command") - completion(.success(resp)) - } - } catch { - completion(.failure(error)) - } - } - }, send: { - try self.deviceInfoService.bindDevice(userId: userId) - }) - } - - /// 切换播放模式(0=单播 / 1=循环播放)。 - /// - /// 复用 `BIND_DEVICE` (0x0F) 命令通道;响应类型为 `BindingResponse`。 - /// 成功后自动刷新一次 `deviceInfo`(含最新 `loop` 字段),与 expo 端行为对齐。 - /// - /// - Important: 此方法要求 **设备已和该 `userId` 完成 `bind`**(即 `isActivated == true`)。 - /// 未绑定状态下设备会返回 `success=0`;SDK 在客户端先做守卫,直接返回失败而不发命令。 - /// - Note: `bind` / `unbind` / `setPlayMode` 共享 `0x0F` / `0x12` opId 通道,**不能并发**。 - public func setPlayMode( - _ mode: PlayMode, - userId: String, - completion: @escaping (Result) -> Void - ) { - guard ensureConnected(completion: completion) else { return } - guard isActivated else { - BleLog.w("setPlayMode called before bind (isActivated=false)", "Command") - DispatchQueue.main.async { - completion(.failure(DuooomiBleError.bindingFailed("Device not bound; call bind(userId:) first"))) - } - return - } - BleLog.d("Sending setPlayMode (mode=\(mode), userId=\(userId))", "Command") - - sendAndWait(commandType: .bindDevice, completion: { [weak self] result in - guard let self = self else { return } - switch result { - case .failure(let error): - completion(.failure(error)) - case .success(let data): - do { - let resp = try self.decodeResponse(BindingResponse.self, from: data) - if resp.success != 1 { - BleLog.w("setPlayMode failed: device rejected", "Command") - completion(.failure(DuooomiBleError.bindingFailed("Set play mode failed"))) - return - } - BleLog.i("setPlayMode success: mode=\(mode)", "Command") - // 自动刷新 deviceInfo(含最新 loop 字段) - self.getDeviceInfo { _ in - completion(.success(resp)) - } - } catch { - completion(.failure(error)) - } - } - }, send: { - try self.deviceInfoService.setPlayMode(userId: userId, loop: mode) - }) - } - - /// 解除设备绑定 - public func unbind(userId: String, completion: @escaping (Result) -> Void) { - guard ensureConnected(completion: completion) else { return } - BleLog.d("Sending unbind (userId=\(userId))", "Command") - - sendAndWait(commandType: .unbindDevice, completion: { [weak self] result in - guard let self = self else { return } - switch result { - case .failure(let error): - completion(.failure(error)) - case .success(let data): - do { - let resp = try self.decodeResponse(UnbindResponse.self, from: data) - if resp.success == 1 { - self.isActivated = false - BleLog.i("Unbind success", "Command") - completion(.success(resp)) - } else { - BleLog.w("Unbind failed", "Command") - completion(.failure(DuooomiBleError.unbindFailed)) - } - } catch { - completion(.failure(error)) - } - } - }, send: { - try self.deviceInfoService.unbindDevice(userId: userId) - }) - } - - /// 删除设备文件 - public func deleteFile(key: String, completion: @escaping (Result) -> Void) { - guard ensureConnected(completion: completion) else { return } - BleLog.d("Sending deleteFile (key=\(key))", "Command") - - sendAndWait(commandType: .deleteFile, completion: { [weak self] result in - guard let self = self else { return } - switch result { - case .failure(let error): - completion(.failure(error)) - case .success(let data): - do { - let resp = try self.decodeResponse(DeleteFileResponse.self, from: data) - if resp.success != 0 { - BleLog.w("Delete failed with status=\(resp.success)", "Command") - completion(.failure(DuooomiBleError.deleteFileFailed(status: resp.success))) - } else { - BleLog.i("Delete success (key=\(key))", "Command") - completion(.success(resp)) - } - } catch { - completion(.failure(error)) - } - } - }, send: { - try self.deviceInfoService.deleteFile(key: key) - }) - } - - /// 传输前准备校验 - public func prepareTransfer(key: String, size: Int, completion: @escaping (Result) -> Void) { - guard ensureConnected(completion: completion) else { return } - let opId = "\(CommandType.prepareTransfer.rawValue)_\(key)" - BleLog.d("Sending prepareTransfer (key=\(key), size=\(size))", "Transfer") - - sendAndWait(opId: opId, commandType: .prepareTransfer, completion: { [weak self] result in - guard let self = self else { return } - switch result { - case .failure(let error): - completion(.failure(error)) - case .success(let data): - do { - let resp = try self.decodeResponse(PrepareTransferResponse.self, from: data) - if resp.status != "ready" { - BleLog.w("PrepareTransfer not ready: status=\(resp.status)", "Transfer") - completion(.failure(DuooomiBleError.prepareTransferFailed(status: resp.status))) - } else { - BleLog.i("PrepareTransfer ready (key=\(key))", "Transfer") - completion(.success(resp)) - } - } catch { - completion(.failure(error)) - } - } - }, send: { - try self.deviceInfoService.prepareTransfer(key: key, size: size) - }) - } - - // MARK: - File Transfer - - /// 传输文件到设备 - public func transferFile( - fileUri: String, - commandType: CommandType = .transferAniVideo, - completion: @escaping (Result) -> Void - ) { - guard connectedDevice != nil else { - completion(.failure(DuooomiBleError.notConnected)) - return - } - transferProgress = 0 - BleLog.i("Transfer start: uri=\(fileUri), cmd=\(commandType)", "Transfer") - - fileTransferService.transferFile( - fileUri: fileUri, - commandType: commandType, - onProgress: { [weak self] progress in - DispatchQueue.main.async { - let p = Int(progress * 100) - self?.transferProgress = p - if p % 10 == 0 { - BleLog.d("Progress: \(p)%", "Transfer") - } - } - }, - completion: { [weak self] result in - if case .success = result { - self?.transferProgress = 100 - BleLog.i("Transfer completed", "Transfer") - } - completion(result) - } - ) - } - - // MARK: - High-Level APIs - - private static let supportedMediaExtensions: Set = [ - "jpeg", "jpg", "png", "gif", "mp4", "webm", "webp" - ] - - private static let supportedMimeTypes: Set = [ - "image/jpeg", "image/png", "image/gif", "image/webp", - "video/mp4", "video/webm" - ] - - private func extractExtension(from urlString: String) -> String? { - guard let comps = URLComponents(string: urlString) else { return nil } - let path = comps.path - let ext = (path.lowercased() as NSString).pathExtension - guard !ext.isEmpty else { return nil } - return ext - } - - private func validateMediaFormat(url: String, completion: @escaping (Result) -> Void) { - if let ext = extractExtension(from: url) { - if DuooomiBleSDK.supportedMediaExtensions.contains(ext) { - completion(.success(())) - } else { - completion(.failure(DuooomiBleError.unsupportedFormat(ext))) - } - return - } - - guard let requestUrl = URL(string: url) else { - completion(.failure(DuooomiBleError.transferFailed("Invalid URL: \(url)"))) - return - } - var request = URLRequest(url: requestUrl) - request.httpMethod = "HEAD" - URLSession.shared.dataTask(with: request) { _, response, error in - if let error = error { - completion(.failure(DuooomiBleError.transferFailed("Format check failed: \(error.localizedDescription)"))) - return - } - guard let mime = (response as? HTTPURLResponse)?.allHeaderFields["Content-Type"] as? String else { - completion(.failure(DuooomiBleError.unsupportedFormat("unknown"))) - return - } - let baseMime = mime.components(separatedBy: ";").first?.trimmingCharacters(in: .whitespaces).lowercased() ?? "" - if DuooomiBleSDK.supportedMimeTypes.contains(baseMime) { - completion(.success(())) - } else { - completion(.failure(DuooomiBleError.unsupportedFormat(baseMime))) - } - }.resume() - } - - /// 传输媒体文件到设备(一步完成) - public func transferMedia(fileUrl: String, completion: @escaping (Result) -> Void) { - guard connectedDevice != nil else { - completion(.failure(DuooomiBleError.notConnected)) - return - } - let url = fileUrl.trimmingCharacters(in: .whitespacesAndNewlines) - guard !url.isEmpty else { - completion(.failure(DuooomiBleError.transferFailed("Empty file URL"))) - return - } - - let isAni = url.lowercased().hasSuffix(".ani") - - if isAni { - BleLog.i("Direct ANI transfer: \(url)", "Transfer") - transferMediaStep2(aniUrl: url, originalUrl: url, completion: completion) - } else { - validateMediaFormat(url: url) { [weak self] result in - switch result { - case .failure(let error): - completion(.failure(error)) - case .success: - BleLog.i("Converting to ANI: \(url)", "Transfer") - self?.aniConverter.convert(fileUrl: url) { [weak self] result in - switch result { - case .failure(let error): - completion(.failure(DuooomiBleError.transferFailed("ANI conversion failed: \(error.localizedDescription)"))) - case .success(let aniUrl): - BleLog.i("ANI ready: \(aniUrl)", "Transfer") - self?.transferMediaStep2(aniUrl: aniUrl, originalUrl: url, completion: completion) - } - } - } - } - } - } - - private func transferMediaStep2(aniUrl: String, originalUrl: String, completion: @escaping (Result) -> Void) { - getRemoteFileSize(url: aniUrl) { [weak self] result in - switch result { - case .failure(let error): - completion(.failure(error)) - case .success(let aniSize): - BleLog.d("ANI size: \(aniSize) bytes", "Transfer") - self?.prepareTransfer(key: originalUrl, size: aniSize) { [weak self] result in - switch result { - case .failure(let error): - completion(.failure(error)) - case .success: - self?.transferFile(fileUri: aniUrl, commandType: .transferAniVideo, completion: completion) - } - } - } - } - } - - /// 获取最新固件信息 - public func fetchLatestFirmware( - identifier: String? = nil, - status: String? = nil, - completion: @escaping (Result) -> Void - ) { - firmwareService.fetchLatest(identifier: identifier, status: status, completion: completion) - } - - /// 比较设备版本和服务端版本 - public func hasNewerFirmware(deviceVersion: String?, serverVersion: String?) -> Bool { - guard let device = deviceVersion, let server = serverVersion, - device.count >= 10, server.count >= 10 else { return false } - let deviceTs = device.suffix(10) - let serverTs = server.suffix(10) - guard let d = Int(deviceTs), let s = Int(serverTs) else { return false } - return s > d - } - - /// OTA 固件升级 - public func upgradeFirmware(fileUrl: String, completion: @escaping (Result) -> Void) { - guard connectedDevice != nil else { - completion(.failure(DuooomiBleError.notConnected)) - return - } - BleLog.i("OTA upgrade start: \(fileUrl)", "Firmware") - transferFile(fileUri: fileUrl, commandType: .otaPackage) { result in - if case .success = result { - BleLog.i("OTA upgrade completed", "Firmware") - } - completion(result) - } - } - - // MARK: - Internal Helpers - - private func getRemoteFileSize(url: String, completion: @escaping (Result) -> Void) { - guard let fileUrl = URL(string: url) else { - completion(.failure(DuooomiBleError.transferFailed("Invalid URL: \(url)"))) - return - } - - var request = URLRequest(url: fileUrl) - request.httpMethod = "HEAD" - - URLSession.shared.dataTask(with: request) { [weak self] _, response, error in - if let error = error { - completion(.failure(error)) - return - } - - if let http = response as? HTTPURLResponse { - if let lengthStr = http.allHeaderFields["Content-Length"] as? String, - let length = Int(lengthStr), length > 0 { - completion(.success(length)) - return - } - if http.expectedContentLength > 0 { - completion(.success(Int(http.expectedContentLength))) - return - } - } - - // Fallback: Range request - self?.getFileSizeViaRange(fileUrl: fileUrl, completion: completion) - }.resume() - } - - private func getFileSizeViaRange(fileUrl: URL, completion: @escaping (Result) -> Void) { - var rangeReq = URLRequest(url: fileUrl) - rangeReq.httpMethod = "GET" - rangeReq.setValue("bytes=0-0", forHTTPHeaderField: "Range") - - URLSession.shared.dataTask(with: rangeReq) { _, response, error in - if let error = error { - completion(.failure(error)) - return - } - if let http = response as? HTTPURLResponse, - let rangeHeader = http.allHeaderFields["Content-Range"] as? String, - let totalStr = rangeHeader.split(separator: "/").last, - let total = Int(totalStr), total > 0 { - completion(.success(total)) - return - } - completion(.failure(DuooomiBleError.transferFailed("Cannot determine file size: \(fileUrl.absoluteString)"))) - }.resume() - } - // MARK: - Request-Response Pattern - private func sendAndWait( + func sendAndWait( commandType: CommandType, timeout: TimeInterval = 10, completion: @escaping (Result) -> Void, @@ -678,7 +193,51 @@ public final class DuooomiBleSDK: NSObject { sendAndWait(opId: opId, commandType: commandType, timeout: timeout, completion: completion, send: send) } - private func sendAndWait( + /// 异步 send 版本:用于需要先取 BeijingTime 再发命令的 bind/unbind/setPlayMode。 + /// 整体 timeout 仍为 10s,覆盖时间获取 + BLE 命令发送 + 设备响应。 + func sendAsyncAndWait( + commandType: CommandType, + timeout: TimeInterval = 10, + completion: @escaping (Result) -> Void, + send: @escaping (@escaping (Result) -> Void) -> Void + ) { + let opId = "\(commandType.rawValue)" + BleLog.d("Register opId=\(opId), cmd=\(commandType) (async send)", "RPC") + + callbackQueue.sync { + self.pendingCallbacks[opId] = { result in + DispatchQueue.main.async { completion(result) } + } + } + + let timeoutWork = DispatchWorkItem { [weak self] in + guard let self = self else { return } + self.callbackQueue.sync { + if let cb = self.pendingCallbacks.removeValue(forKey: opId) { + self.pendingTimeouts.removeValue(forKey: opId) + cb(.failure(DuooomiBleError.timeout(command: commandType))) + } + } + } + callbackQueue.sync { pendingTimeouts[opId] = timeoutWork } + DispatchQueue.global().asyncAfter(deadline: .now() + timeout, execute: timeoutWork) + + send { [weak self] result in + guard let self = self else { return } + if case .failure(let error) = result { + self.callbackQueue.sync { + self.pendingCallbacks.removeValue(forKey: opId) + self.pendingTimeouts.removeValue(forKey: opId)?.cancel() + } + BleLog.e("Async send failed for opId=\(opId): \(error.localizedDescription)", "RPC") + DispatchQueue.main.async { completion(.failure(error)) } + } else { + BleLog.d("Async command sent for opId=\(opId)", "RPC") + } + } + } + + func sendAndWait( opId: String, commandType: CommandType, timeout: TimeInterval = 10, @@ -687,7 +246,6 @@ public final class DuooomiBleSDK: NSObject { ) { BleLog.d("Register opId=\(opId), cmd=\(commandType)", "RPC") - // Register callback BEFORE sending callbackQueue.sync { self.pendingCallbacks[opId] = { result in DispatchQueue.main.async { @@ -696,7 +254,6 @@ public final class DuooomiBleSDK: NSObject { } } - // Setup timeout let timeoutWork = DispatchWorkItem { [weak self] in guard let self = self else { return } self.callbackQueue.sync { @@ -711,7 +268,6 @@ public final class DuooomiBleSDK: NSObject { } DispatchQueue.global().asyncAfter(deadline: .now() + timeout, execute: timeoutWork) - // Send command do { try send() BleLog.d("Command sent for opId=\(opId)", "RPC") @@ -729,7 +285,7 @@ public final class DuooomiBleSDK: NSObject { // MARK: - Helpers - private func ensureConnected(completion: @escaping (Result) -> Void) -> Bool { + func ensureConnected(completion: @escaping (Result) -> Void) -> Bool { guard connectedDevice != nil else { BleLog.w("Operation requires connection", "SDK") completion(.failure(DuooomiBleError.notConnected)) @@ -738,7 +294,7 @@ public final class DuooomiBleSDK: NSObject { return true } - private func decodeResponse(_ type: T.Type, from data: Data) throws -> T { + func decodeResponse(_ type: T.Type, from data: Data) throws -> T { let cleaned = data.filter { $0 != 0 } do { return try JSONDecoder().decode(type, from: cleaned) @@ -749,7 +305,7 @@ public final class DuooomiBleSDK: NSObject { } } - private func cancelAllPending(error: Error) { + func cancelAllPending(error: Error) { callbackQueue.sync { let callbacks = self.pendingCallbacks self.pendingCallbacks.removeAll() @@ -763,7 +319,7 @@ public final class DuooomiBleSDK: NSObject { } } - private func notifyMain(_ block: @escaping () -> Void) { + func notifyMain(_ block: @escaping () -> Void) { if Thread.isMainThread { block() } else { @@ -773,9 +329,14 @@ public final class DuooomiBleSDK: NSObject { // MARK: - Scan Batching (500ms throttle) - fileprivate func queueDevice(_ device: DiscoveredDevice) { + func queueDevice(_ device: DiscoveredDevice) { guard !allDiscoveredDevices.contains(where: { $0.id == device.id }) else { return } + let name = device.name?.lowercased() ?? "" + if !name.hasPrefix(config.scanNamePrefix.lowercased()) { + return + } + allDiscoveredDevices.append(device) pendingDevices.append(device) @@ -787,7 +348,7 @@ public final class DuooomiBleSDK: NSObject { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: workItem) } - private func flushDevices() { + func flushDevices() { guard !pendingDevices.isEmpty else { return } pendingDevices = [] discoveredDevices = allDiscoveredDevices @@ -804,7 +365,6 @@ extension DuooomiBleSDK: BleProtocolServiceDelegate { 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)" @@ -839,7 +399,7 @@ extension DuooomiBleSDK: BleProtocolServiceDelegate { // MARK: - Scan Proxy /// Forwards scan results to SDK while keeping protocol service as data receiver. -private class ScanBleClientDelegate: BleClientDelegate { +final class ScanBleClientDelegate: BleClientDelegate { private weak var sdk: DuooomiBleSDK? private let original: BleClientDelegate? diff --git a/Sources/DuooomiBleSDK/DuooomiBleSDKDelegate.swift b/Sources/DuooomiBleSDK/DuooomiBleSDKDelegate.swift index 9f99111..619098e 100644 --- a/Sources/DuooomiBleSDK/DuooomiBleSDKDelegate.swift +++ b/Sources/DuooomiBleSDK/DuooomiBleSDKDelegate.swift @@ -19,6 +19,13 @@ public protocol DuooomiBleSDKDelegate: AnyObject { func sdk(_ sdk: DuooomiBleSDK, didUpdateProgress progress: Int) /// 错误发生 func sdk(_ sdk: DuooomiBleSDK, didEncounterError error: String?) + /// 升级记录变化(任何 `UpgradeRecord` 的新增 / 状态更新都会触发)。 + /// 用来观察 `tryReportPendingUpgrade` 的对账上报结果: + /// - `record.reported = true, outcome = .success` → 上报成功且版本命中 + /// - `record.reported = true, outcome = .versionMismatch` → 上报成功但版本不匹配 + /// - `record.reported = true, outcome = nil` → 30 天 TTL 过期未上报 + /// - `record.reported = false` → 还在尝试 / HTTP 失败,下次再试 + func sdk(_ sdk: DuooomiBleSDK, didUpdateUpgradeRecords records: [UpgradeRecord]) } /// 所有 delegate 方法提供默认空实现,调用方只需实现关心的方法。 @@ -31,4 +38,5 @@ public extension DuooomiBleSDKDelegate { func sdk(_ sdk: DuooomiBleSDK, didChangeActivation isActivated: Bool) {} func sdk(_ sdk: DuooomiBleSDK, didUpdateProgress progress: Int) {} func sdk(_ sdk: DuooomiBleSDK, didEncounterError error: String?) {} + func sdk(_ sdk: DuooomiBleSDK, didUpdateUpgradeRecords records: [UpgradeRecord]) {} } diff --git a/Sources/DuooomiBleSDK/Models/UpgradeRecord.swift b/Sources/DuooomiBleSDK/Models/UpgradeRecord.swift new file mode 100644 index 0000000..8c293df --- /dev/null +++ b/Sources/DuooomiBleSDK/Models/UpgradeRecord.swift @@ -0,0 +1,44 @@ +import Foundation + +/// 升级对账结果。 +/// - `success`: 设备实际版本 == 目标版本,已成功上报 +/// - `versionMismatch`: 实际版本 ≠ 目标版本,已上报 failure +/// - `nil`: 30 天 TTL 过期未上报,保留为审计轨迹 +public enum UpgradeOutcome: String, Codable, Equatable { + case success + case versionMismatch = "version_mismatch" +} + +/// OTA 升级记录。复合主键 `(sn, firmwareId)`。 +/// 与 RN `bleStore.upgradeRecords` 1:1 对齐。 +public struct UpgradeRecord: Codable, Equatable { + public let sn: String + public let firmwareId: String + public let fromVersion: String + public let targetVersion: String + /// 触发 OTA 烧录的时间戳(毫秒,与 RN `Date.now()` 同口径) + public let triggeredAt: Int64 + public let reported: Bool + public let reportedAt: Int64? + public let outcome: UpgradeOutcome? + + public init( + sn: String, + firmwareId: String, + fromVersion: String, + targetVersion: String, + triggeredAt: Int64, + reported: Bool = false, + reportedAt: Int64? = nil, + outcome: UpgradeOutcome? = nil + ) { + self.sn = sn + self.firmwareId = firmwareId + self.fromVersion = fromVersion + self.targetVersion = targetVersion + self.triggeredAt = triggeredAt + self.reported = reported + self.reportedAt = reportedAt + self.outcome = outcome + } +} diff --git a/Sources/DuooomiBleSDK/Services/DeviceInfoService.swift b/Sources/DuooomiBleSDK/Services/DeviceInfoService.swift index 4666d7d..986cc0d 100644 --- a/Sources/DuooomiBleSDK/Services/DeviceInfoService.swift +++ b/Sources/DuooomiBleSDK/Services/DeviceInfoService.swift @@ -3,9 +3,11 @@ import Foundation /// 设备命令服务:发送 JSON 命令(fire-and-forget,响应由协议层回调处理) final class DeviceInfoService { private let protocolService: BleProtocolService + private let timeProvider: BeijingTimeProvider - init(protocolService: BleProtocolService) { + init(protocolService: BleProtocolService, timeProvider: BeijingTimeProvider) { self.protocolService = protocolService + self.timeProvider = timeProvider } // MARK: - Commands @@ -24,30 +26,66 @@ final class DeviceInfoService { ) } - func bindDevice(userId: String) throws { - try protocolService.sendJSON( - type: .bindDevice, - payload: BindPayload(type: CommandType.bindDevice.rawValue, userId: userId, loop: nil) - ) + /// 绑定:先取北京时间再发命令,与 RN 行为对齐。 + func bindDevice(userId: String, completion: @escaping (Result) -> Void) { + timeProvider.fetch { [weak self] time in + guard let self = self else { return } + do { + try self.protocolService.sendJSON( + type: .bindDevice, + payload: BindPayload( + type: CommandType.bindDevice.rawValue, + userId: userId, + loop: nil, + time: time + ) + ) + completion(.success(())) + } catch { + completion(.failure(error)) + } + } } - func unbindDevice(userId: String) throws { - try protocolService.sendJSON( - type: .unbindDevice, - payload: BindPayload(type: CommandType.unbindDevice.rawValue, userId: userId, loop: nil) - ) + func unbindDevice(userId: String, completion: @escaping (Result) -> Void) { + timeProvider.fetch { [weak self] time in + guard let self = self else { return } + do { + try self.protocolService.sendJSON( + type: .unbindDevice, + payload: BindPayload( + type: CommandType.unbindDevice.rawValue, + userId: userId, + loop: nil, + time: time + ) + ) + completion(.success(())) + } catch { + completion(.failure(error)) + } + } } /// 切换播放模式(复用 BIND_DEVICE 命令字 0x0F,payload 携带 loop 字段) - func setPlayMode(userId: String, loop: PlayMode) throws { - try protocolService.sendJSON( - type: .bindDevice, - payload: BindPayload( - type: CommandType.bindDevice.rawValue, - userId: userId, - loop: UInt8(loop.rawValue) - ) - ) + func setPlayMode(userId: String, loop: PlayMode, completion: @escaping (Result) -> Void) { + timeProvider.fetch { [weak self] time in + guard let self = self else { return } + do { + try self.protocolService.sendJSON( + type: .bindDevice, + payload: BindPayload( + type: CommandType.bindDevice.rawValue, + userId: userId, + loop: UInt8(loop.rawValue), + time: time + ) + ) + completion(.success(())) + } catch { + completion(.failure(error)) + } + } } func deleteFile(key: String) throws { @@ -77,19 +115,22 @@ private struct CommandPayload: Encodable { /// 绑定 / 解绑 / 切模式 共用 payload。 /// `loop == nil` 时严格省略该字段(不写 null),与 Expo 端 zod.optional 行为对齐。 +/// `time` 为北京时间 ISO 8601 字符串。 struct BindPayload: Encodable { let type: UInt8 let userId: String let loop: UInt8? + let time: String enum CodingKeys: String, CodingKey { - case type, userId, loop + case type, userId, loop, time } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(type, forKey: .type) try container.encode(userId, forKey: .userId) + try container.encode(time, forKey: .time) if let loop = loop { try container.encode(loop, forKey: .loop) } diff --git a/Sources/DuooomiBleSDK/Services/FirmwareService.swift b/Sources/DuooomiBleSDK/Services/FirmwareService.swift deleted file mode 100644 index 39c6aa6..0000000 --- a/Sources/DuooomiBleSDK/Services/FirmwareService.swift +++ /dev/null @@ -1,85 +0,0 @@ -import Foundation - -/// 固件信息 -public struct FirmwareInfo: Codable, Equatable { - public let version: String - public let fileUrl: String - public let description: String? - public let fileSize: String? - public let fileMd5: String? - public let identifier: String? - public let status: String? -} - -struct FirmwareResponse: Codable { - let success: Bool - let data: FirmwareInfo? -} - -/// 固件服务内部实现 -final class FirmwareService { - private let config: DuooomiBleConfig - private let latestPath = "api/auth/loomart/firmware/latest-published" - - init(config: DuooomiBleConfig) { - self.config = config - } - - func fetchLatest( - identifier: String? = nil, - status: String? = nil, - completion: @escaping (Result) -> Void - ) { - let id = (identifier ?? config.firmwareIdentifier).trimmingCharacters(in: .whitespacesAndNewlines) - let st = status ?? config.firmwareStatus - guard !id.isEmpty else { - completion(.failure(DuooomiBleError.transferFailed("Invalid firmware identifier"))) - return - } - - 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 { - completion(.failure(DuooomiBleError.transferFailed("Invalid firmware URL"))) - return - } - - var req = URLRequest(url: url) - req.httpMethod = "GET" - req.setValue(config.apiKey, forHTTPHeaderField: "x-api-key") - req.setValue("application/json", forHTTPHeaderField: "accept") - - 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 - } - - do { - let decoded = try JSONDecoder().decode(FirmwareResponse.self, from: data) - completion(.success(decoded.data)) - } catch { - completion(.failure(error)) - } - }.resume() - } -} diff --git a/Sources/DuooomiBleSDK/Services/FirmwareUpgradeService.swift b/Sources/DuooomiBleSDK/Services/FirmwareUpgradeService.swift new file mode 100644 index 0000000..76aabfd --- /dev/null +++ b/Sources/DuooomiBleSDK/Services/FirmwareUpgradeService.swift @@ -0,0 +1,171 @@ +import Foundation + +/// 固件升级 RPC:检查升级 / 升级成功上报 / 升级失败上报。 +/// 与 RN `FirmwareController.{checkUpgrade, reportUpgradeSuccessBySn, reportUpgradeFailureBySn}` 1:1 对齐。 +final class FirmwareUpgradeService { + private let config: DuooomiBleConfig + private let session: URLSession + + init(config: DuooomiBleConfig, session: URLSession = .shared) { + self.config = config + self.session = session + } + + /// 由服务端判断是否需要升级。 + func checkUpgrade( + sn: String, + currentVersion: String?, + completion: @escaping (Result) -> Void + ) { + var body: [String: String] = ["sn": sn] + if let v = currentVersion, !v.isEmpty { body["currentVersion"] = v } + post( + path: "/api/auth/loomart/firmware/upgrade/check", + body: body + ) { result in + switch result { + case .failure(let e): completion(.failure(e)) + case .success(let data): + do { + let parsed = try JSONDecoder().decode(FirmwareUpgradeCheckResult.self, from: data) + completion(.success(parsed)) + } catch { + completion(.failure(error)) + } + } + } + } + + func reportUpgradeSuccess( + sn: String, + firmwareId: String, + fromVersion: String, + bindUserId: String?, + completion: @escaping (Result) -> Void + ) { + var body: [String: String] = [ + "sn": sn, + "firmwareId": firmwareId, + "fromVersion": fromVersion, + ] + if let u = bindUserId, !u.isEmpty { body["bindUserId"] = u } + post(path: "/api/auth/loomart/firmware/upgrade/report-success", body: body) { result in + completion(result.map { _ in () }) + } + } + + func reportUpgradeFailure( + sn: String, + firmwareId: String, + fromVersion: String, + failureReason: String, + completion: @escaping (Result) -> Void + ) { + let body = [ + "sn": sn, + "firmwareId": firmwareId, + "fromVersion": fromVersion, + "failureReason": failureReason, + ] + post(path: "/api/auth/loomart/firmware/upgrade/report-failure", body: body) { result in + completion(result.map { _ in () }) + } + } + + // MARK: - HTTP + + private func post( + path: String, + body: [String: String], + completion: @escaping (Result) -> Void + ) { + guard let url = URL(string: config.apiHost.absoluteString + path) else { + completion(.failure(DuooomiBleError.transferFailed("URL 拼装失败"))) + return + } + var req = URLRequest(url: url) + req.httpMethod = "POST" + req.timeoutInterval = 10 + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.setValue("application/json", forHTTPHeaderField: "Accept") + req.setValue(config.apiKey, forHTTPHeaderField: "x-api-key") + req.setValue(config.owner, forHTTPHeaderField: "x-owner") + do { + req.httpBody = try JSONEncoder().encode(body) + } catch { + completion(.failure(error)) + return + } + session.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("无响应"))) + return + } + guard (200...299).contains(http.statusCode) else { + let msg = Self.parseErrorMessage(data: data) ?? "HTTP \(http.statusCode)" + completion(.failure(DuooomiBleError.transferFailed(msg))) + return + } + // 后端响应 `{ success, data }` 包装:unwrap 后透传 data 字段 + if let data = data, let unwrapped = Self.unwrapData(from: data) { + completion(.success(unwrapped)) + } else { + completion(.success(data ?? Data())) + } + }.resume() + } + + private static func unwrapData(from raw: Data) -> Data? { + guard let json = try? JSONSerialization.jsonObject(with: raw) as? [String: Any], + json["success"] as? Bool == true, + let inner = json["data"] else { + return nil + } + return try? JSONSerialization.data(withJSONObject: inner) + } + + private static func parseErrorMessage(data: Data?) -> String? { + guard let data = data, let raw = String(data: data, encoding: .utf8) else { return nil } + var stripped = raw + if stripped.hasPrefix("API error:") { + stripped = String(stripped.dropFirst("API error:".count)).trimmingCharacters(in: .whitespaces) + } + if stripped.hasPrefix("{"), + let json = try? JSONSerialization.jsonObject(with: Data(stripped.utf8)) as? [String: Any] { + if let msg = json["message"] as? String { return msg } + if let err = json["error"] as? [String: Any], let msg = err["message"] as? String { return msg } + } + return stripped.isEmpty ? nil : stripped + } +} + +// MARK: - Response Model + +/// 服务端 `/firmware/upgrade/check` 响应。 +/// **判断能否升级看 `targetFirmware` 是否非空**——SDK 不再暴露 `upgradeAvailable` / `reason` +/// 这种 client 端"判断"语义,回归"拉取最新固件信息"单一职责。 +public struct FirmwareUpgradeCheckResult: Codable, Equatable { + public let currentSystemVersion: String? + public let targetFirmware: TargetFirmware? + + public struct TargetFirmware: Codable, Equatable { + /// 固件 ID。`UpgradeRecord.firmwareId` 来源、对账上报必带。 + public let id: String + /// 固件版本字符串(如 "1.2.3")。UI 展示「最新版本」+ 落 `UpgradeRecord.targetVersion`。 + public let version: String + /// 固件包下载 URL。烧录入参 `upgradeFirmware(fileUrl:)`。 + public let fileUrl: String? + /// 固件包字节数(字符串编码,避免 Int64 溢出)。可用于 UI 显示「需要下载 xx MB」。 + public let fileSize: String? + /// 固件包 MD5。集成方有需要可校验下载内容;SDK 不做校验。 + public let fileMd5: String? + /// 强制升级标记。`true` 表示该版本必须升级(典型场景:安全补丁 / 协议破坏性变更), + /// 集成方应据此弹不可关闭的升级弹窗、阻断设备其他操作;`false` / `nil` 由用户自行决定。 + /// 服务端可能不返回此字段(旧固件版本),用 Optional 做 Codable 兜底。 + public let forceUpgrade: Bool? + } +} diff --git a/Sources/DuooomiBleSDK/Services/ShipmentSnDeviceService.swift b/Sources/DuooomiBleSDK/Services/ShipmentSnDeviceService.swift new file mode 100644 index 0000000..1e5195b --- /dev/null +++ b/Sources/DuooomiBleSDK/Services/ShipmentSnDeviceService.swift @@ -0,0 +1,99 @@ +import Foundation + +/// 服务端 shipment-sn 软绑/软解绑:本地 BLE 硬绑成功后由 SDK 内部调用,集成方无需手动调起。 +final class ShipmentSnDeviceService { + private let config: DuooomiBleConfig + private let session: URLSession + + init(config: DuooomiBleConfig, session: URLSession = .shared) { + self.config = config + self.session = session + } + + func bind( + sn: String, + bindUserId: String, + completion: @escaping (Result) -> Void + ) { + post( + path: "/api/auth/loomart/shipment-sn/device/bind", + body: ["sn": sn, "bindUserId": bindUserId], + completion: completion + ) + } + + func unbind( + sn: String, + bindUserId: String? = nil, + completion: @escaping (Result) -> Void + ) { + var body: [String: String] = ["sn": sn] + if let bindUserId = bindUserId, !bindUserId.isEmpty { + body["bindUserId"] = bindUserId + } + post( + path: "/api/auth/loomart/shipment-sn/device/unbind", + body: body, + completion: completion + ) + } + + private func post( + path: String, + body: [String: String], + completion: @escaping (Result) -> Void + ) { + guard let url = URL(string: config.apiHost.absoluteString + path) else { + completion(.failure(DuooomiBleError.softBindFailed("URL 拼装失败"))) + return + } + + var req = URLRequest(url: url) + req.httpMethod = "POST" + req.timeoutInterval = 10 + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.setValue("application/json", forHTTPHeaderField: "Accept") + req.setValue(config.apiKey, forHTTPHeaderField: "x-api-key") + req.setValue(config.owner, forHTTPHeaderField: "x-owner") + + do { + req.httpBody = try JSONEncoder().encode(body) + } catch { + completion(.failure(error)) + return + } + + session.dataTask(with: req) { data, resp, error in + if let error = error { + completion(.failure(DuooomiBleError.softBindFailed(error.localizedDescription))) + return + } + guard let http = resp as? HTTPURLResponse else { + completion(.failure(DuooomiBleError.softBindFailed("无响应"))) + return + } + guard (200...299).contains(http.statusCode) else { + let message = Self.parseErrorMessage(data: data) ?? "HTTP \(http.statusCode)" + completion(.failure(DuooomiBleError.softBindFailed(message))) + return + } + // 后端响应 `{ success, data }`;2xx 即视为成功,不强制解 data 字段。 + completion(.success(())) + }.resume() + } + + /// 剥离 `API error: ` 前缀,必要时从 JSON 中抽 `message` 字段,与 RN 端 stripApiErrorPrefix 行为对齐。 + private static func parseErrorMessage(data: Data?) -> String? { + guard let data = data, let raw = String(data: data, encoding: .utf8) else { return nil } + var stripped = raw + if stripped.hasPrefix("API error:") { + stripped = String(stripped.dropFirst("API error:".count)).trimmingCharacters(in: .whitespaces) + } + if stripped.hasPrefix("{"), + let json = try? JSONSerialization.jsonObject(with: Data(stripped.utf8)) as? [String: Any] { + if let msg = json["message"] as? String { return msg } + if let err = json["error"] as? [String: Any], let msg = err["message"] as? String { return msg } + } + return stripped.isEmpty ? nil : stripped + } +} diff --git a/Sources/DuooomiBleSDK/Services/UpgradeRecordStore.swift b/Sources/DuooomiBleSDK/Services/UpgradeRecordStore.swift new file mode 100644 index 0000000..2090090 --- /dev/null +++ b/Sources/DuooomiBleSDK/Services/UpgradeRecordStore.swift @@ -0,0 +1,111 @@ +import Foundation + +/// OTA 升级记录持久化存储。与 RN `bleStore.upgradeRecords` 1:1 对齐: +/// - 复合主键 `(sn, firmwareId)` 的 upsert +/// - 30 天 TTL:过期但保留为审计轨迹(标记 `reported=true, outcome=nil`) +/// - sn 级 in-flight 互斥锁,防止并发重复上报 +final class UpgradeRecordStore { + private static let storageKey = "duooomi.firmware_upgrade_records" + /// 30 天毫秒 + static let ttlMillis: Int64 = 30 * 24 * 60 * 60 * 1000 + + private let defaults: UserDefaults + private let queue = DispatchQueue(label: "duooomi.upgradeRecord", attributes: .concurrent) + private var records: [UpgradeRecord] + private var inFlight: Set = [] + + /// mutation 后主线程回调当前快照(SDK 用它通过 delegate 通知集成方)。 + var onChange: (([UpgradeRecord]) -> Void)? + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + if let data = defaults.data(forKey: Self.storageKey), + let decoded = try? JSONDecoder().decode([UpgradeRecord].self, from: data) { + self.records = decoded + } else { + self.records = [] + } + } + + // MARK: - Read + + var all: [UpgradeRecord] { + queue.sync { records } + } + + /// 取该 sn 下所有未上报的记录,按 triggeredAt 升序(旧→新)。 + func pending(sn: String) -> [UpgradeRecord] { + queue.sync { + records.filter { $0.sn == sn && !$0.reported } + .sorted { $0.triggeredAt < $1.triggeredAt } + } + } + + // MARK: - Mutation + + /// 复合主键 `(sn, firmwareId)`:已存在 → 覆盖并复位 reported/outcome;不存在 → 追加。 + func upsert(_ record: UpgradeRecord) { + guard !record.sn.isEmpty, !record.firmwareId.isEmpty else { return } + queue.async(flags: .barrier) { + if let idx = self.records.firstIndex(where: { $0.sn == record.sn && $0.firmwareId == record.firmwareId }) { + self.records[idx] = record + } else { + self.records.append(record) + } + self.persistLocked() + } + } + + /// 标记某条记录已上报(线程安全)。 + func markReported(sn: String, firmwareId: String, outcome: UpgradeOutcome?) { + queue.async(flags: .barrier) { + guard let idx = self.records.firstIndex(where: { $0.sn == sn && $0.firmwareId == firmwareId }) else { return } + let original = self.records[idx] + self.records[idx] = UpgradeRecord( + sn: original.sn, + firmwareId: original.firmwareId, + fromVersion: original.fromVersion, + targetVersion: original.targetVersion, + triggeredAt: original.triggeredAt, + reported: true, + reportedAt: Self.nowMillis(), + outcome: outcome + ) + self.persistLocked() + } + } + + // MARK: - In-flight Mutex (sn 级) + + /// 尝试占用 sn 互斥锁。返回 false 表示已有进行中的上报,调用方应直接 return。 + func tryAcquire(sn: String) -> Bool { + queue.sync(flags: .barrier) { () -> Bool in + if inFlight.contains(sn) { return false } + inFlight.insert(sn) + return true + } + } + + func release(sn: String) { + queue.async(flags: .barrier) { + self.inFlight.remove(sn) + } + } + + // MARK: - Helpers + + static func nowMillis() -> Int64 { + Int64(Date().timeIntervalSince1970 * 1000) + } + + /// 持久化 + 主线程回调 onChange。**必须在 barrier 内调用**。 + private func persistLocked() { + if let data = try? JSONEncoder().encode(records) { + defaults.set(data, forKey: Self.storageKey) + } + if let onChange = onChange { + let snapshot = records + DispatchQueue.main.async { onChange(snapshot) } + } + } +} diff --git a/Sources/DuooomiBleSDK/Utils/BeijingTimeProvider.swift b/Sources/DuooomiBleSDK/Utils/BeijingTimeProvider.swift new file mode 100644 index 0000000..6232331 --- /dev/null +++ b/Sources/DuooomiBleSDK/Utils/BeijingTimeProvider.swift @@ -0,0 +1,127 @@ +import Foundation + +/// 提供 ISO 8601 格式的北京时间字符串(如 `2026-05-07T16:58:00+08:00`),用于 bind / setPlayMode payload 的 `time` 字段。 +/// +/// 三级 fallback: +/// 1. 苏宁公开时间 API 的 body(`sysTime2` 或 `sysTime1`) +/// 2. 苏宁 response.headers["Date"](HTTP RFC 1123) +/// 3. 本地 `Date()` + 8h +/// +/// 5 秒内单飞复用,避免 bind/setPlayMode 连发时重复请求。 +final class BeijingTimeProvider { + private static let suningURL = URL(string: "https://quan.suning.com/getSysTime.do")! + private static let cacheTTL: TimeInterval = 5 + private static let fetchTimeout: TimeInterval = 3 + private static let beijingOffsetSeconds: TimeInterval = 8 * 3600 + + private let session: URLSession + private let queue = DispatchQueue(label: "duooomi.ble.beijingTime") + private var cached: (value: String, at: Date)? + private var inflight: [(String) -> Void] = [] + private var fetching = false + + init(session: URLSession = .shared) { + self.session = session + } + + /// 异步获取北京时间字符串。回调一定在主队列触发。 + func fetch(completion: @escaping (String) -> Void) { + queue.async { [weak self] in + guard let self = self else { return } + + if let cached = self.cached, Date().timeIntervalSince(cached.at) < Self.cacheTTL { + self.dispatch(cached.value, to: completion) + return + } + + self.inflight.append(completion) + if self.fetching { return } + self.fetching = true + + self.performFetch { value in + self.queue.async { + self.cached = (value, Date()) + let pending = self.inflight + self.inflight = [] + self.fetching = false + pending.forEach { self.dispatch(value, to: $0) } + } + } + } + } + + private func dispatch(_ value: String, to completion: @escaping (String) -> Void) { + DispatchQueue.main.async { completion(value) } + } + + private func performFetch(_ done: @escaping (String) -> Void) { + var req = URLRequest(url: Self.suningURL) + req.timeoutInterval = Self.fetchTimeout + req.httpMethod = "GET" + + session.dataTask(with: req) { [weak self] data, resp, _ in + guard let self = self else { return } + + if let data = data, let body = try? JSONDecoder().decode(SuningTimeBody.self, from: data), + let value = self.formatSuningBody(body) { + done(value) + return + } + + if let http = resp as? HTTPURLResponse, + let dateHeader = (http.allHeaderFields["Date"] ?? http.allHeaderFields["date"]) as? String, + let value = self.formatHTTPDateHeader(dateHeader) { + done(value) + return + } + + done(self.formatLocal()) + }.resume() + } + + private func formatSuningBody(_ body: SuningTimeBody) -> String? { + if let s = body.sysTime2, + s.range(of: #"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$"#, options: .regularExpression) != nil { + return s.replacingOccurrences(of: " ", with: "T") + "+08:00" + } + if let s = body.sysTime1, s.count == 14, Int(s) != nil { + let y = s.prefix(4) + let mo = s.dropFirst(4).prefix(2) + let d = s.dropFirst(6).prefix(2) + let h = s.dropFirst(8).prefix(2) + let mi = s.dropFirst(10).prefix(2) + let se = s.dropFirst(12).prefix(2) + return "\(y)-\(mo)-\(d)T\(h):\(mi):\(se)+08:00" + } + return nil + } + + private func formatHTTPDateHeader(_ raw: String) -> String? { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "GMT") + formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz" + guard let date = formatter.date(from: raw) else { return nil } + return Self.formatBeijing(date) + } + + private func formatLocal() -> String { + Self.formatBeijing(Date()) + } + + private static func formatBeijing(_ date: Date) -> String { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: Int(beijingOffsetSeconds))! + let c = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: date) + return String( + format: "%04d-%02d-%02dT%02d:%02d:%02d+08:00", + c.year ?? 1970, c.month ?? 1, c.day ?? 1, + c.hour ?? 0, c.minute ?? 0, c.second ?? 0 + ) + } +} + +private struct SuningTimeBody: Decodable { + let sysTime1: String? + let sysTime2: String? +} diff --git a/Tests/DuooomiBleSDKTests/PlayModeTests.swift b/Tests/DuooomiBleSDKTests/PlayModeTests.swift index 0c4fa0d..970b76f 100644 --- a/Tests/DuooomiBleSDKTests/PlayModeTests.swift +++ b/Tests/DuooomiBleSDKTests/PlayModeTests.swift @@ -52,31 +52,35 @@ final class DeviceInfoLoopTests: XCTestCase { } final class BindPayloadEncodingTests: XCTestCase { + private static let fixedTime = "2026-05-07T16:58:00+08:00" + private func encode(_ payload: BindPayload) throws -> [String: Any] { let data = try JSONEncoder().encode(payload) return try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:] } func testBindPayloadWithoutLoopOmitsKey() throws { - let payload = BindPayload(type: 0x0F, userId: "u1", loop: nil) + let payload = BindPayload(type: 0x0F, userId: "u1", loop: nil, time: Self.fixedTime) let dict = try encode(payload) XCTAssertEqual(dict["type"] as? Int, 0x0F) XCTAssertEqual(dict["userId"] as? String, "u1") + XCTAssertEqual(dict["time"] as? String, Self.fixedTime) XCTAssertNil(dict["loop"], "loop key MUST be omitted when nil (not encoded as null)") - XCTAssertEqual(dict.count, 2) + XCTAssertEqual(dict.count, 3) } func testBindPayloadWithLoopIncludesValue() throws { - let payload = BindPayload(type: 0x0F, userId: "u1", loop: 1) + let payload = BindPayload(type: 0x0F, userId: "u1", loop: 1, time: Self.fixedTime) let dict = try encode(payload) XCTAssertEqual(dict["type"] as? Int, 0x0F) XCTAssertEqual(dict["userId"] as? String, "u1") XCTAssertEqual(dict["loop"] as? Int, 1) - XCTAssertEqual(dict.count, 3) + XCTAssertEqual(dict["time"] as? String, Self.fixedTime) + XCTAssertEqual(dict.count, 4) } func testBindPayloadWithLoopZeroIncludesValue() throws { - let payload = BindPayload(type: 0x0F, userId: "u1", loop: 0) + let payload = BindPayload(type: 0x0F, userId: "u1", loop: 0, time: Self.fixedTime) let dict = try encode(payload) XCTAssertEqual(dict["loop"] as? Int, 0) } @@ -84,7 +88,7 @@ final class BindPayloadEncodingTests: XCTestCase { final class SetPlayModeGuardTests: XCTestCase { private func makeSDK() -> DuooomiBleSDK { - DuooomiBleSDK(config: DuooomiBleConfig(apiKey: "test")) + DuooomiBleSDK(config: DuooomiBleConfig(apiKey: "test", owner: "test-owner", scanNamePrefix: "Duooomi-")) } func testSetPlayModeFailsWhenNotConnected() { @@ -105,45 +109,50 @@ final class SetPlayModeGuardTests: XCTestCase { } final class BindPayloadByteRegressionTests: XCTestCase { + private static let fixedTime = "2026-05-07T16:58:00+08:00" + /// 关键回归:bind / unbind 在 loop=nil 时报文必须完全不含 "loop" key —— 不能输出 "loop":null。 /// 不假设 JSONEncoder key 顺序(Swift 不同版本顺序可能不同),用 substring + dict 断言锁语义。 func testBindPayloadNilLoopOmitsKeyAndNull() throws { - let payload = BindPayload(type: 0x0F, userId: "abc", loop: nil) + let payload = BindPayload(type: 0x0F, userId: "abc", loop: nil, time: Self.fixedTime) let data = try JSONEncoder().encode(payload) let json = String(data: data, encoding: .utf8) ?? "" // 字面层:JSON 文本不允许出现 "loop" 或 "null" XCTAssertFalse(json.contains("loop"), "loop key MUST be absent; got \(json)") XCTAssertFalse(json.contains("null"), "null literal MUST be absent; got \(json)") - // 结构层:反序列化后只有 type / userId,无第三个 key + // 结构层:反序列化后只有 type / userId / time,无第四个 key let dict = try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:] - XCTAssertEqual(dict.count, 2) + XCTAssertEqual(dict.count, 3) XCTAssertEqual(dict["type"] as? Int, 15) XCTAssertEqual(dict["userId"] as? String, "abc") + XCTAssertEqual(dict["time"] as? String, Self.fixedTime) XCTAssertNil(dict["loop"]) } /// setPlayMode 报文:loop=1 必须以整数形式出现(不是 "1" 字符串)。 func testSetPlayModePayloadIncludesLoopOne() throws { - let payload = BindPayload(type: 0x0F, userId: "abc", loop: 1) + let payload = BindPayload(type: 0x0F, userId: "abc", loop: 1, time: Self.fixedTime) let data = try JSONEncoder().encode(payload) let json = String(data: data, encoding: .utf8) ?? "" // 字面层子串:必须出现 "loop":1(数字,无引号) XCTAssertTrue(json.contains(#""loop":1"#), "expected literal substring \"loop\":1 in \(json)") // 结构层 let dict = try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:] - XCTAssertEqual(dict.count, 3) + XCTAssertEqual(dict.count, 4) XCTAssertEqual(dict["type"] as? Int, 15) XCTAssertEqual(dict["userId"] as? String, "abc") XCTAssertEqual(dict["loop"] as? Int, 1) + XCTAssertEqual(dict["time"] as? String, Self.fixedTime) } /// setPlayMode 报文:loop=0 边界情况,必须以整数 0 出现(不是被当作 "default" 省略)。 func testSetPlayModePayloadIncludesLoopZero() throws { - let payload = BindPayload(type: 0x0F, userId: "abc", loop: 0) + let payload = BindPayload(type: 0x0F, userId: "abc", loop: 0, time: Self.fixedTime) let data = try JSONEncoder().encode(payload) let json = String(data: data, encoding: .utf8) ?? "" XCTAssertTrue(json.contains(#""loop":0"#), "expected literal substring \"loop\":0 in \(json)") let dict = try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:] XCTAssertEqual(dict["loop"] as? Int, 0) + XCTAssertEqual(dict["time"] as? String, Self.fixedTime) } } diff --git a/Tests/DuooomiBleSDKTests/UpgradeRecordStoreTests.swift b/Tests/DuooomiBleSDKTests/UpgradeRecordStoreTests.swift new file mode 100644 index 0000000..e0f1f8e --- /dev/null +++ b/Tests/DuooomiBleSDKTests/UpgradeRecordStoreTests.swift @@ -0,0 +1,168 @@ +import XCTest +@testable import DuooomiBleSDK + +/// 与 RN `bleStore.upgradeRecords` 行为对齐的关键回归测试。 +final class UpgradeRecordStoreTests: XCTestCase { + + private var defaults: UserDefaults! + private let suiteName = "duooomi.test.upgradeRecord" + + override func setUp() { + super.setUp() + defaults = UserDefaults(suiteName: suiteName) + defaults.removePersistentDomain(forName: suiteName) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + defaults = nil + super.tearDown() + } + + private func makeStore() -> UpgradeRecordStore { + UpgradeRecordStore(defaults: defaults) + } + + private func sample(sn: String = "A123456", firmwareId: String = "fw-1", triggeredAt: Int64? = nil) -> UpgradeRecord { + UpgradeRecord( + sn: sn, + firmwareId: firmwareId, + fromVersion: "1.0.0", + targetVersion: "1.1.0", + triggeredAt: triggeredAt ?? UpgradeRecordStore.nowMillis() + ) + } + + private func wait(_ s: TimeInterval = 0.1) { + let exp = expectation(description: "delay") + DispatchQueue.main.asyncAfter(deadline: .now() + s) { exp.fulfill() } + wait(for: [exp], timeout: s + 1) + } + + // MARK: - upsert 复合主键 + + func testUpsertAddsNewRecord() { + let store = makeStore() + store.upsert(sample()) + wait() + XCTAssertEqual(store.all.count, 1) + XCTAssertEqual(store.all.first?.firmwareId, "fw-1") + } + + func testUpsertSameKeyReplacesAndResetsReportedFlag() { + let store = makeStore() + let r1 = UpgradeRecord( + sn: "A1", firmwareId: "fw-1", + fromVersion: "1.0.0", targetVersion: "1.1.0", + triggeredAt: 1000, reported: true, reportedAt: 2000, outcome: .success + ) + store.upsert(r1) + wait() + + // 同 (sn, firmwareId),新 record 覆盖且 reported=false(重试场景) + let r2 = UpgradeRecord( + sn: "A1", firmwareId: "fw-1", + fromVersion: "1.0.0", targetVersion: "1.2.0", + triggeredAt: 5000 + ) + store.upsert(r2) + wait() + + XCTAssertEqual(store.all.count, 1) + let only = store.all[0] + XCTAssertEqual(only.targetVersion, "1.2.0") + XCTAssertFalse(only.reported) + XCTAssertNil(only.reportedAt) + XCTAssertNil(only.outcome) + } + + func testUpsertDifferentFirmwareIdAppends() { + let store = makeStore() + store.upsert(sample(firmwareId: "fw-1")) + store.upsert(sample(firmwareId: "fw-2")) + wait() + XCTAssertEqual(store.all.count, 2) + } + + func testUpsertEmptySnIgnored() { + let store = makeStore() + store.upsert(sample(sn: "")) + wait() + XCTAssertEqual(store.all.count, 0) + } + + // MARK: - pending 排序 + + func testPendingExcludesReportedAndSortsAsc() { + let store = makeStore() + store.upsert(UpgradeRecord(sn: "A", firmwareId: "fw-old", fromVersion: "1.0", targetVersion: "1.1", triggeredAt: 100)) + store.upsert(UpgradeRecord(sn: "A", firmwareId: "fw-new", fromVersion: "1.0", targetVersion: "1.2", triggeredAt: 300)) + store.upsert(UpgradeRecord(sn: "A", firmwareId: "fw-mid", fromVersion: "1.0", targetVersion: "1.15", triggeredAt: 200)) + store.upsert(UpgradeRecord(sn: "B", firmwareId: "fw-other", fromVersion: "1.0", targetVersion: "1.1", triggeredAt: 50)) + wait() + + let pending = store.pending(sn: "A") + XCTAssertEqual(pending.map { $0.firmwareId }, ["fw-old", "fw-mid", "fw-new"]) + + store.markReported(sn: "A", firmwareId: "fw-old", outcome: .success) + wait() + XCTAssertEqual(store.pending(sn: "A").map { $0.firmwareId }, ["fw-mid", "fw-new"]) + } + + // MARK: - markReported + + func testMarkReportedSetsFlagAndOutcome() { + let store = makeStore() + store.upsert(sample()) + wait() + store.markReported(sn: "A123456", firmwareId: "fw-1", outcome: .versionMismatch) + wait() + + let r = store.all[0] + XCTAssertTrue(r.reported) + XCTAssertNotNil(r.reportedAt) + XCTAssertEqual(r.outcome, .versionMismatch) + } + + func testMarkReportedTtlExpiredOutcomeNil() { + // RN 行为:TTL 过期 → markReported(null),保留为审计轨迹 + let store = makeStore() + store.upsert(sample()) + wait() + store.markReported(sn: "A123456", firmwareId: "fw-1", outcome: nil) + wait() + let r = store.all[0] + XCTAssertTrue(r.reported) + XCTAssertNil(r.outcome) + } + + // MARK: - sn 互斥 + + func testTryAcquireReturnsFalseWhileHeld() { + let store = makeStore() + XCTAssertTrue(store.tryAcquire(sn: "A1")) + XCTAssertFalse(store.tryAcquire(sn: "A1")) + // 不同 sn 互不影响 + XCTAssertTrue(store.tryAcquire(sn: "B1")) + } + + func testReleaseAllowsReacquire() { + let store = makeStore() + XCTAssertTrue(store.tryAcquire(sn: "A1")) + store.release(sn: "A1") + wait() + XCTAssertTrue(store.tryAcquire(sn: "A1")) + } + + // MARK: - 持久化 round-trip + + func testRecordsPersistAcrossInstances() { + let store1 = makeStore() + store1.upsert(sample()) + wait() + + let store2 = UpgradeRecordStore(defaults: defaults) + XCTAssertEqual(store2.all.count, 1) + XCTAssertEqual(store2.all[0].firmwareId, "fw-1") + } +} diff --git a/demo/Sources/DemoApp.swift b/demo/Sources/DemoApp.swift index 80cdd48..a153f00 100644 --- a/demo/Sources/DemoApp.swift +++ b/demo/Sources/DemoApp.swift @@ -16,8 +16,21 @@ struct DemoApp: App { } /// ViewModel 包装 SDK callback API,通过 DuooomiBleSDKDelegate 驱动 UI 更新。 -class DemoViewModel: NSObject, ObservableObject, DuooomiBleSDKDelegate { - let sdk: DuooomiBleSDK +final class DemoViewModel: NSObject, ObservableObject, DuooomiBleSDKDelegate { + + // MARK: - SDK Config(demo 内可改,持久化到 UserDefaults) + + @Published var configOwner: String { + didSet { defaults.set(configOwner, forKey: Self.keyOwner) } + } + @Published var configApiKey: String { + didSet { defaults.set(configApiKey, forKey: Self.keyApiKey) } + } + @Published var configScanPrefix: String { + didSet { defaults.set(configScanPrefix, forKey: Self.keyScanPrefix) } + } + + // MARK: - SDK State (driven by delegate) @Published var btState: ConnectionState = .idle @Published var connectedDevice: DiscoveredDevice? = nil @@ -27,11 +40,63 @@ class DemoViewModel: NSObject, ObservableObject, DuooomiBleSDKDelegate { @Published var isActivated: Bool = false @Published var transferProgress: Int = 0 @Published var error: String? = nil + @Published var upgradeRecords: [UpgradeRecord] = [] + + @Published private(set) var sdk: DuooomiBleSDK + + // MARK: - Internals + + private let defaults = UserDefaults.standard + private static let keyOwner = "demo.config.owner" + private static let keyApiKey = "demo.config.apiKey" + private static let keyScanPrefix = "demo.config.scanPrefix" + + /// demo 调试默认值(仅当 UserDefaults 没存过时使用)。秘钥从 DemoSecrets 读,避免泄露到仓库。 + private static let defaultOwner = DemoSecrets.owner + private static let defaultApiKey = DemoSecrets.apiKey + private static let defaultScanPrefix = DemoSecrets.scanNamePrefix override init() { - sdk = DuooomiBleSDK(config: .init(apiKey: "")) + let d = UserDefaults.standard + let owner = d.string(forKey: Self.keyOwner) ?? Self.defaultOwner + let apiKey = d.string(forKey: Self.keyApiKey) ?? Self.defaultApiKey + let scanPrefix = d.string(forKey: Self.keyScanPrefix) ?? Self.defaultScanPrefix + + self.configOwner = owner + self.configApiKey = apiKey + self.configScanPrefix = scanPrefix + + self.sdk = DuooomiBleSDK(config: .init( + apiKey: apiKey, + owner: owner, + scanNamePrefix: scanPrefix + )) + super.init() sdk.delegate = self + // 初次加载 SDK 持久化的 records(之前历史升级 / 待对账) + upgradeRecords = sdk.upgradeRecords + } + + /// demo 内修改配置后调用:重新创建 SDK 实例并迁移 delegate。 + func reloadSDK() { + let new = DuooomiBleSDK(config: .init( + apiKey: configApiKey, + owner: configOwner, + scanNamePrefix: configScanPrefix + )) + new.delegate = self + self.sdk = new + // 重置展示状态 + btState = .idle + connectedDevice = nil + discoveredDevices = [] + deviceInfo = nil + version = "" + isActivated = false + transferProgress = 0 + error = nil + upgradeRecords = new.upgradeRecords } // MARK: - DuooomiBleSDKDelegate @@ -64,6 +129,10 @@ class DemoViewModel: NSObject, ObservableObject, DuooomiBleSDKDelegate { transferProgress = progress } + func sdk(_ sdk: DuooomiBleSDK, didUpdateUpgradeRecords records: [UpgradeRecord]) { + upgradeRecords = records + } + func sdk(_ sdk: DuooomiBleSDK, didEncounterError error: String?) { self.error = error } diff --git a/demo/Sources/DemoSecrets.template.swift b/demo/Sources/DemoSecrets.template.swift new file mode 100644 index 0000000..6e70dfe --- /dev/null +++ b/demo/Sources/DemoSecrets.template.swift @@ -0,0 +1,14 @@ +// Demo 本地秘钥模板。 +// +// 首次 clone 后请: +// cp demo/Sources/DemoSecrets.template.swift demo/Sources/DemoSecrets.swift +// 然后编辑 DemoSecrets.swift 填入真实 apiKey / owner。 +// +// `demo/Sources/DemoSecrets.swift` 已加入 .gitignore,本地修改不会被 commit。 +import Foundation + +enum DemoSecrets { + static let apiKey: String = "" + static let owner: String = "xmly" + static let scanNamePrefix: String = "Duooomi-" +} diff --git a/demo/Sources/WrapperTestView.swift b/demo/Sources/WrapperTestView.swift index bb79151..438c446 100644 --- a/demo/Sources/WrapperTestView.swift +++ b/demo/Sources/WrapperTestView.swift @@ -25,14 +25,13 @@ private enum CDNHelper { struct WrapperTestView: View { @ObservedObject var viewModel: DemoViewModel - @State private var userId = "test-user-001" + @State private var userId = "Cjgex9kTTTJq9u9RMOnc5hiIZTvrOOq3" @State private var fileUrl = "https://cdn.bowong.cc/material/569f48a8e29f47859b3a9808be37f94c.mp4" - // Firmware - @State private var firmwareBrand = "duomi" - @State private var firmwareStatus = "DRAFT" - @State private var firmwareInfo: FirmwareInfo? = nil - @State private var firmwareLoading = false - @State private var firmwareError: String? = nil + @State private var otaRecordsTick = 0 + // fetchLatestFirmware 入参:可手动填,bind 完成 / version 拿到时自动回填 + @State private var otaFetchSn = "" + @State private var otaFetchVersion = "" + @State private var otaFetchUserId = "" // State @State private var isBusy = false @State private var connectingDeviceId: String? @@ -48,6 +47,7 @@ struct WrapperTestView: View { var body: some View { List { + configSection statusSection scanSection deviceSection @@ -60,13 +60,42 @@ struct WrapperTestView: View { .scrollDismissesKeyboard(.interactively) .onAppear { CDNHelper.cdnHost = viewModel.sdk.config.cdnHost - firmwareBrand = viewModel.sdk.config.firmwareIdentifier - firmwareStatus = viewModel.sdk.config.firmwareStatus } } // MARK: - Sections + private var configSection: some View { + Section("SDK Config(修改后点 Reload 生效)") { + HStack { + Text("x-owner").frame(width: 90, alignment: .leading) + TextField("owner", text: $viewModel.configOwner) + .textFieldStyle(.roundedBorder) + .autocapitalization(.none) + .disableAutocorrection(true) + } + HStack { + Text("apiKey").frame(width: 90, alignment: .leading) + TextField("x-api-key", text: $viewModel.configApiKey) + .textFieldStyle(.roundedBorder) + .autocapitalization(.none) + .disableAutocorrection(true) + } + HStack { + Text("scanPrefix").frame(width: 90, alignment: .leading) + TextField("Duooomi-", text: $viewModel.configScanPrefix) + .textFieldStyle(.roundedBorder) + .autocapitalization(.none) + .disableAutocorrection(true) + } + Button("Reload SDK") { + viewModel.reloadSDK() + log("SDK reloaded with owner=\(viewModel.configOwner), prefix=\(viewModel.configScanPrefix)", level: .success) + } + .buttonStyle(.borderedProminent) + } + } + private var statusSection: some View { Section("Status") { LabeledContent("BLE", value: viewModel.btState.rawValue) @@ -185,67 +214,120 @@ struct WrapperTestView: View { } } - private static let statusOptions = ["DRAFT", "PUBLISHED"] - private var firmwareSection: some View { - Section("Firmware Update") { + Section("Firmware OTA") { LabeledContent("Current Version", value: viewModel.version.isEmpty ? "—" : viewModel.version) + // fetchLatestFirmware 三入参:可手动填,bind/getVersion 完成时自动回填 HStack { - Text("Identifier") - TextField("duomi", text: $firmwareBrand) + Text("sn").frame(width: 110, alignment: .leading).font(.caption) + TextField("绑定后自动填", text: $otaFetchSn) + .textFieldStyle(.roundedBorder) + .autocapitalization(.none) + .disableAutocorrection(true) + } + HStack { + Text("currentVersion").frame(width: 110, alignment: .leading).font(.caption) + TextField("getVersion 后自动填", text: $otaFetchVersion) + .textFieldStyle(.roundedBorder) + .autocapitalization(.none) + .disableAutocorrection(true) + } + HStack { + Text("userId").frame(width: 110, alignment: .leading).font(.caption) + TextField("用户 ID", text: $otaFetchUserId) .textFieldStyle(.roundedBorder) .autocapitalization(.none) .disableAutocorrection(true) } - Picker("Status", selection: $firmwareStatus) { - ForEach(Self.statusOptions, id: \.self) { Text($0) } - } - .pickerStyle(.segmented) - - if let info = firmwareInfo { - VStack(alignment: .leading, spacing: 6) { - LabeledContent("Latest", value: info.version) - LabeledContent("Has Update", value: viewModel.sdk.hasNewerFirmware(deviceVersion: viewModel.version, serverVersion: info.version) ? "Yes" : "No") - if let size = info.fileSize { - let bytes = Int(size) ?? 0 - LabeledContent("Size", value: ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file)) - } - if let md5 = info.fileMd5, !md5.isEmpty { - LabeledContent("MD5", value: md5).font(.caption2) - } - if let desc = info.description, !desc.isEmpty { - Text(desc).font(.caption).foregroundStyle(.secondary) - } - Text(info.fileUrl).font(.caption2).foregroundStyle(.secondary).lineLimit(2) - } - } - - if let err = firmwareError { - Text(err).font(.caption).foregroundStyle(.red) - } - - if firmwareLoading { - HStack { - ProgressView().controlSize(.small) - Text("Fetching...").font(.caption).foregroundStyle(.secondary) - } - } - HStack { - Button("Get Update Info") { - runFetchFirmwareInfo() + Button("回填当前状态") { + otaFetchSn = viewModel.sdk.sn + otaFetchVersion = viewModel.version + otaFetchUserId = userId } .buttonStyle(.bordered) - .disabled(firmwareLoading || firmwareBrand.isEmpty) + .controlSize(.small) - Button("Upgrade") { - runUpgradeFirmware() - } - .buttonStyle(.borderedProminent) - .disabled(firmwareInfo?.fileUrl.isEmpty != false || viewModel.connectedDevice == nil || isBusy || firmwareLoading) + Spacer() + + Button("拉取安装包") { runCheckFirmwareUpgrade() } + .buttonStyle(.bordered) + .disabled(otaFetchSn.isEmpty || otaFetchUserId.isEmpty || isBusy) + + Button("安装") { runInstallFirmwareUpgrade() } + .buttonStyle(.borderedProminent) + .disabled(viewModel.sdk.firmwareUpgrade?.targetFirmware?.fileUrl == nil || isBusy) } + + if let upgrade = viewModel.sdk.firmwareUpgrade { + if let target = upgrade.targetFirmware { + LabeledContent("target version", value: target.version).font(.caption) + LabeledContent("firmwareId", value: target.id).font(.caption2).foregroundStyle(.secondary) + if let force = target.forceUpgrade { + LabeledContent("forceUpgrade", value: force ? "是" : "否") + .font(.caption2) + .foregroundStyle(force ? Color.red : Color.secondary) + } + if let size = target.fileSize { + LabeledContent("fileSize", value: size).font(.caption2).foregroundStyle(.secondary) + } + if let md5 = target.fileMd5 { + LabeledContent("md5", value: md5).font(.caption2).foregroundStyle(.secondary) + } + if let url = target.fileUrl { + Text(url).font(.caption2).foregroundStyle(.secondary).lineLimit(2) + } + } else { + Text("无可用固件(targetFirmware 为空)") + .font(.caption).foregroundStyle(.secondary) + } + } + + // UpgradeRecords 列表(来自 ViewModel @Published 镜像,对账上报变化会响应式刷新) + let records = viewModel.upgradeRecords + if !records.isEmpty { + Text("Upgrade Records (\(records.count))").font(.caption).foregroundStyle(.secondary) + ForEach(records, id: \.firmwareId) { r in + VStack(alignment: .leading, spacing: 2) { + Text("\(r.sn) · \(r.firmwareId)").font(.caption) + Text("\(r.fromVersion) → \(r.targetVersion)").font(.caption2).foregroundStyle(.secondary) + HStack { + // 用 (reported, outcome) 组合判定 4 种状态 + switch (r.reported, r.outcome) { + case (false, _): + Text("待上报").font(.caption2).foregroundStyle(Color.orange) + case (true, .success): + Text("✅ 正确上报").font(.caption2).foregroundStyle(Color.green) + case (true, .versionMismatch): + Text("❌ 错误上报(版本不匹配)").font(.caption2).foregroundStyle(Color.red) + case (true, nil): + Text("过期放弃").font(.caption2).foregroundStyle(Color.gray) + } + } + } + .padding(.vertical, 2) + } + } + } + .id(otaRecordsTick) + .onAppear { + // 进入页面时初始回填一次 + if otaFetchSn.isEmpty { otaFetchSn = viewModel.sdk.sn } + if otaFetchVersion.isEmpty { otaFetchVersion = viewModel.version } + if otaFetchUserId.isEmpty { otaFetchUserId = userId } + } + .onChange(of: viewModel.isActivated) { activated in + // bind 成功 → 回填 sn / currentVersion(可被用户手动覆盖;下次 bind 完成时再次回填) + if activated { + otaFetchSn = viewModel.sdk.sn + otaFetchVersion = viewModel.version + } + } + .onChange(of: viewModel.version) { v in + // getVersion 单独触发时也同步过来 + if !v.isEmpty { otaFetchVersion = v } } } @@ -461,47 +543,54 @@ struct WrapperTestView: View { } } - private func runFetchFirmwareInfo() { - let brand = firmwareBrand.trimmingCharacters(in: .whitespacesAndNewlines) - guard !brand.isEmpty else { - firmwareError = "No identifier specified" - return - } - firmwareLoading = true - firmwareError = nil - log("→ fetchLatestFirmware(\(brand), \(firmwareStatus))") - viewModel.sdk.fetchLatestFirmware(identifier: brand, status: firmwareStatus) { [self] result in + private func runCheckFirmwareUpgrade() { + isBusy = true + log("→ fetchLatestFirmware(sn=\(otaFetchSn), version=\(otaFetchVersion), userId=\(otaFetchUserId)) [report→softBind→check]") + viewModel.sdk.fetchLatestFirmware( + sn: otaFetchSn, + currentVersion: otaFetchVersion, + userId: otaFetchUserId + ) { [self] result in DispatchQueue.main.async { - self.firmwareLoading = false + self.isBusy = false + self.otaRecordsTick &+= 1 switch result { - case .success(let info): - self.firmwareInfo = info - if let info = info { - let hasUpdate = self.viewModel.sdk.hasNewerFirmware(deviceVersion: self.viewModel.version, serverVersion: info.version) - self.log("firmware latest: \(info.version), hasUpdate=\(hasUpdate)", level: .success) - } else { - self.log("no firmware available", level: .info) - } + case .success(let r): + let target = r.targetFirmware?.version ?? "—" + self.log("fetchLatestFirmware ✓ target=\(target)", level: .success) case .failure(let error): - self.firmwareError = error.localizedDescription - self.log("firmware info ✗ \(error.localizedDescription)", level: .error) + self.log("fetchLatestFirmware ✗ \(error.localizedDescription)", level: .error) } } } } - private func runUpgradeFirmware() { - guard let info = firmwareInfo, !info.fileUrl.isEmpty else { return } + private func runInstallFirmwareUpgrade() { + guard let upgrade = viewModel.sdk.firmwareUpgrade, + let target = upgrade.targetFirmware, + let fileUrl = target.fileUrl, !fileUrl.isEmpty else { + log("install ✗ 无可用固件包(请先点拉取安装包)", level: .error) + return + } + let sn = viewModel.sdk.sn + let fromVersion = viewModel.version isBusy = true - log("→ upgradeFirmware \(info.version)") - viewModel.sdk.upgradeFirmware(fileUrl: info.fileUrl) { [self] result in + log("→ upgradeFirmware sn=\(sn) \(fromVersion)→\(target.version) (fw=\(target.id))") + viewModel.sdk.upgradeFirmware( + sn: sn, + fileUrl: fileUrl, + firmwareId: target.id, + fromVersion: fromVersion, + targetVersion: target.version + ) { [self] result in DispatchQueue.main.async { self.isBusy = false + self.otaRecordsTick &+= 1 switch result { case .success: - self.log("OTA ✓", level: .success) + self.log("install ✓(已落 UpgradeRecord,下次 fetchLatestFirmware 自动对账上报)", level: .success) case .failure(let error): - self.log("OTA ✗ \(error.localizedDescription)", level: .error) + self.log("install ✗ \(error.localizedDescription)", level: .error) } } }