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.HaClient
|
||||||
import com.example.retroha.network.HaState
|
import com.example.retroha.network.HaState
|
||||||
import com.example.retroha.network.ToggleRequest
|
import com.example.retroha.network.ToggleRequest
|
||||||
|
import com.example.retroha.network.HaWebSocketManager
|
||||||
import retrofit2.Call
|
import retrofit2.Call
|
||||||
import retrofit2.Callback
|
import retrofit2.Callback
|
||||||
import retrofit2.Response
|
import retrofit2.Response
|
||||||
@@ -44,6 +45,8 @@ class MainActivity : BaseActivity() {
|
|||||||
private var currentCategory = StringKey.TAB_ALL
|
private var currentCategory = StringKey.TAB_ALL
|
||||||
private lateinit var tvStatusIndicator: TextView
|
private lateinit var tvStatusIndicator: TextView
|
||||||
private lateinit var tabContainer: LinearLayout
|
private lateinit var tabContainer: LinearLayout
|
||||||
|
private var webSocketManager: HaWebSocketManager? = null
|
||||||
|
|
||||||
private val refreshRunnable = object : Runnable {
|
private val refreshRunnable = object : Runnable {
|
||||||
override fun run() {
|
override fun run() {
|
||||||
fetchHaStates()
|
fetchHaStates()
|
||||||
@@ -86,6 +89,10 @@ class MainActivity : BaseActivity() {
|
|||||||
Toast.makeText(this, stringsProvider.get(StringKey.STATUS_REFRESHING), Toast.LENGTH_SHORT).show()
|
Toast.makeText(this, stringsProvider.get(StringKey.STATUS_REFRESHING), Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
setupTabs()
|
setupTabs()
|
||||||
|
|
||||||
|
webSocketManager = HaWebSocketManager(this) { haState ->
|
||||||
|
handleStateUpdate(haState)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Initializes the category filtering tabs.
|
* Initializes the category filtering tabs.
|
||||||
@@ -167,10 +174,12 @@ class MainActivity : BaseActivity() {
|
|||||||
super.onResume()
|
super.onResume()
|
||||||
mainHandler.removeCallbacks(refreshRunnable)
|
mainHandler.removeCallbacks(refreshRunnable)
|
||||||
mainHandler.post(refreshRunnable)
|
mainHandler.post(refreshRunnable)
|
||||||
|
webSocketManager?.connect()
|
||||||
}
|
}
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
super.onPause()
|
super.onPause()
|
||||||
mainHandler.removeCallbacks(refreshRunnable)
|
mainHandler.removeCallbacks(refreshRunnable)
|
||||||
|
webSocketManager?.disconnect()
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Fetches the latest states from Home Assistant and updates the UI.
|
* 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.
|
* Updates the local [allEntities] list with new data from HA.
|
||||||
* Maps raw HA states to [WidgetConfig] objects.
|
* Maps raw HA states to [WidgetConfig] objects.
|
||||||
@@ -316,11 +362,13 @@ class MainActivity : BaseActivity() {
|
|||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
mainHandler.removeCallbacks(refreshRunnable)
|
mainHandler.removeCallbacks(refreshRunnable)
|
||||||
mainHandler.removeCallbacksAndMessages(null)
|
mainHandler.removeCallbacksAndMessages(null)
|
||||||
|
webSocketManager?.disconnect()
|
||||||
}
|
}
|
||||||
override fun onTrimMemory(level: Int) {
|
override fun onTrimMemory(level: Int) {
|
||||||
super.onTrimMemory(level)
|
super.onTrimMemory(level)
|
||||||
if (level >= android.content.ComponentCallbacks2.TRIM_MEMORY_MODERATE) {
|
if (level >= android.content.ComponentCallbacks2.TRIM_MEMORY_MODERATE) {
|
||||||
mainHandler.removeCallbacks(refreshRunnable)
|
mainHandler.removeCallbacks(refreshRunnable)
|
||||||
|
webSocketManager?.disconnect()
|
||||||
allEntities.clear()
|
allEntities.clear()
|
||||||
displayedEntities.clear()
|
displayedEntities.clear()
|
||||||
adapter.notifyDataSetChanged()
|
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