diff --git a/Android/app/build.gradle.kts b/Android/app/build.gradle.kts index 1d5dae8..ed7fd43 100644 --- a/Android/app/build.gradle.kts +++ b/Android/app/build.gradle.kts @@ -34,6 +34,7 @@ android { buildFeatures { compose = true + buildConfig = true } sourceSets["main"].java.srcDirs("src/main/kotlin") diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/MainActivity.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/MainActivity.kt index 93bf53a..3821db6 100644 --- a/Android/app/src/main/kotlin/de/fritob/campermonitor/MainActivity.kt +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/MainActivity.kt @@ -9,6 +9,7 @@ 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 @@ -35,8 +36,17 @@ class MainActivity : ComponentActivity() { 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. diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/bluetooth/BluetoothManager.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/bluetooth/BluetoothManager.kt index e019cdf..69e7de4 100644 --- a/Android/app/src/main/kotlin/de/fritob/campermonitor/bluetooth/BluetoothManager.kt +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/bluetooth/BluetoothManager.kt @@ -180,9 +180,38 @@ class BluetoothManager( 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, + fridge: Pair, + level: Pair, + history: Map>, + ) { + 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()) diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/store/DemoData.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/store/DemoData.kt new file mode 100644 index 0000000..9ca18a5 --- /dev/null +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/store/DemoData.kt @@ -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()) + } +} diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/store/DeviceStore.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/store/DeviceStore.kt index d6dc56e..68f1677 100644 --- a/Android/app/src/main/kotlin/de/fritob/campermonitor/store/DeviceStore.kt +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/store/DeviceStore.kt @@ -47,10 +47,21 @@ class DeviceStore(context: Context) { 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, demoDevices: List) { + 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() @@ -155,6 +166,7 @@ class DeviceStore(context: Context) { } private fun save() { + if (isDemo) return val root = JSONObject() root.put("profiles", JSONArray().also { array -> profiles.forEach { array.put(profileTo(it)) }