diff --git a/docs/superpowers/plans/2026-05-06-play-mode-toggle.md b/docs/superpowers/plans/2026-05-06-play-mode-toggle.md new file mode 100644 index 0000000..7fc9132 --- /dev/null +++ b/docs/superpowers/plans/2026-05-06-play-mode-toggle.md @@ -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, + 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)✓ diff --git a/docs/superpowers/specs/2026-05-06-play-mode-toggle-design.md b/docs/superpowers/specs/2026-05-06-play-mode-toggle-design.md new file mode 100644 index 0000000..4bc49c4 --- /dev/null +++ b/docs/superpowers/specs/2026-05-06-play-mode-toggle-design.md @@ -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` —— 复用 `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) -> 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