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 366 additions and 1 deletions
Showing only changes of commit ee60b75a32 - Show all commits
@@ -0,0 +1,65 @@
package com.flagship.abox.manager.domain.infrared
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.InfraredGateway
import com.flagship.abox.manager.domain.ProviderProfileId
class SendInfraredKey(
private val repository: InfraredKeyRepository,
private val gateway: InfraredGateway,
) {
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.NoData(detail = "Infrared key not found"),
)
is DomainResult.Failure -> return result
}
if (key.status != InfraredKeyStatus.READY) {
return DomainResult.Failure(
DomainError.InvalidRequest(detail = "Infrared key is not ready"),
)
}
return gateway.sendKey(
profileId = key.profileId,
deviceName = key.deviceName,
key = key.sdkKey,
)
}
}
class RenameInfraredKey(
private val repository: InfraredKeyRepository,
private val clock: EpochMillisClock,
) {
suspend operator fun invoke(
ref: InfraredKeyRef,
displayName: String,
): DomainResult<Unit> {
val trimmed = displayName.trim()
if (trimmed.isEmpty()) {
return DomainResult.Failure(
DomainError.InvalidRequest(detail = "Infrared key display name is required"),
)
}
return repository.rename(ref, trimmed, clock.now())
}
}
class DeleteInfraredKey(
private val repository: InfraredKeyRepository,
) {
suspend operator fun invoke(ref: InfraredKeyRef): DomainResult<Unit> =
repository.deleteIfNotLearning(ref)
}
class RecoverInterruptedInfraredLearning(
private val repository: InfraredKeyRepository,
private val clock: EpochMillisClock,
) {
suspend operator fun invoke(profileId: ProviderProfileId): DomainResult<Int> =
repository.failInterruptedLearning(profileId, clock.now())
}
@@ -25,19 +25,43 @@ data class InfraredStatusTransitionCall(
val updatedAtEpochMillis: Long, val updatedAtEpochMillis: Long,
) )
data class InfraredSendCall(
val profileId: ProviderProfileId,
val deviceName: DeviceName,
val key: String,
)
data class InfraredRenameCall(
val ref: InfraredKeyRef,
val displayName: String,
val updatedAtEpochMillis: Long,
)
data class InfraredRecoveryCall(
val profileId: ProviderProfileId,
val updatedAtEpochMillis: Long,
)
class InMemoryInfraredKeyRepository : InfraredKeyRepository { class InMemoryInfraredKeyRepository : InfraredKeyRepository {
private val keys = linkedMapOf<InfraredKeyRef, SavedInfraredKey>() private val keys = linkedMapOf<InfraredKeyRef, SavedInfraredKey>()
private val state = MutableStateFlow<List<SavedInfraredKey>>(emptyList()) private val state = MutableStateFlow<List<SavedInfraredKey>>(emptyList())
var getError: DomainError? = null var getError: DomainError? = null
var insertError: DomainError? = null var insertError: DomainError? = null
var renameError: DomainError? = null
var deleteError: DomainError? = null
var recoveryError: DomainError? = null
val transitionErrors = mutableMapOf<InfraredKeyStatus, DomainError>() val transitionErrors = mutableMapOf<InfraredKeyStatus, DomainError>()
var onInsert: ((SavedInfraredKey) -> Unit)? = null var onInsert: ((SavedInfraredKey) -> Unit)? = null
var onTransition: ((InfraredStatusTransitionCall) -> Unit)? = null var onTransition: ((InfraredStatusTransitionCall) -> Unit)? = null
val insertedKeys = mutableListOf<SavedInfraredKey>() val insertedKeys = mutableListOf<SavedInfraredKey>()
val getCalls = mutableListOf<InfraredKeyRef>()
val transitionCalls = mutableListOf<InfraredStatusTransitionCall>() val transitionCalls = mutableListOf<InfraredStatusTransitionCall>()
val renameCalls = mutableListOf<InfraredRenameCall>()
val deleteCalls = mutableListOf<InfraredKeyRef>()
val recoveryCalls = mutableListOf<InfraredRecoveryCall>()
override fun observeKeys( override fun observeKeys(
profileId: ProviderProfileId, profileId: ProviderProfileId,
@@ -49,6 +73,7 @@ class InMemoryInfraredKeyRepository : InfraredKeyRepository {
} }
override suspend fun getKey(ref: InfraredKeyRef): DomainResult<SavedInfraredKey?> { override suspend fun getKey(ref: InfraredKeyRef): DomainResult<SavedInfraredKey?> {
getCalls += ref
getError?.let { return DomainResult.Failure(it) } getError?.let { return DomainResult.Failure(it) }
return DomainResult.Success(keys[ref]) return DomainResult.Success(keys[ref])
} }
@@ -101,6 +126,8 @@ class InMemoryInfraredKeyRepository : InfraredKeyRepository {
displayName: String, displayName: String,
updatedAtEpochMillis: Long, updatedAtEpochMillis: Long,
): DomainResult<Unit> { ): DomainResult<Unit> {
renameCalls += InfraredRenameCall(ref, displayName, updatedAtEpochMillis)
renameError?.let { return DomainResult.Failure(it) }
val existing = keys[ref] val existing = keys[ref]
?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Infrared key not found")) ?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Infrared key not found"))
keys[ref] = existing.copy( keys[ref] = existing.copy(
@@ -112,6 +139,8 @@ class InMemoryInfraredKeyRepository : InfraredKeyRepository {
} }
override suspend fun deleteIfNotLearning(ref: InfraredKeyRef): DomainResult<Unit> { override suspend fun deleteIfNotLearning(ref: InfraredKeyRef): DomainResult<Unit> {
deleteCalls += ref
deleteError?.let { return DomainResult.Failure(it) }
val existing = keys[ref] val existing = keys[ref]
?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Infrared key not found")) ?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Infrared key not found"))
if (existing.status == InfraredKeyStatus.LEARNING) { if (existing.status == InfraredKeyStatus.LEARNING) {
@@ -128,6 +157,8 @@ class InMemoryInfraredKeyRepository : InfraredKeyRepository {
profileId: ProviderProfileId, profileId: ProviderProfileId,
updatedAtEpochMillis: Long, updatedAtEpochMillis: Long,
): DomainResult<Int> { ): DomainResult<Int> {
recoveryCalls += InfraredRecoveryCall(profileId, updatedAtEpochMillis)
recoveryError?.let { return DomainResult.Failure(it) }
var changed = 0 var changed = 0
keys.replaceAll { _, key -> keys.replaceAll { _, key ->
if (key.profileId == profileId && key.status == InfraredKeyStatus.LEARNING) { if (key.profileId == profileId && key.status == InfraredKeyStatus.LEARNING) {
@@ -152,6 +183,8 @@ class InMemoryInfraredKeyRepository : InfraredKeyRepository {
fun onlyKey(): SavedInfraredKey = keys.values.single() fun onlyKey(): SavedInfraredKey = keys.values.single()
fun allKeys(): List<SavedInfraredKey> = keys.values.toList()
private fun publish() { private fun publish() {
state.value = keys.values.toList() state.value = keys.values.toList()
} }
@@ -159,8 +192,10 @@ class InMemoryInfraredKeyRepository : InfraredKeyRepository {
class RecordingInfraredGateway : InfraredGateway { class RecordingInfraredGateway : InfraredGateway {
var learnResult: DomainResult<Unit> = DomainResult.Success(Unit) var learnResult: DomainResult<Unit> = DomainResult.Success(Unit)
var sendResult: DomainResult<Unit> = DomainResult.Success(Unit)
var onLearn: ((ProviderProfileId, DeviceName, String) -> DomainResult<Unit>)? = null var onLearn: ((ProviderProfileId, DeviceName, String) -> DomainResult<Unit>)? = null
val learnedKeys = mutableListOf<InfraredLearningCall>() val learnedKeys = mutableListOf<InfraredLearningCall>()
val sentKeys = mutableListOf<InfraredSendCall>()
override suspend fun learnKey( override suspend fun learnKey(
profileId: ProviderProfileId, profileId: ProviderProfileId,
@@ -175,7 +210,10 @@ class RecordingInfraredGateway : InfraredGateway {
profileId: ProviderProfileId, profileId: ProviderProfileId,
deviceName: DeviceName, deviceName: DeviceName,
key: String, key: String,
): DomainResult<Unit> = DomainResult.Success(Unit) ): DomainResult<Unit> {
sentKeys += InfraredSendCall(profileId, deviceName, key)
return sendResult
}
override suspend fun downloadCodes( override suspend fun downloadCodes(
profileId: ProviderProfileId, profileId: ProviderProfileId,
@@ -183,6 +183,36 @@ class LearnInfraredKeyTest {
assertEquals(InfraredKeyStatus.FAILED, repository.onlyKey().status) assertEquals(InfraredKeyStatus.FAILED, repository.onlyKey().status)
} }
@Test
fun retryExpectedStatusFailurePreventsHardwareAfterConcurrentStatusChange() = runTest {
val original = failedKey()
repository.seed(original)
repository.onTransition = { call ->
if (call.targetStatus == InfraredKeyStatus.LEARNING) {
repository.seed(original.copy(status = InfraredKeyStatus.LEARNING))
}
}
val result = retry(original.ref())
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
assertTrue(gateway.learnedKeys.isEmpty())
assertEquals(InfraredKeyStatus.LEARNING, repository.onlyKey().status)
}
@Test
fun retryClearsPriorErrorWhileGatewayCallbackIsRunning() = runTest {
val original = failedKey()
repository.seed(original)
gateway.onLearn = { _, _, _ ->
assertEquals(InfraredKeyStatus.LEARNING, repository.onlyKey().status)
assertEquals(null, repository.onlyKey().lastErrorCode)
DomainResult.Success(Unit)
}
assertEquals(DomainResult.Success(Unit), retry(original.ref()))
}
@Test @Test
fun retryGatewayFailureMarksFailedWithExternalCodeAndReturnsOriginalError() = runTest { fun retryGatewayFailureMarksFailedWithExternalCodeAndReturnsOriginalError() = runTest {
val original = failedKey() val original = failedKey()
@@ -0,0 +1,232 @@
package com.flagship.abox.manager.domain.infrared
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 kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ManageInfraredKeyTest {
private val profile = ProviderProfileId("abox")
private val otherProfile = ProviderProfileId("other")
private val repository = InMemoryInfraredKeyRepository()
private val gateway = RecordingInfraredGateway()
private val clock = SteppingManagementClock(500L)
private val send = SendInfraredKey(repository, gateway)
private val rename = RenameInfraredKey(repository, clock)
private val delete = DeleteInfraredKey(repository)
private val recover = RecoverInterruptedInfraredLearning(repository, clock)
@Test
fun sendsReadyKeyUsingStoredDeviceAndSdkKeyWithoutRepositoryWrites() = runTest {
val key = readyKey(deviceName = DeviceName("ir-living"), sdkKey = "app_ir_power")
repository.seed(key)
assertEquals(DomainResult.Success(Unit), send(key.ref()))
assertEquals(
InfraredSendCall(profile, DeviceName("ir-living"), "app_ir_power"),
gateway.sentKeys.single(),
)
assertEquals(listOf(key.ref()), repository.getCalls)
assertTrue(repository.transitionCalls.isEmpty())
assertTrue(repository.renameCalls.isEmpty())
assertTrue(repository.deleteCalls.isEmpty())
assertTrue(repository.recoveryCalls.isEmpty())
assertEquals(key, repository.onlyKey())
}
@Test
fun sendFailureDoesNotDemoteReadyKey() = runTest {
val key = readyKey()
repository.seed(key)
val gatewayError = DomainError.OperationFailed("20205")
gateway.sendResult = DomainResult.Failure(gatewayError)
assertEquals(gatewayError, (send(key.ref()) as DomainResult.Failure).error)
assertEquals(InfraredKeyStatus.READY, repository.onlyKey().status)
assertTrue(repository.transitionCalls.isEmpty())
}
@Test
fun missingSendReturnsNoDataWithoutHardwareCall() = runTest {
val result = send(InfraredKeyRef(profile, InfraredKeyId("missing")))
assertTrue((result as DomainResult.Failure).error is DomainError.NoData)
assertEquals(
listOf(InfraredKeyRef(profile, InfraredKeyId("missing"))),
repository.getCalls,
)
assertTrue(gateway.sentKeys.isEmpty())
}
@Test
fun nonReadySendReturnsInvalidRequestWithoutHardwareCall() = runTest {
repository.seed(failedKey())
val result = send(repository.onlyKey().ref())
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
assertTrue(gateway.sentKeys.isEmpty())
}
@Test
fun sendReturnsRepositoryFailureWithoutHardwareCall() = runTest {
val repositoryError = DomainError.Unknown(detail = "database unavailable")
repository.getError = repositoryError
val result = send(InfraredKeyRef(profile, InfraredKeyId("key1")))
assertEquals(repositoryError, (result as DomainResult.Failure).error)
assertTrue(gateway.sentKeys.isEmpty())
}
@Test
fun renameTrimsDisplayNameAndPreservesStableIdentity() = runTest {
val before = readyKey(displayName = "Power", sdkKey = "app_ir_power")
repository.seed(before)
assertEquals(DomainResult.Success(Unit), rename(before.ref(), " 空调开关 "))
val after = repository.onlyKey()
assertEquals("空调开关", after.displayName)
assertEquals(before.sdkKey, after.sdkKey)
assertEquals(before.id, after.id)
assertEquals(before.createdAtEpochMillis, after.createdAtEpochMillis)
assertEquals(500L, after.updatedAtEpochMillis)
assertEquals(
InfraredRenameCall(before.ref(), "空调开关", 500L),
repository.renameCalls.single(),
)
}
@Test
fun renameRejectsBlankDisplayNameWithoutRepositoryCall() = runTest {
val key = readyKey()
repository.seed(key)
val result = rename(key.ref(), " \t ")
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
assertTrue(repository.renameCalls.isEmpty())
assertEquals(key, repository.onlyKey())
}
@Test
fun renameReturnsRepositoryFailure() = runTest {
val key = readyKey()
repository.seed(key)
val repositoryError = DomainError.Unknown(detail = "write failed")
repository.renameError = repositoryError
val result = rename(key.ref(), "New name")
assertEquals(repositoryError, (result as DomainResult.Failure).error)
assertEquals("Power", repository.onlyKey().displayName)
}
@Test
fun deleteDelegatesAtomicLearningGuardToRepository() = runTest {
val key = learningKey()
repository.seed(key)
val result = delete(key.ref())
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
assertEquals(listOf(key.ref()), repository.deleteCalls)
assertTrue(repository.getCalls.isEmpty())
assertTrue(repository.allKeys().isNotEmpty())
}
@Test
fun deleteRemovesReadyKey() = runTest {
val key = readyKey()
repository.seed(key)
assertEquals(DomainResult.Success(Unit), delete(key.ref()))
assertEquals(listOf(key.ref()), repository.deleteCalls)
assertTrue(repository.getCalls.isEmpty())
assertTrue(repository.allKeys().isEmpty())
}
@Test
fun recoveryDelegatesOnceAndMarksOnlyProfileLearningKeysFailed() = runTest {
val learningOne = learningKey(id = InfraredKeyId("one"), lastErrorCode = "old")
val learningTwo = learningKey(id = InfraredKeyId("two"))
val ready = readyKey(id = InfraredKeyId("ready"))
val other = learningKey(id = InfraredKeyId("other"), profileId = otherProfile)
repository.seed(learningOne)
repository.seed(learningTwo)
repository.seed(ready)
repository.seed(other)
assertEquals(DomainResult.Success(2), recover(profile))
assertEquals(
listOf(InfraredRecoveryCall(profile, 500L)),
repository.recoveryCalls,
)
assertTrue(repository.getCalls.isEmpty())
assertTrue(repository.transitionCalls.isEmpty())
val recovered = repository.allKeys().filter { it.profileId == profile && it.id.value in setOf("one", "two") }
assertTrue(recovered.all { it.status == InfraredKeyStatus.FAILED })
assertTrue(recovered.all { it.lastErrorCode == null })
assertTrue(recovered.all { it.updatedAtEpochMillis == 500L })
assertEquals(InfraredKeyStatus.READY, repository.allKeys().single { it.id.value == "ready" }.status)
assertEquals(InfraredKeyStatus.LEARNING, repository.allKeys().single { it.profileId == otherProfile }.status)
assertTrue(gateway.learnedKeys.isEmpty())
assertTrue(gateway.sentKeys.isEmpty())
}
@Test
fun recoveryReturnsRepositoryFailureWithoutHardwareCall() = runTest {
val repositoryError = DomainError.Unknown(detail = "write failed")
repository.recoveryError = repositoryError
val result = recover(profile)
assertEquals(repositoryError, (result as DomainResult.Failure).error)
assertEquals(listOf(InfraredRecoveryCall(profile, 500L)), repository.recoveryCalls)
assertTrue(gateway.learnedKeys.isEmpty())
assertTrue(gateway.sentKeys.isEmpty())
}
private fun readyKey(
id: InfraredKeyId = InfraredKeyId("key1"),
profileId: ProviderProfileId = profile,
deviceName: DeviceName = DeviceName("ir-1"),
displayName: String = "Power",
sdkKey: String = "app_ir_key1",
) = SavedInfraredKey(
id = id,
profileId = profileId,
deviceName = deviceName,
displayName = displayName,
sdkKey = sdkKey,
status = InfraredKeyStatus.READY,
lastErrorCode = null,
createdAtEpochMillis = 1L,
updatedAtEpochMillis = 2L,
)
private fun failedKey() = readyKey().copy(
status = InfraredKeyStatus.FAILED,
lastErrorCode = "old-error",
)
private fun learningKey(
id: InfraredKeyId = InfraredKeyId("key1"),
profileId: ProviderProfileId = profile,
lastErrorCode: String? = null,
) = readyKey(id = id, profileId = profileId).copy(
status = InfraredKeyStatus.LEARNING,
lastErrorCode = lastErrorCode,
)
}
private class SteppingManagementClock(start: Long) : EpochMillisClock {
private var next = start
override fun now(): Long = next++
}