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