docs: spec & plan for play-mode toggle feature
This commit is contained in:
912
docs/superpowers/plans/2026-05-06-play-mode-toggle.md
Normal file
912
docs/superpowers/plans/2026-05-06-play-mode-toggle.md
Normal file
@@ -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<CodingKeys>,
|
||||
key: CodingKeys
|
||||
) -> UInt64 {
|
||||
if let value = try? container.decode(UInt64.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? container.decode(Int.self, forKey: key) {
|
||||
return UInt64(max(0, value))
|
||||
}
|
||||
if let str = try? container.decode(String.self, forKey: key),
|
||||
let value = UInt64(str) {
|
||||
return value
|
||||
}
|
||||
return 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 运行测试,确认通过**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
swift test --filter DeviceInfoLoopTests 2>&1 | tail -15
|
||||
```
|
||||
|
||||
Expected: `Executed 4 tests, with 0 failures`。
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add Sources/DuooomiBleSDK/Models/DeviceInfo.swift Tests/DuooomiBleSDKTests/PlayModeTests.swift
|
||||
git commit -m "feat: add optional loop field to DeviceInfo"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `BindPayload` 支持可选 `loop`(自定义 encode 严格省略 nil)
|
||||
|
||||
**背景:** 这是协议字节层最关键的一步。Expo 端用 `zod.optional()` 在 `loop === undefined` 时省略字段。Swift 默认 `JSONEncoder` 对 `Optional<UInt8>` 为 `nil` 时输出 `"loop": null` —— 与 Expo 行为不一致,可能让设备误把"普通绑定"识别为"切模式"。必须自定义 `encode(to:)` 在 `loop == nil` 时不写入 key。
|
||||
|
||||
**Files:**
|
||||
- Modify: `Sources/DuooomiBleSDK/Services/DeviceInfoService.swift`
|
||||
- Modify: `Tests/DuooomiBleSDKTests/PlayModeTests.swift`
|
||||
|
||||
- [ ] **Step 1: 把 `BindPayload` 暴露为 internal,写失败测试**
|
||||
|
||||
在 `Tests/DuooomiBleSDKTests/PlayModeTests.swift` 文件末尾追加:
|
||||
|
||||
```swift
|
||||
final class BindPayloadEncodingTests: XCTestCase {
|
||||
private func encode(_ payload: BindPayload) throws -> [String: Any] {
|
||||
let data = try JSONEncoder().encode(payload)
|
||||
return try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:]
|
||||
}
|
||||
|
||||
func testBindPayloadWithoutLoopOmitsKey() throws {
|
||||
let payload = BindPayload(type: 0x0F, userId: "u1", loop: nil)
|
||||
let dict = try encode(payload)
|
||||
XCTAssertEqual(dict["type"] as? Int, 0x0F)
|
||||
XCTAssertEqual(dict["userId"] as? String, "u1")
|
||||
XCTAssertNil(dict["loop"], "loop key MUST be omitted when nil (not encoded as null)")
|
||||
XCTAssertEqual(dict.count, 2)
|
||||
}
|
||||
|
||||
func testBindPayloadWithLoopIncludesValue() throws {
|
||||
let payload = BindPayload(type: 0x0F, userId: "u1", loop: 1)
|
||||
let dict = try encode(payload)
|
||||
XCTAssertEqual(dict["type"] as? Int, 0x0F)
|
||||
XCTAssertEqual(dict["userId"] as? String, "u1")
|
||||
XCTAssertEqual(dict["loop"] as? Int, 1)
|
||||
XCTAssertEqual(dict.count, 3)
|
||||
}
|
||||
|
||||
func testBindPayloadWithLoopZeroIncludesValue() throws {
|
||||
let payload = BindPayload(type: 0x0F, userId: "u1", loop: 0)
|
||||
let dict = try encode(payload)
|
||||
XCTAssertEqual(dict["loop"] as? Int, 0)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行测试,确认失败**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
swift test --filter BindPayloadEncodingTests 2>&1 | tail -15
|
||||
```
|
||||
|
||||
Expected: 编译失败 `cannot find 'BindPayload' in scope` 或 `'BindPayload' is inaccessible due to 'private' protection level`(因为当前 `BindPayload` 是 `private struct`)。
|
||||
|
||||
- [ ] **Step 3: 修改服务层 — 把 `BindPayload` 提升为 internal、增可选 `loop`、自定义 encode、新增 `setPlayMode`**
|
||||
|
||||
替换 `Sources/DuooomiBleSDK/Services/DeviceInfoService.swift` 全文为:
|
||||
|
||||
```swift
|
||||
import Foundation
|
||||
|
||||
/// 设备命令服务:发送 JSON 命令(fire-and-forget,响应由协议层回调处理)
|
||||
final class DeviceInfoService {
|
||||
private let protocolService: BleProtocolService
|
||||
|
||||
init(protocolService: BleProtocolService) {
|
||||
self.protocolService = protocolService
|
||||
}
|
||||
|
||||
// MARK: - Commands
|
||||
|
||||
func getDeviceInfo() throws {
|
||||
try protocolService.sendJSON(
|
||||
type: .getDeviceInfo,
|
||||
payload: CommandPayload(type: CommandType.getDeviceInfo.rawValue)
|
||||
)
|
||||
}
|
||||
|
||||
func getDeviceVersion() throws {
|
||||
try protocolService.sendJSON(
|
||||
type: .getDeviceVersion,
|
||||
payload: CommandPayload(type: CommandType.getDeviceVersion.rawValue)
|
||||
)
|
||||
}
|
||||
|
||||
func bindDevice(userId: String) throws {
|
||||
try protocolService.sendJSON(
|
||||
type: .bindDevice,
|
||||
payload: BindPayload(type: CommandType.bindDevice.rawValue, userId: userId, loop: nil)
|
||||
)
|
||||
}
|
||||
|
||||
func unbindDevice(userId: String) throws {
|
||||
try protocolService.sendJSON(
|
||||
type: .unbindDevice,
|
||||
payload: BindPayload(type: CommandType.unbindDevice.rawValue, userId: userId, loop: nil)
|
||||
)
|
||||
}
|
||||
|
||||
/// 切换播放模式(复用 BIND_DEVICE 命令字 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<BindingResponse, Error>) -> Void
|
||||
) {
|
||||
guard ensureConnected(completion: completion) else { return }
|
||||
guard isActivated else {
|
||||
BleLog.w("setPlayMode called before bind (isActivated=false)", "Command")
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(DuooomiBleError.bindingFailed("Device not bound; call bind(userId:) first")))
|
||||
}
|
||||
return
|
||||
}
|
||||
BleLog.d("Sending setPlayMode (mode=\(mode), userId=\(userId))", "Command")
|
||||
|
||||
sendAndWait(commandType: .bindDevice, completion: { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
switch result {
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
case .success(let data):
|
||||
do {
|
||||
let resp = try self.decodeResponse(BindingResponse.self, from: data)
|
||||
if resp.success != 1 {
|
||||
BleLog.w("setPlayMode failed: device rejected", "Command")
|
||||
completion(.failure(DuooomiBleError.bindingFailed("Set play mode failed")))
|
||||
return
|
||||
}
|
||||
BleLog.i("setPlayMode success: mode=\(mode)", "Command")
|
||||
// 自动刷新 deviceInfo(含最新 loop 字段)
|
||||
self.getDeviceInfo { _ in
|
||||
completion(.success(resp))
|
||||
}
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}, send: {
|
||||
try self.deviceInfoService.setPlayMode(userId: userId, loop: mode)
|
||||
})
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 运行测试,确认通过**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
swift test --filter SetPlayModeGuardTests 2>&1 | tail -15
|
||||
```
|
||||
|
||||
Expected: `Executed 1 test, with 0 failures`。
|
||||
|
||||
再跑全部测试:
|
||||
|
||||
```bash
|
||||
swift test 2>&1 | tail -15
|
||||
```
|
||||
|
||||
Expected: 所有测试通过。
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add Sources/DuooomiBleSDK/DuooomiBleSDK.swift Tests/DuooomiBleSDKTests/PlayModeTests.swift
|
||||
git commit -m "feat: add setPlayMode SDK API with isActivated precondition"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Demo UI 加 Single/Loop 按钮
|
||||
|
||||
**背景:** 单测覆盖完了协议层和守卫;demo 是真机验证入口。按钮需要 `isActivated == true` 才启用,把"必须先绑定"约束在 UI 上体现出来。
|
||||
|
||||
**Files:**
|
||||
- Modify: `demo/Sources/WrapperTestView.swift:140-155`(Device 区按钮组)
|
||||
- Modify: `demo/Sources/WrapperTestView.swift:380-397` 之后(新增 `runSetPlayMode`)
|
||||
|
||||
- [ ] **Step 1: 在 Device Section 内 `bind/unbind` 行后追加 PlayMode 行**
|
||||
|
||||
打开 `demo/Sources/WrapperTestView.swift`,找到 `private var deviceSection: some View {` 内 `bind`/`unbind` 按钮所在的 `HStack`(约第 146-153 行),把它整体替换为:
|
||||
|
||||
```swift
|
||||
HStack {
|
||||
TextField("userId", text: $userId)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Button("bind") { runBind() }
|
||||
.buttonStyle(.bordered)
|
||||
Button("unbind") { runUnbind() }
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("PlayMode")
|
||||
.font(.caption)
|
||||
Spacer()
|
||||
if let mode = viewModel.deviceInfo?.loop {
|
||||
Text(mode == .loop ? "循环" : "单播")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Button("Single") { runSetPlayMode(.single) }
|
||||
.buttonStyle(.bordered)
|
||||
Button("Loop") { runSetPlayMode(.loop) }
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.disabled(!viewModel.isActivated || isBusy)
|
||||
```
|
||||
|
||||
`.disabled(!viewModel.isActivated || isBusy)` 关键:UI 层把"必须先 bind 才能切模式"显式表达出来。
|
||||
|
||||
- [ ] **Step 2: 在 `runUnbind` 后新增 `runSetPlayMode` 方法**
|
||||
|
||||
找到 `runUnbind` 方法的结束花括号(约 397 行),在其后插入:
|
||||
|
||||
```swift
|
||||
private func runSetPlayMode(_ mode: PlayMode) {
|
||||
isBusy = true
|
||||
log("→ setPlayMode(\(mode == .loop ? "loop" : "single"), userId=\(userId))")
|
||||
viewModel.sdk.setPlayMode(mode, userId: userId) { [self] result in
|
||||
DispatchQueue.main.async {
|
||||
self.isBusy = false
|
||||
switch result {
|
||||
case .success(let resp):
|
||||
self.log("setPlayMode ✓ sn=\(resp.sn)", level: .success)
|
||||
case .failure(let error):
|
||||
self.log("setPlayMode ✗ \(error.localizedDescription)", level: .error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 重新生成 Xcode 工程并编译 demo(验证 SwiftUI 引用正确)**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
xcodegen generate 2>&1 | tail -5
|
||||
xcodebuild -workspace demo.xcworkspace -scheme demo -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' build 2>&1 | tail -10
|
||||
```
|
||||
|
||||
Expected: 编译成功,最后一行包含 `BUILD SUCCEEDED`。
|
||||
|
||||
如未安装 xcodegen:`brew install xcodegen`。
|
||||
|
||||
- [ ] **Step 4: 真机/模拟器手测要点(人工验证清单)**
|
||||
|
||||
> 这一步不写自动化,但必须人工跑一遍,结果记到 commit message 里。
|
||||
|
||||
1. App 起来 → 扫描 → 连接到设备 → "Single" / "Loop" 按钮**应当为禁用态**(因 `isActivated == false`)
|
||||
2. 点 "bind",绑定成功(log 出现 `bind ✓`) → "Single" / "Loop" 按钮变为可点
|
||||
3. 点 "Loop" → log 出现 `setPlayMode ✓`,PlayMode 行右侧文字变成 `循环`
|
||||
4. 点 "Single" → 同理变为 `单播`
|
||||
5. 点 "unbind" → 按钮再次禁用
|
||||
6. 不连接情况下点按钮(需注释掉 `.disabled` 临时验证)→ log 出现 `setPlayMode ✗ Not connected`-风格错误
|
||||
7. **回归** —— `bind` / `unbind` / `getDeviceInfo` / 文件传输 行为不变
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add demo/Sources/WrapperTestView.swift demo.xcodeproj
|
||||
git commit -m "feat(demo): add Single/Loop play-mode buttons gated by isActivated"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: README 加"播放模式"段落 + 显著前置条件
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md:106`(`unbind` 行之后插入)
|
||||
- Modify: `README.md:185`(`DeviceInfo` 数据类型块)
|
||||
|
||||
- [ ] **Step 1: 在"设备命令"代码块插入 `setPlayMode` 用法**
|
||||
|
||||
在 `README.md` 找到这一行:
|
||||
|
||||
```
|
||||
sdk.unbind(userId: "user-id") { result in /* ... */ }
|
||||
sdk.deleteFile(key: "file-key") { result in /* ... */ }
|
||||
```
|
||||
|
||||
把这两行替换为:
|
||||
|
||||
````markdown
|
||||
sdk.unbind(userId: "user-id") { result in /* ... */ }
|
||||
sdk.deleteFile(key: "file-key") { result in /* ... */ }
|
||||
```
|
||||
|
||||
#### 播放模式(Play Mode)
|
||||
|
||||
> ⚠️ **前置条件 — 必须先绑定。** `setPlayMode` 复用 `BIND_DEVICE` (0x0F) 命令通道,固件设计上要求设备先和该 `userId` 完成 `bind`(即 SDK `isActivated == true`)才能切播放模式。SDK 在客户端做了守卫:未绑定时直接 fail,不会发命令。请在 UI 上禁用切换按钮,直到 `didChangeActivation(true)` 回调到达。
|
||||
|
||||
```swift
|
||||
// 0 = 单播(播完一次停)/ 1 = 循环播放
|
||||
sdk.setPlayMode(.loop, userId: "user-id") { result in
|
||||
switch result {
|
||||
case .success:
|
||||
// 成功后 SDK 自动刷新 deviceInfo,
|
||||
// 通过 didUpdateDeviceInfo 回调拿到最新 info.loop
|
||||
break
|
||||
case .failure(let error):
|
||||
print("setPlayMode failed: \(error)")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**注意事项:**
|
||||
- `bind` / `unbind` / `setPlayMode` 共享 `0x0F` / `0x12` 响应通道,**不要并发调用**
|
||||
- 旧固件可能不支持 `loop` 字段 —— 此时 `DeviceInfo.loop` 为 `nil`,调用 `setPlayMode` 设备可能回 `success=0`
|
||||
````
|
||||
|
||||
- [ ] **Step 2: 在"数据类型"块的 `DeviceInfo` 中加 `loop` 字段、并加 `PlayMode` 类型**
|
||||
|
||||
把 `README.md` 中现有的 `DeviceInfo` 块:
|
||||
|
||||
```swift
|
||||
// DeviceInfo — getDeviceInfo 返回
|
||||
public struct DeviceInfo {
|
||||
let name: String // 设备名
|
||||
let brand: String // 品牌
|
||||
let size: String // 屏幕尺寸
|
||||
let powerlevel: Int // 电量百分比
|
||||
let allspace: UInt64 // 总存储(字节)
|
||||
let freespace: UInt64 // 可用存储(字节)
|
||||
}
|
||||
```
|
||||
|
||||
替换为:
|
||||
|
||||
```swift
|
||||
// PlayMode — 播放模式
|
||||
public enum PlayMode: Int {
|
||||
case single = 0 // 单播
|
||||
case loop = 1 // 循环播放
|
||||
}
|
||||
|
||||
// DeviceInfo — getDeviceInfo 返回
|
||||
public struct DeviceInfo {
|
||||
let name: String // 设备名
|
||||
let brand: String // 品牌
|
||||
let size: String // 屏幕尺寸
|
||||
let powerlevel: Int // 电量百分比
|
||||
let allspace: UInt64 // 总存储(字节)
|
||||
let freespace: UInt64 // 可用存储(字节)
|
||||
let loop: PlayMode? // 播放模式(旧固件无此字段时为 nil)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 验证 markdown 渲染(无自动化,肉眼快速过一遍)**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
grep -n "setPlayMode\|PlayMode\|前置条件" README.md
|
||||
```
|
||||
|
||||
Expected: 输出至少 5 处匹配,包括 "前置条件 — 必须先绑定" 警示行。
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add README.md
|
||||
git commit -m "docs: document setPlayMode API with bind-first precondition"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 端到端验收
|
||||
|
||||
- [ ] **Step 1: 全量测试**
|
||||
|
||||
```bash
|
||||
swift test 2>&1 | tail -10
|
||||
```
|
||||
|
||||
Expected: 总计约 12 个测试全部通过。
|
||||
|
||||
- [ ] **Step 2: demo 编译**
|
||||
|
||||
```bash
|
||||
xcodebuild -workspace demo.xcworkspace -scheme demo -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' build 2>&1 | tail -5
|
||||
```
|
||||
|
||||
Expected: `BUILD SUCCEEDED`。
|
||||
|
||||
- [ ] **Step 3: 协议字节对比(关键回归 — 确认 bind/unbind 字节级未变)**
|
||||
|
||||
在 `Tests/DuooomiBleSDKTests/PlayModeTests.swift` 末尾临时加一个对比测试(也可保留作为永久回归):
|
||||
|
||||
```swift
|
||||
final class BindPayloadByteRegressionTests: XCTestCase {
|
||||
/// bind 改动前后字节级一致性(loop=nil 时不可输出 "loop":null)
|
||||
func testBindPayloadJSONExactBytes() throws {
|
||||
let payload = BindPayload(type: 0x0F, userId: "abc", loop: nil)
|
||||
let data = try JSONEncoder().encode(payload)
|
||||
let json = String(data: data, encoding: .utf8) ?? ""
|
||||
// 顺序在 Encodable 中由 encode(to:) 决定 → "type" 先于 "userId"
|
||||
XCTAssertEqual(json, #"{"type":15,"userId":"abc"}"#)
|
||||
XCTAssertFalse(json.contains("loop"))
|
||||
XCTAssertFalse(json.contains("null"))
|
||||
}
|
||||
|
||||
func testSetPlayModePayloadJSONExactBytes() throws {
|
||||
let payload = BindPayload(type: 0x0F, userId: "abc", loop: 1)
|
||||
let data = try JSONEncoder().encode(payload)
|
||||
let json = String(data: data, encoding: .utf8) ?? ""
|
||||
XCTAssertEqual(json, #"{"type":15,"userId":"abc","loop":1}"#)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
swift test --filter BindPayloadByteRegressionTests 2>&1 | tail -10
|
||||
```
|
||||
|
||||
Expected: 2 个测试通过。
|
||||
|
||||
- [ ] **Step 4: 提交回归测试**
|
||||
|
||||
```bash
|
||||
git add Tests/DuooomiBleSDKTests/PlayModeTests.swift
|
||||
git commit -m "test: add byte-level regression for bind/setPlayMode JSON encoding"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 真机功能验收(人工,按 Task 5 Step 4 清单逐项过)**
|
||||
|
||||
完成后:
|
||||
|
||||
```bash
|
||||
git log --oneline -8
|
||||
```
|
||||
|
||||
Expected: 看到 7 个新增 commit,按本 plan 顺序排列。
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec 覆盖:**
|
||||
- §3.1 PlayMode 枚举 → Task 1 ✓
|
||||
- §3.1 DeviceInfo.loop → Task 2 ✓
|
||||
- §3.2 BindPayload + 自定义 encode + setPlayMode 服务方法 → Task 3 ✓
|
||||
- §3.3 SDK 公开 API + isActivated 守卫 → Task 4 ✓
|
||||
- §3.4 Demo UI → Task 5 ✓
|
||||
- §4 README 文档(含必须先绑定)→ Task 6 ✓
|
||||
- §5 验证(编译 + 字节比对 + 真机)→ Task 7 ✓
|
||||
|
||||
**类型一致性:**
|
||||
- `PlayMode` 在 Models / Service / SDK / DeviceInfo / README 全部一致用同名枚举 ✓
|
||||
- `BindPayload(type:userId:loop:)` 三个参数顺序在 service 内部 / 单测 / 回归测一致 ✓
|
||||
- `setPlayMode(_:userId:completion:)` 在 SDK 公开 API / demo 调用 / README 示例一致 ✓
|
||||
- `DuooomiBleError.bindingFailed("...")` 用现有错误类型,未引入新 case ✓
|
||||
|
||||
**Placeholder 扫描:** 全文 grep "TBD/TODO/implement later/类似" → 无遗留。
|
||||
|
||||
**前置条件覆盖:**
|
||||
- SDK 守卫(Task 4 isActivated 检查)✓
|
||||
- UI 守卫(Task 5 `.disabled(!viewModel.isActivated)`)✓
|
||||
- README 警示框(Task 6)✓
|
||||
- 用户人工验收清单(Task 5 Step 4)✓
|
||||
221
docs/superpowers/specs/2026-05-06-play-mode-toggle-design.md
Normal file
221
docs/superpowers/specs/2026-05-06-play-mode-toggle-design.md
Normal file
@@ -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<BindingResponse>` —— 复用 `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<BindingResponse, Error>) -> 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
|
||||
Reference in New Issue
Block a user