docs: reorganize project documentation

This commit is contained in:
2026-08-28 10:38:47 +08:00
parent 32c7d4f8cc
commit 1adcb0fb3b
22 changed files with 495 additions and 5202 deletions
+22
View File
@@ -0,0 +1,22 @@
# ABox Manager 文档
本目录只保留需要长期维护的产品、架构、协议、验证和第三方归属资料。功能分支计划、Pull Request 描述、临时 UI 方案和一次性实施记录不进入长期文档。
## 文档索引
- [`project-overview.md`](project-overview.md):正式版产品范围、关键行为和团队职责。
- [`architecture.md`](architecture.md):当前模块、依赖方向、数据边界和运行时架构。
- [`protocol/absdk-0630.md`](protocol/absdk-0630.md):ABSDK 0630 已确认事实、实现约束和待确认事项。
- [`vendor/ABSDK接口部分_0630.md`](vendor/ABSDK接口部分_0630.md):厂商接口资料的可搜索 Markdown 版本。
- [`vendor/ABSDK接口部分_0630.xls`](vendor/ABSDK接口部分_0630.xls):厂商原始接口表格。
- [`testing/device-data-capability-matrix.md`](testing/device-data-capability-matrix.md):可在界面与自动化中使用的真实数据能力。
- [`testing/infrared-hardware-validation.md`](testing/infrared-hardware-validation.md):红外控制器真机验证流程。
- [`testing/release-lan-hardware-validation.md`](testing/release-lan-hardware-validation.md):正式版局域网端到端验收流程。
- [`third-party/flipper-irdb.md`](third-party/flipper-irdb.md):内置开源红外码库的来源与许可说明。
## 维护规则
- 文档描述当前 `main` 的行为,不描述尚未合并的分支状态。
- SDK 推测必须标为待确认;已确认事实应给出代码、JAR、厂商资料或真机观测依据。
- 不记录密码、Token、完整网关地址、完整设备标识、原始红外码值或未脱敏 SDK 响应。
- 代码行为发生变化时,同一 Pull Request 应更新受影响的长期文档。
+86 -123
View File
@@ -1,166 +1,129 @@
# ABox Manager 架构基线
# ABox Manager 架构
## 1. 文档范围
## 总体结构
本文记录已建立的模块边界、依赖方向、提供方路由和并行开发接口,覆盖 ABox(局域网 ABSDK)与本地体验两条连接路径。ABSDK 0630 的初始化、签名细节和会话清理等未确认内容不在本文中固化,协议观测证据见 `docs/protocol/absdk-0630.md`。
产品范围和固定分工以 `docs/project_plan.md` 为准,原始简要需求继续保留在 `docs/requirement_spec.md`。
## 2. 当前模块
ABox Manager 是单 Activity 的 Kotlin/Compose Android 应用。业务能力通过纯 Kotlin 领域端口表达,Android 模块负责持久化、系统集成、依赖注入和 UI,厂商 SDK 被限制在独立防腐层中。
```text
abox-mgr/
├── android/
│ ├── app/ # Android 应用、提供方路由与依赖注入组合根
│ └── core/
│ ├── designsystem/ # Compose Material 3 设计系统基础
│ └── session/ # 提供方 profile、凭据 Keystore 持久化
├── domain/ # 纯 Kotlin 领域模型和公共端口
├── absdk-bridge/ # ABSDK 0630 防腐层(生产)
├── runtime/simulation/ # 本地体验模拟运行时(生产)
├── testing/fakes/ # 测试专用 Fake(仅测试依赖)
├── docs/
└── samples/ # 历史示例,仅供参考
├── domain/ # 领域模型、端口、用例与执行器
├── absdk-bridge/ # ABSDK 0630 防腐层
├── runtime/simulation/ # 本地体验生产模拟运行时
├── testing/fakes/ # 测试专用 Fake
└── android/
├── core/
│ ├── designsystem/ # Compose 主题、组件和设计令牌
│ ├── preferences/ # 界面偏好 DataStore
│ ├── devicecatalog/ # ABox 本地设备目录与 Provider 路由
│ └── session/ # Profile、会话偏好和加密凭据
├── feature/
│ ├── infrared/ # 遥控器、按键、码库与学习流程
│ └── automation/ # 情景、自动化、通知和活动记录
└── app/ # 应用组合根、导航和设备页面
```
依赖方向:
依赖遵循以下方向:
```text
android/app ────────────────┬──> android/core/designsystem
├──> android/core/session ──> domain
├──> domain
├──> absdk-bridge ──> domain
└──> runtime/simulation ──> domain
android/core/designsystem ─────> Compose Material 3
testing/fakes(仅测试)────────> domain
domain ────────────────────────> Kotlin 与 Coroutines
android/app ──> android/core/*, android/feature/*, domain
android/feature/* ──> domain
android/core/* ──> domain
absdk-bridge ──> domain
runtime/simulation ──> domain
testing/fakes ──> domain
domain ──> Kotlin 与 Coroutines
```
`domain` 不依赖 Android、Compose、Room、ABSDK 或具体网络实现。业务 UI 只依赖领域端口和类型化结果。
`domain` 不依赖 Android、Compose、Room、DataStore 或 ABSDK。UI 不直接调用厂商 SDK。
## 3. 提供方与数据隔离
## Provider 与会话
认证前应用只提供两种入口,两者在所有构建变体中始终可用:
应用支持 `ABOX` 和本地体验两种 Provider。`ProviderProfileId` 是所有会话、目录、遥控器、情景、自动化状态和活动记录的数据隔离键。
1. **ABox**:通过进程内唯一的真实 `AbsdkBridge` 连接当前局域网中的智能家居网关,登录成功后进入应用;
2. **本地体验**:由 `:runtime:simulation` 直接建立模拟会话并进入应用,不请求凭据、不访问网络、不调用 ABSDK。
- 任一时刻只有一个活动 Provider;
- 切换 Provider 前结束当前会话;
- ABox 会话由进程内唯一 `AbsdkBridge` 维护;
- 本地体验由 `runtime/simulation` 建立无凭据会话;
- 自动登录凭据使用 Android Keystore 的 AES-256-GCM 加密,profile id 作为 AAD;
- ABSDK 没有 logout 和 Token setter,退出只清理 App/Bridge 本地会话,重新登录覆盖 SDK Token。
每个提供方 profile 由 `ProviderProfileId` 标识,所有会话、设备目录和设备操作都显式携带该标识。
## 领域端口
约束:
主要公共端口包括:
- 任一时刻只启用一个提供方;
- 切换提供方前必须退出当前会话;
- 不同提供方的数据、凭据和设备状态不得互相混用;
- 本地体验不调用 ABSDK,也不启动本地 HTTP 服务;
- Release 不是特殊情况:debug 与 release 暴露相同的提供方和路由行为。
- `SessionGateway`、`LocalSessionGateway`;
- `ProviderProfileStore`、`CredentialStore`;
- `DeviceCatalog`;
- `SocketGateway`、`TemperatureHumidityGateway`、`InfraredGateway`;
- `VirtualRemoteRepository`、`InfraredKeyRepository`;
- `SceneRepository`、`SceneExecutor`、`NotificationGateway`;
- `ActivityRepository`。
## 4. 公共领域端口
所有设备与会话操作返回 `DomainResult<T>`。错误通过 `DomainError` 分类,必要时保留脱敏的厂商错误码;`ABRet`、原始 `Map` 和厂商对象不得越过 Bridge。
`domain` 当前提供以下最小端口:
## 设备目录与默认内容
- `SessionGateway`:登录、观察会话、验证会话和退出;
- `LocalSessionGateway`:无凭据本地会话的标记端口,继承 `SessionGateway`;
- `ProviderProfileStore`:提供方 profile、活动 profile 和登录偏好持久化;
- `CredentialStore`:自动登录凭据的加密存取;
- `DeviceCatalog`:按提供方观察设备目录;
- `SocketGateway`:查询和设置插座状态;
- `TemperatureHumidityGateway`:读取温度和湿度;
- `InfraredGateway`:发送、学习和下载红外码。
ABSDK 0630 没有设备发现或列表接口。ABox 设备目录由 `android/core/devicecatalog` 使用 Room 按 profile 持久化,设备操作最终使用 `DeviceName` 调用网关。
端口只覆盖需求和 ABSDK 0630 文档已经确认的行为。`DeviceId` 是 App 本地目录中的稳定记录标识;设备操作必须使用 ABSDK 0630 实际接受的 `DeviceName`。同一提供方配置和设备类型内,`DeviceName` 必须唯一,设备目录在添加、编辑和导入时负责强制该约束。设备目录的增删改与二维码导入、遥控器布局、情景动作和持久化模型将在对应模块提出后增量加入,不在公共层提前假定。
ABox profile 首次初始化时,如果某一设备类型尚不存在,App 补充以下默认记录:
## 5. 结果与错误边界
- `智能插座`;
- `温湿度传感器`;
- `红外控制器`。
所有端口返回 `DomainResult<T>`:
初始化按设备类型判断,不迁移或重命名已有记录。用户修改设备名后,名称必须与网关实际设备名一致。
- `Success<T>` 携带类型化领域值;
- `Failure` 携带 `DomainError`;
- `DomainError.externalCode` 可以保留外部 SDK 错误码用于诊断;
- UI 不接触 `ABRet`、原始 `Map`、HTTP 表单或任何服务端数据库模型。
同一初始化流程会为 `红外控制器` 添加两个未学习的自定义遥控器:自定义电视遥控器和自定义面板遥控器。
ABSDK 防腐层已在 `:absdk-bridge` 中完成同步阻塞调用的后台串行化、返回解析和错误映射。
## 设备操作
## 6. Fake 边界
`android/app` 中的路由网关根据活动 Provider 分发操作:
`testing/fakes` 提供会话、设备目录、插座、温湿度和红外 Fake。它们:
- ABox 调用共享 `AbsdkBridge`;
- 本地体验调用确定性的模拟实现。
- 不使用网络、Android API、真实时间或随机数;
- 支持成功、失败、无数据、离线和 Token 无效场景;
- 对插座状态和设备目录按 `ProviderProfileId` 隔离;
- 记录红外学习、发送和下载调用;
- 可直接用于其他模块的 ViewModel 与 UI 测试。
插座控制不会把期望值直接当成真实状态。命令成功后必须回读;回读失败时 UI 保持“等待确认”或错误状态。温湿度允许单字段缺失,只有两个字段都不存在时才进入无数据状态。设备离线通过领域错误显式传递到 UI。
Fake 只用于开发和测试,不能作为真实 ABox 或真实硬件验收证据。`:testing:fakes` 是测试专用模块,不得加入应用生产依赖。
## 红外
ABSDK 不提供设备目录发现能力。生产 ABox 设备目录由 `:android:core:devicecatalog` 按当前 profile 持久化,来源是用户明确配置或导入的真实 `DeviceName`;它不从本地体验目录复制数据,也不把测试 Fake 绑定到生产组合根。Local profile 仍由 `SimulationDeviceCatalog` 提供独立的确定性目录。
`android/feature/infrared` 使用独立 Room 数据库保存虚拟遥控器、按钮布局、学习状态和硬件槽位信息。
## 7. 本地体验模拟运行时
- 预置遥控器从内置码库下载,不提供学习入口;
- 自定义遥控器通过硬件学习建立按键;
- 按键状态区分未学习、学习中、失败和可发送;
- 学习任务由应用级协调器执行,页面离开后可继续;
- 进程中断遗留的学习状态在恢复时保守标记为失败;
- 开源码库来源和转换约束见 `docs/third-party/flipper-irdb.md`。
`:runtime:simulation` 是生产 Kotlin/JVM 模块,为本地体验提供确定性实现:
## 情景、自动化与活动记录
- `SimulationSessionGateway`:直接建立无凭据的本地会话;
- `SimulationDeviceCatalog`:内置的本地体验设备目录;
- `SimulationSocketGateway`、`SimulationTemperatureHumidityGateway`、`SimulationInfraredGateway`:固定行为的模拟设备操作。
`android/feature/automation` 使用 Room 保存情景文档、触发器匹配状态、定时 occurrence claim 和活动记录。
模拟实现不暴露脚本化失败注入、调用记录或断言辅助;这些能力只存在于 `:testing:fakes`。
手动指令直接执行。自动指令由 WorkManager 调度和 `AutomationEngine` 评估:
## 8. Android 组合根与提供方路由
1. 收集相关温湿度与插座状态快照;
2. 对每个启用的自动指令评估多个触发器;
3. 状态型触发器只在不匹配到匹配的转换上产生新触发;
4. 时间触发器通过 occurrence key 去重;
5. 任一新触发出现后,再要求全部条件满足;
6. 按情景配置执行动作并记录结果。
`android/app` 是单 Activity Compose 应用,负责:
触发器支持时间、温度、湿度和插座状态;条件支持温度、湿度、插座状态和时间范围;动作支持插座、虚拟遥控器、红外按键、延迟和通知。
- `ABoxManagerApplication` 与 Hilt 组合根;
- `MainActivity`;
- 根 `NavHost` 与提供方选择、登录、认证后路由;
- 默认英文和简体中文资源;
- 使用 `android/core/designsystem` 的主题和基础组件。
活动记录按 profile 保存设备命令、状态确认、传感器刷新、红外发送/学习、情景及动作结果。记录只保存领域层允许的信息,不保存凭据、原始响应或红外码值。
组合根按端口绑定生产实现:
## 界面偏好
```text
SessionGateway -> AbsdkBridge(进程内单例)
LocalSessionGateway -> SimulationSessionGateway
SocketGateway -> RoutingSocketGateway
TemperatureHumidityGateway -> RoutingTemperatureHumidityGateway
InfraredGateway -> RoutingInfraredGateway
```
`android/core/preferences` 使用 DataStore 保存主题模式、动态颜色、强调色、色彩风格、色彩规范、界面缩放和触觉反馈偏好。当前生产界面保持 Material 渲染;未采用的渲染器值保留兼容数据,但不作为正式功能承诺。预测性返回默认关闭,且不向用户显示设置入口。
`AbsdkBridge` 以 Hilt 单例提供并在所有构建变体中相同:会话与插座/温湿度/红外操作共享同一个 Bridge 实例,使互斥锁和 Bridge 本地会话与厂商 SDK 单例状态保持一致。三个 Routing 网关读取活动 profile 并按提供方分发——ABox 转发到共享的 AbsdkBridge 实现,本地体验转发到模拟实现。任何功能 UI 都不直接选择真实或 Fake 实现。
## ABSDK 边界
设备目录导航、插座与温湿度页面、红外页面和情景页面由固定模块负责人实现,不放入工程基础层。
`absdk-bridge` 是厂商 JAR 的唯一生产接入点:
## 9. ABSDK Bridge
- 全部同步 SDK 调用转移到后台并串行执行;
- 进程内共享同一个 SDK/Bridge 实例;
- 不调用语义未确认的 `setHostInfo`;
- 使用 `@CleartextAbsdkTransport` 显式标记明文传输风险;
- App 仅因厂商 JAR 的历史行为允许明文流量,不把该策略扩展到其他网络功能。
`:absdk-bridge` 是纯 Kotlin/JVM 防腐层,依赖 `domain` 并实现会话、插座、温湿度和红外 Gateway。该模块:
- 只在 `VendorAbsdkClient` 中接触 `ABSDK`、`ABRet` 和 `IrInfo`;
- 将所有同步调用移到后台并串行进入全局 SDK;
- 将返回值和错误码转换为 `DomainResult` 和领域模型;
- 不调用未确认的 `setHostInfo`;
- logout 只清除 Bridge 本地会话,因为 JAR 没有公开 logout 或 Token setter;
- 生产工厂始终返回进程内同一个 Bridge,使互斥锁和本地会话与厂商单例保持一致;
- 真实工厂标记为 `@CleartextAbsdkTransport`,调用方必须显式 opt-in 承认厂商 JAR 的硬编码 HTTP 风险;
- 当前直接引用归档 Sample 目录中的唯一一份 JAR,不复制厂商二进制;
- ABSDK 0630 没有设备目录能力,Bridge 因此也不提供设备目录或设备发现。
App 组合根已通过 Hilt 单例绑定该 Bridge,绑定处以 `@CleartextAbsdkTransport` 显式 opt-in 承认厂商 JAR 的硬编码明文 HTTP 风险;App 同时声明 `INTERNET` 权限并允许明文传输(`usesCleartextTraffic`),这是项目所有者对当前局域网集成约束的明确风险接受,不代表传输具备机密性或完整性。协议证据与限制记录在 `docs/protocol/absdk-0630.md`。
## 10. Provider Session
`:android:core:session` 负责提供方登录的持久化与 Android 安全边界:
- `ProviderProfileStore` 使用 DataStore 保存 profile、当前活动 profile、记住用户名和自动登录开关;已移除的提供方类型(如历史兼容后端值)被忽略,不会成为活动 profile;
- `CredentialStore` 使用 Android Keystore 中的 AES-256-GCM 密钥加密自动登录凭据,DataStore 只保存带随机 IV 的密文;
- `ProviderProfileId` 作为 GCM AAD,防止不同 profile 的密文互换;
- 本地体验的会话由 `:runtime:simulation` 的 `SimulationSessionGateway` 提供,不调用 HTTP 或 ABSDK。
纯 Kotlin `SessionCoordinator` 统一处理启动恢复、提供方选择、登录偏好、自动登录、Token 失效重登、退出和切换提供方。ABox 登录成功必须来自注入的真实 Bridge 返回 `DomainResult.Success<Session>`;无效凭据和网络错误停留在登录页并显示本地化错误。ABox 远端退出清除密码和 Session,但保留 profile 与记住的用户名以便再次显示登录页;本地体验退出清除本地会话和活动 profile,回到提供方选择。
两个提供方在所有构建变体中始终可用,不存在按 debug/release 区分的可用性开关。
登录完成后进入 `authenticated` 协作路由。设备中心、详情、红外、情景、自动化和活动记录由对应 Android 模块提供,并通过当前 profile 路由到真实或本地体验端口。
## 11. 后续边界
Room 与设备业务 DataStore 的具体 schema/key 只在对应数据所有权明确后创建。生产 ABox 设备目录由设备目录模块维护,不能回退到 `FakeDeviceCatalog`。历史示例中的 AsyncTask、明文密码存储和散落 SDK 调用不得进入正式架构;ABSDK 厂商 JAR 的明文局域网传输风险已明确记录并由所有构建变体共同承担。
协议事实、端点、签名边界和待确认事项见 `docs/protocol/absdk-0630.md`。
@@ -1,195 +0,0 @@
# Device List Compose UI Change Log
Branch: `feature/device-control-compose-ui`
## Summary
This branch adds only the Kotlin Jetpack Compose device list page owned by this feature area.
The implementation provides a reusable Compose screen that displays the ABox terminal device categories:
- Smart socket
- Temperature/humidity sensor
- Infrared controller
This branch intentionally does not modify the app entry point or app shell routing. The app shell, login flow, and detail-feature routing are owned by other feature branches.
## Added Files
### `android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecontrollist/DeviceListScreen.kt`
Adds the device list page.
The page displays entries for:
- Smart socket
- Temperature/humidity sensor
- Infrared controller
Current scope:
- Display the device categories as list entry UI.
- Keep the UI in Kotlin Compose.
- Expose click callbacks for each device category.
- Avoid adding app-shell routing, detail routes, or detail control pages in this branch.
Integration API:
```kotlin
DeviceListScreen(
onSocketClick = { /* route to socket module later */ },
onTemperatureHumidityClick = { /* route to temperature/humidity module later */ },
onInfraredClick = { /* route to infrared controller module later */ },
)
```
Future integration point:
- The app shell or feature owner responsible for routing can host this screen and connect these callbacks to their own routes/modules later.
## Architecture Notes
This branch follows the current repository direction:
- Kotlin instead of Java
- Jetpack Compose instead of XML layouts
- Single-Activity UI instead of multiple Activities
- Navigation Compose compatibility through external callbacks
- Existing design-system wrappers where practical
This branch intentionally does not copy the previous Java/XML experiment module into the team repository.
## Scope Intentionally Not Included
This branch does not include:
- App entry point changes
- App shell or login routing changes
- A device-control navigation graph file
- Socket control screen implementation
- Temperature/humidity status screen implementation
- Infrared controller screen implementation
- Shared detail-control component scaffolding
- Direct ABSDK calls from Compose UI
- New Java Activity classes
- XML layout files
- A separate Android module copied from the experiment project
- Full session/login integration for device gateway calls
- Persistent infrared key storage
These should be added by the corresponding feature owners in later branches or follow-up commits after team architecture review.
## Verification
- Built and ran `android.app` from Android Studio successfully during local validation.
- `:android:app:assembleDebug` completed successfully after trimming the feature scope.
## Files Intended for Commit
The intended commit scope is limited to:
```text
android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecontrollist/DeviceListScreen.kt
docs/device-control-compose-ui-change-log.md
```
`settings.gradle.kts` is intentionally excluded from this feature commit. Local Gradle/JDK/toolchain setup should be handled on the developer machine and should not modify the team-provided project configuration in this branch.
---
# 设备列表 Compose UI 修改说明(中文)
分支:`feature/device-control-compose-ui`
## 概要
本分支只新增当前分工负责的 Kotlin Jetpack Compose 设备列表页面。
设备列表展示的设备类别包括:
- 智能插座
- 温湿度传感器
- 红外控制器
本分支故意不修改 App 入口或应用壳路由。程序入口、登录流程和各设备详情模块路由由其他功能分支负责。
## 新增文件
### `android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecontrollist/DeviceListScreen.kt`
新增设备列表页面。
页面展示三个设备入口:
- 智能插座
- 温湿度传感器
- 红外控制器
当前实现范围:
- 展示设备类别入口 UI。
- 保持 Kotlin Compose 实现。
- 为每个设备类别暴露点击回调。
- 不在本分支实现 App 入口接入、设备详情 route 或设备控制页面。
对接 API:
```kotlin
DeviceListScreen(
onSocketClick = { /* 后续跳转到智能插座模块 */ },
onTemperatureHumidityClick = { /* 后续跳转到温湿度模块 */ },
onInfraredClick = { /* 后续跳转到红外控制器模块 */ },
)
```
后续接入点:
- 由负责 App shell 或对应设备模块的同学,在各自分支中挂载这个页面,并把这些回调连接到他们自己的详情路由或功能模块。
## 架构说明
本分支遵循当前小组仓库的技术方向:
- 使用 Kotlin,而不是 Java。
- 使用 Jetpack Compose,而不是 XML layout。
- 使用单 Activity 架构,而不是多个 Activity 跳转。
- 通过外部回调保持与 Navigation Compose 的兼容性。
- 尽量复用已有 design system 中的组件封装。
本分支没有把之前个人实验项目中的 Java/XML 模块直接复制进团队仓库。
## 本分支未包含的内容
本分支暂不包含:
- App 入口修改。
- App shell 或登录路由修改。
- 设备控制 navigation graph 文件。
- 智能插座控制页面。
- 温湿度状态页面。
- 红外控制器操作页面。
- 设备详情控制页面共用组件。
- Compose UI 中直接调用 ABSDK。
- 新增 Java Activity。
- 新增 XML layout。
- 从个人实验项目复制独立 Android 模块。
- 完整登录/session 与设备 gateway 的联动。
- 红外学习按键的持久化存储。
这些内容建议由对应模块负责人在后续 branch 或后续 commit 中继续实现。
## 验证
- 本地验证过程中已从 Android Studio 成功 build/run `android.app`。
- 修剪职责范围后,`:android:app:assembleDebug` 构建成功。
## 预期提交范围
本次提交范围应限制在:
```text
android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecontrollist/DeviceListScreen.kt
docs/device-control-compose-ui-change-log.md
```
`settings.gradle.kts` 当前不属于本次设备列表 UI 功能提交范围。本地 Gradle/JDK/toolchain 配置应在开发者本机处理,不应修改团队提供的项目配置文件。
@@ -1,133 +0,0 @@
# WIP: Add device list Compose screen
## Summary
This MR adds only the reusable Kotlin Jetpack Compose device list screen for my assigned scope.
The screen displays the three ABox device categories:
- Smart socket
- Temperature/humidity sensor
- Infrared controller
This MR intentionally does not modify the app entry point, login flow, app shell routing, or any device detail/control screen. Those parts are handled by other feature branches.
## Changes
- Add `DeviceListScreen`.
- Expose click callbacks for each device category:
- `onSocketClick`
- `onTemperatureHumidityClick`
- `onInfraredClick`
- Update the device-list change log document.
- Remove the earlier out-of-scope temporary device control screens from this branch.
## Scope
Included:
- Device list Compose UI component
- Device category entry UI
- Callback API for future route integration
Not included:
- App entry point changes
- Login flow changes
- Device control navigation graph
- Smart socket control screen
- Temperature/humidity status screen
- Infrared controller screen
- Direct ABSDK calls
- Java Activity classes
- XML layout files
## Integration Notes
The screen is designed to be hosted by the app shell or routing owner later:
```kotlin
DeviceListScreen(
onSocketClick = { /* route to socket module */ },
onTemperatureHumidityClick = { /* route to temperature/humidity module */ },
onInfraredClick = { /* route to infrared controller module */ },
)
```
The current branch keeps these callbacks external so other feature owners can connect their modules without this MR taking ownership of routing.
## Verification
- Built and ran `android.app` locally from Android Studio.
- `:android:app:assembleDebug` completed successfully after trimming this branch to the device-list component scope.
## Notes
`settings.gradle.kts` is intentionally not part of this MR. Local Gradle/JDK/toolchain setup is handled on the developer machine and should not modify the team-provided project configuration.
---
# WIP: 新增设备列表 Compose 页面
## 概要
本 MR 只新增我当前分工负责的 Kotlin Jetpack Compose 设备列表页面组件。
设备列表展示三类 ABox 设备入口:
- 智能插座
- 温湿度传感器
- 红外控制器
本 MR 故意不修改 App 入口、登录流程、应用壳路由,也不实现任何设备详情/控制页面。这些部分由其他功能分支负责。
## 修改内容
- 新增 `DeviceListScreen`。
- 为三类设备入口暴露点击回调:
- `onSocketClick`
- `onTemperatureHumidityClick`
- `onInfraredClick`
- 更新设备列表变更说明文档。
- 从本分支中移除早期临时加入、但不属于当前分工范围的设备控制页面。
## 本 MR 包含
- 设备列表 Compose UI 组件
- 设备类别入口 UI
- 后续路由接入所需的 callback API
## 本 MR 不包含
- App 入口修改
- 登录流程修改
- 设备控制 navigation graph
- 智能插座控制页面
- 温湿度状态页面
- 红外控制器页面
- Compose UI 中直接调用 ABSDK
- Java Activity
- XML layout
## 对接说明
该页面后续可以由 App shell 或路由负责人挂载:
```kotlin
DeviceListScreen(
onSocketClick = { /* 后续跳转到智能插座模块 */ },
onTemperatureHumidityClick = { /* 后续跳转到温湿度模块 */ },
onInfraredClick = { /* 后续跳转到红外控制器模块 */ },
)
```
当前分支只提供页面和回调 API,不负责具体跳转目标,以便其他模块负责人对接自己的功能页面。
## 验证
- 已在本地通过 Android Studio 成功 build/run `android.app`。
- 修剪为设备列表组件范围后,`:android:app:assembleDebug` 构建成功。
## 备注
`settings.gradle.kts` 不属于本 MR 范围。本地 Gradle/JDK/toolchain 配置在开发者本机处理,不修改团队提供的项目配置文件。
-92
View File
@@ -1,92 +0,0 @@
// AsyncTask用法的Demo
package tr.dhee.abox.demo.ui.login;
import android.os.AsyncTask;
import android.os.Bundle;
import android.os.Process;
import android.view.View;
import android.widget.Button;
import androidx.activity.ComponentActivity;
import androidx.activity.EdgeToEdge;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat;
import com.dhc.absdk.ABRet;
import com.dhc.absdk.ABSDK;
import java.lang.reflect.Parameter;
import tr.dhee.abox.demo.R;
public class MainActivity extends AppCompatActivity {
Button buttonLoginABox;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
EdgeToEdge.enable(this);
setContentView(R.layout.activity_main);
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> {
Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
return insets;
});
buttonLoginABox = findViewById(R.id.buttonLoginAbox);
buttonLoginABox.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
//使用aboxSDK里面的芳芳
ABRet abRet = ABSDK.getInstance().loginWithUsername("a", "a");
//替换成如下写法
String[] userArray = {"", ""};
LoginAsyncTask loginAsyncTask = new LoginAsyncTask();
loginAsyncTask.execute(userArray);
}
});
}
class LoginAsyncTask extends AsyncTask<String, Void, ABRet> {
@Override
protected ABRet doInBackground(String... params) {
return ABSDK.getInstance().loginWithUsername(params[0], params[1]);
}
@Override
protected void onPostExecute(ABRet abRet) {
super.onPostExecute(abRet);
if (abRet.getCode().equals("00000")) {
//跳转到设备列表页面
}
}
}
}
-33
View File
@@ -1,33 +0,0 @@
<!-- LoginAsyncTaskDemo 配套布局文件 -->
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:id="@+id/textView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Button
android:id="@+id/buttonLoginAbox"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="260dp"
android:layout_marginBottom="48dp"
android:text="Button"
app:layout_constraintBottom_toTopOf="@+id/textView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
+90
View File
@@ -0,0 +1,90 @@
# ABox Manager 项目概览
## 产品定位
ABox Manager 是面向 Android 手机和平板的 ABox 智能家居管理应用。正式版版本为 `1.0.0`,applicationId 为 `com.flagship.abox.manager`,最低支持 Android 8.0(API 26)。
应用提供两种彼此隔离的使用方式:
1. **ABox**:使用 ABSDK 连接当前局域网中的 ABox 网关;
2. **本地体验**:不使用账号、网络或 ABSDK,通过确定性模拟数据体验完整界面和主要流程。
不同提供方的会话、设备、遥控器、情景、自动化和活动记录互不混用。
## 正式版能力
### 会话与账户
- ABox 用户名和密码登录;
- 记住用户名与可选自动登录;
- Android Keystore 加密保存自动登录凭据;
- Token 失效后清理会话并返回登录流程;
- 从账户面板切换连接方式、进入主题和关于页面或退出当前会话。
### 设备中心
- 展示智能插座、温湿度传感器和红外控制器;
- ABox 首次初始化时按设备类型补充默认设备:`智能插座`、`温湿度传感器`、`红外控制器`;
- 设备名称是网关调用使用的匹配标识,用户可以在 App 中修改名称;
- 明确展示加载、在线、离线、无数据和错误状态;
- 插座命令完成后回读真实状态,无法确认时不得把期望状态冒充为设备终态。
### 红外遥控器
- 添加遥控器时先选择下载预置红外码库,或创建通过学习导入的自定义遥控器;
- 非自定义预置遥控器不提供学习入口;
- 支持电视、空调、风扇等预置类型和开源红外码库;
- 支持自定义电视遥控器和自定义面板遥控器;
- 自定义遥控器可添加、编辑、排序和删除按钮,并区分未学习、学习中、学习失败和可发送状态;
- 管理页面支持硬件槽位、遥控器排序、编辑和删除;
- ABox 首次初始化预置两个自定义遥控器,不预置非自定义遥控器。
### 情景、自动化与活动
- 手动指令和自动指令均可从界面手动执行,便于日常使用和测试;
- 动作支持插座开关、虚拟遥控器按钮、红外按键、延迟和系统通知;
- 自动指令触发器支持时间、温度、湿度和插座状态;
- 条件支持温度、湿度、插座状态和时间范围;
- 多个触发器中任一触发器产生新的匹配即可尝试执行,所有条件必须同时满足;
- 状态型触发器只在从“不匹配”进入“匹配”时触发,不会在每次轮询持续重复执行;
- 情景、自动化运行状态和活动记录通过 Room 按 profile 持久化;
- 活动记录区分用户、自动化和系统来源,以及成功、失败、取消和等待确认结果。
### 外观与本地化
- 使用 Kotlin、Jetpack Compose 和 Material;
- 支持系统、浅色和深色模式;
- 支持动态颜色、手动强调色、色彩风格、色彩规范和界面缩放;
- 预测性返回保持关闭且不在界面中提供开关;
- 提供英文和简体中文本地化,其他系统语言回退到英文。
## 数据与安全边界
- ABSDK 0630 没有设备发现接口,ABox 设备目录由 App 本地维护;
- 业务 UI 不接触 `ABRet`、原始 `Map` 或厂商错误文本;
- App 不伪造网关没有提供的电压、电流、功率、电量、历史趋势或诊断数据;
- 本地体验数据不能作为真实 ABox 或真实硬件验收证据;
- 厂商 JAR 使用明文 HTTP,只允许在已授权、受控的局域网环境中使用;
- 日志和文档不得保存密码、Token、完整网络标识、原始红外码值或未脱敏响应。
## 开发团队
开发团队名称为 **Flagship**。
| 成员 | 主要职责 |
|---|---|
| KremeCN | 项目架构与全模块开发 |
| alZerNest | 设备目录与导航 |
| Aurum | 插座与环境传感器 |
| Moler | 红外遥控器 |
| tzh | 情景与应用集成 |
职责用于说明主要所有权,不限制成员参与其他模块的设计、实现、修复和集成。
## 当前限制
- ABSDK 只支持文档和 JAR 已确认的设备能力;
- 网关设备通过名称匹配,重命名后必须与网关实际名称一致;
- 红外开源码库只导入能够可靠转换的 NEC 和 NECext 数据;
- 自动化依赖 Android 后台调度和周期性设备读取,系统省电策略可能影响触发时效;
- ABSDK 的初始化参数、完整线程安全保证和域名到局域网网关的映射机制仍未由厂商完整确认。
-590
View File
@@ -1,590 +0,0 @@
# ABox Manager 项目计划
## 1. 文档目的
本文档是 ABox Manager 的项目执行蓝图,用于统一产品范围、技术方向、五人固定分工、模块依赖、实施阶段和验收原则。
本文档只记录已经确认的方向。尚未确认的 SDK 行为、协议细节和外部环境必须保留为待确认事项,不得将推测直接作为实现依据。
---
## 2. 项目现状
仓库目前主要包含:
- 产品需求概要;
- ABSDK 0630 接口文档与协议观测记录;
- 正式 Android 应用工程(含提供方登录与本地体验运行时);
- ABSDK 防腐层、领域接口、模拟运行时和测试 Fake;
- 清理归档后的 ABSDK Android Sample;
- Git 和 AI 工具协作规则。
当前还没有设备目录、插座温湿度、红外和情景模式的正式实现,也没有 CI Runner。
重要参考资料:
```text
docs/requirement_spec.md
docs/protocol/absdk-0630.md
docs/architecture.md
docs/examples/
samples/abox-sdk-sample/
```
---
## 3. 产品目标
ABox Manager 是面向 Android 手机和平板的智能家居管理应用,首版覆盖以下核心能力:
1. 登录和会话管理;
2. 设备列表与页面导航;
3. 插座状态查询和开关控制;
4. 温湿度查询和展示;
5. 红外遥控器创建、学习和发送;
6. 基于插座和红外动作的情景模式;
7. 数据本地持久化;
8. 简体中文和英文界面;
9. 手机和平板响应式界面。
---
## 4. MVP 范围
### 4.1 ABox 登录
以下登录要求只适用于 ABox 提供方;本地体验不需要凭据,也不显示登录页面。
- 支持用户名和密码登录;
- 支持记住用户名;
- 支持独立开启自动登录;
- 自动登录所需密码必须通过 Android Keystore 安全保存;
- ABSDK Token 由 SDK 自身管理;进程重启或 Token 失效后,通过保存的凭据重新登录;
- 退出登录时保留用户名,清除密码与会话。
### 4.2 提供方模式
应用提供两种入口,认证前必须选择其一:
1. **ABox**:通过 ABSDK 0630 连接当前局域网中的智能家居网关;
2. **本地体验**:不访问网络或 ABSDK,通过本地模拟数据直接进入完整应用流程。
首版不提供自有兼容后端入口,也不再规划兼容服务端。
约束:
- 任一时刻只启用一个提供方;
- 切换提供方前必须退出当前会话;
- 每个提供方的会话、设备、遥控器、情景和凭据空间互不混用;
- 本地体验不启动 HTTP 服务,也不调用 ABSDK。
### 4.3 设备目录
ABSDK 0630 当前没有已确认的设备列表接口,也不提供设备目录。首版设备目录按提供方区分:
- ABox:由设备目录模块负责的本地持久化目录(二维码批量导入,用户手工添加、编辑和删除);
- 本地体验:由模拟运行时内置的模拟设备目录。
共同约束:
- 设备名称作为调用 ABSDK 的设备标识;
- 设备类型首版只包含插座、温湿度和红外。
不得假设存在尚未确认的新版 SDK 或远程设备发现接口。
### 4.4 插座
- 查询当前状态;
- 显示打开、关闭、加载、错误和离线状态;
- 控制打开和关闭;
- 控制完成后确认实际状态;
- 防止重复操作;
- 记录操作结果。
### 4.5 温湿度
- 查询温度;
- 查询湿度;
- 分别展示温度和湿度;
- 支持刷新;
- 显示加载、无数据、错误和离线状态;
- 温湿度设备是只读设备,不提供控制动作。
### 4.6 红外遥控器
- 创建、编辑和删除遥控器;
- 设置遥控器名称和视觉标识;
- 添加、重命名、排序和删除按键;
- 学习红外按键;
- 发送红外按键;
- 下载红外码;
- 持久化遥控器布局和按键;
- 为情景模式提供稳定的红外按键动作引用。
### 4.7 情景模式
- 创建、编辑和删除情景;
- 设置情景名称、图标和颜色;
- 添加插座开关动作;
- 添加红外按键动作;
- 调整动作顺序;
- 按顺序执行动作;
- 单个动作失败后继续执行后续动作;
- 最后逐项汇总执行结果;
- 防止同一情景被重复触发;
- 按提供方隔离情景数据。
首版不实现:
- 定时触发;
- 条件判断;
- 温湿度或其他传感器自动触发;
- 复杂规则引擎。
---
## 5. 技术方向
### 5.1 Android
正式 Android 应用采用:
- Kotlin;
- Jetpack Compose;
- Material 3;
- 单 Activity;
- Navigation Compose;
- ViewModel;
- StateFlow;
- Coroutines;
- Hilt;
- Room;
- DataStore;
- Android Keystore。
目标:
- applicationId 为 `com.flagship.abox.manager`;
- 最低 Android 版本暂按 API 26;
- 支持手机和平板;
- 支持深色和浅色主题;
- 支持简体中文和英文;
- 其他系统语言回退到英文;
- 满足基本无障碍要求;
- 建立独立的 ABox 视觉设计系统。
### 5.2 ABSDK 接入原则
- 正式业务代码不得直接散落调用 `ABSDK.getInstance()`;
- 必须通过统一适配层调用 SDK;
- SDK 同步阻塞调用必须在后台线程执行;
- `ABRet`、原始 `Map` 和字符串错误码不得泄漏到 UI 层;
- 适配层将 SDK 返回转换为类型化领域结果;
- SDK 的 Host、Token 和线程状态按全局共享状态处理;
- 切换提供方前必须结束当前会话;
- `docs/examples/` 和 `samples/abox-sdk-sample/` 只作为参考,不直接作为正式架构。
### 5.3 模拟运行时
本地体验由生产模块 `:runtime:simulation` 提供确定性模拟实现:
- 不依赖网络、ABSDK 或 Android API;
- 内置固定的模拟设备目录和设备状态;
- 与 ABox 共用同一套领域端口,UI 不感知差异。
测试专用的脚本化 Fake 保留在 `:testing:fakes`,不进入生产依赖。
---
## 6. 固定五人分工
项目总体分工固定为以下五个模块。后续新增工作必须归入这五个模块,不再以协议、服务端或基础架构为理由重划总体职责。
| 成员 | 固定模块 |
|---|---|
| KremeCN | 工程 SDK 登录 |
| alZerNest | 设备列表导航 |
| Aurum | 插座温湿度 |
| Moler | 红外遥控器 |
| tzh | 情景模式集成 |
### 6.1 KremeCN:工程 SDK 登录
负责项目公共基础、SDK 以及登录体系。
主要工作:
- 创建 Android 工程和公共构建配置;
- 建立 Compose、Navigation、Hilt、Room 和 DataStore 基础;
- 建立公共设计系统基础;
- 接入和封装 ABSDK 0630;
- 建立统一领域接口、错误模型和测试 Fake;
- 实现 ABox 和本地体验两个入口,以及按活动提供方的设备操作路由;
- 实现记住用户名、自动登录、Keystore 凭据保存和退出;
- 处理 Token 失效与重新登录;
- 研究 SDK 已确认行为和协议边界。
主要交付:
```text
工程基础
SDK 适配层(AbsdkBridge)
本地体验模拟运行时
登录与提供方选择
公共领域接口与测试 Fake
```
### 6.2 alZerNest:设备列表导航
负责登录后的主界面、设备目录和页面导航。
主要工作:
- 设备模型和三种设备类型;
- 设备列表;
- 二维码导入设备目录;
- 手工添加、编辑和删除设备;
- 按提供方隔离设备数据;
- 手机单栏导航;
- 平板列表详情双栏布局;
- 点击设备进入正确详情页;
- 空列表、未知设备和离线状态;
- Room 设备目录持久化。
主要交付:
```text
设备目录
设备列表
主导航
手机和平板导航
```
### 6.3 Aurum:插座温湿度
负责插座和温湿度的完整功能。
主要工作:
- 插座状态查询和开关控制;
- 控制后的状态确认;
- 插座加载、错误、超时和离线状态;
- 温度和湿度查询、格式化和刷新;
- 温湿度加载、错误、无数据和离线状态;
- 操作日志接入;
- 对应 ViewModel、单元测试和 UI 测试。
主要交付:
```text
插座控制页面
温湿度页面
相关状态管理和测试
```
### 6.4 Moler:红外遥控器
负责全部红外相关能力。
主要工作:
- 遥控器列表;
- 遥控器创建、编辑和删除;
- 按键添加、重命名、排序和删除;
- 红外按键学习;
- 红外发送;
- 红外码下载;
- 红外失败和离线状态;
- Room 持久化;
- 为情景模式提供稳定的红外动作引用。
主要交付:
```text
红外遥控器
红外学习和发送
红外本地持久化
```
### 6.5 tzh:情景模式集成
负责情景模式和最终体验整合。
主要工作:
- 情景列表;
- 情景创建、编辑和删除;
- 插座动作和红外动作选择;
- 动作排序;
- 顺序执行和失败后继续;
- 执行结果逐项汇总;
- 情景 Room 持久化;
- 中英文资源整合;
- 空状态、错误状态和加载状态统一;
- 深浅色、无障碍和最终集成测试;
- 用户使用说明。
主要交付:
```text
情景编辑器
情景执行器
最终体验整合和验收
```
---
## 7. 分工依赖关系
```text
KremeCN 工程 SDK 登录
├── alZerNest 设备列表导航
├── Aurum 插座温湿度
└── Moler 红外遥控器
│
└────────────┐
Aurum 插座温湿度 ───────┤
▼
tzh 情景模式集成
```
开发阶段必须通过 Fake 接口解除等待:
- KremeCN 优先提供领域接口和 Fake;
- alZerNest、Aurum、Moler、tzh 使用 Fake 并行开发;
- 真实 SDK 接口准备好后,各模块只替换数据源,不重写 UI;
- tzh 不等待全部硬件联调完成才开始情景页面。
---
## 8. 执行阶段
### 阶段 0:需求和接口基线
- 扩充产品需求;
- 区分已确认事实与待确认事项;
- 定义最小公共领域接口;
- 提供 Fake 登录、设备目录和设备操作;
- 五人共同评审接口。
退出条件:
- 五个模块都能在 Fake 上开始开发;
- 不确定的 SDK 协议没有被写死;
- 各模块数据所有权明确。
### 阶段 1:工程和 UI 骨架
KremeCN:
- 建立工程、设计系统、SDK 适配和登录骨架。
alZerNest:
- 建立设备列表和导航。
Aurum:
- 建立插座和温湿度页面状态。
Moler:
- 建立红外遥控器页面和数据结构。
tzh:
- 建立情景列表、编辑器和 Fake 动作执行。
退出条件:
- Android App 可以使用 Fake 或本地体验完成主要页面导航;
- 所有人拥有可独立测试的模块。
### 阶段 2:完整功能实现
- 接入 Room、DataStore 和 Keystore;
- 完成登录和提供方隔离;
- 完成设备目录导入和维护;
- 完成插座、温湿度和红外;
- 完成情景模式。
退出条件:
- 本地体验模式完整可用;
- 所有模块在 Fake 或模拟后端下通过测试;
- 提供方切换不会混用数据和凭据。
### 阶段 3:真实集成
- 在网关局域网内连接真实 ABox 环境;
- 使用真实插座、温湿度和红外设备;
- 验证 Token 失效、设备离线和错误码;
- 验证手机和平板布局。
退出条件:
- 三类真实硬件核心流程通过;
- ABox 局域网连接行为明确;
- 已知限制完整记录。
### 阶段 4:发布验收
- 中英文检查;
- 深浅色检查;
- 无障碍检查;
- 安全检查;
- 自动化测试和手工验收;
- 安装和部署文档;
- 发布包和回滚方案。
---
## 9. 分支与 PR 规则
总体模块固定,但具体工作应使用短期功能分支,不应为每个人保留一个持续到项目结束的巨大分支。
建议分支示例:
### KremeCN
```text
feat/project-foundation
feat/absdk-bridge
feat/provider-login
```
### alZerNest
```text
feat/device-catalog
feat/device-navigation
feat/device-import
```
### Aurum
```text
feat/socket-control
feat/temperature-display
```
### Moler
```text
feat/infrared-remote
feat/infrared-learning
```
### tzh
```text
feat/scene-editor
feat/scene-execution
feat/app-integration
```
所有工作遵循 `AGENTS.md`:
- 从最新 `main` 创建工作分支;
- 修改和提交保留在工作分支;
- 完成后创建目标为 `main` 的 Pull Request;
- 不绕过分支保护、评审和必要检查。
---
## 10. 公共文件所有权
| 公共内容 | 主要维护者 |
|---|---|
| 根构建配置 | KremeCN |
| 公共领域接口 | KremeCN,其他成员共同评审 |
| 设计系统基础 | KremeCN |
| 主导航与设备导航 | alZerNest |
| 设备模型 | alZerNest 提出,KremeCN 合入公共层 |
| 插座与温湿度命令 | Aurum 提出,KremeCN 合入公共层 |
| 红外命令与按键引用 | Moler 提出,KremeCN 合入公共层 |
| 情景动作模型 | tzh 提出,KremeCN 合入公共层 |
| 中英文公共文案检查 | tzh |
公共接口变更必须:
1. 单独说明影响范围;
2. 更新对应 Fake;
3. 由受影响模块负责人评审;
4. 合并后再由功能分支更新基线。
---
## 11. 测试与验收
### 11.1 自动测试
- 领域模型和执行逻辑单元测试;
- SDK 返回和错误映射测试;
- 提供方选择、会话协调和设备操作路由测试;
- 模拟运行时行为测试;
- ViewModel 状态测试;
- Room 持久化测试;
- Compose 核心页面测试;
- 情景顺序执行和失败继续测试。
### 11.2 手工验收
- ABox 登录、自动登录和退出;
- 本地体验直接进入应用;
- 两种提供方入口;
- 数据和凭据隔离;
- 设备目录导入和维护;
- 插座状态和控制;
- 温湿度读取;
- 红外学习和发送;
- 情景创建和执行;
- Token 失效与设备离线;
- 手机和平板;
- 中文和英文;
- 深色和浅色。
### 11.3 CI
当前 Gitea 暂无 Runner。
在 Runner 就绪前:
- 提供统一的本地验证命令;
- 每个 PR 附带实际测试结果;
- 不得以未来 CI 会运行作为跳过验证的理由。
Runner 就绪后,再将相同命令配置为受保护 `main` 的必要检查。
---
## 12. 待确认事项
以下内容不能提前假定:
- ABSDK 0630 的完整初始化流程;
- `setHostInfo` 的正式业务含义;
- ABox 官方部署所需参数;
- SDK 的完整签名算法和参数规则;
- SDK 线程安全与会话清理行为;
- 真实测试账号、设备名称和硬件环境;
- 授权凭据下完整的 ABox 登录与设备操作冒烟验证;
- JAR 硬编码域名与局域网网关地址之间的映射机制(详见 `docs/protocol/absdk-0630.md`);
- ABSDK 二进制的生产分发许可。
上述事项必须通过厂商资料、授权分析、实验或真实环境验证后,再更新本文档或对应技术文档。
---
## 13. 当前下一步
本计划落盘后,不立即开始全部功能开发。
正式开工前建议依次完成:
1. 五人确认固定分工;
2. 将 MVP 需求补充到 `docs/requirement_spec.md`;
3. 建立任务看板;
4. 明确第一轮短期分支和 PR;
5. 由 KremeCN 先提供最小公共接口和 Fake;
6. 五人进入第一轮并行开发。
+1 -1
View File
@@ -7,7 +7,7 @@
证据来源:
- JAR:`samples/abox-sdk-sample/login-mac/libs/libABSDK0630.jar`;
- 厂商文档:`docs/ABSDK接口部分_0630.md`;
- 厂商文档:`docs/vendor/ABSDK接口部分_0630.md`;
- 历史 Sample:`samples/abox-sdk-sample/login-mac/`;
- 公开 API 使用 `javap` 验证,协议细节通过授权字节码分析验证。
-20
View File
@@ -1,20 +0,0 @@
## 需求规格
**连接方式**
1. ABox:通过 ABSDK 连接当前局域网中的智能家居网关,登录成功后进入应用。
2. 本地体验:不访问网络或 ABSDK,直接建立模拟会话并进入应用。
3. 首版不提供自有兼容后端入口。
**ABox 登录**
1. 记住已登录用户信息功能:记住我(仅适用于 ABox)
2. 自动登录:选中自动登录之后(仅适用于 ABox)
**设备列表**
1. 设备列表:显示设备名称(插座、温湿度、红外)
2. 点击设备:跳转到相应的设备控制页面
3. 插座页面:可以获取插座状态 (图片)
4. 温湿度控制器页面:分别显示温度及湿度(图片)
5. 红外传感器:创建遥控器的功能,持久化红外学习按钮
**情景模式**
* 基于多种设备完成一种或多种生活场景
File diff suppressed because it is too large Load Diff
@@ -1,589 +0,0 @@
# Infrared Remote Control UI & Multi-Scene Implementation Plan
> **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:** Build a realistic, multi-scene infrared remote control UI (Air Conditioner & Television) with learning/control mode toggle, fixed slot key bindings (1-100), graphic component modeling (circles, capsules, D-pad wheels), and settings navigation.
**Architecture:** Add scene models and predefined key specs in `:android:feature:infrared`. Implement `InfraredRemoteViewModel` to observe Room keys and coordinator actions. Build Compose panels for AC and TV remotes, and integrate the navigation route `infrared_remote/{profileId}` alongside `infrared_settings/{profileId}`.
**Tech Stack:** Kotlin 1.9.24, Jetpack Compose Material 3, Android Gradle Plugin 8.7.3, Room, StateFlow, Hilt, Coroutines.
**Spec:** `docs/superpowers/specs/2026-08-24-infrared-remote-ui-design.md`
## Global Constraints
- Remote buttons display clean symbols/icons, never raw Chinese long names on the button faces.
- Every button maps to a dedicated 1-100 integer slot (AC: 1–20, TV: 21–50).
- Learning mode toggle activates visual indicator and switches button clicks from `sendKey` to `learnKey`.
- Settings navigation allows jumping to the existing `InfraredControlScreen` (renamed to Settings) to view raw entries and device names.
- Follow TDD with unit and Compose UI tests.
---
### Task 1: Define Remote Scenes and Key Specs
**Files:**
- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModels.kt`
- Test: `android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModelsTest.kt`
**Interfaces:**
- Produces: `RemoteScene`, `RemoteButtonId`, `RemoteButtonSpec`, and `RemoteSceneConfig`.
- [ ] **Step 1: Write test for predefined AC and TV remote button specifications**
```kotlin
package com.flagship.abox.manager.infrared.remote
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Test
class RemoteModelsTest {
@Test
fun acSpecsHaveUniqueSlotsBetween1And20() {
val slots = RemoteSceneConfig.AirConditioner.buttons.map { it.defaultSlot }
assertEquals(slots.size, slots.distinct().size)
assert(slots.all { it.toInt() in 1..20 })
}
@Test
fun tvSpecsHaveUniqueSlotsBetween21And50() {
val slots = RemoteSceneConfig.Television.buttons.map { it.defaultSlot }
assertEquals(slots.size, slots.distinct().size)
assert(slots.all { it.toInt() in 21..50 })
}
}
```
- [ ] **Step 2: Run test to verify failure**
Run: `./gradlew :android:feature:infrared:testDebugUnitTest --tests "*.RemoteModelsTest"`
Expected: Compilation failure.
- [ ] **Step 3: Implement `RemoteModels.kt`**
```kotlin
package com.flagship.abox.manager.infrared.remote
enum class RemoteScene {
AIR_CONDITIONER,
TELEVISION,
}
data class RemoteButtonSpec(
val id: String,
val defaultSlot: String,
val displayName: String,
val symbol: String,
)
object RemoteSceneConfig {
object AirConditioner {
val POWER = RemoteButtonSpec("AC_POWER", "1", "空调-电源", "⏻")
val TEMP_UP = RemoteButtonSpec("AC_TEMP_UP", "2", "空调-温度+", "▲")
val TEMP_DOWN = RemoteButtonSpec("AC_TEMP_DOWN", "3", "空调-温度-", "▼")
val MODE = RemoteButtonSpec("AC_MODE", "4", "空调-模式", "模式 ❄/☀")
val FAN = RemoteButtonSpec("AC_FAN", "5", "空调-风速", "风速 💨")
val SWING = RemoteButtonSpec("AC_SWING", "6", "空调-扫风", "扫风 ↔")
val TIMER = RemoteButtonSpec("AC_TIMER", "7", "空调-定时", "定时 ⏱")
val buttons = listOf(POWER, TEMP_UP, TEMP_DOWN, MODE, FAN, SWING, TIMER)
}
object Television {
val POWER = RemoteButtonSpec("TV_POWER", "21", "电视-电源", "⏻")
val MUTE = RemoteButtonSpec("TV_MUTE", "22", "电视-静音", "🔇")
val NAV_UP = RemoteButtonSpec("TV_NAV_UP", "23", "电视-上", "▲")
val NAV_DOWN = RemoteButtonSpec("TV_NAV_DOWN", "24", "电视-下", "▼")
val NAV_LEFT = RemoteButtonSpec("TV_NAV_LEFT", "25", "电视-左", "◀")
val NAV_RIGHT = RemoteButtonSpec("TV_NAV_RIGHT", "26", "电视-右", "▶")
val OK = RemoteButtonSpec("TV_OK", "27", "电视-确定", "OK")
val VOL_UP = RemoteButtonSpec("TV_VOL_UP", "28", "电视-音量+", "VOL +")
val VOL_DOWN = RemoteButtonSpec("TV_VOL_DOWN", "29", "电视-音量-", "VOL -")
val CH_UP = RemoteButtonSpec("TV_CH_UP", "30", "电视-频道+", "CH +")
val CH_DOWN = RemoteButtonSpec("TV_CH_DOWN", "31", "电视-频道-", "CH -")
val HOME = RemoteButtonSpec("TV_HOME", "32", "电视-主页", "🏠")
val BACK = RemoteButtonSpec("TV_BACK", "33", "电视-返回", "↩")
val MENU = RemoteButtonSpec("TV_MENU", "34", "电视-菜单", "☰")
val buttons = listOf(
POWER, MUTE, NAV_UP, NAV_DOWN, NAV_LEFT, NAV_RIGHT, OK,
VOL_UP, VOL_DOWN, CH_UP, CH_DOWN, HOME, BACK, MENU
)
}
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `./gradlew :android:feature:infrared:testDebugUnitTest --tests "*.RemoteModelsTest"`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModels.kt android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModelsTest.kt
git commit -m "feat: define infrared remote scenes and button specifications"
```
---
### Task 2: Implement Infrared Remote ViewModel
**Files:**
- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt`
- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteUiState.kt`
- Test: `android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt`
**Interfaces:**
- Consumes: `InfraredKeyRepository`, `InfraredDeviceConfigurationRepository`, `InfraredLearningCoordinator`, `SendInfraredKey`, `InfraredRuntimeModeStore`.
- Produces: `StateFlow<InfraredRemoteUiState>` with `onButtonClick`, `toggleLearningMode`, `switchScene`.
- [ ] **Step 1: Write failing ViewModel test**
```kotlin
package com.flagship.abox.manager.infrared.remote
import androidx.lifecycle.SavedStateHandle
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.*
import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeProfileResolver
import com.flagship.abox.manager.testing.fakes.FakeInfraredDeviceConfigurationRepository
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
import com.flagship.abox.manager.testing.fakes.FakeInfraredKeyRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.*
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
private class TestModeStore : InfraredRuntimeModeStore {
private val modeFlow = MutableStateFlow(InfraredRuntimeMode.FAKE)
override val mode: Flow<InfraredRuntimeMode> = modeFlow
override suspend fun currentMode(): InfraredRuntimeMode = modeFlow.value
override suspend fun setMode(mode: InfraredRuntimeMode): DomainResult<Unit> {
modeFlow.value = mode
return DomainResult.Success(Unit)
}
}
@OptIn(ExperimentalCoroutinesApi::class)
class InfraredRemoteViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
private val profile = ProviderProfileId("abox")
private val fakeProfile = ProviderProfileId("abox:infrared-fake")
private val device = DeviceName("ir-living")
private val clock = EpochMillisClock { 10_000L }
private val keyRepo = FakeInfraredKeyRepository()
private val deviceRepo = FakeInfraredDeviceConfigurationRepository()
private val modeStore = TestModeStore()
private val resolver = InfraredRuntimeProfileResolver()
private val gateway = FakeInfraredGateway()
private val factory = InfraredKeyFactory({ InfraredKeyId("id-1") }, clock)
private val learn = LearnInfraredKey(keyRepo, gateway, factory, clock)
private val retry = RetryInfraredKeyLearning(keyRepo, gateway, clock)
private val send = SendInfraredKey(keyRepo, gateway)
private lateinit var coordinator: InfraredLearningCoordinator
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
coordinator = InfraredLearningCoordinator(learn, retry, testScope)
}
@After
fun teardown() {
Dispatchers.resetMain()
}
private fun createViewModel(): InfraredRemoteViewModel {
return InfraredRemoteViewModel(
savedStateHandle = SavedStateHandle(mapOf("profileId" to "abox")),
keyRepository = keyRepo,
deviceConfigurationRepository = deviceRepo,
runtimeModeStore = modeStore,
profileResolver = resolver,
learningCoordinator = coordinator,
sendInfraredKey = send,
)
}
@Test
fun controlModeSendsSignalWhenKeyIsReady() = testScope.runTest {
deviceRepo.setDeviceName(fakeProfile, device)
val readyKey = SavedInfraredKey(
id = InfraredKeyId("k1"),
profileId = fakeProfile,
deviceName = device,
displayName = "空调-电源",
sdkKey = "1",
status = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 100L,
updatedAtEpochMillis = 100L,
)
keyRepo.insert(readyKey)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
advanceUntilIdle()
assertEquals(1, gateway.sentKeys.size)
assertEquals("1", gateway.sentKeys.first().key)
job.cancel()
}
@Test
fun learningModeTriggersLearnOnButtonClick() = testScope.runTest {
deviceRepo.setDeviceName(fakeProfile, device)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.toggleLearningMode(true)
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
advanceUntilIdle()
assertEquals(1, gateway.learnedKeys.size)
assertEquals("1", gateway.learnedKeys.first().key)
job.cancel()
}
}
```
- [ ] **Step 2: Run test to verify failure**
Run: `./gradlew :android:feature:infrared:testDebugUnitTest --tests "*.InfraredRemoteViewModelTest"`
Expected: Compilation failure.
- [ ] **Step 3: Implement `InfraredRemoteUiState` and `InfraredRemoteViewModel`**
```kotlin
package com.flagship.abox.manager.infrared.remote
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.*
import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeProfileResolver
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
sealed interface RemoteUiMessage {
data class Info(val text: String) : RemoteUiMessage
data class Error(val text: String) : RemoteUiMessage
data object SessionInvalid : RemoteUiMessage
}
data class InfraredRemoteUiState(
val selectedScene: RemoteScene = RemoteScene.AIR_CONDITIONER,
val isLearningMode: Boolean = false,
val runtimeMode: InfraredRuntimeMode = InfraredRuntimeMode.FAKE,
val effectiveProfileId: ProviderProfileId? = null,
val savedDeviceName: DeviceName? = null,
val learnedSlotMap: Map<String, SavedInfraredKey> = emptyMap(),
val sendingKeySlot: String? = null,
val activeLearningSlot: String? = null,
val message: RemoteUiMessage? = null,
)
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class InfraredRemoteViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val keyRepository: InfraredKeyRepository,
private val deviceConfigurationRepository: InfraredDeviceConfigurationRepository,
private val runtimeModeStore: InfraredRuntimeModeStore,
private val profileResolver: InfraredRuntimeProfileResolver,
private val learningCoordinator: InfraredLearningCoordinator,
private val sendInfraredKey: SendInfraredKey,
) : ViewModel() {
private val rawProfileId: String? = savedStateHandle["profileId"]
private val authenticatedProfileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) }
private val sceneFlow = MutableStateFlow(RemoteScene.AIR_CONDITIONER)
private val isLearningModeFlow = MutableStateFlow(false)
private val sendingKeySlotFlow = MutableStateFlow<String?>(null)
private val activeLearningSlotFlow = MutableStateFlow<String?>(null)
private val messageFlow = MutableStateFlow<RemoteUiMessage?>(null)
private val effectiveProfileAndMode = runtimeModeStore.mode.flatMapLatest { mode ->
if (authenticatedProfileId == null) {
flowOf(Pair(null, mode))
} else {
flowOf(Pair(profileResolver.resolve(authenticatedProfileId, mode), mode))
}
}
private val deviceNameFlow = effectiveProfileAndMode.flatMapLatest { (profile, _) ->
if (profile == null) flowOf(DomainResult.Success(null))
else deviceConfigurationRepository.observeDeviceName(profile)
}
private val keysFlow = effectiveProfileAndMode.flatMapLatest { (profile, _) ->
if (profile == null) flowOf(DomainResult.Success(emptyList()))
else {
deviceConfigurationRepository.observeDeviceName(profile).flatMapLatest { deviceResult ->
when (deviceResult) {
is DomainResult.Success -> {
val device = deviceResult.value
if (device == null) flowOf(DomainResult.Success(emptyList()))
else keyRepository.observeKeys(profile, device)
}
is DomainResult.Failure -> flowOf(DomainResult.Failure(deviceResult.error))
}
}
}
}
val state: StateFlow<InfraredRemoteUiState> = combine(
sceneFlow,
isLearningModeFlow,
effectiveProfileAndMode,
deviceNameFlow,
keysFlow,
sendingKeySlotFlow,
activeLearningSlotFlow,
messageFlow,
) { scene, isLearning, profileAndMode, deviceResult, keysResult, sendingSlot, learningSlot, msg ->
val (profile, mode) = profileAndMode
val savedDevice = (deviceResult as? DomainResult.Success<DeviceName?>)?.value
val keys = (keysResult as? DomainResult.Success<List<SavedInfraredKey>>)?.value ?: emptyList()
val slotMap = keys.associateBy { it.sdkKey }
InfraredRemoteUiState(
selectedScene = scene,
isLearningMode = isLearning,
runtimeMode = mode,
effectiveProfileId = profile,
savedDeviceName = savedDevice,
learnedSlotMap = slotMap,
sendingKeySlot = sendingSlot,
activeLearningSlot = learningSlot,
message = msg,
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = InfraredRemoteUiState(),
)
fun switchScene(scene: RemoteScene) {
sceneFlow.value = scene
}
fun toggleLearningMode(enabled: Boolean) {
isLearningModeFlow.value = enabled
if (enabled) {
messageFlow.value = RemoteUiMessage.Info("🔴 学习录制模式已开启:点击任意按键即可开始录制")
}
}
fun onButtonClick(spec: RemoteButtonSpec) {
val currentProfile = state.value.effectiveProfileId ?: return
val savedDevice = state.value.savedDeviceName
if (savedDevice == null) {
messageFlow.value = RemoteUiMessage.Error("未配置红外设备名,请点击右上角设置配置")
return
}
if (state.value.isLearningMode) {
activeLearningSlotFlow.value = spec.defaultSlot
learningCoordinator.learn(
profileId = currentProfile,
deviceName = savedDevice,
displayName = spec.displayName,
sdkKey = spec.defaultSlot,
)
messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按物理遥控器按键...")
} else {
val key = state.value.learnedSlotMap[spec.defaultSlot]
if (key == null || key.status != InfraredKeyStatus.READY) {
messageFlow.value = RemoteUiMessage.Error("按键【${spec.displayName}】尚未学习,请开启右上角学习模式进行录制")
return
}
sendingKeySlotFlow.value = spec.defaultSlot
viewModelScope.launch {
try {
when (val result = sendInfraredKey(InfraredKeyRef(currentProfile, key.id))) {
is DomainResult.Success -> {
messageFlow.value = RemoteUiMessage.Info("已发射: ${spec.symbol}")
}
is DomainResult.Failure -> {
handleDomainError(result.error)
}
}
} finally {
sendingKeySlotFlow.value = null
}
}
}
}
fun clearMessage() {
messageFlow.value = null
}
private fun handleDomainError(error: DomainError) {
when (error) {
is DomainError.TokenInvalid, is DomainError.Authentication -> {
messageFlow.value = RemoteUiMessage.SessionInvalid
}
is DomainError.DeviceOffline -> {
messageFlow.value = RemoteUiMessage.Error("红外控制器离线 (错误码: ${error.externalCode ?: "20206"})")
}
else -> {
messageFlow.value = RemoteUiMessage.Error(error.detail ?: "操作失败")
}
}
}
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `./gradlew :android:feature:infrared:testDebugUnitTest --tests "*.InfraredRemoteViewModelTest"`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote
git commit -m "feat: implement infrared remote control viewmodel"
```
---
### Task 3: Build Custom Remote Visual Components
**Files:**
- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/RemoteComponents.kt`
- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/AcRemotePanel.kt`
- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/TvRemotePanel.kt`
- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt`
- Test: `android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteScreenTest.kt`
- [ ] **Step 1: Implement Compose visual elements**
- `RemoteCircleButton`, `VerticalCapsuleRocker`, `RemoteDpadWheel`, `AcLcdDisplay`.
- Compose panels for `AcRemotePanel` and `TvRemotePanel`.
- Assemble `InfraredRemoteScreen` with top learning switch and settings action.
- [ ] **Step 2: Write Compose UI Test**
```kotlin
package com.flagship.abox.manager.infrared.remote
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.infrared.remote.ui.InfraredRemoteScreen
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class InfraredRemoteScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun rendersAcPanelAndTvPanelCorrectly() {
val state = InfraredRemoteUiState(
selectedScene = RemoteScene.AIR_CONDITIONER,
savedDeviceName = DeviceName("ir-1"),
)
var clickedSpec: RemoteButtonSpec? = null
composeTestRule.setContent {
InfraredRemoteScreen(
state = state,
onBack = {},
onOpenSettings = {},
onSessionInvalid = {},
onSceneSelected = {},
onToggleLearningMode = {},
onButtonClick = { clickedSpec = it },
onMessageShown = {},
)
}
composeTestRule.onNodeWithTag("remote_button_AC_POWER").assertIsDisplayed().performClick()
assertEquals("AC_POWER", clickedSpec?.id)
}
}
```
- [ ] **Step 3: Run compilation and tests**
Run: `./gradlew :android:feature:infrared:compileDebugAndroidTestKotlin :android:feature:infrared:testDebugUnitTest`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/remote
git commit -m "feat: add infrared remote control Compose screen and panels"
```
---
### Task 4: Integrate Navigation and App Flow
**Files:**
- Modify: `android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt`
- Modify: `android/app/src/main/res/values/strings.xml`
- Modify: `android/app/src/main/res/values-zh-rCN/strings.xml`
- [ ] **Step 1: Wire Navigation**
- Route `infrared/{profileId}` now loads `InfraredRemoteRoute`.
- Add Route `infrared_settings/{profileId}` that loads `InfraredControlRoute` (the settings & raw list view).
- Top bar of `InfraredRemoteScreen` has ⚙️ button navigating to `infrared_settings/{profileId}`.
- [ ] **Step 2: Run Full Verification Suite & Build APK**
Run: `./gradlew :domain:test :testing:fakes:test :absdk-bridge:test :android:feature:infrared:testDebugUnitTest :android:app:testDebugUnitTest :android:app:assembleDebug`
Expected: BUILD SUCCESSFUL.
- [ ] **Step 3: Commit and Push**
```bash
git add android/app android/feature/infrared
git commit -m "feat: wire infrared remote screen and settings navigation"
git push origin feat/moler-infrared-control
```
File diff suppressed because it is too large Load Diff
@@ -1,606 +0,0 @@
# 红外学习与发射控制设计
## 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<IrInfo> irInfos)
```
现有领域层已将其隔离为 `InfraredGateway`:
```kotlin
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 目标,真实接入存在已知传输安全风险。
因此首版保存的是本地按键绑定,而不是原始红外波形或码值:
```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_<normalized-infrared-key-id>
```
实际长度上限尚未由 ABSDK 文档确认,实现前需选择保守长度,并列入真机验证。
## 6. 本地持久化
采用 Room。Repository 边界至少支持:
```kotlin
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` 记录:
```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<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 学习交互
用户输入显示名称后开始学习。页面提示:
```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. 真机验证后对学习语义、超时和错误模型的修订。
@@ -1,115 +0,0 @@
# 红外遥控器界面与多场景控制设计规范 / Infrared Remote Control UI Specification
## 1. 背景与目标 (Background & Goals)
在完成了底层红外学习、发射、Room 数据库、1~100 整数 Key 槽位管理以及 Fake/Real ABSDK 路由的基础上,本项目需要提供专业且直观的前端遥控器人机交互界面。
### 核心目标
1. **多场景遥控面板**:提供空调 (Air Conditioner) 与电视 (Television) 两大经典场景遥控器界面,使用圆形、圆角矩形、胶囊、圆形方向盘等拟物/现代图形组件构建真实遥控器外观。
2. **学习模式与控制模式切换**:右上角提供“红外学习模式”开关,在控制模式下点击按键发射红外,在学习模式下点击按键录制红外。
3. **固定槽位绑定 (Fixed Slot Binding)**:遥控器上的每一个功能按键具有预设唯一的 1~100 整数槽位编号(例如空调电源为 Slot 1,电视电源为 Slot 21)。
4. **简洁的符号化设计**:遥控器按键表面只展示标准图标与简写符号(⏻, +, -, ∧, ∨, ❄, 🔊 等),不展示底层数据库中文长名称。
5. **设置页面解耦与导航**:原“红外控制页面”定位为“红外学习设置页面”(Infrared Settings),通过遥控器右上角设置图标 ⚙️ 进入,用于配置红外设备名和管理全部已学按键列表。
---
## 2. 槽位与按键分配定义 (Slot & Button Mapping)
每个按键定义为一个 `RemoteButtonSpec`,包含按键唯一标识 `id`、对应默认槽位 `defaultSlot`、默认显示名称 `displayName`、图标及在遥控器面板中的图形位置。
### 2.1 空调遥控器 (Air Conditioner: Slots 1–20)
| 按键 ID | 功能/符号 | 槽位 (irKey) | 默认名称 | UI 形态 |
|---|---|---|---|---|
| `AC_POWER` | 电源 ⏻ | `1` | `空调-电源` | 顶部圆形独立红色高亮按键 |
| `AC_TEMP_UP` | 温度+ 🔼 | `2` | `空调-温度+` | 垂直胶囊上半部分 |
| `AC_TEMP_DOWN` | 温度- 🔽 | `3` | `空调-温度-` | 垂直胶囊下半部分 |
| `AC_MODE` | 模式 ❄/☀ | `4` | `空调-模式` | 圆角矩形 |
| `AC_FAN` | 风速 💨 | `5` | `空调-风速` | 圆角矩形 |
| `AC_SWING` | 扫风 ↔️ | `6` | `空调-扫风` | 圆角矩形 |
| `AC_TIMER` | 定时 ⏱️ | `7` | `空调-定时` | 圆角矩形 |
### 2.2 电视遥控器 (Television: Slots 21–50)
| 按键 ID | 功能/符号 | 槽位 (irKey) | 默认名称 | UI 形态 |
|---|---|---|---|---|
| `TV_POWER` | 电源 ⏻ | `21` | `电视-电源` | 顶部圆形红色按键 |
| `TV_MUTE` | 静音 🔇 | `22` | `电视-静音` | 顶部圆形灰色按键 |
| `TV_NAV_UP` | 方向上 🔼 | `23` | `电视-上` | 方向环形盘上弧段 |
| `TV_NAV_DOWN` | 方向下 🔽 | `24` | `电视-下` | 方向环形盘下弧段 |
| `TV_NAV_LEFT` | 方向左 ◀️ | `25` | `电视-左` | 方向环形盘左弧段 |
| `TV_NAV_RIGHT` | 方向右 ▶️ | `26` | `电视-右` | 方向环形盘右弧段 |
| `TV_OK` | 确认 🆗 | `27` | `电视-确定` | 方向盘中心圆形 |
| `TV_VOL_UP` | 音量+ 🔊+ | `28` | `电视-音量+` | 垂直胶囊上半部分 |
| `TV_VOL_DOWN` | 音量- 🔉- | `29` | `电视-音量-` | 垂直胶囊下半部分 |
| `TV_CH_UP` | 频道+ 📺+ | `30` | `电视-频道+` | 垂直胶囊上半部分 |
| `TV_CH_DOWN` | 频道- 📺- | `31` | `电视-频道-` | 垂直胶囊下半部分 |
| `TV_HOME` | 主页 🏠 | `32` | `电视-主页` | 中部圆角胶囊按键 |
| `TV_BACK` | 返回 ↩️ | `33` | `电视-返回` | 中部圆角胶囊按键 |
| `TV_MENU` | 菜单 ☰ | `34` | `电视-菜单` | 中部圆角胶囊按键 |
---
## 3. 架构与组件设计 (Architecture & Component Design)
### 3.1 导航结构
```text
FoundationScreen (首页/网关登录)
↓ 点击 "红外控制" (infrared/{profileId})
InfraredRemoteScreen (遥控器主界面,默认空调/电视)
├─ 学习模式开关 (Toggle: Learning Mode)
├─ 场景切换器 (Tab: ❄️ 空调 | 📺 电视)
├─ 空调遥控器面板 (AcRemotePanel)
├─ 电视遥控器面板 (TvRemotePanel)
└─ 点击右上角 ⚙️ 设置图标
↓ (infrared_settings/{profileId})
InfraredSettingsScreen (原红外设置/按键管理列表页面)
```
### 3.2 状态管理 (`InfraredRemoteViewModel` & `InfraredRemoteUiState`)
- `InfraredRemoteUiState`:
- `selectedScene`: `RemoteScene.AIR_CONDITIONER` 或 `RemoteScene.TELEVISION`
- `isLearningMode`: `Boolean`(是否处于学习录制模式)
- `activeLearningKeySlot`: `String?`(当前正在进行学习请求的按键槽位)
- `sendingKeySlot`: `String?`(当前正在发射信号的按键槽位)
- `learnedSlots`: `Map<String, SavedInfraredKey>`(已在 Room 数据库中记录为 READY 的槽位映射)
- `savedDeviceName`: `DeviceName?`(当前配置的红外设备名,如 `红外控制器`)
- `runtimeMode`: `InfraredRuntimeMode`(Fake 或 Real ABSDK)
- `message`: `UiMessage?`(提示信息或错误)
---
## 4. 交互与视觉规范 (Interaction & Visuals)
### 4.1 视觉造型
1. **遥控器本体外壳 (Remote Body Chassis)**:
- 居中放置的圆角卡片,具有微阴影、圆角半径 `28.dp`、外框及哑光质感。
2. **空调 LCD 虚拟屏**:
- 顶部黑色圆角框,内部呈现液晶感绿色/白色字样(如当前目标温度 `26°C` 与工作状态)。
3. **垂直双向胶囊摇杆 (Vertical Capsule Rockers)**:
- 上半区与下半区独立响应点击,中间由细微分割线隔开,常用于“温度 +/-”、“音量 +/-”及“频道 +/-”。
4. **圆形导航方向盘 (D-Pad Wheel)**:
- 直径约 `180.dp` 的圆形组合组件,外环包含 4 个扇区方向按键,内嵌独立的圆形 OK 键。
### 4.2 按键状态机反馈
1. **控制模式 (学习开关 OFF)**:
- **已录制 (READY)**:按键实体呈现饱满质感,点击触发波纹反馈,调用 `sendKey` 发射红外,提示“已发射”。
- **未录制 (UNLEARNED)**:按键显示浅色未激活状态;点击弹出轻量提示:`“按键尚未学习,请打开右上角学习模式”`。
2. **学习录制模式 (学习开关 ON)**:
- 顶部呈现橙色高亮提示条:`“🔴 学习模式已开启:点击任意按键开始录制”`。
- 所有按键进入可录制状态(带提示外框)。
- 点击目标按键后:
- 按键上显示微型 Loading 光圈。
- 调用 `learnKey(deviceName, defaultSlot, displayName)`。
- 手机或控制器捕获红外信号成功后,按键变为 READY 并伴随成功提示。
---
## 5. 验证与测试标准 (Verification & Acceptance Criteria)
1. **单元测试 (Unit Tests)**:
- `InfraredRemoteViewModelTest` 覆盖场景切换、学习模式开关、按键已学/未学状态映射、点击发射以及点击学习请求的转发。
2. **UI 测试 (Compose Tests)**:
- 空调与电视遥控器按键正常渲染并带正确 Tag。
- 学习模式切换后 UI 状态正确切换。
3. **真机与编译验收**:
- 模块与 App 通过 JVM 测试与打包验证。
- 手机上点击空调/电视按键能发起正确的 Slot 编号学习与发射。
@@ -1,238 +0,0 @@
# LAN ABSDK and Local Experience Design
## Status
Approved on 2026-08-24 by the project owner. This specification replaces the three-provider model in `feat/provider-login`.
## Goal
ABox Manager exposes exactly two choices before authentication:
1. **ABox** uses the real vendor ABSDK bridge to communicate with the smart-home gateway available on the current LAN.
2. **Local experience** uses production-safe simulation implementations, establishes a simulated session immediately, and enters the authenticated app without requesting credentials.
The app no longer offers or plans a self-hosted ABSDK-compatible backend.
## Confirmed Environment and Constraints
- The observed gateway address is `192.168.100.1`.
- The gateway responds to the ABSDK-compatible route `POST /abox/index.php/abjhsdk/user/validate`.
- `libABSDK0630.jar` hardcodes `a-box.com.cn/abox/index.php/` and does not expose a supported base-URL setter.
- `setHostInfo(url, hotelName, roomNo, apikey)` submits `url` as an initialization form field and does not change the HTTP target.
- Hardware testing on `feat/moler-infrared-control` confirms that the current network environment allows the vendor ABSDK to reach the real gateway.
- Debug and release builds expose the same provider choices and routing behavior. Release is not treated as a special case.
- The vendor JAR uses cleartext HTTP. The existing `@CleartextAbsdkTransport` opt-in remains mandatory, and the Android application must explicitly permit the required LAN transport.
- Fake and real sessions, devices, state, and credentials must never be mixed.
## Provider Model
`ProviderType` contains only:
```kotlin
enum class ProviderType {
ABOX,
LOCAL_EXPERIENCE,
}
```
The built-in profiles are:
```text
abox-default -> ProviderType.ABOX
local-experience -> ProviderType.LOCAL_EXPERIENCE
```
The persisted profile store remains tolerant of removed enum values. A stored `COMPATIBLE_BACKEND` profile is ignored. If it was active, the app returns to provider selection rather than attempting migration to another provider.
## User Flow
### ABox
```text
Provider selection
-> choose ABox
-> save and activate ABox profile
-> restore automatic-login preferences when available
-> otherwise show username/password login
-> SessionCoordinator delegates to the process-wide AbsdkBridge
-> enter authenticated app only after DomainResult.Success<Session>
```
Invalid credentials, network errors, and token failures remain on the login screen and display localized errors.
### Local experience
```text
Provider selection
-> choose Local experience
-> save and activate local profile
-> SimulationSessionGateway establishes Username("local") session
-> enter authenticated app immediately
```
Local experience:
- never displays the credential form;
- never calls ABSDK;
- never reads or writes Keystore credentials;
- uses production-safe simulation device gateways;
- can later be removed as a single provider/module boundary if the team decides not to ship it.
## Module Boundaries
### `:absdk-bridge`
Remains the anti-corruption layer around the vendor JAR and implements:
- `SessionGateway`;
- `SocketGateway`;
- `TemperatureHumidityGateway`;
- `InfraredGateway`.
A single process-wide `AbsdkBridge` instance is shared by session and device operations so the mutex and vendor singleton Token remain aligned.
### New `:runtime:simulation`
A production Kotlin/JVM module implementing:
- `LocalSessionGateway` as `SimulationSessionGateway`;
- `DeviceCatalog` as `SimulationDeviceCatalog`;
- `SocketGateway` as `SimulationSocketGateway`;
- `TemperatureHumidityGateway` as `SimulationTemperatureHumidityGateway`;
- `InfraredGateway` as `SimulationInfraredGateway`.
These implementations provide deterministic local experience behavior but do not expose test-only failure injection, mutable call-recording APIs, or assertion helpers.
### `:testing:fakes`
Remains test-only. It keeps scriptable errors, call recording, and other testing controls. It must not be added as an application runtime dependency.
### `:android:core:session`
Keeps provider/profile persistence, Keystore credential storage, and `LocalSessionGatewayImpl` only until the new simulation gateway replaces it. After migration, the old local gateway implementation is removed to prevent two production local-session implementations.
### `:android:app`
Owns the Hilt composition root and provider-aware routing. It injects:
```text
SessionGateway -> AbsdkBridge
LocalSessionGateway -> SimulationSessionGateway
```
For ABSDK-backed device operations, the app exposes provider-routing adapters:
```text
RoutingSocketGateway
RoutingTemperatureHumidityGateway
RoutingInfraredGateway
```
Each adapter reads the active `ProviderProfile` and delegates:
```text
ABOX -> real AbsdkBridge implementation
LOCAL_EXPERIENCE -> simulation implementation
```
`DeviceCatalog` is not implemented by ABSDK 0630. The ABox catalog remains a provider-scoped local persistence responsibility owned by the device-catalog module; `SimulationDeviceCatalog` supplies only the built-in local-experience catalog. This change does not invent a remote discovery API or take ownership of the final ABox catalog implementation from its module owner.
No feature UI selects Real or Fake directly.
## Session and Credential Rules
- `SessionCoordinator` continues to select the session gateway from `ProviderType`.
- ABox credentials can use remember-username and automatic-login settings.
- Automatic-login passwords are stored through Android Keystore.
- Local experience cannot persist credentials or login preferences requiring a password.
- Switching provider logs out the current gateway before activating another profile.
- Logging out of ABox clears credentials and the Bridge-local session but leaves the ABox profile active so the login page can be shown again.
- Logging out of local experience clears the local session and active profile, returning to provider selection.
- Only a session whose `profileId` matches the active profile can produce the authenticated UI state.
## Provider Availability
As implemented, `ProviderAvailability` was deleted entirely: both remaining options are always enabled, so an availability model with disabled states has no caller. The provider selection screen lists exactly ABox and local experience in every build type, with no per-provider availability state or disabled reason.
## Android Transport
The application declares:
```xml
<uses-permission android:name="android.permission.INTERNET" />
```
and permits the vendor cleartext transport required by the current ABSDK. This is an explicit acceptance of the current LAN integration constraint, not a claim that the transport is encrypted.
No password, Token, API key, raw vendor response, or infrared payload may be logged.
## UI and Copy
Provider selection presents exactly:
- **ABox** — connects to the ABox smart-home gateway through ABSDK on the current LAN;
- **Local experience** — uses simulated local devices without network access.
All compatibility-backend copy, status labels, and branches are removed from English and Simplified Chinese resources.
## Error Handling
- ABox authentication failure maps to the localized invalid-credentials message and does not create a session.
- Network failure maps to the localized network message and does not create a session.
- Token invalidation clears the Bridge-local session and returns the app to login.
- Selecting local experience fails visibly if profile persistence or simulated-session creation fails.
- Routing adapters return `DomainError.InvalidRequest` when no active profile exists or the profile type is unsupported.
- Cancellation is rethrown rather than converted to a normal failure.
## Testing Strategy
### Unit tests
- `ProviderType` and provider selection expose exactly ABox and local experience.
- Removed persisted compatible profiles are ignored and cannot become active.
- `SessionCoordinator` routes ABox to `SessionGateway` and local experience to `LocalSessionGateway`.
- Arbitrary ABox credentials do not succeed unless the injected real/fake test gateway returns success.
- Local experience selection creates a session without calling the ABox gateway or credential store.
- Each socket, temperature/humidity, and infrared routing adapter delegates ABox calls to the real test double and local calls to simulation.
- The local-experience device catalog is supplied by simulation without asserting that ABSDK provides ABox device discovery.
- Switching providers logs out the previous session and isolates state.
### Integration and build verification
- Debug and release compile with the same real ABSDK and simulation modules.
- Hilt resolves one binding for each public gateway.
- Android lint validates Internet/cleartext configuration.
- Full command: `./gradlew check testDebugUnitTest test`.
### Authorized hardware validation
On a device connected to the gateway LAN:
1. Choose ABox.
2. Enter a known-invalid credential pair and verify the app stays on login.
3. Enter an authorized valid credential pair and verify authenticated navigation.
4. Validate Token using the shared Bridge.
5. Perform one read-only device query before any control operation.
6. Choose local experience and verify no ABSDK call or network dependency is required.
Sensitive credentials and Tokens are never captured in test reports.
## Documentation Changes
The implementation updates:
- `docs/requirement_spec.md`;
- `docs/project_plan.md`;
- `docs/architecture.md`;
- `docs/protocol/absdk-0630.md`.
The documents must no longer state that the product connects to an ABox cloud service or a self-hosted compatible backend. They must distinguish confirmed LAN behavior from the JAR's still-hardcoded host behavior.
## Out of Scope
- Reimplementing the ABSDK HTTP/signature protocol;
- patching or rewriting the vendor JAR;
- adding a self-hosted compatibility server;
- adding an in-app gateway IP field that the current JAR cannot consume;
- merging the authenticated app shell PR;
- deciding whether local experience remains in the final product beyond preserving it as a removable module boundary.
+35 -25
View File
@@ -1,32 +1,42 @@
# 设备数据能力矩阵
本文只记录当前代码中有领域端口、实现和测试支撑的设备数据能力。未列出的数据不应在界面中以固定值、随机值、`--` 或伪造图表呈现。
本文记录正式版代码能够从领域端口获得、确认和持久化的数据能力。未列出的数据不得在界面、活动记录或自动化中以固定值、随机值、占位趋势或推测结果呈现。
| 能力 | ABox | 本地体验 | 依据 | 界面策略 |
|---|---|---|---|---|
| 插座开关状态读取 | 支持 | 支持 | `SocketGateway.getStatus` | 展示已确认状态 |
| 插座开关控制 | 支持 | 支持 | `SocketGateway.commandPower` / `setPower` | 命令完成后回读;回读失败显示等待确认 |
| 温度当前读数 | 支持,字段可能缺失 | 支持 | `TemperatureHumidityGateway.getReading` | 仅字段存在时展示 |
| 湿度当前读数 | 支持,字段可能缺失 | 支持 | `TemperatureHumidityGateway.getReading` | 仅字段存在时展示 |
| 红外按键发送 | 支持 | 支持 | `InfraredGateway.sendKey` | 展示真实操作结果 |
| 红外按键学习 | 支持 | 支持 | `InfraredGateway.learnKey` | 展示学习状态和结果 |
| 红外码下载 | 支持 | 支持 | `InfraredGateway.downloadCodes` | 仅用于真实下载流程 |
| 插座电压/电流/功率/电量 | 不可用 | 不可用 | 没有对应领域端口 | 不渲染 |
| 插座倒计时 | 不可用 | 不可用 | 没有对应领域端口 | 不渲染 |
| 插座历史趋势 | 不可用 | 不可用 | 没有历史数据端口 | 不渲染 |
| 温湿度历史趋势 | 不可用 | 不可用 | 没有历史数据端口 | 不渲染 |
| 网关诊断延迟 | 不可用 | 不适用 | 没有诊断端口 | 不渲染 |
| Token 有效期展示 | 不可用 | 不适用 | SDK 未提供可安全展示的有效期端口 | 不渲染 |
| 能力 | ABox | 本地体验 | 界面与自动化策略 |
|---|---|---|---|
| 插座状态读取 | 支持 | 支持 | 展示已确认状态;可作为状态触发器或条件 |
| 插座开关控制 | 支持 | 支持 | 命令成功后回读;回读失败显示等待确认 |
| 温度当前读数 | 支持,字段可能缺失 | 支持 | 字段存在时展示;可作为触发器或条件 |
| 湿度当前读数 | 支持,字段可能缺失 | 支持 | 字段存在时展示;可作为触发器或条件 |
| 红外按键发送 | 支持 | 支持 | 只记录领域结果,不记录原始码值 |
| 红外按键学习 | 支持 | 支持 | 仅自定义遥控器使用,区分未学习、失败和完成 |
| 红外码下载 | 支持 | 支持 | 用于预置遥控器导入,保留逐项成功或失败 |
| 情景与自动化结果 | 支持 | 支持 | 记录情景及动作级结果 |
| 系统通知 | 支持,受通知权限影响 | 支持,受通知权限影响 | 权限缺失或系统拒绝时明确失败 |
| 活动记录 | App 本地持久化 | App 本地持久化 | 按 profile 隔离,支持分页读取 |
| 插座电压、电流、功率和电量 | 不可用 | 不可用 | 不渲染,不作为自动化数据源 |
| 插座倒计时 | 不可用 | 不可用 | 不渲染 |
| 插座与温湿度历史趋势 | 不可用 | 不可用 | 不生成伪历史图表 |
| 网关诊断延迟 | 不可用 | 不适用 | 不渲染 |
| Token 精确有效期 | 不可用 | 不适用 | 不展示倒计时或推测时间 |
## Provider 边界
## 状态确认
- ABox 设备目录只包含用户明确配置的 `DeviceName` 和 `DeviceType`。
- 本地体验设备来自确定性的模拟目录。
- 两个 Provider 的设备描述、状态和历史记录不可互换。
- “不可用”表示没有对应能力,不表示设备故障;界面不添加禁用的伪功能控件。
- 控制请求成功只表示网关接受了命令,不等于物理设备状态已经确认。
- 插座控制后必须回读状态;只有回读值与目标一致才显示确认成功。
- 设备断电、离线或无法读取时,不得在前端仅依据用户点击更新为开启或关闭。
- 温湿度单个字段缺失时保留另一个有效字段,两个字段都缺失时进入无数据状态。
## 数据处理
## 自动化数据语义
- ABSDK 原始 Map、响应文本、Token、密码和红外原始码值不进入 UI 活动记录。
- 错误记录最多保留经过领域层确认的外部错误码。
- 温湿度单个字段缺失时保留另一个有效字段;两个字段均缺失时进入无数据状态。
- 温度、湿度和插座状态来自一次评估快照。
- 状态型触发器在“不匹配”到“匹配”的转换上触发,不会因持续满足而在每次轮询重复执行。
- 条件只决定一次已触发执行是否可以继续,不会单独启动自动化。
- 设备数据不可用时,对应触发器或条件为不可用,本轮不执行。
## 数据与隐私边界
- ABox 设备目录只保存用户确认的设备名称和类型;本地体验使用独立模拟目录。
- 两个 Provider 的设备状态、遥控器、情景和活动记录不可互换。
- 活动记录不得包含密码、Token、原始 SDK Map、完整响应文本或红外原始码值。
- 外部错误码可以在领域层脱敏后保留,用于区分认证、网络、离线和操作失败。
+53 -59
View File
@@ -1,81 +1,75 @@
# 红外控制硬件验证规范与记录 / Infrared Hardware Validation Guide & Records
# 红外控制器硬件验证
## 1. 硬件环境与已知设备信息 / Hardware Environment
## 范围与安全要求
真实硬件验证只允许在获授权的隔离局域网中进行。文档不记录完整网关地址、IEEE 地址、凭据、令牌、红外码值或原始 SDK 响应;设备名称也应使用脱敏别名。
本流程只用于已授权的隔离局域网和实体设备。测试记录使用脱敏 profile、设备和操作标识,不记录密码、Token、完整网关地址、完整硬件标识、原始 SDK 响应或红外码值。
- **设备名称**: 脱敏的红外控制器别名
- **设备类型**: 红外控制器
- **网关/主机**: 获授权的 ABox 智能主机
- **被控实体**: 获授权的实体空调、电视或其他电器
真机验证必须使用 ABox Provider。本地体验只能验证界面和状态机,不能作为红外硬件通过证据。
---
## 前置条件
## 2. 硬件测试记录表 / Hardware Validation Log
- 使用与待发布提交一致的 APK;
- 已通过 ABox 登录并进入设备中心;
- 网关中的红外设备名称与 App 当前设备名称一致;默认名称为 `红外控制器`;
- 红外控制器已通电,并可对准一个获授权的实体电视、空调、风扇或其他设备;
- 系统时间、网络和通知权限状态已记录。
| 日期 | App Commit | 运行模式 | 脱敏 Profile | 脱敏设备名 | 脱敏操作标识 | 操作 | 耗时区间 | 脱敏错误分类 | 物理响应 | 结论 |
|---|---|---|---|---|---|---|---|---|---|
| 待填 | 待填 | 本地体验 | local-test | simulation-ir | test-1 | Learn/Send | 待填 | 无 | 模拟流程通过 | 仅软件验证 |
| 待填 | 待填 | ABox | profile-redacted | device-redacted | operation-redacted | Learn | 待填 | 待填 | 授权遥控器完成学习 | 待真机执行 |
| 待填 | 待填 | ABox | profile-redacted | device-redacted | operation-redacted | Send | 待填 | 待填 | 授权电器产生预期响应 | 待真机执行 |
## 验证流程
---
### 1. 默认内容与管理页
## 3. 标准真机测试流程 (12 项用例) / Standard 12-Step Test Procedure
1. 首次进入红外控制器,确认存在“自定义电视遥控器”和“自定义面板遥控器”。
2. 打开管理页,确认硬件槽位位于顶部。
3. 确认每个遥控器提供排序和编辑入口,而不是列表内直接删除。
4. 修改红外控制器设备名称后,确认后续网关调用使用新名称;名称与网关不一致时应明确失败。
### 用例 1: 真实会话认证前置 (Authenticate Session)
- **前置**: 通过登录模块对进程单例 `AbsdkBridge` 进行登录认证。
- **步骤**: 检查进入红外控制页面前,会话是否有效。
- **预期**: 若未认证,真实模式操作返回会话无效错误并提示返回登录。
### 2. 添加预置遥控器
### 用例 2: 输入并保存真实设备名 (Enter and Save irDevName)
- **步骤**: 在“红外设备名称”输入框中输入获授权设备名,点击“保存设备名”。
- **预期**: 本地 Room 保存成功,页面显示脱敏设备名并启用学习入口。
1. 点击添加遥控器。
2. 第一步选择从红外码库下载预置遥控器。
3. 选择一种可用设备类型与预置项并完成下载。
4. 确认新遥控器出现在列表末尾,并位于遥控器页面导航的最右端。
5. 发送多个按键,确认实体设备响应,界面显示真实操作结果。
6. 确认预置遥控器没有学习入口。
7. 记录逐项下载失败时的状态,确认部分失败不会被显示为全部成功。
### 用例 3: 学习全新唯一按键 (Learn Unique Key)
- **步骤**: 点击“学习新按键”,输入显示名称(如“空调开”),点击“开始学习”;将物理遥控器对准红外控制器按下按键。
- **预期**: 本地先写入 `LEARNING` 状态,硬件捕获完成后状态自动刷新为 `READY`,记录响应耗时与返回码。
### 3. 添加自定义遥控器
### 用例 4: 立即发射已学习按键 (Send Learned Key Immediately)
- **步骤**: 学习成功后,点击该按键右侧的“发射”按钮。
- **预期**: ABSDK 返回 `00000`,实体电器执行对应操作,发射期间按钮防重复点击。
1. 再次点击添加遥控器。
2. 第一步选择通过学习模式创建自定义遥控器。
3. 分别验证电视布局和自定义面板布局。
4. 在自定义面板中添加新按钮,编辑名称并调整顺序。
5. 确认新按钮初始状态为“未学习”,而不是“学习失败”。
### 用例 5: 重复发射验证稳定性 (Repeat Send)
- **步骤**: 间隔 3 秒再次点击同一按键的“发射”按钮。
- **预期**: 发射稳定成功,电器再次响应,按键保持 `READY` 状态不变。
### 4. 学习与发送
### 用例 6: 重新学习同一 SDK Key (Relearn Same Key)
- **步骤**: 针对已存在或失败的按键点击“重试”/重新学习。
- **预期**: 复用相同的 `InfraredKeyId` 和 `sdkKey`,先变更为 `LEARNING`,学习完成后恢复 `READY`,不生成冗余记录。
1. 对一个未学习按钮开始学习,并在物理遥控器上发送信号。
2. 确认状态从未学习进入学习中,再进入可发送状态。
3. 立即发送该按键,确认实体设备响应。
4. 重新学习同一按钮,确认复用原按钮记录,不生成重复按钮。
5. 学习过程中离开页面,稍后返回,确认任务结果已持久化。
6. 学习过程中强制结束 App,再次进入后,确认遗留学习状态被保守恢复为失败并允许重试。
### 用例 7: 控制器断电/离线测试 (Controller Offline)
- **步骤**: 拔掉红外控制器电源,发起学习或发射。
- **预期**: ABSDK 返回 `20206`,领域层转换为 `DomainError.DeviceOffline`,学习按键标为 `FAILED`,UI 提示控制器离线。
### 5. 失败与离线
### 用例 8: 遥控器无信号/超时测试 (No Signal / Learning Timeout)
- **步骤**: 点击“开始学习”,但不按物理遥控器任何按键,等待 SDK 返回。
- **预期**: 记录实际超时等待时间与错误码(如 `20204`/`20205`),按键转换为 `FAILED` 并允许重试。
1. 开始学习但不发送物理信号,确认超时显示为学习失败,而不是未学习。
2. 断开红外控制器电源后尝试学习和发送,确认显示设备离线或对应领域错误。
3. 恢复供电后重试,确认成功结果替换失败状态。
4. 使 ABox 会话失效后操作,确认返回登录流程,不继续使用旧会话。
### 用例 9: 学习过程中离开页面 (Leave Page During Learning)
- **步骤**: 点击开始学习后,立即点击“返回”离开红外页面;稍后重新进入。
- **预期**: Application 协程作用域内任务继续执行,学习完成后 Room 数据库正确持久化结果,重新进入显示最新状态。
### 6. 排序、编辑与删除
### 用例 10: 进程被杀死后状态恢复 (Process Interruption Recovery)
- **步骤**: 在 `LEARNING` 状态下强制杀死 App 进程并重启。
- **预期**: App 启动或重新进入页面时,`RecoverInterruptedInfraredLearning` 将遗留的 `LEARNING` 保守恢复为 `FAILED`,提示用户重新学习。
1. 调整遥控器顺序,离开并重新进入后确认顺序持久化。
2. 编辑遥控器名称和布局允许修改的属性,确认列表与详情同步更新。
3. 删除测试遥控器,确认其按钮和引用得到一致处理,不留下可进入的空详情页。
4. 验证长英文按钮文本不会溢出、重叠或遮挡操作控件。
### 用例 11: Token 失效与重新登录 (Token Expiration)
- **步骤**: 使远端会话失效后发起红外操作。
- **预期**: 返回会话无效错误,本地会话被清除,页面提示登录失效并导航返回。
### 7. 活动记录
### 用例 12: 检查未文档化响应字段 (Inspect Undocumented Fields)
- **步骤**: 仅在授权的隔离环境中确认响应是否包含已知字段;不得保存或传播原始响应。
- **安全约束**: 严禁将原始响应、令牌、密码、红外码值或未脱敏网络标识打印至日志或写入测试记录。
1. 完成一次预置按键发送、一次自定义按键学习、一次发送失败和一次离线失败。
2. 打开活动记录,确认事件按时间显示,并区分操作类型、来源和结果。
3. 确认记录中没有原始红外码值、Token 或 SDK 响应。
---
## 结果记录
## 4. 协议与硬件事实确认记录 / Observed Facts
- **SDK 阻塞模式**: 同步阻塞 HTTP POST 调用,由 `AbsdkBridge` 调度至 `Dispatchers.IO` 并通过进程级 `Mutex` 串行执行。
- **数据隔离**: 本地体验使用独立的模拟 profile;ABox 模式使用当前已认证 profile,二者不共享设备、遥控器或会话数据。
- **传输事实**: 真实 ABSDK 使用厂商 JAR 中的明文 HTTP 传输。Debug 与 Release 暴露相同的提供方和硬件路由;项目通过显式传输标记和 manifest 的明文配置接受这一局域网集成风险。该配置不提供机密性或完整性。
每条记录只包含:日期、App 版本与提交号、脱敏 profile、脱敏设备名、遥控器类型、操作类别、成功/失败/取消分类、粗粒度耗时、实体设备响应摘要和缺陷编号。
+75 -24
View File
@@ -1,38 +1,89 @@
# 发布版局域网硬件验证协议
# 正式版局域网硬件验收
## 目的与边界
本协议只用于获授权的隔离局域网。使用签名的 Release APK、已确认的测试账号和明确授权的实体设备;不得把测试扩展到未授权网络或设备。
本协议验证签名 Release APK 在已授权 ABox 局域网中的完整产品路径。本地体验和测试 Fake 不能替代真实硬件验收。
协议验证真实产品路径,不把本地体验或测试 Fake 的结果当作 ABox 硬件证据。每条记录使用脱敏 profile、设备和操作标识。
报告不得包含密码、Token、原始厂商响应、红外码值、完整网关地址或完整硬件标识。设备和 profile 使用脱敏别名。
## 执行前检查
- 确认 APK 签名、版本和提交号,记录到测试报告。
- 确认测试网络与设备均获授权,并记录网络的脱敏名称。
- 先使用已知无效凭据验证登录失败,再使用授权凭据登录。
- 检查当前 profile、会话状态和已配置设备目录;只验证用户明确配置的设备。
- 不在报告、截图、日志或缺陷单中保存密码、令牌、原始厂商响应、红外码值、完整网关地址或完整 IEEE 地址。
- 记录 App 版本、versionCode、Git 提交和 APK 签名摘要;
- 确认测试网络、账号、网关和实体设备均已授权;
- 确认 Android 通知权限和系统省电策略;
- 准备实际名称分别为 `智能插座`、`温湿度传感器` 和 `红外控制器` 的设备,或在 App 中改成网关当前名称;
- 清楚标记哪些步骤是界面验证、网关响应验证和实体设备响应验证。
## 验证流程
## 验收流程
1. 登录失败路径:确认无效凭据停留在登录页,并显示本地化错误。
2. 授权登录路径:确认授权凭据登录成功,进入认证后的设备中心。
3. 设备目录:配置一个已授权的真实设备名,确认只显示当前 profile 的设备,并可进行只读列表检查。
4. 插座操作:对一个已配置插座执行一次开或关命令,随后读取状态确认;记录结果分类,不记录原始响应。
5. 传感器读取:读取已配置的温湿度设备,验证有数据、部分数据和无数据状态的显示边界。
6. 红外学习:对已授权红外控制器学习一个测试按键,确认状态从学习中到完成或明确失败。
7. 红外取消:学习过程中离开页面或取消,确认不会留下可继续发送的半成品记录。
8. 红外发送:发送已完成的测试按键,确认授权电器产生预期响应。
9. 情景执行:执行一个只包含已配置设备的情景,观察实际步骤结果和终态。
10. 活动记录:确认上述真实终态写入当前 profile 的活动时间线,失败、等待确认和取消保持不同分类。
11. 会话失效:使远端会话失效后重试一次设备操作,确认本地会话清理并返回登录流程。
12. Local 隔离复核:切换到本地体验后,确认 ABox 设备、遥控器、活动和会话数据不出现;该步骤不作为真实硬件通过证据。
### 1. 安装、启动与登录
1. 覆盖安装或全新安装 Release APK,确认启动不闪退。
2. 使用无效凭据登录,确认停留在登录页并显示本地化错误。
3. 使用授权凭据登录,确认进入设备中心。
4. 重启 App,验证记住用户名和自动登录的已选行为。
5. 使 Token 失效后执行操作,确认会话清理并返回登录流程。
### 2. 默认内容与数据隔离
1. 首次登录 ABox 后确认三类默认设备存在,名称逐字为 `智能插座`、`温湿度传感器`、`红外控制器`。
2. 确认红外控制器包含两个自定义遥控器,不自动添加预置码库遥控器。
3. 重命名设备并重启,确认 App 保留用户名称,不执行自动迁移或覆盖。
4. 切换到本地体验,确认 ABox 设备、遥控器、情景、活动和会话数据不出现。
### 3. 设备控制
1. 在设备中心和插座详情各执行一次开关操作。
2. 确认命令后回读真实状态;插座断电时不得仅因点击而显示已切换。
3. 连续点击电源按钮,确认页面不抖动且不会并发重复发送。
4. 读取温度和湿度,验证完整数据、单字段缺失、无数据和离线状态。
5. 确认设备离线在设备中心和详情页均有明确视觉状态。
### 4. 红外控制器
1. 按 `infrared-hardware-validation.md` 完成一个预置遥控器下载和发送流程。
2. 完成一个自定义按钮的添加、学习、发送和重新学习流程。
3. 验证学习失败与未学习状态不同。
4. 验证遥控器排序、编辑、删除和硬件槽位管理。
### 5. 手动指令
1. 创建包含插座、红外、延迟和通知动作的手动指令。
2. 按配置顺序执行,确认失败策略和动作结果汇总符合设置。
3. 验证同一指令执行期间不会被重复启动。
### 6. 自动指令
1. 创建一个温度阈值触发的自动指令,并添加插座状态条件。
2. 使用手动执行入口验证动作本身可用。
3. 让传感器状态从不匹配进入匹配,确认执行一次。
4. 在持续匹配期间等待后续轮询,确认不会重复执行。
5. 先恢复到不匹配,再重新进入匹配,确认可以再次执行。
6. 验证时间、湿度和插座状态触发器至少各一次。
7. 验证温度、湿度、插座状态和跨午夜时间范围条件。
8. 拒绝通知权限后执行通知动作,确认显示明确失败而不是成功。
### 7. 活动记录
1. 确认设备命令、状态确认、传感器刷新、红外学习/发送、手动指令和自动指令均写入活动记录。
2. 确认来源区分用户、自动化和系统,结果区分成功、失败、取消和等待确认。
3. 在存在大量记录时验证分页加载和重新进入页面,不得闪退。
4. 确认记录不包含凭据、原始响应或红外码值。
### 8. 外观、导航与本地化
1. 验证浅色、深色、动态颜色、手动强调色、色彩风格、色彩规范和界面缩放。
2. 在英文和简体中文下检查设备、红外、情景、自动化、活动、主题和关于页面。
3. 验证二级页面不显示主页顶栏和底栏。
4. 验证系统返回和左上角返回不会进入空白页面或重复回退。
5. 验证 About 页面显示 `1.0.0 (1)`、包名和 Flagship 团队信息,点击入口不闪退。
## 结果记录
每个步骤只记录:日期、Release 版本或提交号、脱敏 profile、脱敏设备名、操作类别、成功/失败/取消/不可用分类、粗粒度耗时和物理响应摘要。错误只记录已脱敏的领域错误分类;不要复制堆栈或原始 SDK 文本。
每个步骤记录:日期、版本与提交、脱敏 profile、脱敏设备名、操作类别、成功/失败/取消/不可用分类、粗粒度耗时、物理响应摘要和缺陷编号。真实硬件终态与 App 显示不一致必须判定为失败。
## 风险说明
## 已知风险
真实 ABSDK 使用厂商 JAR 中的明文 HTTP 传输。Release 与 Debug 使用相同的提供方和硬件路由;明文传输不提供机密性或完整性,因此只能在隔离且获授权的局域网执行本协议。
- ABSDK 使用厂商 JAR 中的明文 HTTP,只能在隔离且获授权的局域网中验收;
- Android 后台限制和省电策略可能影响自动化轮询时效,应记录测试设备设置;
- 网关依赖设备名称匹配,App 名称与网关名称不一致会导致设备操作失败。