Android: Demo-Modus für den Emulator

Der Emulator hat kein Bluetooth. Damit sich die Ansichten trotzdem
ansehen und durchklicken lassen, füttert ein Demo-Modus fertige Werte
ein - dasselbe Vorgehen wie in der iOS-Fassung.

Nur in Debug-Bauten und nur, wenn beim Start das Extra gesetzt ist:

  adb shell am start -n de.fritob.campermonitor/.MainActivity --ez demo true

Im Demo-Modus funkt nichts und es wird nichts abgelegt; die echten Geräte
und Fahrzeuge bleiben unberührt. Der Kühlbox-Zustand kommt dabei aus
einem echten Einzonen-Datensatz, wie ihn die Box im Fahrzeug schickt -
inklusive des Fühlerplatzhalters, damit die Anzeige auch im Demo zeigt,
was sie im Fahrzeug zeigen würde.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
BiasF
2026-08-31 10:45:49 +02:00
co-authored by Claude Opus 5
parent fcca37bbd0
commit 4c16cdd485
5 changed files with 182 additions and 0 deletions
@@ -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.
@@ -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<DeviceSnapshot>,
fridge: Pair<UUID, AlpicoolState>,
level: Pair<UUID, LevelState>,
history: Map<UUID, List<Double>>,
) {
isDemo = true
isBluetoothReady = true
bluetoothStatusText = "Demo-Daten"
snapshots.forEach {
this.snapshots[it.deviceID] = it
linkStates[it.deviceID] = DeviceLinkState.Live
}
fridgeStates[fridge.first] = fridge.second
levelStates[level.first] = level.second
history.forEach { (id, values) ->
this.history[id] = values.map { HistorySample(System.currentTimeMillis(), it) }
.toMutableList()
}
}
// MARK: - Start
fun start() {
if (isDemo) return
handler.post {
refreshAdapterState()
applyConfiguration(currentDevices())
@@ -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())
}
}
@@ -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<Profile>, demoDevices: List<ConfiguredDevice>) {
isDemo = true
profiles.clear(); profiles.addAll(demoProfiles)
devices.clear(); devices.addAll(demoDevices)
activeProfileID = demoProfiles.first().id
}
val activeProfile: Profile?
get() = profiles.firstOrNull { it.id == activeProfileID } ?: profiles.firstOrNull()
@@ -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)) }