feat(infrared): refactor virtual remote hub with multi-device presets and quota management

This commit is contained in:
2026-08-25 14:11:49 +08:00
parent 86af7f4c91
commit 7f9fb8c2b6
29 changed files with 3072 additions and 44 deletions
@@ -0,0 +1,52 @@
package com.flagship.abox.manager.domain.infrared
object AcCodeGenerator {
/**
* Generates a standard protocol Hex string for an Air Conditioner state.
* Uses Gree/Generic standard 64-bit frame:
* Byte 0: Mode (3 bits) | Power (1 bit) | Fan Speed (2 bits) | Swing (2 bits)
* Byte 1: Temperature (4 bits: temp - 16) | Timer (4 bits)
* Byte 2: Auxiliary options (Eco, Turbo, Health, Light)
* Byte 3: Checksum
*/
fun generateHex(state: AcStateSnapshot, brand: String? = null): String {
val modeBits = when (state.mode) {
AcMode.AUTO -> 0b000
AcMode.COOL -> 0b001
AcMode.DRY -> 0b010
AcMode.FAN -> 0b011
AcMode.HEAT -> 0b100
}
val powerBit = if (state.power) 0b1 else 0b0
val fanBits = when (state.fanSpeed) {
AcFanSpeed.AUTO -> 0b00
AcFanSpeed.LOW -> 0b01
AcFanSpeed.MEDIUM -> 0b10
AcFanSpeed.HIGH -> 0b11
}
val swingBits = (if (state.verticalSwing) 0b01 else 0b00) or (if (state.horizontalSwing) 0b10 else 0b00)
val byte0 = ((modeBits and 0x07) shl 5) or
((powerBit and 0x01) shl 4) or
((fanBits and 0x03) shl 2) or
(swingBits and 0x03)
val clampedTemp = state.temperature.coerceIn(16, 30)
val tempBits = (clampedTemp - 16) and 0x0F
val byte1 = (tempBits shl 4) and 0xF0
val ecoBit = if (state.eco) 0b1 else 0b0
val byte2 = (ecoBit and 0x01) shl 7
val checksum = (byte0 + byte1 + byte2) and 0xFF
return buildString {
append("0x")
append(String.format("%02X", byte0))
append(String.format("%02X", byte1))
append(String.format("%02X", byte2))
append(String.format("%02X", checksum))
}
}
}
@@ -0,0 +1,108 @@
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.InfraredGateway
import com.flagship.abox.manager.domain.ProviderProfileId
import java.util.UUID
class CreateVirtualRemote(
private val repository: VirtualRemoteRepository,
private val gateway: InfraredGateway,
private val clock: EpochMillisClock,
) {
suspend operator fun invoke(
profileId: ProviderProfileId,
deviceName: DeviceName,
displayName: String,
type: RemoteDeviceType,
brandModel: String? = null,
transmissionMode: TransmissionMode,
): DomainResult<VirtualRemoteId> {
val trimmedName = displayName.trim()
if (trimmedName.isBlank()) {
return DomainResult.Failure(
DomainError.InvalidRequest(detail = "Remote display name cannot be blank"),
)
}
val templates = PresetCodeLibrary.getTemplatesForType(type, brandModel)
val quotaResult = repository.getKeyBindings(VirtualRemoteId("dummy")) // or compute from quota
// Obtain slot quota
// We calculate needed static slots:
val neededStaticSlots = if (transmissionMode == TransmissionMode.STATIC_SLOTS) templates.size else 0
val remoteId = VirtualRemoteId(UUID.randomUUID().toString().replace("-", "").take(16))
val now = clock.now()
val initialAcState = if (type == RemoteDeviceType.AIR_CONDITIONER) AcStateSnapshot() else null
val remote = VirtualRemote(
id = remoteId,
profileId = profileId,
deviceName = deviceName,
displayName = trimmedName,
type = type,
brandModel = brandModel,
transmissionMode = transmissionMode,
dynamicScratchSlot = VirtualRemote.DEFAULT_DYNAMIC_SCRATCH_SLOT,
acState = initialAcState,
createdAtEpochMillis = now,
updatedAtEpochMillis = now,
)
// Build key bindings
val bindings = if (transmissionMode == TransmissionMode.STATIC_SLOTS) {
// Find currently used slots by querying quota
// For now, assign slots incrementally or check
templates.mapIndexed { index, template ->
RemoteKeyBinding(
id = UUID.randomUUID().toString(),
remoteId = remoteId,
profileId = profileId,
buttonId = template.buttonId,
displayName = template.displayName,
assignedSlot = (index + 1).toString(), // Will be allocated based on allocator
rawHexCode = template.defaultHexCode,
status = if (template.defaultHexCode != null) InfraredKeyStatus.READY else InfraredKeyStatus.FAILED,
)
}
} else {
templates.map { template ->
RemoteKeyBinding(
id = UUID.randomUUID().toString(),
remoteId = remoteId,
profileId = profileId,
buttonId = template.buttonId,
displayName = template.displayName,
assignedSlot = null,
rawHexCode = template.defaultHexCode,
status = if (template.defaultHexCode != null || type == RemoteDeviceType.AIR_CONDITIONER) {
InfraredKeyStatus.READY
} else {
InfraredKeyStatus.FAILED
},
)
}
}
// If static and has default hex codes, download to hardware slots
if (transmissionMode == TransmissionMode.STATIC_SLOTS) {
val codesToDownload = bindings.mapNotNull { b ->
if (b.assignedSlot != null && b.rawHexCode != null) {
InfraredCode(deviceName, b.assignedSlot, b.rawHexCode)
} else null
}
if (codesToDownload.isNotEmpty()) {
when (val dlResult = gateway.downloadCodes(profileId, codesToDownload)) {
is DomainResult.Failure -> return dlResult
is DomainResult.Success -> Unit
}
}
}
return repository.createRemote(remote, bindings)
}
}
@@ -0,0 +1,13 @@
package com.flagship.abox.manager.domain.infrared
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
class DeleteVirtualRemote(
private val repository: VirtualRemoteRepository,
) {
suspend operator fun invoke(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
): DomainResult<Unit> = repository.deleteRemote(profileId, remoteId)
}
@@ -0,0 +1,81 @@
package com.flagship.abox.manager.domain.infrared
data class PresetBrandOption(
val id: String,
val displayName: String,
val deviceType: RemoteDeviceType,
)
object PresetCodeLibrary {
val availablePresets: List<PresetBrandOption> = listOf(
// 电视预设
PresetBrandOption("generic_tv", "通用电视 (Generic TV)", RemoteDeviceType.TV),
PresetBrandOption("xiaomi_tv", "小米电视 (Xiaomi TV)", RemoteDeviceType.TV),
PresetBrandOption("sony_tv", "索尼电视 (Sony TV)", RemoteDeviceType.TV),
PresetBrandOption("samsung_tv", "三星电视 (Samsung TV)", RemoteDeviceType.TV),
// 空调预设
PresetBrandOption("gree_ac", "格力空调 (Gree AC)", RemoteDeviceType.AIR_CONDITIONER),
PresetBrandOption("midea_ac", "美的空调 (Midea AC)", RemoteDeviceType.AIR_CONDITIONER),
PresetBrandOption("haier_ac", "海尔空调 (Haier AC)", RemoteDeviceType.AIR_CONDITIONER),
PresetBrandOption("aux_ac", "奥克斯空调 (AUX AC)", RemoteDeviceType.AIR_CONDITIONER),
// 风扇预设
PresetBrandOption("midea_fan", "美的电风扇 (Midea Fan)", RemoteDeviceType.FAN),
PresetBrandOption("generic_fan", "通用风扇 (Generic Fan)", RemoteDeviceType.FAN),
// 自定义
PresetBrandOption("custom_blank", "自定义学习遥控器 (Custom)", RemoteDeviceType.CUSTOM),
)
data class PresetButtonTemplate(
val buttonId: String,
val displayName: String,
val defaultHexCode: String? = null,
)
fun getTemplatesForType(type: RemoteDeviceType, presetId: String? = null): List<PresetButtonTemplate> = when (type) {
RemoteDeviceType.TV -> listOf(
PresetButtonTemplate("TV_POWER", "电源", "0x00FF02FD"),
PresetButtonTemplate("TV_MUTE", "静音", "0x00FF04FB"),
PresetButtonTemplate("TV_SOURCE", "信号源", "0x00FF06F9"),
PresetButtonTemplate("TV_NAV_UP", "上", "0x00FF08F7"),
PresetButtonTemplate("TV_NAV_DOWN", "下", "0x00FF0AF5"),
PresetButtonTemplate("TV_NAV_LEFT", "左", "0x00FF0CF3"),
PresetButtonTemplate("TV_NAV_RIGHT", "右", "0x00FF0EF1"),
PresetButtonTemplate("TV_OK", "确定", "0x00FF10EF"),
PresetButtonTemplate("TV_HOME", "主页", "0x00FF12ED"),
PresetButtonTemplate("TV_BACK", "返回", "0x00FF14EB"),
PresetButtonTemplate("TV_MENU", "菜单", "0x00FF16E9"),
PresetButtonTemplate("TV_VOL_UP", "音量 +", "0x00FF18E7"),
PresetButtonTemplate("TV_VOL_DOWN", "音量 -", "0x00FF1AE5"),
PresetButtonTemplate("TV_CH_UP", "频道 +", "0x00FF1CE3"),
PresetButtonTemplate("TV_CH_DOWN", "频道 -", "0x00FF1EE1"),
)
RemoteDeviceType.AIR_CONDITIONER -> listOf(
PresetButtonTemplate("AC_POWER", "电源"),
PresetButtonTemplate("AC_TEMP_UP", "升温"),
PresetButtonTemplate("AC_TEMP_DOWN", "降温"),
PresetButtonTemplate("AC_MODE", "模式"),
PresetButtonTemplate("AC_FAN", "风速"),
PresetButtonTemplate("AC_SWING_V", "上下扫风"),
PresetButtonTemplate("AC_SWING_H", "左右扫风"),
PresetButtonTemplate("AC_ECO", "节能"),
)
RemoteDeviceType.FAN -> listOf(
PresetButtonTemplate("FAN_POWER", "电源", "0x01FE02FD"),
PresetButtonTemplate("FAN_SPEED", "风速", "0x01FE04FB"),
PresetButtonTemplate("FAN_SWING", "摇头", "0x01FE06F9"),
PresetButtonTemplate("FAN_TIMER", "定时", "0x01FE08F7"),
PresetButtonTemplate("FAN_MODE", "模式", "0x01FE0AF5"),
)
RemoteDeviceType.CUSTOM -> listOf(
PresetButtonTemplate("CUSTOM_POWER", "电源"),
PresetButtonTemplate("CUSTOM_KEY_1", "按键 1"),
PresetButtonTemplate("CUSTOM_KEY_2", "按键 2"),
PresetButtonTemplate("CUSTOM_KEY_3", "按键 3"),
PresetButtonTemplate("CUSTOM_KEY_4", "按键 4"),
)
}
}
@@ -0,0 +1,85 @@
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.InfraredCode
import com.flagship.abox.manager.domain.InfraredGateway
import com.flagship.abox.manager.domain.ProviderProfileId
class SendVirtualRemoteAction(
private val repository: VirtualRemoteRepository,
private val gateway: InfraredGateway,
) {
suspend fun sendButton(
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 binding not found"))
if (binding.status != InfraredKeyStatus.READY) {
return DomainResult.Failure(DomainError.InvalidRequest(detail = "Button is not ready to send"))
}
return when (remote.transmissionMode) {
TransmissionMode.STATIC_SLOTS -> {
val slot = binding.assignedSlot
?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "No slot assigned for static mode"))
gateway.sendKey(profileId, remote.deviceName, slot)
}
TransmissionMode.DYNAMIC_SCRATCH -> {
val hexCode = binding.rawHexCode
?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "No hex code available for dynamic transmission"))
// 1. Download to scratch slot
val scratchSlot = remote.dynamicScratchSlot
when (val dlResult = gateway.downloadCodes(profileId, listOf(InfraredCode(remote.deviceName, scratchSlot, hexCode)))) {
is DomainResult.Failure -> return dlResult
is DomainResult.Success -> Unit
}
// 2. Send scratch slot
gateway.sendKey(profileId, remote.deviceName, scratchSlot)
}
}
}
suspend fun sendAcState(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
state: AcStateSnapshot,
): 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
}
if (remote.type != RemoteDeviceType.AIR_CONDITIONER) {
return DomainResult.Failure(DomainError.InvalidRequest(detail = "Target is not an air conditioner"))
}
// 1. Update state snapshot in repository
when (val updateRes = repository.updateAcState(profileId, remoteId, state)) {
is DomainResult.Failure -> return updateRes
is DomainResult.Success -> Unit
}
// 2. Generate full state hex frame
val hexCode = AcCodeGenerator.generateHex(state, remote.brandModel)
// 3. Write to scratch slot and transmit
val scratchSlot = remote.dynamicScratchSlot
when (val dlResult = gateway.downloadCodes(profileId, listOf(InfraredCode(remote.deviceName, scratchSlot, hexCode)))) {
is DomainResult.Failure -> return dlResult
is DomainResult.Success -> Unit
}
return gateway.sendKey(profileId, remote.deviceName, scratchSlot)
}
}
@@ -0,0 +1,93 @@
package com.flagship.abox.manager.domain.infrared
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfileId
@JvmInline
value class VirtualRemoteId(val value: String)
enum class RemoteDeviceType {
TV,
AIR_CONDITIONER,
FAN,
CUSTOM,
}
enum class TransmissionMode {
STATIC_SLOTS,
DYNAMIC_SCRATCH,
}
enum class AcMode {
COOL,
HEAT,
AUTO,
DRY,
FAN,
}
enum class AcFanSpeed {
AUTO,
LOW,
MEDIUM,
HIGH,
}
data class AcStateSnapshot(
val power: Boolean = true,
val mode: AcMode = AcMode.COOL,
val temperature: Int = 26,
val fanSpeed: AcFanSpeed = AcFanSpeed.AUTO,
val verticalSwing: Boolean = true,
val horizontalSwing: Boolean = false,
val eco: Boolean = false,
)
data class VirtualRemote(
val id: VirtualRemoteId,
val profileId: ProviderProfileId,
val deviceName: DeviceName,
val displayName: String,
val type: RemoteDeviceType,
val brandModel: String? = null,
val transmissionMode: TransmissionMode,
val dynamicScratchSlot: String = DEFAULT_DYNAMIC_SCRATCH_SLOT,
val acState: AcStateSnapshot? = null,
val createdAtEpochMillis: Long,
val updatedAtEpochMillis: Long,
) {
companion object {
const val DEFAULT_DYNAMIC_SCRATCH_SLOT = "100"
}
}
data class RemoteKeyBinding(
val id: String,
val remoteId: VirtualRemoteId,
val profileId: ProviderProfileId,
val buttonId: String,
val displayName: String,
val assignedSlot: String? = null,
val rawHexCode: String? = null,
val status: InfraredKeyStatus = InfraredKeyStatus.READY,
val lastErrorCode: String? = null,
val lastErrorDetail: String? = null,
)
data class SlotQuotaSummary(
val totalCapacity: Int = 100,
val dynamicScratchSlot: Int = 100,
val assignedStaticSlots: Set<Int> = emptySet(),
) {
val availableStaticSlots: List<Int>
get() = (1 until dynamicScratchSlot).filter { it !in assignedStaticSlots }
val usedCount: Int
get() = assignedStaticSlots.size + 1 // assigned static slots + 1 dynamic scratch slot
val remainingStaticCount: Int
get() = availableStaticSlots.size
val isStaticFull: Boolean
get() = availableStaticSlots.isEmpty()
}
@@ -0,0 +1,56 @@
package com.flagship.abox.manager.domain.infrared
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import kotlinx.coroutines.flow.Flow
interface VirtualRemoteRepository {
fun observeRemotes(
profileId: ProviderProfileId,
deviceName: DeviceName,
): Flow<DomainResult<List<VirtualRemote>>>
fun observeRemote(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
): Flow<DomainResult<VirtualRemote?>>
fun observeKeyBindings(
remoteId: VirtualRemoteId,
): Flow<DomainResult<List<RemoteKeyBinding>>>
fun observeSlotQuota(
profileId: ProviderProfileId,
deviceName: DeviceName,
): Flow<DomainResult<SlotQuotaSummary>>
suspend fun getRemote(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
): DomainResult<VirtualRemote?>
suspend fun getKeyBindings(
remoteId: VirtualRemoteId,
): DomainResult<List<RemoteKeyBinding>>
suspend fun createRemote(
remote: VirtualRemote,
initialBindings: List<RemoteKeyBinding>,
): DomainResult<VirtualRemoteId>
suspend fun updateRemote(remote: VirtualRemote): DomainResult<Unit>
suspend fun deleteRemote(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
): DomainResult<Unit>
suspend fun updateKeyBinding(binding: RemoteKeyBinding): DomainResult<Unit>
suspend fun updateAcState(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
state: AcStateSnapshot,
): DomainResult<Unit>
}
@@ -202,6 +202,7 @@ class RecordingInfraredGateway : InfraredGateway {
var onLearn: ((ProviderProfileId, DeviceName, String) -> DomainResult<Unit>)? = null
val learnedKeys = mutableListOf<InfraredLearningCall>()
val sentKeys = mutableListOf<InfraredSendCall>()
val downloadedCodes = mutableListOf<InfraredCode>()
override suspend fun learnKey(
profileId: ProviderProfileId,
@@ -224,10 +225,110 @@ class RecordingInfraredGateway : InfraredGateway {
override suspend fun downloadCodes(
profileId: ProviderProfileId,
codes: List<InfraredCode>,
): DomainResult<List<InfraredCodeDownloadResult>> = DomainResult.Success(emptyList())
): DomainResult<List<InfraredCodeDownloadResult>> {
downloadedCodes += codes
return DomainResult.Success(codes.map { InfraredCodeDownloadResult(it, successful = true) })
}
}
class InMemoryVirtualRemoteRepository : VirtualRemoteRepository {
private val remotes = mutableMapOf<VirtualRemoteId, VirtualRemote>()
private val bindings = mutableMapOf<VirtualRemoteId, MutableList<RemoteKeyBinding>>()
private val state = MutableStateFlow<List<VirtualRemote>>(emptyList())
override fun observeRemotes(
profileId: ProviderProfileId,
deviceName: DeviceName,
): Flow<DomainResult<List<VirtualRemote>>> = state.map { values ->
DomainResult.Success(values.filter { it.profileId == profileId && it.deviceName == deviceName })
}
override fun observeRemote(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
): Flow<DomainResult<VirtualRemote?>> = state.map { values ->
DomainResult.Success(values.find { it.id == remoteId && it.profileId == profileId })
}
override fun observeKeyBindings(
remoteId: VirtualRemoteId,
): Flow<DomainResult<List<RemoteKeyBinding>>> = state.map {
DomainResult.Success(bindings[remoteId]?.toList().orEmpty())
}
override fun observeSlotQuota(
profileId: ProviderProfileId,
deviceName: DeviceName,
): Flow<DomainResult<SlotQuotaSummary>> = state.map { values ->
val relevant = values.filter { it.profileId == profileId && it.deviceName == deviceName }
val used = relevant.flatMap { bindings[it.id].orEmpty() }
.mapNotNull { it.assignedSlot?.toIntOrNull() }
.toSet()
DomainResult.Success(SlotQuotaSummary(assignedStaticSlots = used))
}
override suspend fun getRemote(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
): DomainResult<VirtualRemote?> = DomainResult.Success(remotes[remoteId]?.takeIf { it.profileId == profileId })
override suspend fun getKeyBindings(
remoteId: VirtualRemoteId,
): DomainResult<List<RemoteKeyBinding>> = DomainResult.Success(bindings[remoteId]?.toList().orEmpty())
override suspend fun createRemote(
remote: VirtualRemote,
initialBindings: List<RemoteKeyBinding>,
): DomainResult<VirtualRemoteId> {
remotes[remote.id] = remote
bindings[remote.id] = initialBindings.toMutableList()
publish()
return DomainResult.Success(remote.id)
}
override suspend fun updateRemote(remote: VirtualRemote): DomainResult<Unit> {
remotes[remote.id] = remote
publish()
return DomainResult.Success(Unit)
}
override suspend fun deleteRemote(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
): DomainResult<Unit> {
remotes.remove(remoteId)
bindings.remove(remoteId)
publish()
return DomainResult.Success(Unit)
}
override suspend fun updateKeyBinding(binding: RemoteKeyBinding): DomainResult<Unit> {
val list = bindings[binding.remoteId] ?: mutableListOf()
val idx = list.indexOfFirst { it.id == binding.id || it.buttonId == binding.buttonId }
if (idx >= 0) list[idx] = binding else list.add(binding)
bindings[binding.remoteId] = list
publish()
return DomainResult.Success(Unit)
}
override suspend fun updateAcState(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
state: AcStateSnapshot,
): DomainResult<Unit> {
val existing = remotes[remoteId] ?: return DomainResult.Success(Unit)
remotes[remoteId] = existing.copy(acState = state)
publish()
return DomainResult.Success(Unit)
}
private fun publish() {
state.value = remotes.values.toList()
}
}
fun SavedInfraredKey.ref() = InfraredKeyRef(
profileId = profileId,
keyId = id,
)
@@ -0,0 +1,119 @@
package com.flagship.abox.manager.domain.infrared
import com.flagship.abox.manager.domain.DeviceName
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.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class VirtualRemoteUseCaseTest {
private val profileId = ProviderProfileId("profile1")
private val deviceName = DeviceName("ir-1")
private val clock = EpochMillisClock { 10_000L }
private lateinit var repository: InMemoryVirtualRemoteRepository
private lateinit var gateway: RecordingInfraredGateway
private lateinit var createRemote: CreateVirtualRemote
private lateinit var sendAction: SendVirtualRemoteAction
private lateinit var deleteRemote: DeleteVirtualRemote
@Before
fun setup() {
repository = InMemoryVirtualRemoteRepository()
gateway = RecordingInfraredGateway()
createRemote = CreateVirtualRemote(repository, gateway, clock)
sendAction = SendVirtualRemoteAction(repository, gateway)
deleteRemote = DeleteVirtualRemote(repository)
}
@Test
fun createVirtualRemoteWithStaticSlotsDownloadsPresetCodesToHardware() = runTest {
val result = createRemote(
profileId = profileId,
deviceName = deviceName,
displayName = "Living Room TV",
type = RemoteDeviceType.TV,
brandModel = "generic_tv",
transmissionMode = TransmissionMode.STATIC_SLOTS,
)
assertTrue(result is DomainResult.Success)
val remoteId = (result as DomainResult.Success).value
// Check remote created in repository
val remote = (repository.getRemote(profileId, remoteId) as DomainResult.Success<VirtualRemote?>).value
assertNotNull(remote)
assertEquals("Living Room TV", remote?.displayName)
assertEquals(TransmissionMode.STATIC_SLOTS, remote?.transmissionMode)
// Check preset codes downloaded to hardware gateway
assertTrue(gateway.downloadedCodes.isNotEmpty())
// Send a button in static mode -> directly triggers sendKey with assigned slot
val sendRes = sendAction.sendButton(profileId, remoteId, "TV_POWER")
assertTrue(sendRes is DomainResult.Success)
assertEquals(1, gateway.sentKeys.size)
assertEquals("1", gateway.sentKeys.first().key)
}
@Test
fun sendVirtualRemoteActionForAcUpdatesStateAndDownloadsToScratchSlot() = runTest {
val createResult = createRemote(
profileId = profileId,
deviceName = deviceName,
displayName = "Bedroom AC",
type = RemoteDeviceType.AIR_CONDITIONER,
brandModel = "gree_ac",
transmissionMode = TransmissionMode.DYNAMIC_SCRATCH,
)
val remoteId = (createResult as DomainResult.Success<VirtualRemoteId>).value
val newAcState = AcStateSnapshot(
power = true,
mode = AcMode.COOL,
temperature = 27,
fanSpeed = AcFanSpeed.HIGH,
verticalSwing = true,
)
val sendRes = sendAction.sendAcState(profileId, remoteId, newAcState)
assertTrue(sendRes is DomainResult.Success)
// 1. Downloaded to scratch slot 100
assertEquals(1, gateway.downloadedCodes.size)
val downloaded = gateway.downloadedCodes.first()
assertEquals("100", downloaded.key)
assertTrue(downloaded.code.startsWith("0x"))
// 2. Transmitted via sendKey with slot 100
assertEquals(1, gateway.sentKeys.size)
assertEquals("100", gateway.sentKeys.first().key)
// 3. State machine updated in repository
val updatedRemote = (repository.getRemote(profileId, remoteId) as DomainResult.Success<VirtualRemote?>).value
assertEquals(27, updatedRemote?.acState?.temperature)
assertEquals(AcFanSpeed.HIGH, updatedRemote?.acState?.fanSpeed)
}
@Test
fun deleteVirtualRemoteRemovesFromRepository() = runTest {
val createResult = createRemote(
profileId = profileId,
deviceName = deviceName,
displayName = "Fan",
type = RemoteDeviceType.FAN,
transmissionMode = TransmissionMode.DYNAMIC_SCRATCH,
)
val remoteId = (createResult as DomainResult.Success<VirtualRemoteId>).value
deleteRemote(profileId, remoteId)
val fetched = (repository.getRemote(profileId, remoteId) as DomainResult.Success<VirtualRemote?>).value
assertEquals(null, fetched)
}
}