feat: implement telemetry persistence, data visualization, and improved bluetooth stream parsing with frame statistics #9
Generated
+7
@@ -4,6 +4,13 @@
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DropdownSelection timestamp="2026-06-11T23:19:12.913231474Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=38021FDJG009WV" />
|
||||
</handle>
|
||||
</Target>
|
||||
</DropdownSelection>
|
||||
<DialogSelection />
|
||||
</SelectionState>
|
||||
</selectionStates>
|
||||
|
||||
Generated
+1
@@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.example.esp32aldldashboard.bluetooth.BluetoothService
|
||||
import com.example.esp32aldldashboard.repository.SettingsRepository
|
||||
import com.example.esp32aldldashboard.repository.TelemetryRepository
|
||||
import com.example.esp32aldldashboard.logging.CsvLogger
|
||||
import com.example.esp32aldldashboard.logging.RawStreamLogger
|
||||
import com.example.esp32aldldashboard.data.database.TelemetryDatabase
|
||||
|
||||
class AldlApplication : Application() {
|
||||
@@ -13,14 +14,17 @@ class AldlApplication : Application() {
|
||||
lateinit var settingsRepository: SettingsRepository
|
||||
|
||||
lateinit var csvLogger: CsvLogger
|
||||
lateinit var rawStreamLogger: RawStreamLogger
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
val database = TelemetryDatabase.getDatabase(this)
|
||||
settingsRepository = SettingsRepository(this)
|
||||
csvLogger = CsvLogger(this)
|
||||
bluetoothService = BluetoothService(this)
|
||||
rawStreamLogger = RawStreamLogger(this)
|
||||
bluetoothService = BluetoothService(this, rawStreamLogger, settingsRepository)
|
||||
telemetryRepository = TelemetryRepository(
|
||||
this,
|
||||
bluetoothService,
|
||||
database.telemetryDao(),
|
||||
csvLogger,
|
||||
|
||||
@@ -9,6 +9,9 @@ import android.content.Context
|
||||
import android.util.Log
|
||||
import com.example.esp32aldldashboard.parser.ALDLFrame
|
||||
import com.example.esp32aldldashboard.parser.ALDLParser
|
||||
import com.example.esp32aldldashboard.logging.RawStreamLogger
|
||||
import com.example.esp32aldldashboard.repository.SettingsRepository
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -23,7 +26,11 @@ enum class ConnectionState {
|
||||
ERROR
|
||||
}
|
||||
|
||||
class BluetoothService(private val context: Context) {
|
||||
class BluetoothService(
|
||||
private val context: Context,
|
||||
private val rawStreamLogger: RawStreamLogger,
|
||||
private val settingsRepository: SettingsRepository
|
||||
) {
|
||||
|
||||
private val TAG = "ALDLBluetoothService"
|
||||
private val SPP_UUID = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB")
|
||||
@@ -49,6 +56,61 @@ class BluetoothService(private val context: Context) {
|
||||
private val _errorMessage = MutableStateFlow("")
|
||||
val errorMessage: StateFlow<String> = _errorMessage
|
||||
|
||||
private data class HeaderResult(val found: Boolean, val index: Int = -1)
|
||||
|
||||
/**
|
||||
* Finds a valid AA 55 header in the buffer using 27-byte lookahead validation.
|
||||
* If another AA 55 is found within 27 bytes of a detected header, the first is treated
|
||||
* as false (payload data) and search continues.
|
||||
*/
|
||||
private fun findValidHeader(buffer: ArrayDeque<Byte>): HeaderResult {
|
||||
if (buffer.size < 27) return HeaderResult(false)
|
||||
|
||||
val bytes = buffer.toList()
|
||||
var searchStart = 0
|
||||
|
||||
while (searchStart < bytes.size - 1) {
|
||||
// Look for AA 55 starting at searchStart
|
||||
var headerIdx = -1
|
||||
for (i in searchStart until bytes.size - 1) {
|
||||
val prev = bytes[i].toInt() and 0xFF
|
||||
val curr = bytes[i + 1].toInt() and 0xFF
|
||||
if (prev == 0xAA && curr == 0x55) {
|
||||
headerIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (headerIdx == -1) {
|
||||
return HeaderResult(false) // No header found
|
||||
}
|
||||
|
||||
// Check if there's another AA 55 within 27 bytes (indicating false header)
|
||||
val lookAheadEnd = minOf(headerIdx + 27, bytes.size - 1)
|
||||
var foundFalseHeader = false
|
||||
|
||||
for (i in headerIdx + 2 until lookAheadEnd) {
|
||||
if (i + 1 >= bytes.size) break
|
||||
val prev = bytes[i].toInt() and 0xFF
|
||||
val curr = bytes[i + 1].toInt() and 0xFF
|
||||
if (prev == 0xAA && curr == 0x55) {
|
||||
// Found another header within 27 bytes - first one is likely false
|
||||
foundFalseHeader = true
|
||||
searchStart = headerIdx + 1 // Continue searching from after first AA
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundFalseHeader) {
|
||||
// Valid header found
|
||||
return HeaderResult(true, headerIdx)
|
||||
}
|
||||
// Otherwise continue loop to find next candidate
|
||||
}
|
||||
|
||||
return HeaderResult(false)
|
||||
}
|
||||
|
||||
private var connectionJob: Job? = null
|
||||
private var socket: BluetoothSocket? = null
|
||||
private var isConnected = false
|
||||
@@ -77,6 +139,12 @@ class BluetoothService(private val context: Context) {
|
||||
_errorMessage.value = ""
|
||||
|
||||
connectionJob = CoroutineScope(Dispatchers.Default).launch {
|
||||
// Start raw recording if enabled
|
||||
val shouldRecordRaw = settingsRepository.recordRawDataFlow.first()
|
||||
if (shouldRecordRaw) {
|
||||
rawStreamLogger.startRecording()
|
||||
}
|
||||
|
||||
var simStep = 0
|
||||
val basePayload = byteArrayOf(
|
||||
0x20.toByte(), 0x00.toByte(), 0x2A.toByte(), 0x5F.toByte(), 0x59.toByte(),
|
||||
@@ -170,10 +238,22 @@ class BluetoothService(private val context: Context) {
|
||||
_latestFrame.value = parsed.frame
|
||||
val hexString = payload.joinToString(" ") { String.format("%02X", it) }
|
||||
addRawHexLog("AA 55 $hexString (SIMULATED)")
|
||||
|
||||
// Log raw frame if recording is enabled
|
||||
if (rawStreamLogger.isRecording()) {
|
||||
val rawFrame = ByteArray(27)
|
||||
rawFrame[0] = 0xAA.toByte()
|
||||
rawFrame[1] = 0x55.toByte()
|
||||
payload.copyInto(rawFrame, 2, 0, 25)
|
||||
rawStreamLogger.logFrame(rawFrame)
|
||||
}
|
||||
}
|
||||
|
||||
delay(1000)
|
||||
}
|
||||
|
||||
// Stop raw recording when simulation ends
|
||||
rawStreamLogger.stopRecording()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,8 +307,17 @@ class BluetoothService(private val context: Context) {
|
||||
_connectionState.value = ConnectionState.CONNECTED
|
||||
}
|
||||
|
||||
// Start raw recording if enabled
|
||||
val shouldRecordRaw = settingsRepository.recordRawDataFlow.first()
|
||||
if (shouldRecordRaw) {
|
||||
rawStreamLogger.startRecording()
|
||||
}
|
||||
|
||||
readDataStream(socket!!.inputStream)
|
||||
|
||||
// Stop raw recording when connection ends
|
||||
rawStreamLogger.stopRecording()
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Connection failed: ${e.message}", e)
|
||||
withContext(Dispatchers.Main) {
|
||||
@@ -261,25 +350,11 @@ class BluetoothService(private val context: Context) {
|
||||
|
||||
// Check for frame matches in buffer
|
||||
while (syncBuffer.size >= 27) {
|
||||
var foundHeader = false
|
||||
val iterator = syncBuffer.iterator()
|
||||
var idx = 0
|
||||
var headerIdx = -1
|
||||
val headerResult = findValidHeader(syncBuffer)
|
||||
|
||||
// Find header AA 55
|
||||
var prev = iterator.next().toInt() and 0xFF
|
||||
while (iterator.hasNext()) {
|
||||
val curr = iterator.next().toInt() and 0xFF
|
||||
if (prev == 0xAA && curr == 0x55) {
|
||||
headerIdx = idx
|
||||
foundHeader = true
|
||||
break
|
||||
}
|
||||
prev = curr
|
||||
idx++
|
||||
}
|
||||
|
||||
if (foundHeader) {
|
||||
if (headerResult.found) {
|
||||
val headerIdx = headerResult.index
|
||||
|
||||
// Discard garbage preceding header
|
||||
if (headerIdx > 0) {
|
||||
_parseErrors.value += 1
|
||||
@@ -288,13 +363,19 @@ class BluetoothService(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate we have enough data for full frame
|
||||
if (syncBuffer.size >= 27) {
|
||||
syncBuffer.removeFirst() // AA
|
||||
syncBuffer.removeFirst() // 55
|
||||
|
||||
val payload = ByteArray(25)
|
||||
val rawFrame = ByteArray(27) // Include header for raw logging
|
||||
rawFrame[0] = 0xAA.toByte()
|
||||
rawFrame[1] = 0x55.toByte()
|
||||
|
||||
for (p in 0 until 25) {
|
||||
payload[p] = syncBuffer.removeFirst()
|
||||
rawFrame[p + 2] = payload[p]
|
||||
}
|
||||
|
||||
val parsed = ALDLParser.parseFrame(payload)
|
||||
@@ -324,12 +405,17 @@ class BluetoothService(private val context: Context) {
|
||||
// Handled by size check
|
||||
}
|
||||
}
|
||||
|
||||
// Log raw frame if recording is enabled
|
||||
if (rawStreamLogger.isRecording()) {
|
||||
rawStreamLogger.logFrame(rawFrame)
|
||||
}
|
||||
} else {
|
||||
// Header found, but waiting for full 27-byte frame
|
||||
break
|
||||
}
|
||||
} else {
|
||||
// Header sequence not found, purge all but last byte if it is part of a potential header
|
||||
// No valid header found, purge all but potential header start
|
||||
val lastByte = syncBuffer.last()
|
||||
syncBuffer.clear()
|
||||
if ((lastByte.toInt() and 0xFF) == 0xAA) {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.example.esp32aldldashboard.logging
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import java.io.OutputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Logs raw binary ALDL data streams for debugging purposes.
|
||||
* Records full 27-byte frames (AA 55 header + 25-byte payload) to Downloads/ALDLLogs/
|
||||
*/
|
||||
class RawStreamLogger(private val context: Context) {
|
||||
|
||||
private var currentOutputStream: OutputStream? = null
|
||||
private var currentUri: Uri? = null
|
||||
private var isRecording = false
|
||||
|
||||
/**
|
||||
* Starts a new raw recording session.
|
||||
* @return true if recording started successfully, false otherwise
|
||||
*/
|
||||
fun startRecording(): Boolean {
|
||||
if (isRecording) return true
|
||||
|
||||
val timeStamp: String = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.US).format(Date())
|
||||
val fileName = "raw_$timeStamp.bin"
|
||||
|
||||
return try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val resolver = context.contentResolver
|
||||
val contentValues = ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
|
||||
put(MediaStore.MediaColumns.MIME_TYPE, "application/octet-stream")
|
||||
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS + "/ALDLLogs")
|
||||
}
|
||||
|
||||
val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
|
||||
if (uri != null) {
|
||||
currentUri = uri
|
||||
currentOutputStream = resolver.openOutputStream(uri)
|
||||
isRecording = true
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
// Legacy support not implemented for this debug feature
|
||||
false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs a single 27-byte frame (including AA 55 header).
|
||||
* @param frame The complete 27-byte frame to log
|
||||
*/
|
||||
fun logFrame(frame: ByteArray) {
|
||||
if (!isRecording || frame.size != 27) return
|
||||
|
||||
try {
|
||||
currentOutputStream?.write(frame)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the current recording session and closes the file.
|
||||
*/
|
||||
fun stopRecording() {
|
||||
if (!isRecording) return
|
||||
|
||||
try {
|
||||
currentOutputStream?.flush()
|
||||
currentOutputStream?.close()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
} finally {
|
||||
currentOutputStream = null
|
||||
currentUri = null
|
||||
isRecording = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether a recording session is currently active.
|
||||
*/
|
||||
fun isRecording(): Boolean = isRecording
|
||||
}
|
||||
@@ -16,6 +16,7 @@ class SettingsRepository(private val context: Context) {
|
||||
val COOLANT_ALERT_THRESHOLD = floatPreferencesKey("coolant_alert_threshold")
|
||||
val BATTERY_LOW_THRESHOLD = floatPreferencesKey("battery_low_threshold")
|
||||
val AUTO_LOGGING = booleanPreferencesKey("auto_logging")
|
||||
val RECORD_RAW_DATA = booleanPreferencesKey("record_raw_data")
|
||||
}
|
||||
|
||||
val isCelsiusFlow: Flow<Boolean> = context.dataStore.data
|
||||
@@ -38,6 +39,11 @@ class SettingsRepository(private val context: Context) {
|
||||
preferences[AUTO_LOGGING] ?: false
|
||||
}
|
||||
|
||||
val recordRawDataFlow: Flow<Boolean> = context.dataStore.data
|
||||
.map { preferences ->
|
||||
preferences[RECORD_RAW_DATA] ?: false
|
||||
}
|
||||
|
||||
suspend fun setIsCelsius(isCelsius: Boolean) {
|
||||
context.dataStore.edit { preferences ->
|
||||
preferences[IS_CELSIUS] = isCelsius
|
||||
@@ -61,4 +67,10 @@ class SettingsRepository(private val context: Context) {
|
||||
preferences[AUTO_LOGGING] = autoLog
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setRecordRawData(recordRaw: Boolean) {
|
||||
context.dataStore.edit { preferences ->
|
||||
preferences[RECORD_RAW_DATA] = recordRaw
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package com.example.esp32aldldashboard.repository
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.example.esp32aldldashboard.bluetooth.BluetoothService
|
||||
import com.example.esp32aldldashboard.bluetooth.BluetoothForegroundService
|
||||
import com.example.esp32aldldashboard.bluetooth.ConnectionState
|
||||
import com.example.esp32aldldashboard.parser.ALDLFrame
|
||||
import com.example.esp32aldldashboard.data.database.SessionEntity
|
||||
@@ -16,6 +19,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
class TelemetryRepository(
|
||||
private val context: Context,
|
||||
private val bluetoothService: BluetoothService,
|
||||
private val telemetryDao: TelemetryDao,
|
||||
private val csvLogger: CsvLogger,
|
||||
@@ -99,14 +103,31 @@ class TelemetryRepository(
|
||||
}
|
||||
|
||||
fun connect() {
|
||||
startForegroundService()
|
||||
bluetoothService.connect()
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
stopForegroundService()
|
||||
bluetoothService.disconnect()
|
||||
}
|
||||
|
||||
fun startSimulation() {
|
||||
startForegroundService()
|
||||
bluetoothService.startSimulation()
|
||||
}
|
||||
|
||||
private fun startForegroundService() {
|
||||
val intent = Intent(context, BluetoothForegroundService::class.java).apply {
|
||||
action = BluetoothForegroundService.ACTION_START
|
||||
}
|
||||
context.startForegroundService(intent)
|
||||
}
|
||||
|
||||
private fun stopForegroundService() {
|
||||
val intent = Intent(context, BluetoothForegroundService::class.java).apply {
|
||||
action = BluetoothForegroundService.ACTION_STOP
|
||||
}
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,14 @@ fun MainScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val viewModel: MainScreenViewModel = viewModel { MainScreenViewModel(context) }
|
||||
val app = context.applicationContext as AldlApplication
|
||||
|
||||
val viewModel: MainScreenViewModel = viewModel(
|
||||
factory = MainScreenViewModelFactory(
|
||||
telemetryRepository = app.telemetryRepository,
|
||||
settingsRepository = app.settingsRepository
|
||||
)
|
||||
)
|
||||
|
||||
val connState by viewModel.connectionState.collectAsStateWithLifecycle()
|
||||
val frame by viewModel.latestFrame.collectAsStateWithLifecycle()
|
||||
@@ -70,7 +77,6 @@ fun MainScreen(
|
||||
}
|
||||
|
||||
var selectedTab by remember { mutableStateOf(0) }
|
||||
val app = context.applicationContext as AldlApplication
|
||||
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
|
||||
@@ -1,46 +1,51 @@
|
||||
package com.example.esp32aldldashboard.ui.main
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.example.esp32aldldashboard.bluetooth.BluetoothService
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.esp32aldldashboard.bluetooth.ConnectionState
|
||||
import com.example.esp32aldldashboard.parser.ALDLFrame
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import com.example.esp32aldldashboard.repository.SettingsRepository
|
||||
import com.example.esp32aldldashboard.repository.TelemetryRepository
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MainScreenViewModel(context: Context) : ViewModel() {
|
||||
private val bluetoothService = BluetoothService(context.applicationContext)
|
||||
class MainScreenViewModel(
|
||||
private val telemetryRepository: TelemetryRepository,
|
||||
private val settingsRepository: SettingsRepository
|
||||
) : ViewModel() {
|
||||
|
||||
val connectionState: StateFlow<ConnectionState> = bluetoothService.connectionState
|
||||
val latestFrame: StateFlow<ALDLFrame?> = bluetoothService.latestFrame
|
||||
val rawHexLog: StateFlow<List<String>> = bluetoothService.rawHexLog
|
||||
val errorMessage: StateFlow<String> = bluetoothService.errorMessage
|
||||
val connectionState: StateFlow<ConnectionState> = telemetryRepository.connectionState
|
||||
val latestFrame: StateFlow<ALDLFrame?> = telemetryRepository.latestFrame
|
||||
val rawHexLog: StateFlow<List<String>> = telemetryRepository.rawHexLog
|
||||
val errorMessage: StateFlow<String> = telemetryRepository.errorMessage
|
||||
|
||||
val framesReceived: StateFlow<Int> = bluetoothService.framesReceived
|
||||
val parseErrors: StateFlow<Int> = bluetoothService.parseErrors
|
||||
val currentFrameRate: StateFlow<Int> = bluetoothService.currentFrameRate
|
||||
val framesReceived: StateFlow<Int> = telemetryRepository.framesReceived
|
||||
val parseErrors: StateFlow<Int> = telemetryRepository.parseErrors
|
||||
val currentFrameRate: StateFlow<Int> = telemetryRepository.currentFrameRate
|
||||
|
||||
private val _isCelsius = MutableStateFlow(false) // Default to Fahrenheit for standard 80s GM telemetry
|
||||
val isCelsius: StateFlow<Boolean> = _isCelsius
|
||||
val isCelsius: StateFlow<Boolean> = settingsRepository.isCelsiusFlow
|
||||
|
||||
fun toggleTemperatureUnit() {
|
||||
_isCelsius.value = !_isCelsius.value
|
||||
viewModelScope.launch {
|
||||
val currentValue = settingsRepository.isCelsiusFlow.value
|
||||
settingsRepository.setIsCelsius(!currentValue)
|
||||
}
|
||||
}
|
||||
|
||||
fun connect() {
|
||||
bluetoothService.connect()
|
||||
telemetryRepository.connect()
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
bluetoothService.disconnect()
|
||||
telemetryRepository.disconnect()
|
||||
}
|
||||
|
||||
fun startSimulation() {
|
||||
bluetoothService.startSimulation()
|
||||
telemetryRepository.startSimulation()
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
bluetoothService.disconnect()
|
||||
telemetryRepository.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.example.esp32aldldashboard.ui.main
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.example.esp32aldldashboard.repository.SettingsRepository
|
||||
import com.example.esp32aldldashboard.repository.TelemetryRepository
|
||||
|
||||
class MainScreenViewModelFactory(
|
||||
private val telemetryRepository: TelemetryRepository,
|
||||
private val settingsRepository: SettingsRepository
|
||||
) : ViewModelProvider.Factory {
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
if (modelClass.isAssignableFrom(MainScreenViewModel::class.java)) {
|
||||
return MainScreenViewModel(telemetryRepository, settingsRepository) as T
|
||||
}
|
||||
throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}")
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ fun SettingsScreen(
|
||||
) {
|
||||
val isCelsius by settingsRepository.isCelsiusFlow.collectAsStateWithLifecycle(initialValue = false)
|
||||
val autoLogging by settingsRepository.autoLoggingFlow.collectAsStateWithLifecycle(initialValue = false)
|
||||
val recordRawData by settingsRepository.recordRawDataFlow.collectAsStateWithLifecycle(initialValue = false)
|
||||
val coolantThreshold by settingsRepository.coolantAlertThresholdFlow.collectAsStateWithLifecycle(initialValue = 100f)
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
@@ -67,6 +68,25 @@ fun SettingsScreen(
|
||||
}
|
||||
Divider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
|
||||
// Raw Data Recording Toggle (Debug)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Column {
|
||||
Text(text = "Record Raw Datastream (Debug)", style = MaterialTheme.typography.titleMedium)
|
||||
Text(text = "Save binary .bin files for debugging.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Switch(
|
||||
checked = recordRawData,
|
||||
onCheckedChange = {
|
||||
coroutineScope.launch { settingsRepository.setRecordRawData(it) }
|
||||
}
|
||||
)
|
||||
}
|
||||
Divider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
|
||||
// Coolant Alert Threshold
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) {
|
||||
Text(text = "Coolant Alert Threshold", style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
@@ -70,4 +70,105 @@ class ALDLParserTest {
|
||||
assertFalse(frame.isClosedLoop) // bit 7 of u[14] is 0
|
||||
assertFalse(frame.isRich) // bit 6 of u[14] is 0
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRpmBoundaryValues() {
|
||||
// Max valid RPM: 8000 (320 * 25 = 8000)
|
||||
val validPayload = createBasePayload().apply {
|
||||
this[7] = 320.toByte() // 320 * 25 = 8000 RPM
|
||||
}
|
||||
val validResult = ALDLParser.parseFrame(validPayload)
|
||||
assertTrue("RPM at exactly 8000 should be valid", validResult is com.example.esp32aldldashboard.parser.ALDLParseResult.Success)
|
||||
|
||||
// Invalid RPM: 8001 (321 * 25 = 8025)
|
||||
val invalidPayload = createBasePayload().apply {
|
||||
this[7] = 321.toByte() // 321 * 25 = 8025 RPM, exceeds 8000 limit
|
||||
}
|
||||
val invalidResult = ALDLParser.parseFrame(invalidPayload)
|
||||
assertTrue("RPM above 8000 should be rejected", invalidResult is com.example.esp32aldldashboard.parser.ALDLParseResult.InvalidData)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testBatteryVoltageBoundaryValues() {
|
||||
// Min valid: 8V (80 * 0.1 = 8.0V)
|
||||
val minPayload = createBasePayload().apply {
|
||||
this[17] = 80.toByte()
|
||||
}
|
||||
val minResult = ALDLParser.parseFrame(minPayload)
|
||||
assertTrue("Battery at 8V should be valid", minResult is com.example.esp32aldldashboard.parser.ALDLParseResult.Success)
|
||||
|
||||
// Max valid: 18V (180 * 0.1 = 18.0V)
|
||||
val maxPayload = createBasePayload().apply {
|
||||
this[17] = 180.toByte()
|
||||
}
|
||||
val maxResult = ALDLParser.parseFrame(maxPayload)
|
||||
assertTrue("Battery at 18V should be valid", maxResult is com.example.esp32aldldashboard.parser.ALDLParseResult.Success)
|
||||
|
||||
// Too low: 4.9V (49 * 0.1 = 4.9V, below 5V minimum)
|
||||
val lowPayload = createBasePayload().apply {
|
||||
this[17] = 49.toByte()
|
||||
}
|
||||
val lowResult = ALDLParser.parseFrame(lowPayload)
|
||||
assertTrue("Battery below 5V should be rejected", lowResult is com.example.esp32aldldashboard.parser.ALDLParseResult.InvalidData)
|
||||
|
||||
// Too high: 20.1V (201 * 0.1 = 20.1V, above 20V maximum)
|
||||
val highPayload = createBasePayload().apply {
|
||||
this[17] = 201.toByte()
|
||||
}
|
||||
val highResult = ALDLParser.parseFrame(highPayload)
|
||||
assertTrue("Battery above 20V should be rejected", highResult is com.example.esp32aldldashboard.parser.ALDLParseResult.InvalidData)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTpsVoltageBoundaryValues() {
|
||||
// Max valid: 5.1V (260 * 0.019608 = 5.098V ~ 5.1V)
|
||||
val validPayload = createBasePayload().apply {
|
||||
this[8] = 260.toByte()
|
||||
}
|
||||
val validResult = ALDLParser.parseFrame(validPayload)
|
||||
assertTrue("TPS at 5.1V should be valid", validResult is com.example.esp32aldldashboard.parser.ALDLParseResult.Success)
|
||||
|
||||
// Too high: 5.2V (265 * 0.019608 = 5.196V ~ 5.2V, exceeds 5.1V)
|
||||
val invalidPayload = createBasePayload().apply {
|
||||
this[8] = 265.toByte()
|
||||
}
|
||||
val invalidResult = ALDLParser.parseFrame(invalidPayload)
|
||||
assertTrue("TPS above 5.1V should be rejected", invalidResult is com.example.esp32aldldashboard.parser.ALDLParseResult.InvalidData)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCoolantTempBoundaryValues() {
|
||||
// Valid temperature: 89 * 0.75 - 40 = 26.75C (within -45 to 220 range)
|
||||
val validPayload = createBasePayload()
|
||||
val validResult = ALDLParser.parseFrame(validPayload)
|
||||
assertTrue("Valid coolant temp should be accepted", validResult is com.example.esp32aldldashboard.parser.ALDLParseResult.Success)
|
||||
|
||||
// Too low: raw value 0 -> -40C, but below -45C limit
|
||||
// Actually raw 0 gives -40C which is within bounds
|
||||
// Let's test a very high raw value that gives > 220C
|
||||
// raw 350 * 0.75 - 40 = 262.5 - 40 = 222.5C (exceeds 220C)
|
||||
val highTempPayload = createBasePayload().apply {
|
||||
this[4] = 350.toByte()
|
||||
}
|
||||
val highResult = ALDLParser.parseFrame(highTempPayload)
|
||||
assertTrue("Coolant temp above 220C should be rejected", highResult is com.example.esp32aldldashboard.parser.ALDLParseResult.InvalidData)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testIncompletePayload() {
|
||||
// Payload with only 24 bytes (incomplete)
|
||||
val incompletePayload = ByteArray(24) { 0x20.toByte() }
|
||||
val result = ALDLParser.parseFrame(incompletePayload)
|
||||
assertTrue("Incomplete payload should return Incomplete result", result is com.example.esp32aldldashboard.parser.ALDLParseResult.Incomplete)
|
||||
}
|
||||
|
||||
private fun createBasePayload(): ByteArray {
|
||||
return byteArrayOf(
|
||||
0x20.toByte(), 0x00.toByte(), 0x2A.toByte(), 0x5F.toByte(), 0x59.toByte(),
|
||||
0x00.toByte(), 0xF4.toByte(), 0x00.toByte(), 0x1E.toByte(), 0x80.toByte(),
|
||||
0x65.toByte(), 0x08.toByte(), 0x00.toByte(), 0x00.toByte(), 0x00.toByte(),
|
||||
0x25.toByte(), 0x18.toByte(), 0x7D.toByte(), 0x80.toByte(), 0x00.toByte(),
|
||||
0x00.toByte(), 0x00.toByte(), 0xC9.toByte(), 0x02.toByte(), 0x62.toByte()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user