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:
@@ -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)) }
|
||||
|
||||
Reference in New Issue
Block a user