feat(infrared): add infrared control, learning, and virtual remote module #9
+214
@@ -0,0 +1,214 @@
|
|||||||
|
package com.flagship.abox.manager.infrared.remote
|
||||||
|
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
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 com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository
|
||||||
|
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
|
||||||
|
import com.flagship.abox.manager.domain.infrared.InfraredKeyRepository
|
||||||
|
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
|
||||||
|
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
|
||||||
|
import com.flagship.abox.manager.domain.infrared.SendInfraredKey
|
||||||
|
import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator
|
||||||
|
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode
|
||||||
|
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.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
sealed interface RemoteUiMessage {
|
||||||
|
data class Info(val text: String) : RemoteUiMessage
|
||||||
|
data class Error(val text: String) : RemoteUiMessage
|
||||||
|
data object SessionInvalid : RemoteUiMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
data class InfraredRemoteUiState(
|
||||||
|
val selectedScene: RemoteScene = RemoteScene.AIR_CONDITIONER,
|
||||||
|
val isLearningMode: Boolean = false,
|
||||||
|
val runtimeMode: InfraredRuntimeMode = InfraredRuntimeMode.FAKE,
|
||||||
|
val effectiveProfileId: ProviderProfileId? = null,
|
||||||
|
val savedDeviceName: DeviceName? = null,
|
||||||
|
val learnedSlotMap: Map<String, SavedInfraredKey> = emptyMap(),
|
||||||
|
val sendingKeySlot: String? = null,
|
||||||
|
val activeLearningSlot: String? = null,
|
||||||
|
val message: RemoteUiMessage? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
@HiltViewModel
|
||||||
|
class InfraredRemoteViewModel @Inject constructor(
|
||||||
|
savedStateHandle: SavedStateHandle,
|
||||||
|
private val keyRepository: InfraredKeyRepository,
|
||||||
|
private val deviceConfigurationRepository: InfraredDeviceConfigurationRepository,
|
||||||
|
private val runtimeModeStore: InfraredRuntimeModeStore,
|
||||||
|
private val profileResolver: InfraredRuntimeProfileResolver,
|
||||||
|
private val learningCoordinator: InfraredLearningCoordinator,
|
||||||
|
private val sendInfraredKey: SendInfraredKey,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val rawProfileId: String? = savedStateHandle["profileId"]
|
||||||
|
private val authenticatedProfileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) }
|
||||||
|
|
||||||
|
private val sceneFlow = MutableStateFlow(RemoteScene.AIR_CONDITIONER)
|
||||||
|
private val isLearningModeFlow = MutableStateFlow(false)
|
||||||
|
private val sendingKeySlotFlow = MutableStateFlow<String?>(null)
|
||||||
|
private val activeLearningSlotFlow = MutableStateFlow<String?>(null)
|
||||||
|
private val messageFlow = MutableStateFlow<RemoteUiMessage?>(null)
|
||||||
|
|
||||||
|
private val effectiveProfileAndMode: Flow<Pair<ProviderProfileId?, InfraredRuntimeMode>> =
|
||||||
|
runtimeModeStore.mode.flatMapLatest { mode ->
|
||||||
|
if (authenticatedProfileId == null) {
|
||||||
|
flowOf(Pair(null, mode))
|
||||||
|
} else {
|
||||||
|
flowOf(Pair(profileResolver.resolve(authenticatedProfileId, mode), mode))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val deviceNameFlow: Flow<DomainResult<DeviceName?>> =
|
||||||
|
effectiveProfileAndMode.flatMapLatest { (profile, _) ->
|
||||||
|
if (profile == null) flowOf(DomainResult.Success(null))
|
||||||
|
else deviceConfigurationRepository.observeDeviceName(profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val keysFlow: Flow<DomainResult<List<SavedInfraredKey>>> =
|
||||||
|
effectiveProfileAndMode.flatMapLatest { (profile, _) ->
|
||||||
|
if (profile == null) flowOf(DomainResult.Success(emptyList()))
|
||||||
|
else {
|
||||||
|
deviceConfigurationRepository.observeDeviceName(profile).flatMapLatest { deviceResult ->
|
||||||
|
when (deviceResult) {
|
||||||
|
is DomainResult.Success -> {
|
||||||
|
val device = deviceResult.value
|
||||||
|
if (device == null) flowOf(DomainResult.Success(emptyList()))
|
||||||
|
else keyRepository.observeKeys(profile, device)
|
||||||
|
}
|
||||||
|
is DomainResult.Failure -> flowOf(DomainResult.Failure(deviceResult.error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val state: StateFlow<InfraredRemoteUiState> = combine(
|
||||||
|
sceneFlow,
|
||||||
|
isLearningModeFlow,
|
||||||
|
effectiveProfileAndMode,
|
||||||
|
deviceNameFlow,
|
||||||
|
keysFlow,
|
||||||
|
sendingKeySlotFlow,
|
||||||
|
activeLearningSlotFlow,
|
||||||
|
messageFlow,
|
||||||
|
) { args: Array<Any?> ->
|
||||||
|
val scene = args[0] as RemoteScene
|
||||||
|
val isLearning = args[1] as Boolean
|
||||||
|
val profileAndMode = args[2] as Pair<ProviderProfileId?, InfraredRuntimeMode>
|
||||||
|
val deviceResult = args[3] as DomainResult<DeviceName?>
|
||||||
|
val keysResult = args[4] as DomainResult<List<SavedInfraredKey>>
|
||||||
|
val sendingSlot = args[5] as String?
|
||||||
|
val learningSlot = args[6] as String?
|
||||||
|
val msg = args[7] as RemoteUiMessage?
|
||||||
|
|
||||||
|
val (profile, mode) = profileAndMode
|
||||||
|
val savedDevice = (deviceResult as? DomainResult.Success<DeviceName?>)?.value
|
||||||
|
val keys = (keysResult as? DomainResult.Success<List<SavedInfraredKey>>)?.value ?: emptyList()
|
||||||
|
val slotMap = keys.associateBy { it.sdkKey }
|
||||||
|
|
||||||
|
InfraredRemoteUiState(
|
||||||
|
selectedScene = scene,
|
||||||
|
isLearningMode = isLearning,
|
||||||
|
runtimeMode = mode,
|
||||||
|
effectiveProfileId = profile,
|
||||||
|
savedDeviceName = savedDevice,
|
||||||
|
learnedSlotMap = slotMap,
|
||||||
|
sendingKeySlot = sendingSlot,
|
||||||
|
activeLearningSlot = learningSlot,
|
||||||
|
message = msg,
|
||||||
|
)
|
||||||
|
}.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000),
|
||||||
|
initialValue = InfraredRemoteUiState(),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun switchScene(scene: RemoteScene) {
|
||||||
|
sceneFlow.value = scene
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleLearningMode(enabled: Boolean) {
|
||||||
|
isLearningModeFlow.value = enabled
|
||||||
|
if (enabled) {
|
||||||
|
messageFlow.value = RemoteUiMessage.Info("🔴 学习录制模式已开启:点击任意按键即可开始录制")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onButtonClick(spec: RemoteButtonSpec) {
|
||||||
|
val currentProfile = state.value.effectiveProfileId ?: return
|
||||||
|
val savedDevice = state.value.savedDeviceName
|
||||||
|
if (savedDevice == null) {
|
||||||
|
messageFlow.value = RemoteUiMessage.Error("未配置红外设备名,请点击右上角设置配置")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLearningModeFlow.value) {
|
||||||
|
activeLearningSlotFlow.value = spec.defaultSlot
|
||||||
|
learningCoordinator.learn(
|
||||||
|
profileId = currentProfile,
|
||||||
|
deviceName = savedDevice,
|
||||||
|
displayName = spec.displayName,
|
||||||
|
sdkKey = spec.defaultSlot,
|
||||||
|
)
|
||||||
|
messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按遥控器按键...")
|
||||||
|
} else {
|
||||||
|
val key = state.value.learnedSlotMap[spec.defaultSlot]
|
||||||
|
if (key == null || key.status != InfraredKeyStatus.READY) {
|
||||||
|
messageFlow.value = RemoteUiMessage.Error("按键【${spec.displayName}】尚未学习,请开启右上角学习模式进行录制")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendingKeySlotFlow.value = spec.defaultSlot
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
when (val result = sendInfraredKey(InfraredKeyRef(currentProfile, key.id))) {
|
||||||
|
is DomainResult.Success -> {
|
||||||
|
messageFlow.value = RemoteUiMessage.Info("已发射: ${spec.displayName}")
|
||||||
|
}
|
||||||
|
is DomainResult.Failure -> {
|
||||||
|
handleDomainError(result.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
sendingKeySlotFlow.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearMessage() {
|
||||||
|
messageFlow.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleDomainError(error: DomainError) {
|
||||||
|
when (error) {
|
||||||
|
is DomainError.TokenInvalid, is DomainError.Authentication -> {
|
||||||
|
messageFlow.value = RemoteUiMessage.SessionInvalid
|
||||||
|
}
|
||||||
|
is DomainError.DeviceOffline -> {
|
||||||
|
messageFlow.value = RemoteUiMessage.Error("红外控制器离线 (错误码: ${error.externalCode ?: "20206"})")
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
messageFlow.value = RemoteUiMessage.Error(error.detail ?: "操作失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+139
@@ -0,0 +1,139 @@
|
|||||||
|
package com.flagship.abox.manager.infrared.remote
|
||||||
|
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import com.flagship.abox.manager.domain.DeviceName
|
||||||
|
import com.flagship.abox.manager.domain.DomainResult
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
import com.flagship.abox.manager.domain.infrared.EpochMillisClock
|
||||||
|
import com.flagship.abox.manager.domain.infrared.InfraredKeyFactory
|
||||||
|
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
|
||||||
|
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
|
||||||
|
import com.flagship.abox.manager.domain.infrared.LearnInfraredKey
|
||||||
|
import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning
|
||||||
|
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
|
||||||
|
import com.flagship.abox.manager.domain.infrared.SendInfraredKey
|
||||||
|
import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator
|
||||||
|
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode
|
||||||
|
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore
|
||||||
|
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeProfileResolver
|
||||||
|
import com.flagship.abox.manager.testing.fakes.FakeInfraredDeviceConfigurationRepository
|
||||||
|
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
|
||||||
|
import com.flagship.abox.manager.testing.fakes.FakeInfraredKeyRepository
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
private class TestModeStore : InfraredRuntimeModeStore {
|
||||||
|
private val modeFlow = MutableStateFlow(InfraredRuntimeMode.FAKE)
|
||||||
|
override val mode: Flow<InfraredRuntimeMode> = modeFlow
|
||||||
|
override suspend fun currentMode(): InfraredRuntimeMode = modeFlow.value
|
||||||
|
override suspend fun setMode(mode: InfraredRuntimeMode): DomainResult<Unit> {
|
||||||
|
modeFlow.value = mode
|
||||||
|
return DomainResult.Success(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class InfraredRemoteViewModelTest {
|
||||||
|
private val testDispatcher = StandardTestDispatcher()
|
||||||
|
private val testScope = TestScope(testDispatcher)
|
||||||
|
|
||||||
|
private val profile = ProviderProfileId("abox")
|
||||||
|
private val fakeProfile = ProviderProfileId("abox:infrared-fake")
|
||||||
|
private val device = DeviceName("ir-living")
|
||||||
|
private val clock = EpochMillisClock { 10_000L }
|
||||||
|
|
||||||
|
private val keyRepo = FakeInfraredKeyRepository()
|
||||||
|
private val deviceRepo = FakeInfraredDeviceConfigurationRepository()
|
||||||
|
private val modeStore = TestModeStore()
|
||||||
|
private val resolver = InfraredRuntimeProfileResolver()
|
||||||
|
private val gateway = FakeInfraredGateway()
|
||||||
|
private val factory = InfraredKeyFactory({ InfraredKeyId("01J5MOLERABC123") }, clock)
|
||||||
|
|
||||||
|
private val learn = LearnInfraredKey(keyRepo, gateway, factory, clock)
|
||||||
|
private val retry = RetryInfraredKeyLearning(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun teardown() {
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createViewModel(): InfraredRemoteViewModel {
|
||||||
|
return InfraredRemoteViewModel(
|
||||||
|
savedStateHandle = SavedStateHandle(mapOf("profileId" to "abox")),
|
||||||
|
keyRepository = keyRepo,
|
||||||
|
deviceConfigurationRepository = deviceRepo,
|
||||||
|
runtimeModeStore = modeStore,
|
||||||
|
profileResolver = resolver,
|
||||||
|
learningCoordinator = coordinator,
|
||||||
|
sendInfraredKey = send,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun controlModeSendsSignalWhenKeyIsReady() = 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.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(1, gateway.sentKeys.size)
|
||||||
|
assertEquals("1", gateway.sentKeys.first().key)
|
||||||
|
|
||||||
|
job.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun learningModeTriggersLearnOnButtonClick() = testScope.runTest {
|
||||||
|
deviceRepo.setDeviceName(fakeProfile, device)
|
||||||
|
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)
|
||||||
|
|
||||||
|
job.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user