From bb47d61eec563bb85cb7f96948eaf7e87619f216 Mon Sep 17 00:00:00 2001 From: Gandalf Date: Fri, 12 Jun 2026 10:56:51 +0100 Subject: [PATCH] feat: add BLM/INT heatmap table with RPM/MAP bands --- .../esp32aldldashboard/AldlApplication.kt | 6 +- .../repository/BLMTableRepository.kt | 122 ++++++++++ .../repository/TelemetryRepository.kt | 31 ++- .../ui/blm/BLMTableScreen.kt | 226 ++++++++++++++++++ .../esp32aldldashboard/ui/main/MainScreen.kt | 25 +- 5 files changed, 396 insertions(+), 14 deletions(-) create mode 100644 app/src/main/java/com/example/esp32aldldashboard/repository/BLMTableRepository.kt create mode 100644 app/src/main/java/com/example/esp32aldldashboard/ui/blm/BLMTableScreen.kt diff --git a/app/src/main/java/com/example/esp32aldldashboard/AldlApplication.kt b/app/src/main/java/com/example/esp32aldldashboard/AldlApplication.kt index cc39807..e693533 100644 --- a/app/src/main/java/com/example/esp32aldldashboard/AldlApplication.kt +++ b/app/src/main/java/com/example/esp32aldldashboard/AldlApplication.kt @@ -2,6 +2,7 @@ package com.example.esp32aldldashboard import android.app.Application import com.example.esp32aldldashboard.bluetooth.BluetoothService +import com.example.esp32aldldashboard.repository.BLMTableRepository import com.example.esp32aldldashboard.repository.ChartPreferencesRepository import com.example.esp32aldldashboard.repository.SettingsRepository import com.example.esp32aldldashboard.repository.TelemetryRepository @@ -17,12 +18,14 @@ class AldlApplication : Application() { lateinit var csvLogger: CsvLogger lateinit var rawStreamLogger: RawStreamLogger lateinit var chartPreferencesRepository: ChartPreferencesRepository + lateinit var blmTableRepository: BLMTableRepository override fun onCreate() { super.onCreate() val database = TelemetryDatabase.getDatabase(this) settingsRepository = SettingsRepository(this) chartPreferencesRepository = ChartPreferencesRepository(this) + blmTableRepository = BLMTableRepository() csvLogger = CsvLogger(this) rawStreamLogger = RawStreamLogger(this) bluetoothService = BluetoothService(this, rawStreamLogger, settingsRepository) @@ -31,7 +34,8 @@ class AldlApplication : Application() { bluetoothService, database.telemetryDao(), csvLogger, - settingsRepository + settingsRepository, + blmTableRepository ) } } diff --git a/app/src/main/java/com/example/esp32aldldashboard/repository/BLMTableRepository.kt b/app/src/main/java/com/example/esp32aldldashboard/repository/BLMTableRepository.kt new file mode 100644 index 0000000..12f522b --- /dev/null +++ b/app/src/main/java/com/example/esp32aldldashboard/repository/BLMTableRepository.kt @@ -0,0 +1,122 @@ +package com.example.esp32aldldashboard.repository + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlin.math.abs + +data class BLMCellData( + val blm: Int = 128, + val intValue: Int = 128, + val updateCount: Int = 0, + val lastUpdateTime: Long = 0 +) + +class BLMTableRepository { + + // RPM bands as specified by ECM + val rpmBands = listOf(600, 800, 1000, 1200, 1400, 1600, 2000, 2400, 2800, 3200, 3600, 4000, 4400, 4800) + + // MAP bands as specified by ECM + val mapBands = listOf(20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90, 95, 100) + + private val rowCount = rpmBands.size + private val colCount = mapBands.size + + // 2D array [RPM bands × MAP bands] + private val _tableData = MutableStateFlow( + Array(rowCount) { Array(colCount) { BLMCellData() } } + ) + val tableData: StateFlow>> = _tableData + + /** + * Updates the cell corresponding to the nearest RPM and MAP bands. + * Values are retained until replaced by new updates. + */ + fun updateCell(rpm: Int, mapKpa: Float, blm: Int, intValue: Int) { + val rpmIndex = findNearestBandIndex(rpm, rpmBands) + val mapIndex = findNearestBandIndex(mapKpa.toInt(), mapBands) + + if (rpmIndex >= 0 && rpmIndex < rowCount && mapIndex >= 0 && mapIndex < colCount) { + val currentData = _tableData.value + val newData = currentData.map { row -> row.map { it }.toTypedArray() }.toTypedArray() + + val existing = newData[rpmIndex][mapIndex] + newData[rpmIndex][mapIndex] = BLMCellData( + blm = blm, + intValue = intValue, + updateCount = existing.updateCount + 1, + lastUpdateTime = System.currentTimeMillis() + ) + + _tableData.value = newData + } + } + + /** + * Clears all table data, resetting to default values. + */ + fun clearTable() { + _tableData.value = Array(rowCount) { Array(colCount) { BLMCellData() } } + } + + /** + * Gets the color for a BLM value. + * Blue at 128 (center), Green at 120 (lean), Red at 150 (rich) + * Returns ARGB color value + */ + fun getBLMColor(blm: Int): Long { + return when { + blm <= 120 -> { + // Green (120 and below) + 0xFF00E676 + } + blm >= 150 -> { + // Red (150 and above) + 0xFFFF3D00 + } + blm <= 128 -> { + // Interpolate between Green (120) and Blue (128) + val fraction = (blm - 120) / 8f + interpolateColor(0xFF00E676, 0xFF2196F3, fraction) + } + else -> { + // Interpolate between Blue (128) and Red (150) + val fraction = (blm - 128) / 22f + interpolateColor(0xFF2196F3, 0xFFFF3D00, fraction) + } + } + } + + private fun interpolateColor(color1: Long, color2: Long, fraction: Float): Long { + val r1 = (color1 shr 16) and 0xFF + val g1 = (color1 shr 8) and 0xFF + val b1 = color1 and 0xFF + + val r2 = (color2 shr 16) and 0xFF + val g2 = (color2 shr 8) and 0xFF + val b2 = color2 and 0xFF + + val r = (r1 + (r2 - r1) * fraction).toInt() + val g = (g1 + (g2 - g1) * fraction).toInt() + val b = (b1 + (b2 - b1) * fraction).toInt() + + return 0xFF000000 or (r.toLong() shl 16) or (g.toLong() shl 8) or b.toLong() + } + + private fun findNearestBandIndex(value: Int, bands: List): Int { + if (bands.isEmpty()) return -1 + + var nearestIndex = 0 + var minDiff = abs(value - bands[0]) + + for (i in 1 until bands.size) { + val diff = abs(value - bands[i]) + if (diff < minDiff) { + minDiff = diff + nearestIndex = i + } + } + + return nearestIndex + } +} diff --git a/app/src/main/java/com/example/esp32aldldashboard/repository/TelemetryRepository.kt b/app/src/main/java/com/example/esp32aldldashboard/repository/TelemetryRepository.kt index f8e3443..43705d6 100644 --- a/app/src/main/java/com/example/esp32aldldashboard/repository/TelemetryRepository.kt +++ b/app/src/main/java/com/example/esp32aldldashboard/repository/TelemetryRepository.kt @@ -23,7 +23,8 @@ class TelemetryRepository( private val bluetoothService: BluetoothService, private val telemetryDao: TelemetryDao, private val csvLogger: CsvLogger, - private val settingsRepository: SettingsRepository + private val settingsRepository: SettingsRepository, + private val blmTableRepository: BLMTableRepository ) { private val repoScope = CoroutineScope(Dispatchers.IO + Job()) private var currentSessionId: Long? = null @@ -65,15 +66,25 @@ class TelemetryRepository( private fun observeTelemetry() { repoScope.launch { bluetoothService.latestFrame.collectLatest { frame -> - if (frame != null && isRecording) { - csvLogger.logFrame(frame) - currentSessionId?.let { sid -> - val dataPoint = TelemetryDataPointEntity( - sessionId = sid, - timestamp = frame.timestamp, - rawBytes = frame.rawBytes - ) - telemetryDao.insertDataPoints(listOf(dataPoint)) + if (frame != null) { + // Update BLM table with every frame (not just when recording) + blmTableRepository.updateCell( + rpm = frame.engineSpeedRpm, + mapKpa = frame.mapKpa, + blm = frame.blm, + intValue = frame.integrator + ) + + if (isRecording) { + csvLogger.logFrame(frame) + currentSessionId?.let { sid -> + val dataPoint = TelemetryDataPointEntity( + sessionId = sid, + timestamp = frame.timestamp, + rawBytes = frame.rawBytes + ) + telemetryDao.insertDataPoints(listOf(dataPoint)) + } } } } diff --git a/app/src/main/java/com/example/esp32aldldashboard/ui/blm/BLMTableScreen.kt b/app/src/main/java/com/example/esp32aldldashboard/ui/blm/BLMTableScreen.kt new file mode 100644 index 0000000..e1caad8 --- /dev/null +++ b/app/src/main/java/com/example/esp32aldldashboard/ui/blm/BLMTableScreen.kt @@ -0,0 +1,226 @@ +package com.example.esp32aldldashboard.ui.blm + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.example.esp32aldldashboard.repository.BLMTableRepository + +@Composable +fun BLMTableScreen( + viewModel: BLMTableViewModel, + modifier: Modifier = Modifier +) { + val tableData by viewModel.tableData.collectAsStateWithLifecycle() + val rpmBands = viewModel.rpmBands + val mapBands = viewModel.mapBands + + Column( + modifier = modifier + .fillMaxSize() + .padding(16.dp) + .verticalScroll(rememberScrollState()) + ) { + // Header with title and clear button + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + text = "BLM/INT Table", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground + ) + Text( + text = "RPM (vertical) × MAP (horizontal)", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Button( + onClick = { viewModel.clearTable() }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ) + ) { + Text("Clear") + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Legend + BLMLegend() + + Spacer(modifier = Modifier.height(16.dp)) + + // Table container + Column { + // MAP headers (top) + Row { + // Empty corner cell + Box( + modifier = Modifier + .size(60.dp) + .background(Color(0xFF2C2C2C)) + .border(1.dp, Color(0xFF3C3C3C)), + contentAlignment = Alignment.Center + ) { + Text( + text = "RPM\\MAP", + fontSize = 10.sp, + color = Color.Gray, + textAlign = TextAlign.Center + ) + } + + // MAP band headers + mapBands.forEach { map -> + Box( + modifier = Modifier + .width(50.dp) + .height(60.dp) + .background(Color(0xFF2C2C2C)) + .border(1.dp, Color(0xFF3C3C3C)), + contentAlignment = Alignment.Center + ) { + Text( + text = "$map", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFF00E5FF), + textAlign = TextAlign.Center + ) + } + } + } + + // Data rows + rpmBands.forEachIndexed { rowIndex, rpm -> + Row { + // RPM band header (left) + Box( + modifier = Modifier + .width(60.dp) + .height(50.dp) + .background(Color(0xFF2C2C2C)) + .border(1.dp, Color(0xFF3C3C3C)), + contentAlignment = Alignment.Center + ) { + Text( + text = "$rpm", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFF00E5FF), + textAlign = TextAlign.Center + ) + } + + // Data cells + if (rowIndex < tableData.size) { + tableData[rowIndex].forEach { cell -> + val colorArgb = viewModel.getBLMColor(cell.blm) + val cellColor = Color(colorArgb) + + Box( + modifier = Modifier + .width(50.dp) + .height(50.dp) + .background(cellColor.copy(alpha = 0.3f)) + .border(1.dp, cellColor), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "${cell.blm}", + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = if (cell.blm > 140 || cell.blm < 116) Color.White else Color.Black + ) + Text( + text = "${cell.intValue}", + fontSize = 10.sp, + color = if (cell.blm > 140 || cell.blm < 116) Color.White.copy(alpha = 0.7f) else Color.Black.copy(alpha = 0.7f) + ) + } + } + } + } + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + } +} + +@Composable +private fun BLMLegend() { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)) + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = "Color Legend", + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + LegendItem(color = Color(0xFF00E676), label = "≤120 Lean", textColor = Color.White) + LegendItem(color = Color(0xFF2196F3), label = "128 Ideal", textColor = Color.White) + LegendItem(color = Color(0xFFFF3D00), label = "≥150 Rich", textColor = Color.White) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "BLM = Block Learn Multiplier (fuel trim). INT = Integrator (short-term correction). Values show most recent update.", + fontSize = 10.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + } +} + +@Composable +private fun LegendItem(color: Color, label: String, textColor: Color) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(16.dp) + .background(color, shape = RoundedCornerShape(2.dp)) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = label, + fontSize = 10.sp, + color = textColor + ) + } +} diff --git a/app/src/main/java/com/example/esp32aldldashboard/ui/main/MainScreen.kt b/app/src/main/java/com/example/esp32aldldashboard/ui/main/MainScreen.kt index 6e920d9..990dcd6 100644 --- a/app/src/main/java/com/example/esp32aldldashboard/ui/main/MainScreen.kt +++ b/app/src/main/java/com/example/esp32aldldashboard/ui/main/MainScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.ShowChart +import androidx.compose.material.icons.filled.TableChart import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier @@ -18,6 +19,8 @@ import androidx.core.content.ContextCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.example.esp32aldldashboard.AldlApplication +import com.example.esp32aldldashboard.ui.blm.BLMTableScreen +import com.example.esp32aldldashboard.ui.blm.BLMTableViewModelFactory import com.example.esp32aldldashboard.ui.charts.ChartsScreen import com.example.esp32aldldashboard.ui.settings.SettingsScreen @@ -96,12 +99,19 @@ fun MainScreen( colors = NavigationBarItemDefaults.colors(selectedIconColor = NeonCyan, unselectedIconColor = TextMuted) ) NavigationBarItem( - icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") }, - label = { Text("Settings") }, + icon = { Icon(Icons.Default.TableChart, contentDescription = "BLM Table") }, + label = { Text("BLM") }, selected = selectedTab == 2, onClick = { selectedTab = 2 }, colors = NavigationBarItemDefaults.colors(selectedIconColor = NeonCyan, unselectedIconColor = TextMuted) ) + NavigationBarItem( + icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") }, + label = { Text("Settings") }, + selected = selectedTab == 3, + onClick = { selectedTab = 3 }, + colors = NavigationBarItemDefaults.colors(selectedIconColor = NeonCyan, unselectedIconColor = TextMuted) + ) } } ) { paddingValues -> @@ -120,7 +130,16 @@ fun MainScreen( chartPreferencesRepository = app.chartPreferencesRepository, modifier = modifier.padding(paddingValues) ) - 2 -> SettingsScreen( + 2 -> { + val blmViewModel: com.example.esp32aldldashboard.ui.blm.BLMTableViewModel = viewModel( + factory = BLMTableViewModelFactory(app.blmTableRepository) + ) + BLMTableScreen( + viewModel = blmViewModel, + modifier = modifier.padding(paddingValues) + ) + } + 3 -> SettingsScreen( settingsRepository = app.settingsRepository, modifier = modifier.padding(paddingValues) )