OTA 烧录前主动拉新鲜 getDeviceInfo 取实时电量,<40% 直接以 .batteryTooLow(current:required:) 失败、不发任何字节,防烧录中途断电变砖。 新增常量 minBatteryForUpgrade=40;烧录体抽出私有 performFirmwareTransfer。 README 补可用方法/属性汇总表。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
210 lines
9.9 KiB
Swift
210 lines
9.9 KiB
Swift
import Foundation
|
||
|
||
// MARK: - Firmware OTA (检查 / 烧录 / 对账上报)
|
||
//
|
||
// 所有公开方法显式入参,不依赖 SDK 内部状态自动取参。
|
||
// 集成方负责从 `sdk.sn` / `sdk.version` / `sdk.firmwareUpgrade` 等只读属性组装参数。
|
||
|
||
extension DuooomiBleSDK {
|
||
|
||
/// OTA 烧录前置电量门槛(百分比)。低于此值拒绝下发,避免烧录中途断电变砖。
|
||
static let minBatteryForUpgrade = 40
|
||
|
||
/// 拉取服务端最新固件信息。**内部串两步业务编排**(顺序固定):
|
||
///
|
||
/// 1. **对账上报**:把本地 pending `UpgradeRecord` 逐条报给服务端(容错失败,不阻断)
|
||
/// 2. **拉服务端最新固件**:写入 `self.firmwareUpgrade`,通过 completion 回传
|
||
///
|
||
/// 软绑 / metadata 上报 / 硬绑失败回滚 → 已全部内聚到 `bind(userId:)`,本方法不再处理。
|
||
///
|
||
/// - Parameters:
|
||
/// - sn: 设备序列号(bind 成功后从 `sdk.sn` 取)
|
||
/// - currentVersion: 设备当前固件版本(从 `sdk.version` 取)
|
||
/// - userId: 当前用户 ID(写入对账上报的 failureReason)
|
||
public func fetchLatestFirmware(
|
||
sn: String,
|
||
currentVersion: String,
|
||
userId: String,
|
||
completion: @escaping (Result<FirmwareUpgradeCheckResult, Error>) -> Void
|
||
) {
|
||
guard !sn.isEmpty else {
|
||
DispatchQueue.main.async {
|
||
completion(.failure(DuooomiBleError.firmwareUpgradeUnavailable("sn is empty (bind first)")))
|
||
}
|
||
return
|
||
}
|
||
|
||
// Step 1: 对账上报历史 OTA 记录(容错失败,sn 级 in-flight 互斥)
|
||
self.tryReportPendingUpgrade(sn: sn, currentVersion: currentVersion, userId: userId)
|
||
|
||
// Step 2: 服务端 checkUpgrade
|
||
self.firmwareUpgradeService.checkUpgrade(sn: sn, currentVersion: currentVersion) { [weak self] result in
|
||
guard let self = self else { return }
|
||
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)) }
|
||
}
|
||
}
|
||
}
|
||
|
||
/// OTA 固件烧录。完成后自动落 `UpgradeRecord` 到本地(复合主键 `(sn, firmwareId)`),
|
||
/// 等待下次 `fetchLatestFirmware` 触发 `tryReportPendingUpgrade` 对账上报。
|
||
///
|
||
/// - Parameters:
|
||
/// - sn: 设备序列号(用于落 UpgradeRecord 的主键)
|
||
/// - fileUrl: 固件包下载 URL(一般从 `sdk.firmwareUpgrade?.targetFirmware?.fileUrl` 取)
|
||
/// - firmwareId: 目标固件 ID(一般从 `sdk.firmwareUpgrade?.targetFirmware?.id` 取)
|
||
/// - fromVersion: 升级前版本(一般从 `sdk.version` 取)
|
||
/// - targetVersion: 升级目标版本(一般从 `sdk.firmwareUpgrade?.targetFirmware?.version` 取)
|
||
/// - completion: 回调 `Result<Void, Error>`
|
||
/// - 成功:OTA 包已发完,设备会重启完成升级;UpgradeRecord 已落本地
|
||
/// - 失败:`.notConnected` / `.batteryTooLow(current:required:)`(电量 < `minBatteryForUpgrade`,
|
||
/// 未下发任何字节)/ `.transferFailed(...)` / `getDeviceInfo` 的 `.timeout` / 解码错误
|
||
/// - Important: 下发前会**主动拉一次新鲜 `getDeviceInfo`** 校验实时电量,低于
|
||
/// `minBatteryForUpgrade`(40%)直接以 `.batteryTooLow` 失败,不发任何 OTA 字节。
|
||
public func upgradeFirmware(
|
||
sn: String,
|
||
fileUrl: String,
|
||
firmwareId: String,
|
||
fromVersion: String,
|
||
targetVersion: String,
|
||
completion: @escaping (Result<Void, Error>) -> Void
|
||
) {
|
||
guard connectedDevice != nil else {
|
||
completion(.failure(DuooomiBleError.notConnected))
|
||
return
|
||
}
|
||
// OTA 前置电量校验:取实时电量(不用缓存,可能过期),低于门槛拒绝下发。
|
||
getDeviceInfo { [weak self] infoResult in
|
||
guard let self = self else { return }
|
||
switch infoResult {
|
||
case .failure(let error):
|
||
BleLog.w("OTA blocked: getDeviceInfo failed: \(error.localizedDescription)", "Firmware")
|
||
completion(.failure(error))
|
||
case .success(let info):
|
||
guard info.powerlevel >= Self.minBatteryForUpgrade else {
|
||
BleLog.w("OTA blocked: battery \(info.powerlevel)% < \(Self.minBatteryForUpgrade)%", "Firmware")
|
||
completion(.failure(DuooomiBleError.batteryTooLow(
|
||
current: info.powerlevel,
|
||
required: Self.minBatteryForUpgrade
|
||
)))
|
||
return
|
||
}
|
||
self.performFirmwareTransfer(
|
||
sn: sn,
|
||
fileUrl: fileUrl,
|
||
firmwareId: firmwareId,
|
||
fromVersion: fromVersion,
|
||
targetVersion: targetVersion,
|
||
completion: completion
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// `upgradeFirmware` 通过电量门槛后的实际烧录 + 落 `UpgradeRecord`。
|
||
private func performFirmwareTransfer(
|
||
sn: String,
|
||
fileUrl: String,
|
||
firmwareId: String,
|
||
fromVersion: String,
|
||
targetVersion: String,
|
||
completion: @escaping (Result<Void, Error>) -> Void
|
||
) {
|
||
BleLog.i("OTA upgrade start: sn=\(sn) \(fromVersion)→\(targetVersion) (fw=\(firmwareId))", "Firmware")
|
||
transferFile(fileUri: fileUrl, commandType: .otaPackage) { [weak self] result in
|
||
if case .success = result {
|
||
BleLog.i("OTA upgrade completed", "Firmware")
|
||
if let self = self,
|
||
!sn.isEmpty,
|
||
!firmwareId.isEmpty,
|
||
!fromVersion.isEmpty,
|
||
!targetVersion.isEmpty {
|
||
let record = UpgradeRecord(
|
||
sn: sn,
|
||
firmwareId: firmwareId,
|
||
fromVersion: fromVersion,
|
||
targetVersion: targetVersion,
|
||
triggeredAt: UpgradeRecordStore.nowMillis()
|
||
)
|
||
self.upgradeRecordStore.upsert(record)
|
||
BleLog.i("UpgradeRecord upserted: sn=\(sn), fw=\(firmwareId), \(fromVersion)→\(targetVersion)", "Firmware")
|
||
}
|
||
}
|
||
completion(result)
|
||
}
|
||
}
|
||
|
||
/// 升级对账上报:把本地 pending `UpgradeRecord` 逐条报给服务端。
|
||
///
|
||
/// `fetchLatestFirmware` 内部第一步会调,集成方一般无需直接调用。**fire-and-forget**——
|
||
/// 没有 completion 回调,结果通过 `sdk.upgradeRecords` 数组的 `reported` / `outcome` 字段反映。
|
||
///
|
||
/// 内部逻辑(对齐 expo `bleStore.tryReportPendingUpgrade`):
|
||
/// - 30 天 TTL:超期记录 → `markReported(outcome: nil)`,保留为审计轨迹
|
||
/// - 设备实际版本 == 目标版本 → `report-success` → `outcome: .success`
|
||
/// - 不命中 → `report-failure(VERSION_MISMATCH ...)` → `outcome: .versionMismatch`
|
||
/// - HTTP 失败 → 保持 `reported=false`,下次再重试
|
||
/// - sn 级 in-flight 互斥锁防止并发重复上报
|
||
///
|
||
/// - Parameters:
|
||
/// - sn: 设备序列号
|
||
/// - currentVersion: 设备当前固件版本(与 record.targetVersion 比对)
|
||
/// - userId: 当前用户 ID(写入 report 请求体)
|
||
public func tryReportPendingUpgrade(sn: String, currentVersion: String, userId: String) {
|
||
guard !sn.isEmpty, !currentVersion.isEmpty else { return }
|
||
guard upgradeRecordStore.tryAcquire(sn: sn) else { return }
|
||
|
||
let pending = upgradeRecordStore.pending(sn: sn)
|
||
let now = UpgradeRecordStore.nowMillis()
|
||
|
||
let group = DispatchGroup()
|
||
for r in pending {
|
||
if now - r.triggeredAt > UpgradeRecordStore.ttlMillis {
|
||
upgradeRecordStore.markReported(sn: r.sn, firmwareId: r.firmwareId, outcome: nil)
|
||
continue
|
||
}
|
||
group.enter()
|
||
if currentVersion == r.targetVersion {
|
||
firmwareUpgradeService.reportUpgradeSuccess(
|
||
sn: r.sn,
|
||
firmwareId: r.firmwareId,
|
||
fromVersion: r.fromVersion,
|
||
bindUserId: userId
|
||
) { [weak self] result in
|
||
if case .success = result {
|
||
self?.upgradeRecordStore.markReported(sn: r.sn, firmwareId: r.firmwareId, outcome: .success)
|
||
} else if case .failure(let err) = result {
|
||
BleLog.w("Report success failed for fw \(r.firmwareId): \(err.localizedDescription); will retry", "Firmware")
|
||
}
|
||
group.leave()
|
||
}
|
||
} else {
|
||
let reason = "VERSION_MISMATCH expected=\(r.targetVersion) actual=\(currentVersion) sn=\(r.sn) userId=\(userId)"
|
||
firmwareUpgradeService.reportUpgradeFailure(
|
||
sn: r.sn,
|
||
firmwareId: r.firmwareId,
|
||
fromVersion: r.fromVersion,
|
||
failureReason: reason
|
||
) { [weak self] result in
|
||
if case .success = result {
|
||
self?.upgradeRecordStore.markReported(sn: r.sn, firmwareId: r.firmwareId, outcome: .versionMismatch)
|
||
} else if case .failure(let err) = result {
|
||
BleLog.w("Report failure failed for fw \(r.firmwareId): \(err.localizedDescription); will retry", "Firmware")
|
||
}
|
||
group.leave()
|
||
}
|
||
}
|
||
}
|
||
group.notify(queue: .global()) { [weak self] in
|
||
self?.upgradeRecordStore.release(sn: sn)
|
||
}
|
||
}
|
||
}
|