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

9.4 KiB
Raw Blame History

播放模式切换功能 — iOS SDK 同步方案

目标:把 expo-duooomi-app(commit ac1520f) 的播放模式切换功能完整同步到 duooomi-ios-sdk,保持协议层一致,让 SDK 调用方用 Swift 风格 API 完成"单播 ⇄ 循环播放"切换。


1. 参考实现Expo 端)

提交 ac1520f 改动 5 个文件,核心机制:

文件 改动
协议类型 ble/protocol/types.ts DeviceInfoZodloop: 0|1|undefinedBindingRequestZod 增可选 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 里有无 loopSDK 必须严格按"无 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.swiftDevice Commands 区)

DuooomiBleSDK.sendAndWait 已支持基于 commandType.rawValue 的请求-响应配对,新增能力可直接复用。

3. 设计

3.1 类型与协议

新增枚举 Sources/DuooomiBleSDK/Models/PlayMode.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:)

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 走相同路径,报文与改动前完全一致。

新增方法:

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 之间新增:

/// 切换播放模式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 按钮行下加一行:

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)

新增方法:

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 修改:BindPayloadloop + 自定义 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 取 userIdiOS 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