diff options
| author | Blaster4385 <[email protected]> | 2025-05-02 09:58:28 +0530 |
|---|---|---|
| committer | Blaster4385 <[email protected]> | 2025-05-02 22:52:49 +0530 |
| commit | 0595e85714235e15d970dd601af1f6dfc596ad43 (patch) | |
| tree | fa342dabbdffc7b996cc4db67464cd495f1e04a7 /app/src/main/java | |
| parent | 34712408abd6accff0d72ff3962dded2de81396a (diff) | |
feat: added mqtt client implementation
- Also added a service to send battery information over mqtt
Diffstat (limited to 'app/src/main/java')
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" + } +} |