From b9ed2cf01028260fc42ebc41825b757ee8a61ed4 Mon Sep 17 00:00:00 2001 From: mxhsnd <2049295946@qq.com> Date: Mon, 24 Aug 2026 00:16:14 +0800 Subject: [PATCH] docs: design infrared learning and sending --- .../2026-08-24-infrared-control-design.md | 606 ++++++++++++++++++ 1 file changed, 606 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-24-infrared-control-design.md diff --git a/docs/superpowers/specs/2026-08-24-infrared-control-design.md b/docs/superpowers/specs/2026-08-24-infrared-control-design.md new file mode 100644 index 0000000..fde4e17 --- /dev/null +++ b/docs/superpowers/specs/2026-08-24-infrared-control-design.md @@ -0,0 +1,606 @@ +# 红外学习与发射控制设计 + +## 1. 背景与目标 + +本项目当前可用于真实验证的设备包括 ABox 主机、红外控制器、智能插座和温湿度计。本阶段由 moler 负责红外能力,范围聚焦于红外控制器的基础闭环: + +1. 在登录成功后的现有页面提供一个临时入口; +2. 手动配置并记住一个 ABSDK 红外设备名; +3. 创建并命名多个红外按键; +4. 通过 ABSDK 发起红外学习; +5. 保存可重复使用的按键绑定; +6. 通过 ABSDK 发射已学习按键; +7. 支持 Fake 和真实 ABSDK 调试模式; +8. 为后续生活场景保留稳定按键引用。 + +首版前端只用于功能验证,沿用现有 Compose 和设计系统基础组件,不定义最终产品视觉风格。后续统一视觉改版只替换展示层,不改变红外领域、持久化、应用用例或硬件接口。 + +## 2. 已确认的 ABSDK 能力边界 + +ABSDK 0630 提供以下红外公开方法: + +```java +ABRet sendIr(String irDevName, String irKey) +ABRet studyIrByIrDevName(String irDevName, String irKey) +ABRet downloadIrCodes(List irInfos) +``` + +现有领域层已将其隔离为 `InfraredGateway`: + +```kotlin +interface InfraredGateway { + suspend fun sendKey( + profileId: ProviderProfileId, + deviceName: DeviceName, + key: String, + ): DomainResult + + suspend fun learnKey( + profileId: ProviderProfileId, + deviceName: DeviceName, + key: String, + ): DomainResult + + suspend fun downloadCodes( + profileId: ProviderProfileId, + codes: List, + ): DomainResult> +} +``` + +本设计必须遵守以下事实: + +- 学习接口只确认返回成功码和消息,没有已确认的原始红外码返回值; +- ABSDK 没有公开停止学习方法; +- ABSDK 没有公开获取学习结果或读取已学习原始码的方法; +- `downloadIrCodes()` 是把调用方已经拥有的红外码写入主机,不是读取学习结果; +- ABSDK 没有设备扫描或设备列表接口; +- 红外方法按 `irDevName + irKey` 寻址; +- SDK 调用为同步阻塞调用,现有 Bridge 在 `Dispatchers.IO` 上通过全局 `Mutex` 串行执行; +- SDK 使用固定明文 HTTP 目标,真实接入存在已知传输安全风险。 + +因此首版保存的是本地按键绑定,而不是原始红外波形或码值: + +```text +ProviderProfileId + DeviceName + sdkKey + displayName + learning status +``` + +真实硬件闭环成立的判据是: + +```text +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` 作为纯硬件能力端口: + +```text +登录成功页面 + └─ 临时入口:红外控制 + ↓ +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 稳定标识 + +```kotlin +@JvmInline +value class InfraredKeyId(val value: String) + +data class InfraredKeyRef( + val profileId: ProviderProfileId, + val keyId: InfraredKeyId, +) +``` + +`InfraredKeyRef` 是未来生活场景使用的最小稳定引用。场景不应保存显示名、设备名或厂商 Key。 + +### 5.2 学习状态 + +```kotlin +enum class InfraredKeyStatus { + LEARNING, + READY, + FAILED, +} +``` + +状态表示 App 对最近一次学习调用结果的本地记录,不代表设备主动上报的实时状态。 + +### 5.3 领域模型 + +```kotlin +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 和红外设备下唯一; +- 重试学习时复用; +- 修改显示名时保持不变; +- 不直接使用中文、空格或特殊字符。 + +建议格式: + +```text +app_ir_ +``` + +实际长度上限尚未由 ABSDK 文档确认,实现前需选择保守长度,并列入真机验证。 + +## 6. 本地持久化 + +采用 Room。Repository 边界至少支持: + +```kotlin +interface InfraredKeyRepository { + fun observeKeys( + profileId: ProviderProfileId, + deviceName: DeviceName, + ): Flow>> + + suspend fun getKey( + ref: InfraredKeyRef, + ): DomainResult + + suspend fun insertLearningKey( + key: SavedInfraredKey, + ): DomainResult + + suspend fun updateStatus( + ref: InfraredKeyRef, + status: InfraredKeyStatus, + lastErrorCode: String?, + updatedAtEpochMillis: Long, + ): DomainResult + + suspend fun renameKey( + ref: InfraredKeyRef, + displayName: String, + updatedAtEpochMillis: Long, + ): DomainResult + + suspend fun deleteKey( + ref: InfraredKeyRef, + ): DomainResult + + suspend fun findInterruptedLearning( + profileId: ProviderProfileId, + ): DomainResult> +} +``` + +具体签名可在实施计划中按项目既有风格调整,但必须保持职责和语义。 + +### 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` 记录: + +```text +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` 调用同一发送用例或薄执行器: + +```kotlin +interface InfraredActionExecutor { + suspend fun execute(ref: InfraredKeyRef): DomainResult +} +``` + +本阶段只定义或保证稳定引用边界,不实现 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 学习交互 + +用户输入显示名称后开始学习。页面提示: + +```text +请将实体遥控器对准红外控制器并按下目标按键。 +``` + +按键显示 `LEARNING`、`READY` 或 `FAILED`。允许离开页面,但不显示“停止硬件学习”。 + +### 10.5 发射交互 + +- 只有 `READY` 按键可发射; +- 发射期间禁用该按键,防止重复点击; +- 成功显示短暂提示; +- 失败显示具体错误; +- 不自动重试; +- 单次失败不删除或降级按键状态。 + +### 10.6 删除提示 + +删除确认文案应明确: + +```text +仅从本机列表移除,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. 真机验证后对学习语义、超时和错误模型的修订。