From 84aa4454b731e4058cce083d947c27a4d90c5469 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Tue, 30 Aug 2022 03:01:12 +0200 Subject: [PATCH] Support websocket message coalescing (#2829) - Add support for the new feature where core might combine multiple responses in to one single websocket message if it's faster. This might return a JSON array of objects instead of a single object. - Adjust logging for websocket, no longer prints entire result on non-debug builds. --- .../common/data/HomeAssistantVersion.kt | 29 ++++++++++ .../impl/IntegrationRepositoryImpl.kt | 17 +----- .../websocket/impl/WebSocketRepositoryImpl.kt | 55 ++++++++++++++----- .../websocket/impl/entities/SocketResponse.kt | 3 +- 4 files changed, 76 insertions(+), 28 deletions(-) create mode 100644 common/src/main/java/io/homeassistant/companion/android/common/data/HomeAssistantVersion.kt diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/HomeAssistantVersion.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/HomeAssistantVersion.kt new file mode 100644 index 00000000000..7ef13eac60a --- /dev/null +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/HomeAssistantVersion.kt @@ -0,0 +1,29 @@ +package io.homeassistant.companion.android.common.data + +import java.util.regex.Pattern + +data class HomeAssistantVersion( + val year: Int, + val month: Int, + val release: Int +) { + + companion object { + private val VERSION_PATTERN = Pattern.compile("([0-9]{4})\\.([0-9]{1,2})\\.([0-9]{1,2}).*") + + fun fromString(versionString: String): HomeAssistantVersion? { + val matches = VERSION_PATTERN.matcher(versionString) + return if (matches.find() && matches.matches()) { + val coreYear = matches.group(1)?.toIntOrNull() ?: 0 + val coreMonth = matches.group(2)?.toIntOrNull() ?: 0 + val coreRelease = matches.group(3)?.toIntOrNull() ?: 0 + HomeAssistantVersion(coreYear, coreMonth, coreRelease) + } else { // Invalid version + null + } + } + } + + fun isAtLeast(minYear: Int, minMonth: Int, minRelease: Int = 0): Boolean = + year > minYear || (year == minYear && (month > minMonth || (month == minMonth && release >= minRelease))) +} diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/integration/impl/IntegrationRepositoryImpl.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/integration/impl/IntegrationRepositoryImpl.kt index 8359ba1c0eb..c2ef75a2530 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/data/integration/impl/IntegrationRepositoryImpl.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/integration/impl/IntegrationRepositoryImpl.kt @@ -2,6 +2,7 @@ package io.homeassistant.companion.android.common.data.integration.impl import android.util.Log import io.homeassistant.companion.android.common.BuildConfig +import io.homeassistant.companion.android.common.data.HomeAssistantVersion import io.homeassistant.companion.android.common.data.LocalStorage import io.homeassistant.companion.android.common.data.authentication.AuthenticationRepository import io.homeassistant.companion.android.common.data.integration.DeviceRegistration @@ -31,7 +32,6 @@ import kotlinx.coroutines.flow.map import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import org.json.JSONArray import java.util.concurrent.TimeUnit -import java.util.regex.Pattern import javax.inject.Inject import javax.inject.Named @@ -77,8 +77,6 @@ class IntegrationRepositoryImpl @Inject constructor( private const val PREF_SEC_WARNING_NEXT = "sec_warning_last" private const val TAG = "IntegrationRepository" private const val RATE_LIMIT_URL = BuildConfig.RATE_LIMIT_URL - - private val VERSION_PATTERN = Pattern.compile("([0-9]{4})\\.([0-9]{1,2})\\.([0-9]{1,2}).*") } override suspend fun registerDevice(deviceRegistration: DeviceRegistration) { @@ -492,17 +490,8 @@ class IntegrationRepositoryImpl @Inject constructor( ): Boolean { if (!isRegistered()) return false - val version = getHomeAssistantVersion() - val matches = VERSION_PATTERN.matcher(version) - var result = false - if (matches.find() && matches.matches()) { - val coreYear = matches.group(1)?.toIntOrNull() ?: 0 - val coreMonth = matches.group(2)?.toIntOrNull() ?: 0 - val coreRelease = matches.group(3)?.toIntOrNull() ?: 0 - result = - coreYear > year || (coreYear == year && (coreMonth > month || (coreMonth == month && coreRelease >= release))) - } - return result + val version = HomeAssistantVersion.fromString(getHomeAssistantVersion()) + return version?.isAtLeast(year, month, release) ?: false } override suspend fun getConfig(): GetConfigResponse { diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/WebSocketRepositoryImpl.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/WebSocketRepositoryImpl.kt index fe88dc4883f..bd358df724d 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/WebSocketRepositoryImpl.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/WebSocketRepositoryImpl.kt @@ -8,6 +8,8 @@ import com.fasterxml.jackson.module.kotlin.contains import com.fasterxml.jackson.module.kotlin.convertValue import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue +import io.homeassistant.companion.android.common.BuildConfig +import io.homeassistant.companion.android.common.data.HomeAssistantVersion import io.homeassistant.companion.android.common.data.authentication.AuthenticationRepository import io.homeassistant.companion.android.common.data.authentication.AuthorizationException import io.homeassistant.companion.android.common.data.integration.ServiceData @@ -82,6 +84,7 @@ class WebSocketRepositoryImpl @Inject constructor( private val id = AtomicLong(1) private var connection: WebSocket? = null private var connectionState: WebSocketState? = null + private var connectionHaVersion: HomeAssistantVersion? = null private val connectedMutex = Mutex() private var connected = CompletableDeferred() private val eventSubscriptionMutex = Mutex() @@ -327,7 +330,23 @@ class WebSocketRepositoryImpl @Inject constructor( // Wait up to 30 seconds for auth response return true == withTimeoutOrNull(30000) { return@withTimeoutOrNull try { - connected.await() + val didConnect = connected.await() + if (didConnect && connectionHaVersion?.isAtLeast(2022, 9) == true) { + connection?.let { + val supportedFeaturesMessage = mapOf( + "type" to "supported_features", + "id" to id.getAndIncrement(), + "features" to mapOf( + "coalesce_messages" to 1 + ) + ) + Log.d(TAG, "Sending message ${supportedFeaturesMessage["id"]}: $supportedFeaturesMessage") + it.send( + mapper.writeValueAsString(supportedFeaturesMessage) + ) + } + } + didConnect } catch (e: Exception) { Log.e(TAG, "Unable to authenticate", e) false @@ -363,7 +382,8 @@ class WebSocketRepositoryImpl @Inject constructor( private inline fun mapResponse(response: SocketResponse?): T? = if (response?.result != null) mapper.convertValue(response.result) else null - private fun handleAuthComplete(successful: Boolean) { + private fun handleAuthComplete(successful: Boolean, haVersion: String?) { + connectionHaVersion = haVersion?.let { HomeAssistantVersion.fromString(it) } if (successful) { connectionState = WebSocketState.ACTIVE connected.complete(true) @@ -436,6 +456,7 @@ class WebSocketRepositoryImpl @Inject constructor( connectedMutex.withLock { connected = CompletableDeferred() connection = null + connectionHaVersion = null if (connectionState != WebSocketState.CLOSED_AUTH) connectionState = WebSocketState.CLOSED_OTHER } @@ -477,18 +498,26 @@ class WebSocketRepositoryImpl @Inject constructor( } override fun onMessage(webSocket: WebSocket, text: String) { - Log.d(TAG, "Websocket: onMessage (text)") - val message: SocketResponse = mapper.readValue(text) - Log.d(TAG, "Message number ${message.id} received: $text") + Log.d(TAG, "Websocket: onMessage (${if (BuildConfig.DEBUG) "text: $text" else "text"})") + val textTree = mapper.readTree(text) + val messages: List = if (textTree.isArray) { + textTree.elements().asSequence().toList().map { mapper.convertValue(it) } + } else { + listOf(mapper.readValue(text)) + } - ioScope.launch { - when (message.type) { - "auth_required" -> Log.d(TAG, "Auth Requested") - "auth_ok" -> handleAuthComplete(true) - "auth_invalid" -> handleAuthComplete(false) - "pong", "result" -> handleMessage(message) - "event" -> handleEvent(message) - else -> Log.d(TAG, "Unknown message type: $text") + messages.forEach { message -> + Log.d(TAG, "Message number ${message.id} received") + + ioScope.launch { + when (message.type) { + "auth_required" -> Log.d(TAG, "Auth Requested") + "auth_ok" -> handleAuthComplete(true, message.haVersion) + "auth_invalid" -> handleAuthComplete(false, message.haVersion) + "pong", "result" -> handleMessage(message) + "event" -> handleEvent(message) + else -> Log.d(TAG, "Unknown message type: ${message.type}") + } } } } diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/entities/SocketResponse.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/entities/SocketResponse.kt index 5a997ff40b8..b2966725f98 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/entities/SocketResponse.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/entities/SocketResponse.kt @@ -9,5 +9,6 @@ data class SocketResponse( val type: String, val success: Boolean?, val result: JsonNode?, - val event: JsonNode? + val event: JsonNode?, + val haVersion: String? )