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:
km2023
2026-05-08 14:46:41 +08:00
parent f4a32bb7e8
commit d5729a1a41
24 changed files with 2349 additions and 880 deletions

View File

@@ -3,9 +3,11 @@ import Foundation
/// JSON fire-and-forget
final class DeviceInfoService {
private let protocolService: BleProtocolService
private let timeProvider: BeijingTimeProvider
init(protocolService: BleProtocolService) {
init(protocolService: BleProtocolService, timeProvider: BeijingTimeProvider) {
self.protocolService = protocolService
self.timeProvider = timeProvider
}
// MARK: - Commands
@@ -24,30 +26,66 @@ final class DeviceInfoService {
)
}
func bindDevice(userId: String) throws {
try protocolService.sendJSON(
type: .bindDevice,
payload: BindPayload(type: CommandType.bindDevice.rawValue, userId: userId, loop: nil)
)
/// RN
func bindDevice(userId: String, completion: @escaping (Result<Void, Error>) -> Void) {
timeProvider.fetch { [weak self] time in
guard let self = self else { return }
do {
try self.protocolService.sendJSON(
type: .bindDevice,
payload: BindPayload(
type: CommandType.bindDevice.rawValue,
userId: userId,
loop: nil,
time: time
)
)
completion(.success(()))
} catch {
completion(.failure(error))
}
}
}
func unbindDevice(userId: String) throws {
try protocolService.sendJSON(
type: .unbindDevice,
payload: BindPayload(type: CommandType.unbindDevice.rawValue, userId: userId, loop: nil)
)
func unbindDevice(userId: String, completion: @escaping (Result<Void, Error>) -> Void) {
timeProvider.fetch { [weak self] time in
guard let self = self else { return }
do {
try self.protocolService.sendJSON(
type: .unbindDevice,
payload: BindPayload(
type: CommandType.unbindDevice.rawValue,
userId: userId,
loop: nil,
time: time
)
)
completion(.success(()))
} catch {
completion(.failure(error))
}
}
}
/// BIND_DEVICE 0x0Fpayload loop
func setPlayMode(userId: String, loop: PlayMode) throws {
try protocolService.sendJSON(
type: .bindDevice,
payload: BindPayload(
type: CommandType.bindDevice.rawValue,
userId: userId,
loop: UInt8(loop.rawValue)
)
)
func setPlayMode(userId: String, loop: PlayMode, completion: @escaping (Result<Void, Error>) -> Void) {
timeProvider.fetch { [weak self] time in
guard let self = self else { return }
do {
try self.protocolService.sendJSON(
type: .bindDevice,
payload: BindPayload(
type: CommandType.bindDevice.rawValue,
userId: userId,
loop: UInt8(loop.rawValue),
time: time
)
)
completion(.success(()))
} catch {
completion(.failure(error))
}
}
}
func deleteFile(key: String) throws {
@@ -77,19 +115,22 @@ private struct CommandPayload: Encodable {
/// / / payload
/// `loop == nil` null Expo zod.optional
/// `time` ISO 8601
struct BindPayload: Encodable {
let type: UInt8
let userId: String
let loop: UInt8?
let time: String
enum CodingKeys: String, CodingKey {
case type, userId, loop
case type, userId, loop, time
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(type, forKey: .type)
try container.encode(userId, forKey: .userId)
try container.encode(time, forKey: .time)
if let loop = loop {
try container.encode(loop, forKey: .loop)
}

View File

@@ -1,85 +0,0 @@
import Foundation
///
public struct FirmwareInfo: Codable, Equatable {
public let version: String
public let fileUrl: String
public let description: String?
public let fileSize: String?
public let fileMd5: String?
public let identifier: String?
public let status: String?
}
struct FirmwareResponse: Codable {
let success: Bool
let data: FirmwareInfo?
}
///
final class FirmwareService {
private let config: DuooomiBleConfig
private let latestPath = "api/auth/loomart/firmware/latest-published"
init(config: DuooomiBleConfig) {
self.config = config
}
func fetchLatest(
identifier: String? = nil,
status: String? = nil,
completion: @escaping (Result<FirmwareInfo?, Error>) -> Void
) {
let id = (identifier ?? config.firmwareIdentifier).trimmingCharacters(in: .whitespacesAndNewlines)
let st = status ?? config.firmwareStatus
guard !id.isEmpty else {
completion(.failure(DuooomiBleError.transferFailed("Invalid firmware identifier")))
return
}
let urlString = "\(config.apiHost)/\(latestPath)"
guard var comps = URLComponents(string: urlString) else {
completion(.failure(DuooomiBleError.transferFailed("Invalid firmware URL")))
return
}
comps.queryItems = [
URLQueryItem(name: "identifier", value: id),
URLQueryItem(name: "status", value: st)
]
guard let url = comps.url else {
completion(.failure(DuooomiBleError.transferFailed("Invalid firmware URL")))
return
}
var req = URLRequest(url: url)
req.httpMethod = "GET"
req.setValue(config.apiKey, forHTTPHeaderField: "x-api-key")
req.setValue("application/json", forHTTPHeaderField: "accept")
URLSession.shared.dataTask(with: req) { data, resp, error in
if let error = error {
completion(.failure(error))
return
}
guard let http = resp as? HTTPURLResponse else {
completion(.failure(DuooomiBleError.transferFailed("Invalid response")))
return
}
guard (200...299).contains(http.statusCode) else {
completion(.failure(DuooomiBleError.transferFailed("HTTP \(http.statusCode)")))
return
}
guard let data = data else {
completion(.failure(DuooomiBleError.transferFailed("Empty response")))
return
}
do {
let decoded = try JSONDecoder().decode(FirmwareResponse.self, from: data)
completion(.success(decoded.data))
} catch {
completion(.failure(error))
}
}.resume()
}
}

View File

@@ -0,0 +1,171 @@
import Foundation
/// RPC / /
/// RN `FirmwareController.{checkUpgrade, reportUpgradeSuccessBySn, reportUpgradeFailureBySn}` 1:1
final class FirmwareUpgradeService {
private let config: DuooomiBleConfig
private let session: URLSession
init(config: DuooomiBleConfig, session: URLSession = .shared) {
self.config = config
self.session = session
}
///
func checkUpgrade(
sn: String,
currentVersion: String?,
completion: @escaping (Result<FirmwareUpgradeCheckResult, Error>) -> Void
) {
var body: [String: String] = ["sn": sn]
if let v = currentVersion, !v.isEmpty { body["currentVersion"] = v }
post(
path: "/api/auth/loomart/firmware/upgrade/check",
body: body
) { result in
switch result {
case .failure(let e): completion(.failure(e))
case .success(let data):
do {
let parsed = try JSONDecoder().decode(FirmwareUpgradeCheckResult.self, from: data)
completion(.success(parsed))
} catch {
completion(.failure(error))
}
}
}
}
func reportUpgradeSuccess(
sn: String,
firmwareId: String,
fromVersion: String,
bindUserId: String?,
completion: @escaping (Result<Void, Error>) -> Void
) {
var body: [String: String] = [
"sn": sn,
"firmwareId": firmwareId,
"fromVersion": fromVersion,
]
if let u = bindUserId, !u.isEmpty { body["bindUserId"] = u }
post(path: "/api/auth/loomart/firmware/upgrade/report-success", body: body) { result in
completion(result.map { _ in () })
}
}
func reportUpgradeFailure(
sn: String,
firmwareId: String,
fromVersion: String,
failureReason: String,
completion: @escaping (Result<Void, Error>) -> Void
) {
let body = [
"sn": sn,
"firmwareId": firmwareId,
"fromVersion": fromVersion,
"failureReason": failureReason,
]
post(path: "/api/auth/loomart/firmware/upgrade/report-failure", body: body) { result in
completion(result.map { _ in () })
}
}
// MARK: - HTTP
private func post(
path: String,
body: [String: String],
completion: @escaping (Result<Data, Error>) -> Void
) {
guard let url = URL(string: config.apiHost.absoluteString + path) else {
completion(.failure(DuooomiBleError.transferFailed("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(error))
return
}
guard let http = resp as? HTTPURLResponse else {
completion(.failure(DuooomiBleError.transferFailed("无响应")))
return
}
guard (200...299).contains(http.statusCode) else {
let msg = Self.parseErrorMessage(data: data) ?? "HTTP \(http.statusCode)"
completion(.failure(DuooomiBleError.transferFailed(msg)))
return
}
// `{ success, data }` unwrap data
if let data = data, let unwrapped = Self.unwrapData(from: data) {
completion(.success(unwrapped))
} else {
completion(.success(data ?? Data()))
}
}.resume()
}
private static func unwrapData(from raw: Data) -> Data? {
guard let json = try? JSONSerialization.jsonObject(with: raw) as? [String: Any],
json["success"] as? Bool == true,
let inner = json["data"] else {
return nil
}
return try? JSONSerialization.data(withJSONObject: inner)
}
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
}
}
// MARK: - Response Model
/// `/firmware/upgrade/check`
/// ** `targetFirmware` **SDK `upgradeAvailable` / `reason`
/// client """"
public struct FirmwareUpgradeCheckResult: Codable, Equatable {
public let currentSystemVersion: String?
public let targetFirmware: TargetFirmware?
public struct TargetFirmware: Codable, Equatable {
/// ID`UpgradeRecord.firmwareId`
public let id: String
/// "1.2.3"UI + `UpgradeRecord.targetVersion`
public let version: String
/// URL `upgradeFirmware(fileUrl:)`
public let fileUrl: String?
/// Int64 UI xx MB
public let fileSize: String?
/// MD5SDK
public let fileMd5: String?
/// `true` /
/// `false` / `nil`
/// Optional Codable
public let forceUpgrade: Bool?
}
}

View 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
}
}

View File

@@ -0,0 +1,111 @@
import Foundation
/// OTA RN `bleStore.upgradeRecords` 1:1
/// - `(sn, firmwareId)` upsert
/// - 30 TTL `reported=true, outcome=nil`
/// - sn in-flight
final class UpgradeRecordStore {
private static let storageKey = "duooomi.firmware_upgrade_records"
/// 30
static let ttlMillis: Int64 = 30 * 24 * 60 * 60 * 1000
private let defaults: UserDefaults
private let queue = DispatchQueue(label: "duooomi.upgradeRecord", attributes: .concurrent)
private var records: [UpgradeRecord]
private var inFlight: Set<String> = []
/// mutation 线SDK delegate
var onChange: (([UpgradeRecord]) -> Void)?
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
if let data = defaults.data(forKey: Self.storageKey),
let decoded = try? JSONDecoder().decode([UpgradeRecord].self, from: data) {
self.records = decoded
} else {
self.records = []
}
}
// MARK: - Read
var all: [UpgradeRecord] {
queue.sync { records }
}
/// sn triggeredAt
func pending(sn: String) -> [UpgradeRecord] {
queue.sync {
records.filter { $0.sn == sn && !$0.reported }
.sorted { $0.triggeredAt < $1.triggeredAt }
}
}
// MARK: - Mutation
/// `(sn, firmwareId)` reported/outcome
func upsert(_ record: UpgradeRecord) {
guard !record.sn.isEmpty, !record.firmwareId.isEmpty else { return }
queue.async(flags: .barrier) {
if let idx = self.records.firstIndex(where: { $0.sn == record.sn && $0.firmwareId == record.firmwareId }) {
self.records[idx] = record
} else {
self.records.append(record)
}
self.persistLocked()
}
}
/// 线
func markReported(sn: String, firmwareId: String, outcome: UpgradeOutcome?) {
queue.async(flags: .barrier) {
guard let idx = self.records.firstIndex(where: { $0.sn == sn && $0.firmwareId == firmwareId }) else { return }
let original = self.records[idx]
self.records[idx] = UpgradeRecord(
sn: original.sn,
firmwareId: original.firmwareId,
fromVersion: original.fromVersion,
targetVersion: original.targetVersion,
triggeredAt: original.triggeredAt,
reported: true,
reportedAt: Self.nowMillis(),
outcome: outcome
)
self.persistLocked()
}
}
// MARK: - In-flight Mutex (sn )
/// sn false return
func tryAcquire(sn: String) -> Bool {
queue.sync(flags: .barrier) { () -> Bool in
if inFlight.contains(sn) { return false }
inFlight.insert(sn)
return true
}
}
func release(sn: String) {
queue.async(flags: .barrier) {
self.inFlight.remove(sn)
}
}
// MARK: - Helpers
static func nowMillis() -> Int64 {
Int64(Date().timeIntervalSince1970 * 1000)
}
/// + 线 onChange** barrier **
private func persistLocked() {
if let data = try? JSONEncoder().encode(records) {
defaults.set(data, forKey: Self.storageKey)
}
if let onChange = onChange {
let snapshot = records
DispatchQueue.main.async { onChange(snapshot) }
}
}
}