feat: add BLM/INT heatmap table with RPM/MAP bands
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
+21
-10
@@ -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)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user