feat(infrared): add virtual remote learn mode, custom layout switching, and key management

This commit is contained in:
2026-08-25 14:20:30 +08:00
parent 7f9fb8c2b6
commit 63cac722c2
16 changed files with 907 additions and 116 deletions
@@ -0,0 +1,53 @@
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.ProviderProfileId
import java.util.UUID
class AddVirtualRemoteKey(
private val repository: VirtualRemoteRepository,
) {
suspend operator fun invoke(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
displayName: String,
): DomainResult<RemoteKeyBinding> {
val trimmed = displayName.trim()
if (trimmed.isBlank()) {
return DomainResult.Failure(DomainError.InvalidRequest(detail = "Button name cannot be blank"))
}
val remote = when (val res = repository.getRemote(profileId, remoteId)) {
is DomainResult.Success -> res.value ?: return DomainResult.Failure(DomainError.NoData(detail = "Remote not found"))
is DomainResult.Failure -> return res
}
val buttonId = "CUSTOM_" + UUID.randomUUID().toString().take(8).uppercase()
val assignedSlot = if (remote.transmissionMode == TransmissionMode.STATIC_SLOTS) {
// Find next available slot
val bindings = (repository.getKeyBindings(remoteId) as? DomainResult.Success)?.value.orEmpty()
val usedSlots = bindings.mapNotNull { it.assignedSlot?.toIntOrNull() }.toSet()
val nextSlot = (1..99).firstOrNull { it !in usedSlots }
?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "槽位配额已满,无法分配静态槽位"))
nextSlot.toString()
} else null
val binding = RemoteKeyBinding(
id = UUID.randomUUID().toString(),
remoteId = remoteId,
profileId = profileId,
buttonId = buttonId,
displayName = trimmed,
assignedSlot = assignedSlot,
rawHexCode = null,
status = InfraredKeyStatus.FAILED, // Initially unlearned
)
when (val res = repository.updateKeyBinding(binding)) {
is DomainResult.Failure -> return res
is DomainResult.Success -> Unit
}
return DomainResult.Success(binding)
}
}
@@ -0,0 +1,14 @@
package com.flagship.abox.manager.domain.infrared
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
class DeleteVirtualRemoteKey(
private val repository: VirtualRemoteRepository,
) {
suspend operator fun invoke(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
buttonId: String,
): DomainResult<Unit> = repository.deleteKeyBinding(remoteId, buttonId)
}
@@ -0,0 +1,73 @@
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 LearnVirtualRemoteKey(
private val repository: VirtualRemoteRepository,
private val gateway: InfraredGateway,
) {
suspend operator fun invoke(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
buttonId: String,
): DomainResult<Unit> {
val remote = when (val res = repository.getRemote(profileId, remoteId)) {
is DomainResult.Success -> res.value ?: return DomainResult.Failure(DomainError.NoData(detail = "Remote not found"))
is DomainResult.Failure -> return res
}
val bindings = when (val res = repository.getKeyBindings(remoteId)) {
is DomainResult.Success -> res.value
is DomainResult.Failure -> return res
}
val binding = bindings.find { it.buttonId == buttonId }
?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Button not found"))
val targetSlot = when (remote.transmissionMode) {
TransmissionMode.STATIC_SLOTS -> {
binding.assignedSlot ?: run {
// Find available slot from quota
"1"
}
}
TransmissionMode.DYNAMIC_SCRATCH -> remote.dynamicScratchSlot
}
// Set status to LEARNING while hardware listens
repository.updateKeyBinding(
binding.copy(
status = InfraredKeyStatus.LEARNING,
assignedSlot = targetSlot,
lastErrorCode = null,
lastErrorDetail = null,
),
)
return when (val learnResult = gateway.learnKey(profileId, remote.deviceName, targetSlot)) {
is DomainResult.Success -> {
repository.updateKeyBinding(
binding.copy(
status = InfraredKeyStatus.READY,
assignedSlot = targetSlot,
lastErrorCode = null,
lastErrorDetail = null,
),
)
DomainResult.Success(Unit)
}
is DomainResult.Failure -> {
repository.updateKeyBinding(
binding.copy(
status = InfraredKeyStatus.FAILED,
assignedSlot = targetSlot,
lastErrorCode = learnResult.error.externalCode,
lastErrorDetail = learnResult.error.detail,
),
)
learnResult
}
}
}
}
@@ -0,0 +1,25 @@
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.ProviderProfileId
class UpdateVirtualRemoteLayout(
private val repository: VirtualRemoteRepository,
) {
suspend operator fun invoke(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
layoutStyle: PanelLayoutStyle,
): DomainResult<Unit> {
val remote = when (val res = repository.getRemote(profileId, remoteId)) {
is DomainResult.Success -> res.value ?: return DomainResult.Failure(DomainError.NoData(detail = "Remote not found"))
is DomainResult.Failure -> return res
}
val updated = remote.copy(
layoutStyle = layoutStyle,
updatedAtEpochMillis = System.currentTimeMillis(),
)
return repository.updateRemote(updated)
}
}
@@ -18,6 +18,13 @@ enum class TransmissionMode {
DYNAMIC_SCRATCH,
}
enum class PanelLayoutStyle {
TV_NAV,
GRID,
MEDIA_PLAYER,
AC_COMPACT,
}
enum class AcMode {
COOL,
HEAT,
@@ -50,6 +57,7 @@ data class VirtualRemote(
val displayName: String,
val type: RemoteDeviceType,
val brandModel: String? = null,
val layoutStyle: PanelLayoutStyle = defaultLayoutFor(type),
val transmissionMode: TransmissionMode,
val dynamicScratchSlot: String = DEFAULT_DYNAMIC_SCRATCH_SLOT,
val acState: AcStateSnapshot? = null,
@@ -58,6 +66,13 @@ data class VirtualRemote(
) {
companion object {
const val DEFAULT_DYNAMIC_SCRATCH_SLOT = "100"
fun defaultLayoutFor(type: RemoteDeviceType): PanelLayoutStyle = when (type) {
RemoteDeviceType.TV -> PanelLayoutStyle.TV_NAV
RemoteDeviceType.AIR_CONDITIONER -> PanelLayoutStyle.AC_COMPACT
RemoteDeviceType.FAN -> PanelLayoutStyle.GRID
RemoteDeviceType.CUSTOM -> PanelLayoutStyle.GRID
}
}
}
@@ -48,6 +48,8 @@ interface VirtualRemoteRepository {
suspend fun updateKeyBinding(binding: RemoteKeyBinding): DomainResult<Unit>
suspend fun deleteKeyBinding(remoteId: VirtualRemoteId, buttonId: String): DomainResult<Unit>
suspend fun updateAcState(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
@@ -311,6 +311,17 @@ class InMemoryVirtualRemoteRepository : VirtualRemoteRepository {
return DomainResult.Success(Unit)
}
override suspend fun deleteKeyBinding(
remoteId: VirtualRemoteId,
buttonId: String,
): DomainResult<Unit> {
val list = bindings[remoteId] ?: return DomainResult.Success(Unit)
list.removeAll { it.buttonId == buttonId }
bindings[remoteId] = list
publish()
return DomainResult.Success(Unit)
}
override suspend fun updateAcState(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,