feat(infrared): refactor virtual remote hub with multi-device presets and quota management
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+108
@@ -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)
|
||||
}
|
||||
}
|
||||
+13
@@ -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"),
|
||||
)
|
||||
}
|
||||
}
|
||||
+85
@@ -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)
|
||||
}
|
||||
}
|
||||
+93
@@ -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()
|
||||
}
|
||||
+56
@@ -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>
|
||||
}
|
||||
+102
-1
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
+119
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user