feat(infrared): add infrared control, learning, and virtual remote module #9

Merged
KremeCN merged 41 commits from feat/moler-infrared-control into main 2026-08-25 14:51:34 +08:00
5 changed files with 104 additions and 28 deletions
Showing only changes of commit a3812aae27 - Show all commits
@@ -75,8 +75,16 @@ class InfraredRemoteViewModel @Inject constructor(
private var underlyingLearningDeferred: kotlinx.coroutines.Deferred<*>? = null private var underlyingLearningDeferred: kotlinx.coroutines.Deferred<*>? = null
init { init {
authenticatedProfileId?.let { profile -> if (authenticatedProfileId != null) {
learningCoordinator.failInterruptedLearning(profile) viewModelScope.launch {
runtimeModeStore.mode.collect { mode ->
val effective = profileResolver.resolve(authenticatedProfileId, mode)
learningCoordinator.failInterruptedLearning(effective)
if (effective != authenticatedProfileId) {
learningCoordinator.failInterruptedLearning(authenticatedProfileId)
}
}
}
} }
} }
@@ -85,6 +93,7 @@ class InfraredRemoteViewModel @Inject constructor(
val runtimeMode: InfraredRuntimeMode = InfraredRuntimeMode.FAKE, val runtimeMode: InfraredRuntimeMode = InfraredRuntimeMode.FAKE,
val savedDeviceName: DeviceName? = null, val savedDeviceName: DeviceName? = null,
val learnedSlotMap: Map<String, SavedInfraredKey> = emptyMap(), val learnedSlotMap: Map<String, SavedInfraredKey> = emptyMap(),
val configError: DomainError? = null,
) )
private val configSnapshotFlow: Flow<InfraredRemoteConfigSnapshot> = private val configSnapshotFlow: Flow<InfraredRemoteConfigSnapshot> =
@@ -94,25 +103,43 @@ class InfraredRemoteViewModel @Inject constructor(
flowOf(InfraredRemoteConfigSnapshot(effectiveProfileId = null, runtimeMode = mode)) flowOf(InfraredRemoteConfigSnapshot(effectiveProfileId = null, runtimeMode = mode))
} else { } else {
deviceConfigurationRepository.observeDeviceName(profile).flatMapLatest { deviceResult -> deviceConfigurationRepository.observeDeviceName(profile).flatMapLatest { deviceResult ->
val deviceName = (deviceResult as? DomainResult.Success)?.value when (deviceResult) {
if (deviceName == null) { is DomainResult.Failure -> flowOf(
flowOf(
InfraredRemoteConfigSnapshot( InfraredRemoteConfigSnapshot(
effectiveProfileId = profile, effectiveProfileId = profile,
runtimeMode = mode, runtimeMode = mode,
savedDeviceName = null, configError = deviceResult.error,
learnedSlotMap = emptyMap(),
), ),
) )
} else { is DomainResult.Success -> {
keyRepository.observeKeys(profile, deviceName).map { keysResult -> val deviceName = deviceResult.value
val keys = (keysResult as? DomainResult.Success<List<SavedInfraredKey>>)?.value ?: emptyList() if (deviceName == null) {
InfraredRemoteConfigSnapshot( flowOf(
effectiveProfileId = profile, InfraredRemoteConfigSnapshot(
runtimeMode = mode, effectiveProfileId = profile,
savedDeviceName = deviceName, runtimeMode = mode,
learnedSlotMap = keys.associateBy { it.sdkKey }, savedDeviceName = null,
) learnedSlotMap = emptyMap(),
),
)
} else {
keyRepository.observeKeys(profile, deviceName).map { keysResult ->
when (keysResult) {
is DomainResult.Failure -> InfraredRemoteConfigSnapshot(
effectiveProfileId = profile,
runtimeMode = mode,
savedDeviceName = deviceName,
configError = keysResult.error,
)
is DomainResult.Success -> InfraredRemoteConfigSnapshot(
effectiveProfileId = profile,
runtimeMode = mode,
savedDeviceName = deviceName,
learnedSlotMap = keysResult.value.associateBy { it.sdkKey },
)
}
}
}
} }
} }
} }
@@ -127,6 +154,13 @@ class InfraredRemoteViewModel @Inject constructor(
Triple(sending, learning, msg) Triple(sending, learning, msg)
}, },
) { scene, isLearning, snapshot, (sendingSlot, learningSlot, msg) -> ) { scene, isLearning, snapshot, (sendingSlot, learningSlot, msg) ->
val resolvedMessage = msg ?: snapshot.configError?.let { error ->
when (error) {
is DomainError.TokenInvalid, is DomainError.Authentication -> RemoteUiMessage.SessionInvalid
is DomainError.DeviceOffline -> RemoteUiMessage.Error("红外控制器离线 (错误码: ${error.externalCode ?: "20206"})")
else -> RemoteUiMessage.Error(error.detail ?: "数据加载失败")
}
}
InfraredRemoteUiState( InfraredRemoteUiState(
selectedScene = scene, selectedScene = scene,
isLearningMode = isLearning, isLearningMode = isLearning,
@@ -136,7 +170,7 @@ class InfraredRemoteViewModel @Inject constructor(
learnedSlotMap = snapshot.learnedSlotMap, learnedSlotMap = snapshot.learnedSlotMap,
sendingKeySlot = sendingSlot, sendingKeySlot = sendingSlot,
activeLearningSlot = learningSlot, activeLearningSlot = learningSlot,
message = msg, message = resolvedMessage,
) )
}.stateIn( }.stateIn(
scope = viewModelScope, scope = viewModelScope,
@@ -177,15 +211,12 @@ class InfraredRemoteViewModel @Inject constructor(
return return
} }
val existingKey = state.value.learnedSlotMap[spec.defaultSlot] val existingKey = state.value.learnedSlotMap[spec.defaultSlot]
if (existingKey?.status == InfraredKeyStatus.LEARNING) {
return
}
messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按遥控器按键...") messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按遥控器按键...")
activeLearningSlotFlow.value = spec.defaultSlot activeLearningSlotFlow.value = spec.defaultSlot
val deferred = when (existingKey?.status) { val deferred = when (existingKey?.status) {
InfraredKeyStatus.FAILED -> learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id)) InfraredKeyStatus.FAILED -> learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id))
InfraredKeyStatus.READY -> learningCoordinator.relearn(InfraredKeyRef(currentProfile, existingKey.id)) InfraredKeyStatus.READY, InfraredKeyStatus.LEARNING -> learningCoordinator.relearn(InfraredKeyRef(currentProfile, existingKey.id))
else -> learningCoordinator.learn( null -> learningCoordinator.learn(
profileId = currentProfile, profileId = currentProfile,
deviceName = savedDevice, deviceName = savedDevice,
Review

设备错绑

配置尚未加载时,savedDeviceName 为 null,这里会直接回退到 ir-1,但遥控按键仍可操作。若该配置实际保存了其他设备名,冷启动后快速开启学习并点击按键会将按键绑定到错误设备,配置加载完成后该按键还会从当前列表消失。应在设备配置解析完成前禁用学习操作。

**设备错绑** 配置尚未加载时,`savedDeviceName` 为 `null`,这里会直接回退到 `ir-1`,但遥控按键仍可操作。若该配置实际保存了其他设备名,冷启动后快速开启学习并点击按键会将按键绑定到错误设备,配置加载完成后该按键还会从当前列表消失。应在设备配置解析完成前禁用学习操作。 <!-- pr-agent-dedup: 64f51ac400f4 --> <!-- pr-agent-key-issue-location: 40951f9578b1 -->
displayName = spec.displayName, displayName = spec.displayName,
@@ -305,4 +305,51 @@ class InfraredRemoteViewModelTest {
job.cancel() job.cancel()
} }
@Test
fun relearningKeyInLearningStatusSucceeds() = testScope.runTest {
deviceRepo.setDeviceName(fakeProfile, device)
val learningKey = SavedInfraredKey(
id = InfraredKeyId("k1"),
profileId = fakeProfile,
deviceName = device,
displayName = "空调-电源",
sdkKey = "1",
status = InfraredKeyStatus.LEARNING,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 100L,
updatedAtEpochMillis = 100L,
)
keyRepo.insert(learningKey)
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)
val updated = (keyRepo.getKey(com.flagship.abox.manager.domain.infrared.InfraredKeyRef(fakeProfile, InfraredKeyId("k1"))) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.READY, updated.status)
assertEquals(RemoteUiMessage.Info("【空调-电源】录制成功"), vm.state.value.message)
job.cancel()
}
@Test
fun configSnapshotStorageErrorPropagatesToUiStateMessage() = testScope.runTest {
deviceRepo.observeError = com.flagship.abox.manager.domain.DomainError.Unknown(detail = "Disk failure")
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
assertEquals(RemoteUiMessage.Error("Disk failure"), vm.state.value.message)
job.cancel()
}
} }
@@ -176,11 +176,6 @@ class RelearnInfraredKey(
is DomainResult.Failure -> return result is DomainResult.Failure -> return result
} }
if (key.status == InfraredKeyStatus.LEARNING) {
return DomainResult.Failure(
DomainError.InvalidRequest(detail = "Infrared key is already learning"),
)
}
when ( when (
val result = repository.transitionStatus( val result = repository.transitionStatus(
@@ -188,6 +183,7 @@ class RelearnInfraredKey(
expectedStatuses = setOf( expectedStatuses = setOf(
InfraredKeyStatus.READY, InfraredKeyStatus.READY,
InfraredKeyStatus.FAILED, InfraredKeyStatus.FAILED,
InfraredKeyStatus.LEARNING,
), ),
targetStatus = InfraredKeyStatus.LEARNING, targetStatus = InfraredKeyStatus.LEARNING,
lastErrorCode = null, lastErrorCode = null,
@@ -159,7 +159,7 @@ class LearnInfraredKeyTest {
assertEquals(0, repository.insertedKeys.size) assertEquals(0, repository.insertedKeys.size)
assertEquals(2, repository.transitionCalls.size) assertEquals(2, repository.transitionCalls.size)
assertEquals( assertEquals(
setOf(InfraredKeyStatus.READY, InfraredKeyStatus.FAILED), setOf(InfraredKeyStatus.READY, InfraredKeyStatus.FAILED, InfraredKeyStatus.LEARNING),
repository.transitionCalls.first().expectedStatuses, repository.transitionCalls.first().expectedStatuses,
) )
assertEquals( assertEquals(
@@ -134,10 +134,12 @@ class FakeInfraredKeyRepository : InfraredKeyRepository {
class FakeInfraredDeviceConfigurationRepository : InfraredDeviceConfigurationRepository { class FakeInfraredDeviceConfigurationRepository : InfraredDeviceConfigurationRepository {
private val lock = Any() private val lock = Any()
private val deviceNames = MutableStateFlow<Map<ProviderProfileId, DeviceName>>(emptyMap()) private val deviceNames = MutableStateFlow<Map<ProviderProfileId, DeviceName>>(emptyMap())
var observeError: DomainError? = null
override fun observeDeviceName( override fun observeDeviceName(
profileId: ProviderProfileId, profileId: ProviderProfileId,
): Flow<DomainResult<DeviceName?>> = deviceNames.map { values -> ): Flow<DomainResult<DeviceName?>> = deviceNames.map { values ->
observeError?.let { return@map DomainResult.Failure(it) }
DomainResult.Success(values[profileId]) DomainResult.Success(values[profileId])
} }