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:
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user