feat(infrared): add infrared control, learning, and virtual remote module (#9)
### 概述 / Summary 完整实现红外控制与学习模块(`:android:feature:infrared`),支持红外设备按键学习、实体发射、Room 本地持久化,以及空调 (AC) / 电视 (TV) 虚拟遥控器面板。 ### 核心变更 / Key Changes 1. **领域模型与用例 (`:domain`)**: - 实现按键学习流程:`LearnInfraredKey`、`ManageInfraredKey`、`InfraredKeyFactory`; - 增加虚拟遥控器模型与用例:`CreateVirtualRemote`、`LearnVirtualRemoteKey`、`SendVirtualRemoteAction`、`AcCodeGenerator` 与预置码库 `PresetCodeLibrary`。 2. **数据持久化与仓储 (`:android:feature:infrared`)**: - 引入 Room 数据库 `InfraredDatabase` 及 DAO,提供 `RoomInfraredKeyRepository` 与 `RoomVirtualRemoteRepository`; - 支持多配置隔离(`ProviderProfileId`)、按键状态管理(`LEARNING` / `READY` / `FAILED`)。 3. **UI 与 ViewModel (`:android:feature:infrared`)**: - `InfraredControlScreen`:红外学习与按键调试列表; - `RemotePanelScreen` / `AcRemotePanel` / `TvRemotePanel`:空调/电视专属可视化遥控面板; - `InfraredHubScreen`:红外中枢与多遥控器管理入口; - 在认证后导航栈中将 `DeviceListScreen` 的红外入口无缝对接到 `InfraredHubRoute`。 4. **测试与文档**: - 补齐用例、Fakes、Repository、ViewModel 以及 Compose UI 自动化测试; - 补充红外控制设计规范与真机硬件验收清单。 Reviewed-on: #9 Co-authored-by: mxhsnd <2049295946@qq.com> Co-authored-by: KremeCN <3265494849@qq.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,589 @@
|
||||
# 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
|
||||
```
|
||||
@@ -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<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. 真机验证后对学习语义、超时和错误模型的修订。
|
||||
@@ -0,0 +1,115 @@
|
||||
# 红外遥控器界面与多场景控制设计规范 / 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 编号学习与发射。
|
||||
Reference in New Issue
Block a user