diff --git a/README.md b/README.md index 6e2034c..d8f99f5 100644 --- a/README.md +++ b/README.md @@ -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` 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$
$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$
$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$
$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. \ No newline at end of file +## Build Information +Developed using Android Studio and Gradle. Built with Jetpack Compose, Room (with KSP Annotation Processor), and DataStore. \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0cf9aef..f40828d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 86887e4..3efe2fe 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -13,8 +13,14 @@ + + + + + + diff --git a/app/src/main/java/com/example/esp32aldldashboard/AldlApplication.kt b/app/src/main/java/com/example/esp32aldldashboard/AldlApplication.kt new file mode 100644 index 0000000..09d881b --- /dev/null +++ b/app/src/main/java/com/example/esp32aldldashboard/AldlApplication.kt @@ -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 + ) + } +} diff --git a/app/src/main/java/com/example/esp32aldldashboard/Navigation.kt b/app/src/main/java/com/example/esp32aldldashboard/Navigation.kt index 757ef4b..61fce57 100644 --- a/app/src/main/java/com/example/esp32aldldashboard/Navigation.kt +++ b/app/src/main/java/com/example/esp32aldldashboard/Navigation.kt @@ -20,7 +20,7 @@ fun MainNavigation() { entryProvider = entryProvider { entry
{ - MainScreen(onItemClick = { navKey -> backStack.add(navKey) }, modifier = Modifier.safeDrawingPadding().padding(16.dp)) + MainScreen(modifier = Modifier.safeDrawingPadding().padding(horizontal = 0.dp)) } }, ) diff --git a/app/src/main/java/com/example/esp32aldldashboard/bluetooth/BluetoothForegroundService.kt b/app/src/main/java/com/example/esp32aldldashboard/bluetooth/BluetoothForegroundService.kt new file mode 100644 index 0000000..f1dabcd --- /dev/null +++ b/app/src/main/java/com/example/esp32aldldashboard/bluetooth/BluetoothForegroundService.kt @@ -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 + } + } +} diff --git a/app/src/main/java/com/example/esp32aldldashboard/bluetooth/BluetoothService.kt b/app/src/main/java/com/example/esp32aldldashboard/bluetooth/BluetoothService.kt index b47cd23..556b7c1 100644 --- a/app/src/main/java/com/example/esp32aldldashboard/bluetooth/BluetoothService.kt +++ b/app/src/main/java/com/example/esp32aldldashboard/bluetooth/BluetoothService.kt @@ -37,6 +37,15 @@ class BluetoothService(private val context: Context) { private val _rawHexLog = MutableStateFlow>(emptyList()) val rawHexLog: StateFlow> = _rawHexLog + private val _framesReceived = MutableStateFlow(0) + val framesReceived: StateFlow = _framesReceived + + private val _parseErrors = MutableStateFlow(0) + val parseErrors: StateFlow = _parseErrors + + private val _currentFrameRate = MutableStateFlow(0) + val currentFrameRate: StateFlow = _currentFrameRate + private val _errorMessage = MutableStateFlow("") val errorMessage: StateFlow = _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() + val syncBuffer = ArrayDeque(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 } diff --git a/app/src/main/java/com/example/esp32aldldashboard/data/database/SessionEntity.kt b/app/src/main/java/com/example/esp32aldldashboard/data/database/SessionEntity.kt new file mode 100644 index 0000000..fe49715 --- /dev/null +++ b/app/src/main/java/com/example/esp32aldldashboard/data/database/SessionEntity.kt @@ -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 +) diff --git a/app/src/main/java/com/example/esp32aldldashboard/data/database/TelemetryDao.kt b/app/src/main/java/com/example/esp32aldldashboard/data/database/TelemetryDao.kt new file mode 100644 index 0000000..102d191 --- /dev/null +++ b/app/src/main/java/com/example/esp32aldldashboard/data/database/TelemetryDao.kt @@ -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) + + @Query("SELECT * FROM sessions ORDER BY startTime DESC") + fun getAllSessions(): Flow> + + @Query("SELECT * FROM telemetry_data_points WHERE sessionId = :sessionId ORDER BY timestamp ASC") + fun getSessionData(sessionId: Long): Flow> + + @Query("DELETE FROM sessions WHERE id = :sessionId") + suspend fun deleteSession(sessionId: Long) +} diff --git a/app/src/main/java/com/example/esp32aldldashboard/data/database/TelemetryDataPointEntity.kt b/app/src/main/java/com/example/esp32aldldashboard/data/database/TelemetryDataPointEntity.kt new file mode 100644 index 0000000..1ecc084 --- /dev/null +++ b/app/src/main/java/com/example/esp32aldldashboard/data/database/TelemetryDataPointEntity.kt @@ -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 + } +} diff --git a/app/src/main/java/com/example/esp32aldldashboard/data/database/TelemetryDatabase.kt b/app/src/main/java/com/example/esp32aldldashboard/data/database/TelemetryDatabase.kt new file mode 100644 index 0000000..6555bc1 --- /dev/null +++ b/app/src/main/java/com/example/esp32aldldashboard/data/database/TelemetryDatabase.kt @@ -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 + } + } + } +} diff --git a/app/src/main/java/com/example/esp32aldldashboard/logging/CsvLogger.kt b/app/src/main/java/com/example/esp32aldldashboard/logging/CsvLogger.kt new file mode 100644 index 0000000..45f9023 --- /dev/null +++ b/app/src/main/java/com/example/esp32aldldashboard/logging/CsvLogger.kt @@ -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 + } + } +} diff --git a/app/src/main/java/com/example/esp32aldldashboard/parser/ALDLParser.kt b/app/src/main/java/com/example/esp32aldldashboard/parser/ALDLParser.kt index 5cbe560..b472906 100644 --- a/app/src/main/java/com/example/esp32aldldashboard/parser/ALDLParser.kt +++ b/app/src/main/java/com/example/esp32aldldashboard/parser/ALDLParser.kt @@ -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>): 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() - // 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) } } diff --git a/app/src/main/java/com/example/esp32aldldashboard/parser/DerivedTelemetry.kt b/app/src/main/java/com/example/esp32aldldashboard/parser/DerivedTelemetry.kt new file mode 100644 index 0000000..c4e0b95 --- /dev/null +++ b/app/src/main/java/com/example/esp32aldldashboard/parser/DerivedTelemetry.kt @@ -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 + } diff --git a/app/src/main/java/com/example/esp32aldldashboard/parser/TroubleCodeDictionary.kt b/app/src/main/java/com/example/esp32aldldashboard/parser/TroubleCodeDictionary.kt new file mode 100644 index 0000000..38f2156 --- /dev/null +++ b/app/src/main/java/com/example/esp32aldldashboard/parser/TroubleCodeDictionary.kt @@ -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) + } +} diff --git a/app/src/main/java/com/example/esp32aldldashboard/repository/SettingsRepository.kt b/app/src/main/java/com/example/esp32aldldashboard/repository/SettingsRepository.kt new file mode 100644 index 0000000..11c036d --- /dev/null +++ b/app/src/main/java/com/example/esp32aldldashboard/repository/SettingsRepository.kt @@ -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 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 = context.dataStore.data + .map { preferences -> + preferences[IS_CELSIUS] ?: false // Default to Fahrenheit + } + + val coolantAlertThresholdFlow: Flow = context.dataStore.data + .map { preferences -> + preferences[COOLANT_ALERT_THRESHOLD] ?: 100f // Default 100C / 212F + } + + val batteryLowThresholdFlow: Flow = context.dataStore.data + .map { preferences -> + preferences[BATTERY_LOW_THRESHOLD] ?: 11.5f // Default 11.5V + } + + val autoLoggingFlow: Flow = 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 + } + } +} diff --git a/app/src/main/java/com/example/esp32aldldashboard/repository/TelemetryRepository.kt b/app/src/main/java/com/example/esp32aldldashboard/repository/TelemetryRepository.kt new file mode 100644 index 0000000..6641713 --- /dev/null +++ b/app/src/main/java/com/example/esp32aldldashboard/repository/TelemetryRepository.kt @@ -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 = bluetoothService.connectionState + val latestFrame: StateFlow = bluetoothService.latestFrame + val rawHexLog: StateFlow> = bluetoothService.rawHexLog + + val framesReceived: StateFlow = bluetoothService.framesReceived + val parseErrors: StateFlow = bluetoothService.parseErrors + val currentFrameRate: StateFlow = bluetoothService.currentFrameRate + val errorMessage: StateFlow = 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() + } +} diff --git a/app/src/main/java/com/example/esp32aldldashboard/ui/charts/ChartsScreen.kt b/app/src/main/java/com/example/esp32aldldashboard/ui/charts/ChartsScreen.kt new file mode 100644 index 0000000..48b2920 --- /dev/null +++ b/app/src/main/java/com/example/esp32aldldashboard/ui/charts/ChartsScreen.kt @@ -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, + modifier: Modifier = Modifier +) { + val latestFrame by latestFrameFlow.collectAsStateWithLifecycle() + + // We maintain a limited rolling history of points (e.g. 100 points) + val rpmHistory = remember { mutableStateListOf() } + 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, + 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()) + ) + } +} diff --git a/app/src/main/java/com/example/esp32aldldashboard/ui/components/Gauges.kt b/app/src/main/java/com/example/esp32aldldashboard/ui/components/Gauges.kt new file mode 100644 index 0000000..2a4f37e --- /dev/null +++ b/app/src/main/java/com/example/esp32aldldashboard/ui/components/Gauges.kt @@ -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 + ) + } +} diff --git a/app/src/main/java/com/example/esp32aldldashboard/ui/main/DashboardScreen.kt b/app/src/main/java/com/example/esp32aldldashboard/ui/main/DashboardScreen.kt new file mode 100644 index 0000000..af8e26d --- /dev/null +++ b/app/src/main/java/com/example/esp32aldldashboard/ui/main/DashboardScreen.kt @@ -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) { + 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 + ) + } +} diff --git a/app/src/main/java/com/example/esp32aldldashboard/ui/main/MainScreen.kt b/app/src/main/java/com/example/esp32aldldashboard/ui/main/MainScreen.kt index 7e68f17..26d603b 100644 --- a/app/src/main/java/com/example/esp32aldldashboard/ui/main/MainScreen.kt +++ b/app/src/main/java/com/example/esp32aldldashboard/ui/main/MainScreen.kt @@ -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, - 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) { - 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) { - 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 - ) - } - } - } - } - } - } -} diff --git a/app/src/main/java/com/example/esp32aldldashboard/ui/main/MainScreenViewModel.kt b/app/src/main/java/com/example/esp32aldldashboard/ui/main/MainScreenViewModel.kt index 6ac861c..f00106e 100644 --- a/app/src/main/java/com/example/esp32aldldashboard/ui/main/MainScreenViewModel.kt +++ b/app/src/main/java/com/example/esp32aldldashboard/ui/main/MainScreenViewModel.kt @@ -16,6 +16,10 @@ class MainScreenViewModel(context: Context) : ViewModel() { val rawHexLog: StateFlow> = bluetoothService.rawHexLog val errorMessage: StateFlow = bluetoothService.errorMessage + val framesReceived: StateFlow = bluetoothService.framesReceived + val parseErrors: StateFlow = bluetoothService.parseErrors + val currentFrameRate: StateFlow = bluetoothService.currentFrameRate + private val _isCelsius = MutableStateFlow(false) // Default to Fahrenheit for standard 80s GM telemetry val isCelsius: StateFlow = _isCelsius diff --git a/app/src/main/java/com/example/esp32aldldashboard/ui/settings/SettingsScreen.kt b/app/src/main/java/com/example/esp32aldldashboard/ui/settings/SettingsScreen.kt new file mode 100644 index 0000000..c319470 --- /dev/null +++ b/app/src/main/java/com/example/esp32aldldashboard/ui/settings/SettingsScreen.kt @@ -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 + ) + } + } +} diff --git a/app/src/test/java/com/example/esp32aldldashboard/ALDLParserTest.kt b/app/src/test/java/com/example/esp32aldldashboard/ALDLParserTest.kt index 97fd9c9..7649e3d 100644 --- a/app/src/test/java/com/example/esp32aldldashboard/ALDLParserTest.kt +++ b/app/src/test/java/com/example/esp32aldldashboard/ALDLParserTest.kt @@ -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) diff --git a/app/src/test/java/com/example/esp32aldldashboard/RingBufferTest.kt b/app/src/test/java/com/example/esp32aldldashboard/RingBufferTest.kt new file mode 100644 index 0000000..4ed3eb5 --- /dev/null +++ b/app/src/test/java/com/example/esp32aldldashboard/RingBufferTest.kt @@ -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) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 660fd3a..8616f5b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }