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
4 changed files with 66 additions and 8 deletions
Showing only changes of commit a3bcdd973c - Show all commits
@@ -28,6 +28,7 @@ class RoomInfraredKeyRepository(
DomainResult.Success(entities.map { it.toDomain() })
}
.catch { throwable ->
if (throwable is kotlinx.coroutines.CancellationException) throw throwable
emit(DomainResult.Failure(mapThrowableToDomainError(throwable)))
}
}
@@ -38,6 +39,7 @@ class RoomInfraredKeyRepository(
DomainResult.Success(entity?.deviceName?.let { DeviceName(it) })
}
.catch { throwable ->
if (throwable is kotlinx.coroutines.CancellationException) throw throwable
emit(DomainResult.Failure(mapThrowableToDomainError(throwable)))
}
}
@@ -182,6 +184,9 @@ class RoomInfraredKeyRepository(
}
private fun mapThrowableToDomainError(throwable: Throwable): DomainError {
if (throwable is kotlinx.coroutines.CancellationException) {
throw throwable
}
return when (throwable) {
is SQLiteConstraintException -> DomainError.InvalidRequest(detail = "Database constraint violation")
else -> DomainError.Unknown(detail = "Database operation failed")
@@ -19,6 +19,7 @@ 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.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -68,6 +69,7 @@ class InfraredRemoteViewModel @Inject constructor(
private val sendingKeySlotFlow = MutableStateFlow<String?>(null)
private val activeLearningSlotFlow = MutableStateFlow<String?>(null)
private val messageFlow = MutableStateFlow<RemoteUiMessage?>(null)
private var activeLearningJob: Job? = null
private val effectiveProfileAndMode: Flow<Pair<ProviderProfileId?, InfraredRuntimeMode>> =
runtimeModeStore.mode.flatMapLatest { mode ->
@@ -149,6 +151,8 @@ class InfraredRemoteViewModel @Inject constructor(
fun toggleLearningMode(enabled: Boolean) {
isLearningModeFlow.value = enabled
if (!enabled) {
activeLearningJob?.cancel()
activeLearningJob = null
activeLearningSlotFlow.value = null
} else {
messageFlow.value = RemoteUiMessage.Info("🔴 学习录制模式已开启:点击任意按键即可开始录制")
@@ -168,17 +172,26 @@ class InfraredRemoteViewModel @Inject constructor(
return
}
activeLearningSlotFlow.value = spec.defaultSlot
val job = learningCoordinator.learn(
val existingKey = state.value.learnedSlotMap[spec.defaultSlot]
val job = if (existingKey != null) {
learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id))
} else {
learningCoordinator.learn(
profileId = currentProfile,
deviceName = savedDevice,
displayName = spec.displayName,
sdkKey = spec.defaultSlot,
)
}
activeLearningJob = job
job.invokeOnCompletion {
if (activeLearningJob === job) {
activeLearningJob = null
if (activeLearningSlotFlow.value == spec.defaultSlot) {
activeLearningSlotFlow.value = null
}
}
}
messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按遥控器按键...")
} else {
if (sendingKeySlotFlow.value != null) {
@@ -207,6 +220,12 @@ class InfraredRemoteViewModel @Inject constructor(
}
}
override fun onCleared() {
super.onCleared()
activeLearningJob?.cancel()
activeLearningJob = null
}
fun clearMessage() {
messageFlow.value = null
}
@@ -52,6 +52,7 @@ class DataStoreInfraredRuntimeModeStore(
}
DomainResult.Success(Unit)
} catch (throwable: Throwable) {
if (throwable is kotlinx.coroutines.CancellationException) throw throwable
DomainResult.Failure(DomainError.Unknown(detail = "Failed to update runtime mode"))
}
}
@@ -183,4 +183,37 @@ class InfraredRemoteViewModelTest {
job.cancel()
}
@Test
fun learningExistingKeyRetriesExistingKey() = testScope.runTest {
deviceRepo.setDeviceName(fakeProfile, device)
val failedKey = SavedInfraredKey(
id = InfraredKeyId("k1"),
profileId = fakeProfile,
deviceName = device,
displayName = "空调-电源",
sdkKey = "1",
status = InfraredKeyStatus.FAILED,
lastErrorCode = "20206",
lastErrorDetail = "offline",
createdAtEpochMillis = 100L,
updatedAtEpochMillis = 100L,
)
keyRepo.insert(failedKey)
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)
job.cancel()
}
}