feat(infrared): add infrared control, learning, and virtual remote module #9
+32
-8
@@ -5,8 +5,13 @@ import com.flagship.abox.manager.domain.DomainError
|
|||||||
import com.flagship.abox.manager.domain.DomainResult
|
import com.flagship.abox.manager.domain.DomainResult
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
|
||||||
fun interface InfraredKeyIdGenerator { fun nextId(): InfraredKeyId }
|
fun interface InfraredKeyIdGenerator {
|
||||||
fun interface EpochMillisClock { fun now(): Long }
|
fun nextId(): InfraredKeyId
|
||||||
|
}
|
||||||
|
|
||||||
|
fun interface EpochMillisClock {
|
||||||
|
fun now(): Long
|
||||||
|
}
|
||||||
|
|
||||||
class InfraredKeyFactory(
|
class InfraredKeyFactory(
|
||||||
private val idGenerator: InfraredKeyIdGenerator,
|
private val idGenerator: InfraredKeyIdGenerator,
|
||||||
@@ -18,16 +23,22 @@ class InfraredKeyFactory(
|
|||||||
displayName: String,
|
displayName: String,
|
||||||
): DomainResult<SavedInfraredKey> {
|
): DomainResult<SavedInfraredKey> {
|
||||||
val normalizedName = displayName.trim()
|
val normalizedName = displayName.trim()
|
||||||
if (profileId.value.isBlank() || deviceName.value.isBlank() || normalizedName.isBlank()) {
|
if (
|
||||||
|
profileId.value.isBlank() ||
|
||||||
|
profileId.value != profileId.value.trim() ||
|
||||||
|
deviceName.value.isBlank() ||
|
||||||
|
deviceName.value != deviceName.value.trim() ||
|
||||||
|
normalizedName.isBlank()
|
||||||
|
) {
|
||||||
return DomainResult.Failure(
|
return DomainResult.Failure(
|
||||||
DomainError.InvalidRequest(detail = "Profile, device name, and display name are required"),
|
DomainError.InvalidRequest(detail = "Profile, device name, and display name are required"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val id = idGenerator.nextId()
|
val id = idGenerator.nextId()
|
||||||
val normalizedId = id.value.lowercase().filter { it in 'a'..'z' || it in '0'..'9' }
|
val canonicalId = canonicalizeId(id.value)
|
||||||
if (normalizedId.isBlank()) {
|
?: return DomainResult.Failure(
|
||||||
return DomainResult.Failure(DomainError.InvalidRequest(detail = "Infrared key id is invalid"))
|
DomainError.InvalidRequest(detail = "Infrared key id is invalid"),
|
||||||
}
|
)
|
||||||
val now = clock.now()
|
val now = clock.now()
|
||||||
return DomainResult.Success(
|
return DomainResult.Success(
|
||||||
SavedInfraredKey(
|
SavedInfraredKey(
|
||||||
@@ -35,7 +46,7 @@ class InfraredKeyFactory(
|
|||||||
profileId = profileId,
|
profileId = profileId,
|
||||||
deviceName = deviceName,
|
deviceName = deviceName,
|
||||||
displayName = normalizedName,
|
displayName = normalizedName,
|
||||||
sdkKey = "app_ir_${normalizedId.take(40)}",
|
sdkKey = "app_ir_$canonicalId",
|
||||||
status = InfraredKeyStatus.LEARNING,
|
status = InfraredKeyStatus.LEARNING,
|
||||||
lastErrorCode = null,
|
lastErrorCode = null,
|
||||||
createdAtEpochMillis = now,
|
createdAtEpochMillis = now,
|
||||||
@@ -43,4 +54,17 @@ class InfraredKeyFactory(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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}$",
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+5
-1
@@ -11,7 +11,11 @@ data class InfraredKeyRef(
|
|||||||
val keyId: InfraredKeyId,
|
val keyId: InfraredKeyId,
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class InfraredKeyStatus { LEARNING, READY, FAILED }
|
enum class InfraredKeyStatus {
|
||||||
|
LEARNING,
|
||||||
|
READY,
|
||||||
|
FAILED,
|
||||||
|
}
|
||||||
|
|
||||||
data class SavedInfraredKey(
|
data class SavedInfraredKey(
|
||||||
val id: InfraredKeyId,
|
val id: InfraredKeyId,
|
||||||
|
|||||||
+32
-5
@@ -6,15 +6,42 @@ import com.flagship.abox.manager.domain.ProviderProfileId
|
|||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
interface InfraredKeyRepository {
|
interface InfraredKeyRepository {
|
||||||
fun observeKeys(profileId: ProviderProfileId, deviceName: DeviceName): Flow<DomainResult<List<SavedInfraredKey>>>
|
fun observeKeys(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
deviceName: DeviceName,
|
||||||
|
): Flow<DomainResult<List<SavedInfraredKey>>>
|
||||||
|
|
||||||
suspend fun getKey(ref: InfraredKeyRef): DomainResult<SavedInfraredKey?>
|
suspend fun getKey(ref: InfraredKeyRef): DomainResult<SavedInfraredKey?>
|
||||||
|
|
||||||
suspend fun insert(key: SavedInfraredKey): DomainResult<Unit>
|
suspend fun insert(key: SavedInfraredKey): DomainResult<Unit>
|
||||||
suspend fun update(key: SavedInfraredKey): DomainResult<Unit>
|
|
||||||
suspend fun delete(ref: InfraredKeyRef): DomainResult<Unit>
|
suspend fun transitionStatus(
|
||||||
suspend fun findByStatus(profileId: ProviderProfileId, status: InfraredKeyStatus): DomainResult<List<SavedInfraredKey>>
|
ref: InfraredKeyRef,
|
||||||
|
expectedStatuses: Set<InfraredKeyStatus>,
|
||||||
|
targetStatus: InfraredKeyStatus,
|
||||||
|
lastErrorCode: String?,
|
||||||
|
updatedAtEpochMillis: Long,
|
||||||
|
): DomainResult<Unit>
|
||||||
|
|
||||||
|
suspend fun rename(
|
||||||
|
ref: InfraredKeyRef,
|
||||||
|
displayName: String,
|
||||||
|
updatedAtEpochMillis: Long,
|
||||||
|
): DomainResult<Unit>
|
||||||
|
|
||||||
|
suspend fun deleteIfNotLearning(ref: InfraredKeyRef): DomainResult<Unit>
|
||||||
|
|
||||||
|
suspend fun failInterruptedLearning(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
updatedAtEpochMillis: Long,
|
||||||
|
): DomainResult<Int>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InfraredDeviceConfigurationRepository {
|
interface InfraredDeviceConfigurationRepository {
|
||||||
fun observeDeviceName(profileId: ProviderProfileId): Flow<DomainResult<DeviceName?>>
|
fun observeDeviceName(profileId: ProviderProfileId): Flow<DomainResult<DeviceName?>>
|
||||||
suspend fun setDeviceName(profileId: ProviderProfileId, deviceName: DeviceName): DomainResult<Unit>
|
|
||||||
|
suspend fun setDeviceName(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
deviceName: DeviceName,
|
||||||
|
): DomainResult<Unit>
|
||||||
}
|
}
|
||||||
+30
@@ -35,4 +35,34 @@ class InfraredKeyFactoryTest {
|
|||||||
assertTrue(factory.create(ProviderProfileId("abox"), DeviceName(""), "Power") is DomainResult.Failure)
|
assertTrue(factory.create(ProviderProfileId("abox"), DeviceName(""), "Power") is DomainResult.Failure)
|
||||||
assertTrue(factory.create(ProviderProfileId("abox"), DeviceName("ir-1"), " ") 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",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user