Android: Bluetooth-Schicht und Ablage

Damit steht der erste Teil vollständig: Protokoll, Funk und Ablage
übersetzen sich, die 50 Prüfungen laufen.

Die Funkschicht folgt der iOS-Fassung samt allem, was wir uns dort erst
erarbeiten mussten. Ein eigener Thread für alles, was Bluetooth anfasst;
Android ruft Scan- und GATT-Rückmeldungen auf Binder-Threads auf. Die
Wartesperre nach Fehlschlägen, die zwischen einem Abbruch einer stehenden
Verbindung und einem echten Fehlschlag unterscheidet. Stellbefehle, die
aufgehoben statt verworfen werden, wenn die Kühlbox gerade weg ist. Die
Zwanzig-Byte-Grenze beim Schreiben an die Kühlbox. Die stabile Reihenfolge
der Kandidaten, damit nicht der Zufall die Schreibart bestimmt.

Vier Dinge sind auf Android anders und im Quelltext vermerkt:

Die Herstellerdaten kommen ohne die zwei Bytes der Company-ID - die steht
im Schlüssel der Tabelle. Sie werden wieder davorgesetzt, damit derselbe
Rahmen ankommt wie unter iOS und dieselben Prüfungen gelten.

Notify muss über einen Deskriptor eingeschaltet werden; das Einschalten auf
unserer Seite allein genügt nicht.

Geräte werden über ihre MAC-Adresse angesprochen statt über eine
systemvergebene Kennung - iOS gibt die Adresse gar nicht heraus.

Die Victron-Schlüssel liegen in app-privater Ablage statt im
Schlüsselbund. Das schützt gegen andere Apps, ist aber nicht
hardwaregestützt. Ohne zusätzliche Bibliothek gibt es unter Android kein
gleichwertiges Gegenstück.

Als Nächstes die Oberfläche.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
BiasF
2026-08-31 10:28:53 +02:00
co-authored by Claude Opus 5
parent 0cf8448f34
commit 866fce9711
15 changed files with 2209 additions and 0 deletions
+60
View File
@@ -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.
}
+46
View File
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!--
Ab Android 12 gibt es eigene Bluetooth-Rechte. `neverForLocation` sagt
dem System, dass der Scan nicht dem Ermitteln des Standorts dient - sonst
verlangt Android zusätzlich die Standortfreigabe.
-->
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation"
tools:targetApi="s" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Bis Android 11 lief der Scan über die Standortrechte. -->
<uses-permission
android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.ACCESS_FINE_LOCATION"
android:maxSdkVersion="30" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.CamperMonitor">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.CamperMonitor">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -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") } }
}
}
@@ -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<String> = 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<UUID, DeviceSnapshot>()
val linkStates = mutableStateMapOf<UUID, DeviceLinkState>()
val fridgeStates = mutableStateMapOf<UUID, AlpicoolState>()
val levelStates = mutableStateMapOf<UUID, LevelState>()
val bmsDiagnostics = mutableStateMapOf<UUID, BmsDiagnostics>()
val diagnostics = mutableStateMapOf<UUID, VictronDiagnostics>()
val discoveries = mutableStateMapOf<String, Discovery>()
val history = mutableStateMapOf<UUID, MutableList<HistorySample>>()
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<String, ManagedDevice> = emptyMap()
private val bmsSessions = mutableMapOf<String, BmsSession>()
private val levelSessions = mutableMapOf<String, LevelSession>()
private val connecting = mutableSetOf<String>()
/**
* 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<String, Long>()
private val connectFailures = mutableMapOf<String, Int>()
private val connectedSince = mutableMapOf<String, Long>()
/**
* Stellbefehle, die kamen, während die Verbindung weg war. Ohne das
* verschwindet ein Tippen lautlos.
*/
private val pendingControls = mutableMapOf<String, MutableList<Pair<ByteArray, Long>>>()
/** Letzter bekannter Kühlbox-Zustand, auch über eine Trennung hinweg. */
private val fridgeStateCache = mutableMapOf<String, AlpicoolState>()
private val lastHandledAdvertisement = mutableMapOf<String, Long>()
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<ManagedDevice> = 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<ManagedDevice>) {
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<ScanResult>) {
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]
}
}
@@ -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<Int, Int>? = 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<String> = 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<Endpoint> = 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<String>()
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<Pair<ByteArray, Long>>()
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<Endpoint>()
val summary = mutableListOf<String>()
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<String>()
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<ByteArray>()
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<ByteArray>, 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) }
@@ -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()
}
}
}
}
@@ -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<Profile>()
val devices = mutableStateListOf<ConfiguredDevice>()
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<ConfiguredDevice> {
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()
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Camper Monitor</string>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.CamperMonitor" parent="android:Theme.Material.NoActionBar" />
</resources>
@@ -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",
)
}
}