feat: implement telemetry persistence, data visualization, and improved bluetooth stream parsing with frame statistics #9
@@ -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
|
.externalNativeBuild
|
||||||
.cxx
|
.cxx
|
||||||
local.properties
|
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>
|
<selectionStates>
|
||||||
<SelectionState runConfigName="app">
|
<SelectionState runConfigName="app">
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
<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 />
|
<DialogSelection />
|
||||||
</SelectionState>
|
</SelectionState>
|
||||||
</selectionStates>
|
</selectionStates>
|
||||||
|
|||||||
Generated
+1
@@ -1,5 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
|
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||||
<component name="GradleSettings">
|
<component name="GradleSettings">
|
||||||
<option name="linkedExternalProjectsSettings">
|
<option name="linkedExternalProjectsSettings">
|
||||||
<GradleProjectSettings>
|
<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
|
* **Real-Time Instrumentation Dashboard:**
|
||||||
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.
|
* 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.
|
||||||
| Byte Index | Length | Value / Field | Description |
|
* **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.
|
||||||
| **0 - 1** | 2 bytes | `AA 55` | Frame Sync Header (added by ESP32) |
|
* **BLM & INT Fuel Trim Heatmap Grid (New):**
|
||||||
| **2 - 26** | 25 bytes | Raw Data Payload | 1227170 ECM data stream (0-indexed indices 0 to 24) |
|
* 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`).
|
* **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):**
|
||||||
### 1. Primary Sensors & Measurements
|
* 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.
|
||||||
| Parameter | Payload Index | Raw Size | Formula / Conversion | Units |
|
* **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):**
|
||||||
| **IAC Position** | Index 3 (Byte 4) | 8-bit | $Value = Raw$ | Steps |
|
* 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.
|
||||||
| **Coolant Temp** | Index 4 (Byte 5) | 8-bit | $C = (Raw \times 0.75) - 40$<br>$F = (Raw \times 1.35) - 40$ | °C / °F |
|
* **Firebase Integration:** Incorporates Firebase Crashlytics to monitor application stability and track runtime exceptions.
|
||||||
| **Vehicle Speed** | Index 5 (Byte 6) | 8-bit | $Value = Raw$ (Operation 3) | MPH |
|
|
||||||
| **MAP** | Index 6 (Byte 7) | 8-bit | $Volts = Raw \times 0.019608$<br>$kPa = (Raw \times 0.369) + 10.354$ | Volts / kPa |
|
|
||||||
| **Engine Speed** | Index 7 (Byte 8) | 8-bit | $RPM = Raw \times 25$ | RPM |
|
|
||||||
| **TPS** | Index 8 (Byte 9) | 8-bit | $Volts = Raw \times 0.019608$ | Volts |
|
|
||||||
| **Integrator (INT)** | Index 9 (Byte 10) | 8-bit | $Value = Raw$ | — |
|
|
||||||
| **O2 Sensor** | Index 10 (Byte 11) | 8-bit | $mV = Raw \times 4.44$ | mV |
|
|
||||||
| **Battery Voltage** | Index 17 (Byte 18) | 8-bit | $Volts = Raw \times 0.1$ | Volts |
|
|
||||||
| **BLM** | Index 18 (Byte 19) | 8-bit | $Value = Raw$ | — |
|
|
||||||
| **Rich/Lean Crosses** | Index 19 (Byte 20) | 8-bit | $Value = Raw$ | Crosses |
|
|
||||||
| **Spark Advance** | Index 20 (Byte 21) | 8-bit | $Degrees = Raw \times 0.351563$ | Degrees |
|
|
||||||
| **EGR Duty Cycle** | Index 21 (Byte 22) | 8-bit | $Percent = Raw \times 0.392157$ | % |
|
|
||||||
| **Manifold Air Temp (MAT)** | Index 22 (Byte 23) | 8-bit | **Linear Table Interpolation** (see below) | °C / °F |
|
|
||||||
| **Base Pulse Width (BPW)** | Index 23-24 (Byte 24-25) | 16-bit | $Raw = (HighByte \ll 8) \vert LowByte$<br>$ms = Raw \times 0.015259$ | Milliseconds (ms) |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 2. MAT Linear Interpolation Tables
|
## 📱 Requirements
|
||||||
The Manifold Air Temperature (MAT) is read from Index 22. It is mapped to degrees C or F using interpolation curves specified in tables 52 and 53:
|
|
||||||
|
|
||||||
* **Celsius (`MAT C` - Table 52):**
|
* **Android Device:** Android 8.0 (API Level 26) or higher.
|
||||||
* `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`
|
* **Bluetooth Permissions:** Requires Nearby Devices (Android 12+) and Legacy Bluetooth Admin access.
|
||||||
* **Fahrenheit (`MAT F` - Table 53):**
|
* **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`).
|
||||||
* `0` -> `392.0`, `12` -> `302.0`, `13` -> `293.0`, `14` -> `284.0`, `16` -> `275.0`, `18` -> `266.0`, `21` -> `257.0`, `23` -> `248.0`, `26` -> `239.0`, `30` -> `230.0`, `34` -> `221.0`, `39` -> `212.0`, `44` -> `203.0`, `50` -> `194.0`, `56` -> `185.0`, `64` -> `176.0`, `72` -> `167.0`, `81` -> `158.0`, `92` -> `149.0`, `102` -> `140.0`, `114` -> `131.0`, `126` -> `122.0`, `139` -> `113.0`, `152` -> `104.0`, `165` -> `95.0`, `177` -> `86.0`, `189` -> `77.0`, `199` -> `68.0`, `209` -> `59.0`, `218` -> `50.0`, `225` -> `41.0`, `231` -> `32.0`, `237` -> `23.0`, `241` -> `14.0`, `245` -> `5.0`, `247` -> `-4.0`, `250` -> `-13.0`, `251` -> `-22.0`, `255` -> `-40.0`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3. Stored Fault Trouble Codes
|
## 🚀 Setup & Usage
|
||||||
|
|
||||||
Malfunction Indicator Codes are mapped as bit flags across three specific status bytes:
|
1. Pair the `ESP32-ALDL` Bluetooth module in your Android system settings.
|
||||||
|
2. Launch the application and grant all requested permissions.
|
||||||
#### Codes Byte 1 (Payload Index 11 / Byte 12)
|
3. Tap **Connect BT** on the main dashboard to establish connection and start telemetry.
|
||||||
* **Bit 7:** Code 12 - Crank Sensor / System Check
|
4. Navigate between screens (Dashboard, Charts, BLM Table, Settings) using the bottom navigation bar.
|
||||||
* **Bit 6:** Code 13 - O2 Sensor
|
5. Configure logging preferences or browse logged CSV/BIN files in the **Settings** menu.
|
||||||
* **Bit 5:** Code 14 - Coolant High Temp
|
|
||||||
* **Bit 4:** Code 15 - Coolant Low Temp
|
|
||||||
* **Bit 3:** Code 21 - TPS Voltage High
|
|
||||||
* **Bit 2:** Code 22 - TPS Voltage Low
|
|
||||||
* **Bit 1:** Code 23 - MAT Voltage Low
|
|
||||||
* **Bit 0:** Code 24 - Vehicle Speed Sensor (VSS)
|
|
||||||
|
|
||||||
#### Codes Byte 2 (Payload Index 12 / Byte 13)
|
|
||||||
* **Bit 7:** Code 25 - MAT Voltage High
|
|
||||||
* **Bit 5:** Code 32 - EGR System
|
|
||||||
* **Bit 4:** Code 33 - MAP Sensor High
|
|
||||||
* **Bit 3:** Code 34 - MAP Sensor Low
|
|
||||||
* **Bit 2:** Code 35 - IAC Position
|
|
||||||
* **Bit 0:** Code 42 - Electronic Spark Timing (EST)
|
|
||||||
|
|
||||||
#### Codes Byte 3 (Payload Index 13 / Byte 14)
|
|
||||||
* **Bit 7:** Code 43 - Electronic Spark Control (Knock Sensor)
|
|
||||||
* **Bit 6:** Code 44 - O2 Sensor Lean Exhaust
|
|
||||||
* **Bit 5:** Code 45 - O2 Sensor Rich Exhaust
|
|
||||||
* **Bit 4:** Code 51 - PROM Error
|
|
||||||
* **Bit 3:** Code 52 - Cal-Pack Error
|
|
||||||
* **Bit 2:** Code 53 - System Battery Over-Voltage
|
|
||||||
* **Bit 0:** Code 55 - ADU Error
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 4. Engine Status & Bit Flags
|
## 🧪 Testing & Verification
|
||||||
|
|
||||||
#### Misc Byte 1 (Payload Index 14 / Byte 15)
|
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).
|
||||||
* **Bit 1:** BLM Enabled (1 = Yes)
|
|
||||||
* **Bit 3:** Quasi Pulse Mode (1 = Yes)
|
|
||||||
* **Bit 4:** Async Pulse Mode (1 = Yes)
|
|
||||||
* **Bit 6:** Rich/Lean Status (1 = Rich, 0 = Lean)
|
|
||||||
* **Bit 7:** Loop Status (1 = Closed, 0 = Open)
|
|
||||||
|
|
||||||
#### Misc Byte 2 (Payload Index 15 / Byte 16)
|
To run the unit tests, execute the following Gradle command in the root project directory:
|
||||||
* **Bit 5:** A/C Status (0 = Enabled, 1 = Disabled/Idle)
|
```bash
|
||||||
* **Bit 7:** Park/Neutral Switch (1 = Park/Neutral, 0 = In Gear)
|
./gradlew test
|
||||||
|
```
|
||||||
|
|
||||||
#### Misc Byte 3 (Payload Index 16 / Byte 17)
|
### Coverage Areas
|
||||||
* **Bit 0:** A/C Clutch Command (1 = Enabled)
|
* **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.
|
||||||
* **Bit 2:** Torque Converter Clutch (TCC) (1 = Locked)
|
* **Boundary Guards:** Ensures outlier protection for engine speeds, battery voltages, TPS, and coolant temperatures, rejecting out-of-range sensor values as invalid data packets.
|
||||||
* **Bit 5:** Power Steering Cramp Switch (1 = Active)
|
* **Lookahead Stability:** Validates parser behavior against truncated or incomplete frame fragments.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
@@ -2,6 +2,7 @@ plugins {
|
|||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.compose.compiler)
|
alias(libs.plugins.compose.compiler)
|
||||||
alias(libs.plugins.kotlin.serialization)
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
alias(libs.plugins.ksp)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@@ -61,6 +62,8 @@ dependencies {
|
|||||||
implementation(libs.androidx.compose.ui)
|
implementation(libs.androidx.compose.ui)
|
||||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
implementation(libs.androidx.compose.material3)
|
implementation(libs.androidx.compose.material3)
|
||||||
|
implementation(libs.androidx.compose.material.icons.core)
|
||||||
|
implementation(libs.androidx.compose.material.icons.extended)
|
||||||
// Tooling
|
// Tooling
|
||||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||||
// Instrumented tests
|
// Instrumented tests
|
||||||
@@ -70,6 +73,7 @@ dependencies {
|
|||||||
// Local tests: jUnit, coroutines, Android runner
|
// Local tests: jUnit, coroutines, Android runner
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
testImplementation(libs.kotlinx.coroutines.test)
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
|
testImplementation(libs.mockk)
|
||||||
|
|
||||||
// Instrumented tests: jUnit rules and runners
|
// Instrumented tests: jUnit rules and runners
|
||||||
androidTestImplementation(libs.androidx.test.core)
|
androidTestImplementation(libs.androidx.test.core)
|
||||||
@@ -81,4 +85,12 @@ dependencies {
|
|||||||
implementation(libs.androidx.navigation3.ui)
|
implementation(libs.androidx.navigation3.ui)
|
||||||
implementation(libs.androidx.navigation3.runtime)
|
implementation(libs.androidx.navigation3.runtime)
|
||||||
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
|
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_FINE_LOCATION" android:maxSdkVersion="30" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_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
|
<application
|
||||||
|
android:name=".AldlApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
@@ -22,6 +28,10 @@
|
|||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:windowSoftInputMode="adjustResize"
|
android:windowSoftInputMode="adjustResize"
|
||||||
android:theme="@style/Theme.ESP32ALDLDashboard">
|
android:theme="@style/Theme.ESP32ALDLDashboard">
|
||||||
|
<service
|
||||||
|
android:name=".bluetooth.BluetoothForegroundService"
|
||||||
|
android:foregroundServiceType="connectedDevice"
|
||||||
|
android:exported="false" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true">
|
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 =
|
||||||
entryProvider {
|
entryProvider {
|
||||||
entry<Main> {
|
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 android.util.Log
|
||||||
import com.example.esp32aldldashboard.parser.ALDLFrame
|
import com.example.esp32aldldashboard.parser.ALDLFrame
|
||||||
import com.example.esp32aldldashboard.parser.ALDLParser
|
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.*
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -23,7 +26,11 @@ enum class ConnectionState {
|
|||||||
ERROR
|
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 TAG = "ALDLBluetoothService"
|
||||||
private val SPP_UUID = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB")
|
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())
|
private val _rawHexLog = MutableStateFlow<List<String>>(emptyList())
|
||||||
val rawHexLog: StateFlow<List<String>> = _rawHexLog
|
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("")
|
private val _errorMessage = MutableStateFlow("")
|
||||||
val errorMessage: StateFlow<String> = _errorMessage
|
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 connectionJob: Job? = null
|
||||||
private var socket: BluetoothSocket? = null
|
private var socket: BluetoothSocket? = null
|
||||||
private var isConnected = false
|
private var isConnected = false
|
||||||
@@ -68,6 +139,12 @@ class BluetoothService(private val context: Context) {
|
|||||||
_errorMessage.value = ""
|
_errorMessage.value = ""
|
||||||
|
|
||||||
connectionJob = CoroutineScope(Dispatchers.Default).launch {
|
connectionJob = CoroutineScope(Dispatchers.Default).launch {
|
||||||
|
// Start raw recording if enabled
|
||||||
|
val shouldRecordRaw = settingsRepository.recordRawDataFlow.first()
|
||||||
|
if (shouldRecordRaw) {
|
||||||
|
rawStreamLogger.startRecording()
|
||||||
|
}
|
||||||
|
|
||||||
var simStep = 0
|
var simStep = 0
|
||||||
val basePayload = byteArrayOf(
|
val basePayload = byteArrayOf(
|
||||||
0x20.toByte(), 0x00.toByte(), 0x2A.toByte(), 0x5F.toByte(), 0x59.toByte(),
|
0x20.toByte(), 0x00.toByte(), 0x2A.toByte(), 0x5F.toByte(), 0x59.toByte(),
|
||||||
@@ -157,14 +234,26 @@ class BluetoothService(private val context: Context) {
|
|||||||
payload[24] = bpwLowRaw.toByte()
|
payload[24] = bpwLowRaw.toByte()
|
||||||
|
|
||||||
val parsed = ALDLParser.parseFrame(payload)
|
val parsed = ALDLParser.parseFrame(payload)
|
||||||
if (parsed != null) {
|
if (parsed is com.example.esp32aldldashboard.parser.ALDLParseResult.Success) {
|
||||||
_latestFrame.value = parsed
|
_latestFrame.value = parsed.frame
|
||||||
val hexString = payload.joinToString(" ") { String.format("%02X", it) }
|
val hexString = payload.joinToString(" ") { String.format("%02X", it) }
|
||||||
addRawHexLog("AA 55 $hexString (SIMULATED)")
|
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)
|
delay(1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop raw recording when simulation ends
|
||||||
|
rawStreamLogger.stopRecording()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,8 +307,17 @@ class BluetoothService(private val context: Context) {
|
|||||||
_connectionState.value = ConnectionState.CONNECTED
|
_connectionState.value = ConnectionState.CONNECTED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start raw recording if enabled
|
||||||
|
val shouldRecordRaw = settingsRepository.recordRawDataFlow.first()
|
||||||
|
if (shouldRecordRaw) {
|
||||||
|
rawStreamLogger.startRecording()
|
||||||
|
}
|
||||||
|
|
||||||
readDataStream(socket!!.inputStream)
|
readDataStream(socket!!.inputStream)
|
||||||
|
|
||||||
|
// Stop raw recording when connection ends
|
||||||
|
rawStreamLogger.stopRecording()
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Connection failed: ${e.message}", e)
|
Log.e(TAG, "Connection failed: ${e.message}", e)
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
@@ -233,7 +331,9 @@ class BluetoothService(private val context: Context) {
|
|||||||
|
|
||||||
private suspend fun readDataStream(inputStream: InputStream) {
|
private suspend fun readDataStream(inputStream: InputStream) {
|
||||||
val readBuffer = ByteArray(128)
|
val readBuffer = ByteArray(128)
|
||||||
val syncBuffer = ArrayList<Byte>()
|
val syncBuffer = ArrayDeque<Byte>(128)
|
||||||
|
var lastFrameTime = System.currentTimeMillis()
|
||||||
|
var framesInCurrentSecond = 0
|
||||||
|
|
||||||
while (currentCoroutineContext().isActive && isConnected) {
|
while (currentCoroutineContext().isActive && isConnected) {
|
||||||
try {
|
try {
|
||||||
@@ -245,55 +345,83 @@ class BluetoothService(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (j in 0 until bytesRead) {
|
for (j in 0 until bytesRead) {
|
||||||
syncBuffer.add(readBuffer[j])
|
syncBuffer.addLast(readBuffer[j])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for frame matches in buffer
|
// Check for frame matches in buffer
|
||||||
while (syncBuffer.size >= 27) {
|
while (syncBuffer.size >= 27) {
|
||||||
var foundHeader = false
|
val headerResult = findValidHeader(syncBuffer)
|
||||||
for (idx in 0 until syncBuffer.size - 1) {
|
|
||||||
if ((syncBuffer[idx].toInt() and 0xFF) == 0xAA && (syncBuffer[idx + 1].toInt() and 0xFF) == 0x55) {
|
if (headerResult.found) {
|
||||||
// Discard garbage preceding header
|
val headerIdx = headerResult.index
|
||||||
if (idx > 0) {
|
|
||||||
for (d in 0 until idx) {
|
// Discard garbage preceding header
|
||||||
syncBuffer.removeAt(0)
|
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) {
|
if (syncBuffer.size >= 27) {
|
||||||
val payload = ByteArray(25)
|
syncBuffer.removeFirst() // AA
|
||||||
for (p in 0 until 25) {
|
syncBuffer.removeFirst() // 55
|
||||||
payload[p] = syncBuffer[p + 2]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Consume the 27 bytes from buffer
|
val payload = ByteArray(25)
|
||||||
for (r in 0 until 27) {
|
val rawFrame = ByteArray(27) // Include header for raw logging
|
||||||
syncBuffer.removeAt(0)
|
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)
|
val parsed = ALDLParser.parseFrame(payload)
|
||||||
if (parsed != null) {
|
when (parsed) {
|
||||||
withContext(Dispatchers.Main) {
|
is com.example.esp32aldldashboard.parser.ALDLParseResult.Success -> {
|
||||||
_latestFrame.value = parsed
|
_framesReceived.value += 1
|
||||||
val hexString = payload.joinToString(" ") { String.format("%02X", it) }
|
framesInCurrentSecond++
|
||||||
addRawHexLog("AA 55 $hexString")
|
|
||||||
|
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 {
|
} else {
|
||||||
// Header found, but waiting for full 27-byte frame
|
// Header found, but waiting for full 27-byte frame
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
} else {
|
} 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()
|
val lastByte = syncBuffer.last()
|
||||||
syncBuffer.clear()
|
syncBuffer.clear()
|
||||||
if ((lastByte.toInt() and 0xFF) == 0xAA) {
|
if ((lastByte.toInt() and 0xFF) == 0xAA) {
|
||||||
syncBuffer.add(lastByte)
|
syncBuffer.addLast(lastByte)
|
||||||
|
} else {
|
||||||
|
_parseErrors.value += 1
|
||||||
}
|
}
|
||||||
break
|
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 {
|
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 {
|
private fun interpolate(raw: Int, table: List<Pair<Int, Float>>): Float {
|
||||||
if (raw <= table.first().first) return table.first().second
|
if (raw <= table.first().first) return table.first().second
|
||||||
if (raw >= table.last().first) return table.last().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? {
|
fun parseFrame(data: ByteArray): ALDLParseResult {
|
||||||
if (data.size != 25) return null
|
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 engineSpeedRpm = u[7] * ALDLConstants.RPM_SCALE
|
||||||
val iacPosition = u[3] // Byte 4
|
if (engineSpeedRpm > ALDLConstants.MAX_RPM) {
|
||||||
val coolantTempC = u[4] * 0.75f - 40.0f // Byte 5
|
return ALDLParseResult.InvalidData("RPM ($engineSpeedRpm) exceeds plausibility limit (${ALDLConstants.MAX_RPM})")
|
||||||
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 codesByte1 = u[11] // Byte 12
|
val coolantTempC = u[4] * ALDLConstants.COOLANT_SCALE_C + ALDLConstants.COOLANT_OFFSET_C
|
||||||
val codesByte2 = u[12] // Byte 13
|
val coolantTempF = u[4] * ALDLConstants.COOLANT_SCALE_F + ALDLConstants.COOLANT_OFFSET_F
|
||||||
val codesByte3 = u[13] // Byte 14
|
if (coolantTempC < ALDLConstants.MIN_TEMP_C || coolantTempC > ALDLConstants.MAX_TEMP_C) {
|
||||||
val miscByte1 = u[14] // Byte 15
|
return ALDLParseResult.InvalidData("Coolant Temp ($coolantTempC C) outside bounds")
|
||||||
val miscByte2 = u[15] // Byte 16
|
}
|
||||||
val miscByte3 = u[16] // Byte 17
|
|
||||||
|
|
||||||
val batteryVolts = u[17] * 0.1f // Byte 18
|
val batteryVolts = u[17] * ALDLConstants.BATTERY_VOLTS_SCALE
|
||||||
val blm = u[18] // Byte 19
|
if (batteryVolts < ALDLConstants.MIN_BATTERY_V || batteryVolts > ALDLConstants.MAX_BATTERY_V) {
|
||||||
val richLeanCrosses = u[19] // Byte 20
|
return ALDLParseResult.InvalidData("Battery Voltage ($batteryVolts V) outside bounds")
|
||||||
val sparkAdvance = u[20] * 0.351563f // Byte 21
|
}
|
||||||
val egrDutyCycle = u[21] * 0.392157f // Byte 22
|
|
||||||
|
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], ALDLConstants.MAT_TABLE_C)
|
||||||
val matC = interpolate(u[22], matTableC) // Byte 23
|
val matF = interpolate(u[22], ALDLConstants.MAT_TABLE_F)
|
||||||
val matF = interpolate(u[22], matTableF) // Byte 23
|
|
||||||
|
|
||||||
// BPW (Base Pulse Width) 16-bit
|
val rawBpw = (u[23] shl 8) or u[24]
|
||||||
val rawBpw = (u[23] shl 8) or u[24] // Byte 24 (High), Byte 25 (Low)
|
val bpwMs = rawBpw * ALDLConstants.BPW_MS_SCALE
|
||||||
val bpwMs = rawBpw * 0.015259f
|
|
||||||
|
|
||||||
// Status Flags Decoding
|
val blmEnable = (miscByte1 and ALDLConstants.BIT_1) != 0
|
||||||
// Misc Byte 1 (Byte 15)
|
val quasiPulse = (miscByte1 and ALDLConstants.BIT_3) != 0
|
||||||
val blmEnable = (miscByte1 and 0x02) != 0 // bit 1
|
val asyncPulse = (miscByte1 and ALDLConstants.BIT_4) != 0
|
||||||
val quasiPulse = (miscByte1 and 0x08) != 0 // bit 3
|
val isRich = (miscByte1 and ALDLConstants.BIT_6) != 0
|
||||||
val asyncPulse = (miscByte1 and 0x10) != 0 // bit 4
|
val isClosedLoop = (miscByte1 and ALDLConstants.BIT_7) != 0
|
||||||
val isRich = (miscByte1 and 0x40) != 0 // bit 6 (1=RICH, 0=LEAN)
|
|
||||||
val isClosedLoop = (miscByte1 and 0x80) != 0 // bit 7 (1=CLOSED, 0=OPEN)
|
|
||||||
|
|
||||||
// Misc Byte 2 (Byte 16)
|
val isAcEnabled = (miscByte2 and ALDLConstants.BIT_5) == 0
|
||||||
val isAcEnabled = (miscByte2 and 0x20) == 0 // bit 5 (0=ENABLED, 1=DISABLED/IDLE)
|
val isParkNeutral = (miscByte2 and ALDLConstants.BIT_7) != 0
|
||||||
val isParkNeutral = (miscByte2 and 0x80) != 0 // bit 7 (1=PARK/NEUTRAL, 0=IN GEAR)
|
|
||||||
|
|
||||||
// Misc Byte 3 (Byte 17)
|
val isAcClutchEnabled = (miscByte3 and ALDLConstants.BIT_0) != 0
|
||||||
val isAcClutchEnabled = (miscByte3 and 0x01) != 0 // bit 0 (1=ENABLED)
|
val isTccLocked = (miscByte3 and ALDLConstants.BIT_2) != 0
|
||||||
val isTccLocked = (miscByte3 and 0x04) != 0 // bit 2 (1=LOCKED)
|
val isPowerSteeringCrampActive = (miscByte3 and ALDLConstants.BIT_5) != 0
|
||||||
val isPowerSteeringCrampActive = (miscByte3 and 0x20) != 0 // bit 5 (1=ACTIVE)
|
|
||||||
|
|
||||||
// Active Fault Codes list based on code bits
|
|
||||||
val activeCodes = mutableListOf<Int>()
|
val activeCodes = mutableListOf<Int>()
|
||||||
// Byte 12
|
if ((codesByte1 and ALDLConstants.BIT_7) != 0) activeCodes.add(12)
|
||||||
if ((codesByte1 and 0x80) != 0) activeCodes.add(12) // bit 7
|
if ((codesByte1 and ALDLConstants.BIT_6) != 0) activeCodes.add(13)
|
||||||
if ((codesByte1 and 0x40) != 0) activeCodes.add(13) // bit 6
|
if ((codesByte1 and ALDLConstants.BIT_5) != 0) activeCodes.add(14)
|
||||||
if ((codesByte1 and 0x20) != 0) activeCodes.add(14) // bit 5
|
if ((codesByte1 and ALDLConstants.BIT_4) != 0) activeCodes.add(15)
|
||||||
if ((codesByte1 and 0x10) != 0) activeCodes.add(15) // bit 4
|
if ((codesByte1 and ALDLConstants.BIT_3) != 0) activeCodes.add(21)
|
||||||
if ((codesByte1 and 0x08) != 0) activeCodes.add(21) // bit 3
|
if ((codesByte1 and ALDLConstants.BIT_2) != 0) activeCodes.add(22)
|
||||||
if ((codesByte1 and 0x04) != 0) activeCodes.add(22) // bit 2
|
if ((codesByte1 and ALDLConstants.BIT_1) != 0) activeCodes.add(23)
|
||||||
if ((codesByte1 and 0x02) != 0) activeCodes.add(23) // bit 1
|
if ((codesByte1 and ALDLConstants.BIT_0) != 0) activeCodes.add(24)
|
||||||
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
|
|
||||||
|
|
||||||
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,
|
rawBytes = data,
|
||||||
iacPosition = iacPosition,
|
iacPosition = iacPosition,
|
||||||
coolantTempC = coolantTempC,
|
coolantTempC = coolantTempC,
|
||||||
@@ -265,5 +259,6 @@ object ALDLParser {
|
|||||||
isPowerSteeringCrampActive = isPowerSteeringCrampActive,
|
isPowerSteeringCrampActive = isPowerSteeringCrampActive,
|
||||||
activeFaultCodes = activeCodes
|
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 android.widget.Toast
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.material.icons.filled.Info
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.material.icons.filled.ShowChart
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.material.icons.filled.TableChart
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
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.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.core.content.ContextCompat
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.navigation3.runtime.NavKey
|
import com.example.esp32aldldashboard.AldlApplication
|
||||||
import com.example.esp32aldldashboard.bluetooth.ConnectionState
|
import com.example.esp32aldldashboard.ui.blm.BLMTableScreen
|
||||||
import com.example.esp32aldldashboard.parser.ALDLFrame
|
import com.example.esp32aldldashboard.ui.blm.BLMTableViewModelFactory
|
||||||
|
import com.example.esp32aldldashboard.ui.charts.ChartsScreen
|
||||||
// Theme Colors
|
import com.example.esp32aldldashboard.ui.settings.SettingsScreen
|
||||||
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)
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MainScreen(
|
fun MainScreen(
|
||||||
onItemClick: (NavKey) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
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 connState by viewModel.connectionState.collectAsStateWithLifecycle()
|
||||||
val frame by viewModel.latestFrame.collectAsStateWithLifecycle()
|
val frame by viewModel.latestFrame.collectAsStateWithLifecycle()
|
||||||
val rawLog by viewModel.rawHexLog.collectAsStateWithLifecycle()
|
val rawLog by viewModel.rawHexLog.collectAsStateWithLifecycle()
|
||||||
val errorMsg by viewModel.errorMessage.collectAsStateWithLifecycle()
|
|
||||||
val isCelsius by viewModel.isCelsius.collectAsStateWithLifecycle()
|
val isCelsius by viewModel.isCelsius.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val permissionsLauncher = rememberLauncherForActivityResult(
|
val permissionsLauncher = rememberLauncherForActivityResult(
|
||||||
@@ -70,7 +58,8 @@ fun MainScreen(
|
|||||||
val requiredPermissions = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
|
val requiredPermissions = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
|
||||||
arrayOf(
|
arrayOf(
|
||||||
Manifest.permission.BLUETOOTH_SCAN,
|
Manifest.permission.BLUETOOTH_SCAN,
|
||||||
Manifest.permission.BLUETOOTH_CONNECT
|
Manifest.permission.BLUETOOTH_CONNECT,
|
||||||
|
Manifest.permission.POST_NOTIFICATIONS
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
arrayOf(
|
arrayOf(
|
||||||
@@ -90,623 +79,70 @@ fun MainScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
MainScreenContent(
|
var selectedTab by remember { mutableStateOf(0) }
|
||||||
connState = connState,
|
|
||||||
frame = frame,
|
|
||||||
rawLog = rawLog,
|
|
||||||
errorMsg = errorMsg,
|
|
||||||
isCelsius = isCelsius,
|
|
||||||
onConnect = onConnectClick,
|
|
||||||
onDisconnect = { viewModel.disconnect() },
|
|
||||||
onSimulate = { viewModel.startSimulation() },
|
|
||||||
onToggleUnit = { viewModel.toggleTemperatureUnit() },
|
|
||||||
modifier = modifier
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
Scaffold(
|
||||||
fun MainScreenContent(
|
bottomBar = {
|
||||||
connState: ConnectionState,
|
NavigationBar(containerColor = DarkBg, contentColor = NeonOrange) {
|
||||||
frame: ALDLFrame?,
|
NavigationBarItem(
|
||||||
rawLog: List<String>,
|
icon = { Icon(Icons.Default.Info, contentDescription = "Dashboard") },
|
||||||
errorMsg: String,
|
label = { Text("Dashboard") },
|
||||||
isCelsius: Boolean,
|
selected = selectedTab == 0,
|
||||||
onConnect: () -> Unit,
|
onClick = { selectedTab = 0 },
|
||||||
onDisconnect: () -> Unit,
|
colors = NavigationBarItemDefaults.colors(selectedIconColor = NeonCyan, unselectedIconColor = TextMuted)
|
||||||
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)
|
|
||||||
)
|
)
|
||||||
MetricCard(
|
NavigationBarItem(
|
||||||
title = "VEHICLE SPEED",
|
icon = { Icon(Icons.Default.ShowChart, contentDescription = "Charts") },
|
||||||
value = "${frame.vehicleSpeedMPH}",
|
label = { Text("Charts") },
|
||||||
unit = "MPH",
|
selected = selectedTab == 1,
|
||||||
progress = frame.vehicleSpeedMPH / 120f,
|
onClick = { selectedTab = 1 },
|
||||||
progressColor = NeonGreen,
|
colors = NavigationBarItemDefaults.colors(selectedIconColor = NeonCyan, unselectedIconColor = TextMuted)
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
)
|
||||||
}
|
NavigationBarItem(
|
||||||
|
icon = { Icon(Icons.Default.TableChart, contentDescription = "BLM Table") },
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
label = { Text("BLM") },
|
||||||
|
selected = selectedTab == 2,
|
||||||
// Grid of Minor Telemetry Parameters
|
onClick = { selectedTab = 2 },
|
||||||
Column(
|
colors = NavigationBarItemDefaults.colors(selectedIconColor = NeonCyan, unselectedIconColor = TextMuted)
|
||||||
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(
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") },
|
||||||
}
|
label = { Text("Settings") },
|
||||||
|
selected = selectedTab == 3,
|
||||||
// Live Diagnostic Console log
|
onClick = { selectedTab = 3 },
|
||||||
DiagnosticConsole(rawLog = rawLog)
|
colors = NavigationBarItemDefaults.colors(selectedIconColor = NeonCyan, unselectedIconColor = TextMuted)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ConnectionCard(
|
|
||||||
connState: ConnectionState,
|
|
||||||
errorMsg: String,
|
|
||||||
isCelsius: Boolean,
|
|
||||||
onConnect: () -> Unit,
|
|
||||||
onDisconnect: () -> Unit,
|
|
||||||
onSimulate: () -> Unit,
|
|
||||||
onToggleUnit: () -> Unit
|
|
||||||
) {
|
|
||||||
Card(
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
colors = CardDefaults.cardColors(containerColor = CardBg),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
.border(1.dp, BorderColor, shape = RoundedCornerShape(12.dp))
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(16.dp)
|
|
||||||
) {
|
|
||||||
// Status bar
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Text(
|
|
||||||
text = "STATUS: ",
|
|
||||||
color = TextMuted,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
fontSize = 13.sp
|
|
||||||
)
|
|
||||||
val (statusText, statusColor) = when (connState) {
|
|
||||||
ConnectionState.DISCONNECTED -> "DISCONNECTED" to TextMuted
|
|
||||||
ConnectionState.CONNECTING -> "CONNECTING..." to NeonOrange
|
|
||||||
ConnectionState.CONNECTED -> "CONNECTED" to NeonGreen
|
|
||||||
ConnectionState.ERROR -> "ERROR" to NeonRed
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = statusText,
|
|
||||||
color = statusColor,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
fontSize = 13.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Temperature Toggle Button
|
|
||||||
Button(
|
|
||||||
onClick = onToggleUnit,
|
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = BorderColor),
|
|
||||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
|
|
||||||
shape = RoundedCornerShape(6.dp),
|
|
||||||
modifier = Modifier.height(28.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = if (isCelsius) "USE °F" else "USE °C",
|
|
||||||
color = TextWhite,
|
|
||||||
fontSize = 11.sp,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (errorMsg.isNotEmpty()) {
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Text(
|
|
||||||
text = errorMsg,
|
|
||||||
color = NeonRed,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(14.dp))
|
|
||||||
|
|
||||||
// Action Buttons
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
if (connState != ConnectionState.CONNECTED && connState != ConnectionState.CONNECTING) {
|
|
||||||
Button(
|
|
||||||
onClick = onConnect,
|
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = NeonOrange),
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
shape = RoundedCornerShape(8.dp)
|
|
||||||
) {
|
|
||||||
Text("CONNECT BT", fontWeight = FontWeight.Bold)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Button(
|
|
||||||
onClick = onDisconnect,
|
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = BorderColor),
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
shape = RoundedCornerShape(8.dp)
|
|
||||||
) {
|
|
||||||
Text("DISCONNECT", fontWeight = FontWeight.Bold, color = TextWhite)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = onSimulate,
|
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = BorderColor),
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
shape = RoundedCornerShape(8.dp)
|
|
||||||
) {
|
|
||||||
Text("SIMULATE", fontWeight = FontWeight.Bold, color = NeonCyan)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
) { paddingValues ->
|
||||||
}
|
when (selectedTab) {
|
||||||
|
0 -> DashboardScreen(
|
||||||
@Composable
|
connState = connState,
|
||||||
fun MetricCard(
|
frame = frame,
|
||||||
title: String,
|
isCelsius = isCelsius,
|
||||||
value: String,
|
onConnect = onConnectClick,
|
||||||
unit: String,
|
onDisconnect = { viewModel.disconnect() },
|
||||||
progress: Float,
|
onSimulate = { viewModel.startSimulation() },
|
||||||
progressColor: Color,
|
modifier = modifier.padding(paddingValues)
|
||||||
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
|
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
1 -> ChartsScreen(
|
||||||
Row(
|
latestFrameFlow = viewModel.latestFrame,
|
||||||
verticalAlignment = Alignment.Bottom
|
chartPreferencesRepository = app.chartPreferencesRepository,
|
||||||
) {
|
modifier = modifier.padding(paddingValues)
|
||||||
Text(
|
)
|
||||||
text = value,
|
2 -> {
|
||||||
color = TextWhite,
|
val blmViewModel: com.example.esp32aldldashboard.ui.blm.BLMTableViewModel = viewModel(
|
||||||
fontSize = 28.sp,
|
factory = BLMTableViewModelFactory(app.blmTableRepository)
|
||||||
fontWeight = FontWeight.Black
|
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
BLMTableScreen(
|
||||||
Text(
|
viewModel = blmViewModel,
|
||||||
text = unit,
|
modifier = modifier.padding(paddingValues)
|
||||||
color = TextMuted,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
modifier = Modifier.padding(bottom = 4.dp)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
3 -> SettingsScreen(
|
||||||
LinearProgressIndicator(
|
settingsRepository = app.settingsRepository,
|
||||||
progress = progress.coerceIn(0f, 1f),
|
modifier = modifier.padding(paddingValues)
|
||||||
color = progressColor,
|
|
||||||
trackColor = BorderColor,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(6.dp)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@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
|
package com.example.esp32aldldashboard.ui.main
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import com.example.esp32aldldashboard.bluetooth.BluetoothService
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.example.esp32aldldashboard.bluetooth.ConnectionState
|
import com.example.esp32aldldashboard.bluetooth.ConnectionState
|
||||||
import com.example.esp32aldldashboard.parser.ALDLFrame
|
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.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class MainScreenViewModel(context: Context) : ViewModel() {
|
class MainScreenViewModel(
|
||||||
private val bluetoothService = BluetoothService(context.applicationContext)
|
private val telemetryRepository: TelemetryRepository,
|
||||||
|
private val settingsRepository: SettingsRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
val connectionState: StateFlow<ConnectionState> = bluetoothService.connectionState
|
val connectionState: StateFlow<ConnectionState> = telemetryRepository.connectionState
|
||||||
val latestFrame: StateFlow<ALDLFrame?> = bluetoothService.latestFrame
|
val latestFrame: StateFlow<ALDLFrame?> = telemetryRepository.latestFrame
|
||||||
val rawHexLog: StateFlow<List<String>> = bluetoothService.rawHexLog
|
val rawHexLog: StateFlow<List<String>> = telemetryRepository.rawHexLog
|
||||||
val errorMessage: StateFlow<String> = bluetoothService.errorMessage
|
val errorMessage: StateFlow<String> = telemetryRepository.errorMessage
|
||||||
|
|
||||||
private val _isCelsius = MutableStateFlow(false) // Default to Fahrenheit for standard 80s GM telemetry
|
val framesReceived: StateFlow<Int> = telemetryRepository.framesReceived
|
||||||
val isCelsius: StateFlow<Boolean> = _isCelsius
|
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() {
|
fun toggleTemperatureUnit() {
|
||||||
_isCelsius.value = !_isCelsius.value
|
viewModelScope.launch {
|
||||||
|
val currentValue = isCelsius.value
|
||||||
|
settingsRepository.setIsCelsius(!currentValue)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun connect() {
|
fun connect() {
|
||||||
bluetoothService.connect()
|
telemetryRepository.connect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun disconnect() {
|
fun disconnect() {
|
||||||
bluetoothService.disconnect()
|
telemetryRepository.disconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun startSimulation() {
|
fun startSimulation() {
|
||||||
bluetoothService.startSimulation()
|
telemetryRepository.startSimulation()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
super.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()
|
0x00.toByte(), 0x00.toByte(), 0xC9.toByte(), 0x02.toByte(), 0x62.toByte()
|
||||||
)
|
)
|
||||||
|
|
||||||
val frame = ALDLParser.parseFrame(rawPayload)
|
val result = ALDLParser.parseFrame(rawPayload)
|
||||||
assertNotNull(frame)
|
assertTrue(result is com.example.esp32aldldashboard.parser.ALDLParseResult.Success)
|
||||||
frame!!
|
val frame = (result as com.example.esp32aldldashboard.parser.ALDLParseResult.Success).frame
|
||||||
|
|
||||||
// Assert values based on 24-INT10.ads specifications:
|
// Assert values based on 24-INT10.ads specifications:
|
||||||
assertEquals(95, frame.iacPosition) // u[3] (Byte 4)
|
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.isClosedLoop) // bit 7 of u[14] is 0
|
||||||
assertFalse(frame.isRich) // bit 6 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
|
package com.example.esp32aldldashboard.ui.main
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.ContextWrapper
|
|
||||||
import com.example.esp32aldldashboard.bluetooth.ConnectionState
|
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.assertEquals
|
||||||
import junit.framework.TestCase.assertFalse
|
import junit.framework.TestCase.assertFalse
|
||||||
import junit.framework.TestCase.assertTrue
|
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.runTest
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class MainScreenViewModelTest {
|
class MainScreenViewModelTest {
|
||||||
|
|
||||||
private class FakeContext : ContextWrapper(null) {
|
private val testDispatcher = UnconfinedTestDispatcher()
|
||||||
override fun getApplicationContext(): Context {
|
private val telemetryRepository = mockk<TelemetryRepository>(relaxed = true)
|
||||||
return this
|
private val settingsRepository = mockk<SettingsRepository>(relaxed = true)
|
||||||
}
|
private val isCelsiusFlow = MutableStateFlow(false)
|
||||||
override fun getSystemService(name: String): Any? {
|
|
||||||
return null
|
@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
|
@Test
|
||||||
fun testInitialStates() = runTest {
|
fun testInitialStates() = runTest {
|
||||||
val context = FakeContext()
|
val viewModel = MainScreenViewModel(telemetryRepository, settingsRepository)
|
||||||
val viewModel = MainScreenViewModel(context)
|
|
||||||
|
|
||||||
assertEquals(ConnectionState.DISCONNECTED, viewModel.connectionState.value)
|
assertEquals(ConnectionState.DISCONNECTED, viewModel.connectionState.value)
|
||||||
assertEquals(null, viewModel.latestFrame.value)
|
assertEquals(null, viewModel.latestFrame.value)
|
||||||
@@ -32,13 +65,21 @@ class MainScreenViewModelTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testToggleTemperatureUnit() = runTest {
|
fun testToggleTemperatureUnit() = runTest {
|
||||||
val context = FakeContext()
|
val viewModel = MainScreenViewModel(telemetryRepository, settingsRepository)
|
||||||
val viewModel = MainScreenViewModel(context)
|
|
||||||
|
// Start collecting to activate WhileSubscribed stateIn flow
|
||||||
|
backgroundScope.launch(testDispatcher) {
|
||||||
|
viewModel.isCelsius.collect {}
|
||||||
|
}
|
||||||
|
|
||||||
assertFalse(viewModel.isCelsius.value)
|
assertFalse(viewModel.isCelsius.value)
|
||||||
|
|
||||||
viewModel.toggleTemperatureUnit()
|
viewModel.toggleTemperatureUnit()
|
||||||
|
|
||||||
assertTrue(viewModel.isCelsius.value)
|
assertTrue(viewModel.isCelsius.value)
|
||||||
|
|
||||||
viewModel.toggleTemperatureUnit()
|
viewModel.toggleTemperatureUnit()
|
||||||
|
|
||||||
assertFalse(viewModel.isCelsius.value)
|
assertFalse(viewModel.isCelsius.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,4 +3,5 @@ plugins {
|
|||||||
alias(libs.plugins.android.application) apply false
|
alias(libs.plugins.android.application) apply false
|
||||||
alias(libs.plugins.compose.compiler) apply false
|
alias(libs.plugins.compose.compiler) apply false
|
||||||
alias(libs.plugins.kotlin.serialization) 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,
|
# resources declared in the library itself and none from the library's dependencies,
|
||||||
# thereby reducing the size of the R class for that library
|
# thereby reducing the size of the R class for that library
|
||||||
android.nonTransitiveRClass=true
|
android.nonTransitiveRClass=true
|
||||||
|
android.disallowKotlinSourceSets=false
|
||||||
|
|||||||
@@ -10,15 +10,21 @@ androidxTestRunner = "1.7.0"
|
|||||||
androidxTestEspresso = "3.7.0"
|
androidxTestEspresso = "3.7.0"
|
||||||
coroutines = "1.10.2"
|
coroutines = "1.10.2"
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
kotlin = "2.3.20"
|
kotlin = "2.1.0"
|
||||||
nav3Core = "1.0.1"
|
nav3Core = "1.0.1"
|
||||||
lifecycleViewmodelNav3 = "2.10.0"
|
lifecycleViewmodelNav3 = "2.10.0"
|
||||||
|
room = "2.6.1"
|
||||||
|
datastore = "1.1.1"
|
||||||
|
ksp = "2.1.0-1.0.29"
|
||||||
|
mockk = "1.13.10"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCore" }
|
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCore" }
|
||||||
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidxActivity" }
|
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-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "androidxComposeBom" }
|
||||||
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3"}
|
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 = { group = "androidx.compose.ui", name = "ui"}
|
||||||
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview"}
|
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"}
|
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-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" }
|
||||||
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", 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-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]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }
|
android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }
|
||||||
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
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