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

@@ -0,0 +1,127 @@
import Foundation
/// ISO 8601 `2026-05-07T16:58:00+08:00` bind / setPlayMode payload `time`
///
/// fallback
/// 1. API body`sysTime2` `sysTime1`
/// 2. response.headers["Date"]HTTP RFC 1123
/// 3. `Date()` + 8h
///
/// 5 bind/setPlayMode
final class BeijingTimeProvider {
private static let suningURL = URL(string: "https://quan.suning.com/getSysTime.do")!
private static let cacheTTL: TimeInterval = 5
private static let fetchTimeout: TimeInterval = 3
private static let beijingOffsetSeconds: TimeInterval = 8 * 3600
private let session: URLSession
private let queue = DispatchQueue(label: "duooomi.ble.beijingTime")
private var cached: (value: String, at: Date)?
private var inflight: [(String) -> Void] = []
private var fetching = false
init(session: URLSession = .shared) {
self.session = session
}
///
func fetch(completion: @escaping (String) -> Void) {
queue.async { [weak self] in
guard let self = self else { return }
if let cached = self.cached, Date().timeIntervalSince(cached.at) < Self.cacheTTL {
self.dispatch(cached.value, to: completion)
return
}
self.inflight.append(completion)
if self.fetching { return }
self.fetching = true
self.performFetch { value in
self.queue.async {
self.cached = (value, Date())
let pending = self.inflight
self.inflight = []
self.fetching = false
pending.forEach { self.dispatch(value, to: $0) }
}
}
}
}
private func dispatch(_ value: String, to completion: @escaping (String) -> Void) {
DispatchQueue.main.async { completion(value) }
}
private func performFetch(_ done: @escaping (String) -> Void) {
var req = URLRequest(url: Self.suningURL)
req.timeoutInterval = Self.fetchTimeout
req.httpMethod = "GET"
session.dataTask(with: req) { [weak self] data, resp, _ in
guard let self = self else { return }
if let data = data, let body = try? JSONDecoder().decode(SuningTimeBody.self, from: data),
let value = self.formatSuningBody(body) {
done(value)
return
}
if let http = resp as? HTTPURLResponse,
let dateHeader = (http.allHeaderFields["Date"] ?? http.allHeaderFields["date"]) as? String,
let value = self.formatHTTPDateHeader(dateHeader) {
done(value)
return
}
done(self.formatLocal())
}.resume()
}
private func formatSuningBody(_ body: SuningTimeBody) -> String? {
if let s = body.sysTime2,
s.range(of: #"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$"#, options: .regularExpression) != nil {
return s.replacingOccurrences(of: " ", with: "T") + "+08:00"
}
if let s = body.sysTime1, s.count == 14, Int(s) != nil {
let y = s.prefix(4)
let mo = s.dropFirst(4).prefix(2)
let d = s.dropFirst(6).prefix(2)
let h = s.dropFirst(8).prefix(2)
let mi = s.dropFirst(10).prefix(2)
let se = s.dropFirst(12).prefix(2)
return "\(y)-\(mo)-\(d)T\(h):\(mi):\(se)+08:00"
}
return nil
}
private func formatHTTPDateHeader(_ raw: String) -> String? {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(identifier: "GMT")
formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"
guard let date = formatter.date(from: raw) else { return nil }
return Self.formatBeijing(date)
}
private func formatLocal() -> String {
Self.formatBeijing(Date())
}
private static func formatBeijing(_ date: Date) -> String {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(secondsFromGMT: Int(beijingOffsetSeconds))!
let c = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: date)
return String(
format: "%04d-%02d-%02dT%02d:%02d:%02d+08:00",
c.year ?? 1970, c.month ?? 1, c.day ?? 1,
c.hour ?? 0, c.minute ?? 0, c.second ?? 0
)
}
}
private struct SuningTimeBody: Decodable {
let sysTime1: String?
let sysTime2: String?
}