# 播放模式切换功能 — iOS SDK 同步方案 **目标**:把 `expo-duooomi-app`(commit `ac1520f`) 的播放模式切换功能完整同步到 `duooomi-ios-sdk`,保持协议层一致,让 SDK 调用方用 Swift 风格 API 完成"单播 ⇄ 循环播放"切换。 --- ## 1. 参考实现(Expo 端) 提交 `ac1520f` 改动 5 个文件,核心机制: | 层 | 文件 | 改动 | |---|---|---| | 协议类型 | `ble/protocol/types.ts` | `DeviceInfoZod` 增 `loop: 0\|1\|undefined`;`BindingRequestZod` 增可选 `loop` | | 服务层 | `ble/services/DeviceInfoService.ts` | 新增 `setPlayMode(deviceId, userId, loop)` —— 复用 `BIND_DEVICE`(0x0F) 命令字,带上 `loop` 字段 | | 管理层 | `ble/managers/bleManager.ts` | 新增 `setPlayMode(loop): Promise` —— 复用 `bindDevice` 的 waitable 机制 | | UI | `app/device.tsx` | 设备信息卡片中显示当前模式 + 切换按钮 + loading;切换后调 `getDeviceInfo` 刷新 | **关键协议事实**: - 复用 `0x0F`(BIND_DEVICE) 作命令字,请求 payload 中 `loop` 字段为可选 - 响应仍是 `BindingResponse`(含 `type/sn/success/contents`) - 设备返回的 `DeviceInfo` 中新增 `loop`(0=单播 / 1=循环),旧固件可能没这字段 - 设备能区分"绑定" vs "切播放模式"是因为 payload 里有无 `loop`,SDK 必须严格按"无 loop 时省略字段(不能传 null)"编码 - **必须先 `bind` 成功才能 `setPlayMode`**:固件设计上设置播放模式的前置条件是设备已和该 `userId` 完成绑定(`success=1`)。SDK 通过 `isActivated == true` 反映此状态;调用 `setPlayMode` 前必须确保已绑定,否则设备会回 `success=0` ## 2. iOS SDK 当前状态 | Swift 对应位置 | |---| | `Sources/DuooomiBleSDK/Models/DeviceInfo.swift` | | `Sources/DuooomiBleSDK/Services/DeviceInfoService.swift`(私有 `BindPayload`) | | `Sources/DuooomiBleSDK/DuooomiBleSDK.swift`(公开 `bind/unbind`) | | `demo/Sources/WrapperTestView.swift`(Device Commands 区) | `DuooomiBleSDK.sendAndWait` 已支持基于 `commandType.rawValue` 的请求-响应配对,新增能力可直接复用。 ## 3. 设计 ### 3.1 类型与协议 **新增枚举** `Sources/DuooomiBleSDK/Models/PlayMode.swift`: ```swift public enum PlayMode: Int, Codable { case single = 0 // 单播 case loop = 1 // 循环播放 } ``` 理由:暴露给业务层的 API 用强类型枚举,比 raw `Int` / `UInt8` 更安全;`rawValue` 直接对接协议层的 0/1 整数。 **修改** `Models/DeviceInfo.swift`: - 增字段 `public let loop: PlayMode?`(可选,兼容旧固件) - `init(from:)` 容错:缺字段或值非 0/1 时返回 `nil`,不抛错 ### 3.2 协议层 **修改** `Services/DeviceInfoService.swift`: 将私有 `BindPayload` 替换为可选 `loop` 字段,并自定义 `encode(to:)`: ```swift private struct BindPayload: Encodable { let type: UInt8 let userId: String let loop: UInt8? // nil 时编码省略 enum CodingKeys: String, CodingKey { case type, userId, loop } func encode(to encoder: Encoder) throws { var c = encoder.container(keyedBy: CodingKeys.self) try c.encode(type, forKey: .type) try c.encode(userId, forKey: .userId) if let loop = loop { try c.encode(loop, forKey: .loop) } } } ``` **关键约束**:默认 `JSONEncoder` 会把 `nil` 编成 `"loop": null`,与 Expo 端的 `zod.optional()` "省略" 行为不一致 —— 设备固件可能据 key 是否存在区分动作,必须用自定义 encode 严格省略 nil。`bindDevice` / `unbindDevice` 现有调用因 `loop=nil` 走相同路径,报文与改动前完全一致。 新增方法: ```swift func setPlayMode(userId: String, loop: PlayMode) throws { try protocolService.sendJSON( type: .bindDevice, payload: BindPayload( type: CommandType.bindDevice.rawValue, userId: userId, loop: UInt8(loop.rawValue) ) ) } ``` `unbindDevice` 用独立 `UnbindPayload`(已有 `BindPayload` 复用,可保留,或拆出对应 `UnbindPayload` 二选一 —— 当前选择继续复用 `BindPayload(loop:nil)`,最小改动)。 ### 3.3 SDK 公开 API **修改** `DuooomiBleSDK.swift`,在 `bind/unbind` 之间新增: ```swift /// 切换播放模式(0=单播 / 1=循环播放) /// 复用 BIND_DEVICE 命令通道;响应类型为 BindingResponse。 /// 成功后自动刷新一次 deviceInfo(与 expo 端行为对齐)。 public func setPlayMode( _ mode: PlayMode, userId: String, completion: @escaping (Result) -> Void ) { guard ensureConnected(completion: completion) else { return } guard isActivated else { BleLog.w("setPlayMode called before bind", "Command") completion(.failure(DuooomiBleError.bindingFailed("Device not bound; call bind(userId:) first"))) return } BleLog.d("Sending setPlayMode (mode=\(mode), userId=\(userId))", "Command") sendAndWait(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", "Command") completion(.failure(DuooomiBleError.bindingFailed("Set play mode failed"))) return } BleLog.i("setPlayMode success: mode=\(mode)", "Command") // 自动刷新 deviceInfo(含最新 loop 字段) self.getDeviceInfo { _ in completion(.success(resp)) } } catch { completion(.failure(error)) } } }, send: { try self.deviceInfoService.setPlayMode(userId: userId, loop: mode) }) } ``` **重要约束 — 命令字冲突**:`bind` / `unbind` / `setPlayMode` 三者不可并发执行(前两者已有此约束,因都用 `sendAndWait` 的 opId = `"15"` / `"18"`;新方法用 `"15"`,与 `bind` 互斥)。这是与 expo 端一致的协议级限制;后续如需并发,须在协议帧 / opId 维度引入区分维度,超出本次同步范围。在 API 文档注释中明确说明。 ### 3.4 Demo 应用 **修改** `demo/Sources/WrapperTestView.swift`,在 `bind / unbind` 按钮行下加一行: ```swift HStack { Button("Single (0)") { runSetPlayMode(.single) } Button("Loop (1)") { runSetPlayMode(.loop) } if let mode = viewModel.deviceInfo?.loop { Text("now: \(mode == .loop ? "循环" : "单播")") .font(.caption) .foregroundColor(.secondary) } } .buttonStyle(.bordered) .disabled(viewModel.connectedDevice == nil || isBusy) ``` 新增方法: ```swift private func runSetPlayMode(_ mode: PlayMode) { isBusy = true log("→ setPlayMode(\(mode), userId=\(userId))") viewModel.sdk.setPlayMode(mode, userId: userId) { [self] result in DispatchQueue.main.async { self.isBusy = false switch result { case .success(let resp): self.log("setPlayMode ✓ sn=\(resp.sn)", level: .success) case .failure(let error): self.log("setPlayMode ✗ \(error.localizedDescription)", level: .error) } } } } ``` `viewModel` 需暴露 `deviceInfo`(如未暴露则增 `@Published var deviceInfo: DeviceInfo?`,在 `didUpdateDeviceInfo` 回调里更新 —— 已存在则跳过)。 ## 4. 改动文件清单 | # | 文件 | 改动类型 | |---|---|---| | 1 | `Sources/DuooomiBleSDK/Models/PlayMode.swift` | 新建 | | 2 | `Sources/DuooomiBleSDK/Models/DeviceInfo.swift` | 修改:增 `loop: PlayMode?` 字段及解码容错 | | 3 | `Sources/DuooomiBleSDK/Services/DeviceInfoService.swift` | 修改:`BindPayload` 增 `loop` + 自定义 encode;新增 `setPlayMode` 方法 | | 4 | `Sources/DuooomiBleSDK/DuooomiBleSDK.swift` | 修改:新增公开 `setPlayMode(_:userId:completion:)` | | 5 | `demo/Sources/WrapperTestView.swift` | 修改:UI 按钮(仅 `isActivated` 时启用)+ `runSetPlayMode` | | 6 | `README.md` | 修改:在 API 章节新增 `setPlayMode` 段落,**显著标注前置条件 — 必须先 `bind` 成功** | ## 5. 验证 1. **编译**:`xcodebuild -workspace demo.xcworkspace -scheme demo -sdk iphonesimulator build` 2. **协议字节比对**:在真机上抓包/或加日志看发送的 JSON: - `bind(userId)` → `{"type":15,"userId":"..."}`(无 loop 字段) - `setPlayMode(.loop, userId)` → `{"type":15,"userId":"...","loop":1}` - `unbind(userId)` → `{"type":18,"userId":"..."}`(不受影响) 3. **真机功能**:连接设备 → bind → setPlayMode(.loop) → 设备模式确实切到循环;getDeviceInfo 返回的 `loop` 字段为 `.loop` 4. **回归**:现有 `bind` / `unbind` 行为不变(payload 不变) ## 6. 不在本次范围 - iOS 端不需要 `userStore` —— Expo 端从全局 store 取 userId,iOS SDK 设计上由调用方(demo/上层 App)传入 `userId`,与 `bind/unbind` 一致 - 自动重连后的播放模式自动恢复(expo 端也无此能力) - 多模式(>2)扩展 —— 当前协议只定义 0/1 --- **来源**: - expo-duooomi-app commit `ac1520f`(2026-04-20) - duooomi-ios-sdk @ `f42944b`(2026-05-06 当前 HEAD) - 对话日期:2026-05-06