# 播放模式切换功能 — 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, 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` 为 `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 命令字 0x0F,payload 携带 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) -> 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)✓