aboutsummaryrefslogtreecommitdiff
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
parent34712408abd6accff0d72ff3962dded2de81396a (diff)
feat: added mqtt client implementation
- Also added a service to send battery information over mqtt
-rw-r--r--.idea/deploymentTargetSelector.xml2
-rw-r--r--.idea/misc.xml1
-rw-r--r--app/build.gradle.kts1
-rw-r--r--app/src/main/AndroidManifest.xml1
-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
-rw-r--r--app/src/main/res/values/arrays.xml12
-rw-r--r--app/src/main/res/values/strings.xml22
-rw-r--r--app/src/main/res/xml/preferences.xml99
-rw-r--r--gradle/libs.versions.toml3
16 files changed, 1222 insertions, 15 deletions
diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
index 0fdddb5..ab52eac 100644
--- a/.idea/deploymentTargetSelector.xml
+++ b/.idea/deploymentTargetSelector.xml
@@ -4,7 +4,7 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
- <DropdownSelection timestamp="2025-05-01T07:26:45.775636729Z">
+ <DropdownSelection timestamp="2025-05-02T07:52:40.590788823Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=bf1eea06" />
diff --git a/.idea/misc.xml b/.idea/misc.xml
index 74dd639..b2c751a 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -1,4 +1,3 @@
-<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 938ae53..3affb7c 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -44,6 +44,7 @@ dependencies {
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.preference.ktx)
implementation(libs.androidx.webkit)
+ implementation(libs.hivemq.mqtt.client.shaded)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index a905d2e..56300b1 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,6 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
+ <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:allowBackup="true"
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"
+ }
+}
diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml
new file mode 100644
index 0000000..1d21bb0
--- /dev/null
+++ b/app/src/main/res/values/arrays.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string-array name="mqtt_versions">
+ <item>3.1.1</item>
+ <item>5.0</item>
+ </string-array>
+
+ <string-array name="mqtt_versions_values">
+ <item>3.1.1</item>
+ <item>5.0</item>
+ </string-array>
+</resources> \ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index dc3285c..613283d 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -13,4 +13,26 @@
<string name="screen_on_summary">Keep the screen on while the app is running.</string>
<string name="start_on_boot_title">Start on Device Boot</string>
<string name="start_on_boot_summary">Start the app when the device boots up.</string>
+ <string name="preference_mqtt_settings_title">MQTT Settings</string>
+ <string name="mqtt_enabled_title">Enable MQTT</string>
+ <string name="mqtt_broker_title">MQTT Broker</string>
+ <string name="mqtt_broker_summary">Enter the MQTT broker URL</string>
+ <string name="mqtt_port_title">MQTT Port</string>
+ <string name="mqtt_port_summary">Enter the MQTT broker port</string>
+ <string name="mqtt_base_topic_title">MQTT Base Topic</string>
+ <string name="mqtt_base_topic_summary">Enter the MQTT base Topic</string>
+ <string name="mqtt_username_title">MQTT Username</string>
+ <string name="mqtt_username_summary">Enter the MQTT broker username\n</string>
+ <string name="mqtt_password_title">MQTT Password</string>
+ <string name="mqtt_password_summary">Enter the MQTT broker password</string>
+ <string name="mqtt_version_title">MQTT Version</string>
+ <string name="mqtt_version_summary">Select the MQTT protocol version</string>
+ <string name="mqtt_tls_title">Enable TLS</string>
+ <string name="mqtt_tls_summary">Enable TLS (Secure connection)</string>
+ <string name="mqtt_client_id_title">MQTT Client ID</string>
+ <string name="mqtt_client_id_summary">Enter the MQTT client ID</string>
+ <string name="sensor_battery_title">Enable Battery Sensor</string>
+ <string name="sensor_battery_summary">Send battery data over mqtt</string>
+ <string name="sensor_update_interval_title">Sensor Update Interval</string>
+ <string name="sensor_update_interval_summary">The interval(in minutes) after which updated sensor data will be sent.</string>
</resources> \ No newline at end of file
diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml
index b337398..0b8dd26 100644
--- a/app/src/main/res/xml/preferences.xml
+++ b/app/src/main/res/xml/preferences.xml
@@ -1,4 +1,5 @@
-<PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto">
+<PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:android="http://schemas.android.com/apk/res/android">
<EditTextPreference
app:key="dashboard_url"
@@ -24,4 +25,100 @@
app:key="start_on_boot"
app:title="@string/start_on_boot_title"
app:summary="@string/start_on_boot_summary"/>
+
+ <PreferenceCategory
+ app:title="@string/preference_mqtt_settings_title" />
+
+ <SwitchPreferenceCompat
+ app:key="mqtt_enabled"
+ app:title="@string/mqtt_enabled_title" />
+
+ <EditTextPreference
+ app:key="mqtt_broker"
+ app:title="@string/mqtt_broker_title"
+ app:summary="@string/mqtt_broker_summary"
+ app:defaultValue="localhost"
+ android:inputType="text"
+ app:dependency="mqtt_enabled"
+ app:singleLine="true" />
+
+ <EditTextPreference
+ app:key="mqtt_port"
+ app:title="@string/mqtt_port_title"
+ app:summary="@string/mqtt_port_summary"
+ app:defaultValue="1883"
+ app:dependency="mqtt_enabled"
+ android:inputType="number"
+ app:singleLine="true"/>
+
+ <EditTextPreference
+ app:key="mqtt_base_topic"
+ app:title="@string/mqtt_base_topic_title"
+ app:summary="@string/mqtt_base_topic_summary"
+ app:defaultValue="homeassistant"
+ app:dependency="mqtt_enabled"
+ android:inputType="number"
+ app:singleLine="true"/>
+
+ <EditTextPreference
+ app:key="mqtt_username"
+ app:title="@string/mqtt_username_title"
+ app:summary="@string/mqtt_username_summary"
+ app:dependency="mqtt_enabled"
+ android:inputType="text"
+ app:singleLine="true"/>
+
+ <EditTextPreference
+ app:key="mqtt_password"
+ app:title="@string/mqtt_password_title"
+ app:summary="@string/mqtt_password_summary"
+ app:dependency="mqtt_enabled"
+ android:inputType="textPassword"
+ app:singleLine="true"/>
+
+ <ListPreference
+ app:key="mqtt_version"
+ app:title="@string/mqtt_version_title"
+ app:summary="@string/mqtt_version_summary"
+ app:defaultValue="3.1.1"
+ app:entries="@array/mqtt_versions"
+ app:dependency="mqtt_enabled"
+ app:entryValues="@array/mqtt_versions_values"/>
+
+ <!-- New setting for TLS -->
+ <SwitchPreferenceCompat
+ app:key="mqtt_tls"
+ app:title="@string/mqtt_tls_title"
+ app:summary="@string/mqtt_tls_summary"
+ app:dependency="mqtt_enabled"
+ app:defaultValue="false" />
+
+ <EditTextPreference
+ app:key="mqtt_client_id"
+ app:title="@string/mqtt_client_id_title"
+ app:summary="@string/mqtt_client_id_summary"
+ app:dependency="mqtt_enabled"
+ app:defaultValue="DashPanel"
+ android:inputType="text"
+ app:singleLine="true"
+ />
+
+ <PreferenceCategory
+ app:title="Sensor Settings" />
+
+ <EditTextPreference
+ app:key="sensor_update_interval"
+ app:title="@string/sensor_update_interval_title"
+ app:summary="@string/sensor_update_interval_summary"
+ app:defaultValue="15"
+ app:dependency="mqtt_enabled"
+ android:inputType="number"
+ app:singleLine="true"/>
+
+ <SwitchPreferenceCompat
+ app:key="sensor_battery"
+ app:title="@string/sensor_battery_title"
+ app:summary="@string/sensor_battery_summary"
+ app:dependency="mqtt_enabled" />
+
</PreferenceScreen> \ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 531617f..e913014 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,5 +1,6 @@
[versions]
agp = "8.9.1"
+hivemqMqttClient = "1.3.5"
kotlin = "2.0.21"
coreKtx = "1.16.0"
junit = "4.13.2"
@@ -16,6 +17,8 @@ webkit = "1.13.0"
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-preference-ktx = { module = "androidx.preference:preference-ktx", version.ref = "preferenceKtx" }
androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "webkit" }
+hivemq-mqtt-client = { module = "com.hivemq:hivemq-mqtt-client", version.ref = "hivemqMqttClient" }
+hivemq-mqtt-client-shaded = { module = "com.hivemq:hivemq-mqtt-client-shaded", version.ref = "hivemqMqttClient" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }