feat(infrared): add infrared control, learning, and virtual remote module #9
@@ -28,6 +28,7 @@ class RoomInfraredKeyRepository(
|
|||||||
DomainResult.Success(entities.map { it.toDomain() })
|
DomainResult.Success(entities.map { it.toDomain() })
|
||||||
}
|
}
|
||||||
.catch { throwable ->
|
.catch { throwable ->
|
||||||
|
if (throwable is kotlinx.coroutines.CancellationException) throw throwable
|
||||||
emit(DomainResult.Failure(mapThrowableToDomainError(throwable)))
|
emit(DomainResult.Failure(mapThrowableToDomainError(throwable)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -38,6 +39,7 @@ class RoomInfraredKeyRepository(
|
|||||||
DomainResult.Success(entity?.deviceName?.let { DeviceName(it) })
|
DomainResult.Success(entity?.deviceName?.let { DeviceName(it) })
|
||||||
}
|
}
|
||||||
.catch { throwable ->
|
.catch { throwable ->
|
||||||
|
if (throwable is kotlinx.coroutines.CancellationException) throw throwable
|
||||||
emit(DomainResult.Failure(mapThrowableToDomainError(throwable)))
|
emit(DomainResult.Failure(mapThrowableToDomainError(throwable)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,6 +184,9 @@ class RoomInfraredKeyRepository(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun mapThrowableToDomainError(throwable: Throwable): DomainError {
|
private fun mapThrowableToDomainError(throwable: Throwable): DomainError {
|
||||||
|
if (throwable is kotlinx.coroutines.CancellationException) {
|
||||||
|
throw throwable
|
||||||
|
}
|
||||||
return when (throwable) {
|
return when (throwable) {
|
||||||
is SQLiteConstraintException -> DomainError.InvalidRequest(detail = "Database constraint violation")
|
is SQLiteConstraintException -> DomainError.InvalidRequest(detail = "Database constraint violation")
|
||||||
else -> DomainError.Unknown(detail = "Database operation failed")
|
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 com.flagship.abox.manager.infrared.runtime.InfraredRuntimeProfileResolver
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
@@ -68,6 +69,7 @@ class InfraredRemoteViewModel @Inject constructor(
|
|||||||
private val sendingKeySlotFlow = MutableStateFlow<String?>(null)
|
private val sendingKeySlotFlow = MutableStateFlow<String?>(null)
|
||||||
private val activeLearningSlotFlow = MutableStateFlow<String?>(null)
|
private val activeLearningSlotFlow = MutableStateFlow<String?>(null)
|
||||||
private val messageFlow = MutableStateFlow<RemoteUiMessage?>(null)
|
private val messageFlow = MutableStateFlow<RemoteUiMessage?>(null)
|
||||||
|
private var activeLearningJob: Job? = null
|
||||||
|
|
||||||
private val effectiveProfileAndMode: Flow<Pair<ProviderProfileId?, InfraredRuntimeMode>> =
|
private val effectiveProfileAndMode: Flow<Pair<ProviderProfileId?, InfraredRuntimeMode>> =
|
||||||
runtimeModeStore.mode.flatMapLatest { mode ->
|
runtimeModeStore.mode.flatMapLatest { mode ->
|
||||||
@@ -149,6 +151,8 @@ class InfraredRemoteViewModel @Inject constructor(
|
|||||||
fun toggleLearningMode(enabled: Boolean) {
|
fun toggleLearningMode(enabled: Boolean) {
|
||||||
|
|
|||||||
isLearningModeFlow.value = enabled
|
isLearningModeFlow.value = enabled
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
|
activeLearningJob?.cancel()
|
||||||
|
activeLearningJob = null
|
||||||
activeLearningSlotFlow.value = null
|
activeLearningSlotFlow.value = null
|
||||||
} else {
|
} else {
|
||||||
|
pr-agent-bot
commented
取消失效 关闭学习模式时取消的只是等待结果的 ViewModel Job,而 **取消失效**
关闭学习模式时取消的只是等待结果的 ViewModel Job,而 `learn`/`retry` 返回的任务由独立的应用级 scope 执行;取消 `await` 不会停止底层学习。用户关闭录制或离开页面后,硬件仍可能继续学习,并且清空 active slot 后重新开启模式可启动另一个并发学习。应提供对 coordinator 底层任务的取消能力,或在任务真正结束前保留忙碌状态并阻止新任务。
<!-- pr-agent-dedup: 42f163fda940 -->
<!-- pr-agent-key-issue-location: bc00d351aea6 -->
|
|||||||
messageFlow.value = RemoteUiMessage.Info("🔴 学习录制模式已开启:点击任意按键即可开始录制")
|
messageFlow.value = RemoteUiMessage.Info("🔴 学习录制模式已开启:点击任意按键即可开始录制")
|
||||||
@@ -168,15 +172,24 @@ class InfraredRemoteViewModel @Inject constructor(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
activeLearningSlotFlow.value = spec.defaultSlot
|
activeLearningSlotFlow.value = spec.defaultSlot
|
||||||
val job = learningCoordinator.learn(
|
val existingKey = state.value.learnedSlotMap[spec.defaultSlot]
|
||||||
profileId = currentProfile,
|
val job = if (existingKey != null) {
|
||||||
deviceName = savedDevice,
|
learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id))
|
||||||
|
pr-agent-bot
commented
设备竞态
**设备竞态**
`savedDeviceName` 尚未从持久化配置加载时会直接回退到 `DEFAULT_DEVICE_NAME`。如果用户快速开启学习模式并点击按键,而实际配置的是其他设备,学习请求会发送给 `ir-1`,并产生不会显示在当前设备面板中的错误键记录。应在配置成功加载前禁用操作或直接返回,而不是把“尚未加载”解释为默认设备。
<!-- pr-agent-dedup: 20ea2fda4715 -->
<!-- pr-agent-key-issue-location: 702140543860 -->
|
|||||||
displayName = spec.displayName,
|
} else {
|
||||||
sdkKey = spec.defaultSlot,
|
learningCoordinator.learn(
|
||||||
|
pr-agent-bot
commented
重学失败
**重学失败**
`existingKey` 只要非空就会调用 `learningCoordinator.retry`,但该重试路径用于失败按键,而学习模式也允许点击已经处于 `READY` 状态的按键。用户重新录制已学习按键时会因状态转换不合法而失败;应提供明确的重新学习或覆盖路径,或者禁止点击已学习按键。
<!-- pr-agent-dedup: 365d84abc324 -->
<!-- pr-agent-key-issue-location: 6a2d13d13315 -->
pr-agent-bot
commented
无法重录 只要槽位已存在就调用 **无法重录**
只要槽位已存在就调用 `learningCoordinator.retry`,其中也包括状态为 `READY` 的已学习按键。但重试流程用于失败按键,设置页也只对 `FAILED` 状态提供重试,因此用户在学习模式下点击已学习按键以更新红外码时会被状态校验拒绝。应仅对失败按键调用重试,并为已就绪按键提供明确的重新学习或覆盖流程。
<!-- pr-agent-dedup: 6de2ba8f0bdf -->
<!-- pr-agent-key-issue-location: b2730b9541cd -->
|
|||||||
)
|
profileId = currentProfile,
|
||||||
|
pr-agent-bot
commented
并发冲突 发送信号期间仍可开启学习模式,而学习分支没有检查 **并发冲突**
发送信号期间仍可开启学习模式,而学习分支没有检查 `sendingKeySlotFlow`。此时再次点击按键会让发送和学习请求同时操作同一红外网关及设备,可能导致学习失败或捕获到错误信号。应在两种操作之间实施统一互斥,或在发送完成前禁用学习模式开关。
<!-- pr-agent-dedup: 69d5674e86b4 -->
<!-- pr-agent-key-issue-location: f2db1b110ebc -->
|
|||||||
|
deviceName = savedDevice,
|
||||||
|
displayName = spec.displayName,
|
||||||
|
sdkKey = spec.defaultSlot,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
activeLearningJob = job
|
||||||
job.invokeOnCompletion {
|
job.invokeOnCompletion {
|
||||||
if (activeLearningSlotFlow.value == spec.defaultSlot) {
|
if (activeLearningJob === job) {
|
||||||
activeLearningSlotFlow.value = null
|
activeLearningJob = null
|
||||||
|
pr-agent-bot
commented
重复发射 发射过程中没有检查已有的 **重复发射**
发射过程中没有检查已有的 `sendingKeySlotFlow`,而遥控器按键在显示加载状态时仍可点击。用户连续点击电源键会并发执行多次红外发射,可能导致设备连续切换两次并回到原状态;其中任一任务先结束还会提前清除另一个任务的加载指示。请在 ViewModel 中阻止重复请求,或按请求分别跟踪并禁用相应按键。
<!-- pr-agent-dedup: fede3506d9e3 -->
<!-- pr-agent-key-issue-location: d6cc1c91e5b2 -->
|
|||||||
|
if (activeLearningSlotFlow.value == spec.defaultSlot) {
|
||||||
|
activeLearningSlotFlow.value = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按遥控器按键...")
|
messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按遥控器按键...")
|
||||||
@@ -207,6 +220,12 @@ class InfraredRemoteViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
super.onCleared()
|
||||||
|
activeLearningJob?.cancel()
|
||||||
|
activeLearningJob = null
|
||||||
|
}
|
||||||
|
|
||||||
fun clearMessage() {
|
fun clearMessage() {
|
||||||
messageFlow.value = null
|
messageFlow.value = null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ class DataStoreInfraredRuntimeModeStore(
|
|||||||
}
|
}
|
||||||
DomainResult.Success(Unit)
|
DomainResult.Success(Unit)
|
||||||
} catch (throwable: Throwable) {
|
} catch (throwable: Throwable) {
|
||||||
|
if (throwable is kotlinx.coroutines.CancellationException) throw throwable
|
||||||
DomainResult.Failure(DomainError.Unknown(detail = "Failed to update runtime mode"))
|
DomainResult.Failure(DomainError.Unknown(detail = "Failed to update runtime mode"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -183,4 +183,37 @@ class InfraredRemoteViewModelTest {
|
|||||||
|
|
||||||
job.cancel()
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
任务未取消
关闭学习模式时仅清空 UI 槽位,没有保存或取消正在运行的学习任务。用户在录制过程中关闭开关后,底层学习仍会继续,而界面允许发送或再次开始学习;若再次学习同一槽位,旧任务的完成回调还可能提前清除新任务的加载状态。应持有并取消当前任务,或在任务真正结束前保持活动状态。