aboutsummaryrefslogtreecommitdiff
path: root/app/src/main/java
diff options
context:
space:
mode:
authorBlaster4385 <[email protected]>2025-05-02 09:58:28 +0530
committerBlaster4385 <[email protected]>2025-05-02 22:52:49 +0530
commit0595e85714235e15d970dd601af1f6dfc596ad43 (patch)
treefa342dabbdffc7b996cc4db67464cd495f1e04a7 /app/src/main/java
parent34712408abd6accff0d72ff3962dded2de81396a (diff)
feat: added mqtt client implementation
- Also added a service to send battery information over mqtt
Diffstat (limited to 'app/src/main/java')
-rw-r--r--app/src/main/java/dev/tablaster/dashpanel/MainActivity.kt125
-rw-r--r--app/src/main/java/dev/tablaster/dashpanel/mqtt/ConnectionData.kt54
-rw-r--r--app/src/main/java/dev/tablaster/dashpanel/mqtt/IMqttManagerListener.kt8
-rw-r--r--app/src/main/java/dev/tablaster/dashpanel/mqtt/MQTTModule.kt108
-rw-r--r--app/src/main/java/dev/tablaster/dashpanel/mqtt/MQTTOptions.kt134
-rw-r--r--app/src/main/java/dev/tablaster/dashpanel/mqtt/MQTTService.kt452
-rw-r--r--app/src/main/java/dev/tablaster/dashpanel/mqtt/MQTTServiceInterface.kt14
-rw-r--r--app/src/main/java/dev/tablaster/dashpanel/sensor/BatterySensorService.kt201
8 files changed, 1084 insertions, 12 deletions
diff --git a/app/src/main/java/dev/tablaster/dashpanel/MainActivity.kt b/app/src/main/java/dev/tablaster/dashpanel/MainActivity.kt
index 3857280..e59b685 100644
--- a/app/src/main/java/dev/tablaster/dashpanel/MainActivity.kt
+++ b/app/src/main/java/dev/tablaster/dashpanel/MainActivity.kt
@@ -2,6 +2,8 @@ package dev.tablaster.dashpanel
import android.annotation.SuppressLint
import android.content.Intent
+import android.content.SharedPreferences
+import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.view.WindowManager
@@ -15,37 +17,88 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
+import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager
-
import com.google.android.material.floatingactionbutton.FloatingActionButton
+import dev.tablaster.dashpanel.mqtt.ConnectionData
+import dev.tablaster.dashpanel.mqtt.MQTTModule
+import dev.tablaster.dashpanel.mqtt.MQTTOptions
+import dev.tablaster.dashpanel.sensor.BatterySensorService
+import kotlinx.coroutines.launch
+import androidx.core.net.toUri
-class MainActivity : AppCompatActivity() {
+class MainActivity : AppCompatActivity(), MQTTModule.MQTTListener {
private lateinit var webView: WebView
private lateinit var settingsButton: FloatingActionButton
+ private lateinit var mqttOptions: MQTTOptions
+ private lateinit var mqttModule: MQTTModule
+ private lateinit var connectionData: ConnectionData
+ private var batterySensorService: BatterySensorService? = null
+
+ private lateinit var sharedPref: SharedPreferences
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- val sharedPref = PreferenceManager.getDefaultSharedPreferences(this)
+ sharedPref = PreferenceManager.getDefaultSharedPreferences(this)
+
setupScreen()
setContentView(R.layout.activity_main)
- val url = sharedPref.getString("dashboard_url", "")
+
+ initializeMqtt()
+
+ connectionData = ConnectionData(this)
+ connectionData.observe(this) { isConnected ->
+ if (isConnected) {
+ Log.d("MainActivity", "Network connected.")
+ mqttModule.restart()
+ } else {
+ Log.d("MainActivity", "Network disconnected.")
+ mqttModule.pause()
+ }
+ }
+ }
+
+ override fun onWindowFocusChanged(hasFocus: Boolean) {
val settingsPassCodePref = sharedPref.getInt("settings_passcode", 0)
settingsButton = findViewById(R.id.settings_button)
settingsButton.setOnClickListener { openSettings(settingsPassCodePref) }
- if (url != null) {
+ val url = sharedPref.getString("dashboard_url", "")
+ if (!url.isNullOrBlank()) {
setupWebView(url)
+ } else {
+ openSettings(0)
}
- }
-
- override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) {
setupScreen()
+ handleSensors()
+ }
+ }
+
+ private fun initializeMqtt() {
+ mqttOptions = MQTTOptions(sharedPref)
+ mqttModule = MQTTModule(this, mqttOptions, this)
+ lifecycle.addObserver(mqttModule)
+ }
+
+ private fun handleSensors() {
+ val sensorBatteryPref = sharedPref.getBoolean("sensor_battery", false)
+ val updateInterval = sharedPref.getString("sensor_update_interval", "15")?.toIntOrNull() ?: 15
+
+ if (sensorBatteryPref) {
+ if (batterySensorService == null) {
+ batterySensorService = BatterySensorService(this, mqttModule, mqttOptions, updateInterval)
+ lifecycle.addObserver(batterySensorService!!)
+ }
+ } else {
+ batterySensorService?.stop()
+ batterySensorService = null
}
}
@SuppressLint("SetJavaScriptEnabled")
private fun setupWebView(url: String) {
- if (url != "") {
+ if (!::webView.isInitialized) {
webView = findViewById(R.id.webview)
val webSettings = webView.settings
webSettings.javaScriptEnabled = true
@@ -60,9 +113,30 @@ class MainActivity : AppCompatActivity() {
webSettings.mediaPlaybackRequiresUserGesture = false
webView.webViewClient = WebViewClient()
webView.webChromeClient = WebChromeClient()
+ }
+
+ val currentUrl = webView.url
+ val currentOrigin = currentUrl?.let { it.toUri().origin() }
+ val targetOrigin = url.toUri().origin()
+
+ if (currentOrigin != targetOrigin) {
+ Log.d("MainActivity", "WebView origin changed: $currentOrigin -> $targetOrigin. Reloading.")
webView.loadUrl(url)
} else {
- openSettings(0)
+ Log.d("MainActivity", "WebView already showing the same origin. Skipping reload.")
+ }
+ }
+
+ private fun Uri.origin(): String {
+ val portPart = if (port != -1 && port != defaultPortForScheme()) ":$port" else ""
+ return "$scheme://$host$portPart"
+ }
+
+ private fun Uri.defaultPortForScheme(): Int {
+ return when (scheme) {
+ "http" -> 80
+ "https" -> 443
+ else -> -1
}
}
@@ -82,7 +156,6 @@ class MainActivity : AppCompatActivity() {
}
private fun setupScreen() {
- val sharedPref = PreferenceManager.getDefaultSharedPreferences(this)
val fullScreenPref = sharedPref.getBoolean("full_screen", false)
val screenOnPref = sharedPref.getBoolean("screen_on", false)
@@ -99,4 +172,32 @@ class MainActivity : AppCompatActivity() {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
-} \ No newline at end of file
+
+ override fun onMQTTConnect() {
+ Log.d("MainActivity", "MQTT Connected")
+ lifecycleScope.launch {
+ Toast.makeText(this@MainActivity, "Connected to MQTT broker", Toast.LENGTH_SHORT).show()
+ }
+
+ batterySensorService?.onMqttConnected()
+ }
+
+ override fun onMQTTDisconnect() {
+ Log.d("MainActivity", "MQTT Disconnected")
+ lifecycleScope.launch {
+ Toast.makeText(this@MainActivity, "Disconnected from MQTT broker", Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ override fun onMQTTException(message: String) {
+ Log.e("MainActivity", "MQTT Error: $message")
+ lifecycleScope.launch {
+ Toast.makeText(this@MainActivity, "MQTT Error: $message", Toast.LENGTH_LONG).show()
+ }
+ }
+
+ override fun onMQTTMessage(id: String, topic: String, payload: String) {
+ Log.d("MainActivity", "MQTT Message - Topic: $topic, Payload: $payload")
+ // TODO Handle incoming mqtt commands.
+ }
+}
diff --git a/app/src/main/java/dev/tablaster/dashpanel/mqtt/ConnectionData.kt b/app/src/main/java/dev/tablaster/dashpanel/mqtt/ConnectionData.kt
new file mode 100644
index 0000000..34430b0
--- /dev/null
+++ b/app/src/main/java/dev/tablaster/dashpanel/mqtt/ConnectionData.kt
@@ -0,0 +1,54 @@
+package dev.tablaster.dashpanel.mqtt
+
+import android.content.Context
+import android.net.ConnectivityManager
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.util.Log
+import androidx.lifecycle.LiveData
+
+class ConnectionData(private val context: Context) : LiveData<Boolean>() {
+
+ private val connectivityManager =
+ context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+
+ private val networkCallback = object : ConnectivityManager.NetworkCallback() {
+ override fun onAvailable(network: Network) {
+ Log.d("ConnectionData", "Network available")
+ postValue(true)
+ }
+
+ override fun onLost(network: Network) {
+ Log.d("ConnectionData", "Network lost")
+ postValue(false)
+ }
+ }
+
+ override fun onActive() {
+ super.onActive()
+ Log.d("ConnectionData", "Registering network callback.")
+ postValue(isConnected())
+ try {
+ connectivityManager.registerDefaultNetworkCallback(networkCallback)
+ } catch (e: Exception) {
+ Log.e("ConnectionData", "Error registering network callback", e)
+ }
+ }
+
+ override fun onInactive() {
+ super.onInactive()
+ Log.d("ConnectionData", "Unregistering network callback.")
+ try {
+ connectivityManager.unregisterNetworkCallback(networkCallback)
+ } catch (e: Exception) {
+ Log.e("ConnectionData", "Error unregistering network callback", e)
+ }
+ }
+
+ private fun isConnected(): Boolean {
+ val network = connectivityManager.activeNetwork ?: return false
+ val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
+ return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
+ capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
+ }
+}
diff --git a/app/src/main/java/dev/tablaster/dashpanel/mqtt/IMqttManagerListener.kt b/app/src/main/java/dev/tablaster/dashpanel/mqtt/IMqttManagerListener.kt
new file mode 100644
index 0000000..e2c3f62
--- /dev/null
+++ b/app/src/main/java/dev/tablaster/dashpanel/mqtt/IMqttManagerListener.kt
@@ -0,0 +1,8 @@
+package dev.tablaster.dashpanel.mqtt
+
+interface IMqttManagerListener {
+ fun subscriptionMessage(id: String, topic: String, payload: String)
+ fun handleMqttException(errorMessage: String)
+ fun handleMqttDisconnected()
+ fun handleMqttConnected()
+} \ No newline at end of file
diff --git a/app/src/main/java/dev/tablaster/dashpanel/mqtt/MQTTModule.kt b/app/src/main/java/dev/tablaster/dashpanel/mqtt/MQTTModule.kt
new file mode 100644
index 0000000..f90bfb7
--- /dev/null
+++ b/app/src/main/java/dev/tablaster/dashpanel/mqtt/MQTTModule.kt
@@ -0,0 +1,108 @@
+package dev.tablaster.dashpanel.mqtt
+
+import android.content.Context
+import android.content.ContextWrapper
+import android.util.Log
+import androidx.lifecycle.*
+import com.hivemq.client.mqtt.mqtt5.exceptions.Mqtt5MessageException
+
+class MQTTModule(
+ base: Context?,
+ var mqttOptions: MQTTOptions,
+ private val listener: MQTTListener
+) : ContextWrapper(base), IMqttManagerListener, DefaultLifecycleObserver {
+
+ private var mqttService: MQTTService? = null
+ private val TAG = "MQTTModule"
+
+ override fun onStart(owner: LifecycleOwner) {
+ Log.d(TAG, "start")
+ startMqtt()
+ }
+
+ override fun onStop(owner: LifecycleOwner) {
+ Log.d(TAG, "stop")
+ stopMqtt()
+ }
+
+ private fun startMqtt() {
+ if (mqttService == null) {
+ try {
+ mqttService = MQTTService(applicationContext, mqttOptions, this)
+ Log.d(TAG, "MQTT service created")
+ } catch (t: Throwable) {
+ Log.e(TAG, "Could not create MQTT Service: ${t.message}", t)
+ listener.onMQTTException("Failed to create MQTT service: ${t.message}")
+ }
+ } else {
+ try {
+ mqttService?.reconfigure(applicationContext, mqttOptions, this)
+ Log.d(TAG, "MQTT service reconfigured")
+ } catch (t: Throwable) {
+ Log.e(TAG, "Could not reconfigure MQTT Service: ${t.message}", t)
+ listener.onMQTTException("Failed to reconfigure MQTT service: ${t.message}")
+ }
+ }
+ }
+
+ private fun stopMqtt() {
+ mqttService?.let {
+ try {
+ it.close()
+ Log.d(TAG, "MQTT service closed")
+ } catch (e: Mqtt5MessageException) {
+ Log.e(TAG, "Error closing MQTT service", e)
+ } catch (e: Exception) {
+ Log.e(TAG, "Unexpected error closing MQTT service", e)
+ }
+ mqttService = null
+ }
+ }
+
+ fun isReady(): Boolean? {
+ return mqttService?.isReady
+ }
+
+ fun restart() {
+ Log.d(TAG, "restart")
+ stopMqtt()
+ startMqtt()
+ }
+
+ fun pause() {
+ Log.d(TAG, "pause")
+ stopMqtt()
+ }
+
+ fun publish(topic: String, message: String, retain: Boolean) {
+ Log.d(TAG, "Publishing - topic: $topic, message: $message, retain: $retain")
+ mqttService?.publish(topic, message, retain)
+ }
+
+ override fun subscriptionMessage(id: String, topic: String, payload: String) {
+ Log.d(TAG, "Received message on topic: $topic")
+ listener.onMQTTMessage(id, topic, payload)
+ }
+
+ override fun handleMqttException(errorMessage: String) {
+ Log.e(TAG, "MQTT exception: $errorMessage")
+ listener.onMQTTException(errorMessage)
+ }
+
+ override fun handleMqttDisconnected() {
+ Log.d(TAG, "MQTT disconnected")
+ listener.onMQTTDisconnect()
+ }
+
+ override fun handleMqttConnected() {
+ Log.d(TAG, "MQTT connected")
+ listener.onMQTTConnect()
+ }
+
+ interface MQTTListener {
+ fun onMQTTConnect()
+ fun onMQTTDisconnect()
+ fun onMQTTException(message: String)
+ fun onMQTTMessage(id: String, topic: String, payload: String)
+ }
+} \ No newline at end of file
diff --git a/app/src/main/java/dev/tablaster/dashpanel/mqtt/MQTTOptions.kt b/app/src/main/java/dev/tablaster/dashpanel/mqtt/MQTTOptions.kt
new file mode 100644
index 0000000..8bdaa4e
--- /dev/null
+++ b/app/src/main/java/dev/tablaster/dashpanel/mqtt/MQTTOptions.kt
@@ -0,0 +1,134 @@
+package dev.tablaster.dashpanel.mqtt
+
+import android.content.SharedPreferences
+import android.util.Log
+import java.util.Locale
+
+class MQTTOptions(private val sharedPreferences: SharedPreferences) {
+
+ companion object {
+ const val SSL_BROKER_URL_FORMAT = "ssl://%s:%d"
+ const val TCP_BROKER_URL_FORMAT = "tcp://%s:%d"
+ const val HTTP_BROKER_URL_FORMAT = "%s:%d"
+
+ private const val HTTP_PREFIX = "http://"
+ private const val HTTPS_PREFIX = "https://"
+
+ }
+
+ val brokerUrl: String by lazy {
+ val broker = getBroker()
+ if (broker.isNotEmpty()) {
+ when {
+ broker.startsWith(HTTP_PREFIX) || broker.startsWith(HTTPS_PREFIX) -> {
+ String.format(Locale.getDefault(), HTTP_BROKER_URL_FORMAT, broker, getPort())
+ }
+ getTlsConnection() -> {
+ String.format(Locale.getDefault(), SSL_BROKER_URL_FORMAT, broker, getPort())
+ }
+ else -> {
+ String.format(Locale.getDefault(), TCP_BROKER_URL_FORMAT, broker, getPort())
+ }
+ }
+ } else {
+ Log.w("MQTTOptions", "Empty broker address specified")
+ ""
+ }
+ }
+
+ val isValid: Boolean get() {
+ if (!isMqttEnabled()) {
+ Log.d("MQTTOptions", "MQTT is disabled in configuration")
+ return false
+ }
+
+ val broker = getBroker()
+ val clientId = getClientId()
+ val stateTopic = getStateTopic()
+
+ if (broker.isEmpty()) {
+ Log.d("MQTTOptions", "Invalid configuration: Broker address is empty")
+ return false
+ }
+
+ if (clientId.isEmpty()) {
+ Log.d("MQTTOptions", "Invalid configuration: Client ID is empty")
+ return false
+ }
+
+ if (stateTopic.isEmpty()) {
+ Log.d("MQTTOptions", "Invalid configuration: State topic is empty")
+ return false
+ }
+
+ return if (getTlsConnection()) {
+ val baseTopic = getBaseTopic()
+ val username = getUsername()
+ val password = getPassword()
+
+ val isValidTls = baseTopic.isNotEmpty() &&
+ username.isNotEmpty() &&
+ password.isNotEmpty()
+
+ if (!isValidTls) {
+ Log.d("MQTTOptions", "Invalid TLS configuration: missing required fields")
+ }
+
+ isValidTls
+ } else {
+ true
+ }
+ }
+
+ fun getVersion(): String = getStringPreference("mqtt_version")
+
+ fun getBroker(): String = getStringPreference("mqtt_broker").trim()
+
+ fun getClientId(): String = getStringPreference("mqtt_client_id").trim()
+
+ fun getBaseTopic(): String = getStringPreference("mqtt_base_topic").trim()
+
+ fun getStateTopic(): String {
+ val baseTopic = getBaseTopic()
+ return if (baseTopic.isNotEmpty()) {
+ "$baseTopic/command"
+ } else {
+ ""
+ }
+ }
+
+ fun getStateTopics(): Array<String> {
+ val stateTopic = getStateTopic()
+ return if (stateTopic.isNotEmpty()) {
+ arrayOf(stateTopic)
+ } else {
+ emptyArray()
+ }
+ }
+
+ fun getUsername(): String = getStringPreference("mqtt_username").trim()
+
+ fun getPassword(): String = getStringPreference("mqtt_password")
+
+ fun getPort(): Int = getIntPreference("mqtt_port")
+
+ fun getTlsConnection(): Boolean = getBooleanPreference("mqtt_tls")
+
+ private fun getStringPreference(key: String): String {
+ return sharedPreferences.getString(key, "") ?: ""
+ }
+
+ private fun getIntPreference(key: String): Int {
+ val default = 1883
+ val stringValue = sharedPreferences.getString(key, default.toString())
+ return stringValue?.toIntOrNull() ?: default // Default to 1883 for MQTT
+ }
+
+ private fun getBooleanPreference(key: String): Boolean {
+ return sharedPreferences.getBoolean(key, false)
+ }
+
+ private fun isMqttEnabled(): Boolean {
+ return sharedPreferences.getBoolean("mqtt_enabled", false)
+ }
+}
diff --git a/app/src/main/java/dev/tablaster/dashpanel/mqtt/MQTTService.kt b/app/src/main/java/dev/tablaster/dashpanel/mqtt/MQTTService.kt
new file mode 100644
index 0000000..28ae1d6
--- /dev/null
+++ b/app/src/main/java/dev/tablaster/dashpanel/mqtt/MQTTService.kt
@@ -0,0 +1,452 @@
+package dev.tablaster.dashpanel.mqtt
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.text.TextUtils
+import android.util.Log
+import com.hivemq.client.mqtt.MqttClient
+import com.hivemq.client.mqtt.MqttGlobalPublishFilter
+import com.hivemq.client.mqtt.datatypes.MqttQos
+import com.hivemq.client.mqtt.mqtt3.Mqtt3AsyncClient
+import com.hivemq.client.mqtt.mqtt3.message.publish.Mqtt3Publish
+import com.hivemq.client.mqtt.mqtt5.Mqtt5AsyncClient
+import com.hivemq.client.mqtt.mqtt5.exceptions.Mqtt5AuthException
+import com.hivemq.client.mqtt.mqtt5.exceptions.Mqtt5ConnAckException
+import com.hivemq.client.mqtt.mqtt5.message.publish.Mqtt5Publish
+import java.util.concurrent.atomic.AtomicBoolean
+import kotlin.text.Charsets.UTF_8
+
+class MQTTService(
+ private var context: Context,
+ private var mqttOptions: MQTTOptions,
+ private var listener: IMqttManagerListener?
+) : MQTTServiceInterface {
+
+ private var mqtt3AsyncClient: Mqtt3AsyncClient? = null
+ private var mqtt5AsyncClient: Mqtt5AsyncClient? = null
+ private val mIsReady = AtomicBoolean(false)
+ private val isMqtt5: Boolean
+ get() = mqttOptions.getVersion() != "3.1.1"
+
+ companion object {
+ private const val ONLINE = "online"
+ private const val OFFLINE = "offline"
+ private const val CONNECTION = "connection"
+ private const val TAG = "MQTTService"
+ }
+
+ init {
+ initialize(mqttOptions)
+ }
+
+ override fun reconfigure(
+ context: Context,
+ options: MQTTOptions,
+ listener: IMqttManagerListener
+ ) {
+ Log.d(TAG, "Reconfiguring MQTT service")
+ safelyDisconnect()
+ this.listener = listener
+ this.context = context
+ initialize(options)
+ }
+
+ override val isReady: Boolean
+ get() = mIsReady.get()
+
+ override fun close() {
+ Log.d(TAG, "Closing MQTT connection")
+ safelyDisconnect()
+ mqtt3AsyncClient = null
+ mqtt5AsyncClient = null
+ listener = null
+ mIsReady.set(false)
+ }
+
+ private fun safelyDisconnect() {
+ try {
+ if (isMqtt5) {
+ mqtt5AsyncClient?.let { client ->
+ if (client.state.isConnected) {
+ sendOfflineStatusMqtt5(client)
+ client.disconnect().get()
+ }
+ }
+ } else {
+ mqtt3AsyncClient?.let { client ->
+ if (client.state.isConnected) {
+ sendOfflineStatusMqtt3(client)
+ client.disconnect().get()
+ }
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error during disconnect", e)
+ }
+ }
+
+ private fun sendOfflineStatusMqtt3(client: Mqtt3AsyncClient) {
+ try {
+ val offlineMessage = Mqtt3Publish.builder()
+ .topic("${mqttOptions.getBaseTopic()}/dashpanel/$CONNECTION")
+ .payload(OFFLINE.toByteArray())
+ .retain(true)
+ .build()
+
+ client.publish(offlineMessage).get()
+ } catch (e: Exception) {
+ Log.e(TAG, "Error sending offline status (MQTT3)", e)
+ }
+ }
+
+ private fun sendOfflineStatusMqtt5(client: Mqtt5AsyncClient) {
+ try {
+ val offlineMessage = Mqtt5Publish.builder()
+ .topic("${mqttOptions.getBaseTopic()}/dashpanel/$CONNECTION")
+ .payload(OFFLINE.toByteArray())
+ .retain(true)
+ .build()
+
+ client.publish(offlineMessage).get()
+ } catch (e: Exception) {
+ Log.e(TAG, "Error sending offline status (MQTT5)", e)
+ }
+ }
+
+ override fun publish(topic: String, payload: String, retain: Boolean) {
+ if (!mIsReady.get()) {
+ Log.w(TAG, "Attempted to publish when client is not ready")
+ return
+ }
+
+ try {
+ if (isMqtt5) {
+ publishMqtt5(topic, payload, retain)
+ } else {
+ publishMqtt3(topic, payload, retain)
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Exception while publishing to topic: $topic", e)
+ listener?.handleMqttException("Exception while publishing to topic: $topic - ${e.message}")
+ }
+ }
+
+ private fun publishMqtt3(topic: String, payload: String, retain: Boolean) {
+ mqtt3AsyncClient?.let { client ->
+ if (client.state.isConnected) {
+ val mqttMessage = Mqtt3Publish.builder()
+ .topic(topic)
+ .payload(payload.toByteArray())
+ .retain(retain)
+ .build()
+
+ client.publish(mqttMessage)
+ .whenComplete { _, throwable ->
+ if (throwable != null) {
+ Log.e(TAG, "Failed to publish message to topic: $topic", throwable)
+ listener?.handleMqttException("Failed to publish message to topic: $topic")
+ } else {
+ Log.d(TAG, "Published message to topic: $topic")
+ }
+ }
+ } else {
+ Log.w(TAG, "MQTT3 client disconnected, cannot publish to topic: $topic")
+ }
+ } ?: Log.e(TAG, "MQTT3 client is null, cannot publish")
+ }
+
+ private fun publishMqtt5(topic: String, payload: String, retain: Boolean) {
+ mqtt5AsyncClient?.let { client ->
+ if (client.state.isConnected) {
+ val mqttMessage = Mqtt5Publish.builder()
+ .topic(topic)
+ .payload(payload.toByteArray())
+ .retain(retain)
+ .build()
+
+ client.publish(mqttMessage)
+ .whenComplete { _, throwable ->
+ if (throwable != null) {
+ Log.e(TAG, "Failed to publish message to topic: $topic", throwable)
+ listener?.handleMqttException("Failed to publish message to topic: $topic")
+ } else {
+ Log.d(TAG, "Published message to topic: $topic")
+ }
+ }
+ } else {
+ Log.w(TAG, "MQTT5 client disconnected, cannot publish to topic: $topic")
+ }
+ } ?: Log.e(TAG, "MQTT5 client is null, cannot publish")
+ }
+
+ private fun initialize(options: MQTTOptions) {
+ Log.d(TAG, "Initializing MQTT service")
+ mqttOptions = options
+
+ if (!options.isValid) {
+ Log.e(TAG, "Invalid MQTT options")
+ listener?.handleMqttDisconnected()
+ return
+ }
+
+ logConfiguration(options)
+
+ if (isMqtt5) {
+ initializeMqtt5Client()
+ } else {
+ initializeMqtt3Client()
+ }
+ }
+
+ private fun logConfiguration(options: MQTTOptions) {
+ Log.i(TAG, "MQTT Configuration:")
+ Log.i(TAG, "Protocol version: ${options.getVersion()}")
+ Log.i(TAG, "Client ID: ${options.getClientId()}")
+ Log.i(TAG, "Username: ${options.getUsername()}")
+ Log.i(TAG, "Password: ${if (options.getPassword().isNotEmpty()) "****" else "empty"}")
+ Log.i(TAG, "TLS Connect: ${options.getTlsConnection()}")
+ Log.i(TAG, "Broker: ${options.brokerUrl}")
+ Log.i(TAG, "Subscribed to state topics: ${options.getStateTopics().joinToString(", ")}")
+ Log.i(TAG, "Publishing to base topic: ${options.getBaseTopic()}")
+ }
+
+ @SuppressLint("CheckResult")
+ private fun initializeMqtt3Client() {
+ Log.d(TAG, "Setting up MQTT3 client")
+
+ try {
+ val mqttBuilder = MqttClient.builder()
+ .identifier(mqttOptions.getClientId())
+ .serverHost(mqttOptions.getBroker())
+ .serverPort(mqttOptions.getPort())
+
+ mqttBuilder.addConnectedListener { _ ->
+ Log.d(TAG, "Connected to broker (MQTT3)")
+
+ subscribeToTopicsMqtt3(mqttOptions.getStateTopics())
+
+ mqtt3AsyncClient?.let { client ->
+ val onlineMessage = Mqtt3Publish.builder()
+ .topic("${mqttOptions.getBaseTopic()}/dashpanel/$CONNECTION")
+ .payload(ONLINE.toByteArray())
+ .retain(true)
+ .build()
+
+ client.publish(onlineMessage)
+ .whenComplete { _, throwable ->
+ if (throwable != null) {
+ Log.e(TAG, "Failed to publish online status (MQTT3)", throwable)
+ } else {
+ Log.d(TAG, "Published online status (MQTT3)")
+ mIsReady.set(true)
+ listener?.handleMqttConnected()
+ }
+ }
+ }
+ }
+
+ mqttBuilder.addDisconnectedListener { context ->
+ mIsReady.set(false)
+ listener?.handleMqttDisconnected()
+ Log.e(TAG, "Disconnected from: ${mqttOptions.brokerUrl}, cause: ${context?.cause?.message}")
+ }
+
+ mqtt3AsyncClient = mqttBuilder.useMqttVersion3().build().toAsync()
+
+ val clientConnect = mqtt3AsyncClient!!.connectWith()
+ .cleanSession(false)
+ .willPublish()
+ .topic("${mqttOptions.getBaseTopic()}/dashpanel/$CONNECTION")
+ .payload(OFFLINE.toByteArray())
+ .qos(MqttQos.EXACTLY_ONCE)
+ .retain(true)
+ .applyWillPublish()
+
+ if (!TextUtils.isEmpty(mqttOptions.getUsername()) && !TextUtils.isEmpty(mqttOptions.getPassword())) {
+ clientConnect.simpleAuth()
+ .username(mqttOptions.getUsername())
+ .password(mqttOptions.getPassword().toByteArray())
+ .applySimpleAuth()
+ }
+
+ clientConnect.send()
+ .whenComplete { _, throwable ->
+ if (throwable != null) {
+ Log.e(TAG, "Failed to connect to broker (MQTT3)", throwable)
+ listener?.handleMqttException("Failed to connect: ${throwable.message}")
+ } else {
+ Log.d(TAG, "Connection initiated (MQTT3)")
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Exception during MQTT3 client initialization", e)
+ listener?.handleMqttException("Failed to initialize MQTT3 client: ${e.message}")
+ }
+ }
+
+ @SuppressLint("CheckResult")
+ private fun initializeMqtt5Client() {
+ Log.d(TAG, "Setting up MQTT5 client")
+
+ try {
+ val mqttBuilder = MqttClient.builder()
+ .identifier(mqttOptions.getClientId())
+ .serverHost(mqttOptions.getBroker())
+ .serverPort(mqttOptions.getPort())
+
+ mqttBuilder.addConnectedListener { _ ->
+ Log.d(TAG, "Connected to broker (MQTT5)")
+
+ subscribeToTopicsMqtt5(mqttOptions.getStateTopics())
+
+ mqtt5AsyncClient?.let { client ->
+ val onlineMessage = Mqtt5Publish.builder()
+ .topic("${mqttOptions.getBaseTopic()}/dashpanel/$CONNECTION")
+ .payload(ONLINE.toByteArray())
+ .retain(true)
+ .build()
+
+ client.publish(onlineMessage)
+ .whenComplete { _, throwable ->
+ if (throwable != null) {
+ Log.e(TAG, "Failed to publish online status (MQTT5)", throwable)
+ } else {
+ Log.d(TAG, "Published online status (MQTT5)")
+ mIsReady.set(true)
+ listener?.handleMqttConnected()
+ }
+ }
+ }
+ }
+
+ mqttBuilder.addDisconnectedListener { context ->
+ mIsReady.set(false)
+ listener?.handleMqttDisconnected()
+ Log.e(TAG, "Disconnected from: ${mqttOptions.brokerUrl}, cause: ${context?.cause?.message}")
+ }
+
+ mqtt5AsyncClient = mqttBuilder.useMqttVersion5().build().toAsync()
+
+ val clientConnect = mqtt5AsyncClient!!.connectWith()
+ .cleanStart(false)
+ .willPublish()
+ .topic("${mqttOptions.getBaseTopic()}/dashpanel/$CONNECTION")
+ .payload(OFFLINE.toByteArray())
+ .qos(MqttQos.EXACTLY_ONCE)
+ .retain(true)
+ .applyWillPublish()
+
+ if (!TextUtils.isEmpty(mqttOptions.getUsername()) && !TextUtils.isEmpty(mqttOptions.getPassword())) {
+ clientConnect.simpleAuth()
+ .username(mqttOptions.getUsername())
+ .password(mqttOptions.getPassword().toByteArray())
+ .applySimpleAuth()
+ }
+
+ clientConnect.send()
+ .whenComplete { _, throwable ->
+ if (throwable != null) {
+ Log.e(TAG, "Failed to connect to broker (MQTT5)", throwable)
+ when (throwable) {
+ is Mqtt5AuthException -> listener?.handleMqttException("Authentication failed: ${throwable.message}")
+ is Mqtt5ConnAckException -> listener?.handleMqttException("Connection rejected: ${throwable.message}")
+ else -> listener?.handleMqttException("Connection error: ${throwable.message}")
+ }
+ } else {
+ Log.d(TAG, "Connection initiated (MQTT5)")
+ }
+ }
+ } catch (e: Exception) {
+ val errorMessage = when (e) {
+ is Mqtt5AuthException -> "Authentication error: ${e.message}"
+ is Mqtt5ConnAckException -> "Connection rejection: ${e.message}"
+ is IllegalArgumentException -> "Invalid parameter: ${e.message}"
+ is NullPointerException -> "Null reference: ${e.message}"
+ else -> "Error initializing MQTT5 client: ${e.message}"
+ }
+
+ Log.e(TAG, errorMessage, e)
+ listener?.handleMqttException(errorMessage)
+ }
+ }
+
+ private fun subscribeToTopicsMqtt3(topicFilters: Array<String>?) {
+ topicFilters?.let { topics ->
+ if (topics.isEmpty()) {
+ Log.d(TAG, "No topics to subscribe to (MQTT3)")
+ return
+ }
+
+ Log.d(TAG, "Subscribing to topics (MQTT3): ${topics.joinToString(", ")}")
+
+ mqtt3AsyncClient?.let { client ->
+ try {
+ val subscription = client.subscribeWith()
+ .topicFilter(topics.joinToString(","))
+ .send()
+
+ subscription.whenComplete { _, throwable ->
+ if (throwable != null) {
+ Log.e(TAG, "Failed to subscribe to topics (MQTT3)", throwable)
+ listener?.handleMqttException("Failed to subscribe: ${throwable.message}")
+ } else {
+ Log.d(TAG, "Successfully subscribed to topics (MQTT3)")
+
+ client.publishes(MqttGlobalPublishFilter.ALL) { publish ->
+ val topic = publish.topic.toString()
+ val payload = publish.payload.map { UTF_8.decode(it).toString() }.orElse("")
+ val clientId = client.config.clientIdentifier.get().toString()
+
+ Log.d(TAG, "Received message on topic (MQTT3): $topic")
+ listener?.subscriptionMessage(clientId, topic, payload)
+ }
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Exception while subscribing to topics (MQTT3)", e)
+ listener?.handleMqttException("Exception while subscribing: ${e.message}")
+ }
+ }
+ }
+ }
+
+ private fun subscribeToTopicsMqtt5(topicFilters: Array<String>?) {
+ topicFilters?.let { topics ->
+ if (topics.isEmpty()) {
+ Log.d(TAG, "No topics to subscribe to (MQTT5)")
+ return
+ }
+
+ Log.d(TAG, "Subscribing to topics (MQTT5): ${topics.joinToString(", ")}")
+
+ mqtt5AsyncClient?.let { client ->
+ try {
+ val subscription = client.subscribeWith()
+ .topicFilter(topics.joinToString(","))
+ .send()
+
+ subscription.whenComplete { result, throwable ->
+ if (throwable != null) {
+ Log.e(TAG, "Failed to subscribe to topics (MQTT5)", throwable)
+ listener?.handleMqttException("Failed to subscribe: ${throwable.message}")
+ } else {
+ Log.d(TAG, "Successfully subscribed to topics (MQTT5). Response codes: ${result?.reasonCodes}")
+
+ client.publishes(MqttGlobalPublishFilter.ALL) { publish ->
+ val topic = publish.topic.toString()
+ val payload = publish.payload.map { UTF_8.decode(it).toString() }.orElse("")
+ val clientId = client.config.clientIdentifier.get().toString()
+
+ Log.d(TAG, "Received message on topic (MQTT5): $topic")
+ listener?.subscriptionMessage(clientId, topic, payload)
+ }
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Exception while subscribing to topics (MQTT5)", e)
+ listener?.handleMqttException("Exception while subscribing: ${e.message}")
+ }
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/app/src/main/java/dev/tablaster/dashpanel/mqtt/MQTTServiceInterface.kt b/app/src/main/java/dev/tablaster/dashpanel/mqtt/MQTTServiceInterface.kt
new file mode 100644
index 0000000..dcfb8a6
--- /dev/null
+++ b/app/src/main/java/dev/tablaster/dashpanel/mqtt/MQTTServiceInterface.kt
@@ -0,0 +1,14 @@
+package dev.tablaster.dashpanel.mqtt
+
+import android.content.Context
+import com.hivemq.client.mqtt.mqtt5.exceptions.Mqtt5MessageException
+
+interface MQTTServiceInterface {
+ val isReady: Boolean
+
+ fun publish(topic: String, payload: String, retain: Boolean)
+ fun reconfigure(context: Context, options: MQTTOptions, listener: IMqttManagerListener)
+
+ @Throws(Mqtt5MessageException::class)
+ fun close()
+} \ No newline at end of file
diff --git a/app/src/main/java/dev/tablaster/dashpanel/sensor/BatterySensorService.kt b/app/src/main/java/dev/tablaster/dashpanel/sensor/BatterySensorService.kt
new file mode 100644
index 0000000..03405ac
--- /dev/null
+++ b/app/src/main/java/dev/tablaster/dashpanel/sensor/BatterySensorService.kt
@@ -0,0 +1,201 @@
+package dev.tablaster.dashpanel.sensor
+
+import android.content.*
+import android.os.BatteryManager
+import android.os.Handler
+import android.os.Looper
+import android.util.Log
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.LifecycleOwner
+import androidx.preference.PreferenceManager
+import dev.tablaster.dashpanel.mqtt.MQTTModule
+import dev.tablaster.dashpanel.mqtt.MQTTOptions
+import org.json.JSONObject
+
+class BatterySensorService(
+ base: Context,
+ private val mqttModule: MQTTModule,
+ private val mqttOptions: MQTTOptions,
+ private val updateInterval: Int
+) : ContextWrapper(base), DefaultLifecycleObserver {
+
+ private var lastBatteryLevel = -1
+ private var lastBatteryTemperature = -1f
+ private var lastChargingState = false
+
+ private var pendingBatteryLevel: Int? = null
+ private var pendingBatteryTemperature: Float? = null
+ private var pendingChargingState: Boolean? = null
+
+ private val handler = Handler(Looper.getMainLooper())
+ private val availabilityTopic = "${mqttOptions.getBaseTopic()}/dashpanel/connection"
+ private val clientID = mqttOptions.getClientId()
+ private val manufacturer = android.os.Build.MANUFACTURER ?: "Unknown"
+ private val model = android.os.Build.MODEL ?: "Unknown"
+ private val deviceId = android.os.Build.DEVICE ?: clientID
+ private val baseTopic = mqttOptions.getBaseTopic()
+
+ private val batteryReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent?) {
+ intent?.takeIf { it.action == Intent.ACTION_BATTERY_CHANGED }?.let {
+ updateBatteryStates(it)
+ }
+ }
+ }
+
+ override fun onStart(owner: LifecycleOwner) {
+ Log.d(TAG, "Registering battery receiver")
+ registerReceiver(batteryReceiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
+ publishAllDiscoveries()
+ startPeriodicUpdates()
+ }
+
+ override fun onStop(owner: LifecycleOwner) {
+ Log.d(TAG, "Unregistering battery receiver")
+ stop()
+ }
+
+ private fun startPeriodicUpdates() {
+ handler.postDelayed(object : Runnable {
+ override fun run() {
+ queryBatteryState()
+ handler.postDelayed(this, updateInterval * 60 * 1000L)
+ }
+ }, updateInterval * 60 * 1000L)
+ }
+
+ private fun updateBatteryStates(intent: Intent, force: Boolean = false) {
+ handleBatteryLevel(intent, force)
+ handleBatteryTemperature(intent, force)
+ handleChargingState(intent, force)
+ }
+
+ private fun handleBatteryLevel(intent: Intent, force: Boolean = false) {
+ val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
+ val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, 100)
+ if (level >= 0 && scale > 0) {
+ val batteryPct = (level * 100) / scale
+ if (force || batteryPct != lastBatteryLevel) {
+ lastBatteryLevel = batteryPct
+ publishOrCache("battery_level", batteryPct.toString()) { pendingBatteryLevel = batteryPct }
+ }
+ }
+ }
+
+ private fun handleBatteryTemperature(intent: Intent, force: Boolean = false) {
+ val temperature = intent.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, -1)
+ if (temperature > 0) {
+ val tempCelsius = temperature / 10.0f
+ if (force || tempCelsius != lastBatteryTemperature) {
+ lastBatteryTemperature = tempCelsius
+ publishOrCache("battery_temperature", String.format("%.1f", tempCelsius)) {
+ pendingBatteryTemperature = tempCelsius
+ }
+ }
+ }
+ }
+
+ private fun handleChargingState(intent: Intent, force: Boolean = false) {
+ val status = intent.getIntExtra(BatteryManager.EXTRA_STATUS, -1)
+ val isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL
+ if (force || isCharging != lastChargingState) {
+ lastChargingState = isCharging
+ publishOrCache("charging", if (isCharging) "ON" else "OFF") {
+ pendingChargingState = isCharging
+ }
+ }
+ }
+
+ private fun publishOrCache(type: String, value: String, cache: () -> Unit) {
+ if (mqttModule.isReady() == true) {
+ publishState(type, value)
+ } else {
+ cache()
+ }
+ }
+
+ private fun publishState(type: String, value: String) {
+ val topic = when (type) {
+ "battery_level" -> "$baseTopic/sensor/$model/battery_level/state"
+ "battery_temperature" -> "$baseTopic/sensor/$model/battery_temperature/state"
+ "charging" -> "$baseTopic/binary_sensor/$model/charging/state"
+ else -> return
+ }
+ Log.d(TAG, "Publishing $type state: $value to $topic")
+ mqttModule.publish(topic, value, retain = true)
+ }
+
+ private fun publishAllDiscoveries() {
+ publishDiscovery("battery_level", "Battery Level", "battery_level", "battery", "%", "{{ value | int }}")
+ publishDiscovery("battery_temperature", "Battery Temperature", "battery_temperature", "temperature", "°C", "{{ value | float }}")
+ publishDiscovery("charging", "Charging Status", "charging", "battery_charging", null, null, binary = true)
+ }
+
+ private fun publishDiscovery(
+ type: String,
+ nameSuffix: String,
+ idSuffix: String,
+ deviceClass: String?,
+ unit: String?,
+ valueTemplate: String?,
+ binary: Boolean = false
+ ) {
+ val configTopic = when (type) {
+ "battery_level", "battery_temperature" -> "$baseTopic/sensor/$model/$idSuffix/config"
+ "charging" -> "$baseTopic/binary_sensor/$model/$idSuffix/config"
+ else -> return
+ }
+
+ val stateTopic = configTopic.replace("config", "state")
+ val payload = JSONObject().apply {
+ put("name", "$model $nameSuffix")
+ put("unique_id", "${model}_$idSuffix")
+ deviceClass?.let { put("device_class", it) }
+ put("state_topic", stateTopic)
+ put("availability_topic", availabilityTopic)
+ unit?.let { put("unit_of_measurement", it) }
+ valueTemplate?.let { put("value_template", it) }
+ put("expire_after", updateInterval * 60 + 30)
+ put("device", JSONObject().apply {
+ put("identifiers", listOf(deviceId))
+ put("name", model)
+ put("manufacturer", manufacturer)
+ put("model", model)
+ put("sw_version", android.os.Build.VERSION.RELEASE)
+ })
+ if (binary) {
+ put("payload_on", "ON")
+ put("payload_off", "OFF")
+ }
+ }
+
+ Log.d(TAG, "Publishing $type discovery to $configTopic")
+ mqttModule.publish(configTopic, payload.toString(), retain = true)
+ }
+
+ private fun queryBatteryState() {
+ registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))?.let {
+ updateBatteryStates(it, force = true)
+ }
+ }
+
+ fun onMqttConnected() {
+ Log.d(TAG, "MQTT connected, publishing discovery and pending states")
+ publishAllDiscoveries()
+ queryBatteryState()
+ }
+
+ fun stop() {
+ Log.d(TAG, "Stopping BatterySensorService")
+ try {
+ unregisterReceiver(batteryReceiver)
+ } catch (e: IllegalArgumentException) {
+ Log.w(TAG, "Receiver not registered: ${e.message}")
+ }
+ handler.removeCallbacksAndMessages(null)
+ }
+
+ companion object {
+ private const val TAG = "BatterySensorService"
+ }
+}