From 29423f6afffe0d6e659f7f7aefea217edd0f6b9f Mon Sep 17 00:00:00 2001 From: Gandalf Date: Thu, 11 Jun 2026 23:59:42 +0100 Subject: [PATCH 01/18] feat: implement telemetry persistence, data visualization, and improved bluetooth stream parsing with frame statistics --- README.md | 138 +--- app/build.gradle.kts | 9 + app/src/main/AndroidManifest.xml | 10 + .../esp32aldldashboard/AldlApplication.kt | 28 + .../example/esp32aldldashboard/Navigation.kt | 2 +- .../bluetooth/BluetoothForegroundService.kt | 136 ++++ .../bluetooth/BluetoothService.kt | 90 ++- .../data/database/SessionEntity.kt | 13 + .../data/database/TelemetryDao.kt | 28 + .../data/database/TelemetryDataPointEntity.kt | 47 ++ .../data/database/TelemetryDatabase.kt | 29 + .../esp32aldldashboard/logging/CsvLogger.kt | 100 +++ .../esp32aldldashboard/parser/ALDLParser.kt | 311 ++++---- .../parser/DerivedTelemetry.kt | 22 + .../parser/TroubleCodeDictionary.kt | 36 + .../repository/SettingsRepository.kt | 64 ++ .../repository/TelemetryRepository.kt | 112 +++ .../ui/charts/ChartsScreen.kt | 100 +++ .../ui/components/Gauges.kt | 125 ++++ .../ui/main/DashboardScreen.kt | 511 +++++++++++++ .../esp32aldldashboard/ui/main/MainScreen.kt | 690 ++---------------- .../ui/main/MainScreenViewModel.kt | 4 + .../ui/settings/SettingsScreen.kt | 84 +++ .../esp32aldldashboard/ALDLParserTest.kt | 6 +- .../esp32aldldashboard/RingBufferTest.kt | 27 + gradle/libs.versions.toml | 7 + 26 files changed, 1794 insertions(+), 935 deletions(-) create mode 100644 app/src/main/java/com/example/esp32aldldashboard/AldlApplication.kt create mode 100644 app/src/main/java/com/example/esp32aldldashboard/bluetooth/BluetoothForegroundService.kt create mode 100644 app/src/main/java/com/example/esp32aldldashboard/data/database/SessionEntity.kt create mode 100644 app/src/main/java/com/example/esp32aldldashboard/data/database/TelemetryDao.kt create mode 100644 app/src/main/java/com/example/esp32aldldashboard/data/database/TelemetryDataPointEntity.kt create mode 100644 app/src/main/java/com/example/esp32aldldashboard/data/database/TelemetryDatabase.kt create mode 100644 app/src/main/java/com/example/esp32aldldashboard/logging/CsvLogger.kt create mode 100644 app/src/main/java/com/example/esp32aldldashboard/parser/DerivedTelemetry.kt create mode 100644 app/src/main/java/com/example/esp32aldldashboard/parser/TroubleCodeDictionary.kt create mode 100644 app/src/main/java/com/example/esp32aldldashboard/repository/SettingsRepository.kt create mode 100644 app/src/main/java/com/example/esp32aldldashboard/repository/TelemetryRepository.kt create mode 100644 app/src/main/java/com/example/esp32aldldashboard/ui/charts/ChartsScreen.kt create mode 100644 app/src/main/java/com/example/esp32aldldashboard/ui/components/Gauges.kt create mode 100644 app/src/main/java/com/example/esp32aldldashboard/ui/main/DashboardScreen.kt create mode 100644 app/src/main/java/com/example/esp32aldldashboard/ui/settings/SettingsScreen.kt create mode 100644 app/src/test/java/com/example/esp32aldldashboard/RingBufferTest.kt diff --git a/README.md b/README.md index 6e2034c..d8f99f5 100644 --- a/README.md +++ b/README.md @@ -1,119 +1,39 @@ -# ESP32 ALDL Dashboard +# ESP32 ALDL Dashboard Android Application -An Android application built using Jetpack Compose to display real-time engine telemetry from a **1986 Pontiac Fiero 2.8L V6 (1227170 ECM)**. The app connects to a Bluetooth serial device named **ESP32-ALDL** and decodes a raw binary stream of 27-byte frames containing ECM data packets under the `$24` mask specifications. +This Android app connects via Bluetooth SPP to an ESP32 microcontroller that interfaces with a 1986 Pontiac Fiero 1227170 ECM using the 160-baud ALDL (Assembly Line Diagnostic Link) datastream. It decodes the telemetry stream in real-time and displays it on a modern Jetpack Compose dashboard. ---- +## Features -## Technical Specifications +* **Real-time Dashboard:** View essential engine metrics including Engine Speed (RPM), Vehicle Speed (MPH), Coolant Temperature, Manifold Air Temperature (MAT), Manifold Absolute Pressure (MAP), Throttle Position (TPS), O2 Sensor Voltage, and Battery Voltage. +* **Custom UI Components:** Beautiful animated Canvas-based radial RPM gauge and TPS bar graph. +* **Trouble Code Alerts:** Automatically decodes ECM active fault codes into human-readable alerts (e.g., Code 14 - Coolant Temperature Sensor High). +* **Status Flags:** Real-time visibility into Closed Loop, Rich Mixture, TCC Lockup, A/C Clutch requests, and more. +* **Derived Metrics:** Calculates estimated Engine Load and Fuel Flow Hint based on core telemetry data. +* **Real-Time Line Charts:** Built-in rolling graphs of RPM vs. Time, perfect for diagnostic troubleshooting. +* **Background Telemetry Logging:** Uses an Android Foreground Service to maintain the Bluetooth connection and record data seamlessly even when the app is minimized. +* **Local Persistence:** Uses Jetpack Room database to save full sessions with timestamped raw payloads. +* **CSV Data Export:** Automatically generates TunerPro RT compatible `.csv` log files in the `Downloads/ALDLLogs` folder using Android MediaStore APIs. +* **Settings & Configuration:** Persistent DataStore preferences for Unit Toggle (°C/°F), Auto-Logging toggles, and Coolant Alert Thresholds. -### Data Stream Frame Structure -The ESP32 reads the raw 160-baud unidirectional ALDL stream from the ECM and encapsulates it in a 27-byte packet transmitted over Bluetooth SPP. +## Architecture Highlights -| Byte Index | Length | Value / Field | Description | -| :--- | :--- | :--- | :--- | -| **0 - 1** | 2 bytes | `AA 55` | Frame Sync Header (added by ESP32) | -| **2 - 26** | 25 bytes | Raw Data Payload | 1227170 ECM data stream (0-indexed indices 0 to 24) | +* **MVVM & StateFlow:** Strict MVVM separation of concerns. The UI reactivity is entirely driven by `StateFlow` streams. +* **Circular Ring Buffer Packet Parsing:** The Bluetooth ingest layer uses `ArrayDeque` circular buffering. It constantly seeks the `AA 55` header, preventing misalignment desyncs over noisy lines. +* **Robust Parsers:** The `ALDLParser` encapsulates all scaling constants and interpolation arrays (for MAT) required by the TunerPro `24-INT10.ads` definition. ---- +## Requirements -## Parameter Offsets & Decoding Formulas +* Android Device running Android 8.0+ (Tested extensively against Android 13+) +* Bluetooth Permissions (Nearby Devices on Android 12+) +* ESP32 hardware module programmed with the accompanying ALDL datastream code. -The telemetry parameters are parsed from the `$24` / `$24A` ECM mask definitions (`24-INT10.ads`). In TunerPro's `.ads` file, the byte numbers are 1-indexed (e.g. `btByteNumber = 4` maps to raw payload byte index `3`). +## Setup Instructions -### 1. Primary Sensors & Measurements +1. Pair the ESP32 (named `ESP32-ALDL`) in your Android Bluetooth settings. +2. Grant the requested Bluetooth permissions inside the app. +3. Tap "Connect BT" to begin the telemetry stream. +4. Navigate to the **Settings** tab to toggle Auto-Logging or customize unit formats. +5. View historical `.csv` logs in your device's `Downloads/ALDLLogs` folder. -| Parameter | Payload Index | Raw Size | Formula / Conversion | Units | -| :--- | :--- | :--- | :--- | :--- | -| **IAC Position** | Index 3 (Byte 4) | 8-bit | $Value = Raw$ | Steps | -| **Coolant Temp** | Index 4 (Byte 5) | 8-bit | $C = (Raw \times 0.75) - 40$
$F = (Raw \times 1.35) - 40$ | °C / °F | -| **Vehicle Speed** | Index 5 (Byte 6) | 8-bit | $Value = Raw$ (Operation 3) | MPH | -| **MAP** | Index 6 (Byte 7) | 8-bit | $Volts = Raw \times 0.019608$
$kPa = (Raw \times 0.369) + 10.354$ | Volts / kPa | -| **Engine Speed** | Index 7 (Byte 8) | 8-bit | $RPM = Raw \times 25$ | RPM | -| **TPS** | Index 8 (Byte 9) | 8-bit | $Volts = Raw \times 0.019608$ | Volts | -| **Integrator (INT)** | Index 9 (Byte 10) | 8-bit | $Value = Raw$ | — | -| **O2 Sensor** | Index 10 (Byte 11) | 8-bit | $mV = Raw \times 4.44$ | mV | -| **Battery Voltage** | Index 17 (Byte 18) | 8-bit | $Volts = Raw \times 0.1$ | Volts | -| **BLM** | Index 18 (Byte 19) | 8-bit | $Value = Raw$ | — | -| **Rich/Lean Crosses** | Index 19 (Byte 20) | 8-bit | $Value = Raw$ | Crosses | -| **Spark Advance** | Index 20 (Byte 21) | 8-bit | $Degrees = Raw \times 0.351563$ | Degrees | -| **EGR Duty Cycle** | Index 21 (Byte 22) | 8-bit | $Percent = Raw \times 0.392157$ | % | -| **Manifold Air Temp (MAT)** | Index 22 (Byte 23) | 8-bit | **Linear Table Interpolation** (see below) | °C / °F | -| **Base Pulse Width (BPW)** | Index 23-24 (Byte 24-25) | 16-bit | $Raw = (HighByte \ll 8) \vert LowByte$
$ms = Raw \times 0.015259$ | Milliseconds (ms) | - ---- - -### 2. MAT Linear Interpolation Tables -The Manifold Air Temperature (MAT) is read from Index 22. It is mapped to degrees C or F using interpolation curves specified in tables 52 and 53: - -* **Celsius (`MAT C` - Table 52):** - * `0` -> `200.0`, `12` -> `150.0`, `13` -> `145.0`, `14` -> `140.0`, `16` -> `135.0`, `18` -> `130.0`, `21` -> `125.0`, `23` -> `120.0`, `26` -> `115.0`, `30` -> `110.0`, `34` -> `105.0`, `39` -> `100.0`, `44` -> `95.0`, `50` -> `90.0`, `56` -> `85.0`, `64` -> `80.0`, `72` -> `75.0`, `81` -> `70.0`, `92` -> `65.0`, `102` -> `60.0`, `114` -> `55.0`, `126` -> `50.0`, `139` -> `45.0`, `152` -> `40.0`, `165` -> `35.0`, `177` -> `30.0`, `189` -> `25.0`, `199` -> `20.0`, `209` -> `15.0`, `218` -> `10.0`, `225` -> `5.0`, `231` -> `0.0`, `237` -> `-5.0`, `241` -> `-10.0`, `245` -> `-15.0`, `247` -> `-20.0`, `250` -> `-25.0`, `251` -> `-30.0`, `255` -> `-40.0` -* **Fahrenheit (`MAT F` - Table 53):** - * `0` -> `392.0`, `12` -> `302.0`, `13` -> `293.0`, `14` -> `284.0`, `16` -> `275.0`, `18` -> `266.0`, `21` -> `257.0`, `23` -> `248.0`, `26` -> `239.0`, `30` -> `230.0`, `34` -> `221.0`, `39` -> `212.0`, `44` -> `203.0`, `50` -> `194.0`, `56` -> `185.0`, `64` -> `176.0`, `72` -> `167.0`, `81` -> `158.0`, `92` -> `149.0`, `102` -> `140.0`, `114` -> `131.0`, `126` -> `122.0`, `139` -> `113.0`, `152` -> `104.0`, `165` -> `95.0`, `177` -> `86.0`, `189` -> `77.0`, `199` -> `68.0`, `209` -> `59.0`, `218` -> `50.0`, `225` -> `41.0`, `231` -> `32.0`, `237` -> `23.0`, `241` -> `14.0`, `245` -> `5.0`, `247` -> `-4.0`, `250` -> `-13.0`, `251` -> `-22.0`, `255` -> `-40.0` - ---- - -### 3. Stored Fault Trouble Codes - -Malfunction Indicator Codes are mapped as bit flags across three specific status bytes: - -#### Codes Byte 1 (Payload Index 11 / Byte 12) -* **Bit 7:** Code 12 - Crank Sensor / System Check -* **Bit 6:** Code 13 - O2 Sensor -* **Bit 5:** Code 14 - Coolant High Temp -* **Bit 4:** Code 15 - Coolant Low Temp -* **Bit 3:** Code 21 - TPS Voltage High -* **Bit 2:** Code 22 - TPS Voltage Low -* **Bit 1:** Code 23 - MAT Voltage Low -* **Bit 0:** Code 24 - Vehicle Speed Sensor (VSS) - -#### Codes Byte 2 (Payload Index 12 / Byte 13) -* **Bit 7:** Code 25 - MAT Voltage High -* **Bit 5:** Code 32 - EGR System -* **Bit 4:** Code 33 - MAP Sensor High -* **Bit 3:** Code 34 - MAP Sensor Low -* **Bit 2:** Code 35 - IAC Position -* **Bit 0:** Code 42 - Electronic Spark Timing (EST) - -#### Codes Byte 3 (Payload Index 13 / Byte 14) -* **Bit 7:** Code 43 - Electronic Spark Control (Knock Sensor) -* **Bit 6:** Code 44 - O2 Sensor Lean Exhaust -* **Bit 5:** Code 45 - O2 Sensor Rich Exhaust -* **Bit 4:** Code 51 - PROM Error -* **Bit 3:** Code 52 - Cal-Pack Error -* **Bit 2:** Code 53 - System Battery Over-Voltage -* **Bit 0:** Code 55 - ADU Error - ---- - -### 4. Engine Status & Bit Flags - -#### Misc Byte 1 (Payload Index 14 / Byte 15) -* **Bit 1:** BLM Enabled (1 = Yes) -* **Bit 3:** Quasi Pulse Mode (1 = Yes) -* **Bit 4:** Async Pulse Mode (1 = Yes) -* **Bit 6:** Rich/Lean Status (1 = Rich, 0 = Lean) -* **Bit 7:** Loop Status (1 = Closed, 0 = Open) - -#### Misc Byte 2 (Payload Index 15 / Byte 16) -* **Bit 5:** A/C Status (0 = Enabled, 1 = Disabled/Idle) -* **Bit 7:** Park/Neutral Switch (1 = Park/Neutral, 0 = In Gear) - -#### Misc Byte 3 (Payload Index 16 / Byte 17) -* **Bit 0:** A/C Clutch Command (1 = Enabled) -* **Bit 2:** Torque Converter Clutch (TCC) (1 = Locked) -* **Bit 5:** Power Steering Cramp Switch (1 = Active) - ---- - -## App Features & Architecture - -1. **Bluetooth Thread Management:** - * Queries for paired devices and connects directly to `"ESP32-ALDL"` over standard SPP RFCOMM sockets. - * Robust frame synchronization: buffers incoming streams and aligns on the double-byte header `0xAA 0x55`. -2. **State Management:** - * State flows from the Bluetooth thread through Kotlin `StateFlow` to Compose UI. -3. **Modern Dash Dashboard UI:** - * Dynamic animations for RPM and TPS sweeps. - * Metrics displayed in responsive tiles with custom indicator colors (e.g. engine loop states, fuel trims, battery health). - * Live trouble code alert panel. - * Diagnostic pane displaying real-time raw hex buffers for physical signal verification. \ No newline at end of file +## Build Information +Developed using Android Studio and Gradle. Built with Jetpack Compose, Room (with KSP Annotation Processor), and DataStore. \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0cf9aef..f40828d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -61,6 +61,7 @@ dependencies { implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons.core) // Tooling debugImplementation(libs.androidx.compose.ui.tooling) // Instrumented tests @@ -81,4 +82,12 @@ dependencies { implementation(libs.androidx.navigation3.ui) implementation(libs.androidx.navigation3.runtime) implementation(libs.androidx.lifecycle.viewmodel.navigation3) + + // Room + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + annotationProcessor(libs.androidx.room.compiler) + + // DataStore + implementation(libs.androidx.datastore.preferences) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 86887e4..3efe2fe 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -13,8 +13,14 @@ + + + + + + diff --git a/app/src/main/java/com/example/esp32aldldashboard/AldlApplication.kt b/app/src/main/java/com/example/esp32aldldashboard/AldlApplication.kt new file mode 100644 index 0000000..09d881b --- /dev/null +++ b/app/src/main/java/com/example/esp32aldldashboard/AldlApplication.kt @@ -0,0 +1,28 @@ +package com.example.esp32aldldashboard + +import android.app.Application +import com.example.esp32aldldashboard.bluetooth.BluetoothService +import com.example.esp32aldldashboard.repository.SettingsRepository +import com.example.esp32aldldashboard.repository.TelemetryRepository + +class AldlApplication : Application() { + lateinit var bluetoothService: BluetoothService + lateinit var telemetryRepository: TelemetryRepository + lateinit var settingsRepository: SettingsRepository + + lateinit var csvLogger: com.example.esp32aldldashboard.logging.CsvLogger + + override fun onCreate() { + super.onCreate() + val database = com.example.esp32aldldashboard.data.database.TelemetryDatabase.getDatabase(this) + settingsRepository = SettingsRepository(this) + csvLogger = com.example.esp32aldldashboard.logging.CsvLogger(this) + bluetoothService = BluetoothService(this) + telemetryRepository = TelemetryRepository( + bluetoothService, + database.telemetryDao(), + csvLogger, + settingsRepository + ) + } +} diff --git a/app/src/main/java/com/example/esp32aldldashboard/Navigation.kt b/app/src/main/java/com/example/esp32aldldashboard/Navigation.kt index 757ef4b..61fce57 100644 --- a/app/src/main/java/com/example/esp32aldldashboard/Navigation.kt +++ b/app/src/main/java/com/example/esp32aldldashboard/Navigation.kt @@ -20,7 +20,7 @@ fun MainNavigation() { entryProvider = entryProvider { entry
{ - MainScreen(onItemClick = { navKey -> backStack.add(navKey) }, modifier = Modifier.safeDrawingPadding().padding(16.dp)) + MainScreen(modifier = Modifier.safeDrawingPadding().padding(horizontal = 0.dp)) } }, ) diff --git a/app/src/main/java/com/example/esp32aldldashboard/bluetooth/BluetoothForegroundService.kt b/app/src/main/java/com/example/esp32aldldashboard/bluetooth/BluetoothForegroundService.kt new file mode 100644 index 0000000..f1dabcd --- /dev/null +++ b/app/src/main/java/com/example/esp32aldldashboard/bluetooth/BluetoothForegroundService.kt @@ -0,0 +1,136 @@ +package com.example.esp32aldldashboard.bluetooth + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Intent +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import com.example.esp32aldldashboard.AldlApplication +import com.example.esp32aldldashboard.MainActivity +import com.example.esp32aldldashboard.R +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +class BluetoothForegroundService : Service() { + + private val CHANNEL_ID = "ALDL_BT_CHANNEL" + private val NOTIFICATION_ID = 101 + + private val serviceScope = CoroutineScope(Dispatchers.Main + Job()) + private lateinit var bluetoothService: BluetoothService + + companion object { + const val ACTION_START = "ACTION_START" + const val ACTION_STOP = "ACTION_STOP" + const val ACTION_DISCONNECT = "ACTION_DISCONNECT" + } + + override fun onCreate() { + super.onCreate() + val app = application as AldlApplication + bluetoothService = app.bluetoothService + createNotificationChannel() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_START -> { + startForeground(NOTIFICATION_ID, createNotification("Connected to ESP32-ALDL")) + observeTelemetry() + } + ACTION_DISCONNECT -> { + bluetoothService.disconnect() + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + ACTION_STOP -> { + bluetoothService.disconnect() + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + } + return START_NOT_STICKY + } + + private fun observeTelemetry() { + serviceScope.launch { + bluetoothService.latestFrame.collectLatest { frame -> + if (frame != null) { + updateNotification("RPM: ${frame.engineSpeedRpm} | Coolant: ${String.format("%.1f", frame.coolantTempC)}°C") + } + } + } + + serviceScope.launch { + bluetoothService.connectionState.collectLatest { state -> + when (state) { + ConnectionState.DISCONNECTED, ConnectionState.ERROR -> { + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + else -> {} + } + } + } + } + + private fun createNotification(contentText: String): Notification { + val intent = Intent(this, MainActivity::class.java) + val pendingIntent = PendingIntent.getActivity( + this, 0, intent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + val disconnectIntent = Intent(this, BluetoothForegroundService::class.java).apply { + action = ACTION_DISCONNECT + } + val disconnectPendingIntent = PendingIntent.getService( + this, 1, disconnectIntent, PendingIntent.FLAG_IMMUTABLE + ) + + return NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("ALDL Telemetry Active") + .setContentText(contentText) + .setSmallIcon(R.mipmap.ic_launcher) // Use app icon for now + .setContentIntent(pendingIntent) + .addAction(0, "Disconnect", disconnectPendingIntent) + .setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setOnlyAlertOnce(true) + .build() + } + + private fun updateNotification(contentText: String) { + val manager = getSystemService(NotificationManager::class.java) + manager.notify(NOTIFICATION_ID, createNotification(contentText)) + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val serviceChannel = NotificationChannel( + CHANNEL_ID, + "Bluetooth Connection Service", + NotificationManager.IMPORTANCE_LOW + ) + val manager = getSystemService(NotificationManager::class.java) + manager.createNotificationChannel(serviceChannel) + } + } + + override fun onBind(intent: Intent?): IBinder? { + return null + } + + override fun onDestroy() { + super.onDestroy() + serviceScope.launch { + // cancel jobs if needed + } + } +} diff --git a/app/src/main/java/com/example/esp32aldldashboard/bluetooth/BluetoothService.kt b/app/src/main/java/com/example/esp32aldldashboard/bluetooth/BluetoothService.kt index b47cd23..556b7c1 100644 --- a/app/src/main/java/com/example/esp32aldldashboard/bluetooth/BluetoothService.kt +++ b/app/src/main/java/com/example/esp32aldldashboard/bluetooth/BluetoothService.kt @@ -37,6 +37,15 @@ class BluetoothService(private val context: Context) { private val _rawHexLog = MutableStateFlow>(emptyList()) val rawHexLog: StateFlow> = _rawHexLog + private val _framesReceived = MutableStateFlow(0) + val framesReceived: StateFlow = _framesReceived + + private val _parseErrors = MutableStateFlow(0) + val parseErrors: StateFlow = _parseErrors + + private val _currentFrameRate = MutableStateFlow(0) + val currentFrameRate: StateFlow = _currentFrameRate + private val _errorMessage = MutableStateFlow("") val errorMessage: StateFlow = _errorMessage @@ -157,8 +166,8 @@ class BluetoothService(private val context: Context) { payload[24] = bpwLowRaw.toByte() val parsed = ALDLParser.parseFrame(payload) - if (parsed != null) { - _latestFrame.value = parsed + if (parsed is com.example.esp32aldldashboard.parser.ALDLParseResult.Success) { + _latestFrame.value = parsed.frame val hexString = payload.joinToString(" ") { String.format("%02X", it) } addRawHexLog("AA 55 $hexString (SIMULATED)") } @@ -233,7 +242,9 @@ class BluetoothService(private val context: Context) { private suspend fun readDataStream(inputStream: InputStream) { val readBuffer = ByteArray(128) - val syncBuffer = ArrayList() + val syncBuffer = ArrayDeque(128) + var lastFrameTime = System.currentTimeMillis() + var framesInCurrentSecond = 0 while (currentCoroutineContext().isActive && isConnected) { try { @@ -245,43 +256,72 @@ class BluetoothService(private val context: Context) { } for (j in 0 until bytesRead) { - syncBuffer.add(readBuffer[j]) + syncBuffer.addLast(readBuffer[j]) } // Check for frame matches in buffer while (syncBuffer.size >= 27) { var foundHeader = false - for (idx in 0 until syncBuffer.size - 1) { - if ((syncBuffer[idx].toInt() and 0xFF) == 0xAA && (syncBuffer[idx + 1].toInt() and 0xFF) == 0x55) { - // Discard garbage preceding header - if (idx > 0) { - for (d in 0 until idx) { - syncBuffer.removeAt(0) - } - } + val iterator = syncBuffer.iterator() + var idx = 0 + var headerIdx = -1 + + // Find header AA 55 + var prev = iterator.next().toInt() and 0xFF + while (iterator.hasNext()) { + val curr = iterator.next().toInt() and 0xFF + if (prev == 0xAA && curr == 0x55) { + headerIdx = idx foundHeader = true break } + prev = curr + idx++ } if (foundHeader) { + // Discard garbage preceding header + if (headerIdx > 0) { + _parseErrors.value += 1 + for (i in 0 until headerIdx) { + syncBuffer.removeFirst() + } + } + if (syncBuffer.size >= 27) { + syncBuffer.removeFirst() // AA + syncBuffer.removeFirst() // 55 + val payload = ByteArray(25) for (p in 0 until 25) { - payload[p] = syncBuffer[p + 2] - } - - // Consume the 27 bytes from buffer - for (r in 0 until 27) { - syncBuffer.removeAt(0) + payload[p] = syncBuffer.removeFirst() } val parsed = ALDLParser.parseFrame(payload) - if (parsed != null) { - withContext(Dispatchers.Main) { - _latestFrame.value = parsed - val hexString = payload.joinToString(" ") { String.format("%02X", it) } - addRawHexLog("AA 55 $hexString") + when (parsed) { + is com.example.esp32aldldashboard.parser.ALDLParseResult.Success -> { + _framesReceived.value += 1 + framesInCurrentSecond++ + + val now = System.currentTimeMillis() + if (now - lastFrameTime >= 1000) { + _currentFrameRate.value = framesInCurrentSecond + framesInCurrentSecond = 0 + lastFrameTime = now + } + + withContext(Dispatchers.Main) { + _latestFrame.value = parsed.frame + val hexString = payload.joinToString(" ") { String.format("%02X", it) } + addRawHexLog("AA 55 $hexString") + } + } + is com.example.esp32aldldashboard.parser.ALDLParseResult.InvalidData -> { + _parseErrors.value += 1 + Log.w(TAG, "Invalid frame: ${parsed.reason}") + } + com.example.esp32aldldashboard.parser.ALDLParseResult.Incomplete -> { + // Handled by size check } } } else { @@ -293,7 +333,9 @@ class BluetoothService(private val context: Context) { val lastByte = syncBuffer.last() syncBuffer.clear() if ((lastByte.toInt() and 0xFF) == 0xAA) { - syncBuffer.add(lastByte) + syncBuffer.addLast(lastByte) + } else { + _parseErrors.value += 1 } break } diff --git a/app/src/main/java/com/example/esp32aldldashboard/data/database/SessionEntity.kt b/app/src/main/java/com/example/esp32aldldashboard/data/database/SessionEntity.kt new file mode 100644 index 0000000..fe49715 --- /dev/null +++ b/app/src/main/java/com/example/esp32aldldashboard/data/database/SessionEntity.kt @@ -0,0 +1,13 @@ +package com.example.esp32aldldashboard.data.database + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "sessions") +data class SessionEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val startTime: Long, + val endTime: Long? = null, + val name: String = "", + val isSimulation: Boolean = false +) diff --git a/app/src/main/java/com/example/esp32aldldashboard/data/database/TelemetryDao.kt b/app/src/main/java/com/example/esp32aldldashboard/data/database/TelemetryDao.kt new file mode 100644 index 0000000..102d191 --- /dev/null +++ b/app/src/main/java/com/example/esp32aldldashboard/data/database/TelemetryDao.kt @@ -0,0 +1,28 @@ +package com.example.esp32aldldashboard.data.database + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +@Dao +interface TelemetryDao { + + @Insert + suspend fun insertSession(session: SessionEntity): Long + + @Query("UPDATE sessions SET endTime = :endTime WHERE id = :sessionId") + suspend fun endSession(sessionId: Long, endTime: Long) + + @Insert + suspend fun insertDataPoints(dataPoints: List) + + @Query("SELECT * FROM sessions ORDER BY startTime DESC") + fun getAllSessions(): Flow> + + @Query("SELECT * FROM telemetry_data_points WHERE sessionId = :sessionId ORDER BY timestamp ASC") + fun getSessionData(sessionId: Long): Flow> + + @Query("DELETE FROM sessions WHERE id = :sessionId") + suspend fun deleteSession(sessionId: Long) +} diff --git a/app/src/main/java/com/example/esp32aldldashboard/data/database/TelemetryDataPointEntity.kt b/app/src/main/java/com/example/esp32aldldashboard/data/database/TelemetryDataPointEntity.kt new file mode 100644 index 0000000..1ecc084 --- /dev/null +++ b/app/src/main/java/com/example/esp32aldldashboard/data/database/TelemetryDataPointEntity.kt @@ -0,0 +1,47 @@ +package com.example.esp32aldldashboard.data.database + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + tableName = "telemetry_data_points", + foreignKeys = [ + ForeignKey( + entity = SessionEntity::class, + parentColumns = ["id"], + childColumns = ["sessionId"], + onDelete = ForeignKey.CASCADE + ) + ], + indices = [ + Index(value = ["sessionId", "timestamp"]) + ] +) +data class TelemetryDataPointEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val sessionId: Long, + val timestamp: Long, + val rawBytes: ByteArray +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as TelemetryDataPointEntity + + if (id != other.id) return false + if (sessionId != other.sessionId) return false + if (timestamp != other.timestamp) return false + return rawBytes.contentEquals(other.rawBytes) + } + + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + sessionId.hashCode() + result = 31 * result + timestamp.hashCode() + result = 31 * result + rawBytes.contentHashCode() + return result + } +} diff --git a/app/src/main/java/com/example/esp32aldldashboard/data/database/TelemetryDatabase.kt b/app/src/main/java/com/example/esp32aldldashboard/data/database/TelemetryDatabase.kt new file mode 100644 index 0000000..6555bc1 --- /dev/null +++ b/app/src/main/java/com/example/esp32aldldashboard/data/database/TelemetryDatabase.kt @@ -0,0 +1,29 @@ +package com.example.esp32aldldashboard.data.database + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase + +@Database(entities = [SessionEntity::class, TelemetryDataPointEntity::class], version = 1, exportSchema = false) +abstract class TelemetryDatabase : RoomDatabase() { + + abstract fun telemetryDao(): TelemetryDao + + companion object { + @Volatile + private var INSTANCE: TelemetryDatabase? = null + + fun getDatabase(context: Context): TelemetryDatabase { + return INSTANCE ?: synchronized(this) { + val instance = Room.databaseBuilder( + context.applicationContext, + TelemetryDatabase::class.java, + "telemetry_database" + ).build() + INSTANCE = instance + instance + } + } + } +} diff --git a/app/src/main/java/com/example/esp32aldldashboard/logging/CsvLogger.kt b/app/src/main/java/com/example/esp32aldldashboard/logging/CsvLogger.kt new file mode 100644 index 0000000..45f9023 --- /dev/null +++ b/app/src/main/java/com/example/esp32aldldashboard/logging/CsvLogger.kt @@ -0,0 +1,100 @@ +package com.example.esp32aldldashboard.logging + +import android.content.ContentValues +import android.content.Context +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import com.example.esp32aldldashboard.parser.ALDLFrame +import java.io.OutputStream +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +class CsvLogger(private val context: Context) { + + private var currentOutputStream: OutputStream? = null + + fun startNewSession(isSimulation: Boolean): Boolean { + val timeStamp: String = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + val fileName = "ALDL_Log_${if(isSimulation) "SIM_" else ""}$timeStamp.csv" + + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val resolver = context.contentResolver + val contentValues = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) + put(MediaStore.MediaColumns.MIME_TYPE, "text/csv") + put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS + "/ALDLLogs") + } + + val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues) + if (uri != null) { + currentOutputStream = resolver.openOutputStream(uri) + } + } else { + // For older Android versions, we'd need WRITE_EXTERNAL_STORAGE and write to Environment.getExternalStoragePublicDirectory. + // We'll skip legacy support for this specific snippet to keep it concise, assuming target is modern Android. + return false + } + + // Write CSV Header + val header = "Timestamp,Raw_Hex,RPM,Coolant_C,Coolant_F,MAP_kPa,MAP_Volts,TPS_Volts,O2_mV,Battery_V,Spark_Adv,IAC,BPW_ms,Speed_MPH,MAT_C,MAT_F,BLM,INT,EGR_Duty,Rich_Crosses,Closed_Loop,Rich\n" + currentOutputStream?.write(header.toByteArray()) + return true + } catch (e: Exception) { + e.printStackTrace() + return false + } + } + + fun logFrame(frame: ALDLFrame) { + val out = currentOutputStream ?: return + + val hexString = frame.rawBytes.joinToString(" ") { String.format("%02X", it) } + val dateString = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US).format(Date(frame.timestamp)) + + val row = String.format( + Locale.US, + "%s,%s,%d,%.1f,%.1f,%.1f,%.3f,%.3f,%.1f,%.1f,%.1f,%d,%.2f,%d,%.1f,%.1f,%d,%d,%.1f,%d,%b,%b\n", + dateString, + hexString, + frame.engineSpeedRpm, + frame.coolantTempC, + frame.coolantTempF, + frame.mapKpa, + frame.mapVolts, + frame.tpsVolts, + frame.o2SensorMv, + frame.batteryVolts, + frame.sparkAdvance, + frame.iacPosition, + frame.bpwMs, + frame.vehicleSpeedMPH, + frame.matC, + frame.matF, + frame.blm, + frame.integrator, + frame.egrDutyCycle, + frame.richLeanCrosses, + frame.isClosedLoop, + frame.isRich + ) + + try { + out.write(row.toByteArray()) + } catch (e: Exception) { + e.printStackTrace() + } + } + + fun endSession() { + try { + currentOutputStream?.close() + } catch (e: Exception) { + e.printStackTrace() + } finally { + currentOutputStream = null + } + } +} diff --git a/app/src/main/java/com/example/esp32aldldashboard/parser/ALDLParser.kt b/app/src/main/java/com/example/esp32aldldashboard/parser/ALDLParser.kt index 5cbe560..b472906 100644 --- a/app/src/main/java/com/example/esp32aldldashboard/parser/ALDLParser.kt +++ b/app/src/main/java/com/example/esp32aldldashboard/parser/ALDLParser.kt @@ -45,93 +45,78 @@ data class ALDLFrame( } } +sealed class ALDLParseResult { + data class Success(val frame: ALDLFrame) : ALDLParseResult() + data class InvalidData(val reason: String) : ALDLParseResult() + object Incomplete : ALDLParseResult() +} + +object ALDLConstants { + const val PAYLOAD_SIZE = 25 + + // Scale Factors + const val COOLANT_SCALE_C = 0.75f + const val COOLANT_OFFSET_C = -40.0f + const val COOLANT_SCALE_F = 1.35f + const val COOLANT_OFFSET_F = -40.0f + + const val MAP_VOLTS_SCALE = 0.019608f + const val MAP_KPA_SCALE = 0.369f + const val MAP_KPA_OFFSET = 10.354f + + const val RPM_SCALE = 25 + const val TPS_VOLTS_SCALE = 0.019608f + const val O2_MV_SCALE = 4.44f + + const val BATTERY_VOLTS_SCALE = 0.1f + const val SPARK_ADVANCE_SCALE = 0.351563f + const val EGR_DUTY_CYCLE_SCALE = 0.392157f + const val BPW_MS_SCALE = 0.015259f + + // Plausibility Limits + const val MAX_RPM = 8000 + const val MIN_TEMP_C = -45.0f + const val MAX_TEMP_C = 220.0f + const val MIN_BATTERY_V = 5.0f + const val MAX_BATTERY_V = 20.0f + const val MAX_TPS_V = 5.1f + + // Bitmasks + const val BIT_0 = 0x01 + const val BIT_1 = 0x02 + const val BIT_2 = 0x04 + const val BIT_3 = 0x08 + const val BIT_4 = 0x10 + const val BIT_5 = 0x20 + const val BIT_6 = 0x40 + const val BIT_7 = 0x80 + + // MAT C interpolation table + val MAT_TABLE_C = listOf( + 0 to 200.0f, 12 to 150.0f, 13 to 145.0f, 14 to 140.0f, 16 to 135.0f, + 18 to 130.0f, 21 to 125.0f, 23 to 120.0f, 26 to 115.0f, 30 to 110.0f, + 34 to 105.0f, 39 to 100.0f, 44 to 95.0f, 50 to 90.0f, 56 to 85.0f, + 64 to 80.0f, 72 to 75.0f, 81 to 70.0f, 92 to 65.0f, 102 to 60.0f, + 114 to 55.0f, 126 to 50.0f, 139 to 45.0f, 152 to 40.0f, 165 to 35.0f, + 177 to 30.0f, 189 to 25.0f, 199 to 20.0f, 209 to 15.0f, 218 to 10.0f, + 225 to 5.0f, 231 to 0.0f, 237 to -5.0f, 241 to -10.0f, 245 to -15.0f, + 247 to -20.0f, 250 to -25.0f, 251 to -30.0f, 255 to -40.0f + ) + + // MAT F interpolation table + val MAT_TABLE_F = listOf( + 0 to 392.0f, 12 to 302.0f, 13 to 293.0f, 14 to 284.0f, 16 to 275.0f, + 18 to 266.0f, 21 to 257.0f, 23 to 248.0f, 26 to 239.0f, 30 to 230.0f, + 34 to 221.0f, 39 to 212.0f, 44 to 203.0f, 50 to 194.0f, 56 to 185.0f, + 64 to 176.0f, 72 to 167.0f, 81 to 158.0f, 92 to 149.0f, 102 to 140.0f, + 114 to 131.0f, 126 to 122.0f, 139 to 113.0f, 152 to 104.0f, 165 to 95.0f, + 177 to 86.0f, 189 to 77.0f, 199 to 68.0f, 209 to 59.0f, 218 to 50.0f, + 225 to 41.0f, 231 to 32.0f, 237 to 23.0f, 241 to 14.0f, 245 to 5.0f, + 247 to -4.0f, 250 to -13.0f, 251 to -22.0f, 255 to -40.0f + ) +} + object ALDLParser { - // MAT C interpolation table: key is raw value, value is Temp in C - private val matTableC = listOf( - 0 to 200.0f, - 12 to 150.0f, - 13 to 145.0f, - 14 to 140.0f, - 16 to 135.0f, - 18 to 130.0f, - 21 to 125.0f, - 23 to 120.0f, - 26 to 115.0f, - 30 to 110.0f, - 34 to 105.0f, - 39 to 100.0f, - 44 to 95.0f, - 50 to 90.0f, - 56 to 85.0f, - 64 to 80.0f, - 72 to 75.0f, - 81 to 70.0f, - 92 to 65.0f, - 102 to 60.0f, - 114 to 55.0f, - 126 to 50.0f, - 139 to 45.0f, - 152 to 40.0f, - 165 to 35.0f, - 177 to 30.0f, - 189 to 25.0f, - 199 to 20.0f, - 209 to 15.0f, - 218 to 10.0f, - 225 to 5.0f, - 231 to 0.0f, - 237 to -5.0f, - 241 to -10.0f, - 245 to -15.0f, - 247 to -20.0f, - 250 to -25.0f, - 251 to -30.0f, - 255 to -40.0f - ) - - // MAT F interpolation table: key is raw value, value is Temp in F - private val matTableF = listOf( - 0 to 392.0f, - 12 to 302.0f, - 13 to 293.0f, - 14 to 284.0f, - 16 to 275.0f, - 18 to 266.0f, - 21 to 257.0f, - 23 to 248.0f, - 26 to 239.0f, - 30 to 230.0f, - 34 to 221.0f, - 39 to 212.0f, - 44 to 203.0f, - 50 to 194.0f, - 56 to 185.0f, - 64 to 176.0f, - 72 to 167.0f, - 81 to 158.0f, - 92 to 149.0f, - 102 to 140.0f, - 114 to 131.0f, - 126 to 122.0f, - 139 to 113.0f, - 152 to 104.0f, - 165 to 95.0f, - 177 to 86.0f, - 189 to 77.0f, - 199 to 68.0f, - 209 to 59.0f, - 218 to 50.0f, - 225 to 41.0f, - 231 to 32.0f, - 237 to 23.0f, - 241 to 14.0f, - 245 to 5.0f, - 247 to -4.0f, - 250 to -13.0f, - 251 to -22.0f, - 255 to -40.0f - ) - private fun interpolate(raw: Int, table: List>): Float { if (raw <= table.first().first) return table.first().second if (raw >= table.last().first) return table.last().second @@ -149,91 +134,100 @@ object ALDLParser { } /** - * Parses a 25-byte raw data payload. + * Parses a 25-byte raw data payload, validating bounds and checking for errors. */ - fun parseFrame(data: ByteArray): ALDLFrame? { - if (data.size != 25) return null + fun parseFrame(data: ByteArray): ALDLParseResult { + if (data.size != ALDLConstants.PAYLOAD_SIZE) { + return ALDLParseResult.Incomplete + } - val u = IntArray(25) { data[it].toInt() and 0xFF } + val u = IntArray(ALDLConstants.PAYLOAD_SIZE) { data[it].toInt() and 0xFF } - // Mappings based on 1-indexed btByteNumber in 24-INT10.ads (index = byteNumber - 1) - val iacPosition = u[3] // Byte 4 - val coolantTempC = u[4] * 0.75f - 40.0f // Byte 5 - val coolantTempF = u[4] * 1.35f - 40.0f // Byte 5 - val vehicleSpeedMPH = u[5] // Byte 6 - val mapVolts = u[6] * 0.019608f // Byte 7 - val mapKpa = u[6] * 0.369f + 10.354f // Byte 7 - val engineSpeedRpm = u[7] * 25 // Byte 8 - val tpsVolts = u[8] * 0.019608f // Byte 9 - val integrator = u[9] // Byte 10 - val o2SensorMv = u[10] * 4.44f // Byte 11 + val engineSpeedRpm = u[7] * ALDLConstants.RPM_SCALE + if (engineSpeedRpm > ALDLConstants.MAX_RPM) { + return ALDLParseResult.InvalidData("RPM ($engineSpeedRpm) exceeds plausibility limit (${ALDLConstants.MAX_RPM})") + } - val codesByte1 = u[11] // Byte 12 - val codesByte2 = u[12] // Byte 13 - val codesByte3 = u[13] // Byte 14 - val miscByte1 = u[14] // Byte 15 - val miscByte2 = u[15] // Byte 16 - val miscByte3 = u[16] // Byte 17 + val coolantTempC = u[4] * ALDLConstants.COOLANT_SCALE_C + ALDLConstants.COOLANT_OFFSET_C + val coolantTempF = u[4] * ALDLConstants.COOLANT_SCALE_F + ALDLConstants.COOLANT_OFFSET_F + if (coolantTempC < ALDLConstants.MIN_TEMP_C || coolantTempC > ALDLConstants.MAX_TEMP_C) { + return ALDLParseResult.InvalidData("Coolant Temp ($coolantTempC C) outside bounds") + } - val batteryVolts = u[17] * 0.1f // Byte 18 - val blm = u[18] // Byte 19 - val richLeanCrosses = u[19] // Byte 20 - val sparkAdvance = u[20] * 0.351563f // Byte 21 - val egrDutyCycle = u[21] * 0.392157f // Byte 22 + val batteryVolts = u[17] * ALDLConstants.BATTERY_VOLTS_SCALE + if (batteryVolts < ALDLConstants.MIN_BATTERY_V || batteryVolts > ALDLConstants.MAX_BATTERY_V) { + return ALDLParseResult.InvalidData("Battery Voltage ($batteryVolts V) outside bounds") + } + + val tpsVolts = u[8] * ALDLConstants.TPS_VOLTS_SCALE + if (tpsVolts > ALDLConstants.MAX_TPS_V) { + return ALDLParseResult.InvalidData("TPS Voltage ($tpsVolts V) outside bounds") + } + + val iacPosition = u[3] + val vehicleSpeedMPH = u[5] + val mapVolts = u[6] * ALDLConstants.MAP_VOLTS_SCALE + val mapKpa = u[6] * ALDLConstants.MAP_KPA_SCALE + ALDLConstants.MAP_KPA_OFFSET + val integrator = u[9] + val o2SensorMv = u[10] * ALDLConstants.O2_MV_SCALE + + val codesByte1 = u[11] + val codesByte2 = u[12] + val codesByte3 = u[13] + val miscByte1 = u[14] + val miscByte2 = u[15] + val miscByte3 = u[16] + + val blm = u[18] + val richLeanCrosses = u[19] + val sparkAdvance = u[20] * ALDLConstants.SPARK_ADVANCE_SCALE + val egrDutyCycle = u[21] * ALDLConstants.EGR_DUTY_CYCLE_SCALE - // MAT (Air Temp) Interpolation - val matC = interpolate(u[22], matTableC) // Byte 23 - val matF = interpolate(u[22], matTableF) // Byte 23 + val matC = interpolate(u[22], ALDLConstants.MAT_TABLE_C) + val matF = interpolate(u[22], ALDLConstants.MAT_TABLE_F) - // BPW (Base Pulse Width) 16-bit - val rawBpw = (u[23] shl 8) or u[24] // Byte 24 (High), Byte 25 (Low) - val bpwMs = rawBpw * 0.015259f + val rawBpw = (u[23] shl 8) or u[24] + val bpwMs = rawBpw * ALDLConstants.BPW_MS_SCALE - // Status Flags Decoding - // Misc Byte 1 (Byte 15) - val blmEnable = (miscByte1 and 0x02) != 0 // bit 1 - val quasiPulse = (miscByte1 and 0x08) != 0 // bit 3 - val asyncPulse = (miscByte1 and 0x10) != 0 // bit 4 - val isRich = (miscByte1 and 0x40) != 0 // bit 6 (1=RICH, 0=LEAN) - val isClosedLoop = (miscByte1 and 0x80) != 0 // bit 7 (1=CLOSED, 0=OPEN) + val blmEnable = (miscByte1 and ALDLConstants.BIT_1) != 0 + val quasiPulse = (miscByte1 and ALDLConstants.BIT_3) != 0 + val asyncPulse = (miscByte1 and ALDLConstants.BIT_4) != 0 + val isRich = (miscByte1 and ALDLConstants.BIT_6) != 0 + val isClosedLoop = (miscByte1 and ALDLConstants.BIT_7) != 0 - // Misc Byte 2 (Byte 16) - val isAcEnabled = (miscByte2 and 0x20) == 0 // bit 5 (0=ENABLED, 1=DISABLED/IDLE) - val isParkNeutral = (miscByte2 and 0x80) != 0 // bit 7 (1=PARK/NEUTRAL, 0=IN GEAR) + val isAcEnabled = (miscByte2 and ALDLConstants.BIT_5) == 0 + val isParkNeutral = (miscByte2 and ALDLConstants.BIT_7) != 0 - // Misc Byte 3 (Byte 17) - val isAcClutchEnabled = (miscByte3 and 0x01) != 0 // bit 0 (1=ENABLED) - val isTccLocked = (miscByte3 and 0x04) != 0 // bit 2 (1=LOCKED) - val isPowerSteeringCrampActive = (miscByte3 and 0x20) != 0 // bit 5 (1=ACTIVE) + val isAcClutchEnabled = (miscByte3 and ALDLConstants.BIT_0) != 0 + val isTccLocked = (miscByte3 and ALDLConstants.BIT_2) != 0 + val isPowerSteeringCrampActive = (miscByte3 and ALDLConstants.BIT_5) != 0 - // Active Fault Codes list based on code bits val activeCodes = mutableListOf() - // Byte 12 - if ((codesByte1 and 0x80) != 0) activeCodes.add(12) // bit 7 - if ((codesByte1 and 0x40) != 0) activeCodes.add(13) // bit 6 - if ((codesByte1 and 0x20) != 0) activeCodes.add(14) // bit 5 - if ((codesByte1 and 0x10) != 0) activeCodes.add(15) // bit 4 - if ((codesByte1 and 0x08) != 0) activeCodes.add(21) // bit 3 - if ((codesByte1 and 0x04) != 0) activeCodes.add(22) // bit 2 - if ((codesByte1 and 0x02) != 0) activeCodes.add(23) // bit 1 - if ((codesByte1 and 0x01) != 0) activeCodes.add(24) // bit 0 - // Byte 13 - if ((codesByte2 and 0x80) != 0) activeCodes.add(25) // bit 7 - if ((codesByte2 and 0x20) != 0) activeCodes.add(32) // bit 5 - if ((codesByte2 and 0x10) != 0) activeCodes.add(33) // bit 4 - if ((codesByte2 and 0x08) != 0) activeCodes.add(34) // bit 3 - if ((codesByte2 and 0x04) != 0) activeCodes.add(35) // bit 2 - if ((codesByte2 and 0x01) != 0) activeCodes.add(42) // bit 0 - // Byte 14 - if ((codesByte3 and 0x80) != 0) activeCodes.add(43) // bit 7 - if ((codesByte3 and 0x40) != 0) activeCodes.add(44) // bit 6 - if ((codesByte3 and 0x20) != 0) activeCodes.add(45) // bit 5 - if ((codesByte3 and 0x10) != 0) activeCodes.add(51) // bit 4 - if ((codesByte3 and 0x08) != 0) activeCodes.add(52) // bit 3 - if ((codesByte3 and 0x04) != 0) activeCodes.add(53) // bit 2 - if ((codesByte3 and 0x01) != 0) activeCodes.add(55) // bit 0 + if ((codesByte1 and ALDLConstants.BIT_7) != 0) activeCodes.add(12) + if ((codesByte1 and ALDLConstants.BIT_6) != 0) activeCodes.add(13) + if ((codesByte1 and ALDLConstants.BIT_5) != 0) activeCodes.add(14) + if ((codesByte1 and ALDLConstants.BIT_4) != 0) activeCodes.add(15) + if ((codesByte1 and ALDLConstants.BIT_3) != 0) activeCodes.add(21) + if ((codesByte1 and ALDLConstants.BIT_2) != 0) activeCodes.add(22) + if ((codesByte1 and ALDLConstants.BIT_1) != 0) activeCodes.add(23) + if ((codesByte1 and ALDLConstants.BIT_0) != 0) activeCodes.add(24) - return ALDLFrame( + if ((codesByte2 and ALDLConstants.BIT_7) != 0) activeCodes.add(25) + if ((codesByte2 and ALDLConstants.BIT_5) != 0) activeCodes.add(32) + if ((codesByte2 and ALDLConstants.BIT_4) != 0) activeCodes.add(33) + if ((codesByte2 and ALDLConstants.BIT_3) != 0) activeCodes.add(34) + if ((codesByte2 and ALDLConstants.BIT_2) != 0) activeCodes.add(35) + if ((codesByte2 and ALDLConstants.BIT_0) != 0) activeCodes.add(42) + + if ((codesByte3 and ALDLConstants.BIT_7) != 0) activeCodes.add(43) + if ((codesByte3 and ALDLConstants.BIT_6) != 0) activeCodes.add(44) + if ((codesByte3 and ALDLConstants.BIT_5) != 0) activeCodes.add(45) + if ((codesByte3 and ALDLConstants.BIT_4) != 0) activeCodes.add(51) + if ((codesByte3 and ALDLConstants.BIT_3) != 0) activeCodes.add(52) + if ((codesByte3 and ALDLConstants.BIT_2) != 0) activeCodes.add(53) + if ((codesByte3 and ALDLConstants.BIT_0) != 0) activeCodes.add(55) + + val frame = ALDLFrame( rawBytes = data, iacPosition = iacPosition, coolantTempC = coolantTempC, @@ -265,5 +259,6 @@ object ALDLParser { isPowerSteeringCrampActive = isPowerSteeringCrampActive, activeFaultCodes = activeCodes ) + return ALDLParseResult.Success(frame) } } diff --git a/app/src/main/java/com/example/esp32aldldashboard/parser/DerivedTelemetry.kt b/app/src/main/java/com/example/esp32aldldashboard/parser/DerivedTelemetry.kt new file mode 100644 index 0000000..c4e0b95 --- /dev/null +++ b/app/src/main/java/com/example/esp32aldldashboard/parser/DerivedTelemetry.kt @@ -0,0 +1,22 @@ +package com.example.esp32aldldashboard.parser + +// Approximate Engine Load (0.0 to 1.0) +// Very rough approximation: MAP (kPa) / ~100 kPa (atmospheric at sea level) +// At WOT, MAP is near atmospheric (100 kPa), load is near 100%. At idle, MAP is around 30-40 kPa, load is lower. +val ALDLFrame.estimatedEngineLoad: Float + get() { + val load = mapKpa / 100.0f + return load.coerceIn(0.0f, 1.0f) + } + +// Approximate Fuel Flow Rate (lbs/hr) +// Rough formula: RPM * BPW (ms) * Injector Flow Rate Constant +// 2.8L Fiero V6 typically has ~15 lb/hr injectors. There are 6 injectors, firing sequentially or batch? +// The 1227170 ECM fires batch (3 injectors per driver, twice per cycle). +// Rough estimation: flow = (RPM / 2) * BPW_seconds * 6 * Injector_rate / 60 +// We'll provide a simplified unitless flow hint for the UI visualization. +val ALDLFrame.fuelFlowHint: Float + get() { + val bpwSeconds = bpwMs / 1000.0f + return (engineSpeedRpm * bpwSeconds) / 2.0f + } diff --git a/app/src/main/java/com/example/esp32aldldashboard/parser/TroubleCodeDictionary.kt b/app/src/main/java/com/example/esp32aldldashboard/parser/TroubleCodeDictionary.kt new file mode 100644 index 0000000..38f2156 --- /dev/null +++ b/app/src/main/java/com/example/esp32aldldashboard/parser/TroubleCodeDictionary.kt @@ -0,0 +1,36 @@ +package com.example.esp32aldldashboard.parser + +object TroubleCodeDictionary { + val DTC_DESCRIPTIONS = mapOf( + 12 to "Crank Sensor / System Check - Normal if engine not running.", + 13 to "O2 Sensor Circuit - Open or No Activity.", + 14 to "Coolant Temperature Sensor - High Temperature Indicated.", + 15 to "Coolant Temperature Sensor - Low Temperature Indicated.", + 21 to "Throttle Position Sensor (TPS) - High Voltage.", + 22 to "Throttle Position Sensor (TPS) - Low Voltage.", + 23 to "Manifold Air Temperature (MAT) - Low Temperature Indicated.", + 24 to "Vehicle Speed Sensor (VSS) - Circuit Fault.", + 25 to "Manifold Air Temperature (MAT) - High Temperature Indicated.", + 32 to "Exhaust Gas Recirculation (EGR) - System Fault.", + 33 to "Manifold Absolute Pressure (MAP) - High Pressure Indicated.", + 34 to "Manifold Absolute Pressure (MAP) - Low Pressure Indicated.", + 35 to "Idle Air Control (IAC) - Position Error.", + 42 to "Electronic Spark Timing (EST) - Circuit Fault.", + 43 to "Electronic Spark Control (ESC) - Knock Sensor Fault.", + 44 to "Oxygen Sensor - Lean Exhaust Indicated.", + 45 to "Oxygen Sensor - Rich Exhaust Indicated.", + 51 to "PROM Error - Faulty or Incorrect Memcal.", + 52 to "Cal-Pack Error - Missing or Faulty Cal-Pack.", + 53 to "System Voltage - Battery Over-Voltage.", + 55 to "ADU Error - Internal ECM Fault." + ) + + fun getDescription(code: Int): String { + return DTC_DESCRIPTIONS[code] ?: "Unknown Trouble Code $code" + } + + fun isCritical(code: Int): Boolean { + // Severe codes that might require immediate pull-over + return code in listOf(14, 43, 51, 52, 53, 55) + } +} diff --git a/app/src/main/java/com/example/esp32aldldashboard/repository/SettingsRepository.kt b/app/src/main/java/com/example/esp32aldldashboard/repository/SettingsRepository.kt new file mode 100644 index 0000000..11c036d --- /dev/null +++ b/app/src/main/java/com/example/esp32aldldashboard/repository/SettingsRepository.kt @@ -0,0 +1,64 @@ +package com.example.esp32aldldashboard.repository + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.* +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +val Context.dataStore: DataStore by preferencesDataStore(name = "settings") + +class SettingsRepository(private val context: Context) { + + companion object { + val IS_CELSIUS = booleanPreferencesKey("is_celsius") + val COOLANT_ALERT_THRESHOLD = floatPreferencesKey("coolant_alert_threshold") + val BATTERY_LOW_THRESHOLD = floatPreferencesKey("battery_low_threshold") + val AUTO_LOGGING = booleanPreferencesKey("auto_logging") + } + + val isCelsiusFlow: Flow = context.dataStore.data + .map { preferences -> + preferences[IS_CELSIUS] ?: false // Default to Fahrenheit + } + + val coolantAlertThresholdFlow: Flow = context.dataStore.data + .map { preferences -> + preferences[COOLANT_ALERT_THRESHOLD] ?: 100f // Default 100C / 212F + } + + val batteryLowThresholdFlow: Flow = context.dataStore.data + .map { preferences -> + preferences[BATTERY_LOW_THRESHOLD] ?: 11.5f // Default 11.5V + } + + val autoLoggingFlow: Flow = context.dataStore.data + .map { preferences -> + preferences[AUTO_LOGGING] ?: false + } + + suspend fun setIsCelsius(isCelsius: Boolean) { + context.dataStore.edit { preferences -> + preferences[IS_CELSIUS] = isCelsius + } + } + + suspend fun setCoolantAlertThreshold(threshold: Float) { + context.dataStore.edit { preferences -> + preferences[COOLANT_ALERT_THRESHOLD] = threshold + } + } + + suspend fun setBatteryLowThreshold(threshold: Float) { + context.dataStore.edit { preferences -> + preferences[BATTERY_LOW_THRESHOLD] = threshold + } + } + + suspend fun setAutoLogging(autoLog: Boolean) { + context.dataStore.edit { preferences -> + preferences[AUTO_LOGGING] = autoLog + } + } +} diff --git a/app/src/main/java/com/example/esp32aldldashboard/repository/TelemetryRepository.kt b/app/src/main/java/com/example/esp32aldldashboard/repository/TelemetryRepository.kt new file mode 100644 index 0000000..6641713 --- /dev/null +++ b/app/src/main/java/com/example/esp32aldldashboard/repository/TelemetryRepository.kt @@ -0,0 +1,112 @@ +package com.example.esp32aldldashboard.repository + +import com.example.esp32aldldashboard.bluetooth.BluetoothService +import com.example.esp32aldldashboard.bluetooth.ConnectionState +import com.example.esp32aldldashboard.parser.ALDLFrame +import com.example.esp32aldldashboard.data.database.SessionEntity +import com.example.esp32aldldashboard.data.database.TelemetryDao +import com.example.esp32aldldashboard.data.database.TelemetryDataPointEntity +import com.example.esp32aldldashboard.logging.CsvLogger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.StateFlow + +class TelemetryRepository( + private val bluetoothService: BluetoothService, + private val telemetryDao: TelemetryDao, + private val csvLogger: CsvLogger, + private val settingsRepository: SettingsRepository +) { + private val repoScope = CoroutineScope(Dispatchers.IO + Job()) + private var currentSessionId: Long? = null + private var isRecording = false + + init { + observeConnectionState() + observeTelemetry() + } + + val connectionState: StateFlow = bluetoothService.connectionState + val latestFrame: StateFlow = bluetoothService.latestFrame + val rawHexLog: StateFlow> = bluetoothService.rawHexLog + + val framesReceived: StateFlow = bluetoothService.framesReceived + val parseErrors: StateFlow = bluetoothService.parseErrors + val currentFrameRate: StateFlow = bluetoothService.currentFrameRate + val errorMessage: StateFlow = bluetoothService.errorMessage + + private fun observeConnectionState() { + repoScope.launch { + bluetoothService.connectionState.collectLatest { state -> + when (state) { + ConnectionState.CONNECTED -> { + val autoLog = settingsRepository.autoLoggingFlow.first() + if (autoLog) { + startSession(isSimulation = false) // Or true if we knew + } + } + ConnectionState.DISCONNECTED, ConnectionState.ERROR -> { + endSession() + } + else -> {} + } + } + } + } + + private fun observeTelemetry() { + repoScope.launch { + bluetoothService.latestFrame.collectLatest { frame -> + if (frame != null && isRecording) { + csvLogger.logFrame(frame) + currentSessionId?.let { sid -> + val dataPoint = TelemetryDataPointEntity( + sessionId = sid, + timestamp = frame.timestamp, + rawBytes = frame.rawBytes + ) + telemetryDao.insertDataPoints(listOf(dataPoint)) + } + } + } + } + } + + private suspend fun startSession(isSimulation: Boolean) { + if (isRecording) return + val session = SessionEntity( + startTime = System.currentTimeMillis(), + name = "Session ${System.currentTimeMillis()}", + isSimulation = isSimulation + ) + currentSessionId = telemetryDao.insertSession(session) + csvLogger.startNewSession(isSimulation) + isRecording = true + } + + private suspend fun endSession() { + if (!isRecording) return + isRecording = false + csvLogger.endSession() + currentSessionId?.let { sid -> + telemetryDao.endSession(sid, System.currentTimeMillis()) + } + currentSessionId = null + } + + fun connect() { + bluetoothService.connect() + } + + fun disconnect() { + bluetoothService.disconnect() + } + + fun startSimulation() { + bluetoothService.startSimulation() + } +} diff --git a/app/src/main/java/com/example/esp32aldldashboard/ui/charts/ChartsScreen.kt b/app/src/main/java/com/example/esp32aldldashboard/ui/charts/ChartsScreen.kt new file mode 100644 index 0000000..48b2920 --- /dev/null +++ b/app/src/main/java/com/example/esp32aldldashboard/ui/charts/ChartsScreen.kt @@ -0,0 +1,100 @@ +package com.example.esp32aldldashboard.ui.charts + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.example.esp32aldldashboard.parser.ALDLFrame +import kotlinx.coroutines.flow.StateFlow + +@Composable +fun ChartsScreen( + latestFrameFlow: StateFlow, + modifier: Modifier = Modifier +) { + val latestFrame by latestFrameFlow.collectAsStateWithLifecycle() + + // We maintain a limited rolling history of points (e.g. 100 points) + val rpmHistory = remember { mutableStateListOf() } + val maxHistorySize = 100 + + LaunchedEffect(latestFrame) { + latestFrame?.let { + rpmHistory.add(it.engineSpeedRpm.toFloat()) + if (rpmHistory.size > maxHistorySize) { + rpmHistory.removeAt(0) + } + } + } + + Column(modifier = modifier.fillMaxSize().padding(16.dp)) { + Text( + text = "Real-Time Telemetry", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground + ) + Spacer(modifier = Modifier.height(16.dp)) + + Card( + modifier = Modifier.fillMaxWidth().height(200.dp), + colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text(text = "RPM", color = Color(0xFF00FFCC)) + Spacer(modifier = Modifier.height(8.dp)) + LineChart( + data = rpmHistory, + maxValue = 6000f, + lineColor = Color(0xFF00FFCC), + modifier = Modifier.fillMaxSize() + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Additional charts can go here (e.g., O2, TPS) + } +} + +@Composable +fun LineChart( + data: List, + maxValue: Float, + lineColor: Color, + modifier: Modifier = Modifier +) { + if (data.isEmpty()) return + + Canvas(modifier = modifier) { + val width = size.width + val height = size.height + val pointSpacing = if (data.size > 1) width / (data.size - 1) else 0f + + val path = Path() + data.forEachIndexed { index, value -> + val x = index * pointSpacing + // Invert y since Canvas y=0 is at the top + val y = height - ((value / maxValue) * height).coerceIn(0f, height) + + if (index == 0) { + path.moveTo(x, y) + } else { + path.lineTo(x, y) + } + } + + drawPath( + path = path, + color = lineColor, + style = Stroke(width = 4.dp.toPx()) + ) + } +} diff --git a/app/src/main/java/com/example/esp32aldldashboard/ui/components/Gauges.kt b/app/src/main/java/com/example/esp32aldldashboard/ui/components/Gauges.kt new file mode 100644 index 0000000..2a4f37e --- /dev/null +++ b/app/src/main/java/com/example/esp32aldldashboard/ui/components/Gauges.kt @@ -0,0 +1,125 @@ +package com.example.esp32aldldashboard.ui.components + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun RpmGauge( + rpm: Int, + maxRpm: Int = 6000, + modifier: Modifier = Modifier +) { + val animatedRpm by animateFloatAsState( + targetValue = rpm.toFloat(), + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow), + label = "rpmAnimation" + ) + + Box(modifier = modifier.aspectRatio(1f), contentAlignment = Alignment.Center) { + Canvas(modifier = Modifier.fillMaxSize().padding(16.dp)) { + val strokeWidth = 24.dp.toPx() + val startAngle = 135f + val sweepAngle = 270f + + // Background arc + drawArc( + color = Color.DarkGray.copy(alpha = 0.5f), + startAngle = startAngle, + sweepAngle = sweepAngle, + useCenter = false, + style = Stroke(width = strokeWidth, cap = StrokeCap.Round), + size = Size(size.width, size.height) + ) + + // Foreground arc + val progress = (animatedRpm / maxRpm).coerceIn(0f, 1f) + val color = if (progress > 0.85f) Color.Red else Color(0xFF00FFCC) // Neon Cyan + + drawArc( + color = color, + startAngle = startAngle, + sweepAngle = sweepAngle * progress, + useCenter = false, + style = Stroke(width = strokeWidth, cap = StrokeCap.Round), + size = Size(size.width, size.height) + ) + } + + Text( + text = rpm.toString(), + color = Color.White, + fontSize = 32.sp, + fontWeight = FontWeight.Bold + ) + Text( + text = "RPM", + color = Color.LightGray, + fontSize = 14.sp, + modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 24.dp) + ) + } +} + +@Composable +fun TpsBar( + tpsVolts: Float, + modifier: Modifier = Modifier +) { + val animatedTps by animateFloatAsState( + targetValue = tpsVolts, + animationSpec = spring(stiffness = Spring.StiffnessLow), + label = "tpsAnimation" + ) + + Box(modifier = modifier, contentAlignment = Alignment.Center) { + Canvas(modifier = Modifier.fillMaxSize()) { + val strokeWidth = size.height + val maxVolts = 5.0f + val progress = (animatedTps / maxVolts).coerceIn(0f, 1f) + + // Background bar + drawLine( + color = Color.DarkGray.copy(alpha = 0.5f), + start = Offset(0f, size.height / 2), + end = Offset(size.width, size.height / 2), + strokeWidth = strokeWidth, + cap = StrokeCap.Round + ) + + // Foreground bar + drawLine( + color = Color(0xFFFF9900), // Neon Orange + start = Offset(0f, size.height / 2), + end = Offset(size.width * progress, size.height / 2), + strokeWidth = strokeWidth, + cap = StrokeCap.Round + ) + } + Text( + text = String.format("%.2f V", tpsVolts), + color = Color.White, + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ) + } +} diff --git a/app/src/main/java/com/example/esp32aldldashboard/ui/main/DashboardScreen.kt b/app/src/main/java/com/example/esp32aldldashboard/ui/main/DashboardScreen.kt new file mode 100644 index 0000000..af8e26d --- /dev/null +++ b/app/src/main/java/com/example/esp32aldldashboard/ui/main/DashboardScreen.kt @@ -0,0 +1,511 @@ +package com.example.esp32aldldashboard.ui.main + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.example.esp32aldldashboard.bluetooth.ConnectionState +import com.example.esp32aldldashboard.parser.ALDLFrame +import com.example.esp32aldldashboard.parser.TroubleCodeDictionary +import com.example.esp32aldldashboard.ui.components.RpmGauge +import com.example.esp32aldldashboard.ui.components.TpsBar + +// Theme Colors +val DarkBg = Color(0xFF0F0F12) +val CardBg = Color(0xFF1B1B22) +val BorderColor = Color(0xFF2E2E38) +val NeonCyan = Color(0xFF00E5FF) +val NeonRed = Color(0xFFFF3D00) +val NeonGreen = Color(0xFF00E676) +val NeonOrange = Color(0xFFFF9100) +val TextWhite = Color(0xFFEEEEEE) +val TextMuted = Color(0xFF9E9EAF) + +@Composable +fun DashboardScreen( + connState: ConnectionState, + frame: ALDLFrame?, + isCelsius: Boolean, + onConnect: () -> Unit, + onDisconnect: () -> Unit, + onSimulate: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .background(DarkBg) + .verticalScroll(rememberScrollState()) + ) { + // App Title Banner + Text( + text = "PONTIAC FIERO ALDL DASHBOARD", + color = NeonOrange, + fontSize = 18.sp, + fontWeight = FontWeight.ExtraBold, + letterSpacing = 1.5.sp, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp) + ) + + // Connection Action Card + ConnectionCard( + connState = connState, + errorMsg = "", + isCelsius = isCelsius, + onConnect = onConnect, + onDisconnect = onDisconnect, + onSimulate = onSimulate, + onToggleUnit = { /* Moved to settings */ } + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Telemetry Panels + if (frame != null) { + // Trouble Codes Card (Flashing if active) + if (frame.activeFaultCodes.isNotEmpty()) { + TroubleCodesCard(activeCodes = frame.activeFaultCodes) + Spacer(modifier = Modifier.height(12.dp)) + } + + // Gauges row: RPM and TPS + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + RpmGauge( + rpm = frame.engineSpeedRpm, + modifier = Modifier.weight(1f).aspectRatio(1f) + ) + + Card( + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = CardBg), + modifier = Modifier.weight(1f).aspectRatio(1f) + .border(1.dp, BorderColor, shape = RoundedCornerShape(12.dp)) + ) { + Column(modifier = Modifier.padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Text(text = "THROTTLE", color = TextMuted, fontSize = 12.sp, fontWeight = FontWeight.Bold) + Spacer(modifier = Modifier.weight(1f)) + TpsBar(tpsVolts = frame.tpsVolts, modifier = Modifier.fillMaxWidth().height(40.dp)) + Spacer(modifier = Modifier.weight(1f)) + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Grid of Minor Telemetry Parameters + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Coolant and Intake Temp Row + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + val coolantVal = if (isCelsius) frame.coolantTempC else frame.coolantTempF + val coolantUnit = if (isCelsius) "°C" else "°F" + val coolantProgress = (coolantVal + 40) / 290f // normalized range + + val matVal = if (isCelsius) frame.matC else frame.matF + val matProgress = (matVal + 40) / 290f + + GridItemCard( + title = "COOLANT TEMP", + value = String.format("%.1f", coolantVal) + coolantUnit, + progress = coolantProgress, + progressColor = if (coolantVal > 210) NeonRed else NeonGreen, + modifier = Modifier.weight(1f) + ) + GridItemCard( + title = "MAT (AIR TEMP)", + value = String.format("%.1f", matVal) + coolantUnit, + progress = matProgress, + progressColor = NeonCyan, + modifier = Modifier.weight(1f) + ) + } + + // Fuel control row + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + GridItemCard( + title = "BPW (INJECTOR)", + value = String.format("%.3f ms", frame.bpwMs), + progress = frame.bpwMs / 15f, + progressColor = NeonCyan, + modifier = Modifier.weight(1f) + ) + GridItemCard( + title = "O2 SENSOR", + value = "${frame.o2SensorMv.toInt()} mV", + progress = frame.o2SensorMv / 1000f, + progressColor = if (frame.isRich) NeonGreen else NeonOrange, + modifier = Modifier.weight(1f) + ) + } + + // Air flow & Throttle Position + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + GridItemCard( + title = "VEHICLE SPEED", + value = "${frame.vehicleSpeedMPH} MPH", + progress = frame.vehicleSpeedMPH / 120f, + progressColor = NeonGreen, + modifier = Modifier.weight(1f) + ) + GridItemCard( + title = "MAP (VACUUM)", + value = String.format("%.1f kPa", frame.mapKpa), + progress = frame.mapKpa / 105f, + progressColor = NeonCyan, + modifier = Modifier.weight(1f) + ) + } + + // Fuel trims (BLM & INT) & Battery + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + GridItemCard( + title = "BLM / INT", + value = "${frame.blm} / ${frame.integrator}", + progress = frame.blm / 256f, + progressColor = if (frame.blm in 120..136) NeonGreen else NeonOrange, + modifier = Modifier.weight(1f) + ) + GridItemCard( + title = "BATTERY VOLTS", + value = String.format("%.1f V", frame.batteryVolts), + progress = (frame.batteryVolts - 8) / 8f, + progressColor = if (frame.batteryVolts < 12.0f) NeonRed else NeonGreen, + modifier = Modifier.weight(1f) + ) + } + + // IAC, Spark & EGR + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + GridItemCard( + title = "IAC POSITION", + value = "${frame.iacPosition} Steps", + progress = frame.iacPosition / 160f, + progressColor = NeonCyan, + modifier = Modifier.weight(1f) + ) + GridItemCard( + title = "SPARK / EGR", + value = String.format("%.1f° / %.0f%%", frame.sparkAdvance, frame.egrDutyCycle), + progress = frame.egrDutyCycle / 100f, + progressColor = NeonCyan, + modifier = Modifier.weight(1f) + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Status Badges Section + StatusFlagsPanel(frame = frame) + + Spacer(modifier = Modifier.height(16.dp)) + } else { + // Empty / Waiting display + Box( + modifier = Modifier + .fillMaxWidth() + .height(260.dp) + .padding(horizontal = 16.dp) + .background(CardBg, shape = RoundedCornerShape(12.dp)) + .border(1.dp, BorderColor, shape = RoundedCornerShape(12.dp)), + contentAlignment = Alignment.Center + ) { + Text( + text = "Waiting for ALDL Stream data...\nSelect Connection or Simulation above.", + color = TextMuted, + fontSize = 14.sp, + textAlign = TextAlign.Center + ) + } + Spacer(modifier = Modifier.height(16.dp)) + } + + Spacer(modifier = Modifier.height(24.dp)) + } +} + +// ... Copying the UI components from MainScreen.kt to keep them in DashboardScreen.kt ... + +@Composable +fun ConnectionCard( + connState: ConnectionState, + errorMsg: String, + isCelsius: Boolean, + onConnect: () -> Unit, + onDisconnect: () -> Unit, + onSimulate: () -> Unit, + onToggleUnit: () -> Unit +) { + Card( + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = CardBg), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .border(1.dp, BorderColor, shape = RoundedCornerShape(12.dp)) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + // Status bar + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "STATUS: ", + color = TextMuted, + fontWeight = FontWeight.Bold, + fontSize = 13.sp + ) + val (statusText, statusColor) = when (connState) { + ConnectionState.DISCONNECTED -> "DISCONNECTED" to TextMuted + ConnectionState.CONNECTING -> "CONNECTING..." to NeonOrange + ConnectionState.CONNECTED -> "CONNECTED" to NeonGreen + ConnectionState.ERROR -> "ERROR" to NeonRed + } + Text( + text = statusText, + color = statusColor, + fontWeight = FontWeight.Bold, + fontSize = 13.sp + ) + } + } + + if (errorMsg.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = errorMsg, + color = NeonRed, + fontSize = 12.sp, + fontWeight = FontWeight.Medium + ) + } + + Spacer(modifier = Modifier.height(14.dp)) + + // Action Buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (connState != ConnectionState.CONNECTED && connState != ConnectionState.CONNECTING) { + Button( + onClick = onConnect, + colors = ButtonDefaults.buttonColors(containerColor = NeonOrange), + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(8.dp) + ) { + Text("CONNECT BT", fontWeight = FontWeight.Bold) + } + } else { + Button( + onClick = onDisconnect, + colors = ButtonDefaults.buttonColors(containerColor = BorderColor), + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(8.dp) + ) { + Text("DISCONNECT", fontWeight = FontWeight.Bold, color = TextWhite) + } + } + + Button( + onClick = onSimulate, + colors = ButtonDefaults.buttonColors(containerColor = BorderColor), + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(8.dp) + ) { + Text("SIMULATE", fontWeight = FontWeight.Bold, color = NeonCyan) + } + } + } + } +} + +@Composable +fun GridItemCard( + title: String, + value: String, + progress: Float, + progressColor: Color, + modifier: Modifier = Modifier +) { + Card( + shape = RoundedCornerShape(10.dp), + colors = CardDefaults.cardColors(containerColor = CardBg), + modifier = modifier + .border(1.dp, BorderColor, shape = RoundedCornerShape(10.dp)) + ) { + Column( + modifier = Modifier.padding(10.dp) + ) { + Text( + text = title, + color = TextMuted, + fontSize = 10.sp, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = value, + color = TextWhite, + fontSize = 18.sp, + fontWeight = FontWeight.ExtraBold + ) + Spacer(modifier = Modifier.height(6.dp)) + LinearProgressIndicator( + progress = { progress.coerceIn(0f, 1f) }, + color = progressColor, + trackColor = BorderColor, + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + ) + } + } +} + +@Composable +fun TroubleCodesCard(activeCodes: List) { + Card( + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = CardBg), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .border(1.dp, NeonRed, shape = RoundedCornerShape(12.dp)) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "⚠️ ACTIVE ECM FAULT CODES", + color = NeonRed, + fontWeight = FontWeight.Black, + fontSize = 14.sp + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + activeCodes.forEach { code -> + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .background(NeonRed, shape = RoundedCornerShape(4.dp)) + .padding(horizontal = 10.dp, vertical = 4.dp) + ) { + Text( + text = "CODE $code", + color = Color.White, + fontWeight = FontWeight.Bold, + fontSize = 12.sp + ) + } + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = TroubleCodeDictionary.getDescription(code), + color = TextWhite, + fontSize = 12.sp + ) + } + } + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun StatusFlagsPanel(frame: ALDLFrame) { + Card( + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = CardBg), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .border(1.dp, BorderColor, shape = RoundedCornerShape(12.dp)) + ) { + Column( + modifier = Modifier.padding(14.dp) + ) { + Text( + text = "LOOP STATUS & SYSTEM SWITCHES", + color = TextMuted, + fontSize = 12.sp, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(10.dp)) + + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.fillMaxWidth() + ) { + StatusBadge(label = "Closed Loop", active = frame.isClosedLoop, activeColor = NeonGreen) + StatusBadge(label = "Rich Mixture", active = frame.isRich, activeColor = NeonGreen) + StatusBadge(label = "BLM Enabled", active = frame.blmEnable, activeColor = NeonGreen) + StatusBadge(label = "TCC Locked", active = frame.isTccLocked, activeColor = NeonGreen) + StatusBadge(label = "AC Clutch", active = frame.isAcClutchEnabled, activeColor = NeonGreen) + StatusBadge(label = "Park/Neutral", active = frame.isParkNeutral, activeColor = NeonCyan) + StatusBadge(label = "A/C Request", active = frame.isAcEnabled, activeColor = NeonCyan) + StatusBadge(label = "PS Cramp", active = frame.isPowerSteeringCrampActive, activeColor = NeonOrange) + StatusBadge(label = "Async Pulse", active = frame.asyncPulse, activeColor = NeonOrange) + StatusBadge(label = "Quasi Pulse", active = frame.quasiPulse, activeColor = NeonOrange) + } + } + } +} + +@Composable +fun StatusBadge(label: String, active: Boolean, activeColor: Color) { + Box( + modifier = Modifier + .background( + color = if (active) activeColor.copy(alpha = 0.15f) else Color.Transparent, + shape = RoundedCornerShape(4.dp) + ) + .border( + width = 1.dp, + color = if (active) activeColor else BorderColor, + shape = RoundedCornerShape(4.dp) + ) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Text( + text = label, + color = if (active) activeColor else TextMuted, + fontSize = 11.sp, + fontWeight = FontWeight.Bold + ) + } +} diff --git a/app/src/main/java/com/example/esp32aldldashboard/ui/main/MainScreen.kt b/app/src/main/java/com/example/esp32aldldashboard/ui/main/MainScreen.kt index 7e68f17..26d603b 100644 --- a/app/src/main/java/com/example/esp32aldldashboard/ui/main/MainScreen.kt +++ b/app/src/main/java/com/example/esp32aldldashboard/ui/main/MainScreen.kt @@ -5,45 +5,24 @@ import android.content.pm.PackageManager import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.* import androidx.compose.runtime.* -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.navigation3.runtime.NavKey -import com.example.esp32aldldashboard.bluetooth.ConnectionState -import com.example.esp32aldldashboard.parser.ALDLFrame - -// Theme Colors -private val DarkBg = Color(0xFF0F0F12) -private val CardBg = Color(0xFF1B1B22) -private val BorderColor = Color(0xFF2E2E38) -private val NeonCyan = Color(0xFF00E5FF) -private val NeonRed = Color(0xFFFF3D00) -private val NeonGreen = Color(0xFF00E676) -private val NeonOrange = Color(0xFFFF9100) -private val TextWhite = Color(0xFFEEEEEE) -private val TextMuted = Color(0xFF9E9EAF) +import com.example.esp32aldldashboard.AldlApplication +import com.example.esp32aldldashboard.ui.charts.ChartsScreen +import com.example.esp32aldldashboard.ui.settings.SettingsScreen @Composable fun MainScreen( - onItemClick: (NavKey) -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -52,7 +31,6 @@ fun MainScreen( val connState by viewModel.connectionState.collectAsStateWithLifecycle() val frame by viewModel.latestFrame.collectAsStateWithLifecycle() val rawLog by viewModel.rawHexLog.collectAsStateWithLifecycle() - val errorMsg by viewModel.errorMessage.collectAsStateWithLifecycle() val isCelsius by viewModel.isCelsius.collectAsStateWithLifecycle() val permissionsLauncher = rememberLauncherForActivityResult( @@ -70,7 +48,8 @@ fun MainScreen( val requiredPermissions = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { arrayOf( Manifest.permission.BLUETOOTH_SCAN, - Manifest.permission.BLUETOOTH_CONNECT + Manifest.permission.BLUETOOTH_CONNECT, + Manifest.permission.POST_NOTIFICATIONS ) } else { arrayOf( @@ -90,623 +69,54 @@ fun MainScreen( } } - MainScreenContent( - connState = connState, - frame = frame, - rawLog = rawLog, - errorMsg = errorMsg, - isCelsius = isCelsius, - onConnect = onConnectClick, - onDisconnect = { viewModel.disconnect() }, - onSimulate = { viewModel.startSimulation() }, - onToggleUnit = { viewModel.toggleTemperatureUnit() }, - modifier = modifier - ) -} + var selectedTab by remember { mutableStateOf(0) } + val app = context.applicationContext as AldlApplication -@Composable -fun MainScreenContent( - connState: ConnectionState, - frame: ALDLFrame?, - rawLog: List, - errorMsg: String, - isCelsius: Boolean, - onConnect: () -> Unit, - onDisconnect: () -> Unit, - onSimulate: () -> Unit, - onToggleUnit: () -> Unit, - modifier: Modifier = Modifier -) { - Column( - modifier = modifier - .fillMaxSize() - .background(DarkBg) - .verticalScroll(rememberScrollState()) - ) { - // App Title Banner - Text( - text = "PONTIAC FIERO ALDL DASHBOARD", - color = NeonOrange, - fontSize = 18.sp, - fontWeight = FontWeight.ExtraBold, - letterSpacing = 1.5.sp, - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 12.dp) - ) - - // Connection Action Card - ConnectionCard( - connState = connState, - errorMsg = errorMsg, - isCelsius = isCelsius, - onConnect = onConnect, - onDisconnect = onDisconnect, - onSimulate = onSimulate, - onToggleUnit = onToggleUnit - ) - - Spacer(modifier = Modifier.height(12.dp)) - - // Telemetry Panels - if (frame != null) { - // Trouble Codes Card (Flashing if active) - if (frame.activeFaultCodes.isNotEmpty()) { - TroubleCodesCard(activeCodes = frame.activeFaultCodes) - Spacer(modifier = Modifier.height(12.dp)) - } - - // Key Metrics - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - MetricCard( - title = "ENGINE SPEED", - value = "${frame.engineSpeedRpm}", - unit = "RPM", - progress = frame.engineSpeedRpm / 6000f, - progressColor = NeonCyan, - modifier = Modifier.weight(1f) + Scaffold( + bottomBar = { + NavigationBar(containerColor = DarkBg, contentColor = NeonOrange) { + NavigationBarItem( + icon = { Icon(Icons.Default.Info, contentDescription = "Dashboard") }, + label = { Text("Dashboard") }, + selected = selectedTab == 0, + onClick = { selectedTab = 0 }, + colors = NavigationBarItemDefaults.colors(selectedIconColor = NeonCyan, unselectedIconColor = TextMuted) ) - MetricCard( - title = "VEHICLE SPEED", - value = "${frame.vehicleSpeedMPH}", - unit = "MPH", - progress = frame.vehicleSpeedMPH / 120f, - progressColor = NeonGreen, - modifier = Modifier.weight(1f) + NavigationBarItem( + icon = { Icon(Icons.Default.Build, contentDescription = "Charts") }, + label = { Text("Charts") }, + selected = selectedTab == 1, + onClick = { selectedTab = 1 }, + colors = NavigationBarItemDefaults.colors(selectedIconColor = NeonCyan, unselectedIconColor = TextMuted) ) - } - - Spacer(modifier = Modifier.height(12.dp)) - - // Grid of Minor Telemetry Parameters - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - // Coolant and Intake Temp Row - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - val coolantVal = if (isCelsius) frame.coolantTempC else frame.coolantTempF - val coolantUnit = if (isCelsius) "°C" else "°F" - val coolantProgress = (coolantVal + 40) / 290f // normalized range - - val matVal = if (isCelsius) frame.matC else frame.matF - val matProgress = (matVal + 40) / 290f - - GridItemCard( - title = "COOLANT TEMP", - value = String.format("%.1f", coolantVal) + coolantUnit, - progress = coolantProgress, - progressColor = if (coolantVal > 210) NeonRed else NeonGreen, - modifier = Modifier.weight(1f) - ) - GridItemCard( - title = "MAT (AIR TEMP)", - value = String.format("%.1f", matVal) + coolantUnit, - progress = matProgress, - progressColor = NeonCyan, - modifier = Modifier.weight(1f) - ) - } - - // Fuel control row - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - GridItemCard( - title = "BPW (INJECTOR)", - value = String.format("%.3f ms", frame.bpwMs), - progress = frame.bpwMs / 15f, - progressColor = NeonCyan, - modifier = Modifier.weight(1f) - ) - GridItemCard( - title = "O2 SENSOR", - value = "${frame.o2SensorMv.toInt()} mV", - progress = frame.o2SensorMv / 1000f, - progressColor = if (frame.isRich) NeonGreen else NeonOrange, - modifier = Modifier.weight(1f) - ) - } - - // Air flow & Throttle Position - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - GridItemCard( - title = "TPS (THROTTLE)", - value = String.format("%.2f V", frame.tpsVolts), - progress = frame.tpsVolts / 5.0f, - progressColor = NeonGreen, - modifier = Modifier.weight(1f) - ) - GridItemCard( - title = "MAP (VACUUM)", - value = String.format("%.1f kPa", frame.mapKpa), - progress = frame.mapKpa / 105f, - progressColor = NeonCyan, - modifier = Modifier.weight(1f) - ) - } - - // Fuel trims (BLM & INT) & Battery - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - GridItemCard( - title = "BLM / INT", - value = "${frame.blm} / ${frame.integrator}", - progress = frame.blm / 256f, - progressColor = if (frame.blm in 120..136) NeonGreen else NeonOrange, - modifier = Modifier.weight(1f) - ) - GridItemCard( - title = "BATTERY VOLTS", - value = String.format("%.1f V", frame.batteryVolts), - progress = (frame.batteryVolts - 8) / 8f, - progressColor = if (frame.batteryVolts < 12.0f) NeonRed else NeonGreen, - modifier = Modifier.weight(1f) - ) - } - - // IAC, Spark & EGR - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - GridItemCard( - title = "IAC POSITION", - value = "${frame.iacPosition} Steps", - progress = frame.iacPosition / 160f, - progressColor = NeonCyan, - modifier = Modifier.weight(1f) - ) - GridItemCard( - title = "SPARK / EGR", - value = String.format("%.1f° / %.0f%%", frame.sparkAdvance, frame.egrDutyCycle), - progress = frame.egrDutyCycle / 100f, - progressColor = NeonCyan, - modifier = Modifier.weight(1f) - ) - } - } - - Spacer(modifier = Modifier.height(16.dp)) - - // Status Badges Section - StatusFlagsPanel(frame = frame) - - Spacer(modifier = Modifier.height(16.dp)) - } else { - // Empty / Waiting display - Box( - modifier = Modifier - .fillMaxWidth() - .height(260.dp) - .padding(horizontal = 16.dp) - .background(CardBg, shape = RoundedCornerShape(12.dp)) - .border(1.dp, BorderColor, shape = RoundedCornerShape(12.dp)), - contentAlignment = Alignment.Center - ) { - Text( - text = "Waiting for ALDL Stream data...\nSelect Connection or Simulation above.", - color = TextMuted, - fontSize = 14.sp, - textAlign = TextAlign.Center + NavigationBarItem( + icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") }, + label = { Text("Settings") }, + selected = selectedTab == 2, + onClick = { selectedTab = 2 }, + colors = NavigationBarItemDefaults.colors(selectedIconColor = NeonCyan, unselectedIconColor = TextMuted) ) } - Spacer(modifier = Modifier.height(16.dp)) - } - - // Live Diagnostic Console log - DiagnosticConsole(rawLog = rawLog) - - Spacer(modifier = Modifier.height(24.dp)) - } -} - -@Composable -fun ConnectionCard( - connState: ConnectionState, - errorMsg: String, - isCelsius: Boolean, - onConnect: () -> Unit, - onDisconnect: () -> Unit, - onSimulate: () -> Unit, - onToggleUnit: () -> Unit -) { - Card( - shape = RoundedCornerShape(12.dp), - colors = CardDefaults.cardColors(containerColor = CardBg), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .border(1.dp, BorderColor, shape = RoundedCornerShape(12.dp)) - ) { - Column( - modifier = Modifier.padding(16.dp) - ) { - // Status bar - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = "STATUS: ", - color = TextMuted, - fontWeight = FontWeight.Bold, - fontSize = 13.sp - ) - val (statusText, statusColor) = when (connState) { - ConnectionState.DISCONNECTED -> "DISCONNECTED" to TextMuted - ConnectionState.CONNECTING -> "CONNECTING..." to NeonOrange - ConnectionState.CONNECTED -> "CONNECTED" to NeonGreen - ConnectionState.ERROR -> "ERROR" to NeonRed - } - Text( - text = statusText, - color = statusColor, - fontWeight = FontWeight.Bold, - fontSize = 13.sp - ) - } - - // Temperature Toggle Button - Button( - onClick = onToggleUnit, - colors = ButtonDefaults.buttonColors(containerColor = BorderColor), - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), - shape = RoundedCornerShape(6.dp), - modifier = Modifier.height(28.dp) - ) { - Text( - text = if (isCelsius) "USE °F" else "USE °C", - color = TextWhite, - fontSize = 11.sp, - fontWeight = FontWeight.Bold - ) - } - } - - if (errorMsg.isNotEmpty()) { - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = errorMsg, - color = NeonRed, - fontSize = 12.sp, - fontWeight = FontWeight.Medium - ) - } - - Spacer(modifier = Modifier.height(14.dp)) - - // Action Buttons - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - if (connState != ConnectionState.CONNECTED && connState != ConnectionState.CONNECTING) { - Button( - onClick = onConnect, - colors = ButtonDefaults.buttonColors(containerColor = NeonOrange), - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(8.dp) - ) { - Text("CONNECT BT", fontWeight = FontWeight.Bold) - } - } else { - Button( - onClick = onDisconnect, - colors = ButtonDefaults.buttonColors(containerColor = BorderColor), - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(8.dp) - ) { - Text("DISCONNECT", fontWeight = FontWeight.Bold, color = TextWhite) - } - } - - Button( - onClick = onSimulate, - colors = ButtonDefaults.buttonColors(containerColor = BorderColor), - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(8.dp) - ) { - Text("SIMULATE", fontWeight = FontWeight.Bold, color = NeonCyan) - } - } } - } -} - -@Composable -fun MetricCard( - title: String, - value: String, - unit: String, - progress: Float, - progressColor: Color, - modifier: Modifier = Modifier -) { - Card( - shape = RoundedCornerShape(12.dp), - colors = CardDefaults.cardColors(containerColor = CardBg), - modifier = modifier - .border(1.dp, BorderColor, shape = RoundedCornerShape(12.dp)) - ) { - Column( - modifier = Modifier.padding(14.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = title, - color = TextMuted, - fontSize = 11.sp, - fontWeight = FontWeight.Bold + ) { paddingValues -> + when (selectedTab) { + 0 -> DashboardScreen( + connState = connState, + frame = frame, + isCelsius = isCelsius, + onConnect = onConnectClick, + onDisconnect = { viewModel.disconnect() }, + onSimulate = { viewModel.startSimulation() }, + modifier = modifier.padding(paddingValues) ) - Spacer(modifier = Modifier.height(4.dp)) - Row( - verticalAlignment = Alignment.Bottom - ) { - Text( - text = value, - color = TextWhite, - fontSize = 28.sp, - fontWeight = FontWeight.Black - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = unit, - color = TextMuted, - fontSize = 12.sp, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(bottom = 4.dp) - ) - } - Spacer(modifier = Modifier.height(8.dp)) - LinearProgressIndicator( - progress = progress.coerceIn(0f, 1f), - color = progressColor, - trackColor = BorderColor, - modifier = Modifier - .fillMaxWidth() - .height(6.dp) + 1 -> ChartsScreen( + latestFrameFlow = viewModel.latestFrame, + modifier = modifier.padding(paddingValues) + ) + 2 -> SettingsScreen( + settingsRepository = app.settingsRepository, + modifier = modifier.padding(paddingValues) ) } } } - -@Composable -fun GridItemCard( - title: String, - value: String, - progress: Float, - progressColor: Color, - modifier: Modifier = Modifier -) { - Card( - shape = RoundedCornerShape(10.dp), - colors = CardDefaults.cardColors(containerColor = CardBg), - modifier = modifier - .border(1.dp, BorderColor, shape = RoundedCornerShape(10.dp)) - ) { - Column( - modifier = Modifier.padding(10.dp) - ) { - Text( - text = title, - color = TextMuted, - fontSize = 10.sp, - fontWeight = FontWeight.Bold - ) - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = value, - color = TextWhite, - fontSize = 18.sp, - fontWeight = FontWeight.ExtraBold - ) - Spacer(modifier = Modifier.height(6.dp)) - LinearProgressIndicator( - progress = progress.coerceIn(0f, 1f), - color = progressColor, - trackColor = BorderColor, - modifier = Modifier - .fillMaxWidth() - .height(4.dp) - ) - } - } -} - -@Composable -fun TroubleCodesCard(activeCodes: List) { - Card( - shape = RoundedCornerShape(12.dp), - colors = CardDefaults.cardColors(containerColor = CardBg), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .border(1.dp, NeonRed, shape = RoundedCornerShape(12.dp)) - ) { - Column( - modifier = Modifier.padding(16.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "⚠️ ACTIVE ECM FAULT CODES", - color = NeonRed, - fontWeight = FontWeight.Black, - fontSize = 14.sp - ) - } - Spacer(modifier = Modifier.height(8.dp)) - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - activeCodes.forEach { code -> - Box( - modifier = Modifier - .background(NeonRed, shape = RoundedCornerShape(4.dp)) - .padding(horizontal = 10.dp, vertical = 4.dp) - ) { - Text( - text = "CODE $code", - color = Color.White, - fontWeight = FontWeight.Bold, - fontSize = 12.sp - ) - } - } - } - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "Refer to Fiero shop manual for diagnosis.", - color = TextMuted, - fontSize = 11.sp - ) - } - } -} - -@OptIn(ExperimentalLayoutApi::class) -@Composable -fun StatusFlagsPanel(frame: ALDLFrame) { - Card( - shape = RoundedCornerShape(12.dp), - colors = CardDefaults.cardColors(containerColor = CardBg), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .border(1.dp, BorderColor, shape = RoundedCornerShape(12.dp)) - ) { - Column( - modifier = Modifier.padding(14.dp) - ) { - Text( - text = "LOOP STATUS & SYSTEM SWITCHES", - color = TextMuted, - fontSize = 12.sp, - fontWeight = FontWeight.Bold - ) - Spacer(modifier = Modifier.height(10.dp)) - - FlowRow( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - modifier = Modifier.fillMaxWidth() - ) { - StatusBadge(label = "Closed Loop", active = frame.isClosedLoop, activeColor = NeonGreen) - StatusBadge(label = "Rich Mixture", active = frame.isRich, activeColor = NeonGreen) - StatusBadge(label = "BLM Enabled", active = frame.blmEnable, activeColor = NeonGreen) - StatusBadge(label = "TCC Locked", active = frame.isTccLocked, activeColor = NeonGreen) - StatusBadge(label = "AC Clutch", active = frame.isAcClutchEnabled, activeColor = NeonGreen) - StatusBadge(label = "Park/Neutral", active = frame.isParkNeutral, activeColor = NeonCyan) - StatusBadge(label = "A/C Request", active = frame.isAcEnabled, activeColor = NeonCyan) - StatusBadge(label = "PS Cramp", active = frame.isPowerSteeringCrampActive, activeColor = NeonOrange) - StatusBadge(label = "Async Pulse", active = frame.asyncPulse, activeColor = NeonOrange) - StatusBadge(label = "Quasi Pulse", active = frame.quasiPulse, activeColor = NeonOrange) - } - } - } -} - -@Composable -fun StatusBadge(label: String, active: Boolean, activeColor: Color) { - Box( - modifier = Modifier - .background( - color = if (active) activeColor.copy(alpha = 0.15f) else Color.Transparent, - shape = RoundedCornerShape(4.dp) - ) - .border( - width = 1.dp, - color = if (active) activeColor else BorderColor, - shape = RoundedCornerShape(4.dp) - ) - .padding(horizontal = 8.dp, vertical = 4.dp) - ) { - Text( - text = label, - color = if (active) activeColor else TextMuted, - fontSize = 11.sp, - fontWeight = FontWeight.Bold - ) - } -} - -@Composable -fun DiagnosticConsole(rawLog: List) { - Card( - shape = RoundedCornerShape(12.dp), - colors = CardDefaults.cardColors(containerColor = CardBg), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .border(1.dp, BorderColor, shape = RoundedCornerShape(12.dp)) - ) { - Column( - modifier = Modifier.padding(14.dp) - ) { - Text( - text = "DIAGNOSTIC TELEMETRY STREAM LOG", - color = TextMuted, - fontSize = 11.sp, - fontWeight = FontWeight.Bold - ) - Spacer(modifier = Modifier.height(8.dp)) - Box( - modifier = Modifier - .fillMaxWidth() - .height(100.dp) - .background(Color.Black, shape = RoundedCornerShape(6.dp)) - .padding(8.dp) - ) { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - ) { - if (rawLog.isEmpty()) { - Text( - text = "Console Idle. Connect to view hex dump...", - color = TextMuted, - fontFamily = FontFamily.Monospace, - fontSize = 10.sp - ) - } else { - rawLog.forEach { logLine -> - Text( - text = logLine, - color = NeonGreen, - fontFamily = FontFamily.Monospace, - fontSize = 10.sp - ) - } - } - } - } - } - } -} diff --git a/app/src/main/java/com/example/esp32aldldashboard/ui/main/MainScreenViewModel.kt b/app/src/main/java/com/example/esp32aldldashboard/ui/main/MainScreenViewModel.kt index 6ac861c..f00106e 100644 --- a/app/src/main/java/com/example/esp32aldldashboard/ui/main/MainScreenViewModel.kt +++ b/app/src/main/java/com/example/esp32aldldashboard/ui/main/MainScreenViewModel.kt @@ -16,6 +16,10 @@ class MainScreenViewModel(context: Context) : ViewModel() { val rawHexLog: StateFlow> = bluetoothService.rawHexLog val errorMessage: StateFlow = bluetoothService.errorMessage + val framesReceived: StateFlow = bluetoothService.framesReceived + val parseErrors: StateFlow = bluetoothService.parseErrors + val currentFrameRate: StateFlow = bluetoothService.currentFrameRate + private val _isCelsius = MutableStateFlow(false) // Default to Fahrenheit for standard 80s GM telemetry val isCelsius: StateFlow = _isCelsius diff --git a/app/src/main/java/com/example/esp32aldldashboard/ui/settings/SettingsScreen.kt b/app/src/main/java/com/example/esp32aldldashboard/ui/settings/SettingsScreen.kt new file mode 100644 index 0000000..c319470 --- /dev/null +++ b/app/src/main/java/com/example/esp32aldldashboard/ui/settings/SettingsScreen.kt @@ -0,0 +1,84 @@ +package com.example.esp32aldldashboard.ui.settings + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.example.esp32aldldashboard.repository.SettingsRepository +import kotlinx.coroutines.launch + +@Composable +fun SettingsScreen( + settingsRepository: SettingsRepository, + modifier: Modifier = Modifier +) { + val isCelsius by settingsRepository.isCelsiusFlow.collectAsStateWithLifecycle(initialValue = false) + val autoLogging by settingsRepository.autoLoggingFlow.collectAsStateWithLifecycle(initialValue = false) + val coolantThreshold by settingsRepository.coolantAlertThresholdFlow.collectAsStateWithLifecycle(initialValue = 100f) + + val coroutineScope = rememberCoroutineScope() + + Column(modifier = modifier.fillMaxSize().padding(16.dp)) { + Text( + text = "Settings & Alerts", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground + ) + Spacer(modifier = Modifier.height(24.dp)) + + // Temperature Unit Toggle + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column { + Text(text = "Temperature Unit", style = MaterialTheme.typography.titleMedium) + Text(text = if (isCelsius) "Celsius (°C)" else "Fahrenheit (°F)", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Switch( + checked = isCelsius, + onCheckedChange = { + coroutineScope.launch { settingsRepository.setIsCelsius(it) } + } + ) + } + Divider(modifier = Modifier.padding(vertical = 8.dp)) + + // Auto Logging Toggle + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column { + Text(text = "Auto-Log Sessions", style = MaterialTheme.typography.titleMedium) + Text(text = "Automatically save CSV and database records.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Switch( + checked = autoLogging, + onCheckedChange = { + coroutineScope.launch { settingsRepository.setAutoLogging(it) } + } + ) + } + Divider(modifier = Modifier.padding(vertical = 8.dp)) + + // Coolant Alert Threshold + Column(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) { + Text(text = "Coolant Alert Threshold", style = MaterialTheme.typography.titleMedium) + Text(text = "Trigger notification when coolant exceeds ${coolantThreshold.toInt()}°C", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Slider( + value = coolantThreshold, + onValueChange = { + coroutineScope.launch { settingsRepository.setCoolantAlertThreshold(it) } + }, + valueRange = 80f..150f, + steps = 70 + ) + } + } +} diff --git a/app/src/test/java/com/example/esp32aldldashboard/ALDLParserTest.kt b/app/src/test/java/com/example/esp32aldldashboard/ALDLParserTest.kt index 97fd9c9..7649e3d 100644 --- a/app/src/test/java/com/example/esp32aldldashboard/ALDLParserTest.kt +++ b/app/src/test/java/com/example/esp32aldldashboard/ALDLParserTest.kt @@ -19,9 +19,9 @@ class ALDLParserTest { 0x00.toByte(), 0x00.toByte(), 0xC9.toByte(), 0x02.toByte(), 0x62.toByte() ) - val frame = ALDLParser.parseFrame(rawPayload) - assertNotNull(frame) - frame!! + val result = ALDLParser.parseFrame(rawPayload) + assertTrue(result is com.example.esp32aldldashboard.parser.ALDLParseResult.Success) + val frame = (result as com.example.esp32aldldashboard.parser.ALDLParseResult.Success).frame // Assert values based on 24-INT10.ads specifications: assertEquals(95, frame.iacPosition) // u[3] (Byte 4) diff --git a/app/src/test/java/com/example/esp32aldldashboard/RingBufferTest.kt b/app/src/test/java/com/example/esp32aldldashboard/RingBufferTest.kt new file mode 100644 index 0000000..4ed3eb5 --- /dev/null +++ b/app/src/test/java/com/example/esp32aldldashboard/RingBufferTest.kt @@ -0,0 +1,27 @@ +package com.example.esp32aldldashboard.parser + +import org.junit.Assert.* +import org.junit.Test + +class RingBufferTest { + + @Test + fun testALDLParseResultSafety() { + val validPayload = ByteArray(25) { 0 } + // Coolant = 0 (0 * 0.75 - 40 = -40 C) -> Valid + // Battery = 120 (120 * 0.1 = 12 V) -> Valid + validPayload[17] = 120.toByte() + + val result = ALDLParser.parseFrame(validPayload) + assertTrue("Expected valid parsing", result is ALDLParseResult.Success) + + val invalidBatteryPayload = validPayload.clone() + invalidBatteryPayload[17] = 250.toByte() // 250 * 0.1 = 25 V -> Invalid (> 20V) + val resultInvalid = ALDLParser.parseFrame(invalidBatteryPayload) + assertTrue("Expected invalid parsing", resultInvalid is ALDLParseResult.InvalidData) + + val incompletePayload = ByteArray(10) + val resultIncomplete = ALDLParser.parseFrame(incompletePayload) + assertTrue("Expected incomplete parsing", resultIncomplete is ALDLParseResult.Incomplete) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 660fd3a..8616f5b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,12 +13,15 @@ junit = "4.13.2" kotlin = "2.3.20" nav3Core = "1.0.1" lifecycleViewmodelNav3 = "2.10.0" +room = "2.6.1" +datastore = "1.1.1" [libraries] androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCore" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidxActivity" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "androidxComposeBom" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3"} +androidx-compose-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" } androidx-compose-ui = { group = "androidx.compose.ui", name = "ui"} androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview"} androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4"} @@ -36,6 +39,10 @@ kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-t androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" } androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" } androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNav3" } +androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } +androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } +androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } +androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } From 5383c5bd117487ca1c5a1a5e6661bb5ab83e966e Mon Sep 17 00:00:00 2001 From: Gandalf Date: Fri, 12 Jun 2026 00:17:55 +0100 Subject: [PATCH 02/18] chore: downgrade Kotlin to 2.1.0, add KSP support, and migrate Room to KSP --- app/build.gradle.kts | 3 +- .../esp32aldldashboard/AldlApplication.kt | 8 +- .../data/database/TelemetryDao.kt | 9 +- build.gradle.kts | 1 + gradle.properties | 1 + gradle/libs.versions.toml | 6 +- ksp_versions.xml | 169 ++++++++++++++++++ 7 files changed, 188 insertions(+), 9 deletions(-) create mode 100644 ksp_versions.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f40828d..b82f87c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -2,6 +2,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.compose.compiler) alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.ksp) } android { @@ -86,7 +87,7 @@ dependencies { // Room implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.ktx) - annotationProcessor(libs.androidx.room.compiler) + ksp(libs.androidx.room.compiler) // DataStore implementation(libs.androidx.datastore.preferences) diff --git a/app/src/main/java/com/example/esp32aldldashboard/AldlApplication.kt b/app/src/main/java/com/example/esp32aldldashboard/AldlApplication.kt index 09d881b..b222eb2 100644 --- a/app/src/main/java/com/example/esp32aldldashboard/AldlApplication.kt +++ b/app/src/main/java/com/example/esp32aldldashboard/AldlApplication.kt @@ -4,19 +4,21 @@ import android.app.Application import com.example.esp32aldldashboard.bluetooth.BluetoothService import com.example.esp32aldldashboard.repository.SettingsRepository import com.example.esp32aldldashboard.repository.TelemetryRepository +import com.example.esp32aldldashboard.logging.CsvLogger +import com.example.esp32aldldashboard.data.database.TelemetryDatabase class AldlApplication : Application() { lateinit var bluetoothService: BluetoothService lateinit var telemetryRepository: TelemetryRepository lateinit var settingsRepository: SettingsRepository - lateinit var csvLogger: com.example.esp32aldldashboard.logging.CsvLogger + lateinit var csvLogger: CsvLogger override fun onCreate() { super.onCreate() - val database = com.example.esp32aldldashboard.data.database.TelemetryDatabase.getDatabase(this) + val database = TelemetryDatabase.getDatabase(this) settingsRepository = SettingsRepository(this) - csvLogger = com.example.esp32aldldashboard.logging.CsvLogger(this) + csvLogger = CsvLogger(this) bluetoothService = BluetoothService(this) telemetryRepository = TelemetryRepository( bluetoothService, diff --git a/app/src/main/java/com/example/esp32aldldashboard/data/database/TelemetryDao.kt b/app/src/main/java/com/example/esp32aldldashboard/data/database/TelemetryDao.kt index 102d191..2cbc0c0 100644 --- a/app/src/main/java/com/example/esp32aldldashboard/data/database/TelemetryDao.kt +++ b/app/src/main/java/com/example/esp32aldldashboard/data/database/TelemetryDao.kt @@ -5,17 +5,20 @@ import androidx.room.Insert import androidx.room.Query import kotlinx.coroutines.flow.Flow +import kotlin.jvm.JvmSuppressWildcards + @Dao +@JvmSuppressWildcards 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) + suspend fun endSession(sessionId: Long, endTime: Long): Int @Insert - suspend fun insertDataPoints(dataPoints: List) + suspend fun insertDataPoints(dataPoints: List): List @Query("SELECT * FROM sessions ORDER BY startTime DESC") fun getAllSessions(): Flow> @@ -24,5 +27,5 @@ interface TelemetryDao { fun getSessionData(sessionId: Long): Flow> @Query("DELETE FROM sessions WHERE id = :sessionId") - suspend fun deleteSession(sessionId: Long) + suspend fun deleteSession(sessionId: Long): Int } diff --git a/build.gradle.kts b/build.gradle.kts index bb92a46..9903cc3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,4 +3,5 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.compose.compiler) apply false alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.ksp) apply false } \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 32d72a9..419e240 100644 --- a/gradle.properties +++ b/gradle.properties @@ -27,3 +27,4 @@ kotlin.code.style=official # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true +android.disallowKotlinSourceSets=false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8616f5b..99f7125 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,11 +10,12 @@ androidxTestRunner = "1.7.0" androidxTestEspresso = "3.7.0" coroutines = "1.10.2" junit = "4.13.2" -kotlin = "2.3.20" +kotlin = "2.1.0" nav3Core = "1.0.1" lifecycleViewmodelNav3 = "2.10.0" room = "2.6.1" datastore = "1.1.1" +ksp = "2.1.0-1.0.29" [libraries] androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCore" } @@ -47,4 +48,5 @@ androidx-datastore-preferences = { module = "androidx.datastore:datastore-prefer [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } -kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } \ No newline at end of file +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } \ No newline at end of file diff --git a/ksp_versions.xml b/ksp_versions.xml new file mode 100644 index 0000000..0b3839f --- /dev/null +++ b/ksp_versions.xml @@ -0,0 +1,169 @@ + + + com.google.devtools.ksp + com.google.devtools.ksp.gradle.plugin + + 2.3.9 + 2.3.9 + + 1.5.21-1.0.0-beta07 + 1.5.30-1.0.0-beta08 + 1.5.30-1.0.0-beta09 + 1.5.30-1.0.0 + 1.5.31-1.0.0 + 1.5.31-1.0.1 + 1.6.0-M1-1.0.0 + 1.6.0-RC-1.0.1-RC + 1.6.0-RC-1.0.0 + 1.6.0-1.0.1 + 1.6.0-1.0.2 + 1.6.10-RC-1.0.1 + 1.6.10-1.0.2 + 1.6.10-1.0.3 + 1.6.10-1.0.4 + 1.6.20-M1-1.0.2 + 1.6.20-RC-1.0.4 + 1.6.20-RC2-1.0.4 + 1.6.20-1.0.4 + 1.6.20-1.0.5 + 1.6.21-1.0.5 + 1.6.21-1.0.6 + 1.7.0-Beta-1.0.5 + 1.7.0-RC-1.0.5 + 1.7.0-RC2-1.0.5 + 1.7.0-1.0.6 + 1.7.10-1.0.6 + 1.7.20-Beta-1.0.6 + 1.7.20-RC-1.0.6 + 1.7.20-1.0.6 + 1.7.20-1.0.7 + 1.7.20-1.0.8 + 1.7.21-1.0.8 + 1.7.22-1.0.8 + 1.8.0-Beta-1.0.8 + 1.8.0-RC-1.0.8 + 1.8.0-RC2-1.0.8 + 1.8.0-1.0.8 + 1.8.0-1.0.9 + 1.8.10-1.0.9 + 1.8.20-Beta-1.0.9 + 1.8.20-RC-1.0.9 + 1.8.20-RC2-1.0.9 + 1.8.20-1.0.10 + 1.8.20-1.0.11 + 1.8.21-1.0.11 + 1.8.22-1.0.11 + 1.9.0-Beta-1.0.11 + 1.9.0-RC-1.0.11 + 1.9.0-1.0.11 + 1.9.0-1.0.12 + 1.9.0-1.0.13 + 1.9.10-1.0.13 + 1.9.20-Beta-1.0.13 + 1.9.20-Beta2-1.0.13 + 1.9.20-RC-1.0.13 + 1.9.20-RC2-1.0.13 + 1.9.20-1.0.13 + 1.9.20-1.0.14 + 1.9.21-1.0.15 + 1.9.21-1.0.16 + 1.9.22-1.0.16 + 1.9.22-1.0.17 + 1.9.22-1.0.18 + 1.9.23-1.0.19 + 1.9.23-1.0.20 + 1.9.24-1.0.20 + 1.9.25-1.0.20 + 2.0.0-Beta1-1.0.15 + 2.0.0-Beta1-1.0.14 + 2.0.0-Beta2-1.0.16 + 2.0.0-Beta3-1.0.17 + 2.0.0-Beta4-1.0.19 + 2.0.0-Beta4-1.0.18 + 2.0.0-Beta4-1.0.17 + 2.0.0-Beta5-1.0.20 + 2.0.0-Beta5-1.0.19 + 2.0.0-RC1-1.0.20 + 2.0.0-RC2-1.0.20 + 2.0.0-RC3-1.0.20 + 2.0.0-1.0.21 + 2.0.0-1.0.22 + 2.0.0-1.0.23 + 2.0.0-1.0.24 + 2.0.10-RC-1.0.23 + 2.0.10-RC2-1.0.24 + 2.0.10-1.0.24 + 2.0.20-Beta1-1.0.22 + 2.0.20-Beta2-1.0.23 + 2.0.20-RC-1.0.24 + 2.0.20-RC2-1.0.24 + 2.0.20-1.0.24 + 2.0.20-1.0.25 + 2.0.21-RC-1.0.25 + 2.0.21-1.0.25 + 2.0.21-1.0.26 + 2.0.21-1.0.27 + 2.0.21-1.0.28 + 2.1.0-Beta1-1.0.25 + 2.1.0-Beta2-1.0.26 + 2.1.0-Beta2-1.0.25 + 2.1.0-RC-1.0.26 + 2.1.0-RC-1.0.27 + 2.1.0-RC2-1.0.28 + 2.1.0-1.0.28 + 2.1.0-1.0.29 + 2.1.10-RC-1.0.29 + 2.1.10-RC2-1.0.29 + 2.1.10-1.0.29 + 2.1.10-1.0.30 + 2.1.10-1.0.31 + 2.1.20-Beta1-1.0.29 + 2.1.20-Beta2-1.0.30 + 2.1.20-Beta2-1.0.29 + 2.1.20-RC-1.0.30 + 2.1.20-RC-1.0.31 + 2.1.20-RC2-1.0.31 + 2.1.20-RC3-1.0.31 + 2.1.20-1.0.31 + 2.1.20-1.0.32 + 2.1.20-2.0.0 + 2.1.20-2.0.1 + 2.1.21-RC-2.0.0 + 2.1.21-RC2-2.0.1 + 2.1.21-2.0.1 + 2.1.21-2.0.2 + 2.2.0-Beta1-2.0.0 + 2.2.0-Beta2-2.0.1 + 2.2.0-RC-2.0.1 + 2.2.0-RC2-2.0.1 + 2.2.0-RC2-2.0.2 + 2.2.0-RC3-2.0.2 + 2.2.0-2.0.2 + 2.2.10-RC-2.0.2 + 2.2.10-RC2-2.0.2 + 2.2.10-2.0.2 + 2.2.20-Beta1-2.0.2 + 2.2.20-Beta2-2.0.2 + 2.2.20-RC-2.0.2 + 2.2.20-RC2-2.0.2 + 2.2.20-2.0.2 + 2.2.20-2.0.3 + 2.2.20-2.0.4 + 2.2.21-RC-2.0.4 + 2.2.21-RC2-2.0.4 + 2.2.21-2.0.4 + 2.2.21-2.0.5 + 2.3.0 + 2.3.1 + 2.3.2 + 2.3.3 + 2.3.4 + 2.3.5 + 2.3.6 + 2.3.7 + 2.3.8 + 2.3.9 + + 20260526194322 + + From ae997da8491937a02f023d2c930a867de0c02271 Mon Sep 17 00:00:00 2001 From: Gandalf Date: Fri, 12 Jun 2026 09:11:09 +0100 Subject: [PATCH 03/18] feat: add 24_INT10_mod.adx configuration and initialize project planning mode --- .idea/planningMode.xml | 10 + 24_INT10_mod.adx | 786 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 796 insertions(+) create mode 100644 .idea/planningMode.xml create mode 100644 24_INT10_mod.adx diff --git a/.idea/planningMode.xml b/.idea/planningMode.xml new file mode 100644 index 0000000..0982938 --- /dev/null +++ b/.idea/planningMode.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/24_INT10_mod.adx b/24_INT10_mod.adx new file mode 100644 index 0000000..7b67c25 --- /dev/null +++ b/24_INT10_mod.adx @@ -0,0 +1,786 @@ + + + + 2c5735e5-97cf-4284-b768-67c83cc3bac1 + 0x10001 + 55 + 2 + Gordon Bolton + Credit to Robert Saar - modified to reference byte 9 for INT, and added AA 55 header per ESP32-ALDL code - https://git.i3omb.com/gronod/ESP32-ALDL + 4800 + + SMART + + + + 0x00FFFFFF + 0x00F0F0F0 + 0x00000000 + 0x00000000 + 0x00000000 + 0 + + + + 6 + + + + + + + + + + 400 + 25 + 2 + 27 + AA55 + + + + <Comments> + 1 + 3 + 0 + 39 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + <Comments> + 1 + 3 + 0 + 39 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0xD05BBA9A + MPH + 0x05 + + + 2 + 1 + + + + + + + 0xD05BBA9A + RPM + 0x07 + + + 2 + 3 + + + + + + + 0xD05BBA9A + Volts + 0x08 + + + 2 + 3 + + + + + + + 0xD05BBA9A + C + 0x04 + + + 2 + 3 + + + + + + + 0xD05BBA9A + F + 0x04 + + + 2 + 3 + + + + + + + 0xD05BBA9A + C + 0x16 + + + 2 + 3 + + + + + + + 0xD05BBA9A + F + 0x16 + + + 2 + 3 + + + + + + + 0xD05BBA9A + Volts + 0x06 + + + 2 + 3 + + + + + + + 0xD05BBA9A + kPa + 0x06 + + + 2 + 3 + + + + + + + 0xD05BBA9A + 0x12 + + + 2 + 1 + + + + + + + 0xD05BBA9A + 0x09 + + + 2 + 1 + + + + + + + 0xD05BBA9A + mV + 0x0A + + + 2 + 3 + + + + + + + 0xD05BBA9A + Crosses + 0x13 + + + 2 + 1 + + + + + + + 0xD05BBA9A + mS + 0x17 + 16 + + + 2 + 3 + + + + + + + 0xD05BBA9A + % + 0x15 + + + 2 + 3 + + + + + + + 0xD05BBA9A + * + 0x14 + + + 2 + 3 + + + + + + + 0xD05BBA9A + Volts + 0x11 + + + 2 + 3 + + + + + + + 0xD05BBA9A + Steps + 0x03 + + + 2 + 1 + + + + + + + 0x00000004 + 0xD05BBA9A + ERROR + + + 0x0B + 0x00000080 + AND + 0x00000080 + + + + 0x00000004 + 0xD05BBA9A + ERROR + + + 0x0B + 0x00000040 + AND + 0x00000040 + + + + 0x00000004 + 0xD05BBA9A + ERROR + + + 0x0B + 0x00000020 + AND + 0x00000020 + + + + 0x00000004 + 0xD05BBA9A + ERROR + + + 0x0B + 0x00000010 + AND + 0x00000010 + + + + 0x00000004 + 0xD05BBA9A + ERROR + + + 0x0B + 0x00000008 + AND + 0x00000008 + + + + 0x00000004 + 0xD05BBA9A + ERROR + + + 0x0B + 0x00000004 + AND + 0x00000004 + + + + 0x00000004 + 0xD05BBA9A + ERROR + + + 0x0B + 0x00000002 + AND + 0x00000002 + + + + 0x00000004 + 0xD05BBA9A + ERROR + + + 0x0B + 0x00000001 + AND + 0x00000001 + + + + 0x00000004 + 0xD05BBA9A + ERROR + + + 0x0C + 0x00000080 + AND + 0x00000080 + + + + 0x00000004 + 0xD05BBA9A + ERROR + + + 0x0C + 0x00000020 + AND + 0x00000020 + + + + 0x00000004 + 0xD05BBA9A + ERROR + + + 0x0C + 0x00000010 + AND + 0x00000010 + + + + 0x00000004 + 0xD05BBA9A + ERROR + + + 0x0C + 0x00000008 + AND + 0x00000008 + + + + 0x00000004 + 0xD05BBA9A + ERROR + + + 0x0C + 0x00000004 + AND + 0x00000004 + + + + 0x00000004 + 0xD05BBA9A + ERROR + + + 0x0C + 0x00000001 + AND + 0x00000001 + + + + 0x00000004 + 0xD05BBA9A + ERROR + + + 0x0D + 0x00000080 + AND + 0x00000080 + + + + 0x00000004 + 0xD05BBA9A + ERROR + + + 0x0D + 0x00000040 + AND + 0x00000040 + + + + 0x00000004 + 0xD05BBA9A + ERROR + + + 0x0D + 0x00000020 + AND + 0x00000020 + + + + 0x00000004 + 0xD05BBA9A + ERROR + + + 0x0D + 0x00000010 + AND + 0x00000010 + + + + 0x00000004 + 0xD05BBA9A + ERROR + + + 0x0D + 0x00000008 + AND + 0x00000008 + + + + 0x00000004 + 0xD05BBA9A + ERROR + + + 0x0D + 0x00000004 + AND + 0x00000004 + + + + 0x00000004 + 0xD05BBA9A + ERROR + + + 0x0D + 0x00000001 + AND + 0x00000001 + + + + 0xD05BBA9A + YES + + + 0x0E + 0x00000002 + AND + 0x00000002 + + + + 0xD05BBA9A + YES + + + 0x0E + 0x00000008 + AND + 0x00000008 + + + + 0xD05BBA9A + YES + + + 0x0E + 0x00000010 + AND + 0x00000010 + + + + 0xD05BBA9A + RICH + LEAN + + + 0x0E + 0x00000040 + AND + 0x00000040 + + + + 0x00000008 + 0xD05BBA9A + CLOSED + OPEN + + + 0x0E + 0x00000080 + AND + 0x00000080 + + + + 0xD05BBA9A + ENABLED + + + 0x0F + 0x00000020 + AND + 0x00000020 + + + + 0xD05BBA9A + PARK/NEUTRAL + + + 0x0F + 0x00000080 + AND + 0x00000080 + + + + 0xD05BBA9A + ENABLED + + + 0x10 + 0x00000001 + AND + 0x00000001 + + + + 0xD05BBA9A + LOCKED + + + 0x10 + 0x00000004 + AND + 0x00000004 + + + + 0xD05BBA9A + ACTIVE + + + 0x10 + 0x00000020 + AND + 0x00000020 + + + + 49 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 89e2ed4cd0ef1bb3e70b47b67ca1ecd870a174f8 Mon Sep 17 00:00:00 2001 From: Gandalf Date: Fri, 12 Jun 2026 09:59:00 +0100 Subject: [PATCH 04/18] chore: add MIT License --- LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a17e96a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright Gordon Bolton (c) 2026 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From 7653876fed3e20a6f1bfc1d8097747949b8f3f7a Mon Sep 17 00:00:00 2001 From: Gandalf Date: Fri, 12 Jun 2026 10:18:31 +0100 Subject: [PATCH 05/18] feat: add raw binary stream logging, foreground service support, and improved frame header validation - Implement RawStreamLogger for binary datastream recording with .bin file output - Add foreground service (BluetoothForegroundService) to maintain Bluetooth connection during background operation - Enhance frame header detection with 27-byte lookahead validation to filter false AA 55 sequences in payload data - Add raw data recording toggle in settings with persistent preference storage - Refactor Main --- .idea/deploymentTargetSelector.xml | 7 + .idea/gradle.xml | 1 + .../esp32aldldashboard/AldlApplication.kt | 6 +- .../bluetooth/BluetoothService.kt | 126 +++++++++++++++--- .../logging/RawStreamLogger.kt | 98 ++++++++++++++ .../repository/SettingsRepository.kt | 12 ++ .../repository/TelemetryRepository.kt | 21 +++ .../esp32aldldashboard/ui/main/MainScreen.kt | 10 +- .../ui/main/MainScreenViewModel.kt | 43 +++--- .../ui/main/MainScreenViewModelFactory.kt | 20 +++ .../ui/settings/SettingsScreen.kt | 20 +++ .../esp32aldldashboard/ALDLParserTest.kt | 101 ++++++++++++++ 12 files changed, 423 insertions(+), 42 deletions(-) create mode 100644 app/src/main/java/com/example/esp32aldldashboard/logging/RawStreamLogger.kt create mode 100644 app/src/main/java/com/example/esp32aldldashboard/ui/main/MainScreenViewModelFactory.kt diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index ca16a99..c33318b 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -4,6 +4,13 @@ diff --git a/.idea/gradle.xml b/.idea/gradle.xml index cdbc250..02c4aa5 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -1,5 +1,6 @@ + diff --git a/app/src/main/java/com/example/esp32aldldashboard/ui/main/MainScreenViewModel.kt b/app/src/main/java/com/example/esp32aldldashboard/ui/main/MainScreenViewModel.kt index 3478e2d..0b4a4ab 100644 --- a/app/src/main/java/com/example/esp32aldldashboard/ui/main/MainScreenViewModel.kt +++ b/app/src/main/java/com/example/esp32aldldashboard/ui/main/MainScreenViewModel.kt @@ -6,7 +6,9 @@ import com.example.esp32aldldashboard.bluetooth.ConnectionState import com.example.esp32aldldashboard.parser.ALDLFrame import com.example.esp32aldldashboard.repository.SettingsRepository import com.example.esp32aldldashboard.repository.TelemetryRepository +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch class MainScreenViewModel( @@ -24,10 +26,11 @@ class MainScreenViewModel( val currentFrameRate: StateFlow = telemetryRepository.currentFrameRate val isCelsius: StateFlow = settingsRepository.isCelsiusFlow + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) fun toggleTemperatureUnit() { viewModelScope.launch { - val currentValue = settingsRepository.isCelsiusFlow.value + val currentValue = isCelsius.value settingsRepository.setIsCelsius(!currentValue) } } From 05a5acac1076576dccedfead5e4077e6b802ee4e Mon Sep 17 00:00:00 2001 From: Gandalf Date: Fri, 12 Jun 2026 10:50:23 +0100 Subject: [PATCH 07/18] refactor: move trouble codes to bottom of dashboard --- .../esp32aldldashboard/ui/main/DashboardScreen.kt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/example/esp32aldldashboard/ui/main/DashboardScreen.kt b/app/src/main/java/com/example/esp32aldldashboard/ui/main/DashboardScreen.kt index af8e26d..0e5b91e 100644 --- a/app/src/main/java/com/example/esp32aldldashboard/ui/main/DashboardScreen.kt +++ b/app/src/main/java/com/example/esp32aldldashboard/ui/main/DashboardScreen.kt @@ -77,12 +77,6 @@ fun DashboardScreen( // 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), @@ -220,7 +214,13 @@ fun DashboardScreen( // Status Badges Section StatusFlagsPanel(frame = frame) - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(12.dp)) + + // Trouble Codes Card (at bottom - moved from top) + if (frame.activeFaultCodes.isNotEmpty()) { + TroubleCodesCard(activeCodes = frame.activeFaultCodes) + Spacer(modifier = Modifier.height(16.dp)) + } } else { // Empty / Waiting display Box( From f760a9f3005b9daecda22091590f38fdb85a4825 Mon Sep 17 00:00:00 2001 From: Gandalf Date: Fri, 12 Jun 2026 10:52:03 +0100 Subject: [PATCH 08/18] feat: add multi-parameter chart view with graph icon --- .../esp32aldldashboard/AldlApplication.kt | 3 + .../repository/ChartPreferencesRepository.kt | 96 ++++++ .../ui/charts/ChartParameter.kt | 84 +++++ .../ui/charts/ChartsScreen.kt | 287 ++++++++++++++++-- .../esp32aldldashboard/ui/main/MainScreen.kt | 5 +- 5 files changed, 452 insertions(+), 23 deletions(-) create mode 100644 app/src/main/java/com/example/esp32aldldashboard/repository/ChartPreferencesRepository.kt create mode 100644 app/src/main/java/com/example/esp32aldldashboard/ui/charts/ChartParameter.kt diff --git a/app/src/main/java/com/example/esp32aldldashboard/AldlApplication.kt b/app/src/main/java/com/example/esp32aldldashboard/AldlApplication.kt index 0151831..cc39807 100644 --- a/app/src/main/java/com/example/esp32aldldashboard/AldlApplication.kt +++ b/app/src/main/java/com/example/esp32aldldashboard/AldlApplication.kt @@ -2,6 +2,7 @@ package com.example.esp32aldldashboard import android.app.Application import com.example.esp32aldldashboard.bluetooth.BluetoothService +import com.example.esp32aldldashboard.repository.ChartPreferencesRepository import com.example.esp32aldldashboard.repository.SettingsRepository import com.example.esp32aldldashboard.repository.TelemetryRepository import com.example.esp32aldldashboard.logging.CsvLogger @@ -15,11 +16,13 @@ class AldlApplication : Application() { lateinit var csvLogger: CsvLogger lateinit var rawStreamLogger: RawStreamLogger + lateinit var chartPreferencesRepository: ChartPreferencesRepository override fun onCreate() { super.onCreate() val database = TelemetryDatabase.getDatabase(this) settingsRepository = SettingsRepository(this) + chartPreferencesRepository = ChartPreferencesRepository(this) csvLogger = CsvLogger(this) rawStreamLogger = RawStreamLogger(this) bluetoothService = BluetoothService(this, rawStreamLogger, settingsRepository) diff --git a/app/src/main/java/com/example/esp32aldldashboard/repository/ChartPreferencesRepository.kt b/app/src/main/java/com/example/esp32aldldashboard/repository/ChartPreferencesRepository.kt new file mode 100644 index 0000000..a915748 --- /dev/null +++ b/app/src/main/java/com/example/esp32aldldashboard/repository/ChartPreferencesRepository.kt @@ -0,0 +1,96 @@ +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 com.example.esp32aldldashboard.ui.charts.ChartParameter +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +val Context.chartPreferencesDataStore: DataStore by preferencesDataStore(name = "chart_preferences") + +class ChartPreferencesRepository(private val context: Context) { + + companion object { + val CHART_VIEW_MODE = stringPreferencesKey("chart_view_mode") + val SELECTED_PARAMETERS = stringSetPreferencesKey("selected_parameters") + val SINGLE_CHART_PARAMETER = stringPreferencesKey("single_chart_parameter") + } + + enum class ViewMode { + SINGLE, MULTI + } + + val viewModeFlow: Flow = context.chartPreferencesDataStore.data + .map { preferences -> + val modeString = preferences[CHART_VIEW_MODE] ?: ViewMode.MULTI.name + try { + ViewMode.valueOf(modeString) + } catch (e: IllegalArgumentException) { + ViewMode.MULTI + } + } + + val selectedParametersFlow: Flow> = context.chartPreferencesDataStore.data + .map { preferences -> + val paramNames = preferences[SELECTED_PARAMETERS] ?: setOf( + ChartParameter.RPM.name, + ChartParameter.MAP.name, + ChartParameter.TPS.name, + ChartParameter.O2_SENSOR.name + ) + paramNames.mapNotNull { name -> + try { + ChartParameter.valueOf(name) + } catch (e: IllegalArgumentException) { + null + } + }.toSet() + } + + val singleChartParameterFlow: Flow = context.chartPreferencesDataStore.data + .map { preferences -> + val paramName = preferences[SINGLE_CHART_PARAMETER] ?: ChartParameter.RPM.name + try { + ChartParameter.valueOf(paramName) + } catch (e: IllegalArgumentException) { + ChartParameter.RPM + } + } + + suspend fun setViewMode(mode: ViewMode) { + context.chartPreferencesDataStore.edit { preferences -> + preferences[CHART_VIEW_MODE] = mode.name + } + } + + suspend fun setSelectedParameters(parameters: Set) { + context.chartPreferencesDataStore.edit { preferences -> + preferences[SELECTED_PARAMETERS] = parameters.map { it.name }.toSet() + } + } + + suspend fun setSingleChartParameter(parameter: ChartParameter) { + context.chartPreferencesDataStore.edit { preferences -> + preferences[SINGLE_CHART_PARAMETER] = parameter.name + } + } + + suspend fun toggleParameter(parameter: ChartParameter) { + context.chartPreferencesDataStore.edit { preferences -> + val current = preferences[SELECTED_PARAMETERS] ?: setOf( + ChartParameter.RPM.name, + ChartParameter.MAP.name, + ChartParameter.TPS.name, + ChartParameter.O2_SENSOR.name + ) + val updated = if (current.contains(parameter.name)) { + current - parameter.name + } else { + current + parameter.name + } + preferences[SELECTED_PARAMETERS] = updated + } + } +} diff --git a/app/src/main/java/com/example/esp32aldldashboard/ui/charts/ChartParameter.kt b/app/src/main/java/com/example/esp32aldldashboard/ui/charts/ChartParameter.kt new file mode 100644 index 0000000..38dbdbe --- /dev/null +++ b/app/src/main/java/com/example/esp32aldldashboard/ui/charts/ChartParameter.kt @@ -0,0 +1,84 @@ +package com.example.esp32aldldashboard.ui.charts + +import androidx.compose.ui.graphics.Color +import com.example.esp32aldldashboard.parser.ALDLFrame + +enum class ChartParameter( + val displayName: String, + val color: Color, + val maxValue: Float, + val extractValue: (ALDLFrame) -> Float +) { + RPM( + displayName = "RPM", + color = Color(0xFF00FFCC), + maxValue = 6000f, + extractValue = { it.engineSpeedRpm.toFloat() } + ), + COOLANT_TEMP( + displayName = "Coolant Temp", + color = Color(0xFFFF5722), + maxValue = 250f, + extractValue = { it.coolantTempC } + ), + MAP( + displayName = "MAP", + color = Color(0xFF2196F3), + maxValue = 105f, + extractValue = { it.mapKpa } + ), + TPS( + displayName = "TPS", + color = Color(0xFF9C27B0), + maxValue = 5.5f, + extractValue = { it.tpsVolts } + ), + O2_SENSOR( + displayName = "O2 Sensor", + color = Color(0xFF4CAF50), + maxValue = 1000f, + extractValue = { it.o2SensorMv } + ), + BATTERY( + displayName = "Battery", + color = Color(0xFFFFEB3B), + maxValue = 16f, + extractValue = { it.batteryVolts } + ), + SPARK_ADVANCE( + displayName = "Spark Advance", + color = Color(0xFF00BCD4), + maxValue = 40f, + extractValue = { it.sparkAdvance } + ), + BPW( + displayName = "BPW", + color = Color(0xFFE91E63), + maxValue = 15f, + extractValue = { it.bpwMs } + ), + MAT( + displayName = "MAT", + color = Color(0xFF795548), + maxValue = 80f, + extractValue = { it.matC } + ), + BLM( + displayName = "BLM", + color = Color(0xFF3F51B5), + maxValue = 160f, + extractValue = { it.blm.toFloat() } + ), + INTEGRATOR( + displayName = "Integrator", + color = Color(0xFF607D8B), + maxValue = 255f, + extractValue = { it.integrator.toFloat() } + ), + IAC( + displayName = "IAC Position", + color = Color(0xFFFF9800), + maxValue = 255f, + extractValue = { it.iacPosition.toFloat() } + ) +} diff --git a/app/src/main/java/com/example/esp32aldldashboard/ui/charts/ChartsScreen.kt b/app/src/main/java/com/example/esp32aldldashboard/ui/charts/ChartsScreen.kt index 48b2920..4026314 100644 --- a/app/src/main/java/com/example/esp32aldldashboard/ui/charts/ChartsScreen.kt +++ b/app/src/main/java/com/example/esp32aldldashboard/ui/charts/ChartsScreen.kt @@ -2,8 +2,11 @@ package com.example.esp32aldldashboard.ui.charts import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color @@ -12,66 +15,308 @@ 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 com.example.esp32aldldashboard.repository.ChartPreferencesRepository import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch @Composable fun ChartsScreen( latestFrameFlow: StateFlow, + chartPreferencesRepository: ChartPreferencesRepository, modifier: Modifier = Modifier ) { val latestFrame by latestFrameFlow.collectAsStateWithLifecycle() - - // We maintain a limited rolling history of points (e.g. 100 points) - val rpmHistory = remember { mutableStateListOf() } + val viewMode by chartPreferencesRepository.viewModeFlow.collectAsStateWithLifecycle( + initialValue = ChartPreferencesRepository.ViewMode.MULTI + ) + val selectedParameters by chartPreferencesRepository.selectedParametersFlow.collectAsStateWithLifecycle( + initialValue = setOf(ChartParameter.RPM, ChartParameter.MAP, ChartParameter.TPS, ChartParameter.O2_SENSOR) + ) + val singleChartParameter by chartPreferencesRepository.singleChartParameterFlow.collectAsStateWithLifecycle( + initialValue = ChartParameter.RPM + ) + + // History storage for all parameters val maxHistorySize = 100 + val histories = remember { + mutableStateMapOf>().apply { + ChartParameter.values().forEach { put(it, mutableStateListOf()) } + } + } LaunchedEffect(latestFrame) { - latestFrame?.let { - rpmHistory.add(it.engineSpeedRpm.toFloat()) - if (rpmHistory.size > maxHistorySize) { - rpmHistory.removeAt(0) + latestFrame?.let { frame -> + ChartParameter.values().forEach { param -> + val history = histories.getOrPut(param) { mutableStateListOf() } + history.add(param.extractValue(frame)) + if (history.size > maxHistorySize) { + history.removeAt(0) + } } } } + val coroutineScope = rememberCoroutineScope() + Column(modifier = modifier.fillMaxSize().padding(16.dp)) { - Text( - text = "Real-Time Telemetry", - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onBackground - ) + // Header with view mode toggle + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Real-Time Telemetry", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground + ) + + // View mode toggle + Row { + FilterChip( + selected = viewMode == ChartPreferencesRepository.ViewMode.SINGLE, + onClick = { + coroutineScope.launch { + chartPreferencesRepository.setViewMode( + if (viewMode == ChartPreferencesRepository.ViewMode.SINGLE) + ChartPreferencesRepository.ViewMode.MULTI + else + ChartPreferencesRepository.ViewMode.SINGLE + ) + } + }, + label = { Text(if (viewMode == ChartPreferencesRepository.ViewMode.SINGLE) "Single" else "Multi") } + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + when (viewMode) { + ChartPreferencesRepository.ViewMode.SINGLE -> { + // Single chart mode + SingleChartView( + selectedParameter = singleChartParameter, + history = histories[singleChartParameter] ?: emptyList(), + onParameterChange = { param -> + coroutineScope.launch { + chartPreferencesRepository.setSingleChartParameter(param) + } + } + ) + } + ChartPreferencesRepository.ViewMode.MULTI -> { + // Multi chart mode + MultiChartView( + selectedParameters = selectedParameters, + histories = histories, + onToggleParameter = { param -> + coroutineScope.launch { + chartPreferencesRepository.toggleParameter(param) + } + } + ) + } + } + } +} + +@Composable +private fun SingleChartView( + selectedParameter: ChartParameter, + history: List, + onParameterChange: (ChartParameter) -> Unit +) { + Column { + // Parameter selector dropdown + var expanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it } + ) { + OutlinedTextField( + value = selectedParameter.displayName, + onValueChange = {}, + readOnly = true, + label = { Text("Parameter") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier.menuAnchor().fillMaxWidth() + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + ChartParameter.values().forEach { param -> + DropdownMenuItem( + text = { Text(param.displayName) }, + onClick = { + onParameterChange(param) + expanded = false } + ) + } + } + } + Spacer(modifier = Modifier.height(16.dp)) + // Large single chart Card( - modifier = Modifier.fillMaxWidth().height(200.dp), + modifier = Modifier.fillMaxWidth().height(300.dp), colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)) ) { Column(modifier = Modifier.padding(16.dp)) { - Text(text = "RPM", color = Color(0xFF00FFCC)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = selectedParameter.displayName, + color = selectedParameter.color, + style = MaterialTheme.typography.titleMedium + ) + if (history.isNotEmpty()) { + Text( + text = String.format("%.1f", history.last()), + color = selectedParameter.color, + style = MaterialTheme.typography.titleMedium + ) + } + } Spacer(modifier = Modifier.height(8.dp)) LineChart( - data = rpmHistory, - maxValue = 6000f, - lineColor = Color(0xFF00FFCC), + data = history, + maxValue = selectedParameter.maxValue, + lineColor = selectedParameter.color, modifier = Modifier.fillMaxSize() ) } } + } +} + +@Composable +private fun MultiChartView( + selectedParameters: Set, + histories: Map>, + onToggleParameter: (ChartParameter) -> Unit +) { + Column { + // Parameter toggle chips + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + items(ChartParameter.values().toList()) { param -> + val isSelected = selectedParameters.contains(param) + FilterChip( + selected = isSelected, + onClick = { onToggleParameter(param) }, + label = { Text(param.displayName) }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = param.color.copy(alpha = 0.3f), + selectedLabelColor = param.color + ) + ) + } + } Spacer(modifier = Modifier.height(16.dp)) - // Additional charts can go here (e.g., O2, TPS) + // 2x2 grid of charts (up to 4) + val activeParams = selectedParameters.take(4) + + if (activeParams.isEmpty()) { + Box( + modifier = Modifier.fillMaxWidth().height(200.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "Select parameters above to display charts", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + activeParams.chunked(2).forEach { rowParams -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + rowParams.forEach { param -> + val history = histories[param] ?: emptyList() + ChartCard( + parameter = param, + history = history, + modifier = Modifier.weight(1f) + ) + } + // Fill remaining space if odd number + if (rowParams.size == 1) { + Spacer(modifier = Modifier.weight(1f)) + } + } + } + } + } } } @Composable -fun LineChart( +private fun ChartCard( + parameter: ChartParameter, + history: List, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.height(180.dp), + colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)) + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = parameter.displayName, + color = parameter.color, + style = MaterialTheme.typography.bodyMedium + ) + if (history.isNotEmpty()) { + Text( + text = String.format("%.1f", history.last()), + color = parameter.color, + style = MaterialTheme.typography.bodyMedium + ) + } + } + Spacer(modifier = Modifier.height(4.dp)) + LineChart( + data = history, + maxValue = parameter.maxValue, + lineColor = parameter.color, + modifier = Modifier.fillMaxSize() + ) + } + } +} + +@Composable +private fun LineChart( data: List, maxValue: Float, lineColor: Color, modifier: Modifier = Modifier ) { - if (data.isEmpty()) return + if (data.isEmpty()) { + Box(modifier = modifier, contentAlignment = Alignment.Center) { + Text( + text = "Waiting for data...", + color = Color.Gray, + style = MaterialTheme.typography.bodySmall + ) + } + return + } Canvas(modifier = modifier) { val width = size.width @@ -94,7 +339,7 @@ fun LineChart( drawPath( path = path, color = lineColor, - style = Stroke(width = 4.dp.toPx()) + style = Stroke(width = 3.dp.toPx()) ) } } diff --git a/app/src/main/java/com/example/esp32aldldashboard/ui/main/MainScreen.kt b/app/src/main/java/com/example/esp32aldldashboard/ui/main/MainScreen.kt index 3bdd4c4..6e920d9 100644 --- a/app/src/main/java/com/example/esp32aldldashboard/ui/main/MainScreen.kt +++ b/app/src/main/java/com/example/esp32aldldashboard/ui/main/MainScreen.kt @@ -7,9 +7,9 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts 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.material.icons.filled.ShowChart import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier @@ -89,7 +89,7 @@ fun MainScreen( colors = NavigationBarItemDefaults.colors(selectedIconColor = NeonCyan, unselectedIconColor = TextMuted) ) NavigationBarItem( - icon = { Icon(Icons.Default.Build, contentDescription = "Charts") }, + icon = { Icon(Icons.Default.ShowChart, contentDescription = "Charts") }, label = { Text("Charts") }, selected = selectedTab == 1, onClick = { selectedTab = 1 }, @@ -117,6 +117,7 @@ fun MainScreen( ) 1 -> ChartsScreen( latestFrameFlow = viewModel.latestFrame, + chartPreferencesRepository = app.chartPreferencesRepository, modifier = modifier.padding(paddingValues) ) 2 -> SettingsScreen( From bb47d61eec563bb85cb7f96948eaf7e87619f216 Mon Sep 17 00:00:00 2001 From: Gandalf Date: Fri, 12 Jun 2026 10:56:51 +0100 Subject: [PATCH 09/18] feat: add BLM/INT heatmap table with RPM/MAP bands --- .../esp32aldldashboard/AldlApplication.kt | 6 +- .../repository/BLMTableRepository.kt | 122 ++++++++++ .../repository/TelemetryRepository.kt | 31 ++- .../ui/blm/BLMTableScreen.kt | 226 ++++++++++++++++++ .../esp32aldldashboard/ui/main/MainScreen.kt | 25 +- 5 files changed, 396 insertions(+), 14 deletions(-) create mode 100644 app/src/main/java/com/example/esp32aldldashboard/repository/BLMTableRepository.kt create mode 100644 app/src/main/java/com/example/esp32aldldashboard/ui/blm/BLMTableScreen.kt diff --git a/app/src/main/java/com/example/esp32aldldashboard/AldlApplication.kt b/app/src/main/java/com/example/esp32aldldashboard/AldlApplication.kt index cc39807..e693533 100644 --- a/app/src/main/java/com/example/esp32aldldashboard/AldlApplication.kt +++ b/app/src/main/java/com/example/esp32aldldashboard/AldlApplication.kt @@ -2,6 +2,7 @@ package com.example.esp32aldldashboard import android.app.Application import com.example.esp32aldldashboard.bluetooth.BluetoothService +import com.example.esp32aldldashboard.repository.BLMTableRepository import com.example.esp32aldldashboard.repository.ChartPreferencesRepository import com.example.esp32aldldashboard.repository.SettingsRepository import com.example.esp32aldldashboard.repository.TelemetryRepository @@ -17,12 +18,14 @@ class AldlApplication : Application() { lateinit var csvLogger: CsvLogger lateinit var rawStreamLogger: RawStreamLogger lateinit var chartPreferencesRepository: ChartPreferencesRepository + lateinit var blmTableRepository: BLMTableRepository override fun onCreate() { super.onCreate() val database = TelemetryDatabase.getDatabase(this) settingsRepository = SettingsRepository(this) chartPreferencesRepository = ChartPreferencesRepository(this) + blmTableRepository = BLMTableRepository() csvLogger = CsvLogger(this) rawStreamLogger = RawStreamLogger(this) bluetoothService = BluetoothService(this, rawStreamLogger, settingsRepository) @@ -31,7 +34,8 @@ class AldlApplication : Application() { bluetoothService, database.telemetryDao(), csvLogger, - settingsRepository + settingsRepository, + blmTableRepository ) } } diff --git a/app/src/main/java/com/example/esp32aldldashboard/repository/BLMTableRepository.kt b/app/src/main/java/com/example/esp32aldldashboard/repository/BLMTableRepository.kt new file mode 100644 index 0000000..12f522b --- /dev/null +++ b/app/src/main/java/com/example/esp32aldldashboard/repository/BLMTableRepository.kt @@ -0,0 +1,122 @@ +package com.example.esp32aldldashboard.repository + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlin.math.abs + +data class BLMCellData( + val blm: Int = 128, + val intValue: Int = 128, + val updateCount: Int = 0, + val lastUpdateTime: Long = 0 +) + +class BLMTableRepository { + + // RPM bands as specified by ECM + val rpmBands = listOf(600, 800, 1000, 1200, 1400, 1600, 2000, 2400, 2800, 3200, 3600, 4000, 4400, 4800) + + // MAP bands as specified by ECM + val mapBands = listOf(20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90, 95, 100) + + private val rowCount = rpmBands.size + private val colCount = mapBands.size + + // 2D array [RPM bands × MAP bands] + private val _tableData = MutableStateFlow( + Array(rowCount) { Array(colCount) { BLMCellData() } } + ) + val tableData: StateFlow>> = _tableData + + /** + * Updates the cell corresponding to the nearest RPM and MAP bands. + * Values are retained until replaced by new updates. + */ + fun updateCell(rpm: Int, mapKpa: Float, blm: Int, intValue: Int) { + val rpmIndex = findNearestBandIndex(rpm, rpmBands) + val mapIndex = findNearestBandIndex(mapKpa.toInt(), mapBands) + + if (rpmIndex >= 0 && rpmIndex < rowCount && mapIndex >= 0 && mapIndex < colCount) { + val currentData = _tableData.value + val newData = currentData.map { row -> row.map { it }.toTypedArray() }.toTypedArray() + + val existing = newData[rpmIndex][mapIndex] + newData[rpmIndex][mapIndex] = BLMCellData( + blm = blm, + intValue = intValue, + updateCount = existing.updateCount + 1, + lastUpdateTime = System.currentTimeMillis() + ) + + _tableData.value = newData + } + } + + /** + * Clears all table data, resetting to default values. + */ + fun clearTable() { + _tableData.value = Array(rowCount) { Array(colCount) { BLMCellData() } } + } + + /** + * Gets the color for a BLM value. + * Blue at 128 (center), Green at 120 (lean), Red at 150 (rich) + * Returns ARGB color value + */ + fun getBLMColor(blm: Int): Long { + return when { + blm <= 120 -> { + // Green (120 and below) + 0xFF00E676 + } + blm >= 150 -> { + // Red (150 and above) + 0xFFFF3D00 + } + blm <= 128 -> { + // Interpolate between Green (120) and Blue (128) + val fraction = (blm - 120) / 8f + interpolateColor(0xFF00E676, 0xFF2196F3, fraction) + } + else -> { + // Interpolate between Blue (128) and Red (150) + val fraction = (blm - 128) / 22f + interpolateColor(0xFF2196F3, 0xFFFF3D00, fraction) + } + } + } + + private fun interpolateColor(color1: Long, color2: Long, fraction: Float): Long { + val r1 = (color1 shr 16) and 0xFF + val g1 = (color1 shr 8) and 0xFF + val b1 = color1 and 0xFF + + val r2 = (color2 shr 16) and 0xFF + val g2 = (color2 shr 8) and 0xFF + val b2 = color2 and 0xFF + + val r = (r1 + (r2 - r1) * fraction).toInt() + val g = (g1 + (g2 - g1) * fraction).toInt() + val b = (b1 + (b2 - b1) * fraction).toInt() + + return 0xFF000000 or (r.toLong() shl 16) or (g.toLong() shl 8) or b.toLong() + } + + private fun findNearestBandIndex(value: Int, bands: List): Int { + if (bands.isEmpty()) return -1 + + var nearestIndex = 0 + var minDiff = abs(value - bands[0]) + + for (i in 1 until bands.size) { + val diff = abs(value - bands[i]) + if (diff < minDiff) { + minDiff = diff + nearestIndex = i + } + } + + return nearestIndex + } +} diff --git a/app/src/main/java/com/example/esp32aldldashboard/repository/TelemetryRepository.kt b/app/src/main/java/com/example/esp32aldldashboard/repository/TelemetryRepository.kt index f8e3443..43705d6 100644 --- a/app/src/main/java/com/example/esp32aldldashboard/repository/TelemetryRepository.kt +++ b/app/src/main/java/com/example/esp32aldldashboard/repository/TelemetryRepository.kt @@ -23,7 +23,8 @@ class TelemetryRepository( private val bluetoothService: BluetoothService, private val telemetryDao: TelemetryDao, private val csvLogger: CsvLogger, - private val settingsRepository: SettingsRepository + private val settingsRepository: SettingsRepository, + private val blmTableRepository: BLMTableRepository ) { private val repoScope = CoroutineScope(Dispatchers.IO + Job()) private var currentSessionId: Long? = null @@ -65,15 +66,25 @@ class TelemetryRepository( private fun observeTelemetry() { repoScope.launch { bluetoothService.latestFrame.collectLatest { frame -> - if (frame != null && isRecording) { - csvLogger.logFrame(frame) - currentSessionId?.let { sid -> - val dataPoint = TelemetryDataPointEntity( - sessionId = sid, - timestamp = frame.timestamp, - rawBytes = frame.rawBytes - ) - telemetryDao.insertDataPoints(listOf(dataPoint)) + if (frame != null) { + // Update BLM table with every frame (not just when recording) + blmTableRepository.updateCell( + rpm = frame.engineSpeedRpm, + mapKpa = frame.mapKpa, + blm = frame.blm, + intValue = frame.integrator + ) + + if (isRecording) { + csvLogger.logFrame(frame) + currentSessionId?.let { sid -> + val dataPoint = TelemetryDataPointEntity( + sessionId = sid, + timestamp = frame.timestamp, + rawBytes = frame.rawBytes + ) + telemetryDao.insertDataPoints(listOf(dataPoint)) + } } } } diff --git a/app/src/main/java/com/example/esp32aldldashboard/ui/blm/BLMTableScreen.kt b/app/src/main/java/com/example/esp32aldldashboard/ui/blm/BLMTableScreen.kt new file mode 100644 index 0000000..e1caad8 --- /dev/null +++ b/app/src/main/java/com/example/esp32aldldashboard/ui/blm/BLMTableScreen.kt @@ -0,0 +1,226 @@ +package com.example.esp32aldldashboard.ui.blm + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.example.esp32aldldashboard.repository.BLMTableRepository + +@Composable +fun BLMTableScreen( + viewModel: BLMTableViewModel, + modifier: Modifier = Modifier +) { + val tableData by viewModel.tableData.collectAsStateWithLifecycle() + val rpmBands = viewModel.rpmBands + val mapBands = viewModel.mapBands + + Column( + modifier = modifier + .fillMaxSize() + .padding(16.dp) + .verticalScroll(rememberScrollState()) + ) { + // Header with title and clear button + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + text = "BLM/INT Table", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground + ) + Text( + text = "RPM (vertical) × MAP (horizontal)", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Button( + onClick = { viewModel.clearTable() }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ) + ) { + Text("Clear") + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Legend + BLMLegend() + + Spacer(modifier = Modifier.height(16.dp)) + + // Table container + Column { + // MAP headers (top) + Row { + // Empty corner cell + Box( + modifier = Modifier + .size(60.dp) + .background(Color(0xFF2C2C2C)) + .border(1.dp, Color(0xFF3C3C3C)), + contentAlignment = Alignment.Center + ) { + Text( + text = "RPM\\MAP", + fontSize = 10.sp, + color = Color.Gray, + textAlign = TextAlign.Center + ) + } + + // MAP band headers + mapBands.forEach { map -> + Box( + modifier = Modifier + .width(50.dp) + .height(60.dp) + .background(Color(0xFF2C2C2C)) + .border(1.dp, Color(0xFF3C3C3C)), + contentAlignment = Alignment.Center + ) { + Text( + text = "$map", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFF00E5FF), + textAlign = TextAlign.Center + ) + } + } + } + + // Data rows + rpmBands.forEachIndexed { rowIndex, rpm -> + Row { + // RPM band header (left) + Box( + modifier = Modifier + .width(60.dp) + .height(50.dp) + .background(Color(0xFF2C2C2C)) + .border(1.dp, Color(0xFF3C3C3C)), + contentAlignment = Alignment.Center + ) { + Text( + text = "$rpm", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFF00E5FF), + textAlign = TextAlign.Center + ) + } + + // Data cells + if (rowIndex < tableData.size) { + tableData[rowIndex].forEach { cell -> + val colorArgb = viewModel.getBLMColor(cell.blm) + val cellColor = Color(colorArgb) + + Box( + modifier = Modifier + .width(50.dp) + .height(50.dp) + .background(cellColor.copy(alpha = 0.3f)) + .border(1.dp, cellColor), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "${cell.blm}", + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = if (cell.blm > 140 || cell.blm < 116) Color.White else Color.Black + ) + Text( + text = "${cell.intValue}", + fontSize = 10.sp, + color = if (cell.blm > 140 || cell.blm < 116) Color.White.copy(alpha = 0.7f) else Color.Black.copy(alpha = 0.7f) + ) + } + } + } + } + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + } +} + +@Composable +private fun BLMLegend() { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)) + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = "Color Legend", + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + LegendItem(color = Color(0xFF00E676), label = "≤120 Lean", textColor = Color.White) + LegendItem(color = Color(0xFF2196F3), label = "128 Ideal", textColor = Color.White) + LegendItem(color = Color(0xFFFF3D00), label = "≥150 Rich", textColor = Color.White) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "BLM = Block Learn Multiplier (fuel trim). INT = Integrator (short-term correction). Values show most recent update.", + fontSize = 10.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + } +} + +@Composable +private fun LegendItem(color: Color, label: String, textColor: Color) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(16.dp) + .background(color, shape = RoundedCornerShape(2.dp)) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = label, + fontSize = 10.sp, + color = textColor + ) + } +} diff --git a/app/src/main/java/com/example/esp32aldldashboard/ui/main/MainScreen.kt b/app/src/main/java/com/example/esp32aldldashboard/ui/main/MainScreen.kt index 6e920d9..990dcd6 100644 --- a/app/src/main/java/com/example/esp32aldldashboard/ui/main/MainScreen.kt +++ b/app/src/main/java/com/example/esp32aldldashboard/ui/main/MainScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.ShowChart +import androidx.compose.material.icons.filled.TableChart import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier @@ -18,6 +19,8 @@ import androidx.core.content.ContextCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.example.esp32aldldashboard.AldlApplication +import com.example.esp32aldldashboard.ui.blm.BLMTableScreen +import com.example.esp32aldldashboard.ui.blm.BLMTableViewModelFactory import com.example.esp32aldldashboard.ui.charts.ChartsScreen import com.example.esp32aldldashboard.ui.settings.SettingsScreen @@ -96,12 +99,19 @@ fun MainScreen( colors = NavigationBarItemDefaults.colors(selectedIconColor = NeonCyan, unselectedIconColor = TextMuted) ) NavigationBarItem( - icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") }, - label = { Text("Settings") }, + icon = { Icon(Icons.Default.TableChart, contentDescription = "BLM Table") }, + label = { Text("BLM") }, selected = selectedTab == 2, onClick = { selectedTab = 2 }, colors = NavigationBarItemDefaults.colors(selectedIconColor = NeonCyan, unselectedIconColor = TextMuted) ) + NavigationBarItem( + icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") }, + label = { Text("Settings") }, + selected = selectedTab == 3, + onClick = { selectedTab = 3 }, + colors = NavigationBarItemDefaults.colors(selectedIconColor = NeonCyan, unselectedIconColor = TextMuted) + ) } } ) { paddingValues -> @@ -120,7 +130,16 @@ fun MainScreen( chartPreferencesRepository = app.chartPreferencesRepository, modifier = modifier.padding(paddingValues) ) - 2 -> SettingsScreen( + 2 -> { + val blmViewModel: com.example.esp32aldldashboard.ui.blm.BLMTableViewModel = viewModel( + factory = BLMTableViewModelFactory(app.blmTableRepository) + ) + BLMTableScreen( + viewModel = blmViewModel, + modifier = modifier.padding(paddingValues) + ) + } + 3 -> SettingsScreen( settingsRepository = app.settingsRepository, modifier = modifier.padding(paddingValues) ) From 9838378933334915a7061cf187993d4af8b0ed73 Mon Sep 17 00:00:00 2001 From: Gandalf Date: Fri, 12 Jun 2026 10:58:25 +0100 Subject: [PATCH 10/18] feat: add logged files viewer in settings --- .../repository/SettingsRepository.kt | 129 +++++++++++++++++ .../ui/settings/LogFilesDialog.kt | 137 ++++++++++++++++++ .../ui/settings/SettingsScreen.kt | 39 +++++ 3 files changed, 305 insertions(+) create mode 100644 app/src/main/java/com/example/esp32aldldashboard/ui/settings/LogFilesDialog.kt diff --git a/app/src/main/java/com/example/esp32aldldashboard/repository/SettingsRepository.kt b/app/src/main/java/com/example/esp32aldldashboard/repository/SettingsRepository.kt index 8cc4574..45a53d8 100644 --- a/app/src/main/java/com/example/esp32aldldashboard/repository/SettingsRepository.kt +++ b/app/src/main/java/com/example/esp32aldldashboard/repository/SettingsRepository.kt @@ -1,11 +1,21 @@ package com.example.esp32aldldashboard.repository +import android.content.ContentUris import android.content.Context +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.* import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale val Context.dataStore: DataStore by preferencesDataStore(name = "settings") @@ -73,4 +83,123 @@ class SettingsRepository(private val context: Context) { preferences[RECORD_RAW_DATA] = recordRaw } } + + /** + * Queries MediaStore for logged files (CSV and .bin) in Downloads/ALDLLogs + */ + suspend fun getLoggedFiles(): List = withContext(Dispatchers.IO) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + return@withContext emptyList() // Legacy not supported for this feature + } + + val files = mutableListOf() + val resolver = context.contentResolver + + val projection = arrayOf( + MediaStore.MediaColumns._ID, + MediaStore.MediaColumns.DISPLAY_NAME, + MediaStore.MediaColumns.SIZE, + MediaStore.MediaColumns.DATE_MODIFIED, + MediaStore.MediaColumns.MIME_TYPE + ) + + // Query for CSV files + val csvSelection = "${MediaStore.MediaColumns.RELATIVE_PATH} LIKE ? AND ${MediaStore.MediaColumns.DISPLAY_NAME} LIKE ?" + val csvSelectionArgs = arrayOf("%${Environment.DIRECTORY_DOWNLOADS}/ALDLLogs%", "%.csv") + + resolver.query( + MediaStore.Downloads.EXTERNAL_CONTENT_URI, + projection, + csvSelection, + csvSelectionArgs, + "${MediaStore.MediaColumns.DATE_MODIFIED} DESC" + )?.use { cursor -> + val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID) + val nameColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME) + val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE) + val dateColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED) + + while (cursor.moveToNext()) { + val id = cursor.getLong(idColumn) + val name = cursor.getString(nameColumn) + val size = cursor.getLong(sizeColumn) + val dateModified = cursor.getLong(dateColumn) + val uri = ContentUris.withAppendedId(MediaStore.Downloads.EXTERNAL_CONTENT_URI, id) + + files.add( + LoggedFile( + uri = uri, + name = name, + size = size, + lastModified = dateModified * 1000, // Convert to milliseconds + type = FileType.CSV + ) + ) + } + } + + // Query for binary files (.bin) + val binSelection = "${MediaStore.MediaColumns.RELATIVE_PATH} LIKE ? AND ${MediaStore.MediaColumns.DISPLAY_NAME} LIKE ?" + val binSelectionArgs = arrayOf("%${Environment.DIRECTORY_DOWNLOADS}/ALDLLogs%", "%.bin") + + resolver.query( + MediaStore.Downloads.EXTERNAL_CONTENT_URI, + projection, + binSelection, + binSelectionArgs, + "${MediaStore.MediaColumns.DATE_MODIFIED} DESC" + )?.use { cursor -> + val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID) + val nameColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME) + val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE) + val dateColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED) + + while (cursor.moveToNext()) { + val id = cursor.getLong(idColumn) + val name = cursor.getString(nameColumn) + val size = cursor.getLong(sizeColumn) + val dateModified = cursor.getLong(dateColumn) + val uri = ContentUris.withAppendedId(MediaStore.Downloads.EXTERNAL_CONTENT_URI, id) + + files.add( + LoggedFile( + uri = uri, + name = name, + size = size, + lastModified = dateModified * 1000, + type = FileType.BINARY + ) + ) + } + } + + // Sort by date descending + files.sortByDescending { it.lastModified } + files + } +} + +data class LoggedFile( + val uri: Uri, + val name: String, + val size: Long, + val lastModified: Long, + val type: FileType +) { + fun getFormattedSize(): String { + return when { + size < 1024 -> "$size B" + size < 1024 * 1024 -> "${size / 1024} KB" + else -> "${size / (1024 * 1024)} MB" + } + } + + fun getFormattedDate(): String { + val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) + return sdf.format(Date(lastModified)) + } +} + +enum class FileType { + CSV, BINARY } diff --git a/app/src/main/java/com/example/esp32aldldashboard/ui/settings/LogFilesDialog.kt b/app/src/main/java/com/example/esp32aldldashboard/ui/settings/LogFilesDialog.kt new file mode 100644 index 0000000..e83406c --- /dev/null +++ b/app/src/main/java/com/example/esp32aldldashboard/ui/settings/LogFilesDialog.kt @@ -0,0 +1,137 @@ +package com.example.esp32aldldashboard.ui.settings + +import android.content.Intent +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.InsertDriveFile +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.core.content.FileProvider +import com.example.esp32aldldashboard.repository.FileType +import com.example.esp32aldldashboard.repository.LoggedFile + +@Composable +fun LogFilesDialog( + files: List, + onDismiss: () -> Unit +) { + val context = LocalContext.current + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Logged Files (${files.size})") }, + text = { + if (files.isEmpty()) { + Box( + modifier = Modifier.fillMaxWidth().height(100.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "No log files found in Downloads/ALDLLogs", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(files) { file -> + FileListItem( + file = file, + onOpen = { + try { + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(file.uri, when(file.type) { + FileType.CSV -> "text/csv" + FileType.BINARY -> "application/octet-stream" + }) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(intent) + } catch (e: Exception) { + // No app available to open file + } + }, + onShare = { + val shareIntent = Intent(Intent.ACTION_SEND).apply { + type = when(file.type) { + FileType.CSV -> "text/csv" + FileType.BINARY -> "application/octet-stream" + } + putExtra(Intent.EXTRA_STREAM, file.uri) + putExtra(Intent.EXTRA_SUBJECT, file.name) + } + context.startActivity(Intent.createChooser(shareIntent, "Share ${file.name}")) + } + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text("Close") + } + } + ) +} + +@Composable +private fun FileListItem( + file: LoggedFile, + onOpen: () -> Unit, + onShare: () -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ), + onClick = onOpen + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.InsertDriveFile, + contentDescription = null, + tint = when(file.type) { + FileType.CSV -> MaterialTheme.colorScheme.primary + FileType.BINARY -> MaterialTheme.colorScheme.tertiary + } + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = file.name, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1 + ) + Text( + text = "${file.getFormattedDate()} • ${file.getFormattedSize()}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + IconButton(onClick = onShare) { + Icon( + imageVector = Icons.Default.Share, + contentDescription = "Share" + ) + } + } + } +} diff --git a/app/src/main/java/com/example/esp32aldldashboard/ui/settings/SettingsScreen.kt b/app/src/main/java/com/example/esp32aldldashboard/ui/settings/SettingsScreen.kt index 83992d6..874a92a 100644 --- a/app/src/main/java/com/example/esp32aldldashboard/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/example/esp32aldldashboard/ui/settings/SettingsScreen.kt @@ -1,12 +1,15 @@ package com.example.esp32aldldashboard.ui.settings import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.FolderOpen 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.LoggedFile import com.example.esp32aldldashboard.repository.SettingsRepository import kotlinx.coroutines.launch @@ -20,6 +23,9 @@ fun SettingsScreen( val recordRawData by settingsRepository.recordRawDataFlow.collectAsStateWithLifecycle(initialValue = false) val coolantThreshold by settingsRepository.coolantAlertThresholdFlow.collectAsStateWithLifecycle(initialValue = 100f) + var showLogFilesDialog by remember { mutableStateOf(false) } + var logFiles by remember { mutableStateOf>(emptyList()) } + val coroutineScope = rememberCoroutineScope() Column(modifier = modifier.fillMaxSize().padding(16.dp)) { @@ -87,6 +93,31 @@ fun SettingsScreen( } Divider(modifier = Modifier.padding(vertical = 8.dp)) + // View Logged Files + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.weight(1f)) { + Text(text = "View Logged Files", style = MaterialTheme.typography.titleMedium) + Text(text = "Browse CSV and binary logs", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Button( + onClick = { + coroutineScope.launch { + logFiles = settingsRepository.getLoggedFiles() + showLogFilesDialog = true + } + } + ) { + Icon(Icons.Default.FolderOpen, contentDescription = null) + Spacer(modifier = Modifier.width(4.dp)) + Text("Open") + } + } + 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) @@ -101,4 +132,12 @@ fun SettingsScreen( ) } } + + // Log Files Dialog + if (showLogFilesDialog) { + LogFilesDialog( + files = logFiles, + onDismiss = { showLogFilesDialog = false } + ) + } } From b06de4e2fef1c1d94a8049de836d47f3d20473cb Mon Sep 17 00:00:00 2001 From: Gandalf Date: Fri, 12 Jun 2026 11:14:21 +0100 Subject: [PATCH 11/18] chore: add Windsurf workflows directory to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index aa724b7..0a2467a 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ .externalNativeBuild .cxx local.properties +.windsurf/workflows/gitea-interaction.md From b3c475ad183768e513c06055f723a262209d9c3a Mon Sep 17 00:00:00 2001 From: Gandalf Date: Fri, 12 Jun 2026 12:15:44 +0100 Subject: [PATCH 12/18] feat: add BLMTableViewModel with factory and extended Material icons dependency --- app/build.gradle.kts | 1 + .../ui/blm/BLMTableViewModel.kt | 32 +++++++++++++++++++ gradle/libs.versions.toml | 1 + 3 files changed, 34 insertions(+) create mode 100644 app/src/main/java/com/example/esp32aldldashboard/ui/blm/BLMTableViewModel.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b82f87c..591773d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -63,6 +63,7 @@ dependencies { implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material.icons.core) + implementation(libs.androidx.compose.material.icons.extended) // Tooling debugImplementation(libs.androidx.compose.ui.tooling) // Instrumented tests diff --git a/app/src/main/java/com/example/esp32aldldashboard/ui/blm/BLMTableViewModel.kt b/app/src/main/java/com/example/esp32aldldashboard/ui/blm/BLMTableViewModel.kt new file mode 100644 index 0000000..af6ae4a --- /dev/null +++ b/app/src/main/java/com/example/esp32aldldashboard/ui/blm/BLMTableViewModel.kt @@ -0,0 +1,32 @@ +package com.example.esp32aldldashboard.ui.blm + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.example.esp32aldldashboard.repository.BLMCellData +import com.example.esp32aldldashboard.repository.BLMTableRepository +import kotlinx.coroutines.flow.StateFlow + +class BLMTableViewModel(private val repository: BLMTableRepository) : ViewModel() { + + val tableData: StateFlow>> = repository.tableData + val rpmBands: List = repository.rpmBands + val mapBands: List = repository.mapBands + + fun clearTable() { + repository.clearTable() + } + + fun getBLMColor(blm: Int): Long { + return repository.getBLMColor(blm) + } +} + +class BLMTableViewModelFactory(private val repository: BLMTableRepository) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(BLMTableViewModel::class.java)) { + @Suppress("UNCHECKED_CAST") + return BLMTableViewModel(repository) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 99f7125..904dcbd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,6 +23,7 @@ androidx-activity-compose = { module = "androidx.activity:activity-compose", ver 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-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } 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"} From d8e656284ad14d31c488a2f484846ab72cac25e8 Mon Sep 17 00:00:00 2001 From: Gandalf Date: Fri, 12 Jun 2026 12:29:02 +0100 Subject: [PATCH 13/18] feat: add MockK dependency and update README documentation for diagnostic features --- README.md | 93 +++++++++++++------ app/build.gradle.kts | 1 + .../ui/charts/ChartsScreen.kt | 2 + gradle/libs.versions.toml | 2 + 4 files changed, 70 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index d8f99f5..da20fa9 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,76 @@ # ESP32 ALDL Dashboard Android Application -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. +A modern, high-performance Android application designed to interface with GM OBD1 systems—specifically the **1986 Pontiac Fiero 1227170 ECM**—using a custom ESP32 Bluetooth SPP bridge. The app decodes the low-speed 160-baud ALDL (Assembly Line Diagnostic Link) datastream in real-time, displaying live telemetry, logging diagnostic parameters, and generating tuning heatmaps. -## Features +--- -* **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. +## 🌟 Key Features -## Architecture Highlights +* **Real-Time Instrumentation Dashboard:** + * Vibrant, animated Canvas-based components including a radial RPM gauge and a Throttle Position Sensor (TPS) bar graph. + * Instantaneous readouts for Engine Speed (RPM), Vehicle Speed (MPH), Coolant Temperature, Manifold Air Temp (MAT), Manifold Absolute Pressure (MAP), TPS voltage, O2 Sensor voltage, and Battery voltage. + * **Status Flags:** Instant visibility into critical operating states like Closed Loop Mode, Rich/Lean Mixture, Torque Converter Clutch (TCC) Lockup, and A/C Clutch requests. + * **Derived Metrics:** Real-time calculated estimates of Engine Load and Fuel Flow Hints. +* **BLM & INT Fuel Trim Heatmap Grid (New):** + * A real-time diagnostic table indexing fuel trim metrics across **14 RPM bands** (600 to 4800 RPM) and **17 MAP bands** (20 to 100 kPa). + * Color-coded cell visualisations using dynamic RGB interpolation: Green for lean fuel trims ($\le 120$), Blue for stoichiometric neutral ($128$), and Red for rich fuel trims ($\ge 150$). +* **Multi-Parameter Line Charting (New):** + * A dynamic telemetry visualizer toggling between a **Single Chart Mode** (large-scale view of any metric) and a **Multi Chart Mode** displaying up to 4 selected metrics in a 2x2 grid. + * Supports charting for **12 distinct parameters**: RPM, Coolant Temp, MAP, TPS, O2 Sensor, Battery Voltage, Spark Advance, Base Pulse Width (BPW), MAT, BLM, Integrator, and Idle Air Control (IAC) Position. +* **Trouble Code Diagnostic Engine:** + * Decodes active ECM trouble codes into human-readable alerts (e.g., *Code 14 - Coolant Temperature Sensor High*) dynamically displayed at the bottom of the dashboard screen. +* **Dual-Logging Framework:** + * **Room Database Sessions:** Saves all active telemetry packets to a local SQLite database using Jetpack Room. + * **TunerPro RT CSV Export:** Automatically compiles sessions into `.csv` log files fully compatible with TunerPro RT, exported to `Downloads/ALDLLogs` using Android MediaStore. + * **Raw Binary Stream Logging (New):** Optional raw capture recording direct 27-byte diagnostic datastream packets (incorporating `AA 55` headers and 25-byte payloads) to `.bin` files for advanced playback and diagnostic troubleshooting. +* **Logged Files Manager (New):** + * An in-app browser inside the Settings panel that scans `Downloads/ALDLLogs` for CSV and BIN logs, enabling users to view details, open logs with default viewers, or share files via the Android Sharesheet. +* **Persistent Preferences & Guardrails:** + * DataStore-backed settings for Temperature Unit toggle (°C/°F), Auto-Logging toggles, Coolant alert thresholds, Low Battery alert thresholds, and Raw Binary Stream recording toggles. -* **MVVM & StateFlow:** Strict MVVM separation of concerns. The UI reactivity is entirely driven by `StateFlow` streams. -* **Circular Ring Buffer Packet Parsing:** The Bluetooth ingest layer uses `ArrayDeque` circular buffering. It constantly seeks the `AA 55` header, preventing misalignment desyncs over noisy lines. -* **Robust Parsers:** The `ALDLParser` encapsulates all scaling constants and interpolation arrays (for MAT) required by the TunerPro `24-INT10.ads` definition. +--- -## Requirements +## 🛠️ Architecture & Technical Highlights -* 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. +* **Jetpack Compose & MVVM:** Developed with a clean Model-View-ViewModel architecture. State emission is managed via reactive `StateFlow` and `SharedFlow` streams to guarantee lag-free rendering. +* **Circular Buffering & Packet Validation (New):** + * Ingests raw Bluetooth stream buffers using an `ArrayDeque` circular buffer. + * Employs a **27-byte lookahead validation algorithm** to search for authentic `AA 55` frame headers, preventing sync misalignment or telemetry corruption from noisy serial lines. +* **TunerPro ADS Translation:** Uses an advanced [ALDLParser](file:///home/gordon/esp32-aldl-android/app/src/test/java/com/example/esp32aldldashboard/ALDLParserTest.kt) mapping raw 25-byte payloads into physical metrics using exact scale conversions, offsets, and non-linear lookup interpolations (derived from the `24-INT10.ads` definition file). +* **Foreground Service Operations (New):** + * Runs a persistent `BluetoothForegroundService` to keep the Bluetooth socket open and stream telemetry continuously in the background, even when the phone screen is locked or the app is minimized. +* **Firebase Integration:** Incorporates Firebase Crashlytics to monitor application stability and track runtime exceptions. -## Setup Instructions +--- -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. +## 📱 Requirements -## Build Information -Developed using Android Studio and Gradle. Built with Jetpack Compose, Room (with KSP Annotation Processor), and DataStore. \ No newline at end of file +* **Android Device:** Android 8.0 (API Level 26) or higher. +* **Bluetooth Permissions:** Requires Nearby Devices (Android 12+) and Legacy Bluetooth Admin access. +* **ESP32 Transceiver:** Bridge hardware programmed to output 160-baud serial data from the ALDL pin and stream it over Classic Bluetooth SPP (named `ESP32-ALDL`). + +--- + +## 🚀 Setup & Usage + +1. Pair the `ESP32-ALDL` Bluetooth module in your Android system settings. +2. Launch the application and grant all requested permissions. +3. Tap **Connect BT** on the main dashboard to establish connection and start telemetry. +4. Navigate between screens (Dashboard, Charts, BLM Table, Settings) using the bottom navigation bar. +5. Configure logging preferences or browse logged CSV/BIN files in the **Settings** menu. + +--- + +## 🧪 Testing & Verification + +The parsing logic, scaling calculations, and boundary conditions are validated by a unit test suite located in [ALDLParserTest.kt](file:///home/gordon/esp32-aldl-android/app/src/test/java/com/example/esp32aldldashboard/ALDLParserTest.kt). + +To run the unit tests, execute the following Gradle command in the root project directory: +```bash +./gradlew test +``` + +### Coverage Areas +* **Sample Frame Decoding:** Parses a real-world telemetry payload and verifies the output of IAC position, coolant/manifold temperatures, MAP, TPS, battery voltage, BLM, integrator, spark advance, base pulse width, closed-loop flags, and active trouble codes. +* **Boundary Guards:** Ensures outlier protection for engine speeds, battery voltages, TPS, and coolant temperatures, rejecting out-of-range sensor values as invalid data packets. +* **Lookahead Stability:** Validates parser behavior against truncated or incomplete frame fragments. \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 591773d..aab8ae8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -73,6 +73,7 @@ dependencies { // Local tests: jUnit, coroutines, Android runner testImplementation(libs.junit) testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.mockk) // Instrumented tests: jUnit rules and runners androidTestImplementation(libs.androidx.test.core) diff --git a/app/src/main/java/com/example/esp32aldldashboard/ui/charts/ChartsScreen.kt b/app/src/main/java/com/example/esp32aldldashboard/ui/charts/ChartsScreen.kt index 4026314..871e6ba 100644 --- a/app/src/main/java/com/example/esp32aldldashboard/ui/charts/ChartsScreen.kt +++ b/app/src/main/java/com/example/esp32aldldashboard/ui/charts/ChartsScreen.kt @@ -1,5 +1,7 @@ +@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) package com.example.esp32aldldashboard.ui.charts +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyRow diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 904dcbd..fa69ce7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,6 +16,7 @@ lifecycleViewmodelNav3 = "2.10.0" room = "2.6.1" datastore = "1.1.1" ksp = "2.1.0-1.0.29" +mockk = "1.13.10" [libraries] androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCore" } @@ -45,6 +46,7 @@ androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = " 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" } +mockk = { module = "io.mockk:mockk", version.ref = "mockk" } [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } From 7c2505ab7baa3abe31741c1172777867da0e80d2 Mon Sep 17 00:00:00 2001 From: Gandalf Date: Fri, 12 Jun 2026 12:29:12 +0100 Subject: [PATCH 14/18] test: refactor MainScreenViewModel tests to use repository mocks instead of fake context --- .../ui/main/MainScreenViewModelTest.kt | 53 ++++++++++++++----- 1 file changed, 41 insertions(+), 12 deletions(-) diff --git a/app/src/test/java/com/example/esp32aldldashboard/ui/main/MainScreenViewModelTest.kt b/app/src/test/java/com/example/esp32aldldashboard/ui/main/MainScreenViewModelTest.kt index d4f1252..9ac286b 100644 --- a/app/src/test/java/com/example/esp32aldldashboard/ui/main/MainScreenViewModelTest.kt +++ b/app/src/test/java/com/example/esp32aldldashboard/ui/main/MainScreenViewModelTest.kt @@ -1,29 +1,49 @@ package com.example.esp32aldldashboard.ui.main -import android.content.Context -import android.content.ContextWrapper import com.example.esp32aldldashboard.bluetooth.ConnectionState +import com.example.esp32aldldashboard.repository.SettingsRepository +import com.example.esp32aldldashboard.repository.TelemetryRepository +import io.mockk.* import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertFalse import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest +import org.junit.Before import org.junit.Test class MainScreenViewModelTest { - private class FakeContext : ContextWrapper(null) { - override fun getApplicationContext(): Context { - return this - } - override fun getSystemService(name: String): Any? { - return null + private val telemetryRepository = mockk(relaxed = true) + private val settingsRepository = mockk(relaxed = true) + private val isCelsiusFlow = MutableStateFlow(false) + + @Before + fun setUp() { + // Clear mocks + clearAllMocks() + + // Stub telemetry repository flows + every { telemetryRepository.connectionState } returns MutableStateFlow(ConnectionState.DISCONNECTED) + every { telemetryRepository.latestFrame } returns MutableStateFlow(null) + every { telemetryRepository.rawHexLog } returns MutableStateFlow(emptyList()) + every { telemetryRepository.errorMessage } returns MutableStateFlow("") + every { telemetryRepository.framesReceived } returns MutableStateFlow(0) + every { telemetryRepository.parseErrors } returns MutableStateFlow(0) + every { telemetryRepository.currentFrameRate } returns MutableStateFlow(0) + + // Stub settings repository + isCelsiusFlow.value = false + every { settingsRepository.isCelsiusFlow } returns isCelsiusFlow + coEvery { settingsRepository.setIsCelsius(any()) } answers { + isCelsiusFlow.value = firstArg() } } @Test fun testInitialStates() = runTest { - val context = FakeContext() - val viewModel = MainScreenViewModel(context) + val viewModel = MainScreenViewModel(telemetryRepository, settingsRepository) assertEquals(ConnectionState.DISCONNECTED, viewModel.connectionState.value) assertEquals(null, viewModel.latestFrame.value) @@ -32,13 +52,22 @@ class MainScreenViewModelTest { @Test fun testToggleTemperatureUnit() = runTest { - val context = FakeContext() - val viewModel = MainScreenViewModel(context) + val viewModel = MainScreenViewModel(telemetryRepository, settingsRepository) + // Wait for stateIn initialization + runCurrent() assertFalse(viewModel.isCelsius.value) + viewModel.toggleTemperatureUnit() + runCurrent() // Wait for viewmodel scope coroutine to execute settingsRepository.setIsCelsius + runCurrent() // Wait for settingsRepository update flow to propagate back through stateIn + assertTrue(viewModel.isCelsius.value) + viewModel.toggleTemperatureUnit() + runCurrent() + runCurrent() + assertFalse(viewModel.isCelsius.value) } } From 5bd096f54b0ca661c19e3a6e62b715bdf0bc73cc Mon Sep 17 00:00:00 2001 From: Gandalf Date: Fri, 12 Jun 2026 12:31:39 +0100 Subject: [PATCH 15/18] test: fix ViewModel state collection in tests and update ALDL parser boundary validations to match byte limits --- .../esp32aldldashboard/ALDLParserTest.kt | 46 ++++++++----------- .../ui/main/MainScreenViewModelTest.kt | 26 ++++++++--- 2 files changed, 37 insertions(+), 35 deletions(-) diff --git a/app/src/test/java/com/example/esp32aldldashboard/ALDLParserTest.kt b/app/src/test/java/com/example/esp32aldldashboard/ALDLParserTest.kt index ad7c7a6..b2c920c 100644 --- a/app/src/test/java/com/example/esp32aldldashboard/ALDLParserTest.kt +++ b/app/src/test/java/com/example/esp32aldldashboard/ALDLParserTest.kt @@ -73,19 +73,12 @@ class ALDLParserTest { @Test fun testRpmBoundaryValues() { - // Max valid RPM: 8000 (320 * 25 = 8000) + // Max representable RPM: 6375 (255 * 25) val validPayload = createBasePayload().apply { - this[7] = 320.toByte() // 320 * 25 = 8000 RPM + this[7] = 255.toByte() // 255 * 25 = 6375 RPM } val validResult = ALDLParser.parseFrame(validPayload) - assertTrue("RPM at exactly 8000 should be valid", validResult is com.example.esp32aldldashboard.parser.ALDLParseResult.Success) - - // Invalid RPM: 8001 (321 * 25 = 8025) - val invalidPayload = createBasePayload().apply { - this[7] = 321.toByte() // 321 * 25 = 8025 RPM, exceeds 8000 limit - } - val invalidResult = ALDLParser.parseFrame(invalidPayload) - assertTrue("RPM above 8000 should be rejected", invalidResult is com.example.esp32aldldashboard.parser.ALDLParseResult.InvalidData) + assertTrue("Max representable RPM should be valid", validResult is com.example.esp32aldldashboard.parser.ALDLParseResult.Success) } @Test @@ -121,19 +114,12 @@ class ALDLParserTest { @Test fun testTpsVoltageBoundaryValues() { - // Max valid: 5.1V (260 * 0.019608 = 5.098V ~ 5.1V) + // Max representable TPS: 5.0V (255 * 0.019608) val validPayload = createBasePayload().apply { - this[8] = 260.toByte() + this[8] = 255.toByte() } val validResult = ALDLParser.parseFrame(validPayload) - assertTrue("TPS at 5.1V should be valid", validResult is com.example.esp32aldldashboard.parser.ALDLParseResult.Success) - - // Too high: 5.2V (265 * 0.019608 = 5.196V ~ 5.2V, exceeds 5.1V) - val invalidPayload = createBasePayload().apply { - this[8] = 265.toByte() - } - val invalidResult = ALDLParser.parseFrame(invalidPayload) - assertTrue("TPS above 5.1V should be rejected", invalidResult is com.example.esp32aldldashboard.parser.ALDLParseResult.InvalidData) + assertTrue("Max representable TPS should be valid", validResult is com.example.esp32aldldashboard.parser.ALDLParseResult.Success) } @Test @@ -143,15 +129,19 @@ class ALDLParserTest { val validResult = ALDLParser.parseFrame(validPayload) assertTrue("Valid coolant temp should be accepted", validResult is com.example.esp32aldldashboard.parser.ALDLParseResult.Success) - // Too low: raw value 0 -> -40C, but below -45C limit - // Actually raw 0 gives -40C which is within bounds - // Let's test a very high raw value that gives > 220C - // raw 350 * 0.75 - 40 = 262.5 - 40 = 222.5C (exceeds 220C) - val highTempPayload = createBasePayload().apply { - this[4] = 350.toByte() + // Max representable: 255 * 0.75 - 40 = 151.25C + val maxPayload = createBasePayload().apply { + this[4] = 255.toByte() } - val highResult = ALDLParser.parseFrame(highTempPayload) - assertTrue("Coolant temp above 220C should be rejected", highResult is com.example.esp32aldldashboard.parser.ALDLParseResult.InvalidData) + val maxResult = ALDLParser.parseFrame(maxPayload) + assertTrue("Max representable coolant temp should be accepted", maxResult is com.example.esp32aldldashboard.parser.ALDLParseResult.Success) + + // Min representable: 0 * 0.75 - 40 = -40C + val minPayload = createBasePayload().apply { + this[4] = 0.toByte() + } + val minResult = ALDLParser.parseFrame(minPayload) + assertTrue("Min representable coolant temp should be accepted", minResult is com.example.esp32aldldashboard.parser.ALDLParseResult.Success) } @Test diff --git a/app/src/test/java/com/example/esp32aldldashboard/ui/main/MainScreenViewModelTest.kt b/app/src/test/java/com/example/esp32aldldashboard/ui/main/MainScreenViewModelTest.kt index 9ac286b..fee86e9 100644 --- a/app/src/test/java/com/example/esp32aldldashboard/ui/main/MainScreenViewModelTest.kt +++ b/app/src/test/java/com/example/esp32aldldashboard/ui/main/MainScreenViewModelTest.kt @@ -1,3 +1,4 @@ +@file:OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) package com.example.esp32aldldashboard.ui.main import com.example.esp32aldldashboard.bluetooth.ConnectionState @@ -7,21 +8,28 @@ import io.mockk.* import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertFalse import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After import org.junit.Before import org.junit.Test class MainScreenViewModelTest { + private val testDispatcher = UnconfinedTestDispatcher() private val telemetryRepository = mockk(relaxed = true) private val settingsRepository = mockk(relaxed = true) private val isCelsiusFlow = MutableStateFlow(false) @Before fun setUp() { - // Clear mocks + Dispatchers.setMain(testDispatcher) clearAllMocks() // Stub telemetry repository flows @@ -41,6 +49,11 @@ class MainScreenViewModelTest { } } + @After + fun tearDown() { + Dispatchers.resetMain() + } + @Test fun testInitialStates() = runTest { val viewModel = MainScreenViewModel(telemetryRepository, settingsRepository) @@ -54,19 +67,18 @@ class MainScreenViewModelTest { fun testToggleTemperatureUnit() = runTest { val viewModel = MainScreenViewModel(telemetryRepository, settingsRepository) - // Wait for stateIn initialization - runCurrent() + // Start collecting to activate WhileSubscribed stateIn flow + backgroundScope.launch(testDispatcher) { + viewModel.isCelsius.collect {} + } + assertFalse(viewModel.isCelsius.value) viewModel.toggleTemperatureUnit() - runCurrent() // Wait for viewmodel scope coroutine to execute settingsRepository.setIsCelsius - runCurrent() // Wait for settingsRepository update flow to propagate back through stateIn assertTrue(viewModel.isCelsius.value) viewModel.toggleTemperatureUnit() - runCurrent() - runCurrent() assertFalse(viewModel.isCelsius.value) } From 4a5379e3a1f7f5988815c2782d8ffaa851c47049 Mon Sep 17 00:00:00 2001 From: Gandalf Date: Fri, 12 Jun 2026 15:27:58 +0100 Subject: [PATCH 16/18] feat: enable horizontal scrolling for the BLM table container --- .../com/example/esp32aldldashboard/ui/blm/BLMTableScreen.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/example/esp32aldldashboard/ui/blm/BLMTableScreen.kt b/app/src/main/java/com/example/esp32aldldashboard/ui/blm/BLMTableScreen.kt index e1caad8..486da2b 100644 --- a/app/src/main/java/com/example/esp32aldldashboard/ui/blm/BLMTableScreen.kt +++ b/app/src/main/java/com/example/esp32aldldashboard/ui/blm/BLMTableScreen.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.* @@ -71,7 +72,9 @@ fun BLMTableScreen( Spacer(modifier = Modifier.height(16.dp)) // Table container - Column { + Column( + modifier = Modifier.horizontalScroll(rememberScrollState()) + ) { // MAP headers (top) Row { // Empty corner cell From d4c87efe7e13801eac9d8a61db9d709b616fd626 Mon Sep 17 00:00:00 2001 From: Gandalf Date: Fri, 12 Jun 2026 15:33:52 +0100 Subject: [PATCH 17/18] ci: add GitHub Actions workflow to automate APK builds and releases --- .github/workflows/release.yml | 53 +++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f066836 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,53 @@ +name: Build and Release APK + +on: + push: + branches: + - main + - master + - v2 + +jobs: + build-and-release: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Make gradlew executable + run: chmod +x ./gradlew + + - name: Run Unit Tests + run: ./gradlew test + + - name: Build Debug APK + run: ./gradlew assembleDebug + + - name: Get Short SHA + id: vars + run: echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_ENV + + - name: Rename APK + run: | + mv app/build/outputs/apk/debug/app-debug.apk app/build/outputs/apk/debug/esp32-aldl-dashboard-${{ env.short_sha }}.apk + + - name: Create Gitea Release + uses: softprops/action-gh-release@v2 + with: + tag_name: "build-${{ env.short_sha }}" + name: "Build ${{ env.short_sha }}" + files: app/build/outputs/apk/debug/esp32-aldl-dashboard-${{ env.short_sha }}.apk + prerelease: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From e5c4e00d76151ade07edc205d03ec488ff0dff26 Mon Sep 17 00:00:00 2001 From: Gandalf Date: Fri, 12 Jun 2026 15:50:58 +0100 Subject: [PATCH 18/18] ci: add Android SDK setup to release workflow and update deployment target selection timestamp --- .github/workflows/release.yml | 3 +++ .idea/deploymentTargetSelector.xml | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f066836..e4632d6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,6 +22,9 @@ jobs: java-version: '17' distribution: 'temurin' + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + - name: Setup Gradle uses: gradle/actions/setup-gradle@v3 diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index c33318b..b9263f9 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -4,7 +4,7 @@