Feat/moler infrared control (#9)

Co-authored-by: KremeCN <3265494849@qq.com>
Reviewed-on: #9
Co-authored-by: mxhsnd <2049295946@qq.com>
This commit was merged in pull request #9.
This commit is contained in:
2026-08-25 14:51:34 +08:00
committed by KremeCN
co-authored by KremeCN
parent 249721be33
commit 09c352457d
78 changed files with 12785 additions and 15 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,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,110 @@
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 = if (type == RemoteDeviceType.CUSTOM) TransmissionMode.STATIC_SLOTS else TransmissionMode.DYNAMIC_SCRATCH,
layoutStyle: PanelLayoutStyle? = null,
): DomainResult<VirtualRemoteId> {
val trimmedName = displayName.trim()
if (trimmedName.isBlank()) {
return DomainResult.Failure(
DomainError.InvalidRequest(detail = "Remote display name cannot be blank"),
)
}
val resolvedLayout = layoutStyle ?: VirtualRemote.defaultLayoutFor(type)
val resolvedTransMode = if (type == RemoteDeviceType.CUSTOM) {
TransmissionMode.STATIC_SLOTS
} else {
transmissionMode
}
val templates = PresetCodeLibrary.getTemplatesForType(type, brandModel, resolvedLayout)
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,
layoutStyle = resolvedLayout,
transmissionMode = resolvedTransMode,
dynamicScratchSlot = VirtualRemote.DEFAULT_DYNAMIC_SCRATCH_SLOT,
acState = initialAcState,
createdAtEpochMillis = now,
updatedAtEpochMillis = now,
)
// Build key bindings
val bindings = if (resolvedTransMode == TransmissionMode.STATIC_SLOTS) {
templates.mapIndexed { index, template ->
RemoteKeyBinding(
id = UUID.randomUUID().toString(),
remoteId = remoteId,
profileId = profileId,
buttonId = template.buttonId,
displayName = template.displayName,
assignedSlot = (index + 1).toString(),
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 (resolvedTransMode == 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,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,86 @@
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.ProviderProfileId
fun interface InfraredKeyIdGenerator {
fun nextId(): InfraredKeyId
}
fun interface EpochMillisClock {
fun now(): Long
}
class InfraredKeyFactory(
private val idGenerator: InfraredKeyIdGenerator,
private val clock: EpochMillisClock,
) {
fun create(
profileId: ProviderProfileId,
deviceName: DeviceName,
displayName: String,
sdkKey: String? = null,
): DomainResult<SavedInfraredKey> {
val normalizedName = displayName.trim()
if (
profileId.value.isBlank() ||
profileId.value != profileId.value.trim() ||
deviceName.value.isBlank() ||
deviceName.value != deviceName.value.trim() ||
normalizedName.isBlank()
) {
return DomainResult.Failure(
DomainError.InvalidRequest(detail = "Profile, device name, and display name are required"),
)
}
val id = idGenerator.nextId()
val canonicalId = canonicalizeId(id.value)
?: return DomainResult.Failure(
DomainError.InvalidRequest(detail = "Infrared key id is invalid"),
)
val finalSdkKey = if (sdkKey != null) {
val trimmed = sdkKey.trim()
val intVal = trimmed.toIntOrNull()
if (intVal == null || intVal !in 1..100) {
return DomainResult.Failure(
DomainError.InvalidRequest(detail = "Infrared key slot must be an integer between 1 and 100"),
)
}
intVal.toString()
} else {
"app_ir_$canonicalId"
}
val now = clock.now()
return DomainResult.Success(
SavedInfraredKey(
id = id,
profileId = profileId,
deviceName = deviceName,
displayName = normalizedName,
sdkKey = finalSdkKey,
status = InfraredKeyStatus.LEARNING,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = now,
updatedAtEpochMillis = now,
),
)
}
private fun canonicalizeId(value: String): String? = when {
ASCII_ALPHANUMERIC_ID.matches(value) -> value.lowercase()
STANDARD_UUID.matches(value) -> value.replace("-", "").lowercase()
else -> null
}
private companion object {
val ASCII_ALPHANUMERIC_ID = Regex("^[A-Za-z0-9]{1,32}$")
val STANDARD_UUID = Regex(
"^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$",
)
}
}
@@ -0,0 +1,38 @@
package com.flagship.abox.manager.domain.infrared
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfileId
@JvmInline
value class InfraredKeyId(val value: String)
data class InfraredKeyRef(
val profileId: ProviderProfileId,
val keyId: InfraredKeyId,
)
enum class InfraredKeyStatus {
LEARNING,
READY,
FAILED,
}
const val INTERRUPTED_LEARNING_ERROR_DETAIL =
"Interrupted learning result is unknown; retry required"
/**
* Persisted infrared key state. [lastErrorDetail] may contain only fixed, domain-owned,
* sanitized text; raw SDK maps, tokens, codes, and vendor payloads must never be stored there.
*/
data class SavedInfraredKey(
val id: InfraredKeyId,
val profileId: ProviderProfileId,
val deviceName: DeviceName,
val displayName: String,
val sdkKey: String,
val status: InfraredKeyStatus,
val lastErrorCode: String?,
val lastErrorDetail: String?,
val createdAtEpochMillis: Long,
val updatedAtEpochMillis: Long,
)
@@ -0,0 +1,53 @@
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 InfraredKeyRepository {
fun observeKeys(
profileId: ProviderProfileId,
deviceName: DeviceName,
): Flow<DomainResult<List<SavedInfraredKey>>>
suspend fun getKey(ref: InfraredKeyRef): DomainResult<SavedInfraredKey?>
suspend fun insert(key: SavedInfraredKey): DomainResult<Unit>
suspend fun transitionStatus(
ref: InfraredKeyRef,
expectedStatuses: Set<InfraredKeyStatus>,
targetStatus: InfraredKeyStatus,
lastErrorCode: String?,
lastErrorDetail: String?,
updatedAtEpochMillis: Long,
): DomainResult<Unit>
suspend fun rename(
ref: InfraredKeyRef,
displayName: String,
updatedAtEpochMillis: Long,
): DomainResult<Unit>
suspend fun deleteIfNotLearning(ref: InfraredKeyRef): DomainResult<Unit>
/**
* Atomically marks this profile's interrupted LEARNING keys created on or before [cutoffEpochMillis]
* as FAILED, clears the vendor error code, stores [INTERRUPTED_LEARNING_ERROR_DETAIL], and updates their timestamp.
*/
suspend fun failInterruptedLearning(
profileId: ProviderProfileId,
cutoffEpochMillis: Long = Long.MAX_VALUE,
updatedAtEpochMillis: Long = cutoffEpochMillis,
): DomainResult<Int>
}
interface InfraredDeviceConfigurationRepository {
fun observeDeviceName(profileId: ProviderProfileId): Flow<DomainResult<DeviceName?>>
suspend fun setDeviceName(
profileId: ProviderProfileId,
deviceName: DeviceName,
): DomainResult<Unit>
}
@@ -0,0 +1,246 @@
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.InfraredGateway
import com.flagship.abox.manager.domain.ProviderProfileId
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
class LearnInfraredKey(
private val repository: InfraredKeyRepository,
private val gateway: InfraredGateway,
private val factory: InfraredKeyFactory,
private val clock: EpochMillisClock,
) {
suspend operator fun invoke(
profileId: ProviderProfileId,
deviceName: DeviceName,
displayName: String,
sdkKey: String? = null,
): DomainResult<InfraredKeyId> {
val key = when (val result = factory.create(profileId, deviceName, displayName, sdkKey)) {
is DomainResult.Success -> result.value
is DomainResult.Failure -> return result
}
when (val result = repository.insert(key)) {
is DomainResult.Success -> Unit
is DomainResult.Failure -> return result
}
val result = try {
gateway.learnKey(
profileId = key.profileId,
deviceName = key.deviceName,
key = key.sdkKey,
)
} catch (e: CancellationException) {
withContext(NonCancellable) {
repository.transitionStatus(
ref = key.ref(),
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
targetStatus = InfraredKeyStatus.FAILED,
lastErrorCode = null,
lastErrorDetail = "Cancelled",
updatedAtEpochMillis = clock.now(),
)
}
throw e
}
return when (result) {
is DomainResult.Success -> when (
val transition = repository.transitionStatus(
ref = key.ref(),
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
targetStatus = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
updatedAtEpochMillis = clock.now(),
)
) {
is DomainResult.Success -> DomainResult.Success(key.id)
is DomainResult.Failure -> transition
}
is DomainResult.Failure -> {
repository.transitionStatus(
ref = key.ref(),
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
targetStatus = InfraredKeyStatus.FAILED,
lastErrorCode = result.error.externalCode,
lastErrorDetail = null,
updatedAtEpochMillis = clock.now(),
)
result
}
}
}
}
class RetryInfraredKeyLearning(
private val repository: InfraredKeyRepository,
private val gateway: InfraredGateway,
private val clock: EpochMillisClock,
) {
suspend operator fun invoke(ref: InfraredKeyRef): DomainResult<Unit> {
val key = when (val result = repository.getKey(ref)) {
is DomainResult.Success -> result.value
?: return DomainResult.Failure(
DomainError.InvalidRequest(detail = "Infrared key not found"),
)
is DomainResult.Failure -> return result
}
if (key.status == InfraredKeyStatus.LEARNING) {
return DomainResult.Failure(
DomainError.InvalidRequest(detail = "Infrared key is already learning"),
)
}
when (
val result = repository.transitionStatus(
ref = ref,
expectedStatuses = setOf(
InfraredKeyStatus.FAILED,
),
targetStatus = InfraredKeyStatus.LEARNING,
lastErrorCode = null,
lastErrorDetail = null,
updatedAtEpochMillis = clock.now(),
)
) {
is DomainResult.Success -> Unit
is DomainResult.Failure -> return result
}
val result = try {
gateway.learnKey(
profileId = key.profileId,
deviceName = key.deviceName,
key = key.sdkKey,
)
} catch (e: CancellationException) {
withContext(NonCancellable) {
repository.transitionStatus(
ref = ref,
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
targetStatus = InfraredKeyStatus.FAILED,
lastErrorCode = null,
lastErrorDetail = "Cancelled",
updatedAtEpochMillis = clock.now(),
)
}
throw e
}
return when (result) {
is DomainResult.Success -> repository.transitionStatus(
ref = ref,
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
targetStatus = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
updatedAtEpochMillis = clock.now(),
)
is DomainResult.Failure -> {
repository.transitionStatus(
ref = ref,
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
targetStatus = InfraredKeyStatus.FAILED,
lastErrorCode = result.error.externalCode,
lastErrorDetail = null,
updatedAtEpochMillis = clock.now(),
)
result
}
}
}
}
class RelearnInfraredKey(
private val repository: InfraredKeyRepository,
private val gateway: InfraredGateway,
private val clock: EpochMillisClock,
) {
suspend operator fun invoke(ref: InfraredKeyRef): DomainResult<Unit> {
val key = when (val result = repository.getKey(ref)) {
is DomainResult.Success -> result.value
?: return DomainResult.Failure(
DomainError.InvalidRequest(detail = "Infrared key not found"),
)
is DomainResult.Failure -> return result
}
when (
val result = repository.transitionStatus(
ref = ref,
expectedStatuses = setOf(
InfraredKeyStatus.READY,
InfraredKeyStatus.FAILED,
InfraredKeyStatus.LEARNING,
),
targetStatus = InfraredKeyStatus.LEARNING,
lastErrorCode = null,
lastErrorDetail = null,
updatedAtEpochMillis = clock.now(),
)
) {
is DomainResult.Success -> Unit
is DomainResult.Failure -> return result
}
val result = try {
gateway.learnKey(
profileId = key.profileId,
deviceName = key.deviceName,
key = key.sdkKey,
)
} catch (e: CancellationException) {
withContext(NonCancellable) {
repository.transitionStatus(
ref = ref,
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
targetStatus = InfraredKeyStatus.FAILED,
lastErrorCode = null,
lastErrorDetail = "Cancelled",
updatedAtEpochMillis = clock.now(),
)
}
throw e
}
return when (result) {
is DomainResult.Success -> repository.transitionStatus(
ref = ref,
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
targetStatus = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
updatedAtEpochMillis = clock.now(),
)
is DomainResult.Failure -> {
repository.transitionStatus(
ref = ref,
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
targetStatus = InfraredKeyStatus.FAILED,
lastErrorCode = result.error.externalCode,
lastErrorDetail = null,
updatedAtEpochMillis = clock.now(),
)
result
}
}
}
}
private fun SavedInfraredKey.ref() = InfraredKeyRef(
profileId = profileId,
keyId = id,
)
@@ -0,0 +1,70 @@
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 = binding.assignedSlot ?: run {
val allBindings = (repository.getKeyBindings(remoteId) as? DomainResult.Success)?.value.orEmpty()
val usedSlots = allBindings.mapNotNull { it.assignedSlot?.toIntOrNull() }.toSet()
val nextSlot = (1..99).firstOrNull { it !in usedSlots } ?: 1
nextSlot.toString()
}
// 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,65 @@
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 SendInfraredKey(
private val repository: InfraredKeyRepository,
private val gateway: InfraredGateway,
) {
suspend operator fun invoke(ref: InfraredKeyRef): DomainResult<Unit> {
val key = when (val result = repository.getKey(ref)) {
is DomainResult.Success -> result.value
?: return DomainResult.Failure(
DomainError.NoData(detail = "Infrared key not found"),
)
is DomainResult.Failure -> return result
}
if (key.status != InfraredKeyStatus.READY) {
return DomainResult.Failure(
DomainError.InvalidRequest(detail = "Infrared key is not ready"),
)
}
return gateway.sendKey(
profileId = key.profileId,
deviceName = key.deviceName,
key = key.sdkKey,
)
}
}
class RenameInfraredKey(
private val repository: InfraredKeyRepository,
private val clock: EpochMillisClock,
) {
suspend operator fun invoke(
ref: InfraredKeyRef,
displayName: String,
): DomainResult<Unit> {
val trimmed = displayName.trim()
if (trimmed.isEmpty()) {
return DomainResult.Failure(
DomainError.InvalidRequest(detail = "Infrared key display name is required"),
)
}
return repository.rename(ref, trimmed, clock.now())
}
}
class DeleteInfraredKey(
private val repository: InfraredKeyRepository,
) {
suspend operator fun invoke(ref: InfraredKeyRef): DomainResult<Unit> =
repository.deleteIfNotLearning(ref)
}
class RecoverInterruptedInfraredLearning(
private val repository: InfraredKeyRepository,
private val clock: EpochMillisClock,
) {
suspend operator fun invoke(profileId: ProviderProfileId): DomainResult<Int> =
repository.failInterruptedLearning(profileId, clock.now())
}
@@ -0,0 +1,101 @@
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,
layoutStyle: PanelLayoutStyle? = null,
): List<PresetButtonTemplate> = when (type) {
RemoteDeviceType.TV -> getTvTemplates(hasHexCode = true)
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 -> when (layoutStyle) {
PanelLayoutStyle.TV_NAV -> getTvTemplates(hasHexCode = false)
PanelLayoutStyle.MEDIA_PLAYER -> getMediaPlayerTemplates(hasHexCode = false)
PanelLayoutStyle.AC_COMPACT -> emptyList()
PanelLayoutStyle.GRID, null -> listOf(
PresetButtonTemplate("CUSTOM_POWER", "电源"),
PresetButtonTemplate("CUSTOM_KEY_1", "按键 1"),
PresetButtonTemplate("CUSTOM_KEY_2", "按键 2"),
PresetButtonTemplate("CUSTOM_KEY_3", "按键 3"),
)
}
}
private fun getTvTemplates(hasHexCode: Boolean): List<PresetButtonTemplate> = listOf(
PresetButtonTemplate("TV_POWER", "电源", if (hasHexCode) "0x00FF02FD" else null),
PresetButtonTemplate("TV_MUTE", "静音", if (hasHexCode) "0x00FF04FB" else null),
PresetButtonTemplate("TV_SOURCE", "信号源", if (hasHexCode) "0x00FF06F9" else null),
PresetButtonTemplate("TV_NAV_UP", "上", if (hasHexCode) "0x00FF08F7" else null),
PresetButtonTemplate("TV_NAV_DOWN", "下", if (hasHexCode) "0x00FF0AF5" else null),
PresetButtonTemplate("TV_NAV_LEFT", "左", if (hasHexCode) "0x00FF0CF3" else null),
PresetButtonTemplate("TV_NAV_RIGHT", "右", if (hasHexCode) "0x00FF0EF1" else null),
PresetButtonTemplate("TV_OK", "确定", if (hasHexCode) "0x00FF10EF" else null),
PresetButtonTemplate("TV_HOME", "主页", if (hasHexCode) "0x00FF12ED" else null),
PresetButtonTemplate("TV_BACK", "返回", if (hasHexCode) "0x00FF14EB" else null),
PresetButtonTemplate("TV_MENU", "菜单", if (hasHexCode) "0x00FF16E9" else null),
PresetButtonTemplate("TV_VOL_UP", "音量 +", if (hasHexCode) "0x00FF18E7" else null),
PresetButtonTemplate("TV_VOL_DOWN", "音量 -", if (hasHexCode) "0x00FF1AE5" else null),
PresetButtonTemplate("TV_CH_UP", "频道 +", if (hasHexCode) "0x00FF1CE3" else null),
PresetButtonTemplate("TV_CH_DOWN", "频道 -", if (hasHexCode) "0x00FF1EE1" else null),
)
private fun getMediaPlayerTemplates(hasHexCode: Boolean): List<PresetButtonTemplate> = listOf(
PresetButtonTemplate("MEDIA_POWER", "电源", if (hasHexCode) "0x02FD02FD" else null),
PresetButtonTemplate("MEDIA_MUTE", "静音", if (hasHexCode) "0x02FD04FB" else null),
PresetButtonTemplate("MEDIA_PLAY_PAUSE", "播放/暂停", if (hasHexCode) "0x02FD06F9" else null),
PresetButtonTemplate("MEDIA_PREV", "上一曲", if (hasHexCode) "0x02FD08F7" else null),
PresetButtonTemplate("MEDIA_NEXT", "下一曲", if (hasHexCode) "0x02FD0AF5" else null),
PresetButtonTemplate("MEDIA_REWIND", "快退", if (hasHexCode) "0x02FD0CF3" else null),
PresetButtonTemplate("MEDIA_FF", "快进", if (hasHexCode) "0x02FD0EF1" else null),
)
}
@@ -0,0 +1,90 @@
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 -> {
if (binding.rawHexCode != null) {
// 1. Download to scratch slot
val scratchSlot = remote.dynamicScratchSlot
when (val dlResult = gateway.downloadCodes(profileId, listOf(InfraredCode(remote.deviceName, scratchSlot, binding.rawHexCode)))) {
is DomainResult.Failure -> return dlResult
is DomainResult.Success -> Unit
}
// 2. Send scratch slot
gateway.sendKey(profileId, remote.deviceName, scratchSlot)
} else if (binding.assignedSlot != null) {
// Fallback to sending learned hardware slot
gateway.sendKey(profileId, remote.deviceName, binding.assignedSlot)
} else {
DomainResult.Failure(DomainError.InvalidRequest(detail = "No hex code or slot available for transmission"))
}
}
}
}
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,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)
}
}
@@ -0,0 +1,108 @@
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 PanelLayoutStyle {
TV_NAV,
GRID,
MEDIA_PLAYER,
AC_COMPACT,
}
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 layoutStyle: PanelLayoutStyle = defaultLayoutFor(type),
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"
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
}
}
}
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,58 @@
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 deleteKeyBinding(remoteId: VirtualRemoteId, buttonId: String): DomainResult<Unit>
suspend fun updateAcState(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
state: AcStateSnapshot,
): DomainResult<Unit>
}
@@ -0,0 +1,345 @@
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.InfraredCodeDownloadResult
import com.flagship.abox.manager.domain.InfraredGateway
import com.flagship.abox.manager.domain.ProviderProfileId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
data class InfraredLearningCall(
val profileId: ProviderProfileId,
val deviceName: DeviceName,
val key: String,
)
data class InfraredStatusTransitionCall(
val ref: InfraredKeyRef,
val expectedStatuses: Set<InfraredKeyStatus>,
val targetStatus: InfraredKeyStatus,
val lastErrorCode: String?,
val lastErrorDetail: String?,
val updatedAtEpochMillis: Long,
)
data class InfraredSendCall(
val profileId: ProviderProfileId,
val deviceName: DeviceName,
val key: String,
)
data class InfraredRenameCall(
val ref: InfraredKeyRef,
val displayName: String,
val updatedAtEpochMillis: Long,
)
data class InfraredRecoveryCall(
val profileId: ProviderProfileId,
val updatedAtEpochMillis: Long,
)
class InMemoryInfraredKeyRepository : InfraredKeyRepository {
private val keys = linkedMapOf<InfraredKeyRef, SavedInfraredKey>()
private val state = MutableStateFlow<List<SavedInfraredKey>>(emptyList())
var getError: DomainError? = null
var insertError: DomainError? = null
var renameError: DomainError? = null
var deleteError: DomainError? = null
var recoveryError: DomainError? = null
val transitionErrors = mutableMapOf<InfraredKeyStatus, DomainError>()
var onInsert: ((SavedInfraredKey) -> Unit)? = null
var onTransition: ((InfraredStatusTransitionCall) -> Unit)? = null
val insertedKeys = mutableListOf<SavedInfraredKey>()
val getCalls = mutableListOf<InfraredKeyRef>()
val transitionCalls = mutableListOf<InfraredStatusTransitionCall>()
val renameCalls = mutableListOf<InfraredRenameCall>()
val deleteCalls = mutableListOf<InfraredKeyRef>()
val recoveryCalls = mutableListOf<InfraredRecoveryCall>()
override fun observeKeys(
profileId: ProviderProfileId,
deviceName: DeviceName,
): Flow<DomainResult<List<SavedInfraredKey>>> = state.map { values ->
DomainResult.Success(
values.filter { it.profileId == profileId && it.deviceName == deviceName },
)
}
override suspend fun getKey(ref: InfraredKeyRef): DomainResult<SavedInfraredKey?> {
getCalls += ref
getError?.let { return DomainResult.Failure(it) }
return DomainResult.Success(keys[ref])
}
override suspend fun insert(key: SavedInfraredKey): DomainResult<Unit> {
insertedKeys += key
onInsert?.invoke(key)
insertError?.let { return DomainResult.Failure(it) }
keys[key.ref()] = key
publish()
return DomainResult.Success(Unit)
}
override suspend fun transitionStatus(
ref: InfraredKeyRef,
expectedStatuses: Set<InfraredKeyStatus>,
targetStatus: InfraredKeyStatus,
lastErrorCode: String?,
lastErrorDetail: String?,
updatedAtEpochMillis: Long,
): DomainResult<Unit> {
val call = InfraredStatusTransitionCall(
ref = ref,
expectedStatuses = expectedStatuses,
targetStatus = targetStatus,
lastErrorCode = lastErrorCode,
lastErrorDetail = lastErrorDetail,
updatedAtEpochMillis = updatedAtEpochMillis,
)
transitionCalls += call
onTransition?.invoke(call)
transitionErrors[targetStatus]?.let { return DomainResult.Failure(it) }
val existing = keys[ref]
?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Infrared key not found"))
if (existing.status !in expectedStatuses) {
return DomainResult.Failure(
DomainError.InvalidRequest(detail = "Infrared key status changed"),
)
}
keys[ref] = existing.copy(
status = targetStatus,
lastErrorCode = lastErrorCode,
lastErrorDetail = lastErrorDetail,
updatedAtEpochMillis = updatedAtEpochMillis,
)
publish()
return DomainResult.Success(Unit)
}
override suspend fun rename(
ref: InfraredKeyRef,
displayName: String,
updatedAtEpochMillis: Long,
): DomainResult<Unit> {
renameCalls += InfraredRenameCall(ref, displayName, updatedAtEpochMillis)
renameError?.let { return DomainResult.Failure(it) }
val existing = keys[ref]
?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Infrared key not found"))
keys[ref] = existing.copy(
displayName = displayName,
updatedAtEpochMillis = updatedAtEpochMillis,
)
publish()
return DomainResult.Success(Unit)
}
override suspend fun deleteIfNotLearning(ref: InfraredKeyRef): DomainResult<Unit> {
deleteCalls += ref
deleteError?.let { return DomainResult.Failure(it) }
val existing = keys[ref]
?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Infrared key not found"))
if (existing.status == InfraredKeyStatus.LEARNING) {
return DomainResult.Failure(
DomainError.InvalidRequest(detail = "Cannot delete a learning infrared key"),
)
}
keys.remove(ref)
publish()
return DomainResult.Success(Unit)
}
override suspend fun failInterruptedLearning(
profileId: ProviderProfileId,
cutoffEpochMillis: Long,
updatedAtEpochMillis: Long,
): DomainResult<Int> {
recoveryCalls += InfraredRecoveryCall(profileId, updatedAtEpochMillis)
recoveryError?.let { return DomainResult.Failure(it) }
var changed = 0
keys.replaceAll { _, key ->
if (key.profileId == profileId && key.status == InfraredKeyStatus.LEARNING && key.createdAtEpochMillis <= cutoffEpochMillis) {
changed += 1
key.copy(
status = InfraredKeyStatus.FAILED,
lastErrorCode = null,
lastErrorDetail = INTERRUPTED_LEARNING_ERROR_DETAIL,
updatedAtEpochMillis = updatedAtEpochMillis,
)
} else {
key
}
}
publish()
return DomainResult.Success(changed)
}
fun seed(key: SavedInfraredKey) {
keys[key.ref()] = key
publish()
}
fun onlyKey(): SavedInfraredKey = keys.values.single()
fun allKeys(): List<SavedInfraredKey> = keys.values.toList()
private fun publish() {
state.value = keys.values.toList()
}
}
class RecordingInfraredGateway : InfraredGateway {
var learnResult: DomainResult<Unit> = DomainResult.Success(Unit)
var sendResult: DomainResult<Unit> = DomainResult.Success(Unit)
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,
deviceName: DeviceName,
key: String,
): DomainResult<Unit> {
learnedKeys += InfraredLearningCall(profileId, deviceName, key)
return onLearn?.invoke(profileId, deviceName, key) ?: learnResult
}
override suspend fun sendKey(
profileId: ProviderProfileId,
deviceName: DeviceName,
key: String,
): DomainResult<Unit> {
sentKeys += InfraredSendCall(profileId, deviceName, key)
return sendResult
}
override suspend fun downloadCodes(
profileId: ProviderProfileId,
codes: List<InfraredCode>,
): 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 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,
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,89 @@
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 org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class InfraredKeyFactoryTest {
private val id = InfraredKeyId("01J5MOLERABC123")
private val factory = InfraredKeyFactory(
idGenerator = InfraredKeyIdGenerator { id },
clock = EpochMillisClock { 1_724_435_200_000L },
)
@Test
fun createsLearningKeyWithStableAsciiSdkKey() {
val result = factory.create(
ProviderProfileId("abox"),
DeviceName("ir-1"),
"空调开关",
) as DomainResult.Success
assertEquals(id, result.value.id)
assertEquals("app_ir_01j5molerabc123", result.value.sdkKey)
assertEquals(InfraredKeyStatus.LEARNING, result.value.status)
assertEquals(null, result.value.lastErrorCode)
assertEquals(null, result.value.lastErrorDetail)
assertEquals("空调开关", result.value.displayName)
assertEquals(1_724_435_200_000L, result.value.createdAtEpochMillis)
assertEquals(result.value.createdAtEpochMillis, result.value.updatedAtEpochMillis)
}
@Test
fun createsLearningKeyWithCustomNumericSlot() {
val result = factory.create(
ProviderProfileId("abox"),
DeviceName("ir-1"),
"空调开",
sdkKey = "1",
) as DomainResult.Success
assertEquals("1", result.value.sdkKey)
}
@Test
fun rejectsInvalidNumericSlots() {
assertTrue(factory.create(ProviderProfileId("abox"), DeviceName("ir-1"), "Power", sdkKey = "0") is DomainResult.Failure)
assertTrue(factory.create(ProviderProfileId("abox"), DeviceName("ir-1"), "Power", sdkKey = "101") is DomainResult.Failure)
assertTrue(factory.create(ProviderProfileId("abox"), DeviceName("ir-1"), "Power", sdkKey = "abc") is DomainResult.Failure)
}
@Test
fun rejectsBlankDeviceOrDisplayName() {
assertTrue(factory.create(ProviderProfileId("abox"), DeviceName(""), "Power") is DomainResult.Failure)
assertTrue(factory.create(ProviderProfileId("abox"), DeviceName("ir-1"), " ") is DomainResult.Failure)
}
@Test
fun rejectsIdentifiersWithSurroundingWhitespace() {
assertTrue(factory.create(ProviderProfileId(" abox"), DeviceName("ir-1"), "Power") is DomainResult.Failure)
assertTrue(factory.create(ProviderProfileId("abox"), DeviceName("ir-1 "), "Power") is DomainResult.Failure)
}
@Test
fun rejectsLossyOrOversizedIds() {
assertTrue(factoryWithId("ab-c").createValidKey() is DomainResult.Failure)
assertTrue(factoryWithId("a".repeat(33)).createValidKey() is DomainResult.Failure)
}
@Test
fun canonicalizesStandardUuidId() {
val result = factoryWithId("550E8400-E29B-41D4-A716-446655440000").createValidKey() as DomainResult.Success
assertEquals("app_ir_550e8400e29b41d4a716446655440000", result.value.sdkKey)
}
private fun factoryWithId(value: String) = InfraredKeyFactory(
idGenerator = InfraredKeyIdGenerator { InfraredKeyId(value) },
clock = EpochMillisClock { 1_724_435_200_000L },
)
private fun InfraredKeyFactory.createValidKey() = create(
ProviderProfileId("abox"),
DeviceName("ir-1"),
"Power",
)
}
@@ -0,0 +1,343 @@
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.ProviderProfileId
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class LearnInfraredKeyTest {
private val profile = ProviderProfileId("abox")
private val device = DeviceName("ir-1")
private val keyId = InfraredKeyId("key1")
private val clock = SteppingClock(100L)
private val factory = InfraredKeyFactory(
idGenerator = InfraredKeyIdGenerator { keyId },
clock = clock,
)
private val repository = InMemoryInfraredKeyRepository()
private val gateway = RecordingInfraredGateway()
private val learn = LearnInfraredKey(repository, gateway, factory, clock)
private val retry = RetryInfraredKeyLearning(repository, gateway, clock)
private val relearn = RelearnInfraredKey(repository, gateway, clock)
@Test
fun persistsLearningBeforeCallingHardwareThenMarksReady() = runTest {
val events = mutableListOf<String>()
repository.onInsert = { events += "insert:${it.status}" }
gateway.onLearn = { _, _, _ ->
events += "hardware"
DomainResult.Success(Unit)
}
repository.onTransition = { events += "transition:${it.targetStatus}" }
val result = learn(profile, device, "Power")
assertEquals(DomainResult.Success(keyId), result)
assertEquals(listOf("insert:LEARNING", "hardware", "transition:READY"), events)
assertEquals(InfraredKeyStatus.READY, repository.onlyKey().status)
assertEquals(null, repository.onlyKey().lastErrorCode)
assertEquals(null, repository.onlyKey().lastErrorDetail)
}
@Test
fun hardwareFailureMarksFailedAndReturnsOriginalError() = runTest {
val gatewayError = DomainError.DeviceOffline("20206")
gateway.learnResult = DomainResult.Failure(gatewayError)
val result = learn(profile, device, "Power")
assertEquals(gatewayError, (result as DomainResult.Failure).error)
assertEquals(InfraredKeyStatus.FAILED, repository.onlyKey().status)
assertEquals("20206", repository.onlyKey().lastErrorCode)
assertEquals(null, repository.onlyKey().lastErrorDetail)
}
@Test
fun insertFailureNeverCallsHardware() = runTest {
val insertError = DomainError.Unknown(detail = "database unavailable")
repository.insertError = insertError
val result = learn(profile, device, "Power")
assertEquals(insertError, (result as DomainResult.Failure).error)
assertTrue(gateway.learnedKeys.isEmpty())
assertTrue(repository.transitionCalls.isEmpty())
}
@Test
fun factoryFailureNeverTouchesRepositoryOrHardware() = runTest {
val result = learn(profile, device, " ")
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
assertTrue(repository.insertedKeys.isEmpty())
assertTrue(repository.transitionCalls.isEmpty())
assertTrue(gateway.learnedKeys.isEmpty())
}
@Test
fun readyTransitionFailureIsReturnedAfterHardwareSuccess() = runTest {
val transitionError = DomainError.Unknown(detail = "write conflict")
repository.transitionErrors[InfraredKeyStatus.READY] = transitionError
val result = learn(profile, device, "Power")
assertEquals(transitionError, (result as DomainResult.Failure).error)
assertEquals(InfraredKeyStatus.LEARNING, repository.onlyKey().status)
assertEquals(1, gateway.learnedKeys.size)
}
@Test
fun failedTransitionErrorDoesNotReplaceGatewayFailure() = runTest {
val gatewayError = DomainError.DeviceOffline("20206")
gateway.learnResult = DomainResult.Failure(gatewayError)
repository.transitionErrors[InfraredKeyStatus.FAILED] =
DomainError.Unknown(detail = "write conflict")
val result = learn(profile, device, "Power")
assertEquals(gatewayError, (result as DomainResult.Failure).error)
assertEquals(InfraredKeyStatus.LEARNING, repository.onlyKey().status)
assertEquals("20206", repository.transitionCalls.single().lastErrorCode)
assertEquals(null, repository.transitionCalls.single().lastErrorDetail)
}
@Test
fun newLearningUsesExpectedLearningTransitionsAndFreshTimestamps() = runTest {
learn(profile, device, "Power")
val inserted = repository.insertedKeys.single()
val transition = repository.transitionCalls.single()
assertEquals(setOf(InfraredKeyStatus.LEARNING), transition.expectedStatuses)
assertEquals(inserted.ref(), transition.ref)
assertEquals(100L, inserted.createdAtEpochMillis)
assertEquals(100L, inserted.updatedAtEpochMillis)
assertEquals(101L, transition.updatedAtEpochMillis)
}
@Test
fun retryReusesStableIdAndSdkKey() = runTest {
val original = failedKey(id = InfraredKeyId("key1"), sdkKey = "app_ir_key1")
repository.seed(original)
val result = retry(original.ref())
assertEquals(DomainResult.Success(Unit), result)
assertEquals("app_ir_key1", gateway.learnedKeys.single().key)
assertEquals(InfraredKeyId("key1"), repository.onlyKey().id)
assertEquals(InfraredKeyStatus.READY, repository.onlyKey().status)
assertEquals(0, repository.insertedKeys.size)
assertEquals(2, repository.transitionCalls.size)
assertEquals(
setOf(InfraredKeyStatus.FAILED),
repository.transitionCalls.first().expectedStatuses,
)
assertEquals(
setOf(InfraredKeyStatus.LEARNING),
repository.transitionCalls.last().expectedStatuses,
)
}
@Test
fun relearnReusesStableIdAndSdkKeyForReadyKey() = runTest {
val original = failedKey(id = InfraredKeyId("key1"), sdkKey = "app_ir_key1").copy(
status = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
)
repository.seed(original)
val result = relearn(original.ref())
assertEquals(DomainResult.Success(Unit), result)
assertEquals("app_ir_key1", gateway.learnedKeys.single().key)
assertEquals(InfraredKeyId("key1"), repository.onlyKey().id)
assertEquals(InfraredKeyStatus.READY, repository.onlyKey().status)
assertEquals(0, repository.insertedKeys.size)
assertEquals(2, repository.transitionCalls.size)
assertEquals(
setOf(InfraredKeyStatus.READY, InfraredKeyStatus.FAILED, InfraredKeyStatus.LEARNING),
repository.transitionCalls.first().expectedStatuses,
)
assertEquals(
setOf(InfraredKeyStatus.LEARNING),
repository.transitionCalls.last().expectedStatuses,
)
}
@Test
fun retryRejectsAlreadyLearningKey() = runTest {
repository.seed(learningKey())
val result = retry(repository.onlyKey().ref())
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
assertTrue(gateway.learnedKeys.isEmpty())
assertTrue(repository.transitionCalls.isEmpty())
}
@Test
fun retryRejectsMissingKey() = runTest {
val result = retry(InfraredKeyRef(profile, InfraredKeyId("missing")))
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
assertTrue(gateway.learnedKeys.isEmpty())
assertTrue(repository.transitionCalls.isEmpty())
}
@Test
fun retryReturnsGetFailureWithoutCallingHardware() = runTest {
val getError = DomainError.Unknown(detail = "database unavailable")
repository.getError = getError
val result = retry(InfraredKeyRef(profile, keyId))
assertEquals(getError, (result as DomainResult.Failure).error)
assertTrue(gateway.learnedKeys.isEmpty())
assertTrue(repository.transitionCalls.isEmpty())
}
@Test
fun retryDoesNotCallHardwareWhenLearningTransitionFails() = runTest {
val original = failedKey()
repository.seed(original)
val transitionError = DomainError.Unknown(detail = "write conflict")
repository.transitionErrors[InfraredKeyStatus.LEARNING] = transitionError
val result = retry(original.ref())
assertEquals(transitionError, (result as DomainResult.Failure).error)
assertTrue(gateway.learnedKeys.isEmpty())
assertEquals(InfraredKeyStatus.FAILED, repository.onlyKey().status)
}
@Test
fun retryExpectedStatusFailurePreventsHardwareAfterConcurrentStatusChange() = runTest {
val original = failedKey()
repository.seed(original)
repository.onTransition = { call ->
if (call.targetStatus == InfraredKeyStatus.LEARNING) {
repository.seed(original.copy(status = InfraredKeyStatus.LEARNING))
}
}
val result = retry(original.ref())
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
assertTrue(gateway.learnedKeys.isEmpty())
assertEquals(InfraredKeyStatus.LEARNING, repository.onlyKey().status)
}
@Test
fun retryUsesSeparateTimestampsAndClearsErrorsBeforeHardware() = runTest {
val original = failedKey()
repository.seed(original)
gateway.onLearn = { _, _, _ ->
assertEquals(InfraredKeyStatus.LEARNING, repository.onlyKey().status)
assertEquals(null, repository.onlyKey().lastErrorCode)
assertEquals(null, repository.onlyKey().lastErrorDetail)
assertEquals(100L, repository.onlyKey().updatedAtEpochMillis)
DomainResult.Success(Unit)
}
assertEquals(DomainResult.Success(Unit), retry(original.ref()))
assertEquals(InfraredKeyStatus.READY, repository.onlyKey().status)
assertEquals(null, repository.onlyKey().lastErrorCode)
assertEquals(null, repository.onlyKey().lastErrorDetail)
assertEquals(101L, repository.onlyKey().updatedAtEpochMillis)
assertEquals(listOf(100L, 101L), repository.transitionCalls.map { it.updatedAtEpochMillis })
}
@Test
fun retryGatewayFailureMarksFailedWithExternalCodeAndReturnsOriginalError() = runTest {
val original = failedKey()
repository.seed(original)
val gatewayError = DomainError.DeviceOffline("20206")
gateway.learnResult = DomainResult.Failure(gatewayError)
val result = retry(original.ref())
assertEquals(gatewayError, (result as DomainResult.Failure).error)
assertEquals(InfraredKeyStatus.FAILED, repository.onlyKey().status)
assertEquals("20206", repository.onlyKey().lastErrorCode)
assertEquals(null, repository.onlyKey().lastErrorDetail)
}
@Test
fun retryFailedTransitionErrorDoesNotReplaceGatewayFailure() = runTest {
val original = failedKey()
repository.seed(original)
val gatewayError = DomainError.DeviceOffline("20206")
gateway.learnResult = DomainResult.Failure(gatewayError)
repository.transitionErrors[InfraredKeyStatus.FAILED] =
DomainError.Unknown(detail = "write conflict")
val result = retry(original.ref())
assertEquals(gatewayError, (result as DomainResult.Failure).error)
assertEquals(InfraredKeyStatus.LEARNING, repository.onlyKey().status)
assertEquals("20206", repository.transitionCalls.last().lastErrorCode)
assertEquals(null, repository.transitionCalls.last().lastErrorDetail)
}
@Test
fun retryReadyTransitionFailureIsReturnedAfterHardwareSuccess() = runTest {
val original = failedKey()
repository.seed(original)
val transitionError = DomainError.Unknown(detail = "write conflict")
repository.transitionErrors[InfraredKeyStatus.READY] = transitionError
val result = retry(original.ref())
assertEquals(transitionError, (result as DomainResult.Failure).error)
assertEquals(InfraredKeyStatus.LEARNING, repository.onlyKey().status)
assertEquals(1, gateway.learnedKeys.size)
}
@Test
fun learningCancellationTransitionsStatusToFailed() = runTest {
gateway.onLearn = { _, _, _ ->
throw kotlinx.coroutines.CancellationException("User cancelled")
}
try {
learn(profile, device, "Power")
} catch (_: kotlinx.coroutines.CancellationException) {
// expected
}
assertEquals(InfraredKeyStatus.FAILED, repository.onlyKey().status)
assertEquals("Cancelled", repository.onlyKey().lastErrorDetail)
}
private fun failedKey(
id: InfraredKeyId = keyId,
sdkKey: String = "app_ir_key1",
) = SavedInfraredKey(
id = id,
profileId = profile,
deviceName = device,
displayName = "Power",
sdkKey = sdkKey,
status = InfraredKeyStatus.FAILED,
lastErrorCode = "old-error",
lastErrorDetail = "old-detail",
createdAtEpochMillis = 1L,
updatedAtEpochMillis = 2L,
)
private fun learningKey() = failedKey().copy(
status = InfraredKeyStatus.LEARNING,
lastErrorCode = null,
lastErrorDetail = null,
)
}
private class SteppingClock(start: Long) : EpochMillisClock {
private var next = start
override fun now(): Long = next++
}
@@ -0,0 +1,251 @@
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.ProviderProfileId
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ManageInfraredKeyTest {
private val profile = ProviderProfileId("abox")
private val otherProfile = ProviderProfileId("other")
private val repository = InMemoryInfraredKeyRepository()
private val gateway = RecordingInfraredGateway()
private val clock = SteppingManagementClock(500L)
private val send = SendInfraredKey(repository, gateway)
private val rename = RenameInfraredKey(repository, clock)
private val delete = DeleteInfraredKey(repository)
private val recover = RecoverInterruptedInfraredLearning(repository, clock)
@Test
fun sendsReadyKeyUsingStoredDeviceAndSdkKeyWithoutRepositoryWrites() = runTest {
val key = readyKey(deviceName = DeviceName("ir-living"), sdkKey = "app_ir_power")
repository.seed(key)
assertEquals(DomainResult.Success(Unit), send(key.ref()))
assertEquals(
InfraredSendCall(profile, DeviceName("ir-living"), "app_ir_power"),
gateway.sentKeys.single(),
)
assertEquals(listOf(key.ref()), repository.getCalls)
assertTrue(repository.transitionCalls.isEmpty())
assertTrue(repository.renameCalls.isEmpty())
assertTrue(repository.deleteCalls.isEmpty())
assertTrue(repository.recoveryCalls.isEmpty())
assertEquals(key, repository.onlyKey())
}
@Test
fun sendFailureDoesNotDemoteReadyKey() = runTest {
val key = readyKey()
repository.seed(key)
val gatewayError = DomainError.OperationFailed("20205")
gateway.sendResult = DomainResult.Failure(gatewayError)
assertEquals(gatewayError, (send(key.ref()) as DomainResult.Failure).error)
assertEquals(
listOf(InfraredSendCall(profile, DeviceName("ir-1"), "app_ir_key1")),
gateway.sentKeys,
)
assertEquals(InfraredKeyStatus.READY, repository.onlyKey().status)
assertTrue(repository.transitionCalls.isEmpty())
}
@Test
fun missingSendReturnsNoDataWithoutHardwareCall() = runTest {
val result = send(InfraredKeyRef(profile, InfraredKeyId("missing")))
assertTrue((result as DomainResult.Failure).error is DomainError.NoData)
assertEquals(
listOf(InfraredKeyRef(profile, InfraredKeyId("missing"))),
repository.getCalls,
)
assertTrue(gateway.sentKeys.isEmpty())
}
@Test
fun nonReadySendReturnsInvalidRequestWithoutHardwareCall() = runTest {
repository.seed(failedKey())
val result = send(repository.onlyKey().ref())
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
assertTrue(gateway.sentKeys.isEmpty())
}
@Test
fun sendReturnsRepositoryFailureWithoutHardwareCall() = runTest {
val repositoryError = DomainError.Unknown(detail = "database unavailable")
repository.getError = repositoryError
val result = send(InfraredKeyRef(profile, InfraredKeyId("key1")))
assertEquals(repositoryError, (result as DomainResult.Failure).error)
assertTrue(gateway.sentKeys.isEmpty())
}
@Test
fun renameTrimsDisplayNameAndPreservesStableIdentityAndErrors() = runTest {
val before = readyKey(displayName = "Power", sdkKey = "app_ir_power").copy(
status = InfraredKeyStatus.FAILED,
lastErrorCode = "20206",
lastErrorDetail = "fixed-detail",
)
repository.seed(before)
assertEquals(DomainResult.Success(Unit), rename(before.ref(), " 空调开关 "))
val after = repository.onlyKey()
assertEquals("空调开关", after.displayName)
assertEquals(before.sdkKey, after.sdkKey)
assertEquals(before.id, after.id)
assertEquals(before.lastErrorCode, after.lastErrorCode)
assertEquals(before.lastErrorDetail, after.lastErrorDetail)
assertEquals(before.createdAtEpochMillis, after.createdAtEpochMillis)
assertEquals(500L, after.updatedAtEpochMillis)
assertEquals(
InfraredRenameCall(before.ref(), "空调开关", 500L),
repository.renameCalls.single(),
)
}
@Test
fun renameRejectsBlankDisplayNameWithoutRepositoryCall() = runTest {
val key = readyKey()
repository.seed(key)
val result = rename(key.ref(), " \t ")
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
assertTrue(repository.renameCalls.isEmpty())
assertEquals(key, repository.onlyKey())
}
@Test
fun renameReturnsRepositoryFailure() = runTest {
val key = readyKey()
repository.seed(key)
val repositoryError = DomainError.Unknown(detail = "write failed")
repository.renameError = repositoryError
val result = rename(key.ref(), "New name")
assertEquals(repositoryError, (result as DomainResult.Failure).error)
assertEquals("Power", repository.onlyKey().displayName)
}
@Test
fun deleteDelegatesAtomicLearningGuardToRepository() = runTest {
val key = learningKey()
repository.seed(key)
val result = delete(key.ref())
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
assertEquals(listOf(key.ref()), repository.deleteCalls)
assertTrue(repository.getCalls.isEmpty())
assertTrue(repository.allKeys().isNotEmpty())
}
@Test
fun deleteRemovesReadyKey() = runTest {
val key = readyKey()
repository.seed(key)
assertEquals(DomainResult.Success(Unit), delete(key.ref()))
assertEquals(listOf(key.ref()), repository.deleteCalls)
assertTrue(repository.getCalls.isEmpty())
assertTrue(repository.allKeys().isEmpty())
}
@Test
fun recoveryDelegatesOnceAndMarksOnlyProfileLearningKeysFailed() = runTest {
val learningOne = learningKey(id = InfraredKeyId("one"), lastErrorCode = "old")
val learningTwo = learningKey(id = InfraredKeyId("two"))
val ready = readyKey(id = InfraredKeyId("ready"))
val other = learningKey(id = InfraredKeyId("other"), profileId = otherProfile)
repository.seed(learningOne)
repository.seed(learningTwo)
repository.seed(ready)
repository.seed(other)
assertEquals(DomainResult.Success(2), recover(profile))
assertEquals(
listOf(InfraredRecoveryCall(profile, 500L)),
repository.recoveryCalls,
)
assertTrue(repository.getCalls.isEmpty())
assertTrue(repository.transitionCalls.isEmpty())
val recovered = repository.allKeys().filter { it.profileId == profile && it.id.value in setOf("one", "two") }
assertTrue(recovered.all { it.status == InfraredKeyStatus.FAILED })
assertTrue(recovered.all { it.lastErrorCode == null })
assertTrue(
recovered.all {
it.lastErrorDetail == "Interrupted learning result is unknown; retry required"
},
)
assertTrue(recovered.all { it.updatedAtEpochMillis == 500L })
assertEquals(InfraredKeyStatus.READY, repository.allKeys().single { it.id.value == "ready" }.status)
assertEquals(InfraredKeyStatus.LEARNING, repository.allKeys().single { it.profileId == otherProfile }.status)
assertTrue(gateway.learnedKeys.isEmpty())
assertTrue(gateway.sentKeys.isEmpty())
}
@Test
fun recoveryReturnsRepositoryFailureWithoutHardwareCall() = runTest {
val repositoryError = DomainError.Unknown(detail = "write failed")
repository.recoveryError = repositoryError
val result = recover(profile)
assertEquals(repositoryError, (result as DomainResult.Failure).error)
assertEquals(listOf(InfraredRecoveryCall(profile, 500L)), repository.recoveryCalls)
assertTrue(gateway.learnedKeys.isEmpty())
assertTrue(gateway.sentKeys.isEmpty())
}
private fun readyKey(
id: InfraredKeyId = InfraredKeyId("key1"),
profileId: ProviderProfileId = profile,
deviceName: DeviceName = DeviceName("ir-1"),
displayName: String = "Power",
sdkKey: String = "app_ir_key1",
) = SavedInfraredKey(
id = id,
profileId = profileId,
deviceName = deviceName,
displayName = displayName,
sdkKey = sdkKey,
status = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 1L,
updatedAtEpochMillis = 2L,
)
private fun failedKey() = readyKey().copy(
status = InfraredKeyStatus.FAILED,
lastErrorCode = "old-error",
lastErrorDetail = "old-detail",
)
private fun learningKey(
id: InfraredKeyId = InfraredKeyId("key1"),
profileId: ProviderProfileId = profile,
lastErrorCode: String? = null,
lastErrorDetail: String? = null,
) = readyKey(id = id, profileId = profileId).copy(
status = InfraredKeyStatus.LEARNING,
lastErrorCode = lastErrorCode,
lastErrorDetail = lastErrorDetail,
)
}
private class SteppingManagementClock(start: Long) : EpochMillisClock {
private var next = start
override fun now(): Long = next++
}
@@ -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)
}
}