diff --git a/Android/app/build.gradle.kts b/Android/app/build.gradle.kts
new file mode 100644
index 0000000..1d5dae8
--- /dev/null
+++ b/Android/app/build.gradle.kts
@@ -0,0 +1,60 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("org.jetbrains.kotlin.plugin.compose")
+}
+
+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 = 1
+ versionName = "1.0"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+
+ buildFeatures {
+ compose = 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.
+}
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..1d10601
--- /dev/null
+++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/MainActivity.kt
@@ -0,0 +1,14 @@
+package de.fritob.campermonitor
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent { MaterialTheme { Text("Camper Monitor") } }
+ }
+}
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..e019cdf
--- /dev/null
+++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/bluetooth/BluetoothManager.kt
@@ -0,0 +1,632 @@
+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
+ }
+
+ // MARK: - Start
+
+ fun start() {
+ 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 } },
+ )
+ 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
+
+ fun updateSensorOrientation(device: ConfiguredDevice) {
+ handler.post { levelSessions[device.address]?.orientation = device.sensorOrientation }
+ }
+
+ 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..9f97f1f
--- /dev/null
+++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/bluetooth/LevelSession.kt
@@ -0,0 +1,274 @@
+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,
+) {
+ 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 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
+
+ /** 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
+ }
+
+ /** 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)
+ }
+
+ 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)
+
+ 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()
+ startPollingIfNeeded()
+ }
+ }
+ }
+}
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..d6dc56e
--- /dev/null
+++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/store/DeviceStore.kt
@@ -0,0 +1,231 @@
+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()
+ }
+
+ init {
+ load()
+ }
+
+ 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() {
+ 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)
+ }
+
+ 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),
+ ),
+ )
+ }
+
+ 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/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..38352cf
--- /dev/null
+++ b/Android/app/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+
+ Camper Monitor
+
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 @@
+
+
+
+
diff --git a/Android/protocol/src/main/kotlin/de/fritob/campermonitor/protocol/ConfiguredDevice.kt b/Android/protocol/src/main/kotlin/de/fritob/campermonitor/protocol/ConfiguredDevice.kt
new file mode 100644
index 0000000..3d58dbd
--- /dev/null
+++ b/Android/protocol/src/main/kotlin/de/fritob/campermonitor/protocol/ConfiguredDevice.kt
@@ -0,0 +1,85 @@
+package de.fritob.campermonitor.protocol
+
+import java.util.UUID
+
+/**
+ * Welche Rolle ein Gerät im Camper spielt. Bestimmt Symbol, Sortierung und
+ * welche Kennzahl als "Hauptwert" auf der Kachel gross dargestellt wird.
+ */
+enum class DeviceRole(val title: String) {
+ CHARGE_BOOSTER("Ladebooster"),
+ SOLAR_CHARGER("Solarladeregler"),
+ BATTERY_MONITOR("Batteriemonitor"),
+ BMS("Batterie / BMS"),
+ FRIDGE("Kühlbox"),
+ LEVELING("Nivellierung");
+
+ /**
+ * Victron-Geräte werden passiv über das Advertisement gelesen, BMS,
+ * Kühlbox und Neigungsmesser brauchen eine echte GATT-Verbindung.
+ */
+ val transport: DeviceTransport
+ get() = when (this) {
+ BMS, FRIDGE, LEVELING -> DeviceTransport.CONNECT
+ else -> DeviceTransport.ADVERTISEMENT
+ }
+}
+
+enum class DeviceTransport {
+ /** Passives Mitlesen der BLE-Werbedaten (Victron Instant Readout). */
+ ADVERTISEMENT,
+
+ /** Verbindungsaufbau, Kommando schreiben, Antwort per Notify lesen. */
+ CONNECT,
+}
+
+/**
+ * Ein eingerichtetes Gerät.
+ *
+ * Die Bluetooth-Adresse ist unter Android ein Text („AA:BB:CC:DD:EE:FF") und
+ * nicht wie unter iOS eine UUID – iOS gibt die Hardware-Adresse gar nicht
+ * heraus, Android schon.
+ */
+data class ConfiguredDevice(
+ val id: UUID = UUID.randomUUID(),
+ val name: String,
+ val role: DeviceRole,
+ val profileID: UUID,
+ /** MAC-Adresse des Geräts. */
+ val address: String,
+ /** Wie das Gerät sich selbst nennt, für die Wiedererkennung. */
+ val advertisedName: String? = null,
+ val fridgeZoneMode: FridgeZoneMode = FridgeZoneMode.AUTOMATIC,
+ val sensorOrientation: SensorOrientation = SensorOrientation.IDENTITY,
+)
+
+/**
+ * Ein Fahrzeug. Jedes Profil hat seinen eigenen Satz Geräte; die App zeigt und
+ * funkt immer nur für das gerade gewählte.
+ */
+data class Profile(
+ val id: UUID = UUID.randomUUID(),
+ val name: String,
+ /** Symbolname für die Auswahl im Dashboard. */
+ val symbol: String = "box_truck",
+ /** Spurweite in Metern – für die Berechnung der Auffahrkeile quer. */
+ val trackWidth: Double? = null,
+ /** Radstand in Metern – dasselbe längs. */
+ val wheelbase: Double? = null,
+) {
+ companion object {
+ /**
+ * Profil, dem Geräte aus der Zeit vor der Profilverwaltung zugeordnet
+ * werden. Feste Kennung, damit die Zuordnung beim Update erhalten bleibt.
+ */
+ val DEFAULT_ID: UUID = UUID.fromString("00000000-0000-0000-0000-00000000c001")
+
+ fun initial() = Profile(id = DEFAULT_ID, name = "Mein Camper")
+
+ /** Auswahl für die Profilbearbeitung. */
+ val SYMBOLS = listOf(
+ "box_truck", "pickup", "bus", "double_decker",
+ "car", "car_side", "tent", "sailboat", "lodge", "mountains",
+ )
+ }
+}