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
8 changed files with 245 additions and 12 deletions
Showing only changes of commit b6cae9b2c9 - Show all commits
@@ -5,15 +5,21 @@ import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
import com.flagship.abox.manager.domain.infrared.InfraredKeyRepository
import com.flagship.abox.manager.domain.infrared.LearnInfraredKey
import com.flagship.abox.manager.domain.infrared.RelearnInfraredKey
import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
class InfraredLearningCoordinator(
private val learnInfraredKey: LearnInfraredKey,
private val retryInfraredKeyLearning: RetryInfraredKeyLearning,
private val relearnInfraredKey: RelearnInfraredKey,
private val keyRepository: InfraredKeyRepository,
private val externalScope: CoroutineScope,
) {
fun learn(
@@ -28,4 +34,14 @@ class InfraredLearningCoordinator(
fun retry(ref: InfraredKeyRef): Deferred<DomainResult<Unit>> = externalScope.async {
retryInfraredKeyLearning(ref)
}
fun relearn(ref: InfraredKeyRef): Deferred<DomainResult<Unit>> = externalScope.async {
relearnInfraredKey(ref)
}
fun failInterruptedLearning(profileId: ProviderProfileId, updatedAtEpochMillis: Long = System.currentTimeMillis()) {
externalScope.launch(NonCancellable) {
keyRepository.failInterruptedLearning(profileId, updatedAtEpochMillis)
}
}
}
@@ -170,15 +170,28 @@ object InfraredFeatureModule {
clock: EpochMillisClock,
): RecoverInterruptedInfraredLearning = RecoverInterruptedInfraredLearning(repository, clock)
@Provides
@Singleton
fun provideRelearnInfraredKey(
repository: InfraredKeyRepository,
gateway: InfraredGateway,
clock: EpochMillisClock,
): com.flagship.abox.manager.domain.infrared.RelearnInfraredKey =
com.flagship.abox.manager.domain.infrared.RelearnInfraredKey(repository, gateway, clock)
@Provides
@Singleton
fun provideInfraredLearningCoordinator(
learnInfraredKey: LearnInfraredKey,
retryInfraredKeyLearning: RetryInfraredKeyLearning,
relearnInfraredKey: com.flagship.abox.manager.domain.infrared.RelearnInfraredKey,
repository: InfraredKeyRepository,
@InfraredApplicationScope scope: CoroutineScope,
): InfraredLearningCoordinator = InfraredLearningCoordinator(
learnInfraredKey = learnInfraredKey,
retryInfraredKeyLearning = retryInfraredKeyLearning,
relearnInfraredKey = relearnInfraredKey,
keyRepository = repository,
externalScope = scope,
)
}
@@ -74,6 +74,12 @@ class InfraredRemoteViewModel @Inject constructor(
private var activeLearningJob: Job? = null
private var underlyingLearningDeferred: kotlinx.coroutines.Deferred<*>? = null
init {
authenticatedProfileId?.let { profile ->
learningCoordinator.failInterruptedLearning(profile)
}
}
private data class InfraredRemoteConfigSnapshot(
val effectiveProfileId: ProviderProfileId? = null,
val runtimeMode: InfraredRuntimeMode = InfraredRuntimeMode.FAKE,
@@ -151,9 +157,7 @@ class InfraredRemoteViewModel @Inject constructor(
activeLearningJob = null
activeLearningSlotFlow.value = null
state.value.effectiveProfileId?.let { profile ->
viewModelScope.launch(kotlinx.coroutines.NonCancellable) {
keyRepository.failInterruptedLearning(profile, System.currentTimeMillis())
}
learningCoordinator.failInterruptedLearning(profile)
}
} else {
messageFlow.value = RemoteUiMessage.Info("🔴 学习录制模式已开启:点击任意按键即可开始录制")
@@ -178,10 +182,10 @@ class InfraredRemoteViewModel @Inject constructor(
}
messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按遥控器按键...")
activeLearningSlotFlow.value = spec.defaultSlot
val deferred = if (existingKey != null) {
learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id))
} else {
learningCoordinator.learn(
val deferred = when (existingKey?.status) {
InfraredKeyStatus.FAILED -> learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id))
InfraredKeyStatus.READY -> learningCoordinator.relearn(InfraredKeyRef(currentProfile, existingKey.id))
else -> learningCoordinator.learn(
profileId = currentProfile,
deviceName = savedDevice,
displayName = spec.displayName,
@@ -254,6 +258,9 @@ class InfraredRemoteViewModel @Inject constructor(
underlyingLearningDeferred = null
activeLearningJob?.cancel()
activeLearningJob = null
state.value.effectiveProfileId?.let { profile ->
learningCoordinator.failInterruptedLearning(profile)
}
}
fun clearMessage() {
@@ -9,6 +9,7 @@ import com.flagship.abox.manager.domain.infrared.InfraredKeyId
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.LearnInfraredKey
import com.flagship.abox.manager.domain.infrared.RelearnInfraredKey
import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
@@ -34,10 +35,11 @@ class InfraredLearningCoordinatorTest {
private val learn = LearnInfraredKey(repository, gateway, factory, clock)
private val retry = RetryInfraredKeyLearning(repository, gateway, clock)
private val relearn = RelearnInfraredKey(repository, gateway, clock)
@Test
fun learnRunsAsynchronouslyInExternalScope() = runTest {
val coordinator = InfraredLearningCoordinator(learn, retry, this)
val coordinator = InfraredLearningCoordinator(learn, retry, relearn, repository, this)
coordinator.learn(profile, device, "Power")
advanceUntilIdle()
@@ -65,7 +67,7 @@ class InfraredLearningCoordinatorTest {
)
repository.insert(failedKey)
val coordinator = InfraredLearningCoordinator(learn, retry, this)
val coordinator = InfraredLearningCoordinator(learn, retry, relearn, repository, this)
coordinator.retry(InfraredKeyRef(profile, InfraredKeyId("key1")))
advanceUntilIdle()
@@ -73,4 +75,54 @@ class InfraredLearningCoordinatorTest {
val updated = (repository.getKey(InfraredKeyRef(profile, InfraredKeyId("key1"))) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.READY, updated.status)
}
@Test
fun relearnRunsAsynchronouslyInExternalScope() = runTest {
val readyKey = SavedInfraredKey(
id = InfraredKeyId("key1"),
profileId = profile,
deviceName = device,
displayName = "Power",
sdkKey = "app_ir_key1",
status = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 500L,
updatedAtEpochMillis = 500L,
)
repository.insert(readyKey)
val coordinator = InfraredLearningCoordinator(learn, retry, relearn, repository, this)
coordinator.relearn(InfraredKeyRef(profile, InfraredKeyId("key1")))
advanceUntilIdle()
val updated = (repository.getKey(InfraredKeyRef(profile, InfraredKeyId("key1"))) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.READY, updated.status)
}
@Test
fun failInterruptedLearningFailsLearningKeys() = runTest {
val learningKey = SavedInfraredKey(
id = InfraredKeyId("key1"),
profileId = profile,
deviceName = device,
displayName = "Power",
sdkKey = "app_ir_key1",
status = InfraredKeyStatus.LEARNING,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 500L,
updatedAtEpochMillis = 500L,
)
repository.insert(learningKey)
val coordinator = InfraredLearningCoordinator(learn, retry, relearn, repository, this)
coordinator.failInterruptedLearning(profile, 2_000L)
advanceUntilIdle()
val updated = (repository.getKey(InfraredKeyRef(profile, InfraredKeyId("key1"))) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.FAILED, updated.status)
}
}
@@ -64,13 +64,14 @@ class InfraredRemoteViewModelTest {
private val learn = LearnInfraredKey(keyRepo, gateway, factory, clock)
private val retry = RetryInfraredKeyLearning(keyRepo, gateway, clock)
private val relearn = com.flagship.abox.manager.domain.infrared.RelearnInfraredKey(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)
coordinator = InfraredLearningCoordinator(learn, retry, relearn, keyRepo, testScope)
}
@After
@@ -270,4 +271,38 @@ class InfraredRemoteViewModelTest {
job.cancel()
}
@Test
fun relearningExistingReadyKeyOverwritesKey() = 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.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()
}
}
@@ -71,6 +71,7 @@ class InfraredControlViewModelTest {
private val learnUseCase = LearnInfraredKey(keyRepository, gateway, factory, clock)
private val retryUseCase = RetryInfraredKeyLearning(keyRepository, gateway, clock)
private val relearnUseCase = com.flagship.abox.manager.domain.infrared.RelearnInfraredKey(keyRepository, gateway, clock)
private val sendUseCase = SendInfraredKey(keyRepository, gateway)
private val renameUseCase = RenameInfraredKey(keyRepository, clock)
private val deleteUseCase = DeleteInfraredKey(keyRepository)
@@ -81,7 +82,7 @@ class InfraredControlViewModelTest {
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
coordinator = InfraredLearningCoordinator(learnUseCase, retryUseCase, testScope)
coordinator = InfraredLearningCoordinator(learnUseCase, retryUseCase, relearnUseCase, keyRepository, testScope)
}
@After
@@ -106,7 +106,88 @@ class RetryInfraredKeyLearning(
ref = ref,
expectedStatuses = setOf(
InfraredKeyStatus.FAILED,
),
targetStatus = InfraredKeyStatus.LEARNING,
lastErrorCode = null,
lastErrorDetail = null,
updatedAtEpochMillis = clock.now(),
)
) {
is DomainResult.Success -> Unit
is DomainResult.Failure -> return result
}
val result = try {
gateway.learnKey(
profileId = key.profileId,
deviceName = key.deviceName,
key = key.sdkKey,
)
} catch (e: CancellationException) {
withContext(NonCancellable) {
repository.transitionStatus(
ref = ref,
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
targetStatus = InfraredKeyStatus.FAILED,
lastErrorCode = null,
lastErrorDetail = "Cancelled",
updatedAtEpochMillis = clock.now(),
)
}
throw e
}
return when (result) {
is DomainResult.Success -> repository.transitionStatus(
ref = ref,
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
targetStatus = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
updatedAtEpochMillis = clock.now(),
)
is DomainResult.Failure -> {
repository.transitionStatus(
ref = ref,
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
targetStatus = InfraredKeyStatus.FAILED,
lastErrorCode = result.error.externalCode,
lastErrorDetail = null,
updatedAtEpochMillis = clock.now(),
)
result
}
}
}
}
class RelearnInfraredKey(
private val repository: InfraredKeyRepository,
private val gateway: InfraredGateway,
private val clock: EpochMillisClock,
) {
suspend operator fun invoke(ref: InfraredKeyRef): DomainResult<Unit> {
val key = when (val result = repository.getKey(ref)) {
is DomainResult.Success -> result.value
?: return DomainResult.Failure(
DomainError.InvalidRequest(detail = "Infrared key not found"),
)
is DomainResult.Failure -> return result
}
if (key.status == InfraredKeyStatus.LEARNING) {
return DomainResult.Failure(
DomainError.InvalidRequest(detail = "Infrared key is already learning"),
)
}
when (
val result = repository.transitionStatus(
ref = ref,
expectedStatuses = setOf(
InfraredKeyStatus.READY,
InfraredKeyStatus.FAILED,
),
targetStatus = InfraredKeyStatus.LEARNING,
lastErrorCode = null,
@@ -22,6 +22,7 @@ class LearnInfraredKeyTest {
private val gateway = RecordingInfraredGateway()
private val learn = LearnInfraredKey(repository, gateway, factory, clock)
private val retry = RetryInfraredKeyLearning(repository, gateway, clock)
private val relearn = RelearnInfraredKey(repository, gateway, clock)
@Test
fun persistsLearningBeforeCallingHardwareThenMarksReady() = runTest {
@@ -131,7 +132,34 @@ class LearnInfraredKeyTest {
assertEquals(0, repository.insertedKeys.size)
assertEquals(2, repository.transitionCalls.size)
assertEquals(
setOf(InfraredKeyStatus.FAILED, InfraredKeyStatus.READY),
setOf(InfraredKeyStatus.FAILED),
repository.transitionCalls.first().expectedStatuses,
)
assertEquals(
setOf(InfraredKeyStatus.LEARNING),
repository.transitionCalls.last().expectedStatuses,
)
}
@Test
fun relearnReusesStableIdAndSdkKeyForReadyKey() = runTest {
val original = failedKey(id = InfraredKeyId("key1"), sdkKey = "app_ir_key1").copy(
status = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
)
repository.seed(original)
val result = relearn(original.ref())
assertEquals(DomainResult.Success(Unit), result)
assertEquals("app_ir_key1", gateway.learnedKeys.single().key)
assertEquals(InfraredKeyId("key1"), repository.onlyKey().id)
assertEquals(InfraredKeyStatus.READY, repository.onlyKey().status)
assertEquals(0, repository.insertedKeys.size)
assertEquals(2, repository.transitionCalls.size)
assertEquals(
setOf(InfraredKeyStatus.READY, InfraredKeyStatus.FAILED),
repository.transitionCalls.first().expectedStatuses,
)
assertEquals(