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

31 KiB
Raw Blame History

播放模式切换功能 — 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 公开 APIsetPlayMode(_: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 默认 JSONEncoderOptional<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(因为当前 BindPayloadprivate 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 命令字 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:

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 APIisActivated 前置守卫)

背景: SDK 层方法依赖真实 BLE 通信,无法在 SwiftPM 单测里全程跑通。能单测的部分:未连接 / 未绑定 时的前置守卫直接返回错误,不发送任何命令。其余依赖 demo 真机验证。

Files:

  • Modify: Sources/DuooomiBleSDK/DuooomiBleSDK.swift:295-322(在 bindunbind 之间插入)

  • 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-155Device 区按钮组)

  • 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

如未安装 xcodegenbrew 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: 提交
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:106unbind 行之后插入)

  • Modify: README.md:185DeviceInfo 数据类型块)

  • 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