feat(infrared): add infrared control, learning, and virtual remote module #9
@@ -75,8 +75,16 @@ class InfraredRemoteViewModel @Inject constructor(
|
|||||||
private var underlyingLearningDeferred: kotlinx.coroutines.Deferred<*>? = null
|
private var underlyingLearningDeferred: kotlinx.coroutines.Deferred<*>? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
authenticatedProfileId?.let { profile ->
|
if (authenticatedProfileId != null) {
|
||||||
|
|
|||||||
learningCoordinator.failInterruptedLearning(profile)
|
viewModelScope.launch {
|
||||||
|
runtimeModeStore.mode.collect { mode ->
|
||||||
|
val effective = profileResolver.resolve(authenticatedProfileId, mode)
|
||||||
|
learningCoordinator.failInterruptedLearning(effective)
|
||||||
|
if (effective != authenticatedProfileId) {
|
||||||
|
learningCoordinator.failInterruptedLearning(authenticatedProfileId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +93,7 @@ class InfraredRemoteViewModel @Inject constructor(
|
|||||||
val runtimeMode: InfraredRuntimeMode = InfraredRuntimeMode.FAKE,
|
val runtimeMode: InfraredRuntimeMode = InfraredRuntimeMode.FAKE,
|
||||||
val savedDeviceName: DeviceName? = null,
|
val savedDeviceName: DeviceName? = null,
|
||||||
val learnedSlotMap: Map<String, SavedInfraredKey> = emptyMap(),
|
val learnedSlotMap: Map<String, SavedInfraredKey> = emptyMap(),
|
||||||
|
val configError: DomainError? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
private val configSnapshotFlow: Flow<InfraredRemoteConfigSnapshot> =
|
private val configSnapshotFlow: Flow<InfraredRemoteConfigSnapshot> =
|
||||||
@@ -94,7 +103,16 @@ class InfraredRemoteViewModel @Inject constructor(
|
|||||||
flowOf(InfraredRemoteConfigSnapshot(effectiveProfileId = null, runtimeMode = mode))
|
flowOf(InfraredRemoteConfigSnapshot(effectiveProfileId = null, runtimeMode = mode))
|
||||||
} else {
|
} else {
|
||||||
deviceConfigurationRepository.observeDeviceName(profile).flatMapLatest { deviceResult ->
|
deviceConfigurationRepository.observeDeviceName(profile).flatMapLatest { deviceResult ->
|
||||||
|
pr-agent-bot
commented
错误吞没 设备配置和按键观察流中的 **错误吞没**
设备配置和按键观察流中的 `DomainResult.Failure` 分别被转换为“未配置设备”和空按键列表。数据库读取失败时,界面会错误地提示用户配置设备或重新学习按键,而不是报告实际存储错误。应将失败状态传播到 UI,避免把读取故障当成真实的空数据。
<!-- pr-agent-dedup: f7ef39caa7e5 -->
<!-- pr-agent-key-issue-location: babb0d2f9517 -->
|
|||||||
val deviceName = (deviceResult as? DomainResult.Success)?.value
|
when (deviceResult) {
|
||||||
|
is DomainResult.Failure -> flowOf(
|
||||||
|
InfraredRemoteConfigSnapshot(
|
||||||
|
effectiveProfileId = profile,
|
||||||
|
runtimeMode = mode,
|
||||||
|
configError = deviceResult.error,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
is DomainResult.Success -> {
|
||||||
|
val deviceName = deviceResult.value
|
||||||
if (deviceName == null) {
|
if (deviceName == null) {
|
||||||
flowOf(
|
flowOf(
|
||||||
InfraredRemoteConfigSnapshot(
|
InfraredRemoteConfigSnapshot(
|
||||||
@@ -106,13 +124,22 @@ class InfraredRemoteViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
keyRepository.observeKeys(profile, deviceName).map { keysResult ->
|
keyRepository.observeKeys(profile, deviceName).map { keysResult ->
|
||||||
|
pr-agent-bot
commented
状态错配
**状态错配**
`effectiveProfileAndMode`、`deviceNameFlow` 和 `keysFlow` 被独立组合,设备名或运行模式切换时可能短暂生成“新配置 + 旧按键列表”的状态。用户此时点击按键,会使用旧按键引用向旧红外设备发送命令,或用新 profile 查询旧按键。应在同一个 `flatMapLatest` 中生成相互关联的 profile、设备和按键快照,或发送前校验按键的 profile、deviceName 与当前配置一致。
<!-- pr-agent-dedup: 0c88096cf35a -->
<!-- pr-agent-key-issue-location: 6aab6621dcad -->
|
|||||||
val keys = (keysResult as? DomainResult.Success<List<SavedInfraredKey>>)?.value ?: emptyList()
|
when (keysResult) {
|
||||||
InfraredRemoteConfigSnapshot(
|
is DomainResult.Failure -> InfraredRemoteConfigSnapshot(
|
||||||
effectiveProfileId = profile,
|
effectiveProfileId = profile,
|
||||||
runtimeMode = mode,
|
runtimeMode = mode,
|
||||||
savedDeviceName = deviceName,
|
savedDeviceName = deviceName,
|
||||||
learnedSlotMap = keys.associateBy { it.sdkKey },
|
configError = keysResult.error,
|
||||||
)
|
)
|
||||||
|
is DomainResult.Success -> InfraredRemoteConfigSnapshot(
|
||||||
|
effectiveProfileId = profile,
|
||||||
|
runtimeMode = mode,
|
||||||
|
savedDeviceName = deviceName,
|
||||||
|
learnedSlotMap = keysResult.value.associateBy { it.sdkKey },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,6 +154,13 @@ class InfraredRemoteViewModel @Inject constructor(
|
|||||||
Triple(sending, learning, msg)
|
Triple(sending, learning, msg)
|
||||||
},
|
},
|
||||||
) { scene, isLearning, snapshot, (sendingSlot, learningSlot, msg) ->
|
) { scene, isLearning, snapshot, (sendingSlot, learningSlot, msg) ->
|
||||||
|
val resolvedMessage = msg ?: snapshot.configError?.let { error ->
|
||||||
|
when (error) {
|
||||||
|
is DomainError.TokenInvalid, is DomainError.Authentication -> RemoteUiMessage.SessionInvalid
|
||||||
|
is DomainError.DeviceOffline -> RemoteUiMessage.Error("红外控制器离线 (错误码: ${error.externalCode ?: "20206"})")
|
||||||
|
else -> RemoteUiMessage.Error(error.detail ?: "数据加载失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
InfraredRemoteUiState(
|
InfraredRemoteUiState(
|
||||||
selectedScene = scene,
|
selectedScene = scene,
|
||||||
isLearningMode = isLearning,
|
isLearningMode = isLearning,
|
||||||
@@ -136,7 +170,7 @@ class InfraredRemoteViewModel @Inject constructor(
|
|||||||
learnedSlotMap = snapshot.learnedSlotMap,
|
learnedSlotMap = snapshot.learnedSlotMap,
|
||||||
sendingKeySlot = sendingSlot,
|
sendingKeySlot = sendingSlot,
|
||||||
activeLearningSlot = learningSlot,
|
activeLearningSlot = learningSlot,
|
||||||
message = msg,
|
message = resolvedMessage,
|
||||||
)
|
)
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
@@ -177,15 +211,12 @@ class InfraredRemoteViewModel @Inject constructor(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
val existingKey = state.value.learnedSlotMap[spec.defaultSlot]
|
val existingKey = state.value.learnedSlotMap[spec.defaultSlot]
|
||||||
|
pr-agent-bot
commented
重复发射 发射过程中没有检查已有的 **重复发射**
发射过程中没有检查已有的 `sendingKeySlotFlow`,而遥控器按键在显示加载状态时仍可点击。用户连续点击电源键会并发执行多次红外发射,可能导致设备连续切换两次并回到原状态;其中任一任务先结束还会提前清除另一个任务的加载指示。请在 ViewModel 中阻止重复请求,或按请求分别跟踪并禁用相应按键。
<!-- pr-agent-dedup: fede3506d9e3 -->
<!-- pr-agent-key-issue-location: d6cc1c91e5b2 -->
|
|||||||
if (existingKey?.status == InfraredKeyStatus.LEARNING) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按遥控器按键...")
|
messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按遥控器按键...")
|
||||||
activeLearningSlotFlow.value = spec.defaultSlot
|
activeLearningSlotFlow.value = spec.defaultSlot
|
||||||
val deferred = when (existingKey?.status) {
|
val deferred = when (existingKey?.status) {
|
||||||
InfraredKeyStatus.FAILED -> learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id))
|
InfraredKeyStatus.FAILED -> learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id))
|
||||||
InfraredKeyStatus.READY -> learningCoordinator.relearn(InfraredKeyRef(currentProfile, existingKey.id))
|
InfraredKeyStatus.READY, InfraredKeyStatus.LEARNING -> learningCoordinator.relearn(InfraredKeyRef(currentProfile, existingKey.id))
|
||||||
|
pr-agent-bot
commented
错误丢失 学习任务的完成回调只清理加载状态,没有读取或传播学习结果,并且 **错误丢失**
学习任务的完成回调只清理加载状态,没有读取或传播学习结果,并且 `handleDomainError` 仅用于发送操作。设备离线或认证失效导致学习失败时,界面不会显示失败原因,认证失效也不会触发 `onSessionInvalid`;应由协调器返回或发布结果,并将错误映射为对应的 `RemoteUiMessage`。
<!-- pr-agent-dedup: 16b03ab5c738 -->
<!-- pr-agent-key-issue-location: 392b62285515 -->
pr-agent-bot
commented
竞态条件 关闭学习模式时会异步触发按 profile 清理全部 **竞态条件**
关闭学习模式时会异步触发按 profile 清理全部 `LEARNING` 记录,但界面立即允许重新开启学习。若用户快速重新开启并开始新任务,尚未执行的清理可能把新任务也标记为 `FAILED`,导致随后成功的学习无法转换为 `READY`;初始化和 `onCleared` 中也存在同类风险。应等待清理完成后再允许新任务,或使用时间戳/任务标识仅清理旧记录。
<!-- pr-agent-dedup: 943572f8ab5b -->
<!-- pr-agent-key-issue-location: e2acde597e7e -->
|
|||||||
else -> learningCoordinator.learn(
|
null -> learningCoordinator.learn(
|
||||||
profileId = currentProfile,
|
profileId = currentProfile,
|
||||||
deviceName = savedDevice,
|
deviceName = savedDevice,
|
||||||
|
pr-agent-bot
commented
设备错绑 配置尚未加载时, **设备错绑**
配置尚未加载时,`savedDeviceName` 为 `null`,这里会直接回退到 `ir-1`,但遥控按键仍可操作。若该配置实际保存了其他设备名,冷启动后快速开启学习并点击按键会将按键绑定到错误设备,配置加载完成后该按键还会从当前列表消失。应在设备配置解析完成前禁用学习操作。
<!-- pr-agent-dedup: 64f51ac400f4 -->
<!-- pr-agent-key-issue-location: 40951f9578b1 -->
|
|||||||
displayName = spec.displayName,
|
displayName = spec.displayName,
|
||||||
|
|||||||
@@ -305,4 +305,51 @@ class InfraredRemoteViewModelTest {
|
|||||||
|
|
||||||
job.cancel()
|
job.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun relearningKeyInLearningStatusSucceeds() = testScope.runTest {
|
||||||
|
deviceRepo.setDeviceName(fakeProfile, device)
|
||||||
|
val learningKey = SavedInfraredKey(
|
||||||
|
id = InfraredKeyId("k1"),
|
||||||
|
profileId = fakeProfile,
|
||||||
|
deviceName = device,
|
||||||
|
displayName = "空调-电源",
|
||||||
|
sdkKey = "1",
|
||||||
|
status = InfraredKeyStatus.LEARNING,
|
||||||
|
lastErrorCode = null,
|
||||||
|
lastErrorDetail = null,
|
||||||
|
createdAtEpochMillis = 100L,
|
||||||
|
updatedAtEpochMillis = 100L,
|
||||||
|
)
|
||||||
|
keyRepo.insert(learningKey)
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun configSnapshotStorageErrorPropagatesToUiStateMessage() = testScope.runTest {
|
||||||
|
deviceRepo.observeError = com.flagship.abox.manager.domain.DomainError.Unknown(detail = "Disk failure")
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
val job = launch { vm.state.collect {} }
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(RemoteUiMessage.Error("Disk failure"), vm.state.value.message)
|
||||||
|
|
||||||
|
job.cancel()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,11 +176,6 @@ class RelearnInfraredKey(
|
|||||||
|
|
||||||
is DomainResult.Failure -> return result
|
is DomainResult.Failure -> return result
|
||||||
}
|
}
|
||||||
if (key.status == InfraredKeyStatus.LEARNING) {
|
|
||||||
return DomainResult.Failure(
|
|
||||||
DomainError.InvalidRequest(detail = "Infrared key is already learning"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
when (
|
when (
|
||||||
val result = repository.transitionStatus(
|
val result = repository.transitionStatus(
|
||||||
@@ -188,6 +183,7 @@ class RelearnInfraredKey(
|
|||||||
expectedStatuses = setOf(
|
expectedStatuses = setOf(
|
||||||
InfraredKeyStatus.READY,
|
InfraredKeyStatus.READY,
|
||||||
InfraredKeyStatus.FAILED,
|
InfraredKeyStatus.FAILED,
|
||||||
|
InfraredKeyStatus.LEARNING,
|
||||||
),
|
),
|
||||||
targetStatus = InfraredKeyStatus.LEARNING,
|
targetStatus = InfraredKeyStatus.LEARNING,
|
||||||
lastErrorCode = null,
|
lastErrorCode = null,
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ class LearnInfraredKeyTest {
|
|||||||
assertEquals(0, repository.insertedKeys.size)
|
assertEquals(0, repository.insertedKeys.size)
|
||||||
assertEquals(2, repository.transitionCalls.size)
|
assertEquals(2, repository.transitionCalls.size)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
setOf(InfraredKeyStatus.READY, InfraredKeyStatus.FAILED),
|
setOf(InfraredKeyStatus.READY, InfraredKeyStatus.FAILED, InfraredKeyStatus.LEARNING),
|
||||||
repository.transitionCalls.first().expectedStatuses,
|
repository.transitionCalls.first().expectedStatuses,
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
|
|||||||
@@ -134,10 +134,12 @@ class FakeInfraredKeyRepository : InfraredKeyRepository {
|
|||||||
class FakeInfraredDeviceConfigurationRepository : InfraredDeviceConfigurationRepository {
|
class FakeInfraredDeviceConfigurationRepository : InfraredDeviceConfigurationRepository {
|
||||||
private val lock = Any()
|
private val lock = Any()
|
||||||
private val deviceNames = MutableStateFlow<Map<ProviderProfileId, DeviceName>>(emptyMap())
|
private val deviceNames = MutableStateFlow<Map<ProviderProfileId, DeviceName>>(emptyMap())
|
||||||
|
var observeError: DomainError? = null
|
||||||
|
|
||||||
override fun observeDeviceName(
|
override fun observeDeviceName(
|
||||||
profileId: ProviderProfileId,
|
profileId: ProviderProfileId,
|
||||||
): Flow<DomainResult<DeviceName?>> = deviceNames.map { values ->
|
): Flow<DomainResult<DeviceName?>> = deviceNames.map { values ->
|
||||||
|
observeError?.let { return@map DomainResult.Failure(it) }
|
||||||
DomainResult.Success(values[profileId])
|
DomainResult.Success(values[profileId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
恢复错位
failInterruptedLearning使用原始认证配置,而按键数据使用经运行模式解析后的effectiveProfileId。在 FAKE 模式下,按键通常存储于派生配置(如abox:infrared-fake);进程中断后,这些LEARNING记录不会被标记为失败,而onButtonClick又会直接忽略已有的LEARNING按键,导致该按键无法重试或重新学习。应针对当前运行模式解析出的配置执行恢复。