feat: orchestrate infrared key learning

This commit is contained in:
2026-08-25 11:40:22 +08:00
committed by KremeCN
parent 4ff6ef6c43
commit 274807c2ff
3 changed files with 576 additions and 0 deletions
@@ -0,0 +1,132 @@
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.InfraredGateway
import com.flagship.abox.manager.domain.ProviderProfileId
class LearnInfraredKey(
private val repository: InfraredKeyRepository,
private val gateway: InfraredGateway,
private val factory: InfraredKeyFactory,
private val clock: EpochMillisClock,
) {
suspend operator fun invoke(
profileId: ProviderProfileId,
deviceName: DeviceName,
displayName: String,
): DomainResult<InfraredKeyId> {
val key = when (val result = factory.create(profileId, deviceName, displayName)) {
is DomainResult.Success -> result.value
is DomainResult.Failure -> return result
}
when (val result = repository.insert(key)) {
is DomainResult.Success -> Unit
is DomainResult.Failure -> return result
}
return when (
val result = gateway.learnKey(
profileId = key.profileId,
deviceName = key.deviceName,
key = key.sdkKey,
)
) {
is DomainResult.Success -> when (
val transition = repository.transitionStatus(
ref = key.ref(),
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
targetStatus = InfraredKeyStatus.READY,
lastErrorCode = null,
updatedAtEpochMillis = clock.now(),
)
) {
is DomainResult.Success -> DomainResult.Success(key.id)
is DomainResult.Failure -> transition
}
is DomainResult.Failure -> {
repository.transitionStatus(
ref = key.ref(),
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
targetStatus = InfraredKeyStatus.FAILED,
lastErrorCode = result.error.externalCode,
updatedAtEpochMillis = clock.now(),
)
result
}
}
}
}
class RetryInfraredKeyLearning(
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.FAILED,
InfraredKeyStatus.READY,
),
targetStatus = InfraredKeyStatus.LEARNING,
lastErrorCode = null,
updatedAtEpochMillis = clock.now(),
)
) {
is DomainResult.Success -> Unit
is DomainResult.Failure -> return result
}
return when (
val result = gateway.learnKey(
profileId = key.profileId,
deviceName = key.deviceName,
key = key.sdkKey,
)
) {
is DomainResult.Success -> repository.transitionStatus(
ref = ref,
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
targetStatus = InfraredKeyStatus.READY,
lastErrorCode = null,
updatedAtEpochMillis = clock.now(),
)
is DomainResult.Failure -> {
repository.transitionStatus(
ref = ref,
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
targetStatus = InfraredKeyStatus.FAILED,
lastErrorCode = result.error.externalCode,
updatedAtEpochMillis = clock.now(),
)
result
}
}
}
}
private fun SavedInfraredKey.ref() = InfraredKeyRef(
profileId = profileId,
keyId = id,
)
@@ -0,0 +1,189 @@
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.InfraredCode
import com.flagship.abox.manager.domain.InfraredCodeDownloadResult
import com.flagship.abox.manager.domain.InfraredGateway
import com.flagship.abox.manager.domain.ProviderProfileId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
data class InfraredLearningCall(
val profileId: ProviderProfileId,
val deviceName: DeviceName,
val key: String,
)
data class InfraredStatusTransitionCall(
val ref: InfraredKeyRef,
val expectedStatuses: Set<InfraredKeyStatus>,
val targetStatus: InfraredKeyStatus,
val lastErrorCode: String?,
val updatedAtEpochMillis: Long,
)
class InMemoryInfraredKeyRepository : InfraredKeyRepository {
private val keys = linkedMapOf<InfraredKeyRef, SavedInfraredKey>()
private val state = MutableStateFlow<List<SavedInfraredKey>>(emptyList())
var getError: DomainError? = null
var insertError: DomainError? = null
val transitionErrors = mutableMapOf<InfraredKeyStatus, DomainError>()
var onInsert: ((SavedInfraredKey) -> Unit)? = null
var onTransition: ((InfraredStatusTransitionCall) -> Unit)? = null
val insertedKeys = mutableListOf<SavedInfraredKey>()
val transitionCalls = mutableListOf<InfraredStatusTransitionCall>()
override fun observeKeys(
profileId: ProviderProfileId,
deviceName: DeviceName,
): Flow<DomainResult<List<SavedInfraredKey>>> = state.map { values ->
DomainResult.Success(
values.filter { it.profileId == profileId && it.deviceName == deviceName },
)
}
override suspend fun getKey(ref: InfraredKeyRef): DomainResult<SavedInfraredKey?> {
getError?.let { return DomainResult.Failure(it) }
return DomainResult.Success(keys[ref])
}
override suspend fun insert(key: SavedInfraredKey): DomainResult<Unit> {
insertedKeys += key
onInsert?.invoke(key)
insertError?.let { return DomainResult.Failure(it) }
keys[key.ref()] = key
publish()
return DomainResult.Success(Unit)
}
override suspend fun transitionStatus(
ref: InfraredKeyRef,
expectedStatuses: Set<InfraredKeyStatus>,
targetStatus: InfraredKeyStatus,
lastErrorCode: String?,
updatedAtEpochMillis: Long,
): DomainResult<Unit> {
val call = InfraredStatusTransitionCall(
ref = ref,
expectedStatuses = expectedStatuses,
targetStatus = targetStatus,
lastErrorCode = lastErrorCode,
updatedAtEpochMillis = updatedAtEpochMillis,
)
transitionCalls += call
onTransition?.invoke(call)
transitionErrors[targetStatus]?.let { return DomainResult.Failure(it) }
val existing = keys[ref]
?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Infrared key not found"))
if (existing.status !in expectedStatuses) {
return DomainResult.Failure(
DomainError.InvalidRequest(detail = "Infrared key status changed"),
)
}
keys[ref] = existing.copy(
status = targetStatus,
lastErrorCode = lastErrorCode,
updatedAtEpochMillis = updatedAtEpochMillis,
)
publish()
return DomainResult.Success(Unit)
}
override suspend fun rename(
ref: InfraredKeyRef,
displayName: String,
updatedAtEpochMillis: Long,
): DomainResult<Unit> {
val existing = keys[ref]
?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Infrared key not found"))
keys[ref] = existing.copy(
displayName = displayName,
updatedAtEpochMillis = updatedAtEpochMillis,
)
publish()
return DomainResult.Success(Unit)
}
override suspend fun deleteIfNotLearning(ref: InfraredKeyRef): DomainResult<Unit> {
val existing = keys[ref]
?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Infrared key not found"))
if (existing.status == InfraredKeyStatus.LEARNING) {
return DomainResult.Failure(
DomainError.InvalidRequest(detail = "Cannot delete a learning infrared key"),
)
}
keys.remove(ref)
publish()
return DomainResult.Success(Unit)
}
override suspend fun failInterruptedLearning(
profileId: ProviderProfileId,
updatedAtEpochMillis: Long,
): DomainResult<Int> {
var changed = 0
keys.replaceAll { _, key ->
if (key.profileId == profileId && key.status == InfraredKeyStatus.LEARNING) {
changed += 1
key.copy(
status = InfraredKeyStatus.FAILED,
lastErrorCode = null,
updatedAtEpochMillis = updatedAtEpochMillis,
)
} else {
key
}
}
publish()
return DomainResult.Success(changed)
}
fun seed(key: SavedInfraredKey) {
keys[key.ref()] = key
publish()
}
fun onlyKey(): SavedInfraredKey = keys.values.single()
private fun publish() {
state.value = keys.values.toList()
}
}
class RecordingInfraredGateway : InfraredGateway {
var learnResult: DomainResult<Unit> = DomainResult.Success(Unit)
var onLearn: ((ProviderProfileId, DeviceName, String) -> DomainResult<Unit>)? = null
val learnedKeys = mutableListOf<InfraredLearningCall>()
override suspend fun learnKey(
profileId: ProviderProfileId,
deviceName: DeviceName,
key: String,
): DomainResult<Unit> {
learnedKeys += InfraredLearningCall(profileId, deviceName, key)
return onLearn?.invoke(profileId, deviceName, key) ?: learnResult
}
override suspend fun sendKey(
profileId: ProviderProfileId,
deviceName: DeviceName,
key: String,
): DomainResult<Unit> = DomainResult.Success(Unit)
override suspend fun downloadCodes(
profileId: ProviderProfileId,
codes: List<InfraredCode>,
): DomainResult<List<InfraredCodeDownloadResult>> = DomainResult.Success(emptyList())
}
fun SavedInfraredKey.ref() = InfraredKeyRef(
profileId = profileId,
keyId = id,
)
@@ -0,0 +1,255 @@
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 LearnInfraredKeyTest {
private val profile = ProviderProfileId("abox")
private val device = DeviceName("ir-1")
private val keyId = InfraredKeyId("key1")
private val clock = SteppingClock(100L)
private val factory = InfraredKeyFactory(
idGenerator = InfraredKeyIdGenerator { keyId },
clock = clock,
)
private val repository = InMemoryInfraredKeyRepository()
private val gateway = RecordingInfraredGateway()
private val learn = LearnInfraredKey(repository, gateway, factory, clock)
private val retry = RetryInfraredKeyLearning(repository, gateway, clock)
@Test
fun persistsLearningBeforeCallingHardwareThenMarksReady() = runTest {
val events = mutableListOf<String>()
repository.onInsert = { events += "insert:${it.status}" }
gateway.onLearn = { _, _, _ ->
events += "hardware"
DomainResult.Success(Unit)
}
repository.onTransition = { events += "transition:${it.targetStatus}" }
val result = learn(profile, device, "Power")
assertEquals(DomainResult.Success(keyId), result)
assertEquals(listOf("insert:LEARNING", "hardware", "transition:READY"), events)
assertEquals(InfraredKeyStatus.READY, repository.onlyKey().status)
assertEquals(null, repository.onlyKey().lastErrorCode)
}
@Test
fun hardwareFailureMarksFailedAndReturnsOriginalError() = runTest {
val gatewayError = DomainError.DeviceOffline("20206")
gateway.learnResult = DomainResult.Failure(gatewayError)
val result = learn(profile, device, "Power")
assertEquals(gatewayError, (result as DomainResult.Failure).error)
assertEquals(InfraredKeyStatus.FAILED, repository.onlyKey().status)
assertEquals("20206", repository.onlyKey().lastErrorCode)
}
@Test
fun insertFailureNeverCallsHardware() = runTest {
val insertError = DomainError.Unknown(detail = "database unavailable")
repository.insertError = insertError
val result = learn(profile, device, "Power")
assertEquals(insertError, (result as DomainResult.Failure).error)
assertTrue(gateway.learnedKeys.isEmpty())
assertTrue(repository.transitionCalls.isEmpty())
}
@Test
fun factoryFailureNeverTouchesRepositoryOrHardware() = runTest {
val result = learn(profile, device, " ")
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
assertTrue(repository.insertedKeys.isEmpty())
assertTrue(repository.transitionCalls.isEmpty())
assertTrue(gateway.learnedKeys.isEmpty())
}
@Test
fun readyTransitionFailureIsReturnedAfterHardwareSuccess() = runTest {
val transitionError = DomainError.Unknown(detail = "write conflict")
repository.transitionErrors[InfraredKeyStatus.READY] = transitionError
val result = learn(profile, device, "Power")
assertEquals(transitionError, (result as DomainResult.Failure).error)
assertEquals(InfraredKeyStatus.LEARNING, repository.onlyKey().status)
assertEquals(1, gateway.learnedKeys.size)
}
@Test
fun failedTransitionErrorDoesNotReplaceGatewayFailure() = runTest {
val gatewayError = DomainError.DeviceOffline("20206")
gateway.learnResult = DomainResult.Failure(gatewayError)
repository.transitionErrors[InfraredKeyStatus.FAILED] =
DomainError.Unknown(detail = "write conflict")
val result = learn(profile, device, "Power")
assertEquals(gatewayError, (result as DomainResult.Failure).error)
assertEquals(InfraredKeyStatus.LEARNING, repository.onlyKey().status)
assertEquals("20206", repository.transitionCalls.single().lastErrorCode)
}
@Test
fun newLearningUsesExpectedLearningTransitionsAndFreshTimestamps() = runTest {
learn(profile, device, "Power")
val inserted = repository.insertedKeys.single()
val transition = repository.transitionCalls.single()
assertEquals(setOf(InfraredKeyStatus.LEARNING), transition.expectedStatuses)
assertEquals(inserted.ref(), transition.ref)
assertEquals(100L, inserted.createdAtEpochMillis)
assertEquals(100L, inserted.updatedAtEpochMillis)
assertEquals(101L, transition.updatedAtEpochMillis)
}
@Test
fun retryReusesStableIdAndSdkKey() = runTest {
val original = failedKey(id = InfraredKeyId("key1"), sdkKey = "app_ir_key1")
repository.seed(original)
val result = retry(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.FAILED, InfraredKeyStatus.READY),
repository.transitionCalls.first().expectedStatuses,
)
assertEquals(
setOf(InfraredKeyStatus.LEARNING),
repository.transitionCalls.last().expectedStatuses,
)
}
@Test
fun retryRejectsAlreadyLearningKey() = runTest {
repository.seed(learningKey())
val result = retry(repository.onlyKey().ref())
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
assertTrue(gateway.learnedKeys.isEmpty())
assertTrue(repository.transitionCalls.isEmpty())
}
@Test
fun retryRejectsMissingKey() = runTest {
val result = retry(InfraredKeyRef(profile, InfraredKeyId("missing")))
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
assertTrue(gateway.learnedKeys.isEmpty())
assertTrue(repository.transitionCalls.isEmpty())
}
@Test
fun retryReturnsGetFailureWithoutCallingHardware() = runTest {
val getError = DomainError.Unknown(detail = "database unavailable")
repository.getError = getError
val result = retry(InfraredKeyRef(profile, keyId))
assertEquals(getError, (result as DomainResult.Failure).error)
assertTrue(gateway.learnedKeys.isEmpty())
assertTrue(repository.transitionCalls.isEmpty())
}
@Test
fun retryDoesNotCallHardwareWhenLearningTransitionFails() = runTest {
val original = failedKey()
repository.seed(original)
val transitionError = DomainError.Unknown(detail = "write conflict")
repository.transitionErrors[InfraredKeyStatus.LEARNING] = transitionError
val result = retry(original.ref())
assertEquals(transitionError, (result as DomainResult.Failure).error)
assertTrue(gateway.learnedKeys.isEmpty())
assertEquals(InfraredKeyStatus.FAILED, repository.onlyKey().status)
}
@Test
fun retryGatewayFailureMarksFailedWithExternalCodeAndReturnsOriginalError() = runTest {
val original = failedKey()
repository.seed(original)
val gatewayError = DomainError.DeviceOffline("20206")
gateway.learnResult = DomainResult.Failure(gatewayError)
val result = retry(original.ref())
assertEquals(gatewayError, (result as DomainResult.Failure).error)
assertEquals(InfraredKeyStatus.FAILED, repository.onlyKey().status)
assertEquals("20206", repository.onlyKey().lastErrorCode)
}
@Test
fun retryFailedTransitionErrorDoesNotReplaceGatewayFailure() = runTest {
val original = failedKey()
repository.seed(original)
val gatewayError = DomainError.DeviceOffline("20206")
gateway.learnResult = DomainResult.Failure(gatewayError)
repository.transitionErrors[InfraredKeyStatus.FAILED] =
DomainError.Unknown(detail = "write conflict")
val result = retry(original.ref())
assertEquals(gatewayError, (result as DomainResult.Failure).error)
assertEquals(InfraredKeyStatus.LEARNING, repository.onlyKey().status)
assertEquals("20206", repository.transitionCalls.last().lastErrorCode)
}
@Test
fun retryReadyTransitionFailureIsReturnedAfterHardwareSuccess() = runTest {
val original = failedKey()
repository.seed(original)
val transitionError = DomainError.Unknown(detail = "write conflict")
repository.transitionErrors[InfraredKeyStatus.READY] = transitionError
val result = retry(original.ref())
assertEquals(transitionError, (result as DomainResult.Failure).error)
assertEquals(InfraredKeyStatus.LEARNING, repository.onlyKey().status)
assertEquals(1, gateway.learnedKeys.size)
}
private fun failedKey(
id: InfraredKeyId = keyId,
sdkKey: String = "app_ir_key1",
) = SavedInfraredKey(
id = id,
profileId = profile,
deviceName = device,
displayName = "Power",
sdkKey = sdkKey,
status = InfraredKeyStatus.FAILED,
lastErrorCode = "old-error",
createdAtEpochMillis = 1L,
updatedAtEpochMillis = 2L,
)
private fun learningKey() = failedKey().copy(
status = InfraredKeyStatus.LEARNING,
lastErrorCode = null,
)
}
private class SteppingClock(start: Long) : EpochMillisClock {
private var next = start
override fun now(): Long = next++
}