feat(sdk): OTA 升级流程对齐 expo + 拆 extension 文件分层 + 秘钥本地化

主要改动:
- 新增 fetchLatestFirmware/upgradeFirmware/tryReportPendingUpgrade
  三段 OTA 接口,对齐 expo bindDeviceWithOrchestration 行为
- bind 仅做硬绑+getVersion,软绑/对账上报/拉最新固件搬到
  fetchLatestFirmware 内部串;软绑失败自动硬解回滚
- UpgradeRecord 复合主键 (sn, firmwareId) 持久化 + sn 级 in-flight 互斥
- DuooomiBleSDKDelegate 新增 didUpdateUpgradeRecords,集成方
  可观察对账上报结果(reported/outcome 状态)
- DuooomiBleSDK 主类按职责拆 5 个 extension 文件,公开 API 不变
- TargetFirmware 简化为只取必要字段 + 新增 forceUpgrade
- scanNamePrefix 改为必填 String
- demo 秘钥移至本地 DemoSecrets.swift (gitignored)
- README 重写:完整 OTA 流程示例 + 上报状态判定表

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
km2023
2026-05-08 14:46:41 +08:00
parent f4a32bb7e8
commit d5729a1a41
24 changed files with 2349 additions and 880 deletions

3
.gitignore vendored
View File

@@ -37,3 +37,6 @@ fastlane/test_output
# Code Injection
iOSInjectionProject/
# Demo 本地秘钥apiKey / owner不进 git首次 clone 复制 DemoSecrets.template.swift 填值
demo/Sources/DemoSecrets.swift

208
README.md
View File

@@ -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 / nilTTL 过期)
}
// PlayMode — 播放模式

View File

@@ -23,6 +23,12 @@ final class BleClient: NSObject {
private var connectCompletion: ((Result<CBPeripheral, Error>) -> Void)?
private var disconnectCompletion: ((Result<Void, Error>) -> 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<String> = []
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 servicereact-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
}
}
}

View File

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

View File

@@ -4,38 +4,38 @@ import Foundation
public struct DuooomiBleConfig {
/// API
public let apiHost: URL
/// API key
/// API keyLoomart 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
}
}

View File

@@ -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, Error>`
/// - `BindingResponse(success: 1, sn, contents)`
/// - `.notConnected` / `.alreadyBoundByOtherUser`(success1) / `.timeout(.bindDevice)`
/// - Note: `bind` / `unbind` / `setPlayMode` `0x0F` / `0x12` opId ****
public func bind(userId: String, completion: @escaping (Result<BindingResponse, Error>) -> 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 HTTPSDK
///
/// warn bind
///
/// SDK
/// - `sdk.sn`
/// - `sdk.isActivated = false`delegate `didChangeActivation(false)`
///
/// - Parameters:
/// - userId: ID BLE UNBIND payload + `bindUserId`
/// - completion: `Result<UnbindResponse, Error>`
/// - `UnbindResponse(success: 1)`
/// - `.notConnected` / `.unbindFailed`(BLE success1) / `.timeout(.unbindDevice)`
/// - Note: `bind` / `unbind` / `setPlayMode` `0x0F` / `0x12` opId ****
public func unbind(userId: String, completion: @escaping (Result<UnbindResponse, Error>) -> 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)
})
}
}

View File

@@ -0,0 +1,200 @@
import Foundation
// MARK: - Device Commands ( RPC)
extension DuooomiBleSDK {
///
///
/// `sdk.deviceInfo` delegate `didUpdateDeviceInfo`
///
/// - Parameter completion: `Result<DeviceInfo, Error>`
/// - `DeviceInfo` `name` / `brand` / `size` / `powerlevel` / `allspace` / `freespace` / `loop?`
/// - `.notConnected` / `.timeout(.getDeviceInfo)` / JSON
public func getDeviceInfo(completion: @escaping (Result<DeviceInfo, Error>) -> 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, Error>`
/// - `VersionInfo` `version` / `type`
/// - `.notConnected` / `.timeout(.getDeviceVersion)` / JSON
public func getVersion(completion: @escaping (Result<VersionInfo, Error>) -> 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, Error>`
/// - `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<BindingResponse, Error>) -> 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, Error>`
/// - `DeleteFileResponse(success: 0)` 0
/// - `.notConnected` / `.deleteFileFailed(status:)`status 0
/// / `.timeout(.deleteFile)` / JSON
public func deleteFile(key: String, completion: @escaping (Result<DeleteFileResponse, Error>) -> 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, Error>`
/// - `PrepareTransferResponse(status: "ready", key)`
/// - `.notConnected` / `.prepareTransferFailed(status:)` / `.timeout(.prepareTransfer)`
public func prepareTransfer(key: String, size: Int, completion: @escaping (Result<PrepareTransferResponse, Error>) -> 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)
})
}
}

