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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
208
README.md
@@ -21,24 +21,59 @@ dependencies: [
|
||||
]
|
||||
```
|
||||
|
||||
## Demo 初始化(仓库内 demo 目录)
|
||||
|
||||
`apiKey` / `owner` / `scanNamePrefix` 不进 git,由本地 `demo/Sources/DemoSecrets.swift` 提供:
|
||||
|
||||
```bash
|
||||
cp demo/Sources/DemoSecrets.template.swift demo/Sources/DemoSecrets.swift
|
||||
# 然后编辑 DemoSecrets.swift 填真实 apiKey
|
||||
pod install
|
||||
open demo.xcworkspace
|
||||
```
|
||||
|
||||
`DemoSecrets.swift` 已加入 `.gitignore`,本地修改不会被 commit。
|
||||
|
||||
## 初始化
|
||||
|
||||
```swift
|
||||
let sdk = DuooomiBleSDK(config: .init(
|
||||
apiKey: "your-api-key" // 必填
|
||||
// apiHost: URL(...)!, // 默认 https://api.duooomi.com
|
||||
// cdnHost: "https://cdn.bowong.cc/", // 默认
|
||||
// firmwareIdentifier: "duomi", // 默认
|
||||
// firmwareStatus: "DRAFT" // 默认
|
||||
// aniWidth: 360, // ANI 转换宽度,默认 360
|
||||
// aniHeight: 360, // ANI 转换高度,默认 360
|
||||
// aniFps: "24", // ANI 转换帧率,默认 24
|
||||
apiKey: "your-api-key", // 必填,Loomart x-api-key
|
||||
owner: "your-owner", // 必填,所有 RPC 必带 x-owner header
|
||||
scanNamePrefix: "蓝牙广播名前缀" // 必填,大小写不敏感
|
||||
// apiHost: URL(...)!, // 默认 https://api.duooomi.com
|
||||
// cdnHost: "https://cdn.bowong.cc/", // 默认
|
||||
// aniWidth: 360, aniHeight: 360, aniFps: "24", // ANI 转换默认参数
|
||||
))
|
||||
|
||||
// 设置代理接收状态变化
|
||||
sdk.delegate = self
|
||||
```
|
||||
|
||||
## SDK 能力范围
|
||||
|
||||
- **内置**:BLE 协议(扫描/连接/硬绑/硬解/播放模式/文件传输/OTA 烧录)+ 软绑(POST `/api/auth/loomart/shipment-sn/device/bind`)+ 软解绑 + 升级对账上报 + 服务端拉最新固件 + 北京时间获取
|
||||
- **不内置**:UI、retry 策略;SDK 只提供原子方法和状态属性,由集成方组合
|
||||
|
||||
### 绑定与固件升级流程
|
||||
|
||||
绑定按 BLE 链路 / 云端登记拆两步:
|
||||
|
||||
1. **`bind(userId:)`** — 硬绑(BLE 0x0F)+ 同步 `getDeviceVersion`。完成后 `sn` / `version` / `isActivated` 就绪。
|
||||
2. **`fetchLatestFirmware(sn:currentVersion:userId:)`** — 内部串:
|
||||
1. 对账上报本地 pending `UpgradeRecord`(容错失败)
|
||||
2. 软绑 `shipment-sn`(失败 → 自动硬解绑回滚 + 重置 `isActivated`/`sn`,整个 fetch 失败)
|
||||
3. 拉服务端最新固件 → 写入 `firmwareUpgrade`
|
||||
|
||||
升级烧录走 `upgradeFirmware(sn:fileUrl:firmwareId:fromVersion:targetVersion:)`,烧录完自动落 `UpgradeRecord`。
|
||||
|
||||
解绑只一步:`unbind(userId:)` 内部硬解 + 软解(软解失败仅 warn 不阻断)。
|
||||
|
||||
### API 调用风格
|
||||
|
||||
所有公开方法**显式传入参**,不依赖 SDK 内部状态自动取参。调用方从 SDK 只读属性(`sdk.sn` / `sdk.version` / `sdk.firmwareUpgrade?.targetFirmware?.*` 等)组装参数后传入。
|
||||
|
||||
解绑只一步:`unbind(userId:)` 内部硬解 + 软解(软解失败仅 warn 不阻断)。
|
||||
|
||||
## 代理(Delegate)
|
||||
|
||||
通过 `DuooomiBleSDKDelegate` 接收状态变化,所有方法在主线程调用,均有默认空实现:
|
||||
@@ -97,19 +132,22 @@ sdk.getVersion { result in
|
||||
}
|
||||
}
|
||||
|
||||
// 硬绑 + getVersion;不含软绑/OTA 检查
|
||||
sdk.bind(userId: "user-id") { result in
|
||||
if case .success(let resp) = result {
|
||||
print("SN: \(resp.sn), Files: \(resp.contents.count)")
|
||||
// 此时 sdk.sn / sdk.version / sdk.isActivated 已就绪
|
||||
}
|
||||
}
|
||||
|
||||
// 硬解 + 软解(一步完成)
|
||||
sdk.unbind(userId: "user-id") { result in /* ... */ }
|
||||
sdk.deleteFile(key: "file-key") { result in /* ... */ }
|
||||
```
|
||||
|
||||
#### 播放模式(Play Mode)
|
||||
|
||||
> ⚠️ **前置条件 — 必须先绑定。** `setPlayMode` 复用 `BIND_DEVICE` (0x0F) 命令通道,固件设计上要求设备先和该 `userId` 完成 `bind`(即 SDK `isActivated == true`)才能切播放模式。SDK 在客户端做了守卫:未绑定时直接 fail,不会发命令。请在 UI 上禁用切换按钮,直到 `didChangeActivation(true)` 回调到达。
|
||||
> ⚠️ **前置条件 — 必须先绑定。** `setPlayMode` 复用 `BIND_DEVICE` (0x0F) 命令通道,固件设计上要求设备先和该 `userId` 完成 `bind`(即 SDK `isActivated == true`)才能切播放模式。SDK 在客户端做了守卫:未绑定时直接 fail,不会发命令。调用方在 `isActivated == true` 之前不应调用此方法。
|
||||
|
||||
```swift
|
||||
// 0 = 单播(播完一次停)/ 1 = 循环播放
|
||||
@@ -151,24 +189,113 @@ sdk.transferMedia(fileUrl: "https://example.com/video.mp4") { result in
|
||||
|
||||
### 固件升级
|
||||
|
||||
完整流程:**扫描 → 连接 → bind → fetchLatestFirmware(含对账上报)→ upgradeFirmware(落 record)→ 重连后再次 fetchLatestFirmware 触发对账**。
|
||||
|
||||
```swift
|
||||
// 1. 查询最新固件
|
||||
sdk.fetchLatestFirmware { result in
|
||||
guard case .success(let info?) = result else { return }
|
||||
|
||||
// 2. 检查是否有新版本
|
||||
let needUpdate = sdk.hasNewerFirmware(
|
||||
deviceVersion: sdk.version,
|
||||
serverVersion: info.version
|
||||
)
|
||||
|
||||
// 3. OTA 升级
|
||||
if needUpdate {
|
||||
sdk.upgradeFirmware(fileUrl: info.fileUrl) { result in /* ... */ }
|
||||
// ─── 0. 接收升级记录变化(用来观察对账上报结果) ───
|
||||
extension MyController: DuooomiBleSDKDelegate {
|
||||
func sdk(_ sdk: DuooomiBleSDK, didUpdateUpgradeRecords records: [UpgradeRecord]) {
|
||||
for r in records {
|
||||
switch (r.reported, r.outcome) {
|
||||
case (false, _): print("待上报 sn=\(r.sn) fw=\(r.firmwareId)")
|
||||
case (true, .success): print("✅ 正确上报 \(r.fromVersion)→\(r.targetVersion)")
|
||||
case (true, .versionMismatch): print("❌ 错误上报 versionMismatch")
|
||||
case (true, nil): print("过期放弃 (>30 天)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sdk.delegate = self
|
||||
|
||||
// ─── 1. 扫描 + 连接 ───
|
||||
sdk.scan()
|
||||
// 在 didUpdateDevices 拿到列表后选一台 connect
|
||||
sdk.connect(deviceId: device.id) { result in
|
||||
guard case .success = result else { return }
|
||||
|
||||
// ─── 2. 硬绑(BLE 0x0F + 同步 getVersion)───
|
||||
sdk.bind(userId: "user-123") { result in
|
||||
guard case .success(let resp) = result else { return }
|
||||
// 此时 sdk.sn = resp.sn / sdk.version 已就绪 / sdk.isActivated = true
|
||||
|
||||
// ─── 3. 拉最新固件(内部串:对账上报 → 软绑 → checkUpgrade)───
|
||||
sdk.fetchLatestFirmware(
|
||||
sn: sdk.sn,
|
||||
currentVersion: sdk.version,
|
||||
userId: "user-123"
|
||||
) { result in
|
||||
switch result {
|
||||
case .failure(let err):
|
||||
// 软绑失败已自动硬解回滚,可提示重试
|
||||
print("拉取失败:\(err.localizedDescription)")
|
||||
return
|
||||
case .success(let info):
|
||||
// info 同步写入 sdk.firmwareUpgrade
|
||||
guard let target = info.targetFirmware,
|
||||
let fileUrl = target.fileUrl else {
|
||||
print("无可升级固件")
|
||||
return
|
||||
}
|
||||
|
||||
// ─── 4. 强制升级判定(可选)───
|
||||
if target.forceUpgrade == true {
|
||||
// 弹不可关闭弹窗
|
||||
}
|
||||
|
||||
// ─── 5. 烧录(完成后自动落 UpgradeRecord) ───
|
||||
sdk.upgradeFirmware(
|
||||
sn: sdk.sn,
|
||||
fileUrl: fileUrl,
|
||||
firmwareId: target.id,
|
||||
fromVersion: sdk.version,
|
||||
targetVersion: target.version
|
||||
) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
// OTA 包发完,设备开始烧录并重启。
|
||||
// UpgradeRecord 已落本地(reported=false),等设备重启 + 重连。
|
||||
print("烧录命令完成,等待设备重启")
|
||||
case .failure(let err):
|
||||
print("烧录失败:\(err.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 6. 设备重启 + 重连后,再次走完整流程 ───
|
||||
// 重新 scan/connect/bind 完成后,调一次 fetchLatestFirmware:
|
||||
// 内部 tryReportPendingUpgrade 会拿当前 version 和上次 record.targetVersion 对账,
|
||||
// 命中 → report-success,不命中 → report-failure(VERSION_MISMATCH ...)。
|
||||
// 结果通过 didUpdateUpgradeRecords 主动通知(参见步骤 0)。
|
||||
```
|
||||
|
||||
### 升级对账上报机制
|
||||
|
||||
- **何时落记录**:`upgradeFirmware(...)` 烧录命令发完后,SDK 自动 upsert 一条 `UpgradeRecord` 到 `UserDefaults`(复合主键 `(sn, firmwareId)`),初始 `reported=false`。
|
||||
- **何时触发对账**:每次 `fetchLatestFirmware(...)` 进入时,第一步就跑 `tryReportPendingUpgrade`。
|
||||
- **判定规则**:
|
||||
- `currentVersion == record.targetVersion` → `POST report-success` → `outcome=.success`
|
||||
- `currentVersion != record.targetVersion` → `POST report-failure(VERSION_MISMATCH expected=... actual=...)` → `outcome=.versionMismatch`
|
||||
- `now - triggeredAt > 30 天` → 标记 `outcome=nil` 不再尝试,保留为审计轨迹
|
||||
- HTTP 失败 → 保持 `reported=false`,下次 fetchLatestFirmware 再试
|
||||
- **响应回调**:`DuooomiBleSDKDelegate.didUpdateUpgradeRecords` 在每次 record 状态变化时主线程触发,完整 records 数组。
|
||||
- **手动触发**(仅调试用):
|
||||
|
||||
```swift
|
||||
sdk.tryReportPendingUpgrade(sn: sdk.sn, currentVersion: sdk.version, userId: "user-123")
|
||||
```
|
||||
|
||||
### UpgradeRecord 状态判定
|
||||
|
||||
| `reported` | `outcome` | 含义 |
|
||||
|---|---|---|
|
||||
| `false` | `nil` | 待上报(HTTP 失败/未跑),下次 fetchLatestFirmware 重试 |
|
||||
| `true` | `.success` | ✅ 正确上报:版本命中,业务对账成功 |
|
||||
| `true` | `.versionMismatch` | ❌ 错误上报:设备版本与目标不符,烧录可能失败 |
|
||||
| `true` | `nil` | 过期放弃:30 天 TTL 触发,保留作审计 |
|
||||
|
||||
### 可读取状态
|
||||
|
||||
| 属性 | 类型 | 说明 |
|
||||
@@ -185,15 +312,32 @@ sdk.fetchLatestFirmware { result in
|
||||
### 数据类型
|
||||
|
||||
```swift
|
||||
// FirmwareInfo — fetchLatestFirmware 返回
|
||||
public struct FirmwareInfo {
|
||||
let version: String // 版本号
|
||||
let fileUrl: String // 固件下载地址
|
||||
let description: String? // 描述
|
||||
let fileSize: String? // 文件大小(字节字符串)
|
||||
let fileMd5: String? // MD5 校验
|
||||
let identifier: String? // 设备标识
|
||||
let status: String? // DRAFT / PUBLISHED
|
||||
// FirmwareUpgradeCheckResult — fetchLatestFirmware 返回,同步写入 sdk.firmwareUpgrade
|
||||
public struct FirmwareUpgradeCheckResult {
|
||||
let upgradeAvailable: Bool
|
||||
let reason: FirmwareUpgradeCheckReason // UPGRADE_AVAILABLE / ALREADY_LATEST / DEVICE_NOT_FOUND ...
|
||||
let currentSystemVersion: String?
|
||||
let targetFirmware: TargetFirmware?
|
||||
}
|
||||
|
||||
public struct TargetFirmware {
|
||||
let id: String // firmwareId(对账时使用)
|
||||
let version: String // 目标版本
|
||||
let fileUrl: String? // 固件下载地址
|
||||
let fileSize: String? // 字节数(字符串)
|
||||
let fileMd5: String?
|
||||
}
|
||||
|
||||
// UpgradeRecord — 本地持久化的 OTA 烧录记录(用 UserDefaults,集成方一般不用直接读)
|
||||
public struct UpgradeRecord {
|
||||
let sn: String
|
||||
let firmwareId: String
|
||||
let fromVersion: String
|
||||
let targetVersion: String
|
||||
let triggeredAt: Int64 // 毫秒时间戳
|
||||
let reported: Bool
|
||||
let reportedAt: Int64?
|
||||
let outcome: UpgradeOutcome? // .success / .versionMismatch / nil(TTL 过期)
|
||||
}
|
||||
|
||||
// PlayMode — 播放模式
|
||||
|
||||
@@ -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 service(react-native-ble-plx 默认会递归发现)。
|
||||
// 这里对每个 primary service 同时发起:
|
||||
// 1. 发现 included services(递归)
|
||||
// 2. 发现自己的 characteristics
|
||||
for s in services {
|
||||
scheduleServiceFullDiscovery(peripheral: peripheral, service: s)
|
||||
}
|
||||
}
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral, didDiscoverIncludedServicesFor service: CBService, error: Error?) {
|
||||
servicesPendingIncludedDiscover = max(0, servicesPendingIncludedDiscover - 1)
|
||||
if let error = error {
|
||||
BleLog.w("Included service discover error for \(service.uuid.uuidString): \(error.localizedDescription)", "BLE")
|
||||
} else {
|
||||
let included = service.includedServices ?? []
|
||||
if !included.isEmpty {
|
||||
BleLog.d(" [Service \(service.uuid.uuidString)] includes \(included.count) sub-service(s): \(included.map { $0.uuid.uuidString })", "BLE")
|
||||
}
|
||||
for sub in included {
|
||||
scheduleServiceFullDiscovery(peripheral: peripheral, service: sub)
|
||||
}
|
||||
}
|
||||
evaluateConnectCompletionIfReady()
|
||||
}
|
||||
|
||||
/// 对一个 service 同时发起 included services + characteristics 发现,去重避免循环。
|
||||
private func scheduleServiceFullDiscovery(peripheral: CBPeripheral, service: CBService) {
|
||||
let key = service.uuid.uuidString.lowercased()
|
||||
if charDiscoverDispatched.contains(key) { return }
|
||||
charDiscoverDispatched.insert(key)
|
||||
|
||||
servicesPendingIncludedDiscover += 1
|
||||
peripheral.discoverIncludedServices(nil, for: service)
|
||||
|
||||
servicesPendingCharDiscover += 1
|
||||
peripheral.discoverCharacteristics(nil, for: service)
|
||||
}
|
||||
|
||||
/// 所有 included service 与 characteristic 发现都结束后,判定连接成败。
|
||||
private func evaluateConnectCompletionIfReady() {
|
||||
guard servicesPendingIncludedDiscover == 0, servicesPendingCharDiscover == 0 else { return }
|
||||
guard connectCompletion != nil, let peripheral = connectedPeripheral else { return }
|
||||
|
||||
if writeCharacteristic == nil || readCharacteristic == nil {
|
||||
BleLog.e("Required characteristics not found. SDK service=\(BleUUIDs.service.uuidString), write=\(BleUUIDs.writeCharacteristic.uuidString), read=\(BleUUIDs.readCharacteristic.uuidString). 检查上方 [Service ...] Char 列表,把固件实际使用的 UUID 反馈给 SDK。", "BLE")
|
||||
let cb = connectCompletion
|
||||
connectCompletion = nil
|
||||
cb?(.failure(DuooomiBleError.serviceNotFound))
|
||||
return
|
||||
}
|
||||
peripheral.discoverCharacteristics(
|
||||
[BleUUIDs.writeCharacteristic, BleUUIDs.readCharacteristic],
|
||||
for: service
|
||||
)
|
||||
}
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
|
||||
if let error = error {
|
||||
let cb = connectCompletion
|
||||
connectCompletion = nil
|
||||
cb?(.failure(error))
|
||||
return
|
||||
}
|
||||
guard let characteristics = service.characteristics else {
|
||||
let cb = connectCompletion
|
||||
connectCompletion = nil
|
||||
cb?(.failure(DuooomiBleError.characteristicNotFound))
|
||||
return
|
||||
}
|
||||
|
||||
for char in characteristics {
|
||||
if char.uuid == BleUUIDs.writeCharacteristic {
|
||||
writeCharacteristic = char
|
||||
} else if char.uuid == BleUUIDs.readCharacteristic {
|
||||
readCharacteristic = char
|
||||
peripheral.setNotifyValue(true, for: char)
|
||||
}
|
||||
}
|
||||
|
||||
guard writeCharacteristic != nil, readCharacteristic != nil else {
|
||||
let cb = connectCompletion
|
||||
connectCompletion = nil
|
||||
cb?(.failure(DuooomiBleError.characteristicNotFound))
|
||||
return
|
||||
}
|
||||
|
||||
let cb = connectCompletion
|
||||
connectCompletion = nil
|
||||
cb?(.success(peripheral))
|
||||
}
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
|
||||
servicesPendingCharDiscover = max(0, servicesPendingCharDiscover - 1)
|
||||
|
||||
if let error = error {
|
||||
BleLog.w("Char discover error for service \(service.uuid.uuidString): \(error.localizedDescription)", "BLE")
|
||||
} else {
|
||||
// 诊断:打印此 service 下所有 characteristic 的属性(write/notify/read…)
|
||||
for c in service.characteristics ?? [] {
|
||||
let props = Self.describeProps(c.properties)
|
||||
BleLog.d(" [Service \(service.uuid.uuidString)] Char \(c.uuid.uuidString) [\(props)]", "BLE")
|
||||
}
|
||||
|
||||
// 命中目标 service 的 characteristic 才登记
|
||||
if Self.uuidEqual(service.uuid, BleUUIDs.service) {
|
||||
for char in service.characteristics ?? [] {
|
||||
if Self.uuidEqual(char.uuid, BleUUIDs.writeCharacteristic) {
|
||||
writeCharacteristic = char
|
||||
} else if Self.uuidEqual(char.uuid, BleUUIDs.readCharacteristic) {
|
||||
readCharacteristic = char
|
||||
peripheral.setNotifyValue(true, for: char)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
evaluateConnectCompletionIfReady()
|
||||
}
|
||||
|
||||
private static func describeProps(_ p: CBCharacteristicProperties) -> String {
|
||||
var bits: [String] = []
|
||||
if p.contains(.read) { bits.append("Read") }
|
||||
if p.contains(.write) { bits.append("Write") }
|
||||
if p.contains(.writeWithoutResponse) { bits.append("WriteNoResp") }
|
||||
if p.contains(.notify) { bits.append("Notify") }
|
||||
if p.contains(.indicate) { bits.append("Indicate") }
|
||||
return bits.joined(separator: ",")
|
||||
}
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
|
||||
guard error == nil, let data = characteristic.value else { return }
|
||||
delegate?.bleClient(self, didReceiveData: data)
|
||||
}
|
||||
|
||||
// MARK: - UUID 归一化比较
|
||||
|
||||
/// CoreBluetooth 在 service.uuid 上对蓝牙标准 base UUID 范围内的 UUID 会返回 16-bit 短形式
|
||||
/// (如 `"02C4"`),与 `CBUUID(string: "000002c4-0000-1000-8000-00805f9b34fb")` 直接 `==`
|
||||
/// 比较会因 `data` 长度不同而不相等。把短形式展开成完整 128-bit 字符串再比较。
|
||||
static func uuidEqual(_ a: CBUUID, _ b: CBUUID) -> Bool {
|
||||
if a == b { return true }
|
||||
return Self.canonicalUUIDString(a) == Self.canonicalUUIDString(b)
|
||||
}
|
||||
|
||||
private static func canonicalUUIDString(_ uuid: CBUUID) -> String {
|
||||
let s = uuid.uuidString.lowercased()
|
||||
switch s.count {
|
||||
case 4:
|
||||
return "0000\(s)-0000-1000-8000-00805f9b34fb"
|
||||
case 8:
|
||||
return "\(s)-0000-1000-8000-00805f9b34fb"
|
||||
default:
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,38 +4,38 @@ import Foundation
|
||||
public struct DuooomiBleConfig {
|
||||
/// API 服务地址
|
||||
public let apiHost: URL
|
||||
/// API 鉴权 key
|
||||
/// API 鉴权 key(Loomart x-api-key)
|
||||
public let apiKey: String
|
||||
/// 多租户 owner 标识,所有 RPC 必带 `x-owner` header
|
||||
public let owner: String
|
||||
/// CDN 资源地址(末尾带 `/`)
|
||||
public let cdnHost: String
|
||||
/// 固件查询标识,如 "duomi"
|
||||
public let firmwareIdentifier: String
|
||||
/// 固件查询状态,如 "DRAFT" / "PUBLISHED"
|
||||
public let firmwareStatus: String
|
||||
/// ANI 转换宽度(像素)
|
||||
public let aniWidth: Int
|
||||
/// ANI 转换高度(像素)
|
||||
public let aniHeight: Int
|
||||
/// ANI 转换帧率
|
||||
public let aniFps: String
|
||||
/// 扫描时按设备蓝牙广播名前缀过滤(大小写不敏感)。**必填**,由集成方约定固件广播名前缀。
|
||||
public let scanNamePrefix: String
|
||||
|
||||
public init(
|
||||
apiHost: URL = URL(string: "https://api.duooomi.com")!,
|
||||
apiKey: String,
|
||||
owner: String,
|
||||
cdnHost: String = "https://cdn.bowong.cc/",
|
||||
firmwareIdentifier: String = "duomi",
|
||||
firmwareStatus: String = "DRAFT",
|
||||
aniWidth: Int = 360,
|
||||
aniHeight: Int = 360,
|
||||
aniFps: String = "24"
|
||||
aniFps: String = "24",
|
||||
scanNamePrefix: String
|
||||
) {
|
||||
self.apiHost = apiHost
|
||||
self.apiKey = apiKey
|
||||
self.owner = owner
|
||||
self.cdnHost = cdnHost.hasSuffix("/") ? cdnHost : cdnHost + "/"
|
||||
self.firmwareIdentifier = firmwareIdentifier
|
||||
self.firmwareStatus = firmwareStatus
|
||||
self.aniWidth = aniWidth
|
||||
self.aniHeight = aniHeight
|
||||
self.aniFps = aniFps
|
||||
self.scanNamePrefix = scanNamePrefix
|
||||
}
|
||||
}
|
||||
|
||||
109
Sources/DuooomiBleSDK/DuooomiBleSDK+Binding.swift
Normal file
109
Sources/DuooomiBleSDK/DuooomiBleSDK+Binding.swift
Normal 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`(success≠1) / `.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 HTTP)。SDK 内部完成全套。
|
||||
///
|
||||
/// 软解失败仅打 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 层 success≠1) / `.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)
|
||||
})
|
||||
}
|
||||
}
|
||||
200
Sources/DuooomiBleSDK/DuooomiBleSDK+Commands.swift
Normal file
200
Sources/DuooomiBleSDK/DuooomiBleSDK+Commands.swift
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
90
Sources/DuooomiBleSDK/DuooomiBleSDK+Connection.swift
Normal file
90
Sources/DuooomiBleSDK/DuooomiBleSDK+Connection.swift
Normal 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
|
||||
}
|
||||
}
|
||||
189
Sources/DuooomiBleSDK/DuooomiBleSDK+Firmware.swift
Normal file
189
Sources/DuooomiBleSDK/DuooomiBleSDK+Firmware.swift
Normal 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-forget;BLE ack 不影响这次 fetch 错误回传)
|
||||
self.deviceInfoService.unbindDevice(userId: userId) { rollbackResult in
|
||||
if case .failure(let rbErr) = rollbackResult {
|
||||
BleLog.w("Hard unbind rollback failed (ignored): \(rbErr.localizedDescription)", "Firmware")
|
||||
} else {
|
||||
BleLog.i("Hard unbind rollback sent", "Firmware")
|
||||
}
|
||||
}
|
||||
self.isActivated = false
|
||||
self.sn = ""
|
||||
DispatchQueue.main.async { completion(.failure(err)) }
|
||||
return
|
||||
}
|
||||
BleLog.i("Soft bind success: sn=\(sn)", "Firmware")
|
||||
|
||||
// Step 3: 服务端 checkUpgrade
|
||||
self.firmwareUpgradeService.checkUpgrade(sn: sn, currentVersion: currentVersion) { result in
|
||||
switch result {
|
||||
case .success(let r):
|
||||
DispatchQueue.main.async {
|
||||
self.firmwareUpgrade = r
|
||||
completion(.success(r))
|
||||
}
|
||||
case .failure(let err):
|
||||
BleLog.w("checkUpgrade failed: \(err.localizedDescription)", "Firmware")
|
||||
DispatchQueue.main.async { completion(.failure(err)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// OTA 固件烧录。完成后自动落 `UpgradeRecord` 到本地(复合主键 `(sn, firmwareId)`),
|
||||
/// 等待下次 `fetchLatestFirmware` 触发 `tryReportPendingUpgrade` 对账上报。
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - sn: 设备序列号(用于落 UpgradeRecord 的主键)
|
||||
/// - fileUrl: 固件包下载 URL(一般从 `sdk.firmwareUpgrade?.targetFirmware?.fileUrl` 取)
|
||||
/// - firmwareId: 目标固件 ID(一般从 `sdk.firmwareUpgrade?.targetFirmware?.id` 取)
|
||||
/// - fromVersion: 升级前版本(一般从 `sdk.version` 取)
|
||||
/// - targetVersion: 升级目标版本(一般从 `sdk.firmwareUpgrade?.targetFirmware?.version` 取)
|
||||
/// - completion: 回调 `Result<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)
|
||||
}
|
||||
}
|
||||
}
|
||||
220
Sources/DuooomiBleSDK/DuooomiBleSDK+Transfer.swift
Normal file
220
Sources/DuooomiBleSDK/DuooomiBleSDK+Transfer.swift
Normal 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: 已就绪文件 URL(HTTPS 远程或 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()
|
||||
}
|
||||
}
|
||||
@@ -4,51 +4,65 @@ import Foundation
|
||||
/// Duooomi BLE SDK - 纯原生 iOS 蓝牙 SDK
|
||||
///
|
||||
/// 提供设备扫描、连接、命令交互、文件传输等原子蓝牙操作。
|
||||
/// 不做业务编排,调用方自行串联多步流程。
|
||||
/// 业务编排(双层绑定 / OTA 检查 / 升级对账上报)由 SDK 主类内部完成,集成方仅与公开 API 交互。
|
||||
///
|
||||
/// 文件分层(按职责拆分;类即门面,对外 API 形态不变):
|
||||
/// - `DuooomiBleSDK.swift` 状态属性 / init / 扫描 / RPC helpers / delegate 实现
|
||||
/// - `DuooomiBleSDK+Connection.swift` 连接 / 断开
|
||||
/// - `DuooomiBleSDK+Commands.swift` 纯协议层 RPC(getDeviceInfo / getVersion / setPlayMode / deleteFile / prepareTransfer)
|
||||
/// - `DuooomiBleSDK+Binding.swift` 双层绑定编排(硬绑 → getVersion → OTA 检查/对账 → 软绑,失败回滚)
|
||||
/// - `DuooomiBleSDK+Firmware.swift` OTA 检查 / 安装 / 升级对账上报
|
||||
/// - `DuooomiBleSDK+Transfer.swift` 文件传输(mime 校验 / ANI 转换 / 远程 size 探测)
|
||||
///
|
||||
/// 注:`scan()` / `stopScan()` 留在主类不放 extension:name 与 `Combine.Publisher.scan(_:_:)` 冲突,
|
||||
/// 当 SDK 实例被 `@Published` 包装时 extension 上的 `scan` 会被 Publisher.scan 抢占名字解析。
|
||||
public final class DuooomiBleSDK: NSObject {
|
||||
|
||||
// MARK: - Observable State
|
||||
|
||||
public weak var delegate: DuooomiBleSDKDelegate?
|
||||
|
||||
public private(set) var btState: ConnectionState = .idle {
|
||||
public internal(set) var btState: ConnectionState = .idle {
|
||||
didSet {
|
||||
guard btState != oldValue else { return }
|
||||
notifyMain { self.delegate?.sdk(self, didChangeState: self.btState) }
|
||||
}
|
||||
}
|
||||
|
||||
public private(set) var connectedDevice: DiscoveredDevice? = nil {
|
||||
public internal(set) var connectedDevice: DiscoveredDevice? = nil {
|
||||
didSet { notifyMain { self.delegate?.sdk(self, didConnectDevice: self.connectedDevice) } }
|
||||
}
|
||||
|
||||
public private(set) var deviceInfo: DeviceInfo? = nil {
|
||||
public internal(set) var deviceInfo: DeviceInfo? = nil {
|
||||
didSet { notifyMain { self.delegate?.sdk(self, didUpdateDeviceInfo: self.deviceInfo) } }
|
||||
}
|
||||
|
||||
public private(set) var version: String = "" {
|
||||
public internal(set) var version: String = "" {
|
||||
didSet { notifyMain { self.delegate?.sdk(self, didUpdateVersion: self.version) } }
|
||||
}
|
||||
|
||||
public private(set) var isActivated: Bool = false {
|
||||
public internal(set) var isActivated: Bool = false {
|
||||
didSet {
|
||||
guard isActivated != oldValue else { return }
|
||||
notifyMain { self.delegate?.sdk(self, didChangeActivation: self.isActivated) }
|
||||
}
|
||||
}
|
||||
|
||||
public private(set) var transferProgress: Int = 0 {
|
||||
/// 当前已绑定设备的 SN(绑定成功后写入,解绑后清空)
|
||||
public internal(set) var sn: String = ""
|
||||
|
||||
public internal(set) var transferProgress: Int = 0 {
|
||||
didSet {
|
||||
guard transferProgress != oldValue else { return }
|
||||
notifyMain { self.delegate?.sdk(self, didUpdateProgress: self.transferProgress) }
|
||||
}
|
||||
}
|
||||
|
||||
public private(set) var error: String? = nil {
|
||||
public internal(set) var error: String? = nil {
|
||||
didSet { notifyMain { self.delegate?.sdk(self, didEncounterError: self.error) } }
|
||||
}
|
||||
|
||||
public private(set) var discoveredDevices: [DiscoveredDevice] = [] {
|
||||
public internal(set) var discoveredDevices: [DiscoveredDevice] = [] {
|
||||
didSet { notifyMain { self.delegate?.sdk(self, didUpdateDevices: self.discoveredDevices) } }
|
||||
}
|
||||
|
||||
@@ -56,27 +70,36 @@ public final class DuooomiBleSDK: NSObject {
|
||||
|
||||
public let config: DuooomiBleConfig
|
||||
|
||||
// MARK: - Internal Services
|
||||
// MARK: - Internal Services (extension 文件按职责调用)
|
||||
|
||||
private let bleClient: BleClient
|
||||
private let protocolService: BleProtocolService
|
||||
private let deviceInfoService: DeviceInfoService
|
||||
private let fileTransferService: FileTransferService
|
||||
private let aniConverter: AniConverter
|
||||
private let firmwareService: FirmwareService
|
||||
let bleClient: BleClient
|
||||
let protocolService: BleProtocolService
|
||||
let deviceInfoService: DeviceInfoService
|
||||
let fileTransferService: FileTransferService
|
||||
let aniConverter: AniConverter
|
||||
let timeProvider: BeijingTimeProvider
|
||||
let shipmentService: ShipmentSnDeviceService
|
||||
let firmwareUpgradeService: FirmwareUpgradeService
|
||||
let upgradeRecordStore: UpgradeRecordStore
|
||||
|
||||
// MARK: - Request-Response
|
||||
/// 服务端最新一次 `fetchLatestFirmware` 结果
|
||||
public internal(set) var firmwareUpgrade: FirmwareUpgradeCheckResult? = nil
|
||||
|
||||
private var pendingCallbacks: [String: (Result<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?
|
||||
|
||||
|
||||
@@ -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]) {}
|
||||
}
|
||||
|
||||
44
Sources/DuooomiBleSDK/Models/UpgradeRecord.swift
Normal file
44
Sources/DuooomiBleSDK/Models/UpgradeRecord.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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 命令字 0x0F,payload 携带 loop 字段)
|
||||
func setPlayMode(userId: String, loop: PlayMode) throws {
|
||||
try protocolService.sendJSON(
|
||||
type: .bindDevice,
|
||||
payload: BindPayload(
|
||||
type: CommandType.bindDevice.rawValue,
|
||||
userId: userId,
|
||||
loop: UInt8(loop.rawValue)
|
||||
)
|
||||
)
|
||||
func setPlayMode(userId: String, loop: PlayMode, completion: @escaping (Result<Void, 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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
171
Sources/DuooomiBleSDK/Services/FirmwareUpgradeService.swift
Normal file
171
Sources/DuooomiBleSDK/Services/FirmwareUpgradeService.swift
Normal 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?
|
||||
/// 固件包 MD5。集成方有需要可校验下载内容;SDK 不做校验。
|
||||
public let fileMd5: String?
|
||||
/// 强制升级标记。`true` 表示该版本必须升级(典型场景:安全补丁 / 协议破坏性变更),
|
||||
/// 集成方应据此弹不可关闭的升级弹窗、阻断设备其他操作;`false` / `nil` 由用户自行决定。
|
||||
/// 服务端可能不返回此字段(旧固件版本),用 Optional 做 Codable 兜底。
|
||||
public let forceUpgrade: Bool?
|
||||
}
|
||||
}
|
||||
99
Sources/DuooomiBleSDK/Services/ShipmentSnDeviceService.swift
Normal file
99
Sources/DuooomiBleSDK/Services/ShipmentSnDeviceService.swift
Normal 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
|
||||
}
|
||||
}
|
||||
111
Sources/DuooomiBleSDK/Services/UpgradeRecordStore.swift
Normal file
111
Sources/DuooomiBleSDK/Services/UpgradeRecordStore.swift
Normal 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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
127
Sources/DuooomiBleSDK/Utils/BeijingTimeProvider.swift
Normal file
127
Sources/DuooomiBleSDK/Utils/BeijingTimeProvider.swift
Normal 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?
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
168
Tests/DuooomiBleSDKTests/UpgradeRecordStoreTests.swift
Normal file
168
Tests/DuooomiBleSDKTests/UpgradeRecordStoreTests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,21 @@ struct DemoApp: App {
|
||||
}
|
||||
|
||||
/// ViewModel 包装 SDK callback API,通过 DuooomiBleSDKDelegate 驱动 UI 更新。
|
||||
class DemoViewModel: NSObject, ObservableObject, DuooomiBleSDKDelegate {
|
||||
let sdk: DuooomiBleSDK
|
||||
final class DemoViewModel: NSObject, ObservableObject, DuooomiBleSDKDelegate {
|
||||
|
||||
// MARK: - SDK Config(demo 内可改,持久化到 UserDefaults)
|
||||
|
||||
@Published var configOwner: String {
|
||||
didSet { defaults.set(configOwner, forKey: Self.keyOwner) }
|
||||
}
|
||||
@Published var configApiKey: String {
|
||||
didSet { defaults.set(configApiKey, forKey: Self.keyApiKey) }
|
||||
}
|
||||
@Published var configScanPrefix: String {
|
||||
didSet { defaults.set(configScanPrefix, forKey: Self.keyScanPrefix) }
|
||||
}
|
||||
|
||||
// MARK: - SDK State (driven by delegate)
|
||||
|
||||
@Published var btState: ConnectionState = .idle
|
||||
@Published var connectedDevice: DiscoveredDevice? = nil
|
||||
@@ -27,11 +40,63 @@ class DemoViewModel: NSObject, ObservableObject, DuooomiBleSDKDelegate {
|
||||
@Published var isActivated: Bool = false
|
||||
@Published var transferProgress: Int = 0
|
||||
@Published var error: String? = nil
|
||||
@Published var upgradeRecords: [UpgradeRecord] = []
|
||||
|
||||
@Published private(set) var sdk: DuooomiBleSDK
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
private let defaults = UserDefaults.standard
|
||||
private static let keyOwner = "demo.config.owner"
|
||||
private static let keyApiKey = "demo.config.apiKey"
|
||||
private static let keyScanPrefix = "demo.config.scanPrefix"
|
||||
|
||||
/// demo 调试默认值(仅当 UserDefaults 没存过时使用)。秘钥从 DemoSecrets 读,避免泄露到仓库。
|
||||
private static let defaultOwner = DemoSecrets.owner
|
||||
private static let defaultApiKey = DemoSecrets.apiKey
|
||||
private static let defaultScanPrefix = DemoSecrets.scanNamePrefix
|
||||
|
||||
override init() {
|
||||
sdk = DuooomiBleSDK(config: .init(apiKey: ""))
|
||||
let d = UserDefaults.standard
|
||||
let owner = d.string(forKey: Self.keyOwner) ?? Self.defaultOwner
|
||||
let apiKey = d.string(forKey: Self.keyApiKey) ?? Self.defaultApiKey
|
||||
let scanPrefix = d.string(forKey: Self.keyScanPrefix) ?? Self.defaultScanPrefix
|
||||
|
||||
self.configOwner = owner
|
||||
self.configApiKey = apiKey
|
||||
self.configScanPrefix = scanPrefix
|
||||
|
||||
self.sdk = DuooomiBleSDK(config: .init(
|
||||
apiKey: apiKey,
|
||||
owner: owner,
|
||||
scanNamePrefix: scanPrefix
|
||||
))
|
||||
|
||||
super.init()
|
||||
sdk.delegate = self
|
||||
// 初次加载 SDK 持久化的 records(之前历史升级 / 待对账)
|
||||
upgradeRecords = sdk.upgradeRecords
|
||||
}
|
||||
|
||||
/// demo 内修改配置后调用:重新创建 SDK 实例并迁移 delegate。
|
||||
func reloadSDK() {
|
||||
let new = DuooomiBleSDK(config: .init(
|
||||
apiKey: configApiKey,
|
||||
owner: configOwner,
|
||||
scanNamePrefix: configScanPrefix
|
||||
))
|
||||
new.delegate = self
|
||||
self.sdk = new
|
||||
// 重置展示状态
|
||||
btState = .idle
|
||||
connectedDevice = nil
|
||||
discoveredDevices = []
|
||||
deviceInfo = nil
|
||||
version = ""
|
||||
isActivated = false
|
||||
transferProgress = 0
|
||||
error = nil
|
||||
upgradeRecords = new.upgradeRecords
|
||||
}
|
||||
|
||||
// MARK: - DuooomiBleSDKDelegate
|
||||
@@ -64,6 +129,10 @@ class DemoViewModel: NSObject, ObservableObject, DuooomiBleSDKDelegate {
|
||||
transferProgress = progress
|
||||
}
|
||||
|
||||
func sdk(_ sdk: DuooomiBleSDK, didUpdateUpgradeRecords records: [UpgradeRecord]) {
|
||||
upgradeRecords = records
|
||||
}
|
||||
|
||||
func sdk(_ sdk: DuooomiBleSDK, didEncounterError error: String?) {
|
||||
self.error = error
|
||||
}
|
||||
|
||||
14
demo/Sources/DemoSecrets.template.swift
Normal file
14
demo/Sources/DemoSecrets.template.swift
Normal 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-"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user