Merge remote-tracking branch 'origin/main' into feat/scene-editor
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+53
@@ -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)
|
||||
}
|
||||
}
|
||||
+110
@@ -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)
|
||||
}
|
||||
}
|
||||
+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)
|
||||
}
|
||||
+14
@@ -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)
|
||||
}
|
||||
+86
@@ -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,
|
||||
)
|
||||
+53
@@ -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,
|
||||
)
|
||||
+70
@@ -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())
|
||||
}
|
||||
+101
@@ -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),
|
||||
)
|
||||
}
|
||||
+90
@@ -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)
|
||||
}
|
||||
}
|
||||
+25
@@ -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)
|
||||
}
|
||||
}
|
||||
+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.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()
|
||||
}
|
||||
+58
@@ -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>
|
||||
}
|
||||
+345
@@ -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,
|
||||
)
|
||||
|
||||
+89
@@ -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",
|
||||
)
|
||||
}
|
||||
+343
@@ -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++
|
||||
}
|
||||
+251
@@ -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++
|
||||
}
|
||||
+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