View File

@@ -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, Error>`
/// - `DiscoveredDevice`
/// - `.connectionFailed(...)` / `.serviceNotFound` / `.characteristicNotFound`
/// / `.bluetoothNotPoweredOn(...)` / `.permissionDenied`
public func connect(deviceId: String, completion: @escaping (Result<DiscoveredDevice, Error>) -> 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<Void, Error>`
/// -
/// - CoreBluetooth
public func disconnect(completion: @escaping (Result<Void, Error>) -> 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
}
}

View File

@@ -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<FirmwareUpgradeCheckResult, Error>) -> 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-forgetBLE 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<Void, Error>`
/// - OTA UpgradeRecord
/// - `.notConnected` / `.transferFailed(...)`
public func upgradeFirmware(
sn: String,
fileUrl: String,
firmwareId: String,
fromVersion: String,
targetVersion: String,
completion: @escaping (Result<Void, Error>) -> 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)
}
}
}

View File

@@ -0,0 +1,220 @@
import Foundation
// MARK: - File Transfer ( ANI / mime / size )
extension DuooomiBleSDK {
static let supportedMediaExtensions: Set<String> = [
"jpeg", "jpg", "png", "gif", "mp4", "webm", "webp"
]
static let supportedMimeTypes: Set<String> = [
"image/jpeg", "image/png", "image/gif", "image/webp",
"video/mp4", "video/webm"
]
/// URI ****//prepareTransfer
///
/// 使 `transferMedia(fileUrl:)`" ANI prepareTransfer transferFile"
///
/// - Parameters:
/// - fileUri: URLHTTPS file://
/// - commandType: `.transferAniVideo`OTA `.otaPackage`
/// - completion: `Result<Void, Error>` 100%
/// - `.notConnected` / `.transferFailed(...)` / IO
/// - Note: delegate `didUpdateProgress(0...100)` 10% BleLog
public func transferFile(
fileUri: String,
commandType: CommandType = .transferAniVideo,
completion: @escaping (Result<Void, Error>) -> 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<Void, Error>`
/// - `.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, Error>) -> 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, Error>) -> 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, Error>) -> 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<Int, Error>) -> 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<Int, Error>) -> 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()
}
}

View File

@@ -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` RPCgetDeviceInfo / getVersion / setPlayMode / deleteFile / prepareTransfer
/// - `DuooomiBleSDK+Binding.swift` getVersion OTA /
/// - `DuooomiBleSDK+Firmware.swift` OTA / /
/// - `DuooomiBleSDK+Transfer.swift` mime / ANI / size
///
/// `scan()` / `stopScan()` extensionname `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<Data, Error>) -> 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<Data, Error>) -> 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<DiscoveredDevice, Error>) -> 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, Error>) -> 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<DeviceInfo, Error>) -> 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<VersionInfo, Error>) -> 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<BindingResponse, Error>) -> 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<BindingResponse, Error>) -> 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<UnbindResponse, Error>) -> 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<DeleteFileResponse, Error>) -> 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<PrepareTransferResponse, Error>) -> 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, Error>) -> 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<String> = [
"jpeg", "jpg", "png", "gif", "mp4", "webm", "webp"
]
private static let supportedMimeTypes: Set<String> = [
"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, Error>) -> 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, Error>) -> 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, Error>) -> 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<FirmwareInfo?, Error>) -> 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, Error>) -> 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<Int, Error>) -> 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<Int, Error>) -> 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<Data, Error>) -> 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<Data, Error>) -> Void,
send: @escaping (@escaping (Result<Void, Error>) -> 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<T>(completion: @escaping (Result<T, Error>) -> Void) -> Bool {
func ensureConnected<T>(completion: @escaping (Result<T, Error>) -> 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<T: Decodable>(_ type: T.Type, from data: Data) throws -> T {
func decodeResponse<T: Decodable>(_ 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?

View File

@@ -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]) {}
}

View File

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

View File

@@ -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, Error>) -> 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, Error>) -> 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 0x0Fpayload 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, Error>) -> 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)
}

View File

@@ -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<FirmwareInfo?, Error>) -> 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()
}
}

View File

@@ -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<FirmwareUpgradeCheckResult, Error>) -> 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, Error>) -> 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, Error>) -> 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<Data, Error>) -> 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?
/// MD5SDK
public let fileMd5: String?
/// `true` /
/// `false` / `nil`
/// Optional Codable
public let forceUpgrade: Bool?
}
}

View File

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

View File

@@ -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<String> = []
/// 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) }
}
}
}

View File

@@ -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?
}

View File

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

View File

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

View File

@@ -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 Configdemo 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
}

View File

@@ -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-"
}

View File

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