feat: add BLM/INT heatmap table with RPM/MAP bands

This commit is contained in:
2026-06-12 10:56:51 +01:00
parent f760a9f300
commit bb47d61eec
5 changed files with 396 additions and 14 deletions
@@ -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
)
}
}
@@ -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<Array<Array<BLMCellData>>> = _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>): 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
}
}
@@ -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))
}
}
}
}
@@ -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
)
}
}
@@ -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)
)