Compare commits
1 Commits
main
...
backup/sna
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb87e79d41 |
59
.gitignore
vendored
59
.gitignore
vendored
@ -1,31 +1,28 @@
|
||||
# Flutter/Dart
|
||||
.dart_tool/
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
.packages
|
||||
.pub-cache/
|
||||
.pub/
|
||||
build/
|
||||
|
||||
# Android
|
||||
android/.gradle/
|
||||
android/.kotlin/
|
||||
android/captures/
|
||||
android/local.properties
|
||||
android/key.properties
|
||||
android/hs_err_pid*.log
|
||||
android/replay_pid*.log
|
||||
*.jks
|
||||
*.log
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.iml
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Client/reference documents (kept locally, not in the repo)
|
||||
/*.pdf
|
||||
# Flutter/Dart
|
||||
.dart_tool/
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
.packages
|
||||
.pub-cache/
|
||||
.pub/
|
||||
build/
|
||||
|
||||
# Android
|
||||
android/.gradle/
|
||||
android/.kotlin/
|
||||
android/captures/
|
||||
android/local.properties
|
||||
android/key.properties
|
||||
android/hs_err_pid*.log
|
||||
android/replay_pid*.log
|
||||
*.jks
|
||||
*.log
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.iml
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
60
.metadata
60
.metadata
@ -1,30 +1,30 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "db50e20168db8fee486b9abf32fc912de3bc5b6a"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
- platform: android
|
||||
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
|
||||
# User provided section
|
||||
|
||||
# List of Local paths (relative to this file) that should be
|
||||
# ignored by the migrate tool.
|
||||
#
|
||||
# Files that are not part of the templates will be ignored by default.
|
||||
unmanaged_files:
|
||||
- 'lib/main.dart'
|
||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "db50e20168db8fee486b9abf32fc912de3bc5b6a"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
- platform: android
|
||||
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
|
||||
# User provided section
|
||||
|
||||
# List of Local paths (relative to this file) that should be
|
||||
# ignored by the migrate tool.
|
||||
#
|
||||
# Files that are not part of the templates will be ignored by default.
|
||||
unmanaged_files:
|
||||
- 'lib/main.dart'
|
||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||
|
||||
BIN
Bluetooth.pdf
Normal file
BIN
Bluetooth.pdf
Normal file
Binary file not shown.
32
README.md
32
README.md
@ -1,16 +1,16 @@
|
||||
# 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.
|
||||
|
||||
Full layman-friendly documentation is here:
|
||||
|
||||
- [docs/FULL_PROJECT_DOCUMENTATION.md](docs/FULL_PROJECT_DOCUMENTATION.md)
|
||||
|
||||
Common commands:
|
||||
|
||||
```powershell
|
||||
flutter run
|
||||
flutter run --release
|
||||
flutter build apk --release
|
||||
flutter test
|
||||
```
|
||||
# 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.
|
||||
|
||||
Full layman-friendly documentation is here:
|
||||
|
||||
- [docs/FULL_PROJECT_DOCUMENTATION.md](docs/FULL_PROJECT_DOCUMENTATION.md)
|
||||
|
||||
Common commands:
|
||||
|
||||
```powershell
|
||||
flutter run
|
||||
flutter run --release
|
||||
flutter build apk --release
|
||||
flutter test
|
||||
```
|
||||
|
||||
BIN
Requrements.pdf
Normal file
BIN
Requrements.pdf
Normal file
Binary file not shown.
@ -1,28 +1,28 @@
|
||||
# This file configures the analyzer, which statically analyzes Dart code to
|
||||
# check for errors, warnings, and lints.
|
||||
#
|
||||
# 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
|
||||
# invoked from the command line by running `flutter analyze`.
|
||||
|
||||
# The following line activates a set of recommended lints for Flutter apps,
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
# The lint rules applied to this project can be customized in the
|
||||
# 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
|
||||
# and their documentation is published at https://dart.dev/lints.
|
||||
#
|
||||
# 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
|
||||
# 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
|
||||
# producing the lint.
|
||||
rules:
|
||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
# This file configures the analyzer, which statically analyzes Dart code to
|
||||
# check for errors, warnings, and lints.
|
||||
#
|
||||
# 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
|
||||
# invoked from the command line by running `flutter analyze`.
|
||||
|
||||
# The following line activates a set of recommended lints for Flutter apps,
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
# The lint rules applied to this project can be customized in the
|
||||
# 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
|
||||
# and their documentation is published at https://dart.dev/lints.
|
||||
#
|
||||
# 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
|
||||
# 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
|
||||
# producing the lint.
|
||||
rules:
|
||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
|
||||
28
android/.gitignore
vendored
28
android/.gitignore
vendored
@ -1,14 +1,14 @@
|
||||
gradle-wrapper.jar
|
||||
/.gradle
|
||||
/captures/
|
||||
/gradlew
|
||||
/gradlew.bat
|
||||
/local.properties
|
||||
GeneratedPluginRegistrant.java
|
||||
.cxx/
|
||||
|
||||
# Remember to never publicly share your keystore.
|
||||
# See https://flutter.dev/to/reference-keystore
|
||||
key.properties
|
||||
**/*.keystore
|
||||
**/*.jks
|
||||
gradle-wrapper.jar
|
||||
/.gradle
|
||||
/captures/
|
||||
/gradlew
|
||||
/gradlew.bat
|
||||
/local.properties
|
||||
GeneratedPluginRegistrant.java
|
||||
.cxx/
|
||||
|
||||
# Remember to never publicly share your keystore.
|
||||
# See https://flutter.dev/to/reference-keystore
|
||||
key.properties
|
||||
**/*.keystore
|
||||
**/*.jks
|
||||
|
||||
198
android/.kotlin/errors/errors-1775992194545.log
Normal file
198
android/.kotlin/errors/errors-1775992194545.log
Normal file
@ -0,0 +1,198 @@
|
||||
kotlin version: 2.2.20
|
||||
error message: Daemon compilation failed: null
|
||||
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:65)
|
||||
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.run(GradleKotlinCompilerWork.kt:111)
|
||||
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.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:66)
|
||||
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.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:41)
|
||||
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$2.execute(DefaultBuildOperationRunner.java:67)
|
||||
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:60)
|
||||
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.NoIsolationWorkerFactory$1.execute(NoIsolationWorkerFactory.java:59)
|
||||
at org.gradle.workers.internal.DefaultWorkerExecutor.lambda$submitWork$0(DefaultWorkerExecutor.java:174)
|
||||
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.access$700(DefaultConditionalExecutionQueue.java:127)
|
||||
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.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:132)
|
||||
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runBatch(DefaultConditionalExecutionQueue.java:164)
|
||||
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.FutureTask.run(Unknown Source)
|
||||
at org.gradle.internal.concurrent.ExecutorPolicy$CatchAndRecordFailures.onExecute(ExecutorPolicy.java:64)
|
||||
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$Worker.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
|
||||
at org.jetbrains.kotlin.com.google.common.io.Closer.close(Closer.java:218)
|
||||
at org.jetbrains.kotlin.incremental.IncrementalCachesManager.close(IncrementalCachesManager.kt:55)
|
||||
at kotlin.io.CloseableKt.closeFinally(Closeable.kt:46)
|
||||
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileNonIncrementally(IncrementalCompilerRunner.kt:293)
|
||||
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.access$execIncrementalCompiler(CompileServiceImpl.kt:94)
|
||||
at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1810)
|
||||
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.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.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.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$ConnectionHandler.run0(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.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(Unknown Source)
|
||||
... 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
|
||||
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.com.google.common.io.Closer.close(Closer.java:205)
|
||||
... 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.
|
||||
at kotlin.io.FilesKt__UtilsKt.toRelativeString(Utils.kt:117)
|
||||
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.FileDescriptor.save(FileToPathConverter.kt:33)
|
||||
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.put(PersistentMapImpl.java:426)
|
||||
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.InMemoryStorage.applyChanges(InMemoryStorage.kt:108)
|
||||
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.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)
|
||||
... 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.
|
||||
at kotlin.io.FilesKt__UtilsKt.toRelativeString(Utils.kt:117)
|
||||
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.FileDescriptor.getHashCode(FileToPathConverter.kt:50)
|
||||
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.remove(LinkedCustomHashMap.java:153)
|
||||
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.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.PersistentHashMap.put(PersistentHashMap.java:106)
|
||||
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.AppendableInMemoryStorage.applyChanges(InMemoryStorage.kt:179)
|
||||
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.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)
|
||||
... 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.
|
||||
at kotlin.io.FilesKt__UtilsKt.toRelativeString(Utils.kt:117)
|
||||
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.FileDescriptor.save(FileToPathConverter.kt:33)
|
||||
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:142)
|
||||
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.PersistentHashMap.put(PersistentHashMap.java:106)
|
||||
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.AppendableInMemoryStorage.applyChanges(InMemoryStorage.kt:179)
|
||||
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.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)
|
||||
... 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
|
||||
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.LookupStorage.close(LookupStorage.kt:155)
|
||||
... 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.
|
||||
at kotlin.io.FilesKt__UtilsKt.toRelativeString(Utils.kt:117)
|
||||
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.LegacyFileExternalizer.save(IdToFileMap.kt:51)
|
||||
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.put(PersistentMapImpl.java:426)
|
||||
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.InMemoryStorage.applyChanges(InMemoryStorage.kt:108)
|
||||
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.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)
|
||||
... 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.
|
||||
at kotlin.io.FilesKt__UtilsKt.toRelativeString(Utils.kt:117)
|
||||
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.FileDescriptor.getHashCode(FileToPathConverter.kt:50)
|
||||
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.remove(LinkedCustomHashMap.java:153)
|
||||
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.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.PersistentHashMap.put(PersistentHashMap.java:106)
|
||||
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.close(InMemoryStorage.kt:136)
|
||||
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.forEachMapSafe(BasicMapsOwner.kt:87)
|
||||
... 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
|
||||
... 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.
|
||||
at kotlin.io.FilesKt__UtilsKt.toRelativeString(Utils.kt:117)
|
||||
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.FileDescriptor.getHashCode(FileToPathConverter.kt:50)
|
||||
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.remove(LinkedCustomHashMap.java:153)
|
||||
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.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.PersistentHashMap.put(PersistentHashMap.java:106)
|
||||
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.AppendableInMemoryStorage.applyChanges(InMemoryStorage.kt:179)
|
||||
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.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)
|
||||
... 24 more
|
||||
|
||||
|
||||
@ -1,44 +1,44 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.metatroncube.hvbt_dash"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "com.metatroncube.hvbt_dash"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = 26
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.metatroncube.hvbt_dash"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "com.metatroncube.hvbt_dash"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = 26
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
|
||||
@ -1,51 +1,51 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.BLUETOOTH"/>
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
||||
<application
|
||||
android:label="hvbt_dash"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
to determine the Window background behind the Flutter UI. -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
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. -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.BLUETOOTH"/>
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
||||
<application
|
||||
android:label="hvbt_dash"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
to determine the Window background behind the Flutter UI. -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
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. -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
package com.metatroncube.hvbt_dash
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
package com.metatroncube.hvbt_dash
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
|
||||
@ -1,18 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- 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">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- 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">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@ -1,18 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- 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">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- 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">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
|
||||
@ -1,24 +1,24 @@
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
val newBuildDir: Directory =
|
||||
rootProject.layout.buildDirectory
|
||||
.dir("../../build")
|
||||
.get()
|
||||
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||
|
||||
subprojects {
|
||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(":app")
|
||||
}
|
||||
|
||||
tasks.register<Delete>("clean") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
val newBuildDir: Directory =
|
||||
rootProject.layout.buildDirectory
|
||||
.dir("../../build")
|
||||
.get()
|
||||
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||
|
||||
subprojects {
|
||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(":app")
|
||||
}
|
||||
|
||||
tasks.register<Delete>("clean") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
|
||||
10
android/gradle/wrapper/gradle-wrapper.properties
vendored
10
android/gradle/wrapper/gradle-wrapper.properties
vendored
@ -1,5 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
|
||||
|
||||
12804
android/hs_err_pid1456.log
Normal file
12804
android/hs_err_pid1456.log
Normal file
File diff suppressed because it is too large
Load Diff
2990
android/hs_err_pid28476.log
Normal file
2990
android/hs_err_pid28476.log
Normal file
File diff suppressed because it is too large
Load Diff
27317
android/replay_pid28476.log
Normal file
27317
android/replay_pid28476.log
Normal file
File diff suppressed because one or more lines are too long
@ -1,26 +1,26 @@
|
||||
pluginManagement {
|
||||
val flutterSdkPath =
|
||||
run {
|
||||
val properties = java.util.Properties()
|
||||
file("local.properties").inputStream().use { properties.load(it) }
|
||||
val flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
|
||||
flutterSdkPath
|
||||
}
|
||||
|
||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.11.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
pluginManagement {
|
||||
val flutterSdkPath =
|
||||
run {
|
||||
val properties = java.util.Properties()
|
||||
file("local.properties").inputStream().use { properties.load(it) }
|
||||
val flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
|
||||
flutterSdkPath
|
||||
}
|
||||
|
||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.11.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
|
||||
@ -1,209 +0,0 @@
|
||||
# HV BT Dashboard — Fix pass 2026-10-12 (client feedback round)
|
||||
|
||||
**Status:** `flutter analyze` (0 errors) and `flutter test` pass. APKs not yet
|
||||
built; the bench checklist in §6.2 must pass before anything is sent to the
|
||||
client.
|
||||
|
||||
**History:** the earlier uncommitted work and this pass are two commits on top
|
||||
of `77a47eb`: first the earlier work (fuel pump, ECT/IAT/BAT, ECU-type
|
||||
persistence), then this rework. To see this pass alone: `git show HEAD~1..HEAD`
|
||||
(or `git diff HEAD~2 HEAD~1` for the earlier work). The original line-ending-heavy
|
||||
snapshot commit is kept on branch `backup/snapshot-fb87e79`.
|
||||
|
||||
Review follow-ups applied in this pass: a reconnect in flight is discarded if
|
||||
the user disconnects meanwhile; default-frame detection also requires bytes
|
||||
32-36; the friendly error widget applies to release builds only.
|
||||
|
||||
---
|
||||
|
||||
## 1. Client feedback
|
||||
|
||||
1. Phone 1 — app crashes (all permissions allowed).
|
||||
2. Phone 2 — connects to S300, shows nothing correct.
|
||||
3. Phone 3 — some data correct, some obviously wrong; RPM gauge steps and freezes.
|
||||
4. "Too many requests from the app — use a timeout for the answer before sending a
|
||||
new request; it affects our device MCU usage."
|
||||
|
||||
## 2. Root causes found
|
||||
|
||||
### 2.1 "Template frames" are the adapter's DEFAULT frame
|
||||
The frozen frame the team has been working around (bytes 0-16
|
||||
`1B 00 14 00 00 FF FF F2 03 18 00 00 10 00 00 00 C4`, bytes 32-36
|
||||
`93 A0 A0 DC 7D`) is **byte-for-byte the example row printed in
|
||||
`hvbt_protocol.pdf`**. It is the adapter's built-in default buffer, returned when
|
||||
it has no fresh ECU data. Combined with the client's remark about MCU load, the
|
||||
most likely cause is that the app asks again before the adapter has finished
|
||||
reading the ECU. Holding "last good values" during these frames is what made the
|
||||
RPM gauge freeze and then jump, and a session that only ever gets default frames
|
||||
(seen live in HANDOFF.md) is exactly phone 2.
|
||||
|
||||
### 2.2 The poller flooded the adapter
|
||||
Old `bt_poller.dart`: a 50 ms periodic timer, plus an *extra* request whenever a
|
||||
reply took longer than 200 ms (no waiting for the outstanding one), plus a request
|
||||
that was never cleared after a bad-checksum reply (re-fired after 200 ms). Settings
|
||||
even offered 20 ms (50 req/s).
|
||||
|
||||
### 2.3 Wrong byte offsets
|
||||
The PDF's column labels (`+26 +27 +28 +29 +2A … +2F +30 +31`) are **not hex**.
|
||||
Real offsets are plain column counting 0..127. Proof: the four fields already
|
||||
confirmed live (Gear 27, ECT 33, IAT 34, BAT 36) all match column counting, and
|
||||
the default frame decodes to sensible defaults with it (MAP 101.0 kPa, ECT/IAT
|
||||
20 °C, BAT 12.0 V, trims 0 %, gear 1). The code read several fields as hex:
|
||||
|
||||
| Field | Old byte | New byte | Old symptom |
|
||||
|---|---|---|---|
|
||||
| Short trim | 41 (0x29) | 29 | showed ethanol byte |
|
||||
| Long trim | 43 (0x2B) | 31 | wrong |
|
||||
| PA (baro) | 44 (0x2C) | 32 | wrong |
|
||||
| ERR00–ERR03 (DTCs) | 49–52 | 37–40 | phantom DTCs (e.g. SW6=0x60 → P0505/P0350) |
|
||||
| Ethanol | 56 (0x38) | 41 | wrong |
|
||||
| SW5 (FANC) | 67 (0x43) | 49 | always 0 (reserved area) |
|
||||
| AF | 52 (0x34) | 55 | wrong |
|
||||
|
||||
Plus byte-order: INJ and AIN were read big-endian; the adapter is little-endian
|
||||
(RPM/VSS/MAP confirmed live; KPro table labels MAP2 / AFCMD as L/H pairs).
|
||||
|
||||
### 2.4 Phone 1 crash — not proven yet
|
||||
A Dart exception cannot close a Flutter app, so it is a native crash. Main
|
||||
suspects: `flutter_bluetooth_serial 0.4.0` (unmaintained since ~2021, predates
|
||||
Android 12 Bluetooth permissions, has a known "Reply already submitted" crash on a
|
||||
failed connect) and the old picker screen calling `getBondedDevices` before the
|
||||
Bluetooth permission was granted. **Need from client: phone model + Android
|
||||
version, and a logcat of the crash.**
|
||||
|
||||
---
|
||||
|
||||
## 3. Changes, file by file
|
||||
|
||||
### Bluetooth / polling
|
||||
- **`lib/core/bluetooth/bt_poller.dart` — rewritten.**
|
||||
- Exactly one request outstanding. Next request only after a complete,
|
||||
checksum-valid 128-byte reply **or** a 500 ms timeout.
|
||||
- After a timeout: 150 ms quiet period + receive buffer flush, so a late reply
|
||||
is never taken as the answer to the next request.
|
||||
- Pacing: one request per polling interval (default **100 ms = 10 Hz**, minimum
|
||||
80 ms enforced), never less than 20 ms after a reply.
|
||||
- Frame sync now requires the 2-byte header (`1B 00` S300 / `1B 01` KPro) + NEG8.
|
||||
- 6 consecutive timeouts → `onLinkStalled` callback.
|
||||
- DTC-reset and protocol-probe commands go through the same lock, so they never
|
||||
overlap a datalog request (`sendCommandAwaitReply`, `sendCommandCollect`).
|
||||
- Stats: requestsSent, validFrames, droppedFrames, timeouts, lastResponseTime.
|
||||
- **`lib/core/bluetooth/bt_service.dart`** — connect guarded against double
|
||||
attempts + 15 s timeout; write throws when not connected (so the poller counts
|
||||
it); close wrapped in timeout/try; logging.
|
||||
- **`lib/core/providers/bt_provider.dart` — rewritten.**
|
||||
- New `BtStatus.reconnecting`; new `EcuDataStatus` (none / live /
|
||||
adapterDefaults / noResponse).
|
||||
- **Adapter default frames are filtered out here** — they never reach the
|
||||
gauges, graph or datalog; they are counted and shown on the Debug screen.
|
||||
- Auto-reconnect: on BT link loss (up to 5 attempts, 1-5 s backoff); on 6
|
||||
consecutive timeouts or >8 s of default-only frames, at most once per 45 s.
|
||||
- Settings changes (poll interval) apply to a running poller.
|
||||
- `clearDtcs()` / `probeOfficialProtocol()` reimplemented via the poller lock;
|
||||
same return types as before.
|
||||
|
||||
### Decoding
|
||||
- **`lib/core/protocol/s300_parser.dart`** — offsets/formulas per §2.3; full offset
|
||||
map in the file header; `isS300DefaultFrame()` (old name
|
||||
`isS300TemplateFrame()` kept as an alias); fan output = SW5 bit0 FANC only
|
||||
(SW2 bit7 is ALTC = alternator control).
|
||||
- **`lib/core/protocol/kpro_parser.dart`** — RPM, MAP2, AIN read little-endian.
|
||||
⚠ KPro RPM endianness must be checked on the bench (2000 rpm must read 2000).
|
||||
- **`lib/core/providers/sensor_provider.dart`** — removed hold-last-value logic and
|
||||
the TEMP debug `print`s; decode errors go to the app log.
|
||||
- **`lib/core/models/sensor_defs.dart`** — O2, AFR, AIN0-7 shown as `raw` (scales
|
||||
not verified) instead of fake V/λ ranges.
|
||||
|
||||
### UI / robustness
|
||||
- **`lib/main.dart`** — `runZonedGuarded` + `FlutterError.onError` +
|
||||
`PlatformDispatcher.onError` → app log; friendly error widget.
|
||||
- **`lib/core/logging/app_log.dart` (new)** — 400-line in-memory log.
|
||||
- **`lib/ui/screens/debug/raw_frame_debug_screen.dart`** — "Copy app log" button;
|
||||
new link stats (requests, live vs default frames, timeouts, reply time,
|
||||
reconnects); raw-candidate rows use the corrected offsets.
|
||||
- **`lib/ui/widgets/data_status_banner.dart` (new)** + `main_nav.dart` — banner
|
||||
under the app bar when data is not live ("Adapter connected, but no data from
|
||||
the ECU", "Adapter is not responding", "Reconnecting…").
|
||||
- **`lib/ui/widgets/bt_status_chip.dart`** — reconnecting state.
|
||||
- **`lib/ui/screens/bt_picker/bt_picker_screen.dart`** — requests permissions
|
||||
**before** touching the paired-device list; only BLUETOOTH_CONNECT is required
|
||||
(scan/location optional); try/catch around plugin calls; double-tap guard.
|
||||
- **`lib/ui/screens/dashboard/widgets/analog_gauge.dart`** — 110 ms linear needle
|
||||
animation (was 150 ms ease-out) so the needle glides between 10 Hz readings.
|
||||
- **`lib/core/providers/settings_provider.dart` + settings screen** — interval
|
||||
options 100/150/200/300 ms (removed 20 and 50), default 100, persisted.
|
||||
- **`lib/core/providers/sensor_history_provider.dart`** — graph now actually
|
||||
redraws live (`state = state` was swallowed by StateNotifier).
|
||||
|
||||
### Tests
|
||||
- `test/s300_parser_test.dart` — INJ builder LE; new tests for trims, DTC bytes,
|
||||
no phantom DTC from SW6, FANC, Eth/AF/PA offsets, INJ/AIN LE, and decoding the
|
||||
full PDF default frame.
|
||||
- `test/kpro_parser_test.dart` — builder writes RPM and MAP2 little-endian.
|
||||
|
||||
---
|
||||
|
||||
## 4. Still UNVERIFIED (need one bench check against the USB tool)
|
||||
| Item | Current assumption |
|
||||
|---|---|
|
||||
| INJ scale | raw (LE) / 1000 → ms (assumes µs) |
|
||||
| IGN formula | PDF `(raw+120)/2` — one earlier live check disagreed |
|
||||
| PA formula | `raw*0.716 - 5` kPa (default 0x93 → 100.3 kPa) |
|
||||
| O2, AF, AIN | shown raw |
|
||||
| KPro RPM byte order | little-endian |
|
||||
| DTC clear ack | sent between polls now; ack still unconfirmed |
|
||||
|
||||
## 5. Questions for the client (device maker)
|
||||
1. Max response time of the adapter to `1B 00 E5`? (sets our 500 ms timeout)
|
||||
2. Recommended request interval? (we use 100 ms; DLrate byte = 0x14 = 20 — is that
|
||||
20 Hz?)
|
||||
3. Confirm: the PDF example frame = "no fresh ECU data" default frame?
|
||||
4. Phone models + Android versions; logcat from the crashing phone.
|
||||
|
||||
---
|
||||
|
||||
## 6. Verification checklist
|
||||
|
||||
### 6.1 Static (must all pass before building)
|
||||
- [ ] `flutter pub get`
|
||||
- [ ] `flutter analyze` — no errors (fix any compile errors in changed files only)
|
||||
- [ ] `flutter test` — all tests green
|
||||
- [ ] `flutter build apk --release` (and `--debug` for the bench)
|
||||
- [ ] Release build: if it crashes on connect but debug doesn't, add R8 keep rule
|
||||
`-keep class io.github.edufolly.flutterbluetoothserial.** { *; }`
|
||||
|
||||
### 6.2 Bench (S300 + USB reference tool)
|
||||
- [ ] Debug screen: *Requests sent* rises ≈10/s, never faster; *Timeouts* ≈ 0.
|
||||
- [ ] *Adapter default frames* stays ≈ 0 while the ECU is on (main proof the
|
||||
flooding was the cause). If it is still high → report to client with log.
|
||||
- [ ] RPM sweep up/down: needle moves smoothly, no freezes/steps.
|
||||
- [ ] Compare against USB tool: RPM, MAP, TPS, ECT, IAT, BAT, gear (already
|
||||
confirmed) + trims, PA, INJ, IGN, ETH (new).
|
||||
- [ ] DTC tab: no phantom codes; a forced fault shows the right code.
|
||||
- [ ] Unplug adapter power → banner "Reconnecting…", then error; replug +
|
||||
reconnect works.
|
||||
- [ ] Ignition off with adapter powered → banner "no data from the ECU".
|
||||
- [ ] Debug → "Copy app log" pastes a readable log.
|
||||
- [ ] Fresh install on Android 12+: permission prompt first, device list after,
|
||||
no crash. Deny "Nearby devices" → clear message, no crash.
|
||||
|
||||
---
|
||||
|
||||
## 7. Prompt for Claude Code (paste as-is)
|
||||
|
||||
```
|
||||
Read docs/FIX_2026-10-12.md fully. Then:
|
||||
1. Run `git diff fb87e79 --stat` and review every changed file against the doc.
|
||||
Flag anything in the diff that the doc does not describe, and any logic bug
|
||||
(especially in lib/core/bluetooth/bt_poller.dart and
|
||||
lib/core/providers/bt_provider.dart: one request outstanding at a time,
|
||||
timeout handling, reconnect loop, stream/timer cleanup on disconnect).
|
||||
2. Check the byte-offset table in lib/core/protocol/s300_parser.dart against
|
||||
hvbt_protocol.pdf using plain column counting (0..127), as the doc explains.
|
||||
3. Run `flutter pub get`, `flutter analyze`, `flutter test`. Fix compile/analyzer
|
||||
errors and failing tests with minimal changes; do not change protocol logic
|
||||
or offsets without telling me why.
|
||||
4. Build: `flutter build apk --debug` and `flutter build apk --release`.
|
||||
5. Report: what you fixed, analyzer/test results, APK paths, and any concerns.
|
||||
Do not commit until I say so.
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
BIN
hvbt_protocol.pdf
Normal file
BIN
hvbt_protocol.pdf
Normal file
Binary file not shown.
@ -1,320 +1,160 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../logging/app_log.dart';
|
||||
import '../protocol/neg8.dart';
|
||||
import 'bt_service.dart';
|
||||
|
||||
enum EcuType { s300, kpro }
|
||||
|
||||
/// ECU type implied by the adapter's Bluetooth name, or null if the name
|
||||
/// doesn't say. HV adapters advertise it, e.g. "S300[94]-SPP",
|
||||
/// "HVS300-SPP[94]"; a KPro adapter is expected to contain "KPRO"/"K-PRO".
|
||||
EcuType? ecuTypeFromDeviceName(String? name) {
|
||||
if (name == null) return null;
|
||||
final n = name.toUpperCase().replaceAll('-', '');
|
||||
final s300 = n.contains('S300');
|
||||
final kpro = n.contains('KPRO');
|
||||
if (s300 == kpro) return null; // neither, or ambiguous
|
||||
return s300 ? EcuType.s300 : EcuType.kpro;
|
||||
}
|
||||
|
||||
typedef _Matcher = Uint8List? Function(List<int> rxBuf);
|
||||
|
||||
class _Pending {
|
||||
_Pending(this.match);
|
||||
final _Matcher match;
|
||||
final Completer<Uint8List?> completer = Completer<Uint8List?>();
|
||||
}
|
||||
|
||||
/// Strict request/response poller for the HV BT adapter.
|
||||
///
|
||||
/// Rules (agreed with the device maker's feedback — the adapter MCU must not
|
||||
/// be flooded):
|
||||
/// * Exactly ONE request is outstanding at any time. A new request is only
|
||||
/// sent after the previous one was answered with a complete, checksum-valid
|
||||
/// 128-byte frame, or after [responseTimeout] expired.
|
||||
/// * After a timeout the link is left quiet for [quietAfterTimeout] and the
|
||||
/// receive buffer is flushed, so a late reply can't be mistaken for the
|
||||
/// answer to the next request.
|
||||
/// * Requests are paced: at most one every [pollingInterval] (measured from
|
||||
/// request to request) and never less than [minGap] after a reply.
|
||||
/// * After [stallThreshold] consecutive timeouts, [onLinkStalled] fires so the
|
||||
/// owner can reconnect.
|
||||
///
|
||||
/// Other commands (DTC reset, protocol probe) go through the same lock, so they
|
||||
/// never interleave with a datalog request.
|
||||
class BtPoller {
|
||||
BtPoller(
|
||||
this._service, {
|
||||
this.ecuType = EcuType.s300,
|
||||
Duration pollingInterval = defaultInterval,
|
||||
this.responseTimeout = const Duration(milliseconds: 500),
|
||||
this.onLinkStalled,
|
||||
}) : _pollingInterval = _sanitize(pollingInterval);
|
||||
|
||||
static const Duration defaultInterval = Duration(milliseconds: 100);
|
||||
static const Duration minInterval = Duration(milliseconds: 80);
|
||||
static const Duration minGap = Duration(milliseconds: 20);
|
||||
static const Duration quietAfterTimeout = Duration(milliseconds: 150);
|
||||
static const int stallThreshold = 6;
|
||||
static const int frameLength = 128;
|
||||
static const int _maxBuffer = 4096;
|
||||
|
||||
// S300 request: [0x1B, 0x00, NEG8] / KPro request: [0x1B, 0x01, NEG8]
|
||||
static final Uint8List _s300Request = Uint8List.fromList([0x1B, 0x00, 0xE5]);
|
||||
static final Uint8List _kproRequest = Uint8List.fromList([0x1B, 0x01, 0xE4]);
|
||||
|
||||
final BtService _service;
|
||||
EcuType ecuType;
|
||||
final Duration responseTimeout;
|
||||
final void Function()? onLinkStalled;
|
||||
|
||||
Duration _pollingInterval;
|
||||
Duration get pollingInterval => _pollingInterval;
|
||||
set pollingInterval(Duration d) => _pollingInterval = _sanitize(d);
|
||||
|
||||
static Duration _sanitize(Duration d) => d < minInterval ? minInterval : d;
|
||||
|
||||
final StreamController<Uint8List> _frameController =
|
||||
StreamController<Uint8List>.broadcast();
|
||||
|
||||
/// Checksum-valid 128-byte frames, one per answered request.
|
||||
Stream<Uint8List> get frameStream => _frameController.stream;
|
||||
|
||||
final List<int> _rxBuf = [];
|
||||
StreamSubscription<Uint8List>? _rxSub;
|
||||
_Pending? _pending;
|
||||
bool _running = false;
|
||||
int _generation = 0;
|
||||
|
||||
// Serialises every exchange on the link (polls and one-off commands).
|
||||
Future<void> _lockTail = Future<void>.value();
|
||||
|
||||
// ── Stats (read by the debug screen) ──────────────────────────────────────
|
||||
int requestsSent = 0;
|
||||
int validFrames = 0;
|
||||
int droppedFrames = 0; // checksum failures
|
||||
int timeouts = 0;
|
||||
int consecutiveTimeouts = 0;
|
||||
Duration? lastResponseTime;
|
||||
|
||||
bool get isRunning => _running;
|
||||
|
||||
void start() {
|
||||
if (_running) return;
|
||||
_running = true;
|
||||
final gen = ++_generation;
|
||||
_rxBuf.clear();
|
||||
consecutiveTimeouts = 0;
|
||||
_rxSub = _service.rawStream.listen(
|
||||
_onRx,
|
||||
onError: (Object e) {
|
||||
AppLog.w('BT stream error: $e');
|
||||
if (!_frameController.isClosed) _frameController.addError(e);
|
||||
},
|
||||
);
|
||||
AppLog.i('Poller started (${ecuType.name}, every '
|
||||
'${_pollingInterval.inMilliseconds} ms, timeout '
|
||||
'${responseTimeout.inMilliseconds} ms)');
|
||||
unawaited(_loop(gen));
|
||||
}
|
||||
|
||||
void stop() {
|
||||
if (!_running) return;
|
||||
_running = false;
|
||||
_generation++;
|
||||
_rxSub?.cancel();
|
||||
_rxSub = null;
|
||||
final p = _pending;
|
||||
if (p != null && !p.completer.isCompleted) p.completer.complete(null);
|
||||
_pending = null;
|
||||
_rxBuf.clear();
|
||||
AppLog.i('Poller stopped (sent=$requestsSent ok=$validFrames '
|
||||
'timeouts=$timeouts badChecksum=$droppedFrames)');
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
stop();
|
||||
_frameController.close();
|
||||
}
|
||||
|
||||
// ── Main loop ─────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _loop(int gen) async {
|
||||
bool alive() => _running && gen == _generation;
|
||||
|
||||
while (alive()) {
|
||||
final started = DateTime.now();
|
||||
|
||||
await _withLink(() async {
|
||||
if (!alive() || !_service.isConnected) return;
|
||||
final req = ecuType == EcuType.s300 ? _s300Request : _kproRequest;
|
||||
final frame = await _transact(req, _matchDatalogFrame, responseTimeout);
|
||||
if (!alive()) return;
|
||||
|
||||
if (frame != null) {
|
||||
validFrames++;
|
||||
consecutiveTimeouts = 0;
|
||||
lastResponseTime = DateTime.now().difference(started);
|
||||
if (!_frameController.isClosed) _frameController.add(frame);
|
||||
} else {
|
||||
timeouts++;
|
||||
consecutiveTimeouts++;
|
||||
if (consecutiveTimeouts == 1 || consecutiveTimeouts % 10 == 0) {
|
||||
AppLog.w('No reply within ${responseTimeout.inMilliseconds} ms '
|
||||
'(consecutive=$consecutiveTimeouts, total=$timeouts)');
|
||||
}
|
||||
// Let any late reply arrive and be discarded before the next request.
|
||||
await Future<void>.delayed(quietAfterTimeout);
|
||||
_rxBuf.clear();
|
||||
}
|
||||
});
|
||||
|
||||
if (!alive()) break;
|
||||
|
||||
if (consecutiveTimeouts >= stallThreshold) {
|
||||
consecutiveTimeouts = 0;
|
||||
AppLog.w('Link stalled: $stallThreshold requests in a row unanswered');
|
||||
onLinkStalled?.call();
|
||||
}
|
||||
|
||||
final elapsed = DateTime.now().difference(started);
|
||||
var wait = _pollingInterval - elapsed;
|
||||
if (wait < minGap) wait = minGap;
|
||||
await Future<void>.delayed(wait);
|
||||
}
|
||||
}
|
||||
|
||||
// ── One-off commands ──────────────────────────────────────────────────────
|
||||
|
||||
/// Sends [command] (between two polls, never overlapping one) and waits up
|
||||
/// to [timeout] for [expected] to appear in the reply bytes.
|
||||
Future<({bool acknowledged, Uint8List raw})> sendCommandAwaitReply(
|
||||
Uint8List command,
|
||||
Uint8List expected, {
|
||||
Duration timeout = const Duration(seconds: 2),
|
||||
}) {
|
||||
return _withLink(() async {
|
||||
await Future<void>.delayed(quietAfterTimeout);
|
||||
_rxBuf.clear();
|
||||
final match = await _transact(
|
||||
command,
|
||||
(buf) => _indexOf(buf, expected) >= 0 ? expected : null,
|
||||
timeout,
|
||||
);
|
||||
final raw = Uint8List.fromList(_rxBuf);
|
||||
_rxBuf.clear();
|
||||
return (acknowledged: match != null, raw: raw);
|
||||
});
|
||||
}
|
||||
|
||||
/// Sends [command] and returns every byte received during [window].
|
||||
Future<Uint8List> sendCommandCollect(Uint8List command, Duration window) {
|
||||
return _withLink(() async {
|
||||
await Future<void>.delayed(quietAfterTimeout);
|
||||
_rxBuf.clear();
|
||||
await _transact(command, (_) => null, window);
|
||||
final raw = Uint8List.fromList(_rxBuf);
|
||||
_rxBuf.clear();
|
||||
return raw;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Internals ─────────────────────────────────────────────────────────────
|
||||
|
||||
Future<T> _withLink<T>(Future<T> Function() action) {
|
||||
final previous = _lockTail;
|
||||
final release = Completer<void>();
|
||||
_lockTail = release.future;
|
||||
return previous.then((_) => action()).whenComplete(release.complete);
|
||||
}
|
||||
|
||||
/// Writes [request] and waits for [match] to succeed or [timeout]. Must be
|
||||
/// called while holding the link lock.
|
||||
Future<Uint8List?> _transact(
|
||||
Uint8List request,
|
||||
_Matcher match,
|
||||
Duration timeout,
|
||||
) async {
|
||||
_rxBuf.clear();
|
||||
final pending = _Pending(match);
|
||||
_pending = pending;
|
||||
try {
|
||||
await _service.write(request).timeout(const Duration(seconds: 1));
|
||||
requestsSent++;
|
||||
} catch (e) {
|
||||
AppLog.w('BT write failed: $e');
|
||||
if (identical(_pending, pending)) _pending = null;
|
||||
// Don't spin on a broken link.
|
||||
await Future<void>.delayed(quietAfterTimeout);
|
||||
return null;
|
||||
}
|
||||
_tryComplete();
|
||||
try {
|
||||
return await pending.completer.future.timeout(timeout);
|
||||
} on TimeoutException {
|
||||
return null;
|
||||
} finally {
|
||||
if (identical(_pending, pending)) _pending = null;
|
||||
}
|
||||
}
|
||||
|
||||
void _onRx(Uint8List chunk) {
|
||||
if (_pending == null) {
|
||||
// Nobody is waiting: stray/late bytes. Drop them.
|
||||
_rxBuf.clear();
|
||||
return;
|
||||
}
|
||||
_rxBuf.addAll(chunk);
|
||||
if (_rxBuf.length > _maxBuffer) {
|
||||
_rxBuf.removeRange(0, _rxBuf.length - _maxBuffer);
|
||||
}
|
||||
_tryComplete();
|
||||
}
|
||||
|
||||
void _tryComplete() {
|
||||
final p = _pending;
|
||||
if (p == null || p.completer.isCompleted) return;
|
||||
final result = p.match(_rxBuf);
|
||||
if (result != null) p.completer.complete(result);
|
||||
}
|
||||
|
||||
/// Finds a complete, checksum-valid 128-byte datalog frame starting with
|
||||
/// the expected 2-byte header. Consumes bytes it has ruled out.
|
||||
Uint8List? _matchDatalogFrame(List<int> buf) {
|
||||
final id2 = ecuType == EcuType.s300 ? 0x00 : 0x01;
|
||||
while (true) {
|
||||
var i = 0;
|
||||
while (i + 1 < buf.length && !(buf[i] == 0x1B && buf[i + 1] == id2)) {
|
||||
i++;
|
||||
}
|
||||
if (buf.length - i < frameLength) {
|
||||
if (i > 0) buf.removeRange(0, i);
|
||||
return null;
|
||||
}
|
||||
final frame = Uint8List.fromList(buf.sublist(i, i + frameLength));
|
||||
if (validateFrame(frame)) {
|
||||
buf.removeRange(0, i + frameLength);
|
||||
return frame;
|
||||
}
|
||||
// Bad checksum: either a false header inside data or a corrupted frame.
|
||||
droppedFrames++;
|
||||
buf.removeRange(0, i + 1);
|
||||
}
|
||||
}
|
||||
|
||||
static int _indexOf(List<int> buf, List<int> pattern) {
|
||||
for (var i = 0; i + pattern.length <= buf.length; i++) {
|
||||
var ok = true;
|
||||
for (var j = 0; j < pattern.length; j++) {
|
||||
if (buf[i + j] != pattern[j]) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (ok) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../protocol/neg8.dart';
|
||||
import 'bt_service.dart';
|
||||
|
||||
enum EcuType { s300, kpro }
|
||||
|
||||
/// Sends the ECU request byte every [pollingInterval] ms,
|
||||
/// accumulates raw bytes into 128-byte frames, validates NEG8,
|
||||
/// and emits the newest usable frame on [frameStream].
|
||||
class BtPoller {
|
||||
final BtService _service;
|
||||
EcuType ecuType;
|
||||
final Duration pollingInterval;
|
||||
|
||||
// S300 request: [0x1B, 0x00, 0xE5]
|
||||
static final Uint8List _s300Request = Uint8List.fromList([0x1B, 0x00, 0xE5]);
|
||||
// KPro request: [0x1B, 0x01, 0xE4]
|
||||
static final Uint8List _kproRequest = Uint8List.fromList([0x1B, 0x01, 0xE4]);
|
||||
|
||||
final StreamController<Uint8List> _frameController =
|
||||
StreamController<Uint8List>.broadcast();
|
||||
|
||||
Stream<Uint8List> get frameStream => _frameController.stream;
|
||||
|
||||
final List<int> _rxBuf = [];
|
||||
Timer? _pollTimer;
|
||||
StreamSubscription<Uint8List>? _rxSub;
|
||||
|
||||
int droppedFrames = 0;
|
||||
int validFrames = 0;
|
||||
|
||||
// Tracks whether a request is still awaiting its response, so the poller
|
||||
// never pipelines more than one request ahead of the device. Without this,
|
||||
// a device that answers slower than [pollingInterval] falls permanently
|
||||
// behind — every displayed frame ends up answering a stale, already-queued
|
||||
// request instead of the most recent one, and the dashboard visibly lags
|
||||
// real engine changes by a growing amount.
|
||||
bool _awaitingResponse = false;
|
||||
DateTime? _lastRequestSentAt;
|
||||
|
||||
BtPoller(
|
||||
this._service, {
|
||||
this.ecuType = EcuType.s300,
|
||||
this.pollingInterval = const Duration(milliseconds: 50),
|
||||
});
|
||||
|
||||
void start() {
|
||||
_rxBuf.clear();
|
||||
_awaitingResponse = false;
|
||||
_lastRequestSentAt = null;
|
||||
|
||||
// Listen to raw bytes from BT and accumulate into 128-byte frames
|
||||
_rxSub = _service.rawStream.listen(
|
||||
(Uint8List chunk) {
|
||||
_rxBuf.addAll(chunk);
|
||||
_processBuffer();
|
||||
},
|
||||
onError: (Object e) {
|
||||
_frameController.addError(e);
|
||||
stop();
|
||||
},
|
||||
);
|
||||
|
||||
// Check every pollingInterval whether it's time to send the next
|
||||
// request — but only actually send once the prior one has been
|
||||
// answered (or has clearly been lost).
|
||||
_pollTimer = Timer.periodic(pollingInterval, (_) => _maybeSendRequest());
|
||||
_maybeSendRequest();
|
||||
}
|
||||
|
||||
void _maybeSendRequest() {
|
||||
if (!_service.isConnected) return;
|
||||
|
||||
final stalled = _lastRequestSentAt != null &&
|
||||
DateTime.now().difference(_lastRequestSentAt!) > pollingInterval * 4;
|
||||
if (_awaitingResponse && !stalled) return;
|
||||
|
||||
_awaitingResponse = true;
|
||||
_lastRequestSentAt = DateTime.now();
|
||||
_service
|
||||
.write(ecuType == EcuType.s300 ? _s300Request : _kproRequest)
|
||||
.catchError((Object _) {
|
||||
// A failed write must not leave the poller permanently waiting for a
|
||||
// response that was never actually requested — let the next tick retry.
|
||||
_awaitingResponse = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _processBuffer() {
|
||||
// Extract as many 128-byte frames as possible.
|
||||
// Frame start sync: first byte should be 0x1B (header marker).
|
||||
// If we have a misaligned buffer, scan forward.
|
||||
Uint8List? latestUsableFrame;
|
||||
|
||||
while (_rxBuf.length >= 128) {
|
||||
// Scan for 0x1B frame header
|
||||
int startIdx = 0;
|
||||
while (startIdx < _rxBuf.length && _rxBuf[startIdx] != 0x1B) {
|
||||
startIdx++;
|
||||
}
|
||||
|
||||
// Not enough data after sync byte
|
||||
if (_rxBuf.length - startIdx < 128) {
|
||||
// Discard bytes before potential header
|
||||
if (startIdx > 0) {
|
||||
_rxBuf.removeRange(0, startIdx);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
final Uint8List frame =
|
||||
Uint8List.fromList(_rxBuf.sublist(startIdx, startIdx + 128));
|
||||
|
||||
final checksumOk = validateFrame(frame);
|
||||
|
||||
if (checksumOk) {
|
||||
validFrames++;
|
||||
latestUsableFrame = frame;
|
||||
_rxBuf.removeRange(0, startIdx + 128);
|
||||
} else {
|
||||
// Bad checksum — the window is misaligned (or the frame was
|
||||
// corrupted in transit). Drop one byte and rescan for the next
|
||||
// 0x1B so the stream resynchronizes instead of permanently
|
||||
// sliding a fixed-size window over garbage.
|
||||
droppedFrames++;
|
||||
_rxBuf.removeRange(0, startIdx + 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (latestUsableFrame != null) {
|
||||
_frameController.add(latestUsableFrame);
|
||||
// The device answered — clear the road for the next request. Wait out
|
||||
// the configured pollingInterval before sending it rather than firing
|
||||
// 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
|
||||
// RPM count between reads — hammering it faster than that makes it
|
||||
// fall back to reporting "no fresh count yet" on every subsequent
|
||||
// request instead of a real reading. pollingInterval is also what the
|
||||
// Settings screen exposes, so this makes that control actually pace
|
||||
// the link instead of just being a stalled-request fallback.
|
||||
_awaitingResponse = false;
|
||||
Timer(pollingInterval, _maybeSendRequest);
|
||||
}
|
||||
}
|
||||
|
||||
void stop() {
|
||||
_pollTimer?.cancel();
|
||||
_pollTimer = null;
|
||||
_rxSub?.cancel();
|
||||
_rxSub = null;
|
||||
_rxBuf.clear();
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
stop();
|
||||
_frameController.close();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,99 +1,56 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
|
||||
|
||||
import '../logging/app_log.dart';
|
||||
|
||||
/// Wraps flutter_bluetooth_serial for SPP/RFCOMM Classic BT.
|
||||
///
|
||||
/// Every platform call is wrapped in a timeout and try/catch: the plugin is
|
||||
/// old and some Android builds throw (or never answer) on connect/close.
|
||||
class BtService {
|
||||
BluetoothConnection? _connection;
|
||||
StreamSubscription<Uint8List>? _inputSub;
|
||||
bool _connecting = false;
|
||||
|
||||
final StreamController<Uint8List> _rawFrameController =
|
||||
StreamController<Uint8List>.broadcast();
|
||||
|
||||
bool get isConnected {
|
||||
try {
|
||||
return _connection?.isConnected ?? false;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Stream<Uint8List> get rawStream => _rawFrameController.stream;
|
||||
|
||||
Future<List<BluetoothDevice>> getPairedDevices() async {
|
||||
return FlutterBluetoothSerial.instance.getBondedDevices();
|
||||
}
|
||||
|
||||
Future<void> connect(BluetoothDevice device) async {
|
||||
if (_connecting) {
|
||||
throw StateError('A connection attempt is already in progress');
|
||||
}
|
||||
_connecting = true;
|
||||
try {
|
||||
if (_connection != null) {
|
||||
await disconnect();
|
||||
}
|
||||
AppLog.i('Connecting to ${device.name ?? '?'} (${device.address})');
|
||||
final conn = await BluetoothConnection.toAddress(device.address)
|
||||
.timeout(const Duration(seconds: 15));
|
||||
_connection = conn;
|
||||
_inputSub = conn.input?.listen(
|
||||
(Uint8List data) {
|
||||
if (!_rawFrameController.isClosed) _rawFrameController.add(data);
|
||||
},
|
||||
onDone: () {
|
||||
AppLog.w('BT input stream closed by remote/OS');
|
||||
if (!_rawFrameController.isClosed && identical(_connection, conn)) {
|
||||
_rawFrameController.addError('BT connection closed');
|
||||
}
|
||||
},
|
||||
onError: (Object e) {
|
||||
AppLog.w('BT input error: $e');
|
||||
if (!_rawFrameController.isClosed) _rawFrameController.addError(e);
|
||||
},
|
||||
cancelOnError: false,
|
||||
);
|
||||
AppLog.i('Connected');
|
||||
} on TimeoutException {
|
||||
AppLog.w('Connect timed out');
|
||||
throw Exception('Connection timed out — is the adapter powered and '
|
||||
'not connected to another phone?');
|
||||
} finally {
|
||||
_connecting = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> write(Uint8List data) async {
|
||||
final conn = _connection;
|
||||
if (conn == null || !isConnected) {
|
||||
throw StateError('Not connected');
|
||||
}
|
||||
conn.output.add(data);
|
||||
await conn.output.allSent;
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
final conn = _connection;
|
||||
_connection = null;
|
||||
await _inputSub?.cancel();
|
||||
_inputSub = null;
|
||||
if (conn == null) return;
|
||||
try {
|
||||
await conn.close().timeout(const Duration(seconds: 3));
|
||||
} catch (e) {
|
||||
AppLog.w('BT close error (ignored): $e');
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
disconnect();
|
||||
_rawFrameController.close();
|
||||
}
|
||||
}
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
|
||||
|
||||
/// Wraps flutter_bluetooth_serial for SPP/RFCOMM Classic BT.
|
||||
class BtService {
|
||||
BluetoothConnection? _connection;
|
||||
final StreamController<Uint8List> _rawFrameController =
|
||||
StreamController<Uint8List>.broadcast();
|
||||
|
||||
bool get isConnected => _connection?.isConnected ?? false;
|
||||
|
||||
Stream<Uint8List> get rawStream => _rawFrameController.stream;
|
||||
|
||||
Future<List<BluetoothDevice>> getPairedDevices() async {
|
||||
return FlutterBluetoothSerial.instance.getBondedDevices();
|
||||
}
|
||||
|
||||
Future<void> connect(BluetoothDevice device) async {
|
||||
if (_connection != null) {
|
||||
await disconnect();
|
||||
}
|
||||
_connection = await BluetoothConnection.toAddress(device.address);
|
||||
_connection!.input!.listen(
|
||||
(Uint8List data) {
|
||||
if (!_rawFrameController.isClosed) {
|
||||
_rawFrameController.add(data);
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
_rawFrameController.addError('BT connection closed');
|
||||
},
|
||||
onError: (Object e) {
|
||||
_rawFrameController.addError(e);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> write(Uint8List data) async {
|
||||
if (_connection?.isConnected ?? false) {
|
||||
_connection!.output.add(data);
|
||||
await _connection!.output.allSent;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
await _connection?.close();
|
||||
_connection = null;
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
disconnect();
|
||||
_rawFrameController.close();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,93 +0,0 @@
|
||||
import 'dart:collection';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// Drops frames whose RPM jumps further than an engine can in the time since
|
||||
/// the last accepted frame — the same thing the USB tool appears to do.
|
||||
///
|
||||
/// Seen on the KPro bench with a crank-signal fault (P0336): roughly every
|
||||
/// third frame the ECU reported ~250-330 rpm (with matching long injection
|
||||
/// and low-RPM knock threshold) between the real readings, sometimes several
|
||||
/// in a row, so the dial flickered. The USB tool shows a steady value there.
|
||||
///
|
||||
/// A real change still gets through by majority: once [confirmVotes] of the
|
||||
/// last [window] readings agree (within 20 %) on a value, it is accepted even
|
||||
/// if glitch frames are mixed in. (A "consecutive frames" rule never
|
||||
/// confirmed a real change while glitches kept interrupting it, which froze
|
||||
/// the dial.) [onConfirmed] reports each such accepted jump.
|
||||
class RpmGlitchFilter {
|
||||
RpmGlitchFilter({
|
||||
this.baseDelta = 300,
|
||||
this.maxRate = 4000,
|
||||
this.window = 10,
|
||||
this.confirmVotes = 6,
|
||||
this.onConfirmed,
|
||||
});
|
||||
|
||||
/// Allowed jump regardless of elapsed time (rpm).
|
||||
final double baseDelta;
|
||||
|
||||
/// Allowed rate of change (rpm per second).
|
||||
final double maxRate;
|
||||
|
||||
/// Number of recent readings kept for the majority vote.
|
||||
final int window;
|
||||
|
||||
/// Readings out of [window] that must agree to accept a new value.
|
||||
final int confirmVotes;
|
||||
|
||||
final void Function(double from, double to)? onConfirmed;
|
||||
|
||||
double? _lastRpm;
|
||||
DateTime? _lastAt;
|
||||
final _recent = ListQueue<double>();
|
||||
|
||||
/// Frames dropped so far.
|
||||
int dropped = 0;
|
||||
|
||||
void reset() {
|
||||
_lastRpm = null;
|
||||
_lastAt = null;
|
||||
_recent.clear();
|
||||
}
|
||||
|
||||
/// RPM in a raw frame: KPro (`1B 01`) bytes 2-3 LE / 4, S300 bytes 3-4 LE.
|
||||
static double rpmOf(Uint8List frame) => frame[1] == 0x01
|
||||
? (frame[2] | (frame[3] << 8)) / 4.0
|
||||
: (frame[3] | (frame[4] << 8)).toDouble();
|
||||
|
||||
bool _agree(double a, double b) =>
|
||||
(a - b).abs() <= 0.2 * math.max(a.abs(), b.abs()) + baseDelta;
|
||||
|
||||
/// True if [frame] should be shown/recorded, false if it is a glitch.
|
||||
bool accept(Uint8List frame, DateTime at) {
|
||||
final rpm = rpmOf(frame);
|
||||
_recent.addLast(rpm);
|
||||
while (_recent.length > window) {
|
||||
_recent.removeFirst();
|
||||
}
|
||||
|
||||
final last = _lastRpm;
|
||||
if (last == null) return _take(rpm, at);
|
||||
|
||||
// Capped: while frames are being dropped the last accepted one ages, and
|
||||
// an uncapped allowance would eventually let a glitch through. Larger
|
||||
// real changes are taken by the majority vote below.
|
||||
final dt = math.min(at.difference(_lastAt!).inMicroseconds / 1e6, 0.25);
|
||||
if ((rpm - last).abs() <= baseDelta + maxRate * dt) return _take(rpm, at);
|
||||
|
||||
final votes = _recent.where((r) => _agree(r, rpm)).length;
|
||||
if (votes >= confirmVotes) {
|
||||
onConfirmed?.call(last, rpm);
|
||||
return _take(rpm, at);
|
||||
}
|
||||
dropped++;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _take(double rpm, DateTime at) {
|
||||
_lastRpm = rpm;
|
||||
_lastAt = at;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -1,140 +1,140 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
|
||||
import '../models/datalog_session.dart';
|
||||
|
||||
/// SQLite database wrapper for sessions and raw ECU frames.
|
||||
class DatalogDb {
|
||||
static const _dbName = 'hvbt_datalog.db';
|
||||
static const _version = 1;
|
||||
|
||||
Database? _db;
|
||||
|
||||
Future<Database> get _database async {
|
||||
_db ??= await _open();
|
||||
return _db!;
|
||||
}
|
||||
|
||||
Future<Database> _open() async {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final path = p.join(dir.path, _dbName);
|
||||
return openDatabase(
|
||||
path,
|
||||
version: _version,
|
||||
onCreate: (db, _) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ecu_type TEXT NOT NULL,
|
||||
start_time INTEGER NOT NULL,
|
||||
end_time INTEGER,
|
||||
frame_count INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
''');
|
||||
await db.execute('''
|
||||
CREATE TABLE frames (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id INTEGER NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
data BLOB NOT NULL,
|
||||
FOREIGN KEY(session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
||||
)
|
||||
''');
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_frames_session ON frames(session_id)');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sessions ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Inserts a new session row. Returns the assigned id.
|
||||
Future<int> insertSession(DatalogSession session) async {
|
||||
final db = await _database;
|
||||
return db.insert('sessions', session.toMap()..remove('id'));
|
||||
}
|
||||
|
||||
/// Updates end_time and frame_count for an existing session.
|
||||
Future<void> closeSession(int sessionId, DateTime endTime, int frameCount) async {
|
||||
final db = await _database;
|
||||
await db.update(
|
||||
'sessions',
|
||||
{
|
||||
'end_time': endTime.millisecondsSinceEpoch,
|
||||
'frame_count': frameCount,
|
||||
},
|
||||
where: 'id = ?',
|
||||
whereArgs: [sessionId],
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns all sessions ordered newest first.
|
||||
Future<List<DatalogSession>> getSessions() async {
|
||||
final db = await _database;
|
||||
final rows = await db.query('sessions', orderBy: 'start_time DESC');
|
||||
return rows.map(DatalogSession.fromMap).toList();
|
||||
}
|
||||
|
||||
/// Deletes a session and all its frames (CASCADE).
|
||||
Future<void> deleteSession(int sessionId) async {
|
||||
final db = await _database;
|
||||
await db.delete('sessions', where: 'id = ?', whereArgs: [sessionId]);
|
||||
}
|
||||
|
||||
// ── Frames ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Batch-inserts multiple frames in a single transaction.
|
||||
Future<void> insertFrames(
|
||||
int sessionId, List<PendingFrame> frames) async {
|
||||
if (frames.isEmpty) return;
|
||||
final db = await _database;
|
||||
await db.transaction((txn) async {
|
||||
final batch = txn.batch();
|
||||
for (final f in frames) {
|
||||
batch.insert('frames', {
|
||||
'session_id': sessionId,
|
||||
'timestamp': f.timestamp.millisecondsSinceEpoch,
|
||||
'data': f.data,
|
||||
});
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
});
|
||||
}
|
||||
|
||||
/// Returns all raw frames for a session, ordered by timestamp.
|
||||
Future<List<StoredFrame>> getFrames(int sessionId) async {
|
||||
final db = await _database;
|
||||
final rows = await db.query(
|
||||
'frames',
|
||||
where: 'session_id = ?',
|
||||
whereArgs: [sessionId],
|
||||
orderBy: 'timestamp ASC',
|
||||
);
|
||||
return rows
|
||||
.map((r) => StoredFrame(
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(
|
||||
r['timestamp'] as int),
|
||||
data: r['data'] as Uint8List,
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> close() async => _db?.close();
|
||||
}
|
||||
|
||||
// ─── DTOs ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
class PendingFrame {
|
||||
final DateTime timestamp;
|
||||
final Uint8List data;
|
||||
const PendingFrame(this.timestamp, this.data);
|
||||
}
|
||||
|
||||
class StoredFrame {
|
||||
final DateTime timestamp;
|
||||
final Uint8List data;
|
||||
const StoredFrame({required this.timestamp, required this.data});
|
||||
}
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
|
||||
import '../models/datalog_session.dart';
|
||||
|
||||
/// SQLite database wrapper for sessions and raw ECU frames.
|
||||
class DatalogDb {
|
||||
static const _dbName = 'hvbt_datalog.db';
|
||||
static const _version = 1;
|
||||
|
||||
Database? _db;
|
||||
|
||||
Future<Database> get _database async {
|
||||
_db ??= await _open();
|
||||
return _db!;
|
||||
}
|
||||
|
||||
Future<Database> _open() async {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final path = p.join(dir.path, _dbName);
|
||||
return openDatabase(
|
||||
path,
|
||||
version: _version,
|
||||
onCreate: (db, _) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ecu_type TEXT NOT NULL,
|
||||
start_time INTEGER NOT NULL,
|
||||
end_time INTEGER,
|
||||
frame_count INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
''');
|
||||
await db.execute('''
|
||||
CREATE TABLE frames (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id INTEGER NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
data BLOB NOT NULL,
|
||||
FOREIGN KEY(session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
||||
)
|
||||
''');
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_frames_session ON frames(session_id)');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sessions ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Inserts a new session row. Returns the assigned id.
|
||||
Future<int> insertSession(DatalogSession session) async {
|
||||
final db = await _database;
|
||||
return db.insert('sessions', session.toMap()..remove('id'));
|
||||
}
|
||||
|
||||
/// Updates end_time and frame_count for an existing session.
|
||||
Future<void> closeSession(int sessionId, DateTime endTime, int frameCount) async {
|
||||
final db = await _database;
|
||||
await db.update(
|
||||
'sessions',
|
||||
{
|
||||
'end_time': endTime.millisecondsSinceEpoch,
|
||||
'frame_count': frameCount,
|
||||
},
|
||||
where: 'id = ?',
|
||||
whereArgs: [sessionId],
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns all sessions ordered newest first.
|
||||
Future<List<DatalogSession>> getSessions() async {
|
||||
final db = await _database;
|
||||
final rows = await db.query('sessions', orderBy: 'start_time DESC');
|
||||
return rows.map(DatalogSession.fromMap).toList();
|
||||
}
|
||||
|
||||
/// Deletes a session and all its frames (CASCADE).
|
||||
Future<void> deleteSession(int sessionId) async {
|
||||
final db = await _database;
|
||||
await db.delete('sessions', where: 'id = ?', whereArgs: [sessionId]);
|
||||
}
|
||||
|
||||
// ── Frames ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Batch-inserts multiple frames in a single transaction.
|
||||
Future<void> insertFrames(
|
||||
int sessionId, List<PendingFrame> frames) async {
|
||||
if (frames.isEmpty) return;
|
||||
final db = await _database;
|
||||
await db.transaction((txn) async {
|
||||
final batch = txn.batch();
|
||||
for (final f in frames) {
|
||||
batch.insert('frames', {
|
||||
'session_id': sessionId,
|
||||
'timestamp': f.timestamp.millisecondsSinceEpoch,
|
||||
'data': f.data,
|
||||
});
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
});
|
||||
}
|
||||
|
||||
/// Returns all raw frames for a session, ordered by timestamp.
|
||||
Future<List<StoredFrame>> getFrames(int sessionId) async {
|
||||
final db = await _database;
|
||||
final rows = await db.query(
|
||||
'frames',
|
||||
where: 'session_id = ?',
|
||||
whereArgs: [sessionId],
|
||||
orderBy: 'timestamp ASC',
|
||||
);
|
||||
return rows
|
||||
.map((r) => StoredFrame(
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(
|
||||
r['timestamp'] as int),
|
||||
data: r['data'] as Uint8List,
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> close() async => _db?.close();
|
||||
}
|
||||
|
||||
// ─── DTOs ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
class PendingFrame {
|
||||
final DateTime timestamp;
|
||||
final Uint8List data;
|
||||
const PendingFrame(this.timestamp, this.data);
|
||||
}
|
||||
|
||||
class StoredFrame {
|
||||
final DateTime timestamp;
|
||||
final Uint8List data;
|
||||
const StoredFrame({required this.timestamp, required this.data});
|
||||
}
|
||||
|
||||
@ -1,122 +1,119 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'datalog_db.dart';
|
||||
|
||||
enum PlaybackStatus { idle, playing, paused }
|
||||
|
||||
/// Loads stored frames from SQLite and replays them through a stream
|
||||
/// at configurable speed. Consumers watch [frameStream] exactly like
|
||||
/// the live BT stream — the parser layer is unaware of the difference.
|
||||
class DatalogPlayer {
|
||||
final DatalogDb _db;
|
||||
|
||||
List<StoredFrame> _frames = [];
|
||||
int _position = 0; // index into _frames
|
||||
double _speed = 1.0; // 1.0 = real-time, 2.0 = 2× faster
|
||||
|
||||
PlaybackStatus _status = PlaybackStatus.idle;
|
||||
PlaybackStatus get status => _status;
|
||||
int get position => _position;
|
||||
int get totalFrames => _frames.length;
|
||||
double get speed => _speed;
|
||||
set speed(double v) => _speed = v.clamp(0.25, 8.0);
|
||||
|
||||
final StreamController<Uint8List> _controller =
|
||||
StreamController<Uint8List>.broadcast();
|
||||
|
||||
Stream<Uint8List> get frameStream => _controller.stream;
|
||||
|
||||
/// Callback fired on each tick — lets UI update a progress indicator.
|
||||
void Function(int position, int total)? onProgress;
|
||||
|
||||
Timer? _timer;
|
||||
|
||||
DatalogPlayer(this._db);
|
||||
|
||||
/// Load all frames for [sessionId] into memory.
|
||||
Future<void> load(int sessionId) async {
|
||||
await stop();
|
||||
_frames = await _db.getFrames(sessionId);
|
||||
_position = 0;
|
||||
_status = PlaybackStatus.idle;
|
||||
}
|
||||
|
||||
/// Start or resume playback.
|
||||
void play() {
|
||||
if (_status == PlaybackStatus.playing) return;
|
||||
if (_frames.isEmpty) return;
|
||||
_status = PlaybackStatus.playing;
|
||||
_scheduleNext();
|
||||
}
|
||||
|
||||
void pause() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
if (_status == PlaybackStatus.playing) {
|
||||
_status = PlaybackStatus.paused;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
_position = 0;
|
||||
_status = PlaybackStatus.idle;
|
||||
}
|
||||
|
||||
/// Jump to a specific frame index. The frame there is emitted at once so
|
||||
/// the displays follow the slider even while paused.
|
||||
void seek(int index) {
|
||||
if (_frames.isEmpty) return;
|
||||
_position = index.clamp(0, _frames.length - 1);
|
||||
_controller.add(_frames[_position].data);
|
||||
onProgress?.call(_position, _frames.length);
|
||||
}
|
||||
|
||||
void _scheduleNext() {
|
||||
if (_position >= _frames.length) {
|
||||
_status = PlaybackStatus.idle;
|
||||
onProgress?.call(_frames.length, _frames.length);
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate delay to next frame using original timestamps
|
||||
Duration delay = const Duration(milliseconds: 100); // default 10 Hz
|
||||
if (_position + 1 < _frames.length) {
|
||||
final gap = _frames[_position + 1].timestamp
|
||||
.difference(_frames[_position].timestamp);
|
||||
delay = Duration(
|
||||
microseconds: (gap.inMicroseconds / _speed).round(),
|
||||
);
|
||||
// Clamp to 10ms–2s to handle gaps from pauses/reconnects
|
||||
delay = delay.clamp(
|
||||
const Duration(milliseconds: 10),
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
}
|
||||
|
||||
_timer = Timer(delay, () {
|
||||
if (_status != PlaybackStatus.playing) return;
|
||||
if (_position < _frames.length) {
|
||||
_controller.add(_frames[_position].data);
|
||||
onProgress?.call(_position, _frames.length);
|
||||
_position++;
|
||||
}
|
||||
_scheduleNext();
|
||||
});
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
_controller.close();
|
||||
}
|
||||
}
|
||||
|
||||
extension on Duration {
|
||||
Duration clamp(Duration min, Duration max) {
|
||||
if (this < min) return min;
|
||||
if (this > max) return max;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'datalog_db.dart';
|
||||
|
||||
enum PlaybackStatus { idle, playing, paused }
|
||||
|
||||
/// Loads stored frames from SQLite and replays them through a stream
|
||||
/// at configurable speed. Consumers watch [frameStream] exactly like
|
||||
/// the live BT stream — the parser layer is unaware of the difference.
|
||||
class DatalogPlayer {
|
||||
final DatalogDb _db;
|
||||
|
||||
List<StoredFrame> _frames = [];
|
||||
int _position = 0; // index into _frames
|
||||
double _speed = 1.0; // 1.0 = real-time, 2.0 = 2× faster
|
||||
|
||||
PlaybackStatus _status = PlaybackStatus.idle;
|
||||
PlaybackStatus get status => _status;
|
||||
int get position => _position;
|
||||
int get totalFrames => _frames.length;
|
||||
double get speed => _speed;
|
||||
set speed(double v) => _speed = v.clamp(0.25, 8.0);
|
||||
|
||||
final StreamController<Uint8List> _controller =
|
||||
StreamController<Uint8List>.broadcast();
|
||||
|
||||
Stream<Uint8List> get frameStream => _controller.stream;
|
||||
|
||||
/// Callback fired on each tick — lets UI update a progress indicator.
|
||||
void Function(int position, int total)? onProgress;
|
||||
|
||||
Timer? _timer;
|
||||
|
||||
DatalogPlayer(this._db);
|
||||
|
||||
/// Load all frames for [sessionId] into memory.
|
||||
Future<void> load(int sessionId) async {
|
||||
await stop();
|
||||
_frames = await _db.getFrames(sessionId);
|
||||
_position = 0;
|
||||
_status = PlaybackStatus.idle;
|
||||
}
|
||||
|
||||
/// Start or resume playback.
|
||||
void play() {
|
||||
if (_status == PlaybackStatus.playing) return;
|
||||
if (_frames.isEmpty) return;
|
||||
_status = PlaybackStatus.playing;
|
||||
_scheduleNext();
|
||||
}
|
||||
|
||||
void pause() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
if (_status == PlaybackStatus.playing) {
|
||||
_status = PlaybackStatus.paused;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
_position = 0;
|
||||
_status = PlaybackStatus.idle;
|
||||
}
|
||||
|
||||
/// Jump to a specific frame index.
|
||||
void seek(int index) {
|
||||
_position = index.clamp(0, _frames.length - 1);
|
||||
onProgress?.call(_position, _frames.length);
|
||||
}
|
||||
|
||||
void _scheduleNext() {
|
||||
if (_position >= _frames.length) {
|
||||
_status = PlaybackStatus.idle;
|
||||
onProgress?.call(_frames.length, _frames.length);
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate delay to next frame using original timestamps
|
||||
Duration delay = const Duration(milliseconds: 100); // default 10 Hz
|
||||
if (_position + 1 < _frames.length) {
|
||||
final gap = _frames[_position + 1].timestamp
|
||||
.difference(_frames[_position].timestamp);
|
||||
delay = Duration(
|
||||
microseconds: (gap.inMicroseconds / _speed).round(),
|
||||
);
|
||||
// Clamp to 10ms–2s to handle gaps from pauses/reconnects
|
||||
delay = delay.clamp(
|
||||
const Duration(milliseconds: 10),
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
}
|
||||
|
||||
_timer = Timer(delay, () {
|
||||
if (_status != PlaybackStatus.playing) return;
|
||||
if (_position < _frames.length) {
|
||||
_controller.add(_frames[_position].data);
|
||||
onProgress?.call(_position, _frames.length);
|
||||
_position++;
|
||||
}
|
||||
_scheduleNext();
|
||||
});
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
_controller.close();
|
||||
}
|
||||
}
|
||||
|
||||
extension on Duration {
|
||||
Duration clamp(Duration min, Duration max) {
|
||||
if (this < min) return min;
|
||||
if (this > max) return max;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,94 +1,94 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'datalog_db.dart';
|
||||
import '../models/datalog_session.dart';
|
||||
|
||||
/// Listens to a raw frame stream and batches writes to SQLite every 500ms.
|
||||
class DatalogRecorder {
|
||||
final DatalogDb _db;
|
||||
|
||||
int? _sessionId;
|
||||
String? _ecuType;
|
||||
DateTime? _startTime;
|
||||
int _frameCount = 0;
|
||||
|
||||
final List<PendingFrame> _batch = [];
|
||||
Timer? _flushTimer;
|
||||
StreamSubscription<Uint8List>? _frameSub;
|
||||
|
||||
bool get isRecording => _sessionId != null;
|
||||
int get frameCount => _frameCount;
|
||||
int? get sessionId => _sessionId;
|
||||
|
||||
DatalogRecorder(this._db);
|
||||
|
||||
/// Start a new recording session, listening to [frameStream].
|
||||
Future<void> start(Stream<Uint8List> frameStream, String ecuType) async {
|
||||
if (isRecording) await stop();
|
||||
|
||||
_ecuType = ecuType;
|
||||
_startTime = DateTime.now();
|
||||
_frameCount = 0;
|
||||
_batch.clear();
|
||||
|
||||
final session = DatalogSession(
|
||||
ecuType: ecuType,
|
||||
startTime: _startTime!,
|
||||
);
|
||||
_sessionId = await _db.insertSession(session);
|
||||
|
||||
// Collect frames into batch buffer
|
||||
_frameSub = frameStream.listen((Uint8List frame) {
|
||||
_batch.add(PendingFrame(DateTime.now(), frame));
|
||||
_frameCount++;
|
||||
});
|
||||
|
||||
// Flush to DB every 500ms
|
||||
_flushTimer = Timer.periodic(const Duration(milliseconds: 500), (_) {
|
||||
_flush();
|
||||
});
|
||||
}
|
||||
|
||||
/// Stop recording and finalise the session row.
|
||||
Future<DatalogSession?> stop() async {
|
||||
if (!isRecording) return null;
|
||||
|
||||
_flushTimer?.cancel();
|
||||
_flushTimer = null;
|
||||
await _frameSub?.cancel();
|
||||
_frameSub = null;
|
||||
|
||||
await _flush(); // write any remaining buffered frames
|
||||
|
||||
final endTime = DateTime.now();
|
||||
await _db.closeSession(_sessionId!, endTime, _frameCount);
|
||||
|
||||
final finished = DatalogSession(
|
||||
id: _sessionId,
|
||||
ecuType: _ecuType!,
|
||||
startTime: _startTime!,
|
||||
endTime: endTime,
|
||||
frameCount: _frameCount,
|
||||
);
|
||||
|
||||
_sessionId = null;
|
||||
_ecuType = null;
|
||||
_startTime = null;
|
||||
_frameCount = 0;
|
||||
|
||||
return finished;
|
||||
}
|
||||
|
||||
Future<void> _flush() async {
|
||||
if (_batch.isEmpty || _sessionId == null) return;
|
||||
final toWrite = List<PendingFrame>.from(_batch);
|
||||
_batch.clear();
|
||||
await _db.insertFrames(_sessionId!, toWrite);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_flushTimer?.cancel();
|
||||
_frameSub?.cancel();
|
||||
}
|
||||
}
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'datalog_db.dart';
|
||||
import '../models/datalog_session.dart';
|
||||
|
||||
/// Listens to a raw frame stream and batches writes to SQLite every 500ms.
|
||||
class DatalogRecorder {
|
||||
final DatalogDb _db;
|
||||
|
||||
int? _sessionId;
|
||||
String? _ecuType;
|
||||
DateTime? _startTime;
|
||||
int _frameCount = 0;
|
||||
|
||||
final List<PendingFrame> _batch = [];
|
||||
Timer? _flushTimer;
|
||||
StreamSubscription<Uint8List>? _frameSub;
|
||||
|
||||
bool get isRecording => _sessionId != null;
|
||||
int get frameCount => _frameCount;
|
||||
int? get sessionId => _sessionId;
|
||||
|
||||
DatalogRecorder(this._db);
|
||||
|
||||
/// Start a new recording session, listening to [frameStream].
|
||||
Future<void> start(Stream<Uint8List> frameStream, String ecuType) async {
|
||||
if (isRecording) await stop();
|
||||
|
||||
_ecuType = ecuType;
|
||||
_startTime = DateTime.now();
|
||||
_frameCount = 0;
|
||||
_batch.clear();
|
||||
|
||||
final session = DatalogSession(
|
||||
ecuType: ecuType,
|
||||
startTime: _startTime!,
|
||||
);
|
||||
_sessionId = await _db.insertSession(session);
|
||||
|
||||
// Collect frames into batch buffer
|
||||
_frameSub = frameStream.listen((Uint8List frame) {
|
||||
_batch.add(PendingFrame(DateTime.now(), frame));
|
||||
_frameCount++;
|
||||
});
|
||||
|
||||
// Flush to DB every 500ms
|
||||
_flushTimer = Timer.periodic(const Duration(milliseconds: 500), (_) {
|
||||
_flush();
|
||||
});
|
||||
}
|
||||
|
||||
/// Stop recording and finalise the session row.
|
||||
Future<DatalogSession?> stop() async {
|
||||
if (!isRecording) return null;
|
||||
|
||||
_flushTimer?.cancel();
|
||||
_flushTimer = null;
|
||||
await _frameSub?.cancel();
|
||||
_frameSub = null;
|
||||
|
||||
await _flush(); // write any remaining buffered frames
|
||||
|
||||
final endTime = DateTime.now();
|
||||
await _db.closeSession(_sessionId!, endTime, _frameCount);
|
||||
|
||||
final finished = DatalogSession(
|
||||
id: _sessionId,
|
||||
ecuType: _ecuType!,
|
||||
startTime: _startTime!,
|
||||
endTime: endTime,
|
||||
frameCount: _frameCount,
|
||||
);
|
||||
|
||||
_sessionId = null;
|
||||
_ecuType = null;
|
||||
_startTime = null;
|
||||
_frameCount = 0;
|
||||
|
||||
return finished;
|
||||
}
|
||||
|
||||
Future<void> _flush() async {
|
||||
if (_batch.isEmpty || _sessionId == null) return;
|
||||
final toWrite = List<PendingFrame>.from(_batch);
|
||||
_batch.clear();
|
||||
await _db.insertFrames(_sessionId!, toWrite);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_flushTimer?.cancel();
|
||||
_frameSub?.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,42 +0,0 @@
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Small in-memory log the client can copy from the Debug screen and send
|
||||
/// back with a bug report. Keeps the newest [_capacity] lines only.
|
||||
class AppLog {
|
||||
AppLog._();
|
||||
|
||||
static const int _capacity = 400;
|
||||
static final ListQueue<String> _lines = ListQueue<String>();
|
||||
|
||||
static void i(String msg) => _add('I', msg);
|
||||
static void w(String msg) => _add('W', msg);
|
||||
static void e(String msg, [Object? error, StackTrace? stack]) {
|
||||
final b = StringBuffer(msg);
|
||||
if (error != null) b.write(' | $error');
|
||||
if (stack != null) {
|
||||
// First few frames are enough to locate a crash.
|
||||
final frames = stack.toString().split('\n').take(8).join('\n ');
|
||||
b.write('\n $frames');
|
||||
}
|
||||
_add('E', b.toString());
|
||||
}
|
||||
|
||||
static void _add(String level, String msg) {
|
||||
final t = DateTime.now();
|
||||
String two(int v) => v.toString().padLeft(2, '0');
|
||||
final ts = '${two(t.hour)}:${two(t.minute)}:${two(t.second)}.'
|
||||
'${t.millisecond.toString().padLeft(3, '0')}';
|
||||
final line = '$ts $level $msg';
|
||||
_lines.addLast(line);
|
||||
while (_lines.length > _capacity) {
|
||||
_lines.removeFirst();
|
||||
}
|
||||
debugPrint(line);
|
||||
}
|
||||
|
||||
static String dump() => _lines.join('\n');
|
||||
|
||||
static int get length => _lines.length;
|
||||
}
|
||||
@ -1,62 +1,62 @@
|
||||
/// Metadata for a single recorded ECU datalog session.
|
||||
class DatalogSession {
|
||||
final int? id;
|
||||
final String ecuType;
|
||||
final DateTime startTime;
|
||||
final DateTime? endTime;
|
||||
final int frameCount;
|
||||
|
||||
const DatalogSession({
|
||||
this.id,
|
||||
required this.ecuType,
|
||||
required this.startTime,
|
||||
this.endTime,
|
||||
this.frameCount = 0,
|
||||
});
|
||||
|
||||
Duration get duration {
|
||||
final end = endTime ?? DateTime.now();
|
||||
return end.difference(startTime);
|
||||
}
|
||||
|
||||
String get durationLabel {
|
||||
final d = duration;
|
||||
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||||
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||
return '$m:$s';
|
||||
}
|
||||
|
||||
DatalogSession copyWith({
|
||||
int? id,
|
||||
String? ecuType,
|
||||
DateTime? startTime,
|
||||
DateTime? endTime,
|
||||
int? frameCount,
|
||||
}) =>
|
||||
DatalogSession(
|
||||
id: id ?? this.id,
|
||||
ecuType: ecuType ?? this.ecuType,
|
||||
startTime: startTime ?? this.startTime,
|
||||
endTime: endTime ?? this.endTime,
|
||||
frameCount: frameCount ?? this.frameCount,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
'id': id,
|
||||
'ecu_type': ecuType,
|
||||
'start_time': startTime.millisecondsSinceEpoch,
|
||||
'end_time': endTime?.millisecondsSinceEpoch,
|
||||
'frame_count': frameCount,
|
||||
};
|
||||
|
||||
factory DatalogSession.fromMap(Map<String, dynamic> m) => DatalogSession(
|
||||
id: m['id'] as int?,
|
||||
ecuType: m['ecu_type'] as String,
|
||||
startTime:
|
||||
DateTime.fromMillisecondsSinceEpoch(m['start_time'] as int),
|
||||
endTime: m['end_time'] == null
|
||||
? null
|
||||
: DateTime.fromMillisecondsSinceEpoch(m['end_time'] as int),
|
||||
frameCount: m['frame_count'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
/// Metadata for a single recorded ECU datalog session.
|
||||
class DatalogSession {
|
||||
final int? id;
|
||||
final String ecuType;
|
||||
final DateTime startTime;
|
||||
final DateTime? endTime;
|
||||
final int frameCount;
|
||||
|
||||
const DatalogSession({
|
||||
this.id,
|
||||
required this.ecuType,
|
||||
required this.startTime,
|
||||
this.endTime,
|
||||
this.frameCount = 0,
|
||||
});
|
||||
|
||||
Duration get duration {
|
||||
final end = endTime ?? DateTime.now();
|
||||
return end.difference(startTime);
|
||||
}
|
||||
|
||||
String get durationLabel {
|
||||
final d = duration;
|
||||
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||||
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||
return '$m:$s';
|
||||
}
|
||||
|
||||
DatalogSession copyWith({
|
||||
int? id,
|
||||
String? ecuType,
|
||||
DateTime? startTime,
|
||||
DateTime? endTime,
|
||||
int? frameCount,
|
||||
}) =>
|
||||
DatalogSession(
|
||||
id: id ?? this.id,
|
||||
ecuType: ecuType ?? this.ecuType,
|
||||
startTime: startTime ?? this.startTime,
|
||||
endTime: endTime ?? this.endTime,
|
||||
frameCount: frameCount ?? this.frameCount,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
'id': id,
|
||||
'ecu_type': ecuType,
|
||||
'start_time': startTime.millisecondsSinceEpoch,
|
||||
'end_time': endTime?.millisecondsSinceEpoch,
|
||||
'frame_count': frameCount,
|
||||
};
|
||||
|
||||
factory DatalogSession.fromMap(Map<String, dynamic> m) => DatalogSession(
|
||||
id: m['id'] as int?,
|
||||
ecuType: m['ecu_type'] as String,
|
||||
startTime:
|
||||
DateTime.fromMillisecondsSinceEpoch(m['start_time'] as int),
|
||||
endTime: m['end_time'] == null
|
||||
? null
|
||||
: DateTime.fromMillisecondsSinceEpoch(m['end_time'] as int),
|
||||
frameCount: m['frame_count'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,14 +1,7 @@
|
||||
import '../protocol/sensor_state.dart';
|
||||
|
||||
/// Definition of a boolean flag sensor shown as indicator pills.
|
||||
class FlagDef {
|
||||
final String id;
|
||||
final String displayName;
|
||||
final bool Function(SensorState s) read;
|
||||
|
||||
const FlagDef({
|
||||
required this.id,
|
||||
required this.displayName,
|
||||
required this.read,
|
||||
});
|
||||
}
|
||||
/// Definition of a boolean flag sensor shown as indicator pills.
|
||||
class FlagDef {
|
||||
final String id;
|
||||
final String displayName;
|
||||
|
||||
const FlagDef({required this.id, required this.displayName});
|
||||
}
|
||||
|
||||
@ -1,19 +1,16 @@
|
||||
/// Definition of a single sensor — used for bar gauges, pickers, and graph panels.
|
||||
class SensorDef {
|
||||
final String id;
|
||||
final String displayName;
|
||||
final String unit;
|
||||
final double min;
|
||||
final double max;
|
||||
/// Decimal places shown for this sensor in its base unit.
|
||||
final int decimals;
|
||||
|
||||
const SensorDef({
|
||||
required this.id,
|
||||
required this.displayName,
|
||||
required this.unit,
|
||||
required this.min,
|
||||
required this.max,
|
||||
this.decimals = 1,
|
||||
});
|
||||
}
|
||||
/// Definition of a single sensor — used for bar gauges, pickers, and graph panels.
|
||||
class SensorDef {
|
||||
final String id;
|
||||
final String displayName;
|
||||
final String unit;
|
||||
final double min;
|
||||
final double max;
|
||||
|
||||
const SensorDef({
|
||||
required this.id,
|
||||
required this.displayName,
|
||||
required this.unit,
|
||||
required this.min,
|
||||
required this.max,
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,136 +1,67 @@
|
||||
import '../protocol/sensor_state.dart';
|
||||
import 'sensor_def.dart';
|
||||
import 'flag_def.dart';
|
||||
|
||||
/// Master list of all analog sensor definitions. Units, ranges and decimals
|
||||
/// are the base (metric) ones; see units.dart for the displayed unit.
|
||||
const List<SensorDef> sensorDefs = [
|
||||
SensorDef(
|
||||
id: 'rpm', displayName: 'RPM', unit: 'rpm', min: 0, max: 9000, decimals: 0),
|
||||
SensorDef(
|
||||
id: 'vss', displayName: 'Speed', unit: 'km/h', min: 0, max: 280, decimals: 0),
|
||||
SensorDef(
|
||||
id: 'map', displayName: 'Manifold pressure', unit: 'kPa', min: 0, max: 300),
|
||||
SensorDef(
|
||||
id: 'tps', displayName: 'Throttle position', unit: '%', min: 0, max: 100),
|
||||
SensorDef(
|
||||
id: 'inj',
|
||||
displayName: 'Injector duration',
|
||||
unit: 'ms',
|
||||
min: 0,
|
||||
max: 20,
|
||||
decimals: 2),
|
||||
SensorDef(
|
||||
id: 'ign', displayName: 'Timing advance', unit: '°', min: -20, max: 60),
|
||||
SensorDef(
|
||||
id: 'ect', displayName: 'Coolant temp', unit: '°C', min: -40, max: 150),
|
||||
SensorDef(
|
||||
id: 'iat',
|
||||
displayName: 'Intake air temp',
|
||||
unit: '°C',
|
||||
min: -40,
|
||||
max: 150),
|
||||
SensorDef(
|
||||
id: 'bat', displayName: 'Battery voltage', unit: 'V', min: 0, max: 20),
|
||||
SensorDef(
|
||||
id: 'o2', displayName: 'O2 sensor', unit: 'V', min: 0, max: 5, decimals: 2),
|
||||
// Derived: injector duration × RPM (USB tool: 16.67 ms @ 3210 rpm = 44 %).
|
||||
SensorDef(
|
||||
id: 'duty', displayName: 'Injector duty', unit: '%', min: 0, max: 100, decimals: 0),
|
||||
// AFR/AIN scales not yet bench-verified: shown as raw values.
|
||||
SensorDef(
|
||||
id: 'gear', displayName: 'Gear', unit: '', min: 0, max: 6, decimals: 0),
|
||||
SensorDef(
|
||||
id: 'eth', displayName: 'Ethanol', unit: '%', min: 0, max: 100, decimals: 0),
|
||||
SensorDef(
|
||||
id: 'pa', displayName: 'Barometric pressure', unit: 'kPa', min: 0, max: 120),
|
||||
SensorDef(
|
||||
id: 'afr', displayName: 'AFR', unit: 'raw', min: 0, max: 255, decimals: 0),
|
||||
SensorDef(
|
||||
id: 'strim', displayName: 'Short trim', unit: '%', min: -30, max: 30),
|
||||
SensorDef(
|
||||
id: 'ltrim', displayName: 'Long trim', unit: '%', min: -30, max: 30),
|
||||
SensorDef(
|
||||
id: 'ain0', displayName: 'Analog input 0', unit: 'raw', min: 0, max: 1023, decimals: 0),
|
||||
SensorDef(
|
||||
id: 'ain1', displayName: 'Analog input 1', unit: 'raw', min: 0, max: 1023, decimals: 0),
|
||||
SensorDef(
|
||||
id: 'ain2', displayName: 'Analog input 2', unit: 'raw', min: 0, max: 1023, decimals: 0),
|
||||
SensorDef(
|
||||
id: 'ain3', displayName: 'Analog input 3', unit: 'raw', min: 0, max: 1023, decimals: 0),
|
||||
SensorDef(
|
||||
id: 'ain4', displayName: 'Analog input 4', unit: 'raw', min: 0, max: 1023, decimals: 0),
|
||||
SensorDef(
|
||||
id: 'ain5', displayName: 'Analog input 5', unit: 'raw', min: 0, max: 1023, decimals: 0),
|
||||
SensorDef(
|
||||
id: 'ain6', displayName: 'Analog input 6', unit: 'raw', min: 0, max: 1023, decimals: 0),
|
||||
SensorDef(
|
||||
id: 'ain7', displayName: 'Analog input 7', unit: 'raw', min: 0, max: 1023, decimals: 0),
|
||||
];
|
||||
|
||||
SensorDef sensorDef(String id) =>
|
||||
sensorDefs.firstWhere((d) => d.id == id, orElse: () => sensorDefs.first);
|
||||
|
||||
/// Base-unit value of sensor [id] in [s].
|
||||
double sensorValue(SensorState s, String id) {
|
||||
switch (id) {
|
||||
case 'rpm': return s.rpm;
|
||||
case 'vss': return s.vss;
|
||||
case 'map': return s.map;
|
||||
case 'tps': return s.tps;
|
||||
case 'inj': return s.inj;
|
||||
case 'ign': return s.ign;
|
||||
case 'ect': return s.ect;
|
||||
case 'iat': return s.iat;
|
||||
case 'bat': return s.bat;
|
||||
case 'o2': return s.o2;
|
||||
// One injection per 2 revolutions: ms × rpm / 120000 × 100.
|
||||
case 'duty': return s.inj * s.rpm / 1200;
|
||||
case 'gear': return s.gear.toDouble();
|
||||
case 'eth': return s.eth;
|
||||
case 'pa': return s.pa;
|
||||
case 'afr': return s.afr;
|
||||
case 'strim': return s.strim;
|
||||
case 'ltrim': return s.ltrim;
|
||||
case 'ain0': return s.ain0;
|
||||
case 'ain1': return s.ain1;
|
||||
case 'ain2': return s.ain2;
|
||||
case 'ain3': return s.ain3;
|
||||
case 'ain4': return s.ain4;
|
||||
case 'ain5': return s.ain5;
|
||||
case 'ain6': return s.ain6;
|
||||
case 'ain7': return s.ain7;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Master list of boolean flags (selectable on the dashboard). The first
|
||||
/// eight follow the requirement document's examples; the rest are the other
|
||||
/// status bits the ECU frame carries.
|
||||
final List<FlagDef> flagDefs = [
|
||||
FlagDef(id: 'mil', displayName: 'MIL', read: (s) => s.mil),
|
||||
FlagDef(id: 'fanout', displayName: 'FAN Out', read: (s) => s.fanOut),
|
||||
FlagDef(id: 'vtec', displayName: 'VTEC Out', read: (s) => s.vtec),
|
||||
FlagDef(id: 'knock', displayName: 'Knock', read: (s) => s.knock),
|
||||
FlagDef(id: 'o2error', displayName: 'WBO Error', read: (s) => s.o2Error),
|
||||
FlagDef(id: 'iaterror', displayName: 'IAT Error', read: (s) => s.iatError),
|
||||
FlagDef(id: 'ecterror', displayName: 'CLT Error', read: (s) => s.ectError),
|
||||
FlagDef(id: 'fuelpump', displayName: 'Fuel Pump', read: (s) => s.fuelPump),
|
||||
FlagDef(id: 'revlimit', displayName: 'Rev Limit', read: (s) => s.revLimit),
|
||||
FlagDef(id: 'igncut', displayName: 'Ign Cut', read: (s) => s.ignCut),
|
||||
FlagDef(id: 'boostcut', displayName: 'Boost Cut', read: (s) => s.boostCut),
|
||||
FlagDef(id: 'launch', displayName: 'Launch Cut', read: (s) => s.launch),
|
||||
FlagDef(
|
||||
id: 'launchretard',
|
||||
displayName: 'Launch Retard',
|
||||
read: (s) => s.launchRetard),
|
||||
FlagDef(id: 'shiftcut', displayName: 'Shift Cut', read: (s) => s.shiftCut),
|
||||
FlagDef(
|
||||
id: 'closedloop', displayName: 'Closed Loop', read: (s) => s.closedLoop),
|
||||
FlagDef(id: 'vtp', displayName: 'VTEC Press', read: (s) => s.vtp),
|
||||
FlagDef(id: 'acswitch', displayName: 'AC Switch', read: (s) => s.acSwitch),
|
||||
FlagDef(id: 'brake', displayName: 'Brake', read: (s) => s.brakeSwitch),
|
||||
];
|
||||
|
||||
FlagDef flagDef(String id) =>
|
||||
flagDefs.firstWhere((f) => f.id == id, orElse: () => flagDefs.first);
|
||||
import 'sensor_def.dart';
|
||||
import 'flag_def.dart';
|
||||
|
||||
/// Master list of all analog sensor definitions.
|
||||
const List<SensorDef> sensorDefs = [
|
||||
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: 'map',
|
||||
displayName: 'Manifold pressure',
|
||||
unit: 'kPa',
|
||||
min: 0,
|
||||
max: 300),
|
||||
SensorDef(
|
||||
id: 'tps', displayName: 'Throttle pedal', unit: '%', min: 0, max: 100),
|
||||
SensorDef(
|
||||
id: 'inj', displayName: 'Injector duration', unit: 'ms', min: 0, max: 20),
|
||||
SensorDef(
|
||||
id: 'ign', displayName: 'Timing advance', unit: 'deg', min: -20, max: 60),
|
||||
SensorDef(
|
||||
id: 'ect', displayName: 'Coolant temp', unit: '°C', min: -40, max: 150),
|
||||
SensorDef(
|
||||
id: 'iat',
|
||||
displayName: 'Intake air temp',
|
||||
unit: '°C',
|
||||
min: -40,
|
||||
max: 150),
|
||||
SensorDef(
|
||||
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: 'gear', displayName: 'Gear', unit: '—', min: 1, max: 6),
|
||||
SensorDef(id: 'eth', displayName: 'Ethanol', unit: '%', min: 0, max: 100),
|
||||
SensorDef(
|
||||
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: 'strim', displayName: 'Short trim', unit: '%', min: -30, max: 30),
|
||||
SensorDef(
|
||||
id: 'ltrim', displayName: 'Long trim', unit: '%', min: -30, max: 30),
|
||||
SensorDef(
|
||||
id: 'ain0', displayName: 'Analog input 0', unit: 'V', min: 0, max: 5),
|
||||
SensorDef(
|
||||
id: 'ain1', displayName: 'Analog input 1', unit: 'V', min: 0, max: 5),
|
||||
SensorDef(
|
||||
id: 'ain2', displayName: 'Analog input 2', unit: 'V', min: 0, max: 5),
|
||||
SensorDef(
|
||||
id: 'ain3', displayName: 'Analog input 3', unit: 'V', min: 0, max: 5),
|
||||
SensorDef(
|
||||
id: 'ain4', displayName: 'Analog input 4', unit: 'V', min: 0, max: 5),
|
||||
SensorDef(
|
||||
id: 'ain5', displayName: 'Analog input 5', unit: 'V', min: 0, max: 5),
|
||||
SensorDef(
|
||||
id: 'ain6', displayName: 'Analog input 6', unit: 'V', min: 0, max: 5),
|
||||
SensorDef(
|
||||
id: 'ain7', displayName: 'Analog input 7', unit: 'V', min: 0, max: 5),
|
||||
];
|
||||
|
||||
/// Master list of boolean flag sensors.
|
||||
const List<FlagDef> flagDefs = [
|
||||
FlagDef(id: 'mil', displayName: 'MIL'),
|
||||
FlagDef(id: 'fuelpump', displayName: 'Fuel Pump'),
|
||||
FlagDef(id: 'fanout', displayName: 'FAN Out'),
|
||||
FlagDef(id: 'vtec', displayName: 'VTEC'),
|
||||
FlagDef(id: 'knock', displayName: 'Knock'),
|
||||
FlagDef(id: 'revlimit', displayName: 'Rev Limit'),
|
||||
FlagDef(id: 'launch', displayName: 'Launch'),
|
||||
];
|
||||
|
||||
@ -1,101 +0,0 @@
|
||||
import 'sensor_def.dart';
|
||||
import 'sensor_defs.dart';
|
||||
|
||||
enum SpeedUnit { kmh, mph }
|
||||
|
||||
enum TempUnit { celsius, fahrenheit }
|
||||
|
||||
enum PressureUnit { kpa, bar, psi }
|
||||
|
||||
/// User's display units. Values are always stored and logged in the base
|
||||
/// (metric) units of [sensorDefs]; conversion happens only for display.
|
||||
class UnitPrefs {
|
||||
final SpeedUnit speed;
|
||||
final TempUnit temp;
|
||||
final PressureUnit pressure;
|
||||
|
||||
const UnitPrefs({
|
||||
this.speed = SpeedUnit.kmh,
|
||||
this.temp = TempUnit.celsius,
|
||||
this.pressure = PressureUnit.kpa,
|
||||
});
|
||||
|
||||
UnitPrefs copyWith({
|
||||
SpeedUnit? speed,
|
||||
TempUnit? temp,
|
||||
PressureUnit? pressure,
|
||||
}) =>
|
||||
UnitPrefs(
|
||||
speed: speed ?? this.speed,
|
||||
temp: temp ?? this.temp,
|
||||
pressure: pressure ?? this.pressure,
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is UnitPrefs &&
|
||||
other.speed == speed &&
|
||||
other.temp == temp &&
|
||||
other.pressure == pressure;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(speed, temp, pressure);
|
||||
}
|
||||
|
||||
/// How one sensor is displayed under the current [UnitPrefs]: unit label,
|
||||
/// converted range, and value conversion/formatting.
|
||||
class SensorView {
|
||||
final SensorDef def;
|
||||
final String unit;
|
||||
final double min;
|
||||
final double max;
|
||||
final int decimals;
|
||||
final double Function(double base) _convert;
|
||||
|
||||
const SensorView._(
|
||||
this.def, this.unit, this.min, this.max, this.decimals, this._convert);
|
||||
|
||||
String get name => def.displayName;
|
||||
|
||||
/// Converts a base-unit value to the display unit.
|
||||
double convert(double base) => _convert(base);
|
||||
|
||||
/// Converted and formatted, without unit.
|
||||
String format(double base) => convert(base).toStringAsFixed(decimals);
|
||||
|
||||
/// 0..1 position of [base] within the sensor's range.
|
||||
double fraction(double base) =>
|
||||
((convert(base) - min) / (max - min)).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
double _id(double v) => v;
|
||||
|
||||
SensorView sensorView(String id, UnitPrefs u) {
|
||||
final d = sensorDef(id);
|
||||
SensorView view(String unit, double Function(double) f, {int? decimals}) =>
|
||||
SensorView._(d, unit, f(d.min), f(d.max), decimals ?? d.decimals, f);
|
||||
|
||||
switch (id) {
|
||||
case 'vss':
|
||||
return u.speed == SpeedUnit.mph
|
||||
? view('mph', (v) => v * 0.621371)
|
||||
: view(d.unit, _id);
|
||||
case 'ect':
|
||||
case 'iat':
|
||||
return u.temp == TempUnit.fahrenheit
|
||||
? view('°F', (v) => v * 9 / 5 + 32)
|
||||
: view(d.unit, _id);
|
||||
case 'map':
|
||||
case 'pa':
|
||||
switch (u.pressure) {
|
||||
case PressureUnit.bar:
|
||||
return view('bar', (v) => v / 100, decimals: 2);
|
||||
case PressureUnit.psi:
|
||||
return view('psi', (v) => v * 0.145038);
|
||||
case PressureUnit.kpa:
|
||||
return view(d.unit, _id);
|
||||
}
|
||||
default:
|
||||
return view(d.unit, _id);
|
||||
}
|
||||
}
|
||||
@ -1,281 +1,249 @@
|
||||
/// DTC maps for S300 and KPro ECU protocols, transcribed from the "DTC map"
|
||||
/// sections of hvbt_protocol.pdf.
|
||||
///
|
||||
/// Key format: 'ERRxx_bity' where xx = ERR byte index (00-based), y = bit
|
||||
/// number (0=LSB). Value: '<code> — description'. Bits the PDF leaves
|
||||
/// undocumented ("???") use 'ERRxx.y' as the code so the raw bit is still
|
||||
/// visible on screen.
|
||||
library;
|
||||
|
||||
/// S300 DTC map (ERR00–ERR03 = frame bytes 37–40). Descriptions include the
|
||||
/// Honda blink code and sensor abbreviation from the PDF.
|
||||
const Map<String, String> s300DtcMap = {
|
||||
// ERR00
|
||||
'ERR00_bit0': 'P0130 — Oxygen Sensor #1 (O2A, code 1)',
|
||||
'ERR00_bit1': 'P0150 — Oxygen Sensor #2 (O2B, code 2)',
|
||||
'ERR00_bit2': 'P0105 — Manifold Absolute Pressure Sensor (MAP, code 3)',
|
||||
'ERR00_bit3': 'P0335 — Crank Position Sensor (CKP, code 4)',
|
||||
'ERR00_bit4': 'P0105 — Manifold Absolute Pressure Sensor (MAP, code 5)',
|
||||
'ERR00_bit5': 'P0106 — Engine Coolant Sensor (ECT, code 6)',
|
||||
'ERR00_bit6': 'P0123 — Throttle Position Sensor (TPS, code 7)',
|
||||
'ERR00_bit7': 'P1362 — Top Dead Center Sensor (TDC, code 8)',
|
||||
|
||||
// ERR01
|
||||
'ERR01_bit0': 'P0340 — Cylinder/Cam Sensor (CYL, code 9)',
|
||||
'ERR01_bit1': 'P0113 — Intake Air Temperature Sensor (IAT, code 10)',
|
||||
'ERR01_bit2': 'P0217 — Engine Overheating (OH, code 11)',
|
||||
'ERR01_bit3': 'P0403 — EGR Lift Valve (EGR, code 12)',
|
||||
'ERR01_bit4': 'P1106 — Atmospheric Pressure Sensor (BARO, code 13)',
|
||||
'ERR01_bit5': 'P0505 — Idle Air Control Valve (IAC, code 14)',
|
||||
'ERR01_bit6': 'P0350 — Ignition Control Module (IGM, code 15)',
|
||||
'ERR01_bit7': 'P0200 — Fuel Injectors (INJ, code 16)',
|
||||
|
||||
// ERR02
|
||||
'ERR02_bit0': 'P0500 — Vehicle Speed Sensor (VSS, code 17)',
|
||||
'ERR02_bit1': 'ERR02.1 — Undocumented code',
|
||||
'ERR02_bit2': 'P0700 — Automatic Transmission Lockup Control Valve (code 19)',
|
||||
'ERR02_bit3': 'P1298 — Electrical Load Detector (ELD, code 20)',
|
||||
'ERR02_bit4': 'P2649 — VTEC Solenoid (VTS, code 21)',
|
||||
'ERR02_bit5': 'P2647 — VTEC Pressure Valve (VTP, code 22)',
|
||||
'ERR02_bit6': 'P0325 — Knock Sensor (code 23)',
|
||||
'ERR02_bit7': 'P1324 — Knock Sensor (code 24)',
|
||||
|
||||
// ERR03
|
||||
'ERR03_bit0': 'P0750 — Automatic Transmission A Signal (ATA, code 30)',
|
||||
'ERR03_bit1': 'P0755 — Automatic Transmission B Signal (ATB, code 31)',
|
||||
'ERR03_bit2': 'P0135 — Primary Oxygen Sensor Heater (PO2H, code 41)',
|
||||
'ERR03_bit3': 'P0135 — Oxygen Sensor Heater',
|
||||
'ERR03_bit4': 'P0169 — Fuel Supply System (code 43)',
|
||||
'ERR03_bit5': 'ERR03.5 — Undocumented code',
|
||||
'ERR03_bit6': 'ERR03.6 — Undocumented code',
|
||||
'ERR03_bit7': 'ERR03.7 — Undocumented code',
|
||||
};
|
||||
|
||||
/// KPro DTC map (ERR00–ERR17 = frame bytes 62–79).
|
||||
const Map<String, String> kproDtcMap = {
|
||||
// ERR00
|
||||
'ERR00_bit0': 'ERR00.0 — Unknown error 0',
|
||||
'ERR00_bit1': 'P1607 — Internal Circuit Malfunction (check calibration type)',
|
||||
'ERR00_bit2': 'P0131 — O2 Sensor Voltage Low',
|
||||
'ERR00_bit3': 'P0132 — O2 Sensor Voltage High',
|
||||
'ERR00_bit4': 'P0107 — MAP Sensor Low Voltage',
|
||||
'ERR00_bit5': 'P0108 — MAP Sensor High Voltage',
|
||||
'ERR00_bit6': 'P0335 — CRK Sensor No Signal',
|
||||
'ERR00_bit7': 'P0336 — CRK Sensor Intermittent Interruption',
|
||||
|
||||
// ERR01
|
||||
'ERR01_bit0': 'P1128 — MAP Sensor Signal Lower Than Expected',
|
||||
'ERR01_bit1': 'P1129 — MAP Sensor Signal Higher Than Expected',
|
||||
'ERR01_bit2': 'P0117 — ECT Sensor Low Voltage',
|
||||
'ERR01_bit3': 'P0118 — ECT Sensor High Voltage',
|
||||
'ERR01_bit4': 'P0122 — TPS Low Voltage',
|
||||
'ERR01_bit5': 'P0123 — TPS High Voltage',
|
||||
'ERR01_bit6': 'P1121 — TPS Signal Lower Than Expected',
|
||||
'ERR01_bit7': 'P1122 — TPS Signal Higher Than Expected',
|
||||
|
||||
// ERR02
|
||||
'ERR02_bit0': 'P0176 — Ethanol Sensor Input Error',
|
||||
'ERR02_bit1': 'ERR02.1 — No description available',
|
||||
'ERR02_bit2': 'ERR02.2 — No description available',
|
||||
'ERR02_bit3': 'ERR02.3 — No description available',
|
||||
'ERR02_bit4': 'P1362 — TDC Sensor No Signal',
|
||||
'ERR02_bit5': 'P1361 — TDC Sensor Intermittent Signal Interruption',
|
||||
'ERR02_bit6': 'P0112 — IAT Sensor Low Voltage',
|
||||
'ERR02_bit7': 'P0113 — IAT Sensor High Voltage',
|
||||
|
||||
// ERR03
|
||||
'ERR03_bit0': 'P1213 — IMA Sensor Voltage Low',
|
||||
'ERR03_bit1': 'P1214 — IMA Sensor Voltage High',
|
||||
'ERR03_bit2': 'ERR03.2 — EGR Lift Sensor Voltage Error',
|
||||
'ERR03_bit3': 'ERR03.3 — EGR F/B Error',
|
||||
'ERR03_bit4': 'P1107 — Barometric Pressure Low Voltage',
|
||||
'ERR03_bit5': 'P1108 — Barometric Pressure High Voltage',
|
||||
'ERR03_bit6': 'P1106 — Barometric Pressure Sensor Range/Performance',
|
||||
'ERR03_bit7': 'ERR03.7 — Unknown error 37',
|
||||
|
||||
// ERR04
|
||||
'ERR04_bit0': 'P0505 — Idle Control System Malfunction',
|
||||
'ERR04_bit1': 'P1519 — Idle Air Control Valve Circuit Malfunction',
|
||||
'ERR04_bit2': 'P0500 — VSS Malfunction',
|
||||
'ERR04_bit3': 'ERR04.3 — VSP',
|
||||
'ERR04_bit4': 'P1297 — ELD Circuit Low Voltage',
|
||||
'ERR04_bit5': 'P1298 — ELD Circuit High Voltage',
|
||||
'ERR04_bit6': 'P1253 — VTEC Solenoid Drive Time Abnormality',
|
||||
'ERR04_bit7': 'ERR04.7 — Unknown error 47',
|
||||
|
||||
// ERR05
|
||||
'ERR05_bit0': 'P1259 — VTEC System Malfunction (Oil Pressure Switch)',
|
||||
'ERR05_bit1': 'ERR05.1 — Unknown error 51',
|
||||
'ERR05_bit2': 'P0325 — Knock Sensor Circuit Malfunction',
|
||||
'ERR05_bit3': 'P1324 — Knock Sensor Circuit Failure',
|
||||
'ERR05_bit4': 'P0563 — VBU Voltage High',
|
||||
'ERR05_bit5': 'ERR05.5 — Unknown error 55',
|
||||
'ERR05_bit6': 'ERR05.6 — Unknown error 56',
|
||||
'ERR05_bit7': 'ERR05.7 — Unknown error 57',
|
||||
|
||||
// ERR06
|
||||
'ERR06_bit0': 'P1162 — Air Fuel Ratio Sensor Circuit Malfunction',
|
||||
'ERR06_bit1': 'ERR06.1 — LAF Sensor AFV1 Short',
|
||||
'ERR06_bit2': 'ERR06.2 — LAF Sensor AFC1 Short',
|
||||
'ERR06_bit3': 'ERR06.3 — Unknown error 63',
|
||||
'ERR06_bit4':
|
||||
'ERR06.4 — Daughter Board Communication Error (battery voltage low when cranking)',
|
||||
'ERR06_bit5': 'ERR06.5 — Unknown error 65',
|
||||
'ERR06_bit6':
|
||||
'P0600 — Multiplexer Not Present (for engine swaps disable in misc parameters)',
|
||||
'ERR06_bit7': 'ERR06.7 — Unknown error 67',
|
||||
|
||||
// ERR07
|
||||
'ERR07_bit0': 'P1505 — PCV Negative Pressure',
|
||||
'ERR07_bit1': 'ERR07.1 — Unknown error 71',
|
||||
'ERR07_bit2': 'ERR07.2 — Unknown error 72',
|
||||
'ERR07_bit3': 'ERR07.3 — Idle Valve',
|
||||
'ERR07_bit4': 'P0135 — Primary HO2S Circuit Malfunction',
|
||||
'ERR07_bit5': 'P1166 — Primary HO2S Heater Circuit Malfunction',
|
||||
'ERR07_bit6': 'P1167 — Primary LAF HO2S Heater System Malfunction',
|
||||
'ERR07_bit7': 'P0134 — Air/Fuel Ratio Sensor No Activity Detected',
|
||||
|
||||
// ERR08
|
||||
'ERR08_bit0': 'P0172 — Fuel System Too Rich',
|
||||
'ERR08_bit1': 'P0171 — Fuel System Too Lean',
|
||||
'ERR08_bit2': 'P1162 — Air/Fuel Ratio (A/F) Sensor Slow Response',
|
||||
'ERR08_bit3': 'ERR08.3 — Unknown error 83',
|
||||
'ERR08_bit4': 'P0010 — VTC Oil Control Solenoid Valve Malfunction',
|
||||
'ERR08_bit5': 'P0011 — VTC System Malfunction',
|
||||
'ERR08_bit6': 'P0340 — CMP Sensor No Signal',
|
||||
'ERR08_bit7': 'P0344 — CMP Sensor Intermittent Interruption',
|
||||
|
||||
// ERR09
|
||||
'ERR09_bit0': 'P0341 — Variable Valve Timing (VTC) Phase Gap',
|
||||
'ERR09_bit1': 'ERR09.1 — Unknown error 91',
|
||||
'ERR09_bit2': 'ERR09.2 — Undocumented code',
|
||||
'ERR09_bit3': 'P1163 — Air Fuel Ratio Sensor Slow Response',
|
||||
'ERR09_bit4': 'P1164 — Air Fuel Ratio Sensor Range/Performance Problem',
|
||||
'ERR09_bit5': 'ERR09.5 — Undocumented code',
|
||||
'ERR09_bit6': 'P0137 — Secondary O2 Circuit Low Voltage',
|
||||
'ERR09_bit7': 'P0138 — Secondary O2 Circuit High Voltage',
|
||||
|
||||
// ERR10
|
||||
'ERR10_bit0': 'P0139 — Secondary O2 Circuit Slow Response',
|
||||
'ERR10_bit1': 'ERR10.1 — Unknown error 101',
|
||||
'ERR10_bit2': 'P0141 — Secondary O2 Circuit Heater Circuit Malfunction',
|
||||
'ERR10_bit3': 'ERR10.3 — Unknown error 103',
|
||||
'ERR10_bit4': 'P0420 — Catalyst System Efficiency Below Threshold',
|
||||
'ERR10_bit5': 'ERR10.5 — Catalyst / Secondary O2 Sensor',
|
||||
'ERR10_bit6': 'ERR10.6 — Unknown error 106',
|
||||
'ERR10_bit7': 'ERR10.7 — Unknown error 107',
|
||||
|
||||
// ERR11
|
||||
'ERR11_bit0': 'P0301 — Cylinder #1 Misfire',
|
||||
'ERR11_bit1': 'ERR11.1 — Unknown error 111',
|
||||
'ERR11_bit2': 'P0302 — Cylinder #2 Misfire',
|
||||
'ERR11_bit3': 'ERR11.3 — Undocumented code',
|
||||
'ERR11_bit4': 'P0303 — Cylinder #3 Misfire',
|
||||
'ERR11_bit5': 'ERR11.5 — Unknown error 115',
|
||||
'ERR11_bit6': 'P0304 — Cylinder #4 Misfire',
|
||||
'ERR11_bit7': 'ERR11.7 — Unknown error 117',
|
||||
|
||||
// ERR12
|
||||
'ERR12_bit0': 'P0300 — Random Misfire',
|
||||
'ERR12_bit1': 'ERR12.1 — Unknown error 121',
|
||||
'ERR12_bit2': 'ERR12.2 — EGR Flow',
|
||||
'ERR12_bit3': 'ERR12.3 — Unknown error 123',
|
||||
'ERR12_bit4': 'P0116 — ECT Sensor Range/Performance Problem',
|
||||
'ERR12_bit5': 'ERR12.5 — Unknown error 125',
|
||||
'ERR12_bit6': 'P0128 — Cooling System Malfunction (Thermostat)',
|
||||
'ERR12_bit7': 'ERR12.7 — VTC Solenoid',
|
||||
|
||||
// ERR13
|
||||
'ERR13_bit0': 'P1456 — EVAP Control System Leakage (Fuel Tank)',
|
||||
'ERR13_bit1': 'P1457 — EVAP Control System Leakage (Canister System)',
|
||||
'ERR13_bit2': 'P0452 — Fuel Tank Pressure Sensor Low Voltage',
|
||||
'ERR13_bit3': 'P0453 — Fuel Tank Pressure Sensor High Voltage',
|
||||
'ERR13_bit4': 'P0451 — Fuel Tank Pressure Sensor Range/Performance',
|
||||
'ERR13_bit5': 'ERR13.5 — Undocumented code',
|
||||
'ERR13_bit6': 'P0443 — EVAP Purge Valve Circuit',
|
||||
'ERR13_bit7': 'ERR13.7 — Unknown error 137',
|
||||
|
||||
// ERR14
|
||||
'ERR14_bit0': 'ERR14.0 — PF2 Low',
|
||||
'ERR14_bit1': 'ERR14.1 — PF2 High',
|
||||
'ERR14_bit2': 'ERR14.2 — PF2 Monitor',
|
||||
'ERR14_bit3': 'ERR14.3 — Undocumented code',
|
||||
'ERR14_bit4': 'ERR14.4 — TF2 Low',
|
||||
'ERR14_bit5': 'ERR14.5 — TF2 High',
|
||||
'ERR14_bit6': 'ERR14.6 — PF0 Low',
|
||||
'ERR14_bit7': 'ERR14.7 — PF0 High',
|
||||
|
||||
// ERR15
|
||||
'ERR15_bit0': 'ERR15.0 — TF0 Low',
|
||||
'ERR15_bit1': 'ERR15.1 — TF0 High',
|
||||
'ERR15_bit2': 'ERR15.2 — 3rd O2 Sensor Voltage Low',
|
||||
'ERR15_bit3': 'ERR15.3 — 3rd O2 Sensor Voltage High',
|
||||
'ERR15_bit4': 'ERR15.4 — 3rd O2 Sensor Response',
|
||||
'ERR15_bit5': 'ERR15.5 — Unknown error 155',
|
||||
'ERR15_bit6': 'ERR15.6 — 3rd O2 Sensor Heater Current Abnormality',
|
||||
'ERR15_bit7': 'ERR15.7 — Unknown error 157',
|
||||
|
||||
// ERR16
|
||||
'ERR16_bit0': 'ERR16.0 — NOx Ability Monitor',
|
||||
'ERR16_bit1': 'ERR16.1 — Unknown error 161',
|
||||
'ERR16_bit2':
|
||||
'P1077 — Intake Manifold Runner Control System Malfunction (low rpm)',
|
||||
'ERR16_bit3':
|
||||
'P1078 — Intake Manifold Runner Control System Malfunction (high rpm)',
|
||||
'ERR16_bit4': 'P0661 — Intake Manifold Runner Control Low Voltage',
|
||||
'ERR16_bit5': 'P0662 — Intake Manifold Runner Control High Voltage',
|
||||
'ERR16_bit6': 'ERR16.6 — P1 Switch Abnormality',
|
||||
'ERR16_bit7': 'ERR16.7 — Unknown error 167',
|
||||
|
||||
// ERR17
|
||||
'ERR17_bit0': 'P0219 — Engine Over-Rev',
|
||||
'ERR17_bit1': 'P0217 — Engine Over-Heating',
|
||||
'ERR17_bit2': 'ERR17.2 — Undocumented code',
|
||||
'ERR17_bit3': 'ERR17.3 — Undocumented code',
|
||||
'ERR17_bit4': 'ERR17.4 — Undocumented code',
|
||||
'ERR17_bit5': 'ERR17.5 — Undocumented code',
|
||||
'ERR17_bit6': 'ERR17.6 — Undocumented code',
|
||||
'ERR17_bit7': 'ERR17.7 — Undocumented code',
|
||||
};
|
||||
|
||||
/// True if any of [bits] — (ERR byte index, bit number) pairs — is set.
|
||||
/// Used to derive sensor-error flags (IAT/CLT/O2) from the stored codes.
|
||||
bool anyDtcBit(List<int> errBytes, List<(int, int)> bits) {
|
||||
for (final (byte, bit) in bits) {
|
||||
if (byte < errBytes.length && (errBytes[byte] >> bit) & 1 == 1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Sensor-error flag sources, per the DTC maps above.
|
||||
const s300IatErrorBits = [(1, 1)]; // P0113
|
||||
const s300EctErrorBits = [(0, 5)]; // P0106
|
||||
const s300O2ErrorBits = [(0, 0), (0, 1), (3, 2), (3, 3)]; // O2A/B, heaters
|
||||
const kproIatErrorBits = [(2, 6), (2, 7)]; // P0112/P0113
|
||||
const kproEctErrorBits = [(1, 2), (1, 3), (12, 4)]; // P0117/P0118/P0116
|
||||
const kproO2ErrorBits = [
|
||||
(0, 2), (0, 3), // O2 voltage low/high
|
||||
(6, 0), (6, 1), (6, 2), // A/F (LAF) sensor circuit
|
||||
(7, 4), (7, 5), (7, 6), (7, 7), // HO2S / LAF heater, no activity
|
||||
(9, 3), (9, 4), // A/F slow response, range
|
||||
];
|
||||
|
||||
/// Returns a list of active DTC descriptions from the given raw error bytes
|
||||
/// using the provided map.
|
||||
List<String> decodeDtcs(List<int> errBytes, Map<String, String> dtcMap) {
|
||||
final List<String> active = [];
|
||||
for (int byteIdx = 0; byteIdx < errBytes.length; byteIdx++) {
|
||||
final int b = errBytes[byteIdx];
|
||||
if (b == 0) continue;
|
||||
for (int bit = 0; bit < 8; bit++) {
|
||||
if ((b >> bit) & 1 == 1) {
|
||||
final String key = 'ERR${byteIdx.toString().padLeft(2, '0')}_bit$bit';
|
||||
final String? desc = dtcMap[key];
|
||||
if (desc != null) active.add(desc);
|
||||
}
|
||||
}
|
||||
}
|
||||
return active;
|
||||
}
|
||||
/// DTC maps for S300 and KPro ECU protocols.
|
||||
///
|
||||
/// Key format: 'ERRxx_bity' where xx = byte index (00-based), y = bit number (0=LSB).
|
||||
/// Value: 'Pxxxx — description'
|
||||
library;
|
||||
|
||||
/// S300 DTC map (ERR00–ERR03, 4 bytes × 8 bits = 32 possible codes)
|
||||
const Map<String, String> s300DtcMap = {
|
||||
// ERR00 (byte 0x31)
|
||||
'ERR00_bit0': 'P0130 — O2 Sensor (front)',
|
||||
'ERR00_bit1': 'P0135 — O2 Sensor Heater (front)',
|
||||
'ERR00_bit2': 'P0505 — Idle Air Control Valve',
|
||||
'ERR00_bit3': 'P0108 — MAP Sensor High',
|
||||
'ERR00_bit4': 'P0107 — MAP Sensor Low',
|
||||
'ERR00_bit5': 'P0113 — IAT Sensor High',
|
||||
'ERR00_bit6': 'P0112 — IAT Sensor Low',
|
||||
'ERR00_bit7': 'P0118 — ECT Sensor High',
|
||||
|
||||
// ERR01 (byte 0x32)
|
||||
'ERR01_bit0': 'P0117 — ECT Sensor Low',
|
||||
'ERR01_bit1': 'P0336 — CKP Sensor',
|
||||
'ERR01_bit2': 'P0341 — CMP Sensor',
|
||||
'ERR01_bit3': 'P1362 — TDC Sensor (No Signal)',
|
||||
'ERR01_bit4': 'P1363 — TDC Sensor (No Signal 2)',
|
||||
'ERR01_bit5': 'P0335 — CKP Sensor (No Signal)',
|
||||
'ERR01_bit6': 'P1361 — TDC Sensor',
|
||||
'ERR01_bit7': 'P0300 — Random Misfire',
|
||||
|
||||
// ERR02 (byte 0x33)
|
||||
'ERR02_bit0': 'P0301 — Misfire Cylinder 1',
|
||||
'ERR02_bit1': 'P0302 — Misfire Cylinder 2',
|
||||
'ERR02_bit2': 'P0303 — Misfire Cylinder 3',
|
||||
'ERR02_bit3': 'P0304 — Misfire Cylinder 4',
|
||||
'ERR02_bit4': 'P1259 — VTEC System Malfunction',
|
||||
'ERR02_bit5': 'P0420 — Catalyst System Low Efficiency',
|
||||
'ERR02_bit6': 'P0401 — EGR Insufficient Flow',
|
||||
'ERR02_bit7': 'P1456 — EVAP Leak (large) Fuel Tank Side',
|
||||
|
||||
// ERR03 (byte 0x34)
|
||||
'ERR03_bit0': 'P1457 — EVAP Leak (large) Canister Side',
|
||||
'ERR03_bit1': 'P0498 — EVAP Canister Vent Shut Valve',
|
||||
'ERR03_bit2': 'P0497 — EVAP Low Purge Flow',
|
||||
'ERR03_bit3': 'P0122 — TPS Sensor Low',
|
||||
'ERR03_bit4': 'P0123 — TPS Sensor High',
|
||||
'ERR03_bit5': 'P0562 — Battery Voltage Low',
|
||||
'ERR03_bit6': 'P0563 — Battery Voltage High',
|
||||
'ERR03_bit7': 'P1607 — PCM Internal Circuit',
|
||||
};
|
||||
|
||||
/// KPro DTC map (ERR00–ERR17, 18 bytes × 8 bits = 144 possible codes)
|
||||
const Map<String, String> kproDtcMap = {
|
||||
// ERR00
|
||||
'ERR00_bit0': 'P0130 — O2 Sensor (front)',
|
||||
'ERR00_bit1': 'P0135 — O2 Sensor Heater (front)',
|
||||
'ERR00_bit2': 'P0505 — Idle Air Control Valve',
|
||||
'ERR00_bit3': 'P0108 — MAP Sensor High',
|
||||
'ERR00_bit4': 'P0107 — MAP Sensor Low',
|
||||
'ERR00_bit5': 'P0113 — IAT Sensor High',
|
||||
'ERR00_bit6': 'P0112 — IAT Sensor Low',
|
||||
'ERR00_bit7': 'P0118 — ECT Sensor High',
|
||||
|
||||
// ERR01
|
||||
'ERR01_bit0': 'P0117 — ECT Sensor Low',
|
||||
'ERR01_bit1': 'P0336 — CKP Sensor',
|
||||
'ERR01_bit2': 'P0341 — CMP Sensor',
|
||||
'ERR01_bit3': 'P1362 — TDC Sensor (No Signal)',
|
||||
'ERR01_bit4': 'P1363 — TDC Sensor (No Signal 2)',
|
||||
'ERR01_bit5': 'P0335 — CKP Sensor (No Signal)',
|
||||
'ERR01_bit6': 'P1361 — TDC Sensor',
|
||||
'ERR01_bit7': 'P0300 — Random Misfire',
|
||||
|
||||
// ERR02
|
||||
'ERR02_bit0': 'P0301 — Misfire Cylinder 1',
|
||||
'ERR02_bit1': 'P0302 — Misfire Cylinder 2',
|
||||
'ERR02_bit2': 'P0303 — Misfire Cylinder 3',
|
||||
'ERR02_bit3': 'P0304 — Misfire Cylinder 4',
|
||||
'ERR02_bit4': 'P1259 — VTEC System Malfunction',
|
||||
'ERR02_bit5': 'P0420 — Catalyst System Low Efficiency',
|
||||
'ERR02_bit6': 'P0401 — EGR Insufficient Flow',
|
||||
'ERR02_bit7': 'P1456 — EVAP Leak (large) Fuel Tank Side',
|
||||
|
||||
// ERR03
|
||||
'ERR03_bit0': 'P1457 — EVAP Leak (large) Canister Side',
|
||||
'ERR03_bit1': 'P0498 — EVAP Canister Vent Shut Valve',
|
||||
'ERR03_bit2': 'P0497 — EVAP Low Purge Flow',
|
||||
'ERR03_bit3': 'P0122 — TPS Sensor Low',
|
||||
'ERR03_bit4': 'P0123 — TPS Sensor High',
|
||||
'ERR03_bit5': 'P0562 — Battery Voltage Low',
|
||||
'ERR03_bit6': 'P0563 — Battery Voltage High',
|
||||
'ERR03_bit7': 'P1607 — PCM Internal Circuit',
|
||||
|
||||
// ERR04
|
||||
'ERR04_bit0': 'P0136 — O2 Sensor (rear)',
|
||||
'ERR04_bit1': 'P0141 — O2 Sensor Heater (rear)',
|
||||
'ERR04_bit2': 'P1166 — Air Fuel Ratio (A/F) Sensor Heater',
|
||||
'ERR04_bit3': 'P1167 — Air Fuel Ratio (A/F) Sensor',
|
||||
'ERR04_bit4': 'P2195 — A/F Sensor Signal Stuck Lean',
|
||||
'ERR04_bit5': 'P2196 — A/F Sensor Signal Stuck Rich',
|
||||
'ERR04_bit6': 'P2237 — A/F Sensor Pump Cell Current Input Low',
|
||||
'ERR04_bit7': 'P2240 — A/F Sensor Pump Cell Current Input High',
|
||||
|
||||
// ERR05
|
||||
'ERR05_bit0': 'P2243 — A/F Sensor Reference Voltage Low',
|
||||
'ERR05_bit1': 'P2247 — A/F Sensor Reference Voltage High',
|
||||
'ERR05_bit2': 'P2251 — A/F Sensor VS Cell Voltage Low',
|
||||
'ERR05_bit3': 'P2254 — A/F Sensor VS Cell Voltage High',
|
||||
'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_bit6': 'P0037 — O2 Sensor Heater Current Low (bank 1, sensor 2)',
|
||||
'ERR05_bit7': 'P0038 — O2 Sensor Heater Current High (bank 1, sensor 2)',
|
||||
|
||||
// ERR06
|
||||
'ERR06_bit0': 'P0068 — MAP/MAF vs Throttle Position Correlation',
|
||||
'ERR06_bit1': 'P0101 — MAF Sensor Range/Performance',
|
||||
'ERR06_bit2': 'P0102 — MAF Sensor Low Input',
|
||||
'ERR06_bit3': 'P0103 — MAF Sensor High Input',
|
||||
'ERR06_bit4': 'P0171 — System Lean (bank 1)',
|
||||
'ERR06_bit5': 'P0172 — System Rich (bank 1)',
|
||||
'ERR06_bit6': 'P0174 — System Lean (bank 2)',
|
||||
'ERR06_bit7': 'P0175 — System Rich (bank 2)',
|
||||
|
||||
// ERR07
|
||||
'ERR07_bit0': 'P0325 — Knock Sensor',
|
||||
'ERR07_bit1': 'P0330 — Knock Sensor 2',
|
||||
'ERR07_bit2': 'P0339 — CKP Sensor Intermittent',
|
||||
'ERR07_bit3': 'P0344 — CMP Sensor Intermittent',
|
||||
'ERR07_bit4': 'P0365 — CMP Sensor B Circuit',
|
||||
'ERR07_bit5': 'P0390 — CMP Sensor B Circuit (bank 2)',
|
||||
'ERR07_bit6': 'P0498 — EVAP Canister Vent Shut Valve Low',
|
||||
'ERR07_bit7': 'P0499 — EVAP Canister Vent Shut Valve High',
|
||||
|
||||
// ERR08
|
||||
'ERR08_bit0': 'P0600 — Serial Communication Link',
|
||||
'ERR08_bit1': 'P0601 — Internal Control Module Memory',
|
||||
'ERR08_bit2': 'P0604 — Internal Control Module RAM',
|
||||
'ERR08_bit3': 'P0605 — Internal Control Module ROM',
|
||||
'ERR08_bit4': 'P0657 — Actuator Supply Voltage A Open',
|
||||
'ERR08_bit5': 'P0685 — ECM/PCM Power Relay Control Open',
|
||||
'ERR08_bit6': 'P0691 — Fan 1 Control Circuit Low',
|
||||
'ERR08_bit7': 'P0692 — Fan 1 Control Circuit High',
|
||||
|
||||
// ERR09
|
||||
'ERR09_bit0': 'P0693 — Fan 2 Control Circuit Low',
|
||||
'ERR09_bit1': 'P0694 — Fan 2 Control Circuit High',
|
||||
'ERR09_bit2': 'P1454 — EVAP Pressure Sensor Range/Performance',
|
||||
'ERR09_bit3': 'P1455 — EVAP Leak (small)',
|
||||
'ERR09_bit4': 'P1508 — IAC Valve Circuit Low',
|
||||
'ERR09_bit5': 'P1509 — IAC Valve Circuit High',
|
||||
'ERR09_bit6': 'P1519 — Idle Air Control Valve Stuck',
|
||||
'ERR09_bit7': 'P1706 — A/T Range Switch Out Of Range',
|
||||
|
||||
// ERR10
|
||||
'ERR10_bit0': 'P2101 — Throttle Actuator Control Motor Range',
|
||||
'ERR10_bit1': 'P2118 — Throttle Actuator Control Motor Current Range',
|
||||
'ERR10_bit2': 'P2119 — Throttle Actuator Control Throttle Body Range',
|
||||
'ERR10_bit3': 'P2122 — Throttle/Pedal Position Sensor D Low Input',
|
||||
'ERR10_bit4': 'P2123 — Throttle/Pedal Position Sensor D High Input',
|
||||
'ERR10_bit5': 'P2127 — Throttle/Pedal Position Sensor E Low Input',
|
||||
'ERR10_bit6': 'P2128 — Throttle/Pedal Position Sensor E High Input',
|
||||
'ERR10_bit7': 'P2135 — Throttle/Pedal Position Sensor A/B Voltage Correlation',
|
||||
|
||||
// ERR11
|
||||
'ERR11_bit0': 'P2138 — Throttle/Pedal Position Sensor D/E Voltage Correlation',
|
||||
'ERR11_bit1': 'P2176 — Throttle Actuator Control System Idle Position Not Learned',
|
||||
'ERR11_bit2': 'P2227 — Barometric Pressure Sensor A Circuit Range',
|
||||
'ERR11_bit3': 'P2228 — Barometric Pressure Sensor A Circuit Low',
|
||||
'ERR11_bit4': 'P2229 — Barometric Pressure Sensor A Circuit High',
|
||||
'ERR11_bit5': 'P2610 — ECM/PCM Internal Engine Off Timer',
|
||||
'ERR11_bit6': 'P2647 — VTEC System High (bank 1)',
|
||||
'ERR11_bit7': 'P2648 — VTEC System Low (bank 1)',
|
||||
|
||||
// ERR12
|
||||
'ERR12_bit0': 'P2649 — VTEC System High (bank 2)',
|
||||
'ERR12_bit1': 'P2650 — VTEC System Low (bank 2)',
|
||||
'ERR12_bit2': 'P3400 — VTEC System Stuck Off (bank 1)',
|
||||
'ERR12_bit3': 'P3497 — VTEC System Stuck Off (bank 2)',
|
||||
'ERR12_bit4': 'U0073 — Control Module Communication Bus Off',
|
||||
'ERR12_bit5': 'U0100 — Lost Communication With ECM/PCM A',
|
||||
'ERR12_bit6': 'U0155 — Lost Communication With Instrument Panel Cluster',
|
||||
'ERR12_bit7': 'U0164 — Lost Communication With HVAC Control Module',
|
||||
|
||||
// ERR13
|
||||
'ERR13_bit0': 'P0011 — Camshaft Position A — Timing Over-Advanced (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_bit3': 'P0022 — Camshaft Position B — Timing Over-Retarded (bank 1)',
|
||||
'ERR13_bit4': 'P0340 — CMP Sensor A Circuit (bank 1)',
|
||||
'ERR13_bit5': 'P0345 — CMP Sensor A Circuit (bank 2)',
|
||||
'ERR13_bit6': 'P0366 — CMP Sensor B Circuit Range/Performance (bank 1)',
|
||||
'ERR13_bit7': 'P0391 — CMP Sensor B Circuit Range/Performance (bank 2)',
|
||||
|
||||
// ERR14
|
||||
'ERR14_bit0': 'P0016 — Crankshaft/Camshaft Position Correlation (bank 1, sensor A)',
|
||||
'ERR14_bit1': 'P0017 — Crankshaft/Camshaft Position Correlation (bank 1, sensor B)',
|
||||
'ERR14_bit2': 'P0018 — Crankshaft/Camshaft Position Correlation (bank 2, sensor A)',
|
||||
'ERR14_bit3': 'P0019 — Crankshaft/Camshaft Position Correlation (bank 2, sensor B)',
|
||||
'ERR14_bit4': 'P0008 — Engine Position System (bank 1)',
|
||||
'ERR14_bit5': 'P0009 — Engine Position System (bank 2)',
|
||||
'ERR14_bit6': 'P0014 — Camshaft Position B — Timing Over-Advanced (bank 1)',
|
||||
'ERR14_bit7': 'P0024 — Camshaft Position B — Timing Over-Advanced (bank 2)',
|
||||
|
||||
// ERR15
|
||||
'ERR15_bit0': 'P0087 — Fuel Rail/System Pressure Too Low',
|
||||
'ERR15_bit1': 'P0088 — Fuel Rail/System Pressure Too High',
|
||||
'ERR15_bit2': 'P0089 — Fuel Pressure Regulator Performance',
|
||||
'ERR15_bit3': 'P0090 — Fuel Pressure Regulator Control Circuit',
|
||||
'ERR15_bit4': 'P0091 — Fuel Pressure Regulator Control Circuit Low',
|
||||
'ERR15_bit5': 'P0092 — Fuel Pressure Regulator Control Circuit High',
|
||||
'ERR15_bit6': 'P0201 — Injector Circuit/Open Cylinder 1',
|
||||
'ERR15_bit7': 'P0202 — Injector Circuit/Open Cylinder 2',
|
||||
|
||||
// ERR16
|
||||
'ERR16_bit0': 'P0203 — Injector Circuit/Open Cylinder 3',
|
||||
'ERR16_bit1': 'P0204 — Injector Circuit/Open Cylinder 4',
|
||||
'ERR16_bit2': 'P0261 — Injector Circuit Low Cylinder 1',
|
||||
'ERR16_bit3': 'P0264 — Injector Circuit Low Cylinder 2',
|
||||
'ERR16_bit4': 'P0267 — Injector Circuit Low Cylinder 3',
|
||||
'ERR16_bit5': 'P0270 — Injector Circuit Low Cylinder 4',
|
||||
'ERR16_bit6': 'P0262 — Injector Circuit High Cylinder 1',
|
||||
'ERR16_bit7': 'P0265 — Injector Circuit High Cylinder 2',
|
||||
|
||||
// ERR17
|
||||
'ERR17_bit0': 'P0268 — Injector Circuit High Cylinder 3',
|
||||
'ERR17_bit1': 'P0271 — Injector Circuit High Cylinder 4',
|
||||
'ERR17_bit2': 'P0351 — Ignition Coil A Primary/Secondary Circuit',
|
||||
'ERR17_bit3': 'P0352 — Ignition Coil B Primary/Secondary Circuit',
|
||||
'ERR17_bit4': 'P0353 — Ignition Coil C Primary/Secondary Circuit',
|
||||
'ERR17_bit5': 'P0354 — Ignition Coil D Primary/Secondary Circuit',
|
||||
'ERR17_bit6': 'P0627 — Fuel Pump Control Circuit Open',
|
||||
'ERR17_bit7': 'P0628 — Fuel Pump Control Circuit Low',
|
||||
};
|
||||
|
||||
/// Returns a list of active DTC descriptions from the given raw error bytes
|
||||
/// using the provided map.
|
||||
List<String> decodeDtcs(List<int> errBytes, Map<String, String> dtcMap) {
|
||||
final List<String> active = [];
|
||||
for (int byteIdx = 0; byteIdx < errBytes.length; byteIdx++) {
|
||||
final int b = errBytes[byteIdx];
|
||||
if (b == 0) continue;
|
||||
for (int bit = 0; bit < 8; bit++) {
|
||||
if ((b >> bit) & 1 == 1) {
|
||||
final String key = 'ERR${byteIdx.toString().padLeft(2, '0')}_bit$bit';
|
||||
final String? desc = dtcMap[key];
|
||||
if (desc != null) active.add(desc);
|
||||
}
|
||||
}
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
@ -1,163 +1,147 @@
|
||||
import 'dart:typed_data';
|
||||
import 'sensor_state.dart';
|
||||
import 'temp_table.dart';
|
||||
import 'neg8.dart';
|
||||
import 'dtc_map.dart';
|
||||
import 's300_parser.dart' show tpsPercent;
|
||||
|
||||
/// Parses a 128-byte KPro ECU response frame into a [SensorState].
|
||||
/// Throws [ArgumentError] if frame length is wrong or NEG8 checksum fails.
|
||||
SensorState parseKPro(Uint8List frame) {
|
||||
if (frame.length != 128) {
|
||||
throw ArgumentError('KPro frame must be 128 bytes, got ${frame.length}');
|
||||
}
|
||||
if (!validateFrame(frame)) {
|
||||
throw ArgumentError('KPro frame NEG8 checksum failed');
|
||||
}
|
||||
|
||||
// Values marked KMGR were checked against KPro Manager on the bench
|
||||
// (2026-10-13, 7267 rpm frame); the PDF table's offsets for PA and BAT
|
||||
// turned out to be wrong, see below.
|
||||
|
||||
// RPM: bytes 2..3 little-endian / 4. KMGR: 7267 rpm.
|
||||
final int rpmRaw = _readUint16LE(frame, 2);
|
||||
final double rpm = rpmRaw / 4.0;
|
||||
|
||||
// VSS: byte 4 direct km/h
|
||||
final double vss = frame[4].toDouble();
|
||||
|
||||
// TPS: byte 5, (raw - 25) / 2.04. KMGR: raw 163 -> 68 %.
|
||||
final double tps = tpsPercent(frame[5]);
|
||||
|
||||
// MAP: bytes 6 (MAP1) and 33..34 (MAP2 L+H)
|
||||
final int map1Raw = frame[6];
|
||||
final int map2Raw = _readUint16LE(frame, 33); // MAP2L @33, MAP2H @34
|
||||
final double map = (map2Raw != 0) ? map2Raw / 10.0 : map1Raw.toDouble();
|
||||
|
||||
// INJ: bytes 11..12 little-endian, µs -> ms. KMGR: 00 00 -> 0.00 ms (in
|
||||
// fuel cut). Byte order follows the L/H pairs; scale UNVERIFIED.
|
||||
final double inj = _readUint16LE(frame, 11) / 1000.0;
|
||||
|
||||
// IGN: byte 13, (raw - 128) / 2 degrees. KMGR: raw 138 -> 5°.
|
||||
final double ign = (frame[13] - 128) / 2.0;
|
||||
|
||||
// O2: byte 14 raw
|
||||
// O2: volts, raw × 5/256 as on the S300. UNVERIFIED for KPro.
|
||||
final double o2 = frame[14] * 5.0 / 256.0;
|
||||
|
||||
// KRtrd: byte 21
|
||||
final int krtrd = frame[21];
|
||||
|
||||
// SW bitmaps
|
||||
final int sw1 = frame[31];
|
||||
final int sw2 = frame[32];
|
||||
|
||||
// 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
|
||||
// reachable with subtraction).
|
||||
final double ect = (tempXlt[frame[49]] - 40).toDouble();
|
||||
|
||||
// IAT: byte 50
|
||||
final double iat = (tempXlt[frame[50]] - 40).toDouble();
|
||||
|
||||
// PA: byte 53, kPa. KMGR: raw 95 -> 95.0 kPa. (The PDF puts PA at 51.)
|
||||
final double pa = frame[53].toDouble();
|
||||
|
||||
// BAT: byte 51, raw / 10. KMGR: raw 114 -> 11.40 V. (The PDF puts BAT at
|
||||
// 52; that byte read 232 -> "23.2 V".)
|
||||
final double bat = frame[51] / 10.0;
|
||||
|
||||
// STRIM: byte 20, (raw - 128) / 1.28 %. KMGR: raw 128 -> 0 %.
|
||||
final double strim = (frame[20] - 128) / 1.28;
|
||||
|
||||
// LTRIM — not explicitly listed in KPro; use 0
|
||||
const double ltrim = 0.0;
|
||||
|
||||
// ETH — not in KPro spec; use 0
|
||||
const double eth = 0.0;
|
||||
|
||||
// AFR: byte 25 (AFCMD at offset +19, then raw)
|
||||
final double afr = frame[25].toDouble();
|
||||
|
||||
// ERR bytes: 18 bytes starting at offset 0x3E = 62
|
||||
final List<int> errBytes = List<int>.generate(18, (i) => frame[62 + i]);
|
||||
|
||||
// AIN0–AIN7: bytes 0x52..0x61 (offset 82..97, uint16 LE each)
|
||||
final double ain0 = _readUint16LE(frame, 82).toDouble();
|
||||
final double ain1 = _readUint16LE(frame, 84).toDouble();
|
||||
final double ain2 = _readUint16LE(frame, 86).toDouble();
|
||||
final double ain3 = _readUint16LE(frame, 88).toDouble();
|
||||
final double ain4 = _readUint16LE(frame, 90).toDouble();
|
||||
final double ain5 = _readUint16LE(frame, 92).toDouble();
|
||||
final double ain6 = _readUint16LE(frame, 94).toDouble();
|
||||
final double ain7 = _readUint16LE(frame, 96).toDouble();
|
||||
|
||||
// Gear — not in KPro frame, default 0
|
||||
const int gear = 0;
|
||||
|
||||
// Flags. The PDF lists the bits LOWEST first. KMGR: SW1 = 0x50 with SCS
|
||||
// and FLR on -> SCS bit4, FLR bit6.
|
||||
// SW1: RVSLCK(0) BKSW(1) ACSW(2) ACCL(3) SCS(4) EPS(5) FLR(6) FANC(7)
|
||||
final bool brakeSwitch = (sw1 & 0x02) != 0;
|
||||
final bool acSwitch = (sw1 & 0x04) != 0;
|
||||
final bool fuelPump = (sw1 & 0x40) != 0; // FLR = fuel pump relay
|
||||
final bool fanOut = (sw1 & 0x80) != 0; // FANC
|
||||
// SW2: VTP(0) VTS(1) … MIL(3). KMGR: SW2 = 0x01/0x09 with VTP on, VTS
|
||||
// off, MIL on -> MIL is bit3 (the PDF list has a gap). MIL UNVERIFIED
|
||||
// beyond this one capture.
|
||||
final bool vtp = (sw2 & 0x01) != 0;
|
||||
final bool vtec = (sw2 & 0x02) != 0; // VTS = VTEC solenoid
|
||||
final bool mil = (sw2 & 0x08) != 0;
|
||||
// Knock = KRtrd > 0
|
||||
final bool knock = krtrd > 0;
|
||||
// Rev limit — not directly mapped in KPro SW; false
|
||||
const bool revLimit = false;
|
||||
// Launch — not in KPro SW; false
|
||||
const bool launch = false;
|
||||
|
||||
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,
|
||||
brakeSwitch: brakeSwitch,
|
||||
acSwitch: acSwitch,
|
||||
vtp: vtp,
|
||||
iatError: anyDtcBit(errBytes, kproIatErrorBits),
|
||||
ectError: anyDtcBit(errBytes, kproEctErrorBits),
|
||||
o2Error: anyDtcBit(errBytes, kproO2ErrorBits),
|
||||
errBytes: errBytes,
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
int _readUint16LE(Uint8List frame, int offset) {
|
||||
return frame[offset] | (frame[offset + 1] << 8);
|
||||
}
|
||||
import 'dart:typed_data';
|
||||
import 'sensor_state.dart';
|
||||
import 'temp_table.dart';
|
||||
import 'neg8.dart';
|
||||
|
||||
/// Parses a 128-byte KPro ECU response frame into a [SensorState].
|
||||
/// Throws [ArgumentError] if frame length is wrong or NEG8 checksum fails.
|
||||
SensorState parseKPro(Uint8List frame) {
|
||||
if (frame.length != 128) {
|
||||
throw ArgumentError('KPro frame must be 128 bytes, got ${frame.length}');
|
||||
}
|
||||
if (!validateFrame(frame)) {
|
||||
throw ArgumentError('KPro frame NEG8 checksum failed');
|
||||
}
|
||||
|
||||
// RPM: bytes 2..3 — raw / 4
|
||||
final int rpmRaw = _readUint16BE(frame, 2);
|
||||
final double rpm = rpmRaw / 4.0;
|
||||
|
||||
// VSS: byte 4 direct km/h
|
||||
final double vss = frame[4].toDouble();
|
||||
|
||||
// TPS: byte 5 direct %
|
||||
final double tps = frame[5].toDouble();
|
||||
|
||||
// MAP: bytes 6 (MAP1) and 33..34 (MAP2 L+H)
|
||||
final int map1Raw = frame[6];
|
||||
final int map2Raw = _readUint16BE(frame, 33);
|
||||
final double map = (map2Raw != 0) ? map2Raw / 10.0 : map1Raw.toDouble();
|
||||
|
||||
// INJ: byte 11 raw
|
||||
final double inj = frame[11].toDouble();
|
||||
|
||||
// IGN: byte 13 raw degrees
|
||||
final double ign = frame[13].toDouble();
|
||||
|
||||
// O2: byte 14 raw
|
||||
final double o2 = frame[14].toDouble();
|
||||
|
||||
// KRtrd: byte 21
|
||||
final int krtrd = frame[21];
|
||||
|
||||
// SW bitmaps
|
||||
final int sw1 = frame[31];
|
||||
final int sw2 = frame[32];
|
||||
|
||||
// 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
|
||||
// reachable with subtraction).
|
||||
final double ect = (tempXlt[frame[49]] - 40).toDouble();
|
||||
|
||||
// IAT: byte 50
|
||||
final double iat = (tempXlt[frame[50]] - 40).toDouble();
|
||||
|
||||
// PA: byte 51
|
||||
final double pa = frame[51].toDouble();
|
||||
|
||||
// BAT: byte 52 — raw / 10
|
||||
final double bat = frame[52] / 10.0;
|
||||
|
||||
// STRIM: byte 32 (SW2 shares offset 32, STRIM is byte 20 per spec offset +20)
|
||||
final double strim = frame[20].toDouble();
|
||||
|
||||
// LTRIM — not explicitly listed in KPro; use 0
|
||||
const double ltrim = 0.0;
|
||||
|
||||
// ETH — not in KPro spec; use 0
|
||||
const double eth = 0.0;
|
||||
|
||||
// AFR: byte 25 (AFCMD at offset +19, then raw)
|
||||
final double afr = frame[25].toDouble();
|
||||
|
||||
// ERR bytes: 18 bytes starting at offset 0x3E = 62
|
||||
final List<int> errBytes = List<int>.generate(18, (i) => frame[62 + i]);
|
||||
|
||||
// AIN0–AIN7: bytes 0x52..0x61 (offset 82..97, uint16 BE each)
|
||||
final double ain0 = _readUint16BE(frame, 82).toDouble();
|
||||
final double ain1 = _readUint16BE(frame, 84).toDouble();
|
||||
final double ain2 = _readUint16BE(frame, 86).toDouble();
|
||||
final double ain3 = _readUint16BE(frame, 88).toDouble();
|
||||
final double ain4 = _readUint16BE(frame, 90).toDouble();
|
||||
final double ain5 = _readUint16BE(frame, 92).toDouble();
|
||||
final double ain6 = _readUint16BE(frame, 94).toDouble();
|
||||
final double ain7 = _readUint16BE(frame, 96).toDouble();
|
||||
|
||||
// Gear — not in KPro frame, default 0
|
||||
const int gear = 0;
|
||||
|
||||
// Flags
|
||||
// SW2 bit2 = MIL
|
||||
final bool mil = (sw2 & 0x04) != 0;
|
||||
// SW1 bit6 = FLR. Unverified: never live-tested for KPro, and the
|
||||
// equivalent S300 bit turned out to be fuel PUMP relay status, not fuel
|
||||
// cut, despite an initially plausible-looking name — treat "FLR" the
|
||||
// same way until confirmed.
|
||||
final bool fuelPump = (sw1 & 0x40) != 0;
|
||||
// SW1 bit0 = FANC
|
||||
final bool fanOut = (sw1 & 0x01) != 0;
|
||||
// SW2 bit1..0 = VTS (VTEC)
|
||||
final bool vtec = (sw2 & 0x03) != 0;
|
||||
// Knock = KRtrd > 0
|
||||
final bool knock = krtrd > 0;
|
||||
// Rev limit — not directly mapped in KPro SW; false
|
||||
const bool revLimit = false;
|
||||
// Launch — not in KPro SW; false
|
||||
const bool launch = false;
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
@ -1,22 +1,22 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// Calculates the NEG8 checksum for a buffer.
|
||||
/// NEG8: start at 0, subtract each byte, keep lower 8 bits.
|
||||
/// The result should match the last byte of a valid frame.
|
||||
int calculateNeg8(Uint8List buf) {
|
||||
int neg8 = 0;
|
||||
for (int i = 0; i < buf.length; i++) {
|
||||
neg8 = (neg8 - buf[i]) & 0xFF;
|
||||
}
|
||||
return neg8;
|
||||
}
|
||||
|
||||
/// Returns true if the frame's last byte equals the NEG8 of all preceding bytes.
|
||||
bool validateFrame(Uint8List frame) {
|
||||
if (frame.isEmpty) return false;
|
||||
int neg8 = 0;
|
||||
for (int i = 0; i < frame.length - 1; i++) {
|
||||
neg8 = (neg8 - frame[i]) & 0xFF;
|
||||
}
|
||||
return neg8 == frame[frame.length - 1];
|
||||
}
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// Calculates the NEG8 checksum for a buffer.
|
||||
/// NEG8: start at 0, subtract each byte, keep lower 8 bits.
|
||||
/// The result should match the last byte of a valid frame.
|
||||
int calculateNeg8(Uint8List buf) {
|
||||
int neg8 = 0;
|
||||
for (int i = 0; i < buf.length; i++) {
|
||||
neg8 = (neg8 - buf[i]) & 0xFF;
|
||||
}
|
||||
return neg8;
|
||||
}
|
||||
|
||||
/// Returns true if the frame's last byte equals the NEG8 of all preceding bytes.
|
||||
bool validateFrame(Uint8List frame) {
|
||||
if (frame.isEmpty) return false;
|
||||
int neg8 = 0;
|
||||
for (int i = 0; i < frame.length - 1; i++) {
|
||||
neg8 = (neg8 - frame[i]) & 0xFF;
|
||||
}
|
||||
return neg8 == frame[frame.length - 1];
|
||||
}
|
||||
|
||||
@ -1,213 +1,237 @@
|
||||
import 'dart:typed_data';
|
||||
import 'sensor_state.dart';
|
||||
import 'temp_table.dart';
|
||||
import 'neg8.dart';
|
||||
import 'dtc_map.dart';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// S300 datalog frame (128 bytes, response to request 1B 00 E5).
|
||||
//
|
||||
// BYTE OFFSETS: hvbt_protocol.pdf labels its columns inconsistently
|
||||
// ("+26 +27 +28 +29 +2A … +2F +30 +31"). The labels are NOT hex offsets.
|
||||
// The real offsets are simply the column count, 0..127. This is proven by
|
||||
// the fields already confirmed against live USB captures — Gear=27, ECT=33,
|
||||
// IAT=34, BAT=36 — which all match plain column counting, and by the adapter's
|
||||
// default frame (below) reproducing the PDF example row byte-for-byte.
|
||||
//
|
||||
// 0 ID1(1B) 1 ID2(00) 2 DLrate 3-4 RPM 5-6 VSS 7-8 MAP 9 TPS 10-11 INJ
|
||||
// 12 IGN 13 KRtrd 14-15 Klvl 16 O2 17 SW1 18 SW2 19 SW3 20 SW4 21-26 ?
|
||||
// 27 Gear 28 ? 29 Strim 30 ? 31 Ltrim 32 PA 33 ECT 34 IAT 35 ? 36 BAT
|
||||
// 37 ERR00 38 ERR01 39 ERR02 40 ERR03 41 Eth 42-44 ? 45 IATc 46 ECTc 47-48 ?
|
||||
// 49 SW5 50 SW6 51-54 ? 55 AF 56 ? 57 TCrtrd 58 TClim 59 ? 60-81 reserved
|
||||
// 82-97 AIN0..AIN7 (16-bit) 98-126 reserved 127 NEG8
|
||||
//
|
||||
// BYTE ORDER: multi-byte values are LITTLE-endian on the wire (confirmed live
|
||||
// for RPM/VSS/MAP; the PDF's "Big Endian" note only applies to the request).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// True when the frame is the adapter's built-in DEFAULT frame — the exact
|
||||
/// example values printed in hvbt_protocol.pdf (DLrate 0x14, VSS 0xFFFF,
|
||||
/// MAP 0x03F2, TPS 0x18, IGN 0x10, O2 0xC4 …; bytes 32-36 = 93 A0 A0 DC 7D).
|
||||
/// The adapter sends it when it has no fresh data from the ECU (typically
|
||||
/// because it was asked again before it could finish reading the ECU).
|
||||
/// Such a frame carries NO real readings and must not be displayed or logged.
|
||||
///
|
||||
/// Both spans must match: bytes 0-16 alone could, in principle, coincide with
|
||||
/// a real key-on/engine-off reading (RPM 0, MAP 101.0, closed throttle), and a
|
||||
/// false positive here hides real data and triggers auto-reconnects.
|
||||
bool isS300DefaultFrame(Uint8List frame) {
|
||||
if (frame.length != 128) return false;
|
||||
const head = <int>[
|
||||
0x1B, 0x00, 0x14, 0x00, 0x00, 0xFF, 0xFF, 0xF2, 0x03, //
|
||||
0x18, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0xC4,
|
||||
];
|
||||
for (var i = 0; i < head.length; i++) {
|
||||
if (frame[i] != head[i]) return false;
|
||||
}
|
||||
// PA, ECT, IAT, ?, BAT at 32-36.
|
||||
const tail = <int>[0x93, 0xA0, 0xA0, 0xDC, 0x7D];
|
||||
for (var i = 0; i < tail.length; i++) {
|
||||
if (frame[32 + i] != tail[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Former name, kept so older call sites/tests keep compiling.
|
||||
bool isS300TemplateFrame(Uint8List frame) => isS300DefaultFrame(frame);
|
||||
|
||||
/// Parses a 128-byte S300 ECU response frame into a [SensorState].
|
||||
/// Throws [ArgumentError] if frame length is wrong, or if [validateChecksum]
|
||||
/// is true and the NEG8 checksum fails.
|
||||
SensorState parseS300(Uint8List frame, {bool validateChecksum = true}) {
|
||||
if (frame.length != 128) {
|
||||
throw ArgumentError('S300 frame must be 128 bytes, got ${frame.length}');
|
||||
}
|
||||
if (validateChecksum && !validateFrame(frame)) {
|
||||
throw ArgumentError('S300 frame NEG8 checksum failed');
|
||||
}
|
||||
|
||||
// ── Confirmed against live captures ────────────────────────────────────
|
||||
final double rpm = _u16le(frame, 3).toDouble();
|
||||
|
||||
final int vssRaw = _u16le(frame, 5);
|
||||
final double vss =
|
||||
(vssRaw < 893 || vssRaw == 0xFFFF) ? 0.0 : 228480.0 / vssRaw;
|
||||
|
||||
final double map = _u16le(frame, 7) / 10.0;
|
||||
|
||||
final double tps = tpsPercent(frame[9]);
|
||||
|
||||
final int gear = frame[27];
|
||||
|
||||
// Table value minus 40 (live-confirmed; also the default frame's 0xA0
|
||||
// decodes to a sensible 20 °C this way).
|
||||
final double ect = (tempXlt[frame[33]] - 40).toDouble();
|
||||
final double iat = (tempXlt[frame[34]] - 40).toDouble();
|
||||
|
||||
final double bat = frame[36] * 26.0 / 270.0;
|
||||
|
||||
// ── Offsets corrected 2026-10-12 (column counting, see header) ─────────
|
||||
// Formulas marked UNVERIFIED still need one bench check against the USB
|
||||
// tool; they are sanity-checked against the default frame only.
|
||||
|
||||
// INJ: 16-bit LE (was read big-endian). UNVERIFIED scale: assumes µs.
|
||||
final double inj = _u16le(frame, 10) / 1000.0;
|
||||
|
||||
// IGN: formula from the PDF. UNVERIFIED (one live check disagreed).
|
||||
final double ign = (frame[12] + 120) / 2.0;
|
||||
|
||||
// O2: volts, raw × 5/256 (USB tool: raw 195 → 3.81 V).
|
||||
final double o2 = frame[16] * 5.0 / 256.0;
|
||||
|
||||
final int sw1 = frame[17];
|
||||
final int sw2 = frame[18];
|
||||
final int sw3 = frame[19];
|
||||
final int sw5 = frame[49]; // was read from byte 67 (reserved area)
|
||||
|
||||
// Fuel trims: 0x80 = 0 %. Default frame has 0x80 in both → 0 %.
|
||||
final double strim = (frame[29] - 128) * 100.0 / 128.0; // was byte 41
|
||||
final double ltrim = (frame[31] - 128) * 100.0 / 128.0; // was byte 43
|
||||
|
||||
// Baro: byte 32 (was 44). Honda OBD1 baro ADC formula; default frame's
|
||||
// 0x93 → 100.3 kPa (sea level), which supports it. UNVERIFIED on bench.
|
||||
final double pa = frame[32] * 0.716 - 5.0;
|
||||
|
||||
// DTC bytes ERR00..ERR03 = 37..40 (were read from 49..52 = SW5/SW6/…,
|
||||
// which produced phantom codes such as P0505/P0350 from SW6 = 0x60).
|
||||
final List<int> errBytes = [frame[37], frame[38], frame[39], frame[40]];
|
||||
|
||||
// Ethanol %: byte 41 (was 56).
|
||||
final double eth = frame[41].toDouble();
|
||||
|
||||
// AF: byte 55 (was 52 = unknown byte). UNVERIFIED scale — shown as raw.
|
||||
final double afr = frame[55].toDouble();
|
||||
|
||||
// AIN0–AIN7: 82..97, 16-bit LE (was big-endian). Raw counts.
|
||||
final double ain0 = _u16le(frame, 82).toDouble();
|
||||
final double ain1 = _u16le(frame, 84).toDouble();
|
||||
final double ain2 = _u16le(frame, 86).toDouble();
|
||||
final double ain3 = _u16le(frame, 88).toDouble();
|
||||
final double ain4 = _u16le(frame, 90).toDouble();
|
||||
final double ain5 = _u16le(frame, 92).toDouble();
|
||||
final double ain6 = _u16le(frame, 94).toDouble();
|
||||
final double ain7 = _u16le(frame, 96).toDouble();
|
||||
|
||||
// ── Flags (bit maps from the PDF) ──────────────────────────────────────
|
||||
// SW1: ?(7) DistErr(6) SCS(5) PSP(4) REVL(3) BKSW(2) ACSW(1) VTP(0)
|
||||
final bool revLimit = (sw1 & 0x08) != 0;
|
||||
// SW2: ALTC(7) IAB(6) MIL(5) PCS(4) ACCL(3) Fuel(2) VTS(1..0)
|
||||
final bool mil = (sw2 & 0x20) != 0;
|
||||
// Fuel pump relay, active-LOW (live-confirmed).
|
||||
final bool fuelPump = (sw2 & 0x04) == 0;
|
||||
final bool vtec = (sw2 & 0x03) != 0;
|
||||
// SW5: … CL(2) … FANC(0). Fan output is FANC only — SW2 bit7 is ALTC
|
||||
// (alternator control), not the fan.
|
||||
final bool fanOut = (sw5 & 0x01) != 0;
|
||||
final bool knock = frame[13] > 0; // KRtrd
|
||||
// SW3: LnchC(7) LnchR(6) IGNC(5) ShftC(4) BstC(3)
|
||||
final bool launch = (sw3 & 0x80) != 0;
|
||||
final bool launchRetard = (sw3 & 0x40) != 0;
|
||||
final bool ignCut = (sw3 & 0x20) != 0;
|
||||
final bool shiftCut = (sw3 & 0x10) != 0;
|
||||
final bool boostCut = (sw3 & 0x08) != 0;
|
||||
final bool closedLoop = (sw5 & 0x04) != 0; // SW5 CL
|
||||
final bool brakeSwitch = (sw1 & 0x04) != 0; // SW1 BKSW
|
||||
final bool acSwitch = (sw1 & 0x02) != 0; // SW1 ACSW
|
||||
final bool vtp = (sw1 & 0x01) != 0; // SW1 VTP (VTEC pressure)
|
||||
|
||||
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,
|
||||
launchRetard: launchRetard,
|
||||
ignCut: ignCut,
|
||||
shiftCut: shiftCut,
|
||||
boostCut: boostCut,
|
||||
closedLoop: closedLoop,
|
||||
brakeSwitch: brakeSwitch,
|
||||
acSwitch: acSwitch,
|
||||
vtp: vtp,
|
||||
iatError: anyDtcBit(errBytes, s300IatErrorBits),
|
||||
ectError: anyDtcBit(errBytes, s300EctErrorBits),
|
||||
o2Error: anyDtcBit(errBytes, s300O2ErrorBits),
|
||||
errBytes: errBytes,
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Throttle %, both ECUs: (raw - 25) / 2.04, clamped to 0..100 (the PDF's
|
||||
/// "TPS < 25 ? 0" threshold). Checked against the USB tools: S300 raw ≈ 88
|
||||
/// -> 31 %, KPro raw 163 -> 68 %. (The previous raw × 100/255 matched only
|
||||
/// near 50 %.)
|
||||
double tpsPercent(int raw) => ((raw - 25) / 2.04).clamp(0.0, 100.0);
|
||||
|
||||
int _u16le(Uint8List frame, int offset) =>
|
||||
frame[offset] | (frame[offset + 1] << 8);
|
||||
import 'dart:typed_data';
|
||||
import 'sensor_state.dart';
|
||||
import 'temp_table.dart';
|
||||
import 'neg8.dart';
|
||||
|
||||
/// True when bytes 0-16 of the frame (header, RPM, VSS, MAP, TPS, INJ, IGN,
|
||||
/// the knock byte, and O2) match a fixed pattern instead of a real reading,
|
||||
/// with those fields pinned to fixed constants regardless of the true
|
||||
/// physical signal.
|
||||
///
|
||||
/// 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
|
||||
/// byte sequence (`93 a0 a0 dc 7d`) across dozens of unrelated live captures
|
||||
/// 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
|
||||
/// earlier belief that SW2 stays live (based on an RPM sweep showing
|
||||
/// fuelPump correlating with real changes) only holds while the frame
|
||||
/// *isn't* templated; during a template frame it goes stale like everything
|
||||
/// else tested so far. SW1 itself was never actually confirmed for template
|
||||
/// frames specifically — the "matches USB exactly" validation that seemed
|
||||
/// to support "bytes 17+ are live" was checked against a non-template frame.
|
||||
/// At this point, don't assume any field is live during a template frame
|
||||
/// unless it's been individually verified that way — the safer default is
|
||||
/// that the whole frame is templated, with only a shrinking set of
|
||||
/// individually-confirmed-live fields as exceptions (currently: none
|
||||
/// confirmed with certainty — treat the entire byte-17+ region as suspect
|
||||
/// until proven otherwise field by field).
|
||||
///
|
||||
/// Callers should hold the last known-good values for every field sourced
|
||||
/// from the frozen span (bytes 0-16, gear/ECT/IAT/BAT, and the SW2 flags)
|
||||
/// and decode only what's individually confirmed live.
|
||||
bool isS300TemplateFrame(Uint8List frame) {
|
||||
if (frame.length != 128) return false;
|
||||
|
||||
return frame[0] == 0x1B &&
|
||||
frame[1] == 0x00 &&
|
||||
frame[2] == 0x14 &&
|
||||
frame[3] == 0x00 &&
|
||||
frame[4] == 0x00 &&
|
||||
frame[5] == 0xFF &&
|
||||
frame[6] == 0xFF &&
|
||||
frame[7] == 0xF2 &&
|
||||
frame[8] == 0x03 &&
|
||||
frame[9] == 0x18 &&
|
||||
frame[10] == 0x00 &&
|
||||
frame[11] == 0x00 &&
|
||||
frame[12] == 0x10 &&
|
||||
frame[13] == 0x00 &&
|
||||
frame[14] == 0x00 &&
|
||||
frame[15] == 0x00 &&
|
||||
frame[16] == 0xC4;
|
||||
}
|
||||
|
||||
/// Parses a 128-byte S300 ECU response frame into a [SensorState].
|
||||
/// Throws [ArgumentError] if frame length is wrong, or if [validateChecksum]
|
||||
/// is true and the NEG8 checksum fails.
|
||||
SensorState parseS300(Uint8List frame, {bool validateChecksum = true}) {
|
||||
if (frame.length != 128) {
|
||||
throw ArgumentError('S300 frame must be 128 bytes, got ${frame.length}');
|
||||
}
|
||||
if (validateChecksum && !validateFrame(frame)) {
|
||||
throw ArgumentError('S300 frame NEG8 checksum failed');
|
||||
}
|
||||
|
||||
// RPM: bytes +3..4, little-endian, use directly. Confirmed against a live
|
||||
// capture: LE @ offset 3 matched a reference tool's RPM exactly, while
|
||||
// REQUIREMENTS.md's blanket "all multi-byte values are Big Endian" claim
|
||||
// did not hold for this field on real hardware.
|
||||
final double rpm = _readUint16LE(frame, 3).toDouble();
|
||||
|
||||
// VSS: bytes +5..6, little-endian (confirmed against a live capture).
|
||||
final int vssRaw = _readUint16LE(frame, 5);
|
||||
final double vss =
|
||||
(vssRaw < 893 || vssRaw == 0xFFFF) ? 0.0 : 228480.0 / vssRaw;
|
||||
|
||||
// MAP: bytes +7..8, little-endian (confirmed against a live capture — the
|
||||
// big-endian read produced multi-thousand-kPa values, physically
|
||||
// impossible for a 0-300 kPa sensor).
|
||||
final int mapRaw = _readUint16LE(frame, 7);
|
||||
final double map = mapRaw / 10.0;
|
||||
|
||||
// TPS: byte +9. Linear 0-255 -> 0-100%, confirmed against a live capture
|
||||
// (raw 127 matched a reference tool's ~50% reading; the spec's
|
||||
// "raw*51/46" formula produced impossible >100% values on the same byte).
|
||||
final int tpsRaw = frame[9];
|
||||
final double tps = (tpsRaw * 100.0 / 255.0).clamp(0.0, 100.0);
|
||||
|
||||
// INJ: bytes +10..11, BE, raw ms.
|
||||
final double inj = _readUint16BE(frame, 10).toDouble();
|
||||
|
||||
// IGN: byte +12.
|
||||
final double ign = (frame[12] + 120) / 2.0;
|
||||
|
||||
// O2: byte +16, raw.
|
||||
final double o2 = frame[16].toDouble();
|
||||
|
||||
// SW bitmaps
|
||||
final int sw1 = frame[0x11]; // +17
|
||||
final int sw2 = frame[0x12]; // +18
|
||||
final int sw3 = frame[0x13]; // +19
|
||||
final int sw5 = frame[0x43]; // +43
|
||||
|
||||
// Gear: decimal offset 27 (confirmed against a live capture — hex 0x27/39
|
||||
// read 0x00 there; decimal 27 matched a reference tool's gear exactly).
|
||||
final int gear = frame[27];
|
||||
|
||||
// Strim: +29 hex = 41 decimal
|
||||
final double strim = frame[0x29].toDouble();
|
||||
|
||||
// Ltrim: +2B hex = 43 decimal
|
||||
final double ltrim = frame[0x2B].toDouble();
|
||||
|
||||
// PA: +2C hex = 44 decimal. Unconfirmed — a live capture's closest
|
||||
// candidate (byte 50, decimal) was off by ~3 kPa from the reference
|
||||
// tool's 98.7 kPa reading, not a clean enough match to move to yet.
|
||||
final double pa = frame[0x2C].toDouble();
|
||||
|
||||
// ECT: decimal offset 33. Confirmed via live capture matching the
|
||||
// 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
|
||||
// same instant (RPM cross-checked to confirm same-moment capture). The
|
||||
// previous offset (+2D hex = 45 decimal) gave physically impossible
|
||||
// values (~210°F) against the same live data. Table is tempXlt[raw]-40,
|
||||
// not +40: the spec's own sensor range (section 3: ECT/IAT -40..150 degC)
|
||||
// is only reachable with subtraction, since tempXlt entries span 0..190.
|
||||
final double ect = (tempXlt[frame[33]] - 40).toDouble();
|
||||
|
||||
// IAT: decimal offset 34, immediately after ECT. Confirmed via the same
|
||||
// live capture: raw=0xCA(202), tempXlt[202]=43, 43-40=3°C=37.4°F vs the
|
||||
// reference tool's 36.1°F — within ~1.3°F, consistent with table
|
||||
// granularity. The previous offset (+2E hex = 46 decimal) was part of the
|
||||
// same wrong-offset block as the old ECT location.
|
||||
final double iat = (tempXlt[frame[34]] - 40).toDouble();
|
||||
|
||||
// BAT: decimal offset 36. Confirmed via live capture: raw=0x79(121),
|
||||
// 121*26/270=11.66V vs the reference tool's 11.75V — within one raw unit
|
||||
// of quantization (26/270 V per LSB ≈ 0.096V). This is also where BAT
|
||||
// lived before an earlier, unverified "fix" in this project's history
|
||||
// moved it to +30 hex = 48 decimal — that fix was wrong; this restores
|
||||
// the original, now confirmed-correct, offset.
|
||||
final double bat = frame[36] * 26.0 / 270.0;
|
||||
|
||||
// ERR bytes: +31..+34 hex = 49..52 decimal
|
||||
final List<int> errBytes = [
|
||||
frame[0x31],
|
||||
frame[0x32],
|
||||
frame[0x33],
|
||||
frame[0x34],
|
||||
];
|
||||
|
||||
// Eth: +38 hex = 56 decimal
|
||||
final double eth = frame[0x38].toDouble();
|
||||
|
||||
// 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)
|
||||
final double ain0 = _readUint16BE(frame, 82).toDouble();
|
||||
final double ain1 = _readUint16BE(frame, 84).toDouble();
|
||||
final double ain2 = _readUint16BE(frame, 86).toDouble();
|
||||
final double ain3 = _readUint16BE(frame, 88).toDouble();
|
||||
final double ain4 = _readUint16BE(frame, 90).toDouble();
|
||||
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);
|
||||
}
|
||||
|
||||
@ -1,219 +1,184 @@
|
||||
/// Fully decoded ECU sensor state. Immutable. Produced by S300 or KPro parser.
|
||||
class SensorState {
|
||||
final double rpm;
|
||||
final double vss;
|
||||
final double map;
|
||||
final double tps;
|
||||
final double inj;
|
||||
final double ign;
|
||||
final double ect;
|
||||
final double iat;
|
||||
final double bat;
|
||||
final double o2;
|
||||
final int gear;
|
||||
final double eth;
|
||||
final double pa;
|
||||
final double afr;
|
||||
final double strim;
|
||||
final double ltrim;
|
||||
// Analog inputs (AIN0–AIN7)
|
||||
final double ain0;
|
||||
final double ain1;
|
||||
final double ain2;
|
||||
final double ain3;
|
||||
final double ain4;
|
||||
final double ain5;
|
||||
final double ain6;
|
||||
final double ain7;
|
||||
// Boolean flags
|
||||
final bool mil;
|
||||
final bool fuelPump;
|
||||
final bool fanOut;
|
||||
final bool vtec;
|
||||
final bool knock;
|
||||
final bool revLimit;
|
||||
final bool launch;
|
||||
// Extra flags (default false where an ECU does not report them).
|
||||
// iat/ect/o2Error are derived from the stored DTC bits.
|
||||
final bool ignCut;
|
||||
final bool boostCut;
|
||||
final bool shiftCut;
|
||||
final bool launchRetard;
|
||||
final bool closedLoop;
|
||||
final bool acSwitch;
|
||||
final bool brakeSwitch;
|
||||
final bool vtp;
|
||||
final bool iatError;
|
||||
final bool ectError;
|
||||
final bool o2Error;
|
||||
// Raw DTC bytes (ERR00..ERR17 for KPro, ERR00..ERR03 for S300)
|
||||
final List<int> errBytes;
|
||||
// Frame timestamp
|
||||
final DateTime timestamp;
|
||||
|
||||
const SensorState({
|
||||
required this.rpm,
|
||||
required this.vss,
|
||||
required this.map,
|
||||
required this.tps,
|
||||
required this.inj,
|
||||
required this.ign,
|
||||
required this.ect,
|
||||
required this.iat,
|
||||
required this.bat,
|
||||
required this.o2,
|
||||
required this.gear,
|
||||
required this.eth,
|
||||
required this.pa,
|
||||
required this.afr,
|
||||
required this.strim,
|
||||
required this.ltrim,
|
||||
required this.ain0,
|
||||
required this.ain1,
|
||||
required this.ain2,
|
||||
required this.ain3,
|
||||
required this.ain4,
|
||||
required this.ain5,
|
||||
required this.ain6,
|
||||
required this.ain7,
|
||||
required this.mil,
|
||||
required this.fuelPump,
|
||||
required this.fanOut,
|
||||
required this.vtec,
|
||||
required this.knock,
|
||||
required this.revLimit,
|
||||
required this.launch,
|
||||
this.ignCut = false,
|
||||
this.boostCut = false,
|
||||
this.shiftCut = false,
|
||||
this.launchRetard = false,
|
||||
this.closedLoop = false,
|
||||
this.acSwitch = false,
|
||||
this.brakeSwitch = false,
|
||||
this.vtp = false,
|
||||
this.iatError = false,
|
||||
this.ectError = false,
|
||||
this.o2Error = false,
|
||||
required this.errBytes,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
factory SensorState.zero() => SensorState(
|
||||
rpm: 0,
|
||||
vss: 0,
|
||||
map: 0,
|
||||
tps: 0,
|
||||
inj: 0,
|
||||
ign: 0,
|
||||
ect: 0,
|
||||
iat: 0,
|
||||
bat: 0,
|
||||
o2: 0,
|
||||
gear: 0,
|
||||
eth: 0,
|
||||
pa: 0,
|
||||
afr: 0,
|
||||
strim: 0,
|
||||
ltrim: 0,
|
||||
ain0: 0,
|
||||
ain1: 0,
|
||||
ain2: 0,
|
||||
ain3: 0,
|
||||
ain4: 0,
|
||||
ain5: 0,
|
||||
ain6: 0,
|
||||
ain7: 0,
|
||||
mil: false,
|
||||
fuelPump: false,
|
||||
fanOut: false,
|
||||
vtec: false,
|
||||
knock: false,
|
||||
revLimit: false,
|
||||
launch: false,
|
||||
errBytes: const [],
|
||||
timestamp: DateTime(0),
|
||||
);
|
||||
|
||||
SensorState copyWith({
|
||||
double? rpm,
|
||||
double? vss,
|
||||
double? map,
|
||||
double? tps,
|
||||
double? inj,
|
||||
double? ign,
|
||||
double? ect,
|
||||
double? iat,
|
||||
double? bat,
|
||||
double? o2,
|
||||
int? gear,
|
||||
double? eth,
|
||||
double? pa,
|
||||
double? afr,
|
||||
double? strim,
|
||||
double? ltrim,
|
||||
double? ain0,
|
||||
double? ain1,
|
||||
double? ain2,
|
||||
double? ain3,
|
||||
double? ain4,
|
||||
double? ain5,
|
||||
double? ain6,
|
||||
double? ain7,
|
||||
bool? mil,
|
||||
bool? fuelPump,
|
||||
bool? fanOut,
|
||||
bool? vtec,
|
||||
bool? knock,
|
||||
bool? revLimit,
|
||||
bool? launch,
|
||||
List<int>? errBytes,
|
||||
DateTime? timestamp,
|
||||
}) {
|
||||
return SensorState(
|
||||
rpm: rpm ?? this.rpm,
|
||||
vss: vss ?? this.vss,
|
||||
map: map ?? this.map,
|
||||
tps: tps ?? this.tps,
|
||||
inj: inj ?? this.inj,
|
||||
ign: ign ?? this.ign,
|
||||
ect: ect ?? this.ect,
|
||||
iat: iat ?? this.iat,
|
||||
bat: bat ?? this.bat,
|
||||
o2: o2 ?? this.o2,
|
||||
gear: gear ?? this.gear,
|
||||
eth: eth ?? this.eth,
|
||||
pa: pa ?? this.pa,
|
||||
afr: afr ?? this.afr,
|
||||
strim: strim ?? this.strim,
|
||||
ltrim: ltrim ?? this.ltrim,
|
||||
ain0: ain0 ?? this.ain0,
|
||||
ain1: ain1 ?? this.ain1,
|
||||
ain2: ain2 ?? this.ain2,
|
||||
ain3: ain3 ?? this.ain3,
|
||||
ain4: ain4 ?? this.ain4,
|
||||
ain5: ain5 ?? this.ain5,
|
||||
ain6: ain6 ?? this.ain6,
|
||||
ain7: ain7 ?? this.ain7,
|
||||
mil: mil ?? this.mil,
|
||||
fuelPump: fuelPump ?? this.fuelPump,
|
||||
fanOut: fanOut ?? this.fanOut,
|
||||
vtec: vtec ?? this.vtec,
|
||||
knock: knock ?? this.knock,
|
||||
revLimit: revLimit ?? this.revLimit,
|
||||
launch: launch ?? this.launch,
|
||||
ignCut: ignCut,
|
||||
boostCut: boostCut,
|
||||
shiftCut: shiftCut,
|
||||
launchRetard: launchRetard,
|
||||
closedLoop: closedLoop,
|
||||
acSwitch: acSwitch,
|
||||
brakeSwitch: brakeSwitch,
|
||||
vtp: vtp,
|
||||
iatError: iatError,
|
||||
ectError: ectError,
|
||||
o2Error: o2Error,
|
||||
errBytes: errBytes ?? this.errBytes,
|
||||
timestamp: timestamp ?? this.timestamp,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// Fully decoded ECU sensor state. Immutable. Produced by S300 or KPro parser.
|
||||
class SensorState {
|
||||
final double rpm;
|
||||
final double vss;
|
||||
final double map;
|
||||
final double tps;
|
||||
final double inj;
|
||||
final double ign;
|
||||
final double ect;
|
||||
final double iat;
|
||||
final double bat;
|
||||
final double o2;
|
||||
final int gear;
|
||||
final double eth;
|
||||
final double pa;
|
||||
final double afr;
|
||||
final double strim;
|
||||
final double ltrim;
|
||||
// Analog inputs (AIN0–AIN7)
|
||||
final double ain0;
|
||||
final double ain1;
|
||||
final double ain2;
|
||||
final double ain3;
|
||||
final double ain4;
|
||||
final double ain5;
|
||||
final double ain6;
|
||||
final double ain7;
|
||||
// Boolean flags
|
||||
final bool mil;
|
||||
final bool fuelPump;
|
||||
final bool fanOut;
|
||||
final bool vtec;
|
||||
final bool knock;
|
||||
final bool revLimit;
|
||||
final bool launch;
|
||||
// Raw DTC bytes (ERR00..ERR17 for KPro, ERR00..ERR03 for S300)
|
||||
final List<int> errBytes;
|
||||
// Frame timestamp
|
||||
final DateTime timestamp;
|
||||
|
||||
const SensorState({
|
||||
required this.rpm,
|
||||
required this.vss,
|
||||
required this.map,
|
||||
required this.tps,
|
||||
required this.inj,
|
||||
required this.ign,
|
||||
required this.ect,
|
||||
required this.iat,
|
||||
required this.bat,
|
||||
required this.o2,
|
||||
required this.gear,
|
||||
required this.eth,
|
||||
required this.pa,
|
||||
required this.afr,
|
||||
required this.strim,
|
||||
required this.ltrim,
|
||||
required this.ain0,
|
||||
required this.ain1,
|
||||
required this.ain2,
|
||||
required this.ain3,
|
||||
required this.ain4,
|
||||
required this.ain5,
|
||||
required this.ain6,
|
||||
required this.ain7,
|
||||
required this.mil,
|
||||
required this.fuelPump,
|
||||
required this.fanOut,
|
||||
required this.vtec,
|
||||
required this.knock,
|
||||
required this.revLimit,
|
||||
required this.launch,
|
||||
required this.errBytes,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
factory SensorState.zero() => SensorState(
|
||||
rpm: 0,
|
||||
vss: 0,
|
||||
map: 0,
|
||||
tps: 0,
|
||||
inj: 0,
|
||||
ign: 0,
|
||||
ect: 0,
|
||||
iat: 0,
|
||||
bat: 0,
|
||||
o2: 0,
|
||||
gear: 0,
|
||||
eth: 0,
|
||||
pa: 0,
|
||||
afr: 0,
|
||||
strim: 0,
|
||||
ltrim: 0,
|
||||
ain0: 0,
|
||||
ain1: 0,
|
||||
ain2: 0,
|
||||
ain3: 0,
|
||||
ain4: 0,
|
||||
ain5: 0,
|
||||
ain6: 0,
|
||||
ain7: 0,
|
||||
mil: false,
|
||||
fuelPump: false,
|
||||
fanOut: false,
|
||||
vtec: false,
|
||||
knock: false,
|
||||
revLimit: false,
|
||||
launch: false,
|
||||
errBytes: const [],
|
||||
timestamp: DateTime(0),
|
||||
);
|
||||
|
||||
SensorState copyWith({
|
||||
double? rpm,
|
||||
double? vss,
|
||||
double? map,
|
||||
double? tps,
|
||||
double? inj,
|
||||
double? ign,
|
||||
double? ect,
|
||||
double? iat,
|
||||
double? bat,
|
||||
double? o2,
|
||||
int? gear,
|
||||
double? eth,
|
||||
double? pa,
|
||||
double? afr,
|
||||
double? strim,
|
||||
double? ltrim,
|
||||
double? ain0,
|
||||
double? ain1,
|
||||
double? ain2,
|
||||
double? ain3,
|
||||
double? ain4,
|
||||
double? ain5,
|
||||
double? ain6,
|
||||
double? ain7,
|
||||
bool? mil,
|
||||
bool? fuelPump,
|
||||
bool? fanOut,
|
||||
bool? vtec,
|
||||
bool? knock,
|
||||
bool? revLimit,
|
||||
bool? launch,
|
||||
List<int>? errBytes,
|
||||
DateTime? timestamp,
|
||||
}) {
|
||||
return SensorState(
|
||||
rpm: rpm ?? this.rpm,
|
||||
vss: vss ?? this.vss,
|
||||
map: map ?? this.map,
|
||||
tps: tps ?? this.tps,
|
||||
inj: inj ?? this.inj,
|
||||
ign: ign ?? this.ign,
|
||||
ect: ect ?? this.ect,
|
||||
iat: iat ?? this.iat,
|
||||
bat: bat ?? this.bat,
|
||||
o2: o2 ?? this.o2,
|
||||
gear: gear ?? this.gear,
|
||||
eth: eth ?? this.eth,
|
||||
pa: pa ?? this.pa,
|
||||
afr: afr ?? this.afr,
|
||||
strim: strim ?? this.strim,
|
||||
ltrim: ltrim ?? this.ltrim,
|
||||
ain0: ain0 ?? this.ain0,
|
||||
ain1: ain1 ?? this.ain1,
|
||||
ain2: ain2 ?? this.ain2,
|
||||
ain3: ain3 ?? this.ain3,
|
||||
ain4: ain4 ?? this.ain4,
|
||||
ain5: ain5 ?? this.ain5,
|
||||
ain6: ain6 ?? this.ain6,
|
||||
ain7: ain7 ?? this.ain7,
|
||||
mil: mil ?? this.mil,
|
||||
fuelPump: fuelPump ?? this.fuelPump,
|
||||
fanOut: fanOut ?? this.fanOut,
|
||||
vtec: vtec ?? this.vtec,
|
||||
knock: knock ?? this.knock,
|
||||
revLimit: revLimit ?? this.revLimit,
|
||||
launch: launch ?? this.launch,
|
||||
errBytes: errBytes ?? this.errBytes,
|
||||
timestamp: timestamp ?? this.timestamp,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,22 +1,22 @@
|
||||
// Shared temperature lookup table for S300 and KPro ECU protocols.
|
||||
// Index = raw ECT/IAT byte value. Decoded temp = tempXlt[raw] - 40 (°C).
|
||||
// (Subtraction, not addition: table entries span 0..190, and only
|
||||
// subtracting 40 reaches the sensor's declared -40..150°C range.)
|
||||
const List<int> tempXlt = [
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
];
|
||||
// Shared temperature lookup table for S300 and KPro ECU protocols.
|
||||
// Index = raw ECT/IAT byte value. Decoded temp = tempXlt[raw] - 40 (°C).
|
||||
// (Subtraction, not addition: table entries span 0..190, and only
|
||||
// subtracting 40 reaches the sensor's declared -40..150°C range.)
|
||||
const List<int> tempXlt = [
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
];
|
||||
|
||||
@ -1,542 +1,236 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../bluetooth/bt_poller.dart';
|
||||
import '../bluetooth/rpm_glitch_filter.dart';
|
||||
import '../bluetooth/bt_service.dart';
|
||||
import '../logging/app_log.dart';
|
||||
import '../protocol/neg8.dart';
|
||||
import '../protocol/s300_parser.dart';
|
||||
import 'settings_provider.dart';
|
||||
|
||||
// ─── Enums ───────────────────────────────────────────────────────────────────
|
||||
|
||||
enum BtStatus { disconnected, connecting, connected, reconnecting, error }
|
||||
|
||||
/// What the adapter is actually giving us, independent of the BT link.
|
||||
enum EcuDataStatus {
|
||||
/// Not connected / nothing received yet.
|
||||
none,
|
||||
|
||||
/// Real ECU readings are arriving.
|
||||
live,
|
||||
|
||||
/// The adapter answers, but only with its built-in default frame
|
||||
/// (hvbt_protocol.pdf example values) — it has no fresh ECU data.
|
||||
adapterDefaults,
|
||||
|
||||
/// The adapter is not answering requests.
|
||||
noResponse,
|
||||
}
|
||||
|
||||
// ─── State ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class BtState {
|
||||
final BtStatus status;
|
||||
final String? deviceName;
|
||||
final String? error;
|
||||
final EcuDataStatus dataStatus;
|
||||
final int requestsSent;
|
||||
final int frameCount; // valid frames (live + default)
|
||||
final int liveFrames;
|
||||
final int rpmGlitches; // frames dropped for an implausible RPM jump
|
||||
final int defaultFrames;
|
||||
final int droppedFrames; // bad checksum
|
||||
final int timeouts;
|
||||
final int reconnects;
|
||||
final int? lastResponseMs;
|
||||
final Uint8List? lastFrame;
|
||||
|
||||
const BtState({
|
||||
required this.status,
|
||||
this.deviceName,
|
||||
this.error,
|
||||
this.dataStatus = EcuDataStatus.none,
|
||||
this.requestsSent = 0,
|
||||
this.frameCount = 0,
|
||||
this.liveFrames = 0,
|
||||
this.rpmGlitches = 0,
|
||||
this.defaultFrames = 0,
|
||||
this.droppedFrames = 0,
|
||||
this.timeouts = 0,
|
||||
this.reconnects = 0,
|
||||
this.lastResponseMs,
|
||||
this.lastFrame,
|
||||
});
|
||||
|
||||
const BtState.disconnected() : this(status: BtStatus.disconnected);
|
||||
|
||||
BtState copyWith({
|
||||
BtStatus? status,
|
||||
String? deviceName,
|
||||
String? error,
|
||||
bool clearError = false,
|
||||
EcuDataStatus? dataStatus,
|
||||
int? requestsSent,
|
||||
int? frameCount,
|
||||
int? liveFrames,
|
||||
int? rpmGlitches,
|
||||
int? defaultFrames,
|
||||
int? droppedFrames,
|
||||
int? timeouts,
|
||||
int? reconnects,
|
||||
int? lastResponseMs,
|
||||
Uint8List? lastFrame,
|
||||
}) =>
|
||||
BtState(
|
||||
status: status ?? this.status,
|
||||
deviceName: deviceName ?? this.deviceName,
|
||||
error: clearError ? null : (error ?? this.error),
|
||||
dataStatus: dataStatus ?? this.dataStatus,
|
||||
requestsSent: requestsSent ?? this.requestsSent,
|
||||
frameCount: frameCount ?? this.frameCount,
|
||||
liveFrames: liveFrames ?? this.liveFrames,
|
||||
rpmGlitches: rpmGlitches ?? this.rpmGlitches,
|
||||
defaultFrames: defaultFrames ?? this.defaultFrames,
|
||||
droppedFrames: droppedFrames ?? this.droppedFrames,
|
||||
timeouts: timeouts ?? this.timeouts,
|
||||
reconnects: reconnects ?? this.reconnects,
|
||||
lastResponseMs: lastResponseMs ?? this.lastResponseMs,
|
||||
lastFrame: lastFrame ?? this.lastFrame,
|
||||
);
|
||||
|
||||
bool get isConnected => status == BtStatus.connected;
|
||||
bool get isConnecting =>
|
||||
status == BtStatus.connecting || status == BtStatus.reconnecting;
|
||||
}
|
||||
|
||||
// ─── Notifier ────────────────────────────────────────────────────────────────
|
||||
|
||||
class BtNotifier extends StateNotifier<BtState> {
|
||||
BtNotifier(this._ref) : super(const BtState.disconnected()) {
|
||||
// Apply polling-interval changes from Settings to a running poller.
|
||||
_ref.listen<AppSettings>(settingsProvider, (prev, next) {
|
||||
_poller?.pollingInterval = next.pollingInterval;
|
||||
_poller?.ecuType = next.ecuType;
|
||||
});
|
||||
}
|
||||
|
||||
final Ref _ref;
|
||||
BtPoller? _poller;
|
||||
StreamSubscription<Uint8List>? _pollSub;
|
||||
Timer? _statusTimer;
|
||||
final StreamController<Uint8List> _frameController =
|
||||
StreamController<Uint8List>.broadcast();
|
||||
|
||||
BluetoothDevice? _device;
|
||||
bool _userDisconnected = false;
|
||||
bool _reconnecting = false;
|
||||
// Bumped on every user connect/disconnect. A link opened by an older
|
||||
// session (e.g. an auto-reconnect still in flight when the user tapped
|
||||
// Disconnect) is closed instead of being adopted.
|
||||
int _session = 0;
|
||||
DateTime? _lastAnyFrameAt;
|
||||
DateTime? _lastLiveFrameAt;
|
||||
DateTime? _defaultsSince;
|
||||
DateTime? _lastAutoReconnectAt;
|
||||
int _liveFrames = 0;
|
||||
int _defaultFrames = 0;
|
||||
int _reconnects = 0;
|
||||
|
||||
static const _staleAfter = Duration(milliseconds: 1500);
|
||||
static const _defaultsReconnectAfter = Duration(seconds: 8);
|
||||
static const _minTimeBetweenAutoReconnects = Duration(seconds: 45);
|
||||
|
||||
/// Validated 128-byte frames carrying REAL ECU data (adapter default frames
|
||||
/// are filtered out). Consumed by sensorStateProvider and the datalog.
|
||||
Stream<Uint8List> get frameStream => _frameController.stream;
|
||||
|
||||
BtPoller? get poller => _poller;
|
||||
|
||||
Future<void> connect(BluetoothDevice device) async {
|
||||
if (state.isConnecting) return; // ignore double taps
|
||||
final session = ++_session;
|
||||
// The adapter's name says which ECU it is wired to; a stale saved
|
||||
// setting for the other ECU makes every request go unanswered.
|
||||
final inferred = ecuTypeFromDeviceName(device.name);
|
||||
if (inferred != null && inferred != _ref.read(settingsProvider).ecuType) {
|
||||
AppLog.i('ECU protocol set to ${inferred.name} from adapter name '
|
||||
'"${device.name}"');
|
||||
_ref.read(settingsProvider.notifier).setEcuType(inferred);
|
||||
}
|
||||
_device = device;
|
||||
_userDisconnected = false;
|
||||
_resetCounters();
|
||||
state = BtState(
|
||||
status: BtStatus.connecting,
|
||||
deviceName: device.name ?? device.address,
|
||||
);
|
||||
try {
|
||||
await _openLink(device);
|
||||
if (session != _session || !mounted) {
|
||||
AppLog.i('Connect finished after disconnect — closing link');
|
||||
await _closeLink();
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(status: BtStatus.connected, clearError: true);
|
||||
} catch (e, st) {
|
||||
AppLog.e('Connect failed', e, st);
|
||||
if (session != _session || !mounted) return;
|
||||
await _closeLink();
|
||||
state = state.copyWith(status: BtStatus.error, error: _friendly(e));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
_session++;
|
||||
_userDisconnected = true;
|
||||
await _closeLink();
|
||||
state = const BtState.disconnected();
|
||||
}
|
||||
|
||||
// ── Link management ───────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _openLink(BluetoothDevice device) async {
|
||||
final service = _ref.read(btServiceProvider);
|
||||
final settings = _ref.read(settingsProvider);
|
||||
await service.connect(device);
|
||||
|
||||
final poller = BtPoller(
|
||||
service,
|
||||
ecuType: settings.ecuType,
|
||||
pollingInterval: settings.pollingInterval,
|
||||
onLinkStalled: () => _onLinkProblem('adapter stopped answering'),
|
||||
);
|
||||
_poller = poller;
|
||||
_pollSub = poller.frameStream.listen(
|
||||
_onFrame,
|
||||
onError: (Object e) => _onLinkProblem('$e'),
|
||||
);
|
||||
poller.start();
|
||||
|
||||
_statusTimer?.cancel();
|
||||
_statusTimer =
|
||||
Timer.periodic(const Duration(milliseconds: 500), (_) => _tick());
|
||||
}
|
||||
|
||||
Future<void> _closeLink() async {
|
||||
_statusTimer?.cancel();
|
||||
_statusTimer = null;
|
||||
await _pollSub?.cancel();
|
||||
_pollSub = null;
|
||||
_poller?.dispose();
|
||||
_poller = null;
|
||||
await _ref.read(btServiceProvider).disconnect();
|
||||
}
|
||||
|
||||
void _resetCounters() {
|
||||
_rpmFilter.reset();
|
||||
_rpmFilter.dropped = 0;
|
||||
_glitchesLogged = 0;
|
||||
_lastAnyFrameAt = null;
|
||||
_lastLiveFrameAt = null;
|
||||
_defaultsSince = null;
|
||||
_liveFrames = 0;
|
||||
_defaultFrames = 0;
|
||||
}
|
||||
|
||||
void _onFrame(Uint8List frame) {
|
||||
if (!mounted) return;
|
||||
final now = DateTime.now();
|
||||
_lastAnyFrameAt = now;
|
||||
final ecuType = _ref.read(settingsProvider).ecuType;
|
||||
final isDefault = ecuType == EcuType.s300 && isS300DefaultFrame(frame);
|
||||
if (isDefault) {
|
||||
_defaultFrames++;
|
||||
if (_defaultFrames == 1 || _defaultFrames % 50 == 0) {
|
||||
AppLog.w('Adapter returned its default (no-ECU-data) frame '
|
||||
'(count=$_defaultFrames)');
|
||||
}
|
||||
} else if (!_rpmFilter.accept(frame, now)) {
|
||||
// Implausible RPM jump (e.g. crank-signal dropout, P0336 on the KPro
|
||||
// bench): drop the whole frame and keep showing the last good one,
|
||||
// as the USB tool does. Counted for the Debug screen.
|
||||
final n = _rpmFilter.dropped;
|
||||
if (n == 1 || n % 100 == 0) {
|
||||
AppLog.w('RPM glitch frame dropped '
|
||||
'(${RpmGlitchFilter.rpmOf(frame).round()} rpm, count=$n)');
|
||||
}
|
||||
if (_glitchesLogged < 2) {
|
||||
_glitchesLogged++;
|
||||
AppLog.w('Glitch frame: ${[
|
||||
for (final v in frame) v.toRadixString(16).padLeft(2, '0')
|
||||
].join(' ')}');
|
||||
}
|
||||
} else {
|
||||
_liveFrames++;
|
||||
_lastLiveFrameAt = now;
|
||||
_maybeLogFrame(frame, now);
|
||||
_defaultsSince = null;
|
||||
_frameController.add(frame);
|
||||
}
|
||||
_publishStats(frame);
|
||||
}
|
||||
|
||||
DateTime? _lastFrameLogAt;
|
||||
late final _rpmFilter = RpmGlitchFilter(
|
||||
onConfirmed: (from, to) => AppLog.i(
|
||||
'RPM change ${from.round()} -> ${to.round()} accepted (majority of recent frames)'),
|
||||
);
|
||||
int _glitchesLogged = 0;
|
||||
|
||||
/// Raw hex of one live frame every 5 s, so bench readings from the USB
|
||||
/// tool can be matched to exact bytes (Debug → Copy app log, or logcat).
|
||||
void _maybeLogFrame(Uint8List frame, DateTime now) {
|
||||
final last = _lastFrameLogAt;
|
||||
if (last != null && now.difference(last) < const Duration(seconds: 5)) {
|
||||
return;
|
||||
}
|
||||
_lastFrameLogAt = now;
|
||||
final hex = StringBuffer();
|
||||
for (var i = 0; i < 64; i++) {
|
||||
if (i > 0) hex.write(i % 16 == 0 ? ' | ' : ' ');
|
||||
hex.write(frame[i].toRadixString(16).padLeft(2, '0'));
|
||||
}
|
||||
AppLog.i('FRAME[0..63] $hex');
|
||||
}
|
||||
|
||||
void _publishStats([Uint8List? frame]) {
|
||||
final p = _poller;
|
||||
state = state.copyWith(
|
||||
requestsSent: p?.requestsSent,
|
||||
frameCount: p?.validFrames,
|
||||
liveFrames: _liveFrames,
|
||||
rpmGlitches: _rpmFilter.dropped,
|
||||
defaultFrames: _defaultFrames,
|
||||
droppedFrames: p?.droppedFrames,
|
||||
timeouts: p?.timeouts,
|
||||
reconnects: _reconnects,
|
||||
lastResponseMs: p?.lastResponseTime?.inMilliseconds,
|
||||
lastFrame: frame,
|
||||
dataStatus: _computeDataStatus(),
|
||||
);
|
||||
}
|
||||
|
||||
EcuDataStatus _computeDataStatus() {
|
||||
final now = DateTime.now();
|
||||
bool recent(DateTime? t) => t != null && now.difference(t) < _staleAfter;
|
||||
if (recent(_lastLiveFrameAt)) return EcuDataStatus.live;
|
||||
if (recent(_lastAnyFrameAt)) return EcuDataStatus.adapterDefaults;
|
||||
if (_poller?.isRunning ?? false) return EcuDataStatus.noResponse;
|
||||
return EcuDataStatus.none;
|
||||
}
|
||||
|
||||
void _tick() {
|
||||
if (!mounted || _poller == null) return;
|
||||
final status = _computeDataStatus();
|
||||
|
||||
// Adapter answering only with defaults for a long stretch: one reconnect
|
||||
// has been seen to clear this (see docs/HANDOFF.md), but never loop on it.
|
||||
if (status == EcuDataStatus.adapterDefaults) {
|
||||
_defaultsSince ??= DateTime.now();
|
||||
if (DateTime.now().difference(_defaultsSince!) > _defaultsReconnectAfter &&
|
||||
_autoReconnectAllowed()) {
|
||||
_onLinkProblem('only default frames for '
|
||||
'${_defaultsReconnectAfter.inSeconds}s');
|
||||
}
|
||||
} else {
|
||||
_defaultsSince = null;
|
||||
}
|
||||
_publishStats();
|
||||
}
|
||||
|
||||
bool _autoReconnectAllowed() {
|
||||
final last = _lastAutoReconnectAt;
|
||||
return last == null ||
|
||||
DateTime.now().difference(last) > _minTimeBetweenAutoReconnects;
|
||||
}
|
||||
|
||||
Future<void> _onLinkProblem(String reason) async {
|
||||
if (_reconnecting || _userDisconnected || _device == null || !mounted) {
|
||||
return;
|
||||
}
|
||||
final service = _ref.read(btServiceProvider);
|
||||
// A stall on a link that is still up: only reconnect occasionally; the
|
||||
// poller keeps trying at its timeout pace in between.
|
||||
if (service.isConnected && !_autoReconnectAllowed()) {
|
||||
AppLog.w('Link problem ($reason) — keeping link, recent reconnect');
|
||||
return;
|
||||
}
|
||||
final session = _session;
|
||||
bool stillWanted() => session == _session && !_userDisconnected && mounted;
|
||||
_reconnecting = true;
|
||||
_lastAutoReconnectAt = DateTime.now();
|
||||
AppLog.w('Link problem: $reason — reconnecting');
|
||||
state = state.copyWith(status: BtStatus.reconnecting);
|
||||
await _closeLink();
|
||||
|
||||
for (var attempt = 1; attempt <= 5; attempt++) {
|
||||
await Future<void>.delayed(Duration(seconds: attempt));
|
||||
if (!stillWanted()) break;
|
||||
try {
|
||||
_resetCounters();
|
||||
await _openLink(_device!);
|
||||
if (!stillWanted()) {
|
||||
// User disconnected while this attempt was in flight.
|
||||
AppLog.i('Reconnect finished after disconnect — closing link');
|
||||
await _closeLink();
|
||||
break;
|
||||
}
|
||||
_reconnects++;
|
||||
AppLog.i('Reconnected (attempt $attempt)');
|
||||
_reconnecting = false;
|
||||
state = state.copyWith(status: BtStatus.connected, clearError: true);
|
||||
return;
|
||||
} catch (e) {
|
||||
AppLog.w('Reconnect attempt $attempt failed: $e');
|
||||
// Don't tear down a link that a newer session may own.
|
||||
if (!stillWanted()) break;
|
||||
await _closeLink();
|
||||
}
|
||||
}
|
||||
_reconnecting = false;
|
||||
if (stillWanted()) {
|
||||
state = state.copyWith(
|
||||
status: BtStatus.error,
|
||||
error: 'Connection lost. Check the adapter and connect again.',
|
||||
dataStatus: EcuDataStatus.none,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static String _friendly(Object e) {
|
||||
final s = e.toString();
|
||||
if (s.contains('timed out')) return s.replaceFirst('Exception: ', '');
|
||||
if (s.contains('read failed') || s.contains('socket')) {
|
||||
return 'Could not open the Bluetooth link. Make sure the adapter is '
|
||||
'powered and not connected to another phone, then try again.';
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// ── One-off commands ──────────────────────────────────────────────────────
|
||||
|
||||
/// Diagnostic: Hondata official-protocol DL_DeviceInfo probe. Runs between
|
||||
/// polls via the poller's link lock, so it never overlaps a request.
|
||||
Future<Uint8List> probeOfficialProtocol() async {
|
||||
final p = _poller;
|
||||
if (p == null) return Uint8List(0);
|
||||
return p.sendCommandCollect(
|
||||
Uint8List.fromList([0x00, 0x02, 0x04, 0x00]),
|
||||
const Duration(milliseconds: 800),
|
||||
);
|
||||
}
|
||||
|
||||
/// DTC reset per hvbt_protocol.pdf: { 0x1B, 0x70, NEG8 }; the adapter should
|
||||
/// echo the same 3 bytes as acknowledgement.
|
||||
///
|
||||
/// The adapter never sends that ack (confirmed on the bench), so success is
|
||||
/// judged from the data: after sending, polling resumes and the ERR bytes
|
||||
/// of the live frames are watched for [_dtcWatch]. If any code that was
|
||||
/// stored before disappears, the ECU cleared. Codes for faults that are
|
||||
/// still present come straight back (often before the first frame), just
|
||||
/// as with the USB tool, so "all zero" is not required. The ack wait is
|
||||
/// kept short because the adapter stops reading the ECU after ~1 s without
|
||||
/// datalog requests (Bluetooth.pdf §2.4).
|
||||
///
|
||||
/// [codesBefore]/[codesCleared] count stored code bits before the command
|
||||
/// and how many of them were seen cleared.
|
||||
Future<
|
||||
({
|
||||
bool acknowledged,
|
||||
int codesBefore,
|
||||
int codesCleared,
|
||||
Uint8List raw,
|
||||
})> clearDtcs() async {
|
||||
final p = _poller;
|
||||
if (p == null) {
|
||||
return (
|
||||
acknowledged: false,
|
||||
codesBefore: 0,
|
||||
codesCleared: 0,
|
||||
raw: Uint8List(0),
|
||||
);
|
||||
}
|
||||
final ecuType = _ref.read(settingsProvider).ecuType;
|
||||
// ERR00.. bytes: S300 37-40, KPro 62-79 (hvbt_protocol.pdf).
|
||||
List<int> errOf(Uint8List f) =>
|
||||
ecuType == EcuType.s300 ? f.sublist(37, 41) : f.sublist(62, 80);
|
||||
String hex(List<int> b) =>
|
||||
b.map((v) => v.toRadixString(16).padLeft(2, '0')).join(' ');
|
||||
int bits(int v) => v.toRadixString(2).replaceAll('0', '').length;
|
||||
|
||||
final beforeFrame = state.lastFrame;
|
||||
final before = beforeFrame == null ? null : errOf(beforeFrame);
|
||||
final codesBefore = before == null ? 0 : before.fold(0, (n, b) => n + bits(b));
|
||||
AppLog.i('Clear DTC: ERR bytes before = ${before == null ? "?" : hex(before)}');
|
||||
|
||||
// Bits stored before that have been seen clear at least once.
|
||||
final clearedMask = List<int>.filled(before?.length ?? 0, 0);
|
||||
String? lastErr = before == null ? null : hex(before);
|
||||
final watchStart = DateTime.now();
|
||||
final sub = frameStream.listen((f) {
|
||||
final err = errOf(f);
|
||||
final h = hex(err);
|
||||
if (h != lastErr) {
|
||||
final ms = DateTime.now().difference(watchStart).inMilliseconds;
|
||||
AppLog.i('Clear DTC: +$ms ms ERR bytes = $h');
|
||||
lastErr = h;
|
||||
}
|
||||
if (before != null) {
|
||||
for (var i = 0; i < before.length; i++) {
|
||||
clearedMask[i] |= before[i] & ~err[i];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
final command = Uint8List.fromList([0x1B, 0x70, 0x00]);
|
||||
command[2] = calculateNeg8(Uint8List.fromList([0x1B, 0x70]));
|
||||
final result = await p.sendCommandAwaitReply(
|
||||
command,
|
||||
command,
|
||||
timeout: const Duration(milliseconds: 600),
|
||||
);
|
||||
await Future<void>.delayed(_dtcWatch);
|
||||
await sub.cancel();
|
||||
|
||||
final codesCleared = clearedMask.fold(0, (n, b) => n + bits(b));
|
||||
AppLog.i('Clear DTC: ack=${result.acknowledged} '
|
||||
'rxBytes=${result.raw.length} '
|
||||
'codesCleared=$codesCleared/$codesBefore '
|
||||
'ERR bytes after = ${lastErr ?? "?"}');
|
||||
return (
|
||||
acknowledged: result.acknowledged,
|
||||
codesBefore: codesBefore,
|
||||
codesCleared: codesCleared,
|
||||
raw: result.raw,
|
||||
);
|
||||
}
|
||||
|
||||
static const _dtcWatch = Duration(seconds: 5);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_statusTimer?.cancel();
|
||||
_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). Only watched after
|
||||
/// Bluetooth permissions are granted.
|
||||
final pairedDevicesProvider = FutureProvider<List<BluetoothDevice>>((ref) {
|
||||
return FlutterBluetoothSerial.instance.getBondedDevices();
|
||||
});
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../bluetooth/bt_poller.dart';
|
||||
import '../bluetooth/bt_service.dart';
|
||||
import '../protocol/neg8.dart';
|
||||
import 'settings_provider.dart';
|
||||
|
||||
// ─── Connection status enum ──────────────────────────────────────────────────
|
||||
|
||||
enum BtStatus { disconnected, connecting, connected, error }
|
||||
|
||||
// ─── State ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class BtState {
|
||||
final BtStatus status;
|
||||
final String? deviceName;
|
||||
final String? error;
|
||||
final int frameCount;
|
||||
final int droppedFrames;
|
||||
final Uint8List? lastFrame;
|
||||
|
||||
const BtState({
|
||||
required this.status,
|
||||
this.deviceName,
|
||||
this.error,
|
||||
this.frameCount = 0,
|
||||
this.droppedFrames = 0,
|
||||
this.lastFrame,
|
||||
});
|
||||
|
||||
const BtState.disconnected() : this(status: BtStatus.disconnected);
|
||||
|
||||
BtState connecting(String name) =>
|
||||
BtState(status: BtStatus.connecting, deviceName: name);
|
||||
|
||||
BtState connected(String name) =>
|
||||
BtState(status: BtStatus.connected, deviceName: name);
|
||||
|
||||
BtState withError(String msg) => BtState(
|
||||
status: BtStatus.error,
|
||||
deviceName: deviceName,
|
||||
error: msg,
|
||||
frameCount: frameCount,
|
||||
droppedFrames: droppedFrames,
|
||||
lastFrame: lastFrame,
|
||||
);
|
||||
|
||||
BtState withFrameStats(int frames, int dropped, Uint8List frame) => BtState(
|
||||
status: status,
|
||||
deviceName: deviceName,
|
||||
frameCount: frames,
|
||||
droppedFrames: dropped,
|
||||
lastFrame: Uint8List.fromList(frame),
|
||||
);
|
||||
|
||||
bool get isConnected => status == BtStatus.connected;
|
||||
bool get isConnecting => status == BtStatus.connecting;
|
||||
}
|
||||
|
||||
// ─── Notifier ────────────────────────────────────────────────────────────────
|
||||
|
||||
class BtNotifier extends StateNotifier<BtState> {
|
||||
final Ref _ref;
|
||||
BtPoller? _poller;
|
||||
StreamSubscription<Uint8List>? _pollSub;
|
||||
final StreamController<Uint8List> _frameController =
|
||||
StreamController<Uint8List>.broadcast();
|
||||
|
||||
BtNotifier(this._ref) : super(const BtState.disconnected());
|
||||
|
||||
/// 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);
|
||||
final service = _ref.read(btServiceProvider);
|
||||
final settings = _ref.read(settingsProvider);
|
||||
|
||||
try {
|
||||
await service.connect(device);
|
||||
|
||||
_poller = BtPoller(
|
||||
service,
|
||||
ecuType: settings.ecuType,
|
||||
pollingInterval: settings.pollingInterval,
|
||||
);
|
||||
|
||||
_pollSub = _poller!.frameStream.listen(
|
||||
(frame) {
|
||||
_frameController.add(frame);
|
||||
state = state.withFrameStats(
|
||||
_poller!.validFrames,
|
||||
_poller!.droppedFrames,
|
||||
frame,
|
||||
);
|
||||
},
|
||||
onError: (Object e) {
|
||||
_frameController.addError(e);
|
||||
state = state.withError(e.toString());
|
||||
},
|
||||
onDone: () => state = state.withError('BT stream closed'),
|
||||
);
|
||||
|
||||
_poller!.start();
|
||||
state = state.connected(device.name ?? device.address);
|
||||
} catch (e) {
|
||||
await service.disconnect();
|
||||
state = state.withError(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// One-off diagnostic: sends the official Hondata Bluetooth protocol's
|
||||
/// DL_DeviceInfo command ({ Protocol=0x00, ID=0x02, Size=0x0004 LE }) and
|
||||
/// returns whatever raw bytes come back within the response window. Used
|
||||
/// to check whether this device/firmware understands the newer,
|
||||
/// self-describing channel-based protocol (Bluetooth.pdf) as opposed to
|
||||
/// 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.
|
||||
Future<Uint8List> probeOfficialProtocol() async {
|
||||
final service = _ref.read(btServiceProvider);
|
||||
if (!service.isConnected) return Uint8List(0);
|
||||
|
||||
_poller?.stop();
|
||||
|
||||
// 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
|
||||
// drain and discard whatever arrives here before sending the probe, or
|
||||
// that leftover legacy frame gets mistaken for the probe's response.
|
||||
final drainSub = service.rawStream.listen((_) {});
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
await drainSub.cancel();
|
||||
|
||||
final collected = <int>[];
|
||||
final sub = service.rawStream.listen((chunk) => collected.addAll(chunk));
|
||||
|
||||
try {
|
||||
await service.write(Uint8List.fromList([0x00, 0x02, 0x04, 0x00]));
|
||||
await Future.delayed(const Duration(milliseconds: 800));
|
||||
} finally {
|
||||
await sub.cancel();
|
||||
_poller?.start();
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
@ -1,225 +1,206 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../datalog/datalog_db.dart';
|
||||
import '../datalog/datalog_player.dart';
|
||||
import '../datalog/datalog_recorder.dart';
|
||||
import '../models/datalog_session.dart';
|
||||
import '../bluetooth/bt_poller.dart';
|
||||
import 'bt_provider.dart';
|
||||
import 'sensor_history_provider.dart';
|
||||
import 'settings_provider.dart';
|
||||
|
||||
// ─── Singletons ───────────────────────────────────────────────────────────────
|
||||
|
||||
final datalogDbProvider = Provider<DatalogDb>((ref) {
|
||||
final db = DatalogDb();
|
||||
ref.onDispose(db.close);
|
||||
return db;
|
||||
});
|
||||
|
||||
final datalogRecorderProvider = Provider<DatalogRecorder>((ref) {
|
||||
final recorder = DatalogRecorder(ref.watch(datalogDbProvider));
|
||||
ref.onDispose(recorder.dispose);
|
||||
return recorder;
|
||||
});
|
||||
|
||||
final datalogPlayerProvider = Provider<DatalogPlayer>((ref) {
|
||||
final player = DatalogPlayer(ref.watch(datalogDbProvider));
|
||||
ref.onDispose(player.dispose);
|
||||
return player;
|
||||
});
|
||||
|
||||
// ─── Recording state ──────────────────────────────────────────────────────────
|
||||
|
||||
enum RecordingStatus { idle, recording }
|
||||
|
||||
class RecordingState {
|
||||
final RecordingStatus status;
|
||||
final int frameCount;
|
||||
final int? sessionId;
|
||||
|
||||
const RecordingState({
|
||||
this.status = RecordingStatus.idle,
|
||||
this.frameCount = 0,
|
||||
this.sessionId,
|
||||
});
|
||||
|
||||
bool get isRecording => status == RecordingStatus.recording;
|
||||
|
||||
RecordingState copyWith({
|
||||
RecordingStatus? status,
|
||||
int? frameCount,
|
||||
int? sessionId,
|
||||
}) =>
|
||||
RecordingState(
|
||||
status: status ?? this.status,
|
||||
frameCount: frameCount ?? this.frameCount,
|
||||
sessionId: sessionId ?? this.sessionId,
|
||||
);
|
||||
}
|
||||
|
||||
class RecordingNotifier extends StateNotifier<RecordingState> {
|
||||
final Ref _ref;
|
||||
|
||||
RecordingNotifier(this._ref) : super(const RecordingState());
|
||||
|
||||
Future<void> startRecording() async {
|
||||
if (state.isRecording) return;
|
||||
|
||||
final recorder = _ref.read(datalogRecorderProvider);
|
||||
final btNotifier = _ref.read(btProvider.notifier);
|
||||
final ecuType = _ref.read(settingsProvider).ecuType.name;
|
||||
|
||||
await recorder.start(btNotifier.frameStream, ecuType);
|
||||
|
||||
state = RecordingState(
|
||||
status: RecordingStatus.recording,
|
||||
sessionId: recorder.sessionId,
|
||||
);
|
||||
}
|
||||
|
||||
Future<DatalogSession?> stopRecording() async {
|
||||
if (!state.isRecording) return null;
|
||||
|
||||
final recorder = _ref.read(datalogRecorderProvider);
|
||||
final session = await recorder.stop();
|
||||
|
||||
state = const RecordingState();
|
||||
// Refresh session list
|
||||
_ref.invalidate(sessionListProvider);
|
||||
return session;
|
||||
}
|
||||
|
||||
/// Called periodically from UI to update live frame count display.
|
||||
void syncFrameCount() {
|
||||
if (!state.isRecording) return;
|
||||
final count = _ref.read(datalogRecorderProvider).frameCount;
|
||||
if (count != state.frameCount) {
|
||||
state = state.copyWith(frameCount: count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final recordingProvider =
|
||||
StateNotifierProvider<RecordingNotifier, RecordingState>(
|
||||
(ref) => RecordingNotifier(ref),
|
||||
);
|
||||
|
||||
// ─── Session list ─────────────────────────────────────────────────────────────
|
||||
|
||||
final sessionListProvider = FutureProvider<List<DatalogSession>>((ref) {
|
||||
return ref.watch(datalogDbProvider).getSessions();
|
||||
});
|
||||
|
||||
// ─── Playback state ───────────────────────────────────────────────────────────
|
||||
|
||||
class PlaybackState {
|
||||
final bool isActive;
|
||||
final int? sessionId;
|
||||
/// ECU type the session was recorded with; frames are parsed with it.
|
||||
final EcuType ecuType;
|
||||
final int position;
|
||||
final int total;
|
||||
final double speed;
|
||||
final PlaybackStatus status;
|
||||
|
||||
const PlaybackState({
|
||||
this.isActive = false,
|
||||
this.sessionId,
|
||||
this.ecuType = EcuType.s300,
|
||||
this.position = 0,
|
||||
this.total = 0,
|
||||
this.speed = 1.0,
|
||||
this.status = PlaybackStatus.idle,
|
||||
});
|
||||
|
||||
double get progress => total == 0 ? 0 : position / total;
|
||||
|
||||
PlaybackState copyWith({
|
||||
bool? isActive,
|
||||
int? sessionId,
|
||||
int? position,
|
||||
int? total,
|
||||
double? speed,
|
||||
PlaybackStatus? status,
|
||||
}) =>
|
||||
PlaybackState(
|
||||
isActive: isActive ?? this.isActive,
|
||||
sessionId: sessionId ?? this.sessionId,
|
||||
ecuType: ecuType,
|
||||
position: position ?? this.position,
|
||||
total: total ?? this.total,
|
||||
speed: speed ?? this.speed,
|
||||
status: status ?? this.status,
|
||||
);
|
||||
}
|
||||
|
||||
class PlaybackNotifier extends StateNotifier<PlaybackState> {
|
||||
final Ref _ref;
|
||||
|
||||
PlaybackNotifier(this._ref) : super(const PlaybackState());
|
||||
|
||||
/// Loads a recorded session; while active, the dashboard, sensor list,
|
||||
/// graph and DTC screen show the recording instead of live data.
|
||||
Future<void> loadSession(int sessionId, String ecuType) async {
|
||||
final player = _ref.read(datalogPlayerProvider);
|
||||
await player.load(sessionId);
|
||||
_ref.read(sensorHistoryProvider.notifier).clear();
|
||||
|
||||
player.onProgress = (pos, total) {
|
||||
state = state.copyWith(
|
||||
position: pos,
|
||||
total: total,
|
||||
status: player.status,
|
||||
);
|
||||
};
|
||||
|
||||
state = PlaybackState(
|
||||
isActive: true,
|
||||
sessionId: sessionId,
|
||||
ecuType: ecuType == EcuType.kpro.name ? EcuType.kpro : EcuType.s300,
|
||||
total: player.totalFrames,
|
||||
speed: player.speed,
|
||||
status: PlaybackStatus.idle,
|
||||
);
|
||||
}
|
||||
|
||||
void play() {
|
||||
_ref.read(datalogPlayerProvider).play();
|
||||
state = state.copyWith(status: PlaybackStatus.playing);
|
||||
}
|
||||
|
||||
void pause() {
|
||||
_ref.read(datalogPlayerProvider).pause();
|
||||
state = state.copyWith(status: PlaybackStatus.paused);
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
await _ref.read(datalogPlayerProvider).stop();
|
||||
state = const PlaybackState();
|
||||
_ref.read(sensorHistoryProvider.notifier).clear();
|
||||
}
|
||||
|
||||
void seek(int index) {
|
||||
_ref.read(datalogPlayerProvider).seek(index);
|
||||
state = state.copyWith(position: index);
|
||||
}
|
||||
|
||||
void setSpeed(double speed) {
|
||||
_ref.read(datalogPlayerProvider).speed = speed;
|
||||
state = state.copyWith(speed: speed);
|
||||
}
|
||||
}
|
||||
|
||||
final playbackProvider =
|
||||
StateNotifierProvider<PlaybackNotifier, PlaybackState>(
|
||||
(ref) => PlaybackNotifier(ref),
|
||||
);
|
||||
|
||||
/// ECU type the displays should decode with: the recorded session's while a
|
||||
/// datalog is playing back, otherwise the live setting.
|
||||
final effectiveEcuTypeProvider = Provider<EcuType>((ref) {
|
||||
final playback = ref.watch(playbackProvider);
|
||||
if (playback.isActive) return playback.ecuType;
|
||||
return ref.watch(settingsProvider.select((s) => s.ecuType));
|
||||
});
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../datalog/datalog_db.dart';
|
||||
import '../datalog/datalog_player.dart';
|
||||
import '../datalog/datalog_recorder.dart';
|
||||
import '../models/datalog_session.dart';
|
||||
import 'bt_provider.dart';
|
||||
import 'settings_provider.dart';
|
||||
|
||||
// ─── Singletons ───────────────────────────────────────────────────────────────
|
||||
|
||||
final datalogDbProvider = Provider<DatalogDb>((ref) {
|
||||
final db = DatalogDb();
|
||||
ref.onDispose(db.close);
|
||||
return db;
|
||||
});
|
||||
|
||||
final datalogRecorderProvider = Provider<DatalogRecorder>((ref) {
|
||||
final recorder = DatalogRecorder(ref.watch(datalogDbProvider));
|
||||
ref.onDispose(recorder.dispose);
|
||||
return recorder;
|
||||
});
|
||||
|
||||
final datalogPlayerProvider = Provider<DatalogPlayer>((ref) {
|
||||
final player = DatalogPlayer(ref.watch(datalogDbProvider));
|
||||
ref.onDispose(player.dispose);
|
||||
return player;
|
||||
});
|
||||
|
||||
// ─── Recording state ──────────────────────────────────────────────────────────
|
||||
|
||||
enum RecordingStatus { idle, recording }
|
||||
|
||||
class RecordingState {
|
||||
final RecordingStatus status;
|
||||
final int frameCount;
|
||||
final int? sessionId;
|
||||
|
||||
const RecordingState({
|
||||
this.status = RecordingStatus.idle,
|
||||
this.frameCount = 0,
|
||||
this.sessionId,
|
||||
});
|
||||
|
||||
bool get isRecording => status == RecordingStatus.recording;
|
||||
|
||||
RecordingState copyWith({
|
||||
RecordingStatus? status,
|
||||
int? frameCount,
|
||||
int? sessionId,
|
||||
}) =>
|
||||
RecordingState(
|
||||
status: status ?? this.status,
|
||||
frameCount: frameCount ?? this.frameCount,
|
||||
sessionId: sessionId ?? this.sessionId,
|
||||
);
|
||||
}
|
||||
|
||||
class RecordingNotifier extends StateNotifier<RecordingState> {
|
||||
final Ref _ref;
|
||||
|
||||
RecordingNotifier(this._ref) : super(const RecordingState());
|
||||
|
||||
Future<void> startRecording() async {
|
||||
if (state.isRecording) return;
|
||||
|
||||
final recorder = _ref.read(datalogRecorderProvider);
|
||||
final btNotifier = _ref.read(btProvider.notifier);
|
||||
final ecuType = _ref.read(settingsProvider).ecuType.name;
|
||||
|
||||
await recorder.start(btNotifier.frameStream, ecuType);
|
||||
|
||||
state = RecordingState(
|
||||
status: RecordingStatus.recording,
|
||||
sessionId: recorder.sessionId,
|
||||
);
|
||||
}
|
||||
|
||||
Future<DatalogSession?> stopRecording() async {
|
||||
if (!state.isRecording) return null;
|
||||
|
||||
final recorder = _ref.read(datalogRecorderProvider);
|
||||
final session = await recorder.stop();
|
||||
|
||||
state = const RecordingState();
|
||||
// Refresh session list
|
||||
_ref.invalidate(sessionListProvider);
|
||||
return session;
|
||||
}
|
||||
|
||||
/// Called periodically from UI to update live frame count display.
|
||||
void syncFrameCount() {
|
||||
if (!state.isRecording) return;
|
||||
final count = _ref.read(datalogRecorderProvider).frameCount;
|
||||
if (count != state.frameCount) {
|
||||
state = state.copyWith(frameCount: count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final recordingProvider =
|
||||
StateNotifierProvider<RecordingNotifier, RecordingState>(
|
||||
(ref) => RecordingNotifier(ref),
|
||||
);
|
||||
|
||||
// ─── Session list ─────────────────────────────────────────────────────────────
|
||||
|
||||
final sessionListProvider = FutureProvider<List<DatalogSession>>((ref) {
|
||||
return ref.watch(datalogDbProvider).getSessions();
|
||||
});
|
||||
|
||||
// ─── Playback state ───────────────────────────────────────────────────────────
|
||||
|
||||
class PlaybackState {
|
||||
final bool isActive;
|
||||
final int? sessionId;
|
||||
final int position;
|
||||
final int total;
|
||||
final double speed;
|
||||
final PlaybackStatus status;
|
||||
|
||||
const PlaybackState({
|
||||
this.isActive = false,
|
||||
this.sessionId,
|
||||
this.position = 0,
|
||||
this.total = 0,
|
||||
this.speed = 1.0,
|
||||
this.status = PlaybackStatus.idle,
|
||||
});
|
||||
|
||||
double get progress => total == 0 ? 0 : position / total;
|
||||
|
||||
PlaybackState copyWith({
|
||||
bool? isActive,
|
||||
int? sessionId,
|
||||
int? position,
|
||||
int? total,
|
||||
double? speed,
|
||||
PlaybackStatus? status,
|
||||
}) =>
|
||||
PlaybackState(
|
||||
isActive: isActive ?? this.isActive,
|
||||
sessionId: sessionId ?? this.sessionId,
|
||||
position: position ?? this.position,
|
||||
total: total ?? this.total,
|
||||
speed: speed ?? this.speed,
|
||||
status: status ?? this.status,
|
||||
);
|
||||
}
|
||||
|
||||
class PlaybackNotifier extends StateNotifier<PlaybackState> {
|
||||
final Ref _ref;
|
||||
|
||||
PlaybackNotifier(this._ref) : super(const PlaybackState());
|
||||
|
||||
Future<void> loadSession(int sessionId) async {
|
||||
final player = _ref.read(datalogPlayerProvider);
|
||||
await player.load(sessionId);
|
||||
|
||||
player.onProgress = (pos, total) {
|
||||
state = state.copyWith(
|
||||
position: pos,
|
||||
total: total,
|
||||
status: player.status,
|
||||
);
|
||||
};
|
||||
|
||||
state = PlaybackState(
|
||||
isActive: true,
|
||||
sessionId: sessionId,
|
||||
total: player.totalFrames,
|
||||
speed: player.speed,
|
||||
status: PlaybackStatus.idle,
|
||||
);
|
||||
}
|
||||
|
||||
void play() {
|
||||
_ref.read(datalogPlayerProvider).play();
|
||||
state = state.copyWith(status: PlaybackStatus.playing);
|
||||
}
|
||||
|
||||
void pause() {
|
||||
_ref.read(datalogPlayerProvider).pause();
|
||||
state = state.copyWith(status: PlaybackStatus.paused);
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
await _ref.read(datalogPlayerProvider).stop();
|
||||
state = const PlaybackState();
|
||||
}
|
||||
|
||||
void seek(int index) {
|
||||
_ref.read(datalogPlayerProvider).seek(index);
|
||||
state = state.copyWith(position: index);
|
||||
}
|
||||
|
||||
void setSpeed(double speed) {
|
||||
_ref.read(datalogPlayerProvider).speed = speed;
|
||||
state = state.copyWith(speed: speed);
|
||||
}
|
||||
}
|
||||
|
||||
final playbackProvider =
|
||||
StateNotifierProvider<PlaybackNotifier, PlaybackState>(
|
||||
(ref) => PlaybackNotifier(ref),
|
||||
);
|
||||
|
||||
@ -1,72 +1,88 @@
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../models/sensor_defs.dart';
|
||||
import '../protocol/sensor_state.dart';
|
||||
import 'sensor_provider.dart';
|
||||
|
||||
const _maxPoints = 18000;
|
||||
|
||||
class SensorHistory {
|
||||
final Map<String, List<FlSpot>> _data = {
|
||||
for (final d in sensorDefs) d.id: [],
|
||||
};
|
||||
double? _t0;
|
||||
|
||||
void add(SensorState state) {
|
||||
final tSec = state.timestamp.millisecondsSinceEpoch / 1000.0;
|
||||
_t0 ??= tSec;
|
||||
final x = tSec - _t0!;
|
||||
|
||||
void push(String id, double v) {
|
||||
final list = _data[id]!;
|
||||
list.add(FlSpot(x, v));
|
||||
if (list.length > _maxPoints) list.removeAt(0);
|
||||
}
|
||||
|
||||
for (final d in sensorDefs) {
|
||||
push(d.id, sensorValue(state, d.id));
|
||||
}
|
||||
}
|
||||
|
||||
List<FlSpot> get(String id) => List.unmodifiable(_data[id] ?? []);
|
||||
|
||||
void clear() {
|
||||
for (final list in _data.values) {
|
||||
list.clear();
|
||||
}
|
||||
_t0 = null;
|
||||
}
|
||||
}
|
||||
|
||||
class SensorHistoryNotifier extends StateNotifier<SensorHistory> {
|
||||
SensorHistoryNotifier() : super(SensorHistory());
|
||||
|
||||
// History is mutated in place; StateNotifier's default identical() check
|
||||
// would swallow `state = state`, so the graph never redrew live.
|
||||
@override
|
||||
bool updateShouldNotify(SensorHistory old, SensorHistory current) => true;
|
||||
|
||||
void addState(SensorState s) {
|
||||
state.add(s);
|
||||
// trigger rebuild by reassigning same object
|
||||
// ignore: invalid_use_of_protected_member
|
||||
state = state;
|
||||
}
|
||||
|
||||
void clear() {
|
||||
state.clear();
|
||||
state = SensorHistory();
|
||||
}
|
||||
}
|
||||
|
||||
final sensorHistoryProvider =
|
||||
StateNotifierProvider<SensorHistoryNotifier, SensorHistory>((ref) {
|
||||
final notifier = SensorHistoryNotifier();
|
||||
|
||||
// Feed every parsed sensor state into history
|
||||
ref.listen<AsyncValue<SensorState>>(sensorStateProvider, (_, next) {
|
||||
next.whenData(notifier.addState);
|
||||
});
|
||||
|
||||
return notifier;
|
||||
});
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../models/sensor_defs.dart';
|
||||
import '../protocol/sensor_state.dart';
|
||||
import 'sensor_provider.dart';
|
||||
|
||||
const _maxPoints = 18000;
|
||||
|
||||
class SensorHistory {
|
||||
final Map<String, List<FlSpot>> _data = {
|
||||
for (final d in sensorDefs) d.id: [],
|
||||
};
|
||||
double? _t0;
|
||||
|
||||
void add(SensorState state) {
|
||||
final tSec = state.timestamp.millisecondsSinceEpoch / 1000.0;
|
||||
_t0 ??= tSec;
|
||||
final x = tSec - _t0!;
|
||||
|
||||
void push(String id, double v) {
|
||||
final list = _data[id]!;
|
||||
list.add(FlSpot(x, v));
|
||||
if (list.length > _maxPoints) list.removeAt(0);
|
||||
}
|
||||
|
||||
push('rpm', state.rpm);
|
||||
push('vss', state.vss);
|
||||
push('map', state.map);
|
||||
push('tps', state.tps);
|
||||
push('inj', state.inj);
|
||||
push('ign', state.ign);
|
||||
push('ect', state.ect);
|
||||
push('iat', state.iat);
|
||||
push('bat', state.bat);
|
||||
push('o2', state.o2);
|
||||
push('gear', state.gear.toDouble());
|
||||
push('eth', state.eth);
|
||||
push('pa', state.pa);
|
||||
push('afr', state.afr);
|
||||
push('strim', state.strim);
|
||||
push('ltrim', state.ltrim);
|
||||
push('ain0', state.ain0);
|
||||
push('ain1', state.ain1);
|
||||
push('ain2', state.ain2);
|
||||
push('ain3', state.ain3);
|
||||
push('ain4', state.ain4);
|
||||
push('ain5', state.ain5);
|
||||
push('ain6', state.ain6);
|
||||
push('ain7', state.ain7);
|
||||
}
|
||||
|
||||
List<FlSpot> get(String id) => List.unmodifiable(_data[id] ?? []);
|
||||
|
||||
void clear() {
|
||||
for (final list in _data.values) {
|
||||
list.clear();
|
||||
}
|
||||
_t0 = null;
|
||||
}
|
||||
}
|
||||
|
||||
class SensorHistoryNotifier extends StateNotifier<SensorHistory> {
|
||||
SensorHistoryNotifier() : super(SensorHistory());
|
||||
|
||||
void addState(SensorState s) {
|
||||
state.add(s);
|
||||
// trigger rebuild by reassigning same object
|
||||
// ignore: invalid_use_of_protected_member
|
||||
state = state;
|
||||
}
|
||||
|
||||
void clear() {
|
||||
state.clear();
|
||||
state = SensorHistory();
|
||||
}
|
||||
}
|
||||
|
||||
final sensorHistoryProvider =
|
||||
StateNotifierProvider<SensorHistoryNotifier, SensorHistory>((ref) {
|
||||
final notifier = SensorHistoryNotifier();
|
||||
|
||||
// Feed every parsed sensor state into history
|
||||
ref.listen<AsyncValue<SensorState>>(sensorStateProvider, (_, next) {
|
||||
next.whenData(notifier.addState);
|
||||
});
|
||||
|
||||
return notifier;
|
||||
});
|
||||
|
||||
@ -1,13 +1,12 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../bluetooth/bt_poller.dart';
|
||||
import '../logging/app_log.dart';
|
||||
import '../models/sensor_defs.dart';
|
||||
import '../protocol/kpro_parser.dart';
|
||||
import '../protocol/s300_parser.dart';
|
||||
import '../protocol/sensor_state.dart';
|
||||
import 'bt_provider.dart';
|
||||
import 'datalog_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
|
||||
@ -52,39 +51,137 @@ class DecodeStats {
|
||||
|
||||
final decodeStatsProvider = StateProvider<DecodeStats>((ref) => const DecodeStats());
|
||||
|
||||
/// Emits a parsed [SensorState] for every live data frame received from the
|
||||
/// ECU — or, while a datalog plays back, for every recorded frame. Picks the
|
||||
/// S300 or KPro parser via [effectiveEcuTypeProvider].
|
||||
/// Emits a parsed [SensorState] for every live data frame received from the ECU.
|
||||
/// Automatically picks S300 or KPro parser based on [settingsProvider].
|
||||
final sensorStateProvider = StreamProvider<SensorState>((ref) async* {
|
||||
final ecuType = ref.watch(effectiveEcuTypeProvider);
|
||||
final playing = ref.watch(playbackProvider.select((p) => p.isActive));
|
||||
final source = playing
|
||||
? ref.watch(datalogPlayerProvider).frameStream
|
||||
: ref.watch(btProvider.notifier).frameStream;
|
||||
final ecuType = ref.watch(settingsProvider).ecuType;
|
||||
final btNotifier = ref.watch(btProvider.notifier);
|
||||
|
||||
// `await for` treats an error event as fatal; absorb errors upstream so the
|
||||
// loop keeps waiting for the next frame.
|
||||
final safeStream = source.handleError((Object _, StackTrace __) {});
|
||||
// `await for` treats any error *event* on the source stream as fatal and
|
||||
// exits the loop permanently — a try/catch inside the loop body cannot
|
||||
// intercept that, since it happens in the iteration mechanism itself, not
|
||||
// the body. handleError() absorbs error events upstream so `await for`
|
||||
// never sees them and the loop just keeps waiting for the next frame.
|
||||
final safeStream = btNotifier.frameStream.handleError((Object _, StackTrace __) {});
|
||||
|
||||
SensorState? lastGood;
|
||||
int dbgFrameCount = 0; // TEMP: live-debugging session, remove after.
|
||||
|
||||
// Adapter default frames are already filtered out in BtNotifier, so every
|
||||
// frame here is a real ECU reading. No "hold last value" logic any more:
|
||||
// a missing reading simply means the gauge keeps its last position until
|
||||
// the next real frame, and the UI shows a banner if data stops.
|
||||
await for (final frame in safeStream) {
|
||||
// A single bad frame must never take down the rest of the live stream.
|
||||
try {
|
||||
SensorState decoded =
|
||||
ecuType == EcuType.s300 ? parseS300(frame) : parseKPro(frame);
|
||||
decoded = _clampToRealisticRange(decoded);
|
||||
|
||||
// TEMP: live-debugging session, remove after. Throttled to ~2/sec.
|
||||
if (ecuType == EcuType.s300 && (dbgFrameCount++ % 10 == 0)) {
|
||||
// ignore: avoid_print
|
||||
print('[DBG] template=${isS300TemplateFrame(frame)} '
|
||||
'rpm=${decoded.rpm.toStringAsFixed(0)} '
|
||||
'gear=${decoded.gear} ect=${decoded.ect.toStringAsFixed(1)} '
|
||||
'fuelPump=${decoded.fuelPump} mil=${decoded.mil} '
|
||||
'vtec=${decoded.vtec} fan=${decoded.fanOut} '
|
||||
'sw2=0x${frame[18].toRadixString(16).padLeft(2, '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);
|
||||
ref.read(decodeStatsProvider.notifier).state =
|
||||
s.copyWith(decoded: s.decoded + 1);
|
||||
yield decoded;
|
||||
} catch (e) {
|
||||
} catch (_) {
|
||||
// Skip this one frame and keep the stream alive for the next.
|
||||
final s = ref.read(decodeStatsProvider);
|
||||
ref.read(decodeStatsProvider.notifier).state =
|
||||
s.copyWith(decodeErrors: s.decodeErrors + 1);
|
||||
if (s.decodeErrors < 5) AppLog.w('Frame decode error: $e');
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,134 +1,48 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../bluetooth/bt_poller.dart';
|
||||
import '../models/units.dart';
|
||||
|
||||
/// Polling choices offered in Settings. Never faster than 10 Hz: the adapter
|
||||
/// MCU must have time to read the ECU between requests (device maker's
|
||||
/// feedback). The poller additionally never sends a new request before the
|
||||
/// previous one is answered or has timed out.
|
||||
const List<int> pollingIntervalOptionsMs = [100, 150, 200, 300];
|
||||
|
||||
class AppSettings {
|
||||
final EcuType ecuType;
|
||||
final Duration pollingInterval;
|
||||
|
||||
const AppSettings({
|
||||
this.ecuType = EcuType.s300,
|
||||
this.pollingInterval = BtPoller.defaultInterval,
|
||||
});
|
||||
|
||||
AppSettings copyWith({EcuType? ecuType, Duration? pollingInterval}) =>
|
||||
AppSettings(
|
||||
ecuType: ecuType ?? this.ecuType,
|
||||
pollingInterval: pollingInterval ?? this.pollingInterval,
|
||||
);
|
||||
}
|
||||
|
||||
const _ecuTypeKey = 'ecuType';
|
||||
const _pollingKey = 'pollingIntervalMs';
|
||||
|
||||
class SettingsNotifier extends StateNotifier<AppSettings> {
|
||||
SettingsNotifier() : super(const AppSettings()) {
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final saved = prefs.getString(_ecuTypeKey);
|
||||
final ms = prefs.getInt(_pollingKey);
|
||||
var next = state;
|
||||
if (saved == EcuType.kpro.name) next = next.copyWith(ecuType: EcuType.kpro);
|
||||
if (ms != null && pollingIntervalOptionsMs.contains(ms)) {
|
||||
next = next.copyWith(pollingInterval: Duration(milliseconds: ms));
|
||||
}
|
||||
if (mounted) state = next;
|
||||
} catch (_) {
|
||||
// Keep defaults if preferences can't be read.
|
||||
}
|
||||
}
|
||||
|
||||
void setEcuType(EcuType type) {
|
||||
state = state.copyWith(ecuType: type);
|
||||
SharedPreferences.getInstance()
|
||||
.then((prefs) => prefs.setString(_ecuTypeKey, type.name))
|
||||
.catchError((Object _) => false);
|
||||
}
|
||||
|
||||
void setPollingInterval(Duration d) {
|
||||
final ms = pollingIntervalOptionsMs.contains(d.inMilliseconds)
|
||||
? d.inMilliseconds
|
||||
: BtPoller.defaultInterval.inMilliseconds;
|
||||
state = state.copyWith(pollingInterval: Duration(milliseconds: ms));
|
||||
SharedPreferences.getInstance()
|
||||
.then((prefs) => prefs.setInt(_pollingKey, ms))
|
||||
.catchError((Object _) => false);
|
||||
}
|
||||
}
|
||||
|
||||
final settingsProvider = StateNotifierProvider<SettingsNotifier, AppSettings>(
|
||||
(ref) => SettingsNotifier(),
|
||||
);
|
||||
|
||||
// ─── Display units ───────────────────────────────────────────────────────────
|
||||
|
||||
const _unitKeys = (speed: 'unitSpeed', temp: 'unitTemp', pressure: 'unitPressure');
|
||||
|
||||
T? _byName<T extends Enum>(List<T> values, String? name) {
|
||||
for (final v in values) {
|
||||
if (v.name == name) return v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Display units (speed / temperature / pressure), persisted.
|
||||
class UnitPrefsNotifier extends StateNotifier<UnitPrefs> {
|
||||
UnitPrefsNotifier() : super(const UnitPrefs()) {
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final next = UnitPrefs(
|
||||
speed: _byName(SpeedUnit.values, prefs.getString(_unitKeys.speed)) ??
|
||||
state.speed,
|
||||
temp: _byName(TempUnit.values, prefs.getString(_unitKeys.temp)) ??
|
||||
state.temp,
|
||||
pressure: _byName(
|
||||
PressureUnit.values, prefs.getString(_unitKeys.pressure)) ??
|
||||
state.pressure,
|
||||
);
|
||||
if (mounted) state = next;
|
||||
} catch (_) {
|
||||
// Keep defaults if preferences can't be read.
|
||||
}
|
||||
}
|
||||
|
||||
void _save(String key, Enum v) {
|
||||
SharedPreferences.getInstance()
|
||||
.then((prefs) => prefs.setString(key, v.name))
|
||||
.catchError((Object _) => false);
|
||||
}
|
||||
|
||||
void setSpeed(SpeedUnit v) {
|
||||
state = state.copyWith(speed: v);
|
||||
_save(_unitKeys.speed, v);
|
||||
}
|
||||
|
||||
void setTemp(TempUnit v) {
|
||||
state = state.copyWith(temp: v);
|
||||
_save(_unitKeys.temp, v);
|
||||
}
|
||||
|
||||
void setPressure(PressureUnit v) {
|
||||
state = state.copyWith(pressure: v);
|
||||
_save(_unitKeys.pressure, v);
|
||||
}
|
||||
}
|
||||
|
||||
final unitPrefsProvider =
|
||||
StateNotifierProvider<UnitPrefsNotifier, UnitPrefs>(
|
||||
(ref) => UnitPrefsNotifier(),
|
||||
);
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../bluetooth/bt_poller.dart';
|
||||
|
||||
class AppSettings {
|
||||
final EcuType ecuType;
|
||||
final Duration pollingInterval;
|
||||
|
||||
const AppSettings({
|
||||
this.ecuType = EcuType.s300,
|
||||
this.pollingInterval = const Duration(milliseconds: 50),
|
||||
});
|
||||
|
||||
AppSettings copyWith({EcuType? ecuType, Duration? pollingInterval}) =>
|
||||
AppSettings(
|
||||
ecuType: ecuType ?? this.ecuType,
|
||||
pollingInterval: pollingInterval ?? this.pollingInterval,
|
||||
);
|
||||
}
|
||||
|
||||
const _ecuTypeKey = 'ecuType';
|
||||
|
||||
class SettingsNotifier extends StateNotifier<AppSettings> {
|
||||
SettingsNotifier() : super(const AppSettings()) {
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
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(),
|
||||
);
|
||||
|
||||
@ -1,55 +1,32 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
enum AppThemeVariant { redDark, redLight, greenDark, greenLight }
|
||||
|
||||
const _themeKey = 'themeVariant';
|
||||
|
||||
/// Colour scheme (red/green) × brightness (dark/light), persisted.
|
||||
class ThemeNotifier extends StateNotifier<AppThemeVariant> {
|
||||
ThemeNotifier() : super(AppThemeVariant.redDark) {
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final saved = prefs.getString(_themeKey);
|
||||
for (final v in AppThemeVariant.values) {
|
||||
if (v.name == saved && mounted) state = v;
|
||||
}
|
||||
} catch (_) {
|
||||
// Keep the default if preferences can't be read.
|
||||
}
|
||||
}
|
||||
|
||||
void set(AppThemeVariant v) {
|
||||
state = v;
|
||||
SharedPreferences.getInstance()
|
||||
.then((prefs) => prefs.setString(_themeKey, v.name))
|
||||
.catchError((Object _) => false);
|
||||
}
|
||||
}
|
||||
|
||||
final themeProvider =
|
||||
StateNotifierProvider<ThemeNotifier, AppThemeVariant>(
|
||||
(ref) => ThemeNotifier(),
|
||||
);
|
||||
|
||||
/// Returns the accent color for the current theme variant.
|
||||
extension AppThemeVariantX on AppThemeVariant {
|
||||
Color get accentColor {
|
||||
switch (this) {
|
||||
case AppThemeVariant.redDark:
|
||||
case AppThemeVariant.redLight:
|
||||
return const Color(0xFFFF4444);
|
||||
case AppThemeVariant.greenDark:
|
||||
case AppThemeVariant.greenLight:
|
||||
return const Color(0xFF00E676);
|
||||
}
|
||||
}
|
||||
|
||||
bool get isDark =>
|
||||
this == AppThemeVariant.redDark || this == AppThemeVariant.greenDark;
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
enum AppThemeVariant { redDark, redLight, greenDark, greenLight }
|
||||
|
||||
class ThemeNotifier extends StateNotifier<AppThemeVariant> {
|
||||
ThemeNotifier() : super(AppThemeVariant.redDark);
|
||||
|
||||
void set(AppThemeVariant v) => state = v;
|
||||
}
|
||||
|
||||
final themeProvider =
|
||||
StateNotifierProvider<ThemeNotifier, AppThemeVariant>(
|
||||
(ref) => ThemeNotifier(),
|
||||
);
|
||||
|
||||
/// Returns the accent color for the current theme variant.
|
||||
extension AppThemeVariantX on AppThemeVariant {
|
||||
Color get accentColor {
|
||||
switch (this) {
|
||||
case AppThemeVariant.redDark:
|
||||
case AppThemeVariant.redLight:
|
||||
return const Color(0xFFFF4444);
|
||||
case AppThemeVariant.greenDark:
|
||||
case AppThemeVariant.greenLight:
|
||||
return const Color(0xFF00E676);
|
||||
}
|
||||
}
|
||||
|
||||
bool get isDark =>
|
||||
this == AppThemeVariant.redDark || this == AppThemeVariant.greenDark;
|
||||
}
|
||||
|
||||
@ -1,69 +1,26 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'core/logging/app_log.dart';
|
||||
import 'core/providers/theme_provider.dart';
|
||||
import 'ui/theme/app_theme.dart';
|
||||
import 'ui/screens/bt_picker/bt_picker_screen.dart';
|
||||
|
||||
void main() {
|
||||
runZonedGuarded(() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Catch every Dart-side error, log it (copyable from the Debug screen)
|
||||
// and keep the app running instead of showing a red/grey error screen.
|
||||
FlutterError.onError = (FlutterErrorDetails details) {
|
||||
AppLog.e('Flutter error', details.exception, details.stack);
|
||||
};
|
||||
WidgetsBinding.instance.platformDispatcher.onError = (error, stack) {
|
||||
AppLog.e('Uncaught platform error', error, stack);
|
||||
return true;
|
||||
};
|
||||
// Release only: keep Flutter's red error screen in debug builds so
|
||||
// layout/build errors stay visible during development.
|
||||
if (kReleaseMode) {
|
||||
ErrorWidget.builder = (FlutterErrorDetails details) => const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'Something went wrong on this screen.\n'
|
||||
'Open Debug → Copy log and send it to support.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
AppLog.i('App start');
|
||||
runApp(const ProviderScope(child: HvbtApp()));
|
||||
}, (error, stack) {
|
||||
AppLog.e('Uncaught zone error', error, stack);
|
||||
});
|
||||
}
|
||||
|
||||
class HvbtApp extends ConsumerWidget {
|
||||
const HvbtApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final themeVariant = ref.watch(themeProvider);
|
||||
|
||||
return MaterialApp(
|
||||
title: 'HV BT Dashboard',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.build(themeVariant),
|
||||
// Keep every screen clear of the system navigation bar, which sits on
|
||||
// a side in landscape.
|
||||
builder: (context, child) => SafeArea(
|
||||
top: false,
|
||||
bottom: false,
|
||||
child: child ?? const SizedBox.shrink(),
|
||||
),
|
||||
home: const BtPickerScreen(),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'core/providers/theme_provider.dart';
|
||||
import 'ui/theme/app_theme.dart';
|
||||
import 'ui/screens/bt_picker/bt_picker_screen.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const ProviderScope(child: HvbtApp()));
|
||||
}
|
||||
|
||||
class HvbtApp extends ConsumerWidget {
|
||||
const HvbtApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final themeVariant = ref.watch(themeProvider);
|
||||
|
||||
return MaterialApp(
|
||||
title: 'HV BT Dashboard',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.build(themeVariant),
|
||||
home: const BtPickerScreen(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,180 +1,154 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import '../../../core/providers/bt_provider.dart';
|
||||
import '../../widgets/main_nav.dart';
|
||||
import '../../../core/logging/app_log.dart';
|
||||
|
||||
class BtPickerScreen extends ConsumerStatefulWidget {
|
||||
const BtPickerScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<BtPickerScreen> createState() => _BtPickerScreenState();
|
||||
}
|
||||
|
||||
class _BtPickerScreenState extends ConsumerState<BtPickerScreen> {
|
||||
String? _error;
|
||||
bool _initDone = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _initBluetooth());
|
||||
}
|
||||
|
||||
bool _permissionsOk = false;
|
||||
|
||||
Future<void> _initBluetooth() async {
|
||||
setState(() {
|
||||
_error = null;
|
||||
_initDone = false;
|
||||
});
|
||||
|
||||
try {
|
||||
// Android 12+: BLUETOOTH_CONNECT is the only permission needed to list
|
||||
// paired devices and open an RFCOMM socket. On Android 11 and older
|
||||
// permission_handler reports it as granted. Scan/location are requested
|
||||
// but not required (we never run discovery).
|
||||
final statuses = await [
|
||||
Permission.bluetoothConnect,
|
||||
Permission.bluetoothScan,
|
||||
Permission.locationWhenInUse,
|
||||
].request();
|
||||
|
||||
final connect = statuses[Permission.bluetoothConnect];
|
||||
if (connect != null && !connect.isGranted && !connect.isLimited) {
|
||||
AppLog.w('Bluetooth permission not granted: $connect');
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_permissionsOk = false;
|
||||
_error = 'Bluetooth permission ("Nearby devices") was not granted.\n\n'
|
||||
'Go to Settings → Apps → HV BT Dashboard → Permissions.';
|
||||
_initDone = true;
|
||||
});
|
||||
if (connect.isPermanentlyDenied) await openAppSettings();
|
||||
return;
|
||||
}
|
||||
_permissionsOk = true;
|
||||
|
||||
final btState = await FlutterBluetoothSerial.instance.state;
|
||||
if (btState != BluetoothState.STATE_ON) {
|
||||
await FlutterBluetoothSerial.instance.requestEnable();
|
||||
await Future<void>.delayed(const Duration(seconds: 1));
|
||||
}
|
||||
} catch (e, st) {
|
||||
AppLog.e('Bluetooth init failed', e, st);
|
||||
if (!mounted) return;
|
||||
setState(() => _error = 'Bluetooth init failed: $e');
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() => _initDone = true);
|
||||
if (_permissionsOk) 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
|
||||
// dialog here used to re-prompt "S300 or KPro?" on every single tap,
|
||||
// which was pure friction once the user had already told the app which
|
||||
// ECU they have.
|
||||
Future<void> _connect(BluetoothDevice device) async {
|
||||
if (ref.read(btProvider).isConnecting) return;
|
||||
await ref.read(btProvider.notifier).connect(device);
|
||||
if (!mounted) return;
|
||||
if (ref.read(btProvider).isConnected) {
|
||||
// Replace the whole stack: when reached via Settings, a plain
|
||||
// replacement left the old dashboard and Settings underneath.
|
||||
Navigator.of(context).pushAndRemoveUntil(
|
||||
MaterialPageRoute(builder: (_) => const MainNav()),
|
||||
(_) => false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final btState = ref.watch(btProvider);
|
||||
// Do NOT touch the paired-device list before permission is granted:
|
||||
// on Android 12+ that call throws inside the Bluetooth plugin.
|
||||
final AsyncValue<List<BluetoothDevice>> devicesAsync = _permissionsOk
|
||||
? ref.watch(pairedDevicesProvider)
|
||||
: const AsyncValue<List<BluetoothDevice>>.data([]);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Connect to ECU'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _loading ? null : _initBluetooth,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
if (btState.status == BtStatus.error)
|
||||
_banner('Connection failed: ${btState.error}'),
|
||||
if (_error != null) _banner(_error!),
|
||||
Expanded(
|
||||
child: !_initDone
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: devicesAsync.when(
|
||||
loading: () => const Center(
|
||||
child: CircularProgressIndicator()),
|
||||
error: (e, _) =>
|
||||
Center(child: _banner('Failed to load: $e')),
|
||||
data: (devices) => devices.isEmpty
|
||||
? const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Text(
|
||||
'No paired BT devices.\nPair your ECU in Android Settings first.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
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'),
|
||||
subtitle: Text(d.address),
|
||||
trailing: connecting
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2))
|
||||
: const Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
size: 14),
|
||||
onTap: connecting ? null : () => _connect(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)),
|
||||
);
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import '../../../core/providers/bt_provider.dart';
|
||||
import '../../widgets/main_nav.dart';
|
||||
|
||||
class BtPickerScreen extends ConsumerStatefulWidget {
|
||||
const BtPickerScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<BtPickerScreen> createState() => _BtPickerScreenState();
|
||||
}
|
||||
|
||||
class _BtPickerScreenState extends ConsumerState<BtPickerScreen> {
|
||||
String? _error;
|
||||
bool _initDone = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _initBluetooth());
|
||||
}
|
||||
|
||||
Future<void> _initBluetooth() async {
|
||||
setState(() => _error = null);
|
||||
|
||||
final statuses = await [
|
||||
Permission.bluetoothConnect,
|
||||
Permission.bluetoothScan,
|
||||
Permission.locationWhenInUse,
|
||||
].request();
|
||||
|
||||
final denied = statuses.entries
|
||||
.where((e) => e.value.isDenied || e.value.isPermanentlyDenied)
|
||||
.map((e) => e.key.toString())
|
||||
.toList();
|
||||
|
||||
if (denied.isNotEmpty) {
|
||||
setState(() {
|
||||
_error = 'Permissions denied: ${denied.join(", ")}\n\n'
|
||||
'Go to Settings → Apps → HV BT Dashboard → Permissions.';
|
||||
_initDone = true;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final btState = await FlutterBluetoothSerial.instance.state;
|
||||
if (btState != BluetoothState.STATE_ON) {
|
||||
await FlutterBluetoothSerial.instance.requestEnable();
|
||||
await Future<void>.delayed(const Duration(seconds: 1));
|
||||
}
|
||||
|
||||
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
|
||||
// dialog here used to re-prompt "S300 or KPro?" on every single tap,
|
||||
// which was pure friction once the user had already told the app which
|
||||
// ECU they have.
|
||||
Future<void> _connect(BluetoothDevice device) async {
|
||||
await ref.read(btProvider.notifier).connect(device);
|
||||
if (!mounted) return;
|
||||
if (ref.read(btProvider).isConnected) {
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(builder: (_) => const MainNav()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final btState = ref.watch(btProvider);
|
||||
final devicesAsync = ref.watch(pairedDevicesProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Connect to ECU'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _loading ? null : _initBluetooth,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
if (btState.status == BtStatus.error)
|
||||
_banner('Connection failed: ${btState.error}'),
|
||||
if (_error != null) _banner(_error!),
|
||||
Expanded(
|
||||
child: !_initDone
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: devicesAsync.when(
|
||||
loading: () => const Center(
|
||||
child: CircularProgressIndicator()),
|
||||
error: (e, _) =>
|
||||
Center(child: _banner('Failed to load: $e')),
|
||||
data: (devices) => devices.isEmpty
|
||||
? const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Text(
|
||||
'No paired BT devices.\nPair your ECU in Android Settings first.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
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'),
|
||||
subtitle: Text(d.address),
|
||||
trailing: connecting
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2))
|
||||
: const Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
size: 14),
|
||||
onTap: connecting ? null : () => _connect(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)),
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,82 +1,113 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/providers/sensor_provider.dart';
|
||||
import '../../../core/providers/settings_provider.dart';
|
||||
import '../../theme/app_colors.dart';
|
||||
import 'dashboard_slots_provider.dart';
|
||||
import 'widgets/dashboard_common.dart';
|
||||
import 'widgets/flag_pill.dart';
|
||||
import 'widgets/sensor_tile.dart';
|
||||
|
||||
/// Landscape layout (requirements): main + sub sensor in the centre,
|
||||
/// 5 values left, 5 values right, 4 flags along the bottom.
|
||||
class DashboardHorizontal extends ConsumerWidget {
|
||||
const DashboardHorizontal({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final c = AppColors.of(context);
|
||||
final state = ref.watch(latestSensorProvider);
|
||||
final config = ref.watch(dashboardConfigProvider);
|
||||
final units = ref.watch(unitPrefsProvider);
|
||||
|
||||
Widget column(int from) => SizedBox(
|
||||
width: 190,
|
||||
child: Column(
|
||||
children: [
|
||||
for (int i = from; i < from + 5; i++)
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: SensorTile(
|
||||
sensorId: config.values[i],
|
||||
state: state,
|
||||
units: units,
|
||||
onLongPress: () => editDashboardValue(context, ref, i),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
return Container(
|
||||
color: c.background,
|
||||
// SafeArea: in landscape the system navigation bar sits on a side.
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
bottom: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 4, 8, 6),
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
column(0),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Center(child: MainGauge(state: state)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
column(5),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 198),
|
||||
child: FlagRow(
|
||||
state: state,
|
||||
flagIds: config.flags,
|
||||
onLongPress: (i) => editDashboardFlag(context, ref, i),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/providers/sensor_provider.dart';
|
||||
import '../../../core/providers/theme_provider.dart';
|
||||
import '../../../ui/theme/app_colors.dart';
|
||||
import 'widgets/analog_gauge.dart';
|
||||
import 'widgets/flag_pill.dart';
|
||||
import 'widgets/sensor_tile.dart';
|
||||
import 'widgets/sensor_picker_sheet.dart';
|
||||
import 'dashboard_slots_provider.dart';
|
||||
|
||||
class DashboardHorizontal extends ConsumerWidget {
|
||||
const DashboardHorizontal({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(latestSensorProvider);
|
||||
final themeVariant = ref.watch(themeProvider);
|
||||
final colors = AppColors.forTheme(themeVariant);
|
||||
final slots = ref.watch(dashboardSlotsProvider);
|
||||
|
||||
return Container(
|
||||
color: const Color(0xFF0A0A0A),
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Left tiles (slots 0-2)
|
||||
SizedBox(
|
||||
width: 140,
|
||||
child: Column(
|
||||
children: [
|
||||
for (int i = 0; i < 3; i++)
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: SensorTile(
|
||||
slotIndex: i,
|
||||
sensorId: slots[i],
|
||||
state: state,
|
||||
accentColor: colors.accent,
|
||||
onTap: () => _pickSensor(context, ref, i),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Center gauge
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: AnalogGauge(
|
||||
value: state.rpm,
|
||||
minValue: 0,
|
||||
maxValue: 9000,
|
||||
label: 'RPM',
|
||||
unit: 'rpm',
|
||||
accentColor: colors.accent,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Right tiles (slots 3-5)
|
||||
SizedBox(
|
||||
width: 140,
|
||||
child: Column(
|
||||
children: [
|
||||
for (int i = 3; i < 6; i++)
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: SensorTile(
|
||||
slotIndex: i,
|
||||
sensorId: slots[i],
|
||||
state: state,
|
||||
accentColor: colors.accent,
|
||||
onTap: () => _pickSensor(context, ref, i),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Flag pills row
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: FlagRow(state: state, accentColor: colors.accent),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _pickSensor(BuildContext context, WidgetRef ref, int slotIndex) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
builder: (_) => SensorPickerSheet(
|
||||
currentId: ref.read(dashboardSlotsProvider)[slotIndex],
|
||||
onSelected: (id) {
|
||||
ref.read(dashboardSlotsProvider.notifier).setSlot(slotIndex, id);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,19 +1,19 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'dashboard_horizontal.dart';
|
||||
import 'dashboard_vertical.dart';
|
||||
|
||||
class DashboardScreen extends ConsumerWidget {
|
||||
const DashboardScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return OrientationBuilder(
|
||||
builder: (context, orientation) {
|
||||
return orientation == Orientation.landscape
|
||||
? const DashboardHorizontal()
|
||||
: const DashboardVertical();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'dashboard_horizontal.dart';
|
||||
import 'dashboard_vertical.dart';
|
||||
|
||||
class DashboardScreen extends ConsumerWidget {
|
||||
const DashboardScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return OrientationBuilder(
|
||||
builder: (context, orientation) {
|
||||
return orientation == Orientation.landscape
|
||||
? const DashboardHorizontal()
|
||||
: const DashboardVertical();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,125 +1,19 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../../core/models/sensor_defs.dart';
|
||||
|
||||
/// What the dashboard shows, per the requirement document: a selectable
|
||||
/// main sensor (gauge) + sub sensor, 10 selectable values (landscape: 5 left
|
||||
/// + 5 right; portrait: 2×5) and 4 selectable flags. Persisted.
|
||||
class DashboardConfig {
|
||||
static const valueCount = 10;
|
||||
static const flagCount = 4;
|
||||
|
||||
final String main;
|
||||
final String sub;
|
||||
final List<String> values;
|
||||
final List<String> flags;
|
||||
|
||||
const DashboardConfig({
|
||||
required this.main,
|
||||
required this.sub,
|
||||
required this.values,
|
||||
required this.flags,
|
||||
});
|
||||
|
||||
static const defaults = DashboardConfig(
|
||||
main: 'rpm',
|
||||
sub: 'vss',
|
||||
values: [
|
||||
'vss', 'ect', 'iat', 'map', 'o2', // left
|
||||
'tps', 'bat', 'ign', 'inj', 'eth', // right
|
||||
],
|
||||
// Requirement picture: MIL, FuelCut, FAN Out, VTEC Out. On the S300
|
||||
// the fuel flag is the fuel pump relay (client).
|
||||
flags: ['mil', 'fuelpump', 'fanout', 'vtec'],
|
||||
);
|
||||
|
||||
DashboardConfig copyWith({
|
||||
String? main,
|
||||
String? sub,
|
||||
List<String>? values,
|
||||
List<String>? flags,
|
||||
}) =>
|
||||
DashboardConfig(
|
||||
main: main ?? this.main,
|
||||
sub: sub ?? this.sub,
|
||||
values: values ?? this.values,
|
||||
flags: flags ?? this.flags,
|
||||
);
|
||||
}
|
||||
|
||||
const _kMain = 'dash.main';
|
||||
const _kSub = 'dash.sub';
|
||||
const _kValues = 'dash.values';
|
||||
const _kFlags = 'dash.flags';
|
||||
|
||||
class DashboardConfigNotifier extends StateNotifier<DashboardConfig> {
|
||||
DashboardConfigNotifier() : super(DashboardConfig.defaults) {
|
||||
_load();
|
||||
}
|
||||
|
||||
static bool _isSensor(String id) => sensorDefs.any((d) => d.id == id);
|
||||
static bool _isFlag(String id) => flagDefs.any((f) => f.id == id);
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final p = await SharedPreferences.getInstance();
|
||||
const d = DashboardConfig.defaults;
|
||||
List<String> list(String key, int n, bool Function(String) valid,
|
||||
List<String> fallback) {
|
||||
final saved = p.getStringList(key);
|
||||
if (saved == null || saved.length != n || !saved.every(valid)) {
|
||||
return fallback;
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
final main = p.getString(_kMain);
|
||||
final sub = p.getString(_kSub);
|
||||
final next = DashboardConfig(
|
||||
main: main != null && _isSensor(main) ? main : d.main,
|
||||
sub: sub != null && _isSensor(sub) ? sub : d.sub,
|
||||
values: list(_kValues, DashboardConfig.valueCount, _isSensor, d.values),
|
||||
flags: list(_kFlags, DashboardConfig.flagCount, _isFlag, d.flags),
|
||||
);
|
||||
if (mounted) state = next;
|
||||
} catch (_) {
|
||||
// Keep defaults if preferences can't be read.
|
||||
}
|
||||
}
|
||||
|
||||
void _persist() {
|
||||
final s = state;
|
||||
SharedPreferences.getInstance().then((p) {
|
||||
p.setString(_kMain, s.main);
|
||||
p.setString(_kSub, s.sub);
|
||||
p.setStringList(_kValues, s.values);
|
||||
p.setStringList(_kFlags, s.flags);
|
||||
}).catchError((Object _) {});
|
||||
}
|
||||
|
||||
void setMain(String id) {
|
||||
state = state.copyWith(main: id);
|
||||
_persist();
|
||||
}
|
||||
|
||||
void setSub(String id) {
|
||||
state = state.copyWith(sub: id);
|
||||
_persist();
|
||||
}
|
||||
|
||||
void setValue(int index, String id) {
|
||||
state = state.copyWith(values: [...state.values]..[index] = id);
|
||||
_persist();
|
||||
}
|
||||
|
||||
void setFlag(int index, String id) {
|
||||
state = state.copyWith(flags: [...state.flags]..[index] = id);
|
||||
_persist();
|
||||
}
|
||||
}
|
||||
|
||||
final dashboardConfigProvider =
|
||||
StateNotifierProvider<DashboardConfigNotifier, DashboardConfig>(
|
||||
(ref) => DashboardConfigNotifier(),
|
||||
);
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
// Default sensor IDs for the 6 dashboard tile slots
|
||||
const _defaultSlots = ['vss', 'map', 'tps', 'ect', 'iat', 'bat'];
|
||||
|
||||
class DashboardSlotsNotifier extends StateNotifier<List<String>> {
|
||||
DashboardSlotsNotifier() : super(List.from(_defaultSlots));
|
||||
|
||||
void setSlot(int index, String sensorId) {
|
||||
final next = List<String>.from(state);
|
||||
next[index] = sensorId;
|
||||
state = next;
|
||||
}
|
||||
}
|
||||
|
||||
final dashboardSlotsProvider =
|
||||
StateNotifierProvider<DashboardSlotsNotifier, List<String>>(
|
||||
(ref) => DashboardSlotsNotifier(),
|
||||
);
|
||||
|
||||
@ -1,94 +1,81 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/providers/sensor_provider.dart';
|
||||
import '../../../core/providers/settings_provider.dart';
|
||||
import '../../theme/app_colors.dart';
|
||||
import 'dashboard_slots_provider.dart';
|
||||
import 'widgets/dashboard_common.dart';
|
||||
import 'widgets/flag_pill.dart';
|
||||
import 'widgets/sensor_tile.dart';
|
||||
|
||||
/// Portrait layout (requirements): main + sub sensor on top, 4 flags in the
|
||||
/// middle, 2×5 values below — all on one screen, the gauge taking whatever
|
||||
/// height the flags and values leave.
|
||||
class DashboardVertical extends ConsumerWidget {
|
||||
const DashboardVertical({super.key});
|
||||
|
||||
static const _tileHeight = 64.0;
|
||||
static const _gap = 6.0;
|
||||
static const _flagsHeight = 30.0 + 2 * _gap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final c = AppColors.of(context);
|
||||
final state = ref.watch(latestSensorProvider);
|
||||
final config = ref.watch(dashboardConfigProvider);
|
||||
final units = ref.watch(unitPrefsProvider);
|
||||
|
||||
final tiles = Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
|
||||
child: Column(
|
||||
children: [
|
||||
// Left column is values 0-4, right column 5-9, matching landscape.
|
||||
for (int row = 0; row < 5; row++)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: row == 0 ? 0 : _gap),
|
||||
child: SizedBox(
|
||||
height: _tileHeight,
|
||||
child: Row(
|
||||
children: [
|
||||
for (final i in [row, row + 5]) ...[
|
||||
if (i != row) const SizedBox(width: _gap),
|
||||
Expanded(
|
||||
child: SensorTile(
|
||||
sensorId: config.values[i],
|
||||
state: state,
|
||||
units: units,
|
||||
onLongPress: () =>
|
||||
editDashboardValue(context, ref, i),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
return Container(
|
||||
color: c.background,
|
||||
child: LayoutBuilder(builder: (context, box) {
|
||||
const tilesHeight = 5 * _tileHeight + 4 * _gap + 8;
|
||||
final gaugeSize = math.max(
|
||||
140.0,
|
||||
math.min(box.maxWidth - 32, box.maxHeight - tilesHeight - _flagsHeight),
|
||||
);
|
||||
// Scrolls only if the screen is too short even for a small gauge.
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: gaugeSize,
|
||||
child: Center(child: MainGauge(state: state)),
|
||||
),
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: _gap, horizontal: 8),
|
||||
child: FlagRow(
|
||||
state: state,
|
||||
flagIds: config.flags,
|
||||
onLongPress: (i) => editDashboardFlag(context, ref, i),
|
||||
),
|
||||
),
|
||||
tiles,
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/providers/sensor_provider.dart';
|
||||
import '../../../core/providers/theme_provider.dart';
|
||||
import '../../../ui/theme/app_colors.dart';
|
||||
import 'widgets/analog_gauge.dart';
|
||||
import 'widgets/flag_pill.dart';
|
||||
import 'widgets/sensor_tile.dart';
|
||||
import 'widgets/sensor_picker_sheet.dart';
|
||||
import 'dashboard_slots_provider.dart';
|
||||
|
||||
class DashboardVertical extends ConsumerWidget {
|
||||
const DashboardVertical({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(latestSensorProvider);
|
||||
final themeVariant = ref.watch(themeProvider);
|
||||
final colors = AppColors.forTheme(themeVariant);
|
||||
final slots = ref.watch(dashboardSlotsProvider);
|
||||
|
||||
return Container(
|
||||
color: const Color(0xFF0A0A0A),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 8, 24, 0),
|
||||
child: AnalogGauge(
|
||||
value: state.rpm,
|
||||
minValue: 0,
|
||||
maxValue: 9000,
|
||||
label: 'RPM',
|
||||
unit: 'rpm',
|
||||
accentColor: colors.accent,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
|
||||
child: FlagRow(state: state, accentColor: colors.accent),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
|
||||
child: GridView.count(
|
||||
shrinkWrap: true,
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: 6,
|
||||
crossAxisSpacing: 6,
|
||||
childAspectRatio: 2.4,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
children: [
|
||||
for (int i = 0; i < slots.length; i++)
|
||||
SensorTile(
|
||||
slotIndex: i,
|
||||
sensorId: slots[i],
|
||||
state: state,
|
||||
accentColor: colors.accent,
|
||||
onTap: () => _pickSensor(context, ref, i),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _pickSensor(BuildContext context, WidgetRef ref, int slotIndex) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
builder: (_) => SensorPickerSheet(
|
||||
currentId: ref.read(dashboardSlotsProvider)[slotIndex],
|
||||
onSelected: (id) {
|
||||
ref.read(dashboardSlotsProvider.notifier).setSlot(slotIndex, id);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,343 +1,263 @@
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AnalogGauge extends StatefulWidget {
|
||||
final double value;
|
||||
final double minValue;
|
||||
final double maxValue;
|
||||
final String label;
|
||||
final String unit;
|
||||
final Color accentColor;
|
||||
/// Formatted main value; defaults to [value] with no decimals.
|
||||
final String? valueText;
|
||||
/// Second line under the unit, e.g. the sub sensor "120 km/h".
|
||||
final String? subText;
|
||||
final Color foreground;
|
||||
final Color muted;
|
||||
final Color track;
|
||||
final Color background;
|
||||
|
||||
const AnalogGauge({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.minValue,
|
||||
required this.maxValue,
|
||||
required this.label,
|
||||
required this.unit,
|
||||
required this.accentColor,
|
||||
this.valueText,
|
||||
this.subText,
|
||||
this.foreground = Colors.white,
|
||||
this.muted = Colors.white54,
|
||||
this.track = const Color(0xFF2A2A2A),
|
||||
this.background = const Color(0xFF0A0A0A),
|
||||
});
|
||||
|
||||
@override
|
||||
State<AnalogGauge> createState() => _AnalogGaugeState();
|
||||
}
|
||||
|
||||
class _AnalogGaugeState extends State<AnalogGauge>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _ctrl;
|
||||
late Animation<double> _anim;
|
||||
|
||||
double get _normalized =>
|
||||
((widget.value - widget.minValue) /
|
||||
(widget.maxValue - widget.minValue))
|
||||
.clamp(0.0, 1.0);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ctrl = AnimationController(
|
||||
vsync: this,
|
||||
// Roughly one polling interval: the needle glides from reading to
|
||||
// reading instead of jumping and stopping (was 150 ms ease-out, which
|
||||
// looked stepped at 10-20 Hz data).
|
||||
duration: const Duration(milliseconds: 110),
|
||||
);
|
||||
_anim = Tween<double>(begin: 0, end: _normalized)
|
||||
.animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOut));
|
||||
_ctrl.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(AnalogGauge old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (old.value != widget.value) {
|
||||
_anim = Tween<double>(begin: _anim.value, end: _normalized)
|
||||
.animate(CurvedAnimation(parent: _ctrl, curve: Curves.linear));
|
||||
_ctrl.forward(from: 0);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: AnimatedBuilder(
|
||||
animation: _anim,
|
||||
builder: (_, __) => CustomPaint(
|
||||
painter: _GaugePainter(
|
||||
normalizedValue: _anim.value,
|
||||
accentColor: widget.accentColor,
|
||||
valueText: widget.valueText ?? widget.value.toStringAsFixed(0),
|
||||
subText: widget.subText,
|
||||
unit: widget.unit,
|
||||
label: widget.label,
|
||||
foreground: widget.foreground,
|
||||
muted: widget.muted,
|
||||
track: widget.track,
|
||||
background: widget.background,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GaugePainter extends CustomPainter {
|
||||
final double normalizedValue; // 0.0–1.0
|
||||
final Color accentColor;
|
||||
final String valueText;
|
||||
final String? subText;
|
||||
final String unit;
|
||||
final String label;
|
||||
final Color foreground;
|
||||
final Color muted;
|
||||
final Color track;
|
||||
final Color background;
|
||||
|
||||
static const double _startAngle = 135 * pi / 180; // 7:30 position
|
||||
static const double _sweepAngle = 270 * pi / 180; // 270° sweep
|
||||
|
||||
_GaugePainter({
|
||||
required this.normalizedValue,
|
||||
required this.accentColor,
|
||||
required this.valueText,
|
||||
required this.subText,
|
||||
required this.unit,
|
||||
required this.label,
|
||||
required this.foreground,
|
||||
required this.muted,
|
||||
required this.track,
|
||||
required this.background,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final center = Offset(size.width / 2, size.height / 2);
|
||||
final radius = size.width * 0.42;
|
||||
final rect = Rect.fromCircle(center: center, radius: radius);
|
||||
|
||||
// ── Background arc ──────────────────────────────────────────────
|
||||
final bgPaint = Paint()
|
||||
..color = track
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = size.width * 0.06
|
||||
..strokeCap = StrokeCap.round;
|
||||
canvas.drawArc(rect, _startAngle, _sweepAngle, false, bgPaint);
|
||||
|
||||
// ── Red zone (last 20%) ──────────────────────────────────────────
|
||||
final redPaint = Paint()
|
||||
..color = Colors.red.withAlpha(120)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = size.width * 0.06
|
||||
..strokeCap = StrokeCap.round;
|
||||
canvas.drawArc(
|
||||
rect,
|
||||
_startAngle + _sweepAngle * 0.8,
|
||||
_sweepAngle * 0.2,
|
||||
false,
|
||||
redPaint,
|
||||
);
|
||||
|
||||
// ── Value arc ────────────────────────────────────────────────────
|
||||
if (normalizedValue > 0) {
|
||||
final isRedZone = normalizedValue > 0.8;
|
||||
final valPaint = Paint()
|
||||
..color = isRedZone ? Colors.red : accentColor
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = size.width * 0.06
|
||||
..strokeCap = StrokeCap.round;
|
||||
canvas.drawArc(
|
||||
rect,
|
||||
_startAngle,
|
||||
_sweepAngle * normalizedValue,
|
||||
false,
|
||||
valPaint,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tick marks ───────────────────────────────────────────────────
|
||||
_drawTicks(canvas, center, radius, size);
|
||||
|
||||
// ── Needle ────────────────────────────────────────────────────────
|
||||
_drawNeedle(canvas, center, radius, size);
|
||||
|
||||
// ── Center text ──────────────────────────────────────────────────
|
||||
_drawCenterText(canvas, center, size);
|
||||
}
|
||||
|
||||
void _drawTicks(Canvas canvas, Offset center, double radius, Size size) {
|
||||
final majorPaint = Paint()
|
||||
..color = muted
|
||||
..strokeWidth = size.width * 0.012
|
||||
..strokeCap = StrokeCap.round;
|
||||
final minorPaint = Paint()
|
||||
..color = muted.withAlpha(90)
|
||||
..strokeWidth = size.width * 0.006
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
const totalTicks = 50; // 2% each
|
||||
for (int i = 0; i <= totalTicks; i++) {
|
||||
final t = i / totalTicks;
|
||||
final angle = _startAngle + _sweepAngle * t;
|
||||
final isMajor = i % 5 == 0;
|
||||
final paint = isMajor ? majorPaint : minorPaint;
|
||||
final outerR = radius - size.width * 0.01;
|
||||
final innerR = outerR - (isMajor ? size.width * 0.06 : size.width * 0.03);
|
||||
canvas.drawLine(
|
||||
center + Offset(cos(angle) * innerR, sin(angle) * innerR),
|
||||
center + Offset(cos(angle) * outerR, sin(angle) * outerR),
|
||||
paint,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _drawNeedle(Canvas canvas, Offset center, double radius, Size size) {
|
||||
final angle = _startAngle + _sweepAngle * normalizedValue;
|
||||
final tipR = radius - size.width * 0.08;
|
||||
final tailR = size.width * 0.08;
|
||||
|
||||
final needlePaint = Paint()
|
||||
..color = foreground
|
||||
..strokeWidth = size.width * 0.018
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
canvas.drawLine(
|
||||
center - Offset(cos(angle) * tailR, sin(angle) * tailR),
|
||||
center + Offset(cos(angle) * tipR, sin(angle) * tipR),
|
||||
needlePaint,
|
||||
);
|
||||
|
||||
// Center dot
|
||||
canvas.drawCircle(
|
||||
center,
|
||||
size.width * 0.04,
|
||||
Paint()..color = foreground,
|
||||
);
|
||||
canvas.drawCircle(
|
||||
center,
|
||||
size.width * 0.025,
|
||||
Paint()..color = background,
|
||||
);
|
||||
}
|
||||
|
||||
void _drawCenterText(Canvas canvas, Offset center, Size size) {
|
||||
// Value
|
||||
final valSpan = TextSpan(
|
||||
text: valueText,
|
||||
style: TextStyle(
|
||||
color: foreground,
|
||||
fontSize: size.width * 0.14,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: -1,
|
||||
),
|
||||
);
|
||||
final valPainter = TextPainter(
|
||||
text: valSpan,
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
valPainter.paint(
|
||||
canvas,
|
||||
center +
|
||||
Offset(
|
||||
-valPainter.width / 2,
|
||||
size.width * 0.1,
|
||||
),
|
||||
);
|
||||
|
||||
// Unit
|
||||
final unitSpan = TextSpan(
|
||||
text: unit,
|
||||
style: TextStyle(
|
||||
color: muted,
|
||||
fontSize: size.width * 0.07,
|
||||
),
|
||||
);
|
||||
final unitPainter = TextPainter(
|
||||
text: unitSpan,
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
unitPainter.paint(
|
||||
canvas,
|
||||
center +
|
||||
Offset(
|
||||
-unitPainter.width / 2,
|
||||
size.width * 0.1 + valPainter.height + 2,
|
||||
),
|
||||
);
|
||||
|
||||
// Label above the hub, e.g. "RPM"
|
||||
final labelPainter = TextPainter(
|
||||
text: TextSpan(
|
||||
text: label,
|
||||
style: TextStyle(
|
||||
color: muted,
|
||||
fontSize: size.width * 0.055,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
labelPainter.paint(
|
||||
canvas,
|
||||
center + Offset(-labelPainter.width / 2, -size.width * 0.2),
|
||||
);
|
||||
|
||||
// Sub sensor line under the unit
|
||||
final sub = subText;
|
||||
if (sub != null) {
|
||||
final subPainter = TextPainter(
|
||||
text: TextSpan(
|
||||
text: sub,
|
||||
style: TextStyle(
|
||||
color: accentColor,
|
||||
fontSize: size.width * 0.075,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
subPainter.paint(
|
||||
canvas,
|
||||
center +
|
||||
Offset(
|
||||
-subPainter.width / 2,
|
||||
size.width * 0.1 +
|
||||
valPainter.height +
|
||||
unitPainter.height +
|
||||
size.width * 0.02,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_GaugePainter old) =>
|
||||
old.normalizedValue != normalizedValue ||
|
||||
old.accentColor != accentColor ||
|
||||
old.valueText != valueText ||
|
||||
old.subText != subText ||
|
||||
old.label != label ||
|
||||
old.unit != unit ||
|
||||
old.foreground != foreground;
|
||||
}
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AnalogGauge extends StatefulWidget {
|
||||
final double value;
|
||||
final double minValue;
|
||||
final double maxValue;
|
||||
final String label;
|
||||
final String unit;
|
||||
final Color accentColor;
|
||||
|
||||
const AnalogGauge({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.minValue,
|
||||
required this.maxValue,
|
||||
required this.label,
|
||||
required this.unit,
|
||||
required this.accentColor,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AnalogGauge> createState() => _AnalogGaugeState();
|
||||
}
|
||||
|
||||
class _AnalogGaugeState extends State<AnalogGauge>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _ctrl;
|
||||
late Animation<double> _anim;
|
||||
|
||||
double get _normalized =>
|
||||
((widget.value - widget.minValue) /
|
||||
(widget.maxValue - widget.minValue))
|
||||
.clamp(0.0, 1.0);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ctrl = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
);
|
||||
_anim = Tween<double>(begin: 0, end: _normalized)
|
||||
.animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOut));
|
||||
_ctrl.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(AnalogGauge old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (old.value != widget.value) {
|
||||
_anim = Tween<double>(begin: _anim.value, end: _normalized)
|
||||
.animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOut));
|
||||
_ctrl.forward(from: 0);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: AnimatedBuilder(
|
||||
animation: _anim,
|
||||
builder: (_, __) => CustomPaint(
|
||||
painter: _GaugePainter(
|
||||
normalizedValue: _anim.value,
|
||||
accentColor: widget.accentColor,
|
||||
displayValue: widget.value,
|
||||
unit: widget.unit,
|
||||
label: widget.label,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GaugePainter extends CustomPainter {
|
||||
final double normalizedValue; // 0.0–1.0
|
||||
final Color accentColor;
|
||||
final double displayValue;
|
||||
final String unit;
|
||||
final String label;
|
||||
|
||||
static const double _startAngle = 135 * pi / 180; // 7:30 position
|
||||
static const double _sweepAngle = 270 * pi / 180; // 270° sweep
|
||||
|
||||
_GaugePainter({
|
||||
required this.normalizedValue,
|
||||
required this.accentColor,
|
||||
required this.displayValue,
|
||||
required this.unit,
|
||||
required this.label,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final center = Offset(size.width / 2, size.height / 2);
|
||||
final radius = size.width * 0.42;
|
||||
final rect = Rect.fromCircle(center: center, radius: radius);
|
||||
|
||||
// ── Background arc ──────────────────────────────────────────────
|
||||
final bgPaint = Paint()
|
||||
..color = const Color(0xFF2A2A2A)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = size.width * 0.06
|
||||
..strokeCap = StrokeCap.round;
|
||||
canvas.drawArc(rect, _startAngle, _sweepAngle, false, bgPaint);
|
||||
|
||||
// ── Red zone (last 20%) ──────────────────────────────────────────
|
||||
final redPaint = Paint()
|
||||
..color = Colors.red.withAlpha(120)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = size.width * 0.06
|
||||
..strokeCap = StrokeCap.round;
|
||||
canvas.drawArc(
|
||||
rect,
|
||||
_startAngle + _sweepAngle * 0.8,
|
||||
_sweepAngle * 0.2,
|
||||
false,
|
||||
redPaint,
|
||||
);
|
||||
|
||||
// ── Value arc ────────────────────────────────────────────────────
|
||||
if (normalizedValue > 0) {
|
||||
final isRedZone = normalizedValue > 0.8;
|
||||
final valPaint = Paint()
|
||||
..color = isRedZone ? Colors.red : accentColor
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = size.width * 0.06
|
||||
..strokeCap = StrokeCap.round;
|
||||
canvas.drawArc(
|
||||
rect,
|
||||
_startAngle,
|
||||
_sweepAngle * normalizedValue,
|
||||
false,
|
||||
valPaint,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tick marks ───────────────────────────────────────────────────
|
||||
_drawTicks(canvas, center, radius, size);
|
||||
|
||||
// ── Needle ────────────────────────────────────────────────────────
|
||||
_drawNeedle(canvas, center, radius, size);
|
||||
|
||||
// ── Center text ──────────────────────────────────────────────────
|
||||
_drawCenterText(canvas, center, size);
|
||||
}
|
||||
|
||||
void _drawTicks(Canvas canvas, Offset center, double radius, Size size) {
|
||||
final majorPaint = Paint()
|
||||
..color = Colors.white54
|
||||
..strokeWidth = size.width * 0.012
|
||||
..strokeCap = StrokeCap.round;
|
||||
final minorPaint = Paint()
|
||||
..color = Colors.white24
|
||||
..strokeWidth = size.width * 0.006
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
const totalTicks = 50; // 2% each
|
||||
for (int i = 0; i <= totalTicks; i++) {
|
||||
final t = i / totalTicks;
|
||||
final angle = _startAngle + _sweepAngle * t;
|
||||
final isMajor = i % 5 == 0;
|
||||
final paint = isMajor ? majorPaint : minorPaint;
|
||||
final outerR = radius - size.width * 0.01;
|
||||
final innerR = outerR - (isMajor ? size.width * 0.06 : size.width * 0.03);
|
||||
canvas.drawLine(
|
||||
center + Offset(cos(angle) * innerR, sin(angle) * innerR),
|
||||
center + Offset(cos(angle) * outerR, sin(angle) * outerR),
|
||||
paint,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _drawNeedle(Canvas canvas, Offset center, double radius, Size size) {
|
||||
final angle = _startAngle + _sweepAngle * normalizedValue;
|
||||
final tipR = radius - size.width * 0.08;
|
||||
final tailR = size.width * 0.08;
|
||||
|
||||
final needlePaint = Paint()
|
||||
..color = Colors.white
|
||||
..strokeWidth = size.width * 0.018
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
canvas.drawLine(
|
||||
center - Offset(cos(angle) * tailR, sin(angle) * tailR),
|
||||
center + Offset(cos(angle) * tipR, sin(angle) * tipR),
|
||||
needlePaint,
|
||||
);
|
||||
|
||||
// Center dot
|
||||
canvas.drawCircle(
|
||||
center,
|
||||
size.width * 0.04,
|
||||
Paint()..color = Colors.white,
|
||||
);
|
||||
canvas.drawCircle(
|
||||
center,
|
||||
size.width * 0.025,
|
||||
Paint()..color = const Color(0xFF0A0A0A),
|
||||
);
|
||||
}
|
||||
|
||||
void _drawCenterText(Canvas canvas, Offset center, Size size) {
|
||||
// Value
|
||||
final valSpan = TextSpan(
|
||||
text: displayValue.toStringAsFixed(0),
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: size.width * 0.14,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: -1,
|
||||
),
|
||||
);
|
||||
final valPainter = TextPainter(
|
||||
text: valSpan,
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
valPainter.paint(
|
||||
canvas,
|
||||
center +
|
||||
Offset(
|
||||
-valPainter.width / 2,
|
||||
size.width * 0.1,
|
||||
),
|
||||
);
|
||||
|
||||
// Unit
|
||||
final unitSpan = TextSpan(
|
||||
text: unit,
|
||||
style: TextStyle(
|
||||
color: Colors.white54,
|
||||
fontSize: size.width * 0.07,
|
||||
),
|
||||
);
|
||||
final unitPainter = TextPainter(
|
||||
text: unitSpan,
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
unitPainter.paint(
|
||||
canvas,
|
||||
center +
|
||||
Offset(
|
||||
-unitPainter.width / 2,
|
||||
size.width * 0.1 + valPainter.height + 2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_GaugePainter old) =>
|
||||
old.normalizedValue != normalizedValue ||
|
||||
old.accentColor != accentColor ||
|
||||
old.displayValue != displayValue;
|
||||
}
|
||||
|
||||
@ -1,95 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/models/sensor_defs.dart';
|
||||
import '../../../../core/models/units.dart';
|
||||
import '../../../../core/protocol/sensor_state.dart';
|
||||
import '../../../../core/providers/settings_provider.dart';
|
||||
import '../../../theme/app_colors.dart';
|
||||
import '../dashboard_slots_provider.dart';
|
||||
import 'analog_gauge.dart';
|
||||
import 'sensor_picker_sheet.dart';
|
||||
|
||||
/// Gauge for the selectable main sensor with the sub sensor as a second
|
||||
/// line. Long-press to change either.
|
||||
class MainGauge extends ConsumerWidget {
|
||||
final SensorState state;
|
||||
|
||||
const MainGauge({super.key, required this.state});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final c = AppColors.of(context);
|
||||
final config = ref.watch(dashboardConfigProvider);
|
||||
final units = ref.watch(unitPrefsProvider);
|
||||
final main = sensorView(config.main, units);
|
||||
final sub = sensorView(config.sub, units);
|
||||
final mainBase = sensorValue(state, config.main);
|
||||
final subBase = sensorValue(state, config.sub);
|
||||
|
||||
return GestureDetector(
|
||||
onLongPress: () => _editGauge(context, ref),
|
||||
child: AnalogGauge(
|
||||
value: main.convert(mainBase),
|
||||
minValue: main.min,
|
||||
maxValue: main.max,
|
||||
label: main.name.toUpperCase(),
|
||||
unit: main.unit,
|
||||
valueText: main.format(mainBase),
|
||||
subText: '${sub.format(subBase)} ${sub.unit}'.trim(),
|
||||
accentColor: c.accent,
|
||||
foreground: c.onSurface,
|
||||
muted: c.onSurfaceMuted,
|
||||
track: c.track,
|
||||
background: c.background,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _editGauge(BuildContext context, WidgetRef ref) async {
|
||||
final which = await showPickerSheet(
|
||||
context,
|
||||
title: 'GAUGE',
|
||||
currentId: '',
|
||||
items: const [
|
||||
PickerItem('main', 'Change main sensor'),
|
||||
PickerItem('sub', 'Change sub sensor'),
|
||||
],
|
||||
);
|
||||
if (which == null || !context.mounted) return;
|
||||
final config = ref.read(dashboardConfigProvider);
|
||||
final id = await pickSensor(
|
||||
context,
|
||||
title: which == 'main' ? 'MAIN SENSOR' : 'SUB SENSOR',
|
||||
currentId: which == 'main' ? config.main : config.sub,
|
||||
units: ref.read(unitPrefsProvider),
|
||||
);
|
||||
if (id == null) return;
|
||||
final n = ref.read(dashboardConfigProvider.notifier);
|
||||
which == 'main' ? n.setMain(id) : n.setSub(id);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> editDashboardValue(
|
||||
BuildContext context, WidgetRef ref, int index) async {
|
||||
final id = await pickSensor(
|
||||
context,
|
||||
title: 'SELECT SENSOR',
|
||||
currentId: ref.read(dashboardConfigProvider).values[index],
|
||||
units: ref.read(unitPrefsProvider),
|
||||
);
|
||||
if (id != null) {
|
||||
ref.read(dashboardConfigProvider.notifier).setValue(index, id);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> editDashboardFlag(
|
||||
BuildContext context, WidgetRef ref, int index) async {
|
||||
final id = await pickFlag(
|
||||
context,
|
||||
currentId: ref.read(dashboardConfigProvider).flags[index],
|
||||
);
|
||||
if (id != null) {
|
||||
ref.read(dashboardConfigProvider.notifier).setFlag(index, id);
|
||||
}
|
||||
}
|
||||
@ -1,106 +1,66 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/models/sensor_defs.dart';
|
||||
import '../../../../core/protocol/sensor_state.dart';
|
||||
import '../../../theme/app_colors.dart';
|
||||
|
||||
/// Colour of an active flag. Faults red, warnings amber, status green/blue.
|
||||
Color flagColor(String id) {
|
||||
switch (id) {
|
||||
case 'mil':
|
||||
case 'knock':
|
||||
case 'o2error':
|
||||
case 'iaterror':
|
||||
case 'ecterror':
|
||||
return Colors.red;
|
||||
case 'revlimit':
|
||||
case 'igncut':
|
||||
case 'boostcut':
|
||||
case 'launch':
|
||||
case 'launchretard':
|
||||
case 'shiftcut':
|
||||
return Colors.orange;
|
||||
case 'vtec':
|
||||
case 'vtp':
|
||||
return Colors.blue;
|
||||
default:
|
||||
return Colors.green;
|
||||
}
|
||||
}
|
||||
|
||||
class FlagPill extends StatelessWidget {
|
||||
final String label;
|
||||
final bool active;
|
||||
final Color color;
|
||||
|
||||
const FlagPill({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.active,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = AppColors.of(context);
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 100),
|
||||
height: 30,
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? color.withAlpha(200) : c.surface,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(
|
||||
color: active ? color : c.accent.withAlpha(90),
|
||||
width: 1.2,
|
||||
),
|
||||
),
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: active ? Colors.white : c.onSurfaceMuted,
|
||||
fontSize: 12,
|
||||
fontWeight: active ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The 4 selectable flags of the dashboard. Long-press a pill to change it.
|
||||
class FlagRow extends StatelessWidget {
|
||||
final SensorState state;
|
||||
final List<String> flagIds;
|
||||
final void Function(int index) onLongPress;
|
||||
|
||||
const FlagRow({
|
||||
super.key,
|
||||
required this.state,
|
||||
required this.flagIds,
|
||||
required this.onLongPress,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
for (var i = 0; i < flagIds.length; i++) ...[
|
||||
if (i > 0) const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onLongPress: () => onLongPress(i),
|
||||
child: FlagPill(
|
||||
label: flagDef(flagIds[i]).displayName,
|
||||
active: flagDef(flagIds[i]).read(state),
|
||||
color: flagColor(flagIds[i]),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/protocol/sensor_state.dart';
|
||||
|
||||
class FlagPill extends StatelessWidget {
|
||||
final String label;
|
||||
final bool active;
|
||||
final Color color;
|
||||
|
||||
const FlagPill({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.active,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 100),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? color.withAlpha(40) : const Color(0xFF1A1A1A),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: active ? color : Colors.white12,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: active ? color : Colors.grey,
|
||||
fontSize: 11,
|
||||
fontWeight: active ? FontWeight.bold : FontWeight.normal,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Row of all flag pills derived from a SensorState.
|
||||
class FlagRow extends StatelessWidget {
|
||||
final SensorState state;
|
||||
final Color accentColor;
|
||||
|
||||
const FlagRow({super.key, required this.state, required this.accentColor});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
alignment: WrapAlignment.center,
|
||||
children: [
|
||||
FlagPill(label: 'MIL', active: state.mil, color: Colors.amber),
|
||||
FlagPill(label: 'VTEC', active: state.vtec, color: Colors.blue),
|
||||
FlagPill(label: 'KNOCK', active: state.knock, 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: 'FAN', active: state.fanOut, color: Colors.green),
|
||||
FlagPill(label: 'LAUNCH', active: state.launch, color: Colors.purple),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,147 +1,85 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/models/sensor_defs.dart';
|
||||
import '../../../../core/models/units.dart';
|
||||
import '../../../theme/app_colors.dart';
|
||||
|
||||
/// One choosable entry in [showPickerSheet].
|
||||
class PickerItem {
|
||||
final String id;
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
const PickerItem(this.id, this.title, [this.subtitle]);
|
||||
}
|
||||
|
||||
/// Bottom sheet listing [items]; resolves to the chosen id, or null.
|
||||
Future<String?> showPickerSheet(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required List<PickerItem> items,
|
||||
required String currentId,
|
||||
}) {
|
||||
return showModalBottomSheet<String>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => _PickerSheet(
|
||||
title: title,
|
||||
items: items,
|
||||
currentId: currentId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Picker over every analog sensor, showing the unit currently in use.
|
||||
Future<String?> pickSensor(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required String currentId,
|
||||
required UnitPrefs units,
|
||||
}) {
|
||||
return showPickerSheet(
|
||||
context,
|
||||
title: title,
|
||||
currentId: currentId,
|
||||
items: [
|
||||
for (final d in sensorDefs)
|
||||
PickerItem(d.id, d.displayName, sensorView(d.id, units).unit),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Picker over every flag.
|
||||
Future<String?> pickFlag(BuildContext context, {required String currentId}) {
|
||||
return showPickerSheet(
|
||||
context,
|
||||
title: 'SELECT FLAG',
|
||||
currentId: currentId,
|
||||
items: [for (final f in flagDefs) PickerItem(f.id, f.displayName)],
|
||||
);
|
||||
}
|
||||
|
||||
class _PickerSheet extends StatelessWidget {
|
||||
final String title;
|
||||
final List<PickerItem> items;
|
||||
final String currentId;
|
||||
|
||||
const _PickerSheet({
|
||||
required this.title,
|
||||
required this.items,
|
||||
required this.currentId,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = AppColors.of(context);
|
||||
return DraggableScrollableSheet(
|
||||
expand: false,
|
||||
initialChildSize: 0.6,
|
||||
minChildSize: 0.4,
|
||||
maxChildSize: 0.9,
|
||||
builder: (context, scrollController) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: c.surface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: c.onSurfaceMuted.withAlpha(90),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: c.onSurfaceMuted,
|
||||
fontSize: 12,
|
||||
letterSpacing: 2,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Divider(height: 1, color: c.divider),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
controller: scrollController,
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, i) {
|
||||
final item = items[i];
|
||||
final isSelected = item.id == currentId;
|
||||
return ListTile(
|
||||
title: Text(
|
||||
item.title,
|
||||
style: TextStyle(
|
||||
color: c.onSurface,
|
||||
fontWeight:
|
||||
isSelected ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
subtitle: item.subtitle == null
|
||||
? null
|
||||
: Text(
|
||||
item.subtitle!,
|
||||
style: TextStyle(
|
||||
color: c.onSurfaceMuted, fontSize: 12),
|
||||
),
|
||||
trailing: isSelected
|
||||
? Icon(Icons.check, color: c.accent, size: 18)
|
||||
: null,
|
||||
onTap: () => Navigator.pop(context, item.id),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/models/sensor_defs.dart';
|
||||
|
||||
class SensorPickerSheet extends StatelessWidget {
|
||||
final String currentId;
|
||||
final void Function(String id) onSelected;
|
||||
|
||||
const SensorPickerSheet({
|
||||
super.key,
|
||||
required this.currentId,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DraggableScrollableSheet(
|
||||
expand: false,
|
||||
initialChildSize: 0.6,
|
||||
minChildSize: 0.4,
|
||||
maxChildSize: 0.9,
|
||||
builder: (context, scrollController) {
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFF1A1A1A),
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: 40, height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white24,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'SELECT SENSOR',
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 12,
|
||||
letterSpacing: 2,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1, color: Colors.white10),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
controller: scrollController,
|
||||
itemCount: sensorDefs.length,
|
||||
itemBuilder: (context, i) {
|
||||
final def = sensorDefs[i];
|
||||
final isSelected = def.id == currentId;
|
||||
return ListTile(
|
||||
title: Text(
|
||||
def.displayName,
|
||||
style: TextStyle(
|
||||
color: isSelected ? Colors.white : Colors.white70,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
def.unit,
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
trailing: isSelected
|
||||
? const Icon(Icons.check, color: Colors.green, size: 18)
|
||||
: null,
|
||||
onTap: () => onSelected(def.id),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,139 +1,128 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/models/sensor_defs.dart';
|
||||
import '../../../../core/models/units.dart';
|
||||
import '../../../../core/protocol/sensor_state.dart';
|
||||
import '../../../theme/app_colors.dart';
|
||||
|
||||
/// One selectable dashboard value: "unit (name)" caption over a large
|
||||
/// number, as in the requirement layouts. Long-press to change the sensor.
|
||||
class SensorTile extends StatelessWidget {
|
||||
final String sensorId;
|
||||
final SensorState state;
|
||||
final UnitPrefs units;
|
||||
final VoidCallback onLongPress;
|
||||
|
||||
const SensorTile({
|
||||
super.key,
|
||||
required this.sensorId,
|
||||
required this.state,
|
||||
required this.units,
|
||||
required this.onLongPress,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = AppColors.of(context);
|
||||
final view = sensorView(sensorId, units);
|
||||
final base = sensorValue(state, sensorId);
|
||||
final fraction = view.fraction(base);
|
||||
final caption =
|
||||
view.unit.isEmpty ? view.name : '${view.unit} (${view.name})';
|
||||
|
||||
return GestureDetector(
|
||||
onLongPress: onLongPress,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, box) {
|
||||
final valueText = Text(
|
||||
view.format(base),
|
||||
style: TextStyle(
|
||||
color: fraction > 0.9 ? Colors.red : c.onSurface,
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontStyle: FontStyle.italic,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
);
|
||||
final captionText = Text(
|
||||
caption,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: c.onSurfaceMuted,
|
||||
fontSize: 10,
|
||||
fontStyle: FontStyle.italic,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
);
|
||||
// Short tiles (5 per side in landscape): caption beside the value
|
||||
// so the number keeps its size.
|
||||
if (box.maxHeight < 56) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: c.surface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: c.divider),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(flex: 4, child: captionText),
|
||||
Expanded(
|
||||
flex: 5,
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerRight,
|
||||
child: valueText,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
final showBar = box.maxHeight >= 64;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: c.surface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: c.divider),
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(8, 4, 8, 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
caption,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: c.onSurfaceMuted,
|
||||
fontSize: 10,
|
||||
fontStyle: FontStyle.italic,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(
|
||||
view.format(base),
|
||||
style: TextStyle(
|
||||
color: fraction > 0.9 ? Colors.red : c.onSurface,
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontStyle: FontStyle.italic,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showBar)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
child: LinearProgressIndicator(
|
||||
value: fraction,
|
||||
minHeight: 3,
|
||||
backgroundColor: c.track,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
fraction > 0.9 ? Colors.red : c.accent,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/models/sensor_defs.dart';
|
||||
import '../../../../core/protocol/sensor_state.dart';
|
||||
|
||||
class SensorTile extends StatelessWidget {
|
||||
final int slotIndex;
|
||||
final String sensorId;
|
||||
final SensorState state;
|
||||
final Color accentColor;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const SensorTile({
|
||||
super.key,
|
||||
required this.slotIndex,
|
||||
required this.sensorId,
|
||||
required this.state,
|
||||
required this.accentColor,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final def = sensorDefs.firstWhere(
|
||||
(d) => d.id == sensorId,
|
||||
orElse: () => sensorDefs.first,
|
||||
);
|
||||
final value = _getValue(state, sensorId);
|
||||
final normalized =
|
||||
((value - def.min) / (def.max - def.min)).clamp(0.0, 1.0);
|
||||
|
||||
return GestureDetector(
|
||||
onLongPress: onTap,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1A1A1A),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: Colors.white10),
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(10, 8, 10, 6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
def.displayName.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 10,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
_format(value, sensorId),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: Text(
|
||||
def.unit,
|
||||
style: TextStyle(color: accentColor, fontSize: 11),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Bar gauge
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
child: LinearProgressIndicator(
|
||||
value: normalized,
|
||||
minHeight: 4,
|
||||
backgroundColor: Colors.white10,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
normalized > 0.8 ? Colors.red : accentColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _format(double v, String id) {
|
||||
if (id == 'gear') return v.toStringAsFixed(0);
|
||||
if (id == 'rpm') return v.toStringAsFixed(0);
|
||||
if (v.abs() >= 100) return v.toStringAsFixed(1);
|
||||
return v.toStringAsFixed(2);
|
||||
}
|
||||
}
|
||||
|
||||
double _getValue(SensorState s, String id) {
|
||||
switch (id) {
|
||||
case 'rpm': return s.rpm;
|
||||
case 'vss': return s.vss;
|
||||
case 'map': return s.map;
|
||||
case 'tps': return s.tps;
|
||||
case 'inj': return s.inj;
|
||||
case 'ign': return s.ign;
|
||||
case 'ect': return s.ect;
|
||||
case 'iat': return s.iat;
|
||||
case 'bat': return s.bat;
|
||||
case 'o2': return s.o2;
|
||||
case 'gear': return s.gear.toDouble();
|
||||
case 'eth': return s.eth;
|
||||
case 'pa': return s.pa;
|
||||
case 'afr': return s.afr;
|
||||
case 'strim': return s.strim;
|
||||
case 'ltrim': return s.ltrim;
|
||||
case 'ain0': return s.ain0;
|
||||
case 'ain1': return s.ain1;
|
||||
case 'ain2': return s.ain2;
|
||||
case 'ain3': return s.ain3;
|
||||
case 'ain4': return s.ain4;
|
||||
case 'ain5': return s.ain5;
|
||||
case 'ain6': return s.ain6;
|
||||
case 'ain7': return s.ain7;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,127 +1,124 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../core/models/datalog_session.dart';
|
||||
import '../../../core/providers/datalog_provider.dart';
|
||||
import 'datalog_playback_bar.dart';
|
||||
|
||||
class DatalogListScreen extends ConsumerWidget {
|
||||
const DatalogListScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final sessionsAsync = ref.watch(sessionListProvider);
|
||||
final playback = ref.watch(playbackProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Saved Sessions')),
|
||||
body: Column(
|
||||
children: [
|
||||
if (playback.isActive)
|
||||
DatalogPlaybackBar(state: playback),
|
||||
Expanded(
|
||||
child: sessionsAsync.when(
|
||||
loading: () =>
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(
|
||||
child: Text('Error: $e',
|
||||
style: const TextStyle(color: Colors.red))),
|
||||
data: (sessions) => sessions.isEmpty
|
||||
? const Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.videocam_off,
|
||||
size: 56, color: Colors.grey),
|
||||
SizedBox(height: 12),
|
||||
Text('No recorded sessions',
|
||||
style: TextStyle(color: Colors.grey)),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
itemCount: sessions.length,
|
||||
separatorBuilder: (_, __) =>
|
||||
const Divider(height: 1),
|
||||
itemBuilder: (context, i) =>
|
||||
_SessionTile(session: sessions[i]),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SessionTile extends ConsumerWidget {
|
||||
final DatalogSession session;
|
||||
const _SessionTile({required this.session});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fmt = DateFormat('dd MMM yyyy HH:mm');
|
||||
final isPlaying =
|
||||
ref.watch(playbackProvider).sessionId == session.id;
|
||||
|
||||
return Dismissible(
|
||||
key: ValueKey(session.id),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
color: Colors.red,
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 20),
|
||||
child: const Icon(Icons.delete, color: Colors.white),
|
||||
),
|
||||
confirmDismiss: (_) async {
|
||||
return await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Delete session?'),
|
||||
content: const Text('This cannot be undone.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Cancel')),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Delete',
|
||||
style: TextStyle(color: Colors.red))),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
onDismissed: (_) async {
|
||||
await ref
|
||||
.read(datalogDbProvider)
|
||||
.deleteSession(session.id!);
|
||||
ref.invalidate(sessionListProvider);
|
||||
},
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
isPlaying ? Icons.play_circle : Icons.videocam,
|
||||
color: isPlaying ? Colors.green : Colors.grey,
|
||||
),
|
||||
title: Text(
|
||||
fmt.format(session.startTime),
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${session.ecuType.toUpperCase()} • '
|
||||
'${session.frameCount} frames • ${session.durationLabel}',
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 11),
|
||||
),
|
||||
trailing: const Icon(Icons.play_arrow, size: 18),
|
||||
onTap: () async {
|
||||
await ref
|
||||
.read(playbackProvider.notifier)
|
||||
.loadSession(session.id!, session.ecuType);
|
||||
ref.read(playbackProvider.notifier).play();
|
||||
// Back to the dashboard: the displays now show the recording,
|
||||
// with the playback bar for navigation.
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../core/models/datalog_session.dart';
|
||||
import '../../../core/providers/datalog_provider.dart';
|
||||
import 'datalog_playback_bar.dart';
|
||||
|
||||
class DatalogListScreen extends ConsumerWidget {
|
||||
const DatalogListScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final sessionsAsync = ref.watch(sessionListProvider);
|
||||
final playback = ref.watch(playbackProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Saved Sessions')),
|
||||
body: Column(
|
||||
children: [
|
||||
if (playback.isActive)
|
||||
DatalogPlaybackBar(state: playback),
|
||||
Expanded(
|
||||
child: sessionsAsync.when(
|
||||
loading: () =>
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(
|
||||
child: Text('Error: $e',
|
||||
style: const TextStyle(color: Colors.red))),
|
||||
data: (sessions) => sessions.isEmpty
|
||||
? const Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.videocam_off,
|
||||
size: 56, color: Colors.grey),
|
||||
SizedBox(height: 12),
|
||||
Text('No recorded sessions',
|
||||
style: TextStyle(color: Colors.grey)),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
itemCount: sessions.length,
|
||||
separatorBuilder: (_, __) =>
|
||||
const Divider(height: 1, color: Colors.white10),
|
||||
itemBuilder: (context, i) =>
|
||||
_SessionTile(session: sessions[i]),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SessionTile extends ConsumerWidget {
|
||||
final DatalogSession session;
|
||||
const _SessionTile({required this.session});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fmt = DateFormat('dd MMM yyyy HH:mm');
|
||||
final isPlaying =
|
||||
ref.watch(playbackProvider).sessionId == session.id;
|
||||
|
||||
return Dismissible(
|
||||
key: ValueKey(session.id),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
color: Colors.red,
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 20),
|
||||
child: const Icon(Icons.delete, color: Colors.white),
|
||||
),
|
||||
confirmDismiss: (_) async {
|
||||
return await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Delete session?'),
|
||||
content: const Text('This cannot be undone.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Cancel')),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Delete',
|
||||
style: TextStyle(color: Colors.red))),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
onDismissed: (_) async {
|
||||
await ref
|
||||
.read(datalogDbProvider)
|
||||
.deleteSession(session.id!);
|
||||
ref.invalidate(sessionListProvider);
|
||||
},
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
isPlaying ? Icons.play_circle : Icons.videocam,
|
||||
color: isPlaying ? Colors.green : Colors.grey,
|
||||
),
|
||||
title: Text(
|
||||
fmt.format(session.startTime),
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${session.ecuType.toUpperCase()} • '
|
||||
'${session.frameCount} frames • ${session.durationLabel}',
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 11),
|
||||
),
|
||||
trailing: const Icon(Icons.play_arrow, size: 18),
|
||||
onTap: () async {
|
||||
await ref
|
||||
.read(playbackProvider.notifier)
|
||||
.loadSession(session.id!);
|
||||
ref.read(playbackProvider.notifier).play();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,91 +1,90 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/datalog/datalog_player.dart';
|
||||
import '../../../core/providers/datalog_provider.dart';
|
||||
import '../../theme/app_colors.dart';
|
||||
|
||||
class DatalogPlaybackBar extends ConsumerWidget {
|
||||
final PlaybackState state;
|
||||
const DatalogPlaybackBar({super.key, required this.state});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final notifier = ref.read(playbackProvider.notifier);
|
||||
final isPlaying = state.status == PlaybackStatus.playing;
|
||||
final c = AppColors.of(context);
|
||||
|
||||
return Container(
|
||||
color: c.surface,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Progress slider
|
||||
SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
trackHeight: 3,
|
||||
thumbShape:
|
||||
const RoundSliderThumbShape(enabledThumbRadius: 6),
|
||||
overlayShape:
|
||||
const RoundSliderOverlayShape(overlayRadius: 12),
|
||||
),
|
||||
child: Slider(
|
||||
value: state.progress,
|
||||
onChanged: (v) =>
|
||||
notifier.seek((v * state.total).round()),
|
||||
),
|
||||
),
|
||||
// Controls row
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// Frame counter
|
||||
Text(
|
||||
'${state.position} / ${state.total}',
|
||||
style:
|
||||
TextStyle(color: c.onSurfaceMuted, fontSize: 11),
|
||||
),
|
||||
// Play / Pause / Stop
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
isPlaying ? Icons.pause : Icons.play_arrow,
|
||||
size: 22,
|
||||
),
|
||||
onPressed: isPlaying ? notifier.pause : notifier.play,
|
||||
padding: const EdgeInsets.all(4),
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.stop, size: 22),
|
||||
onPressed: () async => notifier.stop(),
|
||||
padding: const EdgeInsets.all(4),
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Speed selector
|
||||
DropdownButton<double>(
|
||||
value: state.speed,
|
||||
isDense: true,
|
||||
underline: const SizedBox(),
|
||||
style: TextStyle(color: c.onSurface, fontSize: 12),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 0.5, child: Text('0.5×')),
|
||||
DropdownMenuItem(value: 1.0, child: Text('1×')),
|
||||
DropdownMenuItem(value: 2.0, child: Text('2×')),
|
||||
DropdownMenuItem(value: 5.0, child: Text('5×')),
|
||||
],
|
||||
onChanged: (v) {
|
||||
if (v != null) notifier.setSpeed(v);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/datalog/datalog_player.dart';
|
||||
import '../../../core/providers/datalog_provider.dart';
|
||||
|
||||
class DatalogPlaybackBar extends ConsumerWidget {
|
||||
final PlaybackState state;
|
||||
const DatalogPlaybackBar({super.key, required this.state});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final notifier = ref.read(playbackProvider.notifier);
|
||||
final isPlaying = state.status == PlaybackStatus.playing;
|
||||
|
||||
return Container(
|
||||
color: const Color(0xFF1A1A1A),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Progress slider
|
||||
SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
trackHeight: 3,
|
||||
thumbShape:
|
||||
const RoundSliderThumbShape(enabledThumbRadius: 6),
|
||||
overlayShape:
|
||||
const RoundSliderOverlayShape(overlayRadius: 12),
|
||||
),
|
||||
child: Slider(
|
||||
value: state.progress,
|
||||
onChanged: (v) =>
|
||||
notifier.seek((v * state.total).round()),
|
||||
),
|
||||
),
|
||||
// Controls row
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// Frame counter
|
||||
Text(
|
||||
'${state.position} / ${state.total}',
|
||||
style:
|
||||
const TextStyle(color: Colors.grey, fontSize: 11),
|
||||
),
|
||||
// Play / Pause / Stop
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
isPlaying ? Icons.pause : Icons.play_arrow,
|
||||
size: 22,
|
||||
),
|
||||
onPressed: isPlaying ? notifier.pause : notifier.play,
|
||||
padding: const EdgeInsets.all(4),
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.stop, size: 22),
|
||||
onPressed: () async => notifier.stop(),
|
||||
padding: const EdgeInsets.all(4),
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Speed selector
|
||||
DropdownButton<double>(
|
||||
value: state.speed,
|
||||
isDense: true,
|
||||
underline: const SizedBox(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white70, fontSize: 12),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 0.5, child: Text('0.5×')),
|
||||
DropdownMenuItem(value: 1.0, child: Text('1×')),
|
||||
DropdownMenuItem(value: 2.0, child: Text('2×')),
|
||||
DropdownMenuItem(value: 5.0, child: Text('5×')),
|
||||
],
|
||||
onChanged: (v) {
|
||||
if (v != null) notifier.setSpeed(v);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,419 +1,391 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/bluetooth/bt_poller.dart';
|
||||
import '../../../core/logging/app_log.dart';
|
||||
import '../../../core/providers/bt_provider.dart';
|
||||
import '../../../core/providers/sensor_provider.dart';
|
||||
import '../../../core/providers/settings_provider.dart';
|
||||
import '../../../core/protocol/neg8.dart';
|
||||
import '../../../core/protocol/sensor_state.dart';
|
||||
|
||||
class RawFrameDebugScreen extends ConsumerStatefulWidget {
|
||||
const RawFrameDebugScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<RawFrameDebugScreen> createState() =>
|
||||
_RawFrameDebugScreenState();
|
||||
}
|
||||
|
||||
class _RawFrameDebugScreenState extends ConsumerState<RawFrameDebugScreen> {
|
||||
Uint8List? _probeResult;
|
||||
bool _probing = false;
|
||||
|
||||
Future<void> _runProbe() async {
|
||||
setState(() {
|
||||
_probing = true;
|
||||
_probeResult = null;
|
||||
});
|
||||
final result =
|
||||
await ref.read(btProvider.notifier).probeOfficialProtocol();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_probing = false;
|
||||
_probeResult = result;
|
||||
});
|
||||
}
|
||||
|
||||
@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);
|
||||
final frame = bt.lastFrame;
|
||||
final hex = frame == null ? '' : _hex(frame);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('ECU Frame Debug'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Copy app log',
|
||||
icon: const Icon(Icons.description_outlined),
|
||||
onPressed: () async {
|
||||
await Clipboard.setData(ClipboardData(text: AppLog.dump()));
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'App log copied (${AppLog.length} lines) — paste it into an email/message')),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Copy raw frame',
|
||||
icon: const Icon(Icons.copy),
|
||||
onPressed: frame == null
|
||||
? null
|
||||
: () async {
|
||||
await Clipboard.setData(ClipboardData(text: hex));
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Raw frame copied')),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
children: [
|
||||
_Section(
|
||||
title: 'Connection',
|
||||
rows: [
|
||||
_RowData('Protocol', settings.ecuType.name.toUpperCase()),
|
||||
_RowData('Link status', bt.status.name),
|
||||
_RowData('ECU data', bt.dataStatus.name),
|
||||
_RowData('Poll interval', '${settings.pollingInterval.inMilliseconds} ms'),
|
||||
_RowData('Requests sent', bt.requestsSent.toString()),
|
||||
_RowData('Valid replies', bt.frameCount.toString()),
|
||||
_RowData(' live ECU frames', bt.liveFrames.toString()),
|
||||
_RowData(' adapter default frames', bt.defaultFrames.toString()),
|
||||
_RowData(' RPM glitch frames dropped', bt.rpmGlitches.toString()),
|
||||
_RowData('Timeouts (no reply)', bt.timeouts.toString()),
|
||||
_RowData('Dropped (bad checksum)', bt.droppedFrames.toString()),
|
||||
_RowData('Last reply time', bt.lastResponseMs == null ? '-' : '${bt.lastResponseMs} ms'),
|
||||
_RowData('Auto-reconnects', bt.reconnects.toString()),
|
||||
_RowData('Last frame bytes', frame?.length.toString() ?? 'none'),
|
||||
if (frame != null)
|
||||
_RowData(
|
||||
'Checksum',
|
||||
validateFrame(frame) ? 'valid' : 'bad',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_ProtocolProbeCard(
|
||||
connected: bt.isConnected,
|
||||
probing: _probing,
|
||||
result: _probeResult,
|
||||
onProbe: _runProbe,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_Section(
|
||||
title: 'Decode Pipeline',
|
||||
rows: [
|
||||
_RowData('Decoded -> UI', decodeStats.decoded.toString()),
|
||||
_RowData('Skipped (decode error)', decodeStats.decodeErrors.toString()),
|
||||
_RowData(
|
||||
'sensorStateProvider raw',
|
||||
sensorAsync.when(
|
||||
data: (s) =>
|
||||
'data(rpm=${s.rpm.toStringAsFixed(0)}, age=${DateTime.now().difference(s.timestamp).inMilliseconds}ms)',
|
||||
loading: () => 'loading (no frame yet)',
|
||||
error: (e, st) => 'error: $e',
|
||||
),
|
||||
),
|
||||
_RowData(
|
||||
'latestSensorProvider age',
|
||||
'${DateTime.now().difference(state.timestamp).inMilliseconds}ms since decode',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_Section(
|
||||
title: 'Current App Decode',
|
||||
rows: _decodedRows(state),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (frame == null)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Text(
|
||||
'No ECU frame received yet.',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
if (settings.ecuType == EcuType.s300) ...[
|
||||
_Section(
|
||||
title: 'S300 Raw Candidates',
|
||||
rows: _s300CandidateRows(frame),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
_RawHexBlock(hex: hex),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<_RowData> _decodedRows(SensorState s) => [
|
||||
_RowData('RPM', s.rpm.toStringAsFixed(0)),
|
||||
_RowData('Speed km/h', s.vss.toStringAsFixed(2)),
|
||||
_RowData('MAP kPa', s.map.toStringAsFixed(2)),
|
||||
_RowData('TPS', s.tps.toStringAsFixed(2)),
|
||||
_RowData('ECT', s.ect.toStringAsFixed(2)),
|
||||
_RowData('IAT', s.iat.toStringAsFixed(2)),
|
||||
_RowData('Battery', s.bat.toStringAsFixed(2)),
|
||||
_RowData('O2', s.o2.toStringAsFixed(2)),
|
||||
_RowData('AFR', s.afr.toStringAsFixed(2)),
|
||||
_RowData('MIL (unverified)', s.mil.toString()),
|
||||
_RowData('Fuel Pump', s.fuelPump.toString()),
|
||||
_RowData('Fan Out (unverified)', s.fanOut.toString()),
|
||||
_RowData('VTEC (unverified)', s.vtec.toString()),
|
||||
_RowData('Knock', s.knock.toString()),
|
||||
_RowData('Rev Limit (unverified)', s.revLimit.toString()),
|
||||
_RowData('Launch (unverified)', s.launch.toString()),
|
||||
];
|
||||
|
||||
List<_RowData> _s300CandidateRows(Uint8List f) {
|
||||
final rpmLe = _u16le(f, 3);
|
||||
final vssLe = _u16le(f, 5);
|
||||
final mapLe = _u16le(f, 7);
|
||||
|
||||
return [
|
||||
_RowData('Header bytes', '${_b(f, 0)} ${_b(f, 1)} ${_b(f, 2)}'),
|
||||
_RowData('RPM raw @3 LE', rpmLe.toString()),
|
||||
_RowData('VSS raw @5 LE', vssLe.toString()),
|
||||
_RowData('VSS current km/h', _s300Vss(vssLe).toStringAsFixed(2)),
|
||||
_RowData('MAP raw @7 LE', mapLe.toString()),
|
||||
_RowData('MAP current kPa', _s300Map(mapLe).toStringAsFixed(2)),
|
||||
_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', _u16le(f, 10).toString()),
|
||||
_RowData('IGN raw @12', f[12].toString()),
|
||||
_RowData('O2 raw @16', f[16].toString()),
|
||||
_RowData('Strim raw @29 / Ltrim raw @31', '${f[29]} / ${f[31]}'),
|
||||
_RowData('PA raw @32', f[32].toString()),
|
||||
_RowData('ECT raw @33', f[33].toString()),
|
||||
_RowData('IAT raw @34', f[34].toString()),
|
||||
_RowData('BAT raw @36', f[36].toString()),
|
||||
_RowData('ERR00-03 @37-40',
|
||||
'${_b(f, 37)} ${_b(f, 38)} ${_b(f, 39)} ${_b(f, 40)}'),
|
||||
_RowData('Eth raw @41', f[41].toString()),
|
||||
_RowData('AF raw @55', f[55].toString()),
|
||||
_RowData('SW1 @17 (bit7..0)', '${_b(f, 17)} ${_bin(f, 17)}'),
|
||||
_RowData('SW2 @18 (bit7..0)', '${_b(f, 18)} ${_bin(f, 18)}'),
|
||||
_RowData('SW3 @19 (bit7..0)', '${_b(f, 19)} ${_bin(f, 19)}'),
|
||||
_RowData('SW5 @49 (bit7..0)', '${_b(f, 49)} ${_bin(f, 49)}'),
|
||||
_RowData('SW6 @50 (bit7..0)', '${_b(f, 50)} ${_bin(f, 50)}'),
|
||||
];
|
||||
}
|
||||
|
||||
static int _u16le(Uint8List f, int o) => f[o] | (f[o + 1] << 8);
|
||||
static String _b(Uint8List f, int o) =>
|
||||
f[o].toRadixString(16).padLeft(2, '0');
|
||||
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) {
|
||||
return raw / 10.0;
|
||||
}
|
||||
|
||||
static double _s300Tps(int raw) {
|
||||
return (raw * 100.0 / 255.0).clamp(0.0, 100.0);
|
||||
}
|
||||
|
||||
static String _hex(Uint8List frame) {
|
||||
final parts = <String>[];
|
||||
for (var i = 0; i < frame.length; i++) {
|
||||
if (i > 0 && i % 16 == 0) parts.add('\n');
|
||||
parts.add(frame[i].toRadixString(16).padLeft(2, '0'));
|
||||
}
|
||||
return parts.join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
/// Step 1 of the data-layer review: checks whether this device/firmware
|
||||
/// understands the official, self-describing Hondata Bluetooth protocol
|
||||
/// (Bluetooth.pdf) rather than only the legacy fixed-offset frame format
|
||||
/// this app currently speaks. A real reply here (Protocol=0x00, ID=0x02,
|
||||
/// with a recognizable DeviceType byte) means migrating to that protocol is
|
||||
/// on the table; silence means this hardware is legacy-only.
|
||||
class _ProtocolProbeCard extends StatelessWidget {
|
||||
final bool connected;
|
||||
final bool probing;
|
||||
final Uint8List? result;
|
||||
final VoidCallback onProbe;
|
||||
|
||||
const _ProtocolProbeCard({
|
||||
required this.connected,
|
||||
required this.probing,
|
||||
required this.result,
|
||||
required this.onProbe,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Official Protocol Probe',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
|
||||
),
|
||||
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),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
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);
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/bluetooth/bt_poller.dart';
|
||||
import '../../../core/providers/bt_provider.dart';
|
||||
import '../../../core/providers/sensor_provider.dart';
|
||||
import '../../../core/providers/settings_provider.dart';
|
||||
import '../../../core/protocol/neg8.dart';
|
||||
import '../../../core/protocol/sensor_state.dart';
|
||||
|
||||
class RawFrameDebugScreen extends ConsumerStatefulWidget {
|
||||
const RawFrameDebugScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<RawFrameDebugScreen> createState() =>
|
||||
_RawFrameDebugScreenState();
|
||||
}
|
||||
|
||||
class _RawFrameDebugScreenState extends ConsumerState<RawFrameDebugScreen> {
|
||||
Uint8List? _probeResult;
|
||||
bool _probing = false;
|
||||
|
||||
Future<void> _runProbe() async {
|
||||
setState(() {
|
||||
_probing = true;
|
||||
_probeResult = null;
|
||||
});
|
||||
final result =
|
||||
await ref.read(btProvider.notifier).probeOfficialProtocol();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_probing = false;
|
||||
_probeResult = result;
|
||||
});
|
||||
}
|
||||
|
||||
@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);
|
||||
final frame = bt.lastFrame;
|
||||
final hex = frame == null ? '' : _hex(frame);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('ECU Frame Debug'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Copy raw frame',
|
||||
icon: const Icon(Icons.copy),
|
||||
onPressed: frame == null
|
||||
? null
|
||||
: () async {
|
||||
await Clipboard.setData(ClipboardData(text: hex));
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Raw frame copied')),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
children: [
|
||||
_Section(
|
||||
title: 'Connection',
|
||||
rows: [
|
||||
_RowData('Protocol', settings.ecuType.name.toUpperCase()),
|
||||
_RowData('Frames (raw, from poller)', bt.frameCount.toString()),
|
||||
_RowData('Dropped (bad checksum)', bt.droppedFrames.toString()),
|
||||
_RowData('Last frame bytes', frame?.length.toString() ?? 'none'),
|
||||
if (frame != null)
|
||||
_RowData(
|
||||
'Checksum',
|
||||
validateFrame(frame) ? 'valid' : 'bad',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_ProtocolProbeCard(
|
||||
connected: bt.isConnected,
|
||||
probing: _probing,
|
||||
result: _probeResult,
|
||||
onProbe: _runProbe,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_Section(
|
||||
title: 'Decode Pipeline',
|
||||
rows: [
|
||||
_RowData('Decoded -> UI', decodeStats.decoded.toString()),
|
||||
_RowData('Skipped (decode error)', decodeStats.decodeErrors.toString()),
|
||||
_RowData(
|
||||
'sensorStateProvider raw',
|
||||
sensorAsync.when(
|
||||
data: (s) =>
|
||||
'data(rpm=${s.rpm.toStringAsFixed(0)}, age=${DateTime.now().difference(s.timestamp).inMilliseconds}ms)',
|
||||
loading: () => 'loading (no frame yet)',
|
||||
error: (e, st) => 'error: $e',
|
||||
),
|
||||
),
|
||||
_RowData(
|
||||
'latestSensorProvider age',
|
||||
'${DateTime.now().difference(state.timestamp).inMilliseconds}ms since decode',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_Section(
|
||||
title: 'Current App Decode',
|
||||
rows: _decodedRows(state),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (frame == null)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Text(
|
||||
'No ECU frame received yet.',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
if (settings.ecuType == EcuType.s300) ...[
|
||||
_Section(
|
||||
title: 'S300 Raw Candidates',
|
||||
rows: _s300CandidateRows(frame),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
_RawHexBlock(hex: hex),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<_RowData> _decodedRows(SensorState s) => [
|
||||
_RowData('RPM', s.rpm.toStringAsFixed(0)),
|
||||
_RowData('Speed km/h', s.vss.toStringAsFixed(2)),
|
||||
_RowData('MAP kPa', s.map.toStringAsFixed(2)),
|
||||
_RowData('TPS', s.tps.toStringAsFixed(2)),
|
||||
_RowData('ECT', s.ect.toStringAsFixed(2)),
|
||||
_RowData('IAT', s.iat.toStringAsFixed(2)),
|
||||
_RowData('Battery', s.bat.toStringAsFixed(2)),
|
||||
_RowData('O2', s.o2.toStringAsFixed(2)),
|
||||
_RowData('AFR', s.afr.toStringAsFixed(2)),
|
||||
_RowData('MIL (unverified)', s.mil.toString()),
|
||||
_RowData('Fuel Pump', s.fuelPump.toString()),
|
||||
_RowData('Fan Out (unverified)', s.fanOut.toString()),
|
||||
_RowData('VTEC (unverified)', s.vtec.toString()),
|
||||
_RowData('Knock', s.knock.toString()),
|
||||
_RowData('Rev Limit (unverified)', s.revLimit.toString()),
|
||||
_RowData('Launch (unverified)', s.launch.toString()),
|
||||
];
|
||||
|
||||
List<_RowData> _s300CandidateRows(Uint8List f) {
|
||||
final rpmLe = _u16le(f, 3);
|
||||
final vssLe = _u16le(f, 5);
|
||||
final mapLe = _u16le(f, 7);
|
||||
|
||||
return [
|
||||
_RowData('Header bytes', '${_b(f, 0)} ${_b(f, 1)} ${_b(f, 2)}'),
|
||||
_RowData('RPM raw @3 LE', rpmLe.toString()),
|
||||
_RowData('VSS raw @5 LE', vssLe.toString()),
|
||||
_RowData('VSS current km/h', _s300Vss(vssLe).toStringAsFixed(2)),
|
||||
_RowData('MAP raw @7 LE', mapLe.toString()),
|
||||
_RowData('MAP current kPa', _s300Map(mapLe).toStringAsFixed(2)),
|
||||
_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 @33', f[33].toString()),
|
||||
_RowData('IAT raw @34', f[34].toString()),
|
||||
_RowData('BAT raw @36', f[36].toString()),
|
||||
_RowData('PA raw @44 (offset unverified)', f[0x2C].toString()),
|
||||
_RowData('PA candidate raw @50 (unconfirmed, ~3kPa off)', f[50].toString()),
|
||||
_RowData('ERR bytes @49-52',
|
||||
'${_b(f, 0x31)} ${_b(f, 0x32)} ${_b(f, 0x33)} ${_b(f, 0x34)}'),
|
||||
_RowData('SW1 @17 (bit7..0)', '${_b(f, 0x11)} ${_bin(f, 0x11)}'),
|
||||
_RowData('SW2 @18 (bit7..0)', '${_b(f, 0x12)} ${_bin(f, 0x12)}'),
|
||||
_RowData('SW3 @19 (bit7..0)', '${_b(f, 0x13)} ${_bin(f, 0x13)}'),
|
||||
_RowData('SW5 @67 (bit7..0)', '${_b(f, 0x43)} ${_bin(f, 0x43)}'),
|
||||
];
|
||||
}
|
||||
|
||||
static int _u16le(Uint8List f, int o) => f[o] | (f[o + 1] << 8);
|
||||
static String _b(Uint8List f, int o) =>
|
||||
f[o].toRadixString(16).padLeft(2, '0');
|
||||
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) {
|
||||
return raw / 10.0;
|
||||
}
|
||||
|
||||
static double _s300Tps(int raw) {
|
||||
return (raw * 100.0 / 255.0).clamp(0.0, 100.0);
|
||||
}
|
||||
|
||||
static String _hex(Uint8List frame) {
|
||||
final parts = <String>[];
|
||||
for (var i = 0; i < frame.length; i++) {
|
||||
if (i > 0 && i % 16 == 0) parts.add('\n');
|
||||
parts.add(frame[i].toRadixString(16).padLeft(2, '0'));
|
||||
}
|
||||
return parts.join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
/// Step 1 of the data-layer review: checks whether this device/firmware
|
||||
/// understands the official, self-describing Hondata Bluetooth protocol
|
||||
/// (Bluetooth.pdf) rather than only the legacy fixed-offset frame format
|
||||
/// this app currently speaks. A real reply here (Protocol=0x00, ID=0x02,
|
||||
/// with a recognizable DeviceType byte) means migrating to that protocol is
|
||||
/// on the table; silence means this hardware is legacy-only.
|
||||
class _ProtocolProbeCard extends StatelessWidget {
|
||||
final bool connected;
|
||||
final bool probing;
|
||||
final Uint8List? result;
|
||||
final VoidCallback onProbe;
|
||||
|
||||
const _ProtocolProbeCard({
|
||||
required this.connected,
|
||||
required this.probing,
|
||||
required this.result,
|
||||
required this.onProbe,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Official Protocol Probe',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
|
||||
),
|
||||
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),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
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);
|
||||
}
|
||||
|
||||
@ -1,243 +1,232 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/protocol/dtc_map.dart';
|
||||
import '../../../core/providers/bt_provider.dart';
|
||||
import '../../../core/providers/sensor_provider.dart';
|
||||
import '../../../core/providers/datalog_provider.dart';
|
||||
import '../../theme/app_colors.dart';
|
||||
import '../../../core/bluetooth/bt_poller.dart';
|
||||
import 'widgets/dtc_row.dart';
|
||||
|
||||
class DtcScreen extends ConsumerStatefulWidget {
|
||||
const DtcScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<DtcScreen> createState() => _DtcScreenState();
|
||||
}
|
||||
|
||||
class _DtcScreenState extends ConsumerState<DtcScreen> {
|
||||
bool _clearing = false;
|
||||
Uint8List? _lastRaw;
|
||||
|
||||
String _hex(Uint8List bytes) {
|
||||
final parts = <String>[];
|
||||
for (var i = 0; i < bytes.length; i++) {
|
||||
if (i > 0 && i % 16 == 0) parts.add('\n');
|
||||
parts.add(bytes[i].toRadixString(16).padLeft(2, '0'));
|
||||
}
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
void _showRawCapture() {
|
||||
final raw = _lastRaw;
|
||||
if (raw == null) return;
|
||||
final hex = _hex(raw);
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('Raw capture (${raw.length} bytes)'),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: SingleChildScrollView(
|
||||
child: SelectableText(
|
||||
hex,
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await Clipboard.setData(ClipboardData(text: hex));
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Copied')),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('Copy'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmAndClear() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Clear Trouble Codes'),
|
||||
content: const Text(
|
||||
'This sends a reset command to the ECU and erases all stored '
|
||||
'fault codes. This cannot be undone.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
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;
|
||||
});
|
||||
|
||||
// Success is judged from the live data: the adapter does not send the
|
||||
// ack. Codes for faults that are still present return right away.
|
||||
final String message;
|
||||
if (result.codesBefore == 0) {
|
||||
message = 'No stored trouble codes to clear';
|
||||
} else if (result.codesCleared > 0) {
|
||||
final cleared = result.codesCleared;
|
||||
final back = result.codesBefore - cleared;
|
||||
message = back == 0
|
||||
? 'Trouble codes cleared'
|
||||
: '$cleared of ${result.codesBefore} codes cleared — $back came '
|
||||
'straight back (active fault, fix the cause first)';
|
||||
} else if (result.acknowledged) {
|
||||
message = 'ECU confirmed the reset, but every code came straight back '
|
||||
'(active faults)';
|
||||
} else if (result.raw.isEmpty) {
|
||||
// Can't tell apart from "every fault re-set before the next frame".
|
||||
message = 'No codes cleared — the ECU ignored the reset, or every '
|
||||
'fault is still active';
|
||||
} else {
|
||||
// "View" opens the raw reply hex to check by hand.
|
||||
message = 'Unexpected reply (${result.raw.length} bytes) — codes did '
|
||||
'not clear';
|
||||
}
|
||||
|
||||
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(effectiveEcuTypeProvider);
|
||||
final c = AppColors.of(context);
|
||||
|
||||
final dtcMap =
|
||||
ecuType == EcuType.s300 ? s300DtcMap : kproDtcMap;
|
||||
final active = decodeDtcs(state.errBytes, dtcMap);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Header bar
|
||||
Container(
|
||||
color: c.surface,
|
||||
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:
|
||||
TextStyle(color: c.onSurfaceMuted, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Divider(height: 1, color: c.divider),
|
||||
|
||||
// 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: (_, __) =>
|
||||
Divider(height: 1, color: c.divider),
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/protocol/dtc_map.dart';
|
||||
import '../../../core/providers/bt_provider.dart';
|
||||
import '../../../core/providers/sensor_provider.dart';
|
||||
import '../../../core/providers/settings_provider.dart';
|
||||
import '../../../core/bluetooth/bt_poller.dart';
|
||||
import 'widgets/dtc_row.dart';
|
||||
|
||||
class DtcScreen extends ConsumerStatefulWidget {
|
||||
const DtcScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<DtcScreen> createState() => _DtcScreenState();
|
||||
}
|
||||
|
||||
class _DtcScreenState extends ConsumerState<DtcScreen> {
|
||||
bool _clearing = false;
|
||||
Uint8List? _lastRaw;
|
||||
|
||||
String _hex(Uint8List bytes) {
|
||||
final parts = <String>[];
|
||||
for (var i = 0; i < bytes.length; i++) {
|
||||
if (i > 0 && i % 16 == 0) parts.add('\n');
|
||||
parts.add(bytes[i].toRadixString(16).padLeft(2, '0'));
|
||||
}
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
void _showRawCapture() {
|
||||
final raw = _lastRaw;
|
||||
if (raw == null) return;
|
||||
final hex = _hex(raw);
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('Raw capture (${raw.length} bytes)'),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: SingleChildScrollView(
|
||||
child: SelectableText(
|
||||
hex,
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await Clipboard.setData(ClipboardData(text: hex));
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Copied')),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('Copy'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmAndClear() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Clear Trouble Codes'),
|
||||
content: const Text(
|
||||
'This sends a reset command to the ECU and erases all stored '
|
||||
'fault codes. This cannot be undone.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,46 +1,44 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../theme/app_colors.dart';
|
||||
|
||||
class DtcRow extends StatelessWidget {
|
||||
final String code;
|
||||
final String description;
|
||||
|
||||
const DtcRow({super.key, required this.code, required this.description});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withAlpha(30),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: Colors.red.withAlpha(100)),
|
||||
),
|
||||
child: Text(
|
||||
code,
|
||||
style: const TextStyle(
|
||||
color: Colors.redAccent,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
description,
|
||||
style: TextStyle(
|
||||
color: AppColors.of(context).onSurface, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class DtcRow extends StatelessWidget {
|
||||
final String code;
|
||||
final String description;
|
||||
|
||||
const DtcRow({super.key, required this.code, required this.description});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withAlpha(30),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: Colors.red.withAlpha(100)),
|
||||
),
|
||||
child: Text(
|
||||
code,
|
||||
style: const TextStyle(
|
||||
color: Colors.redAccent,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
description,
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,103 +1,136 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/models/units.dart';
|
||||
import '../../../core/providers/sensor_history_provider.dart';
|
||||
import '../../../core/providers/settings_provider.dart';
|
||||
import '../../../ui/theme/app_colors.dart';
|
||||
import '../dashboard/widgets/sensor_picker_sheet.dart';
|
||||
import 'widgets/graph_time_selector.dart';
|
||||
import 'widgets/sensor_graph_panel.dart';
|
||||
|
||||
final _graphWindowProvider = StateProvider<int>((ref) => 60);
|
||||
final _graphSensorsProvider = StateProvider<List<String>>(
|
||||
(ref) => ['rpm', 'map', 'tps', 'ect'],
|
||||
);
|
||||
|
||||
class GraphScreen extends ConsumerWidget {
|
||||
const GraphScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final history = ref.watch(sensorHistoryProvider);
|
||||
final colors = AppColors.of(context);
|
||||
final units = ref.watch(unitPrefsProvider);
|
||||
final window = ref.watch(_graphWindowProvider);
|
||||
final sensors = ref.watch(_graphSensorsProvider);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Time selector bar
|
||||
Container(
|
||||
color: colors.surface,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text('Window:',
|
||||
style: TextStyle(color: colors.onSurfaceMuted, fontSize: 12)),
|
||||
const SizedBox(width: 8),
|
||||
GraphTimeSelector(
|
||||
windowSeconds: window,
|
||||
onChanged: (s) =>
|
||||
ref.read(_graphWindowProvider.notifier).state = s,
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: Icon(Icons.add_chart, color: colors.accent, size: 20),
|
||||
tooltip: 'Add sensor',
|
||||
onPressed: () => _addSensor(context, ref, sensors),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Graph panels
|
||||
Expanded(
|
||||
child: sensors.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.show_chart,
|
||||
size: 48, color: colors.accent.withAlpha(80)),
|
||||
const SizedBox(height: 12),
|
||||
Text('No sensors selected',
|
||||
style: TextStyle(color: colors.onSurfaceMuted)),
|
||||
const SizedBox(height: 8),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => _addSensor(context, ref, sensors),
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('Add sensor'),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: sensors.length,
|
||||
itemBuilder: (context, i) => SensorGraphPanel(
|
||||
view: sensorView(sensors[i], units),
|
||||
history: history,
|
||||
windowSeconds: window,
|
||||
onRemove: () {
|
||||
final next = List<String>.from(sensors)..removeAt(i);
|
||||
ref.read(_graphSensorsProvider.notifier).state = next;
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _addSensor(
|
||||
BuildContext context, WidgetRef ref, List<String> current) async {
|
||||
final id = await pickSensor(
|
||||
context,
|
||||
title: 'ADD SENSOR TO GRAPH',
|
||||
currentId: '',
|
||||
units: ref.read(unitPrefsProvider),
|
||||
);
|
||||
if (id == null || current.contains(id)) return;
|
||||
ref.read(_graphSensorsProvider.notifier).state = [...current, id];
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/models/sensor_defs.dart';
|
||||
import '../../../core/providers/sensor_history_provider.dart';
|
||||
import '../../../core/providers/theme_provider.dart';
|
||||
import '../../../ui/theme/app_colors.dart';
|
||||
import 'widgets/graph_time_selector.dart';
|
||||
import 'widgets/sensor_graph_panel.dart';
|
||||
|
||||
final _graphWindowProvider = StateProvider<int>((ref) => 60);
|
||||
final _graphSensorsProvider = StateProvider<List<String>>(
|
||||
(ref) => ['rpm', 'map', 'tps'],
|
||||
);
|
||||
|
||||
class GraphScreen extends ConsumerWidget {
|
||||
const GraphScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final history = ref.watch(sensorHistoryProvider);
|
||||
final colors = AppColors.forTheme(ref.watch(themeProvider));
|
||||
final window = ref.watch(_graphWindowProvider);
|
||||
final sensors = ref.watch(_graphSensorsProvider);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Time selector bar
|
||||
Container(
|
||||
color: const Color(0xFF1A1A1A),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text('Window:',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
const SizedBox(width: 8),
|
||||
GraphTimeSelector(
|
||||
windowSeconds: window,
|
||||
onChanged: (s) =>
|
||||
ref.read(_graphWindowProvider.notifier).state = s,
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: Icon(Icons.add_chart, color: colors.accent, size: 20),
|
||||
tooltip: 'Add sensor',
|
||||
onPressed: () => _addSensor(context, ref, sensors),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Graph panels
|
||||
Expanded(
|
||||
child: sensors.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.show_chart,
|
||||
size: 48, color: colors.accent.withAlpha(80)),
|
||||
const SizedBox(height: 12),
|
||||
const Text('No sensors selected',
|
||||
style: TextStyle(color: Colors.grey)),
|
||||
const SizedBox(height: 8),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => _addSensor(context, ref, sensors),
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('Add sensor'),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: sensors.length,
|
||||
itemBuilder: (context, i) => SensorGraphPanel(
|
||||
sensorId: sensors[i],
|
||||
history: history,
|
||||
windowSeconds: window,
|
||||
accentColor: colors.accent,
|
||||
onRemove: () {
|
||||
final next = List<String>.from(sensors)..removeAt(i);
|
||||
ref.read(_graphSensorsProvider.notifier).state = next;
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _addSensor(
|
||||
BuildContext context, WidgetRef ref, List<String> current) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: const Color(0xFF1A1A1A),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (_) => ListView(
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Text('ADD SENSOR TO GRAPH',
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 12,
|
||||
letterSpacing: 2)),
|
||||
),
|
||||
const Divider(height: 1, color: Colors.white10),
|
||||
...sensorDefs.map((def) {
|
||||
final already = current.contains(def.id);
|
||||
return ListTile(
|
||||
title: Text(def.displayName,
|
||||
style: TextStyle(
|
||||
color: already ? Colors.grey : Colors.white)),
|
||||
subtitle: Text(def.unit,
|
||||
style: const TextStyle(
|
||||
color: Colors.grey, fontSize: 11)),
|
||||
trailing: already
|
||||
? const Icon(Icons.check,
|
||||
color: Colors.grey, size: 16)
|
||||
: null,
|
||||
onTap: already
|
||||
? null
|
||||
: () {
|
||||
ref.read(_graphSensorsProvider.notifier).state =
|
||||
[...current, def.id];
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,49 +1,49 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class GraphTimeSelector extends StatelessWidget {
|
||||
final int windowSeconds;
|
||||
final void Function(int seconds) onChanged;
|
||||
|
||||
static const _options = [30, 60, 120, 300];
|
||||
|
||||
const GraphTimeSelector({
|
||||
super.key,
|
||||
required this.windowSeconds,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final accent = Theme.of(context).colorScheme.primary;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: _options.map((s) {
|
||||
final selected = s == windowSeconds;
|
||||
return GestureDetector(
|
||||
onTap: () => onChanged(s),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 120),
|
||||
margin: const EdgeInsets.only(left: 4),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? accent.withAlpha(30) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: selected ? accent : Theme.of(context).dividerColor,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
s >= 60 ? '${s ~/ 60}m' : '${s}s',
|
||||
style: TextStyle(
|
||||
color: selected ? accent : Theme.of(context).hintColor,
|
||||
fontSize: 12,
|
||||
fontWeight: selected ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class GraphTimeSelector extends StatelessWidget {
|
||||
final int windowSeconds;
|
||||
final void Function(int seconds) onChanged;
|
||||
|
||||
static const _options = [30, 60, 120, 300];
|
||||
|
||||
const GraphTimeSelector({
|
||||
super.key,
|
||||
required this.windowSeconds,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final accent = Theme.of(context).colorScheme.primary;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: _options.map((s) {
|
||||
final selected = s == windowSeconds;
|
||||
return GestureDetector(
|
||||
onTap: () => onChanged(s),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 120),
|
||||
margin: const EdgeInsets.only(left: 4),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? accent.withAlpha(30) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: selected ? accent : Colors.white24,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
s >= 60 ? '${s ~/ 60}m' : '${s}s',
|
||||
style: TextStyle(
|
||||
color: selected ? accent : Colors.white54,
|
||||
fontSize: 12,
|
||||
fontWeight: selected ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,203 +1,133 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/models/units.dart';
|
||||
import '../../../../core/providers/sensor_history_provider.dart';
|
||||
import '../../../theme/app_colors.dart';
|
||||
|
||||
/// One graph (requirements): line over the selected time window with the
|
||||
/// window's max/min top-left, current value and name bottom-left, value
|
||||
/// axis on the right, seconds-ago axis along the bottom, and a touch cursor
|
||||
/// that reads out the value under the finger.
|
||||
class SensorGraphPanel extends StatelessWidget {
|
||||
final SensorView view;
|
||||
final SensorHistory history;
|
||||
final int windowSeconds;
|
||||
final VoidCallback onRemove;
|
||||
|
||||
const SensorGraphPanel({
|
||||
super.key,
|
||||
required this.view,
|
||||
required this.history,
|
||||
required this.windowSeconds,
|
||||
required this.onRemove,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = AppColors.of(context);
|
||||
final raw = history.get(view.def.id);
|
||||
final now = raw.isEmpty ? 0.0 : raw.last.x;
|
||||
// x = seconds before the latest sample (0 at the right edge).
|
||||
final spots = [
|
||||
for (final s in raw)
|
||||
if (now - s.x <= windowSeconds) FlSpot(s.x - now, view.convert(s.y)),
|
||||
];
|
||||
|
||||
double? lo, hi;
|
||||
for (final s in spots) {
|
||||
lo = lo == null ? s.y : math.min(lo, s.y);
|
||||
hi = hi == null ? s.y : math.max(hi, s.y);
|
||||
}
|
||||
// Y axis follows the data in the window (like the reference), with a
|
||||
// margin, and never collapses to zero height for a steady value.
|
||||
var minY = view.min, maxY = view.max;
|
||||
if (lo != null && hi != null) {
|
||||
final pad = math.max((hi - lo) * 0.15, (view.max - view.min) * 0.02);
|
||||
minY = lo - pad;
|
||||
maxY = hi + pad;
|
||||
}
|
||||
final muted = TextStyle(color: c.onSurfaceMuted, fontSize: 11);
|
||||
String f(double v) => v.toStringAsFixed(view.decimals);
|
||||
|
||||
return Container(
|
||||
height: 170,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
padding: const EdgeInsets.fromLTRB(8, 6, 4, 4),
|
||||
decoration: BoxDecoration(
|
||||
color: c.surface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: c.divider),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: spots.length < 2
|
||||
? Center(child: Text('Waiting for data…', style: muted))
|
||||
: LineChart(
|
||||
LineChartData(
|
||||
minX: -windowSeconds.toDouble(),
|
||||
maxX: 0,
|
||||
minY: minY,
|
||||
maxY: maxY,
|
||||
clipData: const FlClipData.all(),
|
||||
gridData: FlGridData(
|
||||
show: true,
|
||||
getDrawingHorizontalLine: (_) =>
|
||||
FlLine(color: c.divider, strokeWidth: 1),
|
||||
getDrawingVerticalLine: (_) =>
|
||||
FlLine(color: c.divider, strokeWidth: 1),
|
||||
),
|
||||
borderData: FlBorderData(show: false),
|
||||
titlesData: FlTitlesData(
|
||||
topTitles: const AxisTitles(),
|
||||
leftTitles: const AxisTitles(),
|
||||
rightTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 40,
|
||||
getTitlesWidget: (v, meta) => Text(
|
||||
_compact(v),
|
||||
style: muted.copyWith(fontSize: 9),
|
||||
),
|
||||
),
|
||||
),
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 16,
|
||||
interval: windowSeconds / 6,
|
||||
getTitlesWidget: (v, meta) => Text(
|
||||
(-v).round().toString(),
|
||||
style: muted.copyWith(fontSize: 9),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
lineTouchData: LineTouchData(
|
||||
getTouchedSpotIndicator: (bar, indexes) => [
|
||||
for (final _ in indexes)
|
||||
const TouchedSpotIndicatorData(
|
||||
FlLine(color: Colors.red, strokeWidth: 1.5),
|
||||
FlDotData(show: true),
|
||||
),
|
||||
],
|
||||
touchTooltipData: LineTouchTooltipData(
|
||||
getTooltipColor: (_) => Colors.red,
|
||||
tooltipPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 4, vertical: 2),
|
||||
getTooltipItems: (touched) => [
|
||||
for (final t in touched)
|
||||
LineTooltipItem(
|
||||
f(t.y),
|
||||
const TextStyle(
|
||||
color: Colors.white, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
spots: spots,
|
||||
isCurved: false,
|
||||
color: c.accent,
|
||||
barWidth: 1.5,
|
||||
dotData: const FlDotData(show: false),
|
||||
),
|
||||
],
|
||||
),
|
||||
duration: Duration.zero,
|
||||
),
|
||||
),
|
||||
// Window max / min
|
||||
Positioned(
|
||||
left: 4,
|
||||
top: 0,
|
||||
child: IgnorePointer(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(hi == null ? '-' : f(hi), style: muted),
|
||||
Text(lo == null ? '-' : f(lo), style: muted),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// Name + current value
|
||||
Positioned(
|
||||
left: 4,
|
||||
bottom: 18,
|
||||
child: IgnorePointer(
|
||||
child: RichText(
|
||||
text: TextSpan(children: [
|
||||
TextSpan(
|
||||
text: '${view.name} ',
|
||||
style: TextStyle(color: c.accent, fontSize: 14),
|
||||
),
|
||||
TextSpan(
|
||||
text: raw.isEmpty ? '-' : view.format(raw.last.y),
|
||||
style: TextStyle(
|
||||
color: c.onSurface,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: ' ${view.unit}',
|
||||
style: TextStyle(color: c.onSurfaceMuted, fontSize: 12),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 0,
|
||||
top: 0,
|
||||
child: GestureDetector(
|
||||
onTap: onRemove,
|
||||
child: Icon(Icons.close, size: 16, color: c.onSurfaceMuted),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _compact(double v) {
|
||||
if (v.abs() >= 1000) return '${(v / 1000).toStringAsFixed(1)}k';
|
||||
if (v.abs() >= 10 || v == v.roundToDouble()) return v.toStringAsFixed(0);
|
||||
return v.toStringAsFixed(1);
|
||||
}
|
||||
}
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/models/sensor_defs.dart';
|
||||
import '../../../../core/providers/sensor_history_provider.dart';
|
||||
|
||||
class SensorGraphPanel extends StatelessWidget {
|
||||
final String sensorId;
|
||||
final SensorHistory history;
|
||||
final int windowSeconds;
|
||||
final Color accentColor;
|
||||
final VoidCallback onRemove;
|
||||
|
||||
const SensorGraphPanel({
|
||||
super.key,
|
||||
required this.sensorId,
|
||||
required this.history,
|
||||
required this.windowSeconds,
|
||||
required this.accentColor,
|
||||
required this.onRemove,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final def = sensorDefs.firstWhere(
|
||||
(d) => d.id == sensorId,
|
||||
orElse: () => sensorDefs.first,
|
||||
);
|
||||
final allSpots = history.get(sensorId);
|
||||
|
||||
// Slice to window
|
||||
final spots = _window(allSpots, windowSeconds.toDouble());
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
padding: const EdgeInsets.fromLTRB(8, 10, 12, 8),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1A1A1A),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
def.displayName.toUpperCase(),
|
||||
style: TextStyle(
|
||||
color: accentColor,
|
||||
fontSize: 11,
|
||||
letterSpacing: 1.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
def.unit,
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 11),
|
||||
),
|
||||
const Spacer(),
|
||||
if (spots.isNotEmpty)
|
||||
Text(
|
||||
_fmt(spots.last.y),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: onRemove,
|
||||
child: const Icon(Icons.close,
|
||||
size: 16, color: Colors.white38),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Chart
|
||||
SizedBox(
|
||||
height: 90,
|
||||
child: spots.length < 2
|
||||
? const Center(
|
||||
child: Text('Waiting for data…',
|
||||
style: TextStyle(color: Colors.white30,
|
||||
fontSize: 12)))
|
||||
: LineChart(
|
||||
LineChartData(
|
||||
gridData: FlGridData(
|
||||
show: true,
|
||||
drawVerticalLine: false,
|
||||
getDrawingHorizontalLine: (_) => FlLine(
|
||||
color: Colors.white10,
|
||||
strokeWidth: 1,
|
||||
),
|
||||
),
|
||||
borderData: FlBorderData(show: false),
|
||||
titlesData: const FlTitlesData(show: false),
|
||||
minY: def.min.toDouble(),
|
||||
maxY: def.max.toDouble(),
|
||||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
spots: spots,
|
||||
isCurved: true,
|
||||
curveSmoothness: 0.2,
|
||||
color: accentColor,
|
||||
barWidth: 1.5,
|
||||
dotData: const FlDotData(show: false),
|
||||
belowBarData: BarAreaData(
|
||||
show: true,
|
||||
color: accentColor.withAlpha(25),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
duration: Duration.zero,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<FlSpot> _window(List<FlSpot> spots, double windowSec) {
|
||||
if (spots.isEmpty) return spots;
|
||||
final maxX = spots.last.x;
|
||||
final minX = maxX - windowSec;
|
||||
return spots.where((s) => s.x >= minX).toList();
|
||||
}
|
||||
|
||||
String _fmt(double v) =>
|
||||
v.abs() >= 100 ? v.toStringAsFixed(1) : v.toStringAsFixed(2);
|
||||
}
|
||||
|
||||
@ -1,39 +1,31 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/models/sensor_defs.dart';
|
||||
import '../../../core/models/units.dart';
|
||||
import '../../../core/providers/sensor_provider.dart';
|
||||
import '../../../core/providers/settings_provider.dart';
|
||||
import 'widgets/sensor_row.dart';
|
||||
|
||||
/// All sensors as a list (requirements): 1 column on a phone in portrait,
|
||||
/// 2 columns on a tablet in portrait and in landscape.
|
||||
class SensorListScreen extends ConsumerWidget {
|
||||
const SensorListScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(latestSensorProvider);
|
||||
final units = ref.watch(unitPrefsProvider);
|
||||
final media = MediaQuery.of(context);
|
||||
final isTablet = media.size.shortestSide >= 600;
|
||||
final isLandscape = media.orientation == Orientation.landscape;
|
||||
final columns = (isTablet || isLandscape) ? 2 : 1;
|
||||
|
||||
return GridView.builder(
|
||||
itemCount: sensorDefs.length,
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: columns,
|
||||
mainAxisExtent: 72,
|
||||
crossAxisSpacing: 8,
|
||||
),
|
||||
itemBuilder: (context, i) {
|
||||
final id = sensorDefs[i].id;
|
||||
return SensorRow(
|
||||
view: sensorView(id, units),
|
||||
baseValue: sensorValue(state, id),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/models/sensor_defs.dart';
|
||||
import '../../../core/providers/sensor_provider.dart';
|
||||
import '../../../core/providers/theme_provider.dart';
|
||||
import '../../../ui/theme/app_colors.dart';
|
||||
import 'widgets/sensor_row.dart';
|
||||
|
||||
class SensorListScreen extends ConsumerWidget {
|
||||
const SensorListScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(latestSensorProvider);
|
||||
final colors = AppColors.forTheme(ref.watch(themeProvider));
|
||||
|
||||
return ListView.separated(
|
||||
itemCount: sensorDefs.length,
|
||||
separatorBuilder: (_, __) =>
|
||||
const Divider(height: 1, color: Colors.white10),
|
||||
itemBuilder: (context, i) {
|
||||
final def = sensorDefs[i];
|
||||
return SensorRow(
|
||||
def: def,
|
||||
value: sensorValue(state, def.id),
|
||||
accentColor: colors.accent,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,129 +1,130 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/models/units.dart';
|
||||
import '../../../theme/app_colors.dart';
|
||||
|
||||
/// Sensor list entry (requirements): name and unit, min/max of the range,
|
||||
/// a segmented bar gauge and the numeric value.
|
||||
class SensorRow extends StatelessWidget {
|
||||
final SensorView view;
|
||||
final double baseValue;
|
||||
|
||||
const SensorRow({super.key, required this.view, required this.baseValue});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = AppColors.of(context);
|
||||
final fraction = view.fraction(baseValue);
|
||||
final isHigh = fraction > 0.9;
|
||||
final rangeStyle = TextStyle(color: c.onSurfaceMuted, fontSize: 10);
|
||||
String fmt(double v) => v.toStringAsFixed(view.decimals > 1 ? 1 : 0);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(12, 6, 12, 6),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: c.divider)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
view.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: c.accent,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(view.unit,
|
||||
style: TextStyle(color: c.onSurfaceMuted, fontSize: 13)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(fmt(view.min), style: rangeStyle),
|
||||
const Spacer(),
|
||||
Text(fmt(view.max), style: rangeStyle),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
_SegmentBar(
|
||||
fraction: fraction,
|
||||
color: isHigh ? Colors.red : c.accent,
|
||||
track: c.track,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 90,
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(
|
||||
view.format(baseValue),
|
||||
style: TextStyle(
|
||||
color: isHigh ? Colors.red : c.onSurface,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bar made of thin segments, like the reference screenshot.
|
||||
class _SegmentBar extends StatelessWidget {
|
||||
final double fraction;
|
||||
final Color color;
|
||||
final Color track;
|
||||
|
||||
const _SegmentBar({
|
||||
required this.fraction,
|
||||
required this.color,
|
||||
required this.track,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 14,
|
||||
child: LayoutBuilder(builder: (context, box) {
|
||||
const segWidth = 3.0;
|
||||
const gap = 2.0;
|
||||
final count = (box.maxWidth / (segWidth + gap)).floor().clamp(1, 400);
|
||||
final lit = (fraction * count).round();
|
||||
return Row(
|
||||
children: [
|
||||
for (var i = 0; i < count; i++)
|
||||
Container(
|
||||
width: segWidth,
|
||||
margin: const EdgeInsets.only(right: gap),
|
||||
color: i < lit ? color : track,
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/models/sensor_def.dart';
|
||||
import '../../../../core/protocol/sensor_state.dart';
|
||||
|
||||
class SensorRow extends StatelessWidget {
|
||||
final SensorDef def;
|
||||
final double value;
|
||||
final Color accentColor;
|
||||
|
||||
const SensorRow({
|
||||
super.key,
|
||||
required this.def,
|
||||
required this.value,
|
||||
required this.accentColor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final normalized =
|
||||
((value - def.min) / (def.max - def.min)).clamp(0.0, 1.0);
|
||||
final isHigh = normalized > 0.8;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
|
||||
child: Row(
|
||||
children: [
|
||||
// Label
|
||||
SizedBox(
|
||||
width: 52,
|
||||
child: Text(
|
||||
def.displayName,
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 12,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Bar gauge
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(height: 14, color: Colors.white10),
|
||||
FractionallySizedBox(
|
||||
widthFactor: normalized,
|
||||
child: Container(
|
||||
height: 14,
|
||||
decoration: BoxDecoration(
|
||||
color: isHigh
|
||||
? Colors.red.withAlpha(200)
|
||||
: accentColor.withAlpha(180),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
// Value + unit
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child: RichText(
|
||||
textAlign: TextAlign.right,
|
||||
text: TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: _fmt(value),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: ' ${def.unit}',
|
||||
style: TextStyle(
|
||||
color: accentColor,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _fmt(double v) {
|
||||
if (def.id == 'rpm' || def.id == 'gear') return v.toStringAsFixed(0);
|
||||
if (v.abs() >= 100) return v.toStringAsFixed(1);
|
||||
return v.toStringAsFixed(2);
|
||||
}
|
||||
}
|
||||
|
||||
double sensorValue(SensorState s, String id) {
|
||||
switch (id) {
|
||||
case 'rpm': return s.rpm;
|
||||
case 'vss': return s.vss;
|
||||
case 'map': return s.map;
|
||||
case 'tps': return s.tps;
|
||||
case 'inj': return s.inj;
|
||||
case 'ign': return s.ign;
|
||||
case 'ect': return s.ect;
|
||||
case 'iat': return s.iat;
|
||||
case 'bat': return s.bat;
|
||||
case 'o2': return s.o2;
|
||||
case 'gear': return s.gear.toDouble();
|
||||
case 'eth': return s.eth;
|
||||
case 'pa': return s.pa;
|
||||
case 'afr': return s.afr;
|
||||
case 'strim': return s.strim;
|
||||
case 'ltrim': return s.ltrim;
|
||||
case 'ain0': return s.ain0;
|
||||
case 'ain1': return s.ain1;
|
||||
case 'ain2': return s.ain2;
|
||||
case 'ain3': return s.ain3;
|
||||
case 'ain4': return s.ain4;
|
||||
case 'ain5': return s.ain5;
|
||||
case 'ain6': return s.ain6;
|
||||
case 'ain7': return s.ain7;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,233 +1,151 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/bluetooth/bt_poller.dart';
|
||||
import '../../../core/models/units.dart';
|
||||
import '../../../core/providers/bt_provider.dart';
|
||||
import '../../../core/providers/settings_provider.dart';
|
||||
import '../../../core/providers/theme_provider.dart';
|
||||
import '../../theme/app_colors.dart';
|
||||
import '../bt_picker/bt_picker_screen.dart';
|
||||
import '../debug/raw_frame_debug_screen.dart';
|
||||
|
||||
class SettingsScreen extends ConsumerWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final themeVariant = ref.watch(themeProvider);
|
||||
final bt = ref.watch(btProvider);
|
||||
final colors = AppColors.of(context);
|
||||
final units = ref.watch(unitPrefsProvider);
|
||||
final unitsN = ref.read(unitPrefsProvider.notifier);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Settings')),
|
||||
body: ListView(
|
||||
children: [
|
||||
// ── Connection ───────────────────────────────────────────
|
||||
_SectionHeader('CONNECTION'),
|
||||
ListTile(
|
||||
leading: Icon(Icons.bluetooth, color: colors.accent),
|
||||
title: const Text('Bluetooth Device'),
|
||||
subtitle: Text(
|
||||
bt.isConnected ? 'Connected: ${bt.deviceName}' : 'Not connected',
|
||||
style:
|
||||
TextStyle(color: bt.isConnected ? Colors.green : Colors.grey),
|
||||
),
|
||||
trailing: bt.isConnected
|
||||
? TextButton(
|
||||
onPressed: () async {
|
||||
await ref.read(btProvider.notifier).disconnect();
|
||||
},
|
||||
child: const Text('Disconnect',
|
||||
style: TextStyle(color: Colors.red)),
|
||||
)
|
||||
: const Icon(Icons.arrow_forward_ios,
|
||||
size: 14, color: Colors.grey),
|
||||
onTap: bt.isConnected
|
||||
? null
|
||||
: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const BtPickerScreen()),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
|
||||
// ── ECU Protocol ─────────────────────────────────────────
|
||||
_SectionHeader('ECU PROTOCOL'),
|
||||
RadioListTile<EcuType>(
|
||||
title: const Text('S300'),
|
||||
subtitle: const Text('Honda S300 ECU',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
value: EcuType.s300,
|
||||
groupValue: settings.ecuType,
|
||||
activeColor: colors.accent,
|
||||
onChanged: (v) {
|
||||
if (v != null) ref.read(settingsProvider.notifier).setEcuType(v);
|
||||
},
|
||||
),
|
||||
RadioListTile<EcuType>(
|
||||
title: const Text('KPro'),
|
||||
subtitle: const Text('Hondata KPro ECU',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
value: EcuType.kpro,
|
||||
groupValue: settings.ecuType,
|
||||
activeColor: colors.accent,
|
||||
onChanged: (v) {
|
||||
if (v != null) ref.read(settingsProvider.notifier).setEcuType(v);
|
||||
},
|
||||
),
|
||||
const Divider(height: 1),
|
||||
|
||||
// ── Polling Interval ─────────────────────────────────────
|
||||
_SectionHeader('POLLING INTERVAL'),
|
||||
...pollingIntervalOptionsMs.map((ms) => RadioListTile<int>(
|
||||
title: Text('${ms}ms (~${(1000 / ms).round()} Hz)'),
|
||||
value: ms,
|
||||
groupValue: settings.pollingInterval.inMilliseconds,
|
||||
activeColor: colors.accent,
|
||||
onChanged: (v) {
|
||||
if (v != null) {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setPollingInterval(Duration(milliseconds: v));
|
||||
}
|
||||
},
|
||||
)),
|
||||
const Divider(height: 1),
|
||||
|
||||
// ── Units ────────────────────────────────────────────────
|
||||
_SectionHeader('UNITS'),
|
||||
_UnitRow<SpeedUnit>(
|
||||
label: 'Speed',
|
||||
value: units.speed,
|
||||
options: const {SpeedUnit.kmh: 'km/h', SpeedUnit.mph: 'mph'},
|
||||
onChanged: unitsN.setSpeed,
|
||||
),
|
||||
_UnitRow<TempUnit>(
|
||||
label: 'Temperature',
|
||||
value: units.temp,
|
||||
options: const {
|
||||
TempUnit.celsius: '°C',
|
||||
TempUnit.fahrenheit: '°F',
|
||||
},
|
||||
onChanged: unitsN.setTemp,
|
||||
),
|
||||
_UnitRow<PressureUnit>(
|
||||
label: 'Pressure',
|
||||
value: units.pressure,
|
||||
options: const {
|
||||
PressureUnit.kpa: 'kPa',
|
||||
PressureUnit.bar: 'bar',
|
||||
PressureUnit.psi: 'psi',
|
||||
},
|
||||
onChanged: unitsN.setPressure,
|
||||
),
|
||||
const Divider(height: 1),
|
||||
|
||||
// ── Theme ────────────────────────────────────────────────
|
||||
_SectionHeader('THEME'),
|
||||
...AppThemeVariant.values.map((v) {
|
||||
final label = switch (v) {
|
||||
AppThemeVariant.redDark => 'Red — Dark',
|
||||
AppThemeVariant.redLight => 'Red — Light',
|
||||
AppThemeVariant.greenDark => 'Green — Dark',
|
||||
AppThemeVariant.greenLight => 'Green — Light',
|
||||
};
|
||||
return RadioListTile<AppThemeVariant>(
|
||||
title: Text(label),
|
||||
value: v,
|
||||
groupValue: themeVariant,
|
||||
activeColor: AppColors.forTheme(v).accent,
|
||||
onChanged: (val) {
|
||||
if (val != null) ref.read(themeProvider.notifier).set(val);
|
||||
},
|
||||
);
|
||||
}),
|
||||
const Divider(height: 1),
|
||||
|
||||
// ── Diagnostics ──────────────────────────────────────────
|
||||
_SectionHeader('DIAGNOSTICS'),
|
||||
ListTile(
|
||||
leading: Icon(Icons.bug_report_outlined, color: colors.accent),
|
||||
title: const Text('ECU frame debug & app log'),
|
||||
trailing: const Icon(Icons.arrow_forward_ios, size: 14),
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const RawFrameDebugScreen()),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
|
||||
// ── App info ─────────────────────────────────────────────
|
||||
_SectionHeader('ABOUT'),
|
||||
const ListTile(
|
||||
title: Text('HV BT Dashboard'),
|
||||
subtitle: Text('v1.4.0 — Phase 4',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
leading: Icon(Icons.info_outline, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
final String title;
|
||||
const _SectionHeader(this.title);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 6),
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 11,
|
||||
letterSpacing: 1.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One unit choice (e.g. Speed: km/h | mph).
|
||||
class _UnitRow<T> extends StatelessWidget {
|
||||
final String label;
|
||||
final T value;
|
||||
final Map<T, String> options;
|
||||
final void Function(T) onChanged;
|
||||
|
||||
const _UnitRow({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.options,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Text(label)),
|
||||
SegmentedButton<T>(
|
||||
showSelectedIcon: false,
|
||||
segments: [
|
||||
for (final e in options.entries)
|
||||
ButtonSegment<T>(value: e.key, label: Text(e.value)),
|
||||
],
|
||||
selected: {value},
|
||||
onSelectionChanged: (s) => onChanged(s.first),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/bluetooth/bt_poller.dart';
|
||||
import '../../../core/providers/bt_provider.dart';
|
||||
import '../../../core/providers/settings_provider.dart';
|
||||
import '../../../core/providers/theme_provider.dart';
|
||||
import '../../theme/app_colors.dart';
|
||||
import '../bt_picker/bt_picker_screen.dart';
|
||||
|
||||
class SettingsScreen extends ConsumerWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final themeVariant = ref.watch(themeProvider);
|
||||
final bt = ref.watch(btProvider);
|
||||
final colors = AppColors.forTheme(themeVariant);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Settings')),
|
||||
body: ListView(
|
||||
children: [
|
||||
// ── Connection ───────────────────────────────────────────
|
||||
_SectionHeader('CONNECTION'),
|
||||
ListTile(
|
||||
leading: Icon(Icons.bluetooth, color: colors.accent),
|
||||
title: const Text('Bluetooth Device'),
|
||||
subtitle: Text(
|
||||
bt.isConnected ? 'Connected: ${bt.deviceName}' : 'Not connected',
|
||||
style:
|
||||
TextStyle(color: bt.isConnected ? Colors.green : Colors.grey),
|
||||
),
|
||||
trailing: bt.isConnected
|
||||
? TextButton(
|
||||
onPressed: () async {
|
||||
await ref.read(btProvider.notifier).disconnect();
|
||||
},
|
||||
child: const Text('Disconnect',
|
||||
style: TextStyle(color: Colors.red)),
|
||||
)
|
||||
: const Icon(Icons.arrow_forward_ios,
|
||||
size: 14, color: Colors.grey),
|
||||
onTap: bt.isConnected
|
||||
? null
|
||||
: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const BtPickerScreen()),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1, color: Colors.white10),
|
||||
|
||||
// ── ECU Protocol ─────────────────────────────────────────
|
||||
_SectionHeader('ECU PROTOCOL'),
|
||||
RadioListTile<EcuType>(
|
||||
title: const Text('S300'),
|
||||
subtitle: const Text('Honda S300 ECU',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
value: EcuType.s300,
|
||||
groupValue: settings.ecuType,
|
||||
activeColor: colors.accent,
|
||||
onChanged: (v) {
|
||||
if (v != null) ref.read(settingsProvider.notifier).setEcuType(v);
|
||||
},
|
||||
),
|
||||
RadioListTile<EcuType>(
|
||||
title: const Text('KPro'),
|
||||
subtitle: const Text('Hondata KPro ECU',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
value: EcuType.kpro,
|
||||
groupValue: settings.ecuType,
|
||||
activeColor: colors.accent,
|
||||
onChanged: (v) {
|
||||
if (v != null) ref.read(settingsProvider.notifier).setEcuType(v);
|
||||
},
|
||||
),
|
||||
const Divider(height: 1, color: Colors.white10),
|
||||
|
||||
// ── Polling Interval ─────────────────────────────────────
|
||||
_SectionHeader('POLLING INTERVAL'),
|
||||
...[20, 50, 100, 200].map((ms) => RadioListTile<int>(
|
||||
title: Text('${ms}ms (~${(1000 / ms).round()} Hz)'),
|
||||
value: ms,
|
||||
groupValue: settings.pollingInterval.inMilliseconds,
|
||||
activeColor: colors.accent,
|
||||
onChanged: (v) {
|
||||
if (v != null) {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setPollingInterval(Duration(milliseconds: v));
|
||||
}
|
||||
},
|
||||
)),
|
||||
const Divider(height: 1, color: Colors.white10),
|
||||
|
||||
// ── Theme ────────────────────────────────────────────────
|
||||
_SectionHeader('THEME'),
|
||||
...AppThemeVariant.values.map((v) {
|
||||
final label = switch (v) {
|
||||
AppThemeVariant.redDark => 'Red — Dark',
|
||||
AppThemeVariant.redLight => 'Red — Light',
|
||||
AppThemeVariant.greenDark => 'Green — Dark',
|
||||
AppThemeVariant.greenLight => 'Green — Light',
|
||||
};
|
||||
return RadioListTile<AppThemeVariant>(
|
||||
title: Text(label),
|
||||
value: v,
|
||||
groupValue: themeVariant,
|
||||
activeColor: AppColors.forTheme(v).accent,
|
||||
onChanged: (val) {
|
||||
if (val != null) ref.read(themeProvider.notifier).set(val);
|
||||
},
|
||||
);
|
||||
}),
|
||||
const Divider(height: 1, color: Colors.white10),
|
||||
|
||||
// ── App info ─────────────────────────────────────────────
|
||||
_SectionHeader('ABOUT'),
|
||||
const ListTile(
|
||||
title: Text('HV BT Dashboard'),
|
||||
subtitle: Text('v1.4.0 — Phase 4',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
leading: Icon(Icons.info_outline, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
final String title;
|
||||
const _SectionHeader(this.title);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 6),
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 11,
|
||||
letterSpacing: 1.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,103 +1,65 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../core/providers/theme_provider.dart';
|
||||
|
||||
/// App palette for one theme variant. Registered as a [ThemeExtension] so
|
||||
/// widgets read it with [AppColors.of] and follow light/dark automatically.
|
||||
class AppColors extends ThemeExtension<AppColors> {
|
||||
final Color accent;
|
||||
final Color background;
|
||||
final Color surface;
|
||||
final Color surfaceVariant;
|
||||
final Color onSurface;
|
||||
final Color onSurfaceMuted;
|
||||
final Color divider;
|
||||
final Color track; // empty part of bars and gauge arcs
|
||||
|
||||
const AppColors._({
|
||||
required this.accent,
|
||||
required this.background,
|
||||
required this.surface,
|
||||
required this.surfaceVariant,
|
||||
required this.onSurface,
|
||||
required this.onSurfaceMuted,
|
||||
required this.divider,
|
||||
required this.track,
|
||||
});
|
||||
|
||||
static const _dark = AppColors._(
|
||||
accent: Color(0xFFFF4444),
|
||||
background: Color(0xFF0A0A0A),
|
||||
surface: Color(0xFF1A1A1A),
|
||||
surfaceVariant: Color(0xFF242424),
|
||||
onSurface: Colors.white,
|
||||
onSurfaceMuted: Color(0xFF9E9E9E),
|
||||
divider: Color(0x1AFFFFFF),
|
||||
track: Color(0xFF2A2A2A),
|
||||
);
|
||||
|
||||
static const _light = AppColors._(
|
||||
accent: Color(0xFFCC0000),
|
||||
background: Color(0xFFF2F2F2),
|
||||
surface: Color(0xFFFFFFFF),
|
||||
surfaceVariant: Color(0xFFEAEAEA),
|
||||
onSurface: Color(0xFF111111),
|
||||
onSurfaceMuted: Color(0xFF6E6E6E),
|
||||
divider: Color(0x1F000000),
|
||||
track: Color(0xFFDDDDDD),
|
||||
);
|
||||
|
||||
static const redDark = _dark;
|
||||
static const redLight = _light;
|
||||
static final greenDark = _dark.copyWith(accent: const Color(0xFF00E676));
|
||||
static final greenLight = _light.copyWith(accent: const Color(0xFF00875A));
|
||||
|
||||
static AppColors forTheme(AppThemeVariant v) {
|
||||
switch (v) {
|
||||
case AppThemeVariant.redDark: return redDark;
|
||||
case AppThemeVariant.redLight: return redLight;
|
||||
case AppThemeVariant.greenDark: return greenDark;
|
||||
case AppThemeVariant.greenLight: return greenLight;
|
||||
}
|
||||
}
|
||||
|
||||
/// Palette of the current theme.
|
||||
static AppColors of(BuildContext context) =>
|
||||
Theme.of(context).extension<AppColors>() ?? _dark;
|
||||
|
||||
@override
|
||||
AppColors copyWith({
|
||||
Color? accent,
|
||||
Color? background,
|
||||
Color? surface,
|
||||
Color? surfaceVariant,
|
||||
Color? onSurface,
|
||||
Color? onSurfaceMuted,
|
||||
Color? divider,
|
||||
Color? track,
|
||||
}) =>
|
||||
AppColors._(
|
||||
accent: accent ?? this.accent,
|
||||
background: background ?? this.background,
|
||||
surface: surface ?? this.surface,
|
||||
surfaceVariant: surfaceVariant ?? this.surfaceVariant,
|
||||
onSurface: onSurface ?? this.onSurface,
|
||||
onSurfaceMuted: onSurfaceMuted ?? this.onSurfaceMuted,
|
||||
divider: divider ?? this.divider,
|
||||
track: track ?? this.track,
|
||||
);
|
||||
|
||||
@override
|
||||
AppColors lerp(AppColors? other, double t) {
|
||||
if (other == null) return this;
|
||||
return AppColors._(
|
||||
accent: Color.lerp(accent, other.accent, t)!,
|
||||
background: Color.lerp(background, other.background, t)!,
|
||||
surface: Color.lerp(surface, other.surface, t)!,
|
||||
surfaceVariant: Color.lerp(surfaceVariant, other.surfaceVariant, t)!,
|
||||
onSurface: Color.lerp(onSurface, other.onSurface, t)!,
|
||||
onSurfaceMuted: Color.lerp(onSurfaceMuted, other.onSurfaceMuted, t)!,
|
||||
divider: Color.lerp(divider, other.divider, t)!,
|
||||
track: Color.lerp(track, other.track, t)!,
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../core/providers/theme_provider.dart';
|
||||
|
||||
class AppColors {
|
||||
final Color accent;
|
||||
final Color background;
|
||||
final Color surface;
|
||||
final Color surfaceVariant;
|
||||
final Color onSurface;
|
||||
final Color onSurfaceMuted;
|
||||
|
||||
const AppColors._({
|
||||
required this.accent,
|
||||
required this.background,
|
||||
required this.surface,
|
||||
required this.surfaceVariant,
|
||||
required this.onSurface,
|
||||
required this.onSurfaceMuted,
|
||||
});
|
||||
|
||||
static const redDark = AppColors._(
|
||||
accent: Color(0xFFFF4444),
|
||||
background: Color(0xFF0A0A0A),
|
||||
surface: Color(0xFF1A1A1A),
|
||||
surfaceVariant: Color(0xFF242424),
|
||||
onSurface: Colors.white,
|
||||
onSurfaceMuted: Colors.grey,
|
||||
);
|
||||
|
||||
static const redLight = AppColors._(
|
||||
accent: Color(0xFFCC0000),
|
||||
background: Color(0xFFF2F2F2),
|
||||
surface: Color(0xFFFFFFFF),
|
||||
surfaceVariant: Color(0xFFEAEAEA),
|
||||
onSurface: Color(0xFF111111),
|
||||
onSurfaceMuted: Color(0xFF777777),
|
||||
);
|
||||
|
||||
static const greenDark = AppColors._(
|
||||
accent: Color(0xFF00E676),
|
||||
background: Color(0xFF0A0A0A),
|
||||
surface: Color(0xFF1A1A1A),
|
||||
surfaceVariant: Color(0xFF242424),
|
||||
onSurface: Colors.white,
|
||||
onSurfaceMuted: Colors.grey,
|
||||
);
|
||||
|
||||
static const greenLight = AppColors._(
|
||||
accent: Color(0xFF00875A),
|
||||
background: Color(0xFFF2F2F2),
|
||||
surface: Color(0xFFFFFFFF),
|
||||
surfaceVariant: Color(0xFFEAEAEA),
|
||||
onSurface: Color(0xFF111111),
|
||||
onSurfaceMuted: Color(0xFF777777),
|
||||
);
|
||||
|
||||
static AppColors forTheme(AppThemeVariant v) {
|
||||
switch (v) {
|
||||
case AppThemeVariant.redDark: return redDark;
|
||||
case AppThemeVariant.redLight: return redLight;
|
||||
case AppThemeVariant.greenDark: return greenDark;
|
||||
case AppThemeVariant.greenLight: return greenLight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,57 +1,55 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../core/providers/theme_provider.dart';
|
||||
import 'app_colors.dart';
|
||||
|
||||
class AppTheme {
|
||||
static ThemeData build(AppThemeVariant variant) {
|
||||
final colors = AppColors.forTheme(variant);
|
||||
final isDark = variant.isDark;
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: isDark ? Brightness.dark : Brightness.light,
|
||||
scaffoldBackgroundColor: colors.background,
|
||||
extensions: [colors],
|
||||
dividerColor: colors.divider,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: colors.accent,
|
||||
brightness: isDark ? Brightness.dark : Brightness.light,
|
||||
).copyWith(
|
||||
primary: colors.accent,
|
||||
surface: colors.surface,
|
||||
),
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: colors.surface,
|
||||
foregroundColor: colors.onSurface,
|
||||
elevation: 0,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
),
|
||||
bottomNavigationBarTheme: BottomNavigationBarThemeData(
|
||||
backgroundColor: colors.surface,
|
||||
selectedItemColor: colors.accent,
|
||||
unselectedItemColor: colors.onSurfaceMuted,
|
||||
type: BottomNavigationBarType.fixed,
|
||||
elevation: 8,
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
color: colors.surface,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
dividerTheme: DividerThemeData(
|
||||
color: colors.divider,
|
||||
thickness: 1,
|
||||
),
|
||||
floatingActionButtonTheme: FloatingActionButtonThemeData(
|
||||
backgroundColor: colors.accent,
|
||||
foregroundColor: isDark ? Colors.black : Colors.white,
|
||||
),
|
||||
textTheme: TextTheme(
|
||||
bodyMedium: TextStyle(color: colors.onSurface),
|
||||
bodySmall: TextStyle(color: colors.onSurfaceMuted),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../core/providers/theme_provider.dart';
|
||||
import 'app_colors.dart';
|
||||
|
||||
class AppTheme {
|
||||
static ThemeData build(AppThemeVariant variant) {
|
||||
final colors = AppColors.forTheme(variant);
|
||||
final isDark = variant.isDark;
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: isDark ? Brightness.dark : Brightness.light,
|
||||
scaffoldBackgroundColor: colors.background,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: colors.accent,
|
||||
brightness: isDark ? Brightness.dark : Brightness.light,
|
||||
).copyWith(
|
||||
primary: colors.accent,
|
||||
surface: colors.surface,
|
||||
),
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: colors.surface,
|
||||
foregroundColor: colors.onSurface,
|
||||
elevation: 0,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
),
|
||||
bottomNavigationBarTheme: BottomNavigationBarThemeData(
|
||||
backgroundColor: colors.surface,
|
||||
selectedItemColor: colors.accent,
|
||||
unselectedItemColor: colors.onSurfaceMuted,
|
||||
type: BottomNavigationBarType.fixed,
|
||||
elevation: 8,
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
color: colors.surface,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
dividerTheme: DividerThemeData(
|
||||
color: colors.surfaceVariant,
|
||||
thickness: 1,
|
||||
),
|
||||
floatingActionButtonTheme: FloatingActionButtonThemeData(
|
||||
backgroundColor: colors.accent,
|
||||
foregroundColor: isDark ? Colors.black : Colors.white,
|
||||
),
|
||||
textTheme: TextTheme(
|
||||
bodyMedium: TextStyle(color: colors.onSurface),
|
||||
bodySmall: TextStyle(color: colors.onSurfaceMuted),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,40 +1,38 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/providers/bt_provider.dart';
|
||||
|
||||
class BtStatusChip extends ConsumerWidget {
|
||||
const BtStatusChip({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final status = ref.watch(btProvider.select((s) => s.status));
|
||||
final name = ref.watch(btProvider.select((s) => s.deviceName));
|
||||
|
||||
final (color, icon, label) = switch (status) {
|
||||
BtStatus.connected => (Colors.green, Icons.bluetooth_connected, name ?? 'Connected'),
|
||||
BtStatus.connecting => (Colors.orange, Icons.bluetooth_searching, 'Connecting…'),
|
||||
BtStatus.reconnecting => (Colors.orange, Icons.bluetooth_searching, 'Reconnecting…'),
|
||||
BtStatus.error => (Colors.red, Icons.bluetooth_disabled, 'Error'),
|
||||
BtStatus.disconnected => (Colors.grey, Icons.bluetooth, 'Disconnected'),
|
||||
};
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 14, color: color),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/providers/bt_provider.dart';
|
||||
|
||||
class BtStatusChip extends ConsumerWidget {
|
||||
const BtStatusChip({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final bt = ref.watch(btProvider);
|
||||
|
||||
final (color, icon, label) = switch (bt.status) {
|
||||
BtStatus.connected => (Colors.green, Icons.bluetooth_connected, bt.deviceName ?? 'Connected'),
|
||||
BtStatus.connecting => (Colors.orange, Icons.bluetooth_searching, 'Connecting…'),
|
||||
BtStatus.error => (Colors.red, Icons.bluetooth_disabled, 'Error'),
|
||||
BtStatus.disconnected=> (Colors.grey, Icons.bluetooth, 'Disconnected'),
|
||||
};
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 14, color: color),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,58 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/providers/bt_provider.dart';
|
||||
import '../../core/providers/datalog_provider.dart';
|
||||
|
||||
/// Thin banner under the app bar that tells the driver when the numbers on
|
||||
/// screen are NOT live, instead of silently showing stale or default values.
|
||||
class DataStatusBanner extends ConsumerWidget {
|
||||
const DataStatusBanner({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final status = ref.watch(btProvider.select((s) => s.status));
|
||||
final data = ref.watch(btProvider.select((s) => s.dataStatus));
|
||||
final error = ref.watch(btProvider.select((s) => s.error));
|
||||
final playing = ref.watch(playbackProvider.select((p) => p.isActive));
|
||||
|
||||
final String? msg;
|
||||
Color color = const Color(0xFF8A5A00);
|
||||
if (playing) {
|
||||
msg = 'Playing back a recorded session — not live data';
|
||||
color = const Color(0xFF0D47A1);
|
||||
} else if (status == BtStatus.reconnecting) {
|
||||
msg = 'Connection interrupted — reconnecting…';
|
||||
} else if (status == BtStatus.error) {
|
||||
msg = error ?? 'Connection lost';
|
||||
color = const Color(0xFF7A0000);
|
||||
} else if (status == BtStatus.disconnected) {
|
||||
msg = 'Not connected';
|
||||
color = const Color(0xFF444444);
|
||||
} else if (status != BtStatus.connected) {
|
||||
msg = null;
|
||||
} else {
|
||||
msg = switch (data) {
|
||||
EcuDataStatus.live => null,
|
||||
EcuDataStatus.none => 'Connected — waiting for data…',
|
||||
EcuDataStatus.adapterDefaults =>
|
||||
'Adapter connected, but no data from the ECU (ignition on?)',
|
||||
EcuDataStatus.noResponse => 'Adapter is not responding',
|
||||
};
|
||||
}
|
||||
|
||||
if (msg == null) return const SizedBox.shrink();
|
||||
return Material(
|
||||
color: color,
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
child: Text(
|
||||
msg,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,101 +1,92 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../screens/dashboard/dashboard_screen.dart';
|
||||
import '../screens/sensor_list/sensor_list_screen.dart';
|
||||
import '../screens/graph/graph_screen.dart';
|
||||
import '../screens/dtc/dtc_screen.dart';
|
||||
import '../screens/datalog/datalog_list_screen.dart';
|
||||
import '../screens/datalog/datalog_playback_bar.dart';
|
||||
import '../../core/providers/datalog_provider.dart';
|
||||
import '../screens/settings/settings_screen.dart';
|
||||
import 'bt_status_chip.dart';
|
||||
import 'data_status_banner.dart';
|
||||
import 'record_fab.dart';
|
||||
|
||||
final _navIndexProvider = StateProvider<int>((ref) => 0);
|
||||
|
||||
class MainNav extends ConsumerWidget {
|
||||
const MainNav({super.key});
|
||||
|
||||
static const _screens = [
|
||||
DashboardScreen(),
|
||||
SensorListScreen(),
|
||||
GraphScreen(),
|
||||
DtcScreen(),
|
||||
];
|
||||
|
||||
static const _items = [
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.speed),
|
||||
label: 'Dashboard',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.list_alt),
|
||||
label: 'Sensors',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.show_chart),
|
||||
label: 'Graph',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.warning_amber_outlined),
|
||||
label: 'DTC',
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final index = ref.watch(_navIndexProvider);
|
||||
final playback = ref.watch(playbackProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('HV BT Dashboard'),
|
||||
actions: [
|
||||
const BtStatusChip(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.folder_open_outlined),
|
||||
tooltip: 'Saved Sessions',
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const DatalogListScreen()),
|
||||
),
|
||||
),
|
||||
// Record sits in the app bar so it never covers dashboard values.
|
||||
const RecordButton(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
tooltip: 'Settings',
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const SettingsScreen()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// SafeArea: in landscape the system navigation bar sits on a side.
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
bottom: false,
|
||||
child: Column(
|
||||
children: [
|
||||
const DataStatusBanner(),
|
||||
if (playback.isActive) DatalogPlaybackBar(state: playback),
|
||||
Expanded(
|
||||
child: IndexedStack(
|
||||
index: index,
|
||||
children: _screens,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: BottomNavigationBar(
|
||||
currentIndex: index,
|
||||
items: _items,
|
||||
onTap: (i) => ref.read(_navIndexProvider.notifier).state = i,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../screens/dashboard/dashboard_screen.dart';
|
||||
import '../screens/sensor_list/sensor_list_screen.dart';
|
||||
import '../screens/graph/graph_screen.dart';
|
||||
import '../screens/dtc/dtc_screen.dart';
|
||||
import '../screens/datalog/datalog_list_screen.dart';
|
||||
import '../screens/debug/raw_frame_debug_screen.dart';
|
||||
import '../screens/settings/settings_screen.dart';
|
||||
import 'bt_status_chip.dart';
|
||||
import 'record_fab.dart';
|
||||
|
||||
final _navIndexProvider = StateProvider<int>((ref) => 0);
|
||||
|
||||
class MainNav extends ConsumerWidget {
|
||||
const MainNav({super.key});
|
||||
|
||||
static const _screens = [
|
||||
DashboardScreen(),
|
||||
SensorListScreen(),
|
||||
GraphScreen(),
|
||||
DtcScreen(),
|
||||
];
|
||||
|
||||
static const _items = [
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.speed),
|
||||
label: 'Dashboard',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.list_alt),
|
||||
label: 'Sensors',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.show_chart),
|
||||
label: 'Graph',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.warning_amber_outlined),
|
||||
label: 'DTC',
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final index = ref.watch(_navIndexProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('HV BT Dashboard'),
|
||||
actions: [
|
||||
const BtStatusChip(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.folder_open_outlined),
|
||||
tooltip: 'Saved Sessions',
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const DatalogListScreen()),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.bug_report_outlined),
|
||||
tooltip: 'ECU Frame Debug',
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const RawFrameDebugScreen()),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
tooltip: 'Settings',
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const SettingsScreen()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: IndexedStack(
|
||||
index: index,
|
||||
children: _screens,
|
||||
),
|
||||
bottomNavigationBar: BottomNavigationBar(
|
||||
currentIndex: index,
|
||||
items: _items,
|
||||
onTap: (i) => ref.read(_navIndexProvider.notifier).state = i,
|
||||
),
|
||||
floatingActionButton: const RecordFab(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,49 +1,48 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/providers/bt_provider.dart';
|
||||
import '../../core/providers/datalog_provider.dart';
|
||||
|
||||
/// App-bar record/stop button for datalogging (shown while connected).
|
||||
class RecordButton extends ConsumerWidget {
|
||||
const RecordButton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final recording = ref.watch(recordingProvider);
|
||||
final bt = ref.watch(btProvider);
|
||||
|
||||
// Only show when BT is connected
|
||||
if (!bt.isConnected) return const SizedBox.shrink();
|
||||
|
||||
final isRecording = recording.isRecording;
|
||||
|
||||
return IconButton(
|
||||
color: isRecording ? Colors.red : null,
|
||||
onPressed: () async {
|
||||
if (isRecording) {
|
||||
await ref.read(recordingProvider.notifier).stopRecording();
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Session saved — ${recording.frameCount} frames',
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await ref.read(recordingProvider.notifier).startRecording();
|
||||
}
|
||||
},
|
||||
tooltip: isRecording ? 'Stop recording' : 'Start recording',
|
||||
icon: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: isRecording
|
||||
? const Icon(Icons.stop_circle, key: ValueKey('stop'))
|
||||
: const Icon(Icons.fiber_manual_record_outlined,
|
||||
key: ValueKey('rec')),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/providers/bt_provider.dart';
|
||||
import '../../core/providers/datalog_provider.dart';
|
||||
|
||||
class RecordFab extends ConsumerWidget {
|
||||
const RecordFab({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final recording = ref.watch(recordingProvider);
|
||||
final bt = ref.watch(btProvider);
|
||||
|
||||
// Only show when BT is connected
|
||||
if (!bt.isConnected) return const SizedBox.shrink();
|
||||
|
||||
final isRecording = recording.isRecording;
|
||||
|
||||
return FloatingActionButton(
|
||||
heroTag: 'record_fab',
|
||||
backgroundColor: isRecording ? Colors.red : Theme.of(context).colorScheme.primary,
|
||||
onPressed: () async {
|
||||
if (isRecording) {
|
||||
await ref.read(recordingProvider.notifier).stopRecording();
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Session saved — ${recording.frameCount} frames',
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await ref.read(recordingProvider.notifier).startRecording();
|
||||
}
|
||||
},
|
||||
tooltip: isRecording ? 'Stop recording' : 'Start recording',
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: isRecording
|
||||
? const Icon(Icons.stop, key: ValueKey('stop'))
|
||||
: const Icon(Icons.fiber_manual_record, key: ValueKey('rec')),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
1252
pubspec.lock
1252
pubspec.lock
File diff suppressed because it is too large
Load Diff
60
pubspec.yaml
60
pubspec.yaml
@ -1,30 +1,30 @@
|
||||
name: hvbt_dash
|
||||
description: HV BT Automotive ECU Dashboard
|
||||
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.3.0+4
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_riverpod: ^2.5.0
|
||||
flutter_bluetooth_serial: ^0.4.0
|
||||
fl_chart: ^0.68.0
|
||||
sqflite: ^2.3.0
|
||||
shared_preferences: ^2.2.0
|
||||
path_provider: ^2.1.0
|
||||
intl: ^0.19.0
|
||||
path: ^1.9.0
|
||||
permission_handler: ^11.0.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^3.0.0
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
name: hvbt_dash
|
||||
description: HV BT Automotive ECU Dashboard
|
||||
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.3.0+4
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_riverpod: ^2.5.0
|
||||
flutter_bluetooth_serial: ^0.4.0
|
||||
fl_chart: ^0.68.0
|
||||
sqflite: ^2.3.0
|
||||
shared_preferences: ^2.2.0
|
||||
path_provider: ^2.1.0
|
||||
intl: ^0.19.0
|
||||
path: ^1.9.0
|
||||
permission_handler: ^11.0.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^3.0.0
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
|
||||
@ -1,85 +1,69 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hvbt_dash/core/protocol/dtc_map.dart';
|
||||
|
||||
void main() {
|
||||
group('S300 DTC map', () {
|
||||
test('contains expected number of entries', () {
|
||||
expect(s300DtcMap.length, equals(32)); // 4 bytes × 8 bits
|
||||
});
|
||||
|
||||
// Expected codes come from the S300 "DTC map" in hvbt_protocol.pdf.
|
||||
test('ERR00 bit0 = P0130 (O2A)', () {
|
||||
expect(s300DtcMap['ERR00_bit0'], startsWith('P0130 — '));
|
||||
});
|
||||
|
||||
test('ERR01 bit5 = P0505 (IAC)', () {
|
||||
expect(s300DtcMap['ERR01_bit5'], startsWith('P0505 — '));
|
||||
});
|
||||
|
||||
test('bench codes: ERR01.6 = P0350, ERR02.3 = P1298, ERR02.4 = P2649', () {
|
||||
expect(s300DtcMap['ERR01_bit6'], startsWith('P0350 — '));
|
||||
expect(s300DtcMap['ERR02_bit3'], startsWith('P1298 — '));
|
||||
expect(s300DtcMap['ERR02_bit4'], startsWith('P2649 — '));
|
||||
});
|
||||
|
||||
test('undocumented bits show the raw ERR position', () {
|
||||
expect(s300DtcMap['ERR03_bit7'], startsWith('ERR03.7 — '));
|
||||
});
|
||||
|
||||
test('every entry has a code and a description', () {
|
||||
for (final v in [...s300DtcMap.values, ...kproDtcMap.values]) {
|
||||
expect(v.split(' — ').length, equals(2), reason: v);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('KPro DTC map', () {
|
||||
test('contains entries for 18 bytes (ERR00..ERR17)', () {
|
||||
// Every ERR byte has 8 bits = 144 entries total
|
||||
expect(kproDtcMap.length, equals(144));
|
||||
});
|
||||
|
||||
// Expected codes come from the KPro "DTC map" in hvbt_protocol.pdf.
|
||||
test('ERR00 bit1 = P1607, ERR05 bit0 = P1259, ERR11 bit6 = P0304', () {
|
||||
expect(kproDtcMap['ERR00_bit1'], startsWith('P1607 — '));
|
||||
expect(kproDtcMap['ERR05_bit0'], startsWith('P1259 — '));
|
||||
expect(kproDtcMap['ERR11_bit6'], startsWith('P0304 — '));
|
||||
});
|
||||
|
||||
test('ERR17 entries present', () {
|
||||
expect(kproDtcMap.containsKey('ERR17_bit0'), isTrue);
|
||||
expect(kproDtcMap.containsKey('ERR17_bit7'), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('decodeDtcs helper', () {
|
||||
test('returns empty list when no errors', () {
|
||||
final result = decodeDtcs([0, 0, 0, 0], s300DtcMap);
|
||||
expect(result, isEmpty);
|
||||
});
|
||||
|
||||
test('decodes ERR00 bit0 = P0130', () {
|
||||
final result = decodeDtcs([0x01, 0, 0, 0], s300DtcMap);
|
||||
expect(result, contains(s300DtcMap['ERR00_bit0']));
|
||||
});
|
||||
|
||||
test('decodes multiple bits in one byte', () {
|
||||
// ERR00 bits 0 and 1
|
||||
final result = decodeDtcs([0x03, 0, 0, 0], s300DtcMap);
|
||||
expect(result.length, equals(2));
|
||||
});
|
||||
|
||||
test('decodes bits across multiple bytes', () {
|
||||
// ERR00 bit0 + ERR01 bit0
|
||||
final result = decodeDtcs([0x01, 0x01, 0, 0], s300DtcMap);
|
||||
expect(result.length, equals(2));
|
||||
});
|
||||
|
||||
test('skips bits not in map gracefully', () {
|
||||
// Use a small dtcMap with only one entry, but set all bits
|
||||
final result = decodeDtcs([0xFF, 0, 0, 0], {'ERR00_bit0': 'P0130 — test'});
|
||||
// Only the one mapped bit should appear
|
||||
expect(result.length, equals(1));
|
||||
});
|
||||
});
|
||||
}
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hvbt_dash/core/protocol/dtc_map.dart';
|
||||
|
||||
void main() {
|
||||
group('S300 DTC map', () {
|
||||
test('contains expected number of entries', () {
|
||||
expect(s300DtcMap.length, equals(32)); // 4 bytes × 8 bits
|
||||
});
|
||||
|
||||
test('P0130 maps to correct description for ERR00 bit0', () {
|
||||
expect(s300DtcMap['ERR00_bit0'], equals('P0130 — O2 Sensor (front)'));
|
||||
});
|
||||
|
||||
test('P0505 maps for ERR00 bit2', () {
|
||||
expect(s300DtcMap['ERR00_bit2'], equals('P0505 — Idle Air Control Valve'));
|
||||
});
|
||||
|
||||
test('P1607 maps for ERR03 bit7', () {
|
||||
expect(s300DtcMap['ERR03_bit7'], equals('P1607 — PCM Internal Circuit'));
|
||||
});
|
||||
});
|
||||
|
||||
group('KPro DTC map', () {
|
||||
test('contains entries for 18 bytes (ERR00..ERR17)', () {
|
||||
// Every ERR byte has 8 bits = 144 entries total
|
||||
expect(kproDtcMap.length, equals(144));
|
||||
});
|
||||
|
||||
test('P0130 maps for ERR00 bit0', () {
|
||||
expect(kproDtcMap['ERR00_bit0'], equals('P0130 — O2 Sensor (front)'));
|
||||
});
|
||||
|
||||
test('ERR17 entries present', () {
|
||||
expect(kproDtcMap.containsKey('ERR17_bit0'), isTrue);
|
||||
expect(kproDtcMap.containsKey('ERR17_bit7'), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('decodeDtcs helper', () {
|
||||
test('returns empty list when no errors', () {
|
||||
final result = decodeDtcs([0, 0, 0, 0], s300DtcMap);
|
||||
expect(result, isEmpty);
|
||||
});
|
||||
|
||||
test('decodes ERR00 bit0 = P0130', () {
|
||||
final result = decodeDtcs([0x01, 0, 0, 0], s300DtcMap);
|
||||
expect(result, contains('P0130 — O2 Sensor (front)'));
|
||||
});
|
||||
|
||||
test('decodes multiple bits in one byte', () {
|
||||
// ERR00 bits 0 and 1
|
||||
final result = decodeDtcs([0x03, 0, 0, 0], s300DtcMap);
|
||||
expect(result.length, equals(2));
|
||||
});
|
||||
|
||||
test('decodes bits across multiple bytes', () {
|
||||
// ERR00 bit0 + ERR01 bit0
|
||||
final result = decodeDtcs([0x01, 0x01, 0, 0], s300DtcMap);
|
||||
expect(result.length, equals(2));
|
||||
});
|
||||
|
||||
test('skips bits not in map gracefully', () {
|
||||
// Use a small dtcMap with only one entry, but set all bits
|
||||
final result = decodeDtcs([0xFF, 0, 0, 0], {'ERR00_bit0': 'P0130 — test'});
|
||||
// Only the one mapped bit should appear
|
||||
expect(result.length, equals(1));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,22 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hvbt_dash/core/bluetooth/bt_poller.dart';
|
||||
|
||||
void main() {
|
||||
group('ecuTypeFromDeviceName', () {
|
||||
test('real S300 adapter names', () {
|
||||
expect(ecuTypeFromDeviceName('S300[94]-SPP'), EcuType.s300);
|
||||
expect(ecuTypeFromDeviceName('HVS300-SPP[94]'), EcuType.s300);
|
||||
});
|
||||
|
||||
test('KPro names', () {
|
||||
expect(ecuTypeFromDeviceName('HV-KPro-SPP'), EcuType.kpro);
|
||||
expect(ecuTypeFromDeviceName('K-PRO 1234'), EcuType.kpro);
|
||||
});
|
||||
|
||||
test('unknown or missing names leave the setting alone', () {
|
||||
expect(ecuTypeFromDeviceName(null), isNull);
|
||||
expect(ecuTypeFromDeviceName('HC-05'), isNull);
|
||||
expect(ecuTypeFromDeviceName('S300 / KPro'), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -1,167 +1,133 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hvbt_dash/core/protocol/kpro_parser.dart';
|
||||
import 'package:hvbt_dash/core/protocol/neg8.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.
|
||||
Uint8List _buildKProFrame({
|
||||
int rpm = 0,
|
||||
int vss = 0,
|
||||
int tps = 0,
|
||||
int map1 = 0,
|
||||
int map2 = 0,
|
||||
int ign = 0,
|
||||
int ect = 0x80, // tempXlt[0x80]=73 → 33°C
|
||||
int bat = 0,
|
||||
int sw1 = 0,
|
||||
int sw2 = 0,
|
||||
int krtrd = 0,
|
||||
}) {
|
||||
final frame = Uint8List(128);
|
||||
// Header
|
||||
frame[0] = 0x1B;
|
||||
frame[1] = 0x01;
|
||||
// RPM: bytes 2..3, little-endian
|
||||
frame[2] = rpm & 0xFF;
|
||||
frame[3] = (rpm >> 8) & 0xFF;
|
||||
// VSS: byte 4
|
||||
frame[4] = vss & 0xFF;
|
||||
// TPS: byte 5
|
||||
frame[5] = tps & 0xFF;
|
||||
// MAP1: byte 6
|
||||
frame[6] = map1 & 0xFF;
|
||||
// MAP2: bytes 33 (L), 34 (H)
|
||||
frame[33] = map2 & 0xFF;
|
||||
frame[34] = (map2 >> 8) & 0xFF;
|
||||
// IGN: byte 13
|
||||
frame[13] = ign;
|
||||
// KRtrd: byte 21
|
||||
frame[21] = krtrd;
|
||||
// SW1: byte 31, SW2: byte 32
|
||||
frame[31] = sw1;
|
||||
frame[32] = sw2;
|
||||
// ECT: byte 49
|
||||
frame[49] = ect;
|
||||
// BAT: byte 52
|
||||
frame[51] = bat; // KMGR: BAT is byte 51 (the PDF says 52)
|
||||
|
||||
// Stamp NEG8 checksum at byte 127
|
||||
int neg8 = 0;
|
||||
for (int i = 0; i < 127; i++) {
|
||||
neg8 = (neg8 - frame[i]) & 0xFF;
|
||||
}
|
||||
frame[127] = neg8;
|
||||
return frame;
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('KPro parser', () {
|
||||
test('parses RPM = raw / 4', () {
|
||||
// rpm raw = 4000 → 1000.0 revs
|
||||
final frame = _buildKProFrame(rpm: 4000);
|
||||
final state = parseKPro(frame);
|
||||
expect(state.rpm, equals(1000.0));
|
||||
});
|
||||
|
||||
test('parses VSS directly', () {
|
||||
final frame = _buildKProFrame(vss: 120);
|
||||
final state = parseKPro(frame);
|
||||
expect(state.vss, equals(120.0));
|
||||
});
|
||||
|
||||
test('parses TPS = (raw - 25) / 2.04', () {
|
||||
final frame = _buildKProFrame(tps: 163); // KMGR: 68 %
|
||||
final state = parseKPro(frame);
|
||||
expect(state.tps, closeTo(67.6, 0.1));
|
||||
});
|
||||
|
||||
test('parses MAP from MAP1 when MAP2 = 0', () {
|
||||
final frame = _buildKProFrame(map1: 100, map2: 0);
|
||||
final state = parseKPro(frame);
|
||||
expect(state.map, equals(100.0));
|
||||
});
|
||||
|
||||
test('parses MAP from MAP2 / 10 when MAP2 != 0', () {
|
||||
// map2 raw = 1000 → 100.0 kPa
|
||||
final frame = _buildKProFrame(map1: 50, map2: 1000);
|
||||
final state = parseKPro(frame);
|
||||
expect(state.map, equals(100.0));
|
||||
});
|
||||
|
||||
test('parses ECT using temp lookup table', () {
|
||||
// ect raw = 0x80 = 128 → tempXlt[128]=73 → 33°C
|
||||
final frame = _buildKProFrame(ect: 0x80);
|
||||
final state = parseKPro(frame);
|
||||
expect(state.ect, equals((tempXlt[0x80] - 40).toDouble()));
|
||||
});
|
||||
|
||||
test('parses BAT = raw / 10', () {
|
||||
// bat raw = 130 → 13.0 V
|
||||
final frame = _buildKProFrame(bat: 130);
|
||||
final state = parseKPro(frame);
|
||||
expect(state.bat, equals(13.0));
|
||||
});
|
||||
|
||||
test('MIL flag from SW2 bit3', () {
|
||||
final frame = _buildKProFrame(sw2: 0x08); // bit3
|
||||
final state = parseKPro(frame);
|
||||
expect(state.mil, isTrue);
|
||||
});
|
||||
|
||||
test('real bench frame matches KPro Manager (7267 rpm capture)', () {
|
||||
// First 64 bytes logged at 00:40:25 on 2026-10-13, while KPro Manager
|
||||
// showed: RPM 7267, VSS 0, TPS 68 %, IGN 5°, S.TRIM 0 %, BAT 11.40 V,
|
||||
// PA 95.0 kPa, INJ 0.00 ms, SCS/FLR on, VTP on, VTS off, MIL on.
|
||||
const head = [
|
||||
0x1b, 0x01, 0x8d, 0x71, 0x00, 0xa3, 0x75, 0xff, //
|
||||
0x28, 0x00, 0x4d, 0x00, 0x00, 0x8a, 0x00, 0xd2,
|
||||
0xd9, 0xaf, 0xf6, 0x97, 0x80, 0x00, 0x03, 0x8b,
|
||||
0x00, 0x00, 0x12, 0x0c, 0x07, 0x09, 0x00, 0x50,
|
||||
0x09, 0xa0, 0x04, 0x01, 0x00, 0x00, 0xff, 0x00,
|
||||
0x00, 0xbf, 0xa3, 0xbf, 0xe8, 0x10, 0xf0, 0xff,
|
||||
0x8d, 0xd8, 0xd2, 0x72, 0xe8, 0x5f, 0xfe, 0x80,
|
||||
0x04, 0x00, 0x00, 0x00, 0x65,
|
||||
];
|
||||
final frame = Uint8List(128)..setRange(0, head.length, head);
|
||||
frame[127] = calculateNeg8(frame.sublist(0, 127));
|
||||
final s = parseKPro(frame);
|
||||
expect(s.rpm, closeTo(7267, 1));
|
||||
expect(s.vss, 0);
|
||||
expect(s.tps.round(), 68);
|
||||
expect(s.ign, 5);
|
||||
expect(s.strim, 0);
|
||||
expect(s.bat, closeTo(11.4, 0.001));
|
||||
expect(s.pa, 95);
|
||||
expect(s.inj, 0);
|
||||
expect(s.map, closeTo(118.4, 0.001)); // 2.5 psi boost
|
||||
expect(s.fuelPump, isTrue); // FLR
|
||||
expect(s.fanOut, isFalse); // FANC
|
||||
expect(s.vtp, isTrue);
|
||||
expect(s.vtec, isFalse); // VTS
|
||||
expect(s.mil, isTrue);
|
||||
});
|
||||
|
||||
test('knock flag when krtrd > 0', () {
|
||||
final frame = _buildKProFrame(krtrd: 3);
|
||||
final state = parseKPro(frame);
|
||||
expect(state.knock, isTrue);
|
||||
});
|
||||
|
||||
test('errBytes has 18 entries', () {
|
||||
final frame = _buildKProFrame();
|
||||
final state = parseKPro(frame);
|
||||
expect(state.errBytes.length, equals(18));
|
||||
});
|
||||
|
||||
test('throws on wrong frame length', () {
|
||||
expect(() => parseKPro(Uint8List(64)), throwsArgumentError);
|
||||
});
|
||||
|
||||
test('throws on bad checksum', () {
|
||||
final frame = _buildKProFrame(rpm: 4000);
|
||||
frame[127] = 0x00; // corrupt
|
||||
expect(() => parseKPro(frame), throwsArgumentError);
|
||||
});
|
||||
});
|
||||
}
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hvbt_dash/core/protocol/kpro_parser.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.
|
||||
Uint8List _buildKProFrame({
|
||||
int rpm = 0,
|
||||
int vss = 0,
|
||||
int tps = 0,
|
||||
int map1 = 0,
|
||||
int map2 = 0,
|
||||
int ign = 0,
|
||||
int ect = 0x80, // tempXlt[0x80]=73 → 33°C
|
||||
int bat = 0,
|
||||
int sw1 = 0,
|
||||
int sw2 = 0,
|
||||
int krtrd = 0,
|
||||
}) {
|
||||
final frame = Uint8List(128);
|
||||
// Header
|
||||
frame[0] = 0x1B;
|
||||
frame[1] = 0x01;
|
||||
// RPM: bytes 2..3
|
||||
frame[2] = (rpm >> 8) & 0xFF;
|
||||
frame[3] = rpm & 0xFF;
|
||||
// VSS: byte 4
|
||||
frame[4] = vss & 0xFF;
|
||||
// TPS: byte 5
|
||||
frame[5] = tps & 0xFF;
|
||||
// MAP1: byte 6
|
||||
frame[6] = map1 & 0xFF;
|
||||
// MAP2: bytes 33..34
|
||||
frame[33] = (map2 >> 8) & 0xFF;
|
||||
frame[34] = map2 & 0xFF;
|
||||
// IGN: byte 13
|
||||
frame[13] = ign;
|
||||
// KRtrd: byte 21
|
||||
frame[21] = krtrd;
|
||||
// SW1: byte 31, SW2: byte 32
|
||||
frame[31] = sw1;
|
||||
frame[32] = sw2;
|
||||
// ECT: byte 49
|
||||
frame[49] = ect;
|
||||
// BAT: byte 52
|
||||
frame[52] = bat;
|
||||
|
||||
// Stamp NEG8 checksum at byte 127
|
||||
int neg8 = 0;
|
||||
for (int i = 0; i < 127; i++) {
|
||||
neg8 = (neg8 - frame[i]) & 0xFF;
|
||||
}
|
||||
frame[127] = neg8;
|
||||
return frame;
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('KPro parser', () {
|
||||
test('parses RPM = raw / 4', () {
|
||||
// rpm raw = 4000 → 1000.0 revs
|
||||
final frame = _buildKProFrame(rpm: 4000);
|
||||
final state = parseKPro(frame);
|
||||
expect(state.rpm, equals(1000.0));
|
||||
});
|
||||
|
||||
test('parses VSS directly', () {
|
||||
final frame = _buildKProFrame(vss: 120);
|
||||
final state = parseKPro(frame);
|
||||
expect(state.vss, equals(120.0));
|
||||
});
|
||||
|
||||
test('parses TPS directly', () {
|
||||
final frame = _buildKProFrame(tps: 75);
|
||||
final state = parseKPro(frame);
|
||||
expect(state.tps, equals(75.0));
|
||||
});
|
||||
|
||||
test('parses MAP from MAP1 when MAP2 = 0', () {
|
||||
final frame = _buildKProFrame(map1: 100, map2: 0);
|
||||
final state = parseKPro(frame);
|
||||
expect(state.map, equals(100.0));
|
||||
});
|
||||
|
||||
test('parses MAP from MAP2 / 10 when MAP2 != 0', () {
|
||||
// map2 raw = 1000 → 100.0 kPa
|
||||
final frame = _buildKProFrame(map1: 50, map2: 1000);
|
||||
final state = parseKPro(frame);
|
||||
expect(state.map, equals(100.0));
|
||||
});
|
||||
|
||||
test('parses ECT using temp lookup table', () {
|
||||
// ect raw = 0x80 = 128 → tempXlt[128]=73 → 33°C
|
||||
final frame = _buildKProFrame(ect: 0x80);
|
||||
final state = parseKPro(frame);
|
||||
expect(state.ect, equals((tempXlt[0x80] - 40).toDouble()));
|
||||
});
|
||||
|
||||
test('parses BAT = raw / 10', () {
|
||||
// bat raw = 130 → 13.0 V
|
||||
final frame = _buildKProFrame(bat: 130);
|
||||
final state = parseKPro(frame);
|
||||
expect(state.bat, equals(13.0));
|
||||
});
|
||||
|
||||
test('MIL flag from SW2 bit2', () {
|
||||
final frame = _buildKProFrame(sw2: 0x04); // bit2
|
||||
final state = parseKPro(frame);
|
||||
expect(state.mil, isTrue);
|
||||
});
|
||||
|
||||
test('knock flag when krtrd > 0', () {
|
||||
final frame = _buildKProFrame(krtrd: 3);
|
||||
final state = parseKPro(frame);
|
||||
expect(state.knock, isTrue);
|
||||
});
|
||||
|
||||
test('errBytes has 18 entries', () {
|
||||
final frame = _buildKProFrame();
|
||||
final state = parseKPro(frame);
|
||||
expect(state.errBytes.length, equals(18));
|
||||
});
|
||||
|
||||
test('throws on wrong frame length', () {
|
||||
expect(() => parseKPro(Uint8List(64)), throwsArgumentError);
|
||||
});
|
||||
|
||||
test('throws on bad checksum', () {
|
||||
final frame = _buildKProFrame(rpm: 4000);
|
||||
frame[127] = 0x00; // corrupt
|
||||
expect(() => parseKPro(frame), throwsArgumentError);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,38 +1,38 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hvbt_dash/core/protocol/neg8.dart';
|
||||
|
||||
void main() {
|
||||
group('NEG8 checksum', () {
|
||||
test('S300 request header [0x1B, 0x00] produces 0xE5', () {
|
||||
// NEG8 of [0x1B, 0x00] = (0 - 0x1B - 0x00) & 0xFF = 0xE5
|
||||
final buf = Uint8List.fromList([0x1B, 0x00]);
|
||||
expect(calculateNeg8(buf), equals(0xE5));
|
||||
});
|
||||
|
||||
test('KPro request header [0x1B, 0x01] produces 0xE4', () {
|
||||
final buf = Uint8List.fromList([0x1B, 0x01]);
|
||||
expect(calculateNeg8(buf), equals(0xE4));
|
||||
});
|
||||
|
||||
test('validateFrame passes for correctly checksummed frame', () {
|
||||
// Build a 4-byte frame where last byte = NEG8 of first 3
|
||||
final data = Uint8List.fromList([0x1B, 0x00, 0x00, 0xE5]);
|
||||
expect(validateFrame(data), isTrue);
|
||||
});
|
||||
|
||||
test('validateFrame fails for corrupted frame', () {
|
||||
final data = Uint8List.fromList([0x1B, 0x00, 0x00, 0xFF]);
|
||||
expect(validateFrame(data), isFalse);
|
||||
});
|
||||
|
||||
test('validateFrame returns false for empty frame', () {
|
||||
expect(validateFrame(Uint8List(0)), isFalse);
|
||||
});
|
||||
|
||||
test('single byte 0x00 validates as [0x00, 0x00]', () {
|
||||
// NEG8([0x00]) = 0x00, so frame [0x00, 0x00] should pass
|
||||
expect(validateFrame(Uint8List.fromList([0x00, 0x00])), isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hvbt_dash/core/protocol/neg8.dart';
|
||||
|
||||
void main() {
|
||||
group('NEG8 checksum', () {
|
||||
test('S300 request header [0x1B, 0x00] produces 0xE5', () {
|
||||
// NEG8 of [0x1B, 0x00] = (0 - 0x1B - 0x00) & 0xFF = 0xE5
|
||||
final buf = Uint8List.fromList([0x1B, 0x00]);
|
||||
expect(calculateNeg8(buf), equals(0xE5));
|
||||
});
|
||||
|
||||
test('KPro request header [0x1B, 0x01] produces 0xE4', () {
|
||||
final buf = Uint8List.fromList([0x1B, 0x01]);
|
||||
expect(calculateNeg8(buf), equals(0xE4));
|
||||
});
|
||||
|
||||
test('validateFrame passes for correctly checksummed frame', () {
|
||||
// Build a 4-byte frame where last byte = NEG8 of first 3
|
||||
final data = Uint8List.fromList([0x1B, 0x00, 0x00, 0xE5]);
|
||||
expect(validateFrame(data), isTrue);
|
||||
});
|
||||
|
||||
test('validateFrame fails for corrupted frame', () {
|
||||
final data = Uint8List.fromList([0x1B, 0x00, 0x00, 0xFF]);
|
||||
expect(validateFrame(data), isFalse);
|
||||
});
|
||||
|
||||
test('validateFrame returns false for empty frame', () {
|
||||
expect(validateFrame(Uint8List(0)), isFalse);
|
||||
});
|
||||
|
||||
test('single byte 0x00 validates as [0x00, 0x00]', () {
|
||||
// NEG8([0x00]) = 0x00, so frame [0x00, 0x00] should pass
|
||||
expect(validateFrame(Uint8List.fromList([0x00, 0x00])), isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,100 +0,0 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hvbt_dash/core/bluetooth/rpm_glitch_filter.dart';
|
||||
|
||||
/// KPro frame carrying [rpm] (bytes 2-3 LE, raw = rpm × 4).
|
||||
Uint8List _kpro(double rpm) {
|
||||
final f = Uint8List(128);
|
||||
f[0] = 0x1B;
|
||||
f[1] = 0x01;
|
||||
final raw = (rpm * 4).round();
|
||||
f[2] = raw & 0xFF;
|
||||
f[3] = raw >> 8;
|
||||
return f;
|
||||
}
|
||||
|
||||
void main() {
|
||||
final t0 = DateTime(2026, 10, 13);
|
||||
DateTime at(int frame) => t0.add(Duration(milliseconds: 100 * frame));
|
||||
|
||||
test('drops the KPro bench glitch frames (2300 rpm with ~246 rpm dips)', () {
|
||||
final f = RpmGlitchFilter();
|
||||
// Pattern from the bench log: normal, odd, normal, odd, odd, normal …
|
||||
final seq = <double>[2256, 245.5, 2315, 246, 246, 2319, 2315, 246, 2320];
|
||||
final shown = <double>[];
|
||||
for (var i = 0; i < seq.length; i++) {
|
||||
if (f.accept(_kpro(seq[i]), at(i))) shown.add(seq[i]);
|
||||
}
|
||||
expect(shown.every((r) => r > 2000), isTrue, reason: '$shown');
|
||||
expect(f.dropped, 4);
|
||||
});
|
||||
|
||||
test('idle glitch 1003 -> 250 is dropped too', () {
|
||||
final f = RpmGlitchFilter();
|
||||
expect(f.accept(_kpro(1003), at(0)), isTrue);
|
||||
expect(f.accept(_kpro(250), at(1)), isFalse);
|
||||
expect(f.accept(_kpro(1005), at(2)), isTrue);
|
||||
});
|
||||
|
||||
test('normal acceleration passes every frame', () {
|
||||
final f = RpmGlitchFilter();
|
||||
for (var i = 0; i < 30; i++) {
|
||||
// +300 rpm per 100 ms = 3000 rpm/s
|
||||
expect(f.accept(_kpro(1000 + 300.0 * i), at(i)), isTrue);
|
||||
}
|
||||
expect(f.dropped, 0);
|
||||
});
|
||||
|
||||
test('a burst of 5 glitch frames is still dropped', () {
|
||||
final f = RpmGlitchFilter();
|
||||
expect(f.accept(_kpro(3117), at(0)), isTrue);
|
||||
for (var i = 1; i <= 5; i++) {
|
||||
expect(f.accept(_kpro(311), at(i)), isFalse);
|
||||
}
|
||||
expect(f.accept(_kpro(3125), at(6)), isTrue);
|
||||
});
|
||||
|
||||
test('a real sudden change is accepted by majority (~0.6 s)', () {
|
||||
double? from, to;
|
||||
final f = RpmGlitchFilter(onConfirmed: (a, b) {
|
||||
from = a;
|
||||
to = b;
|
||||
});
|
||||
expect(f.accept(_kpro(3000), at(0)), isTrue);
|
||||
var i = 1;
|
||||
while (!f.accept(_kpro(800), at(i))) {
|
||||
i++;
|
||||
}
|
||||
expect(i, lessThanOrEqualTo(10));
|
||||
expect((from, to), (3000, 800));
|
||||
expect(f.accept(_kpro(790), at(i + 1)), isTrue);
|
||||
});
|
||||
|
||||
test('new RPM wins even with glitch frames mixed in (bench, after DTC '
|
||||
'clear: 7267 -> 3405 with ~311 dips)', () {
|
||||
final f = RpmGlitchFilter();
|
||||
expect(f.accept(_kpro(7267), at(0)), isTrue);
|
||||
// 3405, 3405, 311, 3405, 311, 3405, 3405, 311 … repeating
|
||||
const pattern = <double>[3405, 3405, 311];
|
||||
var shown = 7267.0;
|
||||
for (var i = 1; i <= 30; i++) {
|
||||
final r = pattern[i % 3];
|
||||
if (f.accept(_kpro(r), at(i))) shown = r;
|
||||
}
|
||||
expect(shown, 3405);
|
||||
// and the 311 dips are never shown afterwards
|
||||
for (var i = 31; i <= 60; i++) {
|
||||
final r = pattern[i % 3];
|
||||
if (f.accept(_kpro(r), at(i))) expect(r, 3405);
|
||||
}
|
||||
});
|
||||
|
||||
test('S300 RPM is read from bytes 3-4', () {
|
||||
final s = Uint8List(128)
|
||||
..[0] = 0x1B
|
||||
..[3] = 0x8A
|
||||
..[4] = 0x0C; // 3210
|
||||
expect(RpmGlitchFilter.rpmOf(s), 3210);
|
||||
});
|
||||
}
|
||||
@ -1,348 +1,264 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hvbt_dash/core/protocol/neg8.dart';
|
||||
import 'package:hvbt_dash/core/protocol/s300_parser.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.
|
||||
Uint8List _buildS300Frame({
|
||||
int rpm = 0,
|
||||
int vss = 0,
|
||||
int mapRaw = 0,
|
||||
int tps = 0,
|
||||
int inj = 0,
|
||||
int ign = 0,
|
||||
int ect = 0x80, // tempXlt[0x80]=73 → 73-40=33°C
|
||||
int iat = 0x80,
|
||||
int bat = 0,
|
||||
int sw1 = 0,
|
||||
int sw2 = 0,
|
||||
int sw3 = 0,
|
||||
int krtrd = 0,
|
||||
int gear = 0,
|
||||
Map<int, int> extra = const {},
|
||||
}) {
|
||||
final frame = Uint8List(128);
|
||||
// Header
|
||||
frame[0] = 0x1B;
|
||||
frame[1] = 0x00;
|
||||
// RPM: bytes +3..4, little-endian, raw
|
||||
frame[3] = rpm & 0xFF;
|
||||
frame[4] = (rpm >> 8) & 0xFF;
|
||||
// VSS: bytes +5..6, little-endian
|
||||
frame[5] = vss & 0xFF;
|
||||
frame[6] = (vss >> 8) & 0xFF;
|
||||
// MAP: bytes +7..8, little-endian
|
||||
frame[7] = mapRaw & 0xFF;
|
||||
frame[8] = (mapRaw >> 8) & 0xFF;
|
||||
// TPS: byte +9
|
||||
frame[9] = tps;
|
||||
// INJ: bytes +10..11, little-endian
|
||||
frame[10] = inj & 0xFF;
|
||||
frame[11] = (inj >> 8) & 0xFF;
|
||||
// IGN: byte +12
|
||||
frame[12] = ign;
|
||||
// KRtrd: byte +13
|
||||
frame[13] = krtrd;
|
||||
// SW bitmaps
|
||||
frame[0x11] = sw1;
|
||||
frame[0x12] = sw2;
|
||||
frame[0x13] = sw3;
|
||||
// Gear: decimal offset 27
|
||||
frame[27] = gear;
|
||||
// ECT: decimal offset 33
|
||||
frame[33] = ect;
|
||||
// IAT: decimal offset 34
|
||||
frame[34] = iat;
|
||||
// BAT: decimal offset 36
|
||||
frame[36] = bat;
|
||||
extra.forEach((offset, value) => frame[offset] = value);
|
||||
|
||||
// Stamp NEG8 checksum at byte 127
|
||||
int neg8 = 0;
|
||||
for (int i = 0; i < 127; i++) {
|
||||
neg8 = (neg8 - frame[i]) & 0xFF;
|
||||
}
|
||||
frame[127] = neg8;
|
||||
return frame;
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('S300 parser', () {
|
||||
test('parses RPM correctly', () {
|
||||
// RPM raw = 3000
|
||||
final frame = _buildS300Frame(rpm: 3000);
|
||||
final state = parseS300(frame);
|
||||
expect(state.rpm, equals(3000.0));
|
||||
});
|
||||
|
||||
test('parses VSS = 0 when raw < 893', () {
|
||||
final frame = _buildS300Frame(vss: 500);
|
||||
final state = parseS300(frame);
|
||||
expect(state.vss, equals(0.0));
|
||||
});
|
||||
|
||||
test('parses VSS correctly when raw >= 893', () {
|
||||
// vss = 228480 / 1000 = 228.48 km/h
|
||||
final frame = _buildS300Frame(vss: 1000);
|
||||
final state = parseS300(frame);
|
||||
expect(state.vss, closeTo(228.48, 0.01));
|
||||
});
|
||||
|
||||
test('parses MAP as raw/10 kPa', () {
|
||||
// mapRaw = 1148 -> 114.8 kPa
|
||||
final frame = _buildS300Frame(mapRaw: 1148);
|
||||
final state = parseS300(frame);
|
||||
expect(state.map, closeTo(114.8, 0.01));
|
||||
});
|
||||
|
||||
test('parses TPS = 0 when raw = 0', () {
|
||||
final frame = _buildS300Frame(tps: 0);
|
||||
final state = parseS300(frame);
|
||||
expect(state.tps, equals(0.0));
|
||||
});
|
||||
|
||||
test('parses TPS = 100 when raw = 255', () {
|
||||
final frame = _buildS300Frame(tps: 255);
|
||||
final state = parseS300(frame);
|
||||
expect(state.tps, equals(100.0));
|
||||
});
|
||||
|
||||
test('parses TPS correctly when raw >= 25', () {
|
||||
// (raw - 25) / 2.04: raw 127 -> 50 %; USB tool showed 31 % at raw ≈ 88.
|
||||
expect(parseS300(_buildS300Frame(tps: 127)).tps, closeTo(50.0, 0.1));
|
||||
expect(parseS300(_buildS300Frame(tps: 88)).tps.round(), 31);
|
||||
});
|
||||
|
||||
test('parses Gear from decimal offset 27', () {
|
||||
final frame = _buildS300Frame(gear: 5);
|
||||
final state = parseS300(frame);
|
||||
expect(state.gear, equals(5));
|
||||
});
|
||||
|
||||
test('parses ECT using temp lookup table', () {
|
||||
// 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
|
||||
final frame = _buildS300Frame(ect: 0xD2);
|
||||
final state = parseS300(frame);
|
||||
expect(state.ect, equals((tempXlt[0xD2] - 40).toDouble()));
|
||||
});
|
||||
|
||||
test('parses IAT using temp lookup table', () {
|
||||
// Confirmed via the same live capture: raw=0xCA(202) was within
|
||||
// ~1.3°F of the reference tool's 36.1°F reading.
|
||||
// iat raw = 0xCA = 202 → tempXlt[202]=43 → 43-40=3°C
|
||||
final frame = _buildS300Frame(iat: 0xCA);
|
||||
final state = parseS300(frame);
|
||||
expect(state.iat, equals((tempXlt[0xCA] - 40).toDouble()));
|
||||
});
|
||||
|
||||
test('parses BAT = raw * 26 / 270', () {
|
||||
// bat raw = 135 → 135*26/270 = 13.0 V
|
||||
final frame = _buildS300Frame(bat: 135);
|
||||
final state = parseS300(frame);
|
||||
expect(state.bat, closeTo(13.0, 0.01));
|
||||
});
|
||||
|
||||
test('IGN decode: (raw + 120) / 2', () {
|
||||
// ign raw = 164 -> (164 + 120) / 2 = 142.0
|
||||
final frame = _buildS300Frame(ign: 164);
|
||||
final state = parseS300(frame);
|
||||
expect(state.ign, equals(142.0));
|
||||
});
|
||||
|
||||
test('MIL flag set from SW2 bit5', () {
|
||||
final frame = _buildS300Frame(sw2: 0x20); // bit5 set
|
||||
final state = parseS300(frame);
|
||||
expect(state.mil, isTrue);
|
||||
});
|
||||
|
||||
test('fuel pump flag is active-low: set when SW2 bit2 is CLEAR', () {
|
||||
// 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
|
||||
// this bit read 0 for the entire session. Separately confirmed the
|
||||
// app showed "on" below ~200 rpm and "off" above it — exactly
|
||||
// inverted from a naive `!= 0` read, hence bit CLEAR = pump on.
|
||||
final frame = _buildS300Frame(sw2: 0x38); // bit2 clear, other bits set
|
||||
final state = parseS300(frame);
|
||||
expect(state.fuelPump, isTrue);
|
||||
});
|
||||
|
||||
test('fuel pump flag clear (pump off) when SW2 bit2 is set', () {
|
||||
final frame = _buildS300Frame(sw2: 0x04); // bit2 set
|
||||
final state = parseS300(frame);
|
||||
expect(state.fuelPump, isFalse);
|
||||
});
|
||||
|
||||
test('knock flag set when krtrd > 0', () {
|
||||
final frame = _buildS300Frame(krtrd: 5);
|
||||
final state = parseS300(frame);
|
||||
expect(state.knock, isTrue);
|
||||
});
|
||||
|
||||
test('knock flag clear when krtrd = 0', () {
|
||||
final frame = _buildS300Frame(krtrd: 0);
|
||||
final state = parseS300(frame);
|
||||
expect(state.knock, isFalse);
|
||||
});
|
||||
|
||||
test('throws on wrong frame length', () {
|
||||
expect(
|
||||
() => parseS300(Uint8List(64)),
|
||||
throwsArgumentError,
|
||||
);
|
||||
});
|
||||
|
||||
// ── Offsets corrected 2026-10-12 (column-count mapping) ──────────────
|
||||
test('fuel trims: bytes 29/31, 0x80 = 0 %', () {
|
||||
final s = parseS300(_buildS300Frame(extra: {29: 0x80, 31: 0x90}));
|
||||
expect(s.strim, closeTo(0.0, 0.001));
|
||||
expect(s.ltrim, closeTo(12.5, 0.001));
|
||||
});
|
||||
|
||||
test('DTC bytes are ERR00..ERR03 at 37..40', () {
|
||||
final s = parseS300(
|
||||
_buildS300Frame(extra: {37: 0x01, 38: 0x02, 39: 0x04, 40: 0x08}));
|
||||
expect(s.errBytes, equals([0x01, 0x02, 0x04, 0x08]));
|
||||
});
|
||||
|
||||
test('SW6 (byte 50) no longer produces phantom DTCs', () {
|
||||
final s = parseS300(_buildS300Frame(extra: {50: 0x60, 49: 0x00}));
|
||||
expect(s.errBytes, equals([0, 0, 0, 0]));
|
||||
});
|
||||
|
||||
test('fan output = SW5 (byte 49) bit0 FANC, not SW2 ALTC', () {
|
||||
expect(parseS300(_buildS300Frame(extra: {49: 0x01})).fanOut, isTrue);
|
||||
expect(parseS300(_buildS300Frame(sw2: 0x80)).fanOut, isFalse);
|
||||
});
|
||||
|
||||
test('ethanol from byte 41, AF raw from byte 55, PA from byte 32', () {
|
||||
final s = parseS300(_buildS300Frame(extra: {41: 85, 55: 0x9A, 32: 0x93}));
|
||||
expect(s.eth, equals(85.0));
|
||||
expect(s.afr, equals(154.0));
|
||||
expect(s.pa, closeTo(100.25, 0.01));
|
||||
});
|
||||
|
||||
test('INJ read little-endian', () {
|
||||
final s = parseS300(_buildS300Frame(inj: 3000));
|
||||
expect(s.inj, closeTo(3.0, 0.001));
|
||||
});
|
||||
|
||||
test('AIN0 read little-endian at 82', () {
|
||||
final s = parseS300(_buildS300Frame(extra: {82: 0x34, 83: 0x02}));
|
||||
expect(s.ain0, equals(0x0234.toDouble()));
|
||||
});
|
||||
|
||||
test('adapter default frame decodes to the PDF example (sanity)', () {
|
||||
final f = _fullDefaultFrame();
|
||||
expect(isS300DefaultFrame(f), isTrue);
|
||||
final s = parseS300(f);
|
||||
expect(s.map, closeTo(101.0, 0.01)); // 0x03F2
|
||||
expect(s.gear, equals(1));
|
||||
expect(s.ect, equals(20.0)); // 0xA0
|
||||
expect(s.iat, equals(20.0));
|
||||
expect(s.bat, closeTo(12.04, 0.01)); // 0x7D
|
||||
expect(s.strim, closeTo(0.0, 0.001));
|
||||
expect(s.errBytes, equals([0, 0, 0, 0]));
|
||||
});
|
||||
|
||||
test('throws on bad checksum', () {
|
||||
final frame = _buildS300Frame(rpm: 1000);
|
||||
frame[127] = 0x00; // corrupt checksum
|
||||
expect(() => parseS300(frame), throwsArgumentError);
|
||||
});
|
||||
|
||||
test('can parse bad checksum frame when validation is disabled', () {
|
||||
final frame = _buildS300Frame(rpm: 1000);
|
||||
frame[127] = 0x00; // corrupt checksum
|
||||
final state = parseS300(frame, validateChecksum: false);
|
||||
expect(state.rpm, equals(1000.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);
|
||||
});
|
||||
|
||||
test('default-frame head with real values at 32-36 is NOT a default frame',
|
||||
() {
|
||||
// Key-on/engine-off could coincide with bytes 0-16; the 32-36 span
|
||||
// (PA/ECT/IAT/?/BAT) must also match before data is discarded.
|
||||
final frame = _templateFrame();
|
||||
frame[33] = 0xD2; // real ECT
|
||||
frame[127] = calculateNeg8(frame.sublist(0, 127));
|
||||
expect(isS300DefaultFrame(frame), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Builds a 128-byte frame stamped with the S300's fixed event-frame
|
||||
/// template in bytes 0-16 and 32-36, 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.setRange(32, 37, [0x93, 0xA0, 0xA0, 0xDC, 0x7D]);
|
||||
frame[127] = calculateNeg8(frame.sublist(0, 127));
|
||||
return frame;
|
||||
}
|
||||
|
||||
/// The full PDF example row (hvbt_protocol.pdf), which the adapter sends as
|
||||
/// its default frame when it has no ECU data. Offsets by column count.
|
||||
Uint8List _fullDefaultFrame() {
|
||||
final frame = Uint8List(128);
|
||||
const bytes = <int>[
|
||||
0x1B, 0x00, 0x14, 0x00, 0x00, 0xFF, 0xFF, 0xF2, 0x03, 0x18, // 0-9
|
||||
0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0xC4, 0x71, 0x1C, 0x05, // 10-19
|
||||
0x40, 0xB4, 0x05, 0x41, 0x05, 0x66, 0x02, 0x01, 0x00, 0x80, // 20-29
|
||||
0x00, 0x80, 0x93, 0xA0, 0xA0, 0xDC, 0x7D, 0x00, 0x00, 0x00, // 30-39
|
||||
0x00, 0x00, 0x80, 0x00, 0x00, 0x8C, 0xB1, 0x00, 0x00, 0x00, // 40-49
|
||||
0x60, 0xC0, 0x7D, 0x7F, 0x08, 0x9A, 0xC4, 0x00, 0x00, 0x00, // 50-59
|
||||
];
|
||||
for (var i = 0; i < bytes.length; i++) {
|
||||
frame[i] = bytes[i];
|
||||
}
|
||||
frame[127] = calculateNeg8(frame.sublist(0, 127));
|
||||
return frame;
|
||||
}
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hvbt_dash/core/protocol/neg8.dart';
|
||||
import 'package:hvbt_dash/core/protocol/s300_parser.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.
|
||||
Uint8List _buildS300Frame({
|
||||
int rpm = 0,
|
||||
int vss = 0,
|
||||
int mapRaw = 0,
|
||||
int tps = 0,
|
||||
int inj = 0,
|
||||
int ign = 0,
|
||||
int ect = 0x80, // tempXlt[0x80]=73 → 73-40=33°C
|
||||
int iat = 0x80,
|
||||
int bat = 0,
|
||||
int sw1 = 0,
|
||||
int sw2 = 0,
|
||||
int sw3 = 0,
|
||||
int krtrd = 0,
|
||||
int gear = 0,
|
||||
}) {
|
||||
final frame = Uint8List(128);
|
||||
// Header
|
||||
frame[0] = 0x1B;
|
||||
frame[1] = 0x00;
|
||||
// RPM: bytes +3..4, little-endian, raw
|
||||
frame[3] = rpm & 0xFF;
|
||||
frame[4] = (rpm >> 8) & 0xFF;
|
||||
// VSS: bytes +5..6, little-endian
|
||||
frame[5] = vss & 0xFF;
|
||||
frame[6] = (vss >> 8) & 0xFF;
|
||||
// MAP: bytes +7..8, little-endian
|
||||
frame[7] = mapRaw & 0xFF;
|
||||
frame[8] = (mapRaw >> 8) & 0xFF;
|
||||
// TPS: byte +9
|
||||
frame[9] = tps;
|
||||
// INJ: bytes +10..11, BE
|
||||
frame[10] = (inj >> 8) & 0xFF;
|
||||
frame[11] = inj & 0xFF;
|
||||
// IGN: byte +12
|
||||
frame[12] = ign;
|
||||
// KRtrd: byte +13
|
||||
frame[13] = krtrd;
|
||||
// SW bitmaps
|
||||
frame[0x11] = sw1;
|
||||
frame[0x12] = sw2;
|
||||
frame[0x13] = sw3;
|
||||
// Gear: decimal offset 27
|
||||
frame[27] = gear;
|
||||
// ECT: decimal offset 33
|
||||
frame[33] = ect;
|
||||
// IAT: decimal offset 34
|
||||
frame[34] = iat;
|
||||
// BAT: decimal offset 36
|
||||
frame[36] = bat;
|
||||
|
||||
// Stamp NEG8 checksum at byte 127
|
||||
int neg8 = 0;
|
||||
for (int i = 0; i < 127; i++) {
|
||||
neg8 = (neg8 - frame[i]) & 0xFF;
|
||||
}
|
||||
frame[127] = neg8;
|
||||
return frame;
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('S300 parser', () {
|
||||
test('parses RPM correctly', () {
|
||||
// RPM raw = 3000
|
||||
final frame = _buildS300Frame(rpm: 3000);
|
||||
final state = parseS300(frame);
|
||||
expect(state.rpm, equals(3000.0));
|
||||
});
|
||||
|
||||
test('parses VSS = 0 when raw < 893', () {
|
||||
final frame = _buildS300Frame(vss: 500);
|
||||
final state = parseS300(frame);
|
||||
expect(state.vss, equals(0.0));
|
||||
});
|
||||
|
||||
test('parses VSS correctly when raw >= 893', () {
|
||||
// vss = 228480 / 1000 = 228.48 km/h
|
||||
final frame = _buildS300Frame(vss: 1000);
|
||||
final state = parseS300(frame);
|
||||
expect(state.vss, closeTo(228.48, 0.01));
|
||||
});
|
||||
|
||||
test('parses MAP as raw/10 kPa', () {
|
||||
// mapRaw = 1148 -> 114.8 kPa
|
||||
final frame = _buildS300Frame(mapRaw: 1148);
|
||||
final state = parseS300(frame);
|
||||
expect(state.map, closeTo(114.8, 0.01));
|
||||
});
|
||||
|
||||
test('parses TPS = 0 when raw = 0', () {
|
||||
final frame = _buildS300Frame(tps: 0);
|
||||
final state = parseS300(frame);
|
||||
expect(state.tps, equals(0.0));
|
||||
});
|
||||
|
||||
test('parses TPS = 100 when raw = 255', () {
|
||||
final frame = _buildS300Frame(tps: 255);
|
||||
final state = parseS300(frame);
|
||||
expect(state.tps, equals(100.0));
|
||||
});
|
||||
|
||||
test('parses TPS correctly when raw >= 25', () {
|
||||
// tps raw=127 -> 127*100/255 ≈ 49.8% (confirmed against live capture)
|
||||
final frame = _buildS300Frame(tps: 127);
|
||||
final state = parseS300(frame);
|
||||
expect(state.tps, closeTo(49.8, 0.1));
|
||||
});
|
||||
|
||||
test('parses Gear from decimal offset 27', () {
|
||||
final frame = _buildS300Frame(gear: 5);
|
||||
final state = parseS300(frame);
|
||||
expect(state.gear, equals(5));
|
||||
});
|
||||
|
||||
test('parses ECT using temp lookup table', () {
|
||||
// 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
|
||||
final frame = _buildS300Frame(ect: 0xD2);
|
||||
final state = parseS300(frame);
|
||||
expect(state.ect, equals((tempXlt[0xD2] - 40).toDouble()));
|
||||
});
|
||||
|
||||
test('parses IAT using temp lookup table', () {
|
||||
// Confirmed via the same live capture: raw=0xCA(202) was within
|
||||
// ~1.3°F of the reference tool's 36.1°F reading.
|
||||
// iat raw = 0xCA = 202 → tempXlt[202]=43 → 43-40=3°C
|
||||
final frame = _buildS300Frame(iat: 0xCA);
|
||||
final state = parseS300(frame);
|
||||
expect(state.iat, equals((tempXlt[0xCA] - 40).toDouble()));
|
||||
});
|
||||
|
||||
test('parses BAT = raw * 26 / 270', () {
|
||||
// bat raw = 135 → 135*26/270 = 13.0 V
|
||||
final frame = _buildS300Frame(bat: 135);
|
||||
final state = parseS300(frame);
|
||||
expect(state.bat, closeTo(13.0, 0.01));
|
||||
});
|
||||
|
||||
test('IGN decode: (raw + 120) / 2', () {
|
||||
// ign raw = 164 -> (164 + 120) / 2 = 142.0
|
||||
final frame = _buildS300Frame(ign: 164);
|
||||
final state = parseS300(frame);
|
||||
expect(state.ign, equals(142.0));
|
||||
});
|
||||
|
||||
test('MIL flag set from SW2 bit5', () {
|
||||
final frame = _buildS300Frame(sw2: 0x20); // bit5 set
|
||||
final state = parseS300(frame);
|
||||
expect(state.mil, isTrue);
|
||||
});
|
||||
|
||||
test('fuel pump flag is active-low: set when SW2 bit2 is CLEAR', () {
|
||||
// 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
|
||||
// this bit read 0 for the entire session. Separately confirmed the
|
||||
// app showed "on" below ~200 rpm and "off" above it — exactly
|
||||
// inverted from a naive `!= 0` read, hence bit CLEAR = pump on.
|
||||
final frame = _buildS300Frame(sw2: 0x38); // bit2 clear, other bits set
|
||||
final state = parseS300(frame);
|
||||
expect(state.fuelPump, isTrue);
|
||||
});
|
||||
|
||||
test('fuel pump flag clear (pump off) when SW2 bit2 is set', () {
|
||||
final frame = _buildS300Frame(sw2: 0x04); // bit2 set
|
||||
final state = parseS300(frame);
|
||||
expect(state.fuelPump, isFalse);
|
||||
});
|
||||
|
||||
test('knock flag set when krtrd > 0', () {
|
||||
final frame = _buildS300Frame(krtrd: 5);
|
||||
final state = parseS300(frame);
|
||||
expect(state.knock, isTrue);
|
||||
});
|
||||
|
||||
test('knock flag clear when krtrd = 0', () {
|
||||
final frame = _buildS300Frame(krtrd: 0);
|
||||
final state = parseS300(frame);
|
||||
expect(state.knock, isFalse);
|
||||
});
|
||||
|
||||
test('throws on wrong frame length', () {
|
||||
expect(
|
||||
() => parseS300(Uint8List(64)),
|
||||
throwsArgumentError,
|
||||
);
|
||||
});
|
||||
|
||||
test('throws on bad checksum', () {
|
||||
final frame = _buildS300Frame(rpm: 1000);
|
||||
frame[127] = 0x00; // corrupt checksum
|
||||
expect(() => parseS300(frame), throwsArgumentError);
|
||||
});
|
||||
|
||||
test('can parse bad checksum frame when validation is disabled', () {
|
||||
final frame = _buildS300Frame(rpm: 1000);
|
||||
frame[127] = 0x00; // corrupt checksum
|
||||
final state = parseS300(frame, validateChecksum: false);
|
||||
expect(state.rpm, equals(1000.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;
|
||||
}
|
||||
|
||||
@ -1,107 +0,0 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hvbt_dash/core/models/sensor_defs.dart';
|
||||
import 'package:hvbt_dash/core/models/units.dart';
|
||||
import 'package:hvbt_dash/core/protocol/neg8.dart';
|
||||
import 'package:hvbt_dash/core/protocol/s300_parser.dart';
|
||||
|
||||
/// S300 frame with header, the given bytes set, and a valid checksum.
|
||||
Uint8List _s300(Map<int, int> bytes) {
|
||||
final f = Uint8List(128);
|
||||
f[0] = 0x1B;
|
||||
bytes.forEach((i, v) => f[i] = v);
|
||||
f[127] = calculateNeg8(f.sublist(0, 127));
|
||||
return f;
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('display units', () {
|
||||
test('metric defaults are unchanged', () {
|
||||
final v = sensorView('vss', const UnitPrefs());
|
||||
expect(v.unit, 'km/h');
|
||||
expect(v.convert(100), 100);
|
||||
});
|
||||
|
||||
test('speed in mph', () {
|
||||
final v = sensorView('vss', const UnitPrefs(speed: SpeedUnit.mph));
|
||||
expect(v.unit, 'mph');
|
||||
expect(v.convert(100), closeTo(62.14, 0.01));
|
||||
});
|
||||
|
||||
test('temperatures in °F, range converted too', () {
|
||||
final v =
|
||||
sensorView('ect', const UnitPrefs(temp: TempUnit.fahrenheit));
|
||||
expect(v.unit, '°F');
|
||||
expect(v.convert(100), closeTo(212, 0.001));
|
||||
expect(v.min, closeTo(-40, 0.001));
|
||||
expect(v.max, closeTo(302, 0.001));
|
||||
});
|
||||
|
||||
test('MAP and baro in bar and psi', () {
|
||||
final bar = sensorView('map', const UnitPrefs(pressure: PressureUnit.bar));
|
||||
expect(bar.unit, 'bar');
|
||||
expect(bar.format(101.3), '1.01');
|
||||
final psi = sensorView('pa', const UnitPrefs(pressure: PressureUnit.psi));
|
||||
expect(psi.unit, 'psi');
|
||||
expect(psi.convert(100), closeTo(14.50, 0.01));
|
||||
});
|
||||
|
||||
test('unit choice does not touch unrelated sensors', () {
|
||||
const u = UnitPrefs(
|
||||
speed: SpeedUnit.mph,
|
||||
temp: TempUnit.fahrenheit,
|
||||
pressure: PressureUnit.psi,
|
||||
);
|
||||
expect(sensorView('rpm', u).convert(3000), 3000);
|
||||
expect(sensorView('bat', u).unit, 'V');
|
||||
});
|
||||
});
|
||||
|
||||
group('values checked against the USB tool (bench, 3210 rpm)', () {
|
||||
test('O2 volts = raw × 5/256 (raw 195 → 3.81 V)', () {
|
||||
expect(parseS300(_s300({16: 195})).o2, closeTo(3.81, 0.005));
|
||||
});
|
||||
|
||||
test('injector duty = INJ ms × RPM / 1200 (16.67 ms @ 3210 → 44 %)', () {
|
||||
final s = parseS300(_s300({})).copyWith(inj: 16.67, rpm: 3210);
|
||||
expect(sensorValue(s, 'duty'), closeTo(44.6, 0.1));
|
||||
});
|
||||
});
|
||||
|
||||
group('S300 flags', () {
|
||||
test('SW3 cut/launch bits (byte 19)', () {
|
||||
final s = parseS300(_s300({19: 0x20 | 0x08}));
|
||||
expect(s.ignCut, isTrue);
|
||||
expect(s.boostCut, isTrue);
|
||||
expect(s.shiftCut, isFalse);
|
||||
expect(s.launch, isFalse);
|
||||
});
|
||||
|
||||
test('SW1 brake/AC/VTP (byte 17), SW5 closed loop (byte 49)', () {
|
||||
final s = parseS300(_s300({17: 0x04 | 0x02 | 0x01, 49: 0x04}));
|
||||
expect(s.brakeSwitch, isTrue);
|
||||
expect(s.acSwitch, isTrue);
|
||||
expect(s.vtp, isTrue);
|
||||
expect(s.closedLoop, isTrue);
|
||||
});
|
||||
|
||||
test('IAT / CLT / WBO error flags come from stored DTC bits', () {
|
||||
// ERR01 bit1 = P0113 IAT, ERR00 bit5 = ECT, ERR00 bit0 = O2A
|
||||
final s = parseS300(_s300({38: 0x02, 37: 0x20 | 0x01}));
|
||||
expect(s.iatError, isTrue);
|
||||
expect(s.ectError, isTrue);
|
||||
expect(s.o2Error, isTrue);
|
||||
final clean = parseS300(_s300({}));
|
||||
expect(clean.iatError || clean.ectError || clean.o2Error, isFalse);
|
||||
});
|
||||
|
||||
test('every flag id resolves and reads a value', () {
|
||||
final s = parseS300(_s300({}));
|
||||
for (final f in flagDefs) {
|
||||
expect(flagDef(f.id).id, f.id);
|
||||
expect(f.read(s), isA<bool>());
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user