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:
@@ -4,21 +4,27 @@ import Foundation
|
||||
|
||||
extension DuooomiBleSDK {
|
||||
|
||||
/// 硬绑:BLE 协议层把当前用户绑定到设备(命令 0x0F),完成后同步拉一次版本号。
|
||||
/// 完整绑定:内部串四步业务编排(顺序固定),对集成方表现为一次原子调用。
|
||||
///
|
||||
/// **作用域**:只做 BLE 通道的绑定 + `getDeviceVersion`。
|
||||
/// 软绑 / 升级对账上报 / 拉最新固件 → 全部移到 `fetchLatestFirmware(sn:currentVersion:userId:)` 内部串。
|
||||
/// 1. **BLE 硬绑**(命令 0x0F):写 sn / isActivated
|
||||
/// 2. **getVersion**:写 version / versionLog(type 兼容 Int/String;log 为任意 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`(success≠1) / `.timeout(.bindDevice)`
|
||||
/// - 成功:`BindingResponse(success: 1, sn, contents)`(软绑也已成功,metadata 上报与否不影响结果)
|
||||
/// - 失败:`.notConnected` / `.alreadyBoundByOtherUser`(硬绑 success≠1) / `.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-forget;BLE 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-forget;versionLog 缺失/空则跳过)
|
||||
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 HTTP)。SDK 内部完成全套。
|
||||
///
|
||||
/// 软解失败仅打 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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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-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")
|
||||
}
|
||||
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)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user