feat(network): implement real-time updates via websockets
All checks were successful
Update Wiki Documentation / generate-docs (push) Successful in 2m26s

This commit is contained in:
Krzysztof Cieślik
2026-06-14 15:48:32 +02:00
parent 246035887f
commit e8db4da537
2 changed files with 160 additions and 0 deletions

View File

@@ -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()

View File

@@ -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
}
}