31 KiB
播放模式切换功能 — 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-tools-version:5.0
- Step 2: 验证 SwiftPM 能解析、占位测试能跑通
Run(在仓库根目录):
swift test 2>&1 | tail -10
Expected: 编译通过,输出包含 Test Suite 'PlaceholderTests' passed 或类似 Executed 1 test, with 0 failures。
- Step 3: 提交
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:
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:
swift test --filter PlayModeTests 2>&1 | tail -15
Expected: 编译失败 cannot find 'PlayMode' in scope。
- Step 3: 实现枚举
新建 Sources/DuooomiBleSDK/Models/PlayMode.swift:
import Foundation
/// 播放模式(设备端协议字段 `loop`)。
///
/// - `single` (0): 单播 —— 设备播完一次后停止
/// - `loop` (1): 循环播放 —— 设备循环播放当前内容
public enum PlayMode: Int, Codable, Equatable {
case single = 0
case loop = 1
}
- Step 4: 运行测试,确认通过
Run:
swift test --filter PlayModeTests 2>&1 | tail -15
Expected: Executed 3 tests, with 0 failures。
- Step 5: 提交
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 文件末尾追加新测试类:
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:
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 全文为:
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:
swift test --filter DeviceInfoLoopTests 2>&1 | tail -15
Expected: Executed 4 tests, with 0 failures。
- Step 5: 提交
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 文件末尾追加:
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:
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 全文为:
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:
swift test --filter BindPayloadEncodingTests 2>&1 | tail -15
Expected: Executed 3 tests, with 0 failures。
再跑全部测试确认未破坏其他用例:
swift test 2>&1 | tail -15
Expected: 所有 test class 通过(PlaceholderTests + PlayModeTests + DeviceInfoLoopTests + BindPayloadEncodingTests,共 11 个测试)。
- Step 5: 提交
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 文件末尾追加:
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:
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:) 之前),紧接其后插入:
/// 切换播放模式(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:
swift test --filter SetPlayModeGuardTests 2>&1 | tail -15
Expected: Executed 1 test, with 0 failures。
再跑全部测试:
swift test 2>&1 | tail -15
Expected: 所有测试通过。
- Step 5: 提交
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 行),把它整体替换为:
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 行),在其后插入:
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:
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 里。
- App 起来 → 扫描 → 连接到设备 → "Single" / "Loop" 按钮应当为禁用态(因
isActivated == false) - 点 "bind",绑定成功(log 出现
bind ✓) → "Single" / "Loop" 按钮变为可点 - 点 "Loop" → log 出现
setPlayMode ✓,PlayMode 行右侧文字变成循环 - 点 "Single" → 同理变为
单播 - 点 "unbind" → 按钮再次禁用
- 不连接情况下点按钮(需注释掉
.disabled临时验证)→ log 出现setPlayMode ✗ Not connected-风格错误 - 回归 ——
bind/unbind/getDeviceInfo/ 文件传输 行为不变
- Step 5: 提交
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 /* ... */ }
把这两行替换为:
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 块:
// DeviceInfo — getDeviceInfo 返回
public struct DeviceInfo {
let name: String // 设备名
let brand: String // 品牌
let size: String // 屏幕尺寸
let powerlevel: Int // 电量百分比
let allspace: UInt64 // 总存储(字节)
let freespace: UInt64 // 可用存储(字节)
}
替换为:
// 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:
grep -n "setPlayMode\|PlayMode\|前置条件" README.md
Expected: 输出至少 5 处匹配,包括 "前置条件 — 必须先绑定" 警示行。
- Step 4: 提交
git add README.md
git commit -m "docs: document setPlayMode API with bind-first precondition"
Task 7: 端到端验收
- Step 1: 全量测试
swift test 2>&1 | tail -10
Expected: 总计约 12 个测试全部通过。
- Step 2: demo 编译
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 末尾临时加一个对比测试(也可保留作为永久回归):
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:
swift test --filter BindPayloadByteRegressionTests 2>&1 | tail -10
Expected: 2 个测试通过。
- Step 4: 提交回归测试
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 清单逐项过)
完成后:
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)✓