Files
duooomi-ios-sdk/docs/superpowers/specs/2026-05-06-play-mode-toggle-design.md

222 lines
9.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 播放模式切换功能 — 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<BindingResponse>` —— 复用 `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<BindingResponse, Error>) -> 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 取 userIdiOS 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