first commit

This commit is contained in:
fototeddy
2026-09-04 21:30:51 +02:00
commit da64b1b6bd
160 changed files with 20664 additions and 0 deletions
+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,147 @@
package de.fritob.campermonitor
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.Composable
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import de.fritob.campermonitor.bluetooth.BluetoothManager
import de.fritob.campermonitor.store.DemoData
import de.fritob.campermonitor.store.DeviceStore
import de.fritob.campermonitor.ui.AddDeviceScreen
import de.fritob.campermonitor.ui.AlignmentAssistantScreen
import de.fritob.campermonitor.ui.CamperTheme
import de.fritob.campermonitor.ui.DashboardScreen
import de.fritob.campermonitor.ui.DeviceDetailScreen
import de.fritob.campermonitor.ui.LevelSetupScreen
import de.fritob.campermonitor.ui.ProfilesScreen
import de.fritob.campermonitor.ui.SensorSetupScreen
import de.fritob.campermonitor.ui.SettingsScreen
import de.fritob.campermonitor.ui.VictronKeyScreen
import de.fritob.campermonitor.ui.WithBluetoothPermission
import java.util.UUID
class MainActivity : ComponentActivity() {
private lateinit var store: DeviceStore
private lateinit var bluetooth: BluetoothManager
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
store = DeviceStore(applicationContext)
bluetooth = BluetoothManager(applicationContext, store)
// Demo-Modus für den Emulator, wo es kein Bluetooth gibt:
// adb shell am start -n de.fritob.campermonitor/.MainActivity --ez demo true
val demo = BuildConfig.DEBUG && intent?.getBooleanExtra("demo", false) == true
if (demo) DemoData.install(store, bluetooth)
setContent {
CamperTheme {
if (demo) {
CamperNavigation(store, bluetooth)
return@CamperTheme
}
WithBluetoothPermission {
// Erst wenn die Rechte da sind, darf der Scan starten -
// ohne sie wirft Android beim Scannen.
androidx.compose.runtime.LaunchedEffect(Unit) { bluetooth.start() }
CamperNavigation(store, bluetooth)
}
}
}
}
override fun onDestroy() {
super.onDestroy()
bluetooth.stopEverything()
}
}
@Composable
private fun CamperNavigation(store: DeviceStore, bluetooth: BluetoothManager) {
val navController = rememberNavController()
/**
* Der Weg vom Navigationsargument zurück zum aktuellen Gerät. Bewusst ohne
* Zwischenspeicher: nach einer Umbenennung soll der neue Stand gelten.
*/
fun device(id: String?) = store.devices.firstOrNull { it.id.toString() == id }
NavHost(navController = navController, startDestination = "dashboard") {
composable("dashboard") {
DashboardScreen(
store = store,
bluetooth = bluetooth,
onOpenDevice = { navController.navigate("device/${it.id}") },
onAddDevice = { navController.navigate("add") },
onOpenProfiles = { navController.navigate("profiles") },
onOpenSettings = { navController.navigate("settings") },
)
}
composable("device/{id}") { entry ->
device(entry.arguments?.getString("id"))?.let {
DeviceDetailScreen(
device = it,
store = store,
bluetooth = bluetooth,
onBack = { navController.popBackStack() },
onOpenKey = { navController.navigate("key/${it.id}") },
onOpenLevelSetup = { navController.navigate("levelsetup/${it.id}") },
onOpenAssistant = { navController.navigate("assistant/${it.id}") },
)
}
}
composable("key/{id}") { entry ->
device(entry.arguments?.getString("id"))?.let {
VictronKeyScreen(it, store, bluetooth) { navController.popBackStack() }
}
}
composable("levelsetup/{id}") { entry ->
device(entry.arguments?.getString("id"))?.let {
LevelSetupScreen(
device = it,
store = store,
bluetooth = bluetooth,
onBack = { navController.popBackStack() },
onOpenSensorSetup = { navController.navigate("sensorsetup/${it.id}") },
)
}
}
composable("sensorsetup/{id}") { entry ->
device(entry.arguments?.getString("id"))?.let {
SensorSetupScreen(it, store, bluetooth) { navController.popBackStack() }
}
}
composable("assistant/{id}") { entry ->
device(entry.arguments?.getString("id"))?.let {
AlignmentAssistantScreen(it, store, bluetooth) { navController.popBackStack() }
}
}
composable("add") {
AddDeviceScreen(store, bluetooth) { navController.popBackStack() }
}
composable("profiles") {
ProfilesScreen(store, bluetooth) { navController.popBackStack() }
}
composable("settings") {
SettingsScreen(store) { navController.popBackStack() }
}
}
}
/** Kleine Hilfe, damit Bildschirme das Gerät über seine Kennung finden. */
internal fun DeviceStore.device(id: UUID) = devices.firstOrNull { it.id == id }
@@ -0,0 +1,690 @@
package de.fritob.campermonitor.bluetooth
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import android.os.Handler
import android.os.HandlerThread
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import de.fritob.campermonitor.protocol.AlpicoolProtocol
import de.fritob.campermonitor.protocol.AlpicoolState
import de.fritob.campermonitor.protocol.ConfiguredDevice
import de.fritob.campermonitor.protocol.DeviceLinkState
import de.fritob.campermonitor.protocol.DeviceRole
import de.fritob.campermonitor.protocol.DeviceSnapshot
import de.fritob.campermonitor.protocol.DeviceTransport
import de.fritob.campermonitor.protocol.FridgeZone
import de.fritob.campermonitor.protocol.FridgeZoneMode
import de.fritob.campermonitor.protocol.LevelState
import de.fritob.campermonitor.protocol.SensorOrientation
import de.fritob.campermonitor.protocol.VictronAdvertisement
import de.fritob.campermonitor.store.DeviceStore
import java.util.UUID
import kotlin.math.min
import kotlin.math.pow
/** Was beim Einrichten in der Geräteliste steht. */
data class Discovery(
val address: String,
val name: String?,
val rssi: Int,
val firstSeen: Long,
val lastSeen: Long,
val isVictron: Boolean,
val victronRecord: String? = null,
val serviceUUIDs: List<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
}
/** Ob die Funkschicht stillgelegt ist, weil Demo-Daten angezeigt werden. */
private var isDemo = false
/**
* Nur für den Demo-Modus: fertige Werte einsetzen und nicht funken. So
* lassen sich die Ansichten ohne Fahrzeug prüfen.
*/
fun installDemo(
snapshots: List<DeviceSnapshot>,
fridge: Pair<UUID, AlpicoolState>,
level: Pair<UUID, LevelState>,
history: Map<UUID, List<Double>>,
) {
isDemo = true
isBluetoothReady = true
bluetoothStatusText = "Demo-Daten"
snapshots.forEach {
this.snapshots[it.deviceID] = it
linkStates[it.deviceID] = DeviceLinkState.Live
}
fridgeStates[fridge.first] = fridge.second
levelStates[level.first] = level.second
history.forEach { (id, values) ->
this.history[id] = values.map { HistorySample(System.currentTimeMillis(), it) }
.toMutableList()
}
}
// MARK: - Start
fun start() {
if (isDemo) return
handler.post {
refreshAdapterState()
applyConfiguration(currentDevices())
}
}
fun refreshConfiguration() {
handler.post { applyConfiguration(currentDevices()) }
}
private fun currentDevices(): List<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 } },
onDeviceOrientation = { orientation ->
// Im Gerät steht, wie der Sensor eingebaut ist für alle
// Clients dasselbe. Also übernehmen statt überschreiben.
adoptOrientation(orientation, device.id)
},
)
session.orientation = device.sensorOrientation
levelSessions[device.address] = session
session.start()
return
}
val session = BmsSession(
deviceID = device.id,
gattProvider = provider,
handler = handler,
onUpdate = { record(it) },
onStateChange = { handleLinkState(device, it) },
onDiagnostics = { info -> publish { bmsDiagnostics[device.id] = info } },
)
session.onFridgeState = { state ->
fridgeStateCache[device.address] = state
publish { fridgeStates[device.id] = state }
}
session.fridgeZoneMode = device.fridgeZoneMode
bmsSessions[device.address] = session
session.start()
flushPendingControls(device.address, session)
}
/**
* Wertet aus, was eine Sitzung über ihren Verbindungszustand meldet, und
* pflegt daraus die Wartesperren.
*/
private fun handleLinkState(device: ManagedDevice, state: DeviceLinkState) {
when (state) {
is DeviceLinkState.Live -> {
if (device.address !in connectedSince) {
connectedSince[device.address] = System.currentTimeMillis()
}
connecting.remove(device.address)
}
is DeviceLinkState.Searching -> {
// Getrennt. Wie lange die Verbindung stand, entscheidet, wie
// lange gewartet wird.
val lifetime = connectedSince.remove(device.address)
?.let { System.currentTimeMillis() - it } ?: 0
connecting.remove(device.address)
bmsSessions.remove(device.address)
levelSessions.remove(device.address)
if (lifetime >= STABLE_CONNECTION_MS) {
// Die Verbindung stand und ist weggefallen im Fahrzeug
// der Normalfall. Kurz durchatmen, dann wieder ran, sonst
// wäre das Gerät minutenlang nicht bedienbar.
clearBackOff(device.address)
nextConnectAttempt[device.address] =
System.currentTimeMillis() + QUICK_RETRY_DELAY_MS
} else {
// Sofort wieder abgebrochen: das zählt wie ein Fehlschlag.
backOff(device.address)
}
}
is DeviceLinkState.Failed -> {
connecting.remove(device.address)
bmsSessions.remove(device.address)
levelSessions.remove(device.address)
backOff(device.address)
}
else -> Unit
}
publish { linkStates[device.id] = state }
}
/**
* Sperrt weitere Versuche für eine Weile. Der Abstand wächst mit jedem
* Fehlschlag, damit ein dauerhaft unerreichbares Gerät nicht endlos
* angeklopft wird.
*/
private fun backOff(address: String) {
val failures = (connectFailures[address] ?: 0) + 1
connectFailures[address] = failures
val delay = min(
FIRST_RETRY_DELAY_MS * 2.0.pow(failures - 1),
LONGEST_RETRY_DELAY_MS.toDouble(),
)
nextConnectAttempt[address] = System.currentTimeMillis() + delay.toLong()
}
private fun clearBackOff(address: String) {
connectFailures.remove(address)
nextConnectAttempt.remove(address)
}
// MARK: - Scan
private val scanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
handler.post { handleScanResult(result) }
}
override fun onBatchScanResults(results: MutableList<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
/**
* Nach einer Änderung der Einbaulage aufrufen.
*
* Die Lage wandert zusätzlich ins Gerät. Dort gehört sie hin: Sie
* beschreibt den Einbau, und iPhone wie Uhr finden sie dann vor, ohne dass
* jemand sie ein zweites Mal bestimmen muss.
*/
fun updateSensorOrientation(device: ConfiguredDevice) {
handler.post {
val session = levelSessions[device.address] ?: return@post
session.orientation = device.sensorOrientation
session.storeOrientation(device.sensorOrientation)
}
}
/**
* Übernimmt die Einbaulage, die das Gerät meldet.
*
* Steht dort noch nichts, meldet das Gerät nichts dann bleibt es bei der
* örtlich gespeicherten Fassung, und die wird beim nächsten Bestimmen
* hinaufgeschrieben.
*/
private fun adoptOrientation(orientation: SensorOrientation, deviceID: UUID) {
val device = store.devices.firstOrNull { it.id == deviceID } ?: return
if (device.sensorOrientation == orientation) return
store.update(device.copy(sensorOrientation = orientation))
}
fun calibrateLevel(deviceID: UUID) {
handler.post { levelSession(deviceID)?.calibrate() }
}
fun resetLevelCalibration(deviceID: UUID) {
handler.post { levelSession(deviceID)?.resetCalibration() }
}
private fun levelSession(deviceID: UUID): LevelSession? {
val device = managed.values.firstOrNull { it.id == deviceID } ?: return null
return levelSessions[device.address]
}
}
@@ -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,320 @@
package de.fritob.campermonitor.bluetooth
import android.annotation.SuppressLint
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothProfile
import android.os.Handler
import de.fritob.campermonitor.protocol.DeviceLinkState
import de.fritob.campermonitor.protocol.DeviceSnapshot
import de.fritob.campermonitor.protocol.LevelState
import de.fritob.campermonitor.protocol.SensorOrientation
import de.fritob.campermonitor.protocol.VanAlignProtocol
import java.util.UUID
/**
* Verbindung zum Neigungsmesser „VanAlign Pro".
*
* Anders als beim BMS gibt es hier kein Rahmenprotokoll: Längs- und
* Querneigung liegen in je einer eigenen Charakteristik als 32-Bit-Float.
* Meldet die Firmware sie per Notify, wird abonniert; sonst wird in Abständen
* gelesen. Ältere Stände können beides nicht gleichzeitig.
*/
@SuppressLint("MissingPermission")
class LevelSession(
private val deviceID: UUID,
private val gattProvider: (BluetoothGattCallback) -> BluetoothGatt?,
private val handler: Handler,
private val onUpdate: (DeviceSnapshot) -> Unit,
private val onStateChange: (DeviceLinkState) -> Unit,
private val onLevelState: (LevelState) -> Unit,
/**
* Meldet die Einbaulage, die im Gerät steht. Sie gilt vor der örtlich
* gespeicherten: dort steht, wie der Sensor eingebaut ist, und das ist für
* alle Clients dasselbe.
*/
private val onDeviceOrientation: (SensorOrientation) -> Unit = {},
) {
private companion object {
val SERVICE: UUID = UUID.fromString(VanAlignProtocol.SERVICE_UUID)
val PITCH: UUID = UUID.fromString(VanAlignProtocol.PITCH_UUID)
val ROLL: UUID = UUID.fromString(VanAlignProtocol.ROLL_UUID)
val OFFSETS: UUID = UUID.fromString(VanAlignProtocol.OFFSETS_UUID)
val CALIBRATE: UUID = UUID.fromString(VanAlignProtocol.CALIBRATE_UUID)
val ORIENTATION: UUID = UUID.fromString(VanAlignProtocol.ORIENTATION_UUID)
val CLIENT_CONFIG: UUID = UUID.fromString("00002902-0000-1000-8000-00805F9B34FB")
const val POLL_INTERVAL_MS = 1_000L
}
/** Die Einbaulage des Sensors; wird auf jede Messung angewandt. */
var orientation: SensorOrientation = SensorOrientation.IDENTITY
set(value) {
field = value
applyOrientation()
}
private var gatt: BluetoothGatt? = null
private var pitchCharacteristic: BluetoothGattCharacteristic? = null
private var rollCharacteristic: BluetoothGattCharacteristic? = null
private var offsetsCharacteristic: BluetoothGattCharacteristic? = null
private var calibrateCharacteristic: BluetoothGattCharacteristic? = null
private var orientationCharacteristic: BluetoothGattCharacteristic? = null
/** Ob die Firmware die Werte von sich aus meldet. */
private var needsPolling = true
private var pollRunnable: Runnable? = null
private var state = LevelState()
fun start() {
onStateChange(DeviceLinkState.Connecting)
gatt = gattProvider(callback)
}
fun stop() {
cancelPoll()
gatt?.close()
gatt = null
clearCharacteristics()
}
fun handleDisconnect() {
cancelPoll()
gatt?.close()
gatt = null
clearCharacteristics()
}
private fun clearCharacteristics() {
pitchCharacteristic = null
rollCharacteristic = null
offsetsCharacteristic = null
calibrateCharacteristic = null
orientationCharacteristic = null
}
/** Setzt die aktuelle Lage als neue Null. */
fun calibrate() {
write(VanAlignProtocol.calibrateCommand)
}
/** Verwirft die Kalibrierung. */
fun resetCalibration() {
write(VanAlignProtocol.resetCommand)
}
@Suppress("DEPRECATION")
private fun write(data: ByteArray) {
val characteristic = calibrateCharacteristic ?: return
val g = gatt ?: return
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
characteristic.value = data
g.writeCharacteristic(characteristic)
// Die Offsets ändern sich dadurch kurz darauf neu lesen.
handler.postDelayed({ readOffsets() }, 400)
}
/**
* Schreibt die Einbaulage ins Gerät, damit alle Clients dieselbe sehen.
*
* Ältere Firmware hat die Charakteristik nicht; dann bleibt es bei der
* örtlich gespeicherten Fassung, und es passiert schlicht nichts.
*/
fun storeOrientation(value: SensorOrientation) {
val characteristic = orientationCharacteristic ?: return
val g = gatt ?: return
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
characteristic.value = VanAlignProtocol.encoded(value)
g.writeCharacteristic(characteristic)
// Zurücklesen, damit gilt, was wirklich im Gerät steht.
handler.postDelayed({ gatt?.readCharacteristic(characteristic) }, 300)
}
private fun readOffsets() {
val characteristic = offsetsCharacteristic ?: return
gatt?.readCharacteristic(characteristic)
}
private fun cancelPoll() {
pollRunnable?.let { handler.removeCallbacks(it) }
pollRunnable = null
}
private fun startPollingIfNeeded() {
if (!needsPolling || pollRunnable != null) return
val runnable = object : Runnable {
override fun run() {
// Android verträgt nur eine ausstehende GATT-Anfrage; deshalb
// erst die eine lesen, die andere folgt in der Rückmeldung.
pitchCharacteristic?.let { gatt?.readCharacteristic(it) }
handler.postDelayed(this, POLL_INTERVAL_MS)
}
}
pollRunnable = runnable
handler.post(runnable)
}
private fun applyOrientation() {
val (pitch, roll) = orientation.apply(state.rawPitch, state.rawRoll)
state = state.copy(pitch = pitch, roll = roll)
}
private fun publish() {
onLevelState(state)
if (state.hasReading) {
onStateChange(DeviceLinkState.Live)
onUpdate(state.snapshot(deviceID, null))
}
}
private val callback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
handler.post {
if (newState == BluetoothProfile.STATE_CONNECTED) {
gatt = g
g.discoverServices()
} else {
onStateChange(DeviceLinkState.Searching)
handleDisconnect()
}
}
}
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
handler.post {
val service = g.getService(SERVICE)
if (service == null) {
onStateChange(DeviceLinkState.Failed("Kein VanAlign-Dienst gefunden"))
return@post
}
pitchCharacteristic = service.getCharacteristic(PITCH)
rollCharacteristic = service.getCharacteristic(ROLL)
offsetsCharacteristic = service.getCharacteristic(OFFSETS)
calibrateCharacteristic = service.getCharacteristic(CALIBRATE)
orientationCharacteristic = service.getCharacteristic(ORIENTATION)
val canNotify = pitchCharacteristic?.let {
it.properties and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0
} ?: false
if (canNotify) {
needsPolling = false
subscribe(g, pitchCharacteristic)
} else {
// Ältere Firmware meldet nichts von sich aus.
needsPolling = true
readOffsets()
}
}
}
private fun subscribe(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic?) {
val c = characteristic ?: return
g.setCharacteristicNotification(c, true)
c.getDescriptor(CLIENT_CONFIG)?.let {
it.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
g.writeDescriptor(it)
}
}
override fun onDescriptorWrite(
g: BluetoothGatt,
descriptor: BluetoothGattDescriptor,
status: Int,
) {
handler.post {
when (descriptor.characteristic.uuid) {
PITCH -> subscribe(g, rollCharacteristic)
ROLL -> readOffsets()
}
}
}
@Suppress("DEPRECATION")
override fun onCharacteristicRead(
g: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
status: Int,
) {
val value = characteristic.value ?: return
handler.post { handleValue(characteristic.uuid, value, wasRead = true) }
}
override fun onCharacteristicRead(
g: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray,
status: Int,
) {
handler.post { handleValue(characteristic.uuid, value, wasRead = true) }
}
@Suppress("DEPRECATION")
override fun onCharacteristicChanged(
g: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
) {
val value = characteristic.value ?: return
handler.post { handleValue(characteristic.uuid, value, wasRead = false) }
}
override fun onCharacteristicChanged(
g: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray,
) {
handler.post { handleValue(characteristic.uuid, value, wasRead = false) }
}
}
private fun handleValue(uuid: UUID, value: ByteArray, wasRead: Boolean) {
when (uuid) {
PITCH -> {
state = state.copy(rawPitch = VanAlignProtocol.angle(value))
applyOrientation()
publish()
// Beim Abfragen folgt jetzt die zweite Achse.
if (wasRead && needsPolling) {
rollCharacteristic?.let { gatt?.readCharacteristic(it) }
}
}
ROLL -> {
state = state.copy(rawRoll = VanAlignProtocol.angle(value))
applyOrientation()
publish()
}
OFFSETS -> {
VanAlignProtocol.offsets(value)?.let { (pitch, roll) ->
state = state.copy(pitchOffset = pitch, rollOffset = roll)
}
publish()
// Android lässt nur eine Abfrage gleichzeitig zu, deshalb der
// Reihe nach: erst die Offsets, dann die Einbaulage, dann der
// Takt. Fehlt die Charakteristik ältere Firmware , geht es
// sofort weiter.
val characteristic = orientationCharacteristic
if (characteristic != null) {
gatt?.readCharacteristic(characteristic)
} else {
startPollingIfNeeded()
}
}
ORIENTATION -> {
// Was im Gerät steht, gilt. Steht dort nichts (Version 0),
// bleibt es bei der örtlichen Fassung, und der Aufrufer
// schreibt sie hinauf.
VanAlignProtocol.orientation(value)?.let { stored ->
if (stored != orientation) orientation = stored
onDeviceOrientation(stored)
}
startPollingIfNeeded()
}
}
}
}
@@ -0,0 +1,130 @@
package de.fritob.campermonitor.store
import de.fritob.campermonitor.bluetooth.BluetoothManager
import de.fritob.campermonitor.protocol.AlpicoolProtocol
import de.fritob.campermonitor.protocol.AlpicoolState
import de.fritob.campermonitor.protocol.ConfiguredDevice
import de.fritob.campermonitor.protocol.DeviceLinkState
import de.fritob.campermonitor.protocol.DeviceRole
import de.fritob.campermonitor.protocol.DeviceSnapshot
import de.fritob.campermonitor.protocol.LevelState
import de.fritob.campermonitor.protocol.Metric
import de.fritob.campermonitor.protocol.Profile
import java.util.UUID
import kotlin.math.sin
/**
* Füllt die App mit erfundenen Messwerten, damit sich die Ansichten ohne
* Fahrzeug und ohne Bluetooth prüfen lassen.
*
* Nur in Debug-Bauten und nur, wenn beim Start das Extra `demo` gesetzt ist:
* ```
* adb shell am start -n de.fritob.campermonitor/.MainActivity --ez demo true
* ```
* Im normalen Betrieb wird hiervon nichts ausgeführt.
*/
object DemoData {
private val profileID = Profile.DEFAULT_ID
private val secondProfileID = UUID.fromString("00000000-0000-0000-0000-0000000000c2")
private fun id(suffix: String) = UUID.fromString("00000000-0000-0000-0000-0000000000$suffix")
val profiles = listOf(
Profile(profileID, "Kastenwagen", "box_truck", trackWidth = 1.85, wheelbase = 3.50),
Profile(secondProfileID, "Wohnwagen", "car_side"),
)
val devices = listOf(
ConfiguredDevice(id("50"), "Solar Dach", DeviceRole.SOLAR_CHARGER, profileID, "00:00:00:00:00:50"),
ConfiguredDevice(id("b0"), "Ladebooster", DeviceRole.CHARGE_BOOSTER, profileID, "00:00:00:00:00:b0"),
ConfiguredDevice(id("c0"), "Bulltron 200 Ah", DeviceRole.BMS, profileID, "00:00:00:00:00:c0"),
ConfiguredDevice(id("d0"), "Kühlbox", DeviceRole.FRIDGE, profileID, "00:00:00:00:00:d0"),
ConfiguredDevice(id("e0"), "Nivellierung", DeviceRole.LEVELING, profileID, "00:00:00:00:00:e0"),
)
fun install(store: DeviceStore, bluetooth: BluetoothManager) {
store.loadDemo(profiles, devices)
val now = System.currentTimeMillis()
fun snapshot(deviceID: UUID, state: String?, vararg metrics: Metric) =
DeviceSnapshot(deviceID, now, metrics.toList(), state = state)
bluetooth.installDemo(
snapshots = listOf(
snapshot(
id("50"), "Konstantspannung (Absorption)",
Metric("pv_power", "PV-Leistung", 284.0, "W", 0, isPrimary = true),
Metric("battery_power", "Ladeleistung", 262.0, "W", 0),
Metric("battery_voltage", "Batteriespannung", 14.12, "V", 2),
Metric("battery_current", "Ladestrom", 18.6, "A", 1),
Metric("yield_today", "Ertrag heute", 1.84, "kWh", 2),
),
snapshot(
id("b0"), "Aus",
Metric("output_voltage", "Ausgang (Aufbaubatterie)", 14.09, "V", 2, isPrimary = true),
Metric("input_voltage", "Eingang (Starterbatterie)", 12.42, "V", 2),
).copy(offReasons = listOf("Keine Eingangsspannung")),
snapshot(
id("c0"), "Lädt",
Metric("soc", "Ladezustand", 78.4, "%", 1, isPrimary = true),
Metric("voltage", "Spannung", 13.66, "V", 2),
Metric("current", "Strom", 18.6, "A", 1),
Metric("power", "Leistung", 254.0, "W", 0),
Metric("cell_delta", "Zell-Differenz", 18.0, "mV", 0),
).copy(cellVoltages = listOf(3.412, 3.418, 3.409, 3.427)),
snapshot(
id("d0"), "Eco",
Metric("temp_left", "Temperatur", 6.0, "°C", 0, isPrimary = true),
Metric("target_left", "Solltemperatur", 4.0, "°C", 0),
Metric("supply_voltage", "Bordspannung", 12.7, "V", 1),
Metric("battery_percent", "Batterieanzeige", 87.0, "%", 0),
),
snapshot(
id("e0"), "Heck steht höher, links steht höher",
Metric("pitch", "Längsneigung", 1.8, "°", 1, isPrimary = true),
Metric("roll", "Querneigung", -0.9, "°", 1),
),
),
fridge = id("d0") to demoFridge(),
level = id("e0") to LevelState(
pitch = 1.8, roll = -0.9,
rawPitch = 1.8, rawRoll = -0.9,
pitchOffset = 0.4, rollOffset = -0.2,
),
history = devices.associate { device ->
device.id to List(48) { step ->
val base = when (device.role) {
DeviceRole.SOLAR_CHARGER -> 250.0
DeviceRole.LEVELING -> 1.8
DeviceRole.BMS -> 78.0
DeviceRole.FRIDGE -> 6.0
else -> 14.0
}
base + sin(step / 6.0) * base * 0.12
}
},
)
}
private fun demoFridge(): AlpicoolState {
// Ein echter Einzonen-Datensatz, wie ihn die Box im Fahrzeug schickt.
val payload = byteArrayOf(
0x00, 0x01, 0x01, 0x02, 0x04, 0x14, 0xEC.toByte(), 0x02, 0x00, 0x00,
0x00, 0x00, 0xFD.toByte(), 0x00,
0x06, 0x57, 0x0C, 0x07,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x80.toByte(), 0x00, 0x01, 0x00,
)
val state = AlpicoolState()
val (frames, _) = AlpicoolProtocol.extractFrames(demoResponse(payload))
frames.forEach { state.apply(it) }
return state
}
private fun demoResponse(payload: ByteArray): ByteArray {
val head = byteArrayOf(0xFE.toByte(), 0xFE.toByte(), (payload.size + 3).toByte(), 0x01) + payload
val sum = AlpicoolProtocol.checksum(head)
return head + byteArrayOf((sum shr 8).toByte(), (sum and 0xFF).toByte())
}
}
@@ -0,0 +1,247 @@
package de.fritob.campermonitor.store
import android.content.Context
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import de.fritob.campermonitor.protocol.ConfiguredDevice
import de.fritob.campermonitor.protocol.DeviceRole
import de.fritob.campermonitor.protocol.FridgeZoneMode
import de.fritob.campermonitor.protocol.Profile
import de.fritob.campermonitor.protocol.SensorOrientation
import org.json.JSONArray
import org.json.JSONObject
import java.util.UUID
/**
* Hält Fahrzeuge und Geräte und legt sie ab.
*
* **Unterschied zur iOS-Fassung:** Dort liegen die Victron-Schlüssel im
* Schlüsselbund. Android hat kein direktes Gegenstück, das ohne zusätzliche
* Bibliothek auskommt; sie liegen deshalb in einer eigenen, app-privaten
* Ablage. Das schützt gegen andere Apps, aber anders als der Schlüsselbund
* nicht hardwaregestützt.
*/
class DeviceStore(context: Context) {
private val prefs = context.getSharedPreferences("camper", Context.MODE_PRIVATE)
private val keyPrefs = context.getSharedPreferences("camper_keys", Context.MODE_PRIVATE)
val profiles = mutableStateListOf<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()
}
/** Im Demo-Modus wird nichts abgelegt die echten Daten bleiben unberührt. */
private var isDemo = false
init {
load()
}
/** Nur für den Demo-Modus: Fahrzeuge und Geräte setzen, ohne zu speichern. */
fun loadDemo(demoProfiles: List<Profile>, demoDevices: List<ConfiguredDevice>) {
isDemo = true
profiles.clear(); profiles.addAll(demoProfiles)
devices.clear(); devices.addAll(demoDevices)
activeProfileID = demoProfiles.first().id
}
val activeProfile: Profile?
get() = profiles.firstOrNull { it.id == activeProfileID } ?: profiles.firstOrNull()
fun activeDevices(): List<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() {
if (isDemo) return
val root = JSONObject()
root.put("profiles", JSONArray().also { array ->
profiles.forEach { array.put(profileTo(it)) }
})
root.put("devices", JSONArray().also { array ->
devices.forEach { array.put(deviceTo(it)) }
})
root.put("activeProfile", activeProfileID.toString())
prefs.edit().putString("state", root.toString()).apply()
}
private fun profileTo(profile: Profile) = JSONObject().apply {
put("id", profile.id.toString())
put("name", profile.name)
put("symbol", profile.symbol)
profile.trackWidth?.let { put("trackWidth", it) }
profile.wheelbase?.let { put("wheelbase", it) }
}
private fun profileFrom(json: JSONObject) = Profile(
id = UUID.fromString(json.getString("id")),
name = json.getString("name"),
symbol = json.optString("symbol", "box_truck"),
// Fahrzeuge aus der Zeit vor den Massen haben noch keine.
trackWidth = if (json.has("trackWidth")) json.getDouble("trackWidth") else null,
wheelbase = if (json.has("wheelbase")) json.getDouble("wheelbase") else null,
)
private fun deviceTo(device: ConfiguredDevice) = JSONObject().apply {
put("id", device.id.toString())
put("name", device.name)
put("role", device.role.name)
put("profileID", device.profileID.toString())
put("address", device.address)
device.advertisedName?.let { put("advertisedName", it) }
put("fridgeZoneMode", device.fridgeZoneMode.name)
put("orientationSource", device.sensorOrientation.longitudinalSource.name)
put("invertLongitudinal", device.sensorOrientation.invertLongitudinal)
put("invertLateral", device.sensorOrientation.invertLateral)
put("twist", device.sensorOrientation.twist)
}
private fun deviceFrom(json: JSONObject): ConfiguredDevice? {
val role = runCatching { DeviceRole.valueOf(json.getString("role")) }.getOrNull()
?: return null
return ConfiguredDevice(
id = UUID.fromString(json.getString("id")),
name = json.getString("name"),
role = role,
profileID = UUID.fromString(json.getString("profileID")),
address = json.getString("address"),
advertisedName = json.optString("advertisedName").takeIf { it.isNotEmpty() },
fridgeZoneMode = runCatching {
FridgeZoneMode.valueOf(json.optString("fridgeZoneMode"))
}.getOrDefault(FridgeZoneMode.AUTOMATIC),
sensorOrientation = SensorOrientation(
longitudinalSource = runCatching {
SensorOrientation.Source.valueOf(json.optString("orientationSource"))
}.getOrDefault(SensorOrientation.Source.PITCH),
invertLongitudinal = json.optBoolean("invertLongitudinal", false),
invertLateral = json.optBoolean("invertLateral", false),
// Fehlt der Wert, ist er null so überleben Einrichtungen aus
// der Zeit vor der Verdrehung.
twist = json.optDouble("twist", 0.0),
),
)
}
private fun hexToBytes(text: String): ByteArray? {
val cleaned = text.filter { !it.isWhitespace() }
if (cleaned.length % 2 != 0) return null
return runCatching {
ByteArray(cleaned.length / 2) {
cleaned.substring(it * 2, it * 2 + 2).toInt(16).toByte()
}
}.getOrNull()
}
}
@@ -0,0 +1,255 @@
package de.fritob.campermonitor.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import de.fritob.campermonitor.bluetooth.BluetoothManager
import de.fritob.campermonitor.bluetooth.Discovery
import de.fritob.campermonitor.protocol.ConfiguredDevice
import de.fritob.campermonitor.protocol.DeviceRole
import de.fritob.campermonitor.store.DeviceStore
/**
* Die Liste der gefundenen Geräte.
*
* Sortiert wird **nicht** nach Signalstärke, sondern nach der Reihenfolge des
* Auftauchens. Nach dem Pegel zu sortieren macht die Liste unbenutzbar: sie
* springt dann sekündlich, und man trifft das gesuchte Gerät nicht.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddDeviceScreen(store: DeviceStore, bluetooth: BluetoothManager, onBack: () -> Unit) {
var showAll by remember { mutableStateOf(false) }
var selected by remember { mutableStateOf<Discovery?>(null) }
DisposableEffect(Unit) {
bluetooth.setDiscovering(true)
onDispose { bluetooth.setDiscovering(false) }
}
val alreadyAdded = store.activeDevices().map { it.address }.toSet()
val found = bluetooth.discoveries.values
.filter { showAll || it.looksLikeSupported || it.name != null }
.sortedWith(
compareByDescending<Discovery> { it.isVictron }
.thenByDescending { it.looksLikeSupported }
.thenBy { it.firstSeen }
)
Scaffold(
topBar = {
TopAppBar(
title = { Text("Gerät für ${store.activeProfile?.name ?: "Camper"}") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
}
},
)
},
) { padding ->
LazyColumn(modifier = Modifier.fillMaxSize().padding(padding)) {
item {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterChip(
selected = !showAll,
onClick = { showAll = false },
label = { Text("Passende") },
)
FilterChip(
selected = showAll,
onClick = { showAll = true },
label = { Text("Alle") },
)
}
}
items(found, key = { it.address }) { discovery ->
val added = discovery.address in alreadyAdded
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = !added) { selected = discovery }
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
discovery.name ?: "Ohne Namen",
style = MaterialTheme.typography.bodyLarge,
color = if (added) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurface
},
)
Text(
buildString {
append(discovery.address)
discovery.victronRecord?.let { append(" · Victron $it") }
append(" · ${discovery.rssi} dBm")
},
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (added) {
Text("schon dabei", style = MaterialTheme.typography.labelMedium)
}
}
}
item {
Text(
"Victron-Geräte werden automatisch erkannt. Damit sie hier " +
"auftauchen, muss „Instant Readout“ in VictronConnect aktiv " +
"sein. Das BMS meldet sich meist als „DL-…“. Kühlboxen tragen " +
"oft einen kryptischen Namen findest du dein Gerät nicht, auf " +
"„Alle“ umschalten.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp),
)
}
}
}
selected?.let { discovery ->
ConfigureDeviceDialog(
discovery = discovery,
onDismiss = { selected = null },
onAdd = { name, role ->
store.update(
ConfiguredDevice(
name = name,
role = role,
profileID = store.activeProfile?.id ?: return@ConfigureDeviceDialog,
address = discovery.address,
advertisedName = discovery.name,
)
)
bluetooth.refreshConfiguration()
selected = null
onBack()
},
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ConfigureDeviceDialog(
discovery: Discovery,
onDismiss: () -> Unit,
onAdd: (String, DeviceRole) -> Unit,
) {
// Ein Vorschlag, der meistens passt geraten wird nur, was sich aus dem
// Advertisement ablesen lässt.
val suggestedRole = remember(discovery) { guessRole(discovery) }
var role by remember { mutableStateOf(suggestedRole) }
var name by remember { mutableStateOf(discovery.name ?: suggestedRole.title) }
var expanded by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Gerät einrichten") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") },
singleLine = true,
)
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it },
) {
OutlinedTextField(
value = role.title,
onValueChange = {},
readOnly = true,
label = { Text("Art") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
modifier = Modifier.menuAnchor(
androidx.compose.material3.MenuAnchorType.PrimaryNotEditable, true
),
)
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DeviceRole.entries.forEach { candidate ->
DropdownMenuItem(
text = { Text(candidate.title) },
onClick = { role = candidate; expanded = false },
)
}
}
}
Text(
discovery.address,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
confirmButton = {
Button(onClick = { onAdd(name.trim().ifEmpty { role.title }, role) }) {
Text("Hinzufügen")
}
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Abbrechen") } },
)
}
/** Was sich aus Namen und Advertisement ableiten lässt. */
private fun guessRole(discovery: Discovery): DeviceRole {
val name = discovery.name?.lowercase() ?: ""
return when {
discovery.victronRecord == "SOLAR_CHARGER" -> DeviceRole.SOLAR_CHARGER
discovery.victronRecord == "BATTERY_MONITOR" -> DeviceRole.BATTERY_MONITOR
discovery.victronRecord == "DCDC_CONVERTER" -> DeviceRole.CHARGE_BOOSTER
discovery.victronRecord == "ORION_XS" -> DeviceRole.CHARGE_BOOSTER
name.contains("vanalign") -> DeviceRole.LEVELING
name.contains("alpicool") || name.contains("icecube") -> DeviceRole.FRIDGE
name.startsWith("dl-") || name.contains("daly") || name.contains("wattcycle") ->
DeviceRole.BMS
else -> DeviceRole.BMS
}
}
@@ -0,0 +1,339 @@
package de.fritob.campermonitor.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Flag
import androidx.compose.material.icons.filled.HourglassEmpty
import androidx.compose.material.icons.filled.PortableWifiOff
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.TrendingDown
import androidx.compose.material.icons.filled.TrendingFlat
import androidx.compose.material.icons.filled.TrendingUp
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.dp
import de.fritob.campermonitor.bluetooth.BluetoothManager
import de.fritob.campermonitor.protocol.AlignmentAssistant
import de.fritob.campermonitor.protocol.ConfiguredDevice
import de.fritob.campermonitor.protocol.DeviceLinkState
import de.fritob.campermonitor.protocol.LevelState
import de.fritob.campermonitor.protocol.LevelingWedge
import de.fritob.campermonitor.store.DeviceStore
/**
* Der Ausrichtungs-Assistent: begleitet das Rangieren und sagt, ob es besser
* oder schlechter wird und wo es am besten stand.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AlignmentAssistantScreen(
device: ConfiguredDevice,
store: DeviceStore,
bluetooth: BluetoothManager,
onBack: () -> Unit,
) {
val state = bluetooth.levelStates[device.id] ?: LevelState()
val linkState = bluetooth.linkStates[device.id]
val profile = store.activeProfile
val assistant = remember { AlignmentAssistant() }
// Zwingt die Ansicht zum Neuzeichnen, wenn sich der Verlauf ändert der
// Assistent selbst ist bewusst kein Compose-Zustand, damit die Protokoll-
// schicht nichts von Compose wissen muss.
var revision by remember { mutableIntStateOf(0) }
var displayStyle by remember { mutableStateOf(0) }
var announced by remember { mutableStateOf(false) }
val view = LocalView.current
LaunchedEffect(state) {
val pitch = state.pitch
val roll = state.roll
if (pitch != null && roll != null) {
assistant.add(pitch, roll)
revision += 1
// Einmal spürbar melden, wenn die Waage erreicht ist man schaut
// beim Rangieren nicht dauernd aufs Display.
if (assistant.hasReachedTarget) {
if (!announced) {
announced = true
@Suppress("DEPRECATION")
view.performHapticFeedback(android.view.HapticFeedbackConstants.CONFIRM)
}
} else {
announced = false
}
}
}
// Beim Rangieren schaut man immer wieder aufs Display; es darf dabei nicht
// dunkel werden.
DisposableEffect(Unit) {
view.keepScreenOn = true
onDispose { view.keepScreenOn = false }
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Ausrichtungs-Assistent") },
actions = { TextButton(onClick = onBack) { Text("Fertig") } },
)
},
) { padding ->
// Der Assistent ist bewusst kein Compose-Zustand - die Protokollschicht
// soll nichts von Compose wissen. Sein Stand wird deshalb bei jeder
// neuen Messung in ein eigenes Wertobjekt gezogen. Ihn direkt
// weiterzureichen genügt nicht: Compose überspringt einen Aufruf, dessen
// Argument dieselbe Instanz ist, und der Ratschlag blieb dann stehen.
val view = remember(revision) {
AssistantView(
advice = assistant.advice,
hasReachedTarget = assistant.hasReachedTarget,
trend = assistant.trend,
deviation = assistant.current?.deviation,
best = assistant.best,
improvementAtBest = assistant.improvementAtBest,
secondsSinceBest = assistant.secondsSinceBest,
)
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
listOf("Libelle", "Fahrzeug").forEachIndexed { index, label ->
SegmentedButton(
selected = displayStyle == index,
onClick = { displayStyle = index },
shape = SegmentedButtonDefaults.itemShape(index, 2),
) { Text(label) }
}
}
if (linkState != DeviceLinkState.Live) DisconnectedBanner()
if (displayStyle == 0) {
LevelBubble(state.pitch, state.roll)
} else {
VehicleTiltView(state.pitch, state.roll)
}
AdviceBanner(view)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
Reading("Längs", state.pitch, Modifier.weight(1f))
Reading("Quer", state.roll, Modifier.weight(1f))
Reading("Gesamt", view.deviation, Modifier.weight(1f))
}
val gain = view.improvementAtBest
val seconds = view.secondsSinceBest
val best = view.best
if (gain != null && seconds != null && best != null) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Flag, contentDescription = null)
Text(
"Bester Punkt",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(start = 8.dp),
)
}
Text(
de("Vor %.0f Sekunden stand das Fahrzeug %.1f° flacher (%.1f° statt %.1f°).",
seconds, gain, best.deviation,
view.deviation ?: 0.0),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
WedgeCard(state, profile?.trackWidth, profile?.wheelbase)
OutlinedButton(
onClick = { assistant.reset(); revision += 1; announced = false },
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Filled.Refresh, contentDescription = null)
Text("Neu beginnen", modifier = Modifier.padding(start = 8.dp))
}
}
}
}
/**
* Reisst die Verbindung beim Rangieren ab, stehen die Zahlen still. Ohne
* Hinweis sähe das aus, als hinge die App man rangiert dann nach einem Wert,
* der längst nicht mehr gilt.
*/
@Composable
private fun DisconnectedBanner() {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = Color(0x33E08600)),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Filled.PortableWifiOff, contentDescription = null,
tint = Color(0xFFE08600), modifier = Modifier.size(32.dp),
)
Column(modifier = Modifier.padding(start = 12.dp)) {
Text("Nicht verbunden", style = MaterialTheme.typography.titleSmall)
Text(
"Die Anzeige steht still, bis der Neigungsmesser wieder da ist.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
/** Der Stand des Assistenten als Wert siehe die Begründung oben. */
private data class AssistantView(
val advice: String,
val hasReachedTarget: Boolean,
val trend: AlignmentAssistant.Trend,
val deviation: Double?,
val best: AlignmentAssistant.Sample?,
val improvementAtBest: Double?,
val secondsSinceBest: Double?,
)
@Composable
private fun AdviceBanner(view: AssistantView) {
val reached = view.hasReachedTarget
val icon: ImageVector = if (reached) {
Icons.Filled.CheckCircle
} else {
when (view.trend) {
AlignmentAssistant.Trend.IMPROVING -> Icons.Filled.TrendingDown
AlignmentAssistant.Trend.WORSENING -> Icons.Filled.TrendingUp
AlignmentAssistant.Trend.STEADY -> Icons.Filled.TrendingFlat
AlignmentAssistant.Trend.UNKNOWN -> Icons.Filled.HourglassEmpty
}
}
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = if (reached) {
Color(0x331F9E52)
} else {
MaterialTheme.colorScheme.surfaceContainer
},
),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
icon, contentDescription = null,
tint = if (reached) Color(0xFF1F9E52) else MaterialTheme.colorScheme.primary,
modifier = Modifier.size(32.dp),
)
Text(
view.advice,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(start = 12.dp),
)
}
}
}
@Composable
private fun WedgeCard(state: LevelState, trackWidth: Double?, wheelbase: Double?) {
val across = state.roll?.let { LevelingWedge.across(it, trackWidth) }
val along = state.pitch?.let { LevelingWedge.along(it, wheelbase) }
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text("Auffahrkeile", style = MaterialTheme.typography.titleSmall)
when {
trackWidth == null && wheelbase == null -> Text(
"Für die Keilhöhe fehlen Spurweite und Radstand. Beides lässt sich " +
"beim Fahrzeug hinterlegen.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
across == null && along == null -> Text(
"Keine Keile nötig.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
else -> {
listOfNotNull(across, along).forEach { wedge ->
Row(modifier = Modifier.fillMaxWidth()) {
Text(
wedge.side.text.replaceFirstChar { it.uppercase() },
modifier = Modifier.weight(1f),
)
Text(
de("%.0f cm", wedge.heightInCentimetres),
style = MaterialTheme.typography.titleMedium,
)
}
}
Text(
"Höhe unter die tieferstehende Seite, damit das Fahrzeug eben steht.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
@@ -0,0 +1,152 @@
package de.fritob.campermonitor.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import de.fritob.campermonitor.bluetooth.BluetoothManager
import de.fritob.campermonitor.protocol.ConfiguredDevice
import de.fritob.campermonitor.protocol.DeviceLinkState
import de.fritob.campermonitor.store.DeviceStore
/**
* Die Startansicht: alle Geräte des gewählten Fahrzeugs als Kacheln.
*
* Anders als unter iOS sitzt die Fahrzeugauswahl nicht als runder Knopf in der
* Ecke, sondern als Titel mit Aufklapp-Pfeil so ist es auf Android üblich.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DashboardScreen(
store: DeviceStore,
bluetooth: BluetoothManager,
onOpenDevice: (ConfiguredDevice) -> Unit,
onAddDevice: () -> Unit,
onOpenProfiles: () -> Unit,
onOpenSettings: () -> Unit,
) {
val devices = store.activeDevices()
Scaffold(
topBar = {
TopAppBar(
title = {
TextButton(onClick = onOpenProfiles) {
Icon(
profileIcon(store.activeProfile?.symbol ?: "box_truck"),
contentDescription = null,
)
Text(
store.activeProfile?.name ?: "Camper",
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(start = 8.dp),
)
}
},
actions = {
IconButton(onClick = onAddDevice) {
Icon(Icons.Filled.Add, contentDescription = "Gerät hinzufügen")
}
IconButton(onClick = onOpenSettings) {
Icon(Icons.Filled.Settings, contentDescription = "Einstellungen")
}
},
)
},
) { padding ->
if (devices.isEmpty()) {
EmptyDashboard(
modifier = Modifier.fillMaxSize().padding(padding),
statusText = bluetooth.bluetoothStatusText,
isReady = bluetooth.isBluetoothReady,
onAddDevice = onAddDevice,
)
return@Scaffold
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 16.dp, end = 16.dp,
top = padding.calculateTopPadding() + 8.dp,
bottom = padding.calculateBottomPadding() + 24.dp,
),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
if (!bluetooth.isBluetoothReady) {
item {
Text(
bluetooth.bluetoothStatusText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
}
}
items(devices, key = { it.id }) { device ->
DeviceCard(
device = device,
snapshot = bluetooth.snapshots[device.id],
linkState = bluetooth.linkStates[device.id] ?: DeviceLinkState.Searching,
onClick = { onOpenDevice(device) },
)
}
}
}
}
@Composable
private fun EmptyDashboard(
modifier: Modifier,
statusText: String,
isReady: Boolean,
onAddDevice: () -> Unit,
) {
Column(
modifier = modifier.padding(32.dp),
verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
"Noch keine Geräte",
style = MaterialTheme.typography.headlineSmall,
)
Text(
"Füge deinen Ladebooster, den Solarladeregler, die Batterie, die " +
"Kühlbox oder den Neigungsmesser hinzu.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
if (!isReady) {
Text(
statusText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
)
}
TextButton(onClick = onAddDevice, modifier = Modifier.fillMaxWidth()) {
Text("Gerät hinzufügen")
}
}
}
@@ -0,0 +1,199 @@
package de.fritob.campermonitor.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.fritob.campermonitor.protocol.ConfiguredDevice
import de.fritob.campermonitor.protocol.DeviceLinkState
import de.fritob.campermonitor.protocol.DeviceSnapshot
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
/**
* Kachel auf dem Dashboard: Hauptwert gross, darunter die wichtigsten
* Nebenwerte und der Verbindungszustand.
*/
@Composable
fun DeviceCard(
device: ConfiguredDevice,
snapshot: DeviceSnapshot?,
linkState: DeviceLinkState,
onClick: () -> Unit,
) {
val isStale = snapshot?.isStale() ?: true
Card(
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = roleIcon(device.role),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
Column(modifier = Modifier.padding(start = 8.dp).weight(1f)) {
Text(device.name, style = MaterialTheme.typography.titleMedium)
Text(
device.role.title,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
StatusDot(linkState, isStale)
}
val primary = snapshot?.primaryMetric
if (snapshot != null && primary != null) {
Row(verticalAlignment = Alignment.Bottom) {
Text(
primary.formatted,
fontSize = 40.sp,
fontWeight = FontWeight.SemiBold,
color = if (isStale) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurface
},
)
Text(
primary.unit,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 4.dp, bottom = 6.dp),
)
}
SecondaryValues(snapshot)
} else {
Text(
placeholderText(linkState),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 12.dp),
)
}
CardFooter(snapshot, linkState)
}
}
}
@Composable
private fun SecondaryValues(snapshot: DeviceSnapshot) {
val others = snapshot.metrics
.filter { it.key != snapshot.primaryMetric?.key && it.value != null }
.take(3)
if (others.isEmpty()) return
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
others.forEach { metric ->
Column {
Text(
metric.formattedWithUnit,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
)
Text(
metric.label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
@Composable
private fun CardFooter(snapshot: DeviceSnapshot?, linkState: DeviceLinkState) {
val fault = snapshot?.fault
when {
fault != null -> Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Filled.Warning, contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(16.dp),
)
Text(
fault,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
maxLines = 2,
modifier = Modifier.padding(start = 6.dp),
)
}
snapshot?.state != null -> {
// Bei "Aus" ist erst der Grund die eigentliche Information.
val reason = snapshot.offReasons.firstOrNull()
Text(
if (reason != null) "${snapshot.state} · $reason" else snapshot.state!!,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
)
}
linkState is DeviceLinkState.Failed -> Text(
linkState.message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.tertiary,
maxLines = 2,
)
}
}
private fun placeholderText(linkState: DeviceLinkState): String = when (linkState) {
is DeviceLinkState.NeedsKey -> "Verschlüsselungsschlüssel fehlt im Detail eintragen."
is DeviceLinkState.Failed -> linkState.message
else -> "Warte auf Daten…"
}
/** Kleiner Punkt, der Verbindungszustand und Aktualität zusammenfasst. */
@Composable
fun StatusDot(linkState: DeviceLinkState, isStale: Boolean) {
val color = when (linkState) {
is DeviceLinkState.Live -> if (isStale) Color(0xFFE08600) else Color(0xFF1F9E52)
is DeviceLinkState.NeedsKey -> Color(0xFFE08600)
is DeviceLinkState.Failed -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
val label = if (linkState is DeviceLinkState.Live && isStale) "Veraltet" else linkState.label
Row(verticalAlignment = Alignment.CenterVertically) {
Box(modifier = Modifier.size(8.dp).clip(CircleShape).background(color))
Text(
label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 5.dp),
)
}
}
@@ -0,0 +1,681 @@
package de.fritob.campermonitor.ui
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import de.fritob.campermonitor.bluetooth.BluetoothManager
import de.fritob.campermonitor.bluetooth.BmsDiagnostics
import de.fritob.campermonitor.protocol.ConfiguredDevice
import de.fritob.campermonitor.protocol.DeviceLinkState
import de.fritob.campermonitor.protocol.DeviceRole
import de.fritob.campermonitor.protocol.DeviceTransport
import de.fritob.campermonitor.protocol.FridgeZoneMode
import de.fritob.campermonitor.protocol.LevelState
import de.fritob.campermonitor.store.DeviceStore
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DeviceDetailScreen(
device: ConfiguredDevice,
store: DeviceStore,
bluetooth: BluetoothManager,
onBack: () -> Unit,
onOpenKey: () -> Unit,
onOpenLevelSetup: () -> Unit,
onOpenAssistant: () -> Unit,
) {
val current = store.devices.firstOrNull { it.id == device.id } ?: device
val snapshot = bluetooth.snapshots[device.id]
val linkState = bluetooth.linkStates[device.id] ?: DeviceLinkState.Searching
val isStale = snapshot?.isStale() ?: true
var showDelete by remember { mutableStateOf(false) }
/**
* Im Alltag stören die technischen Angaben nur. Meldet ein Gerät aber einen
* Fehler oder fehlt der Schlüssel, sind sie genau das, was weiterhilft
* dann werden sie unabhängig von der Einstellung gezeigt.
*/
val showsTechnicalDetails = store.showDiagnostics ||
linkState is DeviceLinkState.Failed || linkState is DeviceLinkState.NeedsKey
Scaffold(
topBar = {
TopAppBar(
title = { Text(current.name) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
}
},
)
},
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(bottom = 32.dp),
) {
// MARK: Zustand
LabeledRow("Verbindung") { StatusDot(linkState, isStale) }
snapshot?.state?.let { LabeledRow("Zustand") { Text(it) } }
snapshot?.fault?.let {
Text(
it,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
snapshot?.offReasons?.forEach {
Text(
it,
color = MaterialTheme.colorScheme.tertiary,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 2.dp),
)
}
// Der Empfangspegel hilft beim Suchen eines Geräts, im Alltag sagt
// er nichts deshalb nur bei eingeblendeter Diagnose.
if (showsTechnicalDetails) {
snapshot?.rssi?.let { LabeledRow("Signal") { Text("$it dBm") } }
}
// MARK: Schlüssel, falls er fehlt
if (needsKeyAttention(current, store, bluetooth, snapshot != null, linkState)) {
KeyPrompt(onOpenKey)
}
// MARK: Steuerung
val fridge = bluetooth.fridgeStates[device.id]
if (current.role == DeviceRole.FRIDGE &&
fridge != null && fridge.hasStatus
) {
FridgeControls(current, fridge, bluetooth)
}
if (current.role == DeviceRole.LEVELING) {
LevelSection(
device = current,
state = bluetooth.levelStates[device.id] ?: LevelState(),
isLive = linkState == DeviceLinkState.Live,
onOpenAssistant = onOpenAssistant,
onOpenSetup = onOpenLevelSetup,
)
}
// MARK: Messwerte
if (snapshot != null && snapshot.metrics.isNotEmpty()) {
SectionHeader("Messwerte")
snapshot.metrics.forEach { metric ->
LabeledRow(metric.label) {
Text(
metric.formattedWithUnit,
color = if (metric.value == null) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurface
},
)
}
}
}
// MARK: Verlauf
val samples = bluetooth.history[device.id]
val primary = snapshot?.primaryMetric
if (samples != null && samples.size > 1 && primary != null) {
SectionHeader("Verlauf ${primary.label}")
HistoryChart(samples.map { it.value }, primary.unit)
}
// MARK: Zellen
if (snapshot != null && snapshot.cellVoltages.isNotEmpty()) {
SectionHeader("Zellspannungen")
snapshot.cellVoltages.forEachIndexed { index, voltage ->
LabeledRow("Zelle ${index + 1}") { Text(de("%.3f V", voltage)) }
}
}
if (snapshot != null && snapshot.info.isNotEmpty()) {
SectionHeader("Gerät")
snapshot.info.forEach { LabeledRow(it.label) { Text(it.value) } }
}
if (snapshot != null && snapshot.temperatures.size > 1) {
SectionHeader("Temperaturen")
snapshot.temperatures.forEachIndexed { index, value ->
LabeledRow("Fühler ${index + 1}") { Text(de("%.0f °C", value)) }
}
}
// MARK: Diagnose
if (showsTechnicalDetails) {
if (current.role.transport == DeviceTransport.ADVERTISEMENT) {
bluetooth.diagnostics[device.id]?.let { VictronDiagnosticsSection(it) }
} else {
bluetooth.bmsDiagnostics[device.id]?.let {
BmsDiagnosticsSection(current, it)
}
}
}
// MARK: Einstellungen
SectionHeader("Einstellungen")
NameField(current, store)
LabeledRow("Typ") { Text(current.role.title) }
if (current.role == DeviceRole.FRIDGE) {
ZoneModePicker(current, store, bluetooth)
}
if (current.role.transport == DeviceTransport.ADVERTISEMENT) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onOpenKey)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text("Verschlüsselung", modifier = Modifier.weight(1f))
Text(
keyStatusText(current, store, bluetooth),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null)
}
}
LabeledRow("Bluetooth-Adresse") {
Text(
current.address,
fontFamily = FontFamily.Monospace,
style = MaterialTheme.typography.bodySmall,
)
}
TextButton(
onClick = { showDelete = true },
modifier = Modifier.padding(horizontal = 8.dp),
) {
Icon(Icons.Filled.Delete, contentDescription = null,
tint = MaterialTheme.colorScheme.error)
Text(
"Gerät entfernen",
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(start = 8.dp),
)
}
}
}
if (showDelete) {
AlertDialog(
onDismissRequest = { showDelete = false },
title = { Text("Gerät entfernen?") },
text = { Text("Die Einstellungen und der hinterlegte Schlüssel werden gelöscht.") },
confirmButton = {
TextButton(onClick = {
store.remove(current)
bluetooth.refreshConfiguration()
showDelete = false
onBack()
}) { Text("Entfernen") }
},
dismissButton = {
TextButton(onClick = { showDelete = false }) { Text("Abbrechen") }
},
)
}
}
// MARK: - Bausteine
@Composable
fun LabeledRow(label: String, content: @Composable () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(label, modifier = Modifier.weight(1f))
content()
}
}
/**
* Ohne passenden Schlüssel bleibt das Gerät stumm das ist dann keine
* Nebensache, sondern das Einzige, was zu tun ist.
*/
private fun needsKeyAttention(
device: ConfiguredDevice,
store: DeviceStore,
bluetooth: BluetoothManager,
hasSnapshot: Boolean,
linkState: DeviceLinkState,
): Boolean {
if (device.role.transport != DeviceTransport.ADVERTISEMENT) return false
val entered = store.victronKey(device.id)
val expected = bluetooth.diagnostics[device.id]?.expectedKeyFirstByte
if (entered != null && expected != null && (entered[0].toInt() and 0xFF) != expected) return true
// Kommen Werte an, passt der Schlüssel offensichtlich.
if (hasSnapshot && linkState == DeviceLinkState.Live) return false
return entered == null
}
private fun keyStatusText(
device: ConfiguredDevice,
store: DeviceStore,
bluetooth: BluetoothManager,
): String {
val entered = store.victronKey(device.id) ?: return "fehlt"
val expected = bluetooth.diagnostics[device.id]?.expectedKeyFirstByte ?: return "hinterlegt"
return if ((entered[0].toInt() and 0xFF) == expected) "hinterlegt" else "passt nicht"
}
@Composable
private fun KeyPrompt(onOpenKey: () -> Unit) {
Card(
modifier = Modifier.fillMaxWidth().padding(16.dp).clickable(onClick = onOpenKey),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Filled.Key, contentDescription = null,
tint = MaterialTheme.colorScheme.tertiary)
Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
Text("Verschlüsselungsschlüssel eintragen")
Text(
"Victron-Geräte senden ihre Werte verschlüsselt. Ohne den Schlüssel " +
"aus VictronConnect bleibt die Anzeige leer.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null)
}
}
}
@Composable
private fun NameField(device: ConfiguredDevice, store: DeviceStore) {
var name by remember(device.id) { mutableStateOf(device.name) }
fun save() {
// Ein leeres Feld beim Tippen darf den Namen nicht löschen.
val trimmed = name.trim()
if (trimmed.isNotEmpty() && trimmed != device.name) {
store.update(device.copy(name = trimmed))
}
}
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { save() }),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)
// Beim Abschluss der Eingabe und beim Verlassen des Feldes
// gesichert - bei jedem Tastendruck zu speichern hiesse, die ganze
// Geräteliste je Zeichen neu zu schreiben.
.onFocusChanged { if (!it.isFocused) save() },
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ZoneModePicker(
device: ConfiguredDevice,
store: DeviceStore,
bluetooth: BluetoothManager,
) {
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it },
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
) {
OutlinedTextField(
value = device.fridgeZoneMode.title,
onValueChange = {},
readOnly = true,
label = { Text("Kühlzonen") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(androidx.compose.material3.MenuAnchorType.PrimaryNotEditable, true),
)
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
FridgeZoneMode.entries.forEach { mode ->
DropdownMenuItem(
text = { Text(mode.title) },
onClick = {
val updated = device.copy(fridgeZoneMode = mode)
store.update(updated)
bluetooth.updateFridgeZoneMode(updated)
expanded = false
},
)
}
}
}
}
/**
* Der Verlauf des Hauptwerts.
*
* Selbst gezeichnet statt mit einer Diagrammbibliothek für eine Linie mit
* Fläche lohnt keine weitere Abhängigkeit.
*/
@Composable
private fun HistoryChart(values: List<Double>, unit: String) {
if (values.size < 2) return
val lowest = values.min()
val highest = values.max()
// Bei Spannungen zählt der Unterschied von Zehntelvolt, nicht die absolute
// Spannung deshalb eng um die Messwerte zoomen.
val low = lowest - 0.05
val high = if (highest - lowest < 0.01) highest + 0.05 else highest + 0.05
val span = (high - low).coerceAtLeast(0.0001)
val lineColor = MaterialTheme.colorScheme.primary
Column {
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(160.dp)
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
val stepX = size.width / (values.size - 1)
fun y(value: Double) = (size.height * (1 - (value - low) / span)).toFloat()
val line = Path().apply {
moveTo(0f, y(values.first()))
values.forEachIndexed { index, value -> lineTo(index * stepX, y(value)) }
}
val area = Path().apply {
addPath(line)
lineTo(size.width, size.height)
lineTo(0f, size.height)
close()
}
drawPath(area, lineColor.copy(alpha = 0.15f))
drawPath(line, lineColor, style = Stroke(width = 3f))
}
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) {
Text(
de("%.2f %s", lowest, unit),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Text(
de("%.2f %s", highest, unit),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun VictronDiagnosticsSection(info: de.fritob.campermonitor.bluetooth.VictronDiagnostics) {
SectionHeader("Diagnose")
LabeledRow("Datensatz") { Text(info.recordName) }
LabeledRow("Produkt-ID") { Text(info.productIDText, fontFamily = FontFamily.Monospace) }
Text("Rohdaten", modifier = Modifier.padding(start = 16.dp, top = 8.dp))
Text(
info.rawHex,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp),
)
Text(
"Diese Werte sendet das Gerät unverschlüsselt mit.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp),
)
}
@Composable
private fun BmsDiagnosticsSection(device: ConfiguredDevice, info: BmsDiagnostics) {
val context = LocalContext.current
var copied by remember { mutableStateOf(false) }
SectionHeader("Diagnose")
LabeledRow("Erkanntes Protokoll") { Text(info.dialect) }
info.endpointPosition?.let {
LabeledRow("Verbindungsweg") { Text("${it.first} von ${it.second}") }
}
info.endpointLabel?.let {
Text("Aktueller Weg", modifier = Modifier.padding(start = 16.dp, top = 8.dp))
Text(
it,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
LabeledRow("Verbunden") { Text(if (info.isConnected) "ja" else "nein") }
LabeledRow("Empfang abonniert") { Text(if (info.isNotifyActive) "ja" else "nein") }
info.isBound?.let { LabeledRow("Angemeldet") { Text(if (it) "ja" else "nein") } }
if (info.confirmedWrites > 0) {
LabeledRow("Schreibvorgänge bestätigt") { Text("${info.confirmedWrites}") }
}
info.lastWriteError?.let {
LabeledRow("Letzter Schreibfehler") {
Text(it, color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall)
}
}
LabeledRow("Gesendet / empfangen") {
Text("${info.sentFrames} Anfragen / ${info.receivedBytes} Byte")
}
HexBlock("Letzter Stellbefehl", info.lastCommandHex)
HexBlock("Letzte Antwort", info.lastResponseHex)
info.fridgePayloadHex?.let {
HexBlock("Statusdaten der Box (${it.split(" ").size} Byte)", it)
}
OutlinedButton(
onClick = {
copyToClipboard(context, buildReport(device, info))
copied = true
},
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Icon(Icons.Filled.ContentCopy, contentDescription = null)
Text(
if (copied) "Diagnose kopiert" else "Diagnose kopieren",
modifier = Modifier.padding(start = 8.dp),
)
}
if (info.gattSummary.isNotEmpty()) {
SectionHeader("Bluetooth-Merkmale des Geräts")
Text(
info.gattSummary.joinToString("\n"),
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
HorizontalDivider(modifier = Modifier.padding(top = 16.dp))
}
@Composable
private fun HexBlock(title: String, hex: String?) {
if (hex == null) return
Text(title, modifier = Modifier.padding(start = 16.dp, top = 8.dp))
Text(
hex,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
/**
* Alles auf einmal, zum Weitergeben. Einzeln abzutippen ist zuviel verlangt,
* und gerade der Merkmalsbaum ist zu lang dafür.
*/
private fun buildReport(device: ConfiguredDevice, info: BmsDiagnostics): String = buildList {
add("Gerät: ${device.name} (${device.role.title})")
add("Protokoll: ${info.dialect}")
add("Verbunden: ${if (info.isConnected) "ja" else "nein"}")
add("Empfang abonniert: ${if (info.isNotifyActive) "ja" else "nein"}")
info.endpointPosition?.let { add("Weg: ${it.first} von ${it.second}") }
info.endpointLabel?.let { add("Merkmal: $it") }
info.isBound?.let { add("Angemeldet: ${if (it) "ja" else "nein"}") }
add("Gesendet: ${info.sentFrames} · empfangen: ${info.receivedBytes} Byte" +
" · bestätigt: ${info.confirmedWrites}")
info.lastWriteError?.let { add("Schreibfehler: $it") }
info.lastCommandHex?.let { add("Letzter Stellbefehl: $it") }
info.lastResponseHex?.let { add("Letzte Antwort: $it") }
info.fridgePayloadHex?.let { add("Statusdaten: $it") }
if (info.gattSummary.isNotEmpty()) {
add("Merkmale:")
addAll(info.gattSummary)
}
}.joinToString("\n")
private fun copyToClipboard(context: Context, text: String) {
val manager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
manager.setPrimaryClip(ClipData.newPlainText("Diagnose", text))
}
/**
* Die Nivellierung in der Geräteansicht: Anzeige, Assistent und der Weg zur
* Einrichtung. Einbaulage und Nullpunkt liegen bewusst eine Ebene tiefer
* direkt unter der Libelle verstellte ein Fehlgriff beim Ablesen den Nullpunkt.
*/
@Composable
private fun LevelSection(
device: ConfiguredDevice,
state: LevelState,
isLive: Boolean,
onOpenAssistant: () -> Unit,
onOpenSetup: () -> Unit,
) {
var displayStyle by remember { mutableStateOf(0) }
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
listOf("Libelle", "Fahrzeug").forEachIndexed { index, label ->
SegmentedButton(
selected = displayStyle == index,
onClick = { displayStyle = index },
shape = SegmentedButtonDefaults.itemShape(index, 2),
) { Text(label) }
}
}
if (displayStyle == 0) {
LevelBubble(state.pitch, state.roll)
} else {
VehicleTiltView(state.pitch, state.roll)
}
state.instruction?.let {
Text(
it,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.fillMaxWidth(),
)
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
Reading("Längs", state.pitch, Modifier.weight(1f))
Reading("Quer", state.roll, Modifier.weight(1f))
}
OutlinedButton(
onClick = onOpenAssistant,
// Der Assistent lebt von laufenden Messwerten. Ein einmal
// empfangener Wert genügt nicht: nach einem Verbindungsabbruch
// bliebe er stehen und die Ansicht sähe eingefroren aus.
enabled = isLive && state.hasReading,
modifier = Modifier.fillMaxWidth(),
) {
Text("Ausrichtungs-Assistent")
}
if (!isLive) {
Text(
"Der Assistent braucht laufende Messwerte. Der Neigungsmesser ist " +
"gerade nicht verbunden.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
OutlinedButton(onClick = onOpenSetup, modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Filled.Tune, contentDescription = null)
Text("Neigungsmesser einrichten", modifier = Modifier.weight(1f).padding(start = 8.dp))
Text(
if (state.isKnownUncalibrated) "Nullpunkt fehlt" else device.sensorOrientation.summary,
style = MaterialTheme.typography.labelSmall,
)
}
}
}
@@ -0,0 +1,14 @@
package de.fritob.campermonitor.ui
import java.util.Locale
/**
* Zahlen mit deutschem Trennzeichen.
*
* `String.format` ohne Sprachangabe nimmt die des Systems dann steht in
* einer sonst deutschen Oberfläche plötzlich "1.58" statt "1,58", je nach
* Telefon verschieden. Die Messwerte selbst formatiert die Protokollschicht
* bereits so; hier gilt dasselbe für alles, was die Ansichten selbst rechnen.
*/
fun de(format: String, vararg args: Any?): String =
String.format(Locale.GERMANY, format, *args)
@@ -0,0 +1,156 @@
package de.fritob.campermonitor.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Remove
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import de.fritob.campermonitor.bluetooth.BluetoothManager
import de.fritob.campermonitor.protocol.AlpicoolState
import de.fritob.campermonitor.protocol.ConfiguredDevice
import de.fritob.campermonitor.protocol.DeviceLinkState
import de.fritob.campermonitor.protocol.FridgeZone
/**
* Bedienelemente einer Alpicool-Kühlbox.
*
* Alle Schalter folgen dem Gerät, nicht der Vermutung: nach jedem Stellbefehl
* fragt die Sitzung den Zustand neu ab, und die Ansicht zeigt, was zurückkam.
*/
@Composable
fun FridgeControls(
device: ConfiguredDevice,
state: AlpicoolState,
bluetooth: BluetoothManager,
) {
val isLinked = bluetooth.linkStates[device.id] == DeviceLinkState.Live
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
SectionHeader("Steuerung")
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text("Eingeschaltet", modifier = Modifier.weight(1f))
Switch(
checked = state.isPoweredOn,
onCheckedChange = { bluetooth.setFridgePower(it, device.id) },
)
}
SingleChoiceSegmentedButtonRow(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
) {
listOf("Max" to false, "Eco" to true).forEachIndexed { index, (label, eco) ->
SegmentedButton(
selected = state.isEco == eco,
onClick = { bluetooth.setFridgeEco(eco, device.id) },
shape = SegmentedButtonDefaults.itemShape(index, 2),
enabled = state.isPoweredOn,
) { Text(label) }
}
}
TargetStepper(
title = if (state.isDualZone) "Soll links" else "Solltemperatur",
value = state.leftTarget,
state = state,
enabled = state.isPoweredOn,
onChange = { bluetooth.setFridgeTarget(it, FridgeZone.LEFT, device.id) },
)
if (state.isDualZone) {
TargetStepper(
title = "Soll rechts",
value = state.rightTarget,
state = state,
enabled = state.isPoweredOn,
onChange = { bluetooth.setFridgeTarget(it, FridgeZone.RIGHT, device.id) },
)
}
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text("Bedienfeld gesperrt", modifier = Modifier.weight(1f))
Switch(
checked = state.isLocked,
onCheckedChange = { bluetooth.setFridgeLock(it, device.id) },
)
}
Text(
// Ist die Box gerade nicht erreichbar, wird ein Befehl aufgehoben
// statt verworfen das gehört gesagt, sonst sieht es aus, als
// hätte das Tippen nichts bewirkt.
if (isLinked) {
"Änderungen gehen direkt an die Box. Der angezeigte Stand kommt aus " +
"ihrer Antwort, nicht aus der Eingabe."
} else {
"Die Box ist gerade nicht verbunden. Die Änderung wird gemerkt und " +
"geht raus, sobald sie wieder erreichbar ist."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
}
@Composable
private fun TargetStepper(
title: String,
value: Int?,
state: AlpicoolState,
enabled: Boolean,
onChange: (Int) -> Unit,
) {
val range = state.targetRange
val current = value ?: range.first
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(title, modifier = Modifier.weight(1f))
Text(
value?.let { "$it ${state.unitSymbol}" } ?: "",
style = MaterialTheme.typography.titleMedium,
)
IconButton(
onClick = { onChange((current - 1).coerceIn(range.first, range.last)) },
enabled = enabled && value != null && current > range.first,
) { Icon(Icons.Filled.Remove, contentDescription = "kälter") }
IconButton(
onClick = { onChange((current + 1).coerceIn(range.first, range.last)) },
enabled = enabled && value != null && current < range.last,
) { Icon(Icons.Filled.Add, contentDescription = "wärmer") }
}
}
@Composable
fun SectionHeader(title: String) {
Text(
title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 4.dp),
)
}
@@ -0,0 +1,53 @@
package de.fritob.campermonitor.ui
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AcUnit
import androidx.compose.material.icons.filled.Agriculture
import androidx.compose.material.icons.filled.AirportShuttle
import androidx.compose.material.icons.filled.Battery5Bar
import androidx.compose.material.icons.filled.BatteryChargingFull
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Cabin
import androidx.compose.material.icons.filled.DirectionsBoat
import androidx.compose.material.icons.filled.DirectionsBus
import androidx.compose.material.icons.filled.DirectionsCar
import androidx.compose.material.icons.filled.LocalShipping
import androidx.compose.material.icons.filled.Landscape
import androidx.compose.material.icons.filled.Speed
import androidx.compose.material.icons.filled.Straighten
import androidx.compose.material.icons.filled.WbSunny
import androidx.compose.ui.graphics.vector.ImageVector
import de.fritob.campermonitor.protocol.DeviceRole
/**
* Die Entsprechungen der SF-Symbole aus der iOS-Fassung.
*
* Material bringt keine deckungsgleichen Symbole mit; ausgewählt ist jeweils
* das, was dieselbe Sache meint nicht das, was am ähnlichsten aussieht.
*/
fun roleIcon(role: DeviceRole): ImageVector = when (role) {
DeviceRole.CHARGE_BOOSTER -> Icons.Filled.Bolt
DeviceRole.SOLAR_CHARGER -> Icons.Filled.WbSunny
DeviceRole.BATTERY_MONITOR -> Icons.Filled.Speed
DeviceRole.BMS -> Icons.Filled.BatteryChargingFull
DeviceRole.FRIDGE -> Icons.Filled.AcUnit
DeviceRole.LEVELING -> Icons.Filled.Straighten
}
/** Symbol eines Fahrzeugprofils. */
fun profileIcon(symbol: String): ImageVector = when (symbol) {
"box_truck" -> Icons.Filled.LocalShipping
"pickup" -> Icons.Filled.Agriculture
"bus" -> Icons.Filled.DirectionsBus
"double_decker" -> Icons.Filled.AirportShuttle
"car" -> Icons.Filled.DirectionsCar
"car_side" -> Icons.Filled.DirectionsCar
"tent" -> Icons.Filled.Cabin
"sailboat" -> Icons.Filled.DirectionsBoat
"lodge" -> Icons.Filled.Cabin
"mountains" -> Icons.Filled.Landscape
else -> Icons.Filled.LocalShipping
}
/** Für die Anzeige des Batteriestands in der Fahrzeugauswahl. */
val batteryIcon: ImageVector = Icons.Filled.Battery5Bar
@@ -0,0 +1,180 @@
package de.fritob.campermonitor.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.GpsFixed
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.SwapHoriz
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import de.fritob.campermonitor.bluetooth.BluetoothManager
import de.fritob.campermonitor.protocol.ConfiguredDevice
import de.fritob.campermonitor.protocol.LevelState
import de.fritob.campermonitor.store.DeviceStore
/**
* Einrichtung des Neigungsmessers: Einbaulage und Nullpunkt.
*
* Beides wird einmal eingestellt und danach kaum wieder angefasst. In der
* Geräteansicht standen die Knöpfe direkt unter der Libelle ein Fehlgriff
* beim Ablesen verstellte dort den Nullpunkt. Deshalb liegen sie hier.
*
* Die Reihenfolge ist nicht beliebig: erst muss klar sein, welche Achse des
* Sensors welche des Fahrzeugs ist, sonst wird der Nullpunkt auf die falsche
* Achse gelegt.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LevelSetupScreen(
device: ConfiguredDevice,
store: DeviceStore,
bluetooth: BluetoothManager,
onBack: () -> Unit,
onOpenSensorSetup: () -> Unit,
) {
val state = bluetooth.levelStates[device.id] ?: LevelState()
val current = store.devices.firstOrNull { it.id == device.id } ?: device
var showResetConfirmation by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Neigungsmesser einrichten") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
}
},
)
},
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
Reading("Längs", state.pitch, Modifier.weight(1f))
Reading("Quer", state.roll, Modifier.weight(1f))
}
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Schritt 1 Einbaulage", style = MaterialTheme.typography.titleSmall)
OutlinedButton(
onClick = onOpenSensorSetup,
enabled = state.hasReading,
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Filled.SwapHoriz, contentDescription = null)
Text(
"Einbaulage bestimmen",
modifier = Modifier.weight(1f).padding(start = 8.dp),
)
Text(
current.sensorOrientation.summary,
style = MaterialTheme.typography.labelSmall,
)
}
Text(
"Sitzt der Sensor quer oder verdreht im Fahrzeug, meldet er längs " +
"und quer vertauscht. Der Assistent klärt das durch zweimaliges " +
"Kippen. Danach den Nullpunkt setzen.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Schritt 2 Nullpunkt", style = MaterialTheme.typography.titleSmall)
Button(
onClick = { bluetooth.calibrateLevel(device.id) },
enabled = state.hasReading,
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Filled.GpsFixed, contentDescription = null)
Text("Aktuelle Lage als eben übernehmen", modifier = Modifier.padding(start = 8.dp))
}
if (!state.isKnownUncalibrated) {
OutlinedButton(
onClick = { showResetConfirmation = true },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error,
),
) {
Icon(Icons.Filled.Refresh, contentDescription = null)
Text("Nullpunkt verwerfen", modifier = Modifier.padding(start = 8.dp))
}
}
Text(
calibrationHint(state),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
if (showResetConfirmation) {
AlertDialog(
onDismissRequest = { showResetConfirmation = false },
title = { Text("Nullpunkt verwerfen?") },
text = { Text("Die Anzeige zeigt danach wieder die Lage des Sensors.") },
confirmButton = {
TextButton(onClick = {
bluetooth.resetLevelCalibration(device.id)
showResetConfirmation = false
}) { Text("Verwerfen", color = Color.Unspecified) }
},
dismissButton = {
TextButton(onClick = { showResetConfirmation = false }) { Text("Abbrechen") }
},
)
}
}
private fun calibrationHint(state: LevelState): String {
val pitchOffset = state.pitchOffset
val rollOffset = state.rollOffset
if (state.isCalibrated && pitchOffset != null && rollOffset != null) {
return de("Der Nullpunkt liegt bei %.1f° längs und %.1f° quer. ", pitchOffset, rollOffset) +
"Zum Neusetzen das Fahrzeug eben stellen und dann tippen."
}
if (state.isKnownUncalibrated) {
return "Noch kein Nullpunkt gesetzt die Anzeige zeigt die Lage des Sensors, " +
"nicht die des Fahrzeugs. Fahrzeug eben stellen, dann tippen."
}
// Ältere Firmware gibt die Offsets nicht heraus.
return "Zum Setzen das Fahrzeug eben stellen und dann tippen. Ob schon ein " +
"Nullpunkt gesetzt wurde, meldet dieses Gerät nicht zurück."
}
@@ -0,0 +1,217 @@
package de.fritob.campermonitor.ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import de.fritob.campermonitor.R
import de.fritob.campermonitor.protocol.LevelState
import kotlin.math.abs
import kotlin.math.max
/**
* Grafische Libelle: eine Blase, die zeigt, wohin das Fahrzeug hängt.
*
* Die Blase wandert dorthin, wo das Fahrzeug **höher** steht so, wie sich
* eine echte Wasserwaage verhält. Wer sie mittig haben will, muss also die
* Gegenseite anheben.
*/
@Composable
fun LevelBubble(pitch: Double?, roll: Double?, range: Double = 6.0) {
val deviation = when {
pitch != null && roll != null -> max(abs(pitch), abs(roll))
pitch != null -> abs(pitch)
roll != null -> abs(roll)
else -> null
}
val isLevel = pitch != null && roll != null &&
abs(pitch) <= LevelState.LEVEL_TOLERANCE && abs(roll) <= LevelState.LEVEL_TOLERANCE
// Grün nur, wenn es wirklich eben ist sonst sähe "schief" wie "eben" aus.
val bubbleColor = when {
deviation == null -> Color.Gray
deviation <= LevelState.LEVEL_TOLERANCE -> Color(0xFF1F9E52)
deviation <= 2 -> Color(0xFFE08600)
else -> Color(0xFFD03B2F)
}
val outline = MaterialTheme.colorScheme.outlineVariant
val fill = MaterialTheme.colorScheme.surfaceVariant
Canvas(
// Feste Höhe statt `aspectRatio`: das würde die Höhe wieder an die
// Breite koppeln und die Deckelung aushebeln - die Libelle wuchs dann
// über ihren Platz hinaus und schob sich über die Umschalter.
// Der Kreis wird darin ohnehin mittig und passend gezeichnet.
modifier = Modifier
.fillMaxWidth()
.height(260.dp)
) {
val side = minOf(size.width, size.height)
val radius = side / 2
val bubble = side * 0.16f
// Die Blase darf den Rand nicht verlassen, auch bei starker Neigung.
val travel = radius - bubble / 2 - 4
val centre = Offset(size.width / 2, size.height / 2)
fun clamped(value: Double?): Float =
((value ?: 0.0).coerceIn(-range, range) / range).toFloat()
drawCircle(color = fill, radius = radius, center = centre)
drawCircle(color = outline, radius = radius, center = centre, style = Stroke(1f))
// Ringe als echter Massstab: der innere markiert die Toleranz, der
// mittlere zwei Grad. Ohne Massstab sagt die Blasenlage nichts darüber,
// wie weit es noch ist.
val toleranceRadius = max(
(LevelState.LEVEL_TOLERANCE / range * travel).toFloat(),
bubble * 0.6f,
)
drawCircle(
color = if (isLevel) Color(0xFF1F9E52) else outline,
radius = toleranceRadius, center = centre,
style = Stroke(if (isLevel) 2f else 1f),
)
drawCircle(
color = outline, radius = (2.0 / range * travel).toFloat(), center = centre,
style = Stroke(1f),
)
drawLine(outline, Offset(centre.x - travel, centre.y), Offset(centre.x + travel, centre.y))
drawLine(outline, Offset(centre.x, centre.y - travel), Offset(centre.x, centre.y + travel))
// Positiver Pitch heisst: das Heck steht höher, die Blase wandert also
// nach oben in der Ansicht nach hinten.
drawCircle(
color = bubbleColor.copy(alpha = if (deviation == null) 0.25f else 1f),
radius = bubble / 2,
center = Offset(
centre.x + clamped(roll) * travel,
centre.y - clamped(pitch) * travel,
),
)
}
}
/**
* Die Neigung am Fahrzeug selbst: Seitenansicht für längs, Heckansicht für
* quer. Aus dem Ursprungsprojekt übernommen.
*/
@Composable
fun VehicleTiltView(pitch: Double?, roll: Double?) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
TiltedVehicle(
title = "Längs",
value = pitch,
// Positiver Pitch heisst Heck höher; das Bild zeigt nach rechts das
// Heck, also dreht die Front nach unten.
degrees = ((pitch ?: 0.0) * EXAGGERATION).toFloat(),
drawable = R.drawable.vehicle_side,
leftLabel = "Front", rightLabel = "Heck",
)
TiltedVehicle(
title = "Quer",
value = roll,
degrees = (-(roll ?: 0.0) * EXAGGERATION).toFloat(),
drawable = R.drawable.vehicle_rear,
leftLabel = "links", rightLabel = "rechts",
)
Text(
"Neigung ${EXAGGERATION.toInt()}-fach überhöht dargestellt sonst wäre " +
"sie kaum zu erkennen. Die Gradzahlen sind echt.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
/** Ohne Überhöhung sind zwei Grad am Bild nicht zu sehen. */
private const val EXAGGERATION = 3.0
@Composable
private fun TiltedVehicle(
title: String,
value: Double?,
degrees: Float,
drawable: Int,
leftLabel: String,
rightLabel: String,
) {
val tint = when {
value == null -> MaterialTheme.colorScheme.onSurfaceVariant
abs(value) <= LevelState.LEVEL_TOLERANCE -> Color(0xFF1F9E52)
abs(value) <= 2 -> Color(0xFFE08600)
else -> Color(0xFFD03B2F)
}
Column {
Row(modifier = Modifier.fillMaxWidth()) {
Text(title, style = MaterialTheme.typography.titleSmall, modifier = Modifier.weight(1f))
Text(
value?.let { de("%.1f°", it) } ?: "",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = tint,
)
}
Box(
modifier = Modifier.fillMaxWidth().height(120.dp),
contentAlignment = Alignment.Center,
) {
Image(
painter = painterResource(drawable),
contentDescription = null,
colorFilter = ColorFilter.tint(tint),
modifier = Modifier.fillMaxWidth(0.8f).rotate(degrees),
)
}
Row(modifier = Modifier.fillMaxWidth()) {
Text(
leftLabel,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Text(
rightLabel,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
/** Ein Messwert mit Beschriftung, wie er unter der Libelle steht. */
@Composable
fun Reading(title: String, value: Double?, modifier: Modifier = Modifier) {
Column(
modifier = modifier.padding(vertical = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
value?.let { de("%.1f°", it) } ?: "",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
)
Text(
title,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -0,0 +1,103 @@
package de.fritob.campermonitor.ui
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
/**
* Welche Rechte für Bluetooth gebraucht werden.
*
* Mit Android 12 hat sich das Modell geändert: davor lief ein BLE-Scan über
* die Standortfreigabe, seither gibt es eigene Bluetooth-Rechte. Beide Wege
* müssen bedient werden, sonst startet die App auf der einen oder der anderen
* Version nicht.
*/
val bluetoothPermissions: Array<String>
get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
} else {
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
}
fun hasBluetoothPermissions(context: Context): Boolean =
bluetoothPermissions.all {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
/**
* Zeigt [content], sobald die Rechte da sind sonst eine Erklärung mit Knopf.
*
* Ohne Erklärung wirkt eine App, die beim ersten Start nach Bluetooth fragt,
* schnell übergriffig. Hier steht, wozu.
*/
@Composable
fun WithBluetoothPermission(content: @Composable () -> Unit) {
val context = LocalContext.current
var granted by remember { mutableStateOf(hasBluetoothPermissions(context)) }
var asked by remember { mutableStateOf(false) }
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { result ->
granted = result.values.all { it }
asked = true
}
if (granted) {
content()
return
}
Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
"Bluetooth wird gebraucht",
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
)
Text(
"Die App liest die Werte deiner Geräte über Bluetooth Ladebooster, " +
"Solarladeregler, Batterie, Kühlbox und Neigungsmesser. Ohne die " +
"Freigabe bleibt sie leer.",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
if (asked) {
Text(
"Die Freigabe wurde abgelehnt. Sie lässt sich in den " +
"Android-Einstellungen unter „Apps → VanControl Pro → " +
"Berechtigungen“ nachholen.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
)
}
Button(onClick = { launcher.launch(bluetoothPermissions) }) {
Text(if (asked) "Nochmal fragen" else "Bluetooth freigeben")
}
}
}
@@ -0,0 +1,241 @@
package de.fritob.campermonitor.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import de.fritob.campermonitor.bluetooth.BluetoothManager
import de.fritob.campermonitor.protocol.Profile
import de.fritob.campermonitor.store.DeviceStore
/**
* Die Fahrzeugverwaltung.
*
* Antippen wählt aus, der Stift öffnet Name, Symbol und Masse. Beides
* getrennt, weil die Auswahl der häufige Fall ist und das Bearbeiten der
* seltene ein Wisch-Menü hatte unter iOS niemand gefunden.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProfilesScreen(store: DeviceStore, bluetooth: BluetoothManager, onBack: () -> Unit) {
var editing by remember { mutableStateOf<Profile?>(null) }
var pendingDeletion by remember { mutableStateOf<Profile?>(null) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Fahrzeuge") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
}
},
actions = {
IconButton(onClick = {
editing = store.addProfile("Camper ${store.profiles.size + 1}")
}) {
Icon(Icons.Filled.Add, contentDescription = "Fahrzeug hinzufügen")
}
},
)
},
) { padding ->
LazyColumn(modifier = Modifier.fillMaxSize().padding(padding)) {
items(store.profiles, key = { it.id }) { profile ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
store.selectProfile(profile.id)
bluetooth.refreshConfiguration()
}
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(profileIcon(profile.symbol), contentDescription = null)
Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
Text(profile.name, style = MaterialTheme.typography.bodyLarge)
Text(
"${store.devices.count { it.profileID == profile.id }} Geräte" +
measuresText(profile),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (profile.id == store.activeProfile?.id) {
Icon(
Icons.Filled.Check, contentDescription = "ausgewählt",
tint = MaterialTheme.colorScheme.primary,
)
}
IconButton(onClick = { editing = profile }) {
Icon(Icons.Filled.Edit, contentDescription = "Bearbeiten")
}
}
}
item {
Text(
"Antippen wählt das Fahrzeug aus, der Stift öffnet Name, Symbol " +
"und Masse. Jedes Fahrzeug hat seine eigenen Geräte, und die " +
"App liest immer nur die des gewählten aus.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp),
)
}
}
}
editing?.let { profile ->
ProfileEditDialog(
profile = profile,
canDelete = store.profiles.size > 1,
onDismiss = { editing = null },
onSave = {
store.updateProfile(it)
bluetooth.refreshConfiguration()
editing = null
},
onDelete = {
pendingDeletion = profile
editing = null
},
)
}
pendingDeletion?.let { profile ->
AlertDialog(
onDismissRequest = { pendingDeletion = null },
title = { Text("Fahrzeug entfernen?") },
text = { Text("Die Geräte dieses Fahrzeugs werden mit gelöscht.") },
confirmButton = {
TextButton(onClick = {
store.removeProfile(profile)
bluetooth.refreshConfiguration()
pendingDeletion = null
}) { Text("Entfernen") }
},
dismissButton = {
TextButton(onClick = { pendingDeletion = null }) { Text("Abbrechen") }
},
)
}
}
private fun measuresText(profile: Profile): String {
val track = profile.trackWidth
val base = profile.wheelbase
if (track == null && base == null) return ""
val parts = buildList {
track?.let { add(de("Spur %.2f m", it)) }
base?.let { add(de("Radstand %.2f m", it)) }
}
return " · " + parts.joinToString(", ")
}
@Composable
private fun ProfileEditDialog(
profile: Profile,
canDelete: Boolean,
onDismiss: () -> Unit,
onSave: (Profile) -> Unit,
onDelete: () -> Unit,
) {
var name by remember { mutableStateOf(profile.name) }
var symbol by remember { mutableStateOf(profile.symbol) }
var track by remember { mutableStateOf(profile.trackWidth?.toString() ?: "") }
var base by remember { mutableStateOf(profile.wheelbase?.toString() ?: "") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Fahrzeug") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") },
singleLine = true,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Profile.SYMBOLS.take(5).forEach { candidate ->
FilterChip(
selected = symbol == candidate,
onClick = { symbol = candidate },
label = { Icon(profileIcon(candidate), contentDescription = null) },
)
}
}
OutlinedTextField(
value = track,
onValueChange = { track = it },
label = { Text("Spurweite in Metern") },
singleLine = true,
)
OutlinedTextField(
value = base,
onValueChange = { base = it },
label = { Text("Radstand in Metern") },
singleLine = true,
)
Text(
"Beide Masse braucht nur der Keilrechner im " +
"Ausrichtungs-Assistenten. Ohne sie bleibt der Rest nutzbar.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
confirmButton = {
Button(onClick = {
onSave(
profile.copy(
name = name.trim().ifEmpty { profile.name },
symbol = symbol,
trackWidth = track.replace(',', '.').toDoubleOrNull(),
wheelbase = base.replace(',', '.').toDoubleOrNull(),
)
)
}) { Text("Sichern") }
},
dismissButton = {
Row {
if (canDelete) {
TextButton(onClick = onDelete) { Text("Entfernen") }
}
TextButton(onClick = onDismiss) { Text("Abbrechen") }
}
},
)
}
@@ -0,0 +1,247 @@
package de.fritob.campermonitor.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.HorizontalRule
import androidx.compose.material.icons.filled.SouthEast
import androidx.compose.material.icons.filled.SouthWest
import androidx.compose.material.icons.filled.SwapHoriz
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import de.fritob.campermonitor.bluetooth.BluetoothManager
import de.fritob.campermonitor.protocol.ConfiguredDevice
import de.fritob.campermonitor.protocol.LevelState
import de.fritob.campermonitor.protocol.OrientationDetection
import de.fritob.campermonitor.protocol.SensorOrientation
import de.fritob.campermonitor.store.DeviceStore
/**
* Führt durch die Einrichtung der Einbaulage des Neigungsmessers.
*
* Der Sensor kann quer, gedreht oder kopfüber sitzen. Statt die Lage aus einer
* Liste raten zu lassen, wird sie gemessen: zweimal kippen, einmal um jede
* Achse, und aus der Reaktion ergibt sich die Zuordnung.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SensorSetupScreen(
device: ConfiguredDevice,
store: DeviceStore,
bluetooth: BluetoothManager,
onBack: () -> Unit,
) {
val state = bluetooth.levelStates[device.id] ?: LevelState()
val live = remember(state) {
val pitch = state.rawPitch
val roll = state.rawRoll
if (pitch != null && roll != null) OrientationDetection.Reading(pitch, roll) else null
}
var step by remember { mutableStateOf(Step.INTRO) }
/** Ruhelage, auf die beide Kippbewegungen bezogen werden. */
var reference by remember { mutableStateOf(OrientationDetection.Reading(0.0, 0.0)) }
var noseChange by remember { mutableStateOf<OrientationDetection.Reading?>(null) }
var result by remember { mutableStateOf<SensorOrientation?>(null) }
var failure by remember { mutableStateOf<OrientationDetection.Failure?>(null) }
Scaffold(
topBar = { TopAppBar(title = { Text("Einbaulage") }) },
) { padding ->
Column(
modifier = Modifier.fillMaxSize().padding(padding).padding(24.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
when (step) {
Step.INTRO -> {
InstructionCard(
icon = Icons.Filled.SwapHoriz,
title = "Einbaulage bestimmen",
text = "Sitzt der Sensor quer oder verdreht im Fahrzeug, meldet " +
"er die Neigung vertauscht. Um das zu klären, wird er gleich " +
"zweimal gekippt.\n\nBaue ihn dazu so ein oder halte ihn so, " +
"wie er später sitzen soll. Er muss nicht angeschraubt sein " +
"nur die Ausrichtung muss stimmen.",
)
LiveReadout(state)
Button(
onClick = {
reference = live ?: OrientationDetection.Reading(0.0, 0.0)
step = Step.TILT_NOSE
},
enabled = live != null,
) { Text("Los geht's") }
}
Step.TILT_NOSE -> {
InstructionCard(
icon = Icons.Filled.SouthEast,
title = "Schritt 1 von 2: nach vorne kippen",
text = "Kippe den Sensor so, als würde das Fahrzeug vorne abwärts " +
"stehen die Front also nach unten.\n\nDeutlich kippen, etwa " +
"eine Handbreit, und in dieser Lage halten. Dann weiter.",
)
LiveReadout(state)
Button(
onClick = {
live?.let { noseChange = it - reference }
// Der Bezug bleibt die Ruhelage. Von der gekippten
// Lage aus zu messen wäre falsch: die zweite Messung
// enthielte dann das Zurückkippen aus der ersten,
// und beide Achsen schlügen aus.
step = Step.SETTLE
},
enabled = live != null,
) { Text("Weiter") }
}
Step.SETTLE -> {
InstructionCard(
icon = Icons.Filled.HorizontalRule,
title = "Zurück in die Ruhelage",
text = "Stelle den Sensor wieder so hin wie am Anfang und lass ihn " +
"kurz ruhen.\n\nVon hier aus wird die zweite Bewegung gemessen.",
)
LiveReadout(state)
Button(
onClick = {
reference = live ?: reference
step = Step.TILT_SIDE
},
enabled = live != null,
) { Text("Weiter") }
}
Step.TILT_SIDE -> {
InstructionCard(
icon = Icons.Filled.SouthWest,
title = "Schritt 2 von 2: nach links kippen",
text = "Kippe den Sensor jetzt so, als würde das Fahrzeug nach " +
"links hängen die linke Seite also nach unten.\n\nWieder " +
"deutlich kippen und in dieser Lage halten.",
)
LiveReadout(state)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedButton(onClick = { step = Step.SETTLE }) { Text("Zurück") }
Button(
onClick = {
val nose = noseChange
val side = live?.minus(reference)
if (nose != null && side != null) {
when (val r = OrientationDetection.orientation(nose, side)) {
is OrientationDetection.Result.Success -> {
result = r.orientation
step = Step.DONE
}
is OrientationDetection.Result.Error -> {
failure = r.failure
step = Step.FAILED
}
}
}
},
enabled = live != null,
) { Text("Fertig") }
}
}
Step.DONE -> {
val orientation = result ?: SensorOrientation.IDENTITY
InstructionCard(
icon = Icons.Filled.CheckCircle,
title = "Einbaulage erkannt",
text = "Ergebnis: ${orientation.summary}.\n\nDie Anzeige rechnet die " +
"Werte des Sensors ab jetzt auf die Achsen des Fahrzeugs um. " +
"Vergiss nicht, anschliessend im ebenen Stand den Nullpunkt " +
"zu setzen.",
)
Button(onClick = {
val updated = device.copy(sensorOrientation = orientation)
store.update(updated)
bluetooth.updateSensorOrientation(updated)
onBack()
}) { Text("Übernehmen") }
}
Step.FAILED -> {
InstructionCard(
icon = Icons.Filled.Warning,
title = "Das hat nicht geklappt",
text = failure?.message ?: "",
)
Button(onClick = { step = Step.INTRO }) { Text("Nochmal versuchen") }
}
}
TextButton(onClick = onBack) { Text("Abbrechen") }
}
}
}
private enum class Step { INTRO, TILT_NOSE, SETTLE, TILT_SIDE, DONE, FAILED }
@Composable
private fun InstructionCard(icon: ImageVector, title: String, text: String) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
icon, contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(44.dp),
)
Text(
title,
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
)
Text(
text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
}
/**
* Was der Sensor gerade meldet ohne Umrechnung, denn die wird hier ja erst
* bestimmt. Deshalb heissen die Achsen A und B statt längs und quer.
*/
@Composable
private fun LiveReadout(state: LevelState) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
Reading("Achse A", state.rawPitch, Modifier.weight(1f))
Reading("Achse B", state.rawRoll, Modifier.weight(1f))
}
}
@@ -0,0 +1,105 @@
package de.fritob.campermonitor.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import de.fritob.campermonitor.store.DeviceStore
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(store: DeviceStore, onBack: () -> Unit) {
// Der Speicher meldet Änderungen selbst; die lokale Kopie hält nur den
// Schalter in Bewegung, während geschrieben wird.
var showDiagnostics by remember { mutableStateOf(store.showDiagnostics) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Einstellungen") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
}
},
)
},
) { padding ->
Column(
modifier = Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()),
) {
SettingRow(
title = "Diagnose einblenden",
subtitle = "Zeigt Protokoll, Verbindungsweg und Rohdaten in den " +
"Gerätedetails. Im Alltag stören sie; bei einem Fehler sind sie " +
"genau das, was weiterhilft.",
trailing = {
Switch(
checked = showDiagnostics,
onCheckedChange = {
showDiagnostics = it
store.showDiagnostics = it
},
)
},
)
Text(
"VanControl Pro liest Victron-Geräte über ihr Advertisement mit " +
"(„Instant Readout“) und spricht Batterie, Kühlbox und " +
"Neigungsmesser direkt über Bluetooth an.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp),
)
}
}
}
@Composable
fun SettingRow(
title: String,
subtitle: String? = null,
onClick: (() -> Unit)? = null,
trailing: @Composable (() -> Unit)? = null,
) {
androidx.compose.foundation.layout.Row(
modifier = Modifier
.fillMaxWidth()
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.bodyLarge)
if (subtitle != null) {
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
trailing?.invoke()
}
}
@@ -0,0 +1,40 @@
package de.fritob.campermonitor.ui
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
/**
* Das Grün der iOS-Fassung als Akzent damit beide Apps als dieselbe erkennbar
* bleiben.
*
* Bewusst **ohne** die dynamischen Systemfarben ab Android 12. In dieser App
* trägt Farbe Bedeutung: grün heisst eben, orange knapp daneben, rot schief.
* Eine vom Hintergrundbild abgeleitete Akzentfarbe stünde daneben und liesse
* die App je nach Telefon anders aussehen als ihr eigenes Logo.
*/
private val CamperGreen = Color(0xFF1F9E52)
private val LightColors = lightColorScheme(
primary = CamperGreen,
secondary = CamperGreen,
)
private val DarkColors = darkColorScheme(
primary = Color(0xFF54C77F),
secondary = Color(0xFF54C77F),
)
@Composable
fun CamperTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
MaterialTheme(
colorScheme = if (darkTheme) DarkColors else LightColors,
content = content,
)
}
@@ -0,0 +1,145 @@
package de.fritob.campermonitor.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import de.fritob.campermonitor.bluetooth.BluetoothManager
import de.fritob.campermonitor.protocol.ConfiguredDevice
import de.fritob.campermonitor.store.DeviceStore
/**
* Eingabe des Victron-Verschlüsselungsschlüssels.
*
* Der Schlüssel wird einmal eingetragen und danach nie wieder angefasst
* deshalb steht er hier und nicht in der Geräteübersicht.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun VictronKeyScreen(
device: ConfiguredDevice,
store: DeviceStore,
bluetooth: BluetoothManager,
onBack: () -> Unit,
) {
var keyInput by remember { mutableStateOf(store.victronKeyText(device.id) ?: "") }
val diagnostics = bluetooth.diagnostics[device.id]
val entered = remember(keyInput) {
keyInput.filter { !it.isWhitespace() }.take(2)
.let { if (it.length == 2) it.toIntOrNull(16) else null }
}
val expected = diagnostics?.expectedKeyFirstByte
val agree = if (expected != null && entered != null) expected == entered else null
Scaffold(
topBar = {
TopAppBar(
title = { Text("Verschlüsselung") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
}
},
)
},
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
OutlinedTextField(
value = keyInput,
onValueChange = { keyInput = it },
label = { Text("32 Hex-Zeichen") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = {
store.setVictronKey(keyInput, device.id)
bluetooth.refreshConfiguration()
},
enabled = keyInput.filter { !it.isWhitespace() }.length == 32,
) {
Text("Schlüssel speichern")
}
Text(
"In VictronConnect: Gerät öffnen → Zahnrad → ⋮ → Produkt-Info → " +
"„Instant Readout“ einschalten → Verschlüsselungsdaten anzeigen. " +
"Der Schlüssel ist 16 Byte lang (32 Hex-Zeichen).",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Das erste Byte sendet das Gerät unverschlüsselt mit. Stimmt es
// nicht mit dem eingetragenen überein, gehört der Schlüssel zu
// einem anderen Victron-Gerät der häufigste Fehler überhaupt.
if (expected != null) {
Text("Erstes Schlüsselbyte", style = MaterialTheme.typography.titleSmall)
Row(modifier = Modifier.fillMaxWidth()) {
Text("Gerät sendet", modifier = Modifier.weight(1f))
Text(
"0x%02X".format(expected),
fontFamily = FontFamily.Monospace,
color = if (agree == false) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurface
},
)
}
Row(modifier = Modifier.fillMaxWidth()) {
Text("Eingetragen", modifier = Modifier.weight(1f))
Text(
entered?.let { "0x%02X".format(it) } ?: "",
fontFamily = FontFamily.Monospace,
color = if (agree == false) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
Text(
if (agree == false) {
"Die beiden Bytes müssen übereinstimmen. Tun sie das nicht, " +
"stammt der Schlüssel von einem anderen Victron-Gerät in " +
"VictronConnect prüfen, ob wirklich dieses Gerät geöffnet war."
} else {
"Zum Vergleichen: dieses Byte sendet das Gerät unverschlüsselt mit."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

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