first commit
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 253 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
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>
|
||||