feat(network): implement real-time updates via websockets
All checks were successful
Update Wiki Documentation / generate-docs (push) Successful in 2m26s
All checks were successful
Update Wiki Documentation / generate-docs (push) Successful in 2m26s
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user