From 4bc8fdb64dab50e3b255caa74ac90e2fc5b16313 Mon Sep 17 00:00:00 2001 From: km2023 Date: Mon, 18 May 2026 16:25:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(sdk):=20=E7=BB=91=E5=AE=9A=E6=97=B6?= =?UTF-8?q?=E4=B8=8A=E6=8A=A5=E8=AE=BE=E5=A4=87=20log=20=E5=88=B0=20update?= =?UTF-8?q?-metadata=20=E5=B9=B6=E5=86=85=E8=81=9A=E5=8F=8C=E5=B1=82?= =?UTF-8?q?=E7=BB=91=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../DuooomiBleSDK/DuooomiBleSDK+Binding.swift | 67 ++++++++++++++++--- .../DuooomiBleSDK+Commands.swift | 11 ++- .../DuooomiBleSDK+Firmware.swift | 51 ++++---------- Sources/DuooomiBleSDK/DuooomiBleSDK.swift | 5 ++ .../DuooomiBleSDK/Models/VersionInfo.swift | 44 ++++++++---- .../Services/ShipmentSnDeviceService.swift | 52 ++++++++++++-- 6 files changed, 163 insertions(+), 67 deletions(-) diff --git a/Sources/DuooomiBleSDK/DuooomiBleSDK+Binding.swift b/Sources/DuooomiBleSDK/DuooomiBleSDK+Binding.swift index 36552a6..c773a8b 100644 --- a/Sources/DuooomiBleSDK/DuooomiBleSDK+Binding.swift +++ b/Sources/DuooomiBleSDK/DuooomiBleSDK+Binding.swift @@ -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(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) -> 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) -> 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 diff --git a/Sources/DuooomiBleSDK/DuooomiBleSDK+Commands.swift b/Sources/DuooomiBleSDK/DuooomiBleSDK+Commands.swift index 7f4b9b2..7e6040f 100644 --- a/Sources/DuooomiBleSDK/DuooomiBleSDK+Commands.swift +++ b/Sources/DuooomiBleSDK/DuooomiBleSDK+Commands.swift @@ -41,8 +41,10 @@ extension DuooomiBleSDK { /// `bind()` 内部已经在硬绑成功后自动调用一次,集成方一般不用单独调。 /// /// - Parameter completion: 回调 `Result` - /// - 成功:`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) -> 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) ?? "" + BleLog.e("VersionInfo parse failed: \(error.localizedDescription)\nRaw: \(raw)", "Decode") completion(.failure(error)) } } diff --git a/Sources/DuooomiBleSDK/DuooomiBleSDK+Firmware.swift b/Sources/DuooomiBleSDK/DuooomiBleSDK+Firmware.swift index 0a71c62..230b5f1 100644 --- a/Sources/DuooomiBleSDK/DuooomiBleSDK+Firmware.swift +++ b/Sources/DuooomiBleSDK/DuooomiBleSDK+Firmware.swift @@ -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)) } - } } } } diff --git a/Sources/DuooomiBleSDK/DuooomiBleSDK.swift b/Sources/DuooomiBleSDK/DuooomiBleSDK.swift index cb69df3..573d147 100644 --- a/Sources/DuooomiBleSDK/DuooomiBleSDK.swift +++ b/Sources/DuooomiBleSDK/DuooomiBleSDK.swift @@ -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() diff --git a/Sources/DuooomiBleSDK/Models/VersionInfo.swift b/Sources/DuooomiBleSDK/Models/VersionInfo.swift index d22b4b2..e1bf060 100644 --- a/Sources/DuooomiBleSDK/Models/VersionInfo.swift +++ b/Sources/DuooomiBleSDK/Models/VersionInfo.swift @@ -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) } } diff --git a/Sources/DuooomiBleSDK/Services/ShipmentSnDeviceService.swift b/Sources/DuooomiBleSDK/Services/ShipmentSnDeviceService.swift index b94e298..626ff99 100644 --- a/Sources/DuooomiBleSDK/Services/ShipmentSnDeviceService.swift +++ b/Sources/DuooomiBleSDK/Services/ShipmentSnDeviceService.swift @@ -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 + ) { + 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 + ) { + 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 + ) { + 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 ) { 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 {