feat: initialize Android project structure with Gradle and basic UI/data architecture

This commit is contained in:
2026-06-11 22:41:02 +01:00
parent 70ae9230ca
commit f8f3be36ee
54 changed files with 3961 additions and 3 deletions
@@ -0,0 +1,22 @@
package com.example.esp32aldldashboard
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import com.example.esp32aldldashboard.theme.ESP32ALDLDashboardTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ESP32ALDLDashboardTheme { Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { MainNavigation() } }
}
}
}
@@ -0,0 +1,27 @@
package com.example.esp32aldldashboard
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
import com.example.esp32aldldashboard.ui.main.MainScreen
@Composable
fun MainNavigation() {
val backStack = rememberNavBackStack(Main)
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider =
entryProvider {
entry<Main> {
MainScreen(onItemClick = { navKey -> backStack.add(navKey) }, modifier = Modifier.safeDrawingPadding().padding(16.dp))
}
},
)
}
@@ -0,0 +1,6 @@
package com.example.esp32aldldashboard
import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable
@Serializable data object Main : NavKey
@@ -0,0 +1,330 @@
package com.example.esp32aldldashboard.bluetooth
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothSocket
import android.content.Context
import android.util.Log
import com.example.esp32aldldashboard.parser.ALDLFrame
import com.example.esp32aldldashboard.parser.ALDLParser
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import java.io.IOException
import java.io.InputStream
import java.util.UUID
enum class ConnectionState {
DISCONNECTED,
CONNECTING,
CONNECTED,
ERROR
}
class BluetoothService(private val context: Context) {
private val TAG = "ALDLBluetoothService"
private val SPP_UUID = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB")
private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED)
val connectionState: StateFlow<ConnectionState> = _connectionState
private val _latestFrame = MutableStateFlow<ALDLFrame?>(null)
val latestFrame: StateFlow<ALDLFrame?> = _latestFrame
private val _rawHexLog = MutableStateFlow<List<String>>(emptyList())
val rawHexLog: StateFlow<List<String>> = _rawHexLog
private val _errorMessage = MutableStateFlow("")
val errorMessage: StateFlow<String> = _errorMessage
private var connectionJob: Job? = null
private var socket: BluetoothSocket? = null
private var isConnected = false
private var isSimulating = false
private val bluetoothAdapter: BluetoothAdapter? by lazy {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
manager.adapter
}
private fun addRawHexLog(hex: String) {
val currentList = _rawHexLog.value.toMutableList()
if (currentList.size >= 100) {
currentList.removeAt(0)
}
currentList.add(hex)
_rawHexLog.value = currentList
}
fun startSimulation() {
if (_connectionState.value == ConnectionState.CONNECTED) {
disconnect()
}
isSimulating = true
_connectionState.value = ConnectionState.CONNECTED
_errorMessage.value = ""
connectionJob = CoroutineScope(Dispatchers.Default).launch {
var simStep = 0
val basePayload = 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()
)
while (isActive && isSimulating) {
// Generate dynamic simulation data to show moving values on UI
val payload = basePayload.clone()
simStep++
// Simulate Engine Speed (Index 7), Coolant Temp (Index 4), Speed (Index 5), TPS (Index 8)
val rpmRaw: Int
val coolantRaw: Int
val speedRaw: Int
val tpsRaw: Int
val bpwHighRaw: Int
val bpwLowRaw: Int
val mapRaw: Int
val o2MvRaw: Int
val codesByte1: Int
val miscByte1: Int
when (simStep % 4) {
0 -> { // Key On Engine Off (Prompt data)
rpmRaw = 0
coolantRaw = 89 // ~80F / 26C
speedRaw = 0
tpsRaw = 30 // ~0.58V
mapRaw = 244 // ~100 kPa (Atmospheric)
o2MvRaw = 101 // ~448 mV
bpwHighRaw = 0x02
bpwLowRaw = 0x62 // 610 dec = 9.3ms
codesByte1 = 0x08 // Code 21 Active (TPS High)
miscByte1 = 0x00 // Open loop
}
1 -> { // Cranking
rpmRaw = 8 // 200 RPM
coolantRaw = 90
speedRaw = 0
tpsRaw = 35 // ~0.68V
mapRaw = 220 // ~91 kPa
o2MvRaw = 110 // ~488 mV
bpwHighRaw = 0x04
bpwLowRaw = 0x10 // 1040 dec = 15.8ms
codesByte1 = 0x00
miscByte1 = 0x00
}
2 -> { // Idle (Warmup)
rpmRaw = 30 // 750 RPM
coolantRaw = 140 // ~149F / 65C
speedRaw = 0
tpsRaw = 28 // ~0.54V
mapRaw = 80 // ~39 kPa (Vacuum)
o2MvRaw = (120 + 80 * Math.sin(simStep.toDouble())).toInt() // oscillating O2
bpwHighRaw = 0x00
bpwLowRaw = 0xD0 // 208 dec = 3.1ms
codesByte1 = 0x00
miscByte1 = 0x82 // Closed Loop, BLM Enable
}
else -> { // Cruising
rpmRaw = 88 // 2200 RPM
coolantRaw = 180 // ~203F / 95C
speedRaw = 45 // 45 MPH
tpsRaw = 62 // ~1.2V
mapRaw = 140 // ~62 kPa
o2MvRaw = (120 + 100 * Math.sin(simStep.toDouble())).toInt()
bpwHighRaw = 0x01
bpwLowRaw = 0x20 // 288 dec = 4.4ms
codesByte1 = 0x00
miscByte1 = 0xC2 // Closed Loop, BLM Enable, Rich
}
}
payload[4] = coolantRaw.toByte()
payload[5] = speedRaw.toByte()
payload[6] = mapRaw.toByte()
payload[7] = rpmRaw.toByte()
payload[8] = tpsRaw.toByte()
payload[10] = o2MvRaw.toByte()
payload[11] = codesByte1.toByte()
payload[14] = miscByte1.toByte()
payload[23] = bpwHighRaw.toByte()
payload[24] = bpwLowRaw.toByte()
val parsed = ALDLParser.parseFrame(payload)
if (parsed != null) {
_latestFrame.value = parsed
val hexString = payload.joinToString(" ") { String.format("%02X", it) }
addRawHexLog("AA 55 $hexString (SIMULATED)")
}
delay(1000)
}
}
}
@SuppressLint("MissingPermission")
fun connect() {
if (isSimulating) {
isSimulating = false
connectionJob?.cancel()
}
if (_connectionState.value == ConnectionState.CONNECTED || _connectionState.value == ConnectionState.CONNECTING) {
return
}
val adapter = bluetoothAdapter
if (adapter == null) {
_connectionState.value = ConnectionState.ERROR
_errorMessage.value = "Bluetooth is not supported on this device"
return
}
if (!adapter.isEnabled) {
_connectionState.value = ConnectionState.ERROR
_errorMessage.value = "Bluetooth is turned off"
return
}
_connectionState.value = ConnectionState.CONNECTING
_errorMessage.value = ""
connectionJob = CoroutineScope(Dispatchers.IO).launch {
try {
val pairedDevices = adapter.bondedDevices
val targetDevice: BluetoothDevice? = pairedDevices.find { it.name == "ESP32-ALDL" }
if (targetDevice == null) {
withContext(Dispatchers.Main) {
_connectionState.value = ConnectionState.ERROR
_errorMessage.value = "Device named 'ESP32-ALDL' is not paired. Please pair in system settings first."
}
return@launch
}
socket = targetDevice.createRfcommSocketToServiceRecord(SPP_UUID)
adapter.cancelDiscovery() // cancel discovery to speed up connection
socket?.connect()
isConnected = true
withContext(Dispatchers.Main) {
_connectionState.value = ConnectionState.CONNECTED
}
readDataStream(socket!!.inputStream)
} catch (e: Exception) {
Log.e(TAG, "Connection failed: ${e.message}", e)
withContext(Dispatchers.Main) {
_connectionState.value = ConnectionState.ERROR
_errorMessage.value = e.message ?: "Failed to connect"
}
disconnect()
}
}
}
private suspend fun readDataStream(inputStream: InputStream) {
val readBuffer = ByteArray(128)
val syncBuffer = ArrayList<Byte>()
while (currentCoroutineContext().isActive && isConnected) {
try {
val bytesRead = withContext(Dispatchers.IO) {
inputStream.read(readBuffer)
}
if (bytesRead <= 0) {
break // Stream closed
}
for (j in 0 until bytesRead) {
syncBuffer.add(readBuffer[j])
}
// Check for frame matches in buffer
while (syncBuffer.size >= 27) {
var foundHeader = false
for (idx in 0 until syncBuffer.size - 1) {
if ((syncBuffer[idx].toInt() and 0xFF) == 0xAA && (syncBuffer[idx + 1].toInt() and 0xFF) == 0x55) {
// Discard garbage preceding header
if (idx > 0) {
for (d in 0 until idx) {
syncBuffer.removeAt(0)
}
}
foundHeader = true
break
}
}
if (foundHeader) {
if (syncBuffer.size >= 27) {
val payload = ByteArray(25)
for (p in 0 until 25) {
payload[p] = syncBuffer[p + 2]
}
// Consume the 27 bytes from buffer
for (r in 0 until 27) {
syncBuffer.removeAt(0)
}
val parsed = ALDLParser.parseFrame(payload)
if (parsed != null) {
withContext(Dispatchers.Main) {
_latestFrame.value = parsed
val hexString = payload.joinToString(" ") { String.format("%02X", it) }
addRawHexLog("AA 55 $hexString")
}
}
} 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
val lastByte = syncBuffer.last()
syncBuffer.clear()
if ((lastByte.toInt() and 0xFF) == 0xAA) {
syncBuffer.add(lastByte)
}
break
}
}
} catch (e: IOException) {
Log.e(TAG, "Read stream error: ${e.message}")
break
}
}
withContext(Dispatchers.Main) {
_connectionState.value = ConnectionState.ERROR
_errorMessage.value = "Connection lost"
}
disconnect()
}
fun disconnect() {
isConnected = false
isSimulating = false
connectionJob?.cancel()
connectionJob = null
try {
socket?.close()
} catch (e: IOException) {
Log.e(TAG, "Error closing socket: ${e.message}")
}
socket = null
_connectionState.value = ConnectionState.DISCONNECTED
}
}
@@ -0,0 +1,12 @@
package com.example.esp32aldldashboard.data
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
interface DataRepository {
val data: Flow<List<String>>
}
class DefaultDataRepository : DataRepository {
override val data: Flow<List<String>> = flow { emit(listOf("Android")) }
}
@@ -0,0 +1,269 @@
package com.example.esp32aldldashboard.parser
data class ALDLFrame(
val rawBytes: ByteArray,
val iacPosition: Int,
val coolantTempC: Float,
val coolantTempF: Float,
val vehicleSpeedMPH: Int,
val mapVolts: Float,
val mapKpa: Float,
val engineSpeedRpm: Int,
val tpsVolts: Float,
val integrator: Int,
val o2SensorMv: Float,
val batteryVolts: Float,
val blm: Int,
val richLeanCrosses: Int,
val sparkAdvance: Float,
val egrDutyCycle: Float,
val matC: Float,
val matF: Float,
val bpwMs: Float,
val blmEnable: Boolean,
val quasiPulse: Boolean,
val asyncPulse: Boolean,
val isRich: Boolean,
val isClosedLoop: Boolean,
val isAcEnabled: Boolean,
val isParkNeutral: Boolean,
val isAcClutchEnabled: Boolean,
val isTccLocked: Boolean,
val isPowerSteeringCrampActive: Boolean,
val activeFaultCodes: List<Int>,
val timestamp: Long = System.currentTimeMillis()
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ALDLFrame
return rawBytes.contentEquals(other.rawBytes)
}
override fun hashCode(): Int {
return rawBytes.contentHashCode()
}
}
object ALDLParser {
// MAT C interpolation table: key is raw value, value is Temp in C
private val matTableC = listOf(
0 to 200.0f,
12 to 150.0f,
13 to 145.0f,
14 to 140.0f,
16 to 135.0f,
18 to 130.0f,
21 to 125.0f,
23 to 120.0f,
26 to 115.0f,
30 to 110.0f,
34 to 105.0f,
39 to 100.0f,
44 to 95.0f,
50 to 90.0f,
56 to 85.0f,
64 to 80.0f,
72 to 75.0f,
81 to 70.0f,
92 to 65.0f,
102 to 60.0f,
114 to 55.0f,
126 to 50.0f,
139 to 45.0f,
152 to 40.0f,
165 to 35.0f,
177 to 30.0f,
189 to 25.0f,
199 to 20.0f,
209 to 15.0f,
218 to 10.0f,
225 to 5.0f,
231 to 0.0f,
237 to -5.0f,
241 to -10.0f,
245 to -15.0f,
247 to -20.0f,
250 to -25.0f,
251 to -30.0f,
255 to -40.0f
)
// MAT F interpolation table: key is raw value, value is Temp in F
private val matTableF = listOf(
0 to 392.0f,
12 to 302.0f,
13 to 293.0f,
14 to 284.0f,
16 to 275.0f,
18 to 266.0f,
21 to 257.0f,
23 to 248.0f,
26 to 239.0f,
30 to 230.0f,
34 to 221.0f,
39 to 212.0f,
44 to 203.0f,
50 to 194.0f,
56 to 185.0f,
64 to 176.0f,
72 to 167.0f,
81 to 158.0f,
92 to 149.0f,
102 to 140.0f,
114 to 131.0f,
126 to 122.0f,
139 to 113.0f,
152 to 104.0f,
165 to 95.0f,
177 to 86.0f,
189 to 77.0f,
199 to 68.0f,
209 to 59.0f,
218 to 50.0f,
225 to 41.0f,
231 to 32.0f,
237 to 23.0f,
241 to 14.0f,
245 to 5.0f,
247 to -4.0f,
250 to -13.0f,
251 to -22.0f,
255 to -40.0f
)
private fun interpolate(raw: Int, table: List<Pair<Int, Float>>): Float {
if (raw <= table.first().first) return table.first().second
if (raw >= table.last().first) return table.last().second
for (i in 0 until table.size - 1) {
val current = table[i]
val next = table[i + 1]
if (raw >= current.first && raw <= next.first) {
val span = next.first - current.first
if (span == 0) return current.second
val t = (raw - current.first).toFloat() / span
return current.second + t * (next.second - current.second)
}
}
return 0.0f
}
/**
* Parses a 25-byte raw data payload.
*/
fun parseFrame(data: ByteArray): ALDLFrame? {
if (data.size != 25) return null
val u = IntArray(25) { data[it].toInt() and 0xFF }
// Mappings based on 1-indexed btByteNumber in 24-INT10.ads (index = byteNumber - 1)
val iacPosition = u[3] // Byte 4
val coolantTempC = u[4] * 0.75f - 40.0f // Byte 5
val coolantTempF = u[4] * 1.35f - 40.0f // Byte 5
val vehicleSpeedMPH = u[5] // Byte 6
val mapVolts = u[6] * 0.019608f // Byte 7
val mapKpa = u[6] * 0.369f + 10.354f // Byte 7
val engineSpeedRpm = u[7] * 25 // Byte 8
val tpsVolts = u[8] * 0.019608f // Byte 9
val integrator = u[9] // Byte 10
val o2SensorMv = u[10] * 4.44f // Byte 11
val codesByte1 = u[11] // Byte 12
val codesByte2 = u[12] // Byte 13
val codesByte3 = u[13] // Byte 14
val miscByte1 = u[14] // Byte 15
val miscByte2 = u[15] // Byte 16
val miscByte3 = u[16] // Byte 17
val batteryVolts = u[17] * 0.1f // Byte 18
val blm = u[18] // Byte 19
val richLeanCrosses = u[19] // Byte 20
val sparkAdvance = u[20] * 0.351563f // Byte 21
val egrDutyCycle = u[21] * 0.392157f // Byte 22
// MAT (Air Temp) Interpolation
val matC = interpolate(u[22], matTableC) // Byte 23
val matF = interpolate(u[22], matTableF) // Byte 23
// BPW (Base Pulse Width) 16-bit
val rawBpw = (u[23] shl 8) or u[24] // Byte 24 (High), Byte 25 (Low)
val bpwMs = rawBpw * 0.015259f
// Status Flags Decoding
// Misc Byte 1 (Byte 15)
val blmEnable = (miscByte1 and 0x02) != 0 // bit 1
val quasiPulse = (miscByte1 and 0x08) != 0 // bit 3
val asyncPulse = (miscByte1 and 0x10) != 0 // bit 4
val isRich = (miscByte1 and 0x40) != 0 // bit 6 (1=RICH, 0=LEAN)
val isClosedLoop = (miscByte1 and 0x80) != 0 // bit 7 (1=CLOSED, 0=OPEN)
// Misc Byte 2 (Byte 16)
val isAcEnabled = (miscByte2 and 0x20) == 0 // bit 5 (0=ENABLED, 1=DISABLED/IDLE)
val isParkNeutral = (miscByte2 and 0x80) != 0 // bit 7 (1=PARK/NEUTRAL, 0=IN GEAR)
// Misc Byte 3 (Byte 17)
val isAcClutchEnabled = (miscByte3 and 0x01) != 0 // bit 0 (1=ENABLED)
val isTccLocked = (miscByte3 and 0x04) != 0 // bit 2 (1=LOCKED)
val isPowerSteeringCrampActive = (miscByte3 and 0x20) != 0 // bit 5 (1=ACTIVE)
// Active Fault Codes list based on code bits
val activeCodes = mutableListOf<Int>()
// Byte 12
if ((codesByte1 and 0x80) != 0) activeCodes.add(12) // bit 7
if ((codesByte1 and 0x40) != 0) activeCodes.add(13) // bit 6
if ((codesByte1 and 0x20) != 0) activeCodes.add(14) // bit 5
if ((codesByte1 and 0x10) != 0) activeCodes.add(15) // bit 4
if ((codesByte1 and 0x08) != 0) activeCodes.add(21) // bit 3
if ((codesByte1 and 0x04) != 0) activeCodes.add(22) // bit 2
if ((codesByte1 and 0x02) != 0) activeCodes.add(23) // bit 1
if ((codesByte1 and 0x01) != 0) activeCodes.add(24) // bit 0
// Byte 13
if ((codesByte2 and 0x80) != 0) activeCodes.add(25) // bit 7
if ((codesByte2 and 0x20) != 0) activeCodes.add(32) // bit 5
if ((codesByte2 and 0x10) != 0) activeCodes.add(33) // bit 4
if ((codesByte2 and 0x08) != 0) activeCodes.add(34) // bit 3
if ((codesByte2 and 0x04) != 0) activeCodes.add(35) // bit 2
if ((codesByte2 and 0x01) != 0) activeCodes.add(42) // bit 0
// Byte 14
if ((codesByte3 and 0x80) != 0) activeCodes.add(43) // bit 7
if ((codesByte3 and 0x40) != 0) activeCodes.add(44) // bit 6
if ((codesByte3 and 0x20) != 0) activeCodes.add(45) // bit 5
if ((codesByte3 and 0x10) != 0) activeCodes.add(51) // bit 4
if ((codesByte3 and 0x08) != 0) activeCodes.add(52) // bit 3
if ((codesByte3 and 0x04) != 0) activeCodes.add(53) // bit 2
if ((codesByte3 and 0x01) != 0) activeCodes.add(55) // bit 0
return ALDLFrame(
rawBytes = data,
iacPosition = iacPosition,
coolantTempC = coolantTempC,
coolantTempF = coolantTempF,
vehicleSpeedMPH = vehicleSpeedMPH,
mapVolts = mapVolts,
mapKpa = mapKpa,
engineSpeedRpm = engineSpeedRpm,
tpsVolts = tpsVolts,
integrator = integrator,
o2SensorMv = o2SensorMv,
batteryVolts = batteryVolts,
blm = blm,
richLeanCrosses = richLeanCrosses,
sparkAdvance = sparkAdvance,
egrDutyCycle = egrDutyCycle,
matC = matC,
matF = matF,
bpwMs = bpwMs,
blmEnable = blmEnable,
quasiPulse = quasiPulse,
asyncPulse = asyncPulse,
isRich = isRich,
isClosedLoop = isClosedLoop,
isAcEnabled = isAcEnabled,
isParkNeutral = isParkNeutral,
isAcClutchEnabled = isAcClutchEnabled,
isTccLocked = isTccLocked,
isPowerSteeringCrampActive = isPowerSteeringCrampActive,
activeFaultCodes = activeCodes
)
}
}
@@ -0,0 +1,11 @@
package com.example.esp32aldldashboard.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
@@ -0,0 +1,50 @@
package com.example.esp32aldldashboard.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme(primary = Purple80, secondary = PurpleGrey80, tertiary = Pink80)
private val LightColorScheme =
lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40,
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
)
@Composable
fun ESP32ALDLDashboardTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit,
) {
val colorScheme =
when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(colorScheme = colorScheme, typography = Typography, content = content)
}
@@ -0,0 +1,36 @@
package com.example.esp32aldldashboard.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography =
Typography(
bodyLarge =
TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp,
)
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)
@@ -0,0 +1,712 @@
package com.example.esp32aldldashboard.ui.main
import android.Manifest
import android.content.pm.PackageManager
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
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.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
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.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation3.runtime.NavKey
import com.example.esp32aldldashboard.bluetooth.ConnectionState
import com.example.esp32aldldashboard.parser.ALDLFrame
// Theme Colors
private val DarkBg = Color(0xFF0F0F12)
private val CardBg = Color(0xFF1B1B22)
private val BorderColor = Color(0xFF2E2E38)
private val NeonCyan = Color(0xFF00E5FF)
private val NeonRed = Color(0xFFFF3D00)
private val NeonGreen = Color(0xFF00E676)
private val NeonOrange = Color(0xFFFF9100)
private val TextWhite = Color(0xFFEEEEEE)
private val TextMuted = Color(0xFF9E9EAF)
@Composable
fun MainScreen(
onItemClick: (NavKey) -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val viewModel: MainScreenViewModel = viewModel { MainScreenViewModel(context) }
val connState by viewModel.connectionState.collectAsStateWithLifecycle()
val frame by viewModel.latestFrame.collectAsStateWithLifecycle()
val rawLog by viewModel.rawHexLog.collectAsStateWithLifecycle()
val errorMsg by viewModel.errorMessage.collectAsStateWithLifecycle()
val isCelsius by viewModel.isCelsius.collectAsStateWithLifecycle()
val permissionsLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
val allGranted = permissions.values.all { it }
if (allGranted) {
viewModel.connect()
} else {
Toast.makeText(context, "Bluetooth and Location permissions are required to connect", Toast.LENGTH_LONG).show()
}
}
val onConnectClick = {
val requiredPermissions = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
arrayOf(
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_CONNECT
)
} else {
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
}
val allGranted = requiredPermissions.all { perm ->
ContextCompat.checkSelfPermission(context, perm) == PackageManager.PERMISSION_GRANTED
}
if (allGranted) {
viewModel.connect()
} else {
permissionsLauncher.launch(requiredPermissions)
}
}
MainScreenContent(
connState = connState,
frame = frame,
rawLog = rawLog,
errorMsg = errorMsg,
isCelsius = isCelsius,
onConnect = onConnectClick,
onDisconnect = { viewModel.disconnect() },
onSimulate = { viewModel.startSimulation() },
onToggleUnit = { viewModel.toggleTemperatureUnit() },
modifier = modifier
)
}
@Composable
fun MainScreenContent(
connState: ConnectionState,
frame: ALDLFrame?,
rawLog: List<String>,
errorMsg: String,
isCelsius: Boolean,
onConnect: () -> Unit,
onDisconnect: () -> Unit,
onSimulate: () -> Unit,
onToggleUnit: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.fillMaxSize()
.background(DarkBg)
.verticalScroll(rememberScrollState())
) {
// App Title Banner
Text(
text = "PONTIAC FIERO ALDL DASHBOARD",
color = NeonOrange,
fontSize = 18.sp,
fontWeight = FontWeight.ExtraBold,
letterSpacing = 1.5.sp,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp)
)
// Connection Action Card
ConnectionCard(
connState = connState,
errorMsg = errorMsg,
isCelsius = isCelsius,
onConnect = onConnect,
onDisconnect = onDisconnect,
onSimulate = onSimulate,
onToggleUnit = onToggleUnit
)
Spacer(modifier = Modifier.height(12.dp))
// Telemetry Panels
if (frame != null) {
// Trouble Codes Card (Flashing if active)
if (frame.activeFaultCodes.isNotEmpty()) {
TroubleCodesCard(activeCodes = frame.activeFaultCodes)
Spacer(modifier = Modifier.height(12.dp))
}
// Key Metrics
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
MetricCard(
title = "ENGINE SPEED",
value = "${frame.engineSpeedRpm}",
unit = "RPM",
progress = frame.engineSpeedRpm / 6000f,
progressColor = NeonCyan,
modifier = Modifier.weight(1f)
)
MetricCard(
title = "VEHICLE SPEED",
value = "${frame.vehicleSpeedMPH}",
unit = "MPH",
progress = frame.vehicleSpeedMPH / 120f,
progressColor = NeonGreen,
modifier = Modifier.weight(1f)
)
}
Spacer(modifier = Modifier.height(12.dp))
// Grid of Minor Telemetry Parameters
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Coolant and Intake Temp Row
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
val coolantVal = if (isCelsius) frame.coolantTempC else frame.coolantTempF
val coolantUnit = if (isCelsius) "°C" else "°F"
val coolantProgress = (coolantVal + 40) / 290f // normalized range
val matVal = if (isCelsius) frame.matC else frame.matF
val matProgress = (matVal + 40) / 290f
GridItemCard(
title = "COOLANT TEMP",
value = String.format("%.1f", coolantVal) + coolantUnit,
progress = coolantProgress,
progressColor = if (coolantVal > 210) NeonRed else NeonGreen,
modifier = Modifier.weight(1f)
)
GridItemCard(
title = "MAT (AIR TEMP)",
value = String.format("%.1f", matVal) + coolantUnit,
progress = matProgress,
progressColor = NeonCyan,
modifier = Modifier.weight(1f)
)
}
// Fuel control row
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
GridItemCard(
title = "BPW (INJECTOR)",
value = String.format("%.3f ms", frame.bpwMs),
progress = frame.bpwMs / 15f,
progressColor = NeonCyan,
modifier = Modifier.weight(1f)
)
GridItemCard(
title = "O2 SENSOR",
value = "${frame.o2SensorMv.toInt()} mV",
progress = frame.o2SensorMv / 1000f,
progressColor = if (frame.isRich) NeonGreen else NeonOrange,
modifier = Modifier.weight(1f)
)
}
// Air flow & Throttle Position
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
GridItemCard(
title = "TPS (THROTTLE)",
value = String.format("%.2f V", frame.tpsVolts),
progress = frame.tpsVolts / 5.0f,
progressColor = NeonGreen,
modifier = Modifier.weight(1f)
)
GridItemCard(
title = "MAP (VACUUM)",
value = String.format("%.1f kPa", frame.mapKpa),
progress = frame.mapKpa / 105f,
progressColor = NeonCyan,
modifier = Modifier.weight(1f)
)
}
// Fuel trims (BLM & INT) & Battery
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
GridItemCard(
title = "BLM / INT",
value = "${frame.blm} / ${frame.integrator}",
progress = frame.blm / 256f,
progressColor = if (frame.blm in 120..136) NeonGreen else NeonOrange,
modifier = Modifier.weight(1f)
)
GridItemCard(
title = "BATTERY VOLTS",
value = String.format("%.1f V", frame.batteryVolts),
progress = (frame.batteryVolts - 8) / 8f,
progressColor = if (frame.batteryVolts < 12.0f) NeonRed else NeonGreen,
modifier = Modifier.weight(1f)
)
}
// IAC, Spark & EGR
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
GridItemCard(
title = "IAC POSITION",
value = "${frame.iacPosition} Steps",
progress = frame.iacPosition / 160f,
progressColor = NeonCyan,
modifier = Modifier.weight(1f)
)
GridItemCard(
title = "SPARK / EGR",
value = String.format("%.1f° / %.0f%%", frame.sparkAdvance, frame.egrDutyCycle),
progress = frame.egrDutyCycle / 100f,
progressColor = NeonCyan,
modifier = Modifier.weight(1f)
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Status Badges Section
StatusFlagsPanel(frame = frame)
Spacer(modifier = Modifier.height(16.dp))
} else {
// Empty / Waiting display
Box(
modifier = Modifier
.fillMaxWidth()
.height(260.dp)
.padding(horizontal = 16.dp)
.background(CardBg, shape = RoundedCornerShape(12.dp))
.border(1.dp, BorderColor, shape = RoundedCornerShape(12.dp)),
contentAlignment = Alignment.Center
) {
Text(
text = "Waiting for ALDL Stream data...\nSelect Connection or Simulation above.",
color = TextMuted,
fontSize = 14.sp,
textAlign = TextAlign.Center
)
}
Spacer(modifier = Modifier.height(16.dp))
}
// Live Diagnostic Console log
DiagnosticConsole(rawLog = rawLog)
Spacer(modifier = Modifier.height(24.dp))
}
}
@Composable
fun ConnectionCard(
connState: ConnectionState,
errorMsg: String,
isCelsius: Boolean,
onConnect: () -> Unit,
onDisconnect: () -> Unit,
onSimulate: () -> Unit,
onToggleUnit: () -> Unit
) {
Card(
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = CardBg),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.border(1.dp, BorderColor, shape = RoundedCornerShape(12.dp))
) {
Column(
modifier = Modifier.padding(16.dp)
) {
// Status bar
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "STATUS: ",
color = TextMuted,
fontWeight = FontWeight.Bold,
fontSize = 13.sp
)
val (statusText, statusColor) = when (connState) {
ConnectionState.DISCONNECTED -> "DISCONNECTED" to TextMuted
ConnectionState.CONNECTING -> "CONNECTING..." to NeonOrange
ConnectionState.CONNECTED -> "CONNECTED" to NeonGreen
ConnectionState.ERROR -> "ERROR" to NeonRed
}
Text(
text = statusText,
color = statusColor,
fontWeight = FontWeight.Bold,
fontSize = 13.sp
)
}
// Temperature Toggle Button
Button(
onClick = onToggleUnit,
colors = ButtonDefaults.buttonColors(containerColor = BorderColor),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
shape = RoundedCornerShape(6.dp),
modifier = Modifier.height(28.dp)
) {
Text(
text = if (isCelsius) "USE °F" else "USE °C",
color = TextWhite,
fontSize = 11.sp,
fontWeight = FontWeight.Bold
)
}
}
if (errorMsg.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = errorMsg,
color = NeonRed,
fontSize = 12.sp,
fontWeight = FontWeight.Medium
)
}
Spacer(modifier = Modifier.height(14.dp))
// Action Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
if (connState != ConnectionState.CONNECTED && connState != ConnectionState.CONNECTING) {
Button(
onClick = onConnect,
colors = ButtonDefaults.buttonColors(containerColor = NeonOrange),
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(8.dp)
) {
Text("CONNECT BT", fontWeight = FontWeight.Bold)
}
} else {
Button(
onClick = onDisconnect,
colors = ButtonDefaults.buttonColors(containerColor = BorderColor),
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(8.dp)
) {
Text("DISCONNECT", fontWeight = FontWeight.Bold, color = TextWhite)
}
}
Button(
onClick = onSimulate,
colors = ButtonDefaults.buttonColors(containerColor = BorderColor),
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(8.dp)
) {
Text("SIMULATE", fontWeight = FontWeight.Bold, color = NeonCyan)
}
}
}
}
}
@Composable
fun MetricCard(
title: String,
value: String,
unit: String,
progress: Float,
progressColor: Color,
modifier: Modifier = Modifier
) {
Card(
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = CardBg),
modifier = modifier
.border(1.dp, BorderColor, shape = RoundedCornerShape(12.dp))
) {
Column(
modifier = Modifier.padding(14.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = title,
color = TextMuted,
fontSize = 11.sp,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(4.dp))
Row(
verticalAlignment = Alignment.Bottom
) {
Text(
text = value,
color = TextWhite,
fontSize = 28.sp,
fontWeight = FontWeight.Black
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = unit,
color = TextMuted,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 4.dp)
)
}
Spacer(modifier = Modifier.height(8.dp))
LinearProgressIndicator(
progress = progress.coerceIn(0f, 1f),
color = progressColor,
trackColor = BorderColor,
modifier = Modifier
.fillMaxWidth()
.height(6.dp)
)
}
}
}
@Composable
fun GridItemCard(
title: String,
value: String,
progress: Float,
progressColor: Color,
modifier: Modifier = Modifier
) {
Card(
shape = RoundedCornerShape(10.dp),
colors = CardDefaults.cardColors(containerColor = CardBg),
modifier = modifier
.border(1.dp, BorderColor, shape = RoundedCornerShape(10.dp))
) {
Column(
modifier = Modifier.padding(10.dp)
) {
Text(
text = title,
color = TextMuted,
fontSize = 10.sp,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = value,
color = TextWhite,
fontSize = 18.sp,
fontWeight = FontWeight.ExtraBold
)
Spacer(modifier = Modifier.height(6.dp))
LinearProgressIndicator(
progress = progress.coerceIn(0f, 1f),
color = progressColor,
trackColor = BorderColor,
modifier = Modifier
.fillMaxWidth()
.height(4.dp)
)
}
}
}
@Composable
fun TroubleCodesCard(activeCodes: List<Int>) {
Card(
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = CardBg),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.border(1.dp, NeonRed, shape = RoundedCornerShape(12.dp))
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "⚠️ ACTIVE ECM FAULT CODES",
color = NeonRed,
fontWeight = FontWeight.Black,
fontSize = 14.sp
)
}
Spacer(modifier = Modifier.height(8.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
activeCodes.forEach { code ->
Box(
modifier = Modifier
.background(NeonRed, shape = RoundedCornerShape(4.dp))
.padding(horizontal = 10.dp, vertical = 4.dp)
) {
Text(
text = "CODE $code",
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 12.sp
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Refer to Fiero shop manual for diagnosis.",
color = TextMuted,
fontSize = 11.sp
)
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun StatusFlagsPanel(frame: ALDLFrame) {
Card(
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = CardBg),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.border(1.dp, BorderColor, shape = RoundedCornerShape(12.dp))
) {
Column(
modifier = Modifier.padding(14.dp)
) {
Text(
text = "LOOP STATUS & SYSTEM SWITCHES",
color = TextMuted,
fontSize = 12.sp,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(10.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth()
) {
StatusBadge(label = "Closed Loop", active = frame.isClosedLoop, activeColor = NeonGreen)
StatusBadge(label = "Rich Mixture", active = frame.isRich, activeColor = NeonGreen)
StatusBadge(label = "BLM Enabled", active = frame.blmEnable, activeColor = NeonGreen)
StatusBadge(label = "TCC Locked", active = frame.isTccLocked, activeColor = NeonGreen)
StatusBadge(label = "AC Clutch", active = frame.isAcClutchEnabled, activeColor = NeonGreen)
StatusBadge(label = "Park/Neutral", active = frame.isParkNeutral, activeColor = NeonCyan)
StatusBadge(label = "A/C Request", active = frame.isAcEnabled, activeColor = NeonCyan)
StatusBadge(label = "PS Cramp", active = frame.isPowerSteeringCrampActive, activeColor = NeonOrange)
StatusBadge(label = "Async Pulse", active = frame.asyncPulse, activeColor = NeonOrange)
StatusBadge(label = "Quasi Pulse", active = frame.quasiPulse, activeColor = NeonOrange)
}
}
}
}
@Composable
fun StatusBadge(label: String, active: Boolean, activeColor: Color) {
Box(
modifier = Modifier
.background(
color = if (active) activeColor.copy(alpha = 0.15f) else Color.Transparent,
shape = RoundedCornerShape(4.dp)
)
.border(
width = 1.dp,
color = if (active) activeColor else BorderColor,
shape = RoundedCornerShape(4.dp)
)
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Text(
text = label,
color = if (active) activeColor else TextMuted,
fontSize = 11.sp,
fontWeight = FontWeight.Bold
)
}
}
@Composable
fun DiagnosticConsole(rawLog: List<String>) {
Card(
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = CardBg),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.border(1.dp, BorderColor, shape = RoundedCornerShape(12.dp))
) {
Column(
modifier = Modifier.padding(14.dp)
) {
Text(
text = "DIAGNOSTIC TELEMETRY STREAM LOG",
color = TextMuted,
fontSize = 11.sp,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.height(100.dp)
.background(Color.Black, shape = RoundedCornerShape(6.dp))
.padding(8.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
if (rawLog.isEmpty()) {
Text(
text = "Console Idle. Connect to view hex dump...",
color = TextMuted,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp
)
} else {
rawLog.forEach { logLine ->
Text(
text = logLine,
color = NeonGreen,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp
)
}
}
}
}
}
}
}
@@ -0,0 +1,42 @@
package com.example.esp32aldldashboard.ui.main
import android.content.Context
import androidx.lifecycle.ViewModel
import com.example.esp32aldldashboard.bluetooth.BluetoothService
import com.example.esp32aldldashboard.bluetooth.ConnectionState
import com.example.esp32aldldashboard.parser.ALDLFrame
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class MainScreenViewModel(context: Context) : ViewModel() {
private val bluetoothService = BluetoothService(context.applicationContext)
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
private val _isCelsius = MutableStateFlow(false) // Default to Fahrenheit for standard 80s GM telemetry
val isCelsius: StateFlow<Boolean> = _isCelsius
fun toggleTemperatureUnit() {
_isCelsius.value = !_isCelsius.value
}
fun connect() {
bluetoothService.connect()
}
fun disconnect() {
bluetoothService.disconnect()
}
fun startSimulation() {
bluetoothService.startSimulation()
}
override fun onCleared() {
super.onCleared()
bluetoothService.disconnect()
}
}