From c2fb63e241eb62e67d8241617d329847dd3f18ce Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Sun, 23 Aug 2026 06:53:04 +0800 Subject: [PATCH 1/3] feat: add ABSDK 0630 bridge --- absdk-bridge/build.gradle.kts | 20 ++ .../abox/manager/absdk/bridge/AbsdkBridge.kt | 324 ++++++++++++++++++ .../abox/manager/absdk/bridge/AbsdkClient.kt | 37 ++ .../manager/absdk/bridge/AbsdkErrorMapper.kt | 51 +++ .../manager/absdk/bridge/VendorAbsdkClient.kt | 66 ++++ .../absdk/bridge/AbsdkDeviceBridgeTest.kt | 150 ++++++++ .../absdk/bridge/AbsdkErrorMapperTest.kt | 49 +++ .../bridge/AbsdkPublicApiContractTest.kt | 36 ++ .../absdk/bridge/AbsdkSerializationTest.kt | 51 +++ .../absdk/bridge/AbsdkSessionBridgeTest.kt | 96 ++++++ .../absdk/bridge/ScriptedAbsdkClient.kt | 73 ++++ docs/architecture.md | 19 +- docs/protocol/absdk-0630.md | 224 ++++++++++++ domain/build.gradle.kts | 2 +- settings.gradle.kts | 1 + testing/fakes/build.gradle.kts | 2 +- 16 files changed, 1196 insertions(+), 5 deletions(-) create mode 100644 absdk-bridge/build.gradle.kts create mode 100644 absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkBridge.kt create mode 100644 absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkClient.kt create mode 100644 absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkErrorMapper.kt create mode 100644 absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/VendorAbsdkClient.kt create mode 100644 absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkDeviceBridgeTest.kt create mode 100644 absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkErrorMapperTest.kt create mode 100644 absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkPublicApiContractTest.kt create mode 100644 absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkSerializationTest.kt create mode 100644 absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkSessionBridgeTest.kt create mode 100644 absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/ScriptedAbsdkClient.kt create mode 100644 docs/protocol/absdk-0630.md diff --git a/absdk-bridge/build.gradle.kts b/absdk-bridge/build.gradle.kts new file mode 100644 index 0000000..057a930 --- /dev/null +++ b/absdk-bridge/build.gradle.kts @@ -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) +} diff --git a/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkBridge.kt b/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkBridge.kt new file mode 100644 index 0000000..16cd787 --- /dev/null +++ b/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkBridge.kt @@ -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(null) + + override fun observeSession(profileId: ProviderProfileId): Flow = + activeSession.map { session -> session?.takeIf { it.profileId == profileId } } + + override suspend fun login( + profileId: ProviderProfileId, + credentials: LoginCredentials, + ): DomainResult = 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 = 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 = serialized { + if (activeSession.value?.profileId == profileId) activeSession.value = null + DomainResult.Success(Unit) + } + + override suspend fun getStatus( + profileId: ProviderProfileId, + deviceName: DeviceName, + ): DomainResult = 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 = 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 = 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 = serialized { + withActiveSession(profileId) { + unitResult(call { client.sendInfrared(deviceName.value, key) }) + } + } + + override suspend fun learnKey( + profileId: ProviderProfileId, + deviceName: DeviceName, + key: String, + ): DomainResult = serialized { + withActiveSession(profileId) { + unitResult(call { client.learnInfrared(deviceName.value, key) }) + } + } + + override suspend fun downloadCodes( + profileId: ProviderProfileId, + codes: List, + ): DomainResult> = 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 serialized(block: suspend () -> T): T = withContext(dispatcher) { + mutex.withLock { block() } + } + + private inline fun withActiveSession( + profileId: ProviderProfileId, + block: () -> DomainResult, + ): DomainResult = 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 { + 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, + response: AbsdkResponse?, + ): DomainResult> { + 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() + 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 = 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.stringValue(key: String): String? = this[key] as? String + +private fun SocketPowerState.toSdkStatus(): String = when (this) { + SocketPowerState.OFF -> "0" + SocketPowerState.ON -> "1" +} diff --git a/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkClient.kt b/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkClient.kt new file mode 100644 index 0000000..9dc7e0a --- /dev/null +++ b/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkClient.kt @@ -0,0 +1,37 @@ +package com.flagship.abox.manager.absdk.bridge + +internal data class AbsdkResponse( + val code: String?, + val message: String?, + val data: Map, +) + +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): AbsdkResponse? +} + +internal data class AbsdkDownloadCode( + val deviceName: String, + val key: String, + val code: String, +) diff --git a/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkErrorMapper.kt b/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkErrorMapper.kt new file mode 100644 index 0000000..fb9dacf --- /dev/null +++ b/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkErrorMapper.kt @@ -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) + } +} diff --git a/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/VendorAbsdkClient.kt b/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/VendorAbsdkClient.kt new file mode 100644 index 0000000..4abdb2f --- /dev/null +++ b/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/VendorAbsdkClient.kt @@ -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): 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, +) diff --git a/absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkDeviceBridgeTest.kt b/absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkDeviceBridgeTest.kt new file mode 100644 index 0000000..5f52112 --- /dev/null +++ b/absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkDeviceBridgeTest.kt @@ -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"))) + } +} diff --git a/absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkErrorMapperTest.kt b/absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkErrorMapperTest.kt new file mode 100644 index 0000000..4f7adf5 --- /dev/null +++ b/absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkErrorMapperTest.kt @@ -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) + } +} diff --git a/absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkPublicApiContractTest.kt b/absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkPublicApiContractTest.kt new file mode 100644 index 0000000..6c717dd --- /dev/null +++ b/absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkPublicApiContractTest.kt @@ -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) + } +} diff --git a/absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkSerializationTest.kt b/absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkSerializationTest.kt new file mode 100644 index 0000000..fe68163 --- /dev/null +++ b/absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkSerializationTest.kt @@ -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()) + } + } +} diff --git a/absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkSessionBridgeTest.kt b/absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkSessionBridgeTest.kt new file mode 100644 index 0000000..294b264 --- /dev/null +++ b/absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkSessionBridgeTest.kt @@ -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) +} diff --git a/absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/ScriptedAbsdkClient.kt b/absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/ScriptedAbsdkClient.kt new file mode 100644 index 0000000..edc2550 --- /dev/null +++ b/absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/ScriptedAbsdkClient.kt @@ -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() + val downloadedCodes = CopyOnWriteArrayList() + 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())) + 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): AbsdkResponse? = + record("ir-download") { + downloadedCodes += codes + downloadResponse + } + + private fun 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 = emptyMap()): AbsdkResponse = + AbsdkResponse("00000", null, data) + + fun failure(code: String, message: String = "failure"): AbsdkResponse = + AbsdkResponse(code, message, emptyMap()) + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 0b60a23..83ce72a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -92,12 +92,25 @@ Fake 只用于开发和测试,不能作为真实 ABox 或真实硬件验收证 设备目录导航、插座与温湿度页面、红外页面和情景页面由固定模块负责人实现,不放入工程基础层。 -## 8. 后续边界 +## 8. ABSDK Bridge + +`:absdk-bridge` 是纯 Kotlin/JVM 防腐层,依赖 `domain` 并实现会话、插座、温湿度和红外 Gateway。该模块: + +- 只在 `VendorAbsdkClient` 中接触 `ABSDK`、`ABRet` 和 `IrInfo`; +- 将所有同步调用移到后台并串行进入全局 SDK; +- 将返回值和错误码转换为 `DomainResult` 和领域模型; +- 不提供设备目录; +- 不调用未确认的 `setHostInfo`; +- logout 只清除 Bridge 本地会话,因为 JAR 没有公开 logout 或 Token setter; +- 当前直接引用归档 Sample 目录中的唯一一份 JAR,不复制厂商二进制。 + +Bridge 本轮尚未加入 `android/app` 依赖,App debug 仍绑定 Fake。提供方选择、安全凭据和真实 Bridge 绑定由后续 `feat/provider-login` 完成。协议证据与限制记录在 `docs/protocol/absdk-0630.md`。 + +## 9. 后续边界 后续 KremeCN 工作保持为独立短期分支: -- `feat/absdk-bridge`:真实 JAR 防腐层和协议验证; -- `feat/provider-login`:提供方入口、安全凭据、自动登录和重新登录; +- `feat/provider-login`:提供方入口、安全凭据、自动登录、重新登录和真实 Bridge 绑定; - `feat/compatible-server`:在协议与连接方式确认后建立服务端、CLI 和 Docker 基础。 Room、DataStore 和 Android Keystore 的具体 schema/key 只在对应数据所有权明确后创建。历史示例中的 AsyncTask、明文密码存储、全局明文网络和散落 SDK 调用不得进入正式架构。 diff --git a/docs/protocol/absdk-0630.md b/docs/protocol/absdk-0630.md new file mode 100644 index 0000000..107335e --- /dev/null +++ b/docs/protocol/absdk-0630.md @@ -0,0 +1,224 @@ +# ABSDK 0630 已确认事实与边界 + +## 1. 文档范围 + +本文记录 `libABSDK0630.jar`、厂商接口文档和仓库历史 Sample 中能够复核的事实,并明确仍待真实环境确认的内容。 + +证据来源: + +- JAR:`samples/abox-sdk-sample/login-mac/libs/libABSDK0630.jar`; +- 厂商文档:`docs/ABSDK接口部分_0630.md`; +- 历史 Sample:`samples/abox-sdk-sample/login-mac/`; +- 公开 API 使用 `javap` 验证,协议细节通过授权字节码分析验证。 + +本文不是自有兼容后端的最终规范。真实连接参数、部署行为和硬件结果仍须通过授权测试环境验证。 + +## 2. JAR 公开类型 + +### `com.dhc.absdk.ABSDK` + +JAR 提供 `getInstance()` 单例入口以及以下与 MVP 有关的方法: + +```text +ABRet loginWithUsername(String username, String password) +ABRet isValidateToken() +ABRet setHostInfo(String url, String hotelName, String roomNo, String apikey) +ABRet getSockStatus(String soDevName) +ABRet sockCtrl(String soDevName, String status) +ABRet getThStatus(String thDevName) +ABRet sendIr(String irDevName, String irKey) +ABRet studyIrByIrDevName(String irDevName, String irKey) +ABRet downloadIrCodes(List irInfos) +``` + +JAR 还包含窗帘、开关、射灯、门磁、人体活动和一氧化碳相关接口,但不属于当前 MVP。 + +JAR 没有公开的设备列表、设备发现、logout 或 Token setter API。 + +### `com.dhc.absdk.ABRet` + +```text +String getCode() +String getMsg() +Map getDicDatas() +``` + +`"00000"` 表示调用成功;其他字符串代码表示失败。Bridge 不向领域层泄漏 `ABRet`、Map 或厂商类型。 + +### `com.dhc.absdk.IrInfo` + +```text +String irDevName +String key +String code +String status +``` + +下载结果的 `status` 是字符串:`"0"` 表示失败,`"1"` 表示成功。 + +## 3. 文档与 JAR 差异 + +厂商 Markdown 文档把温湿度方法写成: + +```text +getThStatusByDevName(String) +``` + +JAR 实际公开的方法是: + +```text +getThStatus(String) +``` + +Bridge 必须以实际 JAR 方法为准。 + +`setHostInfo` 是 JAR 的公开方法,但未出现在厂商 Markdown 接口清单中。 + +## 4. 全局状态与线程边界 + +JAR 使用单例 `ABSDK`,登录成功后把 Token 保存在 SDK 的静态状态中;后续请求从该全局状态读取 Token。HTTP Client 和 HTTP Context 也由 SDK 共享。 + +由此得到当前 Bridge 约束: + +- 任一时刻只维护一个活动 ABSDK 会话; +- 所有 SDK 调用必须在后台执行; +- 所有 SDK 调用默认串行化; +- 切换提供方前必须退出 Bridge 本地会话; +- JAR 没有 logout,Bridge 的 logout 只能清除本地会话并阻止后续调用; +- Bridge 不通过反射访问或清理 SDK 私有 Token;重新登录会覆盖 SDK 中的 Token。 + +JAR 自身是否保证并发线程安全仍未得到厂商确认。 + +## 5. 传输事实 + +字节码确认 SDK 的基础地址硬编码为: + +```text +http://a-box.com.cn/abox/index.php/ +``` + +SDK 使用 Apache HttpClient 4.x,所有已检查业务请求均为 POST。归档 Sample 同时包含: + +```text +httpclient-4.3.6.jar +httpcore-4.3.3.jar +commons-codec-1.6.jar +commons-logging-1.1.3.jar +``` + +JAR 内含宽松的 TLS 信任实现。正式产品不得把这一历史传输行为扩展为 App 其他网络请求的安全策略,也不得记录密码、Token、API Key 或敏感红外载荷。 + +## 6. `setHostInfo` 的已确认行为 + +`setHostInfo(url, hotelName, roomNo, apikey)` 向以下端点发送初始化请求: + +```text +POST abjhsdk/init +``` + +四个参数作为表单字段提交: + +```text +url +hotelName +roomNo +apikey +``` + +其中第一个参数只写入请求字段 `url`,不会修改 SDK 的硬编码 HTTP 基础地址。因此原始 JAR 不能通过该公开 API 直接切换到任意自有后端。 + +仍未确认: + +- 正式业务中四个字段的具体语义; +- ABox 官方环境是否要求每次启动调用; +- hotel、room 和 API Key 的正式取值; +- 初始化失败对后续登录和设备操作的影响。 + +所以 `feat/absdk-bridge` 不实现或调用 `setHostInfo`。 + +## 7. MVP 端点与数据 + +| SDK 方法 | 端点 | 关键参数或返回 | +|---|---|---| +| `setHostInfo` | `abjhsdk/init` | `url`、`hotelName`、`roomNo`、`apikey` | +| `loginWithUsername` | `abjhsdk/user/login` | `username`、SDK 内部 MD5 处理后的 `password`;返回 `token` | +| `isValidateToken` | `abjhsdk/user/validate` | 返回 `status`:`0`/`1` | +| `getSockStatus` | `abjhsdk/sock/properties` | `name`;返回 `status`:`0`/`1` | +| `sockCtrl` | `abjhsdk/zigbee/sock/{device}` | `status`:`0`/`1` | +| `getThStatus` | `abjhsdk/th/properties` | `name`;返回 `temperature`、`humidity` | +| `sendIr` | `abjhsdk/ir/{device}/send` | `irKey` | +| `studyIrByIrDevName` | `abjhsdk/ir/{device}/study` | `irKey` | +| `downloadIrCodes` | `abjhsdk/ir/{device}/download` | 每项发送 `irKey`、`irCode` | + +`downloadIrCodes(List)` 并不是单次批量 HTTP 请求。JAR 按输入顺序逐项调用下载端点,并返回每项 `status`。只要有任一项失败,总体代码为 `20204`,同时 `dicDatas["ret"]` 仍包含逐项结果。 + +## 8. 请求签名 + +字节码显示 SDK 会: + +1. 取得秒级时间戳; +2. 对业务参数键排序并组装签名原文; +3. 加入端点、时间戳和 SDK 内部 secret; +4. 使用 MD5 生成摘要; +5. 按时间戳计算的位置插入时间片段并添加固定前缀; +6. 在请求的 `system` 数据中提交签名、时间戳和 Token。 + +这些事实用于判断协议边界,不在本轮重新实现签名算法或对外宣称兼容性。正式兼容后端开始前,必须建立脱敏签名测试向量并复核每个字段和编码细节。 + +## 9. 响应结构 + +字节码解析的响应结构为: + +```json +{ + "code": "00000", + "msg": "...", + "data": {} +} +``` + +`data` 被转换为 `ABRet.dicDatas`。解析或请求失败时,SDK 可返回 `20000` 和请求失败信息。 + +当前 Bridge 对成功数据进行防御性解析:缺失字段、未知状态或错误类型不会导致 `ClassCastException` 泄漏到调用方。 + +## 10. 错误码来源 + +完整错误码表来自厂商文档和历史 `AboxConstants.java`。Bridge 保留原始 `externalCode`,并映射为以下领域分类: + +- Token 无效; +- 认证失败; +- 网络错误; +- 请求参数错误; +- 设备不存在; +- 设备离线; +- 无数据; +- 操作失败; +- 未知错误。 + +错误 `msg` 只作为诊断 detail,不直接作为最终 UI 文案。 + +## 11. 历史 Sample 观察 + +历史 Sample: + +- 直接调用 `ABSDK.getInstance()`; +- 使用 Executor 或 AsyncTask 把同步调用移出 UI 线程; +- 没有调用 `setHostInfo`; +- logout 是空实现; +- 使用普通 SharedPreferences 明文保存密码; +- 全局允许明文网络。 + +正式实现只复用已确认的 SDK 调用事实,不复制 AsyncTask、明文凭据或全局明文网络配置。 + +## 12. 仍待确认 + +- ABox 官方初始化的完整流程和参数; +- 文档所称 Token 两小时有效期在真实环境中的精确表现; +- Token 失效时各设备接口实际返回的代码; +- SDK 的厂商线程安全保证; +- 真实账号、设备名称和硬件环境; +- 原始 JAR 指向自有兼容后端的最终授权方案; +- ABSDK 和附属 JAR 的生产分发许可; +- 所有端点的完整表单编码和可重复签名测试向量。 + +这些事项验证前不得作为产品实现承诺。 diff --git a/domain/build.gradle.kts b/domain/build.gradle.kts index 989739b..695db50 100644 --- a/domain/build.gradle.kts +++ b/domain/build.gradle.kts @@ -3,7 +3,7 @@ plugins { } kotlin { - jvmToolchain(21) + jvmToolchain(17) } dependencies { diff --git a/settings.gradle.kts b/settings.gradle.kts index 88d4f61..6a69ab0 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -23,6 +23,7 @@ dependencyResolutionManagement { rootProject.name = "abox-manager" include(":domain") +include(":absdk-bridge") include(":testing:fakes") include(":android:core:designsystem") include(":android:app") diff --git a/testing/fakes/build.gradle.kts b/testing/fakes/build.gradle.kts index c2eed16..e8498a8 100644 --- a/testing/fakes/build.gradle.kts +++ b/testing/fakes/build.gradle.kts @@ -3,7 +3,7 @@ plugins { } kotlin { - jvmToolchain(21) + jvmToolchain(17) } dependencies { -- 2.54.0 From 0e9b7dfd4bf6c71f33cc345f9197b70016e20242 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Sun, 23 Aug 2026 07:05:43 +0800 Subject: [PATCH 2/3] fix: guard global ABSDK session state --- .../abox/manager/absdk/bridge/AbsdkBridge.kt | 31 +++++++++++++------ .../absdk/bridge/CleartextAbsdkTransport.kt | 22 +++++++++++++ .../manager/absdk/bridge/VendorAbsdkClient.kt | 1 + .../manager/absdk/bridge/AbsdkFactoryTest.kt | 12 +++++++ .../absdk/bridge/AbsdkSessionBridgeTest.kt | 19 ++++++++++++ docs/architecture.md | 4 ++- docs/protocol/absdk-0630.md | 2 ++ 7 files changed, 80 insertions(+), 11 deletions(-) create mode 100644 absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/CleartextAbsdkTransport.kt create mode 100644 absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkFactoryTest.kt diff --git a/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkBridge.kt b/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkBridge.kt index 16cd787..eb6f337 100644 --- a/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkBridge.kt +++ b/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkBridge.kt @@ -208,12 +208,17 @@ class AbsdkBridge internal constructor( private inline fun withActiveSession( profileId: ProviderProfileId, block: () -> DomainResult, - ): DomainResult = if (hasActiveSession(profileId)) { - block() - } else { - DomainResult.Failure( - DomainError.TokenInvalid(detail = "No active ABSDK session for this provider"), - ) + ): DomainResult { + if (!hasActiveSession(profileId)) { + return DomainResult.Failure( + DomainError.TokenInvalid(detail = "No active ABSDK session for this provider"), + ) + } + return block().also { result -> + if (result is DomainResult.Failure && result.error is DomainError.TokenInvalid) { + activeSession.value = null + } + } } private fun hasActiveSession(profileId: ProviderProfileId): Boolean = @@ -299,10 +304,16 @@ class AbsdkBridge internal constructor( private const val DISABLED_STATUS = "0" private const val ENABLED_STATUS = "1" - fun create(): AbsdkBridge = AbsdkBridge( - client = VendorAbsdkClient.create(), - dispatcher = Dispatchers.IO, - ) + @OptIn(CleartextAbsdkTransport::class) + private val processInstance: AbsdkBridge by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { + AbsdkBridge( + client = VendorAbsdkClient.create(), + dispatcher = Dispatchers.IO, + ) + } + + @CleartextAbsdkTransport + fun create(): AbsdkBridge = processInstance } } diff --git a/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/CleartextAbsdkTransport.kt b/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/CleartextAbsdkTransport.kt new file mode 100644 index 0000000..9cd8dcd --- /dev/null +++ b/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/CleartextAbsdkTransport.kt @@ -0,0 +1,22 @@ +package com.flagship.abox.manager.absdk.bridge + +/** + * Marks construction of the original ABSDK 0630 client, whose base endpoint is hardcoded to HTTP. + * + * Opting in acknowledges that transport confidentiality and integrity are not provided by the + * vendor JAR. App integration must remain disabled until the deployment has an approved transport + * control and the security limitation is presented to the responsible integrator. + */ +@RequiresOptIn( + message = "ABSDK 0630 uses a hardcoded cleartext HTTP transport. " + + "Do not enable it without an approved transport control.", + level = RequiresOptIn.Level.ERROR, +) +@Retention(AnnotationRetention.BINARY) +@Target( + AnnotationTarget.CLASS, + AnnotationTarget.CONSTRUCTOR, + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY, +) +annotation class CleartextAbsdkTransport diff --git a/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/VendorAbsdkClient.kt b/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/VendorAbsdkClient.kt index 4abdb2f..081106d 100644 --- a/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/VendorAbsdkClient.kt +++ b/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/VendorAbsdkClient.kt @@ -4,6 +4,7 @@ import com.dhc.absdk.ABRet import com.dhc.absdk.ABSDK import com.dhc.absdk.IrInfo +@CleartextAbsdkTransport internal class VendorAbsdkClient private constructor( private val sdk: ABSDK, ) : AbsdkClient { diff --git a/absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkFactoryTest.kt b/absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkFactoryTest.kt new file mode 100644 index 0000000..05bbd41 --- /dev/null +++ b/absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkFactoryTest.kt @@ -0,0 +1,12 @@ +package com.flagship.abox.manager.absdk.bridge + +import org.junit.Assert.assertSame +import org.junit.Test + +@OptIn(CleartextAbsdkTransport::class) +class AbsdkFactoryTest { + @Test + fun productionFactoryReturnsOneProcessInstance() { + assertSame(AbsdkBridge.create(), AbsdkBridge.create()) + } +} diff --git a/absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkSessionBridgeTest.kt b/absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkSessionBridgeTest.kt index 294b264..182f692 100644 --- a/absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkSessionBridgeTest.kt +++ b/absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkSessionBridgeTest.kt @@ -91,6 +91,25 @@ class AbsdkSessionBridgeTest { assertNull(bridge.observeSession(profile).first()) } + @Test + fun deviceTokenErrorClearsSessionAndBlocksFurtherCalls() = runTest { + val client = ScriptedAbsdkClient().apply { + socketStatusResponses = ArrayDeque( + listOf(ScriptedAbsdkClient.failure("10002", "expired")), + ) + } + val bridge = bridge(client) + bridge.login(profile, credentials) + + val first = bridge.getStatus(profile, DeviceName("socket")) + + assertTrue((first as DomainResult.Failure).error is DomainError.TokenInvalid) + assertNull(bridge.observeSession(profile).first()) + val second = bridge.getStatus(profile, DeviceName("socket")) + assertTrue((second as DomainResult.Failure).error is DomainError.TokenInvalid) + assertEquals(listOf("login:demo", "socket-status:socket"), client.calls) + } + private fun bridge(client: ScriptedAbsdkClient): AbsdkBridge = AbsdkBridge(client, Dispatchers.Unconfined) } diff --git a/docs/architecture.md b/docs/architecture.md index 83ce72a..c00b4c7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -102,9 +102,11 @@ Fake 只用于开发和测试,不能作为真实 ABox 或真实硬件验收证 - 不提供设备目录; - 不调用未确认的 `setHostInfo`; - logout 只清除 Bridge 本地会话,因为 JAR 没有公开 logout 或 Token setter; +- 生产工厂始终返回进程内同一个 Bridge,使互斥锁和本地会话与厂商单例保持一致; +- 真实工厂标记为 `@CleartextAbsdkTransport`,调用方必须显式 opt-in 承认厂商 JAR 的硬编码 HTTP 风险; - 当前直接引用归档 Sample 目录中的唯一一份 JAR,不复制厂商二进制。 -Bridge 本轮尚未加入 `android/app` 依赖,App debug 仍绑定 Fake。提供方选择、安全凭据和真实 Bridge 绑定由后续 `feat/provider-login` 完成。协议证据与限制记录在 `docs/protocol/absdk-0630.md`。 +Bridge 本轮尚未加入 `android/app` 依赖,App debug 仍绑定 Fake。提供方选择、安全凭据和真实 Bridge 绑定由后续 `feat/provider-login` 完成;在获批的传输控制方案明确前,不得在 App 中启用真实 Bridge。协议证据与限制记录在 `docs/protocol/absdk-0630.md`。 ## 9. 后续边界 diff --git a/docs/protocol/absdk-0630.md b/docs/protocol/absdk-0630.md index 107335e..6ae46c1 100644 --- a/docs/protocol/absdk-0630.md +++ b/docs/protocol/absdk-0630.md @@ -108,6 +108,8 @@ commons-logging-1.1.3.jar JAR 内含宽松的 TLS 信任实现。正式产品不得把这一历史传输行为扩展为 App 其他网络请求的安全策略,也不得记录密码、Token、API Key 或敏感红外载荷。 +真实 Bridge 工厂使用 `@CleartextAbsdkTransport` 作为编译期 opt-in 门禁,并且当前没有接入 Android App。该标记只防止误接入,不会为链路提供加密。生产集成前必须确定并批准 HTTPS、受控加密代理或其他能够提供机密性与完整性的传输控制;在此之前不得启用真实 ABox 入口。 + ## 6. `setHostInfo` 的已确认行为 `setHostInfo(url, hotelName, roomNo, apikey)` 向以下端点发送初始化请求: -- 2.54.0 From 13fd18a9f3a602f30d23474010f19674675bbc9e Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Sun, 23 Aug 2026 07:13:15 +0800 Subject: [PATCH 3/3] fix: reject invalid infrared batch responses --- .../abox/manager/absdk/bridge/AbsdkBridge.kt | 15 +++++++++--- .../absdk/bridge/AbsdkDeviceBridgeTest.kt | 24 +++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkBridge.kt b/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkBridge.kt index eb6f337..9c26d29 100644 --- a/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkBridge.kt +++ b/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkBridge.kt @@ -249,12 +249,19 @@ class AbsdkBridge internal constructor( codes: List, response: AbsdkResponse?, ): DomainResult> { - val results = response?.data?.get(IR_RESULTS_KEY) as? List<*> + val validResponse = response?.takeIf { it.code in IR_DOWNLOAD_RESULT_CODES } ?: return DomainResult.Failure(AbsdkErrorMapper.fromResponse(response)) + val results = validResponse.data[IR_RESULTS_KEY] as? List<*> + ?: return DomainResult.Failure( + DomainError.OperationFailed( + externalCode = validResponse.code, + detail = "Missing infrared download results", + ), + ) if (results.size != codes.size || results.any { it !is AbsdkIrResult }) { return DomainResult.Failure( DomainError.OperationFailed( - externalCode = response.code, + externalCode = validResponse.code, detail = "Malformed infrared download result", ), ) @@ -267,7 +274,7 @@ class AbsdkBridge internal constructor( ) { return DomainResult.Failure( DomainError.OperationFailed( - externalCode = response.code, + externalCode = validResponse.code, detail = "Infrared download results do not match their requests", ), ) @@ -318,6 +325,8 @@ class AbsdkBridge internal constructor( } private const val SUCCESS_CODE = "00000" +private const val PARTIAL_IR_DOWNLOAD_CODE = "20204" +private val IR_DOWNLOAD_RESULT_CODES = setOf(SUCCESS_CODE, PARTIAL_IR_DOWNLOAD_CODE) private sealed interface ClientCall { data class Completed(val response: AbsdkResponse?) : ClientCall diff --git a/absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkDeviceBridgeTest.kt b/absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkDeviceBridgeTest.kt index 5f52112..84002ea 100644 --- a/absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkDeviceBridgeTest.kt +++ b/absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkDeviceBridgeTest.kt @@ -11,6 +11,7 @@ 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.flow.first import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -114,6 +115,29 @@ class AbsdkDeviceBridgeTest { ) } + @Test + fun infraredTokenErrorCannotBeMaskedByValidResults() = runTest { + val code = InfraredCode(DeviceName("ir-1"), "power", "code-1") + val client = ScriptedAbsdkClient().apply { + downloadResponse = AbsdkResponse( + code = "10002", + message = "expired", + data = mapOf( + "ret" to listOf(AbsdkIrResult("ir-1", "power", "1")), + ), + ) + } + val bridge = authenticatedBridge(client) + + val result = bridge.downloadCodes(profile, listOf(code)) + + assertTrue((result as DomainResult.Failure).error is DomainError.TokenInvalid) + assertEquals(null, bridge.observeSession(profile).first()) + val second = bridge.sendKey(profile, DeviceName("ir-1"), "power") + assertTrue((second as DomainResult.Failure).error is DomainError.TokenInvalid) + assertEquals(listOf("login:demo", "ir-download"), client.calls) + } + @Test fun infraredMalformedResultsFailTheBatch() = runTest { val code = InfraredCode(DeviceName("ir-1"), "power", "code-1") -- 2.54.0