feat(sdk): 重命名 owner→brand 并在硬绑前校验设备品牌

- DuooomiBleConfig.owner → brand(语义:设备品牌名),HTTP header x-owner 不变
- bind() 前置 verifyBrand:缺 deviceInfo 先 getDeviceInfo,与 config.brand 严格匹配,不一致主动断连并抛 .brandMismatch
- README 抹除软绑/对账/UpgradeRecord 等内部实现细节,仅保留厂商面向的公开 API(功能代码完全保留)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
km2023
2026-05-09 11:51:17 +08:00
parent d5729a1a41
commit 97e5b8359a
11 changed files with 81 additions and 109 deletions

103
README.md
View File

@@ -23,7 +23,7 @@ dependencies: [
## Demo 初始化(仓库内 demo 目录) ## Demo 初始化(仓库内 demo 目录)
`apiKey` / `owner` / `scanNamePrefix` 不进 git由本地 `demo/Sources/DemoSecrets.swift` 提供: `apiKey` / `brand` / `scanNamePrefix` 不进 git由本地 `demo/Sources/DemoSecrets.swift` 提供:
```bash ```bash
cp demo/Sources/DemoSecrets.template.swift demo/Sources/DemoSecrets.swift cp demo/Sources/DemoSecrets.template.swift demo/Sources/DemoSecrets.swift
@@ -39,7 +39,7 @@ open demo.xcworkspace
```swift ```swift
let sdk = DuooomiBleSDK(config: .init( let sdk = DuooomiBleSDK(config: .init(
apiKey: "your-api-key", // Loomart x-api-key apiKey: "your-api-key", // Loomart x-api-key
owner: "your-owner", // RPC x-owner header brand: "your-brand", // RPC x-owner header
scanNamePrefix: "蓝牙广播名前缀" // scanNamePrefix: "蓝牙广播名前缀" //
// apiHost: URL(...)!, // https://api.duooomi.com // apiHost: URL(...)!, // https://api.duooomi.com
// cdnHost: "https://cdn.bowong.cc/", // // cdnHost: "https://cdn.bowong.cc/", //
@@ -51,29 +51,21 @@ sdk.delegate = self
## SDK 能力范围 ## SDK 能力范围
- **内置**BLE 协议(扫描/连接/硬绑/硬解/播放模式/文件传输/OTA 烧录)+ 软绑POST `/api/auth/loomart/shipment-sn/device/bind`+ 软解绑 + 升级对账上报 + 服务端拉最新固件 + 北京时间获取 - **内置**BLE 协议(扫描/连接/绑定/解绑/播放模式/文件传输/OTA 烧录)+ 固件版本检查
- **不内置**UI、retry 策略SDK 只提供原子方法和状态属性,由集成方组合 - **不内置**UI、retry 策略SDK 只提供原子方法和状态属性,由集成方组合
### 绑定与固件升级流程 ### 绑定与固件升级流程
绑定按 BLE 链路 / 云端登记拆两步: 1. **`bind(userId:)`** — 绑定设备,完成后 `sn` / `version` / `isActivated` 就绪。
2. **`fetchLatestFirmware(sn:currentVersion:userId:)`** — 拉服务端最新固件信息,写入 `firmwareUpgrade`
3. **`upgradeFirmware(...)`** — 烧录固件包,进度通过 `didUpdateProgress` 回调。
1. **`bind(userId:)`** — 硬绑BLE 0x0F+ 同步 `getDeviceVersion`。完成后 `sn` / `version` / `isActivated` 就绪 解绑:`unbind(userId:)` 一步完成
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 调用风格 ### API 调用风格
所有公开方法**显式传入参**,不依赖 SDK 内部状态自动取参。调用方从 SDK 只读属性(`sdk.sn` / `sdk.version` / `sdk.firmwareUpgrade?.targetFirmware?.*` 等)组装参数后传入。 所有公开方法**显式传入参**,不依赖 SDK 内部状态自动取参。调用方从 SDK 只读属性(`sdk.sn` / `sdk.version` / `sdk.firmwareUpgrade?.targetFirmware?.*` 等)组装参数后传入。
解绑只一步:`unbind(userId:)` 内部硬解 + 软解(软解失败仅 warn 不阻断)。
## 代理Delegate ## 代理Delegate
通过 `DuooomiBleSDKDelegate` 接收状态变化,所有方法在主线程调用,均有默认空实现: 通过 `DuooomiBleSDKDelegate` 接收状态变化,所有方法在主线程调用,均有默认空实现:
@@ -132,7 +124,7 @@ sdk.getVersion { result in
} }
} }
// + getVersion/OTA // +
sdk.bind(userId: "user-id") { result in sdk.bind(userId: "user-id") { result in
if case .success(let resp) = result { if case .success(let resp) = result {
print("SN: \(resp.sn), Files: \(resp.contents.count)") print("SN: \(resp.sn), Files: \(resp.contents.count)")
@@ -140,7 +132,7 @@ sdk.bind(userId: "user-id") { result in
} }
} }
// + //
sdk.unbind(userId: "user-id") { result in /* ... */ } sdk.unbind(userId: "user-id") { result in /* ... */ }
sdk.deleteFile(key: "file-key") { result in /* ... */ } sdk.deleteFile(key: "file-key") { result in /* ... */ }
``` ```
@@ -189,36 +181,21 @@ sdk.transferMedia(fileUrl: "https://example.com/video.mp4") { result in
### 固件升级 ### 固件升级
完整流程:**扫描 → 连接 → bind → fetchLatestFirmware(含对账上报)→ upgradeFirmware(落 record→ 重连后再次 fetchLatestFirmware 触发对账** 完整流程:**扫描 → 连接 → bind → fetchLatestFirmware → upgradeFirmware**。设备重启重连后再次走一遍即可
```swift ```swift
// 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. + // 1. +
sdk.scan() sdk.scan()
// didUpdateDevices connect // didUpdateDevices connect
sdk.connect(deviceId: device.id) { result in sdk.connect(deviceId: device.id) { result in
guard case .success = result else { return } guard case .success = result else { return }
// 2. BLE 0x0F + getVersion // 2.
sdk.bind(userId: "user-123") { result in sdk.bind(userId: "user-123") { result in
guard case .success(let resp) = result else { return } guard case .success(let resp) = result else { return }
// sdk.sn = resp.sn / sdk.version / sdk.isActivated = true // sdk.sn = resp.sn / sdk.version / sdk.isActivated = true
// 3. checkUpgrade // 3.
sdk.fetchLatestFirmware( sdk.fetchLatestFirmware(
sn: sdk.sn, sn: sdk.sn,
currentVersion: sdk.version, currentVersion: sdk.version,
@@ -226,7 +203,6 @@ sdk.connect(deviceId: device.id) { result in
) { result in ) { result in
switch result { switch result {
case .failure(let err): case .failure(let err):
//
print("拉取失败:\(err.localizedDescription)") print("拉取失败:\(err.localizedDescription)")
return return
case .success(let info): case .success(let info):
@@ -237,12 +213,7 @@ sdk.connect(deviceId: device.id) { result in
return return
} }
// 4. // 4.
if target.forceUpgrade == true {
//
}
// 5. UpgradeRecord
sdk.upgradeFirmware( sdk.upgradeFirmware(
sn: sdk.sn, sn: sdk.sn,
fileUrl: fileUrl, fileUrl: fileUrl,
@@ -252,8 +223,7 @@ sdk.connect(deviceId: device.id) { result in
) { result in ) { result in
switch result { switch result {
case .success: case .success:
// OTA // OTA
// UpgradeRecord reported=false +
print("烧录命令完成,等待设备重启") print("烧录命令完成,等待设备重启")
case .failure(let err): case .failure(let err):
print("烧录失败:\(err.localizedDescription)") print("烧录失败:\(err.localizedDescription)")
@@ -263,39 +233,8 @@ sdk.connect(deviceId: device.id) { result in
} }
} }
} }
// 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 触发,保留作审计 |
### 可读取状态 ### 可读取状态
| 属性 | 类型 | 说明 | | 属性 | 类型 | 说明 |
@@ -328,18 +267,6 @@ public struct TargetFirmware {
let fileMd5: String? let fileMd5: String?
} }
// UpgradeRecord — 本地持久化的 OTA 烧录记录(用 UserDefaults集成方一般不用直接读
public struct UpgradeRecord {
let sn: String
let firmwareId: String
let fromVersion: String
let targetVersion: String
let triggeredAt: Int64 // 毫秒时间戳
let reported: Bool
let reportedAt: Int64?
let outcome: UpgradeOutcome? // .success / .versionMismatch / nilTTL 过期)
}
// PlayMode // PlayMode
public enum PlayMode: Int { public enum PlayMode: Int {
case single = 0 // case single = 0 //
@@ -348,7 +275,7 @@ public enum PlayMode: Int {
// DeviceInfo getDeviceInfo // DeviceInfo getDeviceInfo
public struct DeviceInfo { public struct DeviceInfo {
let name: String // 设备名 let name: String //
let brand: String // let brand: String //
let size: String // let size: String //
let powerlevel: Int // let powerlevel: Int //

View File

@@ -39,6 +39,7 @@ public enum DuooomiBleError: LocalizedError {
case permissionDenied case permissionDenied
case unsupportedFormat(String) case unsupportedFormat(String)
case firmwareUpgradeUnavailable(String) case firmwareUpgradeUnavailable(String)
case brandMismatch(expected: String, actual: String)
public var errorDescription: String? { public var errorDescription: String? {
switch self { switch self {
@@ -76,6 +77,8 @@ public enum DuooomiBleError: LocalizedError {
return "Unsupported file format: \(ext). Supported: jpeg, png, gif, mp4, webm, webp" return "Unsupported file format: \(ext). Supported: jpeg, png, gif, mp4, webm, webp"
case .firmwareUpgradeUnavailable(let reason): case .firmwareUpgradeUnavailable(let reason):
return "Firmware upgrade unavailable: \(reason)" return "Firmware upgrade unavailable: \(reason)"
case .brandMismatch(let expected, let actual):
return "不支持该品牌设备 (expected=\(expected), actual=\(actual))"
} }
} }
} }

View File

@@ -6,8 +6,8 @@ public struct DuooomiBleConfig {
public let apiHost: URL public let apiHost: URL
/// API keyLoomart x-api-key /// API keyLoomart x-api-key
public let apiKey: String public let apiKey: String
/// owner RPC `x-owner` header /// RPC `x-owner` header
public let owner: String public let brand: String
/// CDN `/` /// CDN `/`
public let cdnHost: String public let cdnHost: String
/// ANI /// ANI
@@ -22,7 +22,7 @@ public struct DuooomiBleConfig {
public init( public init(
apiHost: URL = URL(string: "https://api.duooomi.com")!, apiHost: URL = URL(string: "https://api.duooomi.com")!,
apiKey: String, apiKey: String,
owner: String, brand: String,
cdnHost: String = "https://cdn.bowong.cc/", cdnHost: String = "https://cdn.bowong.cc/",
aniWidth: Int = 360, aniWidth: Int = 360,
aniHeight: Int = 360, aniHeight: Int = 360,
@@ -31,7 +31,7 @@ public struct DuooomiBleConfig {
) { ) {
self.apiHost = apiHost self.apiHost = apiHost
self.apiKey = apiKey self.apiKey = apiKey
self.owner = owner self.brand = brand
self.cdnHost = cdnHost.hasSuffix("/") ? cdnHost : cdnHost + "/" self.cdnHost = cdnHost.hasSuffix("/") ? cdnHost : cdnHost + "/"
self.aniWidth = aniWidth self.aniWidth = aniWidth
self.aniHeight = aniHeight self.aniHeight = aniHeight

View File

@@ -22,6 +22,48 @@ extension DuooomiBleSDK {
/// - Note: `bind` / `unbind` / `setPlayMode` `0x0F` / `0x12` opId **** /// - Note: `bind` / `unbind` / `setPlayMode` `0x0F` / `0x12` opId ****
public func bind(userId: String, completion: @escaping (Result<BindingResponse, Error>) -> Void) { public func bind(userId: String, completion: @escaping (Result<BindingResponse, Error>) -> Void) {
guard ensureConnected(completion: completion) else { return } guard ensureConnected(completion: completion) else { return }
verifyBrand { [weak self] result in
guard let self = self else { return }
switch result {
case .failure(let err):
completion(.failure(err))
case .success:
self.performHardBind(userId: userId, completion: completion)
}
}
}
/// brand SDK brand deviceInfo
/// `.brandMismatch`
private func verifyBrand(completion: @escaping (Result<Void, Error>) -> Void) {
if let cached = deviceInfo {
checkBrand(actual: cached.brand, completion: completion)
return
}
getDeviceInfo { [weak self] result in
guard let self = self else { return }
switch result {
case .failure(let err):
completion(.failure(err))
case .success(let info):
self.checkBrand(actual: info.brand, completion: completion)
}
}
}
private func checkBrand(actual: String, completion: @escaping (Result<Void, Error>) -> Void) {
let expected = config.brand
if actual == expected {
completion(.success(()))
return
}
BleLog.w("Brand mismatch: expected=\(expected), actual=\(actual); disconnecting", "Command")
disconnect { _ in
completion(.failure(DuooomiBleError.brandMismatch(expected: expected, actual: actual)))
}
}
private func performHardBind(userId: String, completion: @escaping (Result<BindingResponse, Error>) -> Void) {
BleLog.d("Sending bind (userId=\(userId))", "Command") BleLog.d("Sending bind (userId=\(userId))", "Command")
sendAsyncAndWait(commandType: .bindDevice, completion: { [weak self] result in sendAsyncAndWait(commandType: .bindDevice, completion: { [weak self] result in

View File

@@ -118,7 +118,7 @@ public final class DuooomiBleSDK: NSObject {
super.init() super.init()
protocolService.delegate = self protocolService.delegate = self
BleLog.i("SDK initialized (apiHost=\(config.apiHost), owner=\(config.owner))", "SDK") BleLog.i("SDK initialized (apiHost=\(config.apiHost), brand=\(config.brand))", "SDK")
setupDisconnectHandler() setupDisconnectHandler()
// UpgradeRecord delegate // UpgradeRecord delegate

View File

@@ -89,7 +89,7 @@ final class FirmwareUpgradeService {
req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue("application/json", forHTTPHeaderField: "Accept") req.setValue("application/json", forHTTPHeaderField: "Accept")
req.setValue(config.apiKey, forHTTPHeaderField: "x-api-key") req.setValue(config.apiKey, forHTTPHeaderField: "x-api-key")
req.setValue(config.owner, forHTTPHeaderField: "x-owner") req.setValue(config.brand, forHTTPHeaderField: "x-owner")
do { do {
req.httpBody = try JSONEncoder().encode(body) req.httpBody = try JSONEncoder().encode(body)
} catch { } catch {

View File

@@ -54,7 +54,7 @@ final class ShipmentSnDeviceService {
req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue("application/json", forHTTPHeaderField: "Accept") req.setValue("application/json", forHTTPHeaderField: "Accept")
req.setValue(config.apiKey, forHTTPHeaderField: "x-api-key") req.setValue(config.apiKey, forHTTPHeaderField: "x-api-key")
req.setValue(config.owner, forHTTPHeaderField: "x-owner") req.setValue(config.brand, forHTTPHeaderField: "x-owner")
do { do {
req.httpBody = try JSONEncoder().encode(body) req.httpBody = try JSONEncoder().encode(body)

View File

@@ -88,7 +88,7 @@ final class BindPayloadEncodingTests: XCTestCase {
final class SetPlayModeGuardTests: XCTestCase { final class SetPlayModeGuardTests: XCTestCase {
private func makeSDK() -> DuooomiBleSDK { private func makeSDK() -> DuooomiBleSDK {
DuooomiBleSDK(config: DuooomiBleConfig(apiKey: "test", owner: "test-owner", scanNamePrefix: "Duooomi-")) DuooomiBleSDK(config: DuooomiBleConfig(apiKey: "test", brand: "test-brand", scanNamePrefix: "Duooomi-"))
} }
func testSetPlayModeFailsWhenNotConnected() { func testSetPlayModeFailsWhenNotConnected() {

View File

@@ -20,8 +20,8 @@ final class DemoViewModel: NSObject, ObservableObject, DuooomiBleSDKDelegate {
// MARK: - SDK Configdemo UserDefaults // MARK: - SDK Configdemo UserDefaults
@Published var configOwner: String { @Published var configBrand: String {
didSet { defaults.set(configOwner, forKey: Self.keyOwner) } didSet { defaults.set(configBrand, forKey: Self.keyBrand) }
} }
@Published var configApiKey: String { @Published var configApiKey: String {
didSet { defaults.set(configApiKey, forKey: Self.keyApiKey) } didSet { defaults.set(configApiKey, forKey: Self.keyApiKey) }
@@ -47,28 +47,28 @@ final class DemoViewModel: NSObject, ObservableObject, DuooomiBleSDKDelegate {
// MARK: - Internals // MARK: - Internals
private let defaults = UserDefaults.standard private let defaults = UserDefaults.standard
private static let keyOwner = "demo.config.owner" private static let keyBrand = "demo.config.brand"
private static let keyApiKey = "demo.config.apiKey" private static let keyApiKey = "demo.config.apiKey"
private static let keyScanPrefix = "demo.config.scanPrefix" private static let keyScanPrefix = "demo.config.scanPrefix"
/// demo UserDefaults 使 DemoSecrets /// demo UserDefaults 使 DemoSecrets
private static let defaultOwner = DemoSecrets.owner private static let defaultBrand = DemoSecrets.brand
private static let defaultApiKey = DemoSecrets.apiKey private static let defaultApiKey = DemoSecrets.apiKey
private static let defaultScanPrefix = DemoSecrets.scanNamePrefix private static let defaultScanPrefix = DemoSecrets.scanNamePrefix
override init() { override init() {
let d = UserDefaults.standard let d = UserDefaults.standard
let owner = d.string(forKey: Self.keyOwner) ?? Self.defaultOwner let brand = d.string(forKey: Self.keyBrand) ?? Self.defaultBrand
let apiKey = d.string(forKey: Self.keyApiKey) ?? Self.defaultApiKey let apiKey = d.string(forKey: Self.keyApiKey) ?? Self.defaultApiKey
let scanPrefix = d.string(forKey: Self.keyScanPrefix) ?? Self.defaultScanPrefix let scanPrefix = d.string(forKey: Self.keyScanPrefix) ?? Self.defaultScanPrefix
self.configOwner = owner self.configBrand = brand
self.configApiKey = apiKey self.configApiKey = apiKey
self.configScanPrefix = scanPrefix self.configScanPrefix = scanPrefix
self.sdk = DuooomiBleSDK(config: .init( self.sdk = DuooomiBleSDK(config: .init(
apiKey: apiKey, apiKey: apiKey,
owner: owner, brand: brand,
scanNamePrefix: scanPrefix scanNamePrefix: scanPrefix
)) ))
@@ -82,7 +82,7 @@ final class DemoViewModel: NSObject, ObservableObject, DuooomiBleSDKDelegate {
func reloadSDK() { func reloadSDK() {
let new = DuooomiBleSDK(config: .init( let new = DuooomiBleSDK(config: .init(
apiKey: configApiKey, apiKey: configApiKey,
owner: configOwner, brand: configBrand,
scanNamePrefix: configScanPrefix scanNamePrefix: configScanPrefix
)) ))
new.delegate = self new.delegate = self

View File

@@ -2,13 +2,13 @@
// //
// clone // clone
// cp demo/Sources/DemoSecrets.template.swift demo/Sources/DemoSecrets.swift // cp demo/Sources/DemoSecrets.template.swift demo/Sources/DemoSecrets.swift
// DemoSecrets.swift apiKey / owner // DemoSecrets.swift apiKey / brand
// //
// `demo/Sources/DemoSecrets.swift` .gitignore commit // `demo/Sources/DemoSecrets.swift` .gitignore commit
import Foundation import Foundation
enum DemoSecrets { enum DemoSecrets {
static let apiKey: String = "" static let apiKey: String = ""
static let owner: String = "xmly" static let brand: String = "xmly"
static let scanNamePrefix: String = "Duooomi-" static let scanNamePrefix: String = "Duooomi-"
} }

View File

@@ -68,8 +68,8 @@ struct WrapperTestView: View {
private var configSection: some View { private var configSection: some View {
Section("SDK Config修改后点 Reload 生效)") { Section("SDK Config修改后点 Reload 生效)") {
HStack { HStack {
Text("x-owner").frame(width: 90, alignment: .leading) Text("brand").frame(width: 90, alignment: .leading)
TextField("owner", text: $viewModel.configOwner) TextField("brand", text: $viewModel.configBrand)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
.autocapitalization(.none) .autocapitalization(.none)
.disableAutocorrection(true) .disableAutocorrection(true)
@@ -90,7 +90,7 @@ struct WrapperTestView: View {
} }
Button("Reload SDK") { Button("Reload SDK") {
viewModel.reloadSDK() viewModel.reloadSDK()
log("SDK reloaded with owner=\(viewModel.configOwner), prefix=\(viewModel.configScanPrefix)", level: .success) log("SDK reloaded with brand=\(viewModel.configBrand), prefix=\(viewModel.configScanPrefix)", level: .success)
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
} }