feat(sdk): 绑定时上报设备 log 到 update-metadata 并内聚双层绑定

- bind(userId:) 内聚为硬绑 + getVersion + 软绑 + updateMetadata 一次原子调用,
  软绑失败自动硬解绑回滚 + 清状态;集成方无需感知双层绑定
- fetchLatestFirmware 简化为对账上报 + checkUpgrade 两步,不再重复软绑
- VersionInfo 增加 log: [String: Any]?,走 JSONSerialization 解析(放弃 Codable);
  SDK 暴露只读 versionLog,断连/解绑/软绑回滚时清空
- ShipmentSnDeviceService 新增 updateMetadata(sn:data:),抽出 performPOST
  共享 HTTP 编排,新增 postAnyJSON 支持 [String: Any] body
- 老固件不返回 log → versionLog == nil → 自动跳过 updateMetadata,前向兼容

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
km2023
2026-05-18 16:25:55 +08:00
parent cdcf36a75b
commit 4bc8fdb64d
6 changed files with 163 additions and 67 deletions

View File

@@ -4,21 +4,27 @@ import Foundation
extension DuooomiBleSDK {
/// BLE 0x0F
///
///
/// **** BLE + `getDeviceVersion`
/// / / `fetchLatestFirmware(sn:currentVersion:userId:)`
/// 1. **BLE ** 0x0F sn / isActivated
/// 2. **getVersion** version / versionLogtype Int/Stringlog JSON nil
/// 3. ****POST `/api/auth/loomart/shipment-sn/device/bind`
/// - BLE 0x12 + `isActivated`/`sn`/`versionLog` bind
/// 4. **updateMetadata**fire-and-forget versionLog `shipment-sn/device/update-metadata`
/// - warn versionLog /
///
/// SDK
/// - `sdk.sn`
/// - `sdk.version` getVersion bind
/// - `sdk.versionLog` log JSON nil
/// - `sdk.isActivated = true`delegate `didChangeActivation(true)`
///
/// - Parameters:
/// - userId: ID BLE BIND payload `bindUserId` token
/// - userId: ID BLE BIND payload + `bindUserId`
/// - completion: `Result<BindingResponse, Error>`
/// - `BindingResponse(success: 1, sn, contents)`
/// - `.notConnected` / `.alreadyBoundByOtherUser`(success1) / `.timeout(.bindDevice)`
/// - `BindingResponse(success: 1, sn, contents)`metadata
/// - `.notConnected` / `.alreadyBoundByOtherUser`( success1) / `.timeout(.bindDevice)`
/// / `.brandMismatch` / `.softBindFailed`
/// - Note: `bind` / `unbind` / `setPlayMode` `0x0F` / `0x12` opId ****
public func bind(userId: String, completion: @escaping (Result<BindingResponse, Error>) -> Void) {
guard ensureConnected(completion: completion) else { return }
@@ -82,9 +88,9 @@ extension DuooomiBleSDK {
}
self.sn = resp.sn
BleLog.i("Hard bind success: sn=\(resp.sn)", "Command")
// fetchLatestFirmware currentVersion
// self.version / self.versionLog updateMetadata
self.getVersion { _ in
completion(.success(resp))
self.performSoftBindAndMetadata(resp: resp, userId: userId, completion: completion)
}
} catch {
completion(.failure(error))
@@ -95,6 +101,50 @@ extension DuooomiBleSDK {
})
}
/// + metadata + + bind
/// metadata fire-and-forget warn
private func performSoftBindAndMetadata(
resp: BindingResponse,
userId: String,
completion: @escaping (Result<BindingResponse, Error>) -> Void
) {
let sn = resp.sn
BleLog.d("Soft bind start: sn=\(sn)", "Command")
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: \(err.localizedDescription); rolling back hard unbind", "Command")
// fire-and-forgetBLE ack bind
self.deviceInfoService.unbindDevice(userId: userId) { rollbackResult in
if case .failure(let rbErr) = rollbackResult {
BleLog.w("Hard unbind rollback failed (ignored): \(rbErr.localizedDescription)", "Command")
} else {
BleLog.i("Hard unbind rollback sent", "Command")
}
}
self.isActivated = false
self.sn = ""
self.versionLog = nil
DispatchQueue.main.async { completion(.failure(err)) }
return
}
BleLog.i("Soft bind success: sn=\(sn)", "Command")
// metadata fire-and-forgetversionLog /
if let log = self.versionLog, !log.isEmpty {
self.shipmentService.updateMetadata(sn: sn, data: log) { mdResult in
if case .failure(let err) = mdResult {
BleLog.w("updateMetadata failed (ignored): \(err.localizedDescription)", "Command")
} else {
BleLog.i("updateMetadata success: sn=\(sn)", "Command")
}
}
}
DispatchQueue.main.async { completion(.success(resp)) }
}
}
/// BLE 0x12 shipment-sn HTTPSDK
///
/// warn bind
@@ -125,6 +175,7 @@ extension DuooomiBleSDK {
if resp.success == 1 {
self.isActivated = false
self.sn = ""
self.versionLog = nil
BleLog.i("Hard unbind success", "Command")
if !cachedSn.isEmpty {
self.shipmentService.unbind(sn: cachedSn, bindUserId: userId) { softResult in

View File

@@ -41,8 +41,10 @@ extension DuooomiBleSDK {
/// `bind()`
///
/// - Parameter completion: `Result<VersionInfo, Error>`
/// - `VersionInfo` `version` / `type`
/// - `VersionInfo` `version` / `type` / `log?`
/// - `.notConnected` / `.timeout(.getDeviceVersion)` / JSON
/// - Note: `log` JSON SDK `sdk.versionLog`
/// `fetchLatestFirmware` shipment-sn `update-metadata`
public func getVersion(completion: @escaping (Result<VersionInfo, Error>) -> Void) {
guard ensureConnected(completion: completion) else { return }
BleLog.d("Sending getDeviceVersion", "Command")
@@ -54,11 +56,14 @@ extension DuooomiBleSDK {
completion(.failure(error))
case .success(let data):
do {
let info = try self.decodeResponse(VersionInfo.self, from: data)
let info = try VersionInfo.parse(from: data)
self.version = info.version
BleLog.i("Version received: \(info.version) (type=\(info.type))", "Command")
self.versionLog = info.log
BleLog.i("Version received: \(info.version) (type=\(info.type), log=\(info.log != nil ? "present" : "nil"))", "Command")
completion(.success(info))
} catch {
let raw = String(data: data.filter { $0 != 0 }, encoding: .utf8) ?? "<non-utf8, \(data.count) bytes>"
BleLog.e("VersionInfo parse failed: \(error.localizedDescription)\nRaw: \(raw)", "Decode")
completion(.failure(error))
}
}

View File

@@ -7,17 +7,17 @@ import Foundation
extension DuooomiBleSDK {
/// ****
/// ****
///
/// 1. **** pending `UpgradeRecord`
/// 2. ****POST `/api/auth/loomart/shipment-sn/device/bind`
/// - BLE 0x12 + `isActivated`/`sn` fetch
/// 3. **** `self.firmwareUpgrade` completion
/// 2. **** `self.firmwareUpgrade` completion
///
/// / metadata / `bind(userId:)`
///
/// - Parameters:
/// - sn: bind `sdk.sn`
/// - currentVersion: `sdk.version`
/// - userId: ID
/// - userId: ID failureReason
public func fetchLatestFirmware(
sn: String,
currentVersion: String,
@@ -34,41 +34,18 @@ extension DuooomiBleSDK {
// 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
// Step 2: checkUpgrade
self.firmwareUpgradeService.checkUpgrade(sn: sn, currentVersion: currentVersion) { [weak self] result in
guard let self = self else { return }
if case .failure(let err) = result {
BleLog.e("Soft bind failed during fetchLatestFirmware: \(err.localizedDescription); rolling back hard unbind", "Firmware")
// fire-and-forgetBLE ack fetch
self.deviceInfoService.unbindDevice(userId: userId) { rollbackResult in
if case .failure(let rbErr) = rollbackResult {
BleLog.w("Hard unbind rollback failed (ignored): \(rbErr.localizedDescription)", "Firmware")
} else {
BleLog.i("Hard unbind rollback sent", "Firmware")
}
switch result {
case .success(let r):
DispatchQueue.main.async {
self.firmwareUpgrade = r
completion(.success(r))
}
self.isActivated = false
self.sn = ""
case .failure(let err):
BleLog.w("checkUpgrade failed: \(err.localizedDescription)", "Firmware")
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)) }
}
}
}
}

View File

@@ -41,6 +41,10 @@ public final class DuooomiBleSDK: NSObject {
didSet { notifyMain { self.delegate?.sdk(self, didUpdateVersion: self.version) } }
}
/// `log` JSON `getVersion` /
/// `fetchLatestFirmware` shipment-sn `update-metadata`
public internal(set) var versionLog: [String: Any]?
public internal(set) var isActivated: Bool = false {
didSet {
guard isActivated != oldValue else { return }
@@ -174,6 +178,7 @@ public final class DuooomiBleSDK: NSObject {
self.connectedDevice = nil
self.deviceInfo = nil
self.version = ""
self.versionLog = nil
self.isActivated = false
self.btState = .disconnected
self.protocolService.stopListening()

View File

@@ -4,24 +4,44 @@ public struct VersionInfo: Equatable {
public let version: String
/// Int ( 7) String ( "0x07")
public let type: String
}
/// BLE `log` JSON / nil
/// SDK `update-metadata`
public let log: [String: Any]?
extension VersionInfo: Codable {
enum CodingKeys: String, CodingKey {
case version, type
public init(version: String, type: String, log: [String: Any]? = nil) {
self.version = version
self.type = type
self.log = log
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
version = try container.decode(String.self, forKey: .version)
public static func == (lhs: VersionInfo, rhs: VersionInfo) -> Bool {
guard lhs.version == rhs.version, lhs.type == rhs.type else { return false }
return NSDictionary(dictionary: lhs.log ?? [:]).isEqual(to: rhs.log ?? [:])
}
}
// type may come as Int or String from different firmware versions
if let str = try? container.decode(String.self, forKey: .type) {
type = str
} else if let num = try? container.decode(Int.self, forKey: .type) {
type = "\(num)"
extension VersionInfo {
/// BLE 0
/// type Int / String log / nil
static func parse(from data: Data) throws -> VersionInfo {
let cleaned = data.filter { $0 != 0 }
let object = try JSONSerialization.jsonObject(with: cleaned)
guard let json = object as? [String: Any] else {
throw DuooomiBleError.transferFailed("VersionInfo: top-level not an object")
}
let version = (json["version"] as? String) ?? ""
let type: String
if let s = json["type"] as? String {
type = s
} else if let n = json["type"] as? NSNumber {
type = "\(n.intValue)"
} else {
type = ""
}
let log = json["log"] as? [String: Any]
return VersionInfo(version: version, type: type, log: log)
}
}

View File

@@ -38,10 +38,54 @@ final class ShipmentSnDeviceService {
)
}
/// metadata`data` JSON BLE getVersion log
/// SDK schema 2xx
func updateMetadata(
sn: String,
data: [String: Any],
completion: @escaping (Result<Void, Error>) -> Void
) {
postAnyJSON(
path: "/api/auth/loomart/shipment-sn/device/update-metadata",
body: ["sn": sn, "data": data],
completion: completion
)
}
private func post(
path: String,
body: [String: String],
completion: @escaping (Result<Void, Error>) -> Void
) {
let bodyData: Data
do {
bodyData = try JSONEncoder().encode(body)
} catch {
completion(.failure(error))
return
}
performPOST(path: path, bodyData: bodyData, completion: completion)
}
private func postAnyJSON(
path: String,
body: [String: Any],
completion: @escaping (Result<Void, Error>) -> Void
) {
let bodyData: Data
do {
bodyData = try JSONSerialization.data(withJSONObject: body, options: [])
} catch {
completion(.failure(error))
return
}
performPOST(path: path, bodyData: bodyData, completion: completion)
}
private func performPOST(
path: String,
bodyData: Data,
completion: @escaping (Result<Void, Error>) -> Void
) {
guard let url = URL(string: config.apiHost.absoluteString + path) else {
completion(.failure(DuooomiBleError.softBindFailed("URL 拼装失败")))
@@ -55,13 +99,7 @@ final class ShipmentSnDeviceService {
req.setValue("application/json", forHTTPHeaderField: "Accept")
req.setValue(config.apiKey, forHTTPHeaderField: "x-api-key")
req.setValue(config.brand, forHTTPHeaderField: "x-owner")
do {
req.httpBody = try JSONEncoder().encode(body)
} catch {
completion(.failure(error))
return
}
req.httpBody = bodyData
session.dataTask(with: req) { data, resp, error in
if let error = error {