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) -> 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 ) { 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 ) { 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) -> 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.brand, 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? /// 固件包 MD5。集成方有需要可校验下载内容;SDK 不做校验。 public let fileMd5: String? /// 强制升级标记。`true` 表示该版本必须升级(典型场景:安全补丁 / 协议破坏性变更), /// 集成方应据此弹不可关闭的升级弹窗、阻断设备其他操作;`false` / `nil` 由用户自行决定。 /// 服务端可能不返回此字段(旧固件版本),用 Optional 做 Codable 兜底。 public let forceUpgrade: Bool? } }