commit da64b1b6bd5ff393eb67bb06d0677134b0467c78 Author: fototeddy Date: Fri Sep 4 21:30:51 2026 +0200 first commit diff --git a/Android/.gitignore b/Android/.gitignore new file mode 100644 index 0000000..580dc6c --- /dev/null +++ b/Android/.gitignore @@ -0,0 +1,13 @@ +# Was Gradle und das Android SDK erzeugen. Nichts davon gehört ins Repository. +.gradle/ +build/ +local.properties +*.iml +.idea/ +captures/ +.cxx/ + +# Signaturschlüssel und Passwörter gehören nirgends ins Repository. +keystore.properties +*.jks +*.keystore diff --git a/Android/RELEASE.md b/Android/RELEASE.md new file mode 100644 index 0000000..08b8dd2 --- /dev/null +++ b/Android/RELEASE.md @@ -0,0 +1,155 @@ +# Eine APK veröffentlichen + +## Vorweg: die APK gehört nicht ins Repository + +Ein Release-Anhang in Gitea liegt **neben** dem Repository, nicht darin. Gitea +legt ihn in seinem Anhang-Speicher ab; im Git-Verlauf taucht er nicht auf. + +Das ist auch gut so. Eine APK ist eine 11-MB-Binärdatei, die sich bei jedem +Build komplett ändert. Läge sie im Repository, wüchse der Verlauf mit jeder +Version um 11 MB — und zwar **unwiderruflich**: Git vergisst nichts, ein +späteres Löschen bringt den Platz nicht zurück. Nach zehn Versionen wäre das +Auschecken des Projekts ein 110-MB-Download, obwohl der Quelltext selbst nur +wenige hundert Kilobyte hat. + +Deshalb: bauen, an ein Release hängen, fertig. `.gitignore` sorgt dafür, dass +die gebauten Dateien gar nicht erst versehentlich hineinrutschen. + +## Signieren + +Android installiert **keine unsignierte APK** — auch nicht per Sideload. Das +hat nichts mit Googles Sperren zu tun, sondern damit, dass das System ohne +Signatur nicht feststellen kann, ob ein Update vom selben Absender stammt wie +die Erstinstallation. + +Ohne eigenen Schlüssel signiert der Build mit dem Android-Debug-Schlüssel und +sagt das beim Bauen deutlich. Das funktioniert, hat aber einen Haken: Dieser +Schlüssel wird pro Rechner erzeugt. Baust du die nächste Version auf einem +anderen Rechner, hat sie eine andere Signatur, und Android verweigert das +Update — es bliebe nur Deinstallieren und neu einrichten. + +### Einen eigenen Schlüssel anlegen + +Einmalig. Das Passwort tippst du dabei selbst. + +```bash +keytool -genkeypair -v \ + -keystore ~/camper-monitor.jks \ + -alias camper -keyalg RSA -keysize 4096 -validity 10000 +``` + +Danach `Android/keystore.properties` anlegen (die Datei ist in `.gitignore`, +sie landet nicht im Repository): + +```properties +storeFile=/Users/DEINNAME/camper-monitor.jks +storePassword=DEIN_PASSWORT +keyAlias=camper +keyPassword=DEIN_PASSWORT +``` + +> **Diesen Schlüssel aufbewahren.** Geht er verloren, lässt sich für alle, die +> die App installiert haben, nie wieder ein Update ausliefern. Eine Kopie an +> einen zweiten Ort, getrennt vom Rechner. + +## Wenn Gitea die APK nicht annimmt + +Zwei Dinge, die leicht zusammenfallen: + +**Der Quelltext als `.zip` und `.tar.gz`** hängt an *jedem* Gitea-Release +automatisch dran. Gitea erzeugt beides aus dem Etikett; es ist nichts, was +versehentlich hochgeladen wurde, und es lässt sich auch nicht abschalten. Es +stört nur optisch und kostet keinen Speicher — die Archive werden erst beim +Anklicken erzeugt. + +**Die APK abgelehnt** wird dagegen aus einem handfesten Grund: Gitea nimmt nur +Dateitypen an, die in seiner Liste stehen, und `.apk` steht dort nicht. Die +Liste dieser Instanz lässt sich abfragen: + +```bash +curl -s https://git.home.fritob.de/api/v1/settings/attachment +``` + +Kommt dort eine `allowed_types`-Liste ohne `.apk` zurück, ist das die Ursache. +Die Grösse ist es nicht — die Grenze liegt bei 100 MiB, die APK bei 11. + +### Der richtige Weg: `.apk` erlauben + +In der `app.ini` des Servers (bei Docker meist +`/data/gitea/conf/app.ini`, sonst `/etc/gitea/app.ini`): + +```ini +[attachment] +ALLOWED_TYPES = .avif,.cpuprofile,.csv,.dmp,.docx,.fodg,.fodp,.fods,.fodt,.gif,.gz,.jpeg,.jpg,.json,.jsonc,.log,.md,.mov,.mp4,.odf,.odg,.odp,.ods,.odt,.patch,.pdf,.png,.pptx,.svg,.tgz,.txt,.webm,.webp,.xls,.xlsx,.zip,.apk + +[repository.release] +ALLOWED_TYPES = .apk,.zip +``` + +Beide Abschnitte, weil je nach Gitea-Fassung mal der eine, mal der andere für +Release-Anhänge zuständig ist. Danach Gitea neu starten. + +Wer es sich einfach machen will, schreibt in beide Schlüssel `*/*` — dann ist +alles erlaubt. Auf einer Instanz im eigenen Netz ist das vertretbar; öffentlich +wäre es das nicht, weil dann auch beliebige ausführbare Dateien hochladbar sind. + +### Ohne Serverzugriff: die Paket-Registry + +Die nimmt beliebige Dateien an, ganz ohne Typenliste, und ist auf dieser +Instanz eingeschaltet. Der Preis: Die Datei landet unter *Packages* statt am +Release. + +```bash +Android/upload-package.sh 1.0.0 +``` + +Das Skript liest den Zugangsschlüssel aus `~/.gitea-token` und gibt ihn +nirgends aus. Ein Token mit dem Recht `write:package` genügt; anzulegen unter +*Einstellungen → Anwendungen → Zugriffstoken*. + +## Von Hand veröffentlichen + +```bash +source Android/env.sh +cd Android +./gradlew :app:assembleRelease -PversionName=1.0.0 -PversionCode=1 +``` + +Die APK liegt dann unter +`Android/app/build/outputs/apk/release/app-release.apk`. + +In Gitea: **Releases → New Release**, ein Etikett wie `v1.0.0` wählen, und die +APK unter *Attachments* hochladen. Fertig — genau das ist das „Wie sage ich +Gitea, dass die APK dort hinein soll". + +## Von selbst veröffentlichen + +`.gitea/workflows/release.yml` nimmt das ab: Ein Etikett hochschieben genügt. + +```bash +git tag v1.0.0 +git push origin v1.0.0 +``` + +Der Ablauf baut die APK, legt das Release an, falls es noch nicht besteht, und +hängt die Datei an. Die Versionsnummer kommt aus dem Etikett, der +Versionszähler aus der Anzahl der Commits — so steigt er verlässlich. + +### Voraussetzungen + +1. **Ein Runner.** Actions brauchen in Gitea einen registrierten Runner mit dem + Etikett `ubuntu-latest`. Unter *Site Administration → Actions → Runners* + nachsehen. Ist keiner da, bleibt der Weg von Hand — der tut es genauso. +2. **Actions eingeschaltet**, im Repository unter *Settings → Advanced*. +3. **Der Schlüssel als Geheimnis**, falls mit eigenem Schlüssel signiert werden + soll. Unter *Settings → Actions → Secrets* anlegen: + + | Name | Inhalt | + | ------------------ | ------------------------------------------ | + | `KEYSTORE_BASE64` | `base64 -i ~/camper-monitor.jks \| pbcopy` | + | `KEYSTORE_PASSWORD`| das Passwort des Schlüsselspeichers | + | `KEY_ALIAS` | `camper` | + | `KEY_PASSWORD` | das Passwort des Schlüssels | + + Fehlen sie, baut der Ablauf trotzdem — dann eben mit dem Debug-Schlüssel, + und die APK heisst entsprechend. diff --git a/Android/app/build.gradle.kts b/Android/app/build.gradle.kts new file mode 100644 index 0000000..c548df8 --- /dev/null +++ b/Android/app/build.gradle.kts @@ -0,0 +1,123 @@ +import java.util.Properties + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") +} + +/** + * Angaben zum Signaturschlüssel. + * + * Sie stehen in `Android/keystore.properties` - einer Datei, die bewusst nicht + * im Repository liegt. Fehlt sie, wird stattdessen aus der Umgebung gelesen; + * so kann ein Bauknecht sie aus seinen Geheimnissen setzen, ohne dass ein + * Passwort je im Verlauf landet. + */ +val signing = Properties().apply { + val file = rootProject.file("keystore.properties") + if (file.exists()) { + file.inputStream().use { load(it) } + } else { + System.getenv("KEYSTORE_FILE")?.let { setProperty("storeFile", it) } + System.getenv("KEYSTORE_PASSWORD")?.let { setProperty("storePassword", it) } + System.getenv("KEY_ALIAS")?.let { setProperty("keyAlias", it) } + System.getenv("KEY_PASSWORD")?.let { setProperty("keyPassword", it) } + } +} +val hasSigningKey = signing.getProperty("storeFile") != null + +android { + namespace = "de.fritob.campermonitor" + compileSdk = 35 + + defaultConfig { + applicationId = "de.fritob.campermonitor" + // Android 8: älter lohnt nicht, dort fehlt zu viel an Bluetooth LE. + minSdk = 26 + targetSdk = 35 + versionCode = (findProperty("versionCode") as String?)?.toInt() ?: 1 + // Beim Bauen aus einem Etikett heraus überschreibbar: + // ./gradlew :app:assembleRelease -PversionName=1.2.0 + versionName = (findProperty("versionName") as String?) ?: "1.0" + } + + signingConfigs { + if (hasSigningKey) { + create("release") { + storeFile = file(signing.getProperty("storeFile")) + storePassword = signing.getProperty("storePassword") + keyAlias = signing.getProperty("keyAlias") + keyPassword = signing.getProperty("keyPassword") + } + } + } + + buildTypes { + release { + isMinifyEnabled = false + // Ohne eigenen Schlüssel wird mit dem Debug-Schlüssel signiert. + // Unsigniert wäre die APK schlicht nicht installierbar - Android + // weist sie ab, auch beim Sideloading. Der Hinweis unten sagt, was + // das für Folgen hat. + signingConfig = if (hasSigningKey) { + signingConfigs.getByName("release") + } else { + signingConfigs.getByName("debug") + } + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + compose = true + buildConfig = true + } + + sourceSets["main"].java.srcDirs("src/main/kotlin") +} + +dependencies { + implementation(project(":protocol")) + + val composeBom = platform("androidx.compose:compose-bom:2024.12.01") + implementation(composeBom) + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-tooling-preview") + debugImplementation("androidx.compose.ui:ui-tooling") + + implementation("androidx.core:core-ktx:1.15.0") + implementation("androidx.activity:activity-compose:1.9.3") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") + implementation("androidx.navigation:navigation-compose:2.8.5") + + // Die Ablage der Geräte und Profile nutzt org.json aus Android selbst - + // eine weitere Bibliothek lohnt für eine Handvoll Felder nicht. +} + +// Sichtbar machen, womit signiert wurde - eine mit dem Debug-Schlüssel +// signierte Release-APK sieht sonst aus wie eine richtige. +tasks.matching { it.name == "assembleRelease" }.configureEach { + doLast { + if (!hasSigningKey) { + logger.lifecycle( + "\n ACHTUNG: mit dem Debug-Schlüssel signiert." + + "\n Installierbar, aber an diesen Rechner gebunden: auf einem anderen" + + "\n entsteht ein anderer Debug-Schlüssel, und ein Update liesse sich" + + "\n nicht mehr über die alte Installation legen." + + "\n Für einen eigenen Schlüssel: Android/keystore.properties anlegen." + + "\n Anleitung in Android/RELEASE.md.\n" + ) + } + } +} diff --git a/Android/app/src/main/AndroidManifest.xml b/Android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..455765a --- /dev/null +++ b/Android/app/src/main/AndroidManifest.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/MainActivity.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/MainActivity.kt new file mode 100644 index 0000000..3821db6 --- /dev/null +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/MainActivity.kt @@ -0,0 +1,147 @@ +package de.fritob.campermonitor + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.runtime.Composable +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import de.fritob.campermonitor.bluetooth.BluetoothManager +import de.fritob.campermonitor.store.DemoData +import de.fritob.campermonitor.store.DeviceStore +import de.fritob.campermonitor.ui.AddDeviceScreen +import de.fritob.campermonitor.ui.AlignmentAssistantScreen +import de.fritob.campermonitor.ui.CamperTheme +import de.fritob.campermonitor.ui.DashboardScreen +import de.fritob.campermonitor.ui.DeviceDetailScreen +import de.fritob.campermonitor.ui.LevelSetupScreen +import de.fritob.campermonitor.ui.ProfilesScreen +import de.fritob.campermonitor.ui.SensorSetupScreen +import de.fritob.campermonitor.ui.SettingsScreen +import de.fritob.campermonitor.ui.VictronKeyScreen +import de.fritob.campermonitor.ui.WithBluetoothPermission +import java.util.UUID + +class MainActivity : ComponentActivity() { + + private lateinit var store: DeviceStore + private lateinit var bluetooth: BluetoothManager + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + store = DeviceStore(applicationContext) + bluetooth = BluetoothManager(applicationContext, store) + + // Demo-Modus für den Emulator, wo es kein Bluetooth gibt: + // adb shell am start -n de.fritob.campermonitor/.MainActivity --ez demo true + val demo = BuildConfig.DEBUG && intent?.getBooleanExtra("demo", false) == true + if (demo) DemoData.install(store, bluetooth) + + setContent { + CamperTheme { + if (demo) { + CamperNavigation(store, bluetooth) + return@CamperTheme + } + WithBluetoothPermission { + // Erst wenn die Rechte da sind, darf der Scan starten - + // ohne sie wirft Android beim Scannen. + androidx.compose.runtime.LaunchedEffect(Unit) { bluetooth.start() } + CamperNavigation(store, bluetooth) + } + } + } + } + + override fun onDestroy() { + super.onDestroy() + bluetooth.stopEverything() + } +} + +@Composable +private fun CamperNavigation(store: DeviceStore, bluetooth: BluetoothManager) { + val navController = rememberNavController() + + /** + * Der Weg vom Navigationsargument zurück zum aktuellen Gerät. Bewusst ohne + * Zwischenspeicher: nach einer Umbenennung soll der neue Stand gelten. + */ + fun device(id: String?) = store.devices.firstOrNull { it.id.toString() == id } + + NavHost(navController = navController, startDestination = "dashboard") { + composable("dashboard") { + DashboardScreen( + store = store, + bluetooth = bluetooth, + onOpenDevice = { navController.navigate("device/${it.id}") }, + onAddDevice = { navController.navigate("add") }, + onOpenProfiles = { navController.navigate("profiles") }, + onOpenSettings = { navController.navigate("settings") }, + ) + } + + composable("device/{id}") { entry -> + device(entry.arguments?.getString("id"))?.let { + DeviceDetailScreen( + device = it, + store = store, + bluetooth = bluetooth, + onBack = { navController.popBackStack() }, + onOpenKey = { navController.navigate("key/${it.id}") }, + onOpenLevelSetup = { navController.navigate("levelsetup/${it.id}") }, + onOpenAssistant = { navController.navigate("assistant/${it.id}") }, + ) + } + } + + composable("key/{id}") { entry -> + device(entry.arguments?.getString("id"))?.let { + VictronKeyScreen(it, store, bluetooth) { navController.popBackStack() } + } + } + + composable("levelsetup/{id}") { entry -> + device(entry.arguments?.getString("id"))?.let { + LevelSetupScreen( + device = it, + store = store, + bluetooth = bluetooth, + onBack = { navController.popBackStack() }, + onOpenSensorSetup = { navController.navigate("sensorsetup/${it.id}") }, + ) + } + } + + composable("sensorsetup/{id}") { entry -> + device(entry.arguments?.getString("id"))?.let { + SensorSetupScreen(it, store, bluetooth) { navController.popBackStack() } + } + } + + composable("assistant/{id}") { entry -> + device(entry.arguments?.getString("id"))?.let { + AlignmentAssistantScreen(it, store, bluetooth) { navController.popBackStack() } + } + } + + composable("add") { + AddDeviceScreen(store, bluetooth) { navController.popBackStack() } + } + + composable("profiles") { + ProfilesScreen(store, bluetooth) { navController.popBackStack() } + } + + composable("settings") { + SettingsScreen(store) { navController.popBackStack() } + } + } +} + +/** Kleine Hilfe, damit Bildschirme das Gerät über seine Kennung finden. */ +internal fun DeviceStore.device(id: UUID) = devices.firstOrNull { it.id == id } diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/bluetooth/BluetoothManager.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/bluetooth/BluetoothManager.kt new file mode 100644 index 0000000..0a5d882 --- /dev/null +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/bluetooth/BluetoothManager.kt @@ -0,0 +1,690 @@ +package de.fritob.campermonitor.bluetooth + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGattCallback +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanResult +import android.bluetooth.le.ScanSettings +import android.content.Context +import android.os.Handler +import android.os.HandlerThread +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import de.fritob.campermonitor.protocol.AlpicoolProtocol +import de.fritob.campermonitor.protocol.AlpicoolState +import de.fritob.campermonitor.protocol.ConfiguredDevice +import de.fritob.campermonitor.protocol.DeviceLinkState +import de.fritob.campermonitor.protocol.DeviceRole +import de.fritob.campermonitor.protocol.DeviceSnapshot +import de.fritob.campermonitor.protocol.DeviceTransport +import de.fritob.campermonitor.protocol.FridgeZone +import de.fritob.campermonitor.protocol.FridgeZoneMode +import de.fritob.campermonitor.protocol.LevelState +import de.fritob.campermonitor.protocol.SensorOrientation +import de.fritob.campermonitor.protocol.VictronAdvertisement +import de.fritob.campermonitor.store.DeviceStore +import java.util.UUID +import kotlin.math.min +import kotlin.math.pow + +/** Was beim Einrichten in der Geräteliste steht. */ +data class Discovery( + val address: String, + val name: String?, + val rssi: Int, + val firstSeen: Long, + val lastSeen: Long, + val isVictron: Boolean, + val victronRecord: String? = null, + val serviceUUIDs: List = emptyList(), +) { + /** + * Ob das Gerät nach einem der unterstützten aussieht. Nur für die + * Sortierung – ausschliessen tut die Liste nichts. + */ + val looksLikeSupported: Boolean + get() { + if (isVictron) return true + val n = name?.lowercase() ?: return false + return listOf("dl-", "daly", "jbd", "xiaoxiang", "wattcycle", "hilink", + "vanalign", "alpicool", "icecube").any { n.contains(it) } + } +} + +/** Was die Diagnoseansicht über ein Victron-Advertisement zeigt. */ +data class VictronDiagnostics( + val expectedKeyFirstByte: Int?, + val recordName: String, + val productIDText: String, + val rawHex: String, +) { + val expectedKeyText: String + get() = expectedKeyFirstByte?.let { "0x%02X".format(it) } ?: "–" +} + +/** + * Die Funkschicht der App. + * + * **Alles, was Bluetooth anfasst, läuft auf einem eigenen Thread.** Android + * ruft Scan- und GATT-Rückmeldungen auf Binder-Threads auf; würden sie direkt + * den Zustand der Oberfläche anfassen, entstünde ein Datenwettlauf und die + * Oberfläche würde bei jedem Advertisement neu zeichnen. Ergebnisse gehen + * deshalb über [publish] zurück auf den Hauptthread. + */ +@SuppressLint("MissingPermission") +class BluetoothManager( + private val context: Context, + private val store: DeviceStore, +) { + // MARK: - Was die Oberfläche liest (nur Hauptthread) + + var isBluetoothReady by mutableStateOf(false) + private set + var bluetoothStatusText by mutableStateOf("Bluetooth wird gestartet…") + private set + + val snapshots = mutableStateMapOf() + val linkStates = mutableStateMapOf() + val fridgeStates = mutableStateMapOf() + val levelStates = mutableStateMapOf() + val bmsDiagnostics = mutableStateMapOf() + val diagnostics = mutableStateMapOf() + val discoveries = mutableStateMapOf() + val history = mutableStateMapOf>() + + data class HistorySample(val time: Long, val value: Double) + + // MARK: - Funk-Thread + + private val thread = HandlerThread("de.fritob.CamperMonitor.bluetooth").apply { start() } + private val handler = Handler(thread.looper) + private val mainHandler = Handler(context.mainLooper) + + private val adapter: BluetoothAdapter? = + (context.getSystemService(Context.BLUETOOTH_SERVICE) as? android.bluetooth.BluetoothManager) + ?.adapter + + private fun publish(work: () -> Unit) = mainHandler.post(work) + + // MARK: - Zustand auf dem Funk-Thread + + private class ManagedDevice( + val id: UUID, + val address: String, + val transport: DeviceTransport, + val role: DeviceRole, + val fridgeZoneMode: FridgeZoneMode, + val sensorOrientation: SensorOrientation, + val victronKey: ByteArray?, + ) + + private var managed: Map = emptyMap() + private val bmsSessions = mutableMapOf() + private val levelSessions = mutableMapOf() + private val connecting = mutableSetOf() + + /** + * Frühester Zeitpunkt für den nächsten Verbindungsversuch je Gerät. + * + * Ohne diese Sperre entsteht ein Verbindungssturm: schlägt ein Versuch + * fehl, ist der Eintrag wieder frei, und das nächste Advertisement löst + * sofort den nächsten aus. Geräte quittieren jeden Versuch, Kühlboxen etwa + * mit einem Piepton. + */ + private val nextConnectAttempt = mutableMapOf() + private val connectFailures = mutableMapOf() + private val connectedSince = mutableMapOf() + + /** + * Stellbefehle, die kamen, während die Verbindung weg war. Ohne das + * verschwindet ein Tippen lautlos. + */ + private val pendingControls = mutableMapOf>>() + + /** Letzter bekannter Kühlbox-Zustand, auch über eine Trennung hinweg. */ + private val fridgeStateCache = mutableMapOf() + + private val lastHandledAdvertisement = mutableMapOf() + + private var discovering = false + private var scanning = false + + private companion object { + const val FIRST_RETRY_DELAY_MS = 5_000L + const val LONGEST_RETRY_DELAY_MS = 60_000L + + /** + * Nach dem Abbruch einer Verbindung, die stand, wird zügig nachgefasst – + * das ist der Normalfall im Fahrzeug und kein Fehler. + */ + const val QUICK_RETRY_DELAY_MS = 2_000L + + /** + * Ab dieser Dauer gilt eine Verbindung als zustandegekommen. Was sofort + * wieder abbricht, zählt als Fehlschlag. + */ + const val STABLE_CONNECTION_MS = 3_000L + + /** + * Advertisements treffen mehrmals je Sekunde ein. Öfter als hier + * festgelegt wird nichts ausgewertet. + */ + const val MINIMUM_ADVERTISEMENT_INTERVAL_MS = 900L + + const val RECONNECT_INTERVAL_MS = 15_000L + const val CONTROL_LIFETIME_MS = 30_000L + const val HISTORY_LIMIT = 720 + } + + /** Ob die Funkschicht stillgelegt ist, weil Demo-Daten angezeigt werden. */ + private var isDemo = false + + /** + * Nur für den Demo-Modus: fertige Werte einsetzen und nicht funken. So + * lassen sich die Ansichten ohne Fahrzeug prüfen. + */ + fun installDemo( + snapshots: List, + fridge: Pair, + level: Pair, + history: Map>, + ) { + isDemo = true + isBluetoothReady = true + bluetoothStatusText = "Demo-Daten" + snapshots.forEach { + this.snapshots[it.deviceID] = it + linkStates[it.deviceID] = DeviceLinkState.Live + } + fridgeStates[fridge.first] = fridge.second + levelStates[level.first] = level.second + history.forEach { (id, values) -> + this.history[id] = values.map { HistorySample(System.currentTimeMillis(), it) } + .toMutableList() + } + } + + // MARK: - Start + + fun start() { + if (isDemo) return + handler.post { + refreshAdapterState() + applyConfiguration(currentDevices()) + } + } + + fun refreshConfiguration() { + handler.post { applyConfiguration(currentDevices()) } + } + + private fun currentDevices(): List = store.activeDevices().map { + ManagedDevice( + id = it.id, + address = it.address, + transport = it.role.transport, + role = it.role, + fridgeZoneMode = it.fridgeZoneMode, + sensorOrientation = it.sensorOrientation, + victronKey = store.victronKey(it.id), + ) + } + + private fun refreshAdapterState() { + val a = adapter + val ready = a != null && a.isEnabled + val text = when { + a == null -> "Dieses Gerät unterstützt kein Bluetooth LE" + !a.isEnabled -> "Bluetooth ist ausgeschaltet" + else -> "Bereit" + } + publish { + isBluetoothReady = ready + bluetoothStatusText = text + } + } + + /** Schaltet die Anzeige der gefundenen Geräte beim Einrichten ein und aus. */ + fun setDiscovering(wanted: Boolean) { + handler.post { + if (discovering == wanted) return@post + discovering = wanted + if (!wanted) publish { discoveries.clear() } + } + } + + // MARK: - Ablauf auf dem Funk-Thread + + private fun applyConfiguration(devices: List) { + managed = devices.associateBy { it.address } + + // Verbindungen zu Geräten lösen, die nicht mehr dazugehören. + val wanted = devices.filter { it.transport == DeviceTransport.CONNECT } + .map { it.address }.toSet() + for (address in bmsSessions.keys.toList()) { + if (address !in wanted) { + bmsSessions.remove(address)?.stop() + } + } + for (address in levelSessions.keys.toList()) { + if (address !in wanted) { + levelSessions.remove(address)?.stop() + } + } + // Einstellungen an bestehende Sitzungen weiterreichen. + for (device in devices) { + bmsSessions[device.address]?.fridgeZoneMode = device.fridgeZoneMode + levelSessions[device.address]?.orientation = device.sensorOrientation + } + lastHandledAdvertisement.clear() + nextConnectAttempt.clear() + connectFailures.clear() + connectedSince.clear() + + startScanning() + } + + private fun startScanning() { + val a = adapter ?: return + if (!a.isEnabled) return + restartScan() + connectManagedDevices() + scheduleReconnects() + } + + private fun restartScan() { + val scanner = adapter?.bluetoothLeScanner ?: return + if (scanning) scanner.stopScan(scanCallback) + val settings = ScanSettings.Builder() + .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) + // Victron sendet seine Werte im Advertisement, also müssen auch + // Wiederholungen durchgereicht werden. + .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES) + .setReportDelay(0) + .build() + scanner.startScan(null, settings, scanCallback) + scanning = true + } + + fun stopEverything() { + handler.post { + if (scanning) adapter?.bluetoothLeScanner?.stopScan(scanCallback) + scanning = false + reconnectRunnable?.let { handler.removeCallbacks(it) } + bmsSessions.values.forEach { it.stop() } + levelSessions.values.forEach { it.stop() } + bmsSessions.clear() + levelSessions.clear() + connectedSince.clear() + pendingControls.clear() + } + } + + private var reconnectRunnable: Runnable? = null + + /** + * Verbindungen fallen im Fahrzeug regelmässig weg – deshalb regelmässig + * nachfassen statt nur auf das Trennen zu reagieren. + */ + private fun scheduleReconnects() { + reconnectRunnable?.let { handler.removeCallbacks(it) } + val runnable = object : Runnable { + override fun run() { + connectManagedDevices() + handler.postDelayed(this, RECONNECT_INTERVAL_MS) + } + } + reconnectRunnable = runnable + handler.postDelayed(runnable, RECONNECT_INTERVAL_MS) + } + + private fun connectManagedDevices() { + managed.values + .filter { it.transport == DeviceTransport.CONNECT } + .forEach { connectIfNeeded(it) } + } + + private fun connectIfNeeded(device: ManagedDevice) { + if (device.address in bmsSessions || device.address in levelSessions) return + if (device.address in connecting) return + // Nach einem Fehlschlag eine Weile Ruhe geben, sonst wird das Gerät im + // Sekundentakt angeklopft. + nextConnectAttempt[device.address]?.let { + if (System.currentTimeMillis() < it) return + } + val remote = adapter?.getRemoteDevice(device.address) ?: return + connecting.add(device.address) + publish { linkStates[device.id] = DeviceLinkState.Connecting } + + val provider: (BluetoothGattCallback) -> android.bluetooth.BluetoothGatt? = { cb -> + remote.connectGatt(context, false, cb, BluetoothDevice.TRANSPORT_LE) + } + + if (device.role == DeviceRole.LEVELING) { + val session = LevelSession( + deviceID = device.id, + gattProvider = provider, + handler = handler, + onUpdate = { record(it) }, + onStateChange = { handleLinkState(device, it) }, + onLevelState = { state -> publish { levelStates[device.id] = state } }, + onDeviceOrientation = { orientation -> + // Im Gerät steht, wie der Sensor eingebaut ist – für alle + // Clients dasselbe. Also übernehmen statt überschreiben. + adoptOrientation(orientation, device.id) + }, + ) + session.orientation = device.sensorOrientation + levelSessions[device.address] = session + session.start() + return + } + + val session = BmsSession( + deviceID = device.id, + gattProvider = provider, + handler = handler, + onUpdate = { record(it) }, + onStateChange = { handleLinkState(device, it) }, + onDiagnostics = { info -> publish { bmsDiagnostics[device.id] = info } }, + ) + session.onFridgeState = { state -> + fridgeStateCache[device.address] = state + publish { fridgeStates[device.id] = state } + } + session.fridgeZoneMode = device.fridgeZoneMode + bmsSessions[device.address] = session + session.start() + flushPendingControls(device.address, session) + } + + /** + * Wertet aus, was eine Sitzung über ihren Verbindungszustand meldet, und + * pflegt daraus die Wartesperren. + */ + private fun handleLinkState(device: ManagedDevice, state: DeviceLinkState) { + when (state) { + is DeviceLinkState.Live -> { + if (device.address !in connectedSince) { + connectedSince[device.address] = System.currentTimeMillis() + } + connecting.remove(device.address) + } + + is DeviceLinkState.Searching -> { + // Getrennt. Wie lange die Verbindung stand, entscheidet, wie + // lange gewartet wird. + val lifetime = connectedSince.remove(device.address) + ?.let { System.currentTimeMillis() - it } ?: 0 + connecting.remove(device.address) + bmsSessions.remove(device.address) + levelSessions.remove(device.address) + if (lifetime >= STABLE_CONNECTION_MS) { + // Die Verbindung stand und ist weggefallen – im Fahrzeug + // der Normalfall. Kurz durchatmen, dann wieder ran, sonst + // wäre das Gerät minutenlang nicht bedienbar. + clearBackOff(device.address) + nextConnectAttempt[device.address] = + System.currentTimeMillis() + QUICK_RETRY_DELAY_MS + } else { + // Sofort wieder abgebrochen: das zählt wie ein Fehlschlag. + backOff(device.address) + } + } + + is DeviceLinkState.Failed -> { + connecting.remove(device.address) + bmsSessions.remove(device.address) + levelSessions.remove(device.address) + backOff(device.address) + } + + else -> Unit + } + publish { linkStates[device.id] = state } + } + + /** + * Sperrt weitere Versuche für eine Weile. Der Abstand wächst mit jedem + * Fehlschlag, damit ein dauerhaft unerreichbares Gerät nicht endlos + * angeklopft wird. + */ + private fun backOff(address: String) { + val failures = (connectFailures[address] ?: 0) + 1 + connectFailures[address] = failures + val delay = min( + FIRST_RETRY_DELAY_MS * 2.0.pow(failures - 1), + LONGEST_RETRY_DELAY_MS.toDouble(), + ) + nextConnectAttempt[address] = System.currentTimeMillis() + delay.toLong() + } + + private fun clearBackOff(address: String) { + connectFailures.remove(address) + nextConnectAttempt.remove(address) + } + + // MARK: - Scan + + private val scanCallback = object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: ScanResult) { + handler.post { handleScanResult(result) } + } + + override fun onBatchScanResults(results: MutableList) { + handler.post { results.forEach { handleScanResult(it) } } + } + + override fun onScanFailed(errorCode: Int) { + publish { bluetoothStatusText = "Suche fehlgeschlagen (Code $errorCode)" } + } + } + + private fun handleScanResult(result: ScanResult) { + val address = result.device.address + val now = System.currentTimeMillis() + val record = result.scanRecord + + // Android liefert die Herstellerdaten ohne die zwei Bytes der + // Company-ID; die steht im Schlüssel der Tabelle. Wir setzen sie wieder + // davor, damit der Rahmen genauso aussieht wie unter iOS. + val manufacturerData = record?.manufacturerSpecificData?.let { table -> + val index = table.indexOfKey(VictronAdvertisement.COMPANY_IDENTIFIER) + if (index >= 0) { + byteArrayOf( + (VictronAdvertisement.COMPANY_IDENTIFIER and 0xFF).toByte(), + (VictronAdvertisement.COMPANY_IDENTIFIER shr 8).toByte(), + ) + table.valueAt(index) + } else { + null + } + } + + if (discovering) { + updateDiscovery(result, manufacturerData, now) + } + + val device = managed[address] ?: return + val last = lastHandledAdvertisement[address] ?: 0 + if (now - last < MINIMUM_ADVERTISEMENT_INTERVAL_MS) return + lastHandledAdvertisement[address] = now + + when (device.transport) { + DeviceTransport.ADVERTISEMENT -> + manufacturerData?.let { handleVictron(it, device, result.rssi) } + + DeviceTransport.CONNECT -> + // Das Gerät wurde gesehen – falls die Verbindung fehlt, jetzt aufbauen. + connectIfNeeded(device) + } + } + + private fun updateDiscovery(result: ScanResult, manufacturerData: ByteArray?, now: Long) { + val address = result.device.address + val envelope = manufacturerData?.let { VictronAdvertisement.envelope(it) } + val existing = discoveries[address] + val discovery = Discovery( + address = address, + name = result.scanRecord?.deviceName ?: result.device.name, + // Geglättet, sonst springt die Liste beim Lesen. + rssi = existing?.let { (it.rssi * 2 + result.rssi) / 3 } ?: result.rssi, + firstSeen = existing?.firstSeen ?: now, + lastSeen = now, + isVictron = envelope != null, + victronRecord = envelope?.knownRecord?.name, + serviceUUIDs = result.scanRecord?.serviceUuids?.map { it.uuid.toString() } ?: emptyList(), + ) + publish { discoveries[address] = discovery } + } + + private fun handleVictron(data: ByteArray, device: ManagedDevice, rssi: Int) { + val envelope = VictronAdvertisement.envelope(data) ?: return + publish { + diagnostics[device.id] = VictronDiagnostics( + expectedKeyFirstByte = envelope.keyCheckByte, + recordName = envelope.knownRecord?.name ?: "unbekannt (0x%02X)".format(envelope.recordType), + productIDText = envelope.productIDText, + rawHex = data.toHexText(), + ) + } + + val key = device.victronKey + if (key == null || key.size != 16) { + publish { linkStates[device.id] = DeviceLinkState.NeedsKey } + return + } + try { + val snapshot = VictronAdvertisement.decode(data, key, device.id, rssi) + publish { linkStates[device.id] = DeviceLinkState.Live } + record(snapshot) + } catch (error: VictronAdvertisement.DecodeError) { + publish { linkStates[device.id] = DeviceLinkState.Failed(error.describe) } + } + } + + // MARK: - Verlauf + + private fun record(snapshot: DeviceSnapshot) { + publish { + snapshots[snapshot.deviceID] = snapshot + snapshot.primaryMetric?.value?.let { value -> + val samples = history.getOrPut(snapshot.deviceID) { mutableListOf() } + samples.add(HistorySample(snapshot.timestamp, value)) + while (samples.size > HISTORY_LIMIT) samples.removeAt(0) + } + } + } + + // MARK: - Kühlbox steuern + + fun setFridgeTarget(celsius: Int, zone: FridgeZone, deviceID: UUID) { + sendFridgeSettings(deviceID) { AlpicoolState.setTarget(zone, celsius) } + } + + fun setFridgePower(on: Boolean, deviceID: UUID) { + sendFridgeSettings(deviceID) { it.settingsCommand(poweredOn = on) } + } + + fun setFridgeEco(eco: Boolean, deviceID: UUID) { + sendFridgeSettings(deviceID) { it.settingsCommand(eco = eco) } + } + + fun setFridgeLock(locked: Boolean, deviceID: UUID) { + sendFridgeSettings(deviceID) { it.settingsCommand(locked = locked) } + } + + /** + * Der Einstellungsblock wird aus dem zuletzt empfangenen Zustand gebaut – + * und zwar auf dem Funk-Thread, wo dieser Zustand lebt. + * + * Fehlt die Verbindung gerade, wird der Befehl aufgehoben und ein Versuch + * angestossen. Ein Tippen darf nicht daran scheitern, dass die Box sich + * zwei Sekunden vorher abgemeldet hat. + */ + private fun sendFridgeSettings(deviceID: UUID, build: (AlpicoolState) -> ByteArray?) { + handler.post { + val device = managed.values.firstOrNull { it.id == deviceID } ?: return@post + val session = bmsSessions[device.address] + if (session != null) { + build(session.alpicoolState)?.let { session.sendControl(it) } + return@post + } + val known = fridgeStateCache[device.address] ?: return@post + val packet = build(known) ?: return@post + val waiting = pendingControls.getOrPut(device.address) { mutableListOf() } + waiting.add(packet to System.currentTimeMillis()) + // Wer mehrfach tippt, meint den letzten Stand. + while (waiting.size > 4) waiting.removeAt(0) + + // Der Wunsch des Benutzers hebt die Wartesperre auf. + clearBackOff(device.address) + connectIfNeeded(device) + } + } + + /** Gibt weiter, was während der Trennung aufgelaufen ist. */ + private fun flushPendingControls(address: String, session: BmsSession) { + val waiting = pendingControls.remove(address) ?: return + val now = System.currentTimeMillis() + waiting.filter { now - it.second < CONTROL_LIFETIME_MS } + .forEachIndexed { index, entry -> + handler.postDelayed({ session.sendControl(entry.first) }, index * 400L) + } + } + + fun updateFridgeZoneMode(device: ConfiguredDevice) { + handler.post { + val session = bmsSessions[device.address] ?: return@post + session.fridgeZoneMode = device.fridgeZoneMode + val state = session.alpicoolState + publish { fridgeStates[device.id] = state } + } + } + + // MARK: - Neigungsmesser + + /** + * Nach einer Änderung der Einbaulage aufrufen. + * + * Die Lage wandert zusätzlich ins Gerät. Dort gehört sie hin: Sie + * beschreibt den Einbau, und iPhone wie Uhr finden sie dann vor, ohne dass + * jemand sie ein zweites Mal bestimmen muss. + */ + fun updateSensorOrientation(device: ConfiguredDevice) { + handler.post { + val session = levelSessions[device.address] ?: return@post + session.orientation = device.sensorOrientation + session.storeOrientation(device.sensorOrientation) + } + } + + /** + * Übernimmt die Einbaulage, die das Gerät meldet. + * + * Steht dort noch nichts, meldet das Gerät nichts – dann bleibt es bei der + * örtlich gespeicherten Fassung, und die wird beim nächsten Bestimmen + * hinaufgeschrieben. + */ + private fun adoptOrientation(orientation: SensorOrientation, deviceID: UUID) { + val device = store.devices.firstOrNull { it.id == deviceID } ?: return + if (device.sensorOrientation == orientation) return + store.update(device.copy(sensorOrientation = orientation)) + } + + fun calibrateLevel(deviceID: UUID) { + handler.post { levelSession(deviceID)?.calibrate() } + } + + fun resetLevelCalibration(deviceID: UUID) { + handler.post { levelSession(deviceID)?.resetCalibration() } + } + + private fun levelSession(deviceID: UUID): LevelSession? { + val device = managed.values.firstOrNull { it.id == deviceID } ?: return null + return levelSessions[device.address] + } +} diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/bluetooth/BmsSession.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/bluetooth/BmsSession.kt new file mode 100644 index 0000000..563081e --- /dev/null +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/bluetooth/BmsSession.kt @@ -0,0 +1,859 @@ +package de.fritob.campermonitor.bluetooth + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCallback +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.BluetoothProfile +import android.os.Handler +import de.fritob.campermonitor.protocol.AlpicoolProtocol +import de.fritob.campermonitor.protocol.AlpicoolState +import de.fritob.campermonitor.protocol.DalyProtocol +import de.fritob.campermonitor.protocol.DalyState +import de.fritob.campermonitor.protocol.DeviceLinkState +import de.fritob.campermonitor.protocol.DeviceSnapshot +import de.fritob.campermonitor.protocol.FridgeZoneMode +import de.fritob.campermonitor.protocol.JbdProtocol +import de.fritob.campermonitor.protocol.JbdState +import de.fritob.campermonitor.protocol.WattCycleProtocol +import de.fritob.campermonitor.protocol.WattCycleState +import java.util.UUID + +/** Was die Diagnoseansicht über eine laufende Sitzung zeigt. */ +data class BmsDiagnostics( + val dialect: String, + val endpointLabel: String? = null, + val endpointPosition: Pair? = null, + val serviceUUID: String? = null, + val isConnected: Boolean = false, + val isNotifyActive: Boolean = false, + /** Nur bei Kühlboxen: ob die Box die Anmeldung beantwortet hat. */ + val isBound: Boolean? = null, + val confirmedWrites: Int = 0, + val lastWriteError: String? = null, + val gattSummary: List = emptyList(), + val sentFrames: Int = 0, + val receivedBytes: Int = 0, + val lastSendAt: Long? = null, + val lastResponseHex: String? = null, + val lastCommandHex: String? = null, + val lastCommandAt: Long? = null, + /** Nur bei Kühlboxen: die vollständige Nutzlast der letzten Statusantwort. */ + val fridgePayloadHex: String? = null, + val updated: Long = System.currentTimeMillis(), +) + +/** + * Hält die GATT-Verbindung zu einem BMS oder einer Kühlbox, fragt die Werte ab + * und meldet fertige Snapshots zurück. + * + * Zwei Dinge sind bei diesen Geräten nicht vorhersehbar und werden deshalb + * ausprobiert statt vorausgesetzt: + * + * 1. **Über welche Charakteristiken gesprochen wird.** Im selben Dienst sehen + * oft mehrere Charakteristiken beschreibbar aus, nur eine nimmt aber + * wirklich Kommandos an. + * 2. **Welches Protokoll gesprochen wird.** Der erste gültige Rahmen legt den + * Dialekt fest. + * + * Alles läuft auf dem übergebenen [Handler] – demselben, auf dem der + * BluetoothManager arbeitet. Android ruft die GATT-Rückmeldungen auf einem + * eigenen Binder-Thread auf; sie werden deshalb konsequent auf diesen Handler + * geschoben, damit der Zustand dieser Klasse nur von einem Thread aus + * angefasst wird. + */ +@SuppressLint("MissingPermission") +class BmsSession( + private val deviceID: UUID, + private val gattProvider: (BluetoothGattCallback) -> BluetoothGatt?, + private val handler: Handler, + private val onUpdate: (DeviceSnapshot) -> Unit, + private val onStateChange: (DeviceLinkState) -> Unit, + private val onDiagnostics: (BmsDiagnostics) -> Unit, +) { + /** Meldet Änderungen am Kühlbox-Zustand, damit die Bedienelemente folgen. */ + var onFridgeState: ((AlpicoolState) -> Unit)? = null + + enum class Dialect(val label: String) { + UNKNOWN("wird ermittelt"), + DALY_CLASSIC("Daly (klassisch)"), + DALY_MODBUS("Daly (Modbus)"), + JBD("JBD / Xiaoxiang"), + WATT_CYCLE("WattCycle"), + ALPICOOL("Alpicool-Kühlbox"), + } + + private companion object { + /** Bekannte Paare, die zuerst versucht werden. */ + val KNOWN_PAIRS = listOf( + Triple("FFF0", "FFF2", "FFF1"), // Daly und viele baugleiche Module + Triple("FF00", "FF02", "FF01"), // JBD / Xiaoxiang + Triple("FFE0", "FFE1", "FFE1"), + Triple( + "6E400001-B5A3-F393-E0A9-E50E24DCCA9E", + "6E400002-B5A3-F393-E0A9-E50E24DCCA9E", + "6E400003-B5A3-F393-E0A9-E50E24DCCA9E", + ), // Nordic UART + ) + + val ALPICOOL_WRITE = shortUuid("1235") + val ALPICOOL_NOTIFY = shortUuid("1236") + + /** + * Freischalt-Charakteristik der WattCycle-Akkus. Liegt im selben Dienst + * wie Schreiben und Empfangen und muss vor der ersten Abfrage + * beschrieben werden, sonst bleibt der Akku stumm. + */ + val WATTCYCLE_AUTH = shortUuid("FFFA") + + val CLIENT_CONFIG: UUID = UUID.fromString("00002902-0000-1000-8000-00805F9B34FB") + + const val SEARCH_INTERVAL_MS = 3_000L + const val POLL_INTERVAL_MS = 5_000L + + /** So lange darf ein aufgehobener Stellbefehl warten. */ + const val CONTROL_LIFETIME_MS = 30_000L + + fun shortUuid(short: String): UUID = + UUID.fromString("0000${short.padStart(4, '0')}-0000-1000-8000-00805F9B34FB") + + fun uuidOf(text: String): UUID = + if (text.length == 4) shortUuid(text) else UUID.fromString(text) + } + + /** + * Ein Kandidat: worüber geschrieben, worüber gelauscht und wie geschrieben + * wird. Der Schreibmodus gehört dazu, weil manche Module nur die eine oder + * nur die andere Variante annehmen. + */ + private class Endpoint( + val write: BluetoothGattCharacteristic, + val notify: BluetoothGattCharacteristic, + /** Falls vorhanden, wird hierauf vor der ersten Abfrage freigeschaltet. */ + val auth: BluetoothGattCharacteristic?, + val writeType: Int, + val isKnownPair: Boolean, + ) { + val label: String + get() { + val mode = if (writeType == BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE) { + "ohne Bestätigung" + } else { + "mit Bestätigung" + } + val unlock = auth?.let { ", Freischaltung über ${it.uuid}" } ?: "" + return "${write.uuid} → ${notify.uuid}, $mode$unlock" + } + } + + private var gatt: BluetoothGatt? = null + private var endpoints: List = emptyList() + private var endpointIndex = 0 + private val currentEndpoint: Endpoint? get() = endpoints.getOrNull(endpointIndex) + + var dialect: Dialect = Dialect.UNKNOWN + private set + + private val dalyState = DalyState() + private val jbdState = JbdState() + private val wattCycleState = WattCycleState() + val alpicoolState = AlpicoolState() + + /** Aus den Geräteeinstellungen; übersteuert die automatische Erkennung. */ + var fridgeZoneMode: FridgeZoneMode = FridgeZoneMode.AUTOMATIC + set(value) { + field = value + alpicoolState.zoneMode = value + } + + private var didBind = false + private var bindAcknowledged = false + + /** + * Ob vor einem Stellbefehl schon einmal nachgemeldet wurde. Jedes Mal + * anzumelden lässt die Box bei jedem Tastendruck erneut piepen. + */ + private var didRebindForControl = false + + private var buffer = ByteArray(0) + private var lastResponse: ByteArray? = null + private var lastCommand: ByteArray? = null + private var lastCommandAt: Long? = null + private var receivedByteCount = 0 + private var sentFrameCount = 0 + private var gattSummary = listOf() + private var silentRounds = 0 + + /** + * Zählt hoch, sobald ein Kandidat aktiviert wird. Späte Rückmeldungen eines + * bereits verworfenen Kandidaten lassen sich so ignorieren. + */ + private var activationToken = 0 + private var isNotifyActive = false + private var didUnlock = false + private var lastSendAt: Long? = null + private var confirmedWrites = 0 + private var lastWriteError: String? = null + + /** + * Stellbefehle, die kamen, bevor der Kanal stand. Sie jetzt schon zu senden + * hiesse, sie an einen womöglich falschen Kandidaten zu schicken; sie + * fallen zu lassen hiesse, ein Tippen zu verschlucken. + */ + private val waitingControls = mutableListOf>() + + private var pollRunnable: Runnable? = null + + // MARK: - Lebenszyklus + + fun start() { + onStateChange(DeviceLinkState.Connecting) + gatt = gattProvider(callback) + } + + fun stop() { + cancelPoll() + val endpoint = currentEndpoint + val g = gatt + if (endpoint != null && g != null) { + g.setCharacteristicNotification(endpoint.notify, false) + } + endpoints = emptyList() + endpointIndex = 0 + dialect = Dialect.UNKNOWN + buffer = ByteArray(0) + gatt?.close() + gatt = null + } + + fun handleDisconnect() { + cancelPoll() + endpoints = emptyList() + endpointIndex = 0 + buffer = ByteArray(0) + gatt?.close() + gatt = null + } + + // MARK: - Kandidaten + + /** + * Stellt nach der Dienstsuche alle Paare zusammen: bekannte Kombinationen + * zuerst, danach jede andere Schreib-/Notify-Kombination im selben Dienst. + */ + private fun buildEndpoints(g: BluetoothGatt) { + val candidates = mutableListOf() + val summary = mutableListOf() + + for (service in g.services) { + summary.add("Dienst ${service.uuid}") + val characteristics = service.characteristics + for (c in characteristics) { + summary.add(" ${c.uuid} ${propertyText(c)}") + } + + val writable = characteristics.filter { + it.properties and BluetoothGattCharacteristic.PROPERTY_WRITE != 0 || + it.properties and BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE != 0 + } + val notifying = characteristics.filter { + it.properties and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0 || + it.properties and BluetoothGattCharacteristic.PROPERTY_INDICATE != 0 + } + if (writable.isEmpty() || notifying.isEmpty()) continue + + val auth = characteristics.firstOrNull { + it.uuid == WATTCYCLE_AUTH && + (it.properties and BluetoothGattCharacteristic.PROPERTY_WRITE != 0 || + it.properties and BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE != 0) + } + + for (write in writable) { + if (write.uuid == WATTCYCLE_AUTH) continue + for (notify in notifying) { + val isFridgePair = write.uuid == ALPICOOL_WRITE && notify.uuid == ALPICOOL_NOTIFY + val known = isFridgePair || KNOWN_PAIRS.any { + uuidOf(it.first) == service.uuid && + uuidOf(it.second) == write.uuid && + uuidOf(it.third) == notify.uuid + } + // Beide Schreibarten anbieten, sofern das Gerät sie kann. + if (write.properties and BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE != 0) { + candidates.add( + Endpoint(write, notify, auth, + BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE, known) + ) + } + if (write.properties and BluetoothGattCharacteristic.PROPERTY_WRITE != 0) { + candidates.add( + Endpoint(write, notify, auth, + BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT, known) + ) + } + } + } + } + gattSummary = summary + + // Bekannte Paare nach vorn, der Rest in Fundreihenfolge. Die + // Fundreihenfolge muss dabei erhalten bleiben, sonst entschiede der + // Zufall, ob mit oder ohne Bestätigung geschrieben wird - `sortedBy` + // ist in Kotlin stabil und genau dafür da. + endpoints = candidates.sortedBy { if (it.isKnownPair) 0 else 1 } + endpointIndex = 0 + + if (endpoints.isEmpty()) { + onStateChange(DeviceLinkState.Failed("Keine passenden Bluetooth-Merkmale gefunden")) + publishDiagnostics() + return + } + activateCurrentEndpoint() + } + + private fun propertyText(c: BluetoothGattCharacteristic): String { + val parts = mutableListOf() + val p = c.properties + if (p and BluetoothGattCharacteristic.PROPERTY_READ != 0) parts.add("read") + if (p and BluetoothGattCharacteristic.PROPERTY_WRITE != 0) parts.add("write") + if (p and BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE != 0) parts.add("write-nr") + if (p and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0) parts.add("notify") + if (p and BluetoothGattCharacteristic.PROPERTY_INDICATE != 0) parts.add("indicate") + return parts.joinToString(", ") + } + + private fun activateCurrentEndpoint() { + val endpoint = currentEndpoint ?: return + val g = gatt ?: return + silentRounds = 0 + isNotifyActive = false + didUnlock = false + didBind = false + bindAcknowledged = false + didRebindForControl = false + buffer = ByteArray(0) + activationToken += 1 + val token = activationToken + + g.setCharacteristicNotification(endpoint.notify, true) + // Ohne geschriebenen Deskriptor schickt das Gerät nichts – anders als + // unter iOS reicht das Einschalten auf unserer Seite nicht. + endpoint.notify.getDescriptor(CLIENT_CONFIG)?.let { descriptor -> + descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + g.writeDescriptor(descriptor) + } + publishDiagnostics() + + // Manche Module bestätigen das Abonnieren nie. Ohne Zeitlimit bliebe + // die Suche hier für immer stehen, ohne je etwas zu senden. + handler.postDelayed({ + if (activationToken != token || isNotifyActive) return@postDelayed + if (endpoints.size > 1) advanceEndpoint() else beginPolling() + }, 4_000) + } + + /** + * Wechselt auf den nächsten Kandidaten. Sind alle durch, wird von vorn + * begonnen – das Gerät kann zwischenzeitlich aufgewacht sein. + */ + private fun advanceEndpoint() { + val previous = currentEndpoint ?: return + gatt?.setCharacteristicNotification(previous.notify, false) + endpointIndex = (endpointIndex + 1) % endpoints.size + onStateChange(DeviceLinkState.Connecting) + activateCurrentEndpoint() + beginPolling() + } + + /** Derselbe Kanal, nur mit der anderen Schreibart. */ + private fun alternateWriteTypeIndex(): Int? { + val current = currentEndpoint ?: return null + val index = endpoints.indexOfFirst { + it.write.uuid == current.write.uuid && + it.notify.uuid == current.notify.uuid && + it.writeType != current.writeType + } + return if (index >= 0) index else null + } + + private fun unlockThenPoll() { + val endpoint = currentEndpoint ?: return + val auth = endpoint.auth + val token = activationToken + if (auth == null || didUnlock) { + beginPolling() + return + } + // WattCycle verlangt den Text "HiLink", bevor es überhaupt antwortet. + writeRaw(auth, WattCycleProtocol.authPayload, endpoint.writeType) + didUnlock = true + publishDiagnostics() + handler.postDelayed({ + if (activationToken == token) beginPolling() + }, 300) + } + + // MARK: - Abfragen + + private fun cancelPoll() { + pollRunnable?.let { handler.removeCallbacks(it) } + pollRunnable = null + } + + private fun beginPolling() { + cancelPoll() + flushWaitingControls() + poll() + val interval = if (dialect == Dialect.UNKNOWN) SEARCH_INTERVAL_MS else POLL_INTERVAL_MS + val runnable = object : Runnable { + override fun run() { + poll() + handler.postDelayed(this, interval) + } + } + pollRunnable = runnable + handler.postDelayed(runnable, interval) + } + + private fun poll() { + if (currentEndpoint == null) return + when (dialect) { + Dialect.UNKNOWN -> + // Alle Protokolle anfragen; was antwortet, gewinnt. + sendSequence( + listOf( + AlpicoolProtocol.packet(AlpicoolProtocol.Command.BIND), + AlpicoolProtocol.packet(AlpicoolProtocol.Command.QUERY), + WattCycleProtocol.requestFrame(WattCycleProtocol.Datapoint.ANALOG), + DalyProtocol.requestFrame(DalyProtocol.Command.SOC), + JbdProtocol.requestFrame(JbdProtocol.Command.BASIC_INFO), + DalyProtocol.modbusReadFrame(), + ), + spacingMs = 600, graceMs = 3_000, + ) + + Dialect.DALY_CLASSIC -> + sendSequence( + DalyProtocol.Command.entries.map { DalyProtocol.requestFrame(it) }, + spacingMs = 250, graceMs = 2_000, + ) + + Dialect.DALY_MODBUS -> + sendSequence(listOf(DalyProtocol.modbusReadFrame()), spacingMs = 250, graceMs = 2_000) + + Dialect.JBD -> + sendSequence( + JbdProtocol.Command.entries.map { JbdProtocol.requestFrame(it) }, + spacingMs = 250, graceMs = 2_000, + ) + + Dialect.WATT_CYCLE -> { + // Modell und Seriennummer ändern sich nie – nur einmal abfragen. + val frames = mutableListOf( + WattCycleProtocol.requestFrame(WattCycleProtocol.Datapoint.ANALOG) + ) + if (!wattCycleState.hasProductInfo) { + frames.add(WattCycleProtocol.requestFrame(WattCycleProtocol.Datapoint.PRODUCT)) + } + sendSequence(frames, spacingMs = 300, graceMs = 2_000) + } + + Dialect.ALPICOOL -> { + // Die Anmeldung gilt für die Dauer der Verbindung. + val frames = mutableListOf() + if (!didBind) { + frames.add(AlpicoolProtocol.packet(AlpicoolProtocol.Command.BIND)) + didBind = true + } + frames.add(AlpicoolProtocol.packet(AlpicoolProtocol.Command.QUERY)) + sendSequence(frames, spacingMs = 300, graceMs = 2_000) + } + } + } + + /** + * Kommandos leicht versetzt senden – manche Module verschlucken Anfragen, + * die zu dicht aufeinander folgen. + */ + private fun sendSequence(frames: List, spacingMs: Long, graceMs: Long) { + frames.forEachIndexed { index, frame -> + handler.postDelayed({ send(frame) }, index * spacingMs) + } + checkForSilence(frames.size * spacingMs + graceMs) + } + + /** Kommt nichts Brauchbares zurück, wird der nächste Kandidat versucht. */ + private fun checkForSilence(delayMs: Long) { + handler.postDelayed({ + if (gatt == null) return@postDelayed + if (hasUsableData) { + silentRounds = 0 + return@postDelayed + } + silentRounds += 1 + publishDiagnostics() + + // Solange noch kein Protokoll steht, zügig weiterprobieren. + val limit = if (dialect == Dialect.UNKNOWN) 1 else 3 + if (silentRounds <= limit) return@postDelayed + + silentRounds = 0 + if (endpoints.size > 1) { + advanceEndpoint() + } else { + dialect = Dialect.UNKNOWN + onStateChange(DeviceLinkState.Failed("Keine Antwort vom Gerät")) + } + }, delayMs) + } + + private val hasUsableData: Boolean + get() = dalyState.hasUsableData || jbdState.hasUsableData || + wattCycleState.hasUsableData || alpicoolState.hasStatus + + // MARK: - Senden + + private fun send(data: ByteArray) { + val endpoint = currentEndpoint ?: return + val g = gatt ?: return + sentFrameCount += 1 + lastSendAt = System.currentTimeMillis() + + val pieces = AlpicoolProtocol.chunks(data, writeLimit(endpoint)) + pieces.forEachIndexed { index, piece -> + if (index == 0) { + writeRaw(endpoint.write, piece, endpoint.writeType) + } else { + handler.postDelayed( + { currentEndpoint?.let { writeRaw(it.write, piece, it.writeType) } }, + index * AlpicoolProtocol.CHUNK_DELAY_MS, + ) + } + } + publishDiagnostics() + } + + @Suppress("DEPRECATION") + private fun writeRaw( + characteristic: BluetoothGattCharacteristic, + data: ByteArray, + writeType: Int, + ) { + val g = gatt ?: return + characteristic.writeType = writeType + characteristic.value = data + g.writeCharacteristic(characteristic) + } + + /** + * Wieviel je Schreibvorgang rausgeht. + * + * Grundsätzlich das, was die Verbindung hergibt. Die Kühlboxen nehmen aber + * nur die 20 Byte der Standard-MTU an, auch wenn eine grössere ausgehandelt + * wurde. Ohne diese Grenze ginge der Einstellungsblock als ein + * Schreibvorgang raus – und die Box würde ihn ablehnen, während die kurzen + * Befehle durchgehen. Belegt an einer IceCube Dual. + */ + private fun writeLimit(endpoint: Endpoint): Int = + if (dialect == Dialect.ALPICOOL) AlpicoolProtocol.MAX_WRITE_SIZE else negotiatedWriteLimit + + /** Was die ausgehandelte MTU je Schreibvorgang zulässt. */ + private var negotiatedWriteLimit = 20 + + // MARK: - Steuern + + /** + * Schickt einen Stellbefehl und fragt kurz darauf den Zustand ab, damit die + * Anzeige dem Gerät folgt statt der Vermutung. + */ + fun sendControl(packet: ByteArray) { + lastCommand = packet + lastCommandAt = System.currentTimeMillis() + + // Erst wenn der Dialekt steht, ist auch der richtige Kanal bekannt. + if (dialect != Dialect.ALPICOOL || currentEndpoint == null || gatt == null) { + waitingControls.add(packet to System.currentTimeMillis()) + while (waitingControls.size > 4) waitingControls.removeAt(0) + publishDiagnostics() + return + } + + // Hat die Box die Anmeldung nie beantwortet, wird sie einmal je + // Verbindung nachgeholt. Jedes Mal anzumelden lässt die Box bei jedem + // Tastendruck zusätzlich piepen. + if (!bindAcknowledged && !didRebindForControl) { + didRebindForControl = true + send(AlpicoolProtocol.packet(AlpicoolProtocol.Command.BIND)) + handler.postDelayed({ deliverControl(packet, attempt = 0) }, 400) + return + } + deliverControl(packet, attempt = 0) + } + + /** + * Schickt den Befehl und prüft, ob er gewirkt hat. + * + * Manche Module nehmen nur eine der beiden Schreibarten an und melden das + * nicht – der Befehl verschwindet dann lautlos. Bleiben die Einstellungen + * der Box unverändert, wird deshalb einmal mit der anderen Art nachgesetzt. + */ + private fun deliverControl(packet: ByteArray, attempt: Int) { + val before = alpicoolState.settingsFingerprint + val token = activationToken + send(packet) + + // Genug Abstand, damit ein aufgeteiltes Paket vollständig draussen ist. + handler.postDelayed({ + if (dialect == Dialect.ALPICOOL) { + send(AlpicoolProtocol.packet(AlpicoolProtocol.Command.QUERY)) + } + }, 1_000) + + if (attempt > 0) return + handler.postDelayed({ + if (activationToken != token || dialect != Dialect.ALPICOOL || gatt == null) { + return@postDelayed + } + if (alpicoolState.settingsFingerprint != before) return@postDelayed + val index = alternateWriteTypeIndex() ?: return@postDelayed + endpointIndex = index + publishDiagnostics() + deliverControl(packet, attempt = 1) + }, 3_500) + } + + /** Schickt raus, was während des Verbindungsaufbaus aufgelaufen ist. */ + private fun flushWaitingControls() { + if (dialect != Dialect.ALPICOOL || waitingControls.isEmpty()) return + val now = System.currentTimeMillis() + val due = waitingControls.filter { now - it.second < CONTROL_LIFETIME_MS } + waitingControls.clear() + due.forEachIndexed { index, entry -> + handler.postDelayed({ sendControl(entry.first) }, index * 400L) + } + } + + // MARK: - Auswertung + + private fun consume(data: ByteArray) { + lastResponse = data + receivedByteCount += data.size + buffer += data + if (buffer.size > 512) buffer = buffer.copyOfRange(buffer.size - 512, buffer.size) + + // Steht der Dialekt fest, nur noch diesen prüfen. Bei jeder Antwort + // alle fünf Parser durchzugehen belastet ohne Nutzen. + when (dialect) { + Dialect.ALPICOOL -> { consumeAlpicool(); return } + Dialect.WATT_CYCLE -> { consumeWattCycle(); return } + Dialect.JBD -> { consumeJbd(); return } + Dialect.DALY_MODBUS -> { consumeDalyModbus(); return } + Dialect.DALY_CLASSIC -> { consumeDalyClassic(); return } + Dialect.UNKNOWN -> Unit + } + + if (consumeAlpicool()) return + if (consumeWattCycle()) return + if (consumeJbd()) return + if (consumeDalyModbus()) return + if (consumeDalyClassic()) return + + // Etwas kam an, liess sich aber nicht zuordnen: für die Diagnose + // sichtbar machen, damit sich das Protokoll bestimmen lässt. + publishDiagnostics() + } + + private fun consumeAlpicool(): Boolean { + val (frames, remainder) = AlpicoolProtocol.extractFrames(buffer) + if (frames.isEmpty()) return false + buffer = remainder + adopt(Dialect.ALPICOOL) + if (frames.any { it.command == AlpicoolProtocol.Command.BIND.raw }) { + bindAcknowledged = true + } + frames.forEach { alpicoolState.apply(it) } + alpicoolState.zoneMode = fridgeZoneMode + onFridgeState?.invoke(alpicoolState) + publish(alpicoolState.snapshot(deviceID, null), alpicoolState.hasStatus) + return true + } + + private fun consumeWattCycle(): Boolean { + val (frames, remainder) = WattCycleProtocol.extractFrames(buffer) + if (frames.isEmpty()) return false + buffer = remainder + adopt(Dialect.WATT_CYCLE) + frames.forEach { wattCycleState.apply(it) } + publish(wattCycleState.snapshot(deviceID, null), wattCycleState.hasUsableData) + return true + } + + private fun consumeJbd(): Boolean { + val (frames, remainder) = JbdProtocol.extractFrames(buffer) + if (frames.isEmpty()) return false + buffer = remainder + adopt(Dialect.JBD) + frames.forEach { jbdState.apply(it) } + publish(jbdState.snapshot(deviceID, null), jbdState.hasUsableData) + return true + } + + private fun consumeDalyModbus(): Boolean { + val start = buffer.indexOfFirst { (it.toInt() and 0xFF) == 0xD2 } + if (start < 0) return false + val registers = DalyProtocol.parseModbusResponse( + buffer.copyOfRange(start, buffer.size) + ) ?: return false + buffer = ByteArray(0) + adopt(Dialect.DALY_MODBUS) + dalyState.apply(registers) + publish(dalyState.snapshot(deviceID, null), dalyState.hasUsableData) + return true + } + + private fun consumeDalyClassic(): Boolean { + val (frames, remainder) = DalyProtocol.extractA5Frames(buffer) + if (frames.isEmpty()) return false + buffer = remainder + adopt(Dialect.DALY_CLASSIC) + frames.forEach { dalyState.apply(it) } + publish(dalyState.snapshot(deviceID, null), dalyState.hasUsableData) + return true + } + + /** + * Erster verwertbarer Rahmen: Kandidat und Dialekt stehen fest, ab jetzt im + * normalen Takt abfragen. + */ + private fun adopt(newDialect: Dialect) { + silentRounds = 0 + if (dialect == newDialect) return + dialect = newDialect + beginPolling() + } + + private fun publish(snapshot: DeviceSnapshot, usable: Boolean) { + publishDiagnostics() + if (!usable) return + onStateChange(DeviceLinkState.Live) + onUpdate(snapshot) + } + + private fun publishDiagnostics() { + onDiagnostics( + BmsDiagnostics( + dialect = dialect.label, + endpointLabel = currentEndpoint?.label, + endpointPosition = if (endpoints.isEmpty()) null else (endpointIndex + 1) to endpoints.size, + serviceUUID = currentEndpoint?.write?.service?.uuid?.toString(), + isConnected = gatt != null, + isNotifyActive = isNotifyActive, + isBound = if (dialect == Dialect.ALPICOOL) bindAcknowledged else null, + confirmedWrites = confirmedWrites, + lastWriteError = lastWriteError, + gattSummary = gattSummary, + sentFrames = sentFrameCount, + receivedBytes = receivedByteCount, + lastSendAt = lastSendAt, + lastResponseHex = lastResponse?.toHexText(), + lastCommandHex = lastCommand?.toHexText(), + lastCommandAt = lastCommandAt, + fridgePayloadHex = alpicoolState.lastPayload + .takeIf { it.isNotEmpty() }?.toHexText(), + ) + ) + } + + // MARK: - GATT-Rückmeldungen + // + // Android ruft diese auf einem eigenen Thread auf. Alles wird deshalb auf + // unseren Handler geschoben, bevor es Zustand anfasst. + + private val callback = object : BluetoothGattCallback() { + + override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) { + handler.post { + if (newState == BluetoothProfile.STATE_CONNECTED) { + gatt = g + // Eine grössere MTU aushandeln; für die Kühlbox gilt + // trotzdem ihre eigene Grenze von 20 Byte. + g.requestMtu(247) + } else { + onStateChange(DeviceLinkState.Searching) + handleDisconnect() + } + } + } + + override fun onMtuChanged(g: BluetoothGatt, mtu: Int, status: Int) { + handler.post { + negotiatedWriteLimit = (mtu - 3).coerceAtLeast(20) + g.discoverServices() + } + } + + override fun onServicesDiscovered(g: BluetoothGatt, status: Int) { + handler.post { + if (status != BluetoothGatt.GATT_SUCCESS) { + onStateChange(DeviceLinkState.Failed("Dienste nicht lesbar")) + return@post + } + buildEndpoints(g) + } + } + + override fun onDescriptorWrite( + g: BluetoothGatt, + descriptor: BluetoothGattDescriptor, + status: Int, + ) { + handler.post { + if (descriptor.characteristic.uuid != currentEndpoint?.notify?.uuid) return@post + isNotifyActive = status == BluetoothGatt.GATT_SUCCESS + publishDiagnostics() + if (isNotifyActive) unlockThenPoll() else advanceEndpoint() + } + } + + @Suppress("DEPRECATION") + override fun onCharacteristicChanged( + g: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + ) { + val value = characteristic.value ?: return + if (value.isEmpty()) return + handler.post { consume(value) } + } + + override fun onCharacteristicChanged( + g: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + value: ByteArray, + ) { + if (value.isEmpty()) return + handler.post { consume(value) } + } + + /** + * Nur bei Schreibvorgängen mit Bestätigung. Ohne Bestätigung meldet + * Android nichts zurück – auch keinen Fehler. + */ + override fun onCharacteristicWrite( + g: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + status: Int, + ) { + handler.post { + if (status == BluetoothGatt.GATT_SUCCESS) { + lastWriteError = null + confirmedWrites += 1 + } else { + lastWriteError = "GATT-Fehler $status" + } + publishDiagnostics() + } + } + } +} + +internal fun ByteArray.toHexText(): String = joinToString(" ") { "%02X".format(it) } diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/bluetooth/LevelSession.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/bluetooth/LevelSession.kt new file mode 100644 index 0000000..4607687 --- /dev/null +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/bluetooth/LevelSession.kt @@ -0,0 +1,320 @@ +package de.fritob.campermonitor.bluetooth + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCallback +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.BluetoothProfile +import android.os.Handler +import de.fritob.campermonitor.protocol.DeviceLinkState +import de.fritob.campermonitor.protocol.DeviceSnapshot +import de.fritob.campermonitor.protocol.LevelState +import de.fritob.campermonitor.protocol.SensorOrientation +import de.fritob.campermonitor.protocol.VanAlignProtocol +import java.util.UUID + +/** + * Verbindung zum Neigungsmesser „VanAlign Pro". + * + * Anders als beim BMS gibt es hier kein Rahmenprotokoll: Längs- und + * Querneigung liegen in je einer eigenen Charakteristik als 32-Bit-Float. + * Meldet die Firmware sie per Notify, wird abonniert; sonst wird in Abständen + * gelesen. Ältere Stände können beides nicht gleichzeitig. + */ +@SuppressLint("MissingPermission") +class LevelSession( + private val deviceID: UUID, + private val gattProvider: (BluetoothGattCallback) -> BluetoothGatt?, + private val handler: Handler, + private val onUpdate: (DeviceSnapshot) -> Unit, + private val onStateChange: (DeviceLinkState) -> Unit, + private val onLevelState: (LevelState) -> Unit, + /** + * Meldet die Einbaulage, die im Gerät steht. Sie gilt vor der örtlich + * gespeicherten: dort steht, wie der Sensor eingebaut ist, und das ist für + * alle Clients dasselbe. + */ + private val onDeviceOrientation: (SensorOrientation) -> Unit = {}, +) { + private companion object { + val SERVICE: UUID = UUID.fromString(VanAlignProtocol.SERVICE_UUID) + val PITCH: UUID = UUID.fromString(VanAlignProtocol.PITCH_UUID) + val ROLL: UUID = UUID.fromString(VanAlignProtocol.ROLL_UUID) + val OFFSETS: UUID = UUID.fromString(VanAlignProtocol.OFFSETS_UUID) + val CALIBRATE: UUID = UUID.fromString(VanAlignProtocol.CALIBRATE_UUID) + val ORIENTATION: UUID = UUID.fromString(VanAlignProtocol.ORIENTATION_UUID) + val CLIENT_CONFIG: UUID = UUID.fromString("00002902-0000-1000-8000-00805F9B34FB") + const val POLL_INTERVAL_MS = 1_000L + } + + /** Die Einbaulage des Sensors; wird auf jede Messung angewandt. */ + var orientation: SensorOrientation = SensorOrientation.IDENTITY + set(value) { + field = value + applyOrientation() + } + + private var gatt: BluetoothGatt? = null + private var pitchCharacteristic: BluetoothGattCharacteristic? = null + private var rollCharacteristic: BluetoothGattCharacteristic? = null + private var offsetsCharacteristic: BluetoothGattCharacteristic? = null + private var calibrateCharacteristic: BluetoothGattCharacteristic? = null + private var orientationCharacteristic: BluetoothGattCharacteristic? = null + + /** Ob die Firmware die Werte von sich aus meldet. */ + private var needsPolling = true + private var pollRunnable: Runnable? = null + + private var state = LevelState() + + fun start() { + onStateChange(DeviceLinkState.Connecting) + gatt = gattProvider(callback) + } + + fun stop() { + cancelPoll() + gatt?.close() + gatt = null + clearCharacteristics() + } + + fun handleDisconnect() { + cancelPoll() + gatt?.close() + gatt = null + clearCharacteristics() + } + + private fun clearCharacteristics() { + pitchCharacteristic = null + rollCharacteristic = null + offsetsCharacteristic = null + calibrateCharacteristic = null + orientationCharacteristic = null + } + + /** Setzt die aktuelle Lage als neue Null. */ + fun calibrate() { + write(VanAlignProtocol.calibrateCommand) + } + + /** Verwirft die Kalibrierung. */ + fun resetCalibration() { + write(VanAlignProtocol.resetCommand) + } + + @Suppress("DEPRECATION") + private fun write(data: ByteArray) { + val characteristic = calibrateCharacteristic ?: return + val g = gatt ?: return + characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT + characteristic.value = data + g.writeCharacteristic(characteristic) + // Die Offsets ändern sich dadurch – kurz darauf neu lesen. + handler.postDelayed({ readOffsets() }, 400) + } + + /** + * Schreibt die Einbaulage ins Gerät, damit alle Clients dieselbe sehen. + * + * Ältere Firmware hat die Charakteristik nicht; dann bleibt es bei der + * örtlich gespeicherten Fassung, und es passiert schlicht nichts. + */ + fun storeOrientation(value: SensorOrientation) { + val characteristic = orientationCharacteristic ?: return + val g = gatt ?: return + characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT + characteristic.value = VanAlignProtocol.encoded(value) + g.writeCharacteristic(characteristic) + // Zurücklesen, damit gilt, was wirklich im Gerät steht. + handler.postDelayed({ gatt?.readCharacteristic(characteristic) }, 300) + } + + private fun readOffsets() { + val characteristic = offsetsCharacteristic ?: return + gatt?.readCharacteristic(characteristic) + } + + private fun cancelPoll() { + pollRunnable?.let { handler.removeCallbacks(it) } + pollRunnable = null + } + + private fun startPollingIfNeeded() { + if (!needsPolling || pollRunnable != null) return + val runnable = object : Runnable { + override fun run() { + // Android verträgt nur eine ausstehende GATT-Anfrage; deshalb + // erst die eine lesen, die andere folgt in der Rückmeldung. + pitchCharacteristic?.let { gatt?.readCharacteristic(it) } + handler.postDelayed(this, POLL_INTERVAL_MS) + } + } + pollRunnable = runnable + handler.post(runnable) + } + + private fun applyOrientation() { + val (pitch, roll) = orientation.apply(state.rawPitch, state.rawRoll) + state = state.copy(pitch = pitch, roll = roll) + } + + private fun publish() { + onLevelState(state) + if (state.hasReading) { + onStateChange(DeviceLinkState.Live) + onUpdate(state.snapshot(deviceID, null)) + } + } + + private val callback = object : BluetoothGattCallback() { + + override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) { + handler.post { + if (newState == BluetoothProfile.STATE_CONNECTED) { + gatt = g + g.discoverServices() + } else { + onStateChange(DeviceLinkState.Searching) + handleDisconnect() + } + } + } + + override fun onServicesDiscovered(g: BluetoothGatt, status: Int) { + handler.post { + val service = g.getService(SERVICE) + if (service == null) { + onStateChange(DeviceLinkState.Failed("Kein VanAlign-Dienst gefunden")) + return@post + } + pitchCharacteristic = service.getCharacteristic(PITCH) + rollCharacteristic = service.getCharacteristic(ROLL) + offsetsCharacteristic = service.getCharacteristic(OFFSETS) + calibrateCharacteristic = service.getCharacteristic(CALIBRATE) + orientationCharacteristic = service.getCharacteristic(ORIENTATION) + + val canNotify = pitchCharacteristic?.let { + it.properties and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0 + } ?: false + + if (canNotify) { + needsPolling = false + subscribe(g, pitchCharacteristic) + } else { + // Ältere Firmware meldet nichts von sich aus. + needsPolling = true + readOffsets() + } + } + } + + private fun subscribe(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic?) { + val c = characteristic ?: return + g.setCharacteristicNotification(c, true) + c.getDescriptor(CLIENT_CONFIG)?.let { + it.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + g.writeDescriptor(it) + } + } + + override fun onDescriptorWrite( + g: BluetoothGatt, + descriptor: BluetoothGattDescriptor, + status: Int, + ) { + handler.post { + when (descriptor.characteristic.uuid) { + PITCH -> subscribe(g, rollCharacteristic) + ROLL -> readOffsets() + } + } + } + + @Suppress("DEPRECATION") + override fun onCharacteristicRead( + g: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + status: Int, + ) { + val value = characteristic.value ?: return + handler.post { handleValue(characteristic.uuid, value, wasRead = true) } + } + + override fun onCharacteristicRead( + g: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + value: ByteArray, + status: Int, + ) { + handler.post { handleValue(characteristic.uuid, value, wasRead = true) } + } + + @Suppress("DEPRECATION") + override fun onCharacteristicChanged( + g: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + ) { + val value = characteristic.value ?: return + handler.post { handleValue(characteristic.uuid, value, wasRead = false) } + } + + override fun onCharacteristicChanged( + g: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + value: ByteArray, + ) { + handler.post { handleValue(characteristic.uuid, value, wasRead = false) } + } + } + + private fun handleValue(uuid: UUID, value: ByteArray, wasRead: Boolean) { + when (uuid) { + PITCH -> { + state = state.copy(rawPitch = VanAlignProtocol.angle(value)) + applyOrientation() + publish() + // Beim Abfragen folgt jetzt die zweite Achse. + if (wasRead && needsPolling) { + rollCharacteristic?.let { gatt?.readCharacteristic(it) } + } + } + + ROLL -> { + state = state.copy(rawRoll = VanAlignProtocol.angle(value)) + applyOrientation() + publish() + } + + OFFSETS -> { + VanAlignProtocol.offsets(value)?.let { (pitch, roll) -> + state = state.copy(pitchOffset = pitch, rollOffset = roll) + } + publish() + // Android lässt nur eine Abfrage gleichzeitig zu, deshalb der + // Reihe nach: erst die Offsets, dann die Einbaulage, dann der + // Takt. Fehlt die Charakteristik – ältere Firmware –, geht es + // sofort weiter. + val characteristic = orientationCharacteristic + if (characteristic != null) { + gatt?.readCharacteristic(characteristic) + } else { + startPollingIfNeeded() + } + } + + ORIENTATION -> { + // Was im Gerät steht, gilt. Steht dort nichts (Version 0), + // bleibt es bei der örtlichen Fassung, und der Aufrufer + // schreibt sie hinauf. + VanAlignProtocol.orientation(value)?.let { stored -> + if (stored != orientation) orientation = stored + onDeviceOrientation(stored) + } + startPollingIfNeeded() + } + } + } +} diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/store/DemoData.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/store/DemoData.kt new file mode 100644 index 0000000..9ca18a5 --- /dev/null +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/store/DemoData.kt @@ -0,0 +1,130 @@ +package de.fritob.campermonitor.store + +import de.fritob.campermonitor.bluetooth.BluetoothManager +import de.fritob.campermonitor.protocol.AlpicoolProtocol +import de.fritob.campermonitor.protocol.AlpicoolState +import de.fritob.campermonitor.protocol.ConfiguredDevice +import de.fritob.campermonitor.protocol.DeviceLinkState +import de.fritob.campermonitor.protocol.DeviceRole +import de.fritob.campermonitor.protocol.DeviceSnapshot +import de.fritob.campermonitor.protocol.LevelState +import de.fritob.campermonitor.protocol.Metric +import de.fritob.campermonitor.protocol.Profile +import java.util.UUID +import kotlin.math.sin + +/** + * Füllt die App mit erfundenen Messwerten, damit sich die Ansichten ohne + * Fahrzeug und ohne Bluetooth prüfen lassen. + * + * Nur in Debug-Bauten und nur, wenn beim Start das Extra `demo` gesetzt ist: + * ``` + * adb shell am start -n de.fritob.campermonitor/.MainActivity --ez demo true + * ``` + * Im normalen Betrieb wird hiervon nichts ausgeführt. + */ +object DemoData { + + private val profileID = Profile.DEFAULT_ID + private val secondProfileID = UUID.fromString("00000000-0000-0000-0000-0000000000c2") + + private fun id(suffix: String) = UUID.fromString("00000000-0000-0000-0000-0000000000$suffix") + + val profiles = listOf( + Profile(profileID, "Kastenwagen", "box_truck", trackWidth = 1.85, wheelbase = 3.50), + Profile(secondProfileID, "Wohnwagen", "car_side"), + ) + + val devices = listOf( + ConfiguredDevice(id("50"), "Solar Dach", DeviceRole.SOLAR_CHARGER, profileID, "00:00:00:00:00:50"), + ConfiguredDevice(id("b0"), "Ladebooster", DeviceRole.CHARGE_BOOSTER, profileID, "00:00:00:00:00:b0"), + ConfiguredDevice(id("c0"), "Bulltron 200 Ah", DeviceRole.BMS, profileID, "00:00:00:00:00:c0"), + ConfiguredDevice(id("d0"), "Kühlbox", DeviceRole.FRIDGE, profileID, "00:00:00:00:00:d0"), + ConfiguredDevice(id("e0"), "Nivellierung", DeviceRole.LEVELING, profileID, "00:00:00:00:00:e0"), + ) + + fun install(store: DeviceStore, bluetooth: BluetoothManager) { + store.loadDemo(profiles, devices) + + val now = System.currentTimeMillis() + fun snapshot(deviceID: UUID, state: String?, vararg metrics: Metric) = + DeviceSnapshot(deviceID, now, metrics.toList(), state = state) + + bluetooth.installDemo( + snapshots = listOf( + snapshot( + id("50"), "Konstantspannung (Absorption)", + Metric("pv_power", "PV-Leistung", 284.0, "W", 0, isPrimary = true), + Metric("battery_power", "Ladeleistung", 262.0, "W", 0), + Metric("battery_voltage", "Batteriespannung", 14.12, "V", 2), + Metric("battery_current", "Ladestrom", 18.6, "A", 1), + Metric("yield_today", "Ertrag heute", 1.84, "kWh", 2), + ), + snapshot( + id("b0"), "Aus", + Metric("output_voltage", "Ausgang (Aufbaubatterie)", 14.09, "V", 2, isPrimary = true), + Metric("input_voltage", "Eingang (Starterbatterie)", 12.42, "V", 2), + ).copy(offReasons = listOf("Keine Eingangsspannung")), + snapshot( + id("c0"), "Lädt", + Metric("soc", "Ladezustand", 78.4, "%", 1, isPrimary = true), + Metric("voltage", "Spannung", 13.66, "V", 2), + Metric("current", "Strom", 18.6, "A", 1), + Metric("power", "Leistung", 254.0, "W", 0), + Metric("cell_delta", "Zell-Differenz", 18.0, "mV", 0), + ).copy(cellVoltages = listOf(3.412, 3.418, 3.409, 3.427)), + snapshot( + id("d0"), "Eco", + Metric("temp_left", "Temperatur", 6.0, "°C", 0, isPrimary = true), + Metric("target_left", "Solltemperatur", 4.0, "°C", 0), + Metric("supply_voltage", "Bordspannung", 12.7, "V", 1), + Metric("battery_percent", "Batterieanzeige", 87.0, "%", 0), + ), + snapshot( + id("e0"), "Heck steht höher, links steht höher", + Metric("pitch", "Längsneigung", 1.8, "°", 1, isPrimary = true), + Metric("roll", "Querneigung", -0.9, "°", 1), + ), + ), + fridge = id("d0") to demoFridge(), + level = id("e0") to LevelState( + pitch = 1.8, roll = -0.9, + rawPitch = 1.8, rawRoll = -0.9, + pitchOffset = 0.4, rollOffset = -0.2, + ), + history = devices.associate { device -> + device.id to List(48) { step -> + val base = when (device.role) { + DeviceRole.SOLAR_CHARGER -> 250.0 + DeviceRole.LEVELING -> 1.8 + DeviceRole.BMS -> 78.0 + DeviceRole.FRIDGE -> 6.0 + else -> 14.0 + } + base + sin(step / 6.0) * base * 0.12 + } + }, + ) + } + + private fun demoFridge(): AlpicoolState { + // Ein echter Einzonen-Datensatz, wie ihn die Box im Fahrzeug schickt. + val payload = byteArrayOf( + 0x00, 0x01, 0x01, 0x02, 0x04, 0x14, 0xEC.toByte(), 0x02, 0x00, 0x00, + 0x00, 0x00, 0xFD.toByte(), 0x00, + 0x06, 0x57, 0x0C, 0x07, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x80.toByte(), 0x00, 0x01, 0x00, + ) + val state = AlpicoolState() + val (frames, _) = AlpicoolProtocol.extractFrames(demoResponse(payload)) + frames.forEach { state.apply(it) } + return state + } + + private fun demoResponse(payload: ByteArray): ByteArray { + val head = byteArrayOf(0xFE.toByte(), 0xFE.toByte(), (payload.size + 3).toByte(), 0x01) + payload + val sum = AlpicoolProtocol.checksum(head) + return head + byteArrayOf((sum shr 8).toByte(), (sum and 0xFF).toByte()) + } +} diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/store/DeviceStore.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/store/DeviceStore.kt new file mode 100644 index 0000000..9ef2467 --- /dev/null +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/store/DeviceStore.kt @@ -0,0 +1,247 @@ +package de.fritob.campermonitor.store + +import android.content.Context +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import de.fritob.campermonitor.protocol.ConfiguredDevice +import de.fritob.campermonitor.protocol.DeviceRole +import de.fritob.campermonitor.protocol.FridgeZoneMode +import de.fritob.campermonitor.protocol.Profile +import de.fritob.campermonitor.protocol.SensorOrientation +import org.json.JSONArray +import org.json.JSONObject +import java.util.UUID + +/** + * Hält Fahrzeuge und Geräte und legt sie ab. + * + * **Unterschied zur iOS-Fassung:** Dort liegen die Victron-Schlüssel im + * Schlüsselbund. Android hat kein direktes Gegenstück, das ohne zusätzliche + * Bibliothek auskommt; sie liegen deshalb in einer eigenen, app-privaten + * Ablage. Das schützt gegen andere Apps, aber anders als der Schlüsselbund + * nicht hardwaregestützt. + */ +class DeviceStore(context: Context) { + + private val prefs = context.getSharedPreferences("camper", Context.MODE_PRIVATE) + private val keyPrefs = context.getSharedPreferences("camper_keys", Context.MODE_PRIVATE) + + val profiles = mutableStateListOf() + val devices = mutableStateListOf() + + var activeProfileID: UUID by mutableStateOf(Profile.DEFAULT_ID) + private set + + private var diagnosticsShown by mutableStateOf(prefs.getBoolean("showDiagnostics", false)) + + /** + * Ob die technischen Angaben in der Oberfläche eingeblendet werden. + * Zuweisen genügt – der Wert wird dabei abgelegt. + */ + var showDiagnostics: Boolean + get() = diagnosticsShown + set(value) { + diagnosticsShown = value + prefs.edit().putBoolean("showDiagnostics", value).apply() + } + + /** Im Demo-Modus wird nichts abgelegt – die echten Daten bleiben unberührt. */ + private var isDemo = false + + init { + load() + } + + /** Nur für den Demo-Modus: Fahrzeuge und Geräte setzen, ohne zu speichern. */ + fun loadDemo(demoProfiles: List, demoDevices: List) { + isDemo = true + profiles.clear(); profiles.addAll(demoProfiles) + devices.clear(); devices.addAll(demoDevices) + activeProfileID = demoProfiles.first().id + } + + val activeProfile: Profile? + get() = profiles.firstOrNull { it.id == activeProfileID } ?: profiles.firstOrNull() + + fun activeDevices(): List { + val profile = activeProfile?.id ?: return emptyList() + return devices.filter { it.profileID == profile } + } + + // MARK: - Ändern + + fun update(device: ConfiguredDevice) { + val index = devices.indexOfFirst { it.id == device.id } + if (index >= 0) devices[index] = device else devices.add(device) + save() + } + + fun remove(device: ConfiguredDevice) { + devices.removeAll { it.id == device.id } + keyPrefs.edit().remove(device.id.toString()).apply() + save() + } + + fun selectProfile(id: UUID) { + activeProfileID = id + save() + } + + fun addProfile(name: String): Profile { + val profile = Profile(name = name) + profiles.add(profile) + save() + return profile + } + + fun updateProfile(profile: Profile) { + val index = profiles.indexOfFirst { it.id == profile.id } + if (index >= 0) profiles[index] = profile else profiles.add(profile) + save() + } + + fun removeProfile(profile: Profile) { + // Das letzte Fahrzeug bleibt stehen – ohne eines hätte die App keinen + // Ort für Geräte. + if (profiles.size <= 1) return + profiles.removeAll { it.id == profile.id } + devices.filter { it.profileID == profile.id }.forEach { remove(it) } + if (activeProfileID == profile.id) { + profiles.firstOrNull()?.let { activeProfileID = it.id } + } + save() + } + + // MARK: - Victron-Schlüssel + + fun victronKeyText(deviceID: UUID): String? = keyPrefs.getString(deviceID.toString(), null) + + fun victronKey(deviceID: UUID): ByteArray? = + victronKeyText(deviceID)?.let { hexToBytes(it) }?.takeIf { it.size == 16 } + + fun setVictronKey(text: String, deviceID: UUID) { + val cleaned = text.filter { !it.isWhitespace() } + if (hexToBytes(cleaned)?.size == 16) { + keyPrefs.edit().putString(deviceID.toString(), cleaned).apply() + } else { + keyPrefs.edit().remove(deviceID.toString()).apply() + } + } + + // MARK: - Ablage + + private fun load() { + val json = prefs.getString("state", null) + if (json == null) { + profiles.add(Profile.initial()) + activeProfileID = Profile.DEFAULT_ID + return + } + try { + val root = JSONObject(json) + profiles.clear() + val profileArray = root.optJSONArray("profiles") ?: JSONArray() + for (i in 0 until profileArray.length()) { + profiles.add(profileFrom(profileArray.getJSONObject(i))) + } + devices.clear() + val deviceArray = root.optJSONArray("devices") ?: JSONArray() + for (i in 0 until deviceArray.length()) { + deviceFrom(deviceArray.getJSONObject(i))?.let { devices.add(it) } + } + activeProfileID = root.optString("activeProfile") + .takeIf { it.isNotEmpty() } + ?.let { runCatching { UUID.fromString(it) }.getOrNull() } + ?: Profile.DEFAULT_ID + } catch (_: Exception) { + // Eine unlesbare Ablage darf die App nicht am Start hindern. + profiles.clear() + devices.clear() + profiles.add(Profile.initial()) + activeProfileID = Profile.DEFAULT_ID + } + if (profiles.isEmpty()) profiles.add(Profile.initial()) + } + + private fun save() { + if (isDemo) return + val root = JSONObject() + root.put("profiles", JSONArray().also { array -> + profiles.forEach { array.put(profileTo(it)) } + }) + root.put("devices", JSONArray().also { array -> + devices.forEach { array.put(deviceTo(it)) } + }) + root.put("activeProfile", activeProfileID.toString()) + prefs.edit().putString("state", root.toString()).apply() + } + + private fun profileTo(profile: Profile) = JSONObject().apply { + put("id", profile.id.toString()) + put("name", profile.name) + put("symbol", profile.symbol) + profile.trackWidth?.let { put("trackWidth", it) } + profile.wheelbase?.let { put("wheelbase", it) } + } + + private fun profileFrom(json: JSONObject) = Profile( + id = UUID.fromString(json.getString("id")), + name = json.getString("name"), + symbol = json.optString("symbol", "box_truck"), + // Fahrzeuge aus der Zeit vor den Massen haben noch keine. + trackWidth = if (json.has("trackWidth")) json.getDouble("trackWidth") else null, + wheelbase = if (json.has("wheelbase")) json.getDouble("wheelbase") else null, + ) + + private fun deviceTo(device: ConfiguredDevice) = JSONObject().apply { + put("id", device.id.toString()) + put("name", device.name) + put("role", device.role.name) + put("profileID", device.profileID.toString()) + put("address", device.address) + device.advertisedName?.let { put("advertisedName", it) } + put("fridgeZoneMode", device.fridgeZoneMode.name) + put("orientationSource", device.sensorOrientation.longitudinalSource.name) + put("invertLongitudinal", device.sensorOrientation.invertLongitudinal) + put("invertLateral", device.sensorOrientation.invertLateral) + put("twist", device.sensorOrientation.twist) + } + + private fun deviceFrom(json: JSONObject): ConfiguredDevice? { + val role = runCatching { DeviceRole.valueOf(json.getString("role")) }.getOrNull() + ?: return null + return ConfiguredDevice( + id = UUID.fromString(json.getString("id")), + name = json.getString("name"), + role = role, + profileID = UUID.fromString(json.getString("profileID")), + address = json.getString("address"), + advertisedName = json.optString("advertisedName").takeIf { it.isNotEmpty() }, + fridgeZoneMode = runCatching { + FridgeZoneMode.valueOf(json.optString("fridgeZoneMode")) + }.getOrDefault(FridgeZoneMode.AUTOMATIC), + sensorOrientation = SensorOrientation( + longitudinalSource = runCatching { + SensorOrientation.Source.valueOf(json.optString("orientationSource")) + }.getOrDefault(SensorOrientation.Source.PITCH), + invertLongitudinal = json.optBoolean("invertLongitudinal", false), + invertLateral = json.optBoolean("invertLateral", false), + // Fehlt der Wert, ist er null – so überleben Einrichtungen aus + // der Zeit vor der Verdrehung. + twist = json.optDouble("twist", 0.0), + ), + ) + } + + private fun hexToBytes(text: String): ByteArray? { + val cleaned = text.filter { !it.isWhitespace() } + if (cleaned.length % 2 != 0) return null + return runCatching { + ByteArray(cleaned.length / 2) { + cleaned.substring(it * 2, it * 2 + 2).toInt(16).toByte() + } + }.getOrNull() + } +} diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/AddDeviceScreen.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/AddDeviceScreen.kt new file mode 100644 index 0000000..f340351 --- /dev/null +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/AddDeviceScreen.kt @@ -0,0 +1,255 @@ +package de.fritob.campermonitor.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import de.fritob.campermonitor.bluetooth.BluetoothManager +import de.fritob.campermonitor.bluetooth.Discovery +import de.fritob.campermonitor.protocol.ConfiguredDevice +import de.fritob.campermonitor.protocol.DeviceRole +import de.fritob.campermonitor.store.DeviceStore + +/** + * Die Liste der gefundenen Geräte. + * + * Sortiert wird **nicht** nach Signalstärke, sondern nach der Reihenfolge des + * Auftauchens. Nach dem Pegel zu sortieren macht die Liste unbenutzbar: sie + * springt dann sekündlich, und man trifft das gesuchte Gerät nicht. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddDeviceScreen(store: DeviceStore, bluetooth: BluetoothManager, onBack: () -> Unit) { + var showAll by remember { mutableStateOf(false) } + var selected by remember { mutableStateOf(null) } + + DisposableEffect(Unit) { + bluetooth.setDiscovering(true) + onDispose { bluetooth.setDiscovering(false) } + } + + val alreadyAdded = store.activeDevices().map { it.address }.toSet() + val found = bluetooth.discoveries.values + .filter { showAll || it.looksLikeSupported || it.name != null } + .sortedWith( + compareByDescending { it.isVictron } + .thenByDescending { it.looksLikeSupported } + .thenBy { it.firstSeen } + ) + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Gerät für ${store.activeProfile?.name ?: "Camper"}") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") + } + }, + ) + }, + ) { padding -> + LazyColumn(modifier = Modifier.fillMaxSize().padding(padding)) { + item { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = !showAll, + onClick = { showAll = false }, + label = { Text("Passende") }, + ) + FilterChip( + selected = showAll, + onClick = { showAll = true }, + label = { Text("Alle") }, + ) + } + } + + items(found, key = { it.address }) { discovery -> + val added = discovery.address in alreadyAdded + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = !added) { selected = discovery } + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + discovery.name ?: "Ohne Namen", + style = MaterialTheme.typography.bodyLarge, + color = if (added) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + Text( + buildString { + append(discovery.address) + discovery.victronRecord?.let { append(" · Victron $it") } + append(" · ${discovery.rssi} dBm") + }, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (added) { + Text("schon dabei", style = MaterialTheme.typography.labelMedium) + } + } + } + + item { + Text( + "Victron-Geräte werden automatisch erkannt. Damit sie hier " + + "auftauchen, muss „Instant Readout“ in VictronConnect aktiv " + + "sein. Das BMS meldet sich meist als „DL-…“. Kühlboxen tragen " + + "oft einen kryptischen Namen – findest du dein Gerät nicht, auf " + + "„Alle“ umschalten.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(16.dp), + ) + } + } + } + + selected?.let { discovery -> + ConfigureDeviceDialog( + discovery = discovery, + onDismiss = { selected = null }, + onAdd = { name, role -> + store.update( + ConfiguredDevice( + name = name, + role = role, + profileID = store.activeProfile?.id ?: return@ConfigureDeviceDialog, + address = discovery.address, + advertisedName = discovery.name, + ) + ) + bluetooth.refreshConfiguration() + selected = null + onBack() + }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ConfigureDeviceDialog( + discovery: Discovery, + onDismiss: () -> Unit, + onAdd: (String, DeviceRole) -> Unit, +) { + // Ein Vorschlag, der meistens passt – geraten wird nur, was sich aus dem + // Advertisement ablesen lässt. + val suggestedRole = remember(discovery) { guessRole(discovery) } + var role by remember { mutableStateOf(suggestedRole) } + var name by remember { mutableStateOf(discovery.name ?: suggestedRole.title) } + var expanded by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Gerät einrichten") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Name") }, + singleLine = true, + ) + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + ) { + OutlinedTextField( + value = role.title, + onValueChange = {}, + readOnly = true, + label = { Text("Art") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, + modifier = Modifier.menuAnchor( + androidx.compose.material3.MenuAnchorType.PrimaryNotEditable, true + ), + ) + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DeviceRole.entries.forEach { candidate -> + DropdownMenuItem( + text = { Text(candidate.title) }, + onClick = { role = candidate; expanded = false }, + ) + } + } + } + Text( + discovery.address, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + confirmButton = { + Button(onClick = { onAdd(name.trim().ifEmpty { role.title }, role) }) { + Text("Hinzufügen") + } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Abbrechen") } }, + ) +} + +/** Was sich aus Namen und Advertisement ableiten lässt. */ +private fun guessRole(discovery: Discovery): DeviceRole { + val name = discovery.name?.lowercase() ?: "" + return when { + discovery.victronRecord == "SOLAR_CHARGER" -> DeviceRole.SOLAR_CHARGER + discovery.victronRecord == "BATTERY_MONITOR" -> DeviceRole.BATTERY_MONITOR + discovery.victronRecord == "DCDC_CONVERTER" -> DeviceRole.CHARGE_BOOSTER + discovery.victronRecord == "ORION_XS" -> DeviceRole.CHARGE_BOOSTER + name.contains("vanalign") -> DeviceRole.LEVELING + name.contains("alpicool") || name.contains("icecube") -> DeviceRole.FRIDGE + name.startsWith("dl-") || name.contains("daly") || name.contains("wattcycle") -> + DeviceRole.BMS + else -> DeviceRole.BMS + } +} diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/AlignmentAssistantScreen.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/AlignmentAssistantScreen.kt new file mode 100644 index 0000000..c2c2024 --- /dev/null +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/AlignmentAssistantScreen.kt @@ -0,0 +1,339 @@ +package de.fritob.campermonitor.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Flag +import androidx.compose.material.icons.filled.HourglassEmpty +import androidx.compose.material.icons.filled.PortableWifiOff +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.TrendingDown +import androidx.compose.material.icons.filled.TrendingFlat +import androidx.compose.material.icons.filled.TrendingUp +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.dp +import de.fritob.campermonitor.bluetooth.BluetoothManager +import de.fritob.campermonitor.protocol.AlignmentAssistant +import de.fritob.campermonitor.protocol.ConfiguredDevice +import de.fritob.campermonitor.protocol.DeviceLinkState +import de.fritob.campermonitor.protocol.LevelState +import de.fritob.campermonitor.protocol.LevelingWedge +import de.fritob.campermonitor.store.DeviceStore + +/** + * Der Ausrichtungs-Assistent: begleitet das Rangieren und sagt, ob es besser + * oder schlechter wird und wo es am besten stand. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AlignmentAssistantScreen( + device: ConfiguredDevice, + store: DeviceStore, + bluetooth: BluetoothManager, + onBack: () -> Unit, +) { + val state = bluetooth.levelStates[device.id] ?: LevelState() + val linkState = bluetooth.linkStates[device.id] + val profile = store.activeProfile + + val assistant = remember { AlignmentAssistant() } + // Zwingt die Ansicht zum Neuzeichnen, wenn sich der Verlauf ändert – der + // Assistent selbst ist bewusst kein Compose-Zustand, damit die Protokoll- + // schicht nichts von Compose wissen muss. + var revision by remember { mutableIntStateOf(0) } + var displayStyle by remember { mutableStateOf(0) } + var announced by remember { mutableStateOf(false) } + + val view = LocalView.current + + LaunchedEffect(state) { + val pitch = state.pitch + val roll = state.roll + if (pitch != null && roll != null) { + assistant.add(pitch, roll) + revision += 1 + // Einmal spürbar melden, wenn die Waage erreicht ist – man schaut + // beim Rangieren nicht dauernd aufs Display. + if (assistant.hasReachedTarget) { + if (!announced) { + announced = true + @Suppress("DEPRECATION") + view.performHapticFeedback(android.view.HapticFeedbackConstants.CONFIRM) + } + } else { + announced = false + } + } + } + + // Beim Rangieren schaut man immer wieder aufs Display; es darf dabei nicht + // dunkel werden. + DisposableEffect(Unit) { + view.keepScreenOn = true + onDispose { view.keepScreenOn = false } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Ausrichtungs-Assistent") }, + actions = { TextButton(onClick = onBack) { Text("Fertig") } }, + ) + }, + ) { padding -> + // Der Assistent ist bewusst kein Compose-Zustand - die Protokollschicht + // soll nichts von Compose wissen. Sein Stand wird deshalb bei jeder + // neuen Messung in ein eigenes Wertobjekt gezogen. Ihn direkt + // weiterzureichen genügt nicht: Compose überspringt einen Aufruf, dessen + // Argument dieselbe Instanz ist, und der Ratschlag blieb dann stehen. + val view = remember(revision) { + AssistantView( + advice = assistant.advice, + hasReachedTarget = assistant.hasReachedTarget, + trend = assistant.trend, + deviation = assistant.current?.deviation, + best = assistant.best, + improvementAtBest = assistant.improvementAtBest, + secondsSinceBest = assistant.secondsSinceBest, + ) + } + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + listOf("Libelle", "Fahrzeug").forEachIndexed { index, label -> + SegmentedButton( + selected = displayStyle == index, + onClick = { displayStyle = index }, + shape = SegmentedButtonDefaults.itemShape(index, 2), + ) { Text(label) } + } + } + + if (linkState != DeviceLinkState.Live) DisconnectedBanner() + + if (displayStyle == 0) { + LevelBubble(state.pitch, state.roll) + } else { + VehicleTiltView(state.pitch, state.roll) + } + + AdviceBanner(view) + + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { + Reading("Längs", state.pitch, Modifier.weight(1f)) + Reading("Quer", state.roll, Modifier.weight(1f)) + Reading("Gesamt", view.deviation, Modifier.weight(1f)) + } + + val gain = view.improvementAtBest + val seconds = view.secondsSinceBest + val best = view.best + if (gain != null && seconds != null && best != null) { + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.Flag, contentDescription = null) + Text( + "Bester Punkt", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(start = 8.dp), + ) + } + Text( + de("Vor %.0f Sekunden stand das Fahrzeug %.1f° flacher (%.1f° statt %.1f°).", + seconds, gain, best.deviation, + view.deviation ?: 0.0), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + WedgeCard(state, profile?.trackWidth, profile?.wheelbase) + + OutlinedButton( + onClick = { assistant.reset(); revision += 1; announced = false }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Filled.Refresh, contentDescription = null) + Text("Neu beginnen", modifier = Modifier.padding(start = 8.dp)) + } + } + } +} + +/** + * Reisst die Verbindung beim Rangieren ab, stehen die Zahlen still. Ohne + * Hinweis sähe das aus, als hinge die App – man rangiert dann nach einem Wert, + * der längst nicht mehr gilt. + */ +@Composable +private fun DisconnectedBanner() { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = Color(0x33E08600)), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Filled.PortableWifiOff, contentDescription = null, + tint = Color(0xFFE08600), modifier = Modifier.size(32.dp), + ) + Column(modifier = Modifier.padding(start = 12.dp)) { + Text("Nicht verbunden", style = MaterialTheme.typography.titleSmall) + Text( + "Die Anzeige steht still, bis der Neigungsmesser wieder da ist.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +/** Der Stand des Assistenten als Wert – siehe die Begründung oben. */ +private data class AssistantView( + val advice: String, + val hasReachedTarget: Boolean, + val trend: AlignmentAssistant.Trend, + val deviation: Double?, + val best: AlignmentAssistant.Sample?, + val improvementAtBest: Double?, + val secondsSinceBest: Double?, +) + +@Composable +private fun AdviceBanner(view: AssistantView) { + val reached = view.hasReachedTarget + val icon: ImageVector = if (reached) { + Icons.Filled.CheckCircle + } else { + when (view.trend) { + AlignmentAssistant.Trend.IMPROVING -> Icons.Filled.TrendingDown + AlignmentAssistant.Trend.WORSENING -> Icons.Filled.TrendingUp + AlignmentAssistant.Trend.STEADY -> Icons.Filled.TrendingFlat + AlignmentAssistant.Trend.UNKNOWN -> Icons.Filled.HourglassEmpty + } + } + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = if (reached) { + Color(0x331F9E52) + } else { + MaterialTheme.colorScheme.surfaceContainer + }, + ), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + icon, contentDescription = null, + tint = if (reached) Color(0xFF1F9E52) else MaterialTheme.colorScheme.primary, + modifier = Modifier.size(32.dp), + ) + Text( + view.advice, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(start = 12.dp), + ) + } + } +} + +@Composable +private fun WedgeCard(state: LevelState, trackWidth: Double?, wheelbase: Double?) { + val across = state.roll?.let { LevelingWedge.across(it, trackWidth) } + val along = state.pitch?.let { LevelingWedge.along(it, wheelbase) } + + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text("Auffahrkeile", style = MaterialTheme.typography.titleSmall) + when { + trackWidth == null && wheelbase == null -> Text( + "Für die Keilhöhe fehlen Spurweite und Radstand. Beides lässt sich " + + "beim Fahrzeug hinterlegen.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + across == null && along == null -> Text( + "Keine Keile nötig.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + else -> { + listOfNotNull(across, along).forEach { wedge -> + Row(modifier = Modifier.fillMaxWidth()) { + Text( + wedge.side.text.replaceFirstChar { it.uppercase() }, + modifier = Modifier.weight(1f), + ) + Text( + de("%.0f cm", wedge.heightInCentimetres), + style = MaterialTheme.typography.titleMedium, + ) + } + } + Text( + "Höhe unter die tieferstehende Seite, damit das Fahrzeug eben steht.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/DashboardScreen.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/DashboardScreen.kt new file mode 100644 index 0000000..f0ecda1 --- /dev/null +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/DashboardScreen.kt @@ -0,0 +1,152 @@ +package de.fritob.campermonitor.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import de.fritob.campermonitor.bluetooth.BluetoothManager +import de.fritob.campermonitor.protocol.ConfiguredDevice +import de.fritob.campermonitor.protocol.DeviceLinkState +import de.fritob.campermonitor.store.DeviceStore + +/** + * Die Startansicht: alle Geräte des gewählten Fahrzeugs als Kacheln. + * + * Anders als unter iOS sitzt die Fahrzeugauswahl nicht als runder Knopf in der + * Ecke, sondern als Titel mit Aufklapp-Pfeil – so ist es auf Android üblich. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DashboardScreen( + store: DeviceStore, + bluetooth: BluetoothManager, + onOpenDevice: (ConfiguredDevice) -> Unit, + onAddDevice: () -> Unit, + onOpenProfiles: () -> Unit, + onOpenSettings: () -> Unit, +) { + val devices = store.activeDevices() + + Scaffold( + topBar = { + TopAppBar( + title = { + TextButton(onClick = onOpenProfiles) { + Icon( + profileIcon(store.activeProfile?.symbol ?: "box_truck"), + contentDescription = null, + ) + Text( + store.activeProfile?.name ?: "Camper", + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(start = 8.dp), + ) + } + }, + actions = { + IconButton(onClick = onAddDevice) { + Icon(Icons.Filled.Add, contentDescription = "Gerät hinzufügen") + } + IconButton(onClick = onOpenSettings) { + Icon(Icons.Filled.Settings, contentDescription = "Einstellungen") + } + }, + ) + }, + ) { padding -> + if (devices.isEmpty()) { + EmptyDashboard( + modifier = Modifier.fillMaxSize().padding(padding), + statusText = bluetooth.bluetoothStatusText, + isReady = bluetooth.isBluetoothReady, + onAddDevice = onAddDevice, + ) + return@Scaffold + } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = 16.dp, end = 16.dp, + top = padding.calculateTopPadding() + 8.dp, + bottom = padding.calculateBottomPadding() + 24.dp, + ), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + if (!bluetooth.isBluetoothReady) { + item { + Text( + bluetooth.bluetoothStatusText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + } + } + items(devices, key = { it.id }) { device -> + DeviceCard( + device = device, + snapshot = bluetooth.snapshots[device.id], + linkState = bluetooth.linkStates[device.id] ?: DeviceLinkState.Searching, + onClick = { onOpenDevice(device) }, + ) + } + } + } +} + +@Composable +private fun EmptyDashboard( + modifier: Modifier, + statusText: String, + isReady: Boolean, + onAddDevice: () -> Unit, +) { + Column( + modifier = modifier.padding(32.dp), + verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + "Noch keine Geräte", + style = MaterialTheme.typography.headlineSmall, + ) + Text( + "Füge deinen Ladebooster, den Solarladeregler, die Batterie, die " + + "Kühlbox oder den Neigungsmesser hinzu.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + if (!isReady) { + Text( + statusText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + ) + } + TextButton(onClick = onAddDevice, modifier = Modifier.fillMaxWidth()) { + Text("Gerät hinzufügen") + } + } +} diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/DeviceCard.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/DeviceCard.kt new file mode 100644 index 0000000..8652b1c --- /dev/null +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/DeviceCard.kt @@ -0,0 +1,199 @@ +package de.fritob.campermonitor.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import de.fritob.campermonitor.protocol.ConfiguredDevice +import de.fritob.campermonitor.protocol.DeviceLinkState +import de.fritob.campermonitor.protocol.DeviceSnapshot +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable + +/** + * Kachel auf dem Dashboard: Hauptwert gross, darunter die wichtigsten + * Nebenwerte und der Verbindungszustand. + */ +@Composable +fun DeviceCard( + device: ConfiguredDevice, + snapshot: DeviceSnapshot?, + linkState: DeviceLinkState, + onClick: () -> Unit, +) { + val isStale = snapshot?.isStale() ?: true + + Card( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = roleIcon(device.role), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + Column(modifier = Modifier.padding(start = 8.dp).weight(1f)) { + Text(device.name, style = MaterialTheme.typography.titleMedium) + Text( + device.role.title, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + StatusDot(linkState, isStale) + } + + val primary = snapshot?.primaryMetric + if (snapshot != null && primary != null) { + Row(verticalAlignment = Alignment.Bottom) { + Text( + primary.formatted, + fontSize = 40.sp, + fontWeight = FontWeight.SemiBold, + color = if (isStale) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + Text( + primary.unit, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 4.dp, bottom = 6.dp), + ) + } + SecondaryValues(snapshot) + } else { + Text( + placeholderText(linkState), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 12.dp), + ) + } + + CardFooter(snapshot, linkState) + } + } +} + +@Composable +private fun SecondaryValues(snapshot: DeviceSnapshot) { + val others = snapshot.metrics + .filter { it.key != snapshot.primaryMetric?.key && it.value != null } + .take(3) + if (others.isEmpty()) return + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + others.forEach { metric -> + Column { + Text( + metric.formattedWithUnit, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + Text( + metric.label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun CardFooter(snapshot: DeviceSnapshot?, linkState: DeviceLinkState) { + val fault = snapshot?.fault + when { + fault != null -> Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Filled.Warning, contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(16.dp), + ) + Text( + fault, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + maxLines = 2, + modifier = Modifier.padding(start = 6.dp), + ) + } + + snapshot?.state != null -> { + // Bei "Aus" ist erst der Grund die eigentliche Information. + val reason = snapshot.offReasons.firstOrNull() + Text( + if (reason != null) "${snapshot.state} · $reason" else snapshot.state!!, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + ) + } + + linkState is DeviceLinkState.Failed -> Text( + linkState.message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.tertiary, + maxLines = 2, + ) + } +} + +private fun placeholderText(linkState: DeviceLinkState): String = when (linkState) { + is DeviceLinkState.NeedsKey -> "Verschlüsselungsschlüssel fehlt – im Detail eintragen." + is DeviceLinkState.Failed -> linkState.message + else -> "Warte auf Daten…" +} + +/** Kleiner Punkt, der Verbindungszustand und Aktualität zusammenfasst. */ +@Composable +fun StatusDot(linkState: DeviceLinkState, isStale: Boolean) { + val color = when (linkState) { + is DeviceLinkState.Live -> if (isStale) Color(0xFFE08600) else Color(0xFF1F9E52) + is DeviceLinkState.NeedsKey -> Color(0xFFE08600) + is DeviceLinkState.Failed -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + val label = if (linkState is DeviceLinkState.Live && isStale) "Veraltet" else linkState.label + + Row(verticalAlignment = Alignment.CenterVertically) { + Box(modifier = Modifier.size(8.dp).clip(CircleShape).background(color)) + Text( + label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 5.dp), + ) + } +} diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/DeviceDetailScreen.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/DeviceDetailScreen.kt new file mode 100644 index 0000000..35c244e --- /dev/null +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/DeviceDetailScreen.kt @@ -0,0 +1,681 @@ +package de.fritob.campermonitor.ui + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Key +import androidx.compose.material.icons.filled.Tune +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import de.fritob.campermonitor.bluetooth.BluetoothManager +import de.fritob.campermonitor.bluetooth.BmsDiagnostics +import de.fritob.campermonitor.protocol.ConfiguredDevice +import de.fritob.campermonitor.protocol.DeviceLinkState +import de.fritob.campermonitor.protocol.DeviceRole +import de.fritob.campermonitor.protocol.DeviceTransport +import de.fritob.campermonitor.protocol.FridgeZoneMode +import de.fritob.campermonitor.protocol.LevelState +import de.fritob.campermonitor.store.DeviceStore + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DeviceDetailScreen( + device: ConfiguredDevice, + store: DeviceStore, + bluetooth: BluetoothManager, + onBack: () -> Unit, + onOpenKey: () -> Unit, + onOpenLevelSetup: () -> Unit, + onOpenAssistant: () -> Unit, +) { + val current = store.devices.firstOrNull { it.id == device.id } ?: device + val snapshot = bluetooth.snapshots[device.id] + val linkState = bluetooth.linkStates[device.id] ?: DeviceLinkState.Searching + val isStale = snapshot?.isStale() ?: true + var showDelete by remember { mutableStateOf(false) } + + /** + * Im Alltag stören die technischen Angaben nur. Meldet ein Gerät aber einen + * Fehler oder fehlt der Schlüssel, sind sie genau das, was weiterhilft – + * dann werden sie unabhängig von der Einstellung gezeigt. + */ + val showsTechnicalDetails = store.showDiagnostics || + linkState is DeviceLinkState.Failed || linkState is DeviceLinkState.NeedsKey + + Scaffold( + topBar = { + TopAppBar( + title = { Text(current.name) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(bottom = 32.dp), + ) { + // MARK: Zustand + LabeledRow("Verbindung") { StatusDot(linkState, isStale) } + snapshot?.state?.let { LabeledRow("Zustand") { Text(it) } } + snapshot?.fault?.let { + Text( + it, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + } + snapshot?.offReasons?.forEach { + Text( + it, + color = MaterialTheme.colorScheme.tertiary, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 2.dp), + ) + } + // Der Empfangspegel hilft beim Suchen eines Geräts, im Alltag sagt + // er nichts – deshalb nur bei eingeblendeter Diagnose. + if (showsTechnicalDetails) { + snapshot?.rssi?.let { LabeledRow("Signal") { Text("$it dBm") } } + } + + // MARK: Schlüssel, falls er fehlt + if (needsKeyAttention(current, store, bluetooth, snapshot != null, linkState)) { + KeyPrompt(onOpenKey) + } + + // MARK: Steuerung + val fridge = bluetooth.fridgeStates[device.id] + if (current.role == DeviceRole.FRIDGE && + fridge != null && fridge.hasStatus + ) { + FridgeControls(current, fridge, bluetooth) + } + + if (current.role == DeviceRole.LEVELING) { + LevelSection( + device = current, + state = bluetooth.levelStates[device.id] ?: LevelState(), + isLive = linkState == DeviceLinkState.Live, + onOpenAssistant = onOpenAssistant, + onOpenSetup = onOpenLevelSetup, + ) + } + + // MARK: Messwerte + if (snapshot != null && snapshot.metrics.isNotEmpty()) { + SectionHeader("Messwerte") + snapshot.metrics.forEach { metric -> + LabeledRow(metric.label) { + Text( + metric.formattedWithUnit, + color = if (metric.value == null) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + } + } + } + + // MARK: Verlauf + val samples = bluetooth.history[device.id] + val primary = snapshot?.primaryMetric + if (samples != null && samples.size > 1 && primary != null) { + SectionHeader("Verlauf – ${primary.label}") + HistoryChart(samples.map { it.value }, primary.unit) + } + + // MARK: Zellen + if (snapshot != null && snapshot.cellVoltages.isNotEmpty()) { + SectionHeader("Zellspannungen") + snapshot.cellVoltages.forEachIndexed { index, voltage -> + LabeledRow("Zelle ${index + 1}") { Text(de("%.3f V", voltage)) } + } + } + + if (snapshot != null && snapshot.info.isNotEmpty()) { + SectionHeader("Gerät") + snapshot.info.forEach { LabeledRow(it.label) { Text(it.value) } } + } + + if (snapshot != null && snapshot.temperatures.size > 1) { + SectionHeader("Temperaturen") + snapshot.temperatures.forEachIndexed { index, value -> + LabeledRow("Fühler ${index + 1}") { Text(de("%.0f °C", value)) } + } + } + + // MARK: Diagnose + if (showsTechnicalDetails) { + if (current.role.transport == DeviceTransport.ADVERTISEMENT) { + bluetooth.diagnostics[device.id]?.let { VictronDiagnosticsSection(it) } + } else { + bluetooth.bmsDiagnostics[device.id]?.let { + BmsDiagnosticsSection(current, it) + } + } + } + + // MARK: Einstellungen + SectionHeader("Einstellungen") + NameField(current, store) + LabeledRow("Typ") { Text(current.role.title) } + if (current.role == DeviceRole.FRIDGE) { + ZoneModePicker(current, store, bluetooth) + } + if (current.role.transport == DeviceTransport.ADVERTISEMENT) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onOpenKey) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Verschlüsselung", modifier = Modifier.weight(1f)) + Text( + keyStatusText(current, store, bluetooth), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) + } + } + LabeledRow("Bluetooth-Adresse") { + Text( + current.address, + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.bodySmall, + ) + } + TextButton( + onClick = { showDelete = true }, + modifier = Modifier.padding(horizontal = 8.dp), + ) { + Icon(Icons.Filled.Delete, contentDescription = null, + tint = MaterialTheme.colorScheme.error) + Text( + "Gerät entfernen", + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(start = 8.dp), + ) + } + } + } + + if (showDelete) { + AlertDialog( + onDismissRequest = { showDelete = false }, + title = { Text("Gerät entfernen?") }, + text = { Text("Die Einstellungen und der hinterlegte Schlüssel werden gelöscht.") }, + confirmButton = { + TextButton(onClick = { + store.remove(current) + bluetooth.refreshConfiguration() + showDelete = false + onBack() + }) { Text("Entfernen") } + }, + dismissButton = { + TextButton(onClick = { showDelete = false }) { Text("Abbrechen") } + }, + ) + } +} + +// MARK: - Bausteine + +@Composable +fun LabeledRow(label: String, content: @Composable () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(label, modifier = Modifier.weight(1f)) + content() + } +} + +/** + * Ohne passenden Schlüssel bleibt das Gerät stumm – das ist dann keine + * Nebensache, sondern das Einzige, was zu tun ist. + */ +private fun needsKeyAttention( + device: ConfiguredDevice, + store: DeviceStore, + bluetooth: BluetoothManager, + hasSnapshot: Boolean, + linkState: DeviceLinkState, +): Boolean { + if (device.role.transport != DeviceTransport.ADVERTISEMENT) return false + val entered = store.victronKey(device.id) + val expected = bluetooth.diagnostics[device.id]?.expectedKeyFirstByte + if (entered != null && expected != null && (entered[0].toInt() and 0xFF) != expected) return true + // Kommen Werte an, passt der Schlüssel offensichtlich. + if (hasSnapshot && linkState == DeviceLinkState.Live) return false + return entered == null +} + +private fun keyStatusText( + device: ConfiguredDevice, + store: DeviceStore, + bluetooth: BluetoothManager, +): String { + val entered = store.victronKey(device.id) ?: return "fehlt" + val expected = bluetooth.diagnostics[device.id]?.expectedKeyFirstByte ?: return "hinterlegt" + return if ((entered[0].toInt() and 0xFF) == expected) "hinterlegt" else "passt nicht" +} + +@Composable +private fun KeyPrompt(onOpenKey: () -> Unit) { + Card( + modifier = Modifier.fillMaxWidth().padding(16.dp).clickable(onClick = onOpenKey), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Filled.Key, contentDescription = null, + tint = MaterialTheme.colorScheme.tertiary) + Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) { + Text("Verschlüsselungsschlüssel eintragen") + Text( + "Victron-Geräte senden ihre Werte verschlüsselt. Ohne den Schlüssel " + + "aus VictronConnect bleibt die Anzeige leer.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) + } + } +} + +@Composable +private fun NameField(device: ConfiguredDevice, store: DeviceStore) { + var name by remember(device.id) { mutableStateOf(device.name) } + + fun save() { + // Ein leeres Feld beim Tippen darf den Namen nicht löschen. + val trimmed = name.trim() + if (trimmed.isNotEmpty() && trimmed != device.name) { + store.update(device.copy(name = trimmed)) + } + } + + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Name") }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { save() }), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + // Beim Abschluss der Eingabe und beim Verlassen des Feldes + // gesichert - bei jedem Tastendruck zu speichern hiesse, die ganze + // Geräteliste je Zeichen neu zu schreiben. + .onFocusChanged { if (!it.isFocused) save() }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ZoneModePicker( + device: ConfiguredDevice, + store: DeviceStore, + bluetooth: BluetoothManager, +) { + var expanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) { + OutlinedTextField( + value = device.fridgeZoneMode.title, + onValueChange = {}, + readOnly = true, + label = { Text("Kühlzonen") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(androidx.compose.material3.MenuAnchorType.PrimaryNotEditable, true), + ) + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + FridgeZoneMode.entries.forEach { mode -> + DropdownMenuItem( + text = { Text(mode.title) }, + onClick = { + val updated = device.copy(fridgeZoneMode = mode) + store.update(updated) + bluetooth.updateFridgeZoneMode(updated) + expanded = false + }, + ) + } + } + } +} + +/** + * Der Verlauf des Hauptwerts. + * + * Selbst gezeichnet statt mit einer Diagrammbibliothek – für eine Linie mit + * Fläche lohnt keine weitere Abhängigkeit. + */ +@Composable +private fun HistoryChart(values: List, unit: String) { + if (values.size < 2) return + val lowest = values.min() + val highest = values.max() + // Bei Spannungen zählt der Unterschied von Zehntelvolt, nicht die absolute + // Spannung – deshalb eng um die Messwerte zoomen. + val low = lowest - 0.05 + val high = if (highest - lowest < 0.01) highest + 0.05 else highest + 0.05 + val span = (high - low).coerceAtLeast(0.0001) + val lineColor = MaterialTheme.colorScheme.primary + + Column { + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(160.dp) + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + val stepX = size.width / (values.size - 1) + fun y(value: Double) = (size.height * (1 - (value - low) / span)).toFloat() + + val line = Path().apply { + moveTo(0f, y(values.first())) + values.forEachIndexed { index, value -> lineTo(index * stepX, y(value)) } + } + val area = Path().apply { + addPath(line) + lineTo(size.width, size.height) + lineTo(0f, size.height) + close() + } + drawPath(area, lineColor.copy(alpha = 0.15f)) + drawPath(line, lineColor, style = Stroke(width = 3f)) + } + Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { + Text( + de("%.2f %s", lowest, unit), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + Text( + de("%.2f %s", highest, unit), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun VictronDiagnosticsSection(info: de.fritob.campermonitor.bluetooth.VictronDiagnostics) { + SectionHeader("Diagnose") + LabeledRow("Datensatz") { Text(info.recordName) } + LabeledRow("Produkt-ID") { Text(info.productIDText, fontFamily = FontFamily.Monospace) } + Text("Rohdaten", modifier = Modifier.padding(start = 16.dp, top = 8.dp)) + Text( + info.rawHex, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp), + ) + Text( + "Diese Werte sendet das Gerät unverschlüsselt mit.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(16.dp), + ) +} + +@Composable +private fun BmsDiagnosticsSection(device: ConfiguredDevice, info: BmsDiagnostics) { + val context = LocalContext.current + var copied by remember { mutableStateOf(false) } + + SectionHeader("Diagnose") + LabeledRow("Erkanntes Protokoll") { Text(info.dialect) } + info.endpointPosition?.let { + LabeledRow("Verbindungsweg") { Text("${it.first} von ${it.second}") } + } + info.endpointLabel?.let { + Text("Aktueller Weg", modifier = Modifier.padding(start = 16.dp, top = 8.dp)) + Text( + it, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + LabeledRow("Verbunden") { Text(if (info.isConnected) "ja" else "nein") } + LabeledRow("Empfang abonniert") { Text(if (info.isNotifyActive) "ja" else "nein") } + info.isBound?.let { LabeledRow("Angemeldet") { Text(if (it) "ja" else "nein") } } + if (info.confirmedWrites > 0) { + LabeledRow("Schreibvorgänge bestätigt") { Text("${info.confirmedWrites}") } + } + info.lastWriteError?.let { + LabeledRow("Letzter Schreibfehler") { + Text(it, color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall) + } + } + LabeledRow("Gesendet / empfangen") { + Text("${info.sentFrames} Anfragen / ${info.receivedBytes} Byte") + } + HexBlock("Letzter Stellbefehl", info.lastCommandHex) + HexBlock("Letzte Antwort", info.lastResponseHex) + info.fridgePayloadHex?.let { + HexBlock("Statusdaten der Box (${it.split(" ").size} Byte)", it) + } + + OutlinedButton( + onClick = { + copyToClipboard(context, buildReport(device, info)) + copied = true + }, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Icon(Icons.Filled.ContentCopy, contentDescription = null) + Text( + if (copied) "Diagnose kopiert" else "Diagnose kopieren", + modifier = Modifier.padding(start = 8.dp), + ) + } + + if (info.gattSummary.isNotEmpty()) { + SectionHeader("Bluetooth-Merkmale des Geräts") + Text( + info.gattSummary.joinToString("\n"), + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + HorizontalDivider(modifier = Modifier.padding(top = 16.dp)) +} + +@Composable +private fun HexBlock(title: String, hex: String?) { + if (hex == null) return + Text(title, modifier = Modifier.padding(start = 16.dp, top = 8.dp)) + Text( + hex, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp), + ) +} + +/** + * Alles auf einmal, zum Weitergeben. Einzeln abzutippen ist zuviel verlangt, + * und gerade der Merkmalsbaum ist zu lang dafür. + */ +private fun buildReport(device: ConfiguredDevice, info: BmsDiagnostics): String = buildList { + add("Gerät: ${device.name} (${device.role.title})") + add("Protokoll: ${info.dialect}") + add("Verbunden: ${if (info.isConnected) "ja" else "nein"}") + add("Empfang abonniert: ${if (info.isNotifyActive) "ja" else "nein"}") + info.endpointPosition?.let { add("Weg: ${it.first} von ${it.second}") } + info.endpointLabel?.let { add("Merkmal: $it") } + info.isBound?.let { add("Angemeldet: ${if (it) "ja" else "nein"}") } + add("Gesendet: ${info.sentFrames} · empfangen: ${info.receivedBytes} Byte" + + " · bestätigt: ${info.confirmedWrites}") + info.lastWriteError?.let { add("Schreibfehler: $it") } + info.lastCommandHex?.let { add("Letzter Stellbefehl: $it") } + info.lastResponseHex?.let { add("Letzte Antwort: $it") } + info.fridgePayloadHex?.let { add("Statusdaten: $it") } + if (info.gattSummary.isNotEmpty()) { + add("Merkmale:") + addAll(info.gattSummary) + } +}.joinToString("\n") + +private fun copyToClipboard(context: Context, text: String) { + val manager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + manager.setPrimaryClip(ClipData.newPlainText("Diagnose", text)) +} + +/** + * Die Nivellierung in der Geräteansicht: Anzeige, Assistent und der Weg zur + * Einrichtung. Einbaulage und Nullpunkt liegen bewusst eine Ebene tiefer – + * direkt unter der Libelle verstellte ein Fehlgriff beim Ablesen den Nullpunkt. + */ +@Composable +private fun LevelSection( + device: ConfiguredDevice, + state: LevelState, + isLive: Boolean, + onOpenAssistant: () -> Unit, + onOpenSetup: () -> Unit, +) { + var displayStyle by remember { mutableStateOf(0) } + + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + listOf("Libelle", "Fahrzeug").forEachIndexed { index, label -> + SegmentedButton( + selected = displayStyle == index, + onClick = { displayStyle = index }, + shape = SegmentedButtonDefaults.itemShape(index, 2), + ) { Text(label) } + } + } + if (displayStyle == 0) { + LevelBubble(state.pitch, state.roll) + } else { + VehicleTiltView(state.pitch, state.roll) + } + state.instruction?.let { + Text( + it, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.fillMaxWidth(), + ) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { + Reading("Längs", state.pitch, Modifier.weight(1f)) + Reading("Quer", state.roll, Modifier.weight(1f)) + } + + OutlinedButton( + onClick = onOpenAssistant, + // Der Assistent lebt von laufenden Messwerten. Ein einmal + // empfangener Wert genügt nicht: nach einem Verbindungsabbruch + // bliebe er stehen und die Ansicht sähe eingefroren aus. + enabled = isLive && state.hasReading, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Ausrichtungs-Assistent") + } + if (!isLive) { + Text( + "Der Assistent braucht laufende Messwerte. Der Neigungsmesser ist " + + "gerade nicht verbunden.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + OutlinedButton(onClick = onOpenSetup, modifier = Modifier.fillMaxWidth()) { + Icon(Icons.Filled.Tune, contentDescription = null) + Text("Neigungsmesser einrichten", modifier = Modifier.weight(1f).padding(start = 8.dp)) + Text( + if (state.isKnownUncalibrated) "Nullpunkt fehlt" else device.sensorOrientation.summary, + style = MaterialTheme.typography.labelSmall, + ) + } + } +} diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/Format.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/Format.kt new file mode 100644 index 0000000..2c1cd92 --- /dev/null +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/Format.kt @@ -0,0 +1,14 @@ +package de.fritob.campermonitor.ui + +import java.util.Locale + +/** + * Zahlen mit deutschem Trennzeichen. + * + * `String.format` ohne Sprachangabe nimmt die des Systems – dann steht in + * einer sonst deutschen Oberfläche plötzlich "1.58" statt "1,58", je nach + * Telefon verschieden. Die Messwerte selbst formatiert die Protokollschicht + * bereits so; hier gilt dasselbe für alles, was die Ansichten selbst rechnen. + */ +fun de(format: String, vararg args: Any?): String = + String.format(Locale.GERMANY, format, *args) diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/FridgeControls.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/FridgeControls.kt new file mode 100644 index 0000000..860c598 --- /dev/null +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/FridgeControls.kt @@ -0,0 +1,156 @@ +package de.fritob.campermonitor.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Remove +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import de.fritob.campermonitor.bluetooth.BluetoothManager +import de.fritob.campermonitor.protocol.AlpicoolState +import de.fritob.campermonitor.protocol.ConfiguredDevice +import de.fritob.campermonitor.protocol.DeviceLinkState +import de.fritob.campermonitor.protocol.FridgeZone + +/** + * Bedienelemente einer Alpicool-Kühlbox. + * + * Alle Schalter folgen dem Gerät, nicht der Vermutung: nach jedem Stellbefehl + * fragt die Sitzung den Zustand neu ab, und die Ansicht zeigt, was zurückkam. + */ +@Composable +fun FridgeControls( + device: ConfiguredDevice, + state: AlpicoolState, + bluetooth: BluetoothManager, +) { + val isLinked = bluetooth.linkStates[device.id] == DeviceLinkState.Live + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + SectionHeader("Steuerung") + + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Eingeschaltet", modifier = Modifier.weight(1f)) + Switch( + checked = state.isPoweredOn, + onCheckedChange = { bluetooth.setFridgePower(it, device.id) }, + ) + } + + SingleChoiceSegmentedButtonRow( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + ) { + listOf("Max" to false, "Eco" to true).forEachIndexed { index, (label, eco) -> + SegmentedButton( + selected = state.isEco == eco, + onClick = { bluetooth.setFridgeEco(eco, device.id) }, + shape = SegmentedButtonDefaults.itemShape(index, 2), + enabled = state.isPoweredOn, + ) { Text(label) } + } + } + + TargetStepper( + title = if (state.isDualZone) "Soll links" else "Solltemperatur", + value = state.leftTarget, + state = state, + enabled = state.isPoweredOn, + onChange = { bluetooth.setFridgeTarget(it, FridgeZone.LEFT, device.id) }, + ) + + if (state.isDualZone) { + TargetStepper( + title = "Soll rechts", + value = state.rightTarget, + state = state, + enabled = state.isPoweredOn, + onChange = { bluetooth.setFridgeTarget(it, FridgeZone.RIGHT, device.id) }, + ) + } + + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Bedienfeld gesperrt", modifier = Modifier.weight(1f)) + Switch( + checked = state.isLocked, + onCheckedChange = { bluetooth.setFridgeLock(it, device.id) }, + ) + } + + Text( + // Ist die Box gerade nicht erreichbar, wird ein Befehl aufgehoben + // statt verworfen – das gehört gesagt, sonst sieht es aus, als + // hätte das Tippen nichts bewirkt. + if (isLinked) { + "Änderungen gehen direkt an die Box. Der angezeigte Stand kommt aus " + + "ihrer Antwort, nicht aus der Eingabe." + } else { + "Die Box ist gerade nicht verbunden. Die Änderung wird gemerkt und " + + "geht raus, sobald sie wieder erreichbar ist." + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + } +} + +@Composable +private fun TargetStepper( + title: String, + value: Int?, + state: AlpicoolState, + enabled: Boolean, + onChange: (Int) -> Unit, +) { + val range = state.targetRange + val current = value ?: range.first + + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(title, modifier = Modifier.weight(1f)) + Text( + value?.let { "$it ${state.unitSymbol}" } ?: "–", + style = MaterialTheme.typography.titleMedium, + ) + IconButton( + onClick = { onChange((current - 1).coerceIn(range.first, range.last)) }, + enabled = enabled && value != null && current > range.first, + ) { Icon(Icons.Filled.Remove, contentDescription = "kälter") } + IconButton( + onClick = { onChange((current + 1).coerceIn(range.first, range.last)) }, + enabled = enabled && value != null && current < range.last, + ) { Icon(Icons.Filled.Add, contentDescription = "wärmer") } + } +} + +@Composable +fun SectionHeader(title: String) { + Text( + title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 4.dp), + ) +} diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/Icons.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/Icons.kt new file mode 100644 index 0000000..ff54198 --- /dev/null +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/Icons.kt @@ -0,0 +1,53 @@ +package de.fritob.campermonitor.ui + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AcUnit +import androidx.compose.material.icons.filled.Agriculture +import androidx.compose.material.icons.filled.AirportShuttle +import androidx.compose.material.icons.filled.Battery5Bar +import androidx.compose.material.icons.filled.BatteryChargingFull +import androidx.compose.material.icons.filled.Bolt +import androidx.compose.material.icons.filled.Cabin +import androidx.compose.material.icons.filled.DirectionsBoat +import androidx.compose.material.icons.filled.DirectionsBus +import androidx.compose.material.icons.filled.DirectionsCar +import androidx.compose.material.icons.filled.LocalShipping +import androidx.compose.material.icons.filled.Landscape +import androidx.compose.material.icons.filled.Speed +import androidx.compose.material.icons.filled.Straighten +import androidx.compose.material.icons.filled.WbSunny +import androidx.compose.ui.graphics.vector.ImageVector +import de.fritob.campermonitor.protocol.DeviceRole + +/** + * Die Entsprechungen der SF-Symbole aus der iOS-Fassung. + * + * Material bringt keine deckungsgleichen Symbole mit; ausgewählt ist jeweils + * das, was dieselbe Sache meint – nicht das, was am ähnlichsten aussieht. + */ +fun roleIcon(role: DeviceRole): ImageVector = when (role) { + DeviceRole.CHARGE_BOOSTER -> Icons.Filled.Bolt + DeviceRole.SOLAR_CHARGER -> Icons.Filled.WbSunny + DeviceRole.BATTERY_MONITOR -> Icons.Filled.Speed + DeviceRole.BMS -> Icons.Filled.BatteryChargingFull + DeviceRole.FRIDGE -> Icons.Filled.AcUnit + DeviceRole.LEVELING -> Icons.Filled.Straighten +} + +/** Symbol eines Fahrzeugprofils. */ +fun profileIcon(symbol: String): ImageVector = when (symbol) { + "box_truck" -> Icons.Filled.LocalShipping + "pickup" -> Icons.Filled.Agriculture + "bus" -> Icons.Filled.DirectionsBus + "double_decker" -> Icons.Filled.AirportShuttle + "car" -> Icons.Filled.DirectionsCar + "car_side" -> Icons.Filled.DirectionsCar + "tent" -> Icons.Filled.Cabin + "sailboat" -> Icons.Filled.DirectionsBoat + "lodge" -> Icons.Filled.Cabin + "mountains" -> Icons.Filled.Landscape + else -> Icons.Filled.LocalShipping +} + +/** Für die Anzeige des Batteriestands in der Fahrzeugauswahl. */ +val batteryIcon: ImageVector = Icons.Filled.Battery5Bar diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/LevelSetupScreen.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/LevelSetupScreen.kt new file mode 100644 index 0000000..5a845ea --- /dev/null +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/LevelSetupScreen.kt @@ -0,0 +1,180 @@ +package de.fritob.campermonitor.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.GpsFixed +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.SwapHoriz +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import de.fritob.campermonitor.bluetooth.BluetoothManager +import de.fritob.campermonitor.protocol.ConfiguredDevice +import de.fritob.campermonitor.protocol.LevelState +import de.fritob.campermonitor.store.DeviceStore + +/** + * Einrichtung des Neigungsmessers: Einbaulage und Nullpunkt. + * + * Beides wird einmal eingestellt und danach kaum wieder angefasst. In der + * Geräteansicht standen die Knöpfe direkt unter der Libelle – ein Fehlgriff + * beim Ablesen verstellte dort den Nullpunkt. Deshalb liegen sie hier. + * + * Die Reihenfolge ist nicht beliebig: erst muss klar sein, welche Achse des + * Sensors welche des Fahrzeugs ist, sonst wird der Nullpunkt auf die falsche + * Achse gelegt. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LevelSetupScreen( + device: ConfiguredDevice, + store: DeviceStore, + bluetooth: BluetoothManager, + onBack: () -> Unit, + onOpenSensorSetup: () -> Unit, +) { + val state = bluetooth.levelStates[device.id] ?: LevelState() + val current = store.devices.firstOrNull { it.id == device.id } ?: device + var showResetConfirmation by remember { mutableStateOf(false) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Neigungsmesser einrichten") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { + Reading("Längs", state.pitch, Modifier.weight(1f)) + Reading("Quer", state.roll, Modifier.weight(1f)) + } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Schritt 1 – Einbaulage", style = MaterialTheme.typography.titleSmall) + OutlinedButton( + onClick = onOpenSensorSetup, + enabled = state.hasReading, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Filled.SwapHoriz, contentDescription = null) + Text( + "Einbaulage bestimmen", + modifier = Modifier.weight(1f).padding(start = 8.dp), + ) + Text( + current.sensorOrientation.summary, + style = MaterialTheme.typography.labelSmall, + ) + } + Text( + "Sitzt der Sensor quer oder verdreht im Fahrzeug, meldet er längs " + + "und quer vertauscht. Der Assistent klärt das durch zweimaliges " + + "Kippen. Danach den Nullpunkt setzen.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Schritt 2 – Nullpunkt", style = MaterialTheme.typography.titleSmall) + Button( + onClick = { bluetooth.calibrateLevel(device.id) }, + enabled = state.hasReading, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Filled.GpsFixed, contentDescription = null) + Text("Aktuelle Lage als eben übernehmen", modifier = Modifier.padding(start = 8.dp)) + } + if (!state.isKnownUncalibrated) { + OutlinedButton( + onClick = { showResetConfirmation = true }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Icon(Icons.Filled.Refresh, contentDescription = null) + Text("Nullpunkt verwerfen", modifier = Modifier.padding(start = 8.dp)) + } + } + Text( + calibrationHint(state), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + if (showResetConfirmation) { + AlertDialog( + onDismissRequest = { showResetConfirmation = false }, + title = { Text("Nullpunkt verwerfen?") }, + text = { Text("Die Anzeige zeigt danach wieder die Lage des Sensors.") }, + confirmButton = { + TextButton(onClick = { + bluetooth.resetLevelCalibration(device.id) + showResetConfirmation = false + }) { Text("Verwerfen", color = Color.Unspecified) } + }, + dismissButton = { + TextButton(onClick = { showResetConfirmation = false }) { Text("Abbrechen") } + }, + ) + } +} + +private fun calibrationHint(state: LevelState): String { + val pitchOffset = state.pitchOffset + val rollOffset = state.rollOffset + if (state.isCalibrated && pitchOffset != null && rollOffset != null) { + return de("Der Nullpunkt liegt bei %.1f° längs und %.1f° quer. ", pitchOffset, rollOffset) + + "Zum Neusetzen das Fahrzeug eben stellen und dann tippen." + } + if (state.isKnownUncalibrated) { + return "Noch kein Nullpunkt gesetzt – die Anzeige zeigt die Lage des Sensors, " + + "nicht die des Fahrzeugs. Fahrzeug eben stellen, dann tippen." + } + // Ältere Firmware gibt die Offsets nicht heraus. + return "Zum Setzen das Fahrzeug eben stellen und dann tippen. Ob schon ein " + + "Nullpunkt gesetzt wurde, meldet dieses Gerät nicht zurück." +} diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/LevelViews.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/LevelViews.kt new file mode 100644 index 0000000..6fa050e --- /dev/null +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/LevelViews.kt @@ -0,0 +1,217 @@ +package de.fritob.campermonitor.ui + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import de.fritob.campermonitor.R +import de.fritob.campermonitor.protocol.LevelState +import kotlin.math.abs +import kotlin.math.max + +/** + * Grafische Libelle: eine Blase, die zeigt, wohin das Fahrzeug hängt. + * + * Die Blase wandert dorthin, wo das Fahrzeug **höher** steht – so, wie sich + * eine echte Wasserwaage verhält. Wer sie mittig haben will, muss also die + * Gegenseite anheben. + */ +@Composable +fun LevelBubble(pitch: Double?, roll: Double?, range: Double = 6.0) { + val deviation = when { + pitch != null && roll != null -> max(abs(pitch), abs(roll)) + pitch != null -> abs(pitch) + roll != null -> abs(roll) + else -> null + } + val isLevel = pitch != null && roll != null && + abs(pitch) <= LevelState.LEVEL_TOLERANCE && abs(roll) <= LevelState.LEVEL_TOLERANCE + + // Grün nur, wenn es wirklich eben ist – sonst sähe "schief" wie "eben" aus. + val bubbleColor = when { + deviation == null -> Color.Gray + deviation <= LevelState.LEVEL_TOLERANCE -> Color(0xFF1F9E52) + deviation <= 2 -> Color(0xFFE08600) + else -> Color(0xFFD03B2F) + } + val outline = MaterialTheme.colorScheme.outlineVariant + val fill = MaterialTheme.colorScheme.surfaceVariant + + Canvas( + // Feste Höhe statt `aspectRatio`: das würde die Höhe wieder an die + // Breite koppeln und die Deckelung aushebeln - die Libelle wuchs dann + // über ihren Platz hinaus und schob sich über die Umschalter. + // Der Kreis wird darin ohnehin mittig und passend gezeichnet. + modifier = Modifier + .fillMaxWidth() + .height(260.dp) + ) { + val side = minOf(size.width, size.height) + val radius = side / 2 + val bubble = side * 0.16f + // Die Blase darf den Rand nicht verlassen, auch bei starker Neigung. + val travel = radius - bubble / 2 - 4 + val centre = Offset(size.width / 2, size.height / 2) + + fun clamped(value: Double?): Float = + ((value ?: 0.0).coerceIn(-range, range) / range).toFloat() + + drawCircle(color = fill, radius = radius, center = centre) + drawCircle(color = outline, radius = radius, center = centre, style = Stroke(1f)) + + // Ringe als echter Massstab: der innere markiert die Toleranz, der + // mittlere zwei Grad. Ohne Massstab sagt die Blasenlage nichts darüber, + // wie weit es noch ist. + val toleranceRadius = max( + (LevelState.LEVEL_TOLERANCE / range * travel).toFloat(), + bubble * 0.6f, + ) + drawCircle( + color = if (isLevel) Color(0xFF1F9E52) else outline, + radius = toleranceRadius, center = centre, + style = Stroke(if (isLevel) 2f else 1f), + ) + drawCircle( + color = outline, radius = (2.0 / range * travel).toFloat(), center = centre, + style = Stroke(1f), + ) + drawLine(outline, Offset(centre.x - travel, centre.y), Offset(centre.x + travel, centre.y)) + drawLine(outline, Offset(centre.x, centre.y - travel), Offset(centre.x, centre.y + travel)) + + // Positiver Pitch heisst: das Heck steht höher, die Blase wandert also + // nach oben – in der Ansicht nach hinten. + drawCircle( + color = bubbleColor.copy(alpha = if (deviation == null) 0.25f else 1f), + radius = bubble / 2, + center = Offset( + centre.x + clamped(roll) * travel, + centre.y - clamped(pitch) * travel, + ), + ) + } +} + +/** + * Die Neigung am Fahrzeug selbst: Seitenansicht für längs, Heckansicht für + * quer. Aus dem Ursprungsprojekt übernommen. + */ +@Composable +fun VehicleTiltView(pitch: Double?, roll: Double?) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + TiltedVehicle( + title = "Längs", + value = pitch, + // Positiver Pitch heisst Heck höher; das Bild zeigt nach rechts das + // Heck, also dreht die Front nach unten. + degrees = ((pitch ?: 0.0) * EXAGGERATION).toFloat(), + drawable = R.drawable.vehicle_side, + leftLabel = "Front", rightLabel = "Heck", + ) + TiltedVehicle( + title = "Quer", + value = roll, + degrees = (-(roll ?: 0.0) * EXAGGERATION).toFloat(), + drawable = R.drawable.vehicle_rear, + leftLabel = "links", rightLabel = "rechts", + ) + Text( + "Neigung ${EXAGGERATION.toInt()}-fach überhöht dargestellt – sonst wäre " + + "sie kaum zu erkennen. Die Gradzahlen sind echt.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** Ohne Überhöhung sind zwei Grad am Bild nicht zu sehen. */ +private const val EXAGGERATION = 3.0 + +@Composable +private fun TiltedVehicle( + title: String, + value: Double?, + degrees: Float, + drawable: Int, + leftLabel: String, + rightLabel: String, +) { + val tint = when { + value == null -> MaterialTheme.colorScheme.onSurfaceVariant + abs(value) <= LevelState.LEVEL_TOLERANCE -> Color(0xFF1F9E52) + abs(value) <= 2 -> Color(0xFFE08600) + else -> Color(0xFFD03B2F) + } + Column { + Row(modifier = Modifier.fillMaxWidth()) { + Text(title, style = MaterialTheme.typography.titleSmall, modifier = Modifier.weight(1f)) + Text( + value?.let { de("%.1f°", it) } ?: "–", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = tint, + ) + } + Box( + modifier = Modifier.fillMaxWidth().height(120.dp), + contentAlignment = Alignment.Center, + ) { + Image( + painter = painterResource(drawable), + contentDescription = null, + colorFilter = ColorFilter.tint(tint), + modifier = Modifier.fillMaxWidth(0.8f).rotate(degrees), + ) + } + Row(modifier = Modifier.fillMaxWidth()) { + Text( + leftLabel, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + Text( + rightLabel, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +/** Ein Messwert mit Beschriftung, wie er unter der Libelle steht. */ +@Composable +fun Reading(title: String, value: Double?, modifier: Modifier = Modifier) { + Column( + modifier = modifier.padding(vertical = 4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + value?.let { de("%.1f°", it) } ?: "–", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + ) + Text( + title, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/Permissions.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/Permissions.kt new file mode 100644 index 0000000..769a143 --- /dev/null +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/Permissions.kt @@ -0,0 +1,103 @@ +package de.fritob.campermonitor.ui + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat + +/** + * Welche Rechte für Bluetooth gebraucht werden. + * + * Mit Android 12 hat sich das Modell geändert: davor lief ein BLE-Scan über + * die Standortfreigabe, seither gibt es eigene Bluetooth-Rechte. Beide Wege + * müssen bedient werden, sonst startet die App auf der einen oder der anderen + * Version nicht. + */ +val bluetoothPermissions: Array + get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT) + } else { + arrayOf(Manifest.permission.ACCESS_FINE_LOCATION) + } + +fun hasBluetoothPermissions(context: Context): Boolean = + bluetoothPermissions.all { + ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED + } + +/** + * Zeigt [content], sobald die Rechte da sind – sonst eine Erklärung mit Knopf. + * + * Ohne Erklärung wirkt eine App, die beim ersten Start nach Bluetooth fragt, + * schnell übergriffig. Hier steht, wozu. + */ +@Composable +fun WithBluetoothPermission(content: @Composable () -> Unit) { + val context = LocalContext.current + var granted by remember { mutableStateOf(hasBluetoothPermissions(context)) } + var asked by remember { mutableStateOf(false) } + + val launcher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { result -> + granted = result.values.all { it } + asked = true + } + + if (granted) { + content() + return + } + + Column( + modifier = Modifier.fillMaxSize().padding(32.dp), + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + "Bluetooth wird gebraucht", + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + ) + Text( + "Die App liest die Werte deiner Geräte über Bluetooth – Ladebooster, " + + "Solarladeregler, Batterie, Kühlbox und Neigungsmesser. Ohne die " + + "Freigabe bleibt sie leer.", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + if (asked) { + Text( + "Die Freigabe wurde abgelehnt. Sie lässt sich in den " + + "Android-Einstellungen unter „Apps → VanControl Pro → " + + "Berechtigungen“ nachholen.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + ) + } + Button(onClick = { launcher.launch(bluetoothPermissions) }) { + Text(if (asked) "Nochmal fragen" else "Bluetooth freigeben") + } + } +} diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/ProfilesScreen.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/ProfilesScreen.kt new file mode 100644 index 0000000..afa638a --- /dev/null +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/ProfilesScreen.kt @@ -0,0 +1,241 @@ +package de.fritob.campermonitor.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import de.fritob.campermonitor.bluetooth.BluetoothManager +import de.fritob.campermonitor.protocol.Profile +import de.fritob.campermonitor.store.DeviceStore + +/** + * Die Fahrzeugverwaltung. + * + * Antippen wählt aus, der Stift öffnet Name, Symbol und Masse. Beides + * getrennt, weil die Auswahl der häufige Fall ist und das Bearbeiten der + * seltene – ein Wisch-Menü hatte unter iOS niemand gefunden. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProfilesScreen(store: DeviceStore, bluetooth: BluetoothManager, onBack: () -> Unit) { + var editing by remember { mutableStateOf(null) } + var pendingDeletion by remember { mutableStateOf(null) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Fahrzeuge") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") + } + }, + actions = { + IconButton(onClick = { + editing = store.addProfile("Camper ${store.profiles.size + 1}") + }) { + Icon(Icons.Filled.Add, contentDescription = "Fahrzeug hinzufügen") + } + }, + ) + }, + ) { padding -> + LazyColumn(modifier = Modifier.fillMaxSize().padding(padding)) { + items(store.profiles, key = { it.id }) { profile -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + store.selectProfile(profile.id) + bluetooth.refreshConfiguration() + } + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(profileIcon(profile.symbol), contentDescription = null) + Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) { + Text(profile.name, style = MaterialTheme.typography.bodyLarge) + Text( + "${store.devices.count { it.profileID == profile.id }} Geräte" + + measuresText(profile), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (profile.id == store.activeProfile?.id) { + Icon( + Icons.Filled.Check, contentDescription = "ausgewählt", + tint = MaterialTheme.colorScheme.primary, + ) + } + IconButton(onClick = { editing = profile }) { + Icon(Icons.Filled.Edit, contentDescription = "Bearbeiten") + } + } + } + + item { + Text( + "Antippen wählt das Fahrzeug aus, der Stift öffnet Name, Symbol " + + "und Masse. Jedes Fahrzeug hat seine eigenen Geräte, und die " + + "App liest immer nur die des gewählten aus.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(16.dp), + ) + } + } + } + + editing?.let { profile -> + ProfileEditDialog( + profile = profile, + canDelete = store.profiles.size > 1, + onDismiss = { editing = null }, + onSave = { + store.updateProfile(it) + bluetooth.refreshConfiguration() + editing = null + }, + onDelete = { + pendingDeletion = profile + editing = null + }, + ) + } + + pendingDeletion?.let { profile -> + AlertDialog( + onDismissRequest = { pendingDeletion = null }, + title = { Text("Fahrzeug entfernen?") }, + text = { Text("Die Geräte dieses Fahrzeugs werden mit gelöscht.") }, + confirmButton = { + TextButton(onClick = { + store.removeProfile(profile) + bluetooth.refreshConfiguration() + pendingDeletion = null + }) { Text("Entfernen") } + }, + dismissButton = { + TextButton(onClick = { pendingDeletion = null }) { Text("Abbrechen") } + }, + ) + } +} + +private fun measuresText(profile: Profile): String { + val track = profile.trackWidth + val base = profile.wheelbase + if (track == null && base == null) return "" + val parts = buildList { + track?.let { add(de("Spur %.2f m", it)) } + base?.let { add(de("Radstand %.2f m", it)) } + } + return " · " + parts.joinToString(", ") +} + +@Composable +private fun ProfileEditDialog( + profile: Profile, + canDelete: Boolean, + onDismiss: () -> Unit, + onSave: (Profile) -> Unit, + onDelete: () -> Unit, +) { + var name by remember { mutableStateOf(profile.name) } + var symbol by remember { mutableStateOf(profile.symbol) } + var track by remember { mutableStateOf(profile.trackWidth?.toString() ?: "") } + var base by remember { mutableStateOf(profile.wheelbase?.toString() ?: "") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Fahrzeug") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Name") }, + singleLine = true, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Profile.SYMBOLS.take(5).forEach { candidate -> + FilterChip( + selected = symbol == candidate, + onClick = { symbol = candidate }, + label = { Icon(profileIcon(candidate), contentDescription = null) }, + ) + } + } + OutlinedTextField( + value = track, + onValueChange = { track = it }, + label = { Text("Spurweite in Metern") }, + singleLine = true, + ) + OutlinedTextField( + value = base, + onValueChange = { base = it }, + label = { Text("Radstand in Metern") }, + singleLine = true, + ) + Text( + "Beide Masse braucht nur der Keilrechner im " + + "Ausrichtungs-Assistenten. Ohne sie bleibt der Rest nutzbar.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + confirmButton = { + Button(onClick = { + onSave( + profile.copy( + name = name.trim().ifEmpty { profile.name }, + symbol = symbol, + trackWidth = track.replace(',', '.').toDoubleOrNull(), + wheelbase = base.replace(',', '.').toDoubleOrNull(), + ) + ) + }) { Text("Sichern") } + }, + dismissButton = { + Row { + if (canDelete) { + TextButton(onClick = onDelete) { Text("Entfernen") } + } + TextButton(onClick = onDismiss) { Text("Abbrechen") } + } + }, + ) +} diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/SensorSetupScreen.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/SensorSetupScreen.kt new file mode 100644 index 0000000..54f5d33 --- /dev/null +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/SensorSetupScreen.kt @@ -0,0 +1,247 @@ +package de.fritob.campermonitor.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.HorizontalRule +import androidx.compose.material.icons.filled.SouthEast +import androidx.compose.material.icons.filled.SouthWest +import androidx.compose.material.icons.filled.SwapHoriz +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import de.fritob.campermonitor.bluetooth.BluetoothManager +import de.fritob.campermonitor.protocol.ConfiguredDevice +import de.fritob.campermonitor.protocol.LevelState +import de.fritob.campermonitor.protocol.OrientationDetection +import de.fritob.campermonitor.protocol.SensorOrientation +import de.fritob.campermonitor.store.DeviceStore + +/** + * Führt durch die Einrichtung der Einbaulage des Neigungsmessers. + * + * Der Sensor kann quer, gedreht oder kopfüber sitzen. Statt die Lage aus einer + * Liste raten zu lassen, wird sie gemessen: zweimal kippen, einmal um jede + * Achse, und aus der Reaktion ergibt sich die Zuordnung. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SensorSetupScreen( + device: ConfiguredDevice, + store: DeviceStore, + bluetooth: BluetoothManager, + onBack: () -> Unit, +) { + val state = bluetooth.levelStates[device.id] ?: LevelState() + val live = remember(state) { + val pitch = state.rawPitch + val roll = state.rawRoll + if (pitch != null && roll != null) OrientationDetection.Reading(pitch, roll) else null + } + + var step by remember { mutableStateOf(Step.INTRO) } + /** Ruhelage, auf die beide Kippbewegungen bezogen werden. */ + var reference by remember { mutableStateOf(OrientationDetection.Reading(0.0, 0.0)) } + var noseChange by remember { mutableStateOf(null) } + var result by remember { mutableStateOf(null) } + var failure by remember { mutableStateOf(null) } + + Scaffold( + topBar = { TopAppBar(title = { Text("Einbaulage") }) }, + ) { padding -> + Column( + modifier = Modifier.fillMaxSize().padding(padding).padding(24.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + when (step) { + Step.INTRO -> { + InstructionCard( + icon = Icons.Filled.SwapHoriz, + title = "Einbaulage bestimmen", + text = "Sitzt der Sensor quer oder verdreht im Fahrzeug, meldet " + + "er die Neigung vertauscht. Um das zu klären, wird er gleich " + + "zweimal gekippt.\n\nBaue ihn dazu so ein oder halte ihn so, " + + "wie er später sitzen soll. Er muss nicht angeschraubt sein – " + + "nur die Ausrichtung muss stimmen.", + ) + LiveReadout(state) + Button( + onClick = { + reference = live ?: OrientationDetection.Reading(0.0, 0.0) + step = Step.TILT_NOSE + }, + enabled = live != null, + ) { Text("Los geht's") } + } + + Step.TILT_NOSE -> { + InstructionCard( + icon = Icons.Filled.SouthEast, + title = "Schritt 1 von 2: nach vorne kippen", + text = "Kippe den Sensor so, als würde das Fahrzeug vorne abwärts " + + "stehen – die Front also nach unten.\n\nDeutlich kippen, etwa " + + "eine Handbreit, und in dieser Lage halten. Dann weiter.", + ) + LiveReadout(state) + Button( + onClick = { + live?.let { noseChange = it - reference } + // Der Bezug bleibt die Ruhelage. Von der gekippten + // Lage aus zu messen wäre falsch: die zweite Messung + // enthielte dann das Zurückkippen aus der ersten, + // und beide Achsen schlügen aus. + step = Step.SETTLE + }, + enabled = live != null, + ) { Text("Weiter") } + } + + Step.SETTLE -> { + InstructionCard( + icon = Icons.Filled.HorizontalRule, + title = "Zurück in die Ruhelage", + text = "Stelle den Sensor wieder so hin wie am Anfang und lass ihn " + + "kurz ruhen.\n\nVon hier aus wird die zweite Bewegung gemessen.", + ) + LiveReadout(state) + Button( + onClick = { + reference = live ?: reference + step = Step.TILT_SIDE + }, + enabled = live != null, + ) { Text("Weiter") } + } + + Step.TILT_SIDE -> { + InstructionCard( + icon = Icons.Filled.SouthWest, + title = "Schritt 2 von 2: nach links kippen", + text = "Kippe den Sensor jetzt so, als würde das Fahrzeug nach " + + "links hängen – die linke Seite also nach unten.\n\nWieder " + + "deutlich kippen und in dieser Lage halten.", + ) + LiveReadout(state) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedButton(onClick = { step = Step.SETTLE }) { Text("Zurück") } + Button( + onClick = { + val nose = noseChange + val side = live?.minus(reference) + if (nose != null && side != null) { + when (val r = OrientationDetection.orientation(nose, side)) { + is OrientationDetection.Result.Success -> { + result = r.orientation + step = Step.DONE + } + is OrientationDetection.Result.Error -> { + failure = r.failure + step = Step.FAILED + } + } + } + }, + enabled = live != null, + ) { Text("Fertig") } + } + } + + Step.DONE -> { + val orientation = result ?: SensorOrientation.IDENTITY + InstructionCard( + icon = Icons.Filled.CheckCircle, + title = "Einbaulage erkannt", + text = "Ergebnis: ${orientation.summary}.\n\nDie Anzeige rechnet die " + + "Werte des Sensors ab jetzt auf die Achsen des Fahrzeugs um. " + + "Vergiss nicht, anschliessend im ebenen Stand den Nullpunkt " + + "zu setzen.", + ) + Button(onClick = { + val updated = device.copy(sensorOrientation = orientation) + store.update(updated) + bluetooth.updateSensorOrientation(updated) + onBack() + }) { Text("Übernehmen") } + } + + Step.FAILED -> { + InstructionCard( + icon = Icons.Filled.Warning, + title = "Das hat nicht geklappt", + text = failure?.message ?: "", + ) + Button(onClick = { step = Step.INTRO }) { Text("Nochmal versuchen") } + } + } + + TextButton(onClick = onBack) { Text("Abbrechen") } + } + } +} + +private enum class Step { INTRO, TILT_NOSE, SETTLE, TILT_SIDE, DONE, FAILED } + +@Composable +private fun InstructionCard(icon: ImageVector, title: String, text: String) { + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + icon, contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(44.dp), + ) + Text( + title, + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + ) + Text( + text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } +} + +/** + * Was der Sensor gerade meldet – ohne Umrechnung, denn die wird hier ja erst + * bestimmt. Deshalb heissen die Achsen A und B statt längs und quer. + */ +@Composable +private fun LiveReadout(state: LevelState) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { + Reading("Achse A", state.rawPitch, Modifier.weight(1f)) + Reading("Achse B", state.rawRoll, Modifier.weight(1f)) + } +} diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/SettingsScreen.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/SettingsScreen.kt new file mode 100644 index 0000000..4fe0d05 --- /dev/null +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/SettingsScreen.kt @@ -0,0 +1,105 @@ +package de.fritob.campermonitor.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import de.fritob.campermonitor.store.DeviceStore + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen(store: DeviceStore, onBack: () -> Unit) { + // Der Speicher meldet Änderungen selbst; die lokale Kopie hält nur den + // Schalter in Bewegung, während geschrieben wird. + var showDiagnostics by remember { mutableStateOf(store.showDiagnostics) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Einstellungen") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()), + ) { + SettingRow( + title = "Diagnose einblenden", + subtitle = "Zeigt Protokoll, Verbindungsweg und Rohdaten in den " + + "Gerätedetails. Im Alltag stören sie; bei einem Fehler sind sie " + + "genau das, was weiterhilft.", + trailing = { + Switch( + checked = showDiagnostics, + onCheckedChange = { + showDiagnostics = it + store.showDiagnostics = it + }, + ) + }, + ) + + Text( + "VanControl Pro liest Victron-Geräte über ihr Advertisement mit " + + "(„Instant Readout“) und spricht Batterie, Kühlbox und " + + "Neigungsmesser direkt über Bluetooth an.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(16.dp), + ) + } + } +} + +@Composable +fun SettingRow( + title: String, + subtitle: String? = null, + onClick: (() -> Unit)? = null, + trailing: @Composable (() -> Unit)? = null, +) { + androidx.compose.foundation.layout.Row( + modifier = Modifier + .fillMaxWidth() + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.bodyLarge) + if (subtitle != null) { + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + trailing?.invoke() + } +} diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/Theme.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/Theme.kt new file mode 100644 index 0000000..10d339e --- /dev/null +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/Theme.kt @@ -0,0 +1,40 @@ +package de.fritob.campermonitor.ui + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +/** + * Das Grün der iOS-Fassung als Akzent – damit beide Apps als dieselbe erkennbar + * bleiben. + * + * Bewusst **ohne** die dynamischen Systemfarben ab Android 12. In dieser App + * trägt Farbe Bedeutung: grün heisst eben, orange knapp daneben, rot schief. + * Eine vom Hintergrundbild abgeleitete Akzentfarbe stünde daneben und liesse + * die App je nach Telefon anders aussehen als ihr eigenes Logo. + */ +private val CamperGreen = Color(0xFF1F9E52) + +private val LightColors = lightColorScheme( + primary = CamperGreen, + secondary = CamperGreen, +) + +private val DarkColors = darkColorScheme( + primary = Color(0xFF54C77F), + secondary = Color(0xFF54C77F), +) + +@Composable +fun CamperTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit, +) { + MaterialTheme( + colorScheme = if (darkTheme) DarkColors else LightColors, + content = content, + ) +} diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/VictronKeyScreen.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/VictronKeyScreen.kt new file mode 100644 index 0000000..92d0a86 --- /dev/null +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/VictronKeyScreen.kt @@ -0,0 +1,145 @@ +package de.fritob.campermonitor.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import de.fritob.campermonitor.bluetooth.BluetoothManager +import de.fritob.campermonitor.protocol.ConfiguredDevice +import de.fritob.campermonitor.store.DeviceStore + +/** + * Eingabe des Victron-Verschlüsselungsschlüssels. + * + * Der Schlüssel wird einmal eingetragen und danach nie wieder angefasst – + * deshalb steht er hier und nicht in der Geräteübersicht. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun VictronKeyScreen( + device: ConfiguredDevice, + store: DeviceStore, + bluetooth: BluetoothManager, + onBack: () -> Unit, +) { + var keyInput by remember { mutableStateOf(store.victronKeyText(device.id) ?: "") } + val diagnostics = bluetooth.diagnostics[device.id] + + val entered = remember(keyInput) { + keyInput.filter { !it.isWhitespace() }.take(2) + .let { if (it.length == 2) it.toIntOrNull(16) else null } + } + val expected = diagnostics?.expectedKeyFirstByte + val agree = if (expected != null && entered != null) expected == entered else null + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Verschlüsselung") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + OutlinedTextField( + value = keyInput, + onValueChange = { keyInput = it }, + label = { Text("32 Hex-Zeichen") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Button( + onClick = { + store.setVictronKey(keyInput, device.id) + bluetooth.refreshConfiguration() + }, + enabled = keyInput.filter { !it.isWhitespace() }.length == 32, + ) { + Text("Schlüssel speichern") + } + Text( + "In VictronConnect: Gerät öffnen → Zahnrad → ⋮ → Produkt-Info → " + + "„Instant Readout“ einschalten → Verschlüsselungsdaten anzeigen. " + + "Der Schlüssel ist 16 Byte lang (32 Hex-Zeichen).", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // Das erste Byte sendet das Gerät unverschlüsselt mit. Stimmt es + // nicht mit dem eingetragenen überein, gehört der Schlüssel zu + // einem anderen Victron-Gerät – der häufigste Fehler überhaupt. + if (expected != null) { + Text("Erstes Schlüsselbyte", style = MaterialTheme.typography.titleSmall) + Row(modifier = Modifier.fillMaxWidth()) { + Text("Gerät sendet", modifier = Modifier.weight(1f)) + Text( + "0x%02X".format(expected), + fontFamily = FontFamily.Monospace, + color = if (agree == false) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + } + Row(modifier = Modifier.fillMaxWidth()) { + Text("Eingetragen", modifier = Modifier.weight(1f)) + Text( + entered?.let { "0x%02X".format(it) } ?: "–", + fontFamily = FontFamily.Monospace, + color = if (agree == false) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + Text( + if (agree == false) { + "Die beiden Bytes müssen übereinstimmen. Tun sie das nicht, " + + "stammt der Schlüssel von einem anderen Victron-Gerät – in " + + "VictronConnect prüfen, ob wirklich dieses Gerät geöffnet war." + } else { + "Zum Vergleichen: dieses Byte sendet das Gerät unverschlüsselt mit." + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} diff --git a/Android/app/src/main/res/drawable-nodpi/vehicle_rear.png b/Android/app/src/main/res/drawable-nodpi/vehicle_rear.png new file mode 100644 index 0000000..a71ab5e Binary files /dev/null and b/Android/app/src/main/res/drawable-nodpi/vehicle_rear.png differ diff --git a/Android/app/src/main/res/drawable-nodpi/vehicle_side.png b/Android/app/src/main/res/drawable-nodpi/vehicle_side.png new file mode 100644 index 0000000..9f1c333 Binary files /dev/null and b/Android/app/src/main/res/drawable-nodpi/vehicle_side.png differ diff --git a/Android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/Android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..08e5aa0 Binary files /dev/null and b/Android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/Android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/Android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..d2638f6 Binary files /dev/null and b/Android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/Android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/Android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..e261160 Binary files /dev/null and b/Android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..ea13a18 Binary files /dev/null and b/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..0aaf9f9 Binary files /dev/null and b/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/Android/app/src/main/res/values/strings.xml b/Android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..0ade3f2 --- /dev/null +++ b/Android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + VanControl Pro + diff --git a/Android/app/src/main/res/values/themes.xml b/Android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..4db0596 --- /dev/null +++ b/Android/app/src/main/res/values/themes.xml @@ -0,0 +1,4 @@ + + +