- bind(userId:) 内聚为硬绑 + getVersion + 软绑 + updateMetadata 一次原子调用, 软绑失败自动硬解绑回滚 + 清状态;集成方无需感知双层绑定 - fetchLatestFirmware 简化为对账上报 + checkUpgrade 两步,不再重复软绑 - VersionInfo 增加 log: [String: Any]?,走 JSONSerialization 解析(放弃 Codable); SDK 暴露只读 versionLog,断连/解绑/软绑回滚时清空 - ShipmentSnDeviceService 新增 updateMetadata(sn:data:),抽出 performPOST 共享 HTTP 编排,新增 postAnyJSON 支持 [String: Any] body - 老固件不返回 log → versionLog == nil → 自动跳过 updateMetadata,前向兼容 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
206 lines
9.8 KiB
Swift
206 lines
9.8 KiB
Swift
import Foundation
|
||
|
||
// MARK: - Device Commands (无业务编排,纯协议层 RPC)
|
||
|
||
extension DuooomiBleSDK {
|
||
|
||
/// 读取设备信息(品牌、屏幕尺寸、电量、存储、播放模式等)。
|
||
///
|
||
/// 成功后同步写入 `sdk.deviceInfo` 并通过 delegate `didUpdateDeviceInfo` 回调。
|
||
///
|
||
/// - Parameter completion: 回调 `Result<DeviceInfo, Error>`
|
||
/// - 成功:`DeviceInfo`(含 `name` / `brand` / `size` / `powerlevel` / `allspace` / `freespace` / `loop?`)
|
||
/// - 失败:`.notConnected` / `.timeout(.getDeviceInfo)` / JSON 解码错误
|
||
public func getDeviceInfo(completion: @escaping (Result<DeviceInfo, Error>) -> Void) {
|
||
guard ensureConnected(completion: completion) else { return }
|
||
BleLog.d("Sending getDeviceInfo", "Command")
|
||
|
||
sendAndWait(commandType: .getDeviceInfo, completion: { [weak self] result in
|
||
guard let self = self else { return }
|
||
switch result {
|
||
case .failure(let error):
|
||
completion(.failure(error))
|
||
case .success(let data):
|
||
do {
|
||
let info = try self.decodeResponse(DeviceInfo.self, from: data)
|
||
BleLog.i("DeviceInfo received: name=\(info.name), brand=\(info.brand)", "Command")
|
||
self.deviceInfo = info
|
||
completion(.success(info))
|
||
} catch {
|
||
completion(.failure(error))
|
||
}
|
||
}
|
||
}, send: {
|
||
try self.deviceInfoService.getDeviceInfo()
|
||
})
|
||
}
|
||
|
||
/// 读取设备固件版本。
|
||
///
|
||
/// 成功后同步写入 `sdk.version` 并通过 delegate `didUpdateVersion` 回调。
|
||
/// `bind()` 内部已经在硬绑成功后自动调用一次,集成方一般不用单独调。
|
||
///
|
||
/// - Parameter completion: 回调 `Result<VersionInfo, Error>`
|
||
/// - 成功:`VersionInfo`(含 `version` / `type` / `log?`)
|
||
/// - 失败:`.notConnected` / `.timeout(.getDeviceVersion)` / JSON 解码错误
|
||
/// - Note: `log` 字段是设备返回的任意 JSON 对象,SDK 不解析其内部,存到 `sdk.versionLog`,
|
||
/// `fetchLatestFirmware` 内部会透传给 shipment-sn `update-metadata` 接口。
|
||
public func getVersion(completion: @escaping (Result<VersionInfo, Error>) -> Void) {
|
||
guard ensureConnected(completion: completion) else { return }
|
||
BleLog.d("Sending getDeviceVersion", "Command")
|
||
|
||
sendAndWait(commandType: .getDeviceVersion, completion: { [weak self] result in
|
||
guard let self = self else { return }
|
||
switch result {
|
||
case .failure(let error):
|
||
completion(.failure(error))
|
||
case .success(let data):
|
||
do {
|
||
let info = try VersionInfo.parse(from: data)
|
||
self.version = info.version
|
||
self.versionLog = info.log
|
||
BleLog.i("Version received: \(info.version) (type=\(info.type), log=\(info.log != nil ? "present" : "nil"))", "Command")
|
||
completion(.success(info))
|
||
} catch {
|
||
let raw = String(data: data.filter { $0 != 0 }, encoding: .utf8) ?? "<non-utf8, \(data.count) bytes>"
|
||
BleLog.e("VersionInfo parse failed: \(error.localizedDescription)\nRaw: \(raw)", "Decode")
|
||
completion(.failure(error))
|
||
}
|
||
}
|
||
}, send: {
|
||
try self.deviceInfoService.getDeviceVersion()
|
||
})
|
||
}
|
||
|
||
/// 切换播放模式(单播 / 循环)。
|
||
///
|
||
/// 复用 `BIND_DEVICE` (0x0F) 命令通道;响应类型为 `BindingResponse`。
|
||
/// 成功后自动刷新一次 `deviceInfo`(含最新 `loop` 字段),与 expo 端行为对齐。
|
||
///
|
||
/// - Parameters:
|
||
/// - mode: `.single`(0)=单播一次停 / `.loop`(1)=循环播放
|
||
/// - userId: 当前用户 ID(必须和已绑定的 userId 一致)
|
||
/// - completion: 回调 `Result<BindingResponse, Error>`
|
||
/// - 成功:`BindingResponse`(`success=1` / `sn` / `contents[]`)
|
||
/// - 失败:`.notConnected` / `.bindingFailed("Device not bound; call bind(userId:) first")`
|
||
/// / `.bindingFailed("Set play mode failed")` / `.timeout(.bindDevice)`
|
||
/// - Important: 必须先 `bind(userId:)` 完成(`isActivated == true`),否则设备会返回 `success=0`,
|
||
/// SDK 在客户端先做守卫,直接返回失败而不发命令。
|
||
/// - Note: `bind` / `unbind` / `setPlayMode` 共享 `0x0F` / `0x12` opId 通道,**不能并发**。
|
||
public func setPlayMode(
|
||
_ mode: PlayMode,
|
||
userId: String,
|
||
completion: @escaping (Result<BindingResponse, Error>) -> Void
|
||
) {
|
||
guard ensureConnected(completion: completion) else { return }
|
||
guard isActivated else {
|
||
BleLog.w("setPlayMode called before bind (isActivated=false)", "Command")
|
||
DispatchQueue.main.async {
|
||
completion(.failure(DuooomiBleError.bindingFailed("Device not bound; call bind(userId:) first")))
|
||
}
|
||
return
|
||
}
|
||
BleLog.d("Sending setPlayMode (mode=\(mode), userId=\(userId))", "Command")
|
||
|
||
sendAsyncAndWait(commandType: .bindDevice, completion: { [weak self] result in
|
||
guard let self = self else { return }
|
||
switch result {
|
||
case .failure(let error):
|
||
completion(.failure(error))
|
||
case .success(let data):
|
||
do {
|
||
let resp = try self.decodeResponse(BindingResponse.self, from: data)
|
||
if resp.success != 1 {
|
||
BleLog.w("setPlayMode failed: device rejected", "Command")
|
||
completion(.failure(DuooomiBleError.bindingFailed("Set play mode failed")))
|
||
return
|
||
}
|
||
BleLog.i("setPlayMode success: mode=\(mode)", "Command")
|
||
self.getDeviceInfo { _ in
|
||
completion(.success(resp))
|
||
}
|
||
} catch {
|
||
completion(.failure(error))
|
||
}
|
||
}
|
||
}, send: { done in
|
||
self.deviceInfoService.setPlayMode(userId: userId, loop: mode, completion: done)
|
||
})
|
||
}
|
||
|
||
/// 删除设备上的文件。
|
||
///
|
||
/// - Parameters:
|
||
/// - key: 文件 key(绑定后从 `BindingResponse.contents` 取得)
|
||
/// - completion: 回调 `Result<DeleteFileResponse, Error>`
|
||
/// - 成功:`DeleteFileResponse(success: 0)`(设备约定 0 表示成功)
|
||
/// - 失败:`.notConnected` / `.deleteFileFailed(status:)`(status 非 0)
|
||
/// / `.timeout(.deleteFile)` / JSON 解码错误
|
||
public func deleteFile(key: String, completion: @escaping (Result<DeleteFileResponse, Error>) -> Void) {
|
||
guard ensureConnected(completion: completion) else { return }
|
||
BleLog.d("Sending deleteFile (key=\(key))", "Command")
|
||
|
||
sendAndWait(commandType: .deleteFile, completion: { [weak self] result in
|
||
guard let self = self else { return }
|
||
switch result {
|
||
case .failure(let error):
|
||
completion(.failure(error))
|
||
case .success(let data):
|
||
do {
|
||
let resp = try self.decodeResponse(DeleteFileResponse.self, from: data)
|
||
if resp.success != 0 {
|
||
BleLog.w("Delete failed with status=\(resp.success)", "Command")
|
||
completion(.failure(DuooomiBleError.deleteFileFailed(status: resp.success)))
|
||
} else {
|
||
BleLog.i("Delete success (key=\(key))", "Command")
|
||
completion(.success(resp))
|
||
}
|
||
} catch {
|
||
completion(.failure(error))
|
||
}
|
||
}
|
||
}, send: {
|
||
try self.deviceInfoService.deleteFile(key: key)
|
||
})
|
||
}
|
||
|
||
/// 传输前的预协商:让设备校验存储空间、key 是否冲突等。
|
||
///
|
||
/// 一般集成方不直接调用,`transferMedia(fileUrl:)` 内部已串好。
|
||
///
|
||
/// - Parameters:
|
||
/// - key: 文件 key(一般用原始 URL)
|
||
/// - size: 待写入文件字节数
|
||
/// - completion: 回调 `Result<PrepareTransferResponse, Error>`
|
||
/// - 成功:`PrepareTransferResponse(status: "ready", key)`
|
||
/// - 失败:`.notConnected` / `.prepareTransferFailed(status:)` / `.timeout(.prepareTransfer)`
|
||
public func prepareTransfer(key: String, size: Int, completion: @escaping (Result<PrepareTransferResponse, Error>) -> Void) {
|
||
guard ensureConnected(completion: completion) else { return }
|
||
let opId = "\(CommandType.prepareTransfer.rawValue)_\(key)"
|
||
BleLog.d("Sending prepareTransfer (key=\(key), size=\(size))", "Transfer")
|
||
|
||
sendAndWait(opId: opId, commandType: .prepareTransfer, completion: { [weak self] result in
|
||
guard let self = self else { return }
|
||
switch result {
|
||
case .failure(let error):
|
||
completion(.failure(error))
|
||
case .success(let data):
|
||
do {
|
||
let resp = try self.decodeResponse(PrepareTransferResponse.self, from: data)
|
||
if resp.status != "ready" {
|
||
BleLog.w("PrepareTransfer not ready: status=\(resp.status)", "Transfer")
|
||
completion(.failure(DuooomiBleError.prepareTransferFailed(status: resp.status)))
|
||
} else {
|
||
BleLog.i("PrepareTransfer ready (key=\(key))", "Transfer")
|
||
completion(.success(resp))
|
||
}
|
||
} catch {
|
||
completion(.failure(error))
|
||
}
|
||
}
|
||
}, send: {
|
||
try self.deviceInfoService.prepareTransfer(key: key, size: size)
|
||
})
|
||
}
|
||
}
|