222 lines
9.4 KiB
Markdown
222 lines
9.4 KiB
Markdown
# 播放模式切换功能 — 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 取 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
|