Files
abox-mgr/docs/superpowers/specs/2026-08-24-infrared-control-design.md
T
MolerandKremeCN 0c783ca2f4 feat(infrared): add infrared control, learning, and virtual remote module (#9)
### 概述 / Summary
完整实现红外控制与学习模块(`:android:feature:infrared`),支持红外设备按键学习、实体发射、Room 本地持久化,以及空调 (AC) / 电视 (TV) 虚拟遥控器面板。

### 核心变更 / Key Changes
1. **领域模型与用例 (`:domain`)**:
   - 实现按键学习流程:`LearnInfraredKey`、`ManageInfraredKey`、`InfraredKeyFactory`;
   - 增加虚拟遥控器模型与用例:`CreateVirtualRemote`、`LearnVirtualRemoteKey`、`SendVirtualRemoteAction`、`AcCodeGenerator` 与预置码库 `PresetCodeLibrary`。
2. **数据持久化与仓储 (`:android:feature:infrared`)**:
   - 引入 Room 数据库 `InfraredDatabase` 及 DAO,提供 `RoomInfraredKeyRepository` 与 `RoomVirtualRemoteRepository`;
   - 支持多配置隔离(`ProviderProfileId`)、按键状态管理(`LEARNING` / `READY` / `FAILED`)。
3. **UI 与 ViewModel (`:android:feature:infrared`)**:
   - `InfraredControlScreen`:红外学习与按键调试列表;
   - `RemotePanelScreen` / `AcRemotePanel` / `TvRemotePanel`:空调/电视专属可视化遥控面板;
   - `InfraredHubScreen`:红外中枢与多遥控器管理入口;
   - 在认证后导航栈中将 `DeviceListScreen` 的红外入口无缝对接到 `InfraredHubRoute`。
4. **测试与文档**:
   - 补齐用例、Fakes、Repository、ViewModel 以及 Compose UI 自动化测试;
   - 补充红外控制设计规范与真机硬件验收清单。

Reviewed-on: #9
Co-authored-by: mxhsnd <2049295946@qq.com>
Co-authored-by: KremeCN <3265494849@qq.com>
2026-08-25 15:00:06 +08:00

19 KiB
Raw Blame History

红外学习与发射控制设计

1. 背景与目标

本项目当前可用于真实验证的设备包括 ABox 主机、红外控制器、智能插座和温湿度计。本阶段由 moler 负责红外能力,范围聚焦于红外控制器的基础闭环:

  1. 在登录成功后的现有页面提供一个临时入口;
  2. 手动配置并记住一个 ABSDK 红外设备名;
  3. 创建并命名多个红外按键;
  4. 通过 ABSDK 发起红外学习;
  5. 保存可重复使用的按键绑定;
  6. 通过 ABSDK 发射已学习按键;
  7. 支持 Fake 和真实 ABSDK 调试模式;
  8. 为后续生活场景保留稳定按键引用。

首版前端只用于功能验证,沿用现有 Compose 和设计系统基础组件,不定义最终产品视觉风格。后续统一视觉改版只替换展示层,不改变红外领域、持久化、应用用例或硬件接口。

2. 已确认的 ABSDK 能力边界

ABSDK 0630 提供以下红外公开方法:

ABRet sendIr(String irDevName, String irKey)
ABRet studyIrByIrDevName(String irDevName, String irKey)
ABRet downloadIrCodes(List<IrInfo> irInfos)

现有领域层已将其隔离为 InfraredGateway:

interface InfraredGateway {
    suspend fun sendKey(
        profileId: ProviderProfileId,
        deviceName: DeviceName,
        key: String,
    ): DomainResult<Unit>

    suspend fun learnKey(
        profileId: ProviderProfileId,
        deviceName: DeviceName,
        key: String,
    ): DomainResult<Unit>

    suspend fun downloadCodes(
        profileId: ProviderProfileId,
        codes: List<InfraredCode>,
    ): DomainResult<List<InfraredCodeDownloadResult>>
}

本设计必须遵守以下事实:

  • 学习接口只确认返回成功码和消息,没有已确认的原始红外码返回值;
  • ABSDK 没有公开停止学习方法;
  • ABSDK 没有公开获取学习结果或读取已学习原始码的方法;
  • downloadIrCodes() 是把调用方已经拥有的红外码写入主机,不是读取学习结果;
  • ABSDK 没有设备扫描或设备列表接口;
  • 红外方法按 irDevName + irKey 寻址;
  • SDK 调用为同步阻塞调用,现有 Bridge 在 Dispatchers.IO 上通过全局 Mutex 串行执行;
  • SDK 使用固定明文 HTTP 目标,真实接入存在已知传输安全风险。

因此首版保存的是本地按键绑定,而不是原始红外波形或码值:

ProviderProfileId + DeviceName + sdkKey + displayName + learning status

真实硬件闭环成立的判据是:

learnKey(deviceName, sdkKey) 成功
                ↓
sendKey(deviceName, sdkKey)
                ↓
实体被控设备产生正确响应

3. 范围

3.1 本阶段包含

  • 红外功能的领域模型和本地持久化端口;
  • Room 数据库、DAO 和 Repository;
  • 学习、重试、发射、删除和恢复应用用例;
  • 红外 ViewModel 和简单 Compose 页面;
  • 登录成功页面的临时红外入口;
  • 手动输入并记住一个 irDevName;
  • 单红外控制器、多按键;
  • Fake/Real ABSDK 显式调试模式;
  • 自动化测试;
  • 真机验收清单;
  • 面向未来生活场景的稳定按键引用。

3.2 本阶段不包含

  • 最终 UI 视觉规范或复杂遥控器布局;
  • 多个遥控器管理界面;
  • ABSDK 不存在的设备扫描;
  • 原始红外码读取或持久化;
  • 红外码库导入 UI;
  • 硬件停止学习;
  • 完整生活场景模型、规则引擎或场景执行器;
  • Release 中默认启用真实 ABSDK;
  • 绕过或隐藏厂商明文 HTTP 风险;
  • 删除 ABox 服务端已学习按键。

4. 架构

采用独立应用编排层,保持现有 InfraredGateway 作为纯硬件能力端口:

登录成功页面
  └─ 临时入口:红外控制
        ↓
InfraredControlScreen
        ↓
InfraredControlViewModel
        ↓
应用用例
  ├─ LearnInfraredKey
  ├─ RetryInfraredKeyLearning
  ├─ SendInfraredKey
  ├─ DeleteInfraredKey
  ├─ RecoverInterruptedInfraredLearning
  └─ ObserveInfraredKeys
       ↙                         ↘
InfraredKeyRepository          InfraredGateway
       ↓                         ├─ FakeInfraredGateway
RoomInfraredKeyRepository       └─ AbsdkBridge
                                      ↓
                                  ABSDK 0630

4.1 边界原则

  • InfraredGateway 只负责学习、发射和码库下载,不负责 Room、页面状态或遥控器管理;
  • 不修改 learnKey() 返回类型,不虚构 ABSDK 原始码结果;
  • 不把 ABSDK、ABRet、IrInfo 或原始 Map 暴露给上层;
  • ViewModel 不直接编排数据库和硬件之间的一致性,统一通过应用用例;
  • Room Entity 不作为领域或 UI 公共模型;
  • Fake 和真实 Bridge 必须共用相同的领域接口和用例;
  • 所有真实 Gateway 接口必须指向同一个进程级 AbsdkBridge 实例,保持全局会话和互斥锁一致。

5. 数据模型

5.1 稳定标识

@JvmInline
value class InfraredKeyId(val value: String)

data class InfraredKeyRef(
    val profileId: ProviderProfileId,
    val keyId: InfraredKeyId,
)

InfraredKeyRef 是未来生活场景使用的最小稳定引用。场景不应保存显示名、设备名或厂商 Key。

5.2 学习状态

enum class InfraredKeyStatus {
    LEARNING,
    READY,
    FAILED,
}

状态表示 App 对最近一次学习调用结果的本地记录,不代表设备主动上报的实时状态。

5.3 领域模型

data class SavedInfraredKey(
    val id: InfraredKeyId,
    val profileId: ProviderProfileId,
    val deviceName: DeviceName,
    val displayName: String,
    val sdkKey: String,
    val status: InfraredKeyStatus,
    val lastErrorCode: String?,
    val createdAtEpochMillis: Long,
    val updatedAtEpochMillis: Long,
)

字段约束:

  • id 在 App 内稳定,供页面、删除和未来生活场景引用;
  • profileId 用于不同登录配置的数据隔离;
  • deviceName 是传给 ABSDK 的真实红外设备名;
  • displayName 是用户可见名称,可以修改;
  • sdkKey 是传给 ABSDK 的稳定 Key,学习后不随显示名改变;
  • lastErrorCode 仅保存脱敏厂商错误码,不保存密码、Token、原始 Map 或红外载荷;
  • 时间字段由应用层时钟提供,测试中使用可控时钟。

5.4 SDK Key

App 自动生成 SDK Key,不允许用户直接编辑。格式应:

  • 仅包含安全 ASCII 字符;
  • 在同一 Provider 和红外设备下唯一;
  • 重试学习时复用;
  • 修改显示名时保持不变;
  • 不直接使用中文、空格或特殊字符。

建议格式:

app_ir_<normalized-infrared-key-id>

实际长度上限尚未由 ABSDK 文档确认,实现前需选择保守长度,并列入真机验证。

6. 本地持久化

采用 Room。Repository 边界至少支持:

interface InfraredKeyRepository {
    fun observeKeys(
        profileId: ProviderProfileId,
        deviceName: DeviceName,
    ): Flow<DomainResult<List<SavedInfraredKey>>>

    suspend fun getKey(
        ref: InfraredKeyRef,
    ): DomainResult<SavedInfraredKey?>

    suspend fun insertLearningKey(
        key: SavedInfraredKey,
    ): DomainResult<Unit>

    suspend fun updateStatus(
        ref: InfraredKeyRef,
        status: InfraredKeyStatus,
        lastErrorCode: String?,
        updatedAtEpochMillis: Long,
    ): DomainResult<Unit>

    suspend fun renameKey(
        ref: InfraredKeyRef,
        displayName: String,
        updatedAtEpochMillis: Long,
    ): DomainResult<Unit>

    suspend fun deleteKey(
        ref: InfraredKeyRef,
    ): DomainResult<Unit>

    suspend fun findInterruptedLearning(
        profileId: ProviderProfileId,
    ): DomainResult<List<SavedInfraredKey>>
}

具体签名可在实施计划中按项目既有风格调整,但必须保持职责和语义。

6.1 数据库约束

  • 主键:InfraredKeyId;
  • 索引:ProviderProfileId + DeviceName;
  • 唯一约束:ProviderProfileId + DeviceName + sdkKey;
  • 查询和删除必须包含 Provider 隔离;
  • 不保存原始红外码;
  • 不把 Room Entity 暴露给领域层;
  • 首版预留 schema version 和迁移测试基础。

6.2 单设备策略

页面记住一个当前 irDevName,并只展示该设备下的按键。切换当前设备名不会迁移或重写旧按键;旧按键仍绑定学习时使用的设备名。

设备名可以保存到 Room 配置表或 DataStore。若采用 DataStore,只用于当前设备名和 debug 模式等轻量配置,不承载红外按键集合。

7. 学习流程

LearnInfraredKey 的流程:

  1. 验证用户已登录且存在有效 ProviderProfileId;
  2. 验证 deviceName 和 displayName 非空;
  3. 生成稳定 InfraredKeyId 和 sdkKey;
  4. 先写入状态为 LEARNING 的记录;
  5. 在应用级协程作用域调用 InfraredGateway.learnKey();
  6. 成功时更新为 READY 并清空错误码;
  7. 失败时更新为 FAILED 并保存脱敏错误码;
  8. 如果状态更新失败,记录可恢复的本地数据错误,但不重新生成 SDK Key;
  9. 页面通过 Room Flow 观察最终状态。

7.1 重试

重试必须:

  • 使用原 InfraredKeyId;
  • 使用原 sdkKey;
  • 先将状态更新为 LEARNING;
  • 再调用 ABSDK;
  • 不创建重复按键记录。

7.2 页面离开

学习调用开始后,页面可以退出。任务继续在应用级作用域等待 SDK 返回,并更新 Room。ViewModel 销毁不应直接取消已经提交的学习编排。

这不是后台系统任务保证:如果进程被系统杀死,底层同步网络请求及设备状态无法可靠恢复。

7.3 进程恢复

App 启动或登录恢复后,查找遗留的 LEARNING 记录:

LEARNING → FAILED
错误提示:上次学习结果未确认,请重新学习

不得自动标记为 READY,也不得自动发射来试探设备状态。

7.4 取消语义

ABSDK 没有停止学习 API。首版 UI 不提供或宣称“停止硬件学习”。用户可以离开页面,但学习调用仍等待成功、失败或 SDK 超时。

8. 发射流程

SendInfraredKey 的流程:

  1. 使用 InfraredKeyRef 查询本地记录;
  2. 记录不存在时返回领域错误;
  3. 仅允许 READY 状态发射;
  4. 取得记录中的 profileId + deviceName + sdkKey;
  5. 调用 InfraredGateway.sendKey();
  6. 成功时向 UI 返回一次性成功事件;
  7. 失败时保留按键 READY 状态,仅报告本次错误;
  8. 不自动重试,避免重复操作电视、空调等实体设备。

未来生活场景通过稳定 InfraredKeyRef 调用同一发送用例或薄执行器:

interface InfraredActionExecutor {
    suspend fun execute(ref: InfraredKeyRef): DomainResult<Unit>
}

本阶段只定义或保证稳定引用边界,不实现 Scene 模型、规则引擎或场景执行顺序。

9. 删除与重命名

9.1 删除

  • FAILED 和 READY 记录可以删除;
  • LEARNING 中不允许删除,避免后台结果写入已不存在的记录;
  • 删除只移除本地引用;
  • ABSDK 没有删除服务端红外 Key 的公开接口;
  • UI 必须提示“ABox 服务端可能仍保留该按键”;
  • 删除后未来场景引用解析失败,不再发射。

9.2 重命名

  • 只修改 displayName;
  • 不修改 id、deviceName 或 sdkKey;
  • 重命名后学习和发射能力保持不变;
  • 场景引用保持有效。

10. Android 前端

10.1 临时入口

在当前登录成功后的页面添加“红外控制”按钮,导航到独立 InfraredControlScreen。未来正式信息架构完成后只迁移入口。

10.2 页面内容

首版页面只包含:

  • 返回按钮和标题;
  • 当前数据源标识:Fake 或真实 ABox;
  • 红外控制器设备名输入和保存;
  • 已学习按键列表;
  • 学习新按键按钮;
  • 按键状态;
  • READY 按键的发射按钮;
  • FAILED 按键的重试按钮;
  • 删除入口和确认提示;
  • 基础错误反馈。

不加入复杂遥控器模板、图标系统、网格布局、品牌色或动画规范。

10.3 设备名

  • ABSDK 没有设备扫描能力;
  • 首次进入时手动填写 irDevName;
  • 保存后本地记住;
  • 没有设备名时禁止学习;
  • 修改设备名前提示旧按键仍绑定旧设备;
  • 列表只显示当前设备名下的按键。

10.4 学习交互

用户输入显示名称后开始学习。页面提示:

请将实体遥控器对准红外控制器并按下目标按键。

按键显示 LEARNING、READY 或 FAILED。允许离开页面,但不显示“停止硬件学习”。

10.5 发射交互

  • 只有 READY 按键可发射;
  • 发射期间禁用该按键,防止重复点击;
  • 成功显示短暂提示;
  • 失败显示具体错误;
  • 不自动重试;
  • 单次失败不删除或降级按键状态。

10.6 删除提示

删除确认文案应明确:

仅从本机列表移除,ABox 服务端可能仍保留该按键。

11. Debug Fake/Real 路由

Debug 构建提供显式运行模式:

  • FAKE:页面开发、自动化测试和错误模拟;
  • REAL_ABSDK:复用当前登录会话,调用真实 AbsdkBridge。

约束:

  • 页面持续显示当前模式;
  • 不允许在学习任务进行中切换模式;
  • Fake 和 Real 共用同一 ViewModel、应用用例和 Room Repository;
  • 模式和 Provider 数据必须隔离,避免 Fake 按键误用于真实设备;
  • Fake 支持按 ProviderProfileId + DeviceName + sdkKey 配置成功或失败;
  • 真实模式的所有 Gateway 接口绑定同一 AbsdkBridge 单例;
  • 真实模式必须显式 opt in CleartextAbsdkTransport;
  • Release 暂不启用真实 Bridge,除非项目明确批准传输安全方案。

真实 App 接入还必须补齐:

  • :absdk-bridge 依赖;
  • Hilt 绑定;
  • INTERNET 权限;
  • 经批准的明文网络配置;
  • 登录会话与红外 Gateway 的同一 Bridge 实例;
  • Token 失效后的页面返回登录流程。

12. 错误处理

继续使用现有 DomainResult 和 DomainError。不为首版扩充 sealed 类型,优先利用已有分类和 externalCode。

情况 UI 反馈 本地状态
Token 无效 登录已失效,返回登录 学习按键为 FAILED;发射按键仍为 READY
红外设备不存在 检查设备名 学习按键为 FAILED
红外设备离线 检查供电和网络 学习按键为 FAILED;发射按键仍为 READY
KEY/码值不存在 建议重新学习 发射按键仍为 READY,由用户决定重学
红外发射失败 显示失败,可手动重试 READY
网络超时 网络或 ABox 服务不可用 学习按键为 FAILED;发射按键仍为 READY
本地保存失败 无法开始学习 不调用硬件学习
学习成功但最终状态写入失败 显示数据保存异常并保留恢复信息 不生成新 sdkKey
未知错误 通用提示及脱敏错误码 按操作类型处理

日志不得包含:

  • 用户密码;
  • Token;
  • 原始 SDK Map;
  • 原始红外码或载荷;
  • 可用于重放的敏感字段。

13. 自动化测试

13.1 应用用例

覆盖:

  1. 学习前先保存 LEARNING;
  2. 学习成功更新为 READY;
  3. 学习失败更新为 FAILED;
  4. 重试复用原 sdkKey;
  5. 修改显示名不改变 sdkKey;
  6. 只有 READY 可以发射;
  7. 发射使用保存的 deviceName + sdkKey;
  8. 不同 Provider 数据隔离;
  9. 遗留 LEARNING 恢复为 FAILED;
  10. 删除后稳定引用失效;
  11. ViewModel 销毁后应用级学习任务仍可落库;
  12. 发射失败不改变 READY;
  13. 进行中的学习阻止运行模式切换。

13.2 Room

覆盖:

  • 新增、观察、查询、重命名和删除;
  • ProviderProfileId + DeviceName 过滤;
  • sdkKey 唯一约束;
  • Provider 隔离;
  • 状态转换;
  • 稳定 ID 跨重命名不变;
  • 首版 schema 和迁移测试基础;
  • 不存储原始红外载荷。

13.3 Fake

扩展现有 Fake,覆盖:

  • 按 Provider、设备和 Key 脚本化结果;
  • 调用参数与顺序;
  • 连续响应;
  • 成功、离线、超时和通用失败;
  • 不同 Provider 互不影响。

13.4 Bridge

保留现有覆盖并补充:

  • 学习参数和调用顺序;
  • 发射参数和调用顺序;
  • 红外错误码 20201 至 20206 的映射;
  • 学习、发射与其他设备调用的全局串行;
  • Token 失效清理会话;
  • SDK 空值和异常防御;
  • 不改变 downloadCodes() 的部分成功语义。

13.5 ViewModel 与 Compose

只验证功能,不验证最终视觉:

  • 无设备名不能学习;
  • 保存和恢复设备名;
  • 创建学习任务;
  • 显示学习中、成功和失败;
  • 重试学习;
  • 点击发射并防重复点击;
  • 删除确认;
  • Fake/Real 模式标识;
  • 登录失效引导;
  • 基本中文和英文资源。

14. 真机验收

第一轮使用现有 ABox 主机和红外控制器:

  1. 登录真实账号;
  2. 输入后台提供的准确 irDevName;
  3. 创建唯一按键,例如“测试电源”;
  4. 调用学习并记录耗时、返回码和消息;
  5. 学习成功后立即发射;
  6. 验证实体电器是否响应;
  7. 用同一按键重复发射;
  8. 重新学习同一 sdkKey,验证覆盖或报错行为;
  9. 红外控制器断电后验证离线错误;
  10. 不按实体遥控器,验证实际超时行为;
  11. 页面退出后验证后台结果落库;
  12. 进程被杀后验证遗留 LEARNING 恢复;
  13. Token 失效后验证返回登录;
  14. 记录 SDK 是否存在未文档化 dicDatas,但不将内容写入生产日志。

14.1 必须验证的未知项

  • 学习调用是立即返回还是阻塞到捕获完成;
  • 学习成功是否自动绑定 sdkKey;
  • 同一 sdkKey 重学是否覆盖;
  • 无信号时实际超时时间;
  • 页面离开后底层调用是否可完成;
  • 设备名格式、大小写和特殊字符规则;
  • 服务端是否永久保留已学习 Key;
  • SDK 是否返回未文档化数据;
  • sdkKey 的长度限制;
  • 真实模式所需初始化信息和调用顺序。

这些未知项不得通过业务层假设隐藏。真机结果应更新协议文档和后续设计。

15. 验收标准

本阶段完成需要满足:

  • 登录成功页面可以进入简单红外页面;
  • 可以手动配置并恢复一个红外设备名;
  • 可以创建多个命名按键;
  • 学习调用前已持久化 LEARNING;
  • 学习成功或失败能正确落库;
  • READY 按键可以调用发射;
  • 显示名修改不影响发射;
  • Fake 模式可以覆盖主要状态和错误;
  • Real 模式明确标识并调用真实 Bridge;
  • 页面退出后学习任务仍可更新状态;
  • 进程中断后遗留学习状态可以保守恢复;
  • 自动化测试通过;
  • 真机至少完成一次“学习成功 → 发射 → 实体响应”;
  • 未确认硬件语义被记录,不伪装为已支持;
  • 未来生活场景可以通过稳定 InfraredKeyRef 引用按键。

16. 后续工作

本设计完成后再分别规划:

  1. 多遥控器和房间组织;
  2. 统一产品视觉和正式导航入口;
  3. 红外码库导入;
  4. 生活场景编排;
  5. Scene 顺序执行和单项结果;
  6. 厂商明文 HTTP 的正式安全方案;
  7. 真机验证后对学习语义、超时和错误模型的修订。