Android: Bluetooth-Schicht und Ablage
Damit steht der erste Teil vollständig: Protokoll, Funk und Ablage übersetzen sich, die 50 Prüfungen laufen. Die Funkschicht folgt der iOS-Fassung samt allem, was wir uns dort erst erarbeiten mussten. Ein eigener Thread für alles, was Bluetooth anfasst; Android ruft Scan- und GATT-Rückmeldungen auf Binder-Threads auf. Die Wartesperre nach Fehlschlägen, die zwischen einem Abbruch einer stehenden Verbindung und einem echten Fehlschlag unterscheidet. Stellbefehle, die aufgehoben statt verworfen werden, wenn die Kühlbox gerade weg ist. Die Zwanzig-Byte-Grenze beim Schreiben an die Kühlbox. Die stabile Reihenfolge der Kandidaten, damit nicht der Zufall die Schreibart bestimmt. Vier Dinge sind auf Android anders und im Quelltext vermerkt: Die Herstellerdaten kommen ohne die zwei Bytes der Company-ID - die steht im Schlüssel der Tabelle. Sie werden wieder davorgesetzt, damit derselbe Rahmen ankommt wie unter iOS und dieselben Prüfungen gelten. Notify muss über einen Deskriptor eingeschaltet werden; das Einschalten auf unserer Seite allein genügt nicht. Geräte werden über ihre MAC-Adresse angesprochen statt über eine systemvergebene Kennung - iOS gibt die Adresse gar nicht heraus. Die Victron-Schlüssel liegen in app-privater Ablage statt im Schlüsselbund. Das schützt gegen andere Apps, ist aber nicht hardwaregestützt. Ohne zusätzliche Bibliothek gibt es unter Android kein gleichwertiges Gegenstück. Als Nächstes die Oberfläche. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "de.fritob.campermonitor"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "de.fritob.campermonitor"
|
||||
// Android 8: älter lohnt nicht, dort fehlt zu viel an Bluetooth LE.
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
sourceSets["main"].java.srcDirs("src/main/kotlin")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":protocol"))
|
||||
|
||||
val composeBom = platform("androidx.compose:compose-bom:2024.12.01")
|
||||
implementation(composeBom)
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
|
||||
implementation("androidx.core:core-ktx:1.15.0")
|
||||
implementation("androidx.activity:activity-compose:1.9.3")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
|
||||
implementation("androidx.navigation:navigation-compose:2.8.5")
|
||||
|
||||
// Die Ablage der Geräte und Profile nutzt org.json aus Android selbst -
|
||||
// eine weitere Bibliothek lohnt für eine Handvoll Felder nicht.
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!--
|
||||
Ab Android 12 gibt es eigene Bluetooth-Rechte. `neverForLocation` sagt
|
||||
dem System, dass der Scan nicht dem Ermitteln des Standorts dient - sonst
|
||||
verlangt Android zusätzlich die Standortfreigabe.
|
||||
-->
|
||||
<uses-permission
|
||||
android:name="android.permission.BLUETOOTH_SCAN"
|
||||
android:usesPermissionFlags="neverForLocation"
|
||||
tools:targetApi="s" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
|
||||
<!-- Bis Android 11 lief der Scan über die Standortrechte. -->
|
||||
<uses-permission
|
||||
android:name="android.permission.BLUETOOTH"
|
||||
android:maxSdkVersion="30" />
|
||||
<uses-permission
|
||||
android:name="android.permission.BLUETOOTH_ADMIN"
|
||||
android:maxSdkVersion="30" />
|
||||
<uses-permission
|
||||
android:name="android.permission.ACCESS_FINE_LOCATION"
|
||||
android:maxSdkVersion="30" />
|
||||
|
||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.CamperMonitor">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.CamperMonitor">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,14 @@
|
||||
package de.fritob.campermonitor
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent { MaterialTheme { Text("Camper Monitor") } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
package de.fritob.campermonitor.bluetooth
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothGattCallback
|
||||
import android.bluetooth.le.ScanCallback
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.bluetooth.le.ScanSettings
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import de.fritob.campermonitor.protocol.AlpicoolProtocol
|
||||
import de.fritob.campermonitor.protocol.AlpicoolState
|
||||
import de.fritob.campermonitor.protocol.ConfiguredDevice
|
||||
import de.fritob.campermonitor.protocol.DeviceLinkState
|
||||
import de.fritob.campermonitor.protocol.DeviceRole
|
||||
import de.fritob.campermonitor.protocol.DeviceSnapshot
|
||||
import de.fritob.campermonitor.protocol.DeviceTransport
|
||||
import de.fritob.campermonitor.protocol.FridgeZone
|
||||
import de.fritob.campermonitor.protocol.FridgeZoneMode
|
||||
import de.fritob.campermonitor.protocol.LevelState
|
||||
import de.fritob.campermonitor.protocol.SensorOrientation
|
||||
import de.fritob.campermonitor.protocol.VictronAdvertisement
|
||||
import de.fritob.campermonitor.store.DeviceStore
|
||||
import java.util.UUID
|
||||
import kotlin.math.min
|
||||
import kotlin.math.pow
|
||||
|
||||
/** Was beim Einrichten in der Geräteliste steht. */
|
||||
data class Discovery(
|
||||
val address: String,
|
||||
val name: String?,
|
||||
val rssi: Int,
|
||||
val firstSeen: Long,
|
||||
val lastSeen: Long,
|
||||
val isVictron: Boolean,
|
||||
val victronRecord: String? = null,
|
||||
val serviceUUIDs: List<String> = emptyList(),
|
||||
) {
|
||||
/**
|
||||
* Ob das Gerät nach einem der unterstützten aussieht. Nur für die
|
||||
* Sortierung – ausschliessen tut die Liste nichts.
|
||||
*/
|
||||
val looksLikeSupported: Boolean
|
||||
get() {
|
||||
if (isVictron) return true
|
||||
val n = name?.lowercase() ?: return false
|
||||
return listOf("dl-", "daly", "jbd", "xiaoxiang", "wattcycle", "hilink",
|
||||
"vanalign", "alpicool", "icecube").any { n.contains(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Was die Diagnoseansicht über ein Victron-Advertisement zeigt. */
|
||||
data class VictronDiagnostics(
|
||||
val expectedKeyFirstByte: Int?,
|
||||
val recordName: String,
|
||||
val productIDText: String,
|
||||
val rawHex: String,
|
||||
) {
|
||||
val expectedKeyText: String
|
||||
get() = expectedKeyFirstByte?.let { "0x%02X".format(it) } ?: "–"
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Funkschicht der App.
|
||||
*
|
||||
* **Alles, was Bluetooth anfasst, läuft auf einem eigenen Thread.** Android
|
||||
* ruft Scan- und GATT-Rückmeldungen auf Binder-Threads auf; würden sie direkt
|
||||
* den Zustand der Oberfläche anfassen, entstünde ein Datenwettlauf und die
|
||||
* Oberfläche würde bei jedem Advertisement neu zeichnen. Ergebnisse gehen
|
||||
* deshalb über [publish] zurück auf den Hauptthread.
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
class BluetoothManager(
|
||||
private val context: Context,
|
||||
private val store: DeviceStore,
|
||||
) {
|
||||
// MARK: - Was die Oberfläche liest (nur Hauptthread)
|
||||
|
||||
var isBluetoothReady by mutableStateOf(false)
|
||||
private set
|
||||
var bluetoothStatusText by mutableStateOf("Bluetooth wird gestartet…")
|
||||
private set
|
||||
|
||||
val snapshots = mutableStateMapOf<UUID, DeviceSnapshot>()
|
||||
val linkStates = mutableStateMapOf<UUID, DeviceLinkState>()
|
||||
val fridgeStates = mutableStateMapOf<UUID, AlpicoolState>()
|
||||
val levelStates = mutableStateMapOf<UUID, LevelState>()
|
||||
val bmsDiagnostics = mutableStateMapOf<UUID, BmsDiagnostics>()
|
||||
val diagnostics = mutableStateMapOf<UUID, VictronDiagnostics>()
|
||||
val discoveries = mutableStateMapOf<String, Discovery>()
|
||||
val history = mutableStateMapOf<UUID, MutableList<HistorySample>>()
|
||||
|
||||
data class HistorySample(val time: Long, val value: Double)
|
||||
|
||||
// MARK: - Funk-Thread
|
||||
|
||||
private val thread = HandlerThread("de.fritob.CamperMonitor.bluetooth").apply { start() }
|
||||
private val handler = Handler(thread.looper)
|
||||
private val mainHandler = Handler(context.mainLooper)
|
||||
|
||||
private val adapter: BluetoothAdapter? =
|
||||
(context.getSystemService(Context.BLUETOOTH_SERVICE) as? android.bluetooth.BluetoothManager)
|
||||
?.adapter
|
||||
|
||||
private fun publish(work: () -> Unit) = mainHandler.post(work)
|
||||
|
||||
// MARK: - Zustand auf dem Funk-Thread
|
||||
|
||||
private class ManagedDevice(
|
||||
val id: UUID,
|
||||
val address: String,
|
||||
val transport: DeviceTransport,
|
||||
val role: DeviceRole,
|
||||
val fridgeZoneMode: FridgeZoneMode,
|
||||
val sensorOrientation: SensorOrientation,
|
||||
val victronKey: ByteArray?,
|
||||
)
|
||||
|
||||
private var managed: Map<String, ManagedDevice> = emptyMap()
|
||||
private val bmsSessions = mutableMapOf<String, BmsSession>()
|
||||
private val levelSessions = mutableMapOf<String, LevelSession>()
|
||||
private val connecting = mutableSetOf<String>()
|
||||
|
||||
/**
|
||||
* Frühester Zeitpunkt für den nächsten Verbindungsversuch je Gerät.
|
||||
*
|
||||
* Ohne diese Sperre entsteht ein Verbindungssturm: schlägt ein Versuch
|
||||
* fehl, ist der Eintrag wieder frei, und das nächste Advertisement löst
|
||||
* sofort den nächsten aus. Geräte quittieren jeden Versuch, Kühlboxen etwa
|
||||
* mit einem Piepton.
|
||||
*/
|
||||
private val nextConnectAttempt = mutableMapOf<String, Long>()
|
||||
private val connectFailures = mutableMapOf<String, Int>()
|
||||
private val connectedSince = mutableMapOf<String, Long>()
|
||||
|
||||
/**
|
||||
* Stellbefehle, die kamen, während die Verbindung weg war. Ohne das
|
||||
* verschwindet ein Tippen lautlos.
|
||||
*/
|
||||
private val pendingControls = mutableMapOf<String, MutableList<Pair<ByteArray, Long>>>()
|
||||
|
||||
/** Letzter bekannter Kühlbox-Zustand, auch über eine Trennung hinweg. */
|
||||
private val fridgeStateCache = mutableMapOf<String, AlpicoolState>()
|
||||
|
||||
private val lastHandledAdvertisement = mutableMapOf<String, Long>()
|
||||
|
||||
private var discovering = false
|
||||
private var scanning = false
|
||||
|
||||
private companion object {
|
||||
const val FIRST_RETRY_DELAY_MS = 5_000L
|
||||
const val LONGEST_RETRY_DELAY_MS = 60_000L
|
||||
|
||||
/**
|
||||
* Nach dem Abbruch einer Verbindung, die stand, wird zügig nachgefasst –
|
||||
* das ist der Normalfall im Fahrzeug und kein Fehler.
|
||||
*/
|
||||
const val QUICK_RETRY_DELAY_MS = 2_000L
|
||||
|
||||
/**
|
||||
* Ab dieser Dauer gilt eine Verbindung als zustandegekommen. Was sofort
|
||||
* wieder abbricht, zählt als Fehlschlag.
|
||||
*/
|
||||
const val STABLE_CONNECTION_MS = 3_000L
|
||||
|
||||
/**
|
||||
* Advertisements treffen mehrmals je Sekunde ein. Öfter als hier
|
||||
* festgelegt wird nichts ausgewertet.
|
||||
*/
|
||||
const val MINIMUM_ADVERTISEMENT_INTERVAL_MS = 900L
|
||||
|
||||
const val RECONNECT_INTERVAL_MS = 15_000L
|
||||
const val CONTROL_LIFETIME_MS = 30_000L
|
||||
const val HISTORY_LIMIT = 720
|
||||
}
|
||||
|
||||
// MARK: - Start
|
||||
|
||||
fun start() {
|
||||
handler.post {
|
||||
refreshAdapterState()
|
||||
applyConfiguration(currentDevices())
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshConfiguration() {
|
||||
handler.post { applyConfiguration(currentDevices()) }
|
||||
}
|
||||
|
||||
private fun currentDevices(): List<ManagedDevice> = store.activeDevices().map {
|
||||
ManagedDevice(
|
||||
id = it.id,
|
||||
address = it.address,
|
||||
transport = it.role.transport,
|
||||
role = it.role,
|
||||
fridgeZoneMode = it.fridgeZoneMode,
|
||||
sensorOrientation = it.sensorOrientation,
|
||||
victronKey = store.victronKey(it.id),
|
||||
)
|
||||
}
|
||||
|
||||
private fun refreshAdapterState() {
|
||||
val a = adapter
|
||||
val ready = a != null && a.isEnabled
|
||||
val text = when {
|
||||
a == null -> "Dieses Gerät unterstützt kein Bluetooth LE"
|
||||
!a.isEnabled -> "Bluetooth ist ausgeschaltet"
|
||||
else -> "Bereit"
|
||||
}
|
||||
publish {
|
||||
isBluetoothReady = ready
|
||||
bluetoothStatusText = text
|
||||
}
|
||||
}
|
||||
|
||||
/** Schaltet die Anzeige der gefundenen Geräte beim Einrichten ein und aus. */
|
||||
fun setDiscovering(wanted: Boolean) {
|
||||
handler.post {
|
||||
if (discovering == wanted) return@post
|
||||
discovering = wanted
|
||||
if (!wanted) publish { discoveries.clear() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Ablauf auf dem Funk-Thread
|
||||
|
||||
private fun applyConfiguration(devices: List<ManagedDevice>) {
|
||||
managed = devices.associateBy { it.address }
|
||||
|
||||
// Verbindungen zu Geräten lösen, die nicht mehr dazugehören.
|
||||
val wanted = devices.filter { it.transport == DeviceTransport.CONNECT }
|
||||
.map { it.address }.toSet()
|
||||
for (address in bmsSessions.keys.toList()) {
|
||||
if (address !in wanted) {
|
||||
bmsSessions.remove(address)?.stop()
|
||||
}
|
||||
}
|
||||
for (address in levelSessions.keys.toList()) {
|
||||
if (address !in wanted) {
|
||||
levelSessions.remove(address)?.stop()
|
||||
}
|
||||
}
|
||||
// Einstellungen an bestehende Sitzungen weiterreichen.
|
||||
for (device in devices) {
|
||||
bmsSessions[device.address]?.fridgeZoneMode = device.fridgeZoneMode
|
||||
levelSessions[device.address]?.orientation = device.sensorOrientation
|
||||
}
|
||||
lastHandledAdvertisement.clear()
|
||||
nextConnectAttempt.clear()
|
||||
connectFailures.clear()
|
||||
connectedSince.clear()
|
||||
|
||||
startScanning()
|
||||
}
|
||||
|
||||
private fun startScanning() {
|
||||
val a = adapter ?: return
|
||||
if (!a.isEnabled) return
|
||||
restartScan()
|
||||
connectManagedDevices()
|
||||
scheduleReconnects()
|
||||
}
|
||||
|
||||
private fun restartScan() {
|
||||
val scanner = adapter?.bluetoothLeScanner ?: return
|
||||
if (scanning) scanner.stopScan(scanCallback)
|
||||
val settings = ScanSettings.Builder()
|
||||
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
|
||||
// Victron sendet seine Werte im Advertisement, also müssen auch
|
||||
// Wiederholungen durchgereicht werden.
|
||||
.setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
|
||||
.setReportDelay(0)
|
||||
.build()
|
||||
scanner.startScan(null, settings, scanCallback)
|
||||
scanning = true
|
||||
}
|
||||
|
||||
fun stopEverything() {
|
||||
handler.post {
|
||||
if (scanning) adapter?.bluetoothLeScanner?.stopScan(scanCallback)
|
||||
scanning = false
|
||||
reconnectRunnable?.let { handler.removeCallbacks(it) }
|
||||
bmsSessions.values.forEach { it.stop() }
|
||||
levelSessions.values.forEach { it.stop() }
|
||||
bmsSessions.clear()
|
||||
levelSessions.clear()
|
||||
connectedSince.clear()
|
||||
pendingControls.clear()
|
||||
}
|
||||
}
|
||||
|
||||
private var reconnectRunnable: Runnable? = null
|
||||
|
||||
/**
|
||||
* Verbindungen fallen im Fahrzeug regelmässig weg – deshalb regelmässig
|
||||
* nachfassen statt nur auf das Trennen zu reagieren.
|
||||
*/
|
||||
private fun scheduleReconnects() {
|
||||
reconnectRunnable?.let { handler.removeCallbacks(it) }
|
||||
val runnable = object : Runnable {
|
||||
override fun run() {
|
||||
connectManagedDevices()
|
||||
handler.postDelayed(this, RECONNECT_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
reconnectRunnable = runnable
|
||||
handler.postDelayed(runnable, RECONNECT_INTERVAL_MS)
|
||||
}
|
||||
|
||||
private fun connectManagedDevices() {
|
||||
managed.values
|
||||
.filter { it.transport == DeviceTransport.CONNECT }
|
||||
.forEach { connectIfNeeded(it) }
|
||||
}
|
||||
|
||||
private fun connectIfNeeded(device: ManagedDevice) {
|
||||
if (device.address in bmsSessions || device.address in levelSessions) return
|
||||
if (device.address in connecting) return
|
||||
// Nach einem Fehlschlag eine Weile Ruhe geben, sonst wird das Gerät im
|
||||
// Sekundentakt angeklopft.
|
||||
nextConnectAttempt[device.address]?.let {
|
||||
if (System.currentTimeMillis() < it) return
|
||||
}
|
||||
val remote = adapter?.getRemoteDevice(device.address) ?: return
|
||||
connecting.add(device.address)
|
||||
publish { linkStates[device.id] = DeviceLinkState.Connecting }
|
||||
|
||||
val provider: (BluetoothGattCallback) -> android.bluetooth.BluetoothGatt? = { cb ->
|
||||
remote.connectGatt(context, false, cb, BluetoothDevice.TRANSPORT_LE)
|
||||
}
|
||||
|
||||
if (device.role == DeviceRole.LEVELING) {
|
||||
val session = LevelSession(
|
||||
deviceID = device.id,
|
||||
gattProvider = provider,
|
||||
handler = handler,
|
||||
onUpdate = { record(it) },
|
||||
onStateChange = { handleLinkState(device, it) },
|
||||
onLevelState = { state -> publish { levelStates[device.id] = state } },
|
||||
)
|
||||
session.orientation = device.sensorOrientation
|
||||
levelSessions[device.address] = session
|
||||
session.start()
|
||||
return
|
||||
}
|
||||
|
||||
val session = BmsSession(
|
||||
deviceID = device.id,
|
||||
gattProvider = provider,
|
||||
handler = handler,
|
||||
onUpdate = { record(it) },
|
||||
onStateChange = { handleLinkState(device, it) },
|
||||
onDiagnostics = { info -> publish { bmsDiagnostics[device.id] = info } },
|
||||
)
|
||||
session.onFridgeState = { state ->
|
||||
fridgeStateCache[device.address] = state
|
||||
publish { fridgeStates[device.id] = state }
|
||||
}
|
||||
session.fridgeZoneMode = device.fridgeZoneMode
|
||||
bmsSessions[device.address] = session
|
||||
session.start()
|
||||
flushPendingControls(device.address, session)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wertet aus, was eine Sitzung über ihren Verbindungszustand meldet, und
|
||||
* pflegt daraus die Wartesperren.
|
||||
*/
|
||||
private fun handleLinkState(device: ManagedDevice, state: DeviceLinkState) {
|
||||
when (state) {
|
||||
is DeviceLinkState.Live -> {
|
||||
if (device.address !in connectedSince) {
|
||||
connectedSince[device.address] = System.currentTimeMillis()
|
||||
}
|
||||
connecting.remove(device.address)
|
||||
}
|
||||
|
||||
is DeviceLinkState.Searching -> {
|
||||
// Getrennt. Wie lange die Verbindung stand, entscheidet, wie
|
||||
// lange gewartet wird.
|
||||
val lifetime = connectedSince.remove(device.address)
|
||||
?.let { System.currentTimeMillis() - it } ?: 0
|
||||
connecting.remove(device.address)
|
||||
bmsSessions.remove(device.address)
|
||||
levelSessions.remove(device.address)
|
||||
if (lifetime >= STABLE_CONNECTION_MS) {
|
||||
// Die Verbindung stand und ist weggefallen – im Fahrzeug
|
||||
// der Normalfall. Kurz durchatmen, dann wieder ran, sonst
|
||||
// wäre das Gerät minutenlang nicht bedienbar.
|
||||
clearBackOff(device.address)
|
||||
nextConnectAttempt[device.address] =
|
||||
System.currentTimeMillis() + QUICK_RETRY_DELAY_MS
|
||||
} else {
|
||||
// Sofort wieder abgebrochen: das zählt wie ein Fehlschlag.
|
||||
backOff(device.address)
|
||||
}
|
||||
}
|
||||
|
||||
is DeviceLinkState.Failed -> {
|
||||
connecting.remove(device.address)
|
||||
bmsSessions.remove(device.address)
|
||||
levelSessions.remove(device.address)
|
||||
backOff(device.address)
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
publish { linkStates[device.id] = state }
|
||||
}
|
||||
|
||||
/**
|
||||
* Sperrt weitere Versuche für eine Weile. Der Abstand wächst mit jedem
|
||||
* Fehlschlag, damit ein dauerhaft unerreichbares Gerät nicht endlos
|
||||
* angeklopft wird.
|
||||
*/
|
||||
private fun backOff(address: String) {
|
||||
val failures = (connectFailures[address] ?: 0) + 1
|
||||
connectFailures[address] = failures
|
||||
val delay = min(
|
||||
FIRST_RETRY_DELAY_MS * 2.0.pow(failures - 1),
|
||||
LONGEST_RETRY_DELAY_MS.toDouble(),
|
||||
)
|
||||
nextConnectAttempt[address] = System.currentTimeMillis() + delay.toLong()
|
||||
}
|
||||
|
||||
private fun clearBackOff(address: String) {
|
||||
connectFailures.remove(address)
|
||||
nextConnectAttempt.remove(address)
|
||||
}
|
||||
|
||||
// MARK: - Scan
|
||||
|
||||
private val scanCallback = object : ScanCallback() {
|
||||
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||
handler.post { handleScanResult(result) }
|
||||
}
|
||||
|
||||
override fun onBatchScanResults(results: MutableList<ScanResult>) {
|
||||
handler.post { results.forEach { handleScanResult(it) } }
|
||||
}
|
||||
|
||||
override fun onScanFailed(errorCode: Int) {
|
||||
publish { bluetoothStatusText = "Suche fehlgeschlagen (Code $errorCode)" }
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleScanResult(result: ScanResult) {
|
||||
val address = result.device.address
|
||||
val now = System.currentTimeMillis()
|
||||
val record = result.scanRecord
|
||||
|
||||
// Android liefert die Herstellerdaten ohne die zwei Bytes der
|
||||
// Company-ID; die steht im Schlüssel der Tabelle. Wir setzen sie wieder
|
||||
// davor, damit der Rahmen genauso aussieht wie unter iOS.
|
||||
val manufacturerData = record?.manufacturerSpecificData?.let { table ->
|
||||
val index = table.indexOfKey(VictronAdvertisement.COMPANY_IDENTIFIER)
|
||||
if (index >= 0) {
|
||||
byteArrayOf(
|
||||
(VictronAdvertisement.COMPANY_IDENTIFIER and 0xFF).toByte(),
|
||||
(VictronAdvertisement.COMPANY_IDENTIFIER shr 8).toByte(),
|
||||
) + table.valueAt(index)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
if (discovering) {
|
||||
updateDiscovery(result, manufacturerData, now)
|
||||
}
|
||||
|
||||
val device = managed[address] ?: return
|
||||
val last = lastHandledAdvertisement[address] ?: 0
|
||||
if (now - last < MINIMUM_ADVERTISEMENT_INTERVAL_MS) return
|
||||
lastHandledAdvertisement[address] = now
|
||||
|
||||
when (device.transport) {
|
||||
DeviceTransport.ADVERTISEMENT ->
|
||||
manufacturerData?.let { handleVictron(it, device, result.rssi) }
|
||||
|
||||
DeviceTransport.CONNECT ->
|
||||
// Das Gerät wurde gesehen – falls die Verbindung fehlt, jetzt aufbauen.
|
||||
connectIfNeeded(device)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateDiscovery(result: ScanResult, manufacturerData: ByteArray?, now: Long) {
|
||||
val address = result.device.address
|
||||
val envelope = manufacturerData?.let { VictronAdvertisement.envelope(it) }
|
||||
val existing = discoveries[address]
|
||||
val discovery = Discovery(
|
||||
address = address,
|
||||
name = result.scanRecord?.deviceName ?: result.device.name,
|
||||
// Geglättet, sonst springt die Liste beim Lesen.
|
||||
rssi = existing?.let { (it.rssi * 2 + result.rssi) / 3 } ?: result.rssi,
|
||||
firstSeen = existing?.firstSeen ?: now,
|
||||
lastSeen = now,
|
||||
isVictron = envelope != null,
|
||||
victronRecord = envelope?.knownRecord?.name,
|
||||
serviceUUIDs = result.scanRecord?.serviceUuids?.map { it.uuid.toString() } ?: emptyList(),
|
||||
)
|
||||
publish { discoveries[address] = discovery }
|
||||
}
|
||||
|
||||
private fun handleVictron(data: ByteArray, device: ManagedDevice, rssi: Int) {
|
||||
val envelope = VictronAdvertisement.envelope(data) ?: return
|
||||
publish {
|
||||
diagnostics[device.id] = VictronDiagnostics(
|
||||
expectedKeyFirstByte = envelope.keyCheckByte,
|
||||
recordName = envelope.knownRecord?.name ?: "unbekannt (0x%02X)".format(envelope.recordType),
|
||||
productIDText = envelope.productIDText,
|
||||
rawHex = data.toHexText(),
|
||||
)
|
||||
}
|
||||
|
||||
val key = device.victronKey
|
||||
if (key == null || key.size != 16) {
|
||||
publish { linkStates[device.id] = DeviceLinkState.NeedsKey }
|
||||
return
|
||||
}
|
||||
try {
|
||||
val snapshot = VictronAdvertisement.decode(data, key, device.id, rssi)
|
||||
publish { linkStates[device.id] = DeviceLinkState.Live }
|
||||
record(snapshot)
|
||||
} catch (error: VictronAdvertisement.DecodeError) {
|
||||
publish { linkStates[device.id] = DeviceLinkState.Failed(error.describe) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Verlauf
|
||||
|
||||
private fun record(snapshot: DeviceSnapshot) {
|
||||
publish {
|
||||
snapshots[snapshot.deviceID] = snapshot
|
||||
snapshot.primaryMetric?.value?.let { value ->
|
||||
val samples = history.getOrPut(snapshot.deviceID) { mutableListOf() }
|
||||
samples.add(HistorySample(snapshot.timestamp, value))
|
||||
while (samples.size > HISTORY_LIMIT) samples.removeAt(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Kühlbox steuern
|
||||
|
||||
fun setFridgeTarget(celsius: Int, zone: FridgeZone, deviceID: UUID) {
|
||||
sendFridgeSettings(deviceID) { AlpicoolState.setTarget(zone, celsius) }
|
||||
}
|
||||
|
||||
fun setFridgePower(on: Boolean, deviceID: UUID) {
|
||||
sendFridgeSettings(deviceID) { it.settingsCommand(poweredOn = on) }
|
||||
}
|
||||
|
||||
fun setFridgeEco(eco: Boolean, deviceID: UUID) {
|
||||
sendFridgeSettings(deviceID) { it.settingsCommand(eco = eco) }
|
||||
}
|
||||
|
||||
fun setFridgeLock(locked: Boolean, deviceID: UUID) {
|
||||
sendFridgeSettings(deviceID) { it.settingsCommand(locked = locked) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Einstellungsblock wird aus dem zuletzt empfangenen Zustand gebaut –
|
||||
* und zwar auf dem Funk-Thread, wo dieser Zustand lebt.
|
||||
*
|
||||
* Fehlt die Verbindung gerade, wird der Befehl aufgehoben und ein Versuch
|
||||
* angestossen. Ein Tippen darf nicht daran scheitern, dass die Box sich
|
||||
* zwei Sekunden vorher abgemeldet hat.
|
||||
*/
|
||||
private fun sendFridgeSettings(deviceID: UUID, build: (AlpicoolState) -> ByteArray?) {
|
||||
handler.post {
|
||||
val device = managed.values.firstOrNull { it.id == deviceID } ?: return@post
|
||||
val session = bmsSessions[device.address]
|
||||
if (session != null) {
|
||||
build(session.alpicoolState)?.let { session.sendControl(it) }
|
||||
return@post
|
||||
}
|
||||
val known = fridgeStateCache[device.address] ?: return@post
|
||||
val packet = build(known) ?: return@post
|
||||
val waiting = pendingControls.getOrPut(device.address) { mutableListOf() }
|
||||
waiting.add(packet to System.currentTimeMillis())
|
||||
// Wer mehrfach tippt, meint den letzten Stand.
|
||||
while (waiting.size > 4) waiting.removeAt(0)
|
||||
|
||||
// Der Wunsch des Benutzers hebt die Wartesperre auf.
|
||||
clearBackOff(device.address)
|
||||
connectIfNeeded(device)
|
||||
}
|
||||
}
|
||||
|
||||
/** Gibt weiter, was während der Trennung aufgelaufen ist. */
|
||||
private fun flushPendingControls(address: String, session: BmsSession) {
|
||||
val waiting = pendingControls.remove(address) ?: return
|
||||
val now = System.currentTimeMillis()
|
||||
waiting.filter { now - it.second < CONTROL_LIFETIME_MS }
|
||||
.forEachIndexed { index, entry ->
|
||||
handler.postDelayed({ session.sendControl(entry.first) }, index * 400L)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateFridgeZoneMode(device: ConfiguredDevice) {
|
||||
handler.post {
|
||||
val session = bmsSessions[device.address] ?: return@post
|
||||
session.fridgeZoneMode = device.fridgeZoneMode
|
||||
val state = session.alpicoolState
|
||||
publish { fridgeStates[device.id] = state }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Neigungsmesser
|
||||
|
||||
fun updateSensorOrientation(device: ConfiguredDevice) {
|
||||
handler.post { levelSessions[device.address]?.orientation = device.sensorOrientation }
|
||||
}
|
||||
|
||||
fun calibrateLevel(deviceID: UUID) {
|
||||
handler.post { levelSession(deviceID)?.calibrate() }
|
||||
}
|
||||
|
||||
fun resetLevelCalibration(deviceID: UUID) {
|
||||
handler.post { levelSession(deviceID)?.resetCalibration() }
|
||||
}
|
||||
|
||||
private fun levelSession(deviceID: UUID): LevelSession? {
|
||||
val device = managed.values.firstOrNull { it.id == deviceID } ?: return null
|
||||
return levelSessions[device.address]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,859 @@
|
||||
package de.fritob.campermonitor.bluetooth
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothGatt
|
||||
import android.bluetooth.BluetoothGattCallback
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import android.bluetooth.BluetoothGattDescriptor
|
||||
import android.bluetooth.BluetoothProfile
|
||||
import android.os.Handler
|
||||
import de.fritob.campermonitor.protocol.AlpicoolProtocol
|
||||
import de.fritob.campermonitor.protocol.AlpicoolState
|
||||
import de.fritob.campermonitor.protocol.DalyProtocol
|
||||
import de.fritob.campermonitor.protocol.DalyState
|
||||
import de.fritob.campermonitor.protocol.DeviceLinkState
|
||||
import de.fritob.campermonitor.protocol.DeviceSnapshot
|
||||
import de.fritob.campermonitor.protocol.FridgeZoneMode
|
||||
import de.fritob.campermonitor.protocol.JbdProtocol
|
||||
import de.fritob.campermonitor.protocol.JbdState
|
||||
import de.fritob.campermonitor.protocol.WattCycleProtocol
|
||||
import de.fritob.campermonitor.protocol.WattCycleState
|
||||
import java.util.UUID
|
||||
|
||||
/** Was die Diagnoseansicht über eine laufende Sitzung zeigt. */
|
||||
data class BmsDiagnostics(
|
||||
val dialect: String,
|
||||
val endpointLabel: String? = null,
|
||||
val endpointPosition: Pair<Int, Int>? = null,
|
||||
val serviceUUID: String? = null,
|
||||
val isConnected: Boolean = false,
|
||||
val isNotifyActive: Boolean = false,
|
||||
/** Nur bei Kühlboxen: ob die Box die Anmeldung beantwortet hat. */
|
||||
val isBound: Boolean? = null,
|
||||
val confirmedWrites: Int = 0,
|
||||
val lastWriteError: String? = null,
|
||||
val gattSummary: List<String> = emptyList(),
|
||||
val sentFrames: Int = 0,
|
||||
val receivedBytes: Int = 0,
|
||||
val lastSendAt: Long? = null,
|
||||
val lastResponseHex: String? = null,
|
||||
val lastCommandHex: String? = null,
|
||||
val lastCommandAt: Long? = null,
|
||||
/** Nur bei Kühlboxen: die vollständige Nutzlast der letzten Statusantwort. */
|
||||
val fridgePayloadHex: String? = null,
|
||||
val updated: Long = System.currentTimeMillis(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Hält die GATT-Verbindung zu einem BMS oder einer Kühlbox, fragt die Werte ab
|
||||
* und meldet fertige Snapshots zurück.
|
||||
*
|
||||
* Zwei Dinge sind bei diesen Geräten nicht vorhersehbar und werden deshalb
|
||||
* ausprobiert statt vorausgesetzt:
|
||||
*
|
||||
* 1. **Über welche Charakteristiken gesprochen wird.** Im selben Dienst sehen
|
||||
* oft mehrere Charakteristiken beschreibbar aus, nur eine nimmt aber
|
||||
* wirklich Kommandos an.
|
||||
* 2. **Welches Protokoll gesprochen wird.** Der erste gültige Rahmen legt den
|
||||
* Dialekt fest.
|
||||
*
|
||||
* Alles läuft auf dem übergebenen [Handler] – demselben, auf dem der
|
||||
* BluetoothManager arbeitet. Android ruft die GATT-Rückmeldungen auf einem
|
||||
* eigenen Binder-Thread auf; sie werden deshalb konsequent auf diesen Handler
|
||||
* geschoben, damit der Zustand dieser Klasse nur von einem Thread aus
|
||||
* angefasst wird.
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
class BmsSession(
|
||||
private val deviceID: UUID,
|
||||
private val gattProvider: (BluetoothGattCallback) -> BluetoothGatt?,
|
||||
private val handler: Handler,
|
||||
private val onUpdate: (DeviceSnapshot) -> Unit,
|
||||
private val onStateChange: (DeviceLinkState) -> Unit,
|
||||
private val onDiagnostics: (BmsDiagnostics) -> Unit,
|
||||
) {
|
||||
/** Meldet Änderungen am Kühlbox-Zustand, damit die Bedienelemente folgen. */
|
||||
var onFridgeState: ((AlpicoolState) -> Unit)? = null
|
||||
|
||||
enum class Dialect(val label: String) {
|
||||
UNKNOWN("wird ermittelt"),
|
||||
DALY_CLASSIC("Daly (klassisch)"),
|
||||
DALY_MODBUS("Daly (Modbus)"),
|
||||
JBD("JBD / Xiaoxiang"),
|
||||
WATT_CYCLE("WattCycle"),
|
||||
ALPICOOL("Alpicool-Kühlbox"),
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Bekannte Paare, die zuerst versucht werden. */
|
||||
val KNOWN_PAIRS = listOf(
|
||||
Triple("FFF0", "FFF2", "FFF1"), // Daly und viele baugleiche Module
|
||||
Triple("FF00", "FF02", "FF01"), // JBD / Xiaoxiang
|
||||
Triple("FFE0", "FFE1", "FFE1"),
|
||||
Triple(
|
||||
"6E400001-B5A3-F393-E0A9-E50E24DCCA9E",
|
||||
"6E400002-B5A3-F393-E0A9-E50E24DCCA9E",
|
||||
"6E400003-B5A3-F393-E0A9-E50E24DCCA9E",
|
||||
), // Nordic UART
|
||||
)
|
||||
|
||||
val ALPICOOL_WRITE = shortUuid("1235")
|
||||
val ALPICOOL_NOTIFY = shortUuid("1236")
|
||||
|
||||
/**
|
||||
* Freischalt-Charakteristik der WattCycle-Akkus. Liegt im selben Dienst
|
||||
* wie Schreiben und Empfangen und muss vor der ersten Abfrage
|
||||
* beschrieben werden, sonst bleibt der Akku stumm.
|
||||
*/
|
||||
val WATTCYCLE_AUTH = shortUuid("FFFA")
|
||||
|
||||
val CLIENT_CONFIG: UUID = UUID.fromString("00002902-0000-1000-8000-00805F9B34FB")
|
||||
|
||||
const val SEARCH_INTERVAL_MS = 3_000L
|
||||
const val POLL_INTERVAL_MS = 5_000L
|
||||
|
||||
/** So lange darf ein aufgehobener Stellbefehl warten. */
|
||||
const val CONTROL_LIFETIME_MS = 30_000L
|
||||
|
||||
fun shortUuid(short: String): UUID =
|
||||
UUID.fromString("0000${short.padStart(4, '0')}-0000-1000-8000-00805F9B34FB")
|
||||
|
||||
fun uuidOf(text: String): UUID =
|
||||
if (text.length == 4) shortUuid(text) else UUID.fromString(text)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Kandidat: worüber geschrieben, worüber gelauscht und wie geschrieben
|
||||
* wird. Der Schreibmodus gehört dazu, weil manche Module nur die eine oder
|
||||
* nur die andere Variante annehmen.
|
||||
*/
|
||||
private class Endpoint(
|
||||
val write: BluetoothGattCharacteristic,
|
||||
val notify: BluetoothGattCharacteristic,
|
||||
/** Falls vorhanden, wird hierauf vor der ersten Abfrage freigeschaltet. */
|
||||
val auth: BluetoothGattCharacteristic?,
|
||||
val writeType: Int,
|
||||
val isKnownPair: Boolean,
|
||||
) {
|
||||
val label: String
|
||||
get() {
|
||||
val mode = if (writeType == BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE) {
|
||||
"ohne Bestätigung"
|
||||
} else {
|
||||
"mit Bestätigung"
|
||||
}
|
||||
val unlock = auth?.let { ", Freischaltung über ${it.uuid}" } ?: ""
|
||||
return "${write.uuid} → ${notify.uuid}, $mode$unlock"
|
||||
}
|
||||
}
|
||||
|
||||
private var gatt: BluetoothGatt? = null
|
||||
private var endpoints: List<Endpoint> = emptyList()
|
||||
private var endpointIndex = 0
|
||||
private val currentEndpoint: Endpoint? get() = endpoints.getOrNull(endpointIndex)
|
||||
|
||||
var dialect: Dialect = Dialect.UNKNOWN
|
||||
private set
|
||||
|
||||
private val dalyState = DalyState()
|
||||
private val jbdState = JbdState()
|
||||
private val wattCycleState = WattCycleState()
|
||||
val alpicoolState = AlpicoolState()
|
||||
|
||||
/** Aus den Geräteeinstellungen; übersteuert die automatische Erkennung. */
|
||||
var fridgeZoneMode: FridgeZoneMode = FridgeZoneMode.AUTOMATIC
|
||||
set(value) {
|
||||
field = value
|
||||
alpicoolState.zoneMode = value
|
||||
}
|
||||
|
||||
private var didBind = false
|
||||
private var bindAcknowledged = false
|
||||
|
||||
/**
|
||||
* Ob vor einem Stellbefehl schon einmal nachgemeldet wurde. Jedes Mal
|
||||
* anzumelden lässt die Box bei jedem Tastendruck erneut piepen.
|
||||
*/
|
||||
private var didRebindForControl = false
|
||||
|
||||
private var buffer = ByteArray(0)
|
||||
private var lastResponse: ByteArray? = null
|
||||
private var lastCommand: ByteArray? = null
|
||||
private var lastCommandAt: Long? = null
|
||||
private var receivedByteCount = 0
|
||||
private var sentFrameCount = 0
|
||||
private var gattSummary = listOf<String>()
|
||||
private var silentRounds = 0
|
||||
|
||||
/**
|
||||
* Zählt hoch, sobald ein Kandidat aktiviert wird. Späte Rückmeldungen eines
|
||||
* bereits verworfenen Kandidaten lassen sich so ignorieren.
|
||||
*/
|
||||
private var activationToken = 0
|
||||
private var isNotifyActive = false
|
||||
private var didUnlock = false
|
||||
private var lastSendAt: Long? = null
|
||||
private var confirmedWrites = 0
|
||||
private var lastWriteError: String? = null
|
||||
|
||||
/**
|
||||
* Stellbefehle, die kamen, bevor der Kanal stand. Sie jetzt schon zu senden
|
||||
* hiesse, sie an einen womöglich falschen Kandidaten zu schicken; sie
|
||||
* fallen zu lassen hiesse, ein Tippen zu verschlucken.
|
||||
*/
|
||||
private val waitingControls = mutableListOf<Pair<ByteArray, Long>>()
|
||||
|
||||
private var pollRunnable: Runnable? = null
|
||||
|
||||
// MARK: - Lebenszyklus
|
||||
|
||||
fun start() {
|
||||
onStateChange(DeviceLinkState.Connecting)
|
||||
gatt = gattProvider(callback)
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
cancelPoll()
|
||||
val endpoint = currentEndpoint
|
||||
val g = gatt
|
||||
if (endpoint != null && g != null) {
|
||||
g.setCharacteristicNotification(endpoint.notify, false)
|
||||
}
|
||||
endpoints = emptyList()
|
||||
endpointIndex = 0
|
||||
dialect = Dialect.UNKNOWN
|
||||
buffer = ByteArray(0)
|
||||
gatt?.close()
|
||||
gatt = null
|
||||
}
|
||||
|
||||
fun handleDisconnect() {
|
||||
cancelPoll()
|
||||
endpoints = emptyList()
|
||||
endpointIndex = 0
|
||||
buffer = ByteArray(0)
|
||||
gatt?.close()
|
||||
gatt = null
|
||||
}
|
||||
|
||||
// MARK: - Kandidaten
|
||||
|
||||
/**
|
||||
* Stellt nach der Dienstsuche alle Paare zusammen: bekannte Kombinationen
|
||||
* zuerst, danach jede andere Schreib-/Notify-Kombination im selben Dienst.
|
||||
*/
|
||||
private fun buildEndpoints(g: BluetoothGatt) {
|
||||
val candidates = mutableListOf<Endpoint>()
|
||||
val summary = mutableListOf<String>()
|
||||
|
||||
for (service in g.services) {
|
||||
summary.add("Dienst ${service.uuid}")
|
||||
val characteristics = service.characteristics
|
||||
for (c in characteristics) {
|
||||
summary.add(" ${c.uuid} ${propertyText(c)}")
|
||||
}
|
||||
|
||||
val writable = characteristics.filter {
|
||||
it.properties and BluetoothGattCharacteristic.PROPERTY_WRITE != 0 ||
|
||||
it.properties and BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE != 0
|
||||
}
|
||||
val notifying = characteristics.filter {
|
||||
it.properties and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0 ||
|
||||
it.properties and BluetoothGattCharacteristic.PROPERTY_INDICATE != 0
|
||||
}
|
||||
if (writable.isEmpty() || notifying.isEmpty()) continue
|
||||
|
||||
val auth = characteristics.firstOrNull {
|
||||
it.uuid == WATTCYCLE_AUTH &&
|
||||
(it.properties and BluetoothGattCharacteristic.PROPERTY_WRITE != 0 ||
|
||||
it.properties and BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE != 0)
|
||||
}
|
||||
|
||||
for (write in writable) {
|
||||
if (write.uuid == WATTCYCLE_AUTH) continue
|
||||
for (notify in notifying) {
|
||||
val isFridgePair = write.uuid == ALPICOOL_WRITE && notify.uuid == ALPICOOL_NOTIFY
|
||||
val known = isFridgePair || KNOWN_PAIRS.any {
|
||||
uuidOf(it.first) == service.uuid &&
|
||||
uuidOf(it.second) == write.uuid &&
|
||||
uuidOf(it.third) == notify.uuid
|
||||
}
|
||||
// Beide Schreibarten anbieten, sofern das Gerät sie kann.
|
||||
if (write.properties and BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE != 0) {
|
||||
candidates.add(
|
||||
Endpoint(write, notify, auth,
|
||||
BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE, known)
|
||||
)
|
||||
}
|
||||
if (write.properties and BluetoothGattCharacteristic.PROPERTY_WRITE != 0) {
|
||||
candidates.add(
|
||||
Endpoint(write, notify, auth,
|
||||
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT, known)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
gattSummary = summary
|
||||
|
||||
// Bekannte Paare nach vorn, der Rest in Fundreihenfolge. Die
|
||||
// Fundreihenfolge muss dabei erhalten bleiben, sonst entschiede der
|
||||
// Zufall, ob mit oder ohne Bestätigung geschrieben wird - `sortedBy`
|
||||
// ist in Kotlin stabil und genau dafür da.
|
||||
endpoints = candidates.sortedBy { if (it.isKnownPair) 0 else 1 }
|
||||
endpointIndex = 0
|
||||
|
||||
if (endpoints.isEmpty()) {
|
||||
onStateChange(DeviceLinkState.Failed("Keine passenden Bluetooth-Merkmale gefunden"))
|
||||
publishDiagnostics()
|
||||
return
|
||||
}
|
||||
activateCurrentEndpoint()
|
||||
}
|
||||
|
||||
private fun propertyText(c: BluetoothGattCharacteristic): String {
|
||||
val parts = mutableListOf<String>()
|
||||
val p = c.properties
|
||||
if (p and BluetoothGattCharacteristic.PROPERTY_READ != 0) parts.add("read")
|
||||
if (p and BluetoothGattCharacteristic.PROPERTY_WRITE != 0) parts.add("write")
|
||||
if (p and BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE != 0) parts.add("write-nr")
|
||||
if (p and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0) parts.add("notify")
|
||||
if (p and BluetoothGattCharacteristic.PROPERTY_INDICATE != 0) parts.add("indicate")
|
||||
return parts.joinToString(", ")
|
||||
}
|
||||
|
||||
private fun activateCurrentEndpoint() {
|
||||
val endpoint = currentEndpoint ?: return
|
||||
val g = gatt ?: return
|
||||
silentRounds = 0
|
||||
isNotifyActive = false
|
||||
didUnlock = false
|
||||
didBind = false
|
||||
bindAcknowledged = false
|
||||
didRebindForControl = false
|
||||
buffer = ByteArray(0)
|
||||
activationToken += 1
|
||||
val token = activationToken
|
||||
|
||||
g.setCharacteristicNotification(endpoint.notify, true)
|
||||
// Ohne geschriebenen Deskriptor schickt das Gerät nichts – anders als
|
||||
// unter iOS reicht das Einschalten auf unserer Seite nicht.
|
||||
endpoint.notify.getDescriptor(CLIENT_CONFIG)?.let { descriptor ->
|
||||
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
||||
g.writeDescriptor(descriptor)
|
||||
}
|
||||
publishDiagnostics()
|
||||
|
||||
// Manche Module bestätigen das Abonnieren nie. Ohne Zeitlimit bliebe
|
||||
// die Suche hier für immer stehen, ohne je etwas zu senden.
|
||||
handler.postDelayed({
|
||||
if (activationToken != token || isNotifyActive) return@postDelayed
|
||||
if (endpoints.size > 1) advanceEndpoint() else beginPolling()
|
||||
}, 4_000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wechselt auf den nächsten Kandidaten. Sind alle durch, wird von vorn
|
||||
* begonnen – das Gerät kann zwischenzeitlich aufgewacht sein.
|
||||
*/
|
||||
private fun advanceEndpoint() {
|
||||
val previous = currentEndpoint ?: return
|
||||
gatt?.setCharacteristicNotification(previous.notify, false)
|
||||
endpointIndex = (endpointIndex + 1) % endpoints.size
|
||||
onStateChange(DeviceLinkState.Connecting)
|
||||
activateCurrentEndpoint()
|
||||
beginPolling()
|
||||
}
|
||||
|
||||
/** Derselbe Kanal, nur mit der anderen Schreibart. */
|
||||
private fun alternateWriteTypeIndex(): Int? {
|
||||
val current = currentEndpoint ?: return null
|
||||
val index = endpoints.indexOfFirst {
|
||||
it.write.uuid == current.write.uuid &&
|
||||
it.notify.uuid == current.notify.uuid &&
|
||||
it.writeType != current.writeType
|
||||
}
|
||||
return if (index >= 0) index else null
|
||||
}
|
||||
|
||||
private fun unlockThenPoll() {
|
||||
val endpoint = currentEndpoint ?: return
|
||||
val auth = endpoint.auth
|
||||
val token = activationToken
|
||||
if (auth == null || didUnlock) {
|
||||
beginPolling()
|
||||
return
|
||||
}
|
||||
// WattCycle verlangt den Text "HiLink", bevor es überhaupt antwortet.
|
||||
writeRaw(auth, WattCycleProtocol.authPayload, endpoint.writeType)
|
||||
didUnlock = true
|
||||
publishDiagnostics()
|
||||
handler.postDelayed({
|
||||
if (activationToken == token) beginPolling()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// MARK: - Abfragen
|
||||
|
||||
private fun cancelPoll() {
|
||||
pollRunnable?.let { handler.removeCallbacks(it) }
|
||||
pollRunnable = null
|
||||
}
|
||||
|
||||
private fun beginPolling() {
|
||||
cancelPoll()
|
||||
flushWaitingControls()
|
||||
poll()
|
||||
val interval = if (dialect == Dialect.UNKNOWN) SEARCH_INTERVAL_MS else POLL_INTERVAL_MS
|
||||
val runnable = object : Runnable {
|
||||
override fun run() {
|
||||
poll()
|
||||
handler.postDelayed(this, interval)
|
||||
}
|
||||
}
|
||||
pollRunnable = runnable
|
||||
handler.postDelayed(runnable, interval)
|
||||
}
|
||||
|
||||
private fun poll() {
|
||||
if (currentEndpoint == null) return
|
||||
when (dialect) {
|
||||
Dialect.UNKNOWN ->
|
||||
// Alle Protokolle anfragen; was antwortet, gewinnt.
|
||||
sendSequence(
|
||||
listOf(
|
||||
AlpicoolProtocol.packet(AlpicoolProtocol.Command.BIND),
|
||||
AlpicoolProtocol.packet(AlpicoolProtocol.Command.QUERY),
|
||||
WattCycleProtocol.requestFrame(WattCycleProtocol.Datapoint.ANALOG),
|
||||
DalyProtocol.requestFrame(DalyProtocol.Command.SOC),
|
||||
JbdProtocol.requestFrame(JbdProtocol.Command.BASIC_INFO),
|
||||
DalyProtocol.modbusReadFrame(),
|
||||
),
|
||||
spacingMs = 600, graceMs = 3_000,
|
||||
)
|
||||
|
||||
Dialect.DALY_CLASSIC ->
|
||||
sendSequence(
|
||||
DalyProtocol.Command.entries.map { DalyProtocol.requestFrame(it) },
|
||||
spacingMs = 250, graceMs = 2_000,
|
||||
)
|
||||
|
||||
Dialect.DALY_MODBUS ->
|
||||
sendSequence(listOf(DalyProtocol.modbusReadFrame()), spacingMs = 250, graceMs = 2_000)
|
||||
|
||||
Dialect.JBD ->
|
||||
sendSequence(
|
||||
JbdProtocol.Command.entries.map { JbdProtocol.requestFrame(it) },
|
||||
spacingMs = 250, graceMs = 2_000,
|
||||
)
|
||||
|
||||
Dialect.WATT_CYCLE -> {
|
||||
// Modell und Seriennummer ändern sich nie – nur einmal abfragen.
|
||||
val frames = mutableListOf(
|
||||
WattCycleProtocol.requestFrame(WattCycleProtocol.Datapoint.ANALOG)
|
||||
)
|
||||
if (!wattCycleState.hasProductInfo) {
|
||||
frames.add(WattCycleProtocol.requestFrame(WattCycleProtocol.Datapoint.PRODUCT))
|
||||
}
|
||||
sendSequence(frames, spacingMs = 300, graceMs = 2_000)
|
||||
}
|
||||
|
||||
Dialect.ALPICOOL -> {
|
||||
// Die Anmeldung gilt für die Dauer der Verbindung.
|
||||
val frames = mutableListOf<ByteArray>()
|
||||
if (!didBind) {
|
||||
frames.add(AlpicoolProtocol.packet(AlpicoolProtocol.Command.BIND))
|
||||
didBind = true
|
||||
}
|
||||
frames.add(AlpicoolProtocol.packet(AlpicoolProtocol.Command.QUERY))
|
||||
sendSequence(frames, spacingMs = 300, graceMs = 2_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kommandos leicht versetzt senden – manche Module verschlucken Anfragen,
|
||||
* die zu dicht aufeinander folgen.
|
||||
*/
|
||||
private fun sendSequence(frames: List<ByteArray>, spacingMs: Long, graceMs: Long) {
|
||||
frames.forEachIndexed { index, frame ->
|
||||
handler.postDelayed({ send(frame) }, index * spacingMs)
|
||||
}
|
||||
checkForSilence(frames.size * spacingMs + graceMs)
|
||||
}
|
||||
|
||||
/** Kommt nichts Brauchbares zurück, wird der nächste Kandidat versucht. */
|
||||
private fun checkForSilence(delayMs: Long) {
|
||||
handler.postDelayed({
|
||||
if (gatt == null) return@postDelayed
|
||||
if (hasUsableData) {
|
||||
silentRounds = 0
|
||||
return@postDelayed
|
||||
}
|
||||
silentRounds += 1
|
||||
publishDiagnostics()
|
||||
|
||||
// Solange noch kein Protokoll steht, zügig weiterprobieren.
|
||||
val limit = if (dialect == Dialect.UNKNOWN) 1 else 3
|
||||
if (silentRounds <= limit) return@postDelayed
|
||||
|
||||
silentRounds = 0
|
||||
if (endpoints.size > 1) {
|
||||
advanceEndpoint()
|
||||
} else {
|
||||
dialect = Dialect.UNKNOWN
|
||||
onStateChange(DeviceLinkState.Failed("Keine Antwort vom Gerät"))
|
||||
}
|
||||
}, delayMs)
|
||||
}
|
||||
|
||||
private val hasUsableData: Boolean
|
||||
get() = dalyState.hasUsableData || jbdState.hasUsableData ||
|
||||
wattCycleState.hasUsableData || alpicoolState.hasStatus
|
||||
|
||||
// MARK: - Senden
|
||||
|
||||
private fun send(data: ByteArray) {
|
||||
val endpoint = currentEndpoint ?: return
|
||||
val g = gatt ?: return
|
||||
sentFrameCount += 1
|
||||
lastSendAt = System.currentTimeMillis()
|
||||
|
||||
val pieces = AlpicoolProtocol.chunks(data, writeLimit(endpoint))
|
||||
pieces.forEachIndexed { index, piece ->
|
||||
if (index == 0) {
|
||||
writeRaw(endpoint.write, piece, endpoint.writeType)
|
||||
} else {
|
||||
handler.postDelayed(
|
||||
{ currentEndpoint?.let { writeRaw(it.write, piece, it.writeType) } },
|
||||
index * AlpicoolProtocol.CHUNK_DELAY_MS,
|
||||
)
|
||||
}
|
||||
}
|
||||
publishDiagnostics()
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun writeRaw(
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
data: ByteArray,
|
||||
writeType: Int,
|
||||
) {
|
||||
val g = gatt ?: return
|
||||
characteristic.writeType = writeType
|
||||
characteristic.value = data
|
||||
g.writeCharacteristic(characteristic)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wieviel je Schreibvorgang rausgeht.
|
||||
*
|
||||
* Grundsätzlich das, was die Verbindung hergibt. Die Kühlboxen nehmen aber
|
||||
* nur die 20 Byte der Standard-MTU an, auch wenn eine grössere ausgehandelt
|
||||
* wurde. Ohne diese Grenze ginge der Einstellungsblock als ein
|
||||
* Schreibvorgang raus – und die Box würde ihn ablehnen, während die kurzen
|
||||
* Befehle durchgehen. Belegt an einer IceCube Dual.
|
||||
*/
|
||||
private fun writeLimit(endpoint: Endpoint): Int =
|
||||
if (dialect == Dialect.ALPICOOL) AlpicoolProtocol.MAX_WRITE_SIZE else negotiatedWriteLimit
|
||||
|
||||
/** Was die ausgehandelte MTU je Schreibvorgang zulässt. */
|
||||
private var negotiatedWriteLimit = 20
|
||||
|
||||
// MARK: - Steuern
|
||||
|
||||
/**
|
||||
* Schickt einen Stellbefehl und fragt kurz darauf den Zustand ab, damit die
|
||||
* Anzeige dem Gerät folgt statt der Vermutung.
|
||||
*/
|
||||
fun sendControl(packet: ByteArray) {
|
||||
lastCommand = packet
|
||||
lastCommandAt = System.currentTimeMillis()
|
||||
|
||||
// Erst wenn der Dialekt steht, ist auch der richtige Kanal bekannt.
|
||||
if (dialect != Dialect.ALPICOOL || currentEndpoint == null || gatt == null) {
|
||||
waitingControls.add(packet to System.currentTimeMillis())
|
||||
while (waitingControls.size > 4) waitingControls.removeAt(0)
|
||||
publishDiagnostics()
|
||||
return
|
||||
}
|
||||
|
||||
// Hat die Box die Anmeldung nie beantwortet, wird sie einmal je
|
||||
// Verbindung nachgeholt. Jedes Mal anzumelden lässt die Box bei jedem
|
||||
// Tastendruck zusätzlich piepen.
|
||||
if (!bindAcknowledged && !didRebindForControl) {
|
||||
didRebindForControl = true
|
||||
send(AlpicoolProtocol.packet(AlpicoolProtocol.Command.BIND))
|
||||
handler.postDelayed({ deliverControl(packet, attempt = 0) }, 400)
|
||||
return
|
||||
}
|
||||
deliverControl(packet, attempt = 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Schickt den Befehl und prüft, ob er gewirkt hat.
|
||||
*
|
||||
* Manche Module nehmen nur eine der beiden Schreibarten an und melden das
|
||||
* nicht – der Befehl verschwindet dann lautlos. Bleiben die Einstellungen
|
||||
* der Box unverändert, wird deshalb einmal mit der anderen Art nachgesetzt.
|
||||
*/
|
||||
private fun deliverControl(packet: ByteArray, attempt: Int) {
|
||||
val before = alpicoolState.settingsFingerprint
|
||||
val token = activationToken
|
||||
send(packet)
|
||||
|
||||
// Genug Abstand, damit ein aufgeteiltes Paket vollständig draussen ist.
|
||||
handler.postDelayed({
|
||||
if (dialect == Dialect.ALPICOOL) {
|
||||
send(AlpicoolProtocol.packet(AlpicoolProtocol.Command.QUERY))
|
||||
}
|
||||
}, 1_000)
|
||||
|
||||
if (attempt > 0) return
|
||||
handler.postDelayed({
|
||||
if (activationToken != token || dialect != Dialect.ALPICOOL || gatt == null) {
|
||||
return@postDelayed
|
||||
}
|
||||
if (alpicoolState.settingsFingerprint != before) return@postDelayed
|
||||
val index = alternateWriteTypeIndex() ?: return@postDelayed
|
||||
endpointIndex = index
|
||||
publishDiagnostics()
|
||||
deliverControl(packet, attempt = 1)
|
||||
}, 3_500)
|
||||
}
|
||||
|
||||
/** Schickt raus, was während des Verbindungsaufbaus aufgelaufen ist. */
|
||||
private fun flushWaitingControls() {
|
||||
if (dialect != Dialect.ALPICOOL || waitingControls.isEmpty()) return
|
||||
val now = System.currentTimeMillis()
|
||||
val due = waitingControls.filter { now - it.second < CONTROL_LIFETIME_MS }
|
||||
waitingControls.clear()
|
||||
due.forEachIndexed { index, entry ->
|
||||
handler.postDelayed({ sendControl(entry.first) }, index * 400L)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Auswertung
|
||||
|
||||
private fun consume(data: ByteArray) {
|
||||
lastResponse = data
|
||||
receivedByteCount += data.size
|
||||
buffer += data
|
||||
if (buffer.size > 512) buffer = buffer.copyOfRange(buffer.size - 512, buffer.size)
|
||||
|
||||
// Steht der Dialekt fest, nur noch diesen prüfen. Bei jeder Antwort
|
||||
// alle fünf Parser durchzugehen belastet ohne Nutzen.
|
||||
when (dialect) {
|
||||
Dialect.ALPICOOL -> { consumeAlpicool(); return }
|
||||
Dialect.WATT_CYCLE -> { consumeWattCycle(); return }
|
||||
Dialect.JBD -> { consumeJbd(); return }
|
||||
Dialect.DALY_MODBUS -> { consumeDalyModbus(); return }
|
||||
Dialect.DALY_CLASSIC -> { consumeDalyClassic(); return }
|
||||
Dialect.UNKNOWN -> Unit
|
||||
}
|
||||
|
||||
if (consumeAlpicool()) return
|
||||
if (consumeWattCycle()) return
|
||||
if (consumeJbd()) return
|
||||
if (consumeDalyModbus()) return
|
||||
if (consumeDalyClassic()) return
|
||||
|
||||
// Etwas kam an, liess sich aber nicht zuordnen: für die Diagnose
|
||||
// sichtbar machen, damit sich das Protokoll bestimmen lässt.
|
||||
publishDiagnostics()
|
||||
}
|
||||
|
||||
private fun consumeAlpicool(): Boolean {
|
||||
val (frames, remainder) = AlpicoolProtocol.extractFrames(buffer)
|
||||
if (frames.isEmpty()) return false
|
||||
buffer = remainder
|
||||
adopt(Dialect.ALPICOOL)
|
||||
if (frames.any { it.command == AlpicoolProtocol.Command.BIND.raw }) {
|
||||
bindAcknowledged = true
|
||||
}
|
||||
frames.forEach { alpicoolState.apply(it) }
|
||||
alpicoolState.zoneMode = fridgeZoneMode
|
||||
onFridgeState?.invoke(alpicoolState)
|
||||
publish(alpicoolState.snapshot(deviceID, null), alpicoolState.hasStatus)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun consumeWattCycle(): Boolean {
|
||||
val (frames, remainder) = WattCycleProtocol.extractFrames(buffer)
|
||||
if (frames.isEmpty()) return false
|
||||
buffer = remainder
|
||||
adopt(Dialect.WATT_CYCLE)
|
||||
frames.forEach { wattCycleState.apply(it) }
|
||||
publish(wattCycleState.snapshot(deviceID, null), wattCycleState.hasUsableData)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun consumeJbd(): Boolean {
|
||||
val (frames, remainder) = JbdProtocol.extractFrames(buffer)
|
||||
if (frames.isEmpty()) return false
|
||||
buffer = remainder
|
||||
adopt(Dialect.JBD)
|
||||
frames.forEach { jbdState.apply(it) }
|
||||
publish(jbdState.snapshot(deviceID, null), jbdState.hasUsableData)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun consumeDalyModbus(): Boolean {
|
||||
val start = buffer.indexOfFirst { (it.toInt() and 0xFF) == 0xD2 }
|
||||
if (start < 0) return false
|
||||
val registers = DalyProtocol.parseModbusResponse(
|
||||
buffer.copyOfRange(start, buffer.size)
|
||||
) ?: return false
|
||||
buffer = ByteArray(0)
|
||||
adopt(Dialect.DALY_MODBUS)
|
||||
dalyState.apply(registers)
|
||||
publish(dalyState.snapshot(deviceID, null), dalyState.hasUsableData)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun consumeDalyClassic(): Boolean {
|
||||
val (frames, remainder) = DalyProtocol.extractA5Frames(buffer)
|
||||
if (frames.isEmpty()) return false
|
||||
buffer = remainder
|
||||
adopt(Dialect.DALY_CLASSIC)
|
||||
frames.forEach { dalyState.apply(it) }
|
||||
publish(dalyState.snapshot(deviceID, null), dalyState.hasUsableData)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Erster verwertbarer Rahmen: Kandidat und Dialekt stehen fest, ab jetzt im
|
||||
* normalen Takt abfragen.
|
||||
*/
|
||||
private fun adopt(newDialect: Dialect) {
|
||||
silentRounds = 0
|
||||
if (dialect == newDialect) return
|
||||
dialect = newDialect
|
||||
beginPolling()
|
||||
}
|
||||
|
||||
private fun publish(snapshot: DeviceSnapshot, usable: Boolean) {
|
||||
publishDiagnostics()
|
||||
if (!usable) return
|
||||
onStateChange(DeviceLinkState.Live)
|
||||
onUpdate(snapshot)
|
||||
}
|
||||
|
||||
private fun publishDiagnostics() {
|
||||
onDiagnostics(
|
||||
BmsDiagnostics(
|
||||
dialect = dialect.label,
|
||||
endpointLabel = currentEndpoint?.label,
|
||||
endpointPosition = if (endpoints.isEmpty()) null else (endpointIndex + 1) to endpoints.size,
|
||||
serviceUUID = currentEndpoint?.write?.service?.uuid?.toString(),
|
||||
isConnected = gatt != null,
|
||||
isNotifyActive = isNotifyActive,
|
||||
isBound = if (dialect == Dialect.ALPICOOL) bindAcknowledged else null,
|
||||
confirmedWrites = confirmedWrites,
|
||||
lastWriteError = lastWriteError,
|
||||
gattSummary = gattSummary,
|
||||
sentFrames = sentFrameCount,
|
||||
receivedBytes = receivedByteCount,
|
||||
lastSendAt = lastSendAt,
|
||||
lastResponseHex = lastResponse?.toHexText(),
|
||||
lastCommandHex = lastCommand?.toHexText(),
|
||||
lastCommandAt = lastCommandAt,
|
||||
fridgePayloadHex = alpicoolState.lastPayload
|
||||
.takeIf { it.isNotEmpty() }?.toHexText(),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - GATT-Rückmeldungen
|
||||
//
|
||||
// Android ruft diese auf einem eigenen Thread auf. Alles wird deshalb auf
|
||||
// unseren Handler geschoben, bevor es Zustand anfasst.
|
||||
|
||||
private val callback = object : BluetoothGattCallback() {
|
||||
|
||||
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
|
||||
handler.post {
|
||||
if (newState == BluetoothProfile.STATE_CONNECTED) {
|
||||
gatt = g
|
||||
// Eine grössere MTU aushandeln; für die Kühlbox gilt
|
||||
// trotzdem ihre eigene Grenze von 20 Byte.
|
||||
g.requestMtu(247)
|
||||
} else {
|
||||
onStateChange(DeviceLinkState.Searching)
|
||||
handleDisconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMtuChanged(g: BluetoothGatt, mtu: Int, status: Int) {
|
||||
handler.post {
|
||||
negotiatedWriteLimit = (mtu - 3).coerceAtLeast(20)
|
||||
g.discoverServices()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
|
||||
handler.post {
|
||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||
onStateChange(DeviceLinkState.Failed("Dienste nicht lesbar"))
|
||||
return@post
|
||||
}
|
||||
buildEndpoints(g)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDescriptorWrite(
|
||||
g: BluetoothGatt,
|
||||
descriptor: BluetoothGattDescriptor,
|
||||
status: Int,
|
||||
) {
|
||||
handler.post {
|
||||
if (descriptor.characteristic.uuid != currentEndpoint?.notify?.uuid) return@post
|
||||
isNotifyActive = status == BluetoothGatt.GATT_SUCCESS
|
||||
publishDiagnostics()
|
||||
if (isNotifyActive) unlockThenPoll() else advanceEndpoint()
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
override fun onCharacteristicChanged(
|
||||
g: BluetoothGatt,
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
) {
|
||||
val value = characteristic.value ?: return
|
||||
if (value.isEmpty()) return
|
||||
handler.post { consume(value) }
|
||||
}
|
||||
|
||||
override fun onCharacteristicChanged(
|
||||
g: BluetoothGatt,
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
value: ByteArray,
|
||||
) {
|
||||
if (value.isEmpty()) return
|
||||
handler.post { consume(value) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Nur bei Schreibvorgängen mit Bestätigung. Ohne Bestätigung meldet
|
||||
* Android nichts zurück – auch keinen Fehler.
|
||||
*/
|
||||
override fun onCharacteristicWrite(
|
||||
g: BluetoothGatt,
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
status: Int,
|
||||
) {
|
||||
handler.post {
|
||||
if (status == BluetoothGatt.GATT_SUCCESS) {
|
||||
lastWriteError = null
|
||||
confirmedWrites += 1
|
||||
} else {
|
||||
lastWriteError = "GATT-Fehler $status"
|
||||
}
|
||||
publishDiagnostics()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun ByteArray.toHexText(): String = joinToString(" ") { "%02X".format(it) }
|
||||
@@ -0,0 +1,274 @@
|
||||
package de.fritob.campermonitor.bluetooth
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothGatt
|
||||
import android.bluetooth.BluetoothGattCallback
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import android.bluetooth.BluetoothGattDescriptor
|
||||
import android.bluetooth.BluetoothProfile
|
||||
import android.os.Handler
|
||||
import de.fritob.campermonitor.protocol.DeviceLinkState
|
||||
import de.fritob.campermonitor.protocol.DeviceSnapshot
|
||||
import de.fritob.campermonitor.protocol.LevelState
|
||||
import de.fritob.campermonitor.protocol.SensorOrientation
|
||||
import de.fritob.campermonitor.protocol.VanAlignProtocol
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Verbindung zum Neigungsmesser „VanAlign Pro".
|
||||
*
|
||||
* Anders als beim BMS gibt es hier kein Rahmenprotokoll: Längs- und
|
||||
* Querneigung liegen in je einer eigenen Charakteristik als 32-Bit-Float.
|
||||
* Meldet die Firmware sie per Notify, wird abonniert; sonst wird in Abständen
|
||||
* gelesen. Ältere Stände können beides nicht gleichzeitig.
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
class LevelSession(
|
||||
private val deviceID: UUID,
|
||||
private val gattProvider: (BluetoothGattCallback) -> BluetoothGatt?,
|
||||
private val handler: Handler,
|
||||
private val onUpdate: (DeviceSnapshot) -> Unit,
|
||||
private val onStateChange: (DeviceLinkState) -> Unit,
|
||||
private val onLevelState: (LevelState) -> Unit,
|
||||
) {
|
||||
private companion object {
|
||||
val SERVICE: UUID = UUID.fromString(VanAlignProtocol.SERVICE_UUID)
|
||||
val PITCH: UUID = UUID.fromString(VanAlignProtocol.PITCH_UUID)
|
||||
val ROLL: UUID = UUID.fromString(VanAlignProtocol.ROLL_UUID)
|
||||
val OFFSETS: UUID = UUID.fromString(VanAlignProtocol.OFFSETS_UUID)
|
||||
val CALIBRATE: UUID = UUID.fromString(VanAlignProtocol.CALIBRATE_UUID)
|
||||
val CLIENT_CONFIG: UUID = UUID.fromString("00002902-0000-1000-8000-00805F9B34FB")
|
||||
const val POLL_INTERVAL_MS = 1_000L
|
||||
}
|
||||
|
||||
/** Die Einbaulage des Sensors; wird auf jede Messung angewandt. */
|
||||
var orientation: SensorOrientation = SensorOrientation.IDENTITY
|
||||
set(value) {
|
||||
field = value
|
||||
applyOrientation()
|
||||
}
|
||||
|
||||
private var gatt: BluetoothGatt? = null
|
||||
private var pitchCharacteristic: BluetoothGattCharacteristic? = null
|
||||
private var rollCharacteristic: BluetoothGattCharacteristic? = null
|
||||
private var offsetsCharacteristic: BluetoothGattCharacteristic? = null
|
||||
private var calibrateCharacteristic: BluetoothGattCharacteristic? = null
|
||||
|
||||
/** Ob die Firmware die Werte von sich aus meldet. */
|
||||
private var needsPolling = true
|
||||
private var pollRunnable: Runnable? = null
|
||||
|
||||
private var state = LevelState()
|
||||
|
||||
fun start() {
|
||||
onStateChange(DeviceLinkState.Connecting)
|
||||
gatt = gattProvider(callback)
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
cancelPoll()
|
||||
gatt?.close()
|
||||
gatt = null
|
||||
clearCharacteristics()
|
||||
}
|
||||
|
||||
fun handleDisconnect() {
|
||||
cancelPoll()
|
||||
gatt?.close()
|
||||
gatt = null
|
||||
clearCharacteristics()
|
||||
}
|
||||
|
||||
private fun clearCharacteristics() {
|
||||
pitchCharacteristic = null
|
||||
rollCharacteristic = null
|
||||
offsetsCharacteristic = null
|
||||
calibrateCharacteristic = null
|
||||
}
|
||||
|
||||
/** Setzt die aktuelle Lage als neue Null. */
|
||||
fun calibrate() {
|
||||
write(VanAlignProtocol.calibrateCommand)
|
||||
}
|
||||
|
||||
/** Verwirft die Kalibrierung. */
|
||||
fun resetCalibration() {
|
||||
write(VanAlignProtocol.resetCommand)
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun write(data: ByteArray) {
|
||||
val characteristic = calibrateCharacteristic ?: return
|
||||
val g = gatt ?: return
|
||||
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
|
||||
characteristic.value = data
|
||||
g.writeCharacteristic(characteristic)
|
||||
// Die Offsets ändern sich dadurch – kurz darauf neu lesen.
|
||||
handler.postDelayed({ readOffsets() }, 400)
|
||||
}
|
||||
|
||||
private fun readOffsets() {
|
||||
val characteristic = offsetsCharacteristic ?: return
|
||||
gatt?.readCharacteristic(characteristic)
|
||||
}
|
||||
|
||||
private fun cancelPoll() {
|
||||
pollRunnable?.let { handler.removeCallbacks(it) }
|
||||
pollRunnable = null
|
||||
}
|
||||
|
||||
private fun startPollingIfNeeded() {
|
||||
if (!needsPolling || pollRunnable != null) return
|
||||
val runnable = object : Runnable {
|
||||
override fun run() {
|
||||
// Android verträgt nur eine ausstehende GATT-Anfrage; deshalb
|
||||
// erst die eine lesen, die andere folgt in der Rückmeldung.
|
||||
pitchCharacteristic?.let { gatt?.readCharacteristic(it) }
|
||||
handler.postDelayed(this, POLL_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
pollRunnable = runnable
|
||||
handler.post(runnable)
|
||||
}
|
||||
|
||||
private fun applyOrientation() {
|
||||
val (pitch, roll) = orientation.apply(state.rawPitch, state.rawRoll)
|
||||
state = state.copy(pitch = pitch, roll = roll)
|
||||
}
|
||||
|
||||
private fun publish() {
|
||||
onLevelState(state)
|
||||
if (state.hasReading) {
|
||||
onStateChange(DeviceLinkState.Live)
|
||||
onUpdate(state.snapshot(deviceID, null))
|
||||
}
|
||||
}
|
||||
|
||||
private val callback = object : BluetoothGattCallback() {
|
||||
|
||||
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
|
||||
handler.post {
|
||||
if (newState == BluetoothProfile.STATE_CONNECTED) {
|
||||
gatt = g
|
||||
g.discoverServices()
|
||||
} else {
|
||||
onStateChange(DeviceLinkState.Searching)
|
||||
handleDisconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
|
||||
handler.post {
|
||||
val service = g.getService(SERVICE)
|
||||
if (service == null) {
|
||||
onStateChange(DeviceLinkState.Failed("Kein VanAlign-Dienst gefunden"))
|
||||
return@post
|
||||
}
|
||||
pitchCharacteristic = service.getCharacteristic(PITCH)
|
||||
rollCharacteristic = service.getCharacteristic(ROLL)
|
||||
offsetsCharacteristic = service.getCharacteristic(OFFSETS)
|
||||
calibrateCharacteristic = service.getCharacteristic(CALIBRATE)
|
||||
|
||||
val canNotify = pitchCharacteristic?.let {
|
||||
it.properties and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0
|
||||
} ?: false
|
||||
|
||||
if (canNotify) {
|
||||
needsPolling = false
|
||||
subscribe(g, pitchCharacteristic)
|
||||
} else {
|
||||
// Ältere Firmware meldet nichts von sich aus.
|
||||
needsPolling = true
|
||||
readOffsets()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun subscribe(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic?) {
|
||||
val c = characteristic ?: return
|
||||
g.setCharacteristicNotification(c, true)
|
||||
c.getDescriptor(CLIENT_CONFIG)?.let {
|
||||
it.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
||||
g.writeDescriptor(it)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDescriptorWrite(
|
||||
g: BluetoothGatt,
|
||||
descriptor: BluetoothGattDescriptor,
|
||||
status: Int,
|
||||
) {
|
||||
handler.post {
|
||||
when (descriptor.characteristic.uuid) {
|
||||
PITCH -> subscribe(g, rollCharacteristic)
|
||||
ROLL -> readOffsets()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
override fun onCharacteristicRead(
|
||||
g: BluetoothGatt,
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
status: Int,
|
||||
) {
|
||||
val value = characteristic.value ?: return
|
||||
handler.post { handleValue(characteristic.uuid, value, wasRead = true) }
|
||||
}
|
||||
|
||||
override fun onCharacteristicRead(
|
||||
g: BluetoothGatt,
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
value: ByteArray,
|
||||
status: Int,
|
||||
) {
|
||||
handler.post { handleValue(characteristic.uuid, value, wasRead = true) }
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
override fun onCharacteristicChanged(
|
||||
g: BluetoothGatt,
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
) {
|
||||
val value = characteristic.value ?: return
|
||||
handler.post { handleValue(characteristic.uuid, value, wasRead = false) }
|
||||
}
|
||||
|
||||
override fun onCharacteristicChanged(
|
||||
g: BluetoothGatt,
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
value: ByteArray,
|
||||
) {
|
||||
handler.post { handleValue(characteristic.uuid, value, wasRead = false) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleValue(uuid: UUID, value: ByteArray, wasRead: Boolean) {
|
||||
when (uuid) {
|
||||
PITCH -> {
|
||||
state = state.copy(rawPitch = VanAlignProtocol.angle(value))
|
||||
applyOrientation()
|
||||
publish()
|
||||
// Beim Abfragen folgt jetzt die zweite Achse.
|
||||
if (wasRead && needsPolling) {
|
||||
rollCharacteristic?.let { gatt?.readCharacteristic(it) }
|
||||
}
|
||||
}
|
||||
|
||||
ROLL -> {
|
||||
state = state.copy(rawRoll = VanAlignProtocol.angle(value))
|
||||
applyOrientation()
|
||||
publish()
|
||||
}
|
||||
|
||||
OFFSETS -> {
|
||||
VanAlignProtocol.offsets(value)?.let { (pitch, roll) ->
|
||||
state = state.copy(pitchOffset = pitch, rollOffset = roll)
|
||||
}
|
||||
publish()
|
||||
startPollingIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package de.fritob.campermonitor.store
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import de.fritob.campermonitor.protocol.ConfiguredDevice
|
||||
import de.fritob.campermonitor.protocol.DeviceRole
|
||||
import de.fritob.campermonitor.protocol.FridgeZoneMode
|
||||
import de.fritob.campermonitor.protocol.Profile
|
||||
import de.fritob.campermonitor.protocol.SensorOrientation
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Hält Fahrzeuge und Geräte und legt sie ab.
|
||||
*
|
||||
* **Unterschied zur iOS-Fassung:** Dort liegen die Victron-Schlüssel im
|
||||
* Schlüsselbund. Android hat kein direktes Gegenstück, das ohne zusätzliche
|
||||
* Bibliothek auskommt; sie liegen deshalb in einer eigenen, app-privaten
|
||||
* Ablage. Das schützt gegen andere Apps, aber anders als der Schlüsselbund
|
||||
* nicht hardwaregestützt.
|
||||
*/
|
||||
class DeviceStore(context: Context) {
|
||||
|
||||
private val prefs = context.getSharedPreferences("camper", Context.MODE_PRIVATE)
|
||||
private val keyPrefs = context.getSharedPreferences("camper_keys", Context.MODE_PRIVATE)
|
||||
|
||||
val profiles = mutableStateListOf<Profile>()
|
||||
val devices = mutableStateListOf<ConfiguredDevice>()
|
||||
|
||||
var activeProfileID: UUID by mutableStateOf(Profile.DEFAULT_ID)
|
||||
private set
|
||||
|
||||
private var diagnosticsShown by mutableStateOf(prefs.getBoolean("showDiagnostics", false))
|
||||
|
||||
/**
|
||||
* Ob die technischen Angaben in der Oberfläche eingeblendet werden.
|
||||
* Zuweisen genügt – der Wert wird dabei abgelegt.
|
||||
*/
|
||||
var showDiagnostics: Boolean
|
||||
get() = diagnosticsShown
|
||||
set(value) {
|
||||
diagnosticsShown = value
|
||||
prefs.edit().putBoolean("showDiagnostics", value).apply()
|
||||
}
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
val activeProfile: Profile?
|
||||
get() = profiles.firstOrNull { it.id == activeProfileID } ?: profiles.firstOrNull()
|
||||
|
||||
fun activeDevices(): List<ConfiguredDevice> {
|
||||
val profile = activeProfile?.id ?: return emptyList()
|
||||
return devices.filter { it.profileID == profile }
|
||||
}
|
||||
|
||||
// MARK: - Ändern
|
||||
|
||||
fun update(device: ConfiguredDevice) {
|
||||
val index = devices.indexOfFirst { it.id == device.id }
|
||||
if (index >= 0) devices[index] = device else devices.add(device)
|
||||
save()
|
||||
}
|
||||
|
||||
fun remove(device: ConfiguredDevice) {
|
||||
devices.removeAll { it.id == device.id }
|
||||
keyPrefs.edit().remove(device.id.toString()).apply()
|
||||
save()
|
||||
}
|
||||
|
||||
fun selectProfile(id: UUID) {
|
||||
activeProfileID = id
|
||||
save()
|
||||
}
|
||||
|
||||
fun addProfile(name: String): Profile {
|
||||
val profile = Profile(name = name)
|
||||
profiles.add(profile)
|
||||
save()
|
||||
return profile
|
||||
}
|
||||
|
||||
fun updateProfile(profile: Profile) {
|
||||
val index = profiles.indexOfFirst { it.id == profile.id }
|
||||
if (index >= 0) profiles[index] = profile else profiles.add(profile)
|
||||
save()
|
||||
}
|
||||
|
||||
fun removeProfile(profile: Profile) {
|
||||
// Das letzte Fahrzeug bleibt stehen – ohne eines hätte die App keinen
|
||||
// Ort für Geräte.
|
||||
if (profiles.size <= 1) return
|
||||
profiles.removeAll { it.id == profile.id }
|
||||
devices.filter { it.profileID == profile.id }.forEach { remove(it) }
|
||||
if (activeProfileID == profile.id) {
|
||||
profiles.firstOrNull()?.let { activeProfileID = it.id }
|
||||
}
|
||||
save()
|
||||
}
|
||||
|
||||
// MARK: - Victron-Schlüssel
|
||||
|
||||
fun victronKeyText(deviceID: UUID): String? = keyPrefs.getString(deviceID.toString(), null)
|
||||
|
||||
fun victronKey(deviceID: UUID): ByteArray? =
|
||||
victronKeyText(deviceID)?.let { hexToBytes(it) }?.takeIf { it.size == 16 }
|
||||
|
||||
fun setVictronKey(text: String, deviceID: UUID) {
|
||||
val cleaned = text.filter { !it.isWhitespace() }
|
||||
if (hexToBytes(cleaned)?.size == 16) {
|
||||
keyPrefs.edit().putString(deviceID.toString(), cleaned).apply()
|
||||
} else {
|
||||
keyPrefs.edit().remove(deviceID.toString()).apply()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Ablage
|
||||
|
||||
private fun load() {
|
||||
val json = prefs.getString("state", null)
|
||||
if (json == null) {
|
||||
profiles.add(Profile.initial())
|
||||
activeProfileID = Profile.DEFAULT_ID
|
||||
return
|
||||
}
|
||||
try {
|
||||
val root = JSONObject(json)
|
||||
profiles.clear()
|
||||
val profileArray = root.optJSONArray("profiles") ?: JSONArray()
|
||||
for (i in 0 until profileArray.length()) {
|
||||
profiles.add(profileFrom(profileArray.getJSONObject(i)))
|
||||
}
|
||||
devices.clear()
|
||||
val deviceArray = root.optJSONArray("devices") ?: JSONArray()
|
||||
for (i in 0 until deviceArray.length()) {
|
||||
deviceFrom(deviceArray.getJSONObject(i))?.let { devices.add(it) }
|
||||
}
|
||||
activeProfileID = root.optString("activeProfile")
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let { runCatching { UUID.fromString(it) }.getOrNull() }
|
||||
?: Profile.DEFAULT_ID
|
||||
} catch (_: Exception) {
|
||||
// Eine unlesbare Ablage darf die App nicht am Start hindern.
|
||||
profiles.clear()
|
||||
devices.clear()
|
||||
profiles.add(Profile.initial())
|
||||
activeProfileID = Profile.DEFAULT_ID
|
||||
}
|
||||
if (profiles.isEmpty()) profiles.add(Profile.initial())
|
||||
}
|
||||
|
||||
private fun save() {
|
||||
val root = JSONObject()
|
||||
root.put("profiles", JSONArray().also { array ->
|
||||
profiles.forEach { array.put(profileTo(it)) }
|
||||
})
|
||||
root.put("devices", JSONArray().also { array ->
|
||||
devices.forEach { array.put(deviceTo(it)) }
|
||||
})
|
||||
root.put("activeProfile", activeProfileID.toString())
|
||||
prefs.edit().putString("state", root.toString()).apply()
|
||||
}
|
||||
|
||||
private fun profileTo(profile: Profile) = JSONObject().apply {
|
||||
put("id", profile.id.toString())
|
||||
put("name", profile.name)
|
||||
put("symbol", profile.symbol)
|
||||
profile.trackWidth?.let { put("trackWidth", it) }
|
||||
profile.wheelbase?.let { put("wheelbase", it) }
|
||||
}
|
||||
|
||||
private fun profileFrom(json: JSONObject) = Profile(
|
||||
id = UUID.fromString(json.getString("id")),
|
||||
name = json.getString("name"),
|
||||
symbol = json.optString("symbol", "box_truck"),
|
||||
// Fahrzeuge aus der Zeit vor den Massen haben noch keine.
|
||||
trackWidth = if (json.has("trackWidth")) json.getDouble("trackWidth") else null,
|
||||
wheelbase = if (json.has("wheelbase")) json.getDouble("wheelbase") else null,
|
||||
)
|
||||
|
||||
private fun deviceTo(device: ConfiguredDevice) = JSONObject().apply {
|
||||
put("id", device.id.toString())
|
||||
put("name", device.name)
|
||||
put("role", device.role.name)
|
||||
put("profileID", device.profileID.toString())
|
||||
put("address", device.address)
|
||||
device.advertisedName?.let { put("advertisedName", it) }
|
||||
put("fridgeZoneMode", device.fridgeZoneMode.name)
|
||||
put("orientationSource", device.sensorOrientation.longitudinalSource.name)
|
||||
put("invertLongitudinal", device.sensorOrientation.invertLongitudinal)
|
||||
put("invertLateral", device.sensorOrientation.invertLateral)
|
||||
}
|
||||
|
||||
private fun deviceFrom(json: JSONObject): ConfiguredDevice? {
|
||||
val role = runCatching { DeviceRole.valueOf(json.getString("role")) }.getOrNull()
|
||||
?: return null
|
||||
return ConfiguredDevice(
|
||||
id = UUID.fromString(json.getString("id")),
|
||||
name = json.getString("name"),
|
||||
role = role,
|
||||
profileID = UUID.fromString(json.getString("profileID")),
|
||||
address = json.getString("address"),
|
||||
advertisedName = json.optString("advertisedName").takeIf { it.isNotEmpty() },
|
||||
fridgeZoneMode = runCatching {
|
||||
FridgeZoneMode.valueOf(json.optString("fridgeZoneMode"))
|
||||
}.getOrDefault(FridgeZoneMode.AUTOMATIC),
|
||||
sensorOrientation = SensorOrientation(
|
||||
longitudinalSource = runCatching {
|
||||
SensorOrientation.Source.valueOf(json.optString("orientationSource"))
|
||||
}.getOrDefault(SensorOrientation.Source.PITCH),
|
||||
invertLongitudinal = json.optBoolean("invertLongitudinal", false),
|
||||
invertLateral = json.optBoolean("invertLateral", false),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun hexToBytes(text: String): ByteArray? {
|
||||
val cleaned = text.filter { !it.isWhitespace() }
|
||||
if (cleaned.length % 2 != 0) return null
|
||||
return runCatching {
|
||||
ByteArray(cleaned.length / 2) {
|
||||
cleaned.substring(it * 2, it * 2 + 2).toInt(16).toByte()
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Camper Monitor</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.CamperMonitor" parent="android:Theme.Material.NoActionBar" />
|
||||
</resources>
|
||||
@@ -0,0 +1,85 @@
|
||||
package de.fritob.campermonitor.protocol
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Welche Rolle ein Gerät im Camper spielt. Bestimmt Symbol, Sortierung und
|
||||
* welche Kennzahl als "Hauptwert" auf der Kachel gross dargestellt wird.
|
||||
*/
|
||||
enum class DeviceRole(val title: String) {
|
||||
CHARGE_BOOSTER("Ladebooster"),
|
||||
SOLAR_CHARGER("Solarladeregler"),
|
||||
BATTERY_MONITOR("Batteriemonitor"),
|
||||
BMS("Batterie / BMS"),
|
||||
FRIDGE("Kühlbox"),
|
||||
LEVELING("Nivellierung");
|
||||
|
||||
/**
|
||||
* Victron-Geräte werden passiv über das Advertisement gelesen, BMS,
|
||||
* Kühlbox und Neigungsmesser brauchen eine echte GATT-Verbindung.
|
||||
*/
|
||||
val transport: DeviceTransport
|
||||
get() = when (this) {
|
||||
BMS, FRIDGE, LEVELING -> DeviceTransport.CONNECT
|
||||
else -> DeviceTransport.ADVERTISEMENT
|
||||
}
|
||||
}
|
||||
|
||||
enum class DeviceTransport {
|
||||
/** Passives Mitlesen der BLE-Werbedaten (Victron Instant Readout). */
|
||||
ADVERTISEMENT,
|
||||
|
||||
/** Verbindungsaufbau, Kommando schreiben, Antwort per Notify lesen. */
|
||||
CONNECT,
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein eingerichtetes Gerät.
|
||||
*
|
||||
* Die Bluetooth-Adresse ist unter Android ein Text („AA:BB:CC:DD:EE:FF") und
|
||||
* nicht wie unter iOS eine UUID – iOS gibt die Hardware-Adresse gar nicht
|
||||
* heraus, Android schon.
|
||||
*/
|
||||
data class ConfiguredDevice(
|
||||
val id: UUID = UUID.randomUUID(),
|
||||
val name: String,
|
||||
val role: DeviceRole,
|
||||
val profileID: UUID,
|
||||
/** MAC-Adresse des Geräts. */
|
||||
val address: String,
|
||||
/** Wie das Gerät sich selbst nennt, für die Wiedererkennung. */
|
||||
val advertisedName: String? = null,
|
||||
val fridgeZoneMode: FridgeZoneMode = FridgeZoneMode.AUTOMATIC,
|
||||
val sensorOrientation: SensorOrientation = SensorOrientation.IDENTITY,
|
||||
)
|
||||
|
||||
/**
|
||||
* Ein Fahrzeug. Jedes Profil hat seinen eigenen Satz Geräte; die App zeigt und
|
||||
* funkt immer nur für das gerade gewählte.
|
||||
*/
|
||||
data class Profile(
|
||||
val id: UUID = UUID.randomUUID(),
|
||||
val name: String,
|
||||
/** Symbolname für die Auswahl im Dashboard. */
|
||||
val symbol: String = "box_truck",
|
||||
/** Spurweite in Metern – für die Berechnung der Auffahrkeile quer. */
|
||||
val trackWidth: Double? = null,
|
||||
/** Radstand in Metern – dasselbe längs. */
|
||||
val wheelbase: Double? = null,
|
||||
) {
|
||||
companion object {
|
||||
/**
|
||||
* Profil, dem Geräte aus der Zeit vor der Profilverwaltung zugeordnet
|
||||
* werden. Feste Kennung, damit die Zuordnung beim Update erhalten bleibt.
|
||||
*/
|
||||
val DEFAULT_ID: UUID = UUID.fromString("00000000-0000-0000-0000-00000000c001")
|
||||
|
||||
fun initial() = Profile(id = DEFAULT_ID, name = "Mein Camper")
|
||||
|
||||
/** Auswahl für die Profilbearbeitung. */
|
||||
val SYMBOLS = listOf(
|
||||
"box_truck", "pickup", "bus", "double_decker",
|
||||
"car", "car_side", "tent", "sailboat", "lodge", "mountains",
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user