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
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:
@@ -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 }}
|
||||
@@ -13,3 +13,4 @@
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
.windsurf/workflows/gitea-interaction.md
|
||||
|
||||
Generated
+26
@@ -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>
|
||||
Generated
+7
@@ -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>
|
||||
|
||||
Generated
+1
@@ -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>
|
||||
|
||||
Generated
+11
@@ -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>
|
||||
@@ -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><Comments></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><Comments></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>
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
package com.example.esp32aldldashboard.bluetooth
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.example.esp32aldldashboard.AldlApplication
|
||||
import com.example.esp32aldldashboard.MainActivity
|
||||
import com.example.esp32aldldashboard.R
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class BluetoothForegroundService : Service() {
|
||||
|
||||
private val CHANNEL_ID = "ALDL_BT_CHANNEL"
|
||||
private val NOTIFICATION_ID = 101
|
||||
|
||||
private val serviceScope = CoroutineScope(Dispatchers.Main + Job())
|
||||
private lateinit var bluetoothService: BluetoothService
|
||||
|
||||
companion object {
|
||||
const val ACTION_START = "ACTION_START"
|
||||
const val ACTION_STOP = "ACTION_STOP"
|
||||
const val ACTION_DISCONNECT = "ACTION_DISCONNECT"
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
val app = application as AldlApplication
|
||||
bluetoothService = app.bluetoothService
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_START -> {
|
||||
startForeground(NOTIFICATION_ID, createNotification("Connected to ESP32-ALDL"))
|
||||
observeTelemetry()
|
||||
}
|
||||
ACTION_DISCONNECT -> {
|
||||
bluetoothService.disconnect()
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
ACTION_STOP -> {
|
||||
bluetoothService.disconnect()
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun observeTelemetry() {
|
||||
serviceScope.launch {
|
||||
bluetoothService.latestFrame.collectLatest { frame ->
|
||||
if (frame != null) {
|
||||
updateNotification("RPM: ${frame.engineSpeedRpm} | Coolant: ${String.format("%.1f", frame.coolantTempC)}°C")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
serviceScope.launch {
|
||||
bluetoothService.connectionState.collectLatest { state ->
|
||||
when (state) {
|
||||
ConnectionState.DISCONNECTED, ConnectionState.ERROR -> {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNotification(contentText: String): Notification {
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this, 0, intent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
|
||||
val disconnectIntent = Intent(this, BluetoothForegroundService::class.java).apply {
|
||||
action = ACTION_DISCONNECT
|
||||
}
|
||||
val disconnectPendingIntent = PendingIntent.getService(
|
||||
this, 1, disconnectIntent, PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("ALDL Telemetry Active")
|
||||
.setContentText(contentText)
|
||||
.setSmallIcon(R.mipmap.ic_launcher) // Use app icon for now
|
||||
.setContentIntent(pendingIntent)
|
||||
.addAction(0, "Disconnect", disconnectPendingIntent)
|
||||
.setOngoing(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setOnlyAlertOnce(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun updateNotification(contentText: String) {
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager.notify(NOTIFICATION_ID, createNotification(contentText))
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val serviceChannel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Bluetooth Connection Service",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
)
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager.createNotificationChannel(serviceChannel)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? {
|
||||
return null
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
serviceScope.launch {
|
||||
// cancel jobs if needed
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package com.example.esp32aldldashboard.data.database
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(
|
||||
tableName = "telemetry_data_points",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = SessionEntity::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["sessionId"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)
|
||||
],
|
||||
indices = [
|
||||
Index(value = ["sessionId", "timestamp"])
|
||||
]
|
||||
)
|
||||
data class TelemetryDataPointEntity(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val sessionId: Long,
|
||||
val timestamp: Long,
|
||||
val rawBytes: ByteArray
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as TelemetryDataPointEntity
|
||||
|
||||
if (id != other.id) return false
|
||||
if (sessionId != other.sessionId) return false
|
||||
if (timestamp != other.timestamp) return false
|
||||
return rawBytes.contentEquals(other.rawBytes)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = id.hashCode()
|
||||
result = 31 * result + sessionId.hashCode()
|
||||
result = 31 * result + timestamp.hashCode()
|
||||
result = 31 * result + rawBytes.contentHashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.example.esp32aldldashboard.data.database
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
|
||||
@Database(entities = [SessionEntity::class, TelemetryDataPointEntity::class], version = 1, exportSchema = false)
|
||||
abstract class TelemetryDatabase : RoomDatabase() {
|
||||
|
||||
abstract fun telemetryDao(): TelemetryDao
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var INSTANCE: TelemetryDatabase? = null
|
||||
|
||||
fun getDatabase(context: Context): TelemetryDatabase {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
val instance = Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
TelemetryDatabase::class.java,
|
||||
"telemetry_database"
|
||||
).build()
|
||||
INSTANCE = instance
|
||||
instance
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.example.esp32aldldashboard.logging
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import com.example.esp32aldldashboard.parser.ALDLFrame
|
||||
import java.io.OutputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
class CsvLogger(private val context: Context) {
|
||||
|
||||
private var currentOutputStream: OutputStream? = null
|
||||
|
||||
fun startNewSession(isSimulation: Boolean): Boolean {
|
||||
val timeStamp: String = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
||||
val fileName = "ALDL_Log_${if(isSimulation) "SIM_" else ""}$timeStamp.csv"
|
||||
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val resolver = context.contentResolver
|
||||
val contentValues = ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
|
||||
put(MediaStore.MediaColumns.MIME_TYPE, "text/csv")
|
||||
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS + "/ALDLLogs")
|
||||
}
|
||||
|
||||
val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
|
||||
if (uri != null) {
|
||||
currentOutputStream = resolver.openOutputStream(uri)
|
||||
}
|
||||
} else {
|
||||
// For older Android versions, we'd need WRITE_EXTERNAL_STORAGE and write to Environment.getExternalStoragePublicDirectory.
|
||||
// We'll skip legacy support for this specific snippet to keep it concise, assuming target is modern Android.
|
||||
return false
|
||||
}
|
||||
|
||||
// Write CSV Header
|
||||
val header = "Timestamp,Raw_Hex,RPM,Coolant_C,Coolant_F,MAP_kPa,MAP_Volts,TPS_Volts,O2_mV,Battery_V,Spark_Adv,IAC,BPW_ms,Speed_MPH,MAT_C,MAT_F,BLM,INT,EGR_Duty,Rich_Crosses,Closed_Loop,Rich\n"
|
||||
currentOutputStream?.write(header.toByteArray())
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
fun logFrame(frame: ALDLFrame) {
|
||||
val out = currentOutputStream ?: return
|
||||
|
||||
val hexString = frame.rawBytes.joinToString(" ") { String.format("%02X", it) }
|
||||
val dateString = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US).format(Date(frame.timestamp))
|
||||
|
||||
val row = String.format(
|
||||
Locale.US,
|
||||
"%s,%s,%d,%.1f,%.1f,%.1f,%.3f,%.3f,%.1f,%.1f,%.1f,%d,%.2f,%d,%.1f,%.1f,%d,%d,%.1f,%d,%b,%b\n",
|
||||
dateString,
|
||||
hexString,
|
||||
frame.engineSpeedRpm,
|
||||
frame.coolantTempC,
|
||||
frame.coolantTempF,
|
||||
frame.mapKpa,
|
||||
frame.mapVolts,
|
||||
frame.tpsVolts,
|
||||
frame.o2SensorMv,
|
||||
frame.batteryVolts,
|
||||
frame.sparkAdvance,
|
||||
frame.iacPosition,
|
||||
frame.bpwMs,
|
||||
frame.vehicleSpeedMPH,
|
||||
frame.matC,
|
||||
frame.matF,
|
||||
frame.blm,
|
||||
frame.integrator,
|
||||
frame.egrDutyCycle,
|
||||
frame.richLeanCrosses,
|
||||
frame.isClosedLoop,
|
||||
frame.isRich
|
||||
)
|
||||
|
||||
try {
|
||||
out.write(row.toByteArray())
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
fun endSession() {
|
||||
try {
|
||||
currentOutputStream?.close()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
} finally {
|
||||
currentOutputStream = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+96
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+20
@@ -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)
|
||||
}
|
||||
}
|
||||
+53
-12
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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" }
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user