Compare commits

...

1 Commits

Author SHA1 Message Date
backup
fb87e79d41 Snapshot before poller/parser rework 2026-10-13 00:34:11 +00:00
79 changed files with 50584 additions and 49949 deletions

56
.gitignore vendored
View File

@ -1,28 +1,28 @@
# Flutter/Dart # Flutter/Dart
.dart_tool/ .dart_tool/
.flutter-plugins .flutter-plugins
.flutter-plugins-dependencies .flutter-plugins-dependencies
.packages .packages
.pub-cache/ .pub-cache/
.pub/ .pub/
build/ build/
# Android # Android
android/.gradle/ android/.gradle/
android/.kotlin/ android/.kotlin/
android/captures/ android/captures/
android/local.properties android/local.properties
android/key.properties android/key.properties
android/hs_err_pid*.log android/hs_err_pid*.log
android/replay_pid*.log android/replay_pid*.log
*.jks *.jks
*.log *.log
# IDE # IDE
.idea/ .idea/
.vscode/ .vscode/
*.iml *.iml
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db

View File

@ -1,30 +1,30 @@
# This file tracks properties of this Flutter project. # This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc. # Used by Flutter tool to assess capabilities and perform upgrades etc.
# #
# This file should be version controlled and should not be manually edited. # This file should be version controlled and should not be manually edited.
version: version:
revision: "db50e20168db8fee486b9abf32fc912de3bc5b6a" revision: "db50e20168db8fee486b9abf32fc912de3bc5b6a"
channel: "stable" channel: "stable"
project_type: app project_type: app
# Tracks metadata for the flutter migrate command # Tracks metadata for the flutter migrate command
migration: migration:
platforms: platforms:
- platform: root - platform: root
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
- platform: android - platform: android
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
# User provided section # User provided section
# List of Local paths (relative to this file) that should be # List of Local paths (relative to this file) that should be
# ignored by the migrate tool. # ignored by the migrate tool.
# #
# Files that are not part of the templates will be ignored by default. # Files that are not part of the templates will be ignored by default.
unmanaged_files: unmanaged_files:
- 'lib/main.dart' - 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj' - 'ios/Runner.xcodeproj/project.pbxproj'

BIN
Bluetooth.pdf Normal file

Binary file not shown.

View File

@ -1,16 +1,16 @@
# HV BT Dashboard # HV BT Dashboard
HV BT Dashboard is a Flutter Android app for live Honda ECU monitoring over Bluetooth. It connects to a paired Bluetooth ECU adapter, polls S300 or KPro data frames, validates and parses those frames, then displays live sensors, graphs, diagnostic trouble codes, and local datalog recordings. HV BT Dashboard is a Flutter Android app for live Honda ECU monitoring over Bluetooth. It connects to a paired Bluetooth ECU adapter, polls S300 or KPro data frames, validates and parses those frames, then displays live sensors, graphs, diagnostic trouble codes, and local datalog recordings.
Full layman-friendly documentation is here: Full layman-friendly documentation is here:
- [docs/FULL_PROJECT_DOCUMENTATION.md](docs/FULL_PROJECT_DOCUMENTATION.md) - [docs/FULL_PROJECT_DOCUMENTATION.md](docs/FULL_PROJECT_DOCUMENTATION.md)
Common commands: Common commands:
```powershell ```powershell
flutter run flutter run
flutter run --release flutter run --release
flutter build apk --release flutter build apk --release
flutter test flutter test
``` ```

BIN
Requrements.pdf Normal file

Binary file not shown.

View File

@ -1,28 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to # This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints. # check for errors, warnings, and lints.
# #
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled # The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be # IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`. # invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps, # The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices. # packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml include: package:flutter_lints/flutter.yaml
linter: linter:
# The lint rules applied to this project can be customized in the # The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml` # section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints # included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints. # and their documentation is published at https://dart.dev/lints.
# #
# Instead of disabling a lint rule for the entire project in the # Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code # section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and # or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file # `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint. # producing the lint.
rules: rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule # avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at # Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options # https://dart.dev/guides/language/analysis-options

28
android/.gitignore vendored
View File

@ -1,14 +1,14 @@
gradle-wrapper.jar gradle-wrapper.jar
/.gradle /.gradle
/captures/ /captures/
/gradlew /gradlew
/gradlew.bat /gradlew.bat
/local.properties /local.properties
GeneratedPluginRegistrant.java GeneratedPluginRegistrant.java
.cxx/ .cxx/
# Remember to never publicly share your keystore. # Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore # See https://flutter.dev/to/reference-keystore
key.properties key.properties
**/*.keystore **/*.keystore
**/*.jks **/*.jks

View File

@ -1,198 +1,198 @@
kotlin version: 2.2.20 kotlin version: 2.2.20
error message: Daemon compilation failed: null error message: Daemon compilation failed: null
java.lang.Exception java.lang.Exception
at org.jetbrains.kotlin.daemon.common.CompileService$CallResult$Error.get(CompileService.kt:69) at org.jetbrains.kotlin.daemon.common.CompileService$CallResult$Error.get(CompileService.kt:69)
at org.jetbrains.kotlin.daemon.common.CompileService$CallResult$Error.get(CompileService.kt:65) at org.jetbrains.kotlin.daemon.common.CompileService$CallResult$Error.get(CompileService.kt:65)
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemon(GradleKotlinCompilerWork.kt:240) at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemon(GradleKotlinCompilerWork.kt:240)
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemonOrFallbackImpl(GradleKotlinCompilerWork.kt:159) at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemonOrFallbackImpl(GradleKotlinCompilerWork.kt:159)
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.run(GradleKotlinCompilerWork.kt:111) at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.run(GradleKotlinCompilerWork.kt:111)
at org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction.execute(GradleCompilerRunnerWithWorkers.kt:74) at org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction.execute(GradleCompilerRunnerWithWorkers.kt:74)
at org.gradle.workers.internal.DefaultWorkerServer.execute(DefaultWorkerServer.java:63) at org.gradle.workers.internal.DefaultWorkerServer.execute(DefaultWorkerServer.java:63)
at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:66) at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:66)
at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:62) at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:62)
at org.gradle.internal.classloader.ClassLoaderUtils.executeInClassloader(ClassLoaderUtils.java:100) at org.gradle.internal.classloader.ClassLoaderUtils.executeInClassloader(ClassLoaderUtils.java:100)
at org.gradle.workers.internal.NoIsolationWorkerFactory$1.lambda$execute$0(NoIsolationWorkerFactory.java:62) at org.gradle.workers.internal.NoIsolationWorkerFactory$1.lambda$execute$0(NoIsolationWorkerFactory.java:62)
at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:44) at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:44)
at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:41) at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:41)
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:210) at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:210)
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:205) at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:205)
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:67) at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:67)
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:60) at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:60)
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:167) at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:167)
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:60) at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:60)
at org.gradle.internal.operations.DefaultBuildOperationRunner.call(DefaultBuildOperationRunner.java:54) at org.gradle.internal.operations.DefaultBuildOperationRunner.call(DefaultBuildOperationRunner.java:54)
at org.gradle.workers.internal.AbstractWorker.executeWrappedInBuildOperation(AbstractWorker.java:41) at org.gradle.workers.internal.AbstractWorker.executeWrappedInBuildOperation(AbstractWorker.java:41)
at org.gradle.workers.internal.NoIsolationWorkerFactory$1.execute(NoIsolationWorkerFactory.java:59) at org.gradle.workers.internal.NoIsolationWorkerFactory$1.execute(NoIsolationWorkerFactory.java:59)
at org.gradle.workers.internal.DefaultWorkerExecutor.lambda$submitWork$0(DefaultWorkerExecutor.java:174) at org.gradle.workers.internal.DefaultWorkerExecutor.lambda$submitWork$0(DefaultWorkerExecutor.java:174)
at java.base/java.util.concurrent.FutureTask.run(Unknown Source) at java.base/java.util.concurrent.FutureTask.run(Unknown Source)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runExecution(DefaultConditionalExecutionQueue.java:194) at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runExecution(DefaultConditionalExecutionQueue.java:194)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.access$700(DefaultConditionalExecutionQueue.java:127) at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.access$700(DefaultConditionalExecutionQueue.java:127)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner$1.run(DefaultConditionalExecutionQueue.java:169) at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner$1.run(DefaultConditionalExecutionQueue.java:169)
at org.gradle.internal.Factories$1.create(Factories.java:31) at org.gradle.internal.Factories$1.create(Factories.java:31)
at org.gradle.internal.work.DefaultWorkerLeaseService.withLocks(DefaultWorkerLeaseService.java:263) at org.gradle.internal.work.DefaultWorkerLeaseService.withLocks(DefaultWorkerLeaseService.java:263)
at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:127) at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:127)
at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:132) at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:132)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runBatch(DefaultConditionalExecutionQueue.java:164) at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runBatch(DefaultConditionalExecutionQueue.java:164)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.run(DefaultConditionalExecutionQueue.java:133) at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.run(DefaultConditionalExecutionQueue.java:133)
at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Unknown Source) at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Unknown Source)
at java.base/java.util.concurrent.FutureTask.run(Unknown Source) at java.base/java.util.concurrent.FutureTask.run(Unknown Source)
at org.gradle.internal.concurrent.ExecutorPolicy$CatchAndRecordFailures.onExecute(ExecutorPolicy.java:64) at org.gradle.internal.concurrent.ExecutorPolicy$CatchAndRecordFailures.onExecute(ExecutorPolicy.java:64)
at org.gradle.internal.concurrent.AbstractManagedExecutor$1.run(AbstractManagedExecutor.java:48) at org.gradle.internal.concurrent.AbstractManagedExecutor$1.run(AbstractManagedExecutor.java:48)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source) at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source) at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source)
at java.base/java.lang.Thread.run(Unknown Source) at java.base/java.lang.Thread.run(Unknown Source)
Caused by: java.lang.AssertionError: java.lang.Exception: Could not close incremental caches in D:\2026\BT-CAR-App\hvbt_dash\build\shared_preferences_android\kotlin\compileReleaseKotlin\cacheable\caches-jvm\jvm\kotlin: class-fq-name-to-source.tab, source-to-classes.tab, internal-name-to-source.tab Caused by: java.lang.AssertionError: java.lang.Exception: Could not close incremental caches in D:\2026\BT-CAR-App\hvbt_dash\build\shared_preferences_android\kotlin\compileReleaseKotlin\cacheable\caches-jvm\jvm\kotlin: class-fq-name-to-source.tab, source-to-classes.tab, internal-name-to-source.tab
at org.jetbrains.kotlin.com.google.common.io.Closer.close(Closer.java:218) at org.jetbrains.kotlin.com.google.common.io.Closer.close(Closer.java:218)
at org.jetbrains.kotlin.incremental.IncrementalCachesManager.close(IncrementalCachesManager.kt:55) at org.jetbrains.kotlin.incremental.IncrementalCachesManager.close(IncrementalCachesManager.kt:55)
at kotlin.io.CloseableKt.closeFinally(Closeable.kt:46) at kotlin.io.CloseableKt.closeFinally(Closeable.kt:46)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileNonIncrementally(IncrementalCompilerRunner.kt:293) at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileNonIncrementally(IncrementalCompilerRunner.kt:293)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compile(IncrementalCompilerRunner.kt:128) at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compile(IncrementalCompilerRunner.kt:128)
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.execIncrementalCompiler(CompileServiceImpl.kt:684) at org.jetbrains.kotlin.daemon.CompileServiceImplBase.execIncrementalCompiler(CompileServiceImpl.kt:684)
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.access$execIncrementalCompiler(CompileServiceImpl.kt:94) at org.jetbrains.kotlin.daemon.CompileServiceImplBase.access$execIncrementalCompiler(CompileServiceImpl.kt:94)
at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1810) at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1810)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(Unknown Source) at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(Unknown Source)
at java.base/java.lang.reflect.Method.invoke(Unknown Source) at java.base/java.lang.reflect.Method.invoke(Unknown Source)
at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(Unknown Source) at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(Unknown Source)
at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source) at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source) at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
at java.base/java.security.AccessController.doPrivileged(Unknown Source) at java.base/java.security.AccessController.doPrivileged(Unknown Source)
at java.rmi/sun.rmi.transport.Transport.serviceCall(Unknown Source) at java.rmi/sun.rmi.transport.Transport.serviceCall(Unknown Source)
at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(Unknown Source) at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(Unknown Source)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(Unknown Source) at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(Unknown Source)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(Unknown Source) at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(Unknown Source)
at java.base/java.security.AccessController.doPrivileged(Unknown Source) at java.base/java.security.AccessController.doPrivileged(Unknown Source)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(Unknown Source) at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(Unknown Source)
... 3 more ... 3 more
Caused by: java.lang.Exception: Could not close incremental caches in D:\2026\BT-CAR-App\hvbt_dash\build\shared_preferences_android\kotlin\compileReleaseKotlin\cacheable\caches-jvm\jvm\kotlin: class-fq-name-to-source.tab, source-to-classes.tab, internal-name-to-source.tab Caused by: java.lang.Exception: Could not close incremental caches in D:\2026\BT-CAR-App\hvbt_dash\build\shared_preferences_android\kotlin\compileReleaseKotlin\cacheable\caches-jvm\jvm\kotlin: class-fq-name-to-source.tab, source-to-classes.tab, internal-name-to-source.tab
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:95) at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:95)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.close(BasicMapsOwner.kt:53) at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.close(BasicMapsOwner.kt:53)
at org.jetbrains.kotlin.com.google.common.io.Closer.close(Closer.java:205) at org.jetbrains.kotlin.com.google.common.io.Closer.close(Closer.java:205)
... 22 more ... 22 more
Suppressed: java.lang.IllegalArgumentException: this and base files have different roots: C:\Users\mohan\AppData\Local\Pub\Cache\hosted\pub.dev\shared_preferences_android-2.4.23\android\src\main\kotlin\io\flutter\plugins\sharedpreferences\MessagesAsync.g.kt and D:\2026\BT-CAR-App\hvbt_dash\android. Suppressed: java.lang.IllegalArgumentException: this and base files have different roots: C:\Users\mohan\AppData\Local\Pub\Cache\hosted\pub.dev\shared_preferences_android-2.4.23\android\src\main\kotlin\io\flutter\plugins\sharedpreferences\MessagesAsync.g.kt and D:\2026\BT-CAR-App\hvbt_dash\android.
at kotlin.io.FilesKt__UtilsKt.toRelativeString(Utils.kt:117) at kotlin.io.FilesKt__UtilsKt.toRelativeString(Utils.kt:117)
at kotlin.io.FilesKt__UtilsKt.relativeTo(Utils.kt:128) at kotlin.io.FilesKt__UtilsKt.relativeTo(Utils.kt:128)
at org.jetbrains.kotlin.incremental.storage.RelocatableFileToPathConverter.toPath(RelocatableFileToPathConverter.kt:24) at org.jetbrains.kotlin.incremental.storage.RelocatableFileToPathConverter.toPath(RelocatableFileToPathConverter.kt:24)
at org.jetbrains.kotlin.incremental.storage.FileDescriptor.save(FileToPathConverter.kt:33) at org.jetbrains.kotlin.incremental.storage.FileDescriptor.save(FileToPathConverter.kt:33)
at org.jetbrains.kotlin.incremental.storage.FileDescriptor.save(FileToPathConverter.kt:30) at org.jetbrains.kotlin.incremental.storage.FileDescriptor.save(FileToPathConverter.kt:30)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.doPut(PersistentMapImpl.java:447) at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.doPut(PersistentMapImpl.java:447)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.put(PersistentMapImpl.java:426) at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.put(PersistentMapImpl.java:426)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.put(PersistentHashMap.java:106) at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.put(PersistentHashMap.java:106)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80) at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80)
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.applyChanges(InMemoryStorage.kt:108) at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.applyChanges(InMemoryStorage.kt:108)
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.close(InMemoryStorage.kt:136) at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.close(InMemoryStorage.kt:136)
at org.jetbrains.kotlin.incremental.storage.PersistentStorageWrapper.close(PersistentStorage.kt:124) at org.jetbrains.kotlin.incremental.storage.PersistentStorageWrapper.close(PersistentStorage.kt:124)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53) at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53) at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:87) at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:87)
... 24 more ... 24 more
Suppressed: java.lang.IllegalArgumentException: this and base files have different roots: C:\Users\mohan\AppData\Local\Pub\Cache\hosted\pub.dev\shared_preferences_android-2.4.23\android\src\main\kotlin\io\flutter\plugins\sharedpreferences\MessagesAsync.g.kt and D:\2026\BT-CAR-App\hvbt_dash\android. Suppressed: java.lang.IllegalArgumentException: this and base files have different roots: C:\Users\mohan\AppData\Local\Pub\Cache\hosted\pub.dev\shared_preferences_android-2.4.23\android\src\main\kotlin\io\flutter\plugins\sharedpreferences\MessagesAsync.g.kt and D:\2026\BT-CAR-App\hvbt_dash\android.
at kotlin.io.FilesKt__UtilsKt.toRelativeString(Utils.kt:117) at kotlin.io.FilesKt__UtilsKt.toRelativeString(Utils.kt:117)
at kotlin.io.FilesKt__UtilsKt.relativeTo(Utils.kt:128) at kotlin.io.FilesKt__UtilsKt.relativeTo(Utils.kt:128)
at org.jetbrains.kotlin.incremental.storage.RelocatableFileToPathConverter.toPath(RelocatableFileToPathConverter.kt:24) at org.jetbrains.kotlin.incremental.storage.RelocatableFileToPathConverter.toPath(RelocatableFileToPathConverter.kt:24)
at org.jetbrains.kotlin.incremental.storage.FileDescriptor.getHashCode(FileToPathConverter.kt:50) at org.jetbrains.kotlin.incremental.storage.FileDescriptor.getHashCode(FileToPathConverter.kt:50)
at org.jetbrains.kotlin.incremental.storage.FileDescriptor.getHashCode(FileToPathConverter.kt:30) at org.jetbrains.kotlin.incremental.storage.FileDescriptor.getHashCode(FileToPathConverter.kt:30)
at org.jetbrains.kotlin.com.intellij.util.containers.LinkedCustomHashMap.hashKey(LinkedCustomHashMap.java:109) at org.jetbrains.kotlin.com.intellij.util.containers.LinkedCustomHashMap.hashKey(LinkedCustomHashMap.java:109)
at org.jetbrains.kotlin.com.intellij.util.containers.LinkedCustomHashMap.remove(LinkedCustomHashMap.java:153) at org.jetbrains.kotlin.com.intellij.util.containers.LinkedCustomHashMap.remove(LinkedCustomHashMap.java:153)
at org.jetbrains.kotlin.com.intellij.util.containers.SLRUMap.remove(SLRUMap.java:89) at org.jetbrains.kotlin.com.intellij.util.containers.SLRUMap.remove(SLRUMap.java:89)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.flushAppendCache(PersistentMapImpl.java:1007) at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.flushAppendCache(PersistentMapImpl.java:1007)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.doPut(PersistentMapImpl.java:455) at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.doPut(PersistentMapImpl.java:455)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.put(PersistentMapImpl.java:426) at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.put(PersistentMapImpl.java:426)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.put(PersistentHashMap.java:106) at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.put(PersistentHashMap.java:106)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80) at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80)
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.applyChanges(InMemoryStorage.kt:108) at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.applyChanges(InMemoryStorage.kt:108)
at org.jetbrains.kotlin.incremental.storage.AppendableInMemoryStorage.applyChanges(InMemoryStorage.kt:179) at org.jetbrains.kotlin.incremental.storage.AppendableInMemoryStorage.applyChanges(InMemoryStorage.kt:179)
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.close(InMemoryStorage.kt:136) at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.close(InMemoryStorage.kt:136)
at org.jetbrains.kotlin.incremental.storage.AppendableSetBasicMap.close(BasicMap.kt:157) at org.jetbrains.kotlin.incremental.storage.AppendableSetBasicMap.close(BasicMap.kt:157)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53) at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53) at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:87) at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:87)
... 24 more ... 24 more
Suppressed: java.lang.IllegalArgumentException: this and base files have different roots: C:\Users\mohan\AppData\Local\Pub\Cache\hosted\pub.dev\shared_preferences_android-2.4.23\android\src\main\kotlin\io\flutter\plugins\sharedpreferences\MessagesAsync.g.kt and D:\2026\BT-CAR-App\hvbt_dash\android. Suppressed: java.lang.IllegalArgumentException: this and base files have different roots: C:\Users\mohan\AppData\Local\Pub\Cache\hosted\pub.dev\shared_preferences_android-2.4.23\android\src\main\kotlin\io\flutter\plugins\sharedpreferences\MessagesAsync.g.kt and D:\2026\BT-CAR-App\hvbt_dash\android.
at kotlin.io.FilesKt__UtilsKt.toRelativeString(Utils.kt:117) at kotlin.io.FilesKt__UtilsKt.toRelativeString(Utils.kt:117)
at kotlin.io.FilesKt__UtilsKt.relativeTo(Utils.kt:128) at kotlin.io.FilesKt__UtilsKt.relativeTo(Utils.kt:128)
at org.jetbrains.kotlin.incremental.storage.RelocatableFileToPathConverter.toPath(RelocatableFileToPathConverter.kt:24) at org.jetbrains.kotlin.incremental.storage.RelocatableFileToPathConverter.toPath(RelocatableFileToPathConverter.kt:24)
at org.jetbrains.kotlin.incremental.storage.FileDescriptor.save(FileToPathConverter.kt:33) at org.jetbrains.kotlin.incremental.storage.FileDescriptor.save(FileToPathConverter.kt:33)
at org.jetbrains.kotlin.incremental.storage.FileDescriptor.save(FileToPathConverter.kt:30) at org.jetbrains.kotlin.incremental.storage.FileDescriptor.save(FileToPathConverter.kt:30)
at org.jetbrains.kotlin.incremental.storage.AppendableCollectionExternalizer.save(LazyStorage.kt:151) at org.jetbrains.kotlin.incremental.storage.AppendableCollectionExternalizer.save(LazyStorage.kt:151)
at org.jetbrains.kotlin.incremental.storage.AppendableCollectionExternalizer.save(LazyStorage.kt:142) at org.jetbrains.kotlin.incremental.storage.AppendableCollectionExternalizer.save(LazyStorage.kt:142)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.doPut(PersistentMapImpl.java:447) at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.doPut(PersistentMapImpl.java:447)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.put(PersistentMapImpl.java:426) at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.put(PersistentMapImpl.java:426)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.put(PersistentHashMap.java:106) at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.put(PersistentHashMap.java:106)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80) at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80)
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.applyChanges(InMemoryStorage.kt:108) at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.applyChanges(InMemoryStorage.kt:108)
at org.jetbrains.kotlin.incremental.storage.AppendableInMemoryStorage.applyChanges(InMemoryStorage.kt:179) at org.jetbrains.kotlin.incremental.storage.AppendableInMemoryStorage.applyChanges(InMemoryStorage.kt:179)
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.close(InMemoryStorage.kt:136) at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.close(InMemoryStorage.kt:136)
at org.jetbrains.kotlin.incremental.storage.PersistentStorageWrapper.close(PersistentStorage.kt:124) at org.jetbrains.kotlin.incremental.storage.PersistentStorageWrapper.close(PersistentStorage.kt:124)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53) at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53) at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:87) at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:87)
... 24 more ... 24 more
Suppressed: java.lang.Exception: Could not close incremental caches in D:\2026\BT-CAR-App\hvbt_dash\build\shared_preferences_android\kotlin\compileReleaseKotlin\cacheable\caches-jvm\lookups: id-to-file.tab, file-to-id.tab Suppressed: java.lang.Exception: Could not close incremental caches in D:\2026\BT-CAR-App\hvbt_dash\build\shared_preferences_android\kotlin\compileReleaseKotlin\cacheable\caches-jvm\lookups: id-to-file.tab, file-to-id.tab
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:95) at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:95)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.close(BasicMapsOwner.kt:53) at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.close(BasicMapsOwner.kt:53)
at org.jetbrains.kotlin.incremental.LookupStorage.close(LookupStorage.kt:155) at org.jetbrains.kotlin.incremental.LookupStorage.close(LookupStorage.kt:155)
... 23 more ... 23 more
Suppressed: java.lang.IllegalArgumentException: this and base files have different roots: C:\Users\mohan\AppData\Local\Pub\Cache\hosted\pub.dev\shared_preferences_android-2.4.23\android\src\main\kotlin\io\flutter\plugins\sharedpreferences\MessagesAsync.g.kt and D:\2026\BT-CAR-App\hvbt_dash\android. Suppressed: java.lang.IllegalArgumentException: this and base files have different roots: C:\Users\mohan\AppData\Local\Pub\Cache\hosted\pub.dev\shared_preferences_android-2.4.23\android\src\main\kotlin\io\flutter\plugins\sharedpreferences\MessagesAsync.g.kt and D:\2026\BT-CAR-App\hvbt_dash\android.
at kotlin.io.FilesKt__UtilsKt.toRelativeString(Utils.kt:117) at kotlin.io.FilesKt__UtilsKt.toRelativeString(Utils.kt:117)
at kotlin.io.FilesKt__UtilsKt.relativeTo(Utils.kt:128) at kotlin.io.FilesKt__UtilsKt.relativeTo(Utils.kt:128)
at org.jetbrains.kotlin.incremental.storage.RelocatableFileToPathConverter.toPath(RelocatableFileToPathConverter.kt:24) at org.jetbrains.kotlin.incremental.storage.RelocatableFileToPathConverter.toPath(RelocatableFileToPathConverter.kt:24)
at org.jetbrains.kotlin.incremental.storage.LegacyFileExternalizer.save(IdToFileMap.kt:51) at org.jetbrains.kotlin.incremental.storage.LegacyFileExternalizer.save(IdToFileMap.kt:51)
at org.jetbrains.kotlin.incremental.storage.LegacyFileExternalizer.save(IdToFileMap.kt:48) at org.jetbrains.kotlin.incremental.storage.LegacyFileExternalizer.save(IdToFileMap.kt:48)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.doPut(PersistentMapImpl.java:447) at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.doPut(PersistentMapImpl.java:447)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.put(PersistentMapImpl.java:426) at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.put(PersistentMapImpl.java:426)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.put(PersistentHashMap.java:106) at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.put(PersistentHashMap.java:106)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80) at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80)
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.applyChanges(InMemoryStorage.kt:108) at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.applyChanges(InMemoryStorage.kt:108)
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.close(InMemoryStorage.kt:136) at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.close(InMemoryStorage.kt:136)
at org.jetbrains.kotlin.incremental.storage.PersistentStorageWrapper.close(PersistentStorage.kt:124) at org.jetbrains.kotlin.incremental.storage.PersistentStorageWrapper.close(PersistentStorage.kt:124)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53) at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53) at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:87) at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:87)
... 25 more ... 25 more
Suppressed: java.lang.IllegalArgumentException: this and base files have different roots: C:\Users\mohan\AppData\Local\Pub\Cache\hosted\pub.dev\shared_preferences_android-2.4.23\android\src\main\kotlin\io\flutter\plugins\sharedpreferences\MessagesAsync.g.kt and D:\2026\BT-CAR-App\hvbt_dash\android. Suppressed: java.lang.IllegalArgumentException: this and base files have different roots: C:\Users\mohan\AppData\Local\Pub\Cache\hosted\pub.dev\shared_preferences_android-2.4.23\android\src\main\kotlin\io\flutter\plugins\sharedpreferences\MessagesAsync.g.kt and D:\2026\BT-CAR-App\hvbt_dash\android.
at kotlin.io.FilesKt__UtilsKt.toRelativeString(Utils.kt:117) at kotlin.io.FilesKt__UtilsKt.toRelativeString(Utils.kt:117)
at kotlin.io.FilesKt__UtilsKt.relativeTo(Utils.kt:128) at kotlin.io.FilesKt__UtilsKt.relativeTo(Utils.kt:128)
at org.jetbrains.kotlin.incremental.storage.RelocatableFileToPathConverter.toPath(RelocatableFileToPathConverter.kt:24) at org.jetbrains.kotlin.incremental.storage.RelocatableFileToPathConverter.toPath(RelocatableFileToPathConverter.kt:24)
at org.jetbrains.kotlin.incremental.storage.FileDescriptor.getHashCode(FileToPathConverter.kt:50) at org.jetbrains.kotlin.incremental.storage.FileDescriptor.getHashCode(FileToPathConverter.kt:50)
at org.jetbrains.kotlin.incremental.storage.FileDescriptor.getHashCode(FileToPathConverter.kt:30) at org.jetbrains.kotlin.incremental.storage.FileDescriptor.getHashCode(FileToPathConverter.kt:30)
at org.jetbrains.kotlin.com.intellij.util.containers.LinkedCustomHashMap.hashKey(LinkedCustomHashMap.java:109) at org.jetbrains.kotlin.com.intellij.util.containers.LinkedCustomHashMap.hashKey(LinkedCustomHashMap.java:109)
at org.jetbrains.kotlin.com.intellij.util.containers.LinkedCustomHashMap.remove(LinkedCustomHashMap.java:153) at org.jetbrains.kotlin.com.intellij.util.containers.LinkedCustomHashMap.remove(LinkedCustomHashMap.java:153)
at org.jetbrains.kotlin.com.intellij.util.containers.SLRUMap.remove(SLRUMap.java:89) at org.jetbrains.kotlin.com.intellij.util.containers.SLRUMap.remove(SLRUMap.java:89)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.flushAppendCache(PersistentMapImpl.java:1007) at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.flushAppendCache(PersistentMapImpl.java:1007)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.doPut(PersistentMapImpl.java:455) at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.doPut(PersistentMapImpl.java:455)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.put(PersistentMapImpl.java:426) at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.put(PersistentMapImpl.java:426)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.put(PersistentHashMap.java:106) at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.put(PersistentHashMap.java:106)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80) at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80)
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.applyChanges(InMemoryStorage.kt:108) at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.applyChanges(InMemoryStorage.kt:108)
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.close(InMemoryStorage.kt:136) at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.close(InMemoryStorage.kt:136)
at org.jetbrains.kotlin.incremental.storage.PersistentStorageWrapper.close(PersistentStorage.kt:124) at org.jetbrains.kotlin.incremental.storage.PersistentStorageWrapper.close(PersistentStorage.kt:124)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53) at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53) at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:87) at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:87)
... 25 more ... 25 more
Suppressed: java.lang.Exception: Could not close incremental caches in D:\2026\BT-CAR-App\hvbt_dash\build\shared_preferences_android\kotlin\compileReleaseKotlin\cacheable\caches-jvm\inputs: source-to-output.tab Suppressed: java.lang.Exception: Could not close incremental caches in D:\2026\BT-CAR-App\hvbt_dash\build\shared_preferences_android\kotlin\compileReleaseKotlin\cacheable\caches-jvm\inputs: source-to-output.tab
... 25 more ... 25 more
Suppressed: java.lang.IllegalArgumentException: this and base files have different roots: C:\Users\mohan\AppData\Local\Pub\Cache\hosted\pub.dev\shared_preferences_android-2.4.23\android\src\main\kotlin\io\flutter\plugins\sharedpreferences\MessagesAsync.g.kt and D:\2026\BT-CAR-App\hvbt_dash\android. Suppressed: java.lang.IllegalArgumentException: this and base files have different roots: C:\Users\mohan\AppData\Local\Pub\Cache\hosted\pub.dev\shared_preferences_android-2.4.23\android\src\main\kotlin\io\flutter\plugins\sharedpreferences\MessagesAsync.g.kt and D:\2026\BT-CAR-App\hvbt_dash\android.
at kotlin.io.FilesKt__UtilsKt.toRelativeString(Utils.kt:117) at kotlin.io.FilesKt__UtilsKt.toRelativeString(Utils.kt:117)
at kotlin.io.FilesKt__UtilsKt.relativeTo(Utils.kt:128) at kotlin.io.FilesKt__UtilsKt.relativeTo(Utils.kt:128)
at org.jetbrains.kotlin.incremental.storage.RelocatableFileToPathConverter.toPath(RelocatableFileToPathConverter.kt:24) at org.jetbrains.kotlin.incremental.storage.RelocatableFileToPathConverter.toPath(RelocatableFileToPathConverter.kt:24)
at org.jetbrains.kotlin.incremental.storage.FileDescriptor.getHashCode(FileToPathConverter.kt:50) at org.jetbrains.kotlin.incremental.storage.FileDescriptor.getHashCode(FileToPathConverter.kt:50)
at org.jetbrains.kotlin.incremental.storage.FileDescriptor.getHashCode(FileToPathConverter.kt:30) at org.jetbrains.kotlin.incremental.storage.FileDescriptor.getHashCode(FileToPathConverter.kt:30)
at org.jetbrains.kotlin.com.intellij.util.containers.LinkedCustomHashMap.hashKey(LinkedCustomHashMap.java:109) at org.jetbrains.kotlin.com.intellij.util.containers.LinkedCustomHashMap.hashKey(LinkedCustomHashMap.java:109)
at org.jetbrains.kotlin.com.intellij.util.containers.LinkedCustomHashMap.remove(LinkedCustomHashMap.java:153) at org.jetbrains.kotlin.com.intellij.util.containers.LinkedCustomHashMap.remove(LinkedCustomHashMap.java:153)
at org.jetbrains.kotlin.com.intellij.util.containers.SLRUMap.remove(SLRUMap.java:89) at org.jetbrains.kotlin.com.intellij.util.containers.SLRUMap.remove(SLRUMap.java:89)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.flushAppendCache(PersistentMapImpl.java:1007) at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.flushAppendCache(PersistentMapImpl.java:1007)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.doPut(PersistentMapImpl.java:455) at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.doPut(PersistentMapImpl.java:455)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.put(PersistentMapImpl.java:426) at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.put(PersistentMapImpl.java:426)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.put(PersistentHashMap.java:106) at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.put(PersistentHashMap.java:106)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80) at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80)
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.applyChanges(InMemoryStorage.kt:108) at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.applyChanges(InMemoryStorage.kt:108)
at org.jetbrains.kotlin.incremental.storage.AppendableInMemoryStorage.applyChanges(InMemoryStorage.kt:179) at org.jetbrains.kotlin.incremental.storage.AppendableInMemoryStorage.applyChanges(InMemoryStorage.kt:179)
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.close(InMemoryStorage.kt:136) at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.close(InMemoryStorage.kt:136)
at org.jetbrains.kotlin.incremental.storage.AppendableSetBasicMap.close(BasicMap.kt:157) at org.jetbrains.kotlin.incremental.storage.AppendableSetBasicMap.close(BasicMap.kt:157)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53) at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53) at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:87) at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:87)
... 24 more ... 24 more

View File

@ -1,44 +1,44 @@
plugins { plugins {
id("com.android.application") id("com.android.application")
id("kotlin-android") id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin") id("dev.flutter.flutter-gradle-plugin")
} }
android { android {
namespace = "com.metatroncube.hvbt_dash" namespace = "com.metatroncube.hvbt_dash"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion ndkVersion = flutter.ndkVersion
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
} }
kotlinOptions { kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString() jvmTarget = JavaVersion.VERSION_17.toString()
} }
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.metatroncube.hvbt_dash" applicationId = "com.metatroncube.hvbt_dash"
// You can update the following values to match your application needs. // You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config. // For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = 26 minSdk = 26
targetSdk = flutter.targetSdkVersion targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode versionCode = flutter.versionCode
versionName = flutter.versionName versionName = flutter.versionName
} }
buildTypes { buildTypes {
release { release {
// TODO: Add your own signing config for the release build. // TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works. // Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug") signingConfig = signingConfigs.getByName("debug")
} }
} }
} }
flutter { flutter {
source = "../.." source = "../.."
} }

View File

@ -1,7 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically, <!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc. to allow setting breakpoints, to provide hot reload, etc.
--> -->
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
</manifest> </manifest>

View File

@ -1,51 +1,51 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.BLUETOOTH"/> <uses-permission android:name="android.permission.BLUETOOTH"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"/> <uses-permission android:name="android.permission.BLUETOOTH_SCAN"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<application <application
android:label="hvbt_dash" android:label="hvbt_dash"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTop" android:launchMode="singleTop"
android:taskAffinity="" android:taskAffinity=""
android:theme="@style/LaunchTheme" android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as <!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. --> to determine the Window background behind the Flutter UI. -->
<meta-data <meta-data
android:name="io.flutter.embedding.android.NormalTheme" android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" android:resource="@style/NormalTheme"
/> />
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
</activity> </activity>
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2" />
</application> </application>
<!-- Required to query activities that can process text, see: <!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT. https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. --> In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries> <queries>
<intent> <intent>
<action android:name="android.intent.action.PROCESS_TEXT"/> <action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/> <data android:mimeType="text/plain"/>
</intent> </intent>
</queries> </queries>
</manifest> </manifest>

View File

@ -1,5 +1,5 @@
package com.metatroncube.hvbt_dash package com.metatroncube.hvbt_dash
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity() class MainActivity : FlutterActivity()

View File

@ -1,12 +1,12 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen --> <!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> <layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" /> <item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here --> <!-- You can insert your own image assets here -->
<!-- <item> <!-- <item>
<bitmap <bitmap
android:gravity="center" android:gravity="center"
android:src="@mipmap/launch_image" /> android:src="@mipmap/launch_image" />
</item> --> </item> -->
</layer-list> </layer-list>

View File

@ -1,12 +1,12 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen --> <!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> <layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" /> <item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here --> <!-- You can insert your own image assets here -->
<!-- <item> <!-- <item>
<bitmap <bitmap
android:gravity="center" android:gravity="center"
android:src="@mipmap/launch_image" /> android:src="@mipmap/launch_image" />
</item> --> </item> -->
</layer-list> </layer-list>

View File

@ -1,18 +1,18 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on --> <!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar"> <style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when <!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame --> the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item> <item name="android:windowBackground">@drawable/launch_background</item>
</style> </style>
<!-- Theme applied to the Android Window as soon as the process has started. <!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its Flutter UI initializes, as well as behind your Flutter UI while its
running. running.
This Theme is only used starting with V2 of Flutter's Android embedding. --> This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar"> <style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item> <item name="android:windowBackground">?android:colorBackground</item>
</style> </style>
</resources> </resources>

View File

@ -1,18 +1,18 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off --> <!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar"> <style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when <!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame --> the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item> <item name="android:windowBackground">@drawable/launch_background</item>
</style> </style>
<!-- Theme applied to the Android Window as soon as the process has started. <!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its Flutter UI initializes, as well as behind your Flutter UI while its
running. running.
This Theme is only used starting with V2 of Flutter's Android embedding. --> This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar"> <style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item> <item name="android:windowBackground">?android:colorBackground</item>
</style> </style>
</resources> </resources>

View File

@ -1,7 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically, <!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc. to allow setting breakpoints, to provide hot reload, etc.
--> -->
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
</manifest> </manifest>

View File

@ -1,24 +1,24 @@
allprojects { allprojects {
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
} }
} }
val newBuildDir: Directory = val newBuildDir: Directory =
rootProject.layout.buildDirectory rootProject.layout.buildDirectory
.dir("../../build") .dir("../../build")
.get() .get()
rootProject.layout.buildDirectory.value(newBuildDir) rootProject.layout.buildDirectory.value(newBuildDir)
subprojects { subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir) project.layout.buildDirectory.value(newSubprojectBuildDir)
} }
subprojects { subprojects {
project.evaluationDependsOn(":app") project.evaluationDependsOn(":app")
} }
tasks.register<Delete>("clean") { tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory) delete(rootProject.layout.buildDirectory)
} }

View File

@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -1,26 +1,26 @@
pluginManagement { pluginManagement {
val flutterSdkPath = val flutterSdkPath =
run { run {
val properties = java.util.Properties() val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) } file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk") val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath flutterSdkPath
} }
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
gradlePluginPortal() gradlePluginPortal()
} }
} }
plugins { plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false id("org.jetbrains.kotlin.android") version "2.2.20" apply false
} }
include(":app") include(":app")

File diff suppressed because it is too large Load Diff

62
docs/HANDOFF.md Normal file
View File

@ -0,0 +1,62 @@
# HV BT Dashboard — Data Layer Handoff
Context for continuing work on the S300/KPro Bluetooth data layer. Paste this into a new chat to resume.
## To be continued tomorrow — debugging in progress, session ended mid-investigation
Was actively live-debugging a connection lockup with the phone connected and a `flutter run` session attached when the user had to leave. **Nothing is currently running** (flutter session and Monitor were both stopped cleanly), but the investigation is mid-thread — see "Today's session" below for exactly where it left off, and pick up from "Next steps for tomorrow" at the end.
The **temporary debug print is still active** in `sensor_provider.dart` (search for `TEMP: live-debugging session, remove after` — two spots, a `dbgFrameCount` counter and a `print(...)` call). It logs `template`/`rpm`/`gear`/`ect`/`fuelPump`/`mil`/`vtec`/`fan`/`sw2` for S300 frames, throttled to ~2/sec. Don't re-add it tomorrow — it's already there. Remove it once this investigation is actually closed out.
## Reference documents (in project root)
- `hvbt_protocol.pdf` — legacy fixed-offset frame protocol (what the app currently uses). Ground truth for byte offsets/formulas, though its own offset labels are internally inconsistent — don't trust them without a live capture.
- `Bluetooth.pdf` — Hondata's official, newer, self-describing channel-based protocol (v1.6, 2020). Confirmed via live probe that this specific hardware does NOT support it — legacy protocol is the only option.
- `Requrements.pdf` — original customer UI/UX requirements, references a "TunerView" app as the visual benchmark.
## Today's session: fuel pump held-during-template fix, then a fresh reproduction of the lockup
1. **Fixed**: `fuelPump`, `mil`, `vtec`, `fanOut` are now also held from last-known-good during S300 template frames (`sensor_provider.dart`), not just RPM/VSS/MAP/etc and gear/ECT/IAT/BAT from the previous round. This was prompted by the user seeing fuel pump flip to a wrong value after leaving the app idle without changing anything on the bench — consistent with the connection sitting in a run of template frames (where SW2 was still being decoded fresh and showing whatever the frozen pattern happened to contain) during that idle stretch.
2. **Reproduced a worse case live**: reconnected and watched a fresh session start with **100% template frames — zero live frames — for the first 103+ consecutive polls**. Since `lastGood` had never been populated yet (no live frame had ever arrived), the hold logic had nothing to fall back on and just displayed raw template garbage (RPM=0, gear=1) directly. A manual disconnect/reconnect fixed it — RPM immediately started tracking real changes again (535 of the next 1159 frames were live).
3. **User raised a valid concern**: will this lockup recur after sitting idle again? Proposed (but did not build, pending a decision) an auto-reconnect feature: detect N consecutive template frames with zero live data and trigger an automatic disconnect+reconnect, rather than requiring the user to notice and do it manually.
4. **It recurred within the same debugging session**, unprompted, while just sitting idle — caught a long run of `template=true, rpm=0` again in the log. It eventually broke free on its own (one live frame came through), but then the live debug session itself died: `flutter run` reported **"Lost connection to device"** and exited (the background bash task completed with exit code 0). This is the phone's USB/ADB link to the laptop dropping, not necessarily the phone's Bluetooth link to the ECU — those are two different connections and losing one doesn't necessarily mean the other dropped too.
5. **Unresolved when the session ended**: whether the app itself (on the phone, independent of the USB debug tether) is still running/connected to the ECU via Bluetooth, or whether it also crashed/disconnected. Debug builds tied to an active `flutter run` session sometimes get killed when that session ends — this needs to be checked first thing tomorrow. `adb devices` showed the phone reachable again by the end of the session, so the USB/ADB link itself recovered at least.
## Next steps for tomorrow, in order
1. **Check whether the app survived** the "Lost connection to device" event — is it still open and showing data on the phone, or did it close? If it closed, that's itself worth understanding (does losing the USB/ADB tether kill the debug-mode app process, or was this a coincidental separate crash?).
2. **Decide on the auto-reconnect feature** (see point 3 above) — now that the lockup has reproduced twice in one session, including a from-cold-start 100%-template case that the hold-last-good-value fix can't help with at all, this is probably worth building. Tradeoff to weigh: an automatic reconnect means a brief, visible disconnect/reconnect blip whenever it triggers, and if tuned too aggressively could interrupt a legitimately long-but-real stretch of template frames.
3. **Relaunch live debugging** (see Environment notes) to keep characterizing the lockup — specifically it would help to know: does it always eventually self-recover (like it did once today), or does it sometimes stay stuck indefinitely without a manual reconnect? How long does a "stuck" stretch typically last? Does it correlate with anything (idle time specifically, or could be triggered by anything)?
4. Once the lockup itself is on firmer ground, return to the still-open data fields below.
## Confirmed & fixed (shipped, working — from before today, still holding)
- **RPM, VSS, MAP, IGN formulas**: match the official doc / live captures. *(IGN's formula itself may be worth a second look — one live check showed a big mismatch — not investigated further, low priority.)*
- **TPS** (offset 9, `raw*100/255`), **Gear** (offset 27): confirmed correct against live USB captures.
- **ECT** (offset 33), **IAT** (offset 34), **BAT** (offset 36): all moved from earlier wrong offsets and confirmed via live capture matching USB closely.
- **Fuel Pump** (SW2 bit 2, active-low: `(sw2 & 0x04) == 0`): confirmed correct when frame is live (non-template).
- **S300 "template frame" detection and hold logic**: `isS300TemplateFrame()` in `s300_parser.dart` detects the frozen byte pattern (bytes 0-16, plus bytes 32-36 covering gear/ECT/IAT/BAT). `sensor_provider.dart` now holds RPM/VSS/MAP/TPS/INJ/IGN/knock/O2/gear/ECT/IAT/BAT/fuelPump/mil/vtec/fanOut from last-known-good during these frames. **This only works once at least one live frame has been seen** — a connection stuck in 100% template from the very start has nothing to hold and shows raw garbage (see today's session, point 2).
- **Root cause of the template-frame phenomenon itself is still unknown.** Most likely an adapter-side sampling/buffering limitation, possibly related to whatever's now also causing full lockups (100% template, no live frames at all) after idle periods — these might be the same underlying issue at different severities, not two separate problems.
- **Bluetooth ECU-type persistence**: connecting no longer shows a "S300 or KPro?" popup every time — it connects directly using whatever's set in Settings (persisted via `SharedPreferences`, survives app restart). `bt_picker_screen.dart` / `settings_provider.dart`.
- **Sanity clamp, dashboard scrolling, official protocol probe**: unchanged from before, still working.
- **Clear DTC command**: still not getting acknowledged over Bluetooth despite the ECU accepting it via USB — unresolved, lower priority than the connection lockup.
## Still open — data fields needing live data to resolve (lower priority than the lockup investigation above)
- **SW1 during template frames** — never actually confirmed either way for this specific frame type.
- **AFR**: confirmed wrong (aliased to ERR03's byte). No correct offset found.
- **PA**: candidate at offset 50 is close (~3 kPa off) but not confirmed.
- **IGN formula**: flagged as possibly wrong, not investigated.
## Verification method that has worked every time
1. Set a bench value to something specific and known.
2. Screenshot the USB tool's reading (fuller flag panel = more cross-checks per shot).
3. Attach a live debug session and read raw frame data straight from the console at the same moment — cross-check RPM between the screenshot and the log line to confirm they're actually simultaneous.
4. Compute the byte offset/formula that reproduces the known value.
5. Get a second data point or a stability check across many consecutive frames before changing code.
Do not guess offsets from the PDF's offset column labels alone. Always confirm against a live capture, and always check whether the frame in question is a template frame before trusting its bytes beyond 16.
## Not started yet (deferred until data layer + connection stability are solid)
UI/UX gaps from `Requrements.pdf`: dashboard tile count (need 10, have 6), missing "SubSensor" next to main gauge, flag pills should be selectable (spec says 4, currently a fixed 7), datalog playback not wired to live display screens.
## Environment notes
- Test device: Samsung SM_S948W, `adb -s R3GL40CPKMP` (transport ID changes across reconnects, device serial stays the same).
- Build/install cycle: `flutter build apk --debug` then `adb -s R3GL40CPKMP install -r build\app\outputs\flutter-apk\app-debug.apk`.
- Live debug session (preferred method, used heavily and successfully this whole project): `flutter run -d R3GL40CPKMP --debug` (run in background). Pair with a Monitor that dedupes or does stateful filtering (e.g. only alert on entering/leaving a "stuck" run of N consecutive template frames) rather than raw tail+grep, which gets rate-limited. **Known failure mode**: the session can die with "Lost connection to device" if the phone's USB/ADB link drops — check `adb devices` and relaunch if so.
- All parser changes should get a unit test in `test/s300_parser_test.dart` (or `kpro_parser_test.dart`) built from actual captured bytes.
- `git status` shows all data-layer, DTC/debug-screen, and BT-connection changes as uncommitted modifications, plus the three reference PDFs and this handoff doc as untracked. Nothing has been committed this project.

BIN
hvbt_protocol.pdf Normal file

Binary file not shown.

View File

@ -1,160 +1,160 @@
import 'dart:async'; import 'dart:async';
import 'dart:typed_data'; import 'dart:typed_data';
import '../protocol/neg8.dart'; import '../protocol/neg8.dart';
import 'bt_service.dart'; import 'bt_service.dart';
enum EcuType { s300, kpro } enum EcuType { s300, kpro }
/// Sends the ECU request byte every [pollingInterval] ms, /// Sends the ECU request byte every [pollingInterval] ms,
/// accumulates raw bytes into 128-byte frames, validates NEG8, /// accumulates raw bytes into 128-byte frames, validates NEG8,
/// and emits the newest usable frame on [frameStream]. /// and emits the newest usable frame on [frameStream].
class BtPoller { class BtPoller {
final BtService _service; final BtService _service;
EcuType ecuType; EcuType ecuType;
final Duration pollingInterval; final Duration pollingInterval;
// S300 request: [0x1B, 0x00, 0xE5] // S300 request: [0x1B, 0x00, 0xE5]
static final Uint8List _s300Request = Uint8List.fromList([0x1B, 0x00, 0xE5]); static final Uint8List _s300Request = Uint8List.fromList([0x1B, 0x00, 0xE5]);
// KPro request: [0x1B, 0x01, 0xE4] // KPro request: [0x1B, 0x01, 0xE4]
static final Uint8List _kproRequest = Uint8List.fromList([0x1B, 0x01, 0xE4]); static final Uint8List _kproRequest = Uint8List.fromList([0x1B, 0x01, 0xE4]);
final StreamController<Uint8List> _frameController = final StreamController<Uint8List> _frameController =
StreamController<Uint8List>.broadcast(); StreamController<Uint8List>.broadcast();
Stream<Uint8List> get frameStream => _frameController.stream; Stream<Uint8List> get frameStream => _frameController.stream;
final List<int> _rxBuf = []; final List<int> _rxBuf = [];
Timer? _pollTimer; Timer? _pollTimer;
StreamSubscription<Uint8List>? _rxSub; StreamSubscription<Uint8List>? _rxSub;
int droppedFrames = 0; int droppedFrames = 0;
int validFrames = 0; int validFrames = 0;
// Tracks whether a request is still awaiting its response, so the poller // Tracks whether a request is still awaiting its response, so the poller
// never pipelines more than one request ahead of the device. Without this, // never pipelines more than one request ahead of the device. Without this,
// a device that answers slower than [pollingInterval] falls permanently // a device that answers slower than [pollingInterval] falls permanently
// behind — every displayed frame ends up answering a stale, already-queued // behind — every displayed frame ends up answering a stale, already-queued
// request instead of the most recent one, and the dashboard visibly lags // request instead of the most recent one, and the dashboard visibly lags
// real engine changes by a growing amount. // real engine changes by a growing amount.
bool _awaitingResponse = false; bool _awaitingResponse = false;
DateTime? _lastRequestSentAt; DateTime? _lastRequestSentAt;
BtPoller( BtPoller(
this._service, { this._service, {
this.ecuType = EcuType.s300, this.ecuType = EcuType.s300,
this.pollingInterval = const Duration(milliseconds: 50), this.pollingInterval = const Duration(milliseconds: 50),
}); });
void start() { void start() {
_rxBuf.clear(); _rxBuf.clear();
_awaitingResponse = false; _awaitingResponse = false;
_lastRequestSentAt = null; _lastRequestSentAt = null;
// Listen to raw bytes from BT and accumulate into 128-byte frames // Listen to raw bytes from BT and accumulate into 128-byte frames
_rxSub = _service.rawStream.listen( _rxSub = _service.rawStream.listen(
(Uint8List chunk) { (Uint8List chunk) {
_rxBuf.addAll(chunk); _rxBuf.addAll(chunk);
_processBuffer(); _processBuffer();
}, },
onError: (Object e) { onError: (Object e) {
_frameController.addError(e); _frameController.addError(e);
stop(); stop();
}, },
); );
// Check every pollingInterval whether it's time to send the next // Check every pollingInterval whether it's time to send the next
// request — but only actually send once the prior one has been // request — but only actually send once the prior one has been
// answered (or has clearly been lost). // answered (or has clearly been lost).
_pollTimer = Timer.periodic(pollingInterval, (_) => _maybeSendRequest()); _pollTimer = Timer.periodic(pollingInterval, (_) => _maybeSendRequest());
_maybeSendRequest(); _maybeSendRequest();
} }
void _maybeSendRequest() { void _maybeSendRequest() {
if (!_service.isConnected) return; if (!_service.isConnected) return;
final stalled = _lastRequestSentAt != null && final stalled = _lastRequestSentAt != null &&
DateTime.now().difference(_lastRequestSentAt!) > pollingInterval * 4; DateTime.now().difference(_lastRequestSentAt!) > pollingInterval * 4;
if (_awaitingResponse && !stalled) return; if (_awaitingResponse && !stalled) return;
_awaitingResponse = true; _awaitingResponse = true;
_lastRequestSentAt = DateTime.now(); _lastRequestSentAt = DateTime.now();
_service _service
.write(ecuType == EcuType.s300 ? _s300Request : _kproRequest) .write(ecuType == EcuType.s300 ? _s300Request : _kproRequest)
.catchError((Object _) { .catchError((Object _) {
// A failed write must not leave the poller permanently waiting for a // A failed write must not leave the poller permanently waiting for a
// response that was never actually requested — let the next tick retry. // response that was never actually requested — let the next tick retry.
_awaitingResponse = false; _awaitingResponse = false;
}); });
} }
void _processBuffer() { void _processBuffer() {
// Extract as many 128-byte frames as possible. // Extract as many 128-byte frames as possible.
// Frame start sync: first byte should be 0x1B (header marker). // Frame start sync: first byte should be 0x1B (header marker).
// If we have a misaligned buffer, scan forward. // If we have a misaligned buffer, scan forward.
Uint8List? latestUsableFrame; Uint8List? latestUsableFrame;
while (_rxBuf.length >= 128) { while (_rxBuf.length >= 128) {
// Scan for 0x1B frame header // Scan for 0x1B frame header
int startIdx = 0; int startIdx = 0;
while (startIdx < _rxBuf.length && _rxBuf[startIdx] != 0x1B) { while (startIdx < _rxBuf.length && _rxBuf[startIdx] != 0x1B) {
startIdx++; startIdx++;
} }
// Not enough data after sync byte // Not enough data after sync byte
if (_rxBuf.length - startIdx < 128) { if (_rxBuf.length - startIdx < 128) {
// Discard bytes before potential header // Discard bytes before potential header
if (startIdx > 0) { if (startIdx > 0) {
_rxBuf.removeRange(0, startIdx); _rxBuf.removeRange(0, startIdx);
} }
break; break;
} }
final Uint8List frame = final Uint8List frame =
Uint8List.fromList(_rxBuf.sublist(startIdx, startIdx + 128)); Uint8List.fromList(_rxBuf.sublist(startIdx, startIdx + 128));
final checksumOk = validateFrame(frame); final checksumOk = validateFrame(frame);
if (checksumOk) { if (checksumOk) {
validFrames++; validFrames++;
latestUsableFrame = frame; latestUsableFrame = frame;
_rxBuf.removeRange(0, startIdx + 128); _rxBuf.removeRange(0, startIdx + 128);
} else { } else {
// Bad checksum — the window is misaligned (or the frame was // Bad checksum — the window is misaligned (or the frame was
// corrupted in transit). Drop one byte and rescan for the next // corrupted in transit). Drop one byte and rescan for the next
// 0x1B so the stream resynchronizes instead of permanently // 0x1B so the stream resynchronizes instead of permanently
// sliding a fixed-size window over garbage. // sliding a fixed-size window over garbage.
droppedFrames++; droppedFrames++;
_rxBuf.removeRange(0, startIdx + 1); _rxBuf.removeRange(0, startIdx + 1);
} }
} }
if (latestUsableFrame != null) { if (latestUsableFrame != null) {
_frameController.add(latestUsableFrame); _frameController.add(latestUsableFrame);
// The device answered — clear the road for the next request. Wait out // The device answered — clear the road for the next request. Wait out
// the configured pollingInterval before sending it rather than firing // the configured pollingInterval before sending it rather than firing
// immediately: back-to-back requests with no gap can outrun the ECU's // immediately: back-to-back requests with no gap can outrun the ECU's
// own tach input-capture timing, which needs a moment to latch a fresh // own tach input-capture timing, which needs a moment to latch a fresh
// RPM count between reads — hammering it faster than that makes it // RPM count between reads — hammering it faster than that makes it
// fall back to reporting "no fresh count yet" on every subsequent // fall back to reporting "no fresh count yet" on every subsequent
// request instead of a real reading. pollingInterval is also what the // request instead of a real reading. pollingInterval is also what the
// Settings screen exposes, so this makes that control actually pace // Settings screen exposes, so this makes that control actually pace
// the link instead of just being a stalled-request fallback. // the link instead of just being a stalled-request fallback.
_awaitingResponse = false; _awaitingResponse = false;
Timer(pollingInterval, _maybeSendRequest); Timer(pollingInterval, _maybeSendRequest);
} }
} }
void stop() { void stop() {
_pollTimer?.cancel(); _pollTimer?.cancel();
_pollTimer = null; _pollTimer = null;
_rxSub?.cancel(); _rxSub?.cancel();
_rxSub = null; _rxSub = null;
_rxBuf.clear(); _rxBuf.clear();
} }
void dispose() { void dispose() {
stop(); stop();
_frameController.close(); _frameController.close();
} }
} }

View File

@ -1,56 +1,56 @@
import 'dart:async'; import 'dart:async';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart'; import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
/// Wraps flutter_bluetooth_serial for SPP/RFCOMM Classic BT. /// Wraps flutter_bluetooth_serial for SPP/RFCOMM Classic BT.
class BtService { class BtService {
BluetoothConnection? _connection; BluetoothConnection? _connection;
final StreamController<Uint8List> _rawFrameController = final StreamController<Uint8List> _rawFrameController =
StreamController<Uint8List>.broadcast(); StreamController<Uint8List>.broadcast();
bool get isConnected => _connection?.isConnected ?? false; bool get isConnected => _connection?.isConnected ?? false;
Stream<Uint8List> get rawStream => _rawFrameController.stream; Stream<Uint8List> get rawStream => _rawFrameController.stream;
Future<List<BluetoothDevice>> getPairedDevices() async { Future<List<BluetoothDevice>> getPairedDevices() async {
return FlutterBluetoothSerial.instance.getBondedDevices(); return FlutterBluetoothSerial.instance.getBondedDevices();
} }
Future<void> connect(BluetoothDevice device) async { Future<void> connect(BluetoothDevice device) async {
if (_connection != null) { if (_connection != null) {
await disconnect(); await disconnect();
} }
_connection = await BluetoothConnection.toAddress(device.address); _connection = await BluetoothConnection.toAddress(device.address);
_connection!.input!.listen( _connection!.input!.listen(
(Uint8List data) { (Uint8List data) {
if (!_rawFrameController.isClosed) { if (!_rawFrameController.isClosed) {
_rawFrameController.add(data); _rawFrameController.add(data);
} }
}, },
onDone: () { onDone: () {
_rawFrameController.addError('BT connection closed'); _rawFrameController.addError('BT connection closed');
}, },
onError: (Object e) { onError: (Object e) {
_rawFrameController.addError(e); _rawFrameController.addError(e);
}, },
); );
} }
Future<void> write(Uint8List data) async { Future<void> write(Uint8List data) async {
if (_connection?.isConnected ?? false) { if (_connection?.isConnected ?? false) {
_connection!.output.add(data); _connection!.output.add(data);
await _connection!.output.allSent; await _connection!.output.allSent;
} }
} }
Future<void> disconnect() async { Future<void> disconnect() async {
await _connection?.close(); await _connection?.close();
_connection = null; _connection = null;
} }
void dispose() { void dispose() {
disconnect(); disconnect();
_rawFrameController.close(); _rawFrameController.close();
} }
} }

View File

@ -1,140 +1,140 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.dart'; import 'package:sqflite/sqflite.dart';
import '../models/datalog_session.dart'; import '../models/datalog_session.dart';
/// SQLite database wrapper for sessions and raw ECU frames. /// SQLite database wrapper for sessions and raw ECU frames.
class DatalogDb { class DatalogDb {
static const _dbName = 'hvbt_datalog.db'; static const _dbName = 'hvbt_datalog.db';
static const _version = 1; static const _version = 1;
Database? _db; Database? _db;
Future<Database> get _database async { Future<Database> get _database async {
_db ??= await _open(); _db ??= await _open();
return _db!; return _db!;
} }
Future<Database> _open() async { Future<Database> _open() async {
final dir = await getApplicationDocumentsDirectory(); final dir = await getApplicationDocumentsDirectory();
final path = p.join(dir.path, _dbName); final path = p.join(dir.path, _dbName);
return openDatabase( return openDatabase(
path, path,
version: _version, version: _version,
onCreate: (db, _) async { onCreate: (db, _) async {
await db.execute(''' await db.execute('''
CREATE TABLE sessions ( CREATE TABLE sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
ecu_type TEXT NOT NULL, ecu_type TEXT NOT NULL,
start_time INTEGER NOT NULL, start_time INTEGER NOT NULL,
end_time INTEGER, end_time INTEGER,
frame_count INTEGER NOT NULL DEFAULT 0 frame_count INTEGER NOT NULL DEFAULT 0
) )
'''); ''');
await db.execute(''' await db.execute('''
CREATE TABLE frames ( CREATE TABLE frames (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL, session_id INTEGER NOT NULL,
timestamp INTEGER NOT NULL, timestamp INTEGER NOT NULL,
data BLOB NOT NULL, data BLOB NOT NULL,
FOREIGN KEY(session_id) REFERENCES sessions(id) ON DELETE CASCADE FOREIGN KEY(session_id) REFERENCES sessions(id) ON DELETE CASCADE
) )
'''); ''');
await db.execute( await db.execute(
'CREATE INDEX idx_frames_session ON frames(session_id)'); 'CREATE INDEX idx_frames_session ON frames(session_id)');
}, },
); );
} }
// ── Sessions ────────────────────────────────────────────────────────────── // ── Sessions ──────────────────────────────────────────────────────────────
/// Inserts a new session row. Returns the assigned id. /// Inserts a new session row. Returns the assigned id.
Future<int> insertSession(DatalogSession session) async { Future<int> insertSession(DatalogSession session) async {
final db = await _database; final db = await _database;
return db.insert('sessions', session.toMap()..remove('id')); return db.insert('sessions', session.toMap()..remove('id'));
} }
/// Updates end_time and frame_count for an existing session. /// Updates end_time and frame_count for an existing session.
Future<void> closeSession(int sessionId, DateTime endTime, int frameCount) async { Future<void> closeSession(int sessionId, DateTime endTime, int frameCount) async {
final db = await _database; final db = await _database;
await db.update( await db.update(
'sessions', 'sessions',
{ {
'end_time': endTime.millisecondsSinceEpoch, 'end_time': endTime.millisecondsSinceEpoch,
'frame_count': frameCount, 'frame_count': frameCount,
}, },
where: 'id = ?', where: 'id = ?',
whereArgs: [sessionId], whereArgs: [sessionId],
); );
} }
/// Returns all sessions ordered newest first. /// Returns all sessions ordered newest first.
Future<List<DatalogSession>> getSessions() async { Future<List<DatalogSession>> getSessions() async {
final db = await _database; final db = await _database;
final rows = await db.query('sessions', orderBy: 'start_time DESC'); final rows = await db.query('sessions', orderBy: 'start_time DESC');
return rows.map(DatalogSession.fromMap).toList(); return rows.map(DatalogSession.fromMap).toList();
} }
/// Deletes a session and all its frames (CASCADE). /// Deletes a session and all its frames (CASCADE).
Future<void> deleteSession(int sessionId) async { Future<void> deleteSession(int sessionId) async {
final db = await _database; final db = await _database;
await db.delete('sessions', where: 'id = ?', whereArgs: [sessionId]); await db.delete('sessions', where: 'id = ?', whereArgs: [sessionId]);
} }
// ── Frames ──────────────────────────────────────────────────────────────── // ── Frames ────────────────────────────────────────────────────────────────
/// Batch-inserts multiple frames in a single transaction. /// Batch-inserts multiple frames in a single transaction.
Future<void> insertFrames( Future<void> insertFrames(
int sessionId, List<PendingFrame> frames) async { int sessionId, List<PendingFrame> frames) async {
if (frames.isEmpty) return; if (frames.isEmpty) return;
final db = await _database; final db = await _database;
await db.transaction((txn) async { await db.transaction((txn) async {
final batch = txn.batch(); final batch = txn.batch();
for (final f in frames) { for (final f in frames) {
batch.insert('frames', { batch.insert('frames', {
'session_id': sessionId, 'session_id': sessionId,
'timestamp': f.timestamp.millisecondsSinceEpoch, 'timestamp': f.timestamp.millisecondsSinceEpoch,
'data': f.data, 'data': f.data,
}); });
} }
await batch.commit(noResult: true); await batch.commit(noResult: true);
}); });
} }
/// Returns all raw frames for a session, ordered by timestamp. /// Returns all raw frames for a session, ordered by timestamp.
Future<List<StoredFrame>> getFrames(int sessionId) async { Future<List<StoredFrame>> getFrames(int sessionId) async {
final db = await _database; final db = await _database;
final rows = await db.query( final rows = await db.query(
'frames', 'frames',
where: 'session_id = ?', where: 'session_id = ?',
whereArgs: [sessionId], whereArgs: [sessionId],
orderBy: 'timestamp ASC', orderBy: 'timestamp ASC',
); );
return rows return rows
.map((r) => StoredFrame( .map((r) => StoredFrame(
timestamp: DateTime.fromMillisecondsSinceEpoch( timestamp: DateTime.fromMillisecondsSinceEpoch(
r['timestamp'] as int), r['timestamp'] as int),
data: r['data'] as Uint8List, data: r['data'] as Uint8List,
)) ))
.toList(); .toList();
} }
Future<void> close() async => _db?.close(); Future<void> close() async => _db?.close();
} }
// ─── DTOs ───────────────────────────────────────────────────────────────────── // ─── DTOs ─────────────────────────────────────────────────────────────────────
class PendingFrame { class PendingFrame {
final DateTime timestamp; final DateTime timestamp;
final Uint8List data; final Uint8List data;
const PendingFrame(this.timestamp, this.data); const PendingFrame(this.timestamp, this.data);
} }
class StoredFrame { class StoredFrame {
final DateTime timestamp; final DateTime timestamp;
final Uint8List data; final Uint8List data;
const StoredFrame({required this.timestamp, required this.data}); const StoredFrame({required this.timestamp, required this.data});
} }

View File

@ -1,119 +1,119 @@
import 'dart:async'; import 'dart:async';
import 'dart:typed_data'; import 'dart:typed_data';
import 'datalog_db.dart'; import 'datalog_db.dart';
enum PlaybackStatus { idle, playing, paused } enum PlaybackStatus { idle, playing, paused }
/// Loads stored frames from SQLite and replays them through a stream /// Loads stored frames from SQLite and replays them through a stream
/// at configurable speed. Consumers watch [frameStream] exactly like /// at configurable speed. Consumers watch [frameStream] exactly like
/// the live BT stream — the parser layer is unaware of the difference. /// the live BT stream — the parser layer is unaware of the difference.
class DatalogPlayer { class DatalogPlayer {
final DatalogDb _db; final DatalogDb _db;
List<StoredFrame> _frames = []; List<StoredFrame> _frames = [];
int _position = 0; // index into _frames int _position = 0; // index into _frames
double _speed = 1.0; // 1.0 = real-time, 2.0 = 2× faster double _speed = 1.0; // 1.0 = real-time, 2.0 = 2× faster
PlaybackStatus _status = PlaybackStatus.idle; PlaybackStatus _status = PlaybackStatus.idle;
PlaybackStatus get status => _status; PlaybackStatus get status => _status;
int get position => _position; int get position => _position;
int get totalFrames => _frames.length; int get totalFrames => _frames.length;
double get speed => _speed; double get speed => _speed;
set speed(double v) => _speed = v.clamp(0.25, 8.0); set speed(double v) => _speed = v.clamp(0.25, 8.0);
final StreamController<Uint8List> _controller = final StreamController<Uint8List> _controller =
StreamController<Uint8List>.broadcast(); StreamController<Uint8List>.broadcast();
Stream<Uint8List> get frameStream => _controller.stream; Stream<Uint8List> get frameStream => _controller.stream;
/// Callback fired on each tick — lets UI update a progress indicator. /// Callback fired on each tick — lets UI update a progress indicator.
void Function(int position, int total)? onProgress; void Function(int position, int total)? onProgress;
Timer? _timer; Timer? _timer;
DatalogPlayer(this._db); DatalogPlayer(this._db);
/// Load all frames for [sessionId] into memory. /// Load all frames for [sessionId] into memory.
Future<void> load(int sessionId) async { Future<void> load(int sessionId) async {
await stop(); await stop();
_frames = await _db.getFrames(sessionId); _frames = await _db.getFrames(sessionId);
_position = 0; _position = 0;
_status = PlaybackStatus.idle; _status = PlaybackStatus.idle;
} }
/// Start or resume playback. /// Start or resume playback.
void play() { void play() {
if (_status == PlaybackStatus.playing) return; if (_status == PlaybackStatus.playing) return;
if (_frames.isEmpty) return; if (_frames.isEmpty) return;
_status = PlaybackStatus.playing; _status = PlaybackStatus.playing;
_scheduleNext(); _scheduleNext();
} }
void pause() { void pause() {
_timer?.cancel(); _timer?.cancel();
_timer = null; _timer = null;
if (_status == PlaybackStatus.playing) { if (_status == PlaybackStatus.playing) {
_status = PlaybackStatus.paused; _status = PlaybackStatus.paused;
} }
} }
Future<void> stop() async { Future<void> stop() async {
_timer?.cancel(); _timer?.cancel();
_timer = null; _timer = null;
_position = 0; _position = 0;
_status = PlaybackStatus.idle; _status = PlaybackStatus.idle;
} }
/// Jump to a specific frame index. /// Jump to a specific frame index.
void seek(int index) { void seek(int index) {
_position = index.clamp(0, _frames.length - 1); _position = index.clamp(0, _frames.length - 1);
onProgress?.call(_position, _frames.length); onProgress?.call(_position, _frames.length);
} }
void _scheduleNext() { void _scheduleNext() {
if (_position >= _frames.length) { if (_position >= _frames.length) {
_status = PlaybackStatus.idle; _status = PlaybackStatus.idle;
onProgress?.call(_frames.length, _frames.length); onProgress?.call(_frames.length, _frames.length);
return; return;
} }
// Calculate delay to next frame using original timestamps // Calculate delay to next frame using original timestamps
Duration delay = const Duration(milliseconds: 100); // default 10 Hz Duration delay = const Duration(milliseconds: 100); // default 10 Hz
if (_position + 1 < _frames.length) { if (_position + 1 < _frames.length) {
final gap = _frames[_position + 1].timestamp final gap = _frames[_position + 1].timestamp
.difference(_frames[_position].timestamp); .difference(_frames[_position].timestamp);
delay = Duration( delay = Duration(
microseconds: (gap.inMicroseconds / _speed).round(), microseconds: (gap.inMicroseconds / _speed).round(),
); );
// Clamp to 10ms–2s to handle gaps from pauses/reconnects // Clamp to 10ms–2s to handle gaps from pauses/reconnects
delay = delay.clamp( delay = delay.clamp(
const Duration(milliseconds: 10), const Duration(milliseconds: 10),
const Duration(seconds: 2), const Duration(seconds: 2),
); );
} }
_timer = Timer(delay, () { _timer = Timer(delay, () {
if (_status != PlaybackStatus.playing) return; if (_status != PlaybackStatus.playing) return;
if (_position < _frames.length) { if (_position < _frames.length) {
_controller.add(_frames[_position].data); _controller.add(_frames[_position].data);
onProgress?.call(_position, _frames.length); onProgress?.call(_position, _frames.length);
_position++; _position++;
} }
_scheduleNext(); _scheduleNext();
}); });
} }
void dispose() { void dispose() {
_timer?.cancel(); _timer?.cancel();
_controller.close(); _controller.close();
} }
} }
extension on Duration { extension on Duration {
Duration clamp(Duration min, Duration max) { Duration clamp(Duration min, Duration max) {
if (this < min) return min; if (this < min) return min;
if (this > max) return max; if (this > max) return max;
return this; return this;
} }
} }

View File

@ -1,94 +1,94 @@
import 'dart:async'; import 'dart:async';
import 'dart:typed_data'; import 'dart:typed_data';
import 'datalog_db.dart'; import 'datalog_db.dart';
import '../models/datalog_session.dart'; import '../models/datalog_session.dart';
/// Listens to a raw frame stream and batches writes to SQLite every 500ms. /// Listens to a raw frame stream and batches writes to SQLite every 500ms.
class DatalogRecorder { class DatalogRecorder {
final DatalogDb _db; final DatalogDb _db;
int? _sessionId; int? _sessionId;
String? _ecuType; String? _ecuType;
DateTime? _startTime; DateTime? _startTime;
int _frameCount = 0; int _frameCount = 0;
final List<PendingFrame> _batch = []; final List<PendingFrame> _batch = [];
Timer? _flushTimer; Timer? _flushTimer;
StreamSubscription<Uint8List>? _frameSub; StreamSubscription<Uint8List>? _frameSub;
bool get isRecording => _sessionId != null; bool get isRecording => _sessionId != null;
int get frameCount => _frameCount; int get frameCount => _frameCount;
int? get sessionId => _sessionId; int? get sessionId => _sessionId;
DatalogRecorder(this._db); DatalogRecorder(this._db);
/// Start a new recording session, listening to [frameStream]. /// Start a new recording session, listening to [frameStream].
Future<void> start(Stream<Uint8List> frameStream, String ecuType) async { Future<void> start(Stream<Uint8List> frameStream, String ecuType) async {
if (isRecording) await stop(); if (isRecording) await stop();
_ecuType = ecuType; _ecuType = ecuType;
_startTime = DateTime.now(); _startTime = DateTime.now();
_frameCount = 0; _frameCount = 0;
_batch.clear(); _batch.clear();
final session = DatalogSession( final session = DatalogSession(
ecuType: ecuType, ecuType: ecuType,
startTime: _startTime!, startTime: _startTime!,
); );
_sessionId = await _db.insertSession(session); _sessionId = await _db.insertSession(session);
// Collect frames into batch buffer // Collect frames into batch buffer
_frameSub = frameStream.listen((Uint8List frame) { _frameSub = frameStream.listen((Uint8List frame) {
_batch.add(PendingFrame(DateTime.now(), frame)); _batch.add(PendingFrame(DateTime.now(), frame));
_frameCount++; _frameCount++;
}); });
// Flush to DB every 500ms // Flush to DB every 500ms
_flushTimer = Timer.periodic(const Duration(milliseconds: 500), (_) { _flushTimer = Timer.periodic(const Duration(milliseconds: 500), (_) {
_flush(); _flush();
}); });
} }
/// Stop recording and finalise the session row. /// Stop recording and finalise the session row.
Future<DatalogSession?> stop() async { Future<DatalogSession?> stop() async {
if (!isRecording) return null; if (!isRecording) return null;
_flushTimer?.cancel(); _flushTimer?.cancel();
_flushTimer = null; _flushTimer = null;
await _frameSub?.cancel(); await _frameSub?.cancel();
_frameSub = null; _frameSub = null;
await _flush(); // write any remaining buffered frames await _flush(); // write any remaining buffered frames
final endTime = DateTime.now(); final endTime = DateTime.now();
await _db.closeSession(_sessionId!, endTime, _frameCount); await _db.closeSession(_sessionId!, endTime, _frameCount);
final finished = DatalogSession( final finished = DatalogSession(
id: _sessionId, id: _sessionId,
ecuType: _ecuType!, ecuType: _ecuType!,
startTime: _startTime!, startTime: _startTime!,
endTime: endTime, endTime: endTime,
frameCount: _frameCount, frameCount: _frameCount,
); );
_sessionId = null; _sessionId = null;
_ecuType = null; _ecuType = null;
_startTime = null; _startTime = null;
_frameCount = 0; _frameCount = 0;
return finished; return finished;
} }
Future<void> _flush() async { Future<void> _flush() async {
if (_batch.isEmpty || _sessionId == null) return; if (_batch.isEmpty || _sessionId == null) return;
final toWrite = List<PendingFrame>.from(_batch); final toWrite = List<PendingFrame>.from(_batch);
_batch.clear(); _batch.clear();
await _db.insertFrames(_sessionId!, toWrite); await _db.insertFrames(_sessionId!, toWrite);
} }
void dispose() { void dispose() {
_flushTimer?.cancel(); _flushTimer?.cancel();
_frameSub?.cancel(); _frameSub?.cancel();
} }
} }

View File

@ -1,62 +1,62 @@
/// Metadata for a single recorded ECU datalog session. /// Metadata for a single recorded ECU datalog session.
class DatalogSession { class DatalogSession {
final int? id; final int? id;
final String ecuType; final String ecuType;
final DateTime startTime; final DateTime startTime;
final DateTime? endTime; final DateTime? endTime;
final int frameCount; final int frameCount;
const DatalogSession({ const DatalogSession({
this.id, this.id,
required this.ecuType, required this.ecuType,
required this.startTime, required this.startTime,
this.endTime, this.endTime,
this.frameCount = 0, this.frameCount = 0,
}); });
Duration get duration { Duration get duration {
final end = endTime ?? DateTime.now(); final end = endTime ?? DateTime.now();
return end.difference(startTime); return end.difference(startTime);
} }
String get durationLabel { String get durationLabel {
final d = duration; final d = duration;
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0'); final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0'); final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
return '$m:$s'; return '$m:$s';
} }
DatalogSession copyWith({ DatalogSession copyWith({
int? id, int? id,
String? ecuType, String? ecuType,
DateTime? startTime, DateTime? startTime,
DateTime? endTime, DateTime? endTime,
int? frameCount, int? frameCount,
}) => }) =>
DatalogSession( DatalogSession(
id: id ?? this.id, id: id ?? this.id,
ecuType: ecuType ?? this.ecuType, ecuType: ecuType ?? this.ecuType,
startTime: startTime ?? this.startTime, startTime: startTime ?? this.startTime,
endTime: endTime ?? this.endTime, endTime: endTime ?? this.endTime,
frameCount: frameCount ?? this.frameCount, frameCount: frameCount ?? this.frameCount,
); );
Map<String, dynamic> toMap() => { Map<String, dynamic> toMap() => {
'id': id, 'id': id,
'ecu_type': ecuType, 'ecu_type': ecuType,
'start_time': startTime.millisecondsSinceEpoch, 'start_time': startTime.millisecondsSinceEpoch,
'end_time': endTime?.millisecondsSinceEpoch, 'end_time': endTime?.millisecondsSinceEpoch,
'frame_count': frameCount, 'frame_count': frameCount,
}; };
factory DatalogSession.fromMap(Map<String, dynamic> m) => DatalogSession( factory DatalogSession.fromMap(Map<String, dynamic> m) => DatalogSession(
id: m['id'] as int?, id: m['id'] as int?,
ecuType: m['ecu_type'] as String, ecuType: m['ecu_type'] as String,
startTime: startTime:
DateTime.fromMillisecondsSinceEpoch(m['start_time'] as int), DateTime.fromMillisecondsSinceEpoch(m['start_time'] as int),
endTime: m['end_time'] == null endTime: m['end_time'] == null
? null ? null
: DateTime.fromMillisecondsSinceEpoch(m['end_time'] as int), : DateTime.fromMillisecondsSinceEpoch(m['end_time'] as int),
frameCount: m['frame_count'] as int? ?? 0, frameCount: m['frame_count'] as int? ?? 0,
); );
} }

View File

@ -1,7 +1,7 @@
/// Definition of a boolean flag sensor shown as indicator pills. /// Definition of a boolean flag sensor shown as indicator pills.
class FlagDef { class FlagDef {
final String id; final String id;
final String displayName; final String displayName;
const FlagDef({required this.id, required this.displayName}); const FlagDef({required this.id, required this.displayName});
} }

View File

@ -1,16 +1,16 @@
/// Definition of a single sensor — used for bar gauges, pickers, and graph panels. /// Definition of a single sensor — used for bar gauges, pickers, and graph panels.
class SensorDef { class SensorDef {
final String id; final String id;
final String displayName; final String displayName;
final String unit; final String unit;
final double min; final double min;
final double max; final double max;
const SensorDef({ const SensorDef({
required this.id, required this.id,
required this.displayName, required this.displayName,
required this.unit, required this.unit,
required this.min, required this.min,
required this.max, required this.max,
}); });
} }

View File

@ -1,67 +1,67 @@
import 'sensor_def.dart'; import 'sensor_def.dart';
import 'flag_def.dart'; import 'flag_def.dart';
/// Master list of all analog sensor definitions. /// Master list of all analog sensor definitions.
const List<SensorDef> sensorDefs = [ const List<SensorDef> sensorDefs = [
SensorDef(id: 'rpm', displayName: 'RPM', unit: 'revs', min: 0, max: 9000), SensorDef(id: 'rpm', displayName: 'RPM', unit: 'revs', min: 0, max: 9000),
SensorDef(id: 'vss', displayName: 'Speed', unit: 'km/h', min: 0, max: 280), SensorDef(id: 'vss', displayName: 'Speed', unit: 'km/h', min: 0, max: 280),
SensorDef( SensorDef(
id: 'map', id: 'map',
displayName: 'Manifold pressure', displayName: 'Manifold pressure',
unit: 'kPa', unit: 'kPa',
min: 0, min: 0,
max: 300), max: 300),
SensorDef( SensorDef(
id: 'tps', displayName: 'Throttle pedal', unit: '%', min: 0, max: 100), id: 'tps', displayName: 'Throttle pedal', unit: '%', min: 0, max: 100),
SensorDef( SensorDef(
id: 'inj', displayName: 'Injector duration', unit: 'ms', min: 0, max: 20), id: 'inj', displayName: 'Injector duration', unit: 'ms', min: 0, max: 20),
SensorDef( SensorDef(
id: 'ign', displayName: 'Timing advance', unit: 'deg', min: -20, max: 60), id: 'ign', displayName: 'Timing advance', unit: 'deg', min: -20, max: 60),
SensorDef( SensorDef(
id: 'ect', displayName: 'Coolant temp', unit: '°C', min: -40, max: 150), id: 'ect', displayName: 'Coolant temp', unit: '°C', min: -40, max: 150),
SensorDef( SensorDef(
id: 'iat', id: 'iat',
displayName: 'Intake air temp', displayName: 'Intake air temp',
unit: '°C', unit: '°C',
min: -40, min: -40,
max: 150), max: 150),
SensorDef( SensorDef(
id: 'bat', displayName: 'Battery voltage', unit: 'V', min: 0, max: 20), id: 'bat', displayName: 'Battery voltage', unit: 'V', min: 0, max: 20),
SensorDef(id: 'o2', displayName: 'O2 sensor', unit: 'V', min: 0, max: 1.5), SensorDef(id: 'o2', displayName: 'O2 sensor', unit: 'V', min: 0, max: 1.5),
SensorDef(id: 'gear', displayName: 'Gear', unit: '—', min: 1, max: 6), SensorDef(id: 'gear', displayName: 'Gear', unit: '—', min: 1, max: 6),
SensorDef(id: 'eth', displayName: 'Ethanol', unit: '%', min: 0, max: 100), SensorDef(id: 'eth', displayName: 'Ethanol', unit: '%', min: 0, max: 100),
SensorDef( SensorDef(
id: 'pa', displayName: 'Baro pressure', unit: 'kPa', min: 0, max: 120), id: 'pa', displayName: 'Baro pressure', unit: 'kPa', min: 0, max: 120),
SensorDef(id: 'afr', displayName: 'AFR', unit: 'λ', min: 0.5, max: 2.0), SensorDef(id: 'afr', displayName: 'AFR', unit: 'λ', min: 0.5, max: 2.0),
SensorDef( SensorDef(
id: 'strim', displayName: 'Short trim', unit: '%', min: -30, max: 30), id: 'strim', displayName: 'Short trim', unit: '%', min: -30, max: 30),
SensorDef( SensorDef(
id: 'ltrim', displayName: 'Long trim', unit: '%', min: -30, max: 30), id: 'ltrim', displayName: 'Long trim', unit: '%', min: -30, max: 30),
SensorDef( SensorDef(
id: 'ain0', displayName: 'Analog input 0', unit: 'V', min: 0, max: 5), id: 'ain0', displayName: 'Analog input 0', unit: 'V', min: 0, max: 5),
SensorDef( SensorDef(
id: 'ain1', displayName: 'Analog input 1', unit: 'V', min: 0, max: 5), id: 'ain1', displayName: 'Analog input 1', unit: 'V', min: 0, max: 5),
SensorDef( SensorDef(
id: 'ain2', displayName: 'Analog input 2', unit: 'V', min: 0, max: 5), id: 'ain2', displayName: 'Analog input 2', unit: 'V', min: 0, max: 5),
SensorDef( SensorDef(
id: 'ain3', displayName: 'Analog input 3', unit: 'V', min: 0, max: 5), id: 'ain3', displayName: 'Analog input 3', unit: 'V', min: 0, max: 5),
SensorDef( SensorDef(
id: 'ain4', displayName: 'Analog input 4', unit: 'V', min: 0, max: 5), id: 'ain4', displayName: 'Analog input 4', unit: 'V', min: 0, max: 5),
SensorDef( SensorDef(
id: 'ain5', displayName: 'Analog input 5', unit: 'V', min: 0, max: 5), id: 'ain5', displayName: 'Analog input 5', unit: 'V', min: 0, max: 5),
SensorDef( SensorDef(
id: 'ain6', displayName: 'Analog input 6', unit: 'V', min: 0, max: 5), id: 'ain6', displayName: 'Analog input 6', unit: 'V', min: 0, max: 5),
SensorDef( SensorDef(
id: 'ain7', displayName: 'Analog input 7', unit: 'V', min: 0, max: 5), id: 'ain7', displayName: 'Analog input 7', unit: 'V', min: 0, max: 5),
]; ];
/// Master list of boolean flag sensors. /// Master list of boolean flag sensors.
const List<FlagDef> flagDefs = [ const List<FlagDef> flagDefs = [
FlagDef(id: 'mil', displayName: 'MIL'), FlagDef(id: 'mil', displayName: 'MIL'),
FlagDef(id: 'fuelcut', displayName: 'Fuel Cut'), FlagDef(id: 'fuelpump', displayName: 'Fuel Pump'),
FlagDef(id: 'fanout', displayName: 'FAN Out'), FlagDef(id: 'fanout', displayName: 'FAN Out'),
FlagDef(id: 'vtec', displayName: 'VTEC'), FlagDef(id: 'vtec', displayName: 'VTEC'),
FlagDef(id: 'knock', displayName: 'Knock'), FlagDef(id: 'knock', displayName: 'Knock'),
FlagDef(id: 'revlimit', displayName: 'Rev Limit'), FlagDef(id: 'revlimit', displayName: 'Rev Limit'),
FlagDef(id: 'launch', displayName: 'Launch'), FlagDef(id: 'launch', displayName: 'Launch'),
]; ];

View File

@ -1,249 +1,249 @@
/// DTC maps for S300 and KPro ECU protocols. /// DTC maps for S300 and KPro ECU protocols.
/// ///
/// Key format: 'ERRxx_bity' where xx = byte index (00-based), y = bit number (0=LSB). /// Key format: 'ERRxx_bity' where xx = byte index (00-based), y = bit number (0=LSB).
/// Value: 'Pxxxx — description' /// Value: 'Pxxxx — description'
library; library;
/// S300 DTC map (ERR00–ERR03, 4 bytes × 8 bits = 32 possible codes) /// S300 DTC map (ERR00–ERR03, 4 bytes × 8 bits = 32 possible codes)
const Map<String, String> s300DtcMap = { const Map<String, String> s300DtcMap = {
// ERR00 (byte 0x31) // ERR00 (byte 0x31)
'ERR00_bit0': 'P0130 — O2 Sensor (front)', 'ERR00_bit0': 'P0130 — O2 Sensor (front)',
'ERR00_bit1': 'P0135 — O2 Sensor Heater (front)', 'ERR00_bit1': 'P0135 — O2 Sensor Heater (front)',
'ERR00_bit2': 'P0505 — Idle Air Control Valve', 'ERR00_bit2': 'P0505 — Idle Air Control Valve',
'ERR00_bit3': 'P0108 — MAP Sensor High', 'ERR00_bit3': 'P0108 — MAP Sensor High',
'ERR00_bit4': 'P0107 — MAP Sensor Low', 'ERR00_bit4': 'P0107 — MAP Sensor Low',
'ERR00_bit5': 'P0113 — IAT Sensor High', 'ERR00_bit5': 'P0113 — IAT Sensor High',
'ERR00_bit6': 'P0112 — IAT Sensor Low', 'ERR00_bit6': 'P0112 — IAT Sensor Low',
'ERR00_bit7': 'P0118 — ECT Sensor High', 'ERR00_bit7': 'P0118 — ECT Sensor High',
// ERR01 (byte 0x32) // ERR01 (byte 0x32)
'ERR01_bit0': 'P0117 — ECT Sensor Low', 'ERR01_bit0': 'P0117 — ECT Sensor Low',
'ERR01_bit1': 'P0336 — CKP Sensor', 'ERR01_bit1': 'P0336 — CKP Sensor',
'ERR01_bit2': 'P0341 — CMP Sensor', 'ERR01_bit2': 'P0341 — CMP Sensor',
'ERR01_bit3': 'P1362 — TDC Sensor (No Signal)', 'ERR01_bit3': 'P1362 — TDC Sensor (No Signal)',
'ERR01_bit4': 'P1363 — TDC Sensor (No Signal 2)', 'ERR01_bit4': 'P1363 — TDC Sensor (No Signal 2)',
'ERR01_bit5': 'P0335 — CKP Sensor (No Signal)', 'ERR01_bit5': 'P0335 — CKP Sensor (No Signal)',
'ERR01_bit6': 'P1361 — TDC Sensor', 'ERR01_bit6': 'P1361 — TDC Sensor',
'ERR01_bit7': 'P0300 — Random Misfire', 'ERR01_bit7': 'P0300 — Random Misfire',
// ERR02 (byte 0x33) // ERR02 (byte 0x33)
'ERR02_bit0': 'P0301 — Misfire Cylinder 1', 'ERR02_bit0': 'P0301 — Misfire Cylinder 1',
'ERR02_bit1': 'P0302 — Misfire Cylinder 2', 'ERR02_bit1': 'P0302 — Misfire Cylinder 2',
'ERR02_bit2': 'P0303 — Misfire Cylinder 3', 'ERR02_bit2': 'P0303 — Misfire Cylinder 3',
'ERR02_bit3': 'P0304 — Misfire Cylinder 4', 'ERR02_bit3': 'P0304 — Misfire Cylinder 4',
'ERR02_bit4': 'P1259 — VTEC System Malfunction', 'ERR02_bit4': 'P1259 — VTEC System Malfunction',
'ERR02_bit5': 'P0420 — Catalyst System Low Efficiency', 'ERR02_bit5': 'P0420 — Catalyst System Low Efficiency',
'ERR02_bit6': 'P0401 — EGR Insufficient Flow', 'ERR02_bit6': 'P0401 — EGR Insufficient Flow',
'ERR02_bit7': 'P1456 — EVAP Leak (large) Fuel Tank Side', 'ERR02_bit7': 'P1456 — EVAP Leak (large) Fuel Tank Side',
// ERR03 (byte 0x34) // ERR03 (byte 0x34)
'ERR03_bit0': 'P1457 — EVAP Leak (large) Canister Side', 'ERR03_bit0': 'P1457 — EVAP Leak (large) Canister Side',
'ERR03_bit1': 'P0498 — EVAP Canister Vent Shut Valve', 'ERR03_bit1': 'P0498 — EVAP Canister Vent Shut Valve',
'ERR03_bit2': 'P0497 — EVAP Low Purge Flow', 'ERR03_bit2': 'P0497 — EVAP Low Purge Flow',
'ERR03_bit3': 'P0122 — TPS Sensor Low', 'ERR03_bit3': 'P0122 — TPS Sensor Low',
'ERR03_bit4': 'P0123 — TPS Sensor High', 'ERR03_bit4': 'P0123 — TPS Sensor High',
'ERR03_bit5': 'P0562 — Battery Voltage Low', 'ERR03_bit5': 'P0562 — Battery Voltage Low',
'ERR03_bit6': 'P0563 — Battery Voltage High', 'ERR03_bit6': 'P0563 — Battery Voltage High',
'ERR03_bit7': 'P1607 — PCM Internal Circuit', 'ERR03_bit7': 'P1607 — PCM Internal Circuit',
}; };
/// KPro DTC map (ERR00–ERR17, 18 bytes × 8 bits = 144 possible codes) /// KPro DTC map (ERR00–ERR17, 18 bytes × 8 bits = 144 possible codes)
const Map<String, String> kproDtcMap = { const Map<String, String> kproDtcMap = {
// ERR00 // ERR00
'ERR00_bit0': 'P0130 — O2 Sensor (front)', 'ERR00_bit0': 'P0130 — O2 Sensor (front)',
'ERR00_bit1': 'P0135 — O2 Sensor Heater (front)', 'ERR00_bit1': 'P0135 — O2 Sensor Heater (front)',
'ERR00_bit2': 'P0505 — Idle Air Control Valve', 'ERR00_bit2': 'P0505 — Idle Air Control Valve',
'ERR00_bit3': 'P0108 — MAP Sensor High', 'ERR00_bit3': 'P0108 — MAP Sensor High',
'ERR00_bit4': 'P0107 — MAP Sensor Low', 'ERR00_bit4': 'P0107 — MAP Sensor Low',
'ERR00_bit5': 'P0113 — IAT Sensor High', 'ERR00_bit5': 'P0113 — IAT Sensor High',
'ERR00_bit6': 'P0112 — IAT Sensor Low', 'ERR00_bit6': 'P0112 — IAT Sensor Low',
'ERR00_bit7': 'P0118 — ECT Sensor High', 'ERR00_bit7': 'P0118 — ECT Sensor High',
// ERR01 // ERR01
'ERR01_bit0': 'P0117 — ECT Sensor Low', 'ERR01_bit0': 'P0117 — ECT Sensor Low',
'ERR01_bit1': 'P0336 — CKP Sensor', 'ERR01_bit1': 'P0336 — CKP Sensor',
'ERR01_bit2': 'P0341 — CMP Sensor', 'ERR01_bit2': 'P0341 — CMP Sensor',
'ERR01_bit3': 'P1362 — TDC Sensor (No Signal)', 'ERR01_bit3': 'P1362 — TDC Sensor (No Signal)',
'ERR01_bit4': 'P1363 — TDC Sensor (No Signal 2)', 'ERR01_bit4': 'P1363 — TDC Sensor (No Signal 2)',
'ERR01_bit5': 'P0335 — CKP Sensor (No Signal)', 'ERR01_bit5': 'P0335 — CKP Sensor (No Signal)',
'ERR01_bit6': 'P1361 — TDC Sensor', 'ERR01_bit6': 'P1361 — TDC Sensor',
'ERR01_bit7': 'P0300 — Random Misfire', 'ERR01_bit7': 'P0300 — Random Misfire',
// ERR02 // ERR02
'ERR02_bit0': 'P0301 — Misfire Cylinder 1', 'ERR02_bit0': 'P0301 — Misfire Cylinder 1',
'ERR02_bit1': 'P0302 — Misfire Cylinder 2', 'ERR02_bit1': 'P0302 — Misfire Cylinder 2',
'ERR02_bit2': 'P0303 — Misfire Cylinder 3', 'ERR02_bit2': 'P0303 — Misfire Cylinder 3',
'ERR02_bit3': 'P0304 — Misfire Cylinder 4', 'ERR02_bit3': 'P0304 — Misfire Cylinder 4',
'ERR02_bit4': 'P1259 — VTEC System Malfunction', 'ERR02_bit4': 'P1259 — VTEC System Malfunction',
'ERR02_bit5': 'P0420 — Catalyst System Low Efficiency', 'ERR02_bit5': 'P0420 — Catalyst System Low Efficiency',
'ERR02_bit6': 'P0401 — EGR Insufficient Flow', 'ERR02_bit6': 'P0401 — EGR Insufficient Flow',
'ERR02_bit7': 'P1456 — EVAP Leak (large) Fuel Tank Side', 'ERR02_bit7': 'P1456 — EVAP Leak (large) Fuel Tank Side',
// ERR03 // ERR03
'ERR03_bit0': 'P1457 — EVAP Leak (large) Canister Side', 'ERR03_bit0': 'P1457 — EVAP Leak (large) Canister Side',
'ERR03_bit1': 'P0498 — EVAP Canister Vent Shut Valve', 'ERR03_bit1': 'P0498 — EVAP Canister Vent Shut Valve',
'ERR03_bit2': 'P0497 — EVAP Low Purge Flow', 'ERR03_bit2': 'P0497 — EVAP Low Purge Flow',
'ERR03_bit3': 'P0122 — TPS Sensor Low', 'ERR03_bit3': 'P0122 — TPS Sensor Low',
'ERR03_bit4': 'P0123 — TPS Sensor High', 'ERR03_bit4': 'P0123 — TPS Sensor High',
'ERR03_bit5': 'P0562 — Battery Voltage Low', 'ERR03_bit5': 'P0562 — Battery Voltage Low',
'ERR03_bit6': 'P0563 — Battery Voltage High', 'ERR03_bit6': 'P0563 — Battery Voltage High',
'ERR03_bit7': 'P1607 — PCM Internal Circuit', 'ERR03_bit7': 'P1607 — PCM Internal Circuit',
// ERR04 // ERR04
'ERR04_bit0': 'P0136 — O2 Sensor (rear)', 'ERR04_bit0': 'P0136 — O2 Sensor (rear)',
'ERR04_bit1': 'P0141 — O2 Sensor Heater (rear)', 'ERR04_bit1': 'P0141 — O2 Sensor Heater (rear)',
'ERR04_bit2': 'P1166 — Air Fuel Ratio (A/F) Sensor Heater', 'ERR04_bit2': 'P1166 — Air Fuel Ratio (A/F) Sensor Heater',
'ERR04_bit3': 'P1167 — Air Fuel Ratio (A/F) Sensor', 'ERR04_bit3': 'P1167 — Air Fuel Ratio (A/F) Sensor',
'ERR04_bit4': 'P2195 — A/F Sensor Signal Stuck Lean', 'ERR04_bit4': 'P2195 — A/F Sensor Signal Stuck Lean',
'ERR04_bit5': 'P2196 — A/F Sensor Signal Stuck Rich', 'ERR04_bit5': 'P2196 — A/F Sensor Signal Stuck Rich',
'ERR04_bit6': 'P2237 — A/F Sensor Pump Cell Current Input Low', 'ERR04_bit6': 'P2237 — A/F Sensor Pump Cell Current Input Low',
'ERR04_bit7': 'P2240 — A/F Sensor Pump Cell Current Input High', 'ERR04_bit7': 'P2240 — A/F Sensor Pump Cell Current Input High',
// ERR05 // ERR05
'ERR05_bit0': 'P2243 — A/F Sensor Reference Voltage Low', 'ERR05_bit0': 'P2243 — A/F Sensor Reference Voltage Low',
'ERR05_bit1': 'P2247 — A/F Sensor Reference Voltage High', 'ERR05_bit1': 'P2247 — A/F Sensor Reference Voltage High',
'ERR05_bit2': 'P2251 — A/F Sensor VS Cell Voltage Low', 'ERR05_bit2': 'P2251 — A/F Sensor VS Cell Voltage Low',
'ERR05_bit3': 'P2254 — A/F Sensor VS Cell Voltage High', 'ERR05_bit3': 'P2254 — A/F Sensor VS Cell Voltage High',
'ERR05_bit4': 'P0031 — O2 Sensor Heater Current Low (bank 1, sensor 1)', 'ERR05_bit4': 'P0031 — O2 Sensor Heater Current Low (bank 1, sensor 1)',
'ERR05_bit5': 'P0032 — O2 Sensor Heater Current High (bank 1, sensor 1)', 'ERR05_bit5': 'P0032 — O2 Sensor Heater Current High (bank 1, sensor 1)',
'ERR05_bit6': 'P0037 — O2 Sensor Heater Current Low (bank 1, sensor 2)', 'ERR05_bit6': 'P0037 — O2 Sensor Heater Current Low (bank 1, sensor 2)',
'ERR05_bit7': 'P0038 — O2 Sensor Heater Current High (bank 1, sensor 2)', 'ERR05_bit7': 'P0038 — O2 Sensor Heater Current High (bank 1, sensor 2)',
// ERR06 // ERR06
'ERR06_bit0': 'P0068 — MAP/MAF vs Throttle Position Correlation', 'ERR06_bit0': 'P0068 — MAP/MAF vs Throttle Position Correlation',
'ERR06_bit1': 'P0101 — MAF Sensor Range/Performance', 'ERR06_bit1': 'P0101 — MAF Sensor Range/Performance',
'ERR06_bit2': 'P0102 — MAF Sensor Low Input', 'ERR06_bit2': 'P0102 — MAF Sensor Low Input',
'ERR06_bit3': 'P0103 — MAF Sensor High Input', 'ERR06_bit3': 'P0103 — MAF Sensor High Input',
'ERR06_bit4': 'P0171 — System Lean (bank 1)', 'ERR06_bit4': 'P0171 — System Lean (bank 1)',
'ERR06_bit5': 'P0172 — System Rich (bank 1)', 'ERR06_bit5': 'P0172 — System Rich (bank 1)',
'ERR06_bit6': 'P0174 — System Lean (bank 2)', 'ERR06_bit6': 'P0174 — System Lean (bank 2)',
'ERR06_bit7': 'P0175 — System Rich (bank 2)', 'ERR06_bit7': 'P0175 — System Rich (bank 2)',
// ERR07 // ERR07
'ERR07_bit0': 'P0325 — Knock Sensor', 'ERR07_bit0': 'P0325 — Knock Sensor',
'ERR07_bit1': 'P0330 — Knock Sensor 2', 'ERR07_bit1': 'P0330 — Knock Sensor 2',
'ERR07_bit2': 'P0339 — CKP Sensor Intermittent', 'ERR07_bit2': 'P0339 — CKP Sensor Intermittent',
'ERR07_bit3': 'P0344 — CMP Sensor Intermittent', 'ERR07_bit3': 'P0344 — CMP Sensor Intermittent',
'ERR07_bit4': 'P0365 — CMP Sensor B Circuit', 'ERR07_bit4': 'P0365 — CMP Sensor B Circuit',
'ERR07_bit5': 'P0390 — CMP Sensor B Circuit (bank 2)', 'ERR07_bit5': 'P0390 — CMP Sensor B Circuit (bank 2)',
'ERR07_bit6': 'P0498 — EVAP Canister Vent Shut Valve Low', 'ERR07_bit6': 'P0498 — EVAP Canister Vent Shut Valve Low',
'ERR07_bit7': 'P0499 — EVAP Canister Vent Shut Valve High', 'ERR07_bit7': 'P0499 — EVAP Canister Vent Shut Valve High',
// ERR08 // ERR08
'ERR08_bit0': 'P0600 — Serial Communication Link', 'ERR08_bit0': 'P0600 — Serial Communication Link',
'ERR08_bit1': 'P0601 — Internal Control Module Memory', 'ERR08_bit1': 'P0601 — Internal Control Module Memory',
'ERR08_bit2': 'P0604 — Internal Control Module RAM', 'ERR08_bit2': 'P0604 — Internal Control Module RAM',
'ERR08_bit3': 'P0605 — Internal Control Module ROM', 'ERR08_bit3': 'P0605 — Internal Control Module ROM',
'ERR08_bit4': 'P0657 — Actuator Supply Voltage A Open', 'ERR08_bit4': 'P0657 — Actuator Supply Voltage A Open',
'ERR08_bit5': 'P0685 — ECM/PCM Power Relay Control Open', 'ERR08_bit5': 'P0685 — ECM/PCM Power Relay Control Open',
'ERR08_bit6': 'P0691 — Fan 1 Control Circuit Low', 'ERR08_bit6': 'P0691 — Fan 1 Control Circuit Low',
'ERR08_bit7': 'P0692 — Fan 1 Control Circuit High', 'ERR08_bit7': 'P0692 — Fan 1 Control Circuit High',
// ERR09 // ERR09
'ERR09_bit0': 'P0693 — Fan 2 Control Circuit Low', 'ERR09_bit0': 'P0693 — Fan 2 Control Circuit Low',
'ERR09_bit1': 'P0694 — Fan 2 Control Circuit High', 'ERR09_bit1': 'P0694 — Fan 2 Control Circuit High',
'ERR09_bit2': 'P1454 — EVAP Pressure Sensor Range/Performance', 'ERR09_bit2': 'P1454 — EVAP Pressure Sensor Range/Performance',
'ERR09_bit3': 'P1455 — EVAP Leak (small)', 'ERR09_bit3': 'P1455 — EVAP Leak (small)',
'ERR09_bit4': 'P1508 — IAC Valve Circuit Low', 'ERR09_bit4': 'P1508 — IAC Valve Circuit Low',
'ERR09_bit5': 'P1509 — IAC Valve Circuit High', 'ERR09_bit5': 'P1509 — IAC Valve Circuit High',
'ERR09_bit6': 'P1519 — Idle Air Control Valve Stuck', 'ERR09_bit6': 'P1519 — Idle Air Control Valve Stuck',
'ERR09_bit7': 'P1706 — A/T Range Switch Out Of Range', 'ERR09_bit7': 'P1706 — A/T Range Switch Out Of Range',
// ERR10 // ERR10
'ERR10_bit0': 'P2101 — Throttle Actuator Control Motor Range', 'ERR10_bit0': 'P2101 — Throttle Actuator Control Motor Range',
'ERR10_bit1': 'P2118 — Throttle Actuator Control Motor Current Range', 'ERR10_bit1': 'P2118 — Throttle Actuator Control Motor Current Range',
'ERR10_bit2': 'P2119 — Throttle Actuator Control Throttle Body Range', 'ERR10_bit2': 'P2119 — Throttle Actuator Control Throttle Body Range',
'ERR10_bit3': 'P2122 — Throttle/Pedal Position Sensor D Low Input', 'ERR10_bit3': 'P2122 — Throttle/Pedal Position Sensor D Low Input',
'ERR10_bit4': 'P2123 — Throttle/Pedal Position Sensor D High Input', 'ERR10_bit4': 'P2123 — Throttle/Pedal Position Sensor D High Input',
'ERR10_bit5': 'P2127 — Throttle/Pedal Position Sensor E Low Input', 'ERR10_bit5': 'P2127 — Throttle/Pedal Position Sensor E Low Input',
'ERR10_bit6': 'P2128 — Throttle/Pedal Position Sensor E High Input', 'ERR10_bit6': 'P2128 — Throttle/Pedal Position Sensor E High Input',
'ERR10_bit7': 'P2135 — Throttle/Pedal Position Sensor A/B Voltage Correlation', 'ERR10_bit7': 'P2135 — Throttle/Pedal Position Sensor A/B Voltage Correlation',
// ERR11 // ERR11
'ERR11_bit0': 'P2138 — Throttle/Pedal Position Sensor D/E Voltage Correlation', 'ERR11_bit0': 'P2138 — Throttle/Pedal Position Sensor D/E Voltage Correlation',
'ERR11_bit1': 'P2176 — Throttle Actuator Control System Idle Position Not Learned', 'ERR11_bit1': 'P2176 — Throttle Actuator Control System Idle Position Not Learned',
'ERR11_bit2': 'P2227 — Barometric Pressure Sensor A Circuit Range', 'ERR11_bit2': 'P2227 — Barometric Pressure Sensor A Circuit Range',
'ERR11_bit3': 'P2228 — Barometric Pressure Sensor A Circuit Low', 'ERR11_bit3': 'P2228 — Barometric Pressure Sensor A Circuit Low',
'ERR11_bit4': 'P2229 — Barometric Pressure Sensor A Circuit High', 'ERR11_bit4': 'P2229 — Barometric Pressure Sensor A Circuit High',
'ERR11_bit5': 'P2610 — ECM/PCM Internal Engine Off Timer', 'ERR11_bit5': 'P2610 — ECM/PCM Internal Engine Off Timer',
'ERR11_bit6': 'P2647 — VTEC System High (bank 1)', 'ERR11_bit6': 'P2647 — VTEC System High (bank 1)',
'ERR11_bit7': 'P2648 — VTEC System Low (bank 1)', 'ERR11_bit7': 'P2648 — VTEC System Low (bank 1)',
// ERR12 // ERR12
'ERR12_bit0': 'P2649 — VTEC System High (bank 2)', 'ERR12_bit0': 'P2649 — VTEC System High (bank 2)',
'ERR12_bit1': 'P2650 — VTEC System Low (bank 2)', 'ERR12_bit1': 'P2650 — VTEC System Low (bank 2)',
'ERR12_bit2': 'P3400 — VTEC System Stuck Off (bank 1)', 'ERR12_bit2': 'P3400 — VTEC System Stuck Off (bank 1)',
'ERR12_bit3': 'P3497 — VTEC System Stuck Off (bank 2)', 'ERR12_bit3': 'P3497 — VTEC System Stuck Off (bank 2)',
'ERR12_bit4': 'U0073 — Control Module Communication Bus Off', 'ERR12_bit4': 'U0073 — Control Module Communication Bus Off',
'ERR12_bit5': 'U0100 — Lost Communication With ECM/PCM A', 'ERR12_bit5': 'U0100 — Lost Communication With ECM/PCM A',
'ERR12_bit6': 'U0155 — Lost Communication With Instrument Panel Cluster', 'ERR12_bit6': 'U0155 — Lost Communication With Instrument Panel Cluster',
'ERR12_bit7': 'U0164 — Lost Communication With HVAC Control Module', 'ERR12_bit7': 'U0164 — Lost Communication With HVAC Control Module',
// ERR13 // ERR13
'ERR13_bit0': 'P0011 — Camshaft Position A — Timing Over-Advanced (bank 1)', 'ERR13_bit0': 'P0011 — Camshaft Position A — Timing Over-Advanced (bank 1)',
'ERR13_bit1': 'P0012 — Camshaft Position A — Timing Over-Retarded (bank 1)', 'ERR13_bit1': 'P0012 — Camshaft Position A — Timing Over-Retarded (bank 1)',
'ERR13_bit2': 'P0021 — Camshaft Position B — Timing Over-Advanced (bank 1)', 'ERR13_bit2': 'P0021 — Camshaft Position B — Timing Over-Advanced (bank 1)',
'ERR13_bit3': 'P0022 — Camshaft Position B — Timing Over-Retarded (bank 1)', 'ERR13_bit3': 'P0022 — Camshaft Position B — Timing Over-Retarded (bank 1)',
'ERR13_bit4': 'P0340 — CMP Sensor A Circuit (bank 1)', 'ERR13_bit4': 'P0340 — CMP Sensor A Circuit (bank 1)',
'ERR13_bit5': 'P0345 — CMP Sensor A Circuit (bank 2)', 'ERR13_bit5': 'P0345 — CMP Sensor A Circuit (bank 2)',
'ERR13_bit6': 'P0366 — CMP Sensor B Circuit Range/Performance (bank 1)', 'ERR13_bit6': 'P0366 — CMP Sensor B Circuit Range/Performance (bank 1)',
'ERR13_bit7': 'P0391 — CMP Sensor B Circuit Range/Performance (bank 2)', 'ERR13_bit7': 'P0391 — CMP Sensor B Circuit Range/Performance (bank 2)',
// ERR14 // ERR14
'ERR14_bit0': 'P0016 — Crankshaft/Camshaft Position Correlation (bank 1, sensor A)', 'ERR14_bit0': 'P0016 — Crankshaft/Camshaft Position Correlation (bank 1, sensor A)',
'ERR14_bit1': 'P0017 — Crankshaft/Camshaft Position Correlation (bank 1, sensor B)', 'ERR14_bit1': 'P0017 — Crankshaft/Camshaft Position Correlation (bank 1, sensor B)',
'ERR14_bit2': 'P0018 — Crankshaft/Camshaft Position Correlation (bank 2, sensor A)', 'ERR14_bit2': 'P0018 — Crankshaft/Camshaft Position Correlation (bank 2, sensor A)',
'ERR14_bit3': 'P0019 — Crankshaft/Camshaft Position Correlation (bank 2, sensor B)', 'ERR14_bit3': 'P0019 — Crankshaft/Camshaft Position Correlation (bank 2, sensor B)',
'ERR14_bit4': 'P0008 — Engine Position System (bank 1)', 'ERR14_bit4': 'P0008 — Engine Position System (bank 1)',
'ERR14_bit5': 'P0009 — Engine Position System (bank 2)', 'ERR14_bit5': 'P0009 — Engine Position System (bank 2)',
'ERR14_bit6': 'P0014 — Camshaft Position B — Timing Over-Advanced (bank 1)', 'ERR14_bit6': 'P0014 — Camshaft Position B — Timing Over-Advanced (bank 1)',
'ERR14_bit7': 'P0024 — Camshaft Position B — Timing Over-Advanced (bank 2)', 'ERR14_bit7': 'P0024 — Camshaft Position B — Timing Over-Advanced (bank 2)',
// ERR15 // ERR15
'ERR15_bit0': 'P0087 — Fuel Rail/System Pressure Too Low', 'ERR15_bit0': 'P0087 — Fuel Rail/System Pressure Too Low',
'ERR15_bit1': 'P0088 — Fuel Rail/System Pressure Too High', 'ERR15_bit1': 'P0088 — Fuel Rail/System Pressure Too High',
'ERR15_bit2': 'P0089 — Fuel Pressure Regulator Performance', 'ERR15_bit2': 'P0089 — Fuel Pressure Regulator Performance',
'ERR15_bit3': 'P0090 — Fuel Pressure Regulator Control Circuit', 'ERR15_bit3': 'P0090 — Fuel Pressure Regulator Control Circuit',
'ERR15_bit4': 'P0091 — Fuel Pressure Regulator Control Circuit Low', 'ERR15_bit4': 'P0091 — Fuel Pressure Regulator Control Circuit Low',
'ERR15_bit5': 'P0092 — Fuel Pressure Regulator Control Circuit High', 'ERR15_bit5': 'P0092 — Fuel Pressure Regulator Control Circuit High',
'ERR15_bit6': 'P0201 — Injector Circuit/Open Cylinder 1', 'ERR15_bit6': 'P0201 — Injector Circuit/Open Cylinder 1',
'ERR15_bit7': 'P0202 — Injector Circuit/Open Cylinder 2', 'ERR15_bit7': 'P0202 — Injector Circuit/Open Cylinder 2',
// ERR16 // ERR16
'ERR16_bit0': 'P0203 — Injector Circuit/Open Cylinder 3', 'ERR16_bit0': 'P0203 — Injector Circuit/Open Cylinder 3',
'ERR16_bit1': 'P0204 — Injector Circuit/Open Cylinder 4', 'ERR16_bit1': 'P0204 — Injector Circuit/Open Cylinder 4',
'ERR16_bit2': 'P0261 — Injector Circuit Low Cylinder 1', 'ERR16_bit2': 'P0261 — Injector Circuit Low Cylinder 1',
'ERR16_bit3': 'P0264 — Injector Circuit Low Cylinder 2', 'ERR16_bit3': 'P0264 — Injector Circuit Low Cylinder 2',
'ERR16_bit4': 'P0267 — Injector Circuit Low Cylinder 3', 'ERR16_bit4': 'P0267 — Injector Circuit Low Cylinder 3',
'ERR16_bit5': 'P0270 — Injector Circuit Low Cylinder 4', 'ERR16_bit5': 'P0270 — Injector Circuit Low Cylinder 4',
'ERR16_bit6': 'P0262 — Injector Circuit High Cylinder 1', 'ERR16_bit6': 'P0262 — Injector Circuit High Cylinder 1',
'ERR16_bit7': 'P0265 — Injector Circuit High Cylinder 2', 'ERR16_bit7': 'P0265 — Injector Circuit High Cylinder 2',
// ERR17 // ERR17
'ERR17_bit0': 'P0268 — Injector Circuit High Cylinder 3', 'ERR17_bit0': 'P0268 — Injector Circuit High Cylinder 3',
'ERR17_bit1': 'P0271 — Injector Circuit High Cylinder 4', 'ERR17_bit1': 'P0271 — Injector Circuit High Cylinder 4',
'ERR17_bit2': 'P0351 — Ignition Coil A Primary/Secondary Circuit', 'ERR17_bit2': 'P0351 — Ignition Coil A Primary/Secondary Circuit',
'ERR17_bit3': 'P0352 — Ignition Coil B Primary/Secondary Circuit', 'ERR17_bit3': 'P0352 — Ignition Coil B Primary/Secondary Circuit',
'ERR17_bit4': 'P0353 — Ignition Coil C Primary/Secondary Circuit', 'ERR17_bit4': 'P0353 — Ignition Coil C Primary/Secondary Circuit',
'ERR17_bit5': 'P0354 — Ignition Coil D Primary/Secondary Circuit', 'ERR17_bit5': 'P0354 — Ignition Coil D Primary/Secondary Circuit',
'ERR17_bit6': 'P0627 — Fuel Pump Control Circuit Open', 'ERR17_bit6': 'P0627 — Fuel Pump Control Circuit Open',
'ERR17_bit7': 'P0628 — Fuel Pump Control Circuit Low', 'ERR17_bit7': 'P0628 — Fuel Pump Control Circuit Low',
}; };
/// Returns a list of active DTC descriptions from the given raw error bytes /// Returns a list of active DTC descriptions from the given raw error bytes
/// using the provided map. /// using the provided map.
List<String> decodeDtcs(List<int> errBytes, Map<String, String> dtcMap) { List<String> decodeDtcs(List<int> errBytes, Map<String, String> dtcMap) {
final List<String> active = []; final List<String> active = [];
for (int byteIdx = 0; byteIdx < errBytes.length; byteIdx++) { for (int byteIdx = 0; byteIdx < errBytes.length; byteIdx++) {
final int b = errBytes[byteIdx]; final int b = errBytes[byteIdx];
if (b == 0) continue; if (b == 0) continue;
for (int bit = 0; bit < 8; bit++) { for (int bit = 0; bit < 8; bit++) {
if ((b >> bit) & 1 == 1) { if ((b >> bit) & 1 == 1) {
final String key = 'ERR${byteIdx.toString().padLeft(2, '0')}_bit$bit'; final String key = 'ERR${byteIdx.toString().padLeft(2, '0')}_bit$bit';
final String? desc = dtcMap[key]; final String? desc = dtcMap[key];
if (desc != null) active.add(desc); if (desc != null) active.add(desc);
} }
} }
} }
return active; return active;
} }

View File

@ -1,144 +1,147 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'sensor_state.dart'; import 'sensor_state.dart';
import 'temp_table.dart'; import 'temp_table.dart';
import 'neg8.dart'; import 'neg8.dart';
/// Parses a 128-byte KPro ECU response frame into a [SensorState]. /// Parses a 128-byte KPro ECU response frame into a [SensorState].
/// Throws [ArgumentError] if frame length is wrong or NEG8 checksum fails. /// Throws [ArgumentError] if frame length is wrong or NEG8 checksum fails.
SensorState parseKPro(Uint8List frame) { SensorState parseKPro(Uint8List frame) {
if (frame.length != 128) { if (frame.length != 128) {
throw ArgumentError('KPro frame must be 128 bytes, got ${frame.length}'); throw ArgumentError('KPro frame must be 128 bytes, got ${frame.length}');
} }
if (!validateFrame(frame)) { if (!validateFrame(frame)) {
throw ArgumentError('KPro frame NEG8 checksum failed'); throw ArgumentError('KPro frame NEG8 checksum failed');
} }
// RPM: bytes 2..3 — raw / 4 // RPM: bytes 2..3 — raw / 4
final int rpmRaw = _readUint16BE(frame, 2); final int rpmRaw = _readUint16BE(frame, 2);
final double rpm = rpmRaw / 4.0; final double rpm = rpmRaw / 4.0;
// VSS: byte 4 direct km/h // VSS: byte 4 direct km/h
final double vss = frame[4].toDouble(); final double vss = frame[4].toDouble();
// TPS: byte 5 direct % // TPS: byte 5 direct %
final double tps = frame[5].toDouble(); final double tps = frame[5].toDouble();
// MAP: bytes 6 (MAP1) and 33..34 (MAP2 L+H) // MAP: bytes 6 (MAP1) and 33..34 (MAP2 L+H)
final int map1Raw = frame[6]; final int map1Raw = frame[6];
final int map2Raw = _readUint16BE(frame, 33); final int map2Raw = _readUint16BE(frame, 33);
final double map = (map2Raw != 0) ? map2Raw / 10.0 : map1Raw.toDouble(); final double map = (map2Raw != 0) ? map2Raw / 10.0 : map1Raw.toDouble();
// INJ: byte 11 raw // INJ: byte 11 raw
final double inj = frame[11].toDouble(); final double inj = frame[11].toDouble();
// IGN: byte 13 raw degrees // IGN: byte 13 raw degrees
final double ign = frame[13].toDouble(); final double ign = frame[13].toDouble();
// O2: byte 14 raw // O2: byte 14 raw
final double o2 = frame[14].toDouble(); final double o2 = frame[14].toDouble();
// KRtrd: byte 21 // KRtrd: byte 21
final int krtrd = frame[21]; final int krtrd = frame[21];
// SW bitmaps // SW bitmaps
final int sw1 = frame[31]; final int sw1 = frame[31];
final int sw2 = frame[32]; final int sw2 = frame[32];
// ECT: byte 49. Table is tempXlt[raw] - 40, not +40 — see s300_parser.dart // ECT: byte 49. Table is tempXlt[raw] - 40, not +40 — see s300_parser.dart
// for the reasoning (the declared -40..150 degC sensor range is only // for the reasoning (the declared -40..150 degC sensor range is only
// reachable with subtraction). // reachable with subtraction).
final double ect = (tempXlt[frame[49]] - 40).toDouble(); final double ect = (tempXlt[frame[49]] - 40).toDouble();
// IAT: byte 50 // IAT: byte 50
final double iat = (tempXlt[frame[50]] - 40).toDouble(); final double iat = (tempXlt[frame[50]] - 40).toDouble();
// PA: byte 51 // PA: byte 51
final double pa = frame[51].toDouble(); final double pa = frame[51].toDouble();
// BAT: byte 52 — raw / 10 // BAT: byte 52 — raw / 10
final double bat = frame[52] / 10.0; final double bat = frame[52] / 10.0;
// STRIM: byte 32 (SW2 shares offset 32, STRIM is byte 20 per spec offset +20) // STRIM: byte 32 (SW2 shares offset 32, STRIM is byte 20 per spec offset +20)
final double strim = frame[20].toDouble(); final double strim = frame[20].toDouble();
// LTRIM — not explicitly listed in KPro; use 0 // LTRIM — not explicitly listed in KPro; use 0
const double ltrim = 0.0; const double ltrim = 0.0;
// ETH — not in KPro spec; use 0 // ETH — not in KPro spec; use 0
const double eth = 0.0; const double eth = 0.0;
// AFR: byte 25 (AFCMD at offset +19, then raw) // AFR: byte 25 (AFCMD at offset +19, then raw)
final double afr = frame[25].toDouble(); final double afr = frame[25].toDouble();
// ERR bytes: 18 bytes starting at offset 0x3E = 62 // ERR bytes: 18 bytes starting at offset 0x3E = 62
final List<int> errBytes = List<int>.generate(18, (i) => frame[62 + i]); final List<int> errBytes = List<int>.generate(18, (i) => frame[62 + i]);
// AIN0–AIN7: bytes 0x52..0x61 (offset 82..97, uint16 BE each) // AIN0–AIN7: bytes 0x52..0x61 (offset 82..97, uint16 BE each)
final double ain0 = _readUint16BE(frame, 82).toDouble(); final double ain0 = _readUint16BE(frame, 82).toDouble();
final double ain1 = _readUint16BE(frame, 84).toDouble(); final double ain1 = _readUint16BE(frame, 84).toDouble();
final double ain2 = _readUint16BE(frame, 86).toDouble(); final double ain2 = _readUint16BE(frame, 86).toDouble();
final double ain3 = _readUint16BE(frame, 88).toDouble(); final double ain3 = _readUint16BE(frame, 88).toDouble();
final double ain4 = _readUint16BE(frame, 90).toDouble(); final double ain4 = _readUint16BE(frame, 90).toDouble();
final double ain5 = _readUint16BE(frame, 92).toDouble(); final double ain5 = _readUint16BE(frame, 92).toDouble();
final double ain6 = _readUint16BE(frame, 94).toDouble(); final double ain6 = _readUint16BE(frame, 94).toDouble();
final double ain7 = _readUint16BE(frame, 96).toDouble(); final double ain7 = _readUint16BE(frame, 96).toDouble();
// Gear — not in KPro frame, default 0 // Gear — not in KPro frame, default 0
const int gear = 0; const int gear = 0;
// Flags // Flags
// SW2 bit2 = MIL // SW2 bit2 = MIL
final bool mil = (sw2 & 0x04) != 0; final bool mil = (sw2 & 0x04) != 0;
// SW1 bit6 = FLR (fuel cut relay) // SW1 bit6 = FLR. Unverified: never live-tested for KPro, and the
final bool fuelCut = (sw1 & 0x40) != 0; // equivalent S300 bit turned out to be fuel PUMP relay status, not fuel
// SW1 bit0 = FANC // cut, despite an initially plausible-looking name — treat "FLR" the
final bool fanOut = (sw1 & 0x01) != 0; // same way until confirmed.
// SW2 bit1..0 = VTS (VTEC) final bool fuelPump = (sw1 & 0x40) != 0;
final bool vtec = (sw2 & 0x03) != 0; // SW1 bit0 = FANC
// Knock = KRtrd > 0 final bool fanOut = (sw1 & 0x01) != 0;
final bool knock = krtrd > 0; // SW2 bit1..0 = VTS (VTEC)
// Rev limit — not directly mapped in KPro SW; false final bool vtec = (sw2 & 0x03) != 0;
const bool revLimit = false; // Knock = KRtrd > 0
// Launch — not in KPro SW; false final bool knock = krtrd > 0;
const bool launch = false; // Rev limit — not directly mapped in KPro SW; false
const bool revLimit = false;
return SensorState( // Launch — not in KPro SW; false
rpm: rpm, const bool launch = false;
vss: vss,
map: map, return SensorState(
tps: tps, rpm: rpm,
inj: inj, vss: vss,
ign: ign, map: map,
ect: ect, tps: tps,
iat: iat, inj: inj,
bat: bat, ign: ign,
o2: o2, ect: ect,
gear: gear, iat: iat,
eth: eth, bat: bat,
pa: pa, o2: o2,
afr: afr, gear: gear,
strim: strim, eth: eth,
ltrim: ltrim, pa: pa,
ain0: ain0, afr: afr,
ain1: ain1, strim: strim,
ain2: ain2, ltrim: ltrim,
ain3: ain3, ain0: ain0,
ain4: ain4, ain1: ain1,
ain5: ain5, ain2: ain2,
ain6: ain6, ain3: ain3,
ain7: ain7, ain4: ain4,
mil: mil, ain5: ain5,
fuelCut: fuelCut, ain6: ain6,
fanOut: fanOut, ain7: ain7,
vtec: vtec, mil: mil,
knock: knock, fuelPump: fuelPump,
revLimit: revLimit, fanOut: fanOut,
launch: launch, vtec: vtec,
errBytes: errBytes, knock: knock,
timestamp: DateTime.now(), revLimit: revLimit,
); launch: launch,
} errBytes: errBytes,
timestamp: DateTime.now(),
int _readUint16BE(Uint8List frame, int offset) { );
return (frame[offset] << 8) | frame[offset + 1]; }
}
int _readUint16BE(Uint8List frame, int offset) {
return (frame[offset] << 8) | frame[offset + 1];
}

View File

@ -1,22 +1,22 @@
import 'dart:typed_data'; import 'dart:typed_data';
/// Calculates the NEG8 checksum for a buffer. /// Calculates the NEG8 checksum for a buffer.
/// NEG8: start at 0, subtract each byte, keep lower 8 bits. /// NEG8: start at 0, subtract each byte, keep lower 8 bits.
/// The result should match the last byte of a valid frame. /// The result should match the last byte of a valid frame.
int calculateNeg8(Uint8List buf) { int calculateNeg8(Uint8List buf) {
int neg8 = 0; int neg8 = 0;
for (int i = 0; i < buf.length; i++) { for (int i = 0; i < buf.length; i++) {
neg8 = (neg8 - buf[i]) & 0xFF; neg8 = (neg8 - buf[i]) & 0xFF;
} }
return neg8; return neg8;
} }
/// Returns true if the frame's last byte equals the NEG8 of all preceding bytes. /// Returns true if the frame's last byte equals the NEG8 of all preceding bytes.
bool validateFrame(Uint8List frame) { bool validateFrame(Uint8List frame) {
if (frame.isEmpty) return false; if (frame.isEmpty) return false;
int neg8 = 0; int neg8 = 0;
for (int i = 0; i < frame.length - 1; i++) { for (int i = 0; i < frame.length - 1; i++) {
neg8 = (neg8 - frame[i]) & 0xFF; neg8 = (neg8 - frame[i]) & 0xFF;
} }
return neg8 == frame[frame.length - 1]; return neg8 == frame[frame.length - 1];
} }

View File

@ -1,165 +1,237 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'sensor_state.dart'; import 'sensor_state.dart';
import 'temp_table.dart'; import 'temp_table.dart';
import 'neg8.dart'; import 'neg8.dart';
/// Parses a 128-byte S300 ECU response frame into a [SensorState]. /// True when bytes 0-16 of the frame (header, RPM, VSS, MAP, TPS, INJ, IGN,
/// Throws [ArgumentError] if frame length is wrong, or if [validateChecksum] /// the knock byte, and O2) match a fixed pattern instead of a real reading,
/// is true and the NEG8 checksum fails. /// with those fields pinned to fixed constants regardless of the true
SensorState parseS300(Uint8List frame, {bool validateChecksum = true}) { /// physical signal.
if (frame.length != 128) { ///
throw ArgumentError('S300 frame must be 128 bytes, got ${frame.length}'); /// The frozen region is now known to extend well past byte 16. Bytes 32-36
} /// (gear, ECT, IAT, BAT at their current offsets) show the exact same fixed
if (validateChecksum && !validateFrame(frame)) { /// byte sequence (`93 a0 a0 dc 7d`) across dozens of unrelated live captures
throw ArgumentError('S300 frame NEG8 checksum failed'); /// throughout this project. SW2's flags (fuelPump, mil, vtec, fanOut's ALTC
} /// bit) were also seen going wrong during an idle period where nothing
/// changed on the bench but the rest of the frame was also frozen — the
// RPM: bytes +3..4, little-endian, use directly. Confirmed against a live /// earlier belief that SW2 stays live (based on an RPM sweep showing
// capture: LE @ offset 3 matched a reference tool's RPM exactly, while /// fuelPump correlating with real changes) only holds while the frame
// REQUIREMENTS.md's blanket "all multi-byte values are Big Endian" claim /// *isn't* templated; during a template frame it goes stale like everything
// did not hold for this field on real hardware. /// else tested so far. SW1 itself was never actually confirmed for template
final double rpm = _readUint16LE(frame, 3).toDouble(); /// frames specifically — the "matches USB exactly" validation that seemed
/// to support "bytes 17+ are live" was checked against a non-template frame.
// VSS: bytes +5..6, little-endian (confirmed against a live capture). /// At this point, don't assume any field is live during a template frame
final int vssRaw = _readUint16LE(frame, 5); /// unless it's been individually verified that way — the safer default is
final double vss = /// that the whole frame is templated, with only a shrinking set of
(vssRaw < 893 || vssRaw == 0xFFFF) ? 0.0 : 228480.0 / vssRaw; /// individually-confirmed-live fields as exceptions (currently: none
/// confirmed with certainty — treat the entire byte-17+ region as suspect
// MAP: bytes +7..8, little-endian (confirmed against a live capture — the /// until proven otherwise field by field).
// big-endian read produced multi-thousand-kPa values, physically ///
// impossible for a 0-300 kPa sensor). /// Callers should hold the last known-good values for every field sourced
final int mapRaw = _readUint16LE(frame, 7); /// from the frozen span (bytes 0-16, gear/ECT/IAT/BAT, and the SW2 flags)
final double map = mapRaw / 10.0; /// and decode only what's individually confirmed live.
bool isS300TemplateFrame(Uint8List frame) {
// TPS: byte +9. Linear 0-255 -> 0-100%, confirmed against a live capture if (frame.length != 128) return false;
// (raw 127 matched a reference tool's ~50% reading; the spec's
// "raw*51/46" formula produced impossible >100% values on the same byte). return frame[0] == 0x1B &&
final int tpsRaw = frame[9]; frame[1] == 0x00 &&
final double tps = (tpsRaw * 100.0 / 255.0).clamp(0.0, 100.0); frame[2] == 0x14 &&
frame[3] == 0x00 &&
// INJ: bytes +10..11, BE, raw ms. frame[4] == 0x00 &&
final double inj = _readUint16BE(frame, 10).toDouble(); frame[5] == 0xFF &&
frame[6] == 0xFF &&
// IGN: byte +12. frame[7] == 0xF2 &&
final double ign = (frame[12] + 120) / 2.0; frame[8] == 0x03 &&
frame[9] == 0x18 &&
// O2: byte +16, raw. frame[10] == 0x00 &&
final double o2 = frame[16].toDouble(); frame[11] == 0x00 &&
frame[12] == 0x10 &&
// SW bitmaps frame[13] == 0x00 &&
final int sw1 = frame[0x11]; // +17 frame[14] == 0x00 &&
final int sw2 = frame[0x12]; // +18 frame[15] == 0x00 &&
final int sw3 = frame[0x13]; // +19 frame[16] == 0xC4;
final int sw5 = frame[0x43]; // +43 }
// Gear: decimal offset 27 (confirmed against a live capture — hex 0x27/39 /// Parses a 128-byte S300 ECU response frame into a [SensorState].
// read 0x00 there; decimal 27 matched a reference tool's gear exactly). /// Throws [ArgumentError] if frame length is wrong, or if [validateChecksum]
final int gear = frame[27]; /// is true and the NEG8 checksum fails.
SensorState parseS300(Uint8List frame, {bool validateChecksum = true}) {
// Strim: +29 hex = 41 decimal if (frame.length != 128) {
final double strim = frame[0x29].toDouble(); throw ArgumentError('S300 frame must be 128 bytes, got ${frame.length}');
}
// Ltrim: +2B hex = 43 decimal if (validateChecksum && !validateFrame(frame)) {
final double ltrim = frame[0x2B].toDouble(); throw ArgumentError('S300 frame NEG8 checksum failed');
}
// PA: +2C hex = 44 decimal
final double pa = frame[0x2C].toDouble(); // RPM: bytes +3..4, little-endian, use directly. Confirmed against a live
// capture: LE @ offset 3 matched a reference tool's RPM exactly, while
// ECT: +2D hex = 45 decimal. Table is tempXlt[raw] - 40, not +40: the // REQUIREMENTS.md's blanket "all multi-byte values are Big Endian" claim
// spec's own sensor range (section 3: ECT/IAT -40..150 degC) is only // did not hold for this field on real hardware.
// reachable with subtraction, since tempXlt entries span 0..190. The final double rpm = _readUint16LE(frame, 3).toDouble();
// written "+40" formula can only ever produce 40..230 degC, which
// contradicts that declared range and explains the ~80 degC-too-hot // VSS: bytes +5..6, little-endian (confirmed against a live capture).
// readings seen against real hardware. final int vssRaw = _readUint16LE(frame, 5);
final double ect = (tempXlt[frame[0x2D]] - 40).toDouble(); final double vss =
(vssRaw < 893 || vssRaw == 0xFFFF) ? 0.0 : 228480.0 / vssRaw;
// IAT: +2E hex = 46 decimal
final double iat = (tempXlt[frame[0x2E]] - 40).toDouble(); // MAP: bytes +7..8, little-endian (confirmed against a live capture — the
// big-endian read produced multi-thousand-kPa values, physically
// BAT: +30 hex = 48 decimal // impossible for a 0-300 kPa sensor).
final double bat = frame[0x30] * 26.0 / 270.0; final int mapRaw = _readUint16LE(frame, 7);
final double map = mapRaw / 10.0;
// ERR bytes: +31..+34 hex = 49..52 decimal
final List<int> errBytes = [ // TPS: byte +9. Linear 0-255 -> 0-100%, confirmed against a live capture
frame[0x31], // (raw 127 matched a reference tool's ~50% reading; the spec's
frame[0x32], // "raw*51/46" formula produced impossible >100% values on the same byte).
frame[0x33], final int tpsRaw = frame[9];
frame[0x34], final double tps = (tpsRaw * 100.0 / 255.0).clamp(0.0, 100.0);
];
// INJ: bytes +10..11, BE, raw ms.
// Eth: +38 hex = 56 decimal final double inj = _readUint16BE(frame, 10).toDouble();
final double eth = frame[0x38].toDouble();
// IGN: byte +12.
// AFR: spec overlaps +34 with ERR03; reuse that byte per the spec's own table. final double ign = (frame[12] + 120) / 2.0;
final double afr = frame[0x34].toDouble();
// O2: byte +16, raw.
// AIN0–AIN7: decimal offsets 82..97 (uint16 BE each) final double o2 = frame[16].toDouble();
final double ain0 = _readUint16BE(frame, 82).toDouble();
final double ain1 = _readUint16BE(frame, 84).toDouble(); // SW bitmaps
final double ain2 = _readUint16BE(frame, 86).toDouble(); final int sw1 = frame[0x11]; // +17
final double ain3 = _readUint16BE(frame, 88).toDouble(); final int sw2 = frame[0x12]; // +18
final double ain4 = _readUint16BE(frame, 90).toDouble(); final int sw3 = frame[0x13]; // +19
final double ain5 = _readUint16BE(frame, 92).toDouble(); final int sw5 = frame[0x43]; // +43
final double ain6 = _readUint16BE(frame, 94).toDouble();
final double ain7 = _readUint16BE(frame, 96).toDouble(); // Gear: decimal offset 27 (confirmed against a live capture — hex 0x27/39
// read 0x00 there; decimal 27 matched a reference tool's gear exactly).
// Flags final int gear = frame[27];
// SW1 bit3 = REVL
final bool revLimit = (sw1 & 0x08) != 0; // Strim: +29 hex = 41 decimal
// SW2 bit5 = MIL final double strim = frame[0x29].toDouble();
final bool mil = (sw2 & 0x20) != 0;
// SW2 bit2 = Fuel (cut) // Ltrim: +2B hex = 43 decimal
final bool fuelCut = (sw2 & 0x04) != 0; final double ltrim = frame[0x2B].toDouble();
// SW2 bit7 = ALTC → fanOut; also SW5 bit0 = FANC
final bool fanOut = (sw2 & 0x80) != 0 || (sw5 & 0x01) != 0; // PA: +2C hex = 44 decimal. Unconfirmed — a live capture's closest
// SW2 bit1..0 = VTS (VTEC solenoid — nonzero = active) // candidate (byte 50, decimal) was off by ~3 kPa from the reference
final bool vtec = (sw2 & 0x03) != 0; // tool's 98.7 kPa reading, not a clean enough match to move to yet.
// Knock = KRtrd > 0 (byte 13) final double pa = frame[0x2C].toDouble();
final bool knock = frame[13] > 0;
// SW3 bit7 = LnchC // ECT: decimal offset 33. Confirmed via live capture matching the
final bool launch = (sw3 & 0x80) != 0; // reference tool exactly: raw=0xD2(210), tempXlt[210]=38, 38-40=-2°C =
// 28.4°F, matching the reference tool's 28.4°F reading precisely at the
return SensorState( // same instant (RPM cross-checked to confirm same-moment capture). The
rpm: rpm, // previous offset (+2D hex = 45 decimal) gave physically impossible
vss: vss, // values (~210°F) against the same live data. Table is tempXlt[raw]-40,
map: map, // not +40: the spec's own sensor range (section 3: ECT/IAT -40..150 degC)
tps: tps, // is only reachable with subtraction, since tempXlt entries span 0..190.
inj: inj, final double ect = (tempXlt[frame[33]] - 40).toDouble();
ign: ign,
ect: ect, // IAT: decimal offset 34, immediately after ECT. Confirmed via the same
iat: iat, // live capture: raw=0xCA(202), tempXlt[202]=43, 43-40=3°C=37.4°F vs the
bat: bat, // reference tool's 36.1°F — within ~1.3°F, consistent with table
o2: o2, // granularity. The previous offset (+2E hex = 46 decimal) was part of the
gear: gear, // same wrong-offset block as the old ECT location.
eth: eth, final double iat = (tempXlt[frame[34]] - 40).toDouble();
pa: pa,
afr: afr, // BAT: decimal offset 36. Confirmed via live capture: raw=0x79(121),
strim: strim, // 121*26/270=11.66V vs the reference tool's 11.75V — within one raw unit
ltrim: ltrim, // of quantization (26/270 V per LSB ≈ 0.096V). This is also where BAT
ain0: ain0, // lived before an earlier, unverified "fix" in this project's history
ain1: ain1, // moved it to +30 hex = 48 decimal — that fix was wrong; this restores
ain2: ain2, // the original, now confirmed-correct, offset.
ain3: ain3, final double bat = frame[36] * 26.0 / 270.0;
ain4: ain4,
ain5: ain5, // ERR bytes: +31..+34 hex = 49..52 decimal
ain6: ain6, final List<int> errBytes = [
ain7: ain7, frame[0x31],
mil: mil, frame[0x32],
fuelCut: fuelCut, frame[0x33],
fanOut: fanOut, frame[0x34],
vtec: vtec, ];
knock: knock,
revLimit: revLimit, // Eth: +38 hex = 56 decimal
launch: launch, final double eth = frame[0x38].toDouble();
errBytes: errBytes,
timestamp: DateTime.now(), // AFR: spec overlaps +34 with ERR03; reuse that byte per the spec's own table.
); final double afr = frame[0x34].toDouble();
}
// AIN0–AIN7: decimal offsets 82..97 (uint16 BE each)
int _readUint16BE(Uint8List frame, int offset) { final double ain0 = _readUint16BE(frame, 82).toDouble();
return (frame[offset] << 8) | frame[offset + 1]; final double ain1 = _readUint16BE(frame, 84).toDouble();
} final double ain2 = _readUint16BE(frame, 86).toDouble();
final double ain3 = _readUint16BE(frame, 88).toDouble();
int _readUint16LE(Uint8List frame, int offset) { final double ain4 = _readUint16BE(frame, 90).toDouble();
return frame[offset] | (frame[offset + 1] << 8); final double ain5 = _readUint16BE(frame, 92).toDouble();
} final double ain6 = _readUint16BE(frame, 94).toDouble();
final double ain7 = _readUint16BE(frame, 96).toDouble();
// Flags
// SW1 bit3 = REVL
final bool revLimit = (sw1 & 0x08) != 0;
// SW2 bit5 = MIL
final bool mil = (sw2 & 0x20) != 0;
// SW2 bit2 = Fuel — this is the fuel PUMP relay status, not fuel cut (the
// reference tool's own channel list labels it as such). There's no
// separate "fuel cut" bit identified in this frame — REVL/IGNC/BstC/
// LnchC/ShftC are the actual cut-type flags this ECU exposes.
//
// Active-LOW: confirmed via an extended live session (RPM swept 400-3842,
// continuously above the ~300 threshold where the pump should be on) that
// this bit read 0 the entire time — and separately confirmed live that the
// app showed "on" below ~200 rpm (pump should be off) and "off" above the
// threshold (pump should be on), i.e. exactly inverted from a `!= 0` read.
// Bit clear (0) means the pump relay is energized/on; bit set (1) means off.
final bool fuelPump = (sw2 & 0x04) == 0;
// SW2 bit7 = ALTC → fanOut; also SW5 bit0 = FANC
final bool fanOut = (sw2 & 0x80) != 0 || (sw5 & 0x01) != 0;
// SW2 bit1..0 = VTS (VTEC solenoid — nonzero = active)
final bool vtec = (sw2 & 0x03) != 0;
// Knock = KRtrd > 0 (byte 13)
final bool knock = frame[13] > 0;
// SW3 bit7 = LnchC
final bool launch = (sw3 & 0x80) != 0;
return SensorState(
rpm: rpm,
vss: vss,
map: map,
tps: tps,
inj: inj,
ign: ign,
ect: ect,
iat: iat,
bat: bat,
o2: o2,
gear: gear,
eth: eth,
pa: pa,
afr: afr,
strim: strim,
ltrim: ltrim,
ain0: ain0,
ain1: ain1,
ain2: ain2,
ain3: ain3,
ain4: ain4,
ain5: ain5,
ain6: ain6,
ain7: ain7,
mil: mil,
fuelPump: fuelPump,
fanOut: fanOut,
vtec: vtec,
knock: knock,
revLimit: revLimit,
launch: launch,
errBytes: errBytes,
timestamp: DateTime.now(),
);
}
int _readUint16BE(Uint8List frame, int offset) {
return (frame[offset] << 8) | frame[offset + 1];
}
int _readUint16LE(Uint8List frame, int offset) {
return frame[offset] | (frame[offset + 1] << 8);
}

View File

@ -1,184 +1,184 @@
/// Fully decoded ECU sensor state. Immutable. Produced by S300 or KPro parser. /// Fully decoded ECU sensor state. Immutable. Produced by S300 or KPro parser.
class SensorState { class SensorState {
final double rpm; final double rpm;
final double vss; final double vss;
final double map; final double map;
final double tps; final double tps;
final double inj; final double inj;
final double ign; final double ign;
final double ect; final double ect;
final double iat; final double iat;
final double bat; final double bat;
final double o2; final double o2;
final int gear; final int gear;
final double eth; final double eth;
final double pa; final double pa;
final double afr; final double afr;
final double strim; final double strim;
final double ltrim; final double ltrim;
// Analog inputs (AIN0–AIN7) // Analog inputs (AIN0–AIN7)
final double ain0; final double ain0;
final double ain1; final double ain1;
final double ain2; final double ain2;
final double ain3; final double ain3;
final double ain4; final double ain4;
final double ain5; final double ain5;
final double ain6; final double ain6;
final double ain7; final double ain7;
// Boolean flags // Boolean flags
final bool mil; final bool mil;
final bool fuelCut; final bool fuelPump;
final bool fanOut; final bool fanOut;
final bool vtec; final bool vtec;
final bool knock; final bool knock;
final bool revLimit; final bool revLimit;
final bool launch; final bool launch;
// Raw DTC bytes (ERR00..ERR17 for KPro, ERR00..ERR03 for S300) // Raw DTC bytes (ERR00..ERR17 for KPro, ERR00..ERR03 for S300)
final List<int> errBytes; final List<int> errBytes;
// Frame timestamp // Frame timestamp
final DateTime timestamp; final DateTime timestamp;
const SensorState({ const SensorState({
required this.rpm, required this.rpm,
required this.vss, required this.vss,
required this.map, required this.map,
required this.tps, required this.tps,
required this.inj, required this.inj,
required this.ign, required this.ign,
required this.ect, required this.ect,
required this.iat, required this.iat,
required this.bat, required this.bat,
required this.o2, required this.o2,
required this.gear, required this.gear,
required this.eth, required this.eth,
required this.pa, required this.pa,
required this.afr, required this.afr,
required this.strim, required this.strim,
required this.ltrim, required this.ltrim,
required this.ain0, required this.ain0,
required this.ain1, required this.ain1,
required this.ain2, required this.ain2,
required this.ain3, required this.ain3,
required this.ain4, required this.ain4,
required this.ain5, required this.ain5,
required this.ain6, required this.ain6,
required this.ain7, required this.ain7,
required this.mil, required this.mil,
required this.fuelCut, required this.fuelPump,
required this.fanOut, required this.fanOut,
required this.vtec, required this.vtec,
required this.knock, required this.knock,
required this.revLimit, required this.revLimit,
required this.launch, required this.launch,
required this.errBytes, required this.errBytes,
required this.timestamp, required this.timestamp,
}); });
factory SensorState.zero() => SensorState( factory SensorState.zero() => SensorState(
rpm: 0, rpm: 0,
vss: 0, vss: 0,
map: 0, map: 0,
tps: 0, tps: 0,
inj: 0, inj: 0,
ign: 0, ign: 0,
ect: 0, ect: 0,
iat: 0, iat: 0,
bat: 0, bat: 0,
o2: 0, o2: 0,
gear: 0, gear: 0,
eth: 0, eth: 0,
pa: 0, pa: 0,
afr: 0, afr: 0,
strim: 0, strim: 0,
ltrim: 0, ltrim: 0,
ain0: 0, ain0: 0,
ain1: 0, ain1: 0,
ain2: 0, ain2: 0,
ain3: 0, ain3: 0,
ain4: 0, ain4: 0,
ain5: 0, ain5: 0,
ain6: 0, ain6: 0,
ain7: 0, ain7: 0,
mil: false, mil: false,
fuelCut: false, fuelPump: false,
fanOut: false, fanOut: false,
vtec: false, vtec: false,
knock: false, knock: false,
revLimit: false, revLimit: false,
launch: false, launch: false,
errBytes: const [], errBytes: const [],
timestamp: DateTime(0), timestamp: DateTime(0),
); );
SensorState copyWith({ SensorState copyWith({
double? rpm, double? rpm,
double? vss, double? vss,
double? map, double? map,
double? tps, double? tps,
double? inj, double? inj,
double? ign, double? ign,
double? ect, double? ect,
double? iat, double? iat,
double? bat, double? bat,
double? o2, double? o2,
int? gear, int? gear,
double? eth, double? eth,
double? pa, double? pa,
double? afr, double? afr,
double? strim, double? strim,
double? ltrim, double? ltrim,
double? ain0, double? ain0,
double? ain1, double? ain1,
double? ain2, double? ain2,
double? ain3, double? ain3,
double? ain4, double? ain4,
double? ain5, double? ain5,
double? ain6, double? ain6,
double? ain7, double? ain7,
bool? mil, bool? mil,
bool? fuelCut, bool? fuelPump,
bool? fanOut, bool? fanOut,
bool? vtec, bool? vtec,
bool? knock, bool? knock,
bool? revLimit, bool? revLimit,
bool? launch, bool? launch,
List<int>? errBytes, List<int>? errBytes,
DateTime? timestamp, DateTime? timestamp,
}) { }) {
return SensorState( return SensorState(
rpm: rpm ?? this.rpm, rpm: rpm ?? this.rpm,
vss: vss ?? this.vss, vss: vss ?? this.vss,
map: map ?? this.map, map: map ?? this.map,
tps: tps ?? this.tps, tps: tps ?? this.tps,
inj: inj ?? this.inj, inj: inj ?? this.inj,
ign: ign ?? this.ign, ign: ign ?? this.ign,
ect: ect ?? this.ect, ect: ect ?? this.ect,
iat: iat ?? this.iat, iat: iat ?? this.iat,
bat: bat ?? this.bat, bat: bat ?? this.bat,
o2: o2 ?? this.o2, o2: o2 ?? this.o2,
gear: gear ?? this.gear, gear: gear ?? this.gear,
eth: eth ?? this.eth, eth: eth ?? this.eth,
pa: pa ?? this.pa, pa: pa ?? this.pa,
afr: afr ?? this.afr, afr: afr ?? this.afr,
strim: strim ?? this.strim, strim: strim ?? this.strim,
ltrim: ltrim ?? this.ltrim, ltrim: ltrim ?? this.ltrim,
ain0: ain0 ?? this.ain0, ain0: ain0 ?? this.ain0,
ain1: ain1 ?? this.ain1, ain1: ain1 ?? this.ain1,
ain2: ain2 ?? this.ain2, ain2: ain2 ?? this.ain2,
ain3: ain3 ?? this.ain3, ain3: ain3 ?? this.ain3,
ain4: ain4 ?? this.ain4, ain4: ain4 ?? this.ain4,
ain5: ain5 ?? this.ain5, ain5: ain5 ?? this.ain5,
ain6: ain6 ?? this.ain6, ain6: ain6 ?? this.ain6,
ain7: ain7 ?? this.ain7, ain7: ain7 ?? this.ain7,
mil: mil ?? this.mil, mil: mil ?? this.mil,
fuelCut: fuelCut ?? this.fuelCut, fuelPump: fuelPump ?? this.fuelPump,
fanOut: fanOut ?? this.fanOut, fanOut: fanOut ?? this.fanOut,
vtec: vtec ?? this.vtec, vtec: vtec ?? this.vtec,
knock: knock ?? this.knock, knock: knock ?? this.knock,
revLimit: revLimit ?? this.revLimit, revLimit: revLimit ?? this.revLimit,
launch: launch ?? this.launch, launch: launch ?? this.launch,
errBytes: errBytes ?? this.errBytes, errBytes: errBytes ?? this.errBytes,
timestamp: timestamp ?? this.timestamp, timestamp: timestamp ?? this.timestamp,
); );
} }
} }

View File

@ -1,22 +1,22 @@
// Shared temperature lookup table for S300 and KPro ECU protocols. // Shared temperature lookup table for S300 and KPro ECU protocols.
// Index = raw ECT/IAT byte value. Decoded temp = tempXlt[raw] - 40 (°C). // Index = raw ECT/IAT byte value. Decoded temp = tempXlt[raw] - 40 (°C).
// (Subtraction, not addition: table entries span 0..190, and only // (Subtraction, not addition: table entries span 0..190, and only
// subtracting 40 reaches the sensor's declared -40..150°C range.) // subtracting 40 reaches the sensor's declared -40..150°C range.)
const List<int> tempXlt = [ const List<int> tempXlt = [
190, 190, 188, 186, 183, 181, 179, 177, 175, 172, 170, 168, 166, 163, 161, 159, 190, 190, 188, 186, 183, 181, 179, 177, 175, 172, 170, 168, 166, 163, 161, 159,
157, 155, 153, 151, 149, 146, 144, 142, 140, 138, 137, 136, 134, 133, 132, 130, 157, 155, 153, 151, 149, 146, 144, 142, 140, 138, 137, 136, 134, 133, 132, 130,
129, 128, 127, 125, 124, 123, 122, 121, 121, 120, 119, 118, 117, 116, 115, 115, 129, 128, 127, 125, 124, 123, 122, 121, 121, 120, 119, 118, 117, 116, 115, 115,
114, 113, 112, 111, 110, 110, 109, 109, 108, 107, 107, 106, 106, 105, 104, 104, 114, 113, 112, 111, 110, 110, 109, 109, 108, 107, 107, 106, 106, 105, 104, 104,
103, 102, 102, 101, 101, 100, 99, 99, 98, 98, 97, 96, 96, 95, 95, 94, 103, 102, 102, 101, 101, 100, 99, 99, 98, 98, 97, 96, 96, 95, 95, 94,
94, 93, 93, 93, 92, 92, 91, 91, 90, 90, 90, 89, 89, 88, 88, 87, 94, 93, 93, 93, 92, 92, 91, 91, 90, 90, 90, 89, 89, 88, 88, 87,
87, 87, 86, 86, 85, 85, 85, 84, 84, 83, 83, 82, 82, 82, 81, 81, 87, 87, 86, 86, 85, 85, 85, 84, 84, 83, 83, 82, 82, 82, 81, 81,
80, 80, 79, 79, 79, 78, 78, 77, 77, 76, 76, 76, 75, 75, 74, 74, 80, 80, 79, 79, 79, 78, 78, 77, 77, 76, 76, 76, 75, 75, 74, 74,
73, 73, 73, 72, 72, 71, 71, 71, 70, 70, 69, 69, 68, 68, 68, 67, 73, 73, 73, 72, 72, 71, 71, 71, 70, 70, 69, 69, 68, 68, 68, 67,
67, 66, 66, 66, 65, 65, 64, 64, 64, 63, 63, 62, 62, 61, 61, 60, 67, 66, 66, 66, 65, 65, 64, 64, 64, 63, 63, 62, 62, 61, 61, 60,
60, 60, 59, 59, 58, 58, 58, 57, 57, 56, 56, 55, 55, 55, 54, 54, 60, 60, 59, 59, 58, 58, 58, 57, 57, 56, 56, 55, 55, 55, 54, 54,
53, 53, 52, 52, 52, 51, 51, 50, 50, 49, 49, 49, 48, 48, 47, 47, 53, 53, 52, 52, 52, 51, 51, 50, 50, 49, 49, 49, 48, 48, 47, 47,
47, 46, 46, 45, 45, 44, 44, 44, 43, 43, 42, 42, 41, 41, 40, 39, 47, 46, 46, 45, 45, 44, 44, 44, 43, 43, 42, 42, 41, 41, 40, 39,
39, 39, 38, 37, 37, 36, 36, 35, 34, 34, 33, 33, 32, 31, 31, 30, 39, 39, 38, 37, 37, 36, 36, 35, 34, 34, 33, 33, 32, 31, 31, 30,
30, 29, 28, 28, 27, 27, 26, 25, 24, 23, 21, 20, 19, 18, 17, 16, 30, 29, 28, 28, 27, 27, 26, 25, 24, 23, 21, 20, 19, 18, 17, 16,
15, 14, 12, 11, 10, 9, 8, 7, 5, 4, 3, 1, 0, 0, 0, 0, 15, 14, 12, 11, 10, 9, 8, 7, 5, 4, 3, 1, 0, 0, 0, 0,
]; ];

View File

@ -1,149 +1,236 @@
import 'dart:async'; import 'dart:async';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart'; import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../bluetooth/bt_poller.dart'; import '../bluetooth/bt_poller.dart';
import '../bluetooth/bt_service.dart'; import '../bluetooth/bt_service.dart';
import 'settings_provider.dart'; import '../protocol/neg8.dart';
import 'settings_provider.dart';
// ─── Connection status enum ──────────────────────────────────────────────────
// ─── Connection status enum ──────────────────────────────────────────────────
enum BtStatus { disconnected, connecting, connected, error }
enum BtStatus { disconnected, connecting, connected, error }
// ─── State ───────────────────────────────────────────────────────────────────
// ─── State ───────────────────────────────────────────────────────────────────
class BtState {
final BtStatus status; class BtState {
final String? deviceName; final BtStatus status;
final String? error; final String? deviceName;
final int frameCount; final String? error;
final int droppedFrames; final int frameCount;
final Uint8List? lastFrame; final int droppedFrames;
final Uint8List? lastFrame;
const BtState({
required this.status, const BtState({
this.deviceName, required this.status,
this.error, this.deviceName,
this.frameCount = 0, this.error,
this.droppedFrames = 0, this.frameCount = 0,
this.lastFrame, this.droppedFrames = 0,
}); this.lastFrame,
});
const BtState.disconnected() : this(status: BtStatus.disconnected);
const BtState.disconnected() : this(status: BtStatus.disconnected);
BtState connecting(String name) =>
BtState(status: BtStatus.connecting, deviceName: name); BtState connecting(String name) =>
BtState(status: BtStatus.connecting, deviceName: name);
BtState connected(String name) =>
BtState(status: BtStatus.connected, deviceName: name); BtState connected(String name) =>
BtState(status: BtStatus.connected, deviceName: name);
BtState withError(String msg) => BtState(
status: BtStatus.error, BtState withError(String msg) => BtState(
deviceName: deviceName, status: BtStatus.error,
error: msg, deviceName: deviceName,
frameCount: frameCount, error: msg,
droppedFrames: droppedFrames, frameCount: frameCount,
lastFrame: lastFrame, droppedFrames: droppedFrames,
); lastFrame: lastFrame,
);
BtState withFrameStats(int frames, int dropped, Uint8List frame) => BtState(
status: status, BtState withFrameStats(int frames, int dropped, Uint8List frame) => BtState(
deviceName: deviceName, status: status,
frameCount: frames, deviceName: deviceName,
droppedFrames: dropped, frameCount: frames,
lastFrame: Uint8List.fromList(frame), droppedFrames: dropped,
); lastFrame: Uint8List.fromList(frame),
);
bool get isConnected => status == BtStatus.connected;
bool get isConnecting => status == BtStatus.connecting; bool get isConnected => status == BtStatus.connected;
} bool get isConnecting => status == BtStatus.connecting;
}
// ─── Notifier ────────────────────────────────────────────────────────────────
// ─── Notifier ────────────────────────────────────────────────────────────────
class BtNotifier extends StateNotifier<BtState> {
final Ref _ref; class BtNotifier extends StateNotifier<BtState> {
BtPoller? _poller; final Ref _ref;
StreamSubscription<Uint8List>? _pollSub; BtPoller? _poller;
final StreamController<Uint8List> _frameController = StreamSubscription<Uint8List>? _pollSub;
StreamController<Uint8List>.broadcast(); final StreamController<Uint8List> _frameController =
StreamController<Uint8List>.broadcast();
BtNotifier(this._ref) : super(const BtState.disconnected());
BtNotifier(this._ref) : super(const BtState.disconnected());
/// Raw validated 128-byte frame stream — consumed by sensorStateProvider.
Stream<Uint8List> get frameStream => _frameController.stream; /// Raw validated 128-byte frame stream — consumed by sensorStateProvider.
Stream<Uint8List> get frameStream => _frameController.stream;
Future<void> connect(BluetoothDevice device) async {
state = state.connecting(device.name ?? device.address); Future<void> connect(BluetoothDevice device) async {
final service = _ref.read(btServiceProvider); state = state.connecting(device.name ?? device.address);
final settings = _ref.read(settingsProvider); final service = _ref.read(btServiceProvider);
final settings = _ref.read(settingsProvider);
try {
await service.connect(device); try {
await service.connect(device);
_poller = BtPoller(
service, _poller = BtPoller(
ecuType: settings.ecuType, service,
pollingInterval: settings.pollingInterval, ecuType: settings.ecuType,
); pollingInterval: settings.pollingInterval,
);
_pollSub = _poller!.frameStream.listen(
(frame) { _pollSub = _poller!.frameStream.listen(
_frameController.add(frame); (frame) {
state = state.withFrameStats( _frameController.add(frame);
_poller!.validFrames, state = state.withFrameStats(
_poller!.droppedFrames, _poller!.validFrames,
frame, _poller!.droppedFrames,
); frame,
}, );
onError: (Object e) { },
_frameController.addError(e); onError: (Object e) {
state = state.withError(e.toString()); _frameController.addError(e);
}, state = state.withError(e.toString());
onDone: () => state = state.withError('BT stream closed'), },
); onDone: () => state = state.withError('BT stream closed'),
);
_poller!.start();
state = state.connected(device.name ?? device.address); _poller!.start();
} catch (e) { state = state.connected(device.name ?? device.address);
await service.disconnect(); } catch (e) {
state = state.withError(e.toString()); await service.disconnect();
} state = state.withError(e.toString());
} }
}
Future<void> disconnect() async {
await _pollSub?.cancel(); /// One-off diagnostic: sends the official Hondata Bluetooth protocol's
_poller?.stop(); /// DL_DeviceInfo command ({ Protocol=0x00, ID=0x02, Size=0x0004 LE }) and
_poller?.dispose(); /// returns whatever raw bytes come back within the response window. Used
_poller = null; /// to check whether this device/firmware understands the newer,
await _ref.read(btServiceProvider).disconnect(); /// self-describing channel-based protocol (Bluetooth.pdf) as opposed to
state = const BtState.disconnected(); /// only the legacy fixed-offset frame format this app currently speaks.
} /// Pauses the legacy poller for the duration so its own request/response
/// traffic doesn't interleave with the probe's reply on the same link.
@override Future<Uint8List> probeOfficialProtocol() async {
void dispose() { final service = _ref.read(btServiceProvider);
_pollSub?.cancel(); if (!service.isConnected) return Uint8List(0);
_poller?.dispose();
_frameController.close(); _poller?.stop();
super.dispose();
} // stop() only cancels the poller's own timer and listener — it can't
} // un-send a legacy request the poller already wrote moments earlier.
// That request's 128-byte reply is very likely still in flight, so
// ─── Providers ─────────────────────────────────────────────────────────────── // drain and discard whatever arrives here before sending the probe, or
// that leftover legacy frame gets mistaken for the probe's response.
/// Singleton BtService — lives as long as the app. final drainSub = service.rawStream.listen((_) {});
final btServiceProvider = Provider<BtService>((ref) { await Future.delayed(const Duration(milliseconds: 500));
final service = BtService(); await drainSub.cancel();
ref.onDispose(service.dispose);
return service; final collected = <int>[];
}); final sub = service.rawStream.listen((chunk) => collected.addAll(chunk));
/// BT connection state notifier. try {
final btProvider = await service.write(Uint8List.fromList([0x00, 0x02, 0x04, 0x00]));
StateNotifierProvider<BtNotifier, BtState>((ref) => BtNotifier(ref)); await Future.delayed(const Duration(milliseconds: 800));
} finally {
/// Paired BT devices list (re-fetchable via ref.refresh). await sub.cancel();
final pairedDevicesProvider = FutureProvider<List<BluetoothDevice>>((ref) { _poller?.start();
return FlutterBluetoothSerial.instance.getBondedDevices(); }
});
return Uint8List.fromList(collected);
}
/// Sends the DTC-reset command from the legacy protocol notes
/// (hvbt_protocol.pdf): { 0x1B, 0x70, NEG8 }, NEG8 over the first two
/// bytes. The ECU echoes the same 3 bytes back to acknowledge.
///
/// Deliberately does NOT pause the poller first (unlike
/// probeOfficialProtocol()) — pausing-then-sending got zero response both
/// here and for the official-protocol probe, while the regular poll cycle
/// always gets answered. One untested explanation: this adapter may only
/// answer additional commands while it keeps seeing the normal poll
/// traffic, not when that traffic stops. So this injects the command
/// alongside ongoing polling and scans the stream for the 3-byte ack
/// appearing anywhere in it — it'll be interleaved with regular 128-byte
/// datalog frames rather than arriving in isolation — over several poll
/// cycles' worth of time, in case the reply is delayed by an EEPROM/flash
/// write on the ECU rather than a simple RAM read.
Future<({bool acknowledged, Uint8List raw})> clearDtcs() async {
final service = _ref.read(btServiceProvider);
if (!service.isConnected) return (acknowledged: false, raw: Uint8List(0));
final command = Uint8List.fromList([0x1B, 0x70, 0x00]);
command[2] = calculateNeg8(Uint8List.fromList([0x1B, 0x70]));
final collected = <int>[];
final sub = service.rawStream.listen((chunk) => collected.addAll(chunk));
bool acknowledged = false;
try {
await service.write(command);
final deadline = DateTime.now().add(const Duration(seconds: 3));
while (DateTime.now().isBefore(deadline) && !acknowledged) {
await Future.delayed(const Duration(milliseconds: 150));
acknowledged = _containsAck(collected);
}
} finally {
await sub.cancel();
}
return (acknowledged: acknowledged, raw: Uint8List.fromList(collected));
}
static bool _containsAck(List<int> bytes) {
for (var i = 0; i + 2 < bytes.length; i++) {
if (bytes[i] == 0x1B && bytes[i + 1] == 0x70 && bytes[i + 2] == 0x75) {
return true;
}
}
return false;
}
Future<void> disconnect() async {
await _pollSub?.cancel();
_poller?.stop();
_poller?.dispose();
_poller = null;
await _ref.read(btServiceProvider).disconnect();
state = const BtState.disconnected();
}
@override
void dispose() {
_pollSub?.cancel();
_poller?.dispose();
_frameController.close();
super.dispose();
}
}
// ─── Providers ───────────────────────────────────────────────────────────────
/// Singleton BtService — lives as long as the app.
final btServiceProvider = Provider<BtService>((ref) {
final service = BtService();
ref.onDispose(service.dispose);
return service;
});
/// BT connection state notifier.
final btProvider =
StateNotifierProvider<BtNotifier, BtState>((ref) => BtNotifier(ref));
/// Paired BT devices list (re-fetchable via ref.refresh).
final pairedDevicesProvider = FutureProvider<List<BluetoothDevice>>((ref) {
return FlutterBluetoothSerial.instance.getBondedDevices();
});

View File

@ -1,206 +1,206 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../datalog/datalog_db.dart'; import '../datalog/datalog_db.dart';
import '../datalog/datalog_player.dart'; import '../datalog/datalog_player.dart';
import '../datalog/datalog_recorder.dart'; import '../datalog/datalog_recorder.dart';
import '../models/datalog_session.dart'; import '../models/datalog_session.dart';
import 'bt_provider.dart'; import 'bt_provider.dart';
import 'settings_provider.dart'; import 'settings_provider.dart';
// ─── Singletons ─────────────────────────────────────────────────────────────── // ─── Singletons ───────────────────────────────────────────────────────────────
final datalogDbProvider = Provider<DatalogDb>((ref) { final datalogDbProvider = Provider<DatalogDb>((ref) {
final db = DatalogDb(); final db = DatalogDb();
ref.onDispose(db.close); ref.onDispose(db.close);
return db; return db;
}); });
final datalogRecorderProvider = Provider<DatalogRecorder>((ref) { final datalogRecorderProvider = Provider<DatalogRecorder>((ref) {
final recorder = DatalogRecorder(ref.watch(datalogDbProvider)); final recorder = DatalogRecorder(ref.watch(datalogDbProvider));
ref.onDispose(recorder.dispose); ref.onDispose(recorder.dispose);
return recorder; return recorder;
}); });
final datalogPlayerProvider = Provider<DatalogPlayer>((ref) { final datalogPlayerProvider = Provider<DatalogPlayer>((ref) {
final player = DatalogPlayer(ref.watch(datalogDbProvider)); final player = DatalogPlayer(ref.watch(datalogDbProvider));
ref.onDispose(player.dispose); ref.onDispose(player.dispose);
return player; return player;
}); });
// ─── Recording state ────────────────────────────────────────────────────────── // ─── Recording state ──────────────────────────────────────────────────────────
enum RecordingStatus { idle, recording } enum RecordingStatus { idle, recording }
class RecordingState { class RecordingState {
final RecordingStatus status; final RecordingStatus status;
final int frameCount; final int frameCount;
final int? sessionId; final int? sessionId;
const RecordingState({ const RecordingState({
this.status = RecordingStatus.idle, this.status = RecordingStatus.idle,
this.frameCount = 0, this.frameCount = 0,
this.sessionId, this.sessionId,
}); });
bool get isRecording => status == RecordingStatus.recording; bool get isRecording => status == RecordingStatus.recording;
RecordingState copyWith({ RecordingState copyWith({
RecordingStatus? status, RecordingStatus? status,
int? frameCount, int? frameCount,
int? sessionId, int? sessionId,
}) => }) =>
RecordingState( RecordingState(
status: status ?? this.status, status: status ?? this.status,
frameCount: frameCount ?? this.frameCount, frameCount: frameCount ?? this.frameCount,
sessionId: sessionId ?? this.sessionId, sessionId: sessionId ?? this.sessionId,
); );
} }
class RecordingNotifier extends StateNotifier<RecordingState> { class RecordingNotifier extends StateNotifier<RecordingState> {
final Ref _ref; final Ref _ref;
RecordingNotifier(this._ref) : super(const RecordingState()); RecordingNotifier(this._ref) : super(const RecordingState());
Future<void> startRecording() async { Future<void> startRecording() async {
if (state.isRecording) return; if (state.isRecording) return;
final recorder = _ref.read(datalogRecorderProvider); final recorder = _ref.read(datalogRecorderProvider);
final btNotifier = _ref.read(btProvider.notifier); final btNotifier = _ref.read(btProvider.notifier);
final ecuType = _ref.read(settingsProvider).ecuType.name; final ecuType = _ref.read(settingsProvider).ecuType.name;
await recorder.start(btNotifier.frameStream, ecuType); await recorder.start(btNotifier.frameStream, ecuType);
state = RecordingState( state = RecordingState(
status: RecordingStatus.recording, status: RecordingStatus.recording,
sessionId: recorder.sessionId, sessionId: recorder.sessionId,
); );
} }
Future<DatalogSession?> stopRecording() async { Future<DatalogSession?> stopRecording() async {
if (!state.isRecording) return null; if (!state.isRecording) return null;
final recorder = _ref.read(datalogRecorderProvider); final recorder = _ref.read(datalogRecorderProvider);
final session = await recorder.stop(); final session = await recorder.stop();
state = const RecordingState(); state = const RecordingState();
// Refresh session list // Refresh session list
_ref.invalidate(sessionListProvider); _ref.invalidate(sessionListProvider);
return session; return session;
} }
/// Called periodically from UI to update live frame count display. /// Called periodically from UI to update live frame count display.
void syncFrameCount() { void syncFrameCount() {
if (!state.isRecording) return; if (!state.isRecording) return;
final count = _ref.read(datalogRecorderProvider).frameCount; final count = _ref.read(datalogRecorderProvider).frameCount;
if (count != state.frameCount) { if (count != state.frameCount) {
state = state.copyWith(frameCount: count); state = state.copyWith(frameCount: count);
} }
} }
} }
final recordingProvider = final recordingProvider =
StateNotifierProvider<RecordingNotifier, RecordingState>( StateNotifierProvider<RecordingNotifier, RecordingState>(
(ref) => RecordingNotifier(ref), (ref) => RecordingNotifier(ref),
); );
// ─── Session list ───────────────────────────────────────────────────────────── // ─── Session list ─────────────────────────────────────────────────────────────
final sessionListProvider = FutureProvider<List<DatalogSession>>((ref) { final sessionListProvider = FutureProvider<List<DatalogSession>>((ref) {
return ref.watch(datalogDbProvider).getSessions(); return ref.watch(datalogDbProvider).getSessions();
}); });
// ─── Playback state ─────────────────────────────────────────────────────────── // ─── Playback state ───────────────────────────────────────────────────────────
class PlaybackState { class PlaybackState {
final bool isActive; final bool isActive;
final int? sessionId; final int? sessionId;
final int position; final int position;
final int total; final int total;
final double speed; final double speed;
final PlaybackStatus status; final PlaybackStatus status;
const PlaybackState({ const PlaybackState({
this.isActive = false, this.isActive = false,
this.sessionId, this.sessionId,
this.position = 0, this.position = 0,
this.total = 0, this.total = 0,
this.speed = 1.0, this.speed = 1.0,
this.status = PlaybackStatus.idle, this.status = PlaybackStatus.idle,
}); });
double get progress => total == 0 ? 0 : position / total; double get progress => total == 0 ? 0 : position / total;
PlaybackState copyWith({ PlaybackState copyWith({
bool? isActive, bool? isActive,
int? sessionId, int? sessionId,
int? position, int? position,
int? total, int? total,
double? speed, double? speed,
PlaybackStatus? status, PlaybackStatus? status,
}) => }) =>
PlaybackState( PlaybackState(
isActive: isActive ?? this.isActive, isActive: isActive ?? this.isActive,
sessionId: sessionId ?? this.sessionId, sessionId: sessionId ?? this.sessionId,
position: position ?? this.position, position: position ?? this.position,
total: total ?? this.total, total: total ?? this.total,
speed: speed ?? this.speed, speed: speed ?? this.speed,
status: status ?? this.status, status: status ?? this.status,
); );
} }
class PlaybackNotifier extends StateNotifier<PlaybackState> { class PlaybackNotifier extends StateNotifier<PlaybackState> {
final Ref _ref; final Ref _ref;
PlaybackNotifier(this._ref) : super(const PlaybackState()); PlaybackNotifier(this._ref) : super(const PlaybackState());
Future<void> loadSession(int sessionId) async { Future<void> loadSession(int sessionId) async {
final player = _ref.read(datalogPlayerProvider); final player = _ref.read(datalogPlayerProvider);
await player.load(sessionId); await player.load(sessionId);
player.onProgress = (pos, total) { player.onProgress = (pos, total) {
state = state.copyWith( state = state.copyWith(
position: pos, position: pos,
total: total, total: total,
status: player.status, status: player.status,
); );
}; };
state = PlaybackState( state = PlaybackState(
isActive: true, isActive: true,
sessionId: sessionId, sessionId: sessionId,
total: player.totalFrames, total: player.totalFrames,
speed: player.speed, speed: player.speed,
status: PlaybackStatus.idle, status: PlaybackStatus.idle,
); );
} }
void play() { void play() {
_ref.read(datalogPlayerProvider).play(); _ref.read(datalogPlayerProvider).play();
state = state.copyWith(status: PlaybackStatus.playing); state = state.copyWith(status: PlaybackStatus.playing);
} }
void pause() { void pause() {
_ref.read(datalogPlayerProvider).pause(); _ref.read(datalogPlayerProvider).pause();
state = state.copyWith(status: PlaybackStatus.paused); state = state.copyWith(status: PlaybackStatus.paused);
} }
Future<void> stop() async { Future<void> stop() async {
await _ref.read(datalogPlayerProvider).stop(); await _ref.read(datalogPlayerProvider).stop();
state = const PlaybackState(); state = const PlaybackState();
} }
void seek(int index) { void seek(int index) {
_ref.read(datalogPlayerProvider).seek(index); _ref.read(datalogPlayerProvider).seek(index);
state = state.copyWith(position: index); state = state.copyWith(position: index);
} }
void setSpeed(double speed) { void setSpeed(double speed) {
_ref.read(datalogPlayerProvider).speed = speed; _ref.read(datalogPlayerProvider).speed = speed;
state = state.copyWith(speed: speed); state = state.copyWith(speed: speed);
} }
} }
final playbackProvider = final playbackProvider =
StateNotifierProvider<PlaybackNotifier, PlaybackState>( StateNotifierProvider<PlaybackNotifier, PlaybackState>(
(ref) => PlaybackNotifier(ref), (ref) => PlaybackNotifier(ref),
); );

View File

@ -1,88 +1,88 @@
import 'package:fl_chart/fl_chart.dart'; import 'package:fl_chart/fl_chart.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/sensor_defs.dart'; import '../models/sensor_defs.dart';
import '../protocol/sensor_state.dart'; import '../protocol/sensor_state.dart';
import 'sensor_provider.dart'; import 'sensor_provider.dart';
const _maxPoints = 18000; const _maxPoints = 18000;
class SensorHistory { class SensorHistory {
final Map<String, List<FlSpot>> _data = { final Map<String, List<FlSpot>> _data = {
for (final d in sensorDefs) d.id: [], for (final d in sensorDefs) d.id: [],
}; };
double? _t0; double? _t0;
void add(SensorState state) { void add(SensorState state) {
final tSec = state.timestamp.millisecondsSinceEpoch / 1000.0; final tSec = state.timestamp.millisecondsSinceEpoch / 1000.0;
_t0 ??= tSec; _t0 ??= tSec;
final x = tSec - _t0!; final x = tSec - _t0!;
void push(String id, double v) { void push(String id, double v) {
final list = _data[id]!; final list = _data[id]!;
list.add(FlSpot(x, v)); list.add(FlSpot(x, v));
if (list.length > _maxPoints) list.removeAt(0); if (list.length > _maxPoints) list.removeAt(0);
} }
push('rpm', state.rpm); push('rpm', state.rpm);
push('vss', state.vss); push('vss', state.vss);
push('map', state.map); push('map', state.map);
push('tps', state.tps); push('tps', state.tps);
push('inj', state.inj); push('inj', state.inj);
push('ign', state.ign); push('ign', state.ign);
push('ect', state.ect); push('ect', state.ect);
push('iat', state.iat); push('iat', state.iat);
push('bat', state.bat); push('bat', state.bat);
push('o2', state.o2); push('o2', state.o2);
push('gear', state.gear.toDouble()); push('gear', state.gear.toDouble());
push('eth', state.eth); push('eth', state.eth);
push('pa', state.pa); push('pa', state.pa);
push('afr', state.afr); push('afr', state.afr);
push('strim', state.strim); push('strim', state.strim);
push('ltrim', state.ltrim); push('ltrim', state.ltrim);
push('ain0', state.ain0); push('ain0', state.ain0);
push('ain1', state.ain1); push('ain1', state.ain1);
push('ain2', state.ain2); push('ain2', state.ain2);
push('ain3', state.ain3); push('ain3', state.ain3);
push('ain4', state.ain4); push('ain4', state.ain4);
push('ain5', state.ain5); push('ain5', state.ain5);
push('ain6', state.ain6); push('ain6', state.ain6);
push('ain7', state.ain7); push('ain7', state.ain7);
} }
List<FlSpot> get(String id) => List.unmodifiable(_data[id] ?? []); List<FlSpot> get(String id) => List.unmodifiable(_data[id] ?? []);
void clear() { void clear() {
for (final list in _data.values) { for (final list in _data.values) {
list.clear(); list.clear();
} }
_t0 = null; _t0 = null;
} }
} }
class SensorHistoryNotifier extends StateNotifier<SensorHistory> { class SensorHistoryNotifier extends StateNotifier<SensorHistory> {
SensorHistoryNotifier() : super(SensorHistory()); SensorHistoryNotifier() : super(SensorHistory());
void addState(SensorState s) { void addState(SensorState s) {
state.add(s); state.add(s);
// trigger rebuild by reassigning same object // trigger rebuild by reassigning same object
// ignore: invalid_use_of_protected_member // ignore: invalid_use_of_protected_member
state = state; state = state;
} }
void clear() { void clear() {
state.clear(); state.clear();
state = SensorHistory(); state = SensorHistory();
} }
} }
final sensorHistoryProvider = final sensorHistoryProvider =
StateNotifierProvider<SensorHistoryNotifier, SensorHistory>((ref) { StateNotifierProvider<SensorHistoryNotifier, SensorHistory>((ref) {
final notifier = SensorHistoryNotifier(); final notifier = SensorHistoryNotifier();
// Feed every parsed sensor state into history // Feed every parsed sensor state into history
ref.listen<AsyncValue<SensorState>>(sensorStateProvider, (_, next) { ref.listen<AsyncValue<SensorState>>(sensorStateProvider, (_, next) {
next.whenData(notifier.addState); next.whenData(notifier.addState);
}); });
return notifier; return notifier;
}); });

View File

@ -1,12 +1,36 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../bluetooth/bt_poller.dart'; import '../bluetooth/bt_poller.dart';
import '../models/sensor_defs.dart';
import '../protocol/kpro_parser.dart'; import '../protocol/kpro_parser.dart';
import '../protocol/s300_parser.dart'; import '../protocol/s300_parser.dart';
import '../protocol/sensor_state.dart'; import '../protocol/sensor_state.dart';
import 'bt_provider.dart'; import 'bt_provider.dart';
import 'settings_provider.dart'; import 'settings_provider.dart';
// NEG8 is only an 8-bit checksum, so roughly 1 in 256 corrupted frames can
// still pass validation — rare, but enough to occasionally show an
// impossible value: a single flipped bit in the high byte of a 16-bit field
// can jump it into the tens of thousands. Clamp just the fields where that's
// possible (multi-byte reads whose units genuinely match sensorDefs — RPM,
// VSS, MAP, INJ) to their realistic range, the same bounds the dashboard's
// gauges already use for normalization. Single-byte fields (TPS, gear, ECT/
// IAT, etc.) are already bounded to 0-255 raw by construction and can't
// produce this kind of runaway spike, so they're left alone here.
SensorState _clampToRealisticRange(SensorState s) {
double clampFor(String id, double value) {
final def = sensorDefs.firstWhere((d) => d.id == id);
return value.clamp(def.min, def.max);
}
return s.copyWith(
rpm: clampFor('rpm', s.rpm),
vss: clampFor('vss', s.vss),
map: clampFor('map', s.map),
inj: clampFor('inj', s.inj),
);
}
/// Counts of what sensorStateProvider actually did with each frame it saw, /// Counts of what sensorStateProvider actually did with each frame it saw,
/// exposed for the debug screen so the live pipeline can be inspected /// exposed for the debug screen so the live pipeline can be inspected
/// directly instead of inferred from symptoms. /// directly instead of inferred from symptoms.
@ -27,16 +51,6 @@ class DecodeStats {
final decodeStatsProvider = StateProvider<DecodeStats>((ref) => const DecodeStats()); final decodeStatsProvider = StateProvider<DecodeStats>((ref) => const DecodeStats());
// Bluetooth's round-trip jitter can make an individual request land outside
// the ECU's transient tach pulse-capture window, so a poll reports "no
// fresh count" (rpm=0) even though the engine/bench signal never actually
// changed, and jitter runs can span more than a couple of polls. Hold the
// last real RPM for this long (measured in wall-clock time, not frame count,
// since frame count at the same real gap would mean a different actual delay
// at every polling-interval setting) before accepting a run of zeros as a
// genuine stop.
const _rpmHoldDuration = Duration(milliseconds: 1000);
/// Emits a parsed [SensorState] for every live data frame received from the ECU. /// Emits a parsed [SensorState] for every live data frame received from the ECU.
/// Automatically picks S300 or KPro parser based on [settingsProvider]. /// Automatically picks S300 or KPro parser based on [settingsProvider].
final sensorStateProvider = StreamProvider<SensorState>((ref) async* { final sensorStateProvider = StreamProvider<SensorState>((ref) async* {
@ -50,27 +64,113 @@ final sensorStateProvider = StreamProvider<SensorState>((ref) async* {
// never sees them and the loop just keeps waiting for the next frame. // never sees them and the loop just keeps waiting for the next frame.
final safeStream = btNotifier.frameStream.handleError((Object _, StackTrace __) {}); final safeStream = btNotifier.frameStream.handleError((Object _, StackTrace __) {});
double lastGoodRpm = 0; SensorState? lastGood;
DateTime? lastGoodRpmAt; int dbgFrameCount = 0; // TEMP: live-debugging session, remove after.
await for (final frame in safeStream) { await for (final frame in safeStream) {
// A single bad frame must never take down the rest of the live stream. // A single bad frame must never take down the rest of the live stream.
try { try {
SensorState decoded = SensorState decoded =
ecuType == EcuType.s300 ? parseS300(frame) : parseKPro(frame); ecuType == EcuType.s300 ? parseS300(frame) : parseKPro(frame);
decoded = _clampToRealisticRange(decoded);
if (decoded.rpm > 0) { // TEMP: live-debugging session, remove after. Throttled to ~2/sec.
lastGoodRpm = decoded.rpm; if (ecuType == EcuType.s300 && (dbgFrameCount++ % 10 == 0)) {
lastGoodRpmAt = decoded.timestamp; // ignore: avoid_print
} else if (lastGoodRpm > 0) { print('[DBG] template=${isS300TemplateFrame(frame)} '
// Anchored to the last real reading rather than refreshed on every 'rpm=${decoded.rpm.toStringAsFixed(0)} '
// held frame, so a genuine stop is accepted once this much wall-clock 'gear=${decoded.gear} ect=${decoded.ect.toStringAsFixed(1)} '
// time has passed regardless of how many zero polls occurred in it. 'fuelPump=${decoded.fuelPump} mil=${decoded.mil} '
if (decoded.timestamp.difference(lastGoodRpmAt!) < _rpmHoldDuration) { 'vtec=${decoded.vtec} fan=${decoded.fanOut} '
decoded = decoded.copyWith(rpm: lastGoodRpm); 'sw2=0x${frame[18].toRadixString(16).padLeft(2, '0')}');
} else { }
lastGoodRpm = 0;
// TEMP: KPro live-debugging session, remove after. Compact decode line
// ~1/sec, plus a full raw hex dump ~every 3s so any offset/formula can
// be recomputed straight from logcat without a rebuild.
if (ecuType == EcuType.kpro) {
final n = dbgFrameCount++;
String h(int o) => o < frame.length
? frame[o].toRadixString(16).padLeft(2, '0')
: '--';
String bin(int o) =>
o < frame.length ? frame[o].toRadixString(2).padLeft(8, '0') : '--';
if (n % 20 == 0) {
// ignore: avoid_print
print('[KDBG] rpm=${decoded.rpm.toStringAsFixed(0)} '
'vss=${decoded.vss.toStringAsFixed(0)} '
'tps=${decoded.tps.toStringAsFixed(1)} '
'map=${decoded.map.toStringAsFixed(1)} '
'inj=${decoded.inj.toStringAsFixed(0)} '
'ign=${decoded.ign.toStringAsFixed(1)} '
'o2=${decoded.o2.toStringAsFixed(0)} '
'ect=${decoded.ect.toStringAsFixed(1)} '
'iat=${decoded.iat.toStringAsFixed(1)} '
'pa=${decoded.pa.toStringAsFixed(1)} '
'bat=${decoded.bat.toStringAsFixed(2)} '
'afr=${decoded.afr.toStringAsFixed(1)} '
'strim=${decoded.strim.toStringAsFixed(0)} '
'| mil=${decoded.mil} pump=${decoded.fuelPump} '
'fan=${decoded.fanOut} vtec=${decoded.vtec} knock=${decoded.knock} '
'| raw b5=${h(5)} b6=${h(6)} b7=${h(7)} b8=${h(8)} b11=${h(11)} '
'b12=${h(12)} b13=${h(13)} b14=${h(14)} b19=${h(19)} b20=${h(20)} '
'b21=${h(21)} b25=${h(25)} b33=${h(33)} b34=${h(34)} b49=${h(49)} '
'b50=${h(50)} b51=${h(51)} b52=${h(52)} '
'sw1=${bin(31)} sw2=${bin(32)} inf=${bin(60)}');
} }
if (n % 60 == 0) {
final sb = StringBuffer('[KHEX] ');
for (var i = 0; i < frame.length; i++) {
sb.write(frame[i].toRadixString(16).padLeft(2, '0'));
if (i != frame.length - 1) sb.write(' ');
}
// ignore: avoid_print
print(sb.toString());
}
}
if (ecuType == EcuType.s300 && isS300TemplateFrame(frame)) {
// Bytes 0-16 in this frame are fixed template filler, not a real
// reading (see isS300TemplateFrame) — hold the last known-good
// values for the fields sourced from that span. The frozen region
// turned out to extend further than originally thought: a live
// session showed the literal byte sequence behind gear/ECT/IAT/BAT
// (`93 a0 a0 dc 7d` at offsets 32-36) recurring identically dozens
// of times regardless of the true values, so those are held too.
//
// SW2-derived flags (fuelPump, mil, vtec, fanOut's ALTC bit) are
// held now too: a user report of fuelPump flipping to a wrong value
// during an idle period with nothing changed on the bench — right
// when everything else was also frozen — indicates SW2 isn't
// reliably live during template frames either, similar to gear/ECT/
// IAT/BAT. This reverses the earlier assumption (based on SW1,
// itself later found to have been checked against a non-template
// frame) that "bytes 17+ stay live" — that no longer looks safe to
// rely on for anything not individually re-verified.
if (lastGood != null) {
decoded = decoded.copyWith(
rpm: lastGood.rpm,
vss: lastGood.vss,
map: lastGood.map,
tps: lastGood.tps,
inj: lastGood.inj,
ign: lastGood.ign,
knock: lastGood.knock,
o2: lastGood.o2,
gear: lastGood.gear,
ect: lastGood.ect,
iat: lastGood.iat,
bat: lastGood.bat,
fuelPump: lastGood.fuelPump,
mil: lastGood.mil,
vtec: lastGood.vtec,
fanOut: lastGood.fanOut,
);
}
} else {
// Clamped above, so a corrupted spike is never remembered as a good
// value to hold into a later template frame.
lastGood = decoded;
} }
final s = ref.read(decodeStatsProvider); final s = ref.read(decodeStatsProvider);

View File

@ -1,30 +1,48 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../bluetooth/bt_poller.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../bluetooth/bt_poller.dart';
class AppSettings {
final EcuType ecuType; class AppSettings {
final Duration pollingInterval; final EcuType ecuType;
final Duration pollingInterval;
const AppSettings({
this.ecuType = EcuType.s300, const AppSettings({
this.pollingInterval = const Duration(milliseconds: 50), this.ecuType = EcuType.s300,
}); this.pollingInterval = const Duration(milliseconds: 50),
});
AppSettings copyWith({EcuType? ecuType, Duration? pollingInterval}) =>
AppSettings( AppSettings copyWith({EcuType? ecuType, Duration? pollingInterval}) =>
ecuType: ecuType ?? this.ecuType, AppSettings(
pollingInterval: pollingInterval ?? this.pollingInterval, ecuType: ecuType ?? this.ecuType,
); pollingInterval: pollingInterval ?? this.pollingInterval,
} );
}
class SettingsNotifier extends StateNotifier<AppSettings> {
SettingsNotifier() : super(const AppSettings()); const _ecuTypeKey = 'ecuType';
void setEcuType(EcuType type) => state = state.copyWith(ecuType: type); class SettingsNotifier extends StateNotifier<AppSettings> {
void setPollingInterval(Duration d) => SettingsNotifier() : super(const AppSettings()) {
state = state.copyWith(pollingInterval: d); _load();
} }
final settingsProvider = StateNotifierProvider<SettingsNotifier, AppSettings>( Future<void> _load() async {
(ref) => SettingsNotifier(), final prefs = await SharedPreferences.getInstance();
); final saved = prefs.getString(_ecuTypeKey);
if (saved == EcuType.kpro.name) {
state = state.copyWith(ecuType: EcuType.kpro);
}
}
void setEcuType(EcuType type) {
state = state.copyWith(ecuType: type);
SharedPreferences.getInstance()
.then((prefs) => prefs.setString(_ecuTypeKey, type.name));
}
void setPollingInterval(Duration d) =>
state = state.copyWith(pollingInterval: d);
}
final settingsProvider = StateNotifierProvider<SettingsNotifier, AppSettings>(
(ref) => SettingsNotifier(),
);

View File

@ -1,32 +1,32 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
enum AppThemeVariant { redDark, redLight, greenDark, greenLight } enum AppThemeVariant { redDark, redLight, greenDark, greenLight }
class ThemeNotifier extends StateNotifier<AppThemeVariant> { class ThemeNotifier extends StateNotifier<AppThemeVariant> {
ThemeNotifier() : super(AppThemeVariant.redDark); ThemeNotifier() : super(AppThemeVariant.redDark);
void set(AppThemeVariant v) => state = v; void set(AppThemeVariant v) => state = v;
} }
final themeProvider = final themeProvider =
StateNotifierProvider<ThemeNotifier, AppThemeVariant>( StateNotifierProvider<ThemeNotifier, AppThemeVariant>(
(ref) => ThemeNotifier(), (ref) => ThemeNotifier(),
); );
/// Returns the accent color for the current theme variant. /// Returns the accent color for the current theme variant.
extension AppThemeVariantX on AppThemeVariant { extension AppThemeVariantX on AppThemeVariant {
Color get accentColor { Color get accentColor {
switch (this) { switch (this) {
case AppThemeVariant.redDark: case AppThemeVariant.redDark:
case AppThemeVariant.redLight: case AppThemeVariant.redLight:
return const Color(0xFFFF4444); return const Color(0xFFFF4444);
case AppThemeVariant.greenDark: case AppThemeVariant.greenDark:
case AppThemeVariant.greenLight: case AppThemeVariant.greenLight:
return const Color(0xFF00E676); return const Color(0xFF00E676);
} }
} }
bool get isDark => bool get isDark =>
this == AppThemeVariant.redDark || this == AppThemeVariant.greenDark; this == AppThemeVariant.redDark || this == AppThemeVariant.greenDark;
} }

View File

@ -1,26 +1,26 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/providers/theme_provider.dart'; import 'core/providers/theme_provider.dart';
import 'ui/theme/app_theme.dart'; import 'ui/theme/app_theme.dart';
import 'ui/screens/bt_picker/bt_picker_screen.dart'; import 'ui/screens/bt_picker/bt_picker_screen.dart';
void main() { void main() {
runApp(const ProviderScope(child: HvbtApp())); runApp(const ProviderScope(child: HvbtApp()));
} }
class HvbtApp extends ConsumerWidget { class HvbtApp extends ConsumerWidget {
const HvbtApp({super.key}); const HvbtApp({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final themeVariant = ref.watch(themeProvider); final themeVariant = ref.watch(themeProvider);
return MaterialApp( return MaterialApp(
title: 'HV BT Dashboard', title: 'HV BT Dashboard',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: AppTheme.build(themeVariant), theme: AppTheme.build(themeVariant),
home: const BtPickerScreen(), home: const BtPickerScreen(),
); );
} }
} }

View File

@ -1,178 +1,154 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart'; import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import '../../../core/bluetooth/bt_poller.dart'; import '../../../core/providers/bt_provider.dart';
import '../../../core/providers/bt_provider.dart'; import '../../widgets/main_nav.dart';
import '../../../core/providers/settings_provider.dart';
import '../../widgets/main_nav.dart'; class BtPickerScreen extends ConsumerStatefulWidget {
const BtPickerScreen({super.key});
class BtPickerScreen extends ConsumerStatefulWidget {
const BtPickerScreen({super.key}); @override
ConsumerState<BtPickerScreen> createState() => _BtPickerScreenState();
@override }
ConsumerState<BtPickerScreen> createState() => _BtPickerScreenState();
} class _BtPickerScreenState extends ConsumerState<BtPickerScreen> {
String? _error;
class _BtPickerScreenState extends ConsumerState<BtPickerScreen> { bool _initDone = false;
String? _error;
bool _initDone = false; @override
void initState() {
@override super.initState();
void initState() { WidgetsBinding.instance.addPostFrameCallback((_) => _initBluetooth());
super.initState(); }
WidgetsBinding.instance.addPostFrameCallback((_) => _initBluetooth());
} Future<void> _initBluetooth() async {
setState(() => _error = null);
Future<void> _initBluetooth() async {
setState(() => _error = null); final statuses = await [
Permission.bluetoothConnect,
final statuses = await [ Permission.bluetoothScan,
Permission.bluetoothConnect, Permission.locationWhenInUse,
Permission.bluetoothScan, ].request();
Permission.locationWhenInUse,
].request(); final denied = statuses.entries
.where((e) => e.value.isDenied || e.value.isPermanentlyDenied)
final denied = statuses.entries .map((e) => e.key.toString())
.where((e) => e.value.isDenied || e.value.isPermanentlyDenied) .toList();
.map((e) => e.key.toString())
.toList(); if (denied.isNotEmpty) {
setState(() {
if (denied.isNotEmpty) { _error = 'Permissions denied: ${denied.join(", ")}\n\n'
setState(() { 'Go to Settings → Apps → HV BT Dashboard → Permissions.';
_error = 'Permissions denied: ${denied.join(", ")}\n\n' _initDone = true;
'Go to Settings → Apps → HV BT Dashboard → Permissions.'; });
_initDone = true; return;
}); }
return;
} final btState = await FlutterBluetoothSerial.instance.state;
if (btState != BluetoothState.STATE_ON) {
final btState = await FlutterBluetoothSerial.instance.state; await FlutterBluetoothSerial.instance.requestEnable();
if (btState != BluetoothState.STATE_ON) { await Future<void>.delayed(const Duration(seconds: 1));
await FlutterBluetoothSerial.instance.requestEnable(); }
await Future<void>.delayed(const Duration(seconds: 1));
} setState(() => _initDone = true);
ref.invalidate(pairedDevicesProvider);
setState(() => _initDone = true); }
ref.invalidate(pairedDevicesProvider);
} // ECU protocol is chosen once via Settings (or defaults to S300) and
// persisted from there — no need to ask again on every connection. A
Future<void> _connect(BluetoothDevice device, EcuType ecuType) async { // dialog here used to re-prompt "S300 or KPro?" on every single tap,
ref.read(settingsProvider.notifier).setEcuType(ecuType); // which was pure friction once the user had already told the app which
await ref.read(btProvider.notifier).connect(device); // ECU they have.
if (!mounted) return; Future<void> _connect(BluetoothDevice device) async {
if (ref.read(btProvider).isConnected) { await ref.read(btProvider.notifier).connect(device);
Navigator.of(context).pushReplacement( if (!mounted) return;
MaterialPageRoute(builder: (_) => const MainNav()), if (ref.read(btProvider).isConnected) {
); Navigator.of(context).pushReplacement(
} MaterialPageRoute(builder: (_) => const MainNav()),
} );
}
void _showProtocolDialog(BluetoothDevice device) { }
showDialog<void>(
context: context, @override
builder: (ctx) => AlertDialog( Widget build(BuildContext context) {
title: Text(device.name ?? device.address), final btState = ref.watch(btProvider);
content: const Text('Select ECU protocol:'), final devicesAsync = ref.watch(pairedDevicesProvider);
actions: [
TextButton( return Scaffold(
onPressed: () { Navigator.pop(ctx); _connect(device, EcuType.s300); }, appBar: AppBar(
child: const Text('S300'), title: const Text('Connect to ECU'),
), actions: [
TextButton( IconButton(
onPressed: () { Navigator.pop(ctx); _connect(device, EcuType.kpro); }, icon: const Icon(Icons.refresh),
child: const Text('KPro'), onPressed: _loading ? null : _initBluetooth,
), ),
TextButton( ],
onPressed: () => Navigator.pop(ctx), ),
child: const Text('Cancel'), body: Column(
), children: [
], if (btState.status == BtStatus.error)
), _banner('Connection failed: ${btState.error}'),
); if (_error != null) _banner(_error!),
} Expanded(
child: !_initDone
@override ? const Center(child: CircularProgressIndicator())
Widget build(BuildContext context) { : devicesAsync.when(
final btState = ref.watch(btProvider); loading: () => const Center(
final devicesAsync = ref.watch(pairedDevicesProvider); child: CircularProgressIndicator()),
error: (e, _) =>
return Scaffold( Center(child: _banner('Failed to load: $e')),
appBar: AppBar( data: (devices) => devices.isEmpty
title: const Text('Connect to ECU'), ? const Center(
actions: [ child: Padding(
IconButton( padding: EdgeInsets.all(24),
icon: const Icon(Icons.refresh), child: Text(
onPressed: _loading ? null : _initBluetooth, 'No paired BT devices.\nPair your ECU in Android Settings first.',
), textAlign: TextAlign.center,
], style: TextStyle(color: Colors.grey),
), ),
body: Column( ),
children: [ )
if (btState.status == BtStatus.error) : ListView.separated(
_banner('Connection failed: ${btState.error}'), itemCount: devices.length,
if (_error != null) _banner(_error!), separatorBuilder: (_, __) =>
Expanded( const Divider(height: 1),
child: !_initDone itemBuilder: (_, i) {
? const Center(child: CircularProgressIndicator()) final d = devices[i];
: devicesAsync.when( final connecting = btState.isConnecting &&
loading: () => const Center( btState.deviceName ==
child: CircularProgressIndicator()), (d.name ?? d.address);
error: (e, _) => return ListTile(
Center(child: _banner('Failed to load: $e')), leading: const Icon(Icons.bluetooth),
data: (devices) => devices.isEmpty title: Text(d.name ?? 'Unknown'),
? const Center( subtitle: Text(d.address),
child: Padding( trailing: connecting
padding: EdgeInsets.all(24), ? const SizedBox(
child: Text( width: 20,
'No paired BT devices.\nPair your ECU in Android Settings first.', height: 20,
textAlign: TextAlign.center, child: CircularProgressIndicator(
style: TextStyle(color: Colors.grey), strokeWidth: 2))
), : const Icon(
), Icons.arrow_forward_ios,
) size: 14),
: ListView.separated( onTap: connecting ? null : () => _connect(d),
itemCount: devices.length, );
separatorBuilder: (_, __) => },
const Divider(height: 1), ),
itemBuilder: (_, i) { ),
final d = devices[i]; ),
final connecting = btState.isConnecting && ],
btState.deviceName == ),
(d.name ?? d.address); );
return ListTile( }
leading: const Icon(Icons.bluetooth),
title: Text(d.name ?? 'Unknown'), bool get _loading => !_initDone || ref.read(btProvider).isConnecting;
subtitle: Text(d.address),
trailing: connecting Widget _banner(String msg) => Container(
? const SizedBox( width: double.infinity,
width: 20, color: const Color(0xFF4A0000),
height: 20, padding: const EdgeInsets.all(12),
child: CircularProgressIndicator( child: Text(msg,
strokeWidth: 2)) style: const TextStyle(color: Colors.redAccent, fontSize: 12)),
: const Icon( );
Icons.arrow_forward_ios, }
size: 14),
onTap: connecting
? null
: () => _showProtocolDialog(d),
);
},
),
),
),
],
),
);
}
bool get _loading => !_initDone || ref.read(btProvider).isConnecting;
Widget _banner(String msg) => Container(
width: double.infinity,
color: const Color(0xFF4A0000),
padding: const EdgeInsets.all(12),
child: Text(msg,
style: const TextStyle(color: Colors.redAccent, fontSize: 12)),
);
}

View File

@ -1,113 +1,113 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/providers/sensor_provider.dart'; import '../../../core/providers/sensor_provider.dart';
import '../../../core/providers/theme_provider.dart'; import '../../../core/providers/theme_provider.dart';
import '../../../ui/theme/app_colors.dart'; import '../../../ui/theme/app_colors.dart';
import 'widgets/analog_gauge.dart'; import 'widgets/analog_gauge.dart';
import 'widgets/flag_pill.dart'; import 'widgets/flag_pill.dart';
import 'widgets/sensor_tile.dart'; import 'widgets/sensor_tile.dart';
import 'widgets/sensor_picker_sheet.dart'; import 'widgets/sensor_picker_sheet.dart';
import 'dashboard_slots_provider.dart'; import 'dashboard_slots_provider.dart';
class DashboardHorizontal extends ConsumerWidget { class DashboardHorizontal extends ConsumerWidget {
const DashboardHorizontal({super.key}); const DashboardHorizontal({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(latestSensorProvider); final state = ref.watch(latestSensorProvider);
final themeVariant = ref.watch(themeProvider); final themeVariant = ref.watch(themeProvider);
final colors = AppColors.forTheme(themeVariant); final colors = AppColors.forTheme(themeVariant);
final slots = ref.watch(dashboardSlotsProvider); final slots = ref.watch(dashboardSlotsProvider);
return Container( return Container(
color: const Color(0xFF0A0A0A), color: const Color(0xFF0A0A0A),
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: Column( child: Column(
children: [ children: [
Expanded( Expanded(
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
// Left tiles (slots 0-2) // Left tiles (slots 0-2)
SizedBox( SizedBox(
width: 140, width: 140,
child: Column( child: Column(
children: [ children: [
for (int i = 0; i < 3; i++) for (int i = 0; i < 3; i++)
Expanded( Expanded(
child: Padding( child: Padding(
padding: const EdgeInsets.only(bottom: 6), padding: const EdgeInsets.only(bottom: 6),
child: SensorTile( child: SensorTile(
slotIndex: i, slotIndex: i,
sensorId: slots[i], sensorId: slots[i],
state: state, state: state,
accentColor: colors.accent, accentColor: colors.accent,
onTap: () => _pickSensor(context, ref, i), onTap: () => _pickSensor(context, ref, i),
), ),
), ),
), ),
], ],
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
// Center gauge // Center gauge
Expanded( Expanded(
child: Center( child: Center(
child: AnalogGauge( child: AnalogGauge(
value: state.rpm, value: state.rpm,
minValue: 0, minValue: 0,
maxValue: 9000, maxValue: 9000,
label: 'RPM', label: 'RPM',
unit: 'rpm', unit: 'rpm',
accentColor: colors.accent, accentColor: colors.accent,
), ),
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
// Right tiles (slots 3-5) // Right tiles (slots 3-5)
SizedBox( SizedBox(
width: 140, width: 140,
child: Column( child: Column(
children: [ children: [
for (int i = 3; i < 6; i++) for (int i = 3; i < 6; i++)
Expanded( Expanded(
child: Padding( child: Padding(
padding: const EdgeInsets.only(bottom: 6), padding: const EdgeInsets.only(bottom: 6),
child: SensorTile( child: SensorTile(
slotIndex: i, slotIndex: i,
sensorId: slots[i], sensorId: slots[i],
state: state, state: state,
accentColor: colors.accent, accentColor: colors.accent,
onTap: () => _pickSensor(context, ref, i), onTap: () => _pickSensor(context, ref, i),
), ),
), ),
), ),
], ],
), ),
), ),
], ],
), ),
), ),
// Flag pills row // Flag pills row
Padding( Padding(
padding: const EdgeInsets.only(top: 6), padding: const EdgeInsets.only(top: 6),
child: FlagRow(state: state, accentColor: colors.accent), child: FlagRow(state: state, accentColor: colors.accent),
), ),
], ],
), ),
); );
} }
void _pickSensor(BuildContext context, WidgetRef ref, int slotIndex) { void _pickSensor(BuildContext context, WidgetRef ref, int slotIndex) {
showModalBottomSheet<void>( showModalBottomSheet<void>(
context: context, context: context,
builder: (_) => SensorPickerSheet( builder: (_) => SensorPickerSheet(
currentId: ref.read(dashboardSlotsProvider)[slotIndex], currentId: ref.read(dashboardSlotsProvider)[slotIndex],
onSelected: (id) { onSelected: (id) {
ref.read(dashboardSlotsProvider.notifier).setSlot(slotIndex, id); ref.read(dashboardSlotsProvider.notifier).setSlot(slotIndex, id);
Navigator.pop(context); Navigator.pop(context);
}, },
), ),
); );
} }
} }

View File

@ -1,19 +1,19 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'dashboard_horizontal.dart'; import 'dashboard_horizontal.dart';
import 'dashboard_vertical.dart'; import 'dashboard_vertical.dart';
class DashboardScreen extends ConsumerWidget { class DashboardScreen extends ConsumerWidget {
const DashboardScreen({super.key}); const DashboardScreen({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
return OrientationBuilder( return OrientationBuilder(
builder: (context, orientation) { builder: (context, orientation) {
return orientation == Orientation.landscape return orientation == Orientation.landscape
? const DashboardHorizontal() ? const DashboardHorizontal()
: const DashboardVertical(); : const DashboardVertical();
}, },
); );
} }
} }

View File

@ -1,19 +1,19 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
// Default sensor IDs for the 6 dashboard tile slots // Default sensor IDs for the 6 dashboard tile slots
const _defaultSlots = ['vss', 'map', 'tps', 'ect', 'iat', 'bat']; const _defaultSlots = ['vss', 'map', 'tps', 'ect', 'iat', 'bat'];
class DashboardSlotsNotifier extends StateNotifier<List<String>> { class DashboardSlotsNotifier extends StateNotifier<List<String>> {
DashboardSlotsNotifier() : super(List.from(_defaultSlots)); DashboardSlotsNotifier() : super(List.from(_defaultSlots));
void setSlot(int index, String sensorId) { void setSlot(int index, String sensorId) {
final next = List<String>.from(state); final next = List<String>.from(state);
next[index] = sensorId; next[index] = sensorId;
state = next; state = next;
} }
} }
final dashboardSlotsProvider = final dashboardSlotsProvider =
StateNotifierProvider<DashboardSlotsNotifier, List<String>>( StateNotifierProvider<DashboardSlotsNotifier, List<String>>(
(ref) => DashboardSlotsNotifier(), (ref) => DashboardSlotsNotifier(),
); );

View File

@ -1,80 +1,81 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/providers/sensor_provider.dart'; import '../../../core/providers/sensor_provider.dart';
import '../../../core/providers/theme_provider.dart'; import '../../../core/providers/theme_provider.dart';
import '../../../ui/theme/app_colors.dart'; import '../../../ui/theme/app_colors.dart';
import 'widgets/analog_gauge.dart'; import 'widgets/analog_gauge.dart';
import 'widgets/flag_pill.dart'; import 'widgets/flag_pill.dart';
import 'widgets/sensor_tile.dart'; import 'widgets/sensor_tile.dart';
import 'widgets/sensor_picker_sheet.dart'; import 'widgets/sensor_picker_sheet.dart';
import 'dashboard_slots_provider.dart'; import 'dashboard_slots_provider.dart';
class DashboardVertical extends ConsumerWidget { class DashboardVertical extends ConsumerWidget {
const DashboardVertical({super.key}); const DashboardVertical({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(latestSensorProvider); final state = ref.watch(latestSensorProvider);
final themeVariant = ref.watch(themeProvider); final themeVariant = ref.watch(themeProvider);
final colors = AppColors.forTheme(themeVariant); final colors = AppColors.forTheme(themeVariant);
final slots = ref.watch(dashboardSlotsProvider); final slots = ref.watch(dashboardSlotsProvider);
return Container( return Container(
color: const Color(0xFF0A0A0A), color: const Color(0xFF0A0A0A),
child: Column( child: SingleChildScrollView(
children: [ child: Column(
Padding( children: [
padding: const EdgeInsets.fromLTRB(24, 8, 24, 0), Padding(
child: AnalogGauge( padding: const EdgeInsets.fromLTRB(24, 8, 24, 0),
value: state.rpm, child: AnalogGauge(
minValue: 0, value: state.rpm,
maxValue: 9000, minValue: 0,
label: 'RPM', maxValue: 9000,
unit: 'rpm', label: 'RPM',
accentColor: colors.accent, unit: 'rpm',
), accentColor: colors.accent,
), ),
Padding( ),
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), Padding(
child: FlagRow(state: state, accentColor: colors.accent), padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
), child: FlagRow(state: state, accentColor: colors.accent),
Expanded( ),
child: Padding( Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
child: GridView.count( child: GridView.count(
crossAxisCount: 2, shrinkWrap: true,
mainAxisSpacing: 6, crossAxisCount: 2,
crossAxisSpacing: 6, mainAxisSpacing: 6,
childAspectRatio: 2.4, crossAxisSpacing: 6,
physics: const NeverScrollableScrollPhysics(), childAspectRatio: 2.4,
children: [ physics: const NeverScrollableScrollPhysics(),
for (int i = 0; i < slots.length; i++) children: [
SensorTile( for (int i = 0; i < slots.length; i++)
slotIndex: i, SensorTile(
sensorId: slots[i], slotIndex: i,
state: state, sensorId: slots[i],
accentColor: colors.accent, state: state,
onTap: () => _pickSensor(context, ref, i), accentColor: colors.accent,
), onTap: () => _pickSensor(context, ref, i),
], ),
), ],
), ),
), ),
], ],
), ),
); ),
} );
}
void _pickSensor(BuildContext context, WidgetRef ref, int slotIndex) {
showModalBottomSheet<void>( void _pickSensor(BuildContext context, WidgetRef ref, int slotIndex) {
context: context, showModalBottomSheet<void>(
builder: (_) => SensorPickerSheet( context: context,
currentId: ref.read(dashboardSlotsProvider)[slotIndex], builder: (_) => SensorPickerSheet(
onSelected: (id) { currentId: ref.read(dashboardSlotsProvider)[slotIndex],
ref.read(dashboardSlotsProvider.notifier).setSlot(slotIndex, id); onSelected: (id) {
Navigator.pop(context); ref.read(dashboardSlotsProvider.notifier).setSlot(slotIndex, id);
}, Navigator.pop(context);
), },
); ),
} );
} }
}

View File

@ -1,263 +1,263 @@
import 'dart:math'; import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class AnalogGauge extends StatefulWidget { class AnalogGauge extends StatefulWidget {
final double value; final double value;
final double minValue; final double minValue;
final double maxValue; final double maxValue;
final String label; final String label;
final String unit; final String unit;
final Color accentColor; final Color accentColor;
const AnalogGauge({ const AnalogGauge({
super.key, super.key,
required this.value, required this.value,
required this.minValue, required this.minValue,
required this.maxValue, required this.maxValue,
required this.label, required this.label,
required this.unit, required this.unit,
required this.accentColor, required this.accentColor,
}); });
@override @override
State<AnalogGauge> createState() => _AnalogGaugeState(); State<AnalogGauge> createState() => _AnalogGaugeState();
} }
class _AnalogGaugeState extends State<AnalogGauge> class _AnalogGaugeState extends State<AnalogGauge>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
late AnimationController _ctrl; late AnimationController _ctrl;
late Animation<double> _anim; late Animation<double> _anim;
double get _normalized => double get _normalized =>
((widget.value - widget.minValue) / ((widget.value - widget.minValue) /
(widget.maxValue - widget.minValue)) (widget.maxValue - widget.minValue))
.clamp(0.0, 1.0); .clamp(0.0, 1.0);
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_ctrl = AnimationController( _ctrl = AnimationController(
vsync: this, vsync: this,
duration: const Duration(milliseconds: 150), duration: const Duration(milliseconds: 150),
); );
_anim = Tween<double>(begin: 0, end: _normalized) _anim = Tween<double>(begin: 0, end: _normalized)
.animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOut)); .animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOut));
_ctrl.forward(); _ctrl.forward();
} }
@override @override
void didUpdateWidget(AnalogGauge old) { void didUpdateWidget(AnalogGauge old) {
super.didUpdateWidget(old); super.didUpdateWidget(old);
if (old.value != widget.value) { if (old.value != widget.value) {
_anim = Tween<double>(begin: _anim.value, end: _normalized) _anim = Tween<double>(begin: _anim.value, end: _normalized)
.animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOut)); .animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOut));
_ctrl.forward(from: 0); _ctrl.forward(from: 0);
} }
} }
@override @override
void dispose() { void dispose() {
_ctrl.dispose(); _ctrl.dispose();
super.dispose(); super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AspectRatio( return AspectRatio(
aspectRatio: 1, aspectRatio: 1,
child: AnimatedBuilder( child: AnimatedBuilder(
animation: _anim, animation: _anim,
builder: (_, __) => CustomPaint( builder: (_, __) => CustomPaint(
painter: _GaugePainter( painter: _GaugePainter(
normalizedValue: _anim.value, normalizedValue: _anim.value,
accentColor: widget.accentColor, accentColor: widget.accentColor,
displayValue: widget.value, displayValue: widget.value,
unit: widget.unit, unit: widget.unit,
label: widget.label, label: widget.label,
), ),
), ),
), ),
); );
} }
} }
class _GaugePainter extends CustomPainter { class _GaugePainter extends CustomPainter {
final double normalizedValue; // 0.0–1.0 final double normalizedValue; // 0.0–1.0
final Color accentColor; final Color accentColor;
final double displayValue; final double displayValue;
final String unit; final String unit;
final String label; final String label;
static const double _startAngle = 135 * pi / 180; // 7:30 position static const double _startAngle = 135 * pi / 180; // 7:30 position
static const double _sweepAngle = 270 * pi / 180; // 270° sweep static const double _sweepAngle = 270 * pi / 180; // 270° sweep
_GaugePainter({ _GaugePainter({
required this.normalizedValue, required this.normalizedValue,
required this.accentColor, required this.accentColor,
required this.displayValue, required this.displayValue,
required this.unit, required this.unit,
required this.label, required this.label,
}); });
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2); final center = Offset(size.width / 2, size.height / 2);
final radius = size.width * 0.42; final radius = size.width * 0.42;
final rect = Rect.fromCircle(center: center, radius: radius); final rect = Rect.fromCircle(center: center, radius: radius);
// ── Background arc ────────────────────────────────────────────── // ── Background arc ──────────────────────────────────────────────
final bgPaint = Paint() final bgPaint = Paint()
..color = const Color(0xFF2A2A2A) ..color = const Color(0xFF2A2A2A)
..style = PaintingStyle.stroke ..style = PaintingStyle.stroke
..strokeWidth = size.width * 0.06 ..strokeWidth = size.width * 0.06
..strokeCap = StrokeCap.round; ..strokeCap = StrokeCap.round;
canvas.drawArc(rect, _startAngle, _sweepAngle, false, bgPaint); canvas.drawArc(rect, _startAngle, _sweepAngle, false, bgPaint);
// ── Red zone (last 20%) ────────────────────────────────────────── // ── Red zone (last 20%) ──────────────────────────────────────────
final redPaint = Paint() final redPaint = Paint()
..color = Colors.red.withAlpha(120) ..color = Colors.red.withAlpha(120)
..style = PaintingStyle.stroke ..style = PaintingStyle.stroke
..strokeWidth = size.width * 0.06 ..strokeWidth = size.width * 0.06
..strokeCap = StrokeCap.round; ..strokeCap = StrokeCap.round;
canvas.drawArc( canvas.drawArc(
rect, rect,
_startAngle + _sweepAngle * 0.8, _startAngle + _sweepAngle * 0.8,
_sweepAngle * 0.2, _sweepAngle * 0.2,
false, false,
redPaint, redPaint,
); );
// ── Value arc ──────────────────────────────────────────────────── // ── Value arc ────────────────────────────────────────────────────
if (normalizedValue > 0) { if (normalizedValue > 0) {
final isRedZone = normalizedValue > 0.8; final isRedZone = normalizedValue > 0.8;
final valPaint = Paint() final valPaint = Paint()
..color = isRedZone ? Colors.red : accentColor ..color = isRedZone ? Colors.red : accentColor
..style = PaintingStyle.stroke ..style = PaintingStyle.stroke
..strokeWidth = size.width * 0.06 ..strokeWidth = size.width * 0.06
..strokeCap = StrokeCap.round; ..strokeCap = StrokeCap.round;
canvas.drawArc( canvas.drawArc(
rect, rect,
_startAngle, _startAngle,
_sweepAngle * normalizedValue, _sweepAngle * normalizedValue,
false, false,
valPaint, valPaint,
); );
} }
// ── Tick marks ─────────────────────────────────────────────────── // ── Tick marks ───────────────────────────────────────────────────
_drawTicks(canvas, center, radius, size); _drawTicks(canvas, center, radius, size);
// ── Needle ──────────────────────────────────────────────────────── // ── Needle ────────────────────────────────────────────────────────
_drawNeedle(canvas, center, radius, size); _drawNeedle(canvas, center, radius, size);
// ── Center text ────────────────────────────────────────────────── // ── Center text ──────────────────────────────────────────────────
_drawCenterText(canvas, center, size); _drawCenterText(canvas, center, size);
} }
void _drawTicks(Canvas canvas, Offset center, double radius, Size size) { void _drawTicks(Canvas canvas, Offset center, double radius, Size size) {
final majorPaint = Paint() final majorPaint = Paint()
..color = Colors.white54 ..color = Colors.white54
..strokeWidth = size.width * 0.012 ..strokeWidth = size.width * 0.012
..strokeCap = StrokeCap.round; ..strokeCap = StrokeCap.round;
final minorPaint = Paint() final minorPaint = Paint()
..color = Colors.white24 ..color = Colors.white24
..strokeWidth = size.width * 0.006 ..strokeWidth = size.width * 0.006
..strokeCap = StrokeCap.round; ..strokeCap = StrokeCap.round;
const totalTicks = 50; // 2% each const totalTicks = 50; // 2% each
for (int i = 0; i <= totalTicks; i++) { for (int i = 0; i <= totalTicks; i++) {
final t = i / totalTicks; final t = i / totalTicks;
final angle = _startAngle + _sweepAngle * t; final angle = _startAngle + _sweepAngle * t;
final isMajor = i % 5 == 0; final isMajor = i % 5 == 0;
final paint = isMajor ? majorPaint : minorPaint; final paint = isMajor ? majorPaint : minorPaint;
final outerR = radius - size.width * 0.01; final outerR = radius - size.width * 0.01;
final innerR = outerR - (isMajor ? size.width * 0.06 : size.width * 0.03); final innerR = outerR - (isMajor ? size.width * 0.06 : size.width * 0.03);
canvas.drawLine( canvas.drawLine(
center + Offset(cos(angle) * innerR, sin(angle) * innerR), center + Offset(cos(angle) * innerR, sin(angle) * innerR),
center + Offset(cos(angle) * outerR, sin(angle) * outerR), center + Offset(cos(angle) * outerR, sin(angle) * outerR),
paint, paint,
); );
} }
} }
void _drawNeedle(Canvas canvas, Offset center, double radius, Size size) { void _drawNeedle(Canvas canvas, Offset center, double radius, Size size) {
final angle = _startAngle + _sweepAngle * normalizedValue; final angle = _startAngle + _sweepAngle * normalizedValue;
final tipR = radius - size.width * 0.08; final tipR = radius - size.width * 0.08;
final tailR = size.width * 0.08; final tailR = size.width * 0.08;
final needlePaint = Paint() final needlePaint = Paint()
..color = Colors.white ..color = Colors.white
..strokeWidth = size.width * 0.018 ..strokeWidth = size.width * 0.018
..strokeCap = StrokeCap.round; ..strokeCap = StrokeCap.round;
canvas.drawLine( canvas.drawLine(
center - Offset(cos(angle) * tailR, sin(angle) * tailR), center - Offset(cos(angle) * tailR, sin(angle) * tailR),
center + Offset(cos(angle) * tipR, sin(angle) * tipR), center + Offset(cos(angle) * tipR, sin(angle) * tipR),
needlePaint, needlePaint,
); );
// Center dot // Center dot
canvas.drawCircle( canvas.drawCircle(
center, center,
size.width * 0.04, size.width * 0.04,
Paint()..color = Colors.white, Paint()..color = Colors.white,
); );
canvas.drawCircle( canvas.drawCircle(
center, center,
size.width * 0.025, size.width * 0.025,
Paint()..color = const Color(0xFF0A0A0A), Paint()..color = const Color(0xFF0A0A0A),
); );
} }
void _drawCenterText(Canvas canvas, Offset center, Size size) { void _drawCenterText(Canvas canvas, Offset center, Size size) {
// Value // Value
final valSpan = TextSpan( final valSpan = TextSpan(
text: displayValue.toStringAsFixed(0), text: displayValue.toStringAsFixed(0),
style: TextStyle( style: TextStyle(
color: Colors.white, color: Colors.white,
fontSize: size.width * 0.14, fontSize: size.width * 0.14,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
letterSpacing: -1, letterSpacing: -1,
), ),
); );
final valPainter = TextPainter( final valPainter = TextPainter(
text: valSpan, text: valSpan,
textDirection: TextDirection.ltr, textDirection: TextDirection.ltr,
)..layout(); )..layout();
valPainter.paint( valPainter.paint(
canvas, canvas,
center + center +
Offset( Offset(
-valPainter.width / 2, -valPainter.width / 2,
size.width * 0.1, size.width * 0.1,
), ),
); );
// Unit // Unit
final unitSpan = TextSpan( final unitSpan = TextSpan(
text: unit, text: unit,
style: TextStyle( style: TextStyle(
color: Colors.white54, color: Colors.white54,
fontSize: size.width * 0.07, fontSize: size.width * 0.07,
), ),
); );
final unitPainter = TextPainter( final unitPainter = TextPainter(
text: unitSpan, text: unitSpan,
textDirection: TextDirection.ltr, textDirection: TextDirection.ltr,
)..layout(); )..layout();
unitPainter.paint( unitPainter.paint(
canvas, canvas,
center + center +
Offset( Offset(
-unitPainter.width / 2, -unitPainter.width / 2,
size.width * 0.1 + valPainter.height + 2, size.width * 0.1 + valPainter.height + 2,
), ),
); );
} }
@override @override
bool shouldRepaint(_GaugePainter old) => bool shouldRepaint(_GaugePainter old) =>
old.normalizedValue != normalizedValue || old.normalizedValue != normalizedValue ||
old.accentColor != accentColor || old.accentColor != accentColor ||
old.displayValue != displayValue; old.displayValue != displayValue;
} }

View File

@ -1,66 +1,66 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../../core/protocol/sensor_state.dart'; import '../../../../core/protocol/sensor_state.dart';
class FlagPill extends StatelessWidget { class FlagPill extends StatelessWidget {
final String label; final String label;
final bool active; final bool active;
final Color color; final Color color;
const FlagPill({ const FlagPill({
super.key, super.key,
required this.label, required this.label,
required this.active, required this.active,
required this.color, required this.color,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AnimatedContainer( return AnimatedContainer(
duration: const Duration(milliseconds: 100), duration: const Duration(milliseconds: 100),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration( decoration: BoxDecoration(
color: active ? color.withAlpha(40) : const Color(0xFF1A1A1A), color: active ? color.withAlpha(40) : const Color(0xFF1A1A1A),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
border: Border.all( border: Border.all(
color: active ? color : Colors.white12, color: active ? color : Colors.white12,
width: 1.5, width: 1.5,
), ),
), ),
child: Text( child: Text(
label, label,
style: TextStyle( style: TextStyle(
color: active ? color : Colors.grey, color: active ? color : Colors.grey,
fontSize: 11, fontSize: 11,
fontWeight: active ? FontWeight.bold : FontWeight.normal, fontWeight: active ? FontWeight.bold : FontWeight.normal,
letterSpacing: 1, letterSpacing: 1,
), ),
), ),
); );
} }
} }
/// Row of all flag pills derived from a SensorState. /// Row of all flag pills derived from a SensorState.
class FlagRow extends StatelessWidget { class FlagRow extends StatelessWidget {
final SensorState state; final SensorState state;
final Color accentColor; final Color accentColor;
const FlagRow({super.key, required this.state, required this.accentColor}); const FlagRow({super.key, required this.state, required this.accentColor});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Wrap( return Wrap(
spacing: 6, spacing: 6,
runSpacing: 6, runSpacing: 6,
alignment: WrapAlignment.center, alignment: WrapAlignment.center,
children: [ children: [
FlagPill(label: 'MIL', active: state.mil, color: Colors.amber), FlagPill(label: 'MIL', active: state.mil, color: Colors.amber),
FlagPill(label: 'VTEC', active: state.vtec, color: Colors.blue), FlagPill(label: 'VTEC', active: state.vtec, color: Colors.blue),
FlagPill(label: 'KNOCK', active: state.knock, color: Colors.red), FlagPill(label: 'KNOCK', active: state.knock, color: Colors.red),
FlagPill(label: 'FUEL CUT', active: state.fuelCut, color: Colors.red), FlagPill(label: 'FUEL PUMP', active: state.fuelPump, color: Colors.cyan),
FlagPill(label: 'REV LIM', active: state.revLimit, color: Colors.orange), FlagPill(label: 'REV LIM', active: state.revLimit, color: Colors.orange),
FlagPill(label: 'FAN', active: state.fanOut, color: Colors.green), FlagPill(label: 'FAN', active: state.fanOut, color: Colors.green),
FlagPill(label: 'LAUNCH', active: state.launch, color: Colors.purple), FlagPill(label: 'LAUNCH', active: state.launch, color: Colors.purple),
], ],
); );
} }
} }

View File

@ -1,85 +1,85 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../../core/models/sensor_defs.dart'; import '../../../../core/models/sensor_defs.dart';
class SensorPickerSheet extends StatelessWidget { class SensorPickerSheet extends StatelessWidget {
final String currentId; final String currentId;
final void Function(String id) onSelected; final void Function(String id) onSelected;
const SensorPickerSheet({ const SensorPickerSheet({
super.key, super.key,
required this.currentId, required this.currentId,
required this.onSelected, required this.onSelected,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return DraggableScrollableSheet( return DraggableScrollableSheet(
expand: false, expand: false,
initialChildSize: 0.6, initialChildSize: 0.6,
minChildSize: 0.4, minChildSize: 0.4,
maxChildSize: 0.9, maxChildSize: 0.9,
builder: (context, scrollController) { builder: (context, scrollController) {
return Container( return Container(
decoration: const BoxDecoration( decoration: const BoxDecoration(
color: Color(0xFF1A1A1A), color: Color(0xFF1A1A1A),
borderRadius: BorderRadius.vertical(top: Radius.circular(16)), borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
), ),
child: Column( child: Column(
children: [ children: [
const SizedBox(height: 8), const SizedBox(height: 8),
Container( Container(
width: 40, height: 4, width: 40, height: 4,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white24, color: Colors.white24,
borderRadius: BorderRadius.circular(2), borderRadius: BorderRadius.circular(2),
), ),
), ),
const Padding( const Padding(
padding: EdgeInsets.all(16), padding: EdgeInsets.all(16),
child: Text( child: Text(
'SELECT SENSOR', 'SELECT SENSOR',
style: TextStyle( style: TextStyle(
color: Colors.white70, color: Colors.white70,
fontSize: 12, fontSize: 12,
letterSpacing: 2, letterSpacing: 2,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
), ),
), ),
const Divider(height: 1, color: Colors.white10), const Divider(height: 1, color: Colors.white10),
Expanded( Expanded(
child: ListView.builder( child: ListView.builder(
controller: scrollController, controller: scrollController,
itemCount: sensorDefs.length, itemCount: sensorDefs.length,
itemBuilder: (context, i) { itemBuilder: (context, i) {
final def = sensorDefs[i]; final def = sensorDefs[i];
final isSelected = def.id == currentId; final isSelected = def.id == currentId;
return ListTile( return ListTile(
title: Text( title: Text(
def.displayName, def.displayName,
style: TextStyle( style: TextStyle(
color: isSelected ? Colors.white : Colors.white70, color: isSelected ? Colors.white : Colors.white70,
fontWeight: isSelected fontWeight: isSelected
? FontWeight.bold ? FontWeight.bold
: FontWeight.normal, : FontWeight.normal,
), ),
), ),
subtitle: Text( subtitle: Text(
def.unit, def.unit,
style: const TextStyle(color: Colors.grey, fontSize: 12), style: const TextStyle(color: Colors.grey, fontSize: 12),
), ),
trailing: isSelected trailing: isSelected
? const Icon(Icons.check, color: Colors.green, size: 18) ? const Icon(Icons.check, color: Colors.green, size: 18)
: null, : null,
onTap: () => onSelected(def.id), onTap: () => onSelected(def.id),
); );
}, },
), ),
), ),
], ],
), ),
); );
}, },
); );
} }
} }

View File

@ -1,128 +1,128 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../../core/models/sensor_defs.dart'; import '../../../../core/models/sensor_defs.dart';
import '../../../../core/protocol/sensor_state.dart'; import '../../../../core/protocol/sensor_state.dart';
class SensorTile extends StatelessWidget { class SensorTile extends StatelessWidget {
final int slotIndex; final int slotIndex;
final String sensorId; final String sensorId;
final SensorState state; final SensorState state;
final Color accentColor; final Color accentColor;
final VoidCallback onTap; final VoidCallback onTap;
const SensorTile({ const SensorTile({
super.key, super.key,
required this.slotIndex, required this.slotIndex,
required this.sensorId, required this.sensorId,
required this.state, required this.state,
required this.accentColor, required this.accentColor,
required this.onTap, required this.onTap,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final def = sensorDefs.firstWhere( final def = sensorDefs.firstWhere(
(d) => d.id == sensorId, (d) => d.id == sensorId,
orElse: () => sensorDefs.first, orElse: () => sensorDefs.first,
); );
final value = _getValue(state, sensorId); final value = _getValue(state, sensorId);
final normalized = final normalized =
((value - def.min) / (def.max - def.min)).clamp(0.0, 1.0); ((value - def.min) / (def.max - def.min)).clamp(0.0, 1.0);
return GestureDetector( return GestureDetector(
onLongPress: onTap, onLongPress: onTap,
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF1A1A1A), color: const Color(0xFF1A1A1A),
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.white10), border: Border.all(color: Colors.white10),
), ),
padding: const EdgeInsets.fromLTRB(10, 8, 10, 6), padding: const EdgeInsets.fromLTRB(10, 8, 10, 6),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text( Text(
def.displayName.toUpperCase(), def.displayName.toUpperCase(),
style: const TextStyle( style: const TextStyle(
color: Colors.grey, color: Colors.grey,
fontSize: 10, fontSize: 10,
letterSpacing: 1, letterSpacing: 1,
), ),
), ),
Row( Row(
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Text( Text(
_format(value, sensorId), _format(value, sensorId),
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 22, fontSize: 22,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
letterSpacing: -0.5, letterSpacing: -0.5,
), ),
), ),
const SizedBox(width: 3), const SizedBox(width: 3),
Padding( Padding(
padding: const EdgeInsets.only(bottom: 2), padding: const EdgeInsets.only(bottom: 2),
child: Text( child: Text(
def.unit, def.unit,
style: TextStyle(color: accentColor, fontSize: 11), style: TextStyle(color: accentColor, fontSize: 11),
), ),
), ),
], ],
), ),
// Bar gauge // Bar gauge
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(2), borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator( child: LinearProgressIndicator(
value: normalized, value: normalized,
minHeight: 4, minHeight: 4,
backgroundColor: Colors.white10, backgroundColor: Colors.white10,
valueColor: AlwaysStoppedAnimation<Color>( valueColor: AlwaysStoppedAnimation<Color>(
normalized > 0.8 ? Colors.red : accentColor, normalized > 0.8 ? Colors.red : accentColor,
), ),
), ),
), ),
], ],
), ),
), ),
); );
} }
String _format(double v, String id) { String _format(double v, String id) {
if (id == 'gear') return v.toStringAsFixed(0); if (id == 'gear') return v.toStringAsFixed(0);
if (id == 'rpm') return v.toStringAsFixed(0); if (id == 'rpm') return v.toStringAsFixed(0);
if (v.abs() >= 100) return v.toStringAsFixed(1); if (v.abs() >= 100) return v.toStringAsFixed(1);
return v.toStringAsFixed(2); return v.toStringAsFixed(2);
} }
} }
double _getValue(SensorState s, String id) { double _getValue(SensorState s, String id) {
switch (id) { switch (id) {
case 'rpm': return s.rpm; case 'rpm': return s.rpm;
case 'vss': return s.vss; case 'vss': return s.vss;
case 'map': return s.map; case 'map': return s.map;
case 'tps': return s.tps; case 'tps': return s.tps;
case 'inj': return s.inj; case 'inj': return s.inj;
case 'ign': return s.ign; case 'ign': return s.ign;
case 'ect': return s.ect; case 'ect': return s.ect;
case 'iat': return s.iat; case 'iat': return s.iat;
case 'bat': return s.bat; case 'bat': return s.bat;
case 'o2': return s.o2; case 'o2': return s.o2;
case 'gear': return s.gear.toDouble(); case 'gear': return s.gear.toDouble();
case 'eth': return s.eth; case 'eth': return s.eth;
case 'pa': return s.pa; case 'pa': return s.pa;
case 'afr': return s.afr; case 'afr': return s.afr;
case 'strim': return s.strim; case 'strim': return s.strim;
case 'ltrim': return s.ltrim; case 'ltrim': return s.ltrim;
case 'ain0': return s.ain0; case 'ain0': return s.ain0;
case 'ain1': return s.ain1; case 'ain1': return s.ain1;
case 'ain2': return s.ain2; case 'ain2': return s.ain2;
case 'ain3': return s.ain3; case 'ain3': return s.ain3;
case 'ain4': return s.ain4; case 'ain4': return s.ain4;
case 'ain5': return s.ain5; case 'ain5': return s.ain5;
case 'ain6': return s.ain6; case 'ain6': return s.ain6;
case 'ain7': return s.ain7; case 'ain7': return s.ain7;
default: return 0; default: return 0;
} }
} }

View File

@ -1,124 +1,124 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../../../core/models/datalog_session.dart'; import '../../../core/models/datalog_session.dart';
import '../../../core/providers/datalog_provider.dart'; import '../../../core/providers/datalog_provider.dart';
import 'datalog_playback_bar.dart'; import 'datalog_playback_bar.dart';
class DatalogListScreen extends ConsumerWidget { class DatalogListScreen extends ConsumerWidget {
const DatalogListScreen({super.key}); const DatalogListScreen({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final sessionsAsync = ref.watch(sessionListProvider); final sessionsAsync = ref.watch(sessionListProvider);
final playback = ref.watch(playbackProvider); final playback = ref.watch(playbackProvider);
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Saved Sessions')), appBar: AppBar(title: const Text('Saved Sessions')),
body: Column( body: Column(
children: [ children: [
if (playback.isActive) if (playback.isActive)
DatalogPlaybackBar(state: playback), DatalogPlaybackBar(state: playback),
Expanded( Expanded(
child: sessionsAsync.when( child: sessionsAsync.when(
loading: () => loading: () =>
const Center(child: CircularProgressIndicator()), const Center(child: CircularProgressIndicator()),
error: (e, _) => Center( error: (e, _) => Center(
child: Text('Error: $e', child: Text('Error: $e',
style: const TextStyle(color: Colors.red))), style: const TextStyle(color: Colors.red))),
data: (sessions) => sessions.isEmpty data: (sessions) => sessions.isEmpty
? const Center( ? const Center(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.videocam_off, Icon(Icons.videocam_off,
size: 56, color: Colors.grey), size: 56, color: Colors.grey),
SizedBox(height: 12), SizedBox(height: 12),
Text('No recorded sessions', Text('No recorded sessions',
style: TextStyle(color: Colors.grey)), style: TextStyle(color: Colors.grey)),
], ],
), ),
) )
: ListView.separated( : ListView.separated(
itemCount: sessions.length, itemCount: sessions.length,
separatorBuilder: (_, __) => separatorBuilder: (_, __) =>
const Divider(height: 1, color: Colors.white10), const Divider(height: 1, color: Colors.white10),
itemBuilder: (context, i) => itemBuilder: (context, i) =>
_SessionTile(session: sessions[i]), _SessionTile(session: sessions[i]),
), ),
), ),
), ),
], ],
), ),
); );
} }
} }
class _SessionTile extends ConsumerWidget { class _SessionTile extends ConsumerWidget {
final DatalogSession session; final DatalogSession session;
const _SessionTile({required this.session}); const _SessionTile({required this.session});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final fmt = DateFormat('dd MMM yyyy HH:mm'); final fmt = DateFormat('dd MMM yyyy HH:mm');
final isPlaying = final isPlaying =
ref.watch(playbackProvider).sessionId == session.id; ref.watch(playbackProvider).sessionId == session.id;
return Dismissible( return Dismissible(
key: ValueKey(session.id), key: ValueKey(session.id),
direction: DismissDirection.endToStart, direction: DismissDirection.endToStart,
background: Container( background: Container(
color: Colors.red, color: Colors.red,
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20), padding: const EdgeInsets.only(right: 20),
child: const Icon(Icons.delete, color: Colors.white), child: const Icon(Icons.delete, color: Colors.white),
), ),
confirmDismiss: (_) async { confirmDismiss: (_) async {
return await showDialog<bool>( return await showDialog<bool>(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: const Text('Delete session?'), title: const Text('Delete session?'),
content: const Text('This cannot be undone.'), content: const Text('This cannot be undone.'),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(ctx, false), onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel')), child: const Text('Cancel')),
TextButton( TextButton(
onPressed: () => Navigator.pop(ctx, true), onPressed: () => Navigator.pop(ctx, true),
child: const Text('Delete', child: const Text('Delete',
style: TextStyle(color: Colors.red))), style: TextStyle(color: Colors.red))),
], ],
), ),
); );
}, },
onDismissed: (_) async { onDismissed: (_) async {
await ref await ref
.read(datalogDbProvider) .read(datalogDbProvider)
.deleteSession(session.id!); .deleteSession(session.id!);
ref.invalidate(sessionListProvider); ref.invalidate(sessionListProvider);
}, },
child: ListTile( child: ListTile(
leading: Icon( leading: Icon(
isPlaying ? Icons.play_circle : Icons.videocam, isPlaying ? Icons.play_circle : Icons.videocam,
color: isPlaying ? Colors.green : Colors.grey, color: isPlaying ? Colors.green : Colors.grey,
), ),
title: Text( title: Text(
fmt.format(session.startTime), fmt.format(session.startTime),
style: const TextStyle(fontSize: 13), style: const TextStyle(fontSize: 13),
), ),
subtitle: Text( subtitle: Text(
'${session.ecuType.toUpperCase()} • ' '${session.ecuType.toUpperCase()} • '
'${session.frameCount} frames • ${session.durationLabel}', '${session.frameCount} frames • ${session.durationLabel}',
style: const TextStyle(color: Colors.grey, fontSize: 11), style: const TextStyle(color: Colors.grey, fontSize: 11),
), ),
trailing: const Icon(Icons.play_arrow, size: 18), trailing: const Icon(Icons.play_arrow, size: 18),
onTap: () async { onTap: () async {
await ref await ref
.read(playbackProvider.notifier) .read(playbackProvider.notifier)
.loadSession(session.id!); .loadSession(session.id!);
ref.read(playbackProvider.notifier).play(); ref.read(playbackProvider.notifier).play();
}, },
), ),
); );
} }
} }

View File

@ -1,90 +1,90 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/datalog/datalog_player.dart'; import '../../../core/datalog/datalog_player.dart';
import '../../../core/providers/datalog_provider.dart'; import '../../../core/providers/datalog_provider.dart';
class DatalogPlaybackBar extends ConsumerWidget { class DatalogPlaybackBar extends ConsumerWidget {
final PlaybackState state; final PlaybackState state;
const DatalogPlaybackBar({super.key, required this.state}); const DatalogPlaybackBar({super.key, required this.state});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final notifier = ref.read(playbackProvider.notifier); final notifier = ref.read(playbackProvider.notifier);
final isPlaying = state.status == PlaybackStatus.playing; final isPlaying = state.status == PlaybackStatus.playing;
return Container( return Container(
color: const Color(0xFF1A1A1A), color: const Color(0xFF1A1A1A),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
// Progress slider // Progress slider
SliderTheme( SliderTheme(
data: SliderTheme.of(context).copyWith( data: SliderTheme.of(context).copyWith(
trackHeight: 3, trackHeight: 3,
thumbShape: thumbShape:
const RoundSliderThumbShape(enabledThumbRadius: 6), const RoundSliderThumbShape(enabledThumbRadius: 6),
overlayShape: overlayShape:
const RoundSliderOverlayShape(overlayRadius: 12), const RoundSliderOverlayShape(overlayRadius: 12),
), ),
child: Slider( child: Slider(
value: state.progress, value: state.progress,
onChanged: (v) => onChanged: (v) =>
notifier.seek((v * state.total).round()), notifier.seek((v * state.total).round()),
), ),
), ),
// Controls row // Controls row
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
// Frame counter // Frame counter
Text( Text(
'${state.position} / ${state.total}', '${state.position} / ${state.total}',
style: style:
const TextStyle(color: Colors.grey, fontSize: 11), const TextStyle(color: Colors.grey, fontSize: 11),
), ),
// Play / Pause / Stop // Play / Pause / Stop
Row( Row(
children: [ children: [
IconButton( IconButton(
icon: Icon( icon: Icon(
isPlaying ? Icons.pause : Icons.play_arrow, isPlaying ? Icons.pause : Icons.play_arrow,
size: 22, size: 22,
), ),
onPressed: isPlaying ? notifier.pause : notifier.play, onPressed: isPlaying ? notifier.pause : notifier.play,
padding: const EdgeInsets.all(4), padding: const EdgeInsets.all(4),
constraints: const BoxConstraints(), constraints: const BoxConstraints(),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
IconButton( IconButton(
icon: const Icon(Icons.stop, size: 22), icon: const Icon(Icons.stop, size: 22),
onPressed: () async => notifier.stop(), onPressed: () async => notifier.stop(),
padding: const EdgeInsets.all(4), padding: const EdgeInsets.all(4),
constraints: const BoxConstraints(), constraints: const BoxConstraints(),
), ),
], ],
), ),
// Speed selector // Speed selector
DropdownButton<double>( DropdownButton<double>(
value: state.speed, value: state.speed,
isDense: true, isDense: true,
underline: const SizedBox(), underline: const SizedBox(),
style: const TextStyle( style: const TextStyle(
color: Colors.white70, fontSize: 12), color: Colors.white70, fontSize: 12),
items: const [ items: const [
DropdownMenuItem(value: 0.5, child: Text('0.5×')), DropdownMenuItem(value: 0.5, child: Text('0.5×')),
DropdownMenuItem(value: 1.0, child: Text('1×')), DropdownMenuItem(value: 1.0, child: Text('1×')),
DropdownMenuItem(value: 2.0, child: Text('2×')), DropdownMenuItem(value: 2.0, child: Text('2×')),
DropdownMenuItem(value: 5.0, child: Text('5×')), DropdownMenuItem(value: 5.0, child: Text('5×')),
], ],
onChanged: (v) { onChanged: (v) {
if (v != null) notifier.setSpeed(v); if (v != null) notifier.setSpeed(v);
}, },
), ),
], ],
), ),
], ],
), ),
); );
} }
} }

View File

@ -1,265 +1,391 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/bluetooth/bt_poller.dart'; import '../../../core/bluetooth/bt_poller.dart';
import '../../../core/providers/bt_provider.dart'; import '../../../core/providers/bt_provider.dart';
import '../../../core/providers/sensor_provider.dart'; import '../../../core/providers/sensor_provider.dart';
import '../../../core/providers/settings_provider.dart'; import '../../../core/providers/settings_provider.dart';
import '../../../core/protocol/neg8.dart'; import '../../../core/protocol/neg8.dart';
import '../../../core/protocol/sensor_state.dart'; import '../../../core/protocol/sensor_state.dart';
class RawFrameDebugScreen extends ConsumerWidget { class RawFrameDebugScreen extends ConsumerStatefulWidget {
const RawFrameDebugScreen({super.key}); const RawFrameDebugScreen({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { ConsumerState<RawFrameDebugScreen> createState() =>
final bt = ref.watch(btProvider); _RawFrameDebugScreenState();
final settings = ref.watch(settingsProvider); }
final sensorAsync = ref.watch(sensorStateProvider);
final state = ref.watch(latestSensorProvider); class _RawFrameDebugScreenState extends ConsumerState<RawFrameDebugScreen> {
final decodeStats = ref.watch(decodeStatsProvider); Uint8List? _probeResult;
final frame = bt.lastFrame; bool _probing = false;
final hex = frame == null ? '' : _hex(frame);
Future<void> _runProbe() async {
return Scaffold( setState(() {
appBar: AppBar( _probing = true;
title: const Text('ECU Frame Debug'), _probeResult = null;
actions: [ });
IconButton( final result =
tooltip: 'Copy raw frame', await ref.read(btProvider.notifier).probeOfficialProtocol();
icon: const Icon(Icons.copy), if (!mounted) return;
onPressed: frame == null setState(() {
? null _probing = false;
: () async { _probeResult = result;
await Clipboard.setData(ClipboardData(text: hex)); });
if (context.mounted) { }
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Raw frame copied')), @override
); Widget build(BuildContext context) {
} final bt = ref.watch(btProvider);
}, final settings = ref.watch(settingsProvider);
), final sensorAsync = ref.watch(sensorStateProvider);
], final state = ref.watch(latestSensorProvider);
), final decodeStats = ref.watch(decodeStatsProvider);
body: ListView( final frame = bt.lastFrame;
padding: const EdgeInsets.all(12), final hex = frame == null ? '' : _hex(frame);
children: [
_Section( return Scaffold(
title: 'Connection', appBar: AppBar(
rows: [ title: const Text('ECU Frame Debug'),
_RowData('Protocol', settings.ecuType.name.toUpperCase()), actions: [
_RowData('Frames (raw, from poller)', bt.frameCount.toString()), IconButton(
_RowData('Dropped (bad checksum)', bt.droppedFrames.toString()), tooltip: 'Copy raw frame',
_RowData('Last frame bytes', frame?.length.toString() ?? 'none'), icon: const Icon(Icons.copy),
if (frame != null) onPressed: frame == null
_RowData( ? null
'Checksum', : () async {
validateFrame(frame) ? 'valid' : 'bad', await Clipboard.setData(ClipboardData(text: hex));
), if (context.mounted) {
], ScaffoldMessenger.of(context).showSnackBar(
), const SnackBar(content: Text('Raw frame copied')),
const SizedBox(height: 12), );
_Section( }
title: 'Decode Pipeline', },
rows: [ ),
_RowData('Decoded -> UI', decodeStats.decoded.toString()), ],
_RowData('Skipped (decode error)', decodeStats.decodeErrors.toString()), ),
_RowData( body: ListView(
'sensorStateProvider raw', padding: const EdgeInsets.all(12),
sensorAsync.when( children: [
data: (s) => _Section(
'data(rpm=${s.rpm.toStringAsFixed(0)}, age=${DateTime.now().difference(s.timestamp).inMilliseconds}ms)', title: 'Connection',
loading: () => 'loading (no frame yet)', rows: [
error: (e, st) => 'error: $e', _RowData('Protocol', settings.ecuType.name.toUpperCase()),
), _RowData('Frames (raw, from poller)', bt.frameCount.toString()),
), _RowData('Dropped (bad checksum)', bt.droppedFrames.toString()),
_RowData( _RowData('Last frame bytes', frame?.length.toString() ?? 'none'),
'latestSensorProvider age', if (frame != null)
'${DateTime.now().difference(state.timestamp).inMilliseconds}ms since decode', _RowData(
), 'Checksum',
], validateFrame(frame) ? 'valid' : 'bad',
), ),
const SizedBox(height: 12), ],
_Section( ),
title: 'Current App Decode', const SizedBox(height: 12),
rows: _decodedRows(state), _ProtocolProbeCard(
), connected: bt.isConnected,
const SizedBox(height: 12), probing: _probing,
if (frame == null) result: _probeResult,
const Center( onProbe: _runProbe,
child: Padding( ),
padding: EdgeInsets.all(24), const SizedBox(height: 12),
child: Text( _Section(
'No ECU frame received yet.', title: 'Decode Pipeline',
style: TextStyle(color: Colors.grey), rows: [
), _RowData('Decoded -> UI', decodeStats.decoded.toString()),
), _RowData('Skipped (decode error)', decodeStats.decodeErrors.toString()),
) _RowData(
else ...[ 'sensorStateProvider raw',
if (settings.ecuType == EcuType.s300) ...[ sensorAsync.when(
_Section( data: (s) =>
title: 'S300 Raw Candidates', 'data(rpm=${s.rpm.toStringAsFixed(0)}, age=${DateTime.now().difference(s.timestamp).inMilliseconds}ms)',
rows: _s300CandidateRows(frame), loading: () => 'loading (no frame yet)',
), error: (e, st) => 'error: $e',
const SizedBox(height: 12), ),
], ),
_RawHexBlock(hex: hex), _RowData(
], 'latestSensorProvider age',
], '${DateTime.now().difference(state.timestamp).inMilliseconds}ms since decode',
), ),
); ],
} ),
const SizedBox(height: 12),
List<_RowData> _decodedRows(SensorState s) => [ _Section(
_RowData('RPM', s.rpm.toStringAsFixed(0)), title: 'Current App Decode',
_RowData('Speed km/h', s.vss.toStringAsFixed(2)), rows: _decodedRows(state),
_RowData('MAP kPa', s.map.toStringAsFixed(2)), ),
_RowData('TPS', s.tps.toStringAsFixed(2)), const SizedBox(height: 12),
_RowData('ECT', s.ect.toStringAsFixed(2)), if (frame == null)
_RowData('IAT', s.iat.toStringAsFixed(2)), const Center(
_RowData('Battery', s.bat.toStringAsFixed(2)), child: Padding(
_RowData('O2', s.o2.toStringAsFixed(2)), padding: EdgeInsets.all(24),
_RowData('AFR', s.afr.toStringAsFixed(2)), child: Text(
]; 'No ECU frame received yet.',
style: TextStyle(color: Colors.grey),
List<_RowData> _s300CandidateRows(Uint8List f) { ),
final rpmLe = _u16le(f, 3); ),
final vssLe = _u16le(f, 5); )
final mapLe = _u16le(f, 7); else ...[
if (settings.ecuType == EcuType.s300) ...[
return [ _Section(
_RowData('Header bytes', '${_b(f, 0)} ${_b(f, 1)} ${_b(f, 2)}'), title: 'S300 Raw Candidates',
_RowData('RPM raw @3 LE', rpmLe.toString()), rows: _s300CandidateRows(frame),
_RowData('VSS raw @5 LE', vssLe.toString()), ),
_RowData('VSS current km/h', _s300Vss(vssLe).toStringAsFixed(2)), const SizedBox(height: 12),
_RowData('MAP raw @7 LE', mapLe.toString()), ],
_RowData('MAP current kPa', _s300Map(mapLe).toStringAsFixed(2)), _RawHexBlock(hex: hex),
_RowData('TPS raw @9', f[9].toString()), ],
_RowData('TPS current', _s300Tps(f[9]).toStringAsFixed(2)), ],
_RowData('Gear raw @27 (decimal)', f[27].toString()), ),
_RowData('INJ raw @10 LE (offset unverified)', _u16le(f, 10).toString()), );
_RowData('IGN raw @12 (offset unverified)', f[12].toString()), }
_RowData('O2 current raw (offset unverified)', f[16].toString()),
_RowData('ECT raw @45', f[0x2D].toString()), List<_RowData> _decodedRows(SensorState s) => [
_RowData('IAT raw @46', f[0x2E].toString()), _RowData('RPM', s.rpm.toStringAsFixed(0)),
_RowData('BAT raw @48 (offset unverified)', f[0x30].toString()), _RowData('Speed km/h', s.vss.toStringAsFixed(2)),
_RowData('PA raw @44 (offset unverified)', f[0x2C].toString()), _RowData('MAP kPa', s.map.toStringAsFixed(2)),
_RowData('ERR bytes @49-52', _RowData('TPS', s.tps.toStringAsFixed(2)),
'${_b(f, 0x31)} ${_b(f, 0x32)} ${_b(f, 0x33)} ${_b(f, 0x34)}'), _RowData('ECT', s.ect.toStringAsFixed(2)),
]; _RowData('IAT', s.iat.toStringAsFixed(2)),
} _RowData('Battery', s.bat.toStringAsFixed(2)),
_RowData('O2', s.o2.toStringAsFixed(2)),
static int _u16le(Uint8List f, int o) => f[o] | (f[o + 1] << 8); _RowData('AFR', s.afr.toStringAsFixed(2)),
static String _b(Uint8List f, int o) => _RowData('MIL (unverified)', s.mil.toString()),
f[o].toRadixString(16).padLeft(2, '0'); _RowData('Fuel Pump', s.fuelPump.toString()),
_RowData('Fan Out (unverified)', s.fanOut.toString()),
static double _s300Vss(int raw) { _RowData('VTEC (unverified)', s.vtec.toString()),
return (raw < 893 || raw == 0xFFFF) ? 0.0 : 228480.0 / raw; _RowData('Knock', s.knock.toString()),
} _RowData('Rev Limit (unverified)', s.revLimit.toString()),
_RowData('Launch (unverified)', s.launch.toString()),
static double _s300Map(int raw) { ];
return raw / 10.0;
} List<_RowData> _s300CandidateRows(Uint8List f) {
final rpmLe = _u16le(f, 3);
static double _s300Tps(int raw) { final vssLe = _u16le(f, 5);
return (raw * 100.0 / 255.0).clamp(0.0, 100.0); final mapLe = _u16le(f, 7);
}
return [
static String _hex(Uint8List frame) { _RowData('Header bytes', '${_b(f, 0)} ${_b(f, 1)} ${_b(f, 2)}'),
final parts = <String>[]; _RowData('RPM raw @3 LE', rpmLe.toString()),
for (var i = 0; i < frame.length; i++) { _RowData('VSS raw @5 LE', vssLe.toString()),
if (i > 0 && i % 16 == 0) parts.add('\n'); _RowData('VSS current km/h', _s300Vss(vssLe).toStringAsFixed(2)),
parts.add(frame[i].toRadixString(16).padLeft(2, '0')); _RowData('MAP raw @7 LE', mapLe.toString()),
} _RowData('MAP current kPa', _s300Map(mapLe).toStringAsFixed(2)),
return parts.join(' '); _RowData('TPS raw @9', f[9].toString()),
} _RowData('TPS current', _s300Tps(f[9]).toStringAsFixed(2)),
} _RowData('Gear raw @27 (decimal)', f[27].toString()),
_RowData('INJ raw @10 LE (offset unverified)', _u16le(f, 10).toString()),
class _Section extends StatelessWidget { _RowData('IGN raw @12 (offset unverified)', f[12].toString()),
final String title; _RowData('O2 current raw (offset unverified)', f[16].toString()),
final List<_RowData> rows; _RowData('ECT raw @33', f[33].toString()),
_RowData('IAT raw @34', f[34].toString()),
const _Section({required this.title, required this.rows}); _RowData('BAT raw @36', f[36].toString()),
_RowData('PA raw @44 (offset unverified)', f[0x2C].toString()),
@override _RowData('PA candidate raw @50 (unconfirmed, ~3kPa off)', f[50].toString()),
Widget build(BuildContext context) { _RowData('ERR bytes @49-52',
return Card( '${_b(f, 0x31)} ${_b(f, 0x32)} ${_b(f, 0x33)} ${_b(f, 0x34)}'),
child: Padding( _RowData('SW1 @17 (bit7..0)', '${_b(f, 0x11)} ${_bin(f, 0x11)}'),
padding: const EdgeInsets.all(12), _RowData('SW2 @18 (bit7..0)', '${_b(f, 0x12)} ${_bin(f, 0x12)}'),
child: Column( _RowData('SW3 @19 (bit7..0)', '${_b(f, 0x13)} ${_bin(f, 0x13)}'),
crossAxisAlignment: CrossAxisAlignment.start, _RowData('SW5 @67 (bit7..0)', '${_b(f, 0x43)} ${_bin(f, 0x43)}'),
children: [ ];
Text( }
title,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13), static int _u16le(Uint8List f, int o) => f[o] | (f[o + 1] << 8);
), static String _b(Uint8List f, int o) =>
const SizedBox(height: 8), f[o].toRadixString(16).padLeft(2, '0');
for (final row in rows) _InfoRow(row), static String _bin(Uint8List f, int o) =>
], f[o].toRadixString(2).padLeft(8, '0');
),
), static double _s300Vss(int raw) {
); return (raw < 893 || raw == 0xFFFF) ? 0.0 : 228480.0 / raw;
} }
}
static double _s300Map(int raw) {
class _InfoRow extends StatelessWidget { return raw / 10.0;
final _RowData row; }
const _InfoRow(this.row); static double _s300Tps(int raw) {
return (raw * 100.0 / 255.0).clamp(0.0, 100.0);
@override }
Widget build(BuildContext context) {
return Padding( static String _hex(Uint8List frame) {
padding: const EdgeInsets.symmetric(vertical: 3), final parts = <String>[];
child: Row( for (var i = 0; i < frame.length; i++) {
children: [ if (i > 0 && i % 16 == 0) parts.add('\n');
Expanded( parts.add(frame[i].toRadixString(16).padLeft(2, '0'));
child: Text( }
row.label, return parts.join(' ');
style: const TextStyle(color: Colors.grey, fontSize: 12), }
), }
),
const SizedBox(width: 12), /// Step 1 of the data-layer review: checks whether this device/firmware
Flexible( /// understands the official, self-describing Hondata Bluetooth protocol
child: Text( /// (Bluetooth.pdf) rather than only the legacy fixed-offset frame format
row.value, /// this app currently speaks. A real reply here (Protocol=0x00, ID=0x02,
textAlign: TextAlign.right, /// with a recognizable DeviceType byte) means migrating to that protocol is
style: const TextStyle( /// on the table; silence means this hardware is legacy-only.
fontSize: 12, class _ProtocolProbeCard extends StatelessWidget {
fontFamily: 'monospace', final bool connected;
fontWeight: FontWeight.w600, final bool probing;
), final Uint8List? result;
), final VoidCallback onProbe;
),
], const _ProtocolProbeCard({
), required this.connected,
); required this.probing,
} required this.result,
} required this.onProbe,
});
class _RawHexBlock extends StatelessWidget {
final String hex; @override
Widget build(BuildContext context) {
const _RawHexBlock({required this.hex}); return Card(
child: Padding(
@override padding: const EdgeInsets.all(12),
Widget build(BuildContext context) { child: Column(
return Card( crossAxisAlignment: CrossAxisAlignment.start,
child: Padding( children: [
padding: const EdgeInsets.all(12), const Text(
child: SelectableText( 'Official Protocol Probe',
hex, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
style: const TextStyle(fontFamily: 'monospace', fontSize: 11), ),
), const SizedBox(height: 4),
), const Text(
); 'Sends DL_DeviceInfo (00 02 04 00). Pauses normal polling for '
} 'the duration of the probe.',
} style: TextStyle(color: Colors.grey, fontSize: 11),
),
class _RowData { const SizedBox(height: 8),
final String label; SizedBox(
final String value; width: double.infinity,
child: ElevatedButton(
const _RowData(this.label, this.value); onPressed: (connected && !probing) ? onProbe : null,
} child: Text(probing ? 'Probing…' : 'Send Probe'),
),
),
if (result != null) ...[
const SizedBox(height: 8),
for (final row in _interpret(result!)) _InfoRow(row),
],
],
),
),
);
}
List<_RowData> _interpret(Uint8List r) {
if (r.isEmpty) {
return const [
_RowData('Result', 'No response — likely legacy protocol only'),
];
}
final hex = r.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ');
final rows = [_RowData('Raw bytes', hex)];
if (r.length >= 4) {
final size = r[2] | (r[3] << 8); // little-endian per TDLHeader.Size
rows.add(_RowData('Protocol / ID / Size', '${r[0]} / ${r[1]} / $size'));
}
if (r.length >= 6) {
final deviceType = switch (r[4]) {
0xC0 => 'S300',
0xC1 => 'KPro',
0xC2 => 'FlashPro',
_ => 'unknown (0x${r[4].toRadixString(16)})',
};
rows.add(_RowData('Device type', deviceType));
rows.add(_RowData('Ignition', r[5] != 0 ? 'On' : 'Off'));
}
return rows;
}
}
class _Section extends StatelessWidget {
final String title;
final List<_RowData> rows;
const _Section({required this.title, required this.rows});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
),
const SizedBox(height: 8),
for (final row in rows) _InfoRow(row),
],
),
),
);
}
}
class _InfoRow extends StatelessWidget {
final _RowData row;
const _InfoRow(this.row);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
children: [
Expanded(
child: Text(
row.label,
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
),
const SizedBox(width: 12),
Flexible(
child: Text(
row.value,
textAlign: TextAlign.right,
style: const TextStyle(
fontSize: 12,
fontFamily: 'monospace',
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
}
class _RawHexBlock extends StatelessWidget {
final String hex;
const _RawHexBlock({required this.hex});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: SelectableText(
hex,
style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
),
),
);
}
}
class _RowData {
final String label;
final String value;
const _RowData(this.label, this.value);
}

View File

@ -1,92 +1,232 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter/services.dart';
import '../../../core/protocol/dtc_map.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/providers/sensor_provider.dart'; import '../../../core/protocol/dtc_map.dart';
import '../../../core/providers/settings_provider.dart'; import '../../../core/providers/bt_provider.dart';
import '../../../core/bluetooth/bt_poller.dart'; import '../../../core/providers/sensor_provider.dart';
import 'widgets/dtc_row.dart'; import '../../../core/providers/settings_provider.dart';
import '../../../core/bluetooth/bt_poller.dart';
class DtcScreen extends ConsumerWidget { import 'widgets/dtc_row.dart';
const DtcScreen({super.key});
class DtcScreen extends ConsumerStatefulWidget {
@override const DtcScreen({super.key});
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(latestSensorProvider); @override
final ecuType = ref.watch(settingsProvider).ecuType; ConsumerState<DtcScreen> createState() => _DtcScreenState();
}
final dtcMap =
ecuType == EcuType.s300 ? s300DtcMap : kproDtcMap; class _DtcScreenState extends ConsumerState<DtcScreen> {
final active = decodeDtcs(state.errBytes, dtcMap); bool _clearing = false;
Uint8List? _lastRaw;
return Column(
children: [ String _hex(Uint8List bytes) {
// Header bar final parts = <String>[];
Container( for (var i = 0; i < bytes.length; i++) {
color: const Color(0xFF1A1A1A), if (i > 0 && i % 16 == 0) parts.add('\n');
padding: parts.add(bytes[i].toRadixString(16).padLeft(2, '0'));
const EdgeInsets.symmetric(horizontal: 16, vertical: 10), }
child: Row( return parts.join(' ');
children: [ }
Icon(
active.isEmpty void _showRawCapture() {
? Icons.check_circle_outline final raw = _lastRaw;
: Icons.warning_amber_rounded, if (raw == null) return;
color: active.isEmpty ? Colors.green : Colors.amber, final hex = _hex(raw);
size: 18, showDialog<void>(
), context: context,
const SizedBox(width: 8), builder: (context) => AlertDialog(
Text( title: Text('Raw capture (${raw.length} bytes)'),
active.isEmpty content: SizedBox(
? 'No active fault codes' width: double.maxFinite,
: '${active.length} active DTC${active.length > 1 ? "s" : ""}', child: SingleChildScrollView(
style: TextStyle( child: SelectableText(
color: active.isEmpty ? Colors.green : Colors.amber, hex,
fontSize: 13, style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
fontWeight: FontWeight.w600, ),
), ),
), ),
const Spacer(), actions: [
Text( TextButton(
ecuType == EcuType.s300 ? 'S300' : 'KPro', onPressed: () async {
style: await Clipboard.setData(ClipboardData(text: hex));
const TextStyle(color: Colors.grey, fontSize: 12), if (context.mounted) {
), ScaffoldMessenger.of(context).showSnackBar(
], const SnackBar(content: Text('Copied')),
), );
), }
const Divider(height: 1, color: Colors.white10), },
child: const Text('Copy'),
// DTC list ),
Expanded( TextButton(
child: active.isEmpty onPressed: () => Navigator.pop(context),
? const Center( child: const Text('Close'),
child: Column( ),
mainAxisSize: MainAxisSize.min, ],
children: [ ),
Icon(Icons.check_circle_outline, );
size: 56, color: Colors.green), }
SizedBox(height: 12),
Text('All clear — no fault codes', Future<void> _confirmAndClear() async {
style: TextStyle( final confirmed = await showDialog<bool>(
color: Colors.grey, fontSize: 15)), context: context,
], builder: (context) => AlertDialog(
), title: const Text('Clear Trouble Codes'),
) content: const Text(
: ListView.separated( 'This sends a reset command to the ECU and erases all stored '
itemCount: active.length, 'fault codes. This cannot be undone.',
separatorBuilder: (_, __) => ),
const Divider(height: 1, color: Colors.white10), actions: [
itemBuilder: (context, i) { TextButton(
final full = active[i]; // "P0130 — O2 Sensor (front)" onPressed: () => Navigator.pop(context, false),
final parts = full.split(' — '); child: const Text('Cancel'),
final code = parts.first.trim(); ),
final desc = TextButton(
parts.length > 1 ? parts.sublist(1).join(' — ') : ''; onPressed: () => Navigator.pop(context, true),
return DtcRow(code: code, description: desc); child: const Text('Clear',
}, style: TextStyle(color: Colors.red)),
), ),
), ],
], ),
); );
} if (confirmed != true) return;
}
setState(() => _clearing = true);
final result = await ref.read(btProvider.notifier).clearDtcs();
if (!mounted) return;
setState(() {
_clearing = false;
_lastRaw = result.raw;
});
final String message;
if (result.acknowledged) {
message = 'Trouble codes cleared';
} else if (result.raw.isEmpty) {
message = 'No response from ECU — codes may not have cleared';
} else {
// The scan runs alongside normal polling now, so "not acknowledged"
// can mean several seconds' worth of regular datalog traffic with no
// ack byte pattern found in it — dumping all of that raw would be
// unusable in a snackbar, so just report how much was seen. "View"
// opens the full hex to check by hand for anything our narrow 3-byte
// pattern match might have missed.
message =
'No ack seen — ${result.raw.length} bytes of other traffic during the window';
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
action: (!result.acknowledged && result.raw.isNotEmpty)
? SnackBarAction(label: 'View', onPressed: _showRawCapture)
: null,
duration: const Duration(seconds: 6),
),
);
}
@override
Widget build(BuildContext context) {
final state = ref.watch(latestSensorProvider);
final bt = ref.watch(btProvider);
final ecuType = ref.watch(settingsProvider).ecuType;
final dtcMap =
ecuType == EcuType.s300 ? s300DtcMap : kproDtcMap;
final active = decodeDtcs(state.errBytes, dtcMap);
return Column(
children: [
// Header bar
Container(
color: const Color(0xFF1A1A1A),
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
children: [
Icon(
active.isEmpty
? Icons.check_circle_outline
: Icons.warning_amber_rounded,
color: active.isEmpty ? Colors.green : Colors.amber,
size: 18,
),
const SizedBox(width: 8),
Text(
active.isEmpty
? 'No active fault codes'
: '${active.length} active DTC${active.length > 1 ? "s" : ""}',
style: TextStyle(
color: active.isEmpty ? Colors.green : Colors.amber,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
Text(
ecuType == EcuType.s300 ? 'S300' : 'KPro',
style:
const TextStyle(color: Colors.grey, fontSize: 12),
),
],
),
),
const Divider(height: 1, color: Colors.white10),
// DTC list
Expanded(
child: active.isEmpty
? const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_circle_outline,
size: 56, color: Colors.green),
SizedBox(height: 12),
Text('All clear — no fault codes',
style: TextStyle(
color: Colors.grey, fontSize: 15)),
],
),
)
: ListView.separated(
itemCount: active.length,
separatorBuilder: (_, __) =>
const Divider(height: 1, color: Colors.white10),
itemBuilder: (context, i) {
final full = active[i]; // "P0130 — O2 Sensor (front)"
final parts = full.split(' — ');
final code = parts.first.trim();
final desc =
parts.length > 1 ? parts.sublist(1).join(' — ') : '';
return DtcRow(code: code, description: desc);
},
),
),
SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.all(16),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
),
onPressed: (bt.isConnected && !_clearing)
? _confirmAndClear
: null,
child: Text(
_clearing ? 'Clearing…' : 'CLEAR TROUBLE CODES',
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
),
),
),
],
);
}
}

View File

@ -1,44 +1,44 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class DtcRow extends StatelessWidget { class DtcRow extends StatelessWidget {
final String code; final String code;
final String description; final String description;
const DtcRow({super.key, required this.code, required this.description}); const DtcRow({super.key, required this.code, required this.description});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.red.withAlpha(30), color: Colors.red.withAlpha(30),
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
border: Border.all(color: Colors.red.withAlpha(100)), border: Border.all(color: Colors.red.withAlpha(100)),
), ),
child: Text( child: Text(
code, code,
style: const TextStyle( style: const TextStyle(
color: Colors.redAccent, color: Colors.redAccent,
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontFamily: 'monospace', fontFamily: 'monospace',
), ),
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Text( child: Text(
description, description,
style: const TextStyle(color: Colors.white70, fontSize: 13), style: const TextStyle(color: Colors.white70, fontSize: 13),
), ),
), ),
], ],
), ),
); );
} }
} }

View File

@ -1,136 +1,136 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/models/sensor_defs.dart'; import '../../../core/models/sensor_defs.dart';
import '../../../core/providers/sensor_history_provider.dart'; import '../../../core/providers/sensor_history_provider.dart';
import '../../../core/providers/theme_provider.dart'; import '../../../core/providers/theme_provider.dart';
import '../../../ui/theme/app_colors.dart'; import '../../../ui/theme/app_colors.dart';
import 'widgets/graph_time_selector.dart'; import 'widgets/graph_time_selector.dart';
import 'widgets/sensor_graph_panel.dart'; import 'widgets/sensor_graph_panel.dart';
final _graphWindowProvider = StateProvider<int>((ref) => 60); final _graphWindowProvider = StateProvider<int>((ref) => 60);
final _graphSensorsProvider = StateProvider<List<String>>( final _graphSensorsProvider = StateProvider<List<String>>(
(ref) => ['rpm', 'map', 'tps'], (ref) => ['rpm', 'map', 'tps'],
); );
class GraphScreen extends ConsumerWidget { class GraphScreen extends ConsumerWidget {
const GraphScreen({super.key}); const GraphScreen({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final history = ref.watch(sensorHistoryProvider); final history = ref.watch(sensorHistoryProvider);
final colors = AppColors.forTheme(ref.watch(themeProvider)); final colors = AppColors.forTheme(ref.watch(themeProvider));
final window = ref.watch(_graphWindowProvider); final window = ref.watch(_graphWindowProvider);
final sensors = ref.watch(_graphSensorsProvider); final sensors = ref.watch(_graphSensorsProvider);
return Column( return Column(
children: [ children: [
// Time selector bar // Time selector bar
Container( Container(
color: const Color(0xFF1A1A1A), color: const Color(0xFF1A1A1A),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row( child: Row(
children: [ children: [
const Text('Window:', const Text('Window:',
style: TextStyle(color: Colors.grey, fontSize: 12)), style: TextStyle(color: Colors.grey, fontSize: 12)),
const SizedBox(width: 8), const SizedBox(width: 8),
GraphTimeSelector( GraphTimeSelector(
windowSeconds: window, windowSeconds: window,
onChanged: (s) => onChanged: (s) =>
ref.read(_graphWindowProvider.notifier).state = s, ref.read(_graphWindowProvider.notifier).state = s,
), ),
const Spacer(), const Spacer(),
IconButton( IconButton(
icon: Icon(Icons.add_chart, color: colors.accent, size: 20), icon: Icon(Icons.add_chart, color: colors.accent, size: 20),
tooltip: 'Add sensor', tooltip: 'Add sensor',
onPressed: () => _addSensor(context, ref, sensors), onPressed: () => _addSensor(context, ref, sensors),
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
constraints: const BoxConstraints(), constraints: const BoxConstraints(),
), ),
], ],
), ),
), ),
// Graph panels // Graph panels
Expanded( Expanded(
child: sensors.isEmpty child: sensors.isEmpty
? Center( ? Center(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.show_chart, Icon(Icons.show_chart,
size: 48, color: colors.accent.withAlpha(80)), size: 48, color: colors.accent.withAlpha(80)),
const SizedBox(height: 12), const SizedBox(height: 12),
const Text('No sensors selected', const Text('No sensors selected',
style: TextStyle(color: Colors.grey)), style: TextStyle(color: Colors.grey)),
const SizedBox(height: 8), const SizedBox(height: 8),
ElevatedButton.icon( ElevatedButton.icon(
onPressed: () => _addSensor(context, ref, sensors), onPressed: () => _addSensor(context, ref, sensors),
icon: const Icon(Icons.add, size: 16), icon: const Icon(Icons.add, size: 16),
label: const Text('Add sensor'), label: const Text('Add sensor'),
), ),
], ],
), ),
) )
: ListView.builder( : ListView.builder(
itemCount: sensors.length, itemCount: sensors.length,
itemBuilder: (context, i) => SensorGraphPanel( itemBuilder: (context, i) => SensorGraphPanel(
sensorId: sensors[i], sensorId: sensors[i],
history: history, history: history,
windowSeconds: window, windowSeconds: window,
accentColor: colors.accent, accentColor: colors.accent,
onRemove: () { onRemove: () {
final next = List<String>.from(sensors)..removeAt(i); final next = List<String>.from(sensors)..removeAt(i);
ref.read(_graphSensorsProvider.notifier).state = next; ref.read(_graphSensorsProvider.notifier).state = next;
}, },
), ),
), ),
), ),
], ],
); );
} }
void _addSensor( void _addSensor(
BuildContext context, WidgetRef ref, List<String> current) { BuildContext context, WidgetRef ref, List<String> current) {
showModalBottomSheet<void>( showModalBottomSheet<void>(
context: context, context: context,
backgroundColor: const Color(0xFF1A1A1A), backgroundColor: const Color(0xFF1A1A1A),
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)), borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
), ),
builder: (_) => ListView( builder: (_) => ListView(
children: [ children: [
const Padding( const Padding(
padding: EdgeInsets.all(16), padding: EdgeInsets.all(16),
child: Text('ADD SENSOR TO GRAPH', child: Text('ADD SENSOR TO GRAPH',
style: TextStyle( style: TextStyle(
color: Colors.white70, color: Colors.white70,
fontSize: 12, fontSize: 12,
letterSpacing: 2)), letterSpacing: 2)),
), ),
const Divider(height: 1, color: Colors.white10), const Divider(height: 1, color: Colors.white10),
...sensorDefs.map((def) { ...sensorDefs.map((def) {
final already = current.contains(def.id); final already = current.contains(def.id);
return ListTile( return ListTile(
title: Text(def.displayName, title: Text(def.displayName,
style: TextStyle( style: TextStyle(
color: already ? Colors.grey : Colors.white)), color: already ? Colors.grey : Colors.white)),
subtitle: Text(def.unit, subtitle: Text(def.unit,
style: const TextStyle( style: const TextStyle(
color: Colors.grey, fontSize: 11)), color: Colors.grey, fontSize: 11)),
trailing: already trailing: already
? const Icon(Icons.check, ? const Icon(Icons.check,
color: Colors.grey, size: 16) color: Colors.grey, size: 16)
: null, : null,
onTap: already onTap: already
? null ? null
: () { : () {
ref.read(_graphSensorsProvider.notifier).state = ref.read(_graphSensorsProvider.notifier).state =
[...current, def.id]; [...current, def.id];
Navigator.pop(context); Navigator.pop(context);
}, },
); );
}), }),
], ],
), ),
); );
} }
} }

View File

@ -1,49 +1,49 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class GraphTimeSelector extends StatelessWidget { class GraphTimeSelector extends StatelessWidget {
final int windowSeconds; final int windowSeconds;
final void Function(int seconds) onChanged; final void Function(int seconds) onChanged;
static const _options = [30, 60, 120, 300]; static const _options = [30, 60, 120, 300];
const GraphTimeSelector({ const GraphTimeSelector({
super.key, super.key,
required this.windowSeconds, required this.windowSeconds,
required this.onChanged, required this.onChanged,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final accent = Theme.of(context).colorScheme.primary; final accent = Theme.of(context).colorScheme.primary;
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: _options.map((s) { children: _options.map((s) {
final selected = s == windowSeconds; final selected = s == windowSeconds;
return GestureDetector( return GestureDetector(
onTap: () => onChanged(s), onTap: () => onChanged(s),
child: AnimatedContainer( child: AnimatedContainer(
duration: const Duration(milliseconds: 120), duration: const Duration(milliseconds: 120),
margin: const EdgeInsets.only(left: 4), margin: const EdgeInsets.only(left: 4),
padding: padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 4), const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: selected ? accent.withAlpha(30) : Colors.transparent, color: selected ? accent.withAlpha(30) : Colors.transparent,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all( border: Border.all(
color: selected ? accent : Colors.white24, color: selected ? accent : Colors.white24,
), ),
), ),
child: Text( child: Text(
s >= 60 ? '${s ~/ 60}m' : '${s}s', s >= 60 ? '${s ~/ 60}m' : '${s}s',
style: TextStyle( style: TextStyle(
color: selected ? accent : Colors.white54, color: selected ? accent : Colors.white54,
fontSize: 12, fontSize: 12,
fontWeight: selected ? FontWeight.bold : FontWeight.normal, fontWeight: selected ? FontWeight.bold : FontWeight.normal,
), ),
), ),
), ),
); );
}).toList(), }).toList(),
); );
} }
} }

View File

@ -1,133 +1,133 @@
import 'package:fl_chart/fl_chart.dart'; import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../../core/models/sensor_defs.dart'; import '../../../../core/models/sensor_defs.dart';
import '../../../../core/providers/sensor_history_provider.dart'; import '../../../../core/providers/sensor_history_provider.dart';
class SensorGraphPanel extends StatelessWidget { class SensorGraphPanel extends StatelessWidget {
final String sensorId; final String sensorId;
final SensorHistory history; final SensorHistory history;
final int windowSeconds; final int windowSeconds;
final Color accentColor; final Color accentColor;
final VoidCallback onRemove; final VoidCallback onRemove;
const SensorGraphPanel({ const SensorGraphPanel({
super.key, super.key,
required this.sensorId, required this.sensorId,
required this.history, required this.history,
required this.windowSeconds, required this.windowSeconds,
required this.accentColor, required this.accentColor,
required this.onRemove, required this.onRemove,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final def = sensorDefs.firstWhere( final def = sensorDefs.firstWhere(
(d) => d.id == sensorId, (d) => d.id == sensorId,
orElse: () => sensorDefs.first, orElse: () => sensorDefs.first,
); );
final allSpots = history.get(sensorId); final allSpots = history.get(sensorId);
// Slice to window // Slice to window
final spots = _window(allSpots, windowSeconds.toDouble()); final spots = _window(allSpots, windowSeconds.toDouble());
return Container( return Container(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
padding: const EdgeInsets.fromLTRB(8, 10, 12, 8), padding: const EdgeInsets.fromLTRB(8, 10, 12, 8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF1A1A1A), color: const Color(0xFF1A1A1A),
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Header // Header
Row( Row(
children: [ children: [
Text( Text(
def.displayName.toUpperCase(), def.displayName.toUpperCase(),
style: TextStyle( style: TextStyle(
color: accentColor, color: accentColor,
fontSize: 11, fontSize: 11,
letterSpacing: 1.5, letterSpacing: 1.5,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(
def.unit, def.unit,
style: const TextStyle(color: Colors.grey, fontSize: 11), style: const TextStyle(color: Colors.grey, fontSize: 11),
), ),
const Spacer(), const Spacer(),
if (spots.isNotEmpty) if (spots.isNotEmpty)
Text( Text(
_fmt(spots.last.y), _fmt(spots.last.y),
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
GestureDetector( GestureDetector(
onTap: onRemove, onTap: onRemove,
child: const Icon(Icons.close, child: const Icon(Icons.close,
size: 16, color: Colors.white38), size: 16, color: Colors.white38),
), ),
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
// Chart // Chart
SizedBox( SizedBox(
height: 90, height: 90,
child: spots.length < 2 child: spots.length < 2
? const Center( ? const Center(
child: Text('Waiting for data…', child: Text('Waiting for data…',
style: TextStyle(color: Colors.white30, style: TextStyle(color: Colors.white30,
fontSize: 12))) fontSize: 12)))
: LineChart( : LineChart(
LineChartData( LineChartData(
gridData: FlGridData( gridData: FlGridData(
show: true, show: true,
drawVerticalLine: false, drawVerticalLine: false,
getDrawingHorizontalLine: (_) => FlLine( getDrawingHorizontalLine: (_) => FlLine(
color: Colors.white10, color: Colors.white10,
strokeWidth: 1, strokeWidth: 1,
), ),
), ),
borderData: FlBorderData(show: false), borderData: FlBorderData(show: false),
titlesData: const FlTitlesData(show: false), titlesData: const FlTitlesData(show: false),
minY: def.min.toDouble(), minY: def.min.toDouble(),
maxY: def.max.toDouble(), maxY: def.max.toDouble(),
lineBarsData: [ lineBarsData: [
LineChartBarData( LineChartBarData(
spots: spots, spots: spots,
isCurved: true, isCurved: true,
curveSmoothness: 0.2, curveSmoothness: 0.2,
color: accentColor, color: accentColor,
barWidth: 1.5, barWidth: 1.5,
dotData: const FlDotData(show: false), dotData: const FlDotData(show: false),
belowBarData: BarAreaData( belowBarData: BarAreaData(
show: true, show: true,
color: accentColor.withAlpha(25), color: accentColor.withAlpha(25),
), ),
), ),
], ],
), ),
duration: Duration.zero, duration: Duration.zero,
), ),
), ),
], ],
), ),
); );
} }
List<FlSpot> _window(List<FlSpot> spots, double windowSec) { List<FlSpot> _window(List<FlSpot> spots, double windowSec) {
if (spots.isEmpty) return spots; if (spots.isEmpty) return spots;
final maxX = spots.last.x; final maxX = spots.last.x;
final minX = maxX - windowSec; final minX = maxX - windowSec;
return spots.where((s) => s.x >= minX).toList(); return spots.where((s) => s.x >= minX).toList();
} }
String _fmt(double v) => String _fmt(double v) =>
v.abs() >= 100 ? v.toStringAsFixed(1) : v.toStringAsFixed(2); v.abs() >= 100 ? v.toStringAsFixed(1) : v.toStringAsFixed(2);
} }

View File

@ -1,31 +1,31 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/models/sensor_defs.dart'; import '../../../core/models/sensor_defs.dart';
import '../../../core/providers/sensor_provider.dart'; import '../../../core/providers/sensor_provider.dart';
import '../../../core/providers/theme_provider.dart'; import '../../../core/providers/theme_provider.dart';
import '../../../ui/theme/app_colors.dart'; import '../../../ui/theme/app_colors.dart';
import 'widgets/sensor_row.dart'; import 'widgets/sensor_row.dart';
class SensorListScreen extends ConsumerWidget { class SensorListScreen extends ConsumerWidget {
const SensorListScreen({super.key}); const SensorListScreen({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(latestSensorProvider); final state = ref.watch(latestSensorProvider);
final colors = AppColors.forTheme(ref.watch(themeProvider)); final colors = AppColors.forTheme(ref.watch(themeProvider));
return ListView.separated( return ListView.separated(
itemCount: sensorDefs.length, itemCount: sensorDefs.length,
separatorBuilder: (_, __) => separatorBuilder: (_, __) =>
const Divider(height: 1, color: Colors.white10), const Divider(height: 1, color: Colors.white10),
itemBuilder: (context, i) { itemBuilder: (context, i) {
final def = sensorDefs[i]; final def = sensorDefs[i];
return SensorRow( return SensorRow(
def: def, def: def,
value: sensorValue(state, def.id), value: sensorValue(state, def.id),
accentColor: colors.accent, accentColor: colors.accent,
); );
}, },
); );
} }
} }

View File

@ -1,130 +1,130 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../../core/models/sensor_def.dart'; import '../../../../core/models/sensor_def.dart';
import '../../../../core/protocol/sensor_state.dart'; import '../../../../core/protocol/sensor_state.dart';
class SensorRow extends StatelessWidget { class SensorRow extends StatelessWidget {
final SensorDef def; final SensorDef def;
final double value; final double value;
final Color accentColor; final Color accentColor;
const SensorRow({ const SensorRow({
super.key, super.key,
required this.def, required this.def,
required this.value, required this.value,
required this.accentColor, required this.accentColor,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final normalized = final normalized =
((value - def.min) / (def.max - def.min)).clamp(0.0, 1.0); ((value - def.min) / (def.max - def.min)).clamp(0.0, 1.0);
final isHigh = normalized > 0.8; final isHigh = normalized > 0.8;
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
child: Row( child: Row(
children: [ children: [
// Label // Label
SizedBox( SizedBox(
width: 52, width: 52,
child: Text( child: Text(
def.displayName, def.displayName,
style: const TextStyle( style: const TextStyle(
color: Colors.grey, color: Colors.grey,
fontSize: 12, fontSize: 12,
letterSpacing: 0.5, letterSpacing: 0.5,
), ),
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
// Bar gauge // Bar gauge
Expanded( Expanded(
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(3), borderRadius: BorderRadius.circular(3),
child: Stack( child: Stack(
children: [ children: [
Container(height: 14, color: Colors.white10), Container(height: 14, color: Colors.white10),
FractionallySizedBox( FractionallySizedBox(
widthFactor: normalized, widthFactor: normalized,
child: Container( child: Container(
height: 14, height: 14,
decoration: BoxDecoration( decoration: BoxDecoration(
color: isHigh color: isHigh
? Colors.red.withAlpha(200) ? Colors.red.withAlpha(200)
: accentColor.withAlpha(180), : accentColor.withAlpha(180),
borderRadius: BorderRadius.circular(3), borderRadius: BorderRadius.circular(3),
), ),
), ),
), ),
], ],
), ),
), ),
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
// Value + unit // Value + unit
SizedBox( SizedBox(
width: 80, width: 80,
child: RichText( child: RichText(
textAlign: TextAlign.right, textAlign: TextAlign.right,
text: TextSpan( text: TextSpan(
children: [ children: [
TextSpan( TextSpan(
text: _fmt(value), text: _fmt(value),
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
), ),
TextSpan( TextSpan(
text: ' ${def.unit}', text: ' ${def.unit}',
style: TextStyle( style: TextStyle(
color: accentColor, color: accentColor,
fontSize: 11, fontSize: 11,
), ),
), ),
], ],
), ),
), ),
), ),
], ],
), ),
); );
} }
String _fmt(double v) { String _fmt(double v) {
if (def.id == 'rpm' || def.id == 'gear') return v.toStringAsFixed(0); if (def.id == 'rpm' || def.id == 'gear') return v.toStringAsFixed(0);
if (v.abs() >= 100) return v.toStringAsFixed(1); if (v.abs() >= 100) return v.toStringAsFixed(1);
return v.toStringAsFixed(2); return v.toStringAsFixed(2);
} }
} }
double sensorValue(SensorState s, String id) { double sensorValue(SensorState s, String id) {
switch (id) { switch (id) {
case 'rpm': return s.rpm; case 'rpm': return s.rpm;
case 'vss': return s.vss; case 'vss': return s.vss;
case 'map': return s.map; case 'map': return s.map;
case 'tps': return s.tps; case 'tps': return s.tps;
case 'inj': return s.inj; case 'inj': return s.inj;
case 'ign': return s.ign; case 'ign': return s.ign;
case 'ect': return s.ect; case 'ect': return s.ect;
case 'iat': return s.iat; case 'iat': return s.iat;
case 'bat': return s.bat; case 'bat': return s.bat;
case 'o2': return s.o2; case 'o2': return s.o2;
case 'gear': return s.gear.toDouble(); case 'gear': return s.gear.toDouble();
case 'eth': return s.eth; case 'eth': return s.eth;
case 'pa': return s.pa; case 'pa': return s.pa;
case 'afr': return s.afr; case 'afr': return s.afr;
case 'strim': return s.strim; case 'strim': return s.strim;
case 'ltrim': return s.ltrim; case 'ltrim': return s.ltrim;
case 'ain0': return s.ain0; case 'ain0': return s.ain0;
case 'ain1': return s.ain1; case 'ain1': return s.ain1;
case 'ain2': return s.ain2; case 'ain2': return s.ain2;
case 'ain3': return s.ain3; case 'ain3': return s.ain3;
case 'ain4': return s.ain4; case 'ain4': return s.ain4;
case 'ain5': return s.ain5; case 'ain5': return s.ain5;
case 'ain6': return s.ain6; case 'ain6': return s.ain6;
case 'ain7': return s.ain7; case 'ain7': return s.ain7;
default: return 0; default: return 0;
} }
} }

View File

@ -1,151 +1,151 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/bluetooth/bt_poller.dart'; import '../../../core/bluetooth/bt_poller.dart';
import '../../../core/providers/bt_provider.dart'; import '../../../core/providers/bt_provider.dart';
import '../../../core/providers/settings_provider.dart'; import '../../../core/providers/settings_provider.dart';
import '../../../core/providers/theme_provider.dart'; import '../../../core/providers/theme_provider.dart';
import '../../theme/app_colors.dart'; import '../../theme/app_colors.dart';
import '../bt_picker/bt_picker_screen.dart'; import '../bt_picker/bt_picker_screen.dart';
class SettingsScreen extends ConsumerWidget { class SettingsScreen extends ConsumerWidget {
const SettingsScreen({super.key}); const SettingsScreen({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(settingsProvider); final settings = ref.watch(settingsProvider);
final themeVariant = ref.watch(themeProvider); final themeVariant = ref.watch(themeProvider);
final bt = ref.watch(btProvider); final bt = ref.watch(btProvider);
final colors = AppColors.forTheme(themeVariant); final colors = AppColors.forTheme(themeVariant);
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Settings')), appBar: AppBar(title: const Text('Settings')),
body: ListView( body: ListView(
children: [ children: [
// ── Connection ─────────────────────────────────────────── // ── Connection ───────────────────────────────────────────
_SectionHeader('CONNECTION'), _SectionHeader('CONNECTION'),
ListTile( ListTile(
leading: Icon(Icons.bluetooth, color: colors.accent), leading: Icon(Icons.bluetooth, color: colors.accent),
title: const Text('Bluetooth Device'), title: const Text('Bluetooth Device'),
subtitle: Text( subtitle: Text(
bt.isConnected ? 'Connected: ${bt.deviceName}' : 'Not connected', bt.isConnected ? 'Connected: ${bt.deviceName}' : 'Not connected',
style: style:
TextStyle(color: bt.isConnected ? Colors.green : Colors.grey), TextStyle(color: bt.isConnected ? Colors.green : Colors.grey),
), ),
trailing: bt.isConnected trailing: bt.isConnected
? TextButton( ? TextButton(
onPressed: () async { onPressed: () async {
await ref.read(btProvider.notifier).disconnect(); await ref.read(btProvider.notifier).disconnect();
}, },
child: const Text('Disconnect', child: const Text('Disconnect',
style: TextStyle(color: Colors.red)), style: TextStyle(color: Colors.red)),
) )
: const Icon(Icons.arrow_forward_ios, : const Icon(Icons.arrow_forward_ios,
size: 14, color: Colors.grey), size: 14, color: Colors.grey),
onTap: bt.isConnected onTap: bt.isConnected
? null ? null
: () => Navigator.push( : () => Navigator.push(
context, context,
MaterialPageRoute(builder: (_) => const BtPickerScreen()), MaterialPageRoute(builder: (_) => const BtPickerScreen()),
), ),
), ),
const Divider(height: 1, color: Colors.white10), const Divider(height: 1, color: Colors.white10),
// ── ECU Protocol ───────────────────────────────────────── // ── ECU Protocol ─────────────────────────────────────────
_SectionHeader('ECU PROTOCOL'), _SectionHeader('ECU PROTOCOL'),
RadioListTile<EcuType>( RadioListTile<EcuType>(
title: const Text('S300'), title: const Text('S300'),
subtitle: const Text('Honda S300 ECU', subtitle: const Text('Honda S300 ECU',
style: TextStyle(color: Colors.grey, fontSize: 12)), style: TextStyle(color: Colors.grey, fontSize: 12)),
value: EcuType.s300, value: EcuType.s300,
groupValue: settings.ecuType, groupValue: settings.ecuType,
activeColor: colors.accent, activeColor: colors.accent,
onChanged: (v) { onChanged: (v) {
if (v != null) ref.read(settingsProvider.notifier).setEcuType(v); if (v != null) ref.read(settingsProvider.notifier).setEcuType(v);
}, },
), ),
RadioListTile<EcuType>( RadioListTile<EcuType>(
title: const Text('KPro'), title: const Text('KPro'),
subtitle: const Text('Hondata KPro ECU', subtitle: const Text('Hondata KPro ECU',
style: TextStyle(color: Colors.grey, fontSize: 12)), style: TextStyle(color: Colors.grey, fontSize: 12)),
value: EcuType.kpro, value: EcuType.kpro,
groupValue: settings.ecuType, groupValue: settings.ecuType,
activeColor: colors.accent, activeColor: colors.accent,
onChanged: (v) { onChanged: (v) {
if (v != null) ref.read(settingsProvider.notifier).setEcuType(v); if (v != null) ref.read(settingsProvider.notifier).setEcuType(v);
}, },
), ),
const Divider(height: 1, color: Colors.white10), const Divider(height: 1, color: Colors.white10),
// ── Polling Interval ───────────────────────────────────── // ── Polling Interval ─────────────────────────────────────
_SectionHeader('POLLING INTERVAL'), _SectionHeader('POLLING INTERVAL'),
...[20, 50, 100, 200].map((ms) => RadioListTile<int>( ...[20, 50, 100, 200].map((ms) => RadioListTile<int>(
title: Text('${ms}ms (~${(1000 / ms).round()} Hz)'), title: Text('${ms}ms (~${(1000 / ms).round()} Hz)'),
value: ms, value: ms,
groupValue: settings.pollingInterval.inMilliseconds, groupValue: settings.pollingInterval.inMilliseconds,
activeColor: colors.accent, activeColor: colors.accent,
onChanged: (v) { onChanged: (v) {
if (v != null) { if (v != null) {
ref ref
.read(settingsProvider.notifier) .read(settingsProvider.notifier)
.setPollingInterval(Duration(milliseconds: v)); .setPollingInterval(Duration(milliseconds: v));
} }
}, },
)), )),
const Divider(height: 1, color: Colors.white10), const Divider(height: 1, color: Colors.white10),
// ── Theme ──────────────────────────────────────────────── // ── Theme ────────────────────────────────────────────────
_SectionHeader('THEME'), _SectionHeader('THEME'),
...AppThemeVariant.values.map((v) { ...AppThemeVariant.values.map((v) {
final label = switch (v) { final label = switch (v) {
AppThemeVariant.redDark => 'Red — Dark', AppThemeVariant.redDark => 'Red — Dark',
AppThemeVariant.redLight => 'Red — Light', AppThemeVariant.redLight => 'Red — Light',
AppThemeVariant.greenDark => 'Green — Dark', AppThemeVariant.greenDark => 'Green — Dark',
AppThemeVariant.greenLight => 'Green — Light', AppThemeVariant.greenLight => 'Green — Light',
}; };
return RadioListTile<AppThemeVariant>( return RadioListTile<AppThemeVariant>(
title: Text(label), title: Text(label),
value: v, value: v,
groupValue: themeVariant, groupValue: themeVariant,
activeColor: AppColors.forTheme(v).accent, activeColor: AppColors.forTheme(v).accent,
onChanged: (val) { onChanged: (val) {
if (val != null) ref.read(themeProvider.notifier).set(val); if (val != null) ref.read(themeProvider.notifier).set(val);
}, },
); );
}), }),
const Divider(height: 1, color: Colors.white10), const Divider(height: 1, color: Colors.white10),
// ── App info ───────────────────────────────────────────── // ── App info ─────────────────────────────────────────────
_SectionHeader('ABOUT'), _SectionHeader('ABOUT'),
const ListTile( const ListTile(
title: Text('HV BT Dashboard'), title: Text('HV BT Dashboard'),
subtitle: Text('v1.4.0 — Phase 4', subtitle: Text('v1.4.0 — Phase 4',
style: TextStyle(color: Colors.grey, fontSize: 12)), style: TextStyle(color: Colors.grey, fontSize: 12)),
leading: Icon(Icons.info_outline, color: Colors.grey), leading: Icon(Icons.info_outline, color: Colors.grey),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
], ],
), ),
); );
} }
} }
class _SectionHeader extends StatelessWidget { class _SectionHeader extends StatelessWidget {
final String title; final String title;
const _SectionHeader(this.title); const _SectionHeader(this.title);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 6), padding: const EdgeInsets.fromLTRB(16, 20, 16, 6),
child: Text( child: Text(
title, title,
style: const TextStyle( style: const TextStyle(
color: Colors.grey, color: Colors.grey,
fontSize: 11, fontSize: 11,
letterSpacing: 1.5, letterSpacing: 1.5,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
), ),
); );
} }
} }

View File

@ -1,65 +1,65 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../core/providers/theme_provider.dart'; import '../../core/providers/theme_provider.dart';
class AppColors { class AppColors {
final Color accent; final Color accent;
final Color background; final Color background;
final Color surface; final Color surface;
final Color surfaceVariant; final Color surfaceVariant;
final Color onSurface; final Color onSurface;
final Color onSurfaceMuted; final Color onSurfaceMuted;
const AppColors._({ const AppColors._({
required this.accent, required this.accent,
required this.background, required this.background,
required this.surface, required this.surface,
required this.surfaceVariant, required this.surfaceVariant,
required this.onSurface, required this.onSurface,
required this.onSurfaceMuted, required this.onSurfaceMuted,
}); });
static const redDark = AppColors._( static const redDark = AppColors._(
accent: Color(0xFFFF4444), accent: Color(0xFFFF4444),
background: Color(0xFF0A0A0A), background: Color(0xFF0A0A0A),
surface: Color(0xFF1A1A1A), surface: Color(0xFF1A1A1A),
surfaceVariant: Color(0xFF242424), surfaceVariant: Color(0xFF242424),
onSurface: Colors.white, onSurface: Colors.white,
onSurfaceMuted: Colors.grey, onSurfaceMuted: Colors.grey,
); );
static const redLight = AppColors._( static const redLight = AppColors._(
accent: Color(0xFFCC0000), accent: Color(0xFFCC0000),
background: Color(0xFFF2F2F2), background: Color(0xFFF2F2F2),
surface: Color(0xFFFFFFFF), surface: Color(0xFFFFFFFF),
surfaceVariant: Color(0xFFEAEAEA), surfaceVariant: Color(0xFFEAEAEA),
onSurface: Color(0xFF111111), onSurface: Color(0xFF111111),
onSurfaceMuted: Color(0xFF777777), onSurfaceMuted: Color(0xFF777777),
); );
static const greenDark = AppColors._( static const greenDark = AppColors._(
accent: Color(0xFF00E676), accent: Color(0xFF00E676),
background: Color(0xFF0A0A0A), background: Color(0xFF0A0A0A),
surface: Color(0xFF1A1A1A), surface: Color(0xFF1A1A1A),
surfaceVariant: Color(0xFF242424), surfaceVariant: Color(0xFF242424),
onSurface: Colors.white, onSurface: Colors.white,
onSurfaceMuted: Colors.grey, onSurfaceMuted: Colors.grey,
); );
static const greenLight = AppColors._( static const greenLight = AppColors._(
accent: Color(0xFF00875A), accent: Color(0xFF00875A),
background: Color(0xFFF2F2F2), background: Color(0xFFF2F2F2),
surface: Color(0xFFFFFFFF), surface: Color(0xFFFFFFFF),
surfaceVariant: Color(0xFFEAEAEA), surfaceVariant: Color(0xFFEAEAEA),
onSurface: Color(0xFF111111), onSurface: Color(0xFF111111),
onSurfaceMuted: Color(0xFF777777), onSurfaceMuted: Color(0xFF777777),
); );
static AppColors forTheme(AppThemeVariant v) { static AppColors forTheme(AppThemeVariant v) {
switch (v) { switch (v) {
case AppThemeVariant.redDark: return redDark; case AppThemeVariant.redDark: return redDark;
case AppThemeVariant.redLight: return redLight; case AppThemeVariant.redLight: return redLight;
case AppThemeVariant.greenDark: return greenDark; case AppThemeVariant.greenDark: return greenDark;
case AppThemeVariant.greenLight: return greenLight; case AppThemeVariant.greenLight: return greenLight;
} }
} }
} }

View File

@ -1,55 +1,55 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../core/providers/theme_provider.dart'; import '../../core/providers/theme_provider.dart';
import 'app_colors.dart'; import 'app_colors.dart';
class AppTheme { class AppTheme {
static ThemeData build(AppThemeVariant variant) { static ThemeData build(AppThemeVariant variant) {
final colors = AppColors.forTheme(variant); final colors = AppColors.forTheme(variant);
final isDark = variant.isDark; final isDark = variant.isDark;
return ThemeData( return ThemeData(
useMaterial3: true, useMaterial3: true,
brightness: isDark ? Brightness.dark : Brightness.light, brightness: isDark ? Brightness.dark : Brightness.light,
scaffoldBackgroundColor: colors.background, scaffoldBackgroundColor: colors.background,
colorScheme: ColorScheme.fromSeed( colorScheme: ColorScheme.fromSeed(
seedColor: colors.accent, seedColor: colors.accent,
brightness: isDark ? Brightness.dark : Brightness.light, brightness: isDark ? Brightness.dark : Brightness.light,
).copyWith( ).copyWith(
primary: colors.accent, primary: colors.accent,
surface: colors.surface, surface: colors.surface,
), ),
appBarTheme: AppBarTheme( appBarTheme: AppBarTheme(
backgroundColor: colors.surface, backgroundColor: colors.surface,
foregroundColor: colors.onSurface, foregroundColor: colors.onSurface,
elevation: 0, elevation: 0,
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
), ),
bottomNavigationBarTheme: BottomNavigationBarThemeData( bottomNavigationBarTheme: BottomNavigationBarThemeData(
backgroundColor: colors.surface, backgroundColor: colors.surface,
selectedItemColor: colors.accent, selectedItemColor: colors.accent,
unselectedItemColor: colors.onSurfaceMuted, unselectedItemColor: colors.onSurfaceMuted,
type: BottomNavigationBarType.fixed, type: BottomNavigationBarType.fixed,
elevation: 8, elevation: 8,
), ),
cardTheme: CardThemeData( cardTheme: CardThemeData(
color: colors.surface, color: colors.surface,
elevation: 0, elevation: 0,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
), ),
dividerTheme: DividerThemeData( dividerTheme: DividerThemeData(
color: colors.surfaceVariant, color: colors.surfaceVariant,
thickness: 1, thickness: 1,
), ),
floatingActionButtonTheme: FloatingActionButtonThemeData( floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: colors.accent, backgroundColor: colors.accent,
foregroundColor: isDark ? Colors.black : Colors.white, foregroundColor: isDark ? Colors.black : Colors.white,
), ),
textTheme: TextTheme( textTheme: TextTheme(
bodyMedium: TextStyle(color: colors.onSurface), bodyMedium: TextStyle(color: colors.onSurface),
bodySmall: TextStyle(color: colors.onSurfaceMuted), bodySmall: TextStyle(color: colors.onSurfaceMuted),
), ),
); );
} }
} }

View File

@ -1,38 +1,38 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/providers/bt_provider.dart'; import '../../core/providers/bt_provider.dart';
class BtStatusChip extends ConsumerWidget { class BtStatusChip extends ConsumerWidget {
const BtStatusChip({super.key}); const BtStatusChip({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final bt = ref.watch(btProvider); final bt = ref.watch(btProvider);
final (color, icon, label) = switch (bt.status) { final (color, icon, label) = switch (bt.status) {
BtStatus.connected => (Colors.green, Icons.bluetooth_connected, bt.deviceName ?? 'Connected'), BtStatus.connected => (Colors.green, Icons.bluetooth_connected, bt.deviceName ?? 'Connected'),
BtStatus.connecting => (Colors.orange, Icons.bluetooth_searching, 'Connecting…'), BtStatus.connecting => (Colors.orange, Icons.bluetooth_searching, 'Connecting…'),
BtStatus.error => (Colors.red, Icons.bluetooth_disabled, 'Error'), BtStatus.error => (Colors.red, Icons.bluetooth_disabled, 'Error'),
BtStatus.disconnected=> (Colors.grey, Icons.bluetooth, 'Disconnected'), BtStatus.disconnected=> (Colors.grey, Icons.bluetooth, 'Disconnected'),
}; };
return Padding( return Padding(
padding: const EdgeInsets.only(right: 8), padding: const EdgeInsets.only(right: 8),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(icon, size: 14, color: color), Icon(icon, size: 14, color: color),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
label, label,
style: TextStyle( style: TextStyle(
color: color, color: color,
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
), ),
], ],
), ),
); );
} }
} }

View File

@ -1,92 +1,92 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../screens/dashboard/dashboard_screen.dart'; import '../screens/dashboard/dashboard_screen.dart';
import '../screens/sensor_list/sensor_list_screen.dart'; import '../screens/sensor_list/sensor_list_screen.dart';
import '../screens/graph/graph_screen.dart'; import '../screens/graph/graph_screen.dart';
import '../screens/dtc/dtc_screen.dart'; import '../screens/dtc/dtc_screen.dart';
import '../screens/datalog/datalog_list_screen.dart'; import '../screens/datalog/datalog_list_screen.dart';
import '../screens/debug/raw_frame_debug_screen.dart'; import '../screens/debug/raw_frame_debug_screen.dart';
import '../screens/settings/settings_screen.dart'; import '../screens/settings/settings_screen.dart';
import 'bt_status_chip.dart'; import 'bt_status_chip.dart';
import 'record_fab.dart'; import 'record_fab.dart';
final _navIndexProvider = StateProvider<int>((ref) => 0); final _navIndexProvider = StateProvider<int>((ref) => 0);
class MainNav extends ConsumerWidget { class MainNav extends ConsumerWidget {
const MainNav({super.key}); const MainNav({super.key});
static const _screens = [ static const _screens = [
DashboardScreen(), DashboardScreen(),
SensorListScreen(), SensorListScreen(),
GraphScreen(), GraphScreen(),
DtcScreen(), DtcScreen(),
]; ];
static const _items = [ static const _items = [
BottomNavigationBarItem( BottomNavigationBarItem(
icon: Icon(Icons.speed), icon: Icon(Icons.speed),
label: 'Dashboard', label: 'Dashboard',
), ),
BottomNavigationBarItem( BottomNavigationBarItem(
icon: Icon(Icons.list_alt), icon: Icon(Icons.list_alt),
label: 'Sensors', label: 'Sensors',
), ),
BottomNavigationBarItem( BottomNavigationBarItem(
icon: Icon(Icons.show_chart), icon: Icon(Icons.show_chart),
label: 'Graph', label: 'Graph',
), ),
BottomNavigationBarItem( BottomNavigationBarItem(
icon: Icon(Icons.warning_amber_outlined), icon: Icon(Icons.warning_amber_outlined),
label: 'DTC', label: 'DTC',
), ),
]; ];
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final index = ref.watch(_navIndexProvider); final index = ref.watch(_navIndexProvider);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('HV BT Dashboard'), title: const Text('HV BT Dashboard'),
actions: [ actions: [
const BtStatusChip(), const BtStatusChip(),
IconButton( IconButton(
icon: const Icon(Icons.folder_open_outlined), icon: const Icon(Icons.folder_open_outlined),
tooltip: 'Saved Sessions', tooltip: 'Saved Sessions',
onPressed: () => Navigator.push( onPressed: () => Navigator.push(
context, context,
MaterialPageRoute(builder: (_) => const DatalogListScreen()), MaterialPageRoute(builder: (_) => const DatalogListScreen()),
), ),
), ),
IconButton( IconButton(
icon: const Icon(Icons.bug_report_outlined), icon: const Icon(Icons.bug_report_outlined),
tooltip: 'ECU Frame Debug', tooltip: 'ECU Frame Debug',
onPressed: () => Navigator.push( onPressed: () => Navigator.push(
context, context,
MaterialPageRoute(builder: (_) => const RawFrameDebugScreen()), MaterialPageRoute(builder: (_) => const RawFrameDebugScreen()),
), ),
), ),
IconButton( IconButton(
icon: const Icon(Icons.settings_outlined), icon: const Icon(Icons.settings_outlined),
tooltip: 'Settings', tooltip: 'Settings',
onPressed: () => Navigator.push( onPressed: () => Navigator.push(
context, context,
MaterialPageRoute(builder: (_) => const SettingsScreen()), MaterialPageRoute(builder: (_) => const SettingsScreen()),
), ),
), ),
], ],
), ),
body: IndexedStack( body: IndexedStack(
index: index, index: index,
children: _screens, children: _screens,
), ),
bottomNavigationBar: BottomNavigationBar( bottomNavigationBar: BottomNavigationBar(
currentIndex: index, currentIndex: index,
items: _items, items: _items,
onTap: (i) => ref.read(_navIndexProvider.notifier).state = i, onTap: (i) => ref.read(_navIndexProvider.notifier).state = i,
), ),
floatingActionButton: const RecordFab(), floatingActionButton: const RecordFab(),
); );
} }
} }

View File

@ -1,48 +1,48 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/providers/bt_provider.dart'; import '../../core/providers/bt_provider.dart';
import '../../core/providers/datalog_provider.dart'; import '../../core/providers/datalog_provider.dart';
class RecordFab extends ConsumerWidget { class RecordFab extends ConsumerWidget {
const RecordFab({super.key}); const RecordFab({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final recording = ref.watch(recordingProvider); final recording = ref.watch(recordingProvider);
final bt = ref.watch(btProvider); final bt = ref.watch(btProvider);
// Only show when BT is connected // Only show when BT is connected
if (!bt.isConnected) return const SizedBox.shrink(); if (!bt.isConnected) return const SizedBox.shrink();
final isRecording = recording.isRecording; final isRecording = recording.isRecording;
return FloatingActionButton( return FloatingActionButton(
heroTag: 'record_fab', heroTag: 'record_fab',
backgroundColor: isRecording ? Colors.red : Theme.of(context).colorScheme.primary, backgroundColor: isRecording ? Colors.red : Theme.of(context).colorScheme.primary,
onPressed: () async { onPressed: () async {
if (isRecording) { if (isRecording) {
await ref.read(recordingProvider.notifier).stopRecording(); await ref.read(recordingProvider.notifier).stopRecording();
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
'Session saved — ${recording.frameCount} frames', 'Session saved — ${recording.frameCount} frames',
), ),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
} }
} else { } else {
await ref.read(recordingProvider.notifier).startRecording(); await ref.read(recordingProvider.notifier).startRecording();
} }
}, },
tooltip: isRecording ? 'Stop recording' : 'Start recording', tooltip: isRecording ? 'Stop recording' : 'Start recording',
child: AnimatedSwitcher( child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
child: isRecording child: isRecording
? const Icon(Icons.stop, key: ValueKey('stop')) ? const Icon(Icons.stop, key: ValueKey('stop'))
: const Icon(Icons.fiber_manual_record, key: ValueKey('rec')), : const Icon(Icons.fiber_manual_record, key: ValueKey('rec')),
), ),
); );
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,30 +1,30 @@
name: hvbt_dash name: hvbt_dash
description: HV BT Automotive ECU Dashboard description: HV BT Automotive ECU Dashboard
publish_to: 'none' publish_to: 'none'
version: 1.3.0+4 version: 1.3.0+4
environment: environment:
sdk: '>=3.0.0 <4.0.0' sdk: '>=3.0.0 <4.0.0'
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
flutter_riverpod: ^2.5.0 flutter_riverpod: ^2.5.0
flutter_bluetooth_serial: ^0.4.0 flutter_bluetooth_serial: ^0.4.0
fl_chart: ^0.68.0 fl_chart: ^0.68.0
sqflite: ^2.3.0 sqflite: ^2.3.0
shared_preferences: ^2.2.0 shared_preferences: ^2.2.0
path_provider: ^2.1.0 path_provider: ^2.1.0
intl: ^0.19.0 intl: ^0.19.0
path: ^1.9.0 path: ^1.9.0
permission_handler: ^11.0.0 permission_handler: ^11.0.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
flutter_lints: ^3.0.0 flutter_lints: ^3.0.0
flutter: flutter:
uses-material-design: true uses-material-design: true

View File

@ -1,69 +1,69 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:hvbt_dash/core/protocol/dtc_map.dart'; import 'package:hvbt_dash/core/protocol/dtc_map.dart';
void main() { void main() {
group('S300 DTC map', () { group('S300 DTC map', () {
test('contains expected number of entries', () { test('contains expected number of entries', () {
expect(s300DtcMap.length, equals(32)); // 4 bytes × 8 bits expect(s300DtcMap.length, equals(32)); // 4 bytes × 8 bits
}); });
test('P0130 maps to correct description for ERR00 bit0', () { test('P0130 maps to correct description for ERR00 bit0', () {
expect(s300DtcMap['ERR00_bit0'], equals('P0130 — O2 Sensor (front)')); expect(s300DtcMap['ERR00_bit0'], equals('P0130 — O2 Sensor (front)'));
}); });
test('P0505 maps for ERR00 bit2', () { test('P0505 maps for ERR00 bit2', () {
expect(s300DtcMap['ERR00_bit2'], equals('P0505 — Idle Air Control Valve')); expect(s300DtcMap['ERR00_bit2'], equals('P0505 — Idle Air Control Valve'));
}); });
test('P1607 maps for ERR03 bit7', () { test('P1607 maps for ERR03 bit7', () {
expect(s300DtcMap['ERR03_bit7'], equals('P1607 — PCM Internal Circuit')); expect(s300DtcMap['ERR03_bit7'], equals('P1607 — PCM Internal Circuit'));
}); });
}); });
group('KPro DTC map', () { group('KPro DTC map', () {
test('contains entries for 18 bytes (ERR00..ERR17)', () { test('contains entries for 18 bytes (ERR00..ERR17)', () {
// Every ERR byte has 8 bits = 144 entries total // Every ERR byte has 8 bits = 144 entries total
expect(kproDtcMap.length, equals(144)); expect(kproDtcMap.length, equals(144));
}); });
test('P0130 maps for ERR00 bit0', () { test('P0130 maps for ERR00 bit0', () {
expect(kproDtcMap['ERR00_bit0'], equals('P0130 — O2 Sensor (front)')); expect(kproDtcMap['ERR00_bit0'], equals('P0130 — O2 Sensor (front)'));
}); });
test('ERR17 entries present', () { test('ERR17 entries present', () {
expect(kproDtcMap.containsKey('ERR17_bit0'), isTrue); expect(kproDtcMap.containsKey('ERR17_bit0'), isTrue);
expect(kproDtcMap.containsKey('ERR17_bit7'), isTrue); expect(kproDtcMap.containsKey('ERR17_bit7'), isTrue);
}); });
}); });
group('decodeDtcs helper', () { group('decodeDtcs helper', () {
test('returns empty list when no errors', () { test('returns empty list when no errors', () {
final result = decodeDtcs([0, 0, 0, 0], s300DtcMap); final result = decodeDtcs([0, 0, 0, 0], s300DtcMap);
expect(result, isEmpty); expect(result, isEmpty);
}); });
test('decodes ERR00 bit0 = P0130', () { test('decodes ERR00 bit0 = P0130', () {
final result = decodeDtcs([0x01, 0, 0, 0], s300DtcMap); final result = decodeDtcs([0x01, 0, 0, 0], s300DtcMap);
expect(result, contains('P0130 — O2 Sensor (front)')); expect(result, contains('P0130 — O2 Sensor (front)'));
}); });
test('decodes multiple bits in one byte', () { test('decodes multiple bits in one byte', () {
// ERR00 bits 0 and 1 // ERR00 bits 0 and 1
final result = decodeDtcs([0x03, 0, 0, 0], s300DtcMap); final result = decodeDtcs([0x03, 0, 0, 0], s300DtcMap);
expect(result.length, equals(2)); expect(result.length, equals(2));
}); });
test('decodes bits across multiple bytes', () { test('decodes bits across multiple bytes', () {
// ERR00 bit0 + ERR01 bit0 // ERR00 bit0 + ERR01 bit0
final result = decodeDtcs([0x01, 0x01, 0, 0], s300DtcMap); final result = decodeDtcs([0x01, 0x01, 0, 0], s300DtcMap);
expect(result.length, equals(2)); expect(result.length, equals(2));
}); });
test('skips bits not in map gracefully', () { test('skips bits not in map gracefully', () {
// Use a small dtcMap with only one entry, but set all bits // Use a small dtcMap with only one entry, but set all bits
final result = decodeDtcs([0xFF, 0, 0, 0], {'ERR00_bit0': 'P0130 — test'}); final result = decodeDtcs([0xFF, 0, 0, 0], {'ERR00_bit0': 'P0130 — test'});
// Only the one mapped bit should appear // Only the one mapped bit should appear
expect(result.length, equals(1)); expect(result.length, equals(1));
}); });
}); });
} }

View File

@ -1,133 +1,133 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:hvbt_dash/core/protocol/kpro_parser.dart'; import 'package:hvbt_dash/core/protocol/kpro_parser.dart';
import 'package:hvbt_dash/core/protocol/temp_table.dart'; import 'package:hvbt_dash/core/protocol/temp_table.dart';
/// Builds a zeroed 128-byte KPro frame and stamps the NEG8 checksum at byte 127. /// Builds a zeroed 128-byte KPro frame and stamps the NEG8 checksum at byte 127.
Uint8List _buildKProFrame({ Uint8List _buildKProFrame({
int rpm = 0, int rpm = 0,
int vss = 0, int vss = 0,
int tps = 0, int tps = 0,
int map1 = 0, int map1 = 0,
int map2 = 0, int map2 = 0,
int ign = 0, int ign = 0,
int ect = 0x80, // tempXlt[0x80]=73 → 33°C int ect = 0x80, // tempXlt[0x80]=73 → 33°C
int bat = 0, int bat = 0,
int sw1 = 0, int sw1 = 0,
int sw2 = 0, int sw2 = 0,
int krtrd = 0, int krtrd = 0,
}) { }) {
final frame = Uint8List(128); final frame = Uint8List(128);
// Header // Header
frame[0] = 0x1B; frame[0] = 0x1B;
frame[1] = 0x01; frame[1] = 0x01;
// RPM: bytes 2..3 // RPM: bytes 2..3
frame[2] = (rpm >> 8) & 0xFF; frame[2] = (rpm >> 8) & 0xFF;
frame[3] = rpm & 0xFF; frame[3] = rpm & 0xFF;
// VSS: byte 4 // VSS: byte 4
frame[4] = vss & 0xFF; frame[4] = vss & 0xFF;
// TPS: byte 5 // TPS: byte 5
frame[5] = tps & 0xFF; frame[5] = tps & 0xFF;
// MAP1: byte 6 // MAP1: byte 6
frame[6] = map1 & 0xFF; frame[6] = map1 & 0xFF;
// MAP2: bytes 33..34 // MAP2: bytes 33..34
frame[33] = (map2 >> 8) & 0xFF; frame[33] = (map2 >> 8) & 0xFF;
frame[34] = map2 & 0xFF; frame[34] = map2 & 0xFF;
// IGN: byte 13 // IGN: byte 13
frame[13] = ign; frame[13] = ign;
// KRtrd: byte 21 // KRtrd: byte 21
frame[21] = krtrd; frame[21] = krtrd;
// SW1: byte 31, SW2: byte 32 // SW1: byte 31, SW2: byte 32
frame[31] = sw1; frame[31] = sw1;
frame[32] = sw2; frame[32] = sw2;
// ECT: byte 49 // ECT: byte 49
frame[49] = ect; frame[49] = ect;
// BAT: byte 52 // BAT: byte 52
frame[52] = bat; frame[52] = bat;
// Stamp NEG8 checksum at byte 127 // Stamp NEG8 checksum at byte 127
int neg8 = 0; int neg8 = 0;
for (int i = 0; i < 127; i++) { for (int i = 0; i < 127; i++) {
neg8 = (neg8 - frame[i]) & 0xFF; neg8 = (neg8 - frame[i]) & 0xFF;
} }
frame[127] = neg8; frame[127] = neg8;
return frame; return frame;
} }
void main() { void main() {
group('KPro parser', () { group('KPro parser', () {
test('parses RPM = raw / 4', () { test('parses RPM = raw / 4', () {
// rpm raw = 4000 → 1000.0 revs // rpm raw = 4000 → 1000.0 revs
final frame = _buildKProFrame(rpm: 4000); final frame = _buildKProFrame(rpm: 4000);
final state = parseKPro(frame); final state = parseKPro(frame);
expect(state.rpm, equals(1000.0)); expect(state.rpm, equals(1000.0));
}); });
test('parses VSS directly', () { test('parses VSS directly', () {
final frame = _buildKProFrame(vss: 120); final frame = _buildKProFrame(vss: 120);
final state = parseKPro(frame); final state = parseKPro(frame);
expect(state.vss, equals(120.0)); expect(state.vss, equals(120.0));
}); });
test('parses TPS directly', () { test('parses TPS directly', () {
final frame = _buildKProFrame(tps: 75); final frame = _buildKProFrame(tps: 75);
final state = parseKPro(frame); final state = parseKPro(frame);
expect(state.tps, equals(75.0)); expect(state.tps, equals(75.0));
}); });
test('parses MAP from MAP1 when MAP2 = 0', () { test('parses MAP from MAP1 when MAP2 = 0', () {
final frame = _buildKProFrame(map1: 100, map2: 0); final frame = _buildKProFrame(map1: 100, map2: 0);
final state = parseKPro(frame); final state = parseKPro(frame);
expect(state.map, equals(100.0)); expect(state.map, equals(100.0));
}); });
test('parses MAP from MAP2 / 10 when MAP2 != 0', () { test('parses MAP from MAP2 / 10 when MAP2 != 0', () {
// map2 raw = 1000 → 100.0 kPa // map2 raw = 1000 → 100.0 kPa
final frame = _buildKProFrame(map1: 50, map2: 1000); final frame = _buildKProFrame(map1: 50, map2: 1000);
final state = parseKPro(frame); final state = parseKPro(frame);
expect(state.map, equals(100.0)); expect(state.map, equals(100.0));
}); });
test('parses ECT using temp lookup table', () { test('parses ECT using temp lookup table', () {
// ect raw = 0x80 = 128 → tempXlt[128]=73 → 33°C // ect raw = 0x80 = 128 → tempXlt[128]=73 → 33°C
final frame = _buildKProFrame(ect: 0x80); final frame = _buildKProFrame(ect: 0x80);
final state = parseKPro(frame); final state = parseKPro(frame);
expect(state.ect, equals((tempXlt[0x80] - 40).toDouble())); expect(state.ect, equals((tempXlt[0x80] - 40).toDouble()));
}); });
test('parses BAT = raw / 10', () { test('parses BAT = raw / 10', () {
// bat raw = 130 → 13.0 V // bat raw = 130 → 13.0 V
final frame = _buildKProFrame(bat: 130); final frame = _buildKProFrame(bat: 130);
final state = parseKPro(frame); final state = parseKPro(frame);
expect(state.bat, equals(13.0)); expect(state.bat, equals(13.0));
}); });
test('MIL flag from SW2 bit2', () { test('MIL flag from SW2 bit2', () {
final frame = _buildKProFrame(sw2: 0x04); // bit2 final frame = _buildKProFrame(sw2: 0x04); // bit2
final state = parseKPro(frame); final state = parseKPro(frame);
expect(state.mil, isTrue); expect(state.mil, isTrue);
}); });
test('knock flag when krtrd > 0', () { test('knock flag when krtrd > 0', () {
final frame = _buildKProFrame(krtrd: 3); final frame = _buildKProFrame(krtrd: 3);
final state = parseKPro(frame); final state = parseKPro(frame);
expect(state.knock, isTrue); expect(state.knock, isTrue);
}); });
test('errBytes has 18 entries', () { test('errBytes has 18 entries', () {
final frame = _buildKProFrame(); final frame = _buildKProFrame();
final state = parseKPro(frame); final state = parseKPro(frame);
expect(state.errBytes.length, equals(18)); expect(state.errBytes.length, equals(18));
}); });
test('throws on wrong frame length', () { test('throws on wrong frame length', () {
expect(() => parseKPro(Uint8List(64)), throwsArgumentError); expect(() => parseKPro(Uint8List(64)), throwsArgumentError);
}); });
test('throws on bad checksum', () { test('throws on bad checksum', () {
final frame = _buildKProFrame(rpm: 4000); final frame = _buildKProFrame(rpm: 4000);
frame[127] = 0x00; // corrupt frame[127] = 0x00; // corrupt
expect(() => parseKPro(frame), throwsArgumentError); expect(() => parseKPro(frame), throwsArgumentError);
}); });
}); });
} }

View File

@ -1,38 +1,38 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:hvbt_dash/core/protocol/neg8.dart'; import 'package:hvbt_dash/core/protocol/neg8.dart';
void main() { void main() {
group('NEG8 checksum', () { group('NEG8 checksum', () {
test('S300 request header [0x1B, 0x00] produces 0xE5', () { test('S300 request header [0x1B, 0x00] produces 0xE5', () {
// NEG8 of [0x1B, 0x00] = (0 - 0x1B - 0x00) & 0xFF = 0xE5 // NEG8 of [0x1B, 0x00] = (0 - 0x1B - 0x00) & 0xFF = 0xE5
final buf = Uint8List.fromList([0x1B, 0x00]); final buf = Uint8List.fromList([0x1B, 0x00]);
expect(calculateNeg8(buf), equals(0xE5)); expect(calculateNeg8(buf), equals(0xE5));
}); });
test('KPro request header [0x1B, 0x01] produces 0xE4', () { test('KPro request header [0x1B, 0x01] produces 0xE4', () {
final buf = Uint8List.fromList([0x1B, 0x01]); final buf = Uint8List.fromList([0x1B, 0x01]);
expect(calculateNeg8(buf), equals(0xE4)); expect(calculateNeg8(buf), equals(0xE4));
}); });
test('validateFrame passes for correctly checksummed frame', () { test('validateFrame passes for correctly checksummed frame', () {
// Build a 4-byte frame where last byte = NEG8 of first 3 // Build a 4-byte frame where last byte = NEG8 of first 3
final data = Uint8List.fromList([0x1B, 0x00, 0x00, 0xE5]); final data = Uint8List.fromList([0x1B, 0x00, 0x00, 0xE5]);
expect(validateFrame(data), isTrue); expect(validateFrame(data), isTrue);
}); });
test('validateFrame fails for corrupted frame', () { test('validateFrame fails for corrupted frame', () {
final data = Uint8List.fromList([0x1B, 0x00, 0x00, 0xFF]); final data = Uint8List.fromList([0x1B, 0x00, 0x00, 0xFF]);
expect(validateFrame(data), isFalse); expect(validateFrame(data), isFalse);
}); });
test('validateFrame returns false for empty frame', () { test('validateFrame returns false for empty frame', () {
expect(validateFrame(Uint8List(0)), isFalse); expect(validateFrame(Uint8List(0)), isFalse);
}); });
test('single byte 0x00 validates as [0x00, 0x00]', () { test('single byte 0x00 validates as [0x00, 0x00]', () {
// NEG8([0x00]) = 0x00, so frame [0x00, 0x00] should pass // NEG8([0x00]) = 0x00, so frame [0x00, 0x00] should pass
expect(validateFrame(Uint8List.fromList([0x00, 0x00])), isTrue); expect(validateFrame(Uint8List.fromList([0x00, 0x00])), isTrue);
}); });
}); });
} }

View File

@ -1,214 +1,264 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:hvbt_dash/core/protocol/neg8.dart'; import 'package:hvbt_dash/core/protocol/neg8.dart';
import 'package:hvbt_dash/core/protocol/s300_parser.dart'; import 'package:hvbt_dash/core/protocol/s300_parser.dart';
import 'package:hvbt_dash/core/protocol/temp_table.dart'; import 'package:hvbt_dash/core/protocol/temp_table.dart';
/// Builds a zeroed 128-byte S300 frame and stamps the NEG8 checksum at byte 127. /// Builds a zeroed 128-byte S300 frame and stamps the NEG8 checksum at byte 127.
Uint8List _buildS300Frame({ Uint8List _buildS300Frame({
int rpm = 0, int rpm = 0,
int vss = 0, int vss = 0,
int mapRaw = 0, int mapRaw = 0,
int tps = 0, int tps = 0,
int inj = 0, int inj = 0,
int ign = 0, int ign = 0,
int ect = 0x80, // tempXlt[0x80]=73 → 73-40=33°C int ect = 0x80, // tempXlt[0x80]=73 → 73-40=33°C
int bat = 0, int iat = 0x80,
int sw1 = 0, int bat = 0,
int sw2 = 0, int sw1 = 0,
int sw3 = 0, int sw2 = 0,
int krtrd = 0, int sw3 = 0,
int gear = 0, int krtrd = 0,
}) { int gear = 0,
final frame = Uint8List(128); }) {
// Header final frame = Uint8List(128);
frame[0] = 0x1B; // Header
frame[1] = 0x00; frame[0] = 0x1B;
// RPM: bytes +3..4, little-endian, raw frame[1] = 0x00;
frame[3] = rpm & 0xFF; // RPM: bytes +3..4, little-endian, raw
frame[4] = (rpm >> 8) & 0xFF; frame[3] = rpm & 0xFF;
// VSS: bytes +5..6, little-endian frame[4] = (rpm >> 8) & 0xFF;
frame[5] = vss & 0xFF; // VSS: bytes +5..6, little-endian
frame[6] = (vss >> 8) & 0xFF; frame[5] = vss & 0xFF;
// MAP: bytes +7..8, little-endian frame[6] = (vss >> 8) & 0xFF;
frame[7] = mapRaw & 0xFF; // MAP: bytes +7..8, little-endian
frame[8] = (mapRaw >> 8) & 0xFF; frame[7] = mapRaw & 0xFF;
// TPS: byte +9 frame[8] = (mapRaw >> 8) & 0xFF;
frame[9] = tps; // TPS: byte +9
// INJ: bytes +10..11, BE frame[9] = tps;
frame[10] = (inj >> 8) & 0xFF; // INJ: bytes +10..11, BE
frame[11] = inj & 0xFF; frame[10] = (inj >> 8) & 0xFF;
// IGN: byte +12 frame[11] = inj & 0xFF;
frame[12] = ign; // IGN: byte +12
// KRtrd: byte +13 frame[12] = ign;
frame[13] = krtrd; // KRtrd: byte +13
// SW bitmaps frame[13] = krtrd;
frame[0x11] = sw1; // SW bitmaps
frame[0x12] = sw2; frame[0x11] = sw1;
frame[0x13] = sw3; frame[0x12] = sw2;
// Gear: decimal offset 27 frame[0x13] = sw3;
frame[27] = gear; // Gear: decimal offset 27
// ECT: byte +2D frame[27] = gear;
frame[0x2D] = ect; // ECT: decimal offset 33
// BAT: byte +30 frame[33] = ect;
frame[0x30] = bat; // IAT: decimal offset 34
frame[34] = iat;
// Stamp NEG8 checksum at byte 127 // BAT: decimal offset 36
int neg8 = 0; frame[36] = bat;
for (int i = 0; i < 127; i++) {
neg8 = (neg8 - frame[i]) & 0xFF; // Stamp NEG8 checksum at byte 127
} int neg8 = 0;
frame[127] = neg8; for (int i = 0; i < 127; i++) {
return frame; neg8 = (neg8 - frame[i]) & 0xFF;
} }
frame[127] = neg8;
void main() { return frame;
group('S300 parser', () { }
test('parses RPM correctly', () {
// RPM raw = 3000 void main() {
final frame = _buildS300Frame(rpm: 3000); group('S300 parser', () {
final state = parseS300(frame); test('parses RPM correctly', () {
expect(state.rpm, equals(3000.0)); // RPM raw = 3000
}); final frame = _buildS300Frame(rpm: 3000);
final state = parseS300(frame);
test('parses VSS = 0 when raw < 893', () { expect(state.rpm, equals(3000.0));
final frame = _buildS300Frame(vss: 500); });
final state = parseS300(frame);
expect(state.vss, equals(0.0)); test('parses VSS = 0 when raw < 893', () {
}); final frame = _buildS300Frame(vss: 500);
final state = parseS300(frame);
test('parses VSS correctly when raw >= 893', () { expect(state.vss, equals(0.0));
// vss = 228480 / 1000 = 228.48 km/h });
final frame = _buildS300Frame(vss: 1000);
final state = parseS300(frame); test('parses VSS correctly when raw >= 893', () {
expect(state.vss, closeTo(228.48, 0.01)); // vss = 228480 / 1000 = 228.48 km/h
}); final frame = _buildS300Frame(vss: 1000);
final state = parseS300(frame);
test('parses MAP as raw/10 kPa', () { expect(state.vss, closeTo(228.48, 0.01));
// mapRaw = 1148 -> 114.8 kPa });
final frame = _buildS300Frame(mapRaw: 1148);
final state = parseS300(frame); test('parses MAP as raw/10 kPa', () {
expect(state.map, closeTo(114.8, 0.01)); // mapRaw = 1148 -> 114.8 kPa
}); final frame = _buildS300Frame(mapRaw: 1148);
final state = parseS300(frame);
test('parses TPS = 0 when raw = 0', () { expect(state.map, closeTo(114.8, 0.01));
final frame = _buildS300Frame(tps: 0); });
final state = parseS300(frame);
expect(state.tps, equals(0.0)); test('parses TPS = 0 when raw = 0', () {
}); final frame = _buildS300Frame(tps: 0);
final state = parseS300(frame);
test('parses TPS = 100 when raw = 255', () { expect(state.tps, equals(0.0));
final frame = _buildS300Frame(tps: 255); });
final state = parseS300(frame);
expect(state.tps, equals(100.0)); test('parses TPS = 100 when raw = 255', () {
}); final frame = _buildS300Frame(tps: 255);
final state = parseS300(frame);
test('parses TPS correctly when raw >= 25', () { expect(state.tps, equals(100.0));
// tps raw=127 -> 127*100/255 ≈ 49.8% (confirmed against live capture) });
final frame = _buildS300Frame(tps: 127);
final state = parseS300(frame); test('parses TPS correctly when raw >= 25', () {
expect(state.tps, closeTo(49.8, 0.1)); // tps raw=127 -> 127*100/255 ≈ 49.8% (confirmed against live capture)
}); final frame = _buildS300Frame(tps: 127);
final state = parseS300(frame);
test('parses Gear from decimal offset 27', () { expect(state.tps, closeTo(49.8, 0.1));
final frame = _buildS300Frame(gear: 5); });
final state = parseS300(frame);
expect(state.gear, equals(5)); test('parses Gear from decimal offset 27', () {
}); final frame = _buildS300Frame(gear: 5);
final state = parseS300(frame);
test('parses ECT using temp lookup table', () { expect(state.gear, equals(5));
// ect raw = 0x80 = 128 → tempXlt[128]=73 → 73-40=33°C });
final frame = _buildS300Frame(ect: 0x80);
final state = parseS300(frame); test('parses ECT using temp lookup table', () {
expect(state.ect, equals((tempXlt[0x80] - 40).toDouble())); // Confirmed via live capture: raw=0xD2(210) matched the reference
}); // tool's 28.4°F exactly at the same instant (RPM cross-checked).
// ect raw = 0xD2 = 210 → tempXlt[210]=38 → 38-40=-2°C
test('parses BAT = raw * 26 / 270', () { final frame = _buildS300Frame(ect: 0xD2);
// bat raw = 135 → 135*26/270 = 13.0 V final state = parseS300(frame);
final frame = _buildS300Frame(bat: 135); expect(state.ect, equals((tempXlt[0xD2] - 40).toDouble()));
final state = parseS300(frame); });
expect(state.bat, closeTo(13.0, 0.01));
}); test('parses IAT using temp lookup table', () {
// Confirmed via the same live capture: raw=0xCA(202) was within
test('IGN decode: (raw + 120) / 2', () { // ~1.3°F of the reference tool's 36.1°F reading.
// ign raw = 164 -> (164 + 120) / 2 = 142.0 // iat raw = 0xCA = 202 → tempXlt[202]=43 → 43-40=3°C
final frame = _buildS300Frame(ign: 164); final frame = _buildS300Frame(iat: 0xCA);
final state = parseS300(frame); final state = parseS300(frame);
expect(state.ign, equals(142.0)); expect(state.iat, equals((tempXlt[0xCA] - 40).toDouble()));
}); });
test('MIL flag set from SW2 bit5', () { test('parses BAT = raw * 26 / 270', () {
final frame = _buildS300Frame(sw2: 0x20); // bit5 set // bat raw = 135 → 135*26/270 = 13.0 V
final state = parseS300(frame); final frame = _buildS300Frame(bat: 135);
expect(state.mil, isTrue); final state = parseS300(frame);
}); expect(state.bat, closeTo(13.0, 0.01));
});
test('knock flag set when krtrd > 0', () {
final frame = _buildS300Frame(krtrd: 5); test('IGN decode: (raw + 120) / 2', () {
final state = parseS300(frame); // ign raw = 164 -> (164 + 120) / 2 = 142.0
expect(state.knock, isTrue); final frame = _buildS300Frame(ign: 164);
}); final state = parseS300(frame);
expect(state.ign, equals(142.0));
test('knock flag clear when krtrd = 0', () { });
final frame = _buildS300Frame(krtrd: 0);
final state = parseS300(frame); test('MIL flag set from SW2 bit5', () {
expect(state.knock, isFalse); final frame = _buildS300Frame(sw2: 0x20); // bit5 set
}); final state = parseS300(frame);
expect(state.mil, isTrue);
test('throws on wrong frame length', () { });
expect(
() => parseS300(Uint8List(64)), test('fuel pump flag is active-low: set when SW2 bit2 is CLEAR', () {
throwsArgumentError, // SW2 bit 2 is the "Fuel" line in the reference tool's own channel
); // list — confirmed live to be the fuel PUMP relay status, not fuel
}); // cut, but also confirmed live to be active-low: an extended session
// held RPM above the ~300 threshold where the pump should be on, and
test('throws on bad checksum', () { // this bit read 0 for the entire session. Separately confirmed the
final frame = _buildS300Frame(rpm: 1000); // app showed "on" below ~200 rpm and "off" above it — exactly
frame[127] = 0x00; // corrupt checksum // inverted from a naive `!= 0` read, hence bit CLEAR = pump on.
expect(() => parseS300(frame), throwsArgumentError); final frame = _buildS300Frame(sw2: 0x38); // bit2 clear, other bits set
}); final state = parseS300(frame);
expect(state.fuelPump, isTrue);
test('can parse bad checksum frame when validation is disabled', () { });
final frame = _buildS300Frame(rpm: 1000);
frame[127] = 0x00; // corrupt checksum test('fuel pump flag clear (pump off) when SW2 bit2 is set', () {
final state = parseS300(frame, validateChecksum: false); final frame = _buildS300Frame(sw2: 0x04); // bit2 set
expect(state.rpm, equals(1000.0)); final state = parseS300(frame);
}); expect(state.fuelPump, isFalse);
});
test('decodes RPM=0 for a frame matching the old "keep-alive" byte prefix', () {
// A fixed 17-byte prefix here was previously treated as a keep-alive test('knock flag set when krtrd > 0', () {
// signature to skip outright. Live testing showed it over-matched final frame = _buildS300Frame(krtrd: 5);
// badly: with RPM actively changing, nearly every frame was being final state = parseS300(frame);
// classified as a keep-alive and discarded, freezing the dashboard. expect(state.knock, isTrue);
// Every checksum-valid frame is decoded normally now regardless of });
// its leading bytes — see sensor_provider.dart.
final frame = Uint8List(128); test('knock flag clear when krtrd = 0', () {
final prefix = [ final frame = _buildS300Frame(krtrd: 0);
0x1B, final state = parseS300(frame);
0x00, expect(state.knock, isFalse);
0x14, });
0x00,
0x00, test('throws on wrong frame length', () {
0xFF, expect(
0xFF, () => parseS300(Uint8List(64)),
0xF2, throwsArgumentError,
0x03, );
0x18, });
0x00,
0x00, test('throws on bad checksum', () {
0x10, final frame = _buildS300Frame(rpm: 1000);
0x00, frame[127] = 0x00; // corrupt checksum
0x00, expect(() => parseS300(frame), throwsArgumentError);
0x00, });
0xC4,
]; test('can parse bad checksum frame when validation is disabled', () {
for (var i = 0; i < prefix.length; i++) { final frame = _buildS300Frame(rpm: 1000);
frame[i] = prefix[i]; frame[127] = 0x00; // corrupt checksum
} final state = parseS300(frame, validateChecksum: false);
frame[127] = calculateNeg8(frame.sublist(0, 127)); expect(state.rpm, equals(1000.0));
});
final state = parseS300(frame);
expect(state.rpm, equals(0.0)); test('decodes RPM=0 for a frame matching the fixed event-frame template', () {
}); // parseS300 itself doesn't special-case this template — it just reads
}); // the normal offsets, which land on the template's fixed bytes.
} // Holding the last known-good value across this frame is the caller's
// job (sensor_provider.dart), driven by isS300TemplateFrame below.
final frame = _templateFrame();
final state = parseS300(frame);
expect(state.rpm, equals(0.0));
});
test('isS300TemplateFrame detects the fixed event-frame byte pattern', () {
// Verified via live A/B capture: this exact bytes-0-16 pattern shows
// up whenever SW2 (byte 18, outside this span) reports fuel cut
// active, corroborated independently by the same frame's INJ pulse
// width reading 0 and TPS reading a closed-throttle value.
expect(isS300TemplateFrame(_templateFrame()), isTrue);
});
test('isS300TemplateFrame is false for a normal live-reading frame', () {
final frame = _buildS300Frame(rpm: 3000, tps: 127);
expect(isS300TemplateFrame(frame), isFalse);
});
});
}
/// Builds a 128-byte frame stamped with the S300's fixed event-frame
/// template in bytes 0-16, with a valid NEG8 checksum.
Uint8List _templateFrame() {
final frame = Uint8List(128);
final prefix = [
0x1B,
0x00,
0x14,
0x00,
0x00,
0xFF,
0xFF,
0xF2,
0x03,
0x18,
0x00,
0x00,
0x10,
0x00,
0x00,
0x00,
0xC4,
];
for (var i = 0; i < prefix.length; i++) {
frame[i] = prefix[i];
}
frame[127] = calculateNeg8(frame.sublist(0, 127));
return frame;
}