From e8db4da5370a3d5a0f506d5ef2ae17cacca3bd26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Cie=C5=9Blik?= Date: Sun, 14 Jun 2026 15:48:32 +0200 Subject: [PATCH] feat(network): implement real-time updates via websockets --- .../java/com/example/retroha/MainActivity.kt | 48 ++++++++ .../retroha/network/HaWebSocketManager.kt | 112 ++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 app/src/main/java/com/example/retroha/network/HaWebSocketManager.kt diff --git a/app/src/main/java/com/example/retroha/MainActivity.kt b/app/src/main/java/com/example/retroha/MainActivity.kt index 396855f..3e0d284 100644 --- a/app/src/main/java/com/example/retroha/MainActivity.kt +++ b/app/src/main/java/com/example/retroha/MainActivity.kt @@ -20,6 +20,7 @@ import com.example.retroha.ui.WidgetAdapter import com.example.retroha.network.HaClient import com.example.retroha.network.HaState import com.example.retroha.network.ToggleRequest +import com.example.retroha.network.HaWebSocketManager import retrofit2.Call import retrofit2.Callback import retrofit2.Response @@ -44,6 +45,8 @@ class MainActivity : BaseActivity() { private var currentCategory = StringKey.TAB_ALL private lateinit var tvStatusIndicator: TextView private lateinit var tabContainer: LinearLayout + private var webSocketManager: HaWebSocketManager? = null + private val refreshRunnable = object : Runnable { override fun run() { fetchHaStates() @@ -86,6 +89,10 @@ class MainActivity : BaseActivity() { Toast.makeText(this, stringsProvider.get(StringKey.STATUS_REFRESHING), Toast.LENGTH_SHORT).show() } setupTabs() + + webSocketManager = HaWebSocketManager(this) { haState -> + handleStateUpdate(haState) + } } /** * Initializes the category filtering tabs. @@ -167,10 +174,12 @@ class MainActivity : BaseActivity() { super.onResume() mainHandler.removeCallbacks(refreshRunnable) mainHandler.post(refreshRunnable) + webSocketManager?.connect() } override fun onPause() { super.onPause() mainHandler.removeCallbacks(refreshRunnable) + webSocketManager?.disconnect() } /** * Fetches the latest states from Home Assistant and updates the UI. @@ -207,6 +216,43 @@ class MainActivity : BaseActivity() { } }) } + /** + * Handles a single entity state update from WebSocket. + */ + private fun handleStateUpdate(ha: HaState) { + val selectedIds = Prefs.getSelectedEntities(this) + if (!selectedIds.contains(ha.entity_id)) return + + val idx = allEntities.indexOfFirst { it.entityId == ha.entity_id } + val domain = ha.entity_id.split(".")[0] + val state = when (ha.state) { + "on" -> EntityState.ON + "off" -> EntityState.OFF + "unavailable" -> EntityState.UNAVAILABLE + else -> EntityState.OFF + } + + val updatedConfig = WidgetConfig( + id = if (idx >= 0) allEntities[idx].id else System.currentTimeMillis(), + entityId = ha.entity_id, + label = ha.attributes.friendly_name ?: ha.entity_id, + value = ha.state.uppercase(), + secondary = ha.attributes.unit_of_measurement ?: "", + domain = domain, + state = state, + brightness = ha.attributes.brightness + ) + + if (idx >= 0) { + allEntities[idx] = updatedConfig + } else { + allEntities.add(updatedConfig) + } + runOnUiThread { + filterEntities() + } + } + /** * Updates the local [allEntities] list with new data from HA. * Maps raw HA states to [WidgetConfig] objects. @@ -316,11 +362,13 @@ class MainActivity : BaseActivity() { super.onDestroy() mainHandler.removeCallbacks(refreshRunnable) mainHandler.removeCallbacksAndMessages(null) + webSocketManager?.disconnect() } override fun onTrimMemory(level: Int) { super.onTrimMemory(level) if (level >= android.content.ComponentCallbacks2.TRIM_MEMORY_MODERATE) { mainHandler.removeCallbacks(refreshRunnable) + webSocketManager?.disconnect() allEntities.clear() displayedEntities.clear() adapter.notifyDataSetChanged() diff --git a/app/src/main/java/com/example/retroha/network/HaWebSocketManager.kt b/app/src/main/java/com/example/retroha/network/HaWebSocketManager.kt new file mode 100644 index 0000000..97d0ad1 --- /dev/null +++ b/app/src/main/java/com/example/retroha/network/HaWebSocketManager.kt @@ -0,0 +1,112 @@ +package com.example.retroha.network + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.util.Log +import com.example.retroha.data.Prefs +import com.google.gson.Gson +import com.google.gson.JsonObject +import okhttp3.* +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +/** + * Manages a persistent WebSocket connection to Home Assistant. + * Handles authentication, automatic reconnection, and event subscriptions. + */ +class HaWebSocketManager( + private val context: Context, + private val onStateChanged: (HaState) -> Unit +) { + private val client = OkHttpClient.Builder() + .readTimeout(0, TimeUnit.MILLISECONDS) + .pingInterval(30, TimeUnit.SECONDS) + .build() + + private var webSocket: WebSocket? = null + private val gson = Gson() + private val messageId = AtomicInteger(1) + private val mainHandler = Handler(Looper.getMainLooper()) + private var isConnected = false + private var reconnectAttempt = 0 + + private fun getAuthMessage(): String { + val token = Prefs.getToken(context) + return """{"type": "auth", "access_token": "$token"}""" + } + + /** Connects to the HA WebSocket API. */ + fun connect() { + val rawUrl = Prefs.getUrl(context) + if (rawUrl.isEmpty()) return + + val wsUrl = rawUrl.replace("http://", "ws://") + .replace("https://", "wss://") + .let { if (it.endsWith("/")) it else "$it/" } + "api/websocket" + + val request = Request.Builder().url(wsUrl).build() + webSocket = client.newWebSocket(request, object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + Log.d("HaWebSocket", "Connected") + isConnected = true + reconnectAttempt = 0 + } + + override fun onMessage(webSocket: WebSocket, text: String) { + val json = gson.fromJson(text, JsonObject::class.java) + when (json.get("type")?.asString) { + "auth_required" -> webSocket.send(getAuthMessage()) + "auth_ok" -> { + Log.d("HaWebSocket", "Authenticated") + subscribeToStates() + } + "event" -> handleEvent(json) + } + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + isConnected = false + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + Log.e("HaWebSocket", "Error: ${t.message}") + isConnected = false + scheduleReconnect() + } + }) + } + + private fun subscribeToStates() { + val id = messageId.getAndIncrement() + val msg = """{"id": $id, "type": "subscribe_events", "event_type": "state_changed"}""" + webSocket?.send(msg) + } + + private fun handleEvent(json: JsonObject) { + val event = json.getAsJsonObject("event") + if (event?.get("event_type")?.asString == "state_changed") { + val data = event.getAsJsonObject("data") + val newState = data?.getAsJsonObject("new_state") + if (newState != null) { + try { + val haState = gson.fromJson(newState, HaState::class.java) + mainHandler.post { onStateChanged(haState) } + } catch (e: Exception) { + Log.e("HaWebSocket", "Parse error: ${e.message}") + } + } + } + } + + private fun scheduleReconnect() { + val delay = (1L shl Math.min(reconnectAttempt++, 5)) * 1000L + mainHandler.postDelayed({ connect() }, delay) + } + + /** Closes the connection. */ + fun disconnect() { + webSocket?.close(1000, "App closing") + webSocket = null + } +}