feat: add ABSDK 0630 bridge
This commit is contained in:
@@ -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,
|
||||
)
|
||||
+51
@@ -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)
|
||||
}
|
||||
}
|
||||
+66
@@ -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,
|
||||
)
|
||||
+150
@@ -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")))
|
||||
}
|
||||
}
|
||||
+49
@@ -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)
|
||||
}
|
||||
}
|
||||
+36
@@ -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)
|
||||
}
|
||||
}
|
||||
+51
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
+96
@@ -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)
|
||||
}
|
||||
+73
@@ -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())
|
||||
}
|
||||
}
|
||||
+16
-3
@@ -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 调用不得进入正式架构。
|
||||
|
||||
@@ -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<IrInfo> irInfos)
|
||||
```
|
||||
|
||||
JAR 还包含窗帘、开关、射灯、门磁、人体活动和一氧化碳相关接口,但不属于当前 MVP。
|
||||
|
||||
JAR 没有公开的设备列表、设备发现、logout 或 Token setter API。
|
||||
|
||||
### `com.dhc.absdk.ABRet`
|
||||
|
||||
```text
|
||||
String getCode()
|
||||
String getMsg()
|
||||
Map<String, Object> 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<IrInfo>)` 并不是单次批量 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 的生产分发许可;
|
||||
- 所有端点的完整表单编码和可重复签名测试向量。
|
||||
|
||||
这些事项验证前不得作为产品实现承诺。
|
||||
@@ -3,7 +3,7 @@ plugins {
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
jvmToolchain(17)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
@@ -23,6 +23,7 @@ dependencyResolutionManagement {
|
||||
rootProject.name = "abox-manager"
|
||||
|
||||
include(":domain")
|
||||
include(":absdk-bridge")
|
||||
include(":testing:fakes")
|
||||
include(":android:core:designsystem")
|
||||
include(":android:app")
|
||||
|
||||
@@ -3,7 +3,7 @@ plugins {
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
jvmToolchain(17)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
Reference in New Issue
Block a user