diff options
| author | Blaster4385 <[email protected]> | 2025-05-03 13:52:44 +0530 |
|---|---|---|
| committer | Blaster4385 <[email protected]> | 2025-05-05 21:29:11 +0530 |
| commit | b9fd819e63644eaa3d77595c9aec507cb4b2bfc4 (patch) | |
| tree | f090cd959066f20b4a8223ea098503db9307bd1f /app/src/main/java | |
| parent | c9fecb147b277d3b43b7d2a4e25ce3b8574febb2 (diff) | |
feat: added foreground service to maintain mqtt connection in background
- Handle all mqtt stuff in the service.
- Convert BatterySensorService to a data provider.
Diffstat (limited to 'app/src/main/java')
9 files changed, 817 insertions, 339 deletions
diff --git a/app/src/main/java/dev/tablaster/dashpanel/DashPanelService.kt b/app/src/main/java/dev/tablaster/dashpanel/DashPanelService.kt new file mode 100644 index 0000000..06ef35c --- /dev/null +++ b/app/src/main/java/dev/tablaster/dashpanel/DashPanelService.kt @@ -0,0 +1,349 @@ +package dev.tablaster.dashpanel + +import android.app.Service +import android.content.Intent +import android.os.Binder +import android.os.Build +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.os.PowerManager +import android.util.Log +import android.content.SharedPreferences +import androidx.lifecycle.ProcessLifecycleOwner +import androidx.preference.PreferenceManager +import dev.tablaster.dashpanel.mqtt.ConnectionData +import dev.tablaster.dashpanel.mqtt.MQTTModule +import dev.tablaster.dashpanel.mqtt.MQTTOptions +import dev.tablaster.dashpanel.sensor.BatterySensorProvider +import dev.tablaster.dashpanel.sensor.SensorProvider +import dev.tablaster.dashpanel.utils.NotificationUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import org.json.JSONObject +import java.util.concurrent.ConcurrentHashMap + +class DashPanelService : Service(), MQTTModule.MQTTListener { + private val TAG = "DashPanelService" + + private lateinit var mqttOptions: MQTTOptions + private lateinit var mqttModule: MQTTModule + private lateinit var connectionData: ConnectionData + private lateinit var notificationUtils: NotificationUtils + + private lateinit var wakeLock: PowerManager.WakeLock + private var wakeLockAcquired = false + + private val handler = Handler(Looper.getMainLooper()) + private var updateInterval = 15 + + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + private val providers = ConcurrentHashMap<String, SensorProvider>() + private val providerInitialized = ConcurrentHashMap<String, Boolean>() + + private val pendingValues = ConcurrentHashMap<String, String>() + + private val binder = LocalBinder() + private var sensorsInitialized = false + private var isConnected = false + + private val updateRunnable = object : Runnable { + override fun run() { + updateAllSensors() + handler.postDelayed(this, updateInterval * 60 * 1000L) + } + } + + private val preferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key -> + Log.d(TAG, "SharedPreference changed: $key") + when (key) { + in mqttKeys -> { + val newOptions = MQTTOptions(sharedPreferences) + if (newOptions.isValid) { + Log.d(TAG, "MQTT settings changed and are now valid. Restarting MQTT.") + mqttModule.stop() + mqttOptions = newOptions + mqttModule = MQTTModule(this, mqttOptions, this) + mqttModule.start() + } else { + Log.d(TAG, "MQTT settings changed but are still invalid.") + mqttModule.stop() + } + } + "sensor_update_interval" -> { + val newInterval = sharedPreferences.getString("sensor_update_interval", "15")!!.toInt() + if (newInterval != updateInterval) { + Log.d(TAG, "Update interval changed from $updateInterval to $newInterval minutes") + updateInterval = newInterval + restartPeriodicUpdates() + } + } + } + } + + private val mqttKeys = setOf( + "mqtt_enabled", "mqtt_broker", "mqtt_port", "mqtt_client_id", + "mqtt_username", "mqtt_password", "mqtt_tls", "mqtt_base_topic" + ) + + inner class LocalBinder : Binder() { + fun getService(): DashPanelService = this@DashPanelService + } + + override fun onCreate() { + super.onCreate() + + val powerManager = getSystemService(POWER_SERVICE) as PowerManager + wakeLock = powerManager.newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK or + PowerManager.ON_AFTER_RELEASE, + "DashPanel:MQTTServiceWakeLock" + ) + wakeLock.setReferenceCounted(false) + + notificationUtils = NotificationUtils(this) + notificationUtils.startForegroundService() + Log.d(TAG, "DashPanelService started") + + val prefs = PreferenceManager.getDefaultSharedPreferences(this) + mqttOptions = MQTTOptions(prefs) + mqttModule = MQTTModule(this, mqttOptions, this) + + updateInterval = prefs.getString("sensor_update_interval", "15")!!.toInt() + + prefs.registerOnSharedPreferenceChangeListener(preferenceChangeListener) + + connectionData = ConnectionData(this) + connectionData.observe(ProcessLifecycleOwner.get()) { connected -> + if (connected && !isConnected) { + mqttModule.restart() + isConnected = true + } else if (!connected && isConnected) { + mqttModule.pause() + isConnected = false + } + } + mqttModule.start() + + if (!sensorsInitialized) { + registerSensorProvider(BatterySensorProvider(this)) + sensorsInitialized = true + } + + startPeriodicUpdates() + } + + private fun startPeriodicUpdates() { + Log.d(TAG, "Starting periodic updates with interval: $updateInterval minutes") + handler.removeCallbacks(updateRunnable) + handler.postDelayed(updateRunnable, updateInterval * 60 * 1000L) + } + + private fun restartPeriodicUpdates() { + Log.d(TAG, "Restarting periodic updates with new interval: $updateInterval minutes") + handler.removeCallbacks(updateRunnable) + handler.postDelayed(updateRunnable, updateInterval * 60 * 1000L) + } + + private fun updateAllSensors() { + Log.d(TAG, "Performing scheduled update of all sensors") + providers.values.forEach { provider -> + Log.d(TAG, "Updating sensor: ${provider.javaClass.simpleName}") + provider.updateSensorData() + } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + acquireWakeLock() + return START_STICKY + } + + override fun onBind(intent: Intent): IBinder = binder + + override fun onDestroy() { + val prefs = PreferenceManager.getDefaultSharedPreferences(this) + prefs.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener) + + publishAvailability(false) + notificationUtils.stopForegroundService() + mqttModule.stop() + providers.values.forEach { it.stop() } + providers.clear() + connectionData.cleanup() + + handler.removeCallbacksAndMessages(null) + + releaseWakeLock() + super.onDestroy() + } + + private fun acquireWakeLock() { + if (!wakeLockAcquired) { + Log.d(TAG, "Acquiring wake lock") + wakeLock.acquire(60*60*1000L) + wakeLockAcquired = true + } + } + + private fun releaseWakeLock() { + if (wakeLockAcquired) { + Log.d(TAG, "Releasing wake lock") + if (wakeLock.isHeld) { + wakeLock.release() + } + wakeLockAcquired = false + } + } + + private fun registerSensorProvider(provider: SensorProvider) { + val id = provider.javaClass.simpleName + providers[id] = provider + provider.setDataListener(object : SensorProvider.DataListener { + override fun onSensorData(type: String, value: String) { + publishOrCache(type, value) + } + }) + + if (mqttModule.isReady() == true) { + publishDiscoveries(provider) + provider.onMqttConnected() + providerInitialized[id] = true + } + } + + private fun publishOrCache(type: String, value: String) { + if (mqttModule.isReady() == true) { + publishSensorData(type, value) + } else { + Log.d(TAG, "Caching sensor data for $type: $value") + pendingValues[type] = value + } + } + + private fun publishSensorData(type: String, value: String) { + var prefix = "sensor" + var found = false + + for (provider in providers.values) { + for (cfg in provider.getSensorConfigurations()) { + if (cfg.idSuffix == type) { + prefix = cfg.prefix + found = true + break + } + } + if (found) break + } + + val topic = "${mqttOptions.getBaseTopic()}/$prefix/${Build.MODEL}/$type/state" + Log.d(TAG, "Publishing sensor data for $type: $value to $topic") + mqttModule.publish(topic, value, retain = true) + } + + private fun publishDiscoveries(provider: SensorProvider) { + val prefs = PreferenceManager.getDefaultSharedPreferences(this) + val interval = prefs.getString("sensor_update_interval", "15")!!.toInt() + + provider.getSensorConfigurations().forEach { cfg -> + val payload = JSONObject().apply { + put("name", "${Build.MODEL} ${cfg.nameSuffix}") + put("unique_id", "${Build.MODEL}_${cfg.idSuffix}") + cfg.deviceClass?.let { dc -> put("device_class", dc) } + put("state_topic", "${mqttOptions.getBaseTopic()}/${cfg.prefix}/${Build.MODEL}/${cfg.idSuffix}/state") + put("availability_topic", "${mqttOptions.getBaseTopic()}/dashpanel/connection") + cfg.unit?.let { u -> put("unit_of_measurement", u) } + cfg.valueTemplate?.let { vt -> put("value_template", vt) } + put("expire_after", interval * 60 + 30) + put("device", JSONObject().apply { + put("identifiers", listOf(Build.DEVICE)) + put("name", Build.MODEL) + put("manufacturer", Build.MANUFACTURER ?: "Unknown") + put("model", Build.MODEL) + put("sw_version", Build.VERSION.RELEASE) + }) + if (cfg.binary) { + put("payload_on", "ON") + put("payload_off", "OFF") + } + } + + val topic = "${mqttOptions.getBaseTopic()}/${cfg.prefix}/${Build.MODEL}/${cfg.idSuffix}/config" + Log.d(TAG, "Publishing discovery for ${cfg.idSuffix} to $topic") + mqttModule.publish(topic, payload.toString(), retain = true) + } + } + + private fun publishAllDiscoveries() { + providers.values.forEach { provider -> + publishDiscoveries(provider) + } + } + + private fun publishPendingValues() { + if (pendingValues.isNotEmpty()) { + Log.d(TAG, "Publishing ${pendingValues.size} pending sensor values") + pendingValues.forEach { (key, value) -> + publishSensorData(key, value) + } + pendingValues.clear() + } + } + + private fun publishAvailability(online: Boolean) { + if (mqttModule.isReady() != true) return + val status = if (online) "online" else "offline" + mqttModule.publish("${mqttOptions.getBaseTopic()}/dashpanel/connection", status, retain = true) + } + + override fun onMQTTConnect() { + scope.launch { + notificationUtils.updateNotification( + getString(R.string.service_notification_title), + "Connected to MQTT broker" + ) + + publishAvailability(true) + + publishAllDiscoveries() + + publishPendingValues() + + providers.forEach { (id, provider) -> + if (providerInitialized[id] != true) { + provider.onMqttConnected() + providerInitialized[id] = true + } + } + updateAllSensors() + } + } + + override fun onMQTTDisconnect() { + scope.launch { + notificationUtils.updateNotification( + getString(R.string.service_notification_title), + "Disconnected from MQTT broker" + ) + + if (isConnected) { + Log.d(TAG, "Attempting to reconnect to MQTT broker after disconnect") + mqttModule.restart() + } + } + } + + override fun onMQTTException(message: String) { + scope.launch { + notificationUtils.updateNotification( + getString(R.string.service_notification_title), + "Error: $message" + ) + } + } + + override fun onMQTTMessage(id: String, topic: String, payload: String) { + // TODO Handle incoming mqtt commands + } +}
\ No newline at end of file diff --git a/app/src/main/java/dev/tablaster/dashpanel/MainActivity.kt b/app/src/main/java/dev/tablaster/dashpanel/MainActivity.kt index 38a7fbd..e575f36 100644 --- a/app/src/main/java/dev/tablaster/dashpanel/MainActivity.kt +++ b/app/src/main/java/dev/tablaster/dashpanel/MainActivity.kt @@ -1,10 +1,18 @@ package dev.tablaster.dashpanel import android.annotation.SuppressLint +import android.content.ComponentName +import android.content.Context import android.content.Intent +import android.content.ServiceConnection import android.content.SharedPreferences +import android.content.pm.PackageManager import android.net.Uri +import android.os.Build import android.os.Bundle +import android.os.IBinder +import android.os.PowerManager +import android.provider.Settings import android.util.Log import android.view.View import android.view.WindowManager @@ -15,86 +23,76 @@ import android.webkit.WebViewClient import android.widget.Toast import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.core.net.toUri 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(), MQTTModule.MQTTListener { +class MainActivity : AppCompatActivity() { + private val TAG = "MainActivity" 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 + private var dashPanelService: DashPanelService? = null + private var isServiceBound = false + + private val connection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + Log.d(TAG, "Service connected") + dashPanelService = (service as DashPanelService.LocalBinder).getService() + isServiceBound = true + } + override fun onServiceDisconnected(name: ComponentName?) { + Log.d(TAG, "Service disconnected") + dashPanelService = null + isServiceBound = false + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + checkBatteryOptimization() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + requestPermissions(arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), 1001) + } + } sharedPref = PreferenceManager.getDefaultSharedPreferences(this) setupScreen() setContentView(R.layout.activity_main) - 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() - } + Intent(this, DashPanelService::class.java).also { + ContextCompat.startForegroundService(this, it) + bindService(it, connection, Context.BIND_AUTO_CREATE) } } - override fun onWindowFocusChanged(hasFocus: Boolean) { - val settingsPassCodePref = sharedPref.getInt("settings_passcode", 0) - settingsButton = findViewById(R.id.settings_button) - settingsButton.setOnClickListener { openSettings(settingsPassCodePref) } - val url = sharedPref.getString("dashboard_url", "") - if (!url.isNullOrBlank()) { - setupWebView(url) - } else { - openSettings(0) - } - super.onWindowFocusChanged(hasFocus) - if (hasFocus) { - setupScreen() - handleSensors() + override fun onStart() { + super.onStart() + if (!isServiceBound) { + Intent(this, DashPanelService::class.java).also { + ContextCompat.startForegroundService(this, it) + bindService(it, connection, Context.BIND_AUTO_CREATE) + } } } - private fun initializeMqtt() { - mqttOptions = MQTTOptions(sharedPref) - mqttModule = MQTTModule(this, mqttOptions, this) - lifecycle.addObserver(mqttModule) + override fun onStop() { + super.onStop() + if (isServiceBound) { + unbindService(connection) + isServiceBound = false + } } - 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 - } + override fun onDestroy() { + super.onDestroy() + if (isServiceBound) unbindService(connection) } @SuppressLint("SetJavaScriptEnabled") @@ -102,107 +100,87 @@ class MainActivity : AppCompatActivity(), MQTTModule.MQTTListener { val hardwareAcceleration = sharedPref.getBoolean("hardware_acceleration", true) if (!::webView.isInitialized) { webView = findViewById(R.id.webview) - val webSettings = webView.settings - webSettings.javaScriptEnabled = true - webSettings.domStorageEnabled = true - webSettings.javaScriptCanOpenWindowsAutomatically = true - webSettings.cacheMode = WebSettings.LOAD_NO_CACHE - webSettings.allowContentAccess = true - webSettings.allowFileAccess = true - webSettings.setSupportZoom(true) - webSettings.loadWithOverviewMode = true - webSettings.useWideViewPort = true - webSettings.mediaPlaybackRequiresUserGesture = false + with(webView.settings) { + javaScriptEnabled = true + domStorageEnabled = true + javaScriptCanOpenWindowsAutomatically = true + cacheMode = WebSettings.LOAD_NO_CACHE + allowContentAccess = true + allowFileAccess = true + setSupportZoom(true) + loadWithOverviewMode = true + useWideViewPort = true + mediaPlaybackRequiresUserGesture = false + } webView.webViewClient = WebViewClient() webView.webChromeClient = WebChromeClient() } - if (hardwareAcceleration) { - webView.setLayerType(View.LAYER_TYPE_HARDWARE, null); - } + if (hardwareAcceleration) webView.setLayerType(View.LAYER_TYPE_HARDWARE, null) val currentUrl = webView.url val currentOrigin = currentUrl?.toUri()?.origin() val targetOrigin = url.toUri().origin() - if (currentOrigin != targetOrigin) { - Log.d("MainActivity", "WebView origin changed: $currentOrigin -> $targetOrigin. Reloading.") + Log.d(TAG, "WebView origin changed: $currentOrigin -> $targetOrigin. Reloading.") webView.loadUrl(url) - } else { - 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 - } - } - - private fun openSettings(settingsPassCodePreference: Int) { - if (settingsPassCodePreference != 0) { - val passCodeDialog = PassCodeFragment { enteredCode -> - if (enteredCode == settingsPassCodePreference) { - startActivity(Intent(this, SettingsActivity::class.java)) - } else { - Toast.makeText(this, "Incorrect passcode", Toast.LENGTH_SHORT).show() - } - } - passCodeDialog.show(supportFragmentManager, "SettingsPassCode") - } else { - startActivity(Intent(this, SettingsActivity::class.java)) } } private fun setupScreen() { val fullScreenPref = sharedPref.getBoolean("full_screen", false) val screenOnPref = sharedPref.getBoolean("screen_on", false) - if (fullScreenPref) { enableEdgeToEdge() - val windowInsetsController = - WindowCompat.getInsetsController(window, window.decorView) - windowInsetsController.systemBarsBehavior = - WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE - windowInsetsController.hide(WindowInsetsCompat.Type.systemBars()) - } - - if (screenOnPref) { - window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + WindowCompat.getInsetsController(window, window.decorView).apply { + systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + hide(WindowInsetsCompat.Type.systemBars()) + } } + if (screenOnPref) window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } - override fun onMQTTConnect() { - Log.d("MainActivity", "MQTT Connected") - lifecycleScope.launch { - Toast.makeText(this@MainActivity, "Connected to MQTT broker", Toast.LENGTH_SHORT).show() + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + settingsButton = findViewById(R.id.settings_button) + val passCodePref = sharedPref.getInt("settings_passcode", 0) + settingsButton.setOnClickListener { + if (passCodePref != 0) { + PassCodeFragment { code -> + if (code == passCodePref) startActivity(Intent(this, SettingsActivity::class.java)) + else Toast.makeText(this, "Incorrect passcode", Toast.LENGTH_SHORT).show() + }.show(supportFragmentManager, "SettingsPassCode") + } else { + startActivity(Intent(this, SettingsActivity::class.java)) + } } - batterySensorService?.onMqttConnected() - } + val url = sharedPref.getString("dashboard_url", "") + if (!url.isNullOrBlank()) setupWebView(url) + else startActivity(Intent(this, SettingsActivity::class.java)) - override fun onMQTTDisconnect() { - Log.d("MainActivity", "MQTT Disconnected") - lifecycleScope.launch { - Toast.makeText(this@MainActivity, "Disconnected from MQTT broker", Toast.LENGTH_SHORT).show() - } + if (hasFocus) setupScreen() } - override fun onMQTTException(message: String) { - Log.e("MainActivity", "MQTT Error: $message") - lifecycleScope.launch { - Toast.makeText(this@MainActivity, "MQTT Error: $message", Toast.LENGTH_LONG).show() + @SuppressLint("BatteryLife") + private fun checkBatteryOptimization() { + val powerManager = getSystemService(POWER_SERVICE) as PowerManager + val packageName = packageName + + if (!powerManager.isIgnoringBatteryOptimizations(packageName)) { + val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) + intent.data = "package:$packageName".toUri() + startActivity(intent) } } +} - override fun onMQTTMessage(id: String, topic: String, payload: String) { - Log.d("MainActivity", "MQTT Message - Topic: $topic, Payload: $payload") - // TODO Handle incoming mqtt commands. - } +private fun Uri.origin(): String { + val portPart = if (port != -1 && port != defaultPortForScheme()) ":$port" else "" + return "$scheme://$host$portPart" } + +private fun Uri.defaultPortForScheme(): Int = when (scheme) { + "http" -> 80 + "https" -> 443 + else -> -1 +}
\ No newline at end of file diff --git a/app/src/main/java/dev/tablaster/dashpanel/mqtt/ConnectionData.kt b/app/src/main/java/dev/tablaster/dashpanel/mqtt/ConnectionData.kt index 34430b0..e346fe0 100644 --- a/app/src/main/java/dev/tablaster/dashpanel/mqtt/ConnectionData.kt +++ b/app/src/main/java/dev/tablaster/dashpanel/mqtt/ConnectionData.kt @@ -4,6 +4,7 @@ import android.content.Context import android.net.ConnectivityManager import android.net.Network import android.net.NetworkCapabilities +import android.net.NetworkRequest import android.util.Log import androidx.lifecycle.LiveData @@ -24,31 +25,48 @@ class ConnectionData(private val context: Context) : LiveData<Boolean>() { } } - override fun onActive() { - super.onActive() - Log.d("ConnectionData", "Registering network callback.") + init { + registerNetworkCallback() postValue(isConnected()) + } + + private fun registerNetworkCallback() { try { - connectivityManager.registerDefaultNetworkCallback(networkCallback) + val request = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build() + + connectivityManager.registerNetworkCallback(request, networkCallback) + Log.d("ConnectionData", "Network callback registered permanently") } catch (e: Exception) { Log.e("ConnectionData", "Error registering network callback", e) } } - override fun onInactive() { - super.onInactive() - Log.d("ConnectionData", "Unregistering network callback.") + fun cleanup() { try { connectivityManager.unregisterNetworkCallback(networkCallback) + Log.d("ConnectionData", "Network callback unregistered during cleanup") } catch (e: Exception) { Log.e("ConnectionData", "Error unregistering network callback", e) } } + override fun onActive() { + super.onActive() + Log.d("ConnectionData", "LiveData observer active") + postValue(isConnected()) + } + + override fun onInactive() { + super.onInactive() + Log.d("ConnectionData", "LiveData observer inactive - keeping network callback registered") + } + 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) } -} +}
\ 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 index f90bfb7..60e8f82 100644 --- a/app/src/main/java/dev/tablaster/dashpanel/mqtt/MQTTModule.kt +++ b/app/src/main/java/dev/tablaster/dashpanel/mqtt/MQTTModule.kt @@ -63,6 +63,14 @@ class MQTTModule( return mqttService?.isReady } + fun start() { + startMqtt() + } + + fun stop() { + stopMqtt() + } + fun restart() { Log.d(TAG, "restart") stopMqtt() diff --git a/app/src/main/java/dev/tablaster/dashpanel/mqtt/MQTTService.kt b/app/src/main/java/dev/tablaster/dashpanel/mqtt/MQTTService.kt index 28ae1d6..8671fb3 100644 --- a/app/src/main/java/dev/tablaster/dashpanel/mqtt/MQTTService.kt +++ b/app/src/main/java/dev/tablaster/dashpanel/mqtt/MQTTService.kt @@ -255,6 +255,7 @@ class MQTTService( val clientConnect = mqtt3AsyncClient!!.connectWith() .cleanSession(false) + .keepAlive(30) .willPublish() .topic("${mqttOptions.getBaseTopic()}/dashpanel/$CONNECTION") .payload(OFFLINE.toByteArray()) @@ -329,6 +330,7 @@ class MQTTService( val clientConnect = mqtt5AsyncClient!!.connectWith() .cleanStart(false) + .keepAlive(30) .willPublish() .topic("${mqttOptions.getBaseTopic()}/dashpanel/$CONNECTION") .payload(OFFLINE.toByteArray()) diff --git a/app/src/main/java/dev/tablaster/dashpanel/sensor/BatterySensorProvider.kt b/app/src/main/java/dev/tablaster/dashpanel/sensor/BatterySensorProvider.kt new file mode 100644 index 0000000..8054ca3 --- /dev/null +++ b/app/src/main/java/dev/tablaster/dashpanel/sensor/BatterySensorProvider.kt @@ -0,0 +1,222 @@ +package dev.tablaster.dashpanel.sensor + +import android.content.* +import android.os.* +import android.util.Log +import androidx.preference.PreferenceManager + +class BatterySensorProvider( + base: Context +) : ContextWrapper(base), SensorProvider { + private val TAG = "BatterySensorProvider" + private var dataListener: SensorProvider.DataListener? = null + private val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this) + private var isEnabled = true + + 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 receiver = object : BroadcastReceiver() { + override fun onReceive(ctx: Context?, intent: Intent?) { + intent?.takeIf { it.action == Intent.ACTION_BATTERY_CHANGED } + ?.let { updateBatteryStates(it) } + } + } + + private val preferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { prefs, key -> + if (key == "sensor_battery") { + val newState = prefs.getBoolean(key, true) + if (newState != isEnabled) { + Log.d(TAG, "Battery sensor state changing from $isEnabled to $newState") + isEnabled = newState + + if (isEnabled) { + Log.d(TAG, "Re-enabling battery sensor provider") + try { + registerReceiver(receiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) + } catch (e: Exception) { + Log.w(TAG, "Failed to register receiver: ${e.message}") + } + forceUpdateBatteryStates() + } else { + Log.d(TAG, "Disabling battery sensor provider") + try { + unregisterReceiver(receiver) + } catch (e: IllegalArgumentException) { + Log.w(TAG, "Receiver not registered: ${e.message}") + } + notifyProvidersDisabled() + } + } + } + } + + init { + Log.d(TAG, "Initializing BatterySensorProvider") + isEnabled = sharedPreferences.getBoolean("sensor_battery", true) + sharedPreferences.registerOnSharedPreferenceChangeListener(preferenceChangeListener) + + if (isEnabled) { + try { + registerReceiver(receiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) + } catch (e: Exception) { + Log.e(TAG, "Error during initialization: ${e.message}") + } + } else { + Log.d(TAG, "Battery sensor provider disabled at initialization") + } + } + + override fun updateSensorData() { + if (isEnabled) { + forceUpdateBatteryStates() + } + } + + private fun forceUpdateBatteryStates() { + if (!isEnabled) return + registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))?.let { + updateBatteryStates(it, true) + } + } + + 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 + Log.d(TAG, "Battery level changed to $batteryPct%") + emitOrCache("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 + Log.d(TAG, "Battery temperature changed to $tempCelsius°C") + emitOrCache("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 + Log.d(TAG, "Charging state changed to ${if (isCharging) "ON" else "OFF"}") + emitOrCache("charging", if (isCharging) "ON" else "OFF") { + pendingChargingState = isCharging + } + } + } + + private fun emitOrCache(sensorId: String, value: String, cache: () -> Unit) { + if (dataListener != null) { + dataListener?.onSensorData(sensorId, value) + } else { + cache() + } + } + + private fun notifyProvidersDisabled() { + dataListener?.let { + Log.d(TAG, "Notifying that all sensors are unavailable") + it.onSensorData("battery_level", "unavailable") + it.onSensorData("battery_temperature", "unavailable") + it.onSensorData("charging", "unavailable") + } + } + + override fun getSensorConfigurations(): List<SensorProvider.SensorConfiguration> = listOf( + SensorProvider.SensorConfiguration( + prefix = "sensor", + idSuffix = "battery_level", + nameSuffix = "Battery Level", + deviceClass = "battery", + unit = "%", + valueTemplate = "{{ value | int }}", + binary = false + ), + SensorProvider.SensorConfiguration( + prefix = "sensor", + idSuffix = "battery_temperature", + nameSuffix = "Battery Temperature", + deviceClass = "temperature", + unit = "°C", + valueTemplate = "{{ value | float }}", + binary = false + ), + SensorProvider.SensorConfiguration( + prefix = "binary_sensor", + idSuffix = "charging", + nameSuffix = "Charging Status", + deviceClass = "battery_charging", + unit = null, + valueTemplate = null, + binary = true + ) + ) + + override fun setDataListener(listener: SensorProvider.DataListener) { + dataListener = listener + Log.d(TAG, "Setting data listener, provider enabled: $isEnabled") + + if (isEnabled) { + pendingBatteryLevel?.let { + dataListener?.onSensorData("battery_level", it.toString()) + pendingBatteryLevel = null + } + pendingBatteryTemperature?.let { + dataListener?.onSensorData("battery_temperature", String.format("%.1f", it)) + pendingBatteryTemperature = null + } + pendingChargingState?.let { + dataListener?.onSensorData("charging", if (it) "ON" else "OFF") + pendingChargingState = null + } + + if (pendingBatteryLevel == null && pendingBatteryTemperature == null && pendingChargingState == null) { + forceUpdateBatteryStates() + } + } else { + notifyProvidersDisabled() + } + } + + override fun onMqttConnected() { + Log.d(TAG, "MQTT connected, provider enabled: $isEnabled") + if (isEnabled) { + forceUpdateBatteryStates() + } + } + + override fun stop() { + try { + unregisterReceiver(receiver) + } catch (e: IllegalArgumentException) { + Log.w(TAG, "Receiver not registered: ${e.message}") + } + sharedPreferences.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener) + } +}
\ 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 deleted file mode 100644 index 03405ac..0000000 --- a/app/src/main/java/dev/tablaster/dashpanel/sensor/BatterySensorService.kt +++ /dev/null @@ -1,201 +0,0 @@ -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/java/dev/tablaster/dashpanel/sensor/SensorProvider.kt b/app/src/main/java/dev/tablaster/dashpanel/sensor/SensorProvider.kt new file mode 100644 index 0000000..892a97a --- /dev/null +++ b/app/src/main/java/dev/tablaster/dashpanel/sensor/SensorProvider.kt @@ -0,0 +1,27 @@ +package dev.tablaster.dashpanel.sensor + +interface SensorProvider { + data class SensorConfiguration( + val prefix: String, // "sensor" or "binary_sensor" + val idSuffix: String, // e.g. "battery_level" + val nameSuffix: String, // e.g. "Battery Level" + val deviceClass: String?, // Device class for Home Assistant + val unit: String?, // Unit of measurement + val valueTemplate: String?,// Template for value parsing + val binary: Boolean // Binary sensor flag + ) + + fun getSensorConfigurations(): List<SensorConfiguration> + + fun setDataListener(listener: DataListener) + + fun updateSensorData() + + fun onMqttConnected() + + fun stop() + + interface DataListener { + fun onSensorData(type: String, value: String) + } +}
\ No newline at end of file diff --git a/app/src/main/java/dev/tablaster/dashpanel/utils/NotificationUtils.kt b/app/src/main/java/dev/tablaster/dashpanel/utils/NotificationUtils.kt new file mode 100644 index 0000000..48baa85 --- /dev/null +++ b/app/src/main/java/dev/tablaster/dashpanel/utils/NotificationUtils.kt @@ -0,0 +1,75 @@ +package dev.tablaster.dashpanel.utils + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationCompat +import dev.tablaster.dashpanel.MainActivity +import dev.tablaster.dashpanel.R + +class NotificationUtils(private val context: Context) { + + companion object { + private const val NOTIFICATION_ID = 1001 + private const val CHANNEL_ID = "dashpanel_service_channel" + } + + private val notificationManager: NotificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + init { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + createNotificationChannel() + } + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + "DashPanel Service", + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "Service running in the background" + } + notificationManager.createNotificationChannel(channel) + } + } + + private fun buildNotification(title: String, message: String): Notification { + val pendingIntent = PendingIntent.getActivity( + context, 0, + Intent(context, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE + ) + + return NotificationCompat.Builder(context, CHANNEL_ID) + .setContentTitle(title) + .setContentText(message) + .setSmallIcon(R.drawable.ic_logo) + .setContentIntent(pendingIntent) + .setOngoing(true) + .setAutoCancel(false) + .build() + } + + fun startForegroundService() { + val notification = buildNotification( + context.getString(R.string.service_notification_title), + context.getString(R.string.service_notification_message) + ) + notificationManager.notify(NOTIFICATION_ID, notification) + } + + fun updateNotification(title: String, message: String) { + val notification = buildNotification(title, message) + notificationManager.notify(NOTIFICATION_ID, notification) + } + + fun stopForegroundService() { + notificationManager.cancel(NOTIFICATION_ID) + } +} |