docs: spec & plan for play-mode toggle feature

This commit is contained in:
km2023
2026-05-06 10:27:38 +08:00
parent f42944bb58
commit 40538da06d
2 changed files with 1133 additions and 0 deletions

View File

@@ -0,0 +1,912 @@
# 播放模式切换功能 — iOS SDK 实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 把 expo-duooomi-app commit `ac1520f` 的播放模式切换能力同步到 duooomi-ios-sdk —— 在协议字节层完全对齐 Expo 端的同时,提供 Swift 风格 `setPlayMode(_:userId:completion:)` API并在 demo / README 中体现"必须先 bind 才能切模式"的前置约束。
**Architecture:** 复用 `BIND_DEVICE` (0x0F) 命令字 —— 请求 payload 中携带 `loop` 字段切播放模式(不带则是绑定)。响应仍是 `BindingResponse`。SDK 在 `bind/unbind` 之间新增 `setPlayMode` 公共方法,复用现有 `sendAndWait` 机制;`BindPayload` 自定义 `encode(to:)` 严格省略 `nil`,避免输出 `"loop": null` 干扰固件判定。设备返回的 `DeviceInfo` 增可选 `loop` 字段(旧固件无此字段时为 `nil`)。
**Tech Stack:** Swift 5.0+ / SwiftPM / XCTest / SwiftUI / CoreBluetooth / XcodeGen
**参考 spec** `docs/superpowers/specs/2026-05-06-play-mode-toggle-design.md`
---
## File Structure
| 文件 | 责任 | 改动类型 |
|------|------|----------|
| `Package.swift` | SwiftPM 入口,统一 tools-version 语法 | 修(前置) |
| `Sources/DuooomiBleSDK/Models/PlayMode.swift` | 播放模式枚举(公开类型) | 新建 |
| `Sources/DuooomiBleSDK/Models/DeviceInfo.swift` | 设备信息 model 增可选 `loop` | 修 |
| `Sources/DuooomiBleSDK/Services/DeviceInfoService.swift` | 命令服务:`BindPayload` 增可选 `loop` + 自定义 encode新增 `setPlayMode` | 修 |
| `Sources/DuooomiBleSDK/DuooomiBleSDK.swift` | SDK 公开 API`setPlayMode(_:userId:completion:)`,含 `isActivated` 前置守卫 | 修 |
| `Tests/DuooomiBleSDKTests/PlayModeTests.swift` | `PlayMode` / `DeviceInfo.loop` / `BindPayload` 编码三组单元测试 | 新建 |
| `demo/Sources/WrapperTestView.swift` | UI Single/Loop 按钮,`isActivated == false` 时禁用 | 修 |
| `README.md` | API 章节增 "播放模式" 段落,**显著标注前置条件** | 修 |
`demo/Sources/DemoApp.swift` 已暴露 `@Published deviceInfo`/`isActivated`,无需改动。
---
### Task 0: 修复 SwiftPM tools-version 语法
**背景:** 当前 `Package.swift` 第一行 `// swift-tools-version: 5.0``:` 后含空格 —— 此语法仅 SwiftPM 5.4+ 支持。本机 Swift 6.3 工具链解析直接失败,`swift test` 无法启动;后续所有 TDD 步骤都依赖此命令。先修。
**Files:**
- Modify: `Package.swift:1`
- [ ] **Step 1: 编辑文件,去掉冒号后空格**
把第 1 行改成:
```swift
// swift-tools-version:5.0
```
- [ ] **Step 2: 验证 SwiftPM 能解析、占位测试能跑通**
Run在仓库根目录:
```bash
swift test 2>&1 | tail -10
```
Expected: 编译通过,输出包含 `Test Suite 'PlaceholderTests' passed` 或类似 `Executed 1 test, with 0 failures`
- [ ] **Step 3: 提交**
```bash
git add Package.swift
git commit -m "fix: tighten swift-tools-version directive for SwiftPM 5.0 parser"
```
---
### Task 1: 新增 `PlayMode` 枚举
**Files:**
- Create: `Sources/DuooomiBleSDK/Models/PlayMode.swift`
- Create: `Tests/DuooomiBleSDKTests/PlayModeTests.swift`
- [ ] **Step 1: 写失败测试**
新建 `Tests/DuooomiBleSDKTests/PlayModeTests.swift`
```swift
import XCTest
@testable import DuooomiBleSDK
final class PlayModeTests: XCTestCase {
func testRawValues() {
XCTAssertEqual(PlayMode.single.rawValue, 0)
XCTAssertEqual(PlayMode.loop.rawValue, 1)
}
func testInitFromRawValue() {
XCTAssertEqual(PlayMode(rawValue: 0), .single)
XCTAssertEqual(PlayMode(rawValue: 1), .loop)
XCTAssertNil(PlayMode(rawValue: 2))
}
func testCodableRoundTrip() throws {
let data = try JSONEncoder().encode(PlayMode.loop)
XCTAssertEqual(String(data: data, encoding: .utf8), "1")
let decoded = try JSONDecoder().decode(PlayMode.self, from: Data("0".utf8))
XCTAssertEqual(decoded, .single)
}
}
```
- [ ] **Step 2: 运行测试,确认失败**
Run:
```bash
swift test --filter PlayModeTests 2>&1 | tail -15
```
Expected: 编译失败 `cannot find 'PlayMode' in scope`
- [ ] **Step 3: 实现枚举**
新建 `Sources/DuooomiBleSDK/Models/PlayMode.swift`
```swift
import Foundation
/// `loop`
///
/// - `single` (0):
/// - `loop` (1):
public enum PlayMode: Int, Codable, Equatable {
case single = 0
case loop = 1
}
```
- [ ] **Step 4: 运行测试,确认通过**
Run:
```bash
swift test --filter PlayModeTests 2>&1 | tail -15
```
Expected: `Executed 3 tests, with 0 failures`
- [ ] **Step 5: 提交**
```bash
git add Sources/DuooomiBleSDK/Models/PlayMode.swift Tests/DuooomiBleSDKTests/PlayModeTests.swift
git commit -m "feat: add PlayMode enum (single=0, loop=1)"
```
---
### Task 2: `DeviceInfo` 增可选 `loop` 字段
**Files:**
- Modify: `Sources/DuooomiBleSDK/Models/DeviceInfo.swift`
- Modify: `Tests/DuooomiBleSDKTests/PlayModeTests.swift`
- [ ] **Step 1: 写失败测试**
`Tests/DuooomiBleSDKTests/PlayModeTests.swift` 文件末尾追加新测试类:
```swift
final class DeviceInfoLoopTests: XCTestCase {
private func decode(_ json: String) throws -> DeviceInfo {
try JSONDecoder().decode(DeviceInfo.self, from: Data(json.utf8))
}
func testLoopMissingDecodesToNil() throws {
let json = #"{"name":"d1","size":"360","brand":"duomi","powerlevel":80,"allspace":1024,"freespace":512}"#
let info = try decode(json)
XCTAssertNil(info.loop)
}
func testLoopZeroDecodesToSingle() throws {
let json = #"{"name":"d1","size":"360","brand":"duomi","powerlevel":80,"allspace":1024,"freespace":512,"loop":0}"#
let info = try decode(json)
XCTAssertEqual(info.loop, .single)
}
func testLoopOneDecodesToLoop() throws {
let json = #"{"name":"d1","size":"360","brand":"duomi","powerlevel":80,"allspace":1024,"freespace":512,"loop":1}"#
let info = try decode(json)
XCTAssertEqual(info.loop, .loop)
}
func testLoopInvalidValueDecodesToNil() throws {
// / 0/1 nil
let json = #"{"name":"d1","size":"360","brand":"duomi","powerlevel":80,"allspace":1024,"freespace":512,"loop":7}"#
let info = try decode(json)
XCTAssertNil(info.loop)
}
}
```
- [ ] **Step 2: 运行测试,确认失败**
Run:
```bash
swift test --filter DeviceInfoLoopTests 2>&1 | tail -15
```
Expected: 4 个测试全部失败 — `value of type 'DeviceInfo' has no member 'loop'`
- [ ] **Step 3: 修改 model**
替换 `Sources/DuooomiBleSDK/Models/DeviceInfo.swift` 全文为:
```swift
import Foundation
public struct DeviceInfo: Equatable {
public let allspace: UInt64
public let freespace: UInt64
public let name: String
public let size: String
public let brand: String
public let powerlevel: Int
/// nil
public let loop: PlayMode?
}
extension DeviceInfo: Codable {
enum CodingKeys: String, CodingKey {
case allspace, freespace, name, size, brand, powerlevel, loop
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
// All string fields: fallback to "" if missing
name = (try? container.decode(String.self, forKey: .name)) ?? ""
size = (try? container.decode(String.self, forKey: .size)) ?? ""
brand = (try? container.decode(String.self, forKey: .brand)) ?? ""
// powerlevel: Int, may come as Int or String
if let num = try? container.decode(Int.self, forKey: .powerlevel) {
powerlevel = num
} else if let str = try? container.decode(String.self, forKey: .powerlevel),
let num = Int(str) {
powerlevel = num
} else {
powerlevel = 0
}
// allspace/freespace: may come as number, string, or be missing
allspace = Self.decodeFlexibleUInt64(container: container, key: .allspace)
freespace = Self.decodeFlexibleUInt64(container: container, key: .freespace)
// loop: optional. Accept Int 0/1; anything else nil.
if let raw = try? container.decode(Int.self, forKey: .loop), let mode = PlayMode(rawValue: raw) {
loop = mode
} else {
loop = nil
}
}
private static func decodeFlexibleUInt64(
container: KeyedDecodingContainer<CodingKeys>,
key: CodingKeys
) -> UInt64 {
if let value = try? container.decode(UInt64.self, forKey: key) {
return value
}
if let value = try? container.decode(Int.self, forKey: key) {
return UInt64(max(0, value))
}
if let str = try? container.decode(String.self, forKey: key),
let value = UInt64(str) {
return value
}
return 0
}
}
```
- [ ] **Step 4: 运行测试,确认通过**
Run:
```bash
swift test --filter DeviceInfoLoopTests 2>&1 | tail -15
```
Expected: `Executed 4 tests, with 0 failures`
- [ ] **Step 5: 提交**
```bash
git add Sources/DuooomiBleSDK/Models/DeviceInfo.swift Tests/DuooomiBleSDKTests/PlayModeTests.swift
git commit -m "feat: add optional loop field to DeviceInfo"
```
---
### Task 3: `BindPayload` 支持可选 `loop`(自定义 encode 严格省略 nil
**背景:** 这是协议字节层最关键的一步。Expo 端用 `zod.optional()``loop === undefined` 时省略字段。Swift 默认 `JSONEncoder``Optional<UInt8>``nil` 时输出 `"loop": null` —— 与 Expo 行为不一致,可能让设备误把"普通绑定"识别为"切模式"。必须自定义 `encode(to:)``loop == nil` 时不写入 key。
**Files:**
- Modify: `Sources/DuooomiBleSDK/Services/DeviceInfoService.swift`
- Modify: `Tests/DuooomiBleSDKTests/PlayModeTests.swift`
- [ ] **Step 1: 把 `BindPayload` 暴露为 internal写失败测试**
`Tests/DuooomiBleSDKTests/PlayModeTests.swift` 文件末尾追加:
```swift
final class BindPayloadEncodingTests: XCTestCase {
private func encode(_ payload: BindPayload) throws -> [String: Any] {
let data = try JSONEncoder().encode(payload)
return try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:]
}
func testBindPayloadWithoutLoopOmitsKey() throws {
let payload = BindPayload(type: 0x0F, userId: "u1", loop: nil)
let dict = try encode(payload)
XCTAssertEqual(dict["type"] as? Int, 0x0F)
XCTAssertEqual(dict["userId"] as? String, "u1")
XCTAssertNil(dict["loop"], "loop key MUST be omitted when nil (not encoded as null)")
XCTAssertEqual(dict.count, 2)
}
func testBindPayloadWithLoopIncludesValue() throws {
let payload = BindPayload(type: 0x0F, userId: "u1", loop: 1)
let dict = try encode(payload)
XCTAssertEqual(dict["type"] as? Int, 0x0F)
XCTAssertEqual(dict["userId"] as? String, "u1")
XCTAssertEqual(dict["loop"] as? Int, 1)
XCTAssertEqual(dict.count, 3)
}
func testBindPayloadWithLoopZeroIncludesValue() throws {
let payload = BindPayload(type: 0x0F, userId: "u1", loop: 0)
let dict = try encode(payload)
XCTAssertEqual(dict["loop"] as? Int, 0)
}
}
```
- [ ] **Step 2: 运行测试,确认失败**
Run:
```bash
swift test --filter BindPayloadEncodingTests 2>&1 | tail -15
```
Expected: 编译失败 `cannot find 'BindPayload' in scope``'BindPayload' is inaccessible due to 'private' protection level`(因为当前 `BindPayload``private struct`)。
- [ ] **Step 3: 修改服务层 — 把 `BindPayload` 提升为 internal、增可选 `loop`、自定义 encode、新增 `setPlayMode`**
替换 `Sources/DuooomiBleSDK/Services/DeviceInfoService.swift` 全文为:
```swift
import Foundation
/// JSON fire-and-forget
final class DeviceInfoService {
private let protocolService: BleProtocolService
init(protocolService: BleProtocolService) {
self.protocolService = protocolService
}
// MARK: - Commands
func getDeviceInfo() throws {
try protocolService.sendJSON(
type: .getDeviceInfo,
payload: CommandPayload(type: CommandType.getDeviceInfo.rawValue)
)
}
func getDeviceVersion() throws {
try protocolService.sendJSON(
type: .getDeviceVersion,
payload: CommandPayload(type: CommandType.getDeviceVersion.rawValue)
)
}
func bindDevice(userId: String) throws {
try protocolService.sendJSON(
type: .bindDevice,
payload: BindPayload(type: CommandType.bindDevice.rawValue, userId: userId, loop: nil)
)
}
func unbindDevice(userId: String) throws {
try protocolService.sendJSON(
type: .unbindDevice,
payload: BindPayload(type: CommandType.unbindDevice.rawValue, userId: userId, loop: nil)
)
}
/// BIND_DEVICE 0x0Fpayload loop
func setPlayMode(userId: String, loop: PlayMode) throws {
try protocolService.sendJSON(
type: .bindDevice,
payload: BindPayload(
type: CommandType.bindDevice.rawValue,
userId: userId,
loop: UInt8(loop.rawValue)
)
)
}
func deleteFile(key: String) throws {
try protocolService.sendJSON(
type: .deleteFile,
payload: FileKeyPayload(type: CommandType.deleteFile.rawValue, key: key)
)
}
func prepareTransfer(key: String, size: Int) throws {
try protocolService.sendJSON(
type: .prepareTransfer,
payload: PrepareTransferPayload(
type: CommandType.prepareTransfer.rawValue,
key: key,
size: size
)
)
}
}
// MARK: - Command Payloads
private struct CommandPayload: Encodable {
let type: UInt8
}
/// / / payload
/// `loop == nil` null Expo zod.optional
struct BindPayload: Encodable {
let type: UInt8
let userId: String
let loop: UInt8?
enum CodingKeys: String, CodingKey {
case type, userId, loop
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(type, forKey: .type)
try container.encode(userId, forKey: .userId)
if let loop = loop {
try container.encode(loop, forKey: .loop)
}
}
}
private struct FileKeyPayload: Encodable {
let type: UInt8
let key: String
}
private struct PrepareTransferPayload: Encodable {
let type: UInt8
let key: String
let size: Int
}
```
- [ ] **Step 4: 运行测试,确认通过**
Run:
```bash
swift test --filter BindPayloadEncodingTests 2>&1 | tail -15
```
Expected: `Executed 3 tests, with 0 failures`
再跑全部测试确认未破坏其他用例:
```bash
swift test 2>&1 | tail -15
```
Expected: 所有 test class 通过(`PlaceholderTests` + `PlayModeTests` + `DeviceInfoLoopTests` + `BindPayloadEncodingTests`,共 11 个测试)。
- [ ] **Step 5: 提交**
```bash
git add Sources/DuooomiBleSDK/Services/DeviceInfoService.swift Tests/DuooomiBleSDKTests/PlayModeTests.swift
git commit -m "feat: extend BindPayload with optional loop field; add setPlayMode service"
```
---
### Task 4: SDK 公开 `setPlayMode` API含 `isActivated` 前置守卫)
**背景:** SDK 层方法依赖真实 BLE 通信,无法在 SwiftPM 单测里全程跑通。能单测的部分:**未连接 / 未绑定** 时的前置守卫直接返回错误,不发送任何命令。其余依赖 demo 真机验证。
**Files:**
- Modify: `Sources/DuooomiBleSDK/DuooomiBleSDK.swift:295-322`(在 `bind``unbind` 之间插入)
- Modify: `Tests/DuooomiBleSDKTests/PlayModeTests.swift`
- [ ] **Step 1: 写失败测试 — 仅覆盖前置守卫**
`Tests/DuooomiBleSDKTests/PlayModeTests.swift` 文件末尾追加:
```swift
final class SetPlayModeGuardTests: XCTestCase {
private func makeSDK() -> DuooomiBleSDK {
DuooomiBleSDK(config: DuooomiBleConfig(apiKey: "test"))
}
func testSetPlayModeFailsWhenNotConnected() {
let sdk = makeSDK()
let exp = expectation(description: "completion")
sdk.setPlayMode(.loop, userId: "u1") { result in
switch result {
case .success:
XCTFail("expected failure when not connected")
case .failure(let error):
XCTAssertTrue("\(error)".lowercased().contains("connect"),
"expected notConnected-style error, got \(error)")
}
exp.fulfill()
}
wait(for: [exp], timeout: 1.0)
}
}
```
注:未绑定时的守卫测试需要 mock `connectedDevice` —— 当前 SDK 架构难以注入,留给真机验证。本测试只守住"未连接"这一最廉价但最常见的误用路径。
- [ ] **Step 2: 运行测试,确认失败**
Run:
```bash
swift test --filter SetPlayModeGuardTests 2>&1 | tail -15
```
Expected: 编译失败 `value of type 'DuooomiBleSDK' has no member 'setPlayMode'`
- [ ] **Step 3: 在 `DuooomiBleSDK.swift` 中实现公开方法**
`Sources/DuooomiBleSDK/DuooomiBleSDK.swift` 找到 `bind(userId:completion:)` 方法的右花括号(第 292 行附近,`unbind(userId:completion:)` 之前),紧接其后插入:
```swift
/// 0= / 1=
///
/// `BIND_DEVICE` (0x0F) `BindingResponse`
/// `deviceInfo` `loop` expo
///
/// - Important: ** `userId` `bind`** `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")
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: device rejected", "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)
})
}
```
- [ ] **Step 4: 运行测试,确认通过**
Run:
```bash
swift test --filter SetPlayModeGuardTests 2>&1 | tail -15
```
Expected: `Executed 1 test, with 0 failures`
再跑全部测试:
```bash
swift test 2>&1 | tail -15
```
Expected: 所有测试通过。
- [ ] **Step 5: 提交**
```bash
git add Sources/DuooomiBleSDK/DuooomiBleSDK.swift Tests/DuooomiBleSDKTests/PlayModeTests.swift
git commit -m "feat: add setPlayMode SDK API with isActivated precondition"
```
---
### Task 5: Demo UI 加 Single/Loop 按钮
**背景:** 单测覆盖完了协议层和守卫demo 是真机验证入口。按钮需要 `isActivated == true` 才启用,把"必须先绑定"约束在 UI 上体现出来。
**Files:**
- Modify: `demo/Sources/WrapperTestView.swift:140-155`Device 区按钮组)
- Modify: `demo/Sources/WrapperTestView.swift:380-397` 之后(新增 `runSetPlayMode`
- [ ] **Step 1: 在 Device Section 内 `bind/unbind` 行后追加 PlayMode 行**
打开 `demo/Sources/WrapperTestView.swift`,找到 `private var deviceSection: some View {``bind`/`unbind` 按钮所在的 `HStack`(约第 146-153 行),把它整体替换为:
```swift
HStack {
TextField("userId", text: $userId)
.textFieldStyle(.roundedBorder)
Button("bind") { runBind() }
.buttonStyle(.bordered)
Button("unbind") { runUnbind() }
.buttonStyle(.bordered)
}
HStack {
Text("PlayMode")
.font(.caption)
Spacer()
if let mode = viewModel.deviceInfo?.loop {
Text(mode == .loop ? "循环" : "单播")
.font(.caption)
.foregroundColor(.secondary)
}
Button("Single") { runSetPlayMode(.single) }
.buttonStyle(.bordered)
Button("Loop") { runSetPlayMode(.loop) }
.buttonStyle(.bordered)
}
.disabled(!viewModel.isActivated || isBusy)
```
`.disabled(!viewModel.isActivated || isBusy)` 关键UI 层把"必须先 bind 才能切模式"显式表达出来。
- [ ] **Step 2: 在 `runUnbind` 后新增 `runSetPlayMode` 方法**
找到 `runUnbind` 方法的结束花括号(约 397 行),在其后插入:
```swift
private func runSetPlayMode(_ mode: PlayMode) {
isBusy = true
log("→ setPlayMode(\(mode == .loop ? "loop" : "single"), 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)
}
}
}
}
```
- [ ] **Step 3: 重新生成 Xcode 工程并编译 demo验证 SwiftUI 引用正确)**
Run:
```bash
xcodegen generate 2>&1 | tail -5
xcodebuild -workspace demo.xcworkspace -scheme demo -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' build 2>&1 | tail -10
```
Expected: 编译成功,最后一行包含 `BUILD SUCCEEDED`
如未安装 xcodegen`brew install xcodegen`
- [ ] **Step 4: 真机/模拟器手测要点(人工验证清单)**
> 这一步不写自动化,但必须人工跑一遍,结果记到 commit message 里。
1. App 起来 → 扫描 → 连接到设备 → "Single" / "Loop" 按钮**应当为禁用态**(因 `isActivated == false`
2. 点 "bind"绑定成功log 出现 `bind ✓` → "Single" / "Loop" 按钮变为可点
3. 点 "Loop" → log 出现 `setPlayMode ✓`PlayMode 行右侧文字变成 `循环`
4. 点 "Single" → 同理变为 `单播`
5. 点 "unbind" → 按钮再次禁用
6. 不连接情况下点按钮(需注释掉 `.disabled` 临时验证)→ log 出现 `setPlayMode ✗ Not connected`-风格错误
7. **回归** —— `bind` / `unbind` / `getDeviceInfo` / 文件传输 行为不变
- [ ] **Step 5: 提交**
```bash
git add demo/Sources/WrapperTestView.swift demo.xcodeproj
git commit -m "feat(demo): add Single/Loop play-mode buttons gated by isActivated"
```
---
### Task 6: README 加"播放模式"段落 + 显著前置条件
**Files:**
- Modify: `README.md:106``unbind` 行之后插入)
- Modify: `README.md:185``DeviceInfo` 数据类型块)
- [ ] **Step 1: 在"设备命令"代码块插入 `setPlayMode` 用法**
`README.md` 找到这一行:
```
sdk.unbind(userId: "user-id") { result in /* ... */ }
sdk.deleteFile(key: "file-key") { result in /* ... */ }
```
把这两行替换为:
````markdown
sdk.unbind(userId: "user-id") { result in /* ... */ }
sdk.deleteFile(key: "file-key") { result in /* ... */ }
```
#### 播放模式Play Mode
> ⚠️ **前置条件 — 必须先绑定。** `setPlayMode` 复用 `BIND_DEVICE` (0x0F) 命令通道,固件设计上要求设备先和该 `userId` 完成 `bind`(即 SDK `isActivated == true`才能切播放模式。SDK 在客户端做了守卫:未绑定时直接 fail不会发命令。请在 UI 上禁用切换按钮,直到 `didChangeActivation(true)` 回调到达。
```swift
// 0 = 单播(播完一次停)/ 1 = 循环播放
sdk.setPlayMode(.loop, userId: "user-id") { result in
switch result {
case .success:
// 成功后 SDK 自动刷新 deviceInfo
// 通过 didUpdateDeviceInfo 回调拿到最新 info.loop
break
case .failure(let error):
print("setPlayMode failed: \(error)")
}
}
```
**注意事项:**
- `bind` / `unbind` / `setPlayMode` 共享 `0x0F` / `0x12` 响应通道,**不要并发调用**
- 旧固件可能不支持 `loop` 字段 —— 此时 `DeviceInfo.loop` 为 `nil`,调用 `setPlayMode` 设备可能回 `success=0`
````
- [ ] **Step 2: 在"数据类型"块的 `DeviceInfo` 中加 `loop` 字段、并加 `PlayMode` 类型**
把 `README.md` 中现有的 `DeviceInfo` 块:
```swift
// DeviceInfo — getDeviceInfo 返回
public struct DeviceInfo {
let name: String // 设备名
let brand: String // 品牌
let size: String // 屏幕尺寸
let powerlevel: Int // 电量百分比
let allspace: UInt64 // 总存储(字节)
let freespace: UInt64 // 可用存储(字节)
}
```
替换为:
```swift
// PlayMode — 播放模式
public enum PlayMode: Int {
case single = 0 // 单播
case loop = 1 // 循环播放
}
// DeviceInfo — getDeviceInfo 返回
public struct DeviceInfo {
let name: String // 设备名
let brand: String // 品牌
let size: String // 屏幕尺寸
let powerlevel: Int // 电量百分比
let allspace: UInt64 // 总存储(字节)
let freespace: UInt64 // 可用存储(字节)
let loop: PlayMode? // 播放模式(旧固件无此字段时为 nil
}
```
- [ ] **Step 3: 验证 markdown 渲染(无自动化,肉眼快速过一遍)**
Run:
```bash
grep -n "setPlayMode\|PlayMode\|前置条件" README.md
```
Expected: 输出至少 5 处匹配,包括 "前置条件 — 必须先绑定" 警示行。
- [ ] **Step 4: 提交**
```bash
git add README.md
git commit -m "docs: document setPlayMode API with bind-first precondition"
```
---
### Task 7: 端到端验收
- [ ] **Step 1: 全量测试**
```bash
swift test 2>&1 | tail -10
```
Expected: 总计约 12 个测试全部通过。
- [ ] **Step 2: demo 编译**
```bash
xcodebuild -workspace demo.xcworkspace -scheme demo -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' build 2>&1 | tail -5
```
Expected: `BUILD SUCCEEDED`。
- [ ] **Step 3: 协议字节对比(关键回归 — 确认 bind/unbind 字节级未变)**
在 `Tests/DuooomiBleSDKTests/PlayModeTests.swift` 末尾临时加一个对比测试(也可保留作为永久回归):
```swift
final class BindPayloadByteRegressionTests: XCTestCase {
/// bind 改动前后字节级一致性loop=nil 时不可输出 "loop":null
func testBindPayloadJSONExactBytes() throws {
let payload = BindPayload(type: 0x0F, userId: "abc", loop: nil)
let data = try JSONEncoder().encode(payload)
let json = String(data: data, encoding: .utf8) ?? ""
// 顺序在 Encodable 中由 encode(to:) 决定 → "type" 先于 "userId"
XCTAssertEqual(json, #"{"type":15,"userId":"abc"}"#)
XCTAssertFalse(json.contains("loop"))
XCTAssertFalse(json.contains("null"))
}
func testSetPlayModePayloadJSONExactBytes() throws {
let payload = BindPayload(type: 0x0F, userId: "abc", loop: 1)
let data = try JSONEncoder().encode(payload)
let json = String(data: data, encoding: .utf8) ?? ""
XCTAssertEqual(json, #"{"type":15,"userId":"abc","loop":1}"#)
}
}
```
Run:
```bash
swift test --filter BindPayloadByteRegressionTests 2>&1 | tail -10
```
Expected: 2 个测试通过。
- [ ] **Step 4: 提交回归测试**
```bash
git add Tests/DuooomiBleSDKTests/PlayModeTests.swift
git commit -m "test: add byte-level regression for bind/setPlayMode JSON encoding"
```
- [ ] **Step 5: 真机功能验收(人工,按 Task 5 Step 4 清单逐项过)**
完成后:
```bash
git log --oneline -8
```
Expected: 看到 7 个新增 commit按本 plan 顺序排列。
---
## Self-Review
**Spec 覆盖:**
- §3.1 PlayMode 枚举 → Task 1 ✓
- §3.1 DeviceInfo.loop → Task 2 ✓
- §3.2 BindPayload + 自定义 encode + setPlayMode 服务方法 → Task 3 ✓
- §3.3 SDK 公开 API + isActivated 守卫 → Task 4 ✓
- §3.4 Demo UI → Task 5 ✓
- §4 README 文档(含必须先绑定)→ Task 6 ✓
- §5 验证(编译 + 字节比对 + 真机)→ Task 7 ✓
**类型一致性:**
- `PlayMode` 在 Models / Service / SDK / DeviceInfo / README 全部一致用同名枚举 ✓
- `BindPayload(type:userId:loop:)` 三个参数顺序在 service 内部 / 单测 / 回归测一致 ✓
- `setPlayMode(_:userId:completion:)` 在 SDK 公开 API / demo 调用 / README 示例一致 ✓
- `DuooomiBleError.bindingFailed("...")` 用现有错误类型,未引入新 case ✓
**Placeholder 扫描:** 全文 grep "TBD/TODO/implement later/类似" → 无遗留。
**前置条件覆盖:**
- SDK 守卫Task 4 isActivated 检查)✓
- UI 守卫Task 5 `.disabled(!viewModel.isActivated)`)✓
- README 警示框Task 6
- 用户人工验收清单Task 5 Step 4

View File

@@ -0,0 +1,221 @@
# 播放模式切换功能 — 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