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 @@ + + +