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

913 lines
31 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 实施计划
> **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