feat: implement telemetry persistence, data visualization, and improved bluetooth stream parsing with frame statistics (#9)
Build and Release APK / build-and-release (push) Successful in 10m7s

Reviewed-on: #9
Co-authored-by: Gandalf <gordon@i3omb.com>
Co-committed-by: Gandalf <gordon@i3omb.com>
This commit was merged in pull request #9.
This commit is contained in:
2026-06-14 08:34:34 +01:00
committed by gronod
parent c95dbfe58e
commit ca3e4d73a5
46 changed files with 4505 additions and 956 deletions
+56
View File
@@ -0,0 +1,56 @@
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 Android SDK
uses: android-actions/setup-android@v3
- 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 }}
+1
View File
@@ -13,3 +13,4 @@
.externalNativeBuild
.cxx
local.properties
.windsurf/workflows/gitea-interaction.md
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AppInsightsSettings">
<option name="tabSettings">
<map>
<entry key="Firebase Crashlytics">
<value>
<InsightsFilterSettings>
<option name="connection">
<ConnectionSetting>
<option name="appId" value="PLACEHOLDER" />
<option name="mobileSdkAppId" value="" />
<option name="projectId" value="" />
<option name="projectNumber" value="" />
</ConnectionSetting>
</option>
<option name="signal" value="SIGNAL_UNSPECIFIED" />
<option name="timeIntervalDays" value="THIRTY_DAYS" />
<option name="visibilityType" value="ALL" />
</InsightsFilterSettings>
</value>
</entry>
</map>
</option>
</component>
</project>
+7
View File
@@ -4,6 +4,13 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-06-12T14:39:35.370192038Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=38021FDJG009WV" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
</selectionStates>
+1
View File
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PlanningModeManager">
<option name="approvalStates">
<map>
<entry key="20260611-222122-a9e9a066-af5b-42dc-aff4-518967db385e" value="false" />
<entry key="736035b4-cfea-4311-a3cb-e5170b1c7e0a" value="false" />
</map>
</option>
</component>
</project>
+786
View File
@@ -0,0 +1,786 @@
<ADXFORMAT version="1.01">
<!-- Written 06/12/2026 09:10:11 -->
<ADXHEADER>
<guid>2c5735e5-97cf-4284-b768-67c83cc3bac1</guid>
<flags>0x10001</flags>
<objectcount>55</objectcount>
<userversion>2</userversion>
<author>Gordon Bolton</author>
<desc>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</desc>
<baud>4800</baud>
<DEFAULTS datasizeinbits="8" sigdigits="2" outputtype="3" baud="0" signed="0" lsbfirst="0" float="0" />
<monitorcmd>SMART</monitorcmd>
</ADXHEADER>
<ADXMONITOR id="MONIMPORT" idhash="0xC5C183FF" title="Imported Mon">
<mainbkgcolor>0x00FFFFFF</mainbkgcolor>
<plotbkgcolor>0x00F0F0F0</plotbkgcolor>
<plotoutlinecolor>0x00000000</plotoutlinecolor>
<titlecolor>0x00000000</titlecolor>
<timeaxiscolor>0x00000000</timeaxiscolor>
<entrycount>0</entrycount>
</ADXMONITOR>
<ADXDASHBOARD id="DASHIMPORT" idhash="0x5D4D481F" title="Imported Dash">
<entrycount>6</entrycount>
<ADXDGENTRY gaugetype="1" left="0" top="0" right="33" bottom="50" />
<ADXDGENTRY gaugetype="1" left="33" top="0" right="66" bottom="50" />
<ADXDGENTRY gaugetype="1" left="66" top="0" right="99" bottom="50" />
<ADXDGENTRY gaugetype="1" left="0" top="50" right="33" bottom="100" />
<ADXDGENTRY gaugetype="1" left="33" top="50" right="66" bottom="100" />
<ADXDGENTRY gaugetype="1" left="66" top="50" right="99" bottom="100" />
</ADXDASHBOARD>
<ADXCLISTENPACKET id="SMART" idhash="0xE82E2B75" title="Data Transfer" flags="0x00000001">
<listentimeout>400</listentimeout>
<packetbodylength>25</packetbodylength>
<packetoffsetinbody>2</packetoffsetinbody>
<packetsize>27</packetsize>
<headerstring size="2">AA55</headerstring>
</ADXCLISTENPACKET>
<ADXLOOKUPTABLE id="52" idhash="0xDE689CB0" title="MAT C">
<desc>&lt;Comments&gt;</desc>
<inputtype>1</inputtype>
<outputtype>3</outputtype>
<lookupmode>0</lookupmode>
<entrycount>39</entrycount>
<tableentry input="1.000000" output="200.000000" />
<tableentry input="13.000000" output="150.000000" />
<tableentry input="14.000000" output="145.000000" />
<tableentry input="15.000000" output="140.000000" />
<tableentry input="17.000000" output="135.000000" />
<tableentry input="19.000000" output="130.000000" />
<tableentry input="22.000000" output="125.000000" />
<tableentry input="24.000000" output="120.000000" />
<tableentry input="27.000000" output="115.000000" />
<tableentry input="31.000000" output="110.000000" />
<tableentry input="35.000000" output="105.000000" />
<tableentry input="40.000000" output="100.000000" />
<tableentry input="45.000000" output="95.000000" />
<tableentry input="51.000000" output="90.000000" />
<tableentry input="57.000000" output="85.000000" />
<tableentry input="65.000000" output="80.000000" />
<tableentry input="73.000000" output="75.000000" />
<tableentry input="82.000000" output="70.000000" />
<tableentry input="93.000000" output="65.000000" />
<tableentry input="103.000000" output="60.000000" />
<tableentry input="115.000000" output="55.000000" />
<tableentry input="127.000000" output="50.000000" />
<tableentry input="140.000000" output="45.000000" />
<tableentry input="153.000000" output="40.000000" />
<tableentry input="166.000000" output="35.000000" />
<tableentry input="178.000000" output="30.000000" />
<tableentry input="190.000000" output="25.000000" />
<tableentry input="200.000000" output="20.000000" />
<tableentry input="210.000000" output="15.000000" />
<tableentry input="219.000000" output="10.000000" />
<tableentry input="226.000000" output="5.000000" />
<tableentry input="232.000000" output="0.000000" />
<tableentry input="238.000000" output="-5.000000" />
<tableentry input="242.000000" output="-10.000000" />
<tableentry input="246.000000" output="-15.000000" />
<tableentry input="248.000000" output="-20.000000" />
<tableentry input="251.000000" output="-25.000000" />
<tableentry input="252.000000" output="-30.000000" />
<tableentry input="256.000000" output="-40.000000" />
</ADXLOOKUPTABLE>
<ADXLOOKUPTABLE id="53" idhash="0xDE6883B0" title="MAT F">
<desc>&lt;Comments&gt;</desc>
<inputtype>1</inputtype>
<outputtype>3</outputtype>
<lookupmode>0</lookupmode>
<entrycount>39</entrycount>
<tableentry input="1.000000" output="392.000000" />
<tableentry input="13.000000" output="302.000000" />
<tableentry input="14.000000" output="293.000000" />
<tableentry input="15.000000" output="284.000000" />
<tableentry input="17.000000" output="275.000000" />
<tableentry input="19.000000" output="266.000000" />
<tableentry input="22.000000" output="257.000000" />
<tableentry input="24.000000" output="248.000000" />
<tableentry input="27.000000" output="239.000000" />
<tableentry input="31.000000" output="230.000000" />
<tableentry input="35.000000" output="221.000000" />
<tableentry input="40.000000" output="212.000000" />
<tableentry input="45.000000" output="203.000000" />
<tableentry input="51.000000" output="194.000000" />
<tableentry input="57.000000" output="185.000000" />
<tableentry input="65.000000" output="176.000000" />
<tableentry input="73.000000" output="167.000000" />
<tableentry input="82.000000" output="158.000000" />
<tableentry input="93.000000" output="149.000000" />
<tableentry input="103.000000" output="140.000000" />
<tableentry input="115.000000" output="131.000000" />
<tableentry input="127.000000" output="122.000000" />
<tableentry input="140.000000" output="113.000000" />
<tableentry input="153.000000" output="104.000000" />
<tableentry input="166.000000" output="95.000000" />
<tableentry input="178.000000" output="86.000000" />
<tableentry input="190.000000" output="77.000000" />
<tableentry input="200.000000" output="68.000000" />
<tableentry input="210.000000" output="59.000000" />
<tableentry input="219.000000" output="50.000000" />
<tableentry input="226.000000" output="41.000000" />
<tableentry input="232.000000" output="32.000000" />
<tableentry input="238.000000" output="23.000000" />
<tableentry input="242.000000" output="14.000000" />
<tableentry input="246.000000" output="5.000000" />
<tableentry input="248.000000" output="-4.000000" />
<tableentry input="251.000000" output="-13.000000" />
<tableentry input="252.000000" output="-22.000000" />
<tableentry input="256.000000" output="-40.000000" />
</ADXLOOKUPTABLE>
<ADXVALUE id="4" idhash="0x86F3533B" title="Vehicle Speed">
<parentcmdidhash>0xD05BBA9A</parentcmdidhash>
<units>MPH</units>
<packetoffset>0x05</packetoffset>
<range low="0.000000" high="255.000000" />
<alarms low="0.000000" high="255.000000" />
<digcount>2</digcount>
<outputtype>1</outputtype>
<MATH equation="X">
<VAR varID="X" type="native" />
</MATH>
</ADXVALUE>
<ADXVALUE id="7" idhash="0x86F35DBB" title="Engine Speed">
<parentcmdidhash>0xD05BBA9A</parentcmdidhash>
<units>RPM</units>
<packetoffset>0x07</packetoffset>
<range low="0.000000" high="6375.000000" />
<alarms low="0.000000" high="6375.000000" />
<digcount>2</digcount>
<outputtype>3</outputtype>
<MATH equation="X * 25.000000 + 0.000000">
<VAR varID="X" type="native" />
</MATH>
</ADXVALUE>
<ADXVALUE id="8" idhash="0x86F350FB" title="TPS">
<parentcmdidhash>0xD05BBA9A</parentcmdidhash>
<units>Volts</units>
<packetoffset>0x08</packetoffset>
<range low="0.000000" high="5.000000" />
<alarms low="0.000000" high="5.000000" />
<digcount>2</digcount>
<outputtype>3</outputtype>
<MATH equation="X * 0.019608 + 0.000000">
<VAR varID="X" type="native" />
</MATH>
</ADXVALUE>
<ADXVALUE id="2" idhash="0x86F34E3B" title="Coolant Temp">
<parentcmdidhash>0xD05BBA9A</parentcmdidhash>
<units>C</units>
<packetoffset>0x04</packetoffset>
<range low="-40.000000" high="151.250000" />
<alarms low="-40.000000" high="151.250000" />
<digcount>2</digcount>
<outputtype>3</outputtype>
<MATH equation="X * 0.750000 + -40.000000">
<VAR varID="X" type="native" />
</MATH>
</ADXVALUE>
<ADXVALUE id="3" idhash="0x86F3513B" title="Coolant Temp">
<parentcmdidhash>0xD05BBA9A</parentcmdidhash>
<units>F</units>
<packetoffset>0x04</packetoffset>
<range low="-40.000000" high="304.250000" />
<alarms low="-40.000000" high="304.250000" />
<digcount>2</digcount>
<outputtype>3</outputtype>
<MATH equation="X * 1.350000 + -40.000000">
<VAR varID="X" type="native" />
</MATH>
</ADXVALUE>
<ADXVALUE id="49" idhash="0xDE6A7930" title="MAT">
<parentcmdidhash>0xD05BBA9A</parentcmdidhash>
<units>C</units>
<packetoffset>0x16</packetoffset>
<range low="200.000000" high="-40.000000" />
<alarms low="200.000000" high="-40.000000" />
<digcount>2</digcount>
<outputtype>3</outputtype>
<MATH equation="X" lookupidhash="0xDE689CB0">
<VAR varID="X" type="native" />
</MATH>
</ADXVALUE>
<ADXVALUE id="50" idhash="0xDE688850" title="MAT">
<parentcmdidhash>0xD05BBA9A</parentcmdidhash>
<units>F</units>
<packetoffset>0x16</packetoffset>
<range low="392.000000" high="-40.000000" />
<alarms low="392.000000" high="-40.000000" />
<digcount>2</digcount>
<outputtype>3</outputtype>
<MATH equation="X" lookupidhash="0xDE6883B0">
<VAR varID="X" type="native" />
</MATH>
</ADXVALUE>
<ADXVALUE id="5" idhash="0x86F3445B" title="MAP">
<parentcmdidhash>0xD05BBA9A</parentcmdidhash>
<units>Volts</units>
<packetoffset>0x06</packetoffset>
<range low="0.000000" high="5.000000" />
<alarms low="0.000000" high="5.000000" />
<digcount>2</digcount>
<outputtype>3</outputtype>
<MATH equation="X * 0.019608 + 0.000000">
<VAR varID="X" type="native" />
</MATH>
</ADXVALUE>
<ADXVALUE id="6" idhash="0x86F347FB" title="MAP">
<parentcmdidhash>0xD05BBA9A</parentcmdidhash>
<units>kPa</units>
<packetoffset>0x06</packetoffset>
<range low="10.350000" high="104.449997" />
<alarms low="10.350000" high="104.449997" />
<digcount>2</digcount>
<outputtype>3</outputtype>
<MATH equation="X * 0.369000 + 10.354000">
<VAR varID="X" type="native" />
</MATH>
</ADXVALUE>
<ADXVALUE id="45" idhash="0xDE6A7AD0" title="BLM">
<parentcmdidhash>0xD05BBA9A</parentcmdidhash>
<packetoffset>0x12</packetoffset>
<range low="0.000000" high="255.000000" />
<alarms low="0.000000" high="255.000000" />
<digcount>2</digcount>
<outputtype>1</outputtype>
<MATH equation="X">
<VAR varID="X" type="native" />
</MATH>
</ADXVALUE>
<ADXVALUE id="9" idhash="0x86F347BB" title="INT">
<parentcmdidhash>0xD05BBA9A</parentcmdidhash>
<packetoffset>0x09</packetoffset>
<range low="0.000000" high="255.000000" />
<alarms low="0.000000" high="255.000000" />
<digcount>2</digcount>
<outputtype>1</outputtype>
<MATH equation="X">
<VAR varID="X" type="native" />
</MATH>
</ADXVALUE>
<ADXVALUE id="10" idhash="0xDE6A9050" title="O2 Sensor">
<parentcmdidhash>0xD05BBA9A</parentcmdidhash>
<units>mV</units>
<packetoffset>0x0A</packetoffset>
<range low="0.000000" high="1132.199951" />
<alarms low="0.000000" high="1132.199951" />
<digcount>2</digcount>
<outputtype>3</outputtype>
<MATH equation="X * 4.440000 + 0.000000">
<VAR varID="X" type="native" />
</MATH>
</ADXVALUE>
<ADXVALUE id="46" idhash="0xDE6A7970" title="Rich/Lean Transitions">
<parentcmdidhash>0xD05BBA9A</parentcmdidhash>
<units>Crosses</units>
<packetoffset>0x13</packetoffset>
<range low="0.000000" high="255.000000" />
<alarms low="0.000000" high="255.000000" />
<digcount>2</digcount>
<outputtype>1</outputtype>
<MATH equation="X">
<VAR varID="X" type="native" />
</MATH>
</ADXVALUE>
<ADXVALUE id="51" idhash="0xDE688610" title="BPW">
<parentcmdidhash>0xD05BBA9A</parentcmdidhash>
<units>mS</units>
<packetoffset>0x17</packetoffset>
<sizeinbits>16</sizeinbits>
<range low="0.000000" high="1000.010010" />
<alarms low="0.000000" high="3.890000" />
<digcount>2</digcount>
<outputtype>3</outputtype>
<MATH equation="X * 0.015259 + 0.000000">
<VAR varID="X" type="native" />
</MATH>
</ADXVALUE>
<ADXVALUE id="48" idhash="0xDE6A6E70" title="EGR Duty Cycle">
<parentcmdidhash>0xD05BBA9A</parentcmdidhash>
<units>%</units>
<packetoffset>0x15</packetoffset>
<range low="0.000000" high="100.000000" />
<alarms low="0.000000" high="100.000000" />
<digcount>2</digcount>
<outputtype>3</outputtype>
<MATH equation="X * 0.392157 + 0.000000">
<VAR varID="X" type="native" />
</MATH>
</ADXVALUE>
<ADXVALUE id="47" idhash="0xDE6A6330" title="Spark Advance">
<parentcmdidhash>0xD05BBA9A</parentcmdidhash>
<units>*</units>
<packetoffset>0x14</packetoffset>
<range low="0.000000" high="89.650002" />
<alarms low="0.000000" high="89.650002" />
<digcount>2</digcount>
<outputtype>3</outputtype>
<MATH equation="X * 0.351563 + 0.000000">
<VAR varID="X" type="native" />
</MATH>
</ADXVALUE>
<ADXVALUE id="44" idhash="0xDE6A6DB0" title="Battery Voltage">
<parentcmdidhash>0xD05BBA9A</parentcmdidhash>
<units>Volts</units>
<packetoffset>0x11</packetoffset>
<range low="0.000000" high="25.500000" />
<alarms low="0.000000" high="25.500000" />
<digcount>2</digcount>
<outputtype>3</outputtype>
<MATH equation="X * 0.100000 + 0.000000">
<VAR varID="X" type="native" />
</MATH>
</ADXVALUE>
<ADXVALUE id="1" idhash="0x86F3549B" title="IAC Position">
<parentcmdidhash>0xD05BBA9A</parentcmdidhash>
<units>Steps</units>
<packetoffset>0x03</packetoffset>
<range low="0.000000" high="255.000000" />
<alarms low="0.000000" high="255.000000" />
<digcount>2</digcount>
<outputtype>1</outputtype>
<MATH equation="X">
<VAR varID="X" type="native" />
</MATH>
</ADXVALUE>
<ADXBITMASK id="19" idhash="0xDE6A8D30" title="12 Crank Sensor/System Check">
<flags>0x00000004</flags>
<parentcmdidhash>0xD05BBA9A</parentcmdidhash>
<truestring>ERROR</truestring>
<ALARMONSET color="0x000000FF" />
<ALARMONNOTSET color="0x0000FF00" />
<packetoffset>0x0B</packetoffset>
<operand>0x00000080</operand>
<bitop>AND</bitop>
<result>0x00000080</result>
</ADXBITMASK>
<ADXBITMASK id="18" idhash="0xDE6A9A70" title="13 O2 Sensor">
<flags>0x00000004</flags>
<parentcmdidhash>0xD05BBA9A</parentcmdidhash>
<truestring>ERROR</truestring>
<ALARMONSET color="0x000000FF" />
<ALARMONNOTSET color="0x0000FF00" />
<packetoffset>0x0B</packetoffset>
<operand>0x00000040</operand>
<bitop>AND</bitop>
<result>0x00000040</result>
</ADXBITMASK>
<ADXBITMASK id="17" idhash="0xDE6A9730" title="14 Coolant High">
<flags>0x00000004</flags>
<parentcmdidhash>0xD05BBA9A</parentcmdidhash>
<truestring>ERROR</truestring>
<ALARMONSET color="0x000000FF" />
<ALARMONNOTSET color="0x0000FF00" />
<packetoffset>0x0B</packetoffset>
<operand>0x00000020</operand>
<bitop>AND</bitop>
<result>0x00000020</result>
</ADXBITMASK>
<ADXBITMASK id="16" idhash="0xDE6A8D70" title="15 Coolant Low">
<flags>0x00000004</flags>
<parentcmdidhash>0xD05BBA9A</parentcmdidhash>
<truestring>ERROR</truestring>
<ALARMONSET color="0x000000FF" />
<ALARMONNOTSET color="0x0000FF00" />
<packetoffset>0x0B</packetoffset>
<operand>0x00000010</operand>
<bitop>AND</bitop>
<result>0x00000010</result>
</ADXBITMASK>
<ADXBITMASK id="15" idhash="0xDE6A8ED0" title="21 TPS High">
<flags>0x00000004</flags>
<parentcmdidhash>0xD05BBA9A</parentcmdidhash>
<truestring>ERROR</truestring>
<ALARMONSET color="0x000000FF" />
<ALARMONNOTSET color="0x0000FF00" />
<packetoffset>0x0B</packetoffset>
<operand>0x00000008</operand>
<bitop>AND</bitop>
<result>0x00000008</result>
</ADXBITMASK>
<ADXBITMASK id="14" idhash="0xDE6A99B0" title="22 TPS Low">
<flags>0x00000004</flags>
<parentcmdidhash>0xD05BBA9A</parentcmdidhash>
<truestring>ERROR</truestring>
<ALARMONSET color="0x000000FF" />
<ALARMONNOTSET color="0x0000FF00" />
<packetoffset>0x0B</packetoffset>
<operand>0x00000004</operand>
<bitop>AND</bitop>
<result>0x00000004</result>
</ADXBITMASK>
<ADXBITMASK id="13" idhash="0xDE6A9BB0" title="23 MAT Low">
<flags>0x00000004</flags>
<parentcmdidhash>0xD05BBA9A</parentcmdidhash>
<truestring>ERROR</truestring>
<ALARMONSET color="0x000000FF" />
<ALARMONNOTSET color="0x0000FF00" />
<packetoffset>0x0B</packetoffset>
<operand>0x00000002</operand>
<bitop>AND</bitop>
<result>0x00000002</result>
</ADXBITMASK>
<ADXBITMASK id="12" idhash="0xDE6A84B0" title="24 VSS">
<flags>0x00000004</flags>
<parentcmdidhash>0xD05BBA9A</parentcmdidhash>
<truestring>ERROR</truestring>
<ALARMONSET color="0x000000FF" />
<ALARMONNOTSET color="0x0000FF00" />
<packetoffset>0x0B</packetoffset>
<operand>0x00000001</operand>
<bitop>AND</bitop>
<result>0x00000001</result>
</ADXBITMASK>
<ADXBITMASK id="25" idhash="0xDE69DAD0" title="25 MAT High">
<flags>0x00000004</flags>
<parentcmdidhash>0xD05BBA9A</parentcmdidhash>
<truestring>ERROR</truestring>
<ALARMONSET color="0x000000FF" />
<ALARMONNOTSET color="0x0000FF00" />
<packetoffset>0x0C</packetoffset>
<operand>0x00000080</operand>
<bitop>AND</bitop>
<result>0x00000080</result>
</ADXBITMASK>
<ADXBITMASK id="24" idhash="0xDE69CDB0" title="32 EGR">
<flags>0x00000004</flags>
<parentcmdidhash>0xD05BBA9A</parentcmdidhash>
<truestring>ERROR</truestring>
<ALARMONSET color="0x000000FF" />
<ALARMONNOTSET color="0x0000FF00" />
<packetoffset>0x0C</packetoffset>
<operand>0x00000020</operand>
<bitop>AND</bitop>
<result>0x00000020</result>
</ADXBITMASK>
<ADXBITMASK id="23" idhash="0xDE69CFB0" title="33 MAP High">
<flags>0x00000004</flags>
<parentcmdidhash>0xD05BBA9A</parentcmdidhash>
<truestring>ERROR</truestring>
<ALARMONSET color="0x000000FF" />
<ALARMONNOTSET color="0x0000FF00" />
<packetoffset>0x0C</packetoffset>
<operand>0x00000010</operand>
<bitop>AND</bitop>
<result>0x00000010</result>
</ADXBITMASK>
<ADXBITMASK id="22" idhash="0xDE69D0B0" title="34 MAP Low">
<flags>0x00000004</flags>
<parentcmdidhash>0xD05BBA9A</parentcmdidhash>
<truestring>ERROR</truestring>
<ALARMONSET color="0x000000FF" />
<ALARMONNOTSET color="0x0000FF00" />
<packetoffset>0x0C</packetoffset>
<operand>0x00000008</operand>
<bitop>AND</bitop>
<result>0x00000008</result>
</ADXBITMASK>
<ADXBITMASK id="21" idhash="0xDE69CA10" title="35 IAC">
<flags>0x00000004</flags>
<parentcmdidhash>0xD05BBA9A</parentcmdidhash>
<truestring>ERROR</truestring>
<ALARMONSET color="0x000000FF" />
<ALARMONNOTSET color="0x0000FF00" />
<packetoffset>0x0C</packetoffset>
<operand>0x00000004</operand>
<bitop>AND</bitop>
<result>0x00000004</result>
</ADXBITMASK>
<ADXBITMASK id="20" idhash="0xDE69C450" title="42 EST">
<flags>0x00000004</flags>
<parentcmdidhash>0xD05BBA9A</parentcmdidhash>
<truestring>ERROR</truestring>
<ALARMONSET color="0x000000FF" />
<ALARMONNOTSET color="0x0000FF00" />
<packetoffset>0x0C</packetoffset>
<operand>0x00000001</operand>
<bitop>AND</bitop>
<result>0x00000001</result>
</ADXBITMASK>
<ADXBITMASK id="32" idhash="0xDE6A30B0" title="43 Knock Sensor">
<flags>0x00000004</flags>
<parentcmdidhash>0xD05BBA9A</parentcmdidhash>
<truestring>ERROR</truestring>
<ALARMONSET color="0x000000FF" />
<ALARMONNOTSET color="0x0000FF00" />
<packetoffset>0x0D</packetoffset>
<operand>0x00000080</operand>
<bitop>AND</bitop>
<result>0x00000080</result>
</ADXBITMASK>
<ADXBITMASK id="31" idhash="0xDE6A2A10" title="44 O2 Lean">
<flags>0x00000004</flags>
<parentcmdidhash>0xD05BBA9A</parentcmdidhash>
<truestring>ERROR</truestring>
<ALARMONSET color="0x000000FF" />
<ALARMONNOTSET color="0x0000FF00" />
<packetoffset>0x0D</packetoffset>
<operand>0x00000040</operand>
<bitop>AND</bitop>
<result>0x00000040</result>
</ADXBITMASK>
<ADXBITMASK id="30" idhash="0xDE6A2450" title="45 O2 Rich">
<flags>0x00000004</flags>
<parentcmdidhash>0xD05BBA9A</parentcmdidhash>
<truestring>ERROR</truestring>
<ALARMONSET color="0x000000FF" />
<ALARMONNOTSET color="0x0000FF00" />
<packetoffset>0x0D</packetoffset>
<operand>0x00000020</operand>
<bitop>AND</bitop>
<result>0x00000020</result>
</ADXBITMASK>
<ADXBITMASK id="29" idhash="0xDE69D930" title="51 PROM">
<flags>0x00000004</flags>
<parentcmdidhash>0xD05BBA9A</parentcmdidhash>
<truestring>ERROR</truestring>
<ALARMONSET color="0x000000FF" />
<ALARMONNOTSET color="0x0000FF00" />
<packetoffset>0x0D</packetoffset>
<operand>0x00000010</operand>
<bitop>AND</bitop>
<result>0x00000010</result>
</ADXBITMASK>
<ADXBITMASK id="28" idhash="0xDE69CE70" title="52 CAL-PACK">
<flags>0x00000004</flags>
<parentcmdidhash>0xD05BBA9A</parentcmdidhash>
<truestring>ERROR</truestring>
<ALARMONSET color="0x000000FF" />
<ALARMONNOTSET color="0x0000FF00" />
<packetoffset>0x0D</packetoffset>
<operand>0x00000008</operand>
<bitop>AND</bitop>
<result>0x00000008</result>
</ADXBITMASK>
<ADXBITMASK id="27" idhash="0xDE69C330" title="53 Battery Voltage High">
<flags>0x00000004</flags>
<parentcmdidhash>0xD05BBA9A</parentcmdidhash>
<truestring>ERROR</truestring>
<ALARMONSET color="0x000000FF" />
<ALARMONNOTSET color="0x0000FF00" />
<packetoffset>0x0D</packetoffset>
<operand>0x00000004</operand>
<bitop>AND</bitop>
<result>0x00000004</result>
</ADXBITMASK>
<ADXBITMASK id="26" idhash="0xDE69D970" title="55 ADU">
<flags>0x00000004</flags>
<parentcmdidhash>0xD05BBA9A</parentcmdidhash>
<truestring>ERROR</truestring>
<ALARMONSET color="0x000000FF" />
<ALARMONNOTSET color="0x0000FF00" />
<packetoffset>0x0D</packetoffset>
<operand>0x00000001</operand>
<bitop>AND</bitop>
<result>0x00000001</result>
</ADXBITMASK>
<ADXBITMASK id="34" idhash="0xDE6A2DB0" title="BLM Enable">
<parentcmdidhash>0xD05BBA9A</parentcmdidhash>
<truestring>YES</truestring>
<ALARMONSET color="0x000000FF" />
<ALARMONNOTSET color="0x0000FF00" />
<packetoffset>0x0E</packetoffset>
<operand>0x00000002</operand>
<bitop>AND</bitop>
<result>0x00000002</result>
</ADXBITMASK>
<ADXBITMASK id="35" idhash="0xDE6A3AD0" title="Quasi Pulse">
<parentcmdidhash>0xD05BBA9A</parentcmdidhash>
<truestring>YES</truestring>
<ALARMONSET color="0x000000FF" />
<ALARMONNOTSET color="0x0000FF00" />
<packetoffset>0x0E</packetoffset>
<operand>0x00000008</operand>
<bitop>AND</bitop>
<result>0x00000008</result>
</ADXBITMASK>
<ADXBITMASK id="36" idhash="0xDE6A3970" title="Async Pulse">
<parentcmdidhash>0xD05BBA9A</parentcmdidhash>
<truestring>YES</truestring>
<ALARMONSET color="0x000000FF" />
<ALARMONNOTSET color="0x0000FF00" />
<packetoffset>0x0E</packetoffset>
<operand>0x00000010</operand>
<bitop>AND</bitop>
<result>0x00000010</result>
</ADXBITMASK>
<ADXBITMASK id="37" idhash="0xDE6A2330" title="Rich/Lean">
<parentcmdidhash>0xD05BBA9A</parentcmdidhash>
<truestring>RICH</truestring>
<falsestring>LEAN</falsestring>
<ALARMONSET color="0x000000FF" />
<ALARMONNOTSET color="0x0000FF00" />
<packetoffset>0x0E</packetoffset>
<operand>0x00000040</operand>
<bitop>AND</bitop>
<result>0x00000040</result>
</ADXBITMASK>
<ADXBITMASK id="38" idhash="0xDE6A2E70" title="Loop Status">
<flags>0x00000008</flags>
<parentcmdidhash>0xD05BBA9A</parentcmdidhash>
<truestring>CLOSED</truestring>
<falsestring>OPEN</falsestring>
<ALARMONSET color="0x000000FF" />
<ALARMONNOTSET color="0x0000FF00" />
<packetoffset>0x0E</packetoffset>
<operand>0x00000080</operand>
<bitop>AND</bitop>
<result>0x00000080</result>
</ADXBITMASK>
<ADXBITMASK id="39" idhash="0xDE6A3930" title="A/C">
<parentcmdidhash>0xD05BBA9A</parentcmdidhash>
<falsestring>ENABLED</falsestring>
<ALARMONSET color="0x000000FF" />
<ALARMONNOTSET color="0x0000FF00" />
<packetoffset>0x0F</packetoffset>
<operand>0x00000020</operand>
<bitop>AND</bitop>
<result>0x00000020</result>
</ADXBITMASK>
<ADXBITMASK id="40" idhash="0xDE6A6450" title="P/N Switch">
<parentcmdidhash>0xD05BBA9A</parentcmdidhash>
<truestring>PARK/NEUTRAL</truestring>
<ALARMONSET color="0x000000FF" />
<ALARMONNOTSET color="0x0000FF00" />
<packetoffset>0x0F</packetoffset>
<operand>0x00000080</operand>
<bitop>AND</bitop>
<result>0x00000080</result>
</ADXBITMASK>
<ADXBITMASK id="41" idhash="0xDE6A6A10" title="A/C Clutch">
<parentcmdidhash>0xD05BBA9A</parentcmdidhash>
<truestring>ENABLED</truestring>
<ALARMONSET color="0x000000FF" />
<ALARMONNOTSET color="0x0000FF00" />
<packetoffset>0x10</packetoffset>
<operand>0x00000001</operand>
<bitop>AND</bitop>
<result>0x00000001</result>
</ADXBITMASK>
<ADXBITMASK id="42" idhash="0xDE6A70B0" title="TCC">
<parentcmdidhash>0xD05BBA9A</parentcmdidhash>
<truestring>LOCKED</truestring>
<ALARMONSET color="0x000000FF" />
<ALARMONNOTSET color="0x0000FF00" />
<packetoffset>0x10</packetoffset>
<operand>0x00000004</operand>
<bitop>AND</bitop>
<result>0x00000004</result>
</ADXBITMASK>
<ADXBITMASK id="43" idhash="0xDE6A6FB0" title="Power Steering Cramp">
<parentcmdidhash>0xD05BBA9A</parentcmdidhash>
<truestring>ACTIVE</truestring>
<ALARMONSET color="0x000000FF" />
<ALARMONNOTSET color="0x0000FF00" />
<packetoffset>0x10</packetoffset>
<operand>0x00000020</operand>
<bitop>AND</bitop>
<result>0x00000020</result>
</ADXBITMASK>
<ADXLISTVIEW id="DEFAULTVIEW" idhash="0x38ADC566" title="Default View">
<entrycount>49</entrycount>
<ADXLVENTRY entrytype="0" itemidhash="0x86F3533B" />
<ADXLVENTRY entrytype="0" itemidhash="0x86F35DBB" />
<ADXLVENTRY entrytype="0" itemidhash="0x86F350FB" />
<ADXLVENTRY entrytype="0" itemidhash="0x86F34E3B" />
<ADXLVENTRY entrytype="0" itemidhash="0x86F3513B" />
<ADXLVENTRY entrytype="0" itemidhash="0xDE6A7930" />
<ADXLVENTRY entrytype="0" itemidhash="0xDE688850" />
<ADXLVENTRY entrytype="0" itemidhash="0x86F3445B" />
<ADXLVENTRY entrytype="0" itemidhash="0x86F347FB" />
<ADXLVENTRY entrytype="0" itemidhash="0xDE6A7AD0" />
<ADXLVENTRY entrytype="0" itemidhash="0x86F347BB" />
<ADXLVENTRY entrytype="0" itemidhash="0xDE6A9050" />
<ADXLVENTRY entrytype="0" itemidhash="0xDE6A7970" />
<ADXLVENTRY entrytype="0" itemidhash="0xDE688610" />
<ADXLVENTRY entrytype="0" itemidhash="0xDE6A6E70" />
<ADXLVENTRY entrytype="0" itemidhash="0xDE6A6330" />
<ADXLVENTRY entrytype="0" itemidhash="0xDE6A6DB0" />
<ADXLVENTRY entrytype="0" itemidhash="0x86F3549B" />
<ADXLVENTRY entrytype="0" itemidhash="0xDE6A8D30" />
<ADXLVENTRY entrytype="0" itemidhash="0xDE6A9A70" />
<ADXLVENTRY entrytype="0" itemidhash="0xDE6A9730" />
<ADXLVENTRY entrytype="0" itemidhash="0xDE6A8D70" />
<ADXLVENTRY entrytype="0" itemidhash="0xDE6A8ED0" />
<ADXLVENTRY entrytype="0" itemidhash="0xDE6A99B0" />
<ADXLVENTRY entrytype="0" itemidhash="0xDE6A9BB0" />
<ADXLVENTRY entrytype="0" itemidhash="0xDE6A84B0" />
<ADXLVENTRY entrytype="0" itemidhash="0xDE69DAD0" />
<ADXLVENTRY entrytype="0" itemidhash="0xDE69CDB0" />
<ADXLVENTRY entrytype="0" itemidhash="0xDE69CFB0" />
<ADXLVENTRY entrytype="0" itemidhash="0xDE69D0B0" />
<ADXLVENTRY entrytype="0" itemidhash="0xDE69CA10" />
<ADXLVENTRY entrytype="0" itemidhash="0xDE69C450" />
<ADXLVENTRY entrytype="0" itemidhash="0xDE6A30B0" />
<ADXLVENTRY entrytype="0" itemidhash="0xDE6A2A10" />
<ADXLVENTRY entrytype="0" itemidhash="0xDE6A2450" />
<ADXLVENTRY entrytype="0" itemidhash="0xDE69D930" />
<ADXLVENTRY entrytype="0" itemidhash="0xDE69CE70" />
<ADXLVENTRY entrytype="0" itemidhash="0xDE69C330" />
<ADXLVENTRY entrytype="0" itemidhash="0xDE69D970" />
<ADXLVENTRY entrytype="0" itemidhash="0xDE6A2DB0" />
<ADXLVENTRY entrytype="0" itemidhash="0xDE6A3AD0" />
<ADXLVENTRY entrytype="0" itemidhash="0xDE6A3970" />
<ADXLVENTRY entrytype="0" itemidhash="0xDE6A2330" />
<ADXLVENTRY entrytype="0" itemidhash="0xDE6A2E70" />
<ADXLVENTRY entrytype="0" itemidhash="0xDE6A3930" />
<ADXLVENTRY entrytype="0" itemidhash="0xDE6A6450" />
<ADXLVENTRY entrytype="0" itemidhash="0xDE6A6A10" />
<ADXLVENTRY entrytype="0" itemidhash="0xDE6A70B0" />
<ADXLVENTRY entrytype="0" itemidhash="0xDE6A6FB0" />
</ADXLISTVIEW>
</ADXFORMAT>
+21
View File
@@ -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.
+53 -96
View File
@@ -1,119 +1,76 @@
# 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.
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.
---
## Technical Specifications
## 🌟 Key Features
### 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.
| 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) |
* **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.
---
## Parameter Offsets & Decoding Formulas
## 🛠️ Architecture & Technical Highlights
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`).
### 1. Primary Sensors & Measurements
| Parameter | Payload Index | Raw Size | Formula / Conversion | Units |
| :--- | :--- | :--- | :--- | :--- |
| **IAC Position** | Index 3 (Byte 4) | 8-bit | $Value = Raw$ | Steps |
| **Coolant Temp** | Index 4 (Byte 5) | 8-bit | $C = (Raw \times 0.75) - 40$<br>$F = (Raw \times 1.35) - 40$ | °C / °F |
| **Vehicle Speed** | Index 5 (Byte 6) | 8-bit | $Value = Raw$ (Operation 3) | MPH |
| **MAP** | Index 6 (Byte 7) | 8-bit | $Volts = Raw \times 0.019608$<br>$kPa = (Raw \times 0.369) + 10.354$ | Volts / kPa |
| **Engine Speed** | Index 7 (Byte 8) | 8-bit | $RPM = Raw \times 25$ | RPM |
| **TPS** | Index 8 (Byte 9) | 8-bit | $Volts = Raw \times 0.019608$ | Volts |
| **Integrator (INT)** | Index 9 (Byte 10) | 8-bit | $Value = Raw$ | — |
| **O2 Sensor** | Index 10 (Byte 11) | 8-bit | $mV = Raw \times 4.44$ | mV |
| **Battery Voltage** | Index 17 (Byte 18) | 8-bit | $Volts = Raw \times 0.1$ | Volts |
| **BLM** | Index 18 (Byte 19) | 8-bit | $Value = Raw$ | — |
| **Rich/Lean Crosses** | Index 19 (Byte 20) | 8-bit | $Value = Raw$ | Crosses |
| **Spark Advance** | Index 20 (Byte 21) | 8-bit | $Degrees = Raw \times 0.351563$ | Degrees |
| **EGR Duty Cycle** | Index 21 (Byte 22) | 8-bit | $Percent = Raw \times 0.392157$ | % |
| **Manifold Air Temp (MAT)** | Index 22 (Byte 23) | 8-bit | **Linear Table Interpolation** (see below) | °C / °F |
| **Base Pulse Width (BPW)** | Index 23-24 (Byte 24-25) | 16-bit | $Raw = (HighByte \ll 8) \vert LowByte$<br>$ms = Raw \times 0.015259$ | Milliseconds (ms) |
* **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<Byte>` 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.
---
### 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:
## 📱 Requirements
* **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`
* **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`).
---
### 3. Stored Fault Trouble Codes
## 🚀 Setup & Usage
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
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.
---
### 4. Engine Status & Bit Flags
## 🧪 Testing & Verification
#### 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)
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).
#### 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)
To run the unit tests, execute the following Gradle command in the root project directory:
```bash
./gradlew test
```
#### 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.
### 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.
+12
View File
@@ -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 {
@@ -61,6 +62,8 @@ 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)
implementation(libs.androidx.compose.material.icons.extended)
// Tooling
debugImplementation(libs.androidx.compose.ui.tooling)
// Instrumented tests
@@ -70,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)
@@ -81,4 +85,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)
ksp(libs.androidx.room.compiler)
// DataStore
implementation(libs.androidx.datastore.preferences)
}
+10
View File
@@ -13,8 +13,14 @@
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" android:maxSdkVersion="30" />
<!-- Foreground Service & Notifications -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".AldlApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
@@ -22,6 +28,10 @@
android:supportsRtl="true"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.ESP32ALDLDashboard">
<service
android:name=".bluetooth.BluetoothForegroundService"
android:foregroundServiceType="connectedDevice"
android:exported="false" />
<activity
android:name=".MainActivity"
android:exported="true">
@@ -0,0 +1,41 @@
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
import com.example.esp32aldldashboard.logging.CsvLogger
import com.example.esp32aldldashboard.logging.RawStreamLogger
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: 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)
telemetryRepository = TelemetryRepository(
this,
bluetoothService,
database.telemetryDao(),
csvLogger,
settingsRepository,
blmTableRepository
)
}
}
@@ -20,7 +20,7 @@ fun MainNavigation() {
entryProvider =
entryProvider {
entry<Main> {
MainScreen(onItemClick = { navKey -> backStack.add(navKey) }, modifier = Modifier.safeDrawingPadding().padding(16.dp))
MainScreen(modifier = Modifier.safeDrawingPadding().padding(horizontal = 0.dp))
}
},
)
@@ -0,0 +1,136 @@
package com.example.esp32aldldashboard.bluetooth
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import com.example.esp32aldldashboard.AldlApplication
import com.example.esp32aldldashboard.MainActivity
import com.example.esp32aldldashboard.R
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
class BluetoothForegroundService : Service() {
private val CHANNEL_ID = "ALDL_BT_CHANNEL"
private val NOTIFICATION_ID = 101
private val serviceScope = CoroutineScope(Dispatchers.Main + Job())
private lateinit var bluetoothService: BluetoothService
companion object {
const val ACTION_START = "ACTION_START"
const val ACTION_STOP = "ACTION_STOP"
const val ACTION_DISCONNECT = "ACTION_DISCONNECT"
}
override fun onCreate() {
super.onCreate()
val app = application as AldlApplication
bluetoothService = app.bluetoothService
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START -> {
startForeground(NOTIFICATION_ID, createNotification("Connected to ESP32-ALDL"))
observeTelemetry()
}
ACTION_DISCONNECT -> {
bluetoothService.disconnect()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
ACTION_STOP -> {
bluetoothService.disconnect()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
}
return START_NOT_STICKY
}
private fun observeTelemetry() {
serviceScope.launch {
bluetoothService.latestFrame.collectLatest { frame ->
if (frame != null) {
updateNotification("RPM: ${frame.engineSpeedRpm} | Coolant: ${String.format("%.1f", frame.coolantTempC)}°C")
}
}
}
serviceScope.launch {
bluetoothService.connectionState.collectLatest { state ->
when (state) {
ConnectionState.DISCONNECTED, ConnectionState.ERROR -> {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
else -> {}
}
}
}
}
private fun createNotification(contentText: String): Notification {
val intent = Intent(this, MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity(
this, 0, intent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val disconnectIntent = Intent(this, BluetoothForegroundService::class.java).apply {
action = ACTION_DISCONNECT
}
val disconnectPendingIntent = PendingIntent.getService(
this, 1, disconnectIntent, PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("ALDL Telemetry Active")
.setContentText(contentText)
.setSmallIcon(R.mipmap.ic_launcher) // Use app icon for now
.setContentIntent(pendingIntent)
.addAction(0, "Disconnect", disconnectPendingIntent)
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOnlyAlertOnce(true)
.build()
}
private fun updateNotification(contentText: String) {
val manager = getSystemService(NotificationManager::class.java)
manager.notify(NOTIFICATION_ID, createNotification(contentText))
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val serviceChannel = NotificationChannel(
CHANNEL_ID,
"Bluetooth Connection Service",
NotificationManager.IMPORTANCE_LOW
)
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(serviceChannel)
}
}
override fun onBind(intent: Intent?): IBinder? {
return null
}
override fun onDestroy() {
super.onDestroy()
serviceScope.launch {
// cancel jobs if needed
}
}
}
@@ -9,6 +9,9 @@ import android.content.Context
import android.util.Log
import com.example.esp32aldldashboard.parser.ALDLFrame
import com.example.esp32aldldashboard.parser.ALDLParser
import com.example.esp32aldldashboard.logging.RawStreamLogger
import com.example.esp32aldldashboard.repository.SettingsRepository
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -23,7 +26,11 @@ enum class ConnectionState {
ERROR
}
class BluetoothService(private val context: Context) {
class BluetoothService(
private val context: Context,
private val rawStreamLogger: RawStreamLogger,
private val settingsRepository: SettingsRepository
) {
private val TAG = "ALDLBluetoothService"
private val SPP_UUID = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB")
@@ -37,9 +44,73 @@ class BluetoothService(private val context: Context) {
private val _rawHexLog = MutableStateFlow<List<String>>(emptyList())
val rawHexLog: StateFlow<List<String>> = _rawHexLog
private val _framesReceived = MutableStateFlow(0)
val framesReceived: StateFlow<Int> = _framesReceived
private val _parseErrors = MutableStateFlow(0)
val parseErrors: StateFlow<Int> = _parseErrors
private val _currentFrameRate = MutableStateFlow(0)
val currentFrameRate: StateFlow<Int> = _currentFrameRate
private val _errorMessage = MutableStateFlow("")
val errorMessage: StateFlow<String> = _errorMessage
private data class HeaderResult(val found: Boolean, val index: Int = -1)
/**
* Finds a valid AA 55 header in the buffer using 27-byte lookahead validation.
* If another AA 55 is found within 27 bytes of a detected header, the first is treated
* as false (payload data) and search continues.
*/
private fun findValidHeader(buffer: ArrayDeque<Byte>): HeaderResult {
if (buffer.size < 27) return HeaderResult(false)
val bytes = buffer.toList()
var searchStart = 0
while (searchStart < bytes.size - 1) {
// Look for AA 55 starting at searchStart
var headerIdx = -1
for (i in searchStart until bytes.size - 1) {
val prev = bytes[i].toInt() and 0xFF
val curr = bytes[i + 1].toInt() and 0xFF
if (prev == 0xAA && curr == 0x55) {
headerIdx = i
break
}
}
if (headerIdx == -1) {
return HeaderResult(false) // No header found
}
// Check if there's another AA 55 within 27 bytes (indicating false header)
val lookAheadEnd = minOf(headerIdx + 27, bytes.size - 1)
var foundFalseHeader = false
for (i in headerIdx + 2 until lookAheadEnd) {
if (i + 1 >= bytes.size) break
val prev = bytes[i].toInt() and 0xFF
val curr = bytes[i + 1].toInt() and 0xFF
if (prev == 0xAA && curr == 0x55) {
// Found another header within 27 bytes - first one is likely false
foundFalseHeader = true
searchStart = headerIdx + 1 // Continue searching from after first AA
break
}
}
if (!foundFalseHeader) {
// Valid header found
return HeaderResult(true, headerIdx)
}
// Otherwise continue loop to find next candidate
}
return HeaderResult(false)
}
private var connectionJob: Job? = null
private var socket: BluetoothSocket? = null
private var isConnected = false
@@ -68,6 +139,12 @@ class BluetoothService(private val context: Context) {
_errorMessage.value = ""
connectionJob = CoroutineScope(Dispatchers.Default).launch {
// Start raw recording if enabled
val shouldRecordRaw = settingsRepository.recordRawDataFlow.first()
if (shouldRecordRaw) {
rawStreamLogger.startRecording()
}
var simStep = 0
val basePayload = byteArrayOf(
0x20.toByte(), 0x00.toByte(), 0x2A.toByte(), 0x5F.toByte(), 0x59.toByte(),
@@ -157,14 +234,26 @@ 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)")
// Log raw frame if recording is enabled
if (rawStreamLogger.isRecording()) {
val rawFrame = ByteArray(27)
rawFrame[0] = 0xAA.toByte()
rawFrame[1] = 0x55.toByte()
payload.copyInto(rawFrame, 2, 0, 25)
rawStreamLogger.logFrame(rawFrame)
}
}
delay(1000)
}
// Stop raw recording when simulation ends
rawStreamLogger.stopRecording()
}
}
@@ -218,8 +307,17 @@ class BluetoothService(private val context: Context) {
_connectionState.value = ConnectionState.CONNECTED
}
// Start raw recording if enabled
val shouldRecordRaw = settingsRepository.recordRawDataFlow.first()
if (shouldRecordRaw) {
rawStreamLogger.startRecording()
}
readDataStream(socket!!.inputStream)
// Stop raw recording when connection ends
rawStreamLogger.stopRecording()
} catch (e: Exception) {
Log.e(TAG, "Connection failed: ${e.message}", e)
withContext(Dispatchers.Main) {
@@ -233,7 +331,9 @@ class BluetoothService(private val context: Context) {
private suspend fun readDataStream(inputStream: InputStream) {
val readBuffer = ByteArray(128)
val syncBuffer = ArrayList<Byte>()
val syncBuffer = ArrayDeque<Byte>(128)
var lastFrameTime = System.currentTimeMillis()
var framesInCurrentSecond = 0
while (currentCoroutineContext().isActive && isConnected) {
try {
@@ -245,55 +345,83 @@ 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 headerResult = findValidHeader(syncBuffer)
if (headerResult.found) {
val headerIdx = headerResult.index
// Discard garbage preceding header
if (headerIdx > 0) {
_parseErrors.value += 1
for (i in 0 until headerIdx) {
syncBuffer.removeFirst()
}
foundHeader = true
break
}
}
if (foundHeader) {
// Validate we have enough data for full frame
if (syncBuffer.size >= 27) {
val payload = ByteArray(25)
for (p in 0 until 25) {
payload[p] = syncBuffer[p + 2]
}
syncBuffer.removeFirst() // AA
syncBuffer.removeFirst() // 55
// Consume the 27 bytes from buffer
for (r in 0 until 27) {
syncBuffer.removeAt(0)
val payload = ByteArray(25)
val rawFrame = ByteArray(27) // Include header for raw logging
rawFrame[0] = 0xAA.toByte()
rawFrame[1] = 0x55.toByte()
for (p in 0 until 25) {
payload[p] = syncBuffer.removeFirst()
rawFrame[p + 2] = payload[p]
}
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
}
}
// Log raw frame if recording is enabled
if (rawStreamLogger.isRecording()) {
rawStreamLogger.logFrame(rawFrame)
}
} else {
// Header found, but waiting for full 27-byte frame
break
}
} else {
// Header sequence not found, purge all but last byte if it is part of a potential header
// No valid header found, purge all but potential header start
val lastByte = syncBuffer.last()
syncBuffer.clear()
if ((lastByte.toInt() and 0xFF) == 0xAA) {
syncBuffer.add(lastByte)
syncBuffer.addLast(lastByte)
} else {
_parseErrors.value += 1
}
break
}
@@ -0,0 +1,13 @@
package com.example.esp32aldldashboard.data.database
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "sessions")
data class SessionEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val startTime: Long,
val endTime: Long? = null,
val name: String = "",
val isSimulation: Boolean = false
)
@@ -0,0 +1,31 @@
package com.example.esp32aldldashboard.data.database
import androidx.room.Dao
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): Int
@Insert
suspend fun insertDataPoints(dataPoints: List<TelemetryDataPointEntity>): List<Long>
@Query("SELECT * FROM sessions ORDER BY startTime DESC")
fun getAllSessions(): Flow<List<SessionEntity>>
@Query("SELECT * FROM telemetry_data_points WHERE sessionId = :sessionId ORDER BY timestamp ASC")
fun getSessionData(sessionId: Long): Flow<List<TelemetryDataPointEntity>>
@Query("DELETE FROM sessions WHERE id = :sessionId")
suspend fun deleteSession(sessionId: Long): Int
}
@@ -0,0 +1,47 @@
package com.example.esp32aldldashboard.data.database
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "telemetry_data_points",
foreignKeys = [
ForeignKey(
entity = SessionEntity::class,
parentColumns = ["id"],
childColumns = ["sessionId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [
Index(value = ["sessionId", "timestamp"])
]
)
data class TelemetryDataPointEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val sessionId: Long,
val timestamp: Long,
val rawBytes: ByteArray
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as TelemetryDataPointEntity
if (id != other.id) return false
if (sessionId != other.sessionId) return false
if (timestamp != other.timestamp) return false
return rawBytes.contentEquals(other.rawBytes)
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + sessionId.hashCode()
result = 31 * result + timestamp.hashCode()
result = 31 * result + rawBytes.contentHashCode()
return result
}
}
@@ -0,0 +1,29 @@
package com.example.esp32aldldashboard.data.database
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@Database(entities = [SessionEntity::class, TelemetryDataPointEntity::class], version = 1, exportSchema = false)
abstract class TelemetryDatabase : RoomDatabase() {
abstract fun telemetryDao(): TelemetryDao
companion object {
@Volatile
private var INSTANCE: TelemetryDatabase? = null
fun getDatabase(context: Context): TelemetryDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
TelemetryDatabase::class.java,
"telemetry_database"
).build()
INSTANCE = instance
instance
}
}
}
}
@@ -0,0 +1,100 @@
package com.example.esp32aldldashboard.logging
import android.content.ContentValues
import android.content.Context
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import com.example.esp32aldldashboard.parser.ALDLFrame
import java.io.OutputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class CsvLogger(private val context: Context) {
private var currentOutputStream: OutputStream? = null
fun startNewSession(isSimulation: Boolean): Boolean {
val timeStamp: String = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val fileName = "ALDL_Log_${if(isSimulation) "SIM_" else ""}$timeStamp.csv"
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val resolver = context.contentResolver
val contentValues = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
put(MediaStore.MediaColumns.MIME_TYPE, "text/csv")
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS + "/ALDLLogs")
}
val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
if (uri != null) {
currentOutputStream = resolver.openOutputStream(uri)
}
} else {
// For older Android versions, we'd need WRITE_EXTERNAL_STORAGE and write to Environment.getExternalStoragePublicDirectory.
// We'll skip legacy support for this specific snippet to keep it concise, assuming target is modern Android.
return false
}
// Write CSV Header
val header = "Timestamp,Raw_Hex,RPM,Coolant_C,Coolant_F,MAP_kPa,MAP_Volts,TPS_Volts,O2_mV,Battery_V,Spark_Adv,IAC,BPW_ms,Speed_MPH,MAT_C,MAT_F,BLM,INT,EGR_Duty,Rich_Crosses,Closed_Loop,Rich\n"
currentOutputStream?.write(header.toByteArray())
return true
} catch (e: Exception) {
e.printStackTrace()
return false
}
}
fun logFrame(frame: ALDLFrame) {
val out = currentOutputStream ?: return
val hexString = frame.rawBytes.joinToString(" ") { String.format("%02X", it) }
val dateString = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US).format(Date(frame.timestamp))
val row = String.format(
Locale.US,
"%s,%s,%d,%.1f,%.1f,%.1f,%.3f,%.3f,%.1f,%.1f,%.1f,%d,%.2f,%d,%.1f,%.1f,%d,%d,%.1f,%d,%b,%b\n",
dateString,
hexString,
frame.engineSpeedRpm,
frame.coolantTempC,
frame.coolantTempF,
frame.mapKpa,
frame.mapVolts,
frame.tpsVolts,
frame.o2SensorMv,
frame.batteryVolts,
frame.sparkAdvance,
frame.iacPosition,
frame.bpwMs,
frame.vehicleSpeedMPH,
frame.matC,
frame.matF,
frame.blm,
frame.integrator,
frame.egrDutyCycle,
frame.richLeanCrosses,
frame.isClosedLoop,
frame.isRich
)
try {
out.write(row.toByteArray())
} catch (e: Exception) {
e.printStackTrace()
}
}
fun endSession() {
try {
currentOutputStream?.close()
} catch (e: Exception) {
e.printStackTrace()
} finally {
currentOutputStream = null
}
}
}
@@ -0,0 +1,98 @@
package com.example.esp32aldldashboard.logging
import android.content.ContentValues
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import java.io.OutputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Logs raw binary ALDL data streams for debugging purposes.
* Records full 27-byte frames (AA 55 header + 25-byte payload) to Downloads/ALDLLogs/
*/
class RawStreamLogger(private val context: Context) {
private var currentOutputStream: OutputStream? = null
private var currentUri: Uri? = null
private var isRecording = false
/**
* Starts a new raw recording session.
* @return true if recording started successfully, false otherwise
*/
fun startRecording(): Boolean {
if (isRecording) return true
val timeStamp: String = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.US).format(Date())
val fileName = "raw_$timeStamp.bin"
return 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, "application/octet-stream")
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS + "/ALDLLogs")
}
val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
if (uri != null) {
currentUri = uri
currentOutputStream = resolver.openOutputStream(uri)
isRecording = true
true
} else {
false
}
} else {
// Legacy support not implemented for this debug feature
false
}
} catch (e: Exception) {
e.printStackTrace()
false
}
}
/**
* Logs a single 27-byte frame (including AA 55 header).
* @param frame The complete 27-byte frame to log
*/
fun logFrame(frame: ByteArray) {
if (!isRecording || frame.size != 27) return
try {
currentOutputStream?.write(frame)
} catch (e: Exception) {
e.printStackTrace()
}
}
/**
* Stops the current recording session and closes the file.
*/
fun stopRecording() {
if (!isRecording) return
try {
currentOutputStream?.flush()
currentOutputStream?.close()
} catch (e: Exception) {
e.printStackTrace()
} finally {
currentOutputStream = null
currentUri = null
isRecording = false
}
}
/**
* Returns whether a recording session is currently active.
*/
fun isRecording(): Boolean = isRecording
}
@@ -45,93 +45,78 @@ data class ALDLFrame(
}
}
sealed class ALDLParseResult {
data class Success(val frame: ALDLFrame) : ALDLParseResult()
data class InvalidData(val reason: String) : ALDLParseResult()
object Incomplete : ALDLParseResult()
}
object ALDLConstants {
const val PAYLOAD_SIZE = 25
// Scale Factors
const val COOLANT_SCALE_C = 0.75f
const val COOLANT_OFFSET_C = -40.0f
const val COOLANT_SCALE_F = 1.35f
const val COOLANT_OFFSET_F = -40.0f
const val MAP_VOLTS_SCALE = 0.019608f
const val MAP_KPA_SCALE = 0.369f
const val MAP_KPA_OFFSET = 10.354f
const val RPM_SCALE = 25
const val TPS_VOLTS_SCALE = 0.019608f
const val O2_MV_SCALE = 4.44f
const val BATTERY_VOLTS_SCALE = 0.1f
const val SPARK_ADVANCE_SCALE = 0.351563f
const val EGR_DUTY_CYCLE_SCALE = 0.392157f
const val BPW_MS_SCALE = 0.015259f
// Plausibility Limits
const val MAX_RPM = 8000
const val MIN_TEMP_C = -45.0f
const val MAX_TEMP_C = 220.0f
const val MIN_BATTERY_V = 5.0f
const val MAX_BATTERY_V = 20.0f
const val MAX_TPS_V = 5.1f
// Bitmasks
const val BIT_0 = 0x01
const val BIT_1 = 0x02
const val BIT_2 = 0x04
const val BIT_3 = 0x08
const val BIT_4 = 0x10
const val BIT_5 = 0x20
const val BIT_6 = 0x40
const val BIT_7 = 0x80
// MAT C interpolation table
val MAT_TABLE_C = listOf(
0 to 200.0f, 12 to 150.0f, 13 to 145.0f, 14 to 140.0f, 16 to 135.0f,
18 to 130.0f, 21 to 125.0f, 23 to 120.0f, 26 to 115.0f, 30 to 110.0f,
34 to 105.0f, 39 to 100.0f, 44 to 95.0f, 50 to 90.0f, 56 to 85.0f,
64 to 80.0f, 72 to 75.0f, 81 to 70.0f, 92 to 65.0f, 102 to 60.0f,
114 to 55.0f, 126 to 50.0f, 139 to 45.0f, 152 to 40.0f, 165 to 35.0f,
177 to 30.0f, 189 to 25.0f, 199 to 20.0f, 209 to 15.0f, 218 to 10.0f,
225 to 5.0f, 231 to 0.0f, 237 to -5.0f, 241 to -10.0f, 245 to -15.0f,
247 to -20.0f, 250 to -25.0f, 251 to -30.0f, 255 to -40.0f
)
// MAT F interpolation table
val MAT_TABLE_F = listOf(
0 to 392.0f, 12 to 302.0f, 13 to 293.0f, 14 to 284.0f, 16 to 275.0f,
18 to 266.0f, 21 to 257.0f, 23 to 248.0f, 26 to 239.0f, 30 to 230.0f,
34 to 221.0f, 39 to 212.0f, 44 to 203.0f, 50 to 194.0f, 56 to 185.0f,
64 to 176.0f, 72 to 167.0f, 81 to 158.0f, 92 to 149.0f, 102 to 140.0f,
114 to 131.0f, 126 to 122.0f, 139 to 113.0f, 152 to 104.0f, 165 to 95.0f,
177 to 86.0f, 189 to 77.0f, 199 to 68.0f, 209 to 59.0f, 218 to 50.0f,
225 to 41.0f, 231 to 32.0f, 237 to 23.0f, 241 to 14.0f, 245 to 5.0f,
247 to -4.0f, 250 to -13.0f, 251 to -22.0f, 255 to -40.0f
)
}
object ALDLParser {
// MAT C interpolation table: key is raw value, value is Temp in C
private val matTableC = listOf(
0 to 200.0f,
12 to 150.0f,
13 to 145.0f,
14 to 140.0f,
16 to 135.0f,
18 to 130.0f,
21 to 125.0f,
23 to 120.0f,
26 to 115.0f,
30 to 110.0f,
34 to 105.0f,
39 to 100.0f,
44 to 95.0f,
50 to 90.0f,
56 to 85.0f,
64 to 80.0f,
72 to 75.0f,
81 to 70.0f,
92 to 65.0f,
102 to 60.0f,
114 to 55.0f,
126 to 50.0f,
139 to 45.0f,
152 to 40.0f,
165 to 35.0f,
177 to 30.0f,
189 to 25.0f,
199 to 20.0f,
209 to 15.0f,
218 to 10.0f,
225 to 5.0f,
231 to 0.0f,
237 to -5.0f,
241 to -10.0f,
245 to -15.0f,
247 to -20.0f,
250 to -25.0f,
251 to -30.0f,
255 to -40.0f
)
// MAT F interpolation table: key is raw value, value is Temp in F
private val matTableF = listOf(
0 to 392.0f,
12 to 302.0f,
13 to 293.0f,
14 to 284.0f,
16 to 275.0f,
18 to 266.0f,
21 to 257.0f,
23 to 248.0f,
26 to 239.0f,
30 to 230.0f,
34 to 221.0f,
39 to 212.0f,
44 to 203.0f,
50 to 194.0f,
56 to 185.0f,
64 to 176.0f,
72 to 167.0f,
81 to 158.0f,
92 to 149.0f,
102 to 140.0f,
114 to 131.0f,
126 to 122.0f,
139 to 113.0f,
152 to 104.0f,
165 to 95.0f,
177 to 86.0f,
189 to 77.0f,
199 to 68.0f,
209 to 59.0f,
218 to 50.0f,
225 to 41.0f,
231 to 32.0f,
237 to 23.0f,
241 to 14.0f,
245 to 5.0f,
247 to -4.0f,
250 to -13.0f,
251 to -22.0f,
255 to -40.0f
)
private fun interpolate(raw: Int, table: List<Pair<Int, Float>>): Float {
if (raw <= table.first().first) return table.first().second
if (raw >= table.last().first) return table.last().second
@@ -149,91 +134,100 @@ object ALDLParser {
}
/**
* Parses a 25-byte raw data payload.
* Parses a 25-byte raw data payload, validating bounds and checking for errors.
*/
fun parseFrame(data: ByteArray): ALDLFrame? {
if (data.size != 25) return null
fun parseFrame(data: ByteArray): ALDLParseResult {
if (data.size != ALDLConstants.PAYLOAD_SIZE) {
return ALDLParseResult.Incomplete
}
val u = IntArray(25) { data[it].toInt() and 0xFF }
val u = IntArray(ALDLConstants.PAYLOAD_SIZE) { data[it].toInt() and 0xFF }
// Mappings based on 1-indexed btByteNumber in 24-INT10.ads (index = byteNumber - 1)
val iacPosition = u[3] // Byte 4
val coolantTempC = u[4] * 0.75f - 40.0f // Byte 5
val coolantTempF = u[4] * 1.35f - 40.0f // Byte 5
val vehicleSpeedMPH = u[5] // Byte 6
val mapVolts = u[6] * 0.019608f // Byte 7
val mapKpa = u[6] * 0.369f + 10.354f // Byte 7
val engineSpeedRpm = u[7] * 25 // Byte 8
val tpsVolts = u[8] * 0.019608f // Byte 9
val integrator = u[9] // Byte 10
val o2SensorMv = u[10] * 4.44f // Byte 11
val engineSpeedRpm = u[7] * ALDLConstants.RPM_SCALE
if (engineSpeedRpm > ALDLConstants.MAX_RPM) {
return ALDLParseResult.InvalidData("RPM ($engineSpeedRpm) exceeds plausibility limit (${ALDLConstants.MAX_RPM})")
}
val codesByte1 = u[11] // Byte 12
val codesByte2 = u[12] // Byte 13
val codesByte3 = u[13] // Byte 14
val miscByte1 = u[14] // Byte 15
val miscByte2 = u[15] // Byte 16
val miscByte3 = u[16] // Byte 17
val coolantTempC = u[4] * ALDLConstants.COOLANT_SCALE_C + ALDLConstants.COOLANT_OFFSET_C
val coolantTempF = u[4] * ALDLConstants.COOLANT_SCALE_F + ALDLConstants.COOLANT_OFFSET_F
if (coolantTempC < ALDLConstants.MIN_TEMP_C || coolantTempC > ALDLConstants.MAX_TEMP_C) {
return ALDLParseResult.InvalidData("Coolant Temp ($coolantTempC C) outside bounds")
}
val batteryVolts = u[17] * 0.1f // Byte 18
val blm = u[18] // Byte 19
val richLeanCrosses = u[19] // Byte 20
val sparkAdvance = u[20] * 0.351563f // Byte 21
val egrDutyCycle = u[21] * 0.392157f // Byte 22
val batteryVolts = u[17] * ALDLConstants.BATTERY_VOLTS_SCALE
if (batteryVolts < ALDLConstants.MIN_BATTERY_V || batteryVolts > ALDLConstants.MAX_BATTERY_V) {
return ALDLParseResult.InvalidData("Battery Voltage ($batteryVolts V) outside bounds")
}
val tpsVolts = u[8] * ALDLConstants.TPS_VOLTS_SCALE
if (tpsVolts > ALDLConstants.MAX_TPS_V) {
return ALDLParseResult.InvalidData("TPS Voltage ($tpsVolts V) outside bounds")
}
val iacPosition = u[3]
val vehicleSpeedMPH = u[5]
val mapVolts = u[6] * ALDLConstants.MAP_VOLTS_SCALE
val mapKpa = u[6] * ALDLConstants.MAP_KPA_SCALE + ALDLConstants.MAP_KPA_OFFSET
val integrator = u[9]
val o2SensorMv = u[10] * ALDLConstants.O2_MV_SCALE
val codesByte1 = u[11]
val codesByte2 = u[12]
val codesByte3 = u[13]
val miscByte1 = u[14]
val miscByte2 = u[15]
val miscByte3 = u[16]
val blm = u[18]
val richLeanCrosses = u[19]
val sparkAdvance = u[20] * ALDLConstants.SPARK_ADVANCE_SCALE
val egrDutyCycle = u[21] * ALDLConstants.EGR_DUTY_CYCLE_SCALE
// MAT (Air Temp) Interpolation
val matC = interpolate(u[22], matTableC) // Byte 23
val matF = interpolate(u[22], matTableF) // Byte 23
val matC = interpolate(u[22], ALDLConstants.MAT_TABLE_C)
val matF = interpolate(u[22], ALDLConstants.MAT_TABLE_F)
// BPW (Base Pulse Width) 16-bit
val rawBpw = (u[23] shl 8) or u[24] // Byte 24 (High), Byte 25 (Low)
val bpwMs = rawBpw * 0.015259f
val rawBpw = (u[23] shl 8) or u[24]
val bpwMs = rawBpw * ALDLConstants.BPW_MS_SCALE
// Status Flags Decoding
// Misc Byte 1 (Byte 15)
val blmEnable = (miscByte1 and 0x02) != 0 // bit 1
val quasiPulse = (miscByte1 and 0x08) != 0 // bit 3
val asyncPulse = (miscByte1 and 0x10) != 0 // bit 4
val isRich = (miscByte1 and 0x40) != 0 // bit 6 (1=RICH, 0=LEAN)
val isClosedLoop = (miscByte1 and 0x80) != 0 // bit 7 (1=CLOSED, 0=OPEN)
val blmEnable = (miscByte1 and ALDLConstants.BIT_1) != 0
val quasiPulse = (miscByte1 and ALDLConstants.BIT_3) != 0
val asyncPulse = (miscByte1 and ALDLConstants.BIT_4) != 0
val isRich = (miscByte1 and ALDLConstants.BIT_6) != 0
val isClosedLoop = (miscByte1 and ALDLConstants.BIT_7) != 0
// Misc Byte 2 (Byte 16)
val isAcEnabled = (miscByte2 and 0x20) == 0 // bit 5 (0=ENABLED, 1=DISABLED/IDLE)
val isParkNeutral = (miscByte2 and 0x80) != 0 // bit 7 (1=PARK/NEUTRAL, 0=IN GEAR)
val isAcEnabled = (miscByte2 and ALDLConstants.BIT_5) == 0
val isParkNeutral = (miscByte2 and ALDLConstants.BIT_7) != 0
// Misc Byte 3 (Byte 17)
val isAcClutchEnabled = (miscByte3 and 0x01) != 0 // bit 0 (1=ENABLED)
val isTccLocked = (miscByte3 and 0x04) != 0 // bit 2 (1=LOCKED)
val isPowerSteeringCrampActive = (miscByte3 and 0x20) != 0 // bit 5 (1=ACTIVE)
val isAcClutchEnabled = (miscByte3 and ALDLConstants.BIT_0) != 0
val isTccLocked = (miscByte3 and ALDLConstants.BIT_2) != 0
val isPowerSteeringCrampActive = (miscByte3 and ALDLConstants.BIT_5) != 0
// Active Fault Codes list based on code bits
val activeCodes = mutableListOf<Int>()
// Byte 12
if ((codesByte1 and 0x80) != 0) activeCodes.add(12) // bit 7
if ((codesByte1 and 0x40) != 0) activeCodes.add(13) // bit 6
if ((codesByte1 and 0x20) != 0) activeCodes.add(14) // bit 5
if ((codesByte1 and 0x10) != 0) activeCodes.add(15) // bit 4
if ((codesByte1 and 0x08) != 0) activeCodes.add(21) // bit 3
if ((codesByte1 and 0x04) != 0) activeCodes.add(22) // bit 2
if ((codesByte1 and 0x02) != 0) activeCodes.add(23) // bit 1
if ((codesByte1 and 0x01) != 0) activeCodes.add(24) // bit 0
// Byte 13
if ((codesByte2 and 0x80) != 0) activeCodes.add(25) // bit 7
if ((codesByte2 and 0x20) != 0) activeCodes.add(32) // bit 5
if ((codesByte2 and 0x10) != 0) activeCodes.add(33) // bit 4
if ((codesByte2 and 0x08) != 0) activeCodes.add(34) // bit 3
if ((codesByte2 and 0x04) != 0) activeCodes.add(35) // bit 2
if ((codesByte2 and 0x01) != 0) activeCodes.add(42) // bit 0
// Byte 14
if ((codesByte3 and 0x80) != 0) activeCodes.add(43) // bit 7
if ((codesByte3 and 0x40) != 0) activeCodes.add(44) // bit 6
if ((codesByte3 and 0x20) != 0) activeCodes.add(45) // bit 5
if ((codesByte3 and 0x10) != 0) activeCodes.add(51) // bit 4
if ((codesByte3 and 0x08) != 0) activeCodes.add(52) // bit 3
if ((codesByte3 and 0x04) != 0) activeCodes.add(53) // bit 2
if ((codesByte3 and 0x01) != 0) activeCodes.add(55) // bit 0
if ((codesByte1 and ALDLConstants.BIT_7) != 0) activeCodes.add(12)
if ((codesByte1 and ALDLConstants.BIT_6) != 0) activeCodes.add(13)
if ((codesByte1 and ALDLConstants.BIT_5) != 0) activeCodes.add(14)
if ((codesByte1 and ALDLConstants.BIT_4) != 0) activeCodes.add(15)
if ((codesByte1 and ALDLConstants.BIT_3) != 0) activeCodes.add(21)
if ((codesByte1 and ALDLConstants.BIT_2) != 0) activeCodes.add(22)
if ((codesByte1 and ALDLConstants.BIT_1) != 0) activeCodes.add(23)
if ((codesByte1 and ALDLConstants.BIT_0) != 0) activeCodes.add(24)
return ALDLFrame(
if ((codesByte2 and ALDLConstants.BIT_7) != 0) activeCodes.add(25)
if ((codesByte2 and ALDLConstants.BIT_5) != 0) activeCodes.add(32)
if ((codesByte2 and ALDLConstants.BIT_4) != 0) activeCodes.add(33)
if ((codesByte2 and ALDLConstants.BIT_3) != 0) activeCodes.add(34)
if ((codesByte2 and ALDLConstants.BIT_2) != 0) activeCodes.add(35)
if ((codesByte2 and ALDLConstants.BIT_0) != 0) activeCodes.add(42)
if ((codesByte3 and ALDLConstants.BIT_7) != 0) activeCodes.add(43)
if ((codesByte3 and ALDLConstants.BIT_6) != 0) activeCodes.add(44)
if ((codesByte3 and ALDLConstants.BIT_5) != 0) activeCodes.add(45)
if ((codesByte3 and ALDLConstants.BIT_4) != 0) activeCodes.add(51)
if ((codesByte3 and ALDLConstants.BIT_3) != 0) activeCodes.add(52)
if ((codesByte3 and ALDLConstants.BIT_2) != 0) activeCodes.add(53)
if ((codesByte3 and ALDLConstants.BIT_0) != 0) activeCodes.add(55)
val frame = ALDLFrame(
rawBytes = data,
iacPosition = iacPosition,
coolantTempC = coolantTempC,
@@ -265,5 +259,6 @@ object ALDLParser {
isPowerSteeringCrampActive = isPowerSteeringCrampActive,
activeFaultCodes = activeCodes
)
return ALDLParseResult.Success(frame)
}
}
@@ -0,0 +1,22 @@
package com.example.esp32aldldashboard.parser
// Approximate Engine Load (0.0 to 1.0)
// Very rough approximation: MAP (kPa) / ~100 kPa (atmospheric at sea level)
// At WOT, MAP is near atmospheric (100 kPa), load is near 100%. At idle, MAP is around 30-40 kPa, load is lower.
val ALDLFrame.estimatedEngineLoad: Float
get() {
val load = mapKpa / 100.0f
return load.coerceIn(0.0f, 1.0f)
}
// Approximate Fuel Flow Rate (lbs/hr)
// Rough formula: RPM * BPW (ms) * Injector Flow Rate Constant
// 2.8L Fiero V6 typically has ~15 lb/hr injectors. There are 6 injectors, firing sequentially or batch?
// The 1227170 ECM fires batch (3 injectors per driver, twice per cycle).
// Rough estimation: flow = (RPM / 2) * BPW_seconds * 6 * Injector_rate / 60
// We'll provide a simplified unitless flow hint for the UI visualization.
val ALDLFrame.fuelFlowHint: Float
get() {
val bpwSeconds = bpwMs / 1000.0f
return (engineSpeedRpm * bpwSeconds) / 2.0f
}
@@ -0,0 +1,36 @@
package com.example.esp32aldldashboard.parser
object TroubleCodeDictionary {
val DTC_DESCRIPTIONS = mapOf(
12 to "Crank Sensor / System Check - Normal if engine not running.",
13 to "O2 Sensor Circuit - Open or No Activity.",
14 to "Coolant Temperature Sensor - High Temperature Indicated.",
15 to "Coolant Temperature Sensor - Low Temperature Indicated.",
21 to "Throttle Position Sensor (TPS) - High Voltage.",
22 to "Throttle Position Sensor (TPS) - Low Voltage.",
23 to "Manifold Air Temperature (MAT) - Low Temperature Indicated.",
24 to "Vehicle Speed Sensor (VSS) - Circuit Fault.",
25 to "Manifold Air Temperature (MAT) - High Temperature Indicated.",
32 to "Exhaust Gas Recirculation (EGR) - System Fault.",
33 to "Manifold Absolute Pressure (MAP) - High Pressure Indicated.",
34 to "Manifold Absolute Pressure (MAP) - Low Pressure Indicated.",
35 to "Idle Air Control (IAC) - Position Error.",
42 to "Electronic Spark Timing (EST) - Circuit Fault.",
43 to "Electronic Spark Control (ESC) - Knock Sensor Fault.",
44 to "Oxygen Sensor - Lean Exhaust Indicated.",
45 to "Oxygen Sensor - Rich Exhaust Indicated.",
51 to "PROM Error - Faulty or Incorrect Memcal.",
52 to "Cal-Pack Error - Missing or Faulty Cal-Pack.",
53 to "System Voltage - Battery Over-Voltage.",
55 to "ADU Error - Internal ECM Fault."
)
fun getDescription(code: Int): String {
return DTC_DESCRIPTIONS[code] ?: "Unknown Trouble Code $code"
}
fun isCritical(code: Int): Boolean {
// Severe codes that might require immediate pull-over
return code in listOf(14, 43, 51, 52, 53, 55)
}
}
@@ -0,0 +1,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<Array<Array<BLMCellData>>> = _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>): 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
}
}
@@ -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<Preferences> 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<ViewMode> = 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<Set<ChartParameter>> = 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<ChartParameter> = 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<ChartParameter>) {
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
}
}
}
@@ -0,0 +1,205 @@
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<Preferences> by preferencesDataStore(name = "settings")
class SettingsRepository(private val context: Context) {
companion object {
val IS_CELSIUS = booleanPreferencesKey("is_celsius")
val COOLANT_ALERT_THRESHOLD = floatPreferencesKey("coolant_alert_threshold")
val BATTERY_LOW_THRESHOLD = floatPreferencesKey("battery_low_threshold")
val AUTO_LOGGING = booleanPreferencesKey("auto_logging")
val RECORD_RAW_DATA = booleanPreferencesKey("record_raw_data")
}
val isCelsiusFlow: Flow<Boolean> = context.dataStore.data
.map { preferences ->
preferences[IS_CELSIUS] ?: false // Default to Fahrenheit
}
val coolantAlertThresholdFlow: Flow<Float> = context.dataStore.data
.map { preferences ->
preferences[COOLANT_ALERT_THRESHOLD] ?: 100f // Default 100C / 212F
}
val batteryLowThresholdFlow: Flow<Float> = context.dataStore.data
.map { preferences ->
preferences[BATTERY_LOW_THRESHOLD] ?: 11.5f // Default 11.5V
}
val autoLoggingFlow: Flow<Boolean> = context.dataStore.data
.map { preferences ->
preferences[AUTO_LOGGING] ?: false
}
val recordRawDataFlow: Flow<Boolean> = context.dataStore.data
.map { preferences ->
preferences[RECORD_RAW_DATA] ?: 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
}
}
suspend fun setRecordRawData(recordRaw: Boolean) {
context.dataStore.edit { preferences ->
preferences[RECORD_RAW_DATA] = recordRaw
}
}
/**
* Queries MediaStore for logged files (CSV and .bin) in Downloads/ALDLLogs
*/
suspend fun getLoggedFiles(): List<LoggedFile> = withContext(Dispatchers.IO) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
return@withContext emptyList() // Legacy not supported for this feature
}
val files = mutableListOf<LoggedFile>()
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
}
@@ -0,0 +1,144 @@
package com.example.esp32aldldashboard.repository
import android.content.Context
import android.content.Intent
import com.example.esp32aldldashboard.bluetooth.BluetoothService
import com.example.esp32aldldashboard.bluetooth.BluetoothForegroundService
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 context: Context,
private val bluetoothService: BluetoothService,
private val telemetryDao: TelemetryDao,
private val csvLogger: CsvLogger,
private val settingsRepository: SettingsRepository,
private val blmTableRepository: BLMTableRepository
) {
private val repoScope = CoroutineScope(Dispatchers.IO + Job())
private var currentSessionId: Long? = null
private var isRecording = false
init {
observeConnectionState()
observeTelemetry()
}
val connectionState: StateFlow<ConnectionState> = bluetoothService.connectionState
val latestFrame: StateFlow<ALDLFrame?> = bluetoothService.latestFrame
val rawHexLog: StateFlow<List<String>> = bluetoothService.rawHexLog
val framesReceived: StateFlow<Int> = bluetoothService.framesReceived
val parseErrors: StateFlow<Int> = bluetoothService.parseErrors
val currentFrameRate: StateFlow<Int> = bluetoothService.currentFrameRate
val errorMessage: StateFlow<String> = bluetoothService.errorMessage
private fun observeConnectionState() {
repoScope.launch {
bluetoothService.connectionState.collectLatest { state ->
when (state) {
ConnectionState.CONNECTED -> {
val autoLog = settingsRepository.autoLoggingFlow.first()
if (autoLog) {
startSession(isSimulation = false) // Or true if we knew
}
}
ConnectionState.DISCONNECTED, ConnectionState.ERROR -> {
endSession()
}
else -> {}
}
}
}
}
private fun observeTelemetry() {
repoScope.launch {
bluetoothService.latestFrame.collectLatest { frame ->
if (frame != null) {
// 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))
}
}
}
}
}
}
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() {
startForegroundService()
bluetoothService.connect()
}
fun disconnect() {
stopForegroundService()
bluetoothService.disconnect()
}
fun startSimulation() {
startForegroundService()
bluetoothService.startSimulation()
}
private fun startForegroundService() {
val intent = Intent(context, BluetoothForegroundService::class.java).apply {
action = BluetoothForegroundService.ACTION_START
}
context.startForegroundService(intent)
}
private fun stopForegroundService() {
val intent = Intent(context, BluetoothForegroundService::class.java).apply {
action = BluetoothForegroundService.ACTION_STOP
}
context.startService(intent)
}
}
@@ -0,0 +1,229 @@
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.horizontalScroll
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(
modifier = Modifier.horizontalScroll(rememberScrollState())
) {
// 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
)
}
}
@@ -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<Array<Array<BLMCellData>>> = repository.tableData
val rpmBands: List<Int> = repository.rpmBands
val mapBands: List<Int> = 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 <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(BLMTableViewModel::class.java)) {
@Suppress("UNCHECKED_CAST")
return BLMTableViewModel(repository) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}
@@ -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() }
)
}
@@ -0,0 +1,347 @@
@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
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
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 com.example.esp32aldldashboard.repository.ChartPreferencesRepository
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
@Composable
fun ChartsScreen(
latestFrameFlow: StateFlow<ALDLFrame?>,
chartPreferencesRepository: ChartPreferencesRepository,
modifier: Modifier = Modifier
) {
val latestFrame by latestFrameFlow.collectAsStateWithLifecycle()
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<ChartParameter, MutableList<Float>>().apply {
ChartParameter.values().forEach { put(it, mutableStateListOf()) }
}
}
LaunchedEffect(latestFrame) {
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)) {
// 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<Float>,
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(300.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E))
) {
Column(modifier = Modifier.padding(16.dp)) {
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 = history,
maxValue = selectedParameter.maxValue,
lineColor = selectedParameter.color,
modifier = Modifier.fillMaxSize()
)
}
}
}
}
@Composable
private fun MultiChartView(
selectedParameters: Set<ChartParameter>,
histories: Map<ChartParameter, List<Float>>,
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))
// 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
private fun ChartCard(
parameter: ChartParameter,
history: List<Float>,
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<Float>,
maxValue: Float,
lineColor: Color,
modifier: Modifier = Modifier
) {
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
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 = 3.dp.toPx())
)
}
}
@@ -0,0 +1,125 @@
package com.example.esp32aldldashboard.ui.components
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun RpmGauge(
rpm: Int,
maxRpm: Int = 6000,
modifier: Modifier = Modifier
) {
val animatedRpm by animateFloatAsState(
targetValue = rpm.toFloat(),
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow),
label = "rpmAnimation"
)
Box(modifier = modifier.aspectRatio(1f), contentAlignment = Alignment.Center) {
Canvas(modifier = Modifier.fillMaxSize().padding(16.dp)) {
val strokeWidth = 24.dp.toPx()
val startAngle = 135f
val sweepAngle = 270f
// Background arc
drawArc(
color = Color.DarkGray.copy(alpha = 0.5f),
startAngle = startAngle,
sweepAngle = sweepAngle,
useCenter = false,
style = Stroke(width = strokeWidth, cap = StrokeCap.Round),
size = Size(size.width, size.height)
)
// Foreground arc
val progress = (animatedRpm / maxRpm).coerceIn(0f, 1f)
val color = if (progress > 0.85f) Color.Red else Color(0xFF00FFCC) // Neon Cyan
drawArc(
color = color,
startAngle = startAngle,
sweepAngle = sweepAngle * progress,
useCenter = false,
style = Stroke(width = strokeWidth, cap = StrokeCap.Round),
size = Size(size.width, size.height)
)
}
Text(
text = rpm.toString(),
color = Color.White,
fontSize = 32.sp,
fontWeight = FontWeight.Bold
)
Text(
text = "RPM",
color = Color.LightGray,
fontSize = 14.sp,
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 24.dp)
)
}
}
@Composable
fun TpsBar(
tpsVolts: Float,
modifier: Modifier = Modifier
) {
val animatedTps by animateFloatAsState(
targetValue = tpsVolts,
animationSpec = spring(stiffness = Spring.StiffnessLow),
label = "tpsAnimation"
)
Box(modifier = modifier, contentAlignment = Alignment.Center) {
Canvas(modifier = Modifier.fillMaxSize()) {
val strokeWidth = size.height
val maxVolts = 5.0f
val progress = (animatedTps / maxVolts).coerceIn(0f, 1f)
// Background bar
drawLine(
color = Color.DarkGray.copy(alpha = 0.5f),
start = Offset(0f, size.height / 2),
end = Offset(size.width, size.height / 2),
strokeWidth = strokeWidth,
cap = StrokeCap.Round
)
// Foreground bar
drawLine(
color = Color(0xFFFF9900), // Neon Orange
start = Offset(0f, size.height / 2),
end = Offset(size.width * progress, size.height / 2),
strokeWidth = strokeWidth,
cap = StrokeCap.Round
)
}
Text(
text = String.format("%.2f V", tpsVolts),
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 14.sp
)
}
}
@@ -0,0 +1,511 @@
package com.example.esp32aldldashboard.ui.main
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.esp32aldldashboard.bluetooth.ConnectionState
import com.example.esp32aldldashboard.parser.ALDLFrame
import com.example.esp32aldldashboard.parser.TroubleCodeDictionary
import com.example.esp32aldldashboard.ui.components.RpmGauge
import com.example.esp32aldldashboard.ui.components.TpsBar
// Theme Colors
val DarkBg = Color(0xFF0F0F12)
val CardBg = Color(0xFF1B1B22)
val BorderColor = Color(0xFF2E2E38)
val NeonCyan = Color(0xFF00E5FF)
val NeonRed = Color(0xFFFF3D00)
val NeonGreen = Color(0xFF00E676)
val NeonOrange = Color(0xFFFF9100)
val TextWhite = Color(0xFFEEEEEE)
val TextMuted = Color(0xFF9E9EAF)
@Composable
fun DashboardScreen(
connState: ConnectionState,
frame: ALDLFrame?,
isCelsius: Boolean,
onConnect: () -> Unit,
onDisconnect: () -> Unit,
onSimulate: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.fillMaxSize()
.background(DarkBg)
.verticalScroll(rememberScrollState())
) {
// App Title Banner
Text(
text = "PONTIAC FIERO ALDL DASHBOARD",
color = NeonOrange,
fontSize = 18.sp,
fontWeight = FontWeight.ExtraBold,
letterSpacing = 1.5.sp,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp)
)
// Connection Action Card
ConnectionCard(
connState = connState,
errorMsg = "",
isCelsius = isCelsius,
onConnect = onConnect,
onDisconnect = onDisconnect,
onSimulate = onSimulate,
onToggleUnit = { /* Moved to settings */ }
)
Spacer(modifier = Modifier.height(12.dp))
// Telemetry Panels
if (frame != null) {
// 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(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(
modifier = Modifier
.fillMaxWidth()
.height(260.dp)
.padding(horizontal = 16.dp)
.background(CardBg, shape = RoundedCornerShape(12.dp))
.border(1.dp, BorderColor, shape = RoundedCornerShape(12.dp)),
contentAlignment = Alignment.Center
) {
Text(
text = "Waiting for ALDL Stream data...\nSelect Connection or Simulation above.",
color = TextMuted,
fontSize = 14.sp,
textAlign = TextAlign.Center
)
}
Spacer(modifier = Modifier.height(16.dp))
}
Spacer(modifier = Modifier.height(24.dp))
}
}
// ... Copying the UI components from MainScreen.kt to keep them in DashboardScreen.kt ...
@Composable
fun ConnectionCard(
connState: ConnectionState,
errorMsg: String,
isCelsius: Boolean,
onConnect: () -> Unit,
onDisconnect: () -> Unit,
onSimulate: () -> Unit,
onToggleUnit: () -> Unit
) {
Card(
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = CardBg),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.border(1.dp, BorderColor, shape = RoundedCornerShape(12.dp))
) {
Column(
modifier = Modifier.padding(16.dp)
) {
// Status bar
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "STATUS: ",
color = TextMuted,
fontWeight = FontWeight.Bold,
fontSize = 13.sp
)
val (statusText, statusColor) = when (connState) {
ConnectionState.DISCONNECTED -> "DISCONNECTED" to TextMuted
ConnectionState.CONNECTING -> "CONNECTING..." to NeonOrange
ConnectionState.CONNECTED -> "CONNECTED" to NeonGreen
ConnectionState.ERROR -> "ERROR" to NeonRed
}
Text(
text = statusText,
color = statusColor,
fontWeight = FontWeight.Bold,
fontSize = 13.sp
)
}
}
if (errorMsg.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = errorMsg,
color = NeonRed,
fontSize = 12.sp,
fontWeight = FontWeight.Medium
)
}
Spacer(modifier = Modifier.height(14.dp))
// Action Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
if (connState != ConnectionState.CONNECTED && connState != ConnectionState.CONNECTING) {
Button(
onClick = onConnect,
colors = ButtonDefaults.buttonColors(containerColor = NeonOrange),
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(8.dp)
) {
Text("CONNECT BT", fontWeight = FontWeight.Bold)
}
} else {
Button(
onClick = onDisconnect,
colors = ButtonDefaults.buttonColors(containerColor = BorderColor),
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(8.dp)
) {
Text("DISCONNECT", fontWeight = FontWeight.Bold, color = TextWhite)
}
}
Button(
onClick = onSimulate,
colors = ButtonDefaults.buttonColors(containerColor = BorderColor),
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(8.dp)
) {
Text("SIMULATE", fontWeight = FontWeight.Bold, color = NeonCyan)
}
}
}
}
}
@Composable
fun GridItemCard(
title: String,
value: String,
progress: Float,
progressColor: Color,
modifier: Modifier = Modifier
) {
Card(
shape = RoundedCornerShape(10.dp),
colors = CardDefaults.cardColors(containerColor = CardBg),
modifier = modifier
.border(1.dp, BorderColor, shape = RoundedCornerShape(10.dp))
) {
Column(
modifier = Modifier.padding(10.dp)
) {
Text(
text = title,
color = TextMuted,
fontSize = 10.sp,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = value,
color = TextWhite,
fontSize = 18.sp,
fontWeight = FontWeight.ExtraBold
)
Spacer(modifier = Modifier.height(6.dp))
LinearProgressIndicator(
progress = { progress.coerceIn(0f, 1f) },
color = progressColor,
trackColor = BorderColor,
modifier = Modifier
.fillMaxWidth()
.height(4.dp)
)
}
}
}
@Composable
fun TroubleCodesCard(activeCodes: List<Int>) {
Card(
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = CardBg),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.border(1.dp, NeonRed, shape = RoundedCornerShape(12.dp))
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "⚠️ ACTIVE ECM FAULT CODES",
color = NeonRed,
fontWeight = FontWeight.Black,
fontSize = 14.sp
)
}
Spacer(modifier = Modifier.height(8.dp))
Column(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
activeCodes.forEach { code ->
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.background(NeonRed, shape = RoundedCornerShape(4.dp))
.padding(horizontal = 10.dp, vertical = 4.dp)
) {
Text(
text = "CODE $code",
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 12.sp
)
}
Spacer(modifier = Modifier.width(8.dp))
Text(
text = TroubleCodeDictionary.getDescription(code),
color = TextWhite,
fontSize = 12.sp
)
}
}
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun StatusFlagsPanel(frame: ALDLFrame) {
Card(
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = CardBg),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.border(1.dp, BorderColor, shape = RoundedCornerShape(12.dp))
) {
Column(
modifier = Modifier.padding(14.dp)
) {
Text(
text = "LOOP STATUS & SYSTEM SWITCHES",
color = TextMuted,
fontSize = 12.sp,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(10.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth()
) {
StatusBadge(label = "Closed Loop", active = frame.isClosedLoop, activeColor = NeonGreen)
StatusBadge(label = "Rich Mixture", active = frame.isRich, activeColor = NeonGreen)
StatusBadge(label = "BLM Enabled", active = frame.blmEnable, activeColor = NeonGreen)
StatusBadge(label = "TCC Locked", active = frame.isTccLocked, activeColor = NeonGreen)
StatusBadge(label = "AC Clutch", active = frame.isAcClutchEnabled, activeColor = NeonGreen)
StatusBadge(label = "Park/Neutral", active = frame.isParkNeutral, activeColor = NeonCyan)
StatusBadge(label = "A/C Request", active = frame.isAcEnabled, activeColor = NeonCyan)
StatusBadge(label = "PS Cramp", active = frame.isPowerSteeringCrampActive, activeColor = NeonOrange)
StatusBadge(label = "Async Pulse", active = frame.asyncPulse, activeColor = NeonOrange)
StatusBadge(label = "Quasi Pulse", active = frame.quasiPulse, activeColor = NeonOrange)
}
}
}
}
@Composable
fun StatusBadge(label: String, active: Boolean, activeColor: Color) {
Box(
modifier = Modifier
.background(
color = if (active) activeColor.copy(alpha = 0.15f) else Color.Transparent,
shape = RoundedCornerShape(4.dp)
)
.border(
width = 1.dp,
color = if (active) activeColor else BorderColor,
shape = RoundedCornerShape(4.dp)
)
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Text(
text = label,
color = if (active) activeColor else TextMuted,
fontSize = 11.sp,
fontWeight = FontWeight.Bold
)
}
}
@@ -5,54 +5,42 @@ 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.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.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.blm.BLMTableScreen
import com.example.esp32aldldashboard.ui.blm.BLMTableViewModelFactory
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
val viewModel: MainScreenViewModel = viewModel { MainScreenViewModel(context) }
val app = context.applicationContext as AldlApplication
val viewModel: MainScreenViewModel = viewModel(
factory = MainScreenViewModelFactory(
telemetryRepository = app.telemetryRepository,
settingsRepository = app.settingsRepository
)
)
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 +58,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 +79,70 @@ 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) }
@Composable
fun MainScreenContent(
connState: ConnectionState,
frame: ALDLFrame?,
rawLog: List<String>,
errorMsg: String,
isCelsius: Boolean,
onConnect: () -> Unit,
onDisconnect: () -> Unit,
onSimulate: () -> Unit,
onToggleUnit: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.fillMaxSize()
.background(DarkBg)
.verticalScroll(rememberScrollState())
) {
// App Title Banner
Text(
text = "PONTIAC FIERO ALDL DASHBOARD",
color = NeonOrange,
fontSize = 18.sp,
fontWeight = FontWeight.ExtraBold,
letterSpacing = 1.5.sp,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp)
)
// Connection Action Card
ConnectionCard(
connState = connState,
errorMsg = errorMsg,
isCelsius = isCelsius,
onConnect = onConnect,
onDisconnect = onDisconnect,
onSimulate = onSimulate,
onToggleUnit = onToggleUnit
)
Spacer(modifier = Modifier.height(12.dp))
// Telemetry Panels
if (frame != null) {
// Trouble Codes Card (Flashing if active)
if (frame.activeFaultCodes.isNotEmpty()) {
TroubleCodesCard(activeCodes = frame.activeFaultCodes)
Spacer(modifier = Modifier.height(12.dp))
}
// Key Metrics
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
MetricCard(
title = "ENGINE SPEED",
value = "${frame.engineSpeedRpm}",
unit = "RPM",
progress = frame.engineSpeedRpm / 6000f,
progressColor = NeonCyan,
modifier = Modifier.weight(1f)
Scaffold(
bottomBar = {
NavigationBar(containerColor = DarkBg, contentColor = NeonOrange) {
NavigationBarItem(
icon = { Icon(Icons.Default.Info, contentDescription = "Dashboard") },
label = { Text("Dashboard") },
selected = selectedTab == 0,
onClick = { selectedTab = 0 },
colors = NavigationBarItemDefaults.colors(selectedIconColor = NeonCyan, unselectedIconColor = TextMuted)
)
MetricCard(
title = "VEHICLE SPEED",
value = "${frame.vehicleSpeedMPH}",
unit = "MPH",
progress = frame.vehicleSpeedMPH / 120f,
progressColor = NeonGreen,
modifier = Modifier.weight(1f)
NavigationBarItem(
icon = { Icon(Icons.Default.ShowChart, 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.TableChart, contentDescription = "BLM Table") },
label = { Text("BLM") },
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
NavigationBarItem(
icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") },
label = { Text("Settings") },
selected = selectedTab == 3,
onClick = { selectedTab = 3 },
colors = NavigationBarItemDefaults.colors(selectedIconColor = NeonCyan, unselectedIconColor = TextMuted)
)
}
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
1 -> ChartsScreen(
latestFrameFlow = viewModel.latestFrame,
chartPreferencesRepository = app.chartPreferencesRepository,
modifier = modifier.padding(paddingValues)
)
2 -> {
val blmViewModel: com.example.esp32aldldashboard.ui.blm.BLMTableViewModel = viewModel(
factory = BLMTableViewModelFactory(app.blmTableRepository)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = unit,
color = TextMuted,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 4.dp)
BLMTableScreen(
viewModel = blmViewModel,
modifier = modifier.padding(paddingValues)
)
}
Spacer(modifier = Modifier.height(8.dp))
LinearProgressIndicator(
progress = progress.coerceIn(0f, 1f),
color = progressColor,
trackColor = BorderColor,
modifier = Modifier
.fillMaxWidth()
.height(6.dp)
3 -> SettingsScreen(
settingsRepository = app.settingsRepository,
modifier = modifier.padding(paddingValues)
)
}
}
}
@Composable
fun GridItemCard(
title: String,
value: String,
progress: Float,
progressColor: Color,
modifier: Modifier = Modifier
) {
Card(
shape = RoundedCornerShape(10.dp),
colors = CardDefaults.cardColors(containerColor = CardBg),
modifier = modifier
.border(1.dp, BorderColor, shape = RoundedCornerShape(10.dp))
) {
Column(
modifier = Modifier.padding(10.dp)
) {
Text(
text = title,
color = TextMuted,
fontSize = 10.sp,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = value,
color = TextWhite,
fontSize = 18.sp,
fontWeight = FontWeight.ExtraBold
)
Spacer(modifier = Modifier.height(6.dp))
LinearProgressIndicator(
progress = progress.coerceIn(0f, 1f),
color = progressColor,
trackColor = BorderColor,
modifier = Modifier
.fillMaxWidth()
.height(4.dp)
)
}
}
}
@Composable
fun TroubleCodesCard(activeCodes: List<Int>) {
Card(
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = CardBg),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.border(1.dp, NeonRed, shape = RoundedCornerShape(12.dp))
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "⚠️ ACTIVE ECM FAULT CODES",
color = NeonRed,
fontWeight = FontWeight.Black,
fontSize = 14.sp
)
}
Spacer(modifier = Modifier.height(8.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
activeCodes.forEach { code ->
Box(
modifier = Modifier
.background(NeonRed, shape = RoundedCornerShape(4.dp))
.padding(horizontal = 10.dp, vertical = 4.dp)
) {
Text(
text = "CODE $code",
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 12.sp
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Refer to Fiero shop manual for diagnosis.",
color = TextMuted,
fontSize = 11.sp
)
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun StatusFlagsPanel(frame: ALDLFrame) {
Card(
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = CardBg),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.border(1.dp, BorderColor, shape = RoundedCornerShape(12.dp))
) {
Column(
modifier = Modifier.padding(14.dp)
) {
Text(
text = "LOOP STATUS & SYSTEM SWITCHES",
color = TextMuted,
fontSize = 12.sp,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(10.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth()
) {
StatusBadge(label = "Closed Loop", active = frame.isClosedLoop, activeColor = NeonGreen)
StatusBadge(label = "Rich Mixture", active = frame.isRich, activeColor = NeonGreen)
StatusBadge(label = "BLM Enabled", active = frame.blmEnable, activeColor = NeonGreen)
StatusBadge(label = "TCC Locked", active = frame.isTccLocked, activeColor = NeonGreen)
StatusBadge(label = "AC Clutch", active = frame.isAcClutchEnabled, activeColor = NeonGreen)
StatusBadge(label = "Park/Neutral", active = frame.isParkNeutral, activeColor = NeonCyan)
StatusBadge(label = "A/C Request", active = frame.isAcEnabled, activeColor = NeonCyan)
StatusBadge(label = "PS Cramp", active = frame.isPowerSteeringCrampActive, activeColor = NeonOrange)
StatusBadge(label = "Async Pulse", active = frame.asyncPulse, activeColor = NeonOrange)
StatusBadge(label = "Quasi Pulse", active = frame.quasiPulse, activeColor = NeonOrange)
}
}
}
}
@Composable
fun StatusBadge(label: String, active: Boolean, activeColor: Color) {
Box(
modifier = Modifier
.background(
color = if (active) activeColor.copy(alpha = 0.15f) else Color.Transparent,
shape = RoundedCornerShape(4.dp)
)
.border(
width = 1.dp,
color = if (active) activeColor else BorderColor,
shape = RoundedCornerShape(4.dp)
)
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Text(
text = label,
color = if (active) activeColor else TextMuted,
fontSize = 11.sp,
fontWeight = FontWeight.Bold
)
}
}
@Composable
fun DiagnosticConsole(rawLog: List<String>) {
Card(
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = CardBg),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.border(1.dp, BorderColor, shape = RoundedCornerShape(12.dp))
) {
Column(
modifier = Modifier.padding(14.dp)
) {
Text(
text = "DIAGNOSTIC TELEMETRY STREAM LOG",
color = TextMuted,
fontSize = 11.sp,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.height(100.dp)
.background(Color.Black, shape = RoundedCornerShape(6.dp))
.padding(8.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
if (rawLog.isEmpty()) {
Text(
text = "Console Idle. Connect to view hex dump...",
color = TextMuted,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp
)
} else {
rawLog.forEach { logLine ->
Text(
text = logLine,
color = NeonGreen,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp
)
}
}
}
}
}
}
}
@@ -1,42 +1,54 @@
package com.example.esp32aldldashboard.ui.main
import android.content.Context
import androidx.lifecycle.ViewModel
import com.example.esp32aldldashboard.bluetooth.BluetoothService
import androidx.lifecycle.viewModelScope
import com.example.esp32aldldashboard.bluetooth.ConnectionState
import com.example.esp32aldldashboard.parser.ALDLFrame
import kotlinx.coroutines.flow.MutableStateFlow
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(context: Context) : ViewModel() {
private val bluetoothService = BluetoothService(context.applicationContext)
class MainScreenViewModel(
private val telemetryRepository: TelemetryRepository,
private val settingsRepository: SettingsRepository
) : ViewModel() {
val connectionState: StateFlow<ConnectionState> = bluetoothService.connectionState
val latestFrame: StateFlow<ALDLFrame?> = bluetoothService.latestFrame
val rawHexLog: StateFlow<List<String>> = bluetoothService.rawHexLog
val errorMessage: StateFlow<String> = bluetoothService.errorMessage
val connectionState: StateFlow<ConnectionState> = telemetryRepository.connectionState
val latestFrame: StateFlow<ALDLFrame?> = telemetryRepository.latestFrame
val rawHexLog: StateFlow<List<String>> = telemetryRepository.rawHexLog
val errorMessage: StateFlow<String> = telemetryRepository.errorMessage
private val _isCelsius = MutableStateFlow(false) // Default to Fahrenheit for standard 80s GM telemetry
val isCelsius: StateFlow<Boolean> = _isCelsius
val framesReceived: StateFlow<Int> = telemetryRepository.framesReceived
val parseErrors: StateFlow<Int> = telemetryRepository.parseErrors
val currentFrameRate: StateFlow<Int> = telemetryRepository.currentFrameRate
val isCelsius: StateFlow<Boolean> = settingsRepository.isCelsiusFlow
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
fun toggleTemperatureUnit() {
_isCelsius.value = !_isCelsius.value
viewModelScope.launch {
val currentValue = isCelsius.value
settingsRepository.setIsCelsius(!currentValue)
}
}
fun connect() {
bluetoothService.connect()
telemetryRepository.connect()
}
fun disconnect() {
bluetoothService.disconnect()
telemetryRepository.disconnect()
}
fun startSimulation() {
bluetoothService.startSimulation()
telemetryRepository.startSimulation()
}
override fun onCleared() {
super.onCleared()
bluetoothService.disconnect()
telemetryRepository.disconnect()
}
}
@@ -0,0 +1,20 @@
package com.example.esp32aldldashboard.ui.main
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.example.esp32aldldashboard.repository.SettingsRepository
import com.example.esp32aldldashboard.repository.TelemetryRepository
class MainScreenViewModelFactory(
private val telemetryRepository: TelemetryRepository,
private val settingsRepository: SettingsRepository
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(MainScreenViewModel::class.java)) {
return MainScreenViewModel(telemetryRepository, settingsRepository) as T
}
throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}")
}
}
@@ -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<LoggedFile>,
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"
)
}
}
}
}
@@ -0,0 +1,143 @@
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
@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 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<List<LoggedFile>>(emptyList()) }
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))
// Raw Data Recording Toggle (Debug)
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column {
Text(text = "Record Raw Datastream (Debug)", style = MaterialTheme.typography.titleMedium)
Text(text = "Save binary .bin files for debugging.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Switch(
checked = recordRawData,
onCheckedChange = {
coroutineScope.launch { settingsRepository.setRecordRawData(it) }
}
)
}
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)
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
)
}
}
// Log Files Dialog
if (showLogFilesDialog) {
LogFilesDialog(
files = logFiles,
onDismiss = { showLogFilesDialog = false }
)
}
}
@@ -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)
@@ -70,4 +70,95 @@ class ALDLParserTest {
assertFalse(frame.isClosedLoop) // bit 7 of u[14] is 0
assertFalse(frame.isRich) // bit 6 of u[14] is 0
}
@Test
fun testRpmBoundaryValues() {
// Max representable RPM: 6375 (255 * 25)
val validPayload = createBasePayload().apply {
this[7] = 255.toByte() // 255 * 25 = 6375 RPM
}
val validResult = ALDLParser.parseFrame(validPayload)
assertTrue("Max representable RPM should be valid", validResult is com.example.esp32aldldashboard.parser.ALDLParseResult.Success)
}
@Test
fun testBatteryVoltageBoundaryValues() {
// Min valid: 8V (80 * 0.1 = 8.0V)
val minPayload = createBasePayload().apply {
this[17] = 80.toByte()
}
val minResult = ALDLParser.parseFrame(minPayload)
assertTrue("Battery at 8V should be valid", minResult is com.example.esp32aldldashboard.parser.ALDLParseResult.Success)
// Max valid: 18V (180 * 0.1 = 18.0V)
val maxPayload = createBasePayload().apply {
this[17] = 180.toByte()
}
val maxResult = ALDLParser.parseFrame(maxPayload)
assertTrue("Battery at 18V should be valid", maxResult is com.example.esp32aldldashboard.parser.ALDLParseResult.Success)
// Too low: 4.9V (49 * 0.1 = 4.9V, below 5V minimum)
val lowPayload = createBasePayload().apply {
this[17] = 49.toByte()
}
val lowResult = ALDLParser.parseFrame(lowPayload)
assertTrue("Battery below 5V should be rejected", lowResult is com.example.esp32aldldashboard.parser.ALDLParseResult.InvalidData)
// Too high: 20.1V (201 * 0.1 = 20.1V, above 20V maximum)
val highPayload = createBasePayload().apply {
this[17] = 201.toByte()
}
val highResult = ALDLParser.parseFrame(highPayload)
assertTrue("Battery above 20V should be rejected", highResult is com.example.esp32aldldashboard.parser.ALDLParseResult.InvalidData)
}
@Test
fun testTpsVoltageBoundaryValues() {
// Max representable TPS: 5.0V (255 * 0.019608)
val validPayload = createBasePayload().apply {
this[8] = 255.toByte()
}
val validResult = ALDLParser.parseFrame(validPayload)
assertTrue("Max representable TPS should be valid", validResult is com.example.esp32aldldashboard.parser.ALDLParseResult.Success)
}
@Test
fun testCoolantTempBoundaryValues() {
// Valid temperature: 89 * 0.75 - 40 = 26.75C (within -45 to 220 range)
val validPayload = createBasePayload()
val validResult = ALDLParser.parseFrame(validPayload)
assertTrue("Valid coolant temp should be accepted", validResult is com.example.esp32aldldashboard.parser.ALDLParseResult.Success)
// Max representable: 255 * 0.75 - 40 = 151.25C
val maxPayload = createBasePayload().apply {
this[4] = 255.toByte()
}
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
fun testIncompletePayload() {
// Payload with only 24 bytes (incomplete)
val incompletePayload = ByteArray(24) { 0x20.toByte() }
val result = ALDLParser.parseFrame(incompletePayload)
assertTrue("Incomplete payload should return Incomplete result", result is com.example.esp32aldldashboard.parser.ALDLParseResult.Incomplete)
}
private fun createBasePayload(): ByteArray {
return byteArrayOf(
0x20.toByte(), 0x00.toByte(), 0x2A.toByte(), 0x5F.toByte(), 0x59.toByte(),
0x00.toByte(), 0xF4.toByte(), 0x00.toByte(), 0x1E.toByte(), 0x80.toByte(),
0x65.toByte(), 0x08.toByte(), 0x00.toByte(), 0x00.toByte(), 0x00.toByte(),
0x25.toByte(), 0x18.toByte(), 0x7D.toByte(), 0x80.toByte(), 0x00.toByte(),
0x00.toByte(), 0x00.toByte(), 0xC9.toByte(), 0x02.toByte(), 0x62.toByte()
)
}
}
@@ -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)
}
}
@@ -1,29 +1,62 @@
@file:OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
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.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 class FakeContext : ContextWrapper(null) {
override fun getApplicationContext(): Context {
return this
}
override fun getSystemService(name: String): Any? {
return null
private val testDispatcher = UnconfinedTestDispatcher()
private val telemetryRepository = mockk<TelemetryRepository>(relaxed = true)
private val settingsRepository = mockk<SettingsRepository>(relaxed = true)
private val isCelsiusFlow = MutableStateFlow(false)
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
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()
}
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@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 +65,21 @@ class MainScreenViewModelTest {
@Test
fun testToggleTemperatureUnit() = runTest {
val context = FakeContext()
val viewModel = MainScreenViewModel(context)
val viewModel = MainScreenViewModel(telemetryRepository, settingsRepository)
// Start collecting to activate WhileSubscribed stateIn flow
backgroundScope.launch(testDispatcher) {
viewModel.isCelsius.collect {}
}
assertFalse(viewModel.isCelsius.value)
viewModel.toggleTemperatureUnit()
assertTrue(viewModel.isCelsius.value)
viewModel.toggleTemperatureUnit()
assertFalse(viewModel.isCelsius.value)
}
}
+1
View File
@@ -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
}
+1
View File
@@ -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
+14 -2
View File
@@ -10,15 +10,21 @@ 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"
mockk = "1.13.10"
[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-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"}
@@ -36,8 +42,14 @@ 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" }
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
[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" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
+169
View File
@@ -0,0 +1,169 @@
<?xml version="1.0" encoding="UTF-8"?>
<metadata>
<groupId>com.google.devtools.ksp</groupId>
<artifactId>com.google.devtools.ksp.gradle.plugin</artifactId>
<versioning>
<latest>2.3.9</latest>
<release>2.3.9</release>
<versions>
<version>1.5.21-1.0.0-beta07</version>
<version>1.5.30-1.0.0-beta08</version>
<version>1.5.30-1.0.0-beta09</version>
<version>1.5.30-1.0.0</version>
<version>1.5.31-1.0.0</version>
<version>1.5.31-1.0.1</version>
<version>1.6.0-M1-1.0.0</version>
<version>1.6.0-RC-1.0.1-RC</version>
<version>1.6.0-RC-1.0.0</version>
<version>1.6.0-1.0.1</version>
<version>1.6.0-1.0.2</version>
<version>1.6.10-RC-1.0.1</version>
<version>1.6.10-1.0.2</version>
<version>1.6.10-1.0.3</version>
<version>1.6.10-1.0.4</version>
<version>1.6.20-M1-1.0.2</version>
<version>1.6.20-RC-1.0.4</version>
<version>1.6.20-RC2-1.0.4</version>
<version>1.6.20-1.0.4</version>
<version>1.6.20-1.0.5</version>
<version>1.6.21-1.0.5</version>
<version>1.6.21-1.0.6</version>
<version>1.7.0-Beta-1.0.5</version>
<version>1.7.0-RC-1.0.5</version>
<version>1.7.0-RC2-1.0.5</version>
<version>1.7.0-1.0.6</version>
<version>1.7.10-1.0.6</version>
<version>1.7.20-Beta-1.0.6</version>
<version>1.7.20-RC-1.0.6</version>
<version>1.7.20-1.0.6</version>
<version>1.7.20-1.0.7</version>
<version>1.7.20-1.0.8</version>
<version>1.7.21-1.0.8</version>
<version>1.7.22-1.0.8</version>
<version>1.8.0-Beta-1.0.8</version>
<version>1.8.0-RC-1.0.8</version>
<version>1.8.0-RC2-1.0.8</version>
<version>1.8.0-1.0.8</version>
<version>1.8.0-1.0.9</version>
<version>1.8.10-1.0.9</version>
<version>1.8.20-Beta-1.0.9</version>
<version>1.8.20-RC-1.0.9</version>
<version>1.8.20-RC2-1.0.9</version>
<version>1.8.20-1.0.10</version>
<version>1.8.20-1.0.11</version>
<version>1.8.21-1.0.11</version>
<version>1.8.22-1.0.11</version>
<version>1.9.0-Beta-1.0.11</version>
<version>1.9.0-RC-1.0.11</version>
<version>1.9.0-1.0.11</version>
<version>1.9.0-1.0.12</version>
<version>1.9.0-1.0.13</version>
<version>1.9.10-1.0.13</version>
<version>1.9.20-Beta-1.0.13</version>
<version>1.9.20-Beta2-1.0.13</version>
<version>1.9.20-RC-1.0.13</version>
<version>1.9.20-RC2-1.0.13</version>
<version>1.9.20-1.0.13</version>
<version>1.9.20-1.0.14</version>
<version>1.9.21-1.0.15</version>
<version>1.9.21-1.0.16</version>
<version>1.9.22-1.0.16</version>
<version>1.9.22-1.0.17</version>
<version>1.9.22-1.0.18</version>
<version>1.9.23-1.0.19</version>
<version>1.9.23-1.0.20</version>
<version>1.9.24-1.0.20</version>
<version>1.9.25-1.0.20</version>
<version>2.0.0-Beta1-1.0.15</version>
<version>2.0.0-Beta1-1.0.14</version>
<version>2.0.0-Beta2-1.0.16</version>
<version>2.0.0-Beta3-1.0.17</version>
<version>2.0.0-Beta4-1.0.19</version>
<version>2.0.0-Beta4-1.0.18</version>
<version>2.0.0-Beta4-1.0.17</version>
<version>2.0.0-Beta5-1.0.20</version>
<version>2.0.0-Beta5-1.0.19</version>
<version>2.0.0-RC1-1.0.20</version>
<version>2.0.0-RC2-1.0.20</version>
<version>2.0.0-RC3-1.0.20</version>
<version>2.0.0-1.0.21</version>
<version>2.0.0-1.0.22</version>
<version>2.0.0-1.0.23</version>
<version>2.0.0-1.0.24</version>
<version>2.0.10-RC-1.0.23</version>
<version>2.0.10-RC2-1.0.24</version>
<version>2.0.10-1.0.24</version>
<version>2.0.20-Beta1-1.0.22</version>
<version>2.0.20-Beta2-1.0.23</version>
<version>2.0.20-RC-1.0.24</version>
<version>2.0.20-RC2-1.0.24</version>
<version>2.0.20-1.0.24</version>
<version>2.0.20-1.0.25</version>
<version>2.0.21-RC-1.0.25</version>
<version>2.0.21-1.0.25</version>
<version>2.0.21-1.0.26</version>
<version>2.0.21-1.0.27</version>
<version>2.0.21-1.0.28</version>
<version>2.1.0-Beta1-1.0.25</version>
<version>2.1.0-Beta2-1.0.26</version>
<version>2.1.0-Beta2-1.0.25</version>
<version>2.1.0-RC-1.0.26</version>
<version>2.1.0-RC-1.0.27</version>
<version>2.1.0-RC2-1.0.28</version>
<version>2.1.0-1.0.28</version>
<version>2.1.0-1.0.29</version>
<version>2.1.10-RC-1.0.29</version>
<version>2.1.10-RC2-1.0.29</version>
<version>2.1.10-1.0.29</version>
<version>2.1.10-1.0.30</version>
<version>2.1.10-1.0.31</version>
<version>2.1.20-Beta1-1.0.29</version>
<version>2.1.20-Beta2-1.0.30</version>
<version>2.1.20-Beta2-1.0.29</version>
<version>2.1.20-RC-1.0.30</version>
<version>2.1.20-RC-1.0.31</version>
<version>2.1.20-RC2-1.0.31</version>
<version>2.1.20-RC3-1.0.31</version>
<version>2.1.20-1.0.31</version>
<version>2.1.20-1.0.32</version>
<version>2.1.20-2.0.0</version>
<version>2.1.20-2.0.1</version>
<version>2.1.21-RC-2.0.0</version>
<version>2.1.21-RC2-2.0.1</version>
<version>2.1.21-2.0.1</version>
<version>2.1.21-2.0.2</version>
<version>2.2.0-Beta1-2.0.0</version>
<version>2.2.0-Beta2-2.0.1</version>
<version>2.2.0-RC-2.0.1</version>
<version>2.2.0-RC2-2.0.1</version>
<version>2.2.0-RC2-2.0.2</version>
<version>2.2.0-RC3-2.0.2</version>
<version>2.2.0-2.0.2</version>
<version>2.2.10-RC-2.0.2</version>
<version>2.2.10-RC2-2.0.2</version>
<version>2.2.10-2.0.2</version>
<version>2.2.20-Beta1-2.0.2</version>
<version>2.2.20-Beta2-2.0.2</version>
<version>2.2.20-RC-2.0.2</version>
<version>2.2.20-RC2-2.0.2</version>
<version>2.2.20-2.0.2</version>
<version>2.2.20-2.0.3</version>
<version>2.2.20-2.0.4</version>
<version>2.2.21-RC-2.0.4</version>
<version>2.2.21-RC2-2.0.4</version>
<version>2.2.21-2.0.4</version>
<version>2.2.21-2.0.5</version>
<version>2.3.0</version>
<version>2.3.1</version>
<version>2.3.2</version>
<version>2.3.3</version>
<version>2.3.4</version>
<version>2.3.5</version>
<version>2.3.6</version>
<version>2.3.7</version>
<version>2.3.8</version>
<version>2.3.9</version>
</versions>
<lastUpdated>20260526194322</lastUpdated>
</versioning>
</metadata>