feat: add ABSDK 0630 bridge

This commit is contained in:
2026-08-23 06:53:04 +08:00
parent 1e10d2ab00
commit c2fb63e241
16 changed files with 1196 additions and 5 deletions
+20
View File
@@ -0,0 +1,20 @@
plugins {
alias(libs.plugins.kotlin.jvm)
}
kotlin {
jvmToolchain(17)
}
dependencies {
api(project(":domain"))
implementation(
fileTree("../samples/abox-sdk-sample/login-mac/libs") {
include("*.jar")
},
)
implementation(libs.kotlinx.coroutines.core)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
}
@@ -0,0 +1,324 @@
package com.flagship.abox.manager.absdk.bridge
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.LoginCredentials
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.Session
import com.flagship.abox.manager.domain.SessionGateway
import com.flagship.abox.manager.domain.SessionValidity
import com.flagship.abox.manager.domain.SocketGateway
import com.flagship.abox.manager.domain.SocketPowerState
import com.flagship.abox.manager.domain.SocketStatus
import com.flagship.abox.manager.domain.TemperatureHumidityGateway
import com.flagship.abox.manager.domain.TemperatureHumidityReading
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
class AbsdkBridge internal constructor(
private val client: AbsdkClient,
private val dispatcher: CoroutineDispatcher,
) : SessionGateway, SocketGateway, TemperatureHumidityGateway, InfraredGateway {
private val mutex = Mutex()
private val activeSession = MutableStateFlow<Session?>(null)
override fun observeSession(profileId: ProviderProfileId): Flow<Session?> =
activeSession.map { session -> session?.takeIf { it.profileId == profileId } }
override suspend fun login(
profileId: ProviderProfileId,
credentials: LoginCredentials,
): DomainResult<Session> = serialized {
val currentSession = activeSession.value
if (currentSession != null && currentSession.profileId != profileId) {
return@serialized DomainResult.Failure(
DomainError.InvalidRequest(detail = "Exit the active provider session before logging in"),
)
}
when (val response = call { client.login(credentials.username.value, credentials.password.value) }) {
is ClientCall.Failed -> DomainResult.Failure(response.error)
is ClientCall.Completed -> {
if (!response.response.isSuccess()) {
DomainResult.Failure(AbsdkErrorMapper.fromResponse(response.response))
} else {
DomainResult.Success(
Session(profileId, credentials.username).also { activeSession.value = it },
)
}
}
}
}
override suspend fun validateSession(
profileId: ProviderProfileId,
): DomainResult<SessionValidity> = serialized {
if (!hasActiveSession(profileId)) {
return@serialized DomainResult.Success(SessionValidity.INVALID)
}
when (val response = call(client::validateToken)) {
is ClientCall.Failed -> DomainResult.Failure(response.error)
is ClientCall.Completed -> {
if (!response.response.isSuccess()) {
val error = AbsdkErrorMapper.fromResponse(response.response)
if (error is DomainError.TokenInvalid) activeSession.value = null
DomainResult.Failure(error)
} else {
when (response.response?.data?.stringValue(STATUS_KEY)) {
ENABLED_STATUS -> DomainResult.Success(SessionValidity.VALID)
DISABLED_STATUS -> DomainResult.Success(SessionValidity.INVALID).also {
activeSession.value = null
}
else -> DomainResult.Failure(DomainError.NoData(detail = "Missing token status"))
}
}
}
}
}
override suspend fun logout(profileId: ProviderProfileId): DomainResult<Unit> = serialized {
if (activeSession.value?.profileId == profileId) activeSession.value = null
DomainResult.Success(Unit)
}
override suspend fun getStatus(
profileId: ProviderProfileId,
deviceName: DeviceName,
): DomainResult<SocketStatus> = serialized {
withActiveSession(profileId) {
when (val result = call { client.getSocketStatus(deviceName.value) }) {
is ClientCall.Failed -> DomainResult.Failure(result.error)
is ClientCall.Completed -> socketStatus(profileId, deviceName, result.response)
}
}
}
override suspend fun setPower(
profileId: ProviderProfileId,
deviceName: DeviceName,
powerState: SocketPowerState,
): DomainResult<SocketStatus> = serialized {
withActiveSession(profileId) {
when (val control = call {
client.setSocketPower(deviceName.value, powerState.toSdkStatus())
}) {
is ClientCall.Failed -> DomainResult.Failure(control.error)
is ClientCall.Completed -> {
if (!control.response.isSuccess()) {
DomainResult.Failure(AbsdkErrorMapper.fromResponse(control.response))
} else {
when (val confirmation = call {
client.getSocketStatus(deviceName.value)
}) {
is ClientCall.Failed -> DomainResult.Failure(confirmation.error)
is ClientCall.Completed -> socketStatus(
profileId,
deviceName,
confirmation.response,
)
}
}
}
}
}
}
override suspend fun getReading(
profileId: ProviderProfileId,
deviceName: DeviceName,
): DomainResult<TemperatureHumidityReading> = serialized {
withActiveSession(profileId) {
when (val result = call { client.getTemperatureHumidity(deviceName.value) }) {
is ClientCall.Failed -> DomainResult.Failure(result.error)
is ClientCall.Completed -> {
val response = result.response
if (!response.isSuccess()) {
DomainResult.Failure(AbsdkErrorMapper.fromResponse(response))
} else {
val temperature = response?.data?.stringValue(TEMPERATURE_KEY)
val humidity = response?.data?.stringValue(HUMIDITY_KEY)
if (temperature.isNullOrBlank() || humidity.isNullOrBlank()) {
DomainResult.Failure(DomainError.NoData(detail = "Missing temperature or humidity"))
} else {
DomainResult.Success(
TemperatureHumidityReading(
profileId = profileId,
deviceName = deviceName,
temperature = temperature,
humidity = humidity,
),
)
}
}
}
}
}
}
override suspend fun sendKey(
profileId: ProviderProfileId,
deviceName: DeviceName,
key: String,
): DomainResult<Unit> = serialized {
withActiveSession(profileId) {
unitResult(call { client.sendInfrared(deviceName.value, key) })
}
}
override suspend fun learnKey(
profileId: ProviderProfileId,
deviceName: DeviceName,
key: String,
): DomainResult<Unit> = serialized {
withActiveSession(profileId) {
unitResult(call { client.learnInfrared(deviceName.value, key) })
}
}
override suspend fun downloadCodes(
profileId: ProviderProfileId,
codes: List<InfraredCode>,
): DomainResult<List<InfraredCodeDownloadResult>> = serialized {
withActiveSession(profileId) {
when (val result = call {
client.downloadInfrared(
codes.map { AbsdkDownloadCode(it.deviceName.value, it.key, it.code) },
)
}) {
is ClientCall.Failed -> DomainResult.Failure(result.error)
is ClientCall.Completed -> parseDownloadResult(codes, result.response)
}
}
}
private suspend fun <T> serialized(block: suspend () -> T): T = withContext(dispatcher) {
mutex.withLock { block() }
}
private inline fun <T> withActiveSession(
profileId: ProviderProfileId,
block: () -> DomainResult<T>,
): DomainResult<T> = if (hasActiveSession(profileId)) {
block()
} else {
DomainResult.Failure(
DomainError.TokenInvalid(detail = "No active ABSDK session for this provider"),
)
}
private fun hasActiveSession(profileId: ProviderProfileId): Boolean =
activeSession.value?.profileId == profileId
private fun socketStatus(
profileId: ProviderProfileId,
deviceName: DeviceName,
response: AbsdkResponse?,
): DomainResult<SocketStatus> {
if (!response.isSuccess()) {
return DomainResult.Failure(AbsdkErrorMapper.fromResponse(response))
}
return when (val status = response?.data?.stringValue(STATUS_KEY)) {
DISABLED_STATUS -> DomainResult.Success(
SocketStatus(profileId, deviceName, SocketPowerState.OFF),
)
ENABLED_STATUS -> DomainResult.Success(
SocketStatus(profileId, deviceName, SocketPowerState.ON),
)
else -> DomainResult.Failure(
DomainError.NoData(detail = "Missing or invalid socket status: $status"),
)
}
}
private fun parseDownloadResult(
codes: List<InfraredCode>,
response: AbsdkResponse?,
): DomainResult<List<InfraredCodeDownloadResult>> {
val results = response?.data?.get(IR_RESULTS_KEY) as? List<*>
?: return DomainResult.Failure(AbsdkErrorMapper.fromResponse(response))
if (results.size != codes.size || results.any { it !is AbsdkIrResult }) {
return DomainResult.Failure(
DomainError.OperationFailed(
externalCode = response.code,
detail = "Malformed infrared download result",
),
)
}
val typedResults = results.filterIsInstance<AbsdkIrResult>()
val orderedResults = codes.zip(typedResults)
if (orderedResults.any { (code, result) ->
result.deviceName != code.deviceName.value || result.key != code.key
}
) {
return DomainResult.Failure(
DomainError.OperationFailed(
externalCode = response.code,
detail = "Infrared download results do not match their requests",
),
)
}
return DomainResult.Success(
orderedResults.map { (code, result) ->
InfraredCodeDownloadResult(code, successful = result.status == ENABLED_STATUS)
},
)
}
private fun unitResult(result: ClientCall): DomainResult<Unit> = when (result) {
is ClientCall.Failed -> DomainResult.Failure(result.error)
is ClientCall.Completed -> if (result.response.isSuccess()) {
DomainResult.Success(Unit)
} else {
DomainResult.Failure(AbsdkErrorMapper.fromResponse(result.response))
}
}
private fun call(block: () -> AbsdkResponse?): ClientCall = try {
ClientCall.Completed(block())
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
ClientCall.Failed(AbsdkErrorMapper.fromException(error))
}
companion object {
private const val STATUS_KEY = "status"
private const val TEMPERATURE_KEY = "temperature"
private const val HUMIDITY_KEY = "humidity"
private const val IR_RESULTS_KEY = "ret"
private const val DISABLED_STATUS = "0"
private const val ENABLED_STATUS = "1"
fun create(): AbsdkBridge = AbsdkBridge(
client = VendorAbsdkClient.create(),
dispatcher = Dispatchers.IO,
)
}
}
private const val SUCCESS_CODE = "00000"
private sealed interface ClientCall {
data class Completed(val response: AbsdkResponse?) : ClientCall
data class Failed(val error: DomainError) : ClientCall
}
private fun AbsdkResponse?.isSuccess(): Boolean = this?.code == SUCCESS_CODE
private fun Map<String, Any?>.stringValue(key: String): String? = this[key] as? String
private fun SocketPowerState.toSdkStatus(): String = when (this) {
SocketPowerState.OFF -> "0"
SocketPowerState.ON -> "1"
}
@@ -0,0 +1,37 @@
package com.flagship.abox.manager.absdk.bridge
internal data class AbsdkResponse(
val code: String?,
val message: String?,
val data: Map<String, Any?>,
)
internal data class AbsdkIrResult(
val deviceName: String?,
val key: String?,
val status: String?,
)
internal interface AbsdkClient {
fun login(username: String, password: String): AbsdkResponse?
fun validateToken(): AbsdkResponse?
fun getSocketStatus(deviceName: String): AbsdkResponse?
fun setSocketPower(deviceName: String, status: String): AbsdkResponse?
fun getTemperatureHumidity(deviceName: String): AbsdkResponse?
fun sendInfrared(deviceName: String, key: String): AbsdkResponse?
fun learnInfrared(deviceName: String, key: String): AbsdkResponse?
fun downloadInfrared(codes: List<AbsdkDownloadCode>): AbsdkResponse?
}
internal data class AbsdkDownloadCode(
val deviceName: String,
val key: String,
val code: String,
)
@@ -0,0 +1,51 @@
package com.flagship.abox.manager.absdk.bridge
import com.flagship.abox.manager.domain.DomainError
import java.io.IOException
import java.net.SocketException
import java.net.SocketTimeoutException
internal object AbsdkErrorMapper {
private val tokenInvalidCodes = setOf("10001", "10002", "10003")
private val authenticationCodes = setOf("10006", "20001", "20002", "20003", "20004", "20005")
private val invalidRequestCodes = setOf(
"10004", "10005", "20101",
"20201", "20202",
"20301", "20302", "20303",
"20501",
"20601", "20602",
"20701", "20702", "20703", "20704", "20705",
"20801", "20804",
)
private val deviceNotFoundCodes = setOf("20203", "20304", "20502", "20603", "20706", "20802")
private val deviceOfflineCodes = setOf("20206", "20306", "20504", "20605", "20805")
private val operationFailedCodes = setOf("00001", "20204", "20205", "20305", "20503", "20604", "20707", "20806")
fun fromResponse(response: AbsdkResponse?): DomainError {
if (response == null) {
return DomainError.Unknown(detail = "ABSDK returned no response")
}
return fromCode(response.code, response.message)
}
fun fromCode(code: String?, message: String?): DomainError = when (code) {
in tokenInvalidCodes -> DomainError.TokenInvalid(code, message)
in authenticationCodes -> DomainError.Authentication(code, message)
"20000" -> DomainError.Network(code, message)
in invalidRequestCodes -> DomainError.InvalidRequest(code, message)
in deviceNotFoundCodes -> DomainError.DeviceNotFound(code, message)
in deviceOfflineCodes -> DomainError.DeviceOffline(code, message)
"20803" -> DomainError.NoData(code, message)
in operationFailedCodes -> DomainError.OperationFailed(code, message)
else -> DomainError.Unknown(code, message)
}
fun fromException(error: Throwable): DomainError = when (error) {
is SocketTimeoutException,
is SocketException,
is IOException,
-> DomainError.Network(detail = error.message ?: error::class.java.simpleName)
else -> DomainError.Unknown(detail = error.message ?: error::class.java.simpleName)
}
}
@@ -0,0 +1,66 @@
package com.flagship.abox.manager.absdk.bridge
import com.dhc.absdk.ABRet
import com.dhc.absdk.ABSDK
import com.dhc.absdk.IrInfo
internal class VendorAbsdkClient private constructor(
private val sdk: ABSDK,
) : AbsdkClient {
override fun login(username: String, password: String): AbsdkResponse? =
sdk.loginWithUsername(username, password).toSnapshot()
override fun validateToken(): AbsdkResponse? = sdk.isValidateToken().toSnapshot()
override fun getSocketStatus(deviceName: String): AbsdkResponse? =
sdk.getSockStatus(deviceName).toSnapshot()
override fun setSocketPower(deviceName: String, status: String): AbsdkResponse? =
sdk.sockCtrl(deviceName, status).toSnapshot()
override fun getTemperatureHumidity(deviceName: String): AbsdkResponse? =
sdk.getThStatus(deviceName).toSnapshot()
override fun sendInfrared(deviceName: String, key: String): AbsdkResponse? =
sdk.sendIr(deviceName, key).toSnapshot()
override fun learnInfrared(deviceName: String, key: String): AbsdkResponse? =
sdk.studyIrByIrDevName(deviceName, key).toSnapshot()
override fun downloadInfrared(codes: List<AbsdkDownloadCode>): AbsdkResponse? {
val response = sdk.downloadIrCodes(
codes.map { code ->
IrInfo().apply {
irDevName = code.deviceName
key = code.key
this.code = code.code
}
},
) ?: return null
val irResults = (response.dicDatas?.get(IR_RESULTS_KEY) as? List<*>)
?.mapNotNull { (it as? IrInfo)?.toSnapshot() }
.orEmpty()
val data = response.dicDatas.orEmpty() + (IR_RESULTS_KEY to irResults)
return AbsdkResponse(response.code, response.msg, data)
}
companion object {
private const val IR_RESULTS_KEY = "ret"
fun create(): VendorAbsdkClient = VendorAbsdkClient(ABSDK.getInstance())
}
}
private fun ABRet?.toSnapshot(): AbsdkResponse? = this?.let {
AbsdkResponse(
code = code,
message = msg,
data = dicDatas.orEmpty().mapValues { entry -> entry.value },
)
}
private fun IrInfo.toSnapshot(): AbsdkIrResult = AbsdkIrResult(
deviceName = irDevName,
key = key,
status = status,
)
@@ -0,0 +1,150 @@
package com.flagship.abox.manager.absdk.bridge
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.LoginCredentials
import com.flagship.abox.manager.domain.Password
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.SocketPowerState
import com.flagship.abox.manager.domain.Username
import java.io.IOException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class AbsdkDeviceBridgeTest {
private val profile = ProviderProfileId("abox")
private val socket = DeviceName("socket-1")
@Test
fun socketControlReturnsConfirmedStatus() = runTest {
val client = ScriptedAbsdkClient().apply {
socketStatusResponses = ArrayDeque(
listOf(ScriptedAbsdkClient.success(mapOf("status" to "1"))),
)
}
val bridge = authenticatedBridge(client)
val result = bridge.setPower(profile, socket, SocketPowerState.ON)
assertEquals(SocketPowerState.ON, (result as DomainResult.Success).value.powerState)
assertEquals(
listOf("login:demo", "socket-power:socket-1:1", "socket-status:socket-1"),
client.calls,
)
}
@Test
fun socketSdkExceptionIsMapped() = runTest {
val client = ScriptedAbsdkClient()
val bridge = authenticatedBridge(client)
client.failure = IOException("socket unavailable")
val result = bridge.getStatus(profile, socket)
assertTrue((result as DomainResult.Failure).error is DomainError.Network)
}
@Test
fun socketMalformedStatusReturnsNoData() = runTest {
val client = ScriptedAbsdkClient().apply {
socketStatusResponses = ArrayDeque(
listOf(ScriptedAbsdkClient.success(mapOf("status" to "unknown"))),
)
}
val bridge = authenticatedBridge(client)
val result = bridge.getStatus(profile, socket)
assertTrue((result as DomainResult.Failure).error is DomainError.NoData)
}
@Test
fun temperatureMapsValuesAndErrors() = runTest {
val client = ScriptedAbsdkClient()
val bridge = authenticatedBridge(client)
val success = bridge.getReading(profile, DeviceName("th-1")) as DomainResult.Success
assertEquals("23.5", success.value.temperature)
assertEquals("45", success.value.humidity)
client.temperatureResponse = ScriptedAbsdkClient.success(mapOf("temperature" to "23"))
val missing = bridge.getReading(profile, DeviceName("th-1"))
assertTrue((missing as DomainResult.Failure).error is DomainError.NoData)
client.temperatureResponse = ScriptedAbsdkClient.failure("20805", "offline")
val offline = bridge.getReading(profile, DeviceName("th-1"))
assertTrue((offline as DomainResult.Failure).error is DomainError.DeviceOffline)
}
@Test
fun infraredCallsAndPartialDownloadResultsAreMapped() = runTest {
val codeOne = InfraredCode(DeviceName("ir-1"), "power", "code-1")
val codeTwo = InfraredCode(DeviceName("ir-1"), "volume", "code-2")
val client = ScriptedAbsdkClient().apply {
downloadResponse = AbsdkResponse(
code = "20204",
message = "partial failure",
data = mapOf(
"ret" to listOf(
AbsdkIrResult("ir-1", "power", "1"),
AbsdkIrResult("ir-1", "volume", "0"),
),
),
)
}
val bridge = authenticatedBridge(client)
assertEquals(DomainResult.Success(Unit), bridge.sendKey(profile, DeviceName("ir-1"), "power"))
assertEquals(DomainResult.Success(Unit), bridge.learnKey(profile, DeviceName("ir-1"), "power"))
val result = bridge.downloadCodes(profile, listOf(codeOne, codeTwo)) as DomainResult.Success
assertEquals(listOf(false, true).sorted(), result.value.map { it.successful }.sorted())
assertEquals(
listOf(
AbsdkDownloadCode("ir-1", "power", "code-1"),
AbsdkDownloadCode("ir-1", "volume", "code-2"),
),
client.downloadedCodes,
)
}
@Test
fun infraredMalformedResultsFailTheBatch() = runTest {
val code = InfraredCode(DeviceName("ir-1"), "power", "code-1")
val client = ScriptedAbsdkClient().apply {
downloadResponse = ScriptedAbsdkClient.success(
mapOf("ret" to listOf(AbsdkIrResult("another-device", "power", "1"))),
)
}
val bridge = authenticatedBridge(client)
val result = bridge.downloadCodes(profile, listOf(code))
assertTrue((result as DomainResult.Failure).error is DomainError.OperationFailed)
}
@Test
fun ioExceptionsStayInsideTheBridge() = runTest {
val client = ScriptedAbsdkClient().apply { failure = IOException("offline") }
val bridge = AbsdkBridge(client, Dispatchers.Unconfined)
val result = bridge.login(
profile,
LoginCredentials(Username("demo"), Password("secret")),
)
assertTrue((result as DomainResult.Failure).error is DomainError.Network)
assertFalse(result.error.detail.orEmpty().contains("secret"))
}
private suspend fun authenticatedBridge(client: ScriptedAbsdkClient): AbsdkBridge =
AbsdkBridge(client, Dispatchers.Unconfined).also {
it.login(profile, LoginCredentials(Username("demo"), Password("secret")))
}
}
@@ -0,0 +1,49 @@
package com.flagship.abox.manager.absdk.bridge
import com.flagship.abox.manager.domain.DomainError
import java.io.IOException
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class AbsdkErrorMapperTest {
@Test
fun mapsKnownCodeGroups() {
val cases = mapOf(
"10002" to DomainError.TokenInvalid::class,
"10004" to DomainError.InvalidRequest::class,
"10006" to DomainError.Authentication::class,
"20000" to DomainError.Network::class,
"20004" to DomainError.Authentication::class,
"20101" to DomainError.InvalidRequest::class,
"20203" to DomainError.DeviceNotFound::class,
"20205" to DomainError.OperationFailed::class,
"20206" to DomainError.DeviceOffline::class,
"20501" to DomainError.InvalidRequest::class,
"20502" to DomainError.DeviceNotFound::class,
"20503" to DomainError.OperationFailed::class,
"20504" to DomainError.DeviceOffline::class,
"20803" to DomainError.NoData::class,
"20805" to DomainError.DeviceOffline::class,
)
cases.forEach { (code, expectedType) ->
val error = AbsdkErrorMapper.fromCode(code, "detail")
assertEquals(code, error.externalCode)
assertEquals("detail", error.detail)
assertEquals(expectedType, error::class)
}
}
@Test
fun mapsUnknownAndNullResponses() {
assertTrue(AbsdkErrorMapper.fromCode("99999", null) is DomainError.Unknown)
assertTrue(AbsdkErrorMapper.fromResponse(null) is DomainError.Unknown)
}
@Test
fun mapsIoExceptionsToNetworkErrors() {
assertTrue(AbsdkErrorMapper.fromException(IOException("offline")) is DomainError.Network)
assertTrue(AbsdkErrorMapper.fromException(IllegalStateException("broken")) is DomainError.Unknown)
}
}
@@ -0,0 +1,36 @@
package com.flagship.abox.manager.absdk.bridge
import com.dhc.absdk.ABRet
import com.dhc.absdk.ABSDK
import com.dhc.absdk.IrInfo
import java.lang.reflect.ParameterizedType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
class AbsdkPublicApiContractTest {
@Test
fun usesActualTemperatureHumidityMethodName() {
assertNotNull(ABSDK::class.java.getMethod("getThStatus", String::class.java))
assertNull(
ABSDK::class.java.methods.find { it.name == "getThStatusByDevName" },
)
}
@Test
fun sdkHasNoPublicLogoutApi() {
assertNull(ABSDK::class.java.methods.find { it.name.equals("logout", ignoreCase = true) })
}
@Test
fun responseAndInfraredAccessorsKeepExpectedTypes() {
assertEquals(String::class.java, ABRet::class.java.getMethod("getCode").returnType)
assertEquals(String::class.java, ABRet::class.java.getMethod("getMsg").returnType)
assertEquals(Map::class.java, ABRet::class.java.getMethod("getDicDatas").returnType)
val dataType = ABRet::class.java.getMethod("getDicDatas").genericReturnType as ParameterizedType
assertEquals(String::class.java, dataType.actualTypeArguments[0])
assertEquals(Any::class.java, dataType.actualTypeArguments[1])
assertEquals(String::class.java, IrInfo::class.java.getMethod("getStatus").returnType)
}
}
@@ -0,0 +1,51 @@
package com.flagship.abox.manager.absdk.bridge
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.LoginCredentials
import com.flagship.abox.manager.domain.Password
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.SocketPowerState
import com.flagship.abox.manager.domain.Username
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.Assert.assertEquals
import org.junit.Test
class AbsdkSerializationTest {
@Test
fun concurrentCallsDoNotEnterTheSdkTogether() = runBlocking {
withTimeout(5_000) {
val profile = ProviderProfileId("abox")
val client = ScriptedAbsdkClient().apply {
socketStatusResponses = ArrayDeque(
listOf(
ScriptedAbsdkClient.success(mapOf("status" to "1")),
ScriptedAbsdkClient.success(mapOf("status" to "0")),
),
)
}
val bridge = AbsdkBridge(client, Dispatchers.IO)
bridge.login(profile, LoginCredentials(Username("demo"), Password("secret")))
client.onCall = {
if (client.calls.last().startsWith("socket-power")) {
Thread.sleep(75)
}
}
val first = async {
bridge.setPower(profile, DeviceName("socket-1"), SocketPowerState.ON)
}
val second = async {
bridge.setPower(profile, DeviceName("socket-2"), SocketPowerState.OFF)
}
delay(10)
first.await()
second.await()
assertEquals(1, client.maxConcurrentCalls.get())
}
}
}
@@ -0,0 +1,96 @@
package com.flagship.abox.manager.absdk.bridge
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.LoginCredentials
import com.flagship.abox.manager.domain.Password
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.SessionValidity
import com.flagship.abox.manager.domain.Username
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class AbsdkSessionBridgeTest {
private val profile = ProviderProfileId("abox")
private val otherProfile = ProviderProfileId("other")
private val credentials = LoginCredentials(Username("demo"), Password("secret"))
@Test
fun loginPublishesSessionAndLogoutClearsOnlyLocalState() = runTest {
val client = ScriptedAbsdkClient()
val bridge = bridge(client)
val login = bridge.login(profile, credentials)
assertTrue(login is DomainResult.Success)
assertEquals(Username("demo"), bridge.observeSession(profile).first()?.username)
assertEquals(DomainResult.Success(Unit), bridge.logout(profile))
assertNull(bridge.observeSession(profile).first())
val deviceResult = bridge.getStatus(profile, DeviceName("socket"))
assertTrue((deviceResult as DomainResult.Failure).error is DomainError.TokenInvalid)
assertEquals(listOf("login:demo"), client.calls)
}
@Test
fun failedLoginDoesNotPublishSession() = runTest {
val client = ScriptedAbsdkClient().apply {
loginResponse = ScriptedAbsdkClient.failure("20004", "invalid")
}
val bridge = bridge(client)
val result = bridge.login(profile, credentials)
assertTrue((result as DomainResult.Failure).error is DomainError.Authentication)
assertNull(bridge.observeSession(profile).first())
}
@Test
fun rejectsSecondProfileUntilLogout() = runTest {
val client = ScriptedAbsdkClient()
val bridge = bridge(client)
bridge.login(profile, credentials)
val result = bridge.login(otherProfile, credentials)
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
assertEquals(listOf("login:demo"), client.calls)
}
@Test
fun validatesAndClearsInvalidSession() = runTest {
val client = ScriptedAbsdkClient().apply {
validationResponse = ScriptedAbsdkClient.success(mapOf("status" to "0"))
}
val bridge = bridge(client)
bridge.login(profile, credentials)
val result = bridge.validateSession(profile)
assertEquals(DomainResult.Success(SessionValidity.INVALID), result)
assertNull(bridge.observeSession(profile).first())
}
@Test
fun tokenErrorClearsSession() = runTest {
val client = ScriptedAbsdkClient().apply {
validationResponse = ScriptedAbsdkClient.failure("10002", "expired")
}
val bridge = bridge(client)
bridge.login(profile, credentials)
val result = bridge.validateSession(profile)
assertTrue((result as DomainResult.Failure).error is DomainError.TokenInvalid)
assertNull(bridge.observeSession(profile).first())
}
private fun bridge(client: ScriptedAbsdkClient): AbsdkBridge =
AbsdkBridge(client, Dispatchers.Unconfined)
}
@@ -0,0 +1,73 @@
package com.flagship.abox.manager.absdk.bridge
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicInteger
internal class ScriptedAbsdkClient : AbsdkClient {
val calls = CopyOnWriteArrayList<String>()
val downloadedCodes = CopyOnWriteArrayList<AbsdkDownloadCode>()
val activeCalls = AtomicInteger(0)
val maxConcurrentCalls = AtomicInteger(0)
var loginResponse: AbsdkResponse? = success()
var validationResponse: AbsdkResponse? = success(mapOf("status" to "1"))
var socketStatusResponses = ArrayDeque(listOf(success(mapOf("status" to "0"))))
var socketControlResponse: AbsdkResponse? = success()
var temperatureResponse: AbsdkResponse? = success(
mapOf("temperature" to "23.5", "humidity" to "45"),
)
var sendResponse: AbsdkResponse? = success()
var learnResponse: AbsdkResponse? = success()
var downloadResponse: AbsdkResponse? = success(mapOf("ret" to emptyList<AbsdkIrResult>()))
var failure: Exception? = null
var onCall: (() -> Unit)? = null
override fun login(username: String, password: String): AbsdkResponse? =
record("login:$username") { loginResponse }
override fun validateToken(): AbsdkResponse? = record("validate") { validationResponse }
override fun getSocketStatus(deviceName: String): AbsdkResponse? =
record("socket-status:$deviceName") {
socketStatusResponses.removeFirstOrNull() ?: error("No socket status response scripted")
}
override fun setSocketPower(deviceName: String, status: String): AbsdkResponse? =
record("socket-power:$deviceName:$status") { socketControlResponse }
override fun getTemperatureHumidity(deviceName: String): AbsdkResponse? =
record("temperature:$deviceName") { temperatureResponse }
override fun sendInfrared(deviceName: String, key: String): AbsdkResponse? =
record("ir-send:$deviceName:$key") { sendResponse }
override fun learnInfrared(deviceName: String, key: String): AbsdkResponse? =
record("ir-learn:$deviceName:$key") { learnResponse }
override fun downloadInfrared(codes: List<AbsdkDownloadCode>): AbsdkResponse? =
record("ir-download") {
downloadedCodes += codes
downloadResponse
}
private fun <T> record(name: String, block: () -> T): T {
failure?.let { throw it }
calls += name
val concurrent = activeCalls.incrementAndGet()
maxConcurrentCalls.updateAndGet { previous -> maxOf(previous, concurrent) }
return try {
onCall?.invoke()
block()
} finally {
activeCalls.decrementAndGet()
}
}
companion object {
fun success(data: Map<String, Any?> = emptyMap()): AbsdkResponse =
AbsdkResponse("00000", null, data)
fun failure(code: String, message: String = "failure"): AbsdkResponse =
AbsdkResponse(code, message, emptyMap())
}
}