feat: implement telemetry persistence, data visualization, and improved bluetooth stream parsing with frame statistics

This commit is contained in:
2026-06-11 23:59:42 +01:00
parent c95dbfe58e
commit 29423f6aff
26 changed files with 1794 additions and 935 deletions
+29 -109
View File
@@ -1,119 +1,39 @@
# ESP32 ALDL Dashboard
# ESP32 ALDL Dashboard Android Application
An Android application built using Jetpack Compose to display real-time engine telemetry from a **1986 Pontiac Fiero 2.8L V6 (1227170 ECM)**. The app connects to a Bluetooth serial device named **ESP32-ALDL** and decodes a raw binary stream of 27-byte frames containing ECM data packets under the `$24` mask specifications.
This Android app connects via Bluetooth SPP to an ESP32 microcontroller that interfaces with a 1986 Pontiac Fiero 1227170 ECM using the 160-baud ALDL (Assembly Line Diagnostic Link) datastream. It decodes the telemetry stream in real-time and displays it on a modern Jetpack Compose dashboard.
---
## Features
## Technical Specifications
* **Real-time Dashboard:** View essential engine metrics including Engine Speed (RPM), Vehicle Speed (MPH), Coolant Temperature, Manifold Air Temperature (MAT), Manifold Absolute Pressure (MAP), Throttle Position (TPS), O2 Sensor Voltage, and Battery Voltage.
* **Custom UI Components:** Beautiful animated Canvas-based radial RPM gauge and TPS bar graph.
* **Trouble Code Alerts:** Automatically decodes ECM active fault codes into human-readable alerts (e.g., Code 14 - Coolant Temperature Sensor High).
* **Status Flags:** Real-time visibility into Closed Loop, Rich Mixture, TCC Lockup, A/C Clutch requests, and more.
* **Derived Metrics:** Calculates estimated Engine Load and Fuel Flow Hint based on core telemetry data.
* **Real-Time Line Charts:** Built-in rolling graphs of RPM vs. Time, perfect for diagnostic troubleshooting.
* **Background Telemetry Logging:** Uses an Android Foreground Service to maintain the Bluetooth connection and record data seamlessly even when the app is minimized.
* **Local Persistence:** Uses Jetpack Room database to save full sessions with timestamped raw payloads.
* **CSV Data Export:** Automatically generates TunerPro RT compatible `.csv` log files in the `Downloads/ALDLLogs` folder using Android MediaStore APIs.
* **Settings & Configuration:** Persistent DataStore preferences for Unit Toggle (°C/°F), Auto-Logging toggles, and Coolant Alert Thresholds.
### Data Stream Frame Structure
The ESP32 reads the raw 160-baud unidirectional ALDL stream from the ECM and encapsulates it in a 27-byte packet transmitted over Bluetooth SPP.
## Architecture Highlights
| Byte Index | Length | Value / Field | Description |
| :--- | :--- | :--- | :--- |
| **0 - 1** | 2 bytes | `AA 55` | Frame Sync Header (added by ESP32) |
| **2 - 26** | 25 bytes | Raw Data Payload | 1227170 ECM data stream (0-indexed indices 0 to 24) |
* **MVVM & StateFlow:** Strict MVVM separation of concerns. The UI reactivity is entirely driven by `StateFlow` streams.
* **Circular Ring Buffer Packet Parsing:** The Bluetooth ingest layer uses `ArrayDeque<Byte>` circular buffering. It constantly seeks the `AA 55` header, preventing misalignment desyncs over noisy lines.
* **Robust Parsers:** The `ALDLParser` encapsulates all scaling constants and interpolation arrays (for MAT) required by the TunerPro `24-INT10.ads` definition.
---
## Requirements
## Parameter Offsets & Decoding Formulas
* Android Device running Android 8.0+ (Tested extensively against Android 13+)
* Bluetooth Permissions (Nearby Devices on Android 12+)
* ESP32 hardware module programmed with the accompanying ALDL datastream code.
The telemetry parameters are parsed from the `$24` / `$24A` ECM mask definitions (`24-INT10.ads`). In TunerPro's `.ads` file, the byte numbers are 1-indexed (e.g. `btByteNumber = 4` maps to raw payload byte index `3`).
## Setup Instructions
### 1. Primary Sensors & Measurements
1. Pair the ESP32 (named `ESP32-ALDL`) in your Android Bluetooth settings.
2. Grant the requested Bluetooth permissions inside the app.
3. Tap "Connect BT" to begin the telemetry stream.
4. Navigate to the **Settings** tab to toggle Auto-Logging or customize unit formats.
5. View historical `.csv` logs in your device's `Downloads/ALDLLogs` folder.
| Parameter | Payload Index | Raw Size | Formula / Conversion | Units |
| :--- | :--- | :--- | :--- | :--- |
| **IAC Position** | Index 3 (Byte 4) | 8-bit | $Value = Raw$ | Steps |
| **Coolant Temp** | Index 4 (Byte 5) | 8-bit | $C = (Raw \times 0.75) - 40$<br>$F = (Raw \times 1.35) - 40$ | °C / °F |
| **Vehicle Speed** | Index 5 (Byte 6) | 8-bit | $Value = Raw$ (Operation 3) | MPH |
| **MAP** | Index 6 (Byte 7) | 8-bit | $Volts = Raw \times 0.019608$<br>$kPa = (Raw \times 0.369) + 10.354$ | Volts / kPa |
| **Engine Speed** | Index 7 (Byte 8) | 8-bit | $RPM = Raw \times 25$ | RPM |
| **TPS** | Index 8 (Byte 9) | 8-bit | $Volts = Raw \times 0.019608$ | Volts |
| **Integrator (INT)** | Index 9 (Byte 10) | 8-bit | $Value = Raw$ | — |
| **O2 Sensor** | Index 10 (Byte 11) | 8-bit | $mV = Raw \times 4.44$ | mV |
| **Battery Voltage** | Index 17 (Byte 18) | 8-bit | $Volts = Raw \times 0.1$ | Volts |
| **BLM** | Index 18 (Byte 19) | 8-bit | $Value = Raw$ | — |
| **Rich/Lean Crosses** | Index 19 (Byte 20) | 8-bit | $Value = Raw$ | Crosses |
| **Spark Advance** | Index 20 (Byte 21) | 8-bit | $Degrees = Raw \times 0.351563$ | Degrees |
| **EGR Duty Cycle** | Index 21 (Byte 22) | 8-bit | $Percent = Raw \times 0.392157$ | % |
| **Manifold Air Temp (MAT)** | Index 22 (Byte 23) | 8-bit | **Linear Table Interpolation** (see below) | °C / °F |
| **Base Pulse Width (BPW)** | Index 23-24 (Byte 24-25) | 16-bit | $Raw = (HighByte \ll 8) \vert LowByte$<br>$ms = Raw \times 0.015259$ | Milliseconds (ms) |
---
### 2. MAT Linear Interpolation Tables
The Manifold Air Temperature (MAT) is read from Index 22. It is mapped to degrees C or F using interpolation curves specified in tables 52 and 53:
* **Celsius (`MAT C` - Table 52):**
* `0` -> `200.0`, `12` -> `150.0`, `13` -> `145.0`, `14` -> `140.0`, `16` -> `135.0`, `18` -> `130.0`, `21` -> `125.0`, `23` -> `120.0`, `26` -> `115.0`, `30` -> `110.0`, `34` -> `105.0`, `39` -> `100.0`, `44` -> `95.0`, `50` -> `90.0`, `56` -> `85.0`, `64` -> `80.0`, `72` -> `75.0`, `81` -> `70.0`, `92` -> `65.0`, `102` -> `60.0`, `114` -> `55.0`, `126` -> `50.0`, `139` -> `45.0`, `152` -> `40.0`, `165` -> `35.0`, `177` -> `30.0`, `189` -> `25.0`, `199` -> `20.0`, `209` -> `15.0`, `218` -> `10.0`, `225` -> `5.0`, `231` -> `0.0`, `237` -> `-5.0`, `241` -> `-10.0`, `245` -> `-15.0`, `247` -> `-20.0`, `250` -> `-25.0`, `251` -> `-30.0`, `255` -> `-40.0`
* **Fahrenheit (`MAT F` - Table 53):**
* `0` -> `392.0`, `12` -> `302.0`, `13` -> `293.0`, `14` -> `284.0`, `16` -> `275.0`, `18` -> `266.0`, `21` -> `257.0`, `23` -> `248.0`, `26` -> `239.0`, `30` -> `230.0`, `34` -> `221.0`, `39` -> `212.0`, `44` -> `203.0`, `50` -> `194.0`, `56` -> `185.0`, `64` -> `176.0`, `72` -> `167.0`, `81` -> `158.0`, `92` -> `149.0`, `102` -> `140.0`, `114` -> `131.0`, `126` -> `122.0`, `139` -> `113.0`, `152` -> `104.0`, `165` -> `95.0`, `177` -> `86.0`, `189` -> `77.0`, `199` -> `68.0`, `209` -> `59.0`, `218` -> `50.0`, `225` -> `41.0`, `231` -> `32.0`, `237` -> `23.0`, `241` -> `14.0`, `245` -> `5.0`, `247` -> `-4.0`, `250` -> `-13.0`, `251` -> `-22.0`, `255` -> `-40.0`
---
### 3. Stored Fault Trouble Codes
Malfunction Indicator Codes are mapped as bit flags across three specific status bytes:
#### Codes Byte 1 (Payload Index 11 / Byte 12)
* **Bit 7:** Code 12 - Crank Sensor / System Check
* **Bit 6:** Code 13 - O2 Sensor
* **Bit 5:** Code 14 - Coolant High Temp
* **Bit 4:** Code 15 - Coolant Low Temp
* **Bit 3:** Code 21 - TPS Voltage High
* **Bit 2:** Code 22 - TPS Voltage Low
* **Bit 1:** Code 23 - MAT Voltage Low
* **Bit 0:** Code 24 - Vehicle Speed Sensor (VSS)
#### Codes Byte 2 (Payload Index 12 / Byte 13)
* **Bit 7:** Code 25 - MAT Voltage High
* **Bit 5:** Code 32 - EGR System
* **Bit 4:** Code 33 - MAP Sensor High
* **Bit 3:** Code 34 - MAP Sensor Low
* **Bit 2:** Code 35 - IAC Position
* **Bit 0:** Code 42 - Electronic Spark Timing (EST)
#### Codes Byte 3 (Payload Index 13 / Byte 14)
* **Bit 7:** Code 43 - Electronic Spark Control (Knock Sensor)
* **Bit 6:** Code 44 - O2 Sensor Lean Exhaust
* **Bit 5:** Code 45 - O2 Sensor Rich Exhaust
* **Bit 4:** Code 51 - PROM Error
* **Bit 3:** Code 52 - Cal-Pack Error
* **Bit 2:** Code 53 - System Battery Over-Voltage
* **Bit 0:** Code 55 - ADU Error
---
### 4. Engine Status & Bit Flags
#### Misc Byte 1 (Payload Index 14 / Byte 15)
* **Bit 1:** BLM Enabled (1 = Yes)
* **Bit 3:** Quasi Pulse Mode (1 = Yes)
* **Bit 4:** Async Pulse Mode (1 = Yes)
* **Bit 6:** Rich/Lean Status (1 = Rich, 0 = Lean)
* **Bit 7:** Loop Status (1 = Closed, 0 = Open)
#### Misc Byte 2 (Payload Index 15 / Byte 16)
* **Bit 5:** A/C Status (0 = Enabled, 1 = Disabled/Idle)
* **Bit 7:** Park/Neutral Switch (1 = Park/Neutral, 0 = In Gear)
#### Misc Byte 3 (Payload Index 16 / Byte 17)
* **Bit 0:** A/C Clutch Command (1 = Enabled)
* **Bit 2:** Torque Converter Clutch (TCC) (1 = Locked)
* **Bit 5:** Power Steering Cramp Switch (1 = Active)
---
## App Features & Architecture
1. **Bluetooth Thread Management:**
* Queries for paired devices and connects directly to `"ESP32-ALDL"` over standard SPP RFCOMM sockets.
* Robust frame synchronization: buffers incoming streams and aligns on the double-byte header `0xAA 0x55`.
2. **State Management:**
* State flows from the Bluetooth thread through Kotlin `StateFlow` to Compose UI.
3. **Modern Dash Dashboard UI:**
* Dynamic animations for RPM and TPS sweeps.
* Metrics displayed in responsive tiles with custom indicator colors (e.g. engine loop states, fuel trims, battery health).
* Live trouble code alert panel.
* Diagnostic pane displaying real-time raw hex buffers for physical signal verification.
## Build Information
Developed using Android Studio and Gradle. Built with Jetpack Compose, Room (with KSP Annotation Processor), and DataStore.
+9
View File
@@ -61,6 +61,7 @@ dependencies {
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons.core)
// Tooling
debugImplementation(libs.androidx.compose.ui.tooling)
// Instrumented tests
@@ -81,4 +82,12 @@ dependencies {
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
// Room
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
annotationProcessor(libs.androidx.room.compiler)
// DataStore
implementation(libs.androidx.datastore.preferences)
}
+10
View File
@@ -13,8 +13,14 @@
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" android:maxSdkVersion="30" />
<!-- Foreground Service & Notifications -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".AldlApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
@@ -22,6 +28,10 @@
android:supportsRtl="true"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.ESP32ALDLDashboard">
<service
android:name=".bluetooth.BluetoothForegroundService"
android:foregroundServiceType="connectedDevice"
android:exported="false" />
<activity
android:name=".MainActivity"
android:exported="true">
@@ -0,0 +1,28 @@
package com.example.esp32aldldashboard
import android.app.Application
import com.example.esp32aldldashboard.bluetooth.BluetoothService
import com.example.esp32aldldashboard.repository.SettingsRepository
import com.example.esp32aldldashboard.repository.TelemetryRepository
class AldlApplication : Application() {
lateinit var bluetoothService: BluetoothService
lateinit var telemetryRepository: TelemetryRepository
lateinit var settingsRepository: SettingsRepository
lateinit var csvLogger: com.example.esp32aldldashboard.logging.CsvLogger
override fun onCreate() {
super.onCreate()
val database = com.example.esp32aldldashboard.data.database.TelemetryDatabase.getDatabase(this)
settingsRepository = SettingsRepository(this)
csvLogger = com.example.esp32aldldashboard.logging.CsvLogger(this)
bluetoothService = BluetoothService(this)
telemetryRepository = TelemetryRepository(
bluetoothService,
database.telemetryDao(),
csvLogger,
settingsRepository
)
}
}
@@ -20,7 +20,7 @@ fun MainNavigation() {
entryProvider =
entryProvider {
entry<Main> {
MainScreen(onItemClick = { navKey -> backStack.add(navKey) }, modifier = Modifier.safeDrawingPadding().padding(16.dp))
MainScreen(modifier = Modifier.safeDrawingPadding().padding(horizontal = 0.dp))
}
},
)
@@ -0,0 +1,136 @@
package com.example.esp32aldldashboard.bluetooth
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import com.example.esp32aldldashboard.AldlApplication
import com.example.esp32aldldashboard.MainActivity
import com.example.esp32aldldashboard.R
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
class BluetoothForegroundService : Service() {
private val CHANNEL_ID = "ALDL_BT_CHANNEL"
private val NOTIFICATION_ID = 101
private val serviceScope = CoroutineScope(Dispatchers.Main + Job())
private lateinit var bluetoothService: BluetoothService
companion object {
const val ACTION_START = "ACTION_START"
const val ACTION_STOP = "ACTION_STOP"
const val ACTION_DISCONNECT = "ACTION_DISCONNECT"
}
override fun onCreate() {
super.onCreate()
val app = application as AldlApplication
bluetoothService = app.bluetoothService
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START -> {
startForeground(NOTIFICATION_ID, createNotification("Connected to ESP32-ALDL"))
observeTelemetry()
}
ACTION_DISCONNECT -> {
bluetoothService.disconnect()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
ACTION_STOP -> {
bluetoothService.disconnect()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
}
return START_NOT_STICKY
}
private fun observeTelemetry() {
serviceScope.launch {
bluetoothService.latestFrame.collectLatest { frame ->
if (frame != null) {
updateNotification("RPM: ${frame.engineSpeedRpm} | Coolant: ${String.format("%.1f", frame.coolantTempC)}°C")
}
}
}
serviceScope.launch {
bluetoothService.connectionState.collectLatest { state ->
when (state) {
ConnectionState.DISCONNECTED, ConnectionState.ERROR -> {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
else -> {}
}
}
}
}
private fun createNotification(contentText: String): Notification {
val intent = Intent(this, MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity(
this, 0, intent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val disconnectIntent = Intent(this, BluetoothForegroundService::class.java).apply {
action = ACTION_DISCONNECT
}
val disconnectPendingIntent = PendingIntent.getService(
this, 1, disconnectIntent, PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("ALDL Telemetry Active")
.setContentText(contentText)
.setSmallIcon(R.mipmap.ic_launcher) // Use app icon for now
.setContentIntent(pendingIntent)
.addAction(0, "Disconnect", disconnectPendingIntent)
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOnlyAlertOnce(true)
.build()
}
private fun updateNotification(contentText: String) {
val manager = getSystemService(NotificationManager::class.java)
manager.notify(NOTIFICATION_ID, createNotification(contentText))
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val serviceChannel = NotificationChannel(
CHANNEL_ID,
"Bluetooth Connection Service",
NotificationManager.IMPORTANCE_LOW
)
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(serviceChannel)
}
}
override fun onBind(intent: Intent?): IBinder? {
return null
}
override fun onDestroy() {
super.onDestroy()
serviceScope.launch {
// cancel jobs if needed
}
}
}
@@ -37,6 +37,15 @@ class BluetoothService(private val context: Context) {
private val _rawHexLog = MutableStateFlow<List<String>>(emptyList())
val rawHexLog: StateFlow<List<String>> = _rawHexLog
private val _framesReceived = MutableStateFlow(0)
val framesReceived: StateFlow<Int> = _framesReceived
private val _parseErrors = MutableStateFlow(0)
val parseErrors: StateFlow<Int> = _parseErrors
private val _currentFrameRate = MutableStateFlow(0)
val currentFrameRate: StateFlow<Int> = _currentFrameRate
private val _errorMessage = MutableStateFlow("")
val errorMessage: StateFlow<String> = _errorMessage
@@ -157,8 +166,8 @@ class BluetoothService(private val context: Context) {
payload[24] = bpwLowRaw.toByte()
val parsed = ALDLParser.parseFrame(payload)
if (parsed != null) {
_latestFrame.value = parsed
if (parsed is com.example.esp32aldldashboard.parser.ALDLParseResult.Success) {
_latestFrame.value = parsed.frame
val hexString = payload.joinToString(" ") { String.format("%02X", it) }
addRawHexLog("AA 55 $hexString (SIMULATED)")
}
@@ -233,7 +242,9 @@ class BluetoothService(private val context: Context) {
private suspend fun readDataStream(inputStream: InputStream) {
val readBuffer = ByteArray(128)
val syncBuffer = ArrayList<Byte>()
val syncBuffer = ArrayDeque<Byte>(128)
var lastFrameTime = System.currentTimeMillis()
var framesInCurrentSecond = 0
while (currentCoroutineContext().isActive && isConnected) {
try {
@@ -245,43 +256,72 @@ class BluetoothService(private val context: Context) {
}
for (j in 0 until bytesRead) {
syncBuffer.add(readBuffer[j])
syncBuffer.addLast(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)
}
}
val iterator = syncBuffer.iterator()
var idx = 0
var headerIdx = -1
// 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) {
// Discard garbage preceding header
if (headerIdx > 0) {
_parseErrors.value += 1
for (i in 0 until headerIdx) {
syncBuffer.removeFirst()
}
}
if (syncBuffer.size >= 27) {
syncBuffer.removeFirst() // AA
syncBuffer.removeFirst() // 55
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)
payload[p] = syncBuffer.removeFirst()
}
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")
when (parsed) {
is com.example.esp32aldldashboard.parser.ALDLParseResult.Success -> {
_framesReceived.value += 1
framesInCurrentSecond++
val now = System.currentTimeMillis()
if (now - lastFrameTime >= 1000) {
_currentFrameRate.value = framesInCurrentSecond
framesInCurrentSecond = 0
lastFrameTime = now
}
withContext(Dispatchers.Main) {
_latestFrame.value = parsed.frame
val hexString = payload.joinToString(" ") { String.format("%02X", it) }
addRawHexLog("AA 55 $hexString")
}
}
is com.example.esp32aldldashboard.parser.ALDLParseResult.InvalidData -> {
_parseErrors.value += 1
Log.w(TAG, "Invalid frame: ${parsed.reason}")
}
com.example.esp32aldldashboard.parser.ALDLParseResult.Incomplete -> {
// Handled by size check
}
}
} else {
@@ -293,7 +333,9 @@ class BluetoothService(private val context: Context) {
val lastByte = syncBuffer.last()
syncBuffer.clear()
if ((lastByte.toInt() and 0xFF) == 0xAA) {
syncBuffer.add(lastByte)
syncBuffer.addLast(lastByte)
} else {
_parseErrors.value += 1
}
break
}
@@ -0,0 +1,13 @@
package com.example.esp32aldldashboard.data.database
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "sessions")
data class SessionEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val startTime: Long,
val endTime: Long? = null,
val name: String = "",
val isSimulation: Boolean = false
)
@@ -0,0 +1,28 @@
package com.example.esp32aldldashboard.data.database
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface TelemetryDao {
@Insert
suspend fun insertSession(session: SessionEntity): Long
@Query("UPDATE sessions SET endTime = :endTime WHERE id = :sessionId")
suspend fun endSession(sessionId: Long, endTime: Long)
@Insert
suspend fun insertDataPoints(dataPoints: List<TelemetryDataPointEntity>)
@Query("SELECT * FROM sessions ORDER BY startTime DESC")
fun getAllSessions(): Flow<List<SessionEntity>>
@Query("SELECT * FROM telemetry_data_points WHERE sessionId = :sessionId ORDER BY timestamp ASC")
fun getSessionData(sessionId: Long): Flow<List<TelemetryDataPointEntity>>
@Query("DELETE FROM sessions WHERE id = :sessionId")
suspend fun deleteSession(sessionId: Long)
}
@@ -0,0 +1,47 @@
package com.example.esp32aldldashboard.data.database
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "telemetry_data_points",
foreignKeys = [
ForeignKey(
entity = SessionEntity::class,
parentColumns = ["id"],
childColumns = ["sessionId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [
Index(value = ["sessionId", "timestamp"])
]
)
data class TelemetryDataPointEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val sessionId: Long,
val timestamp: Long,
val rawBytes: ByteArray
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as TelemetryDataPointEntity
if (id != other.id) return false
if (sessionId != other.sessionId) return false
if (timestamp != other.timestamp) return false
return rawBytes.contentEquals(other.rawBytes)
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + sessionId.hashCode()
result = 31 * result + timestamp.hashCode()
result = 31 * result + rawBytes.contentHashCode()
return result
}
}
@@ -0,0 +1,29 @@
package com.example.esp32aldldashboard.data.database
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@Database(entities = [SessionEntity::class, TelemetryDataPointEntity::class], version = 1, exportSchema = false)
abstract class TelemetryDatabase : RoomDatabase() {
abstract fun telemetryDao(): TelemetryDao
companion object {
@Volatile
private var INSTANCE: TelemetryDatabase? = null
fun getDatabase(context: Context): TelemetryDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
TelemetryDatabase::class.java,
"telemetry_database"
).build()
INSTANCE = instance
instance
}
}
}
}
@@ -0,0 +1,100 @@
package com.example.esp32aldldashboard.logging
import android.content.ContentValues
import android.content.Context
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import com.example.esp32aldldashboard.parser.ALDLFrame
import java.io.OutputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class CsvLogger(private val context: Context) {
private var currentOutputStream: OutputStream? = null
fun startNewSession(isSimulation: Boolean): Boolean {
val timeStamp: String = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val fileName = "ALDL_Log_${if(isSimulation) "SIM_" else ""}$timeStamp.csv"
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, "text/csv")
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS + "/ALDLLogs")
}
val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
if (uri != null) {
currentOutputStream = resolver.openOutputStream(uri)
}
} else {
// For older Android versions, we'd need WRITE_EXTERNAL_STORAGE and write to Environment.getExternalStoragePublicDirectory.
// We'll skip legacy support for this specific snippet to keep it concise, assuming target is modern Android.
return false
}
// Write CSV Header
val header = "Timestamp,Raw_Hex,RPM,Coolant_C,Coolant_F,MAP_kPa,MAP_Volts,TPS_Volts,O2_mV,Battery_V,Spark_Adv,IAC,BPW_ms,Speed_MPH,MAT_C,MAT_F,BLM,INT,EGR_Duty,Rich_Crosses,Closed_Loop,Rich\n"
currentOutputStream?.write(header.toByteArray())
return true
} catch (e: Exception) {
e.printStackTrace()
return false
}
}
fun logFrame(frame: ALDLFrame) {
val out = currentOutputStream ?: return
val hexString = frame.rawBytes.joinToString(" ") { String.format("%02X", it) }
val dateString = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US).format(Date(frame.timestamp))
val row = String.format(
Locale.US,
"%s,%s,%d,%.1f,%.1f,%.1f,%.3f,%.3f,%.1f,%.1f,%.1f,%d,%.2f,%d,%.1f,%.1f,%d,%d,%.1f,%d,%b,%b\n",
dateString,
hexString,
frame.engineSpeedRpm,
frame.coolantTempC,
frame.coolantTempF,
frame.mapKpa,
frame.mapVolts,
frame.tpsVolts,
frame.o2SensorMv,
frame.batteryVolts,
frame.sparkAdvance,
frame.iacPosition,
frame.bpwMs,
frame.vehicleSpeedMPH,
frame.matC,
frame.matF,
frame.blm,
frame.integrator,
frame.egrDutyCycle,
frame.richLeanCrosses,
frame.isClosedLoop,
frame.isRich
)
try {
out.write(row.toByteArray())
} catch (e: Exception) {
e.printStackTrace()
}
}
fun endSession() {
try {
currentOutputStream?.close()
} catch (e: Exception) {
e.printStackTrace()
} finally {
currentOutputStream = null
}
}
}
@@ -45,93 +45,78 @@ data class ALDLFrame(
}
}
sealed class ALDLParseResult {
data class Success(val frame: ALDLFrame) : ALDLParseResult()
data class InvalidData(val reason: String) : ALDLParseResult()
object Incomplete : ALDLParseResult()
}
object ALDLConstants {
const val PAYLOAD_SIZE = 25
// Scale Factors
const val COOLANT_SCALE_C = 0.75f
const val COOLANT_OFFSET_C = -40.0f
const val COOLANT_SCALE_F = 1.35f
const val COOLANT_OFFSET_F = -40.0f
const val MAP_VOLTS_SCALE = 0.019608f
const val MAP_KPA_SCALE = 0.369f
const val MAP_KPA_OFFSET = 10.354f
const val RPM_SCALE = 25
const val TPS_VOLTS_SCALE = 0.019608f
const val O2_MV_SCALE = 4.44f
const val BATTERY_VOLTS_SCALE = 0.1f
const val SPARK_ADVANCE_SCALE = 0.351563f
const val EGR_DUTY_CYCLE_SCALE = 0.392157f
const val BPW_MS_SCALE = 0.015259f
// Plausibility Limits
const val MAX_RPM = 8000
const val MIN_TEMP_C = -45.0f
const val MAX_TEMP_C = 220.0f
const val MIN_BATTERY_V = 5.0f
const val MAX_BATTERY_V = 20.0f
const val MAX_TPS_V = 5.1f
// Bitmasks
const val BIT_0 = 0x01
const val BIT_1 = 0x02
const val BIT_2 = 0x04
const val BIT_3 = 0x08
const val BIT_4 = 0x10
const val BIT_5 = 0x20
const val BIT_6 = 0x40
const val BIT_7 = 0x80
// MAT C interpolation table
val MAT_TABLE_C = 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
val MAT_TABLE_F = 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
)
}
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
@@ -149,91 +134,100 @@ object ALDLParser {
}
/**
* Parses a 25-byte raw data payload.
* Parses a 25-byte raw data payload, validating bounds and checking for errors.
*/
fun parseFrame(data: ByteArray): ALDLFrame? {
if (data.size != 25) return null
fun parseFrame(data: ByteArray): ALDLParseResult {
if (data.size != ALDLConstants.PAYLOAD_SIZE) {
return ALDLParseResult.Incomplete
}
val u = IntArray(25) { data[it].toInt() and 0xFF }
val u = IntArray(ALDLConstants.PAYLOAD_SIZE) { 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 engineSpeedRpm = u[7] * ALDLConstants.RPM_SCALE
if (engineSpeedRpm > ALDLConstants.MAX_RPM) {
return ALDLParseResult.InvalidData("RPM ($engineSpeedRpm) exceeds plausibility limit (${ALDLConstants.MAX_RPM})")
}
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 coolantTempC = u[4] * ALDLConstants.COOLANT_SCALE_C + ALDLConstants.COOLANT_OFFSET_C
val coolantTempF = u[4] * ALDLConstants.COOLANT_SCALE_F + ALDLConstants.COOLANT_OFFSET_F
if (coolantTempC < ALDLConstants.MIN_TEMP_C || coolantTempC > ALDLConstants.MAX_TEMP_C) {
return ALDLParseResult.InvalidData("Coolant Temp ($coolantTempC C) outside bounds")
}
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
val batteryVolts = u[17] * ALDLConstants.BATTERY_VOLTS_SCALE
if (batteryVolts < ALDLConstants.MIN_BATTERY_V || batteryVolts > ALDLConstants.MAX_BATTERY_V) {
return ALDLParseResult.InvalidData("Battery Voltage ($batteryVolts V) outside bounds")
}
val tpsVolts = u[8] * ALDLConstants.TPS_VOLTS_SCALE
if (tpsVolts > ALDLConstants.MAX_TPS_V) {
return ALDLParseResult.InvalidData("TPS Voltage ($tpsVolts V) outside bounds")
}
val iacPosition = u[3]
val vehicleSpeedMPH = u[5]
val mapVolts = u[6] * ALDLConstants.MAP_VOLTS_SCALE
val mapKpa = u[6] * ALDLConstants.MAP_KPA_SCALE + ALDLConstants.MAP_KPA_OFFSET
val integrator = u[9]
val o2SensorMv = u[10] * ALDLConstants.O2_MV_SCALE
val codesByte1 = u[11]
val codesByte2 = u[12]
val codesByte3 = u[13]
val miscByte1 = u[14]
val miscByte2 = u[15]
val miscByte3 = u[16]
val blm = u[18]
val richLeanCrosses = u[19]
val sparkAdvance = u[20] * ALDLConstants.SPARK_ADVANCE_SCALE
val egrDutyCycle = u[21] * ALDLConstants.EGR_DUTY_CYCLE_SCALE
// MAT (Air Temp) Interpolation
val matC = interpolate(u[22], matTableC) // Byte 23
val matF = interpolate(u[22], matTableF) // Byte 23
val matC = interpolate(u[22], ALDLConstants.MAT_TABLE_C)
val matF = interpolate(u[22], ALDLConstants.MAT_TABLE_F)
// 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
val rawBpw = (u[23] shl 8) or u[24]
val bpwMs = rawBpw * ALDLConstants.BPW_MS_SCALE
// 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)
val blmEnable = (miscByte1 and ALDLConstants.BIT_1) != 0
val quasiPulse = (miscByte1 and ALDLConstants.BIT_3) != 0
val asyncPulse = (miscByte1 and ALDLConstants.BIT_4) != 0
val isRich = (miscByte1 and ALDLConstants.BIT_6) != 0
val isClosedLoop = (miscByte1 and ALDLConstants.BIT_7) != 0
// 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)
val isAcEnabled = (miscByte2 and ALDLConstants.BIT_5) == 0
val isParkNeutral = (miscByte2 and ALDLConstants.BIT_7) != 0
// 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)
val isAcClutchEnabled = (miscByte3 and ALDLConstants.BIT_0) != 0
val isTccLocked = (miscByte3 and ALDLConstants.BIT_2) != 0
val isPowerSteeringCrampActive = (miscByte3 and ALDLConstants.BIT_5) != 0
// 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
if ((codesByte1 and ALDLConstants.BIT_7) != 0) activeCodes.add(12)
if ((codesByte1 and ALDLConstants.BIT_6) != 0) activeCodes.add(13)
if ((codesByte1 and ALDLConstants.BIT_5) != 0) activeCodes.add(14)
if ((codesByte1 and ALDLConstants.BIT_4) != 0) activeCodes.add(15)
if ((codesByte1 and ALDLConstants.BIT_3) != 0) activeCodes.add(21)
if ((codesByte1 and ALDLConstants.BIT_2) != 0) activeCodes.add(22)
if ((codesByte1 and ALDLConstants.BIT_1) != 0) activeCodes.add(23)
if ((codesByte1 and ALDLConstants.BIT_0) != 0) activeCodes.add(24)
return ALDLFrame(
if ((codesByte2 and ALDLConstants.BIT_7) != 0) activeCodes.add(25)
if ((codesByte2 and ALDLConstants.BIT_5) != 0) activeCodes.add(32)
if ((codesByte2 and ALDLConstants.BIT_4) != 0) activeCodes.add(33)
if ((codesByte2 and ALDLConstants.BIT_3) != 0) activeCodes.add(34)
if ((codesByte2 and ALDLConstants.BIT_2) != 0) activeCodes.add(35)
if ((codesByte2 and ALDLConstants.BIT_0) != 0) activeCodes.add(42)
if ((codesByte3 and ALDLConstants.BIT_7) != 0) activeCodes.add(43)
if ((codesByte3 and ALDLConstants.BIT_6) != 0) activeCodes.add(44)
if ((codesByte3 and ALDLConstants.BIT_5) != 0) activeCodes.add(45)
if ((codesByte3 and ALDLConstants.BIT_4) != 0) activeCodes.add(51)
if ((codesByte3 and ALDLConstants.BIT_3) != 0) activeCodes.add(52)
if ((codesByte3 and ALDLConstants.BIT_2) != 0) activeCodes.add(53)
if ((codesByte3 and ALDLConstants.BIT_0) != 0) activeCodes.add(55)
val frame = ALDLFrame(
rawBytes = data,
iacPosition = iacPosition,
coolantTempC = coolantTempC,
@@ -265,5 +259,6 @@ object ALDLParser {
isPowerSteeringCrampActive = isPowerSteeringCrampActive,
activeFaultCodes = activeCodes
)
return ALDLParseResult.Success(frame)
}
}
@@ -0,0 +1,22 @@
package com.example.esp32aldldashboard.parser
// Approximate Engine Load (0.0 to 1.0)
// Very rough approximation: MAP (kPa) / ~100 kPa (atmospheric at sea level)
// At WOT, MAP is near atmospheric (100 kPa), load is near 100%. At idle, MAP is around 30-40 kPa, load is lower.
val ALDLFrame.estimatedEngineLoad: Float
get() {
val load = mapKpa / 100.0f
return load.coerceIn(0.0f, 1.0f)
}
// Approximate Fuel Flow Rate (lbs/hr)
// Rough formula: RPM * BPW (ms) * Injector Flow Rate Constant
// 2.8L Fiero V6 typically has ~15 lb/hr injectors. There are 6 injectors, firing sequentially or batch?
// The 1227170 ECM fires batch (3 injectors per driver, twice per cycle).
// Rough estimation: flow = (RPM / 2) * BPW_seconds * 6 * Injector_rate / 60
// We'll provide a simplified unitless flow hint for the UI visualization.
val ALDLFrame.fuelFlowHint: Float
get() {
val bpwSeconds = bpwMs / 1000.0f
return (engineSpeedRpm * bpwSeconds) / 2.0f
}
@@ -0,0 +1,36 @@
package com.example.esp32aldldashboard.parser
object TroubleCodeDictionary {
val DTC_DESCRIPTIONS = mapOf(
12 to "Crank Sensor / System Check - Normal if engine not running.",
13 to "O2 Sensor Circuit - Open or No Activity.",
14 to "Coolant Temperature Sensor - High Temperature Indicated.",
15 to "Coolant Temperature Sensor - Low Temperature Indicated.",
21 to "Throttle Position Sensor (TPS) - High Voltage.",
22 to "Throttle Position Sensor (TPS) - Low Voltage.",
23 to "Manifold Air Temperature (MAT) - Low Temperature Indicated.",
24 to "Vehicle Speed Sensor (VSS) - Circuit Fault.",
25 to "Manifold Air Temperature (MAT) - High Temperature Indicated.",
32 to "Exhaust Gas Recirculation (EGR) - System Fault.",
33 to "Manifold Absolute Pressure (MAP) - High Pressure Indicated.",
34 to "Manifold Absolute Pressure (MAP) - Low Pressure Indicated.",
35 to "Idle Air Control (IAC) - Position Error.",
42 to "Electronic Spark Timing (EST) - Circuit Fault.",
43 to "Electronic Spark Control (ESC) - Knock Sensor Fault.",
44 to "Oxygen Sensor - Lean Exhaust Indicated.",
45 to "Oxygen Sensor - Rich Exhaust Indicated.",
51 to "PROM Error - Faulty or Incorrect Memcal.",
52 to "Cal-Pack Error - Missing or Faulty Cal-Pack.",
53 to "System Voltage - Battery Over-Voltage.",
55 to "ADU Error - Internal ECM Fault."
)
fun getDescription(code: Int): String {
return DTC_DESCRIPTIONS[code] ?: "Unknown Trouble Code $code"
}
fun isCritical(code: Int): Boolean {
// Severe codes that might require immediate pull-over
return code in listOf(14, 43, 51, 52, 53, 55)
}
}
@@ -0,0 +1,64 @@
package com.example.esp32aldldashboard.repository
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.*
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
class SettingsRepository(private val context: Context) {
companion object {
val IS_CELSIUS = booleanPreferencesKey("is_celsius")
val COOLANT_ALERT_THRESHOLD = floatPreferencesKey("coolant_alert_threshold")
val BATTERY_LOW_THRESHOLD = floatPreferencesKey("battery_low_threshold")
val AUTO_LOGGING = booleanPreferencesKey("auto_logging")
}
val isCelsiusFlow: Flow<Boolean> = context.dataStore.data
.map { preferences ->
preferences[IS_CELSIUS] ?: false // Default to Fahrenheit
}
val coolantAlertThresholdFlow: Flow<Float> = context.dataStore.data
.map { preferences ->
preferences[COOLANT_ALERT_THRESHOLD] ?: 100f // Default 100C / 212F
}
val batteryLowThresholdFlow: Flow<Float> = context.dataStore.data
.map { preferences ->
preferences[BATTERY_LOW_THRESHOLD] ?: 11.5f // Default 11.5V
}
val autoLoggingFlow: Flow<Boolean> = context.dataStore.data
.map { preferences ->
preferences[AUTO_LOGGING] ?: false
}
suspend fun setIsCelsius(isCelsius: Boolean) {
context.dataStore.edit { preferences ->
preferences[IS_CELSIUS] = isCelsius
}
}
suspend fun setCoolantAlertThreshold(threshold: Float) {
context.dataStore.edit { preferences ->
preferences[COOLANT_ALERT_THRESHOLD] = threshold
}
}
suspend fun setBatteryLowThreshold(threshold: Float) {
context.dataStore.edit { preferences ->
preferences[BATTERY_LOW_THRESHOLD] = threshold
}
}
suspend fun setAutoLogging(autoLog: Boolean) {
context.dataStore.edit { preferences ->
preferences[AUTO_LOGGING] = autoLog
}
}
}
@@ -0,0 +1,112 @@
package com.example.esp32aldldashboard.repository
import com.example.esp32aldldashboard.bluetooth.BluetoothService
import com.example.esp32aldldashboard.bluetooth.ConnectionState
import com.example.esp32aldldashboard.parser.ALDLFrame
import com.example.esp32aldldashboard.data.database.SessionEntity
import com.example.esp32aldldashboard.data.database.TelemetryDao
import com.example.esp32aldldashboard.data.database.TelemetryDataPointEntity
import com.example.esp32aldldashboard.logging.CsvLogger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.StateFlow
class TelemetryRepository(
private val bluetoothService: BluetoothService,
private val telemetryDao: TelemetryDao,
private val csvLogger: CsvLogger,
private val settingsRepository: SettingsRepository
) {
private val repoScope = CoroutineScope(Dispatchers.IO + Job())
private var currentSessionId: Long? = null
private var isRecording = false
init {
observeConnectionState()
observeTelemetry()
}
val connectionState: StateFlow<ConnectionState> = bluetoothService.connectionState
val latestFrame: StateFlow<ALDLFrame?> = bluetoothService.latestFrame
val rawHexLog: StateFlow<List<String>> = bluetoothService.rawHexLog
val framesReceived: StateFlow<Int> = bluetoothService.framesReceived
val parseErrors: StateFlow<Int> = bluetoothService.parseErrors
val currentFrameRate: StateFlow<Int> = bluetoothService.currentFrameRate
val errorMessage: StateFlow<String> = bluetoothService.errorMessage
private fun observeConnectionState() {
repoScope.launch {
bluetoothService.connectionState.collectLatest { state ->
when (state) {
ConnectionState.CONNECTED -> {
val autoLog = settingsRepository.autoLoggingFlow.first()
if (autoLog) {
startSession(isSimulation = false) // Or true if we knew
}
}
ConnectionState.DISCONNECTED, ConnectionState.ERROR -> {
endSession()
}
else -> {}
}
}
}
}
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))
}
}
}
}
}
private suspend fun startSession(isSimulation: Boolean) {
if (isRecording) return
val session = SessionEntity(
startTime = System.currentTimeMillis(),
name = "Session ${System.currentTimeMillis()}",
isSimulation = isSimulation
)
currentSessionId = telemetryDao.insertSession(session)
csvLogger.startNewSession(isSimulation)
isRecording = true
}
private suspend fun endSession() {
if (!isRecording) return
isRecording = false
csvLogger.endSession()
currentSessionId?.let { sid ->
telemetryDao.endSession(sid, System.currentTimeMillis())
}
currentSessionId = null
}
fun connect() {
bluetoothService.connect()
}
fun disconnect() {
bluetoothService.disconnect()
}
fun startSimulation() {
bluetoothService.startSimulation()
}
}
@@ -0,0 +1,100 @@
package com.example.esp32aldldashboard.ui.charts
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.example.esp32aldldashboard.parser.ALDLFrame
import kotlinx.coroutines.flow.StateFlow
@Composable
fun ChartsScreen(
latestFrameFlow: StateFlow<ALDLFrame?>,
modifier: Modifier = Modifier
) {
val latestFrame by latestFrameFlow.collectAsStateWithLifecycle()
// We maintain a limited rolling history of points (e.g. 100 points)
val rpmHistory = remember { mutableStateListOf<Float>() }
val maxHistorySize = 100
LaunchedEffect(latestFrame) {
latestFrame?.let {
rpmHistory.add(it.engineSpeedRpm.toFloat())
if (rpmHistory.size > maxHistorySize) {
rpmHistory.removeAt(0)
}
}
}
Column(modifier = modifier.fillMaxSize().padding(16.dp)) {
Text(
text = "Real-Time Telemetry",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(16.dp))
Card(
modifier = Modifier.fillMaxWidth().height(200.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E))
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(text = "RPM", color = Color(0xFF00FFCC))
Spacer(modifier = Modifier.height(8.dp))
LineChart(
data = rpmHistory,
maxValue = 6000f,
lineColor = Color(0xFF00FFCC),
modifier = Modifier.fillMaxSize()
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Additional charts can go here (e.g., O2, TPS)
}
}
@Composable
fun LineChart(
data: List<Float>,
maxValue: Float,
lineColor: Color,
modifier: Modifier = Modifier
) {
if (data.isEmpty()) return
Canvas(modifier = modifier) {
val width = size.width
val height = size.height
val pointSpacing = if (data.size > 1) width / (data.size - 1) else 0f
val path = Path()
data.forEachIndexed { index, value ->
val x = index * pointSpacing
// Invert y since Canvas y=0 is at the top
val y = height - ((value / maxValue) * height).coerceIn(0f, height)
if (index == 0) {
path.moveTo(x, y)
} else {
path.lineTo(x, y)
}
}
drawPath(
path = path,
color = lineColor,
style = Stroke(width = 4.dp.toPx())
)
}
}
@@ -0,0 +1,125 @@
package com.example.esp32aldldashboard.ui.components
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun RpmGauge(
rpm: Int,
maxRpm: Int = 6000,
modifier: Modifier = Modifier
) {
val animatedRpm by animateFloatAsState(
targetValue = rpm.toFloat(),
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow),
label = "rpmAnimation"
)
Box(modifier = modifier.aspectRatio(1f), contentAlignment = Alignment.Center) {
Canvas(modifier = Modifier.fillMaxSize().padding(16.dp)) {
val strokeWidth = 24.dp.toPx()
val startAngle = 135f
val sweepAngle = 270f
// Background arc
drawArc(
color = Color.DarkGray.copy(alpha = 0.5f),
startAngle = startAngle,
sweepAngle = sweepAngle,
useCenter = false,
style = Stroke(width = strokeWidth, cap = StrokeCap.Round),
size = Size(size.width, size.height)
)
// Foreground arc
val progress = (animatedRpm / maxRpm).coerceIn(0f, 1f)
val color = if (progress > 0.85f) Color.Red else Color(0xFF00FFCC) // Neon Cyan
drawArc(
color = color,
startAngle = startAngle,
sweepAngle = sweepAngle * progress,
useCenter = false,
style = Stroke(width = strokeWidth, cap = StrokeCap.Round),
size = Size(size.width, size.height)
)
}
Text(
text = rpm.toString(),
color = Color.White,
fontSize = 32.sp,
fontWeight = FontWeight.Bold
)
Text(
text = "RPM",
color = Color.LightGray,
fontSize = 14.sp,
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 24.dp)
)
}
}
@Composable
fun TpsBar(
tpsVolts: Float,
modifier: Modifier = Modifier
) {
val animatedTps by animateFloatAsState(
targetValue = tpsVolts,
animationSpec = spring(stiffness = Spring.StiffnessLow),
label = "tpsAnimation"
)
Box(modifier = modifier, contentAlignment = Alignment.Center) {
Canvas(modifier = Modifier.fillMaxSize()) {
val strokeWidth = size.height
val maxVolts = 5.0f
val progress = (animatedTps / maxVolts).coerceIn(0f, 1f)
// Background bar
drawLine(
color = Color.DarkGray.copy(alpha = 0.5f),
start = Offset(0f, size.height / 2),
end = Offset(size.width, size.height / 2),
strokeWidth = strokeWidth,
cap = StrokeCap.Round
)
// Foreground bar
drawLine(
color = Color(0xFFFF9900), // Neon Orange
start = Offset(0f, size.height / 2),
end = Offset(size.width * progress, size.height / 2),
strokeWidth = strokeWidth,
cap = StrokeCap.Round
)
}
Text(
text = String.format("%.2f V", tpsVolts),
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 14.sp
)
}
}
@@ -0,0 +1,511 @@
package com.example.esp32aldldashboard.ui.main
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
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 com.example.esp32aldldashboard.bluetooth.ConnectionState
import com.example.esp32aldldashboard.parser.ALDLFrame
import com.example.esp32aldldashboard.parser.TroubleCodeDictionary
import com.example.esp32aldldashboard.ui.components.RpmGauge
import com.example.esp32aldldashboard.ui.components.TpsBar
// Theme Colors
val DarkBg = Color(0xFF0F0F12)
val CardBg = Color(0xFF1B1B22)
val BorderColor = Color(0xFF2E2E38)
val NeonCyan = Color(0xFF00E5FF)
val NeonRed = Color(0xFFFF3D00)
val NeonGreen = Color(0xFF00E676)
val NeonOrange = Color(0xFFFF9100)
val TextWhite = Color(0xFFEEEEEE)
val TextMuted = Color(0xFF9E9EAF)
@Composable
fun DashboardScreen(
connState: ConnectionState,
frame: ALDLFrame?,
isCelsius: Boolean,
onConnect: () -> Unit,
onDisconnect: () -> Unit,
onSimulate: () -> 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 = "",
isCelsius = isCelsius,
onConnect = onConnect,
onDisconnect = onDisconnect,
onSimulate = onSimulate,
onToggleUnit = { /* Moved to settings */ }
)
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))
}
// Gauges row: RPM and TPS
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
RpmGauge(
rpm = frame.engineSpeedRpm,
modifier = Modifier.weight(1f).aspectRatio(1f)
)
Card(
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = CardBg),
modifier = Modifier.weight(1f).aspectRatio(1f)
.border(1.dp, BorderColor, shape = RoundedCornerShape(12.dp))
) {
Column(modifier = Modifier.padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = "THROTTLE", color = TextMuted, fontSize = 12.sp, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.weight(1f))
TpsBar(tpsVolts = frame.tpsVolts, modifier = Modifier.fillMaxWidth().height(40.dp))
Spacer(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 = "VEHICLE SPEED",
value = "${frame.vehicleSpeedMPH} MPH",
progress = frame.vehicleSpeedMPH / 120f,
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))
}
Spacer(modifier = Modifier.height(24.dp))
}
}
// ... Copying the UI components from MainScreen.kt to keep them in DashboardScreen.kt ...
@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
)
}
}
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 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))
Column(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
activeCodes.forEach { code ->
Row(verticalAlignment = Alignment.CenterVertically) {
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.width(8.dp))
Text(
text = TroubleCodeDictionary.getDescription(code),
color = TextWhite,
fontSize = 12.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
)
}
}
@@ -5,45 +5,24 @@ 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.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Settings
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)
import com.example.esp32aldldashboard.AldlApplication
import com.example.esp32aldldashboard.ui.charts.ChartsScreen
import com.example.esp32aldldashboard.ui.settings.SettingsScreen
@Composable
fun MainScreen(
onItemClick: (NavKey) -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
@@ -52,7 +31,6 @@ fun MainScreen(
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(
@@ -70,7 +48,8 @@ fun MainScreen(
val requiredPermissions = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
arrayOf(
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_CONNECT
Manifest.permission.BLUETOOTH_CONNECT,
Manifest.permission.POST_NOTIFICATIONS
)
} else {
arrayOf(
@@ -90,623 +69,54 @@ fun MainScreen(
}
}
MainScreenContent(
connState = connState,
frame = frame,
rawLog = rawLog,
errorMsg = errorMsg,
isCelsius = isCelsius,
onConnect = onConnectClick,
onDisconnect = { viewModel.disconnect() },
onSimulate = { viewModel.startSimulation() },
onToggleUnit = { viewModel.toggleTemperatureUnit() },
modifier = modifier
)
}
var selectedTab by remember { mutableStateOf(0) }
val app = context.applicationContext as AldlApplication
@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)
Scaffold(
bottomBar = {
NavigationBar(containerColor = DarkBg, contentColor = NeonOrange) {
NavigationBarItem(
icon = { Icon(Icons.Default.Info, contentDescription = "Dashboard") },
label = { Text("Dashboard") },
selected = selectedTab == 0,
onClick = { selectedTab = 0 },
colors = NavigationBarItemDefaults.colors(selectedIconColor = NeonCyan, unselectedIconColor = TextMuted)
)
MetricCard(
title = "VEHICLE SPEED",
value = "${frame.vehicleSpeedMPH}",
unit = "MPH",
progress = frame.vehicleSpeedMPH / 120f,
progressColor = NeonGreen,
modifier = Modifier.weight(1f)
NavigationBarItem(
icon = { Icon(Icons.Default.Build, contentDescription = "Charts") },
label = { Text("Charts") },
selected = selectedTab == 1,
onClick = { selectedTab = 1 },
colors = NavigationBarItemDefaults.colors(selectedIconColor = NeonCyan, unselectedIconColor = TextMuted)
)
}
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
NavigationBarItem(
icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") },
label = { Text("Settings") },
selected = selectedTab == 2,
onClick = { selectedTab = 2 },
colors = NavigationBarItemDefaults.colors(selectedIconColor = NeonCyan, unselectedIconColor = TextMuted)
)
}
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
) { paddingValues ->
when (selectedTab) {
0 -> DashboardScreen(
connState = connState,
frame = frame,
isCelsius = isCelsius,
onConnect = onConnectClick,
onDisconnect = { viewModel.disconnect() },
onSimulate = { viewModel.startSimulation() },
modifier = modifier.padding(paddingValues)
)
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)
1 -> ChartsScreen(
latestFrameFlow = viewModel.latestFrame,
modifier = modifier.padding(paddingValues)
)
2 -> SettingsScreen(
settingsRepository = app.settingsRepository,
modifier = modifier.padding(paddingValues)
)
}
}
}
@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
)
}
}
}
}
}
}
}
@@ -16,6 +16,10 @@ class MainScreenViewModel(context: Context) : ViewModel() {
val rawHexLog: StateFlow<List<String>> = bluetoothService.rawHexLog
val errorMessage: StateFlow<String> = bluetoothService.errorMessage
val framesReceived: StateFlow<Int> = bluetoothService.framesReceived
val parseErrors: StateFlow<Int> = bluetoothService.parseErrors
val currentFrameRate: StateFlow<Int> = bluetoothService.currentFrameRate
private val _isCelsius = MutableStateFlow(false) // Default to Fahrenheit for standard 80s GM telemetry
val isCelsius: StateFlow<Boolean> = _isCelsius
@@ -0,0 +1,84 @@
package com.example.esp32aldldashboard.ui.settings
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.example.esp32aldldashboard.repository.SettingsRepository
import kotlinx.coroutines.launch
@Composable
fun SettingsScreen(
settingsRepository: SettingsRepository,
modifier: Modifier = Modifier
) {
val isCelsius by settingsRepository.isCelsiusFlow.collectAsStateWithLifecycle(initialValue = false)
val autoLogging by settingsRepository.autoLoggingFlow.collectAsStateWithLifecycle(initialValue = false)
val coolantThreshold by settingsRepository.coolantAlertThresholdFlow.collectAsStateWithLifecycle(initialValue = 100f)
val coroutineScope = rememberCoroutineScope()
Column(modifier = modifier.fillMaxSize().padding(16.dp)) {
Text(
text = "Settings & Alerts",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(24.dp))
// Temperature Unit Toggle
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column {
Text(text = "Temperature Unit", style = MaterialTheme.typography.titleMedium)
Text(text = if (isCelsius) "Celsius (°C)" else "Fahrenheit (°F)", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Switch(
checked = isCelsius,
onCheckedChange = {
coroutineScope.launch { settingsRepository.setIsCelsius(it) }
}
)
}
Divider(modifier = Modifier.padding(vertical = 8.dp))
// Auto Logging Toggle
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column {
Text(text = "Auto-Log Sessions", style = MaterialTheme.typography.titleMedium)
Text(text = "Automatically save CSV and database records.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Switch(
checked = autoLogging,
onCheckedChange = {
coroutineScope.launch { settingsRepository.setAutoLogging(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)
Text(text = "Trigger notification when coolant exceeds ${coolantThreshold.toInt()}°C", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Slider(
value = coolantThreshold,
onValueChange = {
coroutineScope.launch { settingsRepository.setCoolantAlertThreshold(it) }
},
valueRange = 80f..150f,
steps = 70
)
}
}
}
@@ -19,9 +19,9 @@ class ALDLParserTest {
0x00.toByte(), 0x00.toByte(), 0xC9.toByte(), 0x02.toByte(), 0x62.toByte()
)
val frame = ALDLParser.parseFrame(rawPayload)
assertNotNull(frame)
frame!!
val result = ALDLParser.parseFrame(rawPayload)
assertTrue(result is com.example.esp32aldldashboard.parser.ALDLParseResult.Success)
val frame = (result as com.example.esp32aldldashboard.parser.ALDLParseResult.Success).frame
// Assert values based on 24-INT10.ads specifications:
assertEquals(95, frame.iacPosition) // u[3] (Byte 4)
@@ -0,0 +1,27 @@
package com.example.esp32aldldashboard.parser
import org.junit.Assert.*
import org.junit.Test
class RingBufferTest {
@Test
fun testALDLParseResultSafety() {
val validPayload = ByteArray(25) { 0 }
// Coolant = 0 (0 * 0.75 - 40 = -40 C) -> Valid
// Battery = 120 (120 * 0.1 = 12 V) -> Valid
validPayload[17] = 120.toByte()
val result = ALDLParser.parseFrame(validPayload)
assertTrue("Expected valid parsing", result is ALDLParseResult.Success)
val invalidBatteryPayload = validPayload.clone()
invalidBatteryPayload[17] = 250.toByte() // 250 * 0.1 = 25 V -> Invalid (> 20V)
val resultInvalid = ALDLParser.parseFrame(invalidBatteryPayload)
assertTrue("Expected invalid parsing", resultInvalid is ALDLParseResult.InvalidData)
val incompletePayload = ByteArray(10)
val resultIncomplete = ALDLParser.parseFrame(incompletePayload)
assertTrue("Expected incomplete parsing", resultIncomplete is ALDLParseResult.Incomplete)
}
}
+7
View File
@@ -13,12 +13,15 @@ junit = "4.13.2"
kotlin = "2.3.20"
nav3Core = "1.0.1"
lifecycleViewmodelNav3 = "2.10.0"
room = "2.6.1"
datastore = "1.1.1"
[libraries]
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCore" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidxActivity" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "androidxComposeBom" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3"}
androidx-compose-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui"}
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview"}
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4"}
@@ -36,6 +39,10 @@ kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-t
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" }
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" }
androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNav3" }
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
[plugins]
android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